Compare commits

...

385 Commits

Author SHA1 Message Date
H3CoF6
4a27653039 Merge pull request #498 from H3CoF6/feat/linux
fix: 删除pacman打包
2026-03-20 01:01:10 +08:00
H3CoF6
d5b1f5fb1c fix: 删除pacman打包 2026-03-20 00:56:41 +08:00
cc
8dfd39810d Merge pull request #497 from hicccc77/dev
Dev
2026-03-20 00:43:06 +08:00
cc
b2ee143e1c Merge branch 'main' into dev 2026-03-20 00:42:56 +08:00
cc
94b0a9f89b 更新 2026-03-20 00:35:52 +08:00
hicccc77
a0a50ff7d1 fix: add author email for electron-builder Linux packaging 2026-03-20 00:35:12 +08:00
hicccc77
7ccdae23fa fix: resolve TypeScript errors in ChatPage (result.messages narrowing, currentSession non-null) 2026-03-20 00:25:29 +08:00
cc
0bf57502e6 更新依赖锁 2026-03-20 00:24:37 +08:00
cc
2888c369d7 Merge pull request #496 from hicccc77/dev
Dev
2026-03-20 00:23:58 +08:00
cc
bedb872034 修复类型报错 2026-03-20 00:23:18 +08:00
hicccc77
cd42e76659 fix: use npm install instead of npm ci to handle platform optional deps 2026-03-20 00:16:20 +08:00
hicccc77
1b49aa2d39 fix: update package-lock.json to sync sudo-prompt@9.2.1 2026-03-20 00:11:48 +08:00
cc
4423c895c7 Merge pull request #495 from hicccc77/dev
Dev
2026-03-20 00:03:20 +08:00
cc
f9c574ddd9 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-03-20 00:02:54 +08:00
cc
60dc911228 支持聊天记录转发解析与嵌套聊天记录解析;优化聊天记录转发窗口样式 2026-03-20 00:02:49 +08:00
hicccc77
ed25a0e395 chore: update wcdb_api binaries (all platforms) to latest build 2026-03-19 23:27:46 +08:00
cc
7590623d26 添加Linux打包支持 2026-03-19 23:10:29 +08:00
cc
043e518cce 验证优化并同步资源文件 2026-03-19 22:58:03 +08:00
cc
de7f7bc8de 计划优化 P5/5 2026-03-19 22:52:51 +08:00
cc
b8079f11d0 计划优化 P4/5 2026-03-19 22:30:45 +08:00
cc
7c5b3f2241 计划优化 P3/5 2026-03-19 21:52:50 +08:00
cc
48e5ce807d 计划优化 P2/5 2026-03-19 21:24:31 +08:00
cc
35e9ea13de 计划优化 P1/5 2026-03-19 20:58:21 +08:00
xuncha
958677c5b1 Merge pull request #492 from pengstem/wcdb-contact-api-fallback
fix: fallback when compact WCDB contact APIs are missing
2026-03-19 16:43:00 +08:00
xuncha
21a9904b81 Merge pull request #491 from pengstem/linux-key-service-bundle
fix: bundle Linux key service in electron main
2026-03-19 16:35:03 +08:00
xuncha
bc979767d6 Merge branch 'dev' into linux-key-service-bundle 2026-03-19 16:34:36 +08:00
xuncha
e933209ea7 Merge pull request #490 from H3CoF6/feat/linux
Fix: 修复linux的一点bug
2026-03-19 16:31:29 +08:00
H3CoF6
ae6cd88d9e fix: 修复sudo-prompt的问题 2026-03-19 03:04:28 +08:00
H3CoF6
7ffc0c3484 fix: 修复linux打包图片路径 2026-03-19 02:44:08 +08:00
Nastem
0f450154cf fix: fallback when compact WCDB contact APIs are missing 2026-03-19 02:41:39 +08:00
Nastem
e32b4c7406 fix: bundle linux key service in electron main 2026-03-19 02:41:27 +08:00
H3CoF6
d45179a4b0 fix linux: 放弃打包为AppImge格式 2026-03-19 01:56:47 +08:00
H3CoF6
0816fafc02 fix(linux): 修复linux中,require的路径错误 2026-03-19 00:26:03 +08:00
cc
db4cf015c2 Merge pull request #489 from H3CoF6/feat/linux
fix linux:  Linux版本跑通
2026-03-18 23:50:37 +08:00
cc
48c4197b16 重构与优化,旨在解决遗留的性能问题并优化用户体验,本次提交遗留了较多的待测功能 2026-03-18 23:49:50 +08:00
H3CoF6
a0fb109839 chore: 更新让linux跑通的so 2026-03-18 23:41:33 +08:00
hicccc77
4c32bf5934 chore: update libwcdb_api.so for Linux (static OpenSSL 3.x, hide symbols, fix BoringSSL conflict) 2026-03-18 18:56:30 +08:00
hicccc77
19beb846bf chore: update libwcdb_api.so for Linux (dynamic link OpenSSL 3.x) 2026-03-18 18:18:52 +08:00
xuncha
661b6e46cc Merge pull request #485 from H3CoF6/feat/linux
fix: 修复linux版本一些bug
2026-03-18 12:00:18 +08:00
H3CoF6
19d7330d3a Merge remote-tracking branch 'upstream/dev' into feat/linux 2026-03-18 06:54:29 +08:00
hicccc77
75f70c2ae0 chore: update libwcdb_api.so for Linux (fix EVP_CIPHER_nid, statically link OpenSSL) 2026-03-18 06:48:01 +08:00
H3CoF6
fb00b12d13 fix: 添加package.json里面linux的打包 2026-03-18 04:32:19 +08:00
H3CoF6
0f8f202fbb fix: 修复linux图标问题 2026-03-18 04:31:45 +08:00
H3CoF6
f4ad6bf263 fix: 修复linux中so库加载的问题 2026-03-18 03:54:38 +08:00
xuncha
be7d173746 Merge pull request #484 from xunchahaha/dev
Dev
2026-03-17 23:29:56 +08:00
xuncha
e0b2f152b0 新增了一个消息推送 2026-03-17 23:29:21 +08:00
xuncha
d0457a2782 导出图片解密优化 2026-03-17 23:12:12 +08:00
xuncha
ee684021db 图片解密优化 2026-03-17 23:11:37 +08:00
hicccc77
61eef27740 feat: 添加 Linux 平台支持,加载 libwcdb_api.so(含 sqlcipher 静态链接) 2026-03-17 21:38:19 +08:00
xuncha
774ac7f2fa Merge pull request #475 from 2977094657/dev
fix: 修复会话内搜索多次跳转不加载附近消息并增加点击防抖
2026-03-17 11:08:30 +08:00
xuncha
6dcc597b0c Merge pull request #474 from H3CoF6/feat/linux
feat: linux设置密钥获取
2026-03-17 11:08:17 +08:00
2977094657
5bd332369f Merge branch 'hicccc77:dev' into dev 2026-03-17 10:53:29 +08:00
2977094657
f2c0799854 fix: debounce in-session search jump loading 2026-03-17 10:51:56 +08:00
H3CoF6
dea77cc268 Merge remote-tracking branch 'upstream/dev' into feat/linux 2026-03-17 05:26:23 +08:00
H3CoF6
1f5b1e2bb9 fix: 修复内存扫描图片密钥 2026-03-17 05:23:33 +08:00
H3CoF6
da68b0fdae fix: 获取密钥成功后微信继续运行 2026-03-17 04:31:13 +08:00
H3CoF6
1680acb22c fix:修复一些bug 2026-03-17 04:05:50 +08:00
H3CoF6
56a8859eaf feat: 初步实现linux上的密钥获取 2026-03-17 03:42:29 +08:00
cc
fee8c3f0ee Merge pull request #472 from hicccc77/dev
更新issue模板描述
2026-03-16 21:51:14 +08:00
cc
faa22966e4 更新issue模板描述 2026-03-16 21:50:46 +08:00
cc
3c72f3b1c5 Merge pull request #470 from hicccc77/dev
issue模板更新
2026-03-16 21:42:05 +08:00
cc
7497b48531 issue模板更新 2026-03-16 21:40:53 +08:00
cc
70fddac2d5 Merge pull request #469 from hicccc77/dev
细化issue模板
2026-03-16 21:36:45 +08:00
cc
8f65124830 细化issue模板 2026-03-16 21:35:58 +08:00
cc
bb9b7bcf9f Merge pull request #468 from hicccc77/dev
Dev
2026-03-16 21:30:00 +08:00
cc
4bd2c90554 issue模板 2026-03-16 21:28:54 +08:00
cc
bd6b23f413 Revert "Update issue templates"
This reverts commit 85b5943b9e.
2026-03-16 21:20:28 +08:00
cc
85b5943b9e Update issue templates 2026-03-16 21:14:11 +08:00
xuncha
0f5ed083df Merge pull request #465 from 2977094657/dev
修复会话搜索里自己为未知成员
2026-03-16 20:17:52 +08:00
xuncha
486ca220a2 Merge pull request #464 from xunchahaha/dev
增加一个导出的缓存
2026-03-16 20:16:32 +08:00
xuncha
a19bf5fac2 增加一个导出的缓存 2026-03-16 20:15:17 +08:00
2977094657
5cf8ce4385 fix: resolve self sender info in group search 2026-03-16 20:12:10 +08:00
xuncha
8026d19d8f Merge pull request #463 from xunchahaha/dev
修复api导出即使选择了优先不生效的问题 新增了可以查看群内成员wxid等信息的接口 https://github.com/hicccc77/WeFlow/issues/461
2026-03-16 18:36:15 +08:00
xuncha
d64abe4ee3 修复api导出即使选择了优先不生效的问题 新增了可以查看群内成员wxid等信息的接口 https://github.com/hicccc77/WeFlow/issues/461 2026-03-16 18:35:19 +08:00
xuncha
89acfafbd2 Merge pull request #462 from xunchahaha:dev
Dev
2026-03-16 18:24:27 +08:00
xuncha
072c49a037 改名字 2026-03-16 18:23:02 +08:00
xuncha
7fad75fad0 群成员消息导出放在消息查看里面 2026-03-16 18:19:49 +08:00
xuncha
79e40f6a53 新增查看单个群成员消息 2026-03-16 17:51:13 +08:00
xuncha
f2b1b07f58 新增询问窗口 2026-03-16 17:21:59 +08:00
xuncha
999ddaeb9a 修复只有一个滑动条的问题 2026-03-16 17:18:49 +08:00
xuncha
d730ae5bef 修复群聊分析白屏 2026-03-16 17:12:12 +08:00
xuncha
bf48e865ac 新增最小化窗口 https://github.com/hicccc77/WeFlow/issues/359 2026-03-16 16:48:01 +08:00
xuncha
7e05909404 Merge pull request #459 from 2977094657/dev
fix(chat): 修复消息搜索结果的发送者信息和头像加载
2026-03-16 10:34:04 +08:00
xuncha
7a1c944fe6 Merge pull request #456 from H3CoF6/feat/global_config
feat: 解析global_config, 优化账号选择体验,顺便修个搜索的bug
2026-03-16 10:33:58 +08:00
2977094657
66a2b3224f fix(chat): repair search result sender info 2026-03-16 10:17:40 +08:00
H3CoF6
7bcdecaceb fix: 搜索时不污染原对象 2026-03-16 08:35:24 +08:00
H3CoF6
6beefb9fc0 fix: 修复 sidebar 的显示问题 2026-03-16 07:40:16 +08:00
H3CoF6
579b63b036 feat: 解析mmkv数据,优化账号选择体验 2026-03-16 07:30:08 +08:00
cc
1f676254a9 一个略有问题的修复 2026-03-15 23:32:41 +08:00
cc
eac81ac82b Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-03-15 23:20:04 +08:00
cc
8c1b043769 相对稳定的版本 2026-03-15 23:19:45 +08:00
hicccc77
eb870d94c2 chore: 更新 wcdb_api 二进制,回退至 7ad2786 重新构建 2026-03-15 23:17:05 +08:00
hicccc77
c18b62ffb9 chore: 回退 wcdb_api 二进制到 7ad2786(FTS 搜索稳定版) 2026-03-15 23:10:10 +08:00
hicccc77
02f724bfc3 chore: 更新 wcdb_api 二进制,还原为纯 FTS 搜索(无 contact 查询) 2026-03-15 23:09:19 +08:00
hicccc77
e12ea371c0 chore: 更新 wcdb_api 二进制,修复搜索性能问题 2026-03-15 23:01:51 +08:00
hicccc77
9a1726c249 chore: 更新 wcdb_api 二进制,修复 sender 查询性能问题 2026-03-15 22:56:54 +08:00
hicccc77
50f2eaee3b chore: 更新 wcdb_api 二进制,搜索结果附带 sender 信息 2026-03-15 22:49:56 +08:00
hicccc77
6b1229fcf2 chore: 更新 wcdb_api 二进制,使用 FTS 索引搜索 2026-03-15 20:41:05 +08:00
cc
ef97202867 一些更新 2026-03-15 20:28:46 +08:00
hicccc77
5494490ff8 chore: 更新 wcdb_api 二进制,搜索性能优化版本 2026-03-15 19:55:31 +08:00
hicccc77
bd4c4878f1 fix: 修复搜索无法取消/后台持续占用问题
- 全局搜索和会话内搜索均加 generation 计数,新搜索触发时丢弃旧结果
- 防抖从 400ms 统一改为 500ms
- 关闭搜索时立即取消 pending 的 timer 和 generation
2026-03-15 19:53:11 +08:00
hicccc77
6a7851a1cc chore: 更新 wcdb_api 二进制,修复搜索无结果问题 2026-03-15 19:42:49 +08:00
hicccc77
0eac4e2a44 fix: 优化消息搜索体验
- 去掉切换按钮,搜索框直接同时搜索会话名和消息内容
- 消息搜索加 400ms 防抖,输入停止后再请求
- 全局消息结果显示会话 displayName,点击跳转并清空搜索框
- 修复跨会话搜索 meta 为 null 导致无结果的问题(C++ 层)
2026-03-15 19:40:47 +08:00
hicccc77
053e2cdc64 feat: 新增聊天消息搜索功能
- 会话内搜索:header 加搜索按钮,展开搜索栏,结果列表显示在消息区上方,点击跳转到对应时间
- 全局消息搜索:会话列表搜索框新增消息模式切换按钮,搜索结果展示在会话列表下方,点击跳转到对应会话
- preload 暴露 chat.searchMessages IPC
2026-03-15 19:35:41 +08:00
cc
7024b86d00 修复路径错误 2026-03-15 19:30:55 +08:00
cc
ae75820b77 Merge pull request #455 from 2977094657/dev
fix(chat): 修复聊天页历史记录提前断档
2026-03-15 19:25:05 +08:00
hicccc77
a800c71cba chore: 更新 wcdb_api 二进制,支持 searchMessages 接口 2026-03-15 19:17:20 +08:00
2977094657
55cce56230 Merge remote-tracking branch 'upstream/dev' into dev 2026-03-15 19:16:02 +08:00
cc
128f1ca043 Merge pull request #454 from hicccc77/main
DEV
2026-03-15 19:09:57 +08:00
hicccc77
2f25fd1239 feat: 新增聊天消息关键词搜索功能
- wcdbCore: 绑定 wcdb_search_messages DLL 函数,添加 searchMessages 方法
- wcdbWorker: 添加 searchMessages case
- wcdbService: 添加 searchMessages 代理方法
- chatService: 添加 searchMessages,结果解析为 Message 对象
- main: 注册 chat:searchMessages IPC handler
2026-03-15 19:08:52 +08:00
2977094657
c0ad450960 fix(chat): stabilize history pagination and message keys 2026-03-15 19:08:13 +08:00
xuncha
0845ee6775 Merge pull request #452 from hicccc77/revert-442-issue-399-383-sns-self-filter-export
Revert "fix: 修复朋友圈仅看自己和导出自己"
2026-03-15 19:00:14 +08:00
xuncha
ffcdb10802 Revert "fix: 修复朋友圈仅看自己和导出自己" 2026-03-15 19:00:04 +08:00
xuncha
fe5b63eed8 Merge pull request #451 from hicccc77/revert-445-fix/issue-392-export-appmsg-link
Revert "fix(export): 修复导出后链接不可点击"
2026-03-15 18:59:49 +08:00
xuncha
f3ca6c3fa7 Revert "fix(export): 修复导出后链接不可点击" 2026-03-15 18:59:38 +08:00
xuncha
904bc45652 Merge pull request #450 from hicccc77/revert-444-fix/issue-400-exited-group-filter-toggle
Revert "fix(chat): 增加已退出群聊隐藏开关"
2026-03-15 18:58:52 +08:00
xuncha
845d6b2e2c Revert "fix(chat): 增加已退出群聊隐藏开关" 2026-03-15 18:58:41 +08:00
xuncha
5deacf45cb Merge pull request #447 from xunchahaha/dev
Dev
2026-03-15 18:42:07 +08:00
xuncha
e9bc303e0e Merge pull request #444 from 2977094657/fix/issue-400-exited-group-filter-toggle
fix(chat): 增加已退出群聊隐藏开关
2026-03-15 18:41:42 +08:00
xuncha
caaf1e8d0d 修复导入到电脑上的图片无法解密的问题 2026-03-15 18:40:40 +08:00
xuncha
b96e757379 Merge pull request #445 from 2977094657/fix/issue-392-export-appmsg-link
fix(export): 修复导出后链接不可点击
2026-03-15 18:13:20 +08:00
2977094657
53a52d8561 fix(export): keep appmsg type-4 links clickable 2026-03-15 17:42:45 +08:00
2977094657
32424e46b8 fix(chat): replace exited-group filter icon 2026-03-15 17:14:41 +08:00
2977094657
1e3829899a fix(chat): add exited group toggle filter 2026-03-15 17:12:25 +08:00
xuncha
b6df41e05b Merge pull request #442 from 2977094657/issue-399-383-sns-self-filter-export
fix: 修复朋友圈仅看自己和导出自己
2026-03-15 15:05:34 +08:00
2977094657
f4fd5bb797 fix: support self sns filter and export 2026-03-15 14:45:17 +08:00
cc
ecc538a932 Merge pull request #441 from pisauvage/codex/pr-mac-image-key-account-dir-fix
fix(mac): 修复非 wxid 账号目录下的图片密钥获取失败问题
2026-03-15 14:36:32 +08:00
pisauvage
6741a94c1b fix(mac): support non-wxid account dirs for image keys 2026-03-15 15:29:54 +09:00
hicccc77
7be2c69256 fix: 修复 getAvatarUrls 竞态导致 handle 为 null 的崩溃
在 await setImmediate 让出控制权前先捕获 handle,
await 后重新校验 handle 是否仍有效,避免连接关闭后
向 koffi DLL 传入 null 导致 TypeError。
2026-03-15 14:15:51 +08:00
cc
2b97b6ac9d 更新mac sip状态检测 2026-03-15 12:17:13 +08:00
cc
512b47a386 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-03-15 11:42:44 +08:00
cc
d6b95036b5 一个简单的安卓岛 2026-03-15 11:42:41 +08:00
cc
e4c188da75 Merge pull request #438 from hicccc77/main
Dev
2026-03-15 11:07:39 +08:00
cc
edfe28b9ef Merge pull request #437 from hicccc77/dev
Dev
2026-03-15 11:06:58 +08:00
cc
c111ed4f91 Merge pull request #436 from BeiChen-CN/main
fix: 补齐群聊 HTTP API 导出的头像信息
2026-03-15 11:06:20 +08:00
姜北尘
318c296ee9 fix: 补齐群聊 HTTP API 导出的头像信息
为 ChatLab 格式的群聊 HTTP API 导出补齐成员头像与群头像,
并兼容 wxid 清洗后的账号匹配,避免导出结果只有昵称没有头像。

Fixes #371
2026-03-15 01:30:14 +08:00
hicccc77
998b2ce3d7 fix: 修复 Windows 下 tray 图标路径错误,与其他窗口 icon 路径逻辑保持一致 2026-03-14 23:00:08 +08:00
hicccc77
ba5f8928f7 feat: 添加系统托盘图标,关闭主窗口时隐藏到托盘而非退出;修复进程无法完全关闭问题(before-quit 加兜底强制退出 + wcdbService.shutdown 改为 async) 2026-03-14 22:51:31 +08:00
cc
641abc57b9 修复 #389 ;并优化了引导页面 2026-03-14 22:23:10 +08:00
hicccc77
0a23ed6ef4 fix: 修复 elevated helper 输出解析,支持同行多 JSON 拼接的情况 2026-03-14 21:09:53 +08:00
hicccc77
8e69e1ec58 fix: 修复了一些安全问题 2026-03-14 20:57:22 +08:00
hicccc77
d50bffad3e fix: elevated helper 输出解析改为找最后一个合法 JSON 行,修复 stderr 混入导致的解析失败 2026-03-14 20:45:33 +08:00
hicccc77
db71bc3f19 fix: 修复了一些安全问题 2026-03-14 20:40:45 +08:00
hicccc77
f2a9d7097f fix: 修复了一些安全问题 2026-03-14 20:30:00 +08:00
hicccc77
a4b0a25dab fix: elevated helper stderr 重定向到 stdout,修复日志丢失问题 2026-03-14 20:29:27 +08:00
hicccc77
3af530a15e fix: 移除重复的 icon.icns extraResources 条目,修复打包 EEXIST 错误 2026-03-14 19:57:27 +08:00
cc
11c7277878 Merge pull request #435 from hicccc77/dev
Dev
2026-03-14 19:52:17 +08:00
hicccc77
6eae60ba54 fix: 修复了一些安全问题 2026-03-14 19:46:42 +08:00
hicccc77
2d711cca80 fix: 修复了一些安全问题 2026-03-14 19:34:50 +08:00
hicccc77
b274c99b91 fix: osascript 使用完整路径,修复打包后 ENOENT 问题 2026-03-14 19:28:03 +08:00
hicccc77
4e66074603 fix: 增强 macOS 打包后 WeChat PID 获取的兼容性
- 新增 pgrep -f 作为第二 fallback,匹配完整路径
- ps 解析时同时检查 comm 列(打包后 command 列可能被截断)
- 过滤时排除 comm 为 WeChat Helper 的辅助进程
2026-03-14 19:22:42 +08:00
hicccc77
42fbc479c9 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-03-14 18:58:55 +08:00
hicccc77
f47610b98a fix: 修复 macOS 打包后的图标、spawn ENOENT 和 dylib 路径问题
- 新增 resources/icon.icns(由 public/logo.png 转换)
- package.json: 配置 build.icon 和 build.mac.icon 使用 .icns
- main.ts: BrowserWindow 图标改为平台感知,darwin 用 .icns,其他用 .ico
- keyServiceMac.ts: pgrep/ps/pkill 改用绝对路径,修复打包后 PATH 受限导致的 spawn ENOENT
2026-03-14 18:45:12 +08:00
xuncha
cda45ce64c Merge pull request #432 from xunchahaha:dev
Dev
2026-03-14 15:59:31 +08:00
xuncha
009a0d64b8 更新打包 2026-03-14 15:58:50 +08:00
xuncha
3afb0da017 Merge pull request #431 from xunchahaha:dev
Dev
2026-03-14 15:46:38 +08:00
xuncha
bdc7f8a8a8 更新打包 2026-03-14 15:40:44 +08:00
xuncha
69a72f24ed Merge branch 'hicccc77:dev' into dev 2026-03-14 14:50:17 +08:00
cc
ee0e71d50e 更新窗口优化 2026-03-14 14:30:56 +08:00
cc
39ba175651 Merge pull request #430 from Wythehard/codex/cleanup-monitor-debug-logs
chore: remove temporary monitor debug logs and add log clear action
2026-03-14 14:17:25 +08:00
superclaw
731f022669 chore: remove monitor debug logs and add log clear action 2026-03-14 14:16:03 +08:00
hicccc77
8d5527990b chore: 更新 wcdb_api 资源文件 2026-03-14 14:02:06 +08:00
hicccc77
1ff536c2f7 chore: 更新 wcdb_api 资源文件 2026-03-14 13:51:32 +08:00
cc
27a18f1fc6 优化图片窗口的渲染;修复实时管道的问题;优化了图片密钥相关配置流程 2026-03-14 13:34:41 +08:00
hicccc77
8921b90392 chore: 更新 wcdb_api 资源文件 2026-03-14 13:33:07 +08:00
hicccc77
6cd925b062 chore(resources): 更新 macOS Xkey 二进制文件
同步 Xkey 最新版本,新增根据微信版本号动态选择扫描策略:
- 4.1.8 及以上版本使用纯语义定位模式
- 旧版本继续使用传统偏移量模式
2026-03-13 23:11:07 +08:00
cc
28a344c63c 一些小问题的修复 2026-03-13 23:00:17 +08:00
cc
a9b5fa0fae Merge pull request #425 from xioFelix/fix/image-scan-helper-entitlement
fix: 图片密钥内存扫描通过 image_scan_helper 子进程解决 task_for_pid 权限问题
2026-03-13 22:50:58 +08:00
xuncha
65212201ad https://github.com/hicccc77/WeFlow/issues/372导出时给每个格式都接入了群成员显示逻辑 2026-03-13 21:14:46 +08:00
xuncha
d8c3ba34a8 每次点击图片的时候 都解密一遍 2026-03-13 21:14:46 +08:00
xuncha
63be8a35ad 修复消息窗口拖动的问题 2026-03-13 21:14:46 +08:00
xuncha
53ef4e11f9 图片解密逻辑优化https://github.com/hicccc77/WeFlow/issues/408#issuecomment-4053026902 2026-03-13 21:14:46 +08:00
xuncha
c9a6451407 尝试修复 https://github.com/hicccc77/WeFlow/issues/378 中的问题 2026-03-13 21:14:46 +08:00
xuncha
9d07a3a7bd 给别的格式同步 2026-03-13 21:14:46 +08:00
xuncha
bd4296199a 图片解密失败的时候 可以导出缩略图 html 2026-03-13 21:14:46 +08:00
xuncha
b9e0535f63 https://github.com/hicccc77/WeFlow/issues/372导出时给每个格式都接入了群成员显示逻辑 2026-03-13 21:13:45 +08:00
xuncha
6e371d75c8 每次点击图片的时候 都解密一遍 2026-03-13 20:50:08 +08:00
xuncha
7697f382ef 修复消息窗口拖动的问题 2026-03-13 20:49:53 +08:00
xuncha
4c551a8c91 图片解密逻辑优化https://github.com/hicccc77/WeFlow/issues/408#issuecomment-4053026902 2026-03-13 20:40:16 +08:00
cc
56227c69f7 一些小更新 2026-03-13 20:34:40 +08:00
Felix
5acd3d86c8 fix: image_scan_helper 自动检测 task_for_pid 权限不足时通过 osascript 提权运行
macOS SIP 下 ad-hoc 签名的 debugger entitlement 不被信任,导致
image_scan_helper 调用 task_for_pid 返回 kr=5。现在先尝试直接运行,
检测到权限错误后自动切换到 osascript with administrator privileges
方式运行 helper,后续调用跳过直接运行直接走提权路径。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 23:22:27 +11:00
xuncha
d7f7139f36 尝试修复 https://github.com/hicccc77/WeFlow/issues/378 中的问题 2026-03-13 20:21:56 +08:00
xuncha
1c5cacf1ce 给别的格式同步 2026-03-13 20:08:42 +08:00
Felix
0a603116ef Merge branch 'dev' into fix/image-scan-helper-entitlement
解决冲突:在 dev 最新 Mach API 内存扫描方案基础上,保留 image_scan_helper
子进程作为优先路径(有 debugger entitlement),Mach API 作为 fallback。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 22:42:59 +11:00
xuncha
809b28a994 Merge branch 'dev' of https://github.com/xunchahaha/WeFlow into dev 2026-03-13 19:38:20 +08:00
xuncha
f7610a3570 图片解密失败的时候 可以导出缩略图 html 2026-03-13 19:38:17 +08:00
Felix
bff9e87096 fix: 图片密钥内存扫描通过子进程调用解决 task_for_pid 权限问题
Electron 进程缺少 com.apple.security.cs.debugger entitlement,
导致 ScanMemoryForImageKey 中的 task_for_pid 调用失败(kr=5)。

新增 image_scan_helper 子进程包装程序(与 xkey_helper 方案一致):
- 新建 resources/image_scan_helper.c:dlopen libwx_key.dylib 并调用
  ScanMemoryForImageKey,通过 JSON stdout 返回结果
- 新建 resources/image_scan_entitlements.plist:包含 debugger 和
  allow-unsigned-executable-memory entitlements
- 编译为 universal binary(x86_64 + arm64)并 ad-hoc 签名
- 修改 keyServiceMac.ts _scanMemoryForAesKey():优先 spawn
  image_scan_helper 子进程,失败时 fallback 到直接调 dylib

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 15:18:52 +11:00
cc
d872a8af20 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-03-12 23:52:49 +08:00
cc
4966cdbfac 页面交互与动画优化 2026-03-12 23:52:46 +08:00
cc
cb3eb83eac Merge pull request #422 from Wythehard/codex/dev-pr-20260312
feat: sync local changes and add unsigned mac arm64 dmg action
2026-03-12 23:05:59 +08:00
superclaw
5daa7bce73 feat: update app logic and add unsigned mac arm64 dmg action 2026-03-12 22:56:09 +08:00
hicccc77
4e80f93b30 fix: enable file monitor for macOS (remove platform restriction) 2026-03-12 22:48:16 +08:00
hicccc77
2776a1a5ce chore: update wcdb_api with macOS file monitoring support (kqueue-based) 2026-03-12 22:43:32 +08:00
hicccc77
4f402d6a6a chore: update wcdb_api with simplified security check 2026-03-12 21:47:48 +08:00
hicccc77
d544da6e4d chore: update libwcdb_api.dylib with macOS cloud reporting support 2026-03-12 21:30:58 +08:00
hicccc77
0e42c19d3b feat(mac): implement WeChat PID detection and pass to helper 2026-03-12 20:46:10 +08:00
hicccc77
0a2bd3d46a chore: update xkey_helper and libwx_key to support pid parameter 2026-03-12 20:37:11 +08:00
cc
86d2dade11 Merge pull request #420 from hicccc77/main
同步更新
2026-03-12 19:50:17 +08:00
cc
19ab4409a3 Merge pull request #419 from hicccc77/test/macos-compat
适配Mac端功能的合并
2026-03-12 19:48:59 +08:00
cc
3af90bd6e9 Merge pull request #415 from SuperJuice666/dev
Fix: 修复批量解密海量图片时导致的 V8 内存溢出 (OOM) 问题
2026-03-12 19:48:06 +08:00
cc
cfb0cff1a3 Merge branch 'dev' into test/macos-compat
PR
2026-03-12 19:46:50 +08:00
cc
c08d6cd668 Merge PR #417 into test/macos-compat and resolve binary conflict 2026-03-11 23:25:27 +08:00
hicccc77
a53bebefd7 fix: 修复联系人索引问题
- 更新 wcdb_api 库,修复 contact.db 路径解析误匹配 contact_fts.db 的问题
- 更新 wx_key 库到最新版本
2026-03-11 23:13:10 +08:00
superclaw
8e0c3306e8 feat: macOS 接入 xkey_helper 并完善密钥获取与诊断 2026-03-11 23:06:20 +08:00
Jiang Huiyuan
f4364b3bd3 Refactor concurrency management in decryptOne function
Refactor concurrency handling in decryption process to use a Set for tracking promises instead of an array. This improves performance by simplifying the removal of completed promises.
2026-03-11 19:28:48 +08:00
hicccc77
5b5757a1d7 debug: 添加进程过滤日志 2026-03-11 00:11:36 +08:00
hicccc77
f165f4911b fix: 精确过滤微信进程 2026-03-10 23:56:23 +08:00
hicccc77
b81b538d9a fix: 使用 processor_set_tasks 无需权限 2026-03-10 23:48:21 +08:00
hicccc77
2f32c8e092 fix: koffi 自动转换字符串,无需 decode 2026-03-10 23:44:06 +08:00
hicccc77
d101a79bf8 debug: 添加详细日志输出 2026-03-10 23:36:32 +08:00
hicccc77
caea10a190 feat: 添加详细错误信息
- 区分进程未找到、附加失败、扫描失败
- 提示权限解决方案
2026-03-10 23:30:11 +08:00
hicccc77
1445202a0d fix: 更新 dylib 添加调试权限 2026-03-10 23:25:15 +08:00
hicccc77
6f62ac4ffb fix: 修正 koffi decode 调用方式
- 使用 'string' 而非 'char' 类型
- 移除调试代码
2026-03-10 23:18:11 +08:00
hicccc77
e87bbe7223 fix: 更新 dylib 修复模块查找问题 2026-03-10 23:16:22 +08:00
hicccc77
e7e2c40c68 fix: 更新 dylib 使用完整进程路径查找
- 查找包含 '微信' 或 'WeChat.app' 的进程
- 排除 WeChatAppEx
- 复刻 Python frida 脚本逻辑
2026-03-10 23:10:07 +08:00
hicccc77
78b6d445fa feat: 添加进程调试信息
- 显示找到的 WeChat 进程列表
- 更新 dylib 支持多进程名
- 帮助诊断附加失败问题
2026-03-10 23:01:58 +08:00
hicccc77
c212355860 fix: 更新 dylib 导出符号版本 2026-03-10 22:54:08 +08:00
hicccc77
c223c20b38 fix: 更新 dylib 静态链接版本
- 185KB (包含静态链接的 Dobby)
- 解决运行时 libdobby.dylib 依赖问题
2026-03-10 22:50:41 +08:00
hicccc77
524a9cda35 feat: 集成 KeyServiceMac 到 main.ts
- 根据平台自动选择服务
- macOS 使用 KeyServiceMac
- Windows 使用 KeyService
2026-03-10 22:44:26 +08:00
hicccc77
8bee66d404 feat: macOS 密钥获取完整实现
- KeyServiceMac 实现内存扫描
- 复刻 Windows 的图片密钥扫描逻辑
- 使用 Xkey dylib 的 ScanMemoryForImageKey
- 更新 libwx_key.dylib (102KB)
2026-03-10 22:43:47 +08:00
cc
142b00499b Merge pull request #406 from hicccc77/dev
Dev
2026-03-10 22:21:10 +08:00
xuncha
b0ea6c0ea2 Merge pull request #404 from aits2026/codex/ts0306-01-export-opt
feat: 优化聊天分析、设置弹窗与导出会话交互
2026-03-10 17:40:35 +08:00
aits2026
67fd53a503 fix(export): unlock session header left columns 2026-03-10 16:17:22 +08:00
aits2026
29529271fb fix(export): clean session row hover state 2026-03-10 15:58:28 +08:00
aits2026
4489a0f702 fix(export): align session sticky columns 2026-03-10 15:31:15 +08:00
aits2026
0d9fcc731a fix(export): restore sticky session rows 2026-03-10 14:58:52 +08:00
aits2026
fe1c8862e6 feat(export): pin session table edge columns 2026-03-10 14:55:34 +08:00
aits2026
092450e4f8 chore(export): shorten session export label 2026-03-10 14:48:34 +08:00
aits2026
da054de708 fix(export): clean session row action background 2026-03-10 14:47:14 +08:00
aits2026
dfac3c57cc fix(export): remove virtuoso horizontal scrollbar 2026-03-10 14:43:14 +08:00
aits2026
0f3ecdc4ee fix(export): hide duplicate session table scrollbar 2026-03-10 14:39:23 +08:00
aits2026
24c47c3aa3 fix(export): refine session export table layout 2026-03-10 14:33:51 +08:00
aits2026
f53de9fe0b fix(export): tighten session export tab width 2026-03-10 14:27:24 +08:00
aits2026
ee4d1f5689 style: tighten export tab content layout 2026-03-10 14:18:07 +08:00
aits2026
122ad73c2e style: remove outer export table frame 2026-03-10 14:15:40 +08:00
aits2026
6ad1e6c3f3 style: tighten export tab spacing 2026-03-10 14:13:51 +08:00
aits2026
c899fa72b8 fix: keep export tabs on one line 2026-03-10 14:11:05 +08:00
aits2026
e209bd68d4 fix: export link metadata for arkme json 2026-03-10 14:06:03 +08:00
aits2026
96ac655d92 fix: preserve text export format selection 2026-03-10 13:55:26 +08:00
aits2026
1d97b19774 style: clean up whisper model layout 2026-03-10 13:49:31 +08:00
aits2026
11c7de3568 fix: unify collapsed account menu width 2026-03-10 13:43:24 +08:00
aits2026
38d899fa94 style: tighten settings and account menu 2026-03-10 13:38:29 +08:00
aits2026
37796c98c9 feat: show settings as modal dialog 2026-03-10 13:32:19 +08:00
aits2026
5b2e48badd refactor: streamline sidebar account menu 2026-03-10 13:24:47 +08:00
aits2026
627aa35f88 refactor: move sidebar toggle to title bar 2026-03-10 12:38:23 +08:00
aits2026
74e974177c refactor: tighten private analytics header 2026-03-10 12:24:42 +08:00
aits2026
6911132c95 fix: align group analytics header layout 2026-03-10 12:17:21 +08:00
aits2026
f1affc7d63 refactor: simplify chat analytics header 2026-03-10 12:12:50 +08:00
aits2026
bea824aee9 feat: unify chat analytics navigation 2026-03-10 12:04:56 +08:00
aits2026
cbdd5b3a24 fix: refresh sns feed with latest selected contacts 2026-03-10 11:48:25 +08:00
aits2026
c02bc753fd feat: filter SNS feed by selected contacts 2026-03-10 11:38:38 +08:00
aits2026
d4915e1a62 feat: support batch-select SNS contacts 2026-03-10 11:01:54 +08:00
aits2026
2d4a5fc62f Revert "feat: enrich mutual friend identities in export dialog"
This reverts commit f3027da43885a67583099008991dbfc4def3f4d1.
2026-03-10 09:37:31 +08:00
aits2026
94a010c9b2 feat: enrich mutual friend identities in export dialog 2026-03-10 09:37:31 +08:00
aits2026
a6a202f6ff fix(export): remove incorrect row action offset 2026-03-10 09:37:31 +08:00
aits2026
2127fdd443 style(export): tighten sticky action cell right padding 2026-03-10 09:37:31 +08:00
aits2026
3b3fd8b35c fix(export): keep session row actions sticky in viewport 2026-03-10 09:37:31 +08:00
aits2026
95d0937015 fix(export): align session metric columns with header 2026-03-10 09:37:31 +08:00
aits2026
b070b4f659 fix(export): support dragging session table header horizontally 2026-03-10 09:37:31 +08:00
aits2026
a8c05fd26c fix(export): add top horizontal scrollbar for session table 2026-03-10 09:37:31 +08:00
aits2026
ecd64f62bc feat(export): show and stop background page tasks 2026-03-10 09:37:31 +08:00
aits2026
5affd4e57b fix(export): restore session table body rendering 2026-03-10 09:37:31 +08:00
aits2026
76d69ab7dd fix(export): improve dark-mode layout dropdown contrast 2026-03-10 09:37:31 +08:00
aits2026
1d1b38210a fix(export): add horizontal scrollbar for narrow session table 2026-03-10 09:37:31 +08:00
hicccc77
836032d93e fix: 修复聊天记录页面崩溃问题并添加错误边界保护 2026-03-08 18:51:20 +08:00
hicccc77
dc3e285917 fix: 更新 macOS 库文件 2026-03-08 14:07:54 +08:00
hicccc77
e54eb8fea2 fix: 修复年度报告页面导致主题异常的问题 2026-03-08 14:00:38 +08:00
hicccc77
177dbaa5ff fix: 更新 macOS 库以支持进程名校验 2026-03-08 13:46:22 +08:00
hicccc77
1d08ab945d feat: dbPathService 支持 macOS 微信数据库路径 2026-03-08 13:01:49 +08:00
hicccc77
10ce7d772c feat: cloudControlService 支持 macOS 平台版本识别 2026-03-08 12:25:06 +08:00
hicccc77
e1a23ac606 feat: wcdbCore 支持 macOS 平台 2026-03-08 12:21:16 +08:00
hicccc77
439259ec57 feat: 添加 macOS 跨平台支持的 wcdb_api 库 2026-03-08 11:43:26 +08:00
cc
a0dda0b866 Merge pull request #384 from hicccc77/dev
修复了一些问题
2026-03-08 00:21:07 +08:00
cc
6913defc12 修复了一些问题 2026-03-08 00:19:46 +08:00
hicccc77
f3e2fdd4fc 配置:忽略 pnpm-lock.yaml 2026-03-08 00:02:49 +08:00
hicccc77
5c44b35045 配置:忽略 pnpm-lock.yaml 2026-03-08 00:02:36 +08:00
hicccc77
cebb6426f8 发布:合并 dev 分支到 main 2026-03-07 23:58:43 +08:00
hicccc77
f05e50e63e 更新:wcdb_api.dll 至最新版本 2026-03-07 23:55:15 +08:00
hicccc77
f8ef3f18ff 修复:WCDB 初始化失败时显示错误码
- 将 DLL 返回的错误码保存到 lastDllInitError
- 用户可以看到具体错误码,方便反馈问题
- 配合 DLL 更新,支持更详细的错误码
- 修复 #353
2026-03-07 17:49:24 +08:00
hicccc77
47dbc540ac 优化:扫内存获取密钥时如果找不到则自动扫描更多文件
- 首次扫描 32 个模板文件
- 如果找不到有效的 XOR 密钥,自动扩展到 100 个文件
- 提高密钥获取成功率
2026-03-07 15:15:43 +08:00
hicccc77
766d5ed2af 修复:扫内存获取图片密钥时 XOR 密钥为空默认为 0 的问题
- 当无法从模板文件计算出有效的 XOR 密钥时,返回错误而不是默认为 0x00
- 提前检查 XOR 密钥是否为 null,避免后续使用错误的密钥
- 修复 #355
2026-03-07 15:11:57 +08:00
hicccc77
783b408611 修复:在 getImageData 中使用 imageDecryptService 以支持 msg/attach 目录
- 将 ImageDecryptService 集成到 ChatService
- 用 imageDecryptService.decryptImage() 替换 findDatFile
- 支持通过 hardlink.db 查询高清图片路径
- 修复 #363
2026-03-07 15:00:59 +08:00
hicccc77
24c91269a0 refactor: remove unused better-sqlite3 dependency
- Remove better-sqlite3 from package.json dependencies
- Remove @types/better-sqlite3 from devDependencies
- Remove unused hardlink.db query logic in videoService (always uses wcdbService/DLL)
- Remove unused hardlink.db query logic in chatService (fallback to filesystem search)
- Remove HardlinkState type and hardlinkCache
- Simplify code by removing dead optimization paths
2026-03-07 14:26:26 +08:00
xuncha
e786026049 Merge pull request #381 from hicccc77/dev
Dev
2026-03-06 17:22:25 +08:00
xuncha
566b0cf6e5 Merge pull request #373 from aits2026/codex/ts0305-01-export-module-upgrade
导出模块继续优化+朋友圈体验增强+显性化应用锁入口+其他一些小优化
2026-03-06 17:21:59 +08:00
aits2026
b17844e837 style(export): tighten path label spacing 2026-03-06 17:18:06 +08:00
aits2026
5c93c4db57 style(export): align top settings button height 2026-03-06 17:15:27 +08:00
aits2026
57e8a96a4a feat: animate load detail entry icon 2026-03-06 17:09:23 +08:00
aits2026
438581834e refactor: simplify mutual friends list items 2026-03-06 17:05:30 +08:00
aits2026
58cfd49859 feat: clarify mutual friends direction labels 2026-03-06 16:58:44 +08:00
aits2026
4a1933e924 fix: include reverse mutual friends 2026-03-06 16:52:02 +08:00
aits2026
6ded8c5ab5 feat: add export mutual friends stats 2026-03-06 16:39:40 +08:00
aits2026
edf38aad48 refactor(sns): unify export date range dialog 2026-03-06 16:16:34 +08:00
aits2026
f4caa51da5 refactor(sns): open contact timeline from sidebar 2026-03-06 16:07:48 +08:00
aits2026
9575ba2a9f feat(sns): show selected jump date in header 2026-03-06 15:51:03 +08:00
aits2026
af2fe91f81 fix(sns): restore jump calendar positioning 2026-03-06 15:47:48 +08:00
aits2026
c641c86598 style(sns): reorder summary and my timeline 2026-03-06 15:46:30 +08:00
aits2026
0599de372a refactor(sns): move jump calendar to header 2026-03-06 15:44:24 +08:00
aits2026
1c89ee2797 style(sns): move friends count to contact header 2026-03-06 15:42:10 +08:00
aits2026
5fd846bfc8 feat(sns): show daily counts in jump calendar 2026-03-06 15:39:13 +08:00
aits2026
02aefcf155 fix(sns): stabilize jump date popover month 2026-03-06 15:34:58 +08:00
aits2026
e92983dd80 style(sns): compact jump date filter row 2026-03-06 15:32:13 +08:00
aits2026
03f65317a9 refactor(sns): use shared jump date popover 2026-03-06 15:30:10 +08:00
aits2026
21cb09fbde style(sns): tighten feed header spacing 2026-03-06 15:26:03 +08:00
aits2026
6c1e7f6f12 fix(sns): expand feed viewport height 2026-03-06 15:24:53 +08:00
aits2026
344dd3343b feat(export): separate voice transcription toggle 2026-03-06 14:58:28 +08:00
aits2026
cacb9e449c style(export): make media defaults responsive 2026-03-06 14:53:51 +08:00
aits2026
18313141f4 style(export): reorder defaults fields 2026-03-06 14:52:28 +08:00
aits2026
ecd73ae0d6 style(export): compact media default labels 2026-03-06 14:50:35 +08:00
aits2026
7ad754df03 style(export): flatten media default options 2026-03-06 14:48:32 +08:00
aits2026
cfc601e19a fix(export): align modal header gutters 2026-03-06 14:46:32 +08:00
aits2026
9984f9c206 style(export): stack media defaults section 2026-03-06 14:45:33 +08:00
aits2026
39e59a4077 feat(export): split default media options 2026-03-06 14:41:02 +08:00
aits2026
d735ed19cb style(export): soften non-text batch buttons 2026-03-06 14:34:25 +08:00
aits2026
f4037a1ccf fix(export): size format trigger to content 2026-03-06 14:31:00 +08:00
aits2026
3e917e2062 fix(export): right align format trigger label 2026-03-06 14:29:04 +08:00
aits2026
919357a374 fix(export): stretch format trigger 2026-03-06 14:26:43 +08:00
aits2026
5b6be864fd fix(export): clarify text batch dialog copy 2026-03-06 14:23:54 +08:00
aits2026
98a3b06e56 fix(export): align collapsed format selector 2026-03-06 14:19:27 +08:00
aits2026
6253def76c feat(export): refine format selector layouts 2026-03-06 14:15:18 +08:00
aits2026
450e5f7e61 feat(export): centralize avatar export default 2026-03-06 14:11:02 +08:00
aits2026
d2ec9c680d feat(export): simplify concurrency selector 2026-03-06 13:59:17 +08:00
aits2026
56d7ad6999 feat(export): expand defaults format options 2026-03-06 13:57:22 +08:00
aits2026
97024395c1 fix(export): reorder defaults settings 2026-03-06 13:53:12 +08:00
aits2026
10342be2be fix(export): refine defaults modal interactions 2026-03-06 13:48:10 +08:00
aits2026
51a3ee4a9b feat(export): split defaults modal layout 2026-03-06 13:42:56 +08:00
aits2026
8779bbc532 refactor(settings): remove export tab 2026-03-06 13:39:52 +08:00
aits2026
90b33ef444 feat(export): add more defaults modal 2026-03-06 13:36:25 +08:00
aits2026
3fa0b36426 fix(export): pin task center card right 2026-03-06 13:11:29 +08:00
aits2026
60a64cd777 fix(export): balance top card spacing 2026-03-06 13:09:26 +08:00
aits2026
c543fabdf4 fix(export): tighten top control bar 2026-03-06 13:06:32 +08:00
aits2026
64b96f00f7 fix(export): align task center card height 2026-03-06 13:02:27 +08:00
aits2026
86b372de68 feat(export): compact task center entry 2026-03-06 12:58:22 +08:00
aits2026
c108070696 feat(sidebar): surface unlock entry 2026-03-06 12:55:08 +08:00
aits2026
80a193a394 fix(export): align selection column baseline 2026-03-06 12:35:02 +08:00
aits2026
b9c16dbee4 fix(export): align header actions layout 2026-03-06 12:29:46 +08:00
aits2026
6e870ef300 feat(settings): unify export date range defaults 2026-03-06 12:29:32 +08:00
aits2026
cf45ae30ac fix(export): hide scope card for single dialog 2026-03-06 12:12:12 +08:00
aits2026
38a0453cbb fix(export): restore loading states for session metrics 2026-03-06 12:01:21 +08:00
aits2026
92d37abbc5 fix(export): reduce sticky action width 2026-03-06 11:48:10 +08:00
aits2026
39662038f7 fix(export): tighten action column layout 2026-03-06 11:45:38 +08:00
aits2026
75b58d0423 fix(export): tighten sticky action divider 2026-03-06 11:37:04 +08:00
aits2026
1814808df1 fix(export): keep actions sticky and refine headers 2026-03-06 11:32:38 +08:00
aits2026
fe57d80a00 fix(export): center single export action text 2026-03-06 11:27:43 +08:00
aits2026
8cb855328d refactor(export): reuse shared sns timeline dialog 2026-03-06 11:12:23 +08:00
aits2026
a62ba8e167 fix(sns): sync my timeline count and auto load more 2026-03-06 11:03:11 +08:00
aits2026
4f40b4af49 feat(sns): add my timeline shortcut 2026-03-06 10:55:23 +08:00
aits2026
8d9a042489 feat(chat): add sns timeline entry for private sessions 2026-03-06 10:41:06 +08:00
aits2026
ef05466d6d refactor(sns): reuse shared contact timeline dialog in sns page 2026-03-06 10:34:16 +08:00
aits2026
0a5cf005a1 Merge remote-tracking branch 'upstream/dev' into codex/ts0305-01-export-module-upgrade 2026-03-06 10:22:36 +08:00
aits2026
f6c365bdf1 Merge remote-tracking branch 'origin/codex/ts0305-01-export-module-upgrade' into codex/ts0305-01-export-module-upgrade 2026-03-06 10:22:28 +08:00
aits2026
bc2ab60c59 feat(sns): add contact timeline dialog components 2026-03-06 10:22:24 +08:00
aits2026
ad217d4a3b feat(export): compute sns rankings from full contact timeline 2026-03-06 10:05:46 +08:00
aits2026
54684ea3c9 Merge branch 'codex/ts0305-01-export-module-upgrade' into HEAD
# Conflicts:
#	electron/main.ts
2026-03-05 20:45:54 +08:00
aits2026
3de4951c96 fix(export): show top 15 entries in sns rank strip 2026-03-05 20:25:53 +08:00
aits2026
05c551d7ac fix(export): hide recent-export row when no history 2026-03-05 20:24:55 +08:00
aits2026
7cea8b4fb3 fix(export): tune sns rank strip size and theme compatibility 2026-03-05 20:10:47 +08:00
aits2026
ba2cdbf8cf feat(export): add sns like/comment ranking strip in detail header 2026-03-05 20:02:14 +08:00
aits2026
3e004867be fix(export): show sns counts per-session as soon as loaded 2026-03-05 19:55:17 +08:00
aits2026
edaef53712 feat(export): add sns detail sync tip between header and list 2026-03-05 19:52:23 +08:00
aits2026
933842f6af refactor(export): remove official sessions from conversation export flow 2026-03-05 19:47:06 +08:00
aits2026
2eff82891e feat(export): add clickable sns count column in session list 2026-03-05 19:43:11 +08:00
aits2026
c625756ab4 fix(export,sns): preserve sns load state across route switches 2026-03-05 19:35:42 +08:00
aits2026
2140a220e2 fix(electron): ensure app quits after main window close in dev 2026-03-05 19:28:28 +08:00
aits2026
7ead55d801 fix(export,sns): share sns user count cache across pages 2026-03-05 19:21:37 +08:00
aits2026
4e0038c813 feat(export): include sns count loading progress in load detail 2026-03-05 19:07:13 +08:00
aits2026
d07e4c8ecd chore(sns): remove my-posts segment from overview stats line 2026-03-05 19:02:38 +08:00
aits2026
63fd42ff05 feat(sns): incremental contact post-count ranking in filter list 2026-03-05 18:50:46 +08:00
aits2026
d5dbcd3f80 fix(export): align sns timeline dialog with sns page rendering 2026-03-05 18:27:30 +08:00
aits2026
c301f36912 feat(export): add sns count and timeline popup in session detail 2026-03-05 18:08:09 +08:00
aits2026
9dd5ee2365 fix(export): align media load progress with visible loaded state 2026-03-05 17:44:32 +08:00
aits2026
3388b7a122 fix(sns): derive per-user totals from timeline counts map 2026-03-05 17:44:24 +08:00
aits2026
38af8de469 fix(sns): fallback to userName when user_name count is zero 2026-03-05 17:33:41 +08:00
aits2026
db0ebc6c33 feat(sns): show loaded vs total posts in author timeline 2026-03-05 17:24:28 +08:00
aits2026
7cc2961538 fix(export): hide finish time until grouped load completes 2026-03-05 17:24:14 +08:00
aits2026
835ec4782c feat(export): show spinner in load detail in-progress status 2026-03-05 17:15:33 +08:00
aits2026
e6942bc201 feat(export): add session load detail modal with typed progress 2026-03-05 17:11:04 +08:00
aits2026
ebabe1560f feat(sns): support opening author timeline from post 2026-03-05 16:34:29 +08:00
aits2026
4da697f507 feat(export): show loading icon for media metric columns 2026-03-05 16:34:16 +08:00
aits2026
f18fb83a92 feat(chat): smooth standalone session window loading 2026-03-05 16:32:25 +08:00
aits2026
e050402787 feat(export): add 4 media columns with visible-first staged loading 2026-03-05 16:28:18 +08:00
aits2026
b3dd0e25fa feat(export): move open-chat action below message count 2026-03-05 16:18:00 +08:00
aits2026
a5358b82f6 perf(export): further optimize detail loading and prioritize session stats 2026-03-05 16:05:58 +08:00
aits2026
2a9f0f24fd perf(export): speed up session detail stats loading 2026-03-05 15:39:59 +08:00
xuncha
5945942acd 修复dev关闭还有进程 2026-03-05 15:01:03 +08:00
124 changed files with 30018 additions and 7140 deletions

114
.github/ISSUE_TEMPLATE/bug.yml vendored Normal file
View File

@@ -0,0 +1,114 @@
name: "报告 Bug"
description: "代码出现了非预期的问题、崩溃或报错"
title: "[Bug]: "
labels: ["type: bug", "status: needs info"]
body:
- type: markdown
attributes:
value: |
请提供尽可能详细的信息,帮助我们快速定位和修复问题。
- type: checkboxes
id: pre-check
attributes:
label: 提交前确认
description: 请务必确认以下事项
options:
- label: 我已搜索过现有的 Issues确认这不是重复问题
required: true
- label: 我使用的是最新版本
required: true
- label: 我已阅读过相关文档
required: true
- type: dropdown
id: platform
attributes:
label: 使用平台
description: 选择出现问题的平台
options:
- Windows
- macOS
- Linux
validations:
required: true
- type: dropdown
id: severity
attributes:
label: 问题严重程度
description: 这个问题对你的使用造成了多大影响?
options:
- 严重崩溃或数据丢失(无法使用)
- 核心功能受影响(在下一个常规发布中必须修复)
- 边缘场景或轻微问题(等待空闲时修复)
validations:
required: true
- type: textarea
id: description
attributes:
label: 问题描述
description: 清晰描述你遇到的问题,包括实际发生了什么
placeholder: 例如:当我点击发送按钮时,应用程序崩溃并显示白屏
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: 复现步骤
description: 提供详细的操作步骤,让我们能够重现这个问题
placeholder: |
1. 打开应用并登录账号
2. 进入聊天页面
3. 点击发送按钮
4. 观察到应用崩溃
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: 预期行为
description: 描述你期望的正确行为应该是什么样的
placeholder: 例如:点击发送按钮后,消息应该正常发送并显示在聊天窗口中
validations:
required: true
- type: textarea
id: actual-behavior
attributes:
label: 实际行为
description: 描述实际发生的错误行为
placeholder: 例如:点击后应用直接崩溃,显示白屏
validations:
required: true
- type: textarea
id: logs
attributes:
label: 错误日志或截图
description: 粘贴控制台错误信息、崩溃日志,或拖入截图
placeholder: 请粘贴完整的错误堆栈信息
render: shell
- type: input
id: os
attributes:
label: 操作系统版本
description: 例如Windows 11 24H2、macOS 15.0、Ubuntu 24.04
placeholder: Windows 11 24H2
validations:
required: true
- type: input
id: app-version
attributes:
label: 应用版本
description: 在关于页面或设置中查看版本号
placeholder: v1.2.3
validations:
required: true
- type: input
id: architecture
attributes:
label: 系统架构
description: 例如x64、arm64
placeholder: x64
- type: textarea
id: additional-context
attributes:
label: 补充信息
description: 其他可能有助于定位问题的信息
placeholder: 例如:这个问题是在某次更新后开始出现的,或者只在特定网络环境下出现

5
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name:🤔 找不到合适的模板?
url: https://t.me/weflow_cc
about: 如果你的问题不属于上述任何分类,请前往我们的 Telegram 频道与我们交流。

67
.github/ISSUE_TEMPLATE/docs.yml vendored Normal file
View File

@@ -0,0 +1,67 @@
name: "文档反馈"
description: "文档存在错别字、描述不清晰或缺少必要的示例"
title: "[Docs]: "
labels: ["type: docs"]
body:
- type: markdown
attributes:
value: |
优秀的文档和代码一样重要。感谢你帮助我们完善文档!
- type: dropdown
id: doc-type
attributes:
label: 文档类型
description: 问题出现在哪类文档中?
options:
- README 或项目说明
- 安装部署文档
- 使用教程
- API 文档
- 开发者文档
- 其他
validations:
required: true
- type: input
id: doc-link
attributes:
label: 文档位置
description: 提供文档的 URL 或文件路径
placeholder: 例如docs/installation.md 或 https://github.com/xxx/xxx/wiki/xxx
validations:
required: true
- type: dropdown
id: issue-type
attributes:
label: 问题类型
description: 文档存在什么问题?
options:
- 错别字或语法错误
- 内容过时或不准确
- 描述不清晰或有歧义
- 缺少必要的示例代码
- 缺少重要的说明或警告
- 链接失效或错误
- 其他
validations:
required: true
- type: textarea
id: issue-desc
attributes:
label: 问题描述
description: 详细说明文档中存在的问题
placeholder: 例如:第 3 步中的命令拼写错误,应该是 "npm install" 而不是 "npm instal"
validations:
required: true
- type: textarea
id: suggestion
attributes:
label: 修改建议
description: 你认为应该如何修改?
placeholder: 例如:建议将"安装依赖"部分补充完整的命令示例,并说明不同操作系统的差异
validations:
required: true
- type: textarea
id: additional
attributes:
label: 补充说明
description: 其他需要补充的信息

78
.github/ISSUE_TEMPLATE/enhancement.yml vendored Normal file
View File

@@ -0,0 +1,78 @@
name: "功能与体验优化"
description: "对现有的功能逻辑进行优化,或改进用户体验"
title: "[Enhancement]: "
labels: ["type: enhancement"]
body:
- type: markdown
attributes:
value: |
持续优化是项目进步的动力!请告诉我们哪个现有功能可以做得更好。
- type: checkboxes
id: pre-check
attributes:
label: 提交前确认
options:
- label: 我已搜索过现有的 Issues确认这个优化建议尚未被提出
required: true
- label: 这是对现有功能的改进,而不是全新功能
required: true
- type: dropdown
id: category
attributes:
label: 优化类别
description: 这个优化主要属于哪个方面?
options:
- 性能优化(速度、内存、资源占用)
- 交互体验(操作流程、界面布局)
- 视觉设计(样式、动画、美观度)
- 易用性(降低使用门槛、减少操作步骤)
- 稳定性(减少崩溃、提高可靠性)
- 其他
validations:
required: true
- type: textarea
id: target
attributes:
label: 目标功能或模块
description: 你希望优化的具体功能或页面是哪个?
placeholder: 例如:聊天页面的消息加载、设置页面的布局、文件上传功能
validations:
required: true
- type: textarea
id: current-behavior
attributes:
label: 当前表现
description: 描述当前功能的不足之处或存在的问题
placeholder: 例如:消息列表滚动时会出现明显卡顿,加载 100 条消息需要 3 秒
validations:
required: true
- type: textarea
id: improvement
attributes:
label: 优化建议
description: 详细说明你的优化方案和预期效果
placeholder: 例如:建议使用虚拟滚动技术,只渲染可见区域的消息,预计可将加载时间缩短到 0.5 秒以内
validations:
required: true
- type: textarea
id: benefits
attributes:
label: 优化收益
description: 这个优化会带来什么具体好处?
placeholder: 例如:提升 80% 的加载速度、减少 50% 的内存占用、降低用户操作步骤从 5 步到 2 步
validations:
required: true
- type: textarea
id: impact
attributes:
label: 影响范围
description: 这个优化会影响哪些用户或场景?
placeholder: 例如:所有用户在查看历史消息时都会受益,尤其是群聊消息较多的场景
- type: checkboxes
id: contribution
attributes:
label: 参与贡献
options:
- label: 我愿意提交 Pull Request 来实现这个优化
validations:
required: true

71
.github/ISSUE_TEMPLATE/feature.yml vendored Normal file
View File

@@ -0,0 +1,71 @@
name: "全新功能请求"
description: "提议一个目前项目中完全没有的新特性"
title: "[Feature]: "
labels: ["type: feature"]
body:
- type: markdown
attributes:
value: |
感谢你为项目提供新想法!详细的需求描述能极大提高该功能被采纳的几率。
- type: checkboxes
id: pre-check
attributes:
label: 提交前确认
options:
- label: 我已搜索过现有的 Issues 和 Pull Requests确认这个功能尚未被提出或实现
required: true
- label: 这是一个全新的功能,而不是对现有功能的改进
required: true
- type: dropdown
id: priority
attributes:
label: 功能优先级
description: 你认为这个功能有多重要?
options:
- 高优先级(核心功能缺失,严重影响使用体验)
- 中优先级(有助于提升使用体验)
- 低优先级(锦上添花的功能)
validations:
required: true
- type: textarea
id: problem
attributes:
label: 问题或痛点
description: 【为什么需要】你现在做某件事遇到了什么困难?缺少什么能力?
placeholder: 例如:目前无法批量导出聊天记录,每次只能手动复制单条消息,处理 100 条消息需要半小时
validations:
required: true
- type: textarea
id: solution
attributes:
label: 期望的解决方案
description: 【怎么实现】详细描述功能的操作流程、界面位置、可选参数等
placeholder: 例如:在聊天窗口右键菜单添加"导出记录"点击后弹窗可选时间范围、导出格式TXT/JSON、筛选用户最后保存到本地
validations:
required: true
- type: textarea
id: use-case
attributes:
label: 使用场景
description: 【什么时候用】你会在哪些具体情况下使用这个功能?
placeholder: 例如:每周五整理工作讨论记录;保存客户沟通记录作为合同依据;备份重要群聊内容
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: 替代方案
description: 你目前使用什么临时方案?或者有没有考虑过其他实现方式?
placeholder: 例如:目前只能手动截图或逐条复制粘贴
- type: textarea
id: reference
attributes:
label: 参考示例
description: 其他应用中是否有类似功能可以参考?
placeholder: 例如微信的聊天记录导出功能、Telegram 的导出数据功能
- type: checkboxes
id: contribution
attributes:
label: 参与贡献
options:
- label: 我愿意提交 Pull Request 来实现这个功能

71
.github/ISSUE_TEMPLATE/question.yml vendored Normal file
View File

@@ -0,0 +1,71 @@
name: "使用答疑"
description: "关于如何配置、如何使用项目的求助"
title: "[Question]: "
labels: ["type: question"]
body:
- type: markdown
attributes:
value: |
在提问之前,请确保你已经仔细阅读过我们的官方文档。
- type: checkboxes
id: pre-check
attributes:
label: 提交前确认
options:
- label: 我已阅读过相关文档
required: true
- label: 我已搜索过现有的 Issues没有找到类似问题
required: true
- type: dropdown
id: question-type
attributes:
label: 问题类型
description: 你的问题属于哪个方面?
options:
- 安装部署问题
- 配置相关问题
- 功能使用问题
- API 调用问题
- 错误排查问题
- 其他
validations:
required: true
- type: textarea
id: question
attributes:
label: 问题描述
description: 清晰描述你遇到的问题或疑问
placeholder: 例如:我在 Windows 系统上安装后无法启动应用,双击图标没有任何反应
validations:
required: true
- type: textarea
id: attempts
attributes:
label: 已尝试的方法
description: 你已经尝试过哪些解决方法?
placeholder: 例如:我尝试过重新安装、以管理员身份运行、关闭防火墙,但问题依然存在
validations:
required: true
- type: textarea
id: environment
attributes:
label: 运行环境
description: 提供你的系统环境信息
placeholder: |
操作系统Windows 11
应用版本v1.2.3
系统架构x64
validations:
required: true
- type: textarea
id: code-snippet
attributes:
label: 相关配置或代码
description: 如果涉及配置或代码问题,请粘贴相关内容
placeholder: 粘贴你的配置文件或代码片段
render: javascript
- type: textarea
id: screenshots
attributes:
label: 截图或日志
description: 如有必要,请提供截图或错误日志

84
.github/workflows/issue-auto-assign.yml vendored Normal file
View File

@@ -0,0 +1,84 @@
name: Issue Auto Assign
on:
issues:
types: [opened, edited, reopened]
permissions:
issues: write
jobs:
assign-by-platform:
runs-on: ubuntu-latest
steps:
- name: Assign issue by selected platform
uses: actions/github-script@v7
env:
ASSIGNEE_WINDOWS: ${{ vars.ISSUE_ASSIGNEE_WINDOWS }}
ASSIGNEE_MACOS: ${{ vars.ISSUE_ASSIGNEE_MACOS }}
ASSIGNEE_LINUX: ${{ vars.ISSUE_ASSIGNEE_LINUX || 'H3CoF6' }}
with:
script: |
const issue = context.payload.issue;
if (!issue) {
core.info("No issue payload.");
return;
}
const labels = (issue.labels || []).map((l) => l.name);
if (!labels.includes("type: bug")) {
core.info("Skip non-bug issue.");
return;
}
const body = issue.body || "";
const match = body.match(/###\s*(?:使用平台|平台|Platform)\s*\r?\n+([^\r\n]+)/i);
if (!match) {
core.info("No platform field found in issue body.");
return;
}
const rawPlatform = match[1].trim().toLowerCase();
let platformKey = null;
if (rawPlatform.includes("windows")) platformKey = "windows";
if (rawPlatform.includes("macos")) platformKey = "macos";
if (rawPlatform.includes("linux")) platformKey = "linux";
if (!platformKey) {
core.info(`Unrecognized platform value: ${rawPlatform}`);
return;
}
const parseAssignees = (value) =>
(value || "")
.split(",")
.map((v) => v.trim())
.filter(Boolean);
const assigneeMap = {
windows: parseAssignees(process.env.ASSIGNEE_WINDOWS),
macos: parseAssignees(process.env.ASSIGNEE_MACOS),
linux: parseAssignees(process.env.ASSIGNEE_LINUX),
};
const candidates = assigneeMap[platformKey] || [];
if (candidates.length === 0) {
core.info(`No assignee configured for platform: ${platformKey}`);
return;
}
const existing = new Set((issue.assignees || []).map((a) => a.login));
const toAdd = candidates.filter((u) => !existing.has(u));
if (toAdd.length === 0) {
core.info("All configured assignees already assigned.");
return;
}
await github.rest.issues.addAssignees({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
assignees: toAdd,
});
core.info(`Assigned issue #${issue.number} to: ${toAdd.join(", ")}`);

View File

@@ -8,24 +8,100 @@ on:
permissions:
contents: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
release-mac-arm64:
runs-on: macos-14
steps:
- name: Check out git repository
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Install Node.js
uses: actions/setup-node@v5
with:
node-version: 24
cache: "npm"
- name: Install Dependencies
run: npm install
- name: Sync version with tag
shell: bash
run: |
VERSION=${GITHUB_REF_NAME#v}
echo "Syncing package.json version to $VERSION"
npm version $VERSION --no-git-tag-version --allow-same-version
- name: Build Frontend & Type Check
run: |
npx tsc
npx vite build
- name: Package and Publish macOS arm64 (unsigned DMG)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_IDENTITY_AUTO_DISCOVERY: "false"
run: |
npx electron-builder --mac dmg --arm64 --publish always
release-linux:
runs-on: ubuntu-latest
steps:
- name: Check out git repository
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Install Node.js
uses: actions/setup-node@v5
with:
node-version: 24
cache: "npm"
- name: Install Dependencies
run: npm install
- name: Sync version with tag
shell: bash
run: |
VERSION=${GITHUB_REF_NAME#v}
echo "Syncing package.json version to $VERSION"
npm version $VERSION --no-git-tag-version --allow-same-version
- name: Build Frontend & Type Check
run: |
npx tsc
npx vite build
- name: Package and Publish Linux
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
npx electron-builder --linux --publish always
release:
runs-on: windows-latest
steps:
- name: Check out git repository
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Install Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: 22.12
node-version: 24
cache: 'npm'
- name: Install Dependencies
run: npm ci
run: npm install
- name: Sync version with tag
shell: bash
@@ -45,17 +121,63 @@ jobs:
run: |
npx electron-builder --publish always
- name: Update Release Notes
update-release-notes:
runs-on: ubuntu-latest
needs:
- release-mac-arm64
- release-linux
- release
steps:
- name: Generate release notes with platform download links
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
cat <<EOF > release_notes.md
set -euo pipefail
TAG="$GITHUB_REF_NAME"
REPO="$GITHUB_REPOSITORY"
RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG"
ASSETS_JSON="$(gh release view "$TAG" --repo "$REPO" --json assets)"
pick_asset() {
local pattern="$1"
echo "$ASSETS_JSON" | jq -r --arg p "$pattern" '[.assets[].name | select(test($p))][0] // ""'
}
WINDOWS_ASSET="$(pick_asset "\\.exe$")"
MAC_ASSET="$(pick_asset "\\.dmg$")"
LINUX_DEB_ASSET="$(pick_asset "\\.deb$")"
LINUX_TAR_ASSET="$(pick_asset "\\.tar\\.gz$")"
build_link() {
local name="$1"
if [ -n "$name" ]; then
echo "https://github.com/$REPO/releases/download/$TAG/$name"
fi
}
WINDOWS_URL="$(build_link "$WINDOWS_ASSET")"
MAC_URL="$(build_link "$MAC_ASSET")"
LINUX_DEB_URL="$(build_link "$LINUX_DEB_ASSET")"
LINUX_TAR_URL="$(build_link "$LINUX_TAR_ASSET")"
cat > release_notes.md <<EOF
## 更新日志
修复了一些已知问题
## 查看更多日志/获取最新动态
[点击加入 Telegram 频道](https://t.me/weflow_cc)
## 下载
- Windows Win10+: ${WINDOWS_URL:-$RELEASE_PAGE}
- macOSM系列芯片: ${MAC_URL:-$RELEASE_PAGE}
- Linux (.deb): ${LINUX_DEB_URL:-$RELEASE_PAGE}
- Linux (.tar.gz): ${LINUX_TAR_URL:-$RELEASE_PAGE}
> 如果某个平台链接暂时未生成,可进入完整发布页查看全部资源:$RELEASE_PAGE
EOF
gh release edit "$GITHUB_REF_NAME" --notes-file release_notes.md
gh release edit "$TAG" --repo "$REPO" --notes-file release_notes.md

6
.gitignore vendored
View File

@@ -62,7 +62,11 @@ server/
chatlab-format.md
*.bak
AGENTS.md
AGENT.md
.claude/
CLAUDE.md
.agents/
resources/wx_send
概述.md
概述.md
pnpm-lock.yaml
/pnpm-workspace.yaml

4
.npmrc
View File

@@ -1,3 +1,3 @@
registry=https://registry.npmmirror.com
electron_mirror=https://npmmirror.com/mirrors/electron/
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
electron-mirror=https://npmmirror.com/mirrors/electron/
electron-builder-binaries-mirror=https://npmmirror.com/mirrors/electron-builder-binaries/

View File

@@ -1,33 +1,46 @@
# WeFlow HTTP API 接口文档
# WeFlow HTTP API / Push 文档
WeFlow 提供 HTTP API 服务,支持通过 HTTP 接口查询消息数据,支持 [ChatLab](https://github.com/nichuanfang/chatlab-format) 标准化格式输出
WeFlow 提供本地 HTTP API,便于外部脚本或工具读取聊天记录、会话、联系人、群成员和导出的媒体文件;也支持在检测到新消息后通过固定 SSE 地址主动推送消息事件
## 启用 API 服务
## 启用方式
在设置页面 → API 服务 → 点击「启动服务」按钮
应用设置页启用 `API 服务`
默认端口:`5031`
## 基础地址
```
http://127.0.0.1:5031
```
---
- 默认监听地址:`127.0.0.1`
- 默认端口:`5031`
- 基础地址`http://127.0.0.1:5031`
- 可选开启 `主动推送`,检测到新收到的消息后会通过 `GET /api/v1/push/messages` 推送给 SSE 订阅端
## 接口列表
### 1. 健康检查
- `GET /health`
- `GET /api/v1/health`
- `GET /api/v1/push/messages`
- `GET /api/v1/messages`
- `GET /api/v1/messages/new`
- `GET /api/v1/sessions`
- `GET /api/v1/contacts`
- `GET /api/v1/group-members`
- `GET /api/v1/media/*`
检查 API 服务是否正常运行。
---
## 1. 健康检查
**请求**
```
```http
GET /health
```
```http
GET /api/v1/health
```
**响应**
```json
{
"status": "ok"
@@ -36,211 +49,223 @@ GET /health
---
### 2. 获取消息列表
## 2. 主动推送
获取指定会话的消息,支持 ChatLab 格式输出
通过 SSE 长连接接收新消息事件,端口与 HTTP API 共用
**请求**
```http
GET /api/v1/push/messages
```
### 说明
- 需要先在设置页开启 `HTTP API 服务`
- 同时需要开启 `主动推送`
- 响应类型为 `text/event-stream`
- 新消息事件名固定为 `message.new`
- 建议接收端按 `messageKey` 去重
### 事件字段
- `event`
- `sessionId`
- `messageKey`
- `avatarUrl`
- `sourceName`
- `groupName`(仅群聊)
- `content`
### 示例
```bash
curl -N "http://127.0.0.1:5031/api/v1/push/messages"
```
示例事件:
```text
event: message.new
data: {"event":"message.new","sessionId":"xxx@chatroom","messageKey":"server:123456:1760000123:1760000123000:321:wxid_member:1","avatarUrl":"https://example.com/group.jpg","sourceName":"李四","groupName":"项目群","content":"[图片]"}
```
---
## 3. 获取消息
读取指定会话的消息,支持原始 JSON 和 ChatLab 格式。
**请求**
```http
GET /api/v1/messages
```
**参数**
### 参数
| 参数 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `talker` | string | | 会话 IDwxid 或群 ID |
| `limit` | number | | 返回数量限制,默认 100范围 `1~10000` |
| `offset` | number | | 偏移量,用于分页,默认 0 |
| `start` | string | | 开始时间,格式 YYYYMMDD |
| `end` | string | | 结束时间,格式 YYYYMMDD |
| `keyword` | string | | 关键词过滤(基于消息显示文本 |
| `chatlab` | string | | 设为 `1` 输出 ChatLab 格式 |
| `format` | string | | 输出格式:`json`(默认)`chatlab` |
| `media` | string | | 设为 `1` 时导出媒体并返回媒体路径(兼容别名 `meiti``0` 时媒体返回占位符 |
| `image` | string | | 在 `media=1` 时控制图片导出,`1/0`兼容别名 `tupian` |
| `voice` | string | | 在 `media=1` 时控制语音导出,`1/0`兼容别名 `vioce` |
| `video` | string | | 在 `media=1` 时控制视频导出`1/0` |
| `emoji` | string | | 在 `media=1` 时控制表情导出`1/0` |
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `talker` | string | | 会话 ID。私聊通常是对方 `wxid`,群聊是 `xxx@chatroom` |
| `limit` | number | | 返回数,默认 `100`,范围 `1~10000` |
| `offset` | number | | 分页偏移,默认 `0` |
| `start` | string | | 开始时间,支持 `YYYYMMDD` 或时间戳 |
| `end` | string | | 结束时间,支持 `YYYYMMDD` 或时间戳 |
| `keyword` | string | | 基于消息显示文本过滤 |
| `chatlab` | string | | `1/true` 输出 ChatLab 格式 |
| `format` | string | | `json` `chatlab` |
| `media` | string | | `1/true` 时导出媒体并返回媒体地址,兼容别名 `meiti` |
| `image` | string | | 在 `media=1` 时控制图片导出,兼容别名 `tupian` |
| `voice` | string | | 在 `media=1` 时控制语音导出,兼容别名 `vioce` |
| `video` | string | | 在 `media=1` 时控制视频导出 |
| `emoji` | string | | 在 `media=1` 时控制表情导出 |
默认媒体导出目录:`%USERPROFILE%\\Documents\\WeFlow\\api-media`
**示例请求**
### 示例
```bash
# 获取消息(原始格式)
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=50
# 获取消息ChatLab 格式)
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1
# 带时间范围查询
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&start=20260101&end=20260205&limit=100
# 开启媒体导出(只导出图片和语音)
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&media=1&image=1&voice=1&video=0&emoji=0
# 关键词过滤
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&keyword=项目进度&limit=50
curl "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=20"
curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&chatlab=1"
curl "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&start=20260101&end=20260131"
curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&media=1&image=1&voice=0&video=0&emoji=0"
```
**响应(原始格式)**
### JSON 响应字段
顶层字段:
- `success`
- `talker`
- `count`
- `hasMore`
- `media.enabled`
- `media.exportPath`
- `media.count`
- `messages`
单条消息字段:
- `localId`
- `serverId`
- `localType`
- `createTime`
- `isSend`
- `senderUsername`
- `content`
- `rawContent`
- `parsedContent`
- `mediaType`
- `mediaFileName`
- `mediaUrl`
- `mediaLocalPath`
**示例响应**
```json
{
"success": true,
"talker": "wxid_xxx",
"count": 50,
"talker": "xxx@chatroom",
"count": 2,
"hasMore": true,
"media": {
"enabled": true,
"exportPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media",
"count": 12
"count": 1
},
"messages": [
{
"localId": 123,
"serverId": "456",
"localType": 1,
"createTime": 1738713600,
"isSend": 0,
"senderUsername": "wxid_member",
"content": "你好",
"rawContent": "你好",
"parsedContent": "你好"
},
{
"localId": 124,
"localType": 3,
"createTime": 1738713660,
"isSend": 0,
"senderUsername": "wxid_member",
"content": "[图片]",
"createTime": 1738713600000,
"senderUsername": "wxid_sender",
"mediaType": "image",
"mediaFileName": "image_123.jpg",
"mediaUrl": "http://127.0.0.1:5031/api/v1/media/wxid_xxx/images/image_123.jpg",
"mediaLocalPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\wxid_xxx\\images\\image_123.jpg"
"mediaFileName": "abc123.jpg",
"mediaUrl": "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/images/abc123.jpg",
"mediaLocalPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\xxx@chatroom\\images\\abc123.jpg"
}
]
}
```
**响应(ChatLab 格式)**
```json
{
"chatlab": {
"version": "0.0.2",
"exportedAt": 1738713600000,
"generator": "WeFlow",
"description": "Exported from WeFlow"
},
"meta": {
"name": "会话名称",
"platform": "wechat",
"type": "private",
"ownerId": "wxid_me"
},
"members": [
{
"platformId": "wxid_xxx",
"accountName": "用户名",
"groupNickname": "群昵称"
}
],
"messages": [
{
"sender": "wxid_xxx",
"accountName": "用户名",
"timestamp": 1738713600000,
"type": 0,
"content": "消息内容",
"mediaPath": "http://127.0.0.1:5031/api/v1/media/wxid_xxx/images/image_123.jpg"
}
],
"media": {
"enabled": true,
"exportPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media",
"count": 12
}
}
```
### ChatLab 响应
`chatlab=1``format=chatlab` 时,返回 ChatLab 结构:
- `chatlab.version`
- `chatlab.exportedAt`
- `chatlab.generator`
- `meta.name`
- `meta.platform`
- `meta.type`
- `meta.groupId`
- `meta.groupAvatar`
- `meta.ownerId`
- `members[].platformId`
- `members[].accountName`
- `members[].groupNickname`
- `members[].avatar`
- `messages[].sender`
- `messages[].accountName`
- `messages[].groupNickname`
- `messages[].timestamp`
- `messages[].type`
- `messages[].content`
- `messages[].platformMessageId`
- `messages[].mediaPath`
群聊里 `groupNickname` 会优先来自群成员群昵称;若源数据缺失,则回退为空或展示名。
---
### 3. 访问导出媒体文件
通过 HTTP 直接访问已导出的媒体文件(图片、语音、视频、表情)。
## 4. 获取会话列表
**请求**
```
GET /api/v1/media/{relativePath}
```
**路径参数**
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `relativePath` | string | ✅ | 媒体文件的相对路径,如 `wxid_xxx/images/image_123.jpg` |
**支持的媒体类型**
| 扩展名 | Content-Type |
|--------|-------------|
| `.png` | image/png |
| `.jpg` / `.jpeg` | image/jpeg |
| `.gif` | image/gif |
| `.webp` | image/webp |
| `.wav` | audio/wav |
| `.mp3` | audio/mpeg |
| `.mp4` | video/mp4 |
**示例请求**
```bash
# 访问导出的图片
GET http://127.0.0.1:5031/api/v1/media/wxid_xxx/images/image_123.jpg
# 访问导出的语音
GET http://127.0.0.1:5031/api/v1/media/wxid_xxx/voices/voice_456.wav
# 访问导出的视频
GET http://127.0.0.1:5031/api/v1/media/wxid_xxx/videos/video_789.mp4
```
**响应**
成功时直接返回文件内容,`Content-Type` 根据文件扩展名自动设置。
失败时返回:
```json
{ "error": "Media not found" }
```
> 注意:媒体文件需要先通过消息接口的 `media=1` 参数导出后才能访问。
---
### 4. 获取会话列表
获取所有会话列表。
**请求**
```
```http
GET /api/v1/sessions
```
**参数**
### 参数
| 参数 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `keyword` | string | | 搜索关键词,匹配会话名或 ID |
| `limit` | number | | 返回数量限制,默认 100 |
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `keyword` | string | | 匹配 `username``displayName` |
| `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
```
- `success`
- `count`
- `sessions[].username`
- `sessions[].displayName`
- `sessions[].type`
- `sessions[].lastTimestamp`
- `sessions[].unreadCount`
**示例响应**
**响应**
```json
{
"success": true,
"count": 50,
"total": 100,
"count": 1,
"sessions": [
{
"username": "wxid_xxx",
"displayName": "用户名",
"lastMessage": "最后一条消息",
"lastTime": 1738713600000,
"username": "xxx@chatroom",
"displayName": "项目群",
"type": 2,
"lastTimestamp": 1738713600,
"unreadCount": 0
}
]
@@ -249,40 +274,48 @@ GET http://127.0.0.1:5031/api/v1/sessions?keyword=工作群&limit=20
---
### 4. 获取联系人列表
获取所有联系人信息。
## 5. 获取联系人列表
**请求**
```
```http
GET /api/v1/contacts
```
**参数**
### 参数
| 参数 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `keyword` | string | | 搜索关键词 |
| `limit` | number | | 返回数量限制,默认 100 |
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `keyword` | string | | 匹配 `username``nickname``remark``displayName` |
| `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=张三
```
- `success`
- `count`
- `contacts[].username`
- `contacts[].displayName`
- `contacts[].remark`
- `contacts[].nickname`
- `contacts[].alias`
- `contacts[].avatarUrl`
- `contacts[].type`
**示例响应**
**响应**
```json
{
"success": true,
"count": 50,
"count": 1,
"contacts": [
{
"userName": "wxid_xxx",
"alias": "微信号",
"nickName": "昵称",
"remark": "备注名"
"username": "wxid_xxx",
"displayName": "张三",
"remark": "客户张三",
"nickname": "张三",
"alias": "zhangsan",
"avatarUrl": "https://example.com/avatar.jpg",
"type": "friend"
}
]
}
@@ -290,60 +323,157 @@ GET http://127.0.0.1:5031/api/v1/contacts?keyword=张三
---
## ChatLab 格式说明
## 6. 获取群成员列表
ChatLab 是一种标准化的聊天记录交换格式,版本 0.0.2
返回群成员的 `wxid`、群昵称、备注、微信号等信息
### 消息类型映射
**请求**
| 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 | 其他 |
```http
GET /api/v1/group-members
```
### 参数
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `chatroomId` | string | 是 | 群 ID兼容使用 `talker` 传入 |
| `includeMessageCounts` | string | 否 | `1/true` 时附带成员发言数 |
| `withCounts` | string | 否 | `includeMessageCounts` 的别名 |
| `forceRefresh` | string | 否 | `1/true` 时跳过内存缓存强制刷新 |
### 响应字段
- `success`
- `chatroomId`
- `count`
- `fromCache`
- `updatedAt`
- `members[].wxid`
- `members[].displayName`
- `members[].nickname`
- `members[].remark`
- `members[].alias`
- `members[].groupNickname`
- `members[].avatarUrl`
- `members[].isOwner`
- `members[].isFriend`
- `members[].messageCount`
**示例请求**
```bash
curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom"
curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom&includeMessageCounts=1&forceRefresh=1"
```
**示例响应**
```json
{
"success": true,
"chatroomId": "xxx@chatroom",
"count": 2,
"fromCache": false,
"updatedAt": 1760000000000,
"members": [
{
"wxid": "wxid_member_a",
"displayName": "客户A",
"nickname": "阿甲",
"remark": "客户A",
"alias": "kehua",
"groupNickname": "甲方",
"avatarUrl": "https://example.com/a.jpg",
"isOwner": true,
"isFriend": true,
"messageCount": 128
},
{
"wxid": "wxid_member_b",
"displayName": "李四",
"nickname": "李四",
"remark": "",
"alias": "",
"groupNickname": "",
"avatarUrl": "",
"isOwner": false,
"isFriend": false,
"messageCount": 0
}
]
}
```
说明:
- `displayName` 是当前应用内的主展示名。
- `groupNickname` 是成员在该群里的群昵称。
- `remark` 是你对该联系人的备注。
- `alias` 是微信号。
- 当微信源数据里没有群昵称时,`groupNickname` 会为空。
---
## 使用示例
## 7. 访问导出媒体
通过消息接口启用 `media=1` 后,接口会先把图片、语音、视频、表情导出到本地缓存目录,再返回可访问的 HTTP 地址。
**请求**
```http
GET /api/v1/media/{relativePath}
```
### 示例
```bash
curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/images/abc123.jpg"
curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/voices/voice_100.wav"
curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/videos/video_200.mp4"
curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/emojis/emoji_300.gif"
```
### 支持的 Content-Type
| 扩展名 | Content-Type |
| --- | --- |
| `.png` | `image/png` |
| `.jpg` / `.jpeg` | `image/jpeg` |
| `.gif` | `image/gif` |
| `.webp` | `image/webp` |
| `.wav` | `audio/wav` |
| `.mp3` | `audio/mpeg` |
| `.mp4` | `video/mp4` |
常见错误响应:
```json
{
"error": "Media not found"
}
```
---
## 8. 使用示例
### 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
Invoke-RestMethod "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom&includeMessageCounts=1"
```
### 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"
curl "http://127.0.0.1:5031/api/v1/contacts?keyword=张三"
curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom"
```
### Python
@@ -353,39 +483,26 @@ 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": "xxx@chatroom", "limit": 50}
).json()
members = requests.get(
f"{BASE_URL}/api/v1/group-members",
params={"chatroomId": "xxx@chatroom", "includeMessageCounts": 1}
).json()
# 获取消息
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);
print(members)
```
---
## 注意事项
## 9. 注意事项
1. API 仅监听本地地址 `127.0.0.1`,不对外网开放
2. 需要先连接数据库才能查询数据
3. 时间参数格式为 `YYYYMMDD`(如 20260205
4. 支持 CORS可从浏览器前端直接调用
1. API 仅监听本 `127.0.0.1`,不对外网开放
2. 使用前需要先在 WeFlow 中完成数据库连接。
3. `start``end` 支持 `YYYYMMDD` 与时间戳;纯 `YYYYMMDD``end` 会扩展到当天 `23:59:59`
4. 群成员的 `groupNickname` 依赖微信源数据;源数据缺失时不会自动补出。
5. 媒体访问链接只有在对应消息已经通过 `media=1` 导出后才可访问。

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.debugger</key>
<true/>
<key>com.apple.security.get-task-allow</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>

56
electron/exportWorker.ts Normal file
View File

@@ -0,0 +1,56 @@
import { parentPort, workerData } from 'worker_threads'
import type { ExportOptions } from './services/exportService'
interface ExportWorkerConfig {
sessionIds: string[]
outputDir: string
options: ExportOptions
resourcesPath?: string
userDataPath?: string
logEnabled?: boolean
}
const config = workerData as ExportWorkerConfig
process.env.WEFLOW_WORKER = '1'
if (config.resourcesPath) {
process.env.WCDB_RESOURCES_PATH = config.resourcesPath
}
if (config.userDataPath) {
process.env.WEFLOW_USER_DATA_PATH = config.userDataPath
process.env.WEFLOW_CONFIG_CWD = config.userDataPath
}
process.env.WEFLOW_PROJECT_NAME = process.env.WEFLOW_PROJECT_NAME || 'WeFlow'
async function run() {
const [{ wcdbService }, { exportService }] = await Promise.all([
import('./services/wcdbService'),
import('./services/exportService')
])
wcdbService.setPaths(config.resourcesPath || '', config.userDataPath || '')
wcdbService.setLogEnabled(config.logEnabled === true)
const result = await exportService.exportSessions(
Array.isArray(config.sessionIds) ? config.sessionIds : [],
String(config.outputDir || ''),
config.options || { format: 'json' },
(progress) => {
parentPort?.postMessage({
type: 'export:progress',
data: progress
})
}
)
parentPort?.postMessage({
type: 'export:result',
data: result
})
}
run().catch((error) => {
parentPort?.postMessage({
type: 'export:error',
error: String(error)
})
})

View File

@@ -10,7 +10,7 @@ type WorkerPayload = {
thumbOnly: boolean
}
type Candidate = { score: number; path: string; isThumb: boolean; hasX: boolean }
type Candidate = { score: number; path: string; isThumb: boolean }
const payload = workerData as WorkerPayload
@@ -18,16 +18,26 @@ function looksLikeMd5(value: string): boolean {
return /^[a-fA-F0-9]{16,32}$/.test(value)
}
function stripDatVariantSuffix(base: string): string {
const lower = base.toLowerCase()
const suffixes = ['_thumb', '.thumb', '_hd', '.hd', '_h', '.h', '_t', '.t', '_c', '.c']
for (const suffix of suffixes) {
if (lower.endsWith(suffix)) {
return lower.slice(0, -suffix.length)
}
}
if (/[._][a-z]$/.test(lower)) {
return lower.slice(0, -2)
}
return lower
}
function hasXVariant(baseLower: string): boolean {
return /[._][a-z]$/.test(baseLower)
return stripDatVariantSuffix(baseLower) !== baseLower
}
function hasImageVariantSuffix(baseLower: string): boolean {
return /[._][a-z]$/.test(baseLower)
}
function isLikelyImageDatBase(baseLower: string): boolean {
return hasImageVariantSuffix(baseLower) || looksLikeMd5(baseLower)
return stripDatVariantSuffix(baseLower) !== baseLower
}
function normalizeDatBase(name: string): string {
@@ -35,10 +45,17 @@ function normalizeDatBase(name: string): string {
if (base.endsWith('.dat') || base.endsWith('.jpg')) {
base = base.slice(0, -4)
}
while (/[._][a-z]$/.test(base)) {
base = base.slice(0, -2)
while (true) {
const stripped = stripDatVariantSuffix(base)
if (stripped === base) {
return base
}
base = stripped
}
return base
}
function isLikelyImageDatBase(baseLower: string): boolean {
return hasImageVariantSuffix(baseLower) || looksLikeMd5(normalizeDatBase(baseLower))
}
function matchesDatName(fileName: string, datName: string): boolean {
@@ -47,25 +64,23 @@ function matchesDatName(fileName: string, datName: string): boolean {
const normalizedBase = normalizeDatBase(base)
const normalizedTarget = normalizeDatBase(datName.toLowerCase())
if (normalizedBase === normalizedTarget) return true
const pattern = new RegExp(`^${datName}(?:[._][a-z])?\\.dat$`)
if (pattern.test(lower)) return true
return lower.endsWith('.dat') && lower.includes(datName)
return lower.endsWith('.dat') && lower.includes(normalizedTarget)
}
function scoreDatName(fileName: string): number {
if (fileName.includes('.t.dat') || fileName.includes('_t.dat')) return 1
if (fileName.includes('.c.dat') || fileName.includes('_c.dat')) return 1
return 2
const lower = fileName.toLowerCase()
const baseLower = lower.endsWith('.dat') ? lower.slice(0, -4) : lower
if (baseLower.endsWith('_h') || baseLower.endsWith('.h')) return 600
if (!hasXVariant(baseLower)) return 500
if (baseLower.endsWith('_hd') || baseLower.endsWith('.hd')) return 450
if (baseLower.endsWith('_c') || baseLower.endsWith('.c')) return 400
if (isThumbnailDat(lower)) return 100
return 350
}
function isThumbnailDat(fileName: string): boolean {
return fileName.includes('.t.dat') || fileName.includes('_t.dat')
}
function isHdDat(fileName: string): boolean {
const lower = fileName.toLowerCase()
const base = lower.endsWith('.dat') ? lower.slice(0, -4) : lower
return base.endsWith('_hd') || base.endsWith('_h')
return lower.includes('.t.dat') || lower.includes('_t.dat') || lower.includes('_thumb.dat')
}
function walkForDat(
@@ -105,20 +120,15 @@ function walkForDat(
if (!lower.endsWith('.dat')) continue
const baseLower = lower.slice(0, -4)
if (!isLikelyImageDatBase(baseLower)) continue
if (!hasXVariant(baseLower)) continue
if (!matchesDatName(lower, datName)) continue
// 排除高清图片格式 (_hd, _h)
if (isHdDat(lower)) continue
matchedBases.add(baseLower)
const isThumb = isThumbnailDat(lower)
if (!allowThumbnail && isThumb) continue
if (thumbOnly && !isThumb) continue
const score = scoreDatName(lower)
candidates.push({
score,
score: scoreDatName(lower),
path: entryPath,
isThumb,
hasX: hasXVariant(baseLower)
isThumb
})
}
}
@@ -126,10 +136,8 @@ function walkForDat(
return { path: null, matchedBases: Array.from(matchedBases).slice(0, 20) }
}
const withX = candidates.filter((item) => item.hasX)
const basePool = withX.length ? withX : candidates
const nonThumb = basePool.filter((item) => !item.isThumb)
const finalPool = thumbOnly ? basePool : (nonThumb.length ? nonThumb : basePool)
const nonThumb = candidates.filter((item) => !item.isThumb)
const finalPool = thumbOnly ? candidates : (nonThumb.length ? nonThumb : candidates)
let best: { score: number; path: string } | null = null
for (const item of finalPool) {

View File

@@ -1,6 +1,7 @@
import './preload-env'
import { app, BrowserWindow, ipcMain, nativeTheme, session } from 'electron'
import { app, BrowserWindow, ipcMain, nativeTheme, session, Tray, Menu, nativeImage } from 'electron'
import { Worker } from 'worker_threads'
import { randomUUID } from 'crypto'
import { join, dirname } from 'path'
import { autoUpdater } from 'electron-updater'
import { readFile, writeFile, mkdir, rm, readdir } from 'fs/promises'
@@ -16,6 +17,8 @@ import { groupAnalyticsService } from './services/groupAnalyticsService'
import { annualReportService } from './services/annualReportService'
import { exportService, ExportOptions, ExportProgress } from './services/exportService'
import { KeyService } from './services/keyService'
import { KeyServiceLinux } from './services/keyServiceLinux'
import { KeyServiceMac } from './services/keyServiceMac'
import { voiceTranscribeService } from './services/voiceTranscribeService'
import { videoService } from './services/videoService'
import { snsService, isVideoUrl } from './services/snsService'
@@ -24,8 +27,9 @@ import { windowsHelloService } from './services/windowsHelloService'
import { exportCardDiagnosticsService } from './services/exportCardDiagnosticsService'
import { cloudControlService } from './services/cloudControlService'
import { registerNotificationHandlers, showNotification } from './windows/notificationWindow'
import { destroyNotificationWindow, registerNotificationHandlers, showNotification } from './windows/notificationWindow'
import { httpService } from './services/httpService'
import { messagePushService } from './services/messagePushService'
// 配置自动更新
@@ -87,10 +91,31 @@ let onboardingWindow: BrowserWindow | null = null
// Splash 启动窗口
let splashWindow: BrowserWindow | null = null
const sessionChatWindows = new Map<string, BrowserWindow>()
const keyService = new KeyService()
const sessionChatWindowSources = new Map<string, 'chat' | 'export'>()
let keyService: any
if (process.platform === 'darwin') {
keyService = new KeyServiceMac()
} else if (process.platform === 'linux') {
// const { KeyServiceLinux } = require('./services/keyServiceLinux')
// keyService = new KeyServiceLinux()
import('./services/keyServiceLinux').then(({ KeyServiceLinux }) => {
keyService = new KeyServiceLinux();
});
} else {
keyService = new KeyService()
}
let mainWindowReady = false
let shouldShowMain = true
let isAppQuitting = false
let tray: Tray | null = null
let isClosePromptVisible = false
const chatHistoryPayloadStore = new Map<string, { sessionId: string; title?: string; recordList: any[] }>()
type WindowCloseBehavior = 'ask' | 'tray' | 'quit'
// 更新下载状态管理Issue #294 修复)
let isDownloadInProgress = false
@@ -123,6 +148,47 @@ interface AnnualReportYearsTaskState {
updatedAt: number
}
interface OpenSessionChatWindowOptions {
source?: 'chat' | 'export'
initialDisplayName?: string
initialAvatarUrl?: string
initialContactType?: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
}
const normalizeSessionChatWindowSource = (source: unknown): 'chat' | 'export' => {
return String(source || '').trim().toLowerCase() === 'export' ? 'export' : 'chat'
}
const normalizeSessionChatWindowOptionString = (value: unknown): string => {
return String(value || '').trim()
}
const loadSessionChatWindowContent = (
win: BrowserWindow,
sessionId: string,
source: 'chat' | 'export',
options?: OpenSessionChatWindowOptions
) => {
const queryParams = new URLSearchParams({
sessionId,
source
})
const initialDisplayName = normalizeSessionChatWindowOptionString(options?.initialDisplayName)
const initialAvatarUrl = normalizeSessionChatWindowOptionString(options?.initialAvatarUrl)
const initialContactType = normalizeSessionChatWindowOptionString(options?.initialContactType)
if (initialDisplayName) queryParams.set('initialDisplayName', initialDisplayName)
if (initialAvatarUrl) queryParams.set('initialAvatarUrl', initialAvatarUrl)
if (initialContactType) queryParams.set('initialContactType', initialContactType)
const query = queryParams.toString()
if (process.env.VITE_DEV_SERVER_URL) {
win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/chat-window?${query}`)
return
}
win.loadFile(join(__dirname, '../dist/index.html'), {
hash: `/chat-window?${query}`
})
}
const annualReportYearsLoadTasks = new Map<string, AnnualReportYearsTaskState>()
const annualReportYearsTaskByCacheKey = new Map<string, string>()
const annualReportYearsSnapshotCache = new Map<string, { snapshot: AnnualReportYearsProgressPayload; updatedAt: number; taskId: string }>()
@@ -189,13 +255,51 @@ const isYearsLoadCanceled = (taskId: string): boolean => {
return task?.canceled === true
}
const setupCustomTitleBarWindow = (win: BrowserWindow): void => {
if (process.platform === 'darwin') {
win.setWindowButtonVisibility(false)
}
const emitMaximizeState = () => {
if (win.isDestroyed()) return
win.webContents.send('window:maximizeStateChanged', win.isMaximized() || win.isFullScreen())
}
win.on('maximize', emitMaximizeState)
win.on('unmaximize', emitMaximizeState)
win.on('enter-full-screen', emitMaximizeState)
win.on('leave-full-screen', emitMaximizeState)
win.webContents.on('did-finish-load', emitMaximizeState)
}
const getWindowCloseBehavior = (): WindowCloseBehavior => {
const behavior = configService?.get('windowCloseBehavior')
return behavior === 'tray' || behavior === 'quit' ? behavior : 'ask'
}
const requestMainWindowCloseConfirmation = (win: BrowserWindow): void => {
if (isClosePromptVisible) return
isClosePromptVisible = true
win.webContents.send('window:confirmCloseRequested', {
canMinimizeToTray: Boolean(tray)
})
}
function createWindow(options: { autoShow?: boolean } = {}) {
// 获取图标路径 - 打包后在 resources 目录
const { autoShow = true } = options
let iconName = 'icon.ico';
if (process.platform === 'linux') {
iconName = 'icon.png';
} else if (process.platform === 'darwin') {
iconName = 'icon.icns';
}
const isDev = !!process.env.VITE_DEV_SERVER_URL
const iconPath = isDev
? join(__dirname, '../public/icon.ico')
: join(process.resourcesPath, 'icon.ico')
? join(__dirname, `../public/${iconName}`)
: join(process.resourcesPath, iconName);
const win = new BrowserWindow({
width: 1400,
@@ -210,13 +314,10 @@ function createWindow(options: { autoShow?: boolean } = {}) {
webSecurity: false // Allow loading local files (video playback)
},
titleBarStyle: 'hidden',
titleBarOverlay: {
color: '#00000000',
symbolColor: '#1a1a1a',
height: 40
},
titleBarOverlay: false,
show: false
})
setupCustomTitleBarWindow(win)
// 窗口准备好后显示
// Splash 模式下不在这里 show由启动流程统一控制
@@ -290,6 +391,40 @@ function createWindow(options: { autoShow?: boolean } = {}) {
callback(false)
})
win.on('close', (e) => {
if (isAppQuitting || win !== mainWindow) return
e.preventDefault()
const closeBehavior = getWindowCloseBehavior()
if (closeBehavior === 'quit') {
isAppQuitting = true
app.quit()
return
}
if (closeBehavior === 'tray' && tray) {
win.hide()
return
}
requestMainWindowCloseConfirmation(win)
})
win.on('closed', () => {
if (mainWindow !== win) return
mainWindow = null
mainWindowReady = false
isClosePromptVisible = false
if (process.platform !== 'darwin' && !isAppQuitting) {
destroyNotificationWindow()
if (BrowserWindow.getAllWindows().length === 0) {
app.quit()
}
}
})
return win
}
@@ -306,7 +441,9 @@ function createAgreementWindow() {
const isDev = !!process.env.VITE_DEV_SERVER_URL
const iconPath = isDev
? join(__dirname, '../public/icon.ico')
: join(process.resourcesPath, 'icon.ico')
: (process.platform === 'darwin'
? join(process.resourcesPath, 'icon.icns')
: join(process.resourcesPath, 'icon.ico'))
const isDark = nativeTheme.shouldUseDarkColors
@@ -356,7 +493,9 @@ function createSplashWindow(): BrowserWindow {
const isDev = !!process.env.VITE_DEV_SERVER_URL
const iconPath = isDev
? join(__dirname, '../public/icon.ico')
: join(process.resourcesPath, 'icon.ico')
: (process.platform === 'darwin'
? join(process.resourcesPath, 'icon.icns')
: join(process.resourcesPath, 'icon.ico'))
splashWindow = new BrowserWindow({
width: 760,
@@ -427,7 +566,9 @@ function createOnboardingWindow() {
const isDev = !!process.env.VITE_DEV_SERVER_URL
const iconPath = isDev
? join(__dirname, '../public/icon.ico')
: join(process.resourcesPath, 'icon.ico')
: (process.platform === 'darwin'
? join(process.resourcesPath, 'icon.icns')
: join(process.resourcesPath, 'icon.ico'))
onboardingWindow = new BrowserWindow({
width: 960,
@@ -473,7 +614,9 @@ function createVideoPlayerWindow(videoPath: string, videoWidth?: number, videoHe
const isDev = !!process.env.VITE_DEV_SERVER_URL
const iconPath = isDev
? join(__dirname, '../public/icon.ico')
: join(process.resourcesPath, 'icon.ico')
: (process.platform === 'darwin'
? join(process.resourcesPath, 'icon.icns')
: join(process.resourcesPath, 'icon.ico'))
// 获取屏幕尺寸
const { screen } = require('electron')
@@ -571,7 +714,9 @@ function createImageViewerWindow(imagePath: string, liveVideoPath?: string) {
const isDev = !!process.env.VITE_DEV_SERVER_URL
const iconPath = isDev
? join(__dirname, '../public/icon.ico')
: join(process.resourcesPath, 'icon.ico')
: (process.platform === 'darwin'
? join(process.resourcesPath, 'icon.icns')
: join(process.resourcesPath, 'icon.ico'))
const win = new BrowserWindow({
width: 900,
@@ -585,17 +730,14 @@ function createImageViewerWindow(imagePath: string, liveVideoPath?: string) {
nodeIntegration: false,
webSecurity: false // 允许加载本地文件
},
titleBarStyle: 'hidden',
titleBarOverlay: {
color: '#00000000',
symbolColor: '#ffffff',
height: 40
},
frame: false,
show: false,
backgroundColor: '#000000',
autoHideMenuBar: true
})
setupCustomTitleBarWindow(win)
win.once('ready-to-show', () => {
win.show()
})
@@ -629,10 +771,20 @@ function createImageViewerWindow(imagePath: string, liveVideoPath?: string) {
* 创建独立的聊天记录窗口
*/
function createChatHistoryWindow(sessionId: string, messageId: number) {
return createChatHistoryRouteWindow(`/chat-history/${sessionId}/${messageId}`)
}
function createChatHistoryPayloadWindow(payloadId: string) {
return createChatHistoryRouteWindow(`/chat-history-inline/${payloadId}`)
}
function createChatHistoryRouteWindow(route: string) {
const isDev = !!process.env.VITE_DEV_SERVER_URL
const iconPath = isDev
? join(__dirname, '../public/icon.ico')
: join(process.resourcesPath, 'icon.ico')
: (process.platform === 'darwin'
? join(process.resourcesPath, 'icon.icns')
: join(process.resourcesPath, 'icon.ico'))
// 根据系统主题设置窗口背景色
const isDark = nativeTheme.shouldUseDarkColors
@@ -649,22 +801,19 @@ function createChatHistoryWindow(sessionId: string, messageId: number) {
nodeIntegration: false
},
titleBarStyle: 'hidden',
titleBarOverlay: {
color: '#00000000',
symbolColor: isDark ? '#ffffff' : '#1a1a1a',
height: 32
},
titleBarOverlay: false,
show: false,
backgroundColor: isDark ? '#1A1A1A' : '#F0F0F0',
autoHideMenuBar: true
})
setupCustomTitleBarWindow(win)
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.loadURL(`${process.env.VITE_DEV_SERVER_URL}#${route}`)
win.webContents.on('before-input-event', (event, input) => {
if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) {
@@ -678,7 +827,7 @@ function createChatHistoryWindow(sessionId: string, messageId: number) {
})
} else {
win.loadFile(join(__dirname, '../dist/index.html'), {
hash: `/chat-history/${sessionId}/${messageId}`
hash: route
})
}
@@ -688,12 +837,18 @@ function createChatHistoryWindow(sessionId: string, messageId: number) {
/**
* 创建独立的会话聊天窗口(单会话,复用聊天页右侧消息区域)
*/
function createSessionChatWindow(sessionId: string) {
function createSessionChatWindow(sessionId: string, options?: OpenSessionChatWindowOptions) {
const normalizedSessionId = String(sessionId || '').trim()
if (!normalizedSessionId) return null
const normalizedSource = normalizeSessionChatWindowSource(options?.source)
const existing = sessionChatWindows.get(normalizedSessionId)
if (existing && !existing.isDestroyed()) {
const trackedSource = sessionChatWindowSources.get(normalizedSessionId) || 'chat'
if (trackedSource !== normalizedSource) {
loadSessionChatWindowContent(existing, normalizedSessionId, normalizedSource, options)
sessionChatWindowSources.set(normalizedSessionId, normalizedSource)
}
if (existing.isMinimized()) {
existing.restore()
}
@@ -704,7 +859,9 @@ function createSessionChatWindow(sessionId: string) {
const isDev = !!process.env.VITE_DEV_SERVER_URL
const iconPath = isDev
? join(__dirname, '../public/icon.ico')
: join(process.resourcesPath, 'icon.ico')
: (process.platform === 'darwin'
? join(process.resourcesPath, 'icon.icns')
: join(process.resourcesPath, 'icon.ico'))
const isDark = nativeTheme.shouldUseDarkColors
@@ -730,10 +887,9 @@ function createSessionChatWindow(sessionId: string) {
autoHideMenuBar: true
})
const sessionParam = `sessionId=${encodeURIComponent(normalizedSessionId)}`
if (process.env.VITE_DEV_SERVER_URL) {
win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/chat-window?${sessionParam}`)
loadSessionChatWindowContent(win, normalizedSessionId, normalizedSource, options)
if (process.env.VITE_DEV_SERVER_URL) {
win.webContents.on('before-input-event', (event, input) => {
if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) {
if (win.webContents.isDevToolsOpened()) {
@@ -744,10 +900,6 @@ function createSessionChatWindow(sessionId: string) {
event.preventDefault()
}
})
} else {
win.loadFile(join(__dirname, '../dist/index.html'), {
hash: `/chat-window?${sessionParam}`
})
}
win.once('ready-to-show', () => {
@@ -759,10 +911,12 @@ function createSessionChatWindow(sessionId: string) {
const tracked = sessionChatWindows.get(normalizedSessionId)
if (tracked === win) {
sessionChatWindows.delete(normalizedSessionId)
sessionChatWindowSources.delete(normalizedSessionId)
}
})
sessionChatWindows.set(normalizedSessionId, win)
sessionChatWindowSources.set(normalizedSessionId, normalizedSource)
return win
}
@@ -841,11 +995,14 @@ function registerIpcHandlers() {
})
ipcMain.handle('config:set', async (_, key: string, value: any) => {
return configService?.set(key as any, value)
const result = configService?.set(key as any, value)
void messagePushService.handleConfigChanged(key)
return result
})
ipcMain.handle('config:clear', async () => {
configService?.clear()
messagePushService.handleConfigCleared()
return true
})
@@ -900,6 +1057,17 @@ function registerIpcHandlers() {
}
})
ipcMain.handle('log:clear', async () => {
try {
const logPath = join(app.getPath('userData'), 'logs', 'wcdb.log')
await mkdir(dirname(logPath), { recursive: true })
await writeFile(logPath, '', 'utf8')
return { success: true }
} catch (e) {
return { success: false, error: String(e) }
}
})
ipcMain.handle('diagnostics:getExportCardLogs', async (_, options?: { limit?: number }) => {
return exportCardDiagnosticsService.snapshot(options?.limit)
})
@@ -1039,10 +1207,42 @@ function registerIpcHandlers() {
}
})
ipcMain.handle('window:isMaximized', (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
return Boolean(win?.isMaximized() || win?.isFullScreen())
})
ipcMain.on('window:close', (event) => {
BrowserWindow.fromWebContents(event.sender)?.close()
})
ipcMain.handle('window:respondCloseConfirm', async (_event, action: 'tray' | 'quit' | 'cancel') => {
if (!mainWindow || mainWindow.isDestroyed()) {
isClosePromptVisible = false
return false
}
try {
if (action === 'tray') {
if (tray) {
mainWindow.hide()
return true
}
return false
}
if (action === 'quit') {
isAppQuitting = true
app.quit()
return true
}
return true
} finally {
isClosePromptVisible = false
}
})
// 更新窗口控件主题色
ipcMain.on('window:setTitleBarOverlay', (event, options: { symbolColor: string }) => {
const win = BrowserWindow.fromWebContents(event.sender)
@@ -1070,9 +1270,26 @@ function registerIpcHandlers() {
return true
})
ipcMain.handle('window:openChatHistoryPayloadWindow', (_, payload: { sessionId: string; title?: string; recordList: any[] }) => {
const payloadId = randomUUID()
chatHistoryPayloadStore.set(payloadId, {
sessionId: String(payload?.sessionId || '').trim(),
title: String(payload?.title || '').trim() || '聊天记录',
recordList: Array.isArray(payload?.recordList) ? payload.recordList : []
})
createChatHistoryPayloadWindow(payloadId)
return true
})
ipcMain.handle('window:getChatHistoryPayload', (_, payloadId: string) => {
const payload = chatHistoryPayloadStore.get(String(payloadId || '').trim())
if (!payload) return { success: false, error: '聊天记录载荷不存在或已失效' }
return { success: true, payload }
})
// 打开会话聊天窗口(同会话仅保留一个窗口并聚焦)
ipcMain.handle('window:openSessionChatWindow', (_, sessionId: string) => {
const win = createSessionChatWindow(sessionId)
ipcMain.handle('window:openSessionChatWindow', (_, sessionId: string, options?: OpenSessionChatWindowOptions) => {
const win = createSessionChatWindow(sessionId, options)
return Boolean(win)
})
@@ -1410,6 +1627,7 @@ function registerIpcHandlers() {
forceRefresh?: boolean
allowStaleCache?: boolean
preferAccurateSpecialTypes?: boolean
cacheOnly?: boolean
}) => {
return chatService.getExportSessionStats(sessionIds, options)
})
@@ -1443,7 +1661,7 @@ function registerIpcHandlers() {
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 })
event.sender.send('chat:voiceTranscriptPartial', { sessionId, msgId, createTime, text })
})
})
@@ -1451,8 +1669,8 @@ function registerIpcHandlers() {
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('chat:searchMessages', async (_, keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number) => {
return chatService.searchMessages(keyword, sessionId, limit, offset, beginTimestamp, endTimestamp)
})
ipcMain.handle('sns:getTimeline', async (_, limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => {
@@ -1463,6 +1681,10 @@ function registerIpcHandlers() {
return snsService.getSnsUsernames()
})
ipcMain.handle('sns:getUserPostCounts', async () => {
return snsService.getUserPostCounts()
})
ipcMain.handle('sns:getExportStats', async () => {
return snsService.getExportStats()
})
@@ -1471,6 +1693,10 @@ function registerIpcHandlers() {
return snsService.getExportStatsFast()
})
ipcMain.handle('sns:getUserPostStats', async (_, username: string) => {
return snsService.getUserPostStats(username)
})
ipcMain.handle('sns:debugResource', async (_, url: string) => {
return snsService.debugResource(url)
})
@@ -1658,7 +1884,83 @@ function registerIpcHandlers() {
}
}
return exportService.exportSessions(sessionIds, outputDir, options, onProgress)
const runMainFallback = async (reason: string) => {
console.warn(`[fallback-export-main] ${reason}`)
return exportService.exportSessions(sessionIds, outputDir, options, onProgress)
}
const cfg = configService || new ConfigService()
configService = cfg
const logEnabled = cfg.get('logEnabled')
const resourcesPath = app.isPackaged
? join(process.resourcesPath, 'resources')
: join(app.getAppPath(), 'resources')
const userDataPath = app.getPath('userData')
const workerPath = join(__dirname, 'exportWorker.js')
const runWorker = async () => {
return await new Promise<any>((resolve, reject) => {
const worker = new Worker(workerPath, {
workerData: {
sessionIds,
outputDir,
options,
resourcesPath,
userDataPath,
logEnabled
}
})
let settled = false
const finalizeResolve = (value: any) => {
if (settled) return
settled = true
worker.removeAllListeners()
void worker.terminate()
resolve(value)
}
const finalizeReject = (error: Error) => {
if (settled) return
settled = true
worker.removeAllListeners()
void worker.terminate()
reject(error)
}
worker.on('message', (msg: any) => {
if (msg && msg.type === 'export:progress') {
onProgress(msg.data as ExportProgress)
return
}
if (msg && msg.type === 'export:result') {
finalizeResolve(msg.data)
return
}
if (msg && msg.type === 'export:error') {
finalizeReject(new Error(String(msg.error || '导出 Worker 执行失败')))
}
})
worker.on('error', (error) => {
finalizeReject(error instanceof Error ? error : new Error(String(error)))
})
worker.on('exit', (code) => {
if (settled) return
if (code === 0) {
finalizeResolve({ success: false, successCount: 0, failCount: 0, error: '导出 Worker 未返回结果' })
} else {
finalizeReject(new Error(`导出 Worker 异常退出: ${code}`))
}
})
})
}
try {
return await runWorker()
} catch (error) {
return runMainFallback(error instanceof Error ? error.message : String(error))
}
})
ipcMain.handle('export:exportSession', async (_, sessionId: string, outputPath: string, options: ExportOptions) => {
@@ -1769,6 +2071,18 @@ function registerIpcHandlers() {
return groupAnalyticsService.getGroupMediaStats(chatroomId, startTime, endTime)
})
ipcMain.handle(
'groupAnalytics:getGroupMemberMessages',
async (
_,
chatroomId: string,
memberUsername: string,
options?: { startTime?: number; endTime?: number; limit?: number; cursor?: number }
) => {
return groupAnalyticsService.getGroupMemberMessages(chatroomId, memberUsername, options)
}
)
ipcMain.handle('groupAnalytics:exportGroupMembers', async (_, chatroomId: string, outputPath: string) => {
return groupAnalyticsService.exportGroupMembers(chatroomId, outputPath)
})
@@ -1929,7 +2243,6 @@ function registerIpcHandlers() {
dbPath,
decryptKey,
wxid,
nativeTimeoutMs: 5000,
onProgress: (progress) => {
if (isYearsLoadCanceled(taskId)) return
const snapshot = updateTaskSnapshot({
@@ -2317,6 +2630,10 @@ app.whenReady().then(async () => {
// 注册 IPC 处理器
updateSplashProgress(25, '正在初始化...')
registerIpcHandlers()
chatService.addDbMonitorListener((type, json) => {
messagePushService.handleDbMonitorChange(type, json)
})
messagePushService.start()
await delay(200)
// 检查配置状态
@@ -2327,6 +2644,63 @@ app.whenReady().then(async () => {
updateSplashProgress(30, '正在加载界面...')
mainWindow = createWindow({ autoShow: false })
let iconName = 'icon.ico';
if (process.platform === 'linux') {
iconName = 'icon.png';
} else if (process.platform === 'darwin') {
iconName = 'icon.icns';
}
const isDev = !!process.env.VITE_DEV_SERVER_URL
const resolvedTrayIcon = isDev
? join(__dirname, `../public/${iconName}`)
: join(process.resourcesPath, iconName);
try {
tray = new Tray(resolvedTrayIcon)
tray.setToolTip('WeFlow')
const contextMenu = Menu.buildFromTemplate([
{
label: '显示主窗口',
click: () => {
if (mainWindow) {
mainWindow.show()
mainWindow.focus()
}
}
},
{ type: 'separator' },
{
label: '退出',
click: () => {
isAppQuitting = true
app.quit()
}
}
])
tray.setContextMenu(contextMenu)
tray.on('click', () => {
if (mainWindow) {
if (mainWindow.isVisible()) {
mainWindow.focus()
} else {
mainWindow.show()
mainWindow.focus()
}
}
})
tray.on('double-click', () => {
if (mainWindow) {
mainWindow.show()
mainWindow.focus()
}
})
} catch (e) {
console.warn('[Tray] Failed to create tray icon:', e)
}
// 配置网络服务
session.defaultSession.webRequest.onBeforeSendHeaders(
{
@@ -2373,10 +2747,21 @@ app.whenReady().then(async () => {
})
app.on('before-quit', async () => {
isAppQuitting = true
// 销毁 tray 图标
if (tray) { try { tray.destroy() } catch {} tray = null }
// 通知窗使用 hide 而非 close退出时主动销毁避免残留窗口阻塞进程退出。
destroyNotificationWindow()
// 兜底5秒后强制退出防止某个异步任务卡住导致进程残留
const forceExitTimer = setTimeout(() => {
console.warn('[App] Force exit after timeout')
app.exit(0)
}, 5000)
forceExitTimer.unref()
// 停止 HTTP 服务器,释放 TCP 端口占用,避免进程无法退出
try { await httpService.stop() } catch {}
// 终止 wcdb Worker 线程,避免线程阻止进程退出
try { wcdbService.shutdown() } catch {}
try { await wcdbService.shutdown() } catch {}
})
app.on('window-all-closed', () => {

View File

@@ -70,6 +70,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
log: {
getPath: () => ipcRenderer.invoke('log:getPath'),
read: () => ipcRenderer.invoke('log:read'),
clear: () => ipcRenderer.invoke('log:clear'),
debug: (data: any) => ipcRenderer.send('log:debug', data)
},
@@ -86,7 +87,20 @@ contextBridge.exposeInMainWorld('electronAPI', {
window: {
minimize: () => ipcRenderer.send('window:minimize'),
maximize: () => ipcRenderer.send('window:maximize'),
isMaximized: () => ipcRenderer.invoke('window:isMaximized'),
onMaximizeStateChanged: (callback: (isMaximized: boolean) => void) => {
const listener = (_: unknown, isMaximized: boolean) => callback(isMaximized)
ipcRenderer.on('window:maximizeStateChanged', listener)
return () => ipcRenderer.removeListener('window:maximizeStateChanged', listener)
},
close: () => ipcRenderer.send('window:close'),
onCloseConfirmRequested: (callback: (payload: { canMinimizeToTray: boolean }) => void) => {
const listener = (_: unknown, payload: { canMinimizeToTray: boolean }) => callback(payload)
ipcRenderer.on('window:confirmCloseRequested', listener)
return () => ipcRenderer.removeListener('window:confirmCloseRequested', listener)
},
respondCloseConfirm: (action: 'tray' | 'quit' | 'cancel') =>
ipcRenderer.invoke('window:respondCloseConfirm', action),
openAgreementWindow: () => ipcRenderer.invoke('window:openAgreementWindow'),
completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'),
openOnboardingWindow: () => ipcRenderer.invoke('window:openOnboardingWindow'),
@@ -99,8 +113,20 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke('window:openImageViewerWindow', imagePath, liveVideoPath),
openChatHistoryWindow: (sessionId: string, messageId: number) =>
ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId),
openSessionChatWindow: (sessionId: string) =>
ipcRenderer.invoke('window:openSessionChatWindow', sessionId)
openChatHistoryPayloadWindow: (payload: { sessionId: string; title?: string; recordList: any[] }) =>
ipcRenderer.invoke('window:openChatHistoryPayloadWindow', payload),
getChatHistoryPayload: (payloadId: string) =>
ipcRenderer.invoke('window:getChatHistoryPayload', payloadId),
openSessionChatWindow: (
sessionId: string,
options?: {
source?: 'chat' | 'export'
initialDisplayName?: string
initialAvatarUrl?: string
initialContactType?: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
}
) =>
ipcRenderer.invoke('window:openSessionChatWindow', sessionId, options)
},
// 数据库路径
@@ -174,7 +200,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
getSessionDetailExtra: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetailExtra', sessionId),
getExportSessionStats: (
sessionIds: string[],
options?: { includeRelations?: boolean; forceRefresh?: boolean; allowStaleCache?: boolean; preferAccurateSpecialTypes?: boolean }
options?: {
includeRelations?: boolean
forceRefresh?: boolean
allowStaleCache?: boolean
preferAccurateSpecialTypes?: boolean
cacheOnly?: boolean
}
) => ipcRenderer.invoke('chat:getExportSessionStats', sessionIds, options),
getGroupMyMessageCountHint: (chatroomId: string) =>
ipcRenderer.invoke('chat:getGroupMyMessageCountHint', chatroomId),
@@ -187,16 +219,16 @@ contextBridge.exposeInMainWorld('electronAPI', {
getMessageDateCounts: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDateCounts', sessionId),
resolveVoiceCache: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:resolveVoiceCache', sessionId, msgId),
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId, createTime),
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => {
const listener = (_: any, payload: { msgId: string; text: string }) => callback(payload)
onVoiceTranscriptPartial: (callback: (payload: { sessionId?: string; msgId: string; createTime?: number; text: string }) => void) => {
const listener = (_: any, payload: { sessionId?: string; msgId: string; createTime?: number; 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),
searchMessages: (keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number) =>
ipcRenderer.invoke('chat:searchMessages', keyword, sessionId, limit, offset, beginTimestamp, endTimestamp),
onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => {
ipcRenderer.on('wcdb-change', callback)
return () => ipcRenderer.removeListener('wcdb-change', callback)
@@ -214,12 +246,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
preload: (payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>) =>
ipcRenderer.invoke('image:preload', payloads),
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => {
ipcRenderer.on('image:updateAvailable', (_, payload) => callback(payload))
return () => ipcRenderer.removeAllListeners('image:updateAvailable')
const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => callback(payload)
ipcRenderer.on('image:updateAvailable', listener)
return () => ipcRenderer.removeListener('image:updateAvailable', listener)
},
onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => {
ipcRenderer.on('image:cacheResolved', (_, payload) => callback(payload))
return () => ipcRenderer.removeAllListeners('image:cacheResolved')
const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => callback(payload)
ipcRenderer.on('image:cacheResolved', listener)
return () => ipcRenderer.removeListener('image:cacheResolved', listener)
}
},
@@ -262,6 +296,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMessageRanking', chatroomId, limit, startTime, endTime),
getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime),
getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime),
getGroupMemberMessages: (
chatroomId: string,
memberUsername: string,
options?: { startTime?: number; endTime?: number; limit?: number; cursor?: number }
) => ipcRenderer.invoke('groupAnalytics:getGroupMemberMessages', chatroomId, memberUsername, options),
exportGroupMembers: (chatroomId: string, outputPath: string) => ipcRenderer.invoke('groupAnalytics:exportGroupMembers', chatroomId, outputPath),
exportGroupMemberMessages: (chatroomId: string, memberUsername: string, outputPath: string, startTime?: number, endTime?: number) =>
ipcRenderer.invoke('groupAnalytics:exportGroupMemberMessages', chatroomId, memberUsername, outputPath, startTime, endTime)
@@ -317,7 +356,20 @@ contextBridge.exposeInMainWorld('electronAPI', {
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; currentSessionId?: string; phase: string }) => void) => {
onProgress: (callback: (payload: {
current: number
total: number
currentSession: string
currentSessionId?: string
phase: string
phaseProgress?: number
phaseTotal?: number
phaseLabel?: string
collectedMessages?: number
exportedMessages?: number
estimatedTotalMessages?: number
writtenFiles?: number
}) => void) => {
ipcRenderer.on('export:progress', (_, payload) => callback(payload))
return () => ipcRenderer.removeAllListeners('export:progress')
}
@@ -339,8 +391,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) =>
ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime),
getSnsUsernames: () => ipcRenderer.invoke('sns:getSnsUsernames'),
getUserPostCounts: () => ipcRenderer.invoke('sns:getUserPostCounts'),
getExportStatsFast: () => ipcRenderer.invoke('sns:getExportStatsFast'),
getExportStats: () => ipcRenderer.invoke('sns:getExportStats'),
getUserPostStats: (username: string) => ipcRenderer.invoke('sns:getUserPostStats', username),
debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url),
proxyImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:proxyImage', payload),
downloadImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:downloadImage', payload),

View File

@@ -68,29 +68,14 @@ class AnalyticsService {
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
// C++ 层不支持参数绑定,直接内联转义后的字符串值
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(',')
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
}
}
const result = await wcdbService.getContactAliasMap(usernames)
if (!result.success || !result.map) return map
for (const [username, alias] of Object.entries(result.map)) {
if (username && alias) map[username] = alias
}
return map

View File

@@ -278,16 +278,16 @@ class AnnualReportService {
return cached || null
}
const result = await wcdbService.execQuery('message', dbPath, `PRAGMA table_info(${this.quoteSqlIdentifier(tableName)})`)
if (!result.success || !Array.isArray(result.rows) || result.rows.length === 0) {
const result = await wcdbService.getMessageTableColumns(dbPath, tableName)
if (!result.success || !Array.isArray(result.columns) || result.columns.length === 0) {
this.availableYearsColumnCache.set(cacheKey, '')
return null
}
const candidates = ['create_time', 'createtime', 'msg_create_time', 'msg_time', 'msgtime', 'time']
const columns = new Set<string>()
for (const row of result.rows as Record<string, any>[]) {
const name = String(row.name || row.column_name || row.columnName || '').trim().toLowerCase()
for (const columnName of result.columns) {
const name = String(columnName || '').trim().toLowerCase()
if (name) columns.add(name)
}
@@ -309,10 +309,11 @@ class AnnualReportService {
const tried = new Set<string>()
const queryByColumn = async (column: string): Promise<{ first: number; last: number } | null> => {
const sql = `SELECT MIN(${this.quoteSqlIdentifier(column)}) AS first_ts, MAX(${this.quoteSqlIdentifier(column)}) AS last_ts FROM ${this.quoteSqlIdentifier(tableName)}`
const result = await wcdbService.execQuery('message', dbPath, sql)
if (!result.success || !Array.isArray(result.rows) || result.rows.length === 0) return null
const row = result.rows[0] as Record<string, any>
const result = await wcdbService.getMessageTableTimeRange(dbPath, tableName)
if (!result.success || !result.data) return null
const row = result.data as Record<string, any>
const actualColumn = String(row.column || '').trim().toLowerCase()
if (column && actualColumn && column.toLowerCase() !== actualColumn) return null
const first = this.toUnixTimestamp(row.first_ts ?? row.firstTs ?? row.min_ts ?? row.minTs)
const last = this.toUnixTimestamp(row.last_ts ?? row.lastTs ?? row.max_ts ?? row.maxTs)
return { first, last }

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,7 @@ class CloudControlService {
private deviceId: string = ''
private timer: NodeJS.Timeout | null = null
private pages: Set<string> = new Set()
private platformVersionCache: string | null = null
async init() {
this.deviceId = this.getDeviceId()
@@ -47,7 +48,12 @@ class CloudControlService {
}
private getPlatformVersion(): string {
if (this.platformVersionCache) {
return this.platformVersionCache
}
const os = require('os')
const fs = require('fs')
const platform = process.platform
if (platform === 'win32') {
@@ -59,14 +65,79 @@ class CloudControlService {
// Windows 11 是 10.0.22000+,且主版本必须是 10.0
if (major === 10 && minor === 0 && build >= 22000) {
return 'Windows 11'
this.platformVersionCache = 'Windows 11'
return this.platformVersionCache
} else if (major === 10) {
return 'Windows 10'
this.platformVersionCache = 'Windows 10'
return this.platformVersionCache
}
return `Windows ${release}`
this.platformVersionCache = `Windows ${release}`
return this.platformVersionCache
}
return platform
if (platform === 'darwin') {
// `os.release()` returns Darwin kernel version (e.g. 25.3.0),
// while cloud reporting expects the macOS product version (e.g. 26.3).
const macVersion = typeof process.getSystemVersion === 'function' ? process.getSystemVersion() : os.release()
this.platformVersionCache = `macOS ${macVersion}`
return this.platformVersionCache
}
if (platform === 'linux') {
try {
const osReleasePaths = ['/etc/os-release', '/usr/lib/os-release']
for (const filePath of osReleasePaths) {
if (!fs.existsSync(filePath)) {
continue
}
const content = fs.readFileSync(filePath, 'utf8')
const values: Record<string, string> = {}
for (const line of content.split('\n')) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) {
continue
}
const separatorIndex = trimmed.indexOf('=')
if (separatorIndex <= 0) {
continue
}
const key = trimmed.slice(0, separatorIndex)
let value = trimmed.slice(separatorIndex + 1).trim()
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\''))) {
value = value.slice(1, -1)
}
values[key] = value
}
if (values.PRETTY_NAME) {
this.platformVersionCache = values.PRETTY_NAME
return this.platformVersionCache
}
if (values.NAME && values.VERSION_ID) {
this.platformVersionCache = `${values.NAME} ${values.VERSION_ID}`
return this.platformVersionCache
}
if (values.NAME) {
this.platformVersionCache = values.NAME
return this.platformVersionCache
}
}
} catch (error) {
console.warn('[CloudControl] Failed to detect Linux distro version:', error)
}
this.platformVersionCache = `Linux ${os.release()}`
return this.platformVersionCache
}
this.platformVersionCache = platform
return this.platformVersionCache
}
recordPage(pageName: string) {
@@ -88,4 +159,3 @@ class CloudControlService {
export const cloudControlService = new CloudControlService()

View File

@@ -16,7 +16,7 @@ interface ConfigSchema {
imageXorKey: number
imageAesKey: string
wxidConfigs: Record<string, { decryptKey?: string; imageXorKey?: number; imageAesKey?: string; updatedAt?: number }>
exportPath?: string;
// 缓存相关
cachePath: string
lastOpenedDb: string
@@ -47,9 +47,11 @@ interface ConfigSchema {
// 通知
notificationEnabled: boolean
notificationPosition: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'
notificationPosition: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'
notificationFilterMode: 'all' | 'whitelist' | 'blacklist'
notificationFilterList: string[]
messagePushEnabled: boolean
windowCloseBehavior: 'ask' | 'tray' | 'quit'
wordCloudExcludeWords: string[]
}
@@ -82,43 +84,71 @@ export class ConfigService {
return ConfigService.instance
}
ConfigService.instance = this
this.store = new Store<ConfigSchema>({
const defaults: ConfigSchema = {
dbPath: '',
decryptKey: '',
myWxid: '',
onboardingDone: false,
imageXorKey: 0,
imageAesKey: '',
wxidConfigs: {},
cachePath: '',
lastOpenedDb: '',
lastSession: '',
theme: 'system',
themeId: 'cloud-dancer',
language: 'zh-CN',
logEnabled: false,
llmModelPath: '',
whisperModelName: 'base',
whisperModelDir: '',
whisperDownloadSource: 'tsinghua',
autoTranscribeVoice: false,
transcribeLanguages: ['zh'],
exportDefaultConcurrency: 4,
analyticsExcludedUsernames: [],
authEnabled: false,
authPassword: '',
authUseHello: false,
authHelloSecret: '',
ignoredUpdateVersion: '',
notificationEnabled: true,
notificationPosition: 'top-right',
notificationFilterMode: 'all',
notificationFilterList: [],
messagePushEnabled: false,
windowCloseBehavior: 'ask',
wordCloudExcludeWords: []
}
const storeOptions: any = {
name: 'WeFlow-config',
defaults: {
dbPath: '',
decryptKey: '',
myWxid: '',
onboardingDone: false,
imageXorKey: 0,
imageAesKey: '',
wxidConfigs: {},
cachePath: '',
lastOpenedDb: '',
lastSession: '',
theme: 'system',
themeId: 'cloud-dancer',
language: 'zh-CN',
logEnabled: false,
llmModelPath: '',
whisperModelName: 'base',
whisperModelDir: '',
whisperDownloadSource: 'tsinghua',
autoTranscribeVoice: false,
transcribeLanguages: ['zh'],
exportDefaultConcurrency: 4,
analyticsExcludedUsernames: [],
authEnabled: false,
authPassword: '',
authUseHello: false,
authHelloSecret: '',
ignoredUpdateVersion: '',
notificationEnabled: true,
notificationPosition: 'top-right',
notificationFilterMode: 'all',
notificationFilterList: [],
wordCloudExcludeWords: []
defaults,
projectName: String(process.env.WEFLOW_PROJECT_NAME || 'WeFlow').trim() || 'WeFlow'
}
const runningInWorker = process.env.WEFLOW_WORKER === '1'
if (runningInWorker) {
const cwd = String(process.env.WEFLOW_CONFIG_CWD || process.env.WEFLOW_USER_DATA_PATH || '').trim()
if (cwd) {
storeOptions.cwd = cwd
}
})
}
try {
this.store = new Store<ConfigSchema>(storeOptions)
} catch (error) {
const message = String((error as Error)?.message || error || '')
if (message.includes('projectName')) {
const fallbackOptions = {
...storeOptions,
projectName: 'WeFlow',
cwd: storeOptions.cwd || process.env.WEFLOW_CONFIG_CWD || process.env.WEFLOW_USER_DATA_PATH || process.cwd()
}
this.store = new Store<ConfigSchema>(fallbackOptions)
} else {
throw error
}
}
this.migrateAuthFields()
}

View File

@@ -1,13 +1,90 @@
import { join, basename } from 'path'
import { existsSync, readdirSync, statSync } from 'fs'
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
import { homedir } from 'os'
import { createDecipheriv } from 'crypto'
export interface WxidInfo {
wxid: string
modifiedTime: number
nickname?: string
avatarUrl?: string
}
export class DbPathService {
private readVarint(buf: Buffer, offset: number): { value: number, length: number } {
let value = 0;
let length = 0;
let shift = 0;
while (offset < buf.length && shift < 32) {
const b = buf[offset++];
value |= (b & 0x7f) << shift;
length++;
if ((b & 0x80) === 0) break;
shift += 7;
}
return { value, length };
}
private extractMmkvString(buf: Buffer, keyName: string): string {
const keyBuf = Buffer.from(keyName, 'utf8');
const idx = buf.indexOf(keyBuf);
if (idx === -1) return '';
try {
let offset = idx + keyBuf.length;
const v1 = this.readVarint(buf, offset);
offset += v1.length;
const v2 = this.readVarint(buf, offset);
offset += v2.length;
// 合理性检查
if (v2.value > 0 && v2.value <= 10000 && offset + v2.value <= buf.length) {
return buf.toString('utf8', offset, offset + v2.value);
}
} catch { }
return '';
}
private parseGlobalConfig(rootPath: string): { wxid: string, nickname: string, avatarUrl: string } | null {
try {
const configPath = join(rootPath, 'all_users', 'config', 'global_config');
if (!existsSync(configPath)) return null;
const fullData = readFileSync(configPath);
if (fullData.length <= 4) return null;
const encryptedData = fullData.subarray(4);
const key = Buffer.alloc(16, 0);
Buffer.from('xwechat_crypt_key').copy(key); // 直接硬编码iv更是不重要
const iv = Buffer.alloc(16, 0);
const decipher = createDecipheriv('aes-128-cfb', key, iv);
decipher.setAutoPadding(false);
const decrypted = Buffer.concat([decipher.update(encryptedData), decipher.final()]);
const wxid = this.extractMmkvString(decrypted, 'mmkv_key_user_name');
const nickname = this.extractMmkvString(decrypted, 'mmkv_key_nick_name');
let avatarUrl = this.extractMmkvString(decrypted, 'mmkv_key_head_img_url');
if (!avatarUrl && decrypted.includes('http')) {
const httpIdx = decrypted.indexOf('http');
const nullIdx = decrypted.indexOf(0x00, httpIdx);
if (nullIdx !== -1) {
avatarUrl = decrypted.toString('utf8', httpIdx, nullIdx);
}
}
if (wxid || nickname) {
return { wxid, nickname, avatarUrl };
}
return null;
} catch (e) {
console.error('解析 global_config 失败:', e);
return null;
}
}
/**
* 自动检测微信数据库根目录
*/
@@ -16,8 +93,13 @@ export class DbPathService {
const possiblePaths: string[] = []
const home = homedir()
// 微信4.x 数据目录
possiblePaths.push(join(home, 'Documents', 'xwechat_files'))
// macOS 微信路径(固定)
if (process.platform === 'darwin') {
possiblePaths.push(join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'xwechat_files'))
} else {
// Windows 微信4.x 数据目录
possiblePaths.push(join(home, 'Documents', 'xwechat_files'))
}
for (const path of possiblePaths) {
@@ -130,21 +212,16 @@ export class DbPathService {
for (const entry of entries) {
const entryPath = join(rootPath, entry)
let stat: ReturnType<typeof statSync>
try {
stat = statSync(entryPath)
} catch {
continue
}
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') {
@@ -154,12 +231,25 @@ export class DbPathService {
}
} catch { }
return wxids.sort((a, b) => {
const sorted = wxids.sort((a, b) => {
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
return a.wxid.localeCompare(b.wxid)
})
});
const globalInfo = this.parseGlobalConfig(rootPath);
if (globalInfo) {
for (const w of sorted) {
if (w.wxid.startsWith(globalInfo.wxid) || sorted.length === 1) {
w.nickname = globalInfo.nickname;
w.avatarUrl = globalInfo.avatarUrl;
}
}
}
return sorted;
}
/**
* 扫描 wxid 列表
*/
@@ -182,10 +272,21 @@ export class DbPathService {
}
} catch { }
return wxids.sort((a, b) => {
const sorted = wxids.sort((a, b) => {
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
return a.wxid.localeCompare(b.wxid)
})
});
const globalInfo = this.parseGlobalConfig(rootPath);
if (globalInfo) {
for (const w of sorted) {
if (w.wxid.startsWith(globalInfo.wxid) || sorted.length === 1) {
w.nickname = globalInfo.nickname;
w.avatarUrl = globalInfo.avatarUrl;
}
}
}
return sorted;
}
/**
@@ -193,6 +294,9 @@ export class DbPathService {
*/
getDefaultPath(): string {
const home = homedir()
if (process.platform === 'darwin') {
return join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'xwechat_files')
}
return join(home, 'Documents', 'xwechat_files')
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -49,6 +49,12 @@ export interface GroupMediaStats {
total: number
}
export interface GroupMemberMessagesPage {
messages: Message[]
hasMore: boolean
nextCursor: number
}
interface GroupMemberContactInfo {
remark: string
nickName: string
@@ -224,10 +230,9 @@ class GroupAnalyticsService {
}
try {
const escapedChatroomId = chatroomId.replace(/'/g, "''")
const roomResult = await wcdbService.execQuery('contact', null, `SELECT * FROM chat_room WHERE username='${escapedChatroomId}' LIMIT 1`)
if (roomResult.success && roomResult.rows && roomResult.rows.length > 0) {
const owner = tryResolve(roomResult.rows[0])
const roomExt = await wcdbService.getChatRoomExtBuffer(chatroomId)
if (roomExt.success && roomExt.extBuffer) {
const owner = tryResolve({ ext_buffer: roomExt.extBuffer })
if (owner) return owner
}
} catch {
@@ -255,20 +260,46 @@ class GroupAnalyticsService {
* 从 DLL 获取群成员的群昵称
*/
private async getGroupNicknamesForRoom(chatroomId: string, candidates: string[] = []): Promise<Map<string, string>> {
const nicknameMap = new 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 dllResult = await wcdbService.getGroupNicknames(chatroomId)
if (dllResult.success && dllResult.nicknames) {
this.mergeGroupNicknameEntries(nicknameMap, Object.entries(dllResult.nicknames))
}
} catch (e) {
console.error('getGroupNicknamesForRoom dll error:', e)
}
try {
const result = await wcdbService.getChatRoomExtBuffer(chatroomId)
if (!result.success || !result.extBuffer) {
return nicknameMap
}
const extBuffer = this.decodeExtBuffer((result.rows[0] as any).ext_buffer)
if (!extBuffer) return new Map<string, string>()
return this.parseGroupNicknamesFromExtBuffer(extBuffer, candidates)
const extBuffer = this.decodeExtBuffer(result.extBuffer)
if (!extBuffer) return nicknameMap
this.mergeGroupNicknameEntries(nicknameMap, this.parseGroupNicknamesFromExtBuffer(extBuffer, candidates).entries())
return nicknameMap
} catch (e) {
console.error('getGroupNicknamesForRoom error:', e)
return new Map<string, string>()
return nicknameMap
}
}
private mergeGroupNicknameEntries(
target: Map<string, string>,
entries: Iterable<[string, string]>
): void {
for (const [memberIdRaw, nicknameRaw] of entries) {
const nickname = this.normalizeGroupNickname(nicknameRaw || '')
if (!nickname) continue
for (const alias of this.buildIdCandidates([memberIdRaw])) {
if (!alias) continue
if (!target.has(alias)) target.set(alias, nickname)
const lower = alias.toLowerCase()
if (!target.has(lower)) target.set(lower, nickname)
}
}
}
@@ -550,19 +581,9 @@ class GroupAnalyticsService {
const batch = candidates.slice(i, i + batchSize)
if (batch.length === 0) continue
const inList = batch.map((username) => `'${username.replace(/'/g, "''")}'`).join(',')
const lightweightSql = `
SELECT username, user_name, encrypt_username, encrypt_user_name, remark, nick_name, alias, local_type
FROM contact
WHERE username IN (${inList})
`
let result = await wcdbService.execQuery('contact', null, lightweightSql)
if (!result.success || !result.rows) {
// 兼容历史/变体列名,轻查询失败时回退全字段查询,避免好友标识丢失
result = await wcdbService.execQuery('contact', null, `SELECT * FROM contact WHERE username IN (${inList})`)
}
if (!result.success || !result.rows) continue
appendContactsToLookup(result.rows as Record<string, unknown>[])
const result = await wcdbService.getContactsCompact(batch)
if (!result.success || !result.contacts) continue
appendContactsToLookup(result.contacts as Record<string, unknown>[])
}
return lookup
}
@@ -741,36 +762,246 @@ class GroupAnalyticsService {
return ''
}
private normalizeCursorTimestamp(value: number): number {
if (!Number.isFinite(value) || value <= 0) return 0
const normalized = Math.floor(value)
return normalized > 10000000000 ? Math.floor(normalized / 1000) : normalized
}
private extractRowSenderUsername(row: Record<string, any>): string {
const candidates = [
row.sender_username,
row.senderUsername,
row.sender,
row.WCDB_CT_sender_username
]
for (const candidate of candidates) {
const value = String(candidate || '').trim()
if (value) return value
}
for (const [key, value] of Object.entries(row)) {
const normalizedKey = key.toLowerCase()
if (
normalizedKey === 'sender_username' ||
normalizedKey === 'senderusername' ||
normalizedKey === 'sender' ||
normalizedKey === 'wcdb_ct_sender_username'
) {
const normalizedValue = String(value || '').trim()
if (normalizedValue) return normalizedValue
}
}
return ''
}
private parseSingleMessageRow(row: Record<string, any>): Message | null {
try {
const mapped = chatService.mapRowsToMessagesForApi([row])
return Array.isArray(mapped) && mapped.length > 0 ? mapped[0] : null
} catch {
return null
}
}
private async openMemberMessageCursor(
chatroomId: string,
batchSize: number,
ascending: boolean,
startTime: number,
endTime: number
): Promise<{ success: boolean; cursor?: number; error?: string }> {
const beginTimestamp = this.normalizeCursorTimestamp(startTime)
const endTimestamp = this.normalizeCursorTimestamp(endTime)
const liteResult = await wcdbService.openMessageCursorLite(chatroomId, batchSize, ascending, beginTimestamp, endTimestamp)
if (liteResult.success && liteResult.cursor) return liteResult
return wcdbService.openMessageCursor(chatroomId, batchSize, ascending, beginTimestamp, endTimestamp)
}
private async collectMessagesByMember(
chatroomId: string,
memberUsername: string,
startTime: number,
endTime: number
): Promise<{ success: boolean; data?: Message[]; error?: string }> {
const batchSize = 500
const batchSize = 800
const matchedMessages: Message[] = []
let offset = 0
const senderMatchCache = new Map<string, boolean>()
const matchesTargetSender = (sender: string | null | undefined): boolean => {
const key = String(sender || '').trim().toLowerCase()
if (!key) return false
const cached = senderMatchCache.get(key)
if (typeof cached === 'boolean') return cached
const matched = this.isSameAccountIdentity(memberUsername, sender)
senderMatchCache.set(key, matched)
return matched
}
while (true) {
const batch = await chatService.getMessages(chatroomId, offset, batchSize, startTime, endTime, true)
if (!batch.success || !batch.messages) {
return { success: false, error: batch.error || '获取群消息失败' }
}
const cursorResult = await this.openMemberMessageCursor(chatroomId, batchSize, true, startTime, endTime)
if (!cursorResult.success || !cursorResult.cursor) {
return { success: false, error: cursorResult.error || '创建群消息游标失败' }
}
for (const message of batch.messages) {
if (this.isSameAccountIdentity(memberUsername, message.senderUsername)) {
matchedMessages.push(message)
const cursor = cursorResult.cursor
try {
while (true) {
const batch = await wcdbService.fetchMessageBatch(cursor)
if (!batch.success) {
return { success: false, error: batch.error || '获取群消息失败' }
}
}
const rows = Array.isArray(batch.rows) ? batch.rows as Record<string, any>[] : []
if (rows.length === 0) break
const fetchedCount = batch.messages.length
if (fetchedCount <= 0 || !batch.hasMore) break
offset += fetchedCount
for (const row of rows) {
const senderFromRow = this.extractRowSenderUsername(row)
if (senderFromRow && !matchesTargetSender(senderFromRow)) {
continue
}
const message = this.parseSingleMessageRow(row)
if (!message) continue
if (matchesTargetSender(message.senderUsername)) {
matchedMessages.push(message)
}
}
if (!batch.hasMore) break
}
} finally {
await wcdbService.closeMessageCursor(cursor)
}
return { success: true, data: matchedMessages }
}
async getGroupMemberMessages(
chatroomId: string,
memberUsername: string,
options?: { startTime?: number; endTime?: number; limit?: number; cursor?: number }
): Promise<{ success: boolean; data?: GroupMemberMessagesPage; error?: string }> {
try {
const conn = await this.ensureConnected()
if (!conn.success) return { success: false, error: conn.error }
const normalizedChatroomId = String(chatroomId || '').trim()
const normalizedMemberUsername = String(memberUsername || '').trim()
if (!normalizedChatroomId) return { success: false, error: '群聊ID不能为空' }
if (!normalizedMemberUsername) return { success: false, error: '成员ID不能为空' }
const startTimeValue = Number.isFinite(options?.startTime) && typeof options?.startTime === 'number'
? Math.max(0, Math.floor(options.startTime))
: 0
const endTimeValue = Number.isFinite(options?.endTime) && typeof options?.endTime === 'number'
? Math.max(0, Math.floor(options.endTime))
: 0
const limit = Number.isFinite(options?.limit) && typeof options?.limit === 'number'
? Math.max(1, Math.min(100, Math.floor(options.limit)))
: 50
let cursor = Number.isFinite(options?.cursor) && typeof options?.cursor === 'number'
? Math.max(0, Math.floor(options.cursor))
: 0
const matchedMessages: Message[] = []
const senderMatchCache = new Map<string, boolean>()
const matchesTargetSender = (sender: string | null | undefined): boolean => {
const key = String(sender || '').trim().toLowerCase()
if (!key) return false
const cached = senderMatchCache.get(key)
if (typeof cached === 'boolean') return cached
const matched = this.isSameAccountIdentity(normalizedMemberUsername, sender)
senderMatchCache.set(key, matched)
return matched
}
const batchSize = Math.max(limit * 4, 240)
let hasMore = false
const cursorResult = await this.openMemberMessageCursor(
normalizedChatroomId,
batchSize,
false,
startTimeValue,
endTimeValue
)
if (!cursorResult.success || !cursorResult.cursor) {
return { success: false, error: cursorResult.error || '创建群成员消息游标失败' }
}
let consumedRows = 0
const dbCursor = cursorResult.cursor
try {
while (matchedMessages.length < limit) {
const batch = await wcdbService.fetchMessageBatch(dbCursor)
if (!batch.success) {
return { success: false, error: batch.error || '获取群成员消息失败' }
}
const rows = Array.isArray(batch.rows) ? batch.rows as Record<string, any>[] : []
if (rows.length === 0) {
hasMore = false
break
}
let startIndex = 0
if (cursor > consumedRows) {
const skipCount = Math.min(cursor - consumedRows, rows.length)
consumedRows += skipCount
startIndex = skipCount
if (startIndex >= rows.length) {
if (!batch.hasMore) {
hasMore = false
break
}
continue
}
}
for (let index = startIndex; index < rows.length; index += 1) {
const row = rows[index]
consumedRows += 1
const senderFromRow = this.extractRowSenderUsername(row)
if (senderFromRow && !matchesTargetSender(senderFromRow)) {
continue
}
const message = this.parseSingleMessageRow(row)
if (!message) continue
if (!matchesTargetSender(message.senderUsername)) {
continue
}
matchedMessages.push(message)
if (matchedMessages.length >= limit) {
cursor = consumedRows
hasMore = index < rows.length - 1 || batch.hasMore === true
break
}
}
if (matchedMessages.length >= limit) break
cursor = consumedRows
if (!batch.hasMore) {
hasMore = false
break
}
}
} finally {
await wcdbService.closeMessageCursor(dbCursor)
}
return {
success: true,
data: {
messages: matchedMessages,
hasMore,
nextCursor: cursor
}
}
} catch (e) {
return { success: false, error: String(e) }
}
}
async getGroupChats(): Promise<{ success: boolean; data?: GroupChatInfo[]; error?: string }> {
try {
const conn = await this.ensureConnected()

View File

@@ -11,6 +11,7 @@ import { wcdbService } from './wcdbService'
import { ConfigService } from './config'
import { videoService } from './videoService'
import { imageDecryptService } from './imageDecryptService'
import { groupAnalyticsService } from './groupAnalyticsService'
// ChatLab 格式定义
interface ChatLabHeader {
@@ -102,6 +103,8 @@ class HttpService {
private port: number = 5031
private running: boolean = false
private connections: Set<import('net').Socket> = new Set()
private messagePushClients: Set<http.ServerResponse> = new Set()
private messagePushHeartbeatTimer: ReturnType<typeof setInterval> | null = null
private connectionMutex: boolean = false
constructor() {
@@ -152,6 +155,7 @@ class HttpService {
this.server.listen(this.port, '127.0.0.1', () => {
this.running = true
this.startMessagePushHeartbeat()
console.log(`[HttpService] HTTP API server started on http://127.0.0.1:${this.port}`)
resolve({ success: true, port: this.port })
})
@@ -164,6 +168,16 @@ class HttpService {
async stop(): Promise<void> {
return new Promise((resolve) => {
if (this.server) {
for (const client of this.messagePushClients) {
try {
client.end()
} catch {}
}
this.messagePushClients.clear()
if (this.messagePushHeartbeatTimer) {
clearInterval(this.messagePushHeartbeatTimer)
this.messagePushHeartbeatTimer = null
}
// 使用互斥锁保护连接集合操作
this.connectionMutex = true
const socketsToClose = Array.from(this.connections)
@@ -210,6 +224,28 @@ class HttpService {
return this.getApiMediaExportPath()
}
getMessagePushStreamUrl(): string {
return `http://127.0.0.1:${this.port}/api/v1/push/messages`
}
broadcastMessagePush(payload: Record<string, unknown>): void {
if (!this.running || this.messagePushClients.size === 0) return
const eventBody = `event: message.new\ndata: ${JSON.stringify(payload)}\n\n`
for (const client of Array.from(this.messagePushClients)) {
try {
if (client.writableEnded || client.destroyed) {
this.messagePushClients.delete(client)
continue
}
client.write(eventBody)
} catch {
this.messagePushClients.delete(client)
try { client.end() } catch {}
}
}
}
/**
* 处理 HTTP 请求
*/
@@ -232,12 +268,16 @@ class HttpService {
// 路由处理
if (pathname === '/health' || pathname === '/api/v1/health') {
this.sendJson(res, { status: 'ok' })
} else if (pathname === '/api/v1/push/messages') {
this.handleMessagePushStream(req, res)
} 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 if (pathname === '/api/v1/group-members') {
await this.handleGroupMembers(url, res)
} else if (pathname.startsWith('/api/v1/media/')) {
this.handleMediaRequest(pathname, res)
} else {
@@ -249,6 +289,50 @@ class HttpService {
}
}
private startMessagePushHeartbeat(): void {
if (this.messagePushHeartbeatTimer) return
this.messagePushHeartbeatTimer = setInterval(() => {
for (const client of Array.from(this.messagePushClients)) {
try {
if (client.writableEnded || client.destroyed) {
this.messagePushClients.delete(client)
continue
}
client.write(': ping\n\n')
} catch {
this.messagePushClients.delete(client)
try { client.end() } catch {}
}
}
}, 25000)
}
private handleMessagePushStream(req: http.IncomingMessage, res: http.ServerResponse): void {
if (this.configService.get('messagePushEnabled') !== true) {
this.sendError(res, 403, 'Message push is disabled')
return
}
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no'
})
res.flushHeaders?.()
res.write(`event: ready\ndata: ${JSON.stringify({ success: true, stream: this.getMessagePushStreamUrl() })}\n\n`)
this.messagePushClients.add(res)
const cleanup = () => {
this.messagePushClients.delete(res)
}
req.on('close', cleanup)
res.on('close', cleanup)
res.on('error', cleanup)
}
private handleMediaRequest(pathname: string, res: http.ServerResponse): void {
const mediaBasePath = this.getApiMediaExportPath()
const relativePath = pathname.replace('/api/v1/media/', '')
@@ -340,6 +424,7 @@ class HttpService {
const trimmedRows = allRows.slice(0, limit)
const finalHasMore = hasMore || allRows.length > limit
const messages = chatService.mapRowsToMessagesForApi(trimmedRows)
await this.backfillMissingSenderUsernames(talker, messages)
return { success: true, messages, hasMore: finalHasMore }
} finally {
await wcdbService.closeMessageCursor(cursor)
@@ -359,6 +444,41 @@ class HttpService {
return Math.min(Math.max(parsed, min), max)
}
private async backfillMissingSenderUsernames(talker: string, messages: Message[]): Promise<void> {
if (!talker.endsWith('@chatroom')) return
const targets = messages.filter((msg) => !String(msg.senderUsername || '').trim())
if (targets.length === 0) return
const myWxid = (this.configService.get('myWxid') || '').trim()
for (const msg of targets) {
const localId = Number(msg.localId || 0)
if (Number.isFinite(localId) && localId > 0) {
try {
const detail = await wcdbService.getMessageById(talker, localId)
if (detail.success && detail.message) {
const hydrated = chatService.mapRowsToMessagesForApi([detail.message])[0]
if (hydrated?.senderUsername) {
msg.senderUsername = hydrated.senderUsername
}
if ((msg.isSend === null || msg.isSend === undefined) && hydrated?.isSend !== undefined) {
msg.isSend = hydrated.isSend
}
if (!msg.rawContent && hydrated?.rawContent) {
msg.rawContent = hydrated.rawContent
}
}
} catch (error) {
console.warn('[HttpService] backfill sender failed:', error)
}
}
if (!msg.senderUsername && msg.isSend === 1 && myWxid) {
msg.senderUsername = myWxid
}
}
}
private parseBooleanParam(url: URL, keys: string[], defaultValue: boolean = false): boolean {
for (const key of keys) {
const raw = url.searchParams.get(key)
@@ -553,6 +673,54 @@ class HttpService {
}
}
/**
* 处理群成员查询
* GET /api/v1/group-members?chatroomId=xxx@chatroom&includeMessageCounts=1&forceRefresh=0
*/
private async handleGroupMembers(url: URL, res: http.ServerResponse): Promise<void> {
const chatroomId = (url.searchParams.get('chatroomId') || url.searchParams.get('talker') || '').trim()
const includeMessageCounts = this.parseBooleanParam(url, ['includeMessageCounts', 'withCounts'], false)
const forceRefresh = this.parseBooleanParam(url, ['forceRefresh'], false)
if (!chatroomId) {
this.sendError(res, 400, 'Missing chatroomId')
return
}
try {
const result = await groupAnalyticsService.getGroupMembersPanelData(chatroomId, {
forceRefresh,
includeMessageCounts
})
if (!result.success || !result.data) {
this.sendError(res, 500, result.error || 'Failed to get group members')
return
}
this.sendJson(res, {
success: true,
chatroomId,
count: result.data.length,
fromCache: result.fromCache,
updatedAt: result.updatedAt,
members: result.data.map((member) => ({
wxid: member.username,
displayName: member.displayName,
nickname: member.nickname || '',
remark: member.remark || '',
alias: member.alias || '',
groupNickname: member.groupNickname || '',
avatarUrl: member.avatarUrl,
isOwner: Boolean(member.isOwner),
isFriend: Boolean(member.isFriend),
messageCount: Number.isFinite(member.messageCount) ? member.messageCount : 0
}))
})
} catch (error) {
this.sendError(res, 500, String(error))
}
}
private getApiMediaExportPath(): string {
return path.join(this.configService.getCacheBasePath(), 'api-media')
}
@@ -762,6 +930,20 @@ class HttpService {
return 0
}
private normalizeAccountId(value: string): string {
const trimmed = String(value || '').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})$/)
return suffixMatch ? suffixMatch[1] : trimmed
}
/**
* 获取显示名称
*/
@@ -778,6 +960,110 @@ class HttpService {
return {}
}
private async getAvatarUrls(usernames: string[]): Promise<Record<string, string>> {
const lookupUsernames = Array.from(new Set(
usernames.flatMap((username) => {
const normalized = String(username || '').trim()
if (!normalized) return []
const cleaned = this.normalizeAccountId(normalized)
return cleaned && cleaned !== normalized ? [normalized, cleaned] : [normalized]
})
))
if (lookupUsernames.length === 0) return {}
try {
const result = await wcdbService.getAvatarUrls(lookupUsernames)
if (result.success && result.map) {
const avatarMap: Record<string, string> = {}
for (const [username, avatarUrl] of Object.entries(result.map)) {
const normalizedUsername = String(username || '').trim()
const normalizedAvatarUrl = String(avatarUrl || '').trim()
if (!normalizedUsername || !normalizedAvatarUrl) continue
avatarMap[normalizedUsername] = normalizedAvatarUrl
avatarMap[normalizedUsername.toLowerCase()] = normalizedAvatarUrl
const cleaned = this.normalizeAccountId(normalizedUsername)
if (cleaned) {
avatarMap[cleaned] = normalizedAvatarUrl
avatarMap[cleaned.toLowerCase()] = normalizedAvatarUrl
}
}
return avatarMap
}
} catch (e) {
console.error('[HttpService] Failed to get avatar urls:', e)
}
return {}
}
private resolveAvatarUrl(avatarMap: Record<string, string>, candidates: Array<string | undefined | null>): string | undefined {
for (const candidate of candidates) {
const normalized = String(candidate || '').trim()
if (!normalized) continue
const cleaned = this.normalizeAccountId(normalized)
const avatarUrl = avatarMap[normalized]
|| avatarMap[normalized.toLowerCase()]
|| avatarMap[cleaned]
|| avatarMap[cleaned.toLowerCase()]
if (avatarUrl) return avatarUrl
}
return undefined
}
private lookupGroupNickname(groupNicknamesMap: Map<string, string>, sender: string): string {
if (!sender) return ''
const cleaned = this.normalizeAccountId(sender)
return groupNicknamesMap.get(sender)
|| groupNicknamesMap.get(sender.toLowerCase())
|| groupNicknamesMap.get(cleaned)
|| groupNicknamesMap.get(cleaned.toLowerCase())
|| ''
}
private resolveChatLabSenderInfo(
msg: Message,
talkerId: string,
talkerName: string,
myWxid: string,
isGroup: boolean,
senderNames: Record<string, string>,
groupNicknamesMap: Map<string, string>
): { sender: string; accountName: string; groupNickname?: string } {
let sender = String(msg.senderUsername || '').trim()
let usedUnknownPlaceholder = false
const sameAsMe = sender && myWxid && sender.toLowerCase() === myWxid.toLowerCase()
const isSelf = msg.isSend === 1 || sameAsMe
if (!sender && isSelf && myWxid) {
sender = myWxid
}
if (!sender) {
if (msg.localType === 10000 || msg.localType === 266287972401) {
sender = talkerId
} else {
sender = `unknown_sender_${msg.localId || msg.createTime || 0}`
usedUnknownPlaceholder = true
}
}
const groupNickname = isGroup ? this.lookupGroupNickname(groupNicknamesMap, sender) : ''
const displayName = senderNames[sender] || groupNickname || (usedUnknownPlaceholder ? '' : sender)
const accountName = isSelf ? '我' : (displayName || '未知发送者')
return {
sender,
accountName,
groupNickname: groupNickname || undefined
}
}
/**
* 转换为 ChatLab 格式
*/
@@ -789,6 +1075,7 @@ class HttpService {
): Promise<ChatLabData> {
const isGroup = talkerId.endsWith('@chatroom')
const myWxid = this.configService.get('myWxid') || ''
const normalizedMyWxid = this.normalizeAccountId(myWxid).toLowerCase()
// 收集所有发送者
const senderSet = new Set<string>()
@@ -807,7 +1094,21 @@ class HttpService {
try {
const result = await wcdbService.getGroupNicknames(talkerId)
if (result.success && result.nicknames) {
groupNicknamesMap = new Map(Object.entries(result.nicknames))
groupNicknamesMap = new Map()
for (const [memberIdRaw, nicknameRaw] of Object.entries(result.nicknames)) {
const memberId = String(memberIdRaw || '').trim()
const nickname = String(nicknameRaw || '').trim()
if (!memberId || !nickname) continue
groupNicknamesMap.set(memberId, nickname)
groupNicknamesMap.set(memberId.toLowerCase(), nickname)
const cleaned = this.normalizeAccountId(memberId)
if (cleaned) {
groupNicknamesMap.set(cleaned, nickname)
groupNicknamesMap.set(cleaned.toLowerCase(), nickname)
}
}
}
} catch (e) {
console.error('[HttpService] Failed to get group nicknames:', e)
@@ -817,36 +1118,45 @@ class HttpService {
// 构建成员列表
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 senderInfo = this.resolveChatLabSenderInfo(msg, talkerId, talkerName, myWxid, isGroup, senderNames, groupNicknamesMap)
if (!memberMap.has(senderInfo.sender)) {
memberMap.set(senderInfo.sender, {
platformId: senderInfo.sender,
accountName: senderInfo.accountName,
groupNickname: senderInfo.groupNickname
})
}
}
const [memberAvatarMap, myAvatarResult, sessionAvatarInfo] = await Promise.all([
this.getAvatarUrls(Array.from(memberMap.keys()).filter((sender) => !sender.startsWith('unknown_sender_'))),
myWxid
? chatService.getMyAvatarUrl()
: Promise.resolve<{ success: boolean; avatarUrl?: string }>({ success: true }),
isGroup ? chatService.getContactAvatar(talkerId) : Promise.resolve(null)
])
for (const [sender, member] of memberMap.entries()) {
if (sender.startsWith('unknown_sender_')) continue
const normalizedSender = this.normalizeAccountId(sender).toLowerCase()
const isSelfMember = Boolean(normalizedMyWxid && normalizedSender && normalizedSender === normalizedMyWxid)
const avatarUrl = (isSelfMember ? myAvatarResult.avatarUrl : undefined)
|| this.resolveAvatarUrl(memberAvatarMap, isSelfMember ? [sender, myWxid] : [sender])
if (avatarUrl) {
member.avatar = avatarUrl
}
}
// 转换消息
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()) || '')
: ''
const senderInfo = this.resolveChatLabSenderInfo(msg, talkerId, talkerName, myWxid, isGroup, senderNames, groupNicknamesMap)
return {
sender,
accountName,
groupNickname: groupNickname || undefined,
sender: senderInfo.sender,
accountName: senderInfo.accountName,
groupNickname: senderInfo.groupNickname,
timestamp: msg.createTime,
type: this.mapMessageType(msg.localType, msg),
content: this.getMessageContent(msg),
@@ -866,6 +1176,7 @@ class HttpService {
platform: 'wechat',
type: isGroup ? 'group' : 'private',
groupId: isGroup ? talkerId : undefined,
groupAvatar: isGroup ? sessionAvatarInfo?.avatarUrl : undefined,
ownerId: myWxid || undefined
},
members: Array.from(memberMap.values()),

View File

@@ -55,14 +55,19 @@ type DecryptResult = {
isThumb?: boolean // 是否是缩略图(没有高清图时返回缩略图)
}
type HardlinkState = {
imageTable?: string
dirTable?: string
type CachedImagePayload = {
sessionId?: string
imageMd5?: string
imageDatName?: string
preferFilePath?: boolean
}
type DecryptImagePayload = CachedImagePayload & {
force?: boolean
}
export class ImageDecryptService {
private configService = new ConfigService()
private hardlinkCache = new Map<string, HardlinkState>()
private resolvedCache = new Map<string, string>()
private pending = new Map<string, Promise<DecryptResult>>()
private readonly defaultV1AesKey = 'cfcd208495d565ef'
@@ -106,7 +111,7 @@ export class ImageDecryptService {
}
}
async resolveCachedImage(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }): Promise<DecryptResult & { hasUpdate?: boolean }> {
async resolveCachedImage(payload: CachedImagePayload): Promise<DecryptResult & { hasUpdate?: boolean }> {
await this.ensureCacheIndexed()
const cacheKeys = this.getCacheKeys(payload)
const cacheKey = cacheKeys[0]
@@ -116,7 +121,7 @@ export class ImageDecryptService {
for (const key of cacheKeys) {
const cached = this.resolvedCache.get(key)
if (cached && existsSync(cached) && this.isImageFile(cached)) {
const dataUrl = this.fileToDataUrl(cached)
const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath)
const isThumb = this.isThumbnailPath(cached)
const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false
if (isThumb) {
@@ -124,8 +129,8 @@ export class ImageDecryptService {
} else {
this.updateFlags.delete(key)
}
this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(cached))
return { success: true, localPath: dataUrl || this.filePathToUrl(cached), hasUpdate }
this.emitCacheResolved(payload, key, this.resolveEmitPath(cached, payload.preferFilePath))
return { success: true, localPath, hasUpdate }
}
if (cached && !this.isImageFile(cached)) {
this.resolvedCache.delete(key)
@@ -136,7 +141,7 @@ export class ImageDecryptService {
const existing = this.findCachedOutput(key, false, payload.sessionId)
if (existing) {
this.cacheResolvedPaths(key, payload.imageMd5, payload.imageDatName, existing)
const dataUrl = this.fileToDataUrl(existing)
const localPath = this.resolveLocalPathForPayload(existing, payload.preferFilePath)
const isThumb = this.isThumbnailPath(existing)
const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false
if (isThumb) {
@@ -144,27 +149,53 @@ export class ImageDecryptService {
} else {
this.updateFlags.delete(key)
}
this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(existing))
return { success: true, localPath: dataUrl || this.filePathToUrl(existing), hasUpdate }
this.emitCacheResolved(payload, key, this.resolveEmitPath(existing, payload.preferFilePath))
return { success: true, localPath, hasUpdate }
}
}
this.logInfo('未找到缓存', { md5: payload.imageMd5, datName: payload.imageDatName })
return { success: false, error: '未找到缓存图片' }
}
async decryptImage(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }): Promise<DecryptResult> {
async decryptImage(payload: DecryptImagePayload): Promise<DecryptResult> {
await this.ensureCacheIndexed()
const cacheKey = payload.imageMd5 || payload.imageDatName
const cacheKeys = this.getCacheKeys(payload)
const cacheKey = cacheKeys[0]
if (!cacheKey) {
return { success: false, error: '缺少图片标识' }
}
if (payload.force) {
for (const key of cacheKeys) {
const cached = this.resolvedCache.get(key)
if (cached && existsSync(cached) && this.isImageFile(cached) && !this.isThumbnailPath(cached)) {
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, cached)
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath)
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(cached, payload.preferFilePath))
return { success: true, localPath }
}
if (cached && !this.isImageFile(cached)) {
this.resolvedCache.delete(key)
}
}
for (const key of cacheKeys) {
const existingHd = this.findCachedOutput(key, true, payload.sessionId)
if (!existingHd || this.isThumbnailPath(existingHd)) continue
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existingHd)
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
const localPath = this.resolveLocalPathForPayload(existingHd, payload.preferFilePath)
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existingHd, payload.preferFilePath))
return { success: true, localPath }
}
}
if (!payload.force) {
const cached = this.resolvedCache.get(cacheKey)
if (cached && existsSync(cached) && this.isImageFile(cached)) {
const dataUrl = this.fileToDataUrl(cached)
const localPath = dataUrl || this.filePathToUrl(cached)
this.emitCacheResolved(payload, cacheKey, localPath)
const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath)
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(cached, payload.preferFilePath))
return { success: true, localPath }
}
if (cached && !this.isImageFile(cached)) {
@@ -184,8 +215,44 @@ export class ImageDecryptService {
}
}
async preloadImageHardlinkMd5s(md5List: string[]): Promise<void> {
const normalizedList = Array.from(
new Set((md5List || []).map((item) => String(item || '').trim().toLowerCase()).filter(Boolean))
)
if (normalizedList.length === 0) return
const wxid = this.configService.get('myWxid')
const dbPath = this.configService.get('dbPath')
if (!wxid || !dbPath) return
const accountDir = this.resolveAccountDir(dbPath, wxid)
if (!accountDir) return
try {
const ready = await this.ensureWcdbReady()
if (!ready) return
const requests = normalizedList.map((md5) => ({ md5, accountDir }))
const result = await wcdbService.resolveImageHardlinkBatch(requests)
if (!result.success || !Array.isArray(result.rows)) return
for (const row of result.rows) {
const md5 = String(row?.md5 || '').trim().toLowerCase()
if (!md5) continue
const fullPath = String(row?.data?.full_path || '').trim()
if (!fullPath || !existsSync(fullPath)) continue
this.cacheDatPath(accountDir, md5, fullPath)
const fileName = String(row?.data?.file_name || '').trim().toLowerCase()
if (fileName) {
this.cacheDatPath(accountDir, fileName, fullPath)
}
}
} catch {
// ignore preload failures
}
}
private async decryptImageInternal(
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean },
payload: DecryptImagePayload,
cacheKey: string
): Promise<DecryptResult> {
this.logInfo('开始解密图片', { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force })
@@ -225,10 +292,9 @@ export class ImageDecryptService {
if (!extname(datPath).toLowerCase().includes('dat')) {
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, datPath)
const dataUrl = this.fileToDataUrl(datPath)
const localPath = dataUrl || this.filePathToUrl(datPath)
const localPath = this.resolveLocalPathForPayload(datPath, payload.preferFilePath)
const isThumb = this.isThumbnailPath(datPath)
this.emitCacheResolved(payload, cacheKey, localPath)
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(datPath, payload.preferFilePath))
return { success: true, localPath, isThumb }
}
@@ -240,10 +306,9 @@ export class ImageDecryptService {
// 如果要求高清但找到的是缩略图,继续解密高清图
if (!(payload.force && !isHd)) {
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existing)
const dataUrl = this.fileToDataUrl(existing)
const localPath = dataUrl || this.filePathToUrl(existing)
const localPath = this.resolveLocalPathForPayload(existing, payload.preferFilePath)
const isThumb = this.isThumbnailPath(existing)
this.emitCacheResolved(payload, cacheKey, localPath)
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existing, payload.preferFilePath))
return { success: true, localPath, isThumb }
}
}
@@ -303,9 +368,11 @@ export class ImageDecryptService {
if (!isThumb) {
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
}
const dataUrl = this.bufferToDataUrl(decrypted, finalExt)
const localPath = dataUrl || this.filePathToUrl(outputPath)
this.emitCacheResolved(payload, cacheKey, localPath)
const localPath = payload.preferFilePath
? outputPath
: (this.bufferToDataUrl(decrypted, finalExt) || this.filePathToUrl(outputPath))
const emitPath = this.resolveEmitPath(outputPath, payload.preferFilePath)
this.emitCacheResolved(payload, cacheKey, emitPath)
return { success: true, localPath, isThumb }
} catch (e) {
this.logError('解密失败', e, { md5: payload.imageMd5, datName: payload.imageDatName })
@@ -414,23 +481,37 @@ export class ImageDecryptService {
if (!skipResolvedCache) {
if (imageMd5) {
const cached = this.resolvedCache.get(imageMd5)
if (cached && existsSync(cached)) return cached
if (cached && existsSync(cached)) {
const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail)
this.cacheDatPath(accountDir, imageMd5, preferred)
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, preferred)
return preferred
}
}
if (imageDatName) {
const cached = this.resolvedCache.get(imageDatName)
if (cached && existsSync(cached)) return cached
if (cached && existsSync(cached)) {
const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail)
this.cacheDatPath(accountDir, imageDatName, preferred)
if (imageMd5) this.cacheDatPath(accountDir, imageMd5, preferred)
return preferred
}
}
}
// 1. 通过 MD5 快速定位 (MsgAttach 目录)
if (imageMd5) {
const res = await this.fastProbabilisticSearch(accountDir, imageMd5, allowThumbnail)
const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageMd5, allowThumbnail)
if (res) return res
if (imageDatName && imageDatName !== imageMd5 && this.looksLikeMd5(imageDatName)) {
const datNameRes = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageDatName, allowThumbnail)
if (datNameRes) return datNameRes
}
}
// 2. 如果 imageDatName 看起来像 MD5也尝试快速定位
if (!imageMd5 && imageDatName && this.looksLikeMd5(imageDatName)) {
const res = await this.fastProbabilisticSearch(accountDir, imageDatName, allowThumbnail)
const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageDatName, allowThumbnail)
if (res) return res
}
@@ -439,16 +520,17 @@ export class ImageDecryptService {
this.logInfo('[ImageDecrypt] hardlink lookup (md5)', { imageMd5, sessionId })
const hardlinkPath = await this.resolveHardlinkPath(accountDir, imageMd5, sessionId)
if (hardlinkPath) {
const isThumb = this.isThumbnailPath(hardlinkPath)
const preferredPath = this.getPreferredDatVariantPath(hardlinkPath, allowThumbnail)
const isThumb = this.isThumbnailPath(preferredPath)
if (allowThumbnail || !isThumb) {
this.logInfo('[ImageDecrypt] hardlink hit', { imageMd5, path: hardlinkPath })
this.cacheDatPath(accountDir, imageMd5, hardlinkPath)
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hardlinkPath)
return hardlinkPath
this.logInfo('[ImageDecrypt] hardlink hit', { imageMd5, path: preferredPath })
this.cacheDatPath(accountDir, imageMd5, preferredPath)
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, preferredPath)
return preferredPath
}
// hardlink 找到的是缩略图,但要求高清图
// 尝试在同一目录下查找高清图变体(快速查找,不遍历)
const hdPath = this.findHdVariantInSameDir(hardlinkPath)
const hdPath = this.findHdVariantInSameDir(preferredPath)
if (hdPath) {
this.cacheDatPath(accountDir, imageMd5, hdPath)
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdPath)
@@ -462,16 +544,19 @@ export class ImageDecryptService {
this.logInfo('[ImageDecrypt] hardlink fallback (datName)', { imageDatName, sessionId })
const fallbackPath = await this.resolveHardlinkPath(accountDir, imageDatName, sessionId)
if (fallbackPath) {
const isThumb = this.isThumbnailPath(fallbackPath)
const preferredPath = this.getPreferredDatVariantPath(fallbackPath, allowThumbnail)
const isThumb = this.isThumbnailPath(preferredPath)
if (allowThumbnail || !isThumb) {
this.logInfo('[ImageDecrypt] hardlink hit (datName)', { imageMd5: imageDatName, path: fallbackPath })
this.cacheDatPath(accountDir, imageDatName, fallbackPath)
return fallbackPath
this.logInfo('[ImageDecrypt] hardlink hit (datName)', { imageMd5: imageDatName, path: preferredPath })
this.cacheDatPath(accountDir, imageDatName, preferredPath)
if (imageMd5) this.cacheDatPath(accountDir, imageMd5, preferredPath)
return preferredPath
}
// 找到缩略图但要求高清图,尝试同目录查找高清图变体
const hdPath = this.findHdVariantInSameDir(fallbackPath)
const hdPath = this.findHdVariantInSameDir(preferredPath)
if (hdPath) {
this.cacheDatPath(accountDir, imageDatName, hdPath)
if (imageMd5) this.cacheDatPath(accountDir, imageMd5, hdPath)
return hdPath
}
return null
@@ -484,14 +569,15 @@ export class ImageDecryptService {
this.logInfo('[ImageDecrypt] hardlink lookup (datName)', { imageDatName, sessionId })
const hardlinkPath = await this.resolveHardlinkPath(accountDir, imageDatName, sessionId)
if (hardlinkPath) {
const isThumb = this.isThumbnailPath(hardlinkPath)
const preferredPath = this.getPreferredDatVariantPath(hardlinkPath, allowThumbnail)
const isThumb = this.isThumbnailPath(preferredPath)
if (allowThumbnail || !isThumb) {
this.logInfo('[ImageDecrypt] hardlink hit', { imageMd5: imageDatName, path: hardlinkPath })
this.cacheDatPath(accountDir, imageDatName, hardlinkPath)
return hardlinkPath
this.logInfo('[ImageDecrypt] hardlink hit', { imageMd5: imageDatName, path: preferredPath })
this.cacheDatPath(accountDir, imageDatName, preferredPath)
return preferredPath
}
// hardlink 找到的是缩略图,但要求高清图
const hdPath = this.findHdVariantInSameDir(hardlinkPath)
const hdPath = this.findHdVariantInSameDir(preferredPath)
if (hdPath) {
this.cacheDatPath(accountDir, imageDatName, hdPath)
return hdPath
@@ -510,9 +596,10 @@ export class ImageDecryptService {
if (!skipResolvedCache) {
const cached = this.resolvedCache.get(imageDatName)
if (cached && existsSync(cached)) {
if (allowThumbnail || !this.isThumbnailPath(cached)) return cached
const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail)
if (allowThumbnail || !this.isThumbnailPath(preferred)) return preferred
// 缓存的是缩略图,尝试找高清图
const hdPath = this.findHdVariantInSameDir(cached)
const hdPath = this.findHdVariantInSameDir(preferred)
if (hdPath) return hdPath
}
}
@@ -634,45 +721,19 @@ export class ImageDecryptService {
private async resolveHardlinkPath(accountDir: string, md5: string, _sessionId?: string): Promise<string | null> {
try {
const hardlinkPath = this.resolveHardlinkDbPath(accountDir)
if (!hardlinkPath) {
return null
}
const ready = await this.ensureWcdbReady()
if (!ready) {
this.logInfo('[ImageDecrypt] hardlink db not ready')
return null
}
const state = await this.getHardlinkState(accountDir, hardlinkPath)
if (!state.imageTable) {
this.logInfo('[ImageDecrypt] hardlink table missing', { hardlinkPath })
return null
}
const resolveResult = await wcdbService.resolveImageHardlink(md5, accountDir)
if (!resolveResult.success || !resolveResult.data) return null
const fileName = String(resolveResult.data.file_name || '').trim()
const fullPath = String(resolveResult.data.full_path || '').trim()
if (!fileName) return null
const escapedMd5 = this.escapeSqlString(md5)
const rowResult = await wcdbService.execQuery(
'media',
hardlinkPath,
`SELECT dir1, dir2, file_name FROM ${state.imageTable} WHERE lower(md5) = lower('${escapedMd5}') LIMIT 1`
)
const row = rowResult.success && rowResult.rows ? rowResult.rows[0] : null
if (!row) {
this.logInfo('[ImageDecrypt] hardlink row miss', { md5, table: state.imageTable })
return null
}
const dir1 = this.getRowValue(row, 'dir1')
const dir2 = this.getRowValue(row, 'dir2')
const fileName = this.getRowValue(row, 'file_name') ?? this.getRowValue(row, 'fileName')
if (dir1 === undefined || dir2 === undefined || !fileName) {
this.logInfo('[ImageDecrypt] hardlink row incomplete', { row })
return null
}
const lowerFileName = fileName.toLowerCase()
const lowerFileName = String(fileName).toLowerCase()
if (lowerFileName.endsWith('.dat')) {
const baseLower = lowerFileName.slice(0, -4)
if (!this.isLikelyImageDatBase(baseLower) && !this.looksLikeMd5(baseLower)) {
@@ -681,57 +742,11 @@ export class ImageDecryptService {
}
}
// dir1 和 dir2 是 rowid需要从 dir2id 表查询对应的目录名
let dir1Name: string | null = null
let dir2Name: string | null = null
if (state.dirTable) {
try {
// 通过 rowid 查询目录名
const dir1Result = await wcdbService.execQuery(
'media',
hardlinkPath,
`SELECT username FROM ${state.dirTable} WHERE rowid = ${Number(dir1)} LIMIT 1`
)
if (dir1Result.success && dir1Result.rows && dir1Result.rows.length > 0) {
const value = this.getRowValue(dir1Result.rows[0], 'username')
if (value) dir1Name = String(value)
}
const dir2Result = await wcdbService.execQuery(
'media',
hardlinkPath,
`SELECT username FROM ${state.dirTable} WHERE rowid = ${Number(dir2)} LIMIT 1`
)
if (dir2Result.success && dir2Result.rows && dir2Result.rows.length > 0) {
const value = this.getRowValue(dir2Result.rows[0], 'username')
if (value) dir2Name = String(value)
}
} catch {
// ignore
}
if (fullPath && existsSync(fullPath)) {
this.logInfo('[ImageDecrypt] hardlink path hit', { fullPath })
return fullPath
}
if (!dir1Name || !dir2Name) {
this.logInfo('[ImageDecrypt] hardlink dir resolve miss', { dir1, dir2, dir1Name, dir2Name })
return null
}
// 构建路径: msg/attach/{dir1Name}/{dir2Name}/Img/{fileName}
const possiblePaths = [
join(accountDir, 'msg', 'attach', dir1Name, dir2Name, 'Img', fileName),
join(accountDir, 'msg', 'attach', dir1Name, dir2Name, 'mg', fileName),
join(accountDir, 'msg', 'attach', dir1Name, dir2Name, fileName),
]
for (const fullPath of possiblePaths) {
if (existsSync(fullPath)) {
this.logInfo('[ImageDecrypt] hardlink path hit', { fullPath })
return fullPath
}
}
this.logInfo('[ImageDecrypt] hardlink path miss', { possiblePaths })
this.logInfo('[ImageDecrypt] hardlink path miss', { fullPath, md5 })
return null
} catch {
// ignore
@@ -739,35 +754,6 @@ export class ImageDecryptService {
return null
}
private async getHardlinkState(accountDir: string, hardlinkPath: string): Promise<HardlinkState> {
const cached = this.hardlinkCache.get(hardlinkPath)
if (cached) return cached
const imageResult = await wcdbService.execQuery(
'media',
hardlinkPath,
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'image_hardlink_info%' ORDER BY name DESC LIMIT 1"
)
const dirResult = await wcdbService.execQuery(
'media',
hardlinkPath,
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'dir2id%' LIMIT 1"
)
const imageTable = imageResult.success && imageResult.rows && imageResult.rows.length > 0
? this.getRowValue(imageResult.rows[0], 'name')
: undefined
const dirTable = dirResult.success && dirResult.rows && dirResult.rows.length > 0
? this.getRowValue(dirResult.rows[0], 'name')
: undefined
const state: HardlinkState = {
imageTable: imageTable ? String(imageTable) : undefined,
dirTable: dirTable ? String(dirTable) : undefined
}
this.logInfo('[ImageDecrypt] hardlink state', { hardlinkPath, imageTable: state.imageTable, dirTable: state.dirTable })
this.hardlinkCache.set(hardlinkPath, state)
return state
}
private async ensureWcdbReady(): Promise<boolean> {
if (wcdbService.isReady()) return true
const dbPath = this.configService.get('dbPath')
@@ -801,7 +787,8 @@ export class ImageDecryptService {
const key = `${accountDir}|${datName}`
const cached = this.resolvedCache.get(key)
if (cached && existsSync(cached)) {
if (allowThumbnail || !this.isThumbnailPath(cached)) return cached
const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail)
if (allowThumbnail || !this.isThumbnailPath(preferred)) return preferred
}
const root = join(accountDir, 'msg', 'attach')
@@ -810,7 +797,7 @@ export class ImageDecryptService {
// 优化1快速概率性查找
// 包含1. 基于文件名的前缀猜测 (旧版)
// 2. 基于日期的最近月份扫描 (新版无索引时)
const fastHit = await this.fastProbabilisticSearch(root, datName)
const fastHit = await this.fastProbabilisticSearch(root, datName, allowThumbnail)
if (fastHit) {
this.resolvedCache.set(key, fastHit)
return fastHit
@@ -830,33 +817,28 @@ export class ImageDecryptService {
* 包含1. 微信旧版结构 filename.substr(0, 2)/...
* 2. 微信新版结构 msg/attach/{hash}/{YYYY-MM}/Img/filename
*/
private async fastProbabilisticSearch(root: string, datName: string, _allowThumbnail?: boolean): Promise<string | null> {
private async fastProbabilisticSearch(root: string, datName: string, allowThumbnail = true): 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 baseName = this.normalizeDatBase(lowerName)
const targetNames = this.buildPreferredDatNames(baseName, allowThumbnail)
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 targetName of targetNames) {
candidates.push(
join(root, dir1, dir2, targetName),
join(root, dir1, dir2, 'Img', targetName),
join(root, dir1, dir2, 'mg', targetName),
join(root, dir1, dir2, 'Image', targetName)
)
}
}
for (const path of candidates) {
@@ -877,19 +859,13 @@ export class ImageDecryptService {
const now = new Date()
const months: string[] = []
for (let i = 0; i < 2; i++) {
// Imported mobile history can live in older YYYY-MM buckets; keep this bounded but wider than "recent 2 months".
for (let i = 0; i < 24; 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)
@@ -919,36 +895,13 @@ export class ImageDecryptService {
/**
* 在同一目录下查找高清图变体
* 缩略图 xxx_t.dat -> 高清图 xxx_h.dat 或 xxx.dat
* 优先 `_h`,再回退其他非缩略图变体
*/
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
}
}
const fileName = basename(thumbPath)
return this.findPreferredDatVariantInDir(dir, fileName, false)
} catch { }
return null
}
@@ -998,7 +951,86 @@ export class ImageDecryptService {
void worker.terminate()
resolve(null)
})
})
})
}
private stripDatVariantSuffix(base: string): string {
const lower = base.toLowerCase()
const suffixes = ['_thumb', '.thumb', '_hd', '.hd', '_h', '.h', '_t', '.t', '_c', '.c']
for (const suffix of suffixes) {
if (lower.endsWith(suffix)) {
return lower.slice(0, -suffix.length)
}
}
if (/[._][a-z]$/.test(lower)) {
return lower.slice(0, -2)
}
return lower
}
private getDatVariantPriority(name: string): number {
const lower = name.toLowerCase()
const baseLower = lower.endsWith('.dat') || lower.endsWith('.jpg') ? lower.slice(0, -4) : lower
if (baseLower.endsWith('_h') || baseLower.endsWith('.h')) return 600
if (!this.hasXVariant(baseLower)) return 500
if (baseLower.endsWith('_hd') || baseLower.endsWith('.hd')) return 450
if (baseLower.endsWith('_c') || baseLower.endsWith('.c')) return 400
if (this.isThumbnailDat(lower)) return 100
return 350
}
private buildPreferredDatNames(baseName: string, allowThumbnail: boolean): string[] {
if (!baseName) return []
const names = [
`${baseName}_h.dat`,
`${baseName}.h.dat`,
`${baseName}.dat`,
`${baseName}_hd.dat`,
`${baseName}.hd.dat`,
`${baseName}_c.dat`,
`${baseName}.c.dat`
]
if (allowThumbnail) {
names.push(
`${baseName}_thumb.dat`,
`${baseName}.thumb.dat`,
`${baseName}_t.dat`,
`${baseName}.t.dat`
)
}
return Array.from(new Set(names))
}
private findPreferredDatVariantInDir(dirPath: string, baseName: string, allowThumbnail: boolean): string | null {
let entries: string[]
try {
entries = readdirSync(dirPath)
} catch {
return null
}
const target = this.normalizeDatBase(baseName.toLowerCase())
let bestPath: string | null = null
let bestScore = Number.NEGATIVE_INFINITY
for (const entry of entries) {
const lower = entry.toLowerCase()
if (!lower.endsWith('.dat')) continue
if (!allowThumbnail && this.isThumbnailDat(lower)) continue
const baseLower = lower.slice(0, -4)
if (this.normalizeDatBase(baseLower) !== target) continue
const score = this.getDatVariantPriority(lower)
if (score > bestScore) {
bestScore = score
bestPath = join(dirPath, entry)
}
}
return bestPath
}
private getPreferredDatVariantPath(datPath: string, allowThumbnail: boolean): string {
const lower = datPath.toLowerCase()
if (!lower.endsWith('.dat')) return datPath
const preferred = this.findPreferredDatVariantInDir(dirname(datPath), basename(datPath), allowThumbnail)
return preferred || datPath
}
private normalizeDatBase(name: string): string {
@@ -1006,18 +1038,21 @@ export class ImageDecryptService {
if (base.endsWith('.dat') || base.endsWith('.jpg')) {
base = base.slice(0, -4)
}
while (/[._][a-z]$/.test(base)) {
base = base.slice(0, -2)
for (;;) {
const stripped = this.stripDatVariantSuffix(base)
if (stripped === base) {
return base
}
base = stripped
}
return base
}
private hasImageVariantSuffix(baseLower: string): boolean {
return /[._][a-z]$/.test(baseLower)
return this.stripDatVariantSuffix(baseLower) !== baseLower
}
private isLikelyImageDatBase(baseLower: string): boolean {
return this.hasImageVariantSuffix(baseLower) || this.looksLikeMd5(baseLower)
return this.hasImageVariantSuffix(baseLower) || this.looksLikeMd5(this.normalizeDatBase(baseLower))
}
@@ -1206,24 +1241,7 @@ export class ImageDecryptService {
}
private findNonThumbnailVariantInDir(dirPath: string, baseName: string): string | null {
let entries: string[]
try {
entries = readdirSync(dirPath)
} catch {
return null
}
const target = this.normalizeDatBase(baseName.toLowerCase())
for (const entry of entries) {
const lower = entry.toLowerCase()
if (!lower.endsWith('.dat')) continue
if (this.isThumbnailDat(lower)) continue
const baseLower = lower.slice(0, -4)
// 只排除没有 _x 变体后缀的文件(允许 _hd、_h 等所有带变体的)
if (!this.hasXVariant(baseLower)) continue
if (this.normalizeDatBase(baseLower) !== target) continue
return join(dirPath, entry)
}
return null
return this.findPreferredDatVariantInDir(dirPath, baseName, false)
}
private isNonThumbnailVariantDat(datPath: string): boolean {
@@ -1231,8 +1249,7 @@ export class ImageDecryptService {
if (!lower.endsWith('.dat')) return false
if (this.isThumbnailDat(lower)) return false
const baseLower = lower.slice(0, -4)
// 只检查是否有 _x 变体后缀(允许 _hd、_h 等所有带变体的)
return this.hasXVariant(baseLower)
return this.isLikelyImageDatBase(baseLower)
}
private emitImageUpdate(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }, cacheKey: string): void {
@@ -1521,6 +1538,16 @@ export class ImageDecryptService {
return `data:${mimeType};base64,${buffer.toString('base64')}`
}
private resolveLocalPathForPayload(filePath: string, preferFilePath?: boolean): string {
if (preferFilePath) return filePath
return this.resolveEmitPath(filePath, false)
}
private resolveEmitPath(filePath: string, preferFilePath?: boolean): string {
if (preferFilePath) return this.filePathToUrl(filePath)
return this.fileToDataUrl(filePath) || this.filePathToUrl(filePath)
}
private fileToDataUrl(filePath: string): string | null {
try {
const ext = extname(filePath).toLowerCase()
@@ -1858,7 +1885,7 @@ export class ImageDecryptService {
private hasXVariant(base: string): boolean {
const lower = base.toLowerCase()
return lower.endsWith('_h') || lower.endsWith('_hd') || lower.endsWith('_thumb') || lower.endsWith('_t')
return this.stripDatVariantSuffix(lower) !== lower
}
private isHdPath(p: string): boolean {
@@ -1912,7 +1939,6 @@ export class ImageDecryptService {
async clearCache(): Promise<{ success: boolean; error?: string }> {
this.resolvedCache.clear()
this.hardlinkCache.clear()
this.pending.clear()
this.updateFlags.clear()
this.cacheIndexed = false

View File

@@ -12,6 +12,7 @@ type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: stri
type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; error?: string }
export class KeyService {
private readonly isMac = process.platform === 'darwin'
private koffi: any = null
private lib: any = null
private initialized = false
@@ -605,34 +606,14 @@ export class KeyService {
const logs: string[] = []
onStatus?.('正在定位微信安装路径...', 0)
let wechatPath = await this.findWeChatInstallPath()
if (!wechatPath) {
const err = '未找到微信安装路径请确认已安装PC微信'
onStatus?.('正在查找微信进程...', 0)
const pid = await this.findWeChatPid()
if (!pid) {
const err = '未找到微信进程,请先启动微信'
onStatus?.(err, 2)
return { success: false, error: err }
}
onStatus?.('正在关闭微信以进行获取...', 0)
const closed = await this.killWeChatProcesses()
if (!closed) {
const err = '无法自动关闭微信,请手动退出后重试'
onStatus?.(err, 2)
return { success: false, error: err }
}
onStatus?.('正在启动微信...', 0)
const sub = spawn(wechatPath, {
detached: true,
stdio: 'ignore',
cwd: dirname(wechatPath)
})
sub.unref()
onStatus?.('等待微信界面就绪...', 0)
const pid = await this.waitForWeChatWindow()
if (!pid) return { success: false, error: '启动微信失败或等待界面就绪超时' }
onStatus?.(`检测到微信窗口 (PID: ${pid}),正在获取...`, 0)
onStatus?.('正在检测微信界面组件...', 0)
await this.waitForWeChatWindowComponents(pid, 15000)
@@ -714,6 +695,68 @@ export class KeyService {
return wxid.substring(0, second)
}
private deriveImageKeys(code: number, wxid: string): { xorKey: number; aesKey: string } {
const cleanedWxid = this.cleanWxid(wxid)
const xorKey = code & 0xFF
const dataToHash = code.toString() + cleanedWxid
const md5Full = crypto.createHash('md5').update(dataToHash).digest('hex')
const aesKey = md5Full.substring(0, 16)
return { xorKey, aesKey }
}
private verifyDerivedAesKey(aesKey: string, ciphertext: Buffer): boolean {
try {
if (!aesKey || aesKey.length < 16 || ciphertext.length !== 16) return false
const decipher = crypto.createDecipheriv('aes-128-ecb', Buffer.from(aesKey, 'ascii').subarray(0, 16), null)
decipher.setAutoPadding(false)
const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()])
if (dec[0] === 0xFF && dec[1] === 0xD8 && dec[2] === 0xFF) return true
if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4E && dec[3] === 0x47) return true
if (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) return true
if (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true
if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true
return false
} catch {
return false
}
}
private async collectWxidCandidates(manualDir?: string, wxidParam?: string): Promise<string[]> {
const candidates: string[] = []
const pushUnique = (value: string) => {
const v = String(value || '').trim()
if (!v || candidates.includes(v)) return
candidates.push(v)
}
if (wxidParam && wxidParam.startsWith('wxid_')) pushUnique(wxidParam)
if (manualDir) {
const normalized = manualDir.replace(/[\\/]+$/, '')
const dirName = normalized.split(/[\\/]/).pop() ?? ''
if (dirName.startsWith('wxid_')) pushUnique(dirName)
const marker = normalized.match(/[\\/]xwechat_files/i) || normalized.match(/[\\/]WeChat Files/i)
if (marker) {
const root = normalized.slice(0, marker.index! + marker[0].length)
try {
const { readdirSync, statSync } = await import('fs')
const { join } = await import('path')
for (const entry of readdirSync(root)) {
if (!entry.startsWith('wxid_')) continue
const full = join(root, entry)
try {
if (statSync(full).isDirectory()) pushUnique(entry)
} catch { }
}
} catch { }
}
}
pushUnique('unknown')
return candidates
}
async autoGetImageKey(
manualDir?: string,
onProgress?: (message: string) => void,
@@ -749,52 +792,34 @@ export class KeyService {
const codes: number[] = accounts[0].keys.map((k: any) => k.code)
console.log('[ImageKey] codes:', codes, 'DLL wxids:', accounts.map((a: any) => a.wxid))
// 优先级: 1. 直接传入的wxidParam 2. 从manualDir提取 3. DLL返回的wxid可能是unknown
let targetWxid = ''
// 方案1: 直接使用传入的wxidParam最优先
if (wxidParam && wxidParam.startsWith('wxid_')) {
targetWxid = wxidParam
console.log('[ImageKey] 使用直接传入的 wxid:', targetWxid)
const wxidCandidates = await this.collectWxidCandidates(manualDir, wxidParam)
let verifyCiphertext: Buffer | null = null
if (manualDir && existsSync(manualDir)) {
const template = await this._findTemplateData(manualDir, 32)
verifyCiphertext = template.ciphertext
}
// 方案2: 从 manualDir 提取前端已配置好的正确 wxid
// 格式: "D:\weixin\xwechat_files\wxid_xxx_1234" → "wxid_xxx_1234"
if (!targetWxid && manualDir) {
const dirName = manualDir.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? ''
if (dirName.startsWith('wxid_')) {
targetWxid = dirName
console.log('[ImageKey] 从 manualDir 提取 wxid:', targetWxid)
if (verifyCiphertext) {
onProgress?.(`正在校验候选 wxid${wxidCandidates.length} 个)...`)
for (const candidateWxid of wxidCandidates) {
for (const code of codes) {
const { xorKey, aesKey } = this.deriveImageKeys(code, candidateWxid)
if (!this.verifyDerivedAesKey(aesKey, verifyCiphertext)) continue
onProgress?.(`密钥获取成功 (wxid: ${candidateWxid}, code: ${code})`)
console.log('[ImageKey] 校验命中: wxid=', candidateWxid, 'code=', code)
return { success: true, xorKey, aesKey }
}
}
return { success: false, error: '缓存 code 与当前账号 wxid 未匹配,请确认账号目录后重试,或使用内存扫描' }
}
// 方案3: 回退到 DLL 发现的第一个(可能是 unknown
if (!targetWxid) {
targetWxid = accounts[0].wxid
console.log('[ImageKey] 无法获取 wxid使用 DLL 发现的:', targetWxid)
}
// CleanWxid: 截断到第二个下划线,与 xkey 算法一致
const cleanedWxid = this.cleanWxid(targetWxid)
console.log('[ImageKey] wxid:', targetWxid, '→ cleaned:', cleanedWxid)
// 用 cleanedWxid + code 本地计算密钥
// xorKey = code & 0xFF
// aesKey = MD5(code.toString() + cleanedWxid).substring(0, 16)
const code = codes[0]
const xorKey = code & 0xFF
const dataToHash = code.toString() + cleanedWxid
const md5Full = crypto.createHash('md5').update(dataToHash).digest('hex')
const aesKey = md5Full.substring(0, 16)
onProgress?.(`密钥获取成功 (wxid: ${targetWxid}, code: ${code})`)
console.log('[ImageKey] 计算结果: xorKey=', xorKey, 'aesKey=', aesKey)
return {
success: true,
xorKey,
aesKey
}
// 无模板密文可验真时回退旧策略
const fallbackWxid = wxidCandidates[0] || accounts[0].wxid || 'unknown'
const fallbackCode = codes[0]
const { xorKey, aesKey } = this.deriveImageKeys(fallbackCode, fallbackWxid)
onProgress?.(`密钥获取成功 (wxid: ${fallbackWxid}, code: ${fallbackCode})`)
console.log('[ImageKey] 回退计算: wxid=', fallbackWxid, 'code=', fallbackCode)
return { success: true, xorKey, aesKey }
}
// --- 内存扫描备选方案(融合 Dart+Python 优点)---
@@ -810,10 +835,20 @@ export class KeyService {
try {
// 1. 查找模板文件获取密文和 XOR 密钥
onProgress?.('正在查找模板文件...')
const { ciphertext, xorKey } = await this._findTemplateData(userDir)
let result = await this._findTemplateData(userDir, 32)
let { ciphertext, xorKey } = result
// 如果找不到密钥,尝试扫描更多文件
if (ciphertext && xorKey === null) {
onProgress?.('未找到有效密钥,尝试扫描更多文件...')
result = await this._findTemplateData(userDir, 100)
xorKey = result.xorKey
}
if (!ciphertext) return { success: false, error: '未找到 V2 模板文件,请先在微信中查看几张图片' }
if (xorKey === null) return { success: false, error: '未能从模板文件中计算出有效的 XOR 密钥,请确保在微信中查看了多张不同的图片' }
onProgress?.(`XOR 密钥: 0x${(xorKey ?? 0).toString(16).padStart(2, '0')},正在查找微信进程...`)
onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`)
// 2. 找微信 PID
const pid = await this.findWeChatPid()
@@ -830,7 +865,7 @@ export class KeyService {
const aesKey = await this._scanMemoryForAesKey(pid, ciphertext, onProgress)
if (aesKey) {
onProgress?.('密钥获取成功')
return { success: true, xorKey: xorKey ?? 0, aesKey }
return { success: true, xorKey, aesKey }
}
// 等 5 秒再试
await new Promise(r => setTimeout(r, 5000))
@@ -845,26 +880,26 @@ export class KeyService {
}
}
private async _findTemplateData(userDir: string): Promise<{ ciphertext: Buffer | null; xorKey: number | null }> {
private async _findTemplateData(userDir: string, limit: number = 32): Promise<{ ciphertext: Buffer | null; xorKey: number | null }> {
const { readdirSync, readFileSync, statSync } = await import('fs')
const { join } = await import('path')
const V2_MAGIC = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07])
// 递归收集 *_t.dat 文件
const collect = (dir: string, results: string[], limit = 32) => {
if (results.length >= limit) return
const collect = (dir: string, results: string[], maxFiles: number) => {
if (results.length >= maxFiles) return
try {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (results.length >= limit) break
if (results.length >= maxFiles) break
const full = join(dir, entry.name)
if (entry.isDirectory()) collect(full, results, limit)
if (entry.isDirectory()) collect(full, results, maxFiles)
else if (entry.isFile() && entry.name.endsWith('_t.dat')) results.push(full)
}
} catch { /* 忽略无权限目录 */ }
}
const files: string[] = []
collect(userDir, files)
collect(userDir, files, limit)
// 按修改时间降序
files.sort((a, b) => {

View File

@@ -0,0 +1,292 @@
import { app } from 'electron'
import { join } from 'path'
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
import { execFile, exec } from 'child_process'
import { promisify } from 'util'
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const execFileAsync = promisify(execFile)
const execAsync = promisify(exec)
type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] }
type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; error?: string }
export class KeyServiceLinux {
private sudo: any
constructor() {
try {
this.sudo = require('sudo-prompt');
} catch (e) {
console.error('Failed to load sudo-prompt', e);
}
}
private getHelperPath(): string {
const isPackaged = app.isPackaged
const candidates: string[] = []
if (process.env.WX_KEY_HELPER_PATH) candidates.push(process.env.WX_KEY_HELPER_PATH)
if (isPackaged) {
candidates.push(join(process.resourcesPath, 'resources', 'xkey_helper_linux'))
candidates.push(join(process.resourcesPath, 'xkey_helper_linux'))
} else {
candidates.push(join(app.getAppPath(), 'resources', 'xkey_helper_linux'))
candidates.push(join(app.getAppPath(), '..', 'Xkey', 'build', 'xkey_helper_linux'))
}
for (const p of candidates) {
if (existsSync(p)) return p
}
throw new Error('找不到 xkey_helper_linux请检查路径')
}
public async autoGetDbKey(
timeoutMs = 60_000,
onStatus?: (message: string, level: number) => void
): Promise<DbKeyResult> {
try {
onStatus?.('正在尝试结束当前微信进程...', 0)
await execAsync('killall -9 wechat wechat-bin xwechat').catch(() => {})
// 稍微等待进程完全退出
await new Promise(r => setTimeout(r, 1000))
onStatus?.('正在尝试拉起微信...', 0)
const startCmds = [
'nohup wechat >/dev/null 2>&1 &',
'nohup wechat-bin >/dev/null 2>&1 &',
'nohup xwechat >/dev/null 2>&1 &'
]
for (const cmd of startCmds) execAsync(cmd).catch(() => {})
onStatus?.('等待微信进程出现...', 0)
let pid = 0
for (let i = 0; i < 15; i++) { // 最多等 15 秒
await new Promise(r => setTimeout(r, 1000))
const { stdout } = await execAsync('pidof wechat wechat-bin xwechat').catch(() => ({ stdout: '' }))
const pids = stdout.trim().split(/\s+/).filter(p => p)
if (pids.length > 0) {
pid = parseInt(pids[0], 10)
break
}
}
if (!pid) {
const err = '未能自动启动微信,请手动启动并登录。'
onStatus?.(err, 2)
return { success: false, error: err }
}
onStatus?.(`捕获到微信 PID: ${pid},准备获取密钥...`, 0)
await new Promise(r => setTimeout(r, 2000))
return await this.getDbKey(pid, onStatus)
} catch (err: any) {
const errMsg = '自动获取微信 PID 失败: ' + err.message
onStatus?.(errMsg, 2)
return { success: false, error: errMsg }
}
}
public async getDbKey(pid: number, onStatus?: (message: string, level: number) => void): Promise<DbKeyResult> {
try {
const helperPath = this.getHelperPath()
onStatus?.('正在扫描数据库基址...', 0)
const { stdout: scanOut } = await execFileAsync(helperPath, ['db_scan', pid.toString()])
const scanRes = JSON.parse(scanOut.trim())
if (!scanRes.success) {
const err = scanRes.result || '扫描失败,请确保微信已完全登录'
onStatus?.(err, 2)
return { success: false, error: err }
}
const targetAddr = scanRes.target_addr
onStatus?.('基址扫描成功,正在请求管理员权限进行内存 Hook...', 0)
return await new Promise((resolve) => {
const options = { name: 'WeFlow' }
const command = `"${helperPath}" db_hook ${pid} ${targetAddr}`
this.sudo.exec(command, options, (error, stdout) => {
execAsync(`kill -CONT ${pid}`).catch(() => {})
if (error) {
onStatus?.('授权失败或被取消', 2)
resolve({ success: false, error: `授权失败或被取消: ${error.message}` })
return
}
try {
const hookRes = JSON.parse((stdout as string).trim())
if (hookRes.success) {
onStatus?.('密钥获取成功', 1)
resolve({ success: true, key: hookRes.key })
} else {
onStatus?.(hookRes.result, 2)
resolve({ success: false, error: hookRes.result })
}
} catch (e) {
onStatus?.('解析 Hook 结果失败', 2)
resolve({ success: false, error: '解析 Hook 结果失败' })
}
})
})
} catch (err: any) {
onStatus?.(err.message, 2)
return { success: false, error: err.message }
}
}
public async autoGetImageKey(
accountPath?: string,
onProgress?: (msg: string) => void,
wxid?: string
): Promise<ImageKeyResult> {
try {
onProgress?.('正在初始化缓存扫描...');
const helperPath = this.getHelperPath()
const { stdout } = await execFileAsync(helperPath, ['image_local'])
const res = JSON.parse(stdout.trim())
if (!res.success) return { success: false, error: res.result }
const accounts = res.data.accounts || []
let account = accounts.find((a: any) => a.wxid === wxid)
if (!account && accounts.length > 0) account = accounts[0]
if (account && account.keys && account.keys.length > 0) {
onProgress?.(`已找到匹配的图片密钥 (wxid: ${account.wxid})`);
const keyObj = account.keys[0]
return { success: true, xorKey: keyObj.xorKey, aesKey: keyObj.aesKey }
}
return { success: false, error: '未在缓存中找到匹配的图片密钥' }
} catch (err: any) {
return { success: false, error: err.message }
}
}
public async autoGetImageKeyByMemoryScan(
accountPath: string,
onProgress?: (msg: string) => void
): Promise<ImageKeyResult> {
try {
onProgress?.('正在查找模板文件...')
let result = await this._findTemplateData(accountPath, 32)
let { ciphertext, xorKey } = result
if (ciphertext && xorKey === null) {
onProgress?.('未找到有效密钥,尝试扫描更多文件...')
result = await this._findTemplateData(accountPath, 100)
xorKey = result.xorKey
}
if (!ciphertext) return { success: false, error: '未找到 V2 模板文件,请先在微信中查看几张图片' }
if (xorKey === null) return { success: false, error: '未能从模板文件中计算出有效的 XOR 密钥' }
onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`)
// 2. 找微信 PID
const { stdout } = await execAsync('pidof wechat wechat-bin xwechat').catch(() => ({ stdout: '' }))
const pids = stdout.trim().split(/\s+/).filter(p => p)
if (pids.length === 0) return { success: false, error: '微信未运行,无法扫描内存' }
const pid = parseInt(pids[0], 10)
onProgress?.(`已找到微信进程 PID=${pid},正在提权扫描进程内存...`);
// 3. 将 Buffer 转换为 hex 传递给 helper
const ciphertextHex = ciphertext.toString('hex')
const helperPath = this.getHelperPath()
try {
console.log(`[Debug] 准备执行 Helper: ${helperPath} image_mem ${pid} ${ciphertextHex}`);
const { stdout: memOut, stderr } = await execFileAsync(helperPath, ['image_mem', pid.toString(), ciphertextHex])
console.log(`[Debug] Helper stdout: ${memOut}`);
if (stderr) {
console.warn(`[Debug] Helper stderr: ${stderr}`);
}
if (!memOut || memOut.trim() === '') {
return { success: false, error: 'Helper 返回为空,请检查是否有足够的权限(如需sudo)读取进程内存。' }
}
const res = JSON.parse(memOut.trim())
if (res.success) {
onProgress?.('内存扫描成功');
return { success: true, xorKey, aesKey: res.key }
}
return { success: false, error: res.result || '未知错误' }
} catch (err: any) {
console.error('[Debug] 执行或解析 Helper 时发生崩溃:', err);
return {
success: false,
error: `内存扫描失败: ${err.message}\nstdout: ${err.stdout || '无'}\nstderr: ${err.stderr || '无'}`
}
}
} catch (err: any) {
return { success: false, error: `内存扫描失败: ${err.message}` }
}
}
private async _findTemplateData(userDir: string, limit: number = 32): Promise<{ ciphertext: Buffer | null; xorKey: number | null }> {
const V2_MAGIC = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07])
// 递归收集 *_t.dat 文件
const collect = (dir: string, results: string[], maxFiles: number) => {
if (results.length >= maxFiles) return
try {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (results.length >= maxFiles) break
const full = join(dir, entry.name)
if (entry.isDirectory()) collect(full, results, maxFiles)
else if (entry.isFile() && entry.name.endsWith('_t.dat')) results.push(full)
}
} catch { /* 忽略无权限目录 */ }
}
const files: string[] = []
collect(userDir, files, limit)
// 按修改时间降序
files.sort((a, b) => {
try { return statSync(b).mtimeMs - statSync(a).mtimeMs } catch { return 0 }
})
let ciphertext: Buffer | null = null
const tailCounts: Record<string, number> = {}
for (const f of files.slice(0, 32)) {
try {
const data = readFileSync(f)
if (data.length < 8) continue
// 统计末尾两字节用于 XOR 密钥
if (data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 2) {
const key = `${data[data.length - 2]}_${data[data.length - 1]}`
tailCounts[key] = (tailCounts[key] ?? 0) + 1
}
// 提取密文(取第一个有效的)
if (!ciphertext && data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 0x1F) {
ciphertext = data.subarray(0xF, 0x1F)
}
} catch { /* 忽略 */ }
}
// 计算 XOR 密钥
let xorKey: number | null = null
let maxCount = 0
for (const [key, count] of Object.entries(tailCounts)) {
if (count > maxCount) {
maxCount = count
const [x, y] = key.split('_').map(Number)
const k = x ^ 0xFF
if (k === (y ^ 0xD9)) xorKey = k
}
}
return { ciphertext, xorKey }
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,371 @@
import { ConfigService } from './config'
import { chatService, type ChatSession, type Message } from './chatService'
import { wcdbService } from './wcdbService'
import { httpService } from './httpService'
interface SessionBaseline {
lastTimestamp: number
unreadCount: number
}
interface MessagePushPayload {
event: 'message.new'
sessionId: string
messageKey: string
avatarUrl?: string
sourceName: string
groupName?: string
content: string | null
}
const PUSH_CONFIG_KEYS = new Set([
'messagePushEnabled',
'dbPath',
'decryptKey',
'myWxid'
])
class MessagePushService {
private readonly configService: ConfigService
private readonly sessionBaseline = new Map<string, SessionBaseline>()
private readonly recentMessageKeys = new Map<string, number>()
private readonly groupNicknameCache = new Map<string, { nicknames: Record<string, string>; updatedAt: number }>()
private readonly debounceMs = 350
private readonly recentMessageTtlMs = 10 * 60 * 1000
private readonly groupNicknameCacheTtlMs = 5 * 60 * 1000
private debounceTimer: ReturnType<typeof setTimeout> | null = null
private processing = false
private rerunRequested = false
private started = false
private baselineReady = false
constructor() {
this.configService = ConfigService.getInstance()
}
start(): void {
if (this.started) return
this.started = true
void this.refreshConfiguration('startup')
}
handleDbMonitorChange(type: string, json: string): void {
if (!this.started) return
if (!this.isPushEnabled()) return
let payload: Record<string, unknown> | null = null
try {
payload = JSON.parse(json)
} catch {
payload = null
}
const tableName = String(payload?.table || '').trim().toLowerCase()
if (tableName && tableName !== 'session') {
return
}
this.scheduleSync()
}
async handleConfigChanged(key: string): Promise<void> {
if (!PUSH_CONFIG_KEYS.has(String(key || '').trim())) return
if (key === 'dbPath' || key === 'decryptKey' || key === 'myWxid') {
this.resetRuntimeState()
chatService.close()
}
await this.refreshConfiguration(`config:${key}`)
}
handleConfigCleared(): void {
this.resetRuntimeState()
chatService.close()
}
private isPushEnabled(): boolean {
return this.configService.get('messagePushEnabled') === true
}
private resetRuntimeState(): void {
this.sessionBaseline.clear()
this.recentMessageKeys.clear()
this.groupNicknameCache.clear()
this.baselineReady = false
if (this.debounceTimer) {
clearTimeout(this.debounceTimer)
this.debounceTimer = null
}
}
private async refreshConfiguration(reason: string): Promise<void> {
if (!this.isPushEnabled()) {
this.resetRuntimeState()
return
}
const connectResult = await chatService.connect()
if (!connectResult.success) {
console.warn(`[MessagePushService] Bootstrap connect failed (${reason}):`, connectResult.error)
return
}
await this.bootstrapBaseline()
}
private async bootstrapBaseline(): Promise<void> {
const sessionsResult = await chatService.getSessions()
if (!sessionsResult.success || !sessionsResult.sessions) {
return
}
this.setBaseline(sessionsResult.sessions as ChatSession[])
this.baselineReady = true
}
private scheduleSync(): void {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer)
}
this.debounceTimer = setTimeout(() => {
this.debounceTimer = null
void this.flushPendingChanges()
}, this.debounceMs)
}
private async flushPendingChanges(): Promise<void> {
if (this.processing) {
this.rerunRequested = true
return
}
this.processing = true
try {
if (!this.isPushEnabled()) return
const connectResult = await chatService.connect()
if (!connectResult.success) {
console.warn('[MessagePushService] Sync connect failed:', connectResult.error)
return
}
const sessionsResult = await chatService.getSessions()
if (!sessionsResult.success || !sessionsResult.sessions) {
return
}
const sessions = sessionsResult.sessions as ChatSession[]
if (!this.baselineReady) {
this.setBaseline(sessions)
this.baselineReady = true
return
}
const previousBaseline = new Map(this.sessionBaseline)
this.setBaseline(sessions)
const candidates = sessions.filter((session) => this.shouldInspectSession(previousBaseline.get(session.username), session))
for (const session of candidates) {
await this.pushSessionMessages(session, previousBaseline.get(session.username))
}
} finally {
this.processing = false
if (this.rerunRequested) {
this.rerunRequested = false
this.scheduleSync()
}
}
}
private setBaseline(sessions: ChatSession[]): void {
this.sessionBaseline.clear()
for (const session of sessions) {
this.sessionBaseline.set(session.username, {
lastTimestamp: Number(session.lastTimestamp || 0),
unreadCount: Number(session.unreadCount || 0)
})
}
}
private shouldInspectSession(previous: SessionBaseline | undefined, session: ChatSession): boolean {
const sessionId = String(session.username || '').trim()
if (!sessionId || sessionId.toLowerCase().includes('placeholder_foldgroup')) {
return false
}
const summary = String(session.summary || '').trim()
if (Number(session.lastMsgType || 0) === 10002 || summary.includes('撤回了一条消息')) {
return false
}
const lastTimestamp = Number(session.lastTimestamp || 0)
const unreadCount = Number(session.unreadCount || 0)
if (!previous) {
return unreadCount > 0 && lastTimestamp > 0
}
if (lastTimestamp <= previous.lastTimestamp) {
return false
}
// unread 未增长时,大概率是自己发送、其他设备已读或状态同步,不作为主动推送
return unreadCount > previous.unreadCount
}
private async pushSessionMessages(session: ChatSession, previous: SessionBaseline | undefined): Promise<void> {
const since = Math.max(0, Number(previous?.lastTimestamp || 0) - 1)
const newMessagesResult = await chatService.getNewMessages(session.username, since, 1000)
if (!newMessagesResult.success || !newMessagesResult.messages || newMessagesResult.messages.length === 0) {
return
}
for (const message of newMessagesResult.messages) {
const messageKey = String(message.messageKey || '').trim()
if (!messageKey) continue
if (message.isSend === 1) continue
if (previous && Number(message.createTime || 0) < Number(previous.lastTimestamp || 0)) {
continue
}
if (this.isRecentMessage(messageKey)) {
continue
}
const payload = await this.buildPayload(session, message)
if (!payload) continue
httpService.broadcastMessagePush(payload)
this.rememberMessageKey(messageKey)
}
}
private async buildPayload(session: ChatSession, message: Message): Promise<MessagePushPayload | null> {
const sessionId = String(session.username || '').trim()
const messageKey = String(message.messageKey || '').trim()
if (!sessionId || !messageKey) return null
const isGroup = sessionId.endsWith('@chatroom')
const content = this.getMessageDisplayContent(message)
if (isGroup) {
const groupInfo = await chatService.getContactAvatar(sessionId)
const groupName = session.displayName || groupInfo?.displayName || sessionId
const sourceName = await this.resolveGroupSourceName(sessionId, message, session)
return {
event: 'message.new',
sessionId,
messageKey,
avatarUrl: session.avatarUrl || groupInfo?.avatarUrl,
groupName,
sourceName,
content
}
}
const contactInfo = await chatService.getContactAvatar(sessionId)
return {
event: 'message.new',
sessionId,
messageKey,
avatarUrl: session.avatarUrl || contactInfo?.avatarUrl,
sourceName: session.displayName || contactInfo?.displayName || sessionId,
content
}
}
private getMessageDisplayContent(message: Message): string | null {
switch (Number(message.localType || 0)) {
case 1:
return message.rawContent || null
case 3:
return '[图片]'
case 34:
return '[语音]'
case 43:
return '[视频]'
case 47:
return '[表情]'
case 42:
return message.cardNickname || '[名片]'
case 48:
return '[位置]'
case 49:
return message.linkTitle || message.fileName || '[消息]'
default:
return message.parsedContent || message.rawContent || null
}
}
private async resolveGroupSourceName(chatroomId: string, message: Message, session: ChatSession): Promise<string> {
const senderUsername = String(message.senderUsername || '').trim()
if (!senderUsername) {
return session.lastSenderDisplayName || '未知发送者'
}
const groupNicknames = await this.getGroupNicknames(chatroomId)
const normalizedSender = this.normalizeAccountId(senderUsername)
const nickname = groupNicknames[senderUsername]
|| groupNicknames[senderUsername.toLowerCase()]
|| groupNicknames[normalizedSender]
|| groupNicknames[normalizedSender.toLowerCase()]
if (nickname) {
return nickname
}
const contactInfo = await chatService.getContactAvatar(senderUsername)
return contactInfo?.displayName || senderUsername
}
private async getGroupNicknames(chatroomId: string): Promise<Record<string, string>> {
const cacheKey = String(chatroomId || '').trim()
if (!cacheKey) return {}
const cached = this.groupNicknameCache.get(cacheKey)
if (cached && Date.now() - cached.updatedAt < this.groupNicknameCacheTtlMs) {
return cached.nicknames
}
const result = await wcdbService.getGroupNicknames(cacheKey)
const nicknames = result.success && result.nicknames ? result.nicknames : {}
this.groupNicknameCache.set(cacheKey, { nicknames, updatedAt: Date.now() })
return nicknames
}
private normalizeAccountId(value: string): string {
const trimmed = String(value || '').trim()
if (!trimmed) return trimmed
if (trimmed.toLowerCase().startsWith('wxid_')) {
const match = trimmed.match(/^(wxid_[^_]+)/i)
return match ? match[1] : trimmed
}
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
return suffixMatch ? suffixMatch[1] : trimmed
}
private isRecentMessage(messageKey: string): boolean {
this.pruneRecentMessageKeys()
const timestamp = this.recentMessageKeys.get(messageKey)
return typeof timestamp === 'number' && Date.now() - timestamp < this.recentMessageTtlMs
}
private rememberMessageKey(messageKey: string): void {
this.recentMessageKeys.set(messageKey, Date.now())
this.pruneRecentMessageKeys()
}
private pruneRecentMessageKeys(): void {
const now = Date.now()
for (const [key, timestamp] of this.recentMessageKeys.entries()) {
if (now - timestamp > this.recentMessageTtlMs) {
this.recentMessageKeys.delete(key)
}
}
}
}
export const messagePushService = new MessagePushService()

View File

@@ -292,7 +292,9 @@ class SnsService {
private contactCache: ContactCacheService
private imageCache = new Map<string, string>()
private exportStatsCache: { totalPosts: number; totalFriends: number; myPosts: number | null; updatedAt: number } | null = null
private userPostCountsCache: { counts: Record<string, number>; updatedAt: number } | null = null
private readonly exportStatsCacheTtlMs = 5 * 60 * 1000
private readonly userPostCountsCacheTtlMs = 5 * 60 * 1000
private lastTimelineFallbackAt = 0
private readonly timelineFallbackCooldownMs = 3 * 60 * 1000
@@ -661,100 +663,24 @@ class SnsService {
}
async getSnsUsernames(): Promise<{ success: boolean; usernames?: string[]; error?: string }> {
const collect = (rows?: any[]): string[] => {
if (!Array.isArray(rows)) return []
const usernames: string[] = []
for (const row of rows) {
const raw = row?.user_name ?? row?.userName ?? row?.username ?? Object.values(row || {})[0]
const username = typeof raw === 'string' ? raw.trim() : String(raw || '').trim()
if (username) usernames.push(username)
}
return usernames
const result = await wcdbService.getSnsUsernames()
if (!result.success) {
return { success: false, error: result.error || '获取朋友圈联系人失败' }
}
const primary = await wcdbService.execQuery(
'sns',
null,
"SELECT DISTINCT user_name FROM SnsTimeLine WHERE user_name IS NOT NULL AND user_name <> ''"
)
const fallback = await wcdbService.execQuery(
'sns',
null,
"SELECT DISTINCT userName FROM SnsTimeLine WHERE userName IS NOT NULL AND userName <> ''"
)
const merged = Array.from(new Set([
...collect(primary.rows),
...collect(fallback.rows)
]))
// 任一查询成功且拿到用户名即视为成功,避免因为列名差异导致误判为空。
if (merged.length > 0) {
return { success: true, usernames: merged }
}
// 两条查询都成功但无数据,说明确实没有朋友圈发布者。
if (primary.success || fallback.success) {
return { success: true, usernames: [] }
}
return { success: false, error: primary.error || fallback.error || '获取朋友圈联系人失败' }
return { success: true, usernames: result.usernames || [] }
}
private async getExportStatsFromTableCount(myWxid?: string): Promise<{ totalPosts: number; totalFriends: number; myPosts: number | null }> {
let totalPosts = 0
let totalFriends = 0
let myPosts: number | null = null
const postCountResult = await wcdbService.execQuery('sns', null, 'SELECT COUNT(1) AS total FROM SnsTimeLine')
if (postCountResult.success && postCountResult.rows && postCountResult.rows.length > 0) {
totalPosts = this.parseCountValue(postCountResult.rows[0])
}
if (totalPosts > 0) {
const friendCountPrimary = await wcdbService.execQuery(
'sns',
null,
"SELECT COUNT(DISTINCT user_name) AS total FROM SnsTimeLine WHERE user_name IS NOT NULL AND user_name <> ''"
)
if (friendCountPrimary.success && friendCountPrimary.rows && friendCountPrimary.rows.length > 0) {
totalFriends = this.parseCountValue(friendCountPrimary.rows[0])
} else {
const friendCountFallback = await wcdbService.execQuery(
'sns',
null,
"SELECT COUNT(DISTINCT userName) AS total FROM SnsTimeLine WHERE userName IS NOT NULL AND userName <> ''"
)
if (friendCountFallback.success && friendCountFallback.rows && friendCountFallback.rows.length > 0) {
totalFriends = this.parseCountValue(friendCountFallback.rows[0])
}
}
}
const normalizedMyWxid = this.toOptionalString(myWxid)
if (normalizedMyWxid) {
const myPostPrimary = await wcdbService.execQuery(
'sns',
null,
"SELECT COUNT(1) AS total FROM SnsTimeLine WHERE user_name = ?",
[normalizedMyWxid]
)
if (myPostPrimary.success && myPostPrimary.rows && myPostPrimary.rows.length > 0) {
myPosts = this.parseCountValue(myPostPrimary.rows[0])
} else {
const myPostFallback = await wcdbService.execQuery(
'sns',
null,
"SELECT COUNT(1) AS total FROM SnsTimeLine WHERE userName = ?",
[normalizedMyWxid]
)
if (myPostFallback.success && myPostFallback.rows && myPostFallback.rows.length > 0) {
myPosts = this.parseCountValue(myPostFallback.rows[0])
}
}
const result = await wcdbService.getSnsExportStats(normalizedMyWxid || undefined)
if (!result.success || !result.data) {
return { totalPosts: 0, totalFriends: 0, myPosts: normalizedMyWxid ? 0 : null }
}
return {
totalPosts: Number(result.data.totalPosts || 0),
totalFriends: Number(result.data.totalFriends || 0),
myPosts: result.data.myPosts === null || result.data.myPosts === undefined ? null : Number(result.data.myPosts || 0)
}
return { totalPosts, totalFriends, myPosts }
}
async getExportStats(options?: {
@@ -864,6 +790,84 @@ class SnsService {
})
}
private async getUserPostCountsFromTimeline(): Promise<Record<string, number>> {
const pageSize = 500
const counts: Record<string, number> = {}
let offset = 0
for (let round = 0; round < 2000; round++) {
const result = await wcdbService.getSnsTimeline(pageSize, offset, undefined, undefined, 0, 0)
if (!result.success || !Array.isArray(result.timeline)) {
throw new Error(result.error || '获取朋友圈用户总条数失败')
}
const rows = result.timeline
if (rows.length === 0) break
for (const row of rows) {
const username = this.pickTimelineUsername(row)
if (!username) continue
counts[username] = (counts[username] || 0) + 1
}
if (rows.length < pageSize) break
offset += rows.length
}
return counts
}
async getUserPostCounts(options?: {
preferCache?: boolean
}): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> {
const preferCache = options?.preferCache ?? true
const now = Date.now()
try {
if (
preferCache &&
this.userPostCountsCache &&
now - this.userPostCountsCache.updatedAt <= this.userPostCountsCacheTtlMs
) {
return { success: true, counts: this.userPostCountsCache.counts }
}
const counts = await this.getUserPostCountsFromTimeline()
this.userPostCountsCache = {
counts,
updatedAt: Date.now()
}
return { success: true, counts }
} catch (error) {
console.error('[SnsService] getUserPostCounts failed:', error)
if (this.userPostCountsCache) {
return { success: true, counts: this.userPostCountsCache.counts }
}
return { success: false, error: String(error) }
}
}
async getUserPostStats(username: string): Promise<{ success: boolean; data?: { username: string; totalPosts: number }; error?: string }> {
const normalizedUsername = this.toOptionalString(username)
if (!normalizedUsername) {
return { success: false, error: '用户名不能为空' }
}
const countsResult = await this.getUserPostCounts({ preferCache: true })
if (countsResult.success) {
const totalPosts = countsResult.counts?.[normalizedUsername] ?? 0
return {
success: true,
data: {
username: normalizedUsername,
totalPosts: Math.max(0, Number(totalPosts || 0))
}
}
}
return { success: false, error: countsResult.error || '统计单个好友朋友圈失败' }
}
// 安装朋友圈删除拦截
async installSnsBlockDeleteTrigger(): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> {
return wcdbService.installSnsBlockDeleteTrigger()
@@ -881,7 +885,12 @@ class SnsService {
// 从数据库直接删除朋友圈记录
async deleteSnsPost(postId: string): Promise<{ success: boolean; error?: string }> {
return wcdbService.deleteSnsPost(postId)
const result = await wcdbService.deleteSnsPost(postId)
if (result.success) {
this.userPostCountsCache = null
this.exportStatsCache = null
}
return result
}
/**

View File

@@ -2,357 +2,556 @@
import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync } from 'fs'
import { app } from 'electron'
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
videoUrl?: string // 视频文件路径(用于 readFile
coverUrl?: string // 封面 data URL
thumbUrl?: string // 缩略图 data URL
exists: boolean
}
interface TimedCacheEntry<T> {
value: T
expiresAt: number
}
interface VideoIndexEntry {
videoPath?: string
coverPath?: string
thumbPath?: string
}
class VideoService {
private configService: ConfigService
private configService: ConfigService
private hardlinkResolveCache = new Map<string, TimedCacheEntry<string | null>>()
private videoInfoCache = new Map<string, TimedCacheEntry<VideoInfo>>()
private videoDirIndexCache = new Map<string, TimedCacheEntry<Map<string, VideoIndexEntry>>>()
private pendingVideoInfo = new Map<string, Promise<VideoInfo>>()
private readonly hardlinkCacheTtlMs = 10 * 60 * 1000
private readonly videoInfoCacheTtlMs = 2 * 60 * 1000
private readonly videoIndexCacheTtlMs = 90 * 1000
private readonly maxCacheEntries = 2000
private readonly maxIndexEntries = 6
constructor() {
this.configService = new ConfigService()
constructor() {
this.configService = new ConfigService()
}
private log(message: string, meta?: Record<string, unknown>): void {
try {
const timestamp = new Date().toISOString()
const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
const logDir = join(app.getPath('userData'), 'logs')
if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true })
appendFileSync(join(logDir, 'wcdb.log'), `[${timestamp}] [VideoService] ${message}${metaStr}\n`, 'utf8')
} catch { }
}
private readTimedCache<T>(cache: Map<string, TimedCacheEntry<T>>, key: string): T | undefined {
const hit = cache.get(key)
if (!hit) return undefined
if (hit.expiresAt <= Date.now()) {
cache.delete(key)
return undefined
}
return hit.value
}
private writeTimedCache<T>(
cache: Map<string, TimedCacheEntry<T>>,
key: string,
value: T,
ttlMs: number,
maxEntries: number
): void {
cache.set(key, { value, expiresAt: Date.now() + ttlMs })
if (cache.size <= maxEntries) return
const now = Date.now()
for (const [cacheKey, entry] of cache) {
if (entry.expiresAt <= now) {
cache.delete(cacheKey)
}
}
private log(message: string, meta?: Record<string, unknown>): void {
try {
const timestamp = new Date().toISOString()
const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
const logDir = join(app.getPath('userData'), 'logs')
if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true })
appendFileSync(join(logDir, 'wcdb.log'), `[${timestamp}] [VideoService] ${message}${metaStr}\n`, 'utf8')
} catch {}
while (cache.size > maxEntries) {
const oldestKey = cache.keys().next().value as string | undefined
if (!oldestKey) break
cache.delete(oldestKey)
}
}
/**
* 获取数据库根目录
*/
private getDbPath(): string {
return this.configService.get('dbPath') || ''
}
/**
* 获取当前用户的wxid
*/
private getMyWxid(): string {
return this.configService.get('myWxid') || ''
}
/**
* 清理 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
}
/**
* 获取数据库根目录
*/
private getDbPath(): string {
return this.configService.get('dbPath') || ''
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
if (suffixMatch) return suffixMatch[1]
return trimmed
}
private getScopeKey(dbPath: string, wxid: string): string {
return `${dbPath}::${this.cleanWxid(wxid)}`.toLowerCase()
}
private resolveVideoBaseDir(dbPath: string, wxid: string): string {
const cleanedWxid = this.cleanWxid(wxid)
const dbPathLower = dbPath.toLowerCase()
const wxidLower = wxid.toLowerCase()
const cleanedWxidLower = cleanedWxid.toLowerCase()
const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower)
if (dbPathContainsWxid) {
return join(dbPath, 'msg', 'video')
}
return join(dbPath, wxid, 'msg', 'video')
}
private getHardlinkDbPaths(dbPath: string, wxid: string, cleanedWxid: string): string[] {
const dbPathLower = dbPath.toLowerCase()
const wxidLower = wxid.toLowerCase()
const cleanedWxidLower = cleanedWxid.toLowerCase()
const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower)
if (dbPathContainsWxid) {
return [join(dbPath, 'db_storage', 'hardlink', 'hardlink.db')]
}
/**
* 获取当前用户的wxid
*/
private getMyWxid(): string {
return this.configService.get('myWxid') || ''
return [
join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'),
join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db')
]
}
/**
* 从 video_hardlink_info_v4 表查询视频文件名
* 使用 wcdb 专属接口查询加密的 hardlink.db
*/
private async resolveVideoHardlinks(
md5List: string[],
dbPath: string,
wxid: string,
cleanedWxid: string
): Promise<Map<string, string>> {
const scopeKey = this.getScopeKey(dbPath, wxid)
const normalizedList = Array.from(
new Set((md5List || []).map((item) => String(item || '').trim().toLowerCase()).filter(Boolean))
)
const resolvedMap = new Map<string, string>()
const unresolvedSet = new Set(normalizedList)
for (const md5 of normalizedList) {
const cacheKey = `${scopeKey}|${md5}`
const cached = this.readTimedCache(this.hardlinkResolveCache, cacheKey)
if (cached === undefined) continue
if (cached) resolvedMap.set(md5, cached)
unresolvedSet.delete(md5)
}
/**
* 获取缓存目录(解密后的数据库存放位置)
*/
private getCachePath(): string {
return this.configService.getCacheBasePath()
}
if (unresolvedSet.size === 0) return resolvedMap
/**
* 清理 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)
this.log('queryVideoFileName 开始', { md5, wxid, cleanedWxid, cachePath, dbPath })
if (!wxid) {
this.log('queryVideoFileName: 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 {
this.log('尝试缓存 hardlink.db', { path: p })
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(/\.[^.]+$/, '')
this.log('缓存 hardlink.db 命中', { file_name: row.file_name, realMd5 })
return realMd5
}
this.log('缓存 hardlink.db 未命中', { path: p })
} catch (e) {
this.log('缓存 hardlink.db 查询失败', { path: p, error: String(e) })
}
}
}
}
// 方法2使用 wcdbService.execQuery 查询加密的 hardlink.db
if (dbPath) {
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) {
encryptedDbPaths.push(join(dbPath, 'db_storage', 'hardlink', 'hardlink.db'))
} else {
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 {
this.log('尝试加密 hardlink.db', { path: p })
const escapedMd5 = md5.replace(/'/g, "''")
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) {
const realMd5 = String(row.file_name).replace(/\.[^.]+$/, '')
this.log('加密 hardlink.db 命中', { file_name: row.file_name, realMd5 })
return realMd5
}
}
this.log('加密 hardlink.db 未命中', { path: p, result: JSON.stringify(result).slice(0, 200) })
} catch (e) {
this.log('加密 hardlink.db 查询失败', { path: p, error: String(e) })
}
} else {
this.log('加密 hardlink.db 不存在', { path: p })
}
}
}
this.log('queryVideoFileName: 所有方法均未找到', { md5 })
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()
this.log('getVideoInfo 开始', { videoMd5, dbPath, wxid })
if (!dbPath || !wxid || !videoMd5) {
this.log('getVideoInfo: 参数缺失', { dbPath: !!dbPath, wxid: !!wxid, videoMd5: !!videoMd5 })
return { exists: false }
}
// 先尝试从数据库查询真正的视频文件名
const realVideoMd5 = await this.queryVideoFileName(videoMd5) || videoMd5
this.log('realVideoMd5', { input: videoMd5, resolved: realVideoMd5, changed: realVideoMd5 !== 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())) {
videoBaseDir = join(dbPath, 'msg', 'video')
const encryptedDbPaths = this.getHardlinkDbPaths(dbPath, wxid, cleanedWxid)
for (const p of encryptedDbPaths) {
if (!existsSync(p) || unresolvedSet.size === 0) continue
const unresolved = Array.from(unresolvedSet)
const requests = unresolved.map((md5) => ({ md5, dbPath: p }))
try {
const batchResult = await wcdbService.resolveVideoHardlinkMd5Batch(requests)
if (batchResult.success && Array.isArray(batchResult.rows)) {
for (const row of batchResult.rows) {
const index = Number.isFinite(Number(row?.index)) ? Math.floor(Number(row?.index)) : -1
const inputMd5 = index >= 0 && index < requests.length
? requests[index].md5
: String(row?.md5 || '').trim().toLowerCase()
if (!inputMd5) continue
const resolvedMd5 = row?.success && row?.data?.resolved_md5
? String(row.data.resolved_md5).trim().toLowerCase()
: ''
if (!resolvedMd5) continue
const cacheKey = `${scopeKey}|${inputMd5}`
this.writeTimedCache(this.hardlinkResolveCache, cacheKey, resolvedMd5, this.hardlinkCacheTtlMs, this.maxCacheEntries)
resolvedMap.set(inputMd5, resolvedMd5)
unresolvedSet.delete(inputMd5)
}
} else {
videoBaseDir = join(dbPath, wxid, 'msg', 'video')
// 兼容不支持批量接口的版本,回退单条请求。
for (const req of requests) {
try {
const single = await wcdbService.resolveVideoHardlinkMd5(req.md5, req.dbPath)
const resolvedMd5 = single.success && single.data?.resolved_md5
? String(single.data.resolved_md5).trim().toLowerCase()
: ''
if (!resolvedMd5) continue
const cacheKey = `${scopeKey}|${req.md5}`
this.writeTimedCache(this.hardlinkResolveCache, cacheKey, resolvedMd5, this.hardlinkCacheTtlMs, this.maxCacheEntries)
resolvedMap.set(req.md5, resolvedMd5)
unresolvedSet.delete(req.md5)
} catch { }
}
}
this.log('videoBaseDir', { videoBaseDir, exists: existsSync(videoBaseDir) })
if (!existsSync(videoBaseDir)) {
this.log('getVideoInfo: videoBaseDir 不存在')
return { exists: false }
}
// 遍历年月目录查找视频文件
try {
const allDirs = readdirSync(videoBaseDir)
const yearMonthDirs = allDirs
.filter(dir => {
const dirPath = join(videoBaseDir, dir)
return statSync(dirPath).isDirectory()
})
.sort((a, b) => b.localeCompare(a))
this.log('扫描目录', { dirs: yearMonthDirs })
for (const yearMonth of yearMonthDirs) {
const dirPath = join(videoBaseDir, yearMonth)
const videoPath = join(dirPath, `${realVideoMd5}.mp4`)
if (existsSync(videoPath)) {
// 封面/缩略图使用不带 _raw 后缀的基础名(自己发的视频文件名带 _raw但封面不带
const baseMd5 = realVideoMd5.replace(/_raw$/, '')
const coverPath = join(dirPath, `${baseMd5}.jpg`)
const thumbPath = join(dirPath, `${baseMd5}_thumb.jpg`)
// 列出同目录下与该 md5 相关的所有文件,帮助排查封面命名
const allFiles = readdirSync(dirPath)
const relatedFiles = allFiles.filter(f => f.toLowerCase().startsWith(realVideoMd5.slice(0, 8).toLowerCase()))
this.log('找到视频,相关文件列表', {
videoPath,
coverExists: existsSync(coverPath),
thumbExists: existsSync(thumbPath),
relatedFiles,
coverPath,
thumbPath
})
return {
videoUrl: videoPath,
coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'),
thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'),
exists: true
}
}
}
// 没找到,列出所有目录里的 mp4 文件帮助排查(最多每目录 10 个)
this.log('未找到视频,开始全目录扫描', {
lookingForOriginal: `${videoMd5}.mp4`,
lookingForResolved: `${realVideoMd5}.mp4`,
hardlinkResolved: realVideoMd5 !== videoMd5
})
for (const yearMonth of yearMonthDirs) {
const dirPath = join(videoBaseDir, yearMonth)
try {
const allFiles = readdirSync(dirPath)
const mp4Files = allFiles.filter(f => f.endsWith('.mp4')).slice(0, 10)
// 检查原始 md5 是否部分匹配前8位
const partialMatch = mp4Files.filter(f => f.toLowerCase().startsWith(videoMd5.slice(0, 8).toLowerCase()))
this.log(`目录 ${yearMonth} 扫描结果`, {
totalFiles: allFiles.length,
mp4Count: allFiles.filter(f => f.endsWith('.mp4')).length,
sampleMp4: mp4Files,
partialMatchByOriginalMd5: partialMatch
})
} catch (e) {
this.log(`目录 ${yearMonth} 读取失败`, { error: String(e) })
}
}
} catch (e) {
this.log('getVideoInfo 遍历出错', { error: String(e) })
}
this.log('getVideoInfo: 未找到视频', { videoMd5, realVideoMd5 })
return { exists: false }
} catch (e) {
this.log('resolveVideoHardlinks 批量查询失败', { path: p, error: String(e) })
}
}
/**
* 根据消息内容解析视频MD5
*/
parseVideoMd5(content: string): string | undefined {
if (!content) return undefined
for (const md5 of unresolvedSet) {
const cacheKey = `${scopeKey}|${md5}`
this.writeTimedCache(this.hardlinkResolveCache, cacheKey, null, this.hardlinkCacheTtlMs, this.maxCacheEntries)
}
// 打印原始 XML 前 800 字符,帮助排查自己发的视频结构
this.log('parseVideoMd5 原始内容', { preview: content.slice(0, 800) })
return resolvedMap
}
private async queryVideoFileName(md5: string): Promise<string | undefined> {
const normalizedMd5 = String(md5 || '').trim().toLowerCase()
const dbPath = this.getDbPath()
const wxid = this.getMyWxid()
const cleanedWxid = this.cleanWxid(wxid)
this.log('queryVideoFileName 开始', { md5: normalizedMd5, wxid, cleanedWxid, dbPath })
if (!normalizedMd5 || !wxid || !dbPath) {
this.log('queryVideoFileName: 参数缺失', { hasMd5: !!normalizedMd5, hasWxid: !!wxid, hasDbPath: !!dbPath })
return undefined
}
const resolvedMap = await this.resolveVideoHardlinks([normalizedMd5], dbPath, wxid, cleanedWxid)
const resolved = resolvedMap.get(normalizedMd5)
if (resolved) {
this.log('queryVideoFileName 命中', { input: normalizedMd5, resolved })
return resolved
}
return undefined
}
async preloadVideoHardlinkMd5s(md5List: string[]): Promise<void> {
const dbPath = this.getDbPath()
const wxid = this.getMyWxid()
const cleanedWxid = this.cleanWxid(wxid)
if (!dbPath || !wxid) return
await this.resolveVideoHardlinks(md5List, dbPath, wxid, cleanedWxid)
}
/**
* 将文件转换为 data URL
*/
private fileToDataUrl(filePath: string | undefined, mimeType: string): string | undefined {
try {
if (!filePath || !existsSync(filePath)) return undefined
const buffer = readFileSync(filePath)
return `data:${mimeType};base64,${buffer.toString('base64')}`
} catch {
return undefined
}
}
private getOrBuildVideoIndex(videoBaseDir: string): Map<string, VideoIndexEntry> {
const cached = this.readTimedCache(this.videoDirIndexCache, videoBaseDir)
if (cached) return cached
const index = new Map<string, VideoIndexEntry>()
const ensureEntry = (key: string): VideoIndexEntry => {
let entry = index.get(key)
if (!entry) {
entry = {}
index.set(key, entry)
}
return entry
}
try {
const yearMonthDirs = readdirSync(videoBaseDir)
.filter((dir) => {
const dirPath = join(videoBaseDir, dir)
try {
return statSync(dirPath).isDirectory()
} catch {
return false
}
})
.sort((a, b) => b.localeCompare(a))
for (const yearMonth of yearMonthDirs) {
const dirPath = join(videoBaseDir, yearMonth)
let files: string[] = []
try {
// 收集所有 md5 相关属性,方便对比
const allMd5Attrs: string[] = []
const md5Regex = /(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]*)['"]/gi
let match
while ((match = md5Regex.exec(content)) !== null) {
allMd5Attrs.push(match[0])
}
this.log('parseVideoMd5 所有 md5 属性', { attrs: allMd5Attrs })
// 方法1从 <videomsg md5="..."> 提取(收到的视频)
const videoMsgMd5Match = /<videomsg[^>]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
if (videoMsgMd5Match) {
this.log('parseVideoMd5 命中 videomsg md5 属性', { md5: videoMsgMd5Match[1] })
return videoMsgMd5Match[1].toLowerCase()
}
// 方法2从 <videomsg rawmd5="..."> 提取(自己发的视频,没有 md5 只有 rawmd5
const rawMd5Match = /<videomsg[^>]*\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
if (rawMd5Match) {
this.log('parseVideoMd5 命中 videomsg rawmd5 属性(自发视频)', { rawmd5: rawMd5Match[1] })
return rawMd5Match[1].toLowerCase()
}
// 方法3任意属性 md5="..."(非 rawmd5/cdnthumbaeskey 等)
const attrMatch = /(?<![a-z])md5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
if (attrMatch) {
this.log('parseVideoMd5 命中通用 md5 属性', { md5: attrMatch[1] })
return attrMatch[1].toLowerCase()
}
// 方法4<md5>...</md5> 标签
const md5TagMatch = /<md5>([a-fA-F0-9]+)<\/md5>/i.exec(content)
if (md5TagMatch) {
this.log('parseVideoMd5 命中 md5 标签', { md5: md5TagMatch[1] })
return md5TagMatch[1].toLowerCase()
}
// 方法5兜底取 rawmd5 属性(任意位置)
const rawMd5Fallback = /\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
if (rawMd5Fallback) {
this.log('parseVideoMd5 兜底命中 rawmd5', { rawmd5: rawMd5Fallback[1] })
return rawMd5Fallback[1].toLowerCase()
}
this.log('parseVideoMd5 未提取到任何 md5', { contentLength: content.length })
} catch (e) {
this.log('parseVideoMd5 异常', { error: String(e) })
files = readdirSync(dirPath)
} catch {
continue
}
return undefined
for (const file of files) {
const lower = file.toLowerCase()
const fullPath = join(dirPath, file)
if (lower.endsWith('.mp4')) {
const md5 = lower.slice(0, -4)
const entry = ensureEntry(md5)
if (!entry.videoPath) entry.videoPath = fullPath
if (md5.endsWith('_raw')) {
const baseMd5 = md5.replace(/_raw$/, '')
const baseEntry = ensureEntry(baseMd5)
if (!baseEntry.videoPath) baseEntry.videoPath = fullPath
}
continue
}
if (!lower.endsWith('.jpg')) continue
const jpgBase = lower.slice(0, -4)
if (jpgBase.endsWith('_thumb')) {
const baseMd5 = jpgBase.slice(0, -6)
const entry = ensureEntry(baseMd5)
if (!entry.thumbPath) entry.thumbPath = fullPath
} else {
const entry = ensureEntry(jpgBase)
if (!entry.coverPath) entry.coverPath = fullPath
}
}
}
for (const [key, entry] of index) {
if (!key.endsWith('_raw')) continue
const baseKey = key.replace(/_raw$/, '')
const baseEntry = index.get(baseKey)
if (!baseEntry) continue
if (!entry.coverPath) entry.coverPath = baseEntry.coverPath
if (!entry.thumbPath) entry.thumbPath = baseEntry.thumbPath
}
} catch (e) {
this.log('构建视频索引失败', { videoBaseDir, error: String(e) })
}
this.writeTimedCache(
this.videoDirIndexCache,
videoBaseDir,
index,
this.videoIndexCacheTtlMs,
this.maxIndexEntries
)
return index
}
private getVideoInfoFromIndex(index: Map<string, VideoIndexEntry>, md5: string, includePoster = true): VideoInfo | null {
const normalizedMd5 = String(md5 || '').trim().toLowerCase()
if (!normalizedMd5) return null
const candidates = [normalizedMd5]
const baseMd5 = normalizedMd5.replace(/_raw$/, '')
if (baseMd5 !== normalizedMd5) {
candidates.push(baseMd5)
} else {
candidates.push(`${normalizedMd5}_raw`)
}
for (const key of candidates) {
const entry = index.get(key)
if (!entry?.videoPath) continue
if (!existsSync(entry.videoPath)) continue
if (!includePoster) {
return {
videoUrl: entry.videoPath,
exists: true
}
}
return {
videoUrl: entry.videoPath,
coverUrl: this.fileToDataUrl(entry.coverPath, 'image/jpeg'),
thumbUrl: this.fileToDataUrl(entry.thumbPath, 'image/jpeg'),
exists: true
}
}
return null
}
private fallbackScanVideo(videoBaseDir: string, realVideoMd5: string, includePoster = true): VideoInfo | null {
try {
const yearMonthDirs = readdirSync(videoBaseDir)
.filter((dir) => {
const dirPath = join(videoBaseDir, dir)
try {
return statSync(dirPath).isDirectory()
} catch {
return false
}
})
.sort((a, b) => b.localeCompare(a))
for (const yearMonth of yearMonthDirs) {
const dirPath = join(videoBaseDir, yearMonth)
const videoPath = join(dirPath, `${realVideoMd5}.mp4`)
if (!existsSync(videoPath)) continue
if (!includePoster) {
return {
videoUrl: videoPath,
exists: true
}
}
const baseMd5 = realVideoMd5.replace(/_raw$/, '')
const coverPath = join(dirPath, `${baseMd5}.jpg`)
const thumbPath = join(dirPath, `${baseMd5}_thumb.jpg`)
return {
videoUrl: videoPath,
coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'),
thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'),
exists: true
}
}
} catch (e) {
this.log('fallback 扫描视频目录失败', { error: String(e) })
}
return null
}
/**
* 根据视频MD5获取视频文件信息
* 视频存放在: {数据库根目录}/{用户wxid}/msg/video/{年月}/
* 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg
*/
async getVideoInfo(videoMd5: string, options?: { includePoster?: boolean }): Promise<VideoInfo> {
const normalizedMd5 = String(videoMd5 || '').trim().toLowerCase()
const includePoster = options?.includePoster !== false
const dbPath = this.getDbPath()
const wxid = this.getMyWxid()
this.log('getVideoInfo 开始', { videoMd5: normalizedMd5, dbPath, wxid })
if (!dbPath || !wxid || !normalizedMd5) {
this.log('getVideoInfo: 参数缺失', { hasDbPath: !!dbPath, hasWxid: !!wxid, hasVideoMd5: !!normalizedMd5 })
return { exists: false }
}
const scopeKey = this.getScopeKey(dbPath, wxid)
const cacheKey = `${scopeKey}|${normalizedMd5}|poster=${includePoster ? 1 : 0}`
const cachedInfo = this.readTimedCache(this.videoInfoCache, cacheKey)
if (cachedInfo) return cachedInfo
const pending = this.pendingVideoInfo.get(cacheKey)
if (pending) return pending
const task = (async (): Promise<VideoInfo> => {
const realVideoMd5 = await this.queryVideoFileName(normalizedMd5) || normalizedMd5
const videoBaseDir = this.resolveVideoBaseDir(dbPath, wxid)
if (!existsSync(videoBaseDir)) {
const miss = { exists: false }
this.writeTimedCache(this.videoInfoCache, cacheKey, miss, this.videoInfoCacheTtlMs, this.maxCacheEntries)
return miss
}
const index = this.getOrBuildVideoIndex(videoBaseDir)
const indexed = this.getVideoInfoFromIndex(index, realVideoMd5, includePoster)
if (indexed) {
this.writeTimedCache(this.videoInfoCache, cacheKey, indexed, this.videoInfoCacheTtlMs, this.maxCacheEntries)
return indexed
}
const fallback = this.fallbackScanVideo(videoBaseDir, realVideoMd5, includePoster)
if (fallback) {
this.writeTimedCache(this.videoInfoCache, cacheKey, fallback, this.videoInfoCacheTtlMs, this.maxCacheEntries)
return fallback
}
const miss = { exists: false }
this.writeTimedCache(this.videoInfoCache, cacheKey, miss, this.videoInfoCacheTtlMs, this.maxCacheEntries)
this.log('getVideoInfo: 未找到视频', { inputMd5: normalizedMd5, resolvedMd5: realVideoMd5 })
return miss
})()
this.pendingVideoInfo.set(cacheKey, task)
try {
return await task
} finally {
this.pendingVideoInfo.delete(cacheKey)
}
}
/**
* 根据消息内容解析视频MD5
*/
parseVideoMd5(content: string): string | undefined {
if (!content) return undefined
// 打印原始 XML 前 800 字符,帮助排查自己发的视频结构
this.log('parseVideoMd5 原始内容', { preview: content.slice(0, 800) })
try {
// 收集所有 md5 相关属性,方便对比
const allMd5Attrs: string[] = []
const md5Regex = /(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]*)['"]/gi
let match
while ((match = md5Regex.exec(content)) !== null) {
allMd5Attrs.push(match[0])
}
this.log('parseVideoMd5 所有 md5 属性', { attrs: allMd5Attrs })
// 方法1从 <videomsg md5="..."> 提取(收到的视频)
const videoMsgMd5Match = /<videomsg[^>]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
if (videoMsgMd5Match) {
this.log('parseVideoMd5 命中 videomsg md5 属性', { md5: videoMsgMd5Match[1] })
return videoMsgMd5Match[1].toLowerCase()
}
// 方法2从 <videomsg rawmd5="..."> 提取(自己发的视频,没有 md5 只有 rawmd5
const rawMd5Match = /<videomsg[^>]*\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
if (rawMd5Match) {
this.log('parseVideoMd5 命中 videomsg rawmd5 属性(自发视频)', { rawmd5: rawMd5Match[1] })
return rawMd5Match[1].toLowerCase()
}
// 方法3任意属性 md5="..."(非 rawmd5/cdnthumbaeskey 等)
const attrMatch = /(?<![a-z])md5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
if (attrMatch) {
this.log('parseVideoMd5 命中通用 md5 属性', { md5: attrMatch[1] })
return attrMatch[1].toLowerCase()
}
// 方法4<md5>...</md5> 标签
const md5TagMatch = /<md5>([a-fA-F0-9]+)<\/md5>/i.exec(content)
if (md5TagMatch) {
this.log('parseVideoMd5 命中 md5 标签', { md5: md5TagMatch[1] })
return md5TagMatch[1].toLowerCase()
}
// 方法5兜底取 rawmd5 属性(任意位置)
const rawMd5Fallback = /\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
if (rawMd5Fallback) {
this.log('parseVideoMd5 兜底命中 rawmd5', { rawmd5: rawMd5Fallback[1] })
return rawMd5Fallback[1].toLowerCase()
}
this.log('parseVideoMd5 未提取到任何 md5', { contentLength: content.length })
} catch (e) {
this.log('parseVideoMd5 异常', { error: String(e) })
}
return undefined
}
}
export const videoService = new VideoService()

View File

@@ -48,6 +48,38 @@ export class VoiceTranscribeService {
private recognizer: OfflineRecognizer | null = null
private isInitializing = false
private buildTranscribeWorkerEnv(): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = { ...process.env }
const platform = process.platform === 'win32' ? 'win' : process.platform
const platformPkg = `sherpa-onnx-${platform}-${process.arch}`
const candidates = [
join(__dirname, '..', 'node_modules', platformPkg),
join(__dirname, 'node_modules', platformPkg),
join(process.cwd(), 'node_modules', platformPkg),
process.resourcesPath ? join(process.resourcesPath, 'app.asar.unpacked', 'node_modules', platformPkg) : ''
].filter((item): item is string => Boolean(item) && existsSync(item))
if (process.platform === 'darwin') {
const key = 'DYLD_LIBRARY_PATH'
const existing = env[key] || ''
const merged = [...candidates, ...existing.split(':').filter(Boolean)]
env[key] = Array.from(new Set(merged)).join(':')
if (candidates.length === 0) {
console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`)
}
} else if (process.platform === 'linux') {
const key = 'LD_LIBRARY_PATH'
const existing = env[key] || ''
const merged = [...candidates, ...existing.split(':').filter(Boolean)]
env[key] = Array.from(new Set(merged)).join(':')
if (candidates.length === 0) {
console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`)
}
}
return env
}
private resolveModelDir(): string {
const configured = this.configService.get('whisperModelDir') as string | undefined
if (configured) return configured
@@ -206,17 +238,20 @@ export class VoiceTranscribeService {
}
}
const { Worker } = require('worker_threads')
const { fork } = require('child_process')
const workerPath = join(__dirname, 'transcribeWorker.js')
const worker = new Worker(workerPath, {
workerData: {
modelPath,
tokensPath,
wavData,
sampleRate: 16000,
languages: supportedLanguages
}
const worker = fork(workerPath, [], {
env: this.buildTranscribeWorkerEnv(),
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
serialization: 'advanced'
})
worker.send({
modelPath,
tokensPath,
wavData,
sampleRate: 16000,
languages: supportedLanguages
})
let finalTranscript = ''
@@ -227,11 +262,13 @@ export class VoiceTranscribeService {
} else if (msg.type === 'final') {
finalTranscript = msg.text
resolve({ success: true, transcript: finalTranscript })
worker.terminate()
worker.disconnect()
worker.kill()
} else if (msg.type === 'error') {
console.error('[VoiceTranscribe] Worker 错误:', msg.error)
resolve({ success: false, error: msg.error })
worker.terminate()
worker.disconnect()
worker.kill()
}
})

File diff suppressed because it is too large Load Diff

View File

@@ -136,7 +136,7 @@ export class WcdbService {
*/
setMonitor(callback: (type: string, json: string) => void): void {
this.monitorListener = callback;
this.callWorker('setMonitor').catch(() => { });
this.callWorker<{ success?: boolean }>('setMonitor').catch(() => { });
}
/**
@@ -174,10 +174,10 @@ export class WcdbService {
/**
* 关闭服务
*/
shutdown(): void {
this.close()
async shutdown(): Promise<void> {
try { await this.close() } catch {}
if (this.worker) {
this.worker.terminate()
try { await this.worker.terminate() } catch {}
this.worker = null
}
}
@@ -222,6 +222,48 @@ export class WcdbService {
return this.callWorker('getMessageCounts', { sessionIds })
}
async getSessionMessageCounts(sessionIds: string[]): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> {
return this.callWorker('getSessionMessageCounts', { sessionIds })
}
async getSessionMessageTypeStats(
sessionId: string,
beginTimestamp: number = 0,
endTimestamp: number = 0
): Promise<{ success: boolean; data?: any; error?: string }> {
return this.callWorker('getSessionMessageTypeStats', { sessionId, beginTimestamp, endTimestamp })
}
async getSessionMessageTypeStatsBatch(
sessionIds: string[],
options?: {
beginTimestamp?: number
endTimestamp?: number
quickMode?: boolean
includeGroupSenderCount?: boolean
}
): Promise<{ success: boolean; data?: Record<string, any>; error?: string }> {
return this.callWorker('getSessionMessageTypeStatsBatch', { sessionIds, options })
}
async getSessionMessageDateCounts(sessionId: string): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> {
return this.callWorker('getSessionMessageDateCounts', { sessionId })
}
async getSessionMessageDateCountsBatch(sessionIds: string[]): Promise<{ success: boolean; data?: Record<string, Record<string, number>>; error?: string }> {
return this.callWorker('getSessionMessageDateCountsBatch', { sessionIds })
}
async getMessagesByType(
sessionId: string,
localType: number,
ascending = false,
limit = 0,
offset = 0
): Promise<{ success: boolean; rows?: any[]; error?: string }> {
return this.callWorker('getMessagesByType', { sessionId, localType, ascending, limit, offset })
}
/**
* 获取联系人昵称
*/
@@ -287,6 +329,14 @@ export class WcdbService {
return this.callWorker('getMessageMeta', { dbPath, tableName, limit, offset })
}
async getMessageTableColumns(dbPath: string, tableName: string): Promise<{ success: boolean; columns?: string[]; error?: string }> {
return this.callWorker('getMessageTableColumns', { dbPath, tableName })
}
async getMessageTableTimeRange(dbPath: string, tableName: string): Promise<{ success: boolean; data?: any; error?: string }> {
return this.callWorker('getMessageTableTimeRange', { dbPath, tableName })
}
/**
* 获取联系人详情
*/
@@ -301,6 +351,26 @@ export class WcdbService {
return this.callWorker('getContactStatus', { usernames })
}
async getContactTypeCounts(): Promise<{ success: boolean; counts?: { private: number; group: number; official: number; former_friend: number }; error?: string }> {
return this.callWorker('getContactTypeCounts')
}
async getContactsCompact(usernames: string[] = []): Promise<{ success: boolean; contacts?: any[]; error?: string }> {
return this.callWorker('getContactsCompact', { usernames })
}
async getContactAliasMap(usernames: string[]): Promise<{ success: boolean; map?: Record<string, string>; error?: string }> {
return this.callWorker('getContactAliasMap', { usernames })
}
async getContactFriendFlags(usernames: string[]): Promise<{ success: boolean; map?: Record<string, boolean>; error?: string }> {
return this.callWorker('getContactFriendFlags', { usernames })
}
async getChatRoomExtBuffer(chatroomId: string): Promise<{ success: boolean; extBuffer?: string; error?: string }> {
return this.callWorker('getChatRoomExtBuffer', { chatroomId })
}
/**
* 获取聚合统计数据
*/
@@ -372,7 +442,7 @@ export class WcdbService {
}
/**
* 执行 SQL 查询(支持参数化查询
* 执行 SQL 查询(仅主进程内部使用fallback/diagnostic/低频兼容
*/
async execQuery(kind: string, path: string | null, sql: string, params: any[] = []): Promise<{ success: boolean; rows?: any[]; error?: string }> {
return this.callWorker('execQuery', { kind, path, sql, params })
@@ -406,6 +476,10 @@ export class WcdbService {
return this.callWorker('getMessageById', { sessionId, localId })
}
async searchMessages(keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number): Promise<{ success: boolean; messages?: any[]; error?: string }> {
return this.callWorker('searchMessages', { keyword, sessionId, limit, offset, beginTimestamp, endTimestamp })
}
/**
* 获取语音数据
*/
@@ -413,6 +487,40 @@ export class WcdbService {
return this.callWorker('getVoiceData', { sessionId, createTime, candidates, localId, svrId })
}
async getVoiceDataBatch(
requests: Array<{ session_id: string; create_time: number; local_id?: number; svr_id?: string | number; candidates?: string[] }>
): Promise<{ success: boolean; rows?: Array<{ index: number; hex?: string }>; error?: string }> {
return this.callWorker('getVoiceDataBatch', { requests })
}
async getMediaSchemaSummary(dbPath: string): Promise<{ success: boolean; data?: any; error?: string }> {
return this.callWorker('getMediaSchemaSummary', { dbPath })
}
async getHeadImageBuffers(usernames: string[]): Promise<{ success: boolean; map?: Record<string, string>; error?: string }> {
return this.callWorker('getHeadImageBuffers', { usernames })
}
async resolveImageHardlink(md5: string, accountDir?: string): Promise<{ success: boolean; data?: any; error?: string }> {
return this.callWorker('resolveImageHardlink', { md5, accountDir })
}
async resolveImageHardlinkBatch(
requests: Array<{ md5: string; accountDir?: string }>
): Promise<{ success: boolean; rows?: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }>; error?: string }> {
return this.callWorker('resolveImageHardlinkBatch', { requests })
}
async resolveVideoHardlinkMd5(md5: string, dbPath?: string): Promise<{ success: boolean; data?: any; error?: string }> {
return this.callWorker('resolveVideoHardlinkMd5', { md5, dbPath })
}
async resolveVideoHardlinkMd5Batch(
requests: Array<{ md5: string; dbPath?: string }>
): Promise<{ success: boolean; rows?: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }>; error?: string }> {
return this.callWorker('resolveVideoHardlinkMd5Batch', { requests })
}
/**
* 获取朋友圈
*/
@@ -427,6 +535,14 @@ export class WcdbService {
return this.callWorker('getSnsAnnualStats', { beginTimestamp, endTimestamp })
}
async getSnsUsernames(): Promise<{ success: boolean; usernames?: string[]; error?: string }> {
return this.callWorker('getSnsUsernames')
}
async getSnsExportStats(myWxid?: string): Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }> {
return this.callWorker('getSnsExportStats', { myWxid })
}
/**
* 安装朋友圈删除拦截
*/

View File

@@ -1,13 +1,56 @@
import { parentPort, workerData } from 'worker_threads'
import { existsSync } from 'fs'
import { join } from 'path'
interface WorkerParams {
modelPath: string
tokensPath: string
wavData: Buffer
wavData: Buffer | Uint8Array | { type: 'Buffer'; data: number[] }
sampleRate: number
languages?: string[]
}
function appendLibrarySearchPath(libDir: string): void {
if (!existsSync(libDir)) return
if (process.platform === 'darwin') {
const current = process.env.DYLD_LIBRARY_PATH || ''
const paths = current.split(':').filter(Boolean)
if (!paths.includes(libDir)) {
process.env.DYLD_LIBRARY_PATH = [libDir, ...paths].join(':')
}
return
}
if (process.platform === 'linux') {
const current = process.env.LD_LIBRARY_PATH || ''
const paths = current.split(':').filter(Boolean)
if (!paths.includes(libDir)) {
process.env.LD_LIBRARY_PATH = [libDir, ...paths].join(':')
}
}
}
function prepareSherpaRuntimeEnv(): void {
const platform = process.platform === 'win32' ? 'win' : process.platform
const platformPkg = `sherpa-onnx-${platform}-${process.arch}`
const resourcesPath = (process as any).resourcesPath as string | undefined
const candidates = [
// Dev: /project/dist-electron -> /project/node_modules/...
join(__dirname, '..', 'node_modules', platformPkg),
// Fallback for alternate layouts
join(__dirname, 'node_modules', platformPkg),
join(process.cwd(), 'node_modules', platformPkg),
// Packaged app: Resources/app.asar.unpacked/node_modules/...
resourcesPath ? join(resourcesPath, 'app.asar.unpacked', 'node_modules', platformPkg) : ''
].filter(Boolean)
for (const dir of candidates) {
appendLibrarySearchPath(dir)
}
}
// 语言标记映射
const LANGUAGE_TAGS: Record<string, string> = {
'zh': '<|zh|>',
@@ -95,22 +138,60 @@ function isLanguageAllowed(result: any, allowedLanguages: string[]): boolean {
}
async function run() {
if (!parentPort) {
return;
const isForkProcess = !parentPort
const emit = (msg: any) => {
if (parentPort) {
parentPort.postMessage(msg)
return
}
if (typeof process.send === 'function') {
process.send(msg)
}
}
const normalizeBuffer = (data: WorkerParams['wavData']): Buffer => {
if (Buffer.isBuffer(data)) return data
if (data instanceof Uint8Array) return Buffer.from(data)
if (data && typeof data === 'object' && (data as any).type === 'Buffer' && Array.isArray((data as any).data)) {
return Buffer.from((data as any).data)
}
return Buffer.alloc(0)
}
const readParams = async (): Promise<WorkerParams | null> => {
if (parentPort) {
return workerData as WorkerParams
}
return new Promise((resolve) => {
let settled = false
const finish = (value: WorkerParams | null) => {
if (settled) return
settled = true
resolve(value)
}
process.once('message', (msg) => finish(msg as WorkerParams))
process.once('disconnect', () => finish(null))
})
}
try {
prepareSherpaRuntimeEnv()
const params = await readParams()
if (!params) return
// 动态加载以捕获可能的加载错误(如 C++ 运行库缺失等)
let sherpa: any;
try {
sherpa = require('sherpa-onnx-node');
} catch (requireError) {
parentPort.postMessage({ type: 'error', error: 'Failed to load speech engine: ' + String(requireError) });
emit({ type: 'error', error: 'Failed to load speech engine: ' + String(requireError) });
if (isForkProcess) process.exit(1)
return;
}
const { modelPath, tokensPath, wavData: rawWavData, sampleRate, languages } = workerData as WorkerParams
const wavData = Buffer.from(rawWavData);
const { modelPath, tokensPath, wavData: rawWavData, sampleRate, languages } = params
const wavData = normalizeBuffer(rawWavData);
// 确保有有效的语言列表,默认只允许中文
let allowedLanguages = languages || ['zh']
if (allowedLanguages.length === 0) {
@@ -151,16 +232,18 @@ async function run() {
if (isLanguageAllowed(result, allowedLanguages)) {
const processedText = richTranscribePostProcess(result.text)
parentPort.postMessage({ type: 'final', text: processedText })
emit({ type: 'final', text: processedText })
if (isForkProcess) process.exit(0)
} else {
parentPort.postMessage({ type: 'final', text: '' })
emit({ type: 'final', text: '' })
if (isForkProcess) process.exit(0)
}
} catch (error) {
parentPort.postMessage({ type: 'error', error: String(error) })
emit({ type: 'error', error: String(error) })
if (isForkProcess) process.exit(1)
}
}
run();

View File

@@ -20,15 +20,17 @@ if (parentPort) {
result = { success: true }
break
case 'setMonitor':
core.setMonitor((type, json) => {
{
const monitorOk = core.setMonitor((type, json) => {
parentPort!.postMessage({
id: -1,
type: 'monitor',
payload: { type, json }
})
})
result = { success: true }
result = { success: monitorOk }
break
}
case 'testConnection':
result = await core.testConnection(payload.dbPath, payload.hexKey, payload.wxid)
break
@@ -57,6 +59,24 @@ if (parentPort) {
case 'getMessageCounts':
result = await core.getMessageCounts(payload.sessionIds)
break
case 'getSessionMessageCounts':
result = await core.getSessionMessageCounts(payload.sessionIds)
break
case 'getSessionMessageTypeStats':
result = await core.getSessionMessageTypeStats(payload.sessionId, payload.beginTimestamp, payload.endTimestamp)
break
case 'getSessionMessageTypeStatsBatch':
result = await core.getSessionMessageTypeStatsBatch(payload.sessionIds, payload.options)
break
case 'getSessionMessageDateCounts':
result = await core.getSessionMessageDateCounts(payload.sessionId)
break
case 'getSessionMessageDateCountsBatch':
result = await core.getSessionMessageDateCountsBatch(payload.sessionIds)
break
case 'getMessagesByType':
result = await core.getMessagesByType(payload.sessionId, payload.localType, payload.ascending, payload.limit, payload.offset)
break
case 'getDisplayNames':
result = await core.getDisplayNames(payload.usernames)
break
@@ -87,12 +107,33 @@ if (parentPort) {
case 'getMessageMeta':
result = await core.getMessageMeta(payload.dbPath, payload.tableName, payload.limit, payload.offset)
break
case 'getMessageTableColumns':
result = await core.getMessageTableColumns(payload.dbPath, payload.tableName)
break
case 'getMessageTableTimeRange':
result = await core.getMessageTableTimeRange(payload.dbPath, payload.tableName)
break
case 'getContact':
result = await core.getContact(payload.username)
break
case 'getContactStatus':
result = await core.getContactStatus(payload.usernames)
break
case 'getContactTypeCounts':
result = await core.getContactTypeCounts()
break
case 'getContactsCompact':
result = await core.getContactsCompact(payload.usernames)
break
case 'getContactAliasMap':
result = await core.getContactAliasMap(payload.usernames)
break
case 'getContactFriendFlags':
result = await core.getContactFriendFlags(payload.usernames)
break
case 'getChatRoomExtBuffer':
result = await core.getChatRoomExtBuffer(payload.chatroomId)
break
case 'getAggregateStats':
result = await core.getAggregateStats(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp)
break
@@ -138,18 +179,48 @@ if (parentPort) {
case 'getMessageById':
result = await core.getMessageById(payload.sessionId, payload.localId)
break
case 'searchMessages':
result = await core.searchMessages(payload.keyword, payload.sessionId, payload.limit, payload.offset, payload.beginTimestamp, payload.endTimestamp)
break
case 'getVoiceData':
result = await core.getVoiceData(payload.sessionId, payload.createTime, payload.candidates, payload.localId, payload.svrId)
if (!result.success) {
console.error('[wcdbWorker] getVoiceData failed:', result.error)
}
break
case 'getVoiceDataBatch':
result = await core.getVoiceDataBatch(payload.requests)
break
case 'getMediaSchemaSummary':
result = await core.getMediaSchemaSummary(payload.dbPath)
break
case 'getHeadImageBuffers':
result = await core.getHeadImageBuffers(payload.usernames)
break
case 'resolveImageHardlink':
result = await core.resolveImageHardlink(payload.md5, payload.accountDir)
break
case 'resolveImageHardlinkBatch':
result = await core.resolveImageHardlinkBatch(payload.requests)
break
case 'resolveVideoHardlinkMd5':
result = await core.resolveVideoHardlinkMd5(payload.md5, payload.dbPath)
break
case 'resolveVideoHardlinkMd5Batch':
result = await core.resolveVideoHardlinkMd5Batch(payload.requests)
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 'getSnsUsernames':
result = await core.getSnsUsernames()
break
case 'getSnsExportStats':
result = await core.getSnsExportStats(payload.myWxid)
break
case 'installSnsBlockDeleteTrigger':
result = await core.installSnsBlockDeleteTrigger()
break

View File

@@ -5,6 +5,28 @@ import { ConfigService } from '../services/config'
let notificationWindow: BrowserWindow | null = null
let closeTimer: NodeJS.Timeout | null = null
export function destroyNotificationWindow() {
if (closeTimer) {
clearTimeout(closeTimer)
closeTimer = null
}
lastNotificationData = null
if (!notificationWindow || notificationWindow.isDestroyed()) {
notificationWindow = null
return
}
const win = notificationWindow
notificationWindow = null
try {
win.destroy()
} catch (error) {
console.warn('[NotificationWindow] Failed to destroy window:', error)
}
}
export function createNotificationWindow() {
if (notificationWindow && !notificationWindow.isDestroyed()) {
return notificationWindow
@@ -110,7 +132,7 @@ async function showAndSend(win: BrowserWindow, data: any) {
// 更新位置
const { width: screenWidth, height: screenHeight } = screen.getPrimaryDisplay().workAreaSize
const winWidth = 344
const winWidth = position === 'top-center' ? 280 : 344
const winHeight = 114
const padding = 20
@@ -118,6 +140,10 @@ async function showAndSend(win: BrowserWindow, data: any) {
let y = 0
switch (position) {
case 'top-center':
x = (screenWidth - winWidth) / 2
y = padding
break
case 'top-right':
x = screenWidth - winWidth - padding
y = padding
@@ -144,7 +170,7 @@ async function showAndSend(win: BrowserWindow, data: any) {
win.showInactive() // 显示但不聚焦
win.setAlwaysOnTop(true, 'screen-saver') // 最高层级
win.webContents.send('notification:show', data)
win.webContents.send('notification:show', { ...data, position })
// 自动关闭计时器通常由渲染进程管理
// 渲染进程发送 'notification:close' 来隐藏窗口

235
package-lock.json generated
View File

@@ -9,7 +9,6 @@
"version": "2.1.0",
"hasInstallScript": true,
"dependencies": {
"better-sqlite3": "^12.5.0",
"echarts": "^5.5.1",
"echarts-for-react": "^3.0.2",
"electron-store": "^10.0.0",
@@ -30,12 +29,12 @@
"remark-gfm": "^4.0.1",
"sherpa-onnx-node": "^1.10.38",
"silk-wasm": "^3.7.1",
"sudo-prompt": "^9.2.1",
"wechat-emojis": "^1.0.2",
"zustand": "^5.0.2"
},
"devDependencies": {
"@electron/rebuild": "^4.0.2",
"@types/better-sqlite3": "^7.6.13",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.3.4",
@@ -2784,16 +2783,6 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/better-sqlite3": {
"version": "7.6.13",
"resolved": "https://registry.npmmirror.com/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/cacheable-request": {
"version": "6.0.3",
"resolved": "https://registry.npmmirror.com/@types/cacheable-request/-/cacheable-request-6.0.3.tgz",
@@ -3868,20 +3857,6 @@
"baseline-browser-mapping": "dist/cli.js"
}
},
"node_modules/better-sqlite3": {
"version": "12.5.0",
"resolved": "https://registry.npmmirror.com/better-sqlite3/-/better-sqlite3-12.5.0.tgz",
"integrity": "sha512-WwCZ/5Diz7rsF29o27o0Gcc1Du+l7Zsv7SYtVPG0X3G/uUI1LqdxrQI7c9Hs2FWpqXXERjW9hp6g3/tH7DlVKg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
},
"engines": {
"node": "20.x || 22.x || 23.x || 24.x || 25.x"
}
},
"node_modules/big-integer": {
"version": "1.6.52",
"resolved": "https://registry.npmmirror.com/big-integer/-/big-integer-1.6.52.tgz",
@@ -3904,15 +3879,6 @@
"node": "*"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmmirror.com/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmmirror.com/bl/-/bl-4.1.0.tgz",
@@ -4924,6 +4890,7 @@
"version": "6.0.0",
"resolved": "https://registry.npmmirror.com/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
@@ -4939,6 +4906,7 @@
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@@ -4947,15 +4915,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmmirror.com/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/defaults": {
"version": "1.0.4",
"resolved": "https://registry.npmmirror.com/defaults/-/defaults-1.0.4.tgz",
@@ -5047,6 +5006,7 @@
"version": "2.1.2",
"resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
@@ -5817,15 +5777,6 @@
"node": ">=8.3.0"
}
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmmirror.com/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"engines": {
"node": ">=6"
}
},
"node_modules/exponential-backoff": {
"version": "3.1.3",
"resolved": "https://registry.npmmirror.com/exponential-backoff/-/exponential-backoff-3.1.3.tgz",
@@ -5964,12 +5915,6 @@
"node": ">= 6"
}
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
},
"node_modules/filelist": {
"version": "1.0.4",
"resolved": "https://registry.npmmirror.com/filelist/-/filelist-1.0.4.tgz",
@@ -6272,12 +6217,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmmirror.com/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz",
@@ -6744,12 +6683,6 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmmirror.com/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/inline-style-parser": {
"version": "0.2.7",
"resolved": "https://registry.npmmirror.com/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
@@ -8503,12 +8436,6 @@
"node": ">=10"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmmirror.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
@@ -8534,12 +8461,6 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-1.0.0.tgz",
@@ -9003,44 +8924,6 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmmirror.com/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/prebuild-install/node_modules/node-abi": {
"version": "3.85.0",
"resolved": "https://registry.npmmirror.com/node-abi/-/node-abi-3.85.0.tgz",
"integrity": "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/proc-log": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/proc-log/-/proc-log-5.0.0.tgz",
@@ -9101,6 +8984,7 @@
"version": "3.0.3",
"resolved": "https://registry.npmmirror.com/pump/-/pump-3.0.3.tgz",
"integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==",
"dev": true,
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
@@ -9130,21 +9014,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmmirror.com/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/react": {
"version": "19.2.3",
"resolved": "https://registry.npmmirror.com/react/-/react-19.2.3.tgz",
@@ -9823,6 +9692,9 @@
"sherpa-onnx-win-x64": "^1.12.23"
}
},
"node_modules/sherpa-onnx-node/node_modules/sherpa-onnx-darwin-x64": {
"optional": true
},
"node_modules/sherpa-onnx-win-ia32": {
"version": "1.12.23",
"resolved": "https://registry.npmmirror.com/sherpa-onnx-win-ia32/-/sherpa-onnx-win-ia32-1.12.23.tgz",
@@ -9865,51 +9737,6 @@
"node": ">=16.11.0"
}
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmmirror.com/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/simple-update-notifier": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
@@ -10139,15 +9966,6 @@
"node": ">=8"
}
},
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/stubborn-fs": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/stubborn-fs/-/stubborn-fs-2.0.0.tgz",
@@ -10181,6 +9999,13 @@
"inline-style-parser": "0.2.7"
}
},
"node_modules/sudo-prompt": {
"version": "9.2.1",
"resolved": "https://registry.npmmirror.com/sudo-prompt/-/sudo-prompt-9.2.1.tgz",
"integrity": "sha512-Mu7R0g4ig9TUuGSxJavny5Rv0egCEtpZRNMrZaYS1vxkiIxGiGUwoezU3LazIQ+KE04hTrTfNPgxU5gzi7F5Pw==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"license": "MIT"
},
"node_modules/sumchecker": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/sumchecker/-/sumchecker-3.0.1.tgz",
@@ -10225,24 +10050,6 @@
"node": ">=10"
}
},
"node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmmirror.com/tar-fs/-/tar-fs-2.1.4.tgz",
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-fs/node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmmirror.com/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmmirror.com/tar-stream/-/tar-stream-2.2.0.tgz",
@@ -10519,18 +10326,6 @@
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmmirror.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/type-fest": {
"version": "4.41.0",
"resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-4.41.0.tgz",

View File

@@ -3,7 +3,10 @@
"version": "2.1.0",
"description": "WeFlow",
"main": "dist-electron/main.js",
"author": "cc",
"author": {
"name": "cc",
"email": "yccccccy@proton.me"
},
"repository": {
"type": "git",
"url": "https://github.com/hicccc77/WeFlow"
@@ -20,7 +23,6 @@
"electron:build": "npm run build"
},
"dependencies": {
"better-sqlite3": "^12.5.0",
"echarts": "^5.5.1",
"echarts-for-react": "^3.0.2",
"electron-store": "^10.0.0",
@@ -41,12 +43,12 @@
"remark-gfm": "^4.0.1",
"sherpa-onnx-node": "^1.10.38",
"silk-wasm": "^3.7.1",
"sudo-prompt": "^9.2.1",
"wechat-emojis": "^1.0.2",
"zustand": "^5.0.2"
},
"devDependencies": {
"@electron/rebuild": "^4.0.2",
"@types/better-sqlite3": "^7.6.13",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.3.4",
@@ -72,12 +74,34 @@
"directories": {
"output": "release"
},
"mac": {
"target": [
"dmg",
"zip"
],
"category": "public.app-category.utilities",
"hardenedRuntime": false,
"gatekeeperAssess": false,
"entitlements": "electron/entitlements.mac.plist",
"entitlementsInherit": "electron/entitlements.mac.plist",
"icon": "resources/icon.icns"
},
"win": {
"target": [
"nsis"
],
"icon": "public/icon.ico"
},
"linux": {
"icon": "public/icon.png",
"target": [
"deb",
"tar.gz"
],
"category": "Utility",
"executableName": "weflow",
"synopsis": "WeFlow for Linux"
},
"nsis": {
"oneClick": false,
"differentialPackage": false,
@@ -108,6 +132,10 @@
"from": "public/icon.ico",
"to": "icon.ico"
},
{
"from": "public/icon.png",
"to": "icon.png"
},
{
"from": "electron/assets/wasm/",
"to": "assets/wasm/"
@@ -120,6 +148,8 @@
"asarUnpack": [
"node_modules/silk-wasm/**/*",
"node_modules/sherpa-onnx-node/**/*",
"node_modules/sherpa-onnx-*/*",
"node_modules/sherpa-onnx-*/**/*",
"node_modules/ffmpeg-static/**/*"
],
"extraFiles": [
@@ -139,6 +169,7 @@
"from": "resources/vcruntime140_1.dll",
"to": "."
}
]
],
"icon": "resources/icon.icns"
}
}

BIN
public/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
resources/icon.icns Normal file

Binary file not shown.

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.debugger</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
</dict>
</plist>

BIN
resources/image_scan_helper Executable file

Binary file not shown.

View File

@@ -0,0 +1,77 @@
/*
* image_scan_helper - 轻量包装程序
* 加载 libwx_key.dylib 并调用 ScanMemoryForImageKey
* 用法: image_scan_helper <pid> <ciphertext_hex>
* 输出: JSON {"success":true,"aesKey":"..."} 或 {"success":false,"error":"..."}
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <dlfcn.h>
#include <libgen.h>
#include <mach-o/dyld.h>
typedef const char* (*ScanMemoryForImageKeyFn)(int pid, const char* ciphertext);
typedef void (*FreeStringFn)(const char* str);
int main(int argc, char* argv[]) {
if (argc != 3) {
fprintf(stderr, "Usage: %s <pid> <ciphertext_hex>\n", argv[0]);
printf("{\"success\":false,\"error\":\"invalid arguments\"}\n");
return 1;
}
int pid = atoi(argv[1]);
const char* ciphertext_hex = argv[2];
if (pid <= 0) {
printf("{\"success\":false,\"error\":\"invalid pid\"}\n");
return 1;
}
/* 定位 dylib: 与自身同目录下的 libwx_key.dylib */
char exe_path[4096];
uint32_t size = sizeof(exe_path);
if (_NSGetExecutablePath(exe_path, &size) != 0) {
printf("{\"success\":false,\"error\":\"cannot get executable path\"}\n");
return 1;
}
char* dir = dirname(exe_path);
char dylib_path[4096];
snprintf(dylib_path, sizeof(dylib_path), "%s/libwx_key.dylib", dir);
void* handle = dlopen(dylib_path, RTLD_LAZY);
if (!handle) {
printf("{\"success\":false,\"error\":\"dlopen failed: %s\"}\n", dlerror());
return 1;
}
ScanMemoryForImageKeyFn scan_fn = (ScanMemoryForImageKeyFn)dlsym(handle, "ScanMemoryForImageKey");
if (!scan_fn) {
printf("{\"success\":false,\"error\":\"symbol not found: ScanMemoryForImageKey\"}\n");
dlclose(handle);
return 1;
}
FreeStringFn free_fn = (FreeStringFn)dlsym(handle, "FreeString");
fprintf(stderr, "[image_scan_helper] calling ScanMemoryForImageKey(pid=%d, ciphertext=%s)\n", pid, ciphertext_hex);
const char* result = scan_fn(pid, ciphertext_hex);
if (result && strlen(result) > 0) {
/* 检查是否是错误 */
if (strncmp(result, "ERROR", 5) == 0) {
printf("{\"success\":false,\"error\":\"%s\"}\n", result);
} else {
printf("{\"success\":true,\"aesKey\":\"%s\"}\n", result);
}
if (free_fn) free_fn(result);
} else {
printf("{\"success\":false,\"error\":\"no key found\"}\n");
}
dlclose(handle);
return 0;
}

BIN
resources/libwcdb_api.dylib Executable file

Binary file not shown.

BIN
resources/libwx_key.dylib Executable file

Binary file not shown.

BIN
resources/linux/libwcdb_api.so Executable file

Binary file not shown.

BIN
resources/macos/libWCDB.dylib Executable file

Binary file not shown.

BIN
resources/macos/libwcdb_api.dylib Executable file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
resources/xkey_helper Executable file

Binary file not shown.

BIN
resources/xkey_helper_linux Executable file

Binary file not shown.

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { Routes, Route, useNavigate, useLocation } from 'react-router-dom'
import { useEffect, useRef, useState } from 'react'
import { Routes, Route, Navigate, useNavigate, useLocation, type Location } from 'react-router-dom'
import TitleBar from './components/TitleBar'
import Sidebar from './components/Sidebar'
import RouteGuard from './components/RouteGuard'
@@ -8,6 +8,7 @@ import HomePage from './pages/HomePage'
import ChatPage from './pages/ChatPage'
import AnalyticsPage from './pages/AnalyticsPage'
import AnalyticsWelcomePage from './pages/AnalyticsWelcomePage'
import ChatAnalyticsHubPage from './pages/ChatAnalyticsHubPage'
import AnnualReportPage from './pages/AnnualReportPage'
import AnnualReportWindow from './pages/AnnualReportWindow'
import DualReportPage from './pages/DualReportPage'
@@ -36,10 +37,24 @@ import LockScreen from './components/LockScreen'
import { GlobalSessionMonitor } from './components/GlobalSessionMonitor'
import { BatchTranscribeGlobal } from './components/BatchTranscribeGlobal'
import { BatchImageDecryptGlobal } from './components/BatchImageDecryptGlobal'
import WindowCloseDialog from './components/WindowCloseDialog'
function RouteStateRedirect({ to }: { to: string }) {
const location = useLocation()
return <Navigate to={to} replace state={location.state} />
}
function App() {
const navigate = useNavigate()
const location = useLocation()
const settingsBackgroundRef = useRef<Location>({
pathname: '/home',
search: '',
hash: '',
state: null,
key: 'settings-fallback'
} as Location)
const {
setDbConnected,
@@ -60,11 +75,19 @@ function App() {
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 isChatHistoryWindow = location.pathname.startsWith('/chat-history/') || location.pathname.startsWith('/chat-history-inline/')
const isStandaloneChatWindow = location.pathname === '/chat-window'
const isNotificationWindow = location.pathname === '/notification-window'
const isExportRoute = location.pathname === '/export'
const isSettingsRoute = location.pathname === '/settings'
const settingsRouteState = location.state as { backgroundLocation?: Location; initialTab?: unknown } | null
const routeLocation = isSettingsRoute
? settingsRouteState?.backgroundLocation ?? settingsBackgroundRef.current
: location
const isExportRoute = routeLocation.pathname === '/export'
const [themeHydrated, setThemeHydrated] = useState(false)
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const [showCloseDialog, setShowCloseDialog] = useState(false)
const [canMinimizeToTray, setCanMinimizeToTray] = useState(false)
// 锁定状态
// const [isLocked, setIsLocked] = useState(false) // Moved to store
@@ -81,6 +104,21 @@ function App() {
// 数据收集同意状态
const [showAnalyticsConsent, setShowAnalyticsConsent] = useState(false)
useEffect(() => {
if (location.pathname !== '/settings') {
settingsBackgroundRef.current = location
}
}, [location])
useEffect(() => {
const removeCloseConfirmListener = window.electronAPI.window.onCloseConfirmRequested((payload) => {
setCanMinimizeToTray(Boolean(payload.canMinimizeToTray))
setShowCloseDialog(true)
})
return () => removeCloseConfirmListener()
}, [])
useEffect(() => {
const root = document.documentElement
const body = document.body
@@ -112,10 +150,6 @@ function App() {
const effectiveMode = mode === 'system' ? (systemDark ?? mq.matches ? 'dark' : 'light') : mode
document.documentElement.setAttribute('data-theme', currentTheme)
document.documentElement.setAttribute('data-mode', effectiveMode)
const symbolColor = effectiveMode === 'dark' ? '#ffffff' : '#1a1a1a'
if (!isOnboardingWindow && !isNotificationWindow) {
window.electronAPI.window.setTitleBarOverlay({ symbolColor })
}
}
applyMode(themeMode)
@@ -293,6 +327,26 @@ function App() {
setUpdateInfo(null)
}
const handleWindowCloseAction = async (
action: 'tray' | 'quit' | 'cancel',
rememberChoice = false
) => {
setShowCloseDialog(false)
if (rememberChoice && action !== 'cancel') {
try {
await configService.setWindowCloseBehavior(action)
} catch (error) {
console.error('保存关闭偏好失败:', error)
}
}
try {
await window.electronAPI.window.respondCloseConfirm(action)
} catch (error) {
console.error('处理关闭确认失败:', error)
}
}
// 启动时自动检查配置并连接数据库
useEffect(() => {
if (isAgreementWindow || isOnboardingWindow) return
@@ -405,8 +459,22 @@ function App() {
// 独立会话聊天窗口(仅显示聊天内容区域)
if (isStandaloneChatWindow) {
const sessionId = new URLSearchParams(location.search).get('sessionId') || ''
return <ChatPage standaloneSessionWindow initialSessionId={sessionId} />
const params = new URLSearchParams(location.search)
const sessionId = params.get('sessionId') || ''
const standaloneSource = params.get('source')
const standaloneInitialDisplayName = params.get('initialDisplayName')
const standaloneInitialAvatarUrl = params.get('initialAvatarUrl')
const standaloneInitialContactType = params.get('initialContactType')
return (
<ChatPage
standaloneSessionWindow
initialSessionId={sessionId}
standaloneSource={standaloneSource}
standaloneInitialDisplayName={standaloneInitialDisplayName}
standaloneInitialAvatarUrl={standaloneInitialAvatarUrl}
standaloneInitialContactType={standaloneInitialContactType}
/>
)
}
// 独立通知窗口
@@ -415,6 +483,25 @@ function App() {
}
// 主窗口 - 完整布局
const handleCloseSettings = () => {
const backgroundLocation = settingsRouteState?.backgroundLocation ?? settingsBackgroundRef.current
if (backgroundLocation.pathname === '/settings') {
navigate('/home', { replace: true })
return
}
navigate(
{
pathname: backgroundLocation.pathname,
search: backgroundLocation.search,
hash: backgroundLocation.hash
},
{
replace: true,
state: backgroundLocation.state
}
)
}
return (
<div className="app-container">
<div className="window-drag-region" aria-hidden="true" />
@@ -425,7 +512,10 @@ function App() {
useHello={lockUseHello}
/>
)}
<TitleBar />
<TitleBar
sidebarCollapsed={sidebarCollapsed}
onToggleSidebar={() => setSidebarCollapsed((prev) => !prev)}
/>
{/* 全局悬浮进度胶囊 (处理:新版本提示、下载进度、错误提示) */}
<UpdateProgressCapsule />
@@ -535,36 +625,50 @@ function App() {
progress={downloadProgress}
/>
<WindowCloseDialog
open={showCloseDialog}
canMinimizeToTray={canMinimizeToTray}
onSelect={(action, rememberChoice) => handleWindowCloseAction(action, rememberChoice)}
onCancel={() => handleWindowCloseAction('cancel')}
/>
<div className="main-layout">
<Sidebar />
<Sidebar collapsed={sidebarCollapsed} />
<main className="content">
<RouteGuard>
<div className={`export-keepalive-page ${isExportRoute ? 'active' : 'hidden'}`} aria-hidden={!isExportRoute}>
<ExportPage />
</div>
<Routes>
<Routes location={routeLocation}>
<Route path="/" element={<HomePage />} />
<Route path="/home" element={<HomePage />} />
<Route path="/chat" element={<ChatPage />} />
<Route path="/analytics" element={<AnalyticsWelcomePage />} />
<Route path="/analytics/view" element={<AnalyticsPage />} />
<Route path="/group-analytics" element={<GroupAnalyticsPage />} />
<Route path="/analytics" element={<ChatAnalyticsHubPage />} />
<Route path="/analytics/private" element={<AnalyticsWelcomePage />} />
<Route path="/analytics/private/view" element={<AnalyticsPage />} />
<Route path="/analytics/group" element={<GroupAnalyticsPage />} />
<Route path="/analytics/view" element={<RouteStateRedirect to="/analytics/private/view" />} />
<Route path="/group-analytics" element={<RouteStateRedirect to="/analytics/group" />} />
<Route path="/annual-report" element={<AnnualReportPage />} />
<Route path="/annual-report/view" element={<AnnualReportWindow />} />
<Route path="/dual-report" element={<DualReportPage />} />
<Route path="/dual-report/view" element={<DualReportWindow />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/export" element={<div className="export-route-anchor" aria-hidden="true" />} />
<Route path="/sns" element={<SnsPage />} />
<Route path="/contacts" element={<ContactsPage />} />
<Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} />
<Route path="/chat-history-inline/:payloadId" element={<ChatHistoryPage />} />
</Routes>
</RouteGuard>
</main>
</div>
{isSettingsRoute && (
<SettingsPage onClose={handleCloseSettings} />
)}
</div>
)
}

View File

@@ -50,6 +50,21 @@
border-radius: inherit;
}
.avatar-loading {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-tertiary, #999);
background-color: var(--bg-tertiary, #e0e0e0);
border-radius: inherit;
.avatar-loading-icon {
animation: avatar-spin 0.9s linear infinite;
}
}
/* Loading Skeleton */
.avatar-skeleton {
position: absolute;
@@ -76,4 +91,14 @@
background-position: -200% 0;
}
}
}
@keyframes avatar-spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
}

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef, useMemo } from 'react'
import { User } from 'lucide-react'
import { Loader2, User } from 'lucide-react'
import { avatarLoadQueue } from '../utils/AvatarLoadQueue'
import './Avatar.scss'
@@ -13,6 +13,7 @@ interface AvatarProps {
shape?: 'circle' | 'square' | 'rounded'
className?: string
lazy?: boolean
loading?: boolean
onClick?: () => void
}
@@ -23,12 +24,14 @@ export const Avatar = React.memo(function Avatar({
shape = 'rounded',
className = '',
lazy = true,
loading = false,
onClick
}: AvatarProps) {
// 如果 URL 已在缓存中,则直接标记为已加载,不显示骨架屏和淡入动画
const isCached = useMemo(() => src ? loadedAvatarCache.has(src) : false, [src])
const isFailed = useMemo(() => src ? avatarLoadQueue.hasFailed(src) : false, [src])
const [imageLoaded, setImageLoaded] = useState(isCached)
const [imageError, setImageError] = useState(false)
const [imageError, setImageError] = useState(isFailed)
const [shouldLoad, setShouldLoad] = useState(!lazy || isCached)
const [isInQueue, setIsInQueue] = useState(false)
const imgRef = useRef<HTMLImageElement>(null)
@@ -42,7 +45,7 @@ export const Avatar = React.memo(function Avatar({
// Intersection Observer for lazy loading
useEffect(() => {
if (!lazy || shouldLoad || isInQueue || !src || !containerRef.current || isCached) return
if (!lazy || shouldLoad || isInQueue || !src || !containerRef.current || isCached || imageError || isFailed) return
const observer = new IntersectionObserver(
(entries) => {
@@ -50,10 +53,11 @@ export const Avatar = React.memo(function Avatar({
if (entry.isIntersecting && !isInQueue) {
setIsInQueue(true)
avatarLoadQueue.enqueue(src).then(() => {
setImageError(false)
setShouldLoad(true)
}).catch(() => {
// 加载失败不要立刻显示错误,让浏览器渲染去报错
setShouldLoad(true)
setImageError(true)
setShouldLoad(false)
}).finally(() => {
setIsInQueue(false)
})
@@ -67,14 +71,18 @@ export const Avatar = React.memo(function Avatar({
observer.observe(containerRef.current)
return () => observer.disconnect()
}, [src, lazy, shouldLoad, isInQueue, isCached])
}, [src, lazy, shouldLoad, isInQueue, isCached, imageError, isFailed])
// Reset state when src changes
useEffect(() => {
const cached = src ? loadedAvatarCache.has(src) : false
const failed = src ? avatarLoadQueue.hasFailed(src) : false
setImageLoaded(cached)
setImageError(false)
if (lazy && !cached) {
setImageError(failed)
if (failed) {
setShouldLoad(false)
setIsInQueue(false)
} else if (lazy && !cached) {
setShouldLoad(false)
setIsInQueue(false)
} else {
@@ -95,6 +103,7 @@ export const Avatar = React.memo(function Avatar({
}
const hasValidUrl = !!src && !imageError && shouldLoad
const shouldShowLoadingPlaceholder = loading && !hasValidUrl && !imageError
return (
<div
@@ -112,13 +121,30 @@ export const Avatar = React.memo(function Avatar({
alt={name || 'avatar'}
className={`avatar-image ${imageLoaded ? 'loaded' : ''} ${isCached ? 'instant' : ''}`}
onLoad={() => {
if (src) loadedAvatarCache.add(src)
if (src) {
avatarLoadQueue.clearFailed(src)
loadedAvatarCache.add(src)
}
setImageLoaded(true)
setImageError(false)
}}
onError={() => {
if (src) {
avatarLoadQueue.markFailed(src)
loadedAvatarCache.delete(src)
}
setImageLoaded(false)
setImageError(true)
setShouldLoad(false)
}}
onError={() => setImageError(true)}
loading={lazy ? "lazy" : "eager"}
referrerPolicy="no-referrer"
/>
</>
) : shouldShowLoadingPlaceholder ? (
<div className="avatar-loading">
<Loader2 size="50%" className="avatar-loading-icon" />
</div>
) : (
<div className="avatar-placeholder">
{name ? <span className="avatar-letter">{getAvatarLetter()}</span> : <User size="50%" />}

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { Loader2, X, CheckCircle, XCircle, AlertCircle, Clock } from 'lucide-react'
import { Loader2, X, CheckCircle, XCircle, AlertCircle, Clock, Mic } from 'lucide-react'
import { useBatchTranscribeStore } from '../stores/batchTranscribeStore'
import '../styles/batchTranscribe.scss'
@@ -17,6 +17,7 @@ export const BatchTranscribeGlobal: React.FC = () => {
result,
sessionName,
startTime,
taskType,
setShowToast,
setShowResult
} = useBatchTranscribeStore()
@@ -64,7 +65,7 @@ export const BatchTranscribeGlobal: React.FC = () => {
<div className="batch-progress-toast-header">
<div className="batch-progress-toast-title">
<Loader2 size={14} className="spin" />
<span>{sessionName ? `${sessionName}` : ''}</span>
<span>{taskType === 'decrypt' ? '批量解密语音中' : '批量转写中'}{sessionName ? `${sessionName}` : ''}</span>
</div>
<button className="batch-progress-toast-close" onClick={() => setShowToast(false)} title="最小化">
<X size={14} />
@@ -108,8 +109,8 @@ export const BatchTranscribeGlobal: React.FC = () => {
<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>
{taskType === 'decrypt' ? <Mic size={20} /> : <CheckCircle size={20} />}
<h3>{taskType === 'decrypt' ? '语音解密完成' : '转写完成'}</h3>
</div>
<div className="batch-modal-body">
<div className="result-summary">
@@ -129,7 +130,7 @@ export const BatchTranscribeGlobal: React.FC = () => {
{result.fail > 0 && (
<div className="result-tip">
<AlertCircle size={16} />
<span></span>
<span>{taskType === 'decrypt' ? '部分语音解密失败,可能是语音未缓存或文件损坏' : '部分语音转写失败,可能是语音文件损坏或网络问题'}</span>
</div>
)}
</div>

View File

@@ -0,0 +1,136 @@
.chat-analysis-header {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 28px;
padding: 4px 0;
background: transparent;
border: none;
border-radius: 0;
flex-shrink: 0;
}
.chat-analysis-back {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0;
border: none;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
transition: color 0.2s ease;
font-size: 13px;
font-weight: 600;
&:hover {
color: var(--text-primary);
}
}
.chat-analysis-breadcrumb {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-secondary);
.chat-analysis-breadcrumb-separator {
opacity: 0.6;
}
}
.chat-analysis-dropdown {
position: relative;
}
.chat-analysis-current-trigger {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0;
border: none;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
font-size: 13px;
font-weight: 600;
transition: color 0.2s ease;
.current {
color: var(--text-primary);
}
svg {
transition: transform 0.2s ease;
}
&:hover {
color: var(--text-primary);
}
&.open svg {
transform: rotate(180deg);
}
}
.chat-analysis-menu {
position: absolute;
top: calc(100% + 10px);
right: 0;
min-width: 120px;
padding: 6px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.12);
z-index: 20;
}
.chat-analysis-menu-item {
width: 100%;
display: block;
padding: 9px 12px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--text-primary);
text-align: left;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: background 0.2s ease, color 0.2s ease;
&:hover {
background: var(--bg-hover);
color: var(--primary);
}
}
.chat-analysis-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
margin-left: auto;
flex-wrap: wrap;
}
@media (max-width: 768px) {
.chat-analysis-header {
align-items: flex-start;
flex-wrap: wrap;
}
.chat-analysis-breadcrumb {
flex-wrap: wrap;
row-gap: 4px;
}
.chat-analysis-actions {
width: 100%;
justify-content: flex-start;
}
}

View File

@@ -0,0 +1,105 @@
import { ChevronDown, ChevronLeft } from 'lucide-react'
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { useNavigate } from 'react-router-dom'
import './ChatAnalysisHeader.scss'
export type ChatAnalysisMode = 'private' | 'group'
interface ChatAnalysisHeaderProps {
currentMode: ChatAnalysisMode
actions?: ReactNode
}
const MODE_CONFIG: Record<ChatAnalysisMode, { label: string; path: string }> = {
private: {
label: '私聊分析',
path: '/analytics/private'
},
group: {
label: '群聊分析',
path: '/analytics/group'
}
}
function ChatAnalysisHeader({ currentMode, actions }: ChatAnalysisHeaderProps) {
const navigate = useNavigate()
const currentLabel = MODE_CONFIG[currentMode].label
const [menuOpen, setMenuOpen] = useState(false)
const dropdownRef = useRef<HTMLDivElement | null>(null)
const alternateMode = useMemo(
() => (currentMode === 'private' ? 'group' : 'private'),
[currentMode]
)
useEffect(() => {
if (!menuOpen) return
const handleClickOutside = (event: MouseEvent) => {
if (!dropdownRef.current?.contains(event.target as Node)) {
setMenuOpen(false)
}
}
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setMenuOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
document.addEventListener('keydown', handleEscape)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
document.removeEventListener('keydown', handleEscape)
}
}, [menuOpen])
return (
<div className="chat-analysis-header">
<div className="chat-analysis-breadcrumb">
<button
type="button"
className="chat-analysis-back"
onClick={() => navigate('/analytics')}
>
<ChevronLeft size={16} />
<span></span>
</button>
<span className="chat-analysis-breadcrumb-separator">/</span>
<div className="chat-analysis-dropdown" ref={dropdownRef}>
<button
type="button"
className={`chat-analysis-current-trigger ${menuOpen ? 'open' : ''}`}
aria-haspopup="menu"
aria-expanded={menuOpen}
onClick={() => setMenuOpen((prev) => !prev)}
>
<span className="current">{currentLabel}</span>
<ChevronDown size={14} />
</button>
{menuOpen && (
<div className="chat-analysis-menu" role="menu" aria-label="切换聊天分析类型">
<button
type="button"
role="menuitem"
className="chat-analysis-menu-item"
onClick={() => {
setMenuOpen(false)
navigate(MODE_CONFIG[alternateMode].path)
}}
>
{MODE_CONFIG[alternateMode].label}
</button>
</div>
)}
</div>
</div>
{actions ? <div className="chat-analysis-actions">{actions}</div> : null}
</div>
)
}
export default ChatAnalysisHeader

View File

@@ -0,0 +1,123 @@
.confirm-dialog-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
animation: fadeIn 0.2s ease-out;
.confirm-dialog {
width: 480px;
background: var(--bg-primary);
border-radius: 20px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
position: relative;
animation: slideUp 0.2s ease-out;
overflow: hidden;
.close-btn {
position: absolute;
top: 16px;
right: 16px;
background: rgba(0, 0, 0, 0.05);
border: none;
color: var(--text-secondary);
cursor: pointer;
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
&:hover {
background: rgba(0, 0, 0, 0.1);
color: var(--text-primary);
}
}
.dialog-title {
padding: 40px 40px 16px;
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
.dialog-content {
padding: 0 40px 24px;
p {
font-size: 15px;
color: var(--text-primary);
line-height: 1.6;
margin: 0 0 16px 0;
&:last-child {
margin-bottom: 0;
}
}
}
.dialog-actions {
padding: 0 40px 40px;
display: flex;
justify-content: flex-end;
gap: 12px;
button {
padding: 12px 24px;
border-radius: 12px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
border: none;
&.btn-cancel {
background: var(--bg-tertiary);
color: var(--text-secondary);
&:hover {
background: var(--bg-hover);
}
}
&.btn-confirm {
background: var(--primary);
color: var(--on-primary);
&:hover {
background: var(--primary-hover);
}
&:active {
transform: scale(0.98);
}
}
}
}
}
}
@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,32 @@
import { X } from 'lucide-react'
import './ConfirmDialog.scss'
interface ConfirmDialogProps {
open: boolean
title?: string
message: string
onConfirm: () => void
onCancel: () => void
}
export default function ConfirmDialog({ open, title, message, onConfirm, onCancel }: ConfirmDialogProps) {
if (!open) return null
return (
<div className="confirm-dialog-overlay" onClick={onCancel}>
<div className="confirm-dialog" onClick={e => e.stopPropagation()}>
<button className="close-btn" onClick={onCancel}>
<X size={20} />
</button>
{title && <div className="dialog-title">{title}</div>}
<div className="dialog-content">
<p style={{ whiteSpace: 'pre-line' }}>{message}</p>
</div>
<div className="dialog-actions">
<button className="btn-cancel" onClick={onCancel}></button>
<button className="btn-confirm" onClick={onConfirm}></button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,41 @@
import { Component, ReactNode } from 'react'
interface Props {
children: ReactNode
fallback?: ReactNode
}
interface State {
hasError: boolean
error?: Error
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: any) {
console.error('ErrorBoundary caught:', error, errorInfo)
}
render() {
if (this.state.hasError) {
return this.props.fallback || (
<div style={{ padding: '20px', textAlign: 'center', color: '#999' }}>
<p></p>
<p style={{ fontSize: '12px', marginTop: '8px' }}>
{this.state.error?.message || '未知错误'}
</p>
</div>
)
}
return this.props.children
}
}

View File

@@ -0,0 +1,254 @@
.export-date-range-dialog-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
z-index: 2400;
}
.export-date-range-dialog {
width: min(480px, calc(100vw - 32px));
max-height: calc(100vh - 64px);
overflow-y: auto;
border-radius: 12px;
border: 1px solid var(--border-color);
background: var(--bg-secondary-solid, var(--bg-primary));
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.export-date-range-dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
h4 {
margin: 0;
font-size: 14px;
color: var(--text-primary);
}
}
.export-date-range-dialog-close-btn {
border: 1px solid var(--border-color);
background: var(--bg-secondary);
border-radius: 8px;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--text-secondary);
}
.export-date-range-preset-list {
display: flex;
flex-wrap: nowrap;
gap: 4px;
overflow-x: auto;
padding-bottom: 2px;
&::-webkit-scrollbar {
height: 4px;
}
}
.export-date-range-preset-item {
flex: 0 0 auto;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
color: var(--text-primary);
min-height: 30px;
padding: 0 8px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
font-size: 11px;
cursor: pointer;
white-space: nowrap;
&.active {
border-color: var(--primary);
background: rgba(var(--primary-rgb), 0.08);
color: var(--primary);
}
}
.export-date-range-mode-banner {
border-radius: 8px;
padding: 6px 8px;
font-size: 11px;
line-height: 1.4;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-secondary);
&.range {
border-color: rgba(var(--primary-rgb), 0.4);
background: rgba(var(--primary-rgb), 0.1);
color: var(--primary);
}
}
.export-date-range-calendar-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.export-date-range-calendar-panel {
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
padding: 7px;
}
.export-date-range-calendar-panel-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
}
.export-date-range-calendar-date-label {
display: flex;
flex-direction: column;
gap: 2px;
span {
font-size: 11px;
color: var(--text-secondary);
}
}
.export-date-range-date-input {
width: 100%;
min-width: 0;
border-radius: 6px;
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-primary);
height: 24px;
padding: 0 7px;
font-size: 11px;
&.invalid {
border-color: #e84d4d;
box-shadow: 0 0 0 1px rgba(232, 77, 77, 0.2);
}
}
.export-date-range-calendar-nav {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--text-primary);
button {
width: 20px;
height: 20px;
border-radius: 5px;
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-primary);
cursor: pointer;
padding: 0;
line-height: 1;
}
}
.export-date-range-calendar-weekdays {
margin-top: 6px;
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
span {
text-align: center;
font-size: 10px;
color: var(--text-tertiary);
}
}
.export-date-range-calendar-days {
margin-top: 4px;
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
}
.export-date-range-calendar-day {
border: 1px solid transparent;
border-radius: 6px;
min-height: 20px;
background: var(--bg-primary);
color: var(--text-primary);
font-size: 10px;
cursor: pointer;
padding: 0;
&.outside {
color: var(--text-quaternary);
opacity: 0.75;
}
&.selected {
border-color: var(--primary);
background: rgba(var(--primary-rgb), 0.14);
color: var(--primary);
font-weight: 600;
}
}
.export-date-range-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.export-date-range-dialog-btn {
border-radius: 8px;
padding: 7px 12px;
font-size: 12px;
font-weight: 600;
border: 1px solid var(--border-color);
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
&.primary {
border-color: var(--primary);
background: var(--primary);
color: #fff;
&:hover {
background: var(--primary-hover);
}
}
&.secondary {
background: var(--bg-secondary);
color: var(--text-primary);
&:hover {
border-color: var(--primary);
color: var(--primary);
}
}
}
@media (max-width: 860px) {
.export-date-range-calendar-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,340 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { Check, X } from 'lucide-react'
import {
EXPORT_DATE_RANGE_PRESETS,
WEEKDAY_SHORT_LABELS,
addMonths,
buildCalendarCells,
cloneExportDateRangeSelection,
createDateRangeByPreset,
createDefaultDateRange,
formatCalendarMonthTitle,
formatDateInputValue,
isSameDay,
parseDateInputValue,
startOfDay,
endOfDay,
toMonthStart,
type ExportDateRangePreset,
type ExportDateRangeSelection
} from '../../utils/exportDateRange'
import './ExportDateRangeDialog.scss'
interface ExportDateRangeDialogProps {
open: boolean
value: ExportDateRangeSelection
title?: string
onClose: () => void
onConfirm: (value: ExportDateRangeSelection) => void
}
interface ExportDateRangeDialogDraft extends ExportDateRangeSelection {
startPanelMonth: Date
endPanelMonth: Date
}
const buildDialogDraft = (value: ExportDateRangeSelection): ExportDateRangeDialogDraft => ({
...cloneExportDateRangeSelection(value),
startPanelMonth: toMonthStart(value.dateRange.start),
endPanelMonth: toMonthStart(value.dateRange.end)
})
export function ExportDateRangeDialog({
open,
value,
title = '时间范围设置',
onClose,
onConfirm
}: ExportDateRangeDialogProps) {
const [draft, setDraft] = useState<ExportDateRangeDialogDraft>(() => buildDialogDraft(value))
const [dateInput, setDateInput] = useState({
start: formatDateInputValue(value.dateRange.start),
end: formatDateInputValue(value.dateRange.end)
})
const [dateInputError, setDateInputError] = useState({ start: false, end: false })
useEffect(() => {
if (!open) return
const nextDraft = buildDialogDraft(value)
setDraft(nextDraft)
setDateInput({
start: formatDateInputValue(nextDraft.dateRange.start),
end: formatDateInputValue(nextDraft.dateRange.end)
})
setDateInputError({ start: false, end: false })
}, [open, value])
useEffect(() => {
if (!open) return
setDateInput({
start: formatDateInputValue(draft.dateRange.start),
end: formatDateInputValue(draft.dateRange.end)
})
setDateInputError({ start: false, end: false })
}, [draft.dateRange.end.getTime(), draft.dateRange.start.getTime(), open])
const applyPreset = useCallback((preset: Exclude<ExportDateRangePreset, 'custom'>) => {
if (preset === 'all') {
const previewRange = createDefaultDateRange()
setDraft(prev => ({
...prev,
preset,
useAllTime: true,
dateRange: previewRange,
startPanelMonth: toMonthStart(previewRange.start),
endPanelMonth: toMonthStart(previewRange.end)
}))
return
}
const range = createDateRangeByPreset(preset)
setDraft(prev => ({
...prev,
preset,
useAllTime: false,
dateRange: range,
startPanelMonth: toMonthStart(range.start),
endPanelMonth: toMonthStart(range.end)
}))
}, [])
const updateDraftStart = useCallback((targetDate: Date) => {
const start = startOfDay(targetDate)
setDraft(prev => {
const nextEnd = prev.dateRange.end < start ? endOfDay(start) : prev.dateRange.end
return {
...prev,
preset: 'custom',
useAllTime: false,
dateRange: {
start,
end: nextEnd
},
startPanelMonth: toMonthStart(start),
endPanelMonth: toMonthStart(nextEnd)
}
})
}, [])
const updateDraftEnd = useCallback((targetDate: Date) => {
const end = endOfDay(targetDate)
setDraft(prev => {
const nextStart = prev.useAllTime ? startOfDay(targetDate) : prev.dateRange.start
const nextEnd = end < nextStart ? endOfDay(nextStart) : end
return {
...prev,
preset: 'custom',
useAllTime: false,
dateRange: {
start: nextStart,
end: nextEnd
},
startPanelMonth: toMonthStart(nextStart),
endPanelMonth: toMonthStart(nextEnd)
}
})
}, [])
const commitStartFromInput = useCallback(() => {
const parsed = parseDateInputValue(dateInput.start)
if (!parsed) {
setDateInputError(prev => ({ ...prev, start: true }))
return
}
setDateInputError(prev => ({ ...prev, start: false }))
updateDraftStart(parsed)
}, [dateInput.start, updateDraftStart])
const commitEndFromInput = useCallback(() => {
const parsed = parseDateInputValue(dateInput.end)
if (!parsed) {
setDateInputError(prev => ({ ...prev, end: true }))
return
}
setDateInputError(prev => ({ ...prev, end: false }))
updateDraftEnd(parsed)
}, [dateInput.end, updateDraftEnd])
const shiftPanelMonth = useCallback((panel: 'start' | 'end', delta: number) => {
setDraft(prev => (
panel === 'start'
? { ...prev, startPanelMonth: addMonths(prev.startPanelMonth, delta) }
: { ...prev, endPanelMonth: addMonths(prev.endPanelMonth, delta) }
))
}, [])
const isRangeModeActive = !draft.useAllTime
const modeText = isRangeModeActive
? '当前导出模式:按时间范围导出'
: '当前导出模式:全部时间导出(选择下方日期将切换为按时间范围导出)'
const isPresetActive = useCallback((preset: ExportDateRangePreset): boolean => {
if (preset === 'all') return draft.useAllTime
return !draft.useAllTime && draft.preset === preset
}, [draft])
const startPanelCells = useMemo(() => buildCalendarCells(draft.startPanelMonth), [draft.startPanelMonth])
const endPanelCells = useMemo(() => buildCalendarCells(draft.endPanelMonth), [draft.endPanelMonth])
if (!open) return null
return createPortal(
<div className="export-date-range-dialog-overlay" onClick={onClose}>
<div className="export-date-range-dialog" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
<div className="export-date-range-dialog-header">
<h4>{title}</h4>
<button
type="button"
className="export-date-range-dialog-close-btn"
onClick={onClose}
aria-label="关闭时间范围设置"
>
<X size={14} />
</button>
</div>
<div className="export-date-range-preset-list">
{EXPORT_DATE_RANGE_PRESETS.map((preset) => {
const active = isPresetActive(preset.value)
return (
<button
key={preset.value}
type="button"
className={`export-date-range-preset-item ${active ? 'active' : ''}`}
onClick={() => applyPreset(preset.value)}
>
<span>{preset.label}</span>
{active && <Check size={14} />}
</button>
)
})}
</div>
<div className={`export-date-range-mode-banner ${isRangeModeActive ? 'range' : 'all'}`}>
{modeText}
</div>
<div className="export-date-range-calendar-grid">
<section className="export-date-range-calendar-panel">
<div className="export-date-range-calendar-panel-header">
<div className="export-date-range-calendar-date-label">
<span></span>
<input
type="text"
className={`export-date-range-date-input ${dateInputError.start ? 'invalid' : ''}`}
value={dateInput.start}
placeholder="YYYY-MM-DD"
onChange={(event) => {
const nextValue = event.target.value
setDateInput(prev => ({ ...prev, start: nextValue }))
if (dateInputError.start) {
setDateInputError(prev => ({ ...prev, start: false }))
}
}}
onKeyDown={(event) => {
if (event.key !== 'Enter') return
event.preventDefault()
commitStartFromInput()
}}
onBlur={commitStartFromInput}
/>
</div>
<div className="export-date-range-calendar-nav">
<button type="button" onClick={() => shiftPanelMonth('start', -1)} aria-label="上个月"></button>
<span>{formatCalendarMonthTitle(draft.startPanelMonth)}</span>
<button type="button" onClick={() => shiftPanelMonth('start', 1)} aria-label="下个月"></button>
</div>
</div>
<div className="export-date-range-calendar-weekdays">
{WEEKDAY_SHORT_LABELS.map(label => (
<span key={`start-weekday-${label}`}>{label}</span>
))}
</div>
<div className="export-date-range-calendar-days">
{startPanelCells.map((cell) => {
const selected = !draft.useAllTime && isSameDay(cell.date, draft.dateRange.start)
return (
<button
key={`start-${cell.date.getTime()}`}
type="button"
className={`export-date-range-calendar-day ${cell.inCurrentMonth ? '' : 'outside'} ${selected ? 'selected' : ''}`}
onClick={() => updateDraftStart(cell.date)}
>
{cell.date.getDate()}
</button>
)
})}
</div>
</section>
<section className="export-date-range-calendar-panel">
<div className="export-date-range-calendar-panel-header">
<div className="export-date-range-calendar-date-label">
<span></span>
<input
type="text"
className={`export-date-range-date-input ${dateInputError.end ? 'invalid' : ''}`}
value={dateInput.end}
placeholder="YYYY-MM-DD"
onChange={(event) => {
const nextValue = event.target.value
setDateInput(prev => ({ ...prev, end: nextValue }))
if (dateInputError.end) {
setDateInputError(prev => ({ ...prev, end: false }))
}
}}
onKeyDown={(event) => {
if (event.key !== 'Enter') return
event.preventDefault()
commitEndFromInput()
}}
onBlur={commitEndFromInput}
/>
</div>
<div className="export-date-range-calendar-nav">
<button type="button" onClick={() => shiftPanelMonth('end', -1)} aria-label="上个月"></button>
<span>{formatCalendarMonthTitle(draft.endPanelMonth)}</span>
<button type="button" onClick={() => shiftPanelMonth('end', 1)} aria-label="下个月"></button>
</div>
</div>
<div className="export-date-range-calendar-weekdays">
{WEEKDAY_SHORT_LABELS.map(label => (
<span key={`end-weekday-${label}`}>{label}</span>
))}
</div>
<div className="export-date-range-calendar-days">
{endPanelCells.map((cell) => {
const selected = !draft.useAllTime && isSameDay(cell.date, draft.dateRange.end)
return (
<button
key={`end-${cell.date.getTime()}`}
type="button"
className={`export-date-range-calendar-day ${cell.inCurrentMonth ? '' : 'outside'} ${selected ? 'selected' : ''}`}
onClick={() => updateDraftEnd(cell.date)}
>
{cell.date.getDate()}
</button>
)
})}
</div>
</section>
</div>
<div className="export-date-range-dialog-actions">
<button type="button" className="export-date-range-dialog-btn secondary" onClick={onClose}>
</button>
<button
type="button"
className="export-date-range-dialog-btn primary"
onClick={() => onConfirm(cloneExportDateRangeSelection(draft))}
>
</button>
</div>
</div>
</div>,
document.body
)
}

View File

@@ -0,0 +1,459 @@
.export-defaults-settings-form {
.form-group {
margin-bottom: 20px;
&:last-child {
margin-bottom: 0;
}
}
label {
display: block;
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 2px;
}
.form-hint {
display: block;
font-size: 12px;
color: var(--text-tertiary);
margin-bottom: 8px;
}
.select-field {
position: relative;
margin-bottom: 10px;
}
.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: 120;
max-height: 320px;
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);
}
.format-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(156px, 1fr));
gap: 6px;
width: 100%;
margin-bottom: 10px;
}
.format-card {
width: 100%;
min-height: 0;
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 8px 10px;
text-align: left;
background: var(--bg-primary);
cursor: pointer;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
transition: border-color 0.2s ease, background 0.2s ease;
&:hover {
border-color: var(--text-tertiary);
}
&.active {
border-color: var(--primary);
background: rgba(var(--primary-rgb), 0.08);
}
}
.format-label {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
line-height: 1.35;
}
.format-desc {
margin-top: 1px;
font-size: 11px;
color: var(--text-tertiary);
line-height: 1.35;
}
.select-option.active .option-desc {
color: var(--primary);
}
.settings-time-range-field {
margin-bottom: 10px;
}
.settings-time-range-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: rgba(var(--primary-rgb), 0.45);
color: var(--primary);
}
&.open {
border-color: var(--primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent);
}
}
.settings-time-range-value {
flex: 1;
min-width: 0;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.settings-time-range-arrow {
color: var(--text-tertiary);
font-weight: 700;
line-height: 1;
}
.log-toggle-line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
padding: 10px 14px;
border: 1px solid var(--border-color);
border-radius: 14px;
background: var(--bg-primary);
}
.media-default-grid {
width: 100%;
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: 12px;
margin-bottom: 10px;
label {
display: inline-flex;
align-items: center;
gap: 5px;
margin-bottom: 0;
font-size: 13px;
line-height: 1;
font-weight: 500;
color: var(--text-primary);
cursor: pointer;
white-space: nowrap;
}
input[type='checkbox'] {
margin: 0;
accent-color: var(--primary);
}
}
.log-status {
font-size: 13px;
color: var(--text-secondary);
}
.concurrency-inline-options {
width: 100%;
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 6px;
margin-bottom: 10px;
}
.concurrency-option {
border: 1px solid var(--border-color);
border-radius: 10px;
min-height: 38px;
padding: 0;
background: var(--bg-primary);
color: var(--text-primary);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: border-color 0.2s ease, background 0.2s ease, color 0.2s ease;
&:hover {
border-color: var(--text-tertiary);
}
&.active {
border-color: var(--primary);
background: rgba(var(--primary-rgb), 0.08);
color: var(--primary);
}
}
.switch {
position: relative;
display: inline-flex;
width: 48px;
height: 28px;
cursor: pointer;
flex-shrink: 0;
}
.switch-input {
opacity: 0;
width: 0;
height: 0;
position: absolute;
&:checked + .switch-slider {
background: var(--primary);
}
&:checked + .switch-slider::before {
transform: translateX(20px);
}
&:focus + .switch-slider {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent);
}
}
.switch-slider {
position: absolute;
inset: 0;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 999px;
transition: all 0.2s ease;
&::before {
content: '';
position: absolute;
width: 20px;
height: 20px;
left: 3px;
top: 3px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.18);
transition: transform 0.2s ease;
}
}
&.layout-split {
.form-group {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
gap: 18px;
align-items: center;
padding: 14px 0;
margin-bottom: 0;
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
}
.form-group:last-child {
border-bottom: none;
padding-bottom: 0;
}
.form-group:first-child {
padding-top: 0;
}
.form-copy {
min-width: 0;
}
.form-control {
min-width: 0;
display: flex;
justify-content: flex-end;
}
.form-hint {
margin-bottom: 0;
line-height: 1.5;
}
.select-field,
.settings-time-range-field {
width: 100%;
max-width: 360px;
margin-bottom: 0;
}
.log-toggle-line {
width: 100%;
max-width: 360px;
margin-bottom: 0;
}
.media-default-grid {
max-width: 360px;
margin-bottom: 0;
}
.concurrency-inline-options {
max-width: 360px;
margin-bottom: 0;
}
.format-setting-group {
grid-template-columns: 1fr;
gap: 10px;
align-items: stretch;
}
.format-setting-group .form-control {
justify-content: flex-start;
}
.format-grid {
max-width: none;
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-bottom: 0;
}
}
}
@media (max-width: 1024px) {
.export-defaults-settings-form.layout-split {
.media-setting-group {
grid-template-columns: 1fr;
gap: 10px;
align-items: stretch;
}
.media-setting-group .form-control {
justify-content: flex-start;
}
.media-default-grid {
max-width: none;
flex-wrap: wrap;
}
}
}
@media (max-width: 760px) {
.export-defaults-settings-form.layout-split {
.form-group {
grid-template-columns: 1fr;
gap: 10px;
}
.form-control {
justify-content: flex-start;
}
.select-field,
.settings-time-range-field,
.log-toggle-line,
.media-default-grid,
.concurrency-inline-options,
.format-grid {
max-width: none;
}
.media-default-grid {
flex-wrap: wrap;
}
.format-grid {
grid-template-columns: repeat(auto-fit, minmax(156px, 1fr));
}
}
}

View File

@@ -0,0 +1,389 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { ChevronDown } from 'lucide-react'
import * as configService from '../../services/config'
import { ExportDateRangeDialog } from './ExportDateRangeDialog'
import {
createDefaultExportDateRangeSelection,
getExportDateRangeLabel,
resolveExportDateRangeConfig,
serializeExportDateRangeConfig,
type ExportDateRangeSelection
} from '../../utils/exportDateRange'
import './ExportDefaultsSettingsForm.scss'
export interface ExportDefaultsSettingsPatch {
format?: string
avatars?: boolean
dateRange?: ExportDateRangeSelection
media?: configService.ExportDefaultMediaConfig
voiceAsText?: boolean
excelCompactColumns?: boolean
concurrency?: number
}
interface ExportDefaultsSettingsFormProps {
onNotify?: (text: string, success: boolean) => void
onDefaultsChanged?: (patch: ExportDefaultsSettingsPatch) => void
layout?: 'stacked' | 'split'
}
const exportFormatOptions = [
{ value: 'excel', label: 'Excel', desc: '电子表格,适合统计分析' },
{ value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' },
{ value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' },
{ value: 'txt', label: 'TXT', desc: '纯文本,通用格式' },
{ value: 'arkme-json', label: 'Arkme JSON', desc: '紧凑 JSON支持 sender 去重与关系统计' },
{ value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' },
{ value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' },
{ value: 'weclone', label: 'WeClone CSV', desc: 'WeClone 兼容字段格式CSV' },
{ value: 'sql', label: 'PostgreSQL', desc: '数据库脚本,便于导入到数据库' }
] as const
const exportExcelColumnOptions = [
{ value: 'compact', label: '精简列', desc: '序号、时间、发送者身份、消息类型、内容' },
{ value: 'full', label: '完整列', desc: '含发送者昵称/微信ID/备注' }
] as const
const exportConcurrencyOptions = [1, 2, 3, 4, 5, 6] as const
const getOptionLabel = (options: ReadonlyArray<{ value: string; label: string }>, value: string) => {
return options.find((option) => option.value === value)?.label ?? value
}
export function ExportDefaultsSettingsForm({
onNotify,
onDefaultsChanged,
layout = 'stacked'
}: ExportDefaultsSettingsFormProps) {
const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false)
const [isExportDateRangeDialogOpen, setIsExportDateRangeDialogOpen] = useState(false)
const exportExcelColumnsDropdownRef = useRef<HTMLDivElement>(null)
const [exportDefaultFormat, setExportDefaultFormat] = useState('excel')
const [exportDefaultAvatars, setExportDefaultAvatars] = useState(true)
const [exportDefaultDateRange, setExportDefaultDateRange] = useState<ExportDateRangeSelection>(() => createDefaultExportDateRangeSelection())
const [exportDefaultMedia, setExportDefaultMedia] = useState<configService.ExportDefaultMediaConfig>({
images: true,
videos: true,
voices: true,
emojis: true
})
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false)
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2)
useEffect(() => {
let cancelled = false
void (async () => {
const [savedFormat, savedAvatars, savedDateRange, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedConcurrency] = await Promise.all([
configService.getExportDefaultFormat(),
configService.getExportDefaultAvatars(),
configService.getExportDefaultDateRange(),
configService.getExportDefaultMedia(),
configService.getExportDefaultVoiceAsText(),
configService.getExportDefaultExcelCompactColumns(),
configService.getExportDefaultConcurrency()
])
if (cancelled) return
setExportDefaultFormat(savedFormat || 'excel')
setExportDefaultAvatars(savedAvatars ?? true)
setExportDefaultDateRange(resolveExportDateRangeConfig(savedDateRange))
setExportDefaultMedia(savedMedia ?? {
images: true,
videos: true,
voices: true,
emojis: true
})
setExportDefaultVoiceAsText(savedVoiceAsText ?? false)
setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true)
setExportDefaultConcurrency(savedConcurrency ?? 2)
})()
return () => {
cancelled = true
}
}, [])
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as Node
if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) {
setShowExportExcelColumnsSelect(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showExportExcelColumnsSelect])
const exportExcelColumnsValue = exportDefaultExcelCompactColumns ? 'compact' : 'full'
const exportDateRangeLabel = useMemo(() => getExportDateRangeLabel(exportDefaultDateRange), [exportDefaultDateRange])
const exportExcelColumnsLabel = useMemo(() => getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue), [exportExcelColumnsValue])
const notify = (text: string, success = true) => {
onNotify?.(text, success)
}
return (
<div className={`export-defaults-settings-form ${layout === 'split' ? 'layout-split' : 'layout-stacked'}`}>
<div className="form-group">
<div className="form-copy">
<label></label>
<span className="form-hint">1~6</span>
</div>
<div className="form-control">
<div className="concurrency-inline-options" role="radiogroup" aria-label="导出并发数">
{exportConcurrencyOptions.map((option) => (
<button
key={option}
type="button"
className={`concurrency-option ${exportDefaultConcurrency === option ? 'active' : ''}`}
aria-pressed={exportDefaultConcurrency === option}
onClick={async () => {
setExportDefaultConcurrency(option)
await configService.setExportDefaultConcurrency(option)
onDefaultsChanged?.({ concurrency: option })
notify(`已将导出并发数设为 ${option}`, true)
}}
>
{option}
</button>
))}
</div>
</div>
</div>
<div className="form-group format-setting-group">
<div className="form-copy">
<label></label>
<span className="form-hint"></span>
</div>
<div className="form-control">
<div className="format-grid">
{exportFormatOptions.map((option) => (
<button
key={option.value}
type="button"
className={`format-card ${exportDefaultFormat === option.value ? 'active' : ''}`}
onClick={async () => {
setExportDefaultFormat(option.value)
await configService.setExportDefaultFormat(option.value)
onDefaultsChanged?.({ format: option.value })
notify('已更新导出格式默认值', true)
}}
>
<span className="format-label">{option.label}</span>
<span className="format-desc">{option.desc}</span>
</button>
))}
</div>
</div>
</div>
<div className="form-group">
<div className="form-copy">
<label></label>
<span className="form-hint"></span>
</div>
<div className="form-control">
<div className="log-toggle-line">
<span className="log-status">{exportDefaultAvatars ? '已开启' : '已关闭'}</span>
<label className="switch" htmlFor="shared-export-default-avatars">
<input
id="shared-export-default-avatars"
className="switch-input"
type="checkbox"
checked={exportDefaultAvatars}
onChange={async (e) => {
const enabled = e.target.checked
setExportDefaultAvatars(enabled)
await configService.setExportDefaultAvatars(enabled)
onDefaultsChanged?.({ avatars: enabled })
notify(enabled ? '已开启聊天消息导出带头像' : '已关闭聊天消息导出带头像', true)
}}
/>
<span className="switch-slider" />
</label>
</div>
</div>
</div>
<div className="form-group">
<div className="form-copy">
<label></label>
<span className="form-hint"></span>
</div>
<div className="form-control">
<div className="settings-time-range-field">
<button
type="button"
className={`settings-time-range-trigger ${isExportDateRangeDialogOpen ? 'open' : ''}`}
onClick={() => {
setShowExportExcelColumnsSelect(false)
setIsExportDateRangeDialogOpen(true)
}}
>
<span className="settings-time-range-value">{exportDateRangeLabel}</span>
<span className="settings-time-range-arrow">&gt;</span>
</button>
</div>
</div>
</div>
<ExportDateRangeDialog
open={isExportDateRangeDialogOpen}
value={exportDefaultDateRange}
onClose={() => setIsExportDateRangeDialogOpen(false)}
onConfirm={async (nextSelection) => {
setExportDefaultDateRange(nextSelection)
await configService.setExportDefaultDateRange(serializeExportDateRangeConfig(nextSelection))
onDefaultsChanged?.({ dateRange: nextSelection })
notify('已更新默认导出时间范围', true)
setIsExportDateRangeDialogOpen(false)
}}
/>
<div className="form-group">
<div className="form-copy">
<label>Excel </label>
<span className="form-hint"> Excel </span>
</div>
<div className="form-control">
<div className="select-field" ref={exportExcelColumnsDropdownRef}>
<button
type="button"
className={`select-trigger ${showExportExcelColumnsSelect ? 'open' : ''}`}
onClick={() => {
setShowExportExcelColumnsSelect(!showExportExcelColumnsSelect)
setIsExportDateRangeDialogOpen(false)
}}
>
<span className="select-value">{exportExcelColumnsLabel}</span>
<ChevronDown size={16} />
</button>
{showExportExcelColumnsSelect && (
<div className="select-dropdown">
{exportExcelColumnOptions.map((option) => (
<button
key={option.value}
type="button"
className={`select-option ${exportExcelColumnsValue === option.value ? 'active' : ''}`}
onClick={async () => {
const compact = option.value === 'compact'
setExportDefaultExcelCompactColumns(compact)
await configService.setExportDefaultExcelCompactColumns(compact)
onDefaultsChanged?.({ excelCompactColumns: compact })
notify(compact ? '已启用精简列' : '已启用完整列', true)
setShowExportExcelColumnsSelect(false)
}}
>
<span className="option-label">{option.label}</span>
<span className="option-desc">{option.desc}</span>
</button>
))}
</div>
)}
</div>
</div>
</div>
<div className="form-group media-setting-group">
<div className="form-copy">
<label></label>
<span className="form-hint"></span>
</div>
<div className="form-control">
<div className="media-default-grid">
<label>
<input
type="checkbox"
checked={exportDefaultMedia.images}
onChange={async (e) => {
const next = { ...exportDefaultMedia, images: e.target.checked }
setExportDefaultMedia(next)
await configService.setExportDefaultMedia(next)
onDefaultsChanged?.({ media: next })
notify(`${e.target.checked ? '开启' : '关闭'}默认导出图片`, true)
}}
/>
</label>
<label>
<input
type="checkbox"
checked={exportDefaultMedia.voices}
onChange={async (e) => {
const next = { ...exportDefaultMedia, voices: e.target.checked }
setExportDefaultMedia(next)
await configService.setExportDefaultMedia(next)
onDefaultsChanged?.({ media: next })
notify(`${e.target.checked ? '开启' : '关闭'}默认导出语音`, true)
}}
/>
</label>
<label>
<input
type="checkbox"
checked={exportDefaultMedia.videos}
onChange={async (e) => {
const next = { ...exportDefaultMedia, videos: e.target.checked }
setExportDefaultMedia(next)
await configService.setExportDefaultMedia(next)
onDefaultsChanged?.({ media: next })
notify(`${e.target.checked ? '开启' : '关闭'}默认导出视频`, true)
}}
/>
</label>
<label>
<input
type="checkbox"
checked={exportDefaultMedia.emojis}
onChange={async (e) => {
const next = { ...exportDefaultMedia, emojis: e.target.checked }
setExportDefaultMedia(next)
await configService.setExportDefaultMedia(next)
onDefaultsChanged?.({ media: next })
notify(`${e.target.checked ? '开启' : '关闭'}默认导出表情包`, true)
}}
/>
</label>
</div>
</div>
</div>
<div className="form-group">
<div className="form-copy">
<label></label>
<span className="form-hint"></span>
</div>
<div className="form-control">
<div className="log-toggle-line">
<span className="log-status">{exportDefaultVoiceAsText ? '已开启' : '已关闭'}</span>
<label className="switch" htmlFor="shared-export-default-voice-as-text">
<input
id="shared-export-default-voice-as-text"
className="switch-input"
type="checkbox"
checked={exportDefaultVoiceAsText}
onChange={async (e) => {
const enabled = e.target.checked
setExportDefaultVoiceAsText(enabled)
await configService.setExportDefaultVoiceAsText(enabled)
onDefaultsChanged?.({ voiceAsText: enabled })
notify(enabled ? '已开启默认语音转文字' : '已关闭默认语音转文字', true)
}}
/>
<span className="switch-slider" />
</label>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,6 +1,6 @@
import { useEffect, useRef } from 'react'
import { useChatStore } from '../stores/chatStore'
import type { ChatSession } from '../types/models'
import type { ChatSession, Message } from '../types/models'
import { useNavigate } from 'react-router-dom'
export function GlobalSessionMonitor() {
@@ -20,9 +20,9 @@ export function GlobalSessionMonitor() {
}, [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}`
const getMessageKey = (msg: Message) => {
if (msg.messageKey) return msg.messageKey
return `fallback:${msg.serverId || 0}:${msg.createTime}:${msg.sortSeq || 0}:${msg.localId || 0}:${msg.senderUsername || ''}:${msg.localType || 0}`
}
// 处理数据库变更
@@ -46,7 +46,6 @@ export function GlobalSessionMonitor() {
return () => {
removeListener()
}
} else {
}
return () => { }
}, [])
@@ -268,7 +267,12 @@ export function GlobalSessionMonitor() {
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) // 追加到末尾
const latestMessages = useChatStore.getState().messages || []
const existingKeys = new Set(latestMessages.map(getMessageKey))
const newMessages = result.messages.filter((msg: Message) => !existingKeys.has(getMessageKey(msg)))
if (newMessages.length > 0) {
appendMessages(newMessages, false)
}
}
} catch (e) {
console.warn('后台活跃会话刷新失败:', e)

View File

@@ -137,18 +137,22 @@
margin-top: 1px;
font-size: 13px;
line-height: 1;
color: #16a34a;
color: var(--primary, #07c160);
font-weight: 700;
}
.jump-date-popover .day-cell.selected .day-count {
color: #86efac;
color: color-mix(in srgb, #ffffff 78%, var(--primary, #07c160) 22%);
}
.jump-date-popover .day-count-loading {
position: static;
margin-top: 1px;
color: #22c55e;
color: var(--primary, #07c160);
}
.jump-date-popover .day-cell.selected .day-count-loading {
color: color-mix(in srgb, #ffffff 78%, var(--primary, #07c160) 22%);
}
.jump-date-popover .spin {

View File

@@ -6,6 +6,7 @@ interface JumpToDatePopoverProps {
isOpen: boolean
onClose: () => void
onSelect: (date: Date) => void
onMonthChange?: (date: Date) => void
className?: string
style?: React.CSSProperties
currentDate?: Date
@@ -20,6 +21,7 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
isOpen,
onClose,
onSelect,
onMonthChange,
className,
style,
currentDate = new Date(),
@@ -112,13 +114,17 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
const days = generateCalendar()
const mergedClassName = ['jump-date-popover', className || ''].join(' ').trim()
const updateCalendarDate = (nextDate: Date) => {
setCalendarDate(nextDate)
onMonthChange?.(nextDate)
}
return (
<div className={mergedClassName} style={style} role="dialog" aria-label="跳转日期">
<div className="calendar-nav">
<button
className="nav-btn"
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))}
onClick={() => updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))}
aria-label="上一月"
>
<ChevronLeft size={16} />
@@ -126,7 +132,7 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
<span className="current-month">{calendarDate.getFullYear()}{calendarDate.getMonth() + 1}</span>
<button
className="nav-btn"
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))}
onClick={() => updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))}
aria-label="下一月"
>
<ChevronRight size={16} />

View File

@@ -134,6 +134,25 @@
}
}
&.top-center {
top: 24px;
left: 50%;
transform: translate(-50%, -20px) scale(0.95);
&.visible {
transform: translate(-50%, 0) scale(1);
}
// 灵动岛样式
border-radius: 40px !important;
padding: 12px 16px;
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.2);
&.static {
border-radius: 40px !important;
}
}
&:hover {
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.16) !important;
}

View File

@@ -18,7 +18,7 @@ interface NotificationToastProps {
onClose: () => void
onClick: (sessionId: string) => void
duration?: number
position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'
position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'
isStatic?: boolean
initialVisible?: boolean
}

View File

@@ -43,31 +43,62 @@
.sidebar-user-card-wrap {
position: relative;
margin: 0 12px 10px;
--sidebar-user-menu-width: 172px;
}
.sidebar-user-clear-trigger {
.sidebar-user-menu {
position: absolute;
left: 0;
right: 0;
right: auto;
bottom: calc(100% + 8px);
width: max(100%, var(--sidebar-user-menu-width));
z-index: 12;
border: 1px solid rgba(255, 59, 48, 0.28);
border: 1px solid var(--border-color);
border-radius: 12px;
background: var(--bg-secondary-solid, var(--bg-primary));
display: flex;
flex-direction: column;
gap: 4px;
padding: 6px;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12);
opacity: 0;
transform: translateY(8px) scale(0.95);
pointer-events: none;
transition: opacity 0.2s ease, transform 0.2s ease;
&.open {
opacity: 1;
transform: translateY(0) scale(1);
pointer-events: auto;
}
}
.sidebar-user-menu-item {
width: 100%;
border: none;
border-radius: 10px;
background: var(--bg-secondary);
color: #d93025;
padding: 8px 10px;
background: transparent;
color: var(--text-primary);
padding: 9px 10px;
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 600;
font-size: 13px;
font-weight: 500;
cursor: pointer;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12);
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
text-align: left;
transition: background 0.2s ease, color 0.2s ease;
&:hover {
background: rgba(255, 59, 48, 0.08);
border-color: rgba(255, 59, 48, 0.46);
background: var(--bg-tertiary);
}
&.danger {
color: #d93025;
&:hover {
background: rgba(255, 59, 48, 0.08);
}
}
}
@@ -244,24 +275,183 @@
gap: 4px;
}
.collapse-btn {
.sidebar-dialog-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.3);
display: flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 8px;
border: none;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
border-radius: 9999px;
transition: all 0.2s ease;
margin-top: 4px;
z-index: 1100;
padding: 20px;
animation: fadeIn 0.2s ease;
}
&:hover {
background: var(--bg-tertiary);
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.sidebar-dialog {
width: min(420px, 100%);
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 16px;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.24);
padding: 18px 18px 16px;
animation: slideUp 0.25s ease;
h3 {
margin: 0;
font-size: 16px;
color: var(--text-primary);
}
p {
margin: 10px 0 0;
font-size: 13px;
line-height: 1.6;
color: var(--text-secondary);
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.sidebar-wxid-list {
margin-top: 14px;
display: flex;
flex-direction: column;
gap: 8px;
max-height: 300px;
overflow-y: auto;
}
.sidebar-wxid-item {
width: 100%;
padding: 12px 14px;
border: 1px solid var(--border-color);
border-radius: 10px;
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
display: flex;
align-items: center;
gap: 12px;
transition: all 0.2s ease;
&:hover:not(:disabled) {
border-color: rgba(99, 102, 241, 0.32);
background: var(--bg-tertiary);
}
&.current {
border-color: rgba(99, 102, 241, 0.5);
background: var(--bg-tertiary);
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.wxid-avatar {
width: 40px;
height: 40px;
border-radius: 10px;
overflow: hidden;
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
span {
color: var(--on-primary);
font-size: 16px;
font-weight: 600;
}
}
.wxid-info {
flex: 1;
min-width: 0;
text-align: left;
}
.wxid-name {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.wxid-id {
margin-top: 2px;
font-size: 12px;
color: var(--text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.current-badge {
padding: 4px 10px;
border-radius: 6px;
background: var(--primary);
color: var(--on-primary);
font-size: 11px;
font-weight: 600;
flex-shrink: 0;
}
}
.sidebar-dialog-actions {
margin-top: 18px;
display: flex;
justify-content: flex-end;
gap: 10px;
button {
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 8px 14px;
font-size: 13px;
cursor: pointer;
background: var(--bg-secondary);
color: var(--text-primary);
transition: all 0.2s ease;
&:hover:not(:disabled) {
background: var(--bg-tertiary);
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
}
}
}
.sidebar-clear-dialog-overlay {
@@ -273,6 +463,7 @@
justify-content: center;
z-index: 1100;
padding: 20px;
animation: fadeIn 0.2s ease;
}
.sidebar-clear-dialog {
@@ -282,6 +473,7 @@
border-radius: 16px;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.24);
padding: 18px 18px 16px;
animation: slideUp 0.25s ease;
h3 {
margin: 0;

View File

@@ -1,9 +1,12 @@
import { useState, useEffect, useRef } from 'react'
import { NavLink, useLocation } from 'react-router-dom'
import { Home, MessageSquare, BarChart3, Users, FileText, Settings, ChevronLeft, ChevronRight, Download, Aperture, UserCircle, Lock, ChevronUp, Trash2 } from 'lucide-react'
import { NavLink, useLocation, useNavigate } from 'react-router-dom'
import { Home, MessageSquare, BarChart3, FileText, Settings, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, RefreshCw } from 'lucide-react'
import { useAppStore } from '../stores/appStore'
import { useChatStore } from '../stores/chatStore'
import { useAnalyticsStore } from '../stores/analyticsStore'
import * as configService from '../services/config'
import { onExportSessionStatus, requestExportSessionStatus } from '../services/exportBridge'
import { UserRound } from 'lucide-react'
import './Sidebar.scss'
@@ -15,11 +18,29 @@ interface SidebarUserProfile {
}
const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1'
const ACCOUNT_PROFILES_CACHE_KEY = 'account_profiles_cache_v1'
interface SidebarUserProfileCache extends SidebarUserProfile {
updatedAt: number
}
interface AccountProfilesCache {
[wxid: string]: {
displayName: string
avatarUrl?: string
alias?: string
updatedAt: number
}
}
interface WxidOption {
wxid: string
modifiedTime: number
nickname?: string
displayName?: string
avatarUrl?: string
}
const readSidebarUserProfileCache = (): SidebarUserProfile | null => {
try {
const raw = window.localStorage.getItem(SIDEBAR_USER_PROFILE_CACHE_KEY)
@@ -46,11 +67,32 @@ const writeSidebarUserProfileCache = (profile: SidebarUserProfile): void => {
updatedAt: Date.now()
}
window.localStorage.setItem(SIDEBAR_USER_PROFILE_CACHE_KEY, JSON.stringify(payload))
// 同时写入账号缓存池
const accountsCache = readAccountProfilesCache()
accountsCache[profile.wxid] = {
displayName: profile.displayName,
avatarUrl: profile.avatarUrl,
alias: profile.alias,
updatedAt: Date.now()
}
window.localStorage.setItem(ACCOUNT_PROFILES_CACHE_KEY, JSON.stringify(accountsCache))
} catch {
// 忽略本地缓存失败,不影响主流程
}
}
const readAccountProfilesCache = (): AccountProfilesCache => {
try {
const raw = window.localStorage.getItem(ACCOUNT_PROFILES_CACHE_KEY)
if (!raw) return {}
const parsed = JSON.parse(raw)
return typeof parsed === 'object' && parsed ? parsed : {}
} catch {
return {}
}
}
const normalizeAccountId = (value?: string | null): string => {
const trimmed = String(value || '').trim()
if (!trimmed) return ''
@@ -62,9 +104,13 @@ const normalizeAccountId = (value?: string | null): string => {
return suffixMatch ? suffixMatch[1] : trimmed
}
function Sidebar() {
interface SidebarProps {
collapsed: boolean
}
function Sidebar({ collapsed }: SidebarProps) {
const location = useLocation()
const [collapsed, setCollapsed] = useState(false)
const navigate = useNavigate()
const [authEnabled, setAuthEnabled] = useState(false)
const [activeExportTaskCount, setActiveExportTaskCount] = useState(0)
const [userProfile, setUserProfile] = useState<SidebarUserProfile>({
@@ -72,12 +118,14 @@ function Sidebar() {
displayName: '未识别用户'
})
const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false)
const [showClearAccountDialog, setShowClearAccountDialog] = useState(false)
const [shouldClearCacheData, setShouldClearCacheData] = useState(false)
const [shouldClearExportData, setShouldClearExportData] = useState(false)
const [isClearingAccountData, setIsClearingAccountData] = useState(false)
const [showSwitchAccountDialog, setShowSwitchAccountDialog] = useState(false)
const [wxidOptions, setWxidOptions] = useState<WxidOption[]>([])
const [isSwitchingAccount, setIsSwitchingAccount] = useState(false)
const accountCardWrapRef = useRef<HTMLDivElement | null>(null)
const setLocked = useAppStore(state => state.setLocked)
const isDbConnected = useAppStore(state => state.isDbConnected)
const resetChatStore = useChatStore(state => state.reset)
const clearAnalyticsStoreCache = useAnalyticsStore(state => state.clearCache)
useEffect(() => {
window.electronAPI.auth.verifyEnabled().then(setAuthEnabled)
@@ -139,6 +187,9 @@ function Sidebar() {
const resolvedWxidRaw = String(wxid || '').trim()
const cleanedWxid = normalizeAccountId(resolvedWxidRaw)
const resolvedWxid = cleanedWxid || resolvedWxidRaw
if (!resolvedWxidRaw && !resolvedWxid) return
const wxidCandidates = new Set<string>([
resolvedWxidRaw.toLowerCase(),
resolvedWxid.trim().toLowerCase(),
@@ -164,77 +215,36 @@ function Sidebar() {
return undefined
}
const fallbackDisplayName = resolvedWxid || '未识别用户'
// 并行获取名称和头像
const [contactResult, avatarResult] = await Promise.allSettled([
(async () => {
const candidates = Array.from(new Set([resolvedWxidRaw, resolvedWxid, cleanedWxid].filter(Boolean)))
for (const candidate of candidates) {
const contact = await window.electronAPI.chat.getContact(candidate)
if (contact?.remark || contact?.nickName || contact?.alias) {
return contact
}
}
return null
})(),
window.electronAPI.chat.getMyAvatarUrl()
])
const myContact = contactResult.status === 'fulfilled' ? contactResult.value : null
const displayName = pickFirstValidName(
myContact?.remark,
myContact?.nickName,
myContact?.alias
) || resolvedWxid || '未识别用户'
// 第一阶段:先把 wxid/名称打上,保证侧边栏第一时间可见。
patchUserProfile({
wxid: resolvedWxid,
displayName: fallbackDisplayName
displayName,
alias: myContact?.alias,
avatarUrl: avatarResult.status === 'fulfilled' && avatarResult.value.success
? avatarResult.value.avatarUrl
: undefined
})
if (!resolvedWxidRaw && !resolvedWxid) return
// 第二阶段:后台补齐名称(不会阻塞首屏)。
void (async () => {
try {
let myContact: Awaited<ReturnType<typeof window.electronAPI.chat.getContact>> | null = null
for (const candidate of Array.from(new Set([resolvedWxidRaw, resolvedWxid, cleanedWxid].filter(Boolean)))) {
const contact = await window.electronAPI.chat.getContact(candidate)
if (!contact) continue
if (!myContact) myContact = contact
if (contact.remark || contact.nickName || contact.alias) {
myContact = contact
break
}
}
const fromContact = pickFirstValidName(
myContact?.remark,
myContact?.nickName,
myContact?.alias
)
if (fromContact) {
patchUserProfile({ displayName: fromContact }, resolvedWxid)
// 同步补充微信号alias
if (myContact?.alias) {
patchUserProfile({ alias: myContact.alias }, resolvedWxid)
}
return
}
const enrichTargets = Array.from(new Set([resolvedWxidRaw, resolvedWxid, cleanedWxid, 'self'].filter(Boolean)))
const enrichedResult = await window.electronAPI.chat.enrichSessionsContactInfo(enrichTargets)
const enrichedDisplayName = pickFirstValidName(
enrichedResult.contacts?.[resolvedWxidRaw]?.displayName,
enrichedResult.contacts?.[resolvedWxid]?.displayName,
enrichedResult.contacts?.[cleanedWxid]?.displayName,
enrichedResult.contacts?.self?.displayName,
myContact?.alias
)
const bestName = enrichedDisplayName
if (bestName) {
patchUserProfile({ displayName: bestName }, resolvedWxid)
}
// 降级分支也补充微信号
if (myContact?.alias) {
patchUserProfile({ alias: myContact.alias }, resolvedWxid)
}
} catch (nameError) {
console.error('加载侧边栏用户昵称失败:', nameError)
}
})()
// 第二阶段:后台补齐头像(不会阻塞首屏)。
void (async () => {
try {
const avatarResult = await window.electronAPI.chat.getMyAvatarUrl()
if (avatarResult.success && avatarResult.avatarUrl) {
patchUserProfile({ avatarUrl: avatarResult.avatarUrl }, resolvedWxid)
}
} catch (avatarError) {
console.error('加载侧边栏用户头像失败:', avatarError)
}
})()
} catch (error) {
console.error('加载侧边栏用户信息失败:', error)
}
@@ -242,10 +252,7 @@ function Sidebar() {
const cachedProfile = readSidebarUserProfileCache()
if (cachedProfile) {
setUserProfile(prev => ({
...prev,
...cachedProfile
}))
setUserProfile(cachedProfile)
}
void loadCurrentUser()
@@ -259,286 +266,320 @@ function Sidebar() {
return [...name][0] || '?'
}
const openSwitchAccountDialog = async () => {
setIsAccountMenuOpen(false)
if (!isDbConnected) {
window.alert('数据库未连接,无法切换账号')
return
}
const dbPath = await configService.getDbPath()
if (!dbPath) {
window.alert('请先在设置中配置数据库路径')
return
}
try {
const wxids = await window.electronAPI.dbPath.scanWxids(dbPath)
const accountsCache = readAccountProfilesCache()
console.log('[切换账号] 账号缓存:', accountsCache)
const enrichedWxids = wxids.map((option: WxidOption) => {
const normalizedWxid = normalizeAccountId(option.wxid)
const cached = accountsCache[option.wxid] || accountsCache[normalizedWxid]
let displayName = option.nickname || option.wxid
let avatarUrl = option.avatarUrl
if (option.wxid === userProfile.wxid || normalizedWxid === userProfile.wxid) {
displayName = userProfile.displayName || displayName
avatarUrl = userProfile.avatarUrl || avatarUrl
}
else if (cached) {
displayName = cached.displayName || displayName
avatarUrl = cached.avatarUrl || avatarUrl
}
return {
...option,
displayName,
avatarUrl
}
})
setWxidOptions(enrichedWxids)
setShowSwitchAccountDialog(true)
} catch (error) {
console.error('扫描账号失败:', error)
window.alert('扫描账号失败,请稍后重试')
}
}
const handleSwitchAccount = async (selectedWxid: string) => {
if (!selectedWxid || isSwitchingAccount) return
setIsSwitchingAccount(true)
try {
console.log('[切换账号] 开始切换到:', selectedWxid)
const currentWxid = userProfile.wxid
if (currentWxid === selectedWxid) {
console.log('[切换账号] 已经是当前账号,跳过')
setShowSwitchAccountDialog(false)
setIsSwitchingAccount(false)
return
}
console.log('[切换账号] 设置新 wxid')
await configService.setMyWxid(selectedWxid)
console.log('[切换账号] 获取账号配置')
const wxidConfig = await configService.getWxidConfig(selectedWxid)
console.log('[切换账号] 配置内容:', wxidConfig)
if (wxidConfig?.decryptKey) {
console.log('[切换账号] 设置 decryptKey')
await configService.setDecryptKey(wxidConfig.decryptKey)
}
if (typeof wxidConfig?.imageXorKey === 'number') {
console.log('[切换账号] 设置 imageXorKey:', wxidConfig.imageXorKey)
await configService.setImageXorKey(wxidConfig.imageXorKey)
}
if (wxidConfig?.imageAesKey) {
console.log('[切换账号] 设置 imageAesKey')
await configService.setImageAesKey(wxidConfig.imageAesKey)
}
console.log('[切换账号] 检查数据库连接状态')
console.log('[切换账号] 数据库连接状态:', isDbConnected)
if (isDbConnected) {
console.log('[切换账号] 关闭数据库连接')
await window.electronAPI.chat.close()
}
console.log('[切换账号] 清除缓存')
window.localStorage.removeItem(SIDEBAR_USER_PROFILE_CACHE_KEY)
clearAnalyticsStoreCache()
resetChatStore()
console.log('[切换账号] 触发 wxid-changed 事件')
window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: selectedWxid } }))
console.log('[切换账号] 切换成功')
setShowSwitchAccountDialog(false)
} catch (error) {
console.error('[切换账号] 失败:', error)
window.alert('切换账号失败,请稍后重试')
} finally {
setIsSwitchingAccount(false)
}
}
const openSettingsFromAccountMenu = () => {
setIsAccountMenuOpen(false)
navigate('/settings', {
state: {
backgroundLocation: location
}
})
}
const isActive = (path: string) => {
return location.pathname === path || location.pathname.startsWith(`${path}/`)
}
const exportTaskBadge = activeExportTaskCount > 99 ? '99+' : `${activeExportTaskCount}`
const canConfirmClear = shouldClearCacheData || shouldClearExportData
const resetClearDialogState = () => {
setShouldClearCacheData(false)
setShouldClearExportData(false)
setShowClearAccountDialog(false)
}
const openClearAccountDialog = () => {
setIsAccountMenuOpen(false)
setShouldClearCacheData(false)
setShouldClearExportData(false)
setShowClearAccountDialog(true)
}
const handleConfirmClearAccountData = async () => {
if (!canConfirmClear || isClearingAccountData) return
setIsClearingAccountData(true)
try {
const result = await window.electronAPI.chat.clearCurrentAccountData({
clearCache: shouldClearCacheData,
clearExports: shouldClearExportData
})
if (!result.success) {
window.alert(result.error || '清理失败,请稍后重试。')
return
}
window.localStorage.removeItem(SIDEBAR_USER_PROFILE_CACHE_KEY)
setUserProfile({ wxid: '', displayName: '未识别用户' })
window.dispatchEvent(new Event('wxid-changed'))
const removedPaths = Array.isArray(result.removedPaths) ? result.removedPaths : []
const selectedScopes = [
shouldClearCacheData ? '缓存数据' : '',
shouldClearExportData ? '导出数据' : ''
].filter(Boolean)
const detailLines: string[] = [
`清理范围:${selectedScopes.join('、') || '未选择'}`,
`已清理项目:${removedPaths.length}`
]
if (removedPaths.length > 0) {
detailLines.push('', '清理明细(最多显示 8 项):')
for (const [index, path] of removedPaths.slice(0, 8).entries()) {
detailLines.push(`${index + 1}. ${path}`)
}
if (removedPaths.length > 8) {
detailLines.push(`... 其余 ${removedPaths.length - 8} 项已省略`)
}
}
if (result.warning) {
detailLines.push('', `注意:${result.warning}`)
}
const followupHint = shouldClearCacheData
? '若需再次获取数据,请手动登录微信客户端并重新在 WeFlow 完成配置。'
: '你可以继续使用当前登录状态,无需重新登录。'
window.alert(`账号数据清理完成。\n\n${detailLines.join('\n')}\n\n为保障数据安全WeFlow 已清除该账号本地缓存/导出相关数据。${followupHint}`)
resetClearDialogState()
if (shouldClearCacheData) {
window.location.reload()
}
} catch (error) {
console.error('清理账号数据失败:', error)
window.alert('清理失败,请稍后重试。')
} finally {
setIsClearingAccountData(false)
}
}
return (
<aside className={`sidebar ${collapsed ? 'collapsed' : ''}`}>
<nav className="nav-menu">
{/* 首页 */}
<NavLink
to="/home"
className={`nav-item ${isActive('/home') ? 'active' : ''}`}
title={collapsed ? '首页' : undefined}
>
<span className="nav-icon"><Home size={20} /></span>
<span className="nav-label"></span>
</NavLink>
{/* 聊天 */}
<NavLink
to="/chat"
className={`nav-item ${isActive('/chat') ? 'active' : ''}`}
title={collapsed ? '聊天' : undefined}
>
<span className="nav-icon"><MessageSquare size={20} /></span>
<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
to="/analytics"
className={`nav-item ${isActive('/analytics') ? 'active' : ''}`}
title={collapsed ? '私聊分析' : undefined}
>
<span className="nav-icon"><BarChart3 size={20} /></span>
<span className="nav-label"></span>
</NavLink>
{/* 群聊分析 */}
<NavLink
to="/group-analytics"
className={`nav-item ${isActive('/group-analytics') ? 'active' : ''}`}
title={collapsed ? '群聊分析' : undefined}
>
<span className="nav-icon"><Users size={20} /></span>
<span className="nav-label"></span>
</NavLink>
{/* 年度报告 */}
<NavLink
to="/annual-report"
className={`nav-item ${isActive('/annual-report') ? 'active' : ''}`}
title={collapsed ? '年度报告' : undefined}
>
<span className="nav-icon"><FileText size={20} /></span>
<span className="nav-label"></span>
</NavLink>
{/* 导出 */}
<NavLink
to="/export"
className={`nav-item ${isActive('/export') ? 'active' : ''}`}
title={collapsed ? '导出' : undefined}
>
<span className="nav-icon nav-icon-with-badge">
<Download size={20} />
{collapsed && activeExportTaskCount > 0 && (
<span className="nav-badge icon-badge">{exportTaskBadge}</span>
)}
</span>
<span className="nav-label"></span>
{!collapsed && activeExportTaskCount > 0 && (
<span className="nav-badge">{exportTaskBadge}</span>
)}
</NavLink>
</nav>
<div className="sidebar-footer">
<div className="sidebar-user-card-wrap" ref={accountCardWrapRef}>
{isAccountMenuOpen && (
<button
className="sidebar-user-clear-trigger"
onClick={openClearAccountDialog}
type="button"
>
<Trash2 size={14} />
<span></span>
</button>
)}
<div
className={`sidebar-user-card ${isAccountMenuOpen ? 'menu-open' : ''}`}
title={collapsed ? `${userProfile.displayName}${(userProfile.alias || userProfile.wxid) ? `\n${userProfile.alias || userProfile.wxid}` : ''}` : undefined}
onClick={() => setIsAccountMenuOpen(prev => !prev)}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
setIsAccountMenuOpen(prev => !prev)
}
}}
<>
<aside className={`sidebar ${collapsed ? 'collapsed' : ''}`}>
<nav className="nav-menu">
{/* 首页 */}
<NavLink
to="/home"
className={`nav-item ${isActive('/home') ? 'active' : ''}`}
title={collapsed ? '首页' : undefined}
>
<div className="user-avatar">
{userProfile.avatarUrl ? <img src={userProfile.avatarUrl} alt="" /> : <span>{getAvatarLetter(userProfile.displayName)}</span>}
</div>
<div className="user-meta">
<div className="user-name">{userProfile.displayName}</div>
<div className="user-wxid">{userProfile.alias || userProfile.wxid || 'wxid 未识别'}</div>
</div>
{!collapsed && (
<span className={`user-menu-caret ${isAccountMenuOpen ? 'open' : ''}`}>
<ChevronUp size={14} />
</span>
)}
</div>
</div>
<span className="nav-icon"><Home size={20} /></span>
<span className="nav-label"></span>
</NavLink>
{authEnabled && (
{/* 聊天 */}
<NavLink
to="/chat"
className={`nav-item ${isActive('/chat') ? 'active' : ''}`}
title={collapsed ? '聊天' : undefined}
>
<span className="nav-icon"><MessageSquare size={20} /></span>
<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
to="/analytics"
className={`nav-item ${isActive('/analytics') ? 'active' : ''}`}
title={collapsed ? '聊天分析' : undefined}
>
<span className="nav-icon"><BarChart3 size={20} /></span>
<span className="nav-label"></span>
</NavLink>
{/* 年度报告 */}
<NavLink
to="/annual-report"
className={`nav-item ${isActive('/annual-report') ? 'active' : ''}`}
title={collapsed ? '年度报告' : undefined}
>
<span className="nav-icon"><FileText size={20} /></span>
<span className="nav-label"></span>
</NavLink>
{/* 导出 */}
<NavLink
to="/export"
className={`nav-item ${isActive('/export') ? 'active' : ''}`}
title={collapsed ? '导出' : undefined}
>
<span className="nav-icon nav-icon-with-badge">
<Download size={20} />
{collapsed && activeExportTaskCount > 0 && (
<span className="nav-badge icon-badge">{exportTaskBadge}</span>
)}
</span>
<span className="nav-label"></span>
{!collapsed && activeExportTaskCount > 0 && (
<span className="nav-badge">{exportTaskBadge}</span>
)}
</NavLink>
</nav>
<div className="sidebar-footer">
<button
className="nav-item"
onClick={() => setLocked(true)}
title={collapsed ? '锁定' : undefined}
onClick={() => {
if (authEnabled) {
setLocked(true)
return
}
navigate('/settings', {
state: {
initialTab: 'security',
backgroundLocation: location
}
})
}}
title={collapsed ? (authEnabled ? '锁定' : '未锁定') : undefined}
>
<span className="nav-icon"><Lock size={20} /></span>
<span className="nav-label"></span>
<span className="nav-icon">{authEnabled ? <Lock size={20} /> : <LockOpen size={20} />}</span>
<span className="nav-label">{authEnabled ? '锁定' : '未锁定'}</span>
</button>
)}
<NavLink
to="/settings"
className={`nav-item ${isActive('/settings') ? 'active' : ''}`}
title={collapsed ? '设置' : undefined}
>
<span className="nav-icon">
<Settings size={20} />
</span>
<span className="nav-label"></span>
</NavLink>
<button
className="collapse-btn"
onClick={() => setCollapsed(!collapsed)}
title={collapsed ? '展开菜单' : '收起菜单'}
>
{collapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
</button>
</div>
{showClearAccountDialog && (
<div className="sidebar-clear-dialog-overlay" onClick={() => !isClearingAccountData && resetClearDialogState()}>
<div className="sidebar-clear-dialog" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
<h3></h3>
<p>
weflow
weflow
</p>
<div className="sidebar-clear-options">
<label>
<input
type="checkbox"
checked={shouldClearCacheData}
onChange={(event) => setShouldClearCacheData(event.target.checked)}
disabled={isClearingAccountData}
/>
</label>
<label>
<input
type="checkbox"
checked={shouldClearExportData}
onChange={(event) => setShouldClearExportData(event.target.checked)}
disabled={isClearingAccountData}
/>
</label>
</div>
<div className="sidebar-clear-actions">
<button type="button" onClick={resetClearDialogState} disabled={isClearingAccountData}></button>
<div className="sidebar-user-card-wrap" ref={accountCardWrapRef}>
<div className={`sidebar-user-menu ${isAccountMenuOpen ? 'open' : ''}`} role="menu" aria-label="账号菜单">
<button
className="sidebar-user-menu-item"
onClick={openSwitchAccountDialog}
type="button"
className="danger"
disabled={!canConfirmClear || isClearingAccountData}
onClick={handleConfirmClearAccountData}
role="menuitem"
>
{isClearingAccountData ? '清除中...' : '确认清除'}
<RefreshCw size={14} />
<span></span>
</button>
<button
className="sidebar-user-menu-item"
onClick={openSettingsFromAccountMenu}
type="button"
role="menuitem"
>
<Settings size={14} />
<span></span>
</button>
</div>
<div
className={`sidebar-user-card ${isAccountMenuOpen ? 'menu-open' : ''}`}
title={collapsed ? `${userProfile.displayName}${(userProfile.alias || userProfile.wxid) ? `\n${userProfile.alias || userProfile.wxid}` : ''}` : undefined}
onClick={() => setIsAccountMenuOpen(prev => !prev)}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
setIsAccountMenuOpen(prev => !prev)
}
}}
>
<div className="user-avatar">
{userProfile.avatarUrl ? <img src={userProfile.avatarUrl} alt="" /> : <span>{getAvatarLetter(userProfile.displayName)}</span>}
</div>
<div className="user-meta">
<div className="user-name">{userProfile.displayName}</div>
<div className="user-wxid">{userProfile.alias || userProfile.wxid || 'wxid 未识别'}</div>
</div>
{!collapsed && (
<span className={`user-menu-caret ${isAccountMenuOpen ? 'open' : ''}`}>
<ChevronUp size={14} />
</span>
)}
</div>
</div>
</div>
</aside>
{showSwitchAccountDialog && (
<div className="sidebar-dialog-overlay" onClick={() => !isSwitchingAccount && setShowSwitchAccountDialog(false)}>
<div className="sidebar-dialog" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
<h3></h3>
<p></p>
<div className="sidebar-wxid-list">
{wxidOptions.map((option) => (
<button
key={option.wxid}
className={`sidebar-wxid-item ${userProfile.wxid === option.wxid ? 'current' : ''}`}
onClick={() => handleSwitchAccount(option.wxid)}
disabled={isSwitchingAccount}
type="button"
>
<div className="wxid-avatar">
{option.avatarUrl ? (
<img src={option.avatarUrl} alt="" />
) : (
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--bg-tertiary)', borderRadius: '6px', color: 'var(--text-tertiary)' }}>
<UserRound size={16} />
</div>
)}
</div>
<div className="wxid-info">
<div className="wxid-name">{option.displayName}</div>
{option.displayName !== option.wxid && <div className="wxid-id">{option.wxid}</div>}
</div>
{userProfile.wxid === option.wxid && <span className="current-badge"></span>}
</button>
))}
</div>
<div className="sidebar-dialog-actions">
<button type="button" onClick={() => setShowSwitchAccountDialog(false)} disabled={isSwitchingAccount}></button>
</div>
</div>
</div>
)}
</aside>
</>
)
}

View File

@@ -0,0 +1,329 @@
.contact-sns-dialog-overlay {
position: fixed;
inset: 0;
z-index: 1200;
display: flex;
align-items: center;
justify-content: center;
padding: 24px 16px;
background: rgba(15, 23, 42, 0.38);
}
.contact-sns-dialog {
width: min(760px, 100%);
max-height: min(86vh, 860px);
border-radius: 14px;
border: 1px solid var(--border-color);
background: var(--bg-secondary-solid, #ffffff);
box-shadow: 0 22px 46px rgba(0, 0, 0, 0.24);
display: flex;
flex-direction: column;
overflow: hidden;
.spin {
animation: contactSnsDialogSpin 1s linear infinite;
}
.contact-sns-dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid var(--border-color);
}
.contact-sns-dialog-header-main {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.contact-sns-dialog-avatar {
width: 42px;
height: 42px;
border-radius: 10px;
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
overflow: hidden;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
span {
color: #fff;
font-size: 14px;
font-weight: 600;
}
}
.contact-sns-dialog-meta {
min-width: 0;
h4 {
margin: 0;
font-size: 15px;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.contact-sns-dialog-username {
margin-top: 2px;
font-size: 12px;
color: var(--text-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.contact-sns-dialog-stats {
margin-top: 4px;
font-size: 12px;
color: var(--text-secondary);
}
.contact-sns-dialog-header-actions {
display: flex;
align-items: flex-start;
gap: 8px;
flex-shrink: 0;
}
.contact-sns-dialog-rank-switch {
position: relative;
display: inline-flex;
align-items: center;
gap: 6px;
}
.contact-sns-dialog-rank-btn {
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-primary);
color: var(--text-secondary);
height: 28px;
padding: 0 10px;
font-size: 12px;
line-height: 1;
cursor: pointer;
white-space: nowrap;
&:hover {
color: var(--text-primary);
border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color));
}
&.active {
color: var(--primary);
border-color: color-mix(in srgb, var(--primary) 52%, var(--border-color));
background: color-mix(in srgb, var(--primary) 10%, var(--bg-primary));
}
}
.contact-sns-dialog-rank-panel {
position: absolute;
top: calc(100% + 8px);
right: 0;
width: 248px;
max-height: calc((28px * 15) + 16px);
overflow-y: auto;
border: 1px solid color-mix(in srgb, var(--primary) 30%, var(--border-color));
border-radius: 10px;
background: var(--bg-primary);
box-shadow: 0 14px 26px rgba(0, 0, 0, 0.18);
padding: 8px;
z-index: 12;
}
.contact-sns-dialog-rank-empty {
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.5;
text-align: center;
padding: 6px 0;
}
.contact-sns-dialog-rank-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 28px;
padding: 4px 0 8px;
font-size: 12px;
color: var(--text-secondary);
line-height: 1.5;
}
.contact-sns-dialog-rank-row {
display: grid;
grid-template-columns: 20px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
min-height: 28px;
padding: 0 4px;
border-radius: 7px;
&:hover {
background: var(--bg-hover);
}
}
.contact-sns-dialog-rank-index {
font-size: 12px;
color: var(--text-tertiary);
text-align: right;
font-variant-numeric: tabular-nums;
}
.contact-sns-dialog-rank-name {
font-size: 12px;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.contact-sns-dialog-rank-count {
font-size: 12px;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.contact-sns-dialog-close-btn {
border: none;
background: transparent;
color: var(--text-secondary);
width: 28px;
height: 28px;
border-radius: 7px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
}
.contact-sns-dialog-tip {
padding: 10px 16px;
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 88%, transparent);
background: color-mix(in srgb, var(--bg-primary) 78%, var(--bg-secondary));
font-size: 12px;
line-height: 1.6;
color: var(--text-secondary);
word-break: break-word;
}
.contact-sns-dialog-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 12px 16px 14px;
}
.contact-sns-dialog-posts-list {
display: flex;
flex-direction: column;
gap: 14px;
}
.contact-sns-dialog-posts-list .post-header-actions {
display: none;
}
.contact-sns-dialog-status {
padding: 20px 12px;
text-align: center;
font-size: 13px;
color: var(--text-secondary);
&.empty {
color: var(--text-tertiary);
}
}
.contact-sns-dialog-load-more {
display: block;
margin: 12px auto 0;
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-primary);
border-radius: 10px;
padding: 9px 18px;
font-size: 13px;
cursor: pointer;
&:hover:not(:disabled) {
border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color));
color: var(--primary);
}
&:disabled {
cursor: not-allowed;
opacity: 0.72;
}
}
}
@media (max-width: 768px) {
.contact-sns-dialog-overlay {
padding: 12px 8px;
}
.contact-sns-dialog {
width: min(100vw - 16px, 760px);
max-height: calc(100vh - 24px);
.contact-sns-dialog-header {
padding: 12px;
}
.contact-sns-dialog-header-actions {
gap: 6px;
}
.contact-sns-dialog-rank-btn {
height: 26px;
padding: 0 8px;
font-size: 11px;
}
.contact-sns-dialog-rank-panel {
width: min(78vw, 232px);
}
.contact-sns-dialog-tip {
padding: 10px 12px;
line-height: 1.55;
}
.contact-sns-dialog-body {
padding: 10px 10px 12px;
}
}
}
@keyframes contactSnsDialogSpin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,593 @@
import { createPortal } from 'react-dom'
import { Loader2, X } from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { SnsPostItem } from './SnsPostItem'
import type { SnsPost } from '../../types/sns'
import {
type ContactSnsRankItem,
type ContactSnsRankMode,
type ContactSnsTimelineTarget,
getAvatarLetter
} from './contactSnsTimeline'
import './ContactSnsTimelineDialog.scss'
const TIMELINE_PAGE_SIZE = 20
const SNS_RANK_PAGE_SIZE = 50
const SNS_RANK_DISPLAY_LIMIT = 15
interface ContactSnsRankCacheEntry {
likes: ContactSnsRankItem[]
comments: ContactSnsRankItem[]
totalPosts: number
}
interface ContactSnsTimelineDialogProps {
target: ContactSnsTimelineTarget | null
onClose: () => void
initialTotalPosts?: number | null
initialTotalPostsLoading?: boolean
isProtected?: boolean
onDeletePost?: (postId: string, username: string) => void
}
const normalizeTotalPosts = (value?: number | null): number | null => {
if (!Number.isFinite(value)) return null
return Math.max(0, Math.floor(Number(value)))
}
const formatYmdDateFromSeconds = (timestamp?: number): string => {
if (!timestamp || !Number.isFinite(timestamp)) return '—'
const date = new Date(timestamp * 1000)
const year = date.getFullYear()
const month = `${date.getMonth() + 1}`.padStart(2, '0')
const day = `${date.getDate()}`.padStart(2, '0')
return `${year}-${month}-${day}`
}
const buildContactSnsRankings = (posts: SnsPost[]): { likes: ContactSnsRankItem[]; comments: ContactSnsRankItem[] } => {
const likeMap = new Map<string, ContactSnsRankItem>()
const commentMap = new Map<string, ContactSnsRankItem>()
for (const post of posts) {
const createTime = Number(post?.createTime) || 0
const likes = Array.isArray(post?.likes) ? post.likes : []
const comments = Array.isArray(post?.comments) ? post.comments : []
for (const likeNameRaw of likes) {
const name = String(likeNameRaw || '').trim() || '未知用户'
const current = likeMap.get(name)
if (current) {
current.count += 1
if (createTime > current.latestTime) current.latestTime = createTime
continue
}
likeMap.set(name, { name, count: 1, latestTime: createTime })
}
for (const comment of comments) {
const name = String(comment?.nickname || '').trim() || '未知用户'
const current = commentMap.get(name)
if (current) {
current.count += 1
if (createTime > current.latestTime) current.latestTime = createTime
continue
}
commentMap.set(name, { name, count: 1, latestTime: createTime })
}
}
const sorter = (left: ContactSnsRankItem, right: ContactSnsRankItem): number => {
if (right.count !== left.count) return right.count - left.count
if (right.latestTime !== left.latestTime) return right.latestTime - left.latestTime
return left.name.localeCompare(right.name, 'zh-CN')
}
return {
likes: [...likeMap.values()].sort(sorter),
comments: [...commentMap.values()].sort(sorter)
}
}
export function ContactSnsTimelineDialog({
target,
onClose,
initialTotalPosts = null,
initialTotalPostsLoading = false,
isProtected = false,
onDeletePost
}: ContactSnsTimelineDialogProps) {
const [timelinePosts, setTimelinePosts] = useState<SnsPost[]>([])
const [timelineLoading, setTimelineLoading] = useState(false)
const [timelineLoadingMore, setTimelineLoadingMore] = useState(false)
const [timelineHasMore, setTimelineHasMore] = useState(false)
const [timelineTotalPosts, setTimelineTotalPosts] = useState<number | null>(null)
const [timelineStatsLoading, setTimelineStatsLoading] = useState(false)
const [rankMode, setRankMode] = useState<ContactSnsRankMode | null>(null)
const [likeRankings, setLikeRankings] = useState<ContactSnsRankItem[]>([])
const [commentRankings, setCommentRankings] = useState<ContactSnsRankItem[]>([])
const [rankLoading, setRankLoading] = useState(false)
const [rankError, setRankError] = useState<string | null>(null)
const [rankLoadedPosts, setRankLoadedPosts] = useState(0)
const [rankTotalPosts, setRankTotalPosts] = useState<number | null>(null)
const timelinePostsRef = useRef<SnsPost[]>([])
const timelineLoadingRef = useRef(false)
const timelineRequestTokenRef = useRef(0)
const totalPostsRequestTokenRef = useRef(0)
const rankRequestTokenRef = useRef(0)
const rankLoadingRef = useRef(false)
const rankCacheRef = useRef<Record<string, ContactSnsRankCacheEntry>>({})
const targetUsername = String(target?.username || '').trim()
const targetDisplayName = target?.displayName || targetUsername
const targetAvatarUrl = target?.avatarUrl
useEffect(() => {
timelinePostsRef.current = timelinePosts
}, [timelinePosts])
const loadTimelinePosts = useCallback(async (nextTarget: ContactSnsTimelineTarget, options?: { reset?: boolean }) => {
const reset = Boolean(options?.reset)
if (timelineLoadingRef.current) return
timelineLoadingRef.current = true
if (reset) {
setTimelineLoading(true)
setTimelineLoadingMore(false)
setTimelineHasMore(false)
} else {
setTimelineLoadingMore(true)
}
const requestToken = ++timelineRequestTokenRef.current
try {
let endTime: number | undefined
if (!reset && timelinePostsRef.current.length > 0) {
endTime = timelinePostsRef.current[timelinePostsRef.current.length - 1].createTime - 1
}
const result = await window.electronAPI.sns.getTimeline(
TIMELINE_PAGE_SIZE,
0,
[nextTarget.username],
'',
undefined,
endTime
)
if (requestToken !== timelineRequestTokenRef.current) return
if (!result.success || !Array.isArray(result.timeline)) {
if (reset) {
setTimelinePosts([])
setTimelineHasMore(false)
}
return
}
const timeline = [...(result.timeline as SnsPost[])].sort((left, right) => right.createTime - left.createTime)
if (reset) {
setTimelinePosts(timeline)
setTimelineHasMore(timeline.length >= TIMELINE_PAGE_SIZE)
return
}
const existingIds = new Set(timelinePostsRef.current.map((post) => post.id))
const uniqueOlder = timeline.filter((post) => !existingIds.has(post.id))
if (uniqueOlder.length > 0) {
const merged = [...timelinePostsRef.current, ...uniqueOlder].sort((left, right) => right.createTime - left.createTime)
setTimelinePosts(merged)
}
if (timeline.length < TIMELINE_PAGE_SIZE) {
setTimelineHasMore(false)
}
} catch (error) {
console.error('加载联系人朋友圈失败:', error)
if (requestToken === timelineRequestTokenRef.current && reset) {
setTimelinePosts([])
setTimelineHasMore(false)
}
} finally {
if (requestToken === timelineRequestTokenRef.current) {
timelineLoadingRef.current = false
setTimelineLoading(false)
setTimelineLoadingMore(false)
}
}
}, [])
const loadTimelineTotalPosts = useCallback(async (nextTarget: ContactSnsTimelineTarget) => {
const requestToken = ++totalPostsRequestTokenRef.current
setTimelineStatsLoading(true)
try {
const result = await window.electronAPI.sns.getUserPostCounts()
if (requestToken !== totalPostsRequestTokenRef.current) return
if (!result.success || !result.counts) {
setTimelineTotalPosts(null)
setRankTotalPosts(null)
return
}
const rawCount = Number(result.counts[nextTarget.username] || 0)
const normalized = Number.isFinite(rawCount) ? Math.max(0, Math.floor(rawCount)) : 0
setTimelineTotalPosts(normalized)
setRankTotalPosts(normalized)
} catch (error) {
console.error('加载联系人朋友圈条数失败:', error)
if (requestToken !== totalPostsRequestTokenRef.current) return
setTimelineTotalPosts(null)
setRankTotalPosts(null)
} finally {
if (requestToken === totalPostsRequestTokenRef.current) {
setTimelineStatsLoading(false)
}
}
}, [])
const loadRankings = useCallback(async (nextTarget: ContactSnsTimelineTarget) => {
const normalizedUsername = String(nextTarget?.username || '').trim()
if (!normalizedUsername || rankLoadingRef.current) return
const normalizedKnownTotal = normalizeTotalPosts(timelineTotalPosts)
const cached = rankCacheRef.current[normalizedUsername]
if (cached && (normalizedKnownTotal === null || cached.totalPosts === normalizedKnownTotal)) {
setLikeRankings(cached.likes)
setCommentRankings(cached.comments)
setRankLoadedPosts(cached.totalPosts)
setRankTotalPosts(cached.totalPosts)
setRankError(null)
setRankLoading(false)
return
}
rankLoadingRef.current = true
const requestToken = ++rankRequestTokenRef.current
setRankLoading(true)
setRankError(null)
setRankLoadedPosts(0)
setRankTotalPosts(normalizedKnownTotal)
try {
const allPosts: SnsPost[] = []
let endTime: number | undefined
let hasMore = true
while (hasMore) {
const result = await window.electronAPI.sns.getTimeline(
SNS_RANK_PAGE_SIZE,
0,
[normalizedUsername],
'',
undefined,
endTime
)
if (requestToken !== rankRequestTokenRef.current) return
if (!result.success) {
throw new Error(result.error || '加载朋友圈排行失败')
}
const pagePosts = Array.isArray(result.timeline)
? [...(result.timeline as SnsPost[])].sort((left, right) => right.createTime - left.createTime)
: []
if (pagePosts.length === 0) {
hasMore = false
break
}
allPosts.push(...pagePosts)
setRankLoadedPosts(allPosts.length)
if (normalizedKnownTotal === null) {
setRankTotalPosts(allPosts.length)
}
endTime = pagePosts[pagePosts.length - 1].createTime - 1
hasMore = pagePosts.length >= SNS_RANK_PAGE_SIZE
}
if (requestToken !== rankRequestTokenRef.current) return
const rankings = buildContactSnsRankings(allPosts)
const totalPosts = allPosts.length
rankCacheRef.current[normalizedUsername] = {
likes: rankings.likes,
comments: rankings.comments,
totalPosts
}
setLikeRankings(rankings.likes)
setCommentRankings(rankings.comments)
setRankLoadedPosts(totalPosts)
setRankTotalPosts(totalPosts)
setRankError(null)
} catch (error) {
if (requestToken !== rankRequestTokenRef.current) return
const message = error instanceof Error ? error.message : String(error)
setLikeRankings([])
setCommentRankings([])
setRankError(message || '加载朋友圈排行失败')
} finally {
if (requestToken === rankRequestTokenRef.current) {
rankLoadingRef.current = false
setRankLoading(false)
}
}
}, [timelineTotalPosts])
useEffect(() => {
if (!targetUsername) return
totalPostsRequestTokenRef.current += 1
rankRequestTokenRef.current += 1
rankLoadingRef.current = false
setRankMode(null)
setLikeRankings([])
setCommentRankings([])
setRankLoading(false)
setRankError(null)
setRankLoadedPosts(0)
setRankTotalPosts(null)
setTimelinePosts([])
setTimelineTotalPosts(null)
setTimelineStatsLoading(false)
setTimelineHasMore(false)
setTimelineLoadingMore(false)
setTimelineLoading(false)
void loadTimelinePosts({
username: targetUsername,
displayName: targetDisplayName,
avatarUrl: targetAvatarUrl
}, { reset: true })
}, [loadTimelinePosts, targetAvatarUrl, targetDisplayName, targetUsername])
useEffect(() => {
if (!targetUsername) return
const normalizedTotal = normalizeTotalPosts(initialTotalPosts)
if (normalizedTotal !== null) {
setTimelineTotalPosts(normalizedTotal)
setRankTotalPosts(normalizedTotal)
setTimelineStatsLoading(false)
return
}
if (initialTotalPostsLoading) {
setTimelineTotalPosts(null)
setRankTotalPosts(null)
setTimelineStatsLoading(true)
return
}
void loadTimelineTotalPosts({
username: targetUsername,
displayName: targetDisplayName,
avatarUrl: targetAvatarUrl
})
}, [
initialTotalPosts,
initialTotalPostsLoading,
loadTimelineTotalPosts,
targetAvatarUrl,
targetDisplayName,
targetUsername
])
useEffect(() => {
if (timelineTotalPosts === null) return
if (timelinePosts.length >= timelineTotalPosts) {
setTimelineHasMore(false)
}
}, [timelinePosts.length, timelineTotalPosts])
useEffect(() => {
if (!rankMode || !targetUsername) return
void loadRankings({
username: targetUsername,
displayName: targetDisplayName,
avatarUrl: targetAvatarUrl
})
}, [loadRankings, rankMode, targetAvatarUrl, targetDisplayName, targetUsername])
useEffect(() => {
if (!targetUsername) return
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [onClose, targetUsername])
const timelineStatsText = useMemo(() => {
const loadedCount = timelinePosts.length
const loadPart = timelineStatsLoading
? `已加载 ${loadedCount} / 总数统计中...`
: timelineTotalPosts === null
? `已加载 ${loadedCount}`
: `已加载 ${loadedCount} / 共 ${timelineTotalPosts}`
if (timelineLoading && loadedCount === 0) return `${loadPart} 加载中...`
if (loadedCount === 0) return loadPart
const latest = timelinePosts[0]?.createTime
const earliest = timelinePosts[timelinePosts.length - 1]?.createTime
return `${loadPart} ${formatYmdDateFromSeconds(earliest)} ~ ${formatYmdDateFromSeconds(latest)}`
}, [timelineLoading, timelinePosts, timelineStatsLoading, timelineTotalPosts])
const activeRankings = useMemo(() => {
if (rankMode === 'likes') return likeRankings
if (rankMode === 'comments') return commentRankings
return []
}, [commentRankings, likeRankings, rankMode])
const loadMore = useCallback(() => {
if (!targetUsername || timelineLoading || timelineLoadingMore || !timelineHasMore) return
void loadTimelinePosts({
username: targetUsername,
displayName: targetDisplayName,
avatarUrl: targetAvatarUrl
}, { reset: false })
}, [
loadTimelinePosts,
targetAvatarUrl,
targetDisplayName,
targetUsername,
timelineHasMore,
timelineLoading,
timelineLoadingMore
])
const handleBodyScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
const element = event.currentTarget
const remaining = element.scrollHeight - element.scrollTop - element.clientHeight
if (remaining <= 160) {
loadMore()
}
}, [loadMore])
const toggleRankMode = useCallback((mode: ContactSnsRankMode) => {
setRankMode((previous) => (previous === mode ? null : mode))
}, [])
if (!target) return null
return createPortal(
<div className="contact-sns-dialog-overlay" onClick={onClose}>
<div
className="contact-sns-dialog"
role="dialog"
aria-modal="true"
aria-label="联系人朋友圈"
onClick={(event) => event.stopPropagation()}
>
<div className="contact-sns-dialog-header">
<div className="contact-sns-dialog-header-main">
<div className="contact-sns-dialog-avatar">
{targetAvatarUrl ? (
<img src={targetAvatarUrl} alt="" />
) : (
<span>{getAvatarLetter(targetDisplayName)}</span>
)}
</div>
<div className="contact-sns-dialog-meta">
<h4>{targetDisplayName}</h4>
<div className="contact-sns-dialog-username">@{targetUsername}</div>
<div className="contact-sns-dialog-stats">{timelineStatsText}</div>
</div>
</div>
<div className="contact-sns-dialog-header-actions">
<div className="contact-sns-dialog-rank-switch">
<button
type="button"
className={`contact-sns-dialog-rank-btn ${rankMode === 'likes' ? 'active' : ''}`}
onClick={() => toggleRankMode('likes')}
>
</button>
<button
type="button"
className={`contact-sns-dialog-rank-btn ${rankMode === 'comments' ? 'active' : ''}`}
onClick={() => toggleRankMode('comments')}
>
</button>
{rankMode && (
<div
className="contact-sns-dialog-rank-panel"
role="region"
aria-label={rankMode === 'likes' ? '点赞排行' : '评论排行'}
>
{rankLoading && (
<div className="contact-sns-dialog-rank-loading">
<Loader2 size={12} className="spin" />
<span>
{rankTotalPosts !== null && rankTotalPosts > 0
? `统计中,已加载 ${rankLoadedPosts} / ${rankTotalPosts}`
: `统计中,已加载 ${rankLoadedPosts}`}
</span>
</div>
)}
{!rankLoading && rankError ? (
<div className="contact-sns-dialog-rank-empty">{rankError}</div>
) : !rankLoading && activeRankings.length === 0 ? (
<div className="contact-sns-dialog-rank-empty">
{rankMode === 'likes' ? '暂无点赞数据' : '暂无评论数据'}
</div>
) : (
activeRankings.slice(0, SNS_RANK_DISPLAY_LIMIT).map((item, index) => (
<div className="contact-sns-dialog-rank-row" key={`${rankMode}-${item.name}`}>
<span className="contact-sns-dialog-rank-index">{index + 1}</span>
<span className="contact-sns-dialog-rank-name" title={item.name}>{item.name}</span>
<span className="contact-sns-dialog-rank-count">
{item.count.toLocaleString('zh-CN')}
{rankMode === 'likes' ? '次' : '条'}
</span>
</div>
))
)}
</div>
)}
</div>
<button className="contact-sns-dialog-close-btn" type="button" onClick={onClose}>
<X size={16} />
</button>
</div>
</div>
<div className="contact-sns-dialog-tip">
</div>
<div
className="contact-sns-dialog-body"
onScroll={handleBodyScroll}
>
{timelinePosts.length > 0 && (
<div className="contact-sns-dialog-posts-list">
{timelinePosts.map((post) => (
<SnsPostItem
key={post.id}
post={{ ...post, isProtected }}
onPreview={(src, isVideo, liveVideoPath) => {
if (isVideo) {
void window.electronAPI.window.openVideoPlayerWindow(src)
} else {
void window.electronAPI.window.openImageViewerWindow(src, liveVideoPath || undefined)
}
}}
onDebug={() => {}}
onDelete={onDeletePost}
hideAuthorMeta
/>
))}
</div>
)}
{timelineLoading && (
<div className="contact-sns-dialog-status">...</div>
)}
{!timelineLoading && timelinePosts.length === 0 && (
<div className="contact-sns-dialog-status empty"></div>
)}
{!timelineLoading && timelineHasMore && (
<button
className="contact-sns-dialog-load-more"
type="button"
onClick={loadMore}
disabled={timelineLoadingMore}
>
{timelineLoadingMore ? '正在加载...' : '加载更多'}
</button>
)}
</div>
</div>
</div>,
document.body
)
}

View File

@@ -1,60 +1,66 @@
import React, { useState } from 'react'
import { Search, Calendar, User, X, Filter, Check } from 'lucide-react'
import React from 'react'
import { Search, User, X, Loader2, CheckSquare, Square, Download } from 'lucide-react'
import { Avatar } from '../Avatar'
// import JumpToDateDialog from '../JumpToDateDialog' // Assuming this is imported from parent or moved
interface Contact {
username: string
displayName: string
avatarUrl?: string
postCount?: number
postCountStatus?: 'idle' | 'loading' | 'ready'
}
interface ContactsCountProgress {
resolved: number
total: number
running: boolean
}
interface SnsFilterPanelProps {
searchKeyword: string
setSearchKeyword: (val: string) => void
jumpTargetDate?: Date
setJumpTargetDate: (date?: Date) => void
onOpenJumpDialog: () => void
selectedUsernames: string[]
setSelectedUsernames: (val: string[]) => void
totalFriendsLabel?: string
contacts: Contact[]
contactSearch: string
setContactSearch: (val: string) => void
loading?: boolean
contactsCountProgress?: ContactsCountProgress
selectedContactUsernames: string[]
activeContactUsername?: string
onOpenContactTimeline: (contact: Contact) => void
onToggleContactSelected: (contact: Contact) => void
onClearSelectedContacts: () => void
onExportSelectedContacts: () => void
}
export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
searchKeyword,
setSearchKeyword,
jumpTargetDate,
setJumpTargetDate,
onOpenJumpDialog,
selectedUsernames,
setSelectedUsernames,
totalFriendsLabel,
contacts,
contactSearch,
setContactSearch,
loading
loading,
contactsCountProgress,
selectedContactUsernames,
activeContactUsername,
onOpenContactTimeline,
onToggleContactSelected,
onClearSelectedContacts,
onExportSelectedContacts
}) => {
const filteredContacts = contacts.filter(c =>
c.displayName.toLowerCase().includes(contactSearch.toLowerCase()) ||
(c.displayName || '').toLowerCase().includes(contactSearch.toLowerCase()) ||
c.username.toLowerCase().includes(contactSearch.toLowerCase())
)
const toggleUserSelection = (username: string) => {
if (selectedUsernames.includes(username)) {
setSelectedUsernames(selectedUsernames.filter(u => u !== username))
} else {
setJumpTargetDate(undefined) // Reset date jump when selecting user
setSelectedUsernames([...selectedUsernames, username])
}
}
const selectedContactLookup = React.useMemo(
() => new Set(selectedContactUsernames),
[selectedContactUsernames]
)
const clearFilters = () => {
setSearchKeyword('')
setSelectedUsernames([])
setJumpTargetDate(undefined)
setContactSearch('')
}
const getEmptyStateText = () => {
@@ -71,7 +77,7 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
<aside className="sns-filter-panel">
<div className="filter-header">
<h3></h3>
{(searchKeyword || jumpTargetDate || selectedUsernames.length > 0) && (
{(searchKeyword || contactSearch) && (
<button className="reset-all-btn" onClick={clearFilters} title="重置所有筛选">
<RefreshCw size={14} />
</button>
@@ -99,43 +105,13 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
)}
</div>
</div>
{/* Date Widget */}
<div className="filter-widget date-widget">
<div className="widget-header">
<Calendar size={14} />
<span></span>
</div>
<button
className={`date-picker-trigger ${jumpTargetDate ? 'active' : ''}`}
onClick={onOpenJumpDialog}
>
<span className="date-text">
{jumpTargetDate
? jumpTargetDate.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' })
: '选择日期...'}
</span>
{jumpTargetDate && (
<div
className="clear-date-btn"
onClick={(e) => {
e.stopPropagation()
setJumpTargetDate(undefined)
}}
>
<X size={12} />
</div>
)}
</button>
</div>
{/* Contact Widget */}
<div className="filter-widget contact-widget">
<div className="widget-header">
<User size={14} />
<span></span>
{selectedUsernames.length > 0 && (
<span className="badge">{selectedUsernames.length}</span>
{totalFriendsLabel && (
<span className="widget-header-summary">{totalFriendsLabel}</span>
)}
</div>
@@ -152,25 +128,77 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
)}
</div>
{contactsCountProgress && contactsCountProgress.total > 0 && (
<div className="contact-count-progress">
{contactsCountProgress.running
? `朋友圈条数统计中 ${contactsCountProgress.resolved}/${contactsCountProgress.total}`
: `朋友圈条数已统计 ${contactsCountProgress.total}/${contactsCountProgress.total}`}
</div>
)}
<div className="contact-interaction-hint">
</div>
<div className="contact-list-scroll">
{filteredContacts.map(contact => {
const isPostCountReady = contact.postCountStatus === 'ready'
const isSelected = selectedContactLookup.has(contact.username)
const isActive = activeContactUsername === contact.username
return (
<div
key={contact.username}
className={`contact-row ${selectedUsernames.includes(contact.username) ? 'selected' : ''}`}
onClick={() => toggleUserSelection(contact.username)}
>
<Avatar src={contact.avatarUrl} name={contact.displayName} size={36} shape="rounded" />
<div className="contact-meta">
<span className="contact-name">{contact.displayName}</span>
<div
key={contact.username}
className={`contact-row${isSelected ? ' is-selected' : ''}${isActive ? ' is-active' : ''}`}
>
<button
type="button"
className={`contact-select-btn${isSelected ? ' checked' : ''}`}
onClick={() => onToggleContactSelected(contact)}
title={isSelected ? `取消选择 ${contact.displayName}` : `选择 ${contact.displayName}`}
aria-pressed={isSelected}
>
{isSelected ? <CheckSquare size={16} /> : <Square size={16} />}
</button>
<button
type="button"
className="contact-main-btn"
onClick={() => onOpenContactTimeline(contact)}
title={`查看 ${contact.displayName} 的朋友圈`}
>
<Avatar src={contact.avatarUrl} name={contact.displayName} size={36} shape="rounded" />
<div className="contact-meta">
<span className="contact-name">{contact.displayName}</span>
</div>
<div className="contact-post-count-wrap">
{isPostCountReady ? (
<span className="contact-post-count">{Math.max(0, Math.floor(Number(contact.postCount || 0)))}</span>
) : (
<span className="contact-post-count-loading" title="统计中">
<Loader2 size={13} className="spinning" />
</span>
)}
</div>
</button>
</div>
</div>
)
})}
{filteredContacts.length === 0 && (
<div className="empty-state">{getEmptyStateText()}</div>
)}
</div>
{selectedContactUsernames.length > 0 && (
<div className="contact-batch-bar">
<span className="contact-batch-summary"> {selectedContactUsernames.length} </span>
<button type="button" className="contact-batch-btn" onClick={onClearSelectedContacts}>
</button>
<button type="button" className="contact-batch-btn primary" onClick={onExportSelectedContacts}>
<Download size={14} />
<span></span>
</button>
</div>
)}
</div>
</div>
</aside>

View File

@@ -243,10 +243,12 @@ interface SnsPostItemProps {
post: SnsPost
onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void
onDebug: (post: SnsPost) => void
onDelete?: (postId: string) => void
onDelete?: (postId: string, username: string) => void
onOpenAuthorPosts?: (post: SnsPost) => void
hideAuthorMeta?: boolean
}
export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDebug, onDelete }) => {
export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDebug, onDelete, onOpenAuthorPosts, hideAuthorMeta = false }) => {
const [mediaDeleted, setMediaDeleted] = useState(false)
const [dbDeleted, setDbDeleted] = useState(false)
const [deleting, setDeleting] = useState(false)
@@ -299,31 +301,56 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
const r = await window.electronAPI.sns.deleteSnsPost(post.tid ?? post.id)
if (r.success) {
setDbDeleted(true)
onDelete?.(post.id)
onDelete?.(post.id, post.username)
}
} finally {
setDeleting(false)
}
}
const handleOpenAuthorPosts = (e: React.MouseEvent) => {
e.stopPropagation()
onOpenAuthorPosts?.(post)
}
return (
<>
<div className={`sns-post-item ${(mediaDeleted || dbDeleted) ? 'post-deleted' : ''}`}>
<div className="post-avatar-col">
<Avatar
src={post.avatarUrl}
name={post.nickname}
size={48}
shape="rounded"
/>
</div>
{!hideAuthorMeta && (
<div className="post-avatar-col">
<button
type="button"
className="author-trigger-btn avatar-trigger"
onClick={handleOpenAuthorPosts}
title="查看该发布者的全部朋友圈"
>
<Avatar
src={post.avatarUrl}
name={post.nickname}
size={48}
shape="rounded"
/>
</button>
</div>
)}
<div className="post-content-col">
<div className="post-header-row">
<div className="post-author-info">
<span className="author-name">{decodeHtmlEntities(post.nickname)}</span>
<span className="post-time">{formatTime(post.createTime)}</span>
</div>
{hideAuthorMeta ? (
<span className="post-time post-time-standalone">{formatTime(post.createTime)}</span>
) : (
<div className="post-author-info">
<button
type="button"
className="author-trigger-btn author-name-trigger"
onClick={handleOpenAuthorPosts}
title="查看该发布者的全部朋友圈"
>
<span className="author-name">{decodeHtmlEntities(post.nickname)}</span>
</button>
<span className="post-time">{formatTime(post.createTime)}</span>
</div>
)}
<div className="post-header-actions">
{(mediaDeleted || dbDeleted) && (
<span className="post-deleted-badge">

View File

@@ -0,0 +1,26 @@
export interface ContactSnsTimelineTarget {
username: string
displayName: string
avatarUrl?: string
}
export interface ContactSnsRankItem {
name: string
count: number
latestTime: number
}
export type ContactSnsRankMode = 'likes' | 'comments'
export const isSingleContactSession = (sessionId: string): boolean => {
const normalized = String(sessionId || '').trim()
if (!normalized) return false
if (normalized.includes('@chatroom')) return false
if (normalized.startsWith('gh_')) return false
return true
}
export const getAvatarLetter = (name: string): string => {
if (!name) return '?'
return [...name][0] || '?'
}

View File

@@ -3,11 +3,15 @@
background: var(--bg-secondary);
display: flex;
align-items: center;
justify-content: space-between;
padding-left: 16px;
padding-right: 16px;
border-bottom: 1px solid var(--border-color);
-webkit-app-region: drag;
flex-shrink: 0;
gap: 8px;
position: relative;
z-index: 2101;
}
// 繁花如梦:标题栏毛玻璃
@@ -16,6 +20,12 @@
-webkit-backdrop-filter: blur(20px);
}
.title-brand {
display: inline-flex;
align-items: center;
gap: 8px;
}
.title-logo {
width: 20px;
height: 20px;
@@ -26,4 +36,111 @@
font-size: 15px;
font-weight: 500;
color: var(--text-secondary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.title-sidebar-toggle {
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: 8px;
background: transparent;
color: var(--text-tertiary);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.2s ease, color 0.2s ease;
-webkit-app-region: no-drag;
&:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
}
.title-window-controls {
display: inline-flex;
align-items: center;
gap: 6px;
-webkit-app-region: no-drag;
}
.title-window-control-btn {
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: 8px;
background: transparent;
color: var(--text-tertiary);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.2s ease, color 0.2s ease;
&:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
&.is-close:hover {
background: #e5484d;
color: #fff;
}
}
.image-controls {
display: flex;
align-items: center;
gap: 8px;
margin-right: auto;
padding-left: 16px;
-webkit-app-region: no-drag;
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);
}
&:disabled {
cursor: default;
opacity: 1;
}
&.live-play-btn.active {
background: rgba(var(--primary-rgb, 76, 132, 255), 0.16);
color: var(--primary, #4c84ff);
}
}
.scale-text {
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;
}
}

View File

@@ -1,14 +1,87 @@
import { useEffect, useState } from 'react'
import { Copy, Minus, PanelLeftClose, PanelLeftOpen, Square, X } from 'lucide-react'
import './TitleBar.scss'
interface TitleBarProps {
title?: string
sidebarCollapsed?: boolean
onToggleSidebar?: () => void
showWindowControls?: boolean
customControls?: React.ReactNode
showLogo?: boolean
}
function TitleBar({ title }: TitleBarProps = {}) {
function TitleBar({
title,
sidebarCollapsed = false,
onToggleSidebar,
showWindowControls = true,
customControls,
showLogo = true
}: TitleBarProps = {}) {
const [isMaximized, setIsMaximized] = useState(false)
useEffect(() => {
if (!showWindowControls) return
void window.electronAPI.window.isMaximized().then(setIsMaximized).catch(() => {
setIsMaximized(false)
})
return window.electronAPI.window.onMaximizeStateChanged((maximized) => {
setIsMaximized(maximized)
})
}, [showWindowControls])
return (
<div className="title-bar">
<img src="./logo.png" alt="WeFlow" className="title-logo" />
<span className="titles">{title || 'WeFlow'}</span>
<div className="title-brand">
{showLogo && <img src="./logo.png" alt="WeFlow" className="title-logo" />}
<span className="titles">{title || 'WeFlow'}</span>
{onToggleSidebar ? (
<button
type="button"
className="title-sidebar-toggle"
onClick={onToggleSidebar}
title={sidebarCollapsed ? '展开菜单' : '收起菜单'}
aria-label={sidebarCollapsed ? '展开菜单' : '收起菜单'}
>
{sidebarCollapsed ? <PanelLeftOpen size={16} /> : <PanelLeftClose size={16} />}
</button>
) : null}
</div>
{customControls}
{showWindowControls ? (
<div className="title-window-controls">
<button
type="button"
className="title-window-control-btn"
aria-label="最小化"
title="最小化"
onClick={() => window.electronAPI.window.minimize()}
>
<Minus size={14} />
</button>
<button
type="button"
className="title-window-control-btn"
aria-label={isMaximized ? '还原' : '最大化'}
title={isMaximized ? '还原' : '最大化'}
onClick={() => window.electronAPI.window.maximize()}
>
{isMaximized ? <Copy size={12} /> : <Square size={12} />}
</button>
<button
type="button"
className="title-window-control-btn is-close"
aria-label="关闭"
title="关闭"
onClick={() => window.electronAPI.window.close()}
>
<X size={14} />
</button>
</div>
) : null}
</div>
)
}

View File

@@ -14,7 +14,7 @@
.update-dialog {
width: 680px;
background: #f5f5f5;
background: var(--bg-secondary, #f5f5f5);
border-radius: 24px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
overflow: hidden;
@@ -25,7 +25,7 @@
/* Top Section (White/Gradient) */
.dialog-header {
background: #ffffff;
background: var(--bg-primary, #ffffff);
padding: 40px 20px 30px;
display: flex;
flex-direction: column;
@@ -41,14 +41,14 @@
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;
background: radial-gradient(circle, rgba(255, 235, 220, 0.15) 0%, rgba(255, 255, 255, 0) 70%);
opacity: 0.5;
pointer-events: none;
}
.version-tag {
background: #f0eee9;
color: #8c7b6e;
background: var(--bg-tertiary, #f0eee9);
color: var(--text-tertiary, #8c7b6e);
padding: 4px 16px;
border-radius: 12px;
font-size: 13px;
@@ -60,21 +60,21 @@
h2 {
font-size: 32px;
font-weight: 800;
color: #333333;
color: var(--text-primary, #333333);
margin: 0 0 12px;
letter-spacing: -0.5px;
}
.subtitle {
font-size: 15px;
color: #999999;
color: var(--text-secondary, #999999);
font-weight: 400;
}
}
/* Content Section (Light Gray) */
.dialog-content {
background: #f2f2f2;
background: var(--bg-tertiary, #f2f2f2);
padding: 24px 40px 40px;
flex: 1;
display: flex;
@@ -87,7 +87,7 @@
margin-bottom: 30px;
.icon-box {
background: #fbfbfb; // Beige-ish white
background: var(--bg-primary, #fbfbfb);
width: 48px;
height: 48px;
border-radius: 16px;
@@ -96,7 +96,7 @@
justify-content: center;
margin-right: 20px;
flex-shrink: 0;
color: #8c7b6e;
color: var(--text-tertiary, #8c7b6e);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.03);
svg {
@@ -107,27 +107,38 @@
.text-box {
flex: 1;
h3 {
font-size: 18px;
h1, h2, h3, h4, h5, h6 {
color: var(--text-primary, #333333);
font-weight: 700;
color: #333333;
margin: 0 0 8px;
margin: 16px 0 8px;
&:first-child {
margin-top: 0;
}
}
h2 {
font-size: 16px;
}
h3 {
font-size: 15px;
}
p {
font-size: 14px;
color: #666666;
color: var(--text-secondary, #666666);
line-height: 1.6;
margin: 0;
margin: 4px 0;
}
ul {
margin: 8px 0 0 18px;
margin: 4px 0 0 18px;
padding: 0;
li {
font-size: 14px;
color: #666666;
color: var(--text-secondary, #666666);
line-height: 1.6;
}
}
@@ -142,19 +153,19 @@
justify-content: space-between;
margin-bottom: 8px;
font-size: 12px;
color: #888;
color: var(--text-secondary, #888);
font-weight: 500;
}
.progress-bar-bg {
height: 6px;
background: #e0e0e0;
background: var(--border-color, #e0e0e0);
border-radius: 3px;
overflow: hidden;
.progress-bar-fill {
height: 100%;
background: #000000;
background: var(--text-primary, #000000);
border-radius: 3px;
transition: width 0.3s ease;
}
@@ -164,7 +175,7 @@
text-align: center;
margin-top: 12px;
font-size: 13px;
color: #666;
color: var(--text-secondary, #666);
}
}
@@ -175,8 +186,8 @@
.btn-ignore {
background: transparent;
color: #666666;
border: 1px solid #d0d0d0;
color: var(--text-secondary, #666666);
border: 1px solid var(--border-color, #d0d0d0);
padding: 16px 32px;
border-radius: 20px;
font-size: 16px;
@@ -185,9 +196,9 @@
transition: all 0.2s;
&:hover {
background: #f5f5f5;
border-color: #999999;
color: #333333;
background: var(--bg-hover, #f5f5f5);
border-color: var(--text-secondary, #999999);
color: var(--text-primary, #333333);
}
&:active {
@@ -196,11 +207,11 @@
}
.btn-update {
background: #000000;
color: #ffffff;
background: var(--text-primary, #000000);
color: var(--bg-primary, #ffffff);
border: none;
padding: 16px 48px;
border-radius: 20px; // Pill shape
border-radius: 20px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
@@ -231,7 +242,7 @@
right: 16px;
background: rgba(0, 0, 0, 0.05);
border: none;
color: #999;
color: var(--text-secondary, #999);
cursor: pointer;
width: 32px;
height: 32px;
@@ -244,7 +255,7 @@
&:hover {
background: rgba(0, 0, 0, 0.1);
color: #333;
color: var(--text-primary, #333);
transform: rotate(90deg);
}
}

View File

@@ -89,7 +89,6 @@ const UpdateDialog: React.FC<UpdateDialogProps> = ({
<Quote size={20} />
</div>
<div className="text-box">
<h3></h3>
{updateInfo.releaseNotes ? (
<div dangerouslySetInnerHTML={{ __html: updateInfo.releaseNotes }} />
) : (

View File

@@ -0,0 +1,306 @@
.window-close-dialog-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
background:
radial-gradient(circle at top, rgba(36, 42, 54, 0.18), transparent 48%),
rgba(7, 10, 18, 0.56);
backdrop-filter: blur(10px);
z-index: 3000;
animation: windowCloseDialogFadeIn 0.2s ease-out;
}
.window-close-dialog {
width: min(560px, 100%);
border: 1px solid color-mix(in srgb, var(--border-color) 78%, transparent);
border-radius: 24px;
background:
linear-gradient(180deg, color-mix(in srgb, var(--bg-primary) 94%, white 6%) 0%, var(--bg-primary) 100%);
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.32);
overflow: hidden;
position: relative;
animation: windowCloseDialogSlideUp 0.24s cubic-bezier(0.16, 1, 0.3, 1);
}
.window-close-dialog-header {
padding: 28px 30px 18px;
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent);
.window-close-dialog-kicker {
display: inline-flex;
align-items: center;
padding: 6px 10px;
border-radius: 999px;
background: color-mix(in srgb, var(--primary) 12%, transparent);
color: var(--primary);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
h2 {
margin: 14px 0 8px;
font-size: 26px;
line-height: 1.1;
color: var(--text-primary);
}
p {
margin: 0;
font-size: 14px;
line-height: 1.7;
color: var(--text-secondary);
}
}
.window-close-dialog-body {
padding: 20px 24px 10px;
display: flex;
flex-direction: column;
gap: 12px;
}
.window-close-dialog-option {
width: 100%;
display: flex;
align-items: flex-start;
gap: 14px;
padding: 18px 18px 18px 16px;
border: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent);
border-radius: 18px;
background:
linear-gradient(180deg, color-mix(in srgb, var(--bg-secondary) 86%, white 14%) 0%, var(--bg-secondary) 100%);
color: inherit;
cursor: pointer;
text-align: left;
transition:
transform 0.18s ease,
border-color 0.18s ease,
box-shadow 0.18s ease,
background 0.18s ease;
&:hover {
transform: translateY(-1px);
border-color: color-mix(in srgb, var(--primary) 34%, var(--border-color));
box-shadow: 0 14px 28px rgba(0, 0, 0, 0.1);
}
&:active {
transform: translateY(0);
}
&.is-danger:hover {
border-color: rgba(205, 73, 73, 0.42);
}
}
.window-close-dialog-option-icon {
width: 42px;
height: 42px;
flex: 0 0 42px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 14px;
background: color-mix(in srgb, var(--primary) 14%, transparent);
color: var(--primary);
}
.window-close-dialog-option.is-danger .window-close-dialog-option-icon {
background: rgba(205, 73, 73, 0.12);
color: #cd4949;
}
.window-close-dialog-option-text {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
strong {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
}
span {
font-size: 13px;
line-height: 1.6;
color: var(--text-secondary);
}
}
.window-close-dialog-actions {
padding: 8px 24px 24px;
display: flex;
justify-content: flex-end;
}
.window-close-dialog-remember {
display: flex;
align-items: center;
gap: 10px;
margin: 4px 24px 0;
padding: 12px 14px;
border: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent);
border-radius: 16px;
background: color-mix(in srgb, var(--bg-secondary) 76%, transparent);
cursor: pointer;
user-select: none;
input {
position: absolute;
opacity: 0;
pointer-events: none;
}
}
.window-close-dialog-checkbox {
width: 18px;
height: 18px;
flex: 0 0 18px;
border: 1.5px solid color-mix(in srgb, var(--border-color) 88%, transparent);
border-radius: 6px;
background: var(--bg-primary);
position: relative;
transition:
border-color 0.18s ease,
background 0.18s ease,
box-shadow 0.18s ease;
&::after {
content: '';
position: absolute;
left: 5px;
top: 1px;
width: 5px;
height: 10px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg) scale(0.7);
opacity: 0;
transition:
opacity 0.18s ease,
transform 0.18s ease;
}
}
.window-close-dialog-remember input:checked + .window-close-dialog-checkbox {
background: var(--primary);
border-color: var(--primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 16%, transparent);
}
.window-close-dialog-remember input:checked + .window-close-dialog-checkbox::after {
opacity: 1;
transform: rotate(45deg) scale(1);
}
.window-close-dialog-remember-text {
font-size: 13px;
line-height: 1.5;
color: var(--text-secondary);
}
.window-close-dialog-cancel {
min-width: 112px;
padding: 12px 18px;
border: 1px solid color-mix(in srgb, var(--border-color) 76%, transparent);
border-radius: 999px;
background: var(--bg-tertiary);
color: var(--text-secondary);
cursor: pointer;
transition:
background 0.18s ease,
color 0.18s ease,
border-color 0.18s ease;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
border-color: color-mix(in srgb, var(--primary) 24%, var(--border-color));
}
}
.window-close-dialog-close {
position: absolute;
top: 18px;
right: 18px;
width: 34px;
height: 34px;
border: none;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
background: color-mix(in srgb, var(--bg-secondary) 84%, transparent);
color: var(--text-secondary);
cursor: pointer;
transition:
background 0.18s ease,
color 0.18s ease,
transform 0.18s ease;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
transform: rotate(90deg);
}
}
@media (max-width: 640px) {
.window-close-dialog-overlay {
padding: 16px;
align-items: flex-end;
}
.window-close-dialog {
border-radius: 24px 24px 18px 18px;
}
.window-close-dialog-header {
padding: 24px 22px 16px;
h2 {
font-size: 22px;
}
}
.window-close-dialog-body {
padding: 18px 18px 10px;
}
.window-close-dialog-actions {
padding: 8px 18px 18px;
}
.window-close-dialog-cancel {
width: 100%;
}
}
@keyframes windowCloseDialogFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes windowCloseDialogSlideUp {
from {
transform: translateY(24px) scale(0.98);
opacity: 0;
}
to {
transform: translateY(0) scale(1);
opacity: 1;
}
}

View File

@@ -0,0 +1,115 @@
import { Minimize2, Power, X } from 'lucide-react'
import { useEffect, useState } from 'react'
import './WindowCloseDialog.scss'
interface WindowCloseDialogProps {
open: boolean
canMinimizeToTray: boolean
onSelect: (action: 'tray' | 'quit', rememberChoice: boolean) => void
onCancel: () => void
}
export default function WindowCloseDialog({
open,
canMinimizeToTray,
onSelect,
onCancel
}: WindowCloseDialogProps) {
const [rememberChoice, setRememberChoice] = useState(false)
useEffect(() => {
if (!open) return
setRememberChoice(false)
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault()
onCancel()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [open, onCancel])
if (!open) return null
return (
<div className="window-close-dialog-overlay" onClick={onCancel}>
<div
className="window-close-dialog"
onClick={(event) => event.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="window-close-dialog-title"
>
<button
type="button"
className="window-close-dialog-close"
onClick={onCancel}
aria-label="关闭提示"
>
<X size={18} />
</button>
<div className="window-close-dialog-header">
<span className="window-close-dialog-kicker">退</span>
<h2 id="window-close-dialog-title"> WeFlow</h2>
<p>
{canMinimizeToTray
? '你可以保留后台进程与本地 API或者直接完全退出应用。'
: '当前系统托盘不可用,本次只能完全退出应用。'}
</p>
</div>
<div className="window-close-dialog-body">
{canMinimizeToTray && (
<button
type="button"
className="window-close-dialog-option"
onClick={() => onSelect('tray', rememberChoice)}
>
<span className="window-close-dialog-option-icon">
<Minimize2 size={18} />
</span>
<span className="window-close-dialog-option-text">
<strong></strong>
<span> API</span>
</span>
</button>
)}
<button
type="button"
className="window-close-dialog-option is-danger"
onClick={() => onSelect('quit', rememberChoice)}
>
<span className="window-close-dialog-option-icon">
<Power size={18} />
</span>
<span className="window-close-dialog-option-text">
<strong></strong>
<span> WeFlow API</span>
</span>
</button>
</div>
<label className="window-close-dialog-remember">
<input
type="checkbox"
checked={rememberChoice}
onChange={(event) => setRememberChoice(event.target.checked)}
/>
<span className="window-close-dialog-checkbox" aria-hidden="true" />
<span className="window-close-dialog-remember-text"></span>
</label>
<div className="window-close-dialog-actions">
<button type="button" className="window-close-dialog-cancel" onClick={onCancel}>
</button>
</div>
</div>
</div>
)
}

View File

@@ -1,3 +1,15 @@
.analytics-page-shell {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 100%;
.loading-container,
.error-container {
flex: 1;
}
}
// 加载和错误状态
.loading-container,
.error-container {
@@ -53,24 +65,6 @@
}
}
.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);

View File

@@ -1,11 +1,18 @@
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback, type ReactNode } from 'react'
import { useLocation } from 'react-router-dom'
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 {
finishBackgroundTask,
isBackgroundTaskCancelRequested,
registerBackgroundTask,
updateBackgroundTask
} from '../services/backgroundTaskMonitor'
import './AnalyticsPage.scss'
import { Avatar } from '../components/Avatar'
import ChatAnalysisHeader from '../components/ChatAnalysisHeader'
interface ExcludeCandidate {
username: string
@@ -48,6 +55,13 @@ function AnalyticsPage() {
const loadData = useCallback(async (forceRefresh = false) => {
if (isLoaded && !forceRefresh) return
const taskId = registerBackgroundTask({
sourcePage: 'analytics',
title: forceRefresh ? '刷新分析看板' : '加载分析看板',
detail: '准备读取整体统计数据',
progressText: '整体统计',
cancelable: true
})
setIsLoading(true)
setError(null)
setProgress(0)
@@ -60,27 +74,70 @@ function AnalyticsPage() {
try {
setLoadingStatus('正在统计消息数据...')
updateBackgroundTask(taskId, {
detail: '正在统计消息数据',
progressText: '整体统计'
})
const statsResult = await window.electronAPI.analytics.getOverallStatistics(forceRefresh)
if (isBackgroundTaskCancelRequested(taskId)) {
finishBackgroundTask(taskId, 'canceled', {
detail: '已停止后续加载,当前页面分析流程已结束'
})
setIsLoading(false)
return
}
if (statsResult.success && statsResult.data) {
setStatistics(statsResult.data)
} else {
setError(statsResult.error || '加载统计数据失败')
finishBackgroundTask(taskId, 'failed', {
detail: statsResult.error || '加载统计数据失败'
})
setIsLoading(false)
return
}
setLoadingStatus('正在分析联系人排名...')
updateBackgroundTask(taskId, {
detail: '正在分析联系人排名',
progressText: '联系人排名'
})
const rankingsResult = await window.electronAPI.analytics.getContactRankings(20)
if (isBackgroundTaskCancelRequested(taskId)) {
finishBackgroundTask(taskId, 'canceled', {
detail: '已停止后续加载,联系人排名后续步骤未继续'
})
setIsLoading(false)
return
}
if (rankingsResult.success && rankingsResult.data) {
setRankings(rankingsResult.data)
}
setLoadingStatus('正在计算时间分布...')
updateBackgroundTask(taskId, {
detail: '正在计算时间分布',
progressText: '时间分布'
})
const timeResult = await window.electronAPI.analytics.getTimeDistribution()
if (isBackgroundTaskCancelRequested(taskId)) {
finishBackgroundTask(taskId, 'canceled', {
detail: '已停止后续加载,时间分布结果未继续写入'
})
setIsLoading(false)
return
}
if (timeResult.success && timeResult.data) {
setTimeDistribution(timeResult.data)
}
markLoaded()
finishBackgroundTask(taskId, 'completed', {
detail: '分析看板数据加载完成',
progressText: '已完成'
})
} catch (e) {
setError(String(e))
finishBackgroundTask(taskId, 'failed', {
detail: String(e)
})
} finally {
setIsLoading(false)
if (removeListener) removeListener()
@@ -360,8 +417,28 @@ function AnalyticsPage() {
}
}
const renderPageShell = (content: ReactNode) => (
<div className="analytics-page-shell">
<ChatAnalysisHeader currentMode="private" />
{content}
</div>
)
const analyticsHeaderActions = (
<>
<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>
</>
)
if (isLoading && !isLoaded) {
return (
return renderPageShell(
<div className="loading-container">
<Loader2 size={48} className="spin" />
<p className="loading-status">{loadingStatus}</p>
@@ -374,7 +451,7 @@ function AnalyticsPage() {
}
if (error && !isLoaded && isNoSessionError && excludedUsernames.size > 0) {
return (
return renderPageShell(
<div className="error-container">
<p>{error}</p>
<div className="error-actions">
@@ -390,25 +467,18 @@ function AnalyticsPage() {
}
if (error && !isLoaded) {
return (<div className="error-container"><p>{error}</p><button className="btn btn-primary" onClick={() => loadData(true)}></button></div>)
return renderPageShell(
<div className="error-container">
<p>{error}</p>
<button className="btn btn-primary" onClick={() => loadData(true)}></button>
</div>
)
}
return (
<>
<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="analytics-page-shell">
<ChatAnalysisHeader currentMode="private" actions={analyticsHeaderActions} />
<div className="page-scroll">
<section className="page-section">
<div className="stats-overview">
@@ -556,7 +626,7 @@ function AnalyticsPage() {
</div>
</div>
)}
</>
</div>
)
}

View File

@@ -1,13 +1,30 @@
.analytics-entry-page {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 100%;
}
.analytics-welcome-container {
display: flex;
flex-direction: column;
flex: 1;
align-items: center;
justify-content: center;
height: 100%;
min-height: 0;
padding: 40px;
background: var(--bg-primary);
color: var(--text-primary);
animation: fadeIn 0.4s ease-out;
overflow-y: auto;
&.analytics-welcome-container--mode {
border-radius: 20px;
border: 1px solid var(--border-color);
background:
radial-gradient(circle at top, rgba(7, 193, 96, 0.06), transparent 48%),
var(--bg-primary);
}
.welcome-content {
text-align: center;
@@ -106,6 +123,18 @@
}
}
@media (max-width: 768px) {
.analytics-welcome-container {
padding: 28px 18px;
.welcome-content {
.action-cards {
grid-template-columns: 1fr;
}
}
}
}
@keyframes fadeIn {
from {
opacity: 0;
@@ -116,4 +145,4 @@
opacity: 1;
transform: translateY(0);
}
}
}

View File

@@ -1,6 +1,7 @@
import { useNavigate } from 'react-router-dom'
import { BarChart2, History, RefreshCcw } from 'lucide-react'
import { useAnalyticsStore } from '../stores/analyticsStore'
import ChatAnalysisHeader from '../components/ChatAnalysisHeader'
import './AnalyticsWelcomePage.scss'
function AnalyticsWelcomePage() {
@@ -14,11 +15,11 @@ function AnalyticsWelcomePage() {
const { lastLoadTime } = useAnalyticsStore()
const handleLoadCache = () => {
navigate('/analytics/view')
navigate('/analytics/private/view')
}
const handleNewAnalysis = () => {
navigate('/analytics/view', { state: { forceRefresh: true } })
navigate('/analytics/private/view', { state: { forceRefresh: true } })
}
const formatLastTime = (ts: number | null) => {
@@ -27,33 +28,37 @@ function AnalyticsWelcomePage() {
}
return (
<div className="analytics-welcome-container">
<div className="welcome-content">
<div className="icon-wrapper">
<BarChart2 size={40} />
</div>
<h1></h1>
<p>
WeFlow <br />
</p>
<div className="analytics-entry-page">
<ChatAnalysisHeader currentMode="private" />
<div className="action-cards">
<button onClick={handleLoadCache}>
<div className="card-icon">
<History size={24} />
</div>
<h3></h3>
<span><br />(: {formatLastTime(lastLoadTime)})</span>
</button>
<div className="analytics-welcome-container analytics-welcome-container--mode">
<div className="welcome-content">
<div className="icon-wrapper">
<BarChart2 size={40} />
</div>
<h1></h1>
<p>
WeFlow <br />
</p>
<button onClick={handleNewAnalysis}>
<div className="card-icon">
<RefreshCcw size={24} />
</div>
<h3></h3>
<span><br />()</span>
</button>
<div className="action-cards">
<button onClick={handleLoadCache}>
<div className="card-icon">
<History size={24} />
</div>
<h3></h3>
<span><br />(: {formatLastTime(lastLoadTime)})</span>
</button>
<button onClick={handleNewAnalysis}>
<div className="card-icon">
<RefreshCcw size={24} />
</div>
<h3></h3>
<span><br />()</span>
</button>
</div>
</div>
</div>
</div>

View File

@@ -1,6 +1,12 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Calendar, Loader2, Sparkles, Users } from 'lucide-react'
import {
finishBackgroundTask,
isBackgroundTaskCancelRequested,
registerBackgroundTask,
updateBackgroundTask
} from '../services/backgroundTaskMonitor'
import './AnnualReportPage.scss'
type YearOption = number | 'all'
@@ -49,8 +55,17 @@ function AnnualReportPage() {
useEffect(() => {
let disposed = false
let taskId = ''
let uiTaskId = ''
const applyLoadPayload = (payload: YearsLoadPayload) => {
if (uiTaskId) {
updateBackgroundTask(uiTaskId, {
detail: payload.statusText || '正在加载可用年份',
progressText: payload.done
? '已完成'
: `${Array.isArray(payload.years) ? payload.years.length : 0} 个年份`
})
}
if (payload.strategy) setLoadStrategy(payload.strategy)
if (payload.phase) setLoadPhase(payload.phase)
if (typeof payload.statusText === 'string' && payload.statusText) setLoadStatusText(payload.statusText)
@@ -91,6 +106,14 @@ function AnnualReportPage() {
setIsLoadingMoreYears(false)
setHasYearsLoadFinished(true)
setLoadPhase('done')
if (uiTaskId) {
finishBackgroundTask(uiTaskId, payload.canceled ? 'canceled' : 'completed', {
detail: payload.canceled
? '年度报告年份加载已停止'
: `年度报告年份加载完成,共 ${years.length} 个年份`,
progressText: payload.canceled ? '已停止' : `${years.length} 个年份`
})
}
} else {
setIsLoadingMoreYears(true)
setHasYearsLoadFinished(false)
@@ -105,6 +128,18 @@ function AnnualReportPage() {
})
const startLoad = async () => {
uiTaskId = registerBackgroundTask({
sourcePage: 'annualReport',
title: '年度报告年份加载',
detail: '准备使用原生快速模式加载年份',
progressText: '初始化',
cancelable: true,
onCancel: async () => {
if (taskId) {
await window.electronAPI.annualReport.cancelAvailableYearsLoad(taskId)
}
}
})
setIsLoading(true)
setIsLoadingMoreYears(true)
setHasYearsLoadFinished(false)
@@ -120,6 +155,9 @@ function AnnualReportPage() {
try {
const startResult = await window.electronAPI.annualReport.startAvailableYearsLoad()
if (!startResult.success || !startResult.taskId) {
finishBackgroundTask(uiTaskId, 'failed', {
detail: startResult.error || '加载年度数据失败'
})
setLoadError(startResult.error || '加载年度数据失败')
setIsLoading(false)
setIsLoadingMoreYears(false)
@@ -131,6 +169,9 @@ function AnnualReportPage() {
}
} catch (e) {
console.error(e)
finishBackgroundTask(uiTaskId, 'failed', {
detail: String(e)
})
setLoadError(String(e))
setIsLoading(false)
setIsLoadingMoreYears(false)
@@ -168,16 +209,7 @@ function AnnualReportPage() {
return (
<div className="annual-report-page">
<Loader2 size={32} className="spin" style={{ color: 'var(--text-tertiary)' }} />
<p style={{ color: 'var(--text-tertiary)', marginTop: 16 }}>...</p>
<div className="load-telemetry compact">
<p><span className="label"></span>{getStrategyLabel({ loadStrategy, loadPhase, hasYearsLoadFinished, hasSwitchedStrategy, nativeTimedOut })}</p>
<p><span className="label"></span>{loadStatusText || '正在加载年份数据...'}</p>
<p>
<span className="label"></span>{formatLoadElapsed(nativeElapsedMs)}{nativeTimedOut ? '(超时)' : ''} {' '}
<span className="label"></span>{formatLoadElapsed(scanElapsedMs)} {' '}
<span className="label"></span>{formatLoadElapsed(totalElapsedMs)}
</p>
</div>
<p style={{ color: 'var(--text-tertiary)', marginTop: 16 }}>...</p>
</div>
)
}
@@ -223,30 +255,6 @@ function AnnualReportPage() {
<Sparkles size={32} className="header-icon" />
<h1 className="page-title"></h1>
<p className="page-desc"></p>
{loadedYearCount > 0 && (
<p className={`page-desc load-summary ${isYearStatusComplete ? 'complete' : 'loading'}`}>
{isYearStatusComplete ? (
<> {loadedYearCount} {formatLoadElapsed(totalElapsedMs)}</>
) : (
<>
{loadedYearCount} <span className="dot-ellipsis" aria-hidden="true">...</span>
{formatLoadElapsed(totalElapsedMs)}
</>
)}
</p>
)}
<div className={`load-telemetry ${isYearStatusComplete ? 'complete' : 'loading'}`}>
<p><span className="label"></span>{strategyLabel}</p>
<p>
<span className="label"></span>
{loadStatusText || (isYearStatusComplete ? '全部年份已加载完毕' : '正在加载年份数据...')}
</p>
<p>
<span className="label"></span>{formatLoadElapsed(nativeElapsedMs)}{nativeTimedOut ? '(超时)' : ''} {' '}
<span className="label"></span>{formatLoadElapsed(scanElapsedMs)} {' '}
<span className="label"></span>{formatLoadElapsed(totalElapsedMs)}
</p>
</div>
<div className="report-sections">
<section className="report-section">
@@ -270,7 +278,6 @@ function AnnualReportPage() {
</div>
))}
</div>
{renderYearLoadStatus()}
</div>
<button
@@ -317,7 +324,6 @@ function AnnualReportPage() {
</div>
))}
</div>
{renderYearLoadStatus()}
</div>
<button

View File

@@ -2,6 +2,12 @@ import { useState, useEffect, useRef } from 'react'
import { Loader2, Download, Image, Check, X, SlidersHorizontal } from 'lucide-react'
import html2canvas from 'html2canvas'
import { useThemeStore } from '../stores/themeStore'
import {
finishBackgroundTask,
isBackgroundTaskCancelRequested,
registerBackgroundTask,
updateBackgroundTask
} from '../services/backgroundTaskMonitor'
import './AnnualReportWindow.scss'
// SVG 背景图案 (用于导出)
@@ -127,12 +133,6 @@ function AnnualReportWindow() {
const { currentTheme, themeMode } = useThemeStore()
// 应用主题到独立窗口
useEffect(() => {
document.documentElement.setAttribute('data-theme', currentTheme)
document.documentElement.setAttribute('data-mode', themeMode)
}, [currentTheme, themeMode])
// Section refs
const sectionRefs = {
cover: useRef<HTMLElement>(null),
@@ -164,6 +164,13 @@ function AnnualReportWindow() {
}, [])
const generateReport = async (year: number) => {
const taskId = registerBackgroundTask({
sourcePage: 'annualReport',
title: '年度报告生成',
detail: `正在生成 ${formatYearLabel(year)} 年度报告`,
progressText: '初始化',
cancelable: true
})
setIsLoading(true)
setError(null)
setLoadingProgress(0)
@@ -171,25 +178,46 @@ function AnnualReportWindow() {
const removeProgressListener = window.electronAPI.annualReport.onProgress?.((payload: { status: string; progress: number }) => {
setLoadingProgress(payload.progress)
setLoadingStage(payload.status)
updateBackgroundTask(taskId, {
detail: payload.status || '正在生成年度报告',
progressText: `${Math.max(0, Math.round(payload.progress || 0))}%`
})
})
try {
const result = await window.electronAPI.annualReport.generateReport(year)
removeProgressListener?.()
if (isBackgroundTaskCancelRequested(taskId)) {
finishBackgroundTask(taskId, 'canceled', {
detail: '已停止后续加载,当前报告结果未继续写入页面'
})
setIsLoading(false)
return
}
setLoadingProgress(100)
setLoadingStage('完成')
if (result.success && result.data) {
finishBackgroundTask(taskId, 'completed', {
detail: '年度报告生成完成',
progressText: '100%'
})
setTimeout(() => {
setReportData(result.data!)
setIsLoading(false)
}, 300)
} else {
finishBackgroundTask(taskId, 'failed', {
detail: result.error || '生成年度报告失败'
})
setError(result.error || '生成报告失败')
setIsLoading(false)
}
} catch (e) {
removeProgressListener?.()
finishBackgroundTask(taskId, 'failed', {
detail: String(e)
})
setError(String(e))
setIsLoading(false)
}

View File

@@ -0,0 +1,123 @@
.chat-analytics-hub-page {
min-height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 40px 24px;
}
.chat-analytics-hub-content {
width: min(860px, 100%);
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.chat-analytics-hub-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
border-radius: 999px;
background: var(--primary-light);
color: var(--primary);
font-size: 13px;
font-weight: 600;
}
.chat-analytics-hub-content h1 {
margin: 20px 0 12px;
font-size: 32px;
line-height: 1.2;
color: var(--text-primary);
}
.chat-analytics-hub-desc {
max-width: 620px;
margin: 0 0 32px;
color: var(--text-secondary);
font-size: 15px;
line-height: 1.7;
}
.chat-analytics-hub-grid {
width: 100%;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px;
}
.chat-analytics-entry-card {
display: flex;
flex-direction: column;
align-items: flex-start;
text-align: left;
gap: 14px;
min-height: 260px;
padding: 28px;
border: 1px solid var(--border-color);
border-radius: 20px;
background:
linear-gradient(180deg, rgba(7, 193, 96, 0.08) 0%, rgba(7, 193, 96, 0.02) 100%),
var(--card-bg);
color: var(--text-primary);
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
&:hover {
transform: translateY(-4px);
border-color: rgba(7, 193, 96, 0.35);
box-shadow: 0 20px 36px rgba(7, 193, 96, 0.12);
}
.entry-card-icon {
width: 52px;
height: 52px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(7, 193, 96, 0.12);
color: #07c160;
&.group {
background: rgba(24, 119, 242, 0.12);
color: #1877f2;
}
}
.entry-card-header {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
h2 {
margin: 0;
font-size: 24px;
line-height: 1.2;
}
p {
margin: 0;
color: var(--text-secondary);
font-size: 14px;
line-height: 1.7;
}
.entry-card-cta {
margin-top: auto;
color: var(--primary);
font-size: 13px;
font-weight: 600;
}
}
@media (max-width: 900px) {
.chat-analytics-hub-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,59 @@
import { ArrowRight, BarChart3, MessageSquare, Users } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import './ChatAnalyticsHubPage.scss'
function ChatAnalyticsHubPage() {
const navigate = useNavigate()
return (
<div className="chat-analytics-hub-page">
<div className="chat-analytics-hub-content">
<div className="chat-analytics-hub-badge">
<BarChart3 size={16} />
<span></span>
</div>
<h1></h1>
<p className="chat-analytics-hub-desc">
</p>
<div className="chat-analytics-hub-grid">
<button
type="button"
className="chat-analytics-entry-card"
onClick={() => navigate('/analytics/private')}
>
<div className="entry-card-icon">
<MessageSquare size={24} />
</div>
<div className="entry-card-header">
<h2></h2>
<ArrowRight size={18} />
</div>
<p></p>
<span className="entry-card-cta"></span>
</button>
<button
type="button"
className="chat-analytics-entry-card"
onClick={() => navigate('/analytics/group')}
>
<div className="entry-card-icon group">
<Users size={24} />
</div>
<div className="entry-card-header">
<h2></h2>
<ArrowRight size={18} />
</div>
<p></p>
<span className="entry-card-cta"></span>
</button>
</div>
</div>
</div>
)
}
export default ChatAnalyticsHubPage

View File

@@ -2,15 +2,16 @@
display: flex;
flex-direction: column;
height: 100vh;
background: var(--bg-primary);
background:
linear-gradient(180deg, color-mix(in srgb, var(--bg-primary) 96%, white) 0%, var(--bg-primary) 100%);
.history-list {
flex: 1;
overflow-y: auto;
padding: 16px;
padding: 18px 18px 28px;
display: flex;
flex-direction: column;
gap: 12px;
gap: 0;
.status-msg {
text-align: center;
@@ -30,68 +31,84 @@
.history-item {
display: flex;
gap: 12px;
gap: 14px;
align-items: flex-start;
padding: 14px 0 0;
.avatar {
width: 40px;
height: 40px;
border-radius: 4px;
&.error-item {
padding: 12px;
background: var(--bg-secondary);
border-radius: 8px;
color: var(--text-tertiary);
font-size: 13px;
text-align: center;
justify-content: center;
}
.history-avatar {
width: 36px;
height: 36px;
border-radius: 8px;
overflow: hidden;
flex-shrink: 0;
background: var(--bg-tertiary);
border: none;
box-shadow: none;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
img {
.avatar-component.avatar-inner {
width: 100%;
height: 100%;
object-fit: cover;
}
border-radius: inherit;
background: transparent;
.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;
img.avatar-image {
// Forwarded record head images may include a light matte edge.
// Slightly zoom in to crop that edge and align with normal chat avatars.
transform: scale(1.12);
transform-origin: center;
}
}
}
.content-wrapper {
flex: 1;
min-width: 0;
padding-bottom: 18px;
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 82%, transparent);
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
align-items: flex-start;
gap: 12px;
margin-bottom: 4px;
.sender {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
font-size: 13px;
font-weight: 400;
color: color-mix(in srgb, var(--text-secondary) 82%, transparent);
line-height: 1.3;
}
.time {
font-size: 12px;
color: var(--text-tertiary);
color: color-mix(in srgb, var(--text-tertiary) 92%, transparent);
flex-shrink: 0;
margin-left: 8px;
line-height: 1.3;
}
}
.bubble {
background: var(--bg-secondary);
padding: 10px 14px;
border-radius: 18px 18px 18px 4px;
background: transparent;
padding: 0;
border-radius: 0;
word-wrap: break-word;
max-width: 100%;
display: inline-block;
display: block;
&.image-bubble {
padding: 0;
@@ -99,8 +116,8 @@
}
.text-content {
font-size: 14px;
line-height: 1.6;
font-size: 15px;
line-height: 1.7;
color: var(--text-primary);
white-space: pre-wrap;
word-break: break-word;
@@ -108,23 +125,84 @@
.media-content {
img {
max-width: 100%;
max-height: 300px;
border-radius: 8px;
max-width: min(100%, 420px);
max-height: 320px;
border-radius: 12px;
display: block;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08);
background: color-mix(in srgb, var(--bg-secondary) 88%, transparent);
}
.media-tip {
padding: 8px 12px;
padding: 6px 0;
color: var(--text-tertiary);
font-size: 13px;
}
}
.media-placeholder {
font-size: 14px;
font-size: 13px;
color: var(--text-secondary);
padding: 4px 0;
padding: 4px 0 0;
}
.nested-chat-record-card {
min-width: 220px;
max-width: 320px;
background: color-mix(in srgb, var(--bg-secondary) 97%, #f5f7fb);
border: 1px solid var(--border-color);
border-radius: 14px;
overflow: hidden;
padding: 0;
text-align: left;
cursor: default;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.04);
&.clickable {
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
&:hover {
transform: translateY(-1px);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
border-color: color-mix(in srgb, var(--primary) 28%, var(--border-color));
}
}
&:disabled {
border: 1px solid var(--border-color);
opacity: 1;
}
}
.nested-chat-record-title {
padding: 13px 15px 9px;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.nested-chat-record-list {
padding: 0 15px 11px;
display: flex;
flex-direction: column;
gap: 4px;
border-bottom: 1px solid var(--border-color);
}
.nested-chat-record-line {
font-size: 13px;
line-height: 1.45;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.nested-chat-record-footer {
padding: 8px 15px 11px;
font-size: 12px;
color: var(--text-tertiary);
}
}
}

View File

@@ -2,10 +2,14 @@ import { useEffect, useState } from 'react'
import { useParams, useLocation } from 'react-router-dom'
import { ChatRecordItem } from '../types/models'
import TitleBar from '../components/TitleBar'
import { ErrorBoundary } from '../components/ErrorBoundary'
import { Avatar } from '../components/Avatar'
import './ChatHistoryPage.scss'
const forwardedImageCache = new Map<string, string>()
export default function ChatHistoryPage() {
const params = useParams<{ sessionId: string; messageId: string }>()
const params = useParams<{ sessionId: string; messageId: string; payloadId: string }>()
const location = useLocation()
const [recordList, setRecordList] = useState<ChatRecordItem[]>([])
const [loading, setLoading] = useState(true)
@@ -29,64 +33,212 @@ export default function ChatHistoryPage() {
.replace(/&#39;/g, "'")
}
const extractTopLevelXmlElements = (source: string, tagName: string): Array<{ attrs: string; inner: string }> => {
const xml = source || ''
if (!xml) return []
const pattern = new RegExp(`<(/?)${tagName}\\b([^>]*)>`, 'gi')
const result: Array<{ attrs: string; inner: string }> = []
let match: RegExpExecArray | null
let depth = 0
let openEnd = -1
let openStart = -1
let openAttrs = ''
while ((match = pattern.exec(xml)) !== null) {
const isClosing = match[1] === '/'
const attrs = match[2] || ''
const rawTag = match[0] || ''
const selfClosing = !isClosing && /\/\s*>$/.test(rawTag)
if (!isClosing) {
if (depth === 0) {
openStart = match.index
openEnd = pattern.lastIndex
openAttrs = attrs
}
if (!selfClosing) {
depth += 1
} else if (depth === 0 && openEnd >= 0) {
result.push({ attrs: openAttrs, inner: '' })
openStart = -1
openEnd = -1
openAttrs = ''
}
continue
}
if (depth <= 0) continue
depth -= 1
if (depth === 0 && openEnd >= 0 && openStart >= 0) {
result.push({
attrs: openAttrs,
inner: xml.slice(openEnd, match.index)
})
openStart = -1
openEnd = -1
openAttrs = ''
}
}
return result
}
const parseChatRecordDataItem = (body: string, attrs = ''): ChatRecordItem | null => {
const datatypeMatch = /datatype\s*=\s*["']?(\d+)["']?/i.exec(attrs || '')
const datatype = datatypeMatch ? parseInt(datatypeMatch[1], 10) : parseInt(extractXmlValue(body, 'datatype') || '0', 10)
const sourcename = decodeHtmlEntities(extractXmlValue(body, 'sourcename')) || ''
const sourcetime = extractXmlValue(body, 'sourcetime') || ''
const sourceheadurl = extractXmlValue(body, 'sourceheadurl') || undefined
const datadesc = decodeHtmlEntities(extractXmlValue(body, 'datadesc') || extractXmlValue(body, 'content')) || undefined
const datatitle = decodeHtmlEntities(extractXmlValue(body, 'datatitle')) || undefined
const fileext = extractXmlValue(body, 'fileext') || undefined
const datasize = parseInt(extractXmlValue(body, 'datasize') || '0', 10) || undefined
const messageuuid = extractXmlValue(body, 'messageuuid') || undefined
const dataurl = decodeHtmlEntities(extractXmlValue(body, 'dataurl')) || undefined
const datathumburl = decodeHtmlEntities(
extractXmlValue(body, 'datathumburl') ||
extractXmlValue(body, 'thumburl') ||
extractXmlValue(body, 'cdnthumburl')
) || undefined
const datacdnurl = decodeHtmlEntities(
extractXmlValue(body, 'datacdnurl') ||
extractXmlValue(body, 'cdnurl') ||
extractXmlValue(body, 'cdndataurl')
) || undefined
const cdndatakey = decodeHtmlEntities(extractXmlValue(body, 'cdndatakey')) || undefined
const cdnthumbkey = decodeHtmlEntities(extractXmlValue(body, 'cdnthumbkey')) || undefined
const aeskey = decodeHtmlEntities(extractXmlValue(body, 'aeskey') || extractXmlValue(body, 'qaeskey')) || undefined
const md5 = extractXmlValue(body, 'md5') || extractXmlValue(body, 'datamd5') || undefined
const fullmd5 = extractXmlValue(body, 'fullmd5') || undefined
const thumbfullmd5 = extractXmlValue(body, 'thumbfullmd5') || undefined
const srcMsgLocalid = parseInt(extractXmlValue(body, 'srcMsgLocalid') || '0', 10) || undefined
const imgheight = parseInt(extractXmlValue(body, 'imgheight') || '0', 10) || undefined
const imgwidth = parseInt(extractXmlValue(body, 'imgwidth') || '0', 10) || undefined
const duration = parseInt(extractXmlValue(body, 'duration') || '0', 10) || undefined
const nestedRecordXml = extractXmlValue(body, 'recordxml') || undefined
const chatRecordTitle = decodeHtmlEntities(
(nestedRecordXml && extractXmlValue(nestedRecordXml, 'title')) ||
datatitle ||
''
) || undefined
const chatRecordDesc = decodeHtmlEntities(
(nestedRecordXml && extractXmlValue(nestedRecordXml, 'desc')) ||
datadesc ||
''
) || undefined
const chatRecordList =
datatype === 17 && nestedRecordXml
? parseChatRecordContainer(nestedRecordXml)
: undefined
if (!(datatype || sourcename || datadesc || datatitle || messageuuid || srcMsgLocalid)) return null
return {
datatype: Number.isFinite(datatype) ? datatype : 0,
sourcename,
sourcetime,
sourceheadurl,
datadesc,
datatitle,
fileext,
datasize,
messageuuid,
dataurl,
datathumburl,
datacdnurl,
cdndatakey,
cdnthumbkey,
aeskey,
md5,
fullmd5,
thumbfullmd5,
srcMsgLocalid,
imgheight,
imgwidth,
duration,
chatRecordTitle,
chatRecordDesc,
chatRecordList
}
}
const parseChatRecordContainer = (containerXml: string): ChatRecordItem[] => {
const source = containerXml || ''
if (!source) return []
const segments: string[] = [source]
const decodedContainer = decodeHtmlEntities(source)
if (decodedContainer && decodedContainer !== source) {
segments.push(decodedContainer)
}
const cdataRegex = /<!\[CDATA\[([\s\S]*?)\]\]>/g
let cdataMatch: RegExpExecArray | null
while ((cdataMatch = cdataRegex.exec(source)) !== null) {
const cdataInner = cdataMatch[1] || ''
if (!cdataInner) continue
segments.push(cdataInner)
const decodedInner = decodeHtmlEntities(cdataInner)
if (decodedInner && decodedInner !== cdataInner) {
segments.push(decodedInner)
}
}
const items: ChatRecordItem[] = []
const dedupe = new Set<string>()
for (const segment of segments) {
if (!segment) continue
const dataItems = extractTopLevelXmlElements(segment, 'dataitem')
for (const dataItem of dataItems) {
const item = parseChatRecordDataItem(dataItem.inner || '', dataItem.attrs || '')
if (!item) continue
const key = `${item.datatype}|${item.sourcename}|${item.sourcetime}|${item.datadesc || ''}|${item.datatitle || ''}|${item.messageuuid || ''}`
if (!dedupe.has(key)) {
dedupe.add(key)
items.push(item)
}
}
}
if (items.length > 0) return items
const fallback = parseChatRecordDataItem(source, '')
return fallback ? [fallback] : []
}
// 前端兜底解析合并转发聊天记录
const parseChatHistory = (content: string): ChatRecordItem[] | undefined => {
try {
const type = extractXmlValue(content, 'type')
if (type !== '19') return undefined
const decodedContent = decodeHtmlEntities(content) || content
const type = extractXmlValue(decodedContent, 'type')
if (type !== '19' && !decodedContent.includes('<recorditem')) 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
const dedupe = new Set<string>()
const recordItemRegex = /<recorditem>([\s\S]*?)<\/recorditem>/gi
let recordItemMatch: RegExpExecArray | null
while ((recordItemMatch = recordItemRegex.exec(decodedContent)) !== null) {
const parsedItems = parseChatRecordContainer(recordItemMatch[1] || '')
for (const item of parsedItems) {
const key = `${item.datatype}|${item.sourcename}|${item.sourcetime}|${item.datadesc || ''}|${item.datatitle || ''}|${item.messageuuid || ''}`
if (!dedupe.has(key)) {
dedupe.add(key)
items.push(item)
}
}
}
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
})
if (items.length === 0 && decodedContent.includes('<dataitem')) {
const parsedItems = parseChatRecordContainer(decodedContent)
for (const item of parsedItems) {
const key = `${item.datatype}|${item.sourcename}|${item.sourcetime}|${item.datadesc || ''}|${item.datatitle || ''}|${item.messageuuid || ''}`
if (!dedupe.has(key)) {
dedupe.add(key)
items.push(item)
}
}
}
return items.length > 0 ? items : undefined
@@ -114,9 +266,34 @@ export default function ChatHistoryPage() {
return { sid: '', mid: '' }
}
const ids = getIds()
const payloadId = params.payloadId || (() => {
const match = /^\/chat-history-inline\/([^/]+)/.exec(location.pathname)
return match ? match[1] : ''
})()
useEffect(() => {
const loadData = async () => {
const { sid, mid } = getIds()
if (payloadId) {
try {
const result = await window.electronAPI.window.getChatHistoryPayload(payloadId)
if (result.success && result.payload) {
setRecordList(Array.isArray(result.payload.recordList) ? result.payload.recordList : [])
setTitle(result.payload.title || '聊天记录')
setError('')
} else {
setError(result.error || '聊天记录载荷不存在')
}
} catch (e) {
console.error(e)
setError('加载详情失败')
} finally {
setLoading(false)
}
return
}
const { sid, mid } = ids
if (!sid || !mid) {
setError('无效的聊天记录链接')
setLoading(false)
@@ -152,7 +329,7 @@ export default function ChatHistoryPage() {
}
}
loadData()
}, [params.sessionId, params.messageId, location.pathname])
}, [ids.mid, ids.sid, location.pathname, payloadId])
return (
<div className="chat-history-page">
@@ -166,7 +343,9 @@ export default function ChatHistoryPage() {
<div className="status-msg empty"></div>
) : (
recordList.map((item, i) => (
<HistoryItem key={i} item={item} />
<ErrorBoundary key={i} fallback={<div className="history-item error-item"></div>}>
<HistoryItem item={item} sessionId={ids.sid} />
</ErrorBoundary>
))
)}
</div>
@@ -174,7 +353,198 @@ export default function ChatHistoryPage() {
)
}
function HistoryItem({ item }: { item: ChatRecordItem }) {
function detectImageMimeFromBase64(base64: string): string {
try {
const head = window.atob(base64.slice(0, 48))
const bytes = new Uint8Array(head.length)
for (let i = 0; i < head.length; i++) {
bytes[i] = head.charCodeAt(i)
}
if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) return 'image/gif'
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47) return 'image/png'
if (bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF) return 'image/jpeg'
if (
bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 &&
bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50
) {
return 'image/webp'
}
} catch { }
return 'image/jpeg'
}
function normalizeChatRecordText(value?: string): string {
return String(value || '')
.replace(/\u00a0/g, ' ')
.replace(/\s+/g, ' ')
.trim()
}
function getChatRecordPreviewText(item: ChatRecordItem): string {
const text = normalizeChatRecordText(item.datadesc) || normalizeChatRecordText(item.datatitle)
if (item.datatype === 17) {
return normalizeChatRecordText(item.chatRecordTitle) || normalizeChatRecordText(item.datatitle) || '聊天记录'
}
if (item.datatype === 2 || item.datatype === 3) return '[图片]'
if (item.datatype === 43) return '[视频]'
if (item.datatype === 34) return '[语音]'
if (item.datatype === 47) return '[表情]'
return text || '[媒体消息]'
}
function ForwardedImage({ item, sessionId }: { item: ChatRecordItem; sessionId: string }) {
const cacheKey =
item.thumbfullmd5 ||
item.fullmd5 ||
item.md5 ||
item.messageuuid ||
item.datathumburl ||
item.datacdnurl ||
item.dataurl ||
`local:${item.srcMsgLocalid || 0}`
const [localPath, setLocalPath] = useState<string | undefined>(() => forwardedImageCache.get(cacheKey))
const [loading, setLoading] = useState(!forwardedImageCache.has(cacheKey))
const [error, setError] = useState(false)
useEffect(() => {
if (localPath || error) return
let cancelled = false
const candidateMd5s = Array.from(new Set([
item.thumbfullmd5,
item.fullmd5,
item.md5
].filter(Boolean) as string[]))
const load = async () => {
setLoading(true)
for (const imageMd5 of candidateMd5s) {
const cached = await window.electronAPI.image.resolveCache({ imageMd5 })
if (cached.success && cached.localPath) {
if (!cancelled) {
forwardedImageCache.set(cacheKey, cached.localPath)
setLocalPath(cached.localPath)
setLoading(false)
}
return
}
}
for (const imageMd5 of candidateMd5s) {
const decrypted = await window.electronAPI.image.decrypt({ imageMd5 })
if (decrypted.success && decrypted.localPath) {
if (!cancelled) {
forwardedImageCache.set(cacheKey, decrypted.localPath)
setLocalPath(decrypted.localPath)
setLoading(false)
}
return
}
}
if (sessionId && item.srcMsgLocalid) {
const fallback = await window.electronAPI.chat.getImageData(sessionId, String(item.srcMsgLocalid))
if (fallback.success && fallback.data) {
const dataUrl = `data:${detectImageMimeFromBase64(fallback.data)};base64,${fallback.data}`
if (!cancelled) {
forwardedImageCache.set(cacheKey, dataUrl)
setLocalPath(dataUrl)
setLoading(false)
}
return
}
}
const remoteSrc = item.dataurl || item.datathumburl || item.datacdnurl
if (remoteSrc && /^https?:\/\//i.test(remoteSrc)) {
if (!cancelled) {
setLocalPath(remoteSrc)
setLoading(false)
}
return
}
if (!cancelled) {
setError(true)
setLoading(false)
}
}
load().catch(() => {
if (!cancelled) {
setError(true)
setLoading(false)
}
})
return () => {
cancelled = true
}
}, [cacheKey, error, item.dataurl, item.datacdnurl, item.datathumburl, item.fullmd5, item.md5, item.messageuuid, item.srcMsgLocalid, item.thumbfullmd5, localPath, sessionId])
if (localPath) {
return (
<div className="media-content">
<img src={localPath} alt="图片" referrerPolicy="no-referrer" />
</div>
)
}
if (loading) {
return <div className="media-tip">...</div>
}
if (error) {
return <div className="media-tip"></div>
}
return <div className="media-placeholder">[]</div>
}
function NestedChatRecordCard({ item, sessionId }: { item: ChatRecordItem; sessionId: string }) {
const previewItems = (item.chatRecordList || []).slice(0, 3)
const title = normalizeChatRecordText(item.chatRecordTitle) || normalizeChatRecordText(item.datatitle) || '聊天记录'
const description = normalizeChatRecordText(item.chatRecordDesc) || normalizeChatRecordText(item.datadesc)
const canOpen = Boolean(sessionId && item.chatRecordList && item.chatRecordList.length > 0)
const handleOpen = () => {
if (!canOpen) return
window.electronAPI.window.openChatHistoryPayloadWindow({
sessionId,
title,
recordList: item.chatRecordList || []
}).catch(() => { })
}
return (
<button
type="button"
className={`nested-chat-record-card${canOpen ? ' clickable' : ''}`}
onClick={handleOpen}
disabled={!canOpen}
title={canOpen ? '点击打开聊天记录' : undefined}
>
<div className="nested-chat-record-title">{title}</div>
{previewItems.length > 0 ? (
<div className="nested-chat-record-list">
{previewItems.map((previewItem, index) => (
<div key={`${previewItem.messageuuid || previewItem.srcMsgLocalid || index}`} className="nested-chat-record-line">
{getChatRecordPreviewText(previewItem)}
</div>
))}
</div>
) : description ? (
<div className="nested-chat-record-list">
<div className="nested-chat-record-line">{description}</div>
</div>
) : null}
<div className="nested-chat-record-footer"></div>
</button>
)
}
function HistoryItem({ item, sessionId }: { item: ChatRecordItem; sessionId: string }) {
// sourcetime 在合并转发里有两种格式:
// 1) 时间戳(秒) 2) 已格式化的字符串 "2026-01-21 09:56:46"
let time = ''
@@ -186,34 +556,18 @@ function HistoryItem({ item }: { item: ChatRecordItem }) {
}
}
const senderDisplayName = item.sourcename ?? '未知发送者'
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 === 2 || item.datatype === 3) {
return <ForwardedImage item={item} sessionId={sessionId} />
}
if (item.datatype === 17) {
return <NestedChatRecordCard item={item} sessionId={sessionId} />
}
if (item.datatype === 43) {
return <div className="media-placeholder">[] {item.datatitle}</div>
@@ -227,21 +581,20 @@ function HistoryItem({ item }: { item: ChatRecordItem }) {
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 className="history-avatar">
<Avatar
src={item.sourceheadurl}
name={senderDisplayName}
size={36}
className="avatar-inner"
/>
</div>
<div className="content-wrapper">
<div className="header">
<span className="sender">{item.sourcename || '未知发送者'}</span>
<span className="sender">{senderDisplayName}</span>
<span className="time">{time}</span>
</div>
<div className={`bubble ${item.datatype === 3 ? 'image-bubble' : ''}`}>
<div className={`bubble ${(item.datatype === 2 || item.datatype === 3) ? 'image-bubble' : ''}`}>
{renderContent()}
</div>
</div>

View File

@@ -566,7 +566,8 @@
flex: 1;
background: var(--chat-pattern);
background-color: var(--bg-secondary);
padding: 20px 24px;
padding: 20px 24px 112px;
padding-bottom: calc(112px + env(safe-area-inset-bottom));
&::-webkit-scrollbar {
width: 6px;
@@ -600,7 +601,8 @@
}
.message-wrapper {
margin-bottom: 16px;
box-sizing: border-box;
padding-bottom: 16px;
}
.message-bubble {
@@ -1129,8 +1131,12 @@
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
.highlight {
color: var(--primary);
font-weight: 500;
}
}
.unread-badge {
@@ -1605,6 +1611,7 @@
align-items: center;
gap: 12px;
border-bottom: 1px solid var(--border-color);
-webkit-app-region: drag;
.session-avatar {
width: 40px;
@@ -1638,6 +1645,7 @@
display: flex;
align-items: center;
gap: 8px;
-webkit-app-region: no-drag;
.jump-calendar-anchor {
position: relative;
@@ -1742,7 +1750,8 @@
overflow-y: auto;
overflow-x: hidden;
min-height: 0;
padding: 20px 24px;
padding: 20px 24px 112px;
padding-bottom: calc(112px + env(safe-area-inset-bottom));
display: flex;
flex-direction: column;
gap: 16px;
@@ -1771,6 +1780,10 @@
}
}
.message-virtuoso {
width: 100%;
}
.loading-messages.loading-overlay {
position: absolute;
inset: 0;
@@ -1783,6 +1796,30 @@
z-index: 2;
}
.standalone-phase-overlay {
position: absolute;
inset: 0;
z-index: 3;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
background: color-mix(in srgb, var(--bg-tertiary) 82%, transparent);
color: var(--text-secondary);
font-size: 14px;
pointer-events: none;
.spin {
animation: spin 1s linear infinite;
}
small {
color: var(--text-tertiary);
font-size: 12px;
}
}
.empty-chat-inline {
display: flex;
flex-direction: column;
@@ -1804,9 +1841,9 @@
// 回到底部按钮
.scroll-to-bottom {
position: sticky;
position: absolute;
bottom: 20px;
align-self: center;
left: 50%;
padding: 8px 16px;
border-radius: 20px;
background: var(--bg-secondary);
@@ -1821,13 +1858,13 @@
font-size: 13px;
z-index: 10;
opacity: 0;
transform: translateY(20px);
transform: translate(-50%, 20px);
pointer-events: none;
transition: all 0.3s ease;
&.show {
opacity: 1;
transform: translateY(0);
transform: translate(-50%, 0);
pointer-events: auto;
}
@@ -1864,6 +1901,8 @@
.message-wrapper {
display: flex;
flex-direction: column;
box-sizing: border-box;
padding-bottom: 16px;
-webkit-app-region: no-drag;
&.sent {
@@ -2030,6 +2069,10 @@
object-fit: contain;
}
.emoji-message-wrapper {
display: inline-block;
}
.emoji-loading {
width: 90px;
height: 90px;
@@ -2735,7 +2778,7 @@
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 12px;
@@ -3019,13 +3062,15 @@
}
.member-flag {
width: 18px;
height: 18px;
padding: 0 6px;
border-radius: 9999px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border-color);
font-size: 11px;
white-space: nowrap;
&.owner {
color: #f59e0b;
@@ -3262,13 +3307,89 @@
// 聊天记录消息 (合并转发)
.chat-record-message {
background: var(--card-inner-bg) !important;
border: 1px solid var(--border-color) !important;
transition: opacity 0.2s ease;
width: 300px;
min-width: 240px;
max-width: 336px;
background: color-mix(in srgb, var(--bg-secondary) 97%, #f5f7fb);
border: 1px solid var(--border-color);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.04);
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
cursor: pointer;
padding: 0;
&:hover {
opacity: 0.85;
transform: translateY(-1px);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
border-color: color-mix(in srgb, var(--primary) 28%, var(--border-color));
}
.chat-record-title {
padding: 13px 16px 6px;
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
line-height: 1.45;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.chat-record-meta-line {
padding: 0 16px 10px;
font-size: 11px;
color: var(--text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chat-record-list {
padding: 0 16px 11px;
display: flex;
flex-direction: column;
gap: 4px;
max-height: 92px;
overflow: hidden;
border-bottom: 1px solid var(--border-color);
}
.chat-record-item {
font-size: 12px;
line-height: 1.45;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.source-name {
color: currentColor;
opacity: 0.92;
font-weight: 500;
margin-right: 4px;
}
.chat-record-more {
font-size: 11px;
color: var(--text-tertiary);
}
.chat-record-desc {
padding: 0 16px 11px;
font-size: 12px;
line-height: 1.45;
color: var(--text-secondary);
border-bottom: 1px solid var(--border-color);
}
.chat-record-footer {
padding: 8px 16px 10px;
font-size: 11px;
color: var(--text-tertiary);
}
}
@@ -3342,75 +3463,6 @@
}
}
// 聊天记录消息 - 复用 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;
@@ -3507,23 +3559,18 @@
.message-bubble.sent {
.card-message,
.chat-record-message,
.miniapp-message,
.appmsg-rich-card {
background: var(--sent-card-bg);
.card-name,
.miniapp-title,
.source-name,
.link-title {
color: white;
}
.card-label,
.miniapp-label,
.chat-record-item,
.chat-record-meta-line,
.chat-record-desc,
.link-desc,
.appmsg-url-line {
color: rgba(255, 255, 255, 0.8);
@@ -3531,14 +3578,10 @@
.card-icon,
.miniapp-icon,
.chat-record-icon {
.link-thumb-placeholder {
color: white;
}
.chat-record-more {
color: rgba(255, 255, 255, 0.9);
}
.appmsg-meta-badge {
color: rgba(255, 255, 255, 0.92);
background: rgba(255, 255, 255, 0.12);
@@ -3619,11 +3662,11 @@
// 批量转写按钮
.batch-transcribe-btn {
&:hover:not(:disabled) {
color: var(--primary-color);
color: var(--primary);
}
&.transcribing {
color: var(--primary-color);
color: var(--primary);
cursor: pointer;
opacity: 1 !important;
}
@@ -3647,7 +3690,7 @@
border-bottom: 1px solid var(--border-color);
svg {
color: var(--primary-color);
color: var(--primary);
}
h3 {
@@ -3668,6 +3711,36 @@
line-height: 1.6;
}
.batch-task-switch {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
margin-bottom: 1rem;
.batch-task-btn {
border: 1px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-secondary);
border-radius: 8px;
padding: 0.55rem 0.75rem;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: color-mix(in srgb, var(--primary) 50%, var(--border-color));
color: var(--text-primary);
}
&.active {
border-color: var(--primary);
color: var(--primary);
background: color-mix(in srgb, var(--primary) 10%, var(--bg-primary));
box-shadow: 0 0 0 1px color-mix(in srgb, var(--primary) 25%, transparent);
}
}
}
.batch-dates-list-wrap {
margin-bottom: 1rem;
background: var(--bg-tertiary);
@@ -3685,7 +3758,7 @@
.batch-dates-btn {
padding: 0.35rem 0.75rem;
font-size: 12px;
color: var(--primary-color);
color: var(--primary);
background: transparent;
border: 1px solid var(--border-color);
border-radius: 6px;
@@ -3694,7 +3767,7 @@
&:hover {
background: var(--bg-hover);
border-color: var(--primary-color);
border-color: var(--primary);
}
}
}
@@ -3727,9 +3800,14 @@
}
input[type="checkbox"] {
accent-color: var(--primary-color);
accent-color: var(--primary);
cursor: pointer;
flex-shrink: 0;
&:focus-visible {
outline: 2px solid color-mix(in srgb, var(--primary) 45%, transparent);
outline-offset: 1px;
}
}
.batch-date-label {
@@ -3772,7 +3850,7 @@
.value {
font-size: 14px;
font-weight: 600;
color: var(--primary-color);
color: var(--primary);
}
.batch-concurrency-field {
@@ -3898,7 +3976,7 @@
&.btn-primary,
&.batch-transcribe-start-btn {
background: var(--primary-color);
background: var(--primary);
color: #000;
&:hover {
@@ -4145,43 +4223,6 @@
}
}
// 聊天记录消息外观
.chat-record-message {
background: var(--card-inner-bg) !important;
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
&:hover {
background: var(--bg-hover) !important;
}
.chat-record-list {
font-size: 13px;
color: var(--text-tertiary);
line-height: 1.6;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--border-color);
.chat-record-item {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
.source-name {
color: var(--text-secondary);
}
}
}
.chat-record-more {
font-size: 12px;
color: var(--text-tertiary);
margin-top: 4px;
}
}
// 公众号文章图文消息外观 (大图模式)
.official-message {
display: flex;
@@ -4416,18 +4457,23 @@
// 折叠群入口样式
.session-item.fold-entry {
background: var(--card-inner-bg, rgba(0,0,0,0.03));
cursor: pointer;
transition: background-color 0.2s;
&:hover {
background: var(--hover-bg, rgba(0,0,0,0.05));
}
.fold-entry-avatar {
width: 48px;
height: 48px;
border-radius: 8px;
background: var(--primary-color, #07c160);
background: #fff;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #fff;
color: #fa9d3b;
}
.session-name {
@@ -4507,7 +4553,7 @@
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 12px;
@@ -4599,3 +4645,260 @@
}
}
}
// 会话内搜索栏
// 会话内搜索浮窗
.in-session-search-popup {
position: absolute;
top: 60px;
right: 16px;
width: 360px;
max-height: 500px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
z-index: 1000;
display: flex;
flex-direction: column;
overflow: hidden;
.in-session-search-header {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
.search-icon {
color: var(--text-secondary);
flex-shrink: 0;
}
.search-input {
flex: 1;
border: none;
background: transparent;
outline: none;
font-size: 14px;
color: var(--text-primary);
min-width: 0;
&::placeholder { color: var(--text-tertiary); }
}
.spin {
animation: spin 1s linear infinite;
color: var(--primary);
flex-shrink: 0;
}
.close-btn {
padding: 4px;
border-radius: 4px;
background: transparent;
border: none;
color: var(--text-tertiary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
flex-shrink: 0;
&:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
}
}
.search-result-header {
padding: 6px 16px;
font-size: 12px;
color: var(--text-secondary);
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.in-session-results {
flex: 1;
overflow-y: auto;
min-height: 0;
.result-item {
display: flex;
align-items: flex-start;
padding: 12px 16px;
cursor: pointer;
gap: 10px;
border-bottom: 1px solid var(--border-color);
transition: background 0.15s;
&:last-child {
border-bottom: none;
}
&:hover {
background: var(--bg-secondary);
}
.result-header {
flex-shrink: 0;
.result-info {
display: none;
}
}
.result-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
.result-sender {
font-size: 13px;
color: var(--text-primary);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.result-text {
font-size: 13px;
color: var(--text-secondary);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
line-height: 1.4;
}
}
.result-time {
font-size: 11px;
color: var(--text-tertiary);
flex-shrink: 0;
}
}
}
.no-results {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
color: var(--text-tertiary);
gap: 12px;
p {
margin: 0;
font-size: 14px;
}
}
}
// 搜索分类标题
.search-section-header {
padding: 8px 16px;
font-size: 12px;
color: var(--text-tertiary);
background: var(--bg-secondary);
font-weight: 500;
display: flex;
align-items: center;
gap: 6px;
.search-phase-hint {
color: var(--primary);
font-weight: 400;
&.done {
color: var(--text-tertiary);
}
}
}
// 全局消息搜索结果面板
.global-msg-search-results {
max-height: 300px;
overflow-y: auto;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
.search-loading,
.no-results {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 24px;
color: var(--text-tertiary);
font-size: 13px;
}
.search-results-list {
.session-item {
display: flex;
padding: 12px 16px;
cursor: pointer;
border-bottom: 1px solid var(--border-color);
gap: 12px;
background: var(--bg-secondary);
&:hover {
background: var(--bg-hover);
}
.session-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
.session-top {
display: flex;
justify-content: space-between;
align-items: center;
.session-name {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
}
}
.session-preview {
font-size: 13px;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.highlight {
color: var(--primary);
font-weight: 500;
}
}
.search-count {
font-size: 12px;
color: var(--primary);
}
}
}
}
}
.msg-search-toggle-btn.active {
color: var(--accent-color, #07c160);
}
.in-session-search-btn.active {
color: var(--accent-color, #07c160);
}

File diff suppressed because it is too large Load Diff

View File

@@ -535,6 +535,28 @@
word-break: break-all;
user-select: text;
}
.detail-entry-btn {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: auto;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
color: var(--text-primary);
padding: 6px 10px;
font-size: 12px;
line-height: 1;
cursor: pointer;
transition: border-color 0.2s ease, color 0.2s ease, background 0.2s ease;
&:hover {
color: var(--primary);
border-color: color-mix(in srgb, var(--primary) 45%, var(--border-color));
background: color-mix(in srgb, var(--primary) 8%, var(--bg-secondary));
}
}
}
.goto-chat-btn {

View File

@@ -1,20 +1,14 @@
import { useState, useEffect, useCallback, useMemo, useRef, type UIEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown, MessageCircle, UserX, AlertTriangle, ClipboardList } from 'lucide-react'
import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown, MessageCircle, UserX, AlertTriangle, ClipboardList, Aperture } from 'lucide-react'
import { useChatStore } from '../stores/chatStore'
import { toContactTypeCardCounts, useContactTypeCountsStore } from '../stores/contactTypeCountsStore'
import * as configService from '../services/config'
import type { ContactInfo } from '../types/models'
import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog'
import { type ContactSnsTimelineTarget, isSingleContactSession } from '../components/Sns/contactSnsTimeline'
import './ContactsPage.scss'
interface ContactInfo {
username: string
displayName: string
remark?: string
nickname?: string
avatarUrl?: string
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
}
interface ContactEnrichInfo {
displayName?: string
avatarUrl?: string
@@ -62,6 +56,9 @@ function ContactsPage() {
// 导出模式与查看详情
const [exportMode, setExportMode] = useState(false)
const [selectedContact, setSelectedContact] = useState<ContactInfo | null>(null)
const [snsUserPostCounts, setSnsUserPostCounts] = useState<Record<string, number>>({})
const [snsUserPostCountsStatus, setSnsUserPostCountsStatus] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle')
const [snsTimelineTarget, setSnsTimelineTarget] = useState<ContactSnsTimelineTarget | null>(null)
const navigate = useNavigate()
const { setCurrentSession } = useChatStore()
@@ -509,6 +506,41 @@ function ContactsPage() {
return () => window.clearTimeout(timer)
}, [searchKeyword])
const loadSnsUserPostCounts = useCallback(async (options?: { force?: boolean }) => {
if (!options?.force && (snsUserPostCountsStatus === 'loading' || snsUserPostCountsStatus === 'ready')) {
return
}
setSnsUserPostCountsStatus('loading')
try {
const result = await window.electronAPI.sns.getUserPostCounts()
if (!result.success || !result.counts) {
setSnsUserPostCountsStatus('error')
return
}
const normalizedCounts: Record<string, number> = {}
for (const [rawUsername, rawCount] of Object.entries(result.counts)) {
const username = String(rawUsername || '').trim()
if (!username) continue
const value = Number(rawCount)
normalizedCounts[username] = Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0
}
setSnsUserPostCounts(normalizedCounts)
setSnsUserPostCountsStatus('ready')
} catch (error) {
console.error('加载通讯录联系人朋友圈条数失败:', error)
setSnsUserPostCountsStatus('error')
}
}, [snsUserPostCountsStatus])
useEffect(() => {
if (!selectedContact || !isSingleContactSession(selectedContact.username)) return
if (snsUserPostCountsStatus !== 'idle') return
void loadSnsUserPostCounts()
}, [loadSnsUserPostCounts, selectedContact, snsUserPostCountsStatus])
const filteredContacts = useMemo(() => {
let filtered = contacts.filter(contact => {
if (contact.type === 'friend' && !contactTypes.friends) return false
@@ -579,6 +611,38 @@ function ContactsPage() {
}, [filteredContacts, selectedUsernames])
const allFilteredSelected = filteredContacts.length > 0 && selectedInFilteredCount === filteredContacts.length
const selectedContactSupportsSns = useMemo(() => {
return Boolean(selectedContact && isSingleContactSession(selectedContact.username))
}, [selectedContact])
const selectedContactSnsCount = useMemo(() => {
if (!selectedContactSupportsSns || !selectedContact) return null
if (snsUserPostCountsStatus !== 'ready') return null
const rawCount = Number(snsUserPostCounts[selectedContact.username] || 0)
return Number.isFinite(rawCount) ? Math.max(0, Math.floor(rawCount)) : 0
}, [selectedContact, selectedContactSupportsSns, snsUserPostCounts, snsUserPostCountsStatus])
const selectedContactSnsEntryLabel = useMemo(() => {
if (!selectedContactSupportsSns) return ''
if (selectedContactSnsCount !== null) {
return `朋友圈:${selectedContactSnsCount.toLocaleString('zh-CN')}`
}
if (snsUserPostCountsStatus === 'error') return '朋友圈:查看'
return '朋友圈:统计中...'
}, [selectedContactSupportsSns, selectedContactSnsCount, snsUserPostCountsStatus])
const openSelectedContactSnsTimeline = useCallback(() => {
if (!selectedContact || !selectedContactSupportsSns) return
if (snsUserPostCountsStatus === 'idle') {
void loadSnsUserPostCounts()
}
setSnsTimelineTarget({
username: selectedContact.username,
displayName: selectedContact.displayName || selectedContact.remark || selectedContact.nickname || selectedContact.username,
avatarUrl: selectedContact.avatarUrl
})
}, [loadSnsUserPostCounts, selectedContact, selectedContactSupportsSns, snsUserPostCountsStatus])
const { startIndex, endIndex } = useMemo(() => {
if (filteredContacts.length === 0) {
return { startIndex: 0, endIndex: 0 }
@@ -827,28 +891,6 @@ function ContactsPage() {
</label>
</div>
<div className="contacts-count">
{filteredContacts.length} / {contacts.length}
{contactsUpdatedAt && (
<span className="contacts-cache-meta">
{contactsDataSource === 'cache' ? '缓存' : '最新'} · {contactsUpdatedAtLabel}
</span>
)}
{contacts.length > 0 && (
<span className="contacts-cache-meta">
{avatarCachedCount}/{contacts.length}
{avatarCacheUpdatedAtLabel ? ` · 更新于 ${avatarCacheUpdatedAtLabel}` : ''}
</span>
)}
{isLoading && contacts.length > 0 && (
<span className="contacts-cache-meta syncing">...</span>
)}
{avatarEnrichProgress.running && (
<span className="avatar-enrich-progress">
{avatarEnrichProgress.loaded}/{avatarEnrichProgress.total}
</span>
)}
</div>
{exportMode && (
<div className="selection-toolbar">
@@ -1069,6 +1111,19 @@ function ContactsPage() {
<div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.nickname || selectedContact.displayName}</span></div>
{selectedContact.remark && <div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.remark}</span></div>}
<div className="detail-row"><span className="detail-label"></span><span className="detail-value">{getContactTypeName(selectedContact.type)}</span></div>
{selectedContactSupportsSns && (
<div className="detail-row">
<span className="detail-label"></span>
<button
type="button"
className="detail-entry-btn"
onClick={openSelectedContactSnsTimeline}
>
<Aperture size={14} />
<span>{selectedContactSnsEntryLabel}</span>
</button>
</div>
)}
</div>
<button
@@ -1091,6 +1146,14 @@ function ContactsPage() {
</div>
</div>
)}
<ContactSnsTimelineDialog
target={snsTimelineTarget}
onClose={() => setSnsTimelineTarget(null)}
initialTotalPosts={selectedContact && snsTimelineTarget?.username === selectedContact.username ? selectedContactSnsCount : null}
initialTotalPostsLoading={selectedContact && snsTimelineTarget?.username === selectedContact.username
? snsUserPostCountsStatus === 'idle' || snsUserPostCountsStatus === 'loading'
: false}
/>
</div>
)
}

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