Compare commits

..

447 Commits

Author SHA1 Message Date
dependabot[bot]
fe02ff0d84 chore(deps): bump lucide-react from 1.7.0 to 1.8.0
Bumps [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) from 1.7.0 to 1.8.0.
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.8.0/packages/lucide-react)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 1.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-21 00:16:19 +00:00
cc
33fde44cc3 Merge pull request #804 from hellodigua/feat-chatlab
feat: API服务支持ChatLab新版协议
2026-04-20 23:23:39 +08:00
cc
fc9b1ead9e codeql拜拜 2026-04-20 23:21:41 +08:00
xuncha
c5f629ac4a Merge branch 'dev' into feat-chatlab 2026-04-20 23:17:50 +08:00
xuncha
898d2c7f29 更新文档 2026-04-20 23:12:08 +08:00
cc
4aa0f517bf 修复构建问题 2026-04-20 22:44:24 +08:00
cc
682f43bf2f 年度报告优化 #720 2026-04-19 19:28:14 +08:00
cc
bc2e7d616a 年度报告配色修改 2026-04-19 18:57:37 +08:00
cc
ef2bbe5c22 年度报告优化 2026-04-19 18:34:41 +08:00
cc
4de4a74eca 年度报告初版 2026-04-19 15:42:53 +08:00
digua
0ba1067123 feat: API服务支持ChatLab新版协议 2026-04-19 14:50:13 +08:00
cc
b7c7ca4376 修复图片解密部分损坏的问题 2026-04-19 12:10:02 +08:00
cc
c91163abac 同步资源文件 2026-04-19 00:22:12 +08:00
cc
f40f3225df Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-19 00:02:24 +08:00
cc
3a99eb8338 修复群成员问题 2026-04-19 00:02:16 +08:00
cc
a902ef70d9 Merge pull request #800 from Jasonzhu1207/main
feat(insight): introduce whitelist/blacklist mode with typed batch selection
2026-04-18 23:53:19 +08:00
Jason
c9498d5079 Merge pull request #32 from Jasonzhu1207/fix/insight-filter-mode-dropdown-style
fix(ui): repair insight filter mode dropdown styling
2026-04-18 22:52:17 +08:00
Jason
b9d8b303a1 fix(ui): scope insight filter mode dropdown styles correctly 2026-04-18 22:49:00 +08:00
cc
c94405e5bb Merge pull request #799 from BeiChen-CN/dev
fix(export): 修复文件导出功能
2026-04-18 21:22:20 +08:00
姜北尘
9697dcb703 fix(export): 修复文件导出功能 2026-04-18 21:19:42 +08:00
Jason
55ee72225e Merge pull request #31 from Jasonzhu1207/fix/release-publish-current-repo
fix(ci): publish releases to current repository
2026-04-18 18:28:34 +08:00
Jason
e47eaf273e fix(ci): publish releases to current repository 2026-04-18 18:25:28 +08:00
Jason
aa16c87afc Merge pull request #30 from Jasonzhu1207/feature/ai-insight-filter-mode-v1
feat(insight): introduce whitelist/blacklist mode with typed batch selection
2026-04-18 17:53:41 +08:00
cc
bd439b7179 Merge pull request #794 from BeiChen-CN/main
fix(windows): 修复 Electron 加载 WCDB 运行时导致的崩溃
2026-04-18 17:48:57 +08:00
Jason
678c08b507 feat(insight): add whitelist/blacklist mode and typed batch selection 2026-04-18 17:45:44 +08:00
姜北尘
216a6011bd Merge branch 'dev' into main 2026-04-18 17:45:40 +08:00
姜北尘
e12caa16a6 fix(windows): 修复 Electron 加载 WCDB 运行时导致的崩溃 2026-04-18 17:42:24 +08:00
cc
55885449a3 Merge pull request #790 from hicccc77/dev
尝试支持mac下载更新
2026-04-18 15:37:27 +08:00
cc
06c020d9ca 尝试支持mac下载更新 2026-04-18 15:29:09 +08:00
cc
da84623898 Merge pull request #788 from hicccc77/dev
Dev
2026-04-18 12:55:30 +08:00
cc
5221d427ed Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-18 12:54:24 +08:00
cc
6c84e0c35a 图片与视频索引优化 #786;修复 #786;修复导出页面打开目录缺失路径的问题;完善朋友圈卡片封面解析 2026-04-18 12:54:14 +08:00
cc
167ce3fae0 Merge pull request #785 from Jasonzhu1207/main
fix(ai): append current system time to prompt
2026-04-17 22:40:55 +08:00
Jason
b8bcfa23be Merge branch 'hicccc77:main' into main 2026-04-17 22:36:42 +08:00
cc
3bff868df1 Merge pull request #783 from hicccc77/dev
修正文件路径
2026-04-17 22:12:26 +08:00
cc
74012ab252 修正文件路径 2026-04-17 22:11:38 +08:00
cc
574ba94e0e Merge pull request #782 from hicccc77/dev
Dev
2026-04-17 22:08:26 +08:00
cc
6fdeaacb5c Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-17 22:07:47 +08:00
cc
a1ab0834b7 更新 2026-04-17 22:07:40 +08:00
Jason
ade07b8578 Merge pull request #29 from Jasonzhu1207/feature/ai-max-tokens-prompt-time-v1
fix(ai): append current system time to prompt tail
2026-04-17 22:05:12 +08:00
Jason
00bd632ad9 fix(ai): append current time to AI prompts 2026-04-17 21:54:26 +08:00
cc
e83fcfdc4c Merge pull request #776 from Jasonzhu1207/main
feat(ai): add configurable max_tokens in shared AI settings
2026-04-17 20:23:19 +08:00
Jason
95dd2ea551 Merge pull request #28 from Jasonzhu1207/fix/ai-settings-menu-order-v2
feat(ai): add configurable max_tokens in shared AI settings
2026-04-16 23:14:10 +08:00
cc
a36da9d565 修复 #775 2026-04-16 23:09:18 +08:00
Jason
111a1961bf feat(ai): add configurable max_tokens in shared model settings 2026-04-16 23:04:09 +08:00
cc
fd4a214f9f 修复 #773 #714 2026-04-16 22:03:39 +08:00
cc
f9122492db Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-15 23:57:40 +08:00
cc
ab1d64e0c9 图片解密再次优化 2026-04-15 23:57:33 +08:00
cc
93bafbd9f7 Merge pull request #760 from Jasonzhu1207/main
feat:AI见解支持结合社交媒体平台消息以进行综合分析
2026-04-14 23:03:49 +08:00
cc
419a53d6ec 图片解密重构 #527 #522 #696;修复 #752 2026-04-14 23:02:06 +08:00
Jason
2b22975933 Merge pull request #27 from Jasonzhu1207/fix/ai-settings-menu-order-v2
chore: move AI settings group and rename labels
2026-04-14 22:47:53 +08:00
Jason
e049bfd606 chore: move AI settings group and rename labels 2026-04-14 22:41:17 +08:00
Jason
a377669b73 Merge pull request #26 from Jasonzhu1207/fix/insight-social-context-runtime-and-cookie-optional
fix: no-cookie Weibo context + social prompt copy refinements
2026-04-14 00:12:19 +08:00
Jason
5da4454af9 fix: allow no-cookie weibo context and adjust insight copy 2026-04-13 23:58:02 +08:00
Jason
5588721566 Merge pull request #25 from Jasonzhu1207/fix/insight-social-context-runtime-and-cookie-optional
fix: restore social context path and make Weibo cookie optional for binding
2026-04-13 23:34:12 +08:00
Jason
2e77d9468a fix: restore social context path and relax weibo cookie requirement 2026-04-13 23:30:57 +08:00
Jason
9f45c3f5eb Merge branch 'hicccc77:main' into main 2026-04-13 23:02:10 +08:00
Jason
1921d36e17 Merge pull request #24 from Jasonzhu1207/fix/ai-insight-weibo-ui-cookie-modal-timeout
fix: stabilize AI insight Weibo UI and cookie flow
2026-04-13 22:31:03 +08:00
Jason
72569a520e Merge branch 'main' into fix/ai-insight-weibo-ui-cookie-modal-timeout 2026-04-13 22:19:29 +08:00
cc
00b63eed54 Merge pull request #750 from hicccc77/dev
Dev
2026-04-13 20:00:03 +08:00
cc
9af1a0ad56 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-13 19:33:00 +08:00
cc
7aeff80bf9 修复了一处赋值错误 2026-04-13 19:32:54 +08:00
H3CoF6
0d387f05de Merge pull request #744 from H3CoF6/dev
修复Aur发布的action
2026-04-13 05:02:43 +08:00
H3CoF6
f40b039426 style: revert lock file changes 2026-04-13 04:58:58 +08:00
H3CoF6
0cbba05263 fix: 修改AUR发布的action 2026-04-13 04:52:07 +08:00
H3CoF6
1904aa918e Merge remote-tracking branch 'upstream/dev' into dev 2026-04-13 04:37:47 +08:00
H3CoF6
a05cde93bd chore: add aur pkg build file 2026-04-13 04:37:23 +08:00
cc
8f7ece7691 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-12 23:37:29 +08:00
cc
86daa8ef06 支持自动化条件导出;优化引导页面提示;支持快速添加账号 2026-04-12 23:37:26 +08:00
Jason
24eceef6cb fix: stabilize weibo insight ui and cookie flow 2026-04-12 23:36:20 +08:00
Jason
4ba567ca09 Update weiboService.ts 2026-04-12 22:55:59 +08:00
Jason
4446d9439d Merge pull request #23 from Jasonzhu1207/feature/ai-insight-weibo-inline-v2
feat: add experimental Weibo context to AI insights
2026-04-12 21:47:50 +08:00
Jason
6225df296c fix: restore valid settings modal JSX structure 2026-04-12 21:43:49 +08:00
Jason
9f3736ef40 Merge pull request #22 from Jasonzhu1207/feature/ai-insight-weibo-inline
feat: add experimental Weibo context to AI insights
2026-04-12 20:55:33 +08:00
Jason
1be03734a4 feat: add experimental Weibo context to AI insights 2026-04-12 20:53:10 +08:00
cc
7435ab49ab Merge pull request #736 from Jasonzhu1207/main
fix:修复ai见解误发送xml原文给ai的问题,并增加debug日志
2026-04-12 16:53:30 +08:00
Jason
9c5426159d Merge pull request #21 from Jasonzhu1207/feature/ai-insight-debug-log-cleanup
fix: clean AI insight prompt and debug log formatting
2026-04-12 16:26:38 +08:00
Jason
f3bb548626 fix: clean AI insight prompt and debug log formatting 2026-04-12 16:24:29 +08:00
Jason
34cdaa508c Merge pull request #20 from Jasonzhu1207/feature/ai-insight-debug-log-toggle
feat: add AI insight debug log export toggle
2026-04-12 15:49:36 +08:00
Jason
a734cedac1 feat: add AI insight debug log export toggle 2026-04-12 15:45:43 +08:00
Jason
5da98ddc8a Merge branch 'hicccc77:main' into main 2026-04-12 15:29:51 +08:00
cc
e79d18da03 Merge pull request #733 from hicccc77/dev
Update release.yml
2026-04-12 14:46:41 +08:00
cc
69a598f196 Update release.yml 2026-04-12 14:46:17 +08:00
cc
ac84606f20 Merge pull request #732 from hicccc77/dev
Dev
2026-04-12 14:22:42 +08:00
cc
b086507569 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-12 14:19:51 +08:00
cc
360f4917b1 更新提示文案 2026-04-12 14:19:46 +08:00
H3CoF6
89d0f22dac Merge pull request #731 from H3CoF6/fix/linux-key
适配更多wechat路径,优化拉起失败提示
2026-04-12 13:37:01 +08:00
H3CoF6
f4d63d01bd 适配更多wechat路径,优化拉起失败提示 2026-04-12 13:27:18 +08:00
Jason
59a0b1bf16 Merge branch 'hicccc77:main' into main 2026-04-12 12:54:06 +08:00
xuncha
48ca54a856 Merge pull request #721 from xunchahaha/dev
Dev
2026-04-12 08:39:41 +08:00
xuncha
bf3dfbba0f Merge branch 'dev' into dev 2026-04-12 08:37:03 +08:00
xuncha
bd1bd8a8aa Merge pull request #716 from zgshe/feature/export-date-range-time-picker-v2
feat(export): 导出日期范围添加时间选择功能
2026-04-12 08:36:32 +08:00
xuncha
7e1ca95bef 修复导出页头像丢失 2026-04-12 08:36:13 +08:00
xuncha
b7cb2cd42d 优化了选择会话逻辑 2026-04-12 08:20:54 +08:00
xuncha
6359123323 优化了接龙的消息样式 2026-04-12 08:11:20 +08:00
xuncha
f2f78bb4e2 实现了服务号的推送以及未读 2026-04-12 08:03:12 +08:00
xuncha
716b21b0dd Merge branch 'dev' into feature/export-date-range-time-picker-v2 2026-04-12 07:26:00 +08:00
xuncha
cde3590986 优化一下ui 2026-04-12 07:10:59 +08:00
H3CoF6
f89ad6ec15 修release action
fix: 修复yml空格错误
2026-04-12 00:55:57 +08:00
H3CoF6
4efa169313 Merge remote-tracking branch 'upstream/dev' 2026-04-12 00:52:29 +08:00
H3CoF6
933912f15d fix: 修复yml空格的bug 2026-04-12 00:50:52 +08:00
H3CoF6
4e216ce036 Merge pull request #718 from H3CoF6/main
修复linux资源/打包和aur更新
2026-04-11 23:55:48 +08:00
H3CoF6
567fcd3683 Auto update aur release 2026-04-11 23:27:33 +08:00
H3CoF6
49ab0de7b3 release action 为linux文件添加可执行权 2026-04-11 22:59:20 +08:00
H3CoF6
0f34222954 chore: update xkey for linux 2026-04-11 22:53:38 +08:00
佘志高
caf5b0c9db fix(export): 统一时间输入框字体与日期输入框一致 2026-04-11 22:18:03 +08:00
佘志高
f2d6188c53 feat(export): 导出日期范围添加时间选择功能
为导出窗口的日期范围选择器添加了时间(HH:mm)选择功能:

- 在日期输入框下方添加了时间选择控件(type="time")
- 默认时间范围:开始 00:00,结束 23:59
- 支持精确到分钟的时间范围设置
- 预设类型(今天、昨天、最近7天等)默认使用 00:00-23:59
- 自定义时间范围保留用户设置的具体时间
- 添加了结束时间不能早于开始时间的验证

修改文件:
- src/utils/exportDateRange.ts - 支持 YYYY-MM-DD HH:mm 格式的解析和格式化
- src/components/Export/ExportDateRangeDialog.tsx - 添加时间选择 UI 和逻辑
- src/components/Export/ExportDateRangeDialog.scss - 时间输入框样式
- src/pages/ExportPage.tsx - 修复 preset 类型的默认时间不正确的 bug
2026-04-11 22:00:32 +08:00
cc
b9af7ffc8c 一些更新 2026-04-11 19:52:40 +08:00
cc
5bec4f3cd6 Merge pull request #713 from hicccc77/dev
Dev
2026-04-11 17:15:22 +08:00
cc
726edfa850 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-11 17:14:41 +08:00
cc
ff33242887 实现 #706 2026-04-11 17:14:34 +08:00
Jason
a26d5620ca Add files via upload 2026-04-10 22:57:15 +08:00
Jason
8a3f1078f6 Add files via upload 2026-04-10 22:56:41 +08:00
cc
56b767ff46 Merge pull request #705 from hicccc77/dev
Dev
2026-04-10 21:08:43 +08:00
cc
102eb14b0b Merge pull request #704 from Tosd0/main
fix: 非预期白名单通知行为
2026-04-10 21:08:13 +08:00
cc
e57b9d07f1 Merge pull request #703 from Jasonzhu1207/main
fix:修改了几处中文乱码
2026-04-10 21:07:41 +08:00
Tosd0
3be90d00e5 fix(notification): 系统通知豁免会话白/黑名单过滤 2026-04-10 21:00:28 +08:00
Tosd0
efb5cd3586 fix(notification): 修复白名单为空时过滤器完全失效的问题 2026-04-10 21:00:22 +08:00
Jason
86b1043134 Merge pull request #15 from Jasonzhu1207/chore/sync-upstream-main-20260410
chore: sync upstream main into fork main
2026-04-10 20:51:48 +08:00
Jason
36bed846b2 chore: merge upstream main into fork main 2026-04-10 20:45:04 +08:00
cc
9d3d38fa7e Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-10 20:34:19 +08:00
cc
ddf6b63aec 更新描述文案 2026-04-10 20:34:16 +08:00
Jason
079779c2c6 Merge pull request #14 from Jasonzhu1207/fix/chinese-garbled-text
fix: clean up garbled Chinese text
2026-04-10 20:23:30 +08:00
Jason
afa8bb5fe0 fix: clean up garbled Chinese text 2026-04-10 20:13:46 +08:00
cc
127668ae22 Merge pull request #702 from hicccc77/main
合并
2026-04-10 20:13:18 +08:00
cc
b00264d060 Merge pull request #701 from hicccc77/hicccc77-patch-1
Update README.md
2026-04-10 20:12:53 +08:00
cc
2e135587d4 Update README.md 2026-04-10 20:12:42 +08:00
cc
571bffa923 Merge pull request #700 from hicccc77/dev
更新资源
2026-04-10 20:01:59 +08:00
cc
bc355d43a0 更新资源 2026-04-10 20:01:36 +08:00
cc
e2a207be92 Merge pull request #699 from hicccc77/dev
Dev
2026-04-10 19:46:33 +08:00
cc
397cc888db 尝试修复工作流;修复mac上权限异常的问题 2026-04-10 19:46:11 +08:00
cc
22a2616534 修复密钥问题 2026-04-10 19:23:32 +08:00
cc
d6c9a10766 优化表述与提示;导出文件命名格式优化;启用进程优化 2026-04-09 21:13:13 +08:00
cc
657e8015b2 Merge pull request #680 from hicccc77/dev
Dev
2026-04-09 18:19:07 +08:00
cc
fc3612abb2 Merge branch 'main' into dev 2026-04-09 18:18:58 +08:00
cc
8d79a82ac2 Merge pull request #681 from xunchahaha/main
修复工作流
2026-04-09 18:18:44 +08:00
cc
234cf690f0 Merge pull request #683 from H3CoF6/dev
删除wayland的检测和警告相关代码
2026-04-09 18:18:33 +08:00
H3CoF6
d768c8d08c fix: 删除wayland的检测和警告相关代码
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 15:22:13 +08:00
xuncha
e98e9cb7d9 修复工作流 2026-04-09 11:47:02 +08:00
cc
8e8f1b3d22 删除见解多余日志 2026-04-08 19:50:25 +08:00
cc
5b6117ec28 修复见解意外启动的问题 2026-04-08 19:32:44 +08:00
cc
33188485b7 修复一些乱码问题 2026-04-08 19:26:48 +08:00
cc
08bd5e5435 Merge branch 'dev' into dev 2026-04-08 19:21:42 +08:00
cc
714827a36d 修复了一些问题 2026-04-08 19:20:30 +08:00
fatfathao
7a51d8cf64 fix: 将通知头像存储在缓存文件中,通过LRU缓存维护头像缓存数量,点击通知后可以跳转到对应的会话窗口(fixes #654) 2026-04-08 13:11:27 +08:00
cc
902d2c9c74 Merge pull request #666 from xunchahaha/dev
Dev
2026-04-07 23:34:44 +08:00
Jason
d96000f0d9 Merge branch 'hicccc77:main' into main 2026-04-07 23:05:17 +08:00
xuncha
dcad30bc39 x修复工作流 2026-04-07 22:58:41 +08:00
xuncha
73ee524d1f Merge branch 'dev' into dev 2026-04-07 22:49:10 +08:00
xuncha
4af8334f50 修复图片解密 2026-04-07 22:45:15 +08:00
cc
43fed79204 Merge pull request #653 from Jasonzhu1207/feature/ai-insight
Feature:增加AI见解功能
2026-04-07 22:21:42 +08:00
cc
b356814ebb 规范化资源文件;修复消息气泡宽度异常的问题;优化资源管理页面性能 2026-04-07 20:53:45 +08:00
cc
0acad9927a 重新修复 #654 所提到的问题 2026-04-07 20:14:23 +08:00
cc
5bc46fadfc Merge pull request #665 from hicccc77/main
Dev
2026-04-07 19:49:39 +08:00
cc
3090306394 Merge branch 'dev' into main 2026-04-07 19:49:31 +08:00
cc
ec95a16c7a Merge pull request #626 from hicccc77/dependabot/npm_and_yarn/dev/vite-plugin-electron-0.29.1
chore(deps-dev): bump vite-plugin-electron from 0.28.8 to 0.29.1
2026-04-07 19:46:44 +08:00
cc
45d3f735a9 Merge pull request #627 from hicccc77/dependabot/npm_and_yarn/dev/sherpa-onnx-node-1.12.35
chore(deps): bump sherpa-onnx-node from 1.12.34 to 1.12.35
2026-04-07 19:46:35 +08:00
cc
0734b64cc8 Merge pull request #628 from hicccc77/dependabot/npm_and_yarn/dev/sass-1.99.0
chore(deps-dev): bump sass from 1.98.0 to 1.99.0
2026-04-07 19:46:22 +08:00
cc
70ad21cb46 Merge pull request #657 from hicccc77/dependabot/npm_and_yarn/npm_and_yarn-c4bc6a0a9e
chore(deps-dev): bump vite from 7.3.1 to 7.3.2 in the npm_and_yarn group across 1 directory
2026-04-07 19:45:51 +08:00
xuncha
9181490d0f Merge pull request #662 from chrocy/fix-export-excel-columns
feat: 新增 Excel 导出完整列开关
2026-04-07 19:33:14 +08:00
xuncha
01fc5cd1a0 导出在选择完整列的时候私聊不会有群昵称 2026-04-07 19:29:35 +08:00
xuncha
b12ffff310 Merge branch 'dev' into fix-export-excel-columns 2026-04-07 19:16:41 +08:00
xuncha
835359edf8 Merge branch 'main' into fix-export-excel-columns 2026-04-07 19:16:14 +08:00
xuncha
88817cf95e Merge pull request #664 from xunchahaha/dev
修复导出页意外的横向滑动条 朋友圈导出新增多选
2026-04-07 19:15:04 +08:00
xuncha
88d41f6857 修复导出页意外的横向滑动条 2026-04-07 19:09:16 +08:00
xuncha
ec9c1bbbba 朋友圈导出页新增多选功能 2026-04-07 19:09:01 +08:00
chrocy
f9313392f1 feat: 优化 Excel 导出设置,解决 #529,将「导出完整列」选项合并到「发送者名称显示」中 2026-04-07 16:44:59 +08:00
xuncha
2db8af3668 Merge pull request #650 from huanghe/fix/http-api-security
fix(security): harden HTTP API service against multiple vulnerabilities
2026-04-07 15:39:01 +08:00
xuncha
c56ba6e0a1 Merge branch 'dev' into fix/http-api-security 2026-04-07 15:35:46 +08:00
dependabot[bot]
f1dcc84991 chore(deps-dev): bump vite in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 7.3.1 to 7.3.2
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v7.3.2/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.3.2/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.3.2
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-06 22:30:51 +00:00
H3CoF6
e8aaae5616 Merge pull request #656 from H3CoF6/main
delete wayland notice
2026-04-07 03:44:19 +08:00
H3CoF6
45deb99e3d delete wayland notice 2026-04-07 03:37:11 +08:00
dependabot[bot]
28f6f966b9 chore(deps): bump sherpa-onnx-node from 1.12.34 to 1.12.35
Bumps [sherpa-onnx-node](https://github.com/csukuangfj/sherpa-onnx) from 1.12.34 to 1.12.35.
- [Release notes](https://github.com/csukuangfj/sherpa-onnx/releases)
- [Changelog](https://github.com/csukuangfj/sherpa-onnx/blob/dart-v1.12.35/CHANGELOG.md)
- [Commits](https://github.com/csukuangfj/sherpa-onnx/compare/dart-v1.12.34...dart-v1.12.35)

---
updated-dependencies:
- dependency-name: sherpa-onnx-node
  dependency-version: 1.12.35
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-06 19:12:13 +00:00
dependabot[bot]
7bd569feca chore(deps-dev): bump vite-plugin-electron from 0.28.8 to 0.29.1
Bumps [vite-plugin-electron](https://github.com/electron-vite/vite-plugin-electron) from 0.28.8 to 0.29.1.
- [Release notes](https://github.com/electron-vite/vite-plugin-electron/releases)
- [Changelog](https://github.com/electron-vite/vite-plugin-electron/blob/main/CHANGELOG.md)
- [Commits](https://github.com/electron-vite/vite-plugin-electron/compare/v0.28.8...v0.29.1)

---
updated-dependencies:
- dependency-name: vite-plugin-electron
  dependency-version: 0.29.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-06 19:12:10 +00:00
dependabot[bot]
056f2c1833 chore(deps-dev): bump sass from 1.98.0 to 1.99.0
Bumps [sass](https://github.com/sass/dart-sass) from 1.98.0 to 1.99.0.
- [Release notes](https://github.com/sass/dart-sass/releases)
- [Changelog](https://github.com/sass/dart-sass/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sass/dart-sass/compare/1.98.0...1.99.0)

---
updated-dependencies:
- dependency-name: sass
  dependency-version: 1.99.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-06 19:12:10 +00:00
H3CoF6
b821d370f9 Merge pull request #655 from FATFATHAO/feat/linux-notification
[#654] fix: 更改linux中的消息通知走D-bus总线
2026-04-07 03:10:50 +08:00
fatfathao
60248b28f8 fix: 更改linux中的消息通知走D-bus总线 2026-04-07 01:30:26 +08:00
cc
d128bedffa 新增资源管理并修复了朋友圈的资源缓存路径 2026-04-06 23:32:59 +08:00
Jason
489b545965 Add files via upload 2026-04-06 21:01:24 +08:00
Jason
36533d07f8 Add files via upload 2026-04-06 21:01:00 +08:00
Jason
625e4f8e6a Merge pull request #13 from Jasonzhu1207/v0/jasonzhu081207-4751-f2dd3a17
Enable AI insights and Telegram push notifications
2026-04-06 20:39:32 +08:00
v0
c4774e1ce1 refactor: optimize insightService to skip getSessions() in whitelist mode
Eliminate unnecessary getSessions() calls and use lightweight queries for performance.

Co-authored-by: Jason <159670257+Jasonzhu1207@users.noreply.github.com>
2026-04-06 12:33:11 +00:00
Jason
e1682f99d2 Merge pull request #12 from Jasonzhu1207/v0/jasonzhu081207-4751-9343a5f0
Enable AI insights and Telegram push notifications
2026-04-06 20:12:58 +08:00
v0
a23461bfce fix: optimize insightService for performance
Address DB connection issues, cache TTL, and timer handling to improve efficiency.

Co-authored-by: Jason <159670257+Jasonzhu1207@users.noreply.github.com>
2026-04-06 12:06:02 +00:00
Jason
73fc36e63a Merge pull request #11 from Jasonzhu1207/v0/jasonzhu081207-4751-b8ccf9ee
Enable AI insights and Telegram push notifications
2026-04-06 19:31:12 +08:00
v0
4beddb7a62 fix: resolve main thread block and high CPU issues
Switch 'fs.appendFileSync' to 'fs.appendFile' and optimize 'getSessionsCached' to reduce DB access.

Co-authored-by: Jason <159670257+Jasonzhu1207@users.noreply.github.com>
2026-04-06 11:28:33 +00:00
Jason
b130165831 Merge pull request #10 from Jasonzhu1207/v0/jasonzhu081207-4751-c8eef8af
Enable AI insights and Telegram push notifications
2026-04-06 18:59:09 +08:00
v0
9adffc3cd7 fix: resolve multiple issues and performance enhancement
Fix performance issue, Telegram prefix, and two encoding bugs; update custom prompt UI.

Co-authored-by: Jason <159670257+Jasonzhu1207@users.noreply.github.com>
2026-04-06 10:55:30 +00:00
Jason
a52619c4d5 Merge pull request #9 from Jasonzhu1207/v0/jasonzhu081207-4751-0177d73e
Enable AI insights and Telegram push notifications
2026-04-06 18:13:03 +08:00
v0
cf40d3ad63 feat: optimize prompt caching and add Telegram push
Add system prompt caching, custom prompt, and Telegram push settings.

Co-authored-by: Jason <159670257+Jasonzhu1207@users.noreply.github.com>
2026-04-06 09:56:11 +00:00
Ocean
f7f6252d0b Merge branch 'main' into fix/http-api-security 2026-04-06 14:11:17 +08:00
Jason
14a2475fb1 Add files via upload 2026-04-06 14:09:55 +08:00
Jason
76a55998c2 Add files via upload 2026-04-06 14:09:22 +08:00
Jason
1ec8d54e96 Merge branch 'hicccc77:main' into main 2026-04-06 14:07:31 +08:00
huanghe
62395b275d fix(security): harden HTTP API service against multiple vulnerabilities
1. Path traversal in /api/v1/media/ — use path.resolve() and verify
   resolved path stays within media base directory
2. DoS via unlimited POST body — add 10MB size limit to parseBody()
3. Default no-auth — reject all requests when httpApiToken is not
   configured instead of silently allowing everything
4. Overly permissive CORS — restrict Access-Control-Allow-Origin from
   wildcard (*) to localhost/127.0.0.1 only
5. Timing attack on token comparison — use crypto.timingSafeEqual()
   instead of === for token verification
6. Unsafe default bind address — revert httpApiHost default from
   0.0.0.0 back to 127.0.0.1 to prevent network exposure

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 14:06:31 +08:00
cc
57fad47f27 Merge pull request #649 from hicccc77/dev
Dev
2026-04-06 13:45:04 +08:00
cc
20c5381211 更新 2026-04-06 13:23:16 +08:00
Jason
b8cd9a8c38 Merge branch 'hicccc77:main' into main 2026-04-06 13:13:15 +08:00
cc
4335abe31b 更新 2026-04-06 13:08:32 +08:00
cc
e5f7b54a7b Merge pull request #648 from hicccc77/main
Merge pull request #647 from hicccc77/dev
2026-04-06 13:06:33 +08:00
cc
ea1ef03b98 Merge pull request #647 from hicccc77/dev
Dev
2026-04-06 13:06:10 +08:00
cc
8d374d4f49 Merge branch 'main' into dev 2026-04-06 13:06:02 +08:00
cc
f910e17e53 Merge pull request #644 from fortii2/fix/export-worker-config
#580 修复与部分引用功能相关联的无法读取解密配置的问题
2026-04-06 13:04:13 +08:00
cc
35a76aa04f Merge pull request #643 from fortii2/issue-580-partial-quote
#580 引用消息支持部分引用显示和导出
2026-04-06 12:58:57 +08:00
cc
5fce21d799 Merge pull request #641 from FATFATHAO/fix-package
fix: node25使用pnpm拉取文件时,ajv导致拉取失败的问题
2026-04-06 12:52:38 +08:00
cc
a32696ee13 Merge branch 'dev' into fix-package 2026-04-06 12:52:18 +08:00
cc
b573baec80 Merge pull request #646 from hicccc77/dev
Dev
2026-04-06 12:49:47 +08:00
cc
0d4feceffc Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-06 12:48:59 +08:00
cc
92abe73f0a 更新 2026-04-06 12:48:53 +08:00
Jason
7fa26b0716 Merge pull request #8 from Jasonzhu1207/v0/jasonzhu081207-4751-1e322b3f
Enable AI insights and system-native notifications
2026-04-06 12:43:38 +08:00
Jason
dc49bf3877 Update package.json 2026-04-06 12:29:51 +08:00
v0
d825dada59 fix: correct electron-builder upload for prerelease tags
Remove 'releaseType: "release"' to allow automatic handling of prerelease tags.

Co-authored-by: Jason <159670257+Jasonzhu1207@users.noreply.github.com>
2026-04-06 04:28:32 +00:00
cc
74a08732fe Merge pull request #645 from hicccc77/dev
修复了一些问题
2026-04-06 12:16:38 +08:00
cc
7033a77d71 Merge branch 'main' into dev 2026-04-06 12:16:28 +08:00
cc
3b26e0c014 修复了一些问题 2026-04-06 12:15:50 +08:00
Jason
81ec51be33 Update release.yml 2026-04-06 12:09:14 +08:00
Jason
fbecda9f1e Update release.yml 2026-04-06 11:59:57 +08:00
v0
b6950d4027 fix: correct GitHub Actions release download failure
Add '|| true' to suppress exit code from failed downloads

Co-authored-by: Jason <159670257+Jasonzhu1207@users.noreply.github.com>
2026-04-06 03:58:10 +00:00
Jason
f31327b528 Merge pull request #7 from Jasonzhu1207/v0/jasonzhu081207-4751-e705ab05
Enable AI insights and system-native notifications
2026-04-06 11:39:56 +08:00
v0
c4c7df2608 fix: resolve insight tab loading and performance issues
Fix chat session loading logic and optimize session retrieval performance.

Co-authored-by: Jason <159670257+Jasonzhu1207@users.noreply.github.com>
2026-04-06 03:35:39 +00:00
ethan
b8bf29277a 修复与部分引用功能相关联的无法读取解密配置的问题 2026-04-05 17:48:12 -04:00
ethan
867f85e8f2 实现 #580 引用消息支持部分引用显示 2026-04-05 17:39:22 -04:00
Jason
7fb98d764a Merge pull request #6 from Jasonzhu1207/v0/jasonzhu081207-4751-03d90813
Enable AI insights and system-native notifications
2026-04-06 01:49:04 +08:00
v0
792621d982 feat: use Electron's native Notification API for reliable alerts
Replace custom 'showNotification' with Electron's 'Notification' for system-level alerts.

Co-authored-by: Jason <159670257+Jasonzhu1207@users.noreply.github.com>
2026-04-05 17:47:14 +00:00
fatfathao
337fe21d18 fix: node25使用pnpm拉取文件时,ajv导致拉取失败的问题 2026-04-06 01:40:06 +08:00
Jason
c92b50b6ec Merge pull request #5 from Jasonzhu1207/v0/jasonzhu081207-4751-8b63b98d
Enable AI insights and whitelist management in settings
2026-04-06 01:35:19 +08:00
v0
f83117df20 feat: update prompt to force insights output
Modify prompt to encourage model to output insights, disallow SKIP in test mode.

Co-authored-by: Jason <159670257+Jasonzhu1207@users.noreply.github.com>
2026-04-05 17:33:09 +00:00
Jason
b7b7260838 Merge pull request #4 from Jasonzhu1207/v0/jasonzhu081207-4751-507441fc
Enable AI insights and whitelist management in settings
2026-04-06 01:22:46 +08:00
v0
dd960d30ff fix: remove leftover old catch block
Clean up mismatched catch block from previous edit.

Co-authored-by: Jason <159670257+Jasonzhu1207@users.noreply.github.com>
2026-04-05 17:21:24 +00:00
v0
89f3ec57f5 feat: add configurable AI insight settings and desktop logging
Introduce new configurable fields and log insights to desktop.

Co-authored-by: Jason <159670257+Jasonzhu1207@users.noreply.github.com>
2026-04-05 17:20:23 +00:00
v0
95f1e73a39 fix: resolve core bugs and enhance logging for AI insights
Fix aggressive activity analysis and loop bug, add detailed logs, and introduce test trigger button.

Co-authored-by: Jason <159670257+Jasonzhu1207@users.noreply.github.com>
2026-04-05 17:11:05 +00:00
Jason
aa029fe113 Merge pull request #3 from Jasonzhu1207/v0/jasonzhu081207-4751-c1e23024
Enable AI insights and whitelist management in settings
2026-04-06 00:45:11 +08:00
v0
5971757a28 feat: add aiInsightWhitelist to settings page
Implement aiInsightWhitelist feature with UI and filtering logic.

Co-authored-by: Jason <159670257+Jasonzhu1207@users.noreply.github.com>
2026-04-05 16:42:43 +00:00
Jason
1e16ea887b Merge pull request #2 from Jasonzhu1207/v0/jasonzhu081207-4751-3942175b
Add AI insights service and settings tab
2026-04-06 00:12:13 +08:00
v0
837f15c5e8 fix: update repository owner and URL in electron-builder config
Correct hardcoded owner and repository URL in package.json for proper release publishing.

Co-authored-by: Jason <159670257+Jasonzhu1207@users.noreply.github.com>
2026-04-05 16:10:37 +00:00
Jason
f71ff7392c Update package.json 2026-04-05 23:59:09 +08:00
Jason
97ba95e2be Update repository URL in package.json 2026-04-05 23:58:17 +08:00
v0
6aae23180f fix: resolve TypeScript errors in GitHub Actions build
Fix type issues and update import syntax for better compatibility.

Co-authored-by: Jason <159670257+Jasonzhu1207@users.noreply.github.com>
2026-04-05 15:51:40 +00:00
v0
49e82e43e4 fix: resolve TypeScript type issues in CI builds
Fix multiple type errors and improve type checks in build scripts.

Co-authored-by: Jason <159670257+Jasonzhu1207@users.noreply.github.com>
2026-04-05 15:50:00 +00:00
Jason
301c490893 Merge pull request #1 from Jasonzhu1207/ai
Add AI insights service and settings tab
2026-04-05 23:33:04 +08:00
v0
93a9df48f4 feat: implement AI insights service and settings tab
Add core insight service and IPC handlers; update config and settings page.

Co-authored-by: Jason <159670257+Jasonzhu1207@users.noreply.github.com>
2026-04-05 15:32:22 +00:00
cc
209b91bfef Merge pull request #638 from hicccc77/dev
Dev
2026-04-05 19:21:28 +08:00
cc
1049f55118 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-05 14:53:14 +08:00
cc
ba7785a359 修复发布日期问题 2026-04-05 14:53:11 +08:00
cc
e6c821d3ee Merge pull request #637 from hicccc77/dev
交互细节修复与代码修复
2026-04-05 11:24:35 +08:00
cc
17a7741697 Merge branch 'main' into dev 2026-04-05 11:24:26 +08:00
cc
f00525d21a 交互细节修复与代码修复 2026-04-05 10:57:49 +08:00
cc
f5c79c1fab Merge pull request #636 from hicccc77/dev
Dev
2026-04-04 23:27:27 +08:00
cc
4fc0a92651 更新资源文件 2026-04-04 23:25:21 +08:00
cc
585ec39f8e Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-04 23:14:57 +08:00
cc
a0189fdd0a 修复 #597;实现 #556;修复 #623与 #543;修复卡片图片问题 2026-04-04 23:14:54 +08:00
cc
ede31732b3 Merge pull request #634 from BeiChen-CN/main
feat:支持导出聊天记录中的文件
2026-04-04 20:16:05 +08:00
姜北尘
a60381522d fix 2026-04-04 20:04:01 +08:00
姜北尘
64010ad86b feat:添加导出文件 2026-04-04 19:45:05 +08:00
cc
e628154b78 Merge pull request #632 from hicccc77/dev
Dev
2026-04-04 14:04:47 +08:00
cc
e5baf5e994 Merge branch 'main' into dev 2026-04-04 14:04:35 +08:00
cc
05fdbab496 更新信息 2026-04-04 13:26:06 +08:00
cc
512b1f6455 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-04 10:57:46 +08:00
cc
5615d83f04 修复更新渠道问题 2026-04-04 10:57:43 +08:00
cc
ee38918516 Merge pull request #630 from hicccc77/dev
Dev
2026-04-04 09:54:46 +08:00
H3CoF6
d1b8d86a20 Merge pull request #625 from H3CoF6/dev
修复biz的一些问题
2026-04-04 02:58:54 +08:00
H3CoF6
25ef7c5d8a 更快的排序 2026-04-04 02:52:12 +08:00
H3CoF6
db429abf5b 时间排序 2026-04-04 02:34:57 +08:00
H3CoF6
19d5ae7e15 fix: 修复账号类型,删除广告账号 2026-04-04 01:53:03 +08:00
cc
fcbd613f4a Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-03 23:23:47 +08:00
cc
5fae370c55 更新打包 2026-04-03 23:23:42 +08:00
xuncha
f2dbe6ee8f Merge pull request #622 from xunchahaha/dev
Dev
2026-04-03 21:11:08 +08:00
xuncha
0175a6998b Merge branch 'dev' into dev 2026-04-03 21:08:36 +08:00
xuncha
758de9949b 新增开机自启动 [Enhancement]: 希望能够支持静默启动和开机自启动
Fixes #516
2026-04-03 21:08:05 +08:00
xuncha
81b8960d41 双人年度报告支持导出 [Enhancement]: 双人年度报告不支持导出 但总年度报告支持
Fixes #531
2026-04-03 21:07:44 +08:00
xuncha
5b25619b24 Merge pull request #620 from xunchahaha/dev
卡片链接新增解析
2026-04-03 20:50:44 +08:00
xuncha
62e23aaf23 卡片链接新增解析 2026-04-03 20:47:15 +08:00
cc
aac8eed898 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-03 20:35:10 +08:00
cc
108980befb 修复了一些问题 2026-04-03 20:34:57 +08:00
chrocy
6a4cd00d51 fix(export): 补全导出弹窗中缺失的 Excel 完整列切换开关 (fix #529) 2026-04-03 20:24:59 +08:00
xuncha
a6c899c098 Merge pull request #557 from jinkangHe/dev
feat(sns):增加朋友圈相关api
2026-04-03 20:14:57 +08:00
xuncha
28170d31df Merge branch 'dev' into dev 2026-04-03 20:11:25 +08:00
cc
ce8d272d6e Merge pull request #619 from hicccc77/dev
Dev
2026-04-03 20:10:37 +08:00
cc
0047685f54 修复了一些问题 2026-04-03 20:09:37 +08:00
xuncha
2cc0fc64a4 Merge branch 'dev' into dev 2026-04-03 20:08:03 +08:00
xuncha
67642cebfd fix(http): stream live sns media and clarify docs 2026-04-03 20:07:11 +08:00
cc
327dc85d14 优化通道结构 2026-04-03 20:05:23 +08:00
cc
8c4f42bab1 Merge branch 'dev' into dev 2026-04-03 19:52:35 +08:00
cc
40c29e494c 更新配置文件 2026-04-03 19:49:43 +08:00
xuncha
0235ec7edc Merge branch 'dev' into dev 2026-04-03 19:49:29 +08:00
cc
fa2a000624 Merge pull request #617 from hicccc77/dependabot/npm_and_yarn/dev/electron-store-11.0.2
chore(deps): bump electron-store from 10.1.0 to 11.0.2
2026-04-03 19:43:41 +08:00
dependabot[bot]
861b24cef1 chore(deps): bump electron-store from 10.1.0 to 11.0.2
Bumps [electron-store](https://github.com/sindresorhus/electron-store) from 10.1.0 to 11.0.2.
- [Release notes](https://github.com/sindresorhus/electron-store/releases)
- [Commits](https://github.com/sindresorhus/electron-store/compare/v10.1.0...v11.0.2)

---
updated-dependencies:
- dependency-name: electron-store
  dependency-version: 11.0.2
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-03 11:42:44 +00:00
cc
ee1977384e Merge pull request #616 from hicccc77/dependabot/npm_and_yarn/dev/react-router-dom-7.14.0
chore(deps): bump react-router-dom from 7.13.2 to 7.14.0
2026-04-03 19:41:56 +08:00
cc
5d08505f62 Merge pull request #614 from hicccc77/dependabot/npm_and_yarn/dev/electron-41.1.1
chore(deps-dev): bump electron from 39.8.6 to 41.1.1
2026-04-03 19:41:32 +08:00
cc
ab21124327 Merge branch 'dev' into dependabot/npm_and_yarn/dev/electron-41.1.1 2026-04-03 19:41:16 +08:00
cc
1df792ec9c Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-03 19:35:11 +08:00
cc
a8fa6e5987 修复了一些打包问题 2026-04-03 19:34:32 +08:00
dependabot[bot]
1d69c5a78d chore(deps): bump react-router-dom from 7.13.2 to 7.14.0
Bumps [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom) from 7.13.2 to 7.14.0.
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@7.14.0/packages/react-router-dom)

---
updated-dependencies:
- dependency-name: react-router-dom
  dependency-version: 7.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-02 23:16:50 +00:00
dependabot[bot]
0ae7ba3e11 chore(deps-dev): bump electron from 39.8.6 to 41.1.1
Bumps [electron](https://github.com/electron/electron) from 39.8.6 to 41.1.1.
- [Release notes](https://github.com/electron/electron/releases)
- [Commits](https://github.com/electron/electron/compare/v39.8.6...v41.1.1)

---
updated-dependencies:
- dependency-name: electron
  dependency-version: 41.1.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-02 23:16:24 +00:00
H3CoF6
c421ca7f2f Merge pull request #613 from H3CoF6/dev
feat: 公众号服务号内容单独解析
2026-04-03 06:00:43 +08:00
H3CoF6
ea4fff5b10 Merge remote-tracking branch 'upstream/main' into dev 2026-04-03 05:45:10 +08:00
H3CoF6
e0b0e38271 fix: 服务号类型说明 2026-04-03 05:44:19 +08:00
H3CoF6
510b956649 refactor: 样式对齐 2026-04-03 05:20:58 +08:00
H3CoF6
17b8af4bc4 fix: 删除广告,增添无记录显示 2026-04-03 04:48:39 +08:00
H3CoF6
617b400884 feat: 以chat的方式实现biz的解析 2026-04-03 04:40:34 +08:00
cc
a58518ccb5 Merge pull request #611 from hicccc77/dev
Dev
2026-04-03 00:01:58 +08:00
cc
cdd17d919e Merge branch 'main' into dev 2026-04-03 00:01:49 +08:00
cc
4580cef7f2 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-03 00:01:18 +08:00
cc
6f9c765bab 更新依赖 2026-04-03 00:01:05 +08:00
H3CoF6
5b56b2e0be Merge remote-tracking branch 'upstream/dev' into dev 2026-04-02 23:50:35 +08:00
cc
b0cc811807 Merge pull request #610 from hicccc77/dev
Dev
2026-04-02 23:30:36 +08:00
cc
eb540d5c13 Merge branch 'main' into dev 2026-04-02 23:30:29 +08:00
cc
e308293cf6 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-02 23:29:50 +08:00
cc
9ed4659c5c 修复依赖问题 2026-04-02 23:28:55 +08:00
cc
f5f2b76914 Merge pull request #609 from hicccc77/dev
Dev
2026-04-02 23:23:07 +08:00
cc
551a065497 Merge branch 'main' into dev 2026-04-02 23:22:59 +08:00
cc
88d7e38d82 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-02 23:22:16 +08:00
cc
65e6cb22dd 修复下载源问题 2026-04-02 23:22:12 +08:00
cc
689a396f6e Merge pull request #608 from hicccc77/dev
Dev
2026-04-02 23:16:30 +08:00
cc
512ea84850 Merge branch 'main' into dev 2026-04-02 23:16:19 +08:00
cc
1542e583f7 修复了一些问题 2026-04-02 23:15:39 +08:00
cc
c488dcc3c6 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-02 23:15:20 +08:00
cc
1594e0e24b 修复安装问题 2026-04-02 23:15:17 +08:00
cc
1e1fa77621 Merge pull request #607 from hicccc77/dev
Dev
2026-04-02 23:04:50 +08:00
cc
3270c17514 Merge branch 'main' into dev 2026-04-02 23:04:28 +08:00
cc
e635942e3d 补充更新 2026-04-02 23:03:50 +08:00
cc
64dc2858a7 补充更新 2026-04-02 23:02:36 +08:00
cc
d05496bb3d 补充更新 2026-04-02 23:02:24 +08:00
cc
bb94553fff 补充更新 2026-04-02 23:02:15 +08:00
cc
113216b7ba 修复打包问题 2026-04-02 23:01:54 +08:00
cc
55181edaa8 Merge pull request #599 from hicccc77/dependabot/npm_and_yarn/dev/lucide-react-1.7.0
chore(deps): bump lucide-react from 0.562.0 to 1.7.0
2026-04-02 23:01:20 +08:00
cc
4f01a7b577 Merge branch 'dev' into dependabot/npm_and_yarn/dev/lucide-react-1.7.0 2026-04-02 23:01:08 +08:00
cc
ea21111037 Merge pull request #600 from hicccc77/dependabot/npm_and_yarn/dev/typescript-6.0.2
chore(deps-dev): bump typescript from 5.9.3 to 6.0.2
2026-04-02 23:00:57 +08:00
dependabot[bot]
fba9f1de42 chore(deps): bump lucide-react from 0.562.0 to 1.7.0
Bumps [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) from 0.562.0 to 1.7.0.
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.7.0/packages/lucide-react)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 1.7.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-02 14:53:21 +00:00
dependabot[bot]
7d0b8db7a6 chore(deps-dev): bump typescript from 5.9.3 to 6.0.2
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.9.3 to 6.0.2.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v6.0.2)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 6.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-02 14:53:14 +00:00
cc
12faa31e34 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-02 22:51:48 +08:00
cc
74945b1752 修复了一些打包问题 2026-04-02 22:51:44 +08:00
cc
a7c66517d2 Merge pull request #606 from hicccc77/dev
Dev
2026-04-02 22:42:32 +08:00
cc
adf187ddf5 Merge branch 'main' into dev 2026-04-02 22:42:13 +08:00
cc
614d897dd2 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-02 22:41:42 +08:00
cc
ec9a7d68e6 修复了一些问题 2026-04-02 22:41:36 +08:00
cc
79dd91b270 Merge pull request #605 from hicccc77/dev
Dev
2026-04-02 22:31:26 +08:00
cc
b26bcd7603 Merge branch 'main' into dev 2026-04-02 22:31:25 +08:00
cc
a65468191b Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-02 22:30:53 +08:00
cc
4ed5271703 修复管理器问题 2026-04-02 22:30:49 +08:00
cc
f2afe2a977 Merge pull request #604 from hicccc77/dev
Dev
2026-04-02 22:28:35 +08:00
cc
430b0f30c9 Merge branch 'main' into dev 2026-04-02 22:28:24 +08:00
cc
8aac9a795e 修复检测问题与依赖问题 2026-04-02 22:27:45 +08:00
cc
b9405765f9 Merge pull request #601 from hicccc77/dependabot/npm_and_yarn/dev/echarts-6.0.0
chore(deps): bump echarts from 5.6.0 to 6.0.0
2026-04-02 22:25:00 +08:00
cc
90856b3812 Merge pull request #602 from hicccc77/dependabot/npm_and_yarn/dev/sass-1.98.0
chore(deps-dev): bump sass from 1.97.2 to 1.98.0
2026-04-02 22:24:49 +08:00
cc
1e78af3c25 Merge pull request #603 from hicccc77/dependabot/npm_and_yarn/dev/vite-8.0.3
chore(deps-dev): bump vite from 6.4.1 to 8.0.3
2026-04-02 22:24:38 +08:00
dependabot[bot]
4be232b951 chore(deps-dev): bump vite from 6.4.1 to 8.0.3
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 6.4.1 to 8.0.3.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/create-vite@8.0.3/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.3
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-02 14:22:00 +00:00
dependabot[bot]
59d5c2762d chore(deps-dev): bump sass from 1.97.2 to 1.98.0
Bumps [sass](https://github.com/sass/dart-sass) from 1.97.2 to 1.98.0.
- [Release notes](https://github.com/sass/dart-sass/releases)
- [Changelog](https://github.com/sass/dart-sass/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sass/dart-sass/compare/1.97.2...1.98.0)

---
updated-dependencies:
- dependency-name: sass
  dependency-version: 1.98.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-02 14:21:28 +00:00
dependabot[bot]
21a55439ec chore(deps): bump echarts from 5.6.0 to 6.0.0
Bumps [echarts](https://github.com/apache/echarts) from 5.6.0 to 6.0.0.
- [Release notes](https://github.com/apache/echarts/releases)
- [Commits](https://github.com/apache/echarts/compare/5.6.0...6.0.0)

---
updated-dependencies:
- dependency-name: echarts
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-02 14:21:19 +00:00
cc
c4a35f5c15 Merge pull request #598 from hicccc77/dev
Dev
2026-04-02 22:20:04 +08:00
cc
b42f761011 配置更新 2026-04-02 22:18:50 +08:00
cc
46e2e64e65 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-02 22:14:52 +08:00
cc
54ed35ffb3 新增渠道更新 2026-04-02 22:14:46 +08:00
cc
7b8bd747ad Merge branch 'dev' into dev 2026-04-02 21:39:55 +08:00
cc
3e379957e1 Merge pull request #596 from hicccc77/dependabot/npm_and_yarn/npm_and_yarn-282a1442c2
chore(deps-dev): bump @xmldom/xmldom from 0.8.11 to 0.8.12 in the npm_and_yarn group across 1 directory
2026-04-02 20:53:57 +08:00
dependabot[bot]
b64525487e chore(deps-dev): bump @xmldom/xmldom
Bumps the npm_and_yarn group with 1 update in the / directory: [@xmldom/xmldom](https://github.com/xmldom/xmldom).


Updates `@xmldom/xmldom` from 0.8.11 to 0.8.12
- [Release notes](https://github.com/xmldom/xmldom/releases)
- [Changelog](https://github.com/xmldom/xmldom/blob/master/CHANGELOG.md)
- [Commits](https://github.com/xmldom/xmldom/compare/0.8.11...0.8.12)

---
updated-dependencies:
- dependency-name: "@xmldom/xmldom"
  dependency-version: 0.8.12
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-01 05:11:30 +00:00
hicccc77
e544f5c862 fix: add forceReopen retry logic for openMessageCursorLite 2026-04-01 04:04:53 +08:00
hicccc77
669759c52e chore: 更新资源文件 2026-03-31 21:36:53 +08:00
cc
4a13d3209b 更新 2026-03-31 21:24:45 +08:00
cc
be069e9aed 实现 #584 2026-03-31 21:24:31 +08:00
hicccc77
0b20ee1aa2 chore: 更新资源文件 2026-03-31 20:02:07 +08:00
hejk
e4872a78f5 feat(http): change default API host to 0.0.0.0 for external access
- 修改 httpApiHost 默认值从 127.0.0.1 到 0.0.0.0
- 允许 HTTP API 服务接受外网访问
- 用户需要配置 httpApiToken 以确保安全

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 11:32:46 +08:00
H3CoF6
4692216325 Merge remote-tracking branch 'upstream/dev' into dev 2026-03-31 00:18:34 +08:00
hicccc77
69128062fe chore: 更新资源文件 2026-03-30 23:56:34 +08:00
H3CoF6
f81ba3028d Merge remote-tracking branch 'upstream/dev' into dev 2026-03-30 20:36:36 +08:00
H3CoF6
73a948c528 feat: 初步实现服务号/公众号解析 2026-03-30 20:36:20 +08:00
hicccc77
6d652130e6 chore: 更新资源文件 2026-03-30 20:10:35 +08:00
hicccc77
9e6f8077f7 fix: 数据匿名收集受 analyticsConsent 开关控制 (#589)
- 新增 analyticsConsent state 追踪用户同意状态
- initCloudControl() 仅在用户明确同意后执行
- recordPage() 同样受 analyticsConsent 守卫
- handleAnalyticsAllow 同步更新 state,用户同意后立即生效

Fixes #589
2026-03-30 12:05:41 +08:00
hicccc77
40342ca824 fix(deps): 修复 npm install postinstall 阶段 ajv-keywords 兼容性错误
将 npm overrides 中的 ajv 版本范围从 >=6.14.0 改为 ^6.14.0,
确保 electron-builder 依赖链使用 ajv v6,避免在 Node.js v22 上
@develar/schema-utils 加载 ajv-keywords 时访问 formats 返回 undefined 的问题。

Fixes #588
2026-03-30 11:04:44 +08:00
hejinkang
71238d4a01 Merge branch 'dev' into dev 2026-03-30 10:04:56 +08:00
hicccc77
4da9f1e6cf feat: 添加 anti-spam workflow,自动检测并关闭垃圾 issue 2026-03-29 23:19:55 +08:00
hicccc77
93b55fe370 feat: 添加 anti-spam workflow,自动检测并关闭垃圾 issue 2026-03-29 23:18:24 +08:00
hicccc77
ee5e7d2586 fix: 修复微信重装后 openMessageCursor 返回 -3 (no message db) 的问题
- 新增 forceReopen() 方法:清空路径缓存后强制重新初始化账号连接
- openMessageCursor 在 result=-3 时自动触发 forceReopen 并重试一次
- 改善 -3 错误的提示文案,引导用户重新指定数据目录

修复 #591
2026-03-29 19:18:20 +08:00
hicccc77
4f4e09c3de fix: 修复微信重装后 openMessageCursor 返回 -3 (no message db) 的问题
- 新增 forceReopen() 方法:清空路径缓存后强制重新初始化账号连接
- openMessageCursor 在 result=-3 时自动触发 forceReopen 并重试一次
- 改善 -3 错误的提示文案,引导用户重新指定数据目录

修复 #591
2026-03-29 16:07:09 +08:00
hicccc77
d537d81f1c fix(deps): 修复安全漏洞 2026-03-28 21:15:14 +08:00
hicccc77
26c6700152 fix: 修复 CodeQL code scanning warning 问题 2026-03-28 21:12:29 +08:00
hicccc77
49fb96d7a3 Revert "Revert "fix(deps): 修复安全漏洞""
This reverts commit d256ee5696.
2026-03-28 19:29:17 +08:00
hicccc77
d256ee5696 Revert "fix(deps): 修复安全漏洞"
This reverts commit 06079659af.
2026-03-28 19:28:45 +08:00
hicccc77
bd70a7bfa8 Revert "fix: 修复 Linux 下内存扫描找不到微信进程的问题\n\n增加 pidof/pgrep/ps aux 三重兜底逻辑,兼容不同发行版\n(flatpak、AppImage、wechat-bin 等安装方式),解决 #575"
This reverts commit 3fb09bad0d.
2026-03-28 19:28:45 +08:00
hicccc77
3fb09bad0d fix: 修复 Linux 下内存扫描找不到微信进程的问题\n\n增加 pidof/pgrep/ps aux 三重兜底逻辑,兼容不同发行版\n(flatpak、AppImage、wechat-bin 等安装方式),解决 #575 2026-03-28 19:05:21 +08:00
hicccc77
06079659af fix(deps): 修复安全漏洞 2026-03-28 17:36:28 +08:00
hicccc77
22d8049c2c Revert "fix: 兼容微信新目录结构多一层嵌套导致账号目录识别失败的问题"
This reverts commit 5f6b0e8960.
2026-03-28 17:30:56 +08:00
hicccc77
5f6b0e8960 fix: 兼容微信新目录结构多一层嵌套导致账号目录识别失败的问题
修复 scanWxids 和 scanWxidCandidates 在 2.0b4.0.9/xwechat_files/wxid_xxx
结构下扫描不到账号目录的问题,增加往下多扫一层的兜底逻辑

Fixes #541
2026-03-28 17:28:52 +08:00
hicccc77
9b8da7774d fix: 替换失效的 downloads badge 为 shields.io 2026-03-28 17:05:33 +08:00
hicccc77
eabed55a7a fix: 修复 README Downloads badge 嵌套在 Issues 链接内的问题 2026-03-28 17:03:28 +08:00
hicccc77
32cc74f99c merge: 同步 main 最新代码到 dev(依赖更新、版本 4.3.0、资源文件) 2026-03-28 16:54:47 +08:00
cc
ffc4cc3d96 Merge pull request #574 from hicccc77/dependabot/npm_and_yarn/npm_and_yarn-8abc9b7730
chore(deps): bump the npm_and_yarn group across 1 directory with 3 updates
2026-03-28 16:50:28 +08:00
dependabot[bot]
007cf57efd chore(deps): bump the npm_and_yarn group across 1 directory with 3 updates
Bumps the npm_and_yarn group with 3 updates in the / directory: [minimatch](https://github.com/isaacs/minimatch), [brace-expansion](https://github.com/juliangruber/brace-expansion) and [rollup](https://github.com/rollup/rollup).


Updates `minimatch` from 3.1.2 to 3.1.5
- [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/minimatch/compare/v3.1.2...v3.1.5)

Updates `brace-expansion` from 1.1.12 to 1.1.13
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.12...v1.1.13)

Updates `rollup` from 4.55.1 to 4.60.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.55.1...v4.60.0)

---
updated-dependencies:
- dependency-name: minimatch
  dependency-version: 3.1.5
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: brace-expansion
  dependency-version: 1.1.13
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: rollup
  dependency-version: 4.60.0
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-28 07:56:41 +00:00
hicccc77
c6dba71197 fix: 修复 macOS release notes 中 xattr 命令被 bash 吞掉的问题 2026-03-28 15:55:11 +08:00
cc
8aa162e294 Merge pull request #568 from hicccc77/dependabot/npm_and_yarn/npm_and_yarn-1ca40131d0
chore(deps): bump @tootallnate/once from 2.0.0 to removed in the npm_and_yarn group across 1 directory
2026-03-28 14:51:11 +08:00
dependabot[bot]
51d6dec7ff chore(deps): bump @tootallnate/once
Bumps the npm_and_yarn group with 1 update in the / directory: [@tootallnate/once](https://github.com/TooTallNate/once).


Removes `@tootallnate/once`

---
updated-dependencies:
- dependency-name: "@tootallnate/once"
  dependency-version: 
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-27 22:39:12 +00:00
hicccc77
f1b2762769 fix(deps): 修复安全漏洞 2026-03-28 06:37:44 +08:00
hicccc77
d126be2aa5 chore: 更新资源文件 2026-03-27 22:03:27 +08:00
hicccc77
ea034ee76a ci: fix pnpm audit exit code causing job failure 2026-03-27 21:43:16 +08:00
hicccc77
39634a690c fix(deps): remove ajv override to fix electron-builder compatibility 2026-03-27 21:09:28 +08:00
hicccc77
a7001eb6da fix(deps): upgrade react-router-dom to 7.13.2 and add pnpm overrides for security vulnerabilities
- Upgrade react-router-dom ^7.1.1 -> ^7.13.2
- Add pnpm.overrides to force safe versions of: tar, minimatch, rollup,
  immutable, lodash, ajv, brace-expansion, picomatch
2026-03-27 21:04:44 +08:00
hicccc77
71e3540f18 ci: add gitleaks config to suppress false positives 2026-03-27 20:58:14 +08:00
cc
4ea4020faa Merge branch 'dev' into dev 2026-03-27 20:53:56 +08:00
hicccc77
78cadfd352 ci: add security-events write permission for CodeQL 2026-03-27 19:12:31 +08:00
hicccc77
da15f829d3 ci: add security-events write permission for CodeQL 2026-03-27 19:12:20 +08:00
hicccc77
bb60694013 ci: fix pnpm install frozen-lockfile issue 2026-03-27 18:17:26 +08:00
hicccc77
b3758d2baf ci: fix pnpm install frozen-lockfile issue 2026-03-27 18:17:14 +08:00
hicccc77
bc794e9a44 ci: add daily security scan workflow for all branches 2026-03-27 17:59:09 +08:00
hicccc77
c80115d0f7 ci: add daily security scan workflow for all branches 2026-03-27 17:56:35 +08:00
xuncha
6277576249 Merge pull request #560 from JiQingzhe2004/main
feat: 强制更新支持 minimumVersion,阻止低版本用户继续使用
2026-03-27 15:23:02 +08:00
JiQingzhe2004
2201d369fa chore: bump version to 4.3.0 2026-03-27 14:43:33 +08:00
JiQingzhe2004
9f4e4790f5 feat: 强制更新支持 minimumVersion,阻止低版本用户继续使用 2026-03-27 14:43:08 +08:00
xuncha
501e373e38 Merge pull request #559 from xunchahaha/main
更新打包
2026-03-27 13:10:50 +08:00
xuncha
b2cf7c92d5 更新打包 2026-03-27 13:10:27 +08:00
xuncha
e92e13c045 Merge pull request #558 from hicccc77/xunchahaha-patch-1
Delete preinstall.js
2026-03-27 12:59:45 +08:00
xuncha
f3dec958b0 Delete preinstall.js 2026-03-27 12:48:42 +08:00
hejinkang
c88a1c5848 Merge branch 'hicccc77:dev' into dev 2026-03-27 10:08:08 +08:00
hicccc77
0cf8ea8166 chore: bump version to 4.2.0 2026-03-26 23:20:42 +08:00
hicccc77
74b830dd79 chore: update service files and xkey_helper
- xkey_helper: use mach exception port to intercept EXC_BREAKPOINT,
  fixes key capture failure on macOS 26.2
2026-03-26 23:19:08 +08:00
cc
8668c168a7 333 2026-03-26 22:43:59 +08:00
cc
8b8c5f33ce 333 2026-03-26 22:34:50 +08:00
cc
2fcbb026df 222 2026-03-26 22:32:33 +08:00
cc
66ee72380d 222 2026-03-26 22:30:21 +08:00
cc
4f16345351 111 2026-03-26 22:26:33 +08:00
cc
5110618996 再次修复 2026-03-26 22:19:30 +08:00
cc
bf51368cf4 修复密钥问题 2026-03-26 22:16:30 +08:00
cc
d6054745d6 修复macos打包错误 2026-03-26 22:00:42 +08:00
hicccc77
a4731f25f8 chore: update xkey_helper (macOS) with pure semantic scan mode
Always use pure semantic scan mode (KNOWN_RVA=0) regardless of
WeChat version, improving compatibility for versions < 4.1.8.
2026-03-26 21:16:14 +08:00
hicccc77
6c4507e495 fix(ci): remove invalid --no-fail-on-no-release flag from gh release edit 2026-03-26 20:33:18 +08:00
hicccc77
c8e0160d5c fix(ci): use bash shell for Windows packaging steps to avoid PowerShell variable expansion 2026-03-26 20:21:13 +08:00
hicccc77
ac40a81901 fix(ci): pre-release placeholder + fix latest.yml arm64 overwrite
- Add prepare-release job: immediately marks release as pre-release
  with "正在构建中,请勿下载" notice; all build jobs depend on it
- Fix arm64 job channel override: use -c.publish.channel=latest-arm64
  (correct syntax) instead of broken single-quoted CLI arg
- Fix artifactName quoting for both win x64 and arm64 jobs
- Add "Fix latest.yml" step in update-release-notes: downloads x64 exe,
  computes correct sha512/size, uploads latest.yml with --clobber
- Final step in update-release-notes: remove prerelease flag, mark as
  latest official release

Fixes #553
2026-03-26 19:57:45 +08:00
hejk
0162769d22 fix(sns): fallback usernames from timeline when SQL result is empty 2026-03-26 19:38:06 +08:00
hejk
fa55755921 feat(http): add sns HTTP API endpoints 2026-03-26 19:36:19 +08:00
hicccc77
ca38a68a75 fix: 改善错误码 -3001 提示信息并增强 db_storage 路径解析兼容性\n\n- formatInitProtectionError 返回可读的中文错误说明,替代裸错误码\n- resolveDbStoragePath 新增向上查找(最多2级)兜底逻辑\n- 新增 findDbStorageRecursive 递归搜索(最多3层)兜底\n- 解决使用 wx_key 获取密钥后因路径层级不同导致 -3001 报错的问题\n\nFixes #552 2026-03-26 15:08:08 +08:00
hicccc77
64be2dd562 fix: 支持微信 4.0.5+ 新数据目录结构 (Application Support/com.tencent.xinWeChat/2.0b4.0.x)
- dbPathService.autoDetect: 自动枚举版本目录(如 2.0b4.0.9),优先检测新路径
- dbPathService.getDefaultPath: 同步返回新版本路径
- keyServiceMac.resolveXwechatRootFromPath: 兼容新路径标记
- keyServiceMac.getKvcommCandidates: 补充新路径下的 kvcomm 推导

Fixes #551
2026-03-26 12:08:47 +08:00
cc
ea2abb6c72 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-03-25 20:09:53 +08:00
cc
011e2ff37a 修复Action打包问题与渲染层过滤问题 2026-03-25 20:09:48 +08:00
cc
cfa335564a Merge pull request #549 from hicccc77/dev
Dev
2026-03-25 20:02:58 +08:00
cc
3d1493b0a6 Merge pull request #548 from Leoluis0705/perf/optimize-group-analytics
Perf/optimize group analytics
2026-03-25 19:51:08 +08:00
Leoluis0705
a46b52e603 fix(analytics): 完善群成员分析失败时的错误边界处理与UI展示 2026-03-25 19:15:12 +08:00
Leoluis0705
3c0683b9f8 perf(core): 为底层提取器引入 isSend 标识智能判断,解决大量本地消息及富文本消息引发的性能退化问题 2026-03-25 18:30:24 +08:00
Leoluis0705
3214c2804e feat(group-analytics): 新增并极致优化群成员详细分析与图表呈现功能 2026-03-25 18:24:05 +08:00
hicccc77
83f50cbaee fix: support configurable bind host for HTTP API and fix Windows sherpa-onnx PATH
- fix(#547): HTTP API server now supports configurable bind host (default 127.0.0.1)
  Docker/N8N users can set host to 0.0.0.0 in settings to allow container access.
  Adds httpApiHost config key, UI input in settings, and passes host through
  IPC chain (preload -> main -> httpService).

- fix(#546): Add Windows PATH injection for sherpa-onnx native module
  buildTranscribeWorkerEnv() now adds the sherpa-onnx-win-x64 directory to
  PATH on Windows, fixing 'Could not find sherpa-onnx-node' errors caused
  by missing DLL search path in forked worker processes.
2026-03-25 15:10:16 +08:00
Forrest
61ef10de9b Merge pull request #545 from JiQingzhe2004/main
更新图标
2026-03-25 02:09:50 +08:00
Forrest
73f36d6b29 更新图标 2026-03-25 01:36:04 +08:00
Forrest
666a1a3296 Merge branch 'hicccc77:main' into main 2026-03-25 00:18:12 +08:00
H3CoF6
acec2e95a2 Merge pull request #540 from H3CoF6/main
Dev
2026-03-24 04:45:13 +08:00
H3CoF6
d26e7e78a1 支持appimage,添加安装脚本,更新文档 2026-03-24 04:33:17 +08:00
H3CoF6
77e5c44673 feat: 保存api服务的配置,实现随weflow静默启动 2026-03-24 04:11:34 +08:00
H3CoF6
619cc84d15 feat: api接口新增access_token校验 2026-03-24 03:55:37 +08:00
H3CoF6
22b85439d3 chore: 向下兼容低版本linux 2026-03-24 03:14:44 +08:00
xuncha
b5a371da87 Merge pull request #349 from hicccc77/dev
Dev
2026-03-13 08:55:32 +03:00
149 changed files with 37170 additions and 8766 deletions

7
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,7 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "daily"
target-branch: "dev"

212
.github/scripts/release-utils.sh vendored Normal file
View File

@@ -0,0 +1,212 @@
#!/usr/bin/env bash
set -euo pipefail
retry_cmd() {
local attempts="$1"
local delay_seconds="$2"
shift 2
local i
local exit_code
for ((i = 1; i <= attempts; i++)); do
if "$@"; then
return 0
fi
exit_code=$?
if [ "$i" -lt "$attempts" ]; then
echo "Command failed (attempt $i/$attempts, exit=$exit_code): $*" >&2
echo "Retrying in ${delay_seconds}s..." >&2
sleep "$delay_seconds"
fi
done
echo "Command failed after $attempts attempts: $*" >&2
return "$exit_code"
}
capture_cmd_with_retry() {
local result_var="$1"
local attempts="$2"
local delay_seconds="$3"
shift 3
local i
local output=""
local exit_code=1
for ((i = 1; i <= attempts; i++)); do
if output="$("$@" 2>/dev/null)"; then
printf -v "$result_var" "%s" "$output"
return 0
fi
exit_code=$?
if [ "$i" -lt "$attempts" ]; then
echo "Capture command failed (attempt $i/$attempts, exit=$exit_code): $*" >&2
echo "Retrying in ${delay_seconds}s..." >&2
sleep "$delay_seconds"
fi
done
echo "Capture command failed after $attempts attempts: $*" >&2
return "$exit_code"
}
wait_for_release_id() {
local repo="$1"
local tag="$2"
local attempts="${3:-12}"
local delay_seconds="${4:-2}"
local i
local release_id
for ((i = 1; i <= attempts; i++)); do
release_id="$(gh api "repos/$repo/releases/tags/$tag" --jq '.id' 2>/dev/null || true)"
if [[ "$release_id" =~ ^[0-9]+$ ]]; then
echo "$release_id"
return 0
fi
if [ "$i" -lt "$attempts" ]; then
echo "Release id for tag '$tag' is not ready yet (attempt $i/$attempts), retrying in ${delay_seconds}s..." >&2
sleep "$delay_seconds"
fi
done
echo "Unable to fetch release id for tag '$tag' after $attempts attempts." >&2
gh api "repos/$repo/releases/tags/$tag" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}' 2>/dev/null || true
return 1
}
settle_release_state() {
local repo="$1"
local release_id="$2"
local tag="$3"
local attempts="${4:-12}"
local delay_seconds="${5:-2}"
local endpoint="repos/$repo/releases/tags/$tag"
local i
local draft_state
local prerelease_state
for ((i = 1; i <= attempts; i++)); do
gh api --method PATCH "repos/$repo/releases/$release_id" -F draft=false -F prerelease=true >/dev/null 2>&1 || true
draft_state="$(gh api "$endpoint" --jq '.draft' 2>/dev/null || echo true)"
prerelease_state="$(gh api "$endpoint" --jq '.prerelease' 2>/dev/null || echo false)"
if [ "$draft_state" = "false" ] && [ "$prerelease_state" = "true" ]; then
return 0
fi
if [ "$i" -lt "$attempts" ]; then
echo "Release '$tag' state not settled yet (attempt $i/$attempts), retrying in ${delay_seconds}s..." >&2
sleep "$delay_seconds"
fi
done
echo "Failed to settle release state for tag '$tag'." >&2
gh api "$endpoint" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}' 2>/dev/null || true
return 1
}
wait_for_release_absent() {
local repo="$1"
local tag="$2"
local attempts="${3:-12}"
local delay_seconds="${4:-2}"
local i
for ((i = 1; i <= attempts; i++)); do
if gh release view "$tag" --repo "$repo" >/dev/null 2>&1; then
if [ "$i" -lt "$attempts" ]; then
echo "Release '$tag' still exists (attempt $i/$attempts), waiting ${delay_seconds}s..." >&2
sleep "$delay_seconds"
fi
continue
fi
return 0
done
echo "Release '$tag' still exists after waiting." >&2
gh release view "$tag" --repo "$repo" --json url,isDraft,isPrerelease 2>/dev/null || true
return 1
}
wait_for_git_tag_absent() {
local repo="$1"
local tag="$2"
local attempts="${3:-12}"
local delay_seconds="${4:-2}"
local i
for ((i = 1; i <= attempts; i++)); do
if gh api "repos/$repo/git/ref/tags/$tag" >/dev/null 2>&1; then
if [ "$i" -lt "$attempts" ]; then
echo "Git tag '$tag' still exists (attempt $i/$attempts), waiting ${delay_seconds}s..." >&2
sleep "$delay_seconds"
fi
continue
fi
return 0
done
echo "Git tag '$tag' still exists after waiting." >&2
gh api "repos/$repo/git/ref/tags/$tag" --jq '{ref: .ref, object: .object.sha}' 2>/dev/null || true
return 1
}
recreate_fixed_prerelease() {
local repo="$1"
local tag="$2"
local target_branch="$3"
local release_title="$4"
local release_notes="$5"
if gh release view "$tag" --repo "$repo" >/dev/null 2>&1; then
retry_cmd 5 3 gh release delete "$tag" --repo "$repo" --yes --cleanup-tag
fi
wait_for_release_absent "$repo" "$tag" 12 2
if gh api "repos/$repo/git/ref/tags/$tag" >/dev/null 2>&1; then
retry_cmd 5 2 gh api --method DELETE "repos/$repo/git/refs/tags/$tag"
fi
wait_for_git_tag_absent "$repo" "$tag" 12 2
local created="false"
local i
for ((i = 1; i <= 6; i++)); do
if gh release create "$tag" --repo "$repo" --title "$release_title" --notes "$release_notes" --prerelease --target "$target_branch"; then
created="true"
break
fi
if gh release view "$tag" --repo "$repo" >/dev/null 2>&1; then
echo "Release '$tag' appears to exist after create failure; continue to settle state." >&2
created="true"
break
fi
if [ "$i" -lt 6 ]; then
echo "Create release '$tag' failed (attempt $i/6), retrying in 3s..." >&2
sleep 3
fi
done
if [ "$created" != "true" ]; then
echo "Failed to create release '$tag'." >&2
return 1
fi
local release_id
release_id="$(wait_for_release_id "$repo" "$tag" 12 2)"
settle_release_state "$repo" "$release_id" "$tag" 12 2
}
upload_release_assets_with_retry() {
local repo="$1"
local tag="$2"
shift 2
if [ "$#" -eq 0 ]; then
echo "No release assets provided for upload." >&2
return 1
fi
wait_for_release_id "$repo" "$tag" 12 2 >/dev/null
retry_cmd 5 3 gh release upload "$tag" "$@" --repo "$repo" --clobber
}

134
.github/workflows/anti-spam.yml vendored Normal file
View File

@@ -0,0 +1,134 @@
name: Anti-Spam
on:
issues:
types: [opened, edited]
permissions:
issues: write
jobs:
check-spam:
runs-on: ubuntu-latest
steps:
- name: Check for spam
uses: actions/github-script@v7
with:
script: |
const issue = context.payload.issue;
const title = (issue.title || '').toLowerCase();
const body = (issue.body || '').toLowerCase();
const text = title + ' ' + body;
// 博彩/赌球类
const gamblingPatterns = [
/世界杯.*买球/, /买球.*世界杯/,
/世界杯.*下注/, /世界杯.*竞猜/,
/世界杯.*投注/, /世界杯.*押注/,
/世界杯.*彩票/, /世界杯.*平台/,
/世界杯.*app/, /世界杯.*软件/,
/世界杯.*网站/, /世界杯.*网址/,
/足球.*买球/, /买球.*足球/,
/足球.*投注/, /足球.*押注/,
/足球.*竞猜/, /足球.*平台/,
/篮球.*买球/, /篮球.*投注/,
/体育.*投注/, /体育.*竞猜/,
/体育.*买球/, /体育.*押注/,
/赌球/, /赌博.*网站/, /赌博.*平台/,
/博彩/, /博彩.*网站/, /博彩.*平台/,
/正规.*买球/, /官方.*买球/,
/买球.*网站/, /买球.*app/,
/买球.*软件/, /买球.*网址/,
/买球.*平台/, /买球.*技巧/,
/投注.*网站/, /投注.*平台/,
/押注.*网站/, /押注.*平台/,
/竞猜.*网站/, /竞猜.*平台/,
/彩票.*网站/, /彩票.*平台/,
/欧洲杯.*买球/, /欧冠.*买球/,
/nba.*买球/, /nba.*投注/,
];
// 色情/交友类
const adultPatterns = [
/约炮/, /一夜情/, /外围/,
/包养/, /援交/, /陪聊/,
/成人.*网站/, /成人.*视频/,
/av.*网站/, /黄色.*网站/,
];
// 贷款/金融诈骗类
const financePatterns = [
/秒到账.*贷款/, /无抵押.*贷款/,
/征信.*贷款/, /黑户.*贷款/,
/快速.*放款/, /私人.*放贷/,
/刷单/, /兼职.*日入/, /兼职.*月入/,
/网赚/, /躺赚/, /被动收入.*平台/,
/虚拟货币.*投资/, /usdt.*投资/,
/炒币.*平台/, /数字货币.*平台/,
];
// 垃圾推广类
const spamPromoPatterns = [
/代刷/, /粉丝.*购买/, /涨粉/,
/seo.*优化/, /快速排名/,
/微商/, /代理.*招募/,
];
// 账号特征检测(新账号 + 无 contribution
const allPatterns = [
...gamblingPatterns,
...adultPatterns,
...financePatterns,
...spamPromoPatterns,
];
const isSpam = allPatterns.some(pattern => pattern.test(text));
// 额外检测:标题超短且含可疑关键词(常见于批量刷单)
const suspiciousShort = title.length < 10 && /(买球|投注|博彩|赌博|下注|押注)/.test(title);
if (isSpam || suspiciousShort) {
// 确保 spam label 存在
try {
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: 'spam',
color: 'e4e669',
description: 'Spam issue'
});
} catch (e) {
// label 已存在,忽略
}
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
body: '此 issue 已被自动识别为垃圾内容并关闭。\n\nThis issue has been automatically identified as spam and closed.'
});
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: ['spam']
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
state: 'closed',
state_reason: 'not_planned'
});
await github.rest.issues.lock({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
lock_reason: 'spam'
});
console.log(`Closed spam issue #${issue.number}: ${issue.title}`);
}

383
.github/workflows/dev-daily-fixed.yml vendored Normal file
View File

@@ -0,0 +1,383 @@
name: Dev Daily
on:
schedule:
# GitHub Actions schedule uses UTC. 16:00 UTC = 北京时间次日 00:00
- cron: "0 16 * * *"
workflow_dispatch:
concurrency:
group: dev-nightly-fixed-release
cancel-in-progress: true
permissions:
contents: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
FIXED_DEV_TAG: nightly-dev
TARGET_BRANCH: dev
ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/
jobs:
prepare:
runs-on: ubuntu-latest
outputs:
dev_version: ${{ steps.meta.outputs.dev_version }}
steps:
- name: Check out git repository
uses: actions/checkout@v5
with:
ref: ${{ env.TARGET_BRANCH }}
fetch-depth: 0
- name: Install Node.js
uses: actions/setup-node@v5
with:
node-version: 24
cache: "npm"
- name: Generate daily dev version
id: meta
shell: bash
run: |
set -euo pipefail
YEAR_2="$(TZ=Asia/Shanghai date +%y)"
MONTH="$(TZ=Asia/Shanghai date +%-m)"
DAY="$(TZ=Asia/Shanghai date +%-d)"
DEV_VERSION="${YEAR_2}.${MONTH}.${DAY}"
echo "dev_version=$DEV_VERSION" >> "$GITHUB_OUTPUT"
echo "Dev version: $DEV_VERSION"
- name: Recreate fixed prerelease
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
source .github/scripts/release-utils.sh
recreate_fixed_prerelease "$GITHUB_REPOSITORY" "$FIXED_DEV_TAG" "$TARGET_BRANCH" "Daily Dev Build" "开发版发布页"
dev-mac-arm64:
needs: prepare
runs-on: macos-14
steps:
- name: Check out git repository
uses: actions/checkout@v5
with:
ref: ${{ env.TARGET_BRANCH }}
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: Ensure mac key helpers are executable
shell: bash
run: |
set -euo pipefail
for file in \
resources/key/macos/universal/xkey_helper \
resources/key/macos/universal/image_scan_helper \
resources/key/macos/universal/xkey_helper_macos \
resources/key/macos/universal/libwx_key.dylib
do
if [ -f "$file" ]; then
chmod +x "$file"
ls -l "$file"
fi
done
- name: Set dev version
shell: bash
run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version
- name: Build Frontend & Type Check
shell: bash
run: |
npx tsc
npx vite build
- name: Package macOS arm64 dev artifacts
shell: bash
run: |
set -euo pipefail
export ELECTRON_BUILDER_BINARIES_MIRROR="https://github.com/electron-userland/electron-builder-binaries/releases/download/"
echo "Using ELECTRON_BUILDER_BINARIES_MIRROR=$ELECTRON_BUILDER_BINARIES_MIRROR"
if ! npx electron-builder --mac dmg zip --arm64 --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-arm64.${ext}'; then
echo "::warning::DMG packaging failed (hdiutil instability on runner). Retrying with ZIP only."
npx electron-builder --mac zip --arm64 --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-arm64.${ext}'
fi
- name: Upload macOS arm64 assets to fixed release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
source .github/scripts/release-utils.sh
assets=()
while IFS= read -r file; do
assets+=("$file")
done < <(find release -maxdepth 1 -type f | sort)
if [ "${#assets[@]}" -eq 0 ]; then
echo "No release files found in ./release"
exit 1
fi
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_DEV_TAG" "${assets[@]}"
dev-linux:
needs: prepare
runs-on: ubuntu-latest
steps:
- name: Check out git repository
uses: actions/checkout@v5
with:
ref: ${{ env.TARGET_BRANCH }}
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: Set dev version
shell: bash
run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version
- name: Build Frontend & Type Check
shell: bash
run: |
npx tsc
npx vite build
- name: Package Linux dev artifacts
run: |
npx electron-builder --linux --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-linux.${ext}'
- name: Upload Linux assets to fixed release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
source .github/scripts/release-utils.sh
assets=()
while IFS= read -r file; do
assets+=("$file")
done < <(find release -maxdepth 1 -type f | sort)
if [ "${#assets[@]}" -eq 0 ]; then
echo "No release files found in ./release"
exit 1
fi
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_DEV_TAG" "${assets[@]}"
dev-win-x64:
needs: prepare
runs-on: windows-latest
steps:
- name: Check out git repository
uses: actions/checkout@v5
with:
ref: ${{ env.TARGET_BRANCH }}
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: Set dev version
shell: bash
run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version
- name: Build Frontend & Type Check
shell: bash
run: |
npx tsc
npx vite build
- name: Package Windows x64 dev artifacts
run: |
npx electron-builder --win nsis --x64 --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-x64-Setup.${ext}'
- name: Upload Windows x64 assets to fixed release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
source .github/scripts/release-utils.sh
assets=()
while IFS= read -r file; do
assets+=("$file")
done < <(find release -maxdepth 1 -type f | sort)
if [ "${#assets[@]}" -eq 0 ]; then
echo "No release files found in ./release"
exit 1
fi
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_DEV_TAG" "${assets[@]}"
dev-win-arm64:
needs: prepare
runs-on: windows-latest
steps:
- name: Check out git repository
uses: actions/checkout@v5
with:
ref: ${{ env.TARGET_BRANCH }}
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: Set dev version
shell: bash
run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version
- name: Build Frontend & Type Check
shell: bash
run: |
npx tsc
npx vite build
- name: Package Windows arm64 dev artifacts
run: |
npx electron-builder --win nsis --arm64 --publish never '--config.publish.channel=dev-arm64' '--config.artifactName=${productName}-dev-arm64-Setup.${ext}'
- name: Upload Windows arm64 assets to fixed release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
source .github/scripts/release-utils.sh
assets=()
while IFS= read -r file; do
assets+=("$file")
done < <(find release -maxdepth 1 -type f | sort)
if [ "${#assets[@]}" -eq 0 ]; then
echo "No release files found in ./release"
exit 1
fi
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_DEV_TAG" "${assets[@]}"
update-dev-release-notes:
needs:
- prepare
- dev-mac-arm64
- dev-linux
- dev-win-x64
- dev-win-arm64
if: always() && needs.prepare.result == 'success'
runs-on: ubuntu-latest
steps:
- name: Update fixed dev release notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
TAG="${FIXED_DEV_TAG:-}"
if [ -z "$TAG" ]; then
echo "FIXED_DEV_TAG is empty, abort."
exit 1
fi
REPO="$GITHUB_REPOSITORY"
RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG"
echo "Using release tag: $TAG"
if ! gh api "repos/$REPO/releases/tags/$TAG" >/dev/null 2>&1; then
echo "Release $TAG not found, skip notes update."
exit 0
fi
ASSETS_JSON="$(gh api "repos/$REPO/releases/tags/$TAG")"
pick_asset() {
local pattern="$1"
echo "$ASSETS_JSON" | jq -r --arg p "$pattern" '[.assets[].name | select(test($p))][0] // ""'
}
WINDOWS_ASSET="$(pick_asset "dev-x64-Setup[.]exe$")"
WINDOWS_ARM64_ASSET="$(pick_asset "dev-arm64-Setup[.]exe$")"
MAC_ASSET="$(pick_asset "dev-arm64[.]dmg$")"
if [ -z "$MAC_ASSET" ]; then
MAC_ASSET="$(pick_asset "dev-arm64[.]zip$")"
fi
LINUX_TAR_ASSET="$(pick_asset "dev-linux[.]tar[.]gz$")"
LINUX_APPIMAGE_ASSET="$(pick_asset "dev-linux[.]AppImage$")"
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")"
WINDOWS_ARM64_URL="$(build_link "$WINDOWS_ARM64_ASSET")"
MAC_URL="$(build_link "$MAC_ASSET")"
LINUX_TAR_URL="$(build_link "$LINUX_TAR_ASSET")"
LINUX_APPIMAGE_URL="$(build_link "$LINUX_APPIMAGE_ASSET")"
cat > dev_release_notes.md <<EOF
## Daily Dev Build
- 该发布页为 **开发版**。
- 当前构建版本:\`${{ needs.prepare.outputs.dev_version }}\`
- 此版本为每日构建的开发版,包含最新的功能和修复,但可能不够稳定,仅推荐用于测试和验证。
## 下载
- Windows x64: [点击下载](${WINDOWS_URL:-$RELEASE_PAGE})
- Windows arm64: [点击下载](${WINDOWS_ARM64_URL:-$RELEASE_PAGE})
- macOSApple Silicon: [点击下载](${MAC_URL:-$RELEASE_PAGE})
- Linux (.tar.gz): [点击下载](${LINUX_TAR_URL:-$RELEASE_PAGE})
- Linux (.AppImage): [点击下载](${LINUX_APPIMAGE_URL:-$RELEASE_PAGE})
## macOS 安装提示
- 如果被系统提示已损坏,你需要在终端执行以下命令去除隔离标记:
- \`xattr -dr com.apple.quarantine "/Applications/WeFlow.app"\`
- 执行后重新打开 WeFlow。
## 说明
- 该发布页的同名资源会被后续构建覆盖,请勿将其视作长期归档版本。
- 如某个平台资源暂未生成,请进入[发布页]($RELEASE_PAGE)查看最新状态
EOF
update_release_notes() {
local attempts=5
local delay_seconds=2
local i
for ((i=1; i<=attempts; i++)); do
if gh release edit "$TAG" --repo "$REPO" --title "Daily Dev Build" --notes-file dev_release_notes.md --prerelease >/dev/null 2>&1; then
return 0
fi
if [ "$i" -lt "$attempts" ]; then
echo "Release update failed (attempt $i/$attempts), retry in ${delay_seconds}s..."
sleep "$delay_seconds"
fi
done
return 1
}
update_release_notes
source .github/scripts/release-utils.sh
RELEASE_REST_ID="$(wait_for_release_id "$REPO" "$TAG" 12 2)"
settle_release_state "$REPO" "$RELEASE_REST_ID" "$TAG" 12 2
gh api "repos/$REPO/releases/tags/$TAG" --jq '{isDraft: .draft, isPrerelease: .prerelease, url: .html_url}'

View File

@@ -0,0 +1,426 @@
name: Preview Nightly
on:
schedule:
# GitHub Actions schedule uses UTC. 16:00 UTC = 北京时间次日 00:00
- cron: "0 16 * * *"
workflow_dispatch:
concurrency:
group: preview-nightly-fixed-release
cancel-in-progress: true
permissions:
contents: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
FIXED_PREVIEW_TAG: nightly-preview
TARGET_BRANCH: main
ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/
jobs:
prepare:
runs-on: ubuntu-latest
outputs:
should_build: ${{ steps.meta.outputs.should_build }}
preview_version: ${{ steps.meta.outputs.preview_version }}
steps:
- name: Check out git repository
uses: actions/checkout@v5
with:
ref: ${{ env.TARGET_BRANCH }}
fetch-depth: 0
- name: Install Node.js
uses: actions/setup-node@v5
with:
node-version: 24
cache: "npm"
- name: Decide whether to build and generate preview version
id: meta
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
git fetch origin main --depth=1
COMMITS_24H="$(git rev-list --count --since='24 hours ago' origin/main)"
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
SHOULD_BUILD=true
elif [ "$COMMITS_24H" -gt 0 ]; then
SHOULD_BUILD=true
else
SHOULD_BUILD=false
fi
YEAR_2="$(TZ=Asia/Shanghai date +%y)"
YEARLY_RUN_COUNT=1
LAST_VERSION="$(gh release view "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" --json body --jq '.body' 2>/dev/null | grep -Eo '0\.[0-9]{2}\.[0-9]+' | head -n 1 || true)"
if [[ "$LAST_VERSION" =~ ^0\.([0-9]{2})\.([0-9]+)$ ]]; then
LAST_YEAR="${BASH_REMATCH[1]}"
LAST_COUNT="${BASH_REMATCH[2]}"
if [ "$LAST_YEAR" = "$YEAR_2" ]; then
YEARLY_RUN_COUNT=$((LAST_COUNT + 1))
fi
fi
PREVIEW_VERSION="0.${YEAR_2}.${YEARLY_RUN_COUNT}"
echo "should_build=$SHOULD_BUILD" >> "$GITHUB_OUTPUT"
echo "preview_version=$PREVIEW_VERSION" >> "$GITHUB_OUTPUT"
echo "Preview version: $PREVIEW_VERSION (commits in last 24h on main: $COMMITS_24H, yearly count: $YEARLY_RUN_COUNT)"
- name: Recreate fixed preview prerelease
if: steps.meta.outputs.should_build == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
source .github/scripts/release-utils.sh
recreate_fixed_prerelease "$GITHUB_REPOSITORY" "$FIXED_PREVIEW_TAG" "$TARGET_BRANCH" "Preview Nightly Build" "预览版发布页"
preview-mac-arm64:
needs: prepare
if: needs.prepare.outputs.should_build == 'true'
runs-on: macos-14
steps:
- name: Check out git repository
uses: actions/checkout@v5
with:
ref: ${{ env.TARGET_BRANCH }}
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: Ensure mac key helpers are executable
shell: bash
run: |
set -euo pipefail
for file in \
resources/key/macos/universal/xkey_helper \
resources/key/macos/universal/image_scan_helper \
resources/key/macos/universal/xkey_helper_macos \
resources/key/macos/universal/libwx_key.dylib
do
if [ -f "$file" ]; then
chmod +x "$file"
ls -l "$file"
fi
done
- name: Set preview version
shell: bash
run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version
- name: Build Frontend & Type Check
shell: bash
run: |
npx tsc
npx vite build
- name: Package macOS arm64 preview artifacts
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
shell: bash
run: |
set -euo pipefail
export ELECTRON_BUILDER_BINARIES_MIRROR="https://github.com/electron-userland/electron-builder-binaries/releases/download/"
echo "Using ELECTRON_BUILDER_BINARIES_MIRROR=$ELECTRON_BUILDER_BINARIES_MIRROR"
if ! npx electron-builder --mac dmg zip --arm64 --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-arm64.${ext}'; then
echo "::warning::DMG packaging failed (hdiutil instability on runner). Retrying with ZIP only."
npx electron-builder --mac zip --arm64 --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-arm64.${ext}'
fi
- name: Upload macOS arm64 assets to fixed preview release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
source .github/scripts/release-utils.sh
assets=()
while IFS= read -r file; do
assets+=("$file")
done < <(find release -maxdepth 1 -type f | sort)
if [ "${#assets[@]}" -eq 0 ]; then
echo "No release files found in ./release"
exit 1
fi
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_PREVIEW_TAG" "${assets[@]}"
preview-linux:
needs: prepare
if: needs.prepare.outputs.should_build == 'true'
runs-on: ubuntu-latest
steps:
- name: Check out git repository
uses: actions/checkout@v5
with:
ref: ${{ env.TARGET_BRANCH }}
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: Set preview version
shell: bash
run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version
- name: Build Frontend & Type Check
shell: bash
run: |
npx tsc
npx vite build
- name: Package Linux preview artifacts
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
npx electron-builder --linux --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-linux.${ext}'
- name: Upload Linux assets to fixed preview release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
source .github/scripts/release-utils.sh
assets=()
while IFS= read -r file; do
assets+=("$file")
done < <(find release -maxdepth 1 -type f | sort)
if [ "${#assets[@]}" -eq 0 ]; then
echo "No release files found in ./release"
exit 1
fi
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_PREVIEW_TAG" "${assets[@]}"
preview-win-x64:
needs: prepare
if: needs.prepare.outputs.should_build == 'true'
runs-on: windows-latest
steps:
- name: Check out git repository
uses: actions/checkout@v5
with:
ref: ${{ env.TARGET_BRANCH }}
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: Set preview version
shell: bash
run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version
- name: Build Frontend & Type Check
shell: bash
run: |
npx tsc
npx vite build
- name: Package Windows x64 preview artifacts
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
npx electron-builder --win nsis --x64 --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-x64-Setup.${ext}'
- name: Upload Windows x64 assets to fixed preview release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
source .github/scripts/release-utils.sh
assets=()
while IFS= read -r file; do
assets+=("$file")
done < <(find release -maxdepth 1 -type f | sort)
if [ "${#assets[@]}" -eq 0 ]; then
echo "No release files found in ./release"
exit 1
fi
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_PREVIEW_TAG" "${assets[@]}"
preview-win-arm64:
needs: prepare
if: needs.prepare.outputs.should_build == 'true'
runs-on: windows-latest
steps:
- name: Check out git repository
uses: actions/checkout@v5
with:
ref: ${{ env.TARGET_BRANCH }}
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: Set preview version
shell: bash
run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version
- name: Build Frontend & Type Check
shell: bash
run: |
npx tsc
npx vite build
- name: Package Windows arm64 preview artifacts
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
npx electron-builder --win nsis --arm64 --publish never '--config.publish.channel=preview-arm64' '--config.artifactName=${productName}-preview-arm64-Setup.${ext}'
- name: Upload Windows arm64 assets to fixed preview release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
source .github/scripts/release-utils.sh
assets=()
while IFS= read -r file; do
assets+=("$file")
done < <(find release -maxdepth 1 -type f | sort)
if [ "${#assets[@]}" -eq 0 ]; then
echo "No release files found in ./release"
exit 1
fi
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_PREVIEW_TAG" "${assets[@]}"
update-preview-release-notes:
needs:
- prepare
- preview-mac-arm64
- preview-linux
- preview-win-x64
- preview-win-arm64
if: needs.prepare.outputs.should_build == 'true' && always()
runs-on: ubuntu-latest
steps:
- name: Update preview release notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
TAG="${FIXED_PREVIEW_TAG:-}"
if [ -z "$TAG" ]; then
echo "FIXED_PREVIEW_TAG is empty, abort."
exit 1
fi
CURRENT_PREVIEW_VERSION="${{ needs.prepare.outputs.preview_version }}"
REPO="$GITHUB_REPOSITORY"
RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG"
echo "Using release tag: $TAG"
if ! gh api "repos/$REPO/releases/tags/$TAG" >/dev/null 2>&1; then
echo "Release $TAG not found (possibly all publish jobs failed), skip notes update."
exit 0
fi
ASSETS_JSON="$(gh api "repos/$REPO/releases/tags/$TAG")"
pick_asset() {
local pattern="$1"
echo "$ASSETS_JSON" | jq -r --arg p "$pattern" '[.assets[].name | select(test($p))][0] // ""'
}
WINDOWS_ASSET="$(pick_asset "x64.*[.]exe$")"
if [ -z "$WINDOWS_ASSET" ]; then
WINDOWS_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("[.]exe$")) | select(test("arm64") | not)][0] // ""')"
fi
WINDOWS_ARM64_ASSET="$(pick_asset "arm64.*[.]exe$")"
MAC_ASSET="$(pick_asset "[.]dmg$")"
if [ -z "$MAC_ASSET" ]; then
MAC_ASSET="$(pick_asset "[.]zip$")"
fi
LINUX_TAR_ASSET="$(pick_asset "[.]tar[.]gz$")"
LINUX_APPIMAGE_ASSET="$(pick_asset "[.]AppImage$")"
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")"
WINDOWS_ARM64_URL="$(build_link "$WINDOWS_ARM64_ASSET")"
MAC_URL="$(build_link "$MAC_ASSET")"
LINUX_TAR_URL="$(build_link "$LINUX_TAR_ASSET")"
LINUX_APPIMAGE_URL="$(build_link "$LINUX_APPIMAGE_ASSET")"
cat > preview_release_notes.md <<EOF
## Preview Nightly 说明
- 该版本为 **预览版**,用于提前体验即将发布的功能与修复。
- 可能包含尚未完全稳定的改动,不建议长期使用
- 当前版本号:\`$CURRENT_PREVIEW_VERSION\`
## 下载
- Windows x64: [点击下载](${WINDOWS_URL:-$RELEASE_PAGE})
- Windows arm64: [点击下载](${WINDOWS_ARM64_URL:-$RELEASE_PAGE})
- macOSApple Silicon: [点击下载](${MAC_URL:-$RELEASE_PAGE})
- Linux (.tar.gz): [点击下载](${LINUX_TAR_URL:-$RELEASE_PAGE})
- Linux (.AppImage): [点击下载](${LINUX_APPIMAGE_URL:-$RELEASE_PAGE})
## macOS 安装提示
- 如果被系统提示已损坏,你需要在终端执行以下命令去除隔离标记:
- \`xattr -dr com.apple.quarantine "/Applications/WeFlow.app"\`
- 执行后重新打开 WeFlow。
> 如某个平台链接暂未生成,请前往[发布页]($RELEASE_PAGE)查看最新资源
EOF
update_release_notes() {
local attempts=5
local delay_seconds=2
local i
for ((i=1; i<=attempts; i++)); do
if gh release edit "$TAG" --repo "$REPO" --title "Preview Nightly Build" --notes-file preview_release_notes.md --prerelease >/dev/null 2>&1; then
return 0
fi
if [ "$i" -lt "$attempts" ]; then
echo "Release update failed (attempt $i/$attempts), retry in ${delay_seconds}s..."
sleep "$delay_seconds"
fi
done
return 1
}
update_release_notes
source .github/scripts/release-utils.sh
RELEASE_REST_ID="$(wait_for_release_id "$REPO" "$TAG" 12 2)"
settle_release_state "$REPO" "$RELEASE_REST_ID" "$TAG" 12 2
gh api "repos/$REPO/releases/tags/$TAG" --jq '{isDraft: .draft, isPrerelease: .prerelease, url: .html_url}'

View File

@@ -10,6 +10,7 @@ permissions:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/
jobs:
release-mac-arm64:
@@ -26,7 +27,6 @@ jobs:
with:
node-version: 24
cache: "npm"
- name: Install Dependencies
run: npm install
@@ -35,19 +35,49 @@ jobs:
run: |
VERSION=${GITHUB_REF_NAME#v}
echo "Syncing package.json version to $VERSION"
npm version $VERSION --no-git-tag-version --allow-same-version
node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json','utf8'));p.version='$VERSION';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')"
- name: Build Frontend & Type Check
shell: bash
run: |
npx tsc
npx vite build
- name: Package and Publish macOS arm64 (unsigned DMG)
- name: Package and Publish macOS arm64 (unsigned DMG + ZIP)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_IDENTITY_AUTO_DISCOVERY: "false"
shell: bash
run: |
npx electron-builder --mac dmg --arm64 --publish always
set -euo pipefail
export ELECTRON_BUILDER_BINARIES_MIRROR="https://github.com/electron-userland/electron-builder-binaries/releases/download/"
echo "Using ELECTRON_BUILDER_BINARIES_MIRROR=$ELECTRON_BUILDER_BINARIES_MIRROR"
if ! npx electron-builder --mac dmg zip --arm64 --publish always '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}'; then
echo "::warning::DMG packaging failed (hdiutil instability on runner). Retrying with ZIP only."
npx electron-builder --mac zip --arm64 --publish always '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}'
fi
- name: Inject minimumVersion into latest yml
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
source .github/scripts/release-utils.sh
TAG=${GITHUB_REF_NAME}
REPO=${{ github.repository }}
MINIMUM_VERSION="4.1.7"
wait_for_release_id "$REPO" "$TAG" 12 2 >/dev/null
for YML_FILE in latest-mac.yml latest-arm64-mac.yml; do
if ! retry_cmd 5 3 gh release download "$TAG" --repo "$REPO" --pattern "$YML_FILE" --output "/tmp/$YML_FILE"; then
echo "Skip $YML_FILE because download failed after retries."
continue
fi
if ! grep -q 'minimumVersion' "/tmp/$YML_FILE"; then
echo "minimumVersion: $MINIMUM_VERSION" >> "/tmp/$YML_FILE"
fi
retry_cmd 5 3 gh release upload "$TAG" --repo "$REPO" "/tmp/$YML_FILE" --clobber
done
release-linux:
runs-on: ubuntu-latest
@@ -63,18 +93,23 @@ jobs:
with:
node-version: 24
cache: "npm"
- name: Install Dependencies
run: npm install
- name: Ensure linux key helper is executable
shell: bash
run: |
[ -f "resources/key/linux/x64/xkey_helper" ] && chmod +x "resources/key/linux/x64/xkey_helper" || echo "File not found"
- 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
node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json','utf8'));p.version='$VERSION';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')"
- name: Build Frontend & Type Check
shell: bash
run: |
npx tsc
npx vite build
@@ -83,7 +118,24 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
npx electron-builder --linux --publish always
npx electron-builder --linux --publish always '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}'
- name: Inject minimumVersion into latest yml
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
source .github/scripts/release-utils.sh
TAG=${GITHUB_REF_NAME}
REPO=${{ github.repository }}
MINIMUM_VERSION="4.1.7"
wait_for_release_id "$REPO" "$TAG" 12 2 >/dev/null
retry_cmd 5 3 gh release download "$TAG" --repo "$REPO" --pattern "latest-linux.yml" --output "/tmp/latest-linux.yml" || true
if [ -f /tmp/latest-linux.yml ] && ! grep -q 'minimumVersion' /tmp/latest-linux.yml; then
echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest-linux.yml
retry_cmd 5 3 gh release upload "$TAG" --repo "$REPO" /tmp/latest-linux.yml --clobber
fi
release:
runs-on: windows-latest
@@ -99,7 +151,6 @@ jobs:
with:
node-version: 24
cache: 'npm'
- name: Install Dependencies
run: npm install
@@ -108,9 +159,10 @@ jobs:
run: |
VERSION=${GITHUB_REF_NAME#v}
echo "Syncing package.json version to $VERSION"
npm version $VERSION --no-git-tag-version --allow-same-version
node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json','utf8'));p.version='$VERSION';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')"
- name: Build Frontend & Type Check
shell: bash
run: |
npx tsc
npx vite build
@@ -119,7 +171,24 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
npx electron-builder --win nsis --x64 --publish always '--config.artifactName=${productName}-${version}-x64-Setup.${ext}'
npx electron-builder --win nsis --x64 --publish always '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}' '--config.artifactName=${productName}-${version}-x64-Setup.${ext}'
- name: Inject minimumVersion into latest yml
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
source .github/scripts/release-utils.sh
TAG=${GITHUB_REF_NAME}
REPO=${{ github.repository }}
MINIMUM_VERSION="4.1.7"
wait_for_release_id "$REPO" "$TAG" 12 2 >/dev/null
retry_cmd 5 3 gh release download "$TAG" --repo "$REPO" --pattern "latest.yml" --output "/tmp/latest.yml" || true
if [ -f /tmp/latest.yml ] && ! grep -q 'minimumVersion' /tmp/latest.yml; then
echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest.yml
retry_cmd 5 3 gh release upload "$TAG" --repo "$REPO" /tmp/latest.yml --clobber
fi
release-windows-arm64:
runs-on: windows-latest
@@ -135,7 +204,6 @@ jobs:
with:
node-version: 24
cache: 'npm'
- name: Install Dependencies
run: npm install
@@ -144,9 +212,10 @@ jobs:
run: |
VERSION=${GITHUB_REF_NAME#v}
echo "Syncing package.json version to $VERSION"
npm version $VERSION --no-git-tag-version --allow-same-version
node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json','utf8'));p.version='$VERSION';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')"
- name: Build Frontend & Type Check
shell: bash
run: |
npx tsc
npx vite build
@@ -155,7 +224,24 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
npx electron-builder --win nsis --arm64 --publish always '--config.publish.channel=latest-arm64' '--config.artifactName=${productName}-${version}-arm64-Setup.${ext}'
npx electron-builder --win nsis --arm64 --publish always '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}' '--config.publish.channel=latest-arm64' '--config.artifactName=${productName}-${version}-arm64-Setup.${ext}'
- name: Inject minimumVersion into latest yml
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
source .github/scripts/release-utils.sh
TAG=${GITHUB_REF_NAME}
REPO=${{ github.repository }}
MINIMUM_VERSION="4.1.7"
wait_for_release_id "$REPO" "$TAG" 12 2 >/dev/null
retry_cmd 5 3 gh release download "$TAG" --repo "$REPO" --pattern "latest-arm64.yml" --output "/tmp/latest-arm64.yml" || true
if [ -f /tmp/latest-arm64.yml ] && ! grep -q 'minimumVersion' /tmp/latest-arm64.yml; then
echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest-arm64.yml
retry_cmd 5 3 gh release upload "$TAG" --repo "$REPO" /tmp/latest-arm64.yml --clobber
fi
update-release-notes:
runs-on: ubuntu-latest
@@ -172,12 +258,14 @@ jobs:
shell: bash
run: |
set -euo pipefail
source .github/scripts/release-utils.sh
TAG="$GITHUB_REF_NAME"
REPO="$GITHUB_REPOSITORY"
RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG"
wait_for_release_id "$REPO" "$TAG" 12 2 >/dev/null
ASSETS_JSON="$(gh release view "$TAG" --repo "$REPO" --json assets)"
capture_cmd_with_retry ASSETS_JSON 8 3 gh release view "$TAG" --repo "$REPO" --json assets
pick_asset() {
local pattern="$1"
@@ -190,7 +278,9 @@ jobs:
fi
WINDOWS_ARM64_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("arm64.*\\.exe$"))][0] // ""')"
MAC_ASSET="$(pick_asset "\\.dmg$")"
LINUX_DEB_ASSET="$(pick_asset "\\.deb$")"
if [ -z "$MAC_ASSET" ]; then
MAC_ASSET="$(pick_asset "arm64\\.zip$")"
fi
LINUX_TAR_ASSET="$(pick_asset "\\.tar\\.gz$")"
LINUX_APPIMAGE_ASSET="$(pick_asset "\\.AppImage$")"
@@ -204,7 +294,6 @@ jobs:
WINDOWS_URL="$(build_link "$WINDOWS_ASSET")"
WINDOWS_ARM64_URL="$(build_link "$WINDOWS_ARM64_ASSET")"
MAC_URL="$(build_link "$MAC_ASSET")"
LINUX_DEB_URL="$(build_link "$LINUX_DEB_ASSET")"
LINUX_TAR_URL="$(build_link "$LINUX_TAR_ASSET")"
LINUX_APPIMAGE_URL="$(build_link "$LINUX_APPIMAGE_ASSET")"
@@ -216,20 +305,49 @@ jobs:
[点击加入 Telegram 频道](https://t.me/weflow_cc)
## 下载
- Windows x64Win10+: ${WINDOWS_URL:-$RELEASE_PAGE}
- Windows arm64: ${WINDOWS_ARM64_URL:-$RELEASE_PAGE}
- macOSM系列芯片: ${MAC_URL:-$RELEASE_PAGE}
- Linux (.deb) (即将废弃): ${LINUX_DEB_URL:-$RELEASE_PAGE}
- Linux (.tar.gz): ${LINUX_TAR_URL:-$RELEASE_PAGE}
- linux (.AppImage): ${LINUX_APPIMAGE_URL:-$RELEASE_PAGE}
- Windows x64Win10+: [点击下载](${WINDOWS_URL:-$RELEASE_PAGE})
- Windows arm64: [点击下载](${WINDOWS_ARM64_URL:-$RELEASE_PAGE})
- macOSM系列芯片: [点击下载](${MAC_URL:-$RELEASE_PAGE})
- Linux (.tar.gz): [点击下载](${LINUX_TAR_URL:-$RELEASE_PAGE})
- Linux (.AppImage): [点击下载](${LINUX_APPIMAGE_URL:-$RELEASE_PAGE})
## macOS 安装提示(未知来源)
- 若打开时提示“来自未知开发者”或“无法验证开发者”,请到「系统设置 -> 隐私与安全性」中允许打开该应用。
- 如果仍被系统拦截,请在终端执行以下命令去除隔离标记:
- `xattr -dr com.apple.quarantine "/Applications/WeFlow.app"`
## macOS 安装提示
- 如果被系统提示已损坏,你需要在终端执行以下命令去除隔离标记:
- \`xattr -dr com.apple.quarantine "/Applications/WeFlow.app"\`
- 执行后重新打开 WeFlow。
> 如果某个平台链接暂时未生成,可进入完整发布页查看全部资源:$RELEASE_PAGE
> 如果某个平台链接暂时未生成,可进入[完整发布页]($RELEASE_PAGE)查看全部资源
EOF
gh release edit "$TAG" --repo "$REPO" --notes-file release_notes.md
retry_cmd 5 3 gh release edit "$TAG" --repo "$REPO" --notes-file release_notes.md
deploy-aur:
runs-on: ubuntu-latest
needs: [release-linux]
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Checkout code
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Update PKGBUILD version
run: |
NEW_VER=$(echo "${{ github.ref_name }}" | sed 's/^v//')
sed -i "s/^pkgver=.*/pkgver=${NEW_VER}/" resources/installer/linux/PKGBUILD
sed -i "s/^pkgrel=.*/pkgrel=1/" resources/installer/linux/PKGBUILD
- name: Publish AUR package
uses: KSXGitHub/github-actions-deploy-aur@master
with:
pkgname: weflow
pkgbuild: resources/installer/linux/PKGBUILD
updpkgsums: true
assets: |
resources/installer/linux/weflow.desktop
resources/installer/linux/icon.png
ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
commit_username: H3CoF6
commit_email: h3cof6@gmail.com
ssh_keyscan_types: ed25519

5
.gitignore vendored
View File

@@ -56,6 +56,8 @@ Thumbs.db
*.aps
wcdb/
!resources/wcdb/
!resources/wcdb/**
xkey/
server/
*info
@@ -71,3 +73,6 @@ resources/wx_send
pnpm-lock.yaml
/pnpm-workspace.yaml
wechat-research-site
.codex
weflow-web-offical
/Wedecrypt

23
.gitleaks.toml Normal file
View File

@@ -0,0 +1,23 @@
title = "Gitleaks Config"
[extend]
# 继承默认规则
useDefault = true
# 排除误报路径
[[rules]]
id = "curl-auth-header"
[rules.allowlist]
paths = [
'''docs/HTTP-API\.md'''
]
regexes = [
'''YOUR_TOKEN'''
]
[[rules]]
id = "generic-api-key"
[rules.allowlist]
paths = [
'''src/pages/ChatPage\.tsx'''
]

4
.npmrc
View File

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

View File

@@ -1,32 +1,23 @@
# WeFlow
WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析与导出工具。它可以实时获取你的微信聊天记录并将其导出,还可以根据你的聊天记录为你生成独一无二的分析报告
---
WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析与导出工具。它可以实时获取你的微信聊天记录并将其导出,还可以根据你的聊天记录为你生成独一无二的分析报告
<p align="center">
<img src="app.png" alt="WeFlow" width="90%">
<img src="app.png" alt="WeFlow 应用预览" width="90%">
</p>
---
<p align="center">
<a href="https://github.com/hicccc77/WeFlow/stargazers">
<img src="https://img.shields.io/github/stars/hicccc77/WeFlow?style=flat-square" alt="Stargazers">
</a>
<a href="https://github.com/hicccc77/WeFlow/network/members">
<img src="https://img.shields.io/github/forks/hicccc77/WeFlow?style=flat-square" alt="Forks">
</a>
<a href="https://github.com/hicccc77/WeFlow/issues">
<img src="https://img.shields.io/github/issues/hicccc77/WeFlow?style=flat-square" alt="Issues">
<img src="https://gh-down-badges.linkof.link/hicccc77/WeFlow/" alt="Downloads" />
</a>
<a href="https://t.me/weflow_cc">
<img src="https://img.shields.io/badge/Telegram%20频道-0088cc?style=flat-square&logo=telegram&logoColor=0088cc&labelColor=white" alt="Telegram">
</a>
<!-- 第一行修复样式 -->
<a href="https://github.com/hicccc77/WeFlow/stargazers"><img src="https://img.shields.io/github/stars/hicccc77/WeFlow?style=flat&label=Stars&labelColor=1F2937&color=2563EB" alt="Stargazers"></a>
<a href="https://github.com/hicccc77/WeFlow/network/members"><img src="https://img.shields.io/github/forks/hicccc77/WeFlow?style=flat&label=Forks&labelColor=1F2937&color=7C3AED" alt="Forks"></a>
<a href="https://github.com/hicccc77/WeFlow/issues"><img src="https://img.shields.io/github/issues/hicccc77/WeFlow?style=flat&label=Issues&labelColor=1F2937&color=D97706" alt="Issues"></a>
<a href="https://github.com/hicccc77/WeFlow/releases"><img src="https://img.shields.io/github/downloads/hicccc77/WeFlow/total?style=flat&label=Downloads&labelColor=1F2937&color=059669" alt="Downloads"></a>
<br><br>
<!-- 第二行:电报矮一点(22px),排名高一点(32px),使用 vertical-align: middle 居中对齐 -->
<a href="https://t.me/weflow_cc"><img src="https://img.shields.io/badge/Telegram-频道-1D9BF0?style=flat&logo=telegram&logoColor=white&labelColor=1F2937&color=1D9BF0" alt="Telegram Channel" style="height: 22px; vertical-align: middle;"></a>
<a href="https://www.star-history.com/hicccc77/weflow"><img src="https://api.star-history.com/badge?repo=hicccc77/WeFlow&theme=dark" alt="Star History Rank" style="height: 32px; vertical-align: middle;"></a>
</p>
> [!TIP]
> 如果导出聊天记录后,想深入分析聊天内容可以试试 [ChatLab](https://chatlab.fun/)
@@ -45,18 +36,18 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
## 支持平台与设备
| 平台 | 设备/架构 | 安装包 |
|------|----------|--------|
| Windows | Windows10+、x64amd64 | `.exe` |
| macOS | Apple SiliconM 系列arm64 | `.dmg` |
| Linux | x64 设备amd64 | `.deb``.tar.gz` |
| Linux | x64 设备amd64 | `.AppImage``.tar.gz` |
## 快速开始
若你只想使用成品版本,可前往 [Releases](https://github.com/hicccc77/WeFlow/releases) 下载并安装。
> ArchLinux 用户可以选择 `yay -S weflow` 快速安装
## 详细功能清单
当前版本已支持以下能力:
@@ -64,6 +55,7 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
| 功能模块 | 说明 |
|---------|------|
| **聊天** | 解密聊天中的图片、视频、实况(仅支持谷歌协议拍摄的实况);支持**修改**、删除**本地**消息;实时刷新最新消息,无需生成解密中间数据库 |
| **消息防撤回** | 防止其他人发送的消息被撤回 |
| **实时弹窗通知** | 新消息到达时提供桌面弹窗提醒,便于及时查看重要会话,提供黑白名单功能 |
| **私聊分析** | 统计好友间消息数量;分析消息类型与发送比例;查看消息时段分布等 |
| **群聊分析** | 查看群成员详细信息;分析群内发言排行、活跃时段和媒体内容 |
@@ -88,7 +80,6 @@ WeFlow 提供本地 HTTP API 服务,支持通过接口查询消息数据,可
完整接口文档:[点击查看](docs/HTTP-API.md)
## 面向开发者
如果你想从源码构建或为项目贡献代码,请遵循以下步骤:
@@ -103,7 +94,6 @@ npm install
# 3. 运行应用(开发模式)
npm run dev
```
## 致谢
@@ -115,18 +105,16 @@ npm run dev
如果 WeFlow 确实帮到了你,可以考虑请我们喝杯咖啡:
> TRC20 **Address:** `TZCtAw8CaeARWZBfvjidCnTcfnAtf6nvS6`
> TRC20 **Address:** `TZCtAw8CaeARWZBfvjidCnTcfnAtf6nvS6`
## Star History
<a href="https://www.star-history.com/#hicccc77/WeFlow&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=hicccc77/WeFlow&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=hicccc77/WeFlow&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=hicccc77/WeFlow&type=date&legend=top-left" />
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=hicccc77/WeFlow&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=hicccc77/WeFlow&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=hicccc77/WeFlow&type=date&legend=top-left" />
</picture>
</a>
<div align="center">

View File

@@ -1,6 +1,6 @@
# WeFlow HTTP API / Push 文档
WeFlow 提供本地 HTTP API便于外部脚本或工具读取聊天记录、会话、联系人、群成员和导出的媒体文件也支持在检测到新消息后通过固定 SSE 地址主动推送消息事件。
WeFlow 提供本地 HTTP API已支持GET 和 POST请求,便于外部脚本或工具读取聊天记录、会话、联系人、群成员和导出的媒体文件;也支持在检测到新消息后通过固定 SSE 地址主动推送消息事件。
## 启用方式
@@ -11,17 +11,27 @@ WeFlow 提供本地 HTTP API便于外部脚本或工具读取聊天记录、
- 基础地址:`http://127.0.0.1:5031`
- 可选开启 `主动推送`,检测到新收到的消息后会通过 `GET /api/v1/push/messages` 推送给 SSE 订阅端
**状态记忆**API 服务和主动推送的状态及端口会自动保存,重启 WeFlow 后会自动恢复运行。
## 鉴权规范
**鉴权规范 (Access Token)** 除健康检查接口外,所有 `/api/v1/*` 接口均受 Token 保护。支持三种传参方式(任选其一):
1. **HTTP Header (推荐)**: `Authorization: Bearer <您的Token>`
2. **Query 参数**: `?access_token=<您的Token>`SSE 长连接推荐此方式)
3. **JSON Body**: `{"access_token": "<您的Token>"}`(仅限 POST 请求)
## 接口列表
- `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/*`
- `GET|POST /health`
- `GET|POST /api/v1/health`
- `GET|POST /api/v1/push/messages`
- `GET|POST /api/v1/messages`
- `GET|POST /api/v1/sessions`
- `GET /api/v1/sessions/:id/messages` (ChatLab Pull)
- `GET|POST /api/v1/contacts`
- `GET|POST /api/v1/group-members`
- `GET|POST /api/v1/media/*`
---
@@ -76,24 +86,27 @@ GET /api/v1/push/messages
- `sourceName`
- `groupName`(仅群聊)
- `content`
- `timestamp`(消息时间,秒级 Unix 时间戳)
### 示例
```bash
curl -N "http://127.0.0.1:5031/api/v1/push/messages"
curl -N "http://127.0.0.1:5031/api/v1/push/messages?access_token=YOUR_TOKEN
```
示例事件:
```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":"[图片]"}
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":"[图片]","timestamp":1760000123}
```
---
## 3. 获取消息
> 当使用 POST 时,请将参数放在 JSON Body 中Content-Type: application/json
读取指定会话的消息,支持原始 JSON 和 ChatLab 格式。
**请求**
@@ -104,21 +117,21 @@ GET /api/v1/messages
### 参数
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `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` 时控制表情导出 |
| 参数 | 类型 | 必填 | 说明 |
| --------- | ------ | ---- | ----------------------------------------------------- |
| `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` 时控制表情导出 |
### 示例
@@ -231,6 +244,8 @@ curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&media=1&image=1&
## 4. 获取会话列表
> 当使用 POST 时,请将参数放在 JSON Body 中Content-Type: application/json
**请求**
```http
@@ -239,10 +254,10 @@ GET /api/v1/sessions
### 参数
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `keyword` | string | 否 | 匹配 `username``displayName` |
| `limit` | number | 否 | 默认 `100` |
| 参数 | 类型 | 必填 | 说明 |
| --------- | ------ | ---- | -------------------------------- |
| `keyword` | string | 否 | 匹配 `username``displayName` |
| `limit` | number | 否 | 默认 `100` |
### 响应字段
@@ -274,8 +289,134 @@ GET /api/v1/sessions
---
## 4.1 获取会话列表ChatLab 格式)
`format=chatlab` 时,返回 ChatLab Pull 协议兼容格式,可直接作为 ChatLab 远程数据源。
**请求**
```http
GET /api/v1/sessions?format=chatlab
```
### 参数
| 参数 | 类型 | 必填 | 说明 |
| --------- | ------ | ---- | -------------------------------- |
| `format` | string | 是 | 设为 `chatlab` |
| `keyword` | string | 否 | 匹配 `username``displayName` |
| `limit` | number | 否 | 默认 `100` |
### 响应
```json
{
"sessions": [
{
"id": "xxx@chatroom",
"name": "项目群",
"platform": "wechat",
"type": "group",
"messageCount": 58000,
"lastMessageAt": 1738713600
}
]
}
```
| 字段 | 说明 |
| --------------- | ----------------------------------- |
| `id` | 会话 ID微信 username |
| `name` | 会话显示名称 |
| `platform` | 固定 `wechat` |
| `type` | `group`(群聊)或 `private`(私聊) |
| `messageCount` | 消息数量(估算值,可能不精确) |
| `lastMessageAt` | 最后消息的秒级 Unix 时间戳 |
---
## 4.2 拉取会话消息ChatLab Pull
返回 ChatLab 标准格式的聊天数据,支持增量拉取和分页。
**请求**
```http
GET /api/v1/sessions/:id/messages
```
### 参数
| 参数 | 类型 | 必填 | 说明 |
| -------- | ------ | ---- | ---------------------------------------- |
| `:id` | string | 是 | 会话 IDPath 参数) |
| `since` | number | 否 | 秒级 Unix 时间戳,仅返回此时间之后的消息 |
| `end` | number | 否 | 秒级 Unix 时间戳,时间上界 |
| `limit` | number | 否 | 单次返回上限,默认且最大 `5000` |
| `offset` | number | 否 | 分页偏移,默认 `0` |
### 响应
返回 ChatLab 标准 JSON 格式,外加 `sync` 分页块:
```json
{
"chatlab": {
"version": "0.0.2",
"exportedAt": 1738713600,
"generator": "WeFlow"
},
"meta": {
"name": "项目群",
"platform": "wechat",
"type": "group",
"groupId": "xxx@chatroom",
"ownerId": "wxid_xxx"
},
"members": [
{
"platformId": "wxid_a",
"accountName": "张三",
"groupNickname": "产品",
"avatar": "https://example.com/avatar.jpg"
}
],
"messages": [
{
"sender": "wxid_a",
"accountName": "张三",
"timestamp": 1738713600,
"type": 0,
"content": "你好",
"platformMessageId": "123456"
}
],
"sync": {
"hasMore": true,
"nextSince": 1738713600,
"nextOffset": 5000,
"watermark": 1738714000
}
}
```
### sync 块
| 字段 | 说明 |
| ------------ | -------------------------------- |
| `hasMore` | 是否还有更多数据 |
| `nextSince` | 下次请求的 `since` 值 |
| `nextOffset` | 下次请求的 `offset` 值 |
| `watermark` | 本次拉取的时间上界(秒级时间戳) |
**ChatLab 对接方式**:在 ChatLab 设置中添加远程数据源,`baseUrl``http://127.0.0.1:5031/api/v1`Token 填 WeFlow 中配置的 API Token。
---
## 5. 获取联系人列表
> 当使用 POST 时,请将参数放在 JSON Body 中Content-Type: application/json
**请求**
```http
@@ -284,10 +425,10 @@ GET /api/v1/contacts
### 参数
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `keyword` | string | 否 | 匹配 `username``nickname``remark``displayName` |
| `limit` | number | 否 | 默认 `100` |
| 参数 | 类型 | 必填 | 说明 |
| --------- | ------ | ---- | ---------------------------------------------------- |
| `keyword` | string | 否 | 匹配 `username``nickname``remark``displayName` |
| `limit` | number | 否 | 默认 `100` |
### 响应字段
@@ -325,6 +466,8 @@ GET /api/v1/contacts
## 6. 获取群成员列表
> 当使用 POST 时,请将参数放在 JSON Body 中Content-Type: application/json
返回群成员的 `wxid`、群昵称、备注、微信号等信息。
**请求**
@@ -335,12 +478,12 @@ GET /api/v1/group-members
### 参数
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `chatroomId` | string | 是 | 群 ID兼容使用 `talker` 传入 |
| `includeMessageCounts` | string | 否 | `1/true` 时附带成员发言数 |
| `withCounts` | string | 否 | `includeMessageCounts` 的别名 |
| `forceRefresh` | string | 否 | `1/true` 时跳过内存缓存强制刷新 |
| 参数 | 类型 | 必填 | 说明 |
| ---------------------- | ------ | ---- | ------------------------------- |
| `chatroomId` | string | 是 | 群 ID兼容使用 `talker` 传入 |
| `includeMessageCounts` | string | 否 | `1/true` 时附带成员发言数 |
| `withCounts` | string | 否 | `includeMessageCounts` 的别名 |
| `forceRefresh` | string | 否 | `1/true` 时跳过内存缓存强制刷新 |
### 响应字段
@@ -415,7 +558,125 @@ curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom&include
---
## 7. 访问导出媒体
## 7. 朋友圈接口
### 7.1 获取朋友圈时间线
```http
GET /api/v1/sns/timeline
```
参数:
| 参数 | 类型 | 必填 | 说明 |
| ----------- | ------ | ---- | ------------------------------------------------------------ |
| `limit` | number | 否 | 返回数量,默认 20范围 `1~200` |
| `offset` | number | 否 | 偏移量,默认 0 |
| `usernames` | string | 否 | 发布者过滤,逗号分隔,如 `wxid_a,wxid_b` |
| `keyword` | string | 否 | 关键词过滤(正文) |
| `start` | string | 否 | 开始时间,支持 `YYYYMMDD` 或秒/毫秒时间戳 |
| `end` | string | 否 | 结束时间,支持 `YYYYMMDD` 或秒/毫秒时间戳 |
| `media` | number | 否 | 是否返回可直接访问的媒体地址,默认 `1` |
| `replace` | number | 否 | `media=1` 时,是否用解析地址覆盖 `media.url/thumb`,默认 `1` |
| `inline` | number | 否 | `media=1` 时,是否内联返回 `data:` URL默认 `0` |
示例:
```bash
curl "http://127.0.0.1:5031/api/v1/sns/timeline?limit=5"
curl "http://127.0.0.1:5031/api/v1/sns/timeline?usernames=wxid_a,wxid_b&keyword=旅行"
curl "http://127.0.0.1:5031/api/v1/sns/timeline?limit=3&media=1&replace=1"
curl "http://127.0.0.1:5031/api/v1/sns/timeline?limit=3&media=1&inline=1"
```
媒体字段说明(`media=1`
- `media[].url/thumb`:你应该优先直接使用的字段。
- `replace=1`(默认)时,`media[].url/thumb` 会直接被替换成可访问地址,等价于 `resolvedUrl/resolvedThumbUrl`
- `replace=0` 时,`media[].url/thumb` 仍保留微信原始地址;这时再结合下面的 `raw/proxy/resolved` 字段自己决定用哪一个。
- `media[].rawUrl/rawThumb`:原始朋友圈地址
- `media[].proxyUrl/proxyThumbUrl`:可直接访问的代理地址
- `media[].resolvedUrl/resolvedThumbUrl`:最终可用地址(`inline=1` 时可能是 `data:` URL
- `media[].token/key/encIdx`:微信源数据里的访问/解密参数。通常不需要你自己处理;如果你手动调用 `/api/v1/sns/media/proxy`,把当前条目的 `url``key` 原样传回即可。
- `media[].livePhoto`:实况图的视频部分。外层 `media[].url/thumb` 仍是封面图,`livePhoto` 内部会再提供一组自己的 `url/thumb/raw*/proxy*/resolved*` 字段。
- `media=0` 时,不会补充 `raw*/proxy*/resolved*`,接口只返回原始 `url/thumb` 以及源字段(如 `key/token/encIdx`)。
### 7.2 获取朋友圈发布者
```http
GET /api/v1/sns/usernames
```
### 7.3 获取朋友圈导出统计
```http
GET /api/v1/sns/export/stats
```
参数:
| 参数 | 类型 | 必填 | 说明 |
| ------ | ------ | ---- | ---------------------------- |
| `fast` | number | 否 | `1` 使用快速统计(优先缓存) |
### 7.4 朋友圈媒体代理
```http
GET /api/v1/sns/media/proxy
```
参数:
| 参数 | 类型 | 必填 | 说明 |
| ----- | ------------- | ---- | ------------------------ |
| `url` | string | 是 | 媒体原始 URL |
| `key` | string/number | 否 | 解密 key部分资源需要 |
### 7.5 导出朋友圈
```http
POST /api/v1/sns/export
Content-Type: application/json
```
Body 示例:
```json
{
"outputDir": "C:\\Users\\Alice\\Desktop\\sns-export",
"format": "json",
"usernames": "wxid_a,wxid_b",
"keyword": "旅行",
"exportMedia": true,
"exportImages": true,
"exportLivePhotos": true,
"exportVideos": true,
"start": "20250101",
"end": "20251231"
}
```
`format` 支持:`json``html``arkmejson`(兼容写法:`arkme-json`)。
### 7.6 朋友圈防删开关
```http
GET /api/v1/sns/block-delete/status
POST /api/v1/sns/block-delete/install
POST /api/v1/sns/block-delete/uninstall
```
### 7.7 删除单条朋友圈
```http
DELETE /api/v1/sns/post/{postId}
```
---
## 8. 访问导出媒体
> 当使用 POST 时,请将参数放在 JSON Body 中Content-Type: application/json
通过消息接口启用 `media=1` 后,接口会先把图片、语音、视频、表情导出到本地缓存目录,再返回可访问的 HTTP 地址。
@@ -436,15 +697,15 @@ curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/emojis/emoji_300.gif"
### 支持的 Content-Type
| 扩展名 | Content-Type |
| --- | --- |
| `.png` | `image/png` |
| 扩展名 | Content-Type |
| ---------------- | ------------ |
| `.png` | `image/png` |
| `.jpg` / `.jpeg` | `image/jpeg` |
| `.gif` | `image/gif` |
| `.webp` | `image/webp` |
| `.wav` | `audio/wav` |
| `.mp3` | `audio/mpeg` |
| `.mp4` | `video/mp4` |
| `.gif` | `image/gif` |
| `.webp` | `image/webp` |
| `.wav` | `audio/wav` |
| `.mp3` | `audio/mpeg` |
| `.mp4` | `video/mp4` |
常见错误响应:
@@ -456,24 +717,28 @@ curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/emojis/emoji_300.gif"
---
## 8. 使用示例
## 9. 使用示例
### 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"
Invoke-RestMethod "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom&includeMessageCounts=1"
$headers = @{ "Authorization" = "Bearer YOUR_TOKEN" }
$body = @{ talker = "wxid_xxx"; limit = 10 } | ConvertTo-Json
Invoke-RestMethod -Uri "http://127.0.0.1:5031/api/v1/messages" -Method POST -Headers $headers -Body $body -ContentType "application/json"
```
### cURL
```bash
curl http://127.0.0.1:5031/health
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"
# GET 带 Token Header
curl -H "Authorization: Bearer YOUR_TOKEN" "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx"
# POST 带 JSON Body
curl -X POST http://127.0.0.1:5031/api/v1/messages \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"talker": "xxx@chatroom", "chatlab": true}'
```
### Python
@@ -482,24 +747,26 @@ curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom"
import requests
BASE_URL = "http://127.0.0.1:5031"
headers = {"Authorization": "Bearer YOUR_TOKEN", "Content-Type": "application/json"}
messages = requests.get(
# POST 方式获取消息
messages = requests.post(
f"{BASE_URL}/api/v1/messages",
params={"talker": "xxx@chatroom", "limit": 50}
json={"talker": "xxx@chatroom", "limit": 50},
headers=headers
).json()
# GET 方式获取群成员
members = requests.get(
f"{BASE_URL}/api/v1/group-members",
params={"chatroomId": "xxx@chatroom", "includeMessageCounts": 1}
params={"chatroomId": "xxx@chatroom", "includeMessageCounts": 1},
headers=headers
).json()
print(messages)
print(members)
```
---
## 9. 注意事项
## 10. 注意事项
1. API 仅监听本机 `127.0.0.1`,不对外网开放。
2. 使用前需要先在 WeFlow 中完成数据库连接。

View File

@@ -5,6 +5,9 @@ interface ExportWorkerConfig {
sessionIds: string[]
outputDir: string
options: ExportOptions
dbPath?: string
decryptKey?: string
myWxid?: string
resourcesPath?: string
userDataPath?: string
logEnabled?: boolean
@@ -29,6 +32,11 @@ async function run() {
wcdbService.setPaths(config.resourcesPath || '', config.userDataPath || '')
wcdbService.setLogEnabled(config.logEnabled === true)
exportService.setRuntimeConfig({
dbPath: config.dbPath,
decryptKey: config.decryptKey,
myWxid: config.myWxid
})
const result = await exportService.exportSessions(
Array.isArray(config.sessionIds) ? config.sessionIds : [],

View File

@@ -20,7 +20,7 @@ function looksLikeMd5(value: string): boolean {
function stripDatVariantSuffix(base: string): string {
const lower = base.toLowerCase()
const suffixes = ['_thumb', '.thumb', '_hd', '.hd', '_h', '.h', '_t', '.t', '_c', '.c']
const suffixes = ['_thumb', '.thumb', '_hd', '.hd', '_h', '.h', '_b', '.b', '_w', '.w', '_t', '.t', '_c', '.c']
for (const suffix of suffixes) {
if (lower.endsWith(suffix)) {
return lower.slice(0, -suffix.length)
@@ -71,8 +71,10 @@ function scoreDatName(fileName: string): number {
const lower = fileName.toLowerCase()
const baseLower = lower.endsWith('.dat') ? lower.slice(0, -4) : lower
if (baseLower.endsWith('_h') || baseLower.endsWith('.h')) return 600
if (baseLower.endsWith('_hd') || baseLower.endsWith('.hd')) return 550
if (baseLower.endsWith('_b') || baseLower.endsWith('.b')) return 520
if (baseLower.endsWith('_w') || baseLower.endsWith('.w')) return 510
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

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@ import { join, dirname } from 'path'
/**
* 强制将本地资源目录添加到 PATH 最前端,确保优先加载本地 DLL
* 解决系统中存在冲突版本的 DLL 导致的应用崩溃问题
* 解决系统中存在冲突版本的数据服务导致的应用崩溃问题
*/
function enforceLocalDllPriority() {
const isDev = !!process.env.VITE_DEV_SERVER_URL
@@ -35,5 +35,5 @@ function enforceLocalDllPriority() {
try {
enforceLocalDllPriority()
} catch (e) {
console.error('[WeFlow] Failed to enforce local DLL priority:', e)
console.error('[WeFlow] Failed to enforce local service priority:', e)
}

View File

@@ -1,4 +1,4 @@
import { contextBridge, ipcRenderer } from 'electron'
import { contextBridge, ipcRenderer } from 'electron'
// 暴露给渲染进程的 API
contextBridge.exposeInMainWorld('electronAPI', {
@@ -19,6 +19,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
onShow: (callback: (event: any, data: any) => void) => {
ipcRenderer.on('notification:show', callback)
return () => ipcRenderer.removeAllListeners('notification:show')
}, // 监听原本发送出来的navigate-to-session事件跳转到具体的会话
onNavigateToSession: (callback: (sessionId: string) => void) => {
const listener = (_: any, sessionId: string) => callback(sessionId)
ipcRenderer.on('navigate-to-session', listener)
return () => ipcRenderer.removeListener('navigate-to-session', listener)
}
},
@@ -53,6 +58,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
app: {
getDownloadsPath: () => ipcRenderer.invoke('app:getDownloadsPath'),
getVersion: () => ipcRenderer.invoke('app:getVersion'),
getLaunchAtStartupStatus: () => ipcRenderer.invoke('app:getLaunchAtStartupStatus'),
setLaunchAtStartup: (enabled: boolean) => ipcRenderer.invoke('app:setLaunchAtStartup', enabled),
checkForUpdates: () => ipcRenderer.invoke('app:checkForUpdates'),
downloadAndInstall: () => ipcRenderer.invoke('app:downloadAndInstall'),
ignoreUpdate: (version: string) => ipcRenderer.invoke('app:ignoreUpdate', version),
@@ -64,7 +71,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.on('app:updateAvailable', (_, info) => callback(info))
return () => ipcRenderer.removeAllListeners('app:updateAvailable')
},
checkWayland: () => ipcRenderer.invoke('app:checkWayland'),
},
// 日志
@@ -104,7 +110,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke('window:respondCloseConfirm', action),
openAgreementWindow: () => ipcRenderer.invoke('window:openAgreementWindow'),
completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'),
openOnboardingWindow: () => ipcRenderer.invoke('window:openOnboardingWindow'),
openOnboardingWindow: (options?: { mode?: 'add-account' }) => ipcRenderer.invoke('window:openOnboardingWindow', options),
setTitleBarOverlay: (options: { symbolColor: string }) => ipcRenderer.send('window:setTitleBarOverlay', options),
openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) =>
ipcRenderer.invoke('window:openVideoPlayerWindow', videoPath, videoWidth, videoHeight),
@@ -188,6 +194,12 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke('chat:updateMessage', sessionId, localId, createTime, newContent),
deleteMessage: (sessionId: string, localId: number, createTime: number, dbPathHint?: string) =>
ipcRenderer.invoke('chat:deleteMessage', sessionId, localId, createTime, dbPathHint),
checkAntiRevokeTriggers: (sessionIds: string[]) =>
ipcRenderer.invoke('chat:checkAntiRevokeTriggers', sessionIds),
installAntiRevokeTriggers: (sessionIds: string[]) =>
ipcRenderer.invoke('chat:installAntiRevokeTriggers', sessionIds),
uninstallAntiRevokeTriggers: (sessionIds: string[]) =>
ipcRenderer.invoke('chat:uninstallAntiRevokeTriggers', sessionIds),
resolveTransferDisplayNames: (chatroomId: string, payerUsername: string, receiverUsername: string) =>
ipcRenderer.invoke('chat:resolveTransferDisplayNames', chatroomId, payerUsername, receiverUsername),
getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'),
@@ -218,6 +230,22 @@ contextBridge.exposeInMainWorld('electronAPI', {
getAllImageMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllImageMessages', sessionId),
getMessageDates: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDates', sessionId),
getMessageDateCounts: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDateCounts', sessionId),
getResourceMessages: (options?: {
sessionId?: string
types?: Array<'image' | 'video' | 'voice' | 'file'>
beginTimestamp?: number
endTimestamp?: number
limit?: number
offset?: number
}) => ipcRenderer.invoke('chat:getResourceMessages', options),
getMediaStream: (options?: {
sessionId?: string
mediaType?: 'image' | 'video' | 'all'
beginTimestamp?: number
endTimestamp?: number
limit?: number
offset?: number
}) => ipcRenderer.invoke('chat:getMediaStream', options),
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: { sessionId?: string; msgId: string; createTime?: number; text: string }) => void) => {
@@ -230,6 +258,24 @@ contextBridge.exposeInMainWorld('electronAPI', {
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),
getMyFootprintStats: (
beginTimestamp: number,
endTimestamp: number,
options?: {
myWxid?: string
privateSessionIds?: string[]
groupSessionIds?: string[]
mentionLimit?: number
privateLimit?: number
mentionMode?: 'text_at_me' | string
}
) => ipcRenderer.invoke('chat:getMyFootprintStats', beginTimestamp, endTimestamp, options),
exportMyFootprint: (
beginTimestamp: number,
endTimestamp: number,
format: 'csv' | 'json',
filePath: string
) => ipcRenderer.invoke('chat:exportMyFootprint', beginTimestamp, endTimestamp, format, filePath),
onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => {
ipcRenderer.on('wcdb-change', callback)
return () => ipcRenderer.removeListener('wcdb-change', callback)
@@ -240,12 +286,41 @@ contextBridge.exposeInMainWorld('electronAPI', {
// 图片解密
image: {
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) =>
decrypt: (payload: {
sessionId?: string
imageMd5?: string
imageDatName?: string
createTime?: number
force?: boolean
preferFilePath?: boolean
hardlinkOnly?: boolean
disableUpdateCheck?: boolean
allowCacheIndex?: boolean
suppressEvents?: boolean
}) =>
ipcRenderer.invoke('image:decrypt', payload),
resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }) =>
resolveCache: (payload: {
sessionId?: string
imageMd5?: string
imageDatName?: string
createTime?: number
preferFilePath?: boolean
hardlinkOnly?: boolean
disableUpdateCheck?: boolean
allowCacheIndex?: boolean
suppressEvents?: boolean
}) =>
ipcRenderer.invoke('image:resolveCache', payload),
preload: (payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>) =>
ipcRenderer.invoke('image:preload', payloads),
resolveCacheBatch: (
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; preferFilePath?: boolean; hardlinkOnly?: boolean }>,
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean; preferFilePath?: boolean; hardlinkOnly?: boolean; suppressEvents?: boolean }
) => ipcRenderer.invoke('image:resolveCacheBatch', payloads, options),
preload: (
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }>,
options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean }
) => ipcRenderer.invoke('image:preload', payloads, options),
preloadHardlinkMd5s: (md5List: string[]) =>
ipcRenderer.invoke('image:preloadHardlinkMd5s', md5List),
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => {
const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => callback(payload)
ipcRenderer.on('image:updateAvailable', listener)
@@ -255,12 +330,33 @@ contextBridge.exposeInMainWorld('electronAPI', {
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)
},
onDecryptProgress: (callback: (payload: {
cacheKey: string
imageMd5?: string
imageDatName?: string
stage: 'queued' | 'locating' | 'decrypting' | 'writing' | 'done' | 'failed'
progress: number
status: 'running' | 'done' | 'error'
message?: string
}) => void) => {
const listener = (_: unknown, payload: {
cacheKey: string
imageMd5?: string
imageDatName?: string
stage: 'queued' | 'locating' | 'decrypting' | 'writing' | 'done' | 'failed'
progress: number
status: 'running' | 'done' | 'error'
message?: string
}) => callback(payload)
ipcRenderer.on('image:decryptProgress', listener)
return () => ipcRenderer.removeListener('image:decryptProgress', listener)
}
},
// 视频
video: {
getVideoInfo: (videoMd5: string) => ipcRenderer.invoke('video:getVideoInfo', videoMd5),
getVideoInfo: (videoMd5: string, options?: { includePoster?: boolean; posterFormat?: 'dataUrl' | 'fileUrl' }) => ipcRenderer.invoke('video:getVideoInfo', videoMd5, options),
parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content)
},
@@ -297,6 +393,7 @@ 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),
getGroupMemberAnalytics: (chatroomId: string, memberUsername: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMemberAnalytics', chatroomId, memberUsername, startTime, endTime),
getGroupMemberMessages: (
chatroomId: string,
memberUsername: string,
@@ -315,6 +412,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
generateReport: (year: number) => ipcRenderer.invoke('annualReport:generateReport', year),
exportImages: (payload: { baseDir: string; folderName: string; images: Array<{ name: string; dataUrl: string }> }) =>
ipcRenderer.invoke('annualReport:exportImages', payload),
captureCurrentWindow: () => ipcRenderer.invoke('annualReport:captureCurrentWindow'),
onAvailableYearsProgress: (callback: (payload: {
taskId: string
years?: number[]
@@ -409,7 +507,22 @@ contextBridge.exposeInMainWorld('electronAPI', {
uninstallBlockDeleteTrigger: () => ipcRenderer.invoke('sns:uninstallBlockDeleteTrigger'),
checkBlockDeleteTrigger: () => ipcRenderer.invoke('sns:checkBlockDeleteTrigger'),
deleteSnsPost: (postId: string) => ipcRenderer.invoke('sns:deleteSnsPost', postId),
downloadEmoji: (params: { url: string; encryptUrl?: string; aesKey?: string }) => ipcRenderer.invoke('sns:downloadEmoji', params)
downloadEmoji: (params: { url: string; encryptUrl?: string; aesKey?: string }) => ipcRenderer.invoke('sns:downloadEmoji', params),
getCacheMigrationStatus: () => ipcRenderer.invoke('sns:getCacheMigrationStatus'),
startCacheMigration: () => ipcRenderer.invoke('sns:startCacheMigration'),
onCacheMigrationProgress: (callback: (payload: any) => void) => {
const listener = (_event: unknown, payload: any) => callback(payload)
ipcRenderer.on('sns:cacheMigrationProgress', listener)
return () => ipcRenderer.removeListener('sns:cacheMigrationProgress', listener)
}
},
biz: {
listAccounts: (account?: string) => ipcRenderer.invoke('biz:listAccounts', account),
listMessages: (username: string, account?: string, limit?: number, offset?: number) =>
ipcRenderer.invoke('biz:listMessages', username, account, limit, offset),
listPayRecords: (account?: string, limit?: number, offset?: number) =>
ipcRenderer.invoke('biz:listPayRecords', account, limit, offset)
},
@@ -422,8 +535,34 @@ contextBridge.exposeInMainWorld('electronAPI', {
// HTTP API 服务
http: {
start: (port?: number) => ipcRenderer.invoke('http:start', port),
start: (port?: number, host?: string) => ipcRenderer.invoke('http:start', port, host),
stop: () => ipcRenderer.invoke('http:stop'),
status: () => ipcRenderer.invoke('http:status')
},
// AI 见解
insight: {
testConnection: () => ipcRenderer.invoke('insight:testConnection'),
getTodayStats: () => ipcRenderer.invoke('insight:getTodayStats'),
triggerTest: () => ipcRenderer.invoke('insight:triggerTest'),
generateFootprintInsight: (payload: {
rangeLabel: string
summary: {
private_inbound_people?: number
private_replied_people?: number
private_outbound_people?: number
private_reply_rate?: number
mention_count?: number
mention_group_count?: number
}
privateSegments?: Array<{ displayName?: string; session_id?: string; incoming_count?: number; outgoing_count?: number; message_count?: number; replied?: boolean }>
mentionGroups?: Array<{ displayName?: string; session_id?: string; count?: number }>
}) => ipcRenderer.invoke('insight:generateFootprintInsight', payload)
},
social: {
saveWeiboCookie: (rawInput: string) => ipcRenderer.invoke('social:saveWeiboCookie', rawInput),
validateWeiboUid: (uid: string) => ipcRenderer.invoke('social:validateWeiboUid', uid)
}
})

View File

@@ -59,6 +59,8 @@ export interface AnnualReportData {
initiatedChats: number
receivedChats: number
initiativeRate: number
topInitiatedFriend?: string
topInitiatedCount?: number
} | null
responseSpeed: {
avgResponseTime: number
@@ -1135,7 +1137,7 @@ class AnnualReportService {
const now = Date.now()
if (now - lastProgressAt > 200) {
let progress = 30
let progress: number
if (totalMessagesForProgress > 0) {
const ratio = Math.min(1, processedMessages / totalMessagesForProgress)
progress = 30 + Math.floor(ratio * 50)
@@ -1190,7 +1192,9 @@ class AnnualReportService {
topLiked: { username: string; displayName: string; avatarUrl?: string; count: number }[]
} | undefined
const snsStats = await wcdbService.getSnsAnnualStats(actualStartTime, actualEndTime)
const snsBeginTime = isAllTime ? 0 : actualStartTime
const snsEndTime = isAllTime ? Math.floor(Date.now() / 1000) : actualEndTime
const snsStats = await wcdbService.getSnsAnnualStats(snsBeginTime, snsEndTime)
if (snsStats.success && snsStats.data) {
const d = snsStats.data
@@ -1217,6 +1221,20 @@ class AnnualReportService {
}
}
// ALL YEARS 兼容:部分底层实现 begin/end 为 0 时会返回 0兜底使用导出统计总数。
if (isAllTime && (!snsStatsResult || Number(snsStatsResult.totalPosts || 0) <= 0)) {
const snsExportStats = await wcdbService.getSnsExportStats(cleanedWxid || rawWxid)
if (snsExportStats.success && snsExportStats.data) {
const fallbackTotalPosts = Math.max(0, Number(snsExportStats.data.totalPosts || 0))
snsStatsResult = {
totalPosts: fallbackTotalPosts,
typeCounts: snsStatsResult?.typeCounts,
topLikers: snsStatsResult?.topLikers || [],
topLiked: snsStatsResult?.topLiked || []
}
}
}
this.reportProgress('整理联系人信息...', 85, onProgress)
const contactIds = Array.from(contactStats.keys())
@@ -1346,16 +1364,27 @@ class AnnualReportService {
let socialInitiative: AnnualReportData['socialInitiative'] = null
let totalInitiated = 0
let totalReceived = 0
for (const stats of conversationStarts.values()) {
let topInitiatedSessionId = ''
let topInitiatedCount = 0
for (const [sessionId, stats] of conversationStarts.entries()) {
totalInitiated += stats.initiated
totalReceived += stats.received
if (stats.initiated > topInitiatedCount) {
topInitiatedCount = stats.initiated
topInitiatedSessionId = sessionId
}
}
const totalConversations = totalInitiated + totalReceived
if (totalConversations > 0) {
const topInitiatedInfo = topInitiatedSessionId ? contactInfoMap.get(topInitiatedSessionId) : null
socialInitiative = {
initiatedChats: totalInitiated,
receivedChats: totalReceived,
initiativeRate: Math.round((totalInitiated / totalConversations) * 1000) / 10
initiativeRate: Math.round((totalInitiated / totalConversations) * 1000) / 10,
topInitiatedFriend: topInitiatedCount > 0
? (topInitiatedInfo?.displayName || topInitiatedSessionId)
: undefined,
topInitiatedCount: topInitiatedCount > 0 ? topInitiatedCount : undefined
}
}

View File

@@ -0,0 +1,219 @@
import https from "https";
import http, { IncomingMessage } from "http";
import { promises as fs } from "fs";
import { join } from "path";
import { ConfigService } from "./config";
// 头像文件缓存服务 - 复用项目已有的缓存目录结构
export class AvatarFileCacheService {
private static instance: AvatarFileCacheService | null = null;
// 头像文件缓存目录
private readonly cacheDir: string;
// 头像URL -> 本地文件路径的内存缓存(仅追踪正在下载的)
private readonly pendingDownloads: Map<string, Promise<string | null>> =
new Map();
// LRU 追踪:文件路径->最后访问时间
private readonly lruOrder: string[] = [];
private readonly maxCacheFiles = 100;
private constructor() {
const basePath = ConfigService.getInstance().getCacheBasePath();
this.cacheDir = join(basePath, "avatar-files");
this.ensureCacheDir();
this.loadLruOrder();
}
public static getInstance(): AvatarFileCacheService {
if (!AvatarFileCacheService.instance) {
AvatarFileCacheService.instance = new AvatarFileCacheService();
}
return AvatarFileCacheService.instance;
}
private ensureCacheDir(): void {
// 同步确保目录存在(构造函数调用)
try {
fs.mkdir(this.cacheDir, { recursive: true }).catch(() => {});
} catch {}
}
private async ensureCacheDirAsync(): Promise<void> {
try {
await fs.mkdir(this.cacheDir, { recursive: true });
} catch {}
}
private getFilePath(url: string): string {
// 使用URL的hash作为文件名避免特殊字符问题
const hash = this.hashString(url);
return join(this.cacheDir, `avatar_${hash}.png`);
}
private hashString(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // 转换为32位整数
}
return Math.abs(hash).toString(16);
}
private async loadLruOrder(): Promise<void> {
try {
const entries = await fs.readdir(this.cacheDir);
// 按修改时间排序(旧的在前)
const filesWithTime: { file: string; mtime: number }[] = [];
for (const entry of entries) {
if (!entry.startsWith("avatar_") || !entry.endsWith(".png")) continue;
try {
const stat = await fs.stat(join(this.cacheDir, entry));
filesWithTime.push({ file: entry, mtime: stat.mtimeMs });
} catch {}
}
filesWithTime.sort((a, b) => a.mtime - b.mtime);
this.lruOrder.length = 0;
this.lruOrder.push(...filesWithTime.map((f) => f.file));
} catch {}
}
private updateLru(fileName: string): void {
const index = this.lruOrder.indexOf(fileName);
if (index > -1) {
this.lruOrder.splice(index, 1);
}
this.lruOrder.push(fileName);
}
private async evictIfNeeded(): Promise<void> {
while (this.lruOrder.length >= this.maxCacheFiles) {
const oldest = this.lruOrder.shift();
if (oldest) {
try {
await fs.rm(join(this.cacheDir, oldest));
console.log(`[AvatarFileCache] Evicted: ${oldest}`);
} catch {}
}
}
}
private async downloadAvatar(url: string): Promise<string | null> {
const localPath = this.getFilePath(url);
// 检查文件是否已存在
try {
await fs.access(localPath);
const fileName = localPath.split("/").pop()!;
this.updateLru(fileName);
return localPath;
} catch {}
await this.ensureCacheDirAsync();
await this.evictIfNeeded();
return new Promise<string | null>((resolve) => {
const options = {
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351",
Referer: "https://servicewechat.com/",
Accept:
"image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
"Accept-Encoding": "gzip, deflate, br",
"Accept-Language": "zh-CN,zh;q=0.9",
Connection: "keep-alive",
},
};
const callback = (res: IncomingMessage) => {
if (res.statusCode !== 200) {
resolve(null);
return;
}
const chunks: Buffer[] = [];
res.on("data", (chunk: Buffer) => chunks.push(chunk));
res.on("end", async () => {
try {
const buffer = Buffer.concat(chunks);
await fs.writeFile(localPath, buffer);
const fileName = localPath.split("/").pop()!;
this.updateLru(fileName);
console.log(
`[AvatarFileCache] Downloaded: ${url.substring(0, 50)}... -> ${localPath}`,
);
resolve(localPath);
} catch {
resolve(null);
}
});
res.on("error", () => resolve(null));
};
const req = url.startsWith("https")
? https.get(url, options, callback)
: http.get(url, options, callback);
req.on("error", () => resolve(null));
req.setTimeout(10000, () => {
req.destroy();
resolve(null);
});
});
}
/**
* 获取头像本地文件路径,如果需要会下载
* 同一URL并发调用会复用同一个下载任务
*/
async getAvatarPath(url: string): Promise<string | null> {
if (!url) return null;
// 检查是否有正在进行的下载
const pending = this.pendingDownloads.get(url);
if (pending) {
return pending;
}
// 发起新下载
const downloadPromise = this.downloadAvatar(url);
this.pendingDownloads.set(url, downloadPromise);
try {
const result = await downloadPromise;
return result;
} finally {
this.pendingDownloads.delete(url);
}
}
// 清理所有缓存文件App退出时调用
async clearCache(): Promise<void> {
try {
const entries = await fs.readdir(this.cacheDir);
for (const entry of entries) {
if (entry.startsWith("avatar_") && entry.endsWith(".png")) {
try {
await fs.rm(join(this.cacheDir, entry));
} catch {}
}
}
this.lruOrder.length = 0;
console.log("[AvatarFileCache] Cache cleared");
} catch {}
}
// 获取当前缓存的文件数量
async getCacheCount(): Promise<number> {
try {
const entries = await fs.readdir(this.cacheDir);
return entries.filter(
(e) => e.startsWith("avatar_") && e.endsWith(".png"),
).length;
} catch {
return 0;
}
}
}
export const avatarFileCache = AvatarFileCacheService.getInstance();

View File

@@ -0,0 +1,250 @@
import { join } from 'path'
import { readdirSync, existsSync } from 'fs'
import { wcdbService } from './wcdbService'
import { ConfigService } from './config'
import { chatService, Message } from './chatService'
import { ipcMain } from 'electron'
import { createHash } from 'crypto'
export interface BizAccount {
username: string
name: string
avatar: string
type: number
last_time: number
formatted_last_time: string
unread_count?: number
}
export interface BizMessage {
local_id: number
create_time: number
title: string
des: string
url: string
cover: string
content_list: any[]
}
export interface BizPayRecord {
local_id: number
create_time: number
title: string
description: string
merchant_name: string
merchant_icon: string
timestamp: number
formatted_time: string
}
export class BizService {
private configService: ConfigService
constructor() {
this.configService = new ConfigService()
}
private extractXmlValue(xml: string, tagName: string): string {
const regex = new RegExp(`<${tagName}>([\\s\\S]*?)</${tagName}>`, 'i')
const match = regex.exec(xml)
if (match) {
return match[1].replace(/<!\[CDATA\[/g, '').replace(/\]\]>/g, '').trim()
}
return ''
}
private parseBizContentList(xmlStr: string): any[] {
if (!xmlStr) return []
const contentList: any[] = []
try {
const itemRegex = /<item>([\s\S]*?)<\/item>/gi
let match: RegExpExecArray | null
while ((match = itemRegex.exec(xmlStr)) !== null) {
const itemXml = match[1]
const itemStruct = {
title: this.extractXmlValue(itemXml, 'title'),
url: this.extractXmlValue(itemXml, 'url'),
cover: this.extractXmlValue(itemXml, 'cover') || this.extractXmlValue(itemXml, 'thumburl'),
summary: this.extractXmlValue(itemXml, 'summary') || this.extractXmlValue(itemXml, 'digest')
}
if (itemStruct.title) contentList.push(itemStruct)
}
} catch (e) { }
return contentList
}
private parsePayXml(xmlStr: string): any {
if (!xmlStr) return null
try {
const title = this.extractXmlValue(xmlStr, 'title')
const description = this.extractXmlValue(xmlStr, 'des')
const merchantName = this.extractXmlValue(xmlStr, 'display_name') || '微信支付'
const merchantIcon = this.extractXmlValue(xmlStr, 'icon_url')
const pubTime = parseInt(this.extractXmlValue(xmlStr, 'pub_time') || '0')
if (!title && !description) return null
return { title, description, merchant_name: merchantName, merchant_icon: merchantIcon, timestamp: pubTime }
} catch (e) { return null }
}
async listAccounts(account?: string): Promise<BizAccount[]> {
try {
// 1. 获取公众号联系人列表
const contactsResult = await chatService.getContacts({ lite: true })
if (!contactsResult.success || !contactsResult.contacts) return []
const officialContacts = contactsResult.contacts.filter(c => c.type === 'official')
const usernames = officialContacts.map(c => c.username)
// 获取头像和昵称等补充信息
const enrichment = await chatService.enrichSessionsContactInfo(usernames)
const contactInfoMap = enrichment.success && enrichment.contacts ? enrichment.contacts : {}
const root = this.configService.get('dbPath')
const myWxid = this.configService.get('myWxid')
const accountWxid = account || myWxid
if (!root || !accountWxid) return []
const bizLatestTime: Record<string, number> = {}
const bizUnreadCount: Record<string, number> = {}
try {
const sessionsRes = await chatService.getSessions()
if (sessionsRes.success && sessionsRes.sessions) {
for (const session of sessionsRes.sessions) {
const uname = session.username || session.strUsrName || session.userName || session.id
// 适配日志中发现的字段,注意转为整型数字
const timeStr = session.lastTimestamp || session.sortTimestamp || session.last_timestamp || session.sort_timestamp || session.nTime || session.timestamp || '0'
const time = parseInt(timeStr.toString(), 10)
if (usernames.includes(uname) && time > 0) {
bizLatestTime[uname] = time
}
if (usernames.includes(uname)) {
const unread = Number(session.unreadCount ?? session.unread_count ?? 0)
bizUnreadCount[uname] = Number.isFinite(unread) ? Math.max(0, Math.floor(unread)) : 0
}
}
}
} catch (e) {
console.error('获取 Sessions 失败:', e)
}
// 3. 格式化时间显示
const formatBizTime = (ts: number) => {
if (!ts) return ''
const date = new Date(ts * 1000)
const now = new Date()
const isToday = date.toDateString() === now.toDateString()
if (isToday) return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })
const yesterday = new Date(now)
yesterday.setDate(now.getDate() - 1)
if (date.toDateString() === yesterday.toDateString()) return '昨天'
const isThisYear = date.getFullYear() === now.getFullYear()
if (isThisYear) return `${date.getMonth() + 1}/${date.getDate()}`
return `${date.getFullYear().toString().slice(-2)}/${date.getMonth() + 1}/${date.getDate()}`
}
// 4. 组装数据
const result: BizAccount[] = officialContacts.map(contact => {
const uname = contact.username
const info = contactInfoMap[uname]
const lastTime = bizLatestTime[uname] || 0
return {
username: uname,
name: info?.displayName || contact.displayName || uname,
avatar: info?.avatarUrl || '',
type: 0,
last_time: lastTime,
formatted_last_time: formatBizTime(lastTime),
unread_count: bizUnreadCount[uname] || 0
}
})
// 5. 补充公众号类型 (订阅号/服务号)
const contactDbPath = join(root, accountWxid, 'db_storage', 'contact', 'contact.db')
if (existsSync(contactDbPath)) {
const bizInfoRes = await wcdbService.execQuery('contact', contactDbPath, 'SELECT username, type FROM biz_info')
if (bizInfoRes.success && bizInfoRes.rows) {
const typeMap: Record<string, number> = {}
for (const r of bizInfoRes.rows) typeMap[r.username] = r.type
for (const acc of result) if (typeMap[acc.username] !== undefined) acc.type = typeMap[acc.username]
}
}
// 6. 排序输出
return result
.filter(acc => !acc.name.includes('广告'))
.sort((a, b) => {
if (a.username === 'gh_3dfda90e39d6') return -1 // 微信支付置顶
if (b.username === 'gh_3dfda90e39d6') return 1
return b.last_time - a.last_time // 按最新时间降序排列
})
} catch (e) {
console.error('获取账号列表发生错误:', e)
return []
}
}
async listMessages(username: string, account?: string, limit: number = 20, offset: number = 0): Promise<BizMessage[]> {
try {
// 仅保留核心路径:利用 chatService 的自动路由能力
const res = await chatService.getMessages(username, offset, limit)
if (!res.success || !res.messages) return []
return res.messages.map(msg => {
const bizMsg: BizMessage = {
local_id: msg.localId,
create_time: msg.createTime,
title: msg.linkTitle || msg.parsedContent || '',
des: msg.appMsgDesc || '',
url: msg.linkUrl || '',
cover: msg.linkThumb || msg.appMsgThumbUrl || '',
content_list: []
}
if (msg.rawContent) {
bizMsg.content_list = this.parseBizContentList(msg.rawContent)
if (bizMsg.content_list.length > 0 && !bizMsg.title) {
bizMsg.title = bizMsg.content_list[0].title
bizMsg.cover = bizMsg.cover || bizMsg.content_list[0].cover
}
}
return bizMsg
})
} catch (e) { return [] }
}
async listPayRecords(account?: string, limit: number = 20, offset: number = 0): Promise<BizPayRecord[]> {
const username = 'gh_3dfda90e39d6'
try {
const res = await chatService.getMessages(username, offset, limit)
if (!res.success || !res.messages) return []
const records: BizPayRecord[] = []
for (const msg of res.messages) {
if (!msg.rawContent) continue
const parsedData = this.parsePayXml(msg.rawContent)
if (parsedData) {
records.push({
local_id: msg.localId,
create_time: msg.createTime,
...parsedData,
timestamp: parsedData.timestamp || msg.createTime,
formatted_time: new Date((parsedData.timestamp || msg.createTime) * 1000).toLocaleString()
})
}
}
return records
} catch (e) { return [] }
}
registerHandlers() {
ipcMain.handle('biz:listAccounts', (_, account) => this.listAccounts(account))
ipcMain.handle('biz:listMessages', (_, username, account, limit, offset) => this.listMessages(username, account, limit, offset))
ipcMain.handle('biz:listPayRecords', (_, account, limit, offset) => this.listPayRecords(account, limit, offset))
}
}
export const bizService = new BizService()

File diff suppressed because it is too large Load Diff

View File

@@ -15,15 +15,31 @@ class CloudControlService {
private timer: NodeJS.Timeout | null = null
private pages: Set<string> = new Set()
private platformVersionCache: string | null = null
private pendingReports: UsageStats[] = []
private flushInProgress = false
private retryDelayMs = 5_000
private consecutiveFailures = 0
private circuitOpenedAt = 0
private nextDelayOverrideMs: number | null = null
private initialized = false
private static readonly BASE_FLUSH_MS = 300_000
private static readonly JITTER_MS = 30_000
private static readonly MAX_BUFFER_REPORTS = 200
private static readonly MAX_BATCH_REPORTS = 20
private static readonly MAX_RETRY_MS = 120_000
private static readonly CIRCUIT_FAIL_THRESHOLD = 5
private static readonly CIRCUIT_COOLDOWN_MS = 120_000
async init() {
if (this.initialized) return
this.initialized = true
this.deviceId = this.getDeviceId()
await wcdbService.cloudInit(300)
await this.reportOnline()
this.timer = setInterval(() => {
this.reportOnline()
}, 300000)
this.enqueueCurrentReport()
await this.flushQueue(true)
this.scheduleNextFlush(this.nextDelayOverrideMs ?? undefined)
this.nextDelayOverrideMs = null
}
private getDeviceId(): string {
@@ -33,8 +49,8 @@ class CloudControlService {
return crypto.createHash('md5').update(machineId).digest('hex')
}
private async reportOnline() {
const data: UsageStats = {
private buildCurrentReport(): UsageStats {
return {
appVersion: app.getVersion(),
platform: this.getPlatformVersion(),
deviceId: this.deviceId,
@@ -42,11 +58,69 @@ class CloudControlService {
online: true,
pages: Array.from(this.pages)
}
}
await wcdbService.cloudReport(JSON.stringify(data))
private enqueueCurrentReport() {
const report = this.buildCurrentReport()
this.pendingReports.push(report)
if (this.pendingReports.length > CloudControlService.MAX_BUFFER_REPORTS) {
this.pendingReports.splice(0, this.pendingReports.length - CloudControlService.MAX_BUFFER_REPORTS)
}
this.pages.clear()
}
private isCircuitOpen(nowMs: number): boolean {
if (this.circuitOpenedAt <= 0) return false
return nowMs-this.circuitOpenedAt < CloudControlService.CIRCUIT_COOLDOWN_MS
}
private scheduleNextFlush(delayMs?: number) {
if (this.timer) {
clearTimeout(this.timer)
this.timer = null
}
const jitter = Math.floor(Math.random() * CloudControlService.JITTER_MS)
const nextDelay = Math.max(1_000, Number(delayMs) > 0 ? Number(delayMs) : CloudControlService.BASE_FLUSH_MS + jitter)
this.timer = setTimeout(() => {
this.enqueueCurrentReport()
this.flushQueue(false).finally(() => {
this.scheduleNextFlush(this.nextDelayOverrideMs ?? undefined)
this.nextDelayOverrideMs = null
})
}, nextDelay)
}
private async flushQueue(force: boolean) {
if (this.flushInProgress) return
if (this.pendingReports.length === 0) return
const now = Date.now()
if (!force && this.isCircuitOpen(now)) {
return
}
this.flushInProgress = true
try {
while (this.pendingReports.length > 0) {
const batch = this.pendingReports.slice(0, CloudControlService.MAX_BATCH_REPORTS)
const result = await wcdbService.cloudReport(JSON.stringify(batch))
if (!result || result.success !== true) {
this.consecutiveFailures += 1
this.retryDelayMs = Math.min(CloudControlService.MAX_RETRY_MS, this.retryDelayMs * 2)
if (this.consecutiveFailures >= CloudControlService.CIRCUIT_FAIL_THRESHOLD) {
this.circuitOpenedAt = Date.now()
}
this.nextDelayOverrideMs = this.retryDelayMs
return
}
this.pendingReports.splice(0, batch.length)
this.consecutiveFailures = 0
this.retryDelayMs = 5_000
this.circuitOpenedAt = 0
}
} finally {
this.flushInProgress = false
}
}
private getPlatformVersion(): string {
if (this.platformVersionCache) {
return this.platformVersionCache
@@ -144,12 +218,25 @@ class CloudControlService {
this.pages.add(pageName)
}
stop() {
async stop(): Promise<void> {
if (this.timer) {
clearInterval(this.timer)
clearTimeout(this.timer)
this.timer = null
}
wcdbService.cloudStop()
this.pendingReports = []
this.flushInProgress = false
this.retryDelayMs = 5_000
this.consecutiveFailures = 0
this.circuitOpenedAt = 0
this.nextDelayOverrideMs = null
this.initialized = false
if (wcdbService.isReady()) {
try {
await wcdbService.cloudStop()
} catch {
// 忽略停止失败,避免阻塞主进程退出
}
}
}
async getLogs() {
@@ -158,4 +245,3 @@ class CloudControlService {
}
export const cloudControlService = new CloudControlService()

View File

@@ -1,10 +1,18 @@
import { join } from 'path'
import { join } from 'path'
import { app, safeStorage } from 'electron'
import crypto from 'crypto'
import Store from 'electron-store'
import { expandHomePath } from '../utils/pathUtils'
// 加密前缀标记
const SAFE_PREFIX = 'safe:' // safeStorage 加密(普通模式)
const isSafeStorageAvailable = (): boolean => {
try {
return typeof safeStorage?.isEncryptionAvailable === 'function' && safeStorage.isEncryptionAvailable()
} catch {
return false
}
}
const LOCK_PREFIX = 'lock:' // 密码派生密钥加密(锁定模式)
interface ConfigSchema {
@@ -27,6 +35,7 @@ interface ConfigSchema {
themeId: string
language: string
logEnabled: boolean
launchAtStartup?: boolean
llmModelPath: string
whisperModelName: string
whisperModelDir: string
@@ -34,7 +43,6 @@ interface ConfigSchema {
autoTranscribeVoice: boolean
transcribeLanguages: string[]
exportDefaultConcurrency: number
exportDefaultImageDeepSearchOnMiss: boolean
analyticsExcludedUsernames: string[]
// 安全相关
@@ -45,6 +53,7 @@ interface ConfigSchema {
// 更新相关
ignoredUpdateVersion: string
updateChannel: 'auto' | 'stable' | 'preview' | 'dev'
// 通知
notificationEnabled: boolean
@@ -52,13 +61,66 @@ interface ConfigSchema {
notificationFilterMode: 'all' | 'whitelist' | 'blacklist'
notificationFilterList: string[]
messagePushEnabled: boolean
messagePushFilterMode: 'all' | 'whitelist' | 'blacklist'
messagePushFilterList: string[]
httpApiEnabled: boolean
httpApiPort: number
httpApiHost: string
httpApiToken: string
windowCloseBehavior: 'ask' | 'tray' | 'quit'
quoteLayout: 'quote-top' | 'quote-bottom'
wordCloudExcludeWords: string[]
exportWriteLayout: 'A' | 'B' | 'C'
exportAutomationTaskMap: Record<string, unknown>
// AI 见解
aiModelApiBaseUrl: string
aiModelApiKey: string
aiModelApiModel: string
aiModelApiMaxTokens: number
aiInsightEnabled: boolean
aiInsightApiBaseUrl: string
aiInsightApiKey: string
aiInsightApiModel: string
aiInsightSilenceDays: number
aiInsightAllowContext: boolean
aiInsightAllowSocialContext: boolean
aiInsightFilterMode: 'whitelist' | 'blacklist'
aiInsightFilterList: string[]
aiInsightWhitelistEnabled: boolean
aiInsightWhitelist: string[]
/** 活跃分析冷却时间分钟0 表示无冷却 */
aiInsightCooldownMinutes: number
/** 沉默联系人扫描间隔(小时) */
aiInsightScanIntervalHours: number
/** 发送上下文时的最大消息条数 */
aiInsightContextCount: number
/** 自定义 system prompt空字符串表示使用内置默认值 */
aiInsightSystemPrompt: string
/** 是否启用 Telegram 推送 */
aiInsightTelegramEnabled: boolean
/** Telegram Bot Token */
aiInsightTelegramToken: string
/** Telegram 接收 Chat ID逗号分隔支持多个 */
aiInsightTelegramChatIds: string
// AI 足迹
aiFootprintEnabled: boolean
aiFootprintSystemPrompt: string
/** 是否将 AI 见解调试日志输出到桌面 */
aiInsightDebugLogEnabled: boolean
}
// 需要 safeStorage 加密的字段(普通模式)
const ENCRYPTED_STRING_KEYS: Set<string> = new Set(['decryptKey', 'imageAesKey', 'authPassword'])
const ENCRYPTED_STRING_KEYS: Set<string> = new Set([
'decryptKey',
'imageAesKey',
'authPassword',
'httpApiToken',
'aiModelApiKey',
'aiInsightApiKey',
'aiInsightWeiboCookie'
])
const ENCRYPTED_BOOL_KEYS: Set<string> = new Set(['authEnabled', 'authUseHello'])
const ENCRYPTED_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
@@ -108,21 +170,57 @@ export class ConfigService {
autoTranscribeVoice: false,
transcribeLanguages: ['zh'],
exportDefaultConcurrency: 4,
exportDefaultImageDeepSearchOnMiss: true,
analyticsExcludedUsernames: [],
authEnabled: false,
authPassword: '',
authUseHello: false,
authHelloSecret: '',
ignoredUpdateVersion: '',
updateChannel: 'auto',
notificationEnabled: true,
notificationPosition: 'top-right',
notificationFilterMode: 'all',
notificationFilterList: [],
httpApiToken: '',
httpApiEnabled: false,
httpApiPort: 5031,
httpApiHost: '127.0.0.1',
messagePushEnabled: false,
messagePushFilterMode: 'all',
messagePushFilterList: [],
windowCloseBehavior: 'ask',
quoteLayout: 'quote-top',
wordCloudExcludeWords: []
wordCloudExcludeWords: [],
exportWriteLayout: 'A',
exportAutomationTaskMap: {},
aiModelApiBaseUrl: '',
aiModelApiKey: '',
aiModelApiModel: 'gpt-4o-mini',
aiModelApiMaxTokens: 200,
aiInsightEnabled: false,
aiInsightApiBaseUrl: '',
aiInsightApiKey: '',
aiInsightApiModel: 'gpt-4o-mini',
aiInsightSilenceDays: 3,
aiInsightAllowContext: false,
aiInsightAllowSocialContext: false,
aiInsightFilterMode: 'whitelist',
aiInsightFilterList: [],
aiInsightWhitelistEnabled: false,
aiInsightWhitelist: [],
aiInsightCooldownMinutes: 120,
aiInsightScanIntervalHours: 4,
aiInsightContextCount: 40,
aiInsightSocialContextCount: 3,
aiInsightSystemPrompt: '',
aiInsightTelegramEnabled: false,
aiInsightTelegramToken: '',
aiInsightTelegramChatIds: '',
aiInsightWeiboCookie: '',
aiInsightWeiboBindings: {},
aiFootprintEnabled: false,
aiFootprintSystemPrompt: '',
aiInsightDebugLogEnabled: false
}
const storeOptions: any = {
@@ -154,6 +252,7 @@ export class ConfigService {
}
}
this.migrateAuthFields()
this.migrateAiConfig()
}
// === 状态查询 ===
@@ -203,6 +302,10 @@ export class ConfigService {
return this.decryptWxidConfigs(raw as any) as ConfigSchema[K]
}
if (key === 'dbPath' && typeof raw === 'string') {
return expandHomePath(raw) as ConfigSchema[K]
}
return raw
}
@@ -210,8 +313,14 @@ export class ConfigService {
let toStore = value
const inLockMode = this.isLockMode() && this.unlockPassword
if (key === 'dbPath' && typeof value === 'string') {
toStore = expandHomePath(value) as ConfigSchema[K]
}
if (ENCRYPTED_BOOL_KEYS.has(key)) {
toStore = this.safeEncrypt(String(value)) as ConfigSchema[K]
const boolValue = value === true || value === 'true'
// `false` 不需要写入 keychain避免无意义触发 macOS 钥匙串弹窗
toStore = (boolValue ? this.safeEncrypt('true') : false) as ConfigSchema[K]
} else if (ENCRYPTED_NUMBER_KEYS.has(key)) {
if (inLockMode && LOCKABLE_NUMBER_KEYS.has(key)) {
toStore = this.lockEncrypt(String(value), this.unlockPassword!) as ConfigSchema[K]
@@ -244,7 +353,7 @@ export class ConfigService {
private safeEncrypt(plaintext: string): string {
if (!plaintext) return ''
if (plaintext.startsWith(SAFE_PREFIX)) return plaintext
if (!safeStorage.isEncryptionAvailable()) return plaintext
if (!isSafeStorageAvailable()) return plaintext
const encrypted = safeStorage.encryptString(plaintext)
return SAFE_PREFIX + encrypted.toString('base64')
}
@@ -252,7 +361,7 @@ export class ConfigService {
private safeDecrypt(stored: string): string {
if (!stored) return ''
if (!stored.startsWith(SAFE_PREFIX)) return stored
if (!safeStorage.isEncryptionAvailable()) return ''
if (!isSafeStorageAvailable()) return ''
try {
const buf = Buffer.from(stored.slice(SAFE_PREFIX.length), 'base64')
return safeStorage.decryptString(buf)
@@ -590,7 +699,7 @@ export class ConfigService {
clearHelloSecret(): void {
this.store.set('authHelloSecret', '' as any)
this.store.set('authUseHello', this.safeEncrypt('false') as any)
this.store.set('authUseHello', false as any)
}
// === 迁移 ===
@@ -599,13 +708,18 @@ export class ConfigService {
// 将旧版明文 auth 字段迁移为 safeStorage 加密格式
// 如果已经是 safe: 或 lock: 前缀则跳过
const rawEnabled: any = this.store.get('authEnabled')
if (typeof rawEnabled === 'boolean') {
this.store.set('authEnabled', this.safeEncrypt(String(rawEnabled)) as any)
if (rawEnabled === true || rawEnabled === 'true') {
this.store.set('authEnabled', this.safeEncrypt('true') as any)
} else if (rawEnabled === false || rawEnabled === 'false') {
// 保持 false 为明文布尔,避免冷启动访问 keychain
this.store.set('authEnabled', false as any)
}
const rawUseHello: any = this.store.get('authUseHello')
if (typeof rawUseHello === 'boolean') {
this.store.set('authUseHello', this.safeEncrypt(String(rawUseHello)) as any)
if (rawUseHello === true || rawUseHello === 'true') {
this.store.set('authUseHello', this.safeEncrypt('true') as any)
} else if (rawUseHello === false || rawUseHello === 'false') {
this.store.set('authUseHello', false as any)
}
const rawPassword: any = this.store.get('authPassword')
@@ -651,6 +765,26 @@ export class ConfigService {
}
}
private migrateAiConfig(): void {
const sharedBaseUrl = String(this.get('aiModelApiBaseUrl') || '').trim()
const sharedApiKey = String(this.get('aiModelApiKey') || '').trim()
const sharedModel = String(this.get('aiModelApiModel') || '').trim()
const legacyBaseUrl = String(this.get('aiInsightApiBaseUrl') || '').trim()
const legacyApiKey = String(this.get('aiInsightApiKey') || '').trim()
const legacyModel = String(this.get('aiInsightApiModel') || '').trim()
if (!sharedBaseUrl && legacyBaseUrl) {
this.set('aiModelApiBaseUrl', legacyBaseUrl)
}
if (!sharedApiKey && legacyApiKey) {
this.set('aiModelApiKey', legacyApiKey)
}
if (!sharedModel && legacyModel) {
this.set('aiModelApiModel', legacyModel)
}
}
// === 验证 ===
verifyAuthEnabled(): boolean {
@@ -662,11 +796,9 @@ export class ConfigService {
// 即使 authEnabled 被删除/篡改,如果密钥是 lock: 格式,说明曾开启过应用锁
const rawDecryptKey: any = this.store.get('decryptKey')
if (typeof rawDecryptKey === 'string' && rawDecryptKey.startsWith(LOCK_PREFIX)) {
return true
}
return typeof rawDecryptKey === 'string' && rawDecryptKey.startsWith(LOCK_PREFIX);
return false
}
// === 工具方法 ===
@@ -714,3 +846,4 @@ export class ConfigService {
this.unlockPassword = null
}
}

View File

@@ -2,6 +2,7 @@ import { join, basename } from 'path'
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
import { homedir } from 'os'
import { createDecipheriv } from 'crypto'
import { expandHomePath } from '../utils/pathUtils'
export interface WxidInfo {
wxid: string
@@ -93,27 +94,39 @@ export class DbPathService {
const possiblePaths: string[] = []
const home = homedir()
// macOS 微信路径(固定)
if (process.platform === 'darwin') {
// macOS 微信 4.0.5+ 新路径(优先检测)
const appSupportBase = join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Library', 'Application Support', 'com.tencent.xinWeChat')
if (existsSync(appSupportBase)) {
try {
const entries = readdirSync(appSupportBase)
for (const entry of entries) {
// 匹配形如 2.0b4.0.9 的版本目录
if (/^\d+\.\d+b\d+\.\d+/.test(entry) || /^\d+\.\d+\.\d+/.test(entry)) {
possiblePaths.push(join(appSupportBase, entry))
}
}
} catch { }
}
// macOS 旧路径兜底
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) {
if (existsSync(path)) {
const rootName = path.split(/[/\\]/).pop()?.toLowerCase()
if (rootName !== 'xwechat_files' && rootName !== 'wechat files') {
continue
}
if (!existsSync(path)) continue
// 检查是否有有效的账号目录
const accounts = this.findAccountDirs(path)
if (accounts.length > 0) {
return { success: true, path }
}
// 检查是否有有效的账号目录,或本身就是账号目录
const accounts = this.findAccountDirs(path)
if (accounts.length > 0) {
return { success: true, path }
}
// 如果该目录本身就是账号目录(直接包含 db_storage 等)
if (this.isAccountDir(path)) {
return { success: true, path }
}
}
@@ -127,13 +140,14 @@ export class DbPathService {
* 查找账号目录(包含 db_storage 或图片目录)
*/
findAccountDirs(rootPath: string): string[] {
const resolvedRootPath = expandHomePath(rootPath)
const accounts: string[] = []
try {
const entries = readdirSync(rootPath)
const entries = readdirSync(resolvedRootPath)
for (const entry of entries) {
const entryPath = join(rootPath, entry)
const entryPath = join(resolvedRootPath, entry)
let stat: ReturnType<typeof statSync>
try {
stat = statSync(entryPath)
@@ -204,13 +218,14 @@ export class DbPathService {
* 扫描目录名候选(仅包含下划线的文件夹,排除 all_users
*/
scanWxidCandidates(rootPath: string): WxidInfo[] {
const resolvedRootPath = expandHomePath(rootPath)
const wxids: WxidInfo[] = []
try {
if (existsSync(rootPath)) {
const entries = readdirSync(rootPath)
if (existsSync(resolvedRootPath)) {
const entries = readdirSync(resolvedRootPath)
for (const entry of entries) {
const entryPath = join(rootPath, entry)
const entryPath = join(resolvedRootPath, entry)
let stat: ReturnType<typeof statSync>
try { stat = statSync(entryPath) } catch { continue }
if (!stat.isDirectory()) continue
@@ -223,9 +238,9 @@ export class DbPathService {
if (wxids.length === 0) {
const rootName = basename(rootPath)
const rootName = basename(resolvedRootPath)
if (rootName.includes('_') && rootName.toLowerCase() !== 'all_users') {
const rootStat = statSync(rootPath)
const rootStat = statSync(resolvedRootPath)
wxids.push({ wxid: rootName, modifiedTime: rootStat.mtimeMs })
}
}
@@ -236,7 +251,7 @@ export class DbPathService {
return a.wxid.localeCompare(b.wxid)
});
const globalInfo = this.parseGlobalConfig(rootPath);
const globalInfo = this.parseGlobalConfig(resolvedRootPath);
if (globalInfo) {
for (const w of sorted) {
if (w.wxid.startsWith(globalInfo.wxid) || sorted.length === 1) {
@@ -254,19 +269,20 @@ export class DbPathService {
* 扫描 wxid 列表
*/
scanWxids(rootPath: string): WxidInfo[] {
const resolvedRootPath = expandHomePath(rootPath)
const wxids: WxidInfo[] = []
try {
if (this.isAccountDir(rootPath)) {
const wxid = basename(rootPath)
const modifiedTime = this.getAccountModifiedTime(rootPath)
if (this.isAccountDir(resolvedRootPath)) {
const wxid = basename(resolvedRootPath)
const modifiedTime = this.getAccountModifiedTime(resolvedRootPath)
return [{ wxid, modifiedTime }]
}
const accounts = this.findAccountDirs(rootPath)
const accounts = this.findAccountDirs(resolvedRootPath)
for (const account of accounts) {
const fullPath = join(rootPath, account)
const fullPath = join(resolvedRootPath, account)
const modifiedTime = this.getAccountModifiedTime(fullPath)
wxids.push({ wxid: account, modifiedTime })
}
@@ -277,7 +293,7 @@ export class DbPathService {
return a.wxid.localeCompare(b.wxid)
});
const globalInfo = this.parseGlobalConfig(rootPath);
const globalInfo = this.parseGlobalConfig(resolvedRootPath);
if (globalInfo) {
for (const w of sorted) {
if (w.wxid.startsWith(globalInfo.wxid) || sorted.length === 1) {
@@ -295,6 +311,20 @@ export class DbPathService {
getDefaultPath(): string {
const home = homedir()
if (process.platform === 'darwin') {
// 优先返回 4.0.5+ 新路径
const appSupportBase = join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Library', 'Application Support', 'com.tencent.xinWeChat')
if (existsSync(appSupportBase)) {
try {
const entries = readdirSync(appSupportBase)
for (const entry of entries) {
if (/^\d+\.\d+b\d+\.\d+/.test(entry) || /^\d+\.\d+\.\d+/.test(entry)) {
const candidate = join(appSupportBase, entry)
if (existsSync(candidate)) return candidate
}
}
} catch { }
}
// 旧版本路径兜底
return join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'xwechat_files')
}
return join(home, 'Documents', 'xwechat_files')

View File

@@ -19,7 +19,8 @@ class ExportRecordService {
private resolveFilePath(): string {
if (this.filePath) return this.filePath
const userDataPath = app.getPath('userData')
const workerUserDataPath = String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim()
const userDataPath = workerUserDataPath || app?.getPath?.('userData') || process.cwd()
fs.mkdirSync(userDataPath, { recursive: true })
this.filePath = path.join(userDataPath, 'weflow-export-records.json')
return this.filePath

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@ import { ConfigService } from './config'
import { wcdbService } from './wcdbService'
import { chatService } from './chatService'
import type { Message } from './chatService'
import type { ChatStatistics } from './analyticsService'
export interface GroupChatInfo {
username: string
@@ -49,6 +50,13 @@ export interface GroupMediaStats {
total: number
}
export interface GroupMemberAnalytics {
statistics: ChatStatistics
timeDistribution: Record<number, number>
commonPhrases?: Array<{ phrase: string; count: number }>
commonEmojis?: Array<{ emoji: string; count: number }>
}
export interface GroupMemberMessagesPage {
messages: Message[]
hasMore: boolean
@@ -267,7 +275,7 @@ class GroupAnalyticsService {
}
return this.buildTrustedGroupNicknameMap(Object.entries(dllResult.nicknames), candidates)
} catch (e) {
console.error('getGroupNicknamesForRoom dll error:', e)
console.error('getGroupNicknamesForRoom service error:', e)
return new Map<string, string>()
}
}
@@ -797,7 +805,12 @@ class GroupAnalyticsService {
return normalized > 10000000000 ? Math.floor(normalized / 1000) : normalized
}
private extractRowSenderUsername(row: Record<string, any>): string {
private extractRowSenderUsername(row: Record<string, any>, myWxid?: string): string {
const isSendRaw = row.computed_is_send ?? row.is_send ?? row.isSend ?? row.WCDB_CT_is_send
if (isSendRaw != null && parseInt(isSendRaw, 10) === 1 && myWxid) {
return myWxid
}
const candidates = [
row.sender_username,
row.senderUsername,
@@ -820,13 +833,33 @@ class GroupAnalyticsService {
if (normalizedValue) return normalizedValue
}
}
// Fallback: fast extract from raw content to avoid full parse
const rawContent = String(row.StrContent || row.message_content || row.content || row.msg_content || '').trim()
if (rawContent) {
const match = /^\s*([a-zA-Z0-9_@-]{4,}):(?!\/\/)\s*(?:\r?\n|<br\s*\/?>)/i.exec(rawContent)
if (match && match[1]) {
return match[1].trim()
}
}
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
if (Array.isArray(mapped) && mapped.length > 0) {
const msg = mapped[0]
if (!msg.localType) {
msg.localType = parseInt(row.Type || row.type || row.local_type || row.msg_type || '0', 10)
}
if (!msg.createTime) {
msg.createTime = parseInt(row.CreateTime || row.create_time || row.createTime || row.msg_time || '0', 10)
}
return msg
}
return null
} catch {
return null
}
@@ -881,7 +914,7 @@ class GroupAnalyticsService {
if (rows.length === 0) break
for (const row of rows) {
const senderFromRow = this.extractRowSenderUsername(row)
const senderFromRow = this.extractRowSenderUsername(row, String(this.configService.get('myWxid') || '').trim())
if (senderFromRow && !matchesTargetSender(senderFromRow)) {
continue
}
@@ -987,7 +1020,7 @@ class GroupAnalyticsService {
const row = rows[index]
consumedRows += 1
const senderFromRow = this.extractRowSenderUsername(row)
const senderFromRow = this.extractRowSenderUsername(row, String(this.configService.get('myWxid') || '').trim())
if (senderFromRow && !matchesTargetSender(senderFromRow)) {
continue
}
@@ -1467,6 +1500,154 @@ class GroupAnalyticsService {
}
}
async getGroupMemberAnalytics(
chatroomId: string,
memberUsername: string,
startTime?: number,
endTime?: number
): Promise<{ success: boolean; data?: GroupMemberAnalytics; 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()
const batchSize = 10000
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 cursorResult = await this.openMemberMessageCursor(normalizedChatroomId, batchSize, true, startTime || 0, endTime || 0)
if (!cursorResult.success || !cursorResult.cursor) {
return { success: false, error: cursorResult.error || '创建游标失败' }
}
const cursor = cursorResult.cursor
const stats: ChatStatistics = {
totalMessages: 0,
textMessages: 0,
imageMessages: 0,
voiceMessages: 0,
videoMessages: 0,
emojiMessages: 0,
otherMessages: 0,
sentMessages: 0, // In group, we only fetch messages of this member, so sentMessages = totalMessages
receivedMessages: 0, // No meaning here
firstMessageTime: null,
lastMessageTime: null,
activeDays: 0,
messageTypeCounts: {}
}
const hourlyDistribution: Record<number, number> = {}
for (let i = 0; i < 24; i++) hourlyDistribution[i] = 0
const dailySet = new Set<string>()
const textTypes = [1, 244813135921]
const phraseCounts = new Map<string, number>()
const emojiCounts = new Map<string, number>()
const myWxid = String(this.configService.get('myWxid') || '').trim()
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
for (const row of rows) {
let senderFromRow = this.extractRowSenderUsername(row, myWxid)
const isSendRaw = row.computed_is_send ?? row.is_send ?? row.isSend ?? row.WCDB_CT_is_send
const isSend = isSendRaw != null ? parseInt(isSendRaw, 10) === 1 : false
if (isSend) {
senderFromRow = myWxid
}
if (!senderFromRow || !matchesTargetSender(senderFromRow)) {
continue
}
const msgType = parseInt(row.Type || row.type || row.local_type || row.msg_type || '0', 10)
const createTime = parseInt(row.CreateTime || row.create_time || row.createTime || row.msg_time || '0', 10)
let content = String(row.StrContent || row.message_content || row.content || row.msg_content || '')
if (content) {
content = content.replace(/^\s*([a-zA-Z0-9_@-]{4,}):(?!\/\/)\s*(?:\r?\n|<br\s*\/?>)/i, '')
}
stats.totalMessages++
if (textTypes.includes(msgType)) {
stats.textMessages++
if (content) {
const text = content.trim()
if (text && text.length <= 20) {
phraseCounts.set(text, (phraseCounts.get(text) || 0) + 1)
}
const emojiMatches = text.match(/\[.*?\]/g)
if (emojiMatches) {
for (const em of emojiMatches) {
emojiCounts.set(em, (emojiCounts.get(em) || 0) + 1)
}
}
}
}
else if (msgType === 3) stats.imageMessages++
else if (msgType === 34) stats.voiceMessages++
else if (msgType === 43) stats.videoMessages++
else if (msgType === 47) stats.emojiMessages++
else stats.otherMessages++
stats.sentMessages++
stats.messageTypeCounts[msgType] = (stats.messageTypeCounts[msgType] || 0) + 1
if (createTime > 0) {
if (stats.firstMessageTime === null || createTime < stats.firstMessageTime) stats.firstMessageTime = createTime
if (stats.lastMessageTime === null || createTime > stats.lastMessageTime) stats.lastMessageTime = createTime
const d = new Date(createTime * 1000)
const hour = d.getHours()
hourlyDistribution[hour]++
dailySet.add(`${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`)
}
}
if (!batch.hasMore) break
}
} finally {
await wcdbService.closeMessageCursor(cursor)
}
stats.activeDays = dailySet.size
const commonPhrases = Array.from(phraseCounts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([phrase, count]) => ({ phrase, count }))
const commonEmojis = Array.from(emojiCounts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([emoji, count]) => ({ emoji, count }))
return { success: true, data: { statistics: stats, timeDistribution: hourlyDistribution, commonPhrases, commonEmojis } }
} catch (e) {
return { success: false, error: String(e) }
}
}
async exportGroupMemberMessages(
chatroomId: string,
memberUsername: string,

View File

@@ -6,12 +6,14 @@ import * as http from 'http'
import * as fs from 'fs'
import * as path from 'path'
import { URL } from 'url'
import { timingSafeEqual } from 'crypto'
import { chatService, Message } from './chatService'
import { wcdbService } from './wcdbService'
import { ConfigService } from './config'
import { videoService } from './videoService'
import { imageDecryptService } from './imageDecryptService'
import { groupAnalyticsService } from './groupAnalyticsService'
import { snsService } from './snsService'
// ChatLab 格式定义
interface ChatLabHeader {
@@ -101,6 +103,7 @@ class HttpService {
private server: http.Server | null = null
private configService: ConfigService
private port: number = 5031
private host: string = '127.0.0.1'
private running: boolean = false
private connections: Set<import('net').Socket> = new Set()
private messagePushClients: Set<http.ServerResponse> = new Set()
@@ -114,12 +117,13 @@ class HttpService {
/**
* 启动 HTTP 服务
*/
async start(port: number = 5031): Promise<{ success: boolean; port?: number; error?: string }> {
async start(port: number = 5031, host: string = '127.0.0.1'): Promise<{ success: boolean; port?: number; error?: string }> {
if (this.running && this.server) {
return { success: true, port: this.port }
}
this.port = port
this.host = host
return new Promise((resolve) => {
this.server = http.createServer((req, res) => this.handleRequest(req, res))
@@ -153,10 +157,10 @@ class HttpService {
}
})
this.server.listen(this.port, '127.0.0.1', () => {
this.server.listen(this.port, this.host, () => {
this.running = true
this.startMessagePushHeartbeat()
console.log(`[HttpService] HTTP API server started on http://127.0.0.1:${this.port}`)
console.log(`[HttpService] HTTP API server started on http://${this.host}:${this.port}`)
resolve({ success: true, port: this.port })
})
})
@@ -225,7 +229,7 @@ class HttpService {
}
getMessagePushStreamUrl(): string {
return `http://127.0.0.1:${this.port}/api/v1/push/messages`
return `http://${this.host}:${this.port}/api/v1/push/messages`
}
broadcastMessagePush(payload: Record<string, unknown>): void {
@@ -246,49 +250,178 @@ class HttpService {
}
}
/**
* 处理 HTTP 请求
*/
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
// 设置 CORS 头
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
if (req.method === 'OPTIONS') {
res.writeHead(204)
res.end()
return
}
const url = new URL(req.url || '/', `http://127.0.0.1:${this.port}`)
const pathname = url.pathname
try {
// 路由处理
if (pathname === '/health' || pathname === '/api/v1/health') {
this.sendJson(res, { status: 'ok' })
} else if (pathname === '/api/v1/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 {
this.sendError(res, 404, 'Not Found')
async autoStart(): Promise<void> {
const enabled = this.configService.get('httpApiEnabled')
if (enabled) {
const port = Number(this.configService.get('httpApiPort')) || 5031
const host = String(this.configService.get('httpApiHost') || '127.0.0.1').trim() || '127.0.0.1'
try {
await this.start(port, host)
console.log(`[HttpService] Auto-started on port ${port}`)
} catch (err) {
console.error('[HttpService] Auto-start failed:', err)
}
} catch (error) {
console.error('[HttpService] Request error:', error)
this.sendError(res, 500, String(error))
}
}
/**
* 解析 POST 请求的 JSON Body
*/
private async parseBody(req: http.IncomingMessage): Promise<Record<string, any>> {
if (req.method !== 'POST') return {}
const MAX_BODY_SIZE = 10 * 1024 * 1024 // 10MB
return new Promise((resolve) => {
let body = ''
let bodySize = 0
req.on('data', chunk => {
bodySize += chunk.length
if (bodySize > MAX_BODY_SIZE) {
req.destroy()
resolve({})
return
}
body += chunk.toString()
})
req.on('end', () => {
try {
resolve(JSON.parse(body))
} catch {
resolve({})
}
})
req.on('error', () => resolve({}))
})
}
/**
* 鉴权拦截器
*/
private safeEqual(a: string, b: string): boolean {
const bufA = Buffer.from(a)
const bufB = Buffer.from(b)
if (bufA.length !== bufB.length) return false
return timingSafeEqual(bufA, bufB)
}
private verifyToken(req: http.IncomingMessage, url: URL, body: Record<string, any>): boolean {
const expectedToken = String(this.configService.get('httpApiToken') || '').trim()
if (!expectedToken) {
// token 未配置时拒绝所有请求,防止未授权访问
console.warn('[HttpService] Access denied: httpApiToken not configured')
return false
}
const authHeader = req.headers.authorization
if (authHeader && authHeader.toLowerCase().startsWith('bearer ')) {
const token = authHeader.substring(7).trim()
if (this.safeEqual(token, expectedToken)) return true
}
const queryToken = url.searchParams.get('access_token')
if (queryToken && this.safeEqual(queryToken.trim(), expectedToken)) return true
const bodyToken = body['access_token']
return !!(bodyToken && this.safeEqual(String(bodyToken).trim(), expectedToken))
}
/**
* 处理 HTTP 请求 (重构后)
*/
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
// 仅允许本地来源的跨域请求
const origin = req.headers.origin || ''
if (origin && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin)
res.setHeader('Vary', 'Origin')
}
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
if (req.method === 'OPTIONS') {
res.writeHead(204)
res.end()
return
}
const url = new URL(req.url || '/', `http://${this.host}:${this.port}`)
const pathname = url.pathname
try {
const bodyParams = await this.parseBody(req)
for (const [key, value] of Object.entries(bodyParams)) {
if (!url.searchParams.has(key)) {
url.searchParams.set(key, String(value))
}
}
if (pathname !== '/health' && pathname !== '/api/v1/health') {
if (!this.verifyToken(req, url, bodyParams)) {
this.sendError(res, 401, 'Unauthorized: Invalid or missing access_token')
return
}
}
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.startsWith('/api/v1/sessions/') &&
pathname.endsWith('/messages')
) {
const parts = pathname.split('/')
const sessionId = decodeURIComponent(parts[4] || '')
if (!sessionId) {
this.sendError(res, 400, 'Missing session ID')
} else {
await this.handlePullMessages(sessionId, 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 === '/api/v1/sns/timeline') {
if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET')
await this.handleSnsTimeline(url, res)
} else if (pathname === '/api/v1/sns/usernames') {
if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET')
await this.handleSnsUsernames(res)
} else if (pathname === '/api/v1/sns/export/stats') {
if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET')
await this.handleSnsExportStats(url, res)
} else if (pathname === '/api/v1/sns/media/proxy') {
if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET')
await this.handleSnsMediaProxy(url, res)
} else if (pathname === '/api/v1/sns/export') {
if (req.method !== 'POST') return this.sendMethodNotAllowed(res, 'POST')
await this.handleSnsExport(url, res)
} else if (pathname === '/api/v1/sns/block-delete/status') {
if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET')
await this.handleSnsBlockDeleteStatus(res)
} else if (pathname === '/api/v1/sns/block-delete/install') {
if (req.method !== 'POST') return this.sendMethodNotAllowed(res, 'POST')
await this.handleSnsBlockDeleteInstall(res)
} else if (pathname === '/api/v1/sns/block-delete/uninstall') {
if (req.method !== 'POST') return this.sendMethodNotAllowed(res, 'POST')
await this.handleSnsBlockDeleteUninstall(res)
} else if (pathname.startsWith('/api/v1/sns/post/')) {
if (req.method !== 'DELETE') return this.sendMethodNotAllowed(res, 'DELETE')
await this.handleSnsDeletePost(pathname, res)
} else if (pathname.startsWith('/api/v1/media/')) {
this.handleMediaRequest(pathname, res)
} else {
this.sendError(res, 404, 'Not Found')
}
} catch (error) {
console.error('[HttpService] Request error:', error)
this.sendError(res, 500, String(error))
}
}
private startMessagePushHeartbeat(): void {
if (this.messagePushHeartbeatTimer) return
this.messagePushHeartbeatTimer = setInterval(() => {
@@ -334,9 +467,15 @@ class HttpService {
}
private handleMediaRequest(pathname: string, res: http.ServerResponse): void {
const mediaBasePath = this.getApiMediaExportPath()
const mediaBasePath = path.resolve(this.getApiMediaExportPath())
const relativePath = pathname.replace('/api/v1/media/', '')
const fullPath = path.join(mediaBasePath, relativePath)
const fullPath = path.resolve(mediaBasePath, relativePath)
// 防止路径穿越攻击
if (!fullPath.startsWith(mediaBasePath + path.sep) && fullPath !== mediaBasePath) {
this.sendError(res, 403, 'Forbidden')
return
}
if (!fs.existsSync(fullPath)) {
this.sendError(res, 404, 'Media not found')
@@ -490,6 +629,15 @@ class HttpService {
return defaultValue
}
private parseStringListParam(value: string | null): string[] | undefined {
if (!value) return undefined
const values = value
.split(',')
.map((item) => item.trim())
.filter(Boolean)
return values.length > 0 ? Array.from(new Set(values)) : undefined
}
private parseMediaOptions(url: URL): ApiMediaOptions {
const mediaEnabled = this.parseBooleanParam(url, ['media', 'meiti'], false)
if (!mediaEnabled) {
@@ -599,6 +747,7 @@ class HttpService {
private async handleSessions(url: URL, res: http.ServerResponse): Promise<void> {
const keyword = (url.searchParams.get('keyword') || '').trim()
const limit = this.parseIntParam(url.searchParams.get('limit'), 100, 1, 10000)
const format = (url.searchParams.get('format') || '').trim().toLowerCase()
try {
const sessions = await chatService.getSessions()
@@ -616,9 +765,22 @@ class HttpService {
)
}
// 应用 limit
const limitedSessions = filteredSessions.slice(0, limit)
if (format === 'chatlab') {
this.sendJson(res, {
sessions: limitedSessions.map(s => ({
id: s.username,
name: s.displayName || s.username,
platform: 'wechat',
type: s.username.endsWith('@chatroom') ? 'group' : 'private',
messageCount: s.messageCountHint || undefined,
lastMessageAt: s.lastTimestamp
}))
})
return
}
this.sendJson(res, {
success: true,
count: limitedSessions.length,
@@ -635,6 +797,53 @@ class HttpService {
}
}
/**
* ChatLab Pull: GET /api/v1/sessions/:id/messages?since=&limit=&offset=&end=
* 返回 ChatLab 标准格式 + sync 分页块
*/
private async handlePullMessages(sessionId: string, url: URL, res: http.ServerResponse): Promise<void> {
const PULL_MAX_LIMIT = 5000
const limit = this.parseIntParam(url.searchParams.get('limit'), PULL_MAX_LIMIT, 1, PULL_MAX_LIMIT)
const offset = this.parseIntParam(url.searchParams.get('offset'), 0, 0, Number.MAX_SAFE_INTEGER)
const sinceParam = url.searchParams.get('since')
const endParam = url.searchParams.get('end')
const startTime = sinceParam ? this.parseTimeParam(sinceParam) : 0
const endTime = endParam ? this.parseTimeParam(endParam, true) : 0
try {
const result = await this.fetchMessagesBatch(sessionId, offset, limit, startTime, endTime, true)
if (!result.success || !result.messages) {
this.sendError(res, 500, result.error || 'Failed to get messages')
return
}
const messages = result.messages
const hasMore = result.hasMore === true
const displayNames = await this.getDisplayNames([sessionId])
const talkerName = displayNames[sessionId] || sessionId
const chatLabData = await this.convertToChatLab(messages, sessionId, talkerName)
const lastTimestamp = messages.length > 0
? messages[messages.length - 1].createTime
: undefined
this.sendJson(res, {
...chatLabData,
sync: {
hasMore,
nextSince: hasMore && lastTimestamp ? lastTimestamp : undefined,
nextOffset: hasMore ? offset + messages.length : undefined,
watermark: Math.floor(Date.now() / 1000)
}
})
} catch (error) {
console.error('[HttpService] handlePullMessages error:', error)
this.sendError(res, 500, String(error))
}
}
/**
* 处理联系人查询
* GET /api/v1/contacts?keyword=xxx&limit=100
@@ -721,6 +930,313 @@ class HttpService {
}
}
private async handleSnsTimeline(url: URL, res: http.ServerResponse): Promise<void> {
const limit = this.parseIntParam(url.searchParams.get('limit'), 20, 1, 200)
const offset = this.parseIntParam(url.searchParams.get('offset'), 0, 0, Number.MAX_SAFE_INTEGER)
const usernames = this.parseStringListParam(url.searchParams.get('usernames'))
const keyword = (url.searchParams.get('keyword') || '').trim() || undefined
const resolveMedia = this.parseBooleanParam(url, ['media', 'resolveMedia', 'meiti'], true)
const inlineMedia = resolveMedia && this.parseBooleanParam(url, ['inline'], false)
const replaceMedia = resolveMedia && this.parseBooleanParam(url, ['replace'], true)
const startTimeRaw = this.parseTimeParam(url.searchParams.get('start'))
const endTimeRaw = this.parseTimeParam(url.searchParams.get('end'), true)
const startTime = startTimeRaw > 0 ? startTimeRaw : undefined
const endTime = endTimeRaw > 0 ? endTimeRaw : undefined
const result = await snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime)
if (!result.success) {
this.sendError(res, 500, result.error || 'Failed to get sns timeline')
return
}
let timeline = result.timeline || []
if (resolveMedia && timeline.length > 0) {
timeline = await this.enrichSnsTimelineMedia(timeline, inlineMedia, replaceMedia)
}
this.sendJson(res, {
success: true,
count: timeline.length,
timeline
})
}
private async handleSnsUsernames(res: http.ServerResponse): Promise<void> {
const result = await snsService.getSnsUsernames()
if (!result.success) {
this.sendError(res, 500, result.error || 'Failed to get sns usernames')
return
}
this.sendJson(res, {
success: true,
usernames: result.usernames || []
})
}
private async handleSnsExportStats(url: URL, res: http.ServerResponse): Promise<void> {
const fast = this.parseBooleanParam(url, ['fast'], false)
const result = fast
? await snsService.getExportStatsFast()
: await snsService.getExportStats()
if (!result.success) {
this.sendError(res, 500, result.error || 'Failed to get sns export stats')
return
}
this.sendJson(res, result)
}
private async handleSnsMediaProxy(url: URL, res: http.ServerResponse): Promise<void> {
const mediaUrl = (url.searchParams.get('url') || '').trim()
if (!mediaUrl) {
this.sendError(res, 400, 'Missing required parameter: url')
return
}
const key = this.toSnsMediaKey(url.searchParams.get('key'))
const result = await snsService.downloadImage(mediaUrl, key)
if (!result.success) {
this.sendError(res, 502, result.error || 'Failed to proxy sns media')
return
}
if (result.data) {
res.setHeader('Content-Type', result.contentType || 'application/octet-stream')
res.setHeader('Content-Length', result.data.length)
res.writeHead(200)
res.end(result.data)
return
}
if (result.cachePath && fs.existsSync(result.cachePath)) {
try {
const stat = fs.statSync(result.cachePath)
res.setHeader('Content-Type', result.contentType || 'application/octet-stream')
res.setHeader('Content-Length', stat.size)
res.writeHead(200)
const stream = fs.createReadStream(result.cachePath)
stream.on('error', () => {
if (!res.headersSent) {
this.sendError(res, 500, 'Failed to read proxied sns media')
} else {
try { res.destroy() } catch {}
}
})
stream.pipe(res)
return
} catch (error) {
console.error('[HttpService] Failed to stream sns media cache:', error)
}
}
this.sendError(res, 502, result.error || 'Failed to proxy sns media')
}
private async handleSnsExport(url: URL, res: http.ServerResponse): Promise<void> {
const outputDir = String(url.searchParams.get('outputDir') || '').trim()
if (!outputDir) {
this.sendError(res, 400, 'Missing required field: outputDir')
return
}
const rawFormat = String(url.searchParams.get('format') || 'json').trim().toLowerCase()
const format = rawFormat === 'arkme-json' ? 'arkmejson' : rawFormat
if (!['json', 'html', 'arkmejson'].includes(format)) {
this.sendError(res, 400, 'Invalid format, supported: json/html/arkmejson')
return
}
const usernames = this.parseStringListParam(url.searchParams.get('usernames'))
const keyword = String(url.searchParams.get('keyword') || '').trim() || undefined
const startTimeRaw = this.parseTimeParam(url.searchParams.get('start'))
const endTimeRaw = this.parseTimeParam(url.searchParams.get('end'), true)
const options: {
outputDir: string
format: 'json' | 'html' | 'arkmejson'
usernames?: string[]
keyword?: string
exportMedia?: boolean
exportImages?: boolean
exportLivePhotos?: boolean
exportVideos?: boolean
startTime?: number
endTime?: number
} = {
outputDir,
format: format as 'json' | 'html' | 'arkmejson',
usernames,
keyword,
exportMedia: this.parseBooleanParam(url, ['exportMedia'], false)
}
if (url.searchParams.has('exportImages')) options.exportImages = this.parseBooleanParam(url, ['exportImages'], false)
if (url.searchParams.has('exportLivePhotos')) options.exportLivePhotos = this.parseBooleanParam(url, ['exportLivePhotos'], false)
if (url.searchParams.has('exportVideos')) options.exportVideos = this.parseBooleanParam(url, ['exportVideos'], false)
if (startTimeRaw > 0) options.startTime = startTimeRaw
if (endTimeRaw > 0) options.endTime = endTimeRaw
const result = await snsService.exportTimeline(options)
if (!result.success) {
this.sendError(res, 500, result.error || 'Failed to export sns timeline')
return
}
this.sendJson(res, result)
}
private async handleSnsBlockDeleteStatus(res: http.ServerResponse): Promise<void> {
const result = await snsService.checkSnsBlockDeleteTrigger()
if (!result.success) {
this.sendError(res, 500, result.error || 'Failed to check sns block-delete status')
return
}
this.sendJson(res, result)
}
private async handleSnsBlockDeleteInstall(res: http.ServerResponse): Promise<void> {
const result = await snsService.installSnsBlockDeleteTrigger()
if (!result.success) {
this.sendError(res, 500, result.error || 'Failed to install sns block-delete trigger')
return
}
this.sendJson(res, result)
}
private async handleSnsBlockDeleteUninstall(res: http.ServerResponse): Promise<void> {
const result = await snsService.uninstallSnsBlockDeleteTrigger()
if (!result.success) {
this.sendError(res, 500, result.error || 'Failed to uninstall sns block-delete trigger')
return
}
this.sendJson(res, result)
}
private async handleSnsDeletePost(pathname: string, res: http.ServerResponse): Promise<void> {
const postId = decodeURIComponent(pathname.replace('/api/v1/sns/post/', '')).trim()
if (!postId) {
this.sendError(res, 400, 'Missing required path parameter: postId')
return
}
const result = await snsService.deleteSnsPost(postId)
if (!result.success) {
this.sendError(res, 500, result.error || 'Failed to delete sns post')
return
}
this.sendJson(res, result)
}
private toSnsMediaKey(value: unknown): string | number | undefined {
if (value == null) return undefined
if (typeof value === 'number' && Number.isFinite(value)) return value
const text = String(value).trim()
if (!text) return undefined
if (/^-?\d+$/.test(text)) return Number(text)
return text
}
private buildSnsMediaProxyUrl(rawUrl: string, key?: string | number): string | undefined {
const target = String(rawUrl || '').trim()
if (!target) return undefined
const params = new URLSearchParams({ url: target })
if (key !== undefined) params.set('key', String(key))
return `http://${this.host}:${this.port}/api/v1/sns/media/proxy?${params.toString()}`
}
private async resolveSnsMediaUrl(
rawUrl: string,
key: string | number | undefined,
inline: boolean
): Promise<{ resolvedUrl?: string; proxyUrl?: string }> {
const proxyUrl = this.buildSnsMediaProxyUrl(rawUrl, key)
if (!proxyUrl) return {}
if (!inline) return { resolvedUrl: proxyUrl, proxyUrl }
try {
const resolved = await snsService.proxyImage(rawUrl, key)
if (resolved.success && resolved.dataUrl) {
return { resolvedUrl: resolved.dataUrl, proxyUrl }
}
} catch (error) {
console.warn('[HttpService] resolveSnsMediaUrl inline failed:', error)
}
return { resolvedUrl: proxyUrl, proxyUrl }
}
private async enrichSnsTimelineMedia(posts: any[], inline: boolean, replace: boolean): Promise<any[]> {
return Promise.all(
(posts || []).map(async (post) => {
const mediaList = Array.isArray(post?.media) ? post.media : []
if (mediaList.length === 0) return post
const nextMedia = await Promise.all(
mediaList.map(async (media: any) => {
const rawUrl = typeof media?.url === 'string' ? media.url : ''
const rawThumb = typeof media?.thumb === 'string' ? media.thumb : ''
const mediaKey = this.toSnsMediaKey(media?.key)
const [urlResolved, thumbResolved] = await Promise.all([
this.resolveSnsMediaUrl(rawUrl, mediaKey, inline),
this.resolveSnsMediaUrl(rawThumb, mediaKey, inline)
])
const nextItem: any = {
...media,
rawUrl,
rawThumb,
resolvedUrl: urlResolved.resolvedUrl,
resolvedThumbUrl: thumbResolved.resolvedUrl,
proxyUrl: urlResolved.proxyUrl,
proxyThumbUrl: thumbResolved.proxyUrl
}
if (replace) {
nextItem.url = urlResolved.resolvedUrl || rawUrl
nextItem.thumb = thumbResolved.resolvedUrl || rawThumb
}
if (media?.livePhoto && typeof media.livePhoto === 'object') {
const livePhoto = media.livePhoto
const rawLiveUrl = typeof livePhoto.url === 'string' ? livePhoto.url : ''
const rawLiveThumb = typeof livePhoto.thumb === 'string' ? livePhoto.thumb : ''
const liveKey = this.toSnsMediaKey(livePhoto.key ?? mediaKey)
const [liveUrlResolved, liveThumbResolved] = await Promise.all([
this.resolveSnsMediaUrl(rawLiveUrl, liveKey, inline),
this.resolveSnsMediaUrl(rawLiveThumb, liveKey, inline)
])
const nextLive: any = {
...livePhoto,
rawUrl: rawLiveUrl,
rawThumb: rawLiveThumb,
resolvedUrl: liveUrlResolved.resolvedUrl,
resolvedThumbUrl: liveThumbResolved.resolvedUrl,
proxyUrl: liveUrlResolved.proxyUrl,
proxyThumbUrl: liveThumbResolved.proxyUrl
}
if (replace) {
nextLive.url = liveUrlResolved.resolvedUrl || rawLiveUrl
nextLive.thumb = liveThumbResolved.resolvedUrl || rawLiveThumb
}
nextItem.livePhoto = nextLive
}
return nextItem
})
)
return {
...post,
media: nextMedia
}
})
)
}
private getApiMediaExportPath(): string {
return path.join(this.configService.getCacheBasePath(), 'api-media')
}
@@ -764,6 +1280,30 @@ class HttpService {
const sessionDir = path.join(this.getApiMediaExportPath(), this.sanitizeFileName(talker, 'session'))
this.ensureDir(sessionDir)
// 预热图片 hardlink 索引,减少逐条导出时的查找开销
if (options.exportImages) {
const imageMd5Set = new Set<string>()
for (const msg of messages) {
if (msg.localType !== 3) continue
const imageMd5 = String(msg.imageMd5 || '').trim().toLowerCase()
if (imageMd5) {
imageMd5Set.add(imageMd5)
continue
}
const imageDatName = String(msg.imageDatName || '').trim().toLowerCase()
if (/^[a-f0-9]{32}$/i.test(imageDatName)) {
imageMd5Set.add(imageDatName)
}
}
if (imageMd5Set.size > 0) {
try {
await imageDecryptService.preloadImageHardlinkMd5s(Array.from(imageMd5Set))
} catch {
// ignore preload failures
}
}
}
for (const msg of messages) {
const exported = await this.exportMediaForMessage(msg, talker, sessionDir, options)
if (exported) {
@@ -786,27 +1326,54 @@ class HttpService {
sessionId: talker,
imageMd5: msg.imageMd5,
imageDatName: msg.imageDatName,
force: true
createTime: msg.createTime,
force: true,
preferFilePath: true,
hardlinkOnly: true,
disableUpdateCheck: true,
suppressEvents: true
})
if (result.success && result.localPath) {
let imagePath = result.localPath
let imagePath = result.success ? result.localPath : undefined
if (!imagePath) {
try {
const cached = await imageDecryptService.resolveCachedImage({
sessionId: talker,
imageMd5: msg.imageMd5,
imageDatName: msg.imageDatName,
createTime: msg.createTime,
preferFilePath: true,
hardlinkOnly: true,
disableUpdateCheck: true,
suppressEvents: true
})
if (cached.success && cached.localPath) {
imagePath = cached.localPath
}
} catch {
// ignore resolve failures
}
}
if (imagePath) {
if (imagePath.startsWith('data:')) {
const base64Match = imagePath.match(/^data:[^;]+;base64,(.+)$/)
if (base64Match) {
const imageBuffer = Buffer.from(base64Match[1], 'base64')
const ext = this.detectImageExt(imageBuffer)
const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`)
const fileName = `${fileBase}${ext}`
const targetDir = path.join(sessionDir, 'images')
const fullPath = path.join(targetDir, fileName)
this.ensureDir(targetDir)
if (!fs.existsSync(fullPath)) {
fs.writeFileSync(fullPath, imageBuffer)
}
const relativePath = `${this.sanitizeFileName(talker, 'session')}/images/${fileName}`
return { kind: 'image', fileName, fullPath, relativePath }
if (!base64Match) return null
const imageBuffer = Buffer.from(base64Match[1], 'base64')
const ext = this.detectImageExt(imageBuffer)
const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`)
const fileName = `${fileBase}${ext}`
const targetDir = path.join(sessionDir, 'images')
const fullPath = path.join(targetDir, fileName)
this.ensureDir(targetDir)
if (!fs.existsSync(fullPath)) {
fs.writeFileSync(fullPath, imageBuffer)
}
} else if (fs.existsSync(imagePath)) {
const relativePath = `${this.sanitizeFileName(talker, 'session')}/images/${fileName}`
return { kind: 'image', fileName, fullPath, relativePath }
}
if (fs.existsSync(imagePath)) {
const imageBuffer = fs.readFileSync(imagePath)
const ext = this.detectImageExt(imageBuffer)
const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`)
@@ -895,7 +1462,7 @@ class HttpService {
parsedContent: msg.parsedContent,
mediaType: media?.kind,
mediaFileName: media?.fileName,
mediaUrl: media ? `http://127.0.0.1:${this.port}/api/v1/media/${media.relativePath}` : undefined,
mediaUrl: media ? `http://${this.host}:${this.port}/api/v1/media/${media.relativePath}` : undefined,
mediaLocalPath: media?.fullPath
}
}
@@ -1165,7 +1732,7 @@ class HttpService {
type: this.mapMessageType(msg.localType, msg),
content: this.getMessageContent(msg),
platformMessageId: msg.serverId ? String(msg.serverId) : undefined,
mediaPath: mediaMap.get(msg.localId) ? `http://127.0.0.1:${this.port}/api/v1/media/${mediaMap.get(msg.localId)!.relativePath}` : undefined
mediaPath: mediaMap.get(msg.localId) ? `http://${this.host}:${this.port}/api/v1/media/${mediaMap.get(msg.localId)!.relativePath}` : undefined
}
})
@@ -1382,6 +1949,11 @@ class HttpService {
res.end(JSON.stringify(data, null, 2))
}
private sendMethodNotAllowed(res: http.ServerResponse, allow: string): void {
res.setHeader('Allow', allow)
this.sendError(res, 405, `Method Not Allowed. Allowed: ${allow}`)
}
/**
* 发送错误响应
*/

File diff suppressed because it is too large Load Diff

View File

@@ -4,38 +4,63 @@ type PreloadImagePayload = {
sessionId?: string
imageMd5?: string
imageDatName?: string
createTime?: number
}
type PreloadOptions = {
allowDecrypt?: boolean
allowCacheIndex?: boolean
}
type PreloadTask = PreloadImagePayload & {
key: string
allowDecrypt: boolean
allowCacheIndex: boolean
}
export class ImagePreloadService {
private queue: PreloadTask[] = []
private pending = new Set<string>()
private active = 0
private readonly maxConcurrent = 2
private activeCache = 0
private activeDecrypt = 0
private readonly maxCacheConcurrent = 8
private readonly maxDecryptConcurrent = 2
private readonly maxQueueSize = 320
enqueue(payloads: PreloadImagePayload[]): void {
enqueue(payloads: PreloadImagePayload[], options?: PreloadOptions): void {
if (!Array.isArray(payloads) || payloads.length === 0) return
const allowDecrypt = options?.allowDecrypt !== false
const allowCacheIndex = options?.allowCacheIndex !== false
for (const payload of payloads) {
if (!allowDecrypt && this.queue.length >= this.maxQueueSize) break
const cacheKey = payload.imageMd5 || payload.imageDatName
if (!cacheKey) continue
const key = `${payload.sessionId || 'unknown'}|${cacheKey}`
if (this.pending.has(key)) continue
this.pending.add(key)
this.queue.push({ ...payload, key })
this.queue.push({ ...payload, key, allowDecrypt, allowCacheIndex })
}
this.processQueue()
}
private processQueue(): void {
while (this.active < this.maxConcurrent && this.queue.length > 0) {
const task = this.queue.shift()
while (this.queue.length > 0) {
const taskIndex = this.queue.findIndex((task) => (
task.allowDecrypt
? this.activeDecrypt < this.maxDecryptConcurrent
: this.activeCache < this.maxCacheConcurrent
))
if (taskIndex < 0) return
const task = this.queue.splice(taskIndex, 1)[0]
if (!task) return
this.active += 1
if (task.allowDecrypt) this.activeDecrypt += 1
else this.activeCache += 1
void this.handleTask(task).finally(() => {
this.active -= 1
if (task.allowDecrypt) this.activeDecrypt = Math.max(0, this.activeDecrypt - 1)
else this.activeCache = Math.max(0, this.activeCache - 1)
this.pending.delete(task.key)
this.processQueue()
})
@@ -49,13 +74,25 @@ export class ImagePreloadService {
const cached = await imageDecryptService.resolveCachedImage({
sessionId: task.sessionId,
imageMd5: task.imageMd5,
imageDatName: task.imageDatName
imageDatName: task.imageDatName,
createTime: task.createTime,
preferFilePath: true,
hardlinkOnly: true,
disableUpdateCheck: !task.allowDecrypt,
allowCacheIndex: task.allowCacheIndex,
suppressEvents: true
})
if (cached.success) return
if (!task.allowDecrypt) return
await imageDecryptService.decryptImage({
sessionId: task.sessionId,
imageMd5: task.imageMd5,
imageDatName: task.imageDatName
imageDatName: task.imageDatName,
createTime: task.createTime,
preferFilePath: true,
hardlinkOnly: true,
disableUpdateCheck: true,
suppressEvents: true
})
} catch {
// ignore preload failures

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,7 @@ import crypto from 'crypto'
const execFileAsync = promisify(execFile)
type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] }
type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; error?: string }
type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; verified?: boolean; error?: string }
export class KeyService {
private readonly isMac = process.platform === 'darwin'
@@ -61,6 +61,7 @@ export class KeyService {
private getDllPath(): string {
const isPackaged = typeof app !== 'undefined' && app ? app.isPackaged : process.env.NODE_ENV === 'production'
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
const candidates: string[] = []
if (process.env.WX_KEY_DLL_PATH) {
@@ -68,11 +69,20 @@ export class KeyService {
}
if (isPackaged) {
candidates.push(join(process.resourcesPath, 'resources', 'key', 'win32', archDir, 'wx_key.dll'))
candidates.push(join(process.resourcesPath, 'resources', 'key', 'win32', 'x64', 'wx_key.dll'))
candidates.push(join(process.resourcesPath, 'resources', 'key', 'win32', 'wx_key.dll'))
candidates.push(join(process.resourcesPath, 'resources', 'wx_key.dll'))
candidates.push(join(process.resourcesPath, 'wx_key.dll'))
} else {
const cwd = process.cwd()
candidates.push(join(cwd, 'resources', 'key', 'win32', archDir, 'wx_key.dll'))
candidates.push(join(cwd, 'resources', 'key', 'win32', 'x64', 'wx_key.dll'))
candidates.push(join(cwd, 'resources', 'key', 'win32', 'wx_key.dll'))
candidates.push(join(cwd, 'resources', 'wx_key.dll'))
candidates.push(join(app.getAppPath(), 'resources', 'key', 'win32', archDir, 'wx_key.dll'))
candidates.push(join(app.getAppPath(), 'resources', 'key', 'win32', 'x64', 'wx_key.dll'))
candidates.push(join(app.getAppPath(), 'resources', 'key', 'win32', 'wx_key.dll'))
candidates.push(join(app.getAppPath(), 'resources', 'wx_key.dll'))
}
@@ -684,10 +694,7 @@ export class KeyService {
return { success: false, error: '获取密钥超时', logs }
}
// --- Image Key (通过 DLL 从缓存目录获取 code用前端 wxid 计算密钥) ---
private cleanWxid(wxid: string): string {
// 截断到第二个下划线: wxid_g4pshorcc0r529_da6c → wxid_g4pshorcc0r529
const first = wxid.indexOf('_')
if (first === -1) return wxid
const second = wxid.indexOf('_', first + 1)
@@ -807,7 +814,7 @@ export class KeyService {
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: true, xorKey, aesKey, verified: true }
}
}
return { success: false, error: '缓存 code 与当前账号 wxid 未匹配,请确认账号目录后重试,或使用内存扫描' }
@@ -819,7 +826,7 @@ export class KeyService {
const { xorKey, aesKey } = this.deriveImageKeys(fallbackCode, fallbackWxid)
onProgress?.(`密钥获取成功 (wxid: ${fallbackWxid}, code: ${fallbackCode})`)
console.log('[ImageKey] 回退计算: wxid=', fallbackWxid, 'code=', fallbackCode)
return { success: true, xorKey, aesKey }
return { success: true, xorKey, aesKey, verified: false }
}
// --- 内存扫描备选方案(融合 Dart+Python 优点)---

View File

@@ -3,6 +3,7 @@ import { join } from 'path'
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
import { execFile, exec, spawn } from 'child_process'
import { promisify } from 'util'
import crypto from 'crypto'
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
@@ -10,28 +11,38 @@ 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 }
type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; verified?: boolean; error?: string }
export class KeyServiceLinux {
private sudo: any
constructor() {
try {
this.sudo = require('sudo-prompt');
this.sudo = require('@vscode/sudo-prompt');
} catch (e) {
console.error('Failed to load sudo-prompt', e);
console.error('Failed to load @vscode/sudo-prompt', e);
}
}
private getHelperPath(): string {
const isPackaged = app.isPackaged
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
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', 'key', 'linux', archDir, 'xkey_helper_linux'))
candidates.push(join(process.resourcesPath, 'resources', 'key', 'linux', 'x64', 'xkey_helper_linux'))
candidates.push(join(process.resourcesPath, 'resources', 'key', 'linux', 'xkey_helper_linux'))
candidates.push(join(process.resourcesPath, 'resources', 'xkey_helper_linux'))
candidates.push(join(process.resourcesPath, 'xkey_helper_linux'))
} else {
candidates.push(join(app.getAppPath(), 'resources', 'key', 'linux', archDir, 'xkey_helper_linux'))
candidates.push(join(app.getAppPath(), 'resources', 'key', 'linux', 'x64', 'xkey_helper_linux'))
candidates.push(join(app.getAppPath(), 'resources', 'key', 'linux', 'xkey_helper_linux'))
candidates.push(join(app.getAppPath(), 'resources', 'xkey_helper_linux'))
candidates.push(join(process.cwd(), 'resources', 'key', 'linux', archDir, 'xkey_helper_linux'))
candidates.push(join(process.cwd(), 'resources', 'key', 'linux', 'x64', 'xkey_helper_linux'))
candidates.push(join(process.cwd(), 'resources', 'key', 'linux', 'xkey_helper_linux'))
candidates.push(join(app.getAppPath(), '..', 'Xkey', 'build', 'xkey_helper_linux'))
}
for (const p of candidates) {
@@ -88,7 +99,12 @@ export class KeyServiceLinux {
'xwechat',
'/opt/wechat/wechat',
'/usr/bin/wechat',
'/opt/apps/com.tencent.wechat/files/wechat'
'/usr/local/bin/wechat',
'/usr/bin/wechat',
'/opt/apps/com.tencent.wechat/files/wechat',
'/usr/bin/wechat-bin',
'/usr/local/bin/wechat-bin',
'com.tencent.wechat'
]
for (const binName of wechatBins) {
@@ -142,7 +158,7 @@ export class KeyServiceLinux {
}
if (!pid) {
const err = '未能自动启动微信或获取PID失败请查看控制台日志或手动启动并登录。'
const err = '未能自动启动微信或获取PID失败请查看控制台日志或手动启动微信,看到登录窗口后点击确认。'
onStatus?.(err, 2)
return { success: false, error: err }
}
@@ -228,7 +244,14 @@ export class KeyServiceLinux {
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 }
const aesKey = String(keyObj.aesKey || '')
const verified = await this.verifyImageKeyByTemplate(accountPath, aesKey)
if (verified === true) {
onProgress?.('缓存密钥校验成功,已确认可用')
} else if (verified === false) {
onProgress?.('已从缓存计算密钥,但未通过本地模板校验')
}
return { success: true, xorKey: keyObj.xorKey, aesKey, verified: verified === true }
}
return { success: false, error: '未在缓存中找到匹配的图片密钥' }
} catch (err: any) {
@@ -236,6 +259,35 @@ export class KeyServiceLinux {
}
}
private async verifyImageKeyByTemplate(accountPath: string | undefined, aesKey: string): Promise<boolean | null> {
const normalizedPath = String(accountPath || '').trim()
if (!normalizedPath || !aesKey || aesKey.length < 16 || !existsSync(normalizedPath)) return null
try {
const template = await this._findTemplateData(normalizedPath, 32)
if (!template.ciphertext) return null
return this.verifyDerivedAesKey(aesKey, template.ciphertext)
} catch {
return null
}
}
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
}
}
public async autoGetImageKeyByMemoryScan(
accountPath: string,
onProgress?: (msg: string) => void

View File

@@ -1,13 +1,13 @@
import { app, shell } from 'electron'
import { join, basename, dirname } from 'path'
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
import { existsSync, readdirSync, readFileSync, statSync, chmodSync } from 'fs'
import { execFile, spawn } from 'child_process'
import { promisify } from 'util'
import crypto from 'crypto'
import { homedir } from 'os'
type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] }
type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; error?: string }
type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; verified?: boolean; error?: string }
const execFileAsync = promisify(execFile)
export class KeyServiceMac {
@@ -27,6 +27,7 @@ export class KeyServiceMac {
private getHelperPath(): string {
const isPackaged = app.isPackaged
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
const candidates: string[] = []
if (process.env.WX_KEY_HELPER_PATH) {
@@ -34,12 +35,21 @@ export class KeyServiceMac {
}
if (isPackaged) {
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', archDir, 'xkey_helper'))
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'universal', 'xkey_helper'))
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'xkey_helper'))
candidates.push(join(process.resourcesPath, 'resources', 'xkey_helper'))
candidates.push(join(process.resourcesPath, 'xkey_helper'))
} else {
const cwd = process.cwd()
candidates.push(join(cwd, 'resources', 'key', 'macos', archDir, 'xkey_helper'))
candidates.push(join(cwd, 'resources', 'key', 'macos', 'universal', 'xkey_helper'))
candidates.push(join(cwd, 'resources', 'key', 'macos', 'xkey_helper'))
candidates.push(join(cwd, 'resources', 'xkey_helper'))
candidates.push(join(cwd, 'Xkey', 'build', 'xkey_helper'))
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', archDir, 'xkey_helper'))
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'universal', 'xkey_helper'))
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'xkey_helper'))
candidates.push(join(app.getAppPath(), 'resources', 'xkey_helper'))
}
@@ -52,14 +62,24 @@ export class KeyServiceMac {
private getImageScanHelperPath(): string {
const isPackaged = app.isPackaged
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
const candidates: string[] = []
if (isPackaged) {
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', archDir, 'image_scan_helper'))
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'universal', 'image_scan_helper'))
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'image_scan_helper'))
candidates.push(join(process.resourcesPath, 'resources', 'image_scan_helper'))
candidates.push(join(process.resourcesPath, 'image_scan_helper'))
} else {
const cwd = process.cwd()
candidates.push(join(cwd, 'resources', 'key', 'macos', archDir, 'image_scan_helper'))
candidates.push(join(cwd, 'resources', 'key', 'macos', 'universal', 'image_scan_helper'))
candidates.push(join(cwd, 'resources', 'key', 'macos', 'image_scan_helper'))
candidates.push(join(cwd, 'resources', 'image_scan_helper'))
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', archDir, 'image_scan_helper'))
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'universal', 'image_scan_helper'))
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'image_scan_helper'))
candidates.push(join(app.getAppPath(), 'resources', 'image_scan_helper'))
}
@@ -72,6 +92,7 @@ export class KeyServiceMac {
private getDylibPath(): string {
const isPackaged = app.isPackaged
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
const candidates: string[] = []
if (process.env.WX_KEY_DYLIB_PATH) {
@@ -79,11 +100,20 @@ export class KeyServiceMac {
}
if (isPackaged) {
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', archDir, 'libwx_key.dylib'))
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'universal', 'libwx_key.dylib'))
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'libwx_key.dylib'))
candidates.push(join(process.resourcesPath, 'resources', 'libwx_key.dylib'))
candidates.push(join(process.resourcesPath, 'libwx_key.dylib'))
} else {
const cwd = process.cwd()
candidates.push(join(cwd, 'resources', 'key', 'macos', archDir, 'libwx_key.dylib'))
candidates.push(join(cwd, 'resources', 'key', 'macos', 'universal', 'libwx_key.dylib'))
candidates.push(join(cwd, 'resources', 'key', 'macos', 'libwx_key.dylib'))
candidates.push(join(cwd, 'resources', 'libwx_key.dylib'))
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', archDir, 'libwx_key.dylib'))
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'universal', 'libwx_key.dylib'))
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'libwx_key.dylib'))
candidates.push(join(app.getAppPath(), 'resources', 'libwx_key.dylib'))
}
@@ -373,31 +403,81 @@ export class KeyServiceMac {
return `'${String(text).replace(/'/g, `'\\''`)}'`
}
private collectMacKeyArtifactPaths(primaryBinaryPath: string): string[] {
const baseDir = dirname(primaryBinaryPath)
const names = ['xkey_helper', 'image_scan_helper', 'xkey_helper_macos', 'libwx_key.dylib']
const unique: string[] = []
for (const name of names) {
const full = join(baseDir, name)
if (!existsSync(full)) continue
if (!unique.includes(full)) unique.push(full)
}
if (existsSync(primaryBinaryPath) && !unique.includes(primaryBinaryPath)) {
unique.unshift(primaryBinaryPath)
}
return unique
}
private ensureExecutableBitsBestEffort(paths: string[]): void {
for (const p of paths) {
try {
const mode = statSync(p).mode
if ((mode & 0o111) !== 0) continue
chmodSync(p, mode | 0o111)
} catch {
// ignore: 可能无权限(例如 /Applications 下 root-owned 的 .app
}
}
}
private async ensureExecutableBitsWithElevation(paths: string[], timeoutMs: number): Promise<void> {
const existing = paths.filter(p => existsSync(p))
if (existing.length === 0) return
const quotedPaths = existing.map(p => this.shellSingleQuote(p)).join(' ')
const timeoutSec = Math.max(30, Math.ceil(timeoutMs / 1000))
const scriptLines = [
`set chmodCmd to "/bin/chmod +x ${quotedPaths}"`,
`set timeoutSec to ${timeoutSec}`,
'with timeout of timeoutSec seconds',
'do shell script chmodCmd with administrator privileges',
'end timeout'
]
await execFileAsync('/usr/bin/osascript', scriptLines.flatMap(line => ['-e', line]), {
timeout: timeoutMs + 10_000
})
}
private async getDbKeyByHelperElevated(
timeoutMs: number,
onStatus?: (message: string, level: number) => void
): Promise<string> {
const helperPath = this.getHelperPath()
const artifactPaths = this.collectMacKeyArtifactPaths(helperPath)
this.ensureExecutableBitsBestEffort(artifactPaths)
const waitMs = Math.max(timeoutMs, 30_000)
const timeoutSec = Math.ceil(waitMs / 1000) + 30
const pid = await this.getWeChatPid()
const chmodPart = artifactPaths.length > 0
? `/bin/chmod +x ${artifactPaths.map(p => this.shellSingleQuote(p)).join(' ')}`
: ''
const runPart = `${this.shellSingleQuote(helperPath)} ${pid} ${waitMs}`
const privilegedCmd = chmodPart ? `${chmodPart} && ${runPart}` : runPart
// 用 AppleScript 的 quoted form 组装命令,避免复杂 shell 拼接导致整条失败
// 通过 try/on error 回传详细错误,避免只看到 "Command failed"
const scriptLines = [
`set helperPath to ${JSON.stringify(helperPath)}`,
`set cmd to quoted form of helperPath & " ${pid} ${waitMs}"`,
`set cmd to ${JSON.stringify(privilegedCmd)}`,
`set timeoutSec to ${timeoutSec}`,
'try',
'with timeout of timeoutSec seconds',
'set outText to do shell script cmd with administrator privileges',
'set outText to do shell script (cmd & " 2>&1") with administrator privileges',
'end timeout',
'return "WF_OK::" & outText',
'on error errMsg number errNum partial result pr',
'return "WF_ERR::" & errNum & "::" & errMsg & "::" & (pr as text)',
'end try'
]
onStatus?.('已准备就绪,现在登录微信或退出登录后重新登录微信', 0)
let stdout = ''
try {
const result = await execFileAsync('/usr/bin/osascript', scriptLines.flatMap(line => ['-e', line]), {
@@ -473,7 +553,19 @@ export class KeyServiceMac {
if (code === 'HOOK_TARGET_ONLY') {
return `已定位到目标函数地址(${detail || ''}),但当前原生 C++ 仅完成定位,尚未完成远程 Hook 回调取 key 流程。`
}
if (code === 'SCAN_FAILED') return '内存扫描失败'
if (code === 'SCAN_FAILED') {
const normalizedDetail = (detail || '').trim()
if (!normalizedDetail) {
return '内存扫描失败:未匹配到可用特征。可能是当前微信版本更新导致,请升级 WeFlow 后重试。'
}
if (normalizedDetail.includes('Sink pattern not found')) {
return '内存扫描失败:未匹配到目标函数特征,可使用微信 4.1.8.100 版本尝试。'
}
if (normalizedDetail.includes('No suitable module found')) {
return '内存扫描失败:未找到可扫描的微信主模块。请确认微信已完整启动并保持前台,再重试。'
}
return `内存扫描失败:${normalizedDetail}`
}
return '未知错误'
}
@@ -553,7 +645,7 @@ export class KeyServiceMac {
const { xorKey, aesKey } = this.deriveImageKeys(code, candidateWxid)
if (!this.verifyDerivedAesKey(aesKey, template.ciphertext)) continue
onStatus?.(`密钥获取成功 (wxid: ${candidateWxid}, code: ${code})`)
return { success: true, xorKey, aesKey }
return { success: true, xorKey, aesKey, verified: true }
}
}
}
@@ -568,7 +660,7 @@ export class KeyServiceMac {
const fallbackCode = codes[0]
const { xorKey, aesKey } = this.deriveImageKeys(fallbackCode, fallbackWxid)
onStatus?.(`密钥获取成功 (wxid: ${fallbackWxid}, code: ${fallbackCode})`)
return { success: true, xorKey, aesKey }
return { success: true, xorKey, aesKey, verified: false }
} catch (e: any) {
return { success: false, error: `自动获取图片密钥失败: ${e.message}` }
}
@@ -721,10 +813,12 @@ export class KeyServiceMac {
try {
const helperPath = this.getImageScanHelperPath()
const ciphertextHex = ciphertext.toString('hex')
const artifactPaths = this.collectMacKeyArtifactPaths(helperPath)
this.ensureExecutableBitsBestEffort(artifactPaths)
// 1) 直接运行 helper有正式签名的 debugger entitlement 时可用)
if (!this._needsElevation) {
const direct = await this._spawnScanHelper(helperPath, pid, ciphertextHex, false)
const direct = await this._spawnScanHelper(helperPath, pid, ciphertextHex, false, artifactPaths)
if (direct.key) return direct.key
if (direct.permissionError) {
console.warn('[KeyServiceMac] task_for_pid 权限不足,切换到 osascript 提权模式')
@@ -735,7 +829,12 @@ export class KeyServiceMac {
// 2) 通过 osascript 以管理员权限运行 helperSIP 下 ad-hoc 签名无法获取 task_for_pid
if (this._needsElevation) {
const elevated = await this._spawnScanHelper(helperPath, pid, ciphertextHex, true)
try {
await this.ensureExecutableBitsWithElevation(artifactPaths, 45_000)
} catch (e: any) {
console.warn('[KeyServiceMac] elevated chmod failed before image scan:', e?.message || e)
}
const elevated = await this._spawnScanHelper(helperPath, pid, ciphertextHex, true, artifactPaths)
if (elevated.key) return elevated.key
}
} catch (e: any) {
@@ -838,12 +937,19 @@ export class KeyServiceMac {
}
private _spawnScanHelper(
helperPath: string, pid: number, ciphertextHex: string, elevated: boolean
helperPath: string,
pid: number,
ciphertextHex: string,
elevated: boolean,
artifactPaths: string[] = []
): Promise<{ key: string | null; permissionError: boolean }> {
return new Promise((resolve, reject) => {
let child: ReturnType<typeof spawn>
if (elevated) {
const shellCmd = `'${helperPath}' ${pid} ${ciphertextHex}`
const chmodPart = artifactPaths.length > 0
? `/bin/chmod +x ${artifactPaths.map(p => this.shellSingleQuote(p)).join(' ')} && `
: ''
const shellCmd = `${chmodPart}${this.shellSingleQuote(helperPath)} ${pid} ${ciphertextHex}`
child = spawn('/usr/bin/osascript', ['-e', `do shell script ${JSON.stringify(shellCmd)} with administrator privileges`],
{ stdio: ['ignore', 'pipe', 'pipe'] })
} else {
@@ -935,10 +1041,17 @@ export class KeyServiceMac {
private resolveXwechatRootFromPath(accountPath?: string): string | null {
const normalized = String(accountPath || '').replace(/\\/g, '/').replace(/\/+$/, '')
if (!normalized) return null
// 旧路径xwechat_files
const marker = '/xwechat_files'
const markerIdx = normalized.indexOf(marker)
if (markerIdx < 0) return null
return normalized.slice(0, markerIdx + marker.length)
if (markerIdx >= 0) return normalized.slice(0, markerIdx + marker.length)
// 新路径(微信 4.0.5+Application Support/com.tencent.xinWeChat/2.0b4.0.9
const newMarkerMatch = normalized.match(/^(.*\/com\.tencent\.xinWeChat\/(?:\d+\.\d+b\d+\.\d+|\d+\.\d+\.\d+))(\/|$)/)
if (newMarkerMatch) return newMarkerMatch[1]
return null
}
private pushAccountIdCandidates(candidates: string[], value?: string): void {
@@ -1096,6 +1209,16 @@ export class KeyServiceMac {
candidates.add(`${base}/app_data/net/kvcomm`)
}
// 微信 4.0.5+ 新路径推导:版本目录同级的 net/kvcomm
const newMarkerMatch = normalized.match(/^(.*\/com\.tencent\.xinWeChat\/(?:\d+\.\d+b\d+\.\d+|\d+\.\d+\.\d+))/)
if (newMarkerMatch) {
const versionBase = newMarkerMatch[1]
candidates.add(`${versionBase}/net/kvcomm`)
// 上级目录也尝试
const parentBase = versionBase.replace(/\/[^\/]+$/, '')
candidates.add(`${parentBase}/net/kvcomm`)
}
let cursor = accountPath
for (let i = 0; i < 6; i++) {
candidates.add(join(cursor, 'net', 'kvcomm'))

View File

@@ -0,0 +1,174 @@
import { Notification } from "electron";
import { avatarFileCache, AvatarFileCacheService } from "./avatarFileCacheService";
export interface LinuxNotificationData {
sessionId?: string;
title: string;
content: string;
avatarUrl?: string;
expireTimeout?: number;
}
type NotificationCallback = (sessionId: string) => void;
let notificationCallbacks: NotificationCallback[] = [];
let notificationCounter = 1;
const activeNotifications: Map<number, Notification> = new Map();
const closeTimers: Map<number, NodeJS.Timeout> = new Map();
function nextNotificationId(): number {
const id = notificationCounter;
notificationCounter += 1;
return id;
}
function clearNotificationState(notificationId: number): void {
activeNotifications.delete(notificationId);
const timer = closeTimers.get(notificationId);
if (timer) {
clearTimeout(timer);
closeTimers.delete(notificationId);
}
}
function triggerNotificationCallback(sessionId: string): void {
for (const callback of notificationCallbacks) {
try {
callback(sessionId);
} catch (error) {
console.error("[LinuxNotification] Callback error:", error);
}
}
}
export async function showLinuxNotification(
data: LinuxNotificationData,
): Promise<number | null> {
if (process.platform !== "linux") {
return null;
}
if (!Notification.isSupported()) {
console.warn("[LinuxNotification] Notification API is not supported");
return null;
}
try {
let iconPath: string | undefined;
if (data.avatarUrl) {
iconPath = (await avatarFileCache.getAvatarPath(data.avatarUrl)) || undefined;
}
const notification = new Notification({
title: data.title,
body: data.content,
icon: iconPath,
});
const notificationId = nextNotificationId();
activeNotifications.set(notificationId, notification);
notification.on("click", () => {
if (data.sessionId) {
triggerNotificationCallback(data.sessionId);
}
});
notification.on("close", () => {
clearNotificationState(notificationId);
});
notification.on("failed", (_, error) => {
console.error("[LinuxNotification] Notification failed:", error);
clearNotificationState(notificationId);
});
const expireTimeout = data.expireTimeout ?? 5000;
if (expireTimeout > 0) {
const timer = setTimeout(() => {
const currentNotification = activeNotifications.get(notificationId);
if (currentNotification) {
currentNotification.close();
}
}, expireTimeout);
closeTimers.set(notificationId, timer);
}
notification.show();
console.log(
`[LinuxNotification] Shown notification ${notificationId}: ${data.title}`,
);
return notificationId;
} catch (error) {
console.error("[LinuxNotification] Failed to show notification:", error);
return null;
}
}
export async function closeLinuxNotification(
notificationId: number,
): Promise<void> {
const notification = activeNotifications.get(notificationId);
if (!notification) return;
notification.close();
clearNotificationState(notificationId);
}
export async function getCapabilities(): Promise<string[]> {
if (process.platform !== "linux") {
return [];
}
if (!Notification.isSupported()) {
return [];
}
return ["native-notification", "click"];
}
export function onNotificationAction(callback: NotificationCallback): void {
notificationCallbacks.push(callback);
}
export function removeNotificationCallback(
callback: NotificationCallback,
): void {
const index = notificationCallbacks.indexOf(callback);
if (index > -1) {
notificationCallbacks.splice(index, 1);
}
}
export async function initLinuxNotificationService(): Promise<void> {
if (process.platform !== "linux") {
console.log("[LinuxNotification] Not on Linux, skipping init");
return;
}
if (!Notification.isSupported()) {
console.warn("[LinuxNotification] Notification API is not supported");
return;
}
const caps = await getCapabilities();
console.log("[LinuxNotification] Service initialized with native API:", caps);
}
export async function shutdownLinuxNotificationService(): Promise<void> {
// 清理所有活动的通知
for (const [id, notification] of activeNotifications) {
try {
notification.close();
} catch {}
clearNotificationState(id);
}
// 清理头像文件缓存
try {
await avatarFileCache.clearCache();
} catch {}
console.log("[LinuxNotification] Service shutdown complete");
}

View File

@@ -2,6 +2,10 @@ import { ConfigService } from './config'
import { chatService, type ChatSession, type Message } from './chatService'
import { wcdbService } from './wcdbService'
import { httpService } from './httpService'
import { promises as fs } from 'fs'
import path from 'path'
import { createHash } from 'crypto'
import { pathToFileURL } from 'url'
interface SessionBaseline {
lastTimestamp: number
@@ -11,15 +15,19 @@ interface SessionBaseline {
interface MessagePushPayload {
event: 'message.new'
sessionId: string
sessionType: 'private' | 'group' | 'official' | 'other'
messageKey: string
avatarUrl?: string
sourceName: string
groupName?: string
content: string | null
timestamp: number
}
const PUSH_CONFIG_KEYS = new Set([
'messagePushEnabled',
'messagePushFilterMode',
'messagePushFilterList',
'dbPath',
'decryptKey',
'myWxid'
@@ -30,6 +38,8 @@ class MessagePushService {
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 pushAvatarCacheDir: string
private readonly pushAvatarDataCache = new Map<string, string>()
private readonly debounceMs = 350
private readonly recentMessageTtlMs = 10 * 60 * 1000
private readonly groupNicknameCacheTtlMs = 5 * 60 * 1000
@@ -38,9 +48,11 @@ class MessagePushService {
private rerunRequested = false
private started = false
private baselineReady = false
private messageTableScanRequested = false
constructor() {
this.configService = ConfigService.getInstance()
this.pushAvatarCacheDir = path.join(this.configService.getCacheBasePath(), 'push-avatar-files')
}
start(): void {
@@ -49,6 +61,13 @@ class MessagePushService {
void this.refreshConfiguration('startup')
}
stop(): void {
this.started = false
this.processing = false
this.rerunRequested = false
this.resetRuntimeState()
}
handleDbMonitorChange(type: string, json: string): void {
if (!this.started) return
if (!this.isPushEnabled()) return
@@ -60,12 +79,15 @@ class MessagePushService {
payload = null
}
const tableName = String(payload?.table || '').trim().toLowerCase()
if (tableName && tableName !== 'session') {
const tableName = String(payload?.table || '').trim()
if (this.isSessionTableChange(tableName)) {
this.scheduleSync()
return
}
this.scheduleSync()
if (!tableName || this.isMessageTableChange(tableName)) {
this.scheduleSync({ scanMessageBackedSessions: true })
}
}
async handleConfigChanged(key: string): Promise<void> {
@@ -91,6 +113,7 @@ class MessagePushService {
this.recentMessageKeys.clear()
this.groupNicknameCache.clear()
this.baselineReady = false
this.messageTableScanRequested = false
if (this.debounceTimer) {
clearTimeout(this.debounceTimer)
this.debounceTimer = null
@@ -121,7 +144,11 @@ class MessagePushService {
this.baselineReady = true
}
private scheduleSync(): void {
private scheduleSync(options: { scanMessageBackedSessions?: boolean } = {}): void {
if (options.scanMessageBackedSessions) {
this.messageTableScanRequested = true
}
if (this.debounceTimer) {
clearTimeout(this.debounceTimer)
}
@@ -141,6 +168,8 @@ class MessagePushService {
this.processing = true
try {
if (!this.isPushEnabled()) return
const scanMessageBackedSessions = this.messageTableScanRequested
this.messageTableScanRequested = false
const connectResult = await chatService.connect()
if (!connectResult.success) {
@@ -163,27 +192,47 @@ class MessagePushService {
const previousBaseline = new Map(this.sessionBaseline)
this.setBaseline(sessions)
const candidates = sessions.filter((session) => this.shouldInspectSession(previousBaseline.get(session.username), session))
const candidates = sessions.filter((session) => {
const previous = previousBaseline.get(session.username)
if (this.shouldInspectSession(previous, session)) {
return true
}
return scanMessageBackedSessions && this.shouldScanMessageBackedSession(previous, session)
})
for (const session of candidates) {
await this.pushSessionMessages(session, previousBaseline.get(session.username))
await this.pushSessionMessages(
session,
previousBaseline.get(session.username) || this.sessionBaseline.get(session.username)
)
}
} finally {
this.processing = false
if (this.rerunRequested) {
this.rerunRequested = false
this.scheduleSync()
this.scheduleSync({ scanMessageBackedSessions: this.messageTableScanRequested })
}
}
}
private setBaseline(sessions: ChatSession[]): void {
const previousBaseline = new Map(this.sessionBaseline)
const nextBaseline = new Map<string, SessionBaseline>()
const nowSeconds = Math.floor(Date.now() / 1000)
this.sessionBaseline.clear()
for (const session of sessions) {
this.sessionBaseline.set(session.username, {
lastTimestamp: Number(session.lastTimestamp || 0),
const username = String(session.username || '').trim()
if (!username) continue
const previous = previousBaseline.get(username)
const sessionTimestamp = Number(session.lastTimestamp || 0)
const initialTimestamp = sessionTimestamp > 0 ? sessionTimestamp : nowSeconds
nextBaseline.set(username, {
lastTimestamp: Math.max(sessionTimestamp, Number(previous?.lastTimestamp || 0), previous ? 0 : initialTimestamp),
unreadCount: Number(session.unreadCount || 0)
})
}
for (const [username, baseline] of nextBaseline.entries()) {
this.sessionBaseline.set(username, baseline)
}
}
private shouldInspectSession(previous: SessionBaseline | undefined, session: ChatSession): boolean {
@@ -204,16 +253,30 @@ class MessagePushService {
return unreadCount > 0 && lastTimestamp > 0
}
if (lastTimestamp <= previous.lastTimestamp) {
return lastTimestamp > previous.lastTimestamp || unreadCount > previous.unreadCount
}
private shouldScanMessageBackedSession(previous: SessionBaseline | undefined, session: ChatSession): boolean {
const sessionId = String(session.username || '').trim()
if (!sessionId || sessionId.toLowerCase().includes('placeholder_foldgroup')) {
return false
}
// unread 未增长时,大概率是自己发送、其他设备已读或状态同步,不作为主动推送
return unreadCount > previous.unreadCount
const summary = String(session.summary || '').trim()
if (Number(session.lastMsgType || 0) === 10002 || summary.includes('撤回了一条消息')) {
return false
}
const sessionType = this.getSessionType(sessionId, session)
if (sessionType === 'private') {
return false
}
return Boolean(previous) || Number(session.lastTimestamp || 0) > 0
}
private async pushSessionMessages(session: ChatSession, previous: SessionBaseline | undefined): Promise<void> {
const since = Math.max(0, Number(previous?.lastTimestamp || 0) - 1)
const since = Math.max(0, Number(previous?.lastTimestamp || 0))
const newMessagesResult = await chatService.getNewMessages(session.username, since, 1000)
if (!newMessagesResult.success || !newMessagesResult.messages || newMessagesResult.messages.length === 0) {
return
@@ -224,7 +287,7 @@ class MessagePushService {
if (!messageKey) continue
if (message.isSend === 1) continue
if (previous && Number(message.createTime || 0) < Number(previous.lastTimestamp || 0)) {
if (previous && Number(message.createTime || 0) <= Number(previous.lastTimestamp || 0)) {
continue
}
@@ -234,9 +297,11 @@ class MessagePushService {
const payload = await this.buildPayload(session, message)
if (!payload) continue
if (!this.shouldPushPayload(payload)) continue
httpService.broadcastMessagePush(payload)
this.rememberMessageKey(messageKey)
this.bumpSessionBaseline(session.username, message)
}
}
@@ -246,38 +311,166 @@ class MessagePushService {
if (!sessionId || !messageKey) return null
const isGroup = sessionId.endsWith('@chatroom')
const sessionType = this.getSessionType(sessionId, session)
const content = this.getMessageDisplayContent(message)
const createTime = Number(message.createTime || 0)
if (isGroup) {
const groupInfo = await chatService.getContactAvatar(sessionId)
const groupName = session.displayName || groupInfo?.displayName || sessionId
const sourceName = await this.resolveGroupSourceName(sessionId, message, session)
const avatarUrl = await this.normalizePushAvatarUrl(session.avatarUrl || groupInfo?.avatarUrl)
return {
event: 'message.new',
sessionId,
sessionType,
messageKey,
avatarUrl: session.avatarUrl || groupInfo?.avatarUrl,
avatarUrl,
groupName,
sourceName,
content
content,
timestamp: createTime
}
}
const contactInfo = await chatService.getContactAvatar(sessionId)
const avatarUrl = await this.normalizePushAvatarUrl(session.avatarUrl || contactInfo?.avatarUrl)
return {
event: 'message.new',
sessionId,
sessionType,
messageKey,
avatarUrl: session.avatarUrl || contactInfo?.avatarUrl,
avatarUrl,
sourceName: session.displayName || contactInfo?.displayName || sessionId,
content
content,
timestamp: createTime
}
}
private async normalizePushAvatarUrl(avatarUrl?: string): Promise<string | undefined> {
const normalized = String(avatarUrl || '').trim()
if (!normalized) return undefined
if (!normalized.startsWith('data:image/')) {
return normalized
}
const cached = this.pushAvatarDataCache.get(normalized)
if (cached) return cached
const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/i.exec(normalized)
if (!match) return undefined
try {
const mimeType = match[1].toLowerCase()
const base64Data = match[2]
const imageBuffer = Buffer.from(base64Data, 'base64')
if (!imageBuffer.length) return undefined
const ext = this.getImageExtFromMime(mimeType)
const hash = createHash('sha1').update(normalized).digest('hex')
const filePath = path.join(this.pushAvatarCacheDir, `avatar_${hash}.${ext}`)
await fs.mkdir(this.pushAvatarCacheDir, { recursive: true })
try {
await fs.access(filePath)
} catch {
await fs.writeFile(filePath, imageBuffer)
}
const fileUrl = pathToFileURL(filePath).toString()
this.pushAvatarDataCache.set(normalized, fileUrl)
return fileUrl
} catch {
return undefined
}
}
private getImageExtFromMime(mimeType: string): string {
if (mimeType === 'image/png') return 'png'
if (mimeType === 'image/gif') return 'gif'
if (mimeType === 'image/webp') return 'webp'
return 'jpg'
}
private getSessionType(sessionId: string, session: ChatSession): MessagePushPayload['sessionType'] {
if (sessionId.endsWith('@chatroom')) {
return 'group'
}
if (sessionId.startsWith('gh_') || session.type === 'official') {
return 'official'
}
if (session.type === 'friend') {
return 'private'
}
return 'other'
}
private shouldPushPayload(payload: MessagePushPayload): boolean {
const sessionId = String(payload.sessionId || '').trim()
const filterMode = this.getMessagePushFilterMode()
if (filterMode === 'all') {
return true
}
const filterList = this.getMessagePushFilterList()
const listed = filterList.has(sessionId)
if (filterMode === 'whitelist') {
return listed
}
return !listed
}
private getMessagePushFilterMode(): 'all' | 'whitelist' | 'blacklist' {
const value = this.configService.get('messagePushFilterMode')
if (value === 'whitelist' || value === 'blacklist') return value
return 'all'
}
private getMessagePushFilterList(): Set<string> {
const value = this.configService.get('messagePushFilterList')
if (!Array.isArray(value)) return new Set()
return new Set(value.map((item) => String(item || '').trim()).filter(Boolean))
}
private isSessionTableChange(tableName: string): boolean {
return String(tableName || '').trim().toLowerCase() === 'session'
}
private isMessageTableChange(tableName: string): boolean {
const normalized = String(tableName || '').trim().toLowerCase()
if (!normalized) return false
return normalized === 'message' ||
normalized === 'msg' ||
normalized.startsWith('message_') ||
normalized.startsWith('msg_') ||
normalized.includes('message')
}
private bumpSessionBaseline(sessionId: string, message: Message): void {
const key = String(sessionId || '').trim()
if (!key) return
const createTime = Number(message.createTime || 0)
if (!Number.isFinite(createTime) || createTime <= 0) return
const current = this.sessionBaseline.get(key) || { lastTimestamp: 0, unreadCount: 0 }
if (createTime > current.lastTimestamp) {
this.sessionBaseline.set(key, {
...current,
lastTimestamp: createTime
})
}
}
private getMessageDisplayContent(message: Message): string | null {
const cleanOfficialPrefix = (value: string | null): string | null => {
if (!value) return value
return value.replace(/^\s*\[\]\s*/u, '').trim() || value
}
switch (Number(message.localType || 0)) {
case 1:
return message.rawContent || null
return cleanOfficialPrefix(message.rawContent || null)
case 3:
return '[图片]'
case 34:
@@ -287,13 +480,13 @@ class MessagePushService {
case 47:
return '[表情]'
case 42:
return message.cardNickname || '[名片]'
return cleanOfficialPrefix(message.cardNickname || '[名片]')
case 48:
return '[位置]'
case 49:
return message.linkTitle || message.fileName || '[消息]'
return cleanOfficialPrefix(message.linkTitle || message.fileName || '[消息]')
default:
return message.parsedContent || message.rawContent || null
return cleanOfficialPrefix(message.parsedContent || message.rawContent || null)
}
}

View File

@@ -0,0 +1,110 @@
import { existsSync } from 'fs'
import { join } from 'path'
type NativeDecryptResult = {
data: Buffer
ext: string
isWxgf?: boolean
is_wxgf?: boolean
}
type NativeAddon = {
decryptDatNative: (inputPath: string, xorKey: number, aesKey?: string) => NativeDecryptResult
}
let cachedAddon: NativeAddon | null | undefined
function shouldEnableNative(): boolean {
return process.env.WEFLOW_IMAGE_NATIVE !== '0'
}
function expandAsarCandidates(filePath: string): string[] {
if (!filePath.includes('app.asar') || filePath.includes('app.asar.unpacked')) {
return [filePath]
}
return [filePath.replace('app.asar', 'app.asar.unpacked'), filePath]
}
function getPlatformDir(): string {
if (process.platform === 'win32') return 'win32'
if (process.platform === 'darwin') return 'macos'
if (process.platform === 'linux') return 'linux'
return process.platform
}
function getArchDir(): string {
if (process.arch === 'x64') return 'x64'
if (process.arch === 'arm64') return 'arm64'
return process.arch
}
function getAddonCandidates(): string[] {
const platformDir = getPlatformDir()
const archDir = getArchDir()
const cwd = process.cwd()
const fileNames = [
`weflow-image-native-${platformDir}-${archDir}.node`
]
const roots = [
join(cwd, 'resources', 'wedecrypt', platformDir, archDir),
...(process.resourcesPath
? [
join(process.resourcesPath, 'resources', 'wedecrypt', platformDir, archDir),
join(process.resourcesPath, 'wedecrypt', platformDir, archDir)
]
: [])
]
const candidates = roots.flatMap((root) => fileNames.map((name) => join(root, name)))
return Array.from(new Set(candidates.flatMap(expandAsarCandidates)))
}
function loadAddon(): NativeAddon | null {
if (!shouldEnableNative()) return null
if (cachedAddon !== undefined) return cachedAddon
for (const candidate of getAddonCandidates()) {
if (!existsSync(candidate)) continue
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const addon = require(candidate) as NativeAddon
if (addon && typeof addon.decryptDatNative === 'function') {
cachedAddon = addon
return addon
}
} catch {
// try next candidate
}
}
cachedAddon = null
return null
}
export function nativeAddonLocation(): string | null {
for (const candidate of getAddonCandidates()) {
if (existsSync(candidate)) return candidate
}
return null
}
export function decryptDatViaNative(
inputPath: string,
xorKey: number,
aesKey?: string
): { data: Buffer; ext: string; isWxgf: boolean } | null {
const addon = loadAddon()
if (!addon) return null
try {
const result = addon.decryptDatNative(inputPath, xorKey, aesKey)
const isWxgf = Boolean(result?.isWxgf ?? result?.is_wxgf)
if (!result || !Buffer.isBuffer(result.data)) return null
const rawExt = typeof result.ext === 'string' && result.ext.trim()
? result.ext.trim().toLowerCase()
: ''
const ext = rawExt ? (rawExt.startsWith('.') ? rawExt : `.${rawExt}`) : ''
return { data: result.data, ext, isWxgf }
} catch {
return null
}
}

View File

@@ -1,7 +1,8 @@
import { wcdbService } from './wcdbService'
import { ConfigService } from './config'
import { ContactCacheService } from './contactCacheService'
import { existsSync, mkdirSync } from 'fs'
import { app } from 'electron'
import { existsSync, mkdirSync, unlinkSync } from 'fs'
import { readFile, writeFile, mkdir } from 'fs/promises'
import { basename, join } from 'path'
import crypto from 'crypto'
@@ -173,8 +174,17 @@ const detectImageMime = (buf: Buffer, fallback: string = 'image/jpeg') => {
// BMP
if (buf[0] === 0x42 && buf[1] === 0x4d) return 'image/bmp'
// MP4: 00 00 00 18 / 20 / ... + 'ftyp'
if (buf.length > 8 && buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70) return 'video/mp4'
// ISO BMFF 家族:优先识别 AVIF/HEIF避免误判为 MP4
if (buf.length > 12 && buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70) {
const ftypWindow = buf.subarray(8, Math.min(buf.length, 64)).toString('ascii').toLowerCase()
if (ftypWindow.includes('avif') || ftypWindow.includes('avis')) return 'image/avif'
if (
ftypWindow.includes('heic') || ftypWindow.includes('heix') ||
ftypWindow.includes('hevc') || ftypWindow.includes('hevx') ||
ftypWindow.includes('mif1') || ftypWindow.includes('msf1')
) return 'image/heic'
return 'video/mp4'
}
// Fallback logic for video
if (fallback.includes('video') || fallback.includes('mp4')) return 'video/mp4'
@@ -537,6 +547,32 @@ class SnsService {
return raw.trim()
}
private async collectSnsUsernamesFromTimeline(maxRounds: number = 2000): Promise<string[]> {
const pageSize = 500
const uniqueUsers = new Set<string>()
let offset = 0
for (let round = 0; round < maxRounds; 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) uniqueUsers.add(username)
}
if (rows.length < pageSize) break
offset += rows.length
}
return Array.from(uniqueUsers)
}
private async getExportStatsFromTimeline(myWxid?: string): Promise<{ totalPosts: number; totalFriends: number; myPosts: number | null }> {
const pageSize = 500
const uniqueUsers = new Set<string>()
@@ -775,14 +811,25 @@ class SnsService {
}
private getSnsCacheDir(): string {
const cachePath = this.configService.getCacheBasePath()
const snsCacheDir = join(cachePath, 'sns_cache')
const configuredCachePath = String(this.configService.get('cachePath') || '').trim()
const baseDir = configuredCachePath || join(app.getPath('documents'), 'WeFlow')
const snsCacheDir = join(baseDir, 'sns_cache')
if (!existsSync(snsCacheDir)) {
mkdirSync(snsCacheDir, { recursive: true })
}
return snsCacheDir
}
private getEmojiCacheDir(): string {
const configuredCachePath = String(this.configService.get('cachePath') || '').trim()
const baseDir = configuredCachePath || join(app.getPath('documents'), 'WeFlow')
const emojiDir = join(baseDir, 'Emojis')
if (!existsSync(emojiDir)) {
mkdirSync(emojiDir, { recursive: true })
}
return emojiDir
}
private getCacheFilePath(url: string): string {
const hash = crypto.createHash('md5').update(url).digest('hex')
const ext = isVideoUrl(url) ? '.mp4' : '.jpg'
@@ -794,7 +841,22 @@ class SnsService {
if (!result.success) {
return { success: false, error: result.error || '获取朋友圈联系人失败' }
}
return { success: true, usernames: result.usernames || [] }
const directUsernames = Array.isArray(result.usernames) ? result.usernames : []
if (directUsernames.length > 0) {
return { success: true, usernames: directUsernames }
}
// 回退:通过 timeline 分页拉取收集用户名,兼容底层接口暂时返回空数组的场景。
try {
const timelineUsers = await this.collectSnsUsernamesFromTimeline()
if (timelineUsers.length > 0) {
return { success: true, usernames: timelineUsers }
}
} catch {
// 忽略回退错误,保持与原行为一致返回空数组
}
return { success: true, usernames: directUsernames }
}
private async getExportStatsFromTableCount(myWxid?: string): Promise<{ totalPosts: number; totalFriends: number; myPosts: number | null }> {
@@ -1021,14 +1083,14 @@ class SnsService {
}
/**
* 补全 DLL 返回的评论中缺失的 refNickname
* DLL 返回的 refCommentId 是被回复评论的 cmtid
* 补全数据服务返回的评论中缺失的 refNickname
*数据服务返回的 refCommentId 是被回复评论的 cmtid
* 评论按 cmtid 从小到大排列cmtid 从 1 开始递增
*/
private fixCommentRefs(comments: any[]): any[] {
if (!comments || comments.length === 0) return []
// DLL 现在返回完整的评论数据(含 emojis、refNickname
//数据服务现在返回完整的评论数据(含 emojis、refNickname
// 此处做最终的格式化和兜底补全
const idToNickname = new Map<string, string>()
comments.forEach((c, idx) => {
@@ -1099,14 +1161,14 @@ class SnsService {
} : undefined
}))
// DLL 已返回完整评论数据(含 emojis、refNickname
// 如果 DLL 评论缺少表情包信息,回退到从 rawXml 重新解析
//数据服务已返回完整评论数据(含 emojis、refNickname
// 如果数据服务评论缺少表情包信息,回退到从 rawXml 重新解析
const dllComments: any[] = post.comments || []
const hasEmojisInDll = dllComments.some((c: any) => c.emojis && c.emojis.length > 0)
let finalComments: any[]
if (dllComments.length > 0 && (hasEmojisInDll || !rawXml)) {
// DLL 数据完整,直接使用
//数据服务数据完整,直接使用
finalComments = this.fixCommentRefs(dllComments)
} else if (rawXml) {
// 回退:从 rawXml 重新解析(兼容旧版 DLL
@@ -1178,7 +1240,19 @@ class SnsService {
const cacheKey = `${url}|${key ?? ''}`
if (this.imageCache.has(cacheKey)) {
return { success: true, dataUrl: this.imageCache.get(cacheKey) }
const cachedDataUrl = this.imageCache.get(cacheKey) || ''
const base64Part = cachedDataUrl.split(',')[1] || ''
if (base64Part) {
try {
const cachedBuf = Buffer.from(base64Part, 'base64')
if (detectImageMime(cachedBuf, '').startsWith('image/')) {
return { success: true, dataUrl: cachedDataUrl }
}
} catch {
// ignore and fall through to refetch
}
}
this.imageCache.delete(cacheKey)
}
const result = await this.fetchAndDecryptImage(url, key)
@@ -1191,6 +1265,9 @@ class SnsService {
}
if (result.data && result.contentType) {
if (!detectImageMime(result.data, '').startsWith('image/')) {
return { success: false, error: '无效图片数据(可能密钥不匹配或缓存损坏)' }
}
const dataUrl = `data:${result.contentType};base64,${result.data.toString('base64')}`
this.imageCache.set(cacheKey, dataUrl)
return { success: true, dataUrl }
@@ -1199,7 +1276,7 @@ class SnsService {
return { success: false, error: result.error }
}
async downloadImage(url: string, key?: string | number): Promise<{ success: boolean; data?: Buffer; contentType?: string; error?: string }> {
async downloadImage(url: string, key?: string | number): Promise<{ success: boolean; data?: Buffer; contentType?: string; cachePath?: string; error?: string }> {
return this.fetchAndDecryptImage(url, key)
}
@@ -1791,7 +1868,7 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
const isVideo = isVideoUrl(url)
const cachePath = this.getCacheFilePath(url)
// 1. 尝试从磁盘缓存读取
// 1. 优先尝试从当前缓存目录读取
if (existsSync(cachePath)) {
try {
// 对于视频,不读取整个文件到内存,只确认存在即可
@@ -1800,8 +1877,13 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
}
const data = await readFile(cachePath)
const contentType = detectImageMime(data)
return { success: true, data, contentType, cachePath }
if (!detectImageMime(data, '').startsWith('image/')) {
// 旧版本可能把未解密内容写入缓存;发现无效图片头时删除并重新拉取。
try { unlinkSync(cachePath) } catch { }
} else {
const contentType = detectImageMime(data)
return { success: true, data, contentType, cachePath }
}
} catch (e) {
console.warn(`[SnsService] 读取缓存失败: ${cachePath}`, e)
}
@@ -1953,6 +2035,7 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
const xEnc = String(res.headers['x-enc'] || '').trim()
let decoded = raw
const rawMagicMime = detectImageMime(raw, '')
// 图片逻辑
const shouldDecrypt = (xEnc === '1' || !!key) && key !== undefined && key !== null && String(key).trim().length > 0
@@ -1970,13 +2053,24 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
decrypted[i] = raw[i] ^ keystream[i]
}
decoded = decrypted
const decryptedMagicMime = detectImageMime(decrypted, '')
if (decryptedMagicMime.startsWith('image/')) {
decoded = decrypted
} else if (!rawMagicMime.startsWith('image/')) {
decoded = decrypted
}
}
} catch (e) {
console.error('[SnsService] TS Decrypt Error:', e)
}
}
const decodedMagicMime = detectImageMime(decoded, '')
if (!decodedMagicMime.startsWith('image/')) {
resolve({ success: false, error: '图片解密失败:无法识别图片格式' })
return
}
// 写入磁盘缓存
try {
await writeFile(cachePath, decoded)
@@ -2010,6 +2104,15 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
if (buf[0] === 0xFF && buf[1] === 0xD8 && buf[2] === 0xFF) return true
if (buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46
&& buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50) return true
if (buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70) {
const ftypWindow = buf.subarray(8, Math.min(buf.length, 64)).toString('ascii').toLowerCase()
if (ftypWindow.includes('avif') || ftypWindow.includes('avis')) return true
if (
ftypWindow.includes('heic') || ftypWindow.includes('heix') ||
ftypWindow.includes('hevc') || ftypWindow.includes('hevx') ||
ftypWindow.includes('mif1') || ftypWindow.includes('msf1')
) return true
}
return false
}
@@ -2252,9 +2355,7 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
const fs = require('fs')
const cacheKey = crypto.createHash('md5').update(url || encryptUrl!).digest('hex')
const cachePath = this.configService.getCacheBasePath()
const emojiDir = join(cachePath, 'sns_emoji_cache')
if (!existsSync(emojiDir)) mkdirSync(emojiDir, { recursive: true })
const emojiDir = this.getEmojiCacheDir()
// 检查本地缓存
for (const ext of ['.gif', '.png', '.webp', '.jpg', '.jpeg']) {

View File

@@ -0,0 +1,367 @@
import https from 'https'
import { createHash } from 'crypto'
import { URL } from 'url'
const WEIBO_TIMEOUT_MS = 10_000
const WEIBO_MAX_POSTS = 5
const WEIBO_CACHE_TTL_MS = 30 * 60 * 1000
const WEIBO_USER_AGENT =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36'
const WEIBO_MOBILE_USER_AGENT =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1'
interface BrowserCookieEntry {
domain?: string
name?: string
value?: string
}
interface WeiboUserInfo {
id?: number | string
screen_name?: string
}
interface WeiboWaterFallItem {
id?: number | string
idstr?: string
mblogid?: string
created_at?: string
text_raw?: string
isLongText?: boolean
user?: WeiboUserInfo
retweeted_status?: WeiboWaterFallItem
}
interface WeiboWaterFallResponse {
ok?: number
data?: {
list?: WeiboWaterFallItem[]
next_cursor?: string
}
}
interface WeiboStatusShowResponse {
id?: number | string
idstr?: string
mblogid?: string
created_at?: string
text_raw?: string
user?: WeiboUserInfo
retweeted_status?: WeiboWaterFallItem
}
interface MWeiboCard {
mblog?: WeiboWaterFallItem
card_group?: MWeiboCard[]
}
interface MWeiboContainerResponse {
ok?: number
data?: {
cards?: MWeiboCard[]
}
}
export interface WeiboRecentPost {
id: string
createdAt: string
url: string
text: string
screenName?: string
}
interface CachedRecentPosts {
expiresAt: number
posts: WeiboRecentPost[]
}
function requestJson<T>(url: string, options: { cookie?: string; referer?: string; userAgent?: string }): Promise<T> {
return new Promise((resolve, reject) => {
let urlObj: URL
try {
urlObj = new URL(url)
} catch {
reject(new Error(`无效的微博请求地址:${url}`))
return
}
const headers: Record<string, string> = {
Accept: 'application/json, text/plain, */*',
Referer: options.referer || 'https://weibo.com',
'User-Agent': options.userAgent || WEIBO_USER_AGENT,
'X-Requested-With': 'XMLHttpRequest'
}
if (options.cookie) {
headers.Cookie = options.cookie
}
const req = https.request(
{
hostname: urlObj.hostname,
port: urlObj.port || 443,
path: urlObj.pathname + urlObj.search,
method: 'GET',
headers
},
(res) => {
let raw = ''
res.setEncoding('utf8')
res.on('data', (chunk) => {
raw += chunk
})
res.on('end', () => {
const statusCode = res.statusCode || 0
if (statusCode < 200 || statusCode >= 300) {
reject(new Error(`微博接口返回异常状态码 ${statusCode}`))
return
}
try {
resolve(JSON.parse(raw) as T)
} catch {
reject(new Error('微博接口返回了非 JSON 响应'))
}
})
}
)
req.setTimeout(WEIBO_TIMEOUT_MS, () => {
req.destroy()
reject(new Error('微博请求超时'))
})
req.on('error', reject)
req.end()
})
}
function normalizeCookieArray(entries: BrowserCookieEntry[]): string {
const picked = new Map<string, string>()
for (const entry of entries) {
const name = String(entry?.name || '').trim()
const value = String(entry?.value || '').trim()
const domain = String(entry?.domain || '').trim().toLowerCase()
if (!name || !value) continue
if (domain && !domain.includes('weibo.com') && !domain.includes('weibo.cn')) continue
picked.set(name, value)
}
return Array.from(picked.entries())
.map(([name, value]) => `${name}=${value}`)
.join('; ')
}
export function normalizeWeiboCookieInput(rawInput: string): string {
const trimmed = String(rawInput || '').trim()
if (!trimmed) return ''
try {
const parsed = JSON.parse(trimmed) as unknown
if (Array.isArray(parsed)) {
const normalized = normalizeCookieArray(parsed as BrowserCookieEntry[])
if (normalized) return normalized
throw new Error('Cookie JSON 中未找到可用的微博 Cookie 项')
}
} catch (error) {
if (!(error instanceof SyntaxError)) {
throw error
}
}
return trimmed.replace(/^Cookie:\s*/i, '').trim()
}
function normalizeWeiboUid(input: string): string {
const trimmed = String(input || '').trim()
const directMatch = trimmed.match(/^\d{5,}$/)
if (directMatch) return directMatch[0]
const linkMatch = trimmed.match(/(?:weibo\.com|m\.weibo\.cn)\/u\/(\d{5,})/i)
if (linkMatch) return linkMatch[1]
throw new Error('请输入有效的微博 UID纯数字')
}
function sanitizeWeiboText(text: string): string {
return String(text || '')
.replace(/\u200b|\u200c|\u200d|\ufeff/g, '')
.replace(/https?:\/\/t\.cn\/[A-Za-z0-9]+/g, ' ')
.replace(/ +/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
function mergeRetweetText(item: Pick<WeiboWaterFallItem, 'text_raw' | 'retweeted_status'>): string {
const baseText = sanitizeWeiboText(item.text_raw || '')
const retweetText = sanitizeWeiboText(item.retweeted_status?.text_raw || '')
if (!retweetText) return baseText
if (!baseText || baseText === '转发微博') return `转发:${retweetText}`
return `${baseText}\n\n转发内容${retweetText}`
}
function buildCacheKey(uid: string, count: number, cookie: string): string {
const cookieHash = createHash('sha1').update(cookie).digest('hex')
return `${uid}:${count}:${cookieHash}`
}
class WeiboService {
private recentPostsCache = new Map<string, CachedRecentPosts>()
clearCache(): void {
this.recentPostsCache.clear()
}
async validateUid(
uidInput: string,
cookieInput: string
): Promise<{ success: boolean; uid?: string; screenName?: string; error?: string }> {
try {
const uid = normalizeWeiboUid(uidInput)
const cookie = normalizeWeiboCookieInput(cookieInput)
if (!cookie) {
return { success: true, uid }
}
const timeline = await this.fetchTimeline(uid, cookie)
const firstItem = timeline.data?.list?.[0]
if (!firstItem) {
return { success: false, error: '该微博账号暂无可读取的近期公开内容,或当前 Cookie 已失效' }
}
return {
success: true,
uid,
screenName: firstItem.user?.screen_name
}
} catch (error) {
return {
success: false,
error: (error as Error).message || '微博 UID 校验失败'
}
}
}
async fetchRecentPosts(
uidInput: string,
cookieInput: string,
requestedCount: number
): Promise<WeiboRecentPost[]> {
const uid = normalizeWeiboUid(uidInput)
const cookie = normalizeWeiboCookieInput(cookieInput)
const hasCookie = Boolean(cookie)
const count = Math.max(1, Math.min(WEIBO_MAX_POSTS, Math.floor(Number(requestedCount) || 0)))
const cacheKey = buildCacheKey(uid, count, hasCookie ? cookie : '__no_cookie_mobile__')
const cached = this.recentPostsCache.get(cacheKey)
const now = Date.now()
if (cached && cached.expiresAt > now) {
return cached.posts
}
const rawItems = hasCookie
? (await this.fetchTimeline(uid, cookie)).data?.list || []
: await this.fetchMobileTimeline(uid)
const posts: WeiboRecentPost[] = []
for (const item of rawItems) {
if (posts.length >= count) break
const id = String(item.idstr || item.id || '').trim()
if (!id) continue
let text = mergeRetweetText(item)
if (item.isLongText && hasCookie) {
try {
const detail = await this.fetchDetail(id, cookie)
text = mergeRetweetText(detail)
} catch {
// 长文补抓失败时回退到列表摘要
}
}
text = sanitizeWeiboText(text)
if (!text) continue
posts.push({
id,
createdAt: String(item.created_at || ''),
url: `https://m.weibo.cn/detail/${id}`,
text,
screenName: item.user?.screen_name
})
}
this.recentPostsCache.set(cacheKey, {
expiresAt: now + WEIBO_CACHE_TTL_MS,
posts
})
return posts
}
private fetchTimeline(uid: string, cookie: string): Promise<WeiboWaterFallResponse> {
return requestJson<WeiboWaterFallResponse>(
`https://weibo.com/ajax/profile/getWaterFallContent?uid=${encodeURIComponent(uid)}`,
{
cookie,
referer: `https://weibo.com/u/${encodeURIComponent(uid)}`
}
).then((response) => {
if (response.ok !== 1 || !Array.isArray(response.data?.list)) {
throw new Error('微博时间线获取失败,请检查 Cookie 是否仍然有效')
}
return response
})
}
private fetchMobileTimeline(uid: string): Promise<WeiboWaterFallItem[]> {
const containerid = `107603${uid}`
return requestJson<MWeiboContainerResponse>(
`https://m.weibo.cn/api/container/getIndex?type=uid&value=${encodeURIComponent(uid)}&containerid=${encodeURIComponent(containerid)}`,
{
referer: `https://m.weibo.cn/u/${encodeURIComponent(uid)}`,
userAgent: WEIBO_MOBILE_USER_AGENT
}
).then((response) => {
if (response.ok !== 1 || !Array.isArray(response.data?.cards)) {
throw new Error('微博时间线获取失败,请稍后重试')
}
const rows: WeiboWaterFallItem[] = []
for (const card of response.data.cards) {
if (card?.mblog) rows.push(card.mblog)
if (Array.isArray(card?.card_group)) {
for (const subCard of card.card_group) {
if (subCard?.mblog) rows.push(subCard.mblog)
}
}
}
if (rows.length === 0) {
throw new Error('该微博账号暂无可读取的近期公开内容')
}
return rows
})
}
private fetchDetail(id: string, cookie: string): Promise<WeiboStatusShowResponse> {
return requestJson<WeiboStatusShowResponse>(
`https://weibo.com/ajax/statuses/show?id=${encodeURIComponent(id)}&isGetLongText=true`,
{
cookie,
referer: `https://weibo.com/detail/${encodeURIComponent(id)}`
}
).then((response) => {
if (!response || (!response.id && !response.idstr)) {
throw new Error('微博详情获取失败')
}
return response
})
}
}
export const weiboService = new WeiboService()

View File

@@ -1,5 +1,6 @@
import { join } from 'path'
import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync } from 'fs'
import { pathToFileURL } from 'url'
import { app } from 'electron'
import { ConfigService } from './config'
import { wcdbService } from './wcdbService'
@@ -22,6 +23,8 @@ interface VideoIndexEntry {
thumbPath?: string
}
type PosterFormat = 'dataUrl' | 'fileUrl'
class VideoService {
private configService: ConfigService
private hardlinkResolveCache = new Map<string, TimedCacheEntry<string | null>>()
@@ -249,19 +252,15 @@ class VideoService {
}
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)
// 视频链路已改为直接使用 packed_info_data 提取出的文件名索引本地目录。
// 该预热接口保留仅为兼容旧调用方,不再查询 hardlink.db。
void md5List
}
/**
* 将文件转换为 data URL
*/
private fileToDataUrl(filePath: string | undefined, mimeType: string): string | undefined {
private fileToPosterUrl(filePath: string | undefined, mimeType: string, posterFormat: PosterFormat): string | undefined {
try {
if (!filePath || !existsSync(filePath)) return undefined
if (posterFormat === 'fileUrl') return pathToFileURL(filePath).toString()
const buffer = readFileSync(filePath)
return `data:${mimeType};base64,${buffer.toString('base64')}`
} catch {
@@ -355,7 +354,12 @@ class VideoService {
return index
}
private getVideoInfoFromIndex(index: Map<string, VideoIndexEntry>, md5: string, includePoster = true): VideoInfo | null {
private getVideoInfoFromIndex(
index: Map<string, VideoIndexEntry>,
md5: string,
includePoster = true,
posterFormat: PosterFormat = 'dataUrl'
): VideoInfo | null {
const normalizedMd5 = String(md5 || '').trim().toLowerCase()
if (!normalizedMd5) return null
@@ -379,8 +383,8 @@ class VideoService {
}
return {
videoUrl: entry.videoPath,
coverUrl: this.fileToDataUrl(entry.coverPath, 'image/jpeg'),
thumbUrl: this.fileToDataUrl(entry.thumbPath, 'image/jpeg'),
coverUrl: this.fileToPosterUrl(entry.coverPath, 'image/jpeg', posterFormat),
thumbUrl: this.fileToPosterUrl(entry.thumbPath, 'image/jpeg', posterFormat),
exists: true
}
}
@@ -388,7 +392,29 @@ class VideoService {
return null
}
private fallbackScanVideo(videoBaseDir: string, realVideoMd5: string, includePoster = true): VideoInfo | null {
private normalizeVideoLookupKey(value: string): string {
let text = String(value || '').trim().toLowerCase()
if (!text) return ''
text = text.replace(/^.*[\\/]/, '')
text = text.replace(/\.(?:mp4|mov|m4v|avi|mkv|flv|jpg|jpeg|png|gif|dat)$/i, '')
text = text.replace(/_thumb$/, '')
const direct = /^([a-f0-9]{16,64})(?:_raw)?$/i.exec(text)
if (direct) {
const suffix = /_raw$/i.test(text) ? '_raw' : ''
return `${direct[1].toLowerCase()}${suffix}`
}
const preferred32 = /([a-f0-9]{32})(?![a-f0-9])/i.exec(text)
if (preferred32?.[1]) return preferred32[1].toLowerCase()
const fallback = /([a-f0-9]{16,64})(?![a-f0-9])/i.exec(text)
return String(fallback?.[1] || '').toLowerCase()
}
private fallbackScanVideo(
videoBaseDir: string,
realVideoMd5: string,
includePoster = true,
posterFormat: PosterFormat = 'dataUrl'
): VideoInfo | null {
try {
const yearMonthDirs = readdirSync(videoBaseDir)
.filter((dir) => {
@@ -416,8 +442,8 @@ class VideoService {
const thumbPath = join(dirPath, `${baseMd5}_thumb.jpg`)
return {
videoUrl: videoPath,
coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'),
thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'),
coverUrl: this.fileToPosterUrl(coverPath, 'image/jpeg', posterFormat),
thumbUrl: this.fileToPosterUrl(thumbPath, 'image/jpeg', posterFormat),
exists: true
}
}
@@ -427,14 +453,21 @@ class VideoService {
return null
}
private async ensurePoster(info: VideoInfo, includePoster: boolean, posterFormat: PosterFormat): Promise<VideoInfo> {
void posterFormat
if (!includePoster) return info
return info
}
/**
* 根据视频MD5获取视频文件信息
* 视频存放在: {数据库根目录}/{用户wxid}/msg/video/{年月}/
* 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg
*/
async getVideoInfo(videoMd5: string, options?: { includePoster?: boolean }): Promise<VideoInfo> {
async getVideoInfo(videoMd5: string, options?: { includePoster?: boolean; posterFormat?: PosterFormat }): Promise<VideoInfo> {
const normalizedMd5 = String(videoMd5 || '').trim().toLowerCase()
const includePoster = options?.includePoster !== false
const posterFormat: PosterFormat = options?.posterFormat === 'fileUrl' ? 'fileUrl' : 'dataUrl'
const dbPath = this.getDbPath()
const wxid = this.getMyWxid()
@@ -446,7 +479,7 @@ class VideoService {
}
const scopeKey = this.getScopeKey(dbPath, wxid)
const cacheKey = `${scopeKey}|${normalizedMd5}|poster=${includePoster ? 1 : 0}`
const cacheKey = `${scopeKey}|${normalizedMd5}|poster=${includePoster ? 1 : 0}|format=${posterFormat}`
const cachedInfo = this.readTimedCache(this.videoInfoCache, cacheKey)
if (cachedInfo) return cachedInfo
@@ -455,7 +488,7 @@ class VideoService {
if (pending) return pending
const task = (async (): Promise<VideoInfo> => {
const realVideoMd5 = await this.queryVideoFileName(normalizedMd5) || normalizedMd5
const realVideoMd5 = this.normalizeVideoLookupKey(normalizedMd5) || normalizedMd5
const videoBaseDir = this.resolveVideoBaseDir(dbPath, wxid)
if (!existsSync(videoBaseDir)) {
@@ -465,21 +498,23 @@ class VideoService {
}
const index = this.getOrBuildVideoIndex(videoBaseDir)
const indexed = this.getVideoInfoFromIndex(index, realVideoMd5, includePoster)
const indexed = this.getVideoInfoFromIndex(index, realVideoMd5, includePoster, posterFormat)
if (indexed) {
this.writeTimedCache(this.videoInfoCache, cacheKey, indexed, this.videoInfoCacheTtlMs, this.maxCacheEntries)
return indexed
const withPoster = await this.ensurePoster(indexed, includePoster, posterFormat)
this.writeTimedCache(this.videoInfoCache, cacheKey, withPoster, this.videoInfoCacheTtlMs, this.maxCacheEntries)
return withPoster
}
const fallback = this.fallbackScanVideo(videoBaseDir, realVideoMd5, includePoster)
const fallback = this.fallbackScanVideo(videoBaseDir, realVideoMd5, includePoster, posterFormat)
if (fallback) {
this.writeTimedCache(this.videoInfoCache, cacheKey, fallback, this.videoInfoCacheTtlMs, this.maxCacheEntries)
return fallback
const withPoster = await this.ensurePoster(fallback, includePoster, posterFormat)
this.writeTimedCache(this.videoInfoCache, cacheKey, withPoster, this.videoInfoCacheTtlMs, this.maxCacheEntries)
return withPoster
}
const miss = { exists: false }
this.writeTimedCache(this.videoInfoCache, cacheKey, miss, this.videoInfoCacheTtlMs, this.maxCacheEntries)
this.log('getVideoInfo: 未找到视频', { inputMd5: normalizedMd5, resolvedMd5: realVideoMd5 })
this.log('getVideoInfo: 未找到视频', { lookupKey: normalizedMd5, normalizedKey: realVideoMd5 })
return miss
})()

View File

@@ -75,6 +75,14 @@ export class VoiceTranscribeService {
if (candidates.length === 0) {
console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`)
}
} else if (process.platform === 'win32') {
// Windows: 把 sherpa-onnx 所在目录加到 PATH否则 native module 找不到依赖
const existing = env['PATH'] || ''
const merged = [...candidates, ...existing.split(';').filter(Boolean)]
env['PATH'] = Array.from(new Set(merged)).join(';')
if (candidates.length === 0) {
console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`)
}
}
return env

File diff suppressed because it is too large Load Diff

View File

@@ -25,9 +25,7 @@ export class WcdbService {
private logEnabled = false
private monitorListener: ((type: string, json: string) => void) | null = null
constructor() {
this.initWorker()
}
constructor() {}
/**
* 初始化 Worker 线程
@@ -80,7 +78,7 @@ export class WcdbService {
// Worker 退出,需要 reject 所有 pending promises
if (code !== 0) {
console.error('WCDB Worker 异常退出,退出码:', code)
const errorMsg = `Worker 异常退出 (退出码: ${code})。可能是 DLL 加载失败,请检查是否安装了 Visual C++ Redistributable。`
const errorMsg = `Worker 异常退出 (退出码: ${code})。可能是数据服务加载失败,请检查是否安装了 Visual C++ Redistributable。`
for (const [id, p] of this.pending) {
p.reject(new Error(errorMsg))
}
@@ -268,6 +266,37 @@ export class WcdbService {
return this.callWorker('getMessagesByType', { sessionId, localType, ascending, limit, offset })
}
async getMediaStream(options?: {
sessionId?: string
mediaType?: 'image' | 'video' | 'all'
beginTimestamp?: number
endTimestamp?: number
limit?: number
offset?: number
}): Promise<{
success: boolean
items?: Array<{
sessionId: string
sessionDisplayName?: string
mediaType: 'image' | 'video'
localId: number
serverId?: string
createTime: number
localType: number
senderUsername?: string
isSend?: number | null
imageMd5?: string
imageDatName?: string
videoMd5?: string
content?: string
}>
hasMore?: boolean
nextOffset?: number
error?: string
}> {
return this.callWorker('getMediaStream', { options })
}
/**
* 获取联系人昵称
*/
@@ -417,6 +446,19 @@ export class WcdbService {
return this.callWorker('getGroupStats', { chatroomId, beginTimestamp, endTimestamp })
}
async getMyFootprintStats(options: {
beginTimestamp?: number
endTimestamp?: number
myWxid?: string
privateSessionIds?: string[]
groupSessionIds?: string[]
mentionLimit?: number
privateLimit?: number
mentionMode?: 'text_at_me' | string
}): Promise<{ success: boolean; data?: any; error?: string }> {
return this.callWorker('getMyFootprintStats', { options })
}
/**
* 打开消息游标
*/
@@ -467,7 +509,7 @@ export class WcdbService {
}
/**
* 获取表情包释义(严格 DLL 接口)
* 获取表情包释义(严格数据服务接口)
*/
async getEmoticonCaptionStrict(md5: string): Promise<{ success: boolean; caption?: string; error?: string }> {
return this.callWorker('getEmoticonCaptionStrict', { md5 })
@@ -561,6 +603,24 @@ export class WcdbService {
return this.callWorker('getSnsExportStats', { myWxid })
}
async checkMessageAntiRevokeTriggers(
sessionIds: string[]
): Promise<{ success: boolean; rows?: Array<{ sessionId: string; success: boolean; installed?: boolean; error?: string }>; error?: string }> {
return this.callWorker('checkMessageAntiRevokeTriggers', { sessionIds })
}
async installMessageAntiRevokeTriggers(
sessionIds: string[]
): Promise<{ success: boolean; rows?: Array<{ sessionId: string; success: boolean; alreadyInstalled?: boolean; error?: string }>; error?: string }> {
return this.callWorker('installMessageAntiRevokeTriggers', { sessionIds })
}
async uninstallMessageAntiRevokeTriggers(
sessionIds: string[]
): Promise<{ success: boolean; rows?: Array<{ sessionId: string; success: boolean; error?: string }>; error?: string }> {
return this.callWorker('uninstallMessageAntiRevokeTriggers', { sessionIds })
}
/**
* 安装朋友圈删除拦截
*/
@@ -590,7 +650,7 @@ export class WcdbService {
}
/**
* 获取 DLL 内部日志
* 获取数据服务内部日志
*/
async getLogs(): Promise<{ success: boolean; logs?: string[]; error?: string }> {
return this.callWorker('getLogs')

View File

@@ -0,0 +1,20 @@
import { homedir } from 'os'
/**
* Expand "~" prefix to current user's home directory.
* Examples:
* - "~" => "/Users/alex"
* - "~/Library/..." => "/Users/alex/Library/..."
*/
export function expandHomePath(inputPath: string): string {
const raw = String(inputPath || '').trim()
if (!raw) return raw
if (raw === '~') return homedir()
if (/^~[\\/]/.test(raw)) {
return `${homedir()}${raw.slice(1)}`
}
return raw
}

View File

@@ -80,6 +80,9 @@ if (parentPort) {
case 'getMessagesByType':
result = await core.getMessagesByType(payload.sessionId, payload.localType, payload.ascending, payload.limit, payload.offset)
break
case 'getMediaStream':
result = await core.getMediaStream(payload.options)
break
case 'getDisplayNames':
result = await core.getDisplayNames(payload.usernames)
break
@@ -155,6 +158,9 @@ if (parentPort) {
case 'getGroupStats':
result = await core.getGroupStats(payload.chatroomId, payload.beginTimestamp, payload.endTimestamp)
break
case 'getMyFootprintStats':
result = await core.getMyFootprintStats(payload.options || {})
break
case 'openMessageCursor':
result = await core.openMessageCursor(payload.sessionId, payload.batchSize, payload.ascending, payload.beginTimestamp, payload.endTimestamp)
break
@@ -230,6 +236,15 @@ if (parentPort) {
case 'getSnsExportStats':
result = await core.getSnsExportStats(payload.myWxid)
break
case 'checkMessageAntiRevokeTriggers':
result = await core.checkMessageAntiRevokeTriggers(payload.sessionIds)
break
case 'installMessageAntiRevokeTriggers':
result = await core.installMessageAntiRevokeTriggers(payload.sessionIds)
break
case 'uninstallMessageAntiRevokeTriggers':
result = await core.uninstallMessageAntiRevokeTriggers(payload.sessionIds)
break
case 'installSnsBlockDeleteTrigger':
result = await core.installSnsBlockDeleteTrigger()
break

View File

@@ -1,224 +1,343 @@
import { BrowserWindow, ipcMain, screen } from 'electron'
import { join } from 'path'
import { ConfigService } from '../services/config'
import { BrowserWindow, ipcMain, screen } from "electron";
import { join } from "path";
import { ConfigService } from "../services/config";
let notificationWindow: BrowserWindow | null = null
let closeTimer: NodeJS.Timeout | null = null
// Linux D-Bus通知服务
const isLinux = process.platform === "linux";
let linuxNotificationService:
| typeof import("../services/linuxNotificationService")
| null = null;
// 用于处理通知点击的回调函数在Linux上用于导航到会话
let onNotificationNavigate: ((sessionId: string) => void) | null = null;
export function setNotificationNavigateHandler(
callback: (sessionId: string) => void,
) {
onNotificationNavigate = callback;
}
let notificationWindow: BrowserWindow | null = null;
let closeTimer: NodeJS.Timeout | null = null;
export function destroyNotificationWindow() {
if (closeTimer) {
clearTimeout(closeTimer)
closeTimer = null
}
lastNotificationData = null
if (closeTimer) {
clearTimeout(closeTimer);
closeTimer = null;
}
lastNotificationData = null;
if (!notificationWindow || notificationWindow.isDestroyed()) {
notificationWindow = null
return
}
// Linux:关闭通知服务并清理缓存fire-and-forget不阻塞退出
if (isLinux && linuxNotificationService) {
linuxNotificationService.shutdownLinuxNotificationService().catch((error) => {
console.warn("[NotificationWindow] Failed to shutdown Linux notification service:", error);
});
linuxNotificationService = null;
}
const win = notificationWindow
notificationWindow = null
if (!notificationWindow || notificationWindow.isDestroyed()) {
notificationWindow = null;
return;
}
try {
win.destroy()
} catch (error) {
console.warn('[NotificationWindow] Failed to destroy window:', error)
}
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
}
if (notificationWindow && !notificationWindow.isDestroyed()) {
return notificationWindow;
}
const isDev = !!process.env.VITE_DEV_SERVER_URL
const iconPath = isDev
? join(__dirname, '../../public/icon.ico')
: join(process.resourcesPath, 'icon.ico')
const isDev = !!process.env.VITE_DEV_SERVER_URL;
const iconPath = isDev
? join(__dirname, "../../public/icon.ico")
: join(process.resourcesPath, "icon.ico");
console.log('[NotificationWindow] Creating window...')
const width = 344
const height = 114
console.log("[NotificationWindow] Creating window...");
const width = 344;
const height = 114;
// Update default creation size
notificationWindow = new BrowserWindow({
width: width,
height: height,
type: 'toolbar', // 有助于在某些操作系统上保持置顶
frame: false,
transparent: true,
resizable: false,
show: false,
alwaysOnTop: true,
skipTaskbar: true,
focusable: false, // 不抢占焦点
icon: iconPath,
webPreferences: {
preload: join(__dirname, 'preload.js'), // FIX: Use correct relative path (same dir in dist)
contextIsolation: true,
nodeIntegration: false,
// devTools: true // Enable DevTools
}
})
// Update default creation size
notificationWindow = new BrowserWindow({
width: width,
height: height,
type: "toolbar", // 有助于在某些操作系统上保持置顶
frame: false,
transparent: true,
resizable: false,
show: false,
alwaysOnTop: true,
skipTaskbar: true,
focusable: false, // 不抢占焦点
icon: iconPath,
webPreferences: {
preload: join(__dirname, "preload.js"), // FIX: Use correct relative path (same dir in dist)
contextIsolation: true,
nodeIntegration: false,
// devTools: true // Enable DevTools
},
});
// notificationWindow.webContents.openDevTools({ mode: 'detach' }) // DEBUG: Force Open DevTools
notificationWindow.setIgnoreMouseEvents(true, { forward: true }) // 初始点击穿透
// notificationWindow.webContents.openDevTools({ mode: 'detach' }) // DEBUG: Force Open DevTools
notificationWindow.setIgnoreMouseEvents(true, { forward: true }); // 初始点击穿透
// 处理鼠标事件 (如果需要从渲染进程转发,但目前特定区域处理?)
// 实际上,我们希望窗口可点击。
// 我们将在显示时将忽略鼠标事件设为 false。
// 处理鼠标事件 (如果需要从渲染进程转发,但目前特定区域处理?)
// 实际上,我们希望窗口可点击。
// 我们将在显示时将忽略鼠标事件设为 false。
const loadUrl = isDev
? `${process.env.VITE_DEV_SERVER_URL}#/notification-window`
: `file://${join(__dirname, '../dist/index.html')}#/notification-window`
const loadUrl = isDev
? `${process.env.VITE_DEV_SERVER_URL}#/notification-window`
: `file://${join(__dirname, "../dist/index.html")}#/notification-window`;
console.log('[NotificationWindow] Loading URL:', loadUrl)
notificationWindow.loadURL(loadUrl)
console.log("[NotificationWindow] Loading URL:", loadUrl);
notificationWindow.loadURL(loadUrl);
notificationWindow.on('closed', () => {
notificationWindow = null
})
notificationWindow.on("closed", () => {
notificationWindow = null;
});
return notificationWindow
return notificationWindow;
}
export async function showNotification(data: any) {
// 先检查配置
const config = ConfigService.getInstance()
const enabled = await config.get('notificationEnabled')
if (enabled === false) return // 默认为 true
// 先检查配置
const config = ConfigService.getInstance();
const enabled = await config.get("notificationEnabled");
if (enabled === false) return; // 默认为 true
// 检查会话过滤
const filterMode = config.get('notificationFilterMode') || 'all'
const filterList = config.get('notificationFilterList') || []
const sessionId = data.sessionId
// 检查会话过滤
const filterMode = config.get("notificationFilterMode") || "all";
const filterList = config.get("notificationFilterList") || [];
const sessionId = typeof data.sessionId === "string" ? data.sessionId : "";
// 系统通知(如 "WeFlow 准备就绪")不是聊天消息,不应受会话白/黑名单影响
const isSystemNotification = sessionId.startsWith("weflow-");
if (sessionId && filterMode !== 'all' && filterList.length > 0) {
const isInList = filterList.includes(sessionId)
if (filterMode === 'whitelist' && !isInList) {
// 白名单模式:不在列表中则不显示
return
}
if (filterMode === 'blacklist' && isInList) {
// 黑名单模式:在列表中则不显示
return
}
if (!isSystemNotification && filterMode !== "all") {
const isInList = sessionId !== "" && filterList.includes(sessionId);
if (filterMode === "whitelist" && !isInList) {
// 白名单模式:不在列表中则不显示(空列表视为全部拦截)
return;
}
let win = notificationWindow
if (!win || win.isDestroyed()) {
win = createNotificationWindow()
if (filterMode === "blacklist" && isInList) {
// 黑名单模式:在列表中则不显示
return;
}
}
if (!win) return
// Linux 使用 D-Bus 通知
if (isLinux) {
await showLinuxNotification(data);
return;
}
// 确保加载完成
if (win.webContents.isLoading()) {
win.once('ready-to-show', () => {
showAndSend(win!, data)
})
} else {
showAndSend(win, data)
}
let win = notificationWindow;
if (!win || win.isDestroyed()) {
win = createNotificationWindow();
}
if (!win) return;
// 确保加载完成
if (win.webContents.isLoading()) {
win.once("ready-to-show", () => {
showAndSend(win!, data);
});
} else {
showAndSend(win, data);
}
}
let lastNotificationData: any = null
// 显示Linux通知
async function showLinuxNotification(data: any) {
if (!linuxNotificationService) {
try {
linuxNotificationService =
await import("../services/linuxNotificationService");
} catch (error) {
console.error(
"[NotificationWindow] Failed to load Linux notification service:",
error,
);
return;
}
}
const { showLinuxNotification: showNotification } = linuxNotificationService;
const notificationData = {
title: data.title,
content: data.content,
avatarUrl: data.avatarUrl,
sessionId: data.sessionId,
expireTimeout: 5000,
};
showNotification(notificationData);
}
let lastNotificationData: any = null;
async function showAndSend(win: BrowserWindow, data: any) {
lastNotificationData = data
const config = ConfigService.getInstance()
const position = (await config.get('notificationPosition')) || 'top-right'
lastNotificationData = data;
const config = ConfigService.getInstance();
const position = (await config.get("notificationPosition")) || "top-right";
// 更新位置
const { width: screenWidth, height: screenHeight } = screen.getPrimaryDisplay().workAreaSize
const winWidth = position === 'top-center' ? 280 : 344
const winHeight = 114
const padding = 20
// 更新位置
const { width: screenWidth, height: screenHeight } =
screen.getPrimaryDisplay().workAreaSize;
const winWidth = position === "top-center" ? 280 : 344;
const winHeight = 114;
const padding = 20;
let x = 0
let y = 0
let x = 0;
let y = 0;
switch (position) {
case 'top-center':
x = (screenWidth - winWidth) / 2
y = padding
break
case 'top-right':
x = screenWidth - winWidth - padding
y = padding
break
case 'bottom-right':
x = screenWidth - winWidth - padding
y = screenHeight - winHeight - padding
break
case 'top-left':
x = padding
y = padding
break
case 'bottom-left':
x = padding
y = screenHeight - winHeight - padding
break
switch (position) {
case "top-center":
x = (screenWidth - winWidth) / 2;
y = padding;
break;
case "top-right":
x = screenWidth - winWidth - padding;
y = padding;
break;
case "bottom-right":
x = screenWidth - winWidth - padding;
y = screenHeight - winHeight - padding;
break;
case "top-left":
x = padding;
y = padding;
break;
case "bottom-left":
x = padding;
y = screenHeight - winHeight - padding;
break;
}
win.setPosition(Math.floor(x), Math.floor(y));
win.setSize(winWidth, winHeight); // 确保尺寸
// 设为可交互
win.setIgnoreMouseEvents(false);
win.showInactive(); // 显示但不聚焦
win.setAlwaysOnTop(true, "screen-saver"); // 最高层级
win.webContents.send("notification:show", { ...data, position });
// 自动关闭计时器通常由渲染进程管理
// 渲染进程发送 'notification:close' 来隐藏窗口
}
// 注册通知处理
export async function registerNotificationHandlers() {
// Linux: 初始化D-Bus服务
if (isLinux) {
try {
const linuxNotificationModule =
await import("../services/linuxNotificationService");
linuxNotificationService = linuxNotificationModule;
// 初始化服务
await linuxNotificationModule.initLinuxNotificationService();
// 在Linux上注册通知点击回调
linuxNotificationModule.onNotificationAction((sessionId: string) => {
console.log(
"[NotificationWindow] Linux notification clicked, sessionId:",
sessionId,
);
// 如果设置了导航处理程序则使用该处理程序否则回退到ipcMain方法。
if (onNotificationNavigate) {
onNotificationNavigate(sessionId);
} else {
// 如果尚未设置处理程序则通过ipcMain发出事件
// 正常流程中不应该发生这种情况,因为我们在初始化之前设置了处理程序。
console.warn(
"[NotificationWindow] onNotificationNavigate not set yet",
);
}
});
console.log(
"[NotificationWindow] Linux notification service initialized",
);
} catch (error) {
console.error(
"[NotificationWindow] Failed to initialize Linux notification service:",
error,
);
}
}
win.setPosition(Math.floor(x), Math.floor(y))
win.setSize(winWidth, winHeight) // 确保尺寸
ipcMain.handle("notification:show", (_, data) => {
showNotification(data);
});
// 设为可交互
win.setIgnoreMouseEvents(false)
win.showInactive() // 显示但不聚焦
win.setAlwaysOnTop(true, 'screen-saver') // 最高层级
ipcMain.handle("notification:close", () => {
if (isLinux && linuxNotificationService) {
// 注册通知点击回调函数。Linux通知通过D-Bus自动关闭但我们可以根据需要进行跟踪
return;
}
if (notificationWindow && !notificationWindow.isDestroyed()) {
notificationWindow.hide();
notificationWindow.setIgnoreMouseEvents(true, { forward: true });
}
});
win.webContents.send('notification:show', { ...data, position })
// Handle renderer ready event (fix race condition)
ipcMain.on("notification:ready", (event) => {
if (isLinux) {
// Linux不需要通知窗口拦截通知窗口渲染
return;
}
console.log("[NotificationWindow] Renderer ready, checking cached data");
if (
lastNotificationData &&
notificationWindow &&
!notificationWindow.isDestroyed()
) {
console.log("[NotificationWindow] Re-sending cached data");
notificationWindow.webContents.send(
"notification:show",
lastNotificationData,
);
}
});
// 自动关闭计时器通常由渲染进程管理
// 渲染进程发送 'notification:close' 来隐藏窗口
}
export function registerNotificationHandlers() {
ipcMain.handle('notification:show', (_, data) => {
showNotification(data)
})
ipcMain.handle('notification:close', () => {
if (notificationWindow && !notificationWindow.isDestroyed()) {
notificationWindow.hide()
notificationWindow.setIgnoreMouseEvents(true, { forward: true })
}
})
// Handle renderer ready event (fix race condition)
ipcMain.on('notification:ready', (event) => {
console.log('[NotificationWindow] Renderer ready, checking cached data')
if (lastNotificationData && notificationWindow && !notificationWindow.isDestroyed()) {
console.log('[NotificationWindow] Re-sending cached data')
notificationWindow.webContents.send('notification:show', lastNotificationData)
}
})
// Handle resize request from renderer
ipcMain.on('notification:resize', (event, { width, height }) => {
if (notificationWindow && !notificationWindow.isDestroyed()) {
// Enforce max-height if needed, or trust renderer
// Ensure it doesn't go off screen bottom?
// Logic in showAndSend handles position, but we need to keep anchor point (top-right usually).
// If we resize, we should re-calculate position to keep it anchored?
// Actually, setSize changes size. If it's top-right, x/y stays same -> window grows down. That's fine for top-right.
// If bottom-right, growing down pushes it off screen.
// Simple version: just setSize. For V1 we assume Top-Right.
// But wait, the config supports bottom-right.
// We can re-call setPosition or just let it be.
// If bottom-right, y needs to prevent overflow.
// Ideally we get current config position
const bounds = notificationWindow.getBounds()
// Check if we need to adjust Y?
// For now, let's just set the size as requested.
notificationWindow.setSize(Math.round(width), Math.round(height))
}
})
// 'notification-clicked' 在 main.ts 中处理 (导航)
// Handle resize request from renderer
ipcMain.on("notification:resize", (event, { width, height }) => {
if (isLinux) {
// Linux 通知通过D-Bus自动调整大小
return;
}
if (notificationWindow && !notificationWindow.isDestroyed()) {
// Enforce max-height if needed, or trust renderer
// Ensure it doesn't go off screen bottom?
// Logic in showAndSend handles position, but we need to keep anchor point (top-right usually).
// If we resize, we should re-calculate position to keep it anchored?
// Actually, setSize changes size. If it's top-right, x/y stays same -> window grows down. That's fine for top-right.
// If bottom-right, growing down pushes it off screen.
// Simple version: just setSize. For V1 we assume Top-Right.
// But wait, the config supports bottom-right.
// We can re-call setPosition or just let it be.
// If bottom-right, y needs to prevent overflow.
// Ideally we get current config position
const bounds = notificationWindow.getBounds();
// Check if we need to adjust Y?
// For now, let's just set the size as requested.
notificationWindow.setSize(Math.round(width), Math.round(height));
}
});
// 'notification-clicked' 在 main.ts 中处理 (导航)
}

4114
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "weflow",
"version": "2.1.0",
"version": "4.3.0",
"description": "WeFlow",
"main": "dist-electron/main.js",
"author": {
@@ -13,19 +13,19 @@
},
"//": "二改不应改变此处的作者与应用信息",
"scripts": {
"postinstall": "electron-builder install-app-deps",
"postinstall": "electron-builder install-app-deps && node scripts/prepare-electron-runtime.cjs",
"rebuild": "electron-rebuild",
"dev": "vite",
"dev": "node scripts/prepare-electron-runtime.cjs && vite",
"typecheck": "tsc --noEmit",
"build": "tsc && vite build && electron-builder",
"preview": "vite preview",
"electron:dev": "vite --mode electron",
"electron:dev": "node scripts/prepare-electron-runtime.cjs && vite --mode electron",
"electron:build": "npm run build"
},
"dependencies": {
"echarts": "^5.5.1",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.2",
"electron-store": "^10.0.0",
"electron-store": "^11.0.2",
"electron-updater": "^6.3.9",
"exceljs": "^4.4.0",
"ffmpeg-static": "^5.3.0",
@@ -34,11 +34,11 @@
"jieba-wasm": "^2.2.0",
"jszip": "^3.10.1",
"koffi": "^2.9.0",
"lucide-react": "^0.562.0",
"lucide-react": "^1.8.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.1.1",
"react-router-dom": "^7.14.0",
"react-virtuoso": "^4.18.1",
"remark-gfm": "^4.0.1",
"sherpa-onnx-node": "^1.10.38",
@@ -52,15 +52,27 @@
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.3.4",
"electron": "^39.2.7",
"electron-builder": "^25.1.8",
"sass": "^1.83.0",
"electron": "^41.1.1",
"electron-builder": "^26.8.1",
"sass": "^1.98.0",
"sharp": "^0.34.5",
"typescript": "^5.6.3",
"vite": "^6.0.5",
"typescript": "^6.0.2",
"vite": "^7.0.0",
"vite-plugin-electron": "^0.28.8",
"vite-plugin-electron-renderer": "^0.14.6"
},
"pnpm": {
"overrides": {
"tar": ">=6.2.1",
"minimatch": ">=3.1.2",
"rollup": ">=4.0.0",
"immutable": ">=4.0.0",
"lodash": ">=4.17.21",
"brace-expansion": ">=1.1.11",
"picomatch": ">=2.3.1",
"ajv": ">=8.18.0"
}
},
"build": {
"appId": "com.WeFlow.app",
"publish": {
@@ -84,24 +96,47 @@
"gatekeeperAssess": false,
"entitlements": "electron/entitlements.mac.plist",
"entitlementsInherit": "electron/entitlements.mac.plist",
"icon": "resources/icon.icns"
"icon": "resources/icons/macos/icon.icns"
},
"win": {
"target": [
"nsis"
],
"icon": "public/icon.ico"
"icon": "public/icon.ico",
"extraFiles": [
{
"from": "resources/runtime/win32/msvcp140.dll",
"to": "."
},
{
"from": "resources/runtime/win32/msvcp140_1.dll",
"to": "."
},
{
"from": "resources/runtime/win32/vcruntime140.dll",
"to": "."
},
{
"from": "resources/runtime/win32/vcruntime140_1.dll",
"to": "."
}
]
},
"linux": {
"icon": "public/icon.png",
"target": [
"appimage",
"deb",
"tar.gz"
],
"category": "Utility",
"executableName": "weflow",
"synopsis": "WeFlow for Linux"
"synopsis": "WeFlow for Linux",
"extraFiles": [
{
"from": "resources/installer/linux/install.sh",
"to": "install.sh"
}
]
},
"nsis": {
"oneClick": false,
@@ -151,26 +186,14 @@
"node_modules/sherpa-onnx-node/**/*",
"node_modules/sherpa-onnx-*/*",
"node_modules/sherpa-onnx-*/**/*",
"node_modules/ffmpeg-static/**/*"
"node_modules/ffmpeg-static/**/*",
"resources/wedecrypt/**/*.node"
],
"extraFiles": [
{
"from": "resources/msvcp140.dll",
"to": "."
},
{
"from": "resources/msvcp140_1.dll",
"to": "."
},
{
"from": "resources/vcruntime140.dll",
"to": "."
},
{
"from": "resources/vcruntime140_1.dll",
"to": "."
}
],
"icon": "resources/icon.icns"
"icon": "resources/icons/macos/icon.icns"
},
"overrides": {
"picomatch": "^4.0.4",
"tar": "^7.5.13",
"immutable": "^5.1.5"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 570 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 570 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,30 @@
# Maintainer: H3CoF6 <h3cof6@gmail.com>
pkgname=weflow
pkgver=4.3.0
pkgrel=1
pkgdesc="A local WeChat database decryption and analysis tool"
arch=('x86_64')
url="https://github.com/hicccc77/weflow"
license=('CC-BY-NC-SA-4.0')
depends=('alsa-lib' 'gtk3' 'nss' 'glibc')
options=('!strip' '!debug')
source=("WeFlow-${pkgver}-Setup.tar.gz::${url}/releases/download/v${pkgver}/WeFlow-${pkgver}-Setup.tar.gz"
"weflow.desktop"
"icon.png")
sha256sums=('2859aca2f57c42f4d1516ed229613623c57d3e78b9cb152fcb2b9c1096ab9340'
'2cf03766f5c2f1915ad136f060a66f5788ed32b06defe1956e406c73d7e733b7'
'b1c412d9c08ae683e231173c16fe73958ad1063f14c9b3852373385e4fcb6f33')
package() {
install -dm755 "${pkgdir}/opt/${pkgname}"
cp -a "${srcdir}/WeFlow-${pkgver}-Setup/"* "${pkgdir}/opt/${pkgname}/"
install -dm755 "${pkgdir}/usr/bin"
ln -s "/opt/${pkgname}/weflow" "${pkgdir}/usr/bin/${pkgname}"
install -Dm644 "${srcdir}/weflow.desktop" -t "${pkgdir}/usr/share/applications/"
install -Dm644 "${srcdir}/icon.png" "${pkgdir}/usr/share/pixmaps/${pkgname}.png"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

View File

@@ -0,0 +1,59 @@
#!/bin/bash
set -e
APP_NAME="weflow"
APP_EXEC="weflow"
OPT_DIR="/opt/$APP_NAME"
BIN_LINK="/usr/bin/$APP_NAME"
DESKTOP_DIR="/usr/share/applications"
ICON_DIR="/usr/share/pixmaps"
if [ "$EUID" -ne 0 ]; then
echo "❌ 请使用 root 权限运行此脚本 (例如: sudo ./install.sh)"
exit 1
fi
echo "🚀 开始安装 $APP_NAME..."
echo "📦 正在复制文件到 $OPT_DIR..."
rm -rf "$OPT_DIR"
mkdir -p "$OPT_DIR"
cp -r ./* "$OPT_DIR/"
chmod -R 755 "$OPT_DIR"
chmod +x "$OPT_DIR/$APP_EXEC"
echo "🔗 正在创建软链接 $BIN_LINK..."
ln -sf "$OPT_DIR/$APP_EXEC" "$BIN_LINK"
echo "📝 正在创建桌面快捷方式..."
cat <<EOF >"$DESKTOP_DIR/${APP_NAME}.desktop"
[Desktop Entry]
Name=WeFlow
Exec=$OPT_DIR/$APP_EXEC %U
Terminal=false
Type=Application
Icon=$APP_NAME
StartupWMClass=WeFlow
Comment=A local WeChat database decryption and analysis tool
Categories=Utility;
EOF
chmod 644 "$DESKTOP_DIR/${APP_NAME}.desktop"
echo "🖼️ 正在安装图标..."
if [ -f "$OPT_DIR/resources/icon.png" ]; then
cp "$OPT_DIR/resources/icon.png" "$ICON_DIR/${APP_NAME}.png"
chmod 644 "$ICON_DIR/${APP_NAME}.png"
elif [ -f "$OPT_DIR/icon.png" ]; then
cp "$OPT_DIR/icon.png" "$ICON_DIR/${APP_NAME}.png"
chmod 644 "$ICON_DIR/${APP_NAME}.png"
else
echo "⚠️ 警告: 未找到图标文件,跳过图标安装。"
fi
if command -v update-desktop-database >/dev/null 2>&1; then
echo "🔄 更新桌面数据库..."
update-desktop-database "$DESKTOP_DIR"
fi
echo "✅ 安装完成!你现在可以在应用菜单中找到 WeFlow或者在终端输入 'weflow' 启动。"

View File

@@ -0,0 +1,9 @@
[Desktop Entry]
Name=WeFlow
Comment=一个本地的微信聊天记录导出和年度报告应用
Exec=/usr/bin/weflow %U
Terminal=false
Type=Application
Icon=weflow
StartupWMClass=WeFlow
Categories=Utility;

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,57 @@
const fs = require('node:fs');
const path = require('node:path');
const runtimeNames = [
'msvcp140.dll',
'msvcp140_1.dll',
'vcruntime140.dll',
'vcruntime140_1.dll',
];
function copyIfDifferent(sourcePath, targetPath) {
const source = fs.statSync(sourcePath);
const targetExists = fs.existsSync(targetPath);
if (targetExists) {
const target = fs.statSync(targetPath);
if (target.size === source.size && target.mtimeMs >= source.mtimeMs) {
return false;
}
}
fs.copyFileSync(sourcePath, targetPath);
return true;
}
function main() {
if (process.platform !== 'win32') {
return;
}
const projectRoot = path.resolve(__dirname, '..');
const sourceDir = path.join(projectRoot, 'resources', 'runtime', 'win32');
const targetDir = path.join(projectRoot, 'node_modules', 'electron', 'dist');
if (!fs.existsSync(sourceDir) || !fs.existsSync(targetDir)) {
return;
}
let copiedCount = 0;
for (const name of runtimeNames) {
const sourcePath = path.join(sourceDir, name);
const targetPath = path.join(targetDir, name);
if (!fs.existsSync(sourcePath)) {
continue;
}
if (copyIfDifferent(sourcePath, targetPath)) {
copiedCount += 1;
}
}
if (copiedCount > 0) {
console.log(`[prepare-electron-runtime] synced ${copiedCount} runtime DLL(s) to ${targetDir}`);
}
}
main();

View File

@@ -17,12 +17,16 @@ import AgreementPage from './pages/AgreementPage'
import GroupAnalyticsPage from './pages/GroupAnalyticsPage'
import SettingsPage from './pages/SettingsPage'
import ExportPage from './pages/ExportPage'
import MyFootprintPage from './pages/MyFootprintPage'
import VideoWindow from './pages/VideoWindow'
import ImageWindow from './pages/ImageWindow'
import SnsPage from './pages/SnsPage'
import BizPage from './pages/BizPage'
import ContactsPage from './pages/ContactsPage'
import ResourcesPage from './pages/ResourcesPage'
import ChatHistoryPage from './pages/ChatHistoryPage'
import NotificationWindow from './pages/NotificationWindow'
import AccountManagementPage from './pages/AccountManagementPage'
import { useAppStore } from './stores/appStore'
import { themes, useThemeStore, type ThemeId, type ThemeMode } from './stores/themeStore'
@@ -35,8 +39,6 @@ import UpdateDialog from './components/UpdateDialog'
import UpdateProgressCapsule from './components/UpdateProgressCapsule'
import LockScreen from './components/LockScreen'
import { GlobalSessionMonitor } from './components/GlobalSessionMonitor'
import { BatchTranscribeGlobal } from './components/BatchTranscribeGlobal'
import { BatchImageDecryptGlobal } from './components/BatchImageDecryptGlobal'
import WindowCloseDialog from './components/WindowCloseDialog'
function RouteStateRedirect({ to }: { to: string }) {
@@ -78,6 +80,7 @@ function App() {
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 isAnnualReportWindow = location.pathname === '/annual-report/view'
const isSettingsRoute = location.pathname === '/settings'
const settingsRouteState = location.state as { backgroundLocation?: Location; initialTab?: unknown } | null
const routeLocation = isSettingsRoute
@@ -103,44 +106,7 @@ function App() {
// 数据收集同意状态
const [showAnalyticsConsent, setShowAnalyticsConsent] = useState(false)
const [showWaylandWarning, setShowWaylandWarning] = useState(false)
useEffect(() => {
const checkWaylandStatus = async () => {
try {
// 防止在非客户端环境报错,先检查 API 是否存在
if (!window.electronAPI?.app?.checkWayland) return
// 通过 configService 检查是否已经弹过窗
const hasWarned = await window.electronAPI.config.get('waylandWarningShown')
if (!hasWarned) {
const isWayland = await window.electronAPI.app.checkWayland()
if (isWayland) {
setShowWaylandWarning(true)
}
}
} catch (e) {
console.error('检查 Wayland 状态失败:', e)
}
}
// 只有在协议同意之后并且已经进入主应用流程才检查
if (!isAgreementWindow && !isOnboardingWindow && !agreementLoading) {
checkWaylandStatus()
}
}, [isAgreementWindow, isOnboardingWindow, agreementLoading])
const handleDismissWaylandWarning = async () => {
try {
// 记录到本地配置中,下次不再提示
await window.electronAPI.config.set('waylandWarningShown', true)
} catch (e) {
console.error('保存 Wayland 提示状态失败:', e)
}
setShowWaylandWarning(false)
}
const [analyticsConsent, setAnalyticsConsent] = useState<boolean | null>(null)
useEffect(() => {
if (location.pathname !== '/settings') {
@@ -162,7 +128,7 @@ function App() {
const body = document.body
const appRoot = document.getElementById('app')
if (isOnboardingWindow || isNotificationWindow) {
if (isOnboardingWindow || isNotificationWindow || isAnnualReportWindow) {
root.style.background = 'transparent'
body.style.background = 'transparent'
body.style.overflow = 'hidden'
@@ -179,7 +145,7 @@ function App() {
appRoot.style.overflow = ''
}
}
}, [isOnboardingWindow])
}, [isOnboardingWindow, isNotificationWindow, isAnnualReportWindow])
// 应用主题
useEffect(() => {
@@ -200,7 +166,7 @@ function App() {
}
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [currentTheme, themeMode, isOnboardingWindow, isNotificationWindow])
}, [currentTheme, themeMode, isOnboardingWindow, isNotificationWindow, isAnnualReportWindow])
// 读取已保存的主题设置
useEffect(() => {
@@ -252,6 +218,7 @@ function App() {
// 协议已同意,检查数据收集同意状态
const consent = await configService.getAnalyticsConsent()
const denyCount = await configService.getAnalyticsDenyCount()
setAnalyticsConsent(consent)
// 如果未设置同意状态且拒绝次数小于2次显示弹窗
if (consent === null && denyCount < 2) {
setShowAnalyticsConsent(true)
@@ -266,18 +233,21 @@ function App() {
checkAgreement()
}, [])
// 初始化数据收集
// 初始化数据收集(仅在用户同意后)
useEffect(() => {
cloudControl.initCloudControl()
}, [])
if (analyticsConsent === true) {
cloudControl.initCloudControl()
}
}, [analyticsConsent])
// 记录页面访问
// 记录页面访问(仅在用户同意后)
useEffect(() => {
if (analyticsConsent !== true) return
const path = location.pathname
if (path && path !== '/') {
cloudControl.recordPage(path)
}
}, [location.pathname])
}, [location.pathname, analyticsConsent])
const handleAgree = async () => {
if (!agreementChecked) return
@@ -296,6 +266,7 @@ function App() {
const handleAnalyticsAllow = async () => {
await configService.setAnalyticsConsent(true)
setAnalyticsConsent(true)
setShowAnalyticsConsent(false)
}
@@ -312,10 +283,14 @@ function App() {
const removeUpdateListener = window.electronAPI?.app?.onUpdateAvailable?.((info: any) => {
// 发现新版本时保存更新信息,锁定状态下不弹窗,解锁后再显示
if (info) {
setUpdateInfo({ ...info, hasUpdate: true })
if (!useAppStore.getState().isLocked) {
setShowUpdateDialog(true)
}
window.electronAPI.app.getVersion().then((currentVersion: string) => {
const isMandatory = !!(info.minimumVersion && currentVersion &&
currentVersion.localeCompare(info.minimumVersion, undefined, { numeric: true, sensitivity: 'base' }) <= 0)
setUpdateInfo({ ...info, hasUpdate: true, isMandatory })
if (!useAppStore.getState().isLocked) {
setShowUpdateDialog(true)
}
})
}
})
const removeProgressListener = window.electronAPI?.app?.onDownloadProgress?.((progress: any) => {
@@ -327,6 +302,21 @@ function App() {
}
}, [setUpdateInfo, setDownloadProgress, setShowUpdateDialog, isNotificationWindow])
// 监听通知点击导航事件
useEffect(() => {
if (isNotificationWindow) return
const removeListener = window.electronAPI?.notification?.onNavigateToSession?.((sessionId: string) => {
if (!sessionId) return
// 导航到聊天页面通过URL参数让ChatPage接收sessionId
navigate(`/chat?sessionId=${encodeURIComponent(sessionId)}`, { replace: true })
})
return () => {
removeListener?.()
}
}, [navigate, isNotificationWindow])
// 解锁后显示暂存的更新弹窗
useEffect(() => {
if (!isLocked && updateInfo?.hasUpdate && !showUpdateDialog && !isDownloading) {
@@ -419,7 +409,7 @@ function App() {
}
} else {
// 如果错误信息包含 VC++ 或 DLL 相关内容,不清除配置,只提示用户
// 如果错误信息包含 VC++ 或数据服务相关内容,不清除配置,只提示用户
// 其他错误可能需要重新配置
const errorMsg = result.error || ''
if (errorMsg.includes('Visual C++') ||
@@ -522,6 +512,11 @@ function App() {
return <NotificationWindow />
}
// 独立年度报告全屏窗口
if (isAnnualReportWindow) {
return <AnnualReportWindow />
}
// 主窗口 - 完整布局
const handleCloseSettings = () => {
const backgroundLocation = settingsRouteState?.backgroundLocation ?? settingsBackgroundRef.current
@@ -563,10 +558,6 @@ function App() {
{/* 全局会话监听与通知 */}
<GlobalSessionMonitor />
{/* 全局批量转写进度浮窗 */}
<BatchTranscribeGlobal />
<BatchImageDecryptGlobal />
{/* 用户协议弹窗 */}
{showAgreement && !agreementLoading && (
<div className="agreement-overlay">
@@ -580,9 +571,13 @@ function App() {
<div className="agreement-notice">
<strong></strong>
<span className="agreement-notice-link">
<a href="https://weflow.top" target="_blank" rel="noreferrer">
https://weflow.top
</a>
&nbsp;·&nbsp;
<a href="https://github.com/hicccc77/WeFlow" target="_blank" rel="noreferrer">
https://github.com/hicccc77/WeFlow
GitHub
</a>
</span>
</div>
@@ -597,7 +592,7 @@ function App() {
<p>使使</p>
<h4>4. </h4>
<p></p>
<p></p>
</div>
</div>
<div className="agreement-footer">
@@ -654,41 +649,15 @@ function App() {
</div>
)}
{showWaylandWarning && (
<div className="agreement-overlay">
<div className="agreement-modal">
<div className="agreement-header">
<Shield size={32} />
<h2> (Wayland)</h2>
</div>
<div className="agreement-content">
<div className="agreement-text">
<p>使 <strong>Wayland</strong> </p>
<p> Wayland <strong></strong></p>
<p></p>
<br />
<p>使</p>
<p>1. <strong>X11 (Xorg)</strong> </p>
<p>2. (WM/DE) </p>
</div>
</div>
<div className="agreement-footer">
<div className="agreement-actions">
<button className="btn btn-primary" onClick={handleDismissWaylandWarning}></button>
</div>
</div>
</div>
</div>
)}
{/* 更新提示对话框 */}
<UpdateDialog
open={showUpdateDialog}
updateInfo={updateInfo}
onClose={() => setShowUpdateDialog(false)}
onClose={() => { if (!(updateInfo as any)?.isMandatory) setShowUpdateDialog(false) }}
onUpdate={handleUpdateNow}
onIgnore={handleIgnoreUpdate}
isDownloading={isDownloading}
isMandatory={!!(updateInfo as any)?.isMandatory}
progress={downloadProgress}
/>
@@ -710,6 +679,7 @@ function App() {
<Routes location={routeLocation}>
<Route path="/" element={<HomePage />} />
<Route path="/home" element={<HomePage />} />
<Route path="/account-management" element={<AccountManagementPage />} />
<Route path="/chat" element={<ChatPage />} />
<Route path="/analytics" element={<ChatAnalyticsHubPage />} />
@@ -722,10 +692,13 @@ function App() {
<Route path="/annual-report/view" element={<AnnualReportWindow />} />
<Route path="/dual-report" element={<DualReportPage />} />
<Route path="/dual-report/view" element={<DualReportWindow />} />
<Route path="/footprint" element={<MyFootprintPage />} />
<Route path="/export" element={<div className="export-route-anchor" aria-hidden="true" />} />
<Route path="/sns" element={<SnsPage />} />
<Route path="/biz" element={<BizPage />} />
<Route path="/contacts" element={<ContactsPage />} />
<Route path="/resources" element={<ResourcesPage />} />
<Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} />
<Route path="/chat-history-inline/:payloadId" element={<ChatHistoryPage />} />
</Routes>

View File

@@ -54,10 +54,11 @@
position: absolute;
top: calc(100% + 8px);
right: 0;
background: var(--card-bg);
background: var(--bg-secondary-solid, var(--bg-primary, var(--card-bg)));
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
backdrop-filter: blur(20px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
backdrop-filter: none;
-webkit-backdrop-filter: none;
border: 1px solid var(--border-color);
z-index: 1000;
display: flex;

View File

@@ -29,6 +29,20 @@ function DateRangePicker({ startDate, endDate, onStartDateChange, onEndDateChang
const [showYearMonthPicker, setShowYearMonthPicker] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const [internalStart, setInternalStart] = useState(startDate)
const [internalEnd, setInternalEnd] = useState(endDate)
useEffect(() => {
setInternalStart(startDate)
setInternalEnd(endDate)
}, [startDate, endDate])
useEffect(() => {
if (isOpen) {
setSelectingStart(true)
}
}, [isOpen])
// 点击外部关闭
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
@@ -63,8 +77,10 @@ function DateRangePicker({ startDate, endDate, onStartDateChange, onEndDateChang
const end = new Date()
const start = new Date()
start.setDate(start.getDate() - days)
onStartDateChange(start.toISOString().split('T')[0])
onEndDateChange(end.toISOString().split('T')[0])
const startStr = `${start.getFullYear()}-${String(start.getMonth() + 1).padStart(2, '0')}-${String(start.getDate()).padStart(2, '0')}`
const endStr = `${end.getFullYear()}-${String(end.getMonth() + 1).padStart(2, '0')}-${String(end.getDate()).padStart(2, '0')}`
onStartDateChange(startStr)
onEndDateChange(endStr)
}
setIsOpen(false)
setTimeout(() => onRangeComplete?.(), 0)
@@ -89,38 +105,46 @@ function DateRangePicker({ startDate, endDate, onStartDateChange, onEndDateChang
const dateStr = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
if (selectingStart) {
onStartDateChange(dateStr)
if (endDate && dateStr > endDate) {
onEndDateChange('')
setInternalStart(dateStr)
if (internalEnd && dateStr > internalEnd) {
setInternalEnd('')
}
setSelectingStart(false)
} else {
if (dateStr < startDate) {
onStartDateChange(dateStr)
onEndDateChange(startDate)
} else {
onEndDateChange(dateStr)
let finalStart = internalStart
let finalEnd = dateStr
if (dateStr < internalStart) {
finalStart = dateStr
finalEnd = internalStart
}
setInternalStart(finalStart)
setInternalEnd(finalEnd)
setSelectingStart(true)
setIsOpen(false)
onStartDateChange(finalStart)
onEndDateChange(finalEnd)
setTimeout(() => onRangeComplete?.(), 0)
}
}
const isInRange = (day: number) => {
if (!startDate || !endDate) return false
if (!internalStart || !internalEnd) return false
const dateStr = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
return dateStr >= startDate && dateStr <= endDate
return dateStr >= internalStart && dateStr <= internalEnd
}
const isStartDate = (day: number) => {
const dateStr = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
return dateStr === startDate
return dateStr === internalStart
}
const isEndDate = (day: number) => {
const dateStr = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
return dateStr === endDate
return dateStr === internalEnd
}
const isToday = (day: number) => {

View File

@@ -6,7 +6,7 @@
align-items: center;
justify-content: center;
padding: 16px;
z-index: 2400;
z-index: 9200;
}
.export-date-range-dialog {
@@ -192,6 +192,149 @@
}
}
.export-date-range-time-select {
position: relative;
width: 100%;
&.open .export-date-range-time-trigger {
border-color: var(--primary);
box-shadow: 0 0 0 1px rgba(var(--primary-rgb), 0.18);
color: var(--primary);
}
}
.export-date-range-time-trigger {
width: 100%;
min-width: 0;
border-radius: 8px;
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-primary);
height: 30px;
padding: 0 9px;
font-size: 12px;
font-family: inherit;
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 8px;
cursor: pointer;
transition: border-color 0.15s ease, box-shadow 0.15s ease, color 0.15s ease;
&:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 1px rgba(var(--primary-rgb), 0.18);
}
}
.export-date-range-time-trigger-value {
flex: 1;
min-width: 0;
text-align: left;
}
.export-date-range-time-dropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
z-index: 24;
border: 1px solid var(--border-color);
border-radius: 12px;
background: color-mix(in srgb, var(--bg-primary) 88%, var(--bg-secondary));
box-shadow: var(--shadow-md);
padding: 8px;
display: flex;
flex-direction: column;
gap: 8px;
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
}
.export-date-range-time-dropdown-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
span {
font-size: 11px;
color: var(--text-secondary);
}
strong {
font-size: 13px;
color: var(--text-primary);
}
}
.export-date-range-time-quick-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.export-date-range-time-quick-item,
.export-date-range-time-option {
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: var(--text-primary);
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
&:hover {
background: var(--bg-tertiary);
}
&.active {
border-color: rgba(var(--primary-rgb), 0.28);
background: rgba(var(--primary-rgb), 0.12);
color: var(--primary);
}
}
.export-date-range-time-quick-item {
min-width: 52px;
height: 28px;
padding: 0 10px;
font-size: 11px;
}
.export-date-range-time-columns {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.export-date-range-time-column {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.export-date-range-time-column-label {
font-size: 11px;
color: var(--text-secondary);
}
.export-date-range-time-column-list {
max-height: 168px;
overflow-y: auto;
padding-right: 2px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px;
}
.export-date-range-time-option {
min-height: 28px;
padding: 0 8px;
font-size: 11px;
}
.export-date-range-calendar-nav {
display: inline-flex;
align-items: center;

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Check, ChevronLeft, ChevronRight, X } from 'lucide-react'
import { Check, ChevronDown, ChevronLeft, ChevronRight, X } from 'lucide-react'
import {
EXPORT_DATE_RANGE_PRESETS,
WEEKDAY_SHORT_LABELS,
@@ -10,7 +10,6 @@ import {
createDateRangeByPreset,
createDefaultDateRange,
formatCalendarMonthTitle,
formatDateInputValue,
isSameDay,
parseDateInputValue,
startOfDay,
@@ -37,6 +36,10 @@ interface ExportDateRangeDialogDraft extends ExportDateRangeSelection {
panelMonth: Date
}
const HOUR_OPTIONS = Array.from({ length: 24 }, (_, index) => `${index}`.padStart(2, '0'))
const MINUTE_OPTIONS = Array.from({ length: 60 }, (_, index) => `${index}`.padStart(2, '0'))
const QUICK_TIME_OPTIONS = ['00:00', '08:00', '12:00', '18:00', '23:59']
const resolveBounds = (minDate?: Date | null, maxDate?: Date | null): { minDate: Date; maxDate: Date } | null => {
if (!(minDate instanceof Date) || Number.isNaN(minDate.getTime())) return null
if (!(maxDate instanceof Date) || Number.isNaN(maxDate.getTime())) return null
@@ -57,16 +60,42 @@ const clampSelectionToBounds = (
const bounds = resolveBounds(minDate, maxDate)
if (!bounds) return cloneExportDateRangeSelection(value)
const rawStart = value.useAllTime ? bounds.minDate : startOfDay(value.dateRange.start)
const rawEnd = value.useAllTime ? bounds.maxDate : endOfDay(value.dateRange.end)
const nextStart = new Date(Math.min(Math.max(rawStart.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime()))
const nextEndCandidate = new Date(Math.min(Math.max(rawEnd.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime()))
const nextEnd = nextEndCandidate.getTime() < nextStart.getTime() ? endOfDay(nextStart) : nextEndCandidate
const changed = nextStart.getTime() !== rawStart.getTime() || nextEnd.getTime() !== rawEnd.getTime()
// For custom selections, only ensure end >= start, preserve time precision
if (value.preset === 'custom' && !value.useAllTime) {
const { start, end } = value.dateRange
if (end.getTime() < start.getTime()) {
return {
...value,
dateRange: { start, end: start }
}
}
return cloneExportDateRangeSelection(value)
}
// For useAllTime, use bounds directly
if (value.useAllTime) {
return {
preset: value.preset,
useAllTime: true,
dateRange: {
start: bounds.minDate,
end: bounds.maxDate
}
}
}
// For preset selections (not custom), clamp dates to bounds and use default times
const nextStart = new Date(Math.min(Math.max(value.dateRange.start.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime()))
const nextEndCandidate = new Date(Math.min(Math.max(value.dateRange.end.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime()))
const nextEnd = nextEndCandidate.getTime() < nextStart.getTime() ? nextStart : nextEndCandidate
// Set default times: start at 00:00:00, end at 23:59:59
nextStart.setHours(0, 0, 0, 0)
nextEnd.setHours(23, 59, 59, 999)
return {
preset: value.useAllTime ? value.preset : (changed ? 'custom' : value.preset),
useAllTime: value.useAllTime,
preset: value.preset,
useAllTime: false,
dateRange: {
start: nextStart,
end: nextEnd
@@ -95,62 +124,129 @@ export function ExportDateRangeDialog({
onClose,
onConfirm
}: ExportDateRangeDialogProps) {
// Helper: Format date only (YYYY-MM-DD) for the date input field
const formatDateOnly = (date: Date): string => {
const y = date.getFullYear()
const m = `${date.getMonth() + 1}`.padStart(2, '0')
const d = `${date.getDate()}`.padStart(2, '0')
return `${y}-${m}-${d}`
}
// Helper: Format time only (HH:mm) for the time input field
const formatTimeOnly = (date: Date): string => {
const h = `${date.getHours()}`.padStart(2, '0')
const m = `${date.getMinutes()}`.padStart(2, '0')
return `${h}:${m}`
}
const [draft, setDraft] = useState<ExportDateRangeDialogDraft>(() => buildDialogDraft(value, minDate, maxDate))
const [activeBoundary, setActiveBoundary] = useState<ActiveBoundary>('start')
const [dateInput, setDateInput] = useState({
start: formatDateInputValue(value.dateRange.start),
end: formatDateInputValue(value.dateRange.end)
start: formatDateOnly(value.dateRange.start),
end: formatDateOnly(value.dateRange.end)
})
const [dateInputError, setDateInputError] = useState({ start: false, end: false })
// Default times: start at 00:00, end at 23:59
const [timeInput, setTimeInput] = useState({
start: '00:00',
end: '23:59'
})
const [openTimeDropdown, setOpenTimeDropdown] = useState<ActiveBoundary | null>(null)
const startTimeSelectRef = useRef<HTMLDivElement>(null)
const endTimeSelectRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const nextDraft = buildDialogDraft(value, minDate, maxDate)
setDraft(nextDraft)
setActiveBoundary('start')
setDateInput({
start: formatDateInputValue(nextDraft.dateRange.start),
end: formatDateInputValue(nextDraft.dateRange.end)
start: formatDateOnly(nextDraft.dateRange.start),
end: formatDateOnly(nextDraft.dateRange.end)
})
// For preset-based selections (not custom), use default times 00:00 and 23:59
// For custom selections, preserve the time from value.dateRange
if (nextDraft.useAllTime || nextDraft.preset !== 'custom') {
setTimeInput({
start: '00:00',
end: '23:59'
})
} else {
setTimeInput({
start: formatTimeOnly(nextDraft.dateRange.start),
end: formatTimeOnly(nextDraft.dateRange.end)
})
}
setOpenTimeDropdown(null)
setDateInputError({ start: false, end: false })
}, [maxDate, minDate, open, value])
useEffect(() => {
if (!open) return
setDateInput({
start: formatDateInputValue(draft.dateRange.start),
end: formatDateInputValue(draft.dateRange.end)
start: formatDateOnly(draft.dateRange.start),
end: formatDateOnly(draft.dateRange.end)
})
// Don't sync timeInput here - it's controlled by the time picker
setDateInputError({ start: false, end: false })
}, [draft.dateRange.end.getTime(), draft.dateRange.start.getTime(), open])
useEffect(() => {
if (!openTimeDropdown) return
const handlePointerDown = (event: MouseEvent) => {
const target = event.target as Node
const activeContainer = openTimeDropdown === 'start'
? startTimeSelectRef.current
: endTimeSelectRef.current
if (!activeContainer?.contains(target)) {
setOpenTimeDropdown(null)
}
}
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setOpenTimeDropdown(null)
}
}
document.addEventListener('mousedown', handlePointerDown)
document.addEventListener('keydown', handleEscape)
return () => {
document.removeEventListener('mousedown', handlePointerDown)
document.removeEventListener('keydown', handleEscape)
}
}, [openTimeDropdown])
const bounds = useMemo(() => resolveBounds(minDate, maxDate), [maxDate, minDate])
const clampStartDate = useCallback((targetDate: Date) => {
const start = startOfDay(targetDate)
if (!bounds) return start
if (start.getTime() < bounds.minDate.getTime()) return bounds.minDate
if (start.getTime() > bounds.maxDate.getTime()) return startOfDay(bounds.maxDate)
return start
if (!bounds) return targetDate
const min = bounds.minDate
const max = bounds.maxDate
if (targetDate.getTime() < min.getTime()) return min
if (targetDate.getTime() > max.getTime()) return max
return targetDate
}, [bounds])
const clampEndDate = useCallback((targetDate: Date) => {
const end = endOfDay(targetDate)
if (!bounds) return end
if (end.getTime() < bounds.minDate.getTime()) return endOfDay(bounds.minDate)
if (end.getTime() > bounds.maxDate.getTime()) return bounds.maxDate
return end
if (!bounds) return targetDate
const min = bounds.minDate
const max = bounds.maxDate
if (targetDate.getTime() < min.getTime()) return min
if (targetDate.getTime() > max.getTime()) return max
return targetDate
}, [bounds])
const setRangeStart = useCallback((targetDate: Date) => {
const start = clampStartDate(targetDate)
setDraft(prev => {
const nextEnd = prev.dateRange.end < start ? endOfDay(start) : prev.dateRange.end
return {
...prev,
preset: 'custom',
useAllTime: false,
dateRange: {
start,
end: nextEnd
end: prev.dateRange.end
},
panelMonth: toMonthStart(start)
}
@@ -161,14 +257,13 @@ export function ExportDateRangeDialog({
const end = clampEndDate(targetDate)
setDraft(prev => {
const nextStart = prev.useAllTime ? clampStartDate(targetDate) : prev.dateRange.start
const nextEnd = end < nextStart ? endOfDay(nextStart) : end
return {
...prev,
preset: 'custom',
useAllTime: false,
dateRange: {
start: nextStart,
end: nextEnd
end: end
},
panelMonth: toMonthStart(targetDate)
}
@@ -180,6 +275,11 @@ export function ExportDateRangeDialog({
const previewRange = bounds
? { start: bounds.minDate, end: bounds.maxDate }
: createDefaultDateRange()
setTimeInput({
start: '00:00',
end: '23:59'
})
setOpenTimeDropdown(null)
setDraft(prev => ({
...prev,
preset,
@@ -196,6 +296,11 @@ export function ExportDateRangeDialog({
useAllTime: false,
dateRange: createDateRangeByPreset(preset)
}, minDate, maxDate).dateRange
setTimeInput({
start: '00:00',
end: '23:59'
})
setOpenTimeDropdown(null)
setDraft(prev => ({
...prev,
preset,
@@ -206,25 +311,149 @@ export function ExportDateRangeDialog({
setActiveBoundary('start')
}, [bounds, maxDate, minDate])
const parseTimeValue = (timeStr: string): { hours: number; minutes: number } | null => {
const matched = /^(\d{1,2}):(\d{2})$/.exec(timeStr.trim())
if (!matched) return null
const hours = Number(matched[1])
const minutes = Number(matched[2])
if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null
return { hours, minutes }
}
const updateBoundaryTime = useCallback((boundary: ActiveBoundary, timeStr: string) => {
setTimeInput(prev => ({ ...prev, [boundary]: timeStr }))
const parsedTime = parseTimeValue(timeStr)
if (!parsedTime) return
setDraft(prev => {
const dateObj = boundary === 'start' ? prev.dateRange.start : prev.dateRange.end
const newDate = new Date(dateObj)
newDate.setHours(parsedTime.hours, parsedTime.minutes, 0, 0)
return {
...prev,
preset: 'custom',
useAllTime: false,
dateRange: {
...prev.dateRange,
[boundary]: newDate
}
}
})
}, [])
const toggleTimeDropdown = useCallback((boundary: ActiveBoundary) => {
setActiveBoundary(boundary)
setOpenTimeDropdown(prev => (prev === boundary ? null : boundary))
}, [])
const handleTimeColumnSelect = useCallback((boundary: ActiveBoundary, field: 'hour' | 'minute', value: string) => {
const parsedCurrent = parseTimeValue(timeInput[boundary]) ?? {
hours: boundary === 'start' ? 0 : 23,
minutes: boundary === 'start' ? 0 : 59
}
const nextHours = field === 'hour' ? Number(value) : parsedCurrent.hours
const nextMinutes = field === 'minute' ? Number(value) : parsedCurrent.minutes
updateBoundaryTime(boundary, `${`${nextHours}`.padStart(2, '0')}:${`${nextMinutes}`.padStart(2, '0')}`)
}, [timeInput, updateBoundaryTime])
const renderTimeDropdown = (boundary: ActiveBoundary) => {
const currentTime = timeInput[boundary]
const parsedCurrent = parseTimeValue(currentTime) ?? {
hours: boundary === 'start' ? 0 : 23,
minutes: boundary === 'start' ? 0 : 59
}
return (
<div className="export-date-range-time-dropdown" onClick={(event) => event.stopPropagation()}>
<div className="export-date-range-time-dropdown-header">
<span>{boundary === 'start' ? '开始时间' : '结束时间'}</span>
<strong>{currentTime}</strong>
</div>
<div className="export-date-range-time-quick-list">
{QUICK_TIME_OPTIONS.map(option => (
<button
key={`${boundary}-${option}`}
type="button"
className={`export-date-range-time-quick-item ${currentTime === option ? 'active' : ''}`}
onClick={() => updateBoundaryTime(boundary, option)}
>
{option}
</button>
))}
</div>
<div className="export-date-range-time-columns">
<div className="export-date-range-time-column">
<span className="export-date-range-time-column-label"></span>
<div className="export-date-range-time-column-list">
{HOUR_OPTIONS.map(option => (
<button
key={`${boundary}-hour-${option}`}
type="button"
className={`export-date-range-time-option ${parsedCurrent.hours === Number(option) ? 'active' : ''}`}
onClick={() => handleTimeColumnSelect(boundary, 'hour', option)}
>
{option}
</button>
))}
</div>
</div>
<div className="export-date-range-time-column">
<span className="export-date-range-time-column-label"></span>
<div className="export-date-range-time-column-list">
{MINUTE_OPTIONS.map(option => (
<button
key={`${boundary}-minute-${option}`}
type="button"
className={`export-date-range-time-option ${parsedCurrent.minutes === Number(option) ? 'active' : ''}`}
onClick={() => handleTimeColumnSelect(boundary, 'minute', option)}
>
{option}
</button>
))}
</div>
</div>
</div>
</div>
)
}
// Check if date input string contains time (YYYY-MM-DD HH:mm format)
const dateInputHasTime = (dateStr: string): boolean => /^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}$/.test(dateStr.trim())
const commitStartFromInput = useCallback(() => {
const parsed = parseDateInputValue(dateInput.start)
if (!parsed) {
const parsedDate = parseDateInputValue(dateInput.start)
if (!parsedDate) {
setDateInputError(prev => ({ ...prev, start: true }))
return
}
// Only apply time picker value if date input doesn't contain time
if (!dateInputHasTime(dateInput.start)) {
const parsedTime = parseTimeValue(timeInput.start)
if (parsedTime) {
parsedDate.setHours(parsedTime.hours, parsedTime.minutes, 0, 0)
}
}
setDateInputError(prev => ({ ...prev, start: false }))
setRangeStart(parsed)
}, [dateInput.start, setRangeStart])
setRangeStart(parsedDate)
}, [dateInput.start, timeInput.start, setRangeStart])
const commitEndFromInput = useCallback(() => {
const parsed = parseDateInputValue(dateInput.end)
if (!parsed) {
const parsedDate = parseDateInputValue(dateInput.end)
if (!parsedDate) {
setDateInputError(prev => ({ ...prev, end: true }))
return
}
// Only apply time picker value if date input doesn't contain time
if (!dateInputHasTime(dateInput.end)) {
const parsedTime = parseTimeValue(timeInput.end)
if (parsedTime) {
parsedDate.setHours(parsedTime.hours, parsedTime.minutes, 0, 0)
}
}
setDateInputError(prev => ({ ...prev, end: false }))
setRangeEnd(parsed)
}, [dateInput.end, setRangeEnd])
setRangeEnd(parsedDate)
}, [dateInput.end, timeInput.end, setRangeEnd])
const shiftPanelMonth = useCallback((delta: number) => {
setDraft(prev => ({
@@ -234,30 +463,50 @@ export function ExportDateRangeDialog({
}, [])
const handleCalendarSelect = useCallback((targetDate: Date) => {
// Use time from timeInput state (which is updated by the time picker)
const parseTime = (timeStr: string): { hours: number; minutes: number } => {
const matched = /^(\d{1,2}):(\d{2})$/.exec(timeStr.trim())
if (!matched) return { hours: 0, minutes: 0 }
return { hours: Number(matched[1]), minutes: Number(matched[2]) }
}
if (activeBoundary === 'start') {
setRangeStart(targetDate)
const newStart = new Date(targetDate)
const time = parseTime(timeInput.start)
newStart.setHours(time.hours, time.minutes, 0, 0)
setRangeStart(newStart)
setActiveBoundary('end')
setOpenTimeDropdown(null)
return
}
setDraft(prev => {
const start = prev.useAllTime ? startOfDay(targetDate) : prev.dateRange.start
const pickedStart = startOfDay(targetDate)
const nextStart = pickedStart <= start ? pickedStart : start
const nextEnd = pickedStart <= start ? endOfDay(start) : endOfDay(targetDate)
return {
...prev,
preset: 'custom',
useAllTime: false,
dateRange: {
start: nextStart,
end: nextEnd
},
panelMonth: toMonthStart(targetDate)
}
})
const pickedStart = startOfDay(targetDate)
const start = draft.useAllTime ? startOfDay(targetDate) : draft.dateRange.start
const nextStart = pickedStart <= start ? pickedStart : start
const newEnd = new Date(targetDate)
const time = parseTime(timeInput.end)
// If selecting same day or going backwards, use 23:59:59, otherwise use the time from timeInput
if (pickedStart <= start) {
newEnd.setHours(23, 59, 59, 999)
setTimeInput(prev => ({ ...prev, end: '23:59' }))
} else {
newEnd.setHours(time.hours, time.minutes, 59, 999)
}
setDraft(prev => ({
...prev,
preset: 'custom',
useAllTime: false,
dateRange: {
start: nextStart,
end: newEnd
},
panelMonth: toMonthStart(targetDate)
}))
setActiveBoundary('start')
}, [activeBoundary, setRangeEnd, setRangeStart])
setOpenTimeDropdown(null)
}, [activeBoundary, draft.dateRange.start, draft.useAllTime, timeInput.end, timeInput.start, setRangeStart])
const isRangeModeActive = !draft.useAllTime
const modeText = isRangeModeActive
@@ -364,6 +613,23 @@ export function ExportDateRangeDialog({
}}
onBlur={commitStartFromInput}
/>
<div
className={`export-date-range-time-select ${openTimeDropdown === 'start' ? 'open' : ''}`}
ref={startTimeSelectRef}
onClick={(event) => event.stopPropagation()}
>
<button
type="button"
className="export-date-range-time-trigger"
onClick={() => toggleTimeDropdown('start')}
aria-haspopup="dialog"
aria-expanded={openTimeDropdown === 'start'}
>
<span className="export-date-range-time-trigger-value">{timeInput.start}</span>
<ChevronDown size={14} />
</button>
{openTimeDropdown === 'start' && renderTimeDropdown('start')}
</div>
</div>
<div
className={`export-date-range-boundary-card ${activeBoundary === 'end' ? 'active' : ''}`}
@@ -391,6 +657,23 @@ export function ExportDateRangeDialog({
}}
onBlur={commitEndFromInput}
/>
<div
className={`export-date-range-time-select ${openTimeDropdown === 'end' ? 'open' : ''}`}
ref={endTimeSelectRef}
onClick={(event) => event.stopPropagation()}
>
<button
type="button"
className="export-date-range-time-trigger"
onClick={() => toggleTimeDropdown('end')}
aria-haspopup="dialog"
aria-expanded={openTimeDropdown === 'end'}
>
<span className="export-date-range-time-trigger-value">{timeInput.end}</span>
<ChevronDown size={14} />
</button>
{openTimeDropdown === 'end' && renderTimeDropdown('end')}
</div>
</div>
</div>
@@ -453,7 +736,14 @@ export function ExportDateRangeDialog({
<button
type="button"
className="export-date-range-dialog-btn primary"
onClick={() => onConfirm(cloneExportDateRangeSelection(draft))}
onClick={() => {
// Validate: end time should not be earlier than start time
if (draft.dateRange.end.getTime() < draft.dateRange.start.getTime()) {
setDateInputError({ start: true, end: true })
return
}
onConfirm(cloneExportDateRangeSelection(draft))
}}
>
</button>

View File

@@ -15,6 +15,7 @@ export interface ExportDefaultsSettingsPatch {
format?: string
avatars?: boolean
dateRange?: ExportDateRangeSelection
fileNamingMode?: configService.ExportFileNamingMode
media?: configService.ExportDefaultMediaConfig
voiceAsText?: boolean
excelCompactColumns?: boolean
@@ -44,6 +45,11 @@ const exportExcelColumnOptions = [
{ value: 'full', label: '完整列', desc: '含发送者昵称/微信ID/备注' }
] as const
const exportFileNamingModeOptions: Array<{ value: configService.ExportFileNamingMode; label: string; desc: string }> = [
{ value: 'classic', label: '简洁模式', desc: '示例私聊_张三兼容旧版' },
{ value: 'date-range', label: '时间范围模式', desc: '示例私聊_张三_20250101-20250331推荐' }
]
const exportConcurrencyOptions = [1, 2, 3, 4, 5, 6] as const
const getOptionLabel = (options: ReadonlyArray<{ value: string; label: string }>, value: string) => {
@@ -56,17 +62,21 @@ export function ExportDefaultsSettingsForm({
layout = 'stacked'
}: ExportDefaultsSettingsFormProps) {
const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false)
const [showExportFileNamingModeSelect, setShowExportFileNamingModeSelect] = useState(false)
const [isExportDateRangeDialogOpen, setIsExportDateRangeDialogOpen] = useState(false)
const exportExcelColumnsDropdownRef = useRef<HTMLDivElement>(null)
const exportFileNamingModeDropdownRef = useRef<HTMLDivElement>(null)
const [exportDefaultFormat, setExportDefaultFormat] = useState('excel')
const [exportDefaultAvatars, setExportDefaultAvatars] = useState(true)
const [exportDefaultDateRange, setExportDefaultDateRange] = useState<ExportDateRangeSelection>(() => createDefaultExportDateRangeSelection())
const [exportDefaultFileNamingMode, setExportDefaultFileNamingMode] = useState<configService.ExportFileNamingMode>('classic')
const [exportDefaultMedia, setExportDefaultMedia] = useState<configService.ExportDefaultMediaConfig>({
images: true,
videos: true,
voices: true,
emojis: true
emojis: true,
files: true
})
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false)
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
@@ -75,10 +85,11 @@ export function ExportDefaultsSettingsForm({
useEffect(() => {
let cancelled = false
void (async () => {
const [savedFormat, savedAvatars, savedDateRange, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedConcurrency] = await Promise.all([
const [savedFormat, savedAvatars, savedDateRange, savedFileNamingMode, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedConcurrency] = await Promise.all([
configService.getExportDefaultFormat(),
configService.getExportDefaultAvatars(),
configService.getExportDefaultDateRange(),
configService.getExportDefaultFileNamingMode(),
configService.getExportDefaultMedia(),
configService.getExportDefaultVoiceAsText(),
configService.getExportDefaultExcelCompactColumns(),
@@ -90,11 +101,13 @@ export function ExportDefaultsSettingsForm({
setExportDefaultFormat(savedFormat || 'excel')
setExportDefaultAvatars(savedAvatars ?? true)
setExportDefaultDateRange(resolveExportDateRangeConfig(savedDateRange))
setExportDefaultFileNamingMode(savedFileNamingMode ?? 'classic')
setExportDefaultMedia(savedMedia ?? {
images: true,
videos: true,
voices: true,
emojis: true
emojis: true,
files: true
})
setExportDefaultVoiceAsText(savedVoiceAsText ?? false)
setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true)
@@ -112,15 +125,19 @@ export function ExportDefaultsSettingsForm({
if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) {
setShowExportExcelColumnsSelect(false)
}
if (showExportFileNamingModeSelect && exportFileNamingModeDropdownRef.current && !exportFileNamingModeDropdownRef.current.contains(target)) {
setShowExportFileNamingModeSelect(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showExportExcelColumnsSelect])
}, [showExportExcelColumnsSelect, showExportFileNamingModeSelect])
const exportExcelColumnsValue = exportDefaultExcelCompactColumns ? 'compact' : 'full'
const exportDateRangeLabel = useMemo(() => getExportDateRangeLabel(exportDefaultDateRange), [exportDefaultDateRange])
const exportExcelColumnsLabel = useMemo(() => getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue), [exportExcelColumnsValue])
const exportFileNamingModeLabel = useMemo(() => getOptionLabel(exportFileNamingModeOptions, exportDefaultFileNamingMode), [exportDefaultFileNamingMode])
const notify = (text: string, success = true) => {
onNotify?.(text, success)
@@ -222,6 +239,7 @@ export function ExportDefaultsSettingsForm({
className={`settings-time-range-trigger ${isExportDateRangeDialogOpen ? 'open' : ''}`}
onClick={() => {
setShowExportExcelColumnsSelect(false)
setShowExportFileNamingModeSelect(false)
setIsExportDateRangeDialogOpen(true)
}}
>
@@ -245,6 +263,50 @@ export function ExportDefaultsSettingsForm({
}}
/>
<div className="form-group">
<div className="form-copy">
<label></label>
<span className="form-hint"></span>
</div>
<div className="form-control">
<div className="select-field" ref={exportFileNamingModeDropdownRef}>
<button
type="button"
className={`select-trigger ${showExportFileNamingModeSelect ? 'open' : ''}`}
onClick={() => {
setShowExportFileNamingModeSelect(!showExportFileNamingModeSelect)
setShowExportExcelColumnsSelect(false)
setIsExportDateRangeDialogOpen(false)
}}
>
<span className="select-value">{exportFileNamingModeLabel}</span>
<ChevronDown size={16} />
</button>
{showExportFileNamingModeSelect && (
<div className="select-dropdown">
{exportFileNamingModeOptions.map((option) => (
<button
key={option.value}
type="button"
className={`select-option ${exportDefaultFileNamingMode === option.value ? 'active' : ''}`}
onClick={async () => {
setExportDefaultFileNamingMode(option.value)
await configService.setExportDefaultFileNamingMode(option.value)
onDefaultsChanged?.({ fileNamingMode: option.value })
notify('已更新导出文件命名方式', true)
setShowExportFileNamingModeSelect(false)
}}
>
<span className="option-label">{option.label}</span>
<span className="option-desc">{option.desc}</span>
</button>
))}
</div>
)}
</div>
</div>
</div>
<div className="form-group">
<div className="form-copy">
<label>Excel </label>
@@ -257,6 +319,7 @@ export function ExportDefaultsSettingsForm({
className={`select-trigger ${showExportExcelColumnsSelect ? 'open' : ''}`}
onClick={() => {
setShowExportExcelColumnsSelect(!showExportExcelColumnsSelect)
setShowExportFileNamingModeSelect(false)
setIsExportDateRangeDialogOpen(false)
}}
>
@@ -292,7 +355,7 @@ export function ExportDefaultsSettingsForm({
<div className="form-group media-setting-group">
<div className="form-copy">
<label></label>
<span className="form-hint"></span>
<span className="form-hint"></span>
</div>
<div className="form-control">
<div className="media-default-grid">
@@ -352,6 +415,20 @@ export function ExportDefaultsSettingsForm({
/>
</label>
<label>
<input
type="checkbox"
checked={exportDefaultMedia.files}
onChange={async (e) => {
const next = { ...exportDefaultMedia, files: e.target.checked }
setExportDefaultMedia(next)
await configService.setExportDefaultMedia(next)
onDefaultsChanged?.({ media: next })
notify(`${e.target.checked ? '开启' : '关闭'}默认导出文件`, true)
}}
/>
</label>
</div>
</div>
</div>

View File

@@ -75,6 +75,8 @@
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
border: none;
background: transparent;
&.clickable {
cursor: pointer;
@@ -172,6 +174,33 @@
}
}
}
.year-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
.year-btn {
padding: 10px 0;
border: none;
background: transparent;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
color: var(--text-secondary);
transition: all 0.15s;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
&.active {
background: var(--primary);
color: #fff;
}
}
}
}
}

View File

@@ -1,4 +1,4 @@
import React, { useState, useMemo } from 'react'
import React, { useState } from 'react'
import { X, ChevronLeft, ChevronRight, Calendar as CalendarIcon, Loader2 } from 'lucide-react'
import './JumpToDateDialog.scss'
@@ -21,10 +21,15 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
messageDates,
loadingDates = false
}) => {
type CalendarViewMode = 'day' | 'month' | 'year'
const getYearPageStart = (year: number): number => Math.floor(year / 12) * 12
const isValidDate = (d: any) => d instanceof Date && !isNaN(d.getTime())
const [calendarDate, setCalendarDate] = useState(isValidDate(currentDate) ? new Date(currentDate) : new Date())
const [selectedDate, setSelectedDate] = useState(new Date(currentDate))
const [showYearMonthPicker, setShowYearMonthPicker] = useState(false)
const [viewMode, setViewMode] = useState<CalendarViewMode>('day')
const [yearPageStart, setYearPageStart] = useState<number>(
getYearPageStart((isValidDate(currentDate) ? new Date(currentDate) : new Date()).getFullYear())
)
if (!isOpen) return null
@@ -116,6 +121,57 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
const days = generateCalendar()
const monthNames = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月']
const updateCalendarDate = (nextDate: Date) => {
setCalendarDate(nextDate)
}
const openMonthView = () => setViewMode('month')
const openYearView = () => {
setYearPageStart(getYearPageStart(calendarDate.getFullYear()))
setViewMode('year')
}
const handleTitleClick = () => {
if (viewMode === 'day') {
openMonthView()
return
}
if (viewMode === 'month') {
openYearView()
}
}
const handlePrev = () => {
if (viewMode === 'day') {
updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))
return
}
if (viewMode === 'month') {
updateCalendarDate(new Date(calendarDate.getFullYear() - 1, calendarDate.getMonth(), 1))
return
}
setYearPageStart((prev) => prev - 12)
}
const handleNext = () => {
if (viewMode === 'day') {
updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))
return
}
if (viewMode === 'month') {
updateCalendarDate(new Date(calendarDate.getFullYear() + 1, calendarDate.getMonth(), 1))
return
}
setYearPageStart((prev) => prev + 12)
}
const navTitle = viewMode === 'day'
? `${calendarDate.getFullYear()}${calendarDate.getMonth() + 1}`
: viewMode === 'month'
? `${calendarDate.getFullYear()}`
: `${yearPageStart}年 - ${yearPageStart + 11}`
return (
<div className="jump-date-overlay" onClick={onClose}>
@@ -134,45 +190,57 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
<div className="calendar-nav">
<button
className="nav-btn"
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))}
onClick={handlePrev}
>
<ChevronLeft size={18} />
</button>
<span className="current-month clickable" onClick={() => setShowYearMonthPicker(!showYearMonthPicker)}>
{calendarDate.getFullYear()}{calendarDate.getMonth() + 1}
</span>
<button
className={`current-month ${viewMode === 'year' ? '' : 'clickable'}`.trim()}
onClick={handleTitleClick}
type="button"
>
{navTitle}
</button>
<button
className="nav-btn"
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))}
onClick={handleNext}
>
<ChevronRight size={18} />
</button>
</div>
{showYearMonthPicker ? (
{viewMode === 'month' ? (
<div className="year-month-picker">
<div className="year-selector">
<button className="nav-btn" onClick={() => setCalendarDate(new Date(calendarDate.getFullYear() - 1, calendarDate.getMonth(), 1))}>
<ChevronLeft size={16} />
</button>
<span className="year-label">{calendarDate.getFullYear()}</span>
<button className="nav-btn" onClick={() => setCalendarDate(new Date(calendarDate.getFullYear() + 1, calendarDate.getMonth(), 1))}>
<ChevronRight size={16} />
</button>
</div>
<div className="month-grid">
{['一月','二月','三月','四月','五月','六月','七月','八月','九月','十月','十一月','十二月'].map((name, i) => (
{monthNames.map((name, i) => (
<button
key={i}
className={`month-btn ${i === calendarDate.getMonth() ? 'active' : ''}`}
onClick={() => {
setCalendarDate(new Date(calendarDate.getFullYear(), i, 1))
setShowYearMonthPicker(false)
updateCalendarDate(new Date(calendarDate.getFullYear(), i, 1))
setViewMode('day')
}}
>{name}</button>
))}
</div>
</div>
) : viewMode === 'year' ? (
<div className="year-month-picker">
<div className="year-grid">
{Array.from({ length: 12 }, (_, i) => yearPageStart + i).map((year) => (
<button
key={year}
className={`year-btn ${year === calendarDate.getFullYear() ? 'active' : ''}`}
onClick={() => {
updateCalendarDate(new Date(year, calendarDate.getMonth(), 1))
setViewMode('month')
}}
>
{year}
</button>
))}
</div>
</div>
) : (
<div className={`calendar-grid ${loadingDates ? 'loading' : ''}`}>
{loadingDates && (
@@ -208,18 +276,21 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
const d = new Date()
setSelectedDate(d)
setCalendarDate(new Date(d))
setViewMode('day')
}}></button>
<button onClick={() => {
const d = new Date()
d.setDate(d.getDate() - 7)
setSelectedDate(d)
setCalendarDate(new Date(d))
setViewMode('day')
}}></button>
<button onClick={() => {
const d = new Date()
d.setMonth(d.getMonth() - 1)
setSelectedDate(d)
setCalendarDate(new Date(d))
setViewMode('day')
}}></button>
</div>

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