Compare commits

...

159 Commits

Author SHA1 Message Date
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
cc
b4f37451be Merge pull request #275 from hicccc77/dev
修复了修改消息时可能修改到错误消息的问题
2026-02-18 23:18:55 +08:00
cc
84ea378815 修复了修改消息时可能修改到错误消息的问题 2026-02-18 23:18:14 +08:00
cc
72d4db1f27 Merge pull request #274 from hicccc77/dev
Dev
2026-02-18 23:00:20 +08:00
cc
21ea879d97 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-02-18 22:59:31 +08:00
cc
a5baef2240 支持删除消息与修改消息内容 2026-02-18 22:59:28 +08:00
xuncha
bbecf54aba Merge pull request #273 from xunchahaha/dev
Dev
2026-02-18 14:58:09 +08:00
xuncha
5f868d193c 搜索出来的表情包也可以解析 2026-02-18 13:49:56 +08:00
xuncha
62b035ab39 增加反选功能 https://github.com/hicccc77/WeFlow/issues/266
Dev
2026-02-18 02:04:51 +08:00
xuncha
ff5ee33e08 Merge branch 'hicccc77:dev' into dev 2026-02-17 23:15:20 +08:00
cc
8e28016e5e 朋友圈图片解密的优化 2026-02-17 23:14:42 +08:00
cc
f17a18cb6d Merge pull request #271 from hicccc77/main
Dev
2026-02-17 10:29:06 +08:00
cc
999f45e5f5 Merge branch 'main' of https://github.com/hicccc77/WeFlow 2026-02-17 10:27:43 +08:00
cc
3e303fadd7 更新致谢 2026-02-17 10:27:37 +08:00
xuncha
3b7590d8ce 增加好友排除反选功能 2026-02-17 01:59:37 +08:00
xuncha
fabbada580 Merge pull request #269 from hicccc77/dev
Dev
2026-02-16 23:59:44 +08:00
xuncha
6e434d37dc Merge pull request #268 from xunchahaha/dev
修复打包
2026-02-16 23:59:23 +08:00
xuncha
904da80f81 修复打包 2026-02-16 23:58:48 +08:00
cc
2a4bd52f0a Merge pull request #267 from hicccc77/dev
Dev
2026-02-16 23:33:12 +08:00
cc
b4248d4a12 支持朋友圈图片解密;视频解密;实况渲染 2026-02-16 23:31:52 +08:00
xuncha
75b056d5ba 修复后面有.的问题 https://github.com/hicccc77/WeFlow/issues/262 2026-02-16 17:35:25 +08:00
xuncha
e87e12c939 修复后面有.的问题 2026-02-16 17:34:34 +08:00
xuncha
5cb7e3bc73 Merge pull request #263 from xunchahaha:sns
sns
2026-02-16 17:26:51 +08:00
xuncha
1930b91a5b 修复 2026-02-16 17:26:06 +08:00
xuncha
ea0dad132c sns 2026-02-16 16:28:04 +08:00
cc
5b7b94f507 Merge pull request #260 from hicccc77/dev
解决年度报告导出失败 #252;集成WechatVisualization的功能并支持词云排除 #259
2026-02-16 10:24:43 +08:00
cc
28e38f73f8 解决年度报告导出失败 #252;集成WechatVisualization的功能并支持词云排除 #259 2026-02-16 10:23:33 +08:00
cc
d43c0ef209 Merge pull request #258 from hicccc77/dev
Dev
2026-02-15 11:45:34 +08:00
cc
6394384be0 更友好的跳转日期 #256;修复聊天记录显示不完整 #254;修复聊天 tab 对话页面“向下滚动查看更新消息”失效 #253 2026-02-15 11:44:23 +08:00
cc
4f0af3d0cb 修复日期跳转器的问题 2026-02-12 21:48:56 +08:00
cc
2a6f833718 Merge pull request #248 from hicccc77/dev
修复npm问题
2026-02-11 20:15:19 +08:00
cc
c8835f4d4c 修复npm问题 2026-02-11 20:14:40 +08:00
cc
fff1a1c177 Merge pull request #247 from hicccc77/dev
Dev
2026-02-11 20:00:26 +08:00
cc
8fee96d0e1 更新 2026-02-10 13:47:31 +08:00
cc
fdb3d63006 更新 2026-02-09 17:06:20 +08:00
xuncha
071d239892 Merge pull request #240 from xunchahaha:dev
Dev
2026-02-08 23:28:14 +08:00
xuncha
94eb9abe9d 修复 2026-02-08 23:27:45 +08:00
xuncha
1031c4013e 新增weclone格式导出 2026-02-08 22:42:00 +08:00
xuncha
2b5bb34392 修复双人年度报告相关 2026-02-08 22:41:50 +08:00
cc
e28ef9b783 不够无敌炸裂的更新 2026-02-08 21:27:25 +08:00
xuncha
e3c17010c1 Merge pull request #227 from hicccc77/dev
Dev
2026-02-07 01:09:05 +08:00
xuncha
2389aaf314 Merge pull request #226 from xunchahaha/dev
fix
2026-02-07 01:08:45 +08:00
xuncha
4f1dd7a5fb fix 2026-02-07 01:08:19 +08:00
xuncha
4b203a93b6 Merge pull request #225 from hicccc77/dev
Dev
2026-02-07 00:55:43 +08:00
xuncha
f219b1a580 Merge pull request #224 from xunchahaha/main
dev
2026-02-07 00:54:49 +08:00
xuncha
004ee5bbf0 修复了导出群昵称错误的问题 2026-02-07 00:52:49 +08:00
xuncha
5640db9cbd 修复群聊分析群昵称错误的问题 2026-02-07 00:44:50 +08:00
xuncha
52b26533a2 修复了聊天打开的情况下无法拖动窗口的问题 2026-02-07 00:12:56 +08:00
xuncha
d334a214a4 群聊新增群聊分析按钮 2026-02-06 23:53:16 +08:00
xuncha
1aab8dfc4e 聊天页面新增导出按钮 2026-02-06 23:37:50 +08:00
xuncha
e56ee1ff4a 修复导出时拍一拍的问题 2026-02-06 23:24:42 +08:00
xuncha
0393e7aff7 修复拍一拍的问题 2026-02-06 23:19:12 +08:00
xuncha
c988e4accf 优化批量转写的显示效果 2026-02-06 23:11:03 +08:00
xuncha
63ac715792 优化了html导出 2026-02-06 23:09:20 +08:00
xuncha
fe0e2e6592 批量语音转文字改成右下角常驻 2026-02-06 23:09:01 +08:00
xuncha
ca1a386146 优化html导出 2026-02-06 23:01:31 +08:00
xuncha
7c9d0a39c3 Merge pull request #217 from hicccc77/dev 2026-02-06 19:22:23 +08:00
xuncha
a5777027b1 更新版本号 2026-02-06 19:21:49 +08:00
xuncha
c3e911e6fa Merge pull request #215 from xunchahaha:dev
更新版本号
2026-02-06 19:21:19 +08:00
xuncha
4d03110df2 更新版本号 2026-02-06 19:20:55 +08:00
xuncha
8cb640f565 Merge pull request #214 from hicccc77/dev
Dev
2026-02-06 19:16:22 +08:00
xuncha
494bd4f539 转账导出优化 2026-02-06 19:15:45 +08:00
xuncha
38169691cd 给箭头改成对号 2026-02-06 19:15:45 +08:00
xuncha
bd995bc736 新增转账消息的解析 2026-02-06 19:15:45 +08:00
xuncha
6e05e74d5e 会话详情wxid支持复制 2026-02-06 19:15:45 +08:00
xuncha
d3a1db4efe 从密语给批量语音转文字搬过来了 2026-02-06 19:15:45 +08:00
xuncha
a19f2a57c3 优化语音播放逻辑 2026-02-06 19:15:45 +08:00
xuncha
666a53f6ba 修复api limit/chatlab/keyword参数 2026-02-06 19:15:45 +08:00
xuncha
b156a08f0d 转账导出优化 2026-02-06 19:15:22 +08:00
xuncha
9c76aa2189 给箭头改成对号 2026-02-06 19:15:22 +08:00
xuncha
a54c95b6ac 新增转账消息的解析 2026-02-06 19:15:22 +08:00
xuncha
9cb0ada1b7 会话详情wxid支持复制 2026-02-06 19:15:22 +08:00
xuncha
54378a132f 从密语给批量语音转文字搬过来了 2026-02-06 19:15:22 +08:00
xuncha
4d1632a9b9 优化语音播放逻辑 2026-02-06 19:15:22 +08:00
xuncha
1eab835458 修复api limit/chatlab/keyword参数 2026-02-06 19:15:22 +08:00
xuncha
fcbc7fead8 Merge pull request #208 from hicccc77/dev
新增api接口 优化导出
2026-02-05 18:48:03 +08:00
xuncha
ec783e4ccc Merge pull request #209 from xunchahaha/fix-merge-conflict
Fix merge conflict
2026-02-05 18:47:46 +08:00
xuncha
b6f97b102c Merge upstream/main into dev: 解决冲突保留 API 服务功能 2026-02-05 18:45:31 +08:00
xuncha
e4ce9a3bd7 优化api接口说明 2026-02-05 18:33:29 +08:00
xuncha
64d5e721af 优化导出 2026-02-05 18:33:29 +08:00
xuncha
d7419669d6 修复数字解析错误 2026-02-05 18:33:29 +08:00
xuncha
ff2f6799c8 尝试新增api 优化导出 2026-02-05 18:33:29 +08:00
cc
2d573896f9 宇宙超级无敌帅气到爆炸的更新 2026-02-04 22:32:15 +08:00
xuncha
ab15190c44 优化图片解密 2026-02-04 21:59:11 +08:00
cc
551995df68 超级无敌帅气到爆炸起飞的更新 2026-02-04 21:59:11 +08:00
xuncha
8483babd10 优化图片解密 2026-02-04 21:57:23 +08:00
cc
79648cd9d5 超级无敌帅气到爆炸起飞的更新 2026-02-03 21:45:17 +08:00
xuncha
04d690dcf1 Merge pull request #195 from hicccc77/dev
Dev
2026-02-03 18:18:53 +08:00
xuncha
0b308803bf 3 2026-02-03 18:15:47 +08:00
xuncha
419d5aace3 33 2026-02-03 14:56:08 +08:00
xuncha
84005f2d43 Merge pull request #188 from xunchahaha/dev
修复群公告解析错误
2026-02-03 14:50:50 +08:00
xuncha
a166079084 Merge branch 'dev' into dev 2026-02-03 14:50:38 +08:00
xuncha
a70d8fe6c8 修复群公告解析错误 2026-02-03 14:39:48 +08:00
xuncha
34cd337146 11 2026-02-02 23:19:36 +08:00
xuncha
c9216aabad 视频解密优化 2026-02-02 22:59:30 +08:00
xuncha
79d6aef480 同步了密语的头像处理 2026-02-02 22:59:30 +08:00
xuncha
8134d62056 增加对xml的处理 2026-02-02 22:59:30 +08:00
cc
8664ebf6f5 feat: 宇宙超级无敌牛且帅气到爆炸的功能更新和优化 2026-02-02 22:59:30 +08:00
xuncha
7b832ac2ef 给密语的图片查看器搬过来了 2026-02-02 22:59:30 +08:00
xuncha
5934fc33ce 从密语同步了一下图片解密 2026-02-02 22:59:30 +08:00
cc
b6d10f79de feat: 超级无敌帅气的更新和修复 2026-02-02 22:59:30 +08:00
cc
f90822694f feat: 一些非常帅气的优化 2026-02-02 22:59:30 +08:00
cc
123a088a39 feat: 支持忽略更新 2026-02-02 22:59:30 +08:00
xuncha
9283594dd0 Merge pull request #176 from xunchahaha:dev
Dev
2026-02-02 22:58:09 +08:00
xuncha
638246e74d 视频解密优化 2026-02-02 22:57:40 +08:00
xuncha
f506407f67 Merge pull request #175 from xunchahaha/dev
Dev
2026-02-02 22:41:19 +08:00
xuncha
216f201327 同步了密语的头像处理 2026-02-02 22:40:39 +08:00
xuncha
a557f2ada3 增加对xml的处理 2026-02-02 22:36:22 +08:00
cc
e15e4cc3c8 feat: 宇宙超级无敌牛且帅气到爆炸的功能更新和优化 2026-02-02 22:01:22 +08:00
xuncha
2555c46b6d Merge pull request #173 from xunchahaha:dev
Dev
2026-02-02 18:22:28 +08:00
xuncha
fdfd59fbdf 给密语的图片查看器搬过来了 2026-02-02 18:20:26 +08:00
xuncha
0e1c3f9364 从密语同步了一下图片解密 2026-02-02 18:06:24 +08:00
cc
f9bb18d97f feat: 超级无敌帅气的更新和修复 2026-02-01 23:25:19 +08:00
cc
b7339b6a35 feat: 一些非常帅气的优化 2026-02-01 22:56:43 +08:00
cc
26abc30695 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-02-01 20:50:04 +08:00
cc
1f0f824b01 feat: 支持忽略更新 2026-02-01 20:50:01 +08:00
xuncha
cb37f534ac Merge pull request #163 from xunchahaha:main
Main
2026-02-01 17:04:06 +08:00
xuncha
50903b35cf 11 2026-02-01 17:03:47 +08:00
xuncha
c07ef66324 Merge pull request #162 from hicccc77/dev
Dev
2026-02-01 16:57:08 +08:00
xuncha
6bc802e77b Merge pull request #161 from xunchahaha/dev
优化html导出
2026-02-01 16:56:46 +08:00
xuncha
898c86c23f 优化html导出 2026-02-01 16:55:01 +08:00
xuncha
7612353389 Merge pull request #160 from xunchahaha:dev
Dev
2026-02-01 15:25:13 +08:00
xuncha
8b37f20b0f 群聊分析 群成员查看修复 2026-02-01 15:24:48 +08:00
cc
0054509ef2 fix: 修复了一个问题 2026-02-01 15:09:40 +08:00
cc
e0f22f58c8 feat: 一些更新 2026-02-01 15:01:50 +08:00
xuncha
6f41cb34ed Merge pull request #159 from xunchahaha:dev
Dev
2026-02-01 02:26:34 +08:00
xuncha
ddbb0c3b26 优化ui 2026-02-01 02:26:00 +08:00
xuncha
f40f885af3 同步ui 2026-02-01 01:26:43 +08:00
xuncha
5413d7e2c8 双人年度报告后端实现 2026-02-01 01:13:17 +08:00
xuncha
53f0e299e0 年度报告ui实现 2026-02-01 00:30:54 +08:00
xuncha
65365107f5 修复群昵称读取错误的问题 2026-02-01 00:07:38 +08:00
xuncha
cffeeb26ec 新增排除好友 2026-01-31 23:44:16 +08:00
100 changed files with 31560 additions and 3533 deletions

View File

@@ -54,8 +54,8 @@ jobs:
## 更新日志
修复了一些已知问题
## 加入我们的群
[点击加入 Telegram ](https://t.me/+hn3QzNc4DbA0MzNl)
## 查看更多日志/获取最新动态
[点击加入 Telegram 频道](https://t.me/weflow_cc)
EOF
gh release edit "$GITHUB_REF_NAME" --notes-file release_notes.md

5
.gitignore vendored
View File

@@ -57,3 +57,8 @@ Thumbs.db
wcdb/
*info
概述.md
chatlab-format.md
*.bak
AGENTS.md
.claude/

View File

@@ -20,8 +20,8 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
<a href="https://github.com/hicccc77/WeFlow/issues">
<img src="https://img.shields.io/github/issues/hicccc77/WeFlow?style=flat-square" alt="Issues">
</a>
<a href="https://t.me/+hn3QzNc4DbA0MzNl">
<img src="https://img.shields.io/badge/Telegram%20交流群-点击加入-0088cc?style=flat-square&logo=telegram&logoColor=0088cc&labelColor=white" alt="Telegram">
<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">
</a>
</p>
@@ -32,21 +32,29 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
> [!NOTE]
> 仅支持微信 **4.0 及以上**版本,确保你的微信版本符合要求
# 加入微信交流群
> 🎉 扫码加入微信群,与其他 WeFlow 用户一起交流问题和使用心得。
<p align="center">
<img src="mdassets/us.png" alt="WeFlow 微信交流群二维码" width="220" style="margin-right: 16px;"
</p>
## 主要功能
- 本地实时查看聊天记录
- 朋友圈图片、视频、**实况**的预览和解密
- 统计分析与群聊画像
- 年度报告与可视化概览
- 导出聊天记录为 HTML 等格式
- HTTP API 接口(供开发者集成)
## HTTP API
> [!WARNING]
> 此功能目前处于早期阶段,接口可能会有变动,请等待后续更新完善。
WeFlow 提供本地 HTTP API 服务,支持通过接口查询消息数据,可用于与其他工具集成或二次开发。
- **启用方式**:设置 → API 服务 → 启动服务
- **默认端口**5031
- **访问地址**`http://127.0.0.1:5031`
- **支持格式**:原始 JSON 或 [ChatLab](https://chatlab.fun/) 标准格式
📖 完整接口文档:[点击查看](docs/HTTP-API.md)
## 快速开始
@@ -79,6 +87,7 @@ npm run build
## 致谢
- [密语 CipherTalk](https://github.com/ILoveBingLu/miyu) 为本项目提供了基础框架
- [WeChat-Channels-Video-File-Decryption](https://github.com/Evil0ctal/WeChat-Channels-Video-File-Decryption) 提供了视频解密相关的技术参考
## 支持我们

338
docs/HTTP-API.md Normal file
View File

@@ -0,0 +1,338 @@
# WeFlow HTTP API 接口文档
WeFlow 提供 HTTP API 服务,支持通过 HTTP 接口查询消息数据,支持 [ChatLab](https://github.com/nichuanfang/chatlab-format) 标准化格式输出。
## 启用 API 服务
在设置页面 → API 服务 → 点击「启动服务」按钮。
默认端口:`5031`
## 基础地址
```
http://127.0.0.1:5031
```
---
## 接口列表
### 1. 健康检查
检查 API 服务是否正常运行。
**请求**
```
GET /health
```
**响应**
```json
{
"status": "ok"
}
```
---
### 2. 获取消息列表
获取指定会话的消息,支持 ChatLab 格式输出。
**请求**
```
GET /api/v1/messages
```
**参数**
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `talker` | string | ✅ | 会话 IDwxid 或群 ID |
| `limit` | number | ❌ | 返回数量限制,默认 100范围 `1~10000` |
| `offset` | number | ❌ | 偏移量,用于分页,默认 0 |
| `start` | string | ❌ | 开始时间,格式 YYYYMMDD |
| `end` | string | ❌ | 结束时间,格式 YYYYMMDD |
| `keyword` | string | ❌ | 关键词过滤(基于消息显示文本) |
| `chatlab` | string | ❌ | 设为 `1` 则输出 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`
**示例请求**
```bash
# 获取消息(原始格式)
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=50
# 获取消息ChatLab 格式)
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&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
```
**响应(原始格式)**
```json
{
"success": true,
"talker": "wxid_xxx",
"count": 50,
"hasMore": true,
"media": {
"enabled": true,
"exportPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media",
"count": 12
},
"messages": [
{
"localId": 123,
"localType": 3,
"content": "[图片]",
"createTime": 1738713600000,
"senderUsername": "wxid_sender",
"mediaType": "image",
"mediaFileName": "image_123.jpg",
"mediaPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\wxid_xxx\\images\\image_123.jpg"
}
]
}
```
**响应ChatLab 格式)**
```json
{
"chatlab": {
"version": "0.0.2",
"exportedAt": 1738713600000,
"generator": "WeFlow",
"description": "Exported from WeFlow"
},
"meta": {
"name": "会话名称",
"platform": "wechat",
"type": "private",
"ownerId": "wxid_me"
},
"members": [
{
"platformId": "wxid_xxx",
"accountName": "用户名",
"groupNickname": "群昵称"
}
],
"messages": [
{
"sender": "wxid_xxx",
"accountName": "用户名",
"timestamp": 1738713600000,
"type": 0,
"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
}
}
```
---
### 3. 获取会话列表
获取所有会话列表。
**请求**
```
GET /api/v1/sessions
```
**参数**
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `keyword` | string | ❌ | 搜索关键词,匹配会话名或 ID |
| `limit` | number | ❌ | 返回数量限制,默认 100 |
**示例请求**
```bash
GET http://127.0.0.1:5031/api/v1/sessions
GET http://127.0.0.1:5031/api/v1/sessions?keyword=工作群&limit=20
```
**响应**
```json
{
"success": true,
"count": 50,
"total": 100,
"sessions": [
{
"username": "wxid_xxx",
"displayName": "用户名",
"lastMessage": "最后一条消息",
"lastTime": 1738713600000,
"unreadCount": 0
}
]
}
```
---
### 4. 获取联系人列表
获取所有联系人信息。
**请求**
```
GET /api/v1/contacts
```
**参数**
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `keyword` | string | ❌ | 搜索关键词 |
| `limit` | number | ❌ | 返回数量限制,默认 100 |
**示例请求**
```bash
GET http://127.0.0.1:5031/api/v1/contacts
GET http://127.0.0.1:5031/api/v1/contacts?keyword=张三
```
**响应**
```json
{
"success": true,
"count": 50,
"contacts": [
{
"userName": "wxid_xxx",
"alias": "微信号",
"nickName": "昵称",
"remark": "备注名"
}
]
}
```
---
## ChatLab 格式说明
ChatLab 是一种标准化的聊天记录交换格式,版本 0.0.2。
### 消息类型映射
| ChatLab Type | 值 | 说明 |
|--------------|-----|------|
| TEXT | 0 | 文本消息 |
| IMAGE | 1 | 图片 |
| VOICE | 2 | 语音 |
| VIDEO | 3 | 视频 |
| FILE | 4 | 文件 |
| EMOJI | 5 | 表情 |
| LINK | 7 | 链接 |
| LOCATION | 8 | 位置 |
| RED_PACKET | 20 | 红包 |
| TRANSFER | 21 | 转账 |
| CALL | 23 | 通话 |
| SYSTEM | 80 | 系统消息 |
| RECALL | 81 | 撤回消息 |
| OTHER | 99 | 其他 |
---
## 使用示例
### PowerShell
```powershell
# 健康检查
Invoke-RestMethod http://127.0.0.1:5031/health
# 获取会话列表
Invoke-RestMethod http://127.0.0.1:5031/api/v1/sessions
# 获取消息
Invoke-RestMethod "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=10"
# 获取 ChatLab 格式
Invoke-RestMethod "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1" | ConvertTo-Json -Depth 10
```
### cURL
```bash
# 健康检查
curl http://127.0.0.1:5031/health
# 获取会话列表
curl http://127.0.0.1:5031/api/v1/sessions
# 获取消息ChatLab 格式)
curl "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1"
```
### Python
```python
import requests
BASE_URL = "http://127.0.0.1:5031"
# 获取会话列表
sessions = requests.get(f"{BASE_URL}/api/v1/sessions").json()
print(sessions)
# 获取消息
messages = requests.get(f"{BASE_URL}/api/v1/messages", params={
"talker": "wxid_xxx",
"limit": 100,
"chatlab": 1
}).json()
print(messages)
```
### JavaScript / Node.js
```javascript
const BASE_URL = "http://127.0.0.1:5031";
// 获取会话列表
const sessions = await fetch(`${BASE_URL}/api/v1/sessions`).then(r => r.json());
console.log(sessions);
// 获取消息ChatLab 格式)
const messages = await fetch(`${BASE_URL}/api/v1/messages?talker=wxid_xxx&chatlab=1`)
.then(r => r.json());
console.log(messages);
```
---
## 注意事项
1. API 仅监听本地地址 `127.0.0.1`,不对外网开放
2. 需要先连接数据库才能查询数据
3. 时间参数格式为 `YYYYMMDD`(如 20260205
4. 支持 CORS可从浏览器前端直接调用

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

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

View File

@@ -18,9 +18,12 @@ import { exportService, ExportOptions, ExportProgress } from './services/exportS
import { KeyService } from './services/keyService'
import { voiceTranscribeService } from './services/voiceTranscribeService'
import { videoService } from './services/videoService'
import { snsService } from './services/snsService'
import { snsService, isVideoUrl } from './services/snsService'
import { contactExportService } from './services/contactExportService'
import { windowsHelloService } from './services/windowsHelloService'
import { llamaService } from './services/llamaService'
import { registerNotificationHandlers, showNotification } from './windows/notificationWindow'
import { httpService } from './services/httpService'
// 配置自动更新
@@ -101,7 +104,8 @@ function createWindow(options: { autoShow?: boolean } = {}) {
webPreferences: {
preload: join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false
nodeIntegration: false,
webSecurity: false // Allow loading local files (video playback)
},
titleBarStyle: 'hidden',
titleBarOverlay: {
@@ -139,6 +143,14 @@ function createWindow(options: { autoShow?: boolean } = {}) {
win.loadFile(join(__dirname, '../dist/index.html'))
}
// Handle notification click navigation
ipcMain.on('notification-clicked', (_, sessionId) => {
if (win.isMinimized()) win.restore()
win.show()
win.focus()
win.webContents.send('navigate-to-session', sessionId)
})
// 拦截请求,修改 Referer 和 User-Agent 以通过微信 CDN 鉴权
session.defaultSession.webRequest.onBeforeSendHeaders(
{
@@ -161,6 +173,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
}
@@ -366,6 +392,64 @@ function createVideoPlayerWindow(videoPath: string, videoWidth?: number, videoHe
hash: `/video-player-window?${videoParam}`
})
}
}
/**
* 创建独立的图片查看窗口
*/
function createImageViewerWindow(imagePath: string) {
const isDev = !!process.env.VITE_DEV_SERVER_URL
const iconPath = isDev
? join(__dirname, '../public/icon.ico')
: join(process.resourcesPath, 'icon.ico')
const win = new BrowserWindow({
width: 900,
height: 700,
minWidth: 400,
minHeight: 300,
icon: iconPath,
webPreferences: {
preload: join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
webSecurity: false // 允许加载本地文件
},
titleBarStyle: 'hidden',
titleBarOverlay: {
color: '#00000000',
symbolColor: '#ffffff',
height: 40
},
show: false,
backgroundColor: '#000000',
autoHideMenuBar: true
})
win.once('ready-to-show', () => {
win.show()
})
const imageParam = `imagePath=${encodeURIComponent(imagePath)}`
if (process.env.VITE_DEV_SERVER_URL) {
win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/image-viewer-window?${imageParam}`)
win.webContents.on('before-input-event', (event, input) => {
if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) {
if (win.webContents.isDevToolsOpened()) {
win.webContents.closeDevTools()
} else {
win.webContents.openDevTools()
}
event.preventDefault()
}
})
} else {
win.loadFile(join(__dirname, '../dist/index.html'), {
hash: `/image-viewer-window?${imageParam}`
})
}
return win
}
@@ -439,6 +523,7 @@ function showMainWindow() {
// 注册 IPC 处理器
function registerIpcHandlers() {
registerNotificationHandlers()
// 配置相关
ipcMain.handle('config:get', async (_, key: string) => {
return configService?.get(key as any)
@@ -552,6 +637,11 @@ function registerIpcHandlers() {
}
})
ipcMain.handle('app:ignoreUpdate', async (_, version: string) => {
configService?.set('ignoredUpdateVersion', version)
return { success: true }
})
// 窗口控制
ipcMain.on('window:minimize', (event) => {
BrowserWindow.fromWebContents(event.sender)?.minimize()
@@ -719,14 +809,88 @@ function registerIpcHandlers() {
return chatService.getLatestMessages(sessionId, limit)
})
ipcMain.handle('chat:getNewMessages', async (_, sessionId: string, minTime: number, limit?: number) => {
return chatService.getNewMessages(sessionId, minTime, limit)
})
ipcMain.handle('chat:updateMessage', async (_, sessionId: string, localId: number, createTime: number, newContent: string) => {
return chatService.updateMessage(sessionId, localId, createTime, newContent)
})
ipcMain.handle('chat:deleteMessage', async (_, sessionId: string, localId: number, createTime: number, dbPathHint?: string) => {
return chatService.deleteMessage(sessionId, localId, createTime, dbPathHint)
})
ipcMain.handle('chat:getContact', async (_, username: string) => {
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) => {
return await chatService.getContactAvatar(username)
})
ipcMain.handle('chat:resolveTransferDisplayNames', async (_, chatroomId: string, payerUsername: string, receiverUsername: string) => {
return await chatService.resolveTransferDisplayNames(chatroomId, payerUsername, receiverUsername)
})
ipcMain.handle('chat:getContacts', async () => {
return await chatService.getContacts()
})
@@ -759,6 +923,12 @@ function registerIpcHandlers() {
ipcMain.handle('chat:getVoiceData', async (_, sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => {
return chatService.getVoiceData(sessionId, msgId, createTime, serverId)
})
ipcMain.handle('chat:getAllVoiceMessages', async (_, sessionId: string) => {
return chatService.getAllVoiceMessages(sessionId)
})
ipcMain.handle('chat:getMessageDates', async (_, sessionId: string) => {
return chatService.getMessageDates(sessionId)
})
ipcMain.handle('chat:resolveVoiceCache', async (_, sessionId: string, msgId: string) => {
return chatService.resolveVoiceCache(sessionId, msgId)
})
@@ -785,8 +955,66 @@ function registerIpcHandlers() {
return snsService.debugResource(url)
})
ipcMain.handle('sns:proxyImage', async (_, url: string) => {
return snsService.proxyImage(url)
ipcMain.handle('sns:proxyImage', async (_, payload: string | { url: string; key?: string | number }) => {
const url = typeof payload === 'string' ? payload : payload?.url
const key = typeof payload === 'string' ? undefined : payload?.key
return snsService.proxyImage(url, key)
})
ipcMain.handle('sns:downloadImage', async (_, payload: { url: string; key?: string | number }) => {
try {
const { url, key } = payload
const result = await snsService.downloadImage(url, key)
if (!result.success || !result.data) {
return { success: false, error: result.error || '下载图片失败' }
}
const { dialog } = await import('electron')
const ext = (result.contentType || '').split('/')[1] || 'jpg'
const defaultPath = `SNS_${Date.now()}.${ext}`
const filters = isVideoUrl(url)
? [{ name: 'Videos', extensions: ['mp4', 'mov', 'avi', 'mkv'] }]
: [{ name: 'Images', extensions: [ext, 'jpg', 'jpeg', 'png', 'webp', 'gif'] }]
const { filePath, canceled } = await dialog.showSaveDialog({
defaultPath,
filters
})
if (canceled || !filePath) {
return { success: false, error: '用户已取消' }
}
const fs = await import('fs/promises')
await fs.writeFile(filePath, result.data)
return { success: true, filePath }
} catch (e) {
return { success: false, error: String(e) }
}
})
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] }
})
// 私聊克隆
@@ -815,6 +1043,10 @@ function registerIpcHandlers() {
})
// 导出相关
ipcMain.handle('export:getExportStats', async (_, sessionIds: string[], options: any) => {
return exportService.getExportStats(sessionIds, options)
})
ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions) => {
const onProgress = (progress: ExportProgress) => {
if (!event.sender.isDestroyed()) {
@@ -837,14 +1069,26 @@ function registerIpcHandlers() {
return analyticsService.getOverallStatistics(force)
})
ipcMain.handle('analytics:getContactRankings', async (_, limit?: number) => {
return analyticsService.getContactRankings(limit)
ipcMain.handle('analytics:getContactRankings', async (_, limit?: number, beginTimestamp?: number, endTimestamp?: number) => {
return analyticsService.getContactRankings(limit, beginTimestamp, endTimestamp)
})
ipcMain.handle('analytics:getTimeDistribution', async () => {
return analyticsService.getTimeDistribution()
})
ipcMain.handle('analytics:getExcludedUsernames', async () => {
return analyticsService.getExcludedUsernames()
})
ipcMain.handle('analytics:setExcludedUsernames', async (_, usernames: string[]) => {
return analyticsService.setExcludedUsernames(usernames)
})
ipcMain.handle('analytics:getExcludeCandidates', async () => {
return analyticsService.getExcludeCandidates()
})
// 缓存管理
ipcMain.handle('cache:clearAnalytics', async () => {
return analyticsService.clearCache()
@@ -914,12 +1158,24 @@ function registerIpcHandlers() {
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 () => {
createAgreementWindow()
return true
})
// 打开图片查看窗口
ipcMain.handle('window:openImageViewerWindow', (_, imagePath: string) => {
createImageViewerWindow(imagePath)
})
// 完成引导,关闭引导窗口并显示主窗口
ipcMain.handle('window:completeOnboarding', async () => {
try {
@@ -1017,6 +1273,74 @@ function registerIpcHandlers() {
})
})
ipcMain.handle('dualReport:generateReport', async (_, payload: { friendUsername: string; year: number }) => {
const cfg = configService || new ConfigService()
configService = cfg
const dbPath = cfg.get('dbPath')
const decryptKey = cfg.get('decryptKey')
const wxid = cfg.get('myWxid')
const logEnabled = cfg.get('logEnabled')
const friendUsername = payload?.friendUsername
const year = payload?.year ?? 0
const excludeWords = cfg.get('wordCloudExcludeWords') || []
if (!friendUsername) {
return { success: false, error: '缺少好友用户名' }
}
const resourcesPath = app.isPackaged
? join(process.resourcesPath, 'resources')
: join(app.getAppPath(), 'resources')
const userDataPath = app.getPath('userData')
const workerPath = join(__dirname, 'dualReportWorker.js')
return await new Promise((resolve) => {
const worker = new Worker(workerPath, {
workerData: { year, friendUsername, dbPath, decryptKey, myWxid: wxid, resourcesPath, userDataPath, logEnabled, excludeWords }
})
const cleanup = () => {
worker.removeAllListeners()
}
worker.on('message', (msg: any) => {
if (msg && msg.type === 'dualReport:progress') {
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) {
win.webContents.send('dualReport:progress', msg.data)
}
}
return
}
if (msg && (msg.type === 'dualReport:result' || msg.type === 'done')) {
cleanup()
void worker.terminate()
resolve(msg.data ?? msg.result)
return
}
if (msg && (msg.type === 'dualReport:error' || msg.type === 'error')) {
cleanup()
void worker.terminate()
resolve({ success: false, error: msg.error || '双人报告生成失败' })
}
})
worker.on('error', (err) => {
cleanup()
resolve({ success: false, error: String(err) })
})
worker.on('exit', (code) => {
if (code !== 0) {
cleanup()
resolve({ success: false, error: `双人报告线程异常退出: ${code}` })
}
})
})
})
ipcMain.handle('annualReport:exportImages', async (_, payload: { baseDir: string; folderName: string; images: Array<{ name: string; dataUrl: string }> }) => {
try {
const { baseDir, folderName, images } = payload
@@ -1062,6 +1386,24 @@ function registerIpcHandlers() {
})
})
// HTTP API 服务
ipcMain.handle('http:start', async (_, port?: number) => {
return httpService.start(port || 5031)
})
ipcMain.handle('http:stop', async () => {
await httpService.stop()
return { success: true }
})
ipcMain.handle('http:status', async () => {
return {
running: httpService.isRunning(),
port: httpService.getPort(),
mediaExportPath: httpService.getDefaultMediaExportPath()
}
})
}
// 主窗口引用
@@ -1080,7 +1422,16 @@ function checkForUpdatesOnStartup() {
if (result && result.updateInfo) {
const currentVersion = app.getVersion()
const latestVersion = result.updateInfo.version
// 检查是否有新版本
if (latestVersion !== currentVersion && mainWindow) {
// 检查该版本是否被用户忽略
const ignoredVersion = configService?.get('ignoredUpdateVersion')
if (ignoredVersion === latestVersion) {
return
}
// 通知渲染进程有新版本
mainWindow.webContents.send('app:updateAvailable', {
version: latestVersion,

View File

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

View File

@@ -9,6 +9,19 @@ contextBridge.exposeInMainWorld('electronAPI', {
clear: () => ipcRenderer.invoke('config:clear')
},
// 通知
notification: {
show: (data: any) => ipcRenderer.invoke('notification:show', data),
close: () => ipcRenderer.invoke('notification:close'),
click: (sessionId: string) => ipcRenderer.send('notification-clicked', sessionId),
ready: () => ipcRenderer.send('notification:ready'),
resize: (width: number, height: number) => ipcRenderer.send('notification:resize', { width, height }),
onShow: (callback: (event: any, data: any) => void) => {
ipcRenderer.on('notification:show', callback)
return () => ipcRenderer.removeAllListeners('notification:show')
}
},
// 认证
auth: {
hello: (message?: string) => ipcRenderer.invoke('auth:hello', message)
@@ -34,6 +47,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
getVersion: () => ipcRenderer.invoke('app:getVersion'),
checkForUpdates: () => ipcRenderer.invoke('app:checkForUpdates'),
downloadAndInstall: () => ipcRenderer.invoke('app:downloadAndInstall'),
ignoreUpdate: (version: string) => ipcRenderer.invoke('app:ignoreUpdate', version),
onDownloadProgress: (callback: (progress: any) => void) => {
ipcRenderer.on('app:downloadProgress', (_, progress) => callback(progress))
return () => ipcRenderer.removeAllListeners('app:downloadProgress')
@@ -47,7 +61,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
// 日志
log: {
getPath: () => ipcRenderer.invoke('log:getPath'),
read: () => ipcRenderer.invoke('log:read')
read: () => ipcRenderer.invoke('log:read'),
debug: (data: any) => ipcRenderer.send('log:debug', data)
},
// 窗口控制
@@ -63,6 +78,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke('window:openVideoPlayerWindow', videoPath, videoWidth, videoHeight),
resizeToFitVideo: (videoWidth: number, videoHeight: number) =>
ipcRenderer.invoke('window:resizeToFitVideo', videoWidth, videoHeight),
openImageViewerWindow: (imagePath: string) =>
ipcRenderer.invoke('window:openImageViewerWindow', imagePath),
openChatHistoryWindow: (sessionId: string, messageId: number) =>
ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId)
},
@@ -110,8 +127,16 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit, startTime, endTime, ascending),
getLatestMessages: (sessionId: string, limit?: number) =>
ipcRenderer.invoke('chat:getLatestMessages', sessionId, limit),
getNewMessages: (sessionId: string, minTime: number, limit?: number) =>
ipcRenderer.invoke('chat:getNewMessages', sessionId, minTime, limit),
getContact: (username: string) => ipcRenderer.invoke('chat:getContact', username),
getContactAvatar: (username: string) => ipcRenderer.invoke('chat:getContactAvatar', username),
updateMessage: (sessionId: string, localId: number, createTime: number, newContent: string) =>
ipcRenderer.invoke('chat:updateMessage', sessionId, localId, createTime, newContent),
deleteMessage: (sessionId: string, localId: number, createTime: number, dbPathHint?: string) =>
ipcRenderer.invoke('chat:deleteMessage', sessionId, localId, createTime, dbPathHint),
resolveTransferDisplayNames: (chatroomId: string, payerUsername: string, receiverUsername: string) =>
ipcRenderer.invoke('chat:resolveTransferDisplayNames', chatroomId, payerUsername, receiverUsername),
getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'),
downloadEmoji: (cdnUrl: string, md5?: string) => ipcRenderer.invoke('chat:downloadEmoji', cdnUrl, md5),
getCachedMessages: (sessionId: string) => ipcRenderer.invoke('chat:getCachedMessages', sessionId),
@@ -120,6 +145,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId),
getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) =>
ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime, serverId),
getAllVoiceMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllVoiceMessages', sessionId),
getMessageDates: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDates', sessionId),
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),
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => {
@@ -131,7 +158,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke('chat:execQuery', kind, path, sql),
getContacts: () => ipcRenderer.invoke('chat:getContacts'),
getMessage: (sessionId: string, localId: number) =>
ipcRenderer.invoke('chat:getMessage', sessionId, localId)
ipcRenderer.invoke('chat:getMessage', sessionId, localId),
onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => {
ipcRenderer.on('wcdb-change', callback)
return () => ipcRenderer.removeListener('wcdb-change', callback)
}
},
@@ -162,9 +193,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
// 数据分析
analytics: {
getOverallStatistics: () => ipcRenderer.invoke('analytics:getOverallStatistics'),
getContactRankings: (limit?: number) => ipcRenderer.invoke('analytics:getContactRankings', limit),
getOverallStatistics: (force?: boolean) => ipcRenderer.invoke('analytics:getOverallStatistics', force),
getContactRankings: (limit?: number, beginTimestamp?: number, endTimestamp?: number) =>
ipcRenderer.invoke('analytics:getContactRankings', limit, beginTimestamp, endTimestamp),
getTimeDistribution: () => ipcRenderer.invoke('analytics:getTimeDistribution'),
getExcludedUsernames: () => ipcRenderer.invoke('analytics:getExcludedUsernames'),
setExcludedUsernames: (usernames: string[]) => ipcRenderer.invoke('analytics:setExcludedUsernames', usernames),
getExcludeCandidates: () => ipcRenderer.invoke('analytics:getExcludeCandidates'),
onProgress: (callback: (payload: { status: string; progress: number }) => void) => {
ipcRenderer.on('analytics:progress', (_, payload) => callback(payload))
return () => ipcRenderer.removeAllListeners('analytics:progress')
@@ -185,7 +220,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
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),
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)
},
// 年度报告
@@ -199,9 +236,19 @@ contextBridge.exposeInMainWorld('electronAPI', {
return () => ipcRenderer.removeAllListeners('annualReport:progress')
}
},
dualReport: {
generateReport: (payload: { friendUsername: string; year: number }) =>
ipcRenderer.invoke('dualReport:generateReport', payload),
onProgress: (callback: (payload: { status: string; progress: number }) => void) => {
ipcRenderer.on('dualReport:progress', (_, payload) => callback(payload))
return () => ipcRenderer.removeAllListeners('dualReport:progress')
}
},
// 导出
export: {
getExportStats: (sessionIds: string[], options: any) =>
ipcRenderer.invoke('export:getExportStats', sessionIds, options),
exportSessions: (sessionIds: string[], outputDir: string, options: any) =>
ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options),
exportSession: (sessionId: string, outputPath: string, options: any) =>
@@ -230,6 +277,41 @@ contextBridge.exposeInMainWorld('electronAPI', {
getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) =>
ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime),
debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url),
proxyImage: (url: string) => ipcRenderer.invoke('sns:proxyImage', url)
proxyImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:proxyImage', 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) => {
ipcRenderer.on('sns:exportProgress', (_, payload) => callback(payload))
return () => ipcRenderer.removeAllListeners('sns:exportProgress')
},
selectExportDir: () => ipcRenderer.invoke('sns:selectExportDir')
},
// Llama AI
llama: {
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) => {
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: {
start: (port?: number) => ipcRenderer.invoke('http:start', port),
stop: () => ipcRenderer.invoke('http:stop'),
status: () => ipcRenderer.invoke('http:status')
}
})

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,5 @@
import { join } from 'path'
import { app } from 'electron'
import Store from 'electron-store'
interface ConfigSchema {
@@ -12,6 +14,7 @@ interface ConfigSchema {
// 缓存相关
cachePath: string
lastOpenedDb: string
lastSession: string
@@ -27,17 +30,40 @@ interface ConfigSchema {
autoTranscribeVoice: boolean
transcribeLanguages: string[]
exportDefaultConcurrency: number
analyticsExcludedUsernames: string[]
// 安全相关
authEnabled: boolean
authPassword: string // SHA-256 hash
authUseHello: boolean
// 更新相关
ignoredUpdateVersion: string
// 通知
notificationEnabled: boolean
notificationPosition: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'
notificationFilterMode: 'all' | 'whitelist' | 'blacklist'
notificationFilterList: string[]
wordCloudExcludeWords: string[]
}
export class ConfigService {
private store: Store<ConfigSchema>
private static instance: ConfigService
private store!: Store<ConfigSchema>
static getInstance(): ConfigService {
if (!ConfigService.instance) {
ConfigService.instance = new ConfigService()
}
return ConfigService.instance
}
constructor() {
if (ConfigService.instance) {
return ConfigService.instance
}
ConfigService.instance = this
this.store = new Store<ConfigSchema>({
name: 'WeFlow-config',
defaults: {
@@ -49,6 +75,7 @@ export class ConfigService {
imageAesKey: '',
wxidConfigs: {},
cachePath: '',
lastOpenedDb: '',
lastSession: '',
theme: 'system',
@@ -62,10 +89,18 @@ export class ConfigService {
autoTranscribeVoice: false,
transcribeLanguages: ['zh'],
exportDefaultConcurrency: 2,
analyticsExcludedUsernames: [],
authEnabled: false,
authPassword: '',
authUseHello: false
authUseHello: false,
ignoredUpdateVersion: '',
notificationEnabled: true,
notificationPosition: 'top-right',
notificationFilterMode: 'all',
notificationFilterList: [],
wordCloudExcludeWords: []
}
})
}
@@ -78,6 +113,14 @@ export class ConfigService {
this.store.set(key, value)
}
getCacheBasePath(): string {
const configured = this.get('cachePath')
if (configured && configured.trim().length > 0) {
return configured
}
return join(app.getPath('documents'), 'WeFlow')
}
getAll(): ConfigSchema {
return this.store.store
}

View File

@@ -1,6 +1,7 @@
import { join, dirname } from 'path'
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
import { app } from 'electron'
import { ConfigService } from './config'
export interface ContactCacheEntry {
displayName?: string
@@ -15,7 +16,7 @@ export class ContactCacheService {
constructor(cacheBasePath?: string) {
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
? cacheBasePath
: join(app.getPath('documents'), 'WeFlow')
: ConfigService.getInstance().getCacheBasePath()
this.cacheFilePath = join(basePath, 'contacts.json')
this.ensureCacheDir()
this.loadCache()

View File

@@ -10,6 +10,7 @@ interface ContactExportOptions {
groups: boolean
officials: boolean
}
selectedUsernames?: string[]
}
/**
@@ -40,6 +41,11 @@ class ContactExportService {
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) {
return { success: false, error: '没有符合条件的联系人' }
}

View File

@@ -0,0 +1,802 @@
import { parentPort } from 'worker_threads'
import { wcdbService } from './wcdbService'
export interface DualReportMessage {
content: string
isSentByMe: boolean
createTime: number
createTimeStr: string
localType?: number
emojiMd5?: string
emojiCdnUrl?: string
}
export interface DualReportFirstChat {
createTime: number
createTimeStr: string
content: string
isSentByMe: boolean
senderUsername?: string
localType?: number
emojiMd5?: string
emojiCdnUrl?: string
}
export interface DualReportStats {
totalMessages: number
totalWords: number
imageCount: number
voiceCount: number
emojiCount: number
myTopEmojiMd5?: string
friendTopEmojiMd5?: string
myTopEmojiUrl?: string
friendTopEmojiUrl?: string
myTopEmojiCount?: number
friendTopEmojiCount?: number
}
export interface DualReportData {
year: number
selfName: string
selfAvatarUrl?: string
friendUsername: string
friendName: string
friendAvatarUrl?: string
firstChat: DualReportFirstChat | null
firstChatMessages?: DualReportMessage[]
yearFirstChat?: {
createTime: number
createTimeStr: string
content: string
isSentByMe: boolean
friendName: string
firstThreeMessages: DualReportMessage[]
localType?: number
emojiMd5?: string
emojiCdnUrl?: string
} | null
stats: DualReportStats
topPhrases: Array<{ phrase: string; count: number }>
myExclusivePhrases: Array<{ phrase: string; count: number }>
friendExclusivePhrases: Array<{ phrase: string; count: number }>
heatmap?: number[][]
initiative?: { initiated: number; received: number }
response?: { avg: number; fastest: number; count: number }
monthly?: Record<string, number>
streak?: { days: number; startDate: string; endDate: string }
}
class DualReportService {
private broadcastProgress(status: string, progress: number) {
if (parentPort) {
parentPort.postMessage({
type: 'dualReport:progress',
data: { status, progress }
})
}
}
private reportProgress(status: string, progress: number, onProgress?: (status: string, progress: number) => void) {
if (onProgress) {
onProgress(status, progress)
return
}
this.broadcastProgress(status, progress)
}
private cleanAccountDirName(dirName: string): string {
const trimmed = dirName.trim()
if (!trimmed) return trimmed
if (trimmed.toLowerCase().startsWith('wxid_')) {
const match = trimmed.match(/^(wxid_[^_]+)/i)
if (match) return match[1]
return trimmed
}
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
const cleaned = suffixMatch ? suffixMatch[1] : trimmed
return cleaned
}
private async ensureConnectedWithConfig(
dbPath: string,
decryptKey: string,
wxid: string
): Promise<{ success: boolean; cleanedWxid?: string; rawWxid?: string; error?: string }> {
if (!wxid) return { success: false, error: '未配置微信ID' }
if (!dbPath) return { success: false, error: '未配置数据库路径' }
if (!decryptKey) return { success: false, error: '未配置解密密钥' }
const cleanedWxid = this.cleanAccountDirName(wxid)
const ok = await wcdbService.open(dbPath, decryptKey, cleanedWxid)
if (!ok) return { success: false, error: 'WCDB 打开失败' }
return { success: true, cleanedWxid, rawWxid: wxid }
}
private decodeMessageContent(messageContent: any, compressContent: any): string {
let content = this.decodeMaybeCompressed(compressContent)
if (!content || content.length === 0) {
content = this.decodeMaybeCompressed(messageContent)
}
return content
}
private decodeMaybeCompressed(raw: any): string {
if (!raw) return ''
if (typeof raw === 'string') {
if (raw.length === 0) return ''
// 只有当字符串足够长超过16字符且看起来像 hex 时才尝试解码
// 短字符串(如 "123456" 等纯数字)容易被误判为 hex
if (raw.length > 16 && this.looksLikeHex(raw)) {
const bytes = Buffer.from(raw, 'hex')
if (bytes.length > 0) return this.decodeBinaryContent(bytes)
}
// 只有当字符串足够长超过16字符且看起来像 base64 时才尝试解码
// 短字符串(如 "test", "home" 等)容易被误判为 base64
if (raw.length > 16 && this.looksLikeBase64(raw)) {
try {
const bytes = Buffer.from(raw, 'base64')
return this.decodeBinaryContent(bytes)
} catch {
return raw
}
}
return raw
}
return ''
}
private decodeBinaryContent(data: Buffer): string {
if (data.length === 0) return ''
try {
if (data.length >= 4) {
const magic = data.readUInt32LE(0)
if (magic === 0xFD2FB528) {
const fzstd = require('fzstd')
const decompressed = fzstd.decompress(data)
return Buffer.from(decompressed).toString('utf-8')
}
}
const decoded = data.toString('utf-8')
const replacementCount = (decoded.match(/\uFFFD/g) || []).length
if (replacementCount < decoded.length * 0.2) {
return decoded.replace(/\uFFFD/g, '')
}
return data.toString('latin1')
} catch {
return ''
}
}
private looksLikeHex(s: string): boolean {
if (s.length % 2 !== 0) return false
return /^[0-9a-fA-F]+$/.test(s)
}
private looksLikeBase64(s: string): boolean {
if (s.length % 4 !== 0) return false
return /^[A-Za-z0-9+/=]+$/.test(s)
}
private formatDateTime(milliseconds: number): string {
const dt = new Date(milliseconds)
const month = String(dt.getMonth() + 1).padStart(2, '0')
const day = String(dt.getDate()).padStart(2, '0')
const hour = String(dt.getHours()).padStart(2, '0')
const minute = String(dt.getMinutes()).padStart(2, '0')
return `${month}/${day} ${hour}:${minute}`
}
private getRecordField(record: Record<string, any> | undefined | null, keys: string[]): any {
if (!record) return undefined
for (const key of keys) {
if (Object.prototype.hasOwnProperty.call(record, key) && record[key] !== undefined && record[key] !== null) {
return record[key]
}
}
return undefined
}
private coerceNumber(raw: any): number {
if (raw === undefined || raw === null || raw === '') return NaN
if (typeof raw === 'number') return raw
if (typeof raw === 'bigint') return Number(raw)
if (Buffer.isBuffer(raw)) return parseInt(raw.toString('utf-8'), 10)
if (raw instanceof Uint8Array) return parseInt(Buffer.from(raw).toString('utf-8'), 10)
const parsed = parseInt(String(raw), 10)
return Number.isFinite(parsed) ? parsed : NaN
}
private coerceString(raw: any): string {
if (raw === undefined || raw === null) return ''
if (typeof raw === 'string') return raw
if (Buffer.isBuffer(raw)) return this.decodeBinaryContent(raw)
if (raw instanceof Uint8Array) return this.decodeBinaryContent(Buffer.from(raw))
return String(raw)
}
private coerceBoolean(raw: any): boolean | undefined {
if (raw === undefined || raw === null || raw === '') return undefined
if (typeof raw === 'boolean') return raw
if (typeof raw === 'number') return raw !== 0
const normalized = String(raw).trim().toLowerCase()
if (!normalized) return undefined
if (['1', 'true', 'yes', 'me', 'self', 'mine', 'sent', 'out', 'outgoing'].includes(normalized)) return true
if (['0', 'false', 'no', 'friend', 'peer', 'other', 'recv', 'received', 'in', 'incoming'].includes(normalized)) return false
return undefined
}
private normalizeEmojiMd5(raw: string): string | undefined {
if (!raw) return undefined
const trimmed = raw.trim()
if (!trimmed) return undefined
const match = /([a-fA-F0-9]{16,64})/.exec(trimmed)
return match ? match[1].toLowerCase() : undefined
}
private normalizeEmojiUrl(raw: string): string | undefined {
if (!raw) return undefined
let url = raw.trim().replace(/&amp;/g, '&')
if (!url) return undefined
try {
if (url.includes('%')) {
url = decodeURIComponent(url)
}
} catch { }
return url || undefined
}
private extractEmojiUrl(content: string | undefined): string | undefined {
if (!content) return undefined
const direct = this.normalizeEmojiUrl(content)
if (direct && /^https?:\/\//i.test(direct)) return direct
const attrMatch = /(?:cdnurl|thumburl)\s*=\s*['"]([^'"]+)['"]/i.exec(content)
|| /(?:cdnurl|thumburl)\s*=\s*([^'"\s>]+)/i.exec(content)
if (attrMatch) return this.normalizeEmojiUrl(attrMatch[1])
const tagMatch = /<(?:cdnurl|thumburl)>([^<]+)<\/(?:cdnurl|thumburl)>/i.exec(content)
|| /(?:cdnurl|thumburl)[^>]*>([^<]+)/i.exec(content)
return this.normalizeEmojiUrl(tagMatch?.[1] || '')
}
private extractEmojiMd5(content: string | undefined): string | undefined {
if (!content) return undefined
const direct = this.normalizeEmojiMd5(content)
if (direct && direct.length >= 24) return direct
const match = /md5\s*=\s*['"]([a-fA-F0-9]{16,64})['"]/i.exec(content)
|| /md5\s*=\s*([a-fA-F0-9]{16,64})/i.exec(content)
|| /<md5>([a-fA-F0-9]{16,64})<\/md5>/i.exec(content)
return this.normalizeEmojiMd5(match?.[1] || '')
}
private resolveEmojiOwner(item: any, content: string): boolean | undefined {
const sentFlag = this.coerceBoolean(this.getRecordField(item, [
'isMe',
'is_me',
'isSent',
'is_sent',
'isSend',
'is_send',
'fromMe',
'from_me'
]))
if (sentFlag !== undefined) return sentFlag
const sideRaw = this.coerceString(this.getRecordField(item, ['side', 'sender', 'from', 'owner', 'role', 'direction'])).trim().toLowerCase()
if (sideRaw) {
if (['me', 'self', 'mine', 'out', 'outgoing', 'sent'].includes(sideRaw)) return true
if (['friend', 'peer', 'other', 'in', 'incoming', 'received', 'recv'].includes(sideRaw)) return false
}
const prefixMatch = /^\s*([01])\s*:\s*/.exec(content)
if (prefixMatch) return prefixMatch[1] === '1'
return undefined
}
private stripEmojiOwnerPrefix(content: string): string {
if (!content) return ''
return content.replace(/^\s*[01]\s*:\s*/, '')
}
private parseEmojiCandidate(item: any): { isMe?: boolean; md5?: string; url?: string; count: number } {
const rawContent = this.coerceString(this.getRecordField(item, [
'content',
'xml',
'message_content',
'messageContent',
'msg',
'payload',
'raw'
]))
const content = this.stripEmojiOwnerPrefix(rawContent)
const countRaw = this.getRecordField(item, ['count', 'cnt', 'times', 'total', 'num'])
const parsedCount = this.coerceNumber(countRaw)
const count = Number.isFinite(parsedCount) && parsedCount > 0 ? parsedCount : 0
const directMd5 = this.normalizeEmojiMd5(this.coerceString(this.getRecordField(item, [
'md5',
'emojiMd5',
'emoji_md5',
'emd5'
])))
const md5 = directMd5 || this.extractEmojiMd5(content)
const directUrl = this.normalizeEmojiUrl(this.coerceString(this.getRecordField(item, [
'cdnUrl',
'cdnurl',
'emojiUrl',
'emoji_url',
'url',
'thumbUrl',
'thumburl'
])))
const url = directUrl || this.extractEmojiUrl(content)
return {
isMe: this.resolveEmojiOwner(item, rawContent),
md5,
url,
count
}
}
private getRowInt(row: Record<string, any>, keys: string[], fallback = 0): number {
const raw = this.getRecordField(row, keys)
const parsed = this.coerceNumber(raw)
return Number.isFinite(parsed) ? parsed : fallback
}
private decodeRowMessageContent(row: Record<string, any>): string {
const messageContent = this.getRecordField(row, [
'message_content',
'messageContent',
'content',
'msg_content',
'msgContent',
'WCDB_CT_message_content',
'WCDB_CT_messageContent'
])
const compressContent = this.getRecordField(row, [
'compress_content',
'compressContent',
'compressed_content',
'WCDB_CT_compress_content',
'WCDB_CT_compressContent'
])
return this.decodeMessageContent(messageContent, compressContent)
}
private async scanEmojiTopFallback(
sessionId: string,
beginTimestamp: number,
endTimestamp: number,
rawWxid: string,
cleanedWxid: string
): Promise<{ my?: { md5: string; url?: string; count: number }; friend?: { md5: string; url?: string; count: number } }> {
const cursorResult = await wcdbService.openMessageCursor(sessionId, 500, true, beginTimestamp, endTimestamp)
if (!cursorResult.success || !cursorResult.cursor) return {}
const tallyMap = new Map<string, { isMe: boolean; md5: string; url?: string; count: number }>()
try {
let hasMore = true
while (hasMore) {
const batch = await wcdbService.fetchMessageBatch(cursorResult.cursor)
if (!batch.success || !Array.isArray(batch.rows)) break
for (const row of batch.rows) {
const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 0)
if (localType !== 47) continue
const rawContent = this.decodeRowMessageContent(row)
const content = this.stripEmojiOwnerPrefix(rawContent)
const directMd5 = this.normalizeEmojiMd5(this.coerceString(this.getRecordField(row, ['emoji_md5', 'emojiMd5', 'md5'])))
const md5 = directMd5 || this.extractEmojiMd5(content)
if (!md5) continue
const directUrl = this.normalizeEmojiUrl(this.coerceString(this.getRecordField(row, [
'emoji_cdn_url',
'emojiCdnUrl',
'cdnurl',
'cdn_url',
'emoji_url',
'emojiUrl',
'url',
'thumburl',
'thumb_url'
])))
const url = directUrl || this.extractEmojiUrl(content)
const isMe = this.resolveIsSent(row, rawWxid, cleanedWxid)
const mapKey = `${isMe ? '1' : '0'}:${md5}`
const existing = tallyMap.get(mapKey)
if (existing) {
existing.count += 1
if (!existing.url && url) existing.url = url
} else {
tallyMap.set(mapKey, { isMe, md5, url, count: 1 })
}
}
hasMore = batch.hasMore === true
}
} finally {
await wcdbService.closeMessageCursor(cursorResult.cursor)
}
let myTop: { md5: string; url?: string; count: number } | undefined
let friendTop: { md5: string; url?: string; count: number } | undefined
for (const entry of tallyMap.values()) {
if (entry.isMe) {
if (!myTop || entry.count > myTop.count) {
myTop = { md5: entry.md5, url: entry.url, count: entry.count }
}
} else if (!friendTop || entry.count > friendTop.count) {
friendTop = { md5: entry.md5, url: entry.url, count: entry.count }
}
}
return { my: myTop, friend: friendTop }
}
private async getDisplayName(username: string, fallback: string): Promise<string> {
const result = await wcdbService.getDisplayNames([username])
if (result.success && result.map) {
return result.map[username] || fallback
}
return fallback
}
private resolveIsSent(row: any, rawWxid?: string, cleanedWxid?: string): boolean {
const isSendRaw = row.computed_is_send ?? row.is_send
if (isSendRaw !== undefined && isSendRaw !== null) {
return parseInt(isSendRaw, 10) === 1
}
const sender = String(row.sender_username || row.sender || row.talker || '').toLowerCase()
if (!sender) return false
const rawLower = rawWxid ? rawWxid.toLowerCase() : ''
const cleanedLower = cleanedWxid ? cleanedWxid.toLowerCase() : ''
return !!(
sender === rawLower ||
sender === cleanedLower ||
(rawLower && rawLower.startsWith(sender + '_')) ||
(cleanedLower && cleanedLower.startsWith(sender + '_'))
)
}
private async getFirstMessages(
sessionId: string,
limit: number,
beginTimestamp: number,
endTimestamp: number
): Promise<any[]> {
const safeBegin = Math.max(0, beginTimestamp || 0)
const safeEnd = endTimestamp && endTimestamp > 0 ? endTimestamp : Math.floor(Date.now() / 1000)
const cursorResult = await wcdbService.openMessageCursor(sessionId, Math.max(1, limit), true, safeBegin, safeEnd)
if (!cursorResult.success || !cursorResult.cursor) return []
try {
const rows: any[] = []
let hasMore = true
while (hasMore && rows.length < limit) {
const batch = await wcdbService.fetchMessageBatch(cursorResult.cursor)
if (!batch.success || !batch.rows) break
for (const row of batch.rows) {
rows.push(row)
if (rows.length >= limit) break
}
hasMore = batch.hasMore === true
}
return rows.slice(0, limit)
} finally {
await wcdbService.closeMessageCursor(cursorResult.cursor)
}
}
async generateReportWithConfig(params: {
year: number
friendUsername: string
dbPath: string
decryptKey: string
wxid: string
excludeWords?: string[]
onProgress?: (status: string, progress: number) => void
}): Promise<{ success: boolean; data?: DualReportData; error?: string }> {
try {
const { year, friendUsername, dbPath, decryptKey, wxid, excludeWords, onProgress } = params
this.reportProgress('正在连接数据库...', 5, onProgress)
const conn = await this.ensureConnectedWithConfig(dbPath, decryptKey, wxid)
if (!conn.success || !conn.cleanedWxid || !conn.rawWxid) return { success: false, error: conn.error }
const cleanedWxid = conn.cleanedWxid
const rawWxid = conn.rawWxid
const reportYear = year <= 0 ? 0 : year
const isAllTime = reportYear === 0
const startTime = isAllTime ? 0 : Math.floor(new Date(reportYear, 0, 1).getTime() / 1000)
const endTime = isAllTime ? 0 : Math.floor(new Date(reportYear, 11, 31, 23, 59, 59).getTime() / 1000)
this.reportProgress('加载联系人信息...', 10, onProgress)
const friendName = await this.getDisplayName(friendUsername, friendUsername)
let myName = await this.getDisplayName(rawWxid, rawWxid)
if (myName === rawWxid && cleanedWxid && cleanedWxid !== rawWxid) {
myName = await this.getDisplayName(cleanedWxid, rawWxid)
}
const avatarCandidates = Array.from(new Set([
friendUsername,
rawWxid,
cleanedWxid
].filter(Boolean) as string[]))
let selfAvatarUrl: string | undefined
let friendAvatarUrl: string | undefined
const avatarResult = await wcdbService.getAvatarUrls(avatarCandidates)
if (avatarResult.success && avatarResult.map) {
selfAvatarUrl = avatarResult.map[rawWxid] || avatarResult.map[cleanedWxid]
friendAvatarUrl = avatarResult.map[friendUsername]
}
this.reportProgress('获取首条聊天记录...', 15, onProgress)
const firstRows = await this.getFirstMessages(friendUsername, 10, 0, 0)
let firstChat: DualReportFirstChat | null = null
if (firstRows.length > 0) {
const row = firstRows[0]
const createTime = parseInt(row.create_time || '0', 10) * 1000
const rawContent = this.decodeMessageContent(row.message_content, row.compress_content)
const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType'], 0)
let emojiMd5: string | undefined
let emojiCdnUrl: string | undefined
if (localType === 47) {
const stripped = this.stripEmojiOwnerPrefix(rawContent)
emojiMd5 = this.normalizeEmojiMd5(this.coerceString(this.getRecordField(row, ['emoji_md5', 'emojiMd5', 'md5']))) || this.extractEmojiMd5(stripped)
emojiCdnUrl = this.normalizeEmojiUrl(this.coerceString(this.getRecordField(row, ['emoji_cdn_url', 'emojiCdnUrl', 'cdnurl']))) || this.extractEmojiUrl(stripped)
}
firstChat = {
createTime,
createTimeStr: this.formatDateTime(createTime),
content: String(rawContent || ''),
isSentByMe: this.resolveIsSent(row, rawWxid, cleanedWxid),
senderUsername: row.sender_username || row.sender,
localType,
emojiMd5,
emojiCdnUrl
}
}
const firstChatMessages: DualReportMessage[] = firstRows.map((row) => {
const msgTime = parseInt(row.create_time || '0', 10) * 1000
const rawContent = this.decodeMessageContent(row.message_content, row.compress_content)
const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType'], 0)
let emojiMd5: string | undefined
let emojiCdnUrl: string | undefined
if (localType === 47) {
const stripped = this.stripEmojiOwnerPrefix(rawContent)
emojiMd5 = this.normalizeEmojiMd5(this.coerceString(this.getRecordField(row, ['emoji_md5', 'emojiMd5', 'md5']))) || this.extractEmojiMd5(stripped)
emojiCdnUrl = this.normalizeEmojiUrl(this.coerceString(this.getRecordField(row, ['emoji_cdn_url', 'emojiCdnUrl', 'cdnurl']))) || this.extractEmojiUrl(stripped)
}
return {
content: String(rawContent || ''),
isSentByMe: this.resolveIsSent(row, rawWxid, cleanedWxid),
createTime: msgTime,
createTimeStr: this.formatDateTime(msgTime),
localType,
emojiMd5,
emojiCdnUrl
}
})
let yearFirstChat: DualReportData['yearFirstChat'] = null
if (!isAllTime) {
this.reportProgress('获取今年首次聊天...', 20, onProgress)
const firstYearRows = await this.getFirstMessages(friendUsername, 10, startTime, endTime)
if (firstYearRows.length > 0) {
const firstRow = firstYearRows[0]
const createTime = parseInt(firstRow.create_time || '0', 10) * 1000
const firstThreeMessages: DualReportMessage[] = firstYearRows.map((row) => {
const msgTime = parseInt(row.create_time || '0', 10) * 1000
const rawContent = this.decodeMessageContent(row.message_content, row.compress_content)
const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType'], 0)
let emojiMd5: string | undefined
let emojiCdnUrl: string | undefined
if (localType === 47) {
const stripped = this.stripEmojiOwnerPrefix(rawContent)
emojiMd5 = this.normalizeEmojiMd5(this.coerceString(this.getRecordField(row, ['emoji_md5', 'emojiMd5', 'md5']))) || this.extractEmojiMd5(stripped)
emojiCdnUrl = this.normalizeEmojiUrl(this.coerceString(this.getRecordField(row, ['emoji_cdn_url', 'emojiCdnUrl', 'cdnurl']))) || this.extractEmojiUrl(stripped)
}
return {
content: String(rawContent || ''),
isSentByMe: this.resolveIsSent(row, rawWxid, cleanedWxid),
createTime: msgTime,
createTimeStr: this.formatDateTime(msgTime),
localType,
emojiMd5,
emojiCdnUrl
}
})
const firstRowYear = firstYearRows[0]
const rawContentYear = this.decodeMessageContent(firstRowYear.message_content, firstRowYear.compress_content)
const localTypeYear = this.getRowInt(firstRowYear, ['local_type', 'localType', 'type', 'msg_type', 'msgType'], 0)
let emojiMd5Year: string | undefined
let emojiCdnUrlYear: string | undefined
if (localTypeYear === 47) {
const stripped = this.stripEmojiOwnerPrefix(rawContentYear)
emojiMd5Year = this.normalizeEmojiMd5(this.coerceString(this.getRecordField(firstRowYear, ['emoji_md5', 'emojiMd5', 'md5']))) || this.extractEmojiMd5(stripped)
emojiCdnUrlYear = this.normalizeEmojiUrl(this.coerceString(this.getRecordField(firstRowYear, ['emoji_cdn_url', 'emojiCdnUrl', 'cdnurl']))) || this.extractEmojiUrl(stripped)
}
yearFirstChat = {
createTime,
createTimeStr: this.formatDateTime(createTime),
content: String(rawContentYear || ''),
isSentByMe: this.resolveIsSent(firstRowYear, rawWxid, cleanedWxid),
friendName,
firstThreeMessages,
localType: localTypeYear,
emojiMd5: emojiMd5Year,
emojiCdnUrl: emojiCdnUrlYear
}
}
}
this.reportProgress('统计聊天数据...', 30, onProgress)
const statsResult = await wcdbService.getDualReportStats(friendUsername, startTime, endTime)
if (!statsResult.success || !statsResult.data) {
return { success: false, error: statsResult.error || '获取双人报告统计失败' }
}
const cppData = statsResult.data
const counts = cppData.counts || {}
const stats: DualReportStats = {
totalMessages: counts.total || 0,
totalWords: counts.words || 0,
imageCount: counts.image || 0,
voiceCount: counts.voice || 0,
emojiCount: counts.emoji || 0
}
// Process Emojis to find top for me and friend
let myTopEmojiMd5: string | undefined
let myTopEmojiUrl: string | undefined
let myTopCount = -1
let friendTopEmojiMd5: string | undefined
let friendTopEmojiUrl: string | undefined
let friendTopCount = -1
if (cppData.emojis && Array.isArray(cppData.emojis)) {
for (const item of cppData.emojis) {
const candidate = this.parseEmojiCandidate(item)
if (!candidate.md5 || candidate.isMe === undefined || candidate.count <= 0) continue
if (candidate.isMe) {
if (candidate.count > myTopCount) {
myTopCount = candidate.count
myTopEmojiMd5 = candidate.md5
myTopEmojiUrl = candidate.url
}
} else if (candidate.count > friendTopCount) {
friendTopCount = candidate.count
friendTopEmojiMd5 = candidate.md5
friendTopEmojiUrl = candidate.url
}
}
}
const needsEmojiFallback = stats.emojiCount > 0 && (!myTopEmojiMd5 || !friendTopEmojiMd5)
if (needsEmojiFallback) {
const fallback = await this.scanEmojiTopFallback(friendUsername, startTime, endTime, rawWxid, cleanedWxid)
if (!myTopEmojiMd5 && fallback.my?.md5) {
myTopEmojiMd5 = fallback.my.md5
myTopEmojiUrl = myTopEmojiUrl || fallback.my.url
myTopCount = fallback.my.count
}
if (!friendTopEmojiMd5 && fallback.friend?.md5) {
friendTopEmojiMd5 = fallback.friend.md5
friendTopEmojiUrl = friendTopEmojiUrl || fallback.friend.url
friendTopCount = fallback.friend.count
}
}
const [myEmojiUrlResult, friendEmojiUrlResult] = await Promise.all([
myTopEmojiMd5 && !myTopEmojiUrl ? wcdbService.getEmoticonCdnUrl(dbPath, myTopEmojiMd5) : Promise.resolve(null),
friendTopEmojiMd5 && !friendTopEmojiUrl ? wcdbService.getEmoticonCdnUrl(dbPath, friendTopEmojiMd5) : Promise.resolve(null)
])
if (myEmojiUrlResult?.success && myEmojiUrlResult.url) myTopEmojiUrl = myEmojiUrlResult.url
if (friendEmojiUrlResult?.success && friendEmojiUrlResult.url) friendTopEmojiUrl = friendEmojiUrlResult.url
stats.myTopEmojiMd5 = myTopEmojiMd5
stats.myTopEmojiUrl = myTopEmojiUrl
stats.friendTopEmojiMd5 = friendTopEmojiMd5
stats.friendTopEmojiUrl = friendTopEmojiUrl
if (myTopCount >= 0) stats.myTopEmojiCount = myTopCount
if (friendTopCount >= 0) stats.friendTopEmojiCount = friendTopCount
if (friendTopCount >= 0) stats.friendTopEmojiCount = friendTopCount
const excludeSet = new Set(excludeWords || [])
const filterPhrases = (list: any[]) => {
return (list || []).filter((p: any) => !excludeSet.has(p.phrase))
}
const cleanPhrases = filterPhrases(cppData.phrases)
const cleanMyPhrases = filterPhrases(cppData.myPhrases)
const cleanFriendPhrases = filterPhrases(cppData.friendPhrases)
const topPhrases = cleanPhrases.map((p: any) => ({
phrase: p.phrase,
count: p.count
}))
// 计算专属词汇:一方频繁使用而另一方很少使用的词
const myPhraseMap = new Map<string, number>()
const friendPhraseMap = new Map<string, number>()
for (const p of cleanMyPhrases) {
myPhraseMap.set(p.phrase, p.count)
}
for (const p of cleanFriendPhrases) {
friendPhraseMap.set(p.phrase, p.count)
}
// 专属词汇:该方使用占比 >= 75% 且至少出现 2 次
const myExclusivePhrases: Array<{ phrase: string; count: number }> = []
const friendExclusivePhrases: Array<{ phrase: string; count: number }> = []
for (const [phrase, myCount] of myPhraseMap) {
const friendCount = friendPhraseMap.get(phrase) || 0
const total = myCount + friendCount
if (myCount >= 2 && total > 0 && myCount / total >= 0.75) {
myExclusivePhrases.push({ phrase, count: myCount })
}
}
for (const [phrase, friendCount] of friendPhraseMap) {
const myCount = myPhraseMap.get(phrase) || 0
const total = myCount + friendCount
if (friendCount >= 2 && total > 0 && friendCount / total >= 0.75) {
friendExclusivePhrases.push({ phrase, count: friendCount })
}
}
// 按频率排序,取前 20
myExclusivePhrases.sort((a, b) => b.count - a.count)
friendExclusivePhrases.sort((a, b) => b.count - a.count)
if (myExclusivePhrases.length > 20) myExclusivePhrases.length = 20
if (friendExclusivePhrases.length > 20) friendExclusivePhrases.length = 20
const reportData: DualReportData = {
year: reportYear,
selfName: myName,
selfAvatarUrl,
friendUsername,
friendName,
friendAvatarUrl,
firstChat,
firstChatMessages,
yearFirstChat,
stats,
topPhrases,
myExclusivePhrases,
friendExclusivePhrases,
heatmap: cppData.heatmap,
initiative: cppData.initiative,
response: cppData.response,
monthly: cppData.monthly,
streak: cppData.streak
} as any
this.reportProgress('双人报告生成完成', 100, onProgress)
return { success: true, data: reportData }
} catch (e) {
return { success: false, error: String(e) }
}
}
}
export const dualReportService = new DualReportService()

View File

@@ -25,83 +25,87 @@ body {
.page {
max-width: 1080px;
margin: 32px auto 60px;
padding: 0 20px;
margin: 0 auto;
padding: 8px 20px;
height: 100vh;
display: flex;
flex-direction: column;
}
.header {
background: var(--card);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 24px;
margin-bottom: 24px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06);
padding: 12px 20px;
flex-shrink: 0;
}
.title {
font-size: 24px;
font-size: 16px;
font-weight: 600;
margin: 0 0 8px;
margin: 0;
display: inline;
}
.meta {
color: var(--muted);
font-size: 14px;
display: flex;
flex-wrap: wrap;
gap: 12px;
font-size: 13px;
display: inline;
margin-left: 12px;
}
.meta span {
margin-right: 10px;
}
.controls {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
margin-top: 20px;
}
.control {
display: flex;
flex-direction: column;
gap: 6px;
align-items: center;
gap: 8px;
margin-top: 8px;
flex-wrap: wrap;
}
.control label {
font-size: 13px;
color: var(--muted);
}
.control input,
.control select,
.control button {
border-radius: 12px;
.controls input,
.controls button {
border-radius: 8px;
border: 1px solid var(--border);
padding: 10px 12px;
font-size: 14px;
padding: 6px 10px;
font-size: 13px;
font-family: inherit;
}
.control button {
.controls input[type="search"] {
width: 200px;
}
.controls input[type="datetime-local"] {
width: 200px;
}
.controls button {
background: var(--accent);
color: #fff;
border: none;
cursor: pointer;
transition: transform 0.1s ease;
padding: 6px 14px;
}
.control button:active {
.controls button:active {
transform: scale(0.98);
}
.stats {
font-size: 13px;
color: var(--muted);
display: flex;
align-items: flex-end;
margin-left: auto;
}
.message-list {
display: flex;
flex-direction: column;
gap: 18px;
gap: 12px;
padding: 4px 0;
}
.message {
@@ -182,6 +186,17 @@ body {
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 {
width: 22px;
height: 22px;
@@ -248,50 +263,11 @@ body {
cursor: zoom-out;
}
body[data-theme="cloud-dancer"] {
--accent: #6b8cff;
--sent: #e0e7ff;
--received: #ffffff;
--border: #d8e0f7;
--bg: #f6f7fb;
}
body[data-theme="corundum-blue"] {
--accent: #2563eb;
--sent: #dbeafe;
--received: #ffffff;
--border: #c7d2fe;
--bg: #eef2ff;
}
body[data-theme="kiwi-green"] {
--accent: #16a34a;
--sent: #dcfce7;
--received: #ffffff;
--border: #bbf7d0;
--bg: #f0fdf4;
}
body[data-theme="spicy-red"] {
--accent: #e11d48;
--sent: #ffe4e6;
--received: #ffffff;
--border: #fecdd3;
--bg: #fff1f2;
}
body[data-theme="teal-water"] {
--accent: #0f766e;
--sent: #ccfbf1;
--received: #ffffff;
--border: #99f6e4;
--bg: #f0fdfa;
}
.highlight {
outline: 2px solid var(--accent);
outline-offset: 4px;
border-radius: 18px;
transition: outline-color 0.3s;
}
.empty {
@@ -299,3 +275,30 @@ body[data-theme="teal-water"] {
color: var(--muted);
padding: 40px;
}
/* Scroll Container */
.scroll-container {
flex: 1;
min-height: 0;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
margin-top: 8px;
margin-bottom: 8px;
padding: 12px;
-webkit-overflow-scrolling: touch;
}
.scroll-container::-webkit-scrollbar {
width: 6px;
}
.scroll-container::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.load-sentinel {
height: 1px;
}

View File

@@ -25,83 +25,87 @@ body {
.page {
max-width: 1080px;
margin: 32px auto 60px;
padding: 0 20px;
margin: 0 auto;
padding: 8px 20px;
height: 100vh;
display: flex;
flex-direction: column;
}
.header {
background: var(--card);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 24px;
margin-bottom: 24px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06);
padding: 12px 20px;
flex-shrink: 0;
}
.title {
font-size: 24px;
font-size: 16px;
font-weight: 600;
margin: 0 0 8px;
margin: 0;
display: inline;
}
.meta {
color: var(--muted);
font-size: 14px;
display: flex;
flex-wrap: wrap;
gap: 12px;
font-size: 13px;
display: inline;
margin-left: 12px;
}
.meta span {
margin-right: 10px;
}
.controls {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
margin-top: 20px;
}
.control {
display: flex;
flex-direction: column;
gap: 6px;
align-items: center;
gap: 8px;
margin-top: 8px;
flex-wrap: wrap;
}
.control label {
font-size: 13px;
color: var(--muted);
}
.control input,
.control select,
.control button {
border-radius: 12px;
.controls input,
.controls button {
border-radius: 8px;
border: 1px solid var(--border);
padding: 10px 12px;
font-size: 14px;
padding: 6px 10px;
font-size: 13px;
font-family: inherit;
}
.control button {
.controls input[type="search"] {
width: 200px;
}
.controls input[type="datetime-local"] {
width: 200px;
}
.controls button {
background: var(--accent);
color: #fff;
border: none;
cursor: pointer;
transition: transform 0.1s ease;
padding: 6px 14px;
}
.control button:active {
.controls button:active {
transform: scale(0.98);
}
.stats {
font-size: 13px;
color: var(--muted);
display: flex;
align-items: flex-end;
margin-left: auto;
}
.message-list {
display: flex;
flex-direction: column;
gap: 18px;
gap: 12px;
padding: 4px 0;
}
.message {
@@ -182,6 +186,17 @@ body {
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 {
width: 22px;
height: 22px;
@@ -248,50 +263,11 @@ body {
cursor: zoom-out;
}
body[data-theme="cloud-dancer"] {
--accent: #6b8cff;
--sent: #e0e7ff;
--received: #ffffff;
--border: #d8e0f7;
--bg: #f6f7fb;
}
body[data-theme="corundum-blue"] {
--accent: #2563eb;
--sent: #dbeafe;
--received: #ffffff;
--border: #c7d2fe;
--bg: #eef2ff;
}
body[data-theme="kiwi-green"] {
--accent: #16a34a;
--sent: #dcfce7;
--received: #ffffff;
--border: #bbf7d0;
--bg: #f0fdf4;
}
body[data-theme="spicy-red"] {
--accent: #e11d48;
--sent: #ffe4e6;
--received: #ffffff;
--border: #fecdd3;
--bg: #fff1f2;
}
body[data-theme="teal-water"] {
--accent: #0f766e;
--sent: #ccfbf1;
--received: #ffffff;
--border: #99f6e4;
--bg: #f0fdfa;
}
.highlight {
outline: 2px solid var(--accent);
outline-offset: 4px;
border-radius: 18px;
transition: outline-color 0.3s;
}
.empty {
@@ -299,4 +275,32 @@ body[data-theme="teal-water"] {
color: var(--muted);
padding: 40px;
}
/* Scroll Container */
.scroll-container {
flex: 1;
min-height: 0;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
margin-top: 8px;
margin-bottom: 8px;
padding: 12px;
-webkit-overflow-scrolling: touch;
}
.scroll-container::-webkit-scrollbar {
width: 6px;
}
.scroll-container::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.load-sentinel {
height: 1px;
}
`;

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@ import ExcelJS from 'exceljs'
import { ConfigService } from './config'
import { wcdbService } from './wcdbService'
import { chatService } from './chatService'
import type { Message } from './chatService'
export interface GroupChatInfo {
username: string
@@ -16,6 +17,10 @@ export interface GroupMember {
username: string
displayName: string
avatarUrl?: string
nickname?: string
alias?: string
remark?: string
groupNickname?: string
}
export interface GroupMessageRank {
@@ -75,8 +80,13 @@ class GroupAnalyticsService {
if (trimmed.toLowerCase().startsWith('wxid_')) {
const match = trimmed.match(/^(wxid_[^_]+)/i)
if (match) return match[1]
return trimmed
}
return trimmed
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
const cleaned = suffixMatch ? suffixMatch[1] : trimmed
return cleaned
}
private async ensureConnected(): Promise<{ success: boolean; error?: string }> {
@@ -93,6 +103,27 @@ class GroupAnalyticsService {
return { success: true }
}
/**
* 从 DLL 获取群成员的群昵称
*/
private async getGroupNicknamesForRoom(chatroomId: string, candidates: string[] = []): Promise<Map<string, string>> {
try {
const escapedChatroomId = chatroomId.replace(/'/g, "''")
const sql = `SELECT ext_buffer FROM chat_room WHERE username='${escapedChatroomId}' LIMIT 1`
const result = await wcdbService.execQuery('contact', null, sql)
if (!result.success || !result.rows || result.rows.length === 0) {
return new Map<string, string>()
}
const extBuffer = this.decodeExtBuffer((result.rows[0] as any).ext_buffer)
if (!extBuffer) return new Map<string, string>()
return this.parseGroupNicknamesFromExtBuffer(extBuffer, candidates)
} catch (e) {
console.error('getGroupNicknamesForRoom error:', e)
return new Map<string, string>()
}
}
private looksLikeHex(s: string): boolean {
if (s.length % 2 !== 0) return false
return /^[0-9a-fA-F]+$/.test(s)
@@ -103,95 +134,138 @@ class GroupAnalyticsService {
return /^[A-Za-z0-9+/=]+$/.test(s)
}
/**
* 解析 ext_buffer 二进制数据,提取群成员的群昵称
*/
private parseGroupNicknamesFromExtBuffer(buffer: Buffer): Map<string, string> {
private decodeExtBuffer(value: unknown): Buffer | null {
if (!value) return null
if (Buffer.isBuffer(value)) return value
if (value instanceof Uint8Array) return Buffer.from(value)
if (typeof value === 'string') {
const raw = value.trim()
if (!raw) return null
if (this.looksLikeHex(raw)) {
try { return Buffer.from(raw, 'hex') } catch { }
}
if (this.looksLikeBase64(raw)) {
try { return Buffer.from(raw, 'base64') } catch { }
}
try { return Buffer.from(raw, 'hex') } catch { }
try { return Buffer.from(raw, 'base64') } catch { }
try { return Buffer.from(raw, 'utf8') } catch { }
return null
}
return null
}
private readVarint(buffer: Buffer, offset: number, limit: number = buffer.length): { value: number; next: number } | null {
let value = 0
let shift = 0
let pos = offset
while (pos < limit && shift <= 53) {
const byte = buffer[pos]
value += (byte & 0x7f) * Math.pow(2, shift)
pos += 1
if ((byte & 0x80) === 0) return { value, next: pos }
shift += 7
}
return null
}
private isLikelyMemberId(value: string): boolean {
const id = String(value || '').trim()
if (!id) return false
if (id.includes('@chatroom')) return false
if (id.length < 4 || id.length > 80) return false
return /^[A-Za-z][A-Za-z0-9_.@-]*$/.test(id)
}
private isLikelyNickname(value: string): boolean {
const cleaned = this.normalizeGroupNickname(value)
if (!cleaned) return false
if (/^wxid_[a-z0-9_]+$/i.test(cleaned)) return false
if (cleaned.includes('@chatroom')) return false
if (!/[\u4E00-\u9FFF\u3400-\u4DBF\w]/.test(cleaned)) return false
if (cleaned.length === 1) {
const code = cleaned.charCodeAt(0)
const isCjk = code >= 0x3400 && code <= 0x9fff
if (!isCjk) return false
}
return true
}
private parseGroupNicknamesFromExtBuffer(buffer: Buffer, candidates: string[] = []): Map<string, string> {
const nicknameMap = new Map<string, string>()
if (!buffer || buffer.length === 0) return nicknameMap
try {
const raw = buffer.toString('utf8')
const wxidPattern = /wxid_[a-z0-9_]+/gi
const wxids = raw.match(wxidPattern) || []
const candidateSet = new Set(this.buildIdCandidates(candidates).map((id) => id.toLowerCase()))
for (const wxid of wxids) {
const wxidLower = wxid.toLowerCase()
const wxidIndex = raw.toLowerCase().indexOf(wxidLower)
if (wxidIndex === -1) continue
for (let i = 0; i < buffer.length - 2; i += 1) {
if (buffer[i] !== 0x0a) continue
const afterWxid = raw.slice(wxidIndex + wxid.length)
let nickname = ''
let foundStart = false
const idLenInfo = this.readVarint(buffer, i + 1)
if (!idLenInfo) continue
const idLen = idLenInfo.value
if (!Number.isFinite(idLen) || idLen <= 0 || idLen > 96) continue
for (let i = 0; i < afterWxid.length && i < 100; i++) {
const char = afterWxid[i]
const code = char.charCodeAt(0)
const isPrintable = (
(code >= 0x4E00 && code <= 0x9FFF) ||
(code >= 0x3000 && code <= 0x303F) ||
(code >= 0xFF00 && code <= 0xFFEF) ||
(code >= 0x20 && code <= 0x7E)
)
const idStart = idLenInfo.next
const idEnd = idStart + idLen
if (idEnd > buffer.length) continue
if (isPrintable && code !== 0x01 && code !== 0x18) {
foundStart = true
nickname += char
} else if (foundStart) {
break
}
const memberId = buffer.toString('utf8', idStart, idEnd).trim()
if (!this.isLikelyMemberId(memberId)) continue
const memberIdLower = memberId.toLowerCase()
if (candidateSet.size > 0 && !candidateSet.has(memberIdLower)) {
i = idEnd - 1
continue
}
nickname = nickname.trim().replace(/[\x00-\x1F\x7F]/g, '')
if (nickname && nickname.length < 50) {
nicknameMap.set(wxidLower, nickname)
const cursor = idEnd
if (cursor >= buffer.length || buffer[cursor] !== 0x12) {
i = idEnd - 1
continue
}
const nickLenInfo = this.readVarint(buffer, cursor + 1)
if (!nickLenInfo) {
i = idEnd - 1
continue
}
const nickLen = nickLenInfo.value
if (!Number.isFinite(nickLen) || nickLen <= 0 || nickLen > 128) {
i = idEnd - 1
continue
}
const nickStart = nickLenInfo.next
const nickEnd = nickStart + nickLen
if (nickEnd > buffer.length) {
i = idEnd - 1
continue
}
const rawNick = buffer.toString('utf8', nickStart, nickEnd)
const nickname = this.normalizeGroupNickname(rawNick.replace(/[\x00-\x1F\x7F]/g, '').trim())
if (!this.isLikelyNickname(nickname)) {
i = nickEnd - 1
continue
}
if (!nicknameMap.has(memberId)) nicknameMap.set(memberId, nickname)
if (!nicknameMap.has(memberIdLower)) nicknameMap.set(memberIdLower, nickname)
i = nickEnd - 1
}
} catch (e) {
console.error('Failed to parse ext_buffer:', e)
console.error('Failed to parse chat_room.ext_buffer:', e)
}
return nicknameMap
}
/**
* 从 contact.db 的 chat_room 表获取群成员的群昵称
*/
private async getGroupNicknamesForRoom(chatroomId: string): Promise<Map<string, string>> {
try {
const sql = `SELECT ext_buffer FROM chat_room WHERE username = '${chatroomId.replace(/'/g, "''")}'`
const result = await wcdbService.execQuery('contact', null, sql)
if (!result.success || !result.rows || result.rows.length === 0) {
return new Map<string, string>()
}
let extBuffer = result.rows[0].ext_buffer
if (typeof extBuffer === 'string') {
if (this.looksLikeHex(extBuffer)) {
extBuffer = Buffer.from(extBuffer, 'hex')
} else if (this.looksLikeBase64(extBuffer)) {
extBuffer = Buffer.from(extBuffer, 'base64')
} else {
try {
extBuffer = Buffer.from(extBuffer, 'hex')
} catch {
extBuffer = Buffer.from(extBuffer, 'base64')
}
}
}
if (!extBuffer || !Buffer.isBuffer(extBuffer)) {
return new Map<string, string>()
}
return this.parseGroupNicknamesFromExtBuffer(extBuffer)
} catch (e) {
console.error('getGroupNicknamesForRoom error:', e)
return new Map<string, string>()
}
}
private escapeCsvValue(value: string): string {
if (value == null) return ''
const str = String(value)
@@ -201,14 +275,54 @@ class GroupAnalyticsService {
return str
}
private normalizeGroupNickname(value: string, wxid: string, fallback: string): string {
private normalizeGroupNickname(value: string): string {
const trimmed = (value || '').trim()
if (!trimmed) return fallback
if (/^["'@]+$/.test(trimmed)) return fallback
if (trimmed.toLowerCase() === (wxid || '').toLowerCase()) return fallback
if (!trimmed) return ''
if (/^["'@]+$/.test(trimmed)) return ''
return trimmed
}
private buildIdCandidates(values: Array<string | undefined | null>): string[] {
const set = new Set<string>()
for (const rawValue of values) {
const raw = String(rawValue || '').trim()
if (!raw) continue
set.add(raw)
const cleaned = this.cleanAccountDirName(raw)
if (cleaned && cleaned !== raw) {
set.add(cleaned)
}
}
return Array.from(set)
}
private resolveGroupNicknameByCandidates(groupNicknames: Map<string, string>, candidates: string[]): string {
const idCandidates = this.buildIdCandidates(candidates)
if (idCandidates.length === 0) return ''
for (const id of idCandidates) {
const exact = this.normalizeGroupNickname(groupNicknames.get(id) || '')
if (exact) return exact
}
for (const id of idCandidates) {
const lower = id.toLowerCase()
let found = ''
let matched = 0
for (const [key, value] of groupNicknames.entries()) {
if (String(key || '').toLowerCase() !== lower) continue
const normalized = this.normalizeGroupNickname(value || '')
if (!normalized) continue
found = normalized
matched += 1
if (matched > 1) return ''
}
if (matched === 1 && found) return found
}
return ''
}
private sanitizeWorksheetName(name: string): string {
const cleaned = (name || '').replace(/[*?:\\/\\[\\]]/g, '_').trim()
const limited = cleaned.slice(0, 31)
@@ -226,6 +340,92 @@ class GroupAnalyticsService {
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 }> {
try {
const conn = await this.ensureConnected()
@@ -293,15 +493,88 @@ class GroupAnalyticsService {
return { success: false, error: result.error || '获取群成员失败' }
}
const members = result.members as { username: string; avatarUrl?: string }[]
const usernames = members.map((m) => m.username)
const displayNames = await wcdbService.getDisplayNames(usernames)
const members = result.members as Array<{
username: string
avatarUrl?: string
originalName?: string
}>
const usernames = members.map((m) => m.username).filter(Boolean)
const data: GroupMember[] = members.map((m) => ({
username: m.username,
displayName: displayNames.success && displayNames.map ? (displayNames.map[m.username] || m.username) : m.username,
avatarUrl: m.avatarUrl
}))
const displayNamesPromise = wcdbService.getDisplayNames(usernames)
const contactMap = new Map<string, {
remark?: string
nickName?: string
alias?: string
username?: string
userName?: string
encryptUsername?: string
encryptUserName?: string
}>()
const concurrency = 6
await this.parallelLimit(usernames, concurrency, async (username) => {
const contactResult = await wcdbService.getContact(username)
if (contactResult.success && contactResult.contact) {
const contact = contactResult.contact as any
contactMap.set(username, {
remark: contact.remark || '',
nickName: contact.nickName || contact.nick_name || '',
alias: contact.alias || '',
username: contact.username || '',
userName: contact.userName || contact.user_name || '',
encryptUsername: contact.encryptUsername || contact.encrypt_username || '',
encryptUserName: contact.encryptUserName || ''
})
} else {
contactMap.set(username, { remark: '', nickName: '', alias: '' })
}
})
const displayNames = await displayNamesPromise
const nicknameCandidates = this.buildIdCandidates([
...members.map((m) => m.username),
...members.map((m) => m.originalName),
...Array.from(contactMap.values()).map((c) => c?.username),
...Array.from(contactMap.values()).map((c) => c?.userName),
...Array.from(contactMap.values()).map((c) => c?.encryptUsername),
...Array.from(contactMap.values()).map((c) => c?.encryptUserName),
...Array.from(contactMap.values()).map((c) => c?.alias)
])
const groupNicknames = await this.getGroupNicknamesForRoom(chatroomId, nicknameCandidates)
const myWxid = this.cleanAccountDirName(this.configService.get('myWxid') || '')
const data: GroupMember[] = members.map((m) => {
const wxid = m.username || ''
const displayName = displayNames.success && displayNames.map ? (displayNames.map[wxid] || wxid) : wxid
const contact = contactMap.get(wxid)
const nickname = contact?.nickName || ''
const remark = contact?.remark || ''
const alias = contact?.alias || ''
const normalizedWxid = this.cleanAccountDirName(wxid)
const lookupCandidates = this.buildIdCandidates([
wxid,
m.originalName,
contact?.username,
contact?.userName,
contact?.encryptUsername,
contact?.encryptUserName,
alias
])
if (normalizedWxid === myWxid) {
lookupCandidates.push(myWxid)
}
const groupNickname = this.resolveGroupNicknameByCandidates(groupNicknames, lookupCandidates)
return {
username: wxid,
displayName,
nickname,
alias,
remark,
groupNickname,
avatarUrl: m.avatarUrl
}
})
return { success: true, data }
} catch (e) {
@@ -425,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 }> {
try {
const conn = await this.ensureConnected()
@@ -451,18 +899,27 @@ class GroupAnalyticsService {
return { success: false, error: membersResult.error || '获取群成员失败' }
}
const members = membersResult.members as { username: string; avatarUrl?: string }[]
const members = membersResult.members as Array<{
username: string
avatarUrl?: string
originalName?: string
}>
if (members.length === 0) {
return { success: false, error: '群成员为空' }
}
const usernames = members.map((m) => m.username).filter(Boolean)
const [displayNames, groupNicknames] = await Promise.all([
wcdbService.getDisplayNames(usernames),
this.getGroupNicknamesForRoom(chatroomId)
])
const displayNamesPromise = wcdbService.getDisplayNames(usernames)
const contactMap = new Map<string, { remark?: string; nickName?: string; alias?: string }>()
const contactMap = new Map<string, {
remark?: string
nickName?: string
alias?: string
username?: string
userName?: string
encryptUsername?: string
encryptUserName?: string
}>()
const concurrency = 6
await this.parallelLimit(usernames, concurrency, async (username) => {
const result = await wcdbService.getContact(username)
@@ -471,7 +928,11 @@ class GroupAnalyticsService {
contactMap.set(username, {
remark: contact.remark || '',
nickName: contact.nickName || contact.nick_name || '',
alias: contact.alias || ''
alias: contact.alias || '',
username: contact.username || '',
userName: contact.userName || contact.user_name || '',
encryptUsername: contact.encryptUsername || contact.encrypt_username || '',
encryptUserName: contact.encryptUserName || ''
})
} else {
contactMap.set(username, { remark: '', nickName: '', alias: '' })
@@ -486,6 +947,18 @@ class GroupAnalyticsService {
const rows: string[][] = [infoTitleRow, infoRow, metaRow, header]
const myWxid = this.cleanAccountDirName(this.configService.get('myWxid') || '')
const displayNames = await displayNamesPromise
const nicknameCandidates = this.buildIdCandidates([
...members.map((m) => m.username),
...members.map((m) => m.originalName),
...Array.from(contactMap.values()).map((c) => c?.username),
...Array.from(contactMap.values()).map((c) => c?.userName),
...Array.from(contactMap.values()).map((c) => c?.encryptUsername),
...Array.from(contactMap.values()).map((c) => c?.encryptUserName),
...Array.from(contactMap.values()).map((c) => c?.alias)
])
const groupNicknames = await this.getGroupNicknamesForRoom(chatroomId, nicknameCandidates)
for (const member of members) {
const wxid = member.username
const normalizedWxid = this.cleanAccountDirName(wxid || '')
@@ -493,13 +966,20 @@ class GroupAnalyticsService {
const fallbackName = displayNames.success && displayNames.map ? (displayNames.map[wxid] || '') : ''
const nickName = contact?.nickName || fallbackName || ''
const remark = contact?.remark || ''
const rawGroupNickname = groupNicknames.get(wxid.toLowerCase()) || ''
const alias = contact?.alias || ''
const groupNickname = this.normalizeGroupNickname(
rawGroupNickname,
normalizedWxid === myWxid ? myWxid : wxid,
''
)
const lookupCandidates = this.buildIdCandidates([
wxid,
member.originalName,
contact?.username,
contact?.userName,
contact?.encryptUsername,
contact?.encryptUserName,
alias
])
if (normalizedWxid === myWxid) {
lookupCandidates.push(myWxid)
}
const groupNickname = this.resolveGroupNicknameByCandidates(groupNicknames, lookupCandidates)
rows.push([nickName, remark, groupNickname, wxid, alias])
}

View File

@@ -0,0 +1,906 @@
/**
* HTTP API 服务
* 提供 ChatLab 标准化格式的消息查询 API
*/
import * as http from 'http'
import * as fs from 'fs'
import * as path from 'path'
import { URL } from 'url'
import { chatService, Message } from './chatService'
import { wcdbService } from './wcdbService'
import { ConfigService } from './config'
import { videoService } from './videoService'
// ChatLab 格式定义
interface ChatLabHeader {
version: string
exportedAt: number
generator: string
description?: string
}
interface ChatLabMeta {
name: string
platform: string
type: 'group' | 'private'
groupId?: string
groupAvatar?: string
ownerId?: string
}
interface ChatLabMember {
platformId: string
accountName: string
groupNickname?: string
aliases?: string[]
avatar?: string
}
interface ChatLabMessage {
sender: string
accountName: string
groupNickname?: string
timestamp: number
type: number
content: string | null
platformMessageId?: string
replyToMessageId?: string
mediaPath?: string
}
interface ChatLabData {
chatlab: ChatLabHeader
meta: ChatLabMeta
members: ChatLabMember[]
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 消息类型映射
const ChatLabType = {
TEXT: 0,
IMAGE: 1,
VOICE: 2,
VIDEO: 3,
FILE: 4,
EMOJI: 5,
LINK: 7,
LOCATION: 8,
RED_PACKET: 20,
TRANSFER: 21,
POKE: 22,
CALL: 23,
SHARE: 24,
REPLY: 25,
FORWARD: 26,
CONTACT: 27,
SYSTEM: 80,
RECALL: 81,
OTHER: 99
} as const
class HttpService {
private server: http.Server | null = null
private configService: ConfigService
private port: number = 5031
private running: boolean = false
private connections: Set<import('net').Socket> = new Set()
constructor() {
this.configService = ConfigService.getInstance()
}
/**
* 启动 HTTP 服务
*/
async start(port: number = 5031): Promise<{ success: boolean; port?: number; error?: string }> {
if (this.running && this.server) {
return { success: true, port: this.port }
}
this.port = port
return new Promise((resolve) => {
this.server = http.createServer((req, res) => this.handleRequest(req, res))
// 跟踪所有连接,以便关闭时能强制断开
this.server.on('connection', (socket) => {
this.connections.add(socket)
socket.on('close', () => {
this.connections.delete(socket)
})
})
this.server.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EADDRINUSE') {
console.error(`[HttpService] Port ${this.port} is already in use`)
resolve({ success: false, error: `Port ${this.port} is already in use` })
} else {
console.error('[HttpService] Server error:', err)
resolve({ success: false, error: err.message })
}
})
this.server.listen(this.port, '127.0.0.1', () => {
this.running = true
console.log(`[HttpService] HTTP API server started on http://127.0.0.1:${this.port}`)
resolve({ success: true, port: this.port })
})
})
}
/**
* 停止 HTTP 服务
*/
async stop(): Promise<void> {
return new Promise((resolve) => {
if (this.server) {
// 强制关闭所有活动连接
for (const socket of this.connections) {
socket.destroy()
}
this.connections.clear()
this.server.close(() => {
this.running = false
this.server = null
console.log('[HttpService] HTTP API server stopped')
resolve()
})
} else {
this.running = false
resolve()
}
})
}
/**
* 检查服务是否运行
*/
isRunning(): boolean {
return this.running
}
/**
* 获取当前端口
*/
getPort(): number {
return this.port
}
getDefaultMediaExportPath(): string {
return this.getApiMediaExportPath()
}
/**
* 处理 HTTP 请求
*/
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
// 设置 CORS 头
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
if (req.method === 'OPTIONS') {
res.writeHead(204)
res.end()
return
}
const url = new URL(req.url || '/', `http://127.0.0.1:${this.port}`)
const pathname = url.pathname
try {
// 路由处理
if (pathname === '/health' || pathname === '/api/v1/health') {
this.sendJson(res, { status: 'ok' })
} else if (pathname === '/api/v1/messages') {
await this.handleMessages(url, res)
} else if (pathname === '/api/v1/sessions') {
await this.handleSessions(url, res)
} else if (pathname === '/api/v1/contacts') {
await this.handleContacts(url, res)
} else {
this.sendError(res, 404, 'Not Found')
}
} catch (error) {
console.error('[HttpService] Request error:', error)
this.sendError(res, 500, String(error))
}
}
/**
* 批量获取消息(循环游标直到满足 limit
* 绕过 chatService 的单 batch 限制,直接操作 wcdbService 游标
*/
private async fetchMessagesBatch(
talker: string,
offset: number,
limit: number,
startTime: number,
endTime: number,
ascending: boolean
): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; error?: string }> {
try {
// 使用固定 batch 大小(与 limit 相同或最多 500来减少循环次数
const batchSize = Math.min(limit, 500)
const beginTimestamp = startTime > 10000000000 ? Math.floor(startTime / 1000) : startTime
const endTimestamp = endTime > 10000000000 ? Math.floor(endTime / 1000) : endTime
const cursorResult = await wcdbService.openMessageCursor(talker, batchSize, ascending, beginTimestamp, endTimestamp)
if (!cursorResult.success || !cursorResult.cursor) {
return { success: false, error: cursorResult.error || '打开消息游标失败' }
}
const cursor = cursorResult.cursor
try {
const allRows: Record<string, any>[] = []
let hasMore = true
let skipped = 0
// 循环获取消息,处理 offset 跳过 + limit 累积
while (allRows.length < limit && hasMore) {
const batch = await wcdbService.fetchMessageBatch(cursor)
if (!batch.success || !batch.rows || batch.rows.length === 0) {
hasMore = false
break
}
let rows = batch.rows
hasMore = batch.hasMore === true
// 处理 offset跳过前 N 条
if (skipped < offset) {
const remaining = offset - skipped
if (remaining >= rows.length) {
skipped += rows.length
continue
}
rows = rows.slice(remaining)
skipped = offset
}
allRows.push(...rows)
}
const trimmedRows = allRows.slice(0, limit)
const finalHasMore = hasMore || allRows.length > limit
const messages = chatService.mapRowsToMessagesForApi(trimmedRows)
return { success: true, messages, hasMore: finalHasMore }
} finally {
await wcdbService.closeMessageCursor(cursor)
}
} catch (e) {
console.error('[HttpService] fetchMessagesBatch error:', e)
return { success: false, error: String(e) }
}
}
/**
* Query param helpers.
*/
private parseIntParam(value: string | null, defaultValue: number, min: number, max: number): number {
const parsed = parseInt(value || '', 10)
if (!Number.isFinite(parsed)) return defaultValue
return Math.min(Math.max(parsed, min), max)
}
private parseBooleanParam(url: URL, keys: string[], defaultValue: boolean = false): boolean {
for (const key of keys) {
const raw = url.searchParams.get(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)
}
}
private async handleMessages(url: URL, res: http.ServerResponse): Promise<void> {
const talker = (url.searchParams.get('talker') || '').trim()
const limit = this.parseIntParam(url.searchParams.get('limit'), 100, 1, 10000)
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 endParam = url.searchParams.get('end')
const chatlab = this.parseBooleanParam(url, ['chatlab'], false)
const formatParam = (url.searchParams.get('format') || '').trim().toLowerCase()
const format = formatParam || (chatlab ? 'chatlab' : 'json')
const mediaOptions = this.parseMediaOptions(url)
if (!talker) {
this.sendError(res, 400, 'Missing required parameter: talker')
return
}
if (format !== 'json' && format !== 'chatlab') {
this.sendError(res, 400, 'Invalid format, supported: json/chatlab')
return
}
const startTime = this.parseTimeParam(startParam)
const endTime = this.parseTimeParam(endParam, true)
const queryOffset = keyword ? 0 : offset
const queryLimit = keyword ? 10000 : limit
const result = await this.fetchMessagesBatch(talker, queryOffset, queryLimit, startTime, endTime, true)
if (!result.success || !result.messages) {
this.sendError(res, 500, result.error || 'Failed to get messages')
return
}
let messages = result.messages
let hasMore = result.hasMore === true
if (keyword) {
const filtered = messages.filter((msg) => {
const content = (msg.parsedContent || msg.rawContent || '').toLowerCase()
return content.includes(keyword)
})
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
})
}
/**
* 处理会话列表查询
* GET /api/v1/sessions?keyword=xxx&limit=100
*/
private async handleSessions(url: URL, res: http.ServerResponse): Promise<void> {
const keyword = (url.searchParams.get('keyword') || '').trim()
const limit = this.parseIntParam(url.searchParams.get('limit'), 100, 1, 10000)
try {
const sessions = await chatService.getSessions()
if (!sessions.success || !sessions.sessions) {
this.sendError(res, 500, sessions.error || 'Failed to get sessions')
return
}
let filteredSessions = sessions.sessions
if (keyword) {
const lowerKeyword = keyword.toLowerCase()
filteredSessions = sessions.sessions.filter(s =>
s.username.toLowerCase().includes(lowerKeyword) ||
(s.displayName && s.displayName.toLowerCase().includes(lowerKeyword))
)
}
// 应用 limit
const limitedSessions = filteredSessions.slice(0, limit)
this.sendJson(res, {
success: true,
count: limitedSessions.length,
sessions: limitedSessions.map(s => ({
username: s.username,
displayName: s.displayName,
type: s.type,
lastTimestamp: s.lastTimestamp,
unreadCount: s.unreadCount
}))
})
} catch (error) {
this.sendError(res, 500, String(error))
}
}
/**
* 处理联系人查询
* GET /api/v1/contacts?keyword=xxx&limit=100
*/
private async handleContacts(url: URL, res: http.ServerResponse): Promise<void> {
const keyword = (url.searchParams.get('keyword') || '').trim()
const limit = this.parseIntParam(url.searchParams.get('limit'), 100, 1, 10000)
try {
const contacts = await chatService.getContacts()
if (!contacts.success || !contacts.contacts) {
this.sendError(res, 500, contacts.error || 'Failed to get contacts')
return
}
let filteredContacts = contacts.contacts
if (keyword) {
const lowerKeyword = keyword.toLowerCase()
filteredContacts = contacts.contacts.filter(c =>
c.username.toLowerCase().includes(lowerKeyword) ||
(c.nickname && c.nickname.toLowerCase().includes(lowerKeyword)) ||
(c.remark && c.remark.toLowerCase().includes(lowerKeyword)) ||
(c.displayName && c.displayName.toLowerCase().includes(lowerKeyword))
)
}
const limited = filteredContacts.slice(0, limit)
this.sendJson(res, {
success: true,
count: limited.length,
contacts: limited
})
} catch (error) {
this.sendError(res, 500, String(error))
}
}
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 格式,返回秒级时间戳
*/
private parseTimeParam(param: string | null, isEnd: boolean = false): number {
if (!param) return 0
// 纯数字且长度为 8视为 YYYYMMDD
if (/^\d{8}$/.test(param)) {
const year = parseInt(param.slice(0, 4), 10)
const month = parseInt(param.slice(4, 6), 10) - 1
const day = parseInt(param.slice(6, 8), 10)
const date = new Date(year, month, day)
if (isEnd) {
// 结束时间设为当天 23:59:59
date.setHours(23, 59, 59, 999)
}
return Math.floor(date.getTime() / 1000)
}
// 纯数字,视为时间戳
if (/^\d+$/.test(param)) {
const ts = parseInt(param, 10)
// 如果是毫秒级时间戳,转为秒级
return ts > 10000000000 ? Math.floor(ts / 1000) : ts
}
return 0
}
/**
* 获取显示名称
*/
private async getDisplayNames(usernames: string[]): Promise<Record<string, string>> {
try {
const result = await wcdbService.getDisplayNames(usernames)
if (result.success && result.map) {
return result.map
}
} catch (e) {
console.error('[HttpService] Failed to get display names:', e)
}
// 返回空对象,调用方会使用 username 作为备用
return {}
}
/**
* 转换为 ChatLab 格式
*/
private async convertToChatLab(
messages: Message[],
talkerId: string,
talkerName: string,
mediaMap: Map<number, ApiExportedMedia> = new Map()
): Promise<ChatLabData> {
const isGroup = talkerId.endsWith('@chatroom')
const myWxid = this.configService.get('myWxid') || ''
// 收集所有发送者
const senderSet = new Set<string>()
for (const msg of messages) {
if (msg.senderUsername) {
senderSet.add(msg.senderUsername)
}
}
// 获取发送者显示名
const senderNames = await this.getDisplayNames(Array.from(senderSet))
// 获取群昵称(如果是群聊)
let groupNicknamesMap = new Map<string, string>()
if (isGroup) {
try {
const result = await wcdbService.getGroupNicknames(talkerId)
if (result.success && result.nicknames) {
groupNicknamesMap = new Map(Object.entries(result.nicknames))
}
} catch (e) {
console.error('[HttpService] Failed to get group nicknames:', e)
}
}
// 构建成员列表
const memberMap = new Map<string, ChatLabMember>()
for (const msg of messages) {
const sender = msg.senderUsername || ''
if (sender && !memberMap.has(sender)) {
const displayName = senderNames[sender] || sender
const isSelf = sender === myWxid || sender.toLowerCase() === myWxid.toLowerCase()
// 获取群昵称(尝试多种方式)
const groupNickname = isGroup
? (groupNicknamesMap.get(sender) || groupNicknamesMap.get(sender.toLowerCase()) || '')
: ''
memberMap.set(sender, {
platformId: sender,
accountName: isSelf ? '我' : displayName,
groupNickname: groupNickname || undefined
})
}
}
// 转换消息
const chatLabMessages: ChatLabMessage[] = messages.map(msg => {
const sender = msg.senderUsername || ''
const isSelf = msg.isSend === 1 || sender === myWxid
const accountName = isSelf ? '我' : (senderNames[sender] || sender)
// 获取该发送者的群昵称
const groupNickname = isGroup
? (groupNicknamesMap.get(sender) || groupNicknamesMap.get(sender.toLowerCase()) || '')
: ''
return {
sender,
accountName,
groupNickname: groupNickname || undefined,
timestamp: msg.createTime,
type: this.mapMessageType(msg.localType, msg),
content: this.getMessageContent(msg),
platformMessageId: msg.serverId ? String(msg.serverId) : undefined,
mediaPath: mediaMap.get(msg.localId)?.fullPath
}
})
return {
chatlab: {
version: '0.0.2',
exportedAt: Math.floor(Date.now() / 1000),
generator: 'WeFlow'
},
meta: {
name: talkerName,
platform: 'wechat',
type: isGroup ? 'group' : 'private',
groupId: isGroup ? talkerId : undefined,
ownerId: myWxid || undefined
},
members: Array.from(memberMap.values()),
messages: chatLabMessages
}
}
/**
* 映射 WeChat 消息类型到 ChatLab 类型
*/
private mapMessageType(localType: number, msg: Message): number {
switch (localType) {
case 1: // 文本
return ChatLabType.TEXT
case 3: // 图片
return ChatLabType.IMAGE
case 34: // 语音
return ChatLabType.VOICE
case 43: // 视频
return ChatLabType.VIDEO
case 47: // 动画表情
return ChatLabType.EMOJI
case 48: // 位置
return ChatLabType.LOCATION
case 42: // 名片
return ChatLabType.CONTACT
case 50: // 语音/视频通话
return ChatLabType.CALL
case 10000: // 系统消息
return ChatLabType.SYSTEM
case 49: // 复合消息
return this.mapType49(msg)
case 244813135921: // 引用消息
return ChatLabType.REPLY
case 266287972401: // 拍一拍
return ChatLabType.POKE
case 8594229559345: // 红包
return ChatLabType.RED_PACKET
case 8589934592049: // 转账
return ChatLabType.TRANSFER
default:
return ChatLabType.OTHER
}
}
/**
* 映射 Type 49 子类型
*/
private mapType49(msg: Message): number {
const xmlType = msg.xmlType
switch (xmlType) {
case '5': // 链接
case '49':
return ChatLabType.LINK
case '6': // 文件
return ChatLabType.FILE
case '19': // 聊天记录
return ChatLabType.FORWARD
case '33': // 小程序
case '36':
return ChatLabType.SHARE
case '57': // 引用消息
return ChatLabType.REPLY
case '2000': // 转账
return ChatLabType.TRANSFER
case '2001': // 红包
return ChatLabType.RED_PACKET
default:
return ChatLabType.OTHER
}
}
/**
* 获取消息内容
*/
private getMessageContent(msg: Message): string | null {
// 优先使用已解析的内容
if (msg.parsedContent) {
return msg.parsedContent
}
// 根据类型返回占位符
switch (msg.localType) {
case 1:
return msg.rawContent || null
case 3:
return '[图片]'
case 34:
return '[语音]'
case 43:
return '[视频]'
case 47:
return '[表情]'
case 42:
return msg.cardNickname || '[名片]'
case 48:
return '[位置]'
case 49:
return msg.linkTitle || msg.fileName || '[消息]'
default:
return msg.rawContent || null
}
}
/**
* 发送 JSON 响应
*/
private sendJson(res: http.ServerResponse, data: any): void {
res.setHeader('Content-Type', 'application/json; charset=utf-8')
res.writeHead(200)
res.end(JSON.stringify(data, null, 2))
}
/**
* 发送错误响应
*/
private sendError(res: http.ServerResponse, code: number, message: string): void {
res.setHeader('Content-Type', 'application/json; charset=utf-8')
res.writeHead(code)
res.end(JSON.stringify({ error: message }))
}
}
export const httpService = new HttpService()

View File

@@ -11,7 +11,16 @@ import { wcdbService } from './wcdbService'
// 获取 ffmpeg-static 的路径
function getStaticFfmpegPath(): string | null {
try {
// 方法1: 直接 require ffmpeg-static
// 优先处理打包后的路径
if (app.isPackaged) {
const resourcesPath = process.resourcesPath
const packedPath = join(resourcesPath, 'app.asar.unpacked', 'node_modules', 'ffmpeg-static', 'ffmpeg.exe')
if (existsSync(packedPath)) {
return packedPath
}
}
// 方法1: 直接 require ffmpeg-static开发环境
// eslint-disable-next-line @typescript-eslint/no-var-requires
const ffmpegStatic = require('ffmpeg-static')
@@ -19,21 +28,12 @@ function getStaticFfmpegPath(): string | null {
return ffmpegStatic
}
// 方法2: 手动构建路径(开发环境)
// 方法2: 手动构建路径(开发环境备用
const devPath = join(process.cwd(), 'node_modules', 'ffmpeg-static', 'ffmpeg.exe')
if (existsSync(devPath)) {
return devPath
}
// 方法3: 打包后的路径
if (app.isPackaged) {
const resourcesPath = process.resourcesPath
const packedPath = join(resourcesPath, 'app.asar.unpacked', 'node_modules', 'ffmpeg-static', 'ffmpeg.exe')
if (existsSync(packedPath)) {
return packedPath
}
}
return null
} catch {
return null
@@ -380,9 +380,9 @@ export class ImageDecryptService {
}
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
if (suffixMatch) return suffixMatch[1]
return trimmed
const cleaned = suffixMatch ? suffixMatch[1] : trimmed
return cleaned
}
private async resolveDatPath(
@@ -415,10 +415,16 @@ export class ImageDecryptService {
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hardlinkPath)
return hardlinkPath
}
// hardlink 找到的是缩略图,但要求高清图,直接返回 null不再搜索
if (!allowThumbnail && isThumb) {
return null
// hardlink 找到的是缩略图,但要求高清图
// 尝试在同一目录下查找高清图变体(快速查找,不遍历)
const hdPath = this.findHdVariantInSameDir(hardlinkPath)
if (hdPath) {
this.cacheDatPath(accountDir, imageMd5, hdPath)
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdPath)
return hdPath
}
// 没找到高清图,返回 null不进行全局搜索
return null
}
this.logInfo('[ImageDecrypt] hardlink miss (md5)', { imageMd5 })
if (imageDatName && this.looksLikeMd5(imageDatName) && imageDatName !== imageMd5) {
@@ -431,9 +437,13 @@ export class ImageDecryptService {
this.cacheDatPath(accountDir, imageDatName, fallbackPath)
return fallbackPath
}
if (!allowThumbnail && isThumb) {
return null
// 找到缩略图但要求高清图,尝试同目录查找高清图变体
const hdPath = this.findHdVariantInSameDir(fallbackPath)
if (hdPath) {
this.cacheDatPath(accountDir, imageDatName, hdPath)
return hdPath
}
return null
}
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
}
@@ -449,10 +459,13 @@ export class ImageDecryptService {
this.cacheDatPath(accountDir, imageDatName, hardlinkPath)
return hardlinkPath
}
// hardlink 找到的是缩略图,但要求高清图,直接返回 null
if (!allowThumbnail && isThumb) {
return null
// hardlink 找到的是缩略图,但要求高清图
const hdPath = this.findHdVariantInSameDir(hardlinkPath)
if (hdPath) {
this.cacheDatPath(accountDir, imageDatName, hdPath)
return hdPath
}
return null
}
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
}
@@ -467,6 +480,9 @@ export class ImageDecryptService {
const cached = this.resolvedCache.get(imageDatName)
if (cached && existsSync(cached)) {
if (allowThumbnail || !this.isThumbnailPath(cached)) return cached
// 缓存的是缩略图,尝试找高清图
const hdPath = this.findHdVariantInSameDir(cached)
if (hdPath) return hdPath
}
}
@@ -761,6 +777,17 @@ export class ImageDecryptService {
const root = join(accountDir, 'msg', 'attach')
if (!existsSync(root)) return null
// 优化1快速概率性查找
// 包含1. 基于文件名的前缀猜测 (旧版)
// 2. 基于日期的最近月份扫描 (新版无索引时)
const fastHit = await this.fastProbabilisticSearch(root, datName)
if (fastHit) {
this.resolvedCache.set(key, fastHit)
return fastHit
}
// 优化2兜底扫描 (异步非阻塞)
const found = await this.walkForDatInWorker(root, datName.toLowerCase(), 8, allowThumbnail, thumbOnly)
if (found) {
this.resolvedCache.set(key, found)
@@ -769,6 +796,134 @@ export class ImageDecryptService {
return null
}
/**
* 基于文件名的哈希特征猜测可能的路径
* 包含1. 微信旧版结构 filename.substr(0, 2)/...
* 2. 微信新版结构 msg/attach/{hash}/{YYYY-MM}/Img/filename
*/
private async fastProbabilisticSearch(root: string, datName: string): Promise<string | null> {
const { promises: fs } = require('fs')
const { join } = require('path')
try {
// --- 策略 A: 旧版路径猜测 (msg/attach/xx/yy/...) ---
const lowerName = datName.toLowerCase()
let baseName = lowerName
if (baseName.endsWith('.dat')) {
baseName = baseName.slice(0, -4)
if (baseName.endsWith('_t') || baseName.endsWith('.t') || baseName.endsWith('_hd')) {
baseName = baseName.slice(0, -3)
} else if (baseName.endsWith('_thumb')) {
baseName = baseName.slice(0, -6)
}
}
const candidates: string[] = []
if (/^[a-f0-9]{32}$/.test(baseName)) {
const dir1 = baseName.substring(0, 2)
const dir2 = baseName.substring(2, 4)
candidates.push(
join(root, dir1, dir2, datName),
join(root, dir1, dir2, 'Img', datName),
join(root, dir1, dir2, 'mg', datName),
join(root, dir1, dir2, 'Image', datName)
)
}
for (const path of candidates) {
try {
await fs.access(path)
return path
} catch { }
}
// --- 策略 B: 新版 Session 哈希路径猜测 ---
try {
const entries = await fs.readdir(root, { withFileTypes: true })
const sessionDirs = entries
.filter((e: any) => e.isDirectory() && e.name.length === 32 && /^[a-f0-9]+$/i.test(e.name))
.map((e: any) => e.name)
if (sessionDirs.length === 0) return null
const now = new Date()
const months: string[] = []
for (let i = 0; i < 2; i++) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
const mStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
months.push(mStr)
}
const targetNames = [datName]
if (baseName !== lowerName) {
targetNames.push(`${baseName}.dat`)
targetNames.push(`${baseName}_t.dat`)
targetNames.push(`${baseName}_thumb.dat`)
}
const batchSize = 20
for (let i = 0; i < sessionDirs.length; i += batchSize) {
const batch = sessionDirs.slice(i, i + batchSize)
const tasks = batch.map(async (sessDir: string) => {
for (const month of months) {
const subDirs = ['Img', 'Image']
for (const sub of subDirs) {
const dirPath = join(root, sessDir, month, sub)
try { await fs.access(dirPath) } catch { continue }
for (const name of targetNames) {
const p = join(dirPath, name)
try { await fs.access(p); return p } catch { }
}
}
}
return null
})
const results = await Promise.all(tasks)
const hit = results.find(r => r !== null)
if (hit) return hit
}
} catch { }
} catch { }
return null
}
/**
* 在同一目录下查找高清图变体
* 缩略图: xxx_t.dat -> 高清图: xxx_h.dat 或 xxx.dat
*/
private findHdVariantInSameDir(thumbPath: string): string | null {
try {
const dir = dirname(thumbPath)
const fileName = basename(thumbPath).toLowerCase()
// 提取基础名称(去掉 _t.dat 或 .t.dat
let baseName = fileName
if (baseName.endsWith('_t.dat')) {
baseName = baseName.slice(0, -6)
} else if (baseName.endsWith('.t.dat')) {
baseName = baseName.slice(0, -6)
} else {
return null
}
// 尝试查找高清图变体
const variants = [
`${baseName}_h.dat`,
`${baseName}.h.dat`,
`${baseName}.dat`
]
for (const variant of variants) {
const variantPath = join(dir, variant)
if (existsSync(variantPath)) {
return variantPath
}
}
} catch { }
return null
}
private async searchDatFileInDir(
dirPath: string,
datName: string,
@@ -1136,7 +1291,7 @@ export class ImageDecryptService {
// 扫描所有可能的缓存根目录
const allRoots = this.getAllCacheRoots()
this.logInfo('开始索引缓存', { roots: allRoots.length })
for (const root of allRoots) {
try {
this.indexCacheDir(root, 3, 0) // 增加深度到3支持 sessionId/YYYY-MM 结构
@@ -1144,7 +1299,7 @@ export class ImageDecryptService {
this.logError('索引目录失败', e, { root })
}
}
this.logInfo('缓存索引完成', { entries: this.resolvedCache.size })
this.cacheIndexed = true
this.cacheIndexing = null
@@ -1175,7 +1330,7 @@ export class ImageDecryptService {
// 默认路径
roots.push(join(documentsPath, 'WeFlow', 'Images'))
roots.push(join(documentsPath, 'WeFlow', 'images'))
// 兼容旧路径(如果有的话)
roots.push(join(documentsPath, 'WeFlowData', 'Images'))

View File

@@ -0,0 +1,127 @@
/**
* ISAAC-64: A fast cryptographic PRNG
* Re-implemented in TypeScript using BigInt for 64-bit support.
* Used for WeChat Channels/SNS video decryption.
*/
export class Isaac64 {
private mm = new BigUint64Array(256);
private aa = 0n;
private bb = 0n;
private cc = 0n;
private randrsl = new BigUint64Array(256);
private randcnt = 0;
private static readonly MASK = 0xFFFFFFFFFFFFFFFFn;
constructor(seed: number | string | bigint) {
const seedBig = BigInt(seed);
// 通常单密钥初始化是将密钥放在第一个槽位,其余清零(或者按某种规律填充)
// 这里我们尝试仅设置第一个槽位,这在很多 WASM 移植版本中更为常见
this.randrsl.fill(0n);
this.randrsl[0] = seedBig;
this.init(true);
}
private init(flag: boolean) {
let a: bigint, b: bigint, c: bigint, d: bigint, e: bigint, f: bigint, g: bigint, h: bigint;
a = b = c = d = e = f = g = h = 0x9e3779b97f4a7c15n;
const mix = () => {
a = (a - e) & Isaac64.MASK; f ^= (h >> 9n); h = (h + a) & Isaac64.MASK;
b = (b - f) & Isaac64.MASK; g ^= (a << 9n) & Isaac64.MASK; a = (a + b) & Isaac64.MASK;
c = (c - g) & Isaac64.MASK; h ^= (b >> 23n); b = (b + c) & Isaac64.MASK;
d = (d - h) & Isaac64.MASK; a ^= (c << 15n) & Isaac64.MASK; c = (c + d) & Isaac64.MASK;
e = (e - a) & Isaac64.MASK; b ^= (d >> 14n); d = (d + e) & Isaac64.MASK;
f = (f - b) & Isaac64.MASK; c ^= (e << 20n) & Isaac64.MASK; e = (e + f) & Isaac64.MASK;
g = (g - c) & Isaac64.MASK; d ^= (f >> 17n); f = (f + g) & Isaac64.MASK;
h = (h - d) & Isaac64.MASK; e ^= (g << 14n) & Isaac64.MASK; g = (g + h) & Isaac64.MASK;
};
for (let i = 0; i < 4; i++) mix();
for (let i = 0; i < 256; i += 8) {
if (flag) {
a = (a + this.randrsl[i]) & Isaac64.MASK;
b = (b + this.randrsl[i + 1]) & Isaac64.MASK;
c = (c + this.randrsl[i + 2]) & Isaac64.MASK;
d = (d + this.randrsl[i + 3]) & Isaac64.MASK;
e = (e + this.randrsl[i + 4]) & Isaac64.MASK;
f = (f + this.randrsl[i + 5]) & Isaac64.MASK;
g = (g + this.randrsl[i + 6]) & Isaac64.MASK;
h = (h + this.randrsl[i + 7]) & Isaac64.MASK;
}
mix();
this.mm[i] = a; this.mm[i + 1] = b; this.mm[i + 2] = c; this.mm[i + 3] = d;
this.mm[i + 4] = e; this.mm[i + 5] = f; this.mm[i + 6] = g; this.mm[i + 7] = h;
}
if (flag) {
for (let i = 0; i < 256; i += 8) {
a = (a + this.mm[i]) & Isaac64.MASK;
b = (b + this.mm[i + 1]) & Isaac64.MASK;
c = (c + this.mm[i + 2]) & Isaac64.MASK;
d = (d + this.mm[i + 3]) & Isaac64.MASK;
e = (e + this.mm[i + 4]) & Isaac64.MASK;
f = (f + this.mm[i + 5]) & Isaac64.MASK;
g = (g + this.mm[i + 6]) & Isaac64.MASK;
h = (h + this.mm[i + 7]) & Isaac64.MASK;
mix();
this.mm[i] = a; this.mm[i + 1] = b; this.mm[i + 2] = c; this.mm[i + 3] = d;
this.mm[i + 4] = e; this.mm[i + 5] = f; this.mm[i + 6] = g; this.mm[i + 7] = h;
}
}
this.isaac64();
this.randcnt = 256;
}
private isaac64() {
this.cc = (this.cc + 1n) & Isaac64.MASK;
this.bb = (this.bb + this.cc) & Isaac64.MASK;
for (let i = 0; i < 256; i++) {
let x = this.mm[i];
switch (i & 3) {
case 0: this.aa = (this.aa ^ (((this.aa << 21n) & Isaac64.MASK) ^ Isaac64.MASK)) & Isaac64.MASK; break;
case 1: this.aa = (this.aa ^ (this.aa >> 5n)) & Isaac64.MASK; break;
case 2: this.aa = (this.aa ^ ((this.aa << 12n) & Isaac64.MASK)) & Isaac64.MASK; break;
case 3: this.aa = (this.aa ^ (this.aa >> 33n)) & Isaac64.MASK; break;
}
this.aa = (this.mm[(i + 128) & 255] + this.aa) & Isaac64.MASK;
const y = (this.mm[Number(x >> 3n) & 255] + this.aa + this.bb) & Isaac64.MASK;
this.mm[i] = y;
this.bb = (this.mm[Number(y >> 11n) & 255] + x) & Isaac64.MASK;
this.randrsl[i] = this.bb;
}
}
public getNext(): bigint {
if (this.randcnt === 0) {
this.isaac64();
this.randcnt = 256;
}
return this.randrsl[--this.randcnt];
}
/**
* Generates a keystream where each 64-bit block is Big-Endian.
* This matches WeChat's behavior (Reverse index order + byte reversal).
*/
public generateKeystreamBE(size: number): Buffer {
const buffer = Buffer.allocUnsafe(size);
const fullBlocks = Math.floor(size / 8);
for (let i = 0; i < fullBlocks; i++) {
buffer.writeBigUInt64BE(this.getNext(), i * 8);
}
const remaining = size % 8;
if (remaining > 0) {
const lastK = this.getNext();
const temp = Buffer.allocUnsafe(8);
temp.writeBigUInt64BE(lastK, 0);
temp.copy(buffer, fullBlocks * 8, 0, remaining);
}
return buffer;
}
}

View File

@@ -116,13 +116,13 @@ export class KeyService {
// 检查是否已经有本地副本,如果有就使用它
if (existsSync(localPath)) {
console.log(`使用已存在的 DLL 本地副本: ${localPath}`)
return localPath
}
console.log(`检测到网络路径 DLL正在复制到本地: ${originalPath} -> ${localPath}`)
copyFileSync(originalPath, localPath)
console.log('DLL 本地化成功')
return localPath
} catch (e) {
console.error('DLL 本地化失败:', e)
@@ -146,7 +146,7 @@ export class KeyService {
// 检查是否为网络路径,如果是则本地化
if (this.isNetworkPath(dllPath)) {
console.log('检测到网络路径,将进行本地化处理')
dllPath = this.localizeNetworkDll(dllPath)
}
@@ -347,7 +347,7 @@ export class KeyService {
if (pid) {
const runPath = await this.getProcessExecutablePath(pid)
if (runPath && existsSync(runPath)) {
console.log('发现正在运行的微信进程,使用路径:', runPath)
return runPath
}
}

View File

@@ -0,0 +1,371 @@
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

@@ -1,6 +1,7 @@
import { join, dirname } from 'path'
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
import { app } from 'electron'
import { ConfigService } from './config'
export interface SessionMessageCacheEntry {
updatedAt: number
@@ -15,7 +16,7 @@ export class MessageCacheService {
constructor(cacheBasePath?: string) {
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
? cacheBasePath
: join(app.getPath('documents'), 'WeFlow')
: ConfigService.getInstance().getCacheBasePath()
this.cacheFilePath = join(basePath, 'session-messages.json')
this.ensureCacheDir()
this.loadCache()

View File

@@ -1,6 +1,11 @@
import { wcdbService } from './wcdbService'
import { ConfigService } from './config'
import { ContactCacheService } from './contactCacheService'
import { existsSync, mkdirSync } from 'fs'
import { readFile, writeFile, mkdir } from 'fs/promises'
import { basename, join } from 'path'
import crypto from 'crypto'
import { WasmService } from './wasmService'
export interface SnsLivePhoto {
url: string
@@ -32,86 +37,149 @@ export interface SnsPost {
media: SnsMedia[]
likes: string[]
comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }[]
rawXml?: string // 原始 XML 数据
rawXml?: string
linkTitle?: string
linkUrl?: string
}
const fixSnsUrl = (url: string, token?: string) => {
if (!url) return url;
// 1. 统一使用 https
// 2. 将 /150 (缩略图) 强制改为 /0 (原图)
let fixedUrl = url.replace('http://', 'https://').replace(/\/150($|\?)/, '/0$1');
if (!token || fixedUrl.includes('token=')) return fixedUrl;
const fixSnsUrl = (url: string, token?: string, isVideo: boolean = false) => {
if (!url) return url
const connector = fixedUrl.includes('?') ? '&' : '?';
return `${fixedUrl}${connector}token=${token}&idx=1`;
};
let fixedUrl = url.replace('http://', 'https://')
// 只有非视频(即图片)才需要处理 /150 变 /0
if (!isVideo) {
fixedUrl = fixedUrl.replace(/\/150($|\?)/, '/0$1')
}
if (!token || fixedUrl.includes('token=')) return fixedUrl
// 根据用户要求,视频链接组合方式为: BASE_URL + "?" + "token=" + token + "&idx=1" + 原有参数
if (isVideo) {
const urlParts = fixedUrl.split('?')
const baseUrl = urlParts[0]
const existingParams = urlParts[1] ? `&${urlParts[1]}` : ''
return `${baseUrl}?token=${token}&idx=1${existingParams}`
}
const connector = fixedUrl.includes('?') ? '&' : '?'
return `${fixedUrl}${connector}token=${token}&idx=1`
}
const detectImageMime = (buf: Buffer, fallback: string = 'image/jpeg') => {
if (!buf || buf.length < 4) return fallback
// JPEG
if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'image/jpeg'
// PNG
if (
buf.length >= 8 &&
buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47 &&
buf[4] === 0x0d && buf[5] === 0x0a && buf[6] === 0x1a && buf[7] === 0x0a
) return 'image/png'
// GIF
if (buf.length >= 6) {
const sig = buf.subarray(0, 6).toString('ascii')
if (sig === 'GIF87a' || sig === 'GIF89a') return 'image/gif'
}
// WebP
if (
buf.length >= 12 &&
buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46 &&
buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50
) return 'image/webp'
// BMP
if (buf[0] === 0x42 && buf[1] === 0x4d) return 'image/bmp'
// MP4: 00 00 00 18 / 20 / ... + 'ftyp'
if (buf.length > 8 && buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70) return 'video/mp4'
// Fallback logic for video
if (fallback.includes('video') || fallback.includes('mp4')) return 'video/mp4'
return fallback
}
export const isVideoUrl = (url: string) => {
if (!url) return false
// 排除 vweixinthumb 域名 (缩略图)
if (url.includes('vweixinthumb')) return false
return url.includes('snsvideodownload') || url.includes('video') || url.includes('.mp4')
}
import { Isaac64 } from './isaac64'
const extractVideoKey = (xml: string): string | undefined => {
if (!xml) return undefined
// 匹配 <enc key="2105122989" ... /> 或 <enc key="2105122989">
const match = xml.match(/<enc\s+key="(\d+)"/i)
return match ? match[1] : undefined
}
class SnsService {
private configService: ConfigService
private contactCache: ContactCacheService
private imageCache = new Map<string, string>()
constructor() {
const config = new ConfigService()
this.contactCache = new ContactCacheService(config.get('cachePath') as string)
this.configService = new ConfigService()
this.contactCache = new ContactCacheService(this.configService.get('cachePath') as string)
}
private getSnsCacheDir(): string {
const cachePath = this.configService.getCacheBasePath()
const snsCacheDir = join(cachePath, 'sns_cache')
if (!existsSync(snsCacheDir)) {
mkdirSync(snsCacheDir, { recursive: true })
}
return snsCacheDir
}
private getCacheFilePath(url: string): string {
const hash = crypto.createHash('md5').update(url).digest('hex')
const ext = isVideoUrl(url) ? '.mp4' : '.jpg'
return join(this.getSnsCacheDir(), `${hash}${ext}`)
}
async getTimeline(limit: number = 20, offset: number = 0, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: SnsPost[]; error?: string }> {
console.log('[SnsService] getTimeline called with:', { limit, offset, usernames, keyword, startTime, endTime })
const result = await wcdbService.getSnsTimeline(limit, offset, usernames, keyword, startTime, endTime)
console.log('[SnsService] getSnsTimeline result:', {
success: result.success,
timelineCount: result.timeline?.length,
error: result.error
})
if (result.success && result.timeline) {
const enrichedTimeline = result.timeline.map((post: any, index: number) => {
const enrichedTimeline = result.timeline.map((post: any) => {
const contact = this.contactCache.get(post.username)
const isVideoPost = post.type === 15
// 修复媒体 URL
const fixedMedia = post.media.map((m: any, mIdx: number) => {
const base = {
url: fixSnsUrl(m.url, m.token),
thumb: fixSnsUrl(m.thumb, m.token),
md5: m.md5,
token: m.token,
key: m.key,
encIdx: m.encIdx || m.enc_idx, // 兼容不同命名
livePhoto: m.livePhoto ? {
// 尝试从 rawXml 中提取视频解密密钥 (针对视频号视频)
const videoKey = extractVideoKey(post.rawXml || '')
const fixedMedia = (post.media || []).map((m: any) => ({
// 如果是视频动态url 是视频thumb 是缩略图
url: fixSnsUrl(m.url, m.token, isVideoPost),
thumb: fixSnsUrl(m.thumb, m.token, false),
md5: m.md5,
token: m.token,
// 只有在视频动态 (Type 15) 下才尝试将 XML 提取的 videoKey 赋予主媒体
// 对于图片或实况照片的静态部分,应保留原始 m.key (由 DLL/DB 提供),避免由于错误的 Isaac64 密钥导致图片解密损坏
key: isVideoPost ? (videoKey || m.key) : m.key,
encIdx: m.encIdx || m.enc_idx,
livePhoto: m.livePhoto
? {
...m.livePhoto,
url: fixSnsUrl(m.livePhoto.url, m.livePhoto.token),
thumb: fixSnsUrl(m.livePhoto.thumb, m.livePhoto.token),
url: fixSnsUrl(m.livePhoto.url, m.livePhoto.token, true),
thumb: fixSnsUrl(m.livePhoto.thumb, m.livePhoto.token, false),
token: m.livePhoto.token,
key: m.livePhoto.key
} : undefined
}
// [MOCK] 模拟数据:如果后端没返回 key (说明 DLL 未更新),注入一些 Mock 数据以便前端开发
if (!base.key) {
base.key = 'mock_key_for_dev'
if (!base.token) {
base.token = 'mock_token_for_dev'
base.url = fixSnsUrl(base.url, base.token)
base.thumb = fixSnsUrl(base.thumb, base.token)
// 实况照片的视频部分优先使用从 XML 提取的 Key
key: videoKey || m.livePhoto.key || m.key,
encIdx: m.livePhoto.encIdx || m.livePhoto.enc_idx
}
base.encIdx = '1'
// 强制给第一个帖子的第一张图加 LivePhoto 模拟
if (index === 0 && mIdx === 0 && !base.livePhoto) {
base.livePhoto = {
url: fixSnsUrl('https://tm.sh/d4cb0.mp4', 'mock_live_token'),
thumb: base.thumb,
token: 'mock_live_token',
key: 'mock_live_key'
}
}
}
return base
})
: undefined
}))
return {
...post,
@@ -120,20 +188,15 @@ class SnsService {
media: fixedMedia
}
})
console.log('[SnsService] Returning enriched timeline with', enrichedTimeline.length, 'posts')
return { ...result, timeline: enrichedTimeline }
}
console.log('[SnsService] Returning result:', result)
return result
}
async debugResource(url: string): Promise<{ success: boolean; status?: number; headers?: any; error?: string }> {
return new Promise((resolve) => {
try {
const { app, net } = require('electron')
// Remove mocking 'require' if it causes issues, but here we need 'net' or 'https'
// implementing with 'https' for reliability if 'net' is main-process only special
const https = require('https')
const urlObj = new URL(url)
@@ -142,13 +205,12 @@ class SnsService {
path: urlObj.pathname + urlObj.search,
method: 'GET',
headers: {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351",
"Accept": "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
"Accept-Encoding": "gzip, deflate, br",
"Accept-Language": "zh-CN,zh;q=0.9",
"Referer": "https://servicewechat.com/",
"Connection": "keep-alive",
"Range": "bytes=0-10" // Keep our range check
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351',
'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
'Accept-Encoding': 'gzip, deflate, br',
'Accept-Language': 'zh-CN,zh;q=0.9',
'Connection': 'keep-alive',
'Range': 'bytes=0-10'
}
}
@@ -158,17 +220,15 @@ class SnsService {
status: res.statusCode,
headers: {
'x-enc': res.headers['x-enc'],
'x-time': res.headers['x-time'],
'content-length': res.headers['content-length'],
'content-type': res.headers['content-type']
}
})
req.destroy() // We only need headers
})
req.on('error', (e: any) => {
resolve({ success: false, error: e.message })
req.destroy()
})
req.on('error', (e: any) => resolve({ success: false, error: e.message }))
req.end()
} catch (e: any) {
resolve({ success: false, error: e.message })
@@ -176,14 +236,528 @@ class SnsService {
})
}
private imageCache = new Map<string, string>()
async proxyImage(url: string): Promise<{ success: boolean; dataUrl?: string; error?: string }> {
// Check cache
if (this.imageCache.has(url)) {
return { success: true, dataUrl: this.imageCache.get(url) }
async proxyImage(url: string, key?: string | number): Promise<{ success: boolean; dataUrl?: string; videoPath?: string; error?: string }> {
if (!url) return { success: false, error: 'url 不能为空' }
const cacheKey = `${url}|${key ?? ''}`
if (this.imageCache.has(cacheKey)) {
return { success: true, dataUrl: this.imageCache.get(cacheKey) }
}
const result = await this.fetchAndDecryptImage(url, key)
if (result.success) {
// 如果是视频,返回本地文件路径 (需配合 webSecurity: false 或自定义协议)
if (result.contentType?.startsWith('video/')) {
// Return cachePath directly for video
// 注意fetchAndDecryptImage 需要修改以返回 cachePath
return { success: true, videoPath: result.cachePath }
}
if (result.data && result.contentType) {
const dataUrl = `data:${result.contentType};base64,${result.data.toString('base64')}`
this.imageCache.set(cacheKey, dataUrl)
return { success: true, dataUrl }
}
}
return { success: false, error: result.error }
}
async downloadImage(url: string, key?: string | number): Promise<{ success: boolean; data?: Buffer; contentType?: string; error?: string }> {
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 }> {
if (!url) return { success: false, error: 'url 不能为空' }
const isVideo = isVideoUrl(url)
const cachePath = this.getCacheFilePath(url)
// 1. 尝试从磁盘缓存读取
if (existsSync(cachePath)) {
try {
// 对于视频,不读取整个文件到内存,只确认存在即可
if (isVideo) {
return { success: true, cachePath, contentType: 'video/mp4' }
}
const data = await readFile(cachePath)
const contentType = detectImageMime(data)
return { success: true, data, contentType, cachePath }
} catch (e) {
console.warn(`[SnsService] 读取缓存失败: ${cachePath}`, e)
}
}
if (isVideo) {
// 视频专用下载逻辑 (下载 -> 解密 -> 缓存)
return new Promise(async (resolve) => {
const tmpPath = join(require('os').tmpdir(), `sns_video_${Date.now()}_${Math.random().toString(36).slice(2)}.enc`)
try {
const https = require('https')
const urlObj = new URL(url)
const fs = require('fs')
const fileStream = fs.createWriteStream(tmpPath)
const options = {
hostname: urlObj.hostname,
path: urlObj.pathname + urlObj.search,
method: 'GET',
headers: {
'User-Agent': 'MicroMessenger Client',
'Accept': '*/*',
// 'Accept-Encoding': 'gzip, deflate, br', // 视频流通常不压缩,去掉以免 stream 处理复杂
'Connection': 'keep-alive'
}
}
const req = https.request(options, (res: any) => {
if (res.statusCode !== 200 && res.statusCode !== 206) {
fileStream.close()
fs.unlink(tmpPath, () => { }) // 删除临时文件
resolve({ success: false, error: `HTTP ${res.statusCode}` })
return
}
res.pipe(fileStream)
fileStream.on('finish', async () => {
fileStream.close()
try {
const encryptedBuffer = await readFile(tmpPath)
const raw = encryptedBuffer // 引用,方便后续操作
if (key && String(key).trim().length > 0) {
try {
const keyText = String(key).trim()
let keystream: Buffer
try {
const wasmService = WasmService.getInstance()
// 只需要前 128KB (131072 bytes) 用于解密头部
keystream = await wasmService.getKeystream(keyText, 131072)
} catch (wasmErr) {
// 打包漏带 wasm 或 wasm 初始化异常时,回退到纯 TS ISAAC64
const isaac = new Isaac64(keyText)
keystream = isaac.generateKeystreamBE(131072)
}
const decryptLen = Math.min(keystream.length, raw.length)
// XOR 解密
for (let i = 0; i < decryptLen; i++) {
raw[i] ^= keystream[i]
}
// 验证 MP4 签名 ('ftyp' at offset 4)
const ftyp = raw.subarray(4, 8).toString('ascii')
if (ftyp !== 'ftyp') {
// 可以在此处记录解密可能失败的标记,但不打印详细 hex
}
} catch (err) {
console.error(`[SnsService] 视频解密出错: ${err}`)
}
}
// 写入最终缓存 (覆盖)
await writeFile(cachePath, raw)
// 删除临时文件
try { await import('fs/promises').then(fs => fs.unlink(tmpPath)) } catch (e) { }
resolve({ success: true, data: raw, contentType: 'video/mp4', cachePath })
} catch (e: any) {
console.error(`[SnsService] 视频处理失败:`, e)
resolve({ success: false, error: e.message })
}
})
})
req.on('error', (e: any) => {
fs.unlink(tmpPath, () => { })
resolve({ success: false, error: e.message })
})
req.setTimeout(15000, () => {
req.destroy()
fs.unlink(tmpPath, () => { })
resolve({ success: false, error: '请求超时' })
})
req.end()
} catch (e: any) {
resolve({ success: false, error: e.message })
}
})
}
// 图片逻辑 (保持流式处理)
return new Promise((resolve) => {
try {
const https = require('https')
@@ -195,17 +769,16 @@ class SnsService {
path: urlObj.pathname + urlObj.search,
method: 'GET',
headers: {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351",
"Accept": "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
"Accept-Encoding": "gzip, deflate, br",
"Accept-Language": "zh-CN,zh;q=0.9",
"Referer": "https://servicewechat.com/",
"Connection": "keep-alive"
'User-Agent': 'MicroMessenger Client',
'Accept': '*/*',
'Accept-Encoding': 'gzip, deflate, br',
'Accept-Language': 'zh-CN,zh;q=0.9',
'Connection': 'keep-alive'
}
}
const req = https.request(options, (res: any) => {
if (res.statusCode !== 200) {
if (res.statusCode !== 200 && res.statusCode !== 206) {
resolve({ success: false, error: `HTTP ${res.statusCode}` })
return
}
@@ -213,37 +786,59 @@ class SnsService {
const chunks: Buffer[] = []
let stream = res
// Handle gzip compression
const encoding = res.headers['content-encoding']
if (encoding === 'gzip') {
stream = res.pipe(zlib.createGunzip())
} else if (encoding === 'deflate') {
stream = res.pipe(zlib.createInflate())
} else if (encoding === 'br') {
stream = res.pipe(zlib.createBrotliDecompress())
}
if (encoding === 'gzip') stream = res.pipe(zlib.createGunzip())
else if (encoding === 'deflate') stream = res.pipe(zlib.createInflate())
else if (encoding === 'br') stream = res.pipe(zlib.createBrotliDecompress())
stream.on('data', (chunk: Buffer) => chunks.push(chunk))
stream.on('end', () => {
const buffer = Buffer.concat(chunks)
const contentType = res.headers['content-type'] || 'image/jpeg'
const base64 = buffer.toString('base64')
const dataUrl = `data:${contentType};base64,${base64}`
stream.on('end', async () => {
const raw = Buffer.concat(chunks)
const xEnc = String(res.headers['x-enc'] || '').trim()
// Cache
this.imageCache.set(url, dataUrl)
let decoded = raw
resolve({ success: true, dataUrl })
})
stream.on('error', (e: any) => {
resolve({ success: false, error: e.message })
// 图片逻辑
const shouldDecrypt = (xEnc === '1' || !!key) && key !== undefined && key !== null && String(key).trim().length > 0
if (shouldDecrypt) {
try {
const keyStr = String(key).trim()
if (/^\d+$/.test(keyStr)) {
// 使用 WASM 版本的 Isaac64 解密图片
// 修正逻辑:使用带 reverse 且修正了 8字节对齐偏移的 getKeystream
const wasmService = WasmService.getInstance()
const keystream = await wasmService.getKeystream(keyStr, raw.length)
const decrypted = Buffer.allocUnsafe(raw.length)
for (let i = 0; i < raw.length; i++) {
decrypted[i] = raw[i] ^ keystream[i]
}
decoded = decrypted
}
} catch (e) {
console.error('[SnsService] TS Decrypt Error:', e)
}
}
// 写入磁盘缓存
try {
await writeFile(cachePath, decoded)
} catch (e) {
console.warn(`[SnsService] 写入缓存失败: ${cachePath}`, e)
}
const contentType = detectImageMime(decoded, (res.headers['content-type'] || 'image/jpeg') as string)
resolve({ success: true, data: decoded, contentType, cachePath })
})
stream.on('error', (e: any) => resolve({ success: false, error: e.message }))
})
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()
} catch (e: any) {
resolve({ success: false, error: e.message })

View File

@@ -36,7 +36,7 @@ class VideoService {
* 获取缓存目录(解密后的数据库存放位置)
*/
private getCachePath(): string {
return this.configService.get('cachePath') || ''
return this.configService.getCacheBasePath()
}
/**
@@ -97,7 +97,7 @@ class VideoService {
return realMd5
}
} catch (e) {
// Silently fail
// 忽略错误
}
}
}
@@ -105,19 +105,30 @@ class VideoService {
// 方法2使用 wcdbService.execQuery 查询加密的 hardlink.db
if (dbPath) {
const encryptedDbPaths = [
join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'),
join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db')
]
// 检查 dbPath 是否已经包含 wxid
const dbPathLower = dbPath.toLowerCase()
const wxidLower = wxid.toLowerCase()
const cleanedWxidLower = cleanedWxid.toLowerCase()
const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower)
const encryptedDbPaths: string[] = []
if (dbPathContainsWxid) {
// dbPath 已包含 wxid不需要再拼接
encryptedDbPaths.push(join(dbPath, 'db_storage', 'hardlink', 'hardlink.db'))
} else {
// dbPath 不包含 wxid需要拼接
encryptedDbPaths.push(join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'))
encryptedDbPaths.push(join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db'))
}
for (const p of encryptedDbPaths) {
if (existsSync(p)) {
try {
const escapedMd5 = md5.replace(/'/g, "''")
// 用 md5 字段查询,获取 file_name
const sql = `SELECT file_name FROM video_hardlink_info_v4 WHERE md5 = '${escapedMd5}' LIMIT 1`
const result = await wcdbService.execQuery('media', p, sql)
if (result.success && result.rows && result.rows.length > 0) {
@@ -129,6 +140,7 @@ class VideoService {
}
}
} catch (e) {
// 忽略错误
}
}
}
@@ -155,7 +167,6 @@ class VideoService {
* 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg
*/
async getVideoInfo(videoMd5: string): Promise<VideoInfo> {
const dbPath = this.getDbPath()
const wxid = this.getMyWxid()
@@ -166,7 +177,19 @@ class VideoService {
// 先尝试从数据库查询真正的视频文件名
const realVideoMd5 = await this.queryVideoFileName(videoMd5) || videoMd5
const videoBaseDir = join(dbPath, wxid, 'msg', 'video')
// 检查 dbPath 是否已经包含 wxid避免重复拼接
const dbPathLower = dbPath.toLowerCase()
const wxidLower = wxid.toLowerCase()
const cleanedWxid = this.cleanWxid(wxid)
let videoBaseDir: string
if (dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxid.toLowerCase())) {
// dbPath 已经包含 wxid直接使用
videoBaseDir = join(dbPath, 'msg', 'video')
} else {
// dbPath 不包含 wxid需要拼接
videoBaseDir = join(dbPath, wxid, 'msg', 'video')
}
if (!existsSync(videoBaseDir)) {
return { exists: false }
@@ -202,7 +225,7 @@ class VideoService {
}
}
} catch (e) {
console.error('[VideoService] Error searching for video:', e)
// 忽略错误
}
return { exists: false }
@@ -212,7 +235,7 @@ class VideoService {
* 根据消息内容解析视频MD5
*/
parseVideoMd5(content: string): string | undefined {
// 打印前500字符看看 XML 结构
if (!content) return undefined
@@ -229,7 +252,7 @@ class VideoService {
// 提取 md5用于查询 hardlink.db
// 注意:不是 rawmd5rawmd5 是另一个值
// 格式: md5="xxx" 或 <md5>xxx</md5>
// 尝试从videomsg标签中提取md5
const videoMsgMatch = /<videomsg[^>]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
if (videoMsgMatch) {

View File

@@ -1,5 +1,5 @@
import { app } from 'electron'
import { existsSync, mkdirSync, statSync, unlinkSync, createWriteStream } from 'fs'
import { existsSync, mkdirSync, statSync, unlinkSync, createWriteStream, openSync, writeSync, closeSync } from 'fs'
import { join } from 'path'
import * as https from 'https'
import * as http from 'http'
@@ -24,6 +24,7 @@ type DownloadProgress = {
downloadedBytes: number
totalBytes?: number
percent?: number
speed?: number
}
const SENSEVOICE_MODEL: ModelInfo = {
@@ -123,44 +124,44 @@ export class VoiceTranscribeService {
percent: 0
})
// 下载模型文件 (40%)
// 下载模型文件 (80% 权重)
console.info('[VoiceTranscribe] 开始下载模型文件...')
await this.downloadToFile(
MODEL_DOWNLOAD_URLS.model,
modelPath,
'model',
(downloaded, total) => {
const percent = total ? (downloaded / total) * 40 : undefined
(downloaded, total, speed) => {
const percent = total ? (downloaded / total) * 80 : 0
onProgress?.({
modelName: SENSEVOICE_MODEL.name,
downloadedBytes: downloaded,
totalBytes: SENSEVOICE_MODEL.sizeBytes,
percent
percent,
speed
})
}
)
// 下载 tokens 文件 (30%)
// 下载 tokens 文件 (20% 权重)
console.info('[VoiceTranscribe] 开始下载 tokens 文件...')
await this.downloadToFile(
MODEL_DOWNLOAD_URLS.tokens,
tokensPath,
'tokens',
(downloaded, total) => {
(downloaded, total, speed) => {
const modelSize = existsSync(modelPath) ? statSync(modelPath).size : 0
const percent = total ? 40 + (downloaded / total) * 30 : 40
const percent = total ? 80 + (downloaded / total) * 20 : 80
onProgress?.({
modelName: SENSEVOICE_MODEL.name,
downloadedBytes: modelSize + downloaded,
totalBytes: SENSEVOICE_MODEL.sizeBytes,
percent
percent,
speed
})
}
)
console.info('[VoiceTranscribe] 模型下载完成')
console.info('[VoiceTranscribe] 所有文件下载完成')
return { success: true, modelPath, tokensPath }
} catch (error) {
const modelPath = this.resolveModelPath(SENSEVOICE_MODEL.files.model)
@@ -180,7 +181,7 @@ export class VoiceTranscribeService {
}
/**
* 转写 WAV 音频数据 (后台 Worker Threads 版本)
* 转写 WAV 音频数据
*/
async transcribeWavBuffer(
wavData: Buffer,
@@ -197,18 +198,15 @@ export class VoiceTranscribeService {
return
}
// 获取配置的语言列表,如果没有传入则从配置读取
let supportedLanguages = languages
if (!supportedLanguages || supportedLanguages.length === 0) {
supportedLanguages = this.configService.get('transcribeLanguages')
// 如果配置中也没有或为空,使用默认值
if (!supportedLanguages || supportedLanguages.length === 0) {
supportedLanguages = ['zh', 'yue']
}
}
const { Worker } = require('worker_threads')
// main.js 和 transcribeWorker.js 同在 dist-electron 目录下
const workerPath = join(__dirname, 'transcribeWorker.js')
const worker = new Worker(workerPath, {
@@ -224,12 +222,10 @@ export class VoiceTranscribeService {
let finalTranscript = ''
worker.on('message', (msg: any) => {
console.log('[VoiceTranscribe] Worker 消息:', msg)
if (msg.type === 'partial') {
onPartial?.(msg.text)
} else if (msg.type === 'final') {
finalTranscript = msg.text
console.log('[VoiceTranscribe] 最终文本:', finalTranscript)
resolve({ success: true, transcript: finalTranscript })
worker.terminate()
} else if (msg.type === 'error') {
@@ -239,15 +235,9 @@ export class VoiceTranscribeService {
}
})
worker.on('error', (err: Error) => {
resolve({ success: false, error: String(err) })
})
worker.on('error', (err: Error) => resolve({ success: false, error: String(err) }))
worker.on('exit', (code: number) => {
if (code !== 0) {
console.error(`[VoiceTranscribe] Worker stopped with exit code ${code}`)
resolve({ success: false, error: `Worker exited with code ${code}` })
}
if (code !== 0) resolve({ success: false, error: `Worker exited with code ${code}` })
})
} catch (error) {
@@ -257,121 +247,230 @@ export class VoiceTranscribeService {
}
/**
* 下载文件
* 下载文件 (支持多线程)
*/
private downloadToFile(
private async downloadToFile(
url: string,
targetPath: string,
fileName: string,
onProgress?: (downloaded: number, total?: number) => void,
remainingRedirects = 5
onProgress?: (downloaded: number, total?: number, speed?: number) => void
): Promise<void> {
return new Promise((resolve, reject) => {
const protocol = url.startsWith('https') ? https : http
console.info(`[VoiceTranscribe] 下载 ${fileName}:`, url)
if (existsSync(targetPath)) {
unlinkSync(targetPath)
}
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'
},
timeout: 30000 // 30秒连接超时
console.info(`[VoiceTranscribe] 准备下载 ${fileName}: ${url}`)
// 1. 探测支持情况
let probeResult
try {
probeResult = await this.probeUrl(url)
} catch (err) {
console.warn(`[VoiceTranscribe] ${fileName} 探测失败,使用单线程`, err)
return this.downloadSingleThread(url, targetPath, fileName, onProgress)
}
const { totalSize, acceptRanges, finalUrl } = probeResult
// 如果文件太小 (< 2MB) 或者不支持 Range使用单线程
if (totalSize < 2 * 1024 * 1024 || !acceptRanges) {
return this.downloadSingleThread(finalUrl, targetPath, fileName, onProgress)
}
console.info(`[VoiceTranscribe] ${fileName} 开始多线程下载 (4 线程), 大小: ${(totalSize / 1024 / 1024).toFixed(2)} MB`)
const threadCount = 4
const chunkSize = Math.ceil(totalSize / threadCount)
const fd = openSync(targetPath, 'w')
let downloadedTotal = 0
let lastDownloaded = 0
let lastTime = Date.now()
let speed = 0
const speedInterval = setInterval(() => {
const now = Date.now()
const duration = (now - lastTime) / 1000
if (duration > 0) {
speed = (downloadedTotal - lastDownloaded) / duration
lastDownloaded = downloadedTotal
lastTime = now
onProgress?.(downloadedTotal, 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) => {
downloadedTotal += bytes
}))
}
const request = protocol.get(url, options, (response) => {
console.info(`[VoiceTranscribe] ${fileName} 响应状态:`, response.statusCode)
await Promise.all(promises)
// Final progress update
onProgress?.(totalSize, totalSize, 0)
console.info(`[VoiceTranscribe] ${fileName} 多线程下载完成`)
} catch (err) {
console.error(`[VoiceTranscribe] ${fileName} 多线程下载失败:`, err)
throw err
} finally {
clearInterval(speedInterval)
closeSync(fd)
}
}
// 处理重定向
if ([301, 302, 303, 307, 308].includes(response.statusCode || 0) && response.headers.location) {
if (remainingRedirects <= 0) {
reject(new Error('重定向次数过多'))
private async probeUrl(url: string, remainingRedirects = 5): Promise<{ totalSize: number, acceptRanges: boolean, finalUrl: string }> {
return new Promise((resolve, reject) => {
const protocol = url.startsWith('https') ? https : 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://modelscope.cn/',
'Range': 'bytes=0-0'
}
}
const req = protocol.get(url, options, (res) => {
if ([301, 302, 303, 307, 308].includes(res.statusCode || 0)) {
const location = res.headers.location
if (location && remainingRedirects > 0) {
const nextUrl = new URL(location, url).href
this.probeUrl(nextUrl, remainingRedirects - 1).then(resolve).catch(reject)
return
}
console.info(`[VoiceTranscribe] 重定向到:`, response.headers.location)
this.downloadToFile(response.headers.location, targetPath, fileName, onProgress, remainingRedirects - 1)
.then(resolve)
.catch(reject)
}
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> {
return new Promise((resolve, reject) => {
const protocol = url.startsWith('https') ? https : 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://modelscope.cn/',
'Range': `bytes=${start}-${end}`
}
}
const req = protocol.get(url, options, (res) => {
if (res.statusCode !== 206) {
reject(new Error(`Chunk download failed: HTTP ${res.statusCode}`))
return
}
let currentOffset = start
res.on('data', (chunk: Buffer) => {
try {
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, targetPath: string, fileName: string, onProgress?: (downloaded: number, total?: number, speed?: number) => void, remainingRedirects = 5): Promise<void> {
return new Promise((resolve, reject) => {
const protocol = url.startsWith('https') ? https : 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://modelscope.cn/'
}
}
const request = protocol.get(url, options, (response) => {
if ([301, 302, 303, 307, 308].includes(response.statusCode || 0)) {
const location = response.headers.location
if (location && remainingRedirects > 0) {
const nextUrl = new URL(location, url).href
this.downloadSingleThread(nextUrl, targetPath, fileName, onProgress, remainingRedirects - 1).then(resolve).catch(reject)
return
}
}
if (response.statusCode !== 200) {
reject(new Error(`下载失败: HTTP ${response.statusCode}`))
reject(new Error(`Fallback download failed: HTTP ${response.statusCode}`))
return
}
const totalBytes = Number(response.headers['content-length'] || 0) || undefined
let downloadedBytes = 0
let lastDownloaded = 0
let lastTime = Date.now()
let speed = 0
console.info(`[VoiceTranscribe] ${fileName} 文件大小:`, totalBytes ? `${(totalBytes / 1024 / 1024).toFixed(2)} MB` : '未知')
const speedInterval = setInterval(() => {
const now = Date.now()
const duration = (now - lastTime) / 1000
if (duration > 0) {
speed = (downloadedBytes - lastDownloaded) / duration
lastDownloaded = downloadedBytes
lastTime = now
onProgress?.(downloadedBytes, totalBytes, speed)
}
}, 1000)
const writer = createWriteStream(targetPath)
// 设置数据接收超时60秒没有数据则超时
let lastDataTime = Date.now()
const dataTimeout = setInterval(() => {
if (Date.now() - lastDataTime > 60000) {
clearInterval(dataTimeout)
response.destroy()
writer.close()
reject(new Error('下载超时60秒内未收到数据'))
}
}, 5000)
response.on('data', (chunk) => {
lastDataTime = Date.now()
downloadedBytes += chunk.length
onProgress?.(downloadedBytes, totalBytes)
})
response.on('error', (error) => {
clearInterval(dataTimeout)
try { writer.close() } catch { }
console.error(`[VoiceTranscribe] ${fileName} 响应错误:`, error)
reject(error)
})
writer.on('error', (error) => {
clearInterval(dataTimeout)
try { writer.close() } catch { }
console.error(`[VoiceTranscribe] ${fileName} 写入错误:`, error)
reject(error)
})
writer.on('finish', () => {
clearInterval(dataTimeout)
clearInterval(speedInterval)
writer.close()
console.info(`[VoiceTranscribe] ${fileName} 下载完成:`, targetPath)
resolve()
})
writer.on('error', (err) => {
clearInterval(speedInterval)
reject(err)
})
response.pipe(writer)
})
request.on('timeout', () => {
request.destroy()
console.error(`[VoiceTranscribe] ${fileName} 连接超时`)
reject(new Error('连接超时'))
})
request.on('error', (error) => {
console.error(`[VoiceTranscribe] ${fileName} 请求错误:`, error)
reject(error)
})
request.on('error', reject)
})
}
/**
* 清理资源
*/
dispose() {
if (this.recognizer) {
try {
// sherpa-onnx 的 recognizer 可能需要手动释放
this.recognizer = null
} catch (error) {
}
this.recognizer = null
}
}
}
export const voiceTranscribeService = new VoiceTranscribeService()

View File

@@ -0,0 +1,180 @@
import path from 'path';
import fs from 'fs';
import vm from 'vm';
let app: any;
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
app = require('electron').app;
} catch (e) {
app = { isPackaged: false };
}
// This service handles the loading and execution of the WeChat WASM module
// to generate the correct Isaac64 keystream for video decryption.
export class WasmService {
private static instance: WasmService;
private module: any = null;
private wasmLoaded = false;
private initPromise: Promise<void> | null = null;
private capturedKeystream: Uint8Array | null = null;
private constructor() { }
public static getInstance(): WasmService {
if (!WasmService.instance) {
WasmService.instance = new WasmService();
}
return WasmService.instance;
}
private async init(): Promise<void> {
if (this.wasmLoaded) return;
if (this.initPromise) return this.initPromise;
this.initPromise = new Promise((resolve, reject) => {
try {
// For dev, files are in electron/assets/wasm
// __dirname in dev (from dist-electron) is .../dist-electron
// So we need to go up one level and then into electron/assets/wasm
const isDev = !app.isPackaged;
const basePath = isDev
? path.join(__dirname, '../electron/assets/wasm')
: path.join(process.resourcesPath, 'assets/wasm'); // Adjust as needed for production build
const wasmPath = path.join(basePath, 'wasm_video_decode.wasm');
const jsPath = path.join(basePath, 'wasm_video_decode.js');
if (!fs.existsSync(wasmPath) || !fs.existsSync(jsPath)) {
throw new Error(`WASM files not found at ${basePath}`);
}
const wasmBinary = fs.readFileSync(wasmPath);
// Emulate Emscripten environment
// We must use 'any' for global mocking
const mockGlobal: any = {
console: console,
Buffer: Buffer,
Uint8Array: Uint8Array,
Int8Array: Int8Array,
Uint16Array: Uint16Array,
Int16Array: Int16Array,
Uint32Array: Uint32Array,
Int32Array: Int32Array,
Float32Array: Float32Array,
Float64Array: Float64Array,
BigInt64Array: BigInt64Array,
BigUint64Array: BigUint64Array,
Array: Array,
Object: Object,
Function: Function,
String: String,
Number: Number,
Boolean: Boolean,
Error: Error,
Promise: Promise,
require: require,
process: process,
setTimeout: setTimeout,
clearTimeout: clearTimeout,
setInterval: setInterval,
clearInterval: clearInterval,
};
// Define Module
mockGlobal.Module = {
onRuntimeInitialized: () => {
this.wasmLoaded = true;
resolve();
},
wasmBinary: wasmBinary,
print: (text: string) => console.log('[WASM stdout]', text),
printErr: (text: string) => console.error('[WASM stderr]', text)
};
// Define necessary globals for Emscripten loader
mockGlobal.self = mockGlobal;
mockGlobal.self.location = { href: jsPath };
mockGlobal.WorkerGlobalScope = function () { };
mockGlobal.VTS_WASM_URL = `file://${wasmPath}`; // Needs a URL, file protocol works in Node context for our mock?
// Define the callback function that WASM calls to return data
// The WASM module calls `wasm_isaac_generate(ptr, size)`
mockGlobal.wasm_isaac_generate = (ptr: number, size: number) => {
// console.log(`[WasmService] wasm_isaac_generate called: ptr=${ptr}, size=${size}`);
const buffer = new Uint8Array(mockGlobal.Module.HEAPU8.buffer, ptr, size);
// Copy the data because WASM memory might change or be invalidated
this.capturedKeystream = new Uint8Array(buffer);
};
// Execute the loader script in the context
const jsContent = fs.readFileSync(jsPath, 'utf8');
const script = new vm.Script(jsContent, { filename: jsPath });
// create context
const context = vm.createContext(mockGlobal);
script.runInContext(context);
// Store reference to module
this.module = mockGlobal.Module;
} catch (error) {
console.error('[WasmService] Failed to initialize WASM:', error);
reject(error);
}
});
return this.initPromise;
}
public async getKeystream(key: string, size: number = 131072): Promise<Buffer> {
// ISAAC-64 uses 8-byte blocks. If size is not a multiple of 8,
// the global reverse() will cause a shift in alignment.
const alignSize = Math.ceil(size / 8) * 8;
const buffer = await this.getRawKeystream(key, alignSize);
// Reverse the entire aligned buffer
const reversed = new Uint8Array(buffer);
reversed.reverse();
// Return exactly the requested size from the beginning of the reversed stream.
// Since we reversed the 'aligned' buffer, index 0 is the last byte of the last block.
return Buffer.from(reversed).subarray(0, size);
}
public async getRawKeystream(key: string, size: number = 131072): Promise<Buffer> {
await this.init();
if (!this.module || !this.module.WxIsaac64) {
if (this.module.asm && this.module.asm.WxIsaac64) {
this.module.WxIsaac64 = this.module.asm.WxIsaac64;
}
}
if (!this.module.WxIsaac64) {
throw new Error('[WasmService] WxIsaac64 not found in WASM module');
}
try {
this.capturedKeystream = null;
const isaac = new this.module.WxIsaac64(key);
isaac.generate(size);
if (isaac.delete) {
isaac.delete();
}
if (this.capturedKeystream) {
return Buffer.from(this.capturedKeystream);
} else {
throw new Error('[WasmService] Failed to capture keystream (callback not called)');
}
} catch (error) {
console.error('[WasmService] Error generating raw keystream:', error);
throw error;
}
}
}

View File

@@ -27,6 +27,8 @@ export class WcdbCore {
private wcdbCloseAccount: any = null
private wcdbSetMyWxid: any = null
private wcdbFreeString: any = null
private wcdbUpdateMessage: any = null
private wcdbDeleteMessage: any = null
private wcdbGetSessions: any = null
private wcdbGetMessages: any = null
private wcdbGetMessageCount: any = null
@@ -35,6 +37,7 @@ export class WcdbCore {
private wcdbGetGroupMemberCount: any = null
private wcdbGetGroupMemberCounts: any = null
private wcdbGetGroupMembers: any = null
private wcdbGetGroupNicknames: any = null
private wcdbGetMessageTables: any = null
private wcdbGetMessageMeta: any = null
private wcdbGetContact: any = null
@@ -43,7 +46,9 @@ export class WcdbCore {
private wcdbGetAvailableYears: any = null
private wcdbGetAnnualReportStats: any = null
private wcdbGetAnnualReportExtras: any = null
private wcdbGetDualReportStats: any = null
private wcdbGetGroupStats: any = null
private wcdbGetMessageDates: any = null
private wcdbOpenMessageCursor: any = null
private wcdbOpenMessageCursorLite: any = null
private wcdbFetchMessageBatch: any = null
@@ -57,7 +62,14 @@ export class WcdbCore {
private wcdbGetDbStatus: any = null
private wcdbGetVoiceData: any = null
private wcdbGetSnsTimeline: any = null
private wcdbGetSnsAnnualStats: any = null
private wcdbVerifyUser: any = null
private wcdbStartMonitorPipe: any = null
private wcdbStopMonitorPipe: any = null
private monitorPipeClient: any = null
private avatarUrlCache: Map<string, { url?: string; updatedAt: number }> = new Map()
private readonly avatarCacheTtlMs = 10 * 60 * 1000
private logTimer: NodeJS.Timeout | null = null
@@ -77,6 +89,82 @@ export class WcdbCore {
}
}
// 使用命名管道 IPC
startMonitor(callback: (type: string, json: string) => void): boolean {
if (!this.wcdbStartMonitorPipe) {
this.writeLog('startMonitor: wcdbStartMonitorPipe not available')
return false
}
try {
const result = this.wcdbStartMonitorPipe()
if (result !== 0) {
this.writeLog(`startMonitor: wcdbStartMonitorPipe failed with ${result}`)
return false
}
const net = require('net')
const PIPE_PATH = '\\\\.\\pipe\\weflow_monitor'
setTimeout(() => {
this.monitorPipeClient = net.createConnection(PIPE_PATH, () => {
this.writeLog('Monitor pipe connected')
})
let buffer = ''
this.monitorPipeClient.on('data', (data: Buffer) => {
buffer += data.toString('utf8')
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.trim()) {
try {
const parsed = JSON.parse(line)
callback(parsed.action || 'update', line)
} catch {
callback('update', line)
}
}
}
})
this.monitorPipeClient.on('error', (err: Error) => {
this.writeLog(`Monitor pipe error: ${err.message}`)
})
this.monitorPipeClient.on('close', () => {
this.writeLog('Monitor pipe closed')
this.monitorPipeClient = null
})
}, 100)
this.writeLog('Monitor started via named pipe IPC')
return true
} catch (e) {
console.error('打开数据库异常:', e)
return false
}
}
stopMonitor(): void {
if (this.monitorPipeClient) {
this.monitorPipeClient.destroy()
this.monitorPipeClient = null
}
if (this.wcdbStopMonitorPipe) {
this.wcdbStopMonitorPipe()
}
}
// 保留旧方法签名以兼容
setMonitor(callback: (type: string, json: string) => void): boolean {
return this.startMonitor(callback)
}
/**
* 获取 DLL 路径
*/
@@ -111,7 +199,7 @@ export class WcdbCore {
}
private isLogEnabled(): boolean {
if (process.env.WEFLOW_WORKER === '1') return false
// 移除 Worker 线程的日志禁用逻辑,允许在 Worker 中记录日志
if (process.env.WCDB_LOG_ENABLED === '1') return true
return this.logEnabled
}
@@ -120,7 +208,7 @@ export class WcdbCore {
if (!force && !this.isLogEnabled()) return
const line = `[${new Date().toISOString()}] ${message}`
// 同时输出到控制台和文件
console.log('[WCDB]', message)
try {
const base = this.userDataPath || process.env.WCDB_LOG_DIR || process.cwd()
const dir = join(base, 'logs')
@@ -218,9 +306,6 @@ export class WcdbCore {
return false
}
// 关键修复:显式预加载依赖库 WCDB.dll 和 SDL2.dll
// Windows 加载器默认不会查找子目录中的依赖,必须先将其加载到内存
// 这可以解决部分用户因为 VC++ 运行时或 DLL 依赖问题导致的闪退
const dllDir = dirname(dllPath)
const wcdbCorePath = join(dllDir, 'WCDB.dll')
if (existsSync(wcdbCorePath)) {
@@ -260,10 +345,10 @@ export class WcdbCore {
let protectionOk = false
for (const resPath of resourcePaths) {
try {
// console.log(`[WCDB] 尝试 InitProtection: ${resPath}`)
//
protectionOk = this.wcdbInitProtection(resPath)
if (protectionOk) {
// console.log(`[WCDB] InitProtection 成功: ${resPath}`)
//
break
}
} catch (e) {
@@ -302,6 +387,20 @@ export class WcdbCore {
this.wcdbSetMyWxid = null
}
// wcdb_status wcdb_update_message(wcdb_handle handle, const char* session_id, int64_t local_id, int32_t create_time, const char* new_content, char** out_error)
try {
this.wcdbUpdateMessage = this.lib.func('int32 wcdb_update_message(int64 handle, const char* sessionId, int64 localId, int32 createTime, const char* newContent, _Out_ void** outError)')
} catch {
this.wcdbUpdateMessage = null
}
// wcdb_status wcdb_delete_message(wcdb_handle handle, const char* session_id, int64_t local_id, char** out_error)
try {
this.wcdbDeleteMessage = this.lib.func('int32 wcdb_delete_message(int64 handle, const char* sessionId, int64 localId, int32 createTime, const char* dbPathHint, _Out_ void** outError)')
} catch {
this.wcdbDeleteMessage = null
}
// void wcdb_free_string(char* ptr)
this.wcdbFreeString = this.lib.func('void wcdb_free_string(void* ptr)')
@@ -333,6 +432,13 @@ export class WcdbCore {
// wcdb_status wcdb_get_group_members(wcdb_handle handle, const char* chatroom_id, char** out_json)
this.wcdbGetGroupMembers = this.lib.func('int32 wcdb_get_group_members(int64 handle, const char* chatroomId, _Out_ void** outJson)')
// wcdb_status wcdb_get_group_nicknames(wcdb_handle handle, const char* chatroom_id, char** out_json)
try {
this.wcdbGetGroupNicknames = this.lib.func('int32 wcdb_get_group_nicknames(int64 handle, const char* chatroomId, _Out_ void** outJson)')
} catch {
this.wcdbGetGroupNicknames = null
}
// wcdb_status wcdb_get_message_tables(wcdb_handle handle, const char* session_id, char** out_json)
this.wcdbGetMessageTables = this.lib.func('int32 wcdb_get_message_tables(int64 handle, const char* sessionId, _Out_ void** outJson)')
@@ -369,6 +475,20 @@ export class WcdbCore {
this.wcdbGetAnnualReportExtras = null
}
// wcdb_status wcdb_get_dual_report_stats(wcdb_handle handle, const char* session_id, int32_t begin_timestamp, int32_t end_timestamp, char** out_json)
try {
this.wcdbGetDualReportStats = this.lib.func('int32 wcdb_get_dual_report_stats(int64 handle, const char* sessionId, int32 begin, int32 end, _Out_ void** outJson)')
} catch {
this.wcdbGetDualReportStats = null
}
// wcdb_status wcdb_get_logs(char** out_json)
try {
this.wcdbGetLogs = this.lib.func('int32 wcdb_get_logs(_Out_ void** outJson)')
} catch {
this.wcdbGetLogs = null
}
// wcdb_status wcdb_get_group_stats(wcdb_handle handle, const char* chatroom_id, int32_t begin_timestamp, int32_t end_timestamp, char** out_json)
try {
this.wcdbGetGroupStats = this.lib.func('int32 wcdb_get_group_stats(int64 handle, const char* chatroomId, int32 begin, int32 end, _Out_ void** outJson)')
@@ -376,6 +496,13 @@ export class WcdbCore {
this.wcdbGetGroupStats = null
}
// wcdb_status wcdb_get_message_dates(wcdb_handle handle, const char* session_id, char** out_json)
try {
this.wcdbGetMessageDates = this.lib.func('int32 wcdb_get_message_dates(int64 handle, const char* sessionId, _Out_ void** outJson)')
} catch {
this.wcdbGetMessageDates = null
}
// wcdb_status wcdb_open_message_cursor(wcdb_handle handle, const char* session_id, int32_t batch_size, int32_t ascending, int32_t begin_timestamp, int32_t end_timestamp, wcdb_cursor* out_cursor)
this.wcdbOpenMessageCursor = this.lib.func('int32 wcdb_open_message_cursor(int64 handle, const char* sessionId, int32 batchSize, int32 ascending, int32 beginTimestamp, int32 endTimestamp, _Out_ int64* outCursor)')
@@ -431,6 +558,24 @@ export class WcdbCore {
this.wcdbGetSnsTimeline = null
}
// wcdb_status wcdb_get_sns_annual_stats(wcdb_handle handle, int32_t begin_timestamp, int32_t end_timestamp, char** out_json)
try {
this.wcdbGetSnsAnnualStats = this.lib.func('int32 wcdb_get_sns_annual_stats(int64 handle, int32 begin, int32 end, _Out_ void** outJson)')
} catch {
this.wcdbGetSnsAnnualStats = null
}
// Named pipe IPC for monitoring (replaces callback)
try {
this.wcdbStartMonitorPipe = this.lib.func('int32 wcdb_start_monitor_pipe()')
this.wcdbStopMonitorPipe = this.lib.func('void wcdb_stop_monitor_pipe()')
this.writeLog('Monitor pipe functions loaded')
} catch (e) {
console.warn('Failed to load monitor pipe functions:', e)
this.wcdbStartMonitorPipe = null
this.wcdbStopMonitorPipe = null
}
// void VerifyUser(int64_t hwnd_ptr, const char* message, char* out_result, int max_len)
try {
this.wcdbVerifyUser = this.lib.func('void VerifyUser(int64 hwnd, const char* message, _Out_ char* outResult, int maxLen)')
@@ -438,6 +583,8 @@ export class WcdbCore {
this.wcdbVerifyUser = null
}
// 初始化
const initResult = this.wcdbInit()
if (initResult !== 0) {
@@ -831,6 +978,37 @@ export class WcdbCore {
}
}
/**
* 获取指定时间之后的新消息
*/
async getNewMessages(sessionId: string, minTime: number, limit: number = 1000): Promise<{ success: boolean; messages?: any[]; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
try {
// 1. 打开游标 (使用 Ascending=1 从指定时间往后查)
const openRes = await this.openMessageCursorLite(sessionId, limit, true, minTime, 0)
if (!openRes.success || !openRes.cursor) {
return { success: false, error: openRes.error }
}
const cursor = openRes.cursor
try {
// 2. 获取批次
const fetchRes = await this.fetchMessageBatch(cursor)
if (!fetchRes.success) {
return { success: false, error: fetchRes.error }
}
return { success: true, messages: fetchRes.rows }
} finally {
// 3. 关闭游标
await this.closeMessageCursor(cursor)
}
} catch (e) {
return { success: false, error: String(e) }
}
}
async getMessageCount(sessionId: string): Promise<{ success: boolean; count?: number; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
@@ -1002,6 +1180,28 @@ export class WcdbCore {
}
}
async getGroupNicknames(chatroomId: string): Promise<{ success: boolean; nicknames?: Record<string, string>; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
if (!this.wcdbGetGroupNicknames) {
return { success: false, error: '当前 DLL 版本不支持获取群昵称接口' }
}
try {
const outPtr = [null as any]
const result = this.wcdbGetGroupNicknames(this.handle, chatroomId, outPtr)
if (result !== 0 || !outPtr[0]) {
return { success: false, error: `获取群昵称失败: ${result}` }
}
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析群昵称失败' }
const nicknames = JSON.parse(jsonStr)
return { success: true, nicknames }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getMessageTables(sessionId: string): Promise<{ success: boolean; tables?: any[]; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
@@ -1021,6 +1221,29 @@ export class WcdbCore {
}
}
async getMessageDates(sessionId: string): Promise<{ success: boolean; dates?: string[]; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
try {
if (!this.wcdbGetMessageDates) {
return { success: false, error: 'DLL 不支持 getMessageDates' }
}
const outPtr = [null as any]
const result = this.wcdbGetMessageDates(this.handle, sessionId, outPtr)
if (result !== 0 || !outPtr[0]) {
// 空结果也可能是正常的(无消息)
return { success: true, dates: [] }
}
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析日期列表失败' }
const dates = JSON.parse(jsonStr)
return { success: true, dates }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getMessageTableStats(sessionId: string): Promise<{ success: boolean; tables?: any[]; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
@@ -1343,13 +1566,31 @@ export class WcdbCore {
}
}
async getLogs(): Promise<{ success: boolean; logs?: string[]; error?: string }> {
if (!this.lib) return { success: false, error: 'DLL 未加载' }
if (!this.wcdbGetLogs) return { success: false, error: '接口未就绪' }
try {
const outPtr = [null as any]
const result = this.wcdbGetLogs(outPtr)
if (result !== 0 || !outPtr[0]) {
return { success: false, error: `获取日志失败: ${result}` }
}
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析日志失败' }
return { success: true, logs: JSON.parse(jsonStr) }
} catch (e) {
return { success: false, error: String(e) }
}
}
async execQuery(kind: string, path: string | null, sql: string): Promise<{ success: boolean; rows?: any[]; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
try {
if (!this.wcdbExecQuery) return { success: false, error: '接口未就绪' }
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]) {
return { success: false, error: `执行查询失败: ${result}` }
}
@@ -1502,4 +1743,109 @@ export class WcdbCore {
return { success: false, error: String(e) }
}
}
}
async getSnsAnnualStats(beginTimestamp: number, endTimestamp: number): Promise<{ success: boolean; data?: any; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
try {
if (!this.wcdbGetSnsAnnualStats) {
return { success: false, error: 'wcdbGetSnsAnnualStats 未找到' }
}
await new Promise(resolve => setImmediate(resolve))
const outPtr = [null as any]
const result = this.wcdbGetSnsAnnualStats(this.handle, beginTimestamp, endTimestamp, outPtr)
await new Promise(resolve => setImmediate(resolve))
if (result !== 0 || !outPtr[0]) {
return { success: false, error: `getSnsAnnualStats failed: ${result}` }
}
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: 'Failed to decode JSON' }
return { success: true, data: JSON.parse(jsonStr) }
} catch (e) {
console.error('getSnsAnnualStats 异常:', e)
return { success: false, error: String(e) }
}
}
async getDualReportStats(sessionId: string, beginTimestamp: number = 0, endTimestamp: number = 0): Promise<{ success: boolean; data?: any; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
if (!this.wcdbGetDualReportStats) {
return { success: false, error: '未支持双人报告统计' }
}
try {
const { begin, end } = this.normalizeRange(beginTimestamp, endTimestamp)
const outPtr = [null as any]
const result = this.wcdbGetDualReportStats(this.handle, sessionId, begin, end, outPtr)
if (result !== 0 || !outPtr[0]) {
return { success: false, error: `获取双人报告统计失败: ${result}` }
}
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析双人报告统计失败' }
const data = JSON.parse(jsonStr)
return { success: true, data }
} catch (e) {
return { success: false, error: String(e) }
}
}
/**
* 修改消息内容
*/
async updateMessage(sessionId: string, localId: number, createTime: number, newContent: string): Promise<{ success: boolean; error?: string }> {
if (!this.initialized || !this.wcdbUpdateMessage) return { success: false, error: 'WCDB Not Initialized or Method Missing' }
if (!this.handle) return { success: false, error: 'Not Connected' }
return new Promise((resolve) => {
try {
const outError = [null as any]
const result = this.wcdbUpdateMessage(this.handle, sessionId, localId, createTime, newContent, outError)
if (result !== 0) {
let errorMsg = 'Unknown Error'
if (outError[0]) {
errorMsg = this.decodeJsonPtr(outError[0]) || 'Unknown Error (Decode Failed)'
}
resolve({ success: false, error: errorMsg })
return
}
resolve({ success: true })
} catch (e) {
resolve({ success: false, error: String(e) })
}
})
}
/**
* 删除消息
*/
async deleteMessage(sessionId: string, localId: number, createTime: number, dbPathHint?: string): Promise<{ success: boolean; error?: string }> {
if (!this.initialized || !this.wcdbDeleteMessage) return { success: false, error: 'WCDB Not Initialized or Method Missing' }
if (!this.handle) return { success: false, error: 'Not Connected' }
return new Promise((resolve) => {
try {
const outError = [null as any]
const result = this.wcdbDeleteMessage(this.handle, sessionId, localId, createTime || 0, dbPathHint || '', outError)
if (result !== 0) {
let errorMsg = 'Unknown Error'
if (outError[0]) {
errorMsg = this.decodeJsonPtr(outError[0]) || 'Unknown Error (Decode Failed)'
}
console.error(`[WcdbCore] deleteMessage fail: code=${result}, error=${errorMsg}`)
resolve({ success: false, error: errorMsg })
return
}
resolve({ success: true })
} catch (e) {
console.error(`[WcdbCore] deleteMessage exception:`, e)
resolve({ success: false, error: String(e) })
}
})
}
}

View File

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

View File

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

View File

@@ -19,6 +19,16 @@ if (parentPort) {
core.setLogEnabled(payload.enabled)
result = { success: true }
break
case 'setMonitor':
core.setMonitor((type, json) => {
parentPort!.postMessage({
id: -1,
type: 'monitor',
payload: { type, json }
})
})
result = { success: true }
break
case 'testConnection':
result = await core.testConnection(payload.dbPath, payload.hexKey, payload.wxid)
break
@@ -38,6 +48,9 @@ if (parentPort) {
case 'getMessages':
result = await core.getMessages(payload.sessionId, payload.limit, payload.offset)
break
case 'getNewMessages':
result = await core.getNewMessages(payload.sessionId, payload.minTime, payload.limit)
break
case 'getMessageCount':
result = await core.getMessageCount(payload.sessionId)
break
@@ -56,12 +69,18 @@ if (parentPort) {
case 'getGroupMembers':
result = await core.getGroupMembers(payload.chatroomId)
break
case 'getGroupNicknames':
result = await core.getGroupNicknames(payload.chatroomId)
break
case 'getMessageTables':
result = await core.getMessageTables(payload.sessionId)
break
case 'getMessageTableStats':
result = await core.getMessageTableStats(payload.sessionId)
break
case 'getMessageDates':
result = await core.getMessageDates(payload.sessionId)
break
case 'getMessageMeta':
result = await core.getMessageMeta(payload.dbPath, payload.tableName, payload.limit, payload.offset)
break
@@ -80,6 +99,9 @@ if (parentPort) {
case 'getAnnualReportExtras':
result = await core.getAnnualReportExtras(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp, payload.peakDayBegin, payload.peakDayEnd)
break
case 'getDualReportStats':
result = await core.getDualReportStats(payload.sessionId, payload.beginTimestamp, payload.endTimestamp)
break
case 'getGroupStats':
result = await core.getGroupStats(payload.chatroomId, payload.beginTimestamp, payload.endTimestamp)
break
@@ -119,9 +141,22 @@ if (parentPort) {
case 'getSnsTimeline':
result = await core.getSnsTimeline(payload.limit, payload.offset, payload.usernames, payload.keyword, payload.startTime, payload.endTime)
break
case 'getSnsAnnualStats':
result = await core.getSnsAnnualStats(payload.beginTimestamp, payload.endTimestamp)
break
case 'getLogs':
result = await core.getLogs()
break
case 'verifyUser':
result = await core.verifyUser(payload.message, payload.hwnd)
break
case 'updateMessage':
result = await core.updateMessage(payload.sessionId, payload.localId, payload.createTime, payload.newContent)
break
case 'deleteMessage':
result = await core.deleteMessage(payload.sessionId, payload.localId, payload.createTime, payload.dbPathHint)
break
default:
result = { success: false, error: `Unknown method: ${type}` }
}

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 203 KiB

3390
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "weflow",
"version": "1.4.4",
"version": "2.1.0",
"description": "WeFlow",
"main": "dist-electron/main.js",
"author": "cc",
@@ -10,7 +10,7 @@
},
"//": "二改不应改变此处的作者与应用信息",
"scripts": {
"postinstall": "echo 'No native modules to rebuild'",
"postinstall": "electron-builder install-app-deps",
"rebuild": "electron-rebuild",
"dev": "vite",
"build": "tsc && vite build && electron-builder",
@@ -32,9 +32,13 @@
"jszip": "^3.10.1",
"koffi": "^2.9.0",
"lucide-react": "^0.562.0",
"node-llama-cpp": "^3.15.1",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.1.1",
"react-virtuoso": "^4.18.1",
"remark-gfm": "^4.0.1",
"sherpa-onnx-node": "^1.10.38",
"silk-wasm": "^3.7.1",
"wechat-emojis": "^1.0.2",
@@ -103,6 +107,10 @@
{
"from": "public/icon.ico",
"to": "icon.ico"
},
{
"from": "electron/assets/wasm/",
"to": "assets/wasm/"
}
],
"files": [
@@ -111,7 +119,8 @@
],
"asarUnpack": [
"node_modules/silk-wasm/**/*",
"node_modules/sherpa-onnx-node/**/*"
"node_modules/sherpa-onnx-node/**/*",
"node_modules/ffmpeg-static/**/*"
],
"extraFiles": [
{

Binary file not shown.

View File

@@ -6,6 +6,17 @@
animation: appFadeIn 0.35s ease-out;
}
.window-drag-region {
position: fixed;
top: 0;
left: 0;
right: 150px; // 预留系统最小化/最大化/关闭按钮区域
height: 40px;
-webkit-app-region: drag;
pointer-events: auto;
z-index: 2000;
}
.main-layout {
flex: 1;
display: flex;

View File

@@ -10,14 +10,19 @@ import AnalyticsPage from './pages/AnalyticsPage'
import AnalyticsWelcomePage from './pages/AnalyticsWelcomePage'
import AnnualReportPage from './pages/AnnualReportPage'
import AnnualReportWindow from './pages/AnnualReportWindow'
import DualReportPage from './pages/DualReportPage'
import DualReportWindow from './pages/DualReportWindow'
import AgreementPage from './pages/AgreementPage'
import GroupAnalyticsPage from './pages/GroupAnalyticsPage'
import SettingsPage from './pages/SettingsPage'
import ExportPage from './pages/ExportPage'
import VideoWindow from './pages/VideoWindow'
import ImageWindow from './pages/ImageWindow'
import SnsPage from './pages/SnsPage'
import ContactsPage from './pages/ContactsPage'
import ChatHistoryPage from './pages/ChatHistoryPage'
import NotificationWindow from './pages/NotificationWindow'
import AIChatPage from './pages/AIChatPage'
import { useAppStore } from './stores/appStore'
import { themes, useThemeStore, type ThemeId } from './stores/themeStore'
@@ -28,10 +33,13 @@ import './App.scss'
import UpdateDialog from './components/UpdateDialog'
import UpdateProgressCapsule from './components/UpdateProgressCapsule'
import LockScreen from './components/LockScreen'
import { GlobalSessionMonitor } from './components/GlobalSessionMonitor'
import { BatchTranscribeGlobal } from './components/BatchTranscribeGlobal'
function App() {
const navigate = useNavigate()
const location = useLocation()
const {
setDbConnected,
updateInfo,
@@ -52,6 +60,7 @@ function App() {
const isOnboardingWindow = location.pathname === '/onboarding-window'
const isVideoPlayerWindow = location.pathname === '/video-player-window'
const isChatHistoryWindow = location.pathname.startsWith('/chat-history/')
const isNotificationWindow = location.pathname === '/notification-window'
const [themeHydrated, setThemeHydrated] = useState(false)
// 锁定状态
@@ -71,7 +80,7 @@ function App() {
const body = document.body
const appRoot = document.getElementById('app')
if (isOnboardingWindow) {
if (isOnboardingWindow || isNotificationWindow) {
root.style.background = 'transparent'
body.style.background = 'transparent'
body.style.overflow = 'hidden'
@@ -97,10 +106,10 @@ function App() {
// 更新窗口控件颜色以适配主题
const symbolColor = themeMode === 'dark' ? '#ffffff' : '#1a1a1a'
if (!isOnboardingWindow) {
if (!isOnboardingWindow && !isNotificationWindow) {
window.electronAPI.window.setTitleBarOverlay({ symbolColor })
}
}, [currentTheme, themeMode, isOnboardingWindow])
}, [currentTheme, themeMode, isOnboardingWindow, isNotificationWindow])
// 读取已保存的主题设置
useEffect(() => {
@@ -170,21 +179,23 @@ function App() {
// 监听启动时的更新通知
useEffect(() => {
const removeUpdateListener = window.electronAPI.app.onUpdateAvailable?.((info: any) => {
if (isNotificationWindow) return // Skip updates in notification window
const removeUpdateListener = window.electronAPI?.app?.onUpdateAvailable?.((info: any) => {
// 发现新版本时自动打开更新弹窗
if (info) {
setUpdateInfo({ ...info, hasUpdate: true })
setShowUpdateDialog(true)
}
})
const removeProgressListener = window.electronAPI.app.onDownloadProgress?.((progress: any) => {
const removeProgressListener = window.electronAPI?.app?.onDownloadProgress?.((progress: any) => {
setDownloadProgress(progress)
})
return () => {
removeUpdateListener?.()
removeProgressListener?.()
}
}, [setUpdateInfo, setDownloadProgress, setShowUpdateDialog])
}, [setUpdateInfo, setDownloadProgress, setShowUpdateDialog, isNotificationWindow])
const handleUpdateNow = async () => {
setShowUpdateDialog(false)
@@ -201,6 +212,18 @@ function App() {
}
}
const handleIgnoreUpdate = async () => {
if (!updateInfo || !updateInfo.version) return
try {
await window.electronAPI.app.ignoreUpdate(updateInfo.version)
setShowUpdateDialog(false)
setUpdateInfo(null)
} catch (e: any) {
console.error('忽略更新失败:', e)
}
}
const dismissUpdate = () => {
setUpdateInfo(null)
}
@@ -227,18 +250,18 @@ function App() {
if (!onboardingDone) {
await configService.setOnboardingDone(true)
}
console.log('检测到已保存的配置,正在自动连接...')
const result = await window.electronAPI.chat.connect()
if (result.success) {
console.log('自动连接成功')
setDbConnected(true, dbPath)
// 如果当前在欢迎页,跳转到首页
if (window.location.hash === '#/' || window.location.hash === '') {
navigate('/home')
}
} else {
console.log('自动连接失败:', result.error)
// 如果错误信息包含 VC++ 或 DLL 相关内容,不清除配置,只提示用户
// 其他错误可能需要重新配置
const errorMsg = result.error || ''
@@ -304,14 +327,26 @@ function App() {
return <VideoWindow />
}
// 独立图片查看窗口
const isImageViewerWindow = location.pathname === '/image-viewer-window'
if (isImageViewerWindow) {
return <ImageWindow />
}
// 独立聊天记录窗口
if (isChatHistoryWindow) {
return <ChatHistoryPage />
}
// 独立通知窗口
if (isNotificationWindow) {
return <NotificationWindow />
}
// 主窗口 - 完整布局
return (
<div className="app-container">
<div className="window-drag-region" aria-hidden="true" />
{isLocked && (
<LockScreen
onUnlock={() => setLocked(false)}
@@ -324,6 +359,12 @@ function App() {
{/* 全局悬浮进度胶囊 (处理:新版本提示、下载进度、错误提示) */}
<UpdateProgressCapsule />
{/* 全局会话监听与通知 */}
<GlobalSessionMonitor />
{/* 全局批量转写进度浮窗 */}
<BatchTranscribeGlobal />
{/* 用户协议弹窗 */}
{showAgreement && !agreementLoading && (
<div className="agreement-overlay">
@@ -381,6 +422,7 @@ function App() {
updateInfo={updateInfo}
onClose={() => setShowUpdateDialog(false)}
onUpdate={handleUpdateNow}
onIgnore={handleIgnoreUpdate}
isDownloading={isDownloading}
progress={downloadProgress}
/>
@@ -393,11 +435,14 @@ function App() {
<Route path="/" element={<HomePage />} />
<Route path="/home" element={<HomePage />} />
<Route path="/chat" element={<ChatPage />} />
<Route path="/ai-chat" element={<AIChatPage />} />
<Route path="/analytics" element={<AnalyticsWelcomePage />} />
<Route path="/analytics/view" element={<AnalyticsPage />} />
<Route path="/group-analytics" element={<GroupAnalyticsPage />} />
<Route path="/annual-report" element={<AnnualReportPage />} />
<Route path="/annual-report/view" element={<AnnualReportWindow />} />
<Route path="/dual-report" element={<DualReportPage />} />
<Route path="/dual-report/view" element={<DualReportWindow />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/export" element={<ExportPage />} />

View File

@@ -0,0 +1,147 @@
import React, { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { Loader2, X, CheckCircle, XCircle, AlertCircle, Clock } from 'lucide-react'
import { useBatchTranscribeStore } from '../stores/batchTranscribeStore'
import '../styles/batchTranscribe.scss'
/**
* 全局批量转写进度浮窗 + 结果弹窗
* 挂载在 App 层,切换页面时不会消失
*/
export const BatchTranscribeGlobal: React.FC = () => {
const {
isBatchTranscribing,
progress,
showToast,
showResult,
result,
sessionName,
startTime,
setShowToast,
setShowResult
} = useBatchTranscribeStore()
const [eta, setEta] = useState<string>('')
// 计算剩余时间
useEffect(() => {
if (!isBatchTranscribing || !startTime || progress.current === 0) {
setEta('')
return
}
const timer = setInterval(() => {
const now = Date.now()
const elapsed = now - startTime
const rate = progress.current / elapsed // ms per item
const remainingItems = progress.total - progress.current
if (remainingItems <= 0) {
setEta('')
return
}
const remainingTimeMs = remainingItems / rate
const remainingSeconds = Math.ceil(remainingTimeMs / 1000)
if (remainingSeconds < 60) {
setEta(`${remainingSeconds}`)
} else {
const minutes = Math.floor(remainingSeconds / 60)
const seconds = remainingSeconds % 60
setEta(`${minutes}${seconds}`)
}
}, 1000)
return () => clearInterval(timer)
}, [isBatchTranscribing, startTime, progress.current, progress.total])
return (
<>
{/* 批量转写进度浮窗(非阻塞) */}
{showToast && isBatchTranscribing && createPortal(
<div className="batch-progress-toast">
<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
)}
{/* 批量转写结果对话框 */}
{showResult && createPortal(
<div className="batch-modal-overlay" onClick={() => setShowResult(false)}>
<div className="batch-modal-content batch-result-modal" onClick={(e) => e.stopPropagation()}>
<div className="batch-modal-header">
<CheckCircle size={20} />
<h3></h3>
</div>
<div className="batch-modal-body">
<div className="result-summary">
<div className="result-item success">
<CheckCircle size={18} />
<span className="label">:</span>
<span className="value">{result.success} </span>
</div>
{result.fail > 0 && (
<div className="result-item fail">
<XCircle size={18} />
<span className="label">:</span>
<span className="value">{result.fail} </span>
</div>
)}
</div>
{result.fail > 0 && (
<div className="result-tip">
<AlertCircle size={16} />
<span></span>
</div>
)}
</div>
<div className="batch-modal-footer">
<button className="btn-primary" onClick={() => setShowResult(false)}>
</button>
</div>
</div>
</div>,
document.body
)}
</>
)
}

View File

@@ -70,6 +70,7 @@
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
@@ -144,6 +145,7 @@
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-template-rows: auto repeat(6, 32px);
gap: 2px;
}
@@ -156,7 +158,6 @@
}
.calendar-day {
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
@@ -211,4 +212,4 @@
padding-top: 12px;
border-top: 1px solid var(--border-color);
}
}
}

View File

@@ -86,7 +86,7 @@ function DateRangePicker({ startDate, endDate, onStartDateChange, onEndDateChang
const handleDateClick = (day: number) => {
const dateStr = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
if (selectingStart) {
onStartDateChange(dateStr)
if (endDate && dateStr > endDate) {
@@ -125,8 +125,8 @@ function DateRangePicker({ startDate, endDate, onStartDateChange, onEndDateChang
const isToday = (day: number) => {
const today = new Date()
return currentMonth.getFullYear() === today.getFullYear() &&
currentMonth.getMonth() === today.getMonth() &&
day === today.getDate()
currentMonth.getMonth() === today.getMonth() &&
day === today.getDate()
}
const renderCalendar = () => {

View File

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

View File

@@ -14,12 +14,21 @@
max-height: 90vh;
object-fit: contain;
transition: transform 0.15s ease-out;
&.dragging {
transition: none;
}
}
.preview-content {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: fit-content;
height: fit-content;
}
.image-preview-close {
position: absolute;
bottom: 40px;
@@ -44,3 +53,38 @@
transform: translateX(-50%) scale(1.1);
}
}
.live-photo-btn {
position: absolute;
top: 15px;
right: 15px;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-radius: 16px;
background: rgba(0, 0, 0, 0.5);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.2);
cursor: pointer;
backdrop-filter: blur(10px);
transition: all 0.2s;
z-index: 10000;
&:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.4);
transform: translateY(-2px);
}
&.active {
background: var(--accent-color, #007aff);
border-color: transparent;
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.3);
}
span {
font-size: 14px;
font-weight: 500;
}
}

View File

@@ -1,36 +1,41 @@
import React, { useState, useRef, useCallback, useEffect } from 'react'
import { X } from 'lucide-react'
import { LivePhotoIcon } from './LivePhotoIcon'
import { createPortal } from 'react-dom'
import './ImagePreview.scss'
interface ImagePreviewProps {
src: string
isVideo?: boolean
liveVideoPath?: string
onClose: () => void
}
export const ImagePreview: React.FC<ImagePreviewProps> = ({ src, onClose }) => {
export const ImagePreview: React.FC<ImagePreviewProps> = ({ src, isVideo, liveVideoPath, onClose }) => {
const [scale, setScale] = useState(1)
const [position, setPosition] = useState({ x: 0, y: 0 })
const [isDragging, setIsDragging] = useState(false)
const [showLive, setShowLive] = useState(false)
const dragStart = useRef({ x: 0, y: 0 })
const positionStart = useRef({ x: 0, y: 0 })
const containerRef = useRef<HTMLDivElement>(null)
// 滚轮缩放
const handleWheel = useCallback((e: React.WheelEvent) => {
if (showLive) return // 播放实况时禁止缩放? 或者支持缩放? 暂定禁止以简化
e.preventDefault()
const delta = e.deltaY > 0 ? 0.9 : 1.1
setScale(prev => Math.min(Math.max(prev * delta, 0.5), 5))
}, [])
}, [showLive])
// 开始拖动
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (scale <= 1) return
if (showLive || scale <= 1) return
e.preventDefault()
setIsDragging(true)
dragStart.current = { x: e.clientX, y: e.clientY }
positionStart.current = { ...position }
}, [scale, position])
}, [scale, position, showLive])
// 拖动中
const handleMouseMove = useCallback((e: React.MouseEvent) => {
@@ -79,19 +84,62 @@ export const ImagePreview: React.FC<ImagePreviewProps> = ({ src, onClose }) => {
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
<img
src={src}
alt="图片预览"
className={`preview-image ${isDragging ? 'dragging' : ''}`}
<div
className="preview-content"
style={{
transform: `translate(${position.x}px, ${position.y}px) scale(${scale})`,
cursor: scale > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default'
position: 'relative',
transform: `translate(${position.x}px, ${position.y}px)`,
width: 'fit-content',
height: 'fit-content'
}}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onDoubleClick={handleDoubleClick}
draggable={false}
/>
onClick={(e) => e.stopPropagation()}
>
{(isVideo || showLive) ? (
<video
src={showLive ? liveVideoPath : src}
controls={!showLive}
autoPlay
loop={showLive}
className="preview-image"
style={{
transform: `scale(${scale})`,
maxHeight: '90vh',
maxWidth: '90vw'
}}
/>
) : (
<img
src={src}
alt="图片预览"
className={`preview-image ${isDragging ? 'dragging' : ''}`}
style={{
transform: `scale(${scale})`,
maxHeight: '90vh',
maxWidth: '90vw',
cursor: scale > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default'
}}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onDoubleClick={handleDoubleClick}
draggable={false}
/>
)}
{liveVideoPath && !isVideo && (
<button
className={`live-photo-btn ${showLive ? 'active' : ''}`}
onClick={(e) => {
e.stopPropagation()
setShowLive(!showLive)
}}
title={showLive ? "显示照片" : "播放实况"}
>
<LivePhotoIcon size={20} />
<span></span>
</button>
)}
</div>
<button className="image-preview-close" onClick={onClose}>
<X size={20} />
</button>

View File

@@ -100,6 +100,33 @@
}
.calendar-grid {
position: relative;
&.loading {
.weekdays,
.days {
pointer-events: none;
}
}
.calendar-loading {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--text-secondary);
font-size: 13px;
.spin {
color: var(--primary);
animation: spin 1s linear infinite;
}
}
.weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
@@ -117,10 +144,10 @@
.days {
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-template-rows: repeat(6, 36px);
gap: 4px;
.day-cell {
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
@@ -129,12 +156,13 @@
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
position: relative;
&.empty {
cursor: default;
}
&:not(.empty):hover {
&:not(.empty):not(.no-message):hover {
background: var(--bg-hover);
}
@@ -149,10 +177,43 @@
font-weight: 600;
background: var(--primary-light);
}
// 无消息的日期 - 灰显且不可点击
&.no-message {
opacity: 0.3;
cursor: default;
pointer-events: none;
}
// 有消息的日期指示器小圆点
.message-dot {
position: absolute;
bottom: 3px;
left: 50%;
transform: translateX(-50%);
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--primary);
}
&.selected .message-dot {
background: rgba(255, 255, 255, 0.7);
}
}
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.quick-options {
display: flex;
gap: 8px;

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react'
import { X, ChevronLeft, ChevronRight, Calendar as CalendarIcon } from 'lucide-react'
import React, { useState, useMemo } from 'react'
import { X, ChevronLeft, ChevronRight, Calendar as CalendarIcon, Loader2 } from 'lucide-react'
import './JumpToDateDialog.scss'
interface JumpToDateDialogProps {
@@ -7,15 +7,22 @@ interface JumpToDateDialogProps {
onClose: () => void
onSelect: (date: Date) => void
currentDate?: Date
/** 有消息的日期集合,格式为 YYYY-MM-DD */
messageDates?: Set<string>
/** 是否正在加载消息日期 */
loadingDates?: boolean
}
const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
isOpen,
onClose,
onSelect,
currentDate = new Date()
currentDate = new Date(),
messageDates,
loadingDates = false
}) => {
const [calendarDate, setCalendarDate] = useState(new Date(currentDate))
const isValidDate = (d: any) => d instanceof Date && !isNaN(d.getTime())
const [calendarDate, setCalendarDate] = useState(isValidDate(currentDate) ? new Date(currentDate) : new Date())
const [selectedDate, setSelectedDate] = useState(new Date(currentDate))
if (!isOpen) return null
@@ -48,7 +55,20 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
return days
}
/**
* 判断某天是否有消息
*/
const hasMessage = (day: number): boolean => {
if (!messageDates || messageDates.size === 0) return true // 未加载时默认全部可点击
const year = calendarDate.getFullYear()
const month = calendarDate.getMonth() + 1
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
return messageDates.has(dateStr)
}
const handleDateClick = (day: number) => {
// 如果已加载日期数据且该日期无消息,则不可点击
if (messageDates && messageDates.size > 0 && !hasMessage(day)) return
const newDate = new Date(calendarDate.getFullYear(), calendarDate.getMonth(), day)
setSelectedDate(newDate)
}
@@ -71,6 +91,28 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
calendarDate.getFullYear() === selectedDate.getFullYear()
}
/**
* 获取某天的 CSS 类名
*/
const getDayClassName = (day: number | null): string => {
if (day === null) return 'day-cell empty'
const classes = ['day-cell']
if (isSelected(day)) classes.push('selected')
if (isToday(day)) classes.push('today')
// 仅在已加载消息日期数据时区分有/无消息
if (messageDates && messageDates.size > 0) {
if (hasMessage(day)) {
classes.push('has-message')
} else {
classes.push('no-message')
}
}
return classes.join(' ')
}
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
const days = generateCalendar()
@@ -106,18 +148,28 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
</button>
</div>
<div className="calendar-grid">
<div className="weekdays">
<div className={`calendar-grid ${loadingDates ? 'loading' : ''}`}>
{loadingDates && (
<div className="calendar-loading">
<Loader2 size={20} className="spin" />
<span>...</span>
</div>
)}
<div className="weekdays" style={{ visibility: loadingDates ? 'hidden' : 'visible' }}>
{weekdays.map(d => <div key={d} className="weekday">{d}</div>)}
</div>
<div className="days">
<div className="days" style={{ visibility: loadingDates ? 'hidden' : 'visible' }}>
{days.map((day, i) => (
<div
key={i}
className={`day-cell ${day === null ? 'empty' : ''} ${day !== null && isSelected(day) ? 'selected' : ''} ${day !== null && isToday(day) ? 'today' : ''}`}
className={getDayClassName(day)}
style={{ visibility: loadingDates ? 'hidden' : 'visible' }}
onClick={() => day !== null && handleDateClick(day)}
>
{day}
{day !== null && messageDates && messageDates.size > 0 && hasMessage(day) && (
<span className="message-dot" />
)}
</div>
))}
</div>

View File

@@ -0,0 +1,36 @@
import React from 'react'
import { Bot, User } from 'lucide-react'
interface ChatMessage {
id: string;
role: 'user' | 'ai';
content: string;
timestamp: number;
}
interface MessageBubbleProps {
message: ChatMessage;
}
/**
* 优化后的消息气泡组件
* 使用 React.memo 避免不必要的重新渲染
*/
export const MessageBubble = React.memo<MessageBubbleProps>(({ message }) => {
return (
<div className={`message-row ${message.role}`}>
<div className="avatar">
{message.role === 'ai' ? <Bot size={24} /> : <User size={24} />}
</div>
<div className="bubble">
<div className="content">{message.content}</div>
</div>
</div>
)
}, (prevProps, nextProps) => {
// 自定义比较函数只有内容或ID变化时才重新渲染
return prevProps.message.content === nextProps.message.content &&
prevProps.message.id === nextProps.message.id
})
MessageBubble.displayName = 'MessageBubble'

View File

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

View File

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

View File

@@ -0,0 +1,142 @@
// Shared styles for Report components (Heatmap, WordCloud)
// --- Heatmap ---
.heatmap-wrapper {
margin-top: 24px;
width: 100%;
}
.heatmap-header {
display: grid;
grid-template-columns: 28px 1fr;
gap: 3px;
margin-bottom: 6px;
color: var(--ar-text-sub); // Assumes --ar-text-sub is defined in parent context or globally
font-size: 10px;
}
.time-labels {
display: grid;
grid-template-columns: repeat(24, 1fr);
gap: 3px;
span {
text-align: center;
}
}
.heatmap {
display: grid;
grid-template-columns: 28px 1fr;
gap: 3px;
}
.heatmap-week-col {
display: grid;
grid-template-rows: repeat(7, 1fr);
gap: 3px;
font-size: 10px;
color: var(--ar-text-sub);
}
.week-label {
display: flex;
align-items: center;
}
.heatmap-grid {
display: grid;
grid-template-columns: repeat(24, 1fr);
gap: 3px;
}
.h-cell {
aspect-ratio: 1;
border-radius: 2px;
min-height: 10px;
transition: transform 0.15s;
&:hover {
transform: scale(1.3);
z-index: 1;
}
}
// --- Word Cloud ---
.word-cloud-wrapper {
margin: 24px auto 0;
padding: 0;
max-width: 520px;
display: flex;
justify-content: center;
--cloud-scale: clamp(0.72, 80vw / 520, 1);
}
.word-cloud-inner {
position: relative;
width: 520px;
height: 520px;
margin: 0;
border-radius: 50%;
transform: scale(var(--cloud-scale));
transform-origin: center;
&::before {
content: "";
position: absolute;
inset: -6%;
background:
radial-gradient(circle at 35% 45%, rgba(var(--ar-primary-rgb, 7, 193, 96), 0.12), transparent 55%),
radial-gradient(circle at 65% 50%, rgba(var(--ar-accent-rgb, 242, 170, 0), 0.10), transparent 58%),
radial-gradient(circle at 50% 65%, var(--bg-tertiary, rgba(0, 0, 0, 0.04)), transparent 60%);
filter: blur(18px);
border-radius: 50%;
pointer-events: none;
z-index: 0;
}
}
.word-tag {
display: inline-block;
padding: 0;
background: transparent;
border-radius: 0;
border: none;
line-height: 1.2;
white-space: nowrap;
transition: transform 0.2s ease, color 0.2s ease;
cursor: default;
color: var(--ar-text-main);
font-weight: 600;
opacity: 0;
animation: wordPopIn 0.55s ease forwards;
position: absolute;
z-index: 1;
transform: translate(-50%, -50%) scale(0.8);
&:hover {
transform: translate(-50%, -50%) scale(1.08);
color: var(--ar-primary);
z-index: 2;
}
}
@keyframes wordPopIn {
0% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.6);
}
100% {
opacity: var(--final-opacity, 1);
transform: translate(-50%, -50%) scale(1);
}
}
.word-cloud-note {
margin-top: 24px;
font-size: 14px !important;
color: var(--ar-text-sub) !important;
text-align: center;
}

View File

@@ -0,0 +1,51 @@
import React from 'react'
import './ReportComponents.scss'
interface ReportHeatmapProps {
data: number[][]
}
const ReportHeatmap: React.FC<ReportHeatmapProps> = ({ data }) => {
if (!data || data.length === 0) return null
const maxHeat = Math.max(...data.flat())
const weekLabels = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
return (
<div className="heatmap-wrapper">
<div className="heatmap-header">
<div></div>
<div className="time-labels">
{[0, 6, 12, 18].map(h => (
<span key={h} style={{ gridColumn: h + 1 }}>{h}</span>
))}
</div>
</div>
<div className="heatmap">
<div className="heatmap-week-col">
{weekLabels.map(w => <div key={w} className="week-label">{w}</div>)}
</div>
<div className="heatmap-grid">
{data.map((row, wi) =>
row.map((val, hi) => {
const alpha = maxHeat > 0 ? (val / maxHeat * 0.85 + 0.1).toFixed(2) : '0.1'
return (
<div
key={`${wi}-${hi}`}
className="h-cell"
style={{
backgroundColor: 'var(--primary)',
opacity: alpha
}}
title={`${weekLabels[wi]} ${hi}:00 - ${val}`}
/>
)
})
)}
</div>
</div>
</div>
)
}
export default ReportHeatmap

View File

@@ -0,0 +1,113 @@
import React from 'react'
import './ReportComponents.scss'
interface ReportWordCloudProps {
words: { phrase: string; count: number }[]
}
const ReportWordCloud: React.FC<ReportWordCloudProps> = ({ words }) => {
if (!words || words.length === 0) return null
const maxCount = words.length > 0 ? words[0].count : 1
const topWords = words.slice(0, 32)
const baseSize = 520
// 使用确定性随机数生成器
const seededRandom = (seed: number) => {
const x = Math.sin(seed) * 10000
return x - Math.floor(x)
}
// 计算词云位置
const placedItems: { x: number; y: number; w: number; h: number }[] = []
const canPlace = (x: number, y: number, w: number, h: number): boolean => {
const halfW = w / 2
const halfH = h / 2
const dx = x - 50
const dy = y - 50
const dist = Math.sqrt(dx * dx + dy * dy)
const maxR = 49 - Math.max(halfW, halfH)
if (dist > maxR) return false
const pad = 1.8
for (const p of placedItems) {
if ((x - halfW - pad) < (p.x + p.w / 2) &&
(x + halfW + pad) > (p.x - p.w / 2) &&
(y - halfH - pad) < (p.y + p.h / 2) &&
(y + halfH + pad) > (p.y - p.h / 2)) {
return false
}
}
return true
}
const wordItems = topWords.map((item, i) => {
const ratio = item.count / maxCount
const fontSize = Math.round(12 + Math.pow(ratio, 0.65) * 20)
const opacity = Math.min(1, Math.max(0.35, 0.35 + ratio * 0.65))
const delay = (i * 0.04).toFixed(2)
// 计算词语宽度
const charCount = Math.max(1, item.phrase.length)
const hasCjk = /[\u4e00-\u9fff]/.test(item.phrase)
const hasLatin = /[A-Za-z0-9]/.test(item.phrase)
const widthFactor = hasCjk && hasLatin ? 0.85 : hasCjk ? 0.98 : 0.6
const widthPx = fontSize * (charCount * widthFactor)
const heightPx = fontSize * 1.1
const widthPct = (widthPx / baseSize) * 100
const heightPct = (heightPx / baseSize) * 100
// 寻找位置
let x = 50, y = 50
let placedOk = false
const tries = i === 0 ? 1 : 420
for (let t = 0; t < tries; t++) {
if (i === 0) {
x = 50
y = 50
} else {
const idx = i + t * 0.28
const radius = Math.sqrt(idx) * 7.6 + (seededRandom(i * 1000 + t) * 1.2 - 0.6)
const angle = idx * 2.399963 + seededRandom(i * 2000 + t) * 0.35
x = 50 + radius * Math.cos(angle)
y = 50 + radius * Math.sin(angle)
}
if (canPlace(x, y, widthPct, heightPct)) {
placedOk = true
break
}
}
if (!placedOk) return null
placedItems.push({ x, y, w: widthPct, h: heightPct })
return (
<span
key={i}
className="word-tag"
style={{
'--final-opacity': opacity,
left: `${x.toFixed(2)}%`,
top: `${y.toFixed(2)}%`,
fontSize: `${fontSize}px`,
animationDelay: `${delay}s`,
} as React.CSSProperties}
title={`${item.phrase} (出现 ${item.count} 次)`}
>
{item.phrase}
</span>
)
}).filter(Boolean)
return (
<div className="word-cloud-wrapper">
<div className="word-cloud-inner">
{wordItems}
</div>
</div>
)
}
export default ReportWordCloud

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
import { NavLink, useLocation } from 'react-router-dom'
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Bot, 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 * as configService from '../services/config'
import './Sidebar.scss'

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('')} style={{ right: 8, top: 8, position: 'absolute', cursor: 'pointer', color: 'var(--text-tertiary)' }} />
)}
</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,357 @@
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[]
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, onPreview, onMediaDeleted }: { media: SnsMedia; 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
// 视频重试失败时重试最多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: 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: 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: 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: 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: 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, 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} 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} 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

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

View File

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

552
src/pages/AIChatPage.scss Normal file
View File

@@ -0,0 +1,552 @@
// 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);
}
}

391
src/pages/AIChatPage.tsx Normal file
View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,9 @@
.annual-report-window {
// 使用全局主题变量,带回退值
--ar-primary: var(--primary, #07C160);
--ar-primary-rgb: var(--primary-rgb, 7, 193, 96);
--ar-accent: var(--accent, #F2AA00);
--ar-accent-rgb: 242, 170, 0;
--ar-text-main: var(--text-primary, #222222);
--ar-text-sub: var(--text-secondary, #555555);
--ar-bg-color: var(--bg-primary, #F9F8F6);
@@ -43,7 +45,7 @@
// 背景装饰圆点 - 毛玻璃效果
.bg-decoration {
position: absolute; // Changed from fixed
position: absolute;
inset: 0;
pointer-events: none;
z-index: 0;
@@ -53,10 +55,10 @@
.deco-circle {
position: absolute;
border-radius: 50%;
background: rgba(0, 0, 0, 0.03);
background: rgba(var(--ar-primary-rgb), 0.03);
backdrop-filter: blur(40px);
-webkit-backdrop-filter: blur(40px);
border: 1px solid rgba(0, 0, 0, 0.05);
border: 1px solid var(--border-color);
&.c1 {
width: 280px;
@@ -243,6 +245,7 @@
}
.exporting-snapshot {
.hero-title,
.label-text,
.hero-desc,
@@ -253,6 +256,11 @@
background: transparent !important;
box-shadow: none !important;
}
.deco-circle {
background: transparent !important;
border: none !important;
}
}
.section {
@@ -1279,3 +1287,135 @@
color: var(--ar-text-sub) !important;
text-align: center;
}
// 曾经的好朋友 视觉效果
.lost-friend-visual {
display: flex;
align-items: center;
justify-content: center;
gap: 32px;
margin: 64px auto 48px;
position: relative;
max-width: 480px;
.avatar-group {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
z-index: 2;
.avatar-label {
font-size: 13px;
color: var(--ar-text-sub);
font-weight: 500;
opacity: 0.6;
}
&.sender {
animation: fadeInRight 1s ease-out backwards;
}
&.receiver {
animation: fadeInLeft 1s ease-out backwards;
}
}
.fading-line {
position: relative;
flex: 1;
height: 2px;
min-width: 120px;
display: flex;
align-items: center;
justify-content: center;
.line-path {
width: 100%;
height: 100%;
background: linear-gradient(to right,
var(--ar-primary) 0%,
rgba(var(--ar-primary-rgb), 0.4) 50%,
rgba(var(--ar-primary-rgb), 0.05) 100%);
border-radius: 2px;
}
.line-glow {
position: absolute;
inset: -4px 0;
background: linear-gradient(to right,
rgba(var(--ar-primary-rgb), 0.2) 0%,
transparent 100%);
filter: blur(8px);
pointer-events: none;
}
.flow-particle {
position: absolute;
width: 40px;
height: 2px;
background: linear-gradient(to right, transparent, var(--ar-primary), transparent);
border-radius: 2px;
opacity: 0;
animation: flowAcross 4s infinite linear;
}
}
}
.hero-desc.fading {
opacity: 0.7;
font-style: italic;
font-size: 16px;
margin-top: 32px;
line-height: 1.8;
letter-spacing: 0.05em;
animation: fadeIn 1.5s ease-out 0.5s backwards;
}
@keyframes flowAcross {
0% {
left: -20%;
opacity: 0;
}
10% {
opacity: 0.8;
}
50% {
opacity: 0.4;
}
90% {
opacity: 0.1;
}
100% {
left: 120%;
opacity: 0;
}
}
@keyframes fadeInRight {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes fadeInLeft {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}

View File

@@ -71,6 +71,20 @@ interface AnnualReportData {
socialInitiative?: { initiatedChats: number; receivedChats: number; initiativeRate: number } | null
responseSpeed?: { avgResponseTime: number; fastestFriend: string; fastestTime: number } | null
topPhrases?: { phrase: string; count: number }[]
snsStats?: {
totalPosts: number
typeCounts?: Record<string, number>
topLikers: { username: string; displayName: string; avatarUrl?: string; count: number }[]
topLiked: { username: string; displayName: string; avatarUrl?: string; count: number }[]
}
lostFriend: {
username: string
displayName: string
avatarUrl?: string
earlyCount: number
lateCount: number
periodDesc: string
} | null
}
interface SectionInfo {
@@ -95,148 +109,8 @@ const Avatar = ({ url, name, size = 'md' }: { url?: string; name: string; size?:
)
}
// 热力图组件
const Heatmap = ({ data }: { data: number[][] }) => {
const maxHeat = Math.max(...data.flat())
const weekLabels = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
return (
<div className="heatmap-wrapper">
<div className="heatmap-header">
<div></div>
<div className="time-labels">
{[0, 6, 12, 18].map(h => (
<span key={h} style={{ gridColumn: h + 1 }}>{h}</span>
))}
</div>
</div>
<div className="heatmap">
<div className="heatmap-week-col">
{weekLabels.map(w => <div key={w} className="week-label">{w}</div>)}
</div>
<div className="heatmap-grid">
{data.map((row, wi) =>
row.map((val, hi) => {
const alpha = maxHeat > 0 ? (val / maxHeat * 0.85 + 0.1).toFixed(2) : '0.1'
return (
<div
key={`${wi}-${hi}`}
className="h-cell"
style={{ background: `rgba(7, 193, 96, ${alpha})` }}
title={`${weekLabels[wi]} ${hi}:00 - ${val}`}
/>
)
})
)}
</div>
</div>
</div>
)
}
// 词云组件
const WordCloud = ({ words }: { words: { phrase: string; count: number }[] }) => {
const maxCount = words.length > 0 ? words[0].count : 1
const topWords = words.slice(0, 32)
const baseSize = 520
// 使用确定性随机数生成器
const seededRandom = (seed: number) => {
const x = Math.sin(seed) * 10000
return x - Math.floor(x)
}
// 计算词云位置
const placedItems: { x: number; y: number; w: number; h: number }[] = []
const canPlace = (x: number, y: number, w: number, h: number): boolean => {
const halfW = w / 2
const halfH = h / 2
const dx = x - 50
const dy = y - 50
const dist = Math.sqrt(dx * dx + dy * dy)
const maxR = 49 - Math.max(halfW, halfH)
if (dist > maxR) return false
const pad = 1.8
for (const p of placedItems) {
if ((x - halfW - pad) < (p.x + p.w / 2) &&
(x + halfW + pad) > (p.x - p.w / 2) &&
(y - halfH - pad) < (p.y + p.h / 2) &&
(y + halfH + pad) > (p.y - p.h / 2)) {
return false
}
}
return true
}
const wordItems = topWords.map((item, i) => {
const ratio = item.count / maxCount
const fontSize = Math.round(12 + Math.pow(ratio, 0.65) * 20)
const opacity = Math.min(1, Math.max(0.35, 0.35 + ratio * 0.65))
const delay = (i * 0.04).toFixed(2)
// 计算词语宽度
const charCount = Math.max(1, item.phrase.length)
const hasCjk = /[\u4e00-\u9fff]/.test(item.phrase)
const hasLatin = /[A-Za-z0-9]/.test(item.phrase)
const widthFactor = hasCjk && hasLatin ? 0.85 : hasCjk ? 0.98 : 0.6
const widthPx = fontSize * (charCount * widthFactor)
const heightPx = fontSize * 1.1
const widthPct = (widthPx / baseSize) * 100
const heightPct = (heightPx / baseSize) * 100
// 寻找位置
let x = 50, y = 50
let placedOk = false
const tries = i === 0 ? 1 : 420
for (let t = 0; t < tries; t++) {
if (i === 0) {
x = 50
y = 50
} else {
const idx = i + t * 0.28
const radius = Math.sqrt(idx) * 7.6 + (seededRandom(i * 1000 + t) * 1.2 - 0.6)
const angle = idx * 2.399963 + seededRandom(i * 2000 + t) * 0.35
x = 50 + radius * Math.cos(angle)
y = 50 + radius * Math.sin(angle)
}
if (canPlace(x, y, widthPct, heightPct)) {
placedOk = true
break
}
}
if (!placedOk) return null
placedItems.push({ x, y, w: widthPct, h: heightPct })
return (
<span
key={i}
className="word-tag"
style={{
'--final-opacity': opacity,
left: `${x.toFixed(2)}%`,
top: `${y.toFixed(2)}%`,
fontSize: `${fontSize}px`,
animationDelay: `${delay}s`,
} as React.CSSProperties}
title={`${item.phrase} (出现 ${item.count} 次)`}
>
{item.phrase}
</span>
)
}).filter(Boolean)
return (
<div className="word-cloud-wrapper">
<div className="word-cloud-inner">
{wordItems}
</div>
</div>
)
}
import Heatmap from '../components/ReportHeatmap'
import WordCloud from '../components/ReportWordCloud'
function AnnualReportWindow() {
const [reportData, setReportData] = useState<AnnualReportData | null>(null)
@@ -274,6 +148,8 @@ function AnnualReportWindow() {
responseSpeed: useRef<HTMLElement>(null),
topPhrases: useRef<HTMLElement>(null),
ranking: useRef<HTMLElement>(null),
sns: useRef<HTMLElement>(null),
lostFriend: useRef<HTMLElement>(null),
ending: useRef<HTMLElement>(null),
}
@@ -282,7 +158,8 @@ function AnnualReportWindow() {
useEffect(() => {
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
const yearParam = params.get('year')
const year = yearParam ? parseInt(yearParam) : new Date().getFullYear()
const parsedYear = yearParam ? parseInt(yearParam, 10) : new Date().getFullYear()
const year = Number.isNaN(parsedYear) ? new Date().getFullYear() : parsedYear
generateReport(year)
}, [])
@@ -337,6 +214,11 @@ function AnnualReportWindow() {
return `${Math.round(seconds / 3600)}小时`
}
const formatYearLabel = (value: number, withSuffix: boolean = true) => {
if (value === 0) return '历史以来'
return withSuffix ? `${value}` : `${value}`
}
// 获取可用的板块列表
const getAvailableSections = (): SectionInfo[] => {
if (!reportData) return []
@@ -367,10 +249,16 @@ function AnnualReportWindow() {
if (reportData.responseSpeed) {
sections.push({ id: 'responseSpeed', name: '回应速度', ref: sectionRefs.responseSpeed })
}
if (reportData.lostFriend) {
sections.push({ id: 'lostFriend', name: '曾经的好朋友', ref: sectionRefs.lostFriend })
}
if (reportData.topPhrases && reportData.topPhrases.length > 0) {
sections.push({ id: 'topPhrases', name: '年度常用语', ref: sectionRefs.topPhrases })
}
sections.push({ id: 'ranking', name: '好友排行', ref: sectionRefs.ranking })
if (reportData.snsStats && reportData.snsStats.totalPosts > 0) {
sections.push({ id: 'sns', name: '朋友圈', ref: sectionRefs.sns })
}
sections.push({ id: 'ending', name: '尾声', ref: sectionRefs.ending })
return sections
}
@@ -595,7 +483,8 @@ function AnnualReportWindow() {
const dataUrl = outputCanvas.toDataURL('image/png')
const link = document.createElement('a')
link.download = `${reportData?.year}年度报告${filterIds ? '_自定义' : ''}.png`
const yearFilePrefix = reportData ? formatYearLabel(reportData.year, false) : ''
link.download = `${yearFilePrefix}年度报告${filterIds ? '_自定义' : ''}.png`
link.href = dataUrl
document.body.appendChild(link)
link.click()
@@ -658,11 +547,12 @@ function AnnualReportWindow() {
}
setExportProgress('正在写入文件...')
const yearFilePrefix = reportData ? formatYearLabel(reportData.year, false) : ''
const exportResult = await window.electronAPI.annualReport.exportImages({
baseDir: dirResult.filePaths[0],
folderName: `${reportData?.year}年度报告_分模块`,
folderName: `${yearFilePrefix}年度报告_分模块`,
images: exportedImages.map((img) => ({
name: `${reportData?.year}年度报告_${img.name}.png`,
name: `${yearFilePrefix}年度报告_${img.name}.png`,
dataUrl: img.data
}))
})
@@ -733,10 +623,14 @@ function AnnualReportWindow() {
)
}
const { year, totalMessages, totalFriends, coreFriends, monthlyTopFriends, peakDay, longestStreak, activityHeatmap, midnightKing, selfAvatarUrl, mutualFriend, socialInitiative, responseSpeed, topPhrases } = reportData
const { year, totalMessages, totalFriends, coreFriends, monthlyTopFriends, peakDay, longestStreak, activityHeatmap, midnightKing, selfAvatarUrl, mutualFriend, socialInitiative, responseSpeed, topPhrases, lostFriend } = reportData
const topFriend = coreFriends[0]
const mostActive = getMostActiveTime(activityHeatmap.data)
const socialStoryName = topFriend?.displayName || '好友'
const yearTitle = formatYearLabel(year, true)
const yearTitleShort = formatYearLabel(year, false)
const monthlyTitle = year === 0 ? '历史以来月度好友' : `${year}年月度好友`
const phrasesTitle = year === 0 ? '你在历史以来的常用语' : `你在${year}年的年度常用语`
return (
<div className="annual-report-window">
@@ -827,7 +721,7 @@ function AnnualReportWindow() {
{/* 封面 */}
<section className="section" ref={sectionRefs.cover}>
<div className="label-text">WEFLOW · ANNUAL REPORT</div>
<h1 className="hero-title">{year}<br /></h1>
<h1 className="hero-title">{yearTitle}<br /></h1>
<hr className="divider" />
<p className="hero-desc"><br /></p>
</section>
@@ -869,7 +763,7 @@ function AnnualReportWindow() {
{/* 月度好友 */}
<section className="section" ref={sectionRefs.monthlyFriends}>
<div className="label-text"></div>
<h2 className="hero-title">{year}</h2>
<h2 className="hero-title">{monthlyTitle}</h2>
<p className="hero-desc">12</p>
<div className="monthly-orbit">
{monthlyTopFriends.map((m, i) => (
@@ -883,7 +777,7 @@ function AnnualReportWindow() {
<Avatar url={selfAvatarUrl} name="我" size="lg" />
</div>
</div>
<p className="hero-desc"><br /></p>
<p className="hero-desc"><br /></p>
</section>
{/* 双向奔赴 */}
@@ -983,15 +877,15 @@ function AnnualReportWindow() {
{midnightKing && (
<section className="section" ref={sectionRefs.midnightKing}>
<div className="label-text"></div>
<h2 className="hero-title"></h2>
<p className="hero-desc"></p>
<h2 className="hero-title"></h2>
<p className="hero-desc"></p>
<div className="big-stat">
<span className="stat-num">{midnightKing.count}</span>
<span className="stat-unit"></span>
</div>
<p className="hero-desc">
<span className="hl">{midnightKing.displayName}</span>
<br />Ta的对话占深夜期间聊天的 <span className="gold">{midnightKing.percentage}%</span>
<span className="hl">{midnightKing.displayName}</span>
<br />Ta的对话占深夜期间聊天的 <span className="gold">{midnightKing.percentage}%</span>
</p>
</section>
)}
@@ -1012,11 +906,46 @@ function AnnualReportWindow() {
</section>
)}
{/* 曾经的好朋友 */}
{lostFriend && (
<section className="section" ref={sectionRefs.lostFriend}>
<div className="label-text"></div>
<h2 className="hero-title">{lostFriend.displayName}</h2>
<div className="big-stat">
<span className="stat-num">{formatNumber(lostFriend.earlyCount)}</span>
<span className="stat-unit"></span>
</div>
<p className="hero-desc">
<span className="hl">{lostFriend.periodDesc}</span>
<br />
</p>
<div className="lost-friend-visual">
<div className="avatar-group sender">
<Avatar url={lostFriend.avatarUrl} name={lostFriend.displayName} size="lg" />
<span className="avatar-label">TA</span>
</div>
<div className="fading-line">
<div className="line-path" />
<div className="line-glow" />
<div className="flow-particle" />
</div>
<div className="avatar-group receiver">
<Avatar url={selfAvatarUrl} name="我" size="lg" />
<span className="avatar-label"></span>
</div>
</div>
<p className="hero-desc fading">
<br />
</p>
</section>
)}
{/* 年度常用语 - 词云 */}
{topPhrases && topPhrases.length > 0 && (
<section className="section" ref={sectionRefs.topPhrases}>
<div className="label-text"></div>
<h2 className="hero-title">{year}</h2>
<h2 className="hero-title">{phrasesTitle}</h2>
<p className="hero-desc">
<br />
@@ -1029,6 +958,57 @@ function AnnualReportWindow() {
</section>
)}
{/* 朋友圈 */}
{reportData.snsStats && reportData.snsStats.totalPosts > 0 && (
<section className="section" ref={sectionRefs.sns}>
<div className="label-text"></div>
<h2 className="hero-title"></h2>
<p className="hero-desc">
</p>
<div className="big-stat">
<span className="stat-num">{reportData.snsStats.totalPosts}</span>
<span className="stat-unit"></span>
</div>
<div className="sns-stats-container" style={{ display: 'flex', gap: '60px', marginTop: '40px', justifyContent: 'center' }}>
{reportData.snsStats.topLikers.length > 0 && (
<div className="sns-sub-stat" style={{ textAlign: 'left' }}>
<h3 className="sub-title" style={{ fontSize: '18px', marginBottom: '16px', opacity: 0.8, borderBottom: '1px solid currentColor', paddingBottom: '8px' }}>Ta</h3>
<div className="mini-ranking">
{reportData.snsStats.topLikers.slice(0, 3).map((u, i) => (
<div key={i} className="mini-rank-item" style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '14px' }}>
<Avatar url={u.avatarUrl} name={u.displayName} size="sm" />
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span className="name" style={{ fontSize: '15px', fontWeight: 500, maxWidth: '120px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{u.displayName}</span>
</div>
<span className="count hl" style={{ fontSize: '14px', marginLeft: 'auto' }}>{u.count}</span>
</div>
))}
</div>
</div>
)}
{reportData.snsStats.topLiked.length > 0 && (
<div className="sns-sub-stat" style={{ textAlign: 'left' }}>
<h3 className="sub-title" style={{ fontSize: '18px', marginBottom: '16px', opacity: 0.8, borderBottom: '1px solid currentColor', paddingBottom: '8px' }}>Ta</h3>
<div className="mini-ranking">
{reportData.snsStats.topLiked.slice(0, 3).map((u, i) => (
<div key={i} className="mini-rank-item" style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '14px' }}>
<Avatar url={u.avatarUrl} name={u.displayName} size="sm" />
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span className="name" style={{ fontSize: '15px', fontWeight: 500, maxWidth: '120px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{u.displayName}</span>
</div>
<span className="count hl" style={{ fontSize: '14px', marginLeft: 'auto' }}>{u.count}</span>
</div>
))}
</div>
</div>
)}
</div>
</section>
)}
{/* 好友排行 */}
<section className="section" ref={sectionRefs.ranking}>
<div className="label-text"></div>
@@ -1085,7 +1065,7 @@ function AnnualReportWindow() {
<br />
<br />
</p>
<div className="ending-year">{year}</div>
<div className="ending-year">{yearTitleShort}</div>
<div className="ending-brand">WEFLOW</div>
</section>
</div>

View File

@@ -1,8 +1,214 @@
.chat-page {
position: relative;
display: flex;
height: 100%;
gap: 16px;
// 批量删除进度遮罩
.delete-progress-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 0.3s ease;
.delete-progress-card {
width: 400px;
padding: 24px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 16px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
text-align: center;
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
h3 {
margin: 0;
font-size: 18px;
color: var(--text-primary);
}
.count {
font-variant-numeric: tabular-nums;
font-weight: 600;
background: var(--bg-tertiary);
padding: 2px 8px;
border-radius: 4px;
color: var(--primary);
}
}
.progress-bar-container {
height: 10px;
background: var(--bg-tertiary);
border-radius: 5px;
overflow: hidden;
margin-bottom: 20px;
.progress-bar-fill {
height: 100%;
background: linear-gradient(90deg, var(--primary), var(--primary-light));
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
}
.progress-footer {
display: flex;
flex-direction: column;
gap: 16px;
p {
font-size: 13px;
color: var(--text-tertiary);
margin: 0;
}
.cancel-delete-btn {
padding: 10px;
border-radius: 8px;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-secondary);
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
&:hover:not(:disabled) {
background: var(--danger-light);
color: var(--danger);
border-color: var(--danger);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
}
}
}
// 自定义删除确认对话框
.delete-confirm-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 10000;
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 0.2s ease;
.delete-confirm-card {
width: 360px;
padding: 32px 24px 24px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
.confirm-icon {
margin-bottom: 20px;
padding: 16px;
background: var(--danger-light);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.confirm-content {
margin-bottom: 32px;
h3 {
margin: 0 0 12px;
font-size: 20px;
color: var(--text-primary);
}
p {
margin: 0;
font-size: 14px;
color: var(--text-secondary);
line-height: 1.6;
}
}
.confirm-actions {
display: flex;
gap: 12px;
width: 100%;
button {
flex: 1;
padding: 12px;
border-radius: 12px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-secondary {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
}
.btn-danger-filled {
background: var(--danger);
border: none;
color: white;
&:hover {
background: #e54d45; // Darker red
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(229, 77, 69, 0.3);
}
&:active {
transform: translateY(0);
}
}
}
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
// 独立窗口模式 - EchoTrace 特色风格(使用主题变量)
&.standalone {
height: 100vh;
@@ -1082,6 +1288,21 @@
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 * {
-webkit-app-region: no-drag !important;
}
@@ -2016,12 +2237,43 @@
text-align: right;
color: var(--text-primary);
word-break: break-all;
user-select: text;
&.highlight {
color: var(--primary);
font-weight: 600;
}
}
.copy-btn {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
border: none;
border-radius: 4px;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.15s, color 0.15s, background 0.15s;
&:hover {
background: var(--bg-secondary);
color: var(--text-primary);
}
svg {
color: inherit;
}
}
&:hover .copy-btn {
opacity: 1;
}
}
.table-list {
@@ -2146,8 +2398,7 @@
}
.video-placeholder,
.video-loading,
.video-unavailable {
.video-loading {
min-width: 120px;
min-height: 80px;
display: flex;
@@ -2167,6 +2418,46 @@
}
}
.video-unavailable {
min-width: 160px;
min-height: 120px;
border-radius: 12px;
background: var(--bg-tertiary);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
color: var(--text-tertiary);
font-size: 12px;
border: none;
cursor: pointer;
text-align: center;
-webkit-app-region: no-drag;
transition: transform 0.15s ease, box-shadow 0.15s ease;
svg {
width: 24px;
height: 24px;
opacity: 0.6;
}
&.clicked {
transform: scale(0.98);
box-shadow: 0 0 0 2px var(--primary-light);
}
&:disabled {
cursor: default;
opacity: 0.7;
}
}
.video-action {
font-size: 11px;
color: var(--text-quaternary);
}
.video-loading {
.spin {
animation: spin 1s linear infinite;
@@ -2183,24 +2474,24 @@
background: var(--bg-tertiary);
border-radius: 8px;
min-width: 200px;
.card-icon {
flex-shrink: 0;
color: var(--primary);
}
.card-info {
flex: 1;
min-width: 0;
}
.card-name {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 2px;
}
.card-label {
font-size: 12px;
color: var(--text-tertiary);
@@ -2215,7 +2506,7 @@
padding: 8px 12px;
color: var(--text-secondary);
font-size: 13px;
svg {
flex-shrink: 0;
}
@@ -2233,21 +2524,21 @@
min-width: 220px;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: var(--bg-hover);
}
.file-icon {
flex-shrink: 0;
color: var(--primary);
}
.file-info {
flex: 1;
min-width: 0;
}
.file-name {
font-size: 14px;
font-weight: 500;
@@ -2257,7 +2548,7 @@
text-overflow: ellipsis;
white-space: nowrap;
}
.file-meta {
font-size: 12px;
color: var(--text-tertiary);
@@ -2281,7 +2572,7 @@
// 聊天记录消息 - 复用 link-message 基础样式
.chat-record-message {
cursor: pointer;
.link-header {
padding-bottom: 4px;
}
@@ -2356,17 +2647,17 @@
background: var(--bg-tertiary);
border-radius: 8px;
min-width: 200px;
.miniapp-icon {
flex-shrink: 0;
color: var(--primary);
}
.miniapp-info {
flex: 1;
min-width: 0;
}
.miniapp-title {
font-size: 14px;
font-weight: 500;
@@ -2376,7 +2667,7 @@
text-overflow: ellipsis;
white-space: nowrap;
}
.miniapp-label {
font-size: 12px;
color: var(--text-tertiary);
@@ -2419,6 +2710,13 @@
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.transfer-desc {
font-size: 12px;
margin-bottom: 4px;
opacity: 0.9;
line-height: 1.4;
}
.transfer-memo {
font-size: 13px;
margin-bottom: 8px;
@@ -2434,17 +2732,18 @@
// 发送消息中的特殊消息类型适配(除了文件和转账)
.message-bubble.sent {
.card-message,
.chat-record-message,
.miniapp-message {
background: rgba(255, 255, 255, 0.15);
.card-name,
.miniapp-title,
.source-name {
color: white;
}
.card-label,
.miniapp-label,
.chat-record-item,
@@ -2452,23 +2751,471 @@
.chat-record-desc {
color: rgba(255, 255, 255, 0.8);
}
.card-icon,
.miniapp-icon,
.chat-record-icon {
color: white;
}
.chat-record-more {
color: rgba(255, 255, 255, 0.9);
}
}
.call-message {
color: rgba(255, 255, 255, 0.9);
svg {
color: white;
}
}
.announcement-message {
background: rgba(255, 255, 255, 0.15);
.announcement-label {
color: rgba(255, 255, 255, 0.8);
}
.announcement-text {
color: white;
}
.announcement-icon {
color: white;
}
}
}
// 群公告消息
.announcement-message {
display: flex;
gap: 12px;
align-items: flex-start;
padding: 12px 14px;
background: var(--hover-color);
border-radius: 12px;
max-width: 320px;
.announcement-icon {
flex-shrink: 0;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
color: #f59e42;
svg {
width: 20px;
height: 20px;
}
}
.announcement-content {
flex: 1;
min-width: 0;
.announcement-label {
font-size: 12px;
color: var(--text-tertiary);
margin-bottom: 4px;
}
.announcement-text {
font-size: 14px;
color: var(--text-primary);
line-height: 1.5;
word-break: break-word;
white-space: pre-wrap;
}
}
}
// 批量转写按钮
.batch-transcribe-btn {
&:hover:not(:disabled) {
color: var(--primary-color);
}
&.transcribing {
color: var(--primary-color);
cursor: pointer;
opacity: 1 !important;
}
}
// 批量转写模态框基础样式(共享样式在 styles/batchTranscribe.scss
// 批量转写确认对话框
.batch-confirm-modal {
width: 480px;
max-width: 90vw;
.batch-modal-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1.5rem;
border-bottom: 1px solid var(--border-color);
svg {
color: var(--primary-color);
}
h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
}
.batch-modal-body {
padding: 1.5rem;
p {
margin: 0 0 1rem 0;
font-size: 14px;
color: var(--text-secondary);
line-height: 1.6;
}
.batch-dates-list-wrap {
margin-bottom: 1rem;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
.batch-dates-actions {
display: flex;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
.batch-dates-btn {
padding: 0.35rem 0.75rem;
font-size: 12px;
color: var(--primary-color);
background: transparent;
border: 1px solid var(--border-color);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
border-color: var(--primary-color);
}
}
}
.batch-dates-list {
list-style: none;
margin: 0;
padding: 0;
max-height: 160px;
overflow-y: auto;
li {
border-bottom: 1px solid var(--border-color);
&:last-child {
border-bottom: none;
}
}
.batch-date-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 0.75rem;
cursor: pointer;
transition: background 0.15s;
&:hover {
background: var(--bg-hover);
}
input[type="checkbox"] {
accent-color: var(--primary-color);
cursor: pointer;
flex-shrink: 0;
}
.batch-date-label {
flex: 1;
font-size: 14px;
color: var(--text-primary);
font-weight: 500;
}
.batch-date-count {
font-size: 12px;
color: var(--text-tertiary);
flex-shrink: 0;
}
}
}
}
.batch-info {
background: var(--bg-tertiary);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
.info-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0;
&:not(:last-child) {
border-bottom: 1px solid var(--border-color);
}
.label {
font-size: 13px;
color: var(--text-secondary);
}
.value {
font-size: 14px;
font-weight: 600;
color: var(--primary-color);
}
}
}
.batch-warning {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.75rem;
background: rgba(255, 152, 0, 0.1);
border-radius: 8px;
border: 1px solid rgba(255, 152, 0, 0.3);
svg {
flex-shrink: 0;
margin-top: 2px;
color: #ff9800;
}
span {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
}
}
}
.batch-modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.5rem;
border-top: 1px solid var(--border-color);
button {
padding: 0.5rem 1.25rem;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 0.5rem;
border: none;
&.btn-secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
&:hover {
background: var(--border-color);
}
}
&.btn-primary,
&.batch-transcribe-start-btn {
background: var(--primary-color);
color: white;
&:hover {
opacity: 0.9;
}
}
}
}
}
// Context Menu
.context-menu-overlay {
background: transparent;
}
.context-menu {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
padding: 4px;
min-width: 140px;
backdrop-filter: blur(10px);
animation: fadeIn 0.1s ease-out;
.menu-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
border-radius: 4px;
color: var(--text-primary);
font-size: 13px;
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
}
&.delete {
color: var(--danger);
&:hover {
background: rgba(220, 53, 69, 0.1);
}
}
svg {
opacity: 0.7;
}
}
}
// Modal Overlay
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
padding: 20px;
}
// Edit Message Modal
.edit-message-modal {
width: 500px;
max-width: 90vw;
max-height: 85vh;
background: var(--card-bg);
border-radius: 12px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2);
display: flex;
flex-direction: column;
overflow: hidden;
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border-color);
h3 {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.close-btn {
width: 28px;
height: 28px;
border: none;
background: transparent;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-tertiary);
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
}
}
.edit-message-textarea {
flex: 1;
border: none;
padding: 16px 20px;
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 14px;
line-height: 1.6;
resize: none;
outline: none;
min-height: 120px;
&:focus {
background: var(--bg-primary);
}
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 20px;
border-top: 1px solid var(--border-color);
background: var(--card-bg);
button {
padding: 8px 16px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-secondary {
background: var(--bg-tertiary);
border: 1px solid transparent;
color: var(--text-secondary);
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
}
.btn-primary {
background: var(--primary);
border: 1px solid transparent;
color: #fff;
box-shadow: 0 2px 8px var(--primary-light);
&:hover {
background: var(--primary-hover);
box-shadow: 0 4px 12px var(--primary-light);
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -7,8 +7,8 @@
// 左侧联系人面板
.contacts-panel {
width: 380px;
min-width: 380px;
width: 350px;
min-width: 350px;
display: flex;
flex-direction: column;
border-right: 1px solid var(--border-color);
@@ -55,6 +55,11 @@
.spin {
animation: contactsSpin 1s linear infinite;
}
&.export-mode-btn.active {
background: var(--primary);
color: #fff;
}
}
}
@@ -110,11 +115,11 @@
}
.type-filters {
display: flex;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
padding: 0 20px 16px;
flex-wrap: nowrap;
overflow-x: auto;
max-width: 300px;
&::-webkit-scrollbar {
display: none;
@@ -174,6 +179,24 @@
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,
.empty-state {
flex: 1;
@@ -213,12 +236,35 @@
padding: 12px;
border-radius: 10px;
transition: all 0.2s;
cursor: pointer;
margin-bottom: 4px;
&: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 {
width: 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 {
flex: 1;
@@ -548,4 +682,4 @@
to {
transform: rotate(360deg);
}
}
}

View File

@@ -1,5 +1,7 @@
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'
interface ContactInfo {
@@ -8,20 +10,28 @@ interface ContactInfo {
remark?: string
nickname?: string
avatarUrl?: string
type: 'friend' | 'group' | 'official' | 'other'
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
}
function ContactsPage() {
const [contacts, setContacts] = useState<ContactInfo[]>([])
const [filteredContacts, setFilteredContacts] = useState<ContactInfo[]>([])
const [selectedUsernames, setSelectedUsernames] = useState<Set<string>>(new Set())
const [isLoading, setIsLoading] = useState(true)
const [searchKeyword, setSearchKeyword] = useState('')
const [contactTypes, setContactTypes] = useState({
friends: true,
groups: true,
officials: true
groups: false,
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 [exportAvatars, setExportAvatars] = useState(true)
@@ -41,15 +51,10 @@ function ContactsPage() {
return
}
const contactsResult = await window.electronAPI.chat.getContacts()
console.log('📞 getContacts结果:', contactsResult)
if (contactsResult.success && contactsResult.contacts) {
console.log('📊 总联系人数:', contactsResult.contacts.length)
console.log('📊 按类型统计:', {
friends: contactsResult.contacts.filter((c: ContactInfo) => c.type === 'friend').length,
groups: contactsResult.contacts.filter((c: ContactInfo) => c.type === 'group').length,
officials: contactsResult.contacts.filter((c: ContactInfo) => c.type === 'official').length,
other: contactsResult.contacts.filter((c: ContactInfo) => c.type === 'other').length
})
// 获取头像URL
const usernames = contactsResult.contacts.map((c: ContactInfo) => c.username)
@@ -67,6 +72,7 @@ function ContactsPage() {
setContacts(contactsResult.contacts)
setFilteredContacts(contactsResult.contacts)
setSelectedUsernames(new Set())
}
} catch (e) {
console.error('加载通讯录失败:', e)
@@ -88,6 +94,7 @@ function ContactsPage() {
if (c.type === 'friend' && !contactTypes.friends) return false
if (c.type === 'group' && !contactTypes.groups) return false
if (c.type === 'official' && !contactTypes.officials) return false
if (c.type === 'former_friend' && !contactTypes.deletedFriends) return false
return true
})
@@ -116,6 +123,37 @@ function ContactsPage() {
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [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) => {
if (!name) return '?'
return [...name][0] || '?'
@@ -126,6 +164,7 @@ function ContactsPage() {
case 'friend': return <User size={14} />
case 'group': return <Users size={14} />
case 'official': return <MessageSquare size={14} />
case 'former_friend': return <UserX size={14} />
default: return <User size={14} />
}
}
@@ -135,6 +174,7 @@ function ContactsPage() {
case 'friend': return '好友'
case 'group': return '群聊'
case 'official': return '公众号'
case 'former_friend': return '曾经的好友'
default: return '其他'
}
}
@@ -159,6 +199,10 @@ function ContactsPage() {
alert('请先选择导出位置')
return
}
if (selectedUsernames.size === 0) {
alert('请至少选择一个联系人')
return
}
setIsExporting(true)
try {
@@ -169,7 +213,8 @@ function ContactsPage() {
friends: contactTypes.friends,
groups: contactTypes.groups,
officials: contactTypes.officials
}
},
selectedUsernames: Array.from(selectedUsernames)
}
const result = await window.electronAPI.export.exportContacts(exportFolder, exportOptions)
@@ -203,9 +248,18 @@ function ContactsPage() {
<div className="contacts-panel">
<div className="panel-header">
<h2></h2>
<button className="icon-btn" onClick={loadContacts} disabled={isLoading}>
<RefreshCw size={18} className={isLoading ? 'spin' : ''} />
</button>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<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 className="search-bar">
@@ -225,31 +279,20 @@ function ContactsPage() {
<div className="type-filters">
<label className={`filter-chip ${contactTypes.friends ? 'active' : ''}`}>
<input
type="checkbox"
checked={contactTypes.friends}
onChange={e => setContactTypes({ ...contactTypes, friends: e.target.checked })}
/>
<User size={16} />
<span></span>
<input type="checkbox" checked={contactTypes.friends} onChange={e => setContactTypes({ ...contactTypes, friends: e.target.checked })} />
<User size={16} /><span></span>
</label>
<label className={`filter-chip ${contactTypes.groups ? 'active' : ''}`}>
<input
type="checkbox"
checked={contactTypes.groups}
onChange={e => setContactTypes({ ...contactTypes, groups: e.target.checked })}
/>
<Users size={16} />
<span></span>
<input type="checkbox" checked={contactTypes.groups} onChange={e => setContactTypes({ ...contactTypes, groups: e.target.checked })} />
<Users size={16} /><span></span>
</label>
<label className={`filter-chip ${contactTypes.officials ? 'active' : ''}`}>
<input
type="checkbox"
checked={contactTypes.officials}
onChange={e => setContactTypes({ ...contactTypes, officials: e.target.checked })}
/>
<MessageSquare size={16} />
<span></span>
<input type="checkbox" checked={contactTypes.officials} onChange={e => setContactTypes({ ...contactTypes, officials: e.target.checked })} />
<MessageSquare size={16} /><span></span>
</label>
<label className={`filter-chip ${contactTypes.deletedFriends ? 'active' : ''}`}>
<input type="checkbox" checked={contactTypes.deletedFriends} onChange={e => setContactTypes({ ...contactTypes, deletedFriends: e.target.checked })} />
<UserX size={16} /><span></span>
</label>
</div>
@@ -257,6 +300,21 @@ function ContactsPage() {
{filteredContacts.length}
</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 ? (
<div className="loading-state">
<Loader2 size={32} className="spin" />
@@ -268,115 +326,177 @@ function ContactsPage() {
</div>
) : (
<div className="contacts-list">
{filteredContacts.map(contact => (
<div key={contact.username} className="contact-item">
<div className="contact-avatar">
{contact.avatarUrl ? (
<img src={contact.avatarUrl} alt="" />
) : (
<span>{getAvatarLetter(contact.displayName)}</span>
{filteredContacts.map(contact => {
const isChecked = selectedUsernames.has(contact.username)
const isActive = !exportMode && selectedContact?.username === contact.username
return (
<div
key={contact.username}
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 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 className="settings-panel">
<div className="panel-header">
<h2></h2>
</div>
{/* 右侧面板 */}
{exportMode ? (
<div className="settings-panel">
<div className="panel-header">
<h2></h2>
</div>
<div className="settings-content">
<div className="setting-section">
<h3></h3>
<div className="format-select" ref={formatDropdownRef}>
<button
type="button"
className={`select-trigger ${showFormatSelect ? 'open' : ''}`}
onClick={() => setShowFormatSelect(!showFormatSelect)}
>
<span className="select-value">{getOptionLabel(exportFormat)}</span>
<ChevronDown size={16} />
<div className="settings-content">
<div className="setting-section">
<h3></h3>
<div className="format-select" ref={formatDropdownRef}>
<button
type="button"
className={`select-trigger ${showFormatSelect ? 'open' : ''}`}
onClick={() => setShowFormatSelect(!showFormatSelect)}
>
<span className="select-value">{getOptionLabel(exportFormat)}</span>
<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>
{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="export-action">
<button
className="export-btn"
onClick={startExport}
disabled={!exportFolder || isExporting || selectedUsernames.size === 0}
>
{isExporting ? (
<><Loader2 size={18} className="spin" /><span>...</span></>
) : (
<><Download size={18} /><span></span></>
)}
</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>
</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">
<button
className="export-btn"
onClick={startExport}
disabled={!exportFolder || isExporting}
>
{isExporting ? (
<>
<Loader2 size={18} className="spin" />
<span>...</span>
</>
) : (
<>
<Download size={18} />
<span></span>
</>
)}
</button>
<div className="detail-info-list">
<div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.username}</span></div>
<div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.nickname || selectedContact.displayName}</span></div>
{selectedContact.remark && <div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.remark}</span></div>}
<div className="detail-row"><span className="detail-label"></span><span className="detail-value">{getContactTypeName(selectedContact.type)}</span></div>
</div>
<button
className="goto-chat-btn"
onClick={() => {
setCurrentSession(selectedContact.username)
navigate('/chat')
}}
>
<MessageCircle size={18} />
<span></span>
</button>
</div>
</div>
</div>
) : (
<div className="settings-panel">
<div className="empty-detail">
<User size={48} />
<span></span>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,193 @@
.dual-report-page {
padding: 32px 28px;
color: var(--text-primary);
}
.dual-report-page.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
gap: 12px;
color: var(--text-tertiary);
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
h1 {
margin: 0;
font-size: 24px;
font-weight: 700;
}
p {
margin: 8px 0 0;
color: var(--text-secondary);
}
}
.year-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 999px;
background: color-mix(in srgb, var(--primary) 12%, transparent);
color: var(--primary);
border: 1px solid color-mix(in srgb, var(--primary) 30%, transparent);
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.search-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
margin-bottom: 20px;
input {
flex: 1;
border: none;
outline: none;
background: transparent;
color: var(--text-primary);
font-size: 14px;
}
}
.ranking-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.ranking-item {
display: grid;
grid-template-columns: auto auto 1fr auto;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 14px;
text-align: left;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: var(--primary);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08);
transform: translateY(-1px);
}
}
.rank-badge {
width: 28px;
height: 28px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--border-color);
color: var(--text-secondary);
font-size: 12px;
font-weight: 700;
&.top {
background: color-mix(in srgb, var(--primary) 18%, transparent);
color: var(--primary);
}
}
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
overflow: hidden;
background: var(--primary-light);
display: flex;
align-items: center;
justify-content: center;
color: var(--primary);
font-weight: 700;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0; // 允许 flex 子项缩小,配合 ellipsis
.name {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sub {
font-size: 12px;
color: var(--text-secondary); // 从 tertiary 改为 secondary 以增强对比度
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
opacity: 0.8;
}
}
.meta {
text-align: right;
font-size: 12px;
color: var(--text-secondary); // 改为 secondary
flex-shrink: 0;
.count {
font-size: 14px;
font-weight: 700;
color: var(--primary); // 使用主题色更醒目
margin-bottom: 2px;
}
.hint {
opacity: 0.7;
}
}
.empty {
text-align: center;
color: var(--text-tertiary);
padding: 40px 0;
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

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

View File

@@ -0,0 +1,984 @@
.annual-report-window.dual-report-window {
.hero-title {
font-size: clamp(22px, 4vw, 34px);
white-space: nowrap;
}
.dual-cover-title {
font-size: clamp(26px, 5vw, 44px);
white-space: normal;
}
.dual-names {
font-size: clamp(24px, 4vw, 40px);
font-weight: 700;
display: flex;
align-items: center;
gap: 12px;
margin: 8px 0 16px;
color: var(--ar-text-main);
.amp {
color: var(--ar-primary);
}
}
.dual-info-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
margin-top: 16px;
}
.dual-info-card {
padding: 16px;
&.full {
grid-column: 1 / -1;
}
.info-label {
font-size: 12px;
color: var(--ar-text-sub);
margin-bottom: 8px;
}
.info-value {
font-size: 16px;
font-weight: 600;
color: var(--ar-text-main);
}
}
.dual-message-list {
margin-top: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.dual-message {
padding: 14px;
.message-meta {
font-size: 12px;
color: var(--ar-text-sub);
margin-bottom: 6px;
}
.message-content {
font-size: 14px;
color: var(--ar-text-main);
}
}
.first-chat-scene {
padding: 18px 16px 16px;
color: var(--ar-text-main);
position: relative;
overflow: hidden;
margin-top: 16px;
}
.first-chat-scene::before {
display: none;
}
.scene-title {
font-size: 24px;
font-weight: 700;
text-align: center;
margin-bottom: 8px;
color: var(--ar-text-main);
}
.scene-subtitle {
font-size: 18px;
font-weight: 500;
text-align: center;
margin-bottom: 20px;
opacity: 0.9;
color: var(--ar-text-sub);
}
.scene-messages {
display: flex;
flex-direction: column;
gap: 16px;
}
.scene-message {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 32px;
width: 100%;
&.system {
margin: 16px 0;
.system-msg-content {
background: rgba(255, 255, 255, 0.05);
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
color: rgba(255, 255, 255, 0.4);
text-align: center;
max-width: 80%;
}
}
.scene-meta {
font-size: 10px;
opacity: 0.65;
margin-bottom: 12px;
color: var(--text-tertiary);
text-align: center;
width: 100%;
}
.scene-body {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
max-width: 100%;
}
&.sent .scene-body {
flex-direction: row-reverse;
justify-content: flex-start;
}
&.received .scene-body {
flex-direction: row;
justify-content: flex-start;
}
}
.scene-avatar {
width: 42px;
height: 42px;
border-radius: 50%;
background: var(--ar-card-bg);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
color: var(--ar-text-sub);
border: 1px solid var(--bg-tertiary, rgba(0, 0, 0, 0.08));
overflow: hidden;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
.scene-content-wrapper {
display: flex;
flex-direction: column;
width: 100%;
max-width: min(78%, 720px);
}
.scene-message.sent .scene-content-wrapper {
align-items: flex-end;
}
.scene-bubble {
color: var(--ar-text-main);
padding: 10px 14px;
width: fit-content;
min-width: 40px;
max-width: 100%;
background: var(--ar-card-bg);
border-radius: 12px;
position: relative;
&.no-bubble {
background: transparent;
padding: 0;
box-shadow: none;
}
}
.scene-content {
line-height: 1.5;
font-size: clamp(14px, 1.8vw, 16px);
word-break: break-all;
white-space: pre-wrap;
overflow-wrap: break-word;
line-break: auto;
.report-emoji-container {
display: inline-block;
vertical-align: middle;
margin: 2px 0;
.report-emoji-img {
max-width: 120px;
max-height: 120px;
border-radius: 4px;
display: block;
}
}
}
.scene-avatar.fallback {
font-size: 14px;
}
.scene-avatar.with-image {
background: transparent;
color: transparent;
}
.scene-message.sent .scene-avatar {
border-color: color-mix(in srgb, var(--primary) 30%, var(--bg-tertiary, rgba(0, 0, 0, 0.08)));
}
.dual-stat-grid {
display: flex;
flex-wrap: nowrap;
gap: clamp(60px, 10vw, 120px);
margin: 48px 0 32px;
padding: 0;
justify-content: center;
align-items: flex-start;
&.bottom {
margin-top: 0;
margin-bottom: 48px;
gap: clamp(40px, 6vw, 80px);
}
}
.dual-stat-card {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
min-width: 140px;
max-width: 280px;
}
.stat-num {
font-size: clamp(36px, 6vw, 64px);
font-weight: 800;
font-variant-numeric: tabular-nums;
color: var(--ar-primary);
line-height: 1;
white-space: nowrap;
&.small {
font-size: clamp(24px, 4vw, 40px);
}
}
.stat-unit {
font-size: 14px;
margin-top: 4px;
opacity: 0.8;
}
.dual-stat-card.long .stat-num {
font-size: clamp(18px, 2.4vw, 26px);
letter-spacing: -0.02em;
}
.emoji-row {
display: grid;
grid-template-columns: repeat(2, minmax(260px, 1fr));
gap: 20px;
margin: 0 -12px;
}
.emoji-card {
padding: 18px 16px;
display: flex;
flex-direction: column;
gap: 10px;
align-items: center;
justify-content: center;
img {
width: 64px;
height: 64px;
object-fit: contain;
}
}
.emoji-title {
font-size: 12px;
color: var(--ar-text-sub);
}
.emoji-placeholder {
font-size: 12px;
color: var(--ar-text-sub);
word-break: break-all;
text-align: center;
}
.word-cloud-empty {
color: var(--ar-text-sub);
font-size: 14px;
text-align: center;
padding: 24px 0;
}
.initiative-container {
padding: 32px 0;
width: 100%;
background: transparent;
border: none;
}
.initiative-bar-wrapper {
display: flex;
align-items: center;
gap: 32px;
width: 100%;
padding: 24px 0;
margin-bottom: 24px;
position: relative;
}
.initiative-side {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
min-width: 80px;
z-index: 2;
.avatar-placeholder {
width: 54px;
height: 54px;
border-radius: 18px;
background: var(--bg-tertiary);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
color: var(--ar-text-sub);
font-size: 16px;
border: 1.5px solid rgba(255, 255, 255, 0.15);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.count {
font-size: 11px;
font-weight: 500;
opacity: 0.4;
color: var(--ar-text-sub);
}
.percent {
font-size: 14px;
color: var(--ar-text-main);
font-weight: 800;
opacity: 0.9;
}
}
.initiative-progress {
flex: 1;
height: 1px; // 线条样式
position: relative;
display: flex;
align-items: center;
.line-bg {
position: absolute;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg,
transparent 0%,
rgba(255, 255, 255, 0.1) 20%,
rgba(255, 255, 255, 0.1) 80%,
transparent 100%);
}
.initiative-indicator {
position: absolute;
width: 8px;
height: 8px;
background: #fff;
border-radius: 50%;
transform: translateX(-50%);
transition: left 1.5s cubic-bezier(0.16, 1, 0.3, 1);
box-shadow:
0 0 10px #fff,
0 0 20px rgba(255, 255, 255, 0.5),
0 0 30px var(--ar-primary);
z-index: 3;
&::before {
content: '';
position: absolute;
top: -4px;
left: -4px;
right: -4px;
bottom: -4px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 50%;
animation: pulse 2s infinite;
}
}
}
.initiative-desc {
text-align: center;
font-size: 14px;
color: var(--ar-text-sub);
letter-spacing: 1px;
opacity: 0.6;
background: transparent;
padding: 0;
margin: 0 auto;
font-style: italic;
}
@keyframes pulse {
0% {
transform: scale(1);
opacity: 0.8;
}
100% {
transform: scale(2);
opacity: 0;
}
}
.response-pulse-container {
width: 100%;
padding: 80px 0;
display: flex;
justify-content: center;
}
.pulse-visual {
position: relative;
width: 420px;
height: 240px;
display: flex;
align-items: center;
justify-content: center;
}
.pulse-hub {
position: relative;
z-index: 5;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 160px;
height: 160px;
background: radial-gradient(circle at center, rgba(255, 255, 255, 0.12) 0%, transparent 75%);
border-radius: 50%;
box-shadow: 0 0 40px rgba(255, 255, 255, 0.1);
.label {
font-size: 13px;
color: var(--ar-text-sub);
opacity: 0.6;
margin-bottom: 6px;
letter-spacing: 2px;
}
.value {
font-size: 54px;
font-weight: 950;
color: #fff;
line-height: 1;
text-shadow: 0 0 30px rgba(255, 255, 255, 0.5);
span {
font-size: 18px;
font-weight: 500;
margin-left: 4px;
opacity: 0.7;
}
}
}
.pulse-node {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
z-index: 4;
animation: floatNode 4s ease-in-out infinite;
&.left {
left: 0;
transform: translateX(-15%);
}
&.right {
right: 0;
transform: translateX(15%);
animation-delay: -2s;
}
.label {
font-size: 12px;
color: var(--ar-text-sub);
opacity: 0.5;
margin-bottom: 4px;
}
.value {
font-size: 24px;
font-weight: 800;
color: var(--ar-text-main);
opacity: 0.95;
span {
font-size: 13px;
margin-left: 2px;
opacity: 0.6;
}
}
}
.pulse-ripple {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border: 1.5px solid rgba(255, 255, 255, 0.08);
border-radius: 50%;
animation: ripplePulse 8s linear infinite;
pointer-events: none;
&.one {
animation-delay: 0s;
}
&.two {
animation-delay: 2.5s;
}
&.three {
animation-delay: 5s;
}
}
@keyframes ripplePulse {
0% {
width: 140px;
height: 140px;
opacity: 0.5;
}
100% {
width: 700px;
height: 700px;
opacity: 0;
}
}
@keyframes floatNode {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-16px);
}
}
.response-note {
text-align: center;
font-size: 14px;
color: var(--ar-text-sub);
opacity: 0.5;
margin-top: 32px;
font-style: italic;
max-width: none;
line-height: 1.6;
}
.streak-spark-visual.premium {
width: 100%;
height: 400px;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin: 20px 0;
overflow: visible;
.spark-ambient-glow {
position: absolute;
top: 40%;
left: 50%;
transform: translate(-50%, -50%);
width: 600px;
height: 480px;
background: radial-gradient(circle at center, rgba(242, 170, 0, 0.04) 0%, transparent 70%);
filter: blur(60px);
z-index: 1;
pointer-events: none;
}
}
.spark-core-wrapper {
position: relative;
width: 220px;
height: 280px;
display: flex;
align-items: center;
justify-content: center;
z-index: 5;
animation: flameSway 6s ease-in-out infinite;
transform-origin: bottom center;
}
.spark-flame-outer {
position: absolute;
width: 100%;
height: 100%;
background: radial-gradient(ellipse at 50% 85%, rgba(242, 170, 0, 0.15) 0%, transparent 75%);
border-radius: 50% 50% 20% 20% / 80% 80% 30% 30%;
filter: blur(25px);
animation: flickerOuter 4s infinite alternate;
}
.spark-flame-inner {
position: absolute;
bottom: 20%;
width: 140px;
height: 180px;
background: radial-gradient(ellipse at 50% 90%, rgba(255, 215, 0, 0.2) 0%, transparent 80%);
border-radius: 50% 50% 30% 30% / 85% 85% 25% 25%;
filter: blur(12px);
animation: flickerInner 3s infinite alternate-reverse;
}
.spark-core {
position: relative;
z-index: 10;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-bottom: 20px;
.spark-days {
font-size: 84px;
font-weight: 800;
color: rgba(255, 255, 255, 0.9);
line-height: 1;
letter-spacing: -1px;
text-shadow:
0 0 15px rgba(255, 255, 255, 0.4),
0 8px 30px rgba(0, 0, 0, 0.3);
}
.spark-label {
font-size: 14px;
font-weight: 800;
color: rgba(255, 255, 255, 0.4);
letter-spacing: 6px;
margin-top: 12px;
text-indent: 6px;
}
}
.streak-bridge.premium {
width: 100%;
max-width: 500px;
display: flex;
align-items: center;
gap: 0;
margin-top: -20px;
z-index: 20;
.bridge-date {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
width: 100px;
span {
font-size: 13px;
color: var(--ar-text-sub);
opacity: 0.6;
font-weight: 500;
letter-spacing: 0.2px;
position: absolute;
top: 24px;
white-space: nowrap;
}
.date-orb {
width: 6px;
height: 6px;
background: #fff;
border-radius: 50%;
box-shadow: 0 0 12px var(--ar-accent);
border: 1px solid rgba(252, 170, 0, 0.5);
}
}
.bridge-line {
flex: 1;
height: 40px;
position: relative;
display: flex;
align-items: center;
.line-string {
width: 100%;
height: 1.5px;
background: linear-gradient(90deg,
rgba(242, 170, 0, 0) 0%,
rgba(242, 170, 0, 0.6) 20%,
rgba(242, 170, 0, 0.6) 80%,
rgba(242, 170, 0, 0) 100%);
mask-image: radial-gradient(ellipse at center, black 60%, transparent 100%);
}
.line-glow {
position: absolute;
width: 100%;
height: 8px;
background: radial-gradient(ellipse at center, rgba(242, 170, 0, 0.2) 0%, transparent 80%);
filter: blur(4px);
animation: sparkFlicker 2s infinite alternate;
}
}
}
.spark-ember {
position: absolute;
background: #FFD700;
border-radius: 50%;
filter: blur(0.5px);
box-shadow: 0 0 6px #F2AA00;
opacity: 0;
z-index: 4;
&.one {
width: 3px;
height: 3px;
left: 46%;
animation: emberRise 5s infinite 0s;
}
&.two {
width: 2px;
height: 2px;
left: 53%;
animation: emberRise 4s infinite 1.2s;
}
&.three {
width: 4px;
height: 4px;
left: 50%;
animation: emberRise 6s infinite 2.5s;
}
&.four {
width: 2.5px;
height: 2.5px;
left: 48%;
animation: emberRise 5.5s infinite 3.8s;
}
}
@keyframes flameSway {
0%,
100% {
transform: rotate(-1deg) skewX(-1deg);
}
50% {
transform: rotate(1.5deg) skewX(1deg);
}
}
@keyframes flickerOuter {
0%,
100% {
opacity: 0.15;
filter: blur(25px);
}
50% {
opacity: 0.25;
filter: blur(30px);
}
}
@keyframes flickerInner {
0%,
100% {
transform: scale(1);
opacity: 0.2;
}
50% {
transform: scale(1.08);
opacity: 0.3;
}
}
@keyframes emberRise {
0% {
transform: translateY(100px) scale(1);
opacity: 0;
}
20% {
opacity: 0.8;
}
80% {
opacity: 0.3;
}
100% {
transform: translateY(-260px) scale(0.4);
opacity: 0;
}
}
@keyframes sparkFlicker {
0%,
100% {
transform: scale(1);
opacity: 0.9;
filter: brightness(1);
}
50% {
transform: scale(1.03);
opacity: 1;
filter: brightness(1.2);
}
}
@media (max-width: 960px) {
.pulse-visual {
transform: scale(0.85);
}
.scene-avatar {
width: 36px;
height: 36px;
font-size: 13px;
}
.scene-content-wrapper {
max-width: min(86%, 500px);
}
.scene-bubble {
max-width: 100%;
min-width: 56px;
}
}
// Word Cloud Tabs
.word-cloud-section {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
.word-cloud-tabs {
display: flex;
gap: 8px;
background: rgba(255, 255, 255, 0.08);
padding: 4px;
border-radius: 12px;
margin: 0 auto 32px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.tab-item {
padding: 8px 16px;
border-radius: 8px;
border: none;
background: transparent;
color: var(--ar-text-sub);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
&:hover {
color: var(--ar-text-main);
background: rgba(255, 255, 255, 0.05);
}
&.active {
background: var(--ar-card-bg);
color: var(--ar-primary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
font-weight: 600;
}
}
.word-cloud-container {
width: 100%;
&.fade-in {
animation: fadeIn 0.4s ease-out;
}
}
.empty-state {
text-align: center;
padding: 40px 0;
color: var(--ar-text-sub);
opacity: 0.6;
font-size: 14px;
background: rgba(255, 255, 255, 0.03);
border-radius: 16px;
border: 1px dashed rgba(255, 255, 255, 0.1);
margin-top: 20px;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
}

View File

@@ -0,0 +1,695 @@
import { useEffect, useState } from 'react'
import ReportHeatmap from '../components/ReportHeatmap'
import ReportWordCloud from '../components/ReportWordCloud'
import './AnnualReportWindow.scss'
import './DualReportWindow.scss'
interface DualReportMessage {
content: string
isSentByMe: boolean
createTime: number
createTimeStr: string
localType?: number
emojiMd5?: string
emojiCdnUrl?: string
}
interface DualReportData {
year: number
selfName: string
selfAvatarUrl?: string
friendUsername: string
friendName: string
friendAvatarUrl?: string
firstChat: {
createTime: number
createTimeStr: string
content: string
isSentByMe: boolean
senderUsername?: string
localType?: number
emojiMd5?: string
emojiCdnUrl?: string
} | null
firstChatMessages?: DualReportMessage[]
yearFirstChat?: {
createTime: number
createTimeStr: string
content: string
isSentByMe: boolean
friendName: string
firstThreeMessages: DualReportMessage[]
localType?: number
emojiMd5?: string
emojiCdnUrl?: string
} | null
stats: {
totalMessages: number
totalWords: number
imageCount: number
voiceCount: number
emojiCount: number
myTopEmojiMd5?: string
friendTopEmojiMd5?: string
myTopEmojiUrl?: string
friendTopEmojiUrl?: string
myTopEmojiCount?: number
friendTopEmojiCount?: number
}
topPhrases: Array<{ phrase: string; count: number }>
myExclusivePhrases: Array<{ phrase: string; count: number }>
friendExclusivePhrases: Array<{ phrase: string; count: number }>
heatmap?: number[][]
initiative?: { initiated: number; received: number }
response?: { avg: number; fastest: number; slowest: number; count: number }
monthly?: Record<string, number>
streak?: { days: number; startDate: string; endDate: string }
}
function DualReportWindow() {
const [reportData, setReportData] = useState<DualReportData | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [loadingStage, setLoadingStage] = useState('准备中')
const [loadingProgress, setLoadingProgress] = useState(0)
const [myEmojiUrl, setMyEmojiUrl] = useState<string | null>(null)
const [friendEmojiUrl, setFriendEmojiUrl] = useState<string | null>(null)
const [activeWordCloudTab, setActiveWordCloudTab] = useState<'shared' | 'my' | 'friend'>('shared')
useEffect(() => {
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
const username = params.get('username')
const yearParam = params.get('year')
const parsedYear = yearParam ? parseInt(yearParam, 10) : 0
const year = Number.isNaN(parsedYear) ? 0 : parsedYear
if (!username) {
setError('缺少好友信息')
setIsLoading(false)
return
}
generateReport(username, year)
}, [])
const generateReport = async (friendUsername: string, year: number) => {
setIsLoading(true)
setError(null)
setLoadingProgress(0)
const removeProgressListener = window.electronAPI.dualReport.onProgress?.((payload: { status: string; progress: number }) => {
setLoadingProgress(payload.progress)
setLoadingStage(payload.status)
})
try {
const result = await window.electronAPI.dualReport.generateReport({ friendUsername, year })
removeProgressListener?.()
setLoadingProgress(100)
setLoadingStage('完成')
if (result.success && result.data) {
setReportData(result.data)
setIsLoading(false)
} else {
setError(result.error || '生成报告失败')
setIsLoading(false)
}
} catch (e) {
removeProgressListener?.()
setError(String(e))
setIsLoading(false)
}
}
useEffect(() => {
const loadEmojis = async () => {
if (!reportData) return
setMyEmojiUrl(null)
setFriendEmojiUrl(null)
const stats = reportData.stats
if (stats.myTopEmojiUrl) {
const res = await window.electronAPI.chat.downloadEmoji(stats.myTopEmojiUrl, stats.myTopEmojiMd5)
if (res.success && res.localPath) {
setMyEmojiUrl(res.localPath)
}
}
if (stats.friendTopEmojiUrl) {
const res = await window.electronAPI.chat.downloadEmoji(stats.friendTopEmojiUrl, stats.friendTopEmojiMd5)
if (res.success && res.localPath) {
setFriendEmojiUrl(res.localPath)
}
}
}
void loadEmojis()
}, [reportData])
if (isLoading) {
return (
<div className="annual-report-window loading">
<div className="loading-ring">
<svg viewBox="0 0 100 100">
<circle className="ring-bg" cx="50" cy="50" r="42" />
<circle
className="ring-progress"
cx="50" cy="50" r="42"
style={{ strokeDashoffset: 264 - (264 * loadingProgress / 100) }}
/>
</svg>
<span className="ring-text">{loadingProgress}%</span>
</div>
<p className="loading-stage">{loadingStage}</p>
<p className="loading-hint"></p>
</div>
)
}
if (error) {
return (
<div className="annual-report-window error">
<p>: {error}</p>
</div>
)
}
if (!reportData) {
return (
<div className="annual-report-window error">
<p></p>
</div>
)
}
const yearTitle = reportData.year === 0 ? '全部时间' : `${reportData.year}`
const firstChat = reportData.firstChat
const firstChatMessages = (reportData.firstChatMessages && reportData.firstChatMessages.length > 0)
? reportData.firstChatMessages.slice(0, 3)
: firstChat
? [{
content: firstChat.content,
isSentByMe: firstChat.isSentByMe,
createTime: firstChat.createTime,
createTimeStr: firstChat.createTimeStr
}]
: []
const daysSince = firstChat
? Math.max(0, Math.floor((Date.now() - firstChat.createTime) / 86400000))
: null
const yearFirstChat = reportData.yearFirstChat
const stats = reportData.stats
const initiativeTotal = (reportData.initiative?.initiated || 0) + (reportData.initiative?.received || 0)
const initiatedPercent = initiativeTotal > 0 ? (reportData.initiative!.initiated / initiativeTotal) * 100 : 0
const receivedPercent = initiativeTotal > 0 ? (reportData.initiative!.received / initiativeTotal) * 100 : 0
const statItems = [
{ label: '总消息数', value: stats.totalMessages, color: '#07C160' },
{ label: '总字数', value: stats.totalWords, color: '#10AEFF' },
{ label: '图片', value: stats.imageCount, color: '#FFC300' },
{ label: '语音', value: stats.voiceCount, color: '#FA5151' },
{ label: '表情', value: stats.emojiCount, color: '#FA9D3B' },
]
const decodeEntities = (text: string) => (
text
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
)
const filterDisplayMessages = (messages: DualReportMessage[], maxActual: number = 3) => {
let actualCount = 0
const result: DualReportMessage[] = []
for (const msg of messages) {
const isSystem = msg.localType === 10000 || msg.localType === 10002
if (!isSystem) {
if (actualCount >= maxActual) break
actualCount++
}
result.push(msg)
}
return result
}
const stripCdata = (text: string) => text.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
const compactMessageText = (text: string) => (
text
.replace(/\r\n/g, '\n')
.replace(/\s*\n+\s*/g, ' ')
.replace(/\s{2,}/g, ' ')
.trim()
)
const extractXmlText = (content: string) => {
const titleMatch = content.match(/<title>([\s\S]*?)<\/title>/i)
if (titleMatch?.[1]) return titleMatch[1]
const descMatch = content.match(/<des>([\s\S]*?)<\/des>/i)
if (descMatch?.[1]) return descMatch[1]
const summaryMatch = content.match(/<summary>([\s\S]*?)<\/summary>/i)
if (summaryMatch?.[1]) return summaryMatch[1]
const contentMatch = content.match(/<content>([\s\S]*?)<\/content>/i)
if (contentMatch?.[1]) return contentMatch[1]
return ''
}
const formatMessageContent = (content?: string, localType?: number) => {
const isSystemMsg = localType === 10000 || localType === 10002
if (!isSystemMsg) {
if (localType === 3) return '[图片]'
if (localType === 34) return '[语音]'
if (localType === 43) return '[视频]'
if (localType === 47) return '[表情]'
if (localType === 42) return '[名片]'
if (localType === 48) return '[位置]'
if (localType === 49) return '[链接/文件]'
}
const raw = compactMessageText(String(content || '').trim())
if (!raw) return '(空)'
// 1. 尝试提取 XML 关键字段
const titleMatch = raw.match(/<title>([\s\S]*?)<\/title>/i)
if (titleMatch?.[1]) return compactMessageText(decodeEntities(stripCdata(titleMatch[1]).trim()))
const descMatch = raw.match(/<des>([\s\S]*?)<\/des>/i)
if (descMatch?.[1]) return compactMessageText(decodeEntities(stripCdata(descMatch[1]).trim()))
const summaryMatch = raw.match(/<summary>([\s\S]*?)<\/summary>/i)
if (summaryMatch?.[1]) return compactMessageText(decodeEntities(stripCdata(summaryMatch[1]).trim()))
// 2. 检查是否是 XML 结构
const hasXmlTag = /<\s*[a-zA-Z]+[^>]*>/.test(raw)
const looksLikeXml = /<\?xml|<msg\b|<appmsg\b|<sysmsg\b|<appattach\b|<emoji\b|<img\b|<voip\b/i.test(raw) || hasXmlTag
if (!looksLikeXml) return raw
// 3. 最后的尝试:移除所有 XML 标签,看是否还有有意义的文本
const stripped = raw.replace(/<[^>]+>/g, '').trim()
if (stripped && stripped.length > 0 && stripped.length < 50) {
return compactMessageText(decodeEntities(stripped))
}
return '[多媒体消息]'
}
const ReportMessageItem = ({ msg }: { msg: DualReportMessage }) => {
if (msg.localType === 47 && (msg.emojiMd5 || msg.emojiCdnUrl)) {
const emojiUrl = msg.emojiCdnUrl || (msg.emojiMd5 ? `https://emoji.qpic.cn/wx_emoji/${msg.emojiMd5}/0` : '')
if (emojiUrl) {
return (
<div className="report-emoji-container">
<img src={emojiUrl} alt="表情" className="report-emoji-img" onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
(e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style');
}} />
<span style={{ display: 'none' }}>[]</span>
</div>
)
}
}
return <span>{formatMessageContent(msg.content, msg.localType)}</span>
}
const formatFullDate = (timestamp: number) => {
const d = new Date(timestamp)
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const hour = String(d.getHours()).padStart(2, '0')
const minute = String(d.getMinutes()).padStart(2, '0')
return `${year}/${month}/${day} ${hour}:${minute}`
}
const getMostActiveTime = (data: number[][]) => {
let maxHour = 0
let maxWeekday = 0
let maxVal = -1
data.forEach((row, weekday) => {
row.forEach((value, hour) => {
if (value > maxVal) {
maxVal = value
maxHour = hour
maxWeekday = weekday
}
})
})
const weekdayNames = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
return {
weekday: weekdayNames[maxWeekday] || '周一',
hour: maxHour,
value: Math.max(0, maxVal)
}
}
const mostActive = reportData.heatmap ? getMostActiveTime(reportData.heatmap) : null
const responseAvgMinutes = reportData.response ? Math.max(0, Math.round(reportData.response.avg / 60)) : 0
const getSceneAvatarUrl = (isSentByMe: boolean) => (isSentByMe ? reportData.selfAvatarUrl : reportData.friendAvatarUrl)
const getSceneAvatarFallback = (isSentByMe: boolean) => (isSentByMe ? '我' : reportData.friendName.substring(0, 1))
const renderSceneAvatar = (isSentByMe: boolean) => {
const avatarUrl = getSceneAvatarUrl(isSentByMe)
if (avatarUrl) {
return (
<div className="scene-avatar with-image">
<img src={avatarUrl} alt={isSentByMe ? 'me-avatar' : 'friend-avatar'} />
</div>
)
}
return <div className="scene-avatar fallback">{getSceneAvatarFallback(isSentByMe)}</div>
}
const renderMessageList = (messages: DualReportMessage[]) => {
const displayMsgs = filterDisplayMessages(messages)
let lastTime = 0
const TIME_THRESHOLD = 5 * 60 * 1000 // 5 分钟
return displayMsgs.map((msg, idx) => {
const isSystem = msg.localType === 10000 || msg.localType === 10002
const showTime = idx === 0 || (msg.createTime - lastTime > TIME_THRESHOLD)
lastTime = msg.createTime
if (isSystem) {
return (
<div key={idx} className="scene-message system">
{showTime && (
<div className="scene-meta">
{formatFullDate(msg.createTime).split(' ')[1]}
</div>
)}
<div className="system-msg-content">
<ReportMessageItem msg={msg} />
</div>
</div>
)
}
return (
<div key={idx} className={`scene-message ${msg.isSentByMe ? 'sent' : 'received'}`}>
{showTime && (
<div className="scene-meta">
{formatFullDate(msg.createTime).split(' ')[1]}
</div>
)}
<div className="scene-body">
{renderSceneAvatar(msg.isSentByMe)}
<div className="scene-content-wrapper">
<div className={`scene-bubble ${msg.localType === 47 ? 'no-bubble' : ''}`}>
<div className="scene-content"><ReportMessageItem msg={msg} /></div>
</div>
</div>
</div>
</div>
)
})
}
return (
<div className="annual-report-window dual-report-window">
<div className="drag-region" />
<div className="bg-decoration">
<div className="deco-circle c1" />
<div className="deco-circle c2" />
<div className="deco-circle c3" />
<div className="deco-circle c4" />
<div className="deco-circle c5" />
</div>
<div className="report-scroll-view">
<div className="report-container">
<section className="section">
<div className="label-text">WEFLOW · DUAL REPORT</div>
<h1 className="hero-title dual-cover-title">{yearTitle}<br /></h1>
<hr className="divider" />
<div className="dual-names">
<span></span>
<span className="amp">&amp;</span>
<span>{reportData.friendName}</span>
</div>
<p className="hero-desc"></p>
</section>
<section className="section">
<div className="label-text"></div>
<h2 className="hero-title"></h2>
{firstChat ? (
<div className="first-chat-scene">
<div className="scene-title"></div>
<div className="scene-subtitle">{formatFullDate(firstChat.createTime).split(' ')[0]}</div>
{firstChatMessages.length > 0 ? (
<div className="scene-messages">
{renderMessageList(firstChatMessages)}
</div>
) : (
<div className="hero-desc" style={{ textAlign: 'center' }}></div>
)}
<div className="scene-footer" style={{ marginTop: '20px', textAlign: 'center', fontSize: '14px', opacity: 0.6 }}>
{daysSince}
</div>
</div>
) : (
<p className="hero-desc"></p>
)}
</section>
{yearFirstChat && (!firstChat || yearFirstChat.createTime !== firstChat.createTime) ? (
<section className="section">
<div className="label-text"></div>
<h2 className="hero-title">
{reportData.year === 0 ? '你们的第一段对话' : `${reportData.year}年的第一段对话`}
</h2>
<div className="first-chat-scene">
<div className="scene-title"></div>
<div className="scene-subtitle">{formatFullDate(yearFirstChat.createTime).split(' ')[0]}</div>
<div className="scene-messages">
{renderMessageList(yearFirstChat.firstThreeMessages)}
</div>
</div>
</section>
) : null}
{reportData.heatmap && (
<section className="section">
<div className="label-text"></div>
<h2 className="hero-title"></h2>
{mostActive && (
<p className="hero-desc active-time dual-active-time">
<span className="hl">{mostActive.weekday} {String(mostActive.hour).padStart(2, '0')}:00</span> {mostActive.value}
</p>
)}
<ReportHeatmap data={reportData.heatmap} />
</section>
)}
{reportData.initiative && (
<section className="section">
<div className="label-text"></div>
<h2 className="hero-title"></h2>
<div className="initiative-container">
<div className="initiative-bar-wrapper">
<div className="initiative-side">
<div className="avatar-placeholder">
{reportData.selfAvatarUrl ? <img src={reportData.selfAvatarUrl} alt="me-avatar" /> : '我'}
</div>
<div className="count">{reportData.initiative.initiated}</div>
<div className="percent">{initiatedPercent.toFixed(1)}%</div>
</div>
<div className="initiative-progress">
<div className="line-bg" />
<div
className="initiative-indicator"
style={{ left: `${initiatedPercent}%` }}
/>
</div>
<div className="initiative-side">
<div className="avatar-placeholder">
{reportData.friendAvatarUrl ? <img src={reportData.friendAvatarUrl} alt="friend-avatar" /> : reportData.friendName.substring(0, 1)}
</div>
<div className="count">{reportData.initiative.received}</div>
<div className="percent">{receivedPercent.toFixed(1)}%</div>
</div>
</div>
<div className="initiative-desc">
{reportData.initiative.initiated > reportData.initiative.received ? '每一个话题都是你对TA的在意' : 'TA总是那个率先打破沉默的人'}
</div>
</div>
</section>
)}
{reportData.response && (
<section className="section">
<div className="label-text"></div>
<h2 className="hero-title"></h2>
<div className="response-pulse-container">
<div className="pulse-visual">
<div className="pulse-ripple one" />
<div className="pulse-ripple two" />
<div className="pulse-ripple three" />
<div className="pulse-node left">
<div className="label"></div>
<div className="value">{reportData.response.fastest}<span></span></div>
</div>
<div className="pulse-hub">
<div className="label"></div>
<div className="value">{Math.round(reportData.response.avg / 60)}<span></span></div>
</div>
<div className="pulse-node right">
<div className="label"></div>
<div className="value">
{reportData.response.slowest > 3600
? (reportData.response.slowest / 3600).toFixed(1)
: Math.round(reportData.response.slowest / 60)}
<span>{reportData.response.slowest > 3600 ? '时' : '分'}</span>
</div>
</div>
</div>
</div>
<p className="hero-desc response-note">
{`${reportData.response.count} 次互动中,平均约 ${responseAvgMinutes} 分钟,最快 ${reportData.response.fastest} 秒。`}
</p>
</section>
)}
{reportData.streak && (
<section className="section">
<div className="label-text"></div>
<h2 className="hero-title"></h2>
<div className="streak-spark-visual premium">
<div className="spark-ambient-glow" />
<div className="spark-ember one" />
<div className="spark-ember two" />
<div className="spark-ember three" />
<div className="spark-ember four" />
<div className="spark-core-wrapper">
<div className="spark-flame-outer" />
<div className="spark-flame-inner" />
<div className="spark-core">
<div className="spark-days">{reportData.streak.days}</div>
<div className="spark-label">DAYS</div>
</div>
</div>
<div className="streak-bridge premium">
<div className="bridge-date start">
<div className="date-orb" />
<span>{reportData.streak.startDate}</span>
</div>
<div className="bridge-line">
<div className="line-glow" />
<div className="line-string" />
</div>
<div className="bridge-date end">
<span>{reportData.streak.endDate}</span>
<div className="date-orb" />
</div>
</div>
</div>
</section>
)}
<section className="section word-cloud-section">
<div className="label-text"></div>
<h2 className="hero-title">{yearTitle}</h2>
<div className="word-cloud-tabs">
<button
className={`tab-item ${activeWordCloudTab === 'shared' ? 'active' : ''}`}
onClick={() => setActiveWordCloudTab('shared')}
>
</button>
<button
className={`tab-item ${activeWordCloudTab === 'my' ? 'active' : ''}`}
onClick={() => setActiveWordCloudTab('my')}
>
</button>
<button
className={`tab-item ${activeWordCloudTab === 'friend' ? 'active' : ''}`}
onClick={() => setActiveWordCloudTab('friend')}
>
TA的专属
</button>
</div>
<div className={`word-cloud-container fade-in ${activeWordCloudTab}`}>
{activeWordCloudTab === 'shared' && <ReportWordCloud words={reportData.topPhrases} />}
{activeWordCloudTab === 'my' && (
reportData.myExclusivePhrases && reportData.myExclusivePhrases.length > 0 ? (
<ReportWordCloud words={reportData.myExclusivePhrases} />
) : (
<div className="empty-state"></div>
)
)}
{activeWordCloudTab === 'friend' && (
reportData.friendExclusivePhrases && reportData.friendExclusivePhrases.length > 0 ? (
<ReportWordCloud words={reportData.friendExclusivePhrases} />
) : (
<div className="empty-state"></div>
)
)}
</div>
</section>
<section className="section">
<div className="label-text"></div>
<h2 className="hero-title">{yearTitle}</h2>
<div className="dual-stat-grid">
{statItems.slice(0, 2).map((item) => (
<div key={item.label} className="dual-stat-card">
<div className="stat-num">{item.value.toLocaleString()}</div>
<div className="stat-unit">{item.label}</div>
</div>
))}
</div>
<div className="dual-stat-grid bottom">
{statItems.slice(2).map((item) => (
<div key={item.label} className="dual-stat-card">
<div className="stat-num small">{item.value.toLocaleString()}</div>
<div className="stat-unit">{item.label}</div>
</div>
))}
</div>
<div className="emoji-row">
<div className="emoji-card">
<div className="emoji-title"></div>
{myEmojiUrl ? (
<img src={myEmojiUrl} alt="my-emoji" onError={(e) => {
(e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style');
(e.target as HTMLImageElement).style.display = 'none';
}} />
) : null}
<div className="emoji-placeholder" style={myEmojiUrl ? { display: 'none' } : undefined}>
{stats.myTopEmojiMd5 || '暂无'}
</div>
<div className="emoji-count">{stats.myTopEmojiCount ? `${stats.myTopEmojiCount}` : '暂无统计'}</div>
</div>
<div className="emoji-card">
<div className="emoji-title">{reportData.friendName}</div>
{friendEmojiUrl ? (
<img src={friendEmojiUrl} alt="friend-emoji" onError={(e) => {
(e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style');
(e.target as HTMLImageElement).style.display = 'none';
}} />
) : null}
<div className="emoji-placeholder" style={friendEmojiUrl ? { display: 'none' } : undefined}>
{stats.friendTopEmojiMd5 || '暂无'}
</div>
<div className="emoji-count">{stats.friendTopEmojiCount ? `${stats.friendTopEmojiCount}` : '暂无统计'}</div>
</div>
</div>
</section>
<section className="section">
<div className="label-text"></div>
<h2 className="hero-title"></h2>
<p className="hero-desc"></p>
</section>
</div>
</div>
</div>
)
}
export default DualReportWindow

View File

@@ -830,8 +830,7 @@
padding: 28px 32px;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
min-width: 420px;
max-width: 500px;
width: 420px;
h3 {
font-size: 18px;
@@ -977,10 +976,10 @@
.calendar-days {
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-template-rows: repeat(6, 40px);
gap: 4px;
.calendar-day {
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import { useLocation } from 'react-router-dom'
import { Search, Download, FolderOpen, RefreshCw, Check, Calendar, FileJson, FileText, Table, Loader2, X, ChevronDown, ChevronLeft, ChevronRight, FileSpreadsheet, Database, FileCode, CheckCircle, XCircle, ExternalLink } from 'lucide-react'
import * as configService from '../services/config'
import './ExportPage.scss'
@@ -12,7 +13,7 @@ interface ChatSession {
}
interface ExportOptions {
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'sql'
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
dateRange: { start: Date; end: Date } | null
useAllTime: boolean
exportAvatars: boolean
@@ -38,6 +39,7 @@ interface ExportResult {
type SessionLayout = 'shared' | 'per-session'
function ExportPage() {
const location = useLocation()
const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content']
const [sessions, setSessions] = useState<ChatSession[]>([])
const [filteredSessions, setFilteredSessions] = useState<ChatSession[]>([])
@@ -46,14 +48,37 @@ function ExportPage() {
const [searchKeyword, setSearchKeyword] = useState('')
const [exportFolder, setExportFolder] = useState<string>('')
const [isExporting, setIsExporting] = useState(false)
const [exportProgress, setExportProgress] = useState({ current: 0, total: 0, currentName: '' })
const [exportProgress, setExportProgress] = useState({ current: 0, total: 0, currentName: '', phaseLabel: '', phaseProgress: 0, phaseTotal: 0 })
const [exportResult, setExportResult] = useState<ExportResult | null>(null)
const [showDatePicker, setShowDatePicker] = useState(false)
const [calendarDate, setCalendarDate] = useState(new Date())
const [selectingStart, setSelectingStart] = useState(true)
const [showMediaLayoutPrompt, setShowMediaLayoutPrompt] = useState(false)
const [showDisplayNameSelect, setShowDisplayNameSelect] = useState(false)
const [showPreExportDialog, setShowPreExportDialog] = useState(false)
const [preExportStats, setPreExportStats] = useState<{
totalMessages: number; voiceMessages: number; cachedVoiceCount: number;
needTranscribeCount: number; mediaMessages: number; estimatedSeconds: number
} | null>(null)
const [isLoadingStats, setIsLoadingStats] = useState(false)
const [pendingLayout, setPendingLayout] = useState<SessionLayout>('shared')
const exportStartTime = useRef<number>(0)
const [elapsedSeconds, setElapsedSeconds] = useState(0)
const displayNameDropdownRef = useRef<HTMLDivElement>(null)
const preselectAppliedRef = useRef(false)
const statsRequestIdRef = useRef(0)
const preselectSessionIds = useMemo(() => {
const state = location.state as { preselectSessionIds?: unknown; preselectSessionId?: unknown } | null
const rawList = Array.isArray(state?.preselectSessionIds)
? state?.preselectSessionIds
: (typeof state?.preselectSessionId === 'string' ? [state.preselectSessionId] : [])
return rawList
.filter((item): item is string => typeof item === 'string')
.map(item => item.trim())
.filter(Boolean)
}, [location.state])
const [options, setOptions] = useState<ExportOptions>({
format: 'excel',
@@ -68,7 +93,7 @@ function ExportPage() {
exportVoices: true,
exportVideos: true,
exportEmojis: true,
exportVoiceAsText: true,
exportVoiceAsText: false,
excelCompactColumns: true,
txtColumns: defaultTxtColumns,
displayNamePreference: 'remark',
@@ -159,7 +184,7 @@ function ExportPage() {
useAllTime: rangeDefaults.useAllTime,
dateRange: rangeDefaults.dateRange,
exportMedia: savedMedia ?? false,
exportVoiceAsText: savedVoiceAsText ?? true,
exportVoiceAsText: savedVoiceAsText ?? false,
excelCompactColumns: savedExcelCompactColumns ?? true,
txtColumns,
exportConcurrency: savedConcurrency ?? 2
@@ -175,6 +200,24 @@ function ExportPage() {
loadExportDefaults()
}, [loadSessions, loadExportPath, loadExportDefaults])
useEffect(() => {
preselectAppliedRef.current = false
}, [location.key, preselectSessionIds])
useEffect(() => {
if (preselectAppliedRef.current) return
if (sessions.length === 0 || preselectSessionIds.length === 0) return
const exists = new Set(sessions.map(session => session.username))
const matched = preselectSessionIds.filter(id => exists.has(id))
preselectAppliedRef.current = true
if (matched.length > 0) {
setSelectedSessions(new Set(matched))
setSearchKeyword('')
}
}, [sessions, preselectSessionIds])
useEffect(() => {
const handleChange = () => {
setSelectedSessions(new Set())
@@ -189,17 +232,30 @@ function ExportPage() {
}, [loadSessions])
useEffect(() => {
const removeListener = window.electronAPI.export.onProgress?.((payload: { current: number; total: number; currentSession: string; phase: string }) => {
const removeListener = window.electronAPI.export.onProgress?.((payload: { current: number; total: number; currentSession: string; phase: string; phaseProgress?: number; phaseTotal?: number; phaseLabel?: string }) => {
setExportProgress({
current: payload.current,
total: payload.total,
currentName: payload.currentSession
currentName: payload.currentSession,
phaseLabel: payload.phaseLabel || '',
phaseProgress: payload.phaseProgress || 0,
phaseTotal: payload.phaseTotal || 0
})
})
return () => {
removeListener?.()
}
}, [])
// 导出计时器
useEffect(() => {
if (!isExporting) return
const timer = setInterval(() => {
setElapsedSeconds(Math.floor((Date.now() - exportStartTime.current) / 1000))
}, 1000)
return () => clearInterval(timer)
}, [isExporting])
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node
@@ -260,8 +316,7 @@ function ExportPage() {
exportImages: true,
exportVoices: true,
exportVideos: true,
exportEmojis: true,
exportVoiceAsText: true
exportEmojis: true
}
}
return next
@@ -278,8 +333,10 @@ function ExportPage() {
if (selectedSessions.size === 0 || !exportFolder) return
setIsExporting(true)
setExportProgress({ current: 0, total: selectedSessions.size, currentName: '' })
setExportProgress({ current: 0, total: selectedSessions.size, currentName: '', phaseLabel: '', phaseProgress: 0, phaseTotal: 0 })
setExportResult(null)
exportStartTime.current = Date.now()
setElapsedSeconds(0)
try {
const sessionList = Array.from(selectedSessions)
@@ -304,7 +361,7 @@ function ExportPage() {
} : null
}
if (options.format === 'chatlab' || options.format === 'chatlab-jsonl' || options.format === 'json' || options.format === 'excel' || options.format === 'txt' || options.format === 'html') {
if (options.format === 'chatlab' || options.format === 'chatlab-jsonl' || options.format === 'json' || options.format === 'excel' || options.format === 'txt' || options.format === 'html' || options.format === 'weclone') {
const result = await window.electronAPI.export.exportSessions(
sessionList,
exportFolder,
@@ -322,9 +379,48 @@ function ExportPage() {
}
}
const startExport = () => {
const startExport = async () => {
if (selectedSessions.size === 0 || !exportFolder) return
// 先获取预估统计
const requestId = ++statsRequestIdRef.current
setIsLoadingStats(true)
setPreExportStats(null)
setShowPreExportDialog(true)
try {
const sessionList = Array.from(selectedSessions)
const exportOptions = {
format: options.format,
exportVoiceAsText: options.exportVoiceAsText,
exportMedia: options.exportMedia,
exportImages: options.exportMedia && options.exportImages,
exportVoices: options.exportMedia && options.exportVoices,
exportVideos: options.exportMedia && options.exportVideos,
exportEmojis: options.exportMedia && options.exportEmojis,
dateRange: options.useAllTime ? null : options.dateRange ? {
start: Math.floor(options.dateRange.start.getTime() / 1000),
end: Math.floor(new Date(options.dateRange.end.getFullYear(), options.dateRange.end.getMonth(), options.dateRange.end.getDate(), 23, 59, 59).getTime() / 1000)
} : null
}
const stats = await window.electronAPI.export.getExportStats(sessionList, exportOptions)
if (statsRequestIdRef.current !== requestId) return
setPreExportStats(stats)
} catch (e) {
console.error('获取导出统计失败:', e)
if (statsRequestIdRef.current !== requestId) return
setPreExportStats(null)
} finally {
if (statsRequestIdRef.current !== requestId) return
setIsLoadingStats(false)
}
}
const confirmExport = () => {
statsRequestIdRef.current++
setIsLoadingStats(false)
setShowPreExportDialog(false)
setPreExportStats(null)
if (options.exportMedia && selectedSessions.size > 1) {
setShowMediaLayoutPrompt(true)
return
@@ -425,6 +521,7 @@ function ExportPage() {
{ value: 'html', label: 'HTML', icon: FileText, desc: '网页格式,可直接浏览' },
{ value: 'txt', label: 'TXT', icon: Table, desc: '纯文本,通用格式' },
{ value: 'excel', label: 'Excel', icon: FileSpreadsheet, desc: '电子表格,适合统计分析' },
{ value: 'weclone', label: 'WeClone CSV', icon: Table, desc: 'WeClone 兼容字段格式CSV' },
{ value: 'sql', label: 'PostgreSQL', icon: Database, desc: '数据库脚本,便于导入到数据库' }
]
const displayNameOptions = [
@@ -814,6 +911,71 @@ function ExportPage() {
</div>
)}
{/* 导出前预估弹窗 */}
{showPreExportDialog && (
<div className="export-overlay">
<div className="export-layout-modal" onClick={e => e.stopPropagation()}>
<h3></h3>
{isLoadingStats ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '24px 0', justifyContent: 'center' }}>
<Loader2 size={20} className="spin" />
<span style={{ fontSize: 14, color: 'var(--text-secondary)' }}></span>
</div>
) : preExportStats ? (
<div style={{ padding: '12px 0' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px 24px', fontSize: 14 }}>
<div>
<span style={{ color: 'var(--text-secondary)' }}></span>
<div style={{ fontWeight: 600, fontSize: 18, marginTop: 2 }}>{selectedSessions.size}</div>
</div>
<div>
<span style={{ color: 'var(--text-secondary)' }}></span>
<div style={{ fontWeight: 600, fontSize: 18, marginTop: 2 }}>{preExportStats.totalMessages.toLocaleString()}</div>
</div>
{options.exportVoiceAsText && preExportStats.voiceMessages > 0 && (
<>
<div>
<span style={{ color: 'var(--text-secondary)' }}></span>
<div style={{ fontWeight: 600, fontSize: 18, marginTop: 2 }}>{preExportStats.voiceMessages}</div>
</div>
<div>
<span style={{ color: 'var(--text-secondary)' }}></span>
<div style={{ fontWeight: 600, fontSize: 18, marginTop: 2, color: 'var(--primary)' }}>{preExportStats.cachedVoiceCount}</div>
</div>
</>
)}
</div>
{options.exportVoiceAsText && preExportStats.needTranscribeCount > 0 && (
<div style={{ marginTop: 16, padding: '10px 12px', background: 'var(--bg-tertiary)', borderRadius: 8, fontSize: 13 }}>
<span style={{ color: 'var(--text-warning, #e6a23c)' }}></span>
{' '} <b>{preExportStats.needTranscribeCount}</b> <b>{preExportStats.estimatedSeconds > 60
? `${Math.round(preExportStats.estimatedSeconds / 60)} 分钟`
: `${preExportStats.estimatedSeconds}`
}</b>
</div>
)}
{options.exportVoiceAsText && preExportStats.voiceMessages > 0 && preExportStats.needTranscribeCount === 0 && (
<div style={{ marginTop: 16, padding: '10px 12px', background: 'var(--bg-tertiary)', borderRadius: 8, fontSize: 13 }}>
<span style={{ color: 'var(--text-success, #67c23a)' }}></span>
{' '} {preExportStats.voiceMessages}
</div>
)}
</div>
) : (
<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 }}>
<button className="layout-cancel-btn" onClick={() => { statsRequestIdRef.current++; setIsLoadingStats(false); setShowPreExportDialog(false); setPreExportStats(null) }}>
</button>
<button className="layout-option-btn primary" onClick={confirmExport}>
<span className="layout-title">{isLoadingStats ? '直接导出' : '开始导出'}</span>
</button>
</div>
</div>
</div>
)}
{/* 导出进度弹窗 */}
{isExporting && (
<div className="export-overlay">
@@ -823,13 +985,31 @@ function ExportPage() {
</div>
<h3></h3>
<p className="progress-text">{exportProgress.currentName}</p>
{exportProgress.phaseLabel && (
<p className="progress-phase-label" style={{ fontSize: 13, color: 'var(--text-secondary)', margin: '4px 0 8px' }}>
{exportProgress.phaseLabel}
</p>
)}
{exportProgress.phaseTotal > 0 && (
<div className="progress-bar" style={{ marginBottom: 8 }}>
<div
className="progress-fill"
style={{ width: `${(exportProgress.phaseProgress / exportProgress.phaseTotal) * 100}%`, background: 'var(--primary-light, #79bbff)' }}
/>
</div>
)}
<div className="progress-bar">
<div
className="progress-fill"
style={{ width: `${(exportProgress.current / exportProgress.total) * 100}%` }}
style={{ width: `${exportProgress.total > 0 ? (exportProgress.current / exportProgress.total) * 100 : 0}%` }}
/>
</div>
<p className="progress-count">{exportProgress.current} / {exportProgress.total}</p>
<p className="progress-count">
{exportProgress.current} / {exportProgress.total}
<span style={{ marginLeft: 12, fontSize: 12, color: 'var(--text-secondary)' }}>
{elapsedSeconds > 0 && `已用 ${elapsedSeconds >= 60 ? `${Math.floor(elapsedSeconds / 60)}${elapsedSeconds % 60}` : `${elapsedSeconds}`}`}
</span>
</p>
</div>
</div>
)}
@@ -912,7 +1092,7 @@ function ExportPage() {
>
<span className="date-label"></span>
<span className="date-value">
{options.dateRange?.start.toLocaleDateString('zh-CN', {
{options.dateRange?.start?.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
@@ -926,7 +1106,7 @@ function ExportPage() {
>
<span className="date-label"></span>
<span className="date-value">
{options.dateRange?.end.toLocaleDateString('zh-CN', {
{options.dateRange?.end?.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
@@ -964,9 +1144,9 @@ function ExportPage() {
}
const currentDate = new Date(calendarDate.getFullYear(), calendarDate.getMonth(), day)
const isStart = options.dateRange?.start.toDateString() === currentDate.toDateString()
const isEnd = options.dateRange?.end.toDateString() === currentDate.toDateString()
const isInRange = options.dateRange && currentDate >= options.dateRange.start && currentDate <= options.dateRange.end
const isStart = options.dateRange?.start?.toDateString() === currentDate.toDateString()
const isEnd = options.dateRange?.end?.toDateString() === currentDate.toDateString()
const isInRange = options.dateRange?.start && options.dateRange?.end && currentDate >= options.dateRange.start && currentDate <= options.dateRange.end
const today = new Date()
today.setHours(0, 0, 0, 0)
const isFuture = currentDate > today

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 {
display: flex;
flex-direction: column;

View File

@@ -1,8 +1,10 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, User, Medal, Search, X, ChevronLeft, Copy, Check, Download } from 'lucide-react'
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { useLocation } from 'react-router-dom'
import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, Medal, Search, X, ChevronLeft, Copy, Check, Download, ChevronDown } from 'lucide-react'
import { Avatar } from '../components/Avatar'
import ReactECharts from 'echarts-for-react'
import DateRangePicker from '../components/DateRangePicker'
import * as configService from '../services/config'
import './GroupAnalyticsPage.scss'
interface GroupChatInfo {
@@ -16,6 +18,10 @@ interface GroupMember {
username: string
displayName: string
avatarUrl?: string
nickname?: string
alias?: string
remark?: string
groupNickname?: string
}
interface GroupMessageRank {
@@ -23,9 +29,29 @@ interface GroupMessageRank {
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() {
const location = useLocation()
const [groups, setGroups] = useState<GroupChatInfo[]>([])
const [filteredGroups, setFilteredGroups] = useState<GroupChatInfo[]>([])
const [isLoading, setIsLoading] = useState(true)
@@ -40,10 +66,31 @@ function GroupAnalyticsPage() {
const [mediaStats, setMediaStats] = useState<{ typeCounts: Array<{ type: number; name: string; count: number }>; total: number } | null>(null)
const [functionLoading, setFunctionLoading] = 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 [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>('')
@@ -54,10 +101,102 @@ function GroupAnalyticsPage() {
const [sidebarWidth, setSidebarWidth] = useState(300)
const [isResizing, setIsResizing] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const preselectAppliedRef = useRef(false)
const preselectGroupIds = useMemo(() => {
const state = location.state as { preselectGroupIds?: unknown; preselectGroupId?: unknown } | null
const rawList = Array.isArray(state?.preselectGroupIds)
? state.preselectGroupIds
: (typeof state?.preselectGroupId === 'string' ? [state.preselectGroupId] : [])
return rawList
.filter((item): item is string => typeof item === 'string')
.map(item => item.trim())
.filter(Boolean)
}, [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(() => {
loadGroups()
}, [])
loadExportPath()
}, [loadGroups, loadExportPath])
useEffect(() => {
preselectAppliedRef.current = false
}, [location.key, preselectGroupIds])
useEffect(() => {
if (searchQuery) {
@@ -67,6 +206,48 @@ function GroupAnalyticsPage() {
}
}, [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(() => {
if (preselectAppliedRef.current) return
if (groups.length === 0 || preselectGroupIds.length === 0) return
const matchedGroup = groups.find(group => preselectGroupIds.includes(group.username))
preselectAppliedRef.current = true
if (matchedGroup) {
setSelectedGroup(matchedGroup)
setSelectedFunction(null)
setSearchQuery('')
}
}, [groups, preselectGroupIds])
// 拖动调整宽度
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
@@ -88,27 +269,12 @@ function GroupAnalyticsPage() {
// 日期范围变化时自动刷新
useEffect(() => {
if (dateRangeReady && selectedGroup && selectedFunction && selectedFunction !== 'members') {
if (dateRangeReady && selectedGroup && selectedFunction && selectedFunction !== 'members' && selectedFunction !== 'memberExport') {
setDateRangeReady(false)
loadFunctionData(selectedFunction)
}
}, [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(() => {
const handleChange = () => {
setGroups([])
@@ -120,15 +286,21 @@ function GroupAnalyticsPage() {
setActiveHours({})
setMediaStats(null)
void loadGroups()
void loadExportPath()
}
window.addEventListener('wxid-changed', handleChange as EventListener)
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
}, [loadGroups])
}, [loadExportPath, loadGroups])
const handleGroupSelect = (group: GroupChatInfo) => {
if (selectedGroup?.username !== group.username) {
setSelectedGroup(group)
setSelectedFunction(null)
setSelectedExportMemberUsername('')
setMemberSearchKeyword('')
setShowMemberSelect(false)
setShowFormatSelect(false)
setShowDisplayNameSelect(false)
}
}
@@ -154,6 +326,11 @@ function GroupAnalyticsPage() {
if (result.success && result.data) setMembers(result.data)
break
}
case 'memberExport': {
const result = await window.electronAPI.groupAnalytics.getGroupMembers(selectedGroup.username)
if (result.success && result.data) setMembers(result.data)
break
}
case 'ranking': {
const result = await window.electronAPI.groupAnalytics.getGroupMessageRanking(selectedGroup.username, 20, startTime, endTime)
if (result.success && result.data) setRankings(result.data)
@@ -249,6 +426,7 @@ function GroupAnalyticsPage() {
}
const handleDateRangeComplete = () => {
if (selectedFunction === 'memberExport') return
setDateRangeReady(true)
}
@@ -286,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) => {
try {
await navigator.clipboard.writeText(text)
@@ -298,6 +556,10 @@ function GroupAnalyticsPage() {
const renderMemberModal = () => {
if (!selectedMember) return null
const nickname = (selectedMember.nickname || '').trim()
const alias = (selectedMember.alias || '').trim()
const remark = (selectedMember.remark || '').trim()
const groupNickname = (selectedMember.groupNickname || '').trim()
return (
<div className="member-modal-overlay" onClick={() => setSelectedMember(null)}>
@@ -320,11 +582,40 @@ function GroupAnalyticsPage() {
</div>
<div className="detail-row">
<span className="detail-label"></span>
<span className="detail-value">{selectedMember.displayName}</span>
<button className="copy-btn" onClick={() => handleCopy(selectedMember.displayName, 'displayName')}>
{copiedField === 'displayName' ? <Check size={14} /> : <Copy size={14} />}
</button>
<span className="detail-value">{nickname || '未设置'}</span>
{nickname && (
<button className="copy-btn" onClick={() => handleCopy(nickname, 'nickname')}>
{copiedField === 'nickname' ? <Check size={14} /> : <Copy size={14} />}
</button>
)}
</div>
{alias && (
<div className="detail-row">
<span className="detail-label"></span>
<span className="detail-value">{alias}</span>
<button className="copy-btn" onClick={() => handleCopy(alias, 'alias')}>
{copiedField === 'alias' ? <Check size={14} /> : <Copy size={14} />}
</button>
</div>
)}
{groupNickname && (
<div className="detail-row">
<span className="detail-label"></span>
<span className="detail-value">{groupNickname}</span>
<button className="copy-btn" onClick={() => handleCopy(groupNickname, 'groupNickname')}>
{copiedField === 'groupNickname' ? <Check size={14} /> : <Copy size={14} />}
</button>
</div>
)}
{remark && (
<div className="detail-row">
<span className="detail-label"></span>
<span className="detail-value">{remark}</span>
<button className="copy-btn" onClick={() => handleCopy(remark, 'remark')}>
{copiedField === 'remark' ? <Check size={14} /> : <Copy size={14} />}
</button>
</div>
)}
</div>
</div>
</div>
@@ -409,6 +700,10 @@ function GroupAnalyticsPage() {
<Users size={32} />
<span></span>
</div>
<div className="function-card" onClick={() => handleFunctionSelect('memberExport')}>
<Download size={32} />
<span></span>
</div>
<div className="function-card" onClick={() => handleFunctionSelect('ranking')}>
<BarChart3 size={32} />
<span></span>
@@ -429,6 +724,7 @@ function GroupAnalyticsPage() {
const getFunctionTitle = () => {
switch (selectedFunction) {
case 'members': return '群成员查看'
case 'memberExport': return '成员消息导出'
case 'ranking': return '群聊发言排行'
case 'activeHours': return '群聊活跃时段'
case 'mediaStats': return '媒体内容统计'
@@ -484,6 +780,234 @@ function GroupAnalyticsPage() {
))}
</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' && (
<div className="rankings-list">
{rankings.map((item, index) => (

View File

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

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

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

View File

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

View File

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

View File

@@ -180,7 +180,7 @@
animation: pulse 1.5s ease-in-out infinite;
}
input {
input:not(.filter-search-box input) {
width: 100%;
padding: 10px 16px;
border: 1px solid var(--border-color);
@@ -207,6 +207,7 @@
select {
width: 100%;
padding: 10px 16px;
padding-right: 36px;
border: 1px solid var(--border-color);
border-radius: 9999px;
font-size: 14px;
@@ -214,6 +215,9 @@
color: var(--text-primary);
margin-bottom: 10px;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
&:focus {
outline: none;
@@ -221,6 +225,124 @@
}
}
.select-wrapper {
position: relative;
margin-bottom: 10px;
select {
margin-bottom: 0;
}
>svg {
position: absolute;
right: 14px;
top: 50%;
transform: translateY(-50%);
color: var(--text-tertiary);
pointer-events: none;
}
}
// 自定义下拉选择框
.custom-select {
position: relative;
margin-bottom: 10px;
}
.custom-select-trigger {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 10px 16px;
border: 1px solid var(--border-color);
border-radius: 9999px;
font-size: 14px;
background: var(--bg-primary);
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: var(--text-tertiary);
}
&.open {
border-color: var(--primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent);
}
}
.custom-select-value {
flex: 1;
}
.custom-select-arrow {
color: var(--text-tertiary);
transition: transform 0.2s ease;
&.rotate {
transform: rotate(180deg);
}
}
.custom-select-dropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
background: color-mix(in srgb, var(--bg-primary) 90%, var(--bg-secondary));
border: 1px solid var(--border-color);
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
overflow: hidden;
z-index: 100;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
// 展开收起动画
opacity: 0;
visibility: hidden;
transform: translateY(-8px) scaleY(0.95);
transform-origin: top center;
transition: all 0.2s cubic-bezier(0.2, 0, 0.2, 1);
&.open {
opacity: 1;
visibility: visible;
transform: translateY(0) scaleY(1);
}
}
.custom-select-option {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
font-size: 14px;
color: var(--text-primary);
cursor: pointer;
transition: background 0.15s;
&:hover {
background: var(--bg-tertiary);
}
&.selected {
color: var(--primary);
font-weight: 500;
svg {
color: var(--primary);
}
}
svg {
flex-shrink: 0;
}
}
.select-field {
position: relative;
margin-bottom: 10px;
@@ -529,14 +651,80 @@
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 6px;
margin-top: 10px;
padding: 12px 16px;
background: var(--bg-primary);
border-radius: 12px;
border: 1px solid var(--border-color);
transition: all 0.2s;
&:hover {
border-color: var(--text-tertiary);
background: color-mix(in srgb, var(--bg-primary) 98%, var(--primary));
}
}
.log-status {
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
}
/* Premium Switch Style */
.switch {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
flex-shrink: 0;
input {
opacity: 0;
width: 0;
height: 0;
&:checked+.switch-slider {
background-color: var(--primary);
box-shadow: 0 0 8px color-mix(in srgb, var(--primary) 30%, transparent);
&::before {
transform: translateX(18px);
background-color: white;
}
}
&:focus+.switch-slider {
box-shadow: 0 0 1px var(--primary);
}
}
.switch-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--bg-tertiary);
transition: .4s cubic-bezier(0.4, 0, 0.2, 1);
border-radius: 24px;
border: 1px solid var(--border-color);
&::before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 2px;
bottom: 2px;
background-color: var(--text-tertiary);
transition: .4s cubic-bezier(0.4, 0, 0.2, 1);
border-radius: 50%;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
}
}
.language-checkboxes {
display: flex;
flex-wrap: wrap;
@@ -1003,6 +1191,111 @@
}
}
// 通用弹窗覆盖层
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.2s ease;
}
// API 警告弹窗
.api-warning-modal {
width: 420px;
background: var(--bg-primary);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.2);
animation: slideUp 0.25s ease;
.modal-header {
display: flex;
align-items: center;
gap: 10px;
padding: 20px 24px;
border-bottom: 1px solid var(--border-color);
svg {
color: var(--warning, #f59e0b);
}
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
}
.modal-body {
padding: 20px 24px;
.warning-text {
margin: 0 0 16px;
font-size: 14px;
color: var(--text-secondary);
line-height: 1.6;
}
.warning-list {
display: flex;
flex-direction: column;
gap: 10px;
.warning-item {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
.bullet {
color: var(--warning, #f59e0b);
font-weight: bold;
}
}
}
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 16px 24px;
border-top: 1px solid var(--border-color);
background: var(--bg-secondary);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// 协议弹窗
.agreement-overlay {
@@ -1264,4 +1557,619 @@
border-top: 1px solid var(--border-primary);
display: flex;
justify-content: flex-end;
}
// 通知过滤双列表容器
.notification-filter-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-top: 12px;
}
.filter-panel {
display: flex;
flex-direction: column;
background: var(--bg-tertiary);
border-radius: 12px;
overflow: hidden;
border: 1px solid var(--border-color);
}
.filter-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 12px;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
background: var(--bg-primary);
border-bottom: 1px solid var(--border-color);
>span {
flex-shrink: 0;
}
}
.filter-search-box {
display: flex;
align-items: center;
gap: 4px;
flex: 1;
max-width: 140px;
padding: 4px 8px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 6px;
transition: all 0.2s;
&:focus-within {
border-color: var(--primary);
background: var(--bg-primary);
}
svg {
flex-shrink: 0;
width: 12px;
height: 12px;
color: var(--text-tertiary);
}
input {
flex: 1;
min-width: 0;
border: none;
background: transparent;
font-size: 12px;
color: var(--text-primary);
outline: none;
&::placeholder {
color: var(--text-tertiary);
}
}
}
.filter-panel-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 6px;
font-size: 12px;
font-weight: 600;
color: white;
background: var(--primary);
border-radius: 10px;
}
.filter-panel-list {
flex: 1;
min-height: 200px;
max-height: 300px;
overflow-y: auto;
padding: 8px;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 3px;
}
}
.filter-panel-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
margin-bottom: 4px;
background: var(--bg-primary);
border-radius: 8px;
cursor: pointer;
transition: all 0.15s;
&:last-child {
margin-bottom: 0;
}
&:hover {
background: var(--bg-secondary);
.filter-item-action {
opacity: 1;
color: var(--primary);
}
}
&.selected {
background: color-mix(in srgb, var(--primary) 8%, var(--bg-primary));
border: 1px solid color-mix(in srgb, var(--primary) 20%, transparent);
&:hover .filter-item-action {
color: var(--danger);
}
}
.filter-item-name {
flex: 1;
font-size: 14px;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.filter-item-action {
font-size: 18px;
font-weight: 500;
color: var(--text-tertiary);
opacity: 0.5;
transition: all 0.15s;
}
}
.filter-panel-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: 100px;
font-size: 13px;
color: var(--text-tertiary);
// Add styles for the new model cards
}
.setting-control.vertical.has-border {
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 16px;
background: var(--bg-primary);
}
.model-status-card {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
}
.model-info {
flex: 1;
min-width: 0;
.model-name {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 6px;
}
.model-path {
display: flex;
flex-direction: column;
gap: 4px;
.status-indicator {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 500;
&.success {
color: #10b981;
}
&.warning {
color: #f59e0b;
}
}
.path-text {
font-size: 12px;
color: var(--text-tertiary);
font-family: monospace;
word-break: break-all;
}
}
}
.model-actions {
flex-shrink: 0;
.btn-download {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: var(--primary);
color: white;
border: none;
border-radius: 999px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 4px 12px color-mix(in srgb, var(--primary) 25%, transparent);
&:hover {
background: var(--primary-hover);
transform: translateY(-1px);
box-shadow: 0 6px 16px color-mix(in srgb, var(--primary) 35%, transparent);
}
&:active {
transform: translateY(0);
}
svg {
flex-shrink: 0;
}
}
.download-status {
display: flex;
flex-direction: column;
gap: 6px;
width: 280px;
.status-header,
.progress-info {
// specific layout class
display: flex;
justify-content: space-between;
align-items: center; // Align vertically
width: 100%;
}
.percent {
font-size: 14px;
font-weight: 700;
color: var(--primary);
font-family: 'Inter', system-ui, sans-serif;
}
.metrics,
.details {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-secondary);
font-family: var(--font-mono);
.speed {
color: var(--text-primary);
font-weight: 600;
opacity: 0.8;
}
}
}
.progress-bar-mini {
width: 100%;
height: 6px;
background: var(--bg-tertiary);
border-radius: 3px;
overflow: hidden;
border: 1px solid var(--border-color);
.fill {
height: 100%;
background: linear-gradient(90deg, var(--primary) 0%, color-mix(in srgb, var(--primary) 80%, white) 100%);
border-radius: 3px;
transition: width 0.3s ease;
position: relative;
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(90deg,
transparent,
rgba(255, 255, 255, 0.2),
transparent);
animation: progress-shimmer 2s infinite;
}
}
}
.spin {
animation: spin 1s linear infinite;
}
}
@keyframes progress-shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
.sub-setting {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border-color);
.sub-label {
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 8px;
}
}
.path-selector {
display: flex;
gap: 8px;
input {
margin-bottom: 0 !important;
flex: 1;
font-family: monospace;
font-size: 12px;
}
.btn-icon {
width: 38px;
height: 38px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 999px; // Circle
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
&:hover {
color: var(--text-primary);
background: var(--bg-tertiary);
}
&.danger:hover {
color: var(--danger);
background: rgba(220, 38, 38, 0.1);
border-color: rgba(220, 38, 38, 0.2);
}
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
// API 地址显示样式
.api-url-display {
display: flex;
gap: 8px;
margin-top: 8px;
input {
flex: 1;
font-family: 'SF Mono', 'Consolas', monospace;
font-size: 13px;
}
.btn {
flex-shrink: 0;
}
}
// API 服务设置样式
.status-badge {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
&.running {
background: rgba(34, 197, 94, 0.15);
color: #22c55e;
}
&.stopped {
background: rgba(156, 163, 175, 0.15);
color: var(--text-tertiary);
}
}
.api-url {
display: inline-block;
padding: 8px 14px;
background: var(--bg-tertiary);
border-radius: 6px;
font-family: 'SF Mono', 'Consolas', monospace;
font-size: 13px;
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.api-docs {
display: flex;
flex-direction: column;
gap: 12px;
}
.api-item {
padding: 12px 16px;
background: var(--bg-tertiary);
border-radius: 8px;
border: 1px solid var(--border-color);
.api-endpoint {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 6px;
.method {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
&.get {
background: rgba(34, 197, 94, 0.15);
color: #22c55e;
}
&.post {
background: rgba(59, 130, 246, 0.15);
color: #3b82f6;
}
}
code {
font-family: 'SF Mono', 'Consolas', monospace;
font-size: 13px;
color: var(--text-primary);
}
}
.api-desc {
font-size: 12px;
color: var(--text-secondary);
margin: 0 0 8px 0;
}
.api-params {
display: flex;
flex-wrap: wrap;
gap: 6px;
.param {
display: inline-block;
padding: 2px 8px;
background: var(--bg-secondary);
border-radius: 4px;
font-size: 11px;
color: var(--text-tertiary);
code {
color: var(--primary);
font-family: 'SF Mono', 'Consolas', monospace;
}
}
}
}
.code-block {
padding: 12px 16px;
background: var(--bg-tertiary);
border-radius: 8px;
border: 1px solid var(--border-color);
overflow-x: auto;
code {
font-family: 'SF Mono', 'Consolas', monospace;
font-size: 12px;
color: var(--text-primary);
white-space: nowrap;
}
}
.btn-sm {
padding: 4px 10px !important;
font-size: 12px !important;
svg {
width: 14px;
height: 14px;
}
}
// Analysis Settings Styling
.settings-section {
h2 {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 16px;
}
}
.setting-item {
margin-bottom: 20px;
}
.setting-label {
display: flex;
flex-direction: column;
margin-bottom: 8px;
span:first-child {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
}
.setting-desc {
font-size: 13px;
color: var(--text-tertiary);
margin-top: 2px;
}
}
.setting-control {
display: flex;
// textarea specific
textarea.form-input {
width: 100%;
padding: 12px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 12px;
color: var(--text-primary);
font-family: monospace;
font-size: 13px;
resize: vertical;
transition: all 0.2s;
outline: none;
&:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 10%, transparent);
}
&::placeholder {
color: var(--text-tertiary);
}
}
.button-group {
display: flex;
gap: 12px;
width: 100%;
margin-top: 12px;
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,108 @@
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

@@ -12,6 +12,7 @@ export const CONFIG_KEYS = {
LAST_SESSION: 'lastSession',
WINDOW_BOUNDS: 'windowBounds',
CACHE_PATH: 'cachePath',
EXPORT_PATH: 'exportPath',
AGREEMENT_ACCEPTED: 'agreementAccepted',
LOG_ENABLED: 'logEnabled',
@@ -35,7 +36,19 @@ export const CONFIG_KEYS = {
// 安全
AUTH_ENABLED: 'authEnabled',
AUTH_PASSWORD: 'authPassword',
AUTH_USE_HELLO: 'authUseHello'
AUTH_USE_HELLO: 'authUseHello',
// 更新
IGNORED_UPDATE_VERSION: 'ignoredUpdateVersion',
// 通知
NOTIFICATION_ENABLED: 'notificationEnabled',
NOTIFICATION_POSITION: 'notificationPosition',
NOTIFICATION_FILTER_MODE: 'notificationFilterMode',
NOTIFICATION_FILTER_LIST: 'notificationFilterList',
// 词云
WORD_CLOUD_EXCLUDE_WORDS: 'wordCloudExcludeWords'
} as const
export interface WxidConfig {
@@ -150,6 +163,8 @@ export async function setCachePath(path: string): Promise<void> {
}
// 获取导出路径
export async function getExportPath(): Promise<string | null> {
const value = await config.get(CONFIG_KEYS.EXPORT_PATH)
@@ -399,3 +414,71 @@ export async function getAuthUseHello(): Promise<boolean> {
export async function setAuthUseHello(useHello: boolean): Promise<void> {
await config.set(CONFIG_KEYS.AUTH_USE_HELLO, useHello)
}
// === 更新相关 ===
// 获取被忽略的更新版本
export async function getIgnoredUpdateVersion(): Promise<string | null> {
const value = await config.get(CONFIG_KEYS.IGNORED_UPDATE_VERSION)
return (value as string) || null
}
// 设置被忽略的更新版本
export async function setIgnoredUpdateVersion(version: string): Promise<void> {
await config.set(CONFIG_KEYS.IGNORED_UPDATE_VERSION, version)
}
// 获取通知开关
export async function getNotificationEnabled(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.NOTIFICATION_ENABLED)
return value !== false // 默认为 true
}
// 设置通知开关
export async function setNotificationEnabled(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.NOTIFICATION_ENABLED, enabled)
}
// 获取通知位置
export async function getNotificationPosition(): Promise<'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'> {
const value = await config.get(CONFIG_KEYS.NOTIFICATION_POSITION)
return (value as any) || 'top-right'
}
// 设置通知位置
export async function setNotificationPosition(position: string): Promise<void> {
await config.set(CONFIG_KEYS.NOTIFICATION_POSITION, position)
}
// 获取通知过滤模式
export async function getNotificationFilterMode(): Promise<'all' | 'whitelist' | 'blacklist'> {
const value = await config.get(CONFIG_KEYS.NOTIFICATION_FILTER_MODE)
return (value as any) || 'all'
}
// 设置通知过滤模式
export async function setNotificationFilterMode(mode: 'all' | 'whitelist' | 'blacklist'): Promise<void> {
await config.set(CONFIG_KEYS.NOTIFICATION_FILTER_MODE, mode)
}
// 获取通知过滤列表
export async function getNotificationFilterList(): Promise<string[]> {
const value = await config.get(CONFIG_KEYS.NOTIFICATION_FILTER_LIST)
return Array.isArray(value) ? value : []
}
// 设置通知过滤列表
export async function setNotificationFilterList(list: string[]): Promise<void> {
await config.set(CONFIG_KEYS.NOTIFICATION_FILTER_LIST, list)
}
// 获取词云排除词列表
export async function getWordCloudExcludeWords(): Promise<string[]> {
const value = await config.get(CONFIG_KEYS.WORD_CLOUD_EXCLUDE_WORDS)
return Array.isArray(value) ? value : []
}
// 设置词云排除词列表
export async function setWordCloudExcludeWords(words: string[]): Promise<void> {
await config.set(CONFIG_KEYS.WORD_CLOUD_EXCLUDE_WORDS, words)
}

View File

@@ -0,0 +1,70 @@
import { create } from 'zustand'
export interface BatchTranscribeState {
/** 是否正在批量转写 */
isBatchTranscribing: boolean
/** 转写进度 */
progress: { current: number; total: number }
/** 是否显示进度浮窗 */
showToast: boolean
/** 是否显示结果弹窗 */
showResult: boolean
/** 转写结果 */
result: { success: number; fail: number }
/** 当前转写的会话名 */
startTime: number
sessionName: string
// Actions
startTranscribe: (total: number, sessionName: string) => void
updateProgress: (current: number, total: number) => void
finishTranscribe: (success: number, fail: number) => void
setShowToast: (show: boolean) => void
setShowResult: (show: boolean) => void
reset: () => void
}
export const useBatchTranscribeStore = create<BatchTranscribeState>((set) => ({
isBatchTranscribing: false,
progress: { current: 0, total: 0 },
showToast: false,
showResult: false,
result: { success: 0, fail: 0 },
sessionName: '',
startTime: 0,
startTranscribe: (total, sessionName) => set({
isBatchTranscribing: true,
showToast: true,
progress: { current: 0, total },
showResult: false,
result: { success: 0, fail: 0 },
sessionName,
startTime: Date.now()
}),
updateProgress: (current, total) => set({
progress: { current, total }
}),
finishTranscribe: (success, fail) => set({
isBatchTranscribing: false,
showToast: false,
showResult: true,
result: { success, fail },
startTime: 0
}),
setShowToast: (show) => set({ showToast: show }),
setShowResult: (show) => set({ showResult: show }),
reset: () => set({
isBatchTranscribing: false,
progress: { current: 0, total: 0 },
showToast: false,
showResult: false,
result: { success: 0, fail: 0 },
sessionName: '',
startTime: 0
})
}))

View File

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

View File

@@ -0,0 +1,296 @@
// 批量转写 - 共享基础样式overlay / modal-content / animations
// 被 ChatPage.scss 和 BatchTranscribeGlobal.tsx 同时使用
.batch-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
animation: batchFadeIn 0.2s ease-out;
}
.batch-modal-content {
background: var(--bg-primary);
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
max-height: 90vh;
overflow-y: auto;
animation: batchSlideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes batchFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes batchSlideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// 批量转写进度浮窗(非阻塞 toast
.batch-progress-toast {
position: fixed;
bottom: 24px;
right: 24px;
width: 320px;
background: var(--bg-primary);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
border: 1px solid var(--border-color);
z-index: 10000;
animation: batchToastSlideIn 0.3s cubic-bezier(0.16, 1, 0.3, 1);
overflow: hidden;
.batch-progress-toast-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-bottom: 1px solid var(--border-color);
.batch-progress-toast-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
svg {
color: var(--primary);
}
}
}
.batch-progress-toast-close {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
background: transparent;
border-radius: 6px;
color: var(--text-secondary);
cursor: pointer;
transition: background 0.15s, color 0.15s;
&:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
}
.batch-progress-toast-body {
padding: 12px 14px;
.progress-info-row {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 8px;
.progress-text {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: var(--text-secondary);
.progress-percent {
font-weight: 600;
color: var(--primary);
font-size: 13px;
}
}
.progress-eta {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px; // 稍微小一点
color: var(--text-tertiary, #999); // 使用更淡的颜色
svg {
width: 12px;
height: 12px;
opacity: 0.8;
}
}
}
.progress-bar {
height: 6px;
background: var(--bg-tertiary);
border-radius: 3px;
overflow: hidden;
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--primary), var(--primary));
border-radius: 3px;
transition: width 0.3s ease;
}
}
}
}
@keyframes batchToastSlideIn {
from {
opacity: 0;
transform: translateY(16px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
// 批量转写结果对话框
.batch-result-modal {
width: 420px;
max-width: 90vw;
.batch-modal-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1.5rem;
border-bottom: 1px solid var(--border-color);
svg {
color: #4caf50;
}
h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
}
.batch-modal-body {
padding: 1.5rem;
.result-summary {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1rem;
.result-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
border-radius: 8px;
background: var(--bg-tertiary);
svg {
flex-shrink: 0;
}
.label {
font-size: 14px;
color: var(--text-secondary);
}
.value {
margin-left: auto;
font-size: 18px;
font-weight: 600;
}
&.success {
svg {
color: #4caf50;
}
.value {
color: #4caf50;
}
}
&.fail {
svg {
color: #f44336;
}
.value {
color: #f44336;
}
}
}
}
.result-tip {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.75rem;
background: rgba(255, 152, 0, 0.1);
border-radius: 8px;
border: 1px solid rgba(255, 152, 0, 0.3);
svg {
flex-shrink: 0;
margin-top: 2px;
color: #ff9800;
}
span {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
}
}
}
.batch-modal-footer {
display: flex;
justify-content: flex-end;
padding: 1rem 1.5rem;
border-top: 1px solid var(--border-color);
button {
padding: 0.5rem 1.5rem;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border: none;
&.btn-primary {
background: var(--primary);
color: white;
&:hover {
opacity: 0.9;
}
}
}
}
}

View File

@@ -45,6 +45,7 @@
[data-theme="cloud-dancer"][data-mode="light"],
[data-theme="cloud-dancer"]:not([data-mode]) {
--primary: #8B7355;
--primary-rgb: 139, 115, 85;
--primary-hover: #7A6548;
--primary-light: rgba(139, 115, 85, 0.1);
--bg-primary: #F0EEE9;
@@ -64,6 +65,7 @@
[data-theme="corundum-blue"][data-mode="light"],
[data-theme="corundum-blue"]:not([data-mode]) {
--primary: #4A6670;
--primary-rgb: 74, 102, 112;
--primary-hover: #3D565E;
--primary-light: rgba(74, 102, 112, 0.1);
--bg-primary: #E8EEF0;
@@ -83,6 +85,7 @@
[data-theme="kiwi-green"][data-mode="light"],
[data-theme="kiwi-green"]:not([data-mode]) {
--primary: #7A9A5C;
--primary-rgb: 122, 154, 92;
--primary-hover: #6A8A4C;
--primary-light: rgba(122, 154, 92, 0.1);
--bg-primary: #E8F0E4;
@@ -102,6 +105,7 @@
[data-theme="spicy-red"][data-mode="light"],
[data-theme="spicy-red"]:not([data-mode]) {
--primary: #8B4049;
--primary-rgb: 139, 64, 73;
--primary-hover: #7A3540;
--primary-light: rgba(139, 64, 73, 0.1);
--bg-primary: #F0E8E8;
@@ -121,6 +125,7 @@
[data-theme="teal-water"][data-mode="light"],
[data-theme="teal-water"]:not([data-mode]) {
--primary: #5A8A8A;
--primary-rgb: 90, 138, 138;
--primary-hover: #4A7A7A;
--primary-light: rgba(90, 138, 138, 0.1);
--bg-primary: #E4F0F0;
@@ -141,6 +146,7 @@
// 云上舞白 - 深色
[data-theme="cloud-dancer"][data-mode="dark"] {
--primary: #C9A86C;
--primary-rgb: 201, 168, 108;
--primary-hover: #D9B87C;
--primary-light: rgba(201, 168, 108, 0.15);
--bg-primary: #1a1816;
@@ -159,6 +165,7 @@
// 刚玉蓝 - 深色
[data-theme="corundum-blue"][data-mode="dark"] {
--primary: #6A9AAA;
--primary-rgb: 106, 154, 170;
--primary-hover: #7AAABA;
--primary-light: rgba(106, 154, 170, 0.15);
--bg-primary: #141a1c;
@@ -177,6 +184,7 @@
// 冰猕猴桃汁绿 - 深色
[data-theme="kiwi-green"][data-mode="dark"] {
--primary: #9ABA7C;
--primary-rgb: 154, 186, 124;
--primary-hover: #AACA8C;
--primary-light: rgba(154, 186, 124, 0.15);
--bg-primary: #161a14;
@@ -195,6 +203,7 @@
// 辛辣红 - 深色
[data-theme="spicy-red"][data-mode="dark"] {
--primary: #C06068;
--primary-rgb: 192, 96, 104;
--primary-hover: #D07078;
--primary-light: rgba(192, 96, 104, 0.15);
--bg-primary: #1a1416;
@@ -213,6 +222,7 @@
// 明水鸭色 - 深色
[data-theme="teal-water"][data-mode="dark"] {
--primary: #7ABAAA;
--primary-rgb: 122, 186, 170;
--primary-hover: #8ACABA;
--primary-light: rgba(122, 186, 170, 0.15);
--bg-primary: #121a1a;

View File

@@ -11,6 +11,7 @@ export interface ElectronAPI {
setTitleBarOverlay: (options: { symbolColor: string }) => void
openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) => Promise<void>
resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise<void>
openImageViewerWindow: (imagePath: string) => Promise<void>
openChatHistoryWindow: (sessionId: string, messageId: number) => Promise<boolean>
}
config: {
@@ -32,6 +33,7 @@ export interface ElectronAPI {
getVersion: () => Promise<string>
checkForUpdates: () => Promise<{ hasUpdate: boolean; version?: string; releaseNotes?: string }>
downloadAndInstall: () => Promise<void>
ignoreUpdate: (version: string) => Promise<{ success: boolean }>
onDownloadProgress: (callback: (progress: number) => void) => () => void
onUpdateAvailable: (callback: (info: { version: string; releaseNotes: string }) => void) => () => void
}
@@ -76,8 +78,16 @@ export interface ElectronAPI {
messages?: Message[]
error?: string
}>
getNewMessages: (sessionId: string, minTime: number, limit?: number) => Promise<{
success: boolean
messages?: Message[]
error?: string
}>
getContact: (username: string) => Promise<Contact | null>
getContactAvatar: (username: string) => Promise<{ avatarUrl?: string; displayName?: string } | null>
updateMessage: (sessionId: string, localId: number, createTime: number, newContent: string) => Promise<{ success: boolean; error?: string }>
deleteMessage: (sessionId: string, localId: number, createTime: number, dbPathHint?: string) => Promise<{ success: boolean; error?: string }>
resolveTransferDisplayNames: (chatroomId: string, payerUsername: string, receiverUsername: string) => Promise<{ payerName: string; receiverName: string }>
getContacts: () => Promise<{
success: boolean
contacts?: ContactInfo[]
@@ -104,11 +114,13 @@ export interface ElectronAPI {
}>
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 }>
getAllVoiceMessages: (sessionId: string) => Promise<{ success: boolean; messages?: Message[]; error?: 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 }>
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void
execQuery: (kind: string, path: string | null, sql: string) => Promise<{ success: boolean; rows?: any[]; error?: string }>
getMessage: (sessionId: string, localId: number) => Promise<{ success: boolean; message?: Message; error?: string }>
onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => () => void
}
image: {
@@ -153,12 +165,13 @@ export interface ElectronAPI {
}
error?: string
}>
getContactRankings: (limit?: number) => Promise<{
getContactRankings: (limit?: number, beginTimestamp?: number, endTimestamp?: number) => Promise<{
success: boolean
data?: Array<{
username: string
displayName: string
avatarUrl?: string
wechatId?: string
messageCount: number
sentCount: number
receivedCount: number
@@ -175,6 +188,26 @@ export interface ElectronAPI {
}
error?: string
}>
getExcludedUsernames: () => Promise<{
success: boolean
data?: string[]
error?: string
}>
setExcludedUsernames: (usernames: string[]) => Promise<{
success: boolean
data?: string[]
error?: string
}>
getExcludeCandidates: () => Promise<{
success: boolean
data?: Array<{
username: string
displayName: string
avatarUrl?: string
wechatId?: string
}>
error?: string
}>
onProgress: (callback: (payload: { status: string; progress: number }) => void) => () => void
}
cache: {
@@ -199,6 +232,10 @@ export interface ElectronAPI {
username: string
displayName: string
avatarUrl?: string
nickname?: string
alias?: string
remark?: string
groupNickname?: string
}>
error?: string
}>
@@ -238,6 +275,17 @@ export interface ElectronAPI {
count?: number
error?: string
}>
exportGroupMemberMessages: (
chatroomId: string,
memberUsername: string,
outputPath: string,
startTime?: number,
endTime?: number
) => Promise<{
success: boolean
count?: number
error?: string
}>
}
annualReport: {
getAvailableYears: () => Promise<{
@@ -317,7 +365,75 @@ export interface ElectronAPI {
}>
onProgress: (callback: (payload: { status: string; progress: number }) => void) => () => void
}
dualReport: {
generateReport: (payload: { friendUsername: string; year: number }) => Promise<{
success: boolean
data?: {
year: number
selfName: string
selfAvatarUrl?: string
friendUsername: string
friendName: string
friendAvatarUrl?: string
firstChat: {
createTime: number
createTimeStr: string
content: string
isSentByMe: boolean
senderUsername?: string
} | null
firstChatMessages?: Array<{
content: string
isSentByMe: boolean
createTime: number
createTimeStr: string
}>
yearFirstChat?: {
createTime: number
createTimeStr: string
content: string
isSentByMe: boolean
friendName: string
firstThreeMessages: Array<{
content: string
isSentByMe: boolean
createTime: number
createTimeStr: string
}>
} | null
stats: {
totalMessages: number
totalWords: number
imageCount: number
voiceCount: number
emojiCount: number
myTopEmojiMd5?: string
friendTopEmojiMd5?: string
myTopEmojiUrl?: string
topPhrases: Array<{ phrase: string; count: number }>
myExclusivePhrases: Array<{ phrase: string; count: number }>
friendExclusivePhrases: Array<{ phrase: string; count: number }>
heatmap?: number[][]
initiative?: { initiated: number; received: number }
response?: { avg: number; fastest: number; count: number }
monthly?: Record<string, number>
streak?: { days: number; startDate: string; endDate: string }
}
}
error?: string
}>
onProgress: (callback: (payload: { status: string; progress: number }) => void) => () => void
}
export: {
getExportStats: (sessionIds: string[], options: any) => Promise<{
totalMessages: number
voiceMessages: number
cachedVoiceCount: number
needTranscribeCount: number
mediaMessages: number
estimatedSeconds: number
sessions: Array<{ sessionId: string; displayName: string; totalCount: number; voiceCount: number }>
}>
exportSessions: (sessionIds: string[], outputDir: string, options: ExportOptions) => Promise<{
success: boolean
successCount?: number
@@ -328,7 +444,7 @@ export interface ElectronAPI {
success: boolean
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
successCount?: number
error?: string
@@ -374,17 +490,48 @@ export interface ElectronAPI {
error?: string
}>
debugResource: (url: string) => Promise<{ success: boolean; status?: number; headers?: any; error?: string }>
proxyImage: (url: string) => 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 }>
}
llama: {
loadModel: (modelPath: string) => Promise<boolean>
createSession: (systemPrompt?: string) => Promise<boolean>
chat: (message: string) => Promise<{ success: boolean; response?: any; error?: 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
}
http: {
start: (port?: number) => Promise<{ success: boolean; port?: number; error?: string }>
stop: () => Promise<{ success: boolean }>
status: () => Promise<{ running: boolean; port: number; mediaExportPath: string }>
}
}
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
senderUsername?: string
fileNameSuffix?: string
exportMedia?: boolean
exportAvatars?: boolean
exportImages?: boolean
exportVoices?: boolean
exportVideos?: boolean
exportEmojis?: boolean
exportVoiceAsText?: boolean
excelCompactColumns?: boolean
@@ -398,7 +545,10 @@ export interface ExportProgress {
current: number
total: number
currentSession: string
phase: 'preparing' | 'exporting' | 'writing' | 'complete'
phase: 'preparing' | 'exporting' | 'exporting-media' | 'exporting-voice' | 'writing' | 'complete'
phaseProgress?: number
phaseTotal?: number
phaseLabel?: string
}
export interface WxidInfo {

View File

@@ -9,6 +9,9 @@ export interface ChatSession {
lastMsgType: number
displayName?: string
avatarUrl?: string
lastMsgSender?: string
lastSenderDisplayName?: string
selfWxid?: string // Helper field to avoid extra API calls
}
// 联系人
@@ -61,6 +64,9 @@ export interface Message {
fileSize?: number // 文件大小
fileExt?: string // 文件扩展名
xmlType?: string // XML 中的 type 字段
// 转账消息
transferPayerUsername?: string // 转账付款方 wxid
transferReceiverUsername?: string // 转账收款方 wxid
// 名片消息
cardUsername?: string // 名片的微信ID
cardNickname?: string // 名片的昵称

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
}

View File

@@ -33,7 +33,8 @@ export default defineConfig({
'fsevents',
'whisper-node',
'shelljs',
'exceljs'
'exceljs',
'node-llama-cpp'
]
}
}
@@ -57,6 +58,24 @@ export default defineConfig({
}
}
},
{
entry: 'electron/dualReportWorker.ts',
vite: {
build: {
outDir: 'dist-electron',
rollupOptions: {
external: [
'koffi',
'fsevents'
],
output: {
entryFileNames: 'dualReportWorker.js',
inlineDynamicImports: true
}
}
}
}
},
{
entry: 'electron/imageSearchWorker.ts',
vite: {