Compare commits

...

296 Commits

Author SHA1 Message Date
cc
ca6c479496 Merge pull request #971 from hicccc77/dev
Dev
2026-05-15 21:41:08 +08:00
cc
6d419dbe9e Merge pull request #966 from Jasonzhu1207/main
feat: Disable AI Output Truncation & Optimize max_tokens Settings
2026-05-15 06:32:00 +08:00
Jason
ca1ef91bff Merge pull request #40 from Jasonzhu1207/refactor/ui-rebuild
Refactor/UI rebuild
2026-05-14 23:35:18 +08:00
Jason
482259953c feat: Disable AI Output Truncation & Optimize max_tokens Settings 2026-05-14 23:32:23 +08:00
xuncha
26eac85908 Merge pull request #963 from xunchahaha/dev
Dev
2026-05-14 23:06:42 +08:00
xuncha
9cd5947401 fix:缺一行 2026-05-14 22:46:21 +08:00
xuncha
e9e3844e3b Merge branch 'dev' into dev 2026-05-14 22:23:18 +08:00
xuncha
8129c1227b [Question]: 从v4.3.1升级到v4.4.5后,通过/api/v1/messages获取到的content是xml格式
Fixes #958
2026-05-14 22:16:12 +08:00
xuncha
aa4e3388fc [Bug]: 年度报告生成功能无法使用
Fixes #953
2026-05-14 22:11:32 +08:00
xuncha
33bffc10bc Merge pull request #962 from hicccc77/revert-961-dev
Revert "修复 #953"
2026-05-14 22:02:31 +08:00
xuncha
a98e4af9a8 Revert "修复 #953" 2026-05-14 22:01:56 +08:00
xuncha
eaa9dbea73 Merge pull request #961 from xunchahaha/dev
修复 #953
2026-05-14 21:46:10 +08:00
xuncha
046482fccd [Bug]: 年度报告生成功能无法使用
Fixes #953
2026-05-14 21:42:41 +08:00
cc
7e6ce2e0c5 Merge pull request #957 from HaoHaoLucas/bug-account-management
fix: 无可用账号时可进入账号管理界面;删除配置后持久化隐藏已删除账号
2026-05-14 18:41:34 +08:00
HaoHaoHaoLucas
e26c0fce91 1
Remove comment about storage quota or privacy mode.
2026-05-14 05:02:04 +08:00
HaoHaoLucas
abbab85f24 bug fixed 2026-05-14 04:48:41 +08:00
cc
d4f933b715 Merge pull request #947 from Jasonzhu1207/refactor/ui-rebuild
fix: Reply Setting
2026-05-13 20:22:28 +08:00
Jason
16608b2c8e fix: Reply Setting 2026-05-12 23:08:34 +08:00
cc
405a81bcbb Merge pull request #946 from Jasonzhu1207/refactor/ui-rebuild
fix: Reply Style
2026-05-11 23:30:39 +08:00
Jason
d5d64b2b50 fix: Reply Style 2026-05-11 22:57:25 +08:00
cc
cb72cc1b92 Merge pull request #945 from Jasonzhu1207/refactor/ui-rebuild
Refactor/UI rebuild
2026-05-11 22:26:19 +08:00
cc
51214ac994 Merge pull request #944 from ztdd88/fix/silk-wasm-external
修复 silk-wasm 被错误内联导致 macOS 语音转写崩溃 (#943)
2026-05-11 21:31:13 +08:00
ztdd88
7f4f3c2eb0 修复 silk-wasm 被错误内联导致 macOS 语音转写崩溃 (#943)
将 'silk-wasm' 加入主进程 vite external 列表。

silk-wasm 默认会被 rollup 内联进 main.js,CI 构建环境下其内部
import_meta_url 变量被错误优化为 {}.url(即 undefined),
触发 createRequire(undefined) 报错,SILK 解码失败,
语音转写功能完全不可用。

silk-wasm 本身已在 package.json 的 asarUnpack 中(line 184-185),
强制 external 后正常走 require() 从 unpacked 加载,与原意图对齐。

本地 macOS Apple Silicon 验证:
- 构建产物 main.js 已 0 内联(grep tencent silk = 0)
- require("silk-wasm") 动态保留
- 实测语音转写恢复正常
2026-05-11 18:18:41 +08:00
Jason
0dc5efb635 fix: Splash Page UI 2026-05-10 23:07:26 +08:00
Jason
fea00a6e36 fix: Splash Page 2026-05-10 22:50:19 +08:00
Jason
c1be9bcd52 Merge branch 'hicccc77:main' into refactor/ui-rebuild 2026-05-10 22:31:22 +08:00
Jason
af9acb4a36 Merge branch 'hicccc77:main' into main 2026-05-10 22:31:09 +08:00
Jason
b6b930ebb9 feat: enhance splash screen with dynamic theme support and improved styling 2026-05-10 22:29:39 +08:00
Jason
796515d3e8 fix: Updated Chat Page UI, & Fixed Address Book White Screen Issue & Optimized Launch Page UI 2026-05-10 21:50:46 +08:00
cc
39e527a21a 修复了全局wxid错误清洗的问题 2026-05-10 15:17:02 +08:00
cc
70aff53ef1 Update README.md 2026-05-10 12:05:31 +08:00
cc
2d5832d6a9 更新readme 2026-05-10 12:04:18 +08:00
cc
604000ae51 更新readme 2026-05-10 11:54:04 +08:00
Jason
762a2ec832 fix: Reply UI 2026-05-09 23:48:10 +08:00
cc
810a8e9761 #929 2026-05-08 19:33:37 +08:00
cc
b126f7a1db Merge pull request #933 from Jasonzhu1207/refactor/ui-rebuild
Refactor: UI Rebuild
2026-05-08 19:19:28 +08:00
Jason
e41a1197cb fix: polish splash and export settings 2026-05-08 17:33:57 +08:00
cc
3317362187 修复 #926 中错误返回0消息的问题 2026-05-07 23:10:31 +08:00
Jason
ae5d1d95ab refactor: polish UI for Export and Contacts pages 2026-05-07 23:00:24 +08:00
Jason
0bd5610cf0 refactor: modernize splash screen 2026-05-07 19:46:37 +08:00
Jason
45a4247563 ci: harden release workflow 2026-05-06 23:20:14 +08:00
Jason
ff15dc6e9f fix: polish chat page refactor 2026-05-06 22:36:11 +08:00
Jason
0f0f5abb2a refactor: modernize chat page 2026-05-06 21:45:55 +08:00
Jason
128055c4f4 Merge branch 'hicccc77:main' into refactor/ui-rebuild 2026-05-06 20:06:56 +08:00
Jason
f43005ae34 Merge branch 'hicccc77:main' into main 2026-05-06 20:06:42 +08:00
cc
a6d652eec9 同步资源文件 2026-05-06 00:06:50 +08:00
cc
abde85a900 #910 2026-05-06 00:05:11 +08:00
cc
3f908a4dd3 Merge pull request #809 from hicccc77/dependabot/npm_and_yarn/dev/react-virtuoso-4.18.5
chore(deps): bump react-virtuoso from 4.18.4 to 4.18.5
2026-05-05 22:43:32 +08:00
cc
961ae4dea8 Merge pull request #812 from hicccc77/dependabot/npm_and_yarn/dev/lucide-react-1.8.0
chore(deps): bump lucide-react from 1.7.0 to 1.8.0
2026-05-05 22:42:50 +08:00
cc
50a575bf58 Merge pull request #824 from hicccc77/dependabot/npm_and_yarn/npm_and_yarn-0a68a62365
Bump @xmldom/xmldom from 0.8.12 to 0.8.13 in the npm_and_yarn group across 1 directory
2026-05-05 22:42:37 +08:00
cc
df0e638301 Merge pull request #810 from hicccc77/dependabot/npm_and_yarn/dev/vite-8.0.9
chore(deps-dev): bump vite from 7.3.2 to 8.0.10
2026-05-05 22:42:25 +08:00
Jason
24ab0239df refactor: BackupPage & ContactsPage & ExportPage & InsightInboxPage & MyFootprintPage & ResourcesPage UI rebuild 2026-05-05 22:17:30 +08:00
Jason
5319153879 refactor: streamline sns page 2026-05-05 20:24:58 +08:00
Jason
4f13b609d4 refactor: simplify home page 2026-05-05 19:27:56 +08:00
Jason
ab7b27dd27 Merge pull request #39 from Jasonzhu1207/main
feat: add insight inbox
2026-05-05 16:47:31 +08:00
cc
a0eee30f7d Merge pull request #909 from Jasonzhu1207/main
feat: add insight inbox
2026-05-05 14:33:03 +08:00
Jason
416b62fdf1 feat: add insight inbox 2026-05-05 13:54:50 +08:00
Jason
65247a01d3 fix: AI_Insight Icon 2026-05-05 12:35:15 +08:00
Jason
b4758d690b feat: add AI insight notification toggle 2026-05-05 12:08:32 +08:00
cc
98377beebe 同步数据服务 2026-05-05 10:15:02 +08:00
cc
c09128b83e 支持一键已读 2026-05-04 23:34:49 +08:00
Jason
404b06ff16 Polish settings anti-revoke and model directory UI 2026-05-04 21:19:44 +08:00
Jason
6eb304ef94 fix(ui): apply missing flex wrapping and fix sidebar active indicator 2026-05-04 19:22:18 +08:00
cc
fd0db6e306 修复联系人页面导出异常 2026-05-04 18:33:02 +08:00
Jason
a7fa088470 fix(ui): address settings layout issues and tighten industrial design 2026-05-04 18:13:42 +08:00
Jason
b314fc55f9 refactor(ui): apply ChatGPT minimalist styling to SettingsPage 2026-05-04 16:25:33 +08:00
Jason
715718c3e5 style(sidebar): narrow width from 260px to 200px 2026-05-04 15:58:31 +08:00
Jason
72beca65bb fix(theme): restore accent color system, redesign sidebar and report pages 2026-05-04 13:29:07 +08:00
Jason
7dc7888869 refactor(ui): ChatGPT-style visual overhaul for app shell and analytics pages 2026-05-04 12:38:14 +08:00
cc
7233f4249d #894 2026-05-04 09:27:57 +08:00
cc
4271d29f2b Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-05-04 09:25:21 +08:00
cc
86f966d469 #899 2026-05-04 09:25:15 +08:00
cc
99a3ccd228 Merge pull request #896 from CosmicHz/dev
fix: 修复嵌套引用消息显示为[链接]的问题
2026-05-03 10:17:16 +08:00
badboyyyyHmm
a001f3327c fix: 修复嵌套引用消息显示为[链接]的问题(#895) 2026-05-02 23:50:44 +08:00
cc
2d14ba9078 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-05-02 19:08:12 +08:00
cc
1e3a496021 #887 #875 2026-05-02 19:08:07 +08:00
xuncha
4cb799ca7f Merge pull request #889 from xunchahaha:dev
[Bug]: 群聊导出中的邀请记录无法正常查看
2026-05-02 08:45:51 +08:00
xuncha
e61930107a [Bug]: 群聊导出中的邀请记录无法正常查看
Fixes #877
2026-05-02 07:59:54 +08:00
cc
becec65ee3 Merge pull request #885 from hicccc77/dev
Dev
2026-05-01 19:43:31 +08:00
cc
318b553d0e 修复 #884 2026-05-01 19:41:01 +08:00
cc
8946559d94 #883 2026-05-01 16:52:42 +08:00
cc
4ca0d23a2d Merge pull request #882 from hicccc77/dev
Dev
2026-05-01 14:46:42 +08:00
cc
4a57a503f5 #881 2026-05-01 14:46:14 +08:00
cc
d53ddb0ba7 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-30 00:00:04 +08:00
cc
1fc710ccef 修复底层配置服务混乱的问题 2026-04-29 23:59:56 +08:00
H3CoF6
82200e5fd7 Merge pull request #872 from H3CoF6/feat/image_hook
自动下载大图功能
2026-04-29 08:34:39 +08:00
H3CoF6
bdf285062f 优化下载会话选择页面 2026-04-29 08:25:45 +08:00
H3CoF6
b1807b21e7 feat: 选择会话的前端界面 2026-04-29 08:07:16 +08:00
H3CoF6
32feac7d5e chore: update dll for impl whitelist 2026-04-29 07:26:43 +08:00
H3CoF6
d2e59db123 fix: 修复AUR下载安装包并尝试提交的bug 2026-04-29 04:56:01 +08:00
H3CoF6
d27cef6358 优化前端显示和错误提醒 2026-04-29 04:38:25 +08:00
H3CoF6
1f0b2613bf feat(image): 新增自动下载大图选项(win32 x64)
Co-authored-by: NineBird <CavanasD@users.noreply.github.com>
2026-04-29 04:05:48 +08:00
H3CoF6
9c7ed1729a chore: add win32 dll + readme 2026-04-29 02:44:01 +08:00
cc
52f58f6288 Merge pull request #868 from Jasonzhu1207/feature/insight-moments-context
feat(insight): add moments context gating and prompt integration & streamline insight prompts & and optimize UI animations in the Insights section
2026-04-28 22:26:35 +08:00
Jason
dfe0186267 Add files via upload 2026-04-28 14:00:26 +08:00
Jason
fd9b7c4546 Merge pull request #38 from Jasonzhu1207/fix/insight-prompt-animation-polish
fix(insight): trim prompt noise and smooth settings animation
2026-04-28 13:40:23 +08:00
Jason
9f9ad337ab fix(insight): trim prompt noise and smooth settings animation 2026-04-28 13:35:11 +08:00
Jason
c596d24083 Merge branch 'hicccc77:main' into main 2026-04-28 13:00:15 +08:00
Jason
6cfc38c33a Merge pull request #37 from Jasonzhu1207/fix/insight-settings-ui-polish
fix(settings): polish insight context controls
2026-04-28 12:59:57 +08:00
Jason
13cede13f9 fix(settings): polish insight context controls 2026-04-28 12:55:46 +08:00
xuncha
440c1f166a Merge pull request #865 from hicccc77/dev
Dev
2026-04-28 12:50:05 +08:00
Jason
106d19fc6c Merge pull request #36 from Jasonzhu1207/fix/release-upload-assets
fix(release): upload assets after packaging
2026-04-28 12:12:51 +08:00
Jason
60a4011539 fix(release): upload assets after packaging 2026-04-28 12:05:33 +08:00
Jason
fd97920fb2 Merge pull request #35 from Jasonzhu1207/feature/insight-moments-context
feat(insight): add moments context gating and prompt integration
2026-04-28 00:17:41 +08:00
Jason
55a7ce7b66 feat(insight): add moments context gating and prompt integration 2026-04-28 00:14:05 +08:00
cc
7469337aeb fix: support service runtime fallbacks 2026-04-27 23:08:39 +08:00
cc
338d0e2f20 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-26 18:46:59 +08:00
cc
a86a51c30c #849 以及导出中媒体相关问题修复 2026-04-26 18:46:56 +08:00
cc
043332d297 Merge pull request #851 from hicccc77/main
Dev
2026-04-26 14:55:18 +08:00
cc
608f74a3f9 Merge pull request #850 from hicccc77/dev
Dev
2026-04-26 14:54:36 +08:00
cc
551d05fe2e Update MAC-KEY-FAQ with login instructions 2026-04-26 14:53:53 +08:00
cc
c9317f76a3 Merge pull request #846 from BeiChen-CN/codex/export-pause-cancel
fix(export): 添加朋友圈导出控制按钮
2026-04-26 14:48:48 +08:00
cc
ffd533d865 Merge pull request #848 from hicccc77/dev
更新封面图
2026-04-26 12:11:12 +08:00
cc
1976edc483 更新封面图 2026-04-26 12:09:56 +08:00
dependabot[bot]
606bc6ab66 Bump @xmldom/xmldom in the npm_and_yarn group across 1 directory
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.12 to 0.8.13
- [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.12...0.8.13)

---
updated-dependencies:
- dependency-name: "@xmldom/xmldom"
  dependency-version: 0.8.13
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-26 03:15:20 +00:00
cc
27690ee7fa Merge pull request #847 from hicccc77/dev
Dev
2026-04-26 11:13:55 +08:00
cc
81ade84a77 fix actions fixed prerelease sync 2026-04-26 11:13:04 +08:00
姜北尘
bb42a7c0b2 fix(export): 修复朋友圈导出控制按钮 2026-04-25 23:54:32 +08:00
cc
87d894b1f9 Merge pull request #845 from BeiChen-CN/codex/export-pause-cancel
feat(export): 添加导出暂停取消控制
2026-04-25 23:32:53 +08:00
姜北尘
1b75986987 feat(export): 添加导出暂停取消控制 2026-04-25 23:24:27 +08:00
cc
32aab8d490 fix: 迁移图片资源保留原始dat 2026-04-25 22:55:28 +08:00
cc
8e2a6ec933 优化防撤回会话列表 2026-04-25 19:22:35 +08:00
cc
fc3356ece2 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-25 18:38:15 +08:00
cc
cd1ecf0ef6 修复文案描述与添加hardlink备份支持 2026-04-25 18:38:12 +08:00
cc
9e6bf0f21a Merge pull request #842 from Jasonzhu1207/main
feat: add silent startup
2026-04-25 18:37:51 +08:00
Jason
9ea34d74c2 Merge pull request #34 from Jasonzhu1207/feat/silent-startup-tray
feat: add silent startup to tray background
2026-04-25 17:49:10 +08:00
Jason
42d4982728 feat(settings): add silent startup to tray 2026-04-25 17:42:13 +08:00
cc
f07e23b144 完善数据迁移 2026-04-25 17:41:14 +08:00
cc
6cf67828a2 修复Linux密钥问题 2026-04-25 15:50:45 +08:00
dependabot[bot]
5d64efdddf chore(deps-dev): bump vite from 7.3.2 to 8.0.10
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.3.2 to 8.0.10.
- [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/v8.0.10/packages/vite)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-25 07:24:50 +00:00
cc
625e7ac8f1 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-25 15:23:18 +08:00
cc
a0b976e5d2 修复包错误的问题 2026-04-25 15:23:13 +08:00
xuncha
c3fd291d7a Merge pull request #840 from xunchahaha/dev
Dev
2026-04-25 14:58:49 +08:00
xuncha
f63743cc87 Merge branch 'hicccc77:dev' into dev 2026-04-25 14:58:32 +08:00
xuncha
bda1c0b6d7 e 2026-04-25 14:57:49 +08:00
xuncha
69f834ca42 [Bug]: HTTP接口返回的message里的serverId与导出方式中的platformMessageId值不一样,且两者就末尾几位不一样(因为一个返回的是string,一个是number)?是精度问题导致的?
Fixes #838
2026-04-25 14:57:32 +08:00
cc
6cd01b0209 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-04-25 14:55:33 +08:00
cc
5129574729 数据备份测试 2026-04-25 14:55:31 +08:00
xuncha
2cbdb04157 Merge pull request #839 from xunchahaha/dev
Dev
2026-04-25 14:37:05 +08:00
xuncha
2c01951791 chatlab会话新增消息类型 2026-04-25 14:17:32 +08:00
xuncha
7bb5b4f834 修复chatlab推送携带wxid 2026-04-25 14:11:41 +08:00
cc
c167be53b3 修复action报错 2026-04-25 09:38:06 +08:00
H3CoF6
a7ea22b1ae Merge pull request #837 from H3CoF6/dev
修Aur
2026-04-25 02:10:44 +08:00
H3CoF6
b74fda1f66 fix(CI): 删除上传Aur源的icon文件 2026-04-25 02:07:53 +08:00
xuncha
2acbe0fb08 Merge pull request #836 from hicccc77/dev
Dev
2026-04-25 00:59:14 +08:00
xuncha
17c13c2455 Merge pull request #835 from xunchahaha/dev
Dev
2026-04-25 00:58:34 +08:00
xuncha
032aad6539 新增撤回消息推送 2026-04-25 00:57:24 +08:00
xuncha
d3c738f9f1 Merge pull request #834 from xunchahaha/dev
Dev
2026-04-24 23:04:20 +08:00
xuncha
d1741c931f 修复sse推送丢消息 给推送新增了id [Bug]:SSE消息推送 丢消息
Fixes #832
2026-04-24 22:42:19 +08:00
xuncha
b75de26178 修复了导出页额外的滑块 2026-04-24 22:31:48 +08:00
cc
255b857e67 #820 #831 2026-04-24 18:21:09 +08:00
cc
c923327112 修复 #820;支持企业用户会话显示;优化聊天页面性能 2026-04-23 23:41:20 +08:00
cc
c25b231f9c Merge pull request #823 from Jasonzhu1207/main
fix(perf): prevent memory growth in chat and export flows
2026-04-23 18:41:04 +08:00
Jason
fbc2c8d900 Merge pull request #33 from Jasonzhu1207/fix/perf-memory-growth
fix(perf): prevent memory growth in chat and export flows
2026-04-22 23:51:18 +08:00
Jason
6304c9ed51 fix(perf): prevent memory growth in chat and export flows 2026-04-22 23:30:28 +08:00
cc
777f5b82db 优化底层游标索引性能;优化HTTPAPI索引逻辑;优化导出图片的索引写入逻辑 2026-04-22 23:02:17 +08:00
cc
5802cf36c6 年报S8临时修改 2026-04-21 23:44:20 +08:00
xuncha
e3174370bb Merge pull request #817 from xunchahaha:dev
修复双人年度报告[Bug]: 双人年度报告坏了
2026-04-21 20:21:01 +08:00
xuncha
0f8a9602bd 修复双人年度报告[Bug]: 双人年度报告坏了
Fixes #816
2026-04-21 20:20:11 +08:00
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
dependabot[bot]
dfec3dba41 chore(deps): bump react-virtuoso from 4.18.4 to 4.18.5
Bumps [react-virtuoso](https://github.com/petyosi/react-virtuoso/tree/HEAD/packages/react-virtuoso) from 4.18.4 to 4.18.5.
- [Release notes](https://github.com/petyosi/react-virtuoso/releases)
- [Changelog](https://github.com/petyosi/react-virtuoso/blob/master/packages/react-virtuoso/CHANGELOG.md)
- [Commits](https://github.com/petyosi/react-virtuoso/commits/react-virtuoso@4.18.5/packages/react-virtuoso)

---
updated-dependencies:
- dependency-name: react-virtuoso
  dependency-version: 4.18.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-21 00:15:45 +00:00
cc
30d54fcdb1 Merge pull request #807 from hicccc77/dev
Dev
2026-04-20 23:26:07 +08:00
cc
33fde44cc3 Merge pull request #804 from hellodigua/feat-chatlab
feat: API服务支持ChatLab新版协议
2026-04-20 23:23:39 +08:00
cc
eca1411c68 Merge pull request #806 from hicccc77/dev
Dev
2026-04-20 23:23:22 +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
Jason
d96000f0d9 Merge branch 'hicccc77:main' into main 2026-04-07 23:05:17 +08:00
151 changed files with 37762 additions and 12884 deletions

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

@@ -0,0 +1,237 @@
#!/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
local release_api_url
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
release_id="$(gh release view "$tag" --repo "$repo" --json databaseId --jq '.databaseId // empty' 2>/dev/null || true)"
if [[ "$release_id" =~ ^[0-9]+$ ]]; then
echo "$release_id"
return 0
fi
release_api_url="$(gh release view "$tag" --repo "$repo" --json apiUrl --jq '.apiUrl // empty' 2>/dev/null || true)"
if [[ "$release_api_url" =~ /releases/([0-9]+)$ ]]; then
echo "${BASH_REMATCH[1]}"
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 release view "$tag" --repo "$repo" --json databaseId,id,isDraft,isPrerelease,url 2>/dev/null || true
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 release edit "$tag" --repo "$repo" --draft=false --prerelease >/dev/null 2>&1 || true
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 || gh release view "$tag" --repo "$repo" --json isDraft --jq '.isDraft' 2>/dev/null || echo true)"
prerelease_state="$(gh api "$endpoint" --jq '.prerelease' 2>/dev/null || gh release view "$tag" --repo "$repo" --json isPrerelease --jq '.isPrerelease' 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 release view "$tag" --repo "$repo" --json isDraft,isPrerelease,url 2>/dev/null || true
gh api "$endpoint" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}' 2>/dev/null || true
return 1
}
print_release_state() {
local repo="$1"
local tag="$2"
gh api "repos/$repo/releases/tags/$tag" --jq '{isDraft: .draft, isPrerelease: .prerelease, url: .html_url}' 2>/dev/null \
|| gh release view "$tag" --repo "$repo" --json isDraft,isPrerelease,url --jq '{isDraft: .isDraft, isPrerelease: .isPrerelease, url: .url}'
}
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
}

View File

@@ -55,12 +55,8 @@ jobs:
shell: bash
run: |
set -euo pipefail
if gh release view "$FIXED_DEV_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh release delete "$FIXED_DEV_TAG" --repo "$GITHUB_REPOSITORY" --yes --cleanup-tag
fi
gh release create "$FIXED_DEV_TAG" --repo "$GITHUB_REPOSITORY" --title "Daily Dev Build" --notes "开发版发布页" --prerelease --target "$TARGET_BRANCH"
RELEASE_REST_ID="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$FIXED_DEV_TAG" --jq '.id')"
gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$RELEASE_REST_ID" -f draft=false -f prerelease=true >/dev/null
source .github/scripts/release-utils.sh
recreate_fixed_prerelease "$GITHUB_REPOSITORY" "$FIXED_DEV_TAG" "$TARGET_BRANCH" "Daily Dev Build" "开发版发布页"
dev-mac-arm64:
needs: prepare
@@ -77,10 +73,25 @@ jobs:
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
@@ -94,15 +105,21 @@ jobs:
- 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"
npx electron-builder --mac dmg --arm64 --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-arm64.${ext}'
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")
@@ -111,7 +128,7 @@ jobs:
echo "No release files found in ./release"
exit 1
fi
gh release upload "$FIXED_DEV_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_DEV_TAG" "${assets[@]}"
dev-linux:
needs: prepare
@@ -128,7 +145,6 @@ jobs:
with:
node-version: 24
cache: "npm"
- name: Install Dependencies
run: npm install
@@ -151,6 +167,8 @@ jobs:
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")
@@ -159,7 +177,7 @@ jobs:
echo "No release files found in ./release"
exit 1
fi
gh release upload "$FIXED_DEV_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_DEV_TAG" "${assets[@]}"
dev-win-x64:
needs: prepare
@@ -176,7 +194,6 @@ jobs:
with:
node-version: 24
cache: "npm"
- name: Install Dependencies
run: npm install
@@ -199,6 +216,8 @@ jobs:
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")
@@ -207,7 +226,7 @@ jobs:
echo "No release files found in ./release"
exit 1
fi
gh release upload "$FIXED_DEV_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_DEV_TAG" "${assets[@]}"
dev-win-arm64:
needs: prepare
@@ -224,7 +243,6 @@ jobs:
with:
node-version: 24
cache: "npm"
- name: Install Dependencies
run: npm install
@@ -247,6 +265,8 @@ jobs:
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")
@@ -255,7 +275,7 @@ jobs:
echo "No release files found in ./release"
exit 1
fi
gh release upload "$FIXED_DEV_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_DEV_TAG" "${assets[@]}"
update-dev-release-notes:
needs:
@@ -267,24 +287,34 @@ jobs:
if: always() && needs.prepare.result == 'success'
runs-on: ubuntu-latest
steps:
- name: Check out git repository
uses: actions/checkout@v5
with:
ref: ${{ env.TARGET_BRANCH }}
fetch-depth: 1
- name: Update fixed dev release notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
FIXED_DEV_TAG: ${{ env.FIXED_DEV_TAG }}
shell: bash
run: |
set -euo pipefail
TAG="$FIXED_DEV_TAG"
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 release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then
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 release view "$TAG" --repo "$REPO" --json assets)"
ASSETS_JSON="$(gh api "repos/$REPO/releases/tags/$TAG")"
pick_asset() {
local pattern="$1"
@@ -294,6 +324,9 @@ jobs:
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$")"
@@ -350,4 +383,7 @@ jobs:
}
update_release_notes
gh release view "$TAG" --repo "$REPO" --json isDraft,isPrerelease,url
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
print_release_state "$REPO" "$TAG"

View File

@@ -81,12 +81,8 @@ jobs:
shell: bash
run: |
set -euo pipefail
if gh release view "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh release delete "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" --yes --cleanup-tag
fi
gh release create "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" --title "Preview Nightly Build" --notes "预览版发布页" --prerelease --target "$TARGET_BRANCH"
RELEASE_REST_ID="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$FIXED_PREVIEW_TAG" --jq '.id')"
gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$RELEASE_REST_ID" -f draft=false -f prerelease=true >/dev/null
source .github/scripts/release-utils.sh
recreate_fixed_prerelease "$GITHUB_REPOSITORY" "$FIXED_PREVIEW_TAG" "$TARGET_BRANCH" "Preview Nightly Build" "预览版发布页"
preview-mac-arm64:
needs: prepare
@@ -104,10 +100,25 @@ jobs:
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
@@ -123,15 +134,21 @@ jobs:
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"
npx electron-builder --mac dmg --arm64 --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-arm64.${ext}'
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")
@@ -140,7 +157,7 @@ jobs:
echo "No release files found in ./release"
exit 1
fi
gh release upload "$FIXED_PREVIEW_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_PREVIEW_TAG" "${assets[@]}"
preview-linux:
needs: prepare
@@ -158,7 +175,6 @@ jobs:
with:
node-version: 24
cache: "npm"
- name: Install Dependencies
run: npm install
@@ -184,6 +200,8 @@ jobs:
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")
@@ -192,7 +210,7 @@ jobs:
echo "No release files found in ./release"
exit 1
fi
gh release upload "$FIXED_PREVIEW_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_PREVIEW_TAG" "${assets[@]}"
preview-win-x64:
needs: prepare
@@ -210,7 +228,6 @@ jobs:
with:
node-version: 24
cache: "npm"
- name: Install Dependencies
run: npm install
@@ -236,6 +253,8 @@ jobs:
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")
@@ -244,7 +263,7 @@ jobs:
echo "No release files found in ./release"
exit 1
fi
gh release upload "$FIXED_PREVIEW_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_PREVIEW_TAG" "${assets[@]}"
preview-win-arm64:
needs: prepare
@@ -262,7 +281,6 @@ jobs:
with:
node-version: 24
cache: "npm"
- name: Install Dependencies
run: npm install
@@ -288,6 +306,8 @@ jobs:
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")
@@ -296,7 +316,7 @@ jobs:
echo "No release files found in ./release"
exit 1
fi
gh release upload "$FIXED_PREVIEW_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_PREVIEW_TAG" "${assets[@]}"
update-preview-release-notes:
needs:
@@ -308,6 +328,12 @@ jobs:
if: needs.prepare.outputs.should_build == 'true' && always()
runs-on: ubuntu-latest
steps:
- name: Check out git repository
uses: actions/checkout@v5
with:
ref: ${{ env.TARGET_BRANCH }}
fetch-depth: 1
- name: Update preview release notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -315,17 +341,22 @@ jobs:
run: |
set -euo pipefail
TAG="$FIXED_PREVIEW_TAG"
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 release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then
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 release view "$TAG" --repo "$REPO" --json assets)"
ASSETS_JSON="$(gh api "repos/$REPO/releases/tags/$TAG")"
pick_asset() {
local pattern="$1"
@@ -338,6 +369,9 @@ jobs:
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$")"
@@ -392,4 +426,7 @@ jobs:
}
update_release_notes
gh release view "$TAG" --repo "$REPO" --json isDraft,isPrerelease,url
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
print_release_state "$REPO" "$TAG"

View File

@@ -27,16 +27,31 @@ jobs:
with:
node-version: 24
cache: "npm"
- name: Install Dependencies
run: npm install
run: npm install --ignore-scripts
- 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: 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
@@ -44,30 +59,40 @@ jobs:
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: |
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"
npx electron-builder --mac dmg --arm64 --publish always
if ! npx electron-builder --mac dmg zip --arm64 --publish always '--config.npmRebuild=false' '--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.npmRebuild=false' '--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
gh release download "$TAG" --repo "$REPO" --pattern "$YML_FILE" --output "/tmp/$YML_FILE" 2>/dev/null || continue
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
gh release upload "$TAG" --repo "$REPO" "/tmp/$YML_FILE" --clobber
retry_cmd 5 3 gh release upload "$TAG" --repo "$REPO" "/tmp/$YML_FILE" --clobber
done
release-linux:
@@ -84,16 +109,20 @@ 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
@@ -105,20 +134,23 @@ 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"
gh release download "$TAG" --repo "$REPO" --pattern "latest-linux.yml" --output "/tmp/latest-linux.yml" 2>/dev/null
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
gh release upload "$TAG" --repo "$REPO" /tmp/latest-linux.yml --clobber
retry_cmd 5 3 gh release upload "$TAG" --repo "$REPO" /tmp/latest-linux.yml --clobber
fi
release:
@@ -135,7 +167,6 @@ jobs:
with:
node-version: 24
cache: 'npm'
- name: Install Dependencies
run: npm install
@@ -144,7 +175,7 @@ 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
@@ -156,20 +187,23 @@ 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"
gh release download "$TAG" --repo "$REPO" --pattern "latest.yml" --output "/tmp/latest.yml" 2>/dev/null
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
gh release upload "$TAG" --repo "$REPO" /tmp/latest.yml --clobber
retry_cmd 5 3 gh release upload "$TAG" --repo "$REPO" /tmp/latest.yml --clobber
fi
release-windows-arm64:
@@ -186,7 +220,6 @@ jobs:
with:
node-version: 24
cache: 'npm'
- name: Install Dependencies
run: npm install
@@ -195,7 +228,7 @@ 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
@@ -207,20 +240,23 @@ 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"
gh release download "$TAG" --repo "$REPO" --pattern "latest-arm64.yml" --output "/tmp/latest-arm64.yml" 2>/dev/null
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
gh release upload "$TAG" --repo "$REPO" /tmp/latest-arm64.yml --clobber
retry_cmd 5 3 gh release upload "$TAG" --repo "$REPO" /tmp/latest-arm64.yml --clobber
fi
update-release-notes:
@@ -232,18 +268,25 @@ jobs:
- release-windows-arm64
steps:
- name: Check out git repository
uses: actions/checkout@v5
with:
fetch-depth: 1
- name: Generate release notes with platform download links
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"
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"
@@ -256,6 +299,9 @@ jobs:
fi
WINDOWS_ARM64_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("arm64.*\\.exe$"))][0] // ""')"
MAC_ASSET="$(pick_asset "\\.dmg$")"
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$")"
@@ -294,4 +340,52 @@ jobs:
> 如果某个平台链接暂时未生成,可进入[完整发布页]($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: Check AUR credentials
id: aur-credentials
shell: bash
env:
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
run: |
if [ -z "${AUR_SSH_PRIVATE_KEY}" ]; then
echo "::notice::AUR_SSH_PRIVATE_KEY is not configured; skipping AUR publish."
echo "enabled=false" >> "$GITHUB_OUTPUT"
else
echo "enabled=true" >> "$GITHUB_OUTPUT"
fi
- name: Checkout code
if: steps.aur-credentials.outputs.enabled == 'true'
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Update PKGBUILD version
if: steps.aur-credentials.outputs.enabled == 'true'
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
if: steps.aur-credentials.outputs.enabled == 'true'
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
resources/installer/linux/.gitignore
ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
commit_username: H3CoF6
commit_email: h3cof6@gmail.com
ssh_keyscan_types: ed25519

View File

@@ -1,96 +0,0 @@
name: Security Scan
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
on:
schedule:
- cron: '0 2 * * *' # 每天 UTC 02:00
workflow_dispatch: # 手动触发
pull_request: # PR 时触发
branches: [ main, dev ]
permissions:
contents: read
security-events: write
actions: read
jobs:
security-scan:
name: Security Scan (${{ matrix.branch }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
branch:
- main
steps:
- name: Checkout ${{ matrix.branch }}
uses: actions/checkout@v5
with:
ref: ${{ matrix.branch }}
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: '24'
cache: 'npm' # 使用 npm 缓存加速
- name: Install dependencies
run: npm ci --ignore-scripts
# 1. npm audit - 检查依赖漏洞
- name: Dependency vulnerability audit
run: npm audit --audit-level=moderate
continue-on-error: true
# 2. CodeQL 静态分析
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: javascript, typescript
queries: security-and-quality
- name: Autobuild
uses: github/codeql-action/autobuild@v3
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: '/language:javascript-typescript/branch:${{ matrix.branch }}'
# 3. 密钥/敏感信息扫描
- name: Secret scanning with Gitleaks
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
continue-on-error: true
# 动态获取所有分支并扫描
scan-all-branches:
name: Scan additional branches
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: '24'
cache: 'npm'
- name: Run npm audit on all branches
run: |
git branch -r | grep -v HEAD | sed 's|origin/||' | tr -d ' ' | while read branch; do
echo "===== Auditing branch: $branch ====="
git checkout "$branch" 2>/dev/null || continue
# 尝试安装并审计
npm ci --ignore-scripts --silent 2>/dev/null || npm install --ignore-scripts --silent 2>/dev/null || true
npm audit --audit-level=moderate 2>/dev/null || true
done
continue-on-error: true

4
.gitignore vendored
View File

@@ -75,4 +75,6 @@ pnpm-lock.yaml
wechat-research-site
.codex
weflow-web-offical
Insight
/Wedecrypt
/scripts/syncwcdb.py
/scripts/syncWedecrypt.py

144
README.md
View File

@@ -1,39 +1,34 @@
# WeFlow
WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析与导出工具。它可以实时获取你的微信聊天记录并将其导出,还可以根据你的聊天记录为你生成独一无二的分析报告
WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析与导出工具。它可以实时获取你的微信聊天记录并将其导出,还可以根据你的聊天记录为你生成独一无二的分析报告
---
<p align="center">
<img src="app.png" alt="WeFlow" width="90%">
</p>
---
**WeFlow** is a fully local tool for viewing, analyzing, and exporting WeChat chat history in real time. It generates unique analysis reports based on your chat history.
<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">
</a>
<a href="https://github.com/hicccc77/WeFlow/releases">
<img src="https://img.shields.io/github/downloads/hicccc77/WeFlow/total?style=flat-square" 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>
<img src="app.jpg" 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&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>
<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/)
>
> If you want to analyze your exported chat content in depth, try [ChatLab](https://chatlab.fun/)
> [!NOTE]
> 仅支持微信 **4.0 及以上**版本,确保你的微信版本符合要求
>
> Only supports WeChat **version 4.0 and above**. Please ensure your WeChat version meets the requirements.
## 主要功能
@@ -45,8 +40,19 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
- HTTP API 接口(供开发者集成)
- 查看完整能力清单:[详细功能](#详细功能清单)
## 支持平台与设备
---
**Key Features**
- View chat history locally in real-time
- Preview and decrypt Moments photos, videos, and **Live Photos**
- Statistical analysis and group chat insights
- Annual reports and visual overviews
- Export chat history to HTML and other formats
- HTTP API (for developer integration)
- View complete feature list: [Detailed Features](#详细功能清单)
## 支持平台与设备
| 平台 | 设备/架构 | 安装包 |
|------|----------|--------|
@@ -54,6 +60,15 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
| macOS | Apple SiliconM 系列arm64 | `.dmg` |
| Linux | x64 设备amd64 | `.AppImage``.tar.gz` |
---
**Supported Platforms & Devices**
| Platform | Device/Architecture | Package |
|----------|---------------------|---------|
| Windows | Windows 10+, x64 (amd64) | `.exe` |
| macOS | Apple Silicon (M series, arm64) | `.dmg` |
| Linux | x64 devices (amd64) | `.AppImage`, `.tar.gz` |
## 快速开始
@@ -61,6 +76,14 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
> ArchLinux 用户可以选择 `yay -S weflow` 快速安装
---
**Quick Start**
If you just want to use the pre-compiled application, go to [Releases](https://github.com/hicccc77/WeFlow/releases) to download and install.
> ArchLinux users can quickly install with `yay -S weflow`
## 详细功能清单
当前版本已支持以下能力:
@@ -79,6 +102,26 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
| **联系人** | 导出微信好友、群聊、公众号信息;尝试找回曾经的好友(功能尚不完善) |
| **HTTP API 映射** | 将本地消息能力映射为 HTTP API便于对接外部系统、自动化脚本与二次开发 |
---
**Detailed Feature List**
The current version supports the following capabilities:
| Feature Module | Description |
|----------------|-------------|
| **Chat** | Decrypt images, videos, and Live Photos in chats (only supports Live Photos captured with Google protocol); supports **modifying** and deleting **local** messages; real-time refresh of latest messages without generating decrypted intermediate databases |
| **Anti-Recall** | Prevent messages sent by others from being recalled |
| **Real-time Notifications** | Desktop popup notifications when new messages arrive, convenient for timely viewing of important conversations, with blacklist/whitelist functionality |
| **Private Chat Analysis** | Statistics on message counts between friends; analysis of message types and sending ratios; view message time distribution, etc. |
| **Group Chat Analysis** | View detailed group member information; analyze group activity rankings, active periods, and media content |
| **Annual Report** | Generate annual reports by year, or long-term historical reports across years |
| **Duo Report** | Select a specific friend and generate an exclusive analysis report based on your mutual chat history |
| **Message Export** | Export WeChat chat history to multiple formats: JSON, HTML, TXT, Excel, CSV, PGSQL, ChatLab proprietary format, etc. |
| **Moments** | Decrypt Moments photos, videos, and Live Photos; export Moments content; intercept deletion and hiding operations in Moments; bypass time-based access restrictions |
| **Contacts** | Export WeChat friends, group chats, and official account information; attempt to recover deleted friends (work in progress) |
| **HTTP API** | Map local message capabilities to HTTP API for easy integration with external systems, automation scripts, and secondary development |
## HTTP API
> [!WARNING]
@@ -93,6 +136,19 @@ WeFlow 提供本地 HTTP API 服务,支持通过接口查询消息数据,可
完整接口文档:[点击查看](docs/HTTP-API.md)
---
> [!WARNING]
> This feature is currently in its early stages, and the interface may change. Stay tuned for future updates.
WeFlow provides a local HTTP API service that supports querying message data through interfaces, which can be used for integration with other tools or secondary development.
- **Enable Method**: Settings → API Service → Start Service
- **Default Port**: 5031
- **Access Address**: `http://127.0.0.1:5031`
- **Supported Formats**: Raw JSON or [ChatLab](https://chatlab.fun/) standard format
Complete API documentation: [Click to view](docs/HTTP-API.md)
## 面向开发者
@@ -108,7 +164,24 @@ npm install
# 3. 运行应用(开发模式)
npm run dev
```
---
**For Developers**
If you want to build from source or contribute code to the project, please follow these steps:
```bash
# 1. Clone the project locally
git clone https://github.com/hicccc77/WeFlow.git
cd WeFlow
# 2. Install project dependencies
npm install
# 3. Run the application (development mode)
npm run dev
```
## 致谢
@@ -116,22 +189,35 @@ npm run dev
- [密语 CipherTalk](https://github.com/ILoveBingLu/miyu) 为本项目提供了基础框架
- [WeChat-Channels-Video-File-Decryption](https://github.com/Evil0ctal/WeChat-Channels-Video-File-Decryption) 提供了视频解密相关的技术参考
---
**Acknowledgments**
- [CipherTalk](https://github.com/ILoveBingLu/miyu) provided the basic framework for this project
- [WeChat-Channels-Video-File-Decryption](https://github.com/Evil0ctal/WeChat-Channels-Video-File-Decryption) provided technical references for video decryption
## 支持我们
如果 WeFlow 确实帮到了你,可以考虑请我们喝杯咖啡:
> TRC20 **Address:** `TZCtAw8CaeARWZBfvjidCnTcfnAtf6nvS6`
> TRC20 **Address:** `TZCtAw8CaeARWZBfvjidCnTcfnAtf6nvS6`
---
**Support Us**
If WeFlow has truly helped you, consider buying us a coffee:
> 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">
@@ -140,4 +226,6 @@ npm run dev
**请负责任地使用本工具,遵守相关法律法规**
**Please use this tool responsibly and comply with relevant laws and regulations**
</div>

BIN
app.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 599 KiB

BIN
app.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

View File

@@ -27,8 +27,8 @@ WeFlow 提供本地 HTTP API已支持GET 和 POST请求便于外部脚
- `GET|POST /api/v1/health`
- `GET|POST /api/v1/push/messages`
- `GET|POST /api/v1/messages`
- `GET|POST /api/v1/messages/new`
- `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/*`
@@ -74,18 +74,19 @@ GET /api/v1/push/messages
- 需要先在设置页开启 `HTTP API 服务`
- 同时需要开启 `主动推送`
- 响应类型为 `text/event-stream`
- 新消息事件名固定为 `message.new`
- 建议接收端按 `messageKey` 去重
- 事件名包含 `message.new``message.revoke`
- 建议接收端按 `event + rawid` 去重
### 事件字段
- `event`
- `sessionId`
- `messageKey`
- `rawid`
- `avatarUrl`
- `sourceName`
- `groupName`(仅群聊)
- `content`
- `timestamp`(消息时间,秒级 Unix 时间戳)
### 示例
@@ -97,7 +98,14 @@ 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","sessionType":"group","rawid":"1234567890123456789","avatarUrl":"https://example.com/group.jpg","sourceName":"李四","groupName":"项目群","content":"[图片]","timestamp":1760000123}
```
撤回事件示例:
```text
event: message.revoke
data: {"event":"message.revoke","sessionId":"wxid_xxx","sessionType":"other","rawid":"1234567890123456789","avatarUrl":"https://example.com/avatar.jpg","sourceName":"张三","content":"对方撤回了一条消息rawid1234567890123456789 内容为“你好”","timestamp":1760000180}
```
---
@@ -116,21 +124,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` 时控制表情导出 |
### 示例
@@ -165,6 +173,8 @@ curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&media=1&image=1&
- `content`
- `rawContent`
- `parsedContent`
- `replyToMessageId`(引用回复目标消息的 `serverId`,仅引用消息返回)
- `quote`(引用消息快照,包含被引用消息的 ID、发送者、内容和类型
- `mediaType`
- `mediaFileName`
- `mediaUrl`
@@ -176,7 +186,7 @@ curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&media=1&image=1&
{
"success": true,
"talker": "xxx@chatroom",
"count": 2,
"count": 3,
"hasMore": true,
"media": {
"enabled": true,
@@ -186,7 +196,7 @@ curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&media=1&image=1&
"messages": [
{
"localId": 123,
"serverId": "456",
"serverId": "6116895530414915131",
"localType": 1,
"createTime": 1738713600,
"isSend": 0,
@@ -195,6 +205,25 @@ curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&media=1&image=1&
"rawContent": "你好",
"parsedContent": "你好"
},
{
"localId": 125,
"serverId": "6116895530414915133",
"localType": 244813135921,
"createTime": 1738713700,
"isSend": 0,
"senderUsername": "wxid_member",
"content": "收到",
"rawContent": "<msg>...</msg>",
"parsedContent": "收到",
"replyToMessageId": "6116895530414915131",
"quote": {
"platformMessageId": "6116895530414915131",
"sender": "wxid_other",
"accountName": "张三",
"content": "你好",
"type": 0
}
},
{
"localId": 124,
"localType": 3,
@@ -235,6 +264,7 @@ curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&media=1&image=1&
- `messages[].type`
- `messages[].content`
- `messages[].platformMessageId`
- `messages[].replyToMessageId`
- `messages[].mediaPath`
群聊里 `groupNickname` 会优先来自群成员群昵称;若源数据缺失,则回退为空或展示名。
@@ -253,10 +283,10 @@ GET /api/v1/sessions
### 参数
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `keyword` | string | 否 | 匹配 `username``displayName` |
| `limit` | number | 否 | 默认 `100` |
| 参数 | 类型 | 必填 | 说明 |
| --------- | ------ | ---- | -------------------------------- |
| `keyword` | string | 否 | 匹配 `username``displayName` |
| `limit` | number | 否 | 默认 `100` |
### 响应字段
@@ -288,6 +318,130 @@ 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
@@ -300,10 +454,10 @@ GET /api/v1/contacts
### 参数
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `keyword` | string | 否 | 匹配 `username``nickname``remark``displayName` |
| `limit` | number | 否 | 默认 `100` |
| 参数 | 类型 | 必填 | 说明 |
| --------- | ------ | ---- | ---------------------------------------------------- |
| `keyword` | string | 否 | 匹配 `username``nickname``remark``displayName` |
| `limit` | number | 否 | 默认 `100` |
### 响应字段
@@ -353,12 +507,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` 时跳过内存缓存强制刷新 |
### 响应字段
@@ -443,17 +597,17 @@ 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` |
| 参数 | 类型 | 必填 | 说明 |
| ----------- | ------ | ---- | ------------------------------------------------------------ |
| `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` |
示例:
@@ -490,9 +644,9 @@ GET /api/v1/sns/export/stats
参数:
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `fast` | number | 否 | `1` 使用快速统计(优先缓存) |
| 参数 | 类型 | 必填 | 说明 |
| ------ | ------ | ---- | ---------------------------- |
| `fast` | number | 否 | `1` 使用快速统计(优先缓存) |
### 7.4 朋友圈媒体代理
@@ -502,10 +656,10 @@ GET /api/v1/sns/media/proxy
参数:
| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `url` | string | 是 | 媒体原始 URL |
| `key` | string/number | 否 | 解密 key部分资源需要 |
| 参数 | 类型 | 必填 | 说明 |
| ----- | ------------- | ---- | ------------------------ |
| `url` | string | 是 | 媒体原始 URL |
| `key` | string/number | 否 | 解密 key部分资源需要 |
### 7.5 导出朋友圈
@@ -572,15 +726,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` |
常见错误响应:
@@ -626,8 +780,8 @@ headers = {"Authorization": "Bearer YOUR_TOKEN", "Content-Type": "application/js
# POST 方式获取消息
messages = requests.post(
f"{BASE_URL}/api/v1/messages",
json={"talker": "xxx@chatroom", "limit": 50},
f"{BASE_URL}/api/v1/messages",
json={"talker": "xxx@chatroom", "limit": 50},
headers=headers
).json()

54
docs/MAC-KEY-FAQ.md Normal file
View File

@@ -0,0 +1,54 @@
# macOS 微信密钥自动获取失败排障指南
如果你在 macOS 系统下,遇到了 WeFlow 自动获取微信数据库密钥失败的问题,这篇指南或许可以帮到你。
### 请立刻停止连续重试
当你看到下面这些报错时,请务必暂停操作,不要再去反复点击获取:
- SCAN_FAILED通常伴随 No suitable module found 或 Sink pattern not found
- HOOK_FAILED 或 Native Hook Failed
- patch_breakpoint_failed
- thread_get_state_failed
现在的 macOS 系统和微信防护机制非常敏锐。连续的重试动作不仅无法解决问题,反而容易被判定为异常行为,进而触发微信的安全模式或系统级的内存保护。
### 可能的尝试流程
根据大量社区用户的反馈,如果你已经遇到了获取失败的情况,按照下面的步骤顺序操作,通常都能顺利解决问题:
1. **降级微信版本**。找一个经过大家验证、兼容性更好的老版本,目前最推荐先退回到 4.1.7.57 或者 4.1.8.100。
2. **彻底退出微信**。请使用快捷键 Command + Q 或在活动监视器中结束进程,而不仅仅是关闭窗口。
3. **重启你的 Mac**。这一步极其关键,必须是真正的重新启动。注销或睡眠唤醒无法清除系统底层的拦截状态。
4. **重新打开微信**。随便点击几下保持它在最前台,并且确保它是未登录的状态。
5. **回到 WeFlow**。仅仅尝试一次“自动获取密钥”。
6. **输入密码并登录**。先在弹窗中输入你的系统密码后,确认页面弹出允许登录了再登录微信
7. **恢复日常使用**。只要成功拿到了密钥,你就可以放心地把微信更新回你平时爱用的最新版本。
### 常见报错与应对方法
为了方便排查,这里列出了几类最常见的报错及其背后的原因和对策:
**SCAN_FAILED: No suitable module found**
这意味着微信的内存布局并不标准,或者目标模块没有被命中。你可以先确保微信完整启动并保持在前台。如果还是不行,请直接执行上面提到的“降级、重启电脑、获取、再升级”的完整流程。
**SCAN_FAILED: Sink pattern not found**
这说明 WeFlow 还没有适配你当前正在使用的微信版本特征。最快的解决办法是直接降级到微信 4.1.7 或 4.1.8.100 版本再试。
**patch_breakpoint_failed 或 thread_get_state_failed**
这类错误大多是因为调试断点注入或线程状态读取被 macOS 系统的安全机制拦截了。此时继续尝试毫无意义,彻底退出微信并重启电脑再试。
**task_for_pid:5**
这是进程附加权限被系统拒绝的提示。请确保你使用的是打包好的 WeFlow.app同时检查系统的签名与调试权限是否已经正确配置。
### 关于推荐版本的补充说明
截至 2026 年 4 月,综合社区的反馈来看,微信 4.1.7 和 4.1.8.100 版本在密钥获取流程中的表现最为稳定,成功率最高。
这并不意味着其他新版本绝对无法获取,只是作为当前的排障参考。未来 WeFlow 也会在后续的更新中逐步适配新版微信的特征,建议大家多留意项目的 Release 动态。
### 最后的几点建议
首次失败后,首要任务是排查原因,切忌盲目地连续点击自动获取。如果你在看到这篇文档前已经失败了好几次,最好的做法是直接清零重来:彻底退出微信,重启电脑,然后再进行下一次尝试。
最后,如果尝试了上述所有方法依然无法解决,请记得保存完整的报错文本,特别是 SCAN_FAILED 或 HOOK_FAILED 后面跟着的英文细节。把这些信息提交到[issue](https://github.com/hicccc77/WeFlow/issues/745),会大大加快定位和修复兼容性问题的速度。

View File

@@ -1,19 +1,132 @@
import { parentPort, workerData } from 'worker_threads'
import type { ExportOptions } from './services/exportService'
interface ExportWorkerConfig {
sessionIds: string[]
outputDir: string
options: ExportOptions
mode?: 'sessions' | 'single' | 'contacts'
sessionIds?: string[]
sessionId?: string
outputDir?: string
outputPath?: string
options?: any
taskId?: string
dbPath?: string
decryptKey?: string
myWxid?: string
imageXorKey?: unknown
imageAesKey?: string
resourcesPath?: string
userDataPath?: string
logEnabled?: boolean
}
const config = workerData as ExportWorkerConfig
const controlState = {
pauseRequested: false,
stopRequested: false
}
const CREATED_PATH_FLUSH_INTERVAL_MS = 200
const CREATED_PATH_BATCH_LIMIT = 256
const PROGRESS_POST_INTERVAL_MS = 180
let queuedCreatedFiles: string[] = []
let queuedCreatedDirs: string[] = []
let createdPathFlushTimer: ReturnType<typeof setTimeout> | null = null
let pendingProgress: any = null
let progressPostTimer: ReturnType<typeof setTimeout> | null = null
let lastProgressPostedAt = 0
function flushCreatedPaths() {
if (createdPathFlushTimer) {
clearTimeout(createdPathFlushTimer)
createdPathFlushTimer = null
}
const filePaths = queuedCreatedFiles
const dirPaths = queuedCreatedDirs
queuedCreatedFiles = []
queuedCreatedDirs = []
if (!parentPort) return
if (filePaths.length > 0) {
parentPort.postMessage({ type: 'export:createdFiles', filePaths })
}
if (dirPaths.length > 0) {
parentPort.postMessage({ type: 'export:createdDirs', dirPaths })
}
}
function scheduleCreatedPathFlush() {
if (createdPathFlushTimer) return
createdPathFlushTimer = setTimeout(flushCreatedPaths, CREATED_PATH_FLUSH_INTERVAL_MS)
}
function queueCreatedFile(filePath: string) {
const normalized = String(filePath || '').trim()
if (!normalized) return
queuedCreatedFiles.push(normalized)
if (queuedCreatedFiles.length + queuedCreatedDirs.length >= CREATED_PATH_BATCH_LIMIT) {
flushCreatedPaths()
} else {
scheduleCreatedPathFlush()
}
}
function queueCreatedDir(dirPath: string) {
const normalized = String(dirPath || '').trim()
if (!normalized) return
queuedCreatedDirs.push(normalized)
if (queuedCreatedFiles.length + queuedCreatedDirs.length >= CREATED_PATH_BATCH_LIMIT) {
flushCreatedPaths()
} else {
scheduleCreatedPathFlush()
}
}
function flushProgress() {
if (!pendingProgress) return
if (progressPostTimer) {
clearTimeout(progressPostTimer)
progressPostTimer = null
}
parentPort?.postMessage({
type: 'export:progress',
data: pendingProgress
})
pendingProgress = null
lastProgressPostedAt = Date.now()
}
function queueProgress(progress: any) {
pendingProgress = progress
if (progress?.phase === 'complete') {
flushProgress()
return
}
const now = Date.now()
const elapsed = now - lastProgressPostedAt
if (elapsed >= PROGRESS_POST_INTERVAL_MS) {
flushProgress()
return
}
if (progressPostTimer) return
progressPostTimer = setTimeout(flushProgress, PROGRESS_POST_INTERVAL_MS - elapsed)
}
parentPort?.on('message', (message: any) => {
if (!message || typeof message.type !== 'string') return
if (message.type === 'export:pause') {
controlState.pauseRequested = true
return
}
if (message.type === 'export:resume') {
controlState.pauseRequested = false
return
}
if (message.type === 'export:cancel') {
controlState.stopRequested = true
controlState.pauseRequested = false
}
})
process.env.WEFLOW_WORKER = '1'
if (config.resourcesPath) {
process.env.WCDB_RESOURCES_PATH = config.resourcesPath
@@ -35,20 +148,57 @@ async function run() {
exportService.setRuntimeConfig({
dbPath: config.dbPath,
decryptKey: config.decryptKey,
myWxid: config.myWxid
myWxid: config.myWxid,
imageXorKey: config.imageXorKey,
imageAesKey: config.imageAesKey
})
const result = await exportService.exportSessions(
Array.isArray(config.sessionIds) ? config.sessionIds : [],
String(config.outputDir || ''),
config.options || { format: 'json' },
(progress) => {
parentPort?.postMessage({
type: 'export:progress',
data: progress
})
}
)
const onProgress = (progress: any) => queueProgress(progress)
const taskControl = config.taskId
? {
shouldPause: () => controlState.pauseRequested,
shouldStop: () => controlState.stopRequested,
recordCreatedFile: queueCreatedFile,
recordCreatedDir: queueCreatedDir
}
: undefined
let result: any
if (config.mode === 'contacts') {
const [{ contactExportService }, { chatService }] = await Promise.all([
import('./services/contactExportService'),
import('./services/chatService')
])
chatService.setRuntimeConfig({
dbPath: config.dbPath,
decryptKey: config.decryptKey,
myWxid: config.myWxid
})
result = await contactExportService.exportContacts(
String(config.outputDir || ''),
config.options || {}
)
} else if (config.mode === 'single') {
result = await exportService.exportSessionToChatLab(
String(config.sessionId || '').trim(),
String(config.outputPath || '').trim(),
config.options || { format: 'chatlab' },
onProgress,
taskControl
)
} else {
result = await exportService.exportSessions(
Array.isArray(config.sessionIds) ? config.sessionIds : [],
String(config.outputDir || ''),
config.options || { format: 'json' },
onProgress,
taskControl
)
}
flushProgress()
flushCreatedPaths()
parentPort?.postMessage({
type: 'export:result',
@@ -57,6 +207,8 @@ async function run() {
}
run().catch((error) => {
flushProgress()
flushCreatedPaths()
parentPort?.postMessage({
type: 'export:error',
error: String(error)

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import { contextBridge, ipcRenderer } from 'electron'
import { contextBridge, ipcRenderer } from 'electron'
// 暴露给渲染进程的 API
contextBridge.exposeInMainWorld('electronAPI', {
@@ -13,7 +13,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
notification: {
show: (data: any) => ipcRenderer.invoke('notification:show', data),
close: () => ipcRenderer.invoke('notification:close'),
click: (sessionId: string) => ipcRenderer.send('notification-clicked', sessionId),
click: (payload: any) => ipcRenderer.send('notification-clicked', payload),
ready: () => ipcRenderer.send('notification:ready'),
resize: (width: number, height: number) => ipcRenderer.send('notification:resize', { width, height }),
onShow: (callback: (event: any, data: any) => void) => {
@@ -24,6 +24,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
const listener = (_: any, sessionId: string) => callback(sessionId)
ipcRenderer.on('navigate-to-session', listener)
return () => ipcRenderer.removeListener('navigate-to-session', listener)
},
onNavigateToRoute: (callback: (route: string) => void) => {
const listener = (_: any, route: string) => callback(route)
ipcRenderer.on('navigate-to-route', listener)
return () => ipcRenderer.removeListener('navigate-to-route', listener)
}
},
@@ -110,7 +115,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),
@@ -154,6 +159,17 @@ contextBridge.exposeInMainWorld('electronAPI', {
},
backup: {
create: (payload: { outputPath: string; options?: { includeImages?: boolean; includeVideos?: boolean; includeFiles?: boolean } }) => ipcRenderer.invoke('backup:create', payload),
inspect: (payload: { archivePath: string }) => ipcRenderer.invoke('backup:inspect', payload),
restore: (payload: { archivePath: string }) => ipcRenderer.invoke('backup:restore', payload),
onProgress: (callback: (progress: any) => void) => {
const listener = (_: unknown, progress: any) => callback(progress)
ipcRenderer.on('backup:progress', listener)
return () => ipcRenderer.removeListener('backup:progress', listener)
}
},
// 密钥获取
key: {
autoGetDbKey: () => ipcRenderer.invoke('key:autoGetDbKey'),
@@ -174,6 +190,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
chat: {
connect: () => ipcRenderer.invoke('chat:connect'),
getSessions: () => ipcRenderer.invoke('chat:getSessions'),
markAllSessionsRead: () => ipcRenderer.invoke('chat:markAllSessionsRead'),
getAntiRevokeSessions: () => ipcRenderer.invoke('chat:getAntiRevokeSessions'),
getSessionStatuses: (usernames: string[]) => ipcRenderer.invoke('chat:getSessionStatuses', usernames),
getExportTabCounts: () => ipcRenderer.invoke('chat:getExportTabCounts'),
getContactTypeCounts: () => ipcRenderer.invoke('chat:getContactTypeCounts'),
@@ -219,6 +237,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
allowStaleCache?: boolean
preferAccurateSpecialTypes?: boolean
cacheOnly?: boolean
beginTimestamp?: number
endTimestamp?: number
}
) => ipcRenderer.invoke('chat:getExportSessionStats', sessionIds, options),
getGroupMyMessageCountHint: (chatroomId: string) =>
@@ -258,6 +278,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)
@@ -268,24 +306,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
createTime?: number
preferFilePath?: boolean
hardlinkOnly?: boolean
disableUpdateCheck?: boolean
allowCacheIndex?: boolean
suppressEvents?: boolean
}) =>
ipcRenderer.invoke('image:resolveCache', payload),
resolveCacheBatch: (
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean }
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 }>,
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)
@@ -316,7 +371,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
}) => callback(payload)
ipcRenderer.on('image:decryptProgress', listener)
return () => ipcRenderer.removeListener('image:decryptProgress', listener)
}
},
startAutoDownload: (whitelist: string[] | string) => ipcRenderer.invoke('image:startAutoDownload', whitelist),
stopAutoDownload: () => ipcRenderer.invoke('image:stopAutoDownload'),
getAutoDownloadStatus: () => ipcRenderer.invoke('image:getAutoDownloadStatus')
},
// 视频
@@ -325,6 +383,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content)
},
process: {
platform: process.platform,
arch: process.arch
},
// 数据分析
analytics: {
getOverallStatistics: (force?: boolean) => ipcRenderer.invoke('analytics:getOverallStatistics', force),
@@ -377,6 +440,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[]
@@ -413,8 +477,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
export: {
getExportStats: (sessionIds: string[], options: any) =>
ipcRenderer.invoke('export:getExportStats', sessionIds, options),
exportSessions: (sessionIds: string[], outputDir: string, options: any) =>
ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options),
exportSessions: (sessionIds: string[], outputDir: string, options: any, controlOptions?: { taskId?: string }) =>
ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options, controlOptions),
pauseTask: (taskId: string) =>
ipcRenderer.invoke('export:pauseTask', taskId),
resumeTask: (taskId: string) =>
ipcRenderer.invoke('export:resumeTask', taskId),
cancelTask: (taskId: string) =>
ipcRenderer.invoke('export:cancelTask', taskId),
exportSession: (sessionId: string, outputPath: string, options: any) =>
ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options),
exportContacts: (outputDir: string, options: any) =>
@@ -508,6 +578,28 @@ contextBridge.exposeInMainWorld('electronAPI', {
insight: {
testConnection: () => ipcRenderer.invoke('insight:testConnection'),
getTodayStats: () => ipcRenderer.invoke('insight:getTodayStats'),
triggerTest: () => ipcRenderer.invoke('insight:triggerTest')
listRecords: (filters?: any) => ipcRenderer.invoke('insight:listRecords', filters),
getRecord: (id: string) => ipcRenderer.invoke('insight:getRecord', id),
markRecordRead: (id: string) => ipcRenderer.invoke('insight:markRecordRead', id),
clearRecords: (filters?: any) => ipcRenderer.invoke('insight:clearRecords', filters),
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

@@ -0,0 +1,73 @@
import { existsSync, readdirSync, statSync } from 'fs'
import { join } from 'path'
const accountDirCache = new Map<string, string>()
const cleanAccountDirName = (dirName: string): string => {
const trimmed = dirName.trim()
if (!trimmed) return trimmed
if (trimmed.toLowerCase().startsWith('wxid_')) {
const match = trimmed.match(/^(wxid_[^_]+)/i)
if (match) return match[1]
return trimmed
}
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
if (suffixMatch) return suffixMatch[1]
return trimmed
}
const isDirectory = (path: string): boolean => {
try {
return statSync(path).isDirectory()
} catch {
return false
}
}
export const resolveAccountDir = (dbPath?: string, wxid?: string): string | null => {
if (!dbPath || !wxid) return null
const cleanedWxid = cleanAccountDirName(wxid)
const normalized = dbPath.replace(/[\\/]+$/, '')
const cacheKey = `${normalized}|${cleanedWxid.toLowerCase()}`
const cached = accountDirCache.get(cacheKey)
if (cached && existsSync(cached)) return cached
if (cached && !existsSync(cached)) {
accountDirCache.delete(cacheKey)
}
const lowerWxid = cleanedWxid.toLowerCase()
if (!lowerWxid.startsWith('wxid_')) {
const direct = join(normalized, cleanedWxid)
if (existsSync(direct) && isDirectory(direct)) {
accountDirCache.set(cacheKey, direct)
return direct
}
}
try {
const entries = readdirSync(normalized)
for (const entry of entries) {
const entryPath = join(normalized, entry)
if (!isDirectory(entryPath)) continue
const lowerEntry = entry.toLowerCase()
const isExactMatch = lowerEntry === lowerWxid
const isSuffixMatch = lowerEntry.startsWith(`${lowerWxid}_`)
const shouldMatch = lowerWxid.startsWith('wxid_')
? isSuffixMatch
: (isExactMatch || isSuffixMatch)
if (shouldMatch) {
accountDirCache.set(cacheKey, entryPath)
return entryPath
}
}
} catch { }
return null
}

View File

@@ -103,8 +103,10 @@ class AnalyticsService {
if (username === 'filehelper') return false
if (username.startsWith('gh_')) return false
if (username.toLowerCase() === 'weixin') return false
const excludeList = [
'weixin', 'qqmail', 'fmessage', 'medianote', 'floatbottle',
'qqmail', 'fmessage', 'medianote', 'floatbottle',
'newsapp', 'brandsessionholder', 'brandservicesessionholder',
'notifymessage', 'opencustomerservicemsg', 'notification_messages',
'userexperience_alarm', 'helper_folders', 'placeholder_foldgroup',
@@ -125,13 +127,19 @@ class AnalyticsService {
const wxid = this.configService.get('myWxid')
const dbPath = this.configService.get('dbPath')
const decryptKey = this.configService.get('decryptKey')
if (!wxid) return { success: false, error: '未配置微信ID' }
if (!dbPath) return { success: false, error: '未配置数据库路径' }
if (!decryptKey) return { success: false, error: '未配置解密密钥' }
const cleanedWxid = this.cleanAccountDirName(wxid)
const ok = await wcdbService.open(dbPath, decryptKey, cleanedWxid)
const accountDir = this.configService.getAccountDir(dbPath, wxid)
if (!accountDir) return { success: false, error: '未找到账号目录' }
const ok = await wcdbService.open(accountDir, decryptKey)
if (!ok) return { success: false, error: 'WCDB 打开失败' }
const cleanedWxid = this.cleanAccountDirName(wxid)
return { success: true, cleanedWxid }
}
@@ -231,8 +239,7 @@ class AnalyticsService {
}
private async computeAggregateByCursor(sessionIds: string[], beginTimestamp = 0, endTimestamp = 0): Promise<any> {
const wxid = this.configService.get('myWxid')
const cleanedWxid = wxid ? this.cleanAccountDirName(wxid) : ''
const cleanedWxid = this.configService.getMyWxidCleaned() || ''
const aggregate = {
total: 0,
@@ -269,8 +276,7 @@ class AnalyticsService {
const myWxidLower = cleanedWxid.toLowerCase()
isSend = (
senderLower === myWxidLower ||
// 兼容非 wxid 开头的账号(如果文件夹名带后缀,如 custom_backup而 sender 是 custom
(myWxidLower.startsWith(senderLower + '_'))
senderLower.startsWith(myWxidLower + '_')
)
}
}

View File

@@ -1,5 +1,6 @@
import { parentPort } from 'worker_threads'
import { wcdbService } from './wcdbService'
import { resolveAccountDir } from './accountDirResolver'
export interface TopContact {
username: string
@@ -59,6 +60,8 @@ export interface AnnualReportData {
initiatedChats: number
receivedChats: number
initiativeRate: number
topInitiatedFriend?: string
topInitiatedCount?: number
} | null
responseSpeed: {
avgResponseTime: number
@@ -156,9 +159,13 @@ class AnnualReportService {
if (!dbPath) return { success: false, error: '未配置数据库路径' }
if (!decryptKey) return { success: false, error: '未配置解密密钥' }
const cleanedWxid = this.cleanAccountDirName(wxid)
const ok = await wcdbService.open(dbPath, decryptKey, cleanedWxid)
const accountDir = resolveAccountDir(dbPath, wxid)
if (!accountDir) return { success: false, error: '未找到账号目录' }
const ok = await wcdbService.open(accountDir, decryptKey)
if (!ok) return { success: false, error: 'WCDB 打开失败' }
const cleanedWxid = this.cleanAccountDirName(wxid)
return { success: true, cleanedWxid, rawWxid: wxid }
}
@@ -168,7 +175,7 @@ class AnnualReportService {
const rows = sessionResult.sessions as Record<string, any>[]
const excludeList = [
'weixin', 'qqmail', 'fmessage', 'medianote', 'floatbottle',
'qqmail', 'fmessage', 'medianote', 'floatbottle',
'newsapp', 'brandsessionholder', 'brandservicesessionholder',
'notifymessage', 'opencustomerservicemsg', 'notification_messages',
'userexperience_alarm', 'helper_folders', 'placeholder_foldgroup',
@@ -183,6 +190,7 @@ class AnnualReportService {
if (username === 'filehelper') return false
if (username.startsWith('gh_')) return false
if (username.toLowerCase() === cleanedWxid.toLowerCase()) return false
if (username.toLowerCase() === 'weixin') return false
for (const prefix of excludeList) {
if (username.startsWith(prefix) || username === prefix) return false
@@ -1190,7 +1198,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 +1227,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 +1370,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
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,7 @@ export interface BizAccount {
type: number
last_time: number
formatted_last_time: string
unread_count?: number
}
export interface BizMessage {
@@ -99,24 +100,29 @@ export class BizService {
const contactInfoMap = enrichment.success && enrichment.contacts ? enrichment.contacts : {}
const root = this.configService.get('dbPath')
const myWxid = this.configService.get('myWxid')
const myWxid = this.configService.getMyWxidCleaned()
const accountWxid = account || myWxid
if (!root || !accountWxid) return []
const bizLatestTime: Record<string, number> = {}
const bizUnreadCount: Record<string, number> = {}
try {
const sessionsRes = await wcdbService.getSessions()
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.last_timestamp || session.sort_timestamp || session.nTime || session.timestamp || '0'
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) {
@@ -152,7 +158,8 @@ export class BizService {
avatar: info?.avatarUrl || '',
type: 0,
last_time: lastTime,
formatted_last_time: formatBizTime(lastTime)
formatted_last_time: formatBizTime(lastTime),
unread_count: bizUnreadCount[uname] || 0
}
})

File diff suppressed because it is too large Load Diff

View File

@@ -218,7 +218,7 @@ class CloudControlService {
this.pages.add(pageName)
}
stop() {
async stop(): Promise<void> {
if (this.timer) {
clearTimeout(this.timer)
this.timer = null
@@ -230,7 +230,13 @@ class CloudControlService {
this.circuitOpenedAt = 0
this.nextDelayOverrideMs = null
this.initialized = false
wcdbService.cloudStop()
if (wcdbService.isReady()) {
try {
await wcdbService.cloudStop()
} catch {
// 忽略停止失败,避免阻塞主进程退出
}
}
}
async getLogs() {

View File

@@ -1,7 +1,22 @@
import { join } from 'path'
import { app, safeStorage } from 'electron'
import { join } from 'path'
import { existsSync, readdirSync, statSync } from 'fs'
import crypto from 'crypto'
import Store from 'electron-store'
import { expandHomePath } from '../utils/pathUtils'
// 条件导入 electronWorker 环境中不可用)
let app: any = null
let safeStorage: any = null
const isWorkerThread = process.env.WEFLOW_WORKER === '1'
if (!isWorkerThread) {
try {
const electron = require('electron')
app = electron.app
safeStorage = electron.safeStorage
} catch {
// Worker 环境中 electron 不可用
}
}
// 加密前缀标记
const SAFE_PREFIX = 'safe:' // safeStorage 加密(普通模式)
@@ -35,6 +50,7 @@ interface ConfigSchema {
language: string
logEnabled: boolean
launchAtStartup?: boolean
silentStartup?: boolean
llmModelPath: string
whisperModelName: string
whisperModelDir: string
@@ -42,7 +58,6 @@ interface ConfigSchema {
autoTranscribeVoice: boolean
transcribeLanguages: string[]
exportDefaultConcurrency: number
exportDefaultImageDeepSearchOnMiss: boolean
analyticsExcludedUsernames: string[]
// 安全相关
@@ -57,10 +72,13 @@ interface ConfigSchema {
// 通知
notificationEnabled: boolean
aiInsightNotificationEnabled: boolean
notificationPosition: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'
notificationFilterMode: 'all' | 'whitelist' | 'blacklist'
notificationFilterList: string[]
messagePushEnabled: boolean
messagePushFilterMode: 'all' | 'whitelist' | 'blacklist'
messagePushFilterList: string[]
httpApiEnabled: boolean
httpApiPort: number
httpApiHost: string
@@ -69,14 +87,28 @@ interface ConfigSchema {
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
aiInsightAllowMomentsContext: boolean
aiInsightMomentsContextCount: number
aiInsightMomentsBindings: Record<string, { enabled: boolean; updatedAt: number }>
aiInsightAllowSocialContext: boolean
aiInsightSocialContextCount: number
aiInsightWeiboCookie: string
aiInsightWeiboBindings: Record<string, { uid: string; screenName?: string; updatedAt: number }>
aiInsightFilterMode: 'whitelist' | 'blacklist'
aiInsightFilterList: string[]
aiInsightWhitelistEnabled: boolean
aiInsightWhitelist: string[]
/** 活跃分析冷却时间分钟0 表示无冷却 */
@@ -93,10 +125,26 @@ interface ConfigSchema {
aiInsightTelegramToken: string
/** Telegram 接收 Chat ID逗号分隔支持多个 */
aiInsightTelegramChatIds: string
// AI 足迹
aiFootprintEnabled: boolean
aiFootprintSystemPrompt: string
/** 是否将 AI 见解调试日志输出到桌面 */
aiInsightDebugLogEnabled: boolean
autoDownloadHighRes: boolean
autoDownloadWhitelist: string[]
}
// 需要 safeStorage 加密的字段(普通模式)
const ENCRYPTED_STRING_KEYS: Set<string> = new Set(['decryptKey', 'imageAesKey', 'authPassword', 'httpApiToken', 'aiInsightApiKey'])
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'])
@@ -112,6 +160,9 @@ export class ConfigService {
private unlockedKeys: Map<string, any> = new Map()
private unlockPassword: string | null = null
// 账号目录缓存
private accountDirCache: Map<string, string> = new Map()
static getInstance(): ConfigService {
if (!ConfigService.instance) {
ConfigService.instance = new ConfigService()
@@ -139,6 +190,7 @@ export class ConfigService {
themeId: 'cloud-dancer',
language: 'zh-CN',
logEnabled: false,
silentStartup: false,
llmModelPath: '',
whisperModelName: 'base',
whisperModelDir: '',
@@ -146,7 +198,6 @@ export class ConfigService {
autoTranscribeVoice: false,
transcribeLanguages: ['zh'],
exportDefaultConcurrency: 4,
exportDefaultImageDeepSearchOnMiss: true,
analyticsExcludedUsernames: [],
authEnabled: false,
authPassword: '',
@@ -155,6 +206,7 @@ export class ConfigService {
ignoredUpdateVersion: '',
updateChannel: 'auto',
notificationEnabled: true,
aiInsightNotificationEnabled: true,
notificationPosition: 'top-right',
notificationFilterMode: 'all',
notificationFilterList: [],
@@ -163,25 +215,46 @@ export class ConfigService {
httpApiPort: 5031,
httpApiHost: '127.0.0.1',
messagePushEnabled: false,
messagePushFilterMode: 'all',
messagePushFilterList: [],
windowCloseBehavior: 'ask',
quoteLayout: 'quote-top',
wordCloudExcludeWords: [],
exportWriteLayout: 'A',
exportAutomationTaskMap: {},
aiModelApiBaseUrl: '',
aiModelApiKey: '',
aiModelApiModel: 'gpt-4o-mini',
aiModelApiMaxTokens: 1024,
aiInsightEnabled: false,
aiInsightApiBaseUrl: '',
aiInsightApiKey: '',
aiInsightApiModel: 'gpt-4o-mini',
aiInsightSilenceDays: 3,
aiInsightAllowContext: false,
aiInsightAllowMomentsContext: false,
aiInsightMomentsContextCount: 5,
aiInsightMomentsBindings: {},
aiInsightAllowSocialContext: false,
aiInsightFilterMode: 'whitelist',
aiInsightFilterList: [],
aiInsightWhitelistEnabled: false,
aiInsightWhitelist: [],
aiInsightCooldownMinutes: 120,
aiInsightScanIntervalHours: 4,
aiInsightContextCount: 40,
aiInsightSocialContextCount: 3,
aiInsightSystemPrompt: '',
aiInsightTelegramEnabled: false,
aiInsightTelegramToken: '',
aiInsightTelegramChatIds: ''
aiInsightTelegramChatIds: '',
aiInsightWeiboCookie: '',
aiInsightWeiboBindings: {},
aiFootprintEnabled: false,
aiFootprintSystemPrompt: '',
aiInsightDebugLogEnabled: false,
autoDownloadHighRes: false,
autoDownloadWhitelist: []
}
const storeOptions: any = {
@@ -213,6 +286,7 @@ export class ConfigService {
}
}
this.migrateAuthFields()
this.migrateAiConfig()
}
// === 状态查询 ===
@@ -262,6 +336,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
}
@@ -269,8 +347,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]
@@ -649,7 +733,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)
}
// === 迁移 ===
@@ -658,13 +742,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')
@@ -710,6 +799,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 {
@@ -729,7 +838,15 @@ export class ConfigService {
// === 工具方法 ===
/**
* 获取当前 wxid 对应的图片密钥,优先从 wxidConfigs 中取找不到则回退到全局<E585A8><E5B180>
* 获取当前用户 wxid清洗后不带后缀
*/
getMyWxidCleaned(): string {
const wxid = this.get('myWxid')
return wxid ? this.cleanAccountDirName(wxid) : ''
}
/**
* 获取当前 wxid 对应的图片密钥,优先从 wxidConfigs 中取,找不到则回退到全局配置
*/
getImageKeysForCurrentWxid(): { xorKey: unknown; aesKey: string } {
const wxid = this.get('myWxid')
@@ -749,6 +866,99 @@ export class ConfigService {
}
}
/**
* 清理账号目录名称(移除后缀)
*/
private cleanAccountDirName(dirName: string): string {
const trimmed = dirName.trim()
if (!trimmed) return trimmed
// wxid_ 开头的特殊处理
if (trimmed.toLowerCase().startsWith('wxid_')) {
const match = trimmed.match(/^(wxid_[^_]+)/i)
if (match) return match[1]
return trimmed
}
// 移除4位后缀
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
if (suffixMatch) return suffixMatch[1]
return trimmed
}
/**
* 检查是否是目录
*/
private isDirectory(path: string): boolean {
try {
return statSync(path).isDirectory()
} catch {
return false
}
}
/**
* 获取账号目录路径
* 统一的账号目录解析方法,所有服务应该使用此方法而不是自己实现
*
* @param dbPath 数据库根目录(可选,默认从配置读取)
* @param wxid 微信ID可选默认从配置读取
* @returns 账号目录的完整路径,如果找不到返回 null
*/
getAccountDir(dbPath?: string, wxid?: string): string | null {
const actualDbPath = dbPath || this.get('dbPath')
const actualWxid = wxid || this.get('myWxid')
if (!actualDbPath || !actualWxid) return null
const cleanedWxid = this.cleanAccountDirName(actualWxid)
const normalized = actualDbPath.replace(/[\\/]+$/, '')
const cacheKey = `${normalized}|${cleanedWxid.toLowerCase()}`
// 检查缓存
const cached = this.accountDirCache.get(cacheKey)
if (cached && existsSync(cached)) return cached
if (cached && !existsSync(cached)) {
this.accountDirCache.delete(cacheKey)
}
// 尝试直接路径(非 wxid_ 开头的账号)
const lowerWxid = cleanedWxid.toLowerCase()
if (!lowerWxid.startsWith('wxid_')) {
const direct = join(normalized, cleanedWxid)
if (existsSync(direct) && this.isDirectory(direct)) {
this.accountDirCache.set(cacheKey, direct)
return direct
}
}
// 扫描目录查找匹配的账号目录
try {
const entries = readdirSync(normalized)
for (const entry of entries) {
const entryPath = join(normalized, entry)
if (!this.isDirectory(entryPath)) continue
const lowerEntry = entry.toLowerCase()
const isExactMatch = lowerEntry === lowerWxid
const isSuffixMatch = lowerEntry.startsWith(`${lowerWxid}_`)
// wxid_ 开头只接受带后缀的目录;其他账号精确匹配或带后缀都可以
const shouldMatch = lowerWxid.startsWith('wxid_')
? isSuffixMatch
: (isExactMatch || isSuffixMatch)
if (shouldMatch) {
this.accountDirCache.set(cacheKey, entryPath)
return entryPath
}
}
} catch { }
return null
}
private getUserDataPath(): string {
const workerUserDataPath = String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim()
if (workerUserDataPath) {
@@ -771,3 +981,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
@@ -139,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)
@@ -158,6 +160,16 @@ export class DbPathService {
// 检查是否有有效账号目录结构
if (this.isAccountDir(entryPath)) {
// 过滤掉不带后缀的 wxid_ 目录
const lowerEntry = entry.toLowerCase()
if (lowerEntry.startsWith('wxid_')) {
// wxid_ 开头的目录必须带后缀wxid_xxx_yyyy 格式)
const parts = entry.split('_')
if (parts.length <= 2) {
// wxid_xxx 格式,跳过
continue
}
}
accounts.push(entry)
}
}
@@ -216,28 +228,39 @@ 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
const lower = entry.toLowerCase()
if (lower === 'all_users') continue
if (!entry.includes('_')) continue
// 过滤掉不带后缀的 wxid_ 目录
if (lower.startsWith('wxid_')) {
const parts = entry.split('_')
if (parts.length <= 2) {
// wxid_xxx 格式,跳过
continue
}
}
wxids.push({ wxid: entry, modifiedTime: stat.mtimeMs })
}
}
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 })
}
}
@@ -248,7 +271,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) {
@@ -266,19 +289,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 })
}
@@ -289,7 +313,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) {

View File

@@ -1,5 +1,6 @@
import { parentPort } from 'worker_threads'
import { wcdbService } from './wcdbService'
import { resolveAccountDir } from './accountDirResolver'
export interface DualReportMessage {
@@ -109,9 +110,11 @@ class DualReportService {
if (!dbPath) return { success: false, error: '未配置数据库路径' }
if (!decryptKey) return { success: false, error: '未配置解密密钥' }
const cleanedWxid = this.cleanAccountDirName(wxid)
const ok = await wcdbService.open(dbPath, decryptKey, cleanedWxid)
const accountDir = resolveAccountDir(dbPath, wxid)
if (!accountDir) return { success: false, error: '无法找到账号目录' }
const ok = await wcdbService.open(accountDir, decryptKey)
if (!ok) return { success: false, error: 'WCDB 打开失败' }
const cleanedWxid = this.cleanAccountDirName(wxid)
return { success: true, cleanedWxid, rawWxid: wxid }
}

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

@@ -0,0 +1,210 @@
import * as path from 'path'
import { rm, rmdir } from 'fs/promises'
export type ExportTaskControlState = 'running' | 'pause_requested' | 'cancel_requested'
export interface ExportTaskControlHooks {
shouldPause: () => boolean
shouldStop: () => boolean
recordCreatedFile: (filePath: string) => void
recordCreatedDir: (dirPath: string) => void
}
interface ExportTaskManifest {
outputDir: string
files: Set<string>
dirs: Set<string>
}
interface ExportTaskControlRecord {
state: ExportTaskControlState
manifest: ExportTaskManifest
createdAt: number
updatedAt: number
}
export interface ExportTaskCleanupResult {
success: boolean
filesDeleted: number
dirsDeleted: number
error?: string
}
class ExportTaskControlService {
private tasks = new Map<string, ExportTaskControlRecord>()
createControl(taskId: string, outputDir: string): ExportTaskControlHooks {
this.registerTask(taskId, outputDir)
return {
shouldPause: () => this.getState(taskId) === 'pause_requested',
shouldStop: () => this.getState(taskId) === 'cancel_requested',
recordCreatedFile: (filePath: string) => this.recordCreatedFile(taskId, filePath),
recordCreatedDir: (dirPath: string) => this.recordCreatedDir(taskId, dirPath)
}
}
registerTask(taskId: string, outputDir: string): void {
const normalizedTaskId = this.normalizeTaskId(taskId)
if (!normalizedTaskId) return
const normalizedOutputDir = path.resolve(String(outputDir || '').trim() || '.')
const existing = this.tasks.get(normalizedTaskId)
if (existing) {
existing.state = 'running'
existing.updatedAt = Date.now()
if (!existing.manifest.outputDir) {
existing.manifest.outputDir = normalizedOutputDir
}
return
}
this.tasks.set(normalizedTaskId, {
state: 'running',
manifest: {
outputDir: normalizedOutputDir,
files: new Set<string>(),
dirs: new Set<string>()
},
createdAt: Date.now(),
updatedAt: Date.now()
})
}
pauseTask(taskId: string): boolean {
return this.setState(taskId, 'pause_requested')
}
resumeTask(taskId: string): boolean {
return this.setState(taskId, 'running')
}
cancelTask(taskId: string): boolean {
return this.setState(taskId, 'cancel_requested')
}
getState(taskId: string): ExportTaskControlState | null {
const normalizedTaskId = this.normalizeTaskId(taskId)
if (!normalizedTaskId) return null
return this.tasks.get(normalizedTaskId)?.state || null
}
releaseTask(taskId: string): void {
const normalizedTaskId = this.normalizeTaskId(taskId)
if (!normalizedTaskId) return
this.tasks.delete(normalizedTaskId)
}
recordCreatedFile(taskId: string, filePath: string): void {
const task = this.getTaskForManifestWrite(taskId, filePath)
if (!task) return
task.manifest.files.add(path.resolve(filePath))
task.updatedAt = Date.now()
}
recordCreatedDir(taskId: string, dirPath: string): void {
const task = this.getTaskForManifestWrite(taskId, dirPath)
if (!task) return
task.manifest.dirs.add(path.resolve(dirPath))
task.updatedAt = Date.now()
}
async cleanupTask(taskId: string): Promise<ExportTaskCleanupResult> {
const normalizedTaskId = this.normalizeTaskId(taskId)
const task = normalizedTaskId ? this.tasks.get(normalizedTaskId) : undefined
if (!task) {
return { success: true, filesDeleted: 0, dirsDeleted: 0 }
}
const outputDir = task.manifest.outputDir
let filesDeleted = 0
let dirsDeleted = 0
const errors: string[] = []
const files = Array.from(task.manifest.files)
.filter(filePath => this.isInsideOutputDir(filePath, outputDir))
.sort((a, b) => b.length - a.length)
for (const filePath of files) {
try {
await rm(filePath, { force: true, recursive: false })
filesDeleted++
} catch (error) {
const code = (error as NodeJS.ErrnoException | undefined)?.code
if (code !== 'ENOENT') {
errors.push(`${filePath}: ${error instanceof Error ? error.message : String(error)}`)
}
}
}
const dirs = Array.from(task.manifest.dirs)
.filter(dirPath => this.isInsideOutputDir(dirPath, outputDir) || this.isSamePath(dirPath, outputDir))
.sort((a, b) => b.length - a.length)
for (const dirPath of dirs) {
try {
await rmdir(dirPath)
dirsDeleted++
} catch (error) {
const code = (error as NodeJS.ErrnoException | undefined)?.code
if (code !== 'ENOENT' && code !== 'ENOTEMPTY' && code !== 'EEXIST') {
errors.push(`${dirPath}: ${error instanceof Error ? error.message : String(error)}`)
}
}
}
if (errors.length === 0) {
this.releaseTask(normalizedTaskId)
return { success: true, filesDeleted, dirsDeleted }
}
return {
success: false,
filesDeleted,
dirsDeleted,
error: errors.slice(0, 3).join('; ')
}
}
private setState(taskId: string, state: ExportTaskControlState): boolean {
const normalizedTaskId = this.normalizeTaskId(taskId)
if (!normalizedTaskId) return false
const task = this.tasks.get(normalizedTaskId)
if (!task) return false
task.state = state
task.updatedAt = Date.now()
return true
}
private getTaskForManifestWrite(taskId: string, targetPath: string): ExportTaskControlRecord | null {
const normalizedTaskId = this.normalizeTaskId(taskId)
if (!normalizedTaskId) return null
const task = this.tasks.get(normalizedTaskId)
if (!task) return null
if (!this.isInsideOutputDir(targetPath, task.manifest.outputDir) && !this.isSamePath(targetPath, task.manifest.outputDir)) {
return null
}
return task
}
private isInsideOutputDir(targetPath: string, outputDir: string): boolean {
const resolvedTarget = path.resolve(targetPath)
const resolvedOutputDir = path.resolve(outputDir)
const relativePath = path.relative(resolvedOutputDir, resolvedTarget)
return Boolean(relativePath) && !relativePath.startsWith('..') && !path.isAbsolute(relativePath)
}
private isSamePath(left: string, right: string): boolean {
const resolvedLeft = path.resolve(left)
const resolvedRight = path.resolve(right)
if (process.platform === 'win32') {
return resolvedLeft.toLowerCase() === resolvedRight.toLowerCase()
}
return resolvedLeft === resolvedRight
}
private normalizeTaskId(taskId: string): string {
return String(taskId || '').trim()
}
}
export const exportTaskControlService = new ExportTaskControlService()

View File

@@ -251,7 +251,7 @@ class GroupAnalyticsService {
}
private async ensureConnected(): Promise<{ success: boolean; error?: string }> {
const wxid = this.configService.get('myWxid')
const wxid = this.configService.getMyWxidCleaned()
const dbPath = this.configService.get('dbPath')
const decryptKey = this.configService.get('decryptKey')
if (!wxid) return { success: false, error: '未配置微信ID' }
@@ -259,7 +259,9 @@ class GroupAnalyticsService {
if (!decryptKey) return { success: false, error: '未配置解密密钥' }
const cleanedWxid = this.cleanAccountDirName(wxid)
const ok = await wcdbService.open(dbPath, decryptKey, cleanedWxid)
const accountDir = this.configService.getAccountDir(dbPath, wxid)
if (!accountDir) return { success: false, error: '无法找到账号目录' }
const ok = await wcdbService.open(accountDir, decryptKey)
if (!ok) return { success: false, error: 'WCDB 打开失败' }
return { success: true }
}
@@ -1555,7 +1557,7 @@ class GroupAnalyticsService {
const phraseCounts = new Map<string, number>()
const emojiCounts = new Map<string, number>()
const myWxid = String(this.configService.get('myWxid') || '').trim()
const myWxid = String(this.configService.getMyWxidCleaned() || '').trim()
try {
while (true) {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,203 @@
import { app } from 'electron'
import { join } from 'path'
import { existsSync } from 'fs'
import { execFile } from 'child_process'
import { promisify } from 'util'
// import { ConfigService } from './config'
const execFileAsync = promisify(execFile)
export class ImageDownloadService {
private static instance: ImageDownloadService
private koffi: any = null
private lib: any = null
private initialized = false
private initImgHelper: any = null
private uninstallImgHelper: any = null
private getImgHelperError: any = null
private currentPid: number | null = null
private pollTimer: NodeJS.Timeout | null = null
private isHooked = false
private lastWhitelist: string[] = []
static getInstance(): ImageDownloadService {
if (!ImageDownloadService.instance) {
ImageDownloadService.instance = new ImageDownloadService()
}
return ImageDownloadService.instance
}
private constructor() {
}
private async ensureInitialized(): Promise<boolean> {
if (this.initialized) return true
if (process.platform !== 'win32' || process.arch !== 'x64') return false
try {
this.koffi = require('koffi')
const dllPath = this.getDllPath()
if (!existsSync(dllPath)) return false
this.lib = this.koffi.load(dllPath)
this.initImgHelper = this.lib.func('bool InitImgHelper(uint32, const char*)')
this.uninstallImgHelper = this.lib.func('void UninstallImgHelper()')
this.getImgHelperError = this.lib.func('const char* GetImgHelperError()')
this.initialized = true
return true
} catch (error) {
console.error('[ImageDownloadService] failed to initialize:', error)
return false
}
}
private getDllPath(): string {
const isPackaged = app.isPackaged
const candidates: string[] = []
if (isPackaged) {
candidates.push(join(process.resourcesPath, 'resources', 'image', 'win32', 'x64', 'img_helper.dll'))
} else {
candidates.push(join(process.cwd(), 'resources', 'image', 'win32', 'x64', 'img_helper.dll'))
}
for (const path of candidates) {
if (existsSync(path)) return path
}
return candidates[0]
}
private async findMainWeChatPid(): Promise<number | null> {
try {
const script = `
Get-CimInstance Win32_Process -Filter "Name = 'Weixin.exe'" |
Select-Object ProcessId, CommandLine |
ConvertTo-Json -Compress
`;
const { stdout } = await execFileAsync('powershell', ['-NoProfile', '-Command', script])
if (!stdout || !stdout.trim()) return null
let processes = JSON.parse(stdout.trim())
if (!Array.isArray(processes)) processes = [processes]
const target = processes
.filter((p: any) => p.CommandLine && p.CommandLine.toLowerCase().includes('weixin.exe'))
.sort((a: any, b: any) => a.CommandLine.length - b.CommandLine.length)[0]
return target ? target.ProcessId : null;
} catch (e) {
return null
}
}
async startAutoDownload(whitelist: string[] | string = []): Promise<{ success: boolean; error?: string }> {
if (!await this.ensureInitialized()) {
return { success: false, error: '核心组件初始化失败' }
}
if (this.isHooked) {
await this.unhook()
}
this.lastWhitelist = whitelist
if (!this.pollTimer) {
this.pollTimer = setInterval(() => this.checkAndHook(this.lastWhitelist, false), 30000)
}
return await this.checkAndHook(whitelist, true)
}
async stopAutoDownload() {
if (this.pollTimer) {
clearInterval(this.pollTimer)
this.pollTimer = null
}
await this.unhook()
}
private async checkAndHook(whitelist: string[] | string = [], isManualStart = false): Promise<{ success: boolean; error?: string }> {
const pid = await this.findMainWeChatPid()
if (!pid) {
if (this.isHooked) {
console.log('[ImageDownloadService] WeChat exited, unhooking')
await this.unhook()
}
return { success: true, error: '等待微信启动' }
}
if (this.isHooked && this.currentPid === pid) {
return { success: true }
}
if (this.isHooked && this.currentPid !== pid) {
console.log('[ImageDownloadService] WeChat PID changed, re-hooking')
await this.unhook()
}
console.log(`[ImageDownloadService] attempting to hook PID: ${pid}`)
try {
let whitelistBuffer: Buffer | null = null;
if (typeof whitelist === 'string') {
if (whitelist.length > 0) {
whitelistBuffer = Buffer.from(whitelist, 'utf8');
}
} else if (Array.isArray(whitelist) && whitelist.length > 0) {
whitelistBuffer = Buffer.from(whitelist.join('\0') + '\0\0', 'utf8');
}
const success = this.initImgHelper(pid, whitelistBuffer)
if (success) {
this.isHooked = true
this.currentPid = pid
console.log('[ImageDownloadService] hook successful')
return { success: true }
} else {
const err = this.getImgHelperError()
console.error(`[ImageDownloadService] hook failed: ${err}`)
if (isManualStart && this.pollTimer) {
clearInterval(this.pollTimer)
this.pollTimer = null
}
return { success: false, error: err || 'Hook 失败' }
}
} catch (e: any) {
console.error('[ImageDownloadService] InitImgHelper call crashed:', e)
if (isManualStart && this.pollTimer) {
clearInterval(this.pollTimer)
this.pollTimer = null
}
return { success: false, error: `调用异常: ${e.message || String(e)}` }
}
}
private async unhook() {
if (this.isHooked && this.uninstallImgHelper) {
try {
this.uninstallImgHelper()
} catch (e) {
console.error('[ImageDownloadService] uninstall failed:', e)
}
}
this.isHooked = false
this.currentPid = null
}
async getStatus() {
return {
isHooked: this.isHooked,
pid: this.currentPid,
supported: process.platform === 'win32' && process.arch === 'x64'
}
}
}
export const imageDownloadService = ImageDownloadService.getInstance()

View File

@@ -4,6 +4,7 @@ type PreloadImagePayload = {
sessionId?: string
imageMd5?: string
imageDatName?: string
createTime?: number
}
type PreloadOptions = {
@@ -74,15 +75,24 @@ export class ImagePreloadService {
sessionId: task.sessionId,
imageMd5: task.imageMd5,
imageDatName: task.imageDatName,
createTime: task.createTime,
preferFilePath: true,
hardlinkOnly: true,
disableUpdateCheck: !task.allowDecrypt,
allowCacheIndex: task.allowCacheIndex
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

View File

@@ -0,0 +1,292 @@
import { app } from 'electron'
import fs from 'fs'
import path from 'path'
import { createHash, randomUUID } from 'crypto'
import { ConfigService } from './config'
export type InsightRecordTriggerReason = 'activity' | 'silence' | 'test'
export interface InsightRecordLog {
endpoint: string
model: string
maxTokens: number
temperature: number
triggerReason: InsightRecordTriggerReason
allowContext: boolean
contextCount: number
systemPrompt: string
userPrompt: string
rawOutput: string
finalInsight: string
durationMs: number
createdAt: number
}
export interface InsightRecord {
id: string
accountScope: string
createdAt: number
sessionId: string
displayName: string
avatarUrl?: string
triggerReason: InsightRecordTriggerReason
insight: string
read: boolean
log: InsightRecordLog
}
export interface InsightRecordSummary {
id: string
createdAt: number
sessionId: string
displayName: string
avatarUrl?: string
triggerReason: InsightRecordTriggerReason
insight: string
read: boolean
}
export interface InsightRecordContactFacet {
sessionId: string
displayName: string
avatarUrl?: string
count: number
}
export interface InsightRecordFilters {
keyword?: string
sessionId?: string
startTime?: number
endTime?: number
limit?: number
offset?: number
}
export interface InsightRecordListResult {
success: boolean
records: InsightRecordSummary[]
total: number
todayCount: number
unreadCount: number
contacts: InsightRecordContactFacet[]
error?: string
}
class InsightRecordService {
private readonly maxRecordsPerScope = 1000
private filePath: string | null = null
private loaded = false
private records: InsightRecord[] = []
private resolveFilePath(): string {
if (this.filePath) return this.filePath
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-insight-records.json')
return this.filePath
}
private ensureLoaded(): void {
if (this.loaded) return
this.loaded = true
const filePath = this.resolveFilePath()
try {
if (!fs.existsSync(filePath)) return
const raw = fs.readFileSync(filePath, 'utf-8')
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) {
this.records = parsed.filter((item) => item && typeof item === 'object') as InsightRecord[]
} else if (Array.isArray(parsed?.records)) {
this.records = parsed.records.filter((item: unknown) => item && typeof item === 'object') as InsightRecord[]
}
} catch {
this.records = []
}
}
private persist(): void {
try {
const filePath = this.resolveFilePath()
fs.writeFileSync(filePath, JSON.stringify({ version: 1, records: this.records }, null, 2), 'utf-8')
} catch {
// Keep insight generation non-blocking even if local persistence fails.
}
}
private getCurrentAccountScope(): string {
const config = ConfigService.getInstance()
const myWxid = String(config.getMyWxidCleaned() || '').trim()
if (myWxid) return `wxid:${myWxid}`
const dbPath = String(config.get('dbPath') || '').trim()
if (dbPath) {
const hash = createHash('sha1').update(dbPath).digest('hex').slice(0, 16)
return `db:${hash}`
}
return 'default'
}
private getStartOfToday(): number {
const date = new Date()
date.setHours(0, 0, 0, 0)
return date.getTime()
}
private toSummary(record: InsightRecord): InsightRecordSummary {
return {
id: record.id,
createdAt: record.createdAt,
sessionId: record.sessionId,
displayName: record.displayName,
avatarUrl: record.avatarUrl,
triggerReason: record.triggerReason,
insight: record.insight,
read: record.read
}
}
private getScopedRecords(): InsightRecord[] {
this.ensureLoaded()
const scope = this.getCurrentAccountScope()
return this.records.filter((record) => record.accountScope === scope)
}
addRecord(input: {
sessionId: string
displayName: string
avatarUrl?: string
triggerReason: InsightRecordTriggerReason
insight: string
log: InsightRecordLog
}): InsightRecord {
this.ensureLoaded()
const scope = this.getCurrentAccountScope()
const now = Date.now()
const record: InsightRecord = {
id: randomUUID(),
accountScope: scope,
createdAt: now,
sessionId: input.sessionId,
displayName: input.displayName,
avatarUrl: input.avatarUrl,
triggerReason: input.triggerReason,
insight: input.insight,
read: false,
log: input.log
}
this.records.push(record)
const scopedRecords = this.records
.filter((item) => item.accountScope === scope)
.sort((a, b) => b.createdAt - a.createdAt)
const keepIds = new Set(scopedRecords.slice(0, this.maxRecordsPerScope).map((item) => item.id))
this.records = this.records.filter((item) => item.accountScope !== scope || keepIds.has(item.id))
this.persist()
return record
}
listRecords(filters: InsightRecordFilters = {}): InsightRecordListResult {
try {
const allScoped = this.getScopedRecords()
const todayStart = this.getStartOfToday()
const contactsMap = new Map<string, InsightRecordContactFacet>()
for (const record of allScoped) {
const existing = contactsMap.get(record.sessionId)
if (existing) {
existing.count += 1
} else {
contactsMap.set(record.sessionId, {
sessionId: record.sessionId,
displayName: record.displayName,
avatarUrl: record.avatarUrl,
count: 1
})
}
}
const keyword = String(filters.keyword || '').trim().toLowerCase()
const sessionId = String(filters.sessionId || '').trim()
const startTime = Number(filters.startTime || 0)
const endTime = Number(filters.endTime || 0)
const offset = Math.max(0, Math.floor(Number(filters.offset || 0)))
const limit = Math.min(200, Math.max(1, Math.floor(Number(filters.limit || 100))))
const filtered = allScoped
.filter((record) => {
if (sessionId && record.sessionId !== sessionId) return false
if (startTime > 0 && record.createdAt < startTime) return false
if (endTime > 0 && record.createdAt > endTime) return false
if (keyword) {
const haystack = `${record.displayName}\n${record.sessionId}\n${record.insight}`.toLowerCase()
if (!haystack.includes(keyword)) return false
}
return true
})
.sort((a, b) => b.createdAt - a.createdAt)
return {
success: true,
records: filtered.slice(offset, offset + limit).map((record) => this.toSummary(record)),
total: filtered.length,
todayCount: allScoped.filter((record) => record.createdAt >= todayStart).length,
unreadCount: allScoped.filter((record) => !record.read).length,
contacts: Array.from(contactsMap.values()).sort((a, b) => b.count - a.count)
}
} catch (error) {
return {
success: false,
records: [],
total: 0,
todayCount: 0,
unreadCount: 0,
contacts: [],
error: (error as Error).message
}
}
}
getRecord(id: string): { success: boolean; record?: InsightRecord; error?: string } {
this.ensureLoaded()
const normalizedId = String(id || '').trim()
if (!normalizedId) return { success: false, error: '记录 ID 为空' }
const scope = this.getCurrentAccountScope()
const record = this.records.find((item) => item.id === normalizedId && item.accountScope === scope)
if (!record) return { success: false, error: '未找到该见解记录' }
return { success: true, record }
}
markRecordRead(id: string): { success: boolean; error?: string } {
this.ensureLoaded()
const normalizedId = String(id || '').trim()
const scope = this.getCurrentAccountScope()
const record = this.records.find((item) => item.id === normalizedId && item.accountScope === scope)
if (!record) return { success: false, error: '未找到该见解记录' }
if (!record.read) {
record.read = true
this.persist()
}
return { success: true }
}
clearRecords(filters: InsightRecordFilters = {}): { success: boolean; removed: number; error?: string } {
this.ensureLoaded()
const scope = this.getCurrentAccountScope()
const sessionId = String(filters.sessionId || '').trim()
const startTime = Number(filters.startTime || 0)
const endTime = Number(filters.endTime || 0)
let removed = 0
this.records = this.records.filter((record) => {
if (record.accountScope !== scope) return true
if (sessionId && record.sessionId !== sessionId) return true
if (startTime > 0 && record.createdAt < startTime) return true
if (endTime > 0 && record.createdAt > endTime) return true
removed += 1
return false
})
this.persist()
return { success: true, removed }
}
}
export const insightRecordService = new InsightRecordService()

View File

@@ -1,4 +1,4 @@
/**
/**
* insightService.ts
*
* AI 见解后台服务:
@@ -10,15 +10,18 @@
* 设计原则:
* - 不引入任何额外 npm 依赖,使用 Node 原生 https 模块调用 OpenAI 兼容 API
* - 所有失败静默处理,不影响主流程
* - 当日触发记录sessionId + 时间列表)随 prompt 一起发送,让模型自行判断是否克制
* - 触发频率、冷却与名单过滤均在本地完成,不把调度统计塞进模型 prompt
*/
import https from 'https'
import http from 'http'
import { URL } from 'url'
import { Notification } from 'electron'
import { ConfigService } from './config'
import { chatService, ChatSession, Message } from './chatService'
import { snsService } from './snsService'
import { weiboService } from './social/weiboService'
import { showNotification } from '../windows/notificationWindow'
import { insightRecordService, type InsightRecordLog, type InsightRecordTriggerReason } from './insightRecordService'
// ─── 常量 ────────────────────────────────────────────────────────────────────
@@ -33,12 +36,30 @@ const SILENCE_SCAN_INITIAL_DELAY_MS = 3 * 60 * 1000
/** 单次 API 请求超时(毫秒) */
const API_TIMEOUT_MS = 45_000
const API_MAX_TOKENS_DEFAULT = 1024
const API_MAX_TOKENS_MIN = 1
const API_MAX_TOKENS_MAX = 2_000_000
const API_TEMPERATURE = 0.7
const INSIGHT_NOTIFICATION_AVATAR_URL = './assets/insight/AI_Insight.png'
/** 沉默天数阈值默认值 */
const DEFAULT_SILENCE_DAYS = 3
const INSIGHT_CONFIG_KEYS = new Set([
'aiInsightEnabled',
'aiInsightScanIntervalHours',
'aiModelApiBaseUrl',
'aiModelApiKey',
'aiModelApiModel',
'aiModelApiMaxTokens',
'aiInsightFilterMode',
'aiInsightFilterList',
'aiInsightAllowMomentsContext',
'aiInsightMomentsContextCount',
'aiInsightMomentsBindings',
'aiInsightAllowSocialContext',
'aiInsightSocialContextCount',
'aiInsightWeiboCookie',
'aiInsightWeiboBindings',
'dbPath',
'decryptKey',
'myWxid'
@@ -51,17 +72,37 @@ interface TodayTriggerRecord {
timestamps: number[]
}
interface SharedAiModelConfig {
apiBaseUrl: string
apiKey: string
model: string
maxTokens: number
}
type InsightFilterMode = 'whitelist' | 'blacklist'
// ─── 日志 ─────────────────────────────────────────────────────────────────────
type InsightLogLevel = 'INFO' | 'WARN' | 'ERROR'
function insightDebugLine(_level: InsightLogLevel, _message: string): void {
// Desktop debug log export has been replaced by per-insight request logs.
}
function insightDebugSection(_level: InsightLogLevel, _title: string, _payload: unknown): void {
// Desktop debug log export has been replaced by per-insight request logs.
}
/**
* 仅输出到 console不落盘到文件。
*/
function insightLog(level: 'INFO' | 'WARN' | 'ERROR', message: string): void {
function insightLog(level: InsightLogLevel, message: string): void {
if (level === 'ERROR' || level === 'WARN') {
console.warn(`[InsightService] ${message}`)
} else {
console.log(`[InsightService] ${message}`)
}
insightDebugLine(level, message)
}
// ─── 工具函数 ─────────────────────────────────────────────────────────────────
@@ -94,6 +135,32 @@ function formatTimestamp(ts: number): string {
return new Date(ts).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
}
function formatPromptCurrentTime(date: Date = new Date()): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `当前系统时间:${year}${month}${day}${hours}:${minutes}`
}
function appendPromptCurrentTime(prompt: string): string {
const base = String(prompt || '').trimEnd()
if (!base) return formatPromptCurrentTime()
return `${base}\n\n${formatPromptCurrentTime()}`
}
function normalizeApiMaxTokens(value: unknown): number {
const numeric = Number(value)
if (!Number.isFinite(numeric)) return API_MAX_TOKENS_DEFAULT
return Math.min(API_MAX_TOKENS_MAX, Math.max(API_MAX_TOKENS_MIN, Math.floor(numeric)))
}
function normalizeSessionIdList(value: unknown): string[] {
if (!Array.isArray(value)) return []
return Array.from(new Set(value.map((item) => String(item || '').trim()).filter(Boolean)))
}
/**
* 调用 OpenAI 兼容 API非流式返回模型第一条消息内容。
* 使用 Node 原生 https/http 模块,无需任何第三方 SDK。
@@ -103,7 +170,8 @@ function callApi(
apiKey: string,
model: string,
messages: Array<{ role: string; content: string }>,
timeoutMs: number = API_TIMEOUT_MS
timeoutMs: number = API_TIMEOUT_MS,
maxTokens: number = API_MAX_TOKENS_DEFAULT
): Promise<string> {
return new Promise((resolve, reject) => {
const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions')
@@ -118,8 +186,8 @@ function callApi(
const body = JSON.stringify({
model,
messages,
max_tokens: 200,
temperature: 0.7,
max_tokens: normalizeApiMaxTokens(maxTokens),
temperature: API_TEMPERATURE,
stream: false
})
@@ -246,6 +314,10 @@ class InsightService {
if (!INSIGHT_CONFIG_KEYS.has(normalizedKey)) return
// 数据库相关配置变更后,丢弃缓存并强制下次重连
if (normalizedKey === 'aiInsightAllowSocialContext' || normalizedKey === 'aiInsightSocialContextCount' || normalizedKey === 'aiInsightWeiboCookie' || normalizedKey === 'aiInsightWeiboBindings') {
weiboService.clearCache()
}
if (normalizedKey === 'dbPath' || normalizedKey === 'decryptKey' || normalizedKey === 'myWxid') {
this.clearRuntimeCache()
}
@@ -278,6 +350,7 @@ class InsightService {
this.lastSeenTimestamp.clear()
this.todayTriggers.clear()
this.todayDate = getStartOfDay()
weiboService.clearCache()
}
private clearTimers(): void {
@@ -316,28 +389,48 @@ class InsightService {
}
/**
* 测<EFBFBD><EFBFBD><EFBFBD> API 连接,返回 { success, message }。
* 测 API 连接,返回 { success, message }。
* 供设置页"测试连接"按钮调用。
*/
async testConnection(): Promise<{ success: boolean; message: string }> {
const apiBaseUrl = this.config.get('aiInsightApiBaseUrl') as string
const apiKey = this.config.get('aiInsightApiKey') as string
const model = (this.config.get('aiInsightApiModel') as string) || 'gpt-4o-mini'
const { apiBaseUrl, apiKey, model, maxTokens } = this.getSharedAiModelConfig()
if (!apiBaseUrl || !apiKey) {
return { success: false, message: '请先填写 API 地址和 API Key' }
}
try {
const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions')
const requestMessages = [{ role: 'user', content: '请回复"连接成功"四个字。' }]
insightDebugSection(
'INFO',
'AI 测试连接请求',
[
`Endpoint: ${endpoint}`,
`Model: ${model}`,
`Max Tokens: ${maxTokens}`,
'',
'用户提示词:',
requestMessages[0].content
].join('\n')
)
const result = await callApi(
apiBaseUrl,
apiKey,
model,
[{ role: 'user', content: '请回复"连接成功"四个字。' }],
15_000
requestMessages,
15_000,
maxTokens
)
insightDebugSection('INFO', 'AI 测试连接输出原文', result)
return { success: true, message: `连接成功,模型回复:${result.slice(0, 50)}` }
} catch (e) {
insightDebugSection(
'ERROR',
'AI 测试连接失败',
`错误信息:${(e as Error).message}\n\n堆栈\n${(e as Error).stack || '[无堆栈]'}`
)
return { success: false, message: `连接失败:${(e as Error).message}` }
}
}
@@ -348,8 +441,7 @@ class InsightService {
*/
async triggerTest(): Promise<{ success: boolean; message: string }> {
insightLog('INFO', '手动触发测试见解...')
const apiBaseUrl = this.config.get('aiInsightApiBaseUrl') as string
const apiKey = this.config.get('aiInsightApiKey') as string
const { apiBaseUrl, apiKey } = this.getSharedAiModelConfig()
if (!apiBaseUrl || !apiKey) {
return { success: false, message: '请先填写 API 地址和 Key' }
}
@@ -368,7 +460,7 @@ class InsightService {
return id && !id.endsWith('@chatroom') && !id.toLowerCase().includes('placeholder') && this.isSessionAllowed(id)
})
if (!session) {
return { success: false, message: '未找到任何私聊会话(若已启用白名单,请检查是否有勾选的私聊' }
return { success: false, message: '未找到任何可触发的私聊会话(请检查黑白名单模式与选择列表' }
}
const sessionId = session.username?.trim() || ''
const displayName = session.displayName || sessionId
@@ -376,9 +468,15 @@ class InsightService {
await this.generateInsightForSession({
sessionId,
displayName,
triggerReason: 'activity'
triggerReason: 'test'
})
return { success: true, message: `已向「${displayName}」发送测试见解,请查看右下角弹窗` }
const notificationEnabled = this.config.get('aiInsightNotificationEnabled') !== false
return {
success: true,
message: notificationEnabled
? `已向「${displayName}」发送测试见解,请查看通知弹窗`
: `已生成「${displayName}」的测试见解AI 见解消息通知当前已关闭`
}
} catch (e) {
return { success: false, message: `测试失败:${(e as Error).message}` }
}
@@ -398,22 +496,245 @@ class InsightService {
return result
}
async generateFootprintInsight(params: {
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 }>
}): Promise<{ success: boolean; message: string; insight?: string }> {
const enabled = this.config.get('aiFootprintEnabled') === true
if (!enabled) {
return { success: false, message: '请先在设置中开启「AI 足迹总结」' }
}
const { apiBaseUrl, apiKey, model, maxTokens } = this.getSharedAiModelConfig()
if (!apiBaseUrl || !apiKey) {
return { success: false, message: '请先填写通用 AI 模型配置API 地址和 Key' }
}
const summary = params?.summary || {}
const rangeLabel = String(params?.rangeLabel || '').trim() || '当前范围'
const privateSegments = Array.isArray(params?.privateSegments) ? params.privateSegments.slice(0, 6) : []
const mentionGroups = Array.isArray(params?.mentionGroups) ? params.mentionGroups.slice(0, 6) : []
const topPrivateText = privateSegments.length > 0
? privateSegments
.map((item, idx) => {
const name = String(item.displayName || item.session_id || `联系人${idx + 1}`).trim()
const inbound = Number(item.incoming_count) || 0
const outbound = Number(item.outgoing_count) || 0
const total = Math.max(Number(item.message_count) || 0, inbound + outbound)
return `${idx + 1}. ${name}(收${inbound}/发${outbound}/总${total}${item.replied ? '/已回复' : ''}`
})
.join('\n')
: '无'
const topMentionText = mentionGroups.length > 0
? mentionGroups
.map((item, idx) => {
const name = String(item.displayName || item.session_id || `群聊${idx + 1}`).trim()
const count = Number(item.count) || 0
return `${idx + 1}. ${name}@我 ${count} 次)`
})
.join('\n')
: '无'
const defaultSystemPrompt = `你是用户的聊天足迹教练,负责基于统计数据给出一段简明复盘。
要求:
1. 输出 2-3 句,总长度不超过 180 字。
2. 必须包含:总体观察 + 一个可执行建议。
3. 语气务实,不夸张,不使用 Markdown。`
const customPrompt = String(this.config.get('aiFootprintSystemPrompt') || '').trim()
const systemPrompt = customPrompt || defaultSystemPrompt
const userPromptBase = `统计范围:${rangeLabel}
有聊天的人数:${Number(summary.private_inbound_people) || 0}
我有回复的人数:${Number(summary.private_outbound_people) || 0}
回复率:${(((Number(summary.private_reply_rate) || 0) * 100)).toFixed(1)}%
@我次数:${Number(summary.mention_count) || 0}
涉及群聊:${Number(summary.mention_group_count) || 0}
私聊重点:
${topPrivateText}
群聊@我重点:
${topMentionText}
请给出足迹复盘2-3句含建议`
const userPrompt = appendPromptCurrentTime(userPromptBase)
try {
const result = await callApi(
apiBaseUrl,
apiKey,
model,
[
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt }
],
25_000,
maxTokens
)
const insight = result.trim()
if (!insight) return { success: false, message: '模型返回为空' }
return { success: true, message: '生成成功', insight }
} catch (error) {
return { success: false, message: `生成失败:${(error as Error).message}` }
}
}
// ── 私有方法 ────────────────────────────────────────────────────────────────
private isEnabled(): boolean {
return this.config.get('aiInsightEnabled') === true
}
private getSharedAiModelConfig(): SharedAiModelConfig {
const apiBaseUrl = String(
this.config.get('aiModelApiBaseUrl')
|| this.config.get('aiInsightApiBaseUrl')
|| ''
).trim()
const apiKey = String(
this.config.get('aiModelApiKey')
|| this.config.get('aiInsightApiKey')
|| ''
).trim()
const model = String(
this.config.get('aiModelApiModel')
|| this.config.get('aiInsightApiModel')
|| 'gpt-4o-mini'
).trim() || 'gpt-4o-mini'
const maxTokens = normalizeApiMaxTokens(this.config.get('aiModelApiMaxTokens'))
return { apiBaseUrl, apiKey, model, maxTokens }
}
private looksLikeWxid(text: string): boolean {
const normalized = String(text || '').trim()
if (!normalized) return false
return /^wxid_[a-z0-9]+$/i.test(normalized)
|| /^[a-z0-9_]+@chatroom$/i.test(normalized)
}
private looksLikeXmlPayload(text: string): boolean {
const normalized = String(text || '').trim()
if (!normalized) return false
return /^(<\?xml|<msg\b|<appmsg\b|<img\b|<emoji\b|<voip\b|<sysmsg\b|&lt;\?xml|&lt;msg\b|&lt;appmsg\b)/i.test(normalized)
}
private normalizeInsightText(text: string): string {
return String(text || '')
.replace(/\r\n/g, '\n')
.replace(/\u0000/g, '')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
private formatInsightMessageTimestamp(createTime: number): string {
const ms = createTime > 1_000_000_000_000 ? createTime : createTime * 1000
const date = new Date(ms)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
private async resolveInsightSessionDisplayName(sessionId: string, fallbackDisplayName: string): Promise<string> {
const fallback = String(fallbackDisplayName || '').trim()
if (fallback && !this.looksLikeWxid(fallback)) {
return fallback
}
try {
const sessions = await this.getSessionsCached()
const matched = sessions.find((session) => String(session.username || '').trim() === sessionId)
const cachedDisplayName = String(matched?.displayName || '').trim()
if (cachedDisplayName && !this.looksLikeWxid(cachedDisplayName)) {
return cachedDisplayName
}
} catch {
// ignore display name lookup failures
}
try {
const contact = await chatService.getContactAvatar(sessionId)
const contactDisplayName = String(contact?.displayName || '').trim()
if (contactDisplayName && !this.looksLikeWxid(contactDisplayName)) {
return contactDisplayName
}
} catch {
// ignore display name lookup failures
}
return fallback || sessionId
}
private formatInsightMessageContent(message: Message): string {
const parsedContent = this.normalizeInsightText(String(message.parsedContent || ''))
const quotedPreview = this.normalizeInsightText(String(message.quotedContent || ''))
const quotedSender = this.normalizeInsightText(String(message.quotedSender || ''))
if (quotedPreview) {
const cleanQuotedSender = quotedSender && !this.looksLikeWxid(quotedSender) ? quotedSender : ''
const quoteLabel = cleanQuotedSender ? `${cleanQuotedSender}${quotedPreview}` : quotedPreview
const replyText = parsedContent && parsedContent !== '[引用消息]' ? parsedContent : ''
return replyText ? `${replyText}[引用 ${quoteLabel}]` : `[引用 ${quoteLabel}]`
}
if (parsedContent) {
return parsedContent
}
const rawContent = this.normalizeInsightText(String(message.rawContent || ''))
if (rawContent && !this.looksLikeXmlPayload(rawContent)) {
return rawContent
}
return '[其他消息]'
}
private buildInsightContextSection(messages: Message[], peerDisplayName: string): string {
if (!messages.length) return ''
const lines = messages.map((message) => {
const senderName = message.isSend === 1 ? '我' : peerDisplayName
const content = this.formatInsightMessageContent(message)
return `${this.formatInsightMessageTimestamp(message.createTime)} '${senderName}'\n${content}`
})
return `近期聊天记录(最近 ${lines.length} 条):\n\n${lines.join('\n\n')}`
}
/**
* 判断某个会话是否允许触发见解。
* 若白名单未启用,则所有私聊会话均允许;
* 若白名单已启用,则只有在白名单中的会话才允许
* white/black 模式二选一:
* - whitelist仅名单内允许
* - blacklist名单内屏蔽其他允许
*/
private getInsightFilterConfig(): { mode: InsightFilterMode; list: string[] } {
const modeRaw = String(this.config.get('aiInsightFilterMode') || '').trim().toLowerCase()
const mode: InsightFilterMode = modeRaw === 'blacklist' ? 'blacklist' : 'whitelist'
const list = normalizeSessionIdList(this.config.get('aiInsightFilterList'))
return { mode, list }
}
private isSessionAllowed(sessionId: string): boolean {
const whitelistEnabled = this.config.get('aiInsightWhitelistEnabled') as boolean
if (!whitelistEnabled) return true
const whitelist = (this.config.get('aiInsightWhitelist') as string[]) || []
return whitelist.includes(sessionId)
const normalizedSessionId = String(sessionId || '').trim()
if (!normalizedSessionId) return false
const { mode, list } = this.getInsightFilterConfig()
if (mode === 'whitelist') return list.includes(normalizedSessionId)
return !list.includes(normalizedSessionId)
}
/**
@@ -464,26 +785,108 @@ class InsightService {
}
/**
* 记录触发并返回该会话今日所有触发时间(用于组装 prompt
* 记录成功推送的见解,用于设置页展示今日触发统计
*/
private recordTrigger(sessionId: string): string[] {
private recordTrigger(sessionId: string): void {
this.resetIfNewDay()
const existing = this.todayTriggers.get(sessionId) ?? { timestamps: [] }
existing.timestamps.push(Date.now())
this.todayTriggers.set(sessionId, existing)
return existing.timestamps.map(formatTimestamp)
}
/**
* 获取今日全局已触发次数(所有会话合计),用于 prompt 中告知模<E79FA5><E6A8A1><EFBFBD>全局上下文。
*/
private getTodayTotalTriggerCount(): number {
this.resetIfNewDay()
let total = 0
for (const record of this.todayTriggers.values()) {
total += record.timestamps.length
private formatWeiboTimestamp(raw: string): string {
const parsed = Date.parse(String(raw || ''))
if (!Number.isFinite(parsed)) {
return String(raw || '').trim()
}
return new Date(parsed).toLocaleString('zh-CN')
}
private formatMomentsTimestamp(raw: unknown): string {
const numeric = Number(raw)
if (!Number.isFinite(numeric) || numeric <= 0) {
return ''
}
const ms = numeric > 1_000_000_000_000 ? numeric : numeric * 1000
return new Date(ms).toLocaleString('zh-CN')
}
private extractMomentReadableText(post: { contentDesc?: unknown; linkTitle?: unknown }): string {
const contentDesc = this.normalizeInsightText(String(post.contentDesc || '')).replace(/\s+/g, ' ').trim()
if (contentDesc) return contentDesc
const linkTitle = this.normalizeInsightText(String(post.linkTitle || '')).replace(/\s+/g, ' ').trim()
if (linkTitle) return `[链接] ${linkTitle}`
return ''
}
private async getMomentsContextSection(sessionId: string): Promise<string> {
const allowMomentsContext = this.config.get('aiInsightAllowMomentsContext') === true
if (!allowMomentsContext) return ''
const bindings =
(this.config.get('aiInsightMomentsBindings') as Record<string, { enabled?: boolean }> | undefined) || {}
const isEnabledForSession = bindings[sessionId]?.enabled === true
if (!isEnabledForSession) return ''
const countRaw = Number(this.config.get('aiInsightMomentsContextCount') || 5)
const momentsCount = Math.max(1, Math.min(20, Math.floor(countRaw) || 5))
try {
const result = await snsService.getTimeline(momentsCount, 0, [sessionId])
const posts = result.success && Array.isArray(result.timeline) ? result.timeline : []
if (posts.length === 0) return ''
const lines = posts
.map((post) => {
const text = this.extractMomentReadableText(post as { contentDesc?: unknown; linkTitle?: unknown })
if (!text) return ''
const shortText = text.length > 180 ? `${text.slice(0, 180)}...` : text
const time = this.formatMomentsTimestamp((post as { createTime?: unknown }).createTime)
return time ? `[朋友圈 ${time}] ${shortText}` : `[朋友圈] ${shortText}`
})
.filter(Boolean) as string[]
if (lines.length === 0) return ''
insightLog('INFO', `已加载 ${lines.length} 条朋友圈内容 (sessionId=${sessionId})`)
return `近期朋友圈内容(最近 ${lines.length} 条):\n${lines.join('\n')}`
} catch (error) {
insightLog('WARN', `拉取朋友圈内容失败 (sessionId=${sessionId}): ${(error as Error).message}`)
return ''
}
}
private async getSocialContextSection(sessionId: string): Promise<string> {
const allowSocialContext = this.config.get('aiInsightAllowSocialContext') === true
if (!allowSocialContext) return ''
const rawCookie = String(this.config.get('aiInsightWeiboCookie') || '').trim()
const bindings =
(this.config.get('aiInsightWeiboBindings') as Record<string, { uid?: string; screenName?: string }> | undefined) || {}
const binding = bindings[sessionId]
const uid = String(binding?.uid || '').trim()
if (!uid) return ''
const socialCountRaw = Number(this.config.get('aiInsightSocialContextCount') || 3)
const socialCount = Math.max(1, Math.min(5, Math.floor(socialCountRaw) || 3))
try {
const posts = await weiboService.fetchRecentPosts(uid, rawCookie, socialCount)
if (posts.length === 0) return ''
const lines = posts.map((post) => {
const time = this.formatWeiboTimestamp(post.createdAt)
const text = post.text.length > 180 ? `${post.text.slice(0, 180)}...` : post.text
return `[微博 ${time}] ${text}`
})
insightLog('INFO', `已加载 ${lines.length} 条微博公开内容 (uid=${uid})`)
return `近期公开社交平台内容(来源:微博,最近 ${lines.length} 条):\n${lines.join('\n')}`
} catch (error) {
insightLog('WARN', `拉取微博公开内容失败 (uid=${uid}): ${(error as Error).message}`)
return ''
}
return total
}
// ── 沉默联系人扫描 ──────────────────────────────────────────────────────────
@@ -581,8 +984,8 @@ class InsightService {
* 1. 会话有真正的新消息lastTimestamp 比上次见到的更新)
* 2. 该会话距上次活跃分析已超过冷却期
*
* 白名单启用时:直接使用名单里的 sessionId完全跳过 getSessions()。
* 白名单未启用时:从缓存拉取全量会话后过滤私聊
* whitelist 模式:直接使用名单里的 sessionId完全跳过 getSessions()。
* blacklist 模式:从缓存拉取会话后过滤名单
*/
private async analyzeRecentActivity(): Promise<void> {
if (!this.isEnabled()) return
@@ -593,12 +996,11 @@ class InsightService {
const now = Date.now()
const cooldownMinutes = (this.config.get('aiInsightCooldownMinutes') as number) ?? 120
const cooldownMs = cooldownMinutes * 60 * 1000
const whitelistEnabled = this.config.get('aiInsightWhitelistEnabled') as boolean
const whitelist = (this.config.get('aiInsightWhitelist') as string[]) || []
const { mode: filterMode, list: filterList } = this.getInsightFilterConfig()
// 白名单启用且有勾选项时,直接用名单 sessionId无需查数据库全量会话列表。
// whitelist 模式且有勾选项时,直接用名单 sessionId无需查数据库全量会话列表。
// 通过拉取该会话最新 1 条消息时间戳判断是否真正有新消息,开销极低。
if (whitelistEnabled && whitelist.length > 0) {
if (filterMode === 'whitelist' && filterList.length > 0) {
// 确保数据库已连接(首次时连接,之后复用)
if (!this.dbConnected) {
const connectResult = await chatService.connect()
@@ -606,8 +1008,8 @@ class InsightService {
this.dbConnected = true
}
for (const sessionId of whitelist) {
if (!sessionId || sessionId.endsWith('@chatroom')) continue
for (const sessionId of filterList) {
if (!sessionId || sessionId.toLowerCase().includes('placeholder')) continue
// 冷却期检查(先过滤,减少不必要的 DB 查询)
if (cooldownMs > 0) {
@@ -644,16 +1046,22 @@ class InsightService {
return
}
// 白名单未启用:需要拉取全量会话列表,从中过滤私聊
if (filterMode === 'whitelist' && filterList.length === 0) {
insightLog('INFO', '白名单模式且名单为空,跳过活跃分析')
return
}
// blacklist 模式:拉取会话缓存后按过滤规则筛选
const sessions = await this.getSessionsCached()
if (sessions.length === 0) return
const privateSessions = sessions.filter((s) => {
const candidateSessions = sessions.filter((s) => {
const id = s.username?.trim() || ''
return id && !id.endsWith('@chatroom') && !id.toLowerCase().includes('placeholder')
if (!id || id.toLowerCase().includes('placeholder')) return false
return this.isSessionAllowed(id)
})
for (const session of privateSessions.slice(0, 10)) {
for (const session of candidateSessions.slice(0, 10)) {
const sessionId = session.username?.trim() || ''
if (!sessionId) continue
@@ -689,18 +1097,24 @@ class InsightService {
private async generateInsightForSession(params: {
sessionId: string
displayName: string
triggerReason: 'activity' | 'silence'
triggerReason: InsightRecordTriggerReason
silentDays?: number
}): Promise<void> {
const { sessionId, displayName, triggerReason, silentDays } = params
if (!sessionId) return
if (!this.isEnabled()) return
const apiBaseUrl = this.config.get('aiInsightApiBaseUrl') as string
const apiKey = this.config.get('aiInsightApiKey') as string
const model = (this.config.get('aiInsightApiModel') as string) || 'gpt-4o-mini'
const { apiBaseUrl, apiKey, model, maxTokens } = this.getSharedAiModelConfig()
const allowContext = this.config.get('aiInsightAllowContext') as boolean
const contextCount = (this.config.get('aiInsightContextCount') as number) || 40
const resolvedDisplayName = await this.resolveInsightSessionDisplayName(sessionId, displayName)
let resolvedAvatarUrl: string | undefined
try {
const contact = await chatService.getContactAvatar(sessionId)
resolvedAvatarUrl = String(contact?.avatarUrl || '').trim() || undefined
} catch {
resolvedAvatarUrl = undefined
}
insightLog('INFO', `generateInsightForSession: sessionId=${sessionId}, reason=${triggerReason}, contextCount=${contextCount}, api=${apiBaseUrl ? '已配置' : '未配置'}`)
@@ -709,11 +1123,7 @@ class InsightService {
return
}
// ── 构建 prompt ─────────────<EFBFBD><EFBFBD><EFBFBD>───────────────────────────────<EFBFBD><EFBFBD><EFBFBD>────────────
// 今日触发统计(让模型具备时间与克制感)
const sessionTriggerTimes = this.recordTrigger(sessionId)
const totalTodayTriggers = this.getTodayTotalTriggerCount()
// ── 构建 prompt ────────────────────────────────────────────────────────────
let contextSection = ''
if (allowContext) {
@@ -721,20 +1131,17 @@ class InsightService {
const msgsResult = await chatService.getLatestMessages(sessionId, contextCount)
if (msgsResult.success && msgsResult.messages && msgsResult.messages.length > 0) {
const messages: Message[] = msgsResult.messages
const msgLines = messages.map((m) => {
const sender = m.isSend === 1 ? '我' : (displayName || sessionId)
const content = m.rawContent || m.parsedContent || '[非文字消息]'
const time = new Date(Number(m.createTime) * 1000).toLocaleString('zh-CN')
return `[${time}] ${sender}${content}`
})
contextSection = `\n\n近期对话记录最近 ${msgLines.length} 条):\n${msgLines.join('\n')}`
insightLog('INFO', `已加载 ${msgLines.length} 条上下文消息`)
contextSection = this.buildInsightContextSection(messages, resolvedDisplayName)
insightLog('INFO', `已加载 ${messages.length} 条上下文消息`)
}
} catch (e) {
insightLog('WARN', `拉取上下文失败: ${(e as Error).message}`)
}
}
const momentsContextSection = await this.getMomentsContextSection(sessionId)
const socialContextSection = await this.getSocialContextSection(sessionId)
// ── 默认 system prompt稳定内容有利于 provider 端 prompt cache 命中)────
const DEFAULT_SYSTEM_PROMPT = `你是用户的私人关系观察助手,名叫"见解"。你的任务是主动提供有价值的观察和建议。
@@ -748,59 +1155,106 @@ class InsightService {
const customPrompt = (this.config.get('aiInsightSystemPrompt') as string) || ''
const systemPrompt = customPrompt.trim() || DEFAULT_SYSTEM_PROMPT
// 可变的上下文统计信息放在 user message 里,保持 system prompt 稳定不变
// 这样 provider 端Anthropic/OpenAI能最大化命中 prompt cache降低费用
const triggerDesc =
triggerReason === 'silence'
? `你已经 ${silentDays} 天没有和「${displayName}」聊天了。`
: `你最近和「${displayName}」有新的聊天动态。`
const todayStatsDesc =
sessionTriggerTimes.length > 1
? `今天你已经针对「${displayName}」收到过 ${sessionTriggerTimes.length - 1} 条见解(时间:${sessionTriggerTimes.slice(0, -1).join('、')}),请适当克制。`
: `今天你还没有针对「${displayName}」发出过见解。`
const globalStatsDesc = `今天全部联系人合计已触发 ${totalTodayTriggers} 条见解。`
const userPrompt = `触发原因:${triggerDesc}
时间统计:${todayStatsDesc} ${globalStatsDesc}${contextSection}
请给出你的见解≤80字`
const userPromptBase = [
triggerReason === 'silence' && silentDays
? `${silentDays} 天未联系「${resolvedDisplayName}」。`
: '',
contextSection,
momentsContextSection,
socialContextSection,
'请给出你的见解≤80字'
].filter(Boolean).join('\n\n')
const userPrompt = appendPromptCurrentTime(userPromptBase)
const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions')
const requestMessages = [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt }
]
insightLog('INFO', `准备调用 API: ${endpoint},模型: ${model}`)
insightDebugSection(
'INFO',
`AI 请求 ${resolvedDisplayName} (${sessionId})`,
[
`接口地址:${endpoint}`,
`模型:${model}`,
`Max Tokens${maxTokens}`,
`触发类型:${triggerReason}`,
`上下文开关:${allowContext ? '开启' : '关闭'}`,
`上下文条数:${contextCount}`,
'',
'系统提示词:',
systemPrompt,
'',
'用户提示词:',
userPrompt
].join('\n')
)
try {
const apiStartedAt = Date.now()
const result = await callApi(
apiBaseUrl,
apiKey,
model,
[
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt }
]
requestMessages,
API_TIMEOUT_MS,
maxTokens
)
const apiDurationMs = Date.now() - apiStartedAt
insightLog('INFO', `API 返回原文: ${result.slice(0, 150)}`)
insightDebugSection('INFO', `AI 输出原文 ${resolvedDisplayName} (${sessionId})`, result)
// 模型主动选择跳过
if (result.trim().toUpperCase() === 'SKIP' || result.trim().startsWith('SKIP')) {
insightLog('INFO', `模型选择跳过 ${displayName}`)
insightLog('INFO', `模型选择跳过 ${resolvedDisplayName}`)
return
}
if (!this.isEnabled()) return
const insight = result.slice(0, 120)
const notifTitle = `见解 · ${displayName}`
const insight = result.trim()
const notifTitle = `见解 · ${resolvedDisplayName}`
const recordLog: InsightRecordLog = {
endpoint,
model,
maxTokens,
temperature: API_TEMPERATURE,
triggerReason,
allowContext,
contextCount,
systemPrompt,
userPrompt,
rawOutput: result,
finalInsight: insight,
durationMs: apiDurationMs,
createdAt: Date.now()
}
const record = insightRecordService.addRecord({
sessionId,
displayName: resolvedDisplayName,
avatarUrl: resolvedAvatarUrl,
triggerReason,
insight,
log: recordLog
})
insightLog('INFO', `推送通知 → ${displayName}: ${insight}`)
const insightNotificationEnabled = this.config.get('aiInsightNotificationEnabled') !== false
if (insightNotificationEnabled) {
insightLog('INFO', `推送通知 → ${resolvedDisplayName}: ${insight}`)
// 渠道一:Electron 原生系统通知
if (Notification.isSupported()) {
const notif = new Notification({ title: notifTitle, body: insight, silent: false })
notif.show()
// 渠道一:应用内通知窗口。AI 见解使用独立通知开关,不受新消息通知开关和会话过滤影响。
await showNotification({
title: notifTitle,
content: insight,
avatarUrl: INSIGHT_NOTIFICATION_AVATAR_URL,
sessionId,
insightRecordId: record.id,
channel: 'ai-insight'
})
} else {
insightLog('WARN', '当前系统不支持原生通知')
insightLog('INFO', `AI 见解消息通知已关闭,跳过应用通知 → ${resolvedDisplayName}: ${insight}`)
}
// 渠道二Telegram Bot 推送(可选)
@@ -821,9 +1275,15 @@ class InsightService {
}
}
insightLog('INFO', ` ${displayName} 推送见解`)
insightLog('INFO', `完成 ${resolvedDisplayName} 的见解处理`)
this.recordTrigger(sessionId)
} catch (e) {
insightLog('ERROR', `API 调用失败 (${displayName}): ${(e as Error).message}`)
insightDebugSection(
'ERROR',
`AI 请求失败 ${resolvedDisplayName} (${sessionId})`,
`错误信息:${(e as Error).message}\n\n堆栈\n${(e as Error).stack || '[无堆栈]'}`
)
insightLog('ERROR', `API 调用失败 (${resolvedDisplayName}): ${(e as Error).message}`)
}
}
@@ -869,3 +1329,5 @@ class InsightService {
}
export const insightService = new InsightService()

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'
@@ -814,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 未匹配,请确认账号目录后重试,或使用内存扫描' }
@@ -826,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,7 +11,7 @@ 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
@@ -98,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) {
@@ -152,7 +158,7 @@ export class KeyServiceLinux {
}
if (!pid) {
const err = '未能自动启动微信或获取PID失败请查看控制台日志或手动启动并登录。'
const err = '未能自动启动微信或获取PID失败请查看控制台日志或手动启动微信,看到登录窗口后点击确认。'
onStatus?.(err, 2)
return { success: false, error: err }
}
@@ -161,7 +167,7 @@ export class KeyServiceLinux {
await new Promise(r => setTimeout(r, 2000))
return await this.getDbKey(pid, onStatus)
return await this.getDbKey(pid, onStatus, timeoutMs)
} catch (err: any) {
console.error('[Debug] 自动获取流程彻底崩溃:', err);
const errMsg = '自动获取微信 PID 失败: ' + err.message
@@ -170,7 +176,7 @@ export class KeyServiceLinux {
}
}
public async getDbKey(pid: number, onStatus?: (message: string, level: number) => void): Promise<DbKeyResult> {
public async getDbKey(pid: number, onStatus?: (message: string, level: number) => void, timeoutMs = 180_000): Promise<DbKeyResult> {
try {
const helperPath = this.getHelperPath()
@@ -187,29 +193,63 @@ export class KeyServiceLinux {
const targetAddr = scanRes.target_addr
onStatus?.('基址扫描成功,正在请求管理员权限进行内存 Hook...', 0)
return await new Promise((resolve) => {
const options = { name: 'WeFlow' }
const command = `"${helperPath}" db_hook ${pid} ${targetAddr}`
if (!this.sudo || typeof this.sudo.exec !== 'function') {
const err = 'Linux 授权组件 @vscode/sudo-prompt 未加载,请确认依赖已安装并重新启动 WeFlow'
onStatus?.(err, 2)
return { success: false, error: err }
}
this.sudo.exec(command, options, (error, stdout) => {
return await new Promise((resolve) => {
const options = {
name: 'WeFlow',
env: {
PATH: `${process.env.PATH || ''}:/bin:/usr/bin:/sbin:/usr/sbin:/usr/local/bin`
}
}
const timeoutSec = Math.ceil((timeoutMs + 15_000) / 1000)
const command = `timeout -k 5s ${timeoutSec}s "${helperPath}" db_hook ${pid} ${targetAddr} ${timeoutMs}`
let settled = false
const finish = (result: DbKeyResult) => {
if (settled) return
settled = true
clearTimeout(watchdog)
resolve(result)
}
const watchdog = setTimeout(() => {
execAsync(`kill -CONT ${pid}`).catch(() => {})
const err = `Hook 等待超时(${Math.round(timeoutMs / 1000)} 秒)。请确认微信登录确认已完成,或重启微信后重试。`
onStatus?.(err, 2)
finish({ success: false, error: err })
}, timeoutMs + 30_000)
onStatus?.('授权通过后请在手机上确认登录微信,正在等待密钥回调...', 0)
this.sudo.exec(command, options, (error, stdout, stderr) => {
execAsync(`kill -CONT ${pid}`).catch(() => {})
if (error) {
onStatus?.('授权失败或被取消', 2)
resolve({ success: false, error: `授权失败或被取消: ${error.message}` })
const detail = String(stderr || '').trim()
const message = detail ? `${error.message}: ${detail}` : error.message
onStatus?.('授权失败或 Hook 执行失败', 2)
finish({ success: false, error: `授权失败或 Hook 执行失败: ${message}` })
return
}
try {
const hookRes = JSON.parse((stdout as string).trim())
const output = String(stdout || '').trim()
if (!output) {
const detail = String(stderr || '').trim()
throw new Error(detail ? `Hook 无输出: ${detail}` : 'Hook 无输出')
}
const hookRes = JSON.parse(output)
if (hookRes.success) {
onStatus?.('密钥获取成功', 1)
resolve({ success: true, key: hookRes.key })
finish({ success: true, key: hookRes.key })
} else {
onStatus?.(hookRes.result, 2)
resolve({ success: false, error: hookRes.result })
finish({ success: false, error: hookRes.result })
}
} catch (e) {
} catch (e: any) {
onStatus?.('解析 Hook 结果失败', 2)
resolve({ success: false, error: '解析 Hook 结果失败' })
finish({ success: false, error: e?.message || '解析 Hook 结果失败' })
}
})
})
@@ -238,7 +278,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) {
@@ -246,6 +293,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 {
@@ -24,6 +24,9 @@ export class KeyServiceMac {
private machVmReadOverwrite: any = null
private machPortDeallocate: any = null
private _needsElevation = false
private restrictedFailureCount = 0
private restrictedFailureAt = 0
private readonly restrictedFailureWindowMs = 8 * 60_000
private getHelperPath(): string {
const isPackaged = app.isPackaged
@@ -186,18 +189,25 @@ export class KeyServiceMac {
}
if (!parsed.success) {
const errorMsg = this.mapDbKeyErrorMessage(parsed.code, parsed.detail)
const errorMsg = this.enrichDbKeyErrorMessage(
this.mapDbKeyErrorMessage(parsed.code, parsed.detail),
parsed.code,
parsed.detail
)
onStatus?.(errorMsg, 2)
return { success: false, error: errorMsg }
}
this.resetRestrictedFailureState()
onStatus?.('密钥获取成功', 1)
return { success: true, key: parsed.key }
} catch (e: any) {
console.error('[KeyServiceMac] Error:', e)
console.error('[KeyServiceMac] Stack:', e.stack)
onStatus?.('获取失败: ' + e.message, 2)
return { success: false, error: e.message }
const rawError = `${e?.message || e || ''}`.trim()
const resolvedError = this.resolveUnexpectedDbKeyErrorMessage(rawError)
onStatus?.(resolvedError, 2)
return { success: false, error: resolvedError }
}
}
@@ -223,6 +233,149 @@ export class KeyServiceMac {
return this.parseDbKeyResult(helperResult)
}
private resetRestrictedFailureState(): void {
this.restrictedFailureCount = 0
this.restrictedFailureAt = 0
}
private markRestrictedFailureAndGetCount(): number {
const now = Date.now()
if (now - this.restrictedFailureAt > this.restrictedFailureWindowMs) {
this.restrictedFailureCount = 0
}
this.restrictedFailureAt = now
this.restrictedFailureCount += 1
return this.restrictedFailureCount
}
private isRestrictedEnvironmentFailure(code?: string, detail?: string): boolean {
const normalizedCode = String(code || '').toUpperCase()
const normalizedDetail = String(detail || '').toLowerCase()
if (!normalizedCode && !normalizedDetail) return false
if (normalizedCode === 'SCAN_FAILED') {
return normalizedDetail.includes('sink pattern not found')
|| normalizedDetail.includes('no suitable module found')
}
if (normalizedCode === 'HOOK_FAILED') {
return normalizedDetail.includes('patch_breakpoint_failed')
|| normalizedDetail.includes('thread_get_state_failed')
|| normalizedDetail.includes('native hook failed')
}
if (normalizedCode === 'ATTACH_FAILED') {
return normalizedDetail.includes('task_for_pid:5')
|| normalizedDetail.includes('thread_get_state_failed')
}
return normalizedDetail.includes('patch_breakpoint_failed')
|| normalizedDetail.includes('thread_get_state_failed')
|| normalizedDetail.includes('sink pattern not found')
|| normalizedDetail.includes('no suitable module found')
}
private getMacRecoveryHint(isRepeatedFailure: boolean): string {
const steps = isRepeatedFailure
? '建议步骤:彻底退出微信 -> 重启电脑(冷启动)-> 降级微信到 4.1.7 -> 仅尝试一次自动获取 -> 成功后再升级微信。'
: '建议步骤:降级微信到 4.1.7 -> 重启电脑(冷启动)-> 自动获取密钥 -> 成功后再升级微信。'
return `${steps}\n请不要连续重试以免触发微信安全模式或系统内存保护。`
}
private simplifyDbKeyDetail(detail?: string): string {
const raw = String(detail || '')
.replace(/^WF_OK::/i, '')
.replace(/^WF_ERR::/i, '')
.replace(/\r?\n/g, ' ')
.replace(/\s+/g, ' ')
.trim()
if (!raw) return ''
const keys = [
'No suitable module found',
'Sink pattern not found',
'patch_breakpoint_failed',
'thread_get_state_failed',
'task_for_pid:5',
'attach_wait_timeout',
'HOOK_TIMEOUT',
'FRIDA_TIMEOUT'
]
for (const key of keys) {
if (raw.includes(key)) return key
}
const stripped = raw
.replace(/\[xkey_helper\]/gi, ' ')
.replace(/\[debug\]/gi, ' ')
.replace(/\[\*\]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
if (!stripped) return ''
return stripped.length > 140 ? `${stripped.slice(0, 140)}...` : stripped
}
private extractDbKeyErrorFromAnyText(text?: string): { code?: string; detail?: string } {
const raw = String(text || '')
if (!raw) return {}
const explicit = raw.match(/ERROR:([A-Z_]+):([^\r\n]*)/)
if (explicit) {
return {
code: explicit[1] || 'UNKNOWN',
detail: this.simplifyDbKeyDetail(explicit[2] || '')
}
}
if (raw.includes('No suitable module found')) {
return { code: 'SCAN_FAILED', detail: 'No suitable module found' }
}
if (raw.includes('Sink pattern not found')) {
return { code: 'SCAN_FAILED', detail: 'Sink pattern not found' }
}
if (raw.includes('patch_breakpoint_failed')) {
return { code: 'HOOK_FAILED', detail: 'patch_breakpoint_failed' }
}
if (raw.includes('thread_get_state_failed')) {
return { code: 'HOOK_FAILED', detail: 'thread_get_state_failed' }
}
if (raw.includes('task_for_pid:5')) {
return { code: 'ATTACH_FAILED', detail: 'task_for_pid:5' }
}
return {}
}
private resolveUnexpectedDbKeyErrorMessage(rawError?: string): string {
const text = String(rawError || '').trim()
const { code, detail } = this.extractDbKeyErrorFromAnyText(text)
if (code) {
const mapped = this.mapDbKeyErrorMessage(code, detail)
return this.enrichDbKeyErrorMessage(mapped, code, detail)
}
if (text.includes('helper timeout')) {
return '获取密钥超时:请保持微信前台并进行一次会话操作后重试。'
}
if (text.includes('helper returned empty output') || text.includes('invalid json')) {
return '获取失败helper 未返回可识别结果,请彻底退出微信后重启电脑再试。'
}
if (text.includes('xkey_helper not found')) {
return '获取失败:未找到 xkey_helper请重新安装 WeFlow 后重试。'
}
return '自动获取密钥失败:环境可能受限或版本暂未适配,请稍后重试。'
}
private enrichDbKeyErrorMessage(baseMessage: string, code?: string, detail?: string): string {
if (!this.isRestrictedEnvironmentFailure(code, detail)) return baseMessage
const failureCount = this.markRestrictedFailureAndGetCount()
if (failureCount >= 2) {
return `${baseMessage}\n检测到连续失败疑似已进入受限状态。请先彻底退出微信并重启电脑再按下方步骤处理。\n${this.getMacRecoveryHint(true)}`
}
return `${baseMessage}\n${this.getMacRecoveryHint(false)}`
}
private async getWeChatPid(): Promise<number> {
try {
// 优先使用 pgrep -x 精确匹配进程名
@@ -403,19 +556,71 @@ 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',
@@ -426,8 +631,6 @@ export class KeyServiceMac {
'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]), {
@@ -448,7 +651,12 @@ export class KeyServiceMac {
const errNum = parts[1] || 'unknown'
const errMsg = parts[2] || 'unknown'
const partial = parts.slice(3).join('::')
throw new Error(`elevated helper failed: errNum=${errNum}, errMsg=${errMsg}, partial=${partial || '(empty)'}`)
if (errNum === '-128' || String(errMsg).includes('User canceled')) {
throw new Error('User canceled')
}
const inferred = this.extractDbKeyErrorFromAnyText(`${errMsg}\n${partial}`)
if (inferred.code) return `ERROR:${inferred.code}:${inferred.detail || ''}`
throw new Error(`elevated helper failed: errNum=${errNum}, errMsg=${this.simplifyDbKeyDetail(errMsg) || 'unknown'}`)
}
const normalizedOutput = joined.startsWith('WF_OK::') ? joined.slice('WF_OK::'.length) : joined
@@ -470,40 +678,60 @@ export class KeyServiceMac {
// 其次找 result 字段
const resultPayload = allJson.find(p => typeof p?.result === 'string')
if (resultPayload) return resultPayload.result
throw new Error('elevated helper returned invalid json: ' + lines[lines.length - 1])
const inferred = this.extractDbKeyErrorFromAnyText(normalizedOutput)
if (inferred.code) return `ERROR:${inferred.code}:${inferred.detail || ''}`
throw new Error('elevated helper returned invalid output')
}
private mapDbKeyErrorMessage(code?: string, detail?: string): string {
const normalizedDetail = this.simplifyDbKeyDetail(detail)
if (code === 'PROCESS_NOT_FOUND') return '微信进程未运行'
if (code === 'ATTACH_FAILED') {
const isDevElectron = process.execPath.includes('/node_modules/electron/')
if ((detail || '').includes('task_for_pid:5')) {
if (normalizedDetail.includes('task_for_pid:5')) {
if (isDevElectron) {
return `无法附加到微信进程task_for_pid 被拒绝)。当前为开发环境 Electron${process.execPath}\n建议使用打包后的 WeFlow.app已携带调试 entitlements再重试。`
}
return '无法附加到微信进程task_for_pid 被系统拒绝)。请确认当前运行程序已正确签名并包含调试 entitlements。'
return '无法附加到微信进程task_for_pid 被系统拒绝)。请确认当前运行程序已正确签名并包含调试 entitlements,优先使用打包版 WeFlow.app。'
}
return `无法附加到进程 (${detail || ''})`
if (normalizedDetail.includes('thread_get_state_failed')) {
return `无法附加到进程:系统拒绝读取线程状态(${normalizedDetail})。`
}
return `无法附加到进程 (${normalizedDetail || ''})`
}
if (code === 'FRIDA_FAILED') {
if ((detail || '').includes('FRIDA_TIMEOUT')) {
if (normalizedDetail.includes('FRIDA_TIMEOUT')) {
return '定位已成功但在等待时间内未捕获到密钥调用。请保持微信前台并进行一次会话/数据库访问后重试。'
}
return `Frida 语义定位失败 (${detail || ''})`
return `Frida 语义定位失败 (${normalizedDetail || ''})`
}
if (code === 'HOOK_FAILED') {
if ((detail || '').includes('HOOK_TIMEOUT')) {
return 'Hook 已安装,但在等待时间内未触发目标函数。请保持微信前台并执行一次会话/数据库访问后重试。'
if (normalizedDetail.includes('HOOK_TIMEOUT')) {
return 'Hook 已安装,但在等待时间内未触发登录流程。请退出微信账号后重新登录,或在未登录状态下直接登录微信,完成一次登录流程后重试。'
}
if ((detail || '').includes('attach_wait_timeout')) {
if (normalizedDetail.includes('attach_wait_timeout')) {
return '附加调试器超时,未能进入 Hook 阶段。请确认微信处于可交互状态并重试。'
}
return `原生 Hook 失败 (${detail || ''})`
if (normalizedDetail.includes('patch_breakpoint_failed') || normalizedDetail.includes('thread_get_state_failed')) {
return `原生 Hook 失败:检测到系统调试权限或内存保护冲突(${normalizedDetail})。`
}
return `原生 Hook 失败 (${normalizedDetail || ''})`
}
if (code === 'HOOK_TARGET_ONLY') {
return `已定位到目标函数地址(${detail || ''}),但当前原生 C++ 仅完成定位,尚未完成远程 Hook 回调取 key 流程。`
return `已定位到目标函数地址(${normalizedDetail || ''}),但当前原生 C++ 仅完成定位,尚未完成远程 Hook 回调取 key 流程。`
}
if (code === 'SCAN_FAILED') {
if (!normalizedDetail) {
return '内存扫描失败:未匹配到可用特征。可能是当前微信版本更新导致,请升级 WeFlow 后重试。'
}
if (normalizedDetail.includes('Sink pattern not found')) {
return '内存扫描失败未匹配到目标函数特征Sink pattern not found当前微信版本可能暂未适配。'
}
if (normalizedDetail.includes('No suitable module found')) {
return '内存扫描失败:未找到可扫描的微信主模块。请确认微信已完整启动并保持前台;若仍失败,优先尝试微信 4.1.7。'
}
return `内存扫描失败:${normalizedDetail}`
}
if (code === 'SCAN_FAILED') return '内存扫描失败'
return '未知错误'
}
@@ -583,7 +811,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 }
}
}
}
@@ -598,7 +826,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}` }
}
@@ -751,10 +979,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 提权模式')
@@ -765,7 +995,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) {
@@ -868,12 +1103,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 {

View File

@@ -6,10 +6,13 @@ export interface LinuxNotificationData {
title: string;
content: string;
avatarUrl?: string;
channel?: string;
insightRecordId?: string;
targetRoute?: string;
expireTimeout?: number;
}
type NotificationCallback = (sessionId: string) => void;
type NotificationCallback = (payload: unknown) => void;
let notificationCallbacks: NotificationCallback[] = [];
let notificationCounter = 1;
@@ -31,10 +34,10 @@ function clearNotificationState(notificationId: number): void {
}
}
function triggerNotificationCallback(sessionId: string): void {
function triggerNotificationCallback(payload: unknown): void {
for (const callback of notificationCallbacks) {
try {
callback(sessionId);
callback(payload);
} catch (error) {
console.error("[LinuxNotification] Callback error:", error);
}
@@ -69,6 +72,15 @@ export async function showLinuxNotification(
activeNotifications.set(notificationId, notification);
notification.on("click", () => {
if (data.channel === "ai-insight" && data.insightRecordId) {
triggerNotificationCallback({
sessionId: data.sessionId,
channel: data.channel,
insightRecordId: data.insightRecordId,
targetRoute: data.targetRoute,
});
return;
}
if (data.sessionId) {
triggerNotificationCallback(data.sessionId);
}

View File

@@ -12,6 +12,7 @@ export class MessageCacheService {
private readonly cacheFilePath: string
private cache: Record<string, SessionMessageCacheEntry> = {}
private readonly sessionLimit = 150
private readonly maxSessionEntries = 48
constructor(cacheBasePath?: string) {
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
@@ -36,6 +37,7 @@ export class MessageCacheService {
const parsed = JSON.parse(raw)
if (parsed && typeof parsed === 'object') {
this.cache = parsed
this.pruneSessionEntries()
}
} catch (error) {
console.error('MessageCacheService: 载入缓存失败', error)
@@ -43,6 +45,19 @@ export class MessageCacheService {
}
}
private pruneSessionEntries(): void {
const entries = Object.entries(this.cache || {})
if (entries.length <= this.maxSessionEntries) return
entries.sort((left, right) => {
const leftAt = Number(left[1]?.updatedAt || 0)
const rightAt = Number(right[1]?.updatedAt || 0)
return rightAt - leftAt
})
this.cache = Object.fromEntries(entries.slice(0, this.maxSessionEntries))
}
get(sessionId: string): SessionMessageCacheEntry | undefined {
return this.cache[sessionId]
}
@@ -56,6 +71,7 @@ export class MessageCacheService {
updatedAt: Date.now(),
messages: trimmed
}
this.pruneSessionEntries()
this.persist()
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,154 @@
import { existsSync } from 'fs'
import { join } from 'path'
type NativeDecryptResult = {
data: Buffer
ext: string
isWxgf?: boolean
is_wxgf?: boolean
version?: number
aesSize?: number
aes_size?: number
xorSize?: number
xor_size?: number
rawSize?: number
raw_size?: number
flag?: number
}
export type NativeDatMeta = {
version?: number
aesSize?: number
aes_size?: number
xorSize?: number
xor_size?: number
rawSize?: number
raw_size?: number
flag?: number
}
type NativeAddon = {
decryptDatNative: (inputPath: string, xorKey: number, aesKey?: string) => NativeDecryptResult
encryptDatNative?: (inputPath: string, xorKey: number, aesKey?: string, meta?: NativeDatMeta) => Buffer
}
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; meta: NativeDatMeta } | 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}`) : ''
const meta: NativeDatMeta = {
version: result.version,
aes_size: result.aes_size ?? result.aesSize,
xor_size: result.xor_size ?? result.xorSize,
raw_size: result.raw_size ?? result.rawSize,
flag: result.flag
}
return { data: result.data, ext, isWxgf, meta }
} catch {
return null
}
}
export function encryptDatViaNative(
inputPath: string,
xorKey: number,
aesKey?: string,
meta?: NativeDatMeta
): Buffer | null {
const addon = loadAddon()
if (!addon || typeof addon.encryptDatNative !== 'function') return null
try {
const result = addon.encryptDatNative(inputPath, xorKey, aesKey, meta)
return Buffer.isBuffer(result) ? result : null
} catch {
return null
}
}

View File

@@ -2,7 +2,7 @@ import { wcdbService } from './wcdbService'
import { ConfigService } from './config'
import { ContactCacheService } from './contactCacheService'
import { app } from 'electron'
import { existsSync, mkdirSync } from 'fs'
import { existsSync, mkdirSync, unlinkSync } from 'fs'
import { readFile, writeFile, mkdir } from 'fs/promises'
import { basename, join } from 'path'
import crypto from 'crypto'
@@ -174,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'
@@ -315,6 +324,9 @@ class SnsService {
private configService: ConfigService
private contactCache: ContactCacheService
private imageCache = new Map<string, string>()
private imageCacheMeta = new Map<string, number>()
private readonly imageCacheTtlMs = 15 * 60 * 1000
private readonly imageCacheMaxEntries = 120
private exportStatsCache: { totalPosts: number; totalFriends: number; myPosts: number | null; updatedAt: number } | null = null
private userPostCountsCache: { counts: Record<string, number>; updatedAt: number } | null = null
private readonly exportStatsCacheTtlMs = 5 * 60 * 1000
@@ -327,6 +339,38 @@ class SnsService {
this.contactCache = new ContactCacheService(this.configService.get('cachePath') as string)
}
clearMemoryCache(): void {
this.imageCache.clear()
this.imageCacheMeta.clear()
}
private pruneImageCache(now: number = Date.now()): void {
for (const [key, updatedAt] of this.imageCacheMeta.entries()) {
if (now - updatedAt > this.imageCacheTtlMs) {
this.imageCacheMeta.delete(key)
this.imageCache.delete(key)
}
}
while (this.imageCache.size > this.imageCacheMaxEntries) {
const oldestKey = this.imageCache.keys().next().value as string | undefined
if (!oldestKey) break
this.imageCache.delete(oldestKey)
this.imageCacheMeta.delete(oldestKey)
}
}
private rememberImageCache(cacheKey: string, dataUrl: string): void {
if (!cacheKey || !dataUrl) return
const now = Date.now()
if (this.imageCache.has(cacheKey)) {
this.imageCache.delete(cacheKey)
}
this.imageCache.set(cacheKey, dataUrl)
this.imageCacheMeta.set(cacheKey, now)
this.pruneImageCache(now)
}
private toOptionalString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined
const trimmed = value.trim()
@@ -870,7 +914,7 @@ class SnsService {
const allowTimelineFallback = options?.allowTimelineFallback ?? true
const preferCache = options?.preferCache ?? false
const now = Date.now()
const myWxid = this.toOptionalString(this.configService.get('myWxid'))
const myWxid = this.toOptionalString(this.configService.getMyWxidCleaned())
try {
if (preferCache && this.exportStatsCache && now - this.exportStatsCache.updatedAt <= this.exportStatsCacheTtlMs) {
@@ -1230,8 +1274,27 @@ class SnsService {
if (!url) return { success: false, error: 'url 不能为空' }
const cacheKey = `${url}|${key ?? ''}`
if (this.imageCache.has(cacheKey)) {
return { success: true, dataUrl: this.imageCache.get(cacheKey) }
const cachedDataUrl = this.imageCache.get(cacheKey) || ''
if (cachedDataUrl) {
const cachedAt = this.imageCacheMeta.get(cacheKey) || 0
if (cachedAt > 0 && Date.now() - cachedAt <= this.imageCacheTtlMs) {
const base64Part = cachedDataUrl.split(',')[1] || ''
if (base64Part) {
try {
const cachedBuf = Buffer.from(base64Part, 'base64')
if (detectImageMime(cachedBuf, '').startsWith('image/')) {
this.imageCache.delete(cacheKey)
this.imageCache.set(cacheKey, cachedDataUrl)
this.imageCacheMeta.set(cacheKey, Date.now())
return { success: true, dataUrl: cachedDataUrl }
}
} catch {
// ignore and fall through to refetch
}
}
}
this.imageCache.delete(cacheKey)
this.imageCacheMeta.delete(cacheKey)
}
const result = await this.fetchAndDecryptImage(url, key)
@@ -1244,8 +1307,11 @@ 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)
this.rememberImageCache(cacheKey, dataUrl)
return { success: true, dataUrl }
}
}
@@ -1274,6 +1340,8 @@ class SnsService {
}, progressCallback?: (progress: { current: number; total: number; status: string }) => void, control?: {
shouldPause?: () => boolean
shouldStop?: () => boolean
recordCreatedFile?: (filePath: string) => void
recordCreatedDir?: (dirPath: string) => void
}): Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; paused?: boolean; stopped?: boolean; error?: string }> {
const { outputDir, format, usernames, keyword, startTime, endTime } = options
const hasExplicitMediaSelection =
@@ -1295,6 +1363,18 @@ class SnsService {
if (control?.shouldPause?.()) return 'paused'
return null
}
const ensureExportDir = (dirPath: string) => {
const existed = existsSync(dirPath)
if (!existed) {
mkdirSync(dirPath, { recursive: true })
control?.recordCreatedDir?.(dirPath)
}
}
const recordCreatedFileBeforeWrite = (filePath: string) => {
if (!existsSync(filePath)) {
control?.recordCreatedFile?.(filePath)
}
}
const buildInterruptedResult = (state: 'paused' | 'stopped', postCount: number, mediaCount: number) => (
state === 'stopped'
? { success: true, stopped: true, filePath: '', postCount, mediaCount }
@@ -1303,9 +1383,7 @@ class SnsService {
try {
// 确保输出目录存在
if (!existsSync(outputDir)) {
mkdirSync(outputDir, { recursive: true })
}
ensureExportDir(outputDir)
// 1. 分页加载全部帖子
const allPosts: SnsPost[] = []
@@ -1348,9 +1426,7 @@ class SnsService {
const mediaDir = join(outputDir, 'media')
if (shouldExportMedia) {
if (!existsSync(mediaDir)) {
mkdirSync(mediaDir, { recursive: true })
}
ensureExportDir(mediaDir)
// 收集所有媒体下载任务
const mediaTasks: Array<{
@@ -1419,6 +1495,7 @@ class SnsService {
} else {
const result = await this.fetchAndDecryptImage(task.url, task.key)
if (result.success && result.data) {
recordCreatedFileBeforeWrite(filePath)
await writeFile(filePath, result.data)
if (task.kind === 'livephoto') {
if (media.livePhoto) (media.livePhoto as any).localPath = `media/${fileName}`
@@ -1428,6 +1505,7 @@ class SnsService {
mediaCount++
} else if (result.success && result.cachePath) {
const cachedData = await readFile(result.cachePath)
recordCreatedFileBeforeWrite(filePath)
await writeFile(filePath, cachedData)
if (task.kind === 'livephoto') {
if (media.livePhoto) (media.livePhoto as any).localPath = `media/${fileName}`
@@ -1465,7 +1543,7 @@ class SnsService {
// 2.5 下载头像
const avatarMap = new Map<string, string>()
if (format === 'html') {
if (!existsSync(mediaDir)) mkdirSync(mediaDir, { recursive: true })
ensureExportDir(mediaDir)
const uniqueUsers = [...new Map(allPosts.filter(p => p.avatarUrl).map(p => [p.username, p])).values()]
let avatarDone = 0
const avatarQueue = [...uniqueUsers]
@@ -1482,6 +1560,7 @@ class SnsService {
} else {
const result = await this.fetchAndDecryptImage(post.avatarUrl!)
if (result.success && result.data) {
recordCreatedFileBeforeWrite(filePath)
await writeFile(filePath, result.data)
avatarMap.set(post.username, `media/${fileName}`)
}
@@ -1536,6 +1615,7 @@ class SnsService {
linkUrl: (p as any).linkUrl
}))
}
recordCreatedFileBeforeWrite(outputFilePath)
await writeFile(outputFilePath, JSON.stringify(exportData, null, 2), 'utf-8')
} else if (format === 'arkmejson') {
outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.json`)
@@ -1623,11 +1703,13 @@ class SnsService {
},
posts
}
recordCreatedFileBeforeWrite(outputFilePath)
await writeFile(outputFilePath, JSON.stringify(exportData, null, 2), 'utf-8')
} else {
// HTML 格式
outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.html`)
const html = this.generateHtml(allPosts, { usernames, keyword }, avatarMap)
recordCreatedFileBeforeWrite(outputFilePath)
await writeFile(outputFilePath, html, 'utf-8')
}
@@ -1853,8 +1935,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)
}
@@ -2006,6 +2093,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
@@ -2023,13 +2111,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)
@@ -2063,6 +2162,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
}

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,8 +1,6 @@
import { join } from 'path'
import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync, unlinkSync } from 'fs'
import { spawn } from 'child_process'
import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync } from 'fs'
import { pathToFileURL } from 'url'
import crypto from 'crypto'
import { app } from 'electron'
import { ConfigService } from './config'
import { wcdbService } from './wcdbService'
@@ -27,48 +25,15 @@ interface VideoIndexEntry {
type PosterFormat = 'dataUrl' | 'fileUrl'
function getStaticFfmpegPath(): string | null {
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const ffmpegStatic = require('ffmpeg-static')
if (typeof ffmpegStatic === 'string') {
let fixedPath = ffmpegStatic
if (fixedPath.includes('app.asar') && !fixedPath.includes('app.asar.unpacked')) {
fixedPath = fixedPath.replace('app.asar', 'app.asar.unpacked')
}
if (existsSync(fixedPath)) return fixedPath
}
} catch {
// ignore
}
const ffmpegName = process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
const devPath = join(process.cwd(), 'node_modules', 'ffmpeg-static', ffmpegName)
if (existsSync(devPath)) return devPath
if (app.isPackaged) {
const packedPath = join(process.resourcesPath, 'app.asar.unpacked', 'node_modules', 'ffmpeg-static', ffmpegName)
if (existsSync(packedPath)) return packedPath
}
return null
}
class VideoService {
private configService: ConfigService
private hardlinkResolveCache = new Map<string, TimedCacheEntry<string | null>>()
private videoInfoCache = new Map<string, TimedCacheEntry<VideoInfo>>()
private videoDirIndexCache = new Map<string, TimedCacheEntry<Map<string, VideoIndexEntry>>>()
private pendingVideoInfo = new Map<string, Promise<VideoInfo>>()
private pendingPosterExtract = new Map<string, Promise<string | null>>()
private extractedPosterCache = new Map<string, TimedCacheEntry<string | null>>()
private posterExtractRunning = 0
private posterExtractQueue: Array<() => void> = []
private readonly hardlinkCacheTtlMs = 10 * 60 * 1000
private readonly videoInfoCacheTtlMs = 2 * 60 * 1000
private readonly videoIndexCacheTtlMs = 90 * 1000
private readonly extractedPosterCacheTtlMs = 15 * 60 * 1000
private readonly maxPosterExtractConcurrency = 1
private readonly maxCacheEntries = 2000
private readonly maxIndexEntries = 6
@@ -131,7 +96,7 @@ class VideoService {
* 获取当前用户的wxid
*/
private getMyWxid(): string {
return this.configService.get('myWxid') || ''
return this.configService.getMyWxidCleaned() || ''
}
/**
@@ -166,6 +131,14 @@ class VideoService {
if (dbPathContainsWxid) {
return join(dbPath, 'msg', 'video')
}
// 使用 ConfigService 的统一账号目录解析
const accountDir = this.configService.getAccountDir(dbPath, wxid)
if (accountDir) {
return join(accountDir, 'msg', 'video')
}
// 回退到原始逻辑
return join(dbPath, wxid, 'msg', 'video')
}
@@ -179,6 +152,13 @@ class VideoService {
return [join(dbPath, 'db_storage', 'hardlink', 'hardlink.db')]
}
// 使用 ConfigService 的统一账号目录解析
const accountDir = this.configService.getAccountDir(dbPath, wxid)
if (accountDir) {
return [join(accountDir, 'db_storage', 'hardlink', 'hardlink.db')]
}
// 回退到原始逻辑
return [
join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'),
join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db')
@@ -287,11 +267,9 @@ 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
}
private fileToPosterUrl(filePath: string | undefined, mimeType: string, posterFormat: PosterFormat): string | undefined {
@@ -429,6 +407,23 @@ class VideoService {
return 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,
@@ -473,154 +468,10 @@ class VideoService {
return null
}
private getFfmpegPath(): string {
const staticPath = getStaticFfmpegPath()
if (staticPath) return staticPath
return 'ffmpeg'
}
private async withPosterExtractSlot<T>(run: () => Promise<T>): Promise<T> {
if (this.posterExtractRunning >= this.maxPosterExtractConcurrency) {
await new Promise<void>((resolve) => {
this.posterExtractQueue.push(resolve)
})
}
this.posterExtractRunning += 1
try {
return await run()
} finally {
this.posterExtractRunning = Math.max(0, this.posterExtractRunning - 1)
const next = this.posterExtractQueue.shift()
if (next) next()
}
}
private async extractFirstFramePoster(videoPath: string, posterFormat: PosterFormat): Promise<string | null> {
const normalizedPath = String(videoPath || '').trim()
if (!normalizedPath || !existsSync(normalizedPath)) return null
const cacheKey = `${normalizedPath}|format=${posterFormat}`
const cached = this.readTimedCache(this.extractedPosterCache, cacheKey)
if (cached !== undefined) return cached
const pending = this.pendingPosterExtract.get(cacheKey)
if (pending) return pending
const task = this.withPosterExtractSlot(() => new Promise<string | null>((resolve) => {
const tmpDir = join(app.getPath('temp'), 'weflow_video_frames')
try {
if (!existsSync(tmpDir)) mkdirSync(tmpDir, { recursive: true })
} catch {
resolve(null)
return
}
const stableHash = crypto.createHash('sha1').update(normalizedPath).digest('hex').slice(0, 24)
const outputPath = join(tmpDir, `frame_${stableHash}.jpg`)
if (posterFormat === 'fileUrl' && existsSync(outputPath)) {
resolve(pathToFileURL(outputPath).toString())
return
}
const ffmpegPath = this.getFfmpegPath()
const args = [
'-hide_banner', '-loglevel', 'error', '-y',
'-ss', '0',
'-i', normalizedPath,
'-frames:v', '1',
'-q:v', '3',
outputPath
]
const errChunks: Buffer[] = []
let done = false
const finish = (value: string | null) => {
if (done) return
done = true
if (posterFormat === 'dataUrl') {
try {
if (existsSync(outputPath)) unlinkSync(outputPath)
} catch {
// ignore
}
}
resolve(value)
}
const proc = spawn(ffmpegPath, args, {
stdio: ['ignore', 'ignore', 'pipe'],
windowsHide: true
})
const timer = setTimeout(() => {
try { proc.kill('SIGKILL') } catch { /* ignore */ }
finish(null)
}, 12000)
proc.stderr.on('data', (chunk: Buffer) => errChunks.push(chunk))
proc.on('error', () => {
clearTimeout(timer)
finish(null)
})
proc.on('close', (code: number) => {
clearTimeout(timer)
if (code !== 0 || !existsSync(outputPath)) {
if (errChunks.length > 0) {
this.log('extractFirstFrameDataUrl failed', {
videoPath: normalizedPath,
error: Buffer.concat(errChunks).toString().slice(0, 240)
})
}
finish(null)
return
}
try {
const jpgBuf = readFileSync(outputPath)
if (!jpgBuf.length) {
finish(null)
return
}
if (posterFormat === 'fileUrl') {
finish(pathToFileURL(outputPath).toString())
return
}
finish(`data:image/jpeg;base64,${jpgBuf.toString('base64')}`)
} catch {
finish(null)
}
})
}))
this.pendingPosterExtract.set(cacheKey, task)
try {
const result = await task
this.writeTimedCache(
this.extractedPosterCache,
cacheKey,
result,
this.extractedPosterCacheTtlMs,
this.maxCacheEntries
)
return result
} finally {
this.pendingPosterExtract.delete(cacheKey)
}
}
private async ensurePoster(info: VideoInfo, includePoster: boolean, posterFormat: PosterFormat): Promise<VideoInfo> {
void posterFormat
if (!includePoster) return info
if (!info.exists || !info.videoUrl) return info
if (info.coverUrl || info.thumbUrl) return info
const extracted = await this.extractFirstFramePoster(info.videoUrl, posterFormat)
if (!extracted) return info
return {
...info,
coverUrl: extracted,
thumbUrl: extracted
}
return info
}
/**
@@ -652,7 +503,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)) {
@@ -678,7 +529,7 @@ class VideoService {
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

@@ -2,6 +2,7 @@ import { join, dirname, basename } from 'path'
import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs'
import { tmpdir } from 'os'
import * as fzstd from 'fzstd'
import { expandHomePath } from '../utils/pathUtils'
//数据服务初始化错误信息,用于帮助用户诊断问题
let lastDllInitError: string | null = null
@@ -10,6 +11,19 @@ export function getLastDllInitError(): string | null {
return lastDllInitError
}
function cleanAccountDirName(dirName: string): string {
const trimmed = dirName.trim()
if (!trimmed) return trimmed
if (trimmed.toLowerCase().startsWith('wxid_')) {
const match = trimmed.match(/^(wxid_[^_]+)/i)
if (match) return match[1]
return trimmed
}
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
if (suffixMatch) return suffixMatch[1]
return trimmed
}
export class WcdbCore {
private resourcesPath: string | null = null
private userDataPath: string | null = null
@@ -34,8 +48,10 @@ export class WcdbCore {
private wcdbUpdateMessage: any = null
private wcdbDeleteMessage: any = null
private wcdbGetSessions: any = null
private wcdbMarkAllSessionsRead: any = null
private wcdbGetMessages: any = null
private wcdbGetMessageCount: any = null
private wcdbGetMessageByServerId: any = null
private wcdbGetDisplayNames: any = null
private wcdbGetAvatarUrls: any = null
private wcdbGetGroupMemberCount: any = null
@@ -58,6 +74,7 @@ export class WcdbCore {
private wcdbGetAnnualReportExtras: any = null
private wcdbGetDualReportStats: any = null
private wcdbGetGroupStats: any = null
private wcdbGetMyFootprintStats: any = null
private wcdbGetMessageDates: any = null
private wcdbOpenMessageCursor: any = null
private wcdbOpenMessageCursorLite: any = null
@@ -89,6 +106,11 @@ export class WcdbCore {
private wcdbGetSnsUsernames: any = null
private wcdbGetSnsExportStats: any = null
private wcdbGetMessageTableColumns: any = null
private wcdbListTables: any = null
private wcdbGetTableSchema: any = null
private wcdbExportTableSnapshot: any = null
private wcdbImportTableSnapshot: any = null
private wcdbImportTableSnapshotWithSchema: any = null
private wcdbGetMessageTableTimeRange: any = null
private wcdbResolveImageHardlink: any = null
private wcdbResolveImageHardlinkBatch: any = null
@@ -127,6 +149,8 @@ export class WcdbCore {
private logTimer: NodeJS.Timeout | null = null
private lastLogTail: string | null = null
private lastResolvedLogPath: string | null = null
private lastCursorForceReopenAt = 0
private readonly cursorForceReopenCooldownMs = 15000
setPaths(resourcesPath: string, userDataPath: string): void {
this.resourcesPath = resourcesPath
@@ -478,7 +502,7 @@ export class WcdbCore {
private resolveDbStoragePath(basePath: string, wxid: string): string | null {
if (!basePath) return null
const normalized = basePath.replace(/[\\\\/]+$/, '')
const normalized = expandHomePath(basePath).replace(/[\\\\/]+$/, '')
if (normalized.toLowerCase().endsWith('db_storage') && existsSync(normalized)) {
return normalized
}
@@ -801,12 +825,22 @@ export class WcdbCore {
// wcdb_status wcdb_get_sessions(wcdb_handle handle, char** out_json)
this.wcdbGetSessions = this.lib.func('int32 wcdb_get_sessions(int64 handle, _Out_ void** outJson)')
// wcdb_status wcdb_mark_all_sessions_read(wcdb_handle handle, char** out_error)
try {
this.wcdbMarkAllSessionsRead = this.lib.func('int32 wcdb_mark_all_sessions_read(int64 handle, _Out_ void** outError)')
} catch {
this.wcdbMarkAllSessionsRead = null
}
// wcdb_status wcdb_get_messages(wcdb_handle handle, const char* username, int32_t limit, int32_t offset, char** out_json)
this.wcdbGetMessages = this.lib.func('int32 wcdb_get_messages(int64 handle, const char* username, int32 limit, int32 offset, _Out_ void** outJson)')
// wcdb_status wcdb_get_message_count(wcdb_handle handle, const char* username, int32_t* out_count)
this.wcdbGetMessageCount = this.lib.func('int32 wcdb_get_message_count(int64 handle, const char* username, _Out_ int32* outCount)')
// wcdb_status wcdb_get_message_by_svrid(wcdb_handle handle, const char* session_id, const char* svrid, char** out_json)
this.wcdbGetMessageByServerId = this.lib.func('int32 wcdb_get_message_by_svrid(int64 handle, const char* sessionId, const char* svrid, _Out_ void** outJson)')
// wcdb_status wcdb_get_display_names(wcdb_handle handle, const char* usernames_json, char** out_json)
this.wcdbGetDisplayNames = this.lib.func('int32 wcdb_get_display_names(int64 handle, const char* usernamesJson, _Out_ void** outJson)')
@@ -923,6 +957,13 @@ export class WcdbCore {
this.wcdbGetGroupStats = null
}
// wcdb_status wcdb_get_my_footprint_stats(wcdb_handle handle, const char* options_json, char** out_json)
try {
this.wcdbGetMyFootprintStats = this.lib.func('int32 wcdb_get_my_footprint_stats(int64 handle, const char* optionsJson, _Out_ void** outJson)')
} catch {
this.wcdbGetMyFootprintStats = null
}
// wcdb_status wcdb_get_message_dates(wcdb_handle handle, const char* session_id, char** out_json)
try {
this.wcdbGetMessageDates = this.lib.func('int32 wcdb_get_message_dates(int64 handle, const char* sessionId, _Out_ void** outJson)')
@@ -1079,6 +1120,31 @@ export class WcdbCore {
} catch {
this.wcdbGetMessageTableColumns = null
}
try {
this.wcdbListTables = this.lib.func('int32 wcdb_list_tables(int64 handle, const char* kind, const char* dbPath, _Out_ void** outJson)')
} catch {
this.wcdbListTables = null
}
try {
this.wcdbGetTableSchema = this.lib.func('int32 wcdb_get_table_schema(int64 handle, const char* kind, const char* dbPath, const char* tableName, _Out_ void** outJson)')
} catch {
this.wcdbGetTableSchema = null
}
try {
this.wcdbExportTableSnapshot = this.lib.func('int32 wcdb_export_table_snapshot(int64 handle, const char* kind, const char* dbPath, const char* tableName, const char* outputPath, _Out_ void** outJson)')
} catch {
this.wcdbExportTableSnapshot = null
}
try {
this.wcdbImportTableSnapshot = this.lib.func('int32 wcdb_import_table_snapshot(int64 handle, const char* kind, const char* dbPath, const char* tableName, const char* inputPath, _Out_ void** outJson)')
} catch {
this.wcdbImportTableSnapshot = null
}
try {
this.wcdbImportTableSnapshotWithSchema = this.lib.func('int32 wcdb_import_table_snapshot_with_schema(int64 handle, const char* kind, const char* dbPath, const char* tableName, const char* inputPath, const char* createTableSql, _Out_ void** outJson)')
} catch {
this.wcdbImportTableSnapshotWithSchema = null
}
try {
this.wcdbGetMessageTableTimeRange = this.lib.func('int32 wcdb_get_message_table_time_range(int64 handle, const char* dbPath, const char* tableName, _Out_ void** outJson)')
} catch {
@@ -1219,13 +1285,12 @@ export class WcdbCore {
/**
* 测试数据库连接
*/
async testConnection(dbPath: string, hexKey: string, wxid: string): Promise<{ success: boolean; error?: string; sessionCount?: number }> {
async testConnection(accountDir: string, hexKey: string): Promise<{ success: boolean; error?: string; sessionCount?: number }> {
try {
// 如果当前已经有相同参数的活动连接,直接返回成功
if (this.handle !== null &&
this.currentPath === dbPath &&
this.currentKey === hexKey &&
this.currentWxid === wxid) {
this.currentPath === accountDir &&
this.currentKey === hexKey) {
return { success: true, sessionCount: 0 }
}
@@ -1243,9 +1308,9 @@ export class WcdbCore {
}
}
// 构建 db_storage 目录路径
const dbStoragePath = this.resolveDbStoragePath(dbPath, wxid)
this.writeLog(`testConnection dbPath=${dbPath} wxid=${wxid} dbStorage=${dbStoragePath || 'null'}`)
// 直接使用账号目录
const dbStoragePath = join(accountDir, 'db_storage')
this.writeLog(`testConnection accountDir=${accountDir} dbStorage=${dbStoragePath}`)
if (!dbStoragePath || !existsSync(dbStoragePath)) {
return { success: false, error: this.formatInitProtectionError(-3001) }
@@ -1288,9 +1353,9 @@ export class WcdbCore {
}
// 恢复测试前的连接(如果之前有活动连接)
if (hadActiveConnection && prevPath && prevKey && prevWxid) {
if (hadActiveConnection && prevPath && prevKey) {
try {
await this.open(prevPath, prevKey, prevWxid)
await this.open(prevPath, prevKey)
} catch {
// 恢复失败则保持断开,由调用方处理
}
@@ -1495,7 +1560,7 @@ export class WcdbCore {
/**
* 打开数据库
*/
async open(dbPath: string, hexKey: string, wxid: string): Promise<boolean> {
async open(accountDir: string, hexKey: string): Promise<boolean> {
try {
lastDllInitError = null
if (!this.initialized) {
@@ -1505,9 +1570,8 @@ export class WcdbCore {
// 检查是否已经是当前连接的参数,如果是则直接返回成功,实现"始终保持链接"
if (this.handle !== null &&
this.currentPath === dbPath &&
this.currentKey === hexKey &&
this.currentWxid === wxid) {
this.currentPath === accountDir &&
this.currentKey === hexKey) {
return true
}
@@ -1519,12 +1583,12 @@ export class WcdbCore {
if (!initOk) return false
}
const dbStoragePath = this.resolveDbStoragePath(dbPath, wxid)
this.writeLog(`open dbPath=${dbPath} wxid=${wxid} dbStorage=${dbStoragePath || 'null'}`, true)
const dbStoragePath = join(accountDir, 'db_storage')
this.writeLog(`open accountDir=${accountDir} dbStorage=${dbStoragePath}`, true)
if (!dbStoragePath || !existsSync(dbStoragePath)) {
console.error('数据库目录不存在:', dbPath)
this.writeLog(`open failed: dbStorage not found for ${dbPath}`)
console.error('数据库目录不存在:', accountDir)
this.writeLog(`open failed: dbStorage not found for ${accountDir}`)
lastDllInitError = this.formatInitProtectionError(-3001)
return false
}
@@ -1555,8 +1619,12 @@ export class WcdbCore {
return false
}
// 从账号目录路径中提取 wxid目录名
const rawWxid = basename(accountDir)
const wxid = cleanAccountDirName(rawWxid)
this.handle = handle
this.currentPath = dbPath
this.currentPath = accountDir
this.currentKey = hexKey
this.currentWxid = wxid
this.currentDbStoragePath = dbStoragePath
@@ -1574,7 +1642,7 @@ export class WcdbCore {
}
this.writeLog(`open ok handle=${handle}`, true)
await this.dumpDbStatus('open')
await this.runPostOpenDiagnostics(dbPath, dbStoragePath, sessionDbPath, wxid)
await this.runPostOpenDiagnostics(accountDir, dbStoragePath, sessionDbPath, wxid)
return true
} catch (e) {
console.error('打开数据库异常:', e)
@@ -1590,6 +1658,9 @@ export class WcdbCore {
*/
close(): void {
if (this.handle !== null || this.initialized) {
// 先停止监控与云控回调,避免 shutdown 后仍有 native 回调访问已释放资源。
try { this.stopMonitor() } catch {}
try { this.cloudStop() } catch {}
try {
// 不调用 closeAccount直接 shutdown
this.wcdbShutdown()
@@ -1652,6 +1723,39 @@ export class WcdbCore {
}
}
async markAllSessionsRead(): Promise<{ success: boolean; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
if (!this.wcdbMarkAllSessionsRead) {
return { success: false, error: '当前数据服务版本不支持一键已读' }
}
try {
await new Promise(resolve => setImmediate(resolve))
const outPtr = [null as any]
const result = this.wcdbMarkAllSessionsRead(this.handle, outPtr)
let message = ''
if (outPtr[0]) {
try { message = this.koffi.decode(outPtr[0], 'char', -1) } catch { }
try { this.wcdbFreeString(outPtr[0]) } catch { }
}
await new Promise(resolve => setImmediate(resolve))
if (result !== 0) {
this.writeLog(`markAllSessionsRead failed: code=${result} error=${message}`)
return { success: false, error: message || `一键已读失败: ${result}` }
}
this.clearMediaStreamSessionCache()
this.writeLog('markAllSessionsRead ok')
return { success: true }
} catch (e) {
this.writeLog(`markAllSessionsRead exception: ${String(e)}`)
return { success: false, error: String(e) }
}
}
async getMessages(sessionId: string, limit: number, offset: number): Promise<{ success: boolean; messages?: any[]; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
@@ -1721,6 +1825,30 @@ export class WcdbCore {
}
}
async getMessageByServerId(sessionId: string, svrid: string): Promise<{ success: boolean; row?: any; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
try {
const outPtr = [null as any]
const result = this.wcdbGetMessageByServerId(this.handle, sessionId, svrid, outPtr)
if (result !== 0) {
return { success: false, error: `查询消息失败: ${result}` }
}
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) {
return { success: true, row: null }
}
const parsed = JSON.parse(jsonStr)
if (!parsed || Object.keys(parsed).length === 0) {
return { success: true, row: null }
}
return { success: true, row: parsed }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getMessageCounts(sessionIds: string[]): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
@@ -1997,6 +2125,14 @@ export class WcdbCore {
}
return ''
}
const pickRaw = (row: Record<string, any>, keys: string[]): unknown => {
for (const key of keys) {
const value = row[key]
if (value === null || value === undefined) continue
return value
}
return undefined
}
const extractXmlValue = (xml: string, tag: string): string => {
if (!xml) return ''
const regex = new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, 'i')
@@ -2082,25 +2218,37 @@ export class WcdbCore {
const md5Like = /([0-9a-fA-F]{16,64})/.exec(fileBase)
return String(md5Like?.[1] || fileBase || '').trim().toLowerCase()
}
const decodePackedToPrintable = (raw: string): string => {
const text = String(raw || '').trim()
if (!text) return ''
let buf: Buffer | null = null
if (/^[a-fA-F0-9]+$/.test(text) && text.length % 2 === 0) {
try {
buf = Buffer.from(text, 'hex')
} catch {
buf = null
const decodePackedInfoBuffer = (raw: unknown): Buffer | null => {
if (!raw) return null
if (Buffer.isBuffer(raw)) return raw
if (raw instanceof Uint8Array) return Buffer.from(raw)
if (Array.isArray(raw)) return Buffer.from(raw as any[])
if (typeof raw === 'string') {
const text = raw.trim()
if (!text) return null
const compactHex = text.replace(/\s+/g, '')
if (/^[a-fA-F0-9]+$/.test(compactHex) && compactHex.length % 2 === 0) {
try {
return Buffer.from(compactHex, 'hex')
} catch {
// ignore
}
}
}
if (!buf) {
try {
const base64 = Buffer.from(text, 'base64')
if (base64.length > 0) buf = base64
if (base64.length > 0) return base64
} catch {
buf = null
// ignore
}
return null
}
if (typeof raw === 'object' && raw !== null && Array.isArray((raw as any).data)) {
return Buffer.from((raw as any).data)
}
return null
}
const decodePackedToPrintable = (raw: unknown): string => {
const buf = decodePackedInfoBuffer(raw)
if (!buf || buf.length === 0) return ''
const printable: number[] = []
for (const byte of buf) {
@@ -2115,6 +2263,46 @@ export class WcdbCore {
const match = /([a-fA-F0-9]{32})/.exec(input)
return String(match?.[1] || '').toLowerCase()
}
const normalizeVideoFileToken = (value: unknown): 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()
}
const extractVideoFileNameFromPackedRaw = (raw: unknown): string => {
const buf = decodePackedInfoBuffer(raw)
if (!buf || buf.length === 0) return ''
const candidates: string[] = []
let current = ''
for (const byte of buf) {
const isHex =
(byte >= 0x30 && byte <= 0x39) ||
(byte >= 0x41 && byte <= 0x46) ||
(byte >= 0x61 && byte <= 0x66)
if (isHex) {
current += String.fromCharCode(byte)
continue
}
if (current.length >= 16) candidates.push(current)
current = ''
}
if (current.length >= 16) candidates.push(current)
if (candidates.length === 0) return ''
const exact32 = candidates.find((item) => item.length === 32)
if (exact32) return exact32.toLowerCase()
const fallback = candidates.find((item) => item.length >= 16 && item.length <= 64)
return String(fallback || '').toLowerCase()
}
const extractImageDatName = (row: Record<string, any>, content: string): string => {
const direct = pickString(row, [
'image_path',
@@ -2133,7 +2321,7 @@ export class WcdbCore {
const normalizedXml = normalizeDatBase(xmlCandidate)
if (normalizedXml) return normalizedXml
const packedRaw = pickString(row, [
const packedRaw = pickRaw(row, [
'packed_info_data',
'packedInfoData',
'packed_info_blob',
@@ -2158,7 +2346,7 @@ export class WcdbCore {
return ''
}
const extractPackedPayload = (row: Record<string, any>): string => {
const packedRaw = pickString(row, [
const packedRaw = pickRaw(row, [
'packed_info_data',
'packedInfoData',
'packed_info_blob',
@@ -2313,6 +2501,20 @@ export class WcdbCore {
const packedPayload = extractPackedPayload(row)
const imageMd5ByColumn = pickString(row, ['image_md5', 'imageMd5'])
const videoMd5ByColumn = pickString(row, ['video_md5', 'videoMd5', 'raw_md5', 'rawMd5'])
const packedRaw = pickRaw(row, [
'packed_info_data',
'packedInfoData',
'packed_info_blob',
'packedInfoBlob',
'packed_info',
'packedInfo',
'BytesExtra',
'bytes_extra',
'WCDB_CT_packed_info',
'reserved0',
'Reserved0',
'WCDB_CT_Reserved0'
])
let content = ''
let imageMd5: string | undefined
@@ -2328,10 +2530,17 @@ export class WcdbCore {
if (!imageDatName) imageDatName = extractImageDatName(row, content) || undefined
}
} else if (localType === 43) {
videoMd5 = videoMd5ByColumn || extractHexMd5(packedPayload) || undefined
videoMd5 =
extractVideoFileNameFromPackedRaw(packedRaw) ||
normalizeVideoFileToken(videoMd5ByColumn) ||
extractHexMd5(packedPayload) ||
undefined
if (!videoMd5) {
content = decodeContentIfNeeded()
videoMd5 = extractVideoMd5(content) || extractHexMd5(packedPayload) || undefined
videoMd5 =
normalizeVideoFileToken(extractVideoMd5(content)) ||
extractHexMd5(packedPayload) ||
undefined
} else if (useRawMessageContent) {
// 占位态标题只依赖简单 XML已带 md5 时不做额外解压
content = rawMessageContent
@@ -2807,6 +3016,96 @@ export class WcdbCore {
}
}
async listTables(kind: string, dbPath: string = ''): Promise<{ success: boolean; tables?: string[]; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbListTables) return { success: false, error: '接口未就绪' }
try {
const outPtr = [null as any]
const result = this.wcdbListTables(this.handle, kind, dbPath || '', outPtr)
if (result !== 0 || !outPtr[0]) return { success: false, error: `获取表列表失败: ${result}` }
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析表列表失败' }
const tables = JSON.parse(jsonStr)
return { success: true, tables: Array.isArray(tables) ? tables.map((c: any) => String(c || '')).filter(Boolean) : [] }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getTableSchema(kind: string, dbPath: string, tableName: string): Promise<{ success: boolean; schema?: string; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbGetTableSchema) return { success: false, error: '接口未就绪' }
try {
const outPtr = [null as any]
const result = this.wcdbGetTableSchema(this.handle, kind, dbPath || '', tableName, outPtr)
const jsonStr = outPtr[0] ? this.decodeJsonPtr(outPtr[0]) : ''
const data = jsonStr ? JSON.parse(jsonStr) : {}
if (result !== 0 || data?.success === false) return { success: false, error: data?.error || `获取表结构失败: ${result}` }
return { success: true, schema: String(data?.schema || '') }
} catch (e) {
return { success: false, error: String(e) }
}
}
async exportTableSnapshot(kind: string, dbPath: string, tableName: string, outputPath: string): Promise<{ success: boolean; rows?: number; columns?: number; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbExportTableSnapshot) return { success: false, error: '接口未就绪' }
try {
const outPtr = [null as any]
const result = this.wcdbExportTableSnapshot(this.handle, kind, dbPath || '', tableName, outputPath, outPtr)
const jsonStr = outPtr[0] ? this.decodeJsonPtr(outPtr[0]) : ''
const data = jsonStr ? JSON.parse(jsonStr) : {}
if (result !== 0 || data?.success === false) return { success: false, error: data?.error || `导出表快照失败: ${result}` }
return { success: true, rows: Number(data?.rows || 0), columns: Number(data?.columns || 0) }
} catch (e) {
return { success: false, error: String(e) }
}
}
async importTableSnapshot(kind: string, dbPath: string, tableName: string, inputPath: string): Promise<{ success: boolean; rows?: number; inserted?: number; ignored?: number; malformed?: number; columns?: number; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbImportTableSnapshot) return { success: false, error: '接口未就绪' }
try {
const outPtr = [null as any]
const result = this.wcdbImportTableSnapshot(this.handle, kind, dbPath || '', tableName, inputPath, outPtr)
const jsonStr = outPtr[0] ? this.decodeJsonPtr(outPtr[0]) : ''
const data = jsonStr ? JSON.parse(jsonStr) : {}
if (result !== 0 || data?.success === false) return { success: false, error: data?.error || `导入表快照失败: ${result}` }
return {
success: true,
rows: Number(data?.rows || 0),
inserted: Number(data?.inserted || 0),
ignored: Number(data?.ignored || 0),
malformed: Number(data?.malformed || 0),
columns: Number(data?.columns || 0)
}
} catch (e) {
return { success: false, error: String(e) }
}
}
async importTableSnapshotWithSchema(kind: string, dbPath: string, tableName: string, inputPath: string, createTableSql: string): Promise<{ success: boolean; rows?: number; inserted?: number; ignored?: number; malformed?: number; columns?: number; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbImportTableSnapshotWithSchema) return { success: false, error: '接口未就绪' }
try {
const outPtr = [null as any]
const result = this.wcdbImportTableSnapshotWithSchema(this.handle, kind, dbPath || '', tableName, inputPath, createTableSql || '', outPtr)
const jsonStr = outPtr[0] ? this.decodeJsonPtr(outPtr[0]) : ''
const data = jsonStr ? JSON.parse(jsonStr) : {}
if (result !== 0 || data?.success === false) return { success: false, error: data?.error || `导入表快照失败: ${result}` }
return {
success: true,
rows: Number(data?.rows || 0),
inserted: Number(data?.inserted || 0),
ignored: Number(data?.ignored || 0),
malformed: Number(data?.malformed || 0),
columns: Number(data?.columns || 0)
}
} catch (e) {
return { success: false, error: String(e) }
}
}
async getMessageTableTimeRange(dbPath: string, tableName: string): Promise<{ success: boolean; data?: any; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbGetMessageTableTimeRange) return { success: false, error: '接口未就绪' }
@@ -3098,6 +3397,65 @@ export class WcdbCore {
}
}
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 }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
if (!this.wcdbGetMyFootprintStats) {
return { success: false, error: '接口未就绪' }
}
try {
const normalizedPrivateSessions = Array.from(new Set(
(options?.privateSessionIds || [])
.map((value) => String(value || '').trim())
.filter(Boolean)
))
const normalizedGroupSessions = Array.from(new Set(
(options?.groupSessionIds || [])
.map((value) => String(value || '').trim())
.filter(Boolean)
))
const mentionLimitRaw = Number(options?.mentionLimit ?? 0)
const privateLimitRaw = Number(options?.privateLimit ?? 0)
const mentionLimit = Number.isFinite(mentionLimitRaw) && mentionLimitRaw >= 0 ? Math.floor(mentionLimitRaw) : 0
const privateLimit = Number.isFinite(privateLimitRaw) && privateLimitRaw >= 0 ? Math.floor(privateLimitRaw) : 0
const payload = JSON.stringify({
begin: this.normalizeTimestamp(options?.beginTimestamp || 0),
end: this.normalizeTimestamp(options?.endTimestamp || 0),
my_wxid: String(options?.myWxid || '').trim(),
private_session_ids: normalizedPrivateSessions,
group_session_ids: normalizedGroupSessions,
mention_limit: mentionLimit,
private_limit: privateLimit,
mention_mode: options?.mentionMode || 'text_at_me'
})
const outPtr = [null as any]
const result = this.wcdbGetMyFootprintStats(this.handle, payload, outPtr)
if (result !== 0 || !outPtr[0]) {
return { success: false, error: `获取我的足迹统计失败: ${result}` }
}
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) {
return { success: false, error: '解析我的足迹统计失败' }
}
return { success: true, data: JSON.parse(jsonStr) || {} }
} catch (e) {
return { success: false, error: String(e) }
}
}
/**
* 强制重新打开账号连接(绕过路径缓存),用于微信重装后消息数据库刷新失败时的自动恢复。
* 返回重新打开是否成功。
@@ -3119,6 +3477,15 @@ export class WcdbCore {
return this.open(path, key, wxid)
}
private shouldRetryCursorAfterNoDb(): boolean {
const now = Date.now()
if (now - this.lastCursorForceReopenAt < this.cursorForceReopenCooldownMs) {
return false
}
this.lastCursorForceReopenAt = now
return true
}
async openMessageCursor(sessionId: string, batchSize: number, ascending: boolean, beginTimestamp: number, endTimestamp: number): Promise<{ success: boolean; cursor?: number; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
@@ -3136,7 +3503,7 @@ export class WcdbCore {
)
// result=-3 表示 WCDB_STATUS_NO_MESSAGE_DB消息数据库缓存为空常见于微信重装后
// 自动强制重连并重试一次
if (result === -3 && outCursor[0] <= 0) {
if (result === -3 && outCursor[0] <= 0 && this.shouldRetryCursorAfterNoDb()) {
this.writeLog('openMessageCursor: result=-3 (no message db), attempting forceReopen...', true)
const reopened = await this.forceReopen()
if (reopened && this.handle !== null) {
@@ -3156,11 +3523,13 @@ export class WcdbCore {
}
}
if (result !== 0 || outCursor[0] <= 0) {
await this.printLogs(true)
this.writeLog(
`openMessageCursor failed: sessionId=${sessionId} batchSize=${batchSize} ascending=${ascending ? 1 : 0} begin=${beginTimestamp} end=${endTimestamp} result=${result} cursor=${outCursor[0]}`,
true
)
if (result !== -3) {
await this.printLogs(true)
this.writeLog(
`openMessageCursor failed: sessionId=${sessionId} batchSize=${batchSize} ascending=${ascending ? 1 : 0} begin=${beginTimestamp} end=${endTimestamp} result=${result} cursor=${outCursor[0]}`,
true
)
}
const hint = result === -3
? `创建游标失败: ${result}(消息数据库未找到)。如果你最近重装过微信,请尝试重新指定数据目录后重试`
: result === -7
@@ -3197,7 +3566,7 @@ export class WcdbCore {
// result=-3 表示 WCDB_STATUS_NO_MESSAGE_DB消息数据库缓存为空
// 自动强制重连并重试一次
if (result === -3 && outCursor[0] <= 0) {
if (result === -3 && outCursor[0] <= 0 && this.shouldRetryCursorAfterNoDb()) {
this.writeLog('openMessageCursorLite: result=-3 (no message db), attempting forceReopen...', true)
const reopened = await this.forceReopen()
if (reopened && this.handle !== null) {
@@ -3218,11 +3587,13 @@ export class WcdbCore {
}
if (result !== 0 || outCursor[0] <= 0) {
await this.printLogs(true)
this.writeLog(
`openMessageCursorLite failed: sessionId=${sessionId} batchSize=${batchSize} ascending=${ascending ? 1 : 0} begin=${beginTimestamp} end=${endTimestamp} result=${result} cursor=${outCursor[0]}`,
true
)
if (result !== -3) {
await this.printLogs(true)
this.writeLog(
`openMessageCursorLite failed: sessionId=${sessionId} batchSize=${batchSize} ascending=${ascending ? 1 : 0} begin=${beginTimestamp} end=${endTimestamp} result=${result} cursor=${outCursor[0]}`,
true
)
}
if (result === -7) {
return { success: false, error: 'message schema mismatch当前账号消息表结构与程序要求不一致' }
}

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 线程
@@ -94,6 +92,9 @@ export class WcdbService {
this.setPaths(this.resourcesPath, this.userDataPath)
}
this.setLogEnabled(this.logEnabled)
if (this.monitorListener) {
this.callWorker<{ success?: boolean }>('setMonitor').catch(() => { })
}
} catch (e) {
// Failed to create worker
@@ -153,15 +154,17 @@ export class WcdbService {
/**
* 测试数据库连接
*/
async testConnection(dbPath: string, hexKey: string, wxid: string): Promise<{ success: boolean; error?: string; sessionCount?: number }> {
return this.callWorker('testConnection', { dbPath, hexKey, wxid })
async testConnection(accountDir: string, hexKey: string): Promise<{ success: boolean; error?: string; sessionCount?: number }> {
return this.callWorker('testConnection', { accountDir, hexKey })
}
/**
* 打开数据库
* @param accountDir 账号目录的完整路径
* @param hexKey 解密密钥
*/
async open(dbPath: string, hexKey: string, wxid: string): Promise<boolean> {
return this.callWorker('open', { dbPath, hexKey, wxid })
async open(accountDir: string, hexKey: string): Promise<boolean> {
return this.callWorker('open', { accountDir, hexKey })
}
async getLastInitError(): Promise<string | null> {
@@ -201,6 +204,10 @@ export class WcdbService {
return this.callWorker('getSessions')
}
async markAllSessionsRead(): Promise<{ success: boolean; error?: string }> {
return this.callWorker('markAllSessionsRead')
}
/**
* 获取消息列表
*/
@@ -222,6 +229,13 @@ export class WcdbService {
return this.callWorker('getMessageCount', { sessionId })
}
/**
* 根据 server_id 查询单条消息
*/
async getMessageByServerId(sessionId: string, svrid: string): Promise<{ success: boolean; row?: any; error?: string }> {
return this.callWorker('getMessageByServerId', { sessionId, svrid })
}
async getMessageCounts(sessionIds: string[]): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> {
return this.callWorker('getMessageCounts', { sessionIds })
}
@@ -368,6 +382,26 @@ export class WcdbService {
return this.callWorker('getMessageTableColumns', { dbPath, tableName })
}
async listTables(kind: string, dbPath: string = ''): Promise<{ success: boolean; tables?: string[]; error?: string }> {
return this.callWorker('listTables', { kind, dbPath })
}
async getTableSchema(kind: string, dbPath: string, tableName: string): Promise<{ success: boolean; schema?: string; error?: string }> {
return this.callWorker('getTableSchema', { kind, dbPath, tableName })
}
async exportTableSnapshot(kind: string, dbPath: string, tableName: string, outputPath: string): Promise<{ success: boolean; rows?: number; columns?: number; error?: string }> {
return this.callWorker('exportTableSnapshot', { kind, dbPath, tableName, outputPath })
}
async importTableSnapshot(kind: string, dbPath: string, tableName: string, inputPath: string): Promise<{ success: boolean; rows?: number; inserted?: number; ignored?: number; malformed?: number; columns?: number; error?: string }> {
return this.callWorker('importTableSnapshot', { kind, dbPath, tableName, inputPath })
}
async importTableSnapshotWithSchema(kind: string, dbPath: string, tableName: string, inputPath: string, createTableSql: string): Promise<{ success: boolean; rows?: number; inserted?: number; ignored?: number; malformed?: number; columns?: number; error?: string }> {
return this.callWorker('importTableSnapshotWithSchema', { kind, dbPath, tableName, inputPath, createTableSql })
}
async getMessageTableTimeRange(dbPath: string, tableName: string): Promise<{ success: boolean; data?: any; error?: string }> {
return this.callWorker('getMessageTableTimeRange', { dbPath, tableName })
}
@@ -448,6 +482,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 })
}
/**
* 打开消息游标
*/

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

@@ -32,10 +32,10 @@ if (parentPort) {
break
}
case 'testConnection':
result = await core.testConnection(payload.dbPath, payload.hexKey, payload.wxid)
result = await core.testConnection(payload.accountDir, payload.hexKey)
break
case 'open':
result = await core.open(payload.dbPath, payload.hexKey, payload.wxid)
result = await core.open(payload.accountDir, payload.hexKey)
break
case 'getLastInitError':
result = core.getLastInitError()
@@ -50,6 +50,9 @@ if (parentPort) {
case 'getSessions':
result = await core.getSessions()
break
case 'markAllSessionsRead':
result = await core.markAllSessionsRead()
break
case 'getMessages':
result = await core.getMessages(payload.sessionId, payload.limit, payload.offset)
break
@@ -59,6 +62,9 @@ if (parentPort) {
case 'getMessageCount':
result = await core.getMessageCount(payload.sessionId)
break
case 'getMessageByServerId':
result = await core.getMessageByServerId(payload.sessionId, payload.svrid)
break
case 'getMessageCounts':
result = await core.getMessageCounts(payload.sessionIds)
break
@@ -116,6 +122,21 @@ if (parentPort) {
case 'getMessageTableColumns':
result = await core.getMessageTableColumns(payload.dbPath, payload.tableName)
break
case 'listTables':
result = await core.listTables(payload.kind, payload.dbPath)
break
case 'getTableSchema':
result = await core.getTableSchema(payload.kind, payload.dbPath, payload.tableName)
break
case 'exportTableSnapshot':
result = await core.exportTableSnapshot(payload.kind, payload.dbPath, payload.tableName, payload.outputPath)
break
case 'importTableSnapshot':
result = await core.importTableSnapshot(payload.kind, payload.dbPath, payload.tableName, payload.inputPath)
break
case 'importTableSnapshotWithSchema':
result = await core.importTableSnapshotWithSchema(payload.kind, payload.dbPath, payload.tableName, payload.inputPath, payload.createTableSql)
break
case 'getMessageTableTimeRange':
result = await core.getMessageTableTimeRange(payload.dbPath, payload.tableName)
break
@@ -158,6 +179,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

View File

@@ -9,10 +9,10 @@ let linuxNotificationService:
| null = null;
// 用于处理通知点击的回调函数在Linux上用于导航到会话
let onNotificationNavigate: ((sessionId: string) => void) | null = null;
let onNotificationNavigate: ((payload: unknown) => void) | null = null;
export function setNotificationNavigateHandler(
callback: (sessionId: string) => void,
callback: (payload: unknown) => void,
) {
onNotificationNavigate = callback;
}
@@ -109,23 +109,33 @@ export function createNotificationWindow() {
export async function showNotification(data: any) {
// 先检查配置
const config = ConfigService.getInstance();
const enabled = await config.get("notificationEnabled");
if (enabled === false) return; // 默认为 true
const sessionId = typeof data.sessionId === "string" ? data.sessionId : "";
const channel = typeof data.channel === "string" ? data.channel : "";
const isAiInsightNotification = channel === "ai-insight";
// 检查会话过滤
const filterMode = config.get("notificationFilterMode") || "all";
const filterList = config.get("notificationFilterList") || [];
const sessionId = data.sessionId;
if (isAiInsightNotification) {
const enabled = await config.get("aiInsightNotificationEnabled");
if (enabled === false) return; // 默认为 true
} else {
const enabled = await config.get("notificationEnabled");
if (enabled === false) return; // 默认为 true
if (sessionId && filterMode !== "all" && filterList.length > 0) {
const isInList = filterList.includes(sessionId);
if (filterMode === "whitelist" && !isInList) {
// 白名单模式:不在列表中则不显示
return;
}
if (filterMode === "blacklist" && isInList) {
// 黑名单模式:在列表中则不显示
return;
// 检查会话过滤
const filterMode = config.get("notificationFilterMode") || "all";
const filterList = config.get("notificationFilterList") || [];
// 系统通知(如 "WeFlow 准备就绪")不是聊天消息,不应受会话白/黑名单影响
const isSystemNotification = sessionId.startsWith("weflow-");
if (!isSystemNotification && filterMode !== "all") {
const isInList = sessionId !== "" && filterList.includes(sessionId);
if (filterMode === "whitelist" && !isInList) {
// 白名单模式:不在列表中则不显示(空列表视为全部拦截)
return;
}
if (filterMode === "blacklist" && isInList) {
// 黑名单模式:在列表中则不显示
return;
}
}
}
@@ -174,6 +184,9 @@ async function showLinuxNotification(data: any) {
content: data.content,
avatarUrl: data.avatarUrl,
sessionId: data.sessionId,
channel: data.channel,
insightRecordId: data.insightRecordId,
targetRoute: data.targetRoute,
expireTimeout: 5000,
};
@@ -247,14 +260,14 @@ export async function registerNotificationHandlers() {
await linuxNotificationModule.initLinuxNotificationService();
// 在Linux上注册通知点击回调
linuxNotificationModule.onNotificationAction((sessionId: string) => {
linuxNotificationModule.onNotificationAction((payload: unknown) => {
console.log(
"[NotificationWindow] Linux notification clicked, sessionId:",
sessionId,
payload,
);
// 如果设置了导航处理程序则使用该处理程序否则回退到ipcMain方法。
if (onNotificationNavigate) {
onNotificationNavigate(sessionId);
onNotificationNavigate(payload);
} else {
// 如果尚未设置处理程序则通过ipcMain发出事件
// 正常流程中不应该发生这种情况,因为我们在初始化之前设置了处理程序。

1662
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,13 +13,13 @@
},
"//": "二改不应改变此处的作者与应用信息",
"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": {
@@ -35,14 +35,14 @@
"jieba-wasm": "^2.2.0",
"jszip": "^3.10.1",
"koffi": "^2.9.0",
"lucide-react": "^1.7.0",
"lucide-react": "^1.8.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.14.0",
"react-virtuoso": "^4.18.1",
"react-virtuoso": "^4.18.5",
"remark-gfm": "^4.0.1",
"sherpa-onnx-node": "^1.12.35",
"sherpa-onnx-node": "^1.10.38",
"silk-wasm": "^3.7.1",
"wechat-emojis": "^1.0.2",
"zustand": "^5.0.2"
@@ -51,14 +51,15 @@
"@electron/rebuild": "^4.0.2",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.3.4",
"@vitejs/plugin-react": "^6.0.1",
"electron": "^41.1.1",
"electron-builder": "^26.8.1",
"sass": "^1.99.0",
"esbuild": "^0.28.0",
"sass": "^1.98.0",
"sharp": "^0.34.5",
"typescript": "^6.0.2",
"vite": "^7.3.2",
"vite-plugin-electron": "^0.29.1",
"typescript": "^6.0.3",
"vite": "^8.0.10",
"vite-plugin-electron": "^0.28.8",
"vite-plugin-electron-renderer": "^0.14.6"
},
"pnpm": {
@@ -70,9 +71,7 @@
"lodash": ">=4.17.21",
"brace-expansion": ">=1.1.11",
"picomatch": ">=2.3.1",
"ajv": ">=8.18.0",
"ajv-keywords@3>ajv": "^6.12.6",
"@develar/schema-utils>ajv": "^6.12.6"
"ajv": ">=8.18.0"
}
},
"build": {
@@ -188,7 +187,8 @@
"node_modules/sherpa-onnx-node/**/*",
"node_modules/sherpa-onnx-*/*",
"node_modules/sherpa-onnx-*/**/*",
"node_modules/ffmpeg-static/**/*"
"node_modules/ffmpeg-static/**/*",
"resources/wedecrypt/**/*.node"
],
"icon": "resources/icons/macos/icon.icns"
},

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

View File

@@ -4,246 +4,478 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WeFlow</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
<script>
(function initSplashMode() {
var params = new URLSearchParams(window.location.search || "");
var mode = params.get("themeMode") || params.get("mode") || "system";
var themeId = params.get("themeId") || "cloud-dancer";
var mq = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)");
var resolved = mode === "dark" || (mode === "system" && mq && mq.matches) ? "dark" : "light";
html, body {
width: 100%; height: 100%;
background: transparent;
document.documentElement.setAttribute("data-theme", themeId);
document.documentElement.setAttribute("data-theme-mode", mode);
document.documentElement.setAttribute("data-mode", resolved);
})();
</script>
<style>
:root {
--surface-start: #ffffff;
--surface-end: #f8f9fc;
--accent: #5b6abf;
--accent-rgb: 91, 106, 191;
--ambient-glow: rgba(91, 106, 191, 0.08);
--text: #1a1b1e;
--text-muted: #5f6368;
--text-faint: #9aa0a6;
--border-subtle: rgba(0, 0, 0, 0.05);
--loader-track: rgba(0, 0, 0, 0.06);
--shadow-window:
0 24px 60px rgba(23, 27, 38, 0.10),
0 4px 12px rgba(23, 27, 38, 0.04),
inset 0 1px 0 rgba(255, 255, 255, 1);
--radius-window: 24px;
--ease-ambient: cubic-bezier(0.2, 0.8, 0.2, 1);
--font: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif;
}
[data-mode="dark"] {
--surface-start: #14171d;
--surface-end: #0b0d10;
--accent: #7c8deb;
--accent-rgb: 124, 141, 235;
--ambient-glow: rgba(124, 141, 235, 0.08);
--text: #f0f0f0;
--text-muted: #8b92a5;
--text-faint: #4e5569;
--border-subtle: rgba(255, 255, 255, 0.06);
--loader-track: rgba(255, 255, 255, 0.09);
--shadow-window:
0 24px 80px rgba(0, 0, 0, 0.60),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
--radius-window: 20px;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body {
width: 100%;
height: 100%;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', sans-serif;
background: transparent;
color: var(--text);
font-family: var(--font);
-webkit-font-smoothing: antialiased;
user-select: none;
}
body {
display: grid;
place-items: center;
-webkit-app-region: drag;
}
.splash {
width: 100%; height: 100%;
border-radius: 20px;
.splash-shell {
width: 600px;
height: 380px;
max-width: calc(100vw - 64px);
max-height: calc(100vh - 64px);
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: var(--radius-window);
border: 1px solid var(--border-subtle);
background: linear-gradient(145deg, var(--surface-start), var(--surface-end));
box-shadow: var(--shadow-window);
isolation: isolate;
animation: windowAppear 800ms var(--ease-ambient) both;
}
/* 品牌区 */
.brand {
padding: 48px 52px 0;
.splash-shell::before {
content: "";
position: absolute;
width: 200%;
height: 200%;
left: -50%;
top: -50%;
background: radial-gradient(circle at 50% 40%, var(--ambient-glow) 0%, transparent 44%);
pointer-events: none;
z-index: -1;
}
.brand-stage {
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
align-items: center;
margin-top: -20px;
text-align: center;
animation: contentIn 560ms var(--ease-ambient) 90ms both;
}
.logo-core {
width: 64px;
height: 64px;
display: grid;
place-items: center;
margin-bottom: 24px;
background: transparent;
border: 0;
}
.logo-image {
width: 64px;
height: 64px;
display: block;
object-fit: contain;
border-radius: 20px;
animation: logoBreathe 3200ms ease-in-out infinite alternate;
}
.app-name {
font-size: 24px;
line-height: 1.18;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--text);
margin-bottom: 6px;
}
[data-mode="dark"] .app-name {
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.50);
}
.app-desc {
font-size: 13px;
line-height: 1.5;
font-weight: 500;
letter-spacing: 0.04em;
color: var(--text-muted);
}
[data-mode="dark"] .app-desc {
font-weight: 400;
letter-spacing: 0.05em;
}
.status-row {
position: absolute;
left: 32px;
right: 32px;
bottom: 24px;
z-index: 2;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 18px;
color: var(--text-faint);
font-size: 11px;
line-height: 1.4;
font-variant-numeric: tabular-nums;
animation: contentIn 560ms var(--ease-ambient) 170ms both;
}
.progress-text-wrap {
min-width: 0;
display: flex;
align-items: center;
gap: 18px;
animation: fadeIn 0.4s ease both;
gap: 6px;
color: var(--text-muted);
font-weight: 500;
}
.logo {
width: 56px; height: 56px;
border-radius: 14px;
[data-mode="dark"] .progress-text-wrap {
color: var(--text-faint);
font-weight: 400;
}
.status-dot {
width: 4px;
height: 4px;
flex: 0 0 auto;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 6px rgba(var(--accent-rgb), 0.42);
animation: dotPulse 1700ms ease-in-out infinite;
}
.progress-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
letter-spacing: 0.02em;
}
.version {
flex-shrink: 0;
}
.app-name {
font-size: 22px;
font-weight: 700;
letter-spacing: 0.3px;
}
.app-desc {
font-size: 12px;
margin-top: 5px;
opacity: 0.6;
color: var(--text-faint);
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 10px;
letter-spacing: 0;
opacity: 0.62;
}
.spacer { flex: 1; }
/* 底部进度区 */
.bottom {
padding: 0 48px 40px;
animation: fadeIn 0.4s ease 0.1s both;
[data-mode="dark"] .version {
opacity: 0.50;
}
/* 进度条轨道 */
.progress-track {
width: 100%;
height: 2px;
border-radius: 2px;
margin-bottom: 12px;
position: relative;
overflow: hidden;
}
/* 进度条填充 */
.progress-fill {
height: 100%;
width: 0%;
border-radius: 2px;
position: relative;
transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
}
/* 扫光:只在有进度时显示,不循环 */
.progress-fill::after {
content: '';
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.5) 50%, transparent 100%);
animation: sweep 1.2s ease-out forwards;
left: 0;
right: 0;
bottom: 0;
z-index: 3;
height: 3px;
background: var(--loader-track);
overflow: hidden;
}
[data-mode="dark"] .progress-track {
height: 3px;
}
.progress-fill {
position: absolute;
left: 0;
bottom: 0;
width: 0%;
height: 100%;
min-width: 0;
border-radius: 0 999px 999px 0;
background: var(--accent);
box-shadow: 0 0 18px rgba(var(--accent-rgb), 0.34);
overflow: hidden;
transition: width 440ms var(--ease-ambient);
}
.progress-fill::before {
content: "";
position: absolute;
top: -7px;
right: -18px;
width: 44px;
height: 15px;
border-radius: 999px;
background: rgba(var(--accent-rgb), 0.34);
filter: blur(8px);
opacity: 0;
}
/* 等待阶段:进度条末端呼吸光点 */
.progress-fill.waiting::before {
content: '';
.progress-fill::after {
content: "";
position: absolute;
top: -1px; right: -2px;
width: 6px; height: 4px;
border-radius: 50%;
background: inherit;
filter: blur(2px);
animation: pulse 1.5s ease-in-out infinite;
inset: -1px 0;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.54), transparent);
opacity: 0;
transform: translateX(-100%);
animation: spectralGlide 1200ms ease-out;
}
.bottom-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.progress-text {
font-size: 11px;
opacity: 0.38;
}
.version {
font-size: 11px;
opacity: 0.25;
.progress-fill.waiting::before {
opacity: 0.65;
animation: leadingGlow 1300ms ease-in-out infinite;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
@media (prefers-reduced-motion: reduce) {
.splash-shell,
.brand-stage,
.status-row,
.logo-image,
.status-dot,
.progress-fill,
.progress-fill::before,
.progress-fill::after {
animation: none !important;
transition: none !important;
}
.progress-fill {
left: 0 !important;
opacity: 1 !important;
}
}
@keyframes sweep {
0% { opacity: 0; transform: translateX(-100%); }
20% { opacity: 1; }
80% { opacity: 1; }
100% { opacity: 0; transform: translateX(100%); }
@keyframes windowAppear {
0% {
opacity: 0;
transform: scale(0.97) translateY(12px);
}
100% {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@keyframes pulse {
0%, 100% { opacity: 0.4; transform: scaleX(1); }
50% { opacity: 1; transform: scaleX(1.8); }
@keyframes contentIn {
0% {
opacity: 0;
transform: translateY(8px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes logoBreathe {
0% {
opacity: 0.94;
transform: translateY(0);
}
100% {
opacity: 1;
transform: translateY(-3px);
}
}
@keyframes dotPulse {
0%,
100% {
opacity: 0.38;
transform: scale(0.84);
}
50% {
opacity: 1;
transform: scale(1.18);
}
}
@keyframes leadingGlow {
0%,
100% {
opacity: 0.38;
transform: scaleX(0.78);
}
50% {
opacity: 0.86;
transform: scaleX(1.28);
}
}
@keyframes spectralGlide {
0% {
opacity: 0;
transform: translateX(-100%);
}
22%,
66% {
opacity: 0.58;
}
100% {
opacity: 0;
transform: translateX(100%);
}
}
</style>
</head>
<body>
<div class="splash" id="splash">
<div class="brand">
<img class="logo" src="./logo.png" alt="WeFlow" />
<div class="brand-text">
<div class="app-name" id="appName">WeFlow</div>
<div class="app-desc" id="appDesc">微信聊天记录管理工具</div>
<main class="splash-shell" id="splash" role="status" aria-live="polite">
<section class="brand-stage" aria-label="WeFlow">
<div class="logo-core" aria-hidden="true">
<img class="logo-image" src="./logo.png" alt="">
</div>
<h1 class="app-name">WeFlow</h1>
<p class="app-desc">&#24494;&#20449;&#32842;&#22825;&#35760;&#24405;&#31649;&#29702;&#24037;&#20855;</p>
</section>
<div class="status-row">
<div class="progress-text-wrap">
<div class="status-dot" aria-hidden="true"></div>
<div class="progress-text" id="progressText">&#27491;&#22312;&#39044;&#21152;&#36733;&#20250;&#35805;&#36923;&#36753;...</div>
</div>
<div class="version" id="versionText"></div>
</div>
<div class="spacer"></div>
<div class="bottom">
<div class="progress-track" id="progressTrack">
<div class="progress-fill" id="progressFill"></div>
</div>
<div class="bottom-row">
<div class="progress-text" id="progressText">正在启动...</div>
<div class="version" id="versionText"></div>
</div>
<div class="progress-track" aria-hidden="true">
<div class="progress-fill" id="progressFill"></div>
</div>
</div>
</main>
<script>
var themes = {
'cloud-dancer': {
light: { primary: '#8B7355', bg: '#F0EEE9', bgEnd: '#E5E1DA', text: '#3d3d3d', desc: '#8B7355' },
dark: { primary: '#C9A86C', bg: '#1a1816', bgEnd: '#252220', text: '#F0EEE9', desc: '#C9A86C' }
},
'corundum-blue': {
light: { primary: '#4A6670', bg: '#E8EEF0', bgEnd: '#D8E4E8', text: '#3d3d3d', desc: '#4A6670' },
dark: { primary: '#6A9AAA', bg: '#141a1c', bgEnd: '#1e2a2e', text: '#E0EEF2', desc: '#6A9AAA' }
},
'kiwi-green': {
light: { primary: '#7A9A5C', bg: '#E8F0E4', bgEnd: '#D8E8D2', text: '#3d3d3d', desc: '#7A9A5C' },
dark: { primary: '#9ABA7C', bg: '#161a14', bgEnd: '#222a1e', text: '#E8F0E4', desc: '#9ABA7C' }
},
'spicy-red': {
light: { primary: '#8B4049', bg: '#F0E8E8', bgEnd: '#E8D8D8', text: '#3d3d3d', desc: '#8B4049' },
dark: { primary: '#C06068', bg: '#1a1416', bgEnd: '#261e20', text: '#F2E8EA', desc: '#C06068' }
},
'teal-water': {
light: { primary: '#5A8A8A', bg: '#E4F0F0', bgEnd: '#D2E8E8', text: '#3d3d3d', desc: '#5A8A8A' },
dark: { primary: '#7ABAAA', bg: '#121a1a', bgEnd: '#1a2626', text: '#E0F2EE', desc: '#7ABAAA' }
},
'blossom-dream': {
light: { primary: '#D4849A', primaryEnd: '#D4849A', bg: '#FCF9FB', bgMid: '#F8F2F8', bgEnd: '#F2F6FB', text: '#2E2633', desc: '#D4849A' },
dark: { primary: '#C670C3', primaryEnd: '#8A60C0', bg: '#120B16', bgMid: '#1A1020', bgEnd: '#0E0B18', text: '#F2EAF4', desc: '#C670C3' }
}
};
var themeModeQuery = null;
var systemModeQuery = null;
function applyTheme(themeId, mode) {
var t = themes[themeId] || themes['cloud-dancer'];
var isDark = mode === 'dark';
if (mode === 'system') isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var c = isDark ? t.dark : t.light;
var el = document.getElementById('splash');
var fill = document.getElementById('progressFill');
if (themeId === 'blossom-dream') {
if (isDark) {
// 深色
el.style.background =
'radial-gradient(ellipse 60% 50% at 100% 0%, ' + c.primary + '28 0%, transparent 70%), ' +
'linear-gradient(150deg, ' + c.bg + ' 0%, ' + c.bgMid + ' 45%, ' + c.bgEnd + ' 100%)';
} else {
// 浅色
el.style.background = 'linear-gradient(150deg, ' + c.bg + ' 0%, ' + c.bgMid + ' 45%, ' + c.bgEnd + ' 100%)';
}
// 进度条
fill.style.background = 'linear-gradient(90deg, ' + c.primary + ' 0%, ' + c.primaryEnd + ' 100%)';
} else {
if (isDark) {
el.style.background =
'radial-gradient(ellipse 60% 50% at 100% 0%, ' + c.primary + '22 0%, transparent 70%), ' +
'linear-gradient(145deg, ' + c.bg + ' 0%, ' + c.bgEnd + ' 100%)';
} else {
el.style.background = 'linear-gradient(150deg, ' + c.bg + ' 0%, ' + c.bgEnd + ' 100%)';
}
fill.style.background = c.primary;
}
document.getElementById('appName').style.color = c.text;
document.getElementById('appDesc').style.color = c.desc;
document.getElementById('progressText').style.color = c.text;
document.getElementById('versionText').style.color = c.text;
document.getElementById('progressTrack').style.background = c.primary + (isDark ? '25' : '18');
function resolveMode(mode) {
if (mode === "dark" || mode === "light") return mode;
return window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
function syncSystemModeListener(mode) {
if (!window.matchMedia) return;
var nextQuery = window.matchMedia("(prefers-color-scheme: dark)");
if (systemModeQuery && systemModeQuery !== nextQuery && systemModeQuery.removeEventListener) {
systemModeQuery.removeEventListener("change", handleSystemModeChange);
}
systemModeQuery = nextQuery;
themeModeQuery = mode;
if (mode === "system" && nextQuery.addEventListener) {
nextQuery.addEventListener("change", handleSystemModeChange);
}
}
function handleSystemModeChange() {
if (themeModeQuery === "system") {
document.documentElement.setAttribute("data-mode", resolveMode("system"));
}
}
function applyTheme(themeId, mode) {
var safeThemeId = String(themeId || "cloud-dancer");
var safeMode = mode === "light" || mode === "dark" || mode === "system" ? mode : "system";
var resolvedMode = resolveMode(safeMode);
document.documentElement.setAttribute("data-theme", safeThemeId);
document.documentElement.setAttribute("data-theme-mode", safeMode);
document.documentElement.setAttribute("data-mode", resolvedMode);
syncSystemModeListener(safeMode);
}
// percent: 实际进度值waiting: 是否处于等待阶段
function updateProgress(percent, text, waiting) {
var fill = document.getElementById('progressFill');
var label = document.getElementById('progressText');
var fill = document.getElementById("progressFill");
var label = document.getElementById("progressText");
var safePercent = Math.max(0, Math.min(100, Number(percent) || 0));
if (fill) {
fill.style.width = percent + '%';
fill.style.width = safePercent + "%";
if (waiting) {
fill.classList.add('waiting');
fill.classList.add("waiting");
} else {
fill.classList.remove('waiting');
// 触发扫光:重置动画
fill.style.animation = 'none';
fill.classList.remove("waiting");
fill.style.animation = "none";
fill.offsetHeight;
fill.style.animation = '';
fill.style.animation = "";
}
}
if (label && text) label.textContent = text;
}
function setVersion(ver) {
var el = document.getElementById('versionText');
if (el) el.textContent = 'v' + ver;
function setVersion(version) {
var el = document.getElementById("versionText");
if (!el) return;
var text = String(version || "").trim();
el.textContent = text ? "v" + text.replace(/^v/i, "") : "";
}
applyTheme('cloud-dancer', 'light');
(function bootstrapSplash() {
var params = new URLSearchParams(window.location.search || "");
applyTheme(params.get("themeId") || "cloud-dancer", params.get("themeMode") || "system");
updateProgress(0, "", false);
})();
</script>
</body>
</html>

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 @@
> 目前只适配了x64 win32平台其它平台同样原理但是代码还没写

Binary file not shown.

6
resources/installer/linux/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
*.tar.gz
*.tar.xz
*.zip
src/
pkg/
weflow-*/

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

BIN
resources/key/linux/x64/xkey_helper_linux Normal file → Executable file

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

@@ -3,56 +3,15 @@
display: flex;
flex-direction: column;
background: var(--bg-primary);
animation: appFadeIn 0.35s ease-out;
position: relative;
overflow: hidden;
}
// 繁花如梦:底色层(::before+ 光晕层(::after分离避免 blur 吃掉边缘
[data-theme="blossom-dream"] .app-container {
background: transparent;
}
// ::before 纯底色,不模糊
[data-theme="blossom-dream"] .app-container::before {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
z-index: -2;
background: var(--bg-primary);
}
// ::after 光晕层,模糊叠加在底色上
[data-theme="blossom-dream"] .app-container::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
z-index: -1;
background:
radial-gradient(ellipse 55% 45% at 15% 20%, var(--blossom-pink) 0%, transparent 70%),
radial-gradient(ellipse 50% 40% at 85% 75%, var(--blossom-peach) 0%, transparent 65%),
radial-gradient(ellipse 45% 50% at 80% 10%, var(--blossom-blue) 0%, transparent 60%);
filter: blur(80px);
opacity: 0.75;
}
// 深色模式光晕更克制
[data-theme="blossom-dream"][data-mode="dark"] .app-container::after {
background:
radial-gradient(ellipse 55% 45% at 15% 20%, var(--blossom-pink) 0%, transparent 70%),
radial-gradient(ellipse 50% 40% at 85% 75%, var(--blossom-purple) 0%, transparent 65%),
radial-gradient(ellipse 45% 50% at 80% 10%, var(--blossom-blue) 0%, transparent 60%);
filter: blur(100px);
opacity: 0.2;
}
.window-drag-region {
position: fixed;
top: 0;
left: 0;
right: 150px; // 预留系统最小化/最大化/关闭按钮区域
right: 150px;
height: 40px;
-webkit-app-region: drag;
pointer-events: auto;
@@ -68,8 +27,9 @@
.content {
flex: 1;
overflow: auto;
padding: 24px;
padding: 24px 32px;
position: relative;
background: var(--bg-primary);
}
.export-keepalive-page {
@@ -84,18 +44,7 @@
display: none;
}
@keyframes appFadeIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// 更新提示条
// ---- Update banner ----
.update-banner {
display: flex;
align-items: center;
@@ -107,7 +56,7 @@
.update-text {
flex: 1;
strong {
font-weight: 600;
}
@@ -124,7 +73,7 @@
color: white;
font-size: 13px;
cursor: pointer;
transition: background 0.2s;
transition: background 0.15s;
&:hover {
background: rgba(255, 255, 255, 0.3);
@@ -143,7 +92,7 @@
color: white;
cursor: pointer;
opacity: 0.7;
transition: opacity 0.2s;
transition: opacity 0.15s;
&:hover {
opacity: 1;
@@ -178,29 +127,31 @@
}
}
// 用户协议弹窗
// ---- Agreement modal ----
.agreement-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(4px);
}
.agreement-modal {
width: 520px;
max-height: 80vh;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 16px;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.2);
}
.agreement-header {
@@ -241,8 +192,8 @@
margin-bottom: 16px;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid rgba(255, 160, 0, 0.35);
background: rgba(255, 160, 0, 0.12);
border: 1px solid rgba(245, 158, 11, 0.3);
background: rgba(245, 158, 11, 0.08);
color: var(--text-primary);
strong {
@@ -291,19 +242,6 @@
color: var(--text-secondary);
line-height: 1.6;
}
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 3px;
}
}
.agreement-footer {
@@ -347,21 +285,21 @@
border-radius: 8px;
font-size: 14px;
cursor: pointer;
transition: background 0.2s;
transition: background 0.15s;
&:hover {
background: var(--border-color);
background: var(--bg-hover);
}
}
.btn-primary {
background: var(--primary);
color: white;
color: var(--on-primary);
border: none;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
transition: opacity 0.2s;
transition: opacity 0.15s;
&:disabled {
opacity: 0.5;

View File

@@ -17,6 +17,7 @@ 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'
@@ -25,6 +26,9 @@ 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 BackupPage from './pages/BackupPage'
import InsightInboxPage from './pages/InsightInboxPage'
import { useAppStore } from './stores/appStore'
import { themes, useThemeStore, type ThemeId, type ThemeMode } from './stores/themeStore'
@@ -37,8 +41,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 }) {
@@ -80,6 +82,8 @@ 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 isDualReportWindow = location.pathname === '/dual-report/view'
const isSettingsRoute = location.pathname === '/settings'
const settingsRouteState = location.state as { backgroundLocation?: Location; initialTab?: unknown } | null
const routeLocation = isSettingsRoute
@@ -127,7 +131,7 @@ function App() {
const body = document.body
const appRoot = document.getElementById('app')
if (isOnboardingWindow || isNotificationWindow) {
if (isOnboardingWindow || isNotificationWindow || isAnnualReportWindow || isDualReportWindow) {
root.style.background = 'transparent'
body.style.background = 'transparent'
body.style.overflow = 'hidden'
@@ -144,9 +148,9 @@ function App() {
appRoot.style.overflow = ''
}
}
}, [isOnboardingWindow])
}, [isOnboardingWindow, isNotificationWindow, isAnnualReportWindow, isDualReportWindow])
// 应用主题
// 应用主题 (accent color + light/dark mode)
useEffect(() => {
const mq = window.matchMedia('(prefers-color-scheme: dark)')
const applyMode = (mode: ThemeMode, systemDark?: boolean) => {
@@ -165,7 +169,7 @@ function App() {
}
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [currentTheme, themeMode, isOnboardingWindow, isNotificationWindow])
}, [currentTheme, themeMode, isOnboardingWindow, isNotificationWindow, isAnnualReportWindow, isDualReportWindow])
// 读取已保存的主题设置
useEffect(() => {
@@ -316,6 +320,19 @@ function App() {
}
}, [navigate, isNotificationWindow])
useEffect(() => {
if (isNotificationWindow) return
const removeListener = window.electronAPI?.notification?.onNavigateToRoute?.((route: string) => {
if (!route || !route.startsWith('/')) return
navigate(route, { replace: true })
})
return () => {
removeListener?.()
}
}, [navigate, isNotificationWindow])
// 解锁后显示暂存的更新弹窗
useEffect(() => {
if (!isLocked && updateInfo?.hasUpdate && !showUpdateDialog && !isDownloading) {
@@ -511,6 +528,16 @@ function App() {
return <NotificationWindow />
}
// 独立年度报告全屏窗口
if (isAnnualReportWindow) {
return <AnnualReportWindow />
}
// 独立双人报告全屏窗口
if (isDualReportWindow) {
return <DualReportWindow />
}
// 主窗口 - 完整布局
const handleCloseSettings = () => {
const backgroundLocation = settingsRouteState?.backgroundLocation ?? settingsBackgroundRef.current
@@ -552,10 +579,6 @@ function App() {
{/* 全局会话监听与通知 */}
<GlobalSessionMonitor />
{/* 全局批量转写进度浮窗 */}
<BatchTranscribeGlobal />
<BatchImageDecryptGlobal />
{/* 用户协议弹窗 */}
{showAgreement && !agreementLoading && (
<div className="agreement-overlay">
@@ -677,6 +700,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 />} />
@@ -689,12 +713,15 @@ 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="/insight-inbox" element={<InsightInboxPage />} />
<Route path="/biz" element={<BizPage />} />
<Route path="/contacts" element={<ContactsPage />} />
<Route path="/resources" element={<ResourcesPage />} />
<Route path="/backup" element={<BackupPage />} />
<Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} />
<Route path="/chat-history-inline/:payloadId" element={<ChatHistoryPage />} />
</Routes>

View File

@@ -5,6 +5,21 @@ import './Avatar.scss'
// 全局缓存已成功加载过的头像 URL用于控制后续是否显示动画
const loadedAvatarCache = new Set<string>()
const MAX_LOADED_AVATAR_CACHE_SIZE = 3000
const rememberLoadedAvatar = (src: string): void => {
if (!src) return
if (loadedAvatarCache.has(src)) {
loadedAvatarCache.delete(src)
}
loadedAvatarCache.add(src)
while (loadedAvatarCache.size > MAX_LOADED_AVATAR_CACHE_SIZE) {
const oldest = loadedAvatarCache.values().next().value as string | undefined
if (!oldest) break
loadedAvatarCache.delete(oldest)
}
}
interface AvatarProps {
src?: string
@@ -123,7 +138,7 @@ export const Avatar = React.memo(function Avatar({
onLoad={() => {
if (src) {
avatarLoadQueue.clearFailed(src)
loadedAvatarCache.add(src)
rememberLoadedAvatar(src)
}
setImageLoaded(true)
setImageError(false)

View File

@@ -4,28 +4,29 @@
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 28px;
min-height: 32px;
padding: 4px 0;
background: transparent;
border: none;
border-radius: 0;
flex-shrink: 0;
}
.chat-analysis-back {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0;
gap: 4px;
padding: 4px 8px 4px 4px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text-secondary);
color: var(--text-tertiary);
cursor: pointer;
transition: color 0.2s ease;
transition: background 0.15s ease, color 0.15s ease;
font-size: 13px;
font-weight: 600;
font-weight: 500;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
}
@@ -33,12 +34,13 @@
.chat-analysis-breadcrumb {
display: flex;
align-items: center;
gap: 8px;
gap: 4px;
font-size: 13px;
color: var(--text-secondary);
color: var(--text-tertiary);
.chat-analysis-breadcrumb-separator {
opacity: 0.6;
opacity: 0.5;
font-size: 12px;
}
}
@@ -49,25 +51,27 @@
.chat-analysis-current-trigger {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0;
gap: 4px;
padding: 4px 8px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text-secondary);
color: var(--text-tertiary);
cursor: pointer;
font-size: 13px;
font-weight: 600;
transition: color 0.2s ease;
transition: background 0.15s ease, color 0.15s ease;
.current {
color: var(--text-primary);
}
svg {
transition: transform 0.2s ease;
transition: transform 0.15s ease;
}
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
@@ -78,34 +82,33 @@
.chat-analysis-menu {
position: absolute;
top: calc(100% + 10px);
top: calc(100% + 6px);
right: 0;
min-width: 120px;
padding: 6px;
background: var(--card-bg);
padding: 4px;
background: var(--bg-secondary-solid, var(--bg-secondary));
border: 1px solid var(--border-color);
border-radius: 12px;
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.12);
border-radius: 10px;
box-shadow: var(--shadow-md);
z-index: 20;
}
.chat-analysis-menu-item {
width: 100%;
display: block;
padding: 9px 12px;
padding: 8px 12px;
border: none;
border-radius: 8px;
border-radius: 6px;
background: transparent;
color: var(--text-primary);
text-align: left;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: background 0.2s ease, color 0.2s ease;
transition: background 0.15s ease;
&:hover {
background: var(--bg-hover);
color: var(--primary);
}
}

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;
@@ -288,4 +289,4 @@
}
}
}
}
}

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,13 +6,12 @@
align-items: center;
justify-content: center;
padding: 16px;
z-index: 2400;
z-index: 9200;
}
.export-date-range-dialog {
width: min(480px, calc(100vw - 32px));
max-height: calc(100vh - 64px);
overflow-y: auto;
max-height: calc(100vh - 80px);
border-radius: 16px;
border: 1px solid var(--border-color);
background: var(--bg-secondary-solid, var(--bg-primary));
@@ -21,12 +20,14 @@
flex-direction: column;
gap: 10px;
box-shadow: 0 22px 48px rgba(0, 0, 0, 0.16);
overflow: hidden;
}
.export-date-range-dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
h4 {
margin: 0;
@@ -35,6 +36,26 @@
}
}
.export-date-range-dialog-content {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
display: flex;
flex-direction: column;
gap: 10px;
padding-right: 2px;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 3px;
}
}
.export-date-range-dialog-close-btn {
border: 1px solid var(--border-color);
background: var(--bg-secondary);
@@ -192,6 +213,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;
@@ -296,6 +460,7 @@
display: flex;
justify-content: flex-end;
gap: 8px;
flex-shrink: 0;
}
.export-date-range-dialog-btn {

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
@@ -316,6 +565,7 @@ export function ExportDateRangeDialog({
</button>
</div>
<div className="export-date-range-dialog-content">
<div className="export-date-range-preset-list">
{EXPORT_DATE_RANGE_PRESETS.map((preset) => {
const active = isPresetActive(preset.value)
@@ -364,6 +614,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 +658,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>
@@ -445,6 +729,7 @@ export function ExportDateRangeDialog({
})}
</div>
</section>
</div>
<div className="export-date-range-dialog-actions">
<button type="button" className="export-date-range-dialog-btn secondary" onClick={onClose}>
@@ -453,7 +738,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

@@ -457,3 +457,130 @@
}
}
}
// UI rebuild polish for the modal variant used by ExportPage.
.export-defaults-settings-form.layout-split {
display: grid;
gap: 10px;
.form-group {
grid-template-columns: minmax(176px, 0.82fr) minmax(0, 1.18fr);
gap: 12px;
align-items: start;
padding: 12px;
border: none;
border-radius: 12px;
background: color-mix(in srgb, var(--bg-secondary) 82%, var(--bg-primary));
}
.form-group:first-child,
.form-group:last-child {
padding: 12px;
}
.form-copy {
padding-top: 2px;
}
label {
margin-bottom: 3px;
line-height: 1.35;
}
.form-hint {
line-height: 1.45;
}
.form-control {
width: 100%;
min-width: 0;
justify-content: stretch;
}
.select-field,
.settings-time-range-field,
.log-toggle-line,
.media-default-grid,
.concurrency-inline-options {
max-width: none;
width: 100%;
}
.select-trigger,
.settings-time-range-trigger {
border-radius: 12px;
background: var(--bg-primary);
min-height: 42px;
padding: 9px 12px;
}
.log-toggle-line {
border-radius: 12px;
background: var(--bg-primary);
min-height: 42px;
padding: 8px 12px;
}
.concurrency-inline-options {
grid-template-columns: repeat(6, minmax(38px, 1fr));
gap: 6px;
}
.concurrency-option {
min-width: 0;
min-height: 36px;
border-radius: 8px;
}
.format-setting-group {
grid-template-columns: 1fr;
gap: 10px;
}
.format-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
width: 100%;
}
.format-card {
min-height: 68px;
padding: 10px 12px;
border-radius: 8px;
background: var(--bg-primary);
}
.format-label,
.format-desc {
max-width: 100%;
overflow-wrap: anywhere;
}
.media-default-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(78px, 1fr));
gap: 8px;
label {
min-height: 36px;
padding: 8px 10px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-primary);
}
}
}
@media (max-width: 980px) {
.export-defaults-settings-form.layout-split {
.form-group {
grid-template-columns: 1fr;
gap: 10px;
}
.format-grid {
grid-template-columns: repeat(auto-fit, minmax(156px, 1fr));
}
}
}

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,12 +62,15 @@ 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,
@@ -76,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(),
@@ -91,6 +101,7 @@ export function ExportDefaultsSettingsForm({
setExportDefaultFormat(savedFormat || 'excel')
setExportDefaultAvatars(savedAvatars ?? true)
setExportDefaultDateRange(resolveExportDateRangeConfig(savedDateRange))
setExportDefaultFileNamingMode(savedFileNamingMode ?? 'classic')
setExportDefaultMedia(savedMedia ?? {
images: true,
videos: true,
@@ -114,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)
@@ -224,6 +239,7 @@ export function ExportDefaultsSettingsForm({
className={`settings-time-range-trigger ${isExportDateRangeDialogOpen ? 'open' : ''}`}
onClick={() => {
setShowExportExcelColumnsSelect(false)
setShowExportFileNamingModeSelect(false)
setIsExportDateRangeDialogOpen(true)
}}
>
@@ -247,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>
@@ -259,6 +319,7 @@ export function ExportDefaultsSettingsForm({
className={`select-trigger ${showExportExcelColumnsSelect ? 'open' : ''}`}
onClick={() => {
setShowExportExcelColumnsSelect(!showExportExcelColumnsSelect)
setShowExportFileNamingModeSelect(false)
setIsExportDateRangeDialogOpen(false)
}}
>

View File

@@ -22,7 +22,7 @@ export function GlobalSessionMonitor() {
// 去重辅助函数:获取消息 key
const getMessageKey = (msg: Message) => {
if (msg.messageKey) return msg.messageKey
return `fallback:${msg.serverId || 0}:${msg.createTime}:${msg.sortSeq || 0}:${msg.localId || 0}:${msg.senderUsername || ''}:${msg.localType || 0}`
return `fallback:${msg._db_path || ''}:${msg.serverId || 0}:${msg.createTime}:${msg.sortSeq || 0}:${msg.localId || 0}:${msg.senderUsername || ''}:${msg.localType || 0}`
}
// 处理数据库变更

View File

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

View File

@@ -7,6 +7,9 @@ import './NotificationToast.scss'
export interface NotificationData {
id: string
sessionId: string
channel?: string
insightRecordId?: string
targetRoute?: string
avatarUrl?: string
title: string
content: string
@@ -16,7 +19,7 @@ export interface NotificationData {
interface NotificationToastProps {
data: NotificationData | null
onClose: () => void
onClick: (sessionId: string) => void
onClick: (data: NotificationData) => void
duration?: number
position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'
isStatic?: boolean
@@ -64,7 +67,7 @@ export function NotificationToast({
setIsVisible(false)
setTimeout(() => {
onClose()
onClick(currentData.sessionId)
onClick(currentData)
}, 300)
}

View File

@@ -6,7 +6,7 @@ interface RouteGuardProps {
children: React.ReactNode
}
const PUBLIC_ROUTES = ['/', '/home', '/settings']
const PUBLIC_ROUTES = ['/', '/home', '/settings', '/account-management']
function RouteGuard({ children }: RouteGuardProps) {
const navigate = useNavigate()

View File

@@ -1,14 +1,17 @@
// Redesigned sidebar — premium feel with left accent bar, refined spacing
.sidebar {
width: 220px;
background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
width: var(--sidebar-width, 260px);
background: var(--bg-sidebar, var(--bg-secondary));
display: flex;
flex-direction: column;
padding: 16px 0;
transition: width 0.25s ease;
padding: 0;
transition: width 0.2s cubic-bezier(0.4, 0, 0.2, 1);
flex-shrink: 0;
overflow: hidden;
border-right: 1px solid var(--border-color);
&.collapsed {
width: 64px;
width: 68px;
.sidebar-user-card-wrap {
margin: 0 8px 8px;
@@ -21,28 +24,166 @@
.user-meta {
display: none;
}
.user-menu-caret {
display: none;
}
}
.nav-menu,
.sidebar-footer {
.nav-menu {
padding: 0 8px;
}
.sidebar-footer {
padding: 0 8px;
padding-top: 8px;
}
.nav-label {
display: none;
}
.nav-badge:not(.icon-badge) {
display: none;
}
.nav-item {
justify-content: center;
padding: 10px;
gap: 0;
&::before {
display: none;
}
}
}
}
// ---- Navigation ----
.nav-menu {
flex: 1;
display: flex;
flex-direction: column;
gap: 1px;
padding: 12px 10px;
overflow-y: auto;
overflow-x: hidden;
}
.nav-item {
position: relative;
display: flex;
align-items: center;
gap: 12px;
padding: 9px 14px;
border-radius: 10px;
color: var(--text-secondary);
text-decoration: none;
transition: background 0.15s ease, color 0.15s ease;
white-space: nowrap;
border: none;
background: transparent;
cursor: pointer;
font-family: inherit;
font-size: 14px;
margin: 1px 0;
// Left accent bar for active state
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%) scaleY(0);
width: 3px;
height: 16px;
border-radius: 0 2px 2px 0;
background: var(--primary);
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
&.active {
background: var(--bg-hover);
color: var(--text-primary);
font-weight: 600;
&::before {
transform: translateY(-50%) scaleY(1);
}
.nav-icon {
color: var(--primary);
}
}
}
.nav-icon {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
flex-shrink: 0;
transition: color 0.15s ease;
}
.nav-icon-with-badge {
position: relative;
}
.nav-label {
font-size: 14px;
font-weight: 500;
}
.nav-badge {
margin-left: auto;
min-width: 20px;
height: 20px;
border-radius: 999px;
padding: 0 6px;
background: #ef4444;
color: #ffffff;
font-size: 11px;
font-weight: 700;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.nav-badge.icon-badge {
position: absolute;
top: -7px;
right: -10px;
margin-left: 0;
min-width: 16px;
height: 16px;
padding: 0 4px;
font-size: 10px;
box-shadow: 0 0 0 2px var(--bg-sidebar, var(--bg-secondary));
}
// ---- Footer ----
.sidebar-footer {
padding: 4px 10px;
border-top: 1px solid var(--border-color);
padding-top: 8px;
margin-top: 4px;
display: flex;
flex-direction: column;
gap: 1px;
}
// ---- User card ----
.sidebar-user-card-wrap {
position: relative;
margin: 0 12px 10px;
margin: 0 10px 10px;
--sidebar-user-menu-width: 172px;
}
@@ -55,16 +196,16 @@
z-index: 12;
border: 1px solid var(--border-color);
border-radius: 12px;
background: var(--bg-secondary-solid, var(--bg-primary));
background: var(--bg-secondary-solid, var(--bg-secondary));
display: flex;
flex-direction: column;
gap: 4px;
padding: 6px;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12);
gap: 2px;
padding: 4px;
box-shadow: var(--shadow-md);
opacity: 0;
transform: translateY(8px) scale(0.95);
transform: translateY(6px) scale(0.97);
pointer-events: none;
transition: opacity 0.2s ease, transform 0.2s ease;
transition: opacity 0.15s ease, transform 0.15s ease;
&.open {
opacity: 1;
@@ -76,10 +217,10 @@
.sidebar-user-menu-item {
width: 100%;
border: none;
border-radius: 10px;
border-radius: 8px;
background: transparent;
color: var(--text-primary);
padding: 9px 10px;
padding: 8px 10px;
display: flex;
align-items: center;
gap: 8px;
@@ -87,54 +228,53 @@
font-weight: 500;
cursor: pointer;
text-align: left;
transition: background 0.2s ease, color 0.2s ease;
transition: background 0.15s ease;
&:hover {
background: var(--bg-tertiary);
background: var(--bg-hover);
}
&.danger {
color: #d93025;
color: #ef4444;
&:hover {
background: rgba(255, 59, 48, 0.08);
background: rgba(239, 68, 68, 0.08);
}
}
}
.sidebar-user-card {
width: 100%;
padding: 10px;
border: 1px solid var(--border-color);
border-radius: 12px;
background: var(--bg-secondary);
padding: 10px 12px;
border-radius: 10px;
background: transparent;
display: flex;
align-items: center;
gap: 10px;
min-height: 56px;
min-height: 52px;
cursor: pointer;
transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
border: none;
transition: background 0.15s ease;
&:hover {
border-color: rgba(99, 102, 241, 0.32);
background: var(--bg-tertiary);
background: var(--bg-hover);
}
&.menu-open {
border-color: rgba(99, 102, 241, 0.44);
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.12);
background: var(--bg-hover);
}
.user-avatar {
width: 36px;
height: 36px;
border-radius: 10px;
width: 34px;
height: 34px;
border-radius: 50%;
overflow: hidden;
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
background: var(--primary);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 0 0 2px var(--bg-sidebar, var(--bg-secondary));
img {
width: 100%;
@@ -144,7 +284,7 @@
span {
color: var(--on-primary);
font-size: 14px;
font-size: 13px;
font-weight: 600;
}
}
@@ -164,7 +304,7 @@
}
.user-wxid {
margin-top: 2px;
margin-top: 1px;
font-size: 11px;
color: var(--text-tertiary);
white-space: nowrap;
@@ -175,386 +315,10 @@
.user-menu-caret {
color: var(--text-tertiary);
display: inline-flex;
transition: transform 0.2s ease, color 0.2s ease;
transition: transform 0.15s ease;
&.open {
transform: rotate(180deg);
color: var(--text-secondary);
}
}
}
.nav-menu {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
padding: 0 12px;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: 9999px;
color: var(--text-secondary);
text-decoration: none;
transition: all 0.2s ease;
white-space: nowrap;
border: none;
background: transparent;
cursor: pointer;
font-family: inherit;
&:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
&.active {
background: var(--primary);
color: var(--on-primary);
}
}
.nav-icon {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
flex-shrink: 0;
}
.nav-icon-with-badge {
position: relative;
}
.nav-label {
font-size: 14px;
font-weight: 500;
}
.nav-badge {
margin-left: auto;
min-width: 20px;
height: 20px;
border-radius: 999px;
padding: 0 6px;
background: #ff3b30;
color: #ffffff;
font-size: 11px;
font-weight: 700;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
box-shadow: 0 0 0 2px rgba(255, 59, 48, 0.18);
}
.nav-badge.icon-badge {
position: absolute;
top: -7px;
right: -10px;
margin-left: 0;
min-width: 16px;
height: 16px;
padding: 0 4px;
font-size: 10px;
box-shadow: 0 0 0 2px var(--bg-secondary);
}
.sidebar-footer {
padding: 0 12px;
border-top: 1px solid var(--border-color);
padding-top: 12px;
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 4px;
}
.sidebar-dialog-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.3);
display: flex;
align-items: center;
justify-content: center;
z-index: 1100;
padding: 20px;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.sidebar-dialog {
width: min(420px, 100%);
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 16px;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.24);
padding: 18px 18px 16px;
animation: slideUp 0.25s ease;
h3 {
margin: 0;
font-size: 16px;
color: var(--text-primary);
}
p {
margin: 10px 0 0;
font-size: 13px;
line-height: 1.6;
color: var(--text-secondary);
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.sidebar-wxid-list {
margin-top: 14px;
display: flex;
flex-direction: column;
gap: 8px;
max-height: 300px;
overflow-y: auto;
}
.sidebar-wxid-item {
width: 100%;
padding: 12px 14px;
border: 1px solid var(--border-color);
border-radius: 10px;
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
display: flex;
align-items: center;
gap: 12px;
transition: all 0.2s ease;
&:hover:not(:disabled) {
border-color: rgba(99, 102, 241, 0.32);
background: var(--bg-tertiary);
}
&.current {
border-color: rgba(99, 102, 241, 0.5);
background: var(--bg-tertiary);
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.wxid-avatar {
width: 40px;
height: 40px;
border-radius: 10px;
overflow: hidden;
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
span {
color: var(--on-primary);
font-size: 16px;
font-weight: 600;
}
}
.wxid-info {
flex: 1;
min-width: 0;
text-align: left;
}
.wxid-name {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.wxid-id {
margin-top: 2px;
font-size: 12px;
color: var(--text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.current-badge {
padding: 4px 10px;
border-radius: 6px;
background: var(--primary);
color: var(--on-primary);
font-size: 11px;
font-weight: 600;
flex-shrink: 0;
}
}
.sidebar-dialog-actions {
margin-top: 18px;
display: flex;
justify-content: flex-end;
gap: 10px;
button {
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 8px 14px;
font-size: 13px;
cursor: pointer;
background: var(--bg-secondary);
color: var(--text-primary);
transition: all 0.2s ease;
&:hover:not(:disabled) {
background: var(--bg-tertiary);
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
}
}
}
.sidebar-clear-dialog-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.3);
display: flex;
align-items: center;
justify-content: center;
z-index: 1100;
padding: 20px;
animation: fadeIn 0.2s ease;
}
.sidebar-clear-dialog {
width: min(460px, 100%);
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 16px;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.24);
padding: 18px 18px 16px;
animation: slideUp 0.25s ease;
h3 {
margin: 0;
font-size: 16px;
color: var(--text-primary);
}
p {
margin: 10px 0 0;
font-size: 13px;
line-height: 1.6;
color: var(--text-secondary);
}
}
.sidebar-clear-options {
margin-top: 14px;
display: flex;
gap: 14px;
flex-wrap: wrap;
label {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text-primary);
}
}
.sidebar-clear-actions {
margin-top: 18px;
display: flex;
justify-content: flex-end;
gap: 10px;
button {
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 8px 14px;
font-size: 13px;
cursor: pointer;
background: var(--bg-secondary);
color: var(--text-primary);
}
button:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.danger {
border-color: #ef4444;
background: #ef4444;
color: #fff;
}
}
// 繁花如梦主题:侧边栏毛玻璃 + 激活项用主品牌色
[data-theme="blossom-dream"] .sidebar {
background: rgba(255, 255, 255, 0.6);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-right: 1px solid rgba(255, 255, 255, 0.4);
}
[data-theme="blossom-dream"][data-mode="dark"] .sidebar {
background: rgba(34, 30, 36, 0.75);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-right: 1px solid rgba(255, 255, 255, 0.06);
}
// 激活项:主品牌色纵向微渐变
[data-theme="blossom-dream"] .nav-item.active {
background: linear-gradient(180deg, #D4849A 0%, #C4748A 100%);
}
// 深色激活项:用藕粉色,背景深灰底 + 粉色文字/图标(高阶玩法)
[data-theme="blossom-dream"][data-mode="dark"] .nav-item.active {
background: rgba(209, 158, 187, 0.15);
color: #D19EBB;
border: 1px solid rgba(209, 158, 187, 0.2);
}

View File

@@ -1,12 +1,9 @@
import { useState, useEffect, useRef } from 'react'
import { NavLink, useLocation, useNavigate } from 'react-router-dom'
import { Home, MessageSquare, BarChart3, FileText, Settings, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, RefreshCw, FolderClosed } from 'lucide-react'
import { Home, MessageSquare, BarChart3, FileText, Settings, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, FolderClosed, Footprints, Users, ArchiveRestore, Sparkles } from 'lucide-react'
import { useAppStore } from '../stores/appStore'
import { useChatStore } from '../stores/chatStore'
import { useAnalyticsStore } from '../stores/analyticsStore'
import * as configService from '../services/config'
import { onExportSessionStatus, requestExportSessionStatus } from '../services/exportBridge'
import { UserRound } from 'lucide-react'
import './Sidebar.scss'
@@ -19,6 +16,8 @@ interface SidebarUserProfile {
const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1'
const ACCOUNT_PROFILES_CACHE_KEY = 'account_profiles_cache_v1'
const DEFAULT_DISPLAY_NAME = '微信用户'
const DEFAULT_SUBTITLE = '微信账号'
interface SidebarUserProfileCache extends SidebarUserProfile {
updatedAt: number
@@ -33,24 +32,16 @@ interface AccountProfilesCache {
}
}
interface WxidOption {
wxid: string
modifiedTime: number
nickname?: string
displayName?: string
avatarUrl?: string
}
const readSidebarUserProfileCache = (): SidebarUserProfile | null => {
try {
const raw = window.localStorage.getItem(SIDEBAR_USER_PROFILE_CACHE_KEY)
if (!raw) return null
const parsed = JSON.parse(raw) as SidebarUserProfileCache
if (!parsed || typeof parsed !== 'object') return null
if (!parsed.wxid || !parsed.displayName) return null
if (!parsed.wxid) return null
return {
wxid: parsed.wxid,
displayName: parsed.displayName,
displayName: typeof parsed.displayName === 'string' ? parsed.displayName : '',
alias: parsed.alias,
avatarUrl: parsed.avatarUrl
}
@@ -60,7 +51,7 @@ const readSidebarUserProfileCache = (): SidebarUserProfile | null => {
}
const writeSidebarUserProfileCache = (profile: SidebarUserProfile): void => {
if (!profile.wxid || !profile.displayName) return
if (!profile.wxid) return
try {
const payload: SidebarUserProfileCache = {
...profile,
@@ -115,17 +106,11 @@ function Sidebar({ collapsed }: SidebarProps) {
const [activeExportTaskCount, setActiveExportTaskCount] = useState(0)
const [userProfile, setUserProfile] = useState<SidebarUserProfile>({
wxid: '',
displayName: '未识别用户'
displayName: DEFAULT_DISPLAY_NAME
})
const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false)
const [showSwitchAccountDialog, setShowSwitchAccountDialog] = useState(false)
const [wxidOptions, setWxidOptions] = useState<WxidOption[]>([])
const [isSwitchingAccount, setIsSwitchingAccount] = useState(false)
const accountCardWrapRef = useRef<HTMLDivElement | null>(null)
const setLocked = useAppStore(state => state.setLocked)
const isDbConnected = useAppStore(state => state.isDbConnected)
const resetChatStore = useChatStore(state => state.reset)
const clearAnalyticsStoreCache = useAnalyticsStore(state => state.clearCache)
useEffect(() => {
window.electronAPI.auth.verifyEnabled().then(setAuthEnabled)
@@ -164,18 +149,20 @@ function Sidebar({ collapsed }: SidebarProps) {
}, [])
useEffect(() => {
let disposed = false
let loadSeq = 0
const loadCurrentUser = async () => {
const patchUserProfile = (patch: Partial<SidebarUserProfile>, expectedWxid?: string) => {
const seq = ++loadSeq
const patchUserProfile = (patch: Partial<SidebarUserProfile>) => {
if (disposed || seq !== loadSeq) return
setUserProfile(prev => {
if (expectedWxid && prev.wxid && prev.wxid !== expectedWxid) {
return prev
}
const next: SidebarUserProfile = {
...prev,
...patch
}
if (!next.displayName) {
next.displayName = next.wxid || '未识别用户'
if (typeof next.displayName !== 'string' || next.displayName.length === 0) {
next.displayName = DEFAULT_DISPLAY_NAME
}
writeSidebarUserProfileCache(next)
return next
@@ -184,11 +171,33 @@ function Sidebar({ collapsed }: SidebarProps) {
try {
const wxid = await configService.getMyWxid()
if (disposed || seq !== loadSeq) return
const resolvedWxidRaw = String(wxid || '').trim()
const cleanedWxid = normalizeAccountId(resolvedWxidRaw)
const resolvedWxid = cleanedWxid || resolvedWxidRaw
if (!resolvedWxidRaw && !resolvedWxid) return
if (!resolvedWxidRaw && !resolvedWxid) {
window.localStorage.removeItem(SIDEBAR_USER_PROFILE_CACHE_KEY)
patchUserProfile({
wxid: '',
displayName: DEFAULT_DISPLAY_NAME,
alias: undefined,
avatarUrl: undefined
})
return
}
setUserProfile((prev) => {
if (prev.wxid === resolvedWxid) return prev
const seeded: SidebarUserProfile = {
wxid: resolvedWxid,
displayName: DEFAULT_DISPLAY_NAME,
alias: undefined,
avatarUrl: undefined
}
writeSidebarUserProfileCache(seeded)
return seeded
})
const wxidCandidates = new Set<string>([
resolvedWxidRaw.toLowerCase(),
@@ -197,14 +206,13 @@ function Sidebar({ collapsed }: SidebarProps) {
].filter(Boolean))
const normalizeName = (value?: string | null): string | undefined => {
if (!value) return undefined
const trimmed = value.trim()
if (!trimmed) return undefined
const lowered = trimmed.toLowerCase()
if (typeof value !== 'string') return undefined
if (value.length === 0) return undefined
const lowered = value.trim().toLowerCase()
if (lowered === 'self') return undefined
if (lowered.startsWith('wxid_')) return undefined
if (wxidCandidates.has(lowered)) return undefined
return trimmed
return value
}
const pickFirstValidName = (...candidates: Array<string | null | undefined>): string | undefined => {
@@ -229,18 +237,20 @@ function Sidebar({ collapsed }: SidebarProps) {
})(),
window.electronAPI.chat.getMyAvatarUrl()
])
if (disposed || seq !== loadSeq) return
const myContact = contactResult.status === 'fulfilled' ? contactResult.value : null
const displayName = pickFirstValidName(
myContact?.remark,
myContact?.nickName,
myContact?.alias
) || resolvedWxid || '未识别用户'
) || DEFAULT_DISPLAY_NAME
const alias = normalizeName(myContact?.alias)
patchUserProfile({
wxid: resolvedWxid,
displayName,
alias: myContact?.alias,
alias,
avatarUrl: avatarResult.status === 'fulfilled' && avatarResult.value.success
? avatarResult.value.avatarUrl
: undefined
@@ -257,118 +267,28 @@ function Sidebar({ collapsed }: SidebarProps) {
void loadCurrentUser()
const onWxidChanged = () => { void loadCurrentUser() }
const onWindowFocus = () => { void loadCurrentUser() }
const onVisibilityChange = () => {
if (document.visibilityState === 'visible') {
void loadCurrentUser()
}
}
window.addEventListener('wxid-changed', onWxidChanged as EventListener)
return () => window.removeEventListener('wxid-changed', onWxidChanged as EventListener)
window.addEventListener('focus', onWindowFocus)
document.addEventListener('visibilitychange', onVisibilityChange)
return () => {
disposed = true
loadSeq += 1
window.removeEventListener('wxid-changed', onWxidChanged as EventListener)
window.removeEventListener('focus', onWindowFocus)
document.removeEventListener('visibilitychange', onVisibilityChange)
}
}, [])
const getAvatarLetter = (name: string): string => {
if (!name) return '?'
return [...name][0] || '?'
}
const openSwitchAccountDialog = async () => {
setIsAccountMenuOpen(false)
if (!isDbConnected) {
window.alert('数据库未连接,无法切换账号')
return
}
const dbPath = await configService.getDbPath()
if (!dbPath) {
window.alert('请先在设置中配置数据库路径')
return
}
try {
const wxids = await window.electronAPI.dbPath.scanWxids(dbPath)
const accountsCache = readAccountProfilesCache()
console.log('[切换账号] 账号缓存:', accountsCache)
const enrichedWxids = wxids.map((option: WxidOption) => {
const normalizedWxid = normalizeAccountId(option.wxid)
const cached = accountsCache[option.wxid] || accountsCache[normalizedWxid]
let displayName = option.nickname || option.wxid
let avatarUrl = option.avatarUrl
if (option.wxid === userProfile.wxid || normalizedWxid === userProfile.wxid) {
displayName = userProfile.displayName || displayName
avatarUrl = userProfile.avatarUrl || avatarUrl
}
else if (cached) {
displayName = cached.displayName || displayName
avatarUrl = cached.avatarUrl || avatarUrl
}
return {
...option,
displayName,
avatarUrl
}
})
setWxidOptions(enrichedWxids)
setShowSwitchAccountDialog(true)
} catch (error) {
console.error('扫描账号失败:', error)
window.alert('扫描账号失败,请稍后重试')
}
}
const handleSwitchAccount = async (selectedWxid: string) => {
if (!selectedWxid || isSwitchingAccount) return
setIsSwitchingAccount(true)
try {
console.log('[切换账号] 开始切换到:', selectedWxid)
const currentWxid = userProfile.wxid
if (currentWxid === selectedWxid) {
console.log('[切换账号] 已经是当前账号,跳过')
setShowSwitchAccountDialog(false)
setIsSwitchingAccount(false)
return
}
console.log('[切换账号] 设置新 wxid')
await configService.setMyWxid(selectedWxid)
console.log('[切换账号] 获取账号配置')
const wxidConfig = await configService.getWxidConfig(selectedWxid)
console.log('[切换账号] 配置内容:', wxidConfig)
if (wxidConfig?.decryptKey) {
console.log('[切换账号] 设置 decryptKey')
await configService.setDecryptKey(wxidConfig.decryptKey)
}
if (typeof wxidConfig?.imageXorKey === 'number') {
console.log('[切换账号] 设置 imageXorKey:', wxidConfig.imageXorKey)
await configService.setImageXorKey(wxidConfig.imageXorKey)
}
if (wxidConfig?.imageAesKey) {
console.log('[切换账号] 设置 imageAesKey')
await configService.setImageAesKey(wxidConfig.imageAesKey)
}
console.log('[切换账号] 检查数据库连接状态')
console.log('[切换账号] 数据库连接状态:', isDbConnected)
if (isDbConnected) {
console.log('[切换账号] 关闭数据库连接')
await window.electronAPI.chat.close()
}
console.log('[切换账号] 清除缓存')
window.localStorage.removeItem(SIDEBAR_USER_PROFILE_CACHE_KEY)
clearAnalyticsStoreCache()
resetChatStore()
console.log('[切换账号] 触发 wxid-changed 事件')
window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: selectedWxid } }))
console.log('[切换账号] 切换成功')
setShowSwitchAccountDialog(false)
} catch (error) {
console.error('[切换账号] 失败:', error)
window.alert('切换账号失败,请稍后重试')
} finally {
setIsSwitchingAccount(false)
}
if (!name) return ''
const visible = name.trim()
return (visible && [...visible][0]) || '微'
}
const openSettingsFromAccountMenu = () => {
@@ -380,6 +300,11 @@ function Sidebar({ collapsed }: SidebarProps) {
})
}
const openAccountManagement = () => {
setIsAccountMenuOpen(false)
navigate('/account-management')
}
const isActive = (path: string) => {
return location.pathname === path || location.pathname.startsWith(`${path}/`)
}
@@ -419,6 +344,15 @@ function Sidebar({ collapsed }: SidebarProps) {
<span className="nav-label"></span>
</NavLink>
<NavLink
to="/insight-inbox"
className={`nav-item ${isActive('/insight-inbox') ? 'active' : ''}`}
title={collapsed ? '灵感信箱' : undefined}
>
<span className="nav-icon"><Sparkles size={20} /></span>
<span className="nav-label"></span>
</NavLink>
{/* 通讯录 */}
<NavLink
to="/contacts"
@@ -459,6 +393,16 @@ function Sidebar({ collapsed }: SidebarProps) {
<span className="nav-label"></span>
</NavLink>
{/* 我的足迹 */}
<NavLink
to="/footprint"
className={`nav-item ${isActive('/footprint') ? 'active' : ''}`}
title={collapsed ? '我的足迹' : undefined}
>
<span className="nav-icon"><Footprints size={20} /></span>
<span className="nav-label"></span>
</NavLink>
{/* 导出 */}
<NavLink
to="/export"
@@ -477,6 +421,15 @@ function Sidebar({ collapsed }: SidebarProps) {
)}
</NavLink>
<NavLink
to="/backup"
className={`nav-item ${isActive('/backup') ? 'active' : ''}`}
title={collapsed ? '数据库备份' : undefined}
>
<span className="nav-icon"><ArchiveRestore size={20} /></span>
<span className="nav-label"></span>
</NavLink>
</nav>
@@ -505,12 +458,12 @@ function Sidebar({ collapsed }: SidebarProps) {
<div className={`sidebar-user-menu ${isAccountMenuOpen ? 'open' : ''}`} role="menu" aria-label="账号菜单">
<button
className="sidebar-user-menu-item"
onClick={openSwitchAccountDialog}
onClick={openAccountManagement}
type="button"
role="menuitem"
>
<RefreshCw size={14} />
<span></span>
<Users size={14} />
<span></span>
</button>
<button
className="sidebar-user-menu-item"
@@ -524,7 +477,7 @@ function Sidebar({ collapsed }: SidebarProps) {
</div>
<div
className={`sidebar-user-card ${isAccountMenuOpen ? 'menu-open' : ''}`}
title={collapsed ? `${userProfile.displayName}${(userProfile.alias || userProfile.wxid) ? `\n${userProfile.alias || userProfile.wxid}` : ''}` : undefined}
title={collapsed ? `${userProfile.displayName}${(userProfile.alias) ? `\n${userProfile.alias}` : ''}` : undefined}
onClick={() => setIsAccountMenuOpen(prev => !prev)}
role="button"
tabIndex={0}
@@ -539,8 +492,8 @@ function Sidebar({ collapsed }: SidebarProps) {
{userProfile.avatarUrl ? <img src={userProfile.avatarUrl} alt="" /> : <span>{getAvatarLetter(userProfile.displayName)}</span>}
</div>
<div className="user-meta">
<div className="user-name">{userProfile.displayName}</div>
<div className="user-wxid">{userProfile.alias || userProfile.wxid || 'wxid 未识别'}</div>
<div className="user-name">{userProfile.displayName || DEFAULT_DISPLAY_NAME}</div>
<div className="user-wxid">{userProfile.alias || DEFAULT_SUBTITLE}</div>
</div>
{!collapsed && (
<span className={`user-menu-caret ${isAccountMenuOpen ? 'open' : ''}`}>
@@ -551,44 +504,6 @@ function Sidebar({ collapsed }: SidebarProps) {
</div>
</div>
</aside>
{showSwitchAccountDialog && (
<div className="sidebar-dialog-overlay" onClick={() => !isSwitchingAccount && setShowSwitchAccountDialog(false)}>
<div className="sidebar-dialog" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
<h3></h3>
<p></p>
<div className="sidebar-wxid-list">
{wxidOptions.map((option) => (
<button
key={option.wxid}
className={`sidebar-wxid-item ${userProfile.wxid === option.wxid ? 'current' : ''}`}
onClick={() => handleSwitchAccount(option.wxid)}
disabled={isSwitchingAccount}
type="button"
>
<div className="wxid-avatar">
{option.avatarUrl ? (
<img src={option.avatarUrl} alt="" />
) : (
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--bg-tertiary)', borderRadius: '6px', color: 'var(--text-tertiary)' }}>
<UserRound size={16} />
</div>
)}
</div>
<div className="wxid-info">
<div className="wxid-name">{option.displayName}</div>
{option.displayName !== option.wxid && <div className="wxid-id">{option.wxid}</div>}
</div>
{userProfile.wxid === option.wxid && <span className="current-badge"></span>}
</button>
))}
</div>
<div className="sidebar-dialog-actions">
<button type="button" onClick={() => setShowSwitchAccountDialog(false)} disabled={isSwitchingAccount}></button>
</div>
</div>
</div>
)}
</>
)
}

View File

@@ -6,16 +6,16 @@
align-items: center;
justify-content: center;
padding: 24px 16px;
background: rgba(15, 23, 42, 0.38);
background: rgba(15, 23, 42, 0.28);
}
.contact-sns-dialog {
width: min(760px, 100%);
max-height: min(86vh, 860px);
border-radius: 14px;
width: min(720px, 100%);
max-height: min(84vh, 820px);
border-radius: 10px;
border: 1px solid var(--border-color);
background: var(--bg-secondary-solid, #ffffff);
box-shadow: 0 22px 46px rgba(0, 0, 0, 0.24);
box-shadow: 0 18px 34px rgba(0, 0, 0, 0.18);
display: flex;
flex-direction: column;
overflow: hidden;
@@ -29,7 +29,7 @@
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 14px 16px;
padding: 12px 14px;
border-bottom: 1px solid var(--border-color);
}
@@ -41,9 +41,9 @@
}
.contact-sns-dialog-avatar {
width: 42px;
height: 42px;
border-radius: 10px;
width: 36px;
height: 36px;
border-radius: 8px;
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
overflow: hidden;
flex-shrink: 0;
@@ -69,7 +69,7 @@
h4 {
margin: 0;
font-size: 15px;
font-size: 14px;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
@@ -79,7 +79,7 @@
.contact-sns-dialog-username {
margin-top: 2px;
font-size: 12px;
font-size: 11px;
color: var(--text-tertiary);
overflow: hidden;
text-overflow: ellipsis;
@@ -88,7 +88,7 @@
.contact-sns-dialog-stats {
margin-top: 4px;
font-size: 12px;
font-size: 11px;
color: var(--text-secondary);
}
@@ -111,9 +111,9 @@
border-radius: 8px;
background: var(--bg-primary);
color: var(--text-secondary);
height: 28px;
padding: 0 10px;
font-size: 12px;
height: 30px;
padding: 0 9px;
font-size: 11px;
line-height: 1;
cursor: pointer;
white-space: nowrap;
@@ -134,8 +134,8 @@
position: absolute;
top: calc(100% + 8px);
right: 0;
width: 248px;
max-height: calc((28px * 15) + 16px);
width: 228px;
max-height: calc((26px * 15) + 16px);
overflow-y: auto;
border: 1px solid color-mix(in srgb, var(--primary) 30%, var(--border-color));
border-radius: 10px;
@@ -220,26 +220,20 @@
}
.contact-sns-dialog-tip {
padding: 10px 16px;
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 88%, transparent);
background: color-mix(in srgb, var(--bg-primary) 78%, var(--bg-secondary));
font-size: 12px;
line-height: 1.6;
color: var(--text-secondary);
word-break: break-word;
display: none;
}
.contact-sns-dialog-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 12px 16px 14px;
padding: 10px 12px 12px;
}
.contact-sns-dialog-posts-list {
display: flex;
flex-direction: column;
gap: 14px;
gap: 10px;
}
.contact-sns-dialog-posts-list .post-header-actions {
@@ -247,9 +241,9 @@
}
.contact-sns-dialog-status {
padding: 20px 12px;
padding: 16px 10px;
text-align: center;
font-size: 13px;
font-size: 12px;
color: var(--text-secondary);
&.empty {
@@ -264,8 +258,8 @@
background: var(--bg-primary);
color: var(--text-primary);
border-radius: 10px;
padding: 9px 18px;
font-size: 13px;
padding: 8px 14px;
font-size: 12px;
cursor: pointer;
&:hover:not(:disabled) {
@@ -282,15 +276,15 @@
@media (max-width: 768px) {
.contact-sns-dialog-overlay {
padding: 12px 8px;
padding: 10px 8px;
}
.contact-sns-dialog {
width: min(100vw - 16px, 760px);
width: min(100vw - 16px, 720px);
max-height: calc(100vh - 24px);
.contact-sns-dialog-header {
padding: 12px;
padding: 10px 12px;
}
.contact-sns-dialog-header-actions {
@@ -300,18 +294,13 @@
.contact-sns-dialog-rank-btn {
height: 26px;
padding: 0 8px;
font-size: 11px;
font-size: 10px;
}
.contact-sns-dialog-rank-panel {
width: min(78vw, 232px);
}
.contact-sns-dialog-tip {
padding: 10px 12px;
line-height: 1.55;
}
.contact-sns-dialog-body {
padding: 10px 10px 12px;
}

View File

@@ -538,10 +538,6 @@ export function ContactSnsTimelineDialog({
</div>
</div>
<div className="contact-sns-dialog-tip">
</div>
<div
className="contact-sns-dialog-body"
onScroll={handleBodyScroll}

View File

@@ -1,5 +1,6 @@
import React from 'react'
import { Search, User, X, Loader2, CheckSquare, Square, Download } from 'lucide-react'
import { Virtuoso } from 'react-virtuoso'
import { Avatar } from '../Avatar'
interface Contact {
@@ -51,10 +52,14 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
onClearSelectedContacts,
onExportSelectedContacts
}) => {
const filteredContacts = contacts.filter(c =>
(c.displayName || '').toLowerCase().includes(contactSearch.toLowerCase()) ||
c.username.toLowerCase().includes(contactSearch.toLowerCase())
)
const filteredContacts = React.useMemo(() => {
const keyword = contactSearch.trim().toLowerCase()
if (!keyword) return contacts
return contacts.filter(c =>
(c.displayName || '').toLowerCase().includes(keyword) ||
c.username.toLowerCase().includes(keyword)
)
}, [contacts, contactSearch])
const selectedContactLookup = React.useMemo(
() => new Set(selectedContactUsernames),
[selectedContactUsernames]
@@ -85,10 +90,52 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
return '没有找到联系人'
}
const renderContactRow = React.useCallback((_: number, contact: Contact) => {
const isPostCountReady = contact.postCountStatus === 'ready'
const isSelected = selectedContactLookup.has(contact.username)
const isActive = activeContactUsername === contact.username
return (
<div
className={`contact-row${isSelected ? ' is-selected' : ''}${isActive ? ' is-active' : ''}`}
>
<button
type="button"
className={`contact-select-btn${isSelected ? ' checked' : ''}`}
onClick={() => onToggleContactSelected(contact)}
title={isSelected ? `取消选择 ${contact.displayName}` : `选择 ${contact.displayName}`}
aria-pressed={isSelected}
>
{isSelected ? <CheckSquare size={14} /> : <Square size={14} />}
</button>
<button
type="button"
className="contact-main-btn"
onClick={() => onOpenContactTimeline(contact)}
title={`查看 ${contact.displayName} 的朋友圈`}
>
<Avatar src={contact.avatarUrl} name={contact.displayName} size={28} shape="rounded" />
<div className="contact-meta">
<span className="contact-name">{contact.displayName}</span>
</div>
<div className="contact-post-count-wrap">
{isPostCountReady ? (
<span className="contact-post-count">{Math.max(0, Math.floor(Number(contact.postCount || 0)))}</span>
) : (
<span className="contact-post-count-loading" title="统计中">
<Loader2 size={12} className="spinning" />
</span>
)}
</div>
</button>
</div>
)
}, [activeContactUsername, onOpenContactTimeline, onToggleContactSelected, selectedContactLookup])
return (
<aside className="sns-filter-panel">
<div className="filter-header">
<h3></h3>
<h3></h3>
{(searchKeyword || contactSearch) && (
<button className="reset-all-btn" onClick={clearFilters} title="重置所有筛选">
<RefreshCw size={14} />
@@ -101,12 +148,12 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
<div className="filter-widget search-widget">
<div className="widget-header">
<Search size={14} />
<span></span>
<span></span>
</div>
<div className="input-group">
<input
type="text"
placeholder="搜索动态内容..."
placeholder="搜索动态"
value={searchKeyword}
onChange={e => setSearchKeyword(e.target.value)}
/>
@@ -130,7 +177,7 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
<div className="contact-search-bar">
<input
type="text"
placeholder="查找好友..."
placeholder="查找联系人"
value={contactSearch}
onChange={e => setContactSearch(e.target.value)}
/>
@@ -162,53 +209,17 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
</div>
)}
<div className="contact-interaction-hint">
</div>
<div className="contact-list-scroll">
{filteredContacts.map(contact => {
const isPostCountReady = contact.postCountStatus === 'ready'
const isSelected = selectedContactLookup.has(contact.username)
const isActive = activeContactUsername === contact.username
return (
<div
key={contact.username}
className={`contact-row${isSelected ? ' is-selected' : ''}${isActive ? ' is-active' : ''}`}
>
<button
type="button"
className={`contact-select-btn${isSelected ? ' checked' : ''}`}
onClick={() => onToggleContactSelected(contact)}
title={isSelected ? `取消选择 ${contact.displayName}` : `选择 ${contact.displayName}`}
aria-pressed={isSelected}
>
{isSelected ? <CheckSquare size={16} /> : <Square size={16} />}
</button>
<button
type="button"
className="contact-main-btn"
onClick={() => onOpenContactTimeline(contact)}
title={`查看 ${contact.displayName} 的朋友圈`}
>
<Avatar src={contact.avatarUrl} name={contact.displayName} size={36} shape="rounded" />
<div className="contact-meta">
<span className="contact-name">{contact.displayName}</span>
</div>
<div className="contact-post-count-wrap">
{isPostCountReady ? (
<span className="contact-post-count">{Math.max(0, Math.floor(Number(contact.postCount || 0)))}</span>
) : (
<span className="contact-post-count-loading" title="统计中">
<Loader2 size={13} className="spinning" />
</span>
)}
</div>
</button>
</div>
)
})}
{filteredContacts.length === 0 && (
{filteredContacts.length > 0 ? (
<Virtuoso
className="contact-list-virtuoso"
data={filteredContacts}
computeItemKey={(_, contact) => contact.username}
fixedItemHeight={40}
itemContent={renderContactRow}
overscan={320}
/>
) : (
<div className="empty-state">{getEmptyStateText()}</div>
)}
</div>

View File

@@ -1,4 +1,4 @@
import React, { useState, useMemo, useEffect } from 'react'
import React, { useState, useMemo, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { Heart, ChevronRight, ImageIcon, Code, Trash2, MapPin } from 'lucide-react'
import { SnsPost, SnsLinkCardData, SnsLocation } from '../../types/sns'
@@ -8,6 +8,7 @@ import { getEmojiPath } from 'wechat-emojis'
// Helper functions (extracted from SnsPage.tsx but simplified/reused)
const LINK_XML_URL_TAGS = ['url', 'shorturl', 'weburl', 'webpageurl', 'jumpurl']
const LINK_XML_DIRECT_URL_TAGS = ['contentUrl', ...LINK_XML_URL_TAGS]
const LINK_XML_TITLE_TAGS = ['title', 'linktitle', 'webtitle']
const MEDIA_HOST_HINTS = ['mmsns.qpic.cn', 'vweixinthumb', 'snstimeline', 'snsvideodownload']
@@ -29,6 +30,13 @@ const decodeHtmlEntities = (text: string): string => {
.trim()
}
const normalizeRawXmlForParsing = (xml: string): string => {
if (!xml) return ''
return decodeHtmlEntities(xml)
.replace(/\\+"/g, '"')
.replace(/\\+'/g, "'")
}
const normalizeUrlCandidate = (raw: string): string | null => {
const value = decodeHtmlEntities(raw).replace(/[)\],.;]+$/, '').trim()
if (!value) return null
@@ -43,12 +51,13 @@ const simplifyUrlForCompare = (value: string): string => {
}
const getXmlTagValues = (xml: string, tags: string[]): string[] => {
if (!xml) return []
const normalizedXml = normalizeRawXmlForParsing(xml)
if (!normalizedXml) return []
const results: string[] = []
for (const tag of tags) {
const reg = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'ig')
let match: RegExpExecArray | null
while ((match = reg.exec(xml)) !== null) {
while ((match = reg.exec(normalizedXml)) !== null) {
if (match[1]) results.push(match[1])
}
}
@@ -65,20 +74,87 @@ const isLikelyMediaAssetUrl = (url: string): boolean => {
return MEDIA_HOST_HINTS.some((hint) => lower.includes(hint))
}
const normalizeSnsAssetUrl = (url: string, token?: string, encIdx?: string): string => {
const base = decodeHtmlEntities(url).trim()
if (!base) return ''
let fixed = base.replace(/^http:\/\//i, 'https://')
const normalizedToken = decodeHtmlEntities(String(token || '')).trim()
const normalizedEncIdx = decodeHtmlEntities(String(encIdx || '')).trim()
const effectiveIdx = normalizedEncIdx || (normalizedToken ? '1' : '')
const appendParams: string[] = []
if (normalizedToken && !/[?&]token=/i.test(fixed)) {
appendParams.push(`token=${normalizedToken}`)
}
if (effectiveIdx && !/[?&]idx=/i.test(fixed)) {
appendParams.push(`idx=${effectiveIdx}`)
}
if (appendParams.length > 0) {
const connector = fixed.includes('?') ? '&' : '?'
fixed = `${fixed}${connector}${appendParams.join('&')}`
}
return fixed
}
const extractCardThumbMetaFromXml = (xml: string): { thumb?: string; thumbKey?: string } => {
const normalizedXml = normalizeRawXmlForParsing(xml)
if (!normalizedXml) return {}
const mediaMatch = normalizedXml.match(/<media>([\s\S]*?)<\/media>/i)
if (!mediaMatch?.[1]) return {}
const mediaXml = mediaMatch[1]
const thumbMatch = mediaXml.match(/<thumb([^>]*)>([^<]+)<\/thumb>/i)
if (!thumbMatch) return {}
const attrs = thumbMatch[1] || ''
const getAttr = (name: string): string | undefined => {
const reg = new RegExp(`${name}\\s*=\\s*(?:\"([^\"]+)\"|'([^']+)'|([^\\s>]+))`, 'i')
const m = attrs.match(reg)
return decodeHtmlEntities((m?.[1] || m?.[2] || m?.[3] || '').trim()) || undefined
}
const thumbRawUrl = thumbMatch[2] || ''
const thumbToken = getAttr('token')
const thumbKey = getAttr('key')
const thumbEncIdx = getAttr('enc_idx')
const thumb = normalizeSnsAssetUrl(thumbRawUrl, thumbToken, thumbEncIdx)
return {
thumb: thumb || undefined,
thumbKey: thumbKey ? decodeHtmlEntities(thumbKey).trim() : undefined
}
}
const pickCardTitle = (post: SnsPost): string => {
const titleCandidates = [
post.linkTitle || '',
...getXmlTagValues(post.rawXml || '', LINK_XML_TITLE_TAGS),
post.contentDesc || ''
]
return titleCandidates
.map((value) => decodeHtmlEntities(value))
.find((value) => Boolean(value) && !/^https?:\/\//i.test(value)) || '网页链接'
}
const buildLinkCardData = (post: SnsPost): SnsLinkCardData | null => {
// type 3 是链接类型,直接用 media[0] 的 url 和 thumb
if (post.type === 3) {
const url = post.media[0]?.url || post.linkUrl
if (!url) return null
const titleCandidates = [
post.linkTitle || '',
...getXmlTagValues(post.rawXml || '', LINK_XML_TITLE_TAGS),
post.contentDesc || ''
// type 3 / 5 是链接卡片类型,优先按卡片链接解析
if (post.type === 3 || post.type === 5) {
const thumbMeta = extractCardThumbMetaFromXml(post.rawXml || '')
const directUrlCandidates = [
post.linkUrl || '',
...getXmlTagValues(post.rawXml || '', LINK_XML_DIRECT_URL_TAGS),
...post.media.map((item) => item.url || '')
]
const title = titleCandidates
.map((v) => decodeHtmlEntities(v))
.find((v) => Boolean(v) && !/^https?:\/\//i.test(v))
return { url, title: title || '网页链接', thumb: post.media[0]?.thumb }
const url = directUrlCandidates
.map(normalizeUrlCandidate)
.find((value): value is string => Boolean(value))
if (!url) return null
return {
url,
title: pickCardTitle(post),
thumb: thumbMeta.thumb || post.media[0]?.thumb || post.media[0]?.url,
thumbKey: thumbMeta.thumbKey || post.media[0]?.key
}
}
const hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url))
@@ -117,19 +193,9 @@ const buildLinkCardData = (post: SnsPost): SnsLinkCardData | null => {
if (!linkUrl) return null
const titleCandidates = [
post.linkTitle || '',
...getXmlTagValues(post.rawXml || '', LINK_XML_TITLE_TAGS),
post.contentDesc || ''
]
const title = titleCandidates
.map((value) => decodeHtmlEntities(value))
.find((value) => Boolean(value) && !/^https?:\/\//i.test(value))
return {
url: linkUrl,
title: title || '网页链接',
title: pickCardTitle(post),
thumb: post.media[0]?.thumb || post.media[0]?.url
}
}
@@ -158,8 +224,11 @@ const buildLocationText = (location?: SnsLocation): string => {
return primary || region
}
const SnsLinkCard = ({ card }: { card: SnsLinkCardData }) => {
const SnsLinkCard = ({ card, thumbKey }: { card: SnsLinkCardData; thumbKey?: string }) => {
const [thumbFailed, setThumbFailed] = useState(false)
const [thumbSrc, setThumbSrc] = useState(card.thumb || '')
const [reloadNonce, setReloadNonce] = useState(0)
const retryCountRef = useRef(0)
const hostname = useMemo(() => {
try {
return new URL(card.url).hostname.replace(/^www\./i, '')
@@ -168,6 +237,58 @@ const SnsLinkCard = ({ card }: { card: SnsLinkCardData }) => {
}
}, [card.url])
useEffect(() => {
retryCountRef.current = 0
}, [card.thumb, thumbKey])
const scheduleRetry = () => {
if (retryCountRef.current >= 2) return
retryCountRef.current += 1
window.setTimeout(() => {
setReloadNonce((v) => v + 1)
}, 900)
}
useEffect(() => {
const rawThumb = card.thumb || ''
setThumbFailed(false)
setThumbSrc(rawThumb)
if (!rawThumb) return
let cancelled = false
const loadThumb = async () => {
try {
const result = await window.electronAPI.sns.proxyImage({
url: rawThumb,
key: thumbKey
})
if (cancelled) return
if (!result.success) {
console.warn('[SnsLinkCard] thumb decrypt failed', {
url: rawThumb,
key: thumbKey,
error: result.error
})
scheduleRetry()
return
}
if (result.dataUrl) {
setThumbSrc(result.dataUrl)
return
}
if (result.videoPath) {
setThumbSrc(`file://${result.videoPath.replace(/\\/g, '/')}`)
}
} catch {
// noop: keep raw thumb fallback
scheduleRetry()
}
}
loadThumb()
return () => { cancelled = true }
}, [card.thumb, thumbKey, reloadNonce])
const handleClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
try {
@@ -180,13 +301,31 @@ const SnsLinkCard = ({ card }: { card: SnsLinkCardData }) => {
return (
<button type="button" className="post-link-card" onClick={handleClick}>
<div className="link-thumb">
{card.thumb && !thumbFailed ? (
{thumbSrc && !thumbFailed ? (
<img
src={card.thumb}
src={thumbSrc}
alt=""
referrerPolicy="no-referrer"
loading="lazy"
onError={() => setThumbFailed(true)}
onError={() => {
const rawThumb = card.thumb || ''
if (thumbSrc !== rawThumb && rawThumb) {
console.warn('[SnsLinkCard] thumb render failed, fallback raw thumb', {
failedSrc: thumbSrc,
rawThumb,
key: thumbKey
})
setThumbSrc(rawThumb)
return
}
console.warn('[SnsLinkCard] thumb render failed, fallback exhausted', {
failedSrc: thumbSrc,
rawThumb,
key: thumbKey
})
setThumbFailed(true)
scheduleRetry()
}}
/>
) : (
<div className="link-thumb-fallback">
@@ -278,9 +417,11 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
const [deleting, setDeleting] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const linkCard = buildLinkCardData(post)
const linkCardThumbKey = linkCard?.thumbKey || post.media[0]?.key
const locationText = useMemo(() => buildLocationText(post.location), [post.location])
const hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url))
const showLinkCard = Boolean(linkCard) && post.media.length <= 1 && !hasVideoMedia
const isLinkCardType = post.type === 3 || post.type === 5
const showLinkCard = Boolean(linkCard) && !hasVideoMedia && (isLinkCardType || post.media.length <= 1)
const showMediaGrid = post.media.length > 0 && !showLinkCard
const formatTime = (ts: number) => {
@@ -352,7 +493,7 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
<Avatar
src={post.avatarUrl}
name={post.nickname}
size={48}
size={36}
shape="rounded"
/>
</button>
@@ -412,7 +553,7 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
)}
{showLinkCard && linkCard && (
<SnsLinkCard card={linkCard} />
<SnsLinkCard card={linkCard} thumbKey={linkCardThumbKey} />
)}
{showMediaGrid && (

View File

@@ -1,12 +1,11 @@
.title-bar {
height: 41px;
background: var(--bg-secondary);
height: 48px;
background: transparent;
display: flex;
align-items: center;
justify-content: space-between;
padding-left: 16px;
padding-right: 16px;
border-bottom: 1px solid var(--border-color);
padding-right: 8px;
-webkit-app-region: drag;
flex-shrink: 0;
gap: 8px;
@@ -14,12 +13,6 @@
z-index: 2101;
}
// 繁花如梦:标题栏毛玻璃
[data-theme="blossom-dream"] .title-bar {
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
.title-brand {
display: inline-flex;
align-items: center;
@@ -33,16 +26,15 @@
}
.titles {
font-size: 15px;
font-weight: 500;
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
letter-spacing: -0.01em;
}
.title-sidebar-toggle {
width: 28px;
height: 28px;
width: 32px;
height: 32px;
padding: 0;
border: none;
border-radius: 8px;
@@ -52,11 +44,11 @@
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.2s ease, color 0.2s ease;
transition: background 0.15s ease, color 0.15s ease;
-webkit-app-region: no-drag;
&:hover {
background: var(--bg-tertiary);
background: var(--bg-hover);
color: var(--text-primary);
}
}
@@ -64,26 +56,26 @@
.title-window-controls {
display: inline-flex;
align-items: center;
gap: 6px;
gap: 2px;
-webkit-app-region: no-drag;
}
.title-window-control-btn {
width: 28px;
width: 36px;
height: 28px;
padding: 0;
border: none;
border-radius: 8px;
border-radius: 6px;
background: transparent;
color: var(--text-tertiary);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.2s ease, color 0.2s ease;
transition: background 0.15s ease, color 0.15s ease;
&:hover {
background: var(--bg-tertiary);
background: var(--bg-hover);
color: var(--text-primary);
}
@@ -107,14 +99,14 @@
color: var(--text-secondary);
cursor: pointer;
padding: 6px;
border-radius: 4px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
transition: all 0.15s;
&:hover {
background: var(--bg-tertiary);
background: var(--bg-hover);
color: var(--text-primary);
}
@@ -124,8 +116,8 @@
}
&.live-play-btn.active {
background: rgba(var(--primary-rgb, 76, 132, 255), 0.16);
color: var(--primary, #4c84ff);
background: var(--primary-light);
color: var(--primary);
}
}

View File

@@ -0,0 +1,274 @@
.account-management-page {
padding: 22px 24px;
min-height: 100%;
display: flex;
flex-direction: column;
gap: 14px;
color: var(--text-primary);
}
.account-management-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
h2 {
margin: 0;
font-size: 22px;
font-weight: 700;
letter-spacing: -0.01em;
}
p {
margin: 6px 0 0;
color: var(--text-secondary);
font-size: 13px;
}
}
.account-management-actions {
display: inline-flex;
align-items: center;
gap: 8px;
}
.account-management-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.summary-item {
border: 1px solid var(--border-color);
border-radius: 12px;
background: var(--bg-secondary);
padding: 10px 12px;
}
.summary-label {
display: block;
font-size: 11px;
color: var(--text-tertiary);
}
.summary-value {
display: block;
margin-top: 4px;
font-size: 13px;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.account-notice {
border-radius: 10px;
padding: 10px 12px;
font-size: 13px;
border: 1px solid transparent;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.notice-action {
border: 1px solid currentColor;
border-radius: 999px;
background: transparent;
color: inherit;
font-size: 12px;
font-weight: 600;
padding: 4px 10px;
cursor: pointer;
white-space: nowrap;
transition: opacity 0.2s ease, background 0.2s ease;
&:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.35);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.account-notice.success {
background: rgba(34, 197, 94, 0.12);
color: #15803d;
border-color: rgba(34, 197, 94, 0.25);
}
.account-notice.error {
background: rgba(239, 68, 68, 0.12);
color: #b91c1c;
border-color: rgba(239, 68, 68, 0.25);
}
.account-notice.info {
background: rgba(59, 130, 246, 0.12);
color: #1d4ed8;
border-color: rgba(59, 130, 246, 0.25);
}
.account-empty {
border: 1px dashed var(--border-color);
border-radius: 12px;
background: var(--bg-secondary);
padding: 18px 14px;
color: var(--text-secondary);
font-size: 13px;
display: flex;
align-items: center;
gap: 8px;
}
.account-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.account-card {
border: 1px solid var(--border-color);
border-radius: 14px;
background: var(--bg-secondary);
padding: 12px;
display: flex;
gap: 12px;
&.is-current {
border-color: color-mix(in srgb, var(--primary) 60%, var(--border-color));
box-shadow: 0 0 0 1px color-mix(in srgb, var(--primary) 25%, transparent);
}
}
.account-avatar {
width: 42px;
height: 42px;
border-radius: 10px;
overflow: hidden;
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
span {
color: var(--on-primary);
font-weight: 600;
font-size: 14px;
}
}
.account-main {
min-width: 0;
flex: 1;
}
.account-title-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
h3 {
margin: 0;
font-size: 15px;
color: var(--text-primary);
}
}
.account-badge {
display: inline-flex;
align-items: center;
gap: 4px;
border-radius: 999px;
padding: 1px 8px;
font-size: 11px;
font-weight: 600;
&.current {
color: #0f766e;
background: rgba(20, 184, 166, 0.14);
}
&.ok {
color: #166534;
background: rgba(34, 197, 94, 0.12);
}
&.warn {
color: #b45309;
background: rgba(245, 158, 11, 0.15);
}
}
.account-meta {
margin-top: 3px;
font-size: 12px;
color: var(--text-tertiary);
word-break: break-all;
}
.meta-tip {
margin-left: 6px;
color: var(--text-secondary);
}
.account-card-actions {
display: inline-flex;
flex-direction: column;
gap: 8px;
align-items: stretch;
.btn {
min-width: 104px;
justify-content: center;
}
}
.account-management-footer {
margin-top: 2px;
color: var(--text-tertiary);
font-size: 12px;
}
.account-management-page {
.btn-danger {
background: rgba(239, 68, 68, 0.12);
color: #b91c1c;
&:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.2);
}
}
.btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
}
@media (max-width: 920px) {
.account-management-summary {
grid-template-columns: 1fr;
}
.account-card {
flex-direction: column;
}
.account-card-actions {
flex-direction: row;
flex-wrap: wrap;
}
}

View File

@@ -0,0 +1,612 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { RefreshCw, UserPlus, Trash2, ArrowRightLeft, CheckCircle2, Database } from 'lucide-react'
import { useAppStore } from '../stores/appStore'
import { useChatStore } from '../stores/chatStore'
import { useAnalyticsStore } from '../stores/analyticsStore'
import * as configService from '../services/config'
import './AccountManagementPage.scss'
interface ScannedWxidOption {
wxid: string
modifiedTime: number
nickname?: string
avatarUrl?: string
}
interface ManagedAccountItem {
wxid: string
normalizedWxid: string
displayName: string
avatarUrl?: string
modifiedTime?: number
configUpdatedAt?: number
hasConfig: boolean
isCurrent: boolean
fromScan: boolean
}
type AccountProfileCacheEntry = {
displayName?: string
avatarUrl?: string
updatedAt?: number
}
interface DeleteUndoState {
targetWxid: string
deletedConfigEntries: Array<[string, configService.WxidConfig]>
deletedProfileEntries: Array<[string, AccountProfileCacheEntry]>
previousCurrentWxid: string
shouldRestoreAsCurrent: boolean
previousDbConnected: boolean
}
type NoticeState =
| { type: 'success' | 'error' | 'info'; text: string }
| null
const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1'
const ACCOUNT_PROFILES_CACHE_KEY = 'account_profiles_cache_v1'
const HIDDEN_DELETED_ACCOUNT_NORM_IDS_KEY = 'weflow_account_mgmt_hidden_deleted_norm_v1'
const readHiddenDeletedAccountNormIds = (): Set<string> => {
try {
const raw = window.localStorage.getItem(HIDDEN_DELETED_ACCOUNT_NORM_IDS_KEY)
if (!raw) return new Set()
const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) return new Set()
return new Set(parsed.filter((x): x is string => typeof x === 'string' && x.length > 0))
} catch {
return new Set()
}
}
const writeHiddenDeletedAccountNormIds = (ids: Set<string>): void => {
try {
window.localStorage.setItem(HIDDEN_DELETED_ACCOUNT_NORM_IDS_KEY, JSON.stringify(Array.from(ids)))
} catch {
}
}
const addHiddenDeletedAccountNormId = (normalized: string): void => {
if (!normalized) return
const next = readHiddenDeletedAccountNormIds()
next.add(normalized)
writeHiddenDeletedAccountNormIds(next)
}
const removeHiddenDeletedAccountNormId = (normalized: string): void => {
if (!normalized) return
const next = readHiddenDeletedAccountNormIds()
if (!next.delete(normalized)) return
writeHiddenDeletedAccountNormIds(next)
}
const DEFAULT_ACCOUNT_DISPLAY_NAME = '微信用户'
const normalizeAccountId = (value?: string | null): string => {
const trimmed = String(value || '').trim()
if (!trimmed) return ''
if (trimmed.toLowerCase().startsWith('wxid_')) {
const match = trimmed.match(/^(wxid_[^_]+)/i)
return match?.[1] || trimmed
}
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
return suffixMatch ? suffixMatch[1] : trimmed
}
const resolveAccountDisplayName = (
candidates: Array<unknown>,
wxidCandidates: Set<string>
): string => {
for (const candidate of candidates) {
if (typeof candidate !== 'string') continue
if (candidate.length === 0) continue
const normalized = candidate.trim().toLowerCase()
if (normalized.startsWith('wxid_')) continue
if (normalized && wxidCandidates.has(normalized)) continue
return candidate
}
return DEFAULT_ACCOUNT_DISPLAY_NAME
}
const resolveAccountAvatarText = (displayName?: string): string => {
if (typeof displayName !== 'string' || displayName.length === 0) return '微'
const visible = displayName.trim()
return (visible && [...visible][0]) || '微'
}
const readAccountProfilesCache = (): Record<string, AccountProfileCacheEntry> => {
try {
const raw = window.localStorage.getItem(ACCOUNT_PROFILES_CACHE_KEY)
if (!raw) return {}
const parsed = JSON.parse(raw)
return parsed && typeof parsed === 'object' ? parsed as Record<string, AccountProfileCacheEntry> : {}
} catch {
return {}
}
}
function AccountManagementPage() {
const isDbConnected = useAppStore(state => state.isDbConnected)
const setDbConnected = useAppStore(state => state.setDbConnected)
const resetChatStore = useChatStore(state => state.reset)
const clearAnalyticsStoreCache = useAnalyticsStore(state => state.clearCache)
const [dbPath, setDbPath] = useState('')
const [currentWxid, setCurrentWxid] = useState('')
const [accounts, setAccounts] = useState<ManagedAccountItem[]>([])
const [isLoading, setIsLoading] = useState(false)
const [workingWxid, setWorkingWxid] = useState('')
const [notice, setNotice] = useState<NoticeState>(null)
const [deleteUndoState, setDeleteUndoState] = useState<DeleteUndoState | null>(null)
const loadAccounts = useCallback(async () => {
setIsLoading(true)
try {
const [path, rawCurrentWxid, wxidConfigs] = await Promise.all([
configService.getDbPath(),
configService.getMyWxid(),
configService.getWxidConfigs()
])
const nextDbPath = String(path || '').trim()
const nextCurrentWxid = String(rawCurrentWxid || '').trim()
const normalizedCurrent = normalizeAccountId(nextCurrentWxid) || nextCurrentWxid
setDbPath(nextDbPath)
setCurrentWxid(nextCurrentWxid)
let scannedWxids: ScannedWxidOption[] = []
if (nextDbPath) {
try {
const scanned = await window.electronAPI.dbPath.scanWxids(nextDbPath)
scannedWxids = Array.isArray(scanned) ? scanned as ScannedWxidOption[] : []
} catch {
scannedWxids = []
}
}
const accountProfileCache = readAccountProfilesCache()
const configEntries = Object.entries(wxidConfigs || {})
const configByNormalized = new Map<string, { key: string; value: configService.WxidConfig }>()
for (const [wxid, cfg] of configEntries) {
const normalized = normalizeAccountId(wxid) || wxid
if (!normalized) continue
const previous = configByNormalized.get(normalized)
if (!previous || Number(cfg?.updatedAt || 0) > Number(previous.value?.updatedAt || 0)) {
configByNormalized.set(normalized, { key: wxid, value: cfg || {} })
}
}
const merged = new Map<string, ManagedAccountItem>()
for (const scanned of scannedWxids) {
const normalized = normalizeAccountId(scanned.wxid) || scanned.wxid
if (!normalized) continue
const cached = accountProfileCache[scanned.wxid] || accountProfileCache[normalized]
const matchedConfig = configByNormalized.get(normalized)
const wxidCandidates = new Set<string>([
String(scanned.wxid || '').trim().toLowerCase(),
String(normalized || '').trim().toLowerCase()
].filter(Boolean))
const displayName = resolveAccountDisplayName(
[scanned.nickname, cached?.displayName],
wxidCandidates
)
merged.set(normalized, {
wxid: scanned.wxid,
normalizedWxid: normalized,
displayName,
avatarUrl: scanned.avatarUrl || cached?.avatarUrl,
modifiedTime: Number(scanned.modifiedTime || 0),
configUpdatedAt: Number(matchedConfig?.value?.updatedAt || 0),
hasConfig: Boolean(matchedConfig),
isCurrent: Boolean(normalizedCurrent) && normalized === normalizedCurrent,
fromScan: true
})
}
for (const [normalized, matchedConfig] of configByNormalized.entries()) {
if (merged.has(normalized)) continue
const wxid = matchedConfig.key
const cached = accountProfileCache[wxid] || accountProfileCache[normalized]
const wxidCandidates = new Set<string>([
String(wxid || '').trim().toLowerCase(),
String(normalized || '').trim().toLowerCase()
].filter(Boolean))
const displayName = resolveAccountDisplayName(
[cached?.displayName],
wxidCandidates
)
merged.set(normalized, {
wxid,
normalizedWxid: normalized,
displayName,
avatarUrl: cached?.avatarUrl,
modifiedTime: 0,
configUpdatedAt: Number(matchedConfig.value?.updatedAt || 0),
hasConfig: true,
isCurrent: Boolean(normalizedCurrent) && normalized === normalizedCurrent,
fromScan: false
})
}
// 被「删除配置」移除的账号:微信目录仍在扫描结果里会出现无配置条目,持久化隐藏避免误导;
// 若后续再次保存该账号配置,则自动恢复展示。
const hiddenDeletedNormIds = readHiddenDeletedAccountNormIds()
for (const [normalized, item] of Array.from(merged.entries())) {
if (!hiddenDeletedNormIds.has(normalized)) continue
if (item.hasConfig) {
hiddenDeletedNormIds.delete(normalized)
writeHiddenDeletedAccountNormIds(hiddenDeletedNormIds)
continue
}
merged.delete(normalized)
}
const nextAccounts = Array.from(merged.values()).sort((a, b) => {
if (a.isCurrent && !b.isCurrent) return -1
if (!a.isCurrent && b.isCurrent) return 1
const scanDiff = Number(b.modifiedTime || 0) - Number(a.modifiedTime || 0)
if (scanDiff !== 0) return scanDiff
return Number(b.configUpdatedAt || 0) - Number(a.configUpdatedAt || 0)
})
setAccounts(nextAccounts)
} catch (error) {
console.error('加载账号列表失败:', error)
setNotice({ type: 'error', text: '加载账号列表失败,请稍后重试' })
setAccounts([])
} finally {
setIsLoading(false)
}
}, [])
useEffect(() => {
void loadAccounts()
const onWxidChanged = () => { void loadAccounts() }
const onWindowFocus = () => { void loadAccounts() }
const onVisibilityChange = () => {
if (document.visibilityState === 'visible') {
void loadAccounts()
}
}
window.addEventListener('wxid-changed', onWxidChanged as EventListener)
window.addEventListener('focus', onWindowFocus)
document.addEventListener('visibilitychange', onVisibilityChange)
return () => {
window.removeEventListener('wxid-changed', onWxidChanged as EventListener)
window.removeEventListener('focus', onWindowFocus)
document.removeEventListener('visibilitychange', onVisibilityChange)
}
}, [loadAccounts])
const clearRuntimeCacheState = useCallback(async () => {
if (isDbConnected) {
await window.electronAPI.chat.close()
}
window.localStorage.removeItem(SIDEBAR_USER_PROFILE_CACHE_KEY)
clearAnalyticsStoreCache()
resetChatStore()
}, [clearAnalyticsStoreCache, isDbConnected, resetChatStore])
const applyWxidConfig = useCallback(async (wxid: string, wxidConfig: configService.WxidConfig | null) => {
await configService.setMyWxid(wxid)
await configService.setDecryptKey(wxidConfig?.decryptKey || '')
await configService.setImageXorKey(typeof wxidConfig?.imageXorKey === 'number' ? wxidConfig.imageXorKey : 0)
await configService.setImageAesKey(wxidConfig?.imageAesKey || '')
}, [])
const handleSwitchAccount = useCallback(async (wxid: string) => {
if (!wxid || workingWxid) return
const targetNormalized = normalizeAccountId(wxid) || wxid
const currentNormalized = normalizeAccountId(currentWxid) || currentWxid
if (targetNormalized && currentNormalized && targetNormalized === currentNormalized) return
setWorkingWxid(wxid)
setNotice(null)
setDeleteUndoState(null)
try {
const allConfigs = await configService.getWxidConfigs()
const configEntries = Object.entries(allConfigs || {})
const matched = configEntries.find(([key]) => {
const normalized = normalizeAccountId(key) || key
return key === wxid || normalized === targetNormalized
})
const targetConfig = matched?.[1] || null
await applyWxidConfig(wxid, targetConfig)
await clearRuntimeCacheState()
window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid } }))
setNotice({ type: 'success', text: `已切换到账号「${wxid}` })
await loadAccounts()
} catch (error) {
console.error('切换账号失败:', error)
setNotice({ type: 'error', text: '切换账号失败,请稍后重试' })
} finally {
setWorkingWxid('')
}
}, [applyWxidConfig, clearRuntimeCacheState, currentWxid, loadAccounts, workingWxid])
const handleAddAccount = useCallback(async () => {
if (workingWxid) return
setNotice(null)
setDeleteUndoState(null)
try {
await window.electronAPI.window.openOnboardingWindow({ mode: 'add-account' })
await loadAccounts()
const latestWxid = String(await configService.getMyWxid() || '').trim()
window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: latestWxid } }))
} catch (error) {
console.error('打开添加账号引导失败:', error)
setNotice({ type: 'error', text: '打开添加账号引导失败,请稍后重试' })
}
}, [loadAccounts, workingWxid])
const handleDeleteAccountConfig = useCallback(async (targetWxid: string) => {
if (!targetWxid || workingWxid) return
const normalizedTarget = normalizeAccountId(targetWxid) || targetWxid
setWorkingWxid(targetWxid)
setNotice(null)
setDeleteUndoState(null)
try {
const allConfigs = await configService.getWxidConfigs()
const nextConfigs: Record<string, configService.WxidConfig> = { ...allConfigs }
const matchedKeys = Object.keys(nextConfigs).filter((key) => {
const normalized = normalizeAccountId(key) || key
return key === targetWxid || normalized === normalizedTarget
})
if (matchedKeys.length === 0) {
setNotice({ type: 'info', text: `账号「${targetWxid}」暂无可删除配置` })
return
}
const deletedConfigEntries: Array<[string, configService.WxidConfig]> = matchedKeys.map((key) => [key, nextConfigs[key] || {}])
for (const key of matchedKeys) {
delete nextConfigs[key]
}
await configService.setWxidConfigs(nextConfigs)
const accountProfileCache = readAccountProfilesCache()
const deletedProfileEntries: Array<[string, AccountProfileCacheEntry]> = []
for (const key of Object.keys(accountProfileCache)) {
const normalized = normalizeAccountId(key) || key
if (key === targetWxid || normalized === normalizedTarget) {
deletedProfileEntries.push([key, accountProfileCache[key]])
delete accountProfileCache[key]
}
}
window.localStorage.setItem(ACCOUNT_PROFILES_CACHE_KEY, JSON.stringify(accountProfileCache))
const currentNormalized = normalizeAccountId(currentWxid) || currentWxid
const isDeletingCurrent = Boolean(currentNormalized && currentNormalized === normalizedTarget)
const undoPayload: DeleteUndoState = {
targetWxid,
deletedConfigEntries,
deletedProfileEntries,
previousCurrentWxid: currentWxid,
shouldRestoreAsCurrent: isDeletingCurrent,
previousDbConnected: isDbConnected
}
if (isDeletingCurrent) {
await clearRuntimeCacheState()
const remainingEntries = Object.entries(nextConfigs)
.filter(([wxid]) => Boolean(String(wxid || '').trim()))
.sort((a, b) => Number(b[1]?.updatedAt || 0) - Number(a[1]?.updatedAt || 0))
if (remainingEntries.length > 0) {
const [nextWxid, nextConfig] = remainingEntries[0]
await applyWxidConfig(nextWxid, nextConfig || null)
window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: nextWxid } }))
addHiddenDeletedAccountNormId(normalizedTarget)
setDeleteUndoState(undoPayload)
setNotice({ type: 'success', text: `已删除「${targetWxid}」配置,并切换到「${nextWxid}` })
await loadAccounts()
return
}
await configService.setMyWxid('')
await configService.setDecryptKey('')
await configService.setImageXorKey(0)
await configService.setImageAesKey('')
setDbConnected(false)
window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: '' } }))
addHiddenDeletedAccountNormId(normalizedTarget)
setDeleteUndoState(undoPayload)
setNotice({ type: 'info', text: `已删除「${targetWxid}」配置,当前无可用账号配置,可撤回或添加账号` })
await loadAccounts()
return
}
addHiddenDeletedAccountNormId(normalizedTarget)
setDeleteUndoState(undoPayload)
setNotice({ type: 'success', text: `已删除账号「${targetWxid}」配置` })
await loadAccounts()
} catch (error) {
console.error('删除账号配置失败:', error)
setNotice({ type: 'error', text: '删除账号配置失败,请稍后重试' })
} finally {
setWorkingWxid('')
}
}, [applyWxidConfig, clearRuntimeCacheState, currentWxid, isDbConnected, loadAccounts, setDbConnected, workingWxid])
const handleUndoDelete = useCallback(async () => {
if (!deleteUndoState || workingWxid) return
setWorkingWxid(`undo:${deleteUndoState.targetWxid}`)
setNotice(null)
try {
const currentConfigs = await configService.getWxidConfigs()
const restoredConfigs: Record<string, configService.WxidConfig> = { ...currentConfigs }
for (const [key, configValue] of deleteUndoState.deletedConfigEntries) {
restoredConfigs[key] = configValue || {}
}
await configService.setWxidConfigs(restoredConfigs)
removeHiddenDeletedAccountNormId(normalizeAccountId(deleteUndoState.targetWxid) || deleteUndoState.targetWxid)
const accountProfileCache = readAccountProfilesCache()
for (const [key, profile] of deleteUndoState.deletedProfileEntries) {
accountProfileCache[key] = profile
}
window.localStorage.setItem(ACCOUNT_PROFILES_CACHE_KEY, JSON.stringify(accountProfileCache))
if (deleteUndoState.shouldRestoreAsCurrent && deleteUndoState.previousCurrentWxid) {
const previousNormalized = normalizeAccountId(deleteUndoState.previousCurrentWxid) || deleteUndoState.previousCurrentWxid
const restoreConfigEntry = Object.entries(restoredConfigs)
.filter(([key]) => {
const normalized = normalizeAccountId(key) || key
return key === deleteUndoState.previousCurrentWxid || normalized === previousNormalized
})
.sort((a, b) => Number(b[1]?.updatedAt || 0) - Number(a[1]?.updatedAt || 0))[0]
const restoreConfig = restoreConfigEntry?.[1] || null
await clearRuntimeCacheState()
await applyWxidConfig(deleteUndoState.previousCurrentWxid, restoreConfig)
if (deleteUndoState.previousDbConnected) {
setDbConnected(true, dbPath || undefined)
}
window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: deleteUndoState.previousCurrentWxid } }))
}
setNotice({ type: 'success', text: `已撤回删除,账号「${deleteUndoState.targetWxid}」配置已恢复` })
setDeleteUndoState(null)
await loadAccounts()
} catch (error) {
console.error('撤回删除失败:', error)
setNotice({ type: 'error', text: '撤回删除失败,请稍后重试' })
} finally {
setWorkingWxid('')
}
}, [applyWxidConfig, clearRuntimeCacheState, dbPath, deleteUndoState, loadAccounts, setDbConnected, workingWxid])
const currentAccountLabel = useMemo(() => {
if (!currentWxid) return '未设置'
return currentWxid
}, [currentWxid])
const formatTime = (value?: number): string => {
const ts = Number(value || 0)
if (!ts) return '未知'
const date = new Date(ts)
if (Number.isNaN(date.getTime())) return '未知'
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hour = String(date.getHours()).padStart(2, '0')
const minute = String(date.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day} ${hour}:${minute}`
}
return (
<div className="account-management-page">
<header className="account-management-header">
<div>
<h2></h2>
<p></p>
</div>
<div className="account-management-actions">
<button type="button" className="btn btn-secondary" onClick={() => void loadAccounts()} disabled={isLoading || Boolean(workingWxid)}>
<RefreshCw size={16} /> {isLoading ? '刷新中...' : '刷新'}
</button>
<button type="button" className="btn btn-primary" onClick={handleAddAccount} disabled={Boolean(workingWxid)}>
<UserPlus size={16} />
</button>
</div>
</header>
<section className="account-management-summary">
<div className="summary-item">
<span className="summary-label"></span>
<span className="summary-value">{dbPath || '未配置'}</span>
</div>
<div className="summary-item">
<span className="summary-label"></span>
<span className="summary-value">{currentAccountLabel}</span>
</div>
<div className="summary-item">
<span className="summary-label"></span>
<span className="summary-value">{accounts.length}</span>
</div>
</section>
{notice && (
<div className={`account-notice ${notice.type}`}>
<span>{notice.text}</span>
{deleteUndoState && (notice.type === 'success' || notice.type === 'info') && (
<button
type="button"
className="notice-action"
onClick={() => void handleUndoDelete()}
disabled={Boolean(workingWxid)}
>
</button>
)}
</div>
)}
{accounts.length === 0 ? (
<div className="account-empty">
<Database size={20} />
<span></span>
</div>
) : (
<div className="account-list">
{accounts.map((account) => (
<article key={account.normalizedWxid} className={`account-card ${account.isCurrent ? 'is-current' : ''}`}>
<div className="account-avatar">
{account.avatarUrl ? <img src={account.avatarUrl} alt="" /> : <span>{resolveAccountAvatarText(account.displayName)}</span>}
</div>
<div className="account-main">
<div className="account-title-row">
<h3>{account.displayName}</h3>
{account.isCurrent && (
<span className="account-badge current">
<CheckCircle2 size={12} />
</span>
)}
{account.hasConfig ? (
<span className="account-badge ok"></span>
) : (
<span className="account-badge warn"></span>
)}
</div>
<div className="account-meta">wxid: {account.wxid}</div>
<div className="account-meta">
: {formatTime(account.modifiedTime)} · : {formatTime(account.configUpdatedAt)}
{!account.fromScan && <span className="meta-tip"></span>}
</div>
</div>
<div className="account-card-actions">
<button
type="button"
className="btn btn-secondary"
onClick={() => void handleSwitchAccount(account.wxid)}
disabled={Boolean(workingWxid) || account.isCurrent || !account.hasConfig || !account.fromScan}
>
<ArrowRightLeft size={14} /> {account.isCurrent ? '当前账号' : (!account.hasConfig ? '无配置' : (account.fromScan ? '切换' : '无数据'))}
</button>
<button
type="button"
className="btn btn-danger"
onClick={() => void handleDeleteAccountConfig(account.wxid)}
disabled={Boolean(workingWxid) || !account.hasConfig}
>
<Trash2 size={14} />
</button>
</div>
</article>
))}
</div>
)}
<footer className="account-management-footer">
WeFlow
</footer>
</div>
)
}
export default AccountManagementPage

View File

@@ -10,7 +10,7 @@
}
}
// 加载和错误状态
// Loading and error states
.loading-container,
.error-container {
display: flex;
@@ -23,7 +23,7 @@
color: var(--text-secondary);
.spin {
animation: spin 1s linear infinite;
animation: analyticsSpin 1s linear infinite;
}
p.loading-status {
@@ -33,13 +33,12 @@
}
.progress-bar-wrapper {
width: 300px;
height: 8px;
width: 280px;
height: 4px;
background: var(--bg-tertiary);
border-radius: 999px;
overflow: hidden;
position: relative;
border: 1px solid var(--border-color);
}
.progress-bar-fill {
@@ -47,9 +46,9 @@
left: 0;
top: 0;
height: 100%;
background: var(--primary-gradient);
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 0 10px rgba(139, 115, 85, 0.3);
background: var(--primary);
transition: width 0.3s ease;
border-radius: 999px;
}
.progress-percent {
@@ -65,57 +64,82 @@
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
@keyframes analyticsSpin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
to {
transform: rotate(360deg);
// Page scroll content
.page-scroll {
display: flex;
flex-direction: column;
gap: 24px;
}
.page-section {
display: flex;
flex-direction: column;
gap: 16px;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
h2 {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
}
// 统计卡片
// Stats overview cards
.stats-overview {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 24px;
gap: 12px;
}
.stat-card {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
gap: 14px;
padding: 18px 16px;
background: var(--card-bg);
border-radius: 12px;
border: 1px solid var(--border-color);
.stat-icon {
width: 48px;
height: 48px;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: var(--primary-light);
border-radius: 12px;
border-radius: 10px;
color: var(--primary);
flex-shrink: 0;
}
.stat-info {
display: flex;
flex-direction: column;
gap: 4px;
gap: 2px;
.stat-value {
font-size: 24px;
font-weight: 600;
font-size: 22px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.5px;
}
.stat-label {
font-size: 13px;
font-size: 12px;
color: var(--text-tertiary);
}
}
@@ -125,23 +149,23 @@
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
padding: 10px 14px;
background: var(--bg-tertiary);
border-radius: 8px;
margin-bottom: 24px;
font-size: 13px;
color: var(--text-secondary);
svg {
color: var(--text-tertiary);
flex-shrink: 0;
}
}
// Charts
.charts-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
margin-bottom: 24px;
gap: 12px;
}
.chart-card {
@@ -155,30 +179,30 @@
}
h3 {
font-size: 15px;
font-weight: 500;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 16px;
margin: 0 0 12px;
}
}
// Rankings
.rankings-list {
display: flex;
flex-direction: column;
gap: 8px;
gap: 4px;
}
.ranking-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: var(--bg-primary);
padding: 10px 14px;
border-radius: 8px;
transition: background 0.2s;
transition: background 0.15s ease;
&:hover {
background: var(--bg-tertiary);
background: var(--bg-hover);
}
.rank {
@@ -196,13 +220,13 @@
&.top {
background: var(--primary);
color: white;
color: var(--on-primary);
}
}
.contact-avatar {
width: 40px;
height: 40px;
width: 36px;
height: 36px;
flex-shrink: 0;
position: relative;
@@ -228,8 +252,8 @@
position: absolute;
right: -4px;
bottom: -4px;
width: 18px;
height: 18px;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
@@ -239,24 +263,21 @@
&.medal-1 {
background: linear-gradient(135deg, #ffd700, #ffb800);
color: #fff;
box-shadow: 0 2px 4px rgba(255, 184, 0, 0.4);
}
&.medal-2 {
background: linear-gradient(135deg, #c0c0c0, #a8a8a8);
color: #fff;
box-shadow: 0 2px 4px rgba(168, 168, 168, 0.4);
}
&.medal-3 {
background: linear-gradient(135deg, #cd7f32, #b87333);
color: #fff;
box-shadow: 0 2px 4px rgba(184, 115, 51, 0.4);
}
svg {
width: 10px;
height: 10px;
width: 8px;
height: 8px;
}
}
}
@@ -265,7 +286,7 @@
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
gap: 1px;
min-width: 0;
.contact-name {
@@ -284,14 +305,14 @@
}
.message-count {
font-size: 14px;
font-weight: 500;
font-size: 13px;
font-weight: 600;
color: var(--primary);
flex-shrink: 0;
}
}
// 响应式
// Responsive
@media (max-width: 1200px) {
.stats-overview {
grid-template-columns: repeat(2, 1fr);
@@ -312,11 +333,11 @@
}
}
// 排除好友弹窗
// Exclude friends modal
.exclude-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
@@ -325,13 +346,13 @@
}
.exclude-modal {
width: 560px;
width: 520px;
max-width: calc(100vw - 48px);
background: var(--card-bg);
background: var(--bg-secondary-solid, var(--bg-secondary));
border-radius: 16px;
border: 1px solid var(--border-color);
padding: 20px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.2);
.exclude-modal-header {
display: flex;
@@ -342,6 +363,7 @@
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
}
@@ -349,14 +371,14 @@
.modal-close {
width: 32px;
height: 32px;
border-radius: 50%;
border-radius: 8px;
border: none;
background: var(--bg-tertiary);
background: transparent;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--text-secondary);
color: var(--text-tertiary);
transition: all 0.15s;
&:hover {
@@ -370,7 +392,7 @@
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 10px;
border-radius: 8px;
border: 1px solid var(--border-color);
background: var(--bg-primary);
margin-bottom: 12px;
@@ -399,7 +421,7 @@
}
.exclude-modal-body {
max-height: 420px;
max-height: 380px;
overflow: auto;
padding-right: 4px;
}
@@ -419,7 +441,7 @@
.exclude-list {
display: flex;
flex-direction: column;
gap: 6px;
gap: 4px;
}
.exclude-item {
@@ -427,23 +449,23 @@
align-items: center;
gap: 12px;
padding: 8px 10px;
border-radius: 10px;
border-radius: 8px;
cursor: pointer;
border: 1px solid transparent;
transition: all 0.15s;
background: var(--bg-primary);
&:hover {
background: var(--bg-tertiary);
background: var(--bg-hover);
}
&.active {
border-color: rgba(7, 193, 96, 0.4);
background: rgba(7, 193, 96, 0.08);
border-color: rgba(16, 163, 127, 0.3);
background: rgba(16, 163, 127, 0.06);
}
input {
margin: 0;
accent-color: var(--primary);
}
}
@@ -455,7 +477,7 @@
display: flex;
flex-direction: column;
min-width: 0;
gap: 2px;
gap: 1px;
}
.exclude-name {
@@ -479,7 +501,7 @@
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 16px;
margin-top: 14px;
}
.exclude-footer-left {

View File

@@ -1,146 +1,116 @@
.analytics-entry-page {
.analytics-welcome-shell {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 100%;
}
.analytics-welcome-container {
.analytics-welcome-body {
display: flex;
flex-direction: column;
flex: 1;
align-items: center;
justify-content: center;
min-height: 0;
padding: 40px;
background: var(--bg-primary);
padding: 40px 24px;
animation: welcomeFadeIn 0.4s ease-out;
}
.analytics-welcome-content {
text-align: center;
max-width: 480px;
width: 100%;
}
.analytics-welcome-icon {
width: 56px;
height: 56px;
margin: 0 auto 20px;
background: var(--primary-light);
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
color: var(--primary);
}
.analytics-welcome-content h1 {
font-size: 24px;
font-weight: 700;
margin: 0 0 10px;
color: var(--text-primary);
animation: fadeIn 0.4s ease-out;
overflow-y: auto;
letter-spacing: -0.3px;
}
&.analytics-welcome-container--mode {
border-radius: 20px;
border: 1px solid var(--border-color);
background:
radial-gradient(circle at top, rgba(7, 193, 96, 0.06), transparent 48%),
var(--bg-primary);
.analytics-welcome-content p {
color: var(--text-secondary);
margin: 0 0 32px;
font-size: 14px;
line-height: 1.7;
}
.analytics-welcome-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.analytics-welcome-card {
display: flex;
align-items: center;
gap: 14px;
padding: 16px 18px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
cursor: pointer;
text-align: left;
color: var(--text-secondary);
transition: background 0.15s ease, border-color 0.15s ease;
&:hover {
background: var(--bg-hover);
border-color: var(--text-tertiary);
color: var(--primary);
}
.welcome-content {
text-align: center;
max-width: 600px;
.icon-wrapper {
width: 80px;
height: 80px;
margin: 0 auto 24px;
background: rgba(7, 193, 96, 0.1);
border-radius: 20px;
display: flex;
align-items: center;
justify-content: center;
color: #07c160;
svg {
width: 40px;
height: 40px;
}
}
h1 {
font-size: 28px;
margin-bottom: 12px;
font-weight: 600;
}
p {
color: var(--text-secondary);
margin-bottom: 40px;
font-size: 16px;
line-height: 1.6;
}
.action-cards {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-top: 20px;
button {
display: flex;
flex-direction: column;
align-items: center;
padding: 30px 20px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
cursor: pointer;
transition: all 0.2s ease;
text-align: center;
&:hover:not(:disabled) {
transform: translateY(-2px);
border-color: #07c160;
box-shadow: 0 4px 12px rgba(7, 193, 96, 0.1);
.card-icon {
color: #07c160;
background: rgba(7, 193, 96, 0.1);
}
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
filter: grayscale(100%);
}
.card-icon {
width: 50px;
height: 50px;
border-radius: 12px;
background: var(--bg-tertiary);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
color: var(--text-secondary);
transition: all 0.2s ease;
}
h3 {
font-size: 18px;
margin-bottom: 8px;
color: var(--text-primary);
}
span {
font-size: 13px;
color: var(--text-tertiary);
}
}
}
svg {
flex-shrink: 0;
}
}
@media (max-width: 768px) {
.analytics-welcome-container {
padding: 28px 18px;
.analytics-welcome-card-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.welcome-content {
.action-cards {
grid-template-columns: 1fr;
}
}
.analytics-welcome-card-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.analytics-welcome-card-meta {
font-size: 12px;
color: var(--text-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 540px) {
.analytics-welcome-actions {
grid-template-columns: 1fr;
}
}
@keyframes fadeIn {
@keyframes welcomeFadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);

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