Compare commits

...

283 Commits

Author SHA1 Message Date
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
xuncha
26d4751e80 Merge pull request #157 from hicccc77/dev
Dev
2026-01-31 18:37:19 +08:00
xuncha
b8120a5119 Merge pull request #156 from xunchahaha/dev
111
2026-01-31 18:36:50 +08:00
xuncha
68a13cefc3 111 2026-01-31 18:36:28 +08:00
xuncha
cd4b8f3702 Merge pull request #155 from hicccc77/main
同步一下
2026-01-31 18:16:11 +08:00
xuncha
c5956ba203 Merge pull request #154 from xunchahaha/main
xiufu
2026-01-31 18:15:10 +08:00
xuncha
f456357e01 Merge pull request #153 from xunchahaha/dev
Dev
2026-01-31 18:14:26 +08:00
xuncha
4ef821f45f 更新版本号 2026-01-31 18:12:57 +08:00
xuncha
912c78e9e9 Merge branch 'main' of https://github.com/xunchahaha/WeFlow 2026-01-31 18:11:58 +08:00
xuncha
bfcd154a25 wxid可以自己选择 2026-01-31 18:11:55 +08:00
xuncha
a1c8ba48b0 Merge pull request #1 from xunchahaha/main
11
2026-01-31 17:46:20 +08:00
xuncha
f93369489d Merge branch 'hicccc77:main' into main 2026-01-31 17:45:54 +08:00
xuncha
014f57f152 尝试修复秘钥获取失败 2026-01-31 17:44:52 +08:00
xuncha
3f1eb58af4 Merge pull request #151 from xunchahaha:main
Main
2026-01-31 16:14:26 +08:00
xuncha
97f0077e95 打包你快修好啊 我服了 2026-01-31 16:14:00 +08:00
xuncha
3d9b1b0f8c Merge pull request #150 from xunchahaha:main
Main
2026-01-31 16:06:54 +08:00
xuncha
cf292ca9e2 hh 2026-01-31 16:06:36 +08:00
xuncha
97f14030de Merge pull request #149 from xunchahaha:main
Main
2026-01-31 16:02:01 +08:00
xuncha
2cfe0d8ee8 ee 2026-01-31 16:01:21 +08:00
xuncha
a760f45823 Merge pull request #148 from xunchahaha:main
Main
2026-01-31 15:56:53 +08:00
xuncha
baa949a301 呃呃 2026-01-31 15:56:27 +08:00
xuncha
c29bbab25f Merge pull request #147 from xunchahaha:main
Main
2026-01-31 15:51:21 +08:00
xuncha
29981e1232 打包优化 2026-01-31 15:51:04 +08:00
xuncha
2d043cd929 Merge pull request #146 from hicccc77/dev
Dev
2026-01-31 15:41:37 +08:00
xuncha
d6dca0e5f7 Merge pull request #145 from xunchahaha:dev
Dev
2026-01-31 15:40:39 +08:00
xuncha
d47166e6f9 修复打包错误 2026-01-31 15:39:59 +08:00
xuncha
6e3bb9e361 图片解密策略更加激进 2026-01-31 15:24:21 +08:00
xuncha
b8dbc3caf1 群聊分析ui调整 2026-01-31 15:04:54 +08:00
xuncha
c1145c8f89 导出群成员第二版 2026-01-31 14:58:15 +08:00
xuncha
0cba8e6d89 导出群成员第一版 2026-01-31 14:26:13 +08:00
xuncha
f6f468dff3 Merge pull request #144 from xunchahaha/dev
Dev
2026-01-31 14:01:22 +08:00
xuncha
04fc5f9104 修复切换账号后的异常问题 2026-01-31 14:00:01 +08:00
xuncha
3c9ab6763c 导出方面再优化 媒体并行导出 2026-01-31 13:49:21 +08:00
cc
f360333ab4 Merge pull request #143 from hicccc77/dev
Dev
2026-01-30 23:49:43 +08:00
cc
834aa6eecb Merge branch 'main' into dev 2026-01-30 23:49:33 +08:00
cc
2400cc8b55 Merge pull request #142 from yunxilyf/main
fix:自动保存bug
2026-01-30 23:48:39 +08:00
cc
e4ed7faca9 feat: 一些优化 2026-01-30 23:47:46 +08:00
yunxilyf
8012aa49ee fix:自动保存bug 2026-01-30 23:46:26 +08:00
xuncha
7225358b91 Merge pull request #140 from xunchahaha/dev
Dev
2026-01-30 20:47:01 +08:00
xuncha
39688e8e0c Merge branch 'hicccc77:dev' into dev 2026-01-30 20:46:47 +08:00
xuncha
592ca6128f 导出方面优化 2026-01-30 20:46:02 +08:00
xuncha
7cd27d8905 Merge pull request #139 from xunchahaha/dev
修复自动保存失效
2026-01-30 20:19:42 +08:00
xuncha
bca387c54b 修复自动保存失效 2026-01-30 20:19:23 +08:00
cc
e7e4ffd53f Merge pull request #137 from hicccc77/dev
Dev
2026-01-29 22:07:25 +08:00
cc
04e0bf6b29 Merge branch 'main' into dev 2026-01-29 22:07:17 +08:00
Forrest
dadd9d799c Merge pull request #136 from JiQingzhe2004/dev
feat: 一些适配
2026-01-29 22:03:02 +08:00
cc
b3aaea16f2 feat: 支持中文路径 2026-01-29 21:59:29 +08:00
Forrest
f3994a1a72 feat: 一些适配 2026-01-29 21:25:36 +08:00
cc
26fbfd2c98 feat: 一些实现 2026-01-29 21:13:05 +08:00
cc
3c51dee9a6 feat: 一些优化 2026-01-29 20:48:27 +08:00
cc
b9fa0cc215 feat: 一些更新 2026-01-29 20:41:12 +08:00
Forrest
21f748a2dc Merge pull request #135 from JiQingzhe2004/main
优化
2026-01-29 19:06:12 +08:00
Forrest
87fe130791 feat(imageDecrypt): 优化缓存查找:多根目录检索 + 新日期目录结构 + 兼容旧路径 + WCDB 初始化容错 2026-01-29 19:04:43 +08:00
cc
ff1bc279f2 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-01-28 23:07:42 +08:00
cc
77689ec528 feat: 解决了一些问题 2026-01-28 23:04:29 +08:00
xuncha
5ea0b65905 Merge pull request #129 from xunchahaha/dev
Dev
2026-01-28 20:35:46 +08:00
xuncha
eac6b053ee 修一下 2026-01-28 20:35:20 +08:00
cc
d52abfddbf chore: 更新信息 2026-01-28 20:30:05 +08:00
xuncha
8f2e403837 Merge branch 'dev' of https://github.com/xunchahaha/WeFlow into dev 2026-01-28 20:27:19 +08:00
xuncha
17c9436c30 同步 2026-01-28 20:26:48 +08:00
xuncha
9969c073e5 优化导出 2026-01-28 20:24:48 +08:00
xuncha
dc83297854 Merge pull request #128 from xunchahaha/dev
Dev
2026-01-28 20:23:45 +08:00
xuncha
b6c9f2b32b 修复txt导出不映射的问题 2026-01-28 20:05:48 +08:00
xuncha
e63f901478 优化图片显示 2026-01-28 19:55:39 +08:00
xuncha
893cdb4d92 fix:修复ecxel导出问题 2026-01-28 19:31:29 +08:00
cc
d99ec05e81 Merge pull request #126 from hicccc77/main
同步分支
2026-01-28 19:30:08 +08:00
cc
c8f726eddc Merge pull request #125 from hicccc77/dev
Dev
2026-01-28 19:29:22 +08:00
cc
4e57a30c90 feat: 修复了一些问题 2026-01-27 22:18:50 +08:00
xuncha
0a88275669 Merge pull request #117 from xunchahaha/dev
Dev
2026-01-27 19:49:52 +08:00
xuncha
2a45cf1276 修ui 2026-01-27 19:48:34 +08:00
xuncha
d63f1e0d79 ui改 2026-01-27 19:39:53 +08:00
xuncha
f55507cd99 新增了导出联系人的功能 2026-01-27 19:25:34 +08:00
xuncha
836b0f9df4 同步 2026-01-27 18:08:50 +08:00
xuncha
b09068f1f7 Merge pull request #116 from xunchahaha/main
2026-01-27 18:03:40 +08:00
xuncha
714a9400d5 呃呃 2026-01-27 18:03:10 +08:00
xuncha
13dd2fca21 Merge branch 'hicccc77:main' into main 2026-01-27 17:56:34 +08:00
xuncha
5d1f834b61 Merge pull request #115 from hicccc77/dev
Dev
2026-01-27 17:56:19 +08:00
xuncha
3ca86224eb Merge pull request #114 from xunchahaha/dev
Dev
2026-01-27 17:55:15 +08:00
xuncha
f10e974f36 ee 2026-01-27 17:54:28 +08:00
xuncha
76c40e4118 Merge pull request #113 from hicccc77/dev
Dev
2026-01-27 17:49:00 +08:00
xuncha
5307f55840 Merge branch 'hicccc77:dev' into dev 2026-01-27 17:48:13 +08:00
xuncha
3405f26d10 Dev (#112)
* fix:优化表述

* fix:修复了json导出的格式

* fix:修复群聊分析白屏

* fix:修复当天没有会话也依旧会产生导出文件的问题
2026-01-27 17:46:49 +08:00
xuncha
85d82bfd09 fix:修复当天没有会话也依旧会产生导出文件的问题 2026-01-27 17:46:12 +08:00
xuncha
e557ee224e fix:修复群聊分析白屏 2026-01-27 17:42:03 +08:00
xuncha
88544c4a5d Merge branch 'hicccc77:dev' into dev 2026-01-27 17:36:18 +08:00
xuncha
b66fc32068 优化纯json导出格式 (#111)
* fix:优化表述

* fix:修复了json导出的格式
2026-01-27 17:35:52 +08:00
xuncha
7ac3c281a3 Merge branch 'hicccc77:dev' into dev 2026-01-27 17:34:50 +08:00
xuncha
28616493ce Merge branch 'hicccc77:main' into main 2026-01-27 17:34:34 +08:00
Forrest
d68e4fe880 Merge pull request #108 from JiQingzhe2004/main
扫吧,扫到哪个算哪个
2026-01-27 11:38:29 +08:00
Forrest
1fd676d63e 扫吧,扫到哪个算哪个 2026-01-27 11:37:46 +08:00
xuncha
9f31ac0529 Dev (#98)
* fix:优化表述

* fix:修复了json导出的格式
2026-01-25 18:29:50 +08:00
xuncha
3c32ad5ca8 fix:修复了json导出的格式 2026-01-25 18:29:23 +08:00
xuncha
879d84b597 Merge branch 'hicccc77:dev' into dev 2026-01-25 18:21:27 +08:00
xuncha
ab3551fb91 Merge branch 'hicccc77:main' into main 2026-01-25 18:21:19 +08:00
xuncha
b9d1ea316f Revert "fix:优化表述 (#96)" (#97)
This reverts commit 2e61902556.
2026-01-25 18:19:06 +08:00
xuncha
7762bd37c9 Merge branch 'hicccc77:dev' into dev 2026-01-25 18:10:03 +08:00
xuncha
2e61902556 fix:优化表述 (#96) 2026-01-25 18:05:15 +08:00
xuncha
9e8072c337 Merge branch 'dev' into dev 2026-01-25 18:04:50 +08:00
xuncha
827e77c9a3 fix:优化表述 2026-01-25 17:43:23 +08:00
cc
3956989b67 fix: 一些bug 2026-01-25 15:14:24 +08:00
cc
33d7c243a7 Merge pull request #94 from hicccc77/dev
Dev
2026-01-25 15:11:24 +08:00
cc
a215886015 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-01-25 14:25:27 +08:00
cc
1d9e8aded0 feat: 大幅提升语音解密速度;优化引导页面;优化图片密钥扫描逻辑 2026-01-25 14:25:24 +08:00
cc
b7e31c9cff Merge pull request #93 from xunchahaha/dev
Dev
2026-01-25 13:37:57 +08:00
xuncha
4e9c81a93d feat: 导出页面新增群昵称 备注等选择 2026-01-25 10:45:38 +08:00
xuncha
9181ac5d34 feat: ecxel导出支持群昵称显示 2026-01-25 09:40:00 +08:00
xuncha
3a10aeb23e feat:新增了切换账号的功能 (#89) 2026-01-24 12:43:09 +08:00
xuncha
178f9c4fdc Merge branch 'dev' into dev 2026-01-24 12:42:33 +08:00
xuncha
4d647a9467 feat:新增了切换账号的功能 2026-01-24 12:39:20 +08:00
Forrest
16cbc6adb1 Merge pull request #88 from 5xiao0qing5/main
fix:修复打包后html导出渲染失败
2026-01-24 03:40:22 +08:00
QingXiao
7afb872bff Bug Fix:修复打包后html导出渲染失败
Add bundled fallback CSS for HTML export (fix missing styles in builds)
2026-01-24 01:13:02 +08:00
QingXiao
7df6182e70 Fix html export styles fallback 2026-01-24 01:02:11 +08:00
xuncha
40efb04a36 hh (#87) 2026-01-24 00:39:21 +08:00
xuncha
1f03d35253 hh 2026-01-24 00:38:19 +08:00
cc
3efaed488a Merge pull request #82 from 5xiao0qing5/dev
实现 TXT导出 和 HTML导出
2026-01-24 00:25:34 +08:00
QingXiao
decdbf95f7 Merge pull request #9 from 5xiao0qing5/codex/implement-html-export-feature-1g9o7z 2026-01-24 00:19:37 +08:00
QingXiao
cccc712814 Merge pull request #8 from 5xiao0qing5/codex/format-txt-export-for-messages
Adjust txt/excel export message formatting
2026-01-24 00:17:58 +08:00
QingXiao
135f4819fb Align HTML export parsing and voip placeholders 2026-01-24 00:07:49 +08:00
QingXiao
388923257b Handle more message types in exports 2026-01-23 23:53:33 +08:00
cc
6918e359e8 Merge pull request #86 from hicccc77/dev
Dev
2026-01-23 23:46:34 +08:00
cc
d5b33c7e77 Merge branch 'main' of https://github.com/hicccc77/WeFlow into dev 2026-01-23 23:45:26 +08:00
QingXiao
d37f53e120 Adjust txt/excel export message formatting 2026-01-23 23:37:16 +08:00
cc
26478217e7 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-01-23 23:33:08 +08:00
cc
a100f4ef97 feat: 一些朋友圈功能的优化实现 2026-01-23 23:33:06 +08:00
QingXiao
91b746dc59 Merge pull request #7 from 5xiao0qing5/codex/implement-html-export-feature-1g9o7z
将 HTML 导出样式移至外部文件并强化 HTML 导出(清理、表情符号、视频)
2026-01-23 22:40:12 +08:00
QingXiao
1817a847de Merge branch 'dev' into codex/implement-html-export-feature-1g9o7z 2026-01-23 22:38:56 +08:00
cc
7e99feae1e Merge pull request #85 from xunchahaha/dev
fix:修复了头像加载失败的问题
2026-01-23 22:34:42 +08:00
QingXiao
2977c45365 Move HTML export styles to CSS file 2026-01-23 22:32:26 +08:00
Forrest
3b363a3efa Merge pull request #84 from xunchahaha/docs/add-3wm-qrcode
hh
2026-01-23 22:28:01 +08:00
xuncha
e2b0bd44d9 hh 2026-01-23 22:15:42 +08:00
QingXiao
cc26860504 实现 HTML 导出功能 2026-01-23 15:06:07 +08:00
QingXiao
54f3e0481f Fix HTML export app messages and emoji rendering 2026-01-23 15:00:43 +08:00
QingXiao
a61371c8ad Refine HTML export layout and theming 2026-01-23 14:48:34 +08:00
QingXiao
fd6d5e4296 Implement HTML chat export 2026-01-23 14:34:40 +08:00
QingXiao
514a617c55 Merge pull request #4 from 5xiao0qing5/codex/add-txt-export-feature-with-configurable-options
完成未实现的 TXT 导出功能
2026-01-23 13:58:56 +08:00
QingXiao
b47007ea0c Add configurable TXT export 2026-01-23 13:52:47 +08:00
xuncha
6436c39c90 Dev (#79)
* fix:尝试修复闪退的问题

* hhhhh

* fix(chatService): 优化头像加载兜底机制:收集无 URL 的用户名,从 head_image.db 批量获取并转换为 base64 格式,更新头像缓存并添加错误处理,避免聊天界面头像缺失。(解决了部分,我电脑上有几个不显示)

* 优化表诉

* 导出优化

* fix: 尝试修复运行库缺失的问题

* 优化表述

* feat: 实现朋友圈获取; 实现聊天页面跳转到指定日期

* fix:修复了头像加载失败的问题

* Bump version from 1.3.1 to 1.3.2

---------

Co-authored-by: Forrest <jin648862@gmail.com>
Co-authored-by: cc <98377878+hicccc77@users.noreply.github.com>
2026-01-23 10:06:16 +08:00
xuncha
eb2f90e605 fix:修复了头像加载失败的问题 2026-01-23 10:03:31 +08:00
cc
bdbb85175a Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-01-23 00:13:58 +08:00
cc
a5e1bfe49a feat: 实现朋友圈获取; 实现聊天页面跳转到指定日期 2026-01-23 00:13:55 +08:00
xuncha
b3adb54651 优化表述 2026-01-22 22:00:36 +08:00
cc
07e7bce6a9 fix: 尝试修复运行库缺失的问题 2026-01-22 21:46:04 +08:00
xuncha
baa90242a6 导出优化 2026-01-22 21:39:14 +08:00
xuncha
787db0cec2 优化表诉 2026-01-22 20:30:04 +08:00
Forrest
6359118132 fix(chatService): 优化头像加载兜底机制:收集无 URL 的用户名,从 head_image.db 批量获取并转换为 base64 格式,更新头像缓存并添加错误处理,避免聊天界面头像缺失。(解决了部分,我电脑上有几个不显示) 2026-01-22 19:36:57 +08:00
xuncha
49614bf6d8 Dev (#77)
* fix:尝试修复闪退的问题

* hhhhh
2026-01-22 18:46:22 +08:00
xuncha
0901e08c5c hhhhh 2026-01-22 18:44:42 +08:00
xuncha
503a77c7cf fix:尝试修复闪退的问题 2026-01-22 15:28:00 +08:00
xuncha
0e3ab8e4d6 Merge pull request #72 from hicccc77/dev
Dev
2026-01-21 21:01:38 +08:00
xuncha
4452e4921c fix:修复了导出时媒体导出出错的问题 2026-01-21 20:56:36 +08:00
xuncha
97c1aa582d feat(export): 多会话导出布局选择与无媒体直出
- 多会话媒体导出支持共享/分会话目录
- 无媒体导出时直接输出到目标目录
2026-01-21 19:37:05 +08:00
xuncha
076c008329 fix:优化了设置中下拉菜单的视觉表现 2026-01-21 19:23:11 +08:00
xuncha
21d785dd3c fix:修复了导出时因为头像在后台加载导致的不导出的问题 2026-01-21 19:08:22 +08:00
xuncha
348f6c81bf fix:尝试修复了新手引导闪退的问题 2026-01-21 19:02:14 +08:00
xuncha
d5a2e2bb62 word: 优化了新手引导的提示词 2026-01-21 18:59:54 +08:00
xuncha
2b51e0659e Merge pull request #65 from yunxilyf/main
fix:修复初始化的时候获取微信启动路径出现错误问题
2026-01-21 18:34:25 +08:00
yunxilyf
3efca5e60c fix:修复初始化的时候获取微信启动路径出现错误问题 2026-01-21 09:12:10 +08:00
cc
2f7b917f1c Merge branch 'main' of https://github.com/hicccc77/WeFlow 2026-01-20 22:40:26 +08:00
cc
8623f86505 fix: 修复了一些已知问题 2026-01-20 22:40:21 +08:00
Forrest
dc74641c19 Merge pull request #62 from hicccc77/dev
修复了一些已知问题。
2026-01-20 18:52:42 +08:00
Forrest
db7817cc22 refactor(WcdbCore, SettingsPage): 优化数据库测试的连接处理:恢复测试后活跃连接,拆分设置页配置保存与连接测试逻辑,避免连接干扰。 2026-01-20 18:25:05 +08:00
xuncha
ada0f68182 fix: 修复了一些情况下无法触发语音转文字的功能
Fix exporting voice-to-text for JSON and Excel exports
2026-01-20 02:13:40 +08:00
QingXiao
fe806895f0 Merge pull request #2 from 5xiao0qing5/codex/locate-cause-of-export-speech-to-text-bug-hv6kr5
Add export defaults and compact Excel column mode
2026-01-19 12:39:48 +08:00
QingXiao
da137d0a8f Add export defaults and compact Excel columns 2026-01-19 12:39:06 +08:00
Yeqing Zhang
93ebc3bce3 Merge pull request #1 from 5xiao0qing5/codex/locate-cause-of-export-speech-to-text-bug
Fix exporting voice-to-text for JSON and Excel exports
2026-01-19 12:01:25 +08:00
Yeqing Zhang
9f6e9eb9bc Fix voice transcript export for json and excel 2026-01-19 11:33:28 +08:00
xuncha
996b133a4f Merge branch 'dev' 2026-01-19 01:08:01 +08:00
Forrest
dd2602ea35 fix(ChatService, VideoService): 优化系统消息清理逻辑并移除冗余日志
- 改进 cleanSystemMessage 方法,增强 XML 声明移除和尾部时间戳清理
- 优化正则表达式以更准确地处理 XML/HTML 标签
- 从 VideoService 中移除大量冗余的 console.log 调试日志
- 简化错误处理,使用注释替代冗余日志输出
- 提升代码可读性和性能,减少不必要的日志输出开销
2026-01-18 23:41:55 +08:00
Forrest
e5cf71b7c5 Merge: 解决冲突 - 保留链接消息和视频消息样式,合并 rawContent 和 content 字段 2026-01-18 23:25:28 +08:00
Forrest
f2e4e21010 feat(ChatPage): 新增链接卡片消息渲染(支持解析 XML 并展示标题 / 描述 / 图标),采用 flexbox 优化消息气泡布局,添加文本截断、响应式样式及悬浮效果。 2026-01-18 23:20:26 +08:00
xuncha
240514f1e5 feat: 新增了聊天页面播放视频的功能 2026-01-18 23:19:58 +08:00
Forrest
d4c7e86e05 fix(UI): 修复聊天窗口的气泡宽度问题。 2026-01-18 21:28:55 +08:00
Forrest
2876c7a539 feat(voice-transcribe): 优化语音转写流程并增强数据库缓存机制
- 添加 createTime 参数到语音转写接口,支持更精确的消息定位
- 实现 media.db 列表缓存机制(5分钟TTL),减少重复查询开销
- 添加 media.db 表结构信息缓存,提升数据库操作效率
- 优化语音缓存目录获取逻辑,支持自定义缓存路径配置
- 重构语音数据获取实现,绕过WCDB的buggy getVoiceData方法
- 移除冗余的调试日志,提升代码整洁度
- 删除不再使用的 silk_v3_decoder.exe 文件
- 优化数据库连接流程,后台预热缓存提升响应速度
2026-01-18 17:12:45 +08:00
cc
32cdbece2c fix: 修复同时解密多个语音时可能引起错误解密的问题 2026-01-18 10:49:44 +08:00
cc
6e7e994cc6 fix: 修复构建日志异常 2026-01-18 10:41:00 +08:00
cc
d95040ffaf fix: 修复构建日志异常的问题 2026-01-18 10:35:41 +08:00
cc
129dfbe1b6 fix: 自动构建修复 2026-01-18 10:32:19 +08:00
119 changed files with 33665 additions and 2990 deletions

View File

@@ -21,7 +21,7 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22.12
cache: 'npm'
- name: Install Dependencies
@@ -39,49 +39,23 @@ jobs:
npx tsc
npx vite build
- name: Build Changelog
id: build_changelog
uses: mikepenz/release-changelog-builder-action@v4
with:
outputFile: "release-notes.md"
configurationJson: |
{
"template": "# v${{ github.ref_name }} 更新日志\n\n{{CHANGELOG}}\n\n---\n> 此更新由系统自动构建",
"categories": [
{
"title": "## 新功能",
"filter": { "pattern": "^feat.*:.*", "flags": "i" }
},
{
"title": "## 修复",
"filter": { "pattern": "^fix.*:.*", "flags": "i" }
},
{
"title": "## 性能与维护",
"filter": { "pattern": "^(chore|docs|perf|refactor|ci|style|test).*:.*", "flags": "i" }
}
],
"ignore_labels": [],
"commitMode": true,
"empty_summary": "## 更新详情\n- 常规代码优化与维护"
}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Check Changelog Content
shell: bash
run: |
echo "=== RELEASE NOTES CONTENT START ==="
cat release-notes.md
echo "=== RELEASE NOTES CONTENT END ==="
- name: Inject Configuration
shell: bash
run: |
npm pkg set build.releaseInfo.releaseNotesFile=release-notes.md
- name: Package and Publish
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
npx electron-builder --publish always
- name: Update Release Notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
cat <<EOF > release_notes.md
## 更新日志
修复了一些已知问题
## 查看更多日志/获取最新动态
[点击加入 Telegram 频道](https://t.me/weflow_cc)
EOF
gh release edit "$GITHUB_REF_NAME" --notes-file release_notes.md

4
.gitignore vendored
View File

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

View File

@@ -20,21 +20,41 @@ 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>
> [!TIP]
> 如果导出聊天记录后,想深入分析聊天内容可以试试 [ChatLab](https://chatlab.fun/)
> [!NOTE]
> 仅支持微信 **4.0 及以上**版本,确保你的微信版本符合要求
## 主要功能
- 本地实时查看聊天记录
- 统计分析与群聊画像
- 年度报告与可视化概览
- 导出聊天记录为 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)
## 快速开始
@@ -61,39 +81,19 @@ npm run build
打包产物在 `release` 目录下。
## 技术栈
- **前端**: React 19 + TypeScript + Zustand
- **桌面**: Electron 39
- **构建**: Vite + electron-builder
- **数据库**: better-sqlite3 + WCDB DLL
- **样式**: SCSS + CSS Variables
## 项目结构
```
WeFlow/
├── electron/ # Electron 主进程
│ ├── main.ts # 主进程入口
│ ├── preload.ts # 预加载脚本
│ └── services/ # 后端服务
│ ├── chatService.ts # 聊天数据服务
│ ├── wcdbService.ts # 数据库服务
│ └── ...
├── src/ # React 前端
│ ├── components/ # 通用组件
│ ├── pages/ # 页面组件
│ ├── stores/ # Zustand 状态管理
│ ├── services/ # 前端服务
│ └── types/ # TypeScript 类型定义
├── public/ # 静态资源
└── resources/ # 打包资源
```
## 致谢
- [密语 CipherTalk](https://github.com/ILoveBingLu/miyu) 为本项目提供了基础框架
## 支持我们
如果 WeFlow 确实帮到了你,可以考虑请我们喝杯咖啡:
> TRC20 **Address:** `TZCtAw8CaeARWZBfvjidCnTcfnAtf6nvS6`
## Star History
@@ -111,6 +111,4 @@ WeFlow/
**请负责任地使用本工具,遵守相关法律法规**
我们总是在向前走,却很少有机会回头看看
</div>

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

@@ -0,0 +1,312 @@
# 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 |
| `offset` | number | ❌ | 偏移量,用于分页,默认 0 |
| `start` | string | ❌ | 开始时间,格式 YYYYMMDD |
| `end` | string | ❌ | 结束时间,格式 YYYYMMDD |
| `chatlab` | string | ❌ | 设为 `1` 则输出 ChatLab 格式 |
| `format` | string | ❌ | 输出格式:`json`(默认)或 `chatlab` |
**示例请求**
```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
```
**响应(原始格式)**
```json
{
"success": true,
"talker": "wxid_xxx",
"count": 50,
"hasMore": true,
"messages": [
{
"localId": 123,
"talker": "wxid_xxx",
"type": 1,
"content": "消息内容",
"createTime": 1738713600000,
"isSelf": false,
"sender": "wxid_sender"
}
]
}
```
**响应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": "消息内容"
}
]
}
```
---
### 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可从浏览器前端直接调用

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

@@ -1,6 +1,7 @@
import { app, BrowserWindow, ipcMain, nativeTheme } from 'electron'
import './preload-env'
import { app, BrowserWindow, ipcMain, nativeTheme, session } from 'electron'
import { Worker } from 'worker_threads'
import { join } from 'path'
import { join, dirname } from 'path'
import { autoUpdater } from 'electron-updater'
import { readFile, writeFile, mkdir } from 'fs/promises'
import { existsSync } from 'fs'
@@ -13,9 +14,16 @@ import { imagePreloadService } from './services/imagePreloadService'
import { analyticsService } from './services/analyticsService'
import { groupAnalyticsService } from './services/groupAnalyticsService'
import { annualReportService } from './services/annualReportService'
import { exportService, ExportOptions } from './services/exportService'
import { exportService, ExportOptions, ExportProgress } from './services/exportService'
import { KeyService } from './services/keyService'
import { voiceTranscribeService } from './services/voiceTranscribeService'
import { videoService } from './services/videoService'
import { snsService } 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'
// 配置自动更新
@@ -27,6 +35,47 @@ const AUTO_UPDATE_ENABLED =
process.env.AUTO_UPDATE_ENABLED === '1' ||
(process.env.AUTO_UPDATE_ENABLED == null && !process.env.VITE_DEV_SERVER_URL)
// 使用白名单过滤 PATH避免被第三方目录中的旧版 VC++ 运行库劫持。
// 仅保留系统目录Windows/System32/SysWOW64和应用自身目录可执行目录、resources
function sanitizePathEnv() {
// 开发模式不做裁剪,避免影响本地工具链
if (process.env.VITE_DEV_SERVER_URL) return
const rawPath = process.env.PATH || process.env.Path
if (!rawPath) return
const sep = process.platform === 'win32' ? ';' : ':'
const parts = rawPath.split(sep).filter(Boolean)
const systemRoot = process.env.SystemRoot || process.env.WINDIR || ''
const safePrefixes = [
systemRoot,
systemRoot ? join(systemRoot, 'System32') : '',
systemRoot ? join(systemRoot, 'SysWOW64') : '',
dirname(process.execPath),
process.resourcesPath,
join(process.resourcesPath || '', 'resources')
].filter(Boolean)
const normalize = (p: string) => p.replace(/\\/g, '/').toLowerCase()
const isSafe = (p: string) => {
const np = normalize(p)
return safePrefixes.some((prefix) => np.startsWith(normalize(prefix)))
}
const filtered = parts.filter(isSafe)
if (filtered.length !== parts.length) {
const removed = parts.filter((p) => !isSafe(p))
console.warn('[WeFlow] 使用白名单裁剪 PATH移除目录:', removed)
const nextPath = filtered.join(sep)
process.env.PATH = nextPath
process.env.Path = nextPath
}
}
// 启动时立即清理 PATH后续创建的 worker 也能继承安全的环境
sanitizePathEnv()
// 单例服务
let configService: ConfigService | null = null
@@ -93,6 +142,36 @@ 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(
{
urls: [
'*://*.qpic.cn/*',
'*://*.qlogo.cn/*',
'*://*.wechat.com/*',
'*://*.weixin.qq.com/*'
]
},
(details, callback) => {
details.requestHeaders['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"
details.requestHeaders['Accept'] = "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8"
details.requestHeaders['Accept-Encoding'] = "gzip, deflate, br"
details.requestHeaders['Accept-Language'] = "zh-CN,zh;q=0.9"
details.requestHeaders['Referer'] = "https://servicewechat.com/"
details.requestHeaders['Connection'] = "keep-alive"
details.requestHeaders['Range'] = "bytes=0-"
callback({ cancel: false, requestHeaders: details.requestHeaders })
}
)
return win
}
@@ -166,10 +245,11 @@ function createOnboardingWindow() {
: join(process.resourcesPath, 'icon.ico')
onboardingWindow = new BrowserWindow({
width: 1100,
height: 720,
width: 960,
height: 680,
minWidth: 900,
minHeight: 600,
minHeight: 620,
resizable: false,
frame: false,
transparent: true,
backgroundColor: '#00000000',
@@ -200,6 +280,225 @@ function createOnboardingWindow() {
return onboardingWindow
}
/**
* 创建独立的视频播放窗口
* 窗口大小会根据视频比例自动调整
*/
function createVideoPlayerWindow(videoPath: string, videoWidth?: number, videoHeight?: number) {
const isDev = !!process.env.VITE_DEV_SERVER_URL
const iconPath = isDev
? join(__dirname, '../public/icon.ico')
: join(process.resourcesPath, 'icon.ico')
// 获取屏幕尺寸
const { screen } = require('electron')
const primaryDisplay = screen.getPrimaryDisplay()
const { width: screenWidth, height: screenHeight } = primaryDisplay.workAreaSize
// 计算窗口尺寸,只有标题栏 40px控制栏悬浮
let winWidth = 854
let winHeight = 520
const titleBarHeight = 40
if (videoWidth && videoHeight && videoWidth > 0 && videoHeight > 0) {
const aspectRatio = videoWidth / videoHeight
const maxWidth = Math.floor(screenWidth * 0.85)
const maxHeight = Math.floor(screenHeight * 0.85)
if (aspectRatio >= 1) {
// 横向视频
winWidth = Math.min(videoWidth, maxWidth)
winHeight = Math.floor(winWidth / aspectRatio) + titleBarHeight
if (winHeight > maxHeight) {
winHeight = maxHeight
winWidth = Math.floor((winHeight - titleBarHeight) * aspectRatio)
}
} else {
// 竖向视频
const videoDisplayHeight = Math.min(videoHeight, maxHeight - titleBarHeight)
winHeight = videoDisplayHeight + titleBarHeight
winWidth = Math.floor(videoDisplayHeight * aspectRatio)
if (winWidth < 300) {
winWidth = 300
winHeight = Math.floor(winWidth / aspectRatio) + titleBarHeight
}
}
winWidth = Math.max(winWidth, 360)
winHeight = Math.max(winHeight, 280)
}
const win = new BrowserWindow({
width: winWidth,
height: winHeight,
minWidth: 360,
minHeight: 280,
icon: iconPath,
webPreferences: {
preload: join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
webSecurity: false
},
titleBarStyle: 'hidden',
titleBarOverlay: {
color: '#1a1a1a',
symbolColor: '#ffffff',
height: 40
},
show: false,
backgroundColor: '#000000',
autoHideMenuBar: true
})
win.once('ready-to-show', () => {
win.show()
})
const videoParam = `videoPath=${encodeURIComponent(videoPath)}`
if (process.env.VITE_DEV_SERVER_URL) {
win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/video-player-window?${videoParam}`)
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: `/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
}
/**
* 创建独立的聊天记录窗口
*/
function createChatHistoryWindow(sessionId: string, messageId: number) {
const isDev = !!process.env.VITE_DEV_SERVER_URL
const iconPath = isDev
? join(__dirname, '../public/icon.ico')
: join(process.resourcesPath, 'icon.ico')
// 根据系统主题设置窗口背景色
const isDark = nativeTheme.shouldUseDarkColors
const win = new BrowserWindow({
width: 600,
height: 800,
minWidth: 400,
minHeight: 500,
icon: iconPath,
webPreferences: {
preload: join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false
},
titleBarStyle: 'hidden',
titleBarOverlay: {
color: '#00000000',
symbolColor: isDark ? '#ffffff' : '#1a1a1a',
height: 32
},
show: false,
backgroundColor: isDark ? '#1A1A1A' : '#F0F0F0',
autoHideMenuBar: true
})
win.once('ready-to-show', () => {
win.show()
})
if (process.env.VITE_DEV_SERVER_URL) {
win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/chat-history/${sessionId}/${messageId}`)
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: `/chat-history/${sessionId}/${messageId}`
})
}
return win
}
function showMainWindow() {
shouldShowMain = true
if (mainWindowReady) {
@@ -209,6 +508,7 @@ function showMainWindow() {
// 注册 IPC 处理器
function registerIpcHandlers() {
registerNotificationHandlers()
// 配置相关
ipcMain.handle('config:get', async (_, key: string) => {
return configService?.get(key as any)
@@ -306,7 +606,7 @@ function registerIpcHandlers() {
// 监听下载进度
autoUpdater.on('download-progress', (progress) => {
win?.webContents.send('app:downloadProgress', progress.percent)
win?.webContents.send('app:downloadProgress', progress)
})
// 下载完成后自动安装
@@ -322,6 +622,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()
@@ -356,6 +661,85 @@ function registerIpcHandlers() {
}
})
// 打开视频播放窗口
ipcMain.handle('window:openVideoPlayerWindow', (_, videoPath: string, videoWidth?: number, videoHeight?: number) => {
createVideoPlayerWindow(videoPath, videoWidth, videoHeight)
})
// 打开聊天记录窗口
ipcMain.handle('window:openChatHistoryWindow', (_, sessionId: string, messageId: number) => {
createChatHistoryWindow(sessionId, messageId)
return true
})
// 根据视频尺寸调整窗口大小
ipcMain.handle('window:resizeToFitVideo', (event, videoWidth: number, videoHeight: number) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win || !videoWidth || !videoHeight) return
const { screen } = require('electron')
const primaryDisplay = screen.getPrimaryDisplay()
const { width: screenWidth, height: screenHeight } = primaryDisplay.workAreaSize
// 只有标题栏 40px控制栏悬浮在视频上
const titleBarHeight = 40
const aspectRatio = videoWidth / videoHeight
const maxWidth = Math.floor(screenWidth * 0.85)
const maxHeight = Math.floor(screenHeight * 0.85)
let winWidth: number
let winHeight: number
if (aspectRatio >= 1) {
// 横向视频 - 以宽度为基准
winWidth = Math.min(videoWidth, maxWidth)
winHeight = Math.floor(winWidth / aspectRatio) + titleBarHeight
if (winHeight > maxHeight) {
winHeight = maxHeight
winWidth = Math.floor((winHeight - titleBarHeight) * aspectRatio)
}
} else {
// 竖向视频 - 以高度为基准
const videoDisplayHeight = Math.min(videoHeight, maxHeight - titleBarHeight)
winHeight = videoDisplayHeight + titleBarHeight
winWidth = Math.floor(videoDisplayHeight * aspectRatio)
// 确保宽度不会太窄
if (winWidth < 300) {
winWidth = 300
winHeight = Math.floor(winWidth / aspectRatio) + titleBarHeight
}
}
winWidth = Math.max(winWidth, 360)
winHeight = Math.max(winHeight, 280)
// 调整窗口大小并居中
win.setSize(winWidth, winHeight)
win.center()
})
// 视频相关
ipcMain.handle('video:getVideoInfo', async (_, videoMd5: string) => {
try {
const result = await videoService.getVideoInfo(videoMd5)
return { success: true, ...result }
} catch (e) {
return { success: false, error: String(e), exists: false }
}
})
ipcMain.handle('video:parseVideoMd5', async (_, content: string) => {
try {
const md5 = videoService.parseVideoMd5(content)
return { success: true, md5 }
} catch (e) {
return { success: false, error: String(e) }
}
})
// 数据库路径相关
ipcMain.handle('dbpath:autoDetect', async () => {
return dbPathService.autoDetect()
@@ -365,6 +749,10 @@ function registerIpcHandlers() {
return dbPathService.scanWxids(rootPath)
})
ipcMain.handle('dbpath:scanWxidCandidates', async (_, rootPath: string) => {
return dbPathService.scanWxidCandidates(rootPath)
})
ipcMain.handle('dbpath:getDefault', async () => {
return dbPathService.getDefaultPath()
})
@@ -398,20 +786,90 @@ function registerIpcHandlers() {
return chatService.enrichSessionsContactInfo(usernames)
})
ipcMain.handle('chat:getMessages', async (_, sessionId: string, offset?: number, limit?: number) => {
return chatService.getMessages(sessionId, offset, limit)
ipcMain.handle('chat:getMessages', async (_, sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) => {
return chatService.getMessages(sessionId, offset, limit, startTime, endTime, ascending)
})
ipcMain.handle('chat:getLatestMessages', async (_, sessionId: string, limit?: number) => {
return chatService.getLatestMessages(sessionId, limit)
})
ipcMain.handle('chat:getContact', async (_, username: string) => {
return chatService.getContact(username)
ipcMain.handle('chat:getNewMessages', async (_, sessionId: string, minTime: number, limit?: number) => {
return chatService.getNewMessages(sessionId, minTime, limit)
})
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 chatService.getContactAvatar(username)
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()
})
ipcMain.handle('chat:getCachedMessages', async (_, sessionId: string) => {
@@ -442,20 +900,42 @@ 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)
})
ipcMain.handle('chat:getVoiceTranscript', async (event, sessionId: string, msgId: string) => {
return chatService.getVoiceTranscript(sessionId, msgId, (text) => {
ipcMain.handle('chat:getVoiceTranscript', async (event, sessionId: string, msgId: string, createTime?: number) => {
return chatService.getVoiceTranscript(sessionId, msgId, createTime, (text) => {
event.sender.send('chat:voiceTranscriptPartial', { msgId, text })
})
})
ipcMain.handle('chat:getMessageById', async (_, sessionId: string, localId: number) => {
ipcMain.handle('chat:getMessage', async (_, sessionId: string, localId: number) => {
return chatService.getMessageById(sessionId, localId)
})
ipcMain.handle('chat:execQuery', async (_, kind: string, path: string | null, sql: string) => {
return chatService.execQuery(kind, path, sql)
})
ipcMain.handle('sns:getTimeline', async (_, limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => {
return snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime)
})
ipcMain.handle('sns:debugResource', async (_, url: string) => {
return snsService.debugResource(url)
})
ipcMain.handle('sns:proxyImage', async (_, url: string) => {
return snsService.proxyImage(url)
})
// 私聊克隆
@@ -470,28 +950,64 @@ function registerIpcHandlers() {
return true
})
// Windows Hello
ipcMain.handle('auth:hello', async (event, message?: string) => {
// 无论哪个窗口调用,都尝试强制附着到主窗口,确保体验一致
// 如果主窗口不存在(极其罕见),则回退到调用者窗口
const targetWin = (mainWindow && !mainWindow.isDestroyed())
? mainWindow
: (BrowserWindow.fromWebContents(event.sender) || undefined)
return windowsHelloService.verify(message, targetWin)
})
// 导出相关
ipcMain.handle('export:exportSessions', async (_, sessionIds: string[], outputDir: string, options: ExportOptions) => {
return exportService.exportSessions(sessionIds, outputDir, options)
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()) {
event.sender.send('export:progress', progress)
}
}
return exportService.exportSessions(sessionIds, outputDir, options, onProgress)
})
ipcMain.handle('export:exportSession', async (_, sessionId: string, outputPath: string, options: ExportOptions) => {
return exportService.exportSessionToChatLab(sessionId, outputPath, options)
})
ipcMain.handle('export:exportContacts', async (_, outputDir: string, options: any) => {
return contactExportService.exportContacts(outputDir, options)
})
// 数据分析相关
ipcMain.handle('analytics:getOverallStatistics', async (_, force?: boolean) => {
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()
@@ -557,12 +1073,21 @@ function registerIpcHandlers() {
return groupAnalyticsService.getGroupMediaStats(chatroomId, startTime, endTime)
})
ipcMain.handle('groupAnalytics:exportGroupMembers', async (_, chatroomId: string, outputPath: string) => {
return groupAnalyticsService.exportGroupMembers(chatroomId, outputPath)
})
// 打开协议窗口
ipcMain.handle('window:openAgreementWindow', async () => {
createAgreementWindow()
return true
})
// 打开图片查看窗口
ipcMain.handle('window:openImageViewerWindow', (_, imagePath: string) => {
createImageViewerWindow(imagePath)
})
// 完成引导,关闭引导窗口并显示主窗口
ipcMain.handle('window:completeOnboarding', async () => {
try {
@@ -660,6 +1185,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
@@ -705,6 +1298,23 @@ 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()
}
})
}
// 主窗口引用
@@ -723,7 +1333,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,
@@ -756,6 +1375,17 @@ app.whenReady().then(() => {
createOnboardingWindow()
}
// 解决朋友圈图片无法加载问题(添加 Referer
session.defaultSession.webRequest.onBeforeSendHeaders(
{
urls: ['*://*.qpic.cn/*', '*://*.wx.qq.com/*']
},
(details, callback) => {
details.requestHeaders['Referer'] = 'https://wx.qq.com/'
callback({ requestHeaders: details.requestHeaders })
}
)
// 启动时检测更新
checkForUpdatesOnStartup()

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

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

39
electron/preload-env.ts Normal file
View File

@@ -0,0 +1,39 @@
import { join, dirname } from 'path'
/**
* 强制将本地资源目录添加到 PATH 最前端,确保优先加载本地 DLL
* 解决系统中存在冲突版本的 DLL 导致的应用崩溃问题
*/
function enforceLocalDllPriority() {
const isDev = !!process.env.VITE_DEV_SERVER_URL
const sep = process.platform === 'win32' ? ';' : ':'
let possiblePaths: string[] = []
if (isDev) {
// 开发环境
possiblePaths.push(join(process.cwd(), 'resources'))
} else {
// 生产环境
possiblePaths.push(dirname(process.execPath))
if (process.resourcesPath) {
possiblePaths.push(process.resourcesPath)
}
}
const dllPaths = possiblePaths.join(sep)
if (process.env.PATH) {
process.env.PATH = dllPaths + sep + process.env.PATH
} else {
process.env.PATH = dllPaths
}
}
try {
enforceLocalDllPriority()
} catch (e) {
console.error('[WeFlow] Failed to enforce local DLL priority:', e)
}

View File

@@ -9,6 +9,24 @@ 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)
},
// 对话框
dialog: {
@@ -29,7 +47,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
getVersion: () => ipcRenderer.invoke('app:getVersion'),
checkForUpdates: () => ipcRenderer.invoke('app:checkForUpdates'),
downloadAndInstall: () => ipcRenderer.invoke('app:downloadAndInstall'),
onDownloadProgress: (callback: (progress: number) => void) => {
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')
},
@@ -42,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)
},
// 窗口控制
@@ -53,13 +73,22 @@ contextBridge.exposeInMainWorld('electronAPI', {
openAgreementWindow: () => ipcRenderer.invoke('window:openAgreementWindow'),
completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'),
openOnboardingWindow: () => ipcRenderer.invoke('window:openOnboardingWindow'),
setTitleBarOverlay: (options: { symbolColor: string }) => ipcRenderer.send('window:setTitleBarOverlay', options)
setTitleBarOverlay: (options: { symbolColor: string }) => ipcRenderer.send('window:setTitleBarOverlay', options),
openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) =>
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)
},
// 数据库路径
dbPath: {
autoDetect: () => ipcRenderer.invoke('dbpath:autoDetect'),
scanWxids: (rootPath: string) => ipcRenderer.invoke('dbpath:scanWxids', rootPath),
scanWxidCandidates: (rootPath: string) => ipcRenderer.invoke('dbpath:scanWxidCandidates', rootPath),
getDefault: () => ipcRenderer.invoke('dbpath:getDefault')
},
@@ -94,12 +123,16 @@ contextBridge.exposeInMainWorld('electronAPI', {
getSessions: () => ipcRenderer.invoke('chat:getSessions'),
enrichSessionsContactInfo: (usernames: string[]) =>
ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames),
getMessages: (sessionId: string, offset?: number, limit?: number) =>
ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit),
getMessages: (sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) =>
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),
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),
@@ -108,12 +141,23 @@ 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) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId),
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId, createTime),
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => {
const listener = (_: any, payload: { msgId: string; text: string }) => callback(payload)
ipcRenderer.on('chat:voiceTranscriptPartial', listener)
return () => ipcRenderer.removeListener('chat:voiceTranscriptPartial', listener)
},
execQuery: (kind: string, path: string | null, sql: string) =>
ipcRenderer.invoke('chat:execQuery', kind, path, sql),
getContacts: () => ipcRenderer.invoke('chat:getContacts'),
getMessage: (sessionId: string, localId: number) =>
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)
}
},
@@ -137,11 +181,21 @@ contextBridge.exposeInMainWorld('electronAPI', {
}
},
// 视频
video: {
getVideoInfo: (videoMd5: string) => ipcRenderer.invoke('video:getVideoInfo', videoMd5),
parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content)
},
// 数据分析
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')
@@ -161,7 +215,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
getGroupMembers: (chatroomId: string) => ipcRenderer.invoke('groupAnalytics:getGroupMembers', chatroomId),
getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMessageRanking', chatroomId, limit, startTime, endTime),
getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime),
getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime)
getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime),
exportGroupMembers: (chatroomId: string, outputPath: string) => ipcRenderer.invoke('groupAnalytics:exportGroupMembers', chatroomId, outputPath)
},
// 年度报告
@@ -175,13 +230,29 @@ 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) =>
ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options)
ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options),
exportContacts: (outputDir: string, options: any) =>
ipcRenderer.invoke('export:exportContacts', outputDir, options),
onProgress: (callback: (payload: { current: number; total: number; currentSession: string; phase: string }) => void) => {
ipcRenderer.on('export:progress', (_, payload) => callback(payload))
return () => ipcRenderer.removeAllListeners('export:progress')
}
},
whisper: {
@@ -193,5 +264,41 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.on('whisper:downloadProgress', (_, payload) => callback(payload))
return () => ipcRenderer.removeAllListeners('whisper:downloadProgress')
}
},
// 朋友圈
sns: {
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)
},
// 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

@@ -8,6 +8,7 @@ interface ConfigSchema {
onboardingDone: boolean
imageXorKey: number
imageAesKey: string
wxidConfigs: Record<string, { decryptKey?: string; imageXorKey?: number; imageAesKey?: string; updatedAt?: number }>
// 缓存相关
cachePath: string
@@ -25,12 +26,41 @@ interface ConfigSchema {
whisperDownloadSource: string
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: {
@@ -40,6 +70,7 @@ export class ConfigService {
onboardingDone: false,
imageXorKey: 0,
imageAesKey: '',
wxidConfigs: {},
cachePath: '',
lastOpenedDb: '',
lastSession: '',
@@ -52,7 +83,20 @@ export class ConfigService {
whisperModelDir: '',
whisperDownloadSource: 'tsinghua',
autoTranscribeVoice: false,
transcribeLanguages: ['zh']
transcribeLanguages: ['zh'],
exportDefaultConcurrency: 2,
analyticsExcludedUsernames: [],
authEnabled: false,
authPassword: '',
authUseHello: false,
ignoredUpdateVersion: '',
notificationEnabled: true,
notificationPosition: 'top-right',
notificationFilterMode: 'all',
notificationFilterList: [],
wordCloudExcludeWords: []
}
})
}

View File

@@ -34,6 +34,14 @@ export class ContactCacheService {
const raw = readFileSync(this.cacheFilePath, 'utf8')
const parsed = JSON.parse(raw)
if (parsed && typeof parsed === 'object') {
// 清除无效的头像数据hex 格式而非正确的 base64
for (const key of Object.keys(parsed)) {
const entry = parsed[key]
if (entry?.avatarUrl && entry.avatarUrl.includes('base64,ffd8')) {
// 这是错误的 hex 格式,清除它
entry.avatarUrl = undefined
}
}
this.cache = parsed
}
} catch (error) {

View File

@@ -0,0 +1,159 @@
import * as fs from 'fs'
import * as path from 'path'
import { chatService } from './chatService'
interface ContactExportOptions {
format: 'json' | 'csv' | 'vcf'
exportAvatars: boolean
contactTypes: {
friends: boolean
groups: boolean
officials: boolean
}
}
/**
* 联系人导出服务
*/
class ContactExportService {
/**
* 导出联系人
*/
async exportContacts(
outputDir: string,
options: ContactExportOptions
): Promise<{ success: boolean; successCount?: number; error?: string }> {
try {
// 获取所有联系人
const contactsResult = await chatService.getContacts()
if (!contactsResult.success || !contactsResult.contacts) {
return { success: false, error: contactsResult.error || '获取联系人失败' }
}
let contacts = contactsResult.contacts
// 根据类型过滤
contacts = contacts.filter(c => {
if (c.type === 'friend' && !options.contactTypes.friends) return false
if (c.type === 'group' && !options.contactTypes.groups) return false
if (c.type === 'official' && !options.contactTypes.officials) return false
return true
})
if (contacts.length === 0) {
return { success: false, error: '没有符合条件的联系人' }
}
// 确保输出目录存在
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true })
}
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5)
let outputPath: string
switch (options.format) {
case 'json':
outputPath = path.join(outputDir, `contacts_${timestamp}.json`)
await this.exportToJSON(contacts, outputPath)
break
case 'csv':
outputPath = path.join(outputDir, `contacts_${timestamp}.csv`)
await this.exportToCSV(contacts, outputPath)
break
case 'vcf':
outputPath = path.join(outputDir, `contacts_${timestamp}.vcf`)
await this.exportToVCF(contacts, outputPath)
break
default:
return { success: false, error: '不支持的导出格式' }
}
return { success: true, successCount: contacts.length }
} catch (e) {
return { success: false, error: String(e) }
}
}
/**
* 导出为JSON格式
*/
private async exportToJSON(contacts: any[], outputPath: string): Promise<void> {
const data = {
exportedAt: new Date().toISOString(),
count: contacts.length,
contacts: contacts.map(c => ({
username: c.username,
displayName: c.displayName,
remark: c.remark,
nickname: c.nickname,
type: c.type
}))
}
fs.writeFileSync(outputPath, JSON.stringify(data, null, 2), 'utf-8')
}
/**
* 导出为CSV格式
*/
private async exportToCSV(contacts: any[], outputPath: string): Promise<void> {
const headers = ['用户名', '显示名称', '备注', '昵称', '类型']
const rows = contacts.map(c => [
c.username || '',
c.displayName || '',
c.remark || '',
c.nickname || '',
this.getTypeLabel(c.type)
])
const csvContent = [
headers.join(','),
...rows.map(row => row.map(cell => `"${cell}"`).join(','))
].join('\n')
fs.writeFileSync(outputPath, '\uFEFF' + csvContent, 'utf-8') // 添加BOM以支持Excel
}
/**
* 导出为VCF格式vCard
*/
private async exportToVCF(contacts: any[], outputPath: string): Promise<void> {
const vcards = contacts
.filter(c => c.type === 'friend') // VCF通常只用于个人联系人
.map(c => {
const lines = ['BEGIN:VCARD', 'VERSION:3.0']
// 全名
lines.push(`FN:${c.displayName || c.username}`)
// 昵称
if (c.nickname) {
lines.push(`NICKNAME:${c.nickname}`)
}
// 备注
if (c.remark) {
lines.push(`NOTE:${c.remark}`)
}
// 微信ID
lines.push(`X-WECHAT-ID:${c.username}`)
lines.push('END:VCARD')
return lines.join('\r\n')
})
fs.writeFileSync(outputPath, vcards.join('\r\n\r\n'), 'utf-8')
}
private getTypeLabel(type: string): string {
switch (type) {
case 'friend': return '好友'
case 'group': return '群聊'
case 'official': return '公众号'
default: return '其他'
}
}
}
export const contactExportService = new ContactExportService()

View File

@@ -18,8 +18,7 @@ export class DbPathService {
// 微信4.x 数据目录
possiblePaths.push(join(home, 'Documents', 'xwechat_files'))
// 旧版微信数据目录
possiblePaths.push(join(home, 'Documents', 'WeChat Files'))
for (const path of possiblePaths) {
if (existsSync(path)) {
@@ -69,7 +68,7 @@ export class DbPathService {
}
}
}
} catch {}
} catch { }
return accounts
}
@@ -119,6 +118,48 @@ export class DbPathService {
}
}
/**
* 扫描目录名候选(仅包含下划线的文件夹,排除 all_users
*/
scanWxidCandidates(rootPath: string): WxidInfo[] {
const wxids: WxidInfo[] = []
try {
if (existsSync(rootPath)) {
const entries = readdirSync(rootPath)
for (const entry of entries) {
const entryPath = join(rootPath, entry)
let stat: ReturnType<typeof statSync>
try {
stat = statSync(entryPath)
} catch {
continue
}
if (!stat.isDirectory()) continue
const lower = entry.toLowerCase()
if (lower === 'all_users') continue
if (!entry.includes('_')) continue
wxids.push({ wxid: entry, modifiedTime: stat.mtimeMs })
}
}
if (wxids.length === 0) {
const rootName = basename(rootPath)
if (rootName.includes('_') && rootName.toLowerCase() !== 'all_users') {
const rootStat = statSync(rootPath)
wxids.push({ wxid: rootName, modifiedTime: rootStat.mtimeMs })
}
}
} catch { }
return wxids.sort((a, b) => {
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
return a.wxid.localeCompare(b.wxid)
})
}
/**
* 扫描 wxid 列表
*/
@@ -139,7 +180,7 @@ export class DbPathService {
const modifiedTime = this.getAccountModifiedTime(fullPath)
wxids.push({ wxid: account, modifiedTime })
}
} catch {}
} catch { }
return wxids.sort((a, b) => {
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime

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

@@ -0,0 +1,293 @@
:root {
color-scheme: light;
--bg: #f6f7fb;
--card: #ffffff;
--text: #1f2a37;
--muted: #6b7280;
--accent: #4f46e5;
--sent: #dbeafe;
--received: #ffffff;
--border: #e5e7eb;
--shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
--radius: 16px;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "PingFang SC", "Microsoft YaHei", system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
}
.page {
max-width: 1080px;
margin: 0 auto;
padding: 8px 20px;
height: 100vh;
display: flex;
flex-direction: column;
}
.header {
background: var(--card);
border-radius: 12px;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06);
padding: 12px 20px;
flex-shrink: 0;
}
.title {
font-size: 16px;
font-weight: 600;
margin: 0;
display: inline;
}
.meta {
color: var(--muted);
font-size: 13px;
display: inline;
margin-left: 12px;
}
.meta span {
margin-right: 10px;
}
.controls {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
flex-wrap: wrap;
}
.controls input,
.controls button {
border-radius: 8px;
border: 1px solid var(--border);
padding: 6px 10px;
font-size: 13px;
font-family: inherit;
}
.controls input[type="search"] {
width: 200px;
}
.controls input[type="datetime-local"] {
width: 200px;
}
.controls button {
background: var(--accent);
color: #fff;
border: none;
cursor: pointer;
padding: 6px 14px;
}
.controls button:active {
transform: scale(0.98);
}
.stats {
font-size: 13px;
color: var(--muted);
margin-left: auto;
}
.message-list {
display: flex;
flex-direction: column;
gap: 12px;
padding: 4px 0;
}
.message {
display: flex;
flex-direction: column;
gap: 8px;
}
.message.hidden {
display: none;
}
.message-time {
font-size: 12px;
color: var(--muted);
margin-bottom: 6px;
}
.message-row {
display: flex;
gap: 12px;
align-items: flex-end;
}
.message.sent .message-row {
flex-direction: row-reverse;
}
.avatar {
width: 40px;
height: 40px;
border-radius: 12px;
background: #eef2ff;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
color: #475569;
font-weight: 600;
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.bubble {
max-width: min(70%, 720px);
background: var(--received);
border-radius: 18px;
padding: 12px 14px;
border: 1px solid var(--border);
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
}
.message.sent .bubble {
background: var(--sent);
border-color: transparent;
}
.sender-name {
font-size: 12px;
color: var(--muted);
margin-bottom: 6px;
}
.message-content {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 14px;
line-height: 1.6;
}
.message-text {
word-break: break-word;
}
.inline-emoji {
width: 22px;
height: 22px;
vertical-align: text-bottom;
margin: 0 2px;
}
.message-media {
border-radius: 14px;
max-width: 100%;
}
.previewable {
cursor: zoom-in;
}
.message-media.image,
.message-media.emoji {
max-height: 260px;
object-fit: contain;
background: #f1f5f9;
padding: 6px;
}
.message-media.emoji {
max-height: 160px;
width: auto;
}
.message-media.video {
max-height: 360px;
background: #111827;
}
.message-media.audio {
width: 260px;
}
.image-preview {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.7);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
z-index: 999;
}
.image-preview.active {
opacity: 1;
pointer-events: auto;
}
.image-preview img {
max-width: min(90vw, 1200px);
max-height: 90vh;
border-radius: 18px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.35);
background: #0f172a;
transition: transform 0.1s ease;
cursor: zoom-out;
}
.highlight {
outline: 2px solid var(--accent);
outline-offset: 4px;
border-radius: 18px;
transition: outline-color 0.3s;
}
.empty {
text-align: center;
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

@@ -0,0 +1,295 @@
export const EXPORT_HTML_STYLES = `:root {
color-scheme: light;
--bg: #f6f7fb;
--card: #ffffff;
--text: #1f2a37;
--muted: #6b7280;
--accent: #4f46e5;
--sent: #dbeafe;
--received: #ffffff;
--border: #e5e7eb;
--shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
--radius: 16px;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "PingFang SC", "Microsoft YaHei", system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
}
.page {
max-width: 1080px;
margin: 0 auto;
padding: 8px 20px;
height: 100vh;
display: flex;
flex-direction: column;
}
.header {
background: var(--card);
border-radius: 12px;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06);
padding: 12px 20px;
flex-shrink: 0;
}
.title {
font-size: 16px;
font-weight: 600;
margin: 0;
display: inline;
}
.meta {
color: var(--muted);
font-size: 13px;
display: inline;
margin-left: 12px;
}
.meta span {
margin-right: 10px;
}
.controls {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
flex-wrap: wrap;
}
.controls input,
.controls button {
border-radius: 8px;
border: 1px solid var(--border);
padding: 6px 10px;
font-size: 13px;
font-family: inherit;
}
.controls input[type="search"] {
width: 200px;
}
.controls input[type="datetime-local"] {
width: 200px;
}
.controls button {
background: var(--accent);
color: #fff;
border: none;
cursor: pointer;
padding: 6px 14px;
}
.controls button:active {
transform: scale(0.98);
}
.stats {
font-size: 13px;
color: var(--muted);
margin-left: auto;
}
.message-list {
display: flex;
flex-direction: column;
gap: 12px;
padding: 4px 0;
}
.message {
display: flex;
flex-direction: column;
gap: 8px;
}
.message.hidden {
display: none;
}
.message-time {
font-size: 12px;
color: var(--muted);
margin-bottom: 6px;
}
.message-row {
display: flex;
gap: 12px;
align-items: flex-end;
}
.message.sent .message-row {
flex-direction: row-reverse;
}
.avatar {
width: 40px;
height: 40px;
border-radius: 12px;
background: #eef2ff;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
color: #475569;
font-weight: 600;
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.bubble {
max-width: min(70%, 720px);
background: var(--received);
border-radius: 18px;
padding: 12px 14px;
border: 1px solid var(--border);
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
}
.message.sent .bubble {
background: var(--sent);
border-color: transparent;
}
.sender-name {
font-size: 12px;
color: var(--muted);
margin-bottom: 6px;
}
.message-content {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 14px;
line-height: 1.6;
}
.message-text {
word-break: break-word;
}
.inline-emoji {
width: 22px;
height: 22px;
vertical-align: text-bottom;
margin: 0 2px;
}
.message-media {
border-radius: 14px;
max-width: 100%;
}
.previewable {
cursor: zoom-in;
}
.message-media.image,
.message-media.emoji {
max-height: 260px;
object-fit: contain;
background: #f1f5f9;
padding: 6px;
}
.message-media.emoji {
max-height: 160px;
width: auto;
}
.message-media.video {
max-height: 360px;
background: #111827;
}
.message-media.audio {
width: 260px;
}
.image-preview {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.7);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
z-index: 999;
}
.image-preview.active {
opacity: 1;
pointer-events: auto;
}
.image-preview img {
max-width: min(90vw, 1200px);
max-height: 90vh;
border-radius: 18px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.35);
background: #0f172a;
transition: transform 0.1s ease;
cursor: zoom-out;
}
.highlight {
outline: 2px solid var(--accent);
outline-offset: 4px;
border-radius: 18px;
transition: outline-color 0.3s;
}
.empty {
text-align: center;
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

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

View File

@@ -0,0 +1,745 @@
/**
* HTTP API 服务
* 提供 ChatLab 标准化格式的消息查询 API
*/
import * as http from 'http'
import { URL } from 'url'
import { chatService, Message } from './chatService'
import { wcdbService } from './wcdbService'
import { ConfigService } from './config'
// 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
}
interface ChatLabData {
chatlab: ChatLabHeader
meta: ChatLabMeta
members: ChatLabMember[]
messages: ChatLabMessage[]
}
// 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
}
/**
* 处理 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 = this.mapRowsToMessagesSimple(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) }
}
}
/**
* 简单的行数据到 Message 映射(用于 API 输出)
*/
private mapRowsToMessagesSimple(rows: Record<string, any>[]): Message[] {
const myWxid = this.configService.get('myWxid') || ''
const messages: Message[] = []
for (const row of rows) {
const content = this.getField(row, ['message_content', 'messageContent', 'content', 'msg_content', 'WCDB_CT_message_content']) || ''
const localType = parseInt(this.getField(row, ['local_type', 'localType', 'type', 'msg_type', 'WCDB_CT_local_type']) || '1', 10)
const isSendRaw = this.getField(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send'])
const senderUsername = this.getField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username']) || ''
const createTime = parseInt(this.getField(row, ['create_time', 'createTime', 'msg_create_time', 'WCDB_CT_create_time']) || '0', 10)
const localId = parseInt(this.getField(row, ['local_id', 'localId', 'WCDB_CT_local_id', 'rowid']) || '0', 10)
const serverId = this.getField(row, ['server_id', 'serverId', 'WCDB_CT_server_id']) || ''
let isSend: number
if (isSendRaw !== null && isSendRaw !== undefined) {
isSend = parseInt(isSendRaw, 10)
} else if (senderUsername && myWxid) {
isSend = senderUsername.toLowerCase() === myWxid.toLowerCase() ? 1 : 0
} else {
isSend = 0
}
// 解析消息内容中的特殊字段
let parsedContent = content
let xmlType: string | undefined
let linkTitle: string | undefined
let fileName: string | undefined
let emojiCdnUrl: string | undefined
let emojiMd5: string | undefined
let imageMd5: string | undefined
let videoMd5: string | undefined
let cardNickname: string | undefined
if (localType === 49 && content) {
// 提取 type 子标签
const typeMatch = /<type>(\d+)<\/type>/i.exec(content)
if (typeMatch) xmlType = typeMatch[1]
// 提取 title
const titleMatch = /<title>([^<]*)<\/title>/i.exec(content)
if (titleMatch) linkTitle = titleMatch[1]
// 提取文件名
const fnMatch = /<title>([^<]*)<\/title>/i.exec(content)
if (fnMatch) fileName = fnMatch[1]
}
if (localType === 47 && content) {
const cdnMatch = /cdnurl\s*=\s*"([^"]+)"/i.exec(content)
if (cdnMatch) emojiCdnUrl = cdnMatch[1]
const md5Match = /md5\s*=\s*"([^"]+)"/i.exec(content)
if (md5Match) emojiMd5 = md5Match[1]
}
messages.push({
localId,
talker: '',
localType,
createTime,
sortSeq: createTime,
content: parsedContent,
isSend,
senderUsername,
serverId: serverId ? parseInt(serverId, 10) || 0 : 0,
rawContent: content,
parsedContent: content,
emojiCdnUrl,
emojiMd5,
imageMd5,
videoMd5,
xmlType,
linkTitle,
fileName,
cardNickname
} as Message)
}
return messages
}
/**
* 从行数据中获取字段值(兼容多种字段名)
*/
private getField(row: Record<string, any>, keys: string[]): string | null {
for (const key of keys) {
if (row[key] !== undefined && row[key] !== null) {
return String(row[key])
}
}
return null
}
/**
* 处理消息查询
* GET /api/v1/messages?talker=xxx&limit=100&start=20260101&chatlab=1
*/
private async handleMessages(url: URL, res: http.ServerResponse): Promise<void> {
const talker = url.searchParams.get('talker')
const limit = Math.min(parseInt(url.searchParams.get('limit') || '100', 10), 10000)
const offset = parseInt(url.searchParams.get('offset') || '0', 10)
const startParam = url.searchParams.get('start')
const endParam = url.searchParams.get('end')
const chatlab = url.searchParams.get('chatlab') === '1'
const formatParam = url.searchParams.get('format')
const format = formatParam || (chatlab ? 'chatlab' : 'json')
if (!talker) {
this.sendError(res, 400, 'Missing required parameter: talker')
return
}
// 解析时间参数 (支持 YYYYMMDD 格式)
const startTime = this.parseTimeParam(startParam)
const endTime = this.parseTimeParam(endParam, true)
// 使用批量获取方法,绕过 chatService 的单 batch 限制
const result = await this.fetchMessagesBatch(talker, offset, limit, startTime, endTime, true)
if (!result.success || !result.messages) {
this.sendError(res, 500, result.error || 'Failed to get messages')
return
}
if (format === 'chatlab') {
// 获取会话显示名
const displayNames = await this.getDisplayNames([talker])
const talkerName = displayNames[talker] || talker
const chatLabData = await this.convertToChatLab(result.messages, talker, talkerName)
this.sendJson(res, chatLabData)
} else {
// 返回原始消息格式
this.sendJson(res, {
success: true,
talker,
count: result.messages.length,
hasMore: result.hasMore,
messages: result.messages
})
}
}
/**
* 处理会话列表查询
* GET /api/v1/sessions?keyword=xxx&limit=100
*/
private async handleSessions(url: URL, res: http.ServerResponse): Promise<void> {
const keyword = url.searchParams.get('keyword') || ''
const limit = parseInt(url.searchParams.get('limit') || '100', 10)
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') || ''
const limit = parseInt(url.searchParams.get('limit') || '100', 10)
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))
}
}
/**
* 解析时间参数
* 支持 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): 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
}
})
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 msg.imageMd5 || '[图片]'
case 34:
return '[语音]'
case 43:
return msg.videoMd5 || '[视频]'
case 47:
return msg.emojiCdnUrl || msg.emojiMd5 || '[表情]'
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
@@ -68,14 +68,7 @@ export class ImageDecryptService {
const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
const logLine = `[${timestamp}] [ImageDecrypt] ${message}${metaStr}\n`
// 同时输出到控制台
if (meta) {
console.info(message, meta)
} else {
console.info(message)
}
// 写入日志文件
// 只写入文件,不输出到控制台
this.writeLog(logLine)
}
@@ -387,9 +380,9 @@ export class ImageDecryptService {
}
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
if (suffixMatch) return suffixMatch[1]
const cleaned = suffixMatch ? suffixMatch[1] : trimmed
return trimmed
return cleaned
}
private async resolveDatPath(
@@ -422,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) {
@@ -438,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 })
}
@@ -456,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 })
}
@@ -474,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
}
}
@@ -768,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)
@@ -776,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,
@@ -906,35 +1054,63 @@ export class ImageDecryptService {
}
private findCachedOutput(cacheKey: string, preferHd: boolean = false, sessionId?: string): string | null {
const root = this.getCacheRoot()
const allRoots = this.getAllCacheRoots()
const normalizedKey = this.normalizeDatBase(cacheKey.toLowerCase())
const extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp']
// 遍历所有可能的缓存根路径
for (const root of allRoots) {
// 策略1: 新目录结构 Images/{sessionId}/{YYYY-MM}/{file}_hd.jpg
if (sessionId) {
const sessionDir = join(root, this.sanitizeDirName(sessionId))
if (existsSync(sessionDir)) {
try {
const sessionEntries = readdirSync(sessionDir)
for (const entry of sessionEntries) {
const timeDir = join(sessionDir, entry)
if (!this.isDirectory(timeDir)) continue
const hit = this.findCachedOutputInDir(timeDir, normalizedKey, extensions, preferHd)
if (hit) return hit
}
} catch {
// ignore
}
}
}
const dateDirs = readdirSync(sessionDir, { withFileTypes: true })
.filter(d => d.isDirectory() && /^\d{4}-\d{2}$/.test(d.name))
.map(d => d.name)
.sort()
.reverse() // 最新的日期优先
// 新目录结构: Images/{normalizedKey}/{normalizedKey}_thumb.jpg 或 _hd.jpg
const imageDir = join(root, normalizedKey)
if (existsSync(imageDir)) {
for (const dateDir of dateDirs) {
const imageDir = join(sessionDir, dateDir)
const hit = this.findCachedOutputInDir(imageDir, normalizedKey, extensions, preferHd)
if (hit) return hit
}
} catch { }
}
}
// 兼容旧的平铺结构
// 策略2: 遍历所有 sessionId 目录查找(如果没有指定 sessionId
try {
const sessionDirs = readdirSync(root, { withFileTypes: true })
.filter(d => d.isDirectory())
.map(d => d.name)
for (const session of sessionDirs) {
const sessionDir = join(root, session)
// 检查是否是日期目录结构
try {
const subDirs = readdirSync(sessionDir, { withFileTypes: true })
.filter(d => d.isDirectory() && /^\d{4}-\d{2}$/.test(d.name))
.map(d => d.name)
for (const dateDir of subDirs) {
const imageDir = join(sessionDir, dateDir)
const hit = this.findCachedOutputInDir(imageDir, normalizedKey, extensions, preferHd)
if (hit) return hit
}
} catch { }
}
} catch { }
// 策略3: 旧目录结构 Images/{normalizedKey}/{normalizedKey}_thumb.jpg
const oldImageDir = join(root, normalizedKey)
if (existsSync(oldImageDir)) {
const hit = this.findCachedOutputInDir(oldImageDir, normalizedKey, extensions, preferHd)
if (hit) return hit
}
// 策略4: 最旧的平铺结构 Images/{file}.jpg
for (const ext of extensions) {
const candidate = join(root, `${cacheKey}${ext}`)
if (existsSync(candidate)) return candidate
@@ -943,6 +1119,7 @@ export class ImageDecryptService {
const candidate = join(root, `${cacheKey}_t${ext}`)
if (existsSync(candidate)) return candidate
}
}
return null
}
@@ -1111,15 +1288,19 @@ export class ImageDecryptService {
if (this.cacheIndexed) return
if (this.cacheIndexing) return this.cacheIndexing
this.cacheIndexing = new Promise((resolve) => {
const root = this.getCacheRoot()
// 扫描所有可能的缓存根目录
const allRoots = this.getAllCacheRoots()
this.logInfo('开始索引缓存', { roots: allRoots.length })
for (const root of allRoots) {
try {
this.indexCacheDir(root, 2, 0)
} catch {
this.cacheIndexed = true
this.cacheIndexing = null
resolve()
return
this.indexCacheDir(root, 3, 0) // 增加深度到3支持 sessionId/YYYY-MM 结构
} catch (e) {
this.logError('索引目录失败', e, { root })
}
}
this.logInfo('缓存索引完成', { entries: this.resolvedCache.size })
this.cacheIndexed = true
this.cacheIndexing = null
resolve()
@@ -1127,6 +1308,39 @@ export class ImageDecryptService {
return this.cacheIndexing
}
/**
* 获取所有可能的缓存根路径(用于查找已缓存的图片)
* 包含当前路径、配置路径、旧版本路径
*/
private getAllCacheRoots(): string[] {
const roots: string[] = []
const configured = this.configService.get('cachePath')
const documentsPath = app.getPath('documents')
// 主要路径(当前使用的)
const mainRoot = this.getCacheRoot()
roots.push(mainRoot)
// 如果配置了自定义路径,也检查其下的 Images
if (configured) {
roots.push(join(configured, 'Images'))
roots.push(join(configured, 'images'))
}
// 默认路径
roots.push(join(documentsPath, 'WeFlow', 'Images'))
roots.push(join(documentsPath, 'WeFlow', 'images'))
// 兼容旧路径(如果有的话)
roots.push(join(documentsPath, 'WeFlowData', 'Images'))
// 去重并过滤存在的路径
const uniqueRoots = Array.from(new Set(roots))
const existingRoots = uniqueRoots.filter(r => existsSync(r))
return existingRoots
}
private indexCacheDir(root: string, maxDepth: number, depth: number): void {
let entries: string[]
try {

View File

@@ -33,6 +33,7 @@ export class KeyService {
private ReadProcessMemory: any = null
private MEMORY_BASIC_INFORMATION: any = null
private TerminateProcess: any = null
private QueryFullProcessImageNameW: any = null
// User32
private EnumWindows: any = null
@@ -42,6 +43,7 @@ export class KeyService {
private GetWindowThreadProcessId: any = null
private IsWindowVisible: any = null
private EnumChildWindows: any = null
private PostMessageW: any = null
private WNDENUMPROC_PTR: any = null
// Advapi32
@@ -56,6 +58,7 @@ export class KeyService {
private readonly HKEY_LOCAL_MACHINE = 0x80000002
private readonly HKEY_CURRENT_USER = 0x80000001
private readonly ERROR_SUCCESS = 0
private readonly WM_CLOSE = 0x0010
private getDllPath(): string {
const isPackaged = typeof app !== 'undefined' && app ? app.isPackaged : process.env.NODE_ENV === 'production'
@@ -113,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)
@@ -143,7 +146,7 @@ export class KeyService {
// 检查是否为网络路径,如果是则本地化
if (this.isNetworkPath(dllPath)) {
console.log('检测到网络路径,将进行本地化处理')
dllPath = this.localizeNetworkDll(dllPath)
}
@@ -194,6 +197,7 @@ export class KeyService {
this.OpenProcess = this.kernel32.func('OpenProcess', 'HANDLE', ['uint32', 'bool', 'uint32'])
this.CloseHandle = this.kernel32.func('CloseHandle', 'bool', ['HANDLE'])
this.TerminateProcess = this.kernel32.func('TerminateProcess', 'bool', ['HANDLE', 'uint32'])
this.QueryFullProcessImageNameW = this.kernel32.func('QueryFullProcessImageNameW', 'bool', ['HANDLE', 'uint32', this.koffi.out('uint16*'), this.koffi.out('uint32*')])
this.VirtualQueryEx = this.kernel32.func('VirtualQueryEx', 'uint64', ['HANDLE', 'uint64', this.koffi.out(this.koffi.pointer(this.MEMORY_BASIC_INFORMATION)), 'uint64'])
this.ReadProcessMemory = this.kernel32.func('ReadProcessMemory', 'bool', ['HANDLE', 'uint64', 'void*', 'uint64', this.koffi.out(this.koffi.pointer('uint64'))])
@@ -222,6 +226,7 @@ export class KeyService {
this.EnumWindows = this.user32.func('EnumWindows', 'bool', [this.WNDENUMPROC_PTR, 'intptr_t'])
this.EnumChildWindows = this.user32.func('EnumChildWindows', 'bool', ['void*', this.WNDENUMPROC_PTR, 'intptr_t'])
this.PostMessageW = this.user32.func('PostMessageW', 'bool', ['void*', 'uint32', 'uintptr_t', 'intptr_t'])
this.GetWindowTextW = this.user32.func('GetWindowTextW', 'int', ['void*', this.koffi.out('uint16*'), 'int'])
this.GetWindowTextLengthW = this.user32.func('GetWindowTextLengthW', 'int', ['void*'])
@@ -310,7 +315,46 @@ export class KeyService {
}
}
private async getProcessExecutablePath(pid: number): Promise<string | null> {
if (!this.ensureKernel32()) return null
// 0x1000 = PROCESS_QUERY_LIMITED_INFORMATION
const hProcess = this.OpenProcess(0x1000, false, pid)
if (!hProcess) return null
try {
const sizeBuf = Buffer.alloc(4)
sizeBuf.writeUInt32LE(1024, 0)
const pathBuf = Buffer.alloc(1024 * 2)
const ret = this.QueryFullProcessImageNameW(hProcess, 0, pathBuf, sizeBuf)
if (ret) {
const len = sizeBuf.readUInt32LE(0)
return pathBuf.toString('ucs2', 0, len * 2)
}
return null
} catch (e) {
console.error('获取进程路径失败:', e)
return null
} finally {
this.CloseHandle(hProcess)
}
}
private async findWeChatInstallPath(): Promise<string | null> {
// 0. 优先尝试获取正在运行的微信进程路径
try {
const pid = await this.findWeChatPid()
if (pid) {
const runPath = await this.getProcessExecutablePath(pid)
if (runPath && existsSync(runPath)) {
return runPath
}
}
} catch (e) {
console.error('尝试获取运行中微信路径失败:', e)
}
// 1. Registry - Uninstall Keys
const uninstallKeys = [
'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
@@ -396,16 +440,60 @@ export class KeyService {
return fallbackPid ?? null
}
private async killWeChatProcesses() {
private async waitForWeChatExit(timeoutMs = 8000): Promise<boolean> {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
const weixinPid = await this.findPidByImageName('Weixin.exe')
const wechatPid = await this.findPidByImageName('WeChat.exe')
if (!weixinPid && !wechatPid) return true
await new Promise(r => setTimeout(r, 400))
}
return false
}
private async closeWeChatWindows(): Promise<boolean> {
if (!this.ensureUser32()) return false
let requested = false
const enumWindowsCallback = this.koffi.register((hWnd: any, lParam: any) => {
if (!this.IsWindowVisible(hWnd)) return true
const title = this.getWindowTitle(hWnd)
const className = this.getClassName(hWnd)
const classLower = (className || '').toLowerCase()
const isWeChatWindow = this.isWeChatWindowTitle(title) || classLower.includes('wechat') || classLower.includes('weixin')
if (!isWeChatWindow) return true
requested = true
try {
await execFileAsync('taskkill', ['/F', '/IM', 'Weixin.exe'])
await execFileAsync('taskkill', ['/F', '/IM', 'WeChat.exe'])
this.PostMessageW?.(hWnd, this.WM_CLOSE, 0, 0)
} catch { }
return true
}, this.WNDENUMPROC_PTR)
this.EnumWindows(enumWindowsCallback, 0)
this.koffi.unregister(enumWindowsCallback)
return requested
}
private async killWeChatProcesses(): Promise<boolean> {
const requested = await this.closeWeChatWindows()
if (requested) {
const gracefulOk = await this.waitForWeChatExit(1500)
if (gracefulOk) return true
}
try {
await execFileAsync('taskkill', ['/F', '/T', '/IM', 'Weixin.exe'])
await execFileAsync('taskkill', ['/F', '/T', '/IM', 'WeChat.exe'])
} catch (e) {
// Ignore if not found
}
await new Promise(r => setTimeout(r, 1000))
return await this.waitForWeChatExit(5000)
}
// --- Window Detection ---
private getWindowTitle(hWnd: any): string {
@@ -564,15 +652,24 @@ export class KeyService {
}
// 2. Restart WeChat
onStatus?.('正在重启微信以进行获取...', 0)
await this.killWeChatProcesses()
onStatus?.('正在关闭微信以进行获取...', 0)
const closed = await this.killWeChatProcesses()
if (!closed) {
const err = '无法自动关闭微信,请手动退出后重试'
onStatus?.(err, 2)
return { success: false, error: err }
}
// 3. Launch
// 3. Launch
onStatus?.('正在启动微信...', 0)
const sub = spawn(wechatPath, { detached: true, stdio: 'ignore' })
const sub = spawn(wechatPath, {
detached: true,
stdio: 'ignore',
cwd: dirname(wechatPath)
})
sub.unref()
// 4. Wait for Window & Get PID (Crucial change: discover PID from window)
// 4. Wait for Window & Get PID (Crucial change: discover PID from window)
onStatus?.('等待微信界面就绪...', 0)
const pid = await this.waitForWeChatWindow()
if (!pid) {
@@ -588,6 +685,11 @@ export class KeyService {
if (!ok) {
const error = this.getLastErrorMsg ? this.decodeCString(this.getLastErrorMsg()) : ''
if (error) {
// 检测权限不足错误 (NTSTATUS 0xC0000022 = STATUS_ACCESS_DENIED)
if (error.includes('0xC0000022') || error.includes('ACCESS_DENIED') || error.includes('打开目标进程失败')) {
const friendlyError = '权限不足:无法访问微信进程。\n\n解决方法\n1. 右键 WeFlow 图标,选择"以管理员身份运行"\n2. 关闭可能拦截的安全软件如360、火绒等\n3. 确保微信没有以管理员权限运行'
return { success: false, error: friendlyError }
}
return { success: false, error }
}
const statusBuffer = Buffer.alloc(256)
@@ -836,16 +938,17 @@ export class KeyService {
return null
}
private isAlphaNumAscii(byte: number): boolean {
return (byte >= 0x61 && byte <= 0x7a) || (byte >= 0x41 && byte <= 0x5a) || (byte >= 0x30 && byte <= 0x39)
private isAlphaNumLower(byte: number): boolean {
// 只匹配小写字母 a-z 和数字 0-9AES密钥格式
return (byte >= 0x61 && byte <= 0x7a) || (byte >= 0x30 && byte <= 0x39)
}
private isUtf16AsciiKey(buf: Buffer, start: number): boolean {
private isUtf16LowerKey(buf: Buffer, start: number): boolean {
if (start + 64 > buf.length) return false
for (let j = 0; j < 32; j++) {
const charByte = buf[start + j * 2]
const nullByte = buf[start + j * 2 + 1]
if (nullByte !== 0x00 || !this.isAlphaNumAscii(charByte)) {
if (nullByte !== 0x00 || !this.isAlphaNumLower(charByte)) {
return false
}
}
@@ -878,8 +981,6 @@ export class KeyService {
const regions: Array<[number, number]> = []
const MEM_COMMIT = 0x1000
const MEM_PRIVATE = 0x20000
const MEM_MAPPED = 0x40000
const MEM_IMAGE = 0x1000000
const PAGE_NOACCESS = 0x01
const PAGE_GUARD = 0x100
@@ -894,11 +995,10 @@ export class KeyService {
const protect = info.Protect
const type = info.Type
const regionSize = Number(info.RegionSize)
if (state === MEM_COMMIT && (protect & PAGE_NOACCESS) === 0 && (protect & PAGE_GUARD) === 0) {
if (type === MEM_PRIVATE || type === MEM_MAPPED || type === MEM_IMAGE) {
// 只收集已提交的私有内存(大幅减少扫描区域)
if (state === MEM_COMMIT && type === MEM_PRIVATE && (protect & PAGE_NOACCESS) === 0 && (protect & PAGE_GUARD) === 0) {
regions.push([Number(info.BaseAddress), regionSize])
}
}
const nextAddress = address + regionSize
if (nextAddress <= address) break
@@ -926,87 +1026,52 @@ export class KeyService {
try {
const allRegions = this.getMemoryRegions(hProcess)
const totalRegions = allRegions.length
let scannedCount = 0
let skippedCount = 0
// 优化1: 只保留小内存区域(< 10MB- 密钥通常在小区域,可大幅减少扫描时间
const filteredRegions = allRegions.filter(([_, size]) => size <= 10 * 1024 * 1024)
// 优化2: 优先级排序 - 按大小升序,先扫描小区域(密钥通常在较小区域)
const sortedRegions = filteredRegions.sort((a, b) => a[1] - b[1])
// 优化3: 计算总字节数用于精确进度报告
const totalBytes = sortedRegions.reduce((sum, [_, size]) => sum + size, 0)
let processedBytes = 0
// 优化4: 减小分块大小到 1MB参考 wx_key 项目)
const chunkSize = 1 * 1024 * 1024
const overlap = 65
let currentRegion = 0
for (const [baseAddress, regionSize] of sortedRegions) {
currentRegion++
const progress = totalBytes > 0 ? Math.floor((processedBytes / totalBytes) * 100) : 0
onProgress?.(progress, 100, `扫描内存 ${progress}% (${currentRegion}/${sortedRegions.length})`)
// 每个区域都让出主线程确保UI流畅
await new Promise(resolve => setImmediate(resolve))
let offset = 0
let trailing: Buffer | null = null
while (offset < regionSize) {
const remaining = regionSize - offset
const currentChunkSize = remaining > chunkSize ? chunkSize : remaining
const chunk = this.readProcessMemory(hProcess, baseAddress + offset, currentChunkSize)
if (!chunk || !chunk.length) {
offset += currentChunkSize
trailing = null
for (const [baseAddress, regionSize] of allRegions) {
// 跳过太大的内存区域(> 100MB
if (regionSize > 100 * 1024 * 1024) {
skippedCount++
continue
}
let dataToScan: Buffer
if (trailing && trailing.length) {
dataToScan = Buffer.concat([trailing, chunk])
} else {
dataToScan = chunk
scannedCount++
if (scannedCount % 10 === 0) {
onProgress?.(scannedCount, totalRegions, `正在扫描微信内存... (${scannedCount}/${totalRegions})`)
await new Promise(resolve => setImmediate(resolve))
}
for (let i = 0; i < dataToScan.length - 34; i++) {
if (this.isAlphaNumAscii(dataToScan[i])) continue
const memory = this.readProcessMemory(hProcess, baseAddress, regionSize)
if (!memory) continue
// 直接在原始字节中搜索32字节的小写字母数字序列
for (let i = 0; i < memory.length - 34; i++) {
// 检查前导字符(不是小写字母或数字)
if (this.isAlphaNumLower(memory[i])) continue
// 检查接下来32个字节是否都是小写字母或数字
let valid = true
for (let j = 1; j <= 32; j++) {
if (!this.isAlphaNumAscii(dataToScan[i + j])) {
if (!this.isAlphaNumLower(memory[i + j])) {
valid = false
break
}
}
if (valid && this.isAlphaNumAscii(dataToScan[i + 33])) {
valid = false
if (!valid) continue
// 检查尾部字符(不是小写字母或数字)
if (i + 33 < memory.length && this.isAlphaNumLower(memory[i + 33])) {
continue
}
if (valid) {
const keyBytes = dataToScan.subarray(i + 1, i + 33)
const keyBytes = memory.subarray(i + 1, i + 33)
if (this.verifyKey(ciphertext, keyBytes)) {
return keyBytes.toString('ascii')
}
}
}
for (let i = 0; i < dataToScan.length - 65; i++) {
if (!this.isUtf16AsciiKey(dataToScan, i)) continue
const keyBytes = Buffer.alloc(32)
for (let j = 0; j < 32; j++) {
keyBytes[j] = dataToScan[i + j * 2]
}
if (this.verifyKey(ciphertext, keyBytes)) {
return keyBytes.toString('ascii')
}
}
const start = dataToScan.length - overlap
trailing = dataToScan.subarray(start < 0 ? 0 : start)
offset += currentChunkSize
}
// 更新已处理字节数
processedBytes += regionSize
}
return null
} finally {
try {

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

@@ -0,0 +1,251 @@
import { wcdbService } from './wcdbService'
import { ConfigService } from './config'
import { ContactCacheService } from './contactCacheService'
export interface SnsLivePhoto {
url: string
thumb: string
md5?: 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 SnsPost {
id: string
username: string
nickname: string
avatarUrl?: string
createTime: number
contentDesc: string
type?: number
media: SnsMedia[]
likes: string[]
comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }[]
rawXml?: string // 原始 XML 数据
}
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 connector = fixedUrl.includes('?') ? '&' : '?';
return `${fixedUrl}${connector}token=${token}&idx=1`;
};
class SnsService {
private contactCache: ContactCacheService
constructor() {
const config = new ConfigService()
this.contactCache = new ContactCacheService(config.get('cachePath') as string)
}
async getTimeline(limit: number = 20, offset: number = 0, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: SnsPost[]; error?: string }> {
const result = await wcdbService.getSnsTimeline(limit, offset, usernames, keyword, startTime, endTime)
if (result.success && result.timeline) {
const enrichedTimeline = result.timeline.map((post: any, index: number) => {
const contact = this.contactCache.get(post.username)
// 修复媒体 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 ? {
...m.livePhoto,
url: fixSnsUrl(m.livePhoto.url, m.livePhoto.token),
thumb: fixSnsUrl(m.livePhoto.thumb, m.livePhoto.token),
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)
}
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
})
return {
...post,
avatarUrl: contact?.avatarUrl,
nickname: post.nickname || contact?.displayName || post.username,
media: fixedMedia
}
})
return { ...result, timeline: enrichedTimeline }
}
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)
const options = {
hostname: urlObj.hostname,
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
}
}
const req = https.request(options, (res: any) => {
resolve({
success: true,
status: res.statusCode,
headers: {
'x-enc': res.headers['x-enc'],
'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.end()
} catch (e: any) {
resolve({ success: false, error: e.message })
}
})
}
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) }
}
return new Promise((resolve) => {
try {
const https = require('https')
const zlib = require('zlib')
const urlObj = new URL(url)
const options = {
hostname: urlObj.hostname,
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"
}
}
const req = https.request(options, (res: any) => {
if (res.statusCode !== 200) {
resolve({ success: false, error: `HTTP ${res.statusCode}` })
return
}
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())
}
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}`
// Cache
this.imageCache.set(url, dataUrl)
resolve({ success: true, dataUrl })
})
stream.on('error', (e: any) => {
resolve({ success: false, error: e.message })
})
})
req.on('error', (e: any) => {
resolve({ success: false, error: e.message })
})
req.end()
} catch (e: any) {
resolve({ success: false, error: e.message })
}
})
}
}
export const snsService = new SnsService()

View File

@@ -0,0 +1,279 @@
import { join } from 'path'
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
import { ConfigService } from './config'
import Database from 'better-sqlite3'
import { wcdbService } from './wcdbService'
export interface VideoInfo {
videoUrl?: string // 视频文件路径(用于 readFile
coverUrl?: string // 封面 data URL
thumbUrl?: string // 缩略图 data URL
exists: boolean
}
class VideoService {
private configService: ConfigService
constructor() {
this.configService = new ConfigService()
}
/**
* 获取数据库根目录
*/
private getDbPath(): string {
return this.configService.get('dbPath') || ''
}
/**
* 获取当前用户的wxid
*/
private getMyWxid(): string {
return this.configService.get('myWxid') || ''
}
/**
* 获取缓存目录(解密后的数据库存放位置)
*/
private getCachePath(): string {
return this.configService.get('cachePath') || ''
}
/**
* 清理 wxid 目录名(去掉后缀)
*/
private cleanWxid(wxid: string): string {
const trimmed = wxid.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})$/)
if (suffixMatch) return suffixMatch[1]
return trimmed
}
/**
* 从 video_hardlink_info_v4 表查询视频文件名
* 优先使用 cachePath 中解密后的 hardlink.db使用 better-sqlite3
* 如果失败,则尝试使用 wcdbService.execQuery 查询加密的 hardlink.db
*/
private async queryVideoFileName(md5: string): Promise<string | undefined> {
const cachePath = this.getCachePath()
const dbPath = this.getDbPath()
const wxid = this.getMyWxid()
const cleanedWxid = this.cleanWxid(wxid)
if (!wxid) return undefined
// 方法1优先在 cachePath 下查找解密后的 hardlink.db
if (cachePath) {
const cacheDbPaths = [
join(cachePath, cleanedWxid, 'hardlink.db'),
join(cachePath, wxid, 'hardlink.db'),
join(cachePath, 'hardlink.db'),
join(cachePath, 'databases', cleanedWxid, 'hardlink.db'),
join(cachePath, 'databases', wxid, 'hardlink.db')
]
for (const p of cacheDbPaths) {
if (existsSync(p)) {
try {
const db = new Database(p, { readonly: true })
const row = db.prepare(`
SELECT file_name, md5 FROM video_hardlink_info_v4
WHERE md5 = ?
LIMIT 1
`).get(md5) as { file_name: string; md5: string } | undefined
db.close()
if (row?.file_name) {
const realMd5 = row.file_name.replace(/\.[^.]+$/, '')
return realMd5
}
} catch (e) {
// 忽略错误
}
}
}
}
// 方法2使用 wcdbService.execQuery 查询加密的 hardlink.db
if (dbPath) {
// 检查 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) {
const row = result.rows[0]
if (row?.file_name) {
// 提取不带扩展名的文件名作为实际视频 MD5
const realMd5 = String(row.file_name).replace(/\.[^.]+$/, '')
return realMd5
}
}
} catch (e) {
// 忽略错误
}
}
}
}
return undefined
}
/**
* 将文件转换为 data URL
*/
private fileToDataUrl(filePath: string, mimeType: string): string | undefined {
try {
if (!existsSync(filePath)) return undefined
const buffer = readFileSync(filePath)
return `data:${mimeType};base64,${buffer.toString('base64')}`
} catch {
return undefined
}
}
/**
* 根据视频MD5获取视频文件信息
* 视频存放在: {数据库根目录}/{用户wxid}/msg/video/{年月}/
* 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg
*/
async getVideoInfo(videoMd5: string): Promise<VideoInfo> {
const dbPath = this.getDbPath()
const wxid = this.getMyWxid()
if (!dbPath || !wxid || !videoMd5) {
return { exists: false }
}
// 先尝试从数据库查询真正的视频文件名
const realVideoMd5 = await this.queryVideoFileName(videoMd5) || videoMd5
// 检查 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 }
}
// 遍历年月目录查找视频文件
try {
const allDirs = readdirSync(videoBaseDir)
// 支持多种目录格式: YYYY-MM, YYYYMM, 或其他
const yearMonthDirs = allDirs
.filter(dir => {
const dirPath = join(videoBaseDir, dir)
return statSync(dirPath).isDirectory()
})
.sort((a, b) => b.localeCompare(a)) // 从最新的目录开始查找
for (const yearMonth of yearMonthDirs) {
const dirPath = join(videoBaseDir, yearMonth)
const videoPath = join(dirPath, `${realVideoMd5}.mp4`)
const coverPath = join(dirPath, `${realVideoMd5}.jpg`)
const thumbPath = join(dirPath, `${realVideoMd5}_thumb.jpg`)
// 检查视频文件是否存在
if (existsSync(videoPath)) {
return {
videoUrl: videoPath, // 返回文件路径,前端通过 readFile 读取
coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'),
thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'),
exists: true
}
}
}
} catch (e) {
// 忽略错误
}
return { exists: false }
}
/**
* 根据消息内容解析视频MD5
*/
parseVideoMd5(content: string): string | undefined {
// 打印前500字符看看 XML 结构
if (!content) return undefined
try {
// 提取所有可能的 md5 值进行日志
const allMd5s: string[] = []
const md5Regex = /(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]+)['"]/gi
let match
while ((match = md5Regex.exec(content)) !== null) {
allMd5s.push(`${match[0]}`)
}
// 提取 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) {
return videoMsgMatch[1].toLowerCase()
}
const attrMatch = /\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
if (attrMatch) {
return attrMatch[1].toLowerCase()
}
const md5Match = /<md5>([a-fA-F0-9]+)<\/md5>/i.exec(content)
if (md5Match) {
return md5Match[1].toLowerCase()
}
} catch (e) {
console.error('[VideoService] 解析视频MD5失败:', e)
}
return undefined
}
}
export const videoService = new VideoService()

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, {
@@ -231,20 +229,15 @@ export class VoiceTranscribeService {
resolve({ success: true, transcript: finalTranscript })
worker.terminate()
} else if (msg.type === 'error') {
console.error('[VoiceTranscribe] Worker 错误:', msg.error)
resolve({ success: false, error: msg.error })
worker.terminate()
}
})
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) {
@@ -254,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> {
if (existsSync(targetPath)) {
unlinkSync(targetPath)
}
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
}))
}
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)
}
}
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
console.info(`[VoiceTranscribe] 下载 ${fileName}:`, url)
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
}
}
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'
},
timeout: 30000 // 30秒连接超时
'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) => {
console.info(`[VoiceTranscribe] ${fileName} 响应状态:`, response.statusCode)
// 处理重定向
if ([301, 302, 303, 307, 308].includes(response.statusCode || 0) && response.headers.location) {
if (remainingRedirects <= 0) {
reject(new Error('重定向次数过多'))
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
}
console.info(`[VoiceTranscribe] 重定向到:`, response.headers.location)
this.downloadToFile(response.headers.location, 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) {
}
}
}
}
export const voiceTranscribeService = new VoiceTranscribeService()

View File

@@ -1,6 +1,12 @@
import { join, dirname, basename } from 'path'
import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs'
// DLL 初始化错误信息,用于帮助用户诊断问题
let lastDllInitError: string | null = null
export function getLastDllInitError(): string | null {
return lastDllInitError
}
export class WcdbCore {
private resourcesPath: string | null = null
private userDataPath: string | null = null
@@ -14,6 +20,7 @@ export class WcdbCore {
private currentWxid: string | null = null
// 函数引用
private wcdbInitProtection: any = null
private wcdbInit: any = null
private wcdbShutdown: any = null
private wcdbOpenAccount: any = null
@@ -28,6 +35,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
@@ -36,7 +44,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
@@ -49,6 +59,13 @@ export class WcdbCore {
private wcdbGetEmoticonCdnUrl: any = null
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
@@ -68,6 +85,80 @@ export class WcdbCore {
}
}
// 使用命名管道 IPC
startMonitor(callback: (type: string, json: string) => void): boolean {
if (!this.wcdbStartMonitorPipe) {
this.writeLog('startMonitor: wcdbStartMonitorPipe not available')
return false
}
try {
const result = this.wcdbStartMonitorPipe()
if (result !== 0) {
this.writeLog(`startMonitor: wcdbStartMonitorPipe failed with ${result}`)
return false
}
const net = require('net')
const PIPE_PATH = '\\\\.\\pipe\\weflow_monitor'
setTimeout(() => {
this.monitorPipeClient = net.createConnection(PIPE_PATH, () => {
this.writeLog('Monitor pipe connected')
})
let buffer = ''
this.monitorPipeClient.on('data', (data: Buffer) => {
buffer += data.toString('utf8')
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.trim()) {
try {
const parsed = JSON.parse(line)
callback(parsed.action || 'update', line)
} catch {
callback('update', line)
}
}
}
})
this.monitorPipeClient.on('error', (err: Error) => {
this.writeLog(`Monitor pipe error: ${err.message}`)
})
this.monitorPipeClient.on('close', () => {
this.writeLog('Monitor pipe closed')
this.monitorPipeClient = null
})
}, 100)
this.writeLog('Monitor started via named pipe IPC')
return true
} catch (e) {
console.error('startMonitor failed:', e)
return false
}
}
stopMonitor(): void {
if (this.monitorPipeClient) {
this.monitorPipeClient.destroy()
this.monitorPipeClient = null
}
if (this.wcdbStopMonitorPipe) {
this.wcdbStopMonitorPipe()
}
}
// 保留旧方法签名以兼容
setMonitor(callback: (type: string, json: string) => void): boolean {
return this.startMonitor(callback)
}
/**
* 获取 DLL 路径
*/
@@ -102,7 +193,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
}
@@ -110,7 +201,8 @@ export class WcdbCore {
private writeLog(message: string, force = false): void {
if (!force && !this.isLogEnabled()) return
const line = `[${new Date().toISOString()}] ${message}`
console.log(`[WCDB] ${line}`)
// 同时输出到控制台和文件
try {
const base = this.userDataPath || process.env.WCDB_LOG_DIR || process.cwd()
const dir = join(base, 'logs')
@@ -208,8 +300,65 @@ export class WcdbCore {
return false
}
const dllDir = dirname(dllPath)
const wcdbCorePath = join(dllDir, 'WCDB.dll')
if (existsSync(wcdbCorePath)) {
try {
this.koffi.load(wcdbCorePath)
this.writeLog('预加载 WCDB.dll 成功')
} catch (e) {
console.warn('预加载 WCDB.dll 失败(可能不是致命的):', e)
this.writeLog(`预加载 WCDB.dll 失败: ${String(e)}`)
}
}
const sdl2Path = join(dllDir, 'SDL2.dll')
if (existsSync(sdl2Path)) {
try {
this.koffi.load(sdl2Path)
this.writeLog('预加载 SDL2.dll 成功')
} catch (e) {
console.warn('预加载 SDL2.dll 失败(可能不是致命的):', e)
this.writeLog(`预加载 SDL2.dll 失败: ${String(e)}`)
}
}
this.lib = this.koffi.load(dllPath)
// InitProtection (Added for security)
try {
this.wcdbInitProtection = this.lib.func('bool InitProtection(const char* resourcePath)')
// 尝试多个可能的资源路径
const resourcePaths = [
dllDir, // DLL 所在目录
dirname(dllDir), // 上级目录
this.resourcesPath, // 配置的资源路径
join(process.cwd(), 'resources') // 开发环境
].filter(Boolean)
let protectionOk = false
for (const resPath of resourcePaths) {
try {
//
protectionOk = this.wcdbInitProtection(resPath)
if (protectionOk) {
//
break
}
} catch (e) {
// console.warn(`[WCDB] InitProtection 失败 (${resPath}):`, e)
}
}
if (!protectionOk) {
// console.warn('[WCDB] Core security check failed - 继续运行但可能不稳定')
// this.writeLog('InitProtection 失败,继续运行')
// 不返回 false允许继续运行
}
} catch (e) {
// console.warn('InitProtection symbol not found:', e)
}
// 定义类型
// wcdb_status wcdb_init()
this.wcdbInit = this.lib.func('int32 wcdb_init()')
@@ -263,6 +412,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)')
@@ -299,6 +455,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)')
@@ -306,6 +476,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)')
@@ -354,6 +531,38 @@ export class WcdbCore {
this.wcdbGetVoiceData = null
}
// wcdb_status wcdb_get_sns_timeline(wcdb_handle handle, int32_t limit, int32_t offset, const char* username, const char* keyword, int32_t start_time, int32_t end_time, char** out_json)
try {
this.wcdbGetSnsTimeline = this.lib.func('int32 wcdb_get_sns_timeline(int64 handle, int32 limit, int32 offset, const char* username, const char* keyword, int32 startTime, int32 endTime, _Out_ void** outJson)')
} catch {
this.wcdbGetSnsTimeline = null
}
// wcdb_status wcdb_get_sns_annual_stats(wcdb_handle handle, int32_t begin_timestamp, int32_t end_timestamp, char** out_json)
try {
this.wcdbGetSnsAnnualStats = this.lib.func('int32 wcdb_get_sns_annual_stats(int64 handle, int32 begin, int32 end, _Out_ void** outJson)')
} catch {
this.wcdbGetSnsAnnualStats = null
}
// Named pipe IPC for monitoring (replaces callback)
try {
this.wcdbStartMonitorPipe = this.lib.func('int32 wcdb_start_monitor_pipe()')
this.wcdbStopMonitorPipe = this.lib.func('void wcdb_stop_monitor_pipe()')
this.writeLog('Monitor pipe functions loaded')
} catch (e) {
console.warn('Failed to load monitor pipe functions:', e)
this.wcdbStartMonitorPipe = null
this.wcdbStopMonitorPipe = null
}
// void VerifyUser(int64_t hwnd_ptr, const char* message, char* out_result, int max_len)
try {
this.wcdbVerifyUser = this.lib.func('void VerifyUser(int64 hwnd, const char* message, _Out_ char* outResult, int maxLen)')
} catch {
this.wcdbVerifyUser = null
}
// 初始化
const initResult = this.wcdbInit()
if (initResult !== 0) {
@@ -362,9 +571,20 @@ export class WcdbCore {
}
this.initialized = true
lastDllInitError = null
return true
} catch (e) {
console.error('WCDB 初始化异常:', e)
const errorMsg = e instanceof Error ? e.message : String(e)
console.error('WCDB 初始化异常:', errorMsg)
this.writeLog(`WCDB 初始化异常: ${errorMsg}`, true)
lastDllInitError = errorMsg
// 检查是否是常见的 VC++ 运行时缺失错误
if (errorMsg.includes('126') || errorMsg.includes('找不到指定的模块') ||
errorMsg.includes('The specified module could not be found')) {
lastDllInitError = '可能缺少 Visual C++ 运行时库。请安装 Microsoft Visual C++ Redistributable (x64)。'
} else if (errorMsg.includes('193') || errorMsg.includes('不是有效的 Win32 应用程序')) {
lastDllInitError = 'DLL 架构不匹配。请确保使用 64 位版本的应用程序。'
}
return false
}
}
@@ -382,10 +602,18 @@ export class WcdbCore {
return { success: true, sessionCount: 0 }
}
// 记录当前活动连接,用于在测试结束后恢复(避免影响聊天页等正在使用的连接)
const hadActiveConnection = this.handle !== null
const prevPath = this.currentPath
const prevKey = this.currentKey
const prevWxid = this.currentWxid
if (!this.initialized) {
const initOk = await this.initialize()
if (!initOk) {
return { success: false, error: 'WCDB 初始化失败' }
// 返回更详细的错误信息,帮助用户诊断问题
const detailedError = lastDllInitError || 'WCDB 初始化失败'
return { success: false, error: detailedError }
}
}
@@ -424,8 +652,8 @@ export class WcdbCore {
return { success: false, error: '无效的数据库句柄' }
}
// 测试成功使用 shutdown 清理所有资源(包括测试句柄)
// 这会中断当前活动连接,但 testConnection 本应该是独立测试
// 测试成功使用 shutdown 清理资源(包括测试句柄)
// 注意shutdown 会断开当前活动连接,因此需要在测试后尝试恢复之前的连接
try {
this.wcdbShutdown()
this.handle = null
@@ -437,6 +665,15 @@ export class WcdbCore {
console.error('关闭测试数据库时出错:', closeErr)
}
// 恢复测试前的连接(如果之前有活动连接)
if (hadActiveConnection && prevPath && prevKey && prevWxid) {
try {
await this.open(prevPath, prevKey, prevWxid)
} catch {
// 恢复失败则保持断开,由调用方处理
}
}
return { success: true, sessionCount: 0 }
} catch (e) {
console.error('测试连接异常:', e)
@@ -620,7 +857,7 @@ export class WcdbCore {
try {
this.wcdbSetMyWxid(this.handle, wxid)
} catch (e) {
console.warn('设置 wxid 失败:', e)
// 静默失败
}
}
if (this.isLogEnabled()) {
@@ -719,6 +956,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 未连接' }
@@ -799,7 +1067,6 @@ export class WcdbCore {
await new Promise(resolve => setImmediate(resolve))
if (result !== 0 || !outPtr[0]) {
console.warn(`[wcdbCore] getAvatarUrls DLL调用失败: result=${result}, usernames=${toFetch.length}`)
if (Object.keys(resultMap).length > 0) {
return { success: true, map: resultMap, error: `获取头像失败: ${result}` }
}
@@ -807,25 +1074,18 @@ export class WcdbCore {
}
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) {
console.error('[wcdbCore] getAvatarUrls 解析JSON失败')
return { success: false, error: '解析头像失败' }
}
const map = JSON.parse(jsonStr) as Record<string, string>
let successCount = 0
let emptyCount = 0
for (const username of toFetch) {
const url = map[username]
if (url && url.trim()) {
resultMap[username] = url
// 只缓存有效的URL
this.avatarUrlCache.set(username, { url, updatedAt: now })
successCount++
} else {
emptyCount++
}
// 不缓存空URL,下次可以重新尝试
}
}
console.log(`[wcdbCore] getAvatarUrls 成功: ${successCount}个, 空结果: ${emptyCount}个, 总请求: ${toFetch.length}`)
return { success: true, map: resultMap }
} catch (e) {
console.error('[wcdbCore] getAvatarUrls 异常:', e)
@@ -898,6 +1158,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 未连接' }
@@ -917,6 +1199,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 未连接' }
@@ -1239,13 +1544,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}` }
}
@@ -1337,4 +1660,112 @@ export class WcdbCore {
return { success: false, error: String(e) }
}
}
/**
* 验证 Windows Hello
*/
async verifyUser(message: string, hwnd?: string): Promise<{ success: boolean; error?: string }> {
if (!this.initialized) {
const initOk = await this.initialize()
if (!initOk) return { success: false, error: 'WCDB 初始化失败' }
}
if (!this.wcdbVerifyUser) {
return { success: false, error: 'Binding not found: VerifyUser' }
}
return new Promise((resolve) => {
try {
// Allocate buffer for result JSON
const maxLen = 1024
const outBuf = Buffer.alloc(maxLen)
// Call native function
const hwndVal = hwnd ? BigInt(hwnd) : BigInt(0)
this.wcdbVerifyUser(hwndVal, message || '', outBuf, maxLen)
// Parse result
const jsonStr = this.koffi.decode(outBuf, 'char', -1)
const result = JSON.parse(jsonStr)
resolve(result)
} catch (e) {
resolve({ success: false, error: String(e) })
}
})
}
async getSnsTimeline(limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: any[]; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbGetSnsTimeline) return { success: false, error: '当前 DLL 版本不支持获取朋友圈' }
try {
const outPtr = [null as any]
const usernamesJson = usernames && usernames.length > 0 ? JSON.stringify(usernames) : ''
const result = this.wcdbGetSnsTimeline(
this.handle,
limit,
offset,
usernamesJson,
keyword || '',
startTime || 0,
endTime || 0,
outPtr
)
if (result !== 0 || !outPtr[0]) {
return { success: false, error: `获取朋友圈失败: ${result}` }
}
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析朋友圈数据失败' }
const timeline = JSON.parse(jsonStr)
return { success: true, timeline }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getSnsAnnualStats(beginTimestamp: number, endTimestamp: number): Promise<{ success: boolean; data?: any; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
try {
if (!this.wcdbGetSnsAnnualStats) {
return { success: false, error: 'wcdbGetSnsAnnualStats 未找到' }
}
await new Promise(resolve => setImmediate(resolve))
const outPtr = [null as any]
const result = this.wcdbGetSnsAnnualStats(this.handle, beginTimestamp, endTimestamp, outPtr)
await new Promise(resolve => setImmediate(resolve))
if (result !== 0 || !outPtr[0]) {
return { success: false, error: `getSnsAnnualStats failed: ${result}` }
}
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: 'Failed to decode JSON' }
return { success: true, data: JSON.parse(jsonStr) }
} catch (e) {
console.error('getSnsAnnualStats 异常:', e)
return { success: false, error: String(e) }
}
}
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) }
}
}
}

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)
@@ -58,12 +67,24 @@ export class WcdbService {
})
this.worker.on('error', (err) => {
// Worker error
// Worker 发生错误,需要 reject 所有 pending promises
console.error('WCDB Worker 错误:', err)
const errorMsg = err instanceof Error ? err.message : String(err)
for (const [id, p] of this.pending) {
p.reject(new Error(`Worker 错误: ${errorMsg}`))
}
this.pending.clear()
})
this.worker.on('exit', (code) => {
// Worker 退出,需要 reject 所有 pending promises
if (code !== 0) {
// Worker exited with error
console.error('WCDB Worker 异常退出,退出码:', code)
const errorMsg = `Worker 异常退出 (退出码: ${code})。可能是 DLL 加载失败,请检查是否安装了 Visual C++ Redistributable。`
for (const [id, p] of this.pending) {
p.reject(new Error(errorMsg))
}
this.pending.clear()
}
this.worker = null
})
@@ -110,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(() => { });
}
/**
* 检查服务是否就绪
*/
@@ -175,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 })
}
/**
* 获取消息总数
*/
@@ -217,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 })
}
/**
* 获取消息表列表
*/
@@ -231,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 })
}
/**
* 获取消息元数据
*/
@@ -273,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 })
}
/**
* 获取群聊统计
*/
@@ -350,6 +403,34 @@ export class WcdbService {
return this.callWorker('getVoiceData', { sessionId, createTime, candidates, localId, svrId })
}
/**
* 获取朋友圈
*/
async getSnsTimeline(limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: any[]; error?: string }> {
return this.callWorker('getSnsTimeline', { limit, offset, usernames, keyword, startTime, endTime })
}
/**
* 获取朋友圈年度统计
*/
async getSnsAnnualStats(beginTimestamp: number, endTimestamp: number): Promise<{ success: boolean; data?: any; error?: string }> {
return this.callWorker('getSnsAnnualStats', { beginTimestamp, endTimestamp })
}
/**
* 获取 DLL 内部日志
*/
async getLogs(): Promise<{ success: boolean; logs?: string[]; error?: string }> {
return this.callWorker('getLogs')
}
/**
* 验证 Windows Hello
*/
async verifyUser(message: string, hwnd?: string): Promise<{ success: boolean; error?: string }> {
return this.callWorker('verifyUser', { message, hwnd })
}
}
export const wcdbService = new WcdbService()

View File

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

View File

@@ -80,17 +80,17 @@ function isLanguageAllowed(result: any, allowedLanguages: string[]): boolean {
}
const langTag = result.lang
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
@@ -116,6 +138,18 @@ if (parentPort) {
console.error('[wcdbWorker] getVoiceData failed:', result.error)
}
break
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
default:
result = { success: false, error: `Unknown method: ${type}` }
}

View File

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

View File

@@ -47,11 +47,11 @@ ManifestDPIAware true
DetailPrint "Visual C++ Redistributable 安装成功"
MessageBox MB_OK|MB_ICONINFORMATION "Visual C++ 运行库安装成功!"
${Else}
MessageBox MB_OK|MB_ICONEXCLAMATION "Visual C++ 运行库安装失败,可能需要手动安装。"
MessageBox MB_OK|MB_ICONEXCLAMATION "Visual C++ 运行库安装失败,可能需要手动安装。"
${EndIf}
Delete "$TEMP\vc_redist.x64.exe"
${Else}
MessageBox MB_OK|MB_ICONEXCLAMATION "下载失败:$0$\n$\n可以稍后手动下载安装 Visual C++ Redistributable。"
MessageBox MB_OK|MB_ICONEXCLAMATION "下载失败:$0$\n$\n可以稍后手动下载安装 Visual C++ Redistributable。"
${EndIf}
Goto doneVC

3382
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,17 @@
{
"name": "weflow",
"version": "1.2.0",
"version": "1.5.4",
"description": "WeFlow",
"main": "dist-electron/main.js",
"author": "cc",
"repository": {
"type": "git",
"url": "https://github.com/hicccc77/WeFlow"
},
"//": "二改不应改变此处的作者与应用信息",
"scripts": {
"postinstall": "echo 'No native modules to rebuild'",
"rebuild": "echo 'No native modules to rebuild'",
"rebuild": "electron-rebuild",
"dev": "vite",
"build": "tsc && vite build && electron-builder",
"preview": "vite preview",
@@ -27,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",
@@ -54,6 +63,8 @@
"appId": "com.WeFlow.app",
"publish": {
"provider": "github",
"owner": "hicccc77",
"repo": "WeFlow",
"releaseType": "release"
},
"productName": "WeFlow",
@@ -104,7 +115,26 @@
],
"asarUnpack": [
"node_modules/silk-wasm/**/*",
"node_modules/sherpa-onnx-node/**/*"
"node_modules/sherpa-onnx-node/**/*",
"node_modules/ffmpeg-static/**/*"
],
"extraFiles": [
{
"from": "resources/msvcp140.dll",
"to": "."
},
{
"from": "resources/msvcp140_1.dll",
"to": "."
},
{
"from": "resources/vcruntime140.dll",
"to": "."
},
{
"from": "resources/vcruntime140_1.dll",
"to": "."
}
]
}
}

BIN
resources/msvcp140.dll Normal file

Binary file not shown.

BIN
resources/msvcp140_1.dll Normal file

Binary file not shown.

Binary file not shown.

BIN
resources/vcruntime140.dll Normal file

Binary file not shown.

Binary file not shown.

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,11 +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 DataManagementPage from './pages/DataManagementPage'
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'
@@ -22,31 +30,57 @@ import * as configService from './services/config'
import { Download, X, Shield } from 'lucide-react'
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 } = useAppStore()
const {
setDbConnected,
updateInfo,
setUpdateInfo,
isDownloading,
setIsDownloading,
downloadProgress,
setDownloadProgress,
showUpdateDialog,
setShowUpdateDialog,
setUpdateError,
isLocked,
setLocked
} = useAppStore()
const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore()
const isAgreementWindow = location.pathname === '/agreement-window'
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)
// 锁定状态
// const [isLocked, setIsLocked] = useState(false) // Moved to store
const [lockAvatar, setLockAvatar] = useState<string | undefined>(
localStorage.getItem('app_lock_avatar') || undefined
)
const [lockUseHello, setLockUseHello] = useState(false)
// 协议同意状态
const [showAgreement, setShowAgreement] = useState(false)
const [agreementChecked, setAgreementChecked] = useState(false)
const [agreementLoading, setAgreementLoading] = useState(true)
// 更新提示状态
const [updateInfo, setUpdateInfo] = useState<{ version: string; releaseNotes: string } | null>(null)
const [isDownloading, setIsDownloading] = useState(false)
const [downloadProgress, setDownloadProgress] = useState(0)
useEffect(() => {
const root = document.documentElement
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'
@@ -72,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(() => {
@@ -145,26 +179,48 @@ function App() {
// 监听启动时的更新通知
useEffect(() => {
const removeUpdateListener = window.electronAPI.app.onUpdateAvailable?.((info) => {
setUpdateInfo(info)
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) => {
const removeProgressListener = window.electronAPI?.app?.onDownloadProgress?.((progress: any) => {
setDownloadProgress(progress)
})
return () => {
removeUpdateListener?.()
removeProgressListener?.()
}
}, [])
}, [setUpdateInfo, setDownloadProgress, setShowUpdateDialog, isNotificationWindow])
const handleUpdateNow = async () => {
setShowUpdateDialog(false)
setIsDownloading(true)
setDownloadProgress(0)
setDownloadProgress({ percent: 0 })
try {
await window.electronAPI.app.downloadAndInstall()
} catch (e) {
} catch (e: any) {
console.error('更新失败:', e)
setIsDownloading(false)
// Extract clean error message if possible
const errorMsg = e.message || String(e)
setUpdateError(errorMsg.includes('暂时禁用') ? '自动更新已暂时禁用' : errorMsg)
}
}
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)
}
}
@@ -182,34 +238,81 @@ function App() {
const decryptKey = await configService.getDecryptKey()
const wxid = await configService.getMyWxid()
const onboardingDone = await configService.getOnboardingDone()
const wxidConfig = wxid ? await configService.getWxidConfig(wxid) : null
const effectiveDecryptKey = wxidConfig?.decryptKey || decryptKey
if (wxidConfig?.decryptKey && wxidConfig.decryptKey !== decryptKey) {
await configService.setDecryptKey(wxidConfig.decryptKey)
}
// 如果配置完整,自动测试连接
if (dbPath && decryptKey && wxid) {
if (dbPath && effectiveDecryptKey && wxid) {
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 || ''
if (errorMsg.includes('Visual C++') ||
errorMsg.includes('DLL') ||
errorMsg.includes('Worker') ||
errorMsg.includes('126') ||
errorMsg.includes('模块')) {
console.warn('检测到可能的运行时依赖问题:', errorMsg)
// 不清除配置,让用户安装 VC++ 后重试
}
}
}
} catch (e) {
console.error('自动连接出错:', e)
// 捕获异常但不清除配置,防止循环重新引导
}
}
autoConnect()
}, [isAgreementWindow, isOnboardingWindow, navigate, setDbConnected])
// 检查应用锁
useEffect(() => {
if (isAgreementWindow || isOnboardingWindow || isVideoPlayerWindow) return
const checkLock = async () => {
// 并行获取配置,减少等待
const [enabled, useHello] = await Promise.all([
configService.getAuthEnabled(),
configService.getAuthUseHello()
])
if (enabled) {
setLockUseHello(useHello)
setLocked(true)
// 尝试获取头像
try {
const result = await window.electronAPI.chat.getMyAvatarUrl()
if (result && result.success && result.avatarUrl) {
setLockAvatar(result.avatarUrl)
localStorage.setItem('app_lock_avatar', result.avatarUrl)
}
} catch (e) {
console.error('获取锁屏头像失败', e)
}
}
}
checkLock()
}, [isAgreementWindow, isOnboardingWindow, isVideoPlayerWindow])
// 独立协议窗口
if (isAgreementWindow) {
return <AgreementPage />
@@ -219,11 +322,49 @@ function App() {
return <WelcomePage standalone />
}
// 独立视频播放窗口
if (isVideoPlayerWindow) {
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)}
avatar={lockAvatar}
useHello={lockUseHello}
/>
)}
<TitleBar />
{/* 全局悬浮进度胶囊 (处理:新版本提示、下载进度、错误提示) */}
<UpdateProgressCapsule />
{/* 全局会话监听与通知 */}
<GlobalSessionMonitor />
{/* 全局批量转写进度浮窗 */}
<BatchTranscribeGlobal />
{/* 用户协议弹窗 */}
{showAgreement && !agreementLoading && (
<div className="agreement-overlay">
@@ -245,13 +386,13 @@ function App() {
</div>
<div className="agreement-text">
<h4>1. </h4>
<p></p>
<p></p>
<h4>2. 使</h4>
<p>使使</p>
<p>使使</p>
<h4>3. </h4>
<p>使使</p>
<p>使使</p>
<h4>4. </h4>
<p></p>
@@ -275,31 +416,16 @@ function App() {
</div>
)}
{/* 更新提示 */}
{updateInfo && (
<div className="update-banner">
<span className="update-text">
<strong>v{updateInfo.version}</strong>
</span>
{isDownloading ? (
<div className="update-progress">
<div className="progress-bar">
<div className="progress-fill" style={{ width: `${downloadProgress}%` }} />
</div>
<span>{downloadProgress.toFixed(0)}%</span>
</div>
) : (
<>
<button className="update-btn" onClick={handleUpdateNow}>
<Download size={14} />
</button>
<button className="dismiss-btn" onClick={dismissUpdate}>
<X size={14} />
</button>
</>
)}
</div>
)}
{/* 更新提示对话框 */}
<UpdateDialog
open={showUpdateDialog}
updateInfo={updateInfo}
onClose={() => setShowUpdateDialog(false)}
onUpdate={handleUpdateNow}
onIgnore={handleIgnoreUpdate}
isDownloading={isDownloading}
progress={downloadProgress}
/>
<div className="main-layout">
<Sidebar />
@@ -309,14 +435,20 @@ 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="/data-management" element={<DataManagementPage />} />
<Route path="/dual-report" element={<DualReportPage />} />
<Route path="/dual-report/view" element={<DualReportWindow />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/export" element={<ExportPage />} />
<Route path="/sns" element={<SnsPage />} />
<Route path="/contacts" element={<ContactsPage />} />
<Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} />
</Routes>
</RouteGuard>
</main>

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;

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

@@ -0,0 +1,299 @@
.jump-date-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
animation: fadeIn 0.2s ease-out;
}
.jump-date-modal {
background: var(--card-bg);
width: 340px;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
display: flex;
flex-direction: column;
overflow: hidden;
animation: modalSlideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.jump-date-header {
padding: 18px 20px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--border-color);
.title-area {
display: flex;
align-items: center;
gap: 10px;
color: var(--text-primary);
svg {
color: var(--primary);
}
h3 {
font-size: 16px;
font-weight: 600;
margin: 0;
}
}
.close-btn {
background: none;
border: none;
color: var(--text-tertiary);
cursor: pointer;
padding: 4px;
border-radius: 6px;
display: flex;
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
}
}
.calendar-view {
padding: 20px;
.calendar-nav {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
.current-month {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.nav-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
border-color: var(--primary);
color: var(--primary);
}
}
}
}
.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);
margin-bottom: 8px;
.weekday {
text-align: center;
font-size: 12px;
font-weight: 500;
color: var(--text-tertiary);
padding: 4px 0;
}
}
.days {
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-template-rows: repeat(6, 36px);
gap: 4px;
.day-cell {
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
color: var(--text-primary);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
position: relative;
&.empty {
cursor: default;
}
&:not(.empty):not(.no-message):hover {
background: var(--bg-hover);
}
&.selected {
background: var(--primary);
color: #fff;
font-weight: 600;
}
&.today:not(.selected) {
color: var(--primary);
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;
padding: 0 20px 16px;
button {
flex: 1;
padding: 8px;
font-size: 12px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
color: var(--primary);
border-color: var(--primary);
}
}
}
.dialog-footer {
padding: 16px 20px;
display: flex;
gap: 12px;
background: var(--bg-secondary);
button {
flex: 1;
padding: 10px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.cancel-btn {
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-secondary);
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
}
.confirm-btn {
background: var(--primary);
border: none;
color: #fff;
&:hover {
background: var(--primary-hover);
}
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes modalSlideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -0,0 +1,208 @@
import React, { useState, useMemo } from 'react'
import { X, ChevronLeft, ChevronRight, Calendar as CalendarIcon, Loader2 } from 'lucide-react'
import './JumpToDateDialog.scss'
interface JumpToDateDialogProps {
isOpen: boolean
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(),
messageDates,
loadingDates = false
}) => {
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
const getDaysInMonth = (date: Date) => {
const year = date.getFullYear()
const month = date.getMonth()
return new Date(year, month + 1, 0).getDate()
}
const getFirstDayOfMonth = (date: Date) => {
const year = date.getFullYear()
const month = date.getMonth()
return new Date(year, month, 1).getDay()
}
const generateCalendar = () => {
const daysInMonth = getDaysInMonth(calendarDate)
const firstDay = getFirstDayOfMonth(calendarDate)
const days: (number | null)[] = []
for (let i = 0; i < firstDay; i++) {
days.push(null)
}
for (let i = 1; i <= daysInMonth; i++) {
days.push(i)
}
return days
}
/**
* 判断某天是否有消息
*/
const 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)
}
const handleConfirm = () => {
onSelect(selectedDate)
onClose()
}
const isToday = (day: number) => {
const today = new Date()
return day === today.getDate() &&
calendarDate.getMonth() === today.getMonth() &&
calendarDate.getFullYear() === today.getFullYear()
}
const isSelected = (day: number) => {
return day === selectedDate.getDate() &&
calendarDate.getMonth() === selectedDate.getMonth() &&
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()
return (
<div className="jump-date-overlay" onClick={onClose}>
<div className="jump-date-modal" onClick={e => e.stopPropagation()}>
<div className="jump-date-header">
<div className="title-area">
<CalendarIcon size={18} />
<h3></h3>
</div>
<button className="close-btn" onClick={onClose}>
<X size={18} />
</button>
</div>
<div className="calendar-view">
<div className="calendar-nav">
<button
className="nav-btn"
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))}
>
<ChevronLeft size={18} />
</button>
<span className="current-month">
{calendarDate.getFullYear()}{calendarDate.getMonth() + 1}
</span>
<button
className="nav-btn"
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))}
>
<ChevronRight size={18} />
</button>
</div>
<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" style={{ visibility: loadingDates ? 'hidden' : 'visible' }}>
{days.map((day, i) => (
<div
key={i}
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>
</div>
</div>
<div className="quick-options">
<button onClick={() => {
const d = new Date()
setSelectedDate(d)
setCalendarDate(new Date(d))
}}></button>
<button onClick={() => {
const d = new Date()
d.setDate(d.getDate() - 7)
setSelectedDate(d)
setCalendarDate(new Date(d))
}}></button>
<button onClick={() => {
const d = new Date()
d.setMonth(d.getMonth() - 1)
setSelectedDate(d)
setCalendarDate(new Date(d))
}}></button>
</div>
<div className="dialog-footer">
<button className="cancel-btn" onClick={onClose}></button>
<button className="confirm-btn" onClick={handleConfirm}></button>
</div>
</div>
</div>
)
}
export default JumpToDateDialog

View File

@@ -0,0 +1,29 @@
import React from 'react';
interface LivePhotoIconProps {
size?: number | string;
className?: string;
style?: React.CSSProperties;
}
export const LivePhotoIcon: React.FC<LivePhotoIconProps> = ({ size = 24, className = '', style = {} }) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
className={className}
style={style}
>
<g stroke="none" strokeWidth="1" fill="none" fillRule="evenodd" strokeLinecap="round" strokeLinejoin="round">
<g stroke="currentColor" strokeWidth="2">
<circle fill="currentColor" stroke="none" cx="12" cy="12" r="2.5"></circle>
<circle cx="12" cy="12" r="5.5"></circle>
<circle cx="12" cy="12" r="9" strokeDasharray="1 3.7"></circle>
</g>
</g>
</svg>
);
};

View File

@@ -0,0 +1,185 @@
.lock-screen {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: var(--bg-primary);
z-index: 9999;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
user-select: none;
-webkit-app-region: drag;
transition: all 0.5s cubic-bezier(0.22, 1, 0.36, 1);
backdrop-filter: blur(25px) saturate(180%);
background-color: var(--bg-primary);
// 让背景带一点透明度以增强毛玻璃效果
opacity: 1;
&.unlocked {
opacity: 0;
pointer-events: none;
backdrop-filter: blur(0) saturate(100%);
transform: scale(1.02);
.lock-content {
transform: translateY(-20px) scale(0.95);
filter: blur(10px);
opacity: 0;
}
}
.lock-content {
display: flex;
flex-direction: column;
align-items: center;
width: 320px;
-webkit-app-region: no-drag;
animation: fadeIn 0.5s cubic-bezier(0.4, 0, 0.2, 1);
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
.lock-avatar {
width: 100px;
height: 100px;
border-radius: 50%;
margin-bottom: 24px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
border: 4px solid var(--bg-total);
background-color: var(--bg-secondary);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
}
.lock-title {
font-size: 24px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 32px;
}
.lock-form {
width: 100%;
display: flex;
flex-direction: column;
gap: 16px;
.input-group {
position: relative;
width: 100%;
input {
width: 100%;
height: 48px;
padding: 0 16px;
padding-right: 48px;
border-radius: 12px;
border: 1px solid var(--border-color);
background-color: var(--bg-input);
color: var(--text-primary);
font-size: 16px;
outline: none;
transition: all 0.2s;
&:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px var(--primary-color-alpha);
}
}
.submit-btn {
position: absolute;
right: 8px;
top: 8px;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
border: none;
background: var(--primary-color);
color: white;
cursor: pointer;
transition: opacity 0.2s;
&:hover {
opacity: 0.9;
}
}
}
.hello-btn {
width: 100%;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: 12px;
border: 1px solid var(--border-color);
background-color: var(--bg-secondary);
color: var(--text-primary);
font-size: 15px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
&:hover {
background-color: var(--bg-hover);
transform: translateY(-1px);
}
&.loading {
opacity: 0.7;
pointer-events: none;
}
}
}
.lock-error {
margin-top: 16px;
color: #ff4d4f;
font-size: 14px;
animation: shake 0.5s ease-in-out;
}
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes shake {
0%,
100% {
transform: translateX(0);
}
10%,
30%,
50%,
70%,
90% {
transform: translateX(-4px);
}
20%,
40%,
60%,
80% {
transform: translateX(4px);
}
}

View File

@@ -0,0 +1,169 @@
import { useState, useEffect, useRef } from 'react'
import * as configService from '../services/config'
import { ArrowRight, Fingerprint, Lock, ScanFace, ShieldCheck } from 'lucide-react'
import './LockScreen.scss'
interface LockScreenProps {
onUnlock: () => void
avatar?: string
useHello?: boolean
}
async function sha256(message: string) {
const msgBuffer = new TextEncoder().encode(message)
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer)
const hashArray = Array.from(new Uint8Array(hashBuffer))
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
return hashHex
}
export default function LockScreen({ onUnlock, avatar, useHello = false }: LockScreenProps) {
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [isVerifying, setIsVerifying] = useState(false)
const [isUnlocked, setIsUnlocked] = useState(false)
const [showHello, setShowHello] = useState(false)
const [helloAvailable, setHelloAvailable] = useState(false)
// 用于取消 WebAuthn 请求
const abortControllerRef = useRef<AbortController | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
// 快速检查配置并启动
quickStartHello()
inputRef.current?.focus()
return () => {
// 组件卸载时取消请求
abortControllerRef.current?.abort()
}
}, [])
const handleUnlock = () => {
setIsUnlocked(true)
setTimeout(() => {
onUnlock()
}, 1500)
}
const quickStartHello = async () => {
try {
// 如果父组件已经告诉我们要用 Hello直接开始不等待 IPC
let shouldUseHello = useHello
// 为了稳健,如果 prop 没传(虽然现在都传了),再 check 一次 config
if (!shouldUseHello) {
shouldUseHello = await configService.getAuthUseHello()
}
if (shouldUseHello) {
// 标记为可用,显示按钮
setHelloAvailable(true)
setShowHello(true)
// 立即执行验证 (0延迟)
verifyHello()
}
} catch (e) {
console.error('Quick start hello failed', e)
}
}
const verifyHello = async () => {
if (isVerifying || isUnlocked) return
setIsVerifying(true)
setError('')
try {
const result = await window.electronAPI.auth.hello()
if (result.success) {
handleUnlock()
} else {
console.error('Hello verification failed:', result.error)
setError(result.error || '验证失败')
}
} catch (e: any) {
console.error('Hello verification error:', e)
setError(`验证失败: ${e.message || String(e)}`)
} finally {
setIsVerifying(false)
}
}
const handlePasswordSubmit = async (e?: React.FormEvent) => {
e?.preventDefault()
if (!password || isUnlocked) return
// 如果正在进行 Hello 验证它会自动失败或被取代UI上不用特意取消
// 因为 native 调用是模态的或者独立的,我们只要让 JS 状态不对锁住即可
// 不再检查 isVerifying因为我们允许打断 Hello
setIsVerifying(true)
setError('')
try {
const storedHash = await configService.getAuthPassword()
const inputHash = await sha256(password)
if (inputHash === storedHash) {
handleUnlock()
} else {
setError('密码错误')
setPassword('')
setIsVerifying(false)
// 如果密码错误,是否重新触发 Hello?
// 用户可能想重试密码,暂时不自动触发
}
} catch (e) {
setError('验证失败')
setIsVerifying(false)
}
}
return (
<div className={`lock-screen ${isUnlocked ? 'unlocked' : ''}`}>
<div className="lock-content">
<div className="lock-avatar">
{avatar ? (
<img src={avatar} alt="User" style={{ width: '100%', height: '100%', borderRadius: '50%' }} />
) : (
<Lock size={40} />
)}
</div>
<h2 className="lock-title">WeFlow </h2>
<form className="lock-form" onSubmit={handlePasswordSubmit}>
<div className="input-group">
<input
ref={inputRef}
type="password"
placeholder="输入应用密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
// 移除 disabled允许用户随时输入
/>
<button type="submit" className="submit-btn" disabled={!password}>
<ArrowRight size={18} />
</button>
</div>
{showHello && (
<button
type="button"
className={`hello-btn ${isVerifying ? 'loading' : ''}`}
onClick={verifyHello}
>
<Fingerprint size={20} />
{isVerifying ? '验证中...' : '使用 Windows Hello 解锁'}
</button>
)}
</form>
{error && <div className="lock-error">{error}</div>}
</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,200 @@
.notification-toast-container {
position: fixed;
z-index: 9999;
width: 320px;
background: var(--bg-secondary);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--border-light);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
padding: 12px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
opacity: 0;
transform: scale(0.95);
pointer-events: none; // Allow clicking through when hidden
&.visible {
opacity: 1;
transform: scale(1);
pointer-events: auto;
}
&.static {
position: relative !important;
width: calc(100% - 4px) !important; // Leave 2px margin for anti-aliasing saftey
height: auto !important; // Fits content
min-height: 0;
top: 0 !important;
bottom: 0 !important;
left: 0 !important;
right: 0 !important;
transform: none !important;
margin: 2px !important; // 2px centered margin
border-radius: 12px !important; // Rounded corners
// Disable backdrop filter
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
// Ensure background is solid
background: var(--bg-secondary, #2c2c2c);
color: var(--text-primary, #ffffff);
box-shadow: none !important; // NO SHADOW
border: 1px solid var(--border-light, rgba(255, 255, 255, 0.1));
display: flex;
padding: 16px;
padding-right: 32px; // Make space for close button
box-sizing: border-box;
// Force close button to be visible but transparent background
.notification-close {
opacity: 1 !important;
top: 12px;
right: 12px;
background: transparent !important; // Transparent per user request
&:hover {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.1) !important; // Subtle hover effect
}
}
.notification-time {
top: 24px; // Match padding
right: 40px; // Left of close button (12px + 20px + 8px)
}
}
// Position variants
&.bottom-right {
bottom: 24px;
right: 24px;
transform: translate(0, 20px) scale(0.95);
&.visible {
transform: translate(0, 0) scale(1);
}
}
&.top-right {
top: 24px;
right: 24px;
transform: translate(0, -20px) scale(0.95);
&.visible {
transform: translate(0, 0) scale(1);
}
}
&.bottom-left {
bottom: 24px;
left: 24px;
transform: translate(0, 20px) scale(0.95);
&.visible {
transform: translate(0, 0) scale(1);
}
}
&.top-left {
top: 24px;
left: 24px;
transform: translate(0, -20px) scale(0.95);
&.visible {
transform: translate(0, 0) scale(1);
}
}
&:hover {
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.16) !important;
}
.notification-content {
display: flex;
align-items: flex-start;
gap: 12px;
}
.notification-avatar {
flex-shrink: 0;
}
.notification-text {
flex: 1;
min-width: 0;
.notification-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
.notification-title {
font-weight: 600;
font-size: 14px;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%; // 允许缩放
flex: 1; // 占据剩余空间
min-width: 0; // 关键:允许 flex 子项收缩到内容以下
margin-right: 60px; // Make space for absolute time + close button
}
.notification-time {
font-size: 12px;
color: var(--text-tertiary);
position: absolute;
top: 16px;
right: 36px; // Left of close button (8px + 20px + 8px)
font-variant-numeric: tabular-nums;
}
}
.notification-body {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-all;
}
}
.notification-close {
position: absolute;
top: 8px;
right: 8px;
width: 20px;
height: 20px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
opacity: 0;
transition: all 0.2s;
&:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
}
&:hover .notification-close {
opacity: 1;
}
}

View File

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

View File

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

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

View File

@@ -1,5 +1,5 @@
.sidebar {
width: 200px;
width: 220px;
background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
display: flex;
@@ -32,14 +32,14 @@
display: flex;
flex-direction: column;
gap: 4px;
padding: 0 8px;
padding: 0 12px;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
padding: 10px 12px;
border-radius: 9999px;
color: var(--text-secondary);
text-decoration: none;
@@ -49,7 +49,6 @@
background: transparent;
cursor: pointer;
font-family: inherit;
width: 100%;
&:hover {
background: var(--bg-tertiary);
@@ -77,7 +76,7 @@
}
.sidebar-footer {
padding: 0 8px;
padding: 0 12px;
border-top: 1px solid var(--border-color);
padding-top: 12px;
margin-top: 8px;

View File

@@ -1,11 +1,19 @@
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { NavLink, useLocation } from 'react-router-dom'
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Bot } 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'
function Sidebar() {
const location = useLocation()
const [collapsed, setCollapsed] = useState(false)
const [authEnabled, setAuthEnabled] = useState(false)
const setLocked = useAppStore(state => state.setLocked)
useEffect(() => {
configService.getAuthEnabled().then(setAuthEnabled)
}, [])
const isActive = (path: string) => {
return location.pathname === path || location.pathname.startsWith(`${path}/`)
@@ -34,7 +42,25 @@ function Sidebar() {
<span className="nav-label"></span>
</NavLink>
{/* 朋友圈 */}
<NavLink
to="/sns"
className={`nav-item ${isActive('/sns') ? 'active' : ''}`}
title={collapsed ? '朋友圈' : undefined}
>
<span className="nav-icon"><Aperture size={20} /></span>
<span className="nav-label"></span>
</NavLink>
{/* 通讯录 */}
<NavLink
to="/contacts"
className={`nav-item ${isActive('/contacts') ? 'active' : ''}`}
title={collapsed ? '通讯录' : undefined}
>
<span className="nav-icon"><UserCircle size={20} /></span>
<span className="nav-label"></span>
</NavLink>
{/* 私聊分析 */}
<NavLink
@@ -76,18 +102,21 @@ function Sidebar() {
<span className="nav-label"></span>
</NavLink>
{/* 数据管理 */}
<NavLink
to="/data-management"
className={`nav-item ${isActive('/data-management') ? 'active' : ''}`}
title={collapsed ? '数据管理' : undefined}
>
<span className="nav-icon"><Database size={20} /></span>
<span className="nav-label"></span>
</NavLink>
</nav>
<div className="sidebar-footer">
{authEnabled && (
<button
className="nav-item"
onClick={() => setLocked(true)}
title={collapsed ? '锁定' : undefined}
>
<span className="nav-icon"><Lock size={20} /></span>
<span className="nav-label"></span>
</button>
)}
<NavLink
to="/settings"
className={`nav-item ${isActive('/settings') ? 'active' : ''}`}

View File

@@ -1,10 +1,14 @@
import './TitleBar.scss'
function TitleBar() {
interface TitleBarProps {
title?: string
}
function TitleBar({ title }: TitleBarProps = {}) {
return (
<div className="title-bar">
<img src="./logo.png" alt="WeFlow" className="title-logo" />
<span className="titles">WeFlow</span>
<span className="titles">{title || 'WeFlow'}</span>
</div>
)
}

View File

@@ -0,0 +1,274 @@
.update-dialog-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: 9999;
animation: fadeIn 0.3s ease-out;
.update-dialog {
width: 680px;
background: #f5f5f5;
border-radius: 24px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
overflow: hidden;
position: relative;
animation: slideUp 0.3s ease-out;
display: flex;
flex-direction: column;
/* Top Section (White/Gradient) */
.dialog-header {
background: #ffffff;
padding: 40px 20px 30px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
position: relative;
/* Subtle radial gradient effect in top left as seen in image */
&::before {
content: '';
position: absolute;
top: -50px;
left: -50px;
width: 200px;
height: 200px;
background: radial-gradient(circle, rgba(255, 235, 220, 0.4) 0%, rgba(255, 255, 255, 0) 70%);
opacity: 0.8;
pointer-events: none;
}
.version-tag {
background: #f0eee9;
color: #8c7b6e;
padding: 4px 16px;
border-radius: 12px;
font-size: 13px;
font-weight: 600;
margin-bottom: 24px;
letter-spacing: 0.5px;
}
h2 {
font-size: 32px;
font-weight: 800;
color: #333333;
margin: 0 0 12px;
letter-spacing: -0.5px;
}
.subtitle {
font-size: 15px;
color: #999999;
font-weight: 400;
}
}
/* Content Section (Light Gray) */
.dialog-content {
background: #f2f2f2;
padding: 24px 40px 40px;
flex: 1;
display: flex;
flex-direction: column;
.update-notes-container {
display: flex;
align-items: flex-start;
padding: 20px 0;
margin-bottom: 30px;
.icon-box {
background: #fbfbfb; // Beige-ish white
width: 48px;
height: 48px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 20px;
flex-shrink: 0;
color: #8c7b6e;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.03);
svg {
opacity: 0.8;
}
}
.text-box {
flex: 1;
h3 {
font-size: 18px;
font-weight: 700;
color: #333333;
margin: 0 0 8px;
}
p {
font-size: 14px;
color: #666666;
line-height: 1.6;
margin: 0;
}
ul {
margin: 8px 0 0 18px;
padding: 0;
li {
font-size: 14px;
color: #666666;
line-height: 1.6;
}
}
}
}
.progress-section {
margin-bottom: 30px;
.progress-info-row {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
font-size: 12px;
color: #888;
font-weight: 500;
}
.progress-bar-bg {
height: 6px;
background: #e0e0e0;
border-radius: 3px;
overflow: hidden;
.progress-bar-fill {
height: 100%;
background: #000000;
border-radius: 3px;
transition: width 0.3s ease;
}
}
.status-text {
text-align: center;
margin-top: 12px;
font-size: 13px;
color: #666;
}
}
.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;
color: #ffffff;
border: none;
padding: 16px 48px;
border-radius: 20px; // Pill shape
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
}
&:active {
transform: translateY(0);
}
&:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none;
}
}
}
}
.close-btn {
position: absolute;
top: 16px;
right: 16px;
background: rgba(0, 0, 0, 0.05);
border: none;
color: #999;
cursor: pointer;
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
z-index: 10;
&:hover {
background: rgba(0, 0, 0, 0.1);
color: #333;
transform: rotate(90deg);
}
}
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}

View File

@@ -0,0 +1,139 @@
import React, { useEffect, useState } from 'react'
import { Quote, X } from 'lucide-react'
import './UpdateDialog.scss'
interface UpdateInfo {
version?: string
releaseNotes?: string
}
interface UpdateDialogProps {
open: boolean
updateInfo: UpdateInfo | null
onClose: () => void
onUpdate: () => void
onIgnore?: () => void
isDownloading: boolean
progress: number | {
percent: number
bytesPerSecond?: number
transferred?: number
total?: number
remaining?: number // seconds
}
}
const UpdateDialog: React.FC<UpdateDialogProps> = ({
open,
updateInfo,
onClose,
onUpdate,
onIgnore,
isDownloading,
progress
}) => {
if (!open || !updateInfo) return null
// Safe normalize progress
const safeProgress = typeof progress === 'number' ? { percent: progress } : (progress || { percent: 0 })
const percent = safeProgress.percent || 0
const bytesPerSecond = safeProgress.bytesPerSecond
const total = safeProgress.total
const transferred = safeProgress.transferred
const remaining = safeProgress.remaining
// Format bytes
const formatBytes = (bytes: number) => {
if (!Number.isFinite(bytes) || bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
const unitIndex = Math.max(0, Math.min(i, sizes.length - 1))
return parseFloat((bytes / Math.pow(k, unitIndex)).toFixed(1)) + ' ' + sizes[unitIndex]
}
// Format speed
const formatSpeed = (bytesPerSecond: number) => {
return `${formatBytes(bytesPerSecond)}/s`
}
// Format time
const formatTime = (seconds: number) => {
if (!Number.isFinite(seconds)) return '计算中...'
if (seconds < 60) return `${Math.ceil(seconds)}`
const minutes = Math.floor(seconds / 60)
const remainingSeconds = Math.ceil(seconds % 60)
return `${minutes}${remainingSeconds}`
}
return (
<div className="update-dialog-overlay">
<div className="update-dialog">
{!isDownloading && (
<button className="close-btn" onClick={onClose}>
<X size={20} />
</button>
)}
<div className="dialog-header">
<div className="version-tag">
{updateInfo.version}
</div>
<h2> WeFlow</h2>
<div className="subtitle"></div>
</div>
<div className="dialog-content">
<div className="update-notes-container">
<div className="icon-box">
<Quote size={20} />
</div>
<div className="text-box">
<h3></h3>
{updateInfo.releaseNotes ? (
<div dangerouslySetInnerHTML={{ __html: updateInfo.releaseNotes }} />
) : (
<p></p>
)}
</div>
</div>
{isDownloading ? (
<div className="progress-section">
<div className="progress-info-row">
<span>{bytesPerSecond ? formatSpeed(bytesPerSecond) : '下载中...'}</span>
<span>{total ? `${formatBytes(transferred || 0)} / ${formatBytes(total)}` : `${percent.toFixed(1)}%`}</span>
{remaining !== undefined && <span> {formatTime(remaining)}</span>}
</div>
<div className="progress-bar-bg">
<div
className="progress-bar-fill"
style={{ width: `${percent}%` }}
/>
</div>
{/* Fallback status text if detailed info is missing */}
{(!bytesPerSecond && !total) && (
<div className="status-text">{percent.toFixed(0)}% </div>
)}
</div>
) : (
<div className="actions">
{onIgnore && (
<button className="btn-ignore" onClick={onIgnore}>
</button>
)}
<button className="btn-update" onClick={onUpdate}>
</button>
</div>
)}
</div>
</div>
</div>
)
}
export default UpdateDialog

View File

@@ -0,0 +1,192 @@
.update-progress-capsule {
position: fixed;
top: 38px; // Just below title bar
left: 50%;
transform: translateX(-50%);
z-index: 9998;
cursor: pointer;
animation: capsuleSlideDown 0.4s cubic-bezier(0.16, 1, 0.3, 1);
user-select: none;
&:hover {
.capsule-content {
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.12);
transform: scale(1.02);
}
}
.capsule-content {
background: var(--bg-primary);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
padding: 8px 18px;
border-radius: 24px;
border: 1px solid var(--border-color);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.12);
display: flex;
align-items: center;
gap: 12px;
height: 40px;
position: relative;
overflow: hidden;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
.icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
color: var(--text-primary);
.download-icon {
animation: capsulePulse 2s infinite ease-in-out;
}
}
.info-wrapper {
display: flex;
align-items: baseline;
gap: 10px;
z-index: 1;
.percent-text {
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
}
.speed-text {
font-size: 13px;
color: var(--text-tertiary);
font-weight: 500;
font-variant-numeric: tabular-nums;
}
.error-text {
font-size: 15px;
color: #ff4d4f;
font-weight: 600;
}
.available-text {
font-size: 15px;
color: var(--text-primary);
font-weight: 600;
}
}
.progress-bg {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
background: rgba(0, 0, 0, 0.05);
.progress-fill {
height: 100%;
background: var(--primary);
transition: width 0.3s ease;
}
}
.capsule-close {
background: none;
border: none;
padding: 4px;
margin-left: -4px;
margin-right: -8px;
cursor: pointer;
opacity: 0.5;
transition: all 0.2s ease;
display: flex;
align-items: center;
color: var(--text-secondary);
&:hover {
opacity: 1;
background: var(--bg-tertiary);
border-radius: 50%;
}
}
}
// State Modifiers
&.state-available {
.capsule-content {
background: var(--primary);
border-color: rgba(255, 255, 255, 0.1);
color: white;
.icon-wrapper {
color: white;
}
.info-wrapper {
.available-text {
color: white;
}
}
.capsule-close {
color: rgba(255, 255, 255, 0.8);
&:hover {
background: rgba(255, 255, 255, 0.1);
}
}
}
}
&.state-downloading {
.capsule-content {
background: var(--bg-primary);
}
}
&.state-error {
.capsule-content {
background: #fff1f0;
border-color: #ffa39e;
.icon-wrapper {
color: #ff4d4f;
}
.info-wrapper .error-text {
color: #cf1322;
}
.capsule-close {
color: #cf1322;
}
}
}
}
@keyframes capsuleSlideDown {
from {
transform: translate(-50%, -40px);
opacity: 0;
}
to {
transform: translate(-50%, 0);
opacity: 1;
}
}
@keyframes capsulePulse {
0%,
100% {
transform: translateY(0);
opacity: 1;
}
50% {
transform: translateY(2px);
opacity: 0.6;
}
}

View File

@@ -0,0 +1,118 @@
import React from 'react'
import { useAppStore } from '../stores/appStore'
import { Download, X, AlertCircle, Info } from 'lucide-react'
import './UpdateProgressCapsule.scss'
const UpdateProgressCapsule: React.FC = () => {
const {
isDownloading,
downloadProgress,
showUpdateDialog,
setShowUpdateDialog,
updateInfo,
setUpdateInfo,
updateError,
setUpdateError
} = useAppStore()
// Control visibility
// If dialog is open, we usually hide the capsule UNLESS we want it as a mini-indicator
// For now, let's hide it if the dialog is open
if (showUpdateDialog) return null
// State mapping
const hasError = !!updateError
const hasUpdate = !!updateInfo && updateInfo.hasUpdate
if (!hasError && !isDownloading && !hasUpdate) return null
// Safe normalize progress
const safeProgress = typeof downloadProgress === 'number' ? { percent: downloadProgress } : (downloadProgress || { percent: 0 })
const percent = safeProgress.percent || 0
const bytesPerSecond = safeProgress.bytesPerSecond
const formatBytes = (bytes: number) => {
if (!Number.isFinite(bytes) || bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
const unitIndex = Math.max(0, Math.min(i, sizes.length - 1))
return parseFloat((bytes / Math.pow(k, unitIndex)).toFixed(1)) + ' ' + sizes[unitIndex]
}
const formatSpeed = (bps: number) => {
return `${formatBytes(bps)}/s`
}
const handleClose = (e: React.MouseEvent) => {
e.stopPropagation()
if (hasError) {
setUpdateError(null)
} else if (hasUpdate && !isDownloading) {
setUpdateInfo(null)
}
}
// Determine appearance class and content
let capsuleClass = 'update-progress-capsule'
let content = null
if (hasError) {
capsuleClass += ' state-error'
content = (
<>
<div className="icon-wrapper">
<AlertCircle size={14} />
</div>
<div className="info-wrapper">
<span className="error-text">: {updateError}</span>
</div>
</>
)
} else if (isDownloading) {
capsuleClass += ' state-downloading'
content = (
<>
<div className="icon-wrapper">
<Download size={14} className="download-icon" />
</div>
<div className="info-wrapper">
<span className="percent-text">{percent.toFixed(0)}%</span>
{bytesPerSecond > 0 && (
<span className="speed-text">{formatSpeed(bytesPerSecond)}</span>
)}
</div>
<div className="progress-bg">
<div className="progress-fill" style={{ width: `${percent}%` }} />
</div>
</>
)
} else if (hasUpdate) {
capsuleClass += ' state-available'
content = (
<>
<div className="icon-wrapper">
<Info size={14} />
</div>
<div className="info-wrapper">
<span className="available-text"> v{updateInfo?.version}</span>
</div>
</>
)
}
return (
<div className={capsuleClass} onClick={() => setShowUpdateDialog(true)}>
<div className="capsule-content">
{content}
{!isDownloading && (
<button className="capsule-close" onClick={handleClose}>
<X size={12} />
</button>
)}
</div>
</div>
)
}
export default UpdateProgressCapsule

View File

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

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

@@ -11,7 +11,7 @@ function AgreementPage() {
<h2></h2>
<h3></h3>
<p>使WeFlowWeFlow使使</p>
<p>使WeFlowWeFlow使使</p>
<h3></h3>
<p>WeFlow是一款本地化的微信聊天记录查看与分析工具</p>
@@ -35,7 +35,7 @@ function AgreementPage() {
<p></p>
<h3></h3>
<p></p>
<p></p>
<h3></h3>
<p>访</p>

View File

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

View File

@@ -1,22 +1,52 @@
import { useState, useEffect } from 'react'
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 './DataManagementPage.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 loadData = async (forceRefresh = false) => {
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)
setError(null)
@@ -55,17 +85,100 @@ function AnalyticsPage() {
setIsLoading(false)
if (removeListener) removeListener()
}
}
}, [isLoaded, markLoaded, setRankings, setStatistics, setTimeDistribution])
const location = useLocation()
useEffect(() => {
const force = location.state?.forceRefresh === true
loadData(force)
}, [location.state])
}, [location.state, loadData])
useEffect(() => {
const handleChange = () => {
loadExcludedUsernames()
loadData(true)
}
window.addEventListener('wxid-changed', handleChange as EventListener)
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
}, [loadData, loadExcludedUsernames])
useEffect(() => {
loadExcludedUsernames()
}, [loadExcludedUsernames])
const handleRefresh = () => loadData(true)
const loadExcludeCandidates = useCallback(async () => {
setExcludeLoading(true)
setExcludeError(null)
try {
const result = await window.electronAPI.analytics.getExcludeCandidates()
if (result.success && result.data) {
setExcludeCandidates(result.data)
} else {
setExcludeError(result.error || '加载好友列表失败')
}
} catch (e) {
setExcludeError(String(e))
} finally {
setExcludeLoading(false)
}
}, [])
const openExcludeDialog = async () => {
setExcludeQuery('')
setDraftExcluded(new Set(excludedUsernames))
setIsExcludeDialogOpen(true)
await loadExcludeCandidates()
}
const toggleExcluded = (username: string) => {
const key = normalizeUsername(username)
setDraftExcluded((prev) => {
const next = new Set(prev)
if (next.has(key)) {
next.delete(key)
} else {
next.add(key)
}
return next
})
}
const handleApplyExcluded = async () => {
const payload = Array.from(draftExcluded)
setIsExcludeDialogOpen(false)
try {
const result = await window.electronAPI.analytics.setExcludedUsernames(payload)
if (!result.success) {
alert(result.error || '更新排除名单失败')
return
}
setExcludedUsernames(new Set((result.data || payload).map(normalizeUsername)))
clearCache()
await window.electronAPI.cache.clearAnalytics()
await loadData(true)
} catch (e) {
alert(`更新排除名单失败:${String(e)}`)
}
}
const visibleExcludeCandidates = excludeCandidates
.filter((candidate) => {
const query = excludeQuery.trim().toLowerCase()
if (!query) return true
const wechatId = candidate.wechatId || ''
const haystack = `${candidate.displayName} ${candidate.username} ${wechatId}`.toLowerCase()
return haystack.includes(query)
})
.sort((a, b) => {
const aSelected = draftExcluded.has(normalizeUsername(a.username))
const bSelected = draftExcluded.has(normalizeUsername(b.username))
if (aSelected !== bSelected) return aSelected ? -1 : 1
return a.displayName.localeCompare(b.displayName, 'zh')
})
const formatDate = (timestamp: number | null) => {
if (!timestamp) return '-'
const date = new Date(timestamp * 1000)
@@ -240,10 +353,16 @@ function AnalyticsPage() {
<>
<div className="page-header">
<h1></h1>
<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">
@@ -309,6 +428,84 @@ 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">
<span className="exclude-count"> {draftExcluded.size} </span>
<div className="exclude-actions">
<button className="btn btn-secondary" onClick={() => setIsExcludeDialogOpen(false)}>
</button>
<button className="btn btn-primary" onClick={handleApplyExcluded} disabled={excludeLoading}>
</button>
</div>
</div>
</div>
</div>
)}
</>
)
}

View File

@@ -34,8 +34,8 @@ function AnalyticsWelcomePage() {
</div>
<h1></h1>
<p>
WeFlow <br />
WeFlow <br />
</p>
<div className="action-cards">

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,21 +78,39 @@ function AnnualReportPage() {
)
}
const yearOptions: YearOption[] = availableYears.length > 0
? ['all', ...availableYears]
: []
const getYearLabel = (value: YearOption | null) => {
if (!value) return ''
return value === 'all' ? '全部时间' : `${value}`
}
return (
<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="report-sections">
<section className="report-section">
<div className="section-header">
<div>
<h2 className="section-title"></h2>
<p className="section-desc"></p>
</div>
</div>
<div className="year-grid">
{availableYears.map(year => (
{yearOptions.map(option => (
<div
key={year}
className={`year-card ${selectedYear === year ? 'selected' : ''}`}
onClick={() => setSelectedYear(year)}
key={option}
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedYear === option ? 'selected' : ''}`}
onClick={() => setSelectedYear(option)}
>
<span className="year-number">{year}</span>
<span className="year-label"></span>
<span className="year-number">{option === 'all' ? '全部' : option}</span>
<span className="year-label">{option === 'all' ? '时间' : '年'}</span>
</div>
))}
</div>
@@ -99,10 +128,48 @@ function AnnualReportPage() {
) : (
<>
<Sparkles size={20} />
<span> {selectedYear} </span>
<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

@@ -0,0 +1,132 @@
.chat-history-page {
display: flex;
flex-direction: column;
height: 100vh;
background: var(--bg-primary);
.history-list {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
.status-msg {
text-align: center;
padding: 40px 20px;
color: var(--text-tertiary);
font-size: 14px;
&.error {
color: var(--danger);
}
&.empty {
color: var(--text-tertiary);
}
}
}
.history-item {
display: flex;
gap: 12px;
align-items: flex-start;
.avatar {
width: 40px;
height: 40px;
border-radius: 4px;
overflow: hidden;
flex-shrink: 0;
background: var(--bg-tertiary);
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-tertiary);
font-size: 16px;
font-weight: 500;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
}
.content-wrapper {
flex: 1;
min-width: 0;
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
.sender {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
}
.time {
font-size: 12px;
color: var(--text-tertiary);
flex-shrink: 0;
margin-left: 8px;
}
}
.bubble {
background: var(--bg-secondary);
padding: 10px 14px;
border-radius: 18px 18px 18px 4px;
word-wrap: break-word;
max-width: 100%;
display: inline-block;
&.image-bubble {
padding: 0;
background: transparent;
}
.text-content {
font-size: 14px;
line-height: 1.6;
color: var(--text-primary);
white-space: pre-wrap;
word-break: break-word;
}
.media-content {
img {
max-width: 100%;
max-height: 300px;
border-radius: 8px;
display: block;
}
.media-tip {
padding: 8px 12px;
color: var(--text-tertiary);
font-size: 13px;
}
}
.media-placeholder {
font-size: 14px;
color: var(--text-secondary);
padding: 4px 0;
}
}
}
}
}

View File

@@ -0,0 +1,250 @@
import { useEffect, useState } from 'react'
import { useParams, useLocation } from 'react-router-dom'
import { ChatRecordItem } from '../types/models'
import TitleBar from '../components/TitleBar'
import './ChatHistoryPage.scss'
export default function ChatHistoryPage() {
const params = useParams<{ sessionId: string; messageId: string }>()
const location = useLocation()
const [recordList, setRecordList] = useState<ChatRecordItem[]>([])
const [loading, setLoading] = useState(true)
const [title, setTitle] = useState('聊天记录')
const [error, setError] = useState('')
// 简单的 XML 标签内容提取
const extractXmlValue = (xml: string, tag: string): string => {
const match = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`).exec(xml)
return match ? match[1] : ''
}
// 简单的 HTML 实体解码
const decodeHtmlEntities = (text?: string): string | undefined => {
if (!text) return text
return text
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
}
// 前端兜底解析合并转发聊天记录
const parseChatHistory = (content: string): ChatRecordItem[] | undefined => {
try {
const type = extractXmlValue(content, 'type')
if (type !== '19') return undefined
const match = /<recorditem>[\s\S]*?<!\[CDATA\[([\s\S]*?)\]\]>[\s\S]*?<\/recorditem>/.exec(content)
if (!match) return undefined
const innerXml = match[1]
const items: ChatRecordItem[] = []
const itemRegex = /<dataitem\s+(.*?)>([\s\S]*?)<\/dataitem>/g
let itemMatch: RegExpExecArray | null
while ((itemMatch = itemRegex.exec(innerXml)) !== null) {
const attrs = itemMatch[1]
const body = itemMatch[2]
const datatypeMatch = /datatype="(\d+)"/.exec(attrs)
const datatype = datatypeMatch ? parseInt(datatypeMatch[1]) : 0
const sourcename = extractXmlValue(body, 'sourcename')
const sourcetime = extractXmlValue(body, 'sourcetime')
const sourceheadurl = extractXmlValue(body, 'sourceheadurl')
const datadesc = extractXmlValue(body, 'datadesc')
const datatitle = extractXmlValue(body, 'datatitle')
const fileext = extractXmlValue(body, 'fileext')
const datasize = parseInt(extractXmlValue(body, 'datasize') || '0')
const messageuuid = extractXmlValue(body, 'messageuuid')
const dataurl = extractXmlValue(body, 'dataurl')
const datathumburl = extractXmlValue(body, 'datathumburl') || extractXmlValue(body, 'thumburl')
const datacdnurl = extractXmlValue(body, 'datacdnurl') || extractXmlValue(body, 'cdnurl')
const aeskey = extractXmlValue(body, 'aeskey') || extractXmlValue(body, 'qaeskey')
const md5 = extractXmlValue(body, 'md5') || extractXmlValue(body, 'datamd5')
const imgheight = parseInt(extractXmlValue(body, 'imgheight') || '0')
const imgwidth = parseInt(extractXmlValue(body, 'imgwidth') || '0')
const duration = parseInt(extractXmlValue(body, 'duration') || '0')
items.push({
datatype,
sourcename,
sourcetime,
sourceheadurl,
datadesc: decodeHtmlEntities(datadesc),
datatitle: decodeHtmlEntities(datatitle),
fileext,
datasize,
messageuuid,
dataurl: decodeHtmlEntities(dataurl),
datathumburl: decodeHtmlEntities(datathumburl),
datacdnurl: decodeHtmlEntities(datacdnurl),
aeskey: decodeHtmlEntities(aeskey),
md5,
imgheight,
imgwidth,
duration
})
}
return items.length > 0 ? items : undefined
} catch (e) {
console.error('前端解析聊天记录失败:', e)
return undefined
}
}
// 统一从路由参数或 pathname 中解析 sessionId / messageId
const getIds = () => {
const sessionId = params.sessionId || ''
const messageId = params.messageId || ''
if (sessionId && messageId) {
return { sid: sessionId, mid: messageId }
}
// 独立窗口场景下没有 Route 包裹,用 pathname 手动解析
const match = /^\/chat-history\/([^/]+)\/([^/]+)/.exec(location.pathname)
if (match) {
return { sid: match[1], mid: match[2] }
}
return { sid: '', mid: '' }
}
useEffect(() => {
const loadData = async () => {
const { sid, mid } = getIds()
if (!sid || !mid) {
setError('无效的聊天记录链接')
setLoading(false)
return
}
try {
const result = await window.electronAPI.chat.getMessage(sid, parseInt(mid, 10))
if (result.success && result.message) {
const msg = result.message
// 优先使用后端解析好的列表
let records: ChatRecordItem[] | undefined = msg.chatRecordList
// 如果后端没有解析到,则在前端兜底解析一次
if ((!records || records.length === 0) && msg.content) {
records = parseChatHistory(msg.content) || []
}
if (records && records.length > 0) {
setRecordList(records)
const match = /<title>(.*?)<\/title>/.exec(msg.content || '')
if (match) setTitle(match[1])
} else {
setError('暂时无法解析这条聊天记录')
}
} else {
setError(result.error || '获取消息失败')
}
} catch (e) {
console.error(e)
setError('加载详情失败')
} finally {
setLoading(false)
}
}
loadData()
}, [params.sessionId, params.messageId, location.pathname])
return (
<div className="chat-history-page">
<TitleBar title={title} />
<div className="history-list">
{loading ? (
<div className="status-msg">...</div>
) : error ? (
<div className="status-msg error">{error}</div>
) : recordList.length === 0 ? (
<div className="status-msg empty"></div>
) : (
recordList.map((item, i) => (
<HistoryItem key={i} item={item} />
))
)}
</div>
</div>
)
}
function HistoryItem({ item }: { item: ChatRecordItem }) {
// sourcetime 在合并转发里有两种格式:
// 1) 时间戳(秒) 2) 已格式化的字符串 "2026-01-21 09:56:46"
let time = ''
if (item.sourcetime) {
if (/^\d+$/.test(item.sourcetime)) {
time = new Date(parseInt(item.sourcetime, 10) * 1000).toLocaleString()
} else {
time = item.sourcetime
}
}
const renderContent = () => {
if (item.datatype === 1) {
// 文本消息
return <div className="text-content">{item.datadesc || ''}</div>
}
if (item.datatype === 3) {
// 图片
const src = item.datathumburl || item.datacdnurl
if (src) {
return (
<div className="media-content">
<img
src={src}
alt="图片"
referrerPolicy="no-referrer"
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = 'none'
const placeholder = document.createElement('div')
placeholder.className = 'media-tip'
placeholder.textContent = '图片无法加载'
target.parentElement?.appendChild(placeholder)
}}
/>
</div>
)
}
return <div className="media-placeholder">[]</div>
}
if (item.datatype === 43) {
return <div className="media-placeholder">[] {item.datatitle}</div>
}
if (item.datatype === 34) {
return <div className="media-placeholder">[] {item.duration ? (item.duration / 1000).toFixed(0) + '"' : ''}</div>
}
// Fallback
return <div className="text-content">{item.datadesc || item.datatitle || '[不支持的消息类型]'}</div>
}
return (
<div className="history-item">
<div className="avatar">
{item.sourceheadurl ? (
<img src={item.sourceheadurl} alt="" referrerPolicy="no-referrer" />
) : (
<div className="avatar-placeholder">
{item.sourcename?.slice(0, 1)}
</div>
)}
</div>
<div className="content-wrapper">
<div className="header">
<span className="sender">{item.sourcename || '未知发送者'}</span>
<span className="time">{time}</span>
</div>
<div className={`bubble ${item.datatype === 3 ? 'image-bubble' : ''}`}>
{renderContent()}
</div>
</div>
</div>
)
}

View File

@@ -1,4 +1,4 @@
.chat-page {
.chat-page {
display: flex;
height: 100%;
gap: 16px;
@@ -370,9 +370,23 @@
}
.message-bubble {
max-width: 65%;
display: flex;
gap: 12px;
max-width: 80%;
margin-bottom: 4px;
align-items: flex-start;
.bubble-body {
display: flex;
flex-direction: column;
max-width: 100%;
min-width: 0; // 允许收缩
width: fit-content; // 让气泡宽度由内容决定
}
&.sent {
flex-direction: row-reverse;
.bubble-content {
background: var(--primary-gradient);
color: #fff;
@@ -382,6 +396,10 @@
line-height: 1.5;
box-shadow: 0 2px 10px var(--primary-light);
}
.bubble-body {
align-items: flex-end;
}
}
&.received {
@@ -395,6 +413,10 @@
backdrop-filter: blur(10px);
border: 1px solid var(--border-color);
}
.bubble-body {
align-items: flex-start;
}
}
&.system {
@@ -428,6 +450,11 @@
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 4px;
// 防止名字撑开气泡宽度
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.quoted-message {
@@ -462,8 +489,21 @@
}
.load-more-trigger {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 0;
color: var(--text-tertiary);
font-size: 12px;
font-size: 13px;
&.later {
padding: 24px 0 12px;
}
svg {
animation: spin 1s linear infinite;
}
}
.empty-chat {
@@ -790,6 +830,100 @@
}
// 右侧消息区域
// ... (previous content) ...
// 链接卡片消息样式
.link-message {
width: 280px;
background: var(--card-bg);
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: all 0.2s ease;
border: 1px solid var(--border-color);
&:hover {
background: var(--bg-hover);
border-color: var(--primary);
}
.link-header {
padding: 10px 12px 6px;
display: flex;
gap: 8px;
.link-title {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
flex: 1;
}
}
.link-body {
padding: 6px 12px 10px;
display: flex;
gap: 10px;
.link-desc {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
flex: 1;
}
.link-thumb {
width: 48px;
height: 48px;
border-radius: 4px;
object-fit: cover;
flex-shrink: 0;
background: var(--bg-tertiary);
}
.link-thumb-placeholder {
width: 48px;
height: 48px;
border-radius: 4px;
background: var(--bg-tertiary);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--text-tertiary);
svg {
opacity: 0.5;
}
}
}
}
// 适配发送出去的消息中的链接卡片
.message-bubble.sent .link-message {
background: var(--card-bg);
border: 1px solid var(--border-color);
.link-title {
color: var(--text-primary);
}
.link-desc {
color: var(--text-secondary);
}
}
.message-area {
flex: 1 1 70%;
display: flex;
@@ -943,8 +1077,7 @@
align-items: center;
justify-content: center;
gap: 10px;
background: rgba(10, 10, 10, 0.28);
backdrop-filter: blur(6px);
background: var(--bg-tertiary);
transition: opacity 200ms ease;
z-index: 2;
}
@@ -1485,6 +1618,11 @@
font-size: 12px;
color: var(--text-tertiary);
margin-bottom: 4px;
// 防止名字撑开气泡宽度
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
// 引用消息样式
@@ -1533,7 +1671,11 @@
display: flex;
flex-direction: column;
max-width: 100%;
min-width: 0; // 允许收缩
-webkit-app-region: no-drag;
// 让气泡宽度由内容决定,而不是被父容器撑开
width: fit-content;
}
.bubble-content {
@@ -1874,12 +2016,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 {
@@ -1949,3 +2122,698 @@
height: 14px;
}
}
// 视频消息样式
.video-thumb-wrapper {
position: relative;
max-width: 300px;
min-width: 200px;
border-radius: 12px;
overflow: hidden;
cursor: pointer;
background: var(--bg-tertiary);
transition: transform 0.2s;
&:hover {
transform: scale(1.02);
.video-play-button {
opacity: 1;
transform: translate(-50%, -50%) scale(1.1);
}
}
.video-thumb {
width: 100%;
height: auto;
display: block;
}
.video-thumb-placeholder {
width: 100%;
aspect-ratio: 16/9;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-hover);
color: var(--text-tertiary);
svg {
width: 32px;
height: 32px;
}
}
.video-play-button {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
opacity: 0.9;
transition: all 0.2s;
color: #fff;
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.5));
}
}
.video-placeholder,
.video-loading {
min-width: 120px;
min-height: 80px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 16px;
border-radius: 12px;
background: var(--bg-tertiary);
color: var(--text-tertiary);
font-size: 13px;
svg {
width: 24px;
height: 24px;
}
}
.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;
}
}
// 名片消息
.card-message {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
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);
}
}
// 通话消息
.call-message {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
color: var(--text-secondary);
font-size: 13px;
svg {
flex-shrink: 0;
}
}
// 文件消息
// 文件消息
.file-message {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: var(--bg-tertiary);
border-radius: 8px;
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;
color: var(--text-primary);
margin-bottom: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-meta {
font-size: 12px;
color: var(--text-tertiary);
}
}
// 发送的文件消息样式
.message-bubble.sent .file-message {
background: #fff;
border: 1px solid rgba(0, 0, 0, 0.1);
.file-name {
color: #333;
}
.file-meta {
color: #999;
}
}
// 聊天记录消息 - 复用 link-message 基础样式
.chat-record-message {
cursor: pointer;
.link-header {
padding-bottom: 4px;
}
.chat-record-preview {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
min-width: 0;
}
.chat-record-meta-line {
font-size: 11px;
color: var(--text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chat-record-list {
display: flex;
flex-direction: column;
gap: 2px;
max-height: 70px;
overflow: hidden;
}
.chat-record-item {
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.source-name {
color: var(--text-primary);
font-weight: 500;
margin-right: 4px;
}
.chat-record-more {
font-size: 12px;
color: var(--primary);
}
.chat-record-desc {
font-size: 12px;
color: var(--text-secondary);
}
.chat-record-icon {
width: 40px;
height: 40px;
border-radius: 10px;
background: var(--primary-gradient);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
flex-shrink: 0;
}
}
// 小程序消息
.miniapp-message {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
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;
color: var(--text-primary);
margin-bottom: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.miniapp-label {
font-size: 12px;
color: var(--text-tertiary);
}
}
// 转账消息卡片
.transfer-message {
width: 240px;
background: linear-gradient(135deg, #f59e42 0%, #f5a742 100%);
border-radius: 12px;
padding: 14px 16px;
display: flex;
gap: 12px;
align-items: center;
cursor: default;
&.received {
background: linear-gradient(135deg, #b8b8b8 0%, #a8a8a8 100%);
}
.transfer-icon {
flex-shrink: 0;
svg {
width: 32px;
height: 32px;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.1));
}
}
.transfer-info {
flex: 1;
color: white;
.transfer-amount {
font-size: 18px;
font-weight: 600;
margin-bottom: 2px;
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;
opacity: 0.95;
}
.transfer-label {
font-size: 12px;
opacity: 0.85;
}
}
}
// 发送消息中的特殊消息类型适配(除了文件和转账)
.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,
.chat-record-meta-line,
.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; }
}
}
}
}

File diff suppressed because it is too large Load Diff

551
src/pages/ContactsPage.scss Normal file
View File

@@ -0,0 +1,551 @@
.contacts-page {
display: flex;
height: calc(100% + 48px);
margin: -24px;
background: var(--bg-primary);
overflow: hidden;
// 左侧联系人面板
.contacts-panel {
width: 380px;
min-width: 380px;
display: flex;
flex-direction: column;
border-right: 1px solid var(--border-color);
background: var(--card-bg);
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid var(--border-color);
h2 {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.icon-btn {
width: 32px;
height: 32px;
border: none;
background: var(--bg-tertiary);
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.spin {
animation: contactsSpin 1s linear infinite;
}
}
}
.search-bar {
display: flex;
align-items: center;
gap: 10px;
margin: 16px 20px;
padding: 10px 14px;
background: var(--bg-secondary);
border-radius: 10px;
border: 1px solid var(--border-color);
transition: border-color 0.2s;
&:focus-within {
border-color: var(--primary);
}
svg {
color: var(--text-tertiary);
flex-shrink: 0;
}
input {
flex: 1;
border: none;
background: none;
outline: none;
font-size: 14px;
color: var(--text-primary);
&::placeholder {
color: var(--text-tertiary);
}
}
.clear-btn {
background: none;
border: none;
padding: 4px;
cursor: pointer;
color: var(--text-tertiary);
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
}
}
.type-filters {
display: flex;
gap: 8px;
padding: 0 20px 16px;
flex-wrap: nowrap;
overflow-x: auto;
&::-webkit-scrollbar {
display: none;
}
.filter-chip {
display: 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;
input[type="checkbox"] {
display: none;
}
svg {
opacity: 0.7;
transition: transform 0.2s;
}
&:hover {
background: var(--bg-hover);
border-color: var(--text-tertiary);
color: var(--text-primary);
svg {
transform: translateY(-1px);
}
}
&.active {
background: var(--primary-light);
border-color: var(--primary);
color: var(--primary);
svg {
opacity: 1;
color: var(--primary);
}
}
}
}
.contacts-count {
padding: 0 20px 12px;
font-size: 13px;
color: var(--text-secondary);
}
.loading-state,
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: var(--text-tertiary);
font-size: 14px;
.spin {
animation: contactsSpin 1s linear infinite;
}
}
.contacts-list {
flex: 1;
overflow-y: auto;
padding: 0 12px 12px;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: var(--text-tertiary);
border-radius: 3px;
opacity: 0.3;
}
}
.contact-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-radius: 10px;
transition: all 0.2s;
margin-bottom: 4px;
&:hover {
background: var(--bg-hover);
}
.contact-avatar {
width: 44px;
height: 44px;
border-radius: 10px;
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
span {
color: #fff;
font-size: 16px;
font-weight: 600;
}
}
.contact-info {
flex: 1;
min-width: 0;
}
.contact-name {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.contact-remark {
font-size: 12px;
color: var(--text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 2px;
}
.contact-type {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
flex-shrink: 0;
&.friend {
background: rgba(var(--primary-rgb), 0.1);
color: var(--primary);
}
&.group {
background: rgba(52, 211, 153, 0.1);
color: rgb(52, 211, 153);
}
&.official {
background: rgba(251, 191, 36, 0.1);
color: rgb(251, 191, 36);
}
svg {
flex-shrink: 0;
}
}
}
// 右侧设置面板
.settings-panel {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.settings-content {
flex: 1;
overflow-y: auto;
padding: 20px 24px;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: var(--text-tertiary);
border-radius: 3px;
}
}
.setting-section {
margin-bottom: 28px;
h3 {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin: 0 0 14px;
}
}
.format-select {
position: relative;
/* margin-bottom 移到 .setting-section */
.select-trigger {
width: 100%;
padding: 10px 16px;
border: 1px solid var(--border-color);
border-radius: 9999px;
/* Rounded pill shape */
font-size: 14px;
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: 20;
max-height: 260px;
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: 14px;
&:hover {
background: var(--bg-tertiary);
}
&.active {
background: color-mix(in srgb, var(--primary) 12%, transparent);
color: var(--primary);
.option-desc {
color: var(--primary);
}
}
}
.option-label {
font-weight: 500;
}
.option-desc {
font-size: 12px;
color: var(--text-tertiary);
}
}
.checkbox-item {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
font-size: 14px;
color: var(--text-primary);
input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: var(--primary);
cursor: pointer;
}
}
.export-path-display {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
background: var(--bg-secondary);
border-radius: 10px;
font-size: 13px;
color: var(--text-primary);
margin-bottom: 12px;
svg {
color: var(--primary);
flex-shrink: 0;
}
span {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.select-folder-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 16px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
border-color: var(--primary);
color: var(--primary);
svg {
color: var(--primary);
}
}
&:active {
transform: scale(0.98);
}
svg {
color: var(--text-secondary);
transition: color 0.2s;
}
}
.export-action {
padding: 20px 24px;
border-top: 1px solid var(--border-color);
}
.export-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 14px 24px;
background: var(--primary);
color: #fff;
border: none;
border-radius: 12px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
&:hover:not(:disabled) {
background: var(--primary-hover);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.spin {
animation: contactsSpin 1s linear infinite;
}
}
}
@keyframes contactsSpin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

379
src/pages/ContactsPage.tsx Normal file
View File

@@ -0,0 +1,379 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown } from 'lucide-react'
import './ContactsPage.scss'
interface ContactInfo {
username: string
displayName: string
remark?: string
nickname?: string
avatarUrl?: string
type: 'friend' | 'group' | 'official' | 'other'
}
function ContactsPage() {
const [contacts, setContacts] = useState<ContactInfo[]>([])
const [filteredContacts, setFilteredContacts] = useState<ContactInfo[]>([])
const [isLoading, setIsLoading] = useState(true)
const [searchKeyword, setSearchKeyword] = useState('')
const [contactTypes, setContactTypes] = useState({
friends: true,
groups: true,
officials: true
})
// 导出相关状态
const [exportFormat, setExportFormat] = useState<'json' | 'csv' | 'vcf'>('json')
const [exportAvatars, setExportAvatars] = useState(true)
const [exportFolder, setExportFolder] = useState('')
const [isExporting, setIsExporting] = useState(false)
const [showFormatSelect, setShowFormatSelect] = useState(false)
const formatDropdownRef = useRef<HTMLDivElement>(null)
// 加载通讯录
const loadContacts = useCallback(async () => {
setIsLoading(true)
try {
const result = await window.electronAPI.chat.connect()
if (!result.success) {
console.error('连接失败:', result.error)
setIsLoading(false)
return
}
const contactsResult = await window.electronAPI.chat.getContacts()
if (contactsResult.success && contactsResult.contacts) {
// 获取头像URL
const usernames = contactsResult.contacts.map((c: ContactInfo) => c.username)
if (usernames.length > 0) {
const avatarResult = await window.electronAPI.chat.enrichSessionsContactInfo(usernames)
if (avatarResult.success && avatarResult.contacts) {
contactsResult.contacts.forEach((contact: ContactInfo) => {
const enriched = avatarResult.contacts?.[contact.username]
if (enriched?.avatarUrl) {
contact.avatarUrl = enriched.avatarUrl
}
})
}
}
setContacts(contactsResult.contacts)
setFilteredContacts(contactsResult.contacts)
}
} catch (e) {
console.error('加载通讯录失败:', e)
} finally {
setIsLoading(false)
}
}, [])
useEffect(() => {
loadContacts()
}, [loadContacts])
// 搜索和类型过滤
useEffect(() => {
let filtered = contacts
// 类型过滤
filtered = filtered.filter(c => {
if (c.type === 'friend' && !contactTypes.friends) return false
if (c.type === 'group' && !contactTypes.groups) return false
if (c.type === 'official' && !contactTypes.officials) return false
return true
})
// 关键词过滤
if (searchKeyword.trim()) {
const lower = searchKeyword.toLowerCase()
filtered = filtered.filter(c =>
c.displayName?.toLowerCase().includes(lower) ||
c.remark?.toLowerCase().includes(lower) ||
c.username.toLowerCase().includes(lower)
)
}
setFilteredContacts(filtered)
}, [searchKeyword, contacts, contactTypes])
// 点击外部关闭下拉菜单
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node
if (showFormatSelect && formatDropdownRef.current && !formatDropdownRef.current.contains(target)) {
setShowFormatSelect(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showFormatSelect])
const getAvatarLetter = (name: string) => {
if (!name) return '?'
return [...name][0] || '?'
}
const getContactTypeIcon = (type: string) => {
switch (type) {
case 'friend': return <User size={14} />
case 'group': return <Users size={14} />
case 'official': return <MessageSquare size={14} />
default: return <User size={14} />
}
}
const getContactTypeName = (type: string) => {
switch (type) {
case 'friend': return '好友'
case 'group': return '群聊'
case 'official': return '公众号'
default: return '其他'
}
}
// 选择导出文件夹
const selectExportFolder = async () => {
try {
const result = await window.electronAPI.dialog.openDirectory({
title: '选择导出位置'
})
if (result && !result.canceled && result.filePaths && result.filePaths.length > 0) {
setExportFolder(result.filePaths[0])
}
} catch (e) {
console.error('选择文件夹失败:', e)
}
}
// 开始导出
const startExport = async () => {
if (!exportFolder) {
alert('请先选择导出位置')
return
}
setIsExporting(true)
try {
const exportOptions = {
format: exportFormat,
exportAvatars,
contactTypes: {
friends: contactTypes.friends,
groups: contactTypes.groups,
officials: contactTypes.officials
}
}
const result = await window.electronAPI.export.exportContacts(exportFolder, exportOptions)
if (result.success) {
alert(`导出成功!共导出 ${result.successCount} 个联系人`)
} else {
alert(`导出失败:${result.error}`)
}
} catch (e) {
console.error('导出失败:', e)
alert(`导出失败:${String(e)}`)
} finally {
setIsExporting(false)
}
}
const exportFormatOptions = [
{ value: 'json', label: 'JSON', desc: '详细格式,包含完整联系人信息' },
{ value: 'csv', label: 'CSV (Excel)', desc: '电子表格格式适合Excel查看' },
{ value: 'vcf', label: 'VCF (vCard)', desc: '标准名片格式,支持导入手机' }
]
const getOptionLabel = (value: string) => {
return exportFormatOptions.find(opt => opt.value === value)?.label || value
}
return (
<div className="contacts-page">
{/* 左侧:联系人列表 */}
<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>
<div className="search-bar">
<Search size={16} />
<input
type="text"
placeholder="搜索联系人..."
value={searchKeyword}
onChange={e => setSearchKeyword(e.target.value)}
/>
{searchKeyword && (
<button className="clear-btn" onClick={() => setSearchKeyword('')}>
<X size={14} />
</button>
)}
</div>
<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>
</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>
</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>
</label>
</div>
<div className="contacts-count">
{filteredContacts.length}
</div>
{isLoading ? (
<div className="loading-state">
<Loader2 size={32} className="spin" />
<span>...</span>
</div>
) : filteredContacts.length === 0 ? (
<div className="empty-state">
<span></span>
</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>
)}
</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>
<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>
</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>
</div>
</div>
)
}
export default ContactsPage

View File

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

View File

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

View File

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

@@ -338,64 +338,129 @@
}
}
.time-options {
display: flex;
flex-direction: column;
gap: 12px;
}
.checkbox-item {
.time-range-picker-item {
display: flex;
align-items: center;
gap: 10px;
justify-content: space-between;
padding: 14px 16px;
cursor: pointer;
font-size: 14px;
color: var(--text-primary);
input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: var(--primary);
cursor: pointer;
}
svg {
color: var(--text-secondary);
}
&.main-toggle {
padding: 12px 16px;
background: var(--bg-secondary);
border-radius: 10px;
}
}
.date-range {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
background: var(--bg-secondary);
border-radius: 10px;
font-size: 14px;
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s;
transition: background 0.2s;
background: transparent;
&:hover {
background: var(--bg-hover);
}
.time-picker-info {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
color: var(--text-primary);
svg {
color: var(--text-tertiary);
flex-shrink: 0;
color: var(--primary);
}
}
span {
flex: 1;
svg {
color: var(--text-tertiary);
}
}
.select-field {
position: relative;
}
.select-trigger {
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);
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: 20;
max-height: 260px;
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: 14px;
&: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-tertiary);
}
.select-option.active .option-desc {
color: var(--primary);
}
.media-options {
display: flex;
flex-wrap: wrap;
@@ -602,6 +667,87 @@
}
}
.export-layout-modal {
background: var(--card-bg);
padding: 28px 32px;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
text-align: center;
width: min(520px, 90vw);
h3 {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 8px;
}
.layout-subtitle {
font-size: 14px;
color: var(--text-secondary);
margin: 0 0 20px;
}
.layout-options {
display: grid;
gap: 12px;
}
.layout-option-btn {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px 18px;
border-radius: 12px;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
text-align: left;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: var(--primary);
background: rgba(var(--primary-rgb), 0.08);
}
&.primary {
border-color: var(--primary);
background: rgba(var(--primary-rgb), 0.12);
}
.layout-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.layout-desc {
font-size: 12px;
color: var(--text-tertiary);
}
}
.layout-actions {
margin-top: 18px;
display: flex;
justify-content: center;
}
.layout-cancel-btn {
padding: 8px 20px;
border-radius: 8px;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
}
}
}
.export-result-modal {
background: var(--card-bg);
padding: 32px 40px;
@@ -684,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;
@@ -831,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;
@@ -1010,50 +1155,4 @@
color: var(--text-tertiary);
}
// Switch 开关样式
.switch {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
flex-shrink: 0;
input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--bg-tertiary);
transition: 0.3s;
border-radius: 24px;
&::before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
transition: 0.3s;
border-radius: 50%;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
}
input:checked + .slider {
background-color: var(--primary);
}
input:checked + .slider::before {
transform: translateX(20px);
}
}
// 全局样式已在 main.scss 中定义

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback } 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,15 +13,20 @@ 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
exportMedia: boolean
exportImages: boolean
exportVoices: boolean
exportVideos: boolean
exportEmojis: boolean
exportVoiceAsText: boolean
excelCompactColumns: boolean
txtColumns: string[]
displayNamePreference: 'group-nickname' | 'remark' | 'nickname'
exportConcurrency: number
}
interface ExportResult {
@@ -30,7 +36,11 @@ interface ExportResult {
error?: string
}
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[]>([])
const [selectedSessions, setSelectedSessions] = useState<Set<string>>(new Set())
@@ -38,27 +48,76 @@ 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 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: 'chatlab',
format: 'excel',
dateRange: {
start: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
start: new Date(new Date().setHours(0, 0, 0, 0)),
end: new Date()
},
useAllTime: true,
useAllTime: false,
exportAvatars: true,
exportMedia: false,
exportImages: true,
exportVoices: true,
exportVideos: true,
exportEmojis: true,
exportVoiceAsText: false
exportVoiceAsText: false,
excelCompactColumns: true,
txtColumns: defaultTxtColumns,
displayNamePreference: 'remark',
exportConcurrency: 2
})
const buildDateRangeFromPreset = (preset: string) => {
const now = new Date()
if (preset === 'all') {
return { useAllTime: true, dateRange: { start: now, end: now } }
}
let rangeMs = 0
if (preset === '7d') rangeMs = 7 * 24 * 60 * 60 * 1000
if (preset === '30d') rangeMs = 30 * 24 * 60 * 60 * 1000
if (preset === '90d') rangeMs = 90 * 24 * 60 * 60 * 1000
if (preset === 'today' || rangeMs === 0) {
const start = new Date(now)
start.setHours(0, 0, 0, 0)
return { useAllTime: false, dateRange: { start, end: now } }
}
const start = new Date(now.getTime() - rangeMs)
start.setHours(0, 0, 0, 0)
return { useAllTime: false, dateRange: { start, end: now } }
}
const loadSessions = useCallback(async () => {
setIsLoading(true)
try {
@@ -94,10 +153,118 @@ function ExportPage() {
}
}, [])
const loadExportDefaults = useCallback(async () => {
try {
const [
savedFormat,
savedRange,
savedMedia,
savedVoiceAsText,
savedExcelCompactColumns,
savedTxtColumns,
savedConcurrency
] = await Promise.all([
configService.getExportDefaultFormat(),
configService.getExportDefaultDateRange(),
configService.getExportDefaultMedia(),
configService.getExportDefaultVoiceAsText(),
configService.getExportDefaultExcelCompactColumns(),
configService.getExportDefaultTxtColumns(),
configService.getExportDefaultConcurrency()
])
const preset = savedRange || 'today'
const rangeDefaults = buildDateRangeFromPreset(preset)
const txtColumns = savedTxtColumns && savedTxtColumns.length > 0 ? savedTxtColumns : defaultTxtColumns
setOptions((prev) => ({
...prev,
format: (savedFormat as ExportOptions['format']) || 'excel',
useAllTime: rangeDefaults.useAllTime,
dateRange: rangeDefaults.dateRange,
exportMedia: savedMedia ?? false,
exportVoiceAsText: savedVoiceAsText ?? false,
excelCompactColumns: savedExcelCompactColumns ?? true,
txtColumns,
exportConcurrency: savedConcurrency ?? 2
}))
} catch (e) {
console.error('加载导出默认设置失败:', e)
}
}, [])
useEffect(() => {
loadSessions()
loadExportPath()
}, [loadSessions, loadExportPath])
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())
setSearchKeyword('')
setExportResult(null)
setSessions([])
setFilteredSessions([])
loadSessions()
}
window.addEventListener('wxid-changed', handleChange as EventListener)
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
}, [loadSessions])
useEffect(() => {
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,
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
if (showDisplayNameSelect && displayNameDropdownRef.current && !displayNameDropdownRef.current.contains(target)) {
setShowDisplayNameSelect(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showDisplayNameSelect])
useEffect(() => {
if (!searchKeyword.trim()) {
@@ -138,18 +305,37 @@ function ExportPage() {
return date.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' })
}
const handleFormatChange = (format: ExportOptions['format']) => {
setOptions((prev) => {
const next = { ...prev, format }
if (format === 'html') {
return {
...next,
exportMedia: true,
exportImages: true,
exportVoices: true,
exportVideos: true,
exportEmojis: true
}
}
return next
})
}
const openExportFolder = async () => {
if (exportFolder) {
await window.electronAPI.shell.openPath(exportFolder)
}
}
const startExport = async () => {
const runExport = async (sessionLayout: SessionLayout) => {
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)
@@ -159,16 +345,22 @@ function ExportPage() {
exportMedia: options.exportMedia,
exportImages: options.exportMedia && options.exportImages,
exportVoices: options.exportMedia && options.exportVoices,
exportVideos: options.exportMedia && options.exportVideos,
exportEmojis: options.exportMedia && options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText, // 独立于 exportMedia
exportVoiceAsText: options.exportVoiceAsText, // 即使不导出媒体,也可以导出语音转文字内容
excelCompactColumns: options.excelCompactColumns,
txtColumns: options.txtColumns,
displayNamePreference: options.displayNamePreference,
exportConcurrency: options.exportConcurrency,
sessionLayout,
dateRange: options.useAllTime ? null : options.dateRange ? {
start: Math.floor(options.dateRange.start.getTime() / 1000),
// 将结束日期设置为当天的 23:59:59,以包含当天的所有消息
// 将结束日期设置为当天的 23:59:59,确保包含当天的所有记录
end: Math.floor(new Date(options.dateRange.end.getFullYear(), options.dateRange.end.getMonth(), options.dateRange.end.getDate(), 23, 59, 59).getTime() / 1000)
} : null
}
if (options.format === 'chatlab' || options.format === 'chatlab-jsonl' || options.format === 'json' || options.format === 'excel') {
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,
@@ -176,16 +368,60 @@ function ExportPage() {
)
setExportResult(result)
} else {
setExportResult({ success: false, error: `${options.format.toUpperCase()} 格式导出功能开发中...` })
setExportResult({ success: false, error: `${options.format.toUpperCase()} 格式目前暂未实现,请选择其他格式。` })
}
} catch (e) {
console.error('导出失败:', e)
console.error('导出过程中发生异常:', e)
setExportResult({ success: false, error: String(e) })
} finally {
setIsExporting(false)
}
}
const startExport = async () => {
if (selectedSessions.size === 0 || !exportFolder) return
// 先获取预估统计
setIsLoadingStats(true)
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)
setPreExportStats(stats)
} catch (e) {
console.error('获取导出统计失败:', e)
setPreExportStats(null)
} finally {
setIsLoadingStats(false)
}
}
const confirmExport = () => {
setShowPreExportDialog(false)
setPreExportStats(null)
if (options.exportMedia && selectedSessions.size > 1) {
setShowMediaLayoutPrompt(true)
return
}
const layout: SessionLayout = options.exportMedia ? 'per-session' : 'shared'
runExport(layout)
}
const getDaysInMonth = (date: Date) => {
const year = date.getFullYear()
const month = date.getMonth()
@@ -277,8 +513,28 @@ 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 = [
{
value: 'group-nickname',
label: '群昵称优先',
desc: '仅群聊有效,私聊显示备注/昵称'
},
{
value: 'remark',
label: '备注优先',
desc: '有备注显示备注,否则显示昵称'
},
{
value: 'nickname',
label: '微信昵称',
desc: '始终显示微信昵称'
}
]
const displayNameOption = displayNameOptions.find(option => option.value === options.displayNamePreference)
const displayNameLabel = displayNameOption?.label || '备注优先'
return (
<div className="export-page">
@@ -362,7 +618,7 @@ function ExportPage() {
<div
key={fmt.value}
className={`format-card ${options.format === fmt.value ? 'active' : ''}`}
onClick={() => setOptions({ ...options, format: fmt.value as any })}
onClick={() => handleFormatChange(fmt.value as ExportOptions['format'])}
>
<fmt.icon size={24} />
<span className="format-label">{fmt.label}</span>
@@ -374,28 +630,79 @@ function ExportPage() {
<div className="setting-section">
<h3></h3>
<div className="time-options">
<label className="checkbox-item">
<p className="setting-subtitle"></p>
<div className="media-options-card">
<div className="media-switch-row">
<div className="media-switch-info">
<span className="media-switch-title"></span>
<span className="media-switch-desc"></span>
</div>
<label className="switch">
<input
type="checkbox"
checked={options.useAllTime}
onChange={e => setOptions({ ...options, useAllTime: e.target.checked })}
/>
<span></span>
<span className="switch-slider"></span>
</label>
</div>
{!options.useAllTime && options.dateRange && (
<div className="date-range" onClick={() => setShowDatePicker(true)}>
<>
<div className="media-option-divider"></div>
<div className="time-range-picker-item" onClick={() => setShowDatePicker(true)}>
<div className="time-picker-info">
<Calendar size={16} />
<span>{formatDate(options.dateRange.start)} - {formatDate(options.dateRange.end)}</span>
</div>
<ChevronDown size={14} />
</div>
</>
)}
</div>
</div>
{/* 发送者名称显示偏好 */}
{(options.format === 'html' || options.format === 'json' || options.format === 'txt') && (
<div className="setting-section">
<h3></h3>
<p className="setting-subtitle"></p>
<div className="select-field" ref={displayNameDropdownRef}>
<button
type="button"
className={`select-trigger ${showDisplayNameSelect ? 'open' : ''}`}
onClick={() => setShowDisplayNameSelect(!showDisplayNameSelect)}
>
<span className="select-value">{displayNameLabel}</span>
<ChevronDown size={16} />
</button>
{showDisplayNameSelect && (
<div className="select-dropdown">
{displayNameOptions.map(option => (
<button
key={option.value}
type="button"
className={`select-option ${options.displayNamePreference === option.value ? 'active' : ''}`}
onClick={() => {
setOptions({
...options,
displayNamePreference: option.value as ExportOptions['displayNamePreference']
})
setShowDisplayNameSelect(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>
<p className="setting-subtitle">//</p>
<p className="setting-subtitle">///</p>
<div className="media-options-card">
<div className="media-switch-row">
<div className="media-switch-info">
@@ -408,7 +715,7 @@ function ExportPage() {
checked={options.exportMedia}
onChange={e => setOptions({ ...options, exportMedia: e.target.checked })}
/>
<span className="slider"></span>
<span className="switch-slider"></span>
</label>
</div>
@@ -447,7 +754,7 @@ function ExportPage() {
<label className="media-checkbox-row">
<div className="media-checkbox-info">
<span className="media-checkbox-title"></span>
<span className="media-checkbox-desc"></span>
<span className="media-checkbox-desc"></span>
</div>
<input
type="checkbox"
@@ -458,6 +765,21 @@ function ExportPage() {
<div className="media-option-divider"></div>
<label className={`media-checkbox-row ${!options.exportMedia ? 'disabled' : ''}`}>
<div className="media-checkbox-info">
<span className="media-checkbox-title"></span>
<span className="media-checkbox-desc"></span>
</div>
<input
type="checkbox"
checked={options.exportVideos}
disabled={!options.exportMedia}
onChange={e => setOptions({ ...options, exportVideos: e.target.checked })}
/>
</label>
<div className="media-option-divider"></div>
<label className={`media-checkbox-row ${!options.exportMedia ? 'disabled' : ''}`}>
<div className="media-checkbox-info">
<span className="media-checkbox-title"></span>
@@ -488,7 +810,7 @@ function ExportPage() {
checked={options.exportAvatars}
onChange={e => setOptions({ ...options, exportAvatars: e.target.checked })}
/>
<span className="slider"></span>
<span className="switch-slider"></span>
</label>
</div>
</div>
@@ -544,6 +866,108 @@ function ExportPage() {
</div>
</div>
{/* 媒体导出布局选择弹窗 */}
{showMediaLayoutPrompt && (
<div className="export-overlay" onClick={() => setShowMediaLayoutPrompt(false)}>
<div className="export-layout-modal" onClick={e => e.stopPropagation()}>
<h3></h3>
<p className="layout-subtitle"></p>
<div className="layout-options">
<button
className="layout-option-btn primary"
onClick={() => {
setShowMediaLayoutPrompt(false)
runExport('shared')
}}
>
<span className="layout-title"></span>
<span className="layout-desc"> media </span>
</button>
<button
className="layout-option-btn"
onClick={() => {
setShowMediaLayoutPrompt(false)
runExport('per-session')
}}
>
<span className="layout-title"></span>
<span className="layout-desc"></span>
</button>
</div>
<div className="layout-actions">
<button className="layout-cancel-btn" onClick={() => setShowMediaLayoutPrompt(false)}>
</button>
</div>
</div>
</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={() => { setShowPreExportDialog(false); setPreExportStats(null) }}>
</button>
<button className="layout-option-btn primary" onClick={confirmExport} disabled={isLoadingStats}>
<span className="layout-title"></span>
</button>
</div>
</div>
</div>
)}
{/* 导出进度弹窗 */}
{isExporting && (
<div className="export-overlay">
@@ -553,13 +977,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>
)}
@@ -642,7 +1084,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'
@@ -656,7 +1098,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'
@@ -694,9 +1136,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

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

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useRef } from 'react'
import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, User, Medal, Search, X, ChevronLeft, Copy, Check } from 'lucide-react'
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { useLocation } from 'react-router-dom'
import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, User, Medal, Search, X, ChevronLeft, Copy, Check, Download } from 'lucide-react'
import { Avatar } from '../components/Avatar'
import ReactECharts from 'echarts-for-react'
import DateRangePicker from '../components/DateRangePicker'
@@ -16,6 +17,10 @@ interface GroupMember {
username: string
displayName: string
avatarUrl?: string
nickname?: string
alias?: string
remark?: string
groupNickname?: string
}
interface GroupMessageRank {
@@ -26,6 +31,7 @@ interface GroupMessageRank {
type AnalysisFunction = 'members' | 'ranking' | 'activeHours' | 'mediaStats'
function GroupAnalyticsPage() {
const location = useLocation()
const [groups, setGroups] = useState<GroupChatInfo[]>([])
const [filteredGroups, setFilteredGroups] = useState<GroupChatInfo[]>([])
const [isLoading, setIsLoading] = useState(true)
@@ -39,6 +45,7 @@ function GroupAnalyticsPage() {
const [activeHours, setActiveHours] = useState<Record<number, number>>({})
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 [selectedMember, setSelectedMember] = useState<GroupMember | null>(null)
@@ -53,11 +60,28 @@ 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])
useEffect(() => {
loadGroups()
}, [])
useEffect(() => {
preselectAppliedRef.current = false
}, [location.key, preselectGroupIds])
useEffect(() => {
if (searchQuery) {
setFilteredGroups(groups.filter(g => g.displayName.toLowerCase().includes(searchQuery.toLowerCase())))
@@ -66,6 +90,20 @@ function GroupAnalyticsPage() {
}
}, [searchQuery, groups])
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) => {
@@ -93,7 +131,7 @@ function GroupAnalyticsPage() {
}
}, [dateRangeReady])
const loadGroups = async () => {
const loadGroups = useCallback(async () => {
setIsLoading(true)
try {
const result = await window.electronAPI.groupAnalytics.getGroupChats()
@@ -106,7 +144,23 @@ function GroupAnalyticsPage() {
} finally {
setIsLoading(false)
}
}, [])
useEffect(() => {
const handleChange = () => {
setGroups([])
setFilteredGroups([])
setSelectedGroup(null)
setSelectedFunction(null)
setMembers([])
setRankings([])
setActiveHours({})
setMediaStats(null)
void loadGroups()
}
window.addEventListener('wxid-changed', handleChange as EventListener)
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
}, [loadGroups])
const handleGroupSelect = (group: GroupChatInfo) => {
if (selectedGroup?.username !== group.username) {
@@ -165,6 +219,10 @@ function GroupAnalyticsPage() {
return num.toLocaleString()
}
const sanitizeFileName = (name: string) => {
return name.replace(/[<>:"/\\|?*]+/g, '_').trim()
}
const getHourlyOption = () => {
const hours = Array.from({ length: 24 }, (_, i) => i)
const data = hours.map(h => activeHours[h] || 0)
@@ -236,6 +294,35 @@ function GroupAnalyticsPage() {
setCopiedField(null)
}
const handleExportMembers = async () => {
if (!selectedGroup || isExportingMembers) return
setIsExportingMembers(true)
try {
const downloadsPath = await window.electronAPI.app.getDownloadsPath()
const baseName = sanitizeFileName(`${selectedGroup.displayName || selectedGroup.username}_群成员列表`)
const separator = downloadsPath && downloadsPath.includes('\\') ? '\\' : '/'
const defaultPath = downloadsPath ? `${downloadsPath}${separator}${baseName}.xlsx` : `${baseName}.xlsx`
const saveResult = await window.electronAPI.dialog.saveFile({
title: '导出群成员列表',
defaultPath,
filters: [{ name: 'Excel', extensions: ['xlsx'] }]
})
if (!saveResult || saveResult.canceled || !saveResult.filePath) return
const result = await window.electronAPI.groupAnalytics.exportGroupMembers(selectedGroup.username, saveResult.filePath)
if (result.success) {
alert(`导出成功,共 ${result.count ?? members.length}`)
} else {
alert(`导出失败:${result.error || '未知错误'}`)
}
} catch (e) {
console.error('导出群成员失败:', e)
alert(`导出失败:${String(e)}`)
} finally {
setIsExportingMembers(false)
}
}
const handleCopy = async (text: string, field: string) => {
try {
await navigator.clipboard.writeText(text)
@@ -248,6 +335,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)}>
@@ -270,11 +361,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} />}
<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>
@@ -407,6 +527,12 @@ function GroupAnalyticsPage() {
onRangeComplete={handleDateRangeComplete}
/>
)}
{selectedFunction === 'members' && (
<button className="export-btn" onClick={handleExportMembers} disabled={functionLoading || isExportingMembers}>
{isExportingMembers ? <Loader2 size={16} className="spin" /> : <Download size={16} />}
<span></span>
</button>
)}
<button className="refresh-btn" onClick={handleRefresh} disabled={functionLoading}>
<RefreshCw size={16} className={functionLoading ? 'spin' : ''} />
</button>

View File

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

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

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More