Compare commits

..

419 Commits

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 15:18:52 +11:00
cc
d872a8af20 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-03-12 23:52:49 +08:00
cc
4966cdbfac 页面交互与动画优化 2026-03-12 23:52:46 +08:00
cc
cb3eb83eac Merge pull request #422 from Wythehard/codex/dev-pr-20260312
feat: sync local changes and add unsigned mac arm64 dmg action
2026-03-12 23:05:59 +08:00
superclaw
5daa7bce73 feat: update app logic and add unsigned mac arm64 dmg action 2026-03-12 22:56:09 +08:00
hicccc77
4e80f93b30 fix: enable file monitor for macOS (remove platform restriction) 2026-03-12 22:48:16 +08:00
hicccc77
2776a1a5ce chore: update wcdb_api with macOS file monitoring support (kqueue-based) 2026-03-12 22:43:32 +08:00
hicccc77
4f402d6a6a chore: update wcdb_api with simplified security check 2026-03-12 21:47:48 +08:00
hicccc77
d544da6e4d chore: update libwcdb_api.dylib with macOS cloud reporting support 2026-03-12 21:30:58 +08:00
hicccc77
0e42c19d3b feat(mac): implement WeChat PID detection and pass to helper 2026-03-12 20:46:10 +08:00
hicccc77
0a2bd3d46a chore: update xkey_helper and libwx_key to support pid parameter 2026-03-12 20:37:11 +08:00
cc
86d2dade11 Merge pull request #420 from hicccc77/main
同步更新
2026-03-12 19:50:17 +08:00
cc
19ab4409a3 Merge pull request #419 from hicccc77/test/macos-compat
适配Mac端功能的合并
2026-03-12 19:48:59 +08:00
cc
3af90bd6e9 Merge pull request #415 from SuperJuice666/dev
Fix: 修复批量解密海量图片时导致的 V8 内存溢出 (OOM) 问题
2026-03-12 19:48:06 +08:00
cc
cfb0cff1a3 Merge branch 'dev' into test/macos-compat
PR
2026-03-12 19:46:50 +08:00
cc
c08d6cd668 Merge PR #417 into test/macos-compat and resolve binary conflict 2026-03-11 23:25:27 +08:00
hicccc77
a53bebefd7 fix: 修复联系人索引问题
- 更新 wcdb_api 库,修复 contact.db 路径解析误匹配 contact_fts.db 的问题
- 更新 wx_key 库到最新版本
2026-03-11 23:13:10 +08:00
superclaw
8e0c3306e8 feat: macOS 接入 xkey_helper 并完善密钥获取与诊断 2026-03-11 23:06:20 +08:00
Jiang Huiyuan
f4364b3bd3 Refactor concurrency management in decryptOne function
Refactor concurrency handling in decryption process to use a Set for tracking promises instead of an array. This improves performance by simplifying the removal of completed promises.
2026-03-11 19:28:48 +08:00
hicccc77
5b5757a1d7 debug: 添加进程过滤日志 2026-03-11 00:11:36 +08:00
hicccc77
f165f4911b fix: 精确过滤微信进程 2026-03-10 23:56:23 +08:00
hicccc77
b81b538d9a fix: 使用 processor_set_tasks 无需权限 2026-03-10 23:48:21 +08:00
hicccc77
2f32c8e092 fix: koffi 自动转换字符串,无需 decode 2026-03-10 23:44:06 +08:00
hicccc77
d101a79bf8 debug: 添加详细日志输出 2026-03-10 23:36:32 +08:00
hicccc77
caea10a190 feat: 添加详细错误信息
- 区分进程未找到、附加失败、扫描失败
- 提示权限解决方案
2026-03-10 23:30:11 +08:00
hicccc77
1445202a0d fix: 更新 dylib 添加调试权限 2026-03-10 23:25:15 +08:00
hicccc77
6f62ac4ffb fix: 修正 koffi decode 调用方式
- 使用 'string' 而非 'char' 类型
- 移除调试代码
2026-03-10 23:18:11 +08:00
hicccc77
e87bbe7223 fix: 更新 dylib 修复模块查找问题 2026-03-10 23:16:22 +08:00
hicccc77
e7e2c40c68 fix: 更新 dylib 使用完整进程路径查找
- 查找包含 '微信' 或 'WeChat.app' 的进程
- 排除 WeChatAppEx
- 复刻 Python frida 脚本逻辑
2026-03-10 23:10:07 +08:00
hicccc77
78b6d445fa feat: 添加进程调试信息
- 显示找到的 WeChat 进程列表
- 更新 dylib 支持多进程名
- 帮助诊断附加失败问题
2026-03-10 23:01:58 +08:00
hicccc77
c212355860 fix: 更新 dylib 导出符号版本 2026-03-10 22:54:08 +08:00
hicccc77
c223c20b38 fix: 更新 dylib 静态链接版本
- 185KB (包含静态链接的 Dobby)
- 解决运行时 libdobby.dylib 依赖问题
2026-03-10 22:50:41 +08:00
hicccc77
524a9cda35 feat: 集成 KeyServiceMac 到 main.ts
- 根据平台自动选择服务
- macOS 使用 KeyServiceMac
- Windows 使用 KeyService
2026-03-10 22:44:26 +08:00
hicccc77
8bee66d404 feat: macOS 密钥获取完整实现
- KeyServiceMac 实现内存扫描
- 复刻 Windows 的图片密钥扫描逻辑
- 使用 Xkey dylib 的 ScanMemoryForImageKey
- 更新 libwx_key.dylib (102KB)
2026-03-10 22:43:47 +08:00
cc
142b00499b Merge pull request #406 from hicccc77/dev
Dev
2026-03-10 22:21:10 +08:00
xuncha
b0ea6c0ea2 Merge pull request #404 from aits2026/codex/ts0306-01-export-opt
feat: 优化聊天分析、设置弹窗与导出会话交互
2026-03-10 17:40:35 +08:00
aits2026
67fd53a503 fix(export): unlock session header left columns 2026-03-10 16:17:22 +08:00
aits2026
29529271fb fix(export): clean session row hover state 2026-03-10 15:58:28 +08:00
aits2026
4489a0f702 fix(export): align session sticky columns 2026-03-10 15:31:15 +08:00
aits2026
0d9fcc731a fix(export): restore sticky session rows 2026-03-10 14:58:52 +08:00
aits2026
fe1c8862e6 feat(export): pin session table edge columns 2026-03-10 14:55:34 +08:00
aits2026
092450e4f8 chore(export): shorten session export label 2026-03-10 14:48:34 +08:00
aits2026
da054de708 fix(export): clean session row action background 2026-03-10 14:47:14 +08:00
aits2026
dfac3c57cc fix(export): remove virtuoso horizontal scrollbar 2026-03-10 14:43:14 +08:00
aits2026
0f3ecdc4ee fix(export): hide duplicate session table scrollbar 2026-03-10 14:39:23 +08:00
aits2026
24c47c3aa3 fix(export): refine session export table layout 2026-03-10 14:33:51 +08:00
aits2026
f53de9fe0b fix(export): tighten session export tab width 2026-03-10 14:27:24 +08:00
aits2026
ee4d1f5689 style: tighten export tab content layout 2026-03-10 14:18:07 +08:00
aits2026
122ad73c2e style: remove outer export table frame 2026-03-10 14:15:40 +08:00
aits2026
6ad1e6c3f3 style: tighten export tab spacing 2026-03-10 14:13:51 +08:00
aits2026
c899fa72b8 fix: keep export tabs on one line 2026-03-10 14:11:05 +08:00
aits2026
e209bd68d4 fix: export link metadata for arkme json 2026-03-10 14:06:03 +08:00
aits2026
96ac655d92 fix: preserve text export format selection 2026-03-10 13:55:26 +08:00
aits2026
1d97b19774 style: clean up whisper model layout 2026-03-10 13:49:31 +08:00
aits2026
11c7de3568 fix: unify collapsed account menu width 2026-03-10 13:43:24 +08:00
aits2026
38d899fa94 style: tighten settings and account menu 2026-03-10 13:38:29 +08:00
aits2026
37796c98c9 feat: show settings as modal dialog 2026-03-10 13:32:19 +08:00
aits2026
5b2e48badd refactor: streamline sidebar account menu 2026-03-10 13:24:47 +08:00
aits2026
627aa35f88 refactor: move sidebar toggle to title bar 2026-03-10 12:38:23 +08:00
aits2026
74e974177c refactor: tighten private analytics header 2026-03-10 12:24:42 +08:00
aits2026
6911132c95 fix: align group analytics header layout 2026-03-10 12:17:21 +08:00
aits2026
f1affc7d63 refactor: simplify chat analytics header 2026-03-10 12:12:50 +08:00
aits2026
bea824aee9 feat: unify chat analytics navigation 2026-03-10 12:04:56 +08:00
aits2026
cbdd5b3a24 fix: refresh sns feed with latest selected contacts 2026-03-10 11:48:25 +08:00
aits2026
c02bc753fd feat: filter SNS feed by selected contacts 2026-03-10 11:38:38 +08:00
aits2026
d4915e1a62 feat: support batch-select SNS contacts 2026-03-10 11:01:54 +08:00
aits2026
2d4a5fc62f Revert "feat: enrich mutual friend identities in export dialog"
This reverts commit f3027da43885a67583099008991dbfc4def3f4d1.
2026-03-10 09:37:31 +08:00
aits2026
94a010c9b2 feat: enrich mutual friend identities in export dialog 2026-03-10 09:37:31 +08:00
aits2026
a6a202f6ff fix(export): remove incorrect row action offset 2026-03-10 09:37:31 +08:00
aits2026
2127fdd443 style(export): tighten sticky action cell right padding 2026-03-10 09:37:31 +08:00
aits2026
3b3fd8b35c fix(export): keep session row actions sticky in viewport 2026-03-10 09:37:31 +08:00
aits2026
95d0937015 fix(export): align session metric columns with header 2026-03-10 09:37:31 +08:00
aits2026
b070b4f659 fix(export): support dragging session table header horizontally 2026-03-10 09:37:31 +08:00
aits2026
a8c05fd26c fix(export): add top horizontal scrollbar for session table 2026-03-10 09:37:31 +08:00
aits2026
ecd64f62bc feat(export): show and stop background page tasks 2026-03-10 09:37:31 +08:00
aits2026
5affd4e57b fix(export): restore session table body rendering 2026-03-10 09:37:31 +08:00
aits2026
76d69ab7dd fix(export): improve dark-mode layout dropdown contrast 2026-03-10 09:37:31 +08:00
aits2026
1d1b38210a fix(export): add horizontal scrollbar for narrow session table 2026-03-10 09:37:31 +08:00
hicccc77
836032d93e fix: 修复聊天记录页面崩溃问题并添加错误边界保护 2026-03-08 18:51:20 +08:00
hicccc77
dc3e285917 fix: 更新 macOS 库文件 2026-03-08 14:07:54 +08:00
hicccc77
e54eb8fea2 fix: 修复年度报告页面导致主题异常的问题 2026-03-08 14:00:38 +08:00
hicccc77
177dbaa5ff fix: 更新 macOS 库以支持进程名校验 2026-03-08 13:46:22 +08:00
hicccc77
1d08ab945d feat: dbPathService 支持 macOS 微信数据库路径 2026-03-08 13:01:49 +08:00
hicccc77
10ce7d772c feat: cloudControlService 支持 macOS 平台版本识别 2026-03-08 12:25:06 +08:00
hicccc77
e1a23ac606 feat: wcdbCore 支持 macOS 平台 2026-03-08 12:21:16 +08:00
hicccc77
439259ec57 feat: 添加 macOS 跨平台支持的 wcdb_api 库 2026-03-08 11:43:26 +08:00
cc
a0dda0b866 Merge pull request #384 from hicccc77/dev
修复了一些问题
2026-03-08 00:21:07 +08:00
cc
6913defc12 修复了一些问题 2026-03-08 00:19:46 +08:00
hicccc77
f3e2fdd4fc 配置:忽略 pnpm-lock.yaml 2026-03-08 00:02:49 +08:00
hicccc77
5c44b35045 配置:忽略 pnpm-lock.yaml 2026-03-08 00:02:36 +08:00
hicccc77
cebb6426f8 发布:合并 dev 分支到 main 2026-03-07 23:58:43 +08:00
hicccc77
f05e50e63e 更新:wcdb_api.dll 至最新版本 2026-03-07 23:55:15 +08:00
hicccc77
f8ef3f18ff 修复:WCDB 初始化失败时显示错误码
- 将 DLL 返回的错误码保存到 lastDllInitError
- 用户可以看到具体错误码,方便反馈问题
- 配合 DLL 更新,支持更详细的错误码
- 修复 #353
2026-03-07 17:49:24 +08:00
hicccc77
47dbc540ac 优化:扫内存获取密钥时如果找不到则自动扫描更多文件
- 首次扫描 32 个模板文件
- 如果找不到有效的 XOR 密钥,自动扩展到 100 个文件
- 提高密钥获取成功率
2026-03-07 15:15:43 +08:00
hicccc77
766d5ed2af 修复:扫内存获取图片密钥时 XOR 密钥为空默认为 0 的问题
- 当无法从模板文件计算出有效的 XOR 密钥时,返回错误而不是默认为 0x00
- 提前检查 XOR 密钥是否为 null,避免后续使用错误的密钥
- 修复 #355
2026-03-07 15:11:57 +08:00
hicccc77
783b408611 修复:在 getImageData 中使用 imageDecryptService 以支持 msg/attach 目录
- 将 ImageDecryptService 集成到 ChatService
- 用 imageDecryptService.decryptImage() 替换 findDatFile
- 支持通过 hardlink.db 查询高清图片路径
- 修复 #363
2026-03-07 15:00:59 +08:00
hicccc77
24c91269a0 refactor: remove unused better-sqlite3 dependency
- Remove better-sqlite3 from package.json dependencies
- Remove @types/better-sqlite3 from devDependencies
- Remove unused hardlink.db query logic in videoService (always uses wcdbService/DLL)
- Remove unused hardlink.db query logic in chatService (fallback to filesystem search)
- Remove HardlinkState type and hardlinkCache
- Simplify code by removing dead optimization paths
2026-03-07 14:26:26 +08:00
xuncha
e786026049 Merge pull request #381 from hicccc77/dev
Dev
2026-03-06 17:22:25 +08:00
xuncha
566b0cf6e5 Merge pull request #373 from aits2026/codex/ts0305-01-export-module-upgrade
导出模块继续优化+朋友圈体验增强+显性化应用锁入口+其他一些小优化
2026-03-06 17:21:59 +08:00
aits2026
b17844e837 style(export): tighten path label spacing 2026-03-06 17:18:06 +08:00
aits2026
5c93c4db57 style(export): align top settings button height 2026-03-06 17:15:27 +08:00
aits2026
57e8a96a4a feat: animate load detail entry icon 2026-03-06 17:09:23 +08:00
aits2026
438581834e refactor: simplify mutual friends list items 2026-03-06 17:05:30 +08:00
aits2026
58cfd49859 feat: clarify mutual friends direction labels 2026-03-06 16:58:44 +08:00
aits2026
4a1933e924 fix: include reverse mutual friends 2026-03-06 16:52:02 +08:00
aits2026
6ded8c5ab5 feat: add export mutual friends stats 2026-03-06 16:39:40 +08:00
aits2026
edf38aad48 refactor(sns): unify export date range dialog 2026-03-06 16:16:34 +08:00
aits2026
f4caa51da5 refactor(sns): open contact timeline from sidebar 2026-03-06 16:07:48 +08:00
aits2026
9575ba2a9f feat(sns): show selected jump date in header 2026-03-06 15:51:03 +08:00
aits2026
af2fe91f81 fix(sns): restore jump calendar positioning 2026-03-06 15:47:48 +08:00
aits2026
c641c86598 style(sns): reorder summary and my timeline 2026-03-06 15:46:30 +08:00
aits2026
0599de372a refactor(sns): move jump calendar to header 2026-03-06 15:44:24 +08:00
aits2026
1c89ee2797 style(sns): move friends count to contact header 2026-03-06 15:42:10 +08:00
aits2026
5fd846bfc8 feat(sns): show daily counts in jump calendar 2026-03-06 15:39:13 +08:00
aits2026
02aefcf155 fix(sns): stabilize jump date popover month 2026-03-06 15:34:58 +08:00
aits2026
e92983dd80 style(sns): compact jump date filter row 2026-03-06 15:32:13 +08:00
aits2026
03f65317a9 refactor(sns): use shared jump date popover 2026-03-06 15:30:10 +08:00
aits2026
21cb09fbde style(sns): tighten feed header spacing 2026-03-06 15:26:03 +08:00
aits2026
6c1e7f6f12 fix(sns): expand feed viewport height 2026-03-06 15:24:53 +08:00
aits2026
344dd3343b feat(export): separate voice transcription toggle 2026-03-06 14:58:28 +08:00
aits2026
cacb9e449c style(export): make media defaults responsive 2026-03-06 14:53:51 +08:00
aits2026
18313141f4 style(export): reorder defaults fields 2026-03-06 14:52:28 +08:00
aits2026
ecd73ae0d6 style(export): compact media default labels 2026-03-06 14:50:35 +08:00
aits2026
7ad754df03 style(export): flatten media default options 2026-03-06 14:48:32 +08:00
aits2026
cfc601e19a fix(export): align modal header gutters 2026-03-06 14:46:32 +08:00
aits2026
9984f9c206 style(export): stack media defaults section 2026-03-06 14:45:33 +08:00
aits2026
39e59a4077 feat(export): split default media options 2026-03-06 14:41:02 +08:00
aits2026
d735ed19cb style(export): soften non-text batch buttons 2026-03-06 14:34:25 +08:00
aits2026
f4037a1ccf fix(export): size format trigger to content 2026-03-06 14:31:00 +08:00
aits2026
3e917e2062 fix(export): right align format trigger label 2026-03-06 14:29:04 +08:00
aits2026
919357a374 fix(export): stretch format trigger 2026-03-06 14:26:43 +08:00
aits2026
5b6be864fd fix(export): clarify text batch dialog copy 2026-03-06 14:23:54 +08:00
aits2026
98a3b06e56 fix(export): align collapsed format selector 2026-03-06 14:19:27 +08:00
aits2026
6253def76c feat(export): refine format selector layouts 2026-03-06 14:15:18 +08:00
aits2026
450e5f7e61 feat(export): centralize avatar export default 2026-03-06 14:11:02 +08:00
aits2026
d2ec9c680d feat(export): simplify concurrency selector 2026-03-06 13:59:17 +08:00
aits2026
56d7ad6999 feat(export): expand defaults format options 2026-03-06 13:57:22 +08:00
aits2026
97024395c1 fix(export): reorder defaults settings 2026-03-06 13:53:12 +08:00
aits2026
10342be2be fix(export): refine defaults modal interactions 2026-03-06 13:48:10 +08:00
aits2026
51a3ee4a9b feat(export): split defaults modal layout 2026-03-06 13:42:56 +08:00
aits2026
8779bbc532 refactor(settings): remove export tab 2026-03-06 13:39:52 +08:00
aits2026
90b33ef444 feat(export): add more defaults modal 2026-03-06 13:36:25 +08:00
aits2026
3fa0b36426 fix(export): pin task center card right 2026-03-06 13:11:29 +08:00
aits2026
60a64cd777 fix(export): balance top card spacing 2026-03-06 13:09:26 +08:00
aits2026
c543fabdf4 fix(export): tighten top control bar 2026-03-06 13:06:32 +08:00
aits2026
64b96f00f7 fix(export): align task center card height 2026-03-06 13:02:27 +08:00
aits2026
86b372de68 feat(export): compact task center entry 2026-03-06 12:58:22 +08:00
aits2026
c108070696 feat(sidebar): surface unlock entry 2026-03-06 12:55:08 +08:00
aits2026
80a193a394 fix(export): align selection column baseline 2026-03-06 12:35:02 +08:00
aits2026
b9c16dbee4 fix(export): align header actions layout 2026-03-06 12:29:46 +08:00
aits2026
6e870ef300 feat(settings): unify export date range defaults 2026-03-06 12:29:32 +08:00
aits2026
cf45ae30ac fix(export): hide scope card for single dialog 2026-03-06 12:12:12 +08:00
aits2026
38a0453cbb fix(export): restore loading states for session metrics 2026-03-06 12:01:21 +08:00
aits2026
92d37abbc5 fix(export): reduce sticky action width 2026-03-06 11:48:10 +08:00
aits2026
39662038f7 fix(export): tighten action column layout 2026-03-06 11:45:38 +08:00
aits2026
75b58d0423 fix(export): tighten sticky action divider 2026-03-06 11:37:04 +08:00
aits2026
1814808df1 fix(export): keep actions sticky and refine headers 2026-03-06 11:32:38 +08:00
aits2026
fe57d80a00 fix(export): center single export action text 2026-03-06 11:27:43 +08:00
aits2026
8cb855328d refactor(export): reuse shared sns timeline dialog 2026-03-06 11:12:23 +08:00
aits2026
a62ba8e167 fix(sns): sync my timeline count and auto load more 2026-03-06 11:03:11 +08:00
aits2026
4f40b4af49 feat(sns): add my timeline shortcut 2026-03-06 10:55:23 +08:00
aits2026
8d9a042489 feat(chat): add sns timeline entry for private sessions 2026-03-06 10:41:06 +08:00
aits2026
ef05466d6d refactor(sns): reuse shared contact timeline dialog in sns page 2026-03-06 10:34:16 +08:00
aits2026
0a5cf005a1 Merge remote-tracking branch 'upstream/dev' into codex/ts0305-01-export-module-upgrade 2026-03-06 10:22:36 +08:00
aits2026
f6c365bdf1 Merge remote-tracking branch 'origin/codex/ts0305-01-export-module-upgrade' into codex/ts0305-01-export-module-upgrade 2026-03-06 10:22:28 +08:00
aits2026
bc2ab60c59 feat(sns): add contact timeline dialog components 2026-03-06 10:22:24 +08:00
aits2026
ad217d4a3b feat(export): compute sns rankings from full contact timeline 2026-03-06 10:05:46 +08:00
cc
61cc3e6f58 Merge pull request #376 from hicccc77/dev
Dev
2026-03-05 23:08:27 +08:00
cc
a3ab06509e 一些更新 2026-03-05 20:46:18 +08:00
aits2026
54684ea3c9 Merge branch 'codex/ts0305-01-export-module-upgrade' into HEAD
# Conflicts:
#	electron/main.ts
2026-03-05 20:45:54 +08:00
aits2026
3de4951c96 fix(export): show top 15 entries in sns rank strip 2026-03-05 20:25:53 +08:00
aits2026
05c551d7ac fix(export): hide recent-export row when no history 2026-03-05 20:24:55 +08:00
aits2026
7cea8b4fb3 fix(export): tune sns rank strip size and theme compatibility 2026-03-05 20:10:47 +08:00
aits2026
ba2cdbf8cf feat(export): add sns like/comment ranking strip in detail header 2026-03-05 20:02:14 +08:00
aits2026
3e004867be fix(export): show sns counts per-session as soon as loaded 2026-03-05 19:55:17 +08:00
aits2026
edaef53712 feat(export): add sns detail sync tip between header and list 2026-03-05 19:52:23 +08:00
aits2026
933842f6af refactor(export): remove official sessions from conversation export flow 2026-03-05 19:47:06 +08:00
aits2026
2eff82891e feat(export): add clickable sns count column in session list 2026-03-05 19:43:11 +08:00
aits2026
c625756ab4 fix(export,sns): preserve sns load state across route switches 2026-03-05 19:35:42 +08:00
aits2026
2140a220e2 fix(electron): ensure app quits after main window close in dev 2026-03-05 19:28:28 +08:00
aits2026
7ead55d801 fix(export,sns): share sns user count cache across pages 2026-03-05 19:21:37 +08:00
aits2026
4e0038c813 feat(export): include sns count loading progress in load detail 2026-03-05 19:07:13 +08:00
aits2026
d07e4c8ecd chore(sns): remove my-posts segment from overview stats line 2026-03-05 19:02:38 +08:00
aits2026
63fd42ff05 feat(sns): incremental contact post-count ranking in filter list 2026-03-05 18:50:46 +08:00
aits2026
d5dbcd3f80 fix(export): align sns timeline dialog with sns page rendering 2026-03-05 18:27:30 +08:00
aits2026
c301f36912 feat(export): add sns count and timeline popup in session detail 2026-03-05 18:08:09 +08:00
aits2026
9dd5ee2365 fix(export): align media load progress with visible loaded state 2026-03-05 17:44:32 +08:00
aits2026
3388b7a122 fix(sns): derive per-user totals from timeline counts map 2026-03-05 17:44:24 +08:00
aits2026
38af8de469 fix(sns): fallback to userName when user_name count is zero 2026-03-05 17:33:41 +08:00
aits2026
db0ebc6c33 feat(sns): show loaded vs total posts in author timeline 2026-03-05 17:24:28 +08:00
aits2026
7cc2961538 fix(export): hide finish time until grouped load completes 2026-03-05 17:24:14 +08:00
aits2026
835ec4782c feat(export): show spinner in load detail in-progress status 2026-03-05 17:15:33 +08:00
aits2026
e6942bc201 feat(export): add session load detail modal with typed progress 2026-03-05 17:11:04 +08:00
aits2026
ebabe1560f feat(sns): support opening author timeline from post 2026-03-05 16:34:29 +08:00
aits2026
4da697f507 feat(export): show loading icon for media metric columns 2026-03-05 16:34:16 +08:00
aits2026
f18fb83a92 feat(chat): smooth standalone session window loading 2026-03-05 16:32:25 +08:00
aits2026
e050402787 feat(export): add 4 media columns with visible-first staged loading 2026-03-05 16:28:18 +08:00
aits2026
b3dd0e25fa feat(export): move open-chat action below message count 2026-03-05 16:18:00 +08:00
aits2026
a5358b82f6 perf(export): further optimize detail loading and prioritize session stats 2026-03-05 16:05:58 +08:00
aits2026
2a9f0f24fd perf(export): speed up session detail stats loading 2026-03-05 15:39:59 +08:00
xuncha
5945942acd 修复dev关闭还有进程 2026-03-05 15:01:03 +08:00
xuncha
bcdb983b98 Merge pull request #367 from xunchahaha:dev
修复dev关闭还有进程
2026-03-05 14:43:25 +08:00
xuncha
7836c611b7 修复dev关闭还有进程 2026-03-05 14:43:03 +08:00
xuncha
2797d571e4 Merge pull request #366 from hicccc77/dev
Dev
2026-03-05 14:32:36 +08:00
xuncha
389fd0b1b0 Merge pull request #365 from xunchahaha:dev
Dev
2026-03-05 14:31:47 +08:00
xuncha
25630da1ce 图片解密++ 2026-03-05 14:31:15 +08:00
xuncha
ca972d3e28 导出页优化 2026-03-05 14:26:37 +08:00
xuncha
80420302c1 Merge branch 'hicccc77:dev' into dev 2026-03-05 14:03:06 +08:00
xuncha
9759d5f64f Merge pull request #362 from aits2026/codex/ts0301-01-export-opt
导出能力重构 + 聊天/SNS/年报协同优化
2026-03-05 14:01:55 +08:00
tisonhuang
17a9b6102e merge: resolve upstream/dev conflicts in export workflow branch 2026-03-05 13:55:42 +08:00
tisonhuang
7e7503035a refactor(export): remove scroll back-to-top affordance 2026-03-05 13:31:13 +08:00
tisonhuang
02a6b24517 refactor(export): remove redundant header select-all text action 2026-03-05 12:49:13 +08:00
tisonhuang
b3fee5b56d fix(export): show loading text for pending session message counts 2026-03-05 12:47:28 +08:00
tisonhuang
26d38acddb fix(export): increase default session list viewport to 10 rows 2026-03-05 12:34:23 +08:00
tisonhuang
8a30e9b663 refactor(export): merge bulk selection actions into header row 2026-03-05 12:31:29 +08:00
tisonhuang
46a2d04528 fix(export): hand off wheel scroll between page and session list 2026-03-05 12:25:11 +08:00
tisonhuang
6a85b82643 fix(export): restore virtualized contacts list and sticky controls 2026-03-05 12:18:28 +08:00
tisonhuang
b436bb63da feat(export): refine time range dialog mode switching 2026-03-05 12:10:07 +08:00
tisonhuang
b5cb4051ab feat(export): add yearly time range presets 2026-03-05 11:40:32 +08:00
tisonhuang
01f774db54 feat(export): revamp time range dialog with dual calendars 2026-03-05 11:36:56 +08:00
tisonhuang
c5a6d765ee fix(auth): avoid logout on export-only clear and harden db key auto-fetch 2026-03-05 11:15:44 +08:00
tisonhuang
459f23bbd6 feat(sidebar): add account data clear action and detail feedback 2026-03-05 10:57:15 +08:00
tisonhuang
360754737f feat(export): redesign time range selector with nested dialog 2026-03-05 10:48:21 +08:00
tisonhuang
36f1476782 feat(export): add session name prefix toggle in layout dropdown 2026-03-05 10:36:29 +08:00
tisonhuang
ecae83f659 docs(export): add session-coverage note in type export tooltip 2026-03-05 10:29:05 +08:00
tisonhuang
fbe5109ed9 refactor(export): simplify session export title and toolbar text 2026-03-05 10:26:09 +08:00
tisonhuang
4adedad0de fix(export): hide cache meta row and adjust info popover anchor 2026-03-05 10:24:01 +08:00
tisonhuang
28257ba66f style(export): place section info icons next to titles 2026-03-05 10:19:32 +08:00
tisonhuang
3062295069 feat(export): show selected count on batch export button 2026-03-05 10:16:51 +08:00
tisonhuang
3c231a7fde feat(export): add batch export section titles with info popovers 2026-03-05 10:13:58 +08:00
tisonhuang
0247b02f6e fix(sidebar): normalize self wxid and resolve real nickname 2026-03-05 09:46:25 +08:00
tisonhuang
8aaad71784 refactor(sns): remove contact post-count stats flow 2026-03-05 09:34:57 +08:00
tisonhuang
e795474917 fix(export): persist write layout across page switches 2026-03-05 09:20:52 +08:00
tisonhuang
49f99f57c9 fix(chat): render date popover in top portal for stable layering 2026-03-05 09:15:23 +08:00
cc
53398707aa 修复了导出页面一些小问题 2026-03-04 22:46:31 +08:00
xuncha
1d8a7d2e63 新增纯黑白样式 2026-03-04 21:26:20 +08:00
tisonhuang
313e2bc080 feat(export): add multi-select contacts list for batch export 2026-03-04 21:19:11 +08:00
tisonhuang
0037935280 fix(export): force json format and B write layout defaults 2026-03-04 21:19:11 +08:00
tisonhuang
7858b40ce4 feat(export): hide display-name section for selected batch dialogs 2026-03-04 21:19:11 +08:00
tisonhuang
ab6db27ea7 fix(export): show completed sessions progress in task card 2026-03-04 21:19:11 +08:00
tisonhuang
4568795081 perf(export): optimize task center modal responsiveness 2026-03-04 21:19:11 +08:00
tisonhuang
43643d1a83 feat(export): simplify export panel and page-scroll contacts list 2026-03-04 21:19:11 +08:00
tisonhuang
28e7de6ceb fix(chat): portalize standalone jump calendar to avoid translucent compositing 2026-03-04 21:19:11 +08:00
tisonhuang
c204855a71 fix(chat): hide export/transcribe/decrypt actions in standalone chat 2026-03-04 21:19:11 +08:00
tisonhuang
dab33c4e60 fix(chat): force opaque jump calendar in standalone window 2026-03-04 21:19:11 +08:00
tisonhuang
47f9c0a502 fix(chat): keep cross-day browsing after date jump 2026-03-04 21:19:11 +08:00
tisonhuang
d9a6fd2a42 style(chat): make jump calendar popover background fully opaque 2026-03-04 21:19:11 +08:00
tisonhuang
dcb91905ad style(chat): refine jump calendar date/count typography 2026-03-04 21:19:11 +08:00
tisonhuang
b6fd842d4e feat(export): add persistent session export records in detail panel 2026-03-04 21:19:11 +08:00
tisonhuang
4b57e3e350 feat(chat): replace jump date modal with inline calendar popover 2026-03-04 21:19:11 +08:00
tisonhuang
1652ebc4ad fix(chat): show group member count loading and failed states 2026-03-04 21:19:11 +08:00
tisonhuang
924ff1b6fc feat(export): narrow chat window and refine progress settle 2026-03-04 21:19:11 +08:00
tisonhuang
926ca72331 feat(export): add open-chat window from session list 2026-03-04 21:19:11 +08:00
tisonhuang
cf7190aaec refactor(export): remove task pause/stop and prioritize export by loaded message counts 2026-03-04 21:19:11 +08:00
tisonhuang
54d6cded53 perf(chat): restore session window from cache on switch back 2026-03-04 21:19:11 +08:00
tisonhuang
7a7e54ea5b perf(export): reuse pre-estimate cache during export run 2026-03-04 21:19:11 +08:00
tisonhuang
7b4aa23f35 perf(chat): speed up session switch and stabilize message cursor 2026-03-04 21:19:11 +08:00
tisonhuang
ac4482bc8b perf(export): reuse aggregated session stats for pre-export estimate 2026-03-04 21:19:11 +08:00
tisonhuang
0a7f2b15f1 fix(export): keep only total message count in session list 2026-03-04 21:19:11 +08:00
tisonhuang
95e0b83537 fix(export): recover total-count sorting after cache hydrate 2026-03-04 21:19:11 +08:00
tisonhuang
bb602af750 fix(stats): ensure accurate transfer red-packet and call counts in detail panels 2026-03-04 21:19:11 +08:00
tisonhuang
580242b9d2 perf(export): persist session list stats across app restarts 2026-03-04 21:19:11 +08:00
tisonhuang
2cc1b55cbf feat(stats): add transfer red-packet and call message counts in session details 2026-03-04 21:19:11 +08:00
tisonhuang
e1944783d0 feat(report): reuse years loading across page switches 2026-03-04 21:19:11 +08:00
tisonhuang
423d760f36 perf(export): order media stats by total message rank 2026-03-04 21:19:11 +08:00
tisonhuang
16e237b698 feat(report): improve years loading status messaging 2026-03-04 21:19:11 +08:00
tisonhuang
28d68d8a8e feat(report): stream available years loading 2026-03-04 21:19:11 +08:00
tisonhuang
d476fbbdae perf(export): prioritize visible content stats loading 2026-03-04 21:19:11 +08:00
tisonhuang
64542f2902 fix(export): compute missing stats when stale cache allowed 2026-03-04 21:19:11 +08:00
tisonhuang
56a59a5355 fix(report): speed up available years loading 2026-03-04 21:19:11 +08:00
tisonhuang
285ddeb62e perf(export): reduce reloads when switching back 2026-03-04 21:19:11 +08:00
tisonhuang
84ef51f16b perf(export): reuse list message count in detail 2026-03-04 21:19:11 +08:00
tisonhuang
fb1125136c feat(export): refine top card copy and sns header count 2026-03-04 21:19:11 +08:00
tisonhuang
55f7ff1842 perf(chat): reduce session detail stats latency 2026-03-04 21:19:11 +08:00
tisonhuang
ac1d2210da feat(export): sort session list by total messages 2026-03-04 21:19:11 +08:00
tisonhuang
ff92f355e2 feat: update chat service and simplify export contact rows 2026-03-04 21:19:11 +08:00
tisonhuang
4b8c8155fa perf(export): speed up session message count aggregation 2026-03-04 21:19:11 +08:00
tisonhuang
756a83191d feat(export): add session total message count column with staged loading 2026-03-04 21:19:11 +08:00
tisonhuang
b5eb8be15e perf(export): remove private relation stats and avatar backfill overhead 2026-03-04 21:19:11 +08:00
tisonhuang
38a023d0b6 feat(sns): show my post count in overview stats 2026-03-04 21:19:10 +08:00
tisonhuang
3a878dd019 feat(sns-export): add record owner to arkmejson header 2026-03-04 21:19:10 +08:00
tisonhuang
6314c0f1d6 feat(sns-export): split media export selection into image/live/video 2026-03-04 21:19:10 +08:00
tisonhuang
c5eed25f06 feat(export): add sns arkmejson format and consolidate export flow changes 2026-03-04 21:19:10 +08:00
tisonhuang
e1243522b0 feat(export): enrich arkme json for card/location/music 2026-03-04 21:19:10 +08:00
tisonhuang
d9108ac6ed feat(export): optimize text export and enrich arkme metadata 2026-03-04 21:19:10 +08:00
tisonhuang
302abe3e40 perf(export): return card stats from snapshot and refresh in background 2026-03-04 21:19:10 +08:00
tisonhuang
b6a2191e38 fix(export): prevent card stats poll overlap with frontend/backend singleflight 2026-03-04 21:19:10 +08:00
tisonhuang
84b54e43aa feat(export): add card stats diagnostics panel and log export 2026-03-04 21:19:10 +08:00
tisonhuang
e9971aa6c4 fix(chat): avoid detail auto refresh blocking group members load 2026-03-04 21:19:10 +08:00
tisonhuang
91f630209c feat(export): improve count accuracy and include pending updates 2026-03-04 21:19:10 +08:00
tisonhuang
b6878aefd6 feat(export): fast accurate content session counts on cards 2026-03-04 21:19:10 +08:00
tisonhuang
f0f70def8c fix(chat): restore group member friend badge fallback 2026-03-04 21:19:10 +08:00
tisonhuang
81bc5aefff fix(export): improve batch text progress and precheck interruptibility 2026-03-04 21:19:10 +08:00
tisonhuang
698d2c96d7 fix(chat): avoid group members sidebar stuck on first init 2026-03-04 21:19:10 +08:00
tisonhuang
ce683a539d fix(export): improve progress visibility and hard-stop control 2026-03-04 21:19:10 +08:00
tisonhuang
ac481c6b18 feat(export): optimize batch export flow and unify session detail typing 2026-03-04 21:19:10 +08:00
tisonhuang
750d6ad7eb feat(export): add text batch task performance breakdown 2026-03-04 21:19:10 +08:00
tisonhuang
7bd801cd01 feat(chat): add group members sidebar with owner/friend badges 2026-03-04 21:19:10 +08:00
tisonhuang
5cb364f754 perf(export): speed up batch text export pipeline 2026-03-04 21:19:10 +08:00
tisonhuang
04d1b0c694 feat(export): sync task badge globally and finalize export layout updates 2026-03-04 21:19:10 +08:00
tisonhuang
35028df817 fix(export): enforce english type folders for layout A 2026-03-04 21:19:10 +08:00
tisonhuang
2e8f55d7a8 feat(chat-export): open single export dialog in chat with init feedback 2026-03-04 21:19:10 +08:00
tisonhuang
815a440082 fix(export): place text exports in 聊天文本 dir for layout A 2026-03-04 21:19:10 +08:00
tisonhuang
2afcd528dc fix(export): make task center modal fully opaque 2026-03-04 21:19:10 +08:00
tisonhuang
8d68a59799 feat(export): modal task center with pause/stop controls 2026-03-04 21:19:10 +08:00
tisonhuang
51bc60776d feat(export): prefix text export filenames by session type 2026-03-04 21:19:10 +08:00
tisonhuang
43f4c966f9 feat(export): show running state on content and sns cards 2026-03-04 21:19:10 +08:00
tisonhuang
98a0233c4d feat(export): tailor content batch dialog and widen layout menu 2026-03-04 21:19:10 +08:00
tisonhuang
0545be3244 style(export): tighten write-layout trigger width 2026-03-04 21:19:10 +08:00
tisonhuang
4a67b22d8d feat(sns): progressively prune zero-post contacts 2026-03-04 21:19:10 +08:00
tisonhuang
5840bf710c style(export): keep top controls horizontal on narrow widths 2026-03-04 21:19:10 +08:00
tisonhuang
1b8e1c2aab fix(sns): make covered-user query resilient 2026-03-04 21:19:10 +08:00
tisonhuang
60aa949cca fix(export): allow page-level vertical scroll on short windows 2026-03-04 21:19:10 +08:00
tisonhuang
5b05b8927c style(export): tighten and auto-fit content cards 2026-03-04 21:19:10 +08:00
tisonhuang
d65d6d2396 fix(sns): add overview stats status and fallback resilience 2026-03-04 21:19:10 +08:00
tisonhuang
086ac8fdc9 feat(export): simplify media selection in detail dialog 2026-03-04 21:19:10 +08:00
tisonhuang
c6c7f128a9 feat(sns): limit right contacts to covered users 2026-03-04 21:19:10 +08:00
tisonhuang
36ec12fd0f fix(export): ignore invalid avatar in session detail fast 2026-03-04 21:19:10 +08:00
tisonhuang
e9fd751578 feat(export): show session avatar in detail header 2026-03-04 21:19:10 +08:00
tisonhuang
21a97b8871 feat(sns): cache page data and show count loading state 2026-03-04 21:19:10 +08:00
tisonhuang
b8ede4cfd0 fix(export): use solid background for detail drawer 2026-03-04 21:19:10 +08:00
tisonhuang
f47eba5764 fix(export): avoid overlap with window close controls 2026-03-04 21:19:10 +08:00
tisonhuang
1347136b54 feat(export): use window-level detail drawer overlay 2026-03-04 21:19:10 +08:00
tisonhuang
89f0758fbb fix(sns): keep header area always visible 2026-03-04 21:19:10 +08:00
tisonhuang
b5507b9f5d feat(export): add session detail sidebar entry 2026-03-04 21:19:10 +08:00
tisonhuang
204baa52ab feat(sns): show per-contact post counts in filter panel 2026-03-04 21:19:10 +08:00
tisonhuang
bc739dc4a0 style(sns): keep header and actions sticky 2026-03-04 21:19:10 +08:00
tisonhuang
64616b9136 feat(sns): add header overview stats line 2026-03-04 21:19:10 +08:00
tisonhuang
983783ea95 feat(export): add per-contact single export action button 2026-03-04 21:19:10 +08:00
tisonhuang
1414a4a9cf fix(export): style mirrored contacts list in export panel 2026-03-04 21:19:10 +08:00
tisonhuang
af7639aa73 feat(export): optimize dialog defaults and option cards 2026-03-04 21:19:10 +08:00
tisonhuang
dabc6a2d0a fix(export): align avatar loading pipeline with contacts 2026-03-04 21:19:10 +08:00
tisonhuang
d1ef159e87 fix(export): stabilize contact cache fallback and batched avatar enrich 2026-03-04 21:19:10 +08:00
tisonhuang
cc5c323ccb fix(export): fallback contacts cache scope and hydrate list from cache first 2026-03-04 21:19:10 +08:00
tisonhuang
d18a871429 fix(export): restore dialog scroll and adaptive format grid 2026-03-04 21:19:10 +08:00
tisonhuang
0a1f55f6a6 feat(export): reuse contacts cache for session names and avatars 2026-03-04 21:19:10 +08:00
tisonhuang
faeda030e9 feat(contacts): persist avatar cache with incremental refresh 2026-03-04 21:19:10 +08:00
tisonhuang
b3700c3a4c refactor(export): remove session stats columns and background counting 2026-03-04 21:19:10 +08:00
tisonhuang
01a221831f feat(export): move task center into top control row 2026-03-04 21:19:10 +08:00
tisonhuang
9cb41e01e2 fix(contacts): persist list cache and add load timeout diagnostics 2026-03-04 21:19:10 +08:00
tisonhuang
abdb4f62de fix(export): pause hidden export background loading to unblock contacts 2026-03-04 21:19:10 +08:00
tisonhuang
da7d354436 feat(counts): unify contacts and export tab counters 2026-03-04 21:19:10 +08:00
tisonhuang
794a306f89 perf(contacts): speed up directory loading and smooth list rendering 2026-03-04 21:19:10 +08:00
tisonhuang
ac61ee1833 perf(chat): add local session list and preview cache hydration 2026-03-04 21:19:10 +08:00
tisonhuang
a87d419868 fix(chat): collapse detail panel when switching sessions 2026-03-04 21:19:10 +08:00
tisonhuang
abbb7a0cb1 feat(chat): show export-table metrics in session detail sidebar 2026-03-04 21:19:10 +08:00
tisonhuang
a5ae22d2a5 perf(chat): split session detail into fast and extra loading 2026-03-04 21:19:10 +08:00
tisonhuang
22b6a07749 feat(chat): smooth loading with progressive session hydration 2026-03-04 21:19:10 +08:00
tisonhuang
dbdb2e2959 perf(export): prioritize active tab counts and avoid full-list warmup 2026-03-04 21:16:40 +08:00
tisonhuang
5147b3f0e4 perf(export): batch message count retrieval for large session lists 2026-03-04 21:16:40 +08:00
tisonhuang
a8eb0057e3 perf(export): keep page alive across route switches 2026-03-04 21:16:40 +08:00
tisonhuang
7604ff2ae4 perf(export): cache counts and speed sns/session stats 2026-03-04 21:16:40 +08:00
tisonhuang
bf9b5ba593 perf(export): prioritize totals and keep table visible 2026-03-04 21:16:40 +08:00
tisonhuang
d12c111684 perf(export): virtualize session table and prioritize metrics loading 2026-03-04 21:16:39 +08:00
tisonhuang
dffd3c9138 fix(export): batch session stats and avoid stale empty cache 2026-03-04 21:16:39 +08:00
tisonhuang
c34f7af6de chore(export): shorten card exported labels 2026-03-04 21:16:39 +08:00
tisonhuang
22c7048ef6 fix(sidebar): prefer valid nickname over wxid 2026-03-04 21:16:39 +08:00
tisonhuang
96aa9d0813 feat(export): adjust path actions and compact sns card 2026-03-04 21:16:39 +08:00
tisonhuang
d99c0ff8b2 perf(export): make write-layout dropdown instant 2026-03-04 21:16:39 +08:00
tisonhuang
c6e8bde078 feat(export): prioritize tab counts via lightweight api 2026-03-04 21:16:39 +08:00
tisonhuang
adff7b9e1e feat(export): refine task center and loading interactions 2026-03-04 21:16:39 +08:00
tisonhuang
b62c18fd84 perf(export): phase-load sessions and add strong skeleton states 2026-03-04 21:16:39 +08:00
tisonhuang
de7cbdf494 perf(sidebar): show cached user profile before async refresh 2026-03-04 21:16:39 +08:00
tisonhuang
0444ca143e fix(export): correct profile name, sns stats, avatars and sorting 2026-03-04 21:16:39 +08:00
tisonhuang
596baad296 feat(export): add sns stats card and conversation tab updates 2026-03-04 21:16:39 +08:00
tisonhuang
e686bb6247 feat(export): add batch session stats api for export board 2026-03-04 21:16:39 +08:00
tisonhuang
06d6f15e38 feat(export): redesign export board workflow 2026-03-04 21:16:39 +08:00
xuncha
d3adae42fe Merge pull request #352 from xunchahaha:main
同步ui
2026-03-02 22:23:33 +08:00
xuncha
39b38119c1 同步ui 2026-03-02 22:22:56 +08:00
xuncha
eace3e9467 查看消息内容 2026-03-02 21:36:44 +08:00
cc
366da8d38e 修复内存扫描问题 2026-03-02 20:30:38 +08:00
xuncha
a965890916 Merge pull request #349 from hicccc77/dev
Dev
2026-03-02 16:45:32 +08:00
xuncha
b07bbd68d7 Merge pull request #348 from xunchahaha:main
Main
2026-03-02 16:45:12 +08:00
xuncha
3d4a79aac6 修复图片解密 修复密钥获取 2026-03-02 16:44:09 +08:00
cc
e30c4cc644 Merge pull request #341 from hicccc77/dev
更新文档
2026-02-28 23:22:15 +08:00
cc
d317be3ad3 更新文档 2026-02-28 23:21:27 +08:00
cc
1b078bd2fd Merge pull request #340 from hicccc77/dev
Dev
2026-02-28 23:17:43 +08:00
cc
1d84ed1614 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-02-28 23:17:16 +08:00
cc
114476d74c 更新文档 2026-02-28 23:16:42 +08:00
106 changed files with 36359 additions and 5079 deletions

View File

@@ -8,20 +8,75 @@ on:
permissions:
contents: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
release-mac-arm64:
runs-on: macos-14
steps:
- name: Check out git repository
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Install Node.js
uses: actions/setup-node@v5
with:
node-version: 24
cache: "npm"
- name: Install Dependencies
run: npm ci
- name: Sync version with tag
shell: bash
run: |
VERSION=${GITHUB_REF_NAME#v}
echo "Syncing package.json version to $VERSION"
npm version $VERSION --no-git-tag-version --allow-same-version
- name: Build Frontend & Type Check
run: |
npx tsc
npx vite build
- name: Package and Publish macOS arm64 (unsigned DMG)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_IDENTITY_AUTO_DISCOVERY: "false"
run: |
npx electron-builder --mac dmg --arm64 --publish always
- name: Update Release Notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
cat <<EOF > release_notes.md
## 更新日志
修复了一些已知问题
## 查看更多日志/获取最新动态
[点击加入 Telegram 频道](https://t.me/weflow_cc)
EOF
gh release edit "$GITHUB_REF_NAME" --notes-file release_notes.md
release:
runs-on: windows-latest
steps:
- name: Check out git repository
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Install Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: 22.12
node-version: 24
cache: 'npm'
- name: Install Dependencies

7
.gitignore vendored
View File

@@ -57,11 +57,14 @@ Thumbs.db
wcdb/
xkey/
server/
*info
概述.md
chatlab-format.md
*.bak
AGENTS.md
.claude/
CLAUDE.md
.agents/
resources/wx_send
resources/wx_send
概述.md
pnpm-lock.yaml

View File

@@ -41,7 +41,28 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
- 年度报告与可视化概览
- 导出聊天记录为 HTML 等格式
- HTTP API 接口(供开发者集成)
- 查看完整能力清单:[详细功能](#详细功能清单)
## 快速开始
若你只想使用成品版本,可前往 Release 下载并安装。
## 详细功能清单
当前版本已支持以下能力:
| 功能模块 | 说明 |
|---------|------|
| **聊天** | 解密聊天中的图片、视频、实况(仅支持谷歌协议拍摄的实况);支持**修改**、删除**本地**消息;实时刷新最新消息,无需生成解密中间数据库 |
| **实时弹窗通知** | 新消息到达时提供桌面弹窗提醒,便于及时查看重要会话,提供黑白名单功能 |
| **私聊分析** | 统计好友间消息数量;分析消息类型与发送比例;查看消息时段分布等 |
| **群聊分析** | 查看群成员详细信息;分析群内发言排行、活跃时段和媒体内容 |
| **年度报告** | 生成按年统计的年度报告,或跨年度的长期历史报告 |
| **双人报告** | 选择指定好友,基于双方聊天记录生成专属分析报告 |
| **消息导出** | 将微信聊天记录导出为多种格式JSON、HTML、TXT、Excel、CSV、PGSQL、ChatLab专属格式等 |
| **朋友圈** | 解密朋友圈图片、视频、实况;导出朋友圈内容;拦截朋友圈的删除与隐藏操作;突破时间访问限制 |
| **联系人** | 导出微信好友、群聊、公众号信息;尝试找回曾经的好友(功能尚不完善) |
| **HTTP API 映射** | 将本地消息能力映射为 HTTP API便于对接外部系统、自动化脚本与二次开发 |
## HTTP API
@@ -55,13 +76,9 @@ WeFlow 提供本地 HTTP API 服务,支持通过接口查询消息数据,可
- **访问地址**`http://127.0.0.1:5031`
- **支持格式**:原始 JSON 或 [ChatLab](https://chatlab.fun/) 标准格式
📖 完整接口文档:[点击查看](docs/HTTP-API.md)
完整接口文档:[点击查看](docs/HTTP-API.md)
## 快速开始
若你只想使用成品版本,可前往 Release 下载并安装。
## 面向开发者
如果你想从源码构建或为项目贡献代码,请遵循以下步骤:

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -70,13 +70,29 @@ contextBridge.exposeInMainWorld('electronAPI', {
log: {
getPath: () => ipcRenderer.invoke('log:getPath'),
read: () => ipcRenderer.invoke('log:read'),
clear: () => ipcRenderer.invoke('log:clear'),
debug: (data: any) => ipcRenderer.send('log:debug', data)
},
diagnostics: {
getExportCardLogs: (options?: { limit?: number }) =>
ipcRenderer.invoke('diagnostics:getExportCardLogs', options),
clearExportCardLogs: () =>
ipcRenderer.invoke('diagnostics:clearExportCardLogs'),
exportExportCardLogs: (payload: { filePath: string; frontendLogs?: unknown[] }) =>
ipcRenderer.invoke('diagnostics:exportExportCardLogs', payload)
},
// 窗口控制
window: {
minimize: () => ipcRenderer.send('window:minimize'),
maximize: () => ipcRenderer.send('window:maximize'),
isMaximized: () => ipcRenderer.invoke('window:isMaximized'),
onMaximizeStateChanged: (callback: (isMaximized: boolean) => void) => {
const listener = (_: unknown, isMaximized: boolean) => callback(isMaximized)
ipcRenderer.on('window:maximizeStateChanged', listener)
return () => ipcRenderer.removeListener('window:maximizeStateChanged', listener)
},
close: () => ipcRenderer.send('window:close'),
openAgreementWindow: () => ipcRenderer.invoke('window:openAgreementWindow'),
completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'),
@@ -89,7 +105,17 @@ contextBridge.exposeInMainWorld('electronAPI', {
openImageViewerWindow: (imagePath: string, liveVideoPath?: string) =>
ipcRenderer.invoke('window:openImageViewerWindow', imagePath, liveVideoPath),
openChatHistoryWindow: (sessionId: string, messageId: number) =>
ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId)
ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId),
openSessionChatWindow: (
sessionId: string,
options?: {
source?: 'chat' | 'export'
initialDisplayName?: string
initialAvatarUrl?: string
initialContactType?: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
}
) =>
ipcRenderer.invoke('window:openSessionChatWindow', sessionId, options)
},
// 数据库路径
@@ -113,7 +139,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
// 密钥获取
key: {
autoGetDbKey: () => ipcRenderer.invoke('key:autoGetDbKey'),
autoGetImageKey: (manualDir?: string) => ipcRenderer.invoke('key:autoGetImageKey', manualDir),
autoGetImageKey: (manualDir?: string, wxid?: string) => ipcRenderer.invoke('key:autoGetImageKey', manualDir, wxid),
scanImageKeyFromMemory: (userDir: string) => ipcRenderer.invoke('key:scanImageKeyFromMemory', userDir),
onDbKeyStatus: (callback: (payload: { message: string; level: number }) => void) => {
ipcRenderer.on('key:dbKeyStatus', (_, payload) => callback(payload))
return () => ipcRenderer.removeAllListeners('key:dbKeyStatus')
@@ -129,8 +156,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
chat: {
connect: () => ipcRenderer.invoke('chat:connect'),
getSessions: () => ipcRenderer.invoke('chat:getSessions'),
enrichSessionsContactInfo: (usernames: string[]) =>
ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames),
getSessionStatuses: (usernames: string[]) => ipcRenderer.invoke('chat:getSessionStatuses', usernames),
getExportTabCounts: () => ipcRenderer.invoke('chat:getExportTabCounts'),
getContactTypeCounts: () => ipcRenderer.invoke('chat:getContactTypeCounts'),
getSessionMessageCounts: (sessionIds: string[]) => ipcRenderer.invoke('chat:getSessionMessageCounts', sessionIds),
enrichSessionsContactInfo: (
usernames: string[],
options?: { skipDisplayName?: boolean; onlyMissingAvatar?: boolean }
) => ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames, options),
getMessages: (sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) =>
ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit, startTime, endTime, ascending),
getLatestMessages: (sessionId: string, limit?: number) =>
@@ -148,14 +181,31 @@ contextBridge.exposeInMainWorld('electronAPI', {
getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'),
downloadEmoji: (cdnUrl: string, md5?: string) => ipcRenderer.invoke('chat:downloadEmoji', cdnUrl, md5),
getCachedMessages: (sessionId: string) => ipcRenderer.invoke('chat:getCachedMessages', sessionId),
clearCurrentAccountData: (options: { clearCache?: boolean; clearExports?: boolean }) =>
ipcRenderer.invoke('chat:clearCurrentAccountData', options),
close: () => ipcRenderer.invoke('chat:close'),
getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId),
getSessionDetailFast: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetailFast', sessionId),
getSessionDetailExtra: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetailExtra', sessionId),
getExportSessionStats: (
sessionIds: string[],
options?: {
includeRelations?: boolean
forceRefresh?: boolean
allowStaleCache?: boolean
preferAccurateSpecialTypes?: boolean
cacheOnly?: boolean
}
) => ipcRenderer.invoke('chat:getExportSessionStats', sessionIds, options),
getGroupMyMessageCountHint: (chatroomId: string) =>
ipcRenderer.invoke('chat:getGroupMyMessageCountHint', chatroomId),
getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId),
getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) =>
ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime, serverId),
getAllVoiceMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllVoiceMessages', sessionId),
getAllImageMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllImageMessages', sessionId),
getMessageDates: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDates', sessionId),
getMessageDateCounts: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDateCounts', sessionId),
resolveVoiceCache: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:resolveVoiceCache', sessionId, msgId),
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId, createTime),
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => {
@@ -226,6 +276,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
groupAnalytics: {
getGroupChats: () => ipcRenderer.invoke('groupAnalytics:getGroupChats'),
getGroupMembers: (chatroomId: string) => ipcRenderer.invoke('groupAnalytics:getGroupMembers', chatroomId),
getGroupMembersPanelData: (
chatroomId: string,
options?: { forceRefresh?: boolean; includeMessageCounts?: boolean }
) => ipcRenderer.invoke('groupAnalytics:getGroupMembersPanelData', chatroomId, options),
getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMessageRanking', chatroomId, limit, startTime, endTime),
getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime),
getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime),
@@ -237,9 +291,29 @@ contextBridge.exposeInMainWorld('electronAPI', {
// 年度报告
annualReport: {
getAvailableYears: () => ipcRenderer.invoke('annualReport:getAvailableYears'),
startAvailableYearsLoad: () => ipcRenderer.invoke('annualReport:startAvailableYearsLoad'),
cancelAvailableYearsLoad: (taskId: string) => ipcRenderer.invoke('annualReport:cancelAvailableYearsLoad', taskId),
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),
onAvailableYearsProgress: (callback: (payload: {
taskId: string
years?: number[]
done: boolean
error?: string
canceled?: boolean
strategy?: 'cache' | 'native' | 'hybrid'
phase?: 'cache' | 'native' | 'scan' | 'done'
statusText?: string
nativeElapsedMs?: number
scanElapsedMs?: number
totalElapsedMs?: number
switched?: boolean
nativeTimedOut?: boolean
}) => void) => {
ipcRenderer.on('annualReport:availableYearsProgress', (_, payload) => callback(payload))
return () => ipcRenderer.removeAllListeners('annualReport:availableYearsProgress')
},
onProgress: (callback: (payload: { status: string; progress: number }) => void) => {
ipcRenderer.on('annualReport:progress', (_, payload) => callback(payload))
return () => ipcRenderer.removeAllListeners('annualReport:progress')
@@ -264,7 +338,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options),
exportContacts: (outputDir: string, options: any) =>
ipcRenderer.invoke('export:exportContacts', outputDir, options),
onProgress: (callback: (payload: { current: number; total: number; currentSession: string; phase: string }) => void) => {
onProgress: (callback: (payload: { current: number; total: number; currentSession: string; currentSessionId?: string; phase: string }) => void) => {
ipcRenderer.on('export:progress', (_, payload) => callback(payload))
return () => ipcRenderer.removeAllListeners('export:progress')
}
@@ -286,6 +360,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) =>
ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime),
getSnsUsernames: () => ipcRenderer.invoke('sns:getSnsUsernames'),
getUserPostCounts: () => ipcRenderer.invoke('sns:getUserPostCounts'),
getExportStatsFast: () => ipcRenderer.invoke('sns:getExportStatsFast'),
getExportStats: () => ipcRenderer.invoke('sns:getExportStats'),
getUserPostStats: (username: string) => ipcRenderer.invoke('sns:getUserPostStats', username),
debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url),
proxyImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:proxyImage', payload),
downloadImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:downloadImage', payload),
@@ -302,6 +380,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
downloadEmoji: (params: { url: string; encryptUrl?: string; aesKey?: string }) => ipcRenderer.invoke('sns:downloadEmoji', params)
},
// 数据收集
cloud: {
init: () => ipcRenderer.invoke('cloud:init'),
recordPage: (pageName: string) => ipcRenderer.invoke('cloud:recordPage', pageName),
getLogs: () => ipcRenderer.invoke('cloud:getLogs')
},
// HTTP API 服务
http: {
start: (port?: number) => ipcRenderer.invoke('http:start', port),

View File

@@ -85,7 +85,34 @@ export interface AnnualReportData {
} | null
}
export interface AvailableYearsLoadProgress {
years: number[]
strategy: 'cache' | 'native' | 'hybrid'
phase: 'cache' | 'native' | 'scan'
statusText: string
nativeElapsedMs: number
scanElapsedMs: number
totalElapsedMs: number
switched?: boolean
nativeTimedOut?: boolean
}
interface AvailableYearsLoadMeta {
strategy: 'cache' | 'native' | 'hybrid'
nativeElapsedMs: number
scanElapsedMs: number
totalElapsedMs: number
switched: boolean
nativeTimedOut: boolean
statusText: string
}
class AnnualReportService {
private readonly availableYearsCacheTtlMs = 10 * 60 * 1000
private readonly availableYearsScanConcurrency = 4
private readonly availableYearsColumnCache = new Map<string, string>()
private readonly availableYearsCache = new Map<string, { years: number[]; updatedAt: number }>()
constructor() {
}
@@ -181,6 +208,234 @@ class AnnualReportService {
}
}
private quoteSqlIdentifier(identifier: string): string {
return `"${String(identifier || '').replace(/"/g, '""')}"`
}
private toUnixTimestamp(value: any): number {
const n = Number(value)
if (!Number.isFinite(n) || n <= 0) return 0
// 兼容毫秒级时间戳
const seconds = n > 1e12 ? Math.floor(n / 1000) : Math.floor(n)
return seconds > 0 ? seconds : 0
}
private addYearsFromRange(years: Set<number>, firstTs: number, lastTs: number): boolean {
let changed = false
const currentYear = new Date().getFullYear()
const minTs = firstTs > 0 ? firstTs : lastTs
const maxTs = lastTs > 0 ? lastTs : firstTs
if (minTs <= 0 || maxTs <= 0) return changed
const minYear = new Date(minTs * 1000).getFullYear()
const maxYear = new Date(maxTs * 1000).getFullYear()
for (let y = minYear; y <= maxYear; y++) {
if (y >= 2010 && y <= currentYear && !years.has(y)) {
years.add(y)
changed = true
}
}
return changed
}
private normalizeAvailableYears(years: Iterable<number>): number[] {
return Array.from(new Set(Array.from(years)))
.filter((y) => Number.isFinite(y))
.map((y) => Math.floor(y))
.sort((a, b) => b - a)
}
private async forEachWithConcurrency<T>(
items: T[],
concurrency: number,
handler: (item: T, index: number) => Promise<void>,
shouldStop?: () => boolean
): Promise<void> {
if (!items.length) return
const workerCount = Math.max(1, Math.min(concurrency, items.length))
let nextIndex = 0
const workers: Promise<void>[] = []
for (let i = 0; i < workerCount; i++) {
workers.push((async () => {
while (true) {
if (shouldStop?.()) break
const current = nextIndex
nextIndex += 1
if (current >= items.length) break
await handler(items[current], current)
}
})())
}
await Promise.all(workers)
}
private async detectTimeColumn(dbPath: string, tableName: string): Promise<string | null> {
const cacheKey = `${dbPath}\u0001${tableName}`
if (this.availableYearsColumnCache.has(cacheKey)) {
const cached = this.availableYearsColumnCache.get(cacheKey) || ''
return cached || null
}
const result = await wcdbService.execQuery('message', dbPath, `PRAGMA table_info(${this.quoteSqlIdentifier(tableName)})`)
if (!result.success || !Array.isArray(result.rows) || result.rows.length === 0) {
this.availableYearsColumnCache.set(cacheKey, '')
return null
}
const candidates = ['create_time', 'createtime', 'msg_create_time', 'msg_time', 'msgtime', 'time']
const columns = new Set<string>()
for (const row of result.rows as Record<string, any>[]) {
const name = String(row.name || row.column_name || row.columnName || '').trim().toLowerCase()
if (name) columns.add(name)
}
for (const candidate of candidates) {
if (columns.has(candidate)) {
this.availableYearsColumnCache.set(cacheKey, candidate)
return candidate
}
}
this.availableYearsColumnCache.set(cacheKey, '')
return null
}
private async getTableTimeRange(dbPath: string, tableName: string): Promise<{ first: number; last: number } | null> {
const cacheKey = `${dbPath}\u0001${tableName}`
const cachedColumn = this.availableYearsColumnCache.get(cacheKey)
const initialColumn = cachedColumn && cachedColumn.length > 0 ? cachedColumn : 'create_time'
const tried = new Set<string>()
const queryByColumn = async (column: string): Promise<{ first: number; last: number } | null> => {
const sql = `SELECT MIN(${this.quoteSqlIdentifier(column)}) AS first_ts, MAX(${this.quoteSqlIdentifier(column)}) AS last_ts FROM ${this.quoteSqlIdentifier(tableName)}`
const result = await wcdbService.execQuery('message', dbPath, sql)
if (!result.success || !Array.isArray(result.rows) || result.rows.length === 0) return null
const row = result.rows[0] as Record<string, any>
const first = this.toUnixTimestamp(row.first_ts ?? row.firstTs ?? row.min_ts ?? row.minTs)
const last = this.toUnixTimestamp(row.last_ts ?? row.lastTs ?? row.max_ts ?? row.maxTs)
return { first, last }
}
tried.add(initialColumn)
const quick = await queryByColumn(initialColumn)
if (quick) {
if (!cachedColumn) this.availableYearsColumnCache.set(cacheKey, initialColumn)
return quick
}
const detectedColumn = await this.detectTimeColumn(dbPath, tableName)
if (!detectedColumn || tried.has(detectedColumn)) {
return null
}
return queryByColumn(detectedColumn)
}
private async getAvailableYearsByTableScan(
sessionIds: string[],
options?: { onProgress?: (years: number[]) => void; shouldCancel?: () => boolean }
): Promise<number[]> {
const years = new Set<number>()
let lastEmittedSize = 0
const emitIfChanged = (force = false) => {
if (!options?.onProgress) return
const next = this.normalizeAvailableYears(years)
if (!force && next.length === lastEmittedSize) return
options.onProgress(next)
lastEmittedSize = next.length
}
const shouldCancel = () => options?.shouldCancel?.() === true
await this.forEachWithConcurrency(sessionIds, this.availableYearsScanConcurrency, async (sessionId) => {
if (shouldCancel()) return
const tableStats = await wcdbService.getMessageTableStats(sessionId)
if (!tableStats.success || !Array.isArray(tableStats.tables) || tableStats.tables.length === 0) {
return
}
for (const table of tableStats.tables as Record<string, any>[]) {
if (shouldCancel()) return
const tableName = String(table.table_name || table.name || '').trim()
const dbPath = String(table.db_path || table.dbPath || '').trim()
if (!tableName || !dbPath) continue
const range = await this.getTableTimeRange(dbPath, tableName)
if (!range) continue
const changed = this.addYearsFromRange(years, range.first, range.last)
if (changed) emitIfChanged()
}
}, shouldCancel)
emitIfChanged(true)
return this.normalizeAvailableYears(years)
}
private async getAvailableYearsByEdgeScan(
sessionIds: string[],
options?: { onProgress?: (years: number[]) => void; shouldCancel?: () => boolean }
): Promise<number[]> {
const years = new Set<number>()
let lastEmittedSize = 0
const shouldCancel = () => options?.shouldCancel?.() === true
const emitIfChanged = (force = false) => {
if (!options?.onProgress) return
const next = this.normalizeAvailableYears(years)
if (!force && next.length === lastEmittedSize) return
options.onProgress(next)
lastEmittedSize = next.length
}
for (const sessionId of sessionIds) {
if (shouldCancel()) break
const first = await this.getEdgeMessageTime(sessionId, true)
const last = await this.getEdgeMessageTime(sessionId, false)
const changed = this.addYearsFromRange(years, first || 0, last || 0)
if (changed) emitIfChanged()
}
emitIfChanged(true)
return this.normalizeAvailableYears(years)
}
private buildAvailableYearsCacheKey(dbPath: string, cleanedWxid: string): string {
return `${dbPath}\u0001${cleanedWxid}`
}
private getCachedAvailableYears(cacheKey: string): number[] | null {
const cached = this.availableYearsCache.get(cacheKey)
if (!cached) return null
if (Date.now() - cached.updatedAt > this.availableYearsCacheTtlMs) {
this.availableYearsCache.delete(cacheKey)
return null
}
return [...cached.years]
}
private setCachedAvailableYears(cacheKey: string, years: number[]): void {
const normalized = this.normalizeAvailableYears(years)
this.availableYearsCache.set(cacheKey, {
years: normalized,
updatedAt: Date.now()
})
if (this.availableYearsCache.size > 8) {
let oldestKey = ''
let oldestTime = Number.POSITIVE_INFINITY
for (const [key, val] of this.availableYearsCache) {
if (val.updatedAt < oldestTime) {
oldestTime = val.updatedAt
oldestKey = key
}
}
if (oldestKey) this.availableYearsCache.delete(oldestKey)
}
}
private decodeMessageContent(messageContent: any, compressContent: any): string {
let content = this.decodeMaybeCompressed(compressContent)
if (!content || content.length === 0) {
@@ -359,38 +614,226 @@ class AnnualReportService {
return { sessionId: bestSessionId, days: bestDays, start: bestStart, end: bestEnd }
}
async getAvailableYears(params: { dbPath: string; decryptKey: string; wxid: string }): Promise<{ success: boolean; data?: number[]; error?: string }> {
async getAvailableYears(params: {
dbPath: string
decryptKey: string
wxid: string
onProgress?: (payload: AvailableYearsLoadProgress) => void
shouldCancel?: () => boolean
nativeTimeoutMs?: number
}): Promise<{ success: boolean; data?: number[]; error?: string; meta?: AvailableYearsLoadMeta }> {
try {
const isCancelled = () => params.shouldCancel?.() === true
const totalStartedAt = Date.now()
let nativeElapsedMs = 0
let scanElapsedMs = 0
let switched = false
let nativeTimedOut = false
let latestYears: number[] = []
const emitProgress = (payload: {
years?: number[]
strategy: 'cache' | 'native' | 'hybrid'
phase: 'cache' | 'native' | 'scan'
statusText: string
switched?: boolean
nativeTimedOut?: boolean
}) => {
if (!params.onProgress) return
if (Array.isArray(payload.years)) latestYears = payload.years
params.onProgress({
years: latestYears,
strategy: payload.strategy,
phase: payload.phase,
statusText: payload.statusText,
nativeElapsedMs,
scanElapsedMs,
totalElapsedMs: Date.now() - totalStartedAt,
switched: payload.switched ?? switched,
nativeTimedOut: payload.nativeTimedOut ?? nativeTimedOut
})
}
const buildMeta = (
strategy: 'cache' | 'native' | 'hybrid',
statusText: string
): AvailableYearsLoadMeta => ({
strategy,
nativeElapsedMs,
scanElapsedMs,
totalElapsedMs: Date.now() - totalStartedAt,
switched,
nativeTimedOut,
statusText
})
const conn = await this.ensureConnectedWithConfig(params.dbPath, params.decryptKey, params.wxid)
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
const sessionIds = await this.getPrivateSessions(conn.cleanedWxid)
if (sessionIds.length === 0) {
return { success: false, error: '未找到消息会话' }
}
const fastYears = await wcdbService.getAvailableYears(sessionIds)
if (fastYears.success && fastYears.data) {
return { success: true, data: fastYears.data }
}
const years = new Set<number>()
for (const sessionId of sessionIds) {
const first = await this.getEdgeMessageTime(sessionId, true)
const last = await this.getEdgeMessageTime(sessionId, false)
if (!first && !last) continue
const minYear = new Date((first || last || 0) * 1000).getFullYear()
const maxYear = new Date((last || first || 0) * 1000).getFullYear()
for (let y = minYear; y <= maxYear; y++) {
if (y >= 2010 && y <= new Date().getFullYear()) years.add(y)
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error, meta: buildMeta('hybrid', '连接数据库失败') }
if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') }
const cacheKey = this.buildAvailableYearsCacheKey(params.dbPath, conn.cleanedWxid)
const cached = this.getCachedAvailableYears(cacheKey)
if (cached) {
latestYears = cached
emitProgress({
years: cached,
strategy: 'cache',
phase: 'cache',
statusText: '命中缓存,已快速加载年份数据'
})
return {
success: true,
data: cached,
meta: buildMeta('cache', '命中缓存,已快速加载年份数据')
}
}
const sortedYears = Array.from(years).sort((a, b) => b - a)
return { success: true, data: sortedYears }
const sessionIds = await this.getPrivateSessions(conn.cleanedWxid)
if (sessionIds.length === 0) {
return { success: false, error: '未找到消息会话', meta: buildMeta('hybrid', '未找到消息会话') }
}
if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') }
const nativeTimeoutMs = Math.max(1000, Math.floor(params.nativeTimeoutMs || 5000))
const nativeStartedAt = Date.now()
let nativeTicker: ReturnType<typeof setInterval> | null = null
emitProgress({
strategy: 'native',
phase: 'native',
statusText: '正在使用原生快速模式加载年份...'
})
nativeTicker = setInterval(() => {
nativeElapsedMs = Date.now() - nativeStartedAt
emitProgress({
strategy: 'native',
phase: 'native',
statusText: '正在使用原生快速模式加载年份...'
})
}, 120)
const nativeRace = await Promise.race([
wcdbService.getAvailableYears(sessionIds)
.then((result) => ({ kind: 'result' as const, result }))
.catch((error) => ({ kind: 'error' as const, error: String(error) })),
new Promise<{ kind: 'timeout' }>((resolve) => setTimeout(() => resolve({ kind: 'timeout' }), nativeTimeoutMs))
])
if (nativeTicker) {
clearInterval(nativeTicker)
nativeTicker = null
}
nativeElapsedMs = Math.max(nativeElapsedMs, Date.now() - nativeStartedAt)
if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') }
if (nativeRace.kind === 'result' && nativeRace.result.success && Array.isArray(nativeRace.result.data) && nativeRace.result.data.length > 0) {
const years = this.normalizeAvailableYears(nativeRace.result.data)
latestYears = years
this.setCachedAvailableYears(cacheKey, years)
emitProgress({
years,
strategy: 'native',
phase: 'native',
statusText: '原生快速模式加载完成'
})
return {
success: true,
data: years,
meta: buildMeta('native', '原生快速模式加载完成')
}
}
switched = true
nativeTimedOut = nativeRace.kind === 'timeout'
emitProgress({
strategy: 'hybrid',
phase: 'native',
statusText: nativeTimedOut
? '原生快速模式超时,已自动切换到扫表兼容模式...'
: '原生快速模式不可用,已自动切换到扫表兼容模式...',
switched: true,
nativeTimedOut
})
const scanStartedAt = Date.now()
let scanTicker: ReturnType<typeof setInterval> | null = null
scanTicker = setInterval(() => {
scanElapsedMs = Date.now() - scanStartedAt
emitProgress({
strategy: 'hybrid',
phase: 'scan',
statusText: nativeTimedOut
? '原生已超时,正在使用扫表兼容模式加载年份...'
: '正在使用扫表兼容模式加载年份...',
switched: true,
nativeTimedOut
})
}, 120)
let years = await this.getAvailableYearsByTableScan(sessionIds, {
onProgress: (items) => {
latestYears = items
scanElapsedMs = Date.now() - scanStartedAt
emitProgress({
years: items,
strategy: 'hybrid',
phase: 'scan',
statusText: nativeTimedOut
? '原生已超时,正在使用扫表兼容模式加载年份...'
: '正在使用扫表兼容模式加载年份...',
switched: true,
nativeTimedOut
})
},
shouldCancel: params.shouldCancel
})
if (isCancelled()) {
if (scanTicker) clearInterval(scanTicker)
return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') }
}
if (years.length === 0) {
years = await this.getAvailableYearsByEdgeScan(sessionIds, {
onProgress: (items) => {
latestYears = items
scanElapsedMs = Date.now() - scanStartedAt
emitProgress({
years: items,
strategy: 'hybrid',
phase: 'scan',
statusText: '扫表结果为空,正在执行游标兜底扫描...',
switched: true,
nativeTimedOut
})
},
shouldCancel: params.shouldCancel
})
}
if (scanTicker) {
clearInterval(scanTicker)
scanTicker = null
}
scanElapsedMs = Math.max(scanElapsedMs, Date.now() - scanStartedAt)
if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') }
this.setCachedAvailableYears(cacheKey, years)
latestYears = years
emitProgress({
years,
strategy: 'hybrid',
phase: 'scan',
statusText: '扫表兼容模式加载完成',
switched: true,
nativeTimedOut
})
return {
success: true,
data: years,
meta: buildMeta('hybrid', '扫表兼容模式加载完成')
}
} catch (e) {
return { success: false, error: String(e) }
return { success: false, error: String(e), meta: { strategy: 'hybrid', nativeElapsedMs: 0, scanElapsedMs: 0, totalElapsedMs: 0, switched: false, nativeTimedOut: false, statusText: '加载年度数据失败' } }
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,97 @@
import { app } from 'electron'
import { wcdbService } from './wcdbService'
interface UsageStats {
appVersion: string
platform: string
deviceId: string
timestamp: number
online: boolean
pages: string[]
}
class CloudControlService {
private deviceId: string = ''
private timer: NodeJS.Timeout | null = null
private pages: Set<string> = new Set()
async init() {
this.deviceId = this.getDeviceId()
await wcdbService.cloudInit(300)
await this.reportOnline()
this.timer = setInterval(() => {
this.reportOnline()
}, 300000)
}
private getDeviceId(): string {
const crypto = require('crypto')
const os = require('os')
const machineId = os.hostname() + os.platform() + os.arch()
return crypto.createHash('md5').update(machineId).digest('hex')
}
private async reportOnline() {
const data: UsageStats = {
appVersion: app.getVersion(),
platform: this.getPlatformVersion(),
deviceId: this.deviceId,
timestamp: Date.now(),
online: true,
pages: Array.from(this.pages)
}
await wcdbService.cloudReport(JSON.stringify(data))
this.pages.clear()
}
private getPlatformVersion(): string {
const os = require('os')
const platform = process.platform
if (platform === 'win32') {
const release = os.release()
const parts = release.split('.')
const major = parseInt(parts[0])
const minor = parseInt(parts[1] || '0')
const build = parseInt(parts[2] || '0')
// Windows 11 是 10.0.22000+,且主版本必须是 10.0
if (major === 10 && minor === 0 && build >= 22000) {
return 'Windows 11'
} else if (major === 10) {
return 'Windows 10'
}
return `Windows ${release}`
}
if (platform === 'darwin') {
// `os.release()` returns Darwin kernel version (e.g. 25.3.0),
// while cloud reporting expects the macOS product version (e.g. 26.3).
const macVersion = typeof process.getSystemVersion === 'function' ? process.getSystemVersion() : os.release()
return `macOS ${macVersion}`
}
return platform
}
recordPage(pageName: string) {
this.pages.add(pageName)
}
stop() {
if (this.timer) {
clearInterval(this.timer)
this.timer = null
}
wcdbService.cloudStop()
}
async getLogs() {
return wcdbService.getLogs()
}
}
export const cloudControlService = new CloudControlService()

View File

@@ -105,7 +105,7 @@ export class ConfigService {
whisperDownloadSource: 'tsinghua',
autoTranscribeVoice: false,
transcribeLanguages: ['zh'],
exportDefaultConcurrency: 2,
exportDefaultConcurrency: 4,
analyticsExcludedUsernames: [],
authEnabled: false,
authPassword: '',
@@ -671,4 +671,4 @@ export class ConfigService {
this.unlockedKeys.clear()
this.unlockPassword = null
}
}
}

View File

@@ -16,8 +16,13 @@ export class DbPathService {
const possiblePaths: string[] = []
const home = homedir()
// 微信4.x 数据目录
possiblePaths.push(join(home, 'Documents', 'xwechat_files'))
// macOS 微信路径(固定)
if (process.platform === 'darwin') {
possiblePaths.push(join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'xwechat_files'))
} else {
// Windows 微信4.x 数据目录
possiblePaths.push(join(home, 'Documents', 'xwechat_files'))
}
for (const path of possiblePaths) {
@@ -193,6 +198,9 @@ export class DbPathService {
*/
getDefaultPath(): string {
const home = homedir()
if (process.platform === 'darwin') {
return join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'xwechat_files')
}
return join(home, 'Documents', 'xwechat_files')
}
}

View File

@@ -0,0 +1,354 @@
import { mkdir, writeFile } from 'fs/promises'
import { basename, dirname, extname, join } from 'path'
export type ExportCardDiagSource = 'frontend' | 'main' | 'backend' | 'worker'
export type ExportCardDiagLevel = 'debug' | 'info' | 'warn' | 'error'
export type ExportCardDiagStatus = 'running' | 'done' | 'failed' | 'timeout'
export interface ExportCardDiagLogEntry {
id: string
ts: number
source: ExportCardDiagSource
level: ExportCardDiagLevel
message: string
traceId?: string
stepId?: string
stepName?: string
status?: ExportCardDiagStatus
durationMs?: number
data?: Record<string, unknown>
}
interface ActiveStepState {
key: string
traceId: string
stepId: string
stepName: string
source: ExportCardDiagSource
startedAt: number
lastUpdatedAt: number
message?: string
}
interface StepStartInput {
traceId: string
stepId: string
stepName: string
source: ExportCardDiagSource
level?: ExportCardDiagLevel
message?: string
data?: Record<string, unknown>
}
interface StepEndInput {
traceId: string
stepId: string
stepName: string
source: ExportCardDiagSource
status?: Extract<ExportCardDiagStatus, 'done' | 'failed' | 'timeout'>
level?: ExportCardDiagLevel
message?: string
data?: Record<string, unknown>
durationMs?: number
}
interface LogInput {
ts?: number
source: ExportCardDiagSource
level?: ExportCardDiagLevel
message: string
traceId?: string
stepId?: string
stepName?: string
status?: ExportCardDiagStatus
durationMs?: number
data?: Record<string, unknown>
}
export interface ExportCardDiagSnapshot {
logs: ExportCardDiagLogEntry[]
activeSteps: Array<{
traceId: string
stepId: string
stepName: string
source: ExportCardDiagSource
elapsedMs: number
stallMs: number
startedAt: number
lastUpdatedAt: number
message?: string
}>
summary: {
totalLogs: number
activeStepCount: number
errorCount: number
warnCount: number
timeoutCount: number
lastUpdatedAt: number
}
}
export class ExportCardDiagnosticsService {
private readonly maxLogs = 6000
private logs: ExportCardDiagLogEntry[] = []
private activeSteps = new Map<string, ActiveStepState>()
private seq = 0
private nextId(ts: number): string {
this.seq += 1
return `export-card-diag-${ts}-${this.seq}`
}
private trimLogs() {
if (this.logs.length <= this.maxLogs) return
const drop = this.logs.length - this.maxLogs
this.logs.splice(0, drop)
}
log(input: LogInput): ExportCardDiagLogEntry {
const ts = Number.isFinite(input.ts) ? Math.max(0, Math.floor(input.ts as number)) : Date.now()
const entry: ExportCardDiagLogEntry = {
id: this.nextId(ts),
ts,
source: input.source,
level: input.level || 'info',
message: input.message,
traceId: input.traceId,
stepId: input.stepId,
stepName: input.stepName,
status: input.status,
durationMs: Number.isFinite(input.durationMs) ? Math.max(0, Math.floor(input.durationMs as number)) : undefined,
data: input.data
}
this.logs.push(entry)
this.trimLogs()
if (entry.traceId && entry.stepId && entry.stepName) {
const key = `${entry.traceId}::${entry.stepId}`
if (entry.status === 'running') {
const previous = this.activeSteps.get(key)
this.activeSteps.set(key, {
key,
traceId: entry.traceId,
stepId: entry.stepId,
stepName: entry.stepName,
source: entry.source,
startedAt: previous?.startedAt || entry.ts,
lastUpdatedAt: entry.ts,
message: entry.message
})
} else if (entry.status === 'done' || entry.status === 'failed' || entry.status === 'timeout') {
this.activeSteps.delete(key)
}
}
return entry
}
stepStart(input: StepStartInput): ExportCardDiagLogEntry {
return this.log({
source: input.source,
level: input.level || 'info',
message: input.message || `${input.stepName} 开始`,
traceId: input.traceId,
stepId: input.stepId,
stepName: input.stepName,
status: 'running',
data: input.data
})
}
stepEnd(input: StepEndInput): ExportCardDiagLogEntry {
return this.log({
source: input.source,
level: input.level || (input.status === 'done' ? 'info' : 'warn'),
message: input.message || `${input.stepName} ${input.status === 'done' ? '完成' : '结束'}`,
traceId: input.traceId,
stepId: input.stepId,
stepName: input.stepName,
status: input.status || 'done',
durationMs: input.durationMs,
data: input.data
})
}
clear() {
this.logs = []
this.activeSteps.clear()
}
snapshot(limit = 1200): ExportCardDiagSnapshot {
const capped = Number.isFinite(limit) ? Math.max(100, Math.min(5000, Math.floor(limit))) : 1200
const logs = this.logs.slice(-capped)
const now = Date.now()
const activeSteps = Array.from(this.activeSteps.values())
.map(step => ({
traceId: step.traceId,
stepId: step.stepId,
stepName: step.stepName,
source: step.source,
startedAt: step.startedAt,
lastUpdatedAt: step.lastUpdatedAt,
elapsedMs: Math.max(0, now - step.startedAt),
stallMs: Math.max(0, now - step.lastUpdatedAt),
message: step.message
}))
.sort((a, b) => b.lastUpdatedAt - a.lastUpdatedAt)
let errorCount = 0
let warnCount = 0
let timeoutCount = 0
for (const item of logs) {
if (item.level === 'error') errorCount += 1
if (item.level === 'warn') warnCount += 1
if (item.status === 'timeout') timeoutCount += 1
}
return {
logs,
activeSteps,
summary: {
totalLogs: this.logs.length,
activeStepCount: activeSteps.length,
errorCount,
warnCount,
timeoutCount,
lastUpdatedAt: logs.length > 0 ? logs[logs.length - 1].ts : 0
}
}
}
private normalizeExternalLogs(value: unknown[]): ExportCardDiagLogEntry[] {
const result: ExportCardDiagLogEntry[] = []
for (const item of value) {
if (!item || typeof item !== 'object') continue
const row = item as Record<string, unknown>
const tsRaw = row.ts ?? row.timestamp
const tsNum = Number(tsRaw)
const ts = Number.isFinite(tsNum) && tsNum > 0 ? Math.floor(tsNum) : Date.now()
const sourceRaw = String(row.source || 'frontend')
const source: ExportCardDiagSource = sourceRaw === 'main' || sourceRaw === 'backend' || sourceRaw === 'worker'
? sourceRaw
: 'frontend'
const levelRaw = String(row.level || 'info')
const level: ExportCardDiagLevel = levelRaw === 'debug' || levelRaw === 'warn' || levelRaw === 'error'
? levelRaw
: 'info'
const statusRaw = String(row.status || '')
const status: ExportCardDiagStatus | undefined = statusRaw === 'running' || statusRaw === 'done' || statusRaw === 'failed' || statusRaw === 'timeout'
? statusRaw
: undefined
const durationRaw = Number(row.durationMs)
result.push({
id: String(row.id || this.nextId(ts)),
ts,
source,
level,
message: String(row.message || ''),
traceId: typeof row.traceId === 'string' ? row.traceId : undefined,
stepId: typeof row.stepId === 'string' ? row.stepId : undefined,
stepName: typeof row.stepName === 'string' ? row.stepName : undefined,
status,
durationMs: Number.isFinite(durationRaw) ? Math.max(0, Math.floor(durationRaw)) : undefined,
data: row.data && typeof row.data === 'object' ? row.data as Record<string, unknown> : undefined
})
}
return result
}
private serializeLogEntry(log: ExportCardDiagLogEntry): string {
return JSON.stringify(log)
}
private buildSummaryText(logs: ExportCardDiagLogEntry[], activeSteps: ExportCardDiagSnapshot['activeSteps']): string {
const total = logs.length
let errorCount = 0
let warnCount = 0
let timeoutCount = 0
let frontendCount = 0
let backendCount = 0
let mainCount = 0
let workerCount = 0
for (const item of logs) {
if (item.level === 'error') errorCount += 1
if (item.level === 'warn') warnCount += 1
if (item.status === 'timeout') timeoutCount += 1
if (item.source === 'frontend') frontendCount += 1
if (item.source === 'backend') backendCount += 1
if (item.source === 'main') mainCount += 1
if (item.source === 'worker') workerCount += 1
}
const lines: string[] = []
lines.push('WeFlow 导出卡片诊断摘要')
lines.push(`生成时间: ${new Date().toLocaleString('zh-CN')}`)
lines.push(`日志总数: ${total}`)
lines.push(`来源统计: frontend=${frontendCount}, main=${mainCount}, backend=${backendCount}, worker=${workerCount}`)
lines.push(`级别统计: warn=${warnCount}, error=${errorCount}, timeout=${timeoutCount}`)
lines.push(`当前活跃步骤: ${activeSteps.length}`)
if (activeSteps.length > 0) {
lines.push('')
lines.push('活跃步骤:')
for (const step of activeSteps.slice(0, 12)) {
lines.push(`- [${step.source}] ${step.stepName} trace=${step.traceId} elapsed=${step.elapsedMs}ms stall=${step.stallMs}ms`)
}
}
const latestErrors = logs.filter(item => item.level === 'error' || item.status === 'failed' || item.status === 'timeout').slice(-12)
if (latestErrors.length > 0) {
lines.push('')
lines.push('最近异常:')
for (const item of latestErrors) {
lines.push(`- ${new Date(item.ts).toLocaleTimeString('zh-CN')} [${item.source}] ${item.stepName || item.stepId || 'unknown'} ${item.status || item.level} ${item.message}`)
}
}
return lines.join('\n')
}
async exportCombinedLogs(filePath: string, frontendLogs: unknown[] = []): Promise<{
success: boolean
filePath?: string
summaryPath?: string
count?: number
error?: string
}> {
try {
const normalizedFrontend = this.normalizeExternalLogs(Array.isArray(frontendLogs) ? frontendLogs : [])
const merged = [...this.logs, ...normalizedFrontend]
.sort((a, b) => (a.ts - b.ts) || a.id.localeCompare(b.id))
const lines = merged.map(item => this.serializeLogEntry(item)).join('\n')
await mkdir(dirname(filePath), { recursive: true })
await writeFile(filePath, lines ? `${lines}\n` : '', 'utf8')
const ext = extname(filePath)
const baseName = ext ? basename(filePath, ext) : basename(filePath)
const summaryPath = join(dirname(filePath), `${baseName}.txt`)
const snapshot = this.snapshot(1500)
const summaryText = this.buildSummaryText(merged, snapshot.activeSteps)
await writeFile(summaryPath, summaryText, 'utf8')
return {
success: true,
filePath,
summaryPath,
count: merged.length
}
} catch (error) {
return {
success: false,
error: String(error)
}
}
}
}
export const exportCardDiagnosticsService = new ExportCardDiagnosticsService()

View File

@@ -0,0 +1,229 @@
import { join, dirname } from 'path'
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'
import { ConfigService } from './config'
const CACHE_VERSION = 1
const MAX_SCOPE_ENTRIES = 12
const MAX_SESSION_ENTRIES_PER_SCOPE = 6000
export interface ExportContentSessionStatsEntry {
updatedAt: number
hasAny: boolean
hasVoice: boolean
hasImage: boolean
hasVideo: boolean
hasEmoji: boolean
mediaReady: boolean
}
export interface ExportContentScopeStatsEntry {
updatedAt: number
sessions: Record<string, ExportContentSessionStatsEntry>
}
interface ExportContentStatsStore {
version: number
scopes: Record<string, ExportContentScopeStatsEntry>
}
function toNonNegativeInt(value: unknown): number | undefined {
if (typeof value !== 'number' || !Number.isFinite(value)) return undefined
return Math.max(0, Math.floor(value))
}
function toBoolean(value: unknown, fallback = false): boolean {
if (typeof value === 'boolean') return value
return fallback
}
function normalizeSessionStatsEntry(raw: unknown): ExportContentSessionStatsEntry | null {
if (!raw || typeof raw !== 'object') return null
const source = raw as Record<string, unknown>
const updatedAt = toNonNegativeInt(source.updatedAt)
if (updatedAt === undefined) return null
return {
updatedAt,
hasAny: toBoolean(source.hasAny, false),
hasVoice: toBoolean(source.hasVoice, false),
hasImage: toBoolean(source.hasImage, false),
hasVideo: toBoolean(source.hasVideo, false),
hasEmoji: toBoolean(source.hasEmoji, false),
mediaReady: toBoolean(source.mediaReady, false)
}
}
function normalizeScopeStatsEntry(raw: unknown): ExportContentScopeStatsEntry | null {
if (!raw || typeof raw !== 'object') return null
const source = raw as Record<string, unknown>
const updatedAt = toNonNegativeInt(source.updatedAt)
if (updatedAt === undefined) return null
const sessionsRaw = source.sessions
if (!sessionsRaw || typeof sessionsRaw !== 'object') {
return {
updatedAt,
sessions: {}
}
}
const sessions: Record<string, ExportContentSessionStatsEntry> = {}
for (const [sessionId, entryRaw] of Object.entries(sessionsRaw as Record<string, unknown>)) {
const normalized = normalizeSessionStatsEntry(entryRaw)
if (!normalized) continue
sessions[sessionId] = normalized
}
return {
updatedAt,
sessions
}
}
function cloneScope(scope: ExportContentScopeStatsEntry): ExportContentScopeStatsEntry {
return {
updatedAt: scope.updatedAt,
sessions: Object.fromEntries(
Object.entries(scope.sessions).map(([sessionId, entry]) => [sessionId, { ...entry }])
)
}
}
export class ExportContentStatsCacheService {
private readonly cacheFilePath: string
private store: ExportContentStatsStore = {
version: CACHE_VERSION,
scopes: {}
}
constructor(cacheBasePath?: string) {
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
? cacheBasePath
: ConfigService.getInstance().getCacheBasePath()
this.cacheFilePath = join(basePath, 'export-content-stats.json')
this.ensureCacheDir()
this.load()
}
private ensureCacheDir(): void {
const dir = dirname(this.cacheFilePath)
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
}
private load(): void {
if (!existsSync(this.cacheFilePath)) return
try {
const raw = readFileSync(this.cacheFilePath, 'utf8')
const parsed = JSON.parse(raw) as unknown
if (!parsed || typeof parsed !== 'object') {
this.store = { version: CACHE_VERSION, scopes: {} }
return
}
const payload = parsed as Record<string, unknown>
const scopesRaw = payload.scopes
if (!scopesRaw || typeof scopesRaw !== 'object') {
this.store = { version: CACHE_VERSION, scopes: {} }
return
}
const scopes: Record<string, ExportContentScopeStatsEntry> = {}
for (const [scopeKey, scopeRaw] of Object.entries(scopesRaw as Record<string, unknown>)) {
const normalizedScope = normalizeScopeStatsEntry(scopeRaw)
if (!normalizedScope) continue
scopes[scopeKey] = normalizedScope
}
this.store = {
version: CACHE_VERSION,
scopes
}
} catch (error) {
console.error('ExportContentStatsCacheService: 载入缓存失败', error)
this.store = { version: CACHE_VERSION, scopes: {} }
}
}
getScope(scopeKey: string): ExportContentScopeStatsEntry | undefined {
if (!scopeKey) return undefined
const rawScope = this.store.scopes[scopeKey]
if (!rawScope) return undefined
const normalizedScope = normalizeScopeStatsEntry(rawScope)
if (!normalizedScope) {
delete this.store.scopes[scopeKey]
this.persist()
return undefined
}
this.store.scopes[scopeKey] = normalizedScope
return cloneScope(normalizedScope)
}
setScope(scopeKey: string, scope: ExportContentScopeStatsEntry): void {
if (!scopeKey) return
const normalized = normalizeScopeStatsEntry(scope)
if (!normalized) return
this.store.scopes[scopeKey] = normalized
this.trimScope(scopeKey)
this.trimScopes()
this.persist()
}
deleteSession(scopeKey: string, sessionId: string): void {
if (!scopeKey || !sessionId) return
const scope = this.store.scopes[scopeKey]
if (!scope) return
if (!(sessionId in scope.sessions)) return
delete scope.sessions[sessionId]
if (Object.keys(scope.sessions).length === 0) {
delete this.store.scopes[scopeKey]
} else {
scope.updatedAt = Date.now()
}
this.persist()
}
clearScope(scopeKey: string): void {
if (!scopeKey) return
if (!this.store.scopes[scopeKey]) return
delete this.store.scopes[scopeKey]
this.persist()
}
clearAll(): void {
this.store = { version: CACHE_VERSION, scopes: {} }
try {
rmSync(this.cacheFilePath, { force: true })
} catch (error) {
console.error('ExportContentStatsCacheService: 清理缓存失败', error)
}
}
private trimScope(scopeKey: string): void {
const scope = this.store.scopes[scopeKey]
if (!scope) return
const entries = Object.entries(scope.sessions)
if (entries.length <= MAX_SESSION_ENTRIES_PER_SCOPE) return
entries.sort((a, b) => b[1].updatedAt - a[1].updatedAt)
scope.sessions = Object.fromEntries(entries.slice(0, MAX_SESSION_ENTRIES_PER_SCOPE))
}
private trimScopes(): void {
const scopeEntries = Object.entries(this.store.scopes)
if (scopeEntries.length <= MAX_SCOPE_ENTRIES) return
scopeEntries.sort((a, b) => b[1].updatedAt - a[1].updatedAt)
this.store.scopes = Object.fromEntries(scopeEntries.slice(0, MAX_SCOPE_ENTRIES))
}
private persist(): void {
try {
this.ensureCacheDir()
writeFileSync(this.cacheFilePath, JSON.stringify(this.store), 'utf8')
} catch (error) {
console.error('ExportContentStatsCacheService: 持久化缓存失败', error)
}
}
}

View File

@@ -0,0 +1,95 @@
import { app } from 'electron'
import fs from 'fs'
import path from 'path'
export interface ExportRecord {
exportTime: number
format: string
messageCount: number
sourceLatestMessageTimestamp?: number
outputPath?: string
}
type RecordStore = Record<string, ExportRecord[]>
class ExportRecordService {
private filePath: string | null = null
private loaded = false
private store: RecordStore = {}
private resolveFilePath(): string {
if (this.filePath) return this.filePath
const userDataPath = app.getPath('userData')
fs.mkdirSync(userDataPath, { recursive: true })
this.filePath = path.join(userDataPath, 'weflow-export-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 (parsed && typeof parsed === 'object') {
this.store = parsed as RecordStore
}
} catch {
this.store = {}
}
}
private persist(): void {
try {
const filePath = this.resolveFilePath()
fs.writeFileSync(filePath, JSON.stringify(this.store), 'utf-8')
} catch {
// ignore persist errors to avoid blocking export flow
}
}
getLatestRecord(sessionId: string, format: string): ExportRecord | null {
this.ensureLoaded()
const records = this.store[sessionId]
if (!records || records.length === 0) return null
for (let i = records.length - 1; i >= 0; i--) {
const record = records[i]
if (record && record.format === format) return record
}
return null
}
saveRecord(
sessionId: string,
format: string,
messageCount: number,
extra?: {
sourceLatestMessageTimestamp?: number
outputPath?: string
}
): void {
this.ensureLoaded()
const normalizedSessionId = String(sessionId || '').trim()
if (!normalizedSessionId) return
if (!this.store[normalizedSessionId]) {
this.store[normalizedSessionId] = []
}
const list = this.store[normalizedSessionId]
list.push({
exportTime: Date.now(),
format,
messageCount,
sourceLatestMessageTimestamp: extra?.sourceLatestMessageTimestamp,
outputPath: extra?.outputPath
})
// keep the latest 30 records per session
if (list.length > 30) {
this.store[normalizedSessionId] = list.slice(-30)
}
this.persist()
}
}
export const exportRecordService = new ExportRecordService()

File diff suppressed because it is too large Load Diff

View File

@@ -21,6 +21,12 @@ export interface GroupMember {
alias?: string
remark?: string
groupNickname?: string
isOwner?: boolean
}
export interface GroupMembersPanelEntry extends GroupMember {
isFriend: boolean
messageCount: number
}
export interface GroupMessageRank {
@@ -43,8 +49,28 @@ export interface GroupMediaStats {
total: number
}
interface GroupMemberContactInfo {
remark: string
nickName: string
alias: string
username: string
userName: string
encryptUsername: string
encryptUserName: string
localType: number
}
class GroupAnalyticsService {
private configService: ConfigService
private readonly groupMembersPanelCacheTtlMs = 10 * 60 * 1000
private readonly groupMembersPanelMembersTimeoutMs = 12 * 1000
private readonly groupMembersPanelFullTimeoutMs = 25 * 1000
private readonly groupMembersPanelCache = new Map<string, { updatedAt: number; data: GroupMembersPanelEntry[] }>()
private readonly groupMembersPanelInFlight = new Map<
string,
Promise<{ success: boolean; data?: GroupMembersPanelEntry[]; error?: string; fromCache?: boolean; updatedAt?: number }>
>()
private readonly friendExcludeNames = new Set(['medianote', 'floatbottle', 'qmessage', 'qqmail', 'fmessage'])
constructor() {
this.configService = new ConfigService()
@@ -89,6 +115,128 @@ class GroupAnalyticsService {
return cleaned
}
private resolveMemberUsername(
candidate: unknown,
memberLookup: Map<string, string>
): string | null {
if (typeof candidate !== 'string') return null
const raw = candidate.trim()
if (!raw) return null
if (memberLookup.has(raw)) return memberLookup.get(raw) || null
const cleaned = this.cleanAccountDirName(raw)
if (memberLookup.has(cleaned)) return memberLookup.get(cleaned) || null
const parts = raw.split(/[,\s;|]+/).filter(Boolean)
for (const part of parts) {
if (memberLookup.has(part)) return memberLookup.get(part) || null
const normalizedPart = this.cleanAccountDirName(part)
if (memberLookup.has(normalizedPart)) return memberLookup.get(normalizedPart) || null
}
if ((raw.startsWith('{') || raw.startsWith('[')) && raw.length < 4096) {
try {
const parsed = JSON.parse(raw)
return this.extractOwnerUsername(parsed, memberLookup, 0)
} catch {
return null
}
}
return null
}
private extractOwnerUsername(
value: unknown,
memberLookup: Map<string, string>,
depth: number
): string | null {
if (depth > 4 || value == null) return null
if (Buffer.isBuffer(value) || value instanceof Uint8Array) return null
if (typeof value === 'string') {
return this.resolveMemberUsername(value, memberLookup)
}
if (Array.isArray(value)) {
for (const item of value) {
const owner = this.extractOwnerUsername(item, memberLookup, depth + 1)
if (owner) return owner
}
return null
}
if (typeof value !== 'object') return null
const row = value as Record<string, unknown>
for (const [key, entry] of Object.entries(row)) {
const keyLower = key.toLowerCase()
if (!keyLower.includes('owner') && !keyLower.includes('host') && !keyLower.includes('creator')) {
continue
}
if (typeof entry === 'boolean') {
if (entry && typeof row.username === 'string') {
const owner = this.resolveMemberUsername(row.username, memberLookup)
if (owner) return owner
}
continue
}
const owner = this.extractOwnerUsername(entry, memberLookup, depth + 1)
if (owner) return owner
}
return null
}
private async detectGroupOwnerUsername(
chatroomId: string,
members: Array<{ username: string; [key: string]: unknown }>
): Promise<string | undefined> {
const memberLookup = new Map<string, string>()
for (const member of members) {
const username = String(member.username || '').trim()
if (!username) continue
const cleaned = this.cleanAccountDirName(username)
memberLookup.set(username, username)
memberLookup.set(cleaned, username)
}
if (memberLookup.size === 0) return undefined
const tryResolve = (candidate: unknown): string | undefined => {
const owner = this.extractOwnerUsername(candidate, memberLookup, 0)
return owner || undefined
}
for (const member of members) {
const owner = tryResolve(member)
if (owner) return owner
}
try {
const groupContact = await wcdbService.getContact(chatroomId)
if (groupContact.success && groupContact.contact) {
const owner = tryResolve(groupContact.contact)
if (owner) return owner
}
} catch {
// ignore
}
try {
const escapedChatroomId = chatroomId.replace(/'/g, "''")
const roomResult = await wcdbService.execQuery('contact', null, `SELECT * FROM chat_room WHERE username='${escapedChatroomId}' LIMIT 1`)
if (roomResult.success && roomResult.rows && roomResult.rows.length > 0) {
const owner = tryResolve(roomResult.rows[0])
if (owner) return owner
}
} catch {
// ignore
}
return undefined
}
private async ensureConnected(): Promise<{ success: boolean; error?: string }> {
const wxid = this.configService.get('myWxid')
const dbPath = this.configService.get('dbPath')
@@ -296,6 +444,203 @@ class GroupAnalyticsService {
return Array.from(set)
}
private toNonNegativeInteger(value: unknown): number {
const parsed = Number(value)
if (!Number.isFinite(parsed)) return 0
return Math.max(0, Math.floor(parsed))
}
private pickStringField(row: Record<string, unknown>, keys: string[]): string {
for (const key of keys) {
const value = row[key]
if (value == null) continue
const text = String(value).trim()
if (text) return text
}
return ''
}
private pickIntegerField(row: Record<string, unknown>, keys: string[], fallback: number = 0): number {
for (const key of keys) {
const value = row[key]
if (value == null || value === '') continue
const parsed = Number(value)
if (Number.isFinite(parsed)) return Math.floor(parsed)
}
return fallback
}
private buildGroupMembersPanelCacheKey(chatroomId: string, includeMessageCounts: boolean): string {
const dbPath = String(this.configService.get('dbPath') || '').trim()
const wxid = this.cleanAccountDirName(String(this.configService.get('myWxid') || '').trim())
const mode = includeMessageCounts ? 'full' : 'members'
return `${dbPath}::${wxid}::${chatroomId}::${mode}`
}
private pruneGroupMembersPanelCache(maxEntries: number = 80): void {
if (this.groupMembersPanelCache.size <= maxEntries) return
const entries = Array.from(this.groupMembersPanelCache.entries())
.sort((a, b) => a[1].updatedAt - b[1].updatedAt)
const removeCount = this.groupMembersPanelCache.size - maxEntries
for (let i = 0; i < removeCount; i += 1) {
this.groupMembersPanelCache.delete(entries[i][0])
}
}
private async withPromiseTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
timeoutResult: T
): Promise<T> {
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
return promise
}
let timeoutTimer: ReturnType<typeof setTimeout> | null = null
const timeoutPromise = new Promise<T>((resolve) => {
timeoutTimer = setTimeout(() => {
resolve(timeoutResult)
}, timeoutMs)
})
try {
return await Promise.race([promise, timeoutPromise])
} finally {
if (timeoutTimer) {
clearTimeout(timeoutTimer)
}
}
}
private async buildGroupMemberContactLookup(usernames: string[]): Promise<Map<string, GroupMemberContactInfo>> {
const lookup = new Map<string, GroupMemberContactInfo>()
const candidates = this.buildIdCandidates(usernames)
if (candidates.length === 0) return lookup
const appendContactsToLookup = (rows: Record<string, unknown>[]) => {
for (const row of rows) {
const contact: GroupMemberContactInfo = {
remark: this.pickStringField(row, ['remark', 'WCDB_CT_remark']),
nickName: this.pickStringField(row, ['nick_name', 'nickName', 'WCDB_CT_nick_name']),
alias: this.pickStringField(row, ['alias', 'WCDB_CT_alias']),
username: this.pickStringField(row, ['username', 'WCDB_CT_username']),
userName: this.pickStringField(row, ['user_name', 'userName', 'WCDB_CT_user_name']),
encryptUsername: this.pickStringField(row, ['encrypt_username', 'encryptUsername', 'WCDB_CT_encrypt_username']),
encryptUserName: this.pickStringField(row, ['encrypt_user_name', 'encryptUserName', 'WCDB_CT_encrypt_user_name']),
localType: this.pickIntegerField(row, ['local_type', 'localType', 'WCDB_CT_local_type'], 0)
}
const lookupKeys = this.buildIdCandidates([
contact.username,
contact.userName,
contact.encryptUsername,
contact.encryptUserName,
contact.alias
])
for (const key of lookupKeys) {
const normalized = key.toLowerCase()
if (!lookup.has(normalized)) {
lookup.set(normalized, contact)
}
}
}
}
const batchSize = 200
for (let i = 0; i < candidates.length; i += batchSize) {
const batch = candidates.slice(i, i + batchSize)
if (batch.length === 0) continue
const inList = batch.map((username) => `'${username.replace(/'/g, "''")}'`).join(',')
const lightweightSql = `
SELECT username, user_name, encrypt_username, encrypt_user_name, remark, nick_name, alias, local_type
FROM contact
WHERE username IN (${inList})
`
let result = await wcdbService.execQuery('contact', null, lightweightSql)
if (!result.success || !result.rows) {
// 兼容历史/变体列名,轻查询失败时回退全字段查询,避免好友标识丢失
result = await wcdbService.execQuery('contact', null, `SELECT * FROM contact WHERE username IN (${inList})`)
}
if (!result.success || !result.rows) continue
appendContactsToLookup(result.rows as Record<string, unknown>[])
}
return lookup
}
private resolveContactByCandidates(
lookup: Map<string, GroupMemberContactInfo>,
candidates: Array<string | undefined | null>
): GroupMemberContactInfo | undefined {
const ids = this.buildIdCandidates(candidates)
for (const id of ids) {
const hit = lookup.get(id.toLowerCase())
if (hit) return hit
}
return undefined
}
private async buildGroupMessageCountLookup(chatroomId: string): Promise<Map<string, number>> {
const lookup = new Map<string, number>()
const result = await wcdbService.getGroupStats(chatroomId, 0, 0)
if (!result.success || !result.data) return lookup
const sessionData = result.data?.sessions?.[chatroomId]
if (!sessionData || !sessionData.senders) return lookup
const idMap = result.data.idMap || {}
for (const [senderId, rawCount] of Object.entries(sessionData.senders as Record<string, number>)) {
const username = String(idMap[senderId] || senderId || '').trim()
if (!username) continue
const count = this.toNonNegativeInteger(rawCount)
const keys = this.buildIdCandidates([username])
for (const key of keys) {
const normalized = key.toLowerCase()
const prev = lookup.get(normalized) || 0
if (count > prev) {
lookup.set(normalized, count)
}
}
}
return lookup
}
private resolveMessageCountByCandidates(
lookup: Map<string, number>,
candidates: Array<string | undefined | null>
): number {
let maxCount = 0
const ids = this.buildIdCandidates(candidates)
for (const id of ids) {
const count = lookup.get(id.toLowerCase())
if (typeof count === 'number' && count > maxCount) {
maxCount = count
}
}
return maxCount
}
private isFriendMember(wxid: string, contact?: GroupMemberContactInfo): boolean {
const normalizedWxid = String(wxid || '').trim().toLowerCase()
if (!normalizedWxid) return false
if (normalizedWxid.includes('@chatroom') || normalizedWxid.startsWith('gh_')) return false
if (this.friendExcludeNames.has(normalizedWxid)) return false
if (!contact) return false
return contact.localType === 1
}
private sortGroupMembersPanelEntries(members: GroupMembersPanelEntry[]): GroupMembersPanelEntry[] {
return members.sort((a, b) => {
const ownerDiff = Number(Boolean(b.isOwner)) - Number(Boolean(a.isOwner))
if (ownerDiff !== 0) return ownerDiff
const friendDiff = Number(Boolean(b.isFriend)) - Number(Boolean(a.isFriend))
if (friendDiff !== 0) return friendDiff
if (a.messageCount !== b.messageCount) return b.messageCount - a.messageCount
return a.displayName.localeCompare(b.displayName, 'zh-Hans-CN')
})
}
private resolveGroupNicknameByCandidates(groupNicknames: Map<string, string>, candidates: string[]): string {
const idCandidates = this.buildIdCandidates(candidates)
if (idCandidates.length === 0) return ''
@@ -483,6 +828,167 @@ class GroupAnalyticsService {
}
}
private async loadGroupMembersPanelDataFresh(
chatroomId: string,
includeMessageCounts: boolean
): Promise<{ success: boolean; data?: GroupMembersPanelEntry[]; error?: string }> {
const membersResult = await wcdbService.getGroupMembers(chatroomId)
if (!membersResult.success || !membersResult.members) {
return { success: false, error: membersResult.error || '获取群成员失败' }
}
const members = membersResult.members as Array<{
username: string
avatarUrl?: string
originalName?: string
[key: string]: unknown
}>
if (members.length === 0) return { success: true, data: [] }
const usernames = members
.map((member) => String(member.username || '').trim())
.filter(Boolean)
if (usernames.length === 0) return { success: true, data: [] }
const displayNamesPromise = wcdbService.getDisplayNames(usernames)
const contactLookupPromise = this.buildGroupMemberContactLookup(usernames)
const ownerPromise = this.detectGroupOwnerUsername(chatroomId, members)
const messageCountLookupPromise = includeMessageCounts
? this.buildGroupMessageCountLookup(chatroomId)
: Promise.resolve(new Map<string, number>())
const [displayNames, contactLookup, ownerUsername, messageCountLookup] = await Promise.all([
displayNamesPromise,
contactLookupPromise,
ownerPromise,
messageCountLookupPromise
])
const nicknameCandidates = this.buildIdCandidates([
...members.map((member) => member.username),
...members.map((member) => member.originalName),
...Array.from(contactLookup.values()).map((contact) => contact?.username),
...Array.from(contactLookup.values()).map((contact) => contact?.userName),
...Array.from(contactLookup.values()).map((contact) => contact?.encryptUsername),
...Array.from(contactLookup.values()).map((contact) => contact?.encryptUserName),
...Array.from(contactLookup.values()).map((contact) => contact?.alias)
])
const groupNicknames = await this.getGroupNicknamesForRoom(chatroomId, nicknameCandidates)
const myWxid = this.cleanAccountDirName(this.configService.get('myWxid') || '')
let myGroupMessageCountHint: number | undefined
const data: GroupMembersPanelEntry[] = members
.map((member) => {
const wxid = String(member.username || '').trim()
if (!wxid) return null
const contact = this.resolveContactByCandidates(contactLookup, [wxid, member.originalName])
const nickname = contact?.nickName || ''
const remark = contact?.remark || ''
const alias = contact?.alias || ''
const normalizedWxid = this.cleanAccountDirName(wxid)
const lookupCandidates = this.buildIdCandidates([
wxid,
member.originalName as string | undefined,
contact?.username,
contact?.userName,
contact?.encryptUsername,
contact?.encryptUserName,
alias
])
if (normalizedWxid === myWxid) {
lookupCandidates.push(myWxid)
}
const groupNickname = this.resolveGroupNicknameByCandidates(groupNicknames, lookupCandidates)
const displayName = displayNames.success && displayNames.map ? (displayNames.map[wxid] || wxid) : wxid
return {
username: wxid,
displayName,
nickname,
alias,
remark,
groupNickname,
avatarUrl: member.avatarUrl,
isOwner: Boolean(ownerUsername && ownerUsername === wxid),
isFriend: this.isFriendMember(wxid, contact),
messageCount: this.resolveMessageCountByCandidates(messageCountLookup, lookupCandidates)
}
})
.filter((member): member is GroupMembersPanelEntry => Boolean(member))
if (includeMessageCounts && myWxid) {
const selfEntry = data.find((member) => this.cleanAccountDirName(member.username) === myWxid)
if (selfEntry && Number.isFinite(selfEntry.messageCount)) {
myGroupMessageCountHint = Math.max(0, Math.floor(selfEntry.messageCount))
}
}
if (includeMessageCounts && Number.isFinite(myGroupMessageCountHint)) {
void chatService.setGroupMyMessageCountHint(chatroomId, myGroupMessageCountHint as number)
}
return { success: true, data: this.sortGroupMembersPanelEntries(data) }
}
async getGroupMembersPanelData(
chatroomId: string,
options?: { forceRefresh?: boolean; includeMessageCounts?: boolean }
): Promise<{ success: boolean; data?: GroupMembersPanelEntry[]; error?: string; fromCache?: boolean; updatedAt?: number }> {
try {
const normalizedChatroomId = String(chatroomId || '').trim()
if (!normalizedChatroomId) return { success: false, error: '群聊ID不能为空' }
const forceRefresh = Boolean(options?.forceRefresh)
const includeMessageCounts = options?.includeMessageCounts !== false
const cacheKey = this.buildGroupMembersPanelCacheKey(normalizedChatroomId, includeMessageCounts)
const now = Date.now()
const cached = this.groupMembersPanelCache.get(cacheKey)
if (!forceRefresh && cached && now - cached.updatedAt < this.groupMembersPanelCacheTtlMs) {
return { success: true, data: cached.data, fromCache: true, updatedAt: cached.updatedAt }
}
if (!forceRefresh) {
const pending = this.groupMembersPanelInFlight.get(cacheKey)
if (pending) return pending
}
const requestPromise = (async () => {
const conn = await this.ensureConnected()
if (!conn.success) return { success: false, error: conn.error }
const timeoutMs = includeMessageCounts
? this.groupMembersPanelFullTimeoutMs
: this.groupMembersPanelMembersTimeoutMs
const fresh = await this.withPromiseTimeout(
this.loadGroupMembersPanelDataFresh(normalizedChatroomId, includeMessageCounts),
timeoutMs,
{
success: false,
error: includeMessageCounts
? '群成员发言统计加载超时,请稍后重试'
: '群成员列表加载超时,请稍后重试'
}
)
if (!fresh.success || !fresh.data) {
return { success: false, error: fresh.error || '获取群成员面板数据失败' }
}
const updatedAt = Date.now()
this.groupMembersPanelCache.set(cacheKey, { updatedAt, data: fresh.data })
this.pruneGroupMembersPanelCache()
return { success: true, data: fresh.data, fromCache: false, updatedAt }
})().finally(() => {
this.groupMembersPanelInFlight.delete(cacheKey)
})
this.groupMembersPanelInFlight.set(cacheKey, requestPromise)
return await requestPromise
} catch (e) {
return { success: false, error: String(e) }
}
}
async getGroupMembers(chatroomId: string): Promise<{ success: boolean; data?: GroupMember[]; error?: string }> {
try {
const conn = await this.ensureConnected()
@@ -497,6 +1003,7 @@ class GroupAnalyticsService {
username: string
avatarUrl?: string
originalName?: string
[key: string]: unknown
}>
const usernames = members.map((m) => m.username).filter(Boolean)
@@ -543,6 +1050,7 @@ class GroupAnalyticsService {
const groupNicknames = await this.getGroupNicknamesForRoom(chatroomId, nicknameCandidates)
const myWxid = this.cleanAccountDirName(this.configService.get('myWxid') || '')
const ownerUsername = await this.detectGroupOwnerUsername(chatroomId, members)
const data: GroupMember[] = members.map((m) => {
const wxid = m.username || ''
const displayName = displayNames.success && displayNames.map ? (displayNames.map[wxid] || wxid) : wxid
@@ -572,7 +1080,8 @@ class GroupAnalyticsService {
alias,
remark,
groupNickname,
avatarUrl: m.avatarUrl
avatarUrl: m.avatarUrl,
isOwner: Boolean(ownerUsername && ownerUsername === wxid)
}
})

View File

@@ -0,0 +1,204 @@
import { join, dirname } from 'path'
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
import { ConfigService } from './config'
const CACHE_VERSION = 1
const MAX_GROUP_ENTRIES_PER_SCOPE = 3000
const MAX_SCOPE_ENTRIES = 12
export interface GroupMyMessageCountCacheEntry {
updatedAt: number
messageCount: number
}
interface GroupMyMessageCountScopeMap {
[chatroomId: string]: GroupMyMessageCountCacheEntry
}
interface GroupMyMessageCountCacheStore {
version: number
scopes: Record<string, GroupMyMessageCountScopeMap>
}
function toNonNegativeInt(value: unknown): number | undefined {
if (typeof value !== 'number' || !Number.isFinite(value)) return undefined
return Math.max(0, Math.floor(value))
}
function normalizeEntry(raw: unknown): GroupMyMessageCountCacheEntry | null {
if (!raw || typeof raw !== 'object') return null
const source = raw as Record<string, unknown>
const updatedAt = toNonNegativeInt(source.updatedAt)
const messageCount = toNonNegativeInt(source.messageCount)
if (updatedAt === undefined || messageCount === undefined) return null
return {
updatedAt,
messageCount
}
}
export class GroupMyMessageCountCacheService {
private readonly cacheFilePath: string
private store: GroupMyMessageCountCacheStore = {
version: CACHE_VERSION,
scopes: {}
}
constructor(cacheBasePath?: string) {
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
? cacheBasePath
: ConfigService.getInstance().getCacheBasePath()
this.cacheFilePath = join(basePath, 'group-my-message-counts.json')
this.ensureCacheDir()
this.load()
}
private ensureCacheDir(): void {
const dir = dirname(this.cacheFilePath)
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
}
private load(): void {
if (!existsSync(this.cacheFilePath)) return
try {
const raw = readFileSync(this.cacheFilePath, 'utf8')
const parsed = JSON.parse(raw) as unknown
if (!parsed || typeof parsed !== 'object') {
this.store = { version: CACHE_VERSION, scopes: {} }
return
}
const payload = parsed as Record<string, unknown>
const scopesRaw = payload.scopes
if (!scopesRaw || typeof scopesRaw !== 'object') {
this.store = { version: CACHE_VERSION, scopes: {} }
return
}
const scopes: Record<string, GroupMyMessageCountScopeMap> = {}
for (const [scopeKey, scopeValue] of Object.entries(scopesRaw as Record<string, unknown>)) {
if (!scopeValue || typeof scopeValue !== 'object') continue
const normalizedScope: GroupMyMessageCountScopeMap = {}
for (const [chatroomId, entryRaw] of Object.entries(scopeValue as Record<string, unknown>)) {
const entry = normalizeEntry(entryRaw)
if (!entry) continue
normalizedScope[chatroomId] = entry
}
if (Object.keys(normalizedScope).length > 0) {
scopes[scopeKey] = normalizedScope
}
}
this.store = {
version: CACHE_VERSION,
scopes
}
} catch (error) {
console.error('GroupMyMessageCountCacheService: 载入缓存失败', error)
this.store = { version: CACHE_VERSION, scopes: {} }
}
}
get(scopeKey: string, chatroomId: string): GroupMyMessageCountCacheEntry | undefined {
if (!scopeKey || !chatroomId) return undefined
const scope = this.store.scopes[scopeKey]
if (!scope) return undefined
const entry = normalizeEntry(scope[chatroomId])
if (!entry) {
delete scope[chatroomId]
if (Object.keys(scope).length === 0) {
delete this.store.scopes[scopeKey]
}
this.persist()
return undefined
}
return entry
}
set(scopeKey: string, chatroomId: string, entry: GroupMyMessageCountCacheEntry): void {
if (!scopeKey || !chatroomId) return
const normalized = normalizeEntry(entry)
if (!normalized) return
if (!this.store.scopes[scopeKey]) {
this.store.scopes[scopeKey] = {}
}
const existing = this.store.scopes[scopeKey][chatroomId]
if (existing && existing.updatedAt > normalized.updatedAt) {
return
}
this.store.scopes[scopeKey][chatroomId] = normalized
this.trimScope(scopeKey)
this.trimScopes()
this.persist()
}
delete(scopeKey: string, chatroomId: string): void {
if (!scopeKey || !chatroomId) return
const scope = this.store.scopes[scopeKey]
if (!scope) return
if (!(chatroomId in scope)) return
delete scope[chatroomId]
if (Object.keys(scope).length === 0) {
delete this.store.scopes[scopeKey]
}
this.persist()
}
clearScope(scopeKey: string): void {
if (!scopeKey) return
if (!this.store.scopes[scopeKey]) return
delete this.store.scopes[scopeKey]
this.persist()
}
clearAll(): void {
this.store = { version: CACHE_VERSION, scopes: {} }
try {
rmSync(this.cacheFilePath, { force: true })
} catch (error) {
console.error('GroupMyMessageCountCacheService: 清理缓存失败', error)
}
}
private trimScope(scopeKey: string): void {
const scope = this.store.scopes[scopeKey]
if (!scope) return
const entries = Object.entries(scope)
if (entries.length <= MAX_GROUP_ENTRIES_PER_SCOPE) return
entries.sort((a, b) => b[1].updatedAt - a[1].updatedAt)
const trimmed: GroupMyMessageCountScopeMap = {}
for (const [chatroomId, entry] of entries.slice(0, MAX_GROUP_ENTRIES_PER_SCOPE)) {
trimmed[chatroomId] = entry
}
this.store.scopes[scopeKey] = trimmed
}
private trimScopes(): void {
const scopeEntries = Object.entries(this.store.scopes)
if (scopeEntries.length <= MAX_SCOPE_ENTRIES) return
scopeEntries.sort((a, b) => {
const aUpdatedAt = Math.max(...Object.values(a[1]).map((entry) => entry.updatedAt), 0)
const bUpdatedAt = Math.max(...Object.values(b[1]).map((entry) => entry.updatedAt), 0)
return bUpdatedAt - aUpdatedAt
})
const trimmedScopes: Record<string, GroupMyMessageCountScopeMap> = {}
for (const [scopeKey, scopeMap] of scopeEntries.slice(0, MAX_SCOPE_ENTRIES)) {
trimmedScopes[scopeKey] = scopeMap
}
this.store.scopes = trimmedScopes
}
private persist(): void {
try {
writeFileSync(this.cacheFilePath, JSON.stringify(this.store), 'utf8')
} catch (error) {
console.error('GroupMyMessageCountCacheService: 保存缓存失败', error)
}
}
}

View File

@@ -340,6 +340,7 @@ class HttpService {
const trimmedRows = allRows.slice(0, limit)
const finalHasMore = hasMore || allRows.length > limit
const messages = chatService.mapRowsToMessagesForApi(trimmedRows)
await this.backfillMissingSenderUsernames(talker, messages)
return { success: true, messages, hasMore: finalHasMore }
} finally {
await wcdbService.closeMessageCursor(cursor)
@@ -359,6 +360,41 @@ class HttpService {
return Math.min(Math.max(parsed, min), max)
}
private async backfillMissingSenderUsernames(talker: string, messages: Message[]): Promise<void> {
if (!talker.endsWith('@chatroom')) return
const targets = messages.filter((msg) => !String(msg.senderUsername || '').trim())
if (targets.length === 0) return
const myWxid = (this.configService.get('myWxid') || '').trim()
for (const msg of targets) {
const localId = Number(msg.localId || 0)
if (Number.isFinite(localId) && localId > 0) {
try {
const detail = await wcdbService.getMessageById(talker, localId)
if (detail.success && detail.message) {
const hydrated = chatService.mapRowsToMessagesForApi([detail.message])[0]
if (hydrated?.senderUsername) {
msg.senderUsername = hydrated.senderUsername
}
if ((msg.isSend === null || msg.isSend === undefined) && hydrated?.isSend !== undefined) {
msg.isSend = hydrated.isSend
}
if (!msg.rawContent && hydrated?.rawContent) {
msg.rawContent = hydrated.rawContent
}
}
} catch (error) {
console.warn('[HttpService] backfill sender failed:', error)
}
}
if (!msg.senderUsername && msg.isSend === 1 && myWxid) {
msg.senderUsername = myWxid
}
}
}
private parseBooleanParam(url: URL, keys: string[], defaultValue: boolean = false): boolean {
for (const key of keys) {
const raw = url.searchParams.get(key)
@@ -778,6 +814,49 @@ class HttpService {
return {}
}
private lookupGroupNickname(groupNicknamesMap: Map<string, string>, sender: string): string {
if (!sender) return ''
return groupNicknamesMap.get(sender) || groupNicknamesMap.get(sender.toLowerCase()) || ''
}
private resolveChatLabSenderInfo(
msg: Message,
talkerId: string,
talkerName: string,
myWxid: string,
isGroup: boolean,
senderNames: Record<string, string>,
groupNicknamesMap: Map<string, string>
): { sender: string; accountName: string; groupNickname?: string } {
let sender = String(msg.senderUsername || '').trim()
let usedUnknownPlaceholder = false
const sameAsMe = sender && myWxid && sender.toLowerCase() === myWxid.toLowerCase()
const isSelf = msg.isSend === 1 || sameAsMe
if (!sender && isSelf && myWxid) {
sender = myWxid
}
if (!sender) {
if (msg.localType === 10000 || msg.localType === 266287972401) {
sender = talkerId
} else {
sender = `unknown_sender_${msg.localId || msg.createTime || 0}`
usedUnknownPlaceholder = true
}
}
const groupNickname = isGroup ? this.lookupGroupNickname(groupNicknamesMap, sender) : ''
const displayName = senderNames[sender] || groupNickname || (usedUnknownPlaceholder ? '' : sender)
const accountName = isSelf ? '我' : (displayName || '未知发送者')
return {
sender,
accountName,
groupNickname: groupNickname || undefined
}
}
/**
* 转换为 ChatLab 格式
*/
@@ -817,36 +896,24 @@ class HttpService {
// 构建成员列表
const memberMap = new Map<string, ChatLabMember>()
for (const msg of messages) {
const sender = msg.senderUsername || ''
if (sender && !memberMap.has(sender)) {
const displayName = senderNames[sender] || sender
const isSelf = sender === myWxid || sender.toLowerCase() === myWxid.toLowerCase()
// 获取群昵称(尝试多种方式)
const groupNickname = isGroup
? (groupNicknamesMap.get(sender) || groupNicknamesMap.get(sender.toLowerCase()) || '')
: ''
memberMap.set(sender, {
platformId: sender,
accountName: isSelf ? '我' : displayName,
groupNickname: groupNickname || undefined
const senderInfo = this.resolveChatLabSenderInfo(msg, talkerId, talkerName, myWxid, isGroup, senderNames, groupNicknamesMap)
if (!memberMap.has(senderInfo.sender)) {
memberMap.set(senderInfo.sender, {
platformId: senderInfo.sender,
accountName: senderInfo.accountName,
groupNickname: senderInfo.groupNickname
})
}
}
// 转换消息
const chatLabMessages: ChatLabMessage[] = messages.map(msg => {
const sender = msg.senderUsername || ''
const isSelf = msg.isSend === 1 || sender === myWxid
const accountName = isSelf ? '我' : (senderNames[sender] || sender)
// 获取该发送者的群昵称
const groupNickname = isGroup
? (groupNicknamesMap.get(sender) || groupNicknamesMap.get(sender.toLowerCase()) || '')
: ''
const senderInfo = this.resolveChatLabSenderInfo(msg, talkerId, talkerName, myWxid, isGroup, senderNames, groupNicknamesMap)
return {
sender,
accountName,
groupNickname: groupNickname || undefined,
sender: senderInfo.sender,
accountName: senderInfo.accountName,
groupNickname: senderInfo.groupNickname,
timestamp: msg.createTime,
type: this.mapMessageType(msg.localType, msg),
content: this.getMessageContent(msg),

View File

@@ -15,8 +15,16 @@ function getStaticFfmpegPath(): string | null {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const ffmpegStatic = require('ffmpeg-static')
if (typeof ffmpegStatic === 'string' && existsSync(ffmpegStatic)) {
return ffmpegStatic
if (typeof ffmpegStatic === 'string') {
// 修复:如果路径包含 app.asar打包后自动替换为 app.asar.unpacked
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
}
}
// 方法2: 手动构建路径(开发环境)
@@ -406,23 +414,33 @@ export class ImageDecryptService {
if (!skipResolvedCache) {
if (imageMd5) {
const cached = this.resolvedCache.get(imageMd5)
if (cached && existsSync(cached)) return cached
if (cached && existsSync(cached)) {
const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail)
this.cacheDatPath(accountDir, imageMd5, preferred)
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, preferred)
return preferred
}
}
if (imageDatName) {
const cached = this.resolvedCache.get(imageDatName)
if (cached && existsSync(cached)) return cached
if (cached && existsSync(cached)) {
const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail)
this.cacheDatPath(accountDir, imageDatName, preferred)
if (imageMd5) this.cacheDatPath(accountDir, imageMd5, preferred)
return preferred
}
}
}
// 1. 通过 MD5 快速定位 (MsgAttach 目录)
if (imageMd5) {
const res = await this.fastProbabilisticSearch(accountDir, imageMd5, allowThumbnail)
const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageMd5, allowThumbnail)
if (res) return res
}
// 2. 如果 imageDatName 看起来像 MD5也尝试快速定位
if (!imageMd5 && imageDatName && this.looksLikeMd5(imageDatName)) {
const res = await this.fastProbabilisticSearch(accountDir, imageDatName, allowThumbnail)
const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageDatName, allowThumbnail)
if (res) return res
}
@@ -431,16 +449,17 @@ export class ImageDecryptService {
this.logInfo('[ImageDecrypt] hardlink lookup (md5)', { imageMd5, sessionId })
const hardlinkPath = await this.resolveHardlinkPath(accountDir, imageMd5, sessionId)
if (hardlinkPath) {
const isThumb = this.isThumbnailPath(hardlinkPath)
const preferredPath = this.getPreferredDatVariantPath(hardlinkPath, allowThumbnail)
const isThumb = this.isThumbnailPath(preferredPath)
if (allowThumbnail || !isThumb) {
this.logInfo('[ImageDecrypt] hardlink hit', { imageMd5, path: hardlinkPath })
this.cacheDatPath(accountDir, imageMd5, hardlinkPath)
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hardlinkPath)
return hardlinkPath
this.logInfo('[ImageDecrypt] hardlink hit', { imageMd5, path: preferredPath })
this.cacheDatPath(accountDir, imageMd5, preferredPath)
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, preferredPath)
return preferredPath
}
// hardlink 找到的是缩略图,但要求高清图
// 尝试在同一目录下查找高清图变体(快速查找,不遍历)
const hdPath = this.findHdVariantInSameDir(hardlinkPath)
const hdPath = this.findHdVariantInSameDir(preferredPath)
if (hdPath) {
this.cacheDatPath(accountDir, imageMd5, hdPath)
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdPath)
@@ -454,16 +473,19 @@ export class ImageDecryptService {
this.logInfo('[ImageDecrypt] hardlink fallback (datName)', { imageDatName, sessionId })
const fallbackPath = await this.resolveHardlinkPath(accountDir, imageDatName, sessionId)
if (fallbackPath) {
const isThumb = this.isThumbnailPath(fallbackPath)
const preferredPath = this.getPreferredDatVariantPath(fallbackPath, allowThumbnail)
const isThumb = this.isThumbnailPath(preferredPath)
if (allowThumbnail || !isThumb) {
this.logInfo('[ImageDecrypt] hardlink hit (datName)', { imageMd5: imageDatName, path: fallbackPath })
this.cacheDatPath(accountDir, imageDatName, fallbackPath)
return fallbackPath
this.logInfo('[ImageDecrypt] hardlink hit (datName)', { imageMd5: imageDatName, path: preferredPath })
this.cacheDatPath(accountDir, imageDatName, preferredPath)
if (imageMd5) this.cacheDatPath(accountDir, imageMd5, preferredPath)
return preferredPath
}
// 找到缩略图但要求高清图,尝试同目录查找高清图变体
const hdPath = this.findHdVariantInSameDir(fallbackPath)
const hdPath = this.findHdVariantInSameDir(preferredPath)
if (hdPath) {
this.cacheDatPath(accountDir, imageDatName, hdPath)
if (imageMd5) this.cacheDatPath(accountDir, imageMd5, hdPath)
return hdPath
}
return null
@@ -476,14 +498,15 @@ export class ImageDecryptService {
this.logInfo('[ImageDecrypt] hardlink lookup (datName)', { imageDatName, sessionId })
const hardlinkPath = await this.resolveHardlinkPath(accountDir, imageDatName, sessionId)
if (hardlinkPath) {
const isThumb = this.isThumbnailPath(hardlinkPath)
const preferredPath = this.getPreferredDatVariantPath(hardlinkPath, allowThumbnail)
const isThumb = this.isThumbnailPath(preferredPath)
if (allowThumbnail || !isThumb) {
this.logInfo('[ImageDecrypt] hardlink hit', { imageMd5: imageDatName, path: hardlinkPath })
this.cacheDatPath(accountDir, imageDatName, hardlinkPath)
return hardlinkPath
this.logInfo('[ImageDecrypt] hardlink hit', { imageMd5: imageDatName, path: preferredPath })
this.cacheDatPath(accountDir, imageDatName, preferredPath)
return preferredPath
}
// hardlink 找到的是缩略图,但要求高清图
const hdPath = this.findHdVariantInSameDir(hardlinkPath)
const hdPath = this.findHdVariantInSameDir(preferredPath)
if (hdPath) {
this.cacheDatPath(accountDir, imageDatName, hdPath)
return hdPath
@@ -502,9 +525,10 @@ export class ImageDecryptService {
if (!skipResolvedCache) {
const cached = this.resolvedCache.get(imageDatName)
if (cached && existsSync(cached)) {
if (allowThumbnail || !this.isThumbnailPath(cached)) return cached
const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail)
if (allowThumbnail || !this.isThumbnailPath(preferred)) return preferred
// 缓存的是缩略图,尝试找高清图
const hdPath = this.findHdVariantInSameDir(cached)
const hdPath = this.findHdVariantInSameDir(preferred)
if (hdPath) return hdPath
}
}
@@ -793,7 +817,8 @@ export class ImageDecryptService {
const key = `${accountDir}|${datName}`
const cached = this.resolvedCache.get(key)
if (cached && existsSync(cached)) {
if (allowThumbnail || !this.isThumbnailPath(cached)) return cached
const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail)
if (allowThumbnail || !this.isThumbnailPath(preferred)) return preferred
}
const root = join(accountDir, 'msg', 'attach')
@@ -802,7 +827,7 @@ export class ImageDecryptService {
// 优化1快速概率性查找
// 包含1. 基于文件名的前缀猜测 (旧版)
// 2. 基于日期的最近月份扫描 (新版无索引时)
const fastHit = await this.fastProbabilisticSearch(root, datName)
const fastHit = await this.fastProbabilisticSearch(root, datName, allowThumbnail)
if (fastHit) {
this.resolvedCache.set(key, fastHit)
return fastHit
@@ -822,33 +847,28 @@ export class ImageDecryptService {
* 包含1. 微信旧版结构 filename.substr(0, 2)/...
* 2. 微信新版结构 msg/attach/{hash}/{YYYY-MM}/Img/filename
*/
private async fastProbabilisticSearch(root: string, datName: string, _allowThumbnail?: boolean): Promise<string | null> {
private async fastProbabilisticSearch(root: string, datName: string, allowThumbnail = true): Promise<string | null> {
const { promises: fs } = require('fs')
const { join } = require('path')
try {
// --- 策略 A: 旧版路径猜测 (msg/attach/xx/yy/...) ---
const lowerName = datName.toLowerCase()
let baseName = lowerName
if (baseName.endsWith('.dat')) {
baseName = baseName.slice(0, -4)
if (baseName.endsWith('_t') || baseName.endsWith('.t') || baseName.endsWith('_hd')) {
baseName = baseName.slice(0, -3)
} else if (baseName.endsWith('_thumb')) {
baseName = baseName.slice(0, -6)
}
}
const baseName = this.normalizeDatBase(lowerName)
const targetNames = this.buildPreferredDatNames(baseName, allowThumbnail)
const candidates: string[] = []
if (/^[a-f0-9]{32}$/.test(baseName)) {
const dir1 = baseName.substring(0, 2)
const dir2 = baseName.substring(2, 4)
candidates.push(
join(root, dir1, dir2, datName),
join(root, dir1, dir2, 'Img', datName),
join(root, dir1, dir2, 'mg', datName),
join(root, dir1, dir2, 'Image', datName)
)
for (const targetName of targetNames) {
candidates.push(
join(root, dir1, dir2, targetName),
join(root, dir1, dir2, 'Img', targetName),
join(root, dir1, dir2, 'mg', targetName),
join(root, dir1, dir2, 'Image', targetName)
)
}
}
for (const path of candidates) {
@@ -875,13 +895,6 @@ export class ImageDecryptService {
months.push(mStr)
}
const targetNames = [datName]
if (baseName !== lowerName) {
targetNames.push(`${baseName}.dat`)
targetNames.push(`${baseName}_t.dat`)
targetNames.push(`${baseName}_thumb.dat`)
}
const batchSize = 20
for (let i = 0; i < sessionDirs.length; i += batchSize) {
const batch = sessionDirs.slice(i, i + batchSize)
@@ -911,36 +924,13 @@ export class ImageDecryptService {
/**
* 在同一目录下查找高清图变体
* 缩略图 xxx_t.dat -> 高清图 xxx_h.dat 或 xxx.dat
* 优先 `_h`,再回退其他非缩略图变体
*/
private findHdVariantInSameDir(thumbPath: string): string | null {
try {
const dir = dirname(thumbPath)
const fileName = basename(thumbPath).toLowerCase()
// 提取基础名称(去掉 _t.dat 或 .t.dat
let baseName = fileName
if (baseName.endsWith('_t.dat')) {
baseName = baseName.slice(0, -6)
} else if (baseName.endsWith('.t.dat')) {
baseName = baseName.slice(0, -6)
} else {
return null
}
// 尝试查找高清图变体
const variants = [
`${baseName}_h.dat`,
`${baseName}.h.dat`,
`${baseName}.dat`
]
for (const variant of variants) {
const variantPath = join(dir, variant)
if (existsSync(variantPath)) {
return variantPath
}
}
const fileName = basename(thumbPath)
return this.findPreferredDatVariantInDir(dir, fileName, false)
} catch { }
return null
}
@@ -990,7 +980,86 @@ export class ImageDecryptService {
void worker.terminate()
resolve(null)
})
})
})
}
private stripDatVariantSuffix(base: string): string {
const lower = base.toLowerCase()
const suffixes = ['_thumb', '.thumb', '_hd', '.hd', '_h', '.h', '_t', '.t', '_c', '.c']
for (const suffix of suffixes) {
if (lower.endsWith(suffix)) {
return lower.slice(0, -suffix.length)
}
}
if (/[._][a-z]$/.test(lower)) {
return lower.slice(0, -2)
}
return lower
}
private getDatVariantPriority(name: string): number {
const lower = name.toLowerCase()
const baseLower = lower.endsWith('.dat') || lower.endsWith('.jpg') ? lower.slice(0, -4) : lower
if (baseLower.endsWith('_h') || baseLower.endsWith('.h')) return 600
if (!this.hasXVariant(baseLower)) return 500
if (baseLower.endsWith('_hd') || baseLower.endsWith('.hd')) return 450
if (baseLower.endsWith('_c') || baseLower.endsWith('.c')) return 400
if (this.isThumbnailDat(lower)) return 100
return 350
}
private buildPreferredDatNames(baseName: string, allowThumbnail: boolean): string[] {
if (!baseName) return []
const names = [
`${baseName}_h.dat`,
`${baseName}.h.dat`,
`${baseName}.dat`,
`${baseName}_hd.dat`,
`${baseName}.hd.dat`,
`${baseName}_c.dat`,
`${baseName}.c.dat`
]
if (allowThumbnail) {
names.push(
`${baseName}_thumb.dat`,
`${baseName}.thumb.dat`,
`${baseName}_t.dat`,
`${baseName}.t.dat`
)
}
return Array.from(new Set(names))
}
private findPreferredDatVariantInDir(dirPath: string, baseName: string, allowThumbnail: boolean): string | null {
let entries: string[]
try {
entries = readdirSync(dirPath)
} catch {
return null
}
const target = this.normalizeDatBase(baseName.toLowerCase())
let bestPath: string | null = null
let bestScore = Number.NEGATIVE_INFINITY
for (const entry of entries) {
const lower = entry.toLowerCase()
if (!lower.endsWith('.dat')) continue
if (!allowThumbnail && this.isThumbnailDat(lower)) continue
const baseLower = lower.slice(0, -4)
if (this.normalizeDatBase(baseLower) !== target) continue
const score = this.getDatVariantPriority(lower)
if (score > bestScore) {
bestScore = score
bestPath = join(dirPath, entry)
}
}
return bestPath
}
private getPreferredDatVariantPath(datPath: string, allowThumbnail: boolean): string {
const lower = datPath.toLowerCase()
if (!lower.endsWith('.dat')) return datPath
const preferred = this.findPreferredDatVariantInDir(dirname(datPath), basename(datPath), allowThumbnail)
return preferred || datPath
}
private normalizeDatBase(name: string): string {
@@ -998,18 +1067,21 @@ export class ImageDecryptService {
if (base.endsWith('.dat') || base.endsWith('.jpg')) {
base = base.slice(0, -4)
}
while (/[._][a-z]$/.test(base)) {
base = base.slice(0, -2)
for (;;) {
const stripped = this.stripDatVariantSuffix(base)
if (stripped === base) {
return base
}
base = stripped
}
return base
}
private hasImageVariantSuffix(baseLower: string): boolean {
return /[._][a-z]$/.test(baseLower)
return this.stripDatVariantSuffix(baseLower) !== baseLower
}
private isLikelyImageDatBase(baseLower: string): boolean {
return this.hasImageVariantSuffix(baseLower) || this.looksLikeMd5(baseLower)
return this.hasImageVariantSuffix(baseLower) || this.looksLikeMd5(this.normalizeDatBase(baseLower))
}
@@ -1198,24 +1270,7 @@ export class ImageDecryptService {
}
private findNonThumbnailVariantInDir(dirPath: string, baseName: string): string | null {
let entries: string[]
try {
entries = readdirSync(dirPath)
} catch {
return null
}
const target = this.normalizeDatBase(baseName.toLowerCase())
for (const entry of entries) {
const lower = entry.toLowerCase()
if (!lower.endsWith('.dat')) continue
if (this.isThumbnailDat(lower)) continue
const baseLower = lower.slice(0, -4)
// 只排除没有 _x 变体后缀的文件(允许 _hd、_h 等所有带变体的)
if (!this.hasXVariant(baseLower)) continue
if (this.normalizeDatBase(baseLower) !== target) continue
return join(dirPath, entry)
}
return null
return this.findPreferredDatVariantInDir(dirPath, baseName, false)
}
private isNonThumbnailVariantDat(datPath: string): boolean {
@@ -1223,8 +1278,7 @@ export class ImageDecryptService {
if (!lower.endsWith('.dat')) return false
if (this.isThumbnailDat(lower)) return false
const baseLower = lower.slice(0, -4)
// 只检查是否有 _x 变体后缀(允许 _hd、_h 等所有带变体的)
return this.hasXVariant(baseLower)
return this.isLikelyImageDatBase(baseLower)
}
private emitImageUpdate(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }, cacheKey: string): void {
@@ -1850,7 +1904,7 @@ export class ImageDecryptService {
private hasXVariant(base: string): boolean {
const lower = base.toLowerCase()
return lower.endsWith('_h') || lower.endsWith('_hd') || lower.endsWith('_thumb') || lower.endsWith('_t')
return this.stripDatVariantSuffix(lower) !== lower
}
private isHdPath(p: string): boolean {

View File

@@ -12,6 +12,7 @@ type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: stri
type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; error?: string }
export class KeyService {
private readonly isMac = process.platform === 'darwin'
private koffi: any = null
private lib: any = null
private initialized = false
@@ -509,6 +510,58 @@ export class KeyService {
return false
}
private isLoginRelatedText(value: string): boolean {
const normalized = String(value || '').replace(/\s+/g, '').toLowerCase()
if (!normalized) return false
const keywords = [
'登录',
'扫码',
'二维码',
'请在手机上确认',
'手机确认',
'切换账号',
'wechatlogin',
'qrcode',
'scan'
]
return keywords.some((keyword) => normalized.includes(keyword))
}
private async detectWeChatLoginRequired(pid: number): Promise<boolean> {
if (!this.ensureUser32()) return false
let loginRequired = false
const enumWindowsCallback = this.koffi.register((hWnd: any, _lParam: any) => {
if (!this.IsWindowVisible(hWnd)) return true
const title = this.getWindowTitle(hWnd)
if (!this.isWeChatWindowTitle(title)) return true
const pidBuf = Buffer.alloc(4)
this.GetWindowThreadProcessId(hWnd, pidBuf)
const windowPid = pidBuf.readUInt32LE(0)
if (windowPid !== pid) return true
if (this.isLoginRelatedText(title)) {
loginRequired = true
return false
}
const children = this.collectChildWindowInfos(hWnd)
for (const child of children) {
if (this.isLoginRelatedText(child.title) || this.isLoginRelatedText(child.className)) {
loginRequired = true
return false
}
}
return true
}, this.WNDENUMPROC_PTR)
this.EnumWindows(enumWindowsCallback, 0)
this.koffi.unregister(enumWindowsCallback)
return loginRequired
}
private async waitForWeChatWindowComponents(pid: number, timeoutMs = 15000): Promise<boolean> {
if (!this.ensureUser32()) return true
const startTime = Date.now()
@@ -605,6 +658,7 @@ export class KeyService {
const keyBuffer = Buffer.alloc(128)
const start = Date.now()
let loginRequiredDetected = false
try {
while (Date.now() - start < timeoutMs) {
@@ -624,6 +678,9 @@ export class KeyService {
const level = levelOut[0] ?? 0
if (msg) {
logs.push(msg)
if (this.isLoginRelatedText(msg)) {
loginRequiredDetected = true
}
onStatus?.(msg, level)
}
}
@@ -635,6 +692,15 @@ export class KeyService {
} catch { }
}
const loginRequired = loginRequiredDetected || await this.detectWeChatLoginRequired(pid)
if (loginRequired) {
return {
success: false,
error: '微信已启动但尚未完成登录,请先在微信客户端完成登录后再重试自动获取密钥。',
logs
}
}
return { success: false, error: '获取密钥超时', logs }
}
@@ -649,9 +715,72 @@ export class KeyService {
return wxid.substring(0, second)
}
private deriveImageKeys(code: number, wxid: string): { xorKey: number; aesKey: string } {
const cleanedWxid = this.cleanWxid(wxid)
const xorKey = code & 0xFF
const dataToHash = code.toString() + cleanedWxid
const md5Full = crypto.createHash('md5').update(dataToHash).digest('hex')
const aesKey = md5Full.substring(0, 16)
return { xorKey, aesKey }
}
private verifyDerivedAesKey(aesKey: string, ciphertext: Buffer): boolean {
try {
if (!aesKey || aesKey.length < 16 || ciphertext.length !== 16) return false
const decipher = crypto.createDecipheriv('aes-128-ecb', Buffer.from(aesKey, 'ascii').subarray(0, 16), null)
decipher.setAutoPadding(false)
const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()])
if (dec[0] === 0xFF && dec[1] === 0xD8 && dec[2] === 0xFF) return true
if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4E && dec[3] === 0x47) return true
if (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) return true
if (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true
if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true
return false
} catch {
return false
}
}
private async collectWxidCandidates(manualDir?: string, wxidParam?: string): Promise<string[]> {
const candidates: string[] = []
const pushUnique = (value: string) => {
const v = String(value || '').trim()
if (!v || candidates.includes(v)) return
candidates.push(v)
}
if (wxidParam && wxidParam.startsWith('wxid_')) pushUnique(wxidParam)
if (manualDir) {
const normalized = manualDir.replace(/[\\/]+$/, '')
const dirName = normalized.split(/[\\/]/).pop() ?? ''
if (dirName.startsWith('wxid_')) pushUnique(dirName)
const marker = normalized.match(/[\\/]xwechat_files/i) || normalized.match(/[\\/]WeChat Files/i)
if (marker) {
const root = normalized.slice(0, marker.index! + marker[0].length)
try {
const { readdirSync, statSync } = await import('fs')
const { join } = await import('path')
for (const entry of readdirSync(root)) {
if (!entry.startsWith('wxid_')) continue
const full = join(root, entry)
try {
if (statSync(full).isDirectory()) pushUnique(entry)
} catch { }
}
} catch { }
}
}
pushUnique('unknown')
return candidates
}
async autoGetImageKey(
manualDir?: string,
onProgress?: (message: string) => void
onProgress?: (message: string) => void,
wxidParam?: string
): Promise<ImageKeyResult> {
if (!this.ensureWin32()) return { success: false, error: '仅支持 Windows' }
if (!this.ensureLoaded()) return { success: false, error: 'wx_key.dll 未加载' }
@@ -683,42 +812,295 @@ export class KeyService {
const codes: number[] = accounts[0].keys.map((k: any) => k.code)
console.log('[ImageKey] codes:', codes, 'DLL wxids:', accounts.map((a: any) => a.wxid))
// 从 manualDir 提取前端已配置好的正确 wxid
// 格式: "D:\weixin\xwechat_files\wxid_xxx_1234" → "wxid_xxx_1234"
let targetWxid = ''
if (manualDir) {
const dirName = manualDir.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? ''
if (dirName.startsWith('wxid_')) {
targetWxid = dirName
const wxidCandidates = await this.collectWxidCandidates(manualDir, wxidParam)
let verifyCiphertext: Buffer | null = null
if (manualDir && existsSync(manualDir)) {
const template = await this._findTemplateData(manualDir, 32)
verifyCiphertext = template.ciphertext
}
if (verifyCiphertext) {
onProgress?.(`正在校验候选 wxid${wxidCandidates.length} 个)...`)
for (const candidateWxid of wxidCandidates) {
for (const code of codes) {
const { xorKey, aesKey } = this.deriveImageKeys(code, candidateWxid)
if (!this.verifyDerivedAesKey(aesKey, verifyCiphertext)) continue
onProgress?.(`密钥获取成功 (wxid: ${candidateWxid}, code: ${code})`)
console.log('[ImageKey] 校验命中: wxid=', candidateWxid, 'code=', code)
return { success: true, xorKey, aesKey }
}
}
return { success: false, error: '缓存 code 与当前账号 wxid 未匹配,请确认账号目录后重试,或使用内存扫描' }
}
if (!targetWxid) {
// 无法从 manualDir 提取 wxid回退到 DLL 发现的第一个
targetWxid = accounts[0].wxid
console.log('[ImageKey] 无法从 manualDir 提取 wxid使用 DLL 发现的:', targetWxid)
}
// 无模板密文可验真时回退旧策略
const fallbackWxid = wxidCandidates[0] || accounts[0].wxid || 'unknown'
const fallbackCode = codes[0]
const { xorKey, aesKey } = this.deriveImageKeys(fallbackCode, fallbackWxid)
onProgress?.(`密钥获取成功 (wxid: ${fallbackWxid}, code: ${fallbackCode})`)
console.log('[ImageKey] 回退计算: wxid=', fallbackWxid, 'code=', fallbackCode)
return { success: true, xorKey, aesKey }
}
// CleanWxid: 截断到第二个下划线,与 xkey 算法一致
const cleanedWxid = this.cleanWxid(targetWxid)
console.log('[ImageKey] wxid:', targetWxid, '→ cleaned:', cleanedWxid)
// --- 内存扫描备选方案(融合 Dart+Python 优点)---
// 只扫 RW 可写区域(更快),同时支持 ASCII 和 UTF-16LE 两种密钥格式
// 验证支持 JPEG/PNG/WEBP/WXGF/GIF 多种格式
// 用 cleanedWxid + code 本地计算密钥
// xorKey = code & 0xFF
// aesKey = MD5(code.toString() + cleanedWxid).substring(0, 16)
const code = codes[0]
const xorKey = code & 0xFF
const dataToHash = code.toString() + cleanedWxid
const md5Full = crypto.createHash('md5').update(dataToHash).digest('hex')
const aesKey = md5Full.substring(0, 16)
async autoGetImageKeyByMemoryScan(
userDir: string,
onProgress?: (message: string) => void
): Promise<ImageKeyResult> {
if (!this.ensureWin32()) return { success: false, error: '仅支持 Windows' }
onProgress?.(`密钥获取成功 (wxid: ${targetWxid}, code: ${code})`)
console.log('[ImageKey] 计算结果: xorKey=', xorKey, 'aesKey=', aesKey)
try {
// 1. 查找模板文件获取密文和 XOR 密钥
onProgress?.('正在查找模板文件...')
let result = await this._findTemplateData(userDir, 32)
let { ciphertext, xorKey } = result
// 如果找不到密钥,尝试扫描更多文件
if (ciphertext && xorKey === null) {
onProgress?.('未找到有效密钥,尝试扫描更多文件...')
result = await this._findTemplateData(userDir, 100)
xorKey = result.xorKey
}
if (!ciphertext) return { success: false, error: '未找到 V2 模板文件,请先在微信中查看几张图片' }
if (xorKey === null) return { success: false, error: '未能从模板文件中计算出有效的 XOR 密钥,请确保在微信中查看了多张不同的图片' }
return {
success: true,
xorKey,
aesKey
onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`)
// 2. 找微信 PID
const pid = await this.findWeChatPid()
if (!pid) return { success: false, error: '微信进程未运行,请先启动微信' }
onProgress?.(`已找到微信进程 PID=${pid},正在扫描内存...`)
// 3. 持续轮询内存扫描,最多 60 秒
const deadline = Date.now() + 60_000
let scanCount = 0
while (Date.now() < deadline) {
scanCount++
onProgress?.(`${scanCount} 次扫描内存,请在微信中打开图片大图...`)
const aesKey = await this._scanMemoryForAesKey(pid, ciphertext, onProgress)
if (aesKey) {
onProgress?.('密钥获取成功')
return { success: true, xorKey, aesKey }
}
// 等 5 秒再试
await new Promise(r => setTimeout(r, 5000))
}
return {
success: false,
error: '60 秒内未找到 AES 密钥。\n请确保已在微信中打开 2-3 张图片大图后再试。'
}
} catch (e) {
return { success: false, error: `内存扫描失败: ${e}` }
}
}
}
private async _findTemplateData(userDir: string, limit: number = 32): Promise<{ ciphertext: Buffer | null; xorKey: number | null }> {
const { readdirSync, readFileSync, statSync } = await import('fs')
const { join } = await import('path')
const V2_MAGIC = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07])
// 递归收集 *_t.dat 文件
const collect = (dir: string, results: string[], maxFiles: number) => {
if (results.length >= maxFiles) return
try {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (results.length >= maxFiles) break
const full = join(dir, entry.name)
if (entry.isDirectory()) collect(full, results, maxFiles)
else if (entry.isFile() && entry.name.endsWith('_t.dat')) results.push(full)
}
} catch { /* 忽略无权限目录 */ }
}
const files: string[] = []
collect(userDir, files, limit)
// 按修改时间降序
files.sort((a, b) => {
try { return statSync(b).mtimeMs - statSync(a).mtimeMs } catch { return 0 }
})
let ciphertext: Buffer | null = null
const tailCounts: Record<string, number> = {}
for (const f of files.slice(0, 32)) {
try {
const data = readFileSync(f)
if (data.length < 8) continue
// 统计末尾两字节用于 XOR 密钥
if (data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 2) {
const key = `${data[data.length - 2]}_${data[data.length - 1]}`
tailCounts[key] = (tailCounts[key] ?? 0) + 1
}
// 提取密文(取第一个有效的)
if (!ciphertext && data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 0x1F) {
ciphertext = data.subarray(0xF, 0x1F)
}
} catch { /* 忽略 */ }
}
// 计算 XOR 密钥
let xorKey: number | null = null
let maxCount = 0
for (const [key, count] of Object.entries(tailCounts)) {
if (count > maxCount) { maxCount = count; const [x, y] = key.split('_').map(Number); const k = x ^ 0xFF; if (k === (y ^ 0xD9)) xorKey = k }
}
return { ciphertext, xorKey }
}
private async _scanMemoryForAesKey(
pid: number,
ciphertext: Buffer,
onProgress?: (msg: string) => void
): Promise<string | null> {
if (!this.ensureKernel32()) return null
// 直接用已加载的 kernel32 实例,用 uintptr 传地址
const VirtualQueryEx = this.kernel32.func('VirtualQueryEx', 'size_t', ['void*', 'uintptr', 'void*', 'size_t'])
const ReadProcessMemory = this.kernel32.func('ReadProcessMemory', 'bool', ['void*', 'uintptr', 'void*', 'size_t', this.koffi.out('size_t*')])
// RW 保护标志(只扫可写区域,速度更快)
const RW_FLAGS = 0x04 | 0x08 | 0x40 | 0x80 // PAGE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY
const MEM_COMMIT = 0x1000
const PAGE_NOACCESS = 0x01
const PAGE_GUARD = 0x100
const MBI_SIZE = 48 // MEMORY_BASIC_INFORMATION size on x64
const hProcess = this.OpenProcess(0x1F0FFF, false, pid)
if (!hProcess) return null
try {
// 枚举 RW 内存区域
const regions: Array<[number, number]> = []
let addr = 0
const mbi = Buffer.alloc(MBI_SIZE)
while (addr < 0x7FFFFFFFFFFF) {
const ret = VirtualQueryEx(hProcess, addr, mbi, MBI_SIZE)
if (ret === 0) break
// MEMORY_BASIC_INFORMATION x64 布局:
// 0: BaseAddress (8)
// 8: AllocationBase (8)
// 16: AllocationProtect (4) + 4 padding
// 24: RegionSize (8)
// 32: State (4)
// 36: Protect (4)
// 40: Type (4) + 4 padding = 48 total
const base = Number(mbi.readBigUInt64LE(0))
const size = Number(mbi.readBigUInt64LE(24))
const state = mbi.readUInt32LE(32)
const protect = mbi.readUInt32LE(36)
if (state === MEM_COMMIT &&
protect !== PAGE_NOACCESS &&
(protect & PAGE_GUARD) === 0 &&
(protect & RW_FLAGS) !== 0 &&
size <= 50 * 1024 * 1024) {
regions.push([base, size])
}
const next = base + size
if (next <= addr) break
addr = next
}
const totalMB = regions.reduce((s, [, sz]) => s + sz, 0) / 1024 / 1024
onProgress?.(`扫描 ${regions.length} 个 RW 区域 (${totalMB.toFixed(0)} MB)...`)
const CHUNK = 4 * 1024 * 1024
const OVERLAP = 65
for (let i = 0; i < regions.length; i++) {
const [base, size] = regions[i]
if (i % 20 === 0) {
onProgress?.(`扫描进度 ${i}/${regions.length}...`)
await new Promise(r => setTimeout(r, 1)) // 让出事件循环
}
let offset = 0
let trailing: Buffer | null = null
while (offset < size) {
const chunkSize = Math.min(CHUNK, size - offset)
const buf = Buffer.alloc(chunkSize)
const bytesReadOut = [0]
const ok = ReadProcessMemory(hProcess, base + offset, buf, chunkSize, bytesReadOut)
if (!ok || bytesReadOut[0] === 0) { offset += chunkSize; trailing = null; continue }
const data: Buffer = trailing ? Buffer.concat([trailing, buf.subarray(0, bytesReadOut[0])]) : buf.subarray(0, bytesReadOut[0])
// 搜索 ASCII 32字节密钥
const key = this._searchAsciiKey(data, ciphertext)
if (key) { this.CloseHandle(hProcess); return key }
// 搜索 UTF-16LE 32字节密钥
const key16 = this._searchUtf16Key(data, ciphertext)
if (key16) { this.CloseHandle(hProcess); return key16 }
trailing = data.subarray(Math.max(0, data.length - OVERLAP))
offset += chunkSize
}
}
return null
} finally {
this.CloseHandle(hProcess)
}
}
private _searchAsciiKey(data: Buffer, ciphertext: Buffer): string | null {
for (let i = 0; i < data.length - 34; i++) {
if (this._isAlphaNum(data[i])) continue
let valid = true
for (let j = 1; j <= 32; j++) {
if (!this._isAlphaNum(data[i + j])) { valid = false; break }
}
if (!valid) continue
if (i + 33 < data.length && this._isAlphaNum(data[i + 33])) continue
const keyBytes = data.subarray(i + 1, i + 33)
if (this._verifyAesKey(keyBytes, ciphertext)) return keyBytes.toString('ascii').substring(0, 16)
}
return null
}
private _searchUtf16Key(data: Buffer, ciphertext: Buffer): string | null {
for (let i = 0; i < data.length - 65; i++) {
let valid = true
for (let j = 0; j < 32; j++) {
if (data[i + j * 2 + 1] !== 0x00 || !this._isAlphaNum(data[i + j * 2])) { valid = false; break }
}
if (!valid) continue
const keyBytes = Buffer.alloc(32)
for (let j = 0; j < 32; j++) keyBytes[j] = data[i + j * 2]
if (this._verifyAesKey(keyBytes, ciphertext)) return keyBytes.toString('ascii').substring(0, 16)
}
return null
}
private _isAlphaNum(b: number): boolean {
return (b >= 0x61 && b <= 0x7A) || (b >= 0x41 && b <= 0x5A) || (b >= 0x30 && b <= 0x39)
}
private _verifyAesKey(keyBytes: Buffer, ciphertext: Buffer): boolean {
try {
const decipher = crypto.createDecipheriv('aes-128-ecb', keyBytes.subarray(0, 16), null)
decipher.setAutoPadding(false)
const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()])
// 支持 JPEG / PNG / WEBP / WXGF / GIF
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 }
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,293 @@
import { join, dirname } from 'path'
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
import { ConfigService } from './config'
const CACHE_VERSION = 2
const MAX_SESSION_ENTRIES_PER_SCOPE = 2000
const MAX_SCOPE_ENTRIES = 12
export interface SessionStatsCacheStats {
totalMessages: number
voiceMessages: number
imageMessages: number
videoMessages: number
emojiMessages: number
transferMessages: number
redPacketMessages: number
callMessages: number
firstTimestamp?: number
lastTimestamp?: number
privateMutualGroups?: number
groupMemberCount?: number
groupMyMessages?: number
groupActiveSpeakers?: number
groupMutualFriends?: number
}
export interface SessionStatsCacheEntry {
updatedAt: number
includeRelations: boolean
stats: SessionStatsCacheStats
}
interface SessionStatsScopeMap {
[sessionId: string]: SessionStatsCacheEntry
}
interface SessionStatsCacheStore {
version: number
scopes: Record<string, SessionStatsScopeMap>
}
function toNonNegativeInt(value: unknown): number | undefined {
if (typeof value !== 'number' || !Number.isFinite(value)) return undefined
return Math.max(0, Math.floor(value))
}
function normalizeStats(raw: unknown): SessionStatsCacheStats | null {
if (!raw || typeof raw !== 'object') return null
const source = raw as Record<string, unknown>
const totalMessages = toNonNegativeInt(source.totalMessages)
const voiceMessages = toNonNegativeInt(source.voiceMessages)
const imageMessages = toNonNegativeInt(source.imageMessages)
const videoMessages = toNonNegativeInt(source.videoMessages)
const emojiMessages = toNonNegativeInt(source.emojiMessages)
const transferMessages = toNonNegativeInt(source.transferMessages)
const redPacketMessages = toNonNegativeInt(source.redPacketMessages)
const callMessages = toNonNegativeInt(source.callMessages)
if (
totalMessages === undefined ||
voiceMessages === undefined ||
imageMessages === undefined ||
videoMessages === undefined ||
emojiMessages === undefined ||
transferMessages === undefined ||
redPacketMessages === undefined ||
callMessages === undefined
) {
return null
}
const normalized: SessionStatsCacheStats = {
totalMessages,
voiceMessages,
imageMessages,
videoMessages,
emojiMessages,
transferMessages,
redPacketMessages,
callMessages
}
const firstTimestamp = toNonNegativeInt(source.firstTimestamp)
if (firstTimestamp !== undefined) normalized.firstTimestamp = firstTimestamp
const lastTimestamp = toNonNegativeInt(source.lastTimestamp)
if (lastTimestamp !== undefined) normalized.lastTimestamp = lastTimestamp
const privateMutualGroups = toNonNegativeInt(source.privateMutualGroups)
if (privateMutualGroups !== undefined) normalized.privateMutualGroups = privateMutualGroups
const groupMemberCount = toNonNegativeInt(source.groupMemberCount)
if (groupMemberCount !== undefined) normalized.groupMemberCount = groupMemberCount
const groupMyMessages = toNonNegativeInt(source.groupMyMessages)
if (groupMyMessages !== undefined) normalized.groupMyMessages = groupMyMessages
const groupActiveSpeakers = toNonNegativeInt(source.groupActiveSpeakers)
if (groupActiveSpeakers !== undefined) normalized.groupActiveSpeakers = groupActiveSpeakers
const groupMutualFriends = toNonNegativeInt(source.groupMutualFriends)
if (groupMutualFriends !== undefined) normalized.groupMutualFriends = groupMutualFriends
return normalized
}
function normalizeEntry(raw: unknown): SessionStatsCacheEntry | null {
if (!raw || typeof raw !== 'object') return null
const source = raw as Record<string, unknown>
const updatedAt = toNonNegativeInt(source.updatedAt)
const includeRelations = typeof source.includeRelations === 'boolean' ? source.includeRelations : false
const stats = normalizeStats(source.stats)
if (updatedAt === undefined || !stats) {
return null
}
return {
updatedAt,
includeRelations,
stats
}
}
export class SessionStatsCacheService {
private readonly cacheFilePath: string
private store: SessionStatsCacheStore = {
version: CACHE_VERSION,
scopes: {}
}
constructor(cacheBasePath?: string) {
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
? cacheBasePath
: ConfigService.getInstance().getCacheBasePath()
this.cacheFilePath = join(basePath, 'session-stats.json')
this.ensureCacheDir()
this.load()
}
private ensureCacheDir(): void {
const dir = dirname(this.cacheFilePath)
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
}
private load(): void {
if (!existsSync(this.cacheFilePath)) return
try {
const raw = readFileSync(this.cacheFilePath, 'utf8')
const parsed = JSON.parse(raw) as unknown
if (!parsed || typeof parsed !== 'object') {
this.store = { version: CACHE_VERSION, scopes: {} }
return
}
const payload = parsed as Record<string, unknown>
const version = Number(payload.version)
if (!Number.isFinite(version) || version !== CACHE_VERSION) {
this.store = { version: CACHE_VERSION, scopes: {} }
return
}
const scopesRaw = payload.scopes
if (!scopesRaw || typeof scopesRaw !== 'object') {
this.store = { version: CACHE_VERSION, scopes: {} }
return
}
const scopes: Record<string, SessionStatsScopeMap> = {}
for (const [scopeKey, scopeValue] of Object.entries(scopesRaw as Record<string, unknown>)) {
if (!scopeValue || typeof scopeValue !== 'object') continue
const normalizedScope: SessionStatsScopeMap = {}
for (const [sessionId, entryRaw] of Object.entries(scopeValue as Record<string, unknown>)) {
const entry = normalizeEntry(entryRaw)
if (!entry) continue
normalizedScope[sessionId] = entry
}
if (Object.keys(normalizedScope).length > 0) {
scopes[scopeKey] = normalizedScope
}
}
this.store = {
version: CACHE_VERSION,
scopes
}
} catch (error) {
console.error('SessionStatsCacheService: 载入缓存失败', error)
this.store = { version: CACHE_VERSION, scopes: {} }
}
}
get(scopeKey: string, sessionId: string): SessionStatsCacheEntry | undefined {
if (!scopeKey || !sessionId) return undefined
const scope = this.store.scopes[scopeKey]
if (!scope) return undefined
const entry = normalizeEntry(scope[sessionId])
if (!entry) {
delete scope[sessionId]
if (Object.keys(scope).length === 0) {
delete this.store.scopes[scopeKey]
}
this.persist()
return undefined
}
return entry
}
set(scopeKey: string, sessionId: string, entry: SessionStatsCacheEntry): void {
if (!scopeKey || !sessionId) return
const normalized = normalizeEntry(entry)
if (!normalized) return
if (!this.store.scopes[scopeKey]) {
this.store.scopes[scopeKey] = {}
}
this.store.scopes[scopeKey][sessionId] = normalized
this.trimScope(scopeKey)
this.trimScopes()
this.persist()
}
delete(scopeKey: string, sessionId: string): void {
if (!scopeKey || !sessionId) return
const scope = this.store.scopes[scopeKey]
if (!scope) return
if (!(sessionId in scope)) return
delete scope[sessionId]
if (Object.keys(scope).length === 0) {
delete this.store.scopes[scopeKey]
}
this.persist()
}
clearScope(scopeKey: string): void {
if (!scopeKey) return
if (!this.store.scopes[scopeKey]) return
delete this.store.scopes[scopeKey]
this.persist()
}
clearAll(): void {
this.store = { version: CACHE_VERSION, scopes: {} }
try {
rmSync(this.cacheFilePath, { force: true })
} catch (error) {
console.error('SessionStatsCacheService: 清理缓存失败', error)
}
}
private trimScope(scopeKey: string): void {
const scope = this.store.scopes[scopeKey]
if (!scope) return
const entries = Object.entries(scope)
if (entries.length <= MAX_SESSION_ENTRIES_PER_SCOPE) return
entries.sort((a, b) => b[1].updatedAt - a[1].updatedAt)
const trimmed: SessionStatsScopeMap = {}
for (const [sessionId, entry] of entries.slice(0, MAX_SESSION_ENTRIES_PER_SCOPE)) {
trimmed[sessionId] = entry
}
this.store.scopes[scopeKey] = trimmed
}
private trimScopes(): void {
const scopeEntries = Object.entries(this.store.scopes)
if (scopeEntries.length <= MAX_SCOPE_ENTRIES) return
scopeEntries.sort((a, b) => {
const aUpdatedAt = Math.max(...Object.values(a[1]).map((entry) => entry.updatedAt), 0)
const bUpdatedAt = Math.max(...Object.values(b[1]).map((entry) => entry.updatedAt), 0)
return bUpdatedAt - aUpdatedAt
})
const trimmedScopes: Record<string, SessionStatsScopeMap> = {}
for (const [scopeKey, scopeMap] of scopeEntries.slice(0, MAX_SCOPE_ENTRIES)) {
trimmedScopes[scopeKey] = scopeMap
}
this.store.scopes = trimmedScopes
}
private persist(): void {
try {
writeFileSync(this.cacheFilePath, JSON.stringify(this.store), 'utf8')
} catch (error) {
console.error('SessionStatsCacheService: 保存缓存失败', error)
}
}
}

View File

@@ -44,6 +44,68 @@ export interface SnsPost {
linkUrl?: string
}
interface SnsContactIdentity {
username: string
wxid: string
alias?: string
wechatId?: string
remark?: string
nickName?: string
displayName: string
}
interface ParsedLikeUser {
username?: string
nickname?: string
}
interface ParsedCommentItem {
id: string
nickname: string
username?: string
content: string
refCommentId: string
refUsername?: string
refNickname?: string
emojis?: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[]
}
interface ArkmeLikeDetail {
nickname: string
username?: string
wxid?: string
alias?: string
wechatId?: string
remark?: string
nickName?: string
displayName: string
source: 'xml' | 'legacy'
}
interface ArkmeCommentDetail {
id: string
nickname: string
username?: string
wxid?: string
alias?: string
wechatId?: string
remark?: string
nickName?: string
displayName: string
content: string
refCommentId: string
refNickname?: string
refUsername?: string
refWxid?: string
refAlias?: string
refWechatId?: string
refRemark?: string
refNickName?: string
refDisplayName?: string
emojis?: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[]
source: 'xml' | 'legacy'
}
const fixSnsUrl = (url: string, token?: string, isVideo: boolean = false) => {
@@ -127,7 +189,7 @@ const extractVideoKey = (xml: string): string | undefined => {
/**
* 从 XML 中解析评论信息(含表情包、回复关系)
*/
function parseCommentsFromXml(xml: string): { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string; emojis?: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[] }[] {
function parseCommentsFromXml(xml: string): ParsedCommentItem[] {
if (!xml) return []
type CommentItem = {
@@ -229,12 +291,264 @@ class SnsService {
private configService: ConfigService
private contactCache: ContactCacheService
private imageCache = new Map<string, string>()
private exportStatsCache: { totalPosts: number; totalFriends: number; myPosts: number | null; updatedAt: number } | null = null
private userPostCountsCache: { counts: Record<string, number>; updatedAt: number } | null = null
private readonly exportStatsCacheTtlMs = 5 * 60 * 1000
private readonly userPostCountsCacheTtlMs = 5 * 60 * 1000
private lastTimelineFallbackAt = 0
private readonly timelineFallbackCooldownMs = 3 * 60 * 1000
constructor() {
this.configService = new ConfigService()
this.contactCache = new ContactCacheService(this.configService.get('cachePath') as string)
}
private toOptionalString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : undefined
}
private async resolveContactIdentity(
username: string,
identityCache: Map<string, Promise<SnsContactIdentity | null>>
): Promise<SnsContactIdentity | null> {
const normalized = String(username || '').trim()
if (!normalized) return null
let pending = identityCache.get(normalized)
if (!pending) {
pending = (async () => {
const cached = this.contactCache.get(normalized)
let alias: string | undefined
let remark: string | undefined
let nickName: string | undefined
try {
const contactResult = await wcdbService.getContact(normalized)
if (contactResult.success && contactResult.contact) {
const contact = contactResult.contact
alias = this.toOptionalString(contact.alias ?? contact.Alias)
remark = this.toOptionalString(contact.remark ?? contact.Remark)
nickName = this.toOptionalString(contact.nickName ?? contact.nick_name ?? contact.nickname ?? contact.NickName)
}
} catch {
// 联系人补全失败不影响导出
}
const displayName = remark || nickName || alias || cached?.displayName || normalized
return {
username: normalized,
wxid: normalized,
alias,
wechatId: alias,
remark,
nickName,
displayName
}
})()
identityCache.set(normalized, pending)
}
return pending
}
private parseLikeUsersFromXml(xml: string): ParsedLikeUser[] {
if (!xml) return []
const likes: ParsedLikeUser[] = []
try {
let likeListMatch = xml.match(/<LikeUserList>([\s\S]*?)<\/LikeUserList>/i)
if (!likeListMatch) likeListMatch = xml.match(/<likeUserList>([\s\S]*?)<\/likeUserList>/i)
if (!likeListMatch) likeListMatch = xml.match(/<likeList>([\s\S]*?)<\/likeList>/i)
if (!likeListMatch) likeListMatch = xml.match(/<like_user_list>([\s\S]*?)<\/like_user_list>/i)
if (!likeListMatch) return likes
const likeUserRegex = /<(?:LikeUser|likeUser|user_comment)>([\s\S]*?)<\/(?:LikeUser|likeUser|user_comment)>/gi
let m: RegExpExecArray | null
while ((m = likeUserRegex.exec(likeListMatch[1])) !== null) {
const block = m[1]
const username = this.toOptionalString(block.match(/<username>([^<]*)<\/username>/i)?.[1])
const nickname = this.toOptionalString(
block.match(/<nickname>([^<]*)<\/nickname>/i)?.[1]
|| block.match(/<nickName>([^<]*)<\/nickName>/i)?.[1]
)
if (username || nickname) {
likes.push({ username, nickname })
}
}
} catch (e) {
console.error('[SnsService] 解析点赞用户失败:', e)
}
return likes
}
private async buildArkmeInteractionDetails(
post: SnsPost,
identityCache: Map<string, Promise<SnsContactIdentity | null>>
): Promise<{ likesDetail: ArkmeLikeDetail[]; commentsDetail: ArkmeCommentDetail[] }> {
const xmlLikes = this.parseLikeUsersFromXml(post.rawXml || '')
const likeCandidates: ParsedLikeUser[] = xmlLikes.length > 0
? xmlLikes
: (post.likes || []).map((nickname) => ({ nickname }))
const likeSource: 'xml' | 'legacy' = xmlLikes.length > 0 ? 'xml' : 'legacy'
const likesDetail: ArkmeLikeDetail[] = []
const likeSeen = new Set<string>()
for (const like of likeCandidates) {
const identity = like.username
? await this.resolveContactIdentity(like.username, identityCache)
: null
const nickname = like.nickname || identity?.displayName || like.username || ''
const username = identity?.username || like.username
const key = `${username || ''}|${nickname}`
if (likeSeen.has(key)) continue
likeSeen.add(key)
likesDetail.push({
nickname,
username,
wxid: username,
alias: identity?.alias,
wechatId: identity?.wechatId,
remark: identity?.remark,
nickName: identity?.nickName,
displayName: identity?.displayName || nickname || username || '',
source: likeSource
})
}
const xmlComments = parseCommentsFromXml(post.rawXml || '')
const commentMap = new Map<string, SnsPost['comments'][number]>()
for (const comment of post.comments || []) {
if (comment.id) commentMap.set(comment.id, comment)
}
const commentsBase: ParsedCommentItem[] = xmlComments.length > 0
? xmlComments.map((comment) => {
const fallback = comment.id ? commentMap.get(comment.id) : undefined
return {
id: comment.id || fallback?.id || '',
nickname: comment.nickname || fallback?.nickname || '',
username: comment.username,
content: comment.content || fallback?.content || '',
refCommentId: comment.refCommentId || fallback?.refCommentId || '',
refUsername: comment.refUsername,
refNickname: comment.refNickname || fallback?.refNickname,
emojis: comment.emojis && comment.emojis.length > 0 ? comment.emojis : fallback?.emojis
}
})
: (post.comments || []).map((comment) => ({
id: comment.id || '',
nickname: comment.nickname || '',
content: comment.content || '',
refCommentId: comment.refCommentId || '',
refNickname: comment.refNickname,
emojis: comment.emojis
}))
if (xmlComments.length > 0) {
const mappedIds = new Set(commentsBase.map((comment) => comment.id).filter(Boolean))
for (const comment of post.comments || []) {
if (comment.id && mappedIds.has(comment.id)) continue
commentsBase.push({
id: comment.id || '',
nickname: comment.nickname || '',
content: comment.content || '',
refCommentId: comment.refCommentId || '',
refNickname: comment.refNickname,
emojis: comment.emojis
})
}
}
const commentSource: 'xml' | 'legacy' = xmlComments.length > 0 ? 'xml' : 'legacy'
const commentsDetail: ArkmeCommentDetail[] = []
for (const comment of commentsBase) {
const actor = comment.username
? await this.resolveContactIdentity(comment.username, identityCache)
: null
const refActor = comment.refUsername
? await this.resolveContactIdentity(comment.refUsername, identityCache)
: null
const nickname = comment.nickname || actor?.displayName || comment.username || ''
const username = actor?.username || comment.username
const refUsername = refActor?.username || comment.refUsername
commentsDetail.push({
id: comment.id || '',
nickname,
username,
wxid: username,
alias: actor?.alias,
wechatId: actor?.wechatId,
remark: actor?.remark,
nickName: actor?.nickName,
displayName: actor?.displayName || nickname || username || '',
content: comment.content || '',
refCommentId: comment.refCommentId || '',
refNickname: comment.refNickname || refActor?.displayName,
refUsername,
refWxid: refUsername,
refAlias: refActor?.alias,
refWechatId: refActor?.wechatId,
refRemark: refActor?.remark,
refNickName: refActor?.nickName,
refDisplayName: refActor?.displayName,
emojis: comment.emojis,
source: commentSource
})
}
return { likesDetail, commentsDetail }
}
private parseCountValue(row: any): number {
if (!row || typeof row !== 'object') return 0
const raw = row.total ?? row.count ?? row.cnt ?? Object.values(row)[0]
const num = Number(raw)
return Number.isFinite(num) && num > 0 ? Math.floor(num) : 0
}
private pickTimelineUsername(post: any): string {
const raw = post?.username ?? post?.user_name ?? post?.userName ?? ''
if (typeof raw !== 'string') return ''
return raw.trim()
}
private async getExportStatsFromTimeline(myWxid?: string): Promise<{ totalPosts: number; totalFriends: number; myPosts: number | null }> {
const pageSize = 500
const uniqueUsers = new Set<string>()
let totalPosts = 0
let myPosts = 0
let offset = 0
const normalizedMyWxid = this.toOptionalString(myWxid)
for (let round = 0; round < 2000; round++) {
const result = await wcdbService.getSnsTimeline(pageSize, offset, undefined, undefined, 0, 0)
if (!result.success || !Array.isArray(result.timeline)) {
throw new Error(result.error || '获取朋友圈统计失败')
}
const rows = result.timeline
if (rows.length === 0) break
totalPosts += rows.length
for (const row of rows) {
const username = this.pickTimelineUsername(row)
if (username) uniqueUsers.add(username)
if (normalizedMyWxid && username === normalizedMyWxid) myPosts += 1
}
if (rows.length < pageSize) break
offset += rows.length
}
return {
totalPosts,
totalFriends: uniqueUsers.size,
myPosts: normalizedMyWxid ? myPosts : null
}
}
private parseLikesFromXml(xml: string): string[] {
if (!xml) return []
const likes: string[] = []
@@ -349,14 +663,285 @@ class SnsService {
}
async getSnsUsernames(): Promise<{ success: boolean; usernames?: string[]; error?: string }> {
const result = await wcdbService.execQuery('sns', null, 'SELECT DISTINCT user_name FROM SnsTimeLine')
if (!result.success || !result.rows) {
// 尝试 userName 列名
const result2 = await wcdbService.execQuery('sns', null, 'SELECT DISTINCT userName FROM SnsTimeLine')
if (!result2.success || !result2.rows) return { success: false, error: result.error || result2.error }
return { success: true, usernames: result2.rows.map((r: any) => r.userName).filter(Boolean) }
const collect = (rows?: any[]): string[] => {
if (!Array.isArray(rows)) return []
const usernames: string[] = []
for (const row of rows) {
const raw = row?.user_name ?? row?.userName ?? row?.username ?? Object.values(row || {})[0]
const username = typeof raw === 'string' ? raw.trim() : String(raw || '').trim()
if (username) usernames.push(username)
}
return usernames
}
return { success: true, usernames: result.rows.map((r: any) => r.user_name).filter(Boolean) }
const primary = await wcdbService.execQuery(
'sns',
null,
"SELECT DISTINCT user_name FROM SnsTimeLine WHERE user_name IS NOT NULL AND user_name <> ''"
)
const fallback = await wcdbService.execQuery(
'sns',
null,
"SELECT DISTINCT userName FROM SnsTimeLine WHERE userName IS NOT NULL AND userName <> ''"
)
const merged = Array.from(new Set([
...collect(primary.rows),
...collect(fallback.rows)
]))
// 任一查询成功且拿到用户名即视为成功,避免因为列名差异导致误判为空。
if (merged.length > 0) {
return { success: true, usernames: merged }
}
// 两条查询都成功但无数据,说明确实没有朋友圈发布者。
if (primary.success || fallback.success) {
return { success: true, usernames: [] }
}
return { success: false, error: primary.error || fallback.error || '获取朋友圈联系人失败' }
}
private async getExportStatsFromTableCount(myWxid?: string): Promise<{ totalPosts: number; totalFriends: number; myPosts: number | null }> {
let totalPosts = 0
let totalFriends = 0
let myPosts: number | null = null
const postCountResult = await wcdbService.execQuery('sns', null, 'SELECT COUNT(1) AS total FROM SnsTimeLine')
if (postCountResult.success && postCountResult.rows && postCountResult.rows.length > 0) {
totalPosts = this.parseCountValue(postCountResult.rows[0])
}
if (totalPosts > 0) {
const friendCountPrimary = await wcdbService.execQuery(
'sns',
null,
"SELECT COUNT(DISTINCT user_name) AS total FROM SnsTimeLine WHERE user_name IS NOT NULL AND user_name <> ''"
)
if (friendCountPrimary.success && friendCountPrimary.rows && friendCountPrimary.rows.length > 0) {
totalFriends = this.parseCountValue(friendCountPrimary.rows[0])
} else {
const friendCountFallback = await wcdbService.execQuery(
'sns',
null,
"SELECT COUNT(DISTINCT userName) AS total FROM SnsTimeLine WHERE userName IS NOT NULL AND userName <> ''"
)
if (friendCountFallback.success && friendCountFallback.rows && friendCountFallback.rows.length > 0) {
totalFriends = this.parseCountValue(friendCountFallback.rows[0])
}
}
}
const normalizedMyWxid = this.toOptionalString(myWxid)
if (normalizedMyWxid) {
const myPostPrimary = await wcdbService.execQuery(
'sns',
null,
"SELECT COUNT(1) AS total FROM SnsTimeLine WHERE user_name = ?",
[normalizedMyWxid]
)
if (myPostPrimary.success && myPostPrimary.rows && myPostPrimary.rows.length > 0) {
myPosts = this.parseCountValue(myPostPrimary.rows[0])
} else {
const myPostFallback = await wcdbService.execQuery(
'sns',
null,
"SELECT COUNT(1) AS total FROM SnsTimeLine WHERE userName = ?",
[normalizedMyWxid]
)
if (myPostFallback.success && myPostFallback.rows && myPostFallback.rows.length > 0) {
myPosts = this.parseCountValue(myPostFallback.rows[0])
}
}
}
return { totalPosts, totalFriends, myPosts }
}
async getExportStats(options?: {
allowTimelineFallback?: boolean
preferCache?: boolean
}): Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }> {
const allowTimelineFallback = options?.allowTimelineFallback ?? true
const preferCache = options?.preferCache ?? false
const now = Date.now()
const myWxid = this.toOptionalString(this.configService.get('myWxid'))
try {
if (preferCache && this.exportStatsCache && now - this.exportStatsCache.updatedAt <= this.exportStatsCacheTtlMs) {
return {
success: true,
data: {
totalPosts: this.exportStatsCache.totalPosts,
totalFriends: this.exportStatsCache.totalFriends,
myPosts: this.exportStatsCache.myPosts
}
}
}
let { totalPosts, totalFriends, myPosts } = await this.getExportStatsFromTableCount(myWxid)
let fallbackAttempted = false
let fallbackError = ''
// 某些环境下 SnsTimeLine 统计查询会返回 0这里在允许时回退到与导出同源的 timeline 接口统计。
if (
allowTimelineFallback &&
(totalPosts <= 0 || totalFriends <= 0) &&
now - this.lastTimelineFallbackAt >= this.timelineFallbackCooldownMs
) {
fallbackAttempted = true
try {
const timelineStats = await this.getExportStatsFromTimeline(myWxid)
this.lastTimelineFallbackAt = Date.now()
if (timelineStats.totalPosts > 0) {
totalPosts = timelineStats.totalPosts
}
if (timelineStats.totalFriends > 0) {
totalFriends = timelineStats.totalFriends
}
if (timelineStats.myPosts !== null) {
myPosts = timelineStats.myPosts
}
} catch (error) {
fallbackError = String(error)
console.error('[SnsService] getExportStats timeline fallback failed:', error)
}
}
const normalizedStats = {
totalPosts: Math.max(0, Number(totalPosts || 0)),
totalFriends: Math.max(0, Number(totalFriends || 0)),
myPosts: myWxid
? (myPosts === null ? null : Math.max(0, Number(myPosts || 0)))
: null
}
const computedHasData = normalizedStats.totalPosts > 0 || normalizedStats.totalFriends > 0
const cacheHasData = !!this.exportStatsCache && (this.exportStatsCache.totalPosts > 0 || this.exportStatsCache.totalFriends > 0)
// 计算结果全 0 时,优先使用已有非零缓存,避免瞬时异常覆盖有效统计。
if (!computedHasData && cacheHasData && this.exportStatsCache) {
return {
success: true,
data: {
totalPosts: this.exportStatsCache.totalPosts,
totalFriends: this.exportStatsCache.totalFriends,
myPosts: this.exportStatsCache.myPosts
}
}
}
// 当主查询结果全 0 且回退统计执行失败时,返回失败给前端显示明确状态(而非错误地展示 0
if (!computedHasData && fallbackAttempted && fallbackError) {
return { success: false, error: fallbackError }
}
this.exportStatsCache = {
totalPosts: normalizedStats.totalPosts,
totalFriends: normalizedStats.totalFriends,
myPosts: normalizedStats.myPosts,
updatedAt: Date.now()
}
return { success: true, data: normalizedStats }
} catch (e) {
if (this.exportStatsCache) {
return {
success: true,
data: {
totalPosts: this.exportStatsCache.totalPosts,
totalFriends: this.exportStatsCache.totalFriends,
myPosts: this.exportStatsCache.myPosts
}
}
}
return { success: false, error: String(e) }
}
}
async getExportStatsFast(): Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }> {
return this.getExportStats({
allowTimelineFallback: false,
preferCache: true
})
}
private async getUserPostCountsFromTimeline(): Promise<Record<string, number>> {
const pageSize = 500
const counts: Record<string, number> = {}
let offset = 0
for (let round = 0; round < 2000; round++) {
const result = await wcdbService.getSnsTimeline(pageSize, offset, undefined, undefined, 0, 0)
if (!result.success || !Array.isArray(result.timeline)) {
throw new Error(result.error || '获取朋友圈用户总条数失败')
}
const rows = result.timeline
if (rows.length === 0) break
for (const row of rows) {
const username = this.pickTimelineUsername(row)
if (!username) continue
counts[username] = (counts[username] || 0) + 1
}
if (rows.length < pageSize) break
offset += rows.length
}
return counts
}
async getUserPostCounts(options?: {
preferCache?: boolean
}): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> {
const preferCache = options?.preferCache ?? true
const now = Date.now()
try {
if (
preferCache &&
this.userPostCountsCache &&
now - this.userPostCountsCache.updatedAt <= this.userPostCountsCacheTtlMs
) {
return { success: true, counts: this.userPostCountsCache.counts }
}
const counts = await this.getUserPostCountsFromTimeline()
this.userPostCountsCache = {
counts,
updatedAt: Date.now()
}
return { success: true, counts }
} catch (error) {
console.error('[SnsService] getUserPostCounts failed:', error)
if (this.userPostCountsCache) {
return { success: true, counts: this.userPostCountsCache.counts }
}
return { success: false, error: String(error) }
}
}
async getUserPostStats(username: string): Promise<{ success: boolean; data?: { username: string; totalPosts: number }; error?: string }> {
const normalizedUsername = this.toOptionalString(username)
if (!normalizedUsername) {
return { success: false, error: '用户名不能为空' }
}
const countsResult = await this.getUserPostCounts({ preferCache: true })
if (countsResult.success) {
const totalPosts = countsResult.counts?.[normalizedUsername] ?? 0
return {
success: true,
data: {
username: normalizedUsername,
totalPosts: Math.max(0, Number(totalPosts || 0))
}
}
}
return { success: false, error: countsResult.error || '统计单个好友朋友圈失败' }
}
// 安装朋友圈删除拦截
@@ -376,7 +961,12 @@ class SnsService {
// 从数据库直接删除朋友圈记录
async deleteSnsPost(postId: string): Promise<{ success: boolean; error?: string }> {
return wcdbService.deleteSnsPost(postId)
const result = await wcdbService.deleteSnsPost(postId)
if (result.success) {
this.userPostCountsCache = null
this.exportStatsCache = null
}
return result
}
/**
@@ -431,20 +1021,6 @@ class SnsService {
const result = await wcdbService.getSnsTimeline(limit, offset, usernames, keyword, startTime, endTime)
if (!result.success || !result.timeline || result.timeline.length === 0) return result
// 诊断:测试 execQuery 查 content 字段
try {
const testResult = await wcdbService.execQuery('sns', null, 'SELECT tid, CAST(content AS TEXT) as ct, typeof(content) as ctype FROM SnsTimeLine ORDER BY tid DESC LIMIT 1')
if (testResult.success && testResult.rows?.[0]) {
const r = testResult.rows[0]
console.log('[SnsService] execQuery 诊断: ctype=', r.ctype, 'ct长度=', r.ct?.length, 'ct前200=', r.ct?.substring(0, 200))
console.log('[SnsService] ct包含CommentUserList:', r.ct?.includes('CommentUserList'))
} else {
console.log('[SnsService] execQuery 诊断失败:', testResult.error)
}
} catch (e) {
console.log('[SnsService] execQuery 诊断异常:', e)
}
const enrichedTimeline = result.timeline.map((post: any) => {
const contact = this.contactCache.get(post.username)
const isVideoPost = post.type === 15
@@ -577,14 +1153,44 @@ class SnsService {
*/
async exportTimeline(options: {
outputDir: string
format: 'json' | 'html'
format: 'json' | 'html' | 'arkmejson'
usernames?: string[]
keyword?: string
exportMedia?: boolean
exportImages?: boolean
exportLivePhotos?: boolean
exportVideos?: boolean
startTime?: number
endTime?: number
}, progressCallback?: (progress: { current: number; total: number; status: string }) => void): Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; error?: string }> {
const { outputDir, format, usernames, keyword, exportMedia = false, startTime, endTime } = options
}, progressCallback?: (progress: { current: number; total: number; status: string }) => void, control?: {
shouldPause?: () => boolean
shouldStop?: () => boolean
}): 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 =
typeof options.exportImages === 'boolean' ||
typeof options.exportLivePhotos === 'boolean' ||
typeof options.exportVideos === 'boolean'
const shouldExportImages = hasExplicitMediaSelection
? options.exportImages === true
: options.exportMedia === true
const shouldExportLivePhotos = hasExplicitMediaSelection
? options.exportLivePhotos === true
: options.exportMedia === true
const shouldExportVideos = hasExplicitMediaSelection
? options.exportVideos === true
: options.exportMedia === true
const shouldExportMedia = shouldExportImages || shouldExportLivePhotos || shouldExportVideos
const getControlState = (): 'paused' | 'stopped' | null => {
if (control?.shouldStop?.()) return 'stopped'
if (control?.shouldPause?.()) return 'paused'
return null
}
const buildInterruptedResult = (state: 'paused' | 'stopped', postCount: number, mediaCount: number) => (
state === 'stopped'
? { success: true, stopped: true, filePath: '', postCount, mediaCount }
: { success: true, paused: true, filePath: '', postCount, mediaCount }
)
try {
// 确保输出目录存在
@@ -601,6 +1207,10 @@ class SnsService {
progressCallback?.({ current: 0, total: 0, status: '正在加载朋友圈数据...' })
while (hasMore) {
const controlState = getControlState()
if (controlState) {
return buildInterruptedResult(controlState, allPosts.length, 0)
}
const result = await this.getTimeline(pageSize, 0, usernames, keyword, startTime, endTs)
if (result.success && result.timeline && result.timeline.length > 0) {
allPosts.push(...result.timeline)
@@ -628,15 +1238,54 @@ class SnsService {
let mediaCount = 0
const mediaDir = join(outputDir, 'media')
if (exportMedia) {
if (shouldExportMedia) {
if (!existsSync(mediaDir)) {
mkdirSync(mediaDir, { recursive: true })
}
// 收集所有媒体下载任务
const mediaTasks: { media: SnsMedia; postId: string; mi: number }[] = []
const mediaTasks: Array<{
kind: 'image' | 'video' | 'livephoto'
media: SnsMedia
url: string
key?: string
postId: string
mi: number
}> = []
for (const post of allPosts) {
post.media.forEach((media, mi) => mediaTasks.push({ media, postId: post.id, mi }))
post.media.forEach((media, mi) => {
const isVideo = isVideoUrl(media.url)
if (shouldExportImages && !isVideo && media.url) {
mediaTasks.push({
kind: 'image',
media,
url: media.url,
key: media.key,
postId: post.id,
mi
})
}
if (shouldExportVideos && isVideo && media.url) {
mediaTasks.push({
kind: 'video',
media,
url: media.url,
key: media.key,
postId: post.id,
mi
})
}
if (shouldExportLivePhotos && media.livePhoto?.url) {
mediaTasks.push({
kind: 'livephoto',
media,
url: media.livePhoto.url,
key: media.livePhoto.key || media.key,
postId: post.id,
mi
})
}
})
}
// 并发下载5路
@@ -645,29 +1294,42 @@ class SnsService {
const runTask = async (task: typeof mediaTasks[0]) => {
const { media, postId, mi } = task
try {
const isVideo = isVideoUrl(media.url)
const isVideo = task.kind === 'video' || task.kind === 'livephoto' || isVideoUrl(task.url)
const ext = isVideo ? 'mp4' : 'jpg'
const fileName = `${postId}_${mi}.${ext}`
const suffix = task.kind === 'livephoto' ? '_live' : ''
const fileName = `${postId}_${mi}${suffix}.${ext}`
const filePath = join(mediaDir, fileName)
if (existsSync(filePath)) {
;(media as any).localPath = `media/${fileName}`
if (task.kind === 'livephoto') {
if (media.livePhoto) (media.livePhoto as any).localPath = `media/${fileName}`
} else {
;(media as any).localPath = `media/${fileName}`
}
mediaCount++
} else {
const result = await this.fetchAndDecryptImage(media.url, media.key)
const result = await this.fetchAndDecryptImage(task.url, task.key)
if (result.success && result.data) {
await writeFile(filePath, result.data)
;(media as any).localPath = `media/${fileName}`
if (task.kind === 'livephoto') {
if (media.livePhoto) (media.livePhoto as any).localPath = `media/${fileName}`
} else {
;(media as any).localPath = `media/${fileName}`
}
mediaCount++
} else if (result.success && result.cachePath) {
const cachedData = await readFile(result.cachePath)
await writeFile(filePath, cachedData)
;(media as any).localPath = `media/${fileName}`
if (task.kind === 'livephoto') {
if (media.livePhoto) (media.livePhoto as any).localPath = `media/${fileName}`
} else {
;(media as any).localPath = `media/${fileName}`
}
mediaCount++
}
}
} catch (e) {
console.warn(`[SnsExport] 媒体下载失败: ${task.media.url}`, e)
console.warn(`[SnsExport] 媒体下载失败: ${task.url}`, e)
}
done++
progressCallback?.({ current: done, total: mediaTasks.length, status: `正在下载媒体 (${done}/${mediaTasks.length})...` })
@@ -677,11 +1339,18 @@ class SnsService {
const queue = [...mediaTasks]
const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
while (queue.length > 0) {
const controlState = getControlState()
if (controlState) return controlState
const task = queue.shift()!
await runTask(task)
}
return null
})
await Promise.all(workers)
const workerResults = await Promise.all(workers)
const interruptedState = workerResults.find(state => state === 'paused' || state === 'stopped')
if (interruptedState) {
return buildInterruptedResult(interruptedState, allPosts.length, mediaCount)
}
}
// 2.5 下载头像
@@ -693,6 +1362,8 @@ class SnsService {
const avatarQueue = [...uniqueUsers]
const avatarWorkers = Array.from({ length: Math.min(5, avatarQueue.length) }, async () => {
while (avatarQueue.length > 0) {
const controlState = getControlState()
if (controlState) return controlState
const post = avatarQueue.shift()!
try {
const fileName = `avatar_${crypto.createHash('md5').update(post.username).digest('hex').slice(0, 8)}.jpg`
@@ -710,11 +1381,20 @@ class SnsService {
avatarDone++
progressCallback?.({ current: avatarDone, total: uniqueUsers.length, status: `正在下载头像 (${avatarDone}/${uniqueUsers.length})...` })
}
return null
})
await Promise.all(avatarWorkers)
const avatarWorkerResults = await Promise.all(avatarWorkers)
const interruptedState = avatarWorkerResults.find(state => state === 'paused' || state === 'stopped')
if (interruptedState) {
return buildInterruptedResult(interruptedState, allPosts.length, mediaCount)
}
}
// 3. 生成输出文件
const finalControlState = getControlState()
if (finalControlState) {
return buildInterruptedResult(finalControlState, allPosts.length, mediaCount)
}
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
let outputFilePath: string
@@ -747,6 +1427,92 @@ class SnsService {
}))
}
await writeFile(outputFilePath, JSON.stringify(exportData, null, 2), 'utf-8')
} else if (format === 'arkmejson') {
outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.json`)
progressCallback?.({ current: 0, total: allPosts.length, status: '正在构建 ArkmeJSON 数据...' })
const identityCache = new Map<string, Promise<SnsContactIdentity | null>>()
const posts: any[] = []
let built = 0
for (const post of allPosts) {
const controlState = getControlState()
if (controlState) {
return buildInterruptedResult(controlState, allPosts.length, mediaCount)
}
const authorIdentity = await this.resolveContactIdentity(post.username, identityCache)
const { likesDetail, commentsDetail } = await this.buildArkmeInteractionDetails(post, identityCache)
posts.push({
id: post.id,
username: post.username,
nickname: post.nickname,
author: authorIdentity
? {
...authorIdentity
}
: {
username: post.username,
wxid: post.username,
displayName: post.nickname || post.username
},
createTime: post.createTime,
createTimeStr: new Date(post.createTime * 1000).toLocaleString('zh-CN'),
contentDesc: post.contentDesc,
type: post.type,
media: post.media.map(m => ({
url: m.url,
thumb: m.thumb,
localPath: (m as any).localPath || undefined,
livePhoto: m.livePhoto ? {
url: m.livePhoto.url,
thumb: m.livePhoto.thumb,
localPath: (m.livePhoto as any).localPath || undefined
} : undefined
})),
likes: post.likes,
comments: post.comments,
likesDetail,
commentsDetail,
linkTitle: (post as any).linkTitle,
linkUrl: (post as any).linkUrl
})
built++
if (built % 20 === 0 || built === allPosts.length) {
progressCallback?.({ current: built, total: allPosts.length, status: `正在构建 ArkmeJSON 数据 (${built}/${allPosts.length})...` })
}
}
const ownerWxid = this.toOptionalString(this.configService.get('myWxid'))
const ownerIdentity = ownerWxid
? await this.resolveContactIdentity(ownerWxid, identityCache)
: null
const recordOwner = ownerIdentity
? { ...ownerIdentity }
: ownerWxid
? { username: ownerWxid, wxid: ownerWxid, displayName: ownerWxid }
: { username: '', wxid: '', displayName: '' }
const exportData = {
exportTime: new Date().toISOString(),
format: 'arkmejson',
schemaVersion: '1.0.0',
recordOwner,
mediaSelection: {
images: shouldExportImages,
livePhotos: shouldExportLivePhotos,
videos: shouldExportVideos
},
totalPosts: allPosts.length,
filters: {
usernames: usernames || [],
keyword: keyword || ''
},
posts
}
await writeFile(outputFilePath, JSON.stringify(exportData, null, 2), 'utf-8')
} else {
// HTML 格式
outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.html`)

View File

@@ -2,7 +2,6 @@
import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync } from 'fs'
import { app } from 'electron'
import { ConfigService } from './config'
import Database from 'better-sqlite3'
import { wcdbService } from './wcdbService'
export interface VideoInfo {
@@ -71,58 +70,21 @@ class VideoService {
/**
* 从 video_hardlink_info_v4 表查询视频文件名
* 优先使用 cachePath 中解密后的 hardlink.db使用 better-sqlite3
* 如果失败,则尝试使用 wcdbService.execQuery 查询加密的 hardlink.db
* 使用 wcdbService.execQuery 查询加密的 hardlink.db
*/
private async queryVideoFileName(md5: string): Promise<string | undefined> {
const cachePath = this.getCachePath()
const dbPath = this.getDbPath()
const wxid = this.getMyWxid()
const cleanedWxid = this.cleanWxid(wxid)
this.log('queryVideoFileName 开始', { md5, wxid, cleanedWxid, cachePath, dbPath })
this.log('queryVideoFileName 开始', { md5, wxid, cleanedWxid, dbPath })
if (!wxid) {
this.log('queryVideoFileName: wxid 为空')
return undefined
}
// 方法1优先在 cachePath 下查找解密后的 hardlink.db
if (cachePath) {
const cacheDbPaths = [
join(cachePath, cleanedWxid, 'hardlink.db'),
join(cachePath, wxid, 'hardlink.db'),
join(cachePath, 'hardlink.db'),
join(cachePath, 'databases', cleanedWxid, 'hardlink.db'),
join(cachePath, 'databases', wxid, 'hardlink.db')
]
for (const p of cacheDbPaths) {
if (existsSync(p)) {
try {
this.log('尝试缓存 hardlink.db', { path: p })
const db = new Database(p, { readonly: true })
const row = db.prepare(`
SELECT file_name, md5 FROM video_hardlink_info_v4
WHERE md5 = ?
LIMIT 1
`).get(md5) as { file_name: string; md5: string } | undefined
db.close()
if (row?.file_name) {
const realMd5 = row.file_name.replace(/\.[^.]+$/, '')
this.log('缓存 hardlink.db 命中', { file_name: row.file_name, realMd5 })
return realMd5
}
this.log('缓存 hardlink.db 未命中', { path: p })
} catch (e) {
this.log('缓存 hardlink.db 查询失败', { path: p, error: String(e) })
}
}
}
}
// 方法2使用 wcdbService.execQuery 查询加密的 hardlink.db
// 使用 wcdbService.execQuery 查询加密的 hardlink.db
if (dbPath) {
const dbPathLower = dbPath.toLowerCase()
const wxidLower = wxid.toLowerCase()

View File

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

View File

@@ -1,5 +1,6 @@
import { join, dirname, basename } from 'path'
import { join, dirname, basename } from 'path'
import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs'
import { tmpdir } from 'os'
// DLL 初始化错误信息,用于帮助用户诊断问题
let lastDllInitError: string | null = null
@@ -60,6 +61,7 @@ export class WcdbCore {
private currentPath: string | null = null
private currentKey: string | null = null
private currentWxid: string | null = null
private currentDbStoragePath: string | null = null
// 函数引用
private wcdbInitProtection: any = null
@@ -114,6 +116,9 @@ export class WcdbCore {
private wcdbStartMonitorPipe: any = null
private wcdbStopMonitorPipe: any = null
private wcdbGetMonitorPipeName: any = null
private wcdbCloudInit: any = null
private wcdbCloudReport: any = null
private wcdbCloudStop: any = null
private monitorPipeClient: any = null
private monitorCallback: ((type: string, json: string) => void) | null = null
@@ -125,14 +130,17 @@ export class WcdbCore {
private readonly avatarCacheTtlMs = 10 * 60 * 1000
private logTimer: NodeJS.Timeout | null = null
private lastLogTail: string | null = null
private lastResolvedLogPath: string | null = null
setPaths(resourcesPath: string, userDataPath: string): void {
this.resourcesPath = resourcesPath
this.userDataPath = userDataPath
this.writeLog(`[bootstrap] setPaths resourcesPath=${resourcesPath} userDataPath=${userDataPath}`, true)
}
setLogEnabled(enabled: boolean): void {
this.logEnabled = enabled
this.writeLog(`[bootstrap] setLogEnabled=${enabled ? '1' : '0'} env.WCDB_LOG_ENABLED=${process.env.WCDB_LOG_ENABLED || ''}`, true)
if (this.isLogEnabled() && this.initialized) {
this.startLogPolling()
} else {
@@ -140,7 +148,7 @@ export class WcdbCore {
}
}
// 使用命名管道 IPC
// 使用命名管道/socket IPC (Windows: Named Pipe, macOS: Unix Socket)
startMonitor(callback: (type: string, json: string) => void): boolean {
if (!this.wcdbStartMonitorPipe) {
return false
@@ -165,7 +173,6 @@ export class WcdbCore {
}
} catch {}
}
this.connectMonitorPipe(pipePath)
return true
} catch (e) {
@@ -182,13 +189,18 @@ export class WcdbCore {
setTimeout(() => {
if (!this.monitorCallback) return
this.monitorPipeClient = net.createConnection(this.monitorPipePath, () => {
})
this.monitorPipeClient = net.createConnection(this.monitorPipePath, () => {})
let buffer = ''
this.monitorPipeClient.on('data', (data: Buffer) => {
buffer += data.toString('utf8')
const lines = buffer.split('\n')
const rawChunk = data.toString('utf8')
// macOS 侧可能使用 '\0' 或无换行分隔,统一归一化并兜底拆包
const normalizedChunk = rawChunk
.replace(/\u0000/g, '\n')
.replace(/}\s*{/g, '}\n{')
buffer += normalizedChunk
const lines = buffer.split(/\r?\n/)
buffer = lines.pop() || ''
for (const line of lines) {
if (line.trim()) {
@@ -200,9 +212,22 @@ export class WcdbCore {
}
}
}
// 兜底:如果没有分隔符但已形成完整 JSON则直接上报
const tail = buffer.trim()
if (tail.startsWith('{') && tail.endsWith('}')) {
try {
const parsed = JSON.parse(tail)
this.monitorCallback?.(parsed.action || 'update', tail)
buffer = ''
} catch {
// 不可解析则继续等待下一块数据
}
}
})
this.monitorPipeClient.on('error', () => {
// 保持静默,与现有错误处理策略一致
})
this.monitorPipeClient.on('close', () => {
@@ -248,9 +273,13 @@ export class WcdbCore {
/**
* 获取 DLL 路径
* 获取库文件路径(跨平台)
*/
private getDllPath(): string {
const isMac = process.platform === 'darwin'
const libName = isMac ? 'libwcdb_api.dylib' : 'wcdb_api.dll'
const subDir = isMac ? 'macos' : ''
const envDllPath = process.env.WCDB_DLL_PATH
if (envDllPath && envDllPath.length > 0) {
return envDllPath
@@ -262,22 +291,22 @@ export class WcdbCore {
const candidates = [
// 环境变量指定 resource 目录
process.env.WCDB_RESOURCES_PATH ? join(process.env.WCDB_RESOURCES_PATH, 'wcdb_api.dll') : null,
process.env.WCDB_RESOURCES_PATH ? join(process.env.WCDB_RESOURCES_PATH, subDir, libName) : null,
// 显式 setPaths 设置的路径
this.resourcesPath ? join(this.resourcesPath, 'wcdb_api.dll') : null,
// text/resources/wcdb_api.dll (打包常见结构)
join(resourcesPath, 'resources', 'wcdb_api.dll'),
// items/resourcesPath/wcdb_api.dll (扁平结构)
join(resourcesPath, 'wcdb_api.dll'),
this.resourcesPath ? join(this.resourcesPath, subDir, libName) : null,
// resources/macos/libwcdb_api.dylib 或 resources/wcdb_api.dll
join(resourcesPath, 'resources', subDir, libName),
// resources/libwcdb_api.dylib 或 resources/wcdb_api.dll (扁平结构)
join(resourcesPath, subDir, libName),
// CWD fallback
join(process.cwd(), 'resources', 'wcdb_api.dll')
join(process.cwd(), 'resources', subDir, libName)
].filter(Boolean) as string[]
for (const path of candidates) {
if (existsSync(path)) return path
}
return candidates[0] || 'wcdb_api.dll'
return candidates[0] || libName
}
private isLogEnabled(): boolean {
@@ -289,14 +318,97 @@ export class WcdbCore {
private writeLog(message: string, force = false): void {
if (!force && !this.isLogEnabled()) return
const line = `[${new Date().toISOString()}] ${message}`
// 同时输出到控制台和文件
const candidates: string[] = []
if (this.userDataPath) candidates.push(join(this.userDataPath, 'logs', 'wcdb.log'))
if (process.env.WCDB_LOG_DIR) candidates.push(join(process.env.WCDB_LOG_DIR, 'logs', 'wcdb.log'))
candidates.push(join(process.cwd(), 'logs', 'wcdb.log'))
candidates.push(join(tmpdir(), 'weflow-wcdb.log'))
const uniq = Array.from(new Set(candidates))
for (const filePath of uniq) {
try {
const dir = dirname(filePath)
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
appendFileSync(filePath, line + '\n', { encoding: 'utf8' })
this.lastResolvedLogPath = filePath
return
} catch (e) {
console.error(`[wcdbCore] writeLog failed path=${filePath}:`, e)
}
}
console.error('[wcdbCore] writeLog failed for all candidates:', uniq.join(' | '))
}
private formatSqlForLog(sql: string, maxLen = 240): string {
const compact = String(sql || '').replace(/\s+/g, ' ').trim()
if (compact.length <= maxLen) return compact
return compact.slice(0, maxLen) + '...'
}
private async dumpDbStatus(tag: string): Promise<void> {
try {
const base = this.userDataPath || process.env.WCDB_LOG_DIR || process.cwd()
const dir = join(base, 'logs')
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
appendFileSync(join(dir, 'wcdb.log'), line + '\n', { encoding: 'utf8' })
} catch { }
if (!this.ensureReady()) {
this.writeLog(`[diag:${tag}] db_status skipped: not connected`, true)
return
}
if (!this.wcdbGetDbStatus) {
this.writeLog(`[diag:${tag}] db_status skipped: api not supported`, true)
return
}
const outPtr = [null as any]
const rc = this.wcdbGetDbStatus(this.handle, outPtr)
if (rc !== 0 || !outPtr[0]) {
this.writeLog(`[diag:${tag}] db_status failed rc=${rc} outPtr=${outPtr[0] ? 'set' : 'null'}`, true)
return
}
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) {
this.writeLog(`[diag:${tag}] db_status decode failed`, true)
return
}
this.writeLog(`[diag:${tag}] db_status=${jsonStr}`, true)
} catch (e) {
this.writeLog(`[diag:${tag}] db_status exception: ${String(e)}`, true)
}
}
private async runPostOpenDiagnostics(dbPath: string, dbStoragePath: string | null, sessionDbPath: string | null, wxid: string): Promise<void> {
try {
this.writeLog(`[diag:open] input dbPath=${dbPath} wxid=${wxid}`, true)
this.writeLog(`[diag:open] resolved dbStorage=${dbStoragePath || 'null'}`, true)
this.writeLog(`[diag:open] resolved sessionDb=${sessionDbPath || 'null'}`, true)
if (!dbStoragePath) return
try {
const entries = readdirSync(dbStoragePath)
const sample = entries.slice(0, 20).join(',')
this.writeLog(`[diag:open] dbStorage entries(${entries.length}) sample=${sample}`, true)
} catch (e) {
this.writeLog(`[diag:open] list dbStorage failed: ${String(e)}`, true)
}
const contactProbe = await this.execQuery(
'contact',
null,
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name LIMIT 50"
)
if (contactProbe.success) {
const names = (contactProbe.rows || []).map((r: any) => String(r?.name || '')).filter(Boolean)
this.writeLog(`[diag:open] contact sqlite_master rows=${names.length} names=${names.join(',')}`, true)
} else {
this.writeLog(`[diag:open] contact sqlite_master failed: ${contactProbe.error || 'unknown'}`, true)
}
const contactCount = await this.execQuery('contact', null, 'SELECT COUNT(1) AS cnt FROM contact')
if (contactCount.success && Array.isArray(contactCount.rows) && contactCount.rows.length > 0) {
this.writeLog(`[diag:open] contact count=${String((contactCount.rows[0] as any)?.cnt ?? '')}`, true)
} else {
this.writeLog(`[diag:open] contact count failed: ${contactCount.error || 'unknown'}`, true)
}
} catch (e) {
this.writeLog(`[diag:open] post-open diagnostics exception: ${String(e)}`, true)
}
}
/**
@@ -373,6 +485,51 @@ export class WcdbCore {
return null
}
private isRealDbFileName(name: string): boolean {
const lower = String(name || '').toLowerCase()
if (!lower.endsWith('.db')) return false
if (lower.endsWith('.db-shm')) return false
if (lower.endsWith('.db-wal')) return false
if (lower.endsWith('.db-journal')) return false
return true
}
private resolveContactDbPath(): string | null {
const dbStorage = this.currentDbStoragePath || this.resolveDbStoragePath(this.currentPath || '', this.currentWxid || '')
if (!dbStorage) return null
const contactDir = join(dbStorage, 'Contact')
if (!existsSync(contactDir)) return null
const preferred = [
join(contactDir, 'contact.db'),
join(contactDir, 'Contact.db')
]
for (const p of preferred) {
if (existsSync(p)) return p
}
try {
const entries = readdirSync(contactDir)
const cands = entries
.filter((name) => this.isRealDbFileName(name))
.map((name) => join(contactDir, name))
if (cands.length > 0) return cands[0]
} catch { }
return null
}
private pickFirstStringField(row: Record<string, any>, candidates: string[]): string {
for (const key of candidates) {
const v = row[key]
if (typeof v === 'string' && v.trim()) return v
if (v !== null && v !== undefined) {
const s = String(v).trim()
if (s) return s
}
}
return ''
}
/**
* 初始化 WCDB
*/
@@ -382,31 +539,49 @@ export class WcdbCore {
try {
this.koffi = require('koffi')
const dllPath = this.getDllPath()
this.writeLog(`[bootstrap] initialize platform=${process.platform} dllPath=${dllPath} resourcesPath=${this.resourcesPath || ''} userDataPath=${this.userDataPath || ''}`, true)
if (!existsSync(dllPath)) {
console.error('WCDB DLL 不存在:', dllPath)
this.writeLog(`[bootstrap] initialize failed: dll not found path=${dllPath}`, true)
return false
}
const dllDir = dirname(dllPath)
const wcdbCorePath = join(dllDir, 'WCDB.dll')
if (existsSync(wcdbCorePath)) {
try {
this.koffi.load(wcdbCorePath)
this.writeLog('预加载 WCDB.dll 成功')
} catch (e) {
console.warn('预加载 WCDB.dll 失败(可能不是致命的):', e)
this.writeLog(`预加载 WCDB.dll 失败: ${String(e)}`)
const isMac = process.platform === 'darwin'
// 预加载依赖库
if (isMac) {
const wcdbCorePath = join(dllDir, 'libWCDB.dylib')
if (existsSync(wcdbCorePath)) {
try {
this.koffi.load(wcdbCorePath)
this.writeLog('预加载 libWCDB.dylib 成功')
} catch (e) {
console.warn('预加载 libWCDB.dylib 失败(可能不是致命的):', e)
this.writeLog(`预加载 libWCDB.dylib 失败: ${String(e)}`)
}
}
}
const sdl2Path = join(dllDir, 'SDL2.dll')
if (existsSync(sdl2Path)) {
try {
this.koffi.load(sdl2Path)
this.writeLog('预加载 SDL2.dll 成功')
} catch (e) {
console.warn('预加载 SDL2.dll 失败(可能不是致命的):', e)
this.writeLog(`预加载 SDL2.dll 失败: ${String(e)}`)
} else {
const wcdbCorePath = join(dllDir, 'WCDB.dll')
if (existsSync(wcdbCorePath)) {
try {
this.koffi.load(wcdbCorePath)
this.writeLog('预加载 WCDB.dll 成功')
} catch (e) {
console.warn('预加载 WCDB.dll 失败(可能不是致命的):', e)
this.writeLog(`预加载 WCDB.dll 失败: ${String(e)}`)
}
}
const sdl2Path = join(dllDir, 'SDL2.dll')
if (existsSync(sdl2Path)) {
try {
this.koffi.load(sdl2Path)
this.writeLog('预加载 SDL2.dll 成功')
} catch (e) {
console.warn('预加载 SDL2.dll 失败(可能不是致命的):', e)
this.writeLog(`预加载 SDL2.dll 失败: ${String(e)}`)
}
}
}
@@ -420,6 +595,8 @@ export class WcdbCore {
const resourcePaths = [
dllDir, // DLL 所在目录
dirname(dllDir), // 上级目录
process.resourcesPath, // 打包后 Contents/Resources
process.resourcesPath ? join(process.resourcesPath as string, 'resources') : null, // Contents/Resources/resources
this.resourcesPath, // 配置的资源路径
join(process.cwd(), 'resources') // 开发环境
].filter(Boolean)
@@ -702,12 +879,33 @@ export class WcdbCore {
this.wcdbVerifyUser = null
}
// wcdb_status wcdb_cloud_init(int32_t interval_seconds)
try {
this.wcdbCloudInit = this.lib.func('int32 wcdb_cloud_init(int32 intervalSeconds)')
} catch {
this.wcdbCloudInit = null
}
// wcdb_status wcdb_cloud_report(const char* stats_json)
try {
this.wcdbCloudReport = this.lib.func('int32 wcdb_cloud_report(const char* statsJson)')
} catch {
this.wcdbCloudReport = null
}
// void wcdb_cloud_stop()
try {
this.wcdbCloudStop = this.lib.func('void wcdb_cloud_stop()')
} catch {
this.wcdbCloudStop = null
}
// 初始化
const initResult = this.wcdbInit()
if (initResult !== 0) {
console.error('WCDB 初始化失败:', initResult)
lastDllInitError = `初始化失败(错误码: ${initResult}`
return false
}
@@ -958,7 +1156,7 @@ export class WcdbCore {
}
const dbStoragePath = this.resolveDbStoragePath(dbPath, wxid)
this.writeLog(`open dbPath=${dbPath} wxid=${wxid} dbStorage=${dbStoragePath || 'null'}`)
this.writeLog(`open dbPath=${dbPath} wxid=${wxid} dbStorage=${dbStoragePath || 'null'}`, true)
if (!dbStoragePath || !existsSync(dbStoragePath)) {
console.error('数据库目录不存在:', dbPath)
@@ -967,7 +1165,7 @@ export class WcdbCore {
}
const sessionDbPath = this.findSessionDb(dbStoragePath)
this.writeLog(`open sessionDb=${sessionDbPath || 'null'}`)
this.writeLog(`open sessionDb=${sessionDbPath || 'null'}`, true)
if (!sessionDbPath) {
console.error('未找到 session.db 文件')
this.writeLog('open failed: session.db not found')
@@ -993,6 +1191,7 @@ export class WcdbCore {
this.currentPath = dbPath
this.currentKey = hexKey
this.currentWxid = wxid
this.currentDbStoragePath = dbStoragePath
this.initialized = true
if (this.wcdbSetMyWxid && wxid) {
try {
@@ -1004,7 +1203,9 @@ export class WcdbCore {
if (this.isLogEnabled()) {
this.startLogPolling()
}
this.writeLog(`open ok handle=${handle}`)
this.writeLog(`open ok handle=${handle}`, true)
await this.dumpDbStatus('open')
await this.runPostOpenDiagnostics(dbPath, dbStoragePath, sessionDbPath, wxid)
return true
} catch (e) {
console.error('打开数据库异常:', e)
@@ -1029,6 +1230,7 @@ export class WcdbCore {
this.currentPath = null
this.currentKey = null
this.currentWxid = null
this.currentDbStoragePath = null
this.initialized = false
this.stopLogPolling()
}
@@ -1144,12 +1346,71 @@ export class WcdbCore {
}
}
async getMessageCounts(sessionIds: string[]): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
const normalizedSessionIds = Array.from(
new Set(
(sessionIds || [])
.map((id) => String(id || '').trim())
.filter(Boolean)
)
)
if (normalizedSessionIds.length === 0) {
return { success: true, counts: {} }
}
try {
const counts: Record<string, number> = {}
for (let i = 0; i < normalizedSessionIds.length; i += 1) {
const sessionId = normalizedSessionIds[i]
const outCount = [0]
const result = this.wcdbGetMessageCount(this.handle, sessionId, outCount)
counts[sessionId] = result === 0 && Number.isFinite(outCount[0]) ? Math.max(0, Math.floor(outCount[0])) : 0
if (i > 0 && i % 160 === 0) {
await new Promise(resolve => setImmediate(resolve))
}
}
return { success: true, counts }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getDisplayNames(usernames: string[]): Promise<{ success: boolean; map?: Record<string, string>; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
if (usernames.length === 0) return { success: true, map: {} }
try {
if (process.platform === 'darwin') {
const uniq = Array.from(new Set(usernames.map((x) => String(x || '').trim()).filter(Boolean)))
if (uniq.length === 0) return { success: true, map: {} }
const inList = uniq.map((u) => `'${u.replace(/'/g, "''")}'`).join(',')
const sql = `SELECT * FROM contact WHERE username IN (${inList})`
const q = await this.execQuery('contact', null, sql)
if (!q.success) return { success: false, error: q.error || '获取昵称失败' }
const map: Record<string, string> = {}
for (const row of (q.rows || []) as Array<Record<string, any>>) {
const username = this.pickFirstStringField(row, ['username', 'user_name', 'userName'])
if (!username) continue
const display = this.pickFirstStringField(row, [
'remark', 'Remark',
'nick_name', 'nickName', 'nickname', 'NickName',
'alias', 'Alias'
]) || username
map[username] = display
}
// 保证每个请求用户名至少有回退值
for (const u of uniq) {
if (!map[u]) map[u] = u
}
return { success: true, map }
}
// 让出控制权,避免阻塞事件循环
await new Promise(resolve => setImmediate(resolve))
@@ -1198,6 +1459,34 @@ export class WcdbCore {
return { success: true, map: resultMap }
}
if (process.platform === 'darwin') {
const inList = toFetch.map((u) => `'${u.replace(/'/g, "''")}'`).join(',')
const sql = `SELECT * FROM contact WHERE username IN (${inList})`
const q = await this.execQuery('contact', null, sql)
if (!q.success) {
if (Object.keys(resultMap).length > 0) {
return { success: true, map: resultMap, error: q.error || '获取头像失败' }
}
return { success: false, error: q.error || '获取头像失败' }
}
for (const row of (q.rows || []) as Array<Record<string, any>>) {
const username = this.pickFirstStringField(row, ['username', 'user_name', 'userName'])
if (!username) continue
const url = this.pickFirstStringField(row, [
'big_head_img_url', 'bigHeadImgUrl', 'bigHeadUrl', 'big_head_url',
'small_head_img_url', 'smallHeadImgUrl', 'smallHeadUrl', 'small_head_url',
'head_img_url', 'headImgUrl',
'avatar_url', 'avatarUrl'
])
if (url) {
resultMap[username] = url
this.avatarUrlCache.set(username, { url, updatedAt: now })
}
}
return { success: true, map: resultMap }
}
// 让出控制权,避免阻塞事件循环
await new Promise(resolve => setImmediate(resolve))
@@ -1406,10 +1695,42 @@ export class WcdbCore {
return { success: false, error: 'WCDB 未连接' }
}
try {
if (process.platform === 'darwin') {
const safe = String(username || '').replace(/'/g, "''")
const sql = `SELECT * FROM contact WHERE username='${safe}' LIMIT 1`
const q = await this.execQuery('contact', null, sql)
if (!q.success) {
return { success: false, error: q.error || '获取联系人失败' }
}
const row = Array.isArray(q.rows) && q.rows.length > 0 ? q.rows[0] : null
if (!row) {
return { success: false, error: `联系人不存在: ${username}` }
}
return { success: true, contact: row }
}
const outPtr = [null as any]
const result = this.wcdbGetContact(this.handle, username, outPtr)
if (result !== 0 || !outPtr[0]) {
return { success: false, error: `获取联系人失败: ${result}` }
this.writeLog(`[diag:getContact] primary api failed username=${username} code=${result} outPtr=${outPtr[0] ? 'set' : 'null'}`, true)
await this.dumpDbStatus('getContact-primary-fail')
await this.printLogs(true)
// Fallback: 直接查询 contact 表,便于区分是接口失败还是 contact 库本身不可读。
const safe = String(username || '').replace(/'/g, "''")
const fallbackSql = `SELECT * FROM contact WHERE username='${safe}' LIMIT 1`
const fallback = await this.execQuery('contact', null, fallbackSql)
if (fallback.success) {
const row = Array.isArray(fallback.rows) ? fallback.rows[0] : null
if (row) {
this.writeLog(`[diag:getContact] fallback sql hit username=${username}`, true)
return { success: true, contact: row }
}
this.writeLog(`[diag:getContact] fallback sql no row username=${username}`, true)
return { success: false, error: `联系人不存在: ${username}` }
}
this.writeLog(`[diag:getContact] fallback sql failed username=${username} err=${fallback.error || 'unknown'}`, true)
return { success: false, error: `获取联系人失败: ${result}; fallback=${fallback.error || 'unknown'}` }
}
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析联系人失败' }
@@ -1746,16 +2067,43 @@ export class WcdbCore {
console.warn('[wcdbCore] execQuery: 参数化查询暂未在 C++ 层实现,将使用原始 SQL可能存在注入风险')
}
const normalizedKind = String(kind || '').toLowerCase()
const isContactQuery = normalizedKind === 'contact' || /\bfrom\s+contact\b/i.test(String(sql))
let effectivePath = path || ''
if (normalizedKind === 'contact' && !effectivePath) {
const resolvedContactDb = this.resolveContactDbPath()
if (resolvedContactDb) {
effectivePath = resolvedContactDb
this.writeLog(`[diag:execQuery] contact path override -> ${effectivePath}`, true)
} else {
this.writeLog('[diag:execQuery] contact path override miss: Contact/contact.db not found', true)
}
}
const outPtr = [null as any]
const result = this.wcdbExecQuery(this.handle, kind, path || '', sql, outPtr)
const result = this.wcdbExecQuery(this.handle, kind, effectivePath, sql, outPtr)
if (result !== 0 || !outPtr[0]) {
if (isContactQuery) {
this.writeLog(`[diag:execQuery] contact query failed code=${result} kind=${kind} path=${effectivePath} sql="${this.formatSqlForLog(sql)}"`, true)
await this.dumpDbStatus('execQuery-contact-fail')
await this.printLogs(true)
}
return { success: false, error: `执行查询失败: ${result}` }
}
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析查询结果失败' }
const rows = JSON.parse(jsonStr)
if (isContactQuery) {
const count = Array.isArray(rows) ? rows.length : -1
this.writeLog(`[diag:execQuery] contact query ok rows=${count} kind=${kind} path=${effectivePath} sql="${this.formatSqlForLog(sql)}"`, true)
}
return { success: true, rows }
} catch (e) {
const isContactQuery = String(kind).toLowerCase() === 'contact' || /\bfrom\s+contact\b/i.test(String(sql))
if (isContactQuery) {
this.writeLog(`[diag:execQuery] contact query exception kind=${kind} path=${path || ''} sql="${this.formatSqlForLog(sql)}" err=${String(e)}`, true)
await this.dumpDbStatus('execQuery-contact-exception')
}
return { success: false, error: String(e) }
}
}
@@ -1841,8 +2189,57 @@ export class WcdbCore {
}
/**
* 验证 Windows Hello
* 数据收集初始化
*/
async cloudInit(intervalSeconds: number = 600): Promise<{ success: boolean; error?: string }> {
if (!this.initialized) {
const initOk = await this.initialize()
if (!initOk) return { success: false, error: 'WCDB init failed' }
}
if (!this.wcdbCloudInit) {
return { success: false, error: 'Cloud init API not supported by DLL' }
}
try {
const result = this.wcdbCloudInit(intervalSeconds)
if (result !== 0) {
return { success: false, error: `Cloud init failed: ${result}` }
}
return { success: true }
} catch (e) {
return { success: false, error: String(e) }
}
}
async cloudReport(statsJson: string): Promise<{ success: boolean; error?: string }> {
if (!this.initialized) {
const initOk = await this.initialize()
if (!initOk) return { success: false, error: 'WCDB init failed' }
}
if (!this.wcdbCloudReport) {
return { success: false, error: 'Cloud report API not supported by DLL' }
}
try {
const result = this.wcdbCloudReport(statsJson || '')
if (result !== 0) {
return { success: false, error: `Cloud report failed: ${result}` }
}
return { success: true }
} catch (e) {
return { success: false, error: String(e) }
}
}
cloudStop(): { success: boolean; error?: string } {
if (!this.wcdbCloudStop) {
return { success: false, error: 'Cloud stop API not supported by DLL' }
}
try {
this.wcdbCloudStop()
return { success: true }
} catch (e) {
return { success: false, error: String(e) }
}
}
async verifyUser(message: string, hwnd?: string): Promise<{ success: boolean; error?: string }> {
if (!this.initialized) {
const initOk = await this.initialize()
@@ -2093,4 +2490,3 @@ export class WcdbCore {
})
}
}

View File

@@ -136,7 +136,7 @@ export class WcdbService {
*/
setMonitor(callback: (type: string, json: string) => void): void {
this.monitorListener = callback;
this.callWorker('setMonitor').catch(() => { });
this.callWorker<{ success?: boolean }>('setMonitor').catch(() => { });
}
/**
@@ -218,6 +218,10 @@ export class WcdbService {
return this.callWorker('getMessageCount', { sessionId })
}
async getMessageCounts(sessionIds: string[]): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> {
return this.callWorker('getMessageCounts', { sessionIds })
}
/**
* 获取联系人昵称
*/
@@ -479,6 +483,27 @@ export class WcdbService {
return this.callWorker('deleteMessage', { sessionId, localId, createTime, dbPathHint })
}
/**
* 数据收集:初始化
*/
async cloudInit(intervalSeconds: number): Promise<{ success: boolean; error?: string }> {
return this.callWorker('cloudInit', { intervalSeconds })
}
/**
* 数据收集:上报数据
*/
async cloudReport(statsJson: string): Promise<{ success: boolean; error?: string }> {
return this.callWorker('cloudReport', { statsJson })
}
/**
* 数据收集:停止
*/
cloudStop(): Promise<{ success: boolean; error?: string }> {
return this.callWorker('cloudStop', {})
}
}

View File

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

View File

@@ -20,15 +20,17 @@ if (parentPort) {
result = { success: true }
break
case 'setMonitor':
core.setMonitor((type, json) => {
{
const monitorOk = core.setMonitor((type, json) => {
parentPort!.postMessage({
id: -1,
type: 'monitor',
payload: { type, json }
})
})
result = { success: true }
result = { success: monitorOk }
break
}
case 'testConnection':
result = await core.testConnection(payload.dbPath, payload.hexKey, payload.wxid)
break
@@ -54,6 +56,9 @@ if (parentPort) {
case 'getMessageCount':
result = await core.getMessageCount(payload.sessionId)
break
case 'getMessageCounts':
result = await core.getMessageCounts(payload.sessionIds)
break
case 'getDisplayNames':
result = await core.getDisplayNames(payload.usernames)
break
@@ -171,7 +176,15 @@ if (parentPort) {
case 'deleteMessage':
result = await core.deleteMessage(payload.sessionId, payload.localId, payload.createTime, payload.dbPathHint)
break
case 'cloudInit':
result = await core.cloudInit(payload.intervalSeconds)
break
case 'cloudReport':
result = await core.cloudReport(payload.statsJson)
break
case 'cloudStop':
result = core.cloudStop()
break
default:
result = { success: false, error: `Unknown method: ${type}` }
}

View File

@@ -5,6 +5,28 @@ import { ConfigService } from '../services/config'
let notificationWindow: BrowserWindow | null = null
let closeTimer: NodeJS.Timeout | null = null
export function destroyNotificationWindow() {
if (closeTimer) {
clearTimeout(closeTimer)
closeTimer = null
}
lastNotificationData = null
if (!notificationWindow || notificationWindow.isDestroyed()) {
notificationWindow = null
return
}
const win = notificationWindow
notificationWindow = null
try {
win.destroy()
} catch (error) {
console.warn('[NotificationWindow] Failed to destroy window:', error)
}
}
export function createNotificationWindow() {
if (notificationWindow && !notificationWindow.isDestroyed()) {
return notificationWindow

227
package-lock.json generated
View File

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

View File

@@ -13,13 +13,13 @@
"postinstall": "electron-builder install-app-deps",
"rebuild": "electron-rebuild",
"dev": "vite",
"typecheck": "tsc --noEmit",
"build": "tsc && vite build && electron-builder",
"preview": "vite preview",
"electron:dev": "vite --mode electron",
"electron:build": "npm run build"
},
"dependencies": {
"better-sqlite3": "^12.5.0",
"echarts": "^5.5.1",
"echarts-for-react": "^3.0.2",
"electron-store": "^10.0.0",
@@ -45,7 +45,6 @@
},
"devDependencies": {
"@electron/rebuild": "^4.0.2",
"@types/better-sqlite3": "^7.6.13",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.3.4",
@@ -71,6 +70,18 @@
"directories": {
"output": "release"
},
"mac": {
"target": [
"dmg",
"zip"
],
"category": "public.app-category.utilities",
"hardenedRuntime": false,
"gatekeeperAssess": false,
"entitlements": "electron/entitlements.mac.plist",
"entitlementsInherit": "electron/entitlements.mac.plist",
"icon": "resources/icon.icns"
},
"win": {
"target": [
"nsis"
@@ -119,6 +130,8 @@
"asarUnpack": [
"node_modules/silk-wasm/**/*",
"node_modules/sherpa-onnx-node/**/*",
"node_modules/sherpa-onnx-*/*",
"node_modules/sherpa-onnx-*/**/*",
"node_modules/ffmpeg-static/**/*"
],
"extraFiles": [
@@ -138,6 +151,7 @@
"from": "resources/vcruntime140_1.dll",
"to": "."
}
]
],
"icon": "resources/icon.icns"
}
}
}

BIN
resources/icon.icns Normal file

Binary file not shown.

View File

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

BIN
resources/image_scan_helper Executable file

Binary file not shown.

View File

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

BIN
resources/libwx_key.dylib Executable file

Binary file not shown.

BIN
resources/macos/libWCDB.dylib Executable file

Binary file not shown.

BIN
resources/macos/libwcdb_api.dylib Executable file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
resources/xkey_helper Executable file

Binary file not shown.

View File

@@ -69,6 +69,19 @@
flex: 1;
overflow: auto;
padding: 24px;
position: relative;
}
.export-keepalive-page {
height: 100%;
&.hidden {
display: none;
}
}
.export-route-anchor {
display: none;
}
@keyframes appFadeIn {

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { Routes, Route, useNavigate, useLocation } from 'react-router-dom'
import { useEffect, useRef, useState } from 'react'
import { Routes, Route, Navigate, useNavigate, useLocation, type Location } from 'react-router-dom'
import TitleBar from './components/TitleBar'
import Sidebar from './components/Sidebar'
import RouteGuard from './components/RouteGuard'
@@ -8,6 +8,7 @@ import HomePage from './pages/HomePage'
import ChatPage from './pages/ChatPage'
import AnalyticsPage from './pages/AnalyticsPage'
import AnalyticsWelcomePage from './pages/AnalyticsWelcomePage'
import ChatAnalyticsHubPage from './pages/ChatAnalyticsHubPage'
import AnnualReportPage from './pages/AnnualReportPage'
import AnnualReportWindow from './pages/AnnualReportWindow'
import DualReportPage from './pages/DualReportPage'
@@ -26,6 +27,7 @@ import NotificationWindow from './pages/NotificationWindow'
import { useAppStore } from './stores/appStore'
import { themes, useThemeStore, type ThemeId, type ThemeMode } from './stores/themeStore'
import * as configService from './services/config'
import * as cloudControl from './services/cloudControl'
import { Download, X, Shield } from 'lucide-react'
import './App.scss'
@@ -36,9 +38,22 @@ import { GlobalSessionMonitor } from './components/GlobalSessionMonitor'
import { BatchTranscribeGlobal } from './components/BatchTranscribeGlobal'
import { BatchImageDecryptGlobal } from './components/BatchImageDecryptGlobal'
function RouteStateRedirect({ to }: { to: string }) {
const location = useLocation()
return <Navigate to={to} replace state={location.state} />
}
function App() {
const navigate = useNavigate()
const location = useLocation()
const settingsBackgroundRef = useRef<Location>({
pathname: '/home',
search: '',
hash: '',
state: null,
key: 'settings-fallback'
} as Location)
const {
setDbConnected,
@@ -60,8 +75,16 @@ function App() {
const isOnboardingWindow = location.pathname === '/onboarding-window'
const isVideoPlayerWindow = location.pathname === '/video-player-window'
const isChatHistoryWindow = location.pathname.startsWith('/chat-history/')
const isStandaloneChatWindow = location.pathname === '/chat-window'
const isNotificationWindow = location.pathname === '/notification-window'
const isSettingsRoute = location.pathname === '/settings'
const settingsRouteState = location.state as { backgroundLocation?: Location; initialTab?: unknown } | null
const routeLocation = isSettingsRoute
? settingsRouteState?.backgroundLocation ?? settingsBackgroundRef.current
: location
const isExportRoute = routeLocation.pathname === '/export'
const [themeHydrated, setThemeHydrated] = useState(false)
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
// 锁定状态
// const [isLocked, setIsLocked] = useState(false) // Moved to store
@@ -75,6 +98,15 @@ function App() {
const [agreementChecked, setAgreementChecked] = useState(false)
const [agreementLoading, setAgreementLoading] = useState(true)
// 数据收集同意状态
const [showAnalyticsConsent, setShowAnalyticsConsent] = useState(false)
useEffect(() => {
if (location.pathname !== '/settings') {
settingsBackgroundRef.current = location
}
}, [location])
useEffect(() => {
const root = document.documentElement
const body = document.body
@@ -106,10 +138,6 @@ function App() {
const effectiveMode = mode === 'system' ? (systemDark ?? mq.matches ? 'dark' : 'light') : mode
document.documentElement.setAttribute('data-theme', currentTheme)
document.documentElement.setAttribute('data-mode', effectiveMode)
const symbolColor = effectiveMode === 'dark' ? '#ffffff' : '#1a1a1a'
if (!isOnboardingWindow && !isNotificationWindow) {
window.electronAPI.window.setTitleBarOverlay({ symbolColor })
}
}
applyMode(themeMode)
@@ -170,6 +198,14 @@ function App() {
const agreed = await configService.getAgreementAccepted()
if (!agreed) {
setShowAgreement(true)
} else {
// 协议已同意,检查数据收集同意状态
const consent = await configService.getAnalyticsConsent()
const denyCount = await configService.getAnalyticsDenyCount()
// 如果未设置同意状态且拒绝次数小于2次显示弹窗
if (consent === null && denyCount < 2) {
setShowAnalyticsConsent(true)
}
}
} catch (e) {
console.error('检查协议状态失败:', e)
@@ -180,16 +216,45 @@ function App() {
checkAgreement()
}, [])
// 初始化数据收集
useEffect(() => {
cloudControl.initCloudControl()
}, [])
// 记录页面访问
useEffect(() => {
const path = location.pathname
if (path && path !== '/') {
cloudControl.recordPage(path)
}
}, [location.pathname])
const handleAgree = async () => {
if (!agreementChecked) return
await configService.setAgreementAccepted(true)
setShowAgreement(false)
// 协议同意后,检查数据收集同意
const consent = await configService.getAnalyticsConsent()
if (consent === null) {
setShowAnalyticsConsent(true)
}
}
const handleDisagree = () => {
window.electronAPI.window.close()
}
const handleAnalyticsAllow = async () => {
await configService.setAnalyticsConsent(true)
setShowAnalyticsConsent(false)
}
const handleAnalyticsDeny = async () => {
const denyCount = await configService.getAnalyticsDenyCount()
await configService.setAnalyticsDenyCount(denyCount + 1)
setShowAnalyticsConsent(false)
}
// 监听启动时的更新通知
useEffect(() => {
if (isNotificationWindow) return // Skip updates in notification window
@@ -360,12 +425,51 @@ function App() {
return <ChatHistoryPage />
}
// 独立会话聊天窗口(仅显示聊天内容区域)
if (isStandaloneChatWindow) {
const params = new URLSearchParams(location.search)
const sessionId = params.get('sessionId') || ''
const standaloneSource = params.get('source')
const standaloneInitialDisplayName = params.get('initialDisplayName')
const standaloneInitialAvatarUrl = params.get('initialAvatarUrl')
const standaloneInitialContactType = params.get('initialContactType')
return (
<ChatPage
standaloneSessionWindow
initialSessionId={sessionId}
standaloneSource={standaloneSource}
standaloneInitialDisplayName={standaloneInitialDisplayName}
standaloneInitialAvatarUrl={standaloneInitialAvatarUrl}
standaloneInitialContactType={standaloneInitialContactType}
/>
)
}
// 独立通知窗口
if (isNotificationWindow) {
return <NotificationWindow />
}
// 主窗口 - 完整布局
const handleCloseSettings = () => {
const backgroundLocation = settingsRouteState?.backgroundLocation ?? settingsBackgroundRef.current
if (backgroundLocation.pathname === '/settings') {
navigate('/home', { replace: true })
return
}
navigate(
{
pathname: backgroundLocation.pathname,
search: backgroundLocation.search,
hash: backgroundLocation.hash
},
{
replace: true,
state: backgroundLocation.state
}
)
}
return (
<div className="app-container">
<div className="window-drag-region" aria-hidden="true" />
@@ -376,7 +480,10 @@ function App() {
useHello={lockUseHello}
/>
)}
<TitleBar />
<TitleBar
sidebarCollapsed={sidebarCollapsed}
onToggleSidebar={() => setSidebarCollapsed((prev) => !prev)}
/>
{/* 全局悬浮进度胶囊 (处理:新版本提示、下载进度、错误提示) */}
<UpdateProgressCapsule />
@@ -439,6 +546,42 @@ function App() {
</div>
)}
{/* 数据收集同意弹窗 */}
{showAnalyticsConsent && !agreementLoading && (
<div className="agreement-overlay">
<div className="agreement-modal">
<div className="agreement-header">
<Shield size={32} />
<h2>使</h2>
</div>
<div className="agreement-content">
<div className="agreement-text">
<p> WeFlow 使</p>
<h4></h4>
<p> 使使使</p>
<p> </p>
<p> </p>
<h4></h4>
<p> </p>
<p> </p>
<p> </p>
<p> </p>
<p> </p>
</div>
</div>
<div className="agreement-footer">
<div className="agreement-actions">
<button className="btn btn-secondary" onClick={handleAnalyticsDeny}></button>
<button className="btn btn-primary" onClick={handleAnalyticsAllow}></button>
</div>
</div>
</div>
</div>
)}
{/* 更新提示对话框 */}
<UpdateDialog
open={showUpdateDialog}
@@ -451,24 +594,30 @@ function App() {
/>
<div className="main-layout">
<Sidebar />
<Sidebar collapsed={sidebarCollapsed} />
<main className="content">
<RouteGuard>
<Routes>
<div className={`export-keepalive-page ${isExportRoute ? 'active' : 'hidden'}`} aria-hidden={!isExportRoute}>
<ExportPage />
</div>
<Routes location={routeLocation}>
<Route path="/" element={<HomePage />} />
<Route path="/home" element={<HomePage />} />
<Route path="/chat" element={<ChatPage />} />
<Route path="/analytics" element={<AnalyticsWelcomePage />} />
<Route path="/analytics/view" element={<AnalyticsPage />} />
<Route path="/group-analytics" element={<GroupAnalyticsPage />} />
<Route path="/analytics" element={<ChatAnalyticsHubPage />} />
<Route path="/analytics/private" element={<AnalyticsWelcomePage />} />
<Route path="/analytics/private/view" element={<AnalyticsPage />} />
<Route path="/analytics/group" element={<GroupAnalyticsPage />} />
<Route path="/analytics/view" element={<RouteStateRedirect to="/analytics/private/view" />} />
<Route path="/group-analytics" element={<RouteStateRedirect to="/analytics/group" />} />
<Route path="/annual-report" element={<AnnualReportPage />} />
<Route path="/annual-report/view" element={<AnnualReportWindow />} />
<Route path="/dual-report" element={<DualReportPage />} />
<Route path="/dual-report/view" element={<DualReportWindow />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/export" element={<ExportPage />} />
<Route path="/export" element={<div className="export-route-anchor" aria-hidden="true" />} />
<Route path="/sns" element={<SnsPage />} />
<Route path="/contacts" element={<ContactsPage />} />
<Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} />
@@ -476,6 +625,10 @@ function App() {
</RouteGuard>
</main>
</div>
{isSettingsRoute && (
<SettingsPage onClose={handleCloseSettings} />
)}
</div>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -46,7 +46,6 @@ export function GlobalSessionMonitor() {
return () => {
removeListener()
}
} else {
}
return () => { }
}, [])
@@ -198,11 +197,12 @@ export function GlobalSessionMonitor() {
// 尝试丰富或获取联系人详情
const contact = await window.electronAPI.chat.getContact(newSession.username)
if (contact) {
if (contact.remark || contact.nickname) {
title = contact.remark || contact.nickname
if (contact.remark || contact.nickName) {
title = contact.remark || contact.nickName
}
if (contact.avatarUrl) {
avatarUrl = contact.avatarUrl
const avatarResult = await window.electronAPI.chat.getContactAvatar(newSession.username)
if (avatarResult?.avatarUrl) {
avatarUrl = avatarResult.avatarUrl
}
} else {
// 如果不在缓存/数据库中
@@ -222,8 +222,11 @@ export function GlobalSessionMonitor() {
if (title === newSession.username || title.startsWith('wxid_')) {
const retried = await window.electronAPI.chat.getContact(newSession.username)
if (retried) {
title = retried.remark || retried.nickname || title
avatarUrl = retried.avatarUrl || avatarUrl
title = retried.remark || retried.nickName || title
const retriedAvatar = await window.electronAPI.chat.getContactAvatar(newSession.username)
if (retriedAvatar?.avatarUrl) {
avatarUrl = retriedAvatar.avatarUrl
}
}
}
}

View File

@@ -0,0 +1,166 @@
.jump-date-popover {
position: absolute;
top: calc(100% + 10px);
right: 0;
width: 312px;
border-radius: 14px;
border: 1px solid var(--border-color);
background: none;
background-color: var(--bg-secondary-solid, #ffffff) !important;
opacity: 1;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
mix-blend-mode: normal;
isolation: isolate;
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2);
padding: 12px;
z-index: 1600;
}
.jump-date-popover .calendar-nav {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.jump-date-popover .current-month {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.jump-date-popover .nav-btn {
width: 28px;
height: 28px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: none;
background-color: var(--bg-secondary-solid, #ffffff) !important;
color: var(--text-secondary);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.18s ease;
}
.jump-date-popover .nav-btn:hover {
border-color: var(--primary);
color: var(--primary);
background: var(--bg-hover);
}
.jump-date-popover .status-line {
min-height: 16px;
margin-bottom: 6px;
}
.jump-date-popover .status-item {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--text-tertiary);
font-size: 11px;
}
.jump-date-popover .calendar-grid .weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
margin-bottom: 6px;
}
.jump-date-popover .calendar-grid .weekday {
text-align: center;
font-size: 11px;
color: var(--text-tertiary);
}
.jump-date-popover .calendar-grid .days {
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-template-rows: repeat(6, 36px);
gap: 4px;
}
.jump-date-popover .day-cell {
position: relative;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: var(--text-primary);
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1px;
padding: 0;
font-size: 13px;
transition: all 0.18s ease;
}
.jump-date-popover .day-cell .day-number {
position: relative;
z-index: 1;
font-size: 12px;
line-height: 1;
font-weight: 500;
}
.jump-date-popover .day-cell.empty {
cursor: default;
background: transparent;
}
.jump-date-popover .day-cell:not(.empty):not(.no-message):hover {
background: var(--bg-hover);
}
.jump-date-popover .day-cell.today {
border-color: var(--primary-light);
color: var(--primary);
}
.jump-date-popover .day-cell.selected {
background: var(--primary);
color: #fff;
}
.jump-date-popover .day-cell.no-message {
opacity: 0.5;
cursor: default;
}
.jump-date-popover .day-count {
position: static;
margin-top: 1px;
font-size: 13px;
line-height: 1;
color: #16a34a;
font-weight: 700;
}
.jump-date-popover .day-cell.selected .day-count {
color: #86efac;
}
.jump-date-popover .day-count-loading {
position: static;
margin-top: 1px;
color: #22c55e;
}
.jump-date-popover .spin {
animation: jump-date-spin 1s linear infinite;
}
@keyframes jump-date-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,191 @@
import React, { useEffect, useState } from 'react'
import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'
import './JumpToDatePopover.scss'
interface JumpToDatePopoverProps {
isOpen: boolean
onClose: () => void
onSelect: (date: Date) => void
onMonthChange?: (date: Date) => void
className?: string
style?: React.CSSProperties
currentDate?: Date
messageDates?: Set<string>
hasLoadedMessageDates?: boolean
messageDateCounts?: Record<string, number>
loadingDates?: boolean
loadingDateCounts?: boolean
}
const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
isOpen,
onClose,
onSelect,
onMonthChange,
className,
style,
currentDate = new Date(),
messageDates,
hasLoadedMessageDates = false,
messageDateCounts,
loadingDates = false,
loadingDateCounts = false
}) => {
const [calendarDate, setCalendarDate] = useState<Date>(new Date(currentDate))
const [selectedDate, setSelectedDate] = useState<Date>(new Date(currentDate))
useEffect(() => {
if (!isOpen) return
const normalized = new Date(currentDate)
setCalendarDate(normalized)
setSelectedDate(normalized)
}, [isOpen, currentDate])
if (!isOpen) return null
const getDaysInMonth = (date: Date): number => {
const year = date.getFullYear()
const month = date.getMonth()
return new Date(year, month + 1, 0).getDate()
}
const getFirstDayOfMonth = (date: Date): number => {
const year = date.getFullYear()
const month = date.getMonth()
return new Date(year, month, 1).getDay()
}
const toDateKey = (day: number): string => {
const year = calendarDate.getFullYear()
const month = calendarDate.getMonth() + 1
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
}
const hasMessage = (day: number): boolean => {
if (!hasLoadedMessageDates) return true
if (!messageDates || messageDates.size === 0) return false
return messageDates.has(toDateKey(day))
}
const isToday = (day: number): boolean => {
const today = new Date()
return day === today.getDate()
&& calendarDate.getMonth() === today.getMonth()
&& calendarDate.getFullYear() === today.getFullYear()
}
const isSelected = (day: number): boolean => {
return day === selectedDate.getDate()
&& calendarDate.getMonth() === selectedDate.getMonth()
&& calendarDate.getFullYear() === selectedDate.getFullYear()
}
const generateCalendar = (): Array<number | null> => {
const daysInMonth = getDaysInMonth(calendarDate)
const firstDay = getFirstDayOfMonth(calendarDate)
const days: Array<number | null> = []
for (let i = 0; i < firstDay; i++) {
days.push(null)
}
for (let i = 1; i <= daysInMonth; i++) {
days.push(i)
}
return days
}
const handleDateClick = (day: number) => {
if (hasLoadedMessageDates && !hasMessage(day)) return
const targetDate = new Date(calendarDate.getFullYear(), calendarDate.getMonth(), day)
setSelectedDate(targetDate)
onSelect(targetDate)
onClose()
}
const getDayClassName = (day: number | null): string => {
if (day === null) return 'day-cell empty'
const classes = ['day-cell']
if (isToday(day)) classes.push('today')
if (isSelected(day)) classes.push('selected')
if (hasLoadedMessageDates && !hasMessage(day)) classes.push('no-message')
return classes.join(' ')
}
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
const days = generateCalendar()
const mergedClassName = ['jump-date-popover', className || ''].join(' ').trim()
const updateCalendarDate = (nextDate: Date) => {
setCalendarDate(nextDate)
onMonthChange?.(nextDate)
}
return (
<div className={mergedClassName} style={style} role="dialog" aria-label="跳转日期">
<div className="calendar-nav">
<button
className="nav-btn"
onClick={() => updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))}
aria-label="上一月"
>
<ChevronLeft size={16} />
</button>
<span className="current-month">{calendarDate.getFullYear()}{calendarDate.getMonth() + 1}</span>
<button
className="nav-btn"
onClick={() => updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))}
aria-label="下一月"
>
<ChevronRight size={16} />
</button>
</div>
<div className="status-line">
{loadingDates && (
<span className="status-item">
<Loader2 size={12} className="spin" />
<span></span>
</span>
)}
{!loadingDates && loadingDateCounts && (
<span className="status-item">
<Loader2 size={12} className="spin" />
<span></span>
</span>
)}
</div>
<div className="calendar-grid">
<div className="weekdays">
{weekdays.map(day => (
<div key={day} className="weekday">{day}</div>
))}
</div>
<div className="days">
{days.map((day, index) => {
if (day === null) return <div key={index} className="day-cell empty" />
const dateKey = toDateKey(day)
const hasMessageOnDay = hasMessage(day)
const count = Number(messageDateCounts?.[dateKey] || 0)
const showCount = count > 0
const showCountLoading = hasMessageOnDay && loadingDateCounts && !showCount
return (
<button
key={index}
className={getDayClassName(day)}
onClick={() => handleDateClick(day)}
disabled={hasLoadedMessageDates && !hasMessageOnDay}
type="button"
>
<span className="day-number">{day}</span>
{showCount && <span className="day-count">{count}</span>}
{showCountLoading && <Loader2 size={11} className="day-count-loading spin" />}
</button>
)
})}
</div>
</div>
</div>
)
}
export default JumpToDatePopover

View File

@@ -10,6 +10,19 @@
&.collapsed {
width: 64px;
.sidebar-user-card-wrap {
margin: 0 8px 8px;
}
.sidebar-user-card {
padding: 8px 0;
justify-content: center;
.user-meta {
display: none;
}
}
.nav-menu,
.sidebar-footer {
padding: 0 8px;
@@ -27,6 +40,150 @@
}
}
.sidebar-user-card-wrap {
position: relative;
margin: 0 12px 10px;
--sidebar-user-menu-width: 172px;
}
.sidebar-user-menu {
position: absolute;
left: 0;
right: auto;
bottom: calc(100% + 8px);
width: max(100%, var(--sidebar-user-menu-width));
z-index: 12;
border: 1px solid var(--border-color);
border-radius: 12px;
background: var(--bg-secondary-solid, var(--bg-primary));
display: flex;
flex-direction: column;
gap: 4px;
padding: 6px;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12);
opacity: 0;
transform: translateY(8px) scale(0.95);
pointer-events: none;
transition: opacity 0.2s ease, transform 0.2s ease;
&.open {
opacity: 1;
transform: translateY(0) scale(1);
pointer-events: auto;
}
}
.sidebar-user-menu-item {
width: 100%;
border: none;
border-radius: 10px;
background: transparent;
color: var(--text-primary);
padding: 9px 10px;
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
text-align: left;
transition: background 0.2s ease, color 0.2s ease;
&:hover {
background: var(--bg-tertiary);
}
&.danger {
color: #d93025;
&:hover {
background: rgba(255, 59, 48, 0.08);
}
}
}
.sidebar-user-card {
width: 100%;
padding: 10px;
border: 1px solid var(--border-color);
border-radius: 12px;
background: var(--bg-secondary);
display: flex;
align-items: center;
gap: 10px;
min-height: 56px;
cursor: pointer;
transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
&:hover {
border-color: rgba(99, 102, 241, 0.32);
background: var(--bg-tertiary);
}
&.menu-open {
border-color: rgba(99, 102, 241, 0.44);
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.12);
}
.user-avatar {
width: 36px;
height: 36px;
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: 14px;
font-weight: 600;
}
}
.user-meta {
min-width: 0;
flex: 1;
}
.user-name {
font-size: 13px;
color: var(--text-primary);
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-wxid {
margin-top: 2px;
font-size: 11px;
color: var(--text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-menu-caret {
color: var(--text-tertiary);
display: inline-flex;
transition: transform 0.2s ease, color 0.2s ease;
&.open {
transform: rotate(180deg);
color: var(--text-secondary);
}
}
}
.nav-menu {
flex: 1;
display: flex;
@@ -57,7 +214,7 @@
&.active {
background: var(--primary);
color: white;
color: var(--on-primary);
}
}
@@ -70,11 +227,44 @@
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);
@@ -85,24 +275,261 @@
gap: 4px;
}
.collapse-btn {
.sidebar-dialog-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.3);
display: flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 8px;
border: none;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
border-radius: 9999px;
transition: all 0.2s ease;
margin-top: 4px;
z-index: 1100;
padding: 20px;
animation: fadeIn 0.2s ease;
}
&:hover {
background: var(--bg-tertiary);
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.sidebar-dialog {
width: min(420px, 100%);
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 16px;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.24);
padding: 18px 18px 16px;
animation: slideUp 0.25s ease;
h3 {
margin: 0;
font-size: 16px;
color: var(--text-primary);
}
p {
margin: 10px 0 0;
font-size: 13px;
line-height: 1.6;
color: var(--text-secondary);
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.sidebar-wxid-list {
margin-top: 14px;
display: flex;
flex-direction: column;
gap: 8px;
max-height: 300px;
overflow-y: auto;
}
.sidebar-wxid-item {
width: 100%;
padding: 12px 14px;
border: 1px solid var(--border-color);
border-radius: 10px;
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
display: flex;
align-items: center;
gap: 12px;
transition: all 0.2s ease;
&:hover:not(:disabled) {
border-color: rgba(99, 102, 241, 0.32);
background: var(--bg-tertiary);
}
&.current {
border-color: rgba(99, 102, 241, 0.5);
background: var(--bg-tertiary);
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.wxid-avatar {
width: 40px;
height: 40px;
border-radius: 10px;
overflow: hidden;
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
span {
color: var(--on-primary);
font-size: 16px;
font-weight: 600;
}
}
.wxid-info {
flex: 1;
min-width: 0;
text-align: left;
}
.wxid-name {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.wxid-id {
margin-top: 2px;
font-size: 12px;
color: var(--text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.current-badge {
padding: 4px 10px;
border-radius: 6px;
background: var(--primary);
color: var(--on-primary);
font-size: 11px;
font-weight: 600;
flex-shrink: 0;
}
}
.sidebar-dialog-actions {
margin-top: 18px;
display: flex;
justify-content: flex-end;
gap: 10px;
button {
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 8px 14px;
font-size: 13px;
cursor: pointer;
background: var(--bg-secondary);
color: var(--text-primary);
transition: all 0.2s ease;
&:hover:not(:disabled) {
background: var(--bg-tertiary);
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
}
}
}
.sidebar-clear-dialog-overlay {
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;
}
}
// 繁花如梦主题:侧边栏毛玻璃 + 激活项用主品牌色
@@ -130,4 +557,4 @@
background: rgba(209, 158, 187, 0.15);
color: #D19EBB;
border: 1px solid rgba(209, 158, 187, 0.2);
}
}

View File

@@ -1,142 +1,575 @@
import { useState, useEffect } from 'react'
import { NavLink, useLocation } from 'react-router-dom'
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Aperture, UserCircle, Lock } from 'lucide-react'
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 } 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 './Sidebar.scss'
function Sidebar() {
interface SidebarUserProfile {
wxid: string
displayName: string
alias?: string
avatarUrl?: string
}
const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1'
const ACCOUNT_PROFILES_CACHE_KEY = 'account_profiles_cache_v1'
interface SidebarUserProfileCache extends SidebarUserProfile {
updatedAt: number
}
interface AccountProfilesCache {
[wxid: string]: {
displayName: string
avatarUrl?: string
alias?: string
updatedAt: number
}
}
interface WxidOption {
wxid: string
modifiedTime: number
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
return {
wxid: parsed.wxid,
displayName: parsed.displayName,
alias: parsed.alias,
avatarUrl: parsed.avatarUrl
}
} catch {
return null
}
}
const writeSidebarUserProfileCache = (profile: SidebarUserProfile): void => {
if (!profile.wxid || !profile.displayName) return
try {
const payload: SidebarUserProfileCache = {
...profile,
updatedAt: Date.now()
}
window.localStorage.setItem(SIDEBAR_USER_PROFILE_CACHE_KEY, JSON.stringify(payload))
// 同时写入账号缓存池
const accountsCache = readAccountProfilesCache()
accountsCache[profile.wxid] = {
displayName: profile.displayName,
avatarUrl: profile.avatarUrl,
alias: profile.alias,
updatedAt: Date.now()
}
window.localStorage.setItem(ACCOUNT_PROFILES_CACHE_KEY, JSON.stringify(accountsCache))
} catch {
// 忽略本地缓存失败,不影响主流程
}
}
const readAccountProfilesCache = (): AccountProfilesCache => {
try {
const raw = window.localStorage.getItem(ACCOUNT_PROFILES_CACHE_KEY)
if (!raw) return {}
const parsed = JSON.parse(raw)
return typeof parsed === 'object' && parsed ? parsed : {}
} catch {
return {}
}
}
const normalizeAccountId = (value?: string | null): string => {
const trimmed = String(value || '').trim()
if (!trimmed) return ''
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
}
interface SidebarProps {
collapsed: boolean
}
function Sidebar({ collapsed }: SidebarProps) {
const location = useLocation()
const [collapsed, setCollapsed] = useState(false)
const navigate = useNavigate()
const [authEnabled, setAuthEnabled] = useState(false)
const [activeExportTaskCount, setActiveExportTaskCount] = useState(0)
const [userProfile, setUserProfile] = useState<SidebarUserProfile>({
wxid: '',
displayName: '未识别用户'
})
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)
}, [])
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (!isAccountMenuOpen) return
const target = event.target as Node | null
if (accountCardWrapRef.current && target && !accountCardWrapRef.current.contains(target)) {
setIsAccountMenuOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [isAccountMenuOpen])
useEffect(() => {
const unsubscribe = onExportSessionStatus((payload) => {
const countFromPayload = typeof payload?.activeTaskCount === 'number'
? payload.activeTaskCount
: Array.isArray(payload?.inProgressSessionIds)
? payload.inProgressSessionIds.length
: 0
const normalized = Math.max(0, Math.floor(countFromPayload))
setActiveExportTaskCount(normalized)
})
requestExportSessionStatus()
const timer = window.setTimeout(() => requestExportSessionStatus(), 120)
return () => {
unsubscribe()
window.clearTimeout(timer)
}
}, [])
useEffect(() => {
const loadCurrentUser = async () => {
const patchUserProfile = (patch: Partial<SidebarUserProfile>, expectedWxid?: string) => {
setUserProfile(prev => {
if (expectedWxid && prev.wxid && prev.wxid !== expectedWxid) {
return prev
}
const next: SidebarUserProfile = {
...prev,
...patch
}
if (!next.displayName) {
next.displayName = next.wxid || '未识别用户'
}
writeSidebarUserProfileCache(next)
return next
})
}
try {
const wxid = await configService.getMyWxid()
const resolvedWxidRaw = String(wxid || '').trim()
const cleanedWxid = normalizeAccountId(resolvedWxidRaw)
const resolvedWxid = cleanedWxid || resolvedWxidRaw
if (!resolvedWxidRaw && !resolvedWxid) return
const wxidCandidates = new Set<string>([
resolvedWxidRaw.toLowerCase(),
resolvedWxid.trim().toLowerCase(),
cleanedWxid.trim().toLowerCase()
].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 (lowered === 'self') return undefined
if (lowered.startsWith('wxid_')) return undefined
if (wxidCandidates.has(lowered)) return undefined
return trimmed
}
const pickFirstValidName = (...candidates: Array<string | null | undefined>): string | undefined => {
for (const candidate of candidates) {
const normalized = normalizeName(candidate)
if (normalized) return normalized
}
return undefined
}
// 并行获取名称和头像
const [contactResult, avatarResult] = await Promise.allSettled([
(async () => {
const candidates = Array.from(new Set([resolvedWxidRaw, resolvedWxid, cleanedWxid].filter(Boolean)))
for (const candidate of candidates) {
const contact = await window.electronAPI.chat.getContact(candidate)
if (contact?.remark || contact?.nickName || contact?.alias) {
return contact
}
}
return null
})(),
window.electronAPI.chat.getMyAvatarUrl()
])
const myContact = contactResult.status === 'fulfilled' ? contactResult.value : null
const displayName = pickFirstValidName(
myContact?.remark,
myContact?.nickName,
myContact?.alias
) || resolvedWxid || '未识别用户'
patchUserProfile({
wxid: resolvedWxid,
displayName,
alias: myContact?.alias,
avatarUrl: avatarResult.status === 'fulfilled' && avatarResult.value.success
? avatarResult.value.avatarUrl
: undefined
})
} catch (error) {
console.error('加载侧边栏用户信息失败:', error)
}
}
const cachedProfile = readSidebarUserProfileCache()
if (cachedProfile) {
setUserProfile(cachedProfile)
}
void loadCurrentUser()
const onWxidChanged = () => { void loadCurrentUser() }
window.addEventListener('wxid-changed', onWxidChanged as EventListener)
return () => window.removeEventListener('wxid-changed', onWxidChanged as EventListener)
}, [])
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 => {
const normalizedWxid = normalizeAccountId(option.wxid)
const cached = accountsCache[option.wxid] || accountsCache[normalizedWxid]
if (option.wxid === userProfile.wxid || normalizedWxid === userProfile.wxid) {
return {
...option,
displayName: userProfile.displayName,
avatarUrl: userProfile.avatarUrl
}
}
if (cached) {
console.log('[切换账号] 使用缓存:', option.wxid, cached)
return {
...option,
displayName: cached.displayName,
avatarUrl: cached.avatarUrl
}
}
return { ...option, displayName: option.wxid }
})
setWxidOptions(enrichedWxids)
setShowSwitchAccountDialog(true)
} catch (error) {
console.error('扫描账号失败:', error)
window.alert('扫描账号失败,请稍后重试')
}
}
const handleSwitchAccount = async (selectedWxid: string) => {
if (!selectedWxid || isSwitchingAccount) return
setIsSwitchingAccount(true)
try {
console.log('[切换账号] 开始切换到:', selectedWxid)
const currentWxid = userProfile.wxid
if (currentWxid === selectedWxid) {
console.log('[切换账号] 已经是当前账号,跳过')
setShowSwitchAccountDialog(false)
setIsSwitchingAccount(false)
return
}
console.log('[切换账号] 设置新 wxid')
await configService.setMyWxid(selectedWxid)
console.log('[切换账号] 获取账号配置')
const wxidConfig = await configService.getWxidConfig(selectedWxid)
console.log('[切换账号] 配置内容:', wxidConfig)
if (wxidConfig?.decryptKey) {
console.log('[切换账号] 设置 decryptKey')
await configService.setDecryptKey(wxidConfig.decryptKey)
}
if (typeof wxidConfig?.imageXorKey === 'number') {
console.log('[切换账号] 设置 imageXorKey:', wxidConfig.imageXorKey)
await configService.setImageXorKey(wxidConfig.imageXorKey)
}
if (wxidConfig?.imageAesKey) {
console.log('[切换账号] 设置 imageAesKey')
await configService.setImageAesKey(wxidConfig.imageAesKey)
}
console.log('[切换账号] 检查数据库连接状态')
console.log('[切换账号] 数据库连接状态:', isDbConnected)
if (isDbConnected) {
console.log('[切换账号] 关闭数据库连接')
await window.electronAPI.chat.close()
}
console.log('[切换账号] 清除缓存')
window.localStorage.removeItem(SIDEBAR_USER_PROFILE_CACHE_KEY)
clearAnalyticsStoreCache()
resetChatStore()
console.log('[切换账号] 触发 wxid-changed 事件')
window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: selectedWxid } }))
console.log('[切换账号] 切换成功')
setShowSwitchAccountDialog(false)
} catch (error) {
console.error('[切换账号] 失败:', error)
window.alert('切换账号失败,请稍后重试')
} finally {
setIsSwitchingAccount(false)
}
}
const openSettingsFromAccountMenu = () => {
setIsAccountMenuOpen(false)
navigate('/settings', {
state: {
backgroundLocation: location
}
})
}
const isActive = (path: string) => {
return location.pathname === path || location.pathname.startsWith(`${path}/`)
}
const exportTaskBadge = activeExportTaskCount > 99 ? '99+' : `${activeExportTaskCount}`
return (
<aside className={`sidebar ${collapsed ? 'collapsed' : ''}`}>
<nav className="nav-menu">
{/* 首页 */}
<NavLink
to="/home"
className={`nav-item ${isActive('/home') ? 'active' : ''}`}
title={collapsed ? '首页' : undefined}
>
<span className="nav-icon"><Home size={20} /></span>
<span className="nav-label"></span>
</NavLink>
<>
<aside className={`sidebar ${collapsed ? 'collapsed' : ''}`}>
<nav className="nav-menu">
{/* 首页 */}
<NavLink
to="/home"
className={`nav-item ${isActive('/home') ? 'active' : ''}`}
title={collapsed ? '首页' : undefined}
>
<span className="nav-icon"><Home size={20} /></span>
<span className="nav-label"></span>
</NavLink>
{/* 聊天 */}
<NavLink
to="/chat"
className={`nav-item ${isActive('/chat') ? 'active' : ''}`}
title={collapsed ? '聊天' : undefined}
>
<span className="nav-icon"><MessageSquare size={20} /></span>
<span className="nav-label"></span>
</NavLink>
{/* 聊天 */}
<NavLink
to="/chat"
className={`nav-item ${isActive('/chat') ? 'active' : ''}`}
title={collapsed ? '聊天' : undefined}
>
<span className="nav-icon"><MessageSquare size={20} /></span>
<span className="nav-label"></span>
</NavLink>
{/* 朋友圈 */}
<NavLink
to="/sns"
className={`nav-item ${isActive('/sns') ? 'active' : ''}`}
title={collapsed ? '朋友圈' : undefined}
>
<span className="nav-icon"><Aperture size={20} /></span>
<span className="nav-label"></span>
</NavLink>
{/* 朋友圈 */}
<NavLink
to="/sns"
className={`nav-item ${isActive('/sns') ? 'active' : ''}`}
title={collapsed ? '朋友圈' : undefined}
>
<span className="nav-icon"><Aperture size={20} /></span>
<span className="nav-label"></span>
</NavLink>
{/* 通讯录 */}
<NavLink
to="/contacts"
className={`nav-item ${isActive('/contacts') ? 'active' : ''}`}
title={collapsed ? '通讯录' : undefined}
>
<span className="nav-icon"><UserCircle size={20} /></span>
<span className="nav-label"></span>
</NavLink>
{/* 通讯录 */}
<NavLink
to="/contacts"
className={`nav-item ${isActive('/contacts') ? 'active' : ''}`}
title={collapsed ? '通讯录' : undefined}
>
<span className="nav-icon"><UserCircle size={20} /></span>
<span className="nav-label"></span>
</NavLink>
{/* 聊分析 */}
<NavLink
to="/analytics"
className={`nav-item ${isActive('/analytics') ? 'active' : ''}`}
title={collapsed ? '聊分析' : undefined}
>
<span className="nav-icon"><BarChart3 size={20} /></span>
<span className="nav-label"></span>
</NavLink>
{/* 聊分析 */}
<NavLink
to="/analytics"
className={`nav-item ${isActive('/analytics') ? 'active' : ''}`}
title={collapsed ? '聊分析' : undefined}
>
<span className="nav-icon"><BarChart3 size={20} /></span>
<span className="nav-label"></span>
</NavLink>
{/* 群聊分析 */}
<NavLink
to="/group-analytics"
className={`nav-item ${isActive('/group-analytics') ? 'active' : ''}`}
title={collapsed ? '群聊分析' : undefined}
>
<span className="nav-icon"><Users size={20} /></span>
<span className="nav-label"></span>
</NavLink>
{/* 年度报告 */}
<NavLink
to="/annual-report"
className={`nav-item ${isActive('/annual-report') ? 'active' : ''}`}
title={collapsed ? '年度报告' : undefined}
>
<span className="nav-icon"><FileText size={20} /></span>
<span className="nav-label"></span>
</NavLink>
{/* 年度报告 */}
<NavLink
to="/annual-report"
className={`nav-item ${isActive('/annual-report') ? 'active' : ''}`}
title={collapsed ? '年度报告' : undefined}
>
<span className="nav-icon"><FileText size={20} /></span>
<span className="nav-label"></span>
</NavLink>
{/* 导出 */}
<NavLink
to="/export"
className={`nav-item ${isActive('/export') ? 'active' : ''}`}
title={collapsed ? '导出' : undefined}
>
<span className="nav-icon"><Download size={20} /></span>
<span className="nav-label"></span>
</NavLink>
{/* 导出 */}
<NavLink
to="/export"
className={`nav-item ${isActive('/export') ? 'active' : ''}`}
title={collapsed ? '导出' : undefined}
>
<span className="nav-icon nav-icon-with-badge">
<Download size={20} />
{collapsed && activeExportTaskCount > 0 && (
<span className="nav-badge icon-badge">{exportTaskBadge}</span>
)}
</span>
<span className="nav-label"></span>
{!collapsed && activeExportTaskCount > 0 && (
<span className="nav-badge">{exportTaskBadge}</span>
)}
</NavLink>
</nav>
</nav>
<div className="sidebar-footer">
{authEnabled && (
<div className="sidebar-footer">
<button
className="nav-item"
onClick={() => setLocked(true)}
title={collapsed ? '锁定' : undefined}
onClick={() => {
if (authEnabled) {
setLocked(true)
return
}
navigate('/settings', {
state: {
initialTab: 'security',
backgroundLocation: location
}
})
}}
title={collapsed ? (authEnabled ? '锁定' : '未锁定') : undefined}
>
<span className="nav-icon"><Lock size={20} /></span>
<span className="nav-label"></span>
<span className="nav-icon">{authEnabled ? <Lock size={20} /> : <LockOpen size={20} />}</span>
<span className="nav-label">{authEnabled ? '锁定' : '未锁定'}</span>
</button>
)}
<NavLink
to="/settings"
className={`nav-item ${isActive('/settings') ? 'active' : ''}`}
title={collapsed ? '设置' : undefined}
>
<span className="nav-icon">
<Settings size={20} />
</span>
<span className="nav-label"></span>
</NavLink>
<div className="sidebar-user-card-wrap" ref={accountCardWrapRef}>
<div className={`sidebar-user-menu ${isAccountMenuOpen ? 'open' : ''}`} role="menu" aria-label="账号菜单">
<button
className="sidebar-user-menu-item"
onClick={openSwitchAccountDialog}
type="button"
role="menuitem"
>
<RefreshCw size={14} />
<span></span>
</button>
<button
className="sidebar-user-menu-item"
onClick={openSettingsFromAccountMenu}
type="button"
role="menuitem"
>
<Settings size={14} />
<span></span>
</button>
</div>
<div
className={`sidebar-user-card ${isAccountMenuOpen ? 'menu-open' : ''}`}
title={collapsed ? `${userProfile.displayName}${(userProfile.alias || userProfile.wxid) ? `\n${userProfile.alias || userProfile.wxid}` : ''}` : undefined}
onClick={() => setIsAccountMenuOpen(prev => !prev)}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
setIsAccountMenuOpen(prev => !prev)
}
}}
>
<div className="user-avatar">
{userProfile.avatarUrl ? <img src={userProfile.avatarUrl} alt="" /> : <span>{getAvatarLetter(userProfile.displayName)}</span>}
</div>
<div className="user-meta">
<div className="user-name">{userProfile.displayName}</div>
<div className="user-wxid">{userProfile.alias || userProfile.wxid || 'wxid 未识别'}</div>
</div>
{!collapsed && (
<span className={`user-menu-caret ${isAccountMenuOpen ? 'open' : ''}`}>
<ChevronUp size={14} />
</span>
)}
</div>
</div>
</div>
</aside>
<button
className="collapse-btn"
onClick={() => setCollapsed(!collapsed)}
title={collapsed ? '展开菜单' : '收起菜单'}
>
{collapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
</button>
</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="" /> : <span>{getAvatarLetter(option.displayName || option.wxid)}</span>}
</div>
<div className="wxid-info">
<div className="wxid-name">{option.displayName || option.wxid}</div>
<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

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

View File

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

View File

@@ -1,67 +1,83 @@
import React, { useState } from 'react'
import { Search, Calendar, User, X, Filter, Check } from 'lucide-react'
import React from 'react'
import { Search, User, X, Loader2, CheckSquare, Square, Download } from 'lucide-react'
import { Avatar } from '../Avatar'
// import JumpToDateDialog from '../JumpToDateDialog' // Assuming this is imported from parent or moved
interface Contact {
username: string
displayName: string
avatarUrl?: string
postCount?: number
postCountStatus?: 'idle' | 'loading' | 'ready'
}
interface ContactsCountProgress {
resolved: number
total: number
running: boolean
}
interface SnsFilterPanelProps {
searchKeyword: string
setSearchKeyword: (val: string) => void
jumpTargetDate?: Date
setJumpTargetDate: (date?: Date) => void
onOpenJumpDialog: () => void
selectedUsernames: string[]
setSelectedUsernames: (val: string[]) => void
totalFriendsLabel?: string
contacts: Contact[]
contactSearch: string
setContactSearch: (val: string) => void
loading?: boolean
contactsCountProgress?: ContactsCountProgress
selectedContactUsernames: string[]
activeContactUsername?: string
onOpenContactTimeline: (contact: Contact) => void
onToggleContactSelected: (contact: Contact) => void
onClearSelectedContacts: () => void
onExportSelectedContacts: () => void
}
export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
searchKeyword,
setSearchKeyword,
jumpTargetDate,
setJumpTargetDate,
onOpenJumpDialog,
selectedUsernames,
setSelectedUsernames,
totalFriendsLabel,
contacts,
contactSearch,
setContactSearch,
loading
loading,
contactsCountProgress,
selectedContactUsernames,
activeContactUsername,
onOpenContactTimeline,
onToggleContactSelected,
onClearSelectedContacts,
onExportSelectedContacts
}) => {
const filteredContacts = contacts.filter(c =>
c.displayName.toLowerCase().includes(contactSearch.toLowerCase()) ||
(c.displayName || '').toLowerCase().includes(contactSearch.toLowerCase()) ||
c.username.toLowerCase().includes(contactSearch.toLowerCase())
)
const toggleUserSelection = (username: string) => {
if (selectedUsernames.includes(username)) {
setSelectedUsernames(selectedUsernames.filter(u => u !== username))
} else {
setJumpTargetDate(undefined) // Reset date jump when selecting user
setSelectedUsernames([...selectedUsernames, username])
}
}
const selectedContactLookup = React.useMemo(
() => new Set(selectedContactUsernames),
[selectedContactUsernames]
)
const clearFilters = () => {
setSearchKeyword('')
setSelectedUsernames([])
setJumpTargetDate(undefined)
setContactSearch('')
}
const getEmptyStateText = () => {
if (loading && contacts.length === 0) {
return '正在加载联系人...'
}
if (contacts.length === 0) {
return '暂无好友或曾经的好友'
}
return '没有找到联系人'
}
return (
<aside className="sns-filter-panel">
<div className="filter-header">
<h3></h3>
{(searchKeyword || jumpTargetDate || selectedUsernames.length > 0) && (
{(searchKeyword || contactSearch) && (
<button className="reset-all-btn" onClick={clearFilters} title="重置所有筛选">
<RefreshCw size={14} />
</button>
@@ -89,43 +105,13 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
)}
</div>
</div>
{/* Date Widget */}
<div className="filter-widget date-widget">
<div className="widget-header">
<Calendar size={14} />
<span></span>
</div>
<button
className={`date-picker-trigger ${jumpTargetDate ? 'active' : ''}`}
onClick={onOpenJumpDialog}
>
<span className="date-text">
{jumpTargetDate
? jumpTargetDate.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' })
: '选择日期...'}
</span>
{jumpTargetDate && (
<div
className="clear-date-btn"
onClick={(e) => {
e.stopPropagation()
setJumpTargetDate(undefined)
}}
>
<X size={12} />
</div>
)}
</button>
</div>
{/* Contact Widget */}
<div className="filter-widget contact-widget">
<div className="widget-header">
<User size={14} />
<span></span>
{selectedUsernames.length > 0 && (
<span className="badge">{selectedUsernames.length}</span>
{totalFriendsLabel && (
<span className="widget-header-summary">{totalFriendsLabel}</span>
)}
</div>
@@ -142,21 +128,77 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
)}
</div>
{contactsCountProgress && contactsCountProgress.total > 0 && (
<div className="contact-count-progress">
{contactsCountProgress.running
? `朋友圈条数统计中 ${contactsCountProgress.resolved}/${contactsCountProgress.total}`
: `朋友圈条数已统计 ${contactsCountProgress.total}/${contactsCountProgress.total}`}
</div>
)}
<div className="contact-interaction-hint">
</div>
<div className="contact-list-scroll">
{filteredContacts.map(contact => (
<div
key={contact.username}
className={`contact-row ${selectedUsernames.includes(contact.username) ? 'selected' : ''}`}
onClick={() => toggleUserSelection(contact.username)}
>
<Avatar src={contact.avatarUrl} name={contact.displayName} size={36} shape="rounded" />
<span className="contact-name">{contact.displayName}</span>
</div>
))}
{filteredContacts.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 && (
<div className="empty-state"></div>
<div className="empty-state">{getEmptyStateText()}</div>
)}
</div>
{selectedContactUsernames.length > 0 && (
<div className="contact-batch-bar">
<span className="contact-batch-summary"> {selectedContactUsernames.length} </span>
<button type="button" className="contact-batch-btn" onClick={onClearSelectedContacts}>
</button>
<button type="button" className="contact-batch-btn primary" onClick={onExportSelectedContacts}>
<Download size={14} />
<span></span>
</button>
</div>
)}
</div>
</div>
</aside>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,15 @@
.analytics-page-shell {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 100%;
.loading-container,
.error-container {
flex: 1;
}
}
// 加载和错误状态
.loading-container,
.error-container {
@@ -53,24 +65,6 @@
}
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
h1 {
margin: 0;
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
}
}
@keyframes spin {
from {
transform: rotate(0deg);

View File

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

View File

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

View File

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

View File

@@ -26,6 +26,48 @@
margin: 0 0 48px;
}
.page-desc.load-summary {
margin: 0 0 28px;
}
.page-desc.load-summary.complete {
color: var(--text-secondary);
}
.load-telemetry {
width: min(760px, 100%);
padding: 12px 14px;
margin: 0 0 28px;
border-radius: 12px;
border: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent);
background: color-mix(in srgb, var(--card-bg) 92%, transparent);
text-align: left;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
p {
margin: 4px 0;
}
.label {
color: var(--text-tertiary);
}
}
.load-telemetry.loading {
border-color: color-mix(in srgb, var(--primary) 30%, var(--border-color));
}
.load-telemetry.complete {
border-color: color-mix(in srgb, var(--primary) 40%, var(--border-color));
}
.load-telemetry.compact {
margin: 12px 0 0;
width: min(560px, 100%);
}
.report-sections {
display: flex;
flex-direction: column;
@@ -83,6 +125,14 @@
color: var(--text-tertiary);
}
.year-grid-with-status {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
}
.year-grid {
display: flex;
flex-wrap: wrap;
@@ -95,7 +145,39 @@
.report-section .year-grid {
justify-content: flex-start;
max-width: none;
margin-bottom: 24px;
margin-bottom: 0;
}
.year-grid-with-status .year-grid {
flex: 1;
}
.year-load-status {
display: inline-flex;
align-items: center;
font-size: 12px;
color: var(--text-tertiary);
white-space: nowrap;
margin-top: 6px;
flex-shrink: 0;
}
.year-load-status.complete {
color: color-mix(in srgb, var(--primary) 80%, var(--text-secondary));
}
.dot-ellipsis {
display: inline-block;
width: 0;
overflow: hidden;
vertical-align: bottom;
animation: dot-ellipsis 1.2s steps(4, end) infinite;
}
.year-load-status.complete .dot-ellipsis,
.page-desc.load-summary.complete .dot-ellipsis {
animation: none;
width: 0;
}
.year-card {
@@ -185,3 +267,7 @@
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes dot-ellipsis {
to { width: 1.4em; }
}

View File

@@ -1,9 +1,37 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Calendar, Loader2, Sparkles, Users } from 'lucide-react'
import {
finishBackgroundTask,
isBackgroundTaskCancelRequested,
registerBackgroundTask,
updateBackgroundTask
} from '../services/backgroundTaskMonitor'
import './AnnualReportPage.scss'
type YearOption = number | 'all'
type YearsLoadPayload = {
years?: number[]
done: boolean
error?: string
canceled?: boolean
strategy?: 'cache' | 'native' | 'hybrid'
phase?: 'cache' | 'native' | 'scan' | 'done'
statusText?: string
nativeElapsedMs?: number
scanElapsedMs?: number
totalElapsedMs?: number
switched?: boolean
nativeTimedOut?: boolean
}
const formatLoadElapsed = (ms: number) => {
const totalSeconds = Math.max(0, ms) / 1000
if (totalSeconds < 60) return `${totalSeconds.toFixed(1)}s`
const minutes = Math.floor(totalSeconds / 60)
const seconds = Math.floor(totalSeconds % 60)
return `${minutes}m ${String(seconds).padStart(2, '0')}s`
}
function AnnualReportPage() {
const navigate = useNavigate()
@@ -11,32 +39,152 @@ function AnnualReportPage() {
const [selectedYear, setSelectedYear] = useState<YearOption | null>(null)
const [selectedPairYear, setSelectedPairYear] = useState<YearOption | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isLoadingMoreYears, setIsLoadingMoreYears] = useState(false)
const [hasYearsLoadFinished, setHasYearsLoadFinished] = useState(false)
const [loadStrategy, setLoadStrategy] = useState<'cache' | 'native' | 'hybrid'>('native')
const [loadPhase, setLoadPhase] = useState<'cache' | 'native' | 'scan' | 'done'>('native')
const [loadStatusText, setLoadStatusText] = useState('准备加载年份数据...')
const [nativeElapsedMs, setNativeElapsedMs] = useState(0)
const [scanElapsedMs, setScanElapsedMs] = useState(0)
const [totalElapsedMs, setTotalElapsedMs] = useState(0)
const [hasSwitchedStrategy, setHasSwitchedStrategy] = useState(false)
const [nativeTimedOut, setNativeTimedOut] = useState(false)
const [isGenerating, setIsGenerating] = useState(false)
const [loadError, setLoadError] = useState<string | null>(null)
useEffect(() => {
loadAvailableYears()
}, [])
let disposed = false
let taskId = ''
let uiTaskId = ''
const loadAvailableYears = async () => {
setIsLoading(true)
setLoadError(null)
try {
const result = await window.electronAPI.annualReport.getAvailableYears()
if (result.success && result.data && result.data.length > 0) {
setAvailableYears(result.data)
setSelectedYear((prev) => prev ?? result.data[0])
setSelectedPairYear((prev) => prev ?? result.data[0])
} else if (!result.success) {
setLoadError(result.error || '加载年度数据失败')
const applyLoadPayload = (payload: YearsLoadPayload) => {
if (uiTaskId) {
updateBackgroundTask(uiTaskId, {
detail: payload.statusText || '正在加载可用年份',
progressText: payload.done
? '已完成'
: `${Array.isArray(payload.years) ? payload.years.length : 0} 个年份`
})
}
if (payload.strategy) setLoadStrategy(payload.strategy)
if (payload.phase) setLoadPhase(payload.phase)
if (typeof payload.statusText === 'string' && payload.statusText) setLoadStatusText(payload.statusText)
if (typeof payload.nativeElapsedMs === 'number' && Number.isFinite(payload.nativeElapsedMs)) {
setNativeElapsedMs(Math.max(0, payload.nativeElapsedMs))
}
if (typeof payload.scanElapsedMs === 'number' && Number.isFinite(payload.scanElapsedMs)) {
setScanElapsedMs(Math.max(0, payload.scanElapsedMs))
}
if (typeof payload.totalElapsedMs === 'number' && Number.isFinite(payload.totalElapsedMs)) {
setTotalElapsedMs(Math.max(0, payload.totalElapsedMs))
}
if (typeof payload.switched === 'boolean') setHasSwitchedStrategy(payload.switched)
if (typeof payload.nativeTimedOut === 'boolean') setNativeTimedOut(payload.nativeTimedOut)
const years = Array.isArray(payload.years) ? payload.years : []
if (years.length > 0) {
setAvailableYears(years)
setSelectedYear((prev) => {
if (prev === 'all') return prev
if (typeof prev === 'number' && years.includes(prev)) return prev
return years[0]
})
setSelectedPairYear((prev) => {
if (prev === 'all') return prev
if (typeof prev === 'number' && years.includes(prev)) return prev
return years[0]
})
setIsLoading(false)
}
if (payload.error && !payload.canceled) {
setLoadError(payload.error || '加载年度数据失败')
}
if (payload.done) {
setIsLoading(false)
setIsLoadingMoreYears(false)
setHasYearsLoadFinished(true)
setLoadPhase('done')
if (uiTaskId) {
finishBackgroundTask(uiTaskId, payload.canceled ? 'canceled' : 'completed', {
detail: payload.canceled
? '年度报告年份加载已停止'
: `年度报告年份加载完成,共 ${years.length} 个年份`,
progressText: payload.canceled ? '已停止' : `${years.length} 个年份`
})
}
} else {
setIsLoadingMoreYears(true)
setHasYearsLoadFinished(false)
}
} catch (e) {
console.error(e)
setLoadError(String(e))
} finally {
setIsLoading(false)
}
}
const stopListen = window.electronAPI.annualReport.onAvailableYearsProgress((payload) => {
if (disposed) return
if (taskId && payload.taskId !== taskId) return
if (!taskId) taskId = payload.taskId
applyLoadPayload(payload)
})
const startLoad = async () => {
uiTaskId = registerBackgroundTask({
sourcePage: 'annualReport',
title: '年度报告年份加载',
detail: '准备使用原生快速模式加载年份',
progressText: '初始化',
cancelable: true,
onCancel: async () => {
if (taskId) {
await window.electronAPI.annualReport.cancelAvailableYearsLoad(taskId)
}
}
})
setIsLoading(true)
setIsLoadingMoreYears(true)
setHasYearsLoadFinished(false)
setLoadStrategy('native')
setLoadPhase('native')
setLoadStatusText('准备使用原生快速模式加载年份...')
setNativeElapsedMs(0)
setScanElapsedMs(0)
setTotalElapsedMs(0)
setHasSwitchedStrategy(false)
setNativeTimedOut(false)
setLoadError(null)
try {
const startResult = await window.electronAPI.annualReport.startAvailableYearsLoad()
if (!startResult.success || !startResult.taskId) {
finishBackgroundTask(uiTaskId, 'failed', {
detail: startResult.error || '加载年度数据失败'
})
setLoadError(startResult.error || '加载年度数据失败')
setIsLoading(false)
setIsLoadingMoreYears(false)
return
}
taskId = startResult.taskId
if (startResult.snapshot) {
applyLoadPayload(startResult.snapshot)
}
} catch (e) {
console.error(e)
finishBackgroundTask(uiTaskId, 'failed', {
detail: String(e)
})
setLoadError(String(e))
setIsLoading(false)
setIsLoadingMoreYears(false)
}
}
void startLoad()
return () => {
disposed = true
stopListen()
}
}, [])
const handleGenerateReport = async () => {
if (selectedYear === null) return
@@ -57,16 +205,16 @@ function AnnualReportPage() {
navigate(`/dual-report?year=${yearParam}`)
}
if (isLoading) {
if (isLoading && availableYears.length === 0) {
return (
<div className="annual-report-page">
<Loader2 size={32} className="spin" style={{ color: 'var(--text-tertiary)' }} />
<p style={{ color: 'var(--text-tertiary)', marginTop: 16 }}>...</p>
<p style={{ color: 'var(--text-tertiary)', marginTop: 16 }}>...</p>
</div>
)
}
if (availableYears.length === 0) {
if (availableYears.length === 0 && !isLoadingMoreYears) {
return (
<div className="annual-report-page">
<Calendar size={64} style={{ color: 'var(--text-tertiary)', opacity: 0.5 }} />
@@ -87,6 +235,21 @@ function AnnualReportPage() {
return value === 'all' ? '全部时间' : `${value}`
}
const loadedYearCount = availableYears.length
const isYearStatusComplete = hasYearsLoadFinished
const strategyLabel = getStrategyLabel({ loadStrategy, loadPhase, hasYearsLoadFinished, hasSwitchedStrategy, nativeTimedOut })
const renderYearLoadStatus = () => (
<div className={`year-load-status ${isYearStatusComplete ? 'complete' : 'loading'}`}>
{isYearStatusComplete ? (
<></>
) : (
<>
<span className="dot-ellipsis" aria-hidden="true">...</span>
</>
)}
</div>
)
return (
<div className="annual-report-page">
<Sparkles size={32} className="header-icon" />
@@ -102,17 +265,19 @@ function AnnualReportPage() {
</div>
</div>
<div className="year-grid">
{yearOptions.map(option => (
<div
key={option}
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedYear === option ? 'selected' : ''}`}
onClick={() => setSelectedYear(option)}
>
<span className="year-number">{option === 'all' ? '全部' : option}</span>
<span className="year-label">{option === 'all' ? '时间' : '年'}</span>
</div>
))}
<div className="year-grid-with-status">
<div className="year-grid">
{yearOptions.map(option => (
<div
key={option}
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedYear === option ? 'selected' : ''}`}
onClick={() => setSelectedYear(option)}
>
<span className="year-number">{option === 'all' ? '全部' : option}</span>
<span className="year-label">{option === 'all' ? '时间' : '年'}</span>
</div>
))}
</div>
</div>
<button
@@ -146,17 +311,19 @@ function AnnualReportPage() {
</div>
</div>
<div className="year-grid">
{yearOptions.map(option => (
<div
key={`pair-${option}`}
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedPairYear === option ? 'selected' : ''}`}
onClick={() => setSelectedPairYear(option)}
>
<span className="year-number">{option === 'all' ? '全部' : option}</span>
<span className="year-label">{option === 'all' ? '时间' : '年'}</span>
</div>
))}
<div className="year-grid-with-status">
<div className="year-grid">
{yearOptions.map(option => (
<div
key={`pair-${option}`}
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedPairYear === option ? 'selected' : ''}`}
onClick={() => setSelectedPairYear(option)}
>
<span className="year-number">{option === 'all' ? '全部' : option}</span>
<span className="year-label">{option === 'all' ? '时间' : '年'}</span>
</div>
))}
</div>
</div>
<button
@@ -174,4 +341,23 @@ function AnnualReportPage() {
)
}
function getStrategyLabel(params: {
loadStrategy: 'cache' | 'native' | 'hybrid'
loadPhase: 'cache' | 'native' | 'scan' | 'done'
hasYearsLoadFinished: boolean
hasSwitchedStrategy: boolean
nativeTimedOut: boolean
}): string {
const { loadStrategy, loadPhase, hasYearsLoadFinished, hasSwitchedStrategy, nativeTimedOut } = params
if (loadStrategy === 'cache') return '缓存模式(快速)'
if (hasYearsLoadFinished) {
if (loadStrategy === 'native') return '原生快速模式'
if (hasSwitchedStrategy || nativeTimedOut) return '混合策略(原生→扫表)'
return '扫表兼容模式'
}
if (loadPhase === 'native') return '原生快速模式(优先)'
if (loadPhase === 'scan') return '扫表兼容模式(回退)'
return '混合策略'
}
export default AnnualReportPage

View File

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

View File

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

View File

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

View File

@@ -33,6 +33,16 @@
gap: 12px;
align-items: flex-start;
&.error-item {
padding: 12px;
background: var(--bg-secondary);
border-radius: 8px;
color: var(--text-tertiary);
font-size: 13px;
text-align: center;
justify-content: center;
}
.avatar {
width: 40px;
height: 40px;

View File

@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { useParams, useLocation } from 'react-router-dom'
import { ChatRecordItem } from '../types/models'
import TitleBar from '../components/TitleBar'
import { ErrorBoundary } from '../components/ErrorBoundary'
import './ChatHistoryPage.scss'
export default function ChatHistoryPage() {
@@ -166,7 +167,9 @@ export default function ChatHistoryPage() {
<div className="status-msg empty"></div>
) : (
recordList.map((item, i) => (
<HistoryItem key={i} item={item} />
<ErrorBoundary key={i} fallback={<div className="history-item error-item"></div>}>
<HistoryItem item={item} />
</ErrorBoundary>
))
)}
</div>
@@ -175,6 +178,8 @@ export default function ChatHistoryPage() {
}
function HistoryItem({ item }: { item: ChatRecordItem }) {
const [imageError, setImageError] = useState(false)
// sourcetime 在合并转发里有两种格式:
// 1) 时间戳(秒) 2) 已格式化的字符串 "2026-01-21 09:56:46"
let time = ''
@@ -197,19 +202,16 @@ function HistoryItem({ item }: { item: ChatRecordItem }) {
if (src) {
return (
<div className="media-content">
<img
src={src}
alt="图片"
referrerPolicy="no-referrer"
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = 'none'
const placeholder = document.createElement('div')
placeholder.className = 'media-tip'
placeholder.textContent = '图片无法加载'
target.parentElement?.appendChild(placeholder)
}}
/>
{imageError ? (
<div className="media-tip"></div>
) : (
<img
src={src}
alt="图片"
referrerPolicy="no-referrer"
onError={() => setImageError(true)}
/>
)}
</div>
)
}

View File

@@ -490,6 +490,18 @@
gap: 8px;
-webkit-app-region: no-drag;
.jump-calendar-anchor {
position: relative;
display: flex;
align-items: center;
isolation: isolate;
z-index: 20;
.jump-date-popover {
z-index: 2600;
}
}
.icon-btn {
width: 34px;
height: 34px;
@@ -534,6 +546,22 @@
overflow: hidden;
}
.export-prepare-hint {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 24px;
font-size: 12px;
color: var(--text-secondary);
border-bottom: 1px solid var(--border-color);
background: var(--bg-tertiary);
-webkit-app-region: no-drag;
.spin {
animation: spin 1s linear infinite;
}
}
.message-list {
flex: 1;
background: var(--chat-pattern);
@@ -815,6 +843,24 @@
min-width: 0;
}
.session-sync-indicator {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: var(--bg-primary);
color: var(--text-tertiary);
font-size: 11px;
white-space: nowrap;
border: 1px solid var(--border-color);
flex-shrink: 0;
.spin {
animation: spin 0.9s linear infinite;
}
}
.search-box {
flex: 1;
display: flex;
@@ -1559,6 +1605,7 @@
align-items: center;
gap: 12px;
border-bottom: 1px solid var(--border-color);
-webkit-app-region: drag;
.session-avatar {
width: 40px;
@@ -1592,6 +1639,14 @@
display: flex;
align-items: center;
gap: 8px;
-webkit-app-region: no-drag;
.jump-calendar-anchor {
position: relative;
display: flex;
align-items: center;
isolation: isolate;
}
}
.icon-btn {
@@ -1624,6 +1679,10 @@
opacity: 0.5;
cursor: not-allowed;
}
.spin {
animation: spin 1s linear infinite;
}
}
}
@@ -1651,6 +1710,33 @@
opacity: 0;
pointer-events: none;
}
&.switching .message-list {
opacity: 0.42;
transform: scale(0.995);
filter: saturate(0.72) blur(1px);
pointer-events: none;
}
&.switching .loading-overlay {
background: rgba(127, 127, 127, 0.18);
backdrop-filter: blur(4px);
}
}
.export-prepare-hint {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 20px;
font-size: 12px;
color: var(--text-secondary);
border-bottom: 1px solid var(--border-color);
background: var(--bg-tertiary);
.spin {
animation: spin 1s linear infinite;
}
}
.message-list {
@@ -1666,7 +1752,7 @@
background-color: var(--bg-tertiary);
position: relative;
-webkit-app-region: no-drag !important;
transition: opacity 240ms ease, transform 240ms ease;
transition: opacity 240ms ease, transform 240ms ease, filter 220ms ease;
// 滚动条样式
&::-webkit-scrollbar {
@@ -1699,6 +1785,30 @@
z-index: 2;
}
.standalone-phase-overlay {
position: absolute;
inset: 0;
z-index: 3;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
background: color-mix(in srgb, var(--bg-tertiary) 82%, transparent);
color: var(--text-secondary);
font-size: 14px;
pointer-events: none;
.spin {
animation: spin 1s linear infinite;
}
small {
color: var(--text-tertiary);
font-size: 12px;
}
}
.empty-chat-inline {
display: flex;
flex-direction: column;
@@ -2662,6 +2772,13 @@
opacity: 0.7;
}
}
.detail-stats-meta {
margin-top: -6px;
margin-bottom: 10px;
font-size: 12px;
color: var(--text-tertiary);
}
}
.detail-item {
@@ -2699,6 +2816,26 @@
}
}
.detail-inline-btn {
border: none;
background: var(--bg-secondary);
color: var(--primary);
border-radius: 6px;
padding: 4px 8px;
font-size: 12px;
line-height: 1;
cursor: pointer;
&:disabled {
cursor: not-allowed;
opacity: 0.7;
}
&:hover:not(:disabled) {
background: var(--bg-hover);
}
}
.copy-btn {
display: flex;
align-items: center;
@@ -2736,6 +2873,14 @@
gap: 8px;
}
.detail-table-placeholder {
padding: 10px 12px;
background: var(--bg-secondary);
border-radius: 8px;
font-size: 12px;
color: var(--text-secondary);
}
.table-item {
display: flex;
align-items: center;
@@ -2757,6 +2902,188 @@
}
}
.group-members-panel {
.group-members-toolbar {
padding: 12px 16px 10px;
border-bottom: 1px solid var(--border-color);
display: flex;
flex-direction: column;
gap: 8px;
}
.group-members-count {
font-size: 12px;
color: var(--text-secondary);
}
.group-members-search {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border: 1px solid var(--border-color);
border-radius: 10px;
background: var(--bg-secondary);
svg {
color: var(--text-tertiary);
flex-shrink: 0;
}
input {
flex: 1;
border: none;
outline: none;
background: transparent;
color: var(--text-primary);
font-size: 13px;
&::placeholder {
color: var(--text-tertiary);
}
}
}
.group-members-status {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 16px;
font-size: 12px;
color: var(--text-secondary);
border-bottom: 1px solid var(--border-color);
background: var(--bg-secondary);
.spin {
animation: spin 1s linear infinite;
}
&.warning {
color: #b45309;
background: color-mix(in srgb, #f59e0b 10%, transparent);
}
}
.group-members-list {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 4px 0;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
background: var(--text-tertiary);
border-radius: 2px;
}
}
.group-member-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 16px;
border-bottom: 1px solid var(--border-color);
&:last-child {
border-bottom: none;
}
}
.group-member-main {
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
flex: 1;
}
.group-member-avatar {
flex-shrink: 0;
}
.group-member-meta {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.group-member-name-row {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.group-member-name {
font-size: 13px;
color: var(--text-primary);
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.group-member-id {
font-size: 11px;
color: var(--text-tertiary);
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.group-member-badges {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.member-flag {
width: 18px;
height: 18px;
border-radius: 9999px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border-color);
&.owner {
color: #f59e0b;
background: color-mix(in srgb, #f59e0b 16%, transparent);
border-color: color-mix(in srgb, #f59e0b 35%, var(--border-color));
}
&.friend {
color: var(--primary);
background: color-mix(in srgb, var(--primary) 14%, transparent);
border-color: color-mix(in srgb, var(--primary) 35%, var(--border-color));
}
}
.group-member-count {
flex-shrink: 0;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
&.loading {
color: var(--text-tertiary);
font-weight: 500;
}
&.failed {
color: #b45309;
font-weight: 600;
}
}
}
@keyframes slideInRight {
from {
opacity: 0;
@@ -4115,21 +4442,191 @@
// 折叠群入口样式
.session-item.fold-entry {
background: var(--card-inner-bg, rgba(0,0,0,0.03));
cursor: pointer;
transition: background-color 0.2s;
&:hover {
background: var(--hover-bg, rgba(0,0,0,0.05));
}
.fold-entry-avatar {
width: 48px;
height: 48px;
border-radius: 8px;
background: var(--primary-color, #07c160);
background: #fff;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #fff;
color: #fa9d3b;
}
.session-name {
font-weight: 500;
}
}
}
// 消息信息弹窗
.message-info-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(4px);
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
}
.message-info-modal {
width: 360px;
max-width: 90vw;
max-height: 80vh;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
display: flex;
flex-direction: column;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
overflow: hidden;
.detail-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid var(--border-color);
h4 {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.close-btn {
background: none;
border: none;
padding: 4px;
cursor: pointer;
color: var(--text-secondary);
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
}
}
.detail-content {
flex: 1;
overflow-y: auto;
padding: 16px;
&::-webkit-scrollbar { width: 4px; }
&::-webkit-scrollbar-thumb { background: var(--text-tertiary); border-radius: 2px; }
}
.detail-section {
margin-bottom: 20px;
&:last-child { margin-bottom: 0; }
.section-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 12px;
letter-spacing: 0.5px;
svg { opacity: 0.7; }
.copy-btn {
margin-left: auto;
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
border: none;
border-radius: 4px;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
&:hover { background: var(--bg-secondary); color: var(--text-primary); }
}
}
}
.detail-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0;
border-bottom: 1px solid var(--border-color);
font-size: 13px;
&:last-child { border-bottom: none; }
svg { color: var(--text-tertiary); flex-shrink: 0; }
.label { color: var(--text-secondary); flex-shrink: 0; }
.value {
flex: 1;
text-align: right;
color: var(--text-primary);
word-break: break-all;
user-select: text;
&.highlight { color: var(--primary); font-weight: 600; }
&.mono { font-family: 'Consolas', 'Monaco', monospace; font-size: 12px; }
}
.copy-btn {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
border: none;
border-radius: 4px;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.15s, color 0.15s, background 0.15s;
&:hover { background: var(--bg-secondary); color: var(--text-primary); }
svg { color: inherit; }
}
&:hover .copy-btn { opacity: 1; }
}
.raw-content-box {
background: var(--bg-tertiary);
border-radius: 8px;
padding: 12px;
max-height: 200px;
overflow: auto;
pre {
margin: 0;
white-space: pre-wrap;
word-break: break-all;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 12px;
color: var(--text-primary);
user-select: text;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -148,6 +148,17 @@
svg {
opacity: 0.7;
transition: transform 0.2s;
flex-shrink: 0;
}
.chip-label {
min-width: 0;
}
.chip-count {
margin-left: auto;
text-align: right;
font-variant-numeric: tabular-nums;
}
&:hover {
@@ -177,6 +188,22 @@
padding: 0 20px 12px;
font-size: 13px;
color: var(--text-secondary);
.contacts-cache-meta {
margin-left: 10px;
color: var(--text-tertiary);
font-size: 12px;
&.syncing {
color: var(--primary);
}
}
.avatar-enrich-progress {
margin-left: 10px;
color: var(--text-tertiary);
font-size: 12px;
}
}
.selection-toolbar {
@@ -213,10 +240,103 @@
}
}
.load-issue-state {
flex: 1;
padding: 14px 14px 18px;
overflow-y: auto;
}
.issue-card {
border: 1px solid color-mix(in srgb, var(--danger, #ef4444) 45%, var(--border-color));
background: color-mix(in srgb, var(--danger, #ef4444) 8%, var(--card-bg));
border-radius: 12px;
padding: 14px;
color: var(--text-primary);
.issue-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: color-mix(in srgb, var(--danger, #ef4444) 85%, var(--text-primary));
margin-bottom: 8px;
}
.issue-message {
margin: 0 0 8px;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
}
.issue-reason {
margin: 0;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
}
.issue-hints {
margin: 10px 0 0;
padding-left: 18px;
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.6;
}
.issue-actions {
margin-top: 12px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.issue-btn {
border: 1px solid var(--border-color);
background: var(--bg-secondary);
border-radius: 8px;
padding: 7px 10px;
font-size: 12px;
color: var(--text-secondary);
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
color: var(--text-primary);
border-color: var(--text-tertiary);
background: var(--bg-hover);
}
&.primary {
background: color-mix(in srgb, var(--primary) 14%, var(--bg-secondary));
border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color));
color: var(--primary);
}
}
.issue-diagnostics {
margin-top: 12px;
border-radius: 8px;
background: var(--bg-primary);
border: 1px dashed var(--border-color);
padding: 10px;
font-size: 12px;
line-height: 1.5;
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-word;
}
}
.contacts-list {
flex: 1;
overflow-y: auto;
padding: 0 12px 12px;
position: relative;
&::-webkit-scrollbar {
width: 6px;
@@ -229,15 +349,31 @@
}
}
.contacts-list-virtual {
position: relative;
min-height: 100%;
}
.contact-row {
position: absolute;
left: 0;
right: 0;
height: 76px;
padding-bottom: 4px;
will-change: transform;
}
.contact-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
height: 72px;
box-sizing: border-box;
border-radius: 10px;
transition: all 0.2s;
cursor: pointer;
margin-bottom: 4px;
margin-bottom: 0;
&:hover {
background: var(--bg-hover);
@@ -399,6 +535,28 @@
word-break: break-all;
user-select: text;
}
.detail-entry-btn {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: auto;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
color: var(--text-primary);
padding: 6px 10px;
font-size: 12px;
line-height: 1;
cursor: pointer;
transition: border-color 0.2s ease, color 0.2s ease, background 0.2s ease;
&:hover {
color: var(--primary);
border-color: color-mix(in srgb, var(--primary) 45%, var(--border-color));
background: color-mix(in srgb, var(--primary) 8%, var(--bg-secondary));
}
}
}
.goto-chat-btn {

View File

@@ -1,24 +1,51 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useState, useEffect, useCallback, useMemo, useRef, type UIEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown, MessageCircle, UserX } from 'lucide-react'
import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown, MessageCircle, UserX, AlertTriangle, ClipboardList, Aperture } from 'lucide-react'
import { useChatStore } from '../stores/chatStore'
import { toContactTypeCardCounts, useContactTypeCountsStore } from '../stores/contactTypeCountsStore'
import * as configService from '../services/config'
import type { ContactInfo } from '../types/models'
import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog'
import { type ContactSnsTimelineTarget, isSingleContactSession } from '../components/Sns/contactSnsTimeline'
import './ContactsPage.scss'
interface ContactInfo {
username: string
displayName: string
remark?: string
nickname?: string
interface ContactEnrichInfo {
displayName?: string
avatarUrl?: string
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
}
const AVATAR_ENRICH_BATCH_SIZE = 80
const SEARCH_DEBOUNCE_MS = 120
const VIRTUAL_ROW_HEIGHT = 76
const VIRTUAL_OVERSCAN = 10
const DEFAULT_CONTACTS_LOAD_TIMEOUT_MS = 3000
const AVATAR_RECHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
interface ContactsLoadSession {
requestId: string
startedAt: number
attempt: number
timeoutMs: number
}
interface ContactsLoadIssue {
kind: 'timeout' | 'error'
title: string
message: string
reason: string
errorDetail?: string
occurredAt: number
elapsedMs: number
}
type ContactsDataSource = 'cache' | 'network' | null
function ContactsPage() {
const [contacts, setContacts] = useState<ContactInfo[]>([])
const [filteredContacts, setFilteredContacts] = useState<ContactInfo[]>([])
const [selectedUsernames, setSelectedUsernames] = useState<Set<string>>(new Set())
const [isLoading, setIsLoading] = useState(true)
const [searchKeyword, setSearchKeyword] = useState('')
const [debouncedSearchKeyword, setDebouncedSearchKeyword] = useState('')
const [contactTypes, setContactTypes] = useState({
friends: true,
groups: false,
@@ -29,6 +56,9 @@ function ContactsPage() {
// 导出模式与查看详情
const [exportMode, setExportMode] = useState(false)
const [selectedContact, setSelectedContact] = useState<ContactInfo | null>(null)
const [snsUserPostCounts, setSnsUserPostCounts] = useState<Record<string, number>>({})
const [snsUserPostCountsStatus, setSnsUserPostCountsStatus] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle')
const [snsTimelineTarget, setSnsTimelineTarget] = useState<ContactSnsTimelineTarget | null>(null)
const navigate = useNavigate()
const { setCurrentSession } = useChatStore()
@@ -39,79 +69,530 @@ function ContactsPage() {
const [isExporting, setIsExporting] = useState(false)
const [showFormatSelect, setShowFormatSelect] = useState(false)
const formatDropdownRef = useRef<HTMLDivElement>(null)
const listRef = useRef<HTMLDivElement>(null)
const loadVersionRef = useRef(0)
const [avatarEnrichProgress, setAvatarEnrichProgress] = useState({
loaded: 0,
total: 0,
running: false
})
const [scrollTop, setScrollTop] = useState(0)
const [listViewportHeight, setListViewportHeight] = useState(480)
const sharedTabCounts = useContactTypeCountsStore(state => state.tabCounts)
const syncContactTypeCounts = useContactTypeCountsStore(state => state.syncFromContacts)
const loadAttemptRef = useRef(0)
const loadTimeoutTimerRef = useRef<number | null>(null)
const [contactsLoadTimeoutMs, setContactsLoadTimeoutMs] = useState(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS)
const [loadSession, setLoadSession] = useState<ContactsLoadSession | null>(null)
const [loadIssue, setLoadIssue] = useState<ContactsLoadIssue | null>(null)
const [showDiagnostics, setShowDiagnostics] = useState(false)
const [diagnosticTick, setDiagnosticTick] = useState(Date.now())
const [contactsDataSource, setContactsDataSource] = useState<ContactsDataSource>(null)
const [contactsUpdatedAt, setContactsUpdatedAt] = useState<number | null>(null)
const [avatarCacheUpdatedAt, setAvatarCacheUpdatedAt] = useState<number | null>(null)
const contactsLoadTimeoutMsRef = useRef(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS)
const contactsCacheScopeRef = useRef('default')
const contactsAvatarCacheRef = useRef<Record<string, configService.ContactsAvatarCacheEntry>>({})
// 加载通讯录
const loadContacts = useCallback(async () => {
setIsLoading(true)
try {
const result = await window.electronAPI.chat.connect()
if (!result.success) {
console.error('连接失败:', result.error)
setIsLoading(false)
return
}
const contactsResult = await window.electronAPI.chat.getContacts()
if (contactsResult.success && contactsResult.contacts) {
const ensureContactsCacheScope = useCallback(async () => {
if (contactsCacheScopeRef.current !== 'default') {
return contactsCacheScopeRef.current
}
const [dbPath, myWxid] = await Promise.all([
configService.getDbPath(),
configService.getMyWxid()
])
const scopeKey = dbPath || myWxid
? `${dbPath || ''}::${myWxid || ''}`
: 'default'
contactsCacheScopeRef.current = scopeKey
return scopeKey
}, [])
// 获取头像URL
const usernames = contactsResult.contacts.map((c: ContactInfo) => c.username)
if (usernames.length > 0) {
const avatarResult = await window.electronAPI.chat.enrichSessionsContactInfo(usernames)
if (avatarResult.success && avatarResult.contacts) {
contactsResult.contacts.forEach((contact: ContactInfo) => {
const enriched = avatarResult.contacts?.[contact.username]
if (enriched?.avatarUrl) {
contact.avatarUrl = enriched.avatarUrl
}
})
}
useEffect(() => {
let cancelled = false
void (async () => {
try {
const value = await configService.getContactsLoadTimeoutMs()
if (!cancelled) {
setContactsLoadTimeoutMs(value)
}
setContacts(contactsResult.contacts)
setFilteredContacts(contactsResult.contacts)
setSelectedUsernames(new Set())
} catch (error) {
console.error('读取通讯录超时配置失败:', error)
}
} catch (e) {
console.error('加载通讯录失败:', e)
} finally {
setIsLoading(false)
})()
return () => {
cancelled = true
}
}, [])
useEffect(() => {
loadContacts()
}, [loadContacts])
contactsLoadTimeoutMsRef.current = contactsLoadTimeoutMs
}, [contactsLoadTimeoutMs])
const mergeAvatarCacheIntoContacts = useCallback((sourceContacts: ContactInfo[]): ContactInfo[] => {
const avatarCache = contactsAvatarCacheRef.current
if (!sourceContacts.length || Object.keys(avatarCache).length === 0) {
return sourceContacts
}
let changed = false
const merged = sourceContacts.map((contact) => {
const cachedAvatar = avatarCache[contact.username]?.avatarUrl
if (!cachedAvatar || contact.avatarUrl) {
return contact
}
changed = true
return {
...contact,
avatarUrl: cachedAvatar
}
})
return changed ? merged : sourceContacts
}, [])
const upsertAvatarCacheFromContacts = useCallback((
scopeKey: string,
sourceContacts: ContactInfo[],
options?: { prune?: boolean; markCheckedUsernames?: string[] }
) => {
if (!scopeKey) return
const nextCache = { ...contactsAvatarCacheRef.current }
const now = Date.now()
const markCheckedSet = new Set((options?.markCheckedUsernames || []).filter(Boolean))
const usernamesInSource = new Set<string>()
let changed = false
for (const contact of sourceContacts) {
const username = String(contact.username || '').trim()
if (!username) continue
usernamesInSource.add(username)
const prev = nextCache[username]
const avatarUrl = String(contact.avatarUrl || '').trim()
if (!avatarUrl) continue
const updatedAt = !prev || prev.avatarUrl !== avatarUrl ? now : prev.updatedAt
const checkedAt = markCheckedSet.has(username) ? now : (prev?.checkedAt || now)
if (!prev || prev.avatarUrl !== avatarUrl || prev.updatedAt !== updatedAt || prev.checkedAt !== checkedAt) {
nextCache[username] = {
avatarUrl,
updatedAt,
checkedAt
}
changed = true
}
}
for (const username of markCheckedSet) {
const prev = nextCache[username]
if (!prev) continue
if (prev.checkedAt !== now) {
nextCache[username] = {
...prev,
checkedAt: now
}
changed = true
}
}
if (options?.prune) {
for (const username of Object.keys(nextCache)) {
if (usernamesInSource.has(username)) continue
delete nextCache[username]
changed = true
}
}
if (!changed) return
contactsAvatarCacheRef.current = nextCache
setAvatarCacheUpdatedAt(now)
void configService.setContactsAvatarCache(scopeKey, nextCache).catch((error) => {
console.error('写入通讯录头像缓存失败:', error)
})
}, [])
const applyEnrichedContacts = useCallback((enrichedMap: Record<string, ContactEnrichInfo>) => {
if (!enrichedMap || Object.keys(enrichedMap).length === 0) return
setContacts(prev => {
let changed = false
const next = prev.map(contact => {
const enriched = enrichedMap[contact.username]
if (!enriched) return contact
const displayName = enriched.displayName || contact.displayName
const avatarUrl = enriched.avatarUrl || contact.avatarUrl
if (displayName === contact.displayName && avatarUrl === contact.avatarUrl) {
return contact
}
changed = true
return {
...contact,
displayName,
avatarUrl
}
})
return changed ? next : prev
})
setSelectedContact(prev => {
if (!prev) return prev
const enriched = enrichedMap[prev.username]
if (!enriched) return prev
const displayName = enriched.displayName || prev.displayName
const avatarUrl = enriched.avatarUrl || prev.avatarUrl
if (displayName === prev.displayName && avatarUrl === prev.avatarUrl) {
return prev
}
return {
...prev,
displayName,
avatarUrl
}
})
}, [])
const enrichContactsInBackground = useCallback(async (
sourceContacts: ContactInfo[],
loadVersion: number,
scopeKey: string
) => {
const sourceByUsername = new Map<string, ContactInfo>()
for (const contact of sourceContacts) {
if (!contact.username) continue
sourceByUsername.set(contact.username, contact)
}
const now = Date.now()
const usernames = sourceContacts
.map(contact => contact.username)
.filter(Boolean)
.filter((username) => {
const currentContact = sourceByUsername.get(username)
if (!currentContact) return false
const cacheEntry = contactsAvatarCacheRef.current[username]
if (!cacheEntry || !cacheEntry.avatarUrl) {
return !currentContact.avatarUrl
}
if (currentContact.avatarUrl && currentContact.avatarUrl !== cacheEntry.avatarUrl) {
return true
}
const checkedAt = cacheEntry.checkedAt || 0
return now - checkedAt >= AVATAR_RECHECK_INTERVAL_MS
})
const total = usernames.length
setAvatarEnrichProgress({
loaded: 0,
total,
running: total > 0
})
if (total === 0) return
for (let i = 0; i < total; i += AVATAR_ENRICH_BATCH_SIZE) {
if (loadVersionRef.current !== loadVersion) return
const batch = usernames.slice(i, i + AVATAR_ENRICH_BATCH_SIZE)
if (batch.length === 0) continue
try {
const avatarResult = await window.electronAPI.chat.enrichSessionsContactInfo(batch)
if (loadVersionRef.current !== loadVersion) return
if (avatarResult.success && avatarResult.contacts) {
applyEnrichedContacts(avatarResult.contacts)
for (const [username, enriched] of Object.entries(avatarResult.contacts)) {
const prev = sourceByUsername.get(username)
if (!prev) continue
sourceByUsername.set(username, {
...prev,
displayName: enriched.displayName || prev.displayName,
avatarUrl: enriched.avatarUrl || prev.avatarUrl
})
}
}
const batchContacts = batch
.map(username => sourceByUsername.get(username))
.filter((contact): contact is ContactInfo => Boolean(contact))
upsertAvatarCacheFromContacts(scopeKey, batchContacts, {
markCheckedUsernames: batch
})
} catch (e) {
console.error('分批补全头像失败:', e)
}
const loaded = Math.min(i + batch.length, total)
setAvatarEnrichProgress({
loaded,
total,
running: loaded < total
})
await new Promise(resolve => setTimeout(resolve, 0))
}
}, [applyEnrichedContacts, upsertAvatarCacheFromContacts])
// 加载通讯录
const loadContacts = useCallback(async (options?: { scopeKey?: string }) => {
const scopeKey = options?.scopeKey || await ensureContactsCacheScope()
const loadVersion = loadVersionRef.current + 1
loadVersionRef.current = loadVersion
loadAttemptRef.current += 1
const startedAt = Date.now()
const timeoutMs = contactsLoadTimeoutMsRef.current
const requestId = `contacts-${startedAt}-${loadAttemptRef.current}`
setLoadSession({
requestId,
startedAt,
attempt: loadAttemptRef.current,
timeoutMs
})
setLoadIssue(null)
setShowDiagnostics(false)
if (loadTimeoutTimerRef.current) {
window.clearTimeout(loadTimeoutTimerRef.current)
loadTimeoutTimerRef.current = null
}
const timeoutTimerId = window.setTimeout(() => {
if (loadVersionRef.current !== loadVersion) return
const elapsedMs = Date.now() - startedAt
setLoadIssue({
kind: 'timeout',
title: '通讯录加载超时',
message: `等待超过 ${timeoutMs}ms联系人列表仍未返回。`,
reason: 'chat.getContacts 长时间未返回,可能是数据库查询繁忙或连接异常。',
occurredAt: Date.now(),
elapsedMs
})
}, timeoutMs)
loadTimeoutTimerRef.current = timeoutTimerId
setIsLoading(true)
setAvatarEnrichProgress({
loaded: 0,
total: 0,
running: false
})
try {
const contactsResult = await window.electronAPI.chat.getContacts()
if (loadVersionRef.current !== loadVersion) return
if (contactsResult.success && contactsResult.contacts) {
if (loadTimeoutTimerRef.current === timeoutTimerId) {
window.clearTimeout(loadTimeoutTimerRef.current)
loadTimeoutTimerRef.current = null
}
const contactsWithAvatarCache = mergeAvatarCacheIntoContacts(contactsResult.contacts)
setContacts(contactsWithAvatarCache)
syncContactTypeCounts(contactsWithAvatarCache)
setSelectedUsernames(new Set())
setSelectedContact(prev => {
if (!prev) return prev
return contactsWithAvatarCache.find(contact => contact.username === prev.username) || null
})
const now = Date.now()
setContactsDataSource('network')
setContactsUpdatedAt(now)
setLoadIssue(null)
setIsLoading(false)
upsertAvatarCacheFromContacts(scopeKey, contactsWithAvatarCache, { prune: true })
void configService.setContactsListCache(
scopeKey,
contactsWithAvatarCache.map(contact => ({
username: contact.username,
displayName: contact.displayName,
remark: contact.remark,
nickname: contact.nickname,
type: contact.type
}))
).catch((error) => {
console.error('写入通讯录缓存失败:', error)
})
void enrichContactsInBackground(contactsWithAvatarCache, loadVersion, scopeKey)
return
}
const elapsedMs = Date.now() - startedAt
setLoadIssue({
kind: 'error',
title: '通讯录加载失败',
message: '联系人接口返回失败,未拿到联系人列表。',
reason: 'chat.getContacts 返回 success=false。',
errorDetail: contactsResult.error || '未知错误',
occurredAt: Date.now(),
elapsedMs
})
} catch (e) {
console.error('加载通讯录失败:', e)
const elapsedMs = Date.now() - startedAt
setLoadIssue({
kind: 'error',
title: '通讯录加载失败',
message: '联系人请求执行异常。',
reason: '调用 chat.getContacts 发生异常。',
errorDetail: String(e),
occurredAt: Date.now(),
elapsedMs
})
} finally {
if (loadTimeoutTimerRef.current === timeoutTimerId) {
window.clearTimeout(loadTimeoutTimerRef.current)
loadTimeoutTimerRef.current = null
}
if (loadVersionRef.current === loadVersion) {
setIsLoading(false)
}
}
}, [
ensureContactsCacheScope,
enrichContactsInBackground,
mergeAvatarCacheIntoContacts,
syncContactTypeCounts,
upsertAvatarCacheFromContacts
])
// 搜索和类型过滤
useEffect(() => {
let filtered = contacts
let cancelled = false
void (async () => {
const scopeKey = await ensureContactsCacheScope()
if (cancelled) return
try {
const [cacheItem, avatarCacheItem] = await Promise.all([
configService.getContactsListCache(scopeKey),
configService.getContactsAvatarCache(scopeKey)
])
const avatarCacheMap = avatarCacheItem?.avatars || {}
contactsAvatarCacheRef.current = avatarCacheMap
setAvatarCacheUpdatedAt(avatarCacheItem?.updatedAt || null)
if (!cancelled && cacheItem && Array.isArray(cacheItem.contacts) && cacheItem.contacts.length > 0) {
const cachedContacts: ContactInfo[] = cacheItem.contacts.map(contact => ({
...contact,
avatarUrl: avatarCacheMap[contact.username]?.avatarUrl
}))
setContacts(cachedContacts)
syncContactTypeCounts(cachedContacts)
setContactsDataSource('cache')
setContactsUpdatedAt(cacheItem.updatedAt || null)
setIsLoading(false)
}
} catch (error) {
console.error('读取通讯录缓存失败:', error)
}
if (!cancelled) {
void loadContacts({ scopeKey })
}
})()
return () => {
cancelled = true
}
}, [ensureContactsCacheScope, loadContacts, syncContactTypeCounts])
// 类型过滤
filtered = filtered.filter(c => {
if (c.type === 'friend' && !contactTypes.friends) return false
if (c.type === 'group' && !contactTypes.groups) return false
if (c.type === 'official' && !contactTypes.officials) return false
if (c.type === 'former_friend' && !contactTypes.deletedFriends) return false
useEffect(() => {
return () => {
if (loadTimeoutTimerRef.current) {
window.clearTimeout(loadTimeoutTimerRef.current)
loadTimeoutTimerRef.current = null
}
loadVersionRef.current += 1
}
}, [])
useEffect(() => {
if (!loadIssue || contacts.length > 0) return
if (!(isLoading && loadIssue.kind === 'timeout')) return
const timer = window.setInterval(() => {
setDiagnosticTick(Date.now())
}, 500)
return () => window.clearInterval(timer)
}, [contacts.length, isLoading, loadIssue])
useEffect(() => {
const timer = window.setTimeout(() => {
setDebouncedSearchKeyword(searchKeyword.trim().toLowerCase())
}, SEARCH_DEBOUNCE_MS)
return () => window.clearTimeout(timer)
}, [searchKeyword])
const loadSnsUserPostCounts = useCallback(async (options?: { force?: boolean }) => {
if (!options?.force && (snsUserPostCountsStatus === 'loading' || snsUserPostCountsStatus === 'ready')) {
return
}
setSnsUserPostCountsStatus('loading')
try {
const result = await window.electronAPI.sns.getUserPostCounts()
if (!result.success || !result.counts) {
setSnsUserPostCountsStatus('error')
return
}
const normalizedCounts: Record<string, number> = {}
for (const [rawUsername, rawCount] of Object.entries(result.counts)) {
const username = String(rawUsername || '').trim()
if (!username) continue
const value = Number(rawCount)
normalizedCounts[username] = Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0
}
setSnsUserPostCounts(normalizedCounts)
setSnsUserPostCountsStatus('ready')
} catch (error) {
console.error('加载通讯录联系人朋友圈条数失败:', error)
setSnsUserPostCountsStatus('error')
}
}, [snsUserPostCountsStatus])
useEffect(() => {
if (!selectedContact || !isSingleContactSession(selectedContact.username)) return
if (snsUserPostCountsStatus !== 'idle') return
void loadSnsUserPostCounts()
}, [loadSnsUserPostCounts, selectedContact, snsUserPostCountsStatus])
const filteredContacts = useMemo(() => {
let filtered = contacts.filter(contact => {
if (contact.type === 'friend' && !contactTypes.friends) return false
if (contact.type === 'group' && !contactTypes.groups) return false
if (contact.type === 'official' && !contactTypes.officials) return false
if (contact.type === 'former_friend' && !contactTypes.deletedFriends) return false
return true
})
// 关键词过滤
if (searchKeyword.trim()) {
const lower = searchKeyword.toLowerCase()
filtered = filtered.filter(c =>
c.displayName?.toLowerCase().includes(lower) ||
c.remark?.toLowerCase().includes(lower) ||
c.username.toLowerCase().includes(lower)
if (debouncedSearchKeyword) {
filtered = filtered.filter(contact =>
contact.displayName?.toLowerCase().includes(debouncedSearchKeyword) ||
contact.remark?.toLowerCase().includes(debouncedSearchKeyword) ||
contact.username.toLowerCase().includes(debouncedSearchKeyword)
)
}
setFilteredContacts(filtered)
}, [searchKeyword, contacts, contactTypes])
return filtered
}, [contacts, contactTypes, debouncedSearchKeyword])
// 点击外部关闭下拉菜单
const contactTypeCounts = useMemo(() => toContactTypeCardCounts(sharedTabCounts), [sharedTabCounts])
useEffect(() => {
if (!listRef.current) return
listRef.current.scrollTop = 0
setScrollTop(0)
}, [debouncedSearchKeyword, contactTypes])
useEffect(() => {
const node = listRef.current
if (!node) return
const updateViewportHeight = () => {
setListViewportHeight(Math.max(node.clientHeight, VIRTUAL_ROW_HEIGHT))
}
updateViewportHeight()
const observer = new ResizeObserver(() => updateViewportHeight())
observer.observe(node)
return () => observer.disconnect()
}, [filteredContacts.length, isLoading])
useEffect(() => {
const maxScroll = Math.max(0, filteredContacts.length * VIRTUAL_ROW_HEIGHT - listViewportHeight)
if (scrollTop <= maxScroll) return
setScrollTop(maxScroll)
if (listRef.current) {
listRef.current.scrollTop = maxScroll
}
}, [filteredContacts.length, listViewportHeight, scrollTop])
// 搜索和类型过滤
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node
@@ -123,11 +604,117 @@ function ContactsPage() {
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showFormatSelect])
const selectedInFilteredCount = filteredContacts.reduce((count, contact) => {
return selectedUsernames.has(contact.username) ? count + 1 : count
}, 0)
const selectedInFilteredCount = useMemo(() => {
return filteredContacts.reduce((count, contact) => {
return selectedUsernames.has(contact.username) ? count + 1 : count
}, 0)
}, [filteredContacts, selectedUsernames])
const allFilteredSelected = filteredContacts.length > 0 && selectedInFilteredCount === filteredContacts.length
const selectedContactSupportsSns = useMemo(() => {
return Boolean(selectedContact && isSingleContactSession(selectedContact.username))
}, [selectedContact])
const selectedContactSnsCount = useMemo(() => {
if (!selectedContactSupportsSns || !selectedContact) return null
if (snsUserPostCountsStatus !== 'ready') return null
const rawCount = Number(snsUserPostCounts[selectedContact.username] || 0)
return Number.isFinite(rawCount) ? Math.max(0, Math.floor(rawCount)) : 0
}, [selectedContact, selectedContactSupportsSns, snsUserPostCounts, snsUserPostCountsStatus])
const selectedContactSnsEntryLabel = useMemo(() => {
if (!selectedContactSupportsSns) return ''
if (selectedContactSnsCount !== null) {
return `朋友圈:${selectedContactSnsCount.toLocaleString('zh-CN')}`
}
if (snsUserPostCountsStatus === 'error') return '朋友圈:查看'
return '朋友圈:统计中...'
}, [selectedContactSupportsSns, selectedContactSnsCount, snsUserPostCountsStatus])
const openSelectedContactSnsTimeline = useCallback(() => {
if (!selectedContact || !selectedContactSupportsSns) return
if (snsUserPostCountsStatus === 'idle') {
void loadSnsUserPostCounts()
}
setSnsTimelineTarget({
username: selectedContact.username,
displayName: selectedContact.displayName || selectedContact.remark || selectedContact.nickname || selectedContact.username,
avatarUrl: selectedContact.avatarUrl
})
}, [loadSnsUserPostCounts, selectedContact, selectedContactSupportsSns, snsUserPostCountsStatus])
const { startIndex, endIndex } = useMemo(() => {
if (filteredContacts.length === 0) {
return { startIndex: 0, endIndex: 0 }
}
const baseStart = Math.floor(scrollTop / VIRTUAL_ROW_HEIGHT)
const visibleCount = Math.ceil(listViewportHeight / VIRTUAL_ROW_HEIGHT)
const nextStart = Math.max(0, baseStart - VIRTUAL_OVERSCAN)
const nextEnd = Math.min(filteredContacts.length, nextStart + visibleCount + VIRTUAL_OVERSCAN * 2)
return {
startIndex: nextStart,
endIndex: nextEnd
}
}, [filteredContacts.length, listViewportHeight, scrollTop])
const visibleContacts = useMemo(() => {
return filteredContacts.slice(startIndex, endIndex)
}, [filteredContacts, startIndex, endIndex])
const onContactsListScroll = useCallback((event: UIEvent<HTMLDivElement>) => {
setScrollTop(event.currentTarget.scrollTop)
}, [])
const issueElapsedMs = useMemo(() => {
if (!loadIssue) return 0
if (isLoading && loadSession) {
return Math.max(loadIssue.elapsedMs, diagnosticTick - loadSession.startedAt)
}
return loadIssue.elapsedMs
}, [diagnosticTick, isLoading, loadIssue, loadSession])
const diagnosticsText = useMemo(() => {
if (!loadIssue || !loadSession) return ''
return [
`请求ID: ${loadSession.requestId}`,
`请求序号: 第 ${loadSession.attempt}`,
`阈值配置: ${loadSession.timeoutMs}ms`,
`当前状态: ${loadIssue.kind === 'timeout' ? '超时等待中' : '请求失败'}`,
`累计耗时: ${(issueElapsedMs / 1000).toFixed(1)}s`,
`发生时间: ${new Date(loadIssue.occurredAt).toLocaleString()}`,
`阶段: chat.getContacts`,
`原因: ${loadIssue.reason}`,
`错误详情: ${loadIssue.errorDetail || '无'}`
].join('\n')
}, [issueElapsedMs, loadIssue, loadSession])
const copyDiagnostics = useCallback(async () => {
if (!diagnosticsText) return
try {
await navigator.clipboard.writeText(diagnosticsText)
alert('诊断信息已复制')
} catch (error) {
console.error('复制诊断信息失败:', error)
alert('复制失败,请手动复制诊断信息')
}
}, [diagnosticsText])
const contactsUpdatedAtLabel = useMemo(() => {
if (!contactsUpdatedAt) return ''
return new Date(contactsUpdatedAt).toLocaleString()
}, [contactsUpdatedAt])
const avatarCachedCount = useMemo(() => {
return contacts.reduce((count, contact) => (
contact.avatarUrl ? count + 1 : count
), 0)
}, [contacts])
const avatarCacheUpdatedAtLabel = useMemo(() => {
if (!avatarCacheUpdatedAt) return ''
return new Date(avatarCacheUpdatedAt).toLocaleString()
}, [avatarCacheUpdatedAt])
const toggleContactSelected = (username: string, checked: boolean) => {
setSelectedUsernames(prev => {
const next = new Set(prev)
@@ -256,7 +843,7 @@ function ContactsPage() {
>
<Download size={18} />
</button>
<button className="icon-btn" onClick={loadContacts} disabled={isLoading}>
<button className="icon-btn" onClick={() => void loadContacts()} disabled={isLoading}>
<RefreshCw size={18} className={isLoading ? 'spin' : ''} />
</button>
</div>
@@ -280,25 +867,30 @@ function ContactsPage() {
<div className="type-filters">
<label className={`filter-chip ${contactTypes.friends ? 'active' : ''}`}>
<input type="checkbox" checked={contactTypes.friends} onChange={e => setContactTypes({ ...contactTypes, friends: e.target.checked })} />
<User size={16} /><span></span>
<User size={16} />
<span className="chip-label"></span>
<span className="chip-count">{contactTypeCounts.friends}</span>
</label>
<label className={`filter-chip ${contactTypes.groups ? 'active' : ''}`}>
<input type="checkbox" checked={contactTypes.groups} onChange={e => setContactTypes({ ...contactTypes, groups: e.target.checked })} />
<Users size={16} /><span></span>
<Users size={16} />
<span className="chip-label"></span>
<span className="chip-count">{contactTypeCounts.groups}</span>
</label>
<label className={`filter-chip ${contactTypes.officials ? 'active' : ''}`}>
<input type="checkbox" checked={contactTypes.officials} onChange={e => setContactTypes({ ...contactTypes, officials: e.target.checked })} />
<MessageSquare size={16} /><span></span>
<MessageSquare size={16} />
<span className="chip-label"></span>
<span className="chip-count">{contactTypeCounts.officials}</span>
</label>
<label className={`filter-chip ${contactTypes.deletedFriends ? 'active' : ''}`}>
<input type="checkbox" checked={contactTypes.deletedFriends} onChange={e => setContactTypes({ ...contactTypes, deletedFriends: e.target.checked })} />
<UserX size={16} /><span></span>
<UserX size={16} />
<span className="chip-label"></span>
<span className="chip-count">{contactTypeCounts.deletedFriends}</span>
</label>
</div>
<div className="contacts-count">
{filteredContacts.length}
</div>
{exportMode && (
<div className="selection-toolbar">
@@ -315,61 +907,105 @@ function ContactsPage() {
</div>
)}
{isLoading ? (
{contacts.length === 0 && loadIssue ? (
<div className="load-issue-state">
<div className="issue-card">
<div className="issue-title">
<AlertTriangle size={18} />
<span>{loadIssue.title}</span>
</div>
<p className="issue-message">{loadIssue.message}</p>
<p className="issue-reason">{loadIssue.reason}</p>
<ul className="issue-hints">
<li>1</li>
<li>2contact.db </li>
<li>3 IPC </li>
</ul>
<div className="issue-actions">
<button className="issue-btn primary" onClick={() => void loadContacts()}>
<RefreshCw size={14} />
<span></span>
</button>
<button className="issue-btn" onClick={() => setShowDiagnostics(prev => !prev)}>
<ClipboardList size={14} />
<span>{showDiagnostics ? '收起诊断详情' : '查看诊断详情'}</span>
</button>
<button className="issue-btn" onClick={copyDiagnostics}>
<span></span>
</button>
</div>
{showDiagnostics && (
<pre className="issue-diagnostics">{diagnosticsText}</pre>
)}
</div>
</div>
) : isLoading && contacts.length === 0 ? (
<div className="loading-state">
<Loader2 size={32} className="spin" />
<span>...</span>
<span>...</span>
</div>
) : filteredContacts.length === 0 ? (
<div className="empty-state">
<span></span>
</div>
) : (
<div className="contacts-list">
{filteredContacts.map(contact => {
<div className="contacts-list" ref={listRef} onScroll={onContactsListScroll}>
<div
className="contacts-list-virtual"
style={{ height: filteredContacts.length * VIRTUAL_ROW_HEIGHT }}
>
{visibleContacts.map((contact, idx) => {
const absoluteIndex = startIndex + idx
const top = absoluteIndex * VIRTUAL_ROW_HEIGHT
const isChecked = selectedUsernames.has(contact.username)
const isActive = !exportMode && selectedContact?.username === contact.username
return (
<div
key={contact.username}
className={`contact-item ${exportMode && isChecked ? 'selected' : ''} ${isActive ? 'active' : ''}`}
onClick={() => {
if (exportMode) {
toggleContactSelected(contact.username, !isChecked)
} else {
setSelectedContact(isActive ? null : contact)
}
}}
className="contact-row"
style={{ transform: `translateY(${top}px)` }}
>
{exportMode && (
<label className="contact-select" onClick={e => e.stopPropagation()}>
<input
type="checkbox"
checked={isChecked}
onChange={e => toggleContactSelected(contact.username, e.target.checked)}
/>
</label>
)}
<div className="contact-avatar">
{contact.avatarUrl ? (
<img src={contact.avatarUrl} alt="" />
) : (
<span>{getAvatarLetter(contact.displayName)}</span>
<div
className={`contact-item ${exportMode && isChecked ? 'selected' : ''} ${isActive ? 'active' : ''}`}
onClick={() => {
if (exportMode) {
toggleContactSelected(contact.username, !isChecked)
} else {
setSelectedContact(isActive ? null : contact)
}
}}
>
{exportMode && (
<label className="contact-select" onClick={e => e.stopPropagation()}>
<input
type="checkbox"
checked={isChecked}
onChange={e => toggleContactSelected(contact.username, e.target.checked)}
/>
</label>
)}
</div>
<div className="contact-info">
<div className="contact-name">{contact.displayName}</div>
{contact.remark && contact.remark !== contact.displayName && (
<div className="contact-remark">: {contact.remark}</div>
)}
</div>
<div className={`contact-type ${contact.type}`}>
{getContactTypeIcon(contact.type)}
<span>{getContactTypeName(contact.type)}</span>
<div className="contact-avatar">
{contact.avatarUrl ? (
<img src={contact.avatarUrl} alt="" loading="lazy" />
) : (
<span>{getAvatarLetter(contact.displayName)}</span>
)}
</div>
<div className="contact-info">
<div className="contact-name">{contact.displayName}</div>
{contact.remark && contact.remark !== contact.displayName && (
<div className="contact-remark">: {contact.remark}</div>
)}
</div>
<div className={`contact-type ${contact.type}`}>
{getContactTypeIcon(contact.type)}
<span>{getContactTypeName(contact.type)}</span>
</div>
</div>
</div>
)
})}
})}
</div>
</div>
)}
</div>
@@ -475,6 +1111,19 @@ function ContactsPage() {
<div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.nickname || selectedContact.displayName}</span></div>
{selectedContact.remark && <div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.remark}</span></div>}
<div className="detail-row"><span className="detail-label"></span><span className="detail-value">{getContactTypeName(selectedContact.type)}</span></div>
{selectedContactSupportsSns && (
<div className="detail-row">
<span className="detail-label"></span>
<button
type="button"
className="detail-entry-btn"
onClick={openSelectedContactSnsTimeline}
>
<Aperture size={14} />
<span>{selectedContactSnsEntryLabel}</span>
</button>
</div>
)}
</div>
<button
@@ -497,6 +1146,14 @@ function ContactsPage() {
</div>
</div>
)}
<ContactSnsTimelineDialog
target={snsTimelineTarget}
onClose={() => setSnsTimelineTarget(null)}
initialTotalPosts={selectedContact && snsTimelineTarget?.username === selectedContact.username ? selectedContactSnsCount : null}
initialTotalPostsLoading={selectedContact && snsTimelineTarget?.username === selectedContact.username
? snsUserPostCountsStatus === 'idle' || snsUserPostCountsStatus === 'loading'
: false}
/>
</div>
)
}

View File

@@ -107,7 +107,16 @@ function DualReportWindow() {
setLoadingStage('完成')
if (result.success && result.data) {
setReportData(result.data)
const normalizedResponse = result.data.response
? {
...result.data.response,
slowest: result.data.response.slowest ?? result.data.response.avg
}
: undefined
setReportData({
...result.data,
response: normalizedResponse
})
setIsLoading(false)
} else {
setError(result.error || '生成报告失败')

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,14 @@
.group-analytics-shell {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 100%;
}
.group-analytics-page {
display: flex;
height: 100%;
flex: 1;
min-height: 0;
gap: 16px;
&.standalone {

View File

@@ -4,7 +4,14 @@ import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, Medal, Search, X, C
import { Avatar } from '../components/Avatar'
import ReactECharts from 'echarts-for-react'
import DateRangePicker from '../components/DateRangePicker'
import ChatAnalysisHeader from '../components/ChatAnalysisHeader'
import * as configService from '../services/config'
import {
finishBackgroundTask,
isBackgroundTaskCancelRequested,
registerBackgroundTask,
updateBackgroundTask
} from '../services/backgroundTaskMonitor'
import './GroupAnalyticsPage.scss'
interface GroupChatInfo {
@@ -30,7 +37,7 @@ interface GroupMessageRank {
}
type AnalysisFunction = 'members' | 'memberExport' | 'ranking' | 'activeHours' | 'mediaStats'
type MemberExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'weclone'
type MemberExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone'
interface MemberMessageExportOptions {
format: MemberExportFormat
@@ -119,6 +126,7 @@ function GroupAnalyticsPage() {
{ value: 'excel', label: 'Excel', desc: '电子表格,适合统计分析' },
{ value: 'txt', label: 'TXT', desc: '纯文本,通用格式' },
{ value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' },
{ value: 'arkme-json', label: 'Arkme JSON', desc: '紧凑 JSON支持 sender 去重与关系统计' },
{ value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' },
{ value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' },
{ value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' },
@@ -175,15 +183,39 @@ function GroupAnalyticsPage() {
}, [])
const loadGroups = useCallback(async () => {
const taskId = registerBackgroundTask({
sourcePage: 'groupAnalytics',
title: '群列表加载',
detail: '正在读取群聊列表',
progressText: '群聊列表',
cancelable: true
})
setIsLoading(true)
try {
const result = await window.electronAPI.groupAnalytics.getGroupChats()
if (isBackgroundTaskCancelRequested(taskId)) {
finishBackgroundTask(taskId, 'canceled', {
detail: '已停止后续加载,群聊列表结果未继续写入'
})
return
}
if (result.success && result.data) {
setGroups(result.data)
setFilteredGroups(result.data)
finishBackgroundTask(taskId, 'completed', {
detail: `群聊列表加载完成,共 ${result.data.length} 个群`,
progressText: `${result.data.length} 个群`
})
} else {
finishBackgroundTask(taskId, 'failed', {
detail: result.error || '加载群聊列表失败'
})
}
} catch (e) {
console.error(e)
finishBackgroundTask(taskId, 'failed', {
detail: String(e)
})
} finally {
setIsLoading(false)
}
@@ -313,6 +345,13 @@ function GroupAnalyticsPage() {
const loadFunctionData = async (func: AnalysisFunction) => {
if (!selectedGroup) return
const taskId = registerBackgroundTask({
sourcePage: 'groupAnalytics',
title: `群分析:${func}`,
detail: `正在读取 ${selectedGroup.displayName || selectedGroup.username} 的分析数据`,
progressText: func,
cancelable: true
})
setFunctionLoading(true)
// 计算时间戳
@@ -322,33 +361,96 @@ function GroupAnalyticsPage() {
try {
switch (func) {
case 'members': {
updateBackgroundTask(taskId, {
detail: '正在读取群成员列表',
progressText: '成员列表'
})
const result = await window.electronAPI.groupAnalytics.getGroupMembers(selectedGroup.username)
if (isBackgroundTaskCancelRequested(taskId)) {
finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,群成员列表未继续写入' })
return
}
if (result.success && result.data) setMembers(result.data)
finishBackgroundTask(taskId, result.success ? 'completed' : 'failed', {
detail: result.success ? `群成员列表加载完成,共 ${result.data?.length || 0}` : (result.error || '读取群成员列表失败'),
progressText: result.success ? `${result.data?.length || 0}` : '失败'
})
break
}
case 'memberExport': {
updateBackgroundTask(taskId, {
detail: '正在读取导出成员列表',
progressText: '成员导出'
})
const result = await window.electronAPI.groupAnalytics.getGroupMembers(selectedGroup.username)
if (isBackgroundTaskCancelRequested(taskId)) {
finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,成员导出列表未继续写入' })
return
}
if (result.success && result.data) setMembers(result.data)
finishBackgroundTask(taskId, result.success ? 'completed' : 'failed', {
detail: result.success ? `成员导出列表加载完成,共 ${result.data?.length || 0}` : (result.error || '读取成员导出列表失败'),
progressText: result.success ? `${result.data?.length || 0}` : '失败'
})
break
}
case 'ranking': {
updateBackgroundTask(taskId, {
detail: '正在计算群消息排行',
progressText: '消息排行'
})
const result = await window.electronAPI.groupAnalytics.getGroupMessageRanking(selectedGroup.username, 20, startTime, endTime)
if (isBackgroundTaskCancelRequested(taskId)) {
finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,群消息排行未继续写入' })
return
}
if (result.success && result.data) setRankings(result.data)
finishBackgroundTask(taskId, result.success ? 'completed' : 'failed', {
detail: result.success ? `群消息排行加载完成,共 ${result.data?.length || 0}` : (result.error || '读取群消息排行失败'),
progressText: result.success ? `${result.data?.length || 0}` : '失败'
})
break
}
case 'activeHours': {
updateBackgroundTask(taskId, {
detail: '正在计算群活跃时段',
progressText: '活跃时段'
})
const result = await window.electronAPI.groupAnalytics.getGroupActiveHours(selectedGroup.username, startTime, endTime)
if (isBackgroundTaskCancelRequested(taskId)) {
finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,群活跃时段未继续写入' })
return
}
if (result.success && result.data) setActiveHours(result.data.hourlyDistribution)
finishBackgroundTask(taskId, result.success ? 'completed' : 'failed', {
detail: result.success ? '群活跃时段加载完成' : (result.error || '读取群活跃时段失败'),
progressText: result.success ? '24 小时分布' : '失败'
})
break
}
case 'mediaStats': {
updateBackgroundTask(taskId, {
detail: '正在统计群消息类型',
progressText: '消息类型'
})
const result = await window.electronAPI.groupAnalytics.getGroupMediaStats(selectedGroup.username, startTime, endTime)
if (isBackgroundTaskCancelRequested(taskId)) {
finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,群消息类型统计未继续写入' })
return
}
if (result.success && result.data) setMediaStats(result.data)
finishBackgroundTask(taskId, result.success ? 'completed' : 'failed', {
detail: result.success ? `群消息类型统计完成,共 ${result.data?.total || 0}` : (result.error || '读取群消息类型统计失败'),
progressText: result.success ? `${result.data?.total || 0}` : '失败'
})
break
}
}
} catch (e) {
console.error(e)
finishBackgroundTask(taskId, 'failed', {
detail: String(e)
})
} finally {
setFunctionLoading(false)
}
@@ -1084,11 +1186,14 @@ function GroupAnalyticsPage() {
}
return (
<div className={`group-analytics-page ${isResizing ? 'resizing' : ''}`} ref={containerRef}>
{renderGroupList()}
<div className="resize-handle" onMouseDown={() => setIsResizing(true)} />
<div className="detail-area">
{renderDetailPanel()}
<div className="group-analytics-shell">
<ChatAnalysisHeader currentMode="group" />
<div className={`group-analytics-page ${isResizing ? 'resizing' : ''}`} ref={containerRef}>
{renderGroupList()}
<div className="resize-handle" onMouseDown={() => setIsResizing(true)} />
<div className="detail-area">
{renderDetailPanel()}
</div>
</div>
{renderMemberModal()}
</div>

View File

@@ -7,76 +7,6 @@
overflow: hidden;
user-select: none;
.title-bar {
height: 40px;
min-height: 40px;
display: flex;
justify-content: space-between;
align-items: center;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
padding-right: 140px; // 为原生窗口控件留出空间
.window-drag-area {
flex: 1;
height: 100%;
-webkit-app-region: drag;
}
.title-bar-controls {
display: flex;
align-items: center;
gap: 8px;
-webkit-app-region: no-drag;
margin-right: 16px;
button {
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 6px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
&:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
&:disabled {
cursor: default;
opacity: 1;
}
&.live-play-btn {
&.active {
background: rgba(var(--primary-rgb, 76, 132, 255), 0.16);
color: var(--primary, #4c84ff);
}
}
}
.scale-text {
min-width: 50px;
text-align: center;
color: var(--text-secondary);
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.divider {
width: 1px;
height: 14px;
background: var(--border-color);
margin: 0 4px;
}
}
}
.image-viewport {
flex: 1;
display: flex;

View File

@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'
import { useSearchParams } from 'react-router-dom'
import { ZoomIn, ZoomOut, RotateCw, RotateCcw } from 'lucide-react'
import { LivePhotoIcon } from '../components/LivePhotoIcon'
import TitleBar from '../components/TitleBar'
import './ImageWindow.scss'
export default function ImageWindow() {
@@ -207,31 +208,35 @@ export default function ImageWindow() {
return (
<div className="image-window-container">
<div className="title-bar">
<div className="window-drag-area"></div>
<div className="title-bar-controls">
{hasLiveVideo && (
<>
<button
onClick={handlePlayLiveVideo}
title={isPlayingLive ? '正在播放实况' : '播放实况 (空格)'}
className={`live-play-btn ${isPlayingLive ? 'active' : ''}`}
disabled={isPlayingLive}
>
<LivePhotoIcon size={16} />
<span style={{ fontSize: 13, marginLeft: 4 }}>Live</span>
</button>
<div className="divider"></div>
</>
)}
<button onClick={handleZoomOut} title="缩小 (-)"><ZoomOut size={16} /></button>
<span className="scale-text">{Math.round(displayScale * 100)}%</span>
<button onClick={handleZoomIn} title="放大 (+)"><ZoomIn size={16} /></button>
<div className="divider"></div>
<button onClick={handleRotateCcw} title="逆时针旋转"><RotateCcw size={16} /></button>
<button onClick={handleRotate} title="顺时针旋转 (R)"><RotateCw size={16} /></button>
</div>
</div>
<TitleBar
title="图片查看"
showWindowControls={true}
showLogo={false}
customControls={
<div className="image-controls">
{hasLiveVideo && (
<>
<button
onClick={handlePlayLiveVideo}
title={isPlayingLive ? '正在播放实况' : '播放实况 (空格)'}
className={`live-play-btn ${isPlayingLive ? 'active' : ''}`}
disabled={isPlayingLive}
>
<LivePhotoIcon size={16} />
<span style={{ fontSize: 13, marginLeft: 4 }}>Live</span>
</button>
<div className="divider"></div>
</>
)}
<button onClick={handleZoomOut} title="缩小 (-)"><ZoomOut size={16} /></button>
<span className="scale-text">{Math.round(displayScale * 100)}%</span>
<button onClick={handleZoomIn} title="放大 (+)"><ZoomIn size={16} /></button>
<div className="divider"></div>
<button onClick={handleRotateCcw} title="逆时针旋转"><RotateCcw size={16} /></button>
<button onClick={handleRotate} title="顺时针旋转 (R)"><RotateCw size={16} /></button>
</div>
}
/>
<div
className="image-viewport"

View File

@@ -1,17 +1,92 @@
.settings-modal-overlay {
position: fixed;
top: 41px;
left: 0;
right: 0;
bottom: 0;
z-index: 2050;
display: flex;
align-items: center;
justify-content: center;
padding: 28px 32px;
background: rgba(15, 23, 42, 0.28);
backdrop-filter: blur(10px);
animation: settingsFadeIn 0.2s ease;
&.closing {
animation: settingsFadeOut 0.2s ease forwards;
}
}
@keyframes settingsFadeIn {
from {
opacity: 0;
backdrop-filter: blur(0);
}
to {
opacity: 1;
backdrop-filter: blur(10px);
}
}
@keyframes settingsFadeOut {
from {
opacity: 1;
backdrop-filter: blur(10px);
}
to {
opacity: 0;
backdrop-filter: blur(0);
}
}
.settings-page {
display: flex;
flex-direction: column;
height: 100%;
margin: -24px;
width: min(1160px, calc(100vw - 96px));
height: min(820px, calc(100vh - 120px));
max-height: 100%;
padding: 24px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 24px;
box-shadow: 0 28px 80px rgba(15, 23, 42, 0.22);
overflow: hidden;
animation: settingsSlideUp 0.3s ease;
&.closing {
animation: settingsSlideDown 0.2s ease forwards;
}
}
@keyframes settingsSlideUp {
from {
opacity: 0;
transform: translateY(30px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes settingsSlideDown {
from {
opacity: 1;
transform: translateY(0) scale(1);
}
to {
opacity: 0;
transform: translateY(20px) scale(0.98);
}
}
.settings-header {
display: flex;
align-items: center;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 20px;
gap: 20px;
margin-bottom: 14px;
flex-shrink: 0;
h1 {
@@ -22,51 +97,91 @@
}
}
.settings-title-block {
display: flex;
flex-direction: column;
}
.settings-actions {
display: flex;
align-items: center;
gap: 12px;
}
.settings-close-btn {
width: 36px;
height: 36px;
padding: 0;
border: 1px solid var(--border-color);
border-radius: 10px;
background: var(--bg-secondary);
color: var(--text-secondary);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
&:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
border-color: rgba(139, 115, 85, 0.28);
}
}
.settings-layout {
flex: 1;
min-height: 0;
display: flex;
gap: 20px;
overflow: hidden;
}
.settings-tabs {
display: flex;
gap: 4px;
padding: 4px;
background: var(--bg-tertiary);
border-radius: 12px;
margin-bottom: 20px;
flex-shrink: 0;
width: fit-content;
}
.tab-btn {
display: flex;
align-items: center;
flex-direction: column;
gap: 6px;
padding: 10px 18px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
background: transparent;
color: var(--text-secondary);
padding: 12px;
width: 220px;
flex-shrink: 0;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 20px;
overflow-y: auto;
&:hover {
color: var(--text-primary);
background: var(--bg-secondary);
}
.tab-btn {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
justify-content: flex-start;
padding: 11px 14px;
border: none;
border-radius: 12px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
background: transparent;
color: var(--text-secondary);
&.active {
background: var(--card-bg);
color: var(--primary);
box-shadow: var(--shadow-sm);
&:hover {
color: var(--text-primary);
background: var(--bg-secondary);
}
&.active {
background: var(--card-bg);
color: var(--primary);
box-shadow: var(--shadow-sm);
}
}
}
.settings-body {
flex: 1;
overflow-y: auto;
min-width: 0;
padding-right: 8px;
&::-webkit-scrollbar {
@@ -85,8 +200,10 @@
.tab-content {
background: var(--bg-secondary);
border-radius: 16px;
border: 1px solid var(--border-color);
border-radius: 20px;
padding: 24px;
min-height: 100%;
.section-desc {
font-size: 13px;
@@ -348,6 +465,51 @@
margin-bottom: 10px;
}
.settings-time-range-field {
margin-bottom: 10px;
}
.settings-time-range-trigger {
width: 100%;
padding: 10px 16px;
border: 1px solid var(--border-color);
border-radius: 9999px;
font-size: 14px;
background: var(--bg-primary);
color: var(--text-primary);
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: rgba(var(--primary-rgb), 0.45);
color: var(--primary);
}
&.open {
border-color: var(--primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent);
}
}
.settings-time-range-value {
flex: 1;
min-width: 0;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.settings-time-range-arrow {
color: var(--text-tertiary);
font-weight: 700;
line-height: 1;
}
.select-trigger {
width: 100%;
padding: 10px 16px;
@@ -887,7 +1049,7 @@
padding: 10px 24px;
border-radius: 9999px;
font-size: 14px;
z-index: 100;
z-index: 2200;
animation: slideDown 0.3s ease;
&.success {
@@ -901,6 +1063,27 @@
}
}
@media (max-width: 960px) {
.settings-modal-overlay {
padding: 20px;
}
.settings-page {
width: min(100%, calc(100vw - 40px));
height: min(100%, calc(100vh - 82px));
padding: 20px;
}
.settings-layout {
flex-direction: column;
}
.settings-tabs {
width: 100%;
max-height: 220px;
}
}
@keyframes slideDown {
from {
opacity: 0;
@@ -1739,54 +1922,106 @@
.model-status-card {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-direction: column;
align-items: stretch;
gap: 14px;
}
.model-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
.model-name-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.model-name {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 6px;
}
.model-path {
.model-size {
display: inline-flex;
align-items: center;
padding: 3px 9px;
border-radius: 999px;
background: color-mix(in srgb, var(--primary) 8%, var(--bg-secondary));
color: var(--text-secondary);
font-size: 12px;
font-weight: 600;
}
.model-meta {
display: flex;
flex-direction: column;
gap: 4px;
align-items: flex-start;
gap: 10px;
.status-indicator {
display: inline-flex;
align-items: center;
gap: 4px;
width: fit-content;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 500;
font-weight: 600;
&.success {
background: rgba(16, 185, 129, 0.1);
border: 1px solid rgba(16, 185, 129, 0.2);
color: #10b981;
}
&.warning {
background: rgba(245, 158, 11, 0.1);
border: 1px solid rgba(245, 158, 11, 0.2);
color: #f59e0b;
}
}
.model-path-block {
width: 100%;
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 12px;
border: 1px solid var(--border-color);
border-radius: 12px;
background: var(--bg-secondary);
}
.path-label {
font-size: 11px;
font-weight: 600;
color: var(--text-tertiary);
letter-spacing: 0.02em;
}
.path-text {
font-size: 12px;
color: var(--text-tertiary);
font-family: monospace;
word-break: break-all;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.model-actions {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 10px;
align-items: flex-start;
padding-top: 14px;
border-top: 1px solid var(--border-color);
.btn-download {
display: inline-flex;
@@ -1821,16 +2056,18 @@
.download-status {
display: flex;
flex-direction: column;
gap: 6px;
width: 280px;
gap: 8px;
width: 100%;
max-width: 420px;
.status-header,
.progress-info {
// specific layout class
display: flex;
justify-content: space-between;
align-items: center; // Align vertically
align-items: center;
width: 100%;
gap: 12px;
flex-wrap: wrap;
}
.percent {
@@ -1844,6 +2081,7 @@
.details {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
font-size: 12px;
color: var(--text-secondary);
@@ -1918,10 +2156,12 @@
.path-selector {
display: flex;
gap: 8px;
flex-wrap: wrap;
input {
margin-bottom: 0 !important;
flex: 1;
min-width: 220px;
font-family: monospace;
font-size: 12px;
}
@@ -2239,4 +2479,4 @@
}
}
}
}
}

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useRef } from 'react'
import { useLocation } from 'react-router-dom'
import { useAppStore } from '../stores/appStore'
import { useChatStore } from '../stores/chatStore'
import { useThemeStore, themes } from '../stores/themeStore'
@@ -8,20 +9,19 @@ import * as configService from '../services/config'
import {
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
RotateCcw, Trash2, Plug, Check, Sun, Moon, Monitor,
Palette, Database, Download, HardDrive, Info, RefreshCw, ChevronDown, Mic,
ShieldCheck, Fingerprint, Lock, KeyRound, Bell, Globe, BarChart2
Palette, Database, HardDrive, Info, RefreshCw, ChevronDown, Download, Mic,
ShieldCheck, Fingerprint, Lock, KeyRound, Bell, Globe, BarChart2, X
} from 'lucide-react'
import { Avatar } from '../components/Avatar'
import './SettingsPage.scss'
type SettingsTab = 'appearance' | 'notification' | 'database' | 'models' | 'export' | 'cache' | 'api' | 'security' | 'about' | 'analytics'
type SettingsTab = 'appearance' | 'notification' | 'database' | 'models' | 'cache' | 'api' | 'security' | 'about' | 'analytics'
const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
{ id: 'appearance', label: '外观', icon: Palette },
{ id: 'notification', label: '通知', icon: Bell },
{ id: 'database', label: '数据库连接', icon: Database },
{ id: 'models', label: '模型管理', icon: Mic },
{ id: 'export', label: '导出', icon: Download },
{ id: 'cache', label: '缓存', icon: HardDrive },
{ id: 'api', label: 'API 服务', icon: Globe },
@@ -36,7 +36,12 @@ interface WxidOption {
modifiedTime: number
}
function SettingsPage() {
interface SettingsPageProps {
onClose?: () => void
}
function SettingsPage({ onClose }: SettingsPageProps = {}) {
const location = useLocation()
const {
isDbConnected,
setDbConnected,
@@ -73,14 +78,6 @@ function SettingsPage() {
const [wxid, setWxid] = useState('')
const [wxidOptions, setWxidOptions] = useState<WxidOption[]>([])
const [showWxidSelect, setShowWxidSelect] = useState(false)
const [showExportFormatSelect, setShowExportFormatSelect] = useState(false)
const [showExportDateRangeSelect, setShowExportDateRangeSelect] = useState(false)
const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false)
const [showExportConcurrencySelect, setShowExportConcurrencySelect] = useState(false)
const exportFormatDropdownRef = useRef<HTMLDivElement>(null)
const exportDateRangeDropdownRef = useRef<HTMLDivElement>(null)
const exportExcelColumnsDropdownRef = useRef<HTMLDivElement>(null)
const exportConcurrencyDropdownRef = useRef<HTMLDivElement>(null)
const [cachePath, setCachePath] = useState('')
const [imageKeyProgress, setImageKeyProgress] = useState(0)
const [imageKeyPercent, setImageKeyPercent] = useState<number | null>(null)
@@ -103,12 +100,6 @@ function SettingsPage() {
const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(false)
const [transcribeLanguages, setTranscribeLanguages] = useState<string[]>(['zh'])
const [exportDefaultFormat, setExportDefaultFormat] = useState('excel')
const [exportDefaultDateRange, setExportDefaultDateRange] = useState('today')
const [exportDefaultMedia, setExportDefaultMedia] = useState(false)
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false)
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2)
const [notificationEnabled, setNotificationEnabled] = useState(true)
const [notificationPosition, setNotificationPosition] = useState<'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'>('top-right')
@@ -121,6 +112,9 @@ function SettingsPage() {
const [wordCloudExcludeWords, setWordCloudExcludeWords] = useState<string[]>([])
const [excludeWordsInput, setExcludeWordsInput] = useState('')
// 数据收集同意状态
const [analyticsConsent, setAnalyticsConsent] = useState<boolean>(false)
@@ -140,6 +134,7 @@ function SettingsPage() {
const [isClearingAnalyticsCache, setIsClearingAnalyticsCache] = useState(false)
const [isClearingImageCache, setIsClearingImageCache] = useState(false)
const [isClearingAllCache, setIsClearingAllCache] = useState(false)
const [isClosing, setIsClosing] = useState(false)
const saveTimersRef = useRef<Record<string, ReturnType<typeof setTimeout>>>({})
// 安全设置 state
@@ -199,26 +194,22 @@ function SettingsPage() {
}
}, [])
// 点击外部关闭下拉框
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as Node
if (showExportFormatSelect && exportFormatDropdownRef.current && !exportFormatDropdownRef.current.contains(target)) {
setShowExportFormatSelect(false)
}
if (showExportDateRangeSelect && exportDateRangeDropdownRef.current && !exportDateRangeDropdownRef.current.contains(target)) {
setShowExportDateRangeSelect(false)
}
if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) {
setShowExportExcelColumnsSelect(false)
}
if (showExportConcurrencySelect && exportConcurrencyDropdownRef.current && !exportConcurrencyDropdownRef.current.contains(target)) {
setShowExportConcurrencySelect(false)
const initialTab = (location.state as { initialTab?: SettingsTab } | null)?.initialTab
if (!initialTab) return
setActiveTab(initialTab)
}, [location.state])
useEffect(() => {
if (!onClose) return
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
handleClose()
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showExportFormatSelect, showExportDateRangeSelect, showExportExcelColumnsSelect, showExportConcurrencySelect])
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [onClose])
useEffect(() => {
const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => {
@@ -286,13 +277,6 @@ function SettingsPage() {
const savedWhisperModelDir = await configService.getWhisperModelDir()
const savedAutoTranscribe = await configService.getAutoTranscribeVoice()
const savedTranscribeLanguages = await configService.getTranscribeLanguages()
const savedExportDefaultFormat = await configService.getExportDefaultFormat()
const savedExportDefaultDateRange = await configService.getExportDefaultDateRange()
const savedExportDefaultMedia = await configService.getExportDefaultMedia()
const savedExportDefaultVoiceAsText = await configService.getExportDefaultVoiceAsText()
const savedExportDefaultExcelCompactColumns = await configService.getExportDefaultExcelCompactColumns()
const savedExportDefaultConcurrency = await configService.getExportDefaultConcurrency()
const savedNotificationEnabled = await configService.getNotificationEnabled()
const savedNotificationPosition = await configService.getNotificationPosition()
const savedNotificationFilterMode = await configService.getNotificationFilterMode()
@@ -327,12 +311,6 @@ function SettingsPage() {
setLogEnabled(savedLogEnabled)
setAutoTranscribeVoice(savedAutoTranscribe)
setTranscribeLanguages(savedTranscribeLanguages)
setExportDefaultFormat(savedExportDefaultFormat || 'excel')
setExportDefaultDateRange(savedExportDefaultDateRange || 'today')
setExportDefaultMedia(savedExportDefaultMedia ?? false)
setExportDefaultVoiceAsText(savedExportDefaultVoiceAsText ?? false)
setExportDefaultExcelCompactColumns(savedExportDefaultExcelCompactColumns ?? true)
setExportDefaultConcurrency(savedExportDefaultConcurrency ?? 2)
setNotificationEnabled(savedNotificationEnabled)
setNotificationPosition(savedNotificationPosition)
@@ -343,6 +321,9 @@ function SettingsPage() {
setWordCloudExcludeWords(savedExcludeWords)
setExcludeWordsInput(savedExcludeWords.join('\n'))
const savedAnalyticsConsent = await configService.getAnalyticsConsent()
setAnalyticsConsent(savedAnalyticsConsent ?? false)
// 如果语言列表为空,保存默认值
if (!savedTranscribeLanguages || savedTranscribeLanguages.length === 0) {
const defaultLanguages = ['zh']
@@ -465,6 +446,14 @@ function SettingsPage() {
setTimeout(() => setMessage(null), 3000)
}
const handleClose = () => {
if (!onClose) return
setIsClosing(true)
setTimeout(() => {
onClose()
}, 200)
}
type WxidKeys = {
decryptKey: string
imageXorKey: number | null
@@ -768,42 +757,25 @@ function SettingsPage() {
const handleAutoGetImageKey = async () => {
if (isFetchingImageKey) return;
if (!dbPath) {
showMessage('请先选择数据库目录', false);
return;
}
if (!dbPath) { showMessage('请先选择数据库目录', false); return; }
setIsFetchingImageKey(true);
setImageKeyPercent(0)
setImageKeyStatus('正在初始化...');
setImageKeyProgress(0); // 重置进度
setImageKeyProgress(0);
try {
const accountPath = wxid ? `${dbPath}/${wxid}` : dbPath;
const result = await window.electronAPI.key.autoGetImageKey(accountPath)
const result = await window.electronAPI.key.autoGetImageKey(accountPath, wxid)
if (result.success && result.aesKey) {
if (typeof result.xorKey === 'number') {
setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`)
}
if (typeof result.xorKey === 'number') setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`)
setImageAesKey(result.aesKey)
setImageKeyStatus('已获取图片密钥')
showMessage('已自动获取图片密钥', true)
// Auto-save after fetching keys
// We need to use the values directly because state updates are async
const newXorKey = typeof result.xorKey === 'number' ? result.xorKey : 0
const newAesKey = result.aesKey
await configService.setImageXorKey(newXorKey)
await configService.setImageAesKey(newAesKey)
if (wxid) {
await configService.setWxidConfig(wxid, {
decryptKey: decryptKey, // use current state as it hasn't changed here
imageXorKey: newXorKey,
imageAesKey: newAesKey
})
}
if (wxid) await configService.setWxidConfig(wxid, { decryptKey, imageXorKey: newXorKey, imageAesKey: newAesKey })
} else {
showMessage(result.error || '自动获取图片密钥失败', false)
}
@@ -814,6 +786,36 @@ function SettingsPage() {
}
}
const handleScanImageKeyFromMemory = async () => {
if (isFetchingImageKey) return;
if (!dbPath) { showMessage('请先选择数据库目录', false); return; }
setIsFetchingImageKey(true);
setImageKeyPercent(0)
setImageKeyStatus('正在扫描内存...');
try {
const accountPath = wxid ? `${dbPath}/${wxid}` : dbPath;
const result = await window.electronAPI.key.scanImageKeyFromMemory(accountPath)
if (result.success && result.aesKey) {
if (typeof result.xorKey === 'number') setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`)
setImageAesKey(result.aesKey)
setImageKeyStatus('内存扫描成功,已获取图片密钥')
showMessage('内存扫描成功,已获取图片密钥', true)
const newXorKey = typeof result.xorKey === 'number' ? result.xorKey : 0
const newAesKey = result.aesKey
await configService.setImageXorKey(newXorKey)
await configService.setImageAesKey(newAesKey)
if (wxid) await configService.setWxidConfig(wxid, { decryptKey, imageXorKey: newXorKey, imageAesKey: newAesKey })
} else {
showMessage(result.error || '内存扫描获取图片密钥失败', false)
}
} catch (e: any) {
showMessage(`内存扫描失败: ${e}`, false)
} finally {
setIsFetchingImageKey(false)
}
}
const handleTestConnection = async () => {
@@ -895,6 +897,21 @@ function SettingsPage() {
}
}
const handleClearLog = async () => {
const confirmed = window.confirm('确定清空 wcdb.log 吗?')
if (!confirmed) return
try {
const result = await window.electronAPI.log.clear()
if (!result.success) {
showMessage(result.error || '清空日志失败', false)
return
}
showMessage('日志已清空', true)
} catch (e: any) {
showMessage(`清空日志失败: ${e}`, false)
}
}
const handleClearAnalyticsCache = async () => {
if (isClearingCache) return
setIsClearingAnalyticsCache(true)
@@ -966,8 +983,12 @@ function SettingsPage() {
<div key={theme.id} className={`theme-card ${currentTheme === theme.id ? 'active' : ''}`} onClick={() => setTheme(theme.id)}>
<div className="theme-preview" style={{
background: effectiveMode === 'dark'
? (theme.id === 'blossom-dream' ? 'linear-gradient(150deg, #151316 0%, #1A1620 50%, #131018 100%)' : 'linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%)')
: (theme.id === 'blossom-dream' ? `linear-gradient(150deg, ${theme.bgColor} 0%, #F8F2F8 45%, #F2F6FB 100%)` : `linear-gradient(135deg, ${theme.bgColor} 0%, ${theme.bgColor}dd 100%)`)
? (theme.id === 'blossom-dream' ? 'linear-gradient(150deg, #151316 0%, #1A1620 50%, #131018 100%)'
: theme.id === 'geist' ? 'linear-gradient(135deg, #1a1a1a 0%, #222222 100%)'
: 'linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%)')
: (theme.id === 'blossom-dream' ? `linear-gradient(150deg, ${theme.bgColor} 0%, #F8F2F8 45%, #F2F6FB 100%)`
: theme.id === 'geist' ? 'linear-gradient(135deg, #ffffff 0%, #f0f0f0 100%)'
: `linear-gradient(135deg, ${theme.bgColor} 0%, ${theme.bgColor}dd 100%)`)
}}>
<div className="theme-accent" style={{
background: theme.accentColor
@@ -1373,24 +1394,24 @@ function SettingsPage() {
scheduleConfigSave('keys', () => syncCurrentKeys({ imageAesKey: value, wxid }))
}}
/>
<button className="btn btn-secondary btn-sm" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey}>
<Plug size={14} /> {isFetchingImageKey ? '获取中...' : '自动获取图片密钥'}
</button>
<div style={{ display: 'flex', gap: '8px', marginTop: '4px' }}>
<button className="btn btn-primary btn-sm" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey} title="从本地缓存快速计算">
<Plug size={14} /> {isFetchingImageKey ? '获取中...' : '缓存计算(推荐)'}
</button>
<button className="btn btn-secondary btn-sm" onClick={handleScanImageKeyFromMemory} disabled={isFetchingImageKey} title="扫描微信进程内存">
{isFetchingImageKey ? '扫描中...' : '内存扫描'}
</button>
</div>
{isFetchingImageKey ? (
<div className="brute-force-progress">
<div className="status-header">
<span className="status-text">{imageKeyStatus || '正在启动...'}</span>
{imageKeyPercent !== null && <span className="percent">{imageKeyPercent.toFixed(1)}%</span>}
</div>
{imageKeyPercent !== null && (
<div className="progress-bar-container">
<div className="fill" style={{ width: `${imageKeyPercent}%` }}></div>
</div>
)}
</div>
) : (
imageKeyStatus && <div className="form-hint status-text" style={{ marginTop: '8px' }}>{imageKeyStatus}</div>
)}
<span className="form-hint">使 2-3 </span>
</div>
<div className="form-group">
@@ -1421,10 +1442,15 @@ function SettingsPage() {
<button className="btn btn-secondary" onClick={handleCopyLog}>
<Copy size={16} />
</button>
<button className="btn btn-secondary" onClick={handleClearLog}>
<Trash2 size={16} />
</button>
</div>
</div>
</div>
)
const resolvedWhisperModelPath = whisperModelDir || whisperModelStatus?.modelPath || ''
const renderModelsTab = () => (
<div className="tab-content">
<div className="form-group">
@@ -1439,42 +1465,52 @@ function SettingsPage() {
<div className="setting-control vertical has-border">
<div className="model-status-card">
<div className="model-info">
<div className="model-name">SenseVoiceSmall (245 MB)</div>
<div className="model-path">
<div className="model-name-row">
<div className="model-name">SenseVoiceSmall</div>
<span className="model-size">245 MB</span>
</div>
<div className="model-meta">
{whisperModelStatus?.exists ? (
<span className="status-indicator success"><Check size={14} /> </span>
) : (
<span className="status-indicator warning"></span>
)}
{whisperModelDir && <div className="path-text" title={whisperModelDir}>{whisperModelDir}</div>}
{resolvedWhisperModelPath && (
<div className="model-path-block">
<span className="path-label"></span>
<div className="path-text" title={resolvedWhisperModelPath}>{resolvedWhisperModelPath}</div>
</div>
)}
</div>
</div>
<div className="model-actions">
{!whisperModelStatus?.exists && !isWhisperDownloading && (
<button
className="btn-download"
onClick={handleDownloadWhisperModel}
>
<Download size={16} />
</button>
)}
{isWhisperDownloading && (
<div className="download-status">
<div className="status-header">
<span className="percent">{Math.round(whisperDownloadProgress)}%</span>
{whisperProgressData.total > 0 && (
<span className="details">
{formatBytes(whisperProgressData.downloaded)} / {formatBytes(whisperProgressData.total)}
<span className="speed">({formatBytes(whisperProgressData.speed)}/s)</span>
</span>
)}
{(!whisperModelStatus?.exists || isWhisperDownloading) && (
<div className="model-actions">
{!whisperModelStatus?.exists && !isWhisperDownloading && (
<button
className="btn-download"
onClick={handleDownloadWhisperModel}
>
<Download size={16} />
</button>
)}
{isWhisperDownloading && (
<div className="download-status">
<div className="status-header">
<span className="percent">{Math.round(whisperDownloadProgress)}%</span>
{whisperProgressData.total > 0 && (
<span className="details">
{formatBytes(whisperProgressData.downloaded)} / {formatBytes(whisperProgressData.total)}
<span className="speed">({formatBytes(whisperProgressData.speed)}/s)</span>
</span>
)}
</div>
<div className="progress-bar-mini">
<div className="fill" style={{ width: `${whisperDownloadProgress}%` }}></div>
</div>
</div>
<div className="progress-bar-mini">
<div className="fill" style={{ width: `${whisperDownloadProgress}%` }}></div>
</div>
</div>
)}
</div>
)}
</div>
)}
</div>
<div className="sub-setting">
@@ -1521,258 +1557,6 @@ function SettingsPage() {
</div>
)
const exportFormatOptions = [
{ value: 'excel', label: 'Excel', desc: '电子表格,适合统计分析' },
{ value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' },
{ value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' },
{ value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' },
{ value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' },
{ value: 'txt', label: 'TXT', desc: '纯文本,通用格式' },
{ value: 'weclone', label: 'WeClone CSV', desc: 'WeClone 兼容字段格式CSV' },
{ value: 'sql', label: 'PostgreSQL', desc: '数据库脚本,便于导入到数据库' }
]
const exportDateRangeOptions = [
{ value: 'today', label: '今天' },
{ value: '7d', label: '最近7天' },
{ value: '30d', label: '最近30天' },
{ value: '90d', label: '最近90天' },
{ value: 'all', label: '全部时间' }
]
const exportExcelColumnOptions = [
{ value: 'compact', label: '精简列', desc: '序号、时间、发送者身份、消息类型、内容' },
{ value: 'full', label: '完整列', desc: '含发送者昵称/微信ID/备注' }
]
const exportConcurrencyOptions = [
{ value: 1, label: '1' },
{ value: 2, label: '2' },
{ value: 3, label: '3' },
{ value: 4, label: '4' },
{ value: 5, label: '5' },
{ value: 6, label: '6' }
]
const getOptionLabel = (options: { value: string; label: string }[], value: string) => {
return options.find((option) => option.value === value)?.label ?? value
}
const renderExportTab = () => {
const exportExcelColumnsValue = exportDefaultExcelCompactColumns ? 'compact' : 'full'
const exportFormatLabel = getOptionLabel(exportFormatOptions, exportDefaultFormat)
const exportDateRangeLabel = getOptionLabel(exportDateRangeOptions, exportDefaultDateRange)
const exportExcelColumnsLabel = getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue)
const exportConcurrencyLabel = String(exportDefaultConcurrency)
return (
<div className="tab-content">
<div className="form-group">
<label></label>
<span className="form-hint"></span>
<div className="select-field" ref={exportFormatDropdownRef}>
<button
type="button"
className={`select-trigger ${showExportFormatSelect ? 'open' : ''}`}
onClick={() => {
setShowExportFormatSelect(!showExportFormatSelect)
setShowExportDateRangeSelect(false)
setShowExportExcelColumnsSelect(false)
setShowExportConcurrencySelect(false)
}}
>
<span className="select-value">{exportFormatLabel}</span>
<ChevronDown size={16} />
</button>
{showExportFormatSelect && (
<div className="select-dropdown">
{exportFormatOptions.map((option) => (
<button
key={option.value}
type="button"
className={`select-option ${exportDefaultFormat === option.value ? 'active' : ''}`}
onClick={async () => {
setExportDefaultFormat(option.value)
await configService.setExportDefaultFormat(option.value)
showMessage('已更新导出格式默认值', true)
setShowExportFormatSelect(false)
}}
>
<span className="option-label">{option.label}</span>
{option.desc && <span className="option-desc">{option.desc}</span>}
</button>
))}
</div>
)}
</div>
</div>
<div className="form-group">
<label></label>
<span className="form-hint"></span>
<div className="select-field" ref={exportDateRangeDropdownRef}>
<button
type="button"
className={`select-trigger ${showExportDateRangeSelect ? 'open' : ''}`}
onClick={() => {
setShowExportDateRangeSelect(!showExportDateRangeSelect)
setShowExportFormatSelect(false)
setShowExportExcelColumnsSelect(false)
setShowExportConcurrencySelect(false)
}}
>
<span className="select-value">{exportDateRangeLabel}</span>
<ChevronDown size={16} />
</button>
{showExportDateRangeSelect && (
<div className="select-dropdown">
{exportDateRangeOptions.map((option) => (
<button
key={option.value}
type="button"
className={`select-option ${exportDefaultDateRange === option.value ? 'active' : ''}`}
onClick={async () => {
setExportDefaultDateRange(option.value)
await configService.setExportDefaultDateRange(option.value)
showMessage('已更新默认导出时间范围', true)
setShowExportDateRangeSelect(false)
}}
>
<span className="option-label">{option.label}</span>
</button>
))}
</div>
)}
</div>
</div>
<div className="form-group">
<label></label>
<span className="form-hint">//</span>
<div className="log-toggle-line">
<span className="log-status">{exportDefaultMedia ? '已开启' : '已关闭'}</span>
<label className="switch" htmlFor="export-default-media">
<input
id="export-default-media"
className="switch-input"
type="checkbox"
checked={exportDefaultMedia}
onChange={async (e) => {
const enabled = e.target.checked
setExportDefaultMedia(enabled)
await configService.setExportDefaultMedia(enabled)
showMessage(enabled ? '已开启默认媒体导出' : '已关闭默认媒体导出', true)
}}
/>
<span className="switch-slider" />
</label>
</div>
</div>
<div className="form-group">
<label></label>
<span className="form-hint"></span>
<div className="log-toggle-line">
<span className="log-status">{exportDefaultVoiceAsText ? '已开启' : '已关闭'}</span>
<label className="switch" htmlFor="export-default-voice-as-text">
<input
id="export-default-voice-as-text"
className="switch-input"
type="checkbox"
checked={exportDefaultVoiceAsText}
onChange={async (e) => {
const enabled = e.target.checked
setExportDefaultVoiceAsText(enabled)
await configService.setExportDefaultVoiceAsText(enabled)
showMessage(enabled ? '已开启默认语音转文字' : '已关闭默认语音转文字', true)
}}
/>
<span className="switch-slider" />
</label>
</div>
</div>
<div className="form-group">
<label>Excel </label>
<span className="form-hint"> Excel </span>
<div className="select-field" ref={exportExcelColumnsDropdownRef}>
<button
type="button"
className={`select-trigger ${showExportExcelColumnsSelect ? 'open' : ''}`}
onClick={() => {
setShowExportExcelColumnsSelect(!showExportExcelColumnsSelect)
setShowExportFormatSelect(false)
setShowExportDateRangeSelect(false)
setShowExportConcurrencySelect(false)
}}
>
<span className="select-value">{exportExcelColumnsLabel}</span>
<ChevronDown size={16} />
</button>
{showExportExcelColumnsSelect && (
<div className="select-dropdown">
{exportExcelColumnOptions.map((option) => (
<button
key={option.value}
type="button"
className={`select-option ${exportExcelColumnsValue === option.value ? 'active' : ''}`}
onClick={async () => {
const compact = option.value === 'compact'
setExportDefaultExcelCompactColumns(compact)
await configService.setExportDefaultExcelCompactColumns(compact)
showMessage(compact ? '已启用精简列' : '已启用完整列', true)
setShowExportExcelColumnsSelect(false)
}}
>
<span className="option-label">{option.label}</span>
{option.desc && <span className="option-desc">{option.desc}</span>}
</button>
))}
</div>
)}
</div>
</div>
<div className="form-group">
<label></label>
<span className="form-hint">1~6</span>
<div className="select-field" ref={exportConcurrencyDropdownRef}>
<button
type="button"
className={`select-trigger ${showExportConcurrencySelect ? 'open' : ''}`}
onClick={() => {
setShowExportConcurrencySelect(!showExportConcurrencySelect)
setShowExportFormatSelect(false)
setShowExportDateRangeSelect(false)
setShowExportExcelColumnsSelect(false)
}}
>
<span className="select-value">{exportConcurrencyLabel}</span>
<ChevronDown size={16} />
</button>
{showExportConcurrencySelect && (
<div className="select-dropdown">
{exportConcurrencyOptions.map((option) => (
<button
key={option.value}
type="button"
className={`select-option ${exportDefaultConcurrency === option.value ? 'active' : ''}`}
onClick={async () => {
setExportDefaultConcurrency(option.value)
await configService.setExportDefaultConcurrency(option.value)
showMessage(`已将导出并发数设为 ${option.value}`, true)
setShowExportConcurrencySelect(false)
}}
>
<span className="option-label">{option.label}</span>
</button>
))}
</div>
)}
</div>
</div>
</div>
)
}
const renderCacheTab = () => (
<div className="tab-content">
<p className="section-desc"></p>
@@ -2277,7 +2061,6 @@ function SettingsPage() {
<RefreshCw size={16} className={isCheckingUpdate ? 'spin' : ''} />
{isCheckingUpdate ? '检查中...' : '检查更新'}
</button>
</div>
)}
</div>
@@ -2293,72 +2076,103 @@ function SettingsPage() {
<a href="#" onClick={(e) => { e.preventDefault(); window.electronAPI.window.openAgreementWindow() }}></a>
</div>
<p className="copyright">© 2025 WeFlow. All rights reserved.</p>
<div className="log-toggle-line" style={{ marginTop: '16px', justifyContent: 'center' }}>
<span style={{ fontSize: '13px', opacity: 0.7 }}></span>
<label className="switch">
<input
type="checkbox"
className="switch-input"
checked={analyticsConsent}
onChange={async (e) => {
const consent = e.target.checked
setAnalyticsConsent(consent)
await configService.setAnalyticsConsent(consent)
showMessage(consent ? '已允许数据收集' : '已拒绝数据收集', true)
}}
/>
<span className="switch-slider"></span>
</label>
</div>
</div>
</div>
)
return (
<div className="settings-page">
{message && <div className={`message-toast ${message.success ? 'success' : 'error'}`}>{message.text}</div>}
<div className={`settings-modal-overlay ${isClosing ? 'closing' : ''}`} onClick={handleClose}>
<div className={`settings-page ${isClosing ? 'closing' : ''}`} onClick={(event) => event.stopPropagation()}>
{message && <div className={`message-toast ${message.success ? 'success' : 'error'}`}>{message.text}</div>}
{/* 多账号选择对话框 */}
{showWxidSelect && wxidOptions.length > 1 && (
<div className="wxid-dialog-overlay" onClick={() => setShowWxidSelect(false)}>
<div className="wxid-dialog" onClick={(e) => e.stopPropagation()}>
<div className="wxid-dialog-header">
<h3></h3>
<p>使</p>
</div>
<div className="wxid-dialog-list">
{wxidOptions.map((opt) => (
<div
key={opt.wxid}
className={`wxid-dialog-item ${opt.wxid === wxid ? 'active' : ''}`}
onClick={() => handleSelectWxid(opt.wxid)}
>
<span className="wxid-id">{opt.wxid}</span>
<span className="wxid-date"> {new Date(opt.modifiedTime).toLocaleString()}</span>
</div>
))}
</div>
<div className="wxid-dialog-footer">
<button className="btn btn-secondary" onClick={() => setShowWxidSelect(false)}></button>
{/* 多账号选择对话框 */}
{showWxidSelect && wxidOptions.length > 1 && (
<div className="wxid-dialog-overlay" onClick={() => setShowWxidSelect(false)}>
<div className="wxid-dialog" onClick={(e) => e.stopPropagation()}>
<div className="wxid-dialog-header">
<h3></h3>
<p>使</p>
</div>
<div className="wxid-dialog-list">
{wxidOptions.map((opt) => (
<div
key={opt.wxid}
className={`wxid-dialog-item ${opt.wxid === wxid ? 'active' : ''}`}
onClick={() => handleSelectWxid(opt.wxid)}
>
<span className="wxid-id">{opt.wxid}</span>
<span className="wxid-date"> {new Date(opt.modifiedTime).toLocaleString()}</span>
</div>
))}
</div>
<div className="wxid-dialog-footer">
<button className="btn btn-secondary" onClick={() => setShowWxidSelect(false)}></button>
</div>
</div>
</div>
</div>
)}
)}
<div className="settings-header">
<h1></h1>
<div className="settings-actions">
<button className="btn btn-secondary" onClick={handleTestConnection} disabled={isLoading || isTesting}>
<Plug size={16} /> {isTesting ? '测试中...' : '测试连接'}
</button>
<div className="settings-header">
<div className="settings-title-block">
<h1></h1>
</div>
<div className="settings-actions">
<button className="btn btn-secondary" onClick={handleTestConnection} disabled={isLoading || isTesting}>
<Plug size={16} /> {isTesting ? '测试中...' : '测试连接'}
</button>
{onClose && (
<button type="button" className="settings-close-btn" onClick={handleClose} aria-label="关闭设置">
<X size={18} />
</button>
)}
</div>
</div>
<div className="settings-layout">
<div className="settings-tabs" role="tablist" aria-label="设置项">
{tabs.map(tab => (
<button
key={tab.id}
className={`tab-btn ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => setActiveTab(tab.id)}
>
<tab.icon size={16} />
<span>{tab.label}</span>
</button>
))}
</div>
<div className="settings-body">
{activeTab === 'appearance' && renderAppearanceTab()}
{activeTab === 'notification' && renderNotificationTab()}
{activeTab === 'database' && renderDatabaseTab()}
{activeTab === 'models' && renderModelsTab()}
{activeTab === 'cache' && renderCacheTab()}
{activeTab === 'api' && renderApiTab()}
{activeTab === 'analytics' && renderAnalyticsTab()}
{activeTab === 'security' && renderSecurityTab()}
{activeTab === 'about' && renderAboutTab()}
</div>
</div>
</div>
<div className="settings-tabs">
{tabs.map(tab => (
<button key={tab.id} className={`tab-btn ${activeTab === tab.id ? 'active' : ''}`} onClick={() => setActiveTab(tab.id)}>
<tab.icon size={16} />
<span>{tab.label}</span>
</button>
))}
</div>
<div className="settings-body">
{activeTab === 'appearance' && renderAppearanceTab()}
{activeTab === 'notification' && renderNotificationTab()}
{activeTab === 'database' && renderDatabaseTab()}
{activeTab === 'models' && renderModelsTab()}
{activeTab === 'export' && renderExportTab()}
{activeTab === 'cache' && renderCacheTab()}
{activeTab === 'api' && renderApiTab()}
{activeTab === 'analytics' && renderAnalyticsTab()}
{activeTab === 'security' && renderSecurityTab()}
{activeTab === 'about' && renderAboutTab()}
</div>
</div>
)
}

View File

@@ -1,7 +1,7 @@
/* Global Variables */
:root {
--sns-max-width: 800px;
--sns-panel-width: 320px;
--sns-panel-width: 380px;
--sns-bg-color: var(--bg-primary);
--sns-card-bg: var(--bg-secondary);
--sns-border-radius-lg: 16px;
@@ -11,7 +11,8 @@
.sns-page-layout {
display: flex;
height: 100%;
height: calc(100% + 48px);
margin: -24px;
overflow: hidden;
background: var(--sns-bg-color);
position: relative;
@@ -23,7 +24,7 @@
========================================= */
.sns-main-viewport {
flex: 1;
overflow-y: scroll;
overflow: hidden;
position: relative;
display: flex;
justify-content: center;
@@ -32,24 +33,138 @@
.sns-feed-container {
width: 100%;
max-width: var(--sns-max-width);
padding: 20px 24px 60px 24px;
padding: 10px 24px 12px 24px;
display: flex;
flex-direction: column;
gap: 24px;
gap: 0;
min-height: 0;
height: 100%;
}
.feed-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
margin-bottom: 4px;
padding: 0 4px;
z-index: 2;
background: var(--sns-bg-color);
border-bottom: 1px solid var(--border-color);
padding-top: 4px;
padding-bottom: 6px;
h2 {
font-size: 20px;
font-weight: 700;
margin: 0;
color: var(--text-primary);
.feed-header-main {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
h2 {
font-size: 20px;
font-weight: 700;
margin: 0;
color: var(--text-primary);
}
}
.feed-stats-line {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.4;
&.loading {
opacity: 0.7;
}
&.error {
color: #d94f45;
}
}
.feed-stats-range {
gap: 0;
}
.feed-overview-total {
font-size: inherit;
color: inherit;
white-space: nowrap;
}
.feed-stats-divider {
color: color-mix(in srgb, var(--text-secondary) 78%, transparent);
}
.feed-my-timeline-entry {
display: inline-flex;
align-items: center;
gap: 8px;
width: fit-content;
padding: 0;
border: none;
background: transparent;
font-size: 13px;
line-height: 1.4;
color: var(--text-secondary);
cursor: default;
transition: color 0.2s ease, opacity 0.2s ease;
.feed-my-timeline-label {
font-weight: 500;
}
.feed-my-timeline-count {
color: var(--text-primary);
font-weight: 600;
display: inline-flex;
align-items: center;
.spin {
animation: spin 0.8s linear infinite;
}
}
&.ready {
cursor: pointer;
&:hover {
color: var(--primary);
}
&:hover .feed-my-timeline-count {
color: var(--primary);
}
&:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 3px;
border-radius: 6px;
}
}
&.loading {
opacity: 0.72;
}
&:disabled {
opacity: 0.68;
}
}
.feed-stats-retry {
border: none;
background: transparent;
color: inherit;
font-size: 13px;
padding: 0;
line-height: 1.4;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.header-actions {
@@ -58,6 +173,18 @@
gap: 10px;
}
.jump-calendar-anchor {
position: relative;
display: flex;
align-items: center;
isolation: isolate;
z-index: 20;
.jump-date-popover {
z-index: 2600;
}
}
.icon-btn {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
@@ -83,6 +210,99 @@
animation: spin 0.8s linear infinite;
}
}
.jump-date-chip {
display: inline-flex;
align-items: center;
gap: 8px;
border: 1px solid var(--border-color);
border-radius: var(--sns-border-radius-sm);
background: var(--bg-tertiary);
color: var(--text-secondary);
padding: 8px 10px;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: var(--hover-bg);
color: var(--primary);
}
&.active {
border-color: var(--primary);
background: rgba(var(--primary-rgb), 0.08);
color: var(--primary);
}
}
.jump-date-chip-label {
font-size: 13px;
font-weight: 500;
line-height: 1;
white-space: nowrap;
}
.jump-date-chip-clear {
display: inline-flex;
align-items: center;
justify-content: center;
color: inherit;
border-radius: 999px;
padding: 1px;
&:hover {
background: color-mix(in srgb, var(--primary) 12%, transparent);
}
}
}
.sns-posts-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
padding-top: 16px;
}
.feed-contact-filter-bar {
margin: 10px 4px 0;
padding: 10px 12px;
border: 1px solid color-mix(in srgb, var(--primary) 28%, var(--border-color));
border-radius: 12px;
background: rgba(var(--primary-rgb), 0.08);
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
.feed-contact-filter-label {
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
}
.feed-contact-filter-summary {
font-size: 13px;
color: var(--text-primary);
font-weight: 600;
min-width: 0;
}
.feed-contact-filter-clear {
margin-left: auto;
border: none;
background: transparent;
color: var(--primary);
font-size: 12px;
font-weight: 600;
cursor: pointer;
padding: 0;
white-space: nowrap;
&:hover {
text-decoration: underline;
text-underline-offset: 2px;
}
}
}
.posts-list {
@@ -132,6 +352,30 @@
flex-shrink: 0;
}
.author-trigger-btn {
background: transparent;
border: none;
padding: 0;
margin: 0;
color: inherit;
cursor: pointer;
}
.avatar-trigger {
border-radius: 12px;
transition: transform 0.15s ease, box-shadow 0.15s ease;
&:hover {
transform: translateY(-1px);
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.1);
}
&:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
}
.post-content-col {
flex: 1;
min-width: 0;
@@ -159,6 +403,30 @@
margin-bottom: 2px;
}
.author-name-trigger {
align-self: flex-start;
border-radius: 6px;
margin-bottom: 2px;
.author-name {
transition: color 0.15s ease, text-decoration-color 0.15s ease;
text-decoration: underline;
text-decoration-color: transparent;
text-underline-offset: 2px;
margin-bottom: 0;
}
&:hover .author-name {
color: var(--primary);
text-decoration-color: currentColor;
}
&:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
}
.post-time {
font-size: 12px;
color: var(--text-tertiary);
@@ -172,6 +440,13 @@
flex-shrink: 0;
}
.post-time-standalone {
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.2;
padding-top: 2px;
}
.debug-btn {
opacity: 0;
transition: opacity 0.2s;
@@ -862,9 +1137,21 @@
padding: 2px 6px;
border-radius: 10px;
}
.widget-header-summary {
margin-left: auto;
font-size: 12px;
font-weight: 500;
color: var(--text-tertiary);
white-space: nowrap;
}
}
}
.contact-widget .widget-header .badge + .widget-header-summary {
margin-left: 8px;
}
/* Search Widget */
.input-group {
position: relative;
@@ -903,44 +1190,6 @@
}
}
/* Date Widget */
.date-picker-trigger {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
background: var(--bg-tertiary);
border: 1px solid transparent;
border-radius: var(--sns-border-radius-sm);
padding: 12px;
cursor: pointer;
transition: all 0.2s;
font-size: 13px;
color: var(--text-secondary);
&:hover {
background: var(--bg-primary);
border-color: var(--primary);
}
&.active {
background: rgba(var(--primary-rgb), 0.08);
border-color: var(--primary);
color: var(--primary);
font-weight: 500;
}
.clear-date-btn {
padding: 4px;
display: flex;
color: var(--primary);
&:hover {
transform: scale(1.1);
}
}
}
/* Contact Widget - Refactored */
.contact-widget {
display: flex;
@@ -996,6 +1245,21 @@
}
}
.contact-count-progress {
padding: 8px 16px 10px;
font-size: 11px;
color: var(--text-tertiary);
border-bottom: 1px dashed color-mix(in srgb, var(--border-color) 70%, transparent);
font-variant-numeric: tabular-nums;
}
.contact-interaction-hint {
padding: 10px 16px 0;
font-size: 11px;
line-height: 1.5;
color: var(--text-tertiary);
}
.contact-list-scroll {
flex: 1;
overflow-y: auto;
@@ -1003,67 +1267,164 @@
display: flex;
flex-direction: column;
gap: 0;
/* Remove gap to allow borders to merge */
.contact-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px;
border-radius: var(--sns-border-radius-md);
cursor: pointer;
transition: background 0.2s ease, transform 0.2s ease;
border: 2px solid transparent;
gap: 8px;
margin-bottom: 4px;
/* Separation for unselected items */
border-radius: var(--sns-border-radius-md);
transition: transform 0.2s ease;
&:hover {
background: var(--hover-bg);
transform: translateX(2px);
z-index: 10;
}
&.selected {
background: rgba(var(--primary-rgb), 0.1);
border-color: var(--primary);
box-shadow: none;
z-index: 5;
margin-bottom: 0;
/* Remove margin to merge */
&.is-selected .contact-main-btn {
background: rgba(var(--primary-rgb), 0.06);
border-color: color-mix(in srgb, var(--primary) 20%, var(--border-color));
}
&.is-active .contact-main-btn {
background: rgba(var(--primary-rgb), 0.12);
border-color: color-mix(in srgb, var(--primary) 48%, var(--border-color));
box-shadow: inset 0 0 0 1px rgba(var(--primary-rgb), 0.18);
}
&.is-active .contact-name {
color: var(--text-primary);
}
.contact-select-btn {
width: 32px;
height: 32px;
flex-shrink: 0;
border: none;
background: transparent;
border-radius: 8px;
color: var(--text-tertiary);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.2s ease, color 0.2s ease;
&:hover {
background: rgba(var(--primary-rgb), 0.1);
color: var(--primary);
}
&.checked {
color: var(--primary);
}
}
.contact-main-btn {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: var(--sns-border-radius-md);
border: 1px solid transparent;
background: transparent;
cursor: pointer;
transition: background 0.2s ease, border-color 0.2s ease;
text-align: left;
&:hover {
background: var(--hover-bg);
}
}
.contact-meta {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
.contact-name {
color: var(--primary);
font-weight: 600;
}
/* If the NEXT item is also selected */
&:has(+ .contact-row.selected) {
border-bottom: none;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
padding-bottom: 12px;
/* Compensate for missing border (+2px) */
font-size: 14px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
/* If the PREVIOUS item is selected */
&.selected+.contact-row.selected {
border-top: none;
border-top-left-radius: 0;
border-top-right-radius: 0;
margin-top: 0;
padding-top: 12px;
/* Compensate for missing border */
.contact-post-count-wrap {
margin-left: 8px;
min-width: 46px;
display: flex;
justify-content: flex-end;
align-items: center;
flex-shrink: 0;
}
.contact-name {
flex: 1;
font-size: 14px;
color: var(--text-secondary);
.contact-post-count {
font-size: 12px;
color: var(--text-tertiary);
font-variant-numeric: tabular-nums;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.contact-post-count-loading {
color: var(--text-tertiary);
display: inline-flex;
align-items: center;
justify-content: center;
.spinning {
animation: spin 0.8s linear infinite;
}
}
}
}
.contact-batch-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px 14px;
border-top: 1px solid var(--border-color);
background: color-mix(in srgb, var(--bg-primary) 86%, var(--bg-secondary));
}
.contact-batch-summary {
flex: 1;
min-width: 0;
font-size: 12px;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
}
.contact-batch-btn {
border: 1px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-secondary);
border-radius: var(--sns-border-radius-md);
height: 32px;
padding: 0 10px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
font-size: 12px;
cursor: pointer;
transition: border-color 0.2s ease, background 0.2s ease, color 0.2s ease;
&:hover {
border-color: var(--text-tertiary);
background: var(--hover-bg);
color: var(--text-primary);
}
&.primary {
border-color: color-mix(in srgb, var(--primary) 35%, var(--border-color));
background: rgba(var(--primary-rgb), 0.12);
color: var(--primary);
}
}
@@ -1261,6 +1622,116 @@
}
}
.author-timeline-dialog {
background: var(--sns-card-bg);
border-radius: var(--sns-border-radius-lg);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
width: min(860px, 94vw);
max-height: 86vh;
display: flex;
flex-direction: column;
border: 1px solid var(--border-color);
overflow: hidden;
animation: slide-up-fade 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.author-timeline-header {
padding: 14px 18px;
border-bottom: 1px solid var(--border-color);
background: var(--bg-tertiary);
display: flex;
align-items: flex-start;
justify-content: space-between;
.close-btn {
background: none;
border: none;
color: var(--text-tertiary);
cursor: pointer;
padding: 6px;
border-radius: 6px;
display: flex;
&:hover {
background: var(--bg-primary);
color: var(--text-primary);
}
}
}
.author-timeline-meta {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.author-timeline-meta-text {
min-width: 0;
h3 {
margin: 0;
font-size: 16px;
color: var(--text-primary);
}
}
.author-timeline-username {
margin-top: 2px;
font-size: 12px;
color: var(--text-secondary);
}
.author-timeline-stats {
margin-top: 4px;
font-size: 12px;
color: var(--text-secondary);
}
.author-timeline-body {
padding: 16px;
overflow-y: auto;
min-height: 180px;
max-height: calc(86vh - 96px);
}
.author-timeline-posts-list {
gap: 16px;
}
.author-timeline-loading {
margin-top: 12px;
}
.author-timeline-empty {
padding: 42px 10px 30px;
text-align: center;
font-size: 14px;
color: var(--text-secondary);
}
.author-timeline-load-more {
display: block;
margin: 12px auto 2px;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-secondary);
border-radius: 999px;
padding: 7px 16px;
font-size: 13px;
cursor: pointer;
&:hover:not(:disabled) {
color: var(--primary);
border-color: var(--primary);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
@keyframes slide-up-fade {
from {
opacity: 0;
@@ -1380,6 +1851,44 @@
gap: 8px;
}
.export-section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.time-range-trigger.sns-export-time-range-trigger {
border: 1px solid var(--border-color);
background: var(--bg-primary);
border-radius: 999px;
color: var(--text-primary);
font-size: 12px;
min-height: 32px;
padding: 0 10px;
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
transition: border-color 0.2s ease, color 0.2s ease, background 0.2s ease;
&:hover:not(:disabled) {
border-color: rgba(var(--primary-rgb), 0.45);
color: var(--primary);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.time-range-arrow {
color: var(--text-tertiary);
font-weight: 700;
line-height: 1;
}
}
.export-format-options {
display: grid;
@@ -1909,10 +2418,31 @@
font-size: 12px;
color: var(--text-tertiary);
margin: 0;
padding-left: 24px;
line-height: 1.4;
}
.export-media-check-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
gap: 8px;
label {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text-primary);
padding: 8px 10px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
}
input[type='checkbox'] {
margin: 0;
}
}
.export-progress {
display: flex;
flex-direction: column;
@@ -2091,4 +2621,4 @@
cursor: not-allowed;
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -23,6 +23,18 @@ interface WelcomePageProps {
standalone?: boolean
}
const formatDbKeyFailureMessage = (error?: string, logs?: string[]): string => {
const base = String(error || '自动获取密钥失败').trim()
const tailLogs = Array.isArray(logs)
? logs
.map(item => String(item || '').trim())
.filter(Boolean)
.slice(-6)
: []
if (tailLogs.length === 0) return base
return `${base};最近状态:${tailLogs.join(' | ')}`
}
function WelcomePage({ standalone = false }: WelcomePageProps) {
const navigate = useNavigate()
const { isDbConnected, setDbConnected, setLoading } = useAppStore()
@@ -292,7 +304,10 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
setIsManualStartPrompt(true)
setDbKeyStatus('需要手动启动微信')
} else {
setError(result.error || '自动获取密钥失败')
if (result.error?.includes('尚未完成登录')) {
setDbKeyStatus('请先在微信完成登录后重试')
}
setError(formatDbKeyFailureMessage(result.error, result.logs))
}
}
} catch (e) {
@@ -309,22 +324,16 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
const handleAutoGetImageKey = async () => {
if (isFetchingImageKey) return
if (!dbPath) {
setError('请先选择数据库目录')
return
}
if (!dbPath) { setError('请先选择数据库目录'); return }
setIsFetchingImageKey(true)
setError('')
setImageKeyPercent(0)
setImageKeyStatus('正在准备获取图片密钥...')
try {
// 拼接完整的账号目录,确保 KeyService 能准确找到模板文件
const accountPath = wxid ? `${dbPath}/${wxid}` : dbPath
const result = await window.electronAPI.key.autoGetImageKey(accountPath)
const result = await window.electronAPI.key.autoGetImageKey(accountPath, wxid)
if (result.success && result.aesKey) {
if (typeof result.xorKey === 'number') {
setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`)
}
if (typeof result.xorKey === 'number') setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`)
setImageAesKey(result.aesKey)
setImageKeyStatus('已获取图片密钥')
} else {
@@ -337,6 +346,30 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
}
}
const handleScanImageKeyFromMemory = async () => {
if (isFetchingImageKey) return
if (!dbPath) { setError('请先选择数据库目录'); return }
setIsFetchingImageKey(true)
setError('')
setImageKeyPercent(0)
setImageKeyStatus('正在扫描内存...')
try {
const accountPath = wxid ? `${dbPath}/${wxid}` : dbPath
const result = await window.electronAPI.key.scanImageKeyFromMemory(accountPath)
if (result.success && result.aesKey) {
if (typeof result.xorKey === 'number') setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`)
setImageAesKey(result.aesKey)
setImageKeyStatus('内存扫描成功,已获取图片密钥')
} else {
setError(result.error || '内存扫描获取图片密钥失败')
}
} catch (e) {
setError(`内存扫描失败: ${e}`)
} finally {
setIsFetchingImageKey(false)
}
}
const canGoNext = () => {
if (currentStep.id === 'intro') return true
if (currentStep.id === 'db') return Boolean(dbPath)
@@ -750,47 +783,34 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
<div className="grid-2">
<div>
<label className="field-label"> XOR </label>
<input
type="text"
className="field-input"
placeholder="0x..."
value={imageXorKey}
onChange={(e) => setImageXorKey(e.target.value)}
/>
<input type="text" className="field-input" placeholder="0x..." value={imageXorKey} onChange={(e) => setImageXorKey(e.target.value)} />
</div>
<div>
<label className="field-label"> AES </label>
<input
type="text"
className="field-input"
placeholder="16位密钥"
value={imageAesKey}
onChange={(e) => setImageAesKey(e.target.value)}
/>
<input type="text" className="field-input" placeholder="16位密钥" value={imageAesKey} onChange={(e) => setImageAesKey(e.target.value)} />
</div>
</div>
<button className="btn btn-secondary btn-block mt-4" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey}>
{isFetchingImageKey ? '获取中...' : '自动获取图片密钥'}
</button>
<div style={{ display: 'flex', gap: '8px', marginTop: '16px' }}>
<button className="btn btn-primary btn-block" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey} title="从本地缓存快速计算">
{isFetchingImageKey ? '获取中...' : '缓存计算(推荐)'}
</button>
<button className="btn btn-secondary btn-block" onClick={handleScanImageKeyFromMemory} disabled={isFetchingImageKey} title="扫描微信进程内存">
{isFetchingImageKey ? '扫描中...' : '内存扫描'}
</button>
</div>
{isFetchingImageKey ? (
<div className="brute-force-progress">
<div className="status-header">
<span className="status-text">{imageKeyStatus || '正在启动...'}</span>
{imageKeyPercent !== null && <span className="percent">{imageKeyPercent.toFixed(1)}%</span>}
</div>
{imageKeyPercent !== null && (
<div className="progress-bar-container">
<div className="fill" style={{ width: `${imageKeyPercent}%` }}></div>
</div>
)}
</div>
) : (
imageKeyStatus && <div className="status-message" style={{ marginTop: '12px' }}>{imageKeyStatus}</div>
)}
<div className="field-hint"></div>
<div className="field-hint" style={{ marginTop: '8px' }}>使 2-3 </div>
</div>
)}
</div>

View File

@@ -0,0 +1,149 @@
import type {
BackgroundTaskInput,
BackgroundTaskRecord,
BackgroundTaskStatus,
BackgroundTaskUpdate
} from '../types/backgroundTask'
type BackgroundTaskListener = (tasks: BackgroundTaskRecord[]) => void
const tasks = new Map<string, BackgroundTaskRecord>()
const cancelHandlers = new Map<string, () => void | Promise<void>>()
const listeners = new Set<BackgroundTaskListener>()
let taskSequence = 0
const ACTIVE_STATUSES = new Set<BackgroundTaskStatus>(['running', 'cancel_requested'])
const MAX_SETTLED_TASKS = 24
const buildTaskId = (): string => {
taskSequence += 1
return `bg-task-${Date.now()}-${taskSequence}`
}
const notifyListeners = () => {
const snapshot = getBackgroundTaskSnapshot()
for (const listener of listeners) {
listener(snapshot)
}
}
const pruneSettledTasks = () => {
const settledTasks = [...tasks.values()]
.filter(task => !ACTIVE_STATUSES.has(task.status))
.sort((a, b) => (b.finishedAt || b.updatedAt) - (a.finishedAt || a.updatedAt))
for (const staleTask of settledTasks.slice(MAX_SETTLED_TASKS)) {
tasks.delete(staleTask.id)
}
}
export const getBackgroundTaskSnapshot = (): BackgroundTaskRecord[] => (
[...tasks.values()].sort((a, b) => {
const aActive = ACTIVE_STATUSES.has(a.status) ? 1 : 0
const bActive = ACTIVE_STATUSES.has(b.status) ? 1 : 0
if (aActive !== bActive) return bActive - aActive
return b.updatedAt - a.updatedAt
})
)
export const subscribeBackgroundTasks = (listener: BackgroundTaskListener): (() => void) => {
listeners.add(listener)
listener(getBackgroundTaskSnapshot())
return () => {
listeners.delete(listener)
}
}
export const registerBackgroundTask = (input: BackgroundTaskInput): string => {
const now = Date.now()
const taskId = buildTaskId()
tasks.set(taskId, {
id: taskId,
sourcePage: input.sourcePage,
title: input.title,
detail: input.detail,
progressText: input.progressText,
cancelable: input.cancelable !== false,
cancelRequested: false,
status: 'running',
startedAt: now,
updatedAt: now
})
if (input.onCancel) {
cancelHandlers.set(taskId, input.onCancel)
}
pruneSettledTasks()
notifyListeners()
return taskId
}
export const updateBackgroundTask = (taskId: string, patch: BackgroundTaskUpdate): void => {
const existing = tasks.get(taskId)
if (!existing) return
const nextStatus = patch.status || existing.status
const nextUpdatedAt = Date.now()
tasks.set(taskId, {
...existing,
...patch,
status: nextStatus,
updatedAt: nextUpdatedAt,
finishedAt: ACTIVE_STATUSES.has(nextStatus) ? undefined : (existing.finishedAt || nextUpdatedAt)
})
pruneSettledTasks()
notifyListeners()
}
export const finishBackgroundTask = (
taskId: string,
status: Extract<BackgroundTaskStatus, 'completed' | 'failed' | 'canceled'>,
patch?: Omit<BackgroundTaskUpdate, 'status'>
): void => {
const existing = tasks.get(taskId)
if (!existing) return
const now = Date.now()
tasks.set(taskId, {
...existing,
...patch,
status,
updatedAt: now,
finishedAt: now,
cancelRequested: status === 'canceled' ? true : existing.cancelRequested
})
cancelHandlers.delete(taskId)
pruneSettledTasks()
notifyListeners()
}
export const requestCancelBackgroundTask = (taskId: string): boolean => {
const existing = tasks.get(taskId)
if (!existing || !existing.cancelable || !ACTIVE_STATUSES.has(existing.status)) return false
tasks.set(taskId, {
...existing,
status: 'cancel_requested',
cancelRequested: true,
detail: existing.detail || '停止请求已发出,当前查询完成后会结束后续加载',
updatedAt: Date.now()
})
const cancelHandler = cancelHandlers.get(taskId)
if (cancelHandler) {
void Promise.resolve(cancelHandler()).catch(() => {})
}
notifyListeners()
return true
}
export const requestCancelBackgroundTasks = (predicate: (task: BackgroundTaskRecord) => boolean): number => {
let canceledCount = 0
for (const task of tasks.values()) {
if (!predicate(task)) continue
if (requestCancelBackgroundTask(task.id)) {
canceledCount += 1
}
}
return canceledCount
}
export const isBackgroundTaskCancelRequested = (taskId: string): boolean => {
const task = tasks.get(taskId)
return Boolean(task?.cancelRequested)
}

View File

@@ -0,0 +1,9 @@
// 数据收集服务前端接口
export async function initCloudControl() {
return window.electronAPI.cloud.init()
}
export function recordPage(pageName: string) {
window.electronAPI.cloud.recordPage(pageName)
}

View File

@@ -1,5 +1,6 @@
// 配置服务 - 封装 Electron Store
import { config } from './ipc'
import type { ExportDefaultDateRangeConfig } from '../utils/exportDateRange'
// 配置键名
export const CONFIG_KEYS = {
@@ -26,12 +27,27 @@ export const CONFIG_KEYS = {
AUTO_TRANSCRIBE_VOICE: 'autoTranscribeVoice',
TRANSCRIBE_LANGUAGES: 'transcribeLanguages',
EXPORT_DEFAULT_FORMAT: 'exportDefaultFormat',
EXPORT_DEFAULT_AVATARS: 'exportDefaultAvatars',
EXPORT_DEFAULT_DATE_RANGE: 'exportDefaultDateRange',
EXPORT_DEFAULT_MEDIA: 'exportDefaultMedia',
EXPORT_DEFAULT_VOICE_AS_TEXT: 'exportDefaultVoiceAsText',
EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns',
EXPORT_DEFAULT_TXT_COLUMNS: 'exportDefaultTxtColumns',
EXPORT_DEFAULT_CONCURRENCY: 'exportDefaultConcurrency',
EXPORT_WRITE_LAYOUT: 'exportWriteLayout',
EXPORT_SESSION_NAME_PREFIX_ENABLED: 'exportSessionNamePrefixEnabled',
EXPORT_LAST_SESSION_RUN_MAP: 'exportLastSessionRunMap',
EXPORT_LAST_CONTENT_RUN_MAP: 'exportLastContentRunMap',
EXPORT_SESSION_RECORD_MAP: 'exportSessionRecordMap',
EXPORT_LAST_SNS_POST_COUNT: 'exportLastSnsPostCount',
EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP: 'exportSessionMessageCountCacheMap',
EXPORT_SESSION_CONTENT_METRIC_CACHE_MAP: 'exportSessionContentMetricCacheMap',
EXPORT_SNS_STATS_CACHE_MAP: 'exportSnsStatsCacheMap',
EXPORT_SNS_USER_POST_COUNTS_CACHE_MAP: 'exportSnsUserPostCountsCacheMap',
SNS_PAGE_CACHE_MAP: 'snsPageCacheMap',
CONTACTS_LOAD_TIMEOUT_MS: 'contactsLoadTimeoutMs',
CONTACTS_LIST_CACHE_MAP: 'contactsListCacheMap',
CONTACTS_AVATAR_CACHE_MAP: 'contactsAvatarCacheMap',
// 安全
AUTH_ENABLED: 'authEnabled',
@@ -48,7 +64,11 @@ export const CONFIG_KEYS = {
NOTIFICATION_FILTER_LIST: 'notificationFilterList',
// 词云
WORD_CLOUD_EXCLUDE_WORDS: 'wordCloudExcludeWords'
WORD_CLOUD_EXCLUDE_WORDS: 'wordCloudExcludeWords',
// 数据收集
ANALYTICS_CONSENT: 'analyticsConsent',
ANALYTICS_DENY_COUNT: 'analyticsDenyCount'
} as const
export interface WxidConfig {
@@ -58,6 +78,20 @@ export interface WxidConfig {
updatedAt?: number
}
export interface ExportDefaultMediaConfig {
images: boolean
videos: boolean
voices: boolean
emojis: boolean
}
const DEFAULT_EXPORT_MEDIA_CONFIG: ExportDefaultMediaConfig = {
images: true,
videos: true,
voices: true,
emojis: true
}
// 获取解密密钥
export async function getDecryptKey(): Promise<string | null> {
const value = await config.get(CONFIG_KEYS.DECRYPT_KEY)
@@ -316,27 +350,64 @@ export async function setExportDefaultFormat(format: string): Promise<void> {
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_FORMAT, format)
}
// 获取导出默认时间范围
export async function getExportDefaultDateRange(): Promise<string | null> {
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_DATE_RANGE)
return (value as string) || null
}
// 设置导出默认时间范围
export async function setExportDefaultDateRange(range: string): Promise<void> {
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_DATE_RANGE, range)
}
// 获取导出默认媒体设置
export async function getExportDefaultMedia(): Promise<boolean | null> {
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_MEDIA)
// 获取导出默认头像设置
export async function getExportDefaultAvatars(): Promise<boolean | null> {
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_AVATARS)
if (typeof value === 'boolean') return value
return null
}
// 设置导出默认头像设置
export async function setExportDefaultAvatars(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_AVATARS, enabled)
}
// 获取导出默认时间范围
export async function getExportDefaultDateRange(): Promise<ExportDefaultDateRangeConfig | string | null> {
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_DATE_RANGE)
if (typeof value === 'string') return value
if (value && typeof value === 'object') {
return value as ExportDefaultDateRangeConfig
}
return null
}
// 设置导出默认时间范围
export async function setExportDefaultDateRange(range: ExportDefaultDateRangeConfig | string): Promise<void> {
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_DATE_RANGE, range)
}
// 获取导出默认媒体设置
export async function getExportDefaultMedia(): Promise<ExportDefaultMediaConfig | null> {
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_MEDIA)
if (typeof value === 'boolean') {
return {
images: value,
videos: value,
voices: value,
emojis: value
}
}
if (value && typeof value === 'object') {
const raw = value as Partial<Record<keyof ExportDefaultMediaConfig, unknown>>
return {
images: typeof raw.images === 'boolean' ? raw.images : DEFAULT_EXPORT_MEDIA_CONFIG.images,
videos: typeof raw.videos === 'boolean' ? raw.videos : DEFAULT_EXPORT_MEDIA_CONFIG.videos,
voices: typeof raw.voices === 'boolean' ? raw.voices : DEFAULT_EXPORT_MEDIA_CONFIG.voices,
emojis: typeof raw.emojis === 'boolean' ? raw.emojis : DEFAULT_EXPORT_MEDIA_CONFIG.emojis
}
}
return null
}
// 设置导出默认媒体设置
export async function setExportDefaultMedia(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_MEDIA, enabled)
export async function setExportDefaultMedia(media: ExportDefaultMediaConfig): Promise<void> {
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_MEDIA, {
images: media.images,
videos: media.videos,
voices: media.voices,
emojis: media.emojis
})
}
// 获取导出默认语音转文字
@@ -386,6 +457,651 @@ export async function setExportDefaultConcurrency(concurrency: number): Promise<
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_CONCURRENCY, concurrency)
}
export type ExportWriteLayout = 'A' | 'B' | 'C'
export async function getExportWriteLayout(): Promise<ExportWriteLayout> {
const value = await config.get(CONFIG_KEYS.EXPORT_WRITE_LAYOUT)
if (value === 'A' || value === 'B' || value === 'C') return value
return 'B'
}
export async function setExportWriteLayout(layout: ExportWriteLayout): Promise<void> {
await config.set(CONFIG_KEYS.EXPORT_WRITE_LAYOUT, layout)
}
export async function getExportSessionNamePrefixEnabled(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.EXPORT_SESSION_NAME_PREFIX_ENABLED)
if (typeof value === 'boolean') return value
return true
}
export async function setExportSessionNamePrefixEnabled(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.EXPORT_SESSION_NAME_PREFIX_ENABLED, enabled)
}
export async function getExportLastSessionRunMap(): Promise<Record<string, number>> {
const value = await config.get(CONFIG_KEYS.EXPORT_LAST_SESSION_RUN_MAP)
if (!value || typeof value !== 'object') return {}
const entries = Object.entries(value as Record<string, unknown>)
const map: Record<string, number> = {}
for (const [sessionId, raw] of entries) {
if (typeof raw === 'number' && Number.isFinite(raw)) {
map[sessionId] = raw
}
}
return map
}
export async function setExportLastSessionRunMap(map: Record<string, number>): Promise<void> {
await config.set(CONFIG_KEYS.EXPORT_LAST_SESSION_RUN_MAP, map)
}
export async function getExportLastContentRunMap(): Promise<Record<string, number>> {
const value = await config.get(CONFIG_KEYS.EXPORT_LAST_CONTENT_RUN_MAP)
if (!value || typeof value !== 'object') return {}
const entries = Object.entries(value as Record<string, unknown>)
const map: Record<string, number> = {}
for (const [key, raw] of entries) {
if (typeof raw === 'number' && Number.isFinite(raw)) {
map[key] = raw
}
}
return map
}
export async function setExportLastContentRunMap(map: Record<string, number>): Promise<void> {
await config.set(CONFIG_KEYS.EXPORT_LAST_CONTENT_RUN_MAP, map)
}
export interface ExportSessionRecordEntry {
exportTime: number
content: string
outputDir: string
}
export async function getExportSessionRecordMap(): Promise<Record<string, ExportSessionRecordEntry[]>> {
const value = await config.get(CONFIG_KEYS.EXPORT_SESSION_RECORD_MAP)
if (!value || typeof value !== 'object') return {}
const map: Record<string, ExportSessionRecordEntry[]> = {}
const entries = Object.entries(value as Record<string, unknown>)
for (const [sessionId, rawList] of entries) {
if (!Array.isArray(rawList)) continue
const normalizedList: ExportSessionRecordEntry[] = []
for (const rawItem of rawList) {
if (!rawItem || typeof rawItem !== 'object') continue
const exportTime = Number((rawItem as Record<string, unknown>).exportTime)
const content = String((rawItem as Record<string, unknown>).content || '').trim()
const outputDir = String((rawItem as Record<string, unknown>).outputDir || '').trim()
if (!Number.isFinite(exportTime) || exportTime <= 0) continue
if (!content || !outputDir) continue
normalizedList.push({
exportTime: Math.floor(exportTime),
content,
outputDir
})
}
if (normalizedList.length > 0) {
map[sessionId] = normalizedList
}
}
return map
}
export async function setExportSessionRecordMap(map: Record<string, ExportSessionRecordEntry[]>): Promise<void> {
await config.set(CONFIG_KEYS.EXPORT_SESSION_RECORD_MAP, map)
}
export async function getExportLastSnsPostCount(): Promise<number> {
const value = await config.get(CONFIG_KEYS.EXPORT_LAST_SNS_POST_COUNT)
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {
return Math.floor(value)
}
return 0
}
export async function setExportLastSnsPostCount(count: number): Promise<void> {
const normalized = Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0
await config.set(CONFIG_KEYS.EXPORT_LAST_SNS_POST_COUNT, normalized)
}
export interface ExportSessionMessageCountCacheItem {
updatedAt: number
counts: Record<string, number>
}
export interface ExportSessionContentMetricCacheEntry {
totalMessages?: number
voiceMessages?: number
imageMessages?: number
videoMessages?: number
emojiMessages?: number
}
export interface ExportSessionContentMetricCacheItem {
updatedAt: number
metrics: Record<string, ExportSessionContentMetricCacheEntry>
}
export interface ExportSnsStatsCacheItem {
updatedAt: number
totalPosts: number
totalFriends: number
}
export interface ExportSnsUserPostCountsCacheItem {
updatedAt: number
counts: Record<string, number>
}
export interface SnsPageOverviewCache {
totalPosts: number
totalFriends: number
myPosts: number | null
earliestTime: number | null
latestTime: number | null
}
export interface SnsPageCacheItem {
updatedAt: number
overviewStats: SnsPageOverviewCache
posts: unknown[]
}
export interface ContactsListCacheContact {
username: string
displayName: string
remark?: string
nickname?: string
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
}
export interface ContactsListCacheItem {
updatedAt: number
contacts: ContactsListCacheContact[]
}
export interface ContactsAvatarCacheEntry {
avatarUrl: string
updatedAt: number
checkedAt: number
}
export interface ContactsAvatarCacheItem {
updatedAt: number
avatars: Record<string, ContactsAvatarCacheEntry>
}
export async function getExportSessionMessageCountCache(scopeKey: string): Promise<ExportSessionMessageCountCacheItem | null> {
if (!scopeKey) return null
const value = await config.get(CONFIG_KEYS.EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP)
if (!value || typeof value !== 'object') return null
const rawMap = value as Record<string, unknown>
const rawItem = rawMap[scopeKey]
if (!rawItem || typeof rawItem !== 'object') return null
const rawUpdatedAt = (rawItem as Record<string, unknown>).updatedAt
const rawCounts = (rawItem as Record<string, unknown>).counts
if (!rawCounts || typeof rawCounts !== 'object') return null
const counts: Record<string, number> = {}
for (const [sessionId, countRaw] of Object.entries(rawCounts as Record<string, unknown>)) {
if (typeof countRaw === 'number' && Number.isFinite(countRaw) && countRaw >= 0) {
counts[sessionId] = Math.floor(countRaw)
}
}
return {
updatedAt: typeof rawUpdatedAt === 'number' && Number.isFinite(rawUpdatedAt) ? rawUpdatedAt : 0,
counts
}
}
export async function setExportSessionMessageCountCache(scopeKey: string, counts: Record<string, number>): Promise<void> {
if (!scopeKey) return
const current = await config.get(CONFIG_KEYS.EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP)
const map = current && typeof current === 'object'
? { ...(current as Record<string, unknown>) }
: {}
const normalized: Record<string, number> = {}
for (const [sessionId, countRaw] of Object.entries(counts || {})) {
if (typeof countRaw === 'number' && Number.isFinite(countRaw) && countRaw >= 0) {
normalized[sessionId] = Math.floor(countRaw)
}
}
map[scopeKey] = {
updatedAt: Date.now(),
counts: normalized
}
await config.set(CONFIG_KEYS.EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP, map)
}
export async function getExportSessionContentMetricCache(scopeKey: string): Promise<ExportSessionContentMetricCacheItem | null> {
if (!scopeKey) return null
const value = await config.get(CONFIG_KEYS.EXPORT_SESSION_CONTENT_METRIC_CACHE_MAP)
if (!value || typeof value !== 'object') return null
const rawMap = value as Record<string, unknown>
const rawItem = rawMap[scopeKey]
if (!rawItem || typeof rawItem !== 'object') return null
const rawUpdatedAt = (rawItem as Record<string, unknown>).updatedAt
const rawMetrics = (rawItem as Record<string, unknown>).metrics
if (!rawMetrics || typeof rawMetrics !== 'object') return null
const metrics: Record<string, ExportSessionContentMetricCacheEntry> = {}
for (const [sessionId, rawMetric] of Object.entries(rawMetrics as Record<string, unknown>)) {
if (!rawMetric || typeof rawMetric !== 'object') continue
const source = rawMetric as Record<string, unknown>
const metric: ExportSessionContentMetricCacheEntry = {}
if (typeof source.totalMessages === 'number' && Number.isFinite(source.totalMessages) && source.totalMessages >= 0) {
metric.totalMessages = Math.floor(source.totalMessages)
}
if (typeof source.voiceMessages === 'number' && Number.isFinite(source.voiceMessages) && source.voiceMessages >= 0) {
metric.voiceMessages = Math.floor(source.voiceMessages)
}
if (typeof source.imageMessages === 'number' && Number.isFinite(source.imageMessages) && source.imageMessages >= 0) {
metric.imageMessages = Math.floor(source.imageMessages)
}
if (typeof source.videoMessages === 'number' && Number.isFinite(source.videoMessages) && source.videoMessages >= 0) {
metric.videoMessages = Math.floor(source.videoMessages)
}
if (typeof source.emojiMessages === 'number' && Number.isFinite(source.emojiMessages) && source.emojiMessages >= 0) {
metric.emojiMessages = Math.floor(source.emojiMessages)
}
if (Object.keys(metric).length === 0) continue
metrics[sessionId] = metric
}
return {
updatedAt: typeof rawUpdatedAt === 'number' && Number.isFinite(rawUpdatedAt) ? rawUpdatedAt : 0,
metrics
}
}
export async function setExportSessionContentMetricCache(
scopeKey: string,
metrics: Record<string, ExportSessionContentMetricCacheEntry>
): Promise<void> {
if (!scopeKey) return
const current = await config.get(CONFIG_KEYS.EXPORT_SESSION_CONTENT_METRIC_CACHE_MAP)
const map = current && typeof current === 'object'
? { ...(current as Record<string, unknown>) }
: {}
const normalized: Record<string, ExportSessionContentMetricCacheEntry> = {}
for (const [sessionId, rawMetric] of Object.entries(metrics || {})) {
if (!rawMetric || typeof rawMetric !== 'object') continue
const metric: ExportSessionContentMetricCacheEntry = {}
if (typeof rawMetric.totalMessages === 'number' && Number.isFinite(rawMetric.totalMessages) && rawMetric.totalMessages >= 0) {
metric.totalMessages = Math.floor(rawMetric.totalMessages)
}
if (typeof rawMetric.voiceMessages === 'number' && Number.isFinite(rawMetric.voiceMessages) && rawMetric.voiceMessages >= 0) {
metric.voiceMessages = Math.floor(rawMetric.voiceMessages)
}
if (typeof rawMetric.imageMessages === 'number' && Number.isFinite(rawMetric.imageMessages) && rawMetric.imageMessages >= 0) {
metric.imageMessages = Math.floor(rawMetric.imageMessages)
}
if (typeof rawMetric.videoMessages === 'number' && Number.isFinite(rawMetric.videoMessages) && rawMetric.videoMessages >= 0) {
metric.videoMessages = Math.floor(rawMetric.videoMessages)
}
if (typeof rawMetric.emojiMessages === 'number' && Number.isFinite(rawMetric.emojiMessages) && rawMetric.emojiMessages >= 0) {
metric.emojiMessages = Math.floor(rawMetric.emojiMessages)
}
if (Object.keys(metric).length === 0) continue
normalized[sessionId] = metric
}
map[scopeKey] = {
updatedAt: Date.now(),
metrics: normalized
}
await config.set(CONFIG_KEYS.EXPORT_SESSION_CONTENT_METRIC_CACHE_MAP, map)
}
export async function getExportSnsStatsCache(scopeKey: string): Promise<ExportSnsStatsCacheItem | null> {
if (!scopeKey) return null
const value = await config.get(CONFIG_KEYS.EXPORT_SNS_STATS_CACHE_MAP)
if (!value || typeof value !== 'object') return null
const rawMap = value as Record<string, unknown>
const rawItem = rawMap[scopeKey]
if (!rawItem || typeof rawItem !== 'object') return null
const raw = rawItem as Record<string, unknown>
const totalPosts = typeof raw.totalPosts === 'number' && Number.isFinite(raw.totalPosts) && raw.totalPosts >= 0
? Math.floor(raw.totalPosts)
: 0
const totalFriends = typeof raw.totalFriends === 'number' && Number.isFinite(raw.totalFriends) && raw.totalFriends >= 0
? Math.floor(raw.totalFriends)
: 0
const updatedAt = typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt)
? raw.updatedAt
: 0
return { updatedAt, totalPosts, totalFriends }
}
export async function setExportSnsStatsCache(
scopeKey: string,
stats: { totalPosts: number; totalFriends: number }
): Promise<void> {
if (!scopeKey) return
const current = await config.get(CONFIG_KEYS.EXPORT_SNS_STATS_CACHE_MAP)
const map = current && typeof current === 'object'
? { ...(current as Record<string, unknown>) }
: {}
map[scopeKey] = {
updatedAt: Date.now(),
totalPosts: Number.isFinite(stats.totalPosts) ? Math.max(0, Math.floor(stats.totalPosts)) : 0,
totalFriends: Number.isFinite(stats.totalFriends) ? Math.max(0, Math.floor(stats.totalFriends)) : 0
}
await config.set(CONFIG_KEYS.EXPORT_SNS_STATS_CACHE_MAP, map)
}
export async function getExportSnsUserPostCountsCache(scopeKey: string): Promise<ExportSnsUserPostCountsCacheItem | null> {
if (!scopeKey) return null
const value = await config.get(CONFIG_KEYS.EXPORT_SNS_USER_POST_COUNTS_CACHE_MAP)
if (!value || typeof value !== 'object') return null
const rawMap = value as Record<string, unknown>
const rawItem = rawMap[scopeKey]
if (!rawItem || typeof rawItem !== 'object') return null
const raw = rawItem as Record<string, unknown>
const rawCounts = raw.counts
if (!rawCounts || typeof rawCounts !== 'object') return null
const counts: Record<string, number> = {}
for (const [rawUsername, rawCount] of Object.entries(rawCounts as Record<string, unknown>)) {
const username = String(rawUsername || '').trim()
if (!username) continue
const valueNum = Number(rawCount)
counts[username] = Number.isFinite(valueNum) ? Math.max(0, Math.floor(valueNum)) : 0
}
const updatedAt = typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt)
? raw.updatedAt
: 0
return { updatedAt, counts }
}
export async function setExportSnsUserPostCountsCache(
scopeKey: string,
counts: Record<string, number>
): Promise<void> {
if (!scopeKey) return
const current = await config.get(CONFIG_KEYS.EXPORT_SNS_USER_POST_COUNTS_CACHE_MAP)
const map = current && typeof current === 'object'
? { ...(current as Record<string, unknown>) }
: {}
const normalized: Record<string, number> = {}
for (const [rawUsername, rawCount] of Object.entries(counts || {})) {
const username = String(rawUsername || '').trim()
if (!username) continue
const valueNum = Number(rawCount)
normalized[username] = Number.isFinite(valueNum) ? Math.max(0, Math.floor(valueNum)) : 0
}
map[scopeKey] = {
updatedAt: Date.now(),
counts: normalized
}
await config.set(CONFIG_KEYS.EXPORT_SNS_USER_POST_COUNTS_CACHE_MAP, map)
}
export async function getSnsPageCache(scopeKey: string): Promise<SnsPageCacheItem | null> {
if (!scopeKey) return null
const value = await config.get(CONFIG_KEYS.SNS_PAGE_CACHE_MAP)
if (!value || typeof value !== 'object') return null
const rawMap = value as Record<string, unknown>
const rawItem = rawMap[scopeKey]
if (!rawItem || typeof rawItem !== 'object') return null
const raw = rawItem as Record<string, unknown>
const rawOverview = raw.overviewStats
const rawPosts = raw.posts
if (!rawOverview || typeof rawOverview !== 'object' || !Array.isArray(rawPosts)) return null
const overviewObj = rawOverview as Record<string, unknown>
const normalizeNumber = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? Math.floor(v) : 0)
const normalizeNullableTimestamp = (v: unknown) => {
if (v === null || v === undefined) return null
if (typeof v === 'number' && Number.isFinite(v) && v > 0) return Math.floor(v)
return null
}
const normalizeNullableCount = (v: unknown) => {
if (v === null || v === undefined) return null
if (typeof v === 'number' && Number.isFinite(v) && v >= 0) return Math.floor(v)
return null
}
return {
updatedAt: typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt) ? raw.updatedAt : 0,
overviewStats: {
totalPosts: Math.max(0, normalizeNumber(overviewObj.totalPosts)),
totalFriends: Math.max(0, normalizeNumber(overviewObj.totalFriends)),
myPosts: normalizeNullableCount(overviewObj.myPosts),
earliestTime: normalizeNullableTimestamp(overviewObj.earliestTime),
latestTime: normalizeNullableTimestamp(overviewObj.latestTime)
},
posts: rawPosts
}
}
export async function setSnsPageCache(
scopeKey: string,
payload: { overviewStats: SnsPageOverviewCache; posts: unknown[] }
): Promise<void> {
if (!scopeKey) return
const current = await config.get(CONFIG_KEYS.SNS_PAGE_CACHE_MAP)
const map = current && typeof current === 'object'
? { ...(current as Record<string, unknown>) }
: {}
const normalizeNumber = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? Math.max(0, Math.floor(v)) : 0)
const normalizeNullableTimestamp = (v: unknown) => {
if (v === null || v === undefined) return null
if (typeof v === 'number' && Number.isFinite(v) && v > 0) return Math.floor(v)
return null
}
const normalizeNullableCount = (v: unknown) => {
if (v === null || v === undefined) return null
if (typeof v === 'number' && Number.isFinite(v) && v >= 0) return Math.floor(v)
return null
}
map[scopeKey] = {
updatedAt: Date.now(),
overviewStats: {
totalPosts: normalizeNumber(payload?.overviewStats?.totalPosts),
totalFriends: normalizeNumber(payload?.overviewStats?.totalFriends),
myPosts: normalizeNullableCount(payload?.overviewStats?.myPosts),
earliestTime: normalizeNullableTimestamp(payload?.overviewStats?.earliestTime),
latestTime: normalizeNullableTimestamp(payload?.overviewStats?.latestTime)
},
posts: Array.isArray(payload?.posts) ? payload.posts : []
}
await config.set(CONFIG_KEYS.SNS_PAGE_CACHE_MAP, map)
}
// 获取通讯录加载超时阈值(毫秒)
export async function getContactsLoadTimeoutMs(): Promise<number> {
const value = await config.get(CONFIG_KEYS.CONTACTS_LOAD_TIMEOUT_MS)
if (typeof value === 'number' && Number.isFinite(value) && value >= 1000 && value <= 60000) {
return Math.floor(value)
}
return 3000
}
// 设置通讯录加载超时阈值(毫秒)
export async function setContactsLoadTimeoutMs(timeoutMs: number): Promise<void> {
const normalized = Number.isFinite(timeoutMs)
? Math.min(60000, Math.max(1000, Math.floor(timeoutMs)))
: 3000
await config.set(CONFIG_KEYS.CONTACTS_LOAD_TIMEOUT_MS, normalized)
}
export async function getContactsListCache(scopeKey: string): Promise<ContactsListCacheItem | null> {
if (!scopeKey) return null
const value = await config.get(CONFIG_KEYS.CONTACTS_LIST_CACHE_MAP)
if (!value || typeof value !== 'object') return null
const rawMap = value as Record<string, unknown>
const rawItem = rawMap[scopeKey]
if (!rawItem || typeof rawItem !== 'object') return null
const rawUpdatedAt = (rawItem as Record<string, unknown>).updatedAt
const rawContacts = (rawItem as Record<string, unknown>).contacts
if (!Array.isArray(rawContacts)) return null
const contacts: ContactsListCacheContact[] = []
for (const raw of rawContacts) {
if (!raw || typeof raw !== 'object') continue
const item = raw as Record<string, unknown>
const username = typeof item.username === 'string' ? item.username.trim() : ''
if (!username) continue
const displayName = typeof item.displayName === 'string' ? item.displayName : username
const type = typeof item.type === 'string' ? item.type : 'other'
contacts.push({
username,
displayName,
remark: typeof item.remark === 'string' ? item.remark : undefined,
nickname: typeof item.nickname === 'string' ? item.nickname : undefined,
type: (type === 'friend' || type === 'group' || type === 'official' || type === 'former_friend' || type === 'other')
? type
: 'other'
})
}
return {
updatedAt: typeof rawUpdatedAt === 'number' && Number.isFinite(rawUpdatedAt) ? rawUpdatedAt : 0,
contacts
}
}
export async function setContactsListCache(scopeKey: string, contacts: ContactsListCacheContact[]): Promise<void> {
if (!scopeKey) return
const current = await config.get(CONFIG_KEYS.CONTACTS_LIST_CACHE_MAP)
const map = current && typeof current === 'object'
? { ...(current as Record<string, unknown>) }
: {}
const normalized: ContactsListCacheContact[] = []
for (const contact of contacts || []) {
const username = String(contact?.username || '').trim()
if (!username) continue
const displayName = String(contact?.displayName || username)
const type = contact?.type || 'other'
if (type !== 'friend' && type !== 'group' && type !== 'official' && type !== 'former_friend' && type !== 'other') {
continue
}
normalized.push({
username,
displayName,
remark: contact?.remark ? String(contact.remark) : undefined,
nickname: contact?.nickname ? String(contact.nickname) : undefined,
type
})
}
map[scopeKey] = {
updatedAt: Date.now(),
contacts: normalized
}
await config.set(CONFIG_KEYS.CONTACTS_LIST_CACHE_MAP, map)
}
export async function getContactsAvatarCache(scopeKey: string): Promise<ContactsAvatarCacheItem | null> {
if (!scopeKey) return null
const value = await config.get(CONFIG_KEYS.CONTACTS_AVATAR_CACHE_MAP)
if (!value || typeof value !== 'object') return null
const rawMap = value as Record<string, unknown>
const rawItem = rawMap[scopeKey]
if (!rawItem || typeof rawItem !== 'object') return null
const rawUpdatedAt = (rawItem as Record<string, unknown>).updatedAt
const rawAvatars = (rawItem as Record<string, unknown>).avatars
if (!rawAvatars || typeof rawAvatars !== 'object') return null
const avatars: Record<string, ContactsAvatarCacheEntry> = {}
for (const [rawUsername, rawEntry] of Object.entries(rawAvatars as Record<string, unknown>)) {
const username = rawUsername.trim()
if (!username) continue
if (typeof rawEntry === 'string') {
const avatarUrl = rawEntry.trim()
if (!avatarUrl) continue
avatars[username] = {
avatarUrl,
updatedAt: typeof rawUpdatedAt === 'number' && Number.isFinite(rawUpdatedAt) ? rawUpdatedAt : 0,
checkedAt: typeof rawUpdatedAt === 'number' && Number.isFinite(rawUpdatedAt) ? rawUpdatedAt : 0
}
continue
}
if (!rawEntry || typeof rawEntry !== 'object') continue
const entry = rawEntry as Record<string, unknown>
const avatarUrl = typeof entry.avatarUrl === 'string' ? entry.avatarUrl.trim() : ''
if (!avatarUrl) continue
const updatedAt = typeof entry.updatedAt === 'number' && Number.isFinite(entry.updatedAt)
? entry.updatedAt
: 0
const checkedAt = typeof entry.checkedAt === 'number' && Number.isFinite(entry.checkedAt)
? entry.checkedAt
: updatedAt
avatars[username] = {
avatarUrl,
updatedAt,
checkedAt
}
}
return {
updatedAt: typeof rawUpdatedAt === 'number' && Number.isFinite(rawUpdatedAt) ? rawUpdatedAt : 0,
avatars
}
}
export async function setContactsAvatarCache(
scopeKey: string,
avatars: Record<string, ContactsAvatarCacheEntry>
): Promise<void> {
if (!scopeKey) return
const current = await config.get(CONFIG_KEYS.CONTACTS_AVATAR_CACHE_MAP)
const map = current && typeof current === 'object'
? { ...(current as Record<string, unknown>) }
: {}
const normalized: Record<string, ContactsAvatarCacheEntry> = {}
for (const [rawUsername, rawEntry] of Object.entries(avatars || {})) {
const username = String(rawUsername || '').trim()
if (!username || !rawEntry || typeof rawEntry !== 'object') continue
const avatarUrl = String(rawEntry.avatarUrl || '').trim()
if (!avatarUrl) continue
const updatedAt = Number.isFinite(rawEntry.updatedAt)
? Math.max(0, Math.floor(rawEntry.updatedAt))
: Date.now()
const checkedAt = Number.isFinite(rawEntry.checkedAt)
? Math.max(0, Math.floor(rawEntry.checkedAt))
: updatedAt
normalized[username] = {
avatarUrl,
updatedAt,
checkedAt
}
}
map[scopeKey] = {
updatedAt: Date.now(),
avatars: normalized
}
await config.set(CONFIG_KEYS.CONTACTS_AVATAR_CACHE_MAP, map)
}
// === 安全相关 ===
export async function getAuthEnabled(): Promise<boolean> {
@@ -482,3 +1198,26 @@ export async function getWordCloudExcludeWords(): Promise<string[]> {
export async function setWordCloudExcludeWords(words: string[]): Promise<void> {
await config.set(CONFIG_KEYS.WORD_CLOUD_EXCLUDE_WORDS, words)
}
// 获取数据收集同意状态
export async function getAnalyticsConsent(): Promise<boolean | null> {
const value = await config.get(CONFIG_KEYS.ANALYTICS_CONSENT)
if (typeof value === 'boolean') return value
return null
}
// 设置数据收集同意状态
export async function setAnalyticsConsent(consent: boolean): Promise<void> {
await config.set(CONFIG_KEYS.ANALYTICS_CONSENT, consent)
}
// 获取数据收集拒绝次数
export async function getAnalyticsDenyCount(): Promise<number> {
const value = await config.get(CONFIG_KEYS.ANALYTICS_DENY_COUNT)
return typeof value === 'number' ? value : 0
}
// 设置数据收集拒绝次数
export async function setAnalyticsDenyCount(count: number): Promise<void> {
await config.set(CONFIG_KEYS.ANALYTICS_DENY_COUNT, count)
}

View File

@@ -0,0 +1,85 @@
export interface OpenSingleExportPayload {
sessionId: string
sessionName?: string
requestId?: string
}
export interface ExportSessionStatusPayload {
inProgressSessionIds: string[]
activeTaskCount: number
}
export interface SingleExportDialogStatusPayload {
requestId: string
status: 'initializing' | 'opened' | 'failed'
message?: string
}
const OPEN_SINGLE_EXPORT_EVENT = 'weflow:open-single-export'
const EXPORT_SESSION_STATUS_EVENT = 'weflow:export-session-status'
const EXPORT_SESSION_STATUS_REQUEST_EVENT = 'weflow:export-session-status-request'
const SINGLE_EXPORT_DIALOG_STATUS_EVENT = 'weflow:single-export-dialog-status'
export const emitOpenSingleExport = (payload: OpenSingleExportPayload) => {
window.dispatchEvent(new CustomEvent<OpenSingleExportPayload>(OPEN_SINGLE_EXPORT_EVENT, {
detail: payload
}))
}
export const onOpenSingleExport = (
listener: (payload: OpenSingleExportPayload) => void
): (() => void) => {
const handler = (event: Event) => {
const customEvent = event as CustomEvent<OpenSingleExportPayload>
listener(customEvent.detail)
}
window.addEventListener(OPEN_SINGLE_EXPORT_EVENT, handler as EventListener)
return () => window.removeEventListener(OPEN_SINGLE_EXPORT_EVENT, handler as EventListener)
}
export const emitExportSessionStatus = (payload: ExportSessionStatusPayload) => {
window.dispatchEvent(new CustomEvent<ExportSessionStatusPayload>(EXPORT_SESSION_STATUS_EVENT, {
detail: payload
}))
}
export const onExportSessionStatus = (
listener: (payload: ExportSessionStatusPayload) => void
): (() => void) => {
const handler = (event: Event) => {
const customEvent = event as CustomEvent<ExportSessionStatusPayload>
listener(customEvent.detail)
}
window.addEventListener(EXPORT_SESSION_STATUS_EVENT, handler as EventListener)
return () => window.removeEventListener(EXPORT_SESSION_STATUS_EVENT, handler as EventListener)
}
export const requestExportSessionStatus = () => {
window.dispatchEvent(new CustomEvent(EXPORT_SESSION_STATUS_REQUEST_EVENT))
}
export const onExportSessionStatusRequest = (listener: () => void): (() => void) => {
const handler = () => listener()
window.addEventListener(EXPORT_SESSION_STATUS_REQUEST_EVENT, handler)
return () => window.removeEventListener(EXPORT_SESSION_STATUS_REQUEST_EVENT, handler)
}
export const emitSingleExportDialogStatus = (payload: SingleExportDialogStatusPayload) => {
window.dispatchEvent(new CustomEvent<SingleExportDialogStatusPayload>(SINGLE_EXPORT_DIALOG_STATUS_EVENT, {
detail: payload
}))
}
export const onSingleExportDialogStatus = (
listener: (payload: SingleExportDialogStatusPayload) => void
): (() => void) => {
const handler = (event: Event) => {
const customEvent = event as CustomEvent<SingleExportDialogStatusPayload>
listener(customEvent.detail)
}
window.addEventListener(SINGLE_EXPORT_DIALOG_STATUS_EVENT, handler as EventListener)
return () => window.removeEventListener(SINGLE_EXPORT_DIALOG_STATUS_EVENT, handler as EventListener)
}

View File

@@ -32,7 +32,7 @@ export interface ChatState {
setConnectionError: (error: string | null) => void
setSessions: (sessions: ChatSession[]) => void
setFilteredSessions: (sessions: ChatSession[]) => void
setCurrentSession: (sessionId: string | null) => void
setCurrentSession: (sessionId: string | null, options?: { preserveMessages?: boolean }) => void
setLoadingSessions: (loading: boolean) => void
setMessages: (messages: Message[]) => void
appendMessages: (messages: Message[], prepend?: boolean) => void
@@ -69,12 +69,12 @@ export const useChatStore = create<ChatState>((set, get) => ({
setSessions: (sessions) => set({ sessions, filteredSessions: sessions }),
setFilteredSessions: (sessions) => set({ filteredSessions: sessions }),
setCurrentSession: (sessionId) => set({
setCurrentSession: (sessionId, options) => set((state) => ({
currentSessionId: sessionId,
messages: [],
messages: options?.preserveMessages ? state.messages : [],
hasMoreMessages: true,
hasMoreLater: false
}),
})),
setLoadingSessions: (loading) => set({ isLoadingSessions: loading }),

View File

@@ -0,0 +1,115 @@
import { create } from 'zustand'
import type { ContactInfo } from '../types/models'
export interface ContactTypeTabCounts {
private: number
group: number
official: number
former_friend: number
}
export interface ContactTypeCardCounts {
friends: number
groups: number
officials: number
deletedFriends: number
}
const emptyTabCounts: ContactTypeTabCounts = {
private: 0,
group: 0,
official: 0,
former_friend: 0
}
let inflightPromise: Promise<ContactTypeTabCounts> | null = null
const normalizeCounts = (counts?: Partial<ContactTypeTabCounts> | null): ContactTypeTabCounts => {
return {
private: Number.isFinite(counts?.private) ? Math.max(0, Math.floor(Number(counts?.private))) : 0,
group: Number.isFinite(counts?.group) ? Math.max(0, Math.floor(Number(counts?.group))) : 0,
official: Number.isFinite(counts?.official) ? Math.max(0, Math.floor(Number(counts?.official))) : 0,
former_friend: Number.isFinite(counts?.former_friend) ? Math.max(0, Math.floor(Number(counts?.former_friend))) : 0
}
}
export const toContactTypeTabCountsFromContacts = (contacts: ContactInfo[]): ContactTypeTabCounts => {
const next = { ...emptyTabCounts }
for (const contact of contacts || []) {
if (contact.type === 'friend') next.private += 1
if (contact.type === 'group') next.group += 1
if (contact.type === 'official') next.official += 1
if (contact.type === 'former_friend') next.former_friend += 1
}
return next
}
export const toContactTypeCardCounts = (counts: ContactTypeTabCounts): ContactTypeCardCounts => {
return {
friends: counts.private,
groups: counts.group,
officials: counts.official,
deletedFriends: counts.former_friend
}
}
interface ContactTypeCountsState {
tabCounts: ContactTypeTabCounts
isLoading: boolean
isReady: boolean
updatedAt: number
setTabCounts: (counts: ContactTypeTabCounts) => void
syncFromContacts: (contacts: ContactInfo[]) => void
ensureLoaded: (options?: { force?: boolean }) => Promise<ContactTypeTabCounts>
}
export const useContactTypeCountsStore = create<ContactTypeCountsState>((set, get) => ({
tabCounts: { ...emptyTabCounts },
isLoading: false,
isReady: false,
updatedAt: 0,
setTabCounts: (counts) => {
const normalized = normalizeCounts(counts)
set({
tabCounts: normalized,
isReady: true,
updatedAt: Date.now()
})
},
syncFromContacts: (contacts) => {
const fromContacts = toContactTypeTabCountsFromContacts(contacts || [])
get().setTabCounts(fromContacts)
},
ensureLoaded: async (options) => {
if (!options?.force && get().isReady) {
return get().tabCounts
}
if (inflightPromise) {
return inflightPromise
}
set({ isLoading: true })
inflightPromise = (async () => {
try {
const result = await window.electronAPI.chat.getContactTypeCounts()
if (result?.success && result.counts) {
const normalized = normalizeCounts(result.counts)
set({
tabCounts: normalized,
isReady: true,
updatedAt: Date.now()
})
return normalized
}
} catch (error) {
console.error('加载联系人类型计数失败:', error)
}
return get().tabCounts
})().finally(() => {
inflightPromise = null
set({ isLoading: false })
})
return inflightPromise
}
}))

View File

@@ -1,7 +1,7 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export type ThemeId = 'cloud-dancer' | 'corundum-blue' | 'kiwi-green' | 'spicy-red' | 'teal-water' | 'blossom-dream'
export type ThemeId = 'cloud-dancer' | 'corundum-blue' | 'kiwi-green' | 'spicy-red' | 'teal-water' | 'blossom-dream' | 'geist'
export type ThemeMode = 'light' | 'dark' | 'system'
export interface ThemeInfo {
@@ -57,6 +57,13 @@ export const themes: ThemeInfo[] = [
description: 'RAL 180 80 10',
primaryColor: '#5A8A8A',
bgColor: '#E4F0F0'
},
{
id: 'geist',
name: 'Geist',
description: 'Vercel · 极简黑白',
primaryColor: '#000000',
bgColor: '#ffffff'
}
]

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