Compare commits

...

505 Commits

Author SHA1 Message Date
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
xuncha
fb8663fb24 Merge pull request #338 from hicccc77/dev
Dev
2026-02-28 21:19:49 +08:00
xuncha
3a9be771b4 Merge pull request #337 from xunchahaha/dev
Dev
2026-02-28 21:19:21 +08:00
xuncha
b2ef8f5cd2 修复 2026-02-28 21:18:57 +08:00
xuncha
83d501ae9b Merge branch 'hicccc77:dev' into dev 2026-02-28 20:00:06 +08:00
xuncha
c555566c9d 修复日期跳转 2026-02-28 19:58:44 +08:00
cc
264f9a380b Merge pull request #335 from hicccc77/main
Dev
2026-02-28 19:49:06 +08:00
cc
33d5951a14 修复视频索引逻辑 2026-02-28 19:47:35 +08:00
cc
68c4e43e05 Merge pull request #334 from hicccc77/dev
Dev
2026-02-28 19:31:46 +08:00
cc
54510f1c18 Merge branch 'main' into dev 2026-02-28 19:30:57 +08:00
cc
940234c743 Merge pull request #327 from StarsUnsurpass/main
还原了原有的视频解密逻辑
2026-02-28 19:27:39 +08:00
cc
b31ab46d11 修复通知内部分组件显示异常;修复结束引导后无法正确连接后端服务的问题;优化了图片密钥的解析速度 2026-02-28 19:26:54 +08:00
ace
c359821844 fix(electron): 修复 imageDecryptService 中的中文乱码、语法错误和 TypeScript 检查错误 2026-02-28 18:22:11 +08:00
cc
d49cf08e21 优化 2026-02-28 17:56:48 +08:00
cc
0f4cd23989 Merge pull request #332 from xunchahaha/dev
Dev
2026-02-28 17:56:20 +08:00
xuncha
e12451911b 导出新增位置消息 2026-02-28 17:33:24 +08:00
xuncha
b26f8cc43c 修复批量解密图片逻辑 加快速度 2026-02-28 17:32:28 +08:00
xuncha
d63c37cd78 视频解密丰富日志 方便定位 2026-02-28 16:51:18 +08:00
xuncha
c88aa2c9d8 修复图片解密 2026-02-28 16:44:55 +08:00
xuncha
4d5c744583 修复 2026-02-28 16:28:46 +08:00
xuncha
5033c5c7b7 Merge branch 'hicccc77:dev' into dev 2026-02-28 16:23:36 +08:00
cc
5a1f2ffac7 修复报错 2026-02-28 16:11:13 +08:00
xuncha
8eecb592e6 优化图片密钥获取
feat: brute aes key within 2 minutes
2026-02-28 13:47:14 +08:00
xuncha
fb188d6aaa Merge branch 'hicccc77:dev' into dev 2026-02-28 13:19:42 +08:00
H3CoF6
0d33fe8fe4 feat: update welcome page and fix handle error 2026-02-28 05:37:19 +08:00
H3CoF6
5b3b8b5bc3 feat: add progress 2026-02-28 05:00:42 +08:00
H3CoF6
17de7f2e56 feat: first trial for brute aes_key 2026-02-28 03:07:29 +08:00
cc
03aec7a34e 支持折叠的群聊判定 2026-02-28 00:21:25 +08:00
cc
266d68be22 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-02-27 20:44:12 +08:00
cc
bfbdefe773 更新服务文件 2026-02-27 20:44:08 +08:00
ace
5e96cdb1d6 恢复了原有的视频解密逻辑 2026-02-27 16:07:27 +08:00
xuncha
19ee47ceb2 Merge branch 'dev' of https://github.com/xunchahaha/WeFlow into dev 2026-02-27 14:15:57 +08:00
xuncha
2823607146 更新文档 2026-02-27 14:15:31 +08:00
xuncha
1869abd9df Merge pull request #326 from hicccc77/revert-324-main
Revert "修复了图片解密失败的问题"
2026-02-27 14:13:34 +08:00
xuncha
f070d184ea Revert "修复了图片解密失败的问题" 2026-02-27 14:13:04 +08:00
xuncha
d59d552aae Merge pull request #325 from vipxlm/feat/media-http-and-sort-fix
feat: 支持通过本地 HTTP 访问导出媒体 https://github.com/hicccc77/WeFlow/issues/322
fix: 修复消息排序问题
2026-02-27 14:08:55 +08:00
xuncha
a370531f1d Merge pull request #324 from StarsUnsurpass/main
图片解密wxid后加上后缀 简单重写了视频解密
2026-02-27 13:50:45 +08:00
cc
9ae1b455f4 支持朋友圈防撤回;修复朋友圈回复嵌套关系错误;支持朋友圈评论表情解析;支持删除本地朋友圈记录 2026-02-27 13:40:13 +08:00
ace
ec0eb64ffd 修复了图片解密失败的问题 2026-02-27 11:12:05 +08:00
hanyu
f31886e1ab feat: 支持通过本地 HTTP 访问导出媒体并修复消息排序问题 2026-02-26 19:51:39 +08:00
cc
7365831ec1 Merge pull request #321 from hicccc77/dev
新增启动页面;修复转发表情包无法索引的问题;修复群回复中消息溢出错误;修复群消息中消息类型判定错误
2026-02-26 19:43:12 +08:00
cc
4a09b682b2 新增启动页面;修复转发表情包无法索引的问题;修复群回复中消息溢出错误;修复群消息中消息类型判定错误 2026-02-26 19:40:26 +08:00
cc
afbd52a91e Merge pull request #314 from hicccc77/dev
修复图片密钥在部分情况下无法索引到正确数据的问题
2026-02-26 11:13:45 +08:00
cc
1c6e14acb4 修复图片密钥在部分情况下无法索引到正确数据的问题 2026-02-26 11:13:16 +08:00
xuncha
6968936c8f Merge pull request #312 from hicccc77/dev
Dev
2026-02-25 19:52:23 +08:00
xuncha
a571278145 Merge pull request #311 from xunchahaha:dev
修复引用消息错误的问题
2026-02-25 19:23:18 +08:00
xuncha
e4e25394e2 修复引用消息错误的问题 2026-02-25 19:22:53 +08:00
xuncha
fe47d7b9e3 Merge pull request #310 from hicccc77/dev
Dev
2026-02-25 18:03:32 +08:00
xuncha
4bb5bc6e32 Merge pull request #309 from xunchahaha:dev
Dev
2026-02-25 18:03:07 +08:00
xuncha
49d951e96a 1 2026-02-25 18:01:27 +08:00
xuncha
9585a02959 修复透明卡片问题 2026-02-25 17:59:42 +08:00
xuncha
a51fa5e4a2 修复 2026-02-25 17:26:45 +08:00
xuncha
bc0671440c 更新消息类型适配 2026-02-25 17:07:47 +08:00
xuncha
1a07c3970f 简单优化图片解密 2026-02-25 14:54:08 +08:00
xuncha
83c07b27f9 图片批量解密 图片解密优化 2026-02-25 14:23:22 +08:00
xuncha
fbcf7d2fc3 实况播放更加丝滑 2026-02-25 13:54:06 +08:00
cc
b547ac1aed 重要安全更新 2026-02-25 13:25:25 +08:00
cc
411f8a8d61 修复朋友圈封面信息被错误解析的问题;解决了一些安全问题 2026-02-25 12:12:08 +08:00
cc
b3741a5cf4 Merge pull request #299 from hicccc77/main
main
2026-02-23 10:32:18 +08:00
cc
b1cf524612 Merge pull request #298 from hicccc77/dev
聊天页面支持实况解析;朋友圈页面优化
2026-02-23 10:31:37 +08:00
cc
364c920fff Merge pull request #297 from Leoluis0705/fix-issues-clean
fix: 修复更新弹窗无响应、内存泄漏、SQL注入、文件句柄泄漏及并发安全问题;优化导出功能
2026-02-23 10:30:55 +08:00
你的名字
e89ccee5f4 refactor: 响应Codex代码评审建议 2026-02-23 10:28:51 +08:00
你的名字
6a86e69cd4 fix: 修复聊天记录加载问题 2026-02-23 10:28:45 +08:00
你的名字
ab2c086e93 fix: 修复更新弹窗无响应、内存泄漏、SQL注入、文件句柄泄漏及并发安全问题;优化导出功能 2026-02-23 09:55:33 +08:00
cc
b9c65e634c 聊天页面支持实况解析;朋友圈页面优化 2026-02-22 21:39:11 +08:00
cc
b7852a8c07 Merge pull request #293 from hicccc77/dev
Dev
2026-02-22 15:26:46 +08:00
cc
4b9d94eb62 修复实时更新偶发失效的问题;删除AI对话有关组件与依赖 2026-02-22 15:26:13 +08:00
cc
70481fd468 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-02-22 14:26:44 +08:00
cc
52c67f4d23 修复开启应用锁时更新公告弹窗无法关闭的bug #291;修复朋友圈时间排序错乱 #290;支持日期选择器快速跳转年月;朋友圈页面性能优化 2026-02-22 14:26:41 +08:00
xuncha
d3618f3065 Merge pull request #292 from hicccc77/xunchahaha-patch-1
Xunchahaha patch 1
2026-02-22 13:02:04 +08:00
xuncha
29472beee8 Update README.md 2026-02-22 13:01:41 +08:00
cc
acaac507b1 支持系统深浅自适应同步 2026-02-21 23:23:40 +08:00
cc
f25c23b2b3 Merge pull request #287 from hicccc77/dev
Dev
2026-02-21 23:07:10 +08:00
cc
5ab0466a87 联系人页面优化算法,同时支持获取曾经的好友;支持通过联系人页面打开聊天会话;朋友圈页面优化;支持检测并标记部分已删除的朋友圈 2026-02-21 23:06:41 +08:00
cc
d49c44f3be Merge pull request #286 from hicccc77/main
Main
2026-02-21 12:56:32 +08:00
cc
4577b4e955 修复了一些问题,并引入了新的问题 2026-02-21 12:55:44 +08:00
cc
dafde2eaba Merge pull request #283 from hicccc77/dev
Dev
2026-02-20 21:57:07 +08:00
The Shit Code Here
db4fab9130 修复HTML导出图片文件名冲突 (#282)
Co-authored-by: 0xshitcode <0xshitcode@users.noreply.github.com>
2026-02-20 21:55:31 +08:00
cc
9aee578707 支持朋友圈导出 2026-02-20 21:53:35 +08:00
cc
6d74eb65ae 更新朋友圈样式 2026-02-20 21:53:35 +08:00
cc
6e8ae3a12b 支持朋友圈导出 2026-02-20 21:50:02 +08:00
cc
a4be7f9005 更新朋友圈样式 2026-02-20 11:28:25 +08:00
xuncha
587ee630d7 Merge pull request #281 from hicccc77/dev
Dev
2026-02-19 18:44:39 +08:00
xuncha
6952a5f680 Merge pull request #280 from xunchahaha:main
Main
2026-02-19 18:43:55 +08:00
xuncha
b263ecd45c 修复会话太多的堵塞 2026-02-19 18:43:16 +08:00
xuncha
74fc0e4e88 Merge pull request #279 from hicccc77/dev
Dev
2026-02-19 18:07:34 +08:00
xuncha
a873366342 Merge pull request #278 from xunchahaha/dev
Dev
2026-02-19 18:07:09 +08:00
xuncha
c4dc266f93 排除好友防呆设计 2026-02-19 18:05:37 +08:00
xuncha
96ff783bbd html导出卡片链接优化 2026-02-19 17:55:01 +08:00
xuncha
804a65f52b 单个好友导出ui优化 2026-02-19 17:54:55 +08:00
xuncha
e88c859f4f 成员消息导出单拎出来 2026-02-19 17:40:41 +08:00
xuncha
c1a393eaf6 修改中文注释 2026-02-19 17:28:12 +08:00
xuncha
15e08dc529 修复朋友圈视频也走卡片消息解析 2026-02-19 17:12:28 +08:00
xuncha
e55bcaf7eb Merge branch 'dev' of https://github.com/xunchahaha/WeFlow into dev 2026-02-19 17:05:47 +08:00
xuncha
4e64c6ad6e api相关优化 2026-02-19 17:05:43 +08:00
xuncha
5a15e1a1d6 Merge branch 'hicccc77:dev' into dev 2026-02-19 16:54:43 +08:00
xuncha
ba07d47496 朋友圈优化卡片消息类 2026-02-19 16:51:32 +08:00
xuncha
25325e80ee 通讯录可勾选部分好友导出 2026-02-19 16:49:46 +08:00
xuncha
89783b4d45 群聊单个成员消息导出 2026-02-19 16:49:00 +08:00
xuncha
d5f0094025 优化转账类消息导出 2026-02-19 16:47:50 +08:00
cc
b4f37451be Merge pull request #275 from hicccc77/dev
修复了修改消息时可能修改到错误消息的问题
2026-02-18 23:18:55 +08:00
cc
84ea378815 修复了修改消息时可能修改到错误消息的问题 2026-02-18 23:18:14 +08:00
cc
72d4db1f27 Merge pull request #274 from hicccc77/dev
Dev
2026-02-18 23:00:20 +08:00
cc
21ea879d97 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-02-18 22:59:31 +08:00
cc
a5baef2240 支持删除消息与修改消息内容 2026-02-18 22:59:28 +08:00
xuncha
bbecf54aba Merge pull request #273 from xunchahaha/dev
Dev
2026-02-18 14:58:09 +08:00
xuncha
5f868d193c 搜索出来的表情包也可以解析 2026-02-18 13:49:56 +08:00
xuncha
62b035ab39 增加反选功能 https://github.com/hicccc77/WeFlow/issues/266
Dev
2026-02-18 02:04:51 +08:00
xuncha
ff5ee33e08 Merge branch 'hicccc77:dev' into dev 2026-02-17 23:15:20 +08:00
cc
8e28016e5e 朋友圈图片解密的优化 2026-02-17 23:14:42 +08:00
cc
f17a18cb6d Merge pull request #271 from hicccc77/main
Dev
2026-02-17 10:29:06 +08:00
cc
999f45e5f5 Merge branch 'main' of https://github.com/hicccc77/WeFlow 2026-02-17 10:27:43 +08:00
cc
3e303fadd7 更新致谢 2026-02-17 10:27:37 +08:00
xuncha
3b7590d8ce 增加好友排除反选功能 2026-02-17 01:59:37 +08:00
xuncha
fabbada580 Merge pull request #269 from hicccc77/dev
Dev
2026-02-16 23:59:44 +08:00
xuncha
6e434d37dc Merge pull request #268 from xunchahaha/dev
修复打包
2026-02-16 23:59:23 +08:00
xuncha
904da80f81 修复打包 2026-02-16 23:58:48 +08:00
cc
2a4bd52f0a Merge pull request #267 from hicccc77/dev
Dev
2026-02-16 23:33:12 +08:00
cc
b4248d4a12 支持朋友圈图片解密;视频解密;实况渲染 2026-02-16 23:31:52 +08:00
xuncha
75b056d5ba 修复后面有.的问题 https://github.com/hicccc77/WeFlow/issues/262 2026-02-16 17:35:25 +08:00
xuncha
e87e12c939 修复后面有.的问题 2026-02-16 17:34:34 +08:00
xuncha
5cb7e3bc73 Merge pull request #263 from xunchahaha:sns
sns
2026-02-16 17:26:51 +08:00
xuncha
1930b91a5b 修复 2026-02-16 17:26:06 +08:00
xuncha
ea0dad132c sns 2026-02-16 16:28:04 +08:00
cc
5b7b94f507 Merge pull request #260 from hicccc77/dev
解决年度报告导出失败 #252;集成WechatVisualization的功能并支持词云排除 #259
2026-02-16 10:24:43 +08:00
cc
28e38f73f8 解决年度报告导出失败 #252;集成WechatVisualization的功能并支持词云排除 #259 2026-02-16 10:23:33 +08:00
cc
d43c0ef209 Merge pull request #258 from hicccc77/dev
Dev
2026-02-15 11:45:34 +08:00
cc
6394384be0 更友好的跳转日期 #256;修复聊天记录显示不完整 #254;修复聊天 tab 对话页面“向下滚动查看更新消息”失效 #253 2026-02-15 11:44:23 +08:00
cc
4f0af3d0cb 修复日期跳转器的问题 2026-02-12 21:48:56 +08:00
cc
2a6f833718 Merge pull request #248 from hicccc77/dev
修复npm问题
2026-02-11 20:15:19 +08:00
cc
c8835f4d4c 修复npm问题 2026-02-11 20:14:40 +08:00
cc
fff1a1c177 Merge pull request #247 from hicccc77/dev
Dev
2026-02-11 20:00:26 +08:00
cc
8fee96d0e1 更新 2026-02-10 13:47:31 +08:00
cc
fdb3d63006 更新 2026-02-09 17:06:20 +08:00
xuncha
071d239892 Merge pull request #240 from xunchahaha:dev
Dev
2026-02-08 23:28:14 +08:00
xuncha
94eb9abe9d 修复 2026-02-08 23:27:45 +08:00
xuncha
1031c4013e 新增weclone格式导出 2026-02-08 22:42:00 +08:00
xuncha
2b5bb34392 修复双人年度报告相关 2026-02-08 22:41:50 +08:00
cc
e28ef9b783 不够无敌炸裂的更新 2026-02-08 21:27:25 +08:00
xuncha
e3c17010c1 Merge pull request #227 from hicccc77/dev
Dev
2026-02-07 01:09:05 +08:00
xuncha
2389aaf314 Merge pull request #226 from xunchahaha/dev
fix
2026-02-07 01:08:45 +08:00
xuncha
4f1dd7a5fb fix 2026-02-07 01:08:19 +08:00
xuncha
4b203a93b6 Merge pull request #225 from hicccc77/dev
Dev
2026-02-07 00:55:43 +08:00
xuncha
f219b1a580 Merge pull request #224 from xunchahaha/main
dev
2026-02-07 00:54:49 +08:00
xuncha
004ee5bbf0 修复了导出群昵称错误的问题 2026-02-07 00:52:49 +08:00
xuncha
5640db9cbd 修复群聊分析群昵称错误的问题 2026-02-07 00:44:50 +08:00
xuncha
52b26533a2 修复了聊天打开的情况下无法拖动窗口的问题 2026-02-07 00:12:56 +08:00
xuncha
d334a214a4 群聊新增群聊分析按钮 2026-02-06 23:53:16 +08:00
xuncha
1aab8dfc4e 聊天页面新增导出按钮 2026-02-06 23:37:50 +08:00
xuncha
e56ee1ff4a 修复导出时拍一拍的问题 2026-02-06 23:24:42 +08:00
xuncha
0393e7aff7 修复拍一拍的问题 2026-02-06 23:19:12 +08:00
xuncha
c988e4accf 优化批量转写的显示效果 2026-02-06 23:11:03 +08:00
xuncha
63ac715792 优化了html导出 2026-02-06 23:09:20 +08:00
xuncha
fe0e2e6592 批量语音转文字改成右下角常驻 2026-02-06 23:09:01 +08:00
xuncha
ca1a386146 优化html导出 2026-02-06 23:01:31 +08:00
xuncha
7c9d0a39c3 Merge pull request #217 from hicccc77/dev 2026-02-06 19:22:23 +08:00
xuncha
a5777027b1 更新版本号 2026-02-06 19:21:49 +08:00
xuncha
c3e911e6fa Merge pull request #215 from xunchahaha:dev
更新版本号
2026-02-06 19:21:19 +08:00
xuncha
4d03110df2 更新版本号 2026-02-06 19:20:55 +08:00
xuncha
8cb640f565 Merge pull request #214 from hicccc77/dev
Dev
2026-02-06 19:16:22 +08:00
xuncha
494bd4f539 转账导出优化 2026-02-06 19:15:45 +08:00
xuncha
38169691cd 给箭头改成对号 2026-02-06 19:15:45 +08:00
xuncha
bd995bc736 新增转账消息的解析 2026-02-06 19:15:45 +08:00
xuncha
6e05e74d5e 会话详情wxid支持复制 2026-02-06 19:15:45 +08:00
xuncha
d3a1db4efe 从密语给批量语音转文字搬过来了 2026-02-06 19:15:45 +08:00
xuncha
a19f2a57c3 优化语音播放逻辑 2026-02-06 19:15:45 +08:00
xuncha
666a53f6ba 修复api limit/chatlab/keyword参数 2026-02-06 19:15:45 +08:00
xuncha
b156a08f0d 转账导出优化 2026-02-06 19:15:22 +08:00
xuncha
9c76aa2189 给箭头改成对号 2026-02-06 19:15:22 +08:00
xuncha
a54c95b6ac 新增转账消息的解析 2026-02-06 19:15:22 +08:00
xuncha
9cb0ada1b7 会话详情wxid支持复制 2026-02-06 19:15:22 +08:00
xuncha
54378a132f 从密语给批量语音转文字搬过来了 2026-02-06 19:15:22 +08:00
xuncha
4d1632a9b9 优化语音播放逻辑 2026-02-06 19:15:22 +08:00
xuncha
1eab835458 修复api limit/chatlab/keyword参数 2026-02-06 19:15:22 +08:00
xuncha
fcbc7fead8 Merge pull request #208 from hicccc77/dev
新增api接口 优化导出
2026-02-05 18:48:03 +08:00
xuncha
ec783e4ccc Merge pull request #209 from xunchahaha/fix-merge-conflict
Fix merge conflict
2026-02-05 18:47:46 +08:00
xuncha
b6f97b102c Merge upstream/main into dev: 解决冲突保留 API 服务功能 2026-02-05 18:45:31 +08:00
xuncha
e4ce9a3bd7 优化api接口说明 2026-02-05 18:33:29 +08:00
xuncha
64d5e721af 优化导出 2026-02-05 18:33:29 +08:00
xuncha
d7419669d6 修复数字解析错误 2026-02-05 18:33:29 +08:00
xuncha
ff2f6799c8 尝试新增api 优化导出 2026-02-05 18:33:29 +08:00
cc
2d573896f9 宇宙超级无敌帅气到爆炸的更新 2026-02-04 22:32:15 +08:00
xuncha
ab15190c44 优化图片解密 2026-02-04 21:59:11 +08:00
cc
551995df68 超级无敌帅气到爆炸起飞的更新 2026-02-04 21:59:11 +08:00
xuncha
8483babd10 优化图片解密 2026-02-04 21:57:23 +08:00
cc
79648cd9d5 超级无敌帅气到爆炸起飞的更新 2026-02-03 21:45:17 +08:00
xuncha
04d690dcf1 Merge pull request #195 from hicccc77/dev
Dev
2026-02-03 18:18:53 +08:00
xuncha
0b308803bf 3 2026-02-03 18:15:47 +08:00
xuncha
419d5aace3 33 2026-02-03 14:56:08 +08:00
xuncha
84005f2d43 Merge pull request #188 from xunchahaha/dev
修复群公告解析错误
2026-02-03 14:50:50 +08:00
xuncha
a166079084 Merge branch 'dev' into dev 2026-02-03 14:50:38 +08:00
xuncha
a70d8fe6c8 修复群公告解析错误 2026-02-03 14:39:48 +08:00
xuncha
34cd337146 11 2026-02-02 23:19:36 +08:00
xuncha
c9216aabad 视频解密优化 2026-02-02 22:59:30 +08:00
xuncha
79d6aef480 同步了密语的头像处理 2026-02-02 22:59:30 +08:00
xuncha
8134d62056 增加对xml的处理 2026-02-02 22:59:30 +08:00
cc
8664ebf6f5 feat: 宇宙超级无敌牛且帅气到爆炸的功能更新和优化 2026-02-02 22:59:30 +08:00
xuncha
7b832ac2ef 给密语的图片查看器搬过来了 2026-02-02 22:59:30 +08:00
xuncha
5934fc33ce 从密语同步了一下图片解密 2026-02-02 22:59:30 +08:00
cc
b6d10f79de feat: 超级无敌帅气的更新和修复 2026-02-02 22:59:30 +08:00
cc
f90822694f feat: 一些非常帅气的优化 2026-02-02 22:59:30 +08:00
cc
123a088a39 feat: 支持忽略更新 2026-02-02 22:59:30 +08:00
xuncha
9283594dd0 Merge pull request #176 from xunchahaha:dev
Dev
2026-02-02 22:58:09 +08:00
xuncha
638246e74d 视频解密优化 2026-02-02 22:57:40 +08:00
xuncha
f506407f67 Merge pull request #175 from xunchahaha/dev
Dev
2026-02-02 22:41:19 +08:00
xuncha
216f201327 同步了密语的头像处理 2026-02-02 22:40:39 +08:00
xuncha
a557f2ada3 增加对xml的处理 2026-02-02 22:36:22 +08:00
cc
e15e4cc3c8 feat: 宇宙超级无敌牛且帅气到爆炸的功能更新和优化 2026-02-02 22:01:22 +08:00
xuncha
2555c46b6d Merge pull request #173 from xunchahaha:dev
Dev
2026-02-02 18:22:28 +08:00
xuncha
fdfd59fbdf 给密语的图片查看器搬过来了 2026-02-02 18:20:26 +08:00
xuncha
0e1c3f9364 从密语同步了一下图片解密 2026-02-02 18:06:24 +08:00
cc
f9bb18d97f feat: 超级无敌帅气的更新和修复 2026-02-01 23:25:19 +08:00
cc
b7339b6a35 feat: 一些非常帅气的优化 2026-02-01 22:56:43 +08:00
cc
26abc30695 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-02-01 20:50:04 +08:00
cc
1f0f824b01 feat: 支持忽略更新 2026-02-01 20:50:01 +08:00
xuncha
cb37f534ac Merge pull request #163 from xunchahaha:main
Main
2026-02-01 17:04:06 +08:00
xuncha
50903b35cf 11 2026-02-01 17:03:47 +08:00
xuncha
c07ef66324 Merge pull request #162 from hicccc77/dev
Dev
2026-02-01 16:57:08 +08:00
xuncha
6bc802e77b Merge pull request #161 from xunchahaha/dev
优化html导出
2026-02-01 16:56:46 +08:00
xuncha
898c86c23f 优化html导出 2026-02-01 16:55:01 +08:00
xuncha
7612353389 Merge pull request #160 from xunchahaha:dev
Dev
2026-02-01 15:25:13 +08:00
xuncha
8b37f20b0f 群聊分析 群成员查看修复 2026-02-01 15:24:48 +08:00
cc
0054509ef2 fix: 修复了一个问题 2026-02-01 15:09:40 +08:00
cc
e0f22f58c8 feat: 一些更新 2026-02-01 15:01:50 +08:00
xuncha
6f41cb34ed Merge pull request #159 from xunchahaha:dev
Dev
2026-02-01 02:26:34 +08:00
xuncha
ddbb0c3b26 优化ui 2026-02-01 02:26:00 +08:00
xuncha
f40f885af3 同步ui 2026-02-01 01:26:43 +08:00
xuncha
5413d7e2c8 双人年度报告后端实现 2026-02-01 01:13:17 +08:00
xuncha
53f0e299e0 年度报告ui实现 2026-02-01 00:30:54 +08:00
xuncha
65365107f5 修复群昵称读取错误的问题 2026-02-01 00:07:38 +08:00
xuncha
cffeeb26ec 新增排除好友 2026-01-31 23:44:16 +08:00
xuncha
26d4751e80 Merge pull request #157 from hicccc77/dev
Dev
2026-01-31 18:37:19 +08:00
xuncha
b8120a5119 Merge pull request #156 from xunchahaha/dev
111
2026-01-31 18:36:50 +08:00
xuncha
68a13cefc3 111 2026-01-31 18:36:28 +08:00
xuncha
cd4b8f3702 Merge pull request #155 from hicccc77/main
同步一下
2026-01-31 18:16:11 +08:00
xuncha
c5956ba203 Merge pull request #154 from xunchahaha/main
xiufu
2026-01-31 18:15:10 +08:00
xuncha
f456357e01 Merge pull request #153 from xunchahaha/dev
Dev
2026-01-31 18:14:26 +08:00
xuncha
4ef821f45f 更新版本号 2026-01-31 18:12:57 +08:00
xuncha
912c78e9e9 Merge branch 'main' of https://github.com/xunchahaha/WeFlow 2026-01-31 18:11:58 +08:00
xuncha
bfcd154a25 wxid可以自己选择 2026-01-31 18:11:55 +08:00
xuncha
a1c8ba48b0 Merge pull request #1 from xunchahaha/main
11
2026-01-31 17:46:20 +08:00
xuncha
f93369489d Merge branch 'hicccc77:main' into main 2026-01-31 17:45:54 +08:00
xuncha
014f57f152 尝试修复秘钥获取失败 2026-01-31 17:44:52 +08:00
xuncha
3f1eb58af4 Merge pull request #151 from xunchahaha:main
Main
2026-01-31 16:14:26 +08:00
xuncha
97f0077e95 打包你快修好啊 我服了 2026-01-31 16:14:00 +08:00
xuncha
3d9b1b0f8c Merge pull request #150 from xunchahaha:main
Main
2026-01-31 16:06:54 +08:00
xuncha
cf292ca9e2 hh 2026-01-31 16:06:36 +08:00
xuncha
97f14030de Merge pull request #149 from xunchahaha:main
Main
2026-01-31 16:02:01 +08:00
xuncha
2cfe0d8ee8 ee 2026-01-31 16:01:21 +08:00
xuncha
a760f45823 Merge pull request #148 from xunchahaha:main
Main
2026-01-31 15:56:53 +08:00
xuncha
baa949a301 呃呃 2026-01-31 15:56:27 +08:00
xuncha
c29bbab25f Merge pull request #147 from xunchahaha:main
Main
2026-01-31 15:51:21 +08:00
xuncha
29981e1232 打包优化 2026-01-31 15:51:04 +08:00
xuncha
2d043cd929 Merge pull request #146 from hicccc77/dev
Dev
2026-01-31 15:41:37 +08:00
xuncha
d6dca0e5f7 Merge pull request #145 from xunchahaha:dev
Dev
2026-01-31 15:40:39 +08:00
xuncha
d47166e6f9 修复打包错误 2026-01-31 15:39:59 +08:00
xuncha
6e3bb9e361 图片解密策略更加激进 2026-01-31 15:24:21 +08:00
xuncha
b8dbc3caf1 群聊分析ui调整 2026-01-31 15:04:54 +08:00
xuncha
c1145c8f89 导出群成员第二版 2026-01-31 14:58:15 +08:00
xuncha
0cba8e6d89 导出群成员第一版 2026-01-31 14:26:13 +08:00
xuncha
f6f468dff3 Merge pull request #144 from xunchahaha/dev
Dev
2026-01-31 14:01:22 +08:00
xuncha
04fc5f9104 修复切换账号后的异常问题 2026-01-31 14:00:01 +08:00
xuncha
3c9ab6763c 导出方面再优化 媒体并行导出 2026-01-31 13:49:21 +08:00
cc
f360333ab4 Merge pull request #143 from hicccc77/dev
Dev
2026-01-30 23:49:43 +08:00
cc
834aa6eecb Merge branch 'main' into dev 2026-01-30 23:49:33 +08:00
cc
2400cc8b55 Merge pull request #142 from yunxilyf/main
fix:自动保存bug
2026-01-30 23:48:39 +08:00
cc
e4ed7faca9 feat: 一些优化 2026-01-30 23:47:46 +08:00
yunxilyf
8012aa49ee fix:自动保存bug 2026-01-30 23:46:26 +08:00
xuncha
7225358b91 Merge pull request #140 from xunchahaha/dev
Dev
2026-01-30 20:47:01 +08:00
xuncha
39688e8e0c Merge branch 'hicccc77:dev' into dev 2026-01-30 20:46:47 +08:00
xuncha
592ca6128f 导出方面优化 2026-01-30 20:46:02 +08:00
xuncha
7cd27d8905 Merge pull request #139 from xunchahaha/dev
修复自动保存失效
2026-01-30 20:19:42 +08:00
xuncha
bca387c54b 修复自动保存失效 2026-01-30 20:19:23 +08:00
cc
e7e4ffd53f Merge pull request #137 from hicccc77/dev
Dev
2026-01-29 22:07:25 +08:00
cc
04e0bf6b29 Merge branch 'main' into dev 2026-01-29 22:07:17 +08:00
Forrest
dadd9d799c Merge pull request #136 from JiQingzhe2004/dev
feat: 一些适配
2026-01-29 22:03:02 +08:00
cc
b3aaea16f2 feat: 支持中文路径 2026-01-29 21:59:29 +08:00
Forrest
f3994a1a72 feat: 一些适配 2026-01-29 21:25:36 +08:00
cc
26fbfd2c98 feat: 一些实现 2026-01-29 21:13:05 +08:00
cc
3c51dee9a6 feat: 一些优化 2026-01-29 20:48:27 +08:00
cc
b9fa0cc215 feat: 一些更新 2026-01-29 20:41:12 +08:00
Forrest
21f748a2dc Merge pull request #135 from JiQingzhe2004/main
优化
2026-01-29 19:06:12 +08:00
Forrest
87fe130791 feat(imageDecrypt): 优化缓存查找:多根目录检索 + 新日期目录结构 + 兼容旧路径 + WCDB 初始化容错 2026-01-29 19:04:43 +08:00
cc
ff1bc279f2 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-01-28 23:07:42 +08:00
cc
77689ec528 feat: 解决了一些问题 2026-01-28 23:04:29 +08:00
xuncha
5ea0b65905 Merge pull request #129 from xunchahaha/dev
Dev
2026-01-28 20:35:46 +08:00
xuncha
eac6b053ee 修一下 2026-01-28 20:35:20 +08:00
cc
d52abfddbf chore: 更新信息 2026-01-28 20:30:05 +08:00
xuncha
8f2e403837 Merge branch 'dev' of https://github.com/xunchahaha/WeFlow into dev 2026-01-28 20:27:19 +08:00
xuncha
17c9436c30 同步 2026-01-28 20:26:48 +08:00
xuncha
9969c073e5 优化导出 2026-01-28 20:24:48 +08:00
xuncha
dc83297854 Merge pull request #128 from xunchahaha/dev
Dev
2026-01-28 20:23:45 +08:00
xuncha
b6c9f2b32b 修复txt导出不映射的问题 2026-01-28 20:05:48 +08:00
xuncha
e63f901478 优化图片显示 2026-01-28 19:55:39 +08:00
xuncha
893cdb4d92 fix:修复ecxel导出问题 2026-01-28 19:31:29 +08:00
cc
d99ec05e81 Merge pull request #126 from hicccc77/main
同步分支
2026-01-28 19:30:08 +08:00
cc
c8f726eddc Merge pull request #125 from hicccc77/dev
Dev
2026-01-28 19:29:22 +08:00
cc
4e57a30c90 feat: 修复了一些问题 2026-01-27 22:18:50 +08:00
xuncha
0a88275669 Merge pull request #117 from xunchahaha/dev
Dev
2026-01-27 19:49:52 +08:00
xuncha
2a45cf1276 修ui 2026-01-27 19:48:34 +08:00
xuncha
d63f1e0d79 ui改 2026-01-27 19:39:53 +08:00
xuncha
f55507cd99 新增了导出联系人的功能 2026-01-27 19:25:34 +08:00
xuncha
836b0f9df4 同步 2026-01-27 18:08:50 +08:00
xuncha
b09068f1f7 Merge pull request #116 from xunchahaha/main
2026-01-27 18:03:40 +08:00
xuncha
714a9400d5 呃呃 2026-01-27 18:03:10 +08:00
xuncha
13dd2fca21 Merge branch 'hicccc77:main' into main 2026-01-27 17:56:34 +08:00
xuncha
5d1f834b61 Merge pull request #115 from hicccc77/dev
Dev
2026-01-27 17:56:19 +08:00
xuncha
3ca86224eb Merge pull request #114 from xunchahaha/dev
Dev
2026-01-27 17:55:15 +08:00
xuncha
f10e974f36 ee 2026-01-27 17:54:28 +08:00
xuncha
76c40e4118 Merge pull request #113 from hicccc77/dev
Dev
2026-01-27 17:49:00 +08:00
xuncha
5307f55840 Merge branch 'hicccc77:dev' into dev 2026-01-27 17:48:13 +08:00
xuncha
3405f26d10 Dev (#112)
* fix:优化表述

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

* fix:修复群聊分析白屏

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

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

* fix:修复了json导出的格式
2026-01-25 18:29:50 +08:00
xuncha
3c32ad5ca8 fix:修复了json导出的格式 2026-01-25 18:29:23 +08:00
xuncha
879d84b597 Merge branch 'hicccc77:dev' into dev 2026-01-25 18:21:27 +08:00
xuncha
ab3551fb91 Merge branch 'hicccc77:main' into main 2026-01-25 18:21:19 +08:00
xuncha
b9d1ea316f Revert "fix:优化表述 (#96)" (#97)
This reverts commit 2e61902556.
2026-01-25 18:19:06 +08:00
xuncha
7762bd37c9 Merge branch 'hicccc77:dev' into dev 2026-01-25 18:10:03 +08:00
xuncha
2e61902556 fix:优化表述 (#96) 2026-01-25 18:05:15 +08:00
xuncha
9e8072c337 Merge branch 'dev' into dev 2026-01-25 18:04:50 +08:00
xuncha
827e77c9a3 fix:优化表述 2026-01-25 17:43:23 +08:00
xuncha
1f03d35253 hh 2026-01-24 00:38:19 +08:00
139 changed files with 63116 additions and 7287 deletions

View File

@@ -21,7 +21,7 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22.12
cache: 'npm'
- name: Install Dependencies
@@ -54,8 +54,8 @@ jobs:
## 更新日志
修复了一些已知问题
## 加入我们的群
[点击加入 Telegram ](https://t.me/+hn3QzNc4DbA0MzNl)
## 查看更多日志/获取最新动态
[点击加入 Telegram 频道](https://t.me/weflow_cc)
EOF
gh release edit "$GITHUB_REF_NAME" --notes-file release_notes.md
gh release edit "$GITHUB_REF_NAME" --notes-file release_notes.md

9
.gitignore vendored
View File

@@ -56,4 +56,13 @@ Thumbs.db
*.aps
wcdb/
xkey/
server/
*info
chatlab-format.md
*.bak
AGENTS.md
.claude/
.agents/
resources/wx_send
概述.md

BIN
2wm.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

BIN
3wm.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

View File

@@ -19,9 +19,10 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
</a>
<a href="https://github.com/hicccc77/WeFlow/issues">
<img src="https://img.shields.io/github/issues/hicccc77/WeFlow?style=flat-square" alt="Issues">
<img src="https://gh-down-badges.linkof.link/hicccc77/WeFlow/" alt="Downloads" />
</a>
<a href="https://t.me/+hn3QzNc4DbA0MzNl">
<img src="https://img.shields.io/badge/Telegram%20交流群-点击加入-0088cc?style=flat-square&logo=telegram&logoColor=0088cc&labelColor=white" alt="Telegram">
<a href="https://t.me/weflow_cc">
<img src="https://img.shields.io/badge/Telegram%20频道-0088cc?style=flat-square&logo=telegram&logoColor=0088cc&labelColor=white" alt="Telegram">
</a>
</p>
@@ -29,34 +30,55 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
> [!TIP]
> 如果导出聊天记录后,想深入分析聊天内容可以试试 [ChatLab](https://chatlab.fun/)
> [!TIP]
> 仅支持微信 **4.0** 及以上版本
# 加入微信交流群
> 🎉 扫码加入微信群,与其他 WeFlow 用户一起交流问题和使用心得。
<p align="center">
<img src="2wm.png" alt="WeFlow 微信交流群二维码(一群)" width="220" style="margin-right: 16px;">
<img src="3wm.png" alt="WeFlow 微信交流群二维码(二群)" width="220">
</p>
<p align="center">一群满了加二群</p>
> [!NOTE]
> 仅支持微信 **4.0 及以上**版本,确保你的微信版本符合要求
## 主要功能
- 本地实时查看聊天记录
- 朋友圈图片、视频、**实况**的预览和解密
- 统计分析与群聊画像
- 年度报告与可视化概览
- 导出聊天记录为 HTML 等格式
- 本地解密与数据库管理
> [!NOTE]
> ⚠️ 本工具仅适配微信 **4.0 及以上**版本,请确保你的微信版本符合要求
- HTTP API 接口(供开发者集成)
- 查看完整能力清单:[详细功能](#详细功能清单)
## 快速开始
若你只想使用成品版本,可前往 Release 下载并安装。
## 详细功能清单
当前版本已支持以下能力:
| 功能模块 | 说明 |
|---------|------|
| **聊天** | 解密聊天中的图片、视频、实况(仅支持谷歌协议拍摄的实况);支持**修改**、删除**本地**消息;实时刷新最新消息,无需生成解密中间数据库 |
| **实时弹窗通知** | 新消息到达时提供桌面弹窗提醒,便于及时查看重要会话,提供黑白名单功能 |
| **私聊分析** | 统计好友间消息数量;分析消息类型与发送比例;查看消息时段分布等 |
| **群聊分析** | 查看群成员详细信息;分析群内发言排行、活跃时段和媒体内容 |
| **年度报告** | 生成按年统计的年度报告,或跨年度的长期历史报告 |
| **双人报告** | 选择指定好友,基于双方聊天记录生成专属分析报告 |
| **消息导出** | 将微信聊天记录导出为多种格式JSON、HTML、TXT、Excel、CSV、PGSQL、ChatLab专属格式等 |
| **朋友圈** | 解密朋友圈图片、视频、实况;导出朋友圈内容;拦截朋友圈的删除与隐藏操作;突破时间访问限制 |
| **联系人** | 导出微信好友、群聊、公众号信息;尝试找回曾经的好友(功能尚不完善) |
| **HTTP API 映射** | 将本地消息能力映射为 HTTP API便于对接外部系统、自动化脚本与二次开发 |
## HTTP API
> [!WARNING]
> 此功能目前处于早期阶段,接口可能会有变动,请等待后续更新完善。
WeFlow 提供本地 HTTP API 服务,支持通过接口查询消息数据,可用于与其他工具集成或二次开发。
- **启用方式**:设置 → API 服务 → 启动服务
- **默认端口**5031
- **访问地址**`http://127.0.0.1:5031`
- **支持格式**:原始 JSON 或 [ChatLab](https://chatlab.fun/) 标准格式
完整接口文档:[点击查看](docs/HTTP-API.md)
## 面向开发者
如果你想从源码构建或为项目贡献代码,请遵循以下步骤:
@@ -78,38 +100,19 @@ npm run build
打包产物在 `release` 目录下。
## 技术栈
- **前端**: React 19 + TypeScript + Zustand
- **桌面**: Electron 39
- **构建**: Vite + electron-builder
- **数据库**: better-sqlite3 + WCDB DLL
- **样式**: SCSS + CSS Variables
## 项目结构
```
WeFlow/
├── electron/ # Electron 主进程
│ ├── main.ts # 主进程入口
│ ├── preload.ts # 预加载脚本
│ └── services/ # 后端服务
│ ├── chatService.ts # 聊天数据服务
│ ├── wcdbService.ts # 数据库服务
│ └── ...
├── src/ # React 前端
│ ├── components/ # 通用组件
│ ├── pages/ # 页面组件
│ ├── stores/ # Zustand 状态管理
│ ├── services/ # 前端服务
│ └── types/ # TypeScript 类型定义
├── public/ # 静态资源
└── resources/ # 打包资源
```
## 致谢
- [密语 CipherTalk](https://github.com/ILoveBingLu/miyu) 为本项目提供了基础框架
- [WeChat-Channels-Video-File-Decryption](https://github.com/Evil0ctal/WeChat-Channels-Video-File-Decryption) 提供了视频解密相关的技术参考
## 支持我们
如果 WeFlow 确实帮到了你,可以考虑请我们喝杯咖啡:
> TRC20 **Address:** `TZCtAw8CaeARWZBfvjidCnTcfnAtf6nvS6`
## Star History
@@ -128,6 +131,4 @@ WeFlow/
**请负责任地使用本工具,遵守相关法律法规**
我们总是在向前走,却很少有机会回头看看
</div>

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

@@ -0,0 +1,391 @@
# WeFlow HTTP API 接口文档
WeFlow 提供 HTTP API 服务,支持通过 HTTP 接口查询消息数据,支持 [ChatLab](https://github.com/nichuanfang/chatlab-format) 标准化格式输出。
## 启用 API 服务
在设置页面 → API 服务 → 点击「启动服务」按钮。
默认端口:`5031`
## 基础地址
```
http://127.0.0.1:5031
```
---
## 接口列表
### 1. 健康检查
检查 API 服务是否正常运行。
**请求**
```
GET /health
```
**响应**
```json
{
"status": "ok"
}
```
---
### 2. 获取消息列表
获取指定会话的消息,支持 ChatLab 格式输出。
**请求**
```
GET /api/v1/messages
```
**参数**
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `talker` | string | ✅ | 会话 IDwxid 或群 ID |
| `limit` | number | ❌ | 返回数量限制,默认 100范围 `1~10000` |
| `offset` | number | ❌ | 偏移量,用于分页,默认 0 |
| `start` | string | ❌ | 开始时间,格式 YYYYMMDD |
| `end` | string | ❌ | 结束时间,格式 YYYYMMDD |
| `keyword` | string | ❌ | 关键词过滤(基于消息显示文本) |
| `chatlab` | string | ❌ | 设为 `1` 则输出 ChatLab 格式 |
| `format` | string | ❌ | 输出格式:`json`(默认)或 `chatlab` |
| `media` | string | ❌ | 设为 `1` 时导出媒体并返回媒体路径(兼容别名 `meiti``0` 时媒体返回占位符 |
| `image` | string | ❌ | 在 `media=1` 时控制图片导出,`1/0`(兼容别名 `tupian` |
| `voice` | string | ❌ | 在 `media=1` 时控制语音导出,`1/0`(兼容别名 `vioce` |
| `video` | string | ❌ | 在 `media=1` 时控制视频导出,`1/0` |
| `emoji` | string | ❌ | 在 `media=1` 时控制表情导出,`1/0` |
默认媒体导出目录:`%USERPROFILE%\\Documents\\WeFlow\\api-media`
**示例请求**
```bash
# 获取消息(原始格式)
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=50
# 获取消息ChatLab 格式)
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1
# 带时间范围查询
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&start=20260101&end=20260205&limit=100
# 开启媒体导出(只导出图片和语音)
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&media=1&image=1&voice=1&video=0&emoji=0
# 关键词过滤
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&keyword=项目进度&limit=50
```
**响应(原始格式)**
```json
{
"success": true,
"talker": "wxid_xxx",
"count": 50,
"hasMore": true,
"media": {
"enabled": true,
"exportPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media",
"count": 12
},
"messages": [
{
"localId": 123,
"localType": 3,
"content": "[图片]",
"createTime": 1738713600000,
"senderUsername": "wxid_sender",
"mediaType": "image",
"mediaFileName": "image_123.jpg",
"mediaUrl": "http://127.0.0.1:5031/api/v1/media/wxid_xxx/images/image_123.jpg",
"mediaLocalPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\wxid_xxx\\images\\image_123.jpg"
}
]
}
```
**响应ChatLab 格式)**
```json
{
"chatlab": {
"version": "0.0.2",
"exportedAt": 1738713600000,
"generator": "WeFlow",
"description": "Exported from WeFlow"
},
"meta": {
"name": "会话名称",
"platform": "wechat",
"type": "private",
"ownerId": "wxid_me"
},
"members": [
{
"platformId": "wxid_xxx",
"accountName": "用户名",
"groupNickname": "群昵称"
}
],
"messages": [
{
"sender": "wxid_xxx",
"accountName": "用户名",
"timestamp": 1738713600000,
"type": 0,
"content": "消息内容",
"mediaPath": "http://127.0.0.1:5031/api/v1/media/wxid_xxx/images/image_123.jpg"
}
],
"media": {
"enabled": true,
"exportPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media",
"count": 12
}
}
```
---
### 3. 访问导出媒体文件
通过 HTTP 直接访问已导出的媒体文件(图片、语音、视频、表情)。
**请求**
```
GET /api/v1/media/{relativePath}
```
**路径参数**
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `relativePath` | string | ✅ | 媒体文件的相对路径,如 `wxid_xxx/images/image_123.jpg` |
**支持的媒体类型**
| 扩展名 | Content-Type |
|--------|-------------|
| `.png` | image/png |
| `.jpg` / `.jpeg` | image/jpeg |
| `.gif` | image/gif |
| `.webp` | image/webp |
| `.wav` | audio/wav |
| `.mp3` | audio/mpeg |
| `.mp4` | video/mp4 |
**示例请求**
```bash
# 访问导出的图片
GET http://127.0.0.1:5031/api/v1/media/wxid_xxx/images/image_123.jpg
# 访问导出的语音
GET http://127.0.0.1:5031/api/v1/media/wxid_xxx/voices/voice_456.wav
# 访问导出的视频
GET http://127.0.0.1:5031/api/v1/media/wxid_xxx/videos/video_789.mp4
```
**响应**
成功时直接返回文件内容,`Content-Type` 根据文件扩展名自动设置。
失败时返回:
```json
{ "error": "Media not found" }
```
> 注意:媒体文件需要先通过消息接口的 `media=1` 参数导出后才能访问。
---
### 4. 获取会话列表
获取所有会话列表。
**请求**
```
GET /api/v1/sessions
```
**参数**
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `keyword` | string | ❌ | 搜索关键词,匹配会话名或 ID |
| `limit` | number | ❌ | 返回数量限制,默认 100 |
**示例请求**
```bash
GET http://127.0.0.1:5031/api/v1/sessions
GET http://127.0.0.1:5031/api/v1/sessions?keyword=工作群&limit=20
```
**响应**
```json
{
"success": true,
"count": 50,
"total": 100,
"sessions": [
{
"username": "wxid_xxx",
"displayName": "用户名",
"lastMessage": "最后一条消息",
"lastTime": 1738713600000,
"unreadCount": 0
}
]
}
```
---
### 4. 获取联系人列表
获取所有联系人信息。
**请求**
```
GET /api/v1/contacts
```
**参数**
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `keyword` | string | ❌ | 搜索关键词 |
| `limit` | number | ❌ | 返回数量限制,默认 100 |
**示例请求**
```bash
GET http://127.0.0.1:5031/api/v1/contacts
GET http://127.0.0.1:5031/api/v1/contacts?keyword=张三
```
**响应**
```json
{
"success": true,
"count": 50,
"contacts": [
{
"userName": "wxid_xxx",
"alias": "微信号",
"nickName": "昵称",
"remark": "备注名"
}
]
}
```
---
## ChatLab 格式说明
ChatLab 是一种标准化的聊天记录交换格式,版本 0.0.2。
### 消息类型映射
| ChatLab Type | 值 | 说明 |
|--------------|-----|------|
| TEXT | 0 | 文本消息 |
| IMAGE | 1 | 图片 |
| VOICE | 2 | 语音 |
| VIDEO | 3 | 视频 |
| FILE | 4 | 文件 |
| EMOJI | 5 | 表情 |
| LINK | 7 | 链接 |
| LOCATION | 8 | 位置 |
| RED_PACKET | 20 | 红包 |
| TRANSFER | 21 | 转账 |
| CALL | 23 | 通话 |
| SYSTEM | 80 | 系统消息 |
| RECALL | 81 | 撤回消息 |
| OTHER | 99 | 其他 |
---
## 使用示例
### PowerShell
```powershell
# 健康检查
Invoke-RestMethod http://127.0.0.1:5031/health
# 获取会话列表
Invoke-RestMethod http://127.0.0.1:5031/api/v1/sessions
# 获取消息
Invoke-RestMethod "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=10"
# 获取 ChatLab 格式
Invoke-RestMethod "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1" | ConvertTo-Json -Depth 10
```
### cURL
```bash
# 健康检查
curl http://127.0.0.1:5031/health
# 获取会话列表
curl http://127.0.0.1:5031/api/v1/sessions
# 获取消息ChatLab 格式)
curl "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1"
```
### Python
```python
import requests
BASE_URL = "http://127.0.0.1:5031"
# 获取会话列表
sessions = requests.get(f"{BASE_URL}/api/v1/sessions").json()
print(sessions)
# 获取消息
messages = requests.get(f"{BASE_URL}/api/v1/messages", params={
"talker": "wxid_xxx",
"limit": 100,
"chatlab": 1
}).json()
print(messages)
```
### JavaScript / Node.js
```javascript
const BASE_URL = "http://127.0.0.1:5031";
// 获取会话列表
const sessions = await fetch(`${BASE_URL}/api/v1/sessions`).then(r => r.json());
console.log(sessions);
// 获取消息ChatLab 格式)
const messages = await fetch(`${BASE_URL}/api/v1/messages?talker=wxid_xxx&chatlab=1`)
.then(r => r.json());
console.log(messages);
```
---
## 注意事项
1. API 仅监听本地地址 `127.0.0.1`,不对外网开放
2. 需要先连接数据库才能查询数据
3. 时间参数格式为 `YYYYMMDD`(如 20260205
4. 支持 CORS可从浏览器前端直接调用

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -0,0 +1,47 @@
import { parentPort, workerData } from 'worker_threads'
import { wcdbService } from './services/wcdbService'
import { dualReportService } from './services/dualReportService'
interface WorkerConfig {
year: number
friendUsername: string
dbPath: string
decryptKey: string
myWxid: string
resourcesPath?: string
userDataPath?: string
logEnabled?: boolean
excludeWords?: string[]
}
const config = workerData as WorkerConfig
process.env.WEFLOW_WORKER = '1'
if (config.resourcesPath) {
process.env.WCDB_RESOURCES_PATH = config.resourcesPath
}
wcdbService.setPaths(config.resourcesPath || '', config.userDataPath || '')
wcdbService.setLogEnabled(config.logEnabled === true)
async function run() {
const result = await dualReportService.generateReportWithConfig({
year: config.year,
friendUsername: config.friendUsername,
dbPath: config.dbPath,
decryptKey: config.decryptKey,
wxid: config.myWxid,
excludeWords: config.excludeWords,
onProgress: (status: string, progress: number) => {
parentPort?.postMessage({
type: 'dualReport:progress',
data: { status, progress }
})
}
})
parentPort?.postMessage({ type: 'dualReport:result', data: result })
}
run().catch((err) => {
parentPort?.postMessage({ type: 'dualReport:error', error: String(err) })
})

File diff suppressed because it is too large Load Diff

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

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

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

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

View File

@@ -9,6 +9,32 @@ contextBridge.exposeInMainWorld('electronAPI', {
clear: () => ipcRenderer.invoke('config:clear')
},
// 通知
notification: {
show: (data: any) => ipcRenderer.invoke('notification:show', data),
close: () => ipcRenderer.invoke('notification:close'),
click: (sessionId: string) => ipcRenderer.send('notification-clicked', sessionId),
ready: () => ipcRenderer.send('notification:ready'),
resize: (width: number, height: number) => ipcRenderer.send('notification:resize', { width, height }),
onShow: (callback: (event: any, data: any) => void) => {
ipcRenderer.on('notification:show', callback)
return () => ipcRenderer.removeAllListeners('notification:show')
}
},
// 认证
auth: {
hello: (message?: string) => ipcRenderer.invoke('auth:hello', message),
verifyEnabled: () => ipcRenderer.invoke('auth:verifyEnabled'),
unlock: (password: string) => ipcRenderer.invoke('auth:unlock', password),
enableLock: (password: string) => ipcRenderer.invoke('auth:enableLock', password),
disableLock: (password: string) => ipcRenderer.invoke('auth:disableLock', password),
changePassword: (oldPassword: string, newPassword: string) => ipcRenderer.invoke('auth:changePassword', oldPassword, newPassword),
setHelloSecret: (password: string) => ipcRenderer.invoke('auth:setHelloSecret', password),
clearHelloSecret: () => ipcRenderer.invoke('auth:clearHelloSecret'),
isLockMode: () => ipcRenderer.invoke('auth:isLockMode')
},
// 对话框
dialog: {
@@ -29,7 +55,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
getVersion: () => ipcRenderer.invoke('app:getVersion'),
checkForUpdates: () => ipcRenderer.invoke('app:checkForUpdates'),
downloadAndInstall: () => ipcRenderer.invoke('app:downloadAndInstall'),
onDownloadProgress: (callback: (progress: number) => void) => {
ignoreUpdate: (version: string) => ipcRenderer.invoke('app:ignoreUpdate', version),
onDownloadProgress: (callback: (progress: any) => void) => {
ipcRenderer.on('app:downloadProgress', (_, progress) => callback(progress))
return () => ipcRenderer.removeAllListeners('app:downloadProgress')
},
@@ -42,7 +69,17 @@ contextBridge.exposeInMainWorld('electronAPI', {
// 日志
log: {
getPath: () => ipcRenderer.invoke('log:getPath'),
read: () => ipcRenderer.invoke('log:read')
read: () => ipcRenderer.invoke('log:read'),
debug: (data: any) => ipcRenderer.send('log:debug', data)
},
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)
},
// 窗口控制
@@ -57,13 +94,20 @@ contextBridge.exposeInMainWorld('electronAPI', {
openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) =>
ipcRenderer.invoke('window:openVideoPlayerWindow', videoPath, videoWidth, videoHeight),
resizeToFitVideo: (videoWidth: number, videoHeight: number) =>
ipcRenderer.invoke('window:resizeToFitVideo', videoWidth, videoHeight)
ipcRenderer.invoke('window:resizeToFitVideo', videoWidth, videoHeight),
openImageViewerWindow: (imagePath: string, liveVideoPath?: string) =>
ipcRenderer.invoke('window:openImageViewerWindow', imagePath, liveVideoPath),
openChatHistoryWindow: (sessionId: string, messageId: number) =>
ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId),
openSessionChatWindow: (sessionId: string) =>
ipcRenderer.invoke('window:openSessionChatWindow', sessionId)
},
// 数据库路径
dbPath: {
autoDetect: () => ipcRenderer.invoke('dbpath:autoDetect'),
scanWxids: (rootPath: string) => ipcRenderer.invoke('dbpath:scanWxids', rootPath),
scanWxidCandidates: (rootPath: string) => ipcRenderer.invoke('dbpath:scanWxidCandidates', rootPath),
getDefault: () => ipcRenderer.invoke('dbpath:getDefault')
},
@@ -80,7 +124,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')
@@ -96,22 +141,50 @@ 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) =>
ipcRenderer.invoke('chat:getLatestMessages', sessionId, limit),
getNewMessages: (sessionId: string, minTime: number, limit?: number) =>
ipcRenderer.invoke('chat:getNewMessages', sessionId, minTime, limit),
getContact: (username: string) => ipcRenderer.invoke('chat:getContact', username),
getContactAvatar: (username: string) => ipcRenderer.invoke('chat:getContactAvatar', username),
updateMessage: (sessionId: string, localId: number, createTime: number, newContent: string) =>
ipcRenderer.invoke('chat:updateMessage', sessionId, localId, createTime, newContent),
deleteMessage: (sessionId: string, localId: number, createTime: number, dbPathHint?: string) =>
ipcRenderer.invoke('chat:deleteMessage', sessionId, localId, createTime, dbPathHint),
resolveTransferDisplayNames: (chatroomId: string, payerUsername: string, receiverUsername: string) =>
ipcRenderer.invoke('chat:resolveTransferDisplayNames', chatroomId, payerUsername, receiverUsername),
getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'),
downloadEmoji: (cdnUrl: string, md5?: string) => ipcRenderer.invoke('chat:downloadEmoji', cdnUrl, md5),
getCachedMessages: (sessionId: string) => ipcRenderer.invoke('chat:getCachedMessages', sessionId),
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 }
) => 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) => {
@@ -120,7 +193,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
return () => ipcRenderer.removeListener('chat:voiceTranscriptPartial', listener)
},
execQuery: (kind: string, path: string | null, sql: string) =>
ipcRenderer.invoke('chat:execQuery', kind, path, sql)
ipcRenderer.invoke('chat:execQuery', kind, path, sql),
getContacts: () => ipcRenderer.invoke('chat:getContacts'),
getMessage: (sessionId: string, localId: number) =>
ipcRenderer.invoke('chat:getMessage', sessionId, localId),
onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => {
ipcRenderer.on('wcdb-change', callback)
return () => ipcRenderer.removeListener('wcdb-change', callback)
}
},
@@ -151,9 +231,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
// 数据分析
analytics: {
getOverallStatistics: () => ipcRenderer.invoke('analytics:getOverallStatistics'),
getContactRankings: (limit?: number) => ipcRenderer.invoke('analytics:getContactRankings', limit),
getOverallStatistics: (force?: boolean) => ipcRenderer.invoke('analytics:getOverallStatistics', force),
getContactRankings: (limit?: number, beginTimestamp?: number, endTimestamp?: number) =>
ipcRenderer.invoke('analytics:getContactRankings', limit, beginTimestamp, endTimestamp),
getTimeDistribution: () => ipcRenderer.invoke('analytics:getTimeDistribution'),
getExcludedUsernames: () => ipcRenderer.invoke('analytics:getExcludedUsernames'),
setExcludedUsernames: (usernames: string[]) => ipcRenderer.invoke('analytics:setExcludedUsernames', usernames),
getExcludeCandidates: () => ipcRenderer.invoke('analytics:getExcludeCandidates'),
onProgress: (callback: (payload: { status: string; progress: number }) => void) => {
ipcRenderer.on('analytics:progress', (_, payload) => callback(payload))
return () => ipcRenderer.removeAllListeners('analytics:progress')
@@ -171,30 +255,69 @@ 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)
getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime),
exportGroupMembers: (chatroomId: string, outputPath: string) => ipcRenderer.invoke('groupAnalytics:exportGroupMembers', chatroomId, outputPath),
exportGroupMemberMessages: (chatroomId: string, memberUsername: string, outputPath: string, startTime?: number, endTime?: number) =>
ipcRenderer.invoke('groupAnalytics:exportGroupMemberMessages', chatroomId, memberUsername, outputPath, startTime, endTime)
},
// 年度报告
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')
}
},
dualReport: {
generateReport: (payload: { friendUsername: string; year: number }) =>
ipcRenderer.invoke('dualReport:generateReport', payload),
onProgress: (callback: (payload: { status: string; progress: number }) => void) => {
ipcRenderer.on('dualReport:progress', (_, payload) => callback(payload))
return () => ipcRenderer.removeAllListeners('dualReport:progress')
}
},
// 导出
export: {
getExportStats: (sessionIds: string[], options: any) =>
ipcRenderer.invoke('export:getExportStats', sessionIds, options),
exportSessions: (sessionIds: string[], outputDir: string, options: any) =>
ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options),
exportSession: (sessionId: string, outputPath: string, options: any) =>
ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options),
onProgress: (callback: (payload: { current: number; total: number; currentSession: string; phase: string }) => void) => {
exportContacts: (outputDir: string, options: any) =>
ipcRenderer.invoke('export:exportContacts', outputDir, options),
onProgress: (callback: (payload: { current: number; total: number; currentSession: string; currentSessionId?: string; phase: string }) => void) => {
ipcRenderer.on('export:progress', (_, payload) => callback(payload))
return () => ipcRenderer.removeAllListeners('export:progress')
}
@@ -214,6 +337,38 @@ contextBridge.exposeInMainWorld('electronAPI', {
// 朋友圈
sns: {
getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) =>
ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime)
ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime),
getSnsUsernames: () => ipcRenderer.invoke('sns:getSnsUsernames'),
getExportStatsFast: () => ipcRenderer.invoke('sns:getExportStatsFast'),
getExportStats: () => ipcRenderer.invoke('sns:getExportStats'),
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),
exportTimeline: (options: any) => ipcRenderer.invoke('sns:exportTimeline', options),
onExportProgress: (callback: (payload: any) => void) => {
ipcRenderer.on('sns:exportProgress', (_, payload) => callback(payload))
return () => ipcRenderer.removeAllListeners('sns:exportProgress')
},
selectExportDir: () => ipcRenderer.invoke('sns:selectExportDir'),
installBlockDeleteTrigger: () => ipcRenderer.invoke('sns:installBlockDeleteTrigger'),
uninstallBlockDeleteTrigger: () => ipcRenderer.invoke('sns:uninstallBlockDeleteTrigger'),
checkBlockDeleteTrigger: () => ipcRenderer.invoke('sns:checkBlockDeleteTrigger'),
deleteSnsPost: (postId: string) => ipcRenderer.invoke('sns:deleteSnsPost', postId),
downloadEmoji: (params: { url: string; encryptUrl?: string; aesKey?: string }) => ipcRenderer.invoke('sns:downloadEmoji', params)
},
// 数据收集
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),
stop: () => ipcRenderer.invoke('http:stop'),
status: () => ipcRenderer.invoke('http:status')
}
})

View File

@@ -3,6 +3,7 @@ import { wcdbService } from './wcdbService'
import { join } from 'path'
import { readFile, writeFile, rm } from 'fs/promises'
import { app } from 'electron'
import { createHash } from 'crypto'
export interface ChatStatistics {
totalMessages: number
@@ -30,6 +31,7 @@ export interface ContactRanking {
username: string
displayName: string
avatarUrl?: string
wechatId?: string
messageCount: number
sentCount: number
receivedCount: number
@@ -46,6 +48,54 @@ class AnalyticsService {
this.configService = new ConfigService()
}
private normalizeUsername(username: string): string {
return username.trim().toLowerCase()
}
private normalizeExcludedUsernames(value: unknown): string[] {
if (!Array.isArray(value)) return []
const normalized = value
.map((item) => typeof item === 'string' ? item.trim().toLowerCase() : '')
.filter((item) => item.length > 0)
return Array.from(new Set(normalized))
}
private getExcludedUsernamesList(): string[] {
return this.normalizeExcludedUsernames(this.configService.get('analyticsExcludedUsernames'))
}
private getExcludedUsernamesSet(): Set<string> {
return new Set(this.getExcludedUsernamesList())
}
private escapeSqlValue(value: string): string {
return value.replace(/'/g, "''")
}
private async getAliasMap(usernames: string[]): Promise<Record<string, string>> {
const map: Record<string, string> = {}
if (usernames.length === 0) return map
// C++ 层不支持参数绑定,直接内联转义后的字符串值
const chunkSize = 200
for (let i = 0; i < usernames.length; i += chunkSize) {
const chunk = usernames.slice(i, i + chunkSize)
const inList = chunk.map((u) => `'${this.escapeSqlValue(u)}'`).join(',')
const sql = `SELECT username, alias FROM contact WHERE username IN (${inList})`
const result = await wcdbService.execQuery('contact', null, sql)
if (!result.success || !result.rows) continue
for (const row of result.rows as Record<string, any>[]) {
const username = row.username || ''
const alias = row.alias || ''
if (username && alias) {
map[username] = alias
}
}
}
return map
}
private cleanAccountDirName(name: string): string {
const trimmed = name.trim()
if (!trimmed) return trimmed
@@ -54,7 +104,11 @@ class AnalyticsService {
if (match) return match[1]
return trimmed
}
return trimmed
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
const cleaned = suffixMatch ? suffixMatch[1] : trimmed
return cleaned
}
private isPrivateSession(username: string, cleanedWxid: string): boolean {
@@ -97,13 +151,15 @@ class AnalyticsService {
}
private async getPrivateSessions(
cleanedWxid: string
cleanedWxid: string,
excludedUsernames?: Set<string>
): Promise<{ usernames: string[]; numericIds: string[] }> {
const sessionResult = await wcdbService.getSessions()
if (!sessionResult.success || !sessionResult.sessions) {
return { usernames: [], numericIds: [] }
}
const rows = sessionResult.sessions as Record<string, any>[]
const excluded = excludedUsernames ?? this.getExcludedUsernamesSet()
const sample = rows[0]
void sample
@@ -124,7 +180,11 @@ class AnalyticsService {
return { username, idValue }
})
const usernames = sessions.map((s) => s.username)
const privateSessions = sessions.filter((s) => this.isPrivateSession(s.username, cleanedWxid))
const privateSessions = sessions.filter((s) => {
if (!this.isPrivateSession(s.username, cleanedWxid)) return false
if (excluded.size === 0) return true
return !excluded.has(this.normalizeUsername(s.username))
})
const privateUsernames = privateSessions.map((s) => s.username)
const numericIds = privateSessions
.map((s) => s.idValue)
@@ -177,11 +237,18 @@ class AnalyticsService {
}
private buildAggregateCacheKey(sessionIds: string[], beginTimestamp: number, endTimestamp: number): string {
const sample = sessionIds.slice(0, 5).join(',')
return `${beginTimestamp}-${endTimestamp}-${sessionIds.length}-${sample}`
if (sessionIds.length === 0) {
return `${beginTimestamp}-${endTimestamp}-0-empty`
}
const normalized = Array.from(new Set(sessionIds.map((id) => String(id)))).sort()
const hash = createHash('sha1').update(normalized.join('|')).digest('hex').slice(0, 12)
return `${beginTimestamp}-${endTimestamp}-${normalized.length}-${hash}`
}
private async computeAggregateByCursor(sessionIds: string[], beginTimestamp = 0, endTimestamp = 0): Promise<any> {
const wxid = this.configService.get('myWxid')
const cleanedWxid = wxid ? this.cleanAccountDirName(wxid) : ''
const aggregate = {
total: 0,
sent: 0,
@@ -206,8 +273,22 @@ class AnalyticsService {
if (endTimestamp > 0 && createTime > endTimestamp) return
const localType = parseInt(row.local_type || row.type || '1', 10)
const isSendRaw = row.computed_is_send ?? row.is_send ?? row.isSend ?? 0
const isSend = String(isSendRaw) === '1' || isSendRaw === 1 || isSendRaw === true
const isSendRaw = row.computed_is_send ?? row.is_send ?? row.isSend
let isSend = String(isSendRaw) === '1' || isSendRaw === 1 || isSendRaw === true
// 如果底层没有提供 is_send则根据发送者用户名推断
const senderUsername = row.sender_username || row.senderUsername || row.sender
if (isSendRaw === undefined || isSendRaw === null) {
if (senderUsername && (cleanedWxid)) {
const senderLower = String(senderUsername).toLowerCase()
const myWxidLower = cleanedWxid.toLowerCase()
isSend = (
senderLower === myWxidLower ||
// 兼容非 wxid 开头的账号(如果文件夹名带后缀,如 custom_backup而 sender 是 custom
(myWxidLower.startsWith(senderLower + '_'))
)
}
}
aggregate.total += 1
sessionStat.total += 1
@@ -369,6 +450,65 @@ class AnalyticsService {
void results
}
async getExcludedUsernames(): Promise<{ success: boolean; data?: string[]; error?: string }> {
try {
return { success: true, data: this.getExcludedUsernamesList() }
} catch (e) {
return { success: false, error: String(e) }
}
}
async setExcludedUsernames(usernames: string[]): Promise<{ success: boolean; data?: string[]; error?: string }> {
try {
const normalized = this.normalizeExcludedUsernames(usernames)
this.configService.set('analyticsExcludedUsernames', normalized)
await this.clearCache()
return { success: true, data: normalized }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getExcludeCandidates(): Promise<{ success: boolean; data?: Array<{ username: string; displayName: string; avatarUrl?: string; wechatId?: string }>; error?: string }> {
try {
const conn = await this.ensureConnected()
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
const excluded = this.getExcludedUsernamesSet()
const sessionInfo = await this.getPrivateSessions(conn.cleanedWxid, new Set())
const usernames = new Set<string>(sessionInfo.usernames)
for (const name of excluded) usernames.add(name)
if (usernames.size === 0) {
return { success: true, data: [] }
}
const usernameList = Array.from(usernames)
const [displayNames, avatarUrls, aliasMap] = await Promise.all([
wcdbService.getDisplayNames(usernameList),
wcdbService.getAvatarUrls(usernameList),
this.getAliasMap(usernameList)
])
const entries = usernameList.map((username) => {
const displayName = displayNames.success && displayNames.map
? (displayNames.map[username] || username)
: username
const avatarUrl = avatarUrls.success && avatarUrls.map
? avatarUrls.map[username]
: undefined
const alias = aliasMap[username]
const wechatId = alias || (!username.startsWith('wxid_') ? username : '')
return { username, displayName, avatarUrl, wechatId }
})
return { success: true, data: entries }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getOverallStatistics(force = false): Promise<{ success: boolean; data?: ChatStatistics; error?: string }> {
try {
const conn = await this.ensureConnected()
@@ -433,7 +573,11 @@ class AnalyticsService {
}
}
async getContactRankings(limit: number = 20): Promise<{ success: boolean; data?: ContactRanking[]; error?: string }> {
async getContactRankings(
limit: number = 20,
beginTimestamp: number = 0,
endTimestamp: number = 0
): Promise<{ success: boolean; data?: ContactRanking[]; error?: string }> {
try {
const conn = await this.ensureConnected()
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
@@ -443,7 +587,7 @@ class AnalyticsService {
return { success: false, error: '未找到消息会话' }
}
const result = await this.getAggregateWithFallback(sessionInfo.usernames, 0, 0)
const result = await this.getAggregateWithFallback(sessionInfo.usernames, beginTimestamp, endTimestamp)
if (!result.success || !result.data) {
return { success: false, error: result.error || '聚合统计失败' }
}
@@ -451,9 +595,10 @@ class AnalyticsService {
const d = result.data
const sessions = this.normalizeAggregateSessions(d.sessions, d.idMap)
const usernames = Object.keys(sessions)
const [displayNames, avatarUrls] = await Promise.all([
const [displayNames, avatarUrls, aliasMap] = await Promise.all([
wcdbService.getDisplayNames(usernames),
wcdbService.getAvatarUrls(usernames)
wcdbService.getAvatarUrls(usernames),
this.getAliasMap(usernames)
])
const rankings: ContactRanking[] = usernames
@@ -465,10 +610,13 @@ class AnalyticsService {
const avatarUrl = avatarUrls.success && avatarUrls.map
? avatarUrls.map[username]
: undefined
const alias = aliasMap[username] || ''
const wechatId = alias || (!username.startsWith('wxid_') ? username : '')
return {
username,
displayName,
avatarUrl,
wechatId,
messageCount: stat.total,
sentCount: stat.sent,
receivedCount: stat.received,

View File

@@ -69,9 +69,50 @@ export interface AnnualReportData {
phrase: string
count: number
}[]
snsStats?: {
totalPosts: number
typeCounts?: Record<string, number>
topLikers: { username: string; displayName: string; avatarUrl?: string; count: number }[]
topLiked: { username: string; displayName: string; avatarUrl?: string; count: number }[]
}
lostFriend: {
username: string
displayName: string
avatarUrl?: string
earlyCount: number
lateCount: number
periodDesc: string
} | null
}
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() {
}
@@ -101,8 +142,9 @@ class AnnualReportService {
return trimmed
}
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
if (suffixMatch) return suffixMatch[1]
return trimmed
const cleaned = suffixMatch ? suffixMatch[1] : trimmed
return cleaned
}
private async ensureConnectedWithConfig(
@@ -166,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) {
@@ -178,11 +448,15 @@ class AnnualReportService {
if (!raw) return ''
if (typeof raw === 'string') {
if (raw.length === 0) return ''
if (this.looksLikeHex(raw)) {
// 只有当字符串足够长超过16字符且看起来像 hex 时才尝试解码
// 短字符串(如 "123456" 等纯数字)容易被误判为 hex
if (raw.length > 16 && this.looksLikeHex(raw)) {
const bytes = Buffer.from(raw, 'hex')
if (bytes.length > 0) return this.decodeBinaryContent(bytes)
}
if (this.looksLikeBase64(raw)) {
// 只有当字符串足够长超过16字符且看起来像 base64 时才尝试解码
// 短字符串(如 "test", "home" 等)容易被误判为 base64
if (raw.length > 16 && this.looksLikeBase64(raw)) {
try {
const bytes = Buffer.from(raw, 'base64')
return this.decodeBinaryContent(bytes)
@@ -340,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: '加载年度数据失败' } }
}
}
@@ -397,8 +859,15 @@ class AnnualReportService {
this.reportProgress('加载会话列表...', 15, onProgress)
const startTime = Math.floor(new Date(year, 0, 1).getTime() / 1000)
const endTime = Math.floor(new Date(year, 11, 31, 23, 59, 59).getTime() / 1000)
const isAllTime = year <= 0
const reportYear = isAllTime ? 0 : year
const startTime = isAllTime ? 0 : Math.floor(new Date(year, 0, 1).getTime() / 1000)
const endTime = isAllTime ? 0 : Math.floor(new Date(year, 11, 31, 23, 59, 59).getTime() / 1000)
const now = new Date()
// 全局统计始终使用自然年范围 (Jan 1st - Now/YearEnd)
const actualStartTime = startTime
const actualEndTime = endTime
let totalMessages = 0
const contactStats = new Map<string, { sent: number; received: number }>()
@@ -420,7 +889,7 @@ class AnnualReportService {
const CONVERSATION_GAP = 3600
this.reportProgress('统计会话消息...', 20, onProgress)
const result = await wcdbService.getAnnualReportStats(sessionIds, startTime, endTime)
const result = await wcdbService.getAnnualReportStats(sessionIds, actualStartTime, actualEndTime)
if (!result.success || !result.data) {
return { success: false, error: result.error ? `基础统计失败: ${result.error}` : '基础统计失败' }
}
@@ -473,8 +942,8 @@ class AnnualReportService {
}
}
this.reportProgress('加载扩展统计... (初始化)', 30, onProgress)
const extras = await wcdbService.getAnnualReportExtras(sessionIds, startTime, endTime, peakDayBegin, peakDayEnd)
this.reportProgress('加载扩展统计...', 30, onProgress)
const extras = await wcdbService.getAnnualReportExtras(sessionIds, actualStartTime, actualEndTime, peakDayBegin, peakDayEnd)
if (extras.success && extras.data) {
this.reportProgress('加载扩展统计... (解析热力图)', 32, onProgress)
const extrasData = extras.data as any
@@ -554,7 +1023,7 @@ class AnnualReportService {
// 为保持功能完整,我们进行深度集成的轻量遍历:
for (let i = 0; i < sessionIds.length; i++) {
const sessionId = sessionIds[i]
const cursor = await wcdbService.openMessageCursorLite(sessionId, 1000, true, startTime, endTime)
const cursor = await wcdbService.openMessageCursorLite(sessionId, 1000, true, actualStartTime, actualEndTime)
if (!cursor.success || !cursor.cursor) continue
let lastDayIndex: number | null = null
@@ -575,9 +1044,22 @@ class AnnualReportService {
if (!createTime) continue
const isSendRaw = row.computed_is_send ?? row.is_send ?? '0'
const isSent = parseInt(isSendRaw, 10) === 1
let isSent = parseInt(isSendRaw, 10) === 1
const localType = parseInt(row.local_type || row.type || '1', 10)
// 兼容逻辑
if (isSendRaw === undefined || isSendRaw === null || isSendRaw === '0') {
const sender = String(row.sender_username || row.sender || row.talker || '').toLowerCase()
if (sender) {
const rawLower = rawWxid.toLowerCase()
const cleanedLower = cleanedWxid.toLowerCase()
if (sender === rawLower || sender === cleanedLower ||
rawLower.startsWith(sender + '_') || cleanedLower.startsWith(sender + '_')) {
isSent = true
}
}
}
// 响应速度 & 对话发起
if (!conversationStarts.has(sessionId)) {
conversationStarts.set(sessionId, { initiated: 0, received: 0 })
@@ -689,7 +1171,7 @@ class AnnualReportService {
if (!streakComputedInLoop) {
this.reportProgress('计算连续聊天...', 45, onProgress)
const streakResult = await this.computeLongestStreak(sessionIds, startTime, endTime, onProgress, 45, 75)
const streakResult = await this.computeLongestStreak(sessionIds, actualStartTime, actualEndTime, onProgress, 45, 75)
if (streakResult.days > longestStreakDays) {
longestStreakDays = streakResult.days
longestStreakSessionId = streakResult.sessionId
@@ -698,6 +1180,42 @@ class AnnualReportService {
}
}
// 获取朋友圈统计
this.reportProgress('分析朋友圈数据...', 75, onProgress)
let snsStatsResult: {
totalPosts: number
typeCounts?: Record<string, number>
topLikers: { username: string; displayName: string; avatarUrl?: string; count: number }[]
topLiked: { username: string; displayName: string; avatarUrl?: string; count: number }[]
} | undefined
const snsStats = await wcdbService.getSnsAnnualStats(actualStartTime, actualEndTime)
if (snsStats.success && snsStats.data) {
const d = snsStats.data
const usersToFetch = new Set<string>()
d.topLikers?.forEach((u: any) => usersToFetch.add(u.username))
d.topLiked?.forEach((u: any) => usersToFetch.add(u.username))
const snsUserIds = Array.from(usersToFetch)
const [snsDisplayNames, snsAvatarUrls] = await Promise.all([
wcdbService.getDisplayNames(snsUserIds),
wcdbService.getAvatarUrls(snsUserIds)
])
const getSnsUserInfo = (username: string) => ({
displayName: snsDisplayNames.success && snsDisplayNames.map ? (snsDisplayNames.map[username] || username) : username,
avatarUrl: snsAvatarUrls.success && snsAvatarUrls.map ? snsAvatarUrls.map[username] : undefined
})
snsStatsResult = {
totalPosts: d.totalPosts || 0,
typeCounts: d.typeCounts,
topLikers: (d.topLikers || []).map((u: any) => ({ ...u, ...getSnsUserInfo(u.username) })),
topLiked: (d.topLiked || []).map((u: any) => ({ ...u, ...getSnsUserInfo(u.username) }))
}
}
this.reportProgress('整理联系人信息...', 85, onProgress)
const contactIds = Array.from(contactStats.keys())
@@ -901,8 +1419,130 @@ class AnnualReportService {
.slice(0, 32)
.map(([phrase, count]) => ({ phrase, count }))
// 曾经的好朋友 (Once Best Friend / Lost Friend)
let lostFriend: AnnualReportData['lostFriend'] = null
let maxEarlyCount = 80 // 最低门槛
let bestEarlyCount = 0
let bestLateCount = 0
let bestSid = ''
let bestPeriodDesc = ''
const currentMonthIndex = new Date().getMonth() + 1 // 1-12
const currentYearNum = now.getFullYear()
if (isAllTime) {
const days = Object.keys(d.daily).sort()
if (days.length >= 2) {
const firstDay = Math.floor(new Date(days[0]).getTime() / 1000)
const lastDay = Math.floor(new Date(days[days.length - 1]).getTime() / 1000)
const midPoint = Math.floor((firstDay + lastDay) / 2)
this.reportProgress('分析历史趋势 (1/2)...', 86, onProgress)
const earlyRes = await wcdbService.getAggregateStats(sessionIds, 0, midPoint)
this.reportProgress('分析历史趋势 (2/2)...', 88, onProgress)
const lateRes = await wcdbService.getAggregateStats(sessionIds, midPoint, 0)
if (earlyRes.success && lateRes.success && earlyRes.data) {
const earlyData = earlyRes.data.sessions || {}
const lateData = (lateRes.data?.sessions) || {}
for (const sid of sessionIds) {
const e = earlyData[sid] || { sent: 0, received: 0 }
const l = lateData[sid] || { sent: 0, received: 0 }
const early = (e.sent || 0) + (e.received || 0)
const late = (l.sent || 0) + (l.received || 0)
if (early > 100 && early > late * 5) {
// 选择前期消息量最多的
if (early > maxEarlyCount) {
maxEarlyCount = early
bestEarlyCount = early
bestLateCount = late
bestSid = sid
bestPeriodDesc = '这段时间以来'
}
}
}
}
}
} else if (year === currentYearNum) {
// 当前年份独立获取过去12个月的滚动数据
this.reportProgress('分析近期好友趋势...', 86, onProgress)
// 往前数12个月的起点、中点、终点
const rollingStart = Math.floor(new Date(now.getFullYear(), now.getMonth() - 11, 1).getTime() / 1000)
const rollingMid = Math.floor(new Date(now.getFullYear(), now.getMonth() - 5, 1).getTime() / 1000)
const rollingEnd = Math.floor(now.getTime() / 1000)
const earlyRes = await wcdbService.getAggregateStats(sessionIds, rollingStart, rollingMid - 1)
const lateRes = await wcdbService.getAggregateStats(sessionIds, rollingMid, rollingEnd)
if (earlyRes.success && lateRes.success && earlyRes.data) {
const earlyData = earlyRes.data.sessions || {}
const lateData = lateRes.data?.sessions || {}
for (const sid of sessionIds) {
const e = earlyData[sid] || { sent: 0, received: 0 }
const l = lateData[sid] || { sent: 0, received: 0 }
const early = (e.sent || 0) + (e.received || 0)
const late = (l.sent || 0) + (l.received || 0)
if (early > 80 && early > late * 5) {
// 选择前期消息量最多的
if (early > maxEarlyCount) {
maxEarlyCount = early
bestEarlyCount = early
bestLateCount = late
bestSid = sid
bestPeriodDesc = '去年的这个时候'
}
}
}
}
} else {
// 指定完整年份 (1-6 vs 7-12)
for (const [sid, stat] of Object.entries(d.sessions)) {
const s = stat as any
const mWeights = s.monthly || {}
let early = 0
let late = 0
for (let m = 1; m <= 6; m++) early += mWeights[m] || 0
for (let m = 7; m <= 12; m++) late += mWeights[m] || 0
if (early > 80 && early > late * 5) {
// 选择前期消息量最多的
if (early > maxEarlyCount) {
maxEarlyCount = early
bestEarlyCount = early
bestLateCount = late
bestSid = sid
bestPeriodDesc = `${year}年上半年`
}
}
}
}
if (bestSid) {
let info = contactInfoMap.get(bestSid)
// 如果 contactInfoMap 中没有该联系人,则单独获取
if (!info) {
const [displayNameRes, avatarUrlRes] = await Promise.all([
wcdbService.getDisplayNames([bestSid]),
wcdbService.getAvatarUrls([bestSid])
])
info = {
displayName: displayNameRes.success && displayNameRes.map ? (displayNameRes.map[bestSid] || bestSid) : bestSid,
avatarUrl: avatarUrlRes.success && avatarUrlRes.map ? avatarUrlRes.map[bestSid] : undefined
}
}
lostFriend = {
username: bestSid,
displayName: info?.displayName || bestSid,
avatarUrl: info?.avatarUrl,
earlyCount: bestEarlyCount,
lateCount: bestLateCount,
periodDesc: bestPeriodDesc
}
}
const reportData: AnnualReportData = {
year,
year: reportYear,
totalMessages,
totalFriends: contactStats.size,
coreFriends,
@@ -915,7 +1555,9 @@ class AnnualReportService {
mutualFriend,
socialInitiative,
responseSpeed,
topPhrases
topPhrases,
snsStats: snsStatsResult,
lostFriend
}
return { success: true, data: reportData }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,68 @@
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: process.platform,
deviceId: this.deviceId,
timestamp: Date.now(),
online: true,
pages: Array.from(this.pages)
}
await wcdbService.cloudReport(JSON.stringify(data))
this.pages.clear()
}
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

@@ -1,20 +1,27 @@
import { join } from 'path'
import { app, safeStorage } from 'electron'
import crypto from 'crypto'
import Store from 'electron-store'
// 加密前缀标记
const SAFE_PREFIX = 'safe:' // safeStorage 加密(普通模式)
const LOCK_PREFIX = 'lock:' // 密码派生密钥加密(锁定模式)
interface ConfigSchema {
// 数据库相关
dbPath: string // 数据库根目录 (xwechat_files)
decryptKey: string // 解密密钥
myWxid: string // 当前用户 wxid
dbPath: string
decryptKey: string
myWxid: string
onboardingDone: boolean
imageXorKey: number
imageAesKey: string
wxidConfigs: Record<string, { decryptKey?: string; imageXorKey?: number; imageAesKey?: string; updatedAt?: number }>
// 缓存相关
cachePath: string
lastOpenedDb: string
lastSession: string
// 界面相关
theme: 'light' | 'dark' | 'system'
themeId: string
@@ -26,12 +33,55 @@ interface ConfigSchema {
whisperDownloadSource: string
autoTranscribeVoice: boolean
transcribeLanguages: string[]
exportDefaultConcurrency: number
analyticsExcludedUsernames: string[]
// 安全相关
authEnabled: boolean
authPassword: string // SHA-256 hashsafeStorage 加密)
authUseHello: boolean
authHelloSecret: string // 原始密码safeStorage 加密Hello 解锁时使用)
// 更新相关
ignoredUpdateVersion: string
// 通知
notificationEnabled: boolean
notificationPosition: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'
notificationFilterMode: 'all' | 'whitelist' | 'blacklist'
notificationFilterList: string[]
wordCloudExcludeWords: string[]
}
// 需要 safeStorage 加密的字段(普通模式)
const ENCRYPTED_STRING_KEYS: Set<string> = new Set(['decryptKey', 'imageAesKey', 'authPassword'])
const ENCRYPTED_BOOL_KEYS: Set<string> = new Set(['authEnabled', 'authUseHello'])
const ENCRYPTED_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
// 需要与密码绑定的敏感密钥字段(锁定模式时用 lock: 加密)
const LOCKABLE_STRING_KEYS: Set<string> = new Set(['decryptKey', 'imageAesKey'])
const LOCKABLE_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
export class ConfigService {
private store: Store<ConfigSchema>
private static instance: ConfigService
private store!: Store<ConfigSchema>
// 锁定模式运行时状态
private unlockedKeys: Map<string, any> = new Map()
private unlockPassword: string | null = null
static getInstance(): ConfigService {
if (!ConfigService.instance) {
ConfigService.instance = new ConfigService()
}
return ConfigService.instance
}
constructor() {
if (ConfigService.instance) {
return ConfigService.instance
}
ConfigService.instance = this
this.store = new Store<ConfigSchema>({
name: 'WeFlow-config',
defaults: {
@@ -54,24 +104,571 @@ export class ConfigService {
whisperModelDir: '',
whisperDownloadSource: 'tsinghua',
autoTranscribeVoice: false,
transcribeLanguages: ['zh']
transcribeLanguages: ['zh'],
exportDefaultConcurrency: 4,
analyticsExcludedUsernames: [],
authEnabled: false,
authPassword: '',
authUseHello: false,
authHelloSecret: '',
ignoredUpdateVersion: '',
notificationEnabled: true,
notificationPosition: 'top-right',
notificationFilterMode: 'all',
notificationFilterList: [],
wordCloudExcludeWords: []
}
})
this.migrateAuthFields()
}
// === 状态查询 ===
isLockMode(): boolean {
const raw: any = this.store.get('decryptKey')
return typeof raw === 'string' && raw.startsWith(LOCK_PREFIX)
}
isUnlocked(): boolean {
return !this.isLockMode() || this.unlockedKeys.size > 0
}
// === get / set ===
get<K extends keyof ConfigSchema>(key: K): ConfigSchema[K] {
return this.store.get(key)
const raw = this.store.get(key)
if (ENCRYPTED_BOOL_KEYS.has(key)) {
const str = typeof raw === 'string' ? raw : ''
if (!str || !str.startsWith(SAFE_PREFIX)) return raw
return (this.safeDecrypt(str) === 'true') as ConfigSchema[K]
}
if (ENCRYPTED_NUMBER_KEYS.has(key)) {
const str = typeof raw === 'string' ? raw : ''
if (!str) return raw
if (str.startsWith(LOCK_PREFIX)) {
const cached = this.unlockedKeys.get(key as string)
return (cached !== undefined ? cached : 0) as ConfigSchema[K]
}
if (!str.startsWith(SAFE_PREFIX)) return raw
const num = Number(this.safeDecrypt(str))
return (Number.isFinite(num) ? num : 0) as ConfigSchema[K]
}
if (ENCRYPTED_STRING_KEYS.has(key) && typeof raw === 'string') {
if (key === 'authPassword') return this.safeDecrypt(raw) as ConfigSchema[K]
if (raw.startsWith(LOCK_PREFIX)) {
const cached = this.unlockedKeys.get(key as string)
return (cached !== undefined ? cached : '') as ConfigSchema[K]
}
return this.safeDecrypt(raw) as ConfigSchema[K]
}
if (key === 'wxidConfigs' && raw && typeof raw === 'object') {
return this.decryptWxidConfigs(raw as any) as ConfigSchema[K]
}
return raw
}
set<K extends keyof ConfigSchema>(key: K, value: ConfigSchema[K]): void {
this.store.set(key, value)
let toStore = value
const inLockMode = this.isLockMode() && this.unlockPassword
if (ENCRYPTED_BOOL_KEYS.has(key)) {
toStore = this.safeEncrypt(String(value)) as ConfigSchema[K]
} else if (ENCRYPTED_NUMBER_KEYS.has(key)) {
if (inLockMode && LOCKABLE_NUMBER_KEYS.has(key)) {
toStore = this.lockEncrypt(String(value), this.unlockPassword!) as ConfigSchema[K]
this.unlockedKeys.set(key as string, value)
} else {
toStore = this.safeEncrypt(String(value)) as ConfigSchema[K]
}
} else if (ENCRYPTED_STRING_KEYS.has(key) && typeof value === 'string') {
if (key === 'authPassword') {
toStore = this.safeEncrypt(value) as ConfigSchema[K]
} else if (inLockMode && LOCKABLE_STRING_KEYS.has(key)) {
toStore = this.lockEncrypt(value, this.unlockPassword!) as ConfigSchema[K]
this.unlockedKeys.set(key as string, value)
} else {
toStore = this.safeEncrypt(value) as ConfigSchema[K]
}
} else if (key === 'wxidConfigs' && value && typeof value === 'object') {
if (inLockMode) {
toStore = this.lockEncryptWxidConfigs(value as any) as ConfigSchema[K]
} else {
toStore = this.encryptWxidConfigs(value as any) as ConfigSchema[K]
}
}
this.store.set(key, toStore)
}
getAll(): ConfigSchema {
// === 加密/解密工具 ===
private safeEncrypt(plaintext: string): string {
if (!plaintext) return ''
if (plaintext.startsWith(SAFE_PREFIX)) return plaintext
if (!safeStorage.isEncryptionAvailable()) return plaintext
const encrypted = safeStorage.encryptString(plaintext)
return SAFE_PREFIX + encrypted.toString('base64')
}
private safeDecrypt(stored: string): string {
if (!stored) return ''
if (!stored.startsWith(SAFE_PREFIX)) return stored
if (!safeStorage.isEncryptionAvailable()) return ''
try {
const buf = Buffer.from(stored.slice(SAFE_PREFIX.length), 'base64')
return safeStorage.decryptString(buf)
} catch {
return ''
}
}
private lockEncrypt(plaintext: string, password: string): string {
if (!plaintext) return ''
const salt = crypto.randomBytes(16)
const iv = crypto.randomBytes(12)
const derivedKey = crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha256')
const cipher = crypto.createCipheriv('aes-256-gcm', derivedKey, iv)
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
const authTag = cipher.getAuthTag()
const combined = Buffer.concat([salt, iv, authTag, encrypted])
return LOCK_PREFIX + combined.toString('base64')
}
private lockDecrypt(stored: string, password: string): string | null {
if (!stored || !stored.startsWith(LOCK_PREFIX)) return null
try {
const combined = Buffer.from(stored.slice(LOCK_PREFIX.length), 'base64')
const salt = combined.subarray(0, 16)
const iv = combined.subarray(16, 28)
const authTag = combined.subarray(28, 44)
const ciphertext = combined.subarray(44)
const derivedKey = crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha256')
const decipher = crypto.createDecipheriv('aes-256-gcm', derivedKey, iv)
decipher.setAuthTag(authTag)
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()])
return decrypted.toString('utf8')
} catch {
return null
}
}
// 通过尝试解密 lock: 字段来验证密码是否正确(当 authPassword 被删除时使用)
private verifyPasswordByDecrypt(password: string): boolean {
// 依次尝试解密任意一个 lock: 字段GCM authTag 会验证密码正确性
const lockFields = ['decryptKey', 'imageAesKey', 'imageXorKey'] as const
for (const key of lockFields) {
const raw: any = this.store.get(key as any)
if (typeof raw === 'string' && raw.startsWith(LOCK_PREFIX)) {
const result = this.lockDecrypt(raw, password)
// lockDecrypt 返回 null 表示解密失败(密码错误),非 null 表示成功
return result !== null
}
}
return false
}
// === wxidConfigs 加密/解密 ===
private encryptWxidConfigs(configs: ConfigSchema['wxidConfigs']): ConfigSchema['wxidConfigs'] {
const result: ConfigSchema['wxidConfigs'] = {}
for (const [wxid, cfg] of Object.entries(configs)) {
result[wxid] = { ...cfg }
if (cfg.decryptKey) result[wxid].decryptKey = this.safeEncrypt(cfg.decryptKey)
if (cfg.imageAesKey) result[wxid].imageAesKey = this.safeEncrypt(cfg.imageAesKey)
if (cfg.imageXorKey !== undefined) {
(result[wxid] as any).imageXorKey = this.safeEncrypt(String(cfg.imageXorKey))
}
}
return result
}
private decryptLockedWxidConfigs(password: string): void {
const wxidConfigs = this.store.get('wxidConfigs')
if (!wxidConfigs || typeof wxidConfigs !== 'object') return
for (const [wxid, cfg] of Object.entries(wxidConfigs) as [string, any][]) {
if (cfg.decryptKey && typeof cfg.decryptKey === 'string' && cfg.decryptKey.startsWith(LOCK_PREFIX)) {
const d = this.lockDecrypt(cfg.decryptKey, password)
if (d !== null) this.unlockedKeys.set(`wxid:${wxid}:decryptKey`, d)
}
if (cfg.imageAesKey && typeof cfg.imageAesKey === 'string' && cfg.imageAesKey.startsWith(LOCK_PREFIX)) {
const d = this.lockDecrypt(cfg.imageAesKey, password)
if (d !== null) this.unlockedKeys.set(`wxid:${wxid}:imageAesKey`, d)
}
if (cfg.imageXorKey && typeof cfg.imageXorKey === 'string' && cfg.imageXorKey.startsWith(LOCK_PREFIX)) {
const d = this.lockDecrypt(cfg.imageXorKey, password)
if (d !== null) this.unlockedKeys.set(`wxid:${wxid}:imageXorKey`, Number(d))
}
}
}
private decryptWxidConfigs(configs: ConfigSchema['wxidConfigs']): ConfigSchema['wxidConfigs'] {
const result: ConfigSchema['wxidConfigs'] = {}
for (const [wxid, cfg] of Object.entries(configs) as [string, any][]) {
result[wxid] = { ...cfg, updatedAt: cfg.updatedAt }
// decryptKey
if (typeof cfg.decryptKey === 'string') {
if (cfg.decryptKey.startsWith(LOCK_PREFIX)) {
result[wxid].decryptKey = this.unlockedKeys.get(`wxid:${wxid}:decryptKey`) ?? ''
} else {
result[wxid].decryptKey = this.safeDecrypt(cfg.decryptKey)
}
}
// imageAesKey
if (typeof cfg.imageAesKey === 'string') {
if (cfg.imageAesKey.startsWith(LOCK_PREFIX)) {
result[wxid].imageAesKey = this.unlockedKeys.get(`wxid:${wxid}:imageAesKey`) ?? ''
} else {
result[wxid].imageAesKey = this.safeDecrypt(cfg.imageAesKey)
}
}
// imageXorKey
if (typeof cfg.imageXorKey === 'string') {
if (cfg.imageXorKey.startsWith(LOCK_PREFIX)) {
result[wxid].imageXorKey = this.unlockedKeys.get(`wxid:${wxid}:imageXorKey`) ?? 0
} else if (cfg.imageXorKey.startsWith(SAFE_PREFIX)) {
const num = Number(this.safeDecrypt(cfg.imageXorKey))
result[wxid].imageXorKey = Number.isFinite(num) ? num : 0
}
}
}
return result
}
private lockEncryptWxidConfigs(configs: ConfigSchema['wxidConfigs']): ConfigSchema['wxidConfigs'] {
const result: ConfigSchema['wxidConfigs'] = {}
for (const [wxid, cfg] of Object.entries(configs)) {
result[wxid] = { ...cfg }
if (cfg.decryptKey) result[wxid].decryptKey = this.lockEncrypt(cfg.decryptKey, this.unlockPassword!) as any
if (cfg.imageAesKey) result[wxid].imageAesKey = this.lockEncrypt(cfg.imageAesKey, this.unlockPassword!) as any
if (cfg.imageXorKey !== undefined) {
(result[wxid] as any).imageXorKey = this.lockEncrypt(String(cfg.imageXorKey), this.unlockPassword!)
}
}
return result
}
// === 业务方法 ===
enableLock(password: string): { success: boolean; error?: string } {
try {
// 先读取当前所有明文密钥
const decryptKey = this.get('decryptKey')
const imageAesKey = this.get('imageAesKey')
const imageXorKey = this.get('imageXorKey')
const wxidConfigs = this.get('wxidConfigs')
// 存储密码 hashsafeStorage 加密)
const passwordHash = crypto.createHash('sha256').update(password).digest('hex')
this.store.set('authPassword', this.safeEncrypt(passwordHash) as any)
this.store.set('authEnabled', this.safeEncrypt('true') as any)
// 设置运行时状态
this.unlockPassword = password
this.unlockedKeys.set('decryptKey', decryptKey)
this.unlockedKeys.set('imageAesKey', imageAesKey)
this.unlockedKeys.set('imageXorKey', imageXorKey)
// 用密码派生密钥重新加密所有敏感字段
if (decryptKey) this.store.set('decryptKey', this.lockEncrypt(String(decryptKey), password) as any)
if (imageAesKey) this.store.set('imageAesKey', this.lockEncrypt(String(imageAesKey), password) as any)
if (imageXorKey !== undefined) this.store.set('imageXorKey', this.lockEncrypt(String(imageXorKey), password) as any)
// 处理 wxidConfigs 中的嵌套密钥
if (wxidConfigs && Object.keys(wxidConfigs).length > 0) {
const lockedConfigs = this.lockEncryptWxidConfigs(wxidConfigs)
this.store.set('wxidConfigs', lockedConfigs)
for (const [wxid, cfg] of Object.entries(wxidConfigs)) {
if (cfg.decryptKey) this.unlockedKeys.set(`wxid:${wxid}:decryptKey`, cfg.decryptKey)
if (cfg.imageAesKey) this.unlockedKeys.set(`wxid:${wxid}:imageAesKey`, cfg.imageAesKey)
if (cfg.imageXorKey !== undefined) this.unlockedKeys.set(`wxid:${wxid}:imageXorKey`, cfg.imageXorKey)
}
}
return { success: true }
} catch (e: any) {
return { success: false, error: e.message }
}
}
unlock(password: string): { success: boolean; error?: string } {
try {
// 验证密码
const storedHash = this.safeDecrypt(this.store.get('authPassword') as any)
const inputHash = crypto.createHash('sha256').update(password).digest('hex')
if (storedHash && storedHash !== inputHash) {
// authPassword 存在但密码不匹配
return { success: false, error: '密码错误' }
}
if (!storedHash) {
// authPassword 被删除/损坏,尝试用密码直接解密 lock: 字段来验证
const verified = this.verifyPasswordByDecrypt(password)
if (!verified) {
return { success: false, error: '密码错误' }
}
// 密码正确,自愈 authPassword
const newHash = crypto.createHash('sha256').update(password).digest('hex')
this.store.set('authPassword', this.safeEncrypt(newHash) as any)
this.store.set('authEnabled', this.safeEncrypt('true') as any)
}
// 解密所有 lock: 字段到内存缓存
const rawDecryptKey: any = this.store.get('decryptKey')
if (typeof rawDecryptKey === 'string' && rawDecryptKey.startsWith(LOCK_PREFIX)) {
const d = this.lockDecrypt(rawDecryptKey, password)
if (d !== null) this.unlockedKeys.set('decryptKey', d)
}
const rawImageAesKey: any = this.store.get('imageAesKey')
if (typeof rawImageAesKey === 'string' && rawImageAesKey.startsWith(LOCK_PREFIX)) {
const d = this.lockDecrypt(rawImageAesKey, password)
if (d !== null) this.unlockedKeys.set('imageAesKey', d)
}
const rawImageXorKey: any = this.store.get('imageXorKey')
if (typeof rawImageXorKey === 'string' && rawImageXorKey.startsWith(LOCK_PREFIX)) {
const d = this.lockDecrypt(rawImageXorKey, password)
if (d !== null) this.unlockedKeys.set('imageXorKey', Number(d))
}
// 解密 wxidConfigs 嵌套密钥
this.decryptLockedWxidConfigs(password)
// 保留密码供 set() 使用
this.unlockPassword = password
return { success: true }
} catch (e: any) {
return { success: false, error: e.message }
}
}
disableLock(password: string): { success: boolean; error?: string } {
try {
// 验证密码
const storedHash = this.safeDecrypt(this.store.get('authPassword') as any)
const inputHash = crypto.createHash('sha256').update(password).digest('hex')
if (storedHash !== inputHash) {
return { success: false, error: '密码错误' }
}
// 先解密所有 lock: 字段
if (this.unlockedKeys.size === 0) {
this.unlock(password)
}
// 将所有密钥转回 safe: 格式
const decryptKey = this.unlockedKeys.get('decryptKey')
const imageAesKey = this.unlockedKeys.get('imageAesKey')
const imageXorKey = this.unlockedKeys.get('imageXorKey')
if (decryptKey) this.store.set('decryptKey', this.safeEncrypt(String(decryptKey)) as any)
if (imageAesKey) this.store.set('imageAesKey', this.safeEncrypt(String(imageAesKey)) as any)
if (imageXorKey !== undefined) this.store.set('imageXorKey', this.safeEncrypt(String(imageXorKey)) as any)
// 转换 wxidConfigs
const wxidConfigs = this.get('wxidConfigs')
if (wxidConfigs && Object.keys(wxidConfigs).length > 0) {
const safeConfigs = this.encryptWxidConfigs(wxidConfigs)
this.store.set('wxidConfigs', safeConfigs)
}
// 清除 auth 字段
this.store.set('authEnabled', false as any)
this.store.set('authPassword', '' as any)
this.store.set('authUseHello', false as any)
this.store.set('authHelloSecret', '' as any)
// 清除运行时状态
this.unlockedKeys.clear()
this.unlockPassword = null
return { success: true }
} catch (e: any) {
return { success: false, error: e.message }
}
}
changePassword(oldPassword: string, newPassword: string): { success: boolean; error?: string } {
try {
// 验证旧密码
const storedHash = this.safeDecrypt(this.store.get('authPassword') as any)
const oldHash = crypto.createHash('sha256').update(oldPassword).digest('hex')
if (storedHash !== oldHash) {
return { success: false, error: '旧密码错误' }
}
// 确保已解锁
if (this.unlockedKeys.size === 0) {
this.unlock(oldPassword)
}
// 用新密码重新加密所有密钥
const decryptKey = this.unlockedKeys.get('decryptKey')
const imageAesKey = this.unlockedKeys.get('imageAesKey')
const imageXorKey = this.unlockedKeys.get('imageXorKey')
if (decryptKey) this.store.set('decryptKey', this.lockEncrypt(String(decryptKey), newPassword) as any)
if (imageAesKey) this.store.set('imageAesKey', this.lockEncrypt(String(imageAesKey), newPassword) as any)
if (imageXorKey !== undefined) this.store.set('imageXorKey', this.lockEncrypt(String(imageXorKey), newPassword) as any)
// 重新加密 wxidConfigs
const wxidConfigs = this.get('wxidConfigs')
if (wxidConfigs && Object.keys(wxidConfigs).length > 0) {
this.unlockPassword = newPassword
const lockedConfigs = this.lockEncryptWxidConfigs(wxidConfigs)
this.store.set('wxidConfigs', lockedConfigs)
}
// 更新密码 hash
const newHash = crypto.createHash('sha256').update(newPassword).digest('hex')
this.store.set('authPassword', this.safeEncrypt(newHash) as any)
// 更新 Hello secret如果启用了 Hello
const useHello = this.get('authUseHello')
if (useHello) {
this.store.set('authHelloSecret', this.safeEncrypt(newPassword) as any)
}
this.unlockPassword = newPassword
return { success: true }
} catch (e: any) {
return { success: false, error: e.message }
}
}
// === Hello 相关 ===
setHelloSecret(password: string): void {
this.store.set('authHelloSecret', this.safeEncrypt(password) as any)
this.store.set('authUseHello', this.safeEncrypt('true') as any)
}
getHelloSecret(): string {
const raw: any = this.store.get('authHelloSecret')
if (!raw || typeof raw !== 'string') return ''
return this.safeDecrypt(raw)
}
clearHelloSecret(): void {
this.store.set('authHelloSecret', '' as any)
this.store.set('authUseHello', this.safeEncrypt('false') as any)
}
// === 迁移 ===
private migrateAuthFields(): void {
// 将旧版明文 auth 字段迁移为 safeStorage 加密格式
// 如果已经是 safe: 或 lock: 前缀则跳过
const rawEnabled: any = this.store.get('authEnabled')
if (typeof rawEnabled === 'boolean') {
this.store.set('authEnabled', this.safeEncrypt(String(rawEnabled)) as any)
}
const rawUseHello: any = this.store.get('authUseHello')
if (typeof rawUseHello === 'boolean') {
this.store.set('authUseHello', this.safeEncrypt(String(rawUseHello)) as any)
}
const rawPassword: any = this.store.get('authPassword')
if (typeof rawPassword === 'string' && rawPassword && !rawPassword.startsWith(SAFE_PREFIX)) {
this.store.set('authPassword', this.safeEncrypt(rawPassword) as any)
}
// 迁移敏感密钥字段(明文 → safe:
for (const key of LOCKABLE_STRING_KEYS) {
const raw: any = this.store.get(key as any)
if (typeof raw === 'string' && raw && !raw.startsWith(SAFE_PREFIX) && !raw.startsWith(LOCK_PREFIX)) {
this.store.set(key as any, this.safeEncrypt(raw) as any)
}
}
// imageXorKey: 数字 → safe:
const rawXor: any = this.store.get('imageXorKey')
if (typeof rawXor === 'number' && rawXor !== 0) {
this.store.set('imageXorKey', this.safeEncrypt(String(rawXor)) as any)
}
// wxidConfigs 中的嵌套密钥
const wxidConfigs: any = this.store.get('wxidConfigs')
if (wxidConfigs && typeof wxidConfigs === 'object') {
let changed = false
for (const [_wxid, cfg] of Object.entries(wxidConfigs) as [string, any][]) {
if (cfg.decryptKey && typeof cfg.decryptKey === 'string' && !cfg.decryptKey.startsWith(SAFE_PREFIX) && !cfg.decryptKey.startsWith(LOCK_PREFIX)) {
cfg.decryptKey = this.safeEncrypt(cfg.decryptKey)
changed = true
}
if (cfg.imageAesKey && typeof cfg.imageAesKey === 'string' && !cfg.imageAesKey.startsWith(SAFE_PREFIX) && !cfg.imageAesKey.startsWith(LOCK_PREFIX)) {
cfg.imageAesKey = this.safeEncrypt(cfg.imageAesKey)
changed = true
}
if (typeof cfg.imageXorKey === 'number' && cfg.imageXorKey !== 0) {
cfg.imageXorKey = this.safeEncrypt(String(cfg.imageXorKey))
changed = true
}
}
if (changed) {
this.store.set('wxidConfigs', wxidConfigs)
}
}
}
// === 验证 ===
verifyAuthEnabled(): boolean {
// 先检查 authEnabled 字段
const rawEnabled: any = this.store.get('authEnabled')
if (typeof rawEnabled === 'string' && rawEnabled.startsWith(SAFE_PREFIX)) {
if (this.safeDecrypt(rawEnabled) === 'true') return true
}
// 即使 authEnabled 被删除/篡改,如果密钥是 lock: 格式,说明曾开启过应用锁
const rawDecryptKey: any = this.store.get('decryptKey')
if (typeof rawDecryptKey === 'string' && rawDecryptKey.startsWith(LOCK_PREFIX)) {
return true
}
return false
}
// === 工具方法 ===
/**
* 获取当前 wxid 对应的图片密钥,优先从 wxidConfigs 中取,找不到则回退到全局配置
*/
getImageKeysForCurrentWxid(): { xorKey: unknown; aesKey: string } {
const wxid = this.get('myWxid')
if (wxid) {
const wxidConfigs = this.get('wxidConfigs')
const cfg = wxidConfigs?.[wxid]
if (cfg && (cfg.imageXorKey !== undefined || cfg.imageAesKey)) {
return {
xorKey: cfg.imageXorKey ?? this.get('imageXorKey'),
aesKey: cfg.imageAesKey ?? this.get('imageAesKey')
}
}
}
return {
xorKey: this.get('imageXorKey'),
aesKey: this.get('imageAesKey')
}
}
getCacheBasePath(): string {
return join(app.getPath('userData'), 'cache')
}
getAll(): Partial<ConfigSchema> {
return this.store.store
}
clear(): void {
this.store.clear()
this.unlockedKeys.clear()
this.unlockPassword = null
}
}

View File

@@ -1,6 +1,7 @@
import { join, dirname } from 'path'
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
import { app } from 'electron'
import { ConfigService } from './config'
export interface ContactCacheEntry {
displayName?: string
@@ -15,7 +16,7 @@ export class ContactCacheService {
constructor(cacheBasePath?: string) {
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
? cacheBasePath
: join(app.getPath('documents'), 'WeFlow')
: ConfigService.getInstance().getCacheBasePath()
this.cacheFilePath = join(basePath, 'contacts.json')
this.ensureCacheDir()
this.loadCache()

View File

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

View File

@@ -18,8 +18,7 @@ export class DbPathService {
// 微信4.x 数据目录
possiblePaths.push(join(home, 'Documents', 'xwechat_files'))
// 旧版微信数据目录
possiblePaths.push(join(home, 'Documents', 'WeChat Files'))
for (const path of possiblePaths) {
if (existsSync(path)) {
@@ -27,7 +26,7 @@ export class DbPathService {
if (rootName !== 'xwechat_files' && rootName !== 'wechat files') {
continue
}
// 检查是否有有效的账号目录
const accounts = this.findAccountDirs(path)
if (accounts.length > 0) {
@@ -47,10 +46,10 @@ export class DbPathService {
*/
findAccountDirs(rootPath: string): string[] {
const accounts: string[] = []
try {
const entries = readdirSync(rootPath)
for (const entry of entries) {
const entryPath = join(rootPath, entry)
let stat: ReturnType<typeof statSync>
@@ -59,7 +58,7 @@ export class DbPathService {
} catch {
continue
}
if (stat.isDirectory()) {
if (!this.isPotentialAccountName(entry)) continue
@@ -69,8 +68,8 @@ export class DbPathService {
}
}
}
} catch {}
} catch { }
return accounts
}
@@ -119,12 +118,54 @@ export class DbPathService {
}
}
/**
* 扫描目录名候选(仅包含下划线的文件夹,排除 all_users
*/
scanWxidCandidates(rootPath: string): WxidInfo[] {
const wxids: WxidInfo[] = []
try {
if (existsSync(rootPath)) {
const entries = readdirSync(rootPath)
for (const entry of entries) {
const entryPath = join(rootPath, entry)
let stat: ReturnType<typeof statSync>
try {
stat = statSync(entryPath)
} catch {
continue
}
if (!stat.isDirectory()) continue
const lower = entry.toLowerCase()
if (lower === 'all_users') continue
if (!entry.includes('_')) continue
wxids.push({ wxid: entry, modifiedTime: stat.mtimeMs })
}
}
if (wxids.length === 0) {
const rootName = basename(rootPath)
if (rootName.includes('_') && rootName.toLowerCase() !== 'all_users') {
const rootStat = statSync(rootPath)
wxids.push({ wxid: rootName, modifiedTime: rootStat.mtimeMs })
}
}
} catch { }
return wxids.sort((a, b) => {
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
return a.wxid.localeCompare(b.wxid)
})
}
/**
* 扫描 wxid 列表
*/
scanWxids(rootPath: string): WxidInfo[] {
const wxids: WxidInfo[] = []
try {
if (this.isAccountDir(rootPath)) {
const wxid = basename(rootPath)
@@ -133,14 +174,14 @@ export class DbPathService {
}
const accounts = this.findAccountDirs(rootPath)
for (const account of accounts) {
const fullPath = join(rootPath, account)
const modifiedTime = this.getAccountModifiedTime(fullPath)
wxids.push({ wxid: account, modifiedTime })
}
} catch {}
} catch { }
return wxids.sort((a, b) => {
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
return a.wxid.localeCompare(b.wxid)

View File

@@ -0,0 +1,802 @@
import { parentPort } from 'worker_threads'
import { wcdbService } from './wcdbService'
export interface DualReportMessage {
content: string
isSentByMe: boolean
createTime: number
createTimeStr: string
localType?: number
emojiMd5?: string
emojiCdnUrl?: string
}
export interface DualReportFirstChat {
createTime: number
createTimeStr: string
content: string
isSentByMe: boolean
senderUsername?: string
localType?: number
emojiMd5?: string
emojiCdnUrl?: string
}
export interface DualReportStats {
totalMessages: number
totalWords: number
imageCount: number
voiceCount: number
emojiCount: number
myTopEmojiMd5?: string
friendTopEmojiMd5?: string
myTopEmojiUrl?: string
friendTopEmojiUrl?: string
myTopEmojiCount?: number
friendTopEmojiCount?: number
}
export interface DualReportData {
year: number
selfName: string
selfAvatarUrl?: string
friendUsername: string
friendName: string
friendAvatarUrl?: string
firstChat: DualReportFirstChat | null
firstChatMessages?: DualReportMessage[]
yearFirstChat?: {
createTime: number
createTimeStr: string
content: string
isSentByMe: boolean
friendName: string
firstThreeMessages: DualReportMessage[]
localType?: number
emojiMd5?: string
emojiCdnUrl?: string
} | null
stats: DualReportStats
topPhrases: Array<{ phrase: string; count: number }>
myExclusivePhrases: Array<{ phrase: string; count: number }>
friendExclusivePhrases: Array<{ phrase: string; count: number }>
heatmap?: number[][]
initiative?: { initiated: number; received: number }
response?: { avg: number; fastest: number; count: number }
monthly?: Record<string, number>
streak?: { days: number; startDate: string; endDate: string }
}
class DualReportService {
private broadcastProgress(status: string, progress: number) {
if (parentPort) {
parentPort.postMessage({
type: 'dualReport:progress',
data: { status, progress }
})
}
}
private reportProgress(status: string, progress: number, onProgress?: (status: string, progress: number) => void) {
if (onProgress) {
onProgress(status, progress)
return
}
this.broadcastProgress(status, progress)
}
private cleanAccountDirName(dirName: string): string {
const trimmed = dirName.trim()
if (!trimmed) return trimmed
if (trimmed.toLowerCase().startsWith('wxid_')) {
const match = trimmed.match(/^(wxid_[^_]+)/i)
if (match) return match[1]
return trimmed
}
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
const cleaned = suffixMatch ? suffixMatch[1] : trimmed
return cleaned
}
private async ensureConnectedWithConfig(
dbPath: string,
decryptKey: string,
wxid: string
): Promise<{ success: boolean; cleanedWxid?: string; rawWxid?: string; error?: string }> {
if (!wxid) return { success: false, error: '未配置微信ID' }
if (!dbPath) return { success: false, error: '未配置数据库路径' }
if (!decryptKey) return { success: false, error: '未配置解密密钥' }
const cleanedWxid = this.cleanAccountDirName(wxid)
const ok = await wcdbService.open(dbPath, decryptKey, cleanedWxid)
if (!ok) return { success: false, error: 'WCDB 打开失败' }
return { success: true, cleanedWxid, rawWxid: wxid }
}
private decodeMessageContent(messageContent: any, compressContent: any): string {
let content = this.decodeMaybeCompressed(compressContent)
if (!content || content.length === 0) {
content = this.decodeMaybeCompressed(messageContent)
}
return content
}
private decodeMaybeCompressed(raw: any): string {
if (!raw) return ''
if (typeof raw === 'string') {
if (raw.length === 0) return ''
// 只有当字符串足够长超过16字符且看起来像 hex 时才尝试解码
// 短字符串(如 "123456" 等纯数字)容易被误判为 hex
if (raw.length > 16 && this.looksLikeHex(raw)) {
const bytes = Buffer.from(raw, 'hex')
if (bytes.length > 0) return this.decodeBinaryContent(bytes)
}
// 只有当字符串足够长超过16字符且看起来像 base64 时才尝试解码
// 短字符串(如 "test", "home" 等)容易被误判为 base64
if (raw.length > 16 && this.looksLikeBase64(raw)) {
try {
const bytes = Buffer.from(raw, 'base64')
return this.decodeBinaryContent(bytes)
} catch {
return raw
}
}
return raw
}
return ''
}
private decodeBinaryContent(data: Buffer): string {
if (data.length === 0) return ''
try {
if (data.length >= 4) {
const magic = data.readUInt32LE(0)
if (magic === 0xFD2FB528) {
const fzstd = require('fzstd')
const decompressed = fzstd.decompress(data)
return Buffer.from(decompressed).toString('utf-8')
}
}
const decoded = data.toString('utf-8')
const replacementCount = (decoded.match(/\uFFFD/g) || []).length
if (replacementCount < decoded.length * 0.2) {
return decoded.replace(/\uFFFD/g, '')
}
return data.toString('latin1')
} catch {
return ''
}
}
private looksLikeHex(s: string): boolean {
if (s.length % 2 !== 0) return false
return /^[0-9a-fA-F]+$/.test(s)
}
private looksLikeBase64(s: string): boolean {
if (s.length % 4 !== 0) return false
return /^[A-Za-z0-9+/=]+$/.test(s)
}
private formatDateTime(milliseconds: number): string {
const dt = new Date(milliseconds)
const month = String(dt.getMonth() + 1).padStart(2, '0')
const day = String(dt.getDate()).padStart(2, '0')
const hour = String(dt.getHours()).padStart(2, '0')
const minute = String(dt.getMinutes()).padStart(2, '0')
return `${month}/${day} ${hour}:${minute}`
}
private getRecordField(record: Record<string, any> | undefined | null, keys: string[]): any {
if (!record) return undefined
for (const key of keys) {
if (Object.prototype.hasOwnProperty.call(record, key) && record[key] !== undefined && record[key] !== null) {
return record[key]
}
}
return undefined
}
private coerceNumber(raw: any): number {
if (raw === undefined || raw === null || raw === '') return NaN
if (typeof raw === 'number') return raw
if (typeof raw === 'bigint') return Number(raw)
if (Buffer.isBuffer(raw)) return parseInt(raw.toString('utf-8'), 10)
if (raw instanceof Uint8Array) return parseInt(Buffer.from(raw).toString('utf-8'), 10)
const parsed = parseInt(String(raw), 10)
return Number.isFinite(parsed) ? parsed : NaN
}
private coerceString(raw: any): string {
if (raw === undefined || raw === null) return ''
if (typeof raw === 'string') return raw
if (Buffer.isBuffer(raw)) return this.decodeBinaryContent(raw)
if (raw instanceof Uint8Array) return this.decodeBinaryContent(Buffer.from(raw))
return String(raw)
}
private coerceBoolean(raw: any): boolean | undefined {
if (raw === undefined || raw === null || raw === '') return undefined
if (typeof raw === 'boolean') return raw
if (typeof raw === 'number') return raw !== 0
const normalized = String(raw).trim().toLowerCase()
if (!normalized) return undefined
if (['1', 'true', 'yes', 'me', 'self', 'mine', 'sent', 'out', 'outgoing'].includes(normalized)) return true
if (['0', 'false', 'no', 'friend', 'peer', 'other', 'recv', 'received', 'in', 'incoming'].includes(normalized)) return false
return undefined
}
private normalizeEmojiMd5(raw: string): string | undefined {
if (!raw) return undefined
const trimmed = raw.trim()
if (!trimmed) return undefined
const match = /([a-fA-F0-9]{16,64})/.exec(trimmed)
return match ? match[1].toLowerCase() : undefined
}
private normalizeEmojiUrl(raw: string): string | undefined {
if (!raw) return undefined
let url = raw.trim().replace(/&amp;/g, '&')
if (!url) return undefined
try {
if (url.includes('%')) {
url = decodeURIComponent(url)
}
} catch { }
return url || undefined
}
private extractEmojiUrl(content: string | undefined): string | undefined {
if (!content) return undefined
const direct = this.normalizeEmojiUrl(content)
if (direct && /^https?:\/\//i.test(direct)) return direct
const attrMatch = /(?:cdnurl|thumburl)\s*=\s*['"]([^'"]+)['"]/i.exec(content)
|| /(?:cdnurl|thumburl)\s*=\s*([^'"\s>]+)/i.exec(content)
if (attrMatch) return this.normalizeEmojiUrl(attrMatch[1])
const tagMatch = /<(?:cdnurl|thumburl)>([^<]+)<\/(?:cdnurl|thumburl)>/i.exec(content)
|| /(?:cdnurl|thumburl)[^>]*>([^<]+)/i.exec(content)
return this.normalizeEmojiUrl(tagMatch?.[1] || '')
}
private extractEmojiMd5(content: string | undefined): string | undefined {
if (!content) return undefined
const direct = this.normalizeEmojiMd5(content)
if (direct && direct.length >= 24) return direct
const match = /md5\s*=\s*['"]([a-fA-F0-9]{16,64})['"]/i.exec(content)
|| /md5\s*=\s*([a-fA-F0-9]{16,64})/i.exec(content)
|| /<md5>([a-fA-F0-9]{16,64})<\/md5>/i.exec(content)
return this.normalizeEmojiMd5(match?.[1] || '')
}
private resolveEmojiOwner(item: any, content: string): boolean | undefined {
const sentFlag = this.coerceBoolean(this.getRecordField(item, [
'isMe',
'is_me',
'isSent',
'is_sent',
'isSend',
'is_send',
'fromMe',
'from_me'
]))
if (sentFlag !== undefined) return sentFlag
const sideRaw = this.coerceString(this.getRecordField(item, ['side', 'sender', 'from', 'owner', 'role', 'direction'])).trim().toLowerCase()
if (sideRaw) {
if (['me', 'self', 'mine', 'out', 'outgoing', 'sent'].includes(sideRaw)) return true
if (['friend', 'peer', 'other', 'in', 'incoming', 'received', 'recv'].includes(sideRaw)) return false
}
const prefixMatch = /^\s*([01])\s*:\s*/.exec(content)
if (prefixMatch) return prefixMatch[1] === '1'
return undefined
}
private stripEmojiOwnerPrefix(content: string): string {
if (!content) return ''
return content.replace(/^\s*[01]\s*:\s*/, '')
}
private parseEmojiCandidate(item: any): { isMe?: boolean; md5?: string; url?: string; count: number } {
const rawContent = this.coerceString(this.getRecordField(item, [
'content',
'xml',
'message_content',
'messageContent',
'msg',
'payload',
'raw'
]))
const content = this.stripEmojiOwnerPrefix(rawContent)
const countRaw = this.getRecordField(item, ['count', 'cnt', 'times', 'total', 'num'])
const parsedCount = this.coerceNumber(countRaw)
const count = Number.isFinite(parsedCount) && parsedCount > 0 ? parsedCount : 0
const directMd5 = this.normalizeEmojiMd5(this.coerceString(this.getRecordField(item, [
'md5',
'emojiMd5',
'emoji_md5',
'emd5'
])))
const md5 = directMd5 || this.extractEmojiMd5(content)
const directUrl = this.normalizeEmojiUrl(this.coerceString(this.getRecordField(item, [
'cdnUrl',
'cdnurl',
'emojiUrl',
'emoji_url',
'url',
'thumbUrl',
'thumburl'
])))
const url = directUrl || this.extractEmojiUrl(content)
return {
isMe: this.resolveEmojiOwner(item, rawContent),
md5,
url,
count
}
}
private getRowInt(row: Record<string, any>, keys: string[], fallback = 0): number {
const raw = this.getRecordField(row, keys)
const parsed = this.coerceNumber(raw)
return Number.isFinite(parsed) ? parsed : fallback
}
private decodeRowMessageContent(row: Record<string, any>): string {
const messageContent = this.getRecordField(row, [
'message_content',
'messageContent',
'content',
'msg_content',
'msgContent',
'WCDB_CT_message_content',
'WCDB_CT_messageContent'
])
const compressContent = this.getRecordField(row, [
'compress_content',
'compressContent',
'compressed_content',
'WCDB_CT_compress_content',
'WCDB_CT_compressContent'
])
return this.decodeMessageContent(messageContent, compressContent)
}
private async scanEmojiTopFallback(
sessionId: string,
beginTimestamp: number,
endTimestamp: number,
rawWxid: string,
cleanedWxid: string
): Promise<{ my?: { md5: string; url?: string; count: number }; friend?: { md5: string; url?: string; count: number } }> {
const cursorResult = await wcdbService.openMessageCursor(sessionId, 500, true, beginTimestamp, endTimestamp)
if (!cursorResult.success || !cursorResult.cursor) return {}
const tallyMap = new Map<string, { isMe: boolean; md5: string; url?: string; count: number }>()
try {
let hasMore = true
while (hasMore) {
const batch = await wcdbService.fetchMessageBatch(cursorResult.cursor)
if (!batch.success || !Array.isArray(batch.rows)) break
for (const row of batch.rows) {
const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 0)
if (localType !== 47) continue
const rawContent = this.decodeRowMessageContent(row)
const content = this.stripEmojiOwnerPrefix(rawContent)
const directMd5 = this.normalizeEmojiMd5(this.coerceString(this.getRecordField(row, ['emoji_md5', 'emojiMd5', 'md5'])))
const md5 = directMd5 || this.extractEmojiMd5(content)
if (!md5) continue
const directUrl = this.normalizeEmojiUrl(this.coerceString(this.getRecordField(row, [
'emoji_cdn_url',
'emojiCdnUrl',
'cdnurl',
'cdn_url',
'emoji_url',
'emojiUrl',
'url',
'thumburl',
'thumb_url'
])))
const url = directUrl || this.extractEmojiUrl(content)
const isMe = this.resolveIsSent(row, rawWxid, cleanedWxid)
const mapKey = `${isMe ? '1' : '0'}:${md5}`
const existing = tallyMap.get(mapKey)
if (existing) {
existing.count += 1
if (!existing.url && url) existing.url = url
} else {
tallyMap.set(mapKey, { isMe, md5, url, count: 1 })
}
}
hasMore = batch.hasMore === true
}
} finally {
await wcdbService.closeMessageCursor(cursorResult.cursor)
}
let myTop: { md5: string; url?: string; count: number } | undefined
let friendTop: { md5: string; url?: string; count: number } | undefined
for (const entry of tallyMap.values()) {
if (entry.isMe) {
if (!myTop || entry.count > myTop.count) {
myTop = { md5: entry.md5, url: entry.url, count: entry.count }
}
} else if (!friendTop || entry.count > friendTop.count) {
friendTop = { md5: entry.md5, url: entry.url, count: entry.count }
}
}
return { my: myTop, friend: friendTop }
}
private async getDisplayName(username: string, fallback: string): Promise<string> {
const result = await wcdbService.getDisplayNames([username])
if (result.success && result.map) {
return result.map[username] || fallback
}
return fallback
}
private resolveIsSent(row: any, rawWxid?: string, cleanedWxid?: string): boolean {
const isSendRaw = row.computed_is_send ?? row.is_send
if (isSendRaw !== undefined && isSendRaw !== null) {
return parseInt(isSendRaw, 10) === 1
}
const sender = String(row.sender_username || row.sender || row.talker || '').toLowerCase()
if (!sender) return false
const rawLower = rawWxid ? rawWxid.toLowerCase() : ''
const cleanedLower = cleanedWxid ? cleanedWxid.toLowerCase() : ''
return !!(
sender === rawLower ||
sender === cleanedLower ||
(rawLower && rawLower.startsWith(sender + '_')) ||
(cleanedLower && cleanedLower.startsWith(sender + '_'))
)
}
private async getFirstMessages(
sessionId: string,
limit: number,
beginTimestamp: number,
endTimestamp: number
): Promise<any[]> {
const safeBegin = Math.max(0, beginTimestamp || 0)
const safeEnd = endTimestamp && endTimestamp > 0 ? endTimestamp : Math.floor(Date.now() / 1000)
const cursorResult = await wcdbService.openMessageCursor(sessionId, Math.max(1, limit), true, safeBegin, safeEnd)
if (!cursorResult.success || !cursorResult.cursor) return []
try {
const rows: any[] = []
let hasMore = true
while (hasMore && rows.length < limit) {
const batch = await wcdbService.fetchMessageBatch(cursorResult.cursor)
if (!batch.success || !batch.rows) break
for (const row of batch.rows) {
rows.push(row)
if (rows.length >= limit) break
}
hasMore = batch.hasMore === true
}
return rows.slice(0, limit)
} finally {
await wcdbService.closeMessageCursor(cursorResult.cursor)
}
}
async generateReportWithConfig(params: {
year: number
friendUsername: string
dbPath: string
decryptKey: string
wxid: string
excludeWords?: string[]
onProgress?: (status: string, progress: number) => void
}): Promise<{ success: boolean; data?: DualReportData; error?: string }> {
try {
const { year, friendUsername, dbPath, decryptKey, wxid, excludeWords, onProgress } = params
this.reportProgress('正在连接数据库...', 5, onProgress)
const conn = await this.ensureConnectedWithConfig(dbPath, decryptKey, wxid)
if (!conn.success || !conn.cleanedWxid || !conn.rawWxid) return { success: false, error: conn.error }
const cleanedWxid = conn.cleanedWxid
const rawWxid = conn.rawWxid
const reportYear = year <= 0 ? 0 : year
const isAllTime = reportYear === 0
const startTime = isAllTime ? 0 : Math.floor(new Date(reportYear, 0, 1).getTime() / 1000)
const endTime = isAllTime ? 0 : Math.floor(new Date(reportYear, 11, 31, 23, 59, 59).getTime() / 1000)
this.reportProgress('加载联系人信息...', 10, onProgress)
const friendName = await this.getDisplayName(friendUsername, friendUsername)
let myName = await this.getDisplayName(rawWxid, rawWxid)
if (myName === rawWxid && cleanedWxid && cleanedWxid !== rawWxid) {
myName = await this.getDisplayName(cleanedWxid, rawWxid)
}
const avatarCandidates = Array.from(new Set([
friendUsername,
rawWxid,
cleanedWxid
].filter(Boolean) as string[]))
let selfAvatarUrl: string | undefined
let friendAvatarUrl: string | undefined
const avatarResult = await wcdbService.getAvatarUrls(avatarCandidates)
if (avatarResult.success && avatarResult.map) {
selfAvatarUrl = avatarResult.map[rawWxid] || avatarResult.map[cleanedWxid]
friendAvatarUrl = avatarResult.map[friendUsername]
}
this.reportProgress('获取首条聊天记录...', 15, onProgress)
const firstRows = await this.getFirstMessages(friendUsername, 10, 0, 0)
let firstChat: DualReportFirstChat | null = null
if (firstRows.length > 0) {
const row = firstRows[0]
const createTime = parseInt(row.create_time || '0', 10) * 1000
const rawContent = this.decodeMessageContent(row.message_content, row.compress_content)
const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType'], 0)
let emojiMd5: string | undefined
let emojiCdnUrl: string | undefined
if (localType === 47) {
const stripped = this.stripEmojiOwnerPrefix(rawContent)
emojiMd5 = this.normalizeEmojiMd5(this.coerceString(this.getRecordField(row, ['emoji_md5', 'emojiMd5', 'md5']))) || this.extractEmojiMd5(stripped)
emojiCdnUrl = this.normalizeEmojiUrl(this.coerceString(this.getRecordField(row, ['emoji_cdn_url', 'emojiCdnUrl', 'cdnurl']))) || this.extractEmojiUrl(stripped)
}
firstChat = {
createTime,
createTimeStr: this.formatDateTime(createTime),
content: String(rawContent || ''),
isSentByMe: this.resolveIsSent(row, rawWxid, cleanedWxid),
senderUsername: row.sender_username || row.sender,
localType,
emojiMd5,
emojiCdnUrl
}
}
const firstChatMessages: DualReportMessage[] = firstRows.map((row) => {
const msgTime = parseInt(row.create_time || '0', 10) * 1000
const rawContent = this.decodeMessageContent(row.message_content, row.compress_content)
const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType'], 0)
let emojiMd5: string | undefined
let emojiCdnUrl: string | undefined
if (localType === 47) {
const stripped = this.stripEmojiOwnerPrefix(rawContent)
emojiMd5 = this.normalizeEmojiMd5(this.coerceString(this.getRecordField(row, ['emoji_md5', 'emojiMd5', 'md5']))) || this.extractEmojiMd5(stripped)
emojiCdnUrl = this.normalizeEmojiUrl(this.coerceString(this.getRecordField(row, ['emoji_cdn_url', 'emojiCdnUrl', 'cdnurl']))) || this.extractEmojiUrl(stripped)
}
return {
content: String(rawContent || ''),
isSentByMe: this.resolveIsSent(row, rawWxid, cleanedWxid),
createTime: msgTime,
createTimeStr: this.formatDateTime(msgTime),
localType,
emojiMd5,
emojiCdnUrl
}
})
let yearFirstChat: DualReportData['yearFirstChat'] = null
if (!isAllTime) {
this.reportProgress('获取今年首次聊天...', 20, onProgress)
const firstYearRows = await this.getFirstMessages(friendUsername, 10, startTime, endTime)
if (firstYearRows.length > 0) {
const firstRow = firstYearRows[0]
const createTime = parseInt(firstRow.create_time || '0', 10) * 1000
const firstThreeMessages: DualReportMessage[] = firstYearRows.map((row) => {
const msgTime = parseInt(row.create_time || '0', 10) * 1000
const rawContent = this.decodeMessageContent(row.message_content, row.compress_content)
const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType'], 0)
let emojiMd5: string | undefined
let emojiCdnUrl: string | undefined
if (localType === 47) {
const stripped = this.stripEmojiOwnerPrefix(rawContent)
emojiMd5 = this.normalizeEmojiMd5(this.coerceString(this.getRecordField(row, ['emoji_md5', 'emojiMd5', 'md5']))) || this.extractEmojiMd5(stripped)
emojiCdnUrl = this.normalizeEmojiUrl(this.coerceString(this.getRecordField(row, ['emoji_cdn_url', 'emojiCdnUrl', 'cdnurl']))) || this.extractEmojiUrl(stripped)
}
return {
content: String(rawContent || ''),
isSentByMe: this.resolveIsSent(row, rawWxid, cleanedWxid),
createTime: msgTime,
createTimeStr: this.formatDateTime(msgTime),
localType,
emojiMd5,
emojiCdnUrl
}
})
const firstRowYear = firstYearRows[0]
const rawContentYear = this.decodeMessageContent(firstRowYear.message_content, firstRowYear.compress_content)
const localTypeYear = this.getRowInt(firstRowYear, ['local_type', 'localType', 'type', 'msg_type', 'msgType'], 0)
let emojiMd5Year: string | undefined
let emojiCdnUrlYear: string | undefined
if (localTypeYear === 47) {
const stripped = this.stripEmojiOwnerPrefix(rawContentYear)
emojiMd5Year = this.normalizeEmojiMd5(this.coerceString(this.getRecordField(firstRowYear, ['emoji_md5', 'emojiMd5', 'md5']))) || this.extractEmojiMd5(stripped)
emojiCdnUrlYear = this.normalizeEmojiUrl(this.coerceString(this.getRecordField(firstRowYear, ['emoji_cdn_url', 'emojiCdnUrl', 'cdnurl']))) || this.extractEmojiUrl(stripped)
}
yearFirstChat = {
createTime,
createTimeStr: this.formatDateTime(createTime),
content: String(rawContentYear || ''),
isSentByMe: this.resolveIsSent(firstRowYear, rawWxid, cleanedWxid),
friendName,
firstThreeMessages,
localType: localTypeYear,
emojiMd5: emojiMd5Year,
emojiCdnUrl: emojiCdnUrlYear
}
}
}
this.reportProgress('统计聊天数据...', 30, onProgress)
const statsResult = await wcdbService.getDualReportStats(friendUsername, startTime, endTime)
if (!statsResult.success || !statsResult.data) {
return { success: false, error: statsResult.error || '获取双人报告统计失败' }
}
const cppData = statsResult.data
const counts = cppData.counts || {}
const stats: DualReportStats = {
totalMessages: counts.total || 0,
totalWords: counts.words || 0,
imageCount: counts.image || 0,
voiceCount: counts.voice || 0,
emojiCount: counts.emoji || 0
}
// Process Emojis to find top for me and friend
let myTopEmojiMd5: string | undefined
let myTopEmojiUrl: string | undefined
let myTopCount = -1
let friendTopEmojiMd5: string | undefined
let friendTopEmojiUrl: string | undefined
let friendTopCount = -1
if (cppData.emojis && Array.isArray(cppData.emojis)) {
for (const item of cppData.emojis) {
const candidate = this.parseEmojiCandidate(item)
if (!candidate.md5 || candidate.isMe === undefined || candidate.count <= 0) continue
if (candidate.isMe) {
if (candidate.count > myTopCount) {
myTopCount = candidate.count
myTopEmojiMd5 = candidate.md5
myTopEmojiUrl = candidate.url
}
} else if (candidate.count > friendTopCount) {
friendTopCount = candidate.count
friendTopEmojiMd5 = candidate.md5
friendTopEmojiUrl = candidate.url
}
}
}
const needsEmojiFallback = stats.emojiCount > 0 && (!myTopEmojiMd5 || !friendTopEmojiMd5)
if (needsEmojiFallback) {
const fallback = await this.scanEmojiTopFallback(friendUsername, startTime, endTime, rawWxid, cleanedWxid)
if (!myTopEmojiMd5 && fallback.my?.md5) {
myTopEmojiMd5 = fallback.my.md5
myTopEmojiUrl = myTopEmojiUrl || fallback.my.url
myTopCount = fallback.my.count
}
if (!friendTopEmojiMd5 && fallback.friend?.md5) {
friendTopEmojiMd5 = fallback.friend.md5
friendTopEmojiUrl = friendTopEmojiUrl || fallback.friend.url
friendTopCount = fallback.friend.count
}
}
const [myEmojiUrlResult, friendEmojiUrlResult] = await Promise.all([
myTopEmojiMd5 && !myTopEmojiUrl ? wcdbService.getEmoticonCdnUrl(dbPath, myTopEmojiMd5) : Promise.resolve(null),
friendTopEmojiMd5 && !friendTopEmojiUrl ? wcdbService.getEmoticonCdnUrl(dbPath, friendTopEmojiMd5) : Promise.resolve(null)
])
if (myEmojiUrlResult?.success && myEmojiUrlResult.url) myTopEmojiUrl = myEmojiUrlResult.url
if (friendEmojiUrlResult?.success && friendEmojiUrlResult.url) friendTopEmojiUrl = friendEmojiUrlResult.url
stats.myTopEmojiMd5 = myTopEmojiMd5
stats.myTopEmojiUrl = myTopEmojiUrl
stats.friendTopEmojiMd5 = friendTopEmojiMd5
stats.friendTopEmojiUrl = friendTopEmojiUrl
if (myTopCount >= 0) stats.myTopEmojiCount = myTopCount
if (friendTopCount >= 0) stats.friendTopEmojiCount = friendTopCount
if (friendTopCount >= 0) stats.friendTopEmojiCount = friendTopCount
const excludeSet = new Set(excludeWords || [])
const filterPhrases = (list: any[]) => {
return (list || []).filter((p: any) => !excludeSet.has(p.phrase))
}
const cleanPhrases = filterPhrases(cppData.phrases)
const cleanMyPhrases = filterPhrases(cppData.myPhrases)
const cleanFriendPhrases = filterPhrases(cppData.friendPhrases)
const topPhrases = cleanPhrases.map((p: any) => ({
phrase: p.phrase,
count: p.count
}))
// 计算专属词汇:一方频繁使用而另一方很少使用的词
const myPhraseMap = new Map<string, number>()
const friendPhraseMap = new Map<string, number>()
for (const p of cleanMyPhrases) {
myPhraseMap.set(p.phrase, p.count)
}
for (const p of cleanFriendPhrases) {
friendPhraseMap.set(p.phrase, p.count)
}
// 专属词汇:该方使用占比 >= 75% 且至少出现 2 次
const myExclusivePhrases: Array<{ phrase: string; count: number }> = []
const friendExclusivePhrases: Array<{ phrase: string; count: number }> = []
for (const [phrase, myCount] of myPhraseMap) {
const friendCount = friendPhraseMap.get(phrase) || 0
const total = myCount + friendCount
if (myCount >= 2 && total > 0 && myCount / total >= 0.75) {
myExclusivePhrases.push({ phrase, count: myCount })
}
}
for (const [phrase, friendCount] of friendPhraseMap) {
const myCount = myPhraseMap.get(phrase) || 0
const total = myCount + friendCount
if (friendCount >= 2 && total > 0 && friendCount / total >= 0.75) {
friendExclusivePhrases.push({ phrase, count: friendCount })
}
}
// 按频率排序,取前 20
myExclusivePhrases.sort((a, b) => b.count - a.count)
friendExclusivePhrases.sort((a, b) => b.count - a.count)
if (myExclusivePhrases.length > 20) myExclusivePhrases.length = 20
if (friendExclusivePhrases.length > 20) friendExclusivePhrases.length = 20
const reportData: DualReportData = {
year: reportYear,
selfName: myName,
selfAvatarUrl,
friendUsername,
friendName,
friendAvatarUrl,
firstChat,
firstChatMessages,
yearFirstChat,
stats,
topPhrases,
myExclusivePhrases,
friendExclusivePhrases,
heatmap: cppData.heatmap,
initiative: cppData.initiative,
response: cppData.response,
monthly: cppData.monthly,
streak: cppData.streak
} as any
this.reportProgress('双人报告生成完成', 100, onProgress)
return { success: true, data: reportData }
} catch (e) {
return { success: false, error: String(e) }
}
}
}
export const dualReportService = new DualReportService()

View File

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

@@ -25,83 +25,87 @@ body {
.page {
max-width: 1080px;
margin: 32px auto 60px;
padding: 0 20px;
margin: 0 auto;
padding: 8px 20px;
height: 100vh;
display: flex;
flex-direction: column;
}
.header {
background: var(--card);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 24px;
margin-bottom: 24px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06);
padding: 12px 20px;
flex-shrink: 0;
}
.title {
font-size: 24px;
font-size: 16px;
font-weight: 600;
margin: 0 0 8px;
margin: 0;
display: inline;
}
.meta {
color: var(--muted);
font-size: 14px;
display: flex;
flex-wrap: wrap;
gap: 12px;
font-size: 13px;
display: inline;
margin-left: 12px;
}
.meta span {
margin-right: 10px;
}
.controls {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
margin-top: 20px;
}
.control {
display: flex;
flex-direction: column;
gap: 6px;
align-items: center;
gap: 8px;
margin-top: 8px;
flex-wrap: wrap;
}
.control label {
font-size: 13px;
color: var(--muted);
}
.control input,
.control select,
.control button {
border-radius: 12px;
.controls input,
.controls button {
border-radius: 8px;
border: 1px solid var(--border);
padding: 10px 12px;
font-size: 14px;
padding: 6px 10px;
font-size: 13px;
font-family: inherit;
}
.control button {
.controls input[type="search"] {
width: 200px;
}
.controls input[type="datetime-local"] {
width: 200px;
}
.controls button {
background: var(--accent);
color: #fff;
border: none;
cursor: pointer;
transition: transform 0.1s ease;
padding: 6px 14px;
}
.control button:active {
.controls button:active {
transform: scale(0.98);
}
.stats {
font-size: 13px;
color: var(--muted);
display: flex;
align-items: flex-end;
margin-left: auto;
}
.message-list {
display: flex;
flex-direction: column;
gap: 18px;
gap: 12px;
padding: 4px 0;
}
.message {
@@ -182,6 +186,17 @@ body {
word-break: break-word;
}
.message-link-card {
color: #2563eb;
text-decoration: underline;
text-underline-offset: 2px;
word-break: break-all;
}
.message-link-card:hover {
color: #1d4ed8;
}
.inline-emoji {
width: 22px;
height: 22px;
@@ -248,50 +263,11 @@ body {
cursor: zoom-out;
}
body[data-theme="cloud-dancer"] {
--accent: #6b8cff;
--sent: #e0e7ff;
--received: #ffffff;
--border: #d8e0f7;
--bg: #f6f7fb;
}
body[data-theme="corundum-blue"] {
--accent: #2563eb;
--sent: #dbeafe;
--received: #ffffff;
--border: #c7d2fe;
--bg: #eef2ff;
}
body[data-theme="kiwi-green"] {
--accent: #16a34a;
--sent: #dcfce7;
--received: #ffffff;
--border: #bbf7d0;
--bg: #f0fdf4;
}
body[data-theme="spicy-red"] {
--accent: #e11d48;
--sent: #ffe4e6;
--received: #ffffff;
--border: #fecdd3;
--bg: #fff1f2;
}
body[data-theme="teal-water"] {
--accent: #0f766e;
--sent: #ccfbf1;
--received: #ffffff;
--border: #99f6e4;
--bg: #f0fdfa;
}
.highlight {
outline: 2px solid var(--accent);
outline-offset: 4px;
border-radius: 18px;
transition: outline-color 0.3s;
}
.empty {
@@ -299,3 +275,30 @@ body[data-theme="teal-water"] {
color: var(--muted);
padding: 40px;
}
/* Scroll Container */
.scroll-container {
flex: 1;
min-height: 0;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
margin-top: 8px;
margin-bottom: 8px;
padding: 12px;
-webkit-overflow-scrolling: touch;
}
.scroll-container::-webkit-scrollbar {
width: 6px;
}
.scroll-container::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.load-sentinel {
height: 1px;
}

View File

@@ -25,83 +25,87 @@ body {
.page {
max-width: 1080px;
margin: 32px auto 60px;
padding: 0 20px;
margin: 0 auto;
padding: 8px 20px;
height: 100vh;
display: flex;
flex-direction: column;
}
.header {
background: var(--card);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 24px;
margin-bottom: 24px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06);
padding: 12px 20px;
flex-shrink: 0;
}
.title {
font-size: 24px;
font-size: 16px;
font-weight: 600;
margin: 0 0 8px;
margin: 0;
display: inline;
}
.meta {
color: var(--muted);
font-size: 14px;
display: flex;
flex-wrap: wrap;
gap: 12px;
font-size: 13px;
display: inline;
margin-left: 12px;
}
.meta span {
margin-right: 10px;
}
.controls {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
margin-top: 20px;
}
.control {
display: flex;
flex-direction: column;
gap: 6px;
align-items: center;
gap: 8px;
margin-top: 8px;
flex-wrap: wrap;
}
.control label {
font-size: 13px;
color: var(--muted);
}
.control input,
.control select,
.control button {
border-radius: 12px;
.controls input,
.controls button {
border-radius: 8px;
border: 1px solid var(--border);
padding: 10px 12px;
font-size: 14px;
padding: 6px 10px;
font-size: 13px;
font-family: inherit;
}
.control button {
.controls input[type="search"] {
width: 200px;
}
.controls input[type="datetime-local"] {
width: 200px;
}
.controls button {
background: var(--accent);
color: #fff;
border: none;
cursor: pointer;
transition: transform 0.1s ease;
padding: 6px 14px;
}
.control button:active {
.controls button:active {
transform: scale(0.98);
}
.stats {
font-size: 13px;
color: var(--muted);
display: flex;
align-items: flex-end;
margin-left: auto;
}
.message-list {
display: flex;
flex-direction: column;
gap: 18px;
gap: 12px;
padding: 4px 0;
}
.message {
@@ -182,6 +186,17 @@ body {
word-break: break-word;
}
.message-link-card {
color: #2563eb;
text-decoration: underline;
text-underline-offset: 2px;
word-break: break-all;
}
.message-link-card:hover {
color: #1d4ed8;
}
.inline-emoji {
width: 22px;
height: 22px;
@@ -248,50 +263,11 @@ body {
cursor: zoom-out;
}
body[data-theme="cloud-dancer"] {
--accent: #6b8cff;
--sent: #e0e7ff;
--received: #ffffff;
--border: #d8e0f7;
--bg: #f6f7fb;
}
body[data-theme="corundum-blue"] {
--accent: #2563eb;
--sent: #dbeafe;
--received: #ffffff;
--border: #c7d2fe;
--bg: #eef2ff;
}
body[data-theme="kiwi-green"] {
--accent: #16a34a;
--sent: #dcfce7;
--received: #ffffff;
--border: #bbf7d0;
--bg: #f0fdf4;
}
body[data-theme="spicy-red"] {
--accent: #e11d48;
--sent: #ffe4e6;
--received: #ffffff;
--border: #fecdd3;
--bg: #fff1f2;
}
body[data-theme="teal-water"] {
--accent: #0f766e;
--sent: #ccfbf1;
--received: #ffffff;
--border: #99f6e4;
--bg: #f0fdfa;
}
.highlight {
outline: 2px solid var(--accent);
outline-offset: 4px;
border-radius: 18px;
transition: outline-color 0.3s;
}
.empty {
@@ -299,4 +275,32 @@ body[data-theme="teal-water"] {
color: var(--muted);
padding: 40px;
}
/* Scroll Container */
.scroll-container {
flex: 1;
min-height: 0;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
margin-top: 8px;
margin-bottom: 8px;
padding: 12px;
-webkit-overflow-scrolling: touch;
}
.scroll-container::-webkit-scrollbar {
width: 6px;
}
.scroll-container::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.load-sentinel {
height: 1px;
}
`;

View File

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

File diff suppressed because it is too large Load Diff

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

@@ -0,0 +1,994 @@
/**
* HTTP API 服务
* 提供 ChatLab 标准化格式的消息查询 API
*/
import * as http from 'http'
import * as fs from 'fs'
import * as path from 'path'
import { URL } from 'url'
import { chatService, Message } from './chatService'
import { wcdbService } from './wcdbService'
import { ConfigService } from './config'
import { videoService } from './videoService'
import { imageDecryptService } from './imageDecryptService'
// ChatLab 格式定义
interface ChatLabHeader {
version: string
exportedAt: number
generator: string
description?: string
}
interface ChatLabMeta {
name: string
platform: string
type: 'group' | 'private'
groupId?: string
groupAvatar?: string
ownerId?: string
}
interface ChatLabMember {
platformId: string
accountName: string
groupNickname?: string
aliases?: string[]
avatar?: string
}
interface ChatLabMessage {
sender: string
accountName: string
groupNickname?: string
timestamp: number
type: number
content: string | null
platformMessageId?: string
replyToMessageId?: string
mediaPath?: string
}
interface ChatLabData {
chatlab: ChatLabHeader
meta: ChatLabMeta
members: ChatLabMember[]
messages: ChatLabMessage[]
}
interface ApiMediaOptions {
enabled: boolean
exportImages: boolean
exportVoices: boolean
exportVideos: boolean
exportEmojis: boolean
}
type MediaKind = 'image' | 'voice' | 'video' | 'emoji'
interface ApiExportedMedia {
kind: MediaKind
fileName: string
fullPath: string
relativePath: string
}
// ChatLab 消息类型映射
const ChatLabType = {
TEXT: 0,
IMAGE: 1,
VOICE: 2,
VIDEO: 3,
FILE: 4,
EMOJI: 5,
LINK: 7,
LOCATION: 8,
RED_PACKET: 20,
TRANSFER: 21,
POKE: 22,
CALL: 23,
SHARE: 24,
REPLY: 25,
FORWARD: 26,
CONTACT: 27,
SYSTEM: 80,
RECALL: 81,
OTHER: 99
} as const
class HttpService {
private server: http.Server | null = null
private configService: ConfigService
private port: number = 5031
private running: boolean = false
private connections: Set<import('net').Socket> = new Set()
private connectionMutex: boolean = false
constructor() {
this.configService = ConfigService.getInstance()
}
/**
* 启动 HTTP 服务
*/
async start(port: number = 5031): Promise<{ success: boolean; port?: number; error?: string }> {
if (this.running && this.server) {
return { success: true, port: this.port }
}
this.port = port
return new Promise((resolve) => {
this.server = http.createServer((req, res) => this.handleRequest(req, res))
// 跟踪所有连接,以便关闭时能强制断开
this.server.on('connection', (socket) => {
// 使用互斥锁防止并发修改
if (!this.connectionMutex) {
this.connectionMutex = true
this.connections.add(socket)
this.connectionMutex = false
}
socket.on('close', () => {
// 使用互斥锁防止并发修改
if (!this.connectionMutex) {
this.connectionMutex = true
this.connections.delete(socket)
this.connectionMutex = false
}
})
})
this.server.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EADDRINUSE') {
console.error(`[HttpService] Port ${this.port} is already in use`)
resolve({ success: false, error: `Port ${this.port} is already in use` })
} else {
console.error('[HttpService] Server error:', err)
resolve({ success: false, error: err.message })
}
})
this.server.listen(this.port, '127.0.0.1', () => {
this.running = true
console.log(`[HttpService] HTTP API server started on http://127.0.0.1:${this.port}`)
resolve({ success: true, port: this.port })
})
})
}
/**
* 停止 HTTP 服务
*/
async stop(): Promise<void> {
return new Promise((resolve) => {
if (this.server) {
// 使用互斥锁保护连接集合操作
this.connectionMutex = true
const socketsToClose = Array.from(this.connections)
this.connections.clear()
this.connectionMutex = false
// 强制关闭所有活动连接
for (const socket of socketsToClose) {
try {
socket.destroy()
} catch (err) {
console.error('[HttpService] Error destroying socket:', err)
}
}
this.server.close(() => {
this.running = false
this.server = null
console.log('[HttpService] HTTP API server stopped')
resolve()
})
} else {
this.running = false
resolve()
}
})
}
/**
* 检查服务是否运行
*/
isRunning(): boolean {
return this.running
}
/**
* 获取当前端口
*/
getPort(): number {
return this.port
}
getDefaultMediaExportPath(): string {
return this.getApiMediaExportPath()
}
/**
* 处理 HTTP 请求
*/
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
// 设置 CORS 头
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
if (req.method === 'OPTIONS') {
res.writeHead(204)
res.end()
return
}
const url = new URL(req.url || '/', `http://127.0.0.1:${this.port}`)
const pathname = url.pathname
try {
// 路由处理
if (pathname === '/health' || pathname === '/api/v1/health') {
this.sendJson(res, { status: 'ok' })
} else if (pathname === '/api/v1/messages') {
await this.handleMessages(url, res)
} else if (pathname === '/api/v1/sessions') {
await this.handleSessions(url, res)
} else if (pathname === '/api/v1/contacts') {
await this.handleContacts(url, res)
} else if (pathname.startsWith('/api/v1/media/')) {
this.handleMediaRequest(pathname, res)
} else {
this.sendError(res, 404, 'Not Found')
}
} catch (error) {
console.error('[HttpService] Request error:', error)
this.sendError(res, 500, String(error))
}
}
private handleMediaRequest(pathname: string, res: http.ServerResponse): void {
const mediaBasePath = this.getApiMediaExportPath()
const relativePath = pathname.replace('/api/v1/media/', '')
const fullPath = path.join(mediaBasePath, relativePath)
if (!fs.existsSync(fullPath)) {
this.sendError(res, 404, 'Media not found')
return
}
const ext = path.extname(fullPath).toLowerCase()
const mimeTypes: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.wav': 'audio/wav',
'.mp3': 'audio/mpeg',
'.mp4': 'video/mp4'
}
const contentType = mimeTypes[ext] || 'application/octet-stream'
try {
const fileBuffer = fs.readFileSync(fullPath)
res.setHeader('Content-Type', contentType)
res.setHeader('Content-Length', fileBuffer.length)
res.writeHead(200)
res.end(fileBuffer)
} catch (e) {
this.sendError(res, 500, 'Failed to read media file')
}
}
/**
* 批量获取消息(循环游标直到满足 limit
* 绕过 chatService 的单 batch 限制,直接操作 wcdbService 游标
*/
private async fetchMessagesBatch(
talker: string,
offset: number,
limit: number,
startTime: number,
endTime: number,
ascending: boolean
): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; error?: string }> {
try {
// 使用固定 batch 大小(与 limit 相同或最多 500来减少循环次数
const batchSize = Math.min(limit, 500)
const beginTimestamp = startTime > 10000000000 ? Math.floor(startTime / 1000) : startTime
const endTimestamp = endTime > 10000000000 ? Math.floor(endTime / 1000) : endTime
const cursorResult = await wcdbService.openMessageCursor(talker, batchSize, ascending, beginTimestamp, endTimestamp)
if (!cursorResult.success || !cursorResult.cursor) {
return { success: false, error: cursorResult.error || '打开消息游标失败' }
}
const cursor = cursorResult.cursor
try {
const allRows: Record<string, any>[] = []
let hasMore = true
let skipped = 0
// 循环获取消息,处理 offset 跳过 + limit 累积
while (allRows.length < limit && hasMore) {
const batch = await wcdbService.fetchMessageBatch(cursor)
if (!batch.success || !batch.rows || batch.rows.length === 0) {
hasMore = false
break
}
let rows = batch.rows
hasMore = batch.hasMore === true
// 处理 offset跳过前 N 条
if (skipped < offset) {
const remaining = offset - skipped
if (remaining >= rows.length) {
skipped += rows.length
continue
}
rows = rows.slice(remaining)
skipped = offset
}
allRows.push(...rows)
}
const trimmedRows = allRows.slice(0, limit)
const finalHasMore = hasMore || allRows.length > limit
const messages = chatService.mapRowsToMessagesForApi(trimmedRows)
return { success: true, messages, hasMore: finalHasMore }
} finally {
await wcdbService.closeMessageCursor(cursor)
}
} catch (e) {
console.error('[HttpService] fetchMessagesBatch error:', e)
return { success: false, error: String(e) }
}
}
/**
* Query param helpers.
*/
private parseIntParam(value: string | null, defaultValue: number, min: number, max: number): number {
const parsed = parseInt(value || '', 10)
if (!Number.isFinite(parsed)) return defaultValue
return Math.min(Math.max(parsed, min), max)
}
private parseBooleanParam(url: URL, keys: string[], defaultValue: boolean = false): boolean {
for (const key of keys) {
const raw = url.searchParams.get(key)
if (raw === null) continue
const normalized = raw.trim().toLowerCase()
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true
if (['0', 'false', 'no', 'off'].includes(normalized)) return false
}
return defaultValue
}
private parseMediaOptions(url: URL): ApiMediaOptions {
const mediaEnabled = this.parseBooleanParam(url, ['media', 'meiti'], false)
if (!mediaEnabled) {
return {
enabled: false,
exportImages: false,
exportVoices: false,
exportVideos: false,
exportEmojis: false
}
}
return {
enabled: true,
exportImages: this.parseBooleanParam(url, ['image', 'tupian'], true),
exportVoices: this.parseBooleanParam(url, ['voice', 'vioce'], true),
exportVideos: this.parseBooleanParam(url, ['video'], true),
exportEmojis: this.parseBooleanParam(url, ['emoji'], true)
}
}
private async handleMessages(url: URL, res: http.ServerResponse): Promise<void> {
const talker = (url.searchParams.get('talker') || '').trim()
const limit = this.parseIntParam(url.searchParams.get('limit'), 100, 1, 10000)
const offset = this.parseIntParam(url.searchParams.get('offset'), 0, 0, Number.MAX_SAFE_INTEGER)
const keyword = (url.searchParams.get('keyword') || '').trim().toLowerCase()
const startParam = url.searchParams.get('start')
const endParam = url.searchParams.get('end')
const chatlab = this.parseBooleanParam(url, ['chatlab'], false)
const formatParam = (url.searchParams.get('format') || '').trim().toLowerCase()
const format = formatParam || (chatlab ? 'chatlab' : 'json')
const mediaOptions = this.parseMediaOptions(url)
if (!talker) {
this.sendError(res, 400, 'Missing required parameter: talker')
return
}
if (format !== 'json' && format !== 'chatlab') {
this.sendError(res, 400, 'Invalid format, supported: json/chatlab')
return
}
const startTime = this.parseTimeParam(startParam)
const endTime = this.parseTimeParam(endParam, true)
const queryOffset = keyword ? 0 : offset
const queryLimit = keyword ? 10000 : limit
const result = await this.fetchMessagesBatch(talker, queryOffset, queryLimit, startTime, endTime, false)
if (!result.success || !result.messages) {
this.sendError(res, 500, result.error || 'Failed to get messages')
return
}
let messages = result.messages
let hasMore = result.hasMore === true
if (keyword) {
const filtered = messages.filter((msg) => {
const content = (msg.parsedContent || msg.rawContent || '').toLowerCase()
return content.includes(keyword)
})
const endIndex = offset + limit
hasMore = filtered.length > endIndex
messages = filtered.slice(offset, endIndex)
}
const mediaMap = mediaOptions.enabled
? await this.exportMediaForMessages(messages, talker, mediaOptions)
: new Map<number, ApiExportedMedia>()
const displayNames = await this.getDisplayNames([talker])
const talkerName = displayNames[talker] || talker
if (format === 'chatlab') {
const chatLabData = await this.convertToChatLab(messages, talker, talkerName, mediaMap)
this.sendJson(res, {
...chatLabData,
media: {
enabled: mediaOptions.enabled,
exportPath: this.getApiMediaExportPath(),
count: mediaMap.size
}
})
return
}
const apiMessages = messages.map((msg) => this.toApiMessage(msg, mediaMap.get(msg.localId)))
this.sendJson(res, {
success: true,
talker,
count: apiMessages.length,
hasMore,
media: {
enabled: mediaOptions.enabled,
exportPath: this.getApiMediaExportPath(),
count: mediaMap.size
},
messages: apiMessages
})
}
/**
* 处理会话列表查询
* GET /api/v1/sessions?keyword=xxx&limit=100
*/
private async handleSessions(url: URL, res: http.ServerResponse): Promise<void> {
const keyword = (url.searchParams.get('keyword') || '').trim()
const limit = this.parseIntParam(url.searchParams.get('limit'), 100, 1, 10000)
try {
const sessions = await chatService.getSessions()
if (!sessions.success || !sessions.sessions) {
this.sendError(res, 500, sessions.error || 'Failed to get sessions')
return
}
let filteredSessions = sessions.sessions
if (keyword) {
const lowerKeyword = keyword.toLowerCase()
filteredSessions = sessions.sessions.filter(s =>
s.username.toLowerCase().includes(lowerKeyword) ||
(s.displayName && s.displayName.toLowerCase().includes(lowerKeyword))
)
}
// 应用 limit
const limitedSessions = filteredSessions.slice(0, limit)
this.sendJson(res, {
success: true,
count: limitedSessions.length,
sessions: limitedSessions.map(s => ({
username: s.username,
displayName: s.displayName,
type: s.type,
lastTimestamp: s.lastTimestamp,
unreadCount: s.unreadCount
}))
})
} catch (error) {
this.sendError(res, 500, String(error))
}
}
/**
* 处理联系人查询
* GET /api/v1/contacts?keyword=xxx&limit=100
*/
private async handleContacts(url: URL, res: http.ServerResponse): Promise<void> {
const keyword = (url.searchParams.get('keyword') || '').trim()
const limit = this.parseIntParam(url.searchParams.get('limit'), 100, 1, 10000)
try {
const contacts = await chatService.getContacts()
if (!contacts.success || !contacts.contacts) {
this.sendError(res, 500, contacts.error || 'Failed to get contacts')
return
}
let filteredContacts = contacts.contacts
if (keyword) {
const lowerKeyword = keyword.toLowerCase()
filteredContacts = contacts.contacts.filter(c =>
c.username.toLowerCase().includes(lowerKeyword) ||
(c.nickname && c.nickname.toLowerCase().includes(lowerKeyword)) ||
(c.remark && c.remark.toLowerCase().includes(lowerKeyword)) ||
(c.displayName && c.displayName.toLowerCase().includes(lowerKeyword))
)
}
const limited = filteredContacts.slice(0, limit)
this.sendJson(res, {
success: true,
count: limited.length,
contacts: limited
})
} catch (error) {
this.sendError(res, 500, String(error))
}
}
private getApiMediaExportPath(): string {
return path.join(this.configService.getCacheBasePath(), 'api-media')
}
private sanitizeFileName(value: string, fallback: string): string {
const safe = (value || '')
.trim()
.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_')
.replace(/\.+$/g, '')
return safe || fallback
}
private ensureDir(dirPath: string): void {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true })
}
}
private detectImageExt(buffer: Buffer): string {
if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return '.jpg'
if (buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return '.png'
if (buffer.length >= 6) {
const sig6 = buffer.subarray(0, 6).toString('ascii')
if (sig6 === 'GIF87a' || sig6 === 'GIF89a') return '.gif'
}
if (buffer.length >= 12 && buffer.subarray(0, 4).toString('ascii') === 'RIFF' && buffer.subarray(8, 12).toString('ascii') === 'WEBP') return '.webp'
if (buffer.length >= 2 && buffer[0] === 0x42 && buffer[1] === 0x4d) return '.bmp'
return '.jpg'
}
private async exportMediaForMessages(
messages: Message[],
talker: string,
options: ApiMediaOptions
): Promise<Map<number, ApiExportedMedia>> {
const mediaMap = new Map<number, ApiExportedMedia>()
if (!options.enabled || messages.length === 0) {
return mediaMap
}
const sessionDir = path.join(this.getApiMediaExportPath(), this.sanitizeFileName(talker, 'session'))
this.ensureDir(sessionDir)
for (const msg of messages) {
const exported = await this.exportMediaForMessage(msg, talker, sessionDir, options)
if (exported) {
mediaMap.set(msg.localId, exported)
}
}
return mediaMap
}
private async exportMediaForMessage(
msg: Message,
talker: string,
sessionDir: string,
options: ApiMediaOptions
): Promise<ApiExportedMedia | null> {
try {
if (msg.localType === 3 && options.exportImages) {
const result = await imageDecryptService.decryptImage({
sessionId: talker,
imageMd5: msg.imageMd5,
imageDatName: msg.imageDatName,
force: true
})
if (result.success && result.localPath) {
let imagePath = result.localPath
if (imagePath.startsWith('data:')) {
const base64Match = imagePath.match(/^data:[^;]+;base64,(.+)$/)
if (base64Match) {
const imageBuffer = Buffer.from(base64Match[1], 'base64')
const ext = this.detectImageExt(imageBuffer)
const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`)
const fileName = `${fileBase}${ext}`
const targetDir = path.join(sessionDir, 'images')
const fullPath = path.join(targetDir, fileName)
this.ensureDir(targetDir)
if (!fs.existsSync(fullPath)) {
fs.writeFileSync(fullPath, imageBuffer)
}
const relativePath = `${this.sanitizeFileName(talker, 'session')}/images/${fileName}`
return { kind: 'image', fileName, fullPath, relativePath }
}
} else if (fs.existsSync(imagePath)) {
const imageBuffer = fs.readFileSync(imagePath)
const ext = this.detectImageExt(imageBuffer)
const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`)
const fileName = `${fileBase}${ext}`
const targetDir = path.join(sessionDir, 'images')
const fullPath = path.join(targetDir, fileName)
this.ensureDir(targetDir)
if (!fs.existsSync(fullPath)) {
fs.copyFileSync(imagePath, fullPath)
}
const relativePath = `${this.sanitizeFileName(talker, 'session')}/images/${fileName}`
return { kind: 'image', fileName, fullPath, relativePath }
}
}
}
if (msg.localType === 34 && options.exportVoices) {
const result = await chatService.getVoiceData(
talker,
String(msg.localId),
msg.createTime || undefined,
msg.serverId || undefined
)
if (result.success && result.data) {
const fileName = `voice_${msg.localId}.wav`
const targetDir = path.join(sessionDir, 'voices')
const fullPath = path.join(targetDir, fileName)
this.ensureDir(targetDir)
if (!fs.existsSync(fullPath)) {
fs.writeFileSync(fullPath, Buffer.from(result.data, 'base64'))
}
const relativePath = `${this.sanitizeFileName(talker, 'session')}/voices/${fileName}`
return { kind: 'voice', fileName, fullPath, relativePath }
}
}
if (msg.localType === 43 && options.exportVideos && msg.videoMd5) {
const info = await videoService.getVideoInfo(msg.videoMd5)
if (info.exists && info.videoUrl && fs.existsSync(info.videoUrl)) {
const ext = path.extname(info.videoUrl) || '.mp4'
const fileName = `${this.sanitizeFileName(msg.videoMd5, `video_${msg.localId}`)}${ext}`
const targetDir = path.join(sessionDir, 'videos')
const fullPath = path.join(targetDir, fileName)
this.ensureDir(targetDir)
if (!fs.existsSync(fullPath)) {
fs.copyFileSync(info.videoUrl, fullPath)
}
const relativePath = `${this.sanitizeFileName(talker, 'session')}/videos/${fileName}`
return { kind: 'video', fileName, fullPath, relativePath }
}
}
if (msg.localType === 47 && options.exportEmojis && msg.emojiCdnUrl) {
const result = await chatService.downloadEmoji(msg.emojiCdnUrl, msg.emojiMd5)
if (result.success && result.localPath && fs.existsSync(result.localPath)) {
const sourceExt = path.extname(result.localPath) || '.gif'
const fileName = `${this.sanitizeFileName(msg.emojiMd5 || `emoji_${msg.localId}`, `emoji_${msg.localId}`)}${sourceExt}`
const targetDir = path.join(sessionDir, 'emojis')
const fullPath = path.join(targetDir, fileName)
this.ensureDir(targetDir)
if (!fs.existsSync(fullPath)) {
fs.copyFileSync(result.localPath, fullPath)
}
const relativePath = `${this.sanitizeFileName(talker, 'session')}/emojis/${fileName}`
return { kind: 'emoji', fileName, fullPath, relativePath }
}
}
} catch (e) {
console.warn('[HttpService] exportMediaForMessage failed:', e)
}
return null
}
private toApiMessage(msg: Message, media?: ApiExportedMedia): Record<string, any> {
return {
localId: msg.localId,
serverId: msg.serverId,
localType: msg.localType,
createTime: msg.createTime,
sortSeq: msg.sortSeq,
isSend: msg.isSend,
senderUsername: msg.senderUsername,
content: this.getMessageContent(msg),
rawContent: msg.rawContent,
parsedContent: msg.parsedContent,
mediaType: media?.kind,
mediaFileName: media?.fileName,
mediaUrl: media ? `http://127.0.0.1:${this.port}/api/v1/media/${media.relativePath}` : undefined,
mediaLocalPath: media?.fullPath
}
}
/**
* 解析时间参数
* 支持 YYYYMMDD 格式,返回秒级时间戳
*/
private parseTimeParam(param: string | null, isEnd: boolean = false): number {
if (!param) return 0
// 纯数字且长度为 8视为 YYYYMMDD
if (/^\d{8}$/.test(param)) {
const year = parseInt(param.slice(0, 4), 10)
const month = parseInt(param.slice(4, 6), 10) - 1
const day = parseInt(param.slice(6, 8), 10)
const date = new Date(year, month, day)
if (isEnd) {
// 结束时间设为当天 23:59:59
date.setHours(23, 59, 59, 999)
}
return Math.floor(date.getTime() / 1000)
}
// 纯数字,视为时间戳
if (/^\d+$/.test(param)) {
const ts = parseInt(param, 10)
// 如果是毫秒级时间戳,转为秒级
return ts > 10000000000 ? Math.floor(ts / 1000) : ts
}
return 0
}
/**
* 获取显示名称
*/
private async getDisplayNames(usernames: string[]): Promise<Record<string, string>> {
try {
const result = await wcdbService.getDisplayNames(usernames)
if (result.success && result.map) {
return result.map
}
} catch (e) {
console.error('[HttpService] Failed to get display names:', e)
}
// 返回空对象,调用方会使用 username 作为备用
return {}
}
/**
* 转换为 ChatLab 格式
*/
private async convertToChatLab(
messages: Message[],
talkerId: string,
talkerName: string,
mediaMap: Map<number, ApiExportedMedia> = new Map()
): Promise<ChatLabData> {
const isGroup = talkerId.endsWith('@chatroom')
const myWxid = this.configService.get('myWxid') || ''
// 收集所有发送者
const senderSet = new Set<string>()
for (const msg of messages) {
if (msg.senderUsername) {
senderSet.add(msg.senderUsername)
}
}
// 获取发送者显示名
const senderNames = await this.getDisplayNames(Array.from(senderSet))
// 获取群昵称(如果是群聊)
let groupNicknamesMap = new Map<string, string>()
if (isGroup) {
try {
const result = await wcdbService.getGroupNicknames(talkerId)
if (result.success && result.nicknames) {
groupNicknamesMap = new Map(Object.entries(result.nicknames))
}
} catch (e) {
console.error('[HttpService] Failed to get group nicknames:', e)
}
}
// 构建成员列表
const memberMap = new Map<string, ChatLabMember>()
for (const msg of messages) {
const sender = msg.senderUsername || ''
if (sender && !memberMap.has(sender)) {
const displayName = senderNames[sender] || sender
const isSelf = sender === myWxid || sender.toLowerCase() === myWxid.toLowerCase()
// 获取群昵称(尝试多种方式)
const groupNickname = isGroup
? (groupNicknamesMap.get(sender) || groupNicknamesMap.get(sender.toLowerCase()) || '')
: ''
memberMap.set(sender, {
platformId: sender,
accountName: isSelf ? '我' : displayName,
groupNickname: groupNickname || undefined
})
}
}
// 转换消息
const chatLabMessages: ChatLabMessage[] = messages.map(msg => {
const sender = msg.senderUsername || ''
const isSelf = msg.isSend === 1 || sender === myWxid
const accountName = isSelf ? '我' : (senderNames[sender] || sender)
// 获取该发送者的群昵称
const groupNickname = isGroup
? (groupNicknamesMap.get(sender) || groupNicknamesMap.get(sender.toLowerCase()) || '')
: ''
return {
sender,
accountName,
groupNickname: groupNickname || undefined,
timestamp: msg.createTime,
type: this.mapMessageType(msg.localType, msg),
content: this.getMessageContent(msg),
platformMessageId: msg.serverId ? String(msg.serverId) : undefined,
mediaPath: mediaMap.get(msg.localId) ? `http://127.0.0.1:${this.port}/api/v1/media/${mediaMap.get(msg.localId)!.relativePath}` : undefined
}
})
return {
chatlab: {
version: '0.0.2',
exportedAt: Math.floor(Date.now() / 1000),
generator: 'WeFlow'
},
meta: {
name: talkerName,
platform: 'wechat',
type: isGroup ? 'group' : 'private',
groupId: isGroup ? talkerId : undefined,
ownerId: myWxid || undefined
},
members: Array.from(memberMap.values()),
messages: chatLabMessages
}
}
/**
* 映射 WeChat 消息类型到 ChatLab 类型
*/
private mapMessageType(localType: number, msg: Message): number {
switch (localType) {
case 1: // 文本
return ChatLabType.TEXT
case 3: // 图片
return ChatLabType.IMAGE
case 34: // 语音
return ChatLabType.VOICE
case 43: // 视频
return ChatLabType.VIDEO
case 47: // 动画表情
return ChatLabType.EMOJI
case 48: // 位置
return ChatLabType.LOCATION
case 42: // 名片
return ChatLabType.CONTACT
case 50: // 语音/视频通话
return ChatLabType.CALL
case 10000: // 系统消息
return ChatLabType.SYSTEM
case 49: // 复合消息
return this.mapType49(msg)
case 244813135921: // 引用消息
return ChatLabType.REPLY
case 266287972401: // 拍一拍
return ChatLabType.POKE
case 8594229559345: // 红包
return ChatLabType.RED_PACKET
case 8589934592049: // 转账
return ChatLabType.TRANSFER
default:
return ChatLabType.OTHER
}
}
/**
* 映射 Type 49 子类型
*/
private mapType49(msg: Message): number {
const xmlType = msg.xmlType
switch (xmlType) {
case '5': // 链接
case '49':
return ChatLabType.LINK
case '6': // 文件
return ChatLabType.FILE
case '19': // 聊天记录
return ChatLabType.FORWARD
case '33': // 小程序
case '36':
return ChatLabType.SHARE
case '57': // 引用消息
return ChatLabType.REPLY
case '2000': // 转账
return ChatLabType.TRANSFER
case '2001': // 红包
return ChatLabType.RED_PACKET
default:
return ChatLabType.OTHER
}
}
/**
* 获取消息内容
*/
private getMessageContent(msg: Message): string | null {
// 优先使用已解析的内容
if (msg.parsedContent) {
return msg.parsedContent
}
// 根据类型返回占位符
switch (msg.localType) {
case 1:
return msg.rawContent || null
case 3:
return '[图片]'
case 34:
return '[语音]'
case 43:
return '[视频]'
case 47:
return '[表情]'
case 42:
return msg.cardNickname || '[名片]'
case 48:
return '[位置]'
case 49:
return msg.linkTitle || msg.fileName || '[消息]'
default:
return msg.rawContent || null
}
}
/**
* 发送 JSON 响应
*/
private sendJson(res: http.ServerResponse, data: any): void {
res.setHeader('Content-Type', 'application/json; charset=utf-8')
res.writeHead(200)
res.end(JSON.stringify(data, null, 2))
}
/**
* 发送错误响应
*/
private sendError(res: http.ServerResponse, code: number, message: string): void {
res.setHeader('Content-Type', 'application/json; charset=utf-8')
res.writeHead(code)
res.end(JSON.stringify({ error: message }))
}
}
export const httpService = new HttpService()

View File

@@ -1,4 +1,4 @@
import { app, BrowserWindow } from 'electron'
import { app, BrowserWindow } from 'electron'
import { basename, dirname, extname, join } from 'path'
import { pathToFileURL } from 'url'
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, appendFileSync } from 'fs'
@@ -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: 手动构建路径(开发环境)
@@ -240,7 +248,9 @@ export class ImageDecryptService {
}
}
const xorKeyRaw = this.configService.get('imageXorKey') as unknown
// 优先使用当前 wxid 对应的密钥,找不到则回退到全局配置
const imageKeys = this.configService.getImageKeysForCurrentWxid()
const xorKeyRaw = imageKeys.xorKey
// 支持十六进制格式(如 0x53和十进制格式
let xorKey: number
if (typeof xorKeyRaw === 'number') {
@@ -257,7 +267,7 @@ export class ImageDecryptService {
return { success: false, error: '未配置图片解密密钥' }
}
const aesKeyRaw = this.configService.get('imageAesKey')
const aesKeyRaw = imageKeys.aesKey
const aesKey = this.resolveAesKey(aesKeyRaw)
this.logInfo('开始解密DAT文件', { datPath, xorKey, hasAesKey: !!aesKey })
@@ -280,14 +290,14 @@ export class ImageDecryptService {
await writeFile(outputPath, decrypted)
this.logInfo('解密成功', { outputPath, size: decrypted.length })
// 对于 hevc 格式,返回错误提示
if (finalExt === '.hevc') {
return {
success: false,
error: '此图片为微信新格式(wxgf)需要安装 ffmpeg 才能显示',
error: '此图片为微信新格式(wxgf)ffmpeg 转换失败,请检查日志',
isThumb: this.isThumbnailPath(datPath)
}
}
const isThumb = this.isThumbnailPath(datPath)
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath)
if (!isThumb) {
@@ -380,9 +390,9 @@ export class ImageDecryptService {
}
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
if (suffixMatch) return suffixMatch[1]
const cleaned = suffixMatch ? suffixMatch[1] : trimmed
return trimmed
return cleaned
}
private async resolveDatPath(
@@ -395,14 +405,35 @@ export class ImageDecryptService {
const allowThumbnail = options?.allowThumbnail ?? true
const skipResolvedCache = options?.skipResolvedCache ?? false
this.logInfo('[ImageDecrypt] resolveDatPath', {
accountDir,
imageMd5,
imageDatName,
sessionId,
allowThumbnail,
skipResolvedCache
})
if (!skipResolvedCache) {
if (imageMd5) {
const cached = this.resolvedCache.get(imageMd5)
if (cached && existsSync(cached)) return cached
}
if (imageDatName) {
const cached = this.resolvedCache.get(imageDatName)
if (cached && existsSync(cached)) return cached
}
}
// 1. 通过 MD5 快速定位 (MsgAttach 目录)
if (imageMd5) {
const res = await this.fastProbabilisticSearch(accountDir, imageMd5, allowThumbnail)
if (res) return res
}
// 2. 如果 imageDatName 看起来像 MD5也尝试快速定位
if (!imageMd5 && imageDatName && this.looksLikeMd5(imageDatName)) {
const res = await this.fastProbabilisticSearch(accountDir, imageDatName, allowThumbnail)
if (res) return res
}
// 优先通过 hardlink.db 查询
if (imageMd5) {
this.logInfo('[ImageDecrypt] hardlink lookup (md5)', { imageMd5, sessionId })
@@ -415,10 +446,16 @@ export class ImageDecryptService {
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hardlinkPath)
return hardlinkPath
}
// hardlink 找到的是缩略图,但要求高清图,直接返回 null不再搜索
if (!allowThumbnail && isThumb) {
return null
// hardlink 找到的是缩略图,但要求高清图
// 尝试在同一目录下查找高清图变体(快速查找,不遍历)
const hdPath = this.findHdVariantInSameDir(hardlinkPath)
if (hdPath) {
this.cacheDatPath(accountDir, imageMd5, hdPath)
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdPath)
return hdPath
}
// 没找到高清图,返回 null不进行全局搜索
return null
}
this.logInfo('[ImageDecrypt] hardlink miss (md5)', { imageMd5 })
if (imageDatName && this.looksLikeMd5(imageDatName) && imageDatName !== imageMd5) {
@@ -431,9 +468,13 @@ export class ImageDecryptService {
this.cacheDatPath(accountDir, imageDatName, fallbackPath)
return fallbackPath
}
if (!allowThumbnail && isThumb) {
return null
// 找到缩略图但要求高清图,尝试同目录查找高清图变体
const hdPath = this.findHdVariantInSameDir(fallbackPath)
if (hdPath) {
this.cacheDatPath(accountDir, imageDatName, hdPath)
return hdPath
}
return null
}
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
}
@@ -449,10 +490,13 @@ export class ImageDecryptService {
this.cacheDatPath(accountDir, imageDatName, hardlinkPath)
return hardlinkPath
}
// hardlink 找到的是缩略图,但要求高清图,直接返回 null
if (!allowThumbnail && isThumb) {
return null
// hardlink 找到的是缩略图,但要求高清图
const hdPath = this.findHdVariantInSameDir(hardlinkPath)
if (hdPath) {
this.cacheDatPath(accountDir, imageDatName, hdPath)
return hdPath
}
return null
}
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
}
@@ -467,6 +511,9 @@ export class ImageDecryptService {
const cached = this.resolvedCache.get(imageDatName)
if (cached && existsSync(cached)) {
if (allowThumbnail || !this.isThumbnailPath(cached)) return cached
// 缓存的是缩略图,尝试找高清图
const hdPath = this.findHdVariantInSameDir(cached)
if (hdPath) return hdPath
}
}
@@ -567,9 +614,7 @@ export class ImageDecryptService {
}).catch(() => { })
}
private looksLikeMd5(value: string): boolean {
return /^[a-fA-F0-9]{16,32}$/.test(value)
}
private resolveHardlinkDbPath(accountDir: string): string | null {
const wxid = this.configService.get('myWxid')
@@ -761,6 +806,17 @@ export class ImageDecryptService {
const root = join(accountDir, 'msg', 'attach')
if (!existsSync(root)) return null
// 优化1快速概率性查找
// 包含1. 基于文件名的前缀猜测 (旧版)
// 2. 基于日期的最近月份扫描 (新版无索引时)
const fastHit = await this.fastProbabilisticSearch(root, datName)
if (fastHit) {
this.resolvedCache.set(key, fastHit)
return fastHit
}
// 优化2兜底扫描 (异步非阻塞)
const found = await this.walkForDatInWorker(root, datName.toLowerCase(), 8, allowThumbnail, thumbOnly)
if (found) {
this.resolvedCache.set(key, found)
@@ -769,6 +825,134 @@ export class ImageDecryptService {
return null
}
/**
* 基于文件名的哈希特征猜测可能的路径
* 包含1. 微信旧版结构 filename.substr(0, 2)/...
* 2. 微信新版结构 msg/attach/{hash}/{YYYY-MM}/Img/filename
*/
private async fastProbabilisticSearch(root: string, datName: string, _allowThumbnail?: boolean): Promise<string | null> {
const { promises: fs } = require('fs')
const { join } = require('path')
try {
// --- 策略 A: 旧版路径猜测 (msg/attach/xx/yy/...) ---
const lowerName = datName.toLowerCase()
let baseName = lowerName
if (baseName.endsWith('.dat')) {
baseName = baseName.slice(0, -4)
if (baseName.endsWith('_t') || baseName.endsWith('.t') || baseName.endsWith('_hd')) {
baseName = baseName.slice(0, -3)
} else if (baseName.endsWith('_thumb')) {
baseName = baseName.slice(0, -6)
}
}
const candidates: string[] = []
if (/^[a-f0-9]{32}$/.test(baseName)) {
const dir1 = baseName.substring(0, 2)
const dir2 = baseName.substring(2, 4)
candidates.push(
join(root, dir1, dir2, datName),
join(root, dir1, dir2, 'Img', datName),
join(root, dir1, dir2, 'mg', datName),
join(root, dir1, dir2, 'Image', datName)
)
}
for (const path of candidates) {
try {
await fs.access(path)
return path
} catch { }
}
// --- 绛栫暐 B: 鏂扮増 Session 鍝堝笇璺緞鐚滄祴 ---
try {
const entries = await fs.readdir(root, { withFileTypes: true })
const sessionDirs = entries
.filter((e: any) => e.isDirectory() && e.name.length === 32 && /^[a-f0-9]+$/i.test(e.name))
.map((e: any) => e.name)
if (sessionDirs.length === 0) return null
const now = new Date()
const months: string[] = []
for (let i = 0; i < 2; i++) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
const mStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
months.push(mStr)
}
const targetNames = [datName]
if (baseName !== lowerName) {
targetNames.push(`${baseName}.dat`)
targetNames.push(`${baseName}_t.dat`)
targetNames.push(`${baseName}_thumb.dat`)
}
const batchSize = 20
for (let i = 0; i < sessionDirs.length; i += batchSize) {
const batch = sessionDirs.slice(i, i + batchSize)
const tasks = batch.map(async (sessDir: string) => {
for (const month of months) {
const subDirs = ['Img', 'Image']
for (const sub of subDirs) {
const dirPath = join(root, sessDir, month, sub)
try { await fs.access(dirPath) } catch { continue }
for (const name of targetNames) {
const p = join(dirPath, name)
try { await fs.access(p); return p } catch { }
}
}
}
return null
})
const results = await Promise.all(tasks)
const hit = results.find(r => r !== null)
if (hit) return hit
}
} catch { }
} catch { }
return null
}
/**
* 在同一目录下查找高清图变体
* 缩略图 xxx_t.dat -> 高清图 xxx_h.dat 或 xxx.dat
*/
private findHdVariantInSameDir(thumbPath: string): string | null {
try {
const dir = dirname(thumbPath)
const fileName = basename(thumbPath).toLowerCase()
// 提取基础名称(去掉 _t.dat 或 .t.dat
let baseName = fileName
if (baseName.endsWith('_t.dat')) {
baseName = baseName.slice(0, -6)
} else if (baseName.endsWith('.t.dat')) {
baseName = baseName.slice(0, -6)
} else {
return null
}
// 尝试查找高清图变体
const variants = [
`${baseName}_h.dat`,
`${baseName}.h.dat`,
`${baseName}.dat`
]
for (const variant of variants) {
const variantPath = join(dir, variant)
if (existsSync(variantPath)) {
return variantPath
}
}
} catch { }
return null
}
private async searchDatFileInDir(
dirPath: string,
datName: string,
@@ -817,55 +1001,6 @@ export class ImageDecryptService {
})
}
private matchesDatName(fileName: string, datName: string): boolean {
const lower = fileName.toLowerCase()
const base = lower.endsWith('.dat') ? lower.slice(0, -4) : lower
const normalizedBase = this.normalizeDatBase(base)
const normalizedTarget = this.normalizeDatBase(datName.toLowerCase())
if (normalizedBase === normalizedTarget) return true
const pattern = new RegExp(`^${datName}(?:[._][a-z])?\\.dat$`, 'i')
if (pattern.test(lower)) return true
return lower.endsWith('.dat') && lower.includes(datName)
}
private 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
}
private isThumbnailDat(fileName: string): boolean {
return fileName.includes('.t.dat') || fileName.includes('_t.dat')
}
private hasXVariant(baseLower: string): boolean {
return /[._][a-z]$/.test(baseLower)
}
private isThumbnailPath(filePath: string): boolean {
const lower = basename(filePath).toLowerCase()
if (this.isThumbnailDat(lower)) return true
const ext = extname(lower)
const base = ext ? lower.slice(0, -ext.length) : lower
// 支持新命名 _thumb 和旧命名 _t
return base.endsWith('_t') || base.endsWith('_thumb')
}
private isHdPath(filePath: string): boolean {
const lower = basename(filePath).toLowerCase()
const ext = extname(lower)
const base = ext ? lower.slice(0, -ext.length) : lower
return base.endsWith('_hd') || base.endsWith('_h')
}
private hasImageVariantSuffix(baseLower: string): boolean {
return /[._][a-z]$/.test(baseLower)
}
private isLikelyImageDatBase(baseLower: string): boolean {
return this.hasImageVariantSuffix(baseLower) || this.looksLikeMd5(baseLower)
}
private normalizeDatBase(name: string): string {
let base = name.toLowerCase()
if (base.endsWith('.dat') || base.endsWith('.jpg')) {
@@ -877,64 +1012,82 @@ export class ImageDecryptService {
return base
}
private sanitizeDirName(name: string): string {
const trimmed = name.trim()
if (!trimmed) return 'unknown'
return trimmed.replace(/[<>:"/\\|?*]/g, '_')
private hasImageVariantSuffix(baseLower: string): boolean {
return /[._][a-z]$/.test(baseLower)
}
private resolveTimeDir(datPath: string): string {
const parts = datPath.split(/[\\/]+/)
for (const part of parts) {
if (/^\d{4}-\d{2}$/.test(part)) return part
}
try {
const stat = statSync(datPath)
const year = stat.mtime.getFullYear()
const month = String(stat.mtime.getMonth() + 1).padStart(2, '0')
return `${year}-${month}`
} catch {
return 'unknown-time'
}
private isLikelyImageDatBase(baseLower: string): boolean {
return this.hasImageVariantSuffix(baseLower) || this.looksLikeMd5(baseLower)
}
private findCachedOutput(cacheKey: string, preferHd: boolean = false, sessionId?: string): string | null {
const root = this.getCacheRoot()
const allRoots = this.getAllCacheRoots()
const normalizedKey = this.normalizeDatBase(cacheKey.toLowerCase())
const extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp']
if (sessionId) {
const sessionDir = join(root, this.sanitizeDirName(sessionId))
if (existsSync(sessionDir)) {
try {
const sessionEntries = readdirSync(sessionDir)
for (const entry of sessionEntries) {
const timeDir = join(sessionDir, entry)
if (!this.isDirectory(timeDir)) continue
const hit = this.findCachedOutputInDir(timeDir, normalizedKey, extensions, preferHd)
if (hit) return hit
}
} catch {
// ignore
// 遍历所有可能的缓存根路径
for (const root of allRoots) {
// 策略1: 新目录结构 Images/{sessionId}/{YYYY-MM}/{file}_hd.jpg
if (sessionId) {
const sessionDir = join(root, this.sanitizeDirName(sessionId))
if (existsSync(sessionDir)) {
try {
const dateDirs = readdirSync(sessionDir, { withFileTypes: true })
.filter(d => d.isDirectory() && /^\d{4}-\d{2}$/.test(d.name))
.map(d => d.name)
.sort()
.reverse() // 最新的日期优先
for (const dateDir of dateDirs) {
const imageDir = join(sessionDir, dateDir)
const hit = this.findCachedOutputInDir(imageDir, normalizedKey, extensions, preferHd)
if (hit) return hit
}
} catch { }
}
}
}
// 新目录结构: Images/{normalizedKey}/{normalizedKey}_thumb.jpg 或 _hd.jpg
const imageDir = join(root, normalizedKey)
if (existsSync(imageDir)) {
const hit = this.findCachedOutputInDir(imageDir, normalizedKey, extensions, preferHd)
if (hit) return hit
}
// 策略2: 遍历所有 sessionId 目录查找(如果没有指定 sessionId
try {
const sessionDirs = readdirSync(root, { withFileTypes: true })
.filter(d => d.isDirectory())
.map(d => d.name)
// 兼容旧的平铺结构
for (const ext of extensions) {
const candidate = join(root, `${cacheKey}${ext}`)
if (existsSync(candidate)) return candidate
}
for (const ext of extensions) {
const candidate = join(root, `${cacheKey}_t${ext}`)
if (existsSync(candidate)) return candidate
for (const session of sessionDirs) {
const sessionDir = join(root, session)
// 检查是否是日期目录结构
try {
const subDirs = readdirSync(sessionDir, { withFileTypes: true })
.filter(d => d.isDirectory() && /^\d{4}-\d{2}$/.test(d.name))
.map(d => d.name)
for (const dateDir of subDirs) {
const imageDir = join(sessionDir, dateDir)
const hit = this.findCachedOutputInDir(imageDir, normalizedKey, extensions, preferHd)
if (hit) return hit
}
} catch { }
}
} catch { }
// 策略3: 旧目录结构 Images/{normalizedKey}/{normalizedKey}_thumb.jpg
const oldImageDir = join(root, normalizedKey)
if (existsSync(oldImageDir)) {
const hit = this.findCachedOutputInDir(oldImageDir, normalizedKey, extensions, preferHd)
if (hit) return hit
}
// 策略4: 最旧的平铺结构 Images/{file}.jpg
for (const ext of extensions) {
const candidate = join(root, `${cacheKey}${ext}`)
if (existsSync(candidate)) return candidate
}
for (const ext of extensions) {
const candidate = join(root, `${cacheKey}_t${ext}`)
if (existsSync(candidate)) return candidate
}
}
return null
@@ -1103,23 +1256,59 @@ export class ImageDecryptService {
private async ensureCacheIndexed(): Promise<void> {
if (this.cacheIndexed) return
if (this.cacheIndexing) return this.cacheIndexing
this.cacheIndexing = new Promise((resolve) => {
const root = this.getCacheRoot()
try {
this.indexCacheDir(root, 2, 0)
} catch {
this.cacheIndexed = true
this.cacheIndexing = null
resolve()
return
this.cacheIndexing = (async () => {
// 扫描所有可能的缓存根目录
const allRoots = this.getAllCacheRoots()
this.logInfo('开始索引缓存', { roots: allRoots.length })
for (const root of allRoots) {
try {
this.indexCacheDir(root, 3, 0) // 增加深度到 3支持 sessionId/YYYY-MM 结构
} catch (e) {
this.logError('索引目录失败', e, { root })
}
}
this.logInfo('缓存索引完成', { entries: this.resolvedCache.size })
this.cacheIndexed = true
this.cacheIndexing = null
resolve()
})
})()
return this.cacheIndexing
}
/**
* 获取所有可能的缓存根路径(用于查找已缓存的图片)
* 包含当前路径、配置路径、旧版本路径
*/
private getAllCacheRoots(): string[] {
const roots: string[] = []
const configured = this.configService.get('cachePath')
const documentsPath = app.getPath('documents')
// 主要路径(当前使用的)
const mainRoot = this.getCacheRoot()
roots.push(mainRoot)
// 如果配置了自定义路径,也检查其下的 Images
if (configured) {
roots.push(join(configured, 'Images'))
roots.push(join(configured, 'images'))
}
// 默认路径
roots.push(join(documentsPath, 'WeFlow', 'Images'))
roots.push(join(documentsPath, 'WeFlow', 'images'))
// 兼容旧路径(如果有的话)
roots.push(join(documentsPath, 'WeFlowData', 'Images'))
// 去重并过滤存在的路径
const uniqueRoots = Array.from(new Set(roots))
const existingRoots = uniqueRoots.filter(r => existsSync(r))
return existingRoots
}
private indexCacheDir(root: string, maxDepth: number, depth: number): void {
let entries: string[]
try {
@@ -1286,14 +1475,14 @@ export class ImageDecryptService {
private bytesToInt32(bytes: Buffer): number {
if (bytes.length !== 4) {
throw new Error('需要4个字节')
throw new Error('需要 4 个字节')
}
return bytes[0] | (bytes[1] << 8) | (bytes[2] << 16) | (bytes[3] << 24)
}
asciiKey16(keyString: string): Buffer {
if (keyString.length < 16) {
throw new Error('AES密钥至少需要16个字符')
throw new Error('AES密钥至少需要 16 个字符')
}
return Buffer.from(keyString, 'ascii').subarray(0, 16)
}
@@ -1485,25 +1674,28 @@ export class ImageDecryptService {
// 提取 HEVC NALU 裸流
const hevcData = this.extractHevcNalu(buffer)
if (!hevcData || hevcData.length < 100) {
return { data: buffer, isWxgf: true }
}
// 优先用提取的 NALU 裸流,提取失败则跳过 wxgf 头部直接用原始数据
const feedData = (hevcData && hevcData.length >= 100) ? hevcData : buffer.subarray(4)
this.logInfo('unwrapWxgf: 准备 ffmpeg 转换', {
naluExtracted: !!(hevcData && hevcData.length >= 100),
feedSize: feedData.length
})
// 尝试用 ffmpeg 转换
try {
const jpgData = await this.convertHevcToJpg(hevcData)
const jpgData = await this.convertHevcToJpg(feedData)
if (jpgData && jpgData.length > 0) {
return { data: jpgData, isWxgf: false }
}
} catch {
// ffmpeg 转换失败
} catch (e) {
this.logError('unwrapWxgf: ffmpeg 转换失败', e)
}
return { data: hevcData, isWxgf: true }
return { data: feedData, isWxgf: true }
}
/**
* wxgf 数据中提取 HEVC NALU 裸流
* 浠?wxgf 鏁版嵁涓彁鍙?HEVC NALU 瑁告祦
*/
private extractHevcNalu(buffer: Buffer): Buffer | null {
const nalUnits: Buffer[] = []
@@ -1566,53 +1758,133 @@ export class ImageDecryptService {
/**
* 使用 ffmpeg 将 HEVC 裸流转换为 JPG
*/
private convertHevcToJpg(hevcData: Buffer): Promise<Buffer | null> {
private async convertHevcToJpg(hevcData: Buffer): Promise<Buffer | null> {
const ffmpeg = this.getFfmpegPath()
this.logInfo('ffmpeg 转换开始', { ffmpegPath: ffmpeg, hevcSize: hevcData.length })
const tmpDir = join(app.getPath('temp'), 'weflow_hevc')
if (!existsSync(tmpDir)) mkdirSync(tmpDir, { recursive: true })
const ts = Date.now()
const tmpInput = join(tmpDir, `hevc_${ts}.hevc`)
const tmpOutput = join(tmpDir, `hevc_${ts}.jpg`)
try {
await writeFile(tmpInput, hevcData)
// 依次尝试: 1) -f hevc 裸流 2) 不指定格式让 ffmpeg 自动检测
const attempts: { label: string; inputArgs: string[] }[] = [
{ label: 'hevc raw', inputArgs: ['-f', 'hevc', '-i', tmpInput] },
{ label: 'auto detect', inputArgs: ['-i', tmpInput] },
]
for (const attempt of attempts) {
// 清理上一轮的输出
try { if (existsSync(tmpOutput)) require('fs').unlinkSync(tmpOutput) } catch {}
const result = await this.runFfmpegConvert(ffmpeg, attempt.inputArgs, tmpOutput, attempt.label)
if (result) return result
}
return null
} catch (e) {
this.logError('ffmpeg 转换异常', e)
return null
} finally {
try { if (existsSync(tmpInput)) require('fs').unlinkSync(tmpInput) } catch {}
try { if (existsSync(tmpOutput)) require('fs').unlinkSync(tmpOutput) } catch {}
}
}
private runFfmpegConvert(ffmpeg: string, inputArgs: string[], tmpOutput: string, label: string): Promise<Buffer | null> {
return new Promise((resolve) => {
const { spawn } = require('child_process')
const chunks: Buffer[] = []
const errChunks: Buffer[] = []
const proc = spawn(ffmpeg, [
'-hide_banner',
'-loglevel', 'error',
'-f', 'hevc',
'-i', 'pipe:0',
'-vframes', '1',
'-q:v', '3',
'-f', 'mjpeg',
'pipe:1'
], {
stdio: ['pipe', 'pipe', 'pipe'],
const args = [
'-hide_banner', '-loglevel', 'error',
...inputArgs,
'-vframes', '1', '-q:v', '2', '-f', 'image2', tmpOutput
]
this.logInfo(`ffmpeg 尝试 [${label}]`, { args: args.join(' ') })
const proc = spawn(ffmpeg, args, {
stdio: ['ignore', 'ignore', 'pipe'],
windowsHide: true
})
proc.stdout.on('data', (chunk: Buffer) => chunks.push(chunk))
proc.stderr.on('data', (chunk: Buffer) => errChunks.push(chunk))
proc.on('close', (code: number) => {
if (code === 0 && chunks.length > 0) {
this.logInfo('ffmpeg 转换成功', { outputSize: Buffer.concat(chunks).length })
resolve(Buffer.concat(chunks))
} else {
const errMsg = Buffer.concat(errChunks).toString()
this.logInfo('ffmpeg 转换失败', { code, error: errMsg })
resolve(null)
}
})
const timer = setTimeout(() => {
proc.kill('SIGKILL')
this.logError(`ffmpeg [${label}] 超时(15s)`)
resolve(null)
}, 15000)
proc.on('error', (err: Error) => {
this.logInfo('ffmpeg 进程错误', { error: err.message })
proc.on('close', (code: number) => {
clearTimeout(timer)
if (code === 0 && existsSync(tmpOutput)) {
try {
const jpgBuf = readFileSync(tmpOutput)
if (jpgBuf.length > 0) {
this.logInfo(`ffmpeg [${label}] 成功`, { outputSize: jpgBuf.length })
resolve(jpgBuf)
return
}
} catch (e) {
this.logError(`ffmpeg [${label}] 读取输出失败`, e)
}
}
const errMsg = Buffer.concat(errChunks).toString().trim()
this.logInfo(`ffmpeg [${label}] 失败`, { code, error: errMsg })
resolve(null)
})
proc.stdin.write(hevcData)
proc.stdin.end()
proc.on('error', (err: Error) => {
clearTimeout(timer)
this.logError(`ffmpeg [${label}] 进程错误`, err)
resolve(null)
})
})
}
private looksLikeMd5(s: string): boolean {
return /^[a-f0-9]{32}$/i.test(s)
}
private isThumbnailDat(name: string): boolean {
const lower = name.toLowerCase()
return lower.includes('_t.dat') || lower.includes('.t.dat') || lower.includes('_thumb.dat')
}
private hasXVariant(base: string): boolean {
const lower = base.toLowerCase()
return lower.endsWith('_h') || lower.endsWith('_hd') || lower.endsWith('_thumb') || lower.endsWith('_t')
}
private isHdPath(p: string): boolean {
return p.toLowerCase().includes('_hd') || p.toLowerCase().includes('_h')
}
private isThumbnailPath(p: string): boolean {
const lower = p.toLowerCase()
return lower.includes('_thumb') || lower.includes('_t') || lower.includes('.t.')
}
private sanitizeDirName(s: string): string {
return s.replace(/[<>:"/\\|?*]/g, '_').trim() || 'unknown'
}
private resolveTimeDir(filePath: string): string {
try {
const stats = statSync(filePath)
const d = new Date(stats.mtime)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
} catch {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
}
}
// 保留原有的解密到文件方法(用于兼容)
async decryptToFile(inputPath: string, outputPath: string, xorKey: number, aesKey?: Buffer): Promise<void> {
const version = this.getDatVersion(inputPath)
@@ -1625,7 +1897,7 @@ export class ImageDecryptService {
decrypted = this.decryptDatV4(inputPath, xorKey, key)
} else {
if (!aesKey || aesKey.length !== 16) {
throw new Error('V4版本需要16字节AES密钥')
throw new Error('V4版本需要 16 字节 AES 密钥')
}
decrypted = this.decryptDatV4(inputPath, xorKey, aesKey)
}

View File

@@ -0,0 +1,127 @@
/**
* ISAAC-64: A fast cryptographic PRNG
* Re-implemented in TypeScript using BigInt for 64-bit support.
* Used for WeChat Channels/SNS video decryption.
*/
export class Isaac64 {
private mm = new BigUint64Array(256);
private aa = 0n;
private bb = 0n;
private cc = 0n;
private randrsl = new BigUint64Array(256);
private randcnt = 0;
private static readonly MASK = 0xFFFFFFFFFFFFFFFFn;
constructor(seed: number | string | bigint) {
const seedBig = BigInt(seed);
// 通常单密钥初始化是将密钥放在第一个槽位,其余清零(或者按某种规律填充)
// 这里我们尝试仅设置第一个槽位,这在很多 WASM 移植版本中更为常见
this.randrsl.fill(0n);
this.randrsl[0] = seedBig;
this.init(true);
}
private init(flag: boolean) {
let a: bigint, b: bigint, c: bigint, d: bigint, e: bigint, f: bigint, g: bigint, h: bigint;
a = b = c = d = e = f = g = h = 0x9e3779b97f4a7c15n;
const mix = () => {
a = (a - e) & Isaac64.MASK; f ^= (h >> 9n); h = (h + a) & Isaac64.MASK;
b = (b - f) & Isaac64.MASK; g ^= (a << 9n) & Isaac64.MASK; a = (a + b) & Isaac64.MASK;
c = (c - g) & Isaac64.MASK; h ^= (b >> 23n); b = (b + c) & Isaac64.MASK;
d = (d - h) & Isaac64.MASK; a ^= (c << 15n) & Isaac64.MASK; c = (c + d) & Isaac64.MASK;
e = (e - a) & Isaac64.MASK; b ^= (d >> 14n); d = (d + e) & Isaac64.MASK;
f = (f - b) & Isaac64.MASK; c ^= (e << 20n) & Isaac64.MASK; e = (e + f) & Isaac64.MASK;
g = (g - c) & Isaac64.MASK; d ^= (f >> 17n); f = (f + g) & Isaac64.MASK;
h = (h - d) & Isaac64.MASK; e ^= (g << 14n) & Isaac64.MASK; g = (g + h) & Isaac64.MASK;
};
for (let i = 0; i < 4; i++) mix();
for (let i = 0; i < 256; i += 8) {
if (flag) {
a = (a + this.randrsl[i]) & Isaac64.MASK;
b = (b + this.randrsl[i + 1]) & Isaac64.MASK;
c = (c + this.randrsl[i + 2]) & Isaac64.MASK;
d = (d + this.randrsl[i + 3]) & Isaac64.MASK;
e = (e + this.randrsl[i + 4]) & Isaac64.MASK;
f = (f + this.randrsl[i + 5]) & Isaac64.MASK;
g = (g + this.randrsl[i + 6]) & Isaac64.MASK;
h = (h + this.randrsl[i + 7]) & Isaac64.MASK;
}
mix();
this.mm[i] = a; this.mm[i + 1] = b; this.mm[i + 2] = c; this.mm[i + 3] = d;
this.mm[i + 4] = e; this.mm[i + 5] = f; this.mm[i + 6] = g; this.mm[i + 7] = h;
}
if (flag) {
for (let i = 0; i < 256; i += 8) {
a = (a + this.mm[i]) & Isaac64.MASK;
b = (b + this.mm[i + 1]) & Isaac64.MASK;
c = (c + this.mm[i + 2]) & Isaac64.MASK;
d = (d + this.mm[i + 3]) & Isaac64.MASK;
e = (e + this.mm[i + 4]) & Isaac64.MASK;
f = (f + this.mm[i + 5]) & Isaac64.MASK;
g = (g + this.mm[i + 6]) & Isaac64.MASK;
h = (h + this.mm[i + 7]) & Isaac64.MASK;
mix();
this.mm[i] = a; this.mm[i + 1] = b; this.mm[i + 2] = c; this.mm[i + 3] = d;
this.mm[i + 4] = e; this.mm[i + 5] = f; this.mm[i + 6] = g; this.mm[i + 7] = h;
}
}
this.isaac64();
this.randcnt = 256;
}
private isaac64() {
this.cc = (this.cc + 1n) & Isaac64.MASK;
this.bb = (this.bb + this.cc) & Isaac64.MASK;
for (let i = 0; i < 256; i++) {
let x = this.mm[i];
switch (i & 3) {
case 0: this.aa = (this.aa ^ (((this.aa << 21n) & Isaac64.MASK) ^ Isaac64.MASK)) & Isaac64.MASK; break;
case 1: this.aa = (this.aa ^ (this.aa >> 5n)) & Isaac64.MASK; break;
case 2: this.aa = (this.aa ^ ((this.aa << 12n) & Isaac64.MASK)) & Isaac64.MASK; break;
case 3: this.aa = (this.aa ^ (this.aa >> 33n)) & Isaac64.MASK; break;
}
this.aa = (this.mm[(i + 128) & 255] + this.aa) & Isaac64.MASK;
const y = (this.mm[Number(x >> 3n) & 255] + this.aa + this.bb) & Isaac64.MASK;
this.mm[i] = y;
this.bb = (this.mm[Number(y >> 11n) & 255] + x) & Isaac64.MASK;
this.randrsl[i] = this.bb;
}
}
public getNext(): bigint {
if (this.randcnt === 0) {
this.isaac64();
this.randcnt = 256;
}
return this.randrsl[--this.randcnt];
}
/**
* Generates a keystream where each 64-bit block is Big-Endian.
* This matches WeChat's behavior (Reverse index order + byte reversal).
*/
public generateKeystreamBE(size: number): Buffer {
const buffer = Buffer.allocUnsafe(size);
const fullBlocks = Math.floor(size / 8);
for (let i = 0; i < fullBlocks; i++) {
buffer.writeBigUInt64BE(this.getNext(), i * 8);
}
const remaining = size % 8;
if (remaining > 0) {
const lastK = this.getNext();
const temp = Buffer.allocUnsafe(8);
temp.writeBigUInt64BE(lastK, 0);
temp.copy(buffer, fullBlocks * 8, 0, remaining);
}
return buffer;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
import { join, dirname } from 'path'
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
import { app } from 'electron'
import { ConfigService } from './config'
export interface SessionMessageCacheEntry {
updatedAt: number
@@ -15,7 +16,7 @@ export class MessageCacheService {
constructor(cacheBasePath?: string) {
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
? cacheBasePath
: join(app.getPath('documents'), 'WeFlow')
: ConfigService.getInstance().getCacheBasePath()
this.cacheFilePath = join(basePath, 'session-messages.json')
this.ensureCacheDir()
this.loadCache()

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)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
import { join } from 'path'
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
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'
@@ -18,6 +19,16 @@ class VideoService {
this.configService = new ConfigService()
}
private log(message: string, meta?: Record<string, unknown>): void {
try {
const timestamp = new Date().toISOString()
const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
const logDir = join(app.getPath('userData'), 'logs')
if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true })
appendFileSync(join(logDir, 'wcdb.log'), `[${timestamp}] [VideoService] ${message}${metaStr}\n`, 'utf8')
} catch {}
}
/**
* 获取数据库根目录
*/
@@ -36,7 +47,7 @@ class VideoService {
* 获取缓存目录(解密后的数据库存放位置)
*/
private getCachePath(): string {
return this.configService.get('cachePath') || ''
return this.configService.getCacheBasePath()
}
/**
@@ -69,7 +80,12 @@ class VideoService {
const wxid = this.getMyWxid()
const cleanedWxid = this.cleanWxid(wxid)
if (!wxid) return undefined
this.log('queryVideoFileName 开始', { md5, wxid, cleanedWxid, cachePath, dbPath })
if (!wxid) {
this.log('queryVideoFileName: wxid 为空')
return undefined
}
// 方法1优先在 cachePath 下查找解密后的 hardlink.db
if (cachePath) {
@@ -84,20 +100,23 @@ class VideoService {
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 = ?
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) {
// Silently fail
this.log('缓存 hardlink.db 查询失败', { path: p, error: String(e) })
}
}
}
@@ -105,34 +124,45 @@ class VideoService {
// 方法2使用 wcdbService.execQuery 查询加密的 hardlink.db
if (dbPath) {
const encryptedDbPaths = [
join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'),
join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db')
]
const dbPathLower = dbPath.toLowerCase()
const wxidLower = wxid.toLowerCase()
const cleanedWxidLower = cleanedWxid.toLowerCase()
const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower)
const encryptedDbPaths: string[] = []
if (dbPathContainsWxid) {
encryptedDbPaths.push(join(dbPath, 'db_storage', 'hardlink', 'hardlink.db'))
} else {
encryptedDbPaths.push(join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'))
encryptedDbPaths.push(join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db'))
}
for (const p of encryptedDbPaths) {
if (existsSync(p)) {
try {
this.log('尝试加密 hardlink.db', { path: p })
const escapedMd5 = md5.replace(/'/g, "''")
// 用 md5 字段查询,获取 file_name
const sql = `SELECT file_name FROM video_hardlink_info_v4 WHERE md5 = '${escapedMd5}' LIMIT 1`
const result = await wcdbService.execQuery('media', p, sql)
if (result.success && result.rows && result.rows.length > 0) {
const row = result.rows[0]
if (row?.file_name) {
// 提取不带扩展名的文件名作为实际视频 MD5
const realMd5 = String(row.file_name).replace(/\.[^.]+$/, '')
this.log('加密 hardlink.db 命中', { file_name: row.file_name, realMd5 })
return realMd5
}
}
this.log('加密 hardlink.db 未命中', { path: p, result: JSON.stringify(result).slice(0, 200) })
} catch (e) {
this.log('加密 hardlink.db 查询失败', { path: p, error: String(e) })
}
} else {
this.log('加密 hardlink.db 不存在', { path: p })
}
}
}
this.log('queryVideoFileName: 所有方法均未找到', { md5 })
return undefined
}
@@ -155,56 +185,110 @@ class VideoService {
* 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg
*/
async getVideoInfo(videoMd5: string): Promise<VideoInfo> {
const dbPath = this.getDbPath()
const wxid = this.getMyWxid()
this.log('getVideoInfo 开始', { videoMd5, dbPath, wxid })
if (!dbPath || !wxid || !videoMd5) {
this.log('getVideoInfo: 参数缺失', { dbPath: !!dbPath, wxid: !!wxid, videoMd5: !!videoMd5 })
return { exists: false }
}
// 先尝试从数据库查询真正的视频文件名
const realVideoMd5 = await this.queryVideoFileName(videoMd5) || videoMd5
this.log('realVideoMd5', { input: videoMd5, resolved: realVideoMd5, changed: realVideoMd5 !== videoMd5 })
const videoBaseDir = join(dbPath, wxid, 'msg', 'video')
// 检查 dbPath 是否已经包含 wxid避免重复拼接
const dbPathLower = dbPath.toLowerCase()
const wxidLower = wxid.toLowerCase()
const cleanedWxid = this.cleanWxid(wxid)
let videoBaseDir: string
if (dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxid.toLowerCase())) {
videoBaseDir = join(dbPath, 'msg', 'video')
} else {
videoBaseDir = join(dbPath, wxid, 'msg', 'video')
}
this.log('videoBaseDir', { videoBaseDir, exists: existsSync(videoBaseDir) })
if (!existsSync(videoBaseDir)) {
this.log('getVideoInfo: videoBaseDir 不存在')
return { exists: false }
}
// 遍历年月目录查找视频文件
try {
const allDirs = readdirSync(videoBaseDir)
// 支持多种目录格式: YYYY-MM, YYYYMM, 或其他
const yearMonthDirs = allDirs
.filter(dir => {
const dirPath = join(videoBaseDir, dir)
return statSync(dirPath).isDirectory()
})
.sort((a, b) => b.localeCompare(a)) // 从最新的目录开始查找
.sort((a, b) => b.localeCompare(a))
this.log('扫描目录', { dirs: yearMonthDirs })
for (const yearMonth of yearMonthDirs) {
const dirPath = join(videoBaseDir, yearMonth)
const videoPath = join(dirPath, `${realVideoMd5}.mp4`)
const coverPath = join(dirPath, `${realVideoMd5}.jpg`)
const thumbPath = join(dirPath, `${realVideoMd5}_thumb.jpg`)
// 检查视频文件是否存在
if (existsSync(videoPath)) {
// 封面/缩略图使用不带 _raw 后缀的基础名(自己发的视频文件名带 _raw但封面不带
const baseMd5 = realVideoMd5.replace(/_raw$/, '')
const coverPath = join(dirPath, `${baseMd5}.jpg`)
const thumbPath = join(dirPath, `${baseMd5}_thumb.jpg`)
// 列出同目录下与该 md5 相关的所有文件,帮助排查封面命名
const allFiles = readdirSync(dirPath)
const relatedFiles = allFiles.filter(f => f.toLowerCase().startsWith(realVideoMd5.slice(0, 8).toLowerCase()))
this.log('找到视频,相关文件列表', {
videoPath,
coverExists: existsSync(coverPath),
thumbExists: existsSync(thumbPath),
relatedFiles,
coverPath,
thumbPath
})
return {
videoUrl: videoPath, // 返回文件路径,前端通过 readFile 读取
videoUrl: videoPath,
coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'),
thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'),
exists: true
}
}
}
// 没找到,列出所有目录里的 mp4 文件帮助排查(最多每目录 10 个)
this.log('未找到视频,开始全目录扫描', {
lookingForOriginal: `${videoMd5}.mp4`,
lookingForResolved: `${realVideoMd5}.mp4`,
hardlinkResolved: realVideoMd5 !== videoMd5
})
for (const yearMonth of yearMonthDirs) {
const dirPath = join(videoBaseDir, yearMonth)
try {
const allFiles = readdirSync(dirPath)
const mp4Files = allFiles.filter(f => f.endsWith('.mp4')).slice(0, 10)
// 检查原始 md5 是否部分匹配前8位
const partialMatch = mp4Files.filter(f => f.toLowerCase().startsWith(videoMd5.slice(0, 8).toLowerCase()))
this.log(`目录 ${yearMonth} 扫描结果`, {
totalFiles: allFiles.length,
mp4Count: allFiles.filter(f => f.endsWith('.mp4')).length,
sampleMp4: mp4Files,
partialMatchByOriginalMd5: partialMatch
})
} catch (e) {
this.log(`目录 ${yearMonth} 读取失败`, { error: String(e) })
}
}
} catch (e) {
console.error('[VideoService] Error searching for video:', e)
this.log('getVideoInfo 遍历出错', { error: String(e) })
}
this.log('getVideoInfo: 未找到视频', { videoMd5, realVideoMd5 })
return { exists: false }
}
@@ -212,41 +296,59 @@ class VideoService {
* 根据消息内容解析视频MD5
*/
parseVideoMd5(content: string): string | undefined {
// 打印前500字符看看 XML 结构
if (!content) return undefined
// 打印原始 XML 前 800 字符,帮助排查自己发的视频结构
this.log('parseVideoMd5 原始内容', { preview: content.slice(0, 800) })
try {
// 提取所有可能的 md5 值进行日志
const allMd5s: string[] = []
const md5Regex = /(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]+)['"]/gi
// 收集所有 md5 相关属性,方便对比
const allMd5Attrs: string[] = []
const md5Regex = /(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]*)['"]/gi
let match
while ((match = md5Regex.exec(content)) !== null) {
allMd5s.push(`${match[0]}`)
allMd5Attrs.push(match[0])
}
this.log('parseVideoMd5 所有 md5 属性', { attrs: allMd5Attrs })
// 方法1从 <videomsg md5="..."> 提取(收到的视频)
const videoMsgMd5Match = /<videomsg[^>]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
if (videoMsgMd5Match) {
this.log('parseVideoMd5 命中 videomsg md5 属性', { md5: videoMsgMd5Match[1] })
return videoMsgMd5Match[1].toLowerCase()
}
// 提取 md5用于查询 hardlink.db
// 注意:不是 rawmd5rawmd5 是另一个值
// 格式: md5="xxx" 或 <md5>xxx</md5>
// 尝试从videomsg标签中提取md5
const videoMsgMatch = /<videomsg[^>]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
if (videoMsgMatch) {
return videoMsgMatch[1].toLowerCase()
// 方法2从 <videomsg rawmd5="..."> 提取(自己发的视频,没有 md5 只有 rawmd5
const rawMd5Match = /<videomsg[^>]*\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
if (rawMd5Match) {
this.log('parseVideoMd5 命中 videomsg rawmd5 属性(自发视频)', { rawmd5: rawMd5Match[1] })
return rawMd5Match[1].toLowerCase()
}
const attrMatch = /\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
// 方法3任意属性 md5="..."(非 rawmd5/cdnthumbaeskey 等)
const attrMatch = /(?<![a-z])md5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
if (attrMatch) {
this.log('parseVideoMd5 命中通用 md5 属性', { md5: attrMatch[1] })
return attrMatch[1].toLowerCase()
}
const md5Match = /<md5>([a-fA-F0-9]+)<\/md5>/i.exec(content)
if (md5Match) {
return md5Match[1].toLowerCase()
// 方法4<md5>...</md5> 标签
const md5TagMatch = /<md5>([a-fA-F0-9]+)<\/md5>/i.exec(content)
if (md5TagMatch) {
this.log('parseVideoMd5 命中 md5 标签', { md5: md5TagMatch[1] })
return md5TagMatch[1].toLowerCase()
}
// 方法5兜底取 rawmd5 属性(任意位置)
const rawMd5Fallback = /\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
if (rawMd5Fallback) {
this.log('parseVideoMd5 兜底命中 rawmd5', { rawmd5: rawMd5Fallback[1] })
return rawMd5Fallback[1].toLowerCase()
}
this.log('parseVideoMd5 未提取到任何 md5', { contentLength: content.length })
} catch (e) {
console.error('[VideoService] 解析视频MD5失败:', e)
this.log('parseVideoMd5 异常', { error: String(e) })
}
return undefined

View File

@@ -1,5 +1,5 @@
import { app } from 'electron'
import { existsSync, mkdirSync, statSync, unlinkSync, createWriteStream } from 'fs'
import { existsSync, mkdirSync, statSync, unlinkSync, createWriteStream, openSync, writeSync, closeSync } from 'fs'
import { join } from 'path'
import * as https from 'https'
import * as http from 'http'
@@ -24,6 +24,7 @@ type DownloadProgress = {
downloadedBytes: number
totalBytes?: number
percent?: number
speed?: number
}
const SENSEVOICE_MODEL: ModelInfo = {
@@ -123,44 +124,44 @@ export class VoiceTranscribeService {
percent: 0
})
// 下载模型文件 (40%)
// 下载模型文件 (80% 权重)
console.info('[VoiceTranscribe] 开始下载模型文件...')
await this.downloadToFile(
MODEL_DOWNLOAD_URLS.model,
modelPath,
'model',
(downloaded, total) => {
const percent = total ? (downloaded / total) * 40 : undefined
(downloaded, total, speed) => {
const percent = total ? (downloaded / total) * 80 : 0
onProgress?.({
modelName: SENSEVOICE_MODEL.name,
downloadedBytes: downloaded,
totalBytes: SENSEVOICE_MODEL.sizeBytes,
percent
percent,
speed
})
}
)
// 下载 tokens 文件 (30%)
// 下载 tokens 文件 (20% 权重)
console.info('[VoiceTranscribe] 开始下载 tokens 文件...')
await this.downloadToFile(
MODEL_DOWNLOAD_URLS.tokens,
tokensPath,
'tokens',
(downloaded, total) => {
(downloaded, total, speed) => {
const modelSize = existsSync(modelPath) ? statSync(modelPath).size : 0
const percent = total ? 40 + (downloaded / total) * 30 : 40
const percent = total ? 80 + (downloaded / total) * 20 : 80
onProgress?.({
modelName: SENSEVOICE_MODEL.name,
downloadedBytes: modelSize + downloaded,
totalBytes: SENSEVOICE_MODEL.sizeBytes,
percent
percent,
speed
})
}
)
console.info('[VoiceTranscribe] 模型下载完成')
console.info('[VoiceTranscribe] 所有文件下载完成')
return { success: true, modelPath, tokensPath }
} catch (error) {
const modelPath = this.resolveModelPath(SENSEVOICE_MODEL.files.model)
@@ -180,7 +181,7 @@ export class VoiceTranscribeService {
}
/**
* 转写 WAV 音频数据 (后台 Worker Threads 版本)
* 转写 WAV 音频数据
*/
async transcribeWavBuffer(
wavData: Buffer,
@@ -197,18 +198,15 @@ export class VoiceTranscribeService {
return
}
// 获取配置的语言列表,如果没有传入则从配置读取
let supportedLanguages = languages
if (!supportedLanguages || supportedLanguages.length === 0) {
supportedLanguages = this.configService.get('transcribeLanguages')
// 如果配置中也没有或为空,使用默认值
if (!supportedLanguages || supportedLanguages.length === 0) {
supportedLanguages = ['zh', 'yue']
}
}
const { Worker } = require('worker_threads')
// main.js 和 transcribeWorker.js 同在 dist-electron 目录下
const workerPath = join(__dirname, 'transcribeWorker.js')
const worker = new Worker(workerPath, {
@@ -224,12 +222,10 @@ export class VoiceTranscribeService {
let finalTranscript = ''
worker.on('message', (msg: any) => {
console.log('[VoiceTranscribe] Worker 消息:', msg)
if (msg.type === 'partial') {
onPartial?.(msg.text)
} else if (msg.type === 'final') {
finalTranscript = msg.text
console.log('[VoiceTranscribe] 最终文本:', finalTranscript)
resolve({ success: true, transcript: finalTranscript })
worker.terminate()
} else if (msg.type === 'error') {
@@ -239,15 +235,9 @@ export class VoiceTranscribeService {
}
})
worker.on('error', (err: Error) => {
resolve({ success: false, error: String(err) })
})
worker.on('error', (err: Error) => resolve({ success: false, error: String(err) }))
worker.on('exit', (code: number) => {
if (code !== 0) {
console.error(`[VoiceTranscribe] Worker stopped with exit code ${code}`)
resolve({ success: false, error: `Worker exited with code ${code}` })
}
if (code !== 0) resolve({ success: false, error: `Worker exited with code ${code}` })
})
} catch (error) {
@@ -257,121 +247,240 @@ export class VoiceTranscribeService {
}
/**
* 下载文件
* 下载文件 (支持多线程)
*/
private downloadToFile(
private async downloadToFile(
url: string,
targetPath: string,
fileName: string,
onProgress?: (downloaded: number, total?: number) => void,
remainingRedirects = 5
onProgress?: (downloaded: number, total?: number, speed?: number) => void
): Promise<void> {
return new Promise((resolve, reject) => {
const protocol = url.startsWith('https') ? https : http
console.info(`[VoiceTranscribe] 下载 ${fileName}:`, url)
if (existsSync(targetPath)) {
unlinkSync(targetPath)
}
const options = {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
},
timeout: 30000 // 30秒连接超时
console.info(`[VoiceTranscribe] 准备下载 ${fileName}: ${url}`)
// 1. 探测支持情况
let probeResult
try {
probeResult = await this.probeUrl(url)
} catch (err) {
console.warn(`[VoiceTranscribe] ${fileName} 探测失败,使用单线程`, err)
return this.downloadSingleThread(url, targetPath, fileName, onProgress)
}
const { totalSize, acceptRanges, finalUrl } = probeResult
// 如果文件太小 (< 2MB) 或者不支持 Range使用单线程
if (totalSize < 2 * 1024 * 1024 || !acceptRanges) {
return this.downloadSingleThread(finalUrl, targetPath, fileName, onProgress)
}
console.info(`[VoiceTranscribe] ${fileName} 开始多线程下载 (4 线程), 大小: ${(totalSize / 1024 / 1024).toFixed(2)} MB`)
const threadCount = 4
const chunkSize = Math.ceil(totalSize / threadCount)
const fd = openSync(targetPath, 'w')
let downloadedTotal = 0
let lastDownloaded = 0
let lastTime = Date.now()
let speed = 0
const speedInterval = setInterval(() => {
const now = Date.now()
const duration = (now - lastTime) / 1000
if (duration > 0) {
speed = (downloadedTotal - lastDownloaded) / duration
lastDownloaded = downloadedTotal
lastTime = now
onProgress?.(downloadedTotal, totalSize, speed)
}
}, 1000)
try {
const promises = []
for (let i = 0; i < threadCount; i++) {
const start = i * chunkSize
const end = i === threadCount - 1 ? totalSize - 1 : (i + 1) * chunkSize - 1
promises.push(this.downloadChunk(finalUrl, fd, start, end, (bytes) => {
downloadedTotal += bytes
}))
}
const request = protocol.get(url, options, (response) => {
console.info(`[VoiceTranscribe] ${fileName} 响应状态:`, response.statusCode)
await Promise.all(promises)
// Final progress update
onProgress?.(totalSize, totalSize, 0)
console.info(`[VoiceTranscribe] ${fileName} 多线程下载完成`)
} catch (err) {
console.error(`[VoiceTranscribe] ${fileName} 多线程下载失败:`, err)
throw err
} finally {
clearInterval(speedInterval)
closeSync(fd)
}
}
// 处理重定向
if ([301, 302, 303, 307, 308].includes(response.statusCode || 0) && response.headers.location) {
if (remainingRedirects <= 0) {
reject(new Error('重定向次数过多'))
private async probeUrl(url: string, remainingRedirects = 5): Promise<{ totalSize: number, acceptRanges: boolean, finalUrl: string }> {
return new Promise((resolve, reject) => {
const protocol = url.startsWith('https') ? https : http
const options = {
method: 'GET',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Referer': 'https://modelscope.cn/',
'Range': 'bytes=0-0'
}
}
const req = protocol.get(url, options, (res) => {
if ([301, 302, 303, 307, 308].includes(res.statusCode || 0)) {
const location = res.headers.location
if (location && remainingRedirects > 0) {
const nextUrl = new URL(location, url).href
this.probeUrl(nextUrl, remainingRedirects - 1).then(resolve).catch(reject)
return
}
console.info(`[VoiceTranscribe] 重定向到:`, response.headers.location)
this.downloadToFile(response.headers.location, targetPath, fileName, onProgress, remainingRedirects - 1)
.then(resolve)
.catch(reject)
}
if (res.statusCode !== 206 && res.statusCode !== 200) {
reject(new Error(`Probe failed: HTTP ${res.statusCode}`))
return
}
const contentRange = res.headers['content-range']
let totalSize = 0
if (contentRange) {
const parts = contentRange.split('/')
totalSize = parseInt(parts[parts.length - 1], 10)
} else {
totalSize = parseInt(res.headers['content-length'] || '0', 10)
}
const acceptRanges = res.headers['accept-ranges'] === 'bytes' || !!contentRange
resolve({ totalSize, acceptRanges, finalUrl: url })
res.destroy()
})
req.on('error', reject)
})
}
private async downloadChunk(url: string, fd: number, start: number, end: number, onData: (bytes: number) => void): Promise<void> {
return new Promise((resolve, reject) => {
const protocol = url.startsWith('https') ? https : http
const options = {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Referer': 'https://modelscope.cn/',
'Range': `bytes=${start}-${end}`
}
}
const req = protocol.get(url, options, (res) => {
if (res.statusCode !== 206) {
reject(new Error(`Chunk download failed: HTTP ${res.statusCode}`))
return
}
let currentOffset = start
res.on('data', (chunk: Buffer) => {
try {
writeSync(fd, chunk, 0, chunk.length, currentOffset)
currentOffset += chunk.length
onData(chunk.length)
} catch (err) {
reject(err)
res.destroy()
}
})
res.on('end', () => resolve())
res.on('error', reject)
})
req.on('error', reject)
})
}
private async downloadSingleThread(url: string, targetPath: string, fileName: string, onProgress?: (downloaded: number, total?: number, speed?: number) => void, remainingRedirects = 5): Promise<void> {
return new Promise((resolve, reject) => {
const protocol = url.startsWith('https') ? https : http
const options = {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Referer': 'https://modelscope.cn/'
}
}
const request = protocol.get(url, options, (response) => {
if ([301, 302, 303, 307, 308].includes(response.statusCode || 0)) {
const location = response.headers.location
if (location && remainingRedirects > 0) {
const nextUrl = new URL(location, url).href
this.downloadSingleThread(nextUrl, targetPath, fileName, onProgress, remainingRedirects - 1).then(resolve).catch(reject)
return
}
}
if (response.statusCode !== 200) {
reject(new Error(`下载失败: HTTP ${response.statusCode}`))
reject(new Error(`Fallback download failed: HTTP ${response.statusCode}`))
return
}
const totalBytes = Number(response.headers['content-length'] || 0) || undefined
let downloadedBytes = 0
let lastDownloaded = 0
let lastTime = Date.now()
let speed = 0
console.info(`[VoiceTranscribe] ${fileName} 文件大小:`, totalBytes ? `${(totalBytes / 1024 / 1024).toFixed(2)} MB` : '未知')
const speedInterval = setInterval(() => {
const now = Date.now()
const duration = (now - lastTime) / 1000
if (duration > 0) {
speed = (downloadedBytes - lastDownloaded) / duration
lastDownloaded = downloadedBytes
lastTime = now
onProgress?.(downloadedBytes, totalBytes, speed)
}
}, 1000)
const writer = createWriteStream(targetPath)
// 设置数据接收超时60秒没有数据则超时
let lastDataTime = Date.now()
const dataTimeout = setInterval(() => {
if (Date.now() - lastDataTime > 60000) {
clearInterval(dataTimeout)
response.destroy()
writer.close()
reject(new Error('下载超时60秒内未收到数据'))
}
}, 5000)
response.on('data', (chunk) => {
lastDataTime = Date.now()
downloadedBytes += chunk.length
onProgress?.(downloadedBytes, totalBytes)
})
response.on('error', (error) => {
clearInterval(dataTimeout)
try { writer.close() } catch { }
console.error(`[VoiceTranscribe] ${fileName} 响应错误:`, error)
reject(error)
})
writer.on('error', (error) => {
clearInterval(dataTimeout)
try { writer.close() } catch { }
console.error(`[VoiceTranscribe] ${fileName} 写入错误:`, error)
reject(error)
})
writer.on('finish', () => {
clearInterval(dataTimeout)
clearInterval(speedInterval)
writer.close()
console.info(`[VoiceTranscribe] ${fileName} 下载完成:`, targetPath)
resolve()
})
writer.on('error', (err) => {
clearInterval(speedInterval)
// 确保在错误情况下也关闭文件句柄
writer.destroy()
reject(err)
})
response.on('error', (err) => {
clearInterval(speedInterval)
// 确保在响应错误时也关闭文件句柄
writer.destroy()
reject(err)
})
response.pipe(writer)
})
request.on('timeout', () => {
request.destroy()
console.error(`[VoiceTranscribe] ${fileName} 连接超时`)
reject(new Error('连接超时'))
})
request.on('error', (error) => {
console.error(`[VoiceTranscribe] ${fileName} 请求错误:`, error)
reject(error)
})
request.on('error', reject)
})
}
/**
* 清理资源
*/
dispose() {
if (this.recognizer) {
try {
// sherpa-onnx 的 recognizer 可能需要手动释放
this.recognizer = null
} catch (error) {
}
this.recognizer = null
}
}
}
export const voiceTranscribeService = new VoiceTranscribeService()

View File

@@ -0,0 +1,180 @@
import path from 'path';
import fs from 'fs';
import vm from 'vm';
let app: any;
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
app = require('electron').app;
} catch (e) {
app = { isPackaged: false };
}
// This service handles the loading and execution of the WeChat WASM module
// to generate the correct Isaac64 keystream for video decryption.
export class WasmService {
private static instance: WasmService;
private module: any = null;
private wasmLoaded = false;
private initPromise: Promise<void> | null = null;
private capturedKeystream: Uint8Array | null = null;
private constructor() { }
public static getInstance(): WasmService {
if (!WasmService.instance) {
WasmService.instance = new WasmService();
}
return WasmService.instance;
}
private async init(): Promise<void> {
if (this.wasmLoaded) return;
if (this.initPromise) return this.initPromise;
this.initPromise = new Promise((resolve, reject) => {
try {
// For dev, files are in electron/assets/wasm
// __dirname in dev (from dist-electron) is .../dist-electron
// So we need to go up one level and then into electron/assets/wasm
const isDev = !app.isPackaged;
const basePath = isDev
? path.join(__dirname, '../electron/assets/wasm')
: path.join(process.resourcesPath, 'assets/wasm'); // Adjust as needed for production build
const wasmPath = path.join(basePath, 'wasm_video_decode.wasm');
const jsPath = path.join(basePath, 'wasm_video_decode.js');
if (!fs.existsSync(wasmPath) || !fs.existsSync(jsPath)) {
throw new Error(`WASM files not found at ${basePath}`);
}
const wasmBinary = fs.readFileSync(wasmPath);
// Emulate Emscripten environment
// We must use 'any' for global mocking
const mockGlobal: any = {
console: console,
Buffer: Buffer,
Uint8Array: Uint8Array,
Int8Array: Int8Array,
Uint16Array: Uint16Array,
Int16Array: Int16Array,
Uint32Array: Uint32Array,
Int32Array: Int32Array,
Float32Array: Float32Array,
Float64Array: Float64Array,
BigInt64Array: BigInt64Array,
BigUint64Array: BigUint64Array,
Array: Array,
Object: Object,
Function: Function,
String: String,
Number: Number,
Boolean: Boolean,
Error: Error,
Promise: Promise,
require: require,
process: process,
setTimeout: setTimeout,
clearTimeout: clearTimeout,
setInterval: setInterval,
clearInterval: clearInterval,
};
// Define Module
mockGlobal.Module = {
onRuntimeInitialized: () => {
this.wasmLoaded = true;
resolve();
},
wasmBinary: wasmBinary,
print: (text: string) => console.log('[WASM stdout]', text),
printErr: (text: string) => console.error('[WASM stderr]', text)
};
// Define necessary globals for Emscripten loader
mockGlobal.self = mockGlobal;
mockGlobal.self.location = { href: jsPath };
mockGlobal.WorkerGlobalScope = function () { };
mockGlobal.VTS_WASM_URL = `file://${wasmPath}`; // Needs a URL, file protocol works in Node context for our mock?
// Define the callback function that WASM calls to return data
// The WASM module calls `wasm_isaac_generate(ptr, size)`
mockGlobal.wasm_isaac_generate = (ptr: number, size: number) => {
// console.log(`[WasmService] wasm_isaac_generate called: ptr=${ptr}, size=${size}`);
const buffer = new Uint8Array(mockGlobal.Module.HEAPU8.buffer, ptr, size);
// Copy the data because WASM memory might change or be invalidated
this.capturedKeystream = new Uint8Array(buffer);
};
// Execute the loader script in the context
const jsContent = fs.readFileSync(jsPath, 'utf8');
const script = new vm.Script(jsContent, { filename: jsPath });
// create context
const context = vm.createContext(mockGlobal);
script.runInContext(context);
// Store reference to module
this.module = mockGlobal.Module;
} catch (error) {
console.error('[WasmService] Failed to initialize WASM:', error);
reject(error);
}
});
return this.initPromise;
}
public async getKeystream(key: string, size: number = 131072): Promise<Buffer> {
// ISAAC-64 uses 8-byte blocks. If size is not a multiple of 8,
// the global reverse() will cause a shift in alignment.
const alignSize = Math.ceil(size / 8) * 8;
const buffer = await this.getRawKeystream(key, alignSize);
// Reverse the entire aligned buffer
const reversed = new Uint8Array(buffer);
reversed.reverse();
// Return exactly the requested size from the beginning of the reversed stream.
// Since we reversed the 'aligned' buffer, index 0 is the last byte of the last block.
return Buffer.from(reversed).subarray(0, size);
}
public async getRawKeystream(key: string, size: number = 131072): Promise<Buffer> {
await this.init();
if (!this.module || !this.module.WxIsaac64) {
if (this.module.asm && this.module.asm.WxIsaac64) {
this.module.WxIsaac64 = this.module.asm.WxIsaac64;
}
}
if (!this.module.WxIsaac64) {
throw new Error('[WasmService] WxIsaac64 not found in WASM module');
}
try {
this.capturedKeystream = null;
const isaac = new this.module.WxIsaac64(key);
isaac.generate(size);
if (isaac.delete) {
isaac.delete();
}
if (this.capturedKeystream) {
return Buffer.from(this.capturedKeystream);
} else {
throw new Error('[WasmService] Failed to capture keystream (callback not called)');
}
} catch (error) {
console.error('[WasmService] Error generating raw keystream:', error);
throw error;
}
}
}

View File

@@ -1,8 +1,50 @@
import { join, dirname, basename } from 'path'
import { join, dirname, basename } from 'path'
import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs'
// DLL 初始化错误信息,用于帮助用户诊断问题
let lastDllInitError: string | null = null
/**
* 解析 extra_bufferprotobuf中的免打扰状态
* - field 12 (tag 0x60): 值非0 = 免打扰
* 折叠状态通过 contact.flag & 0x10000000 判断
*/
function parseExtraBuffer(raw: Buffer | string | null | undefined): { isMuted: boolean } {
if (!raw) return { isMuted: false }
// execQuery 返回的 BLOB 列是十六进制字符串,需要先解码
const buf: Buffer = typeof raw === 'string' ? Buffer.from(raw, 'hex') : raw
if (buf.length === 0) return { isMuted: false }
let isMuted = false
let i = 0
const len = buf.length
const readVarint = (): number => {
let result = 0, shift = 0
while (i < len) {
const b = buf[i++]
result |= (b & 0x7f) << shift
shift += 7
if (!(b & 0x80)) break
}
return result
}
while (i < len) {
const tag = readVarint()
const fieldNum = tag >>> 3
const wireType = tag & 0x07
if (wireType === 0) {
const val = readVarint()
if (fieldNum === 12 && val !== 0) isMuted = true
} else if (wireType === 2) {
const sz = readVarint()
i += sz
} else if (wireType === 5) { i += 4
} else if (wireType === 1) { i += 8
} else { break }
}
return { isMuted }
}
export function getLastDllInitError(): string | null {
return lastDllInitError
}
@@ -27,6 +69,8 @@ export class WcdbCore {
private wcdbCloseAccount: any = null
private wcdbSetMyWxid: any = null
private wcdbFreeString: any = null
private wcdbUpdateMessage: any = null
private wcdbDeleteMessage: any = null
private wcdbGetSessions: any = null
private wcdbGetMessages: any = null
private wcdbGetMessageCount: any = null
@@ -35,15 +79,19 @@ export class WcdbCore {
private wcdbGetGroupMemberCount: any = null
private wcdbGetGroupMemberCounts: any = null
private wcdbGetGroupMembers: any = null
private wcdbGetGroupNicknames: any = null
private wcdbGetMessageTables: any = null
private wcdbGetMessageMeta: any = null
private wcdbGetContact: any = null
private wcdbGetContactStatus: any = null
private wcdbGetMessageTableStats: any = null
private wcdbGetAggregateStats: any = null
private wcdbGetAvailableYears: any = null
private wcdbGetAnnualReportStats: any = null
private wcdbGetAnnualReportExtras: any = null
private wcdbGetDualReportStats: any = null
private wcdbGetGroupStats: any = null
private wcdbGetMessageDates: any = null
private wcdbOpenMessageCursor: any = null
private wcdbOpenMessageCursorLite: any = null
private wcdbFetchMessageBatch: any = null
@@ -57,6 +105,25 @@ export class WcdbCore {
private wcdbGetDbStatus: any = null
private wcdbGetVoiceData: any = null
private wcdbGetSnsTimeline: any = null
private wcdbGetSnsAnnualStats: any = null
private wcdbInstallSnsBlockDeleteTrigger: any = null
private wcdbUninstallSnsBlockDeleteTrigger: any = null
private wcdbCheckSnsBlockDeleteTrigger: any = null
private wcdbDeleteSnsPost: any = null
private wcdbVerifyUser: any = null
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
private monitorReconnectTimer: any = null
private monitorPipePath: string = ''
private avatarUrlCache: Map<string, { url?: string; updatedAt: number }> = new Map()
private readonly avatarCacheTtlMs = 10 * 60 * 1000
private logTimer: NodeJS.Timeout | null = null
@@ -76,6 +143,113 @@ export class WcdbCore {
}
}
// 使用命名管道 IPC
startMonitor(callback: (type: string, json: string) => void): boolean {
if (!this.wcdbStartMonitorPipe) {
return false
}
this.monitorCallback = callback
try {
const result = this.wcdbStartMonitorPipe()
if (result !== 0) {
return false
}
// 从 DLL 获取动态管道名(含 PID
let pipePath = '\\\\.\\pipe\\weflow_monitor'
if (this.wcdbGetMonitorPipeName) {
try {
const namePtr = [null as any]
if (this.wcdbGetMonitorPipeName(namePtr) === 0 && namePtr[0]) {
pipePath = this.koffi.decode(namePtr[0], 'char', -1)
this.wcdbFreeString(namePtr[0])
}
} catch {}
}
this.connectMonitorPipe(pipePath)
return true
} catch (e) {
console.error('[wcdbCore] startMonitor exception:', e)
return false
}
}
// 连接命名管道,支持断开后自动重连
private connectMonitorPipe(pipePath: string) {
this.monitorPipePath = pipePath
const net = require('net')
setTimeout(() => {
if (!this.monitorCallback) return
this.monitorPipeClient = net.createConnection(this.monitorPipePath, () => {
})
let buffer = ''
this.monitorPipeClient.on('data', (data: Buffer) => {
buffer += data.toString('utf8')
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.trim()) {
try {
const parsed = JSON.parse(line)
this.monitorCallback?.(parsed.action || 'update', line)
} catch {
this.monitorCallback?.('update', line)
}
}
}
})
this.monitorPipeClient.on('error', () => {
})
this.monitorPipeClient.on('close', () => {
this.monitorPipeClient = null
this.scheduleReconnect()
})
}, 100)
}
// 定时重连
private scheduleReconnect() {
if (this.monitorReconnectTimer || !this.monitorCallback) return
this.monitorReconnectTimer = setTimeout(() => {
this.monitorReconnectTimer = null
if (this.monitorCallback && !this.monitorPipeClient) {
this.connectMonitorPipe(this.monitorPipePath)
}
}, 3000)
}
stopMonitor(): void {
this.monitorCallback = null
if (this.monitorReconnectTimer) {
clearTimeout(this.monitorReconnectTimer)
this.monitorReconnectTimer = null
}
if (this.monitorPipeClient) {
this.monitorPipeClient.destroy()
this.monitorPipeClient = null
}
if (this.wcdbStopMonitorPipe) {
this.wcdbStopMonitorPipe()
}
}
// 保留旧方法签名以兼容
setMonitor(callback: (type: string, json: string) => void): boolean {
return this.startMonitor(callback)
}
/**
* 获取 DLL 路径
*/
@@ -110,7 +284,7 @@ export class WcdbCore {
}
private isLogEnabled(): boolean {
if (process.env.WEFLOW_WORKER === '1') return false
// 移除 Worker 线程的日志禁用逻辑,允许在 Worker 中记录日志
if (process.env.WCDB_LOG_ENABLED === '1') return true
return this.logEnabled
}
@@ -119,7 +293,7 @@ export class WcdbCore {
if (!force && !this.isLogEnabled()) return
const line = `[${new Date().toISOString()}] ${message}`
// 同时输出到控制台和文件
console.log('[WCDB]', message)
try {
const base = this.userDataPath || process.env.WCDB_LOG_DIR || process.cwd()
const dir = join(base, 'logs')
@@ -217,9 +391,6 @@ export class WcdbCore {
return false
}
// 关键修复:显式预加载依赖库 WCDB.dll 和 SDL2.dll
// Windows 加载器默认不会查找子目录中的依赖,必须先将其加载到内存
// 这可以解决部分用户因为 VC++ 运行时或 DLL 依赖问题导致的闪退
const dllDir = dirname(dllPath)
const wcdbCorePath = join(dllDir, 'WCDB.dll')
if (existsSync(wcdbCorePath)) {
@@ -247,13 +418,36 @@ export class WcdbCore {
// InitProtection (Added for security)
try {
this.wcdbInitProtection = this.lib.func('bool InitProtection(const char* resourcePath)')
const protectionOk = this.wcdbInitProtection(dllDir)
// 尝试多个可能的资源路径
const resourcePaths = [
dllDir, // DLL 所在目录
dirname(dllDir), // 上级目录
this.resourcesPath, // 配置的资源路径
join(process.cwd(), 'resources') // 开发环境
].filter(Boolean)
let protectionOk = false
for (const resPath of resourcePaths) {
try {
//
protectionOk = this.wcdbInitProtection(resPath)
if (protectionOk) {
//
break
}
} catch (e) {
// console.warn(`[WCDB] InitProtection 失败 (${resPath}):`, e)
}
}
if (!protectionOk) {
console.error('Core security check failed')
return false
// console.warn('[WCDB] Core security check failed - 继续运行但可能不稳定')
// this.writeLog('InitProtection 失败,继续运行')
// 不返回 false允许继续运行
}
} catch (e) {
console.warn('InitProtection symbol not found:', e)
// console.warn('InitProtection symbol not found:', e)
}
// 定义类型
@@ -278,6 +472,20 @@ export class WcdbCore {
this.wcdbSetMyWxid = null
}
// wcdb_status wcdb_update_message(wcdb_handle handle, const char* session_id, int64_t local_id, int32_t create_time, const char* new_content, char** out_error)
try {
this.wcdbUpdateMessage = this.lib.func('int32 wcdb_update_message(int64 handle, const char* sessionId, int64 localId, int32 createTime, const char* newContent, _Out_ void** outError)')
} catch {
this.wcdbUpdateMessage = null
}
// wcdb_status wcdb_delete_message(wcdb_handle handle, const char* session_id, int64_t local_id, char** out_error)
try {
this.wcdbDeleteMessage = this.lib.func('int32 wcdb_delete_message(int64 handle, const char* sessionId, int64 localId, int32 createTime, const char* dbPathHint, _Out_ void** outError)')
} catch {
this.wcdbDeleteMessage = null
}
// void wcdb_free_string(char* ptr)
this.wcdbFreeString = this.lib.func('void wcdb_free_string(void* ptr)')
@@ -309,6 +517,13 @@ export class WcdbCore {
// wcdb_status wcdb_get_group_members(wcdb_handle handle, const char* chatroom_id, char** out_json)
this.wcdbGetGroupMembers = this.lib.func('int32 wcdb_get_group_members(int64 handle, const char* chatroomId, _Out_ void** outJson)')
// wcdb_status wcdb_get_group_nicknames(wcdb_handle handle, const char* chatroom_id, char** out_json)
try {
this.wcdbGetGroupNicknames = this.lib.func('int32 wcdb_get_group_nicknames(int64 handle, const char* chatroomId, _Out_ void** outJson)')
} catch {
this.wcdbGetGroupNicknames = null
}
// wcdb_status wcdb_get_message_tables(wcdb_handle handle, const char* session_id, char** out_json)
this.wcdbGetMessageTables = this.lib.func('int32 wcdb_get_message_tables(int64 handle, const char* sessionId, _Out_ void** outJson)')
@@ -318,6 +533,13 @@ export class WcdbCore {
// wcdb_status wcdb_get_contact(wcdb_handle handle, const char* username, char** out_json)
this.wcdbGetContact = this.lib.func('int32 wcdb_get_contact(int64 handle, const char* username, _Out_ void** outJson)')
// wcdb_status wcdb_get_contact_status(wcdb_handle handle, const char* usernames_json, char** out_json)
try {
this.wcdbGetContactStatus = this.lib.func('int32 wcdb_get_contact_status(int64 handle, const char* usernamesJson, _Out_ void** outJson)')
} catch {
this.wcdbGetContactStatus = null
}
// wcdb_status wcdb_get_message_table_stats(wcdb_handle handle, const char* session_id, char** out_json)
this.wcdbGetMessageTableStats = this.lib.func('int32 wcdb_get_message_table_stats(int64 handle, const char* sessionId, _Out_ void** outJson)')
@@ -345,6 +567,20 @@ export class WcdbCore {
this.wcdbGetAnnualReportExtras = null
}
// wcdb_status wcdb_get_dual_report_stats(wcdb_handle handle, const char* session_id, int32_t begin_timestamp, int32_t end_timestamp, char** out_json)
try {
this.wcdbGetDualReportStats = this.lib.func('int32 wcdb_get_dual_report_stats(int64 handle, const char* sessionId, int32 begin, int32 end, _Out_ void** outJson)')
} catch {
this.wcdbGetDualReportStats = null
}
// wcdb_status wcdb_get_logs(char** out_json)
try {
this.wcdbGetLogs = this.lib.func('int32 wcdb_get_logs(_Out_ void** outJson)')
} catch {
this.wcdbGetLogs = null
}
// wcdb_status wcdb_get_group_stats(wcdb_handle handle, const char* chatroom_id, int32_t begin_timestamp, int32_t end_timestamp, char** out_json)
try {
this.wcdbGetGroupStats = this.lib.func('int32 wcdb_get_group_stats(int64 handle, const char* chatroomId, int32 begin, int32 end, _Out_ void** outJson)')
@@ -352,6 +588,13 @@ export class WcdbCore {
this.wcdbGetGroupStats = null
}
// wcdb_status wcdb_get_message_dates(wcdb_handle handle, const char* session_id, char** out_json)
try {
this.wcdbGetMessageDates = this.lib.func('int32 wcdb_get_message_dates(int64 handle, const char* sessionId, _Out_ void** outJson)')
} catch {
this.wcdbGetMessageDates = null
}
// wcdb_status wcdb_open_message_cursor(wcdb_handle handle, const char* session_id, int32_t batch_size, int32_t ascending, int32_t begin_timestamp, int32_t end_timestamp, wcdb_cursor* out_cursor)
this.wcdbOpenMessageCursor = this.lib.func('int32 wcdb_open_message_cursor(int64 handle, const char* sessionId, int32 batchSize, int32 ascending, int32 beginTimestamp, int32 endTimestamp, _Out_ int64* outCursor)')
@@ -407,6 +650,83 @@ export class WcdbCore {
this.wcdbGetSnsTimeline = null
}
// wcdb_status wcdb_get_sns_annual_stats(wcdb_handle handle, int32_t begin_timestamp, int32_t end_timestamp, char** out_json)
try {
this.wcdbGetSnsAnnualStats = this.lib.func('int32 wcdb_get_sns_annual_stats(int64 handle, int32 begin, int32 end, _Out_ void** outJson)')
} catch {
this.wcdbGetSnsAnnualStats = null
}
// wcdb_status wcdb_install_sns_block_delete_trigger(wcdb_handle handle, char** out_error)
try {
this.wcdbInstallSnsBlockDeleteTrigger = this.lib.func('int32 wcdb_install_sns_block_delete_trigger(int64 handle, _Out_ void** outError)')
} catch {
this.wcdbInstallSnsBlockDeleteTrigger = null
}
// wcdb_status wcdb_uninstall_sns_block_delete_trigger(wcdb_handle handle, char** out_error)
try {
this.wcdbUninstallSnsBlockDeleteTrigger = this.lib.func('int32 wcdb_uninstall_sns_block_delete_trigger(int64 handle, _Out_ void** outError)')
} catch {
this.wcdbUninstallSnsBlockDeleteTrigger = null
}
// wcdb_status wcdb_check_sns_block_delete_trigger(wcdb_handle handle, int32_t* out_installed)
try {
this.wcdbCheckSnsBlockDeleteTrigger = this.lib.func('int32 wcdb_check_sns_block_delete_trigger(int64 handle, _Out_ int32* outInstalled)')
} catch {
this.wcdbCheckSnsBlockDeleteTrigger = null
}
// wcdb_status wcdb_delete_sns_post(wcdb_handle handle, const char* post_id, char** out_error)
try {
this.wcdbDeleteSnsPost = this.lib.func('int32 wcdb_delete_sns_post(int64 handle, const char* postId, _Out_ void** outError)')
} catch {
this.wcdbDeleteSnsPost = null
}
// Named pipe IPC for monitoring (replaces callback)
try {
this.wcdbStartMonitorPipe = this.lib.func('int32 wcdb_start_monitor_pipe()')
this.wcdbStopMonitorPipe = this.lib.func('void wcdb_stop_monitor_pipe()')
this.wcdbGetMonitorPipeName = this.lib.func('int32 wcdb_get_monitor_pipe_name(_Out_ void** outName)')
this.writeLog('Monitor pipe functions loaded')
} catch (e) {
console.warn('Failed to load monitor pipe functions:', e)
this.wcdbStartMonitorPipe = null
this.wcdbStopMonitorPipe = null
this.wcdbGetMonitorPipeName = null
}
// void VerifyUser(int64_t hwnd_ptr, const char* message, char* out_result, int max_len)
try {
this.wcdbVerifyUser = this.lib.func('void VerifyUser(int64 hwnd, const char* message, _Out_ char* outResult, int maxLen)')
} catch {
this.wcdbVerifyUser = null
}
// 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) {
@@ -800,6 +1120,37 @@ export class WcdbCore {
}
}
/**
* 获取指定时间之后的新消息
*/
async getNewMessages(sessionId: string, minTime: number, limit: number = 1000): Promise<{ success: boolean; messages?: any[]; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
try {
// 1. 打开游标 (使用 Ascending=1 从指定时间往后查)
const openRes = await this.openMessageCursor(sessionId, limit, true, minTime, 0)
if (!openRes.success || !openRes.cursor) {
return { success: false, error: openRes.error }
}
const cursor = openRes.cursor
try {
// 2. 获取批次
const fetchRes = await this.fetchMessageBatch(cursor)
if (!fetchRes.success) {
return { success: false, error: fetchRes.error }
}
return { success: true, messages: fetchRes.rows }
} finally {
// 3. 关闭游标
await this.closeMessageCursor(cursor)
}
} catch (e) {
return { success: false, error: String(e) }
}
}
async getMessageCount(sessionId: string): Promise<{ success: boolean; count?: number; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
@@ -816,6 +1167,40 @@ 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 未连接' }
@@ -971,6 +1356,28 @@ export class WcdbCore {
}
}
async getGroupNicknames(chatroomId: string): Promise<{ success: boolean; nicknames?: Record<string, string>; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
if (!this.wcdbGetGroupNicknames) {
return { success: false, error: '当前 DLL 版本不支持获取群昵称接口' }
}
try {
const outPtr = [null as any]
const result = this.wcdbGetGroupNicknames(this.handle, chatroomId, outPtr)
if (result !== 0 || !outPtr[0]) {
return { success: false, error: `获取群昵称失败: ${result}` }
}
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析群昵称失败' }
const nicknames = JSON.parse(jsonStr)
return { success: true, nicknames }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getMessageTables(sessionId: string): Promise<{ success: boolean; tables?: any[]; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
@@ -990,6 +1397,29 @@ export class WcdbCore {
}
}
async getMessageDates(sessionId: string): Promise<{ success: boolean; dates?: string[]; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
try {
if (!this.wcdbGetMessageDates) {
return { success: false, error: 'DLL 不支持 getMessageDates' }
}
const outPtr = [null as any]
const result = this.wcdbGetMessageDates(this.handle, sessionId, outPtr)
if (result !== 0 || !outPtr[0]) {
// 空结果也可能是正常的(无消息)
return { success: true, dates: [] }
}
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析日期列表失败' }
const dates = JSON.parse(jsonStr)
return { success: true, dates }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getMessageTableStats(sessionId: string): Promise<{ success: boolean; tables?: any[]; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
@@ -1047,6 +1477,36 @@ export class WcdbCore {
}
}
async getContactStatus(usernames: string[]): Promise<{ success: boolean; map?: Record<string, { isFolded: boolean; isMuted: boolean }>; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
try {
// 分批查询,避免 SQL 过长execQuery 不支持参数绑定,直接拼 SQL
const BATCH = 200
const map: Record<string, { isFolded: boolean; isMuted: boolean }> = {}
for (let i = 0; i < usernames.length; i += BATCH) {
const batch = usernames.slice(i, i + BATCH)
const inList = batch.map(u => `'${u.replace(/'/g, "''")}'`).join(',')
const sql = `SELECT username, flag, extra_buffer FROM contact WHERE username IN (${inList})`
const result = await this.execQuery('contact', null, sql)
if (!result.success || !result.rows) continue
for (const row of result.rows) {
const uname: string = row.username
// 折叠flag bit 28 (0x10000000)
const flag = parseInt(row.flag ?? '0', 10)
const isFolded = (flag & 0x10000000) !== 0
// 免打扰extra_buffer field 12 非0
const { isMuted } = parseExtraBuffer(row.extra_buffer)
map[uname] = { isFolded, isMuted }
}
}
return { success: true, map }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getAggregateStats(sessionIds: string[], beginTimestamp: number = 0, endTimestamp: number = 0): Promise<{ success: boolean; data?: any; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
@@ -1312,13 +1772,39 @@ export class WcdbCore {
}
}
async execQuery(kind: string, path: string | null, sql: string): Promise<{ success: boolean; rows?: any[]; error?: string }> {
async getLogs(): Promise<{ success: boolean; logs?: string[]; error?: string }> {
if (!this.lib) return { success: false, error: 'DLL 未加载' }
if (!this.wcdbGetLogs) return { success: false, error: '接口未就绪' }
try {
const outPtr = [null as any]
const result = this.wcdbGetLogs(outPtr)
if (result !== 0 || !outPtr[0]) {
return { success: false, error: `获取日志失败: ${result}` }
}
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析日志失败' }
return { success: true, logs: JSON.parse(jsonStr) }
} catch (e) {
return { success: false, error: String(e) }
}
}
async execQuery(kind: string, path: string | null, sql: string, params: any[] = []): Promise<{ success: boolean; rows?: any[]; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
try {
if (!this.wcdbExecQuery) return { success: false, error: '接口未就绪' }
// 如果提供了参数,使用参数化查询(需要 C++ 层支持)
// 注意:当前 wcdbExecQuery 可能不支持参数化,这是一个占位符实现
// TODO: 需要更新 C++ 层的 wcdb_exec_query 以支持参数绑定
if (params && params.length > 0) {
console.warn('[wcdbCore] execQuery: 参数化查询暂未在 C++ 层实现,将使用原始 SQL可能存在注入风险')
}
const outPtr = [null as any]
const result = this.wcdbExecQuery(this.handle, kind, path, sql, outPtr)
const result = this.wcdbExecQuery(this.handle, kind, path || '', sql, outPtr)
if (result !== 0 || !outPtr[0]) {
return { success: false, error: `执行查询失败: ${result}` }
}
@@ -1411,6 +1897,88 @@ export class WcdbCore {
}
}
/**
* 数据收集初始化
*/
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()
if (!initOk) return { success: false, error: 'WCDB 初始化失败' }
}
if (!this.wcdbVerifyUser) {
return { success: false, error: 'Binding not found: VerifyUser' }
}
return new Promise((resolve) => {
try {
// Allocate buffer for result JSON
const maxLen = 1024
const outBuf = Buffer.alloc(maxLen)
// Call native function
const hwndVal = hwnd ? BigInt(hwnd) : BigInt(0)
this.wcdbVerifyUser(hwndVal, message || '', outBuf, maxLen)
// Parse result
const jsonStr = this.koffi.decode(outBuf, 'char', -1)
const result = JSON.parse(jsonStr)
resolve(result)
} catch (e) {
resolve({ success: false, error: String(e) })
}
})
}
async getSnsTimeline(limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: any[]; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbGetSnsTimeline) return { success: false, error: '当前 DLL 版本不支持获取朋友圈' }
@@ -1438,4 +2006,196 @@ export class WcdbCore {
return { success: false, error: String(e) }
}
}
async getSnsAnnualStats(beginTimestamp: number, endTimestamp: number): Promise<{ success: boolean; data?: any; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
try {
if (!this.wcdbGetSnsAnnualStats) {
return { success: false, error: 'wcdbGetSnsAnnualStats 未找到' }
}
await new Promise(resolve => setImmediate(resolve))
const outPtr = [null as any]
const result = this.wcdbGetSnsAnnualStats(this.handle, beginTimestamp, endTimestamp, outPtr)
await new Promise(resolve => setImmediate(resolve))
if (result !== 0 || !outPtr[0]) {
return { success: false, error: `getSnsAnnualStats failed: ${result}` }
}
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: 'Failed to decode JSON' }
return { success: true, data: JSON.parse(jsonStr) }
} catch (e) {
console.error('getSnsAnnualStats 异常:', e)
return { success: false, error: String(e) }
}
}
/**
* 为朋友圈安装删除
*/
async installSnsBlockDeleteTrigger(): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbInstallSnsBlockDeleteTrigger) return { success: false, error: '当前 DLL 版本不支持此功能' }
try {
const outPtr = [null]
const status = this.wcdbInstallSnsBlockDeleteTrigger(this.handle, outPtr)
let msg = ''
if (outPtr[0]) {
try { msg = this.koffi.decode(outPtr[0], 'char', -1) } catch { }
try { this.wcdbFreeString(outPtr[0]) } catch { }
}
if (status === 1) {
// DLL 返回 1 表示已安装
return { success: true, alreadyInstalled: true }
}
if (status !== 0) {
return { success: false, error: msg || `DLL error ${status}` }
}
return { success: true, alreadyInstalled: false }
} catch (e) {
return { success: false, error: String(e) }
}
}
/**
* 关闭朋友圈删除拦截
*/
async uninstallSnsBlockDeleteTrigger(): Promise<{ success: boolean; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbUninstallSnsBlockDeleteTrigger) return { success: false, error: '当前 DLL 版本不支持此功能' }
try {
const outPtr = [null]
const status = this.wcdbUninstallSnsBlockDeleteTrigger(this.handle, outPtr)
let msg = ''
if (outPtr[0]) {
try { msg = this.koffi.decode(outPtr[0], 'char', -1) } catch { }
try { this.wcdbFreeString(outPtr[0]) } catch { }
}
if (status !== 0) {
return { success: false, error: msg || `DLL error ${status}` }
}
return { success: true }
} catch (e) {
return { success: false, error: String(e) }
}
}
/**
* 查询朋友圈删除拦截是否已安装
*/
async checkSnsBlockDeleteTrigger(): Promise<{ success: boolean; installed?: boolean; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbCheckSnsBlockDeleteTrigger) return { success: false, error: '当前 DLL 版本不支持此功能' }
try {
const outInstalled = [0]
const status = this.wcdbCheckSnsBlockDeleteTrigger(this.handle, outInstalled)
if (status !== 0) {
return { success: false, error: `DLL error ${status}` }
}
return { success: true, installed: outInstalled[0] === 1 }
} catch (e) {
return { success: false, error: String(e) }
}
}
async deleteSnsPost(postId: string): Promise<{ success: boolean; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbDeleteSnsPost) return { success: false, error: '当前 DLL 版本不支持此功能' }
try {
const outPtr = [null]
const status = this.wcdbDeleteSnsPost(this.handle, postId, outPtr)
let msg = ''
if (outPtr[0]) {
try { msg = this.koffi.decode(outPtr[0], 'char', -1) } catch { }
try { this.wcdbFreeString(outPtr[0]) } catch { }
}
if (status !== 0) {
return { success: false, error: msg || `DLL error ${status}` }
}
return { success: true }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getDualReportStats(sessionId: string, beginTimestamp: number = 0, endTimestamp: number = 0): Promise<{ success: boolean; data?: any; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
if (!this.wcdbGetDualReportStats) {
return { success: false, error: '未支持双人报告统计' }
}
try {
const { begin, end } = this.normalizeRange(beginTimestamp, endTimestamp)
const outPtr = [null as any]
const result = this.wcdbGetDualReportStats(this.handle, sessionId, begin, end, outPtr)
if (result !== 0 || !outPtr[0]) {
return { success: false, error: `获取双人报告统计失败: ${result}` }
}
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析双人报告统计失败' }
const data = JSON.parse(jsonStr)
return { success: true, data }
} catch (e) {
return { success: false, error: String(e) }
}
}
/**
* 修改消息内容
*/
async updateMessage(sessionId: string, localId: number, createTime: number, newContent: string): Promise<{ success: boolean; error?: string }> {
if (!this.initialized || !this.wcdbUpdateMessage) return { success: false, error: 'WCDB Not Initialized or Method Missing' }
if (!this.handle) return { success: false, error: 'Not Connected' }
return new Promise((resolve) => {
try {
const outError = [null as any]
const result = this.wcdbUpdateMessage(this.handle, sessionId, localId, createTime, newContent, outError)
if (result !== 0) {
let errorMsg = 'Unknown Error'
if (outError[0]) {
errorMsg = this.decodeJsonPtr(outError[0]) || 'Unknown Error (Decode Failed)'
}
resolve({ success: false, error: errorMsg })
return
}
resolve({ success: true })
} catch (e) {
resolve({ success: false, error: String(e) })
}
})
}
/**
* 删除消息
*/
async deleteMessage(sessionId: string, localId: number, createTime: number, dbPathHint?: string): Promise<{ success: boolean; error?: string }> {
if (!this.initialized || !this.wcdbDeleteMessage) return { success: false, error: 'WCDB Not Initialized or Method Missing' }
if (!this.handle) return { success: false, error: 'Not Connected' }
return new Promise((resolve) => {
try {
const outError = [null as any]
const result = this.wcdbDeleteMessage(this.handle, sessionId, localId, createTime || 0, dbPathHint || '', outError)
if (result !== 0) {
let errorMsg = 'Unknown Error'
if (outError[0]) {
errorMsg = this.decodeJsonPtr(outError[0]) || 'Unknown Error (Decode Failed)'
}
console.error(`[WcdbCore] deleteMessage fail: code=${result}, error=${errorMsg}`)
resolve({ success: false, error: errorMsg })
return
}
resolve({ success: true })
} catch (e) {
console.error(`[WcdbCore] deleteMessage exception:`, e)
resolve({ success: false, error: String(e) })
}
})
}
}

View File

@@ -23,6 +23,7 @@ export class WcdbService {
private resourcesPath: string | null = null
private userDataPath: string | null = null
private logEnabled = false
private monitorListener: ((type: string, json: string) => void) | null = null
constructor() {
this.initWorker()
@@ -47,8 +48,16 @@ export class WcdbService {
try {
this.worker = new Worker(finalPath)
this.worker.on('message', (msg: WorkerMessage) => {
const { id, result, error } = msg
this.worker.on('message', (msg: any) => {
const { id, result, error, type, payload } = msg
if (type === 'monitor') {
if (this.monitorListener) {
this.monitorListener(payload.type, payload.json)
}
return
}
const p = this.pending.get(id)
if (p) {
this.pending.delete(id)
@@ -122,6 +131,14 @@ export class WcdbService {
this.callWorker('setLogEnabled', { enabled }).catch(() => { })
}
/**
* 设置数据库监控回调
*/
setMonitor(callback: (type: string, json: string) => void): void {
this.monitorListener = callback;
this.callWorker('setMonitor').catch(() => { });
}
/**
* 检查服务是否就绪
*/
@@ -187,6 +204,13 @@ export class WcdbService {
return this.callWorker('getMessages', { sessionId, limit, offset })
}
/**
* 获取新消息(增量刷新)
*/
async getNewMessages(sessionId: string, minTime: number, limit: number = 1000): Promise<{ success: boolean; messages?: any[]; error?: string }> {
return this.callWorker('getNewMessages', { sessionId, minTime, limit })
}
/**
* 获取消息总数
*/
@@ -194,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 })
}
/**
* 获取联系人昵称
*/
@@ -229,6 +257,11 @@ export class WcdbService {
return this.callWorker('getGroupMembers', { chatroomId })
}
// 获取群成员群名片昵称
async getGroupNicknames(chatroomId: string): Promise<{ success: boolean; nicknames?: Record<string, string>; error?: string }> {
return this.callWorker('getGroupNicknames', { chatroomId })
}
/**
* 获取消息表列表
*/
@@ -243,6 +276,10 @@ export class WcdbService {
return this.callWorker('getMessageTableStats', { sessionId })
}
async getMessageDates(sessionId: string): Promise<{ success: boolean; dates?: string[]; error?: string }> {
return this.callWorker('getMessageDates', { sessionId })
}
/**
* 获取消息元数据
*/
@@ -257,6 +294,13 @@ export class WcdbService {
return this.callWorker('getContact', { username })
}
/**
* 批量获取联系人 extra_buffer 状态isFolded/isMuted
*/
async getContactStatus(usernames: string[]): Promise<{ success: boolean; map?: Record<string, { isFolded: boolean; isMuted: boolean }>; error?: string }> {
return this.callWorker('getContactStatus', { usernames })
}
/**
* 获取聚合统计数据
*/
@@ -285,6 +329,13 @@ export class WcdbService {
return this.callWorker('getAnnualReportExtras', { sessionIds, beginTimestamp, endTimestamp, peakDayBegin, peakDayEnd })
}
/**
* 获取双人报告统计数据
*/
async getDualReportStats(sessionId: string, beginTimestamp: number, endTimestamp: number): Promise<{ success: boolean; data?: any; error?: string }> {
return this.callWorker('getDualReportStats', { sessionId, beginTimestamp, endTimestamp })
}
/**
* 获取群聊统计
*/
@@ -321,10 +372,10 @@ export class WcdbService {
}
/**
* 执行 SQL 查询
* 执行 SQL 查询(支持参数化查询)
*/
async execQuery(kind: string, path: string | null, sql: string): Promise<{ success: boolean; rows?: any[]; error?: string }> {
return this.callWorker('execQuery', { kind, path, sql })
async execQuery(kind: string, path: string | null, sql: string, params: any[] = []): Promise<{ success: boolean; rows?: any[]; error?: string }> {
return this.callWorker('execQuery', { kind, path, sql, params })
}
/**
@@ -369,6 +420,92 @@ export class WcdbService {
return this.callWorker('getSnsTimeline', { limit, offset, usernames, keyword, startTime, endTime })
}
/**
* 获取朋友圈年度统计
*/
async getSnsAnnualStats(beginTimestamp: number, endTimestamp: number): Promise<{ success: boolean; data?: any; error?: string }> {
return this.callWorker('getSnsAnnualStats', { beginTimestamp, endTimestamp })
}
/**
* 安装朋友圈删除拦截
*/
async installSnsBlockDeleteTrigger(): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> {
return this.callWorker('installSnsBlockDeleteTrigger')
}
/**
* 卸载朋友圈删除拦截
*/
async uninstallSnsBlockDeleteTrigger(): Promise<{ success: boolean; error?: string }> {
return this.callWorker('uninstallSnsBlockDeleteTrigger')
}
/**
* 查询朋友圈删除拦截是否已安装
*/
async checkSnsBlockDeleteTrigger(): Promise<{ success: boolean; installed?: boolean; error?: string }> {
return this.callWorker('checkSnsBlockDeleteTrigger')
}
/**
* 从数据库直接删除朋友圈记录
*/
async deleteSnsPost(postId: string): Promise<{ success: boolean; error?: string }> {
return this.callWorker('deleteSnsPost', { postId })
}
/**
* 获取 DLL 内部日志
*/
async getLogs(): Promise<{ success: boolean; logs?: string[]; error?: string }> {
return this.callWorker('getLogs')
}
/**
* 验证 Windows Hello
*/
async verifyUser(message: string, hwnd?: string): Promise<{ success: boolean; error?: string }> {
return this.callWorker('verifyUser', { message, hwnd })
}
/**
* 修改消息内容
*/
async updateMessage(sessionId: string, localId: number, createTime: number, newContent: string): Promise<{ success: boolean; error?: string }> {
return this.callWorker('updateMessage', { sessionId, localId, createTime, newContent })
}
/**
* 删除消息
*/
async deleteMessage(sessionId: string, localId: number, createTime: number, dbPathHint?: string): Promise<{ success: boolean; error?: string }> {
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', {})
}
}
export const wcdbService = new WcdbService()

View File

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

View File

@@ -80,17 +80,17 @@ function isLanguageAllowed(result: any, allowedLanguages: string[]): boolean {
}
const langTag = result.lang
console.log('[TranscribeWorker] 检测到语言标记:', langTag)
// 检查是否在允许的语言列表中
for (const lang of allowedLanguages) {
if (LANGUAGE_TAGS[lang] === langTag) {
console.log('[TranscribeWorker] 语言匹配,允许:', lang)
return true
}
}
console.log('[TranscribeWorker] 语言不在白名单中,过滤掉')
return false
}
@@ -117,7 +117,7 @@ async function run() {
allowedLanguages = ['zh']
}
console.log('[TranscribeWorker] 使用的语言白名单:', allowedLanguages)
// 1. 初始化识别器 (SenseVoiceSmall)
const recognizerConfig = {
@@ -145,15 +145,15 @@ async function run() {
recognizer.decode(stream)
const result = recognizer.getResult(stream)
console.log('[TranscribeWorker] 识别完成 - 结果对象:', JSON.stringify(result, null, 2))
// 3. 检查语言是否在白名单中
if (isLanguageAllowed(result, allowedLanguages)) {
const processedText = richTranscribePostProcess(result.text)
console.log('[TranscribeWorker] 语言匹配,返回文本:', processedText)
parentPort.postMessage({ type: 'final', text: processedText })
} else {
console.log('[TranscribeWorker] 语言不匹配,返回空文本')
parentPort.postMessage({ type: 'final', text: '' })
}

114
electron/utils/LRUCache.ts Normal file
View File

@@ -0,0 +1,114 @@
/**
* LRU (Least Recently Used) Cache implementation for memory management
*/
export class LRUCache<K, V> {
private cache: Map<K, V>
private maxSize: number
constructor(maxSize: number = 100) {
this.maxSize = maxSize
this.cache = new Map()
}
/**
* Get value from cache
*/
get(key: K): V | undefined {
const value = this.cache.get(key)
if (value !== undefined) {
// Move to end (most recently used)
this.cache.delete(key)
this.cache.set(key, value)
}
return value
}
/**
* Set value in cache
*/
set(key: K, value: V): void {
if (this.cache.has(key)) {
// Update existing
this.cache.delete(key)
} else if (this.cache.size >= this.maxSize) {
// Remove least recently used (first item)
const firstKey = this.cache.keys().next().value
if (firstKey !== undefined) {
this.cache.delete(firstKey)
}
}
this.cache.set(key, value)
}
/**
* Check if key exists
*/
has(key: K): boolean {
return this.cache.has(key)
}
/**
* Delete key from cache
*/
delete(key: K): boolean {
return this.cache.delete(key)
}
/**
* Clear all cache entries
*/
clear(): void {
this.cache.clear()
}
/**
* Get current cache size
*/
get size(): number {
return this.cache.size
}
/**
* Get all keys (for debugging)
*/
keys(): IterableIterator<K> {
return this.cache.keys()
}
/**
* Get all values (for debugging)
*/
values(): IterableIterator<V> {
return this.cache.values()
}
/**
* Get all entries (for iteration support)
*/
entries(): IterableIterator<[K, V]> {
return this.cache.entries()
}
/**
* Make LRUCache iterable (for...of support)
*/
[Symbol.iterator](): IterableIterator<[K, V]> {
return this.cache.entries()
}
/**
* Force cleanup (optional method for explicit memory management)
*/
cleanup(): void {
// In JavaScript/TypeScript, this is mainly for consistency
// The garbage collector will handle actual memory cleanup
if (this.cache.size > this.maxSize * 1.5) {
// Emergency cleanup if cache somehow exceeds limit
const entries = Array.from(this.cache.entries())
this.cache.clear()
// Keep only the most recent half
const keepEntries = entries.slice(-Math.floor(this.maxSize / 2))
keepEntries.forEach(([key, value]) => this.cache.set(key, value))
}
}
}

View File

@@ -19,6 +19,16 @@ if (parentPort) {
core.setLogEnabled(payload.enabled)
result = { success: true }
break
case 'setMonitor':
core.setMonitor((type, json) => {
parentPort!.postMessage({
id: -1,
type: 'monitor',
payload: { type, json }
})
})
result = { success: true }
break
case 'testConnection':
result = await core.testConnection(payload.dbPath, payload.hexKey, payload.wxid)
break
@@ -38,9 +48,15 @@ if (parentPort) {
case 'getMessages':
result = await core.getMessages(payload.sessionId, payload.limit, payload.offset)
break
case 'getNewMessages':
result = await core.getNewMessages(payload.sessionId, payload.minTime, payload.limit)
break
case 'getMessageCount':
result = await core.getMessageCount(payload.sessionId)
break
case 'getMessageCounts':
result = await core.getMessageCounts(payload.sessionIds)
break
case 'getDisplayNames':
result = await core.getDisplayNames(payload.usernames)
break
@@ -56,18 +72,27 @@ if (parentPort) {
case 'getGroupMembers':
result = await core.getGroupMembers(payload.chatroomId)
break
case 'getGroupNicknames':
result = await core.getGroupNicknames(payload.chatroomId)
break
case 'getMessageTables':
result = await core.getMessageTables(payload.sessionId)
break
case 'getMessageTableStats':
result = await core.getMessageTableStats(payload.sessionId)
break
case 'getMessageDates':
result = await core.getMessageDates(payload.sessionId)
break
case 'getMessageMeta':
result = await core.getMessageMeta(payload.dbPath, payload.tableName, payload.limit, payload.offset)
break
case 'getContact':
result = await core.getContact(payload.username)
break
case 'getContactStatus':
result = await core.getContactStatus(payload.usernames)
break
case 'getAggregateStats':
result = await core.getAggregateStats(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp)
break
@@ -80,6 +105,9 @@ if (parentPort) {
case 'getAnnualReportExtras':
result = await core.getAnnualReportExtras(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp, payload.peakDayBegin, payload.peakDayEnd)
break
case 'getDualReportStats':
result = await core.getDualReportStats(payload.sessionId, payload.beginTimestamp, payload.endTimestamp)
break
case 'getGroupStats':
result = await core.getGroupStats(payload.chatroomId, payload.beginTimestamp, payload.endTimestamp)
break
@@ -96,7 +124,7 @@ if (parentPort) {
result = await core.closeMessageCursor(payload.cursor)
break
case 'execQuery':
result = await core.execQuery(payload.kind, payload.path, payload.sql)
result = await core.execQuery(payload.kind, payload.path, payload.sql, payload.params)
break
case 'getEmoticonCdnUrl':
result = await core.getEmoticonCdnUrl(payload.dbPath, payload.md5)
@@ -119,6 +147,42 @@ if (parentPort) {
case 'getSnsTimeline':
result = await core.getSnsTimeline(payload.limit, payload.offset, payload.usernames, payload.keyword, payload.startTime, payload.endTime)
break
case 'getSnsAnnualStats':
result = await core.getSnsAnnualStats(payload.beginTimestamp, payload.endTimestamp)
break
case 'installSnsBlockDeleteTrigger':
result = await core.installSnsBlockDeleteTrigger()
break
case 'uninstallSnsBlockDeleteTrigger':
result = await core.uninstallSnsBlockDeleteTrigger()
break
case 'checkSnsBlockDeleteTrigger':
result = await core.checkSnsBlockDeleteTrigger()
break
case 'deleteSnsPost':
result = await core.deleteSnsPost(payload.postId)
break
case 'getLogs':
result = await core.getLogs()
break
case 'verifyUser':
result = await core.verifyUser(payload.message, payload.hwnd)
break
case 'updateMessage':
result = await core.updateMessage(payload.sessionId, payload.localId, payload.createTime, payload.newContent)
break
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

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

View File

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

1493
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,19 @@
{
"name": "weflow",
"version": "1.4.0",
"version": "2.1.0",
"description": "WeFlow",
"main": "dist-electron/main.js",
"author": "cc",
"repository": {
"type": "git",
"url": "https://github.com/hicccc77/WeFlow"
},
"//": "二改不应改变此处的作者与应用信息",
"scripts": {
"postinstall": "echo 'No native modules to rebuild'",
"rebuild": "echo 'No native modules to rebuild'",
"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",
@@ -30,7 +35,10 @@
"lucide-react": "^0.562.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.1.1",
"react-virtuoso": "^4.18.1",
"remark-gfm": "^4.0.1",
"sherpa-onnx-node": "^1.10.38",
"silk-wasm": "^3.7.1",
"wechat-emojis": "^1.0.2",
@@ -55,6 +63,8 @@
"appId": "com.WeFlow.app",
"publish": {
"provider": "github",
"owner": "hicccc77",
"repo": "WeFlow",
"releaseType": "release"
},
"productName": "WeFlow",
@@ -97,6 +107,10 @@
{
"from": "public/icon.ico",
"to": "icon.ico"
},
{
"from": "electron/assets/wasm/",
"to": "assets/wasm/"
}
],
"files": [
@@ -105,7 +119,8 @@
],
"asarUnpack": [
"node_modules/silk-wasm/**/*",
"node_modules/sherpa-onnx-node/**/*"
"node_modules/sherpa-onnx-node/**/*",
"node_modules/ffmpeg-static/**/*"
],
"extraFiles": [
{
@@ -126,4 +141,4 @@
}
]
}
}
}

249
public/splash.html Normal file
View File

@@ -0,0 +1,249 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WeFlow</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%; height: 100%;
background: transparent;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', sans-serif;
user-select: none;
-webkit-app-region: drag;
}
.splash {
width: 100%; height: 100%;
border-radius: 20px;
display: flex;
flex-direction: column;
}
/* 品牌区 */
.brand {
padding: 48px 52px 0;
display: flex;
align-items: center;
gap: 18px;
animation: fadeIn 0.4s ease both;
}
.logo {
width: 56px; height: 56px;
border-radius: 14px;
flex-shrink: 0;
}
.app-name {
font-size: 22px;
font-weight: 700;
letter-spacing: 0.3px;
}
.app-desc {
font-size: 12px;
margin-top: 5px;
opacity: 0.6;
}
.spacer { flex: 1; }
/* 底部进度区 */
.bottom {
padding: 0 48px 40px;
animation: fadeIn 0.4s ease 0.1s both;
}
/* 进度条轨道 */
.progress-track {
width: 100%;
height: 2px;
border-radius: 2px;
margin-bottom: 12px;
position: relative;
overflow: hidden;
}
/* 进度条填充 */
.progress-fill {
height: 100%;
width: 0%;
border-radius: 2px;
position: relative;
transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
}
/* 扫光:只在有进度时显示,不循环 */
.progress-fill::after {
content: '';
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.5) 50%, transparent 100%);
animation: sweep 1.2s ease-out forwards;
opacity: 0;
}
/* 等待阶段:进度条末端呼吸光点 */
.progress-fill.waiting::before {
content: '';
position: absolute;
top: -1px; right: -2px;
width: 6px; height: 4px;
border-radius: 50%;
background: inherit;
filter: blur(2px);
animation: pulse 1.5s ease-in-out infinite;
}
.bottom-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.progress-text {
font-size: 11px;
opacity: 0.38;
}
.version {
font-size: 11px;
opacity: 0.25;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes sweep {
0% { opacity: 0; transform: translateX(-100%); }
20% { opacity: 1; }
80% { opacity: 1; }
100% { opacity: 0; transform: translateX(100%); }
}
@keyframes pulse {
0%, 100% { opacity: 0.4; transform: scaleX(1); }
50% { opacity: 1; transform: scaleX(1.8); }
}
</style>
</head>
<body>
<div class="splash" id="splash">
<div class="brand">
<img class="logo" src="./logo.png" alt="WeFlow" />
<div class="brand-text">
<div class="app-name" id="appName">WeFlow</div>
<div class="app-desc" id="appDesc">微信聊天记录管理工具</div>
</div>
</div>
<div class="spacer"></div>
<div class="bottom">
<div class="progress-track" id="progressTrack">
<div class="progress-fill" id="progressFill"></div>
</div>
<div class="bottom-row">
<div class="progress-text" id="progressText">正在启动...</div>
<div class="version" id="versionText"></div>
</div>
</div>
</div>
<script>
var themes = {
'cloud-dancer': {
light: { primary: '#8B7355', bg: '#F0EEE9', bgEnd: '#E5E1DA', text: '#3d3d3d', desc: '#8B7355' },
dark: { primary: '#C9A86C', bg: '#1a1816', bgEnd: '#252220', text: '#F0EEE9', desc: '#C9A86C' }
},
'corundum-blue': {
light: { primary: '#4A6670', bg: '#E8EEF0', bgEnd: '#D8E4E8', text: '#3d3d3d', desc: '#4A6670' },
dark: { primary: '#6A9AAA', bg: '#141a1c', bgEnd: '#1e2a2e', text: '#E0EEF2', desc: '#6A9AAA' }
},
'kiwi-green': {
light: { primary: '#7A9A5C', bg: '#E8F0E4', bgEnd: '#D8E8D2', text: '#3d3d3d', desc: '#7A9A5C' },
dark: { primary: '#9ABA7C', bg: '#161a14', bgEnd: '#222a1e', text: '#E8F0E4', desc: '#9ABA7C' }
},
'spicy-red': {
light: { primary: '#8B4049', bg: '#F0E8E8', bgEnd: '#E8D8D8', text: '#3d3d3d', desc: '#8B4049' },
dark: { primary: '#C06068', bg: '#1a1416', bgEnd: '#261e20', text: '#F2E8EA', desc: '#C06068' }
},
'teal-water': {
light: { primary: '#5A8A8A', bg: '#E4F0F0', bgEnd: '#D2E8E8', text: '#3d3d3d', desc: '#5A8A8A' },
dark: { primary: '#7ABAAA', bg: '#121a1a', bgEnd: '#1a2626', text: '#E0F2EE', desc: '#7ABAAA' }
},
'blossom-dream': {
light: { primary: '#D4849A', primaryEnd: '#D4849A', bg: '#FCF9FB', bgMid: '#F8F2F8', bgEnd: '#F2F6FB', text: '#2E2633', desc: '#D4849A' },
dark: { primary: '#C670C3', primaryEnd: '#8A60C0', bg: '#120B16', bgMid: '#1A1020', bgEnd: '#0E0B18', text: '#F2EAF4', desc: '#C670C3' }
}
};
function applyTheme(themeId, mode) {
var t = themes[themeId] || themes['cloud-dancer'];
var isDark = mode === 'dark';
if (mode === 'system') isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var c = isDark ? t.dark : t.light;
var el = document.getElementById('splash');
var fill = document.getElementById('progressFill');
if (themeId === 'blossom-dream') {
if (isDark) {
// 深色
el.style.background =
'radial-gradient(ellipse 60% 50% at 100% 0%, ' + c.primary + '28 0%, transparent 70%), ' +
'linear-gradient(150deg, ' + c.bg + ' 0%, ' + c.bgMid + ' 45%, ' + c.bgEnd + ' 100%)';
} else {
// 浅色
el.style.background = 'linear-gradient(150deg, ' + c.bg + ' 0%, ' + c.bgMid + ' 45%, ' + c.bgEnd + ' 100%)';
}
// 进度条
fill.style.background = 'linear-gradient(90deg, ' + c.primary + ' 0%, ' + c.primaryEnd + ' 100%)';
} else {
if (isDark) {
el.style.background =
'radial-gradient(ellipse 60% 50% at 100% 0%, ' + c.primary + '22 0%, transparent 70%), ' +
'linear-gradient(145deg, ' + c.bg + ' 0%, ' + c.bgEnd + ' 100%)';
} else {
el.style.background = 'linear-gradient(150deg, ' + c.bg + ' 0%, ' + c.bgEnd + ' 100%)';
}
fill.style.background = c.primary;
}
document.getElementById('appName').style.color = c.text;
document.getElementById('appDesc').style.color = c.desc;
document.getElementById('progressText').style.color = c.text;
document.getElementById('versionText').style.color = c.text;
document.getElementById('progressTrack').style.background = c.primary + (isDark ? '25' : '18');
}
// percent: 实际进度值waiting: 是否处于等待阶段
function updateProgress(percent, text, waiting) {
var fill = document.getElementById('progressFill');
var label = document.getElementById('progressText');
if (fill) {
fill.style.width = percent + '%';
if (waiting) {
fill.classList.add('waiting');
} else {
fill.classList.remove('waiting');
// 触发扫光:重置动画
fill.style.animation = 'none';
fill.offsetHeight;
fill.style.animation = '';
}
}
if (label && text) label.textContent = text;
}
function setVersion(ver) {
var el = document.getElementById('versionText');
if (el) el.textContent = 'v' + ver;
}
applyTheme('cloud-dancer', 'light');
</script>
</body>
</html>

Binary file not shown.

Binary file not shown.

View File

@@ -4,6 +4,59 @@
flex-direction: column;
background: var(--bg-primary);
animation: appFadeIn 0.35s ease-out;
position: relative;
overflow: hidden;
}
// 繁花如梦:底色层(::before+ 光晕层(::after分离避免 blur 吃掉边缘
[data-theme="blossom-dream"] .app-container {
background: transparent;
}
// ::before 纯底色,不模糊
[data-theme="blossom-dream"] .app-container::before {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
z-index: -2;
background: var(--bg-primary);
}
// ::after 光晕层,模糊叠加在底色上
[data-theme="blossom-dream"] .app-container::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
z-index: -1;
background:
radial-gradient(ellipse 55% 45% at 15% 20%, var(--blossom-pink) 0%, transparent 70%),
radial-gradient(ellipse 50% 40% at 85% 75%, var(--blossom-peach) 0%, transparent 65%),
radial-gradient(ellipse 45% 50% at 80% 10%, var(--blossom-blue) 0%, transparent 60%);
filter: blur(80px);
opacity: 0.75;
}
// 深色模式光晕更克制
[data-theme="blossom-dream"][data-mode="dark"] .app-container::after {
background:
radial-gradient(ellipse 55% 45% at 15% 20%, var(--blossom-pink) 0%, transparent 70%),
radial-gradient(ellipse 50% 40% at 85% 75%, var(--blossom-purple) 0%, transparent 65%),
radial-gradient(ellipse 45% 50% at 80% 10%, var(--blossom-blue) 0%, transparent 60%);
filter: blur(100px);
opacity: 0.2;
}
.window-drag-region {
position: fixed;
top: 0;
left: 0;
right: 150px; // 预留系统最小化/最大化/关闭按钮区域
height: 40px;
-webkit-app-region: drag;
pointer-events: auto;
z-index: 2000;
}
.main-layout {
@@ -16,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

@@ -10,46 +10,83 @@ import AnalyticsPage from './pages/AnalyticsPage'
import AnalyticsWelcomePage from './pages/AnalyticsWelcomePage'
import AnnualReportPage from './pages/AnnualReportPage'
import AnnualReportWindow from './pages/AnnualReportWindow'
import DualReportPage from './pages/DualReportPage'
import DualReportWindow from './pages/DualReportWindow'
import AgreementPage from './pages/AgreementPage'
import GroupAnalyticsPage from './pages/GroupAnalyticsPage'
import DataManagementPage from './pages/DataManagementPage'
import SettingsPage from './pages/SettingsPage'
import ExportPage from './pages/ExportPage'
import VideoWindow from './pages/VideoWindow'
import ImageWindow from './pages/ImageWindow'
import SnsPage from './pages/SnsPage'
import ContactsPage from './pages/ContactsPage'
import ChatHistoryPage from './pages/ChatHistoryPage'
import NotificationWindow from './pages/NotificationWindow'
import { useAppStore } from './stores/appStore'
import { themes, useThemeStore, type ThemeId } from './stores/themeStore'
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'
import UpdateDialog from './components/UpdateDialog'
import UpdateProgressCapsule from './components/UpdateProgressCapsule'
import LockScreen from './components/LockScreen'
import { GlobalSessionMonitor } from './components/GlobalSessionMonitor'
import { BatchTranscribeGlobal } from './components/BatchTranscribeGlobal'
import { BatchImageDecryptGlobal } from './components/BatchImageDecryptGlobal'
function App() {
const navigate = useNavigate()
const location = useLocation()
const { setDbConnected } = useAppStore()
const {
setDbConnected,
updateInfo,
setUpdateInfo,
isDownloading,
setIsDownloading,
downloadProgress,
setDownloadProgress,
showUpdateDialog,
setShowUpdateDialog,
setUpdateError,
isLocked,
setLocked
} = useAppStore()
const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore()
const isAgreementWindow = location.pathname === '/agreement-window'
const isOnboardingWindow = location.pathname === '/onboarding-window'
const isVideoPlayerWindow = location.pathname === '/video-player-window'
const isChatHistoryWindow = location.pathname.startsWith('/chat-history/')
const isStandaloneChatWindow = location.pathname === '/chat-window'
const isNotificationWindow = location.pathname === '/notification-window'
const isExportRoute = location.pathname === '/export'
const [themeHydrated, setThemeHydrated] = useState(false)
// 锁定状态
// const [isLocked, setIsLocked] = useState(false) // Moved to store
const [lockAvatar, setLockAvatar] = useState<string | undefined>(
localStorage.getItem('app_lock_avatar') || undefined
)
const [lockUseHello, setLockUseHello] = useState(false)
// 协议同意状态
const [showAgreement, setShowAgreement] = useState(false)
const [agreementChecked, setAgreementChecked] = useState(false)
const [agreementLoading, setAgreementLoading] = useState(true)
// 更新提示状态
const [updateInfo, setUpdateInfo] = useState<{ version: string; releaseNotes: string } | null>(null)
const [isDownloading, setIsDownloading] = useState(false)
const [downloadProgress, setDownloadProgress] = useState(0)
// 数据收集同意状态
const [showAnalyticsConsent, setShowAnalyticsConsent] = useState(false)
useEffect(() => {
const root = document.documentElement
const body = document.body
const appRoot = document.getElementById('app')
if (isOnboardingWindow) {
if (isOnboardingWindow || isNotificationWindow) {
root.style.background = 'transparent'
body.style.background = 'transparent'
body.style.overflow = 'hidden'
@@ -70,15 +107,28 @@ function App() {
// 应用主题
useEffect(() => {
document.documentElement.setAttribute('data-theme', currentTheme)
document.documentElement.setAttribute('data-mode', themeMode)
// 更新窗口控件颜色以适配主题
const symbolColor = themeMode === 'dark' ? '#ffffff' : '#1a1a1a'
if (!isOnboardingWindow) {
window.electronAPI.window.setTitleBarOverlay({ symbolColor })
const mq = window.matchMedia('(prefers-color-scheme: dark)')
const applyMode = (mode: ThemeMode, systemDark?: boolean) => {
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 })
}
}
}, [currentTheme, themeMode, isOnboardingWindow])
applyMode(themeMode)
// 监听系统主题变化
const handler = (e: MediaQueryListEvent) => {
if (useThemeStore.getState().themeMode === 'system') {
applyMode('system', e.matches)
}
}
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [currentTheme, themeMode, isOnboardingWindow, isNotificationWindow])
// 读取已保存的主题设置
useEffect(() => {
@@ -91,7 +141,7 @@ function App() {
if (savedThemeId && themes.some((theme) => theme.id === savedThemeId)) {
setTheme(savedThemeId as ThemeId)
}
if (savedThemeMode === 'light' || savedThemeMode === 'dark') {
if (savedThemeMode === 'light' || savedThemeMode === 'dark' || savedThemeMode === 'system') {
setThemeMode(savedThemeMode)
}
} catch (e) {
@@ -126,6 +176,12 @@ function App() {
const agreed = await configService.getAgreementAccepted()
if (!agreed) {
setShowAgreement(true)
} else {
// 协议已同意,检查数据收集同意状态
const consent = await configService.getAnalyticsConsent()
if (consent === null) {
setShowAnalyticsConsent(true)
}
}
} catch (e) {
console.error('检查协议状态失败:', e)
@@ -136,38 +192,97 @@ 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 () => {
await configService.setAnalyticsConsent(false)
window.electronAPI.window.close()
}
// 监听启动时的更新通知
useEffect(() => {
const removeUpdateListener = window.electronAPI.app.onUpdateAvailable?.((info) => {
setUpdateInfo(info)
if (isNotificationWindow) return // Skip updates in notification window
const removeUpdateListener = window.electronAPI?.app?.onUpdateAvailable?.((info: any) => {
// 发现新版本时保存更新信息,锁定状态下不弹窗,解锁后再显示
if (info) {
setUpdateInfo({ ...info, hasUpdate: true })
if (!useAppStore.getState().isLocked) {
setShowUpdateDialog(true)
}
}
})
const removeProgressListener = window.electronAPI.app.onDownloadProgress?.((progress) => {
const removeProgressListener = window.electronAPI?.app?.onDownloadProgress?.((progress: any) => {
setDownloadProgress(progress)
})
return () => {
removeUpdateListener?.()
removeProgressListener?.()
}
}, [])
}, [setUpdateInfo, setDownloadProgress, setShowUpdateDialog, isNotificationWindow])
// 解锁后显示暂存的更新弹窗
useEffect(() => {
if (!isLocked && updateInfo?.hasUpdate && !showUpdateDialog && !isDownloading) {
setShowUpdateDialog(true)
}
}, [isLocked])
const handleUpdateNow = async () => {
setShowUpdateDialog(false)
setIsDownloading(true)
setDownloadProgress(0)
setDownloadProgress({ percent: 0 })
try {
await window.electronAPI.app.downloadAndInstall()
} catch (e) {
} catch (e: any) {
console.error('更新失败:', e)
setIsDownloading(false)
// Extract clean error message if possible
const errorMsg = e.message || String(e)
setUpdateError(errorMsg.includes('暂时禁用') ? '自动更新已暂时禁用' : errorMsg)
}
}
const handleIgnoreUpdate = async () => {
if (!updateInfo || !updateInfo.version) return
try {
await window.electronAPI.app.ignoreUpdate(updateInfo.version)
setShowUpdateDialog(false)
setUpdateInfo(null)
} catch (e: any) {
console.error('忽略更新失败:', e)
}
}
@@ -197,18 +312,18 @@ function App() {
if (!onboardingDone) {
await configService.setOnboardingDone(true)
}
console.log('检测到已保存的配置,正在自动连接...')
const result = await window.electronAPI.chat.connect()
if (result.success) {
console.log('自动连接成功')
setDbConnected(true, dbPath)
// 如果当前在欢迎页,跳转到首页
if (window.location.hash === '#/' || window.location.hash === '') {
navigate('/home')
}
} else {
console.log('自动连接失败:', result.error)
// 如果错误信息包含 VC++ 或 DLL 相关内容,不清除配置,只提示用户
// 其他错误可能需要重新配置
const errorMsg = result.error || ''
@@ -231,6 +346,35 @@ function App() {
autoConnect()
}, [isAgreementWindow, isOnboardingWindow, navigate, setDbConnected])
// 检查应用锁
useEffect(() => {
if (isAgreementWindow || isOnboardingWindow || isVideoPlayerWindow) return
const checkLock = async () => {
// 并行获取配置,减少等待
const [enabled, useHello] = await Promise.all([
window.electronAPI.auth.verifyEnabled(),
configService.getAuthUseHello()
])
if (enabled) {
setLockUseHello(useHello)
setLocked(true)
// 尝试获取头像
try {
const result = await window.electronAPI.chat.getMyAvatarUrl()
if (result && result.success && result.avatarUrl) {
setLockAvatar(result.avatarUrl)
localStorage.setItem('app_lock_avatar', result.avatarUrl)
}
} catch (e) {
console.error('获取锁屏头像失败', e)
}
}
}
checkLock()
}, [isAgreementWindow, isOnboardingWindow, isVideoPlayerWindow])
// 独立协议窗口
if (isAgreementWindow) {
return <AgreementPage />
@@ -245,11 +389,51 @@ function App() {
return <VideoWindow />
}
// 独立图片查看窗口
const isImageViewerWindow = location.pathname === '/image-viewer-window'
if (isImageViewerWindow) {
return <ImageWindow />
}
// 独立聊天记录窗口
if (isChatHistoryWindow) {
return <ChatHistoryPage />
}
// 独立会话聊天窗口(仅显示聊天内容区域)
if (isStandaloneChatWindow) {
const sessionId = new URLSearchParams(location.search).get('sessionId') || ''
return <ChatPage standaloneSessionWindow initialSessionId={sessionId} />
}
// 独立通知窗口
if (isNotificationWindow) {
return <NotificationWindow />
}
// 主窗口 - 完整布局
return (
<div className="app-container">
<div className="window-drag-region" aria-hidden="true" />
{isLocked && (
<LockScreen
onUnlock={() => setLocked(false)}
avatar={lockAvatar}
useHello={lockUseHello}
/>
)}
<TitleBar />
{/* 全局悬浮进度胶囊 (处理:新版本提示、下载进度、错误提示) */}
<UpdateProgressCapsule />
{/* 全局会话监听与通知 */}
<GlobalSessionMonitor />
{/* 全局批量转写进度浮窗 */}
<BatchTranscribeGlobal />
<BatchImageDecryptGlobal />
{/* 用户协议弹窗 */}
{showAgreement && !agreementLoading && (
<div className="agreement-overlay">
@@ -271,13 +455,13 @@ function App() {
</div>
<div className="agreement-text">
<h4>1. </h4>
<p></p>
<p></p>
<h4>2. 使</h4>
<p>使使</p>
<p>使使</p>
<h4>3. </h4>
<p>使使</p>
<p>使使</p>
<h4>4. </h4>
<p></p>
@@ -301,49 +485,79 @@ function App() {
</div>
)}
{/* 更新提示条 */}
{updateInfo && (
<div className="update-banner">
<span className="update-text">
<strong>v{updateInfo.version}</strong>
</span>
{isDownloading ? (
<div className="update-progress">
<div className="progress-bar">
<div className="progress-fill" style={{ width: `${downloadProgress}%` }} />
</div>
<span>{downloadProgress.toFixed(0)}%</span>
{/* 数据收集同意弹窗 */}
{showAnalyticsConsent && !agreementLoading && (
<div className="agreement-overlay">
<div className="agreement-modal">
<div className="agreement-header">
<Shield size={32} />
<h2>使</h2>
</div>
) : (
<>
<button className="update-btn" onClick={handleUpdateNow}>
<Download size={14} />
</button>
<button className="dismiss-btn" onClick={dismissUpdate}>
<X size={14} />
</button>
</>
)}
<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}
updateInfo={updateInfo}
onClose={() => setShowUpdateDialog(false)}
onUpdate={handleUpdateNow}
onIgnore={handleIgnoreUpdate}
isDownloading={isDownloading}
progress={downloadProgress}
/>
<div className="main-layout">
<Sidebar />
<main className="content">
<RouteGuard>
<div className={`export-keepalive-page ${isExportRoute ? 'active' : 'hidden'}`} aria-hidden={!isExportRoute}>
<ExportPage />
</div>
<Routes>
<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="/annual-report" element={<AnnualReportPage />} />
<Route path="/annual-report/view" element={<AnnualReportWindow />} />
<Route path="/data-management" element={<DataManagementPage />} />
<Route path="/dual-report" element={<DualReportPage />} />
<Route path="/dual-report/view" element={<DualReportWindow />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/export" element={<ExportPage />} />
<Route path="/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 />} />
</Routes>
</RouteGuard>
</main>

View File

@@ -0,0 +1,133 @@
import React, { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { Loader2, X, Image as ImageIcon, Clock, CheckCircle, XCircle } from 'lucide-react'
import { useBatchImageDecryptStore } from '../stores/batchImageDecryptStore'
import { useBatchTranscribeStore } from '../stores/batchTranscribeStore'
import '../styles/batchTranscribe.scss'
export const BatchImageDecryptGlobal: React.FC = () => {
const {
isBatchDecrypting,
progress,
showToast,
showResultToast,
result,
sessionName,
startTime,
setShowToast,
setShowResultToast
} = useBatchImageDecryptStore()
const voiceToastOccupied = useBatchTranscribeStore(
state => state.isBatchTranscribing && state.showToast
)
const [eta, setEta] = useState('')
useEffect(() => {
if (!isBatchDecrypting || !startTime || progress.current === 0) {
setEta('')
return
}
const timer = setInterval(() => {
const elapsed = Date.now() - startTime
if (elapsed <= 0) return
const rate = progress.current / elapsed
const remain = progress.total - progress.current
if (remain <= 0 || rate <= 0) {
setEta('')
return
}
const seconds = Math.ceil((remain / rate) / 1000)
if (seconds < 60) {
setEta(`${seconds}`)
} else {
const m = Math.floor(seconds / 60)
const s = seconds % 60
setEta(`${m}${s}`)
}
}, 1000)
return () => clearInterval(timer)
}, [isBatchDecrypting, progress.current, progress.total, startTime])
useEffect(() => {
if (!showResultToast) return
const timer = window.setTimeout(() => setShowResultToast(false), 6000)
return () => window.clearTimeout(timer)
}, [showResultToast, setShowResultToast])
const toastBottom = useMemo(() => (voiceToastOccupied ? 148 : 24), [voiceToastOccupied])
return (
<>
{showToast && isBatchDecrypting && createPortal(
<div className="batch-progress-toast" style={{ bottom: toastBottom }}>
<div className="batch-progress-toast-header">
<div className="batch-progress-toast-title">
<Loader2 size={14} className="spin" />
<span>{sessionName ? `${sessionName}` : ''}</span>
</div>
<button className="batch-progress-toast-close" onClick={() => setShowToast(false)} title="最小化">
<X size={14} />
</button>
</div>
<div className="batch-progress-toast-body">
<div className="progress-info-row">
<div className="progress-text">
<span>{progress.current} / {progress.total}</span>
<span className="progress-percent">
{progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0}%
</span>
</div>
{eta && (
<div className="progress-eta">
<Clock size={12} />
<span> {eta}</span>
</div>
)}
</div>
<div className="progress-bar">
<div
className="progress-fill"
style={{
width: `${progress.total > 0 ? (progress.current / progress.total) * 100 : 0}%`
}}
/>
</div>
</div>
</div>,
document.body
)}
{showResultToast && createPortal(
<div className="batch-progress-toast batch-inline-result-toast" style={{ bottom: toastBottom }}>
<div className="batch-progress-toast-header">
<div className="batch-progress-toast-title">
<ImageIcon size={14} />
<span></span>
</div>
<button className="batch-progress-toast-close" onClick={() => setShowResultToast(false)} title="关闭">
<X size={14} />
</button>
</div>
<div className="batch-progress-toast-body">
<div className="batch-inline-result-summary">
<div className="batch-inline-result-item success">
<CheckCircle size={14} />
<span> {result.success}</span>
</div>
<div className={`batch-inline-result-item ${result.fail > 0 ? 'fail' : 'muted'}`}>
<XCircle size={14} />
<span> {result.fail}</span>
</div>
</div>
</div>
</div>,
document.body
)}
</>
)
}

View File

@@ -0,0 +1,147 @@
import React, { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { Loader2, X, CheckCircle, XCircle, AlertCircle, Clock } from 'lucide-react'
import { useBatchTranscribeStore } from '../stores/batchTranscribeStore'
import '../styles/batchTranscribe.scss'
/**
* 全局批量转写进度浮窗 + 结果弹窗
* 挂载在 App 层,切换页面时不会消失
*/
export const BatchTranscribeGlobal: React.FC = () => {
const {
isBatchTranscribing,
progress,
showToast,
showResult,
result,
sessionName,
startTime,
setShowToast,
setShowResult
} = useBatchTranscribeStore()
const [eta, setEta] = useState<string>('')
// 计算剩余时间
useEffect(() => {
if (!isBatchTranscribing || !startTime || progress.current === 0) {
setEta('')
return
}
const timer = setInterval(() => {
const now = Date.now()
const elapsed = now - startTime
const rate = progress.current / elapsed // ms per item
const remainingItems = progress.total - progress.current
if (remainingItems <= 0) {
setEta('')
return
}
const remainingTimeMs = remainingItems / rate
const remainingSeconds = Math.ceil(remainingTimeMs / 1000)
if (remainingSeconds < 60) {
setEta(`${remainingSeconds}`)
} else {
const minutes = Math.floor(remainingSeconds / 60)
const seconds = remainingSeconds % 60
setEta(`${minutes}${seconds}`)
}
}, 1000)
return () => clearInterval(timer)
}, [isBatchTranscribing, startTime, progress.current, progress.total])
return (
<>
{/* 批量转写进度浮窗(非阻塞) */}
{showToast && isBatchTranscribing && createPortal(
<div className="batch-progress-toast">
<div className="batch-progress-toast-header">
<div className="batch-progress-toast-title">
<Loader2 size={14} className="spin" />
<span>{sessionName ? `${sessionName}` : ''}</span>
</div>
<button className="batch-progress-toast-close" onClick={() => setShowToast(false)} title="最小化">
<X size={14} />
</button>
</div>
<div className="batch-progress-toast-body">
<div className="progress-info-row">
<div className="progress-text">
<span>{progress.current} / {progress.total}</span>
<span className="progress-percent">
{progress.total > 0
? Math.round((progress.current / progress.total) * 100)
: 0}%
</span>
</div>
{eta && (
<div className="progress-eta">
<Clock size={12} />
<span> {eta}</span>
</div>
)}
</div>
<div className="progress-bar">
<div
className="progress-fill"
style={{
width: `${progress.total > 0
? (progress.current / progress.total) * 100
: 0}%`
}}
/>
</div>
</div>
</div>,
document.body
)}
{/* 批量转写结果对话框 */}
{showResult && createPortal(
<div className="batch-modal-overlay" onClick={() => setShowResult(false)}>
<div className="batch-modal-content batch-result-modal" onClick={(e) => e.stopPropagation()}>
<div className="batch-modal-header">
<CheckCircle size={20} />
<h3></h3>
</div>
<div className="batch-modal-body">
<div className="result-summary">
<div className="result-item success">
<CheckCircle size={18} />
<span className="label">:</span>
<span className="value">{result.success} </span>
</div>
{result.fail > 0 && (
<div className="result-item fail">
<XCircle size={18} />
<span className="label">:</span>
<span className="value">{result.fail} </span>
</div>
)}
</div>
{result.fail > 0 && (
<div className="result-tip">
<AlertCircle size={16} />
<span></span>
</div>
)}
</div>
<div className="batch-modal-footer">
<button className="btn-primary" onClick={() => setShowResult(false)}>
</button>
</div>
</div>
</div>,
document.body
)}
</>
)
}

View File

@@ -70,6 +70,7 @@
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
@@ -138,12 +139,25 @@
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
&.clickable {
cursor: pointer;
border-radius: 6px;
padding: 2px 8px;
transition: all 0.15s;
&:hover {
background: var(--bg-hover);
color: var(--primary);
}
}
}
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-template-rows: auto repeat(6, 32px);
gap: 2px;
}
@@ -156,7 +170,6 @@
}
.calendar-day {
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
@@ -211,4 +224,68 @@
padding-top: 12px;
border-top: 1px solid var(--border-color);
}
}
.year-month-picker {
padding: 4px 0;
.year-selector {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
.year-label {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.nav-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
background: var(--bg-tertiary);
border-radius: 6px;
cursor: pointer;
color: var(--text-secondary);
transition: all 0.15s;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
}
}
.month-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
.month-btn {
padding: 8px 0;
border: none;
background: transparent;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
color: var(--text-secondary);
transition: all 0.15s;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
&.active {
background: var(--primary);
color: #fff;
}
}
}
}
}

View File

@@ -26,6 +26,7 @@ function DateRangePicker({ startDate, endDate, onStartDateChange, onEndDateChang
const [isOpen, setIsOpen] = useState(false)
const [currentMonth, setCurrentMonth] = useState(new Date())
const [selectingStart, setSelectingStart] = useState(true)
const [showYearMonthPicker, setShowYearMonthPicker] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
// 点击外部关闭
@@ -86,7 +87,7 @@ function DateRangePicker({ startDate, endDate, onStartDateChange, onEndDateChang
const handleDateClick = (day: number) => {
const dateStr = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
if (selectingStart) {
onStartDateChange(dateStr)
if (endDate && dateStr > endDate) {
@@ -125,8 +126,8 @@ function DateRangePicker({ startDate, endDate, onStartDateChange, onEndDateChang
const isToday = (day: number) => {
const today = new Date()
return currentMonth.getFullYear() === today.getFullYear() &&
currentMonth.getMonth() === today.getMonth() &&
day === today.getDate()
currentMonth.getMonth() === today.getMonth() &&
day === today.getDate()
}
const renderCalendar = () => {
@@ -185,12 +186,38 @@ function DateRangePicker({ startDate, endDate, onStartDateChange, onEndDateChang
<button className="nav-btn" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1))}>
<ChevronLeft size={16} />
</button>
<span className="month-year">{currentMonth.getFullYear()} {MONTH_NAMES[currentMonth.getMonth()]}</span>
<span className="month-year clickable" onClick={() => setShowYearMonthPicker(!showYearMonthPicker)}>
{currentMonth.getFullYear()} {MONTH_NAMES[currentMonth.getMonth()]}
</span>
<button className="nav-btn" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1))}>
<ChevronRight size={16} />
</button>
</div>
{renderCalendar()}
{showYearMonthPicker ? (
<div className="year-month-picker">
<div className="year-selector">
<button className="nav-btn" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() - 1, currentMonth.getMonth()))}>
<ChevronLeft size={14} />
</button>
<span className="year-label">{currentMonth.getFullYear()}</span>
<button className="nav-btn" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() + 1, currentMonth.getMonth()))}>
<ChevronRight size={14} />
</button>
</div>
<div className="month-grid">
{MONTH_NAMES.map((name, i) => (
<button
key={i}
className={`month-btn ${i === currentMonth.getMonth() ? 'active' : ''}`}
onClick={() => {
setCurrentMonth(new Date(currentMonth.getFullYear(), i))
setShowYearMonthPicker(false)
}}
>{name}</button>
))}
</div>
</div>
) : renderCalendar()}
<div className="selection-hint">
{selectingStart ? '请选择开始日期' : '请选择结束日期'}
</div>

View File

@@ -0,0 +1,280 @@
import { useEffect, useRef } from 'react'
import { useChatStore } from '../stores/chatStore'
import type { ChatSession } from '../types/models'
import { useNavigate } from 'react-router-dom'
export function GlobalSessionMonitor() {
const navigate = useNavigate()
const {
sessions,
setSessions,
currentSessionId,
appendMessages,
messages
} = useChatStore()
const sessionsRef = useRef(sessions)
// 保持 ref 同步
useEffect(() => {
sessionsRef.current = sessions
}, [sessions])
// 去重辅助函数:获取消息 key
const getMessageKey = (msg: any) => {
if (msg.localId && msg.localId > 0) return `l:${msg.localId}`
return `t:${msg.createTime}:${msg.sortSeq || 0}:${msg.serverId || 0}`
}
// 处理数据库变更
useEffect(() => {
const handleDbChange = (_event: any, data: { type: string; json: string }) => {
try {
const payload = JSON.parse(data.json)
const tableName = payload.table
// 只关注 Session 表
if (tableName === 'Session' || tableName === 'session') {
refreshSessions()
}
} catch (e) {
console.error('解析数据库变更失败:', e)
}
}
if (window.electronAPI.chat.onWcdbChange) {
const removeListener = window.electronAPI.chat.onWcdbChange(handleDbChange)
return () => {
removeListener()
}
} else {
}
return () => { }
}, [])
const refreshSessions = async () => {
try {
const result = await window.electronAPI.chat.getSessions()
if (result.success && result.sessions && Array.isArray(result.sessions)) {
const newSessions = result.sessions as ChatSession[]
const oldSessions = sessionsRef.current
// 1. 检测变更并通知
checkForNewMessages(oldSessions, newSessions)
// 2. 更新 store
setSessions(newSessions)
// 3. 如果在活跃会话中,增量刷新消息
const currentId = useChatStore.getState().currentSessionId
if (currentId) {
const currentSessionNew = newSessions.find(s => s.username === currentId)
const currentSessionOld = oldSessions.find(s => s.username === currentId)
if (currentSessionNew && (!currentSessionOld || currentSessionNew.lastTimestamp > currentSessionOld.lastTimestamp)) {
void handleActiveSessionRefresh(currentId)
}
}
}
} catch (e) {
console.error('全局会话刷新失败:', e)
}
}
const checkForNewMessages = async (oldSessions: ChatSession[], newSessions: ChatSession[]) => {
if (!oldSessions || oldSessions.length === 0) {
console.log('[NotificationFilter] Skipping check on initial load (empty baseline)')
return
}
const oldMap = new Map(oldSessions.map(s => [s.username, s]))
for (const newSession of newSessions) {
const oldSession = oldMap.get(newSession.username)
// 条件: 新会话或时间戳更新
const isCurrentSession = newSession.username === useChatStore.getState().currentSessionId
if (!isCurrentSession && (!oldSession || newSession.lastTimestamp > oldSession.lastTimestamp)) {
// 这是新消息事件
// 免打扰、折叠群、折叠入口不弹通知
if (newSession.isMuted || newSession.isFolded) continue
if (newSession.username.toLowerCase().includes('placeholder_foldgroup')) continue
// 1. 群聊过滤自己发送的消息
if (newSession.username.includes('@chatroom')) {
// 如果是自己发的消息,不弹通知
// 注意lastMsgSender 需要后端支持返回
// 使用宽松比较以处理 wxid_ 前缀差异
if (newSession.lastMsgSender && newSession.selfWxid) {
const sender = newSession.lastMsgSender.replace(/^wxid_/, '');
const self = newSession.selfWxid.replace(/^wxid_/, '');
// 使用主进程日志打印,方便用户查看
const debugInfo = {
type: 'NotificationFilter',
username: newSession.username,
lastMsgSender: newSession.lastMsgSender,
selfWxid: newSession.selfWxid,
senderClean: sender,
selfClean: self,
match: sender === self
};
if (window.electronAPI.log?.debug) {
window.electronAPI.log.debug(debugInfo);
} else {
console.log('[NotificationFilter]', debugInfo);
}
if (sender === self) {
if (window.electronAPI.log?.debug) {
window.electronAPI.log.debug('[NotificationFilter] Filtered own message');
} else {
console.log('[NotificationFilter] Filtered own message');
}
continue;
}
} else {
const missingInfo = {
type: 'NotificationFilter Missing info',
lastMsgSender: newSession.lastMsgSender,
selfWxid: newSession.selfWxid
};
if (window.electronAPI.log?.debug) {
window.electronAPI.log.debug(missingInfo);
} else {
console.log('[NotificationFilter] Missing info:', missingInfo);
}
}
}
// 新增:如果未读数量没有增加,说明可能是自己在其他设备回复(或者已读),不弹通知
const oldUnread = oldSession ? oldSession.unreadCount : 0
const newUnread = newSession.unreadCount
if (newUnread <= oldUnread) {
// 仅仅是状态同步(如自己在手机上发消息 or 已读),跳过通知
continue
}
let title = newSession.displayName || newSession.username
let avatarUrl = newSession.avatarUrl
let content = newSession.summary || '[新消息]'
if (newSession.username.includes('@chatroom')) {
// 1. 群聊过滤自己发送的消息
// 辅助函数:清理 wxid 后缀 (如 _8602)
const cleanWxid = (id: string) => {
if (!id) return '';
const trimmed = id.trim();
// 仅移除末尾的 _xxxx (4位字母数字)
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/);
return suffixMatch ? suffixMatch[1] : trimmed;
}
if (newSession.lastMsgSender && newSession.selfWxid) {
const senderClean = cleanWxid(newSession.lastMsgSender);
const selfClean = cleanWxid(newSession.selfWxid);
const match = senderClean === selfClean;
if (match) {
continue;
}
}
// 2. 群聊显示发送者名字 (放在内容中: "Name: Message")
// 标题保持为群聊名称 (title 变量)
if (newSession.lastSenderDisplayName) {
content = `${newSession.lastSenderDisplayName}: ${content}`
}
}
// 修复 "Random User" 的逻辑 (缺少具体信息)
// 如果标题看起来像 wxid 或没有头像,尝试获取信息
const needsEnrichment = !newSession.displayName || !newSession.avatarUrl || newSession.displayName === newSession.username
if (needsEnrichment && newSession.username) {
try {
// 尝试丰富或获取联系人详情
const contact = await window.electronAPI.chat.getContact(newSession.username)
if (contact) {
if (contact.remark || contact.nickName) {
title = contact.remark || contact.nickName
}
const avatarResult = await window.electronAPI.chat.getContactAvatar(newSession.username)
if (avatarResult?.avatarUrl) {
avatarUrl = avatarResult.avatarUrl
}
} else {
// 如果不在缓存/数据库中
const enrichResult = await window.electronAPI.chat.enrichSessionsContactInfo([newSession.username])
if (enrichResult.success && enrichResult.contacts) {
const enrichedContact = enrichResult.contacts[newSession.username]
if (enrichedContact) {
if (enrichedContact.displayName) {
title = enrichedContact.displayName
}
if (enrichedContact.avatarUrl) {
avatarUrl = enrichedContact.avatarUrl
}
}
}
// 如果仍然没有有效名称,再尝试一次获取
if (title === newSession.username || title.startsWith('wxid_')) {
const retried = await window.electronAPI.chat.getContact(newSession.username)
if (retried) {
title = retried.remark || retried.nickName || title
const retriedAvatar = await window.electronAPI.chat.getContactAvatar(newSession.username)
if (retriedAvatar?.avatarUrl) {
avatarUrl = retriedAvatar.avatarUrl
}
}
}
}
} catch (e) {
console.warn('获取通知的联系人信息失败', e)
}
}
// 最终检查:如果标题仍是 wxid 格式,则跳过通知(避免显示乱跳用户)
// 群聊例外,因为群聊 username 包含 @chatroom
const isGroupChat = newSession.username.includes('@chatroom')
const isWxidTitle = title.startsWith('wxid_') && title === newSession.username
if (isWxidTitle && !isGroupChat) {
console.warn('[NotificationFilter] 跳过无法识别的用户通知:', newSession.username)
continue
}
// 调用 IPC 以显示独立窗口通知
window.electronAPI.notification?.show({
title: title,
content: content,
avatarUrl: avatarUrl,
sessionId: newSession.username
})
// 我们不再为 Toast 设置本地状态
}
}
}
const handleActiveSessionRefresh = async (sessionId: string) => {
// 从 ChatPage 复制/调整的逻辑,以保持集中
const state = useChatStore.getState()
const msgs = state.messages || []
const lastMsg = msgs[msgs.length - 1]
const minTime = lastMsg?.createTime || 0
try {
const result = await (window.electronAPI.chat as any).getNewMessages(sessionId, minTime)
if (result.success && result.messages && result.messages.length > 0) {
appendMessages(result.messages, false) // 追加到末尾
}
} catch (e) {
console.warn('后台活跃会话刷新失败:', e)
}
}
// 此组件不再渲染 UI
return null
}

View File

@@ -14,12 +14,21 @@
max-height: 90vh;
object-fit: contain;
transition: transform 0.15s ease-out;
&.dragging {
transition: none;
}
}
.preview-content {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: fit-content;
height: fit-content;
}
.image-preview-close {
position: absolute;
bottom: 40px;
@@ -44,3 +53,38 @@
transform: translateX(-50%) scale(1.1);
}
}
.live-photo-btn {
position: absolute;
top: 15px;
right: 15px;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-radius: 16px;
background: rgba(0, 0, 0, 0.5);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.2);
cursor: pointer;
backdrop-filter: blur(10px);
transition: all 0.2s;
z-index: 10000;
&:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.4);
transform: translateY(-2px);
}
&.active {
background: var(--accent-color, #007aff);
border-color: transparent;
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.3);
}
span {
font-size: 14px;
font-weight: 500;
}
}

View File

@@ -1,36 +1,41 @@
import React, { useState, useRef, useCallback, useEffect } from 'react'
import { X } from 'lucide-react'
import { LivePhotoIcon } from './LivePhotoIcon'
import { createPortal } from 'react-dom'
import './ImagePreview.scss'
interface ImagePreviewProps {
src: string
isVideo?: boolean
liveVideoPath?: string
onClose: () => void
}
export const ImagePreview: React.FC<ImagePreviewProps> = ({ src, onClose }) => {
export const ImagePreview: React.FC<ImagePreviewProps> = ({ src, isVideo, liveVideoPath, onClose }) => {
const [scale, setScale] = useState(1)
const [position, setPosition] = useState({ x: 0, y: 0 })
const [isDragging, setIsDragging] = useState(false)
const [showLive, setShowLive] = useState(false)
const dragStart = useRef({ x: 0, y: 0 })
const positionStart = useRef({ x: 0, y: 0 })
const containerRef = useRef<HTMLDivElement>(null)
// 滚轮缩放
const handleWheel = useCallback((e: React.WheelEvent) => {
if (showLive) return // 播放实况时禁止缩放? 或者支持缩放? 暂定禁止以简化
e.preventDefault()
const delta = e.deltaY > 0 ? 0.9 : 1.1
setScale(prev => Math.min(Math.max(prev * delta, 0.5), 5))
}, [])
}, [showLive])
// 开始拖动
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (scale <= 1) return
if (showLive || scale <= 1) return
e.preventDefault()
setIsDragging(true)
dragStart.current = { x: e.clientX, y: e.clientY }
positionStart.current = { ...position }
}, [scale, position])
}, [scale, position, showLive])
// 拖动中
const handleMouseMove = useCallback((e: React.MouseEvent) => {
@@ -79,19 +84,62 @@ export const ImagePreview: React.FC<ImagePreviewProps> = ({ src, onClose }) => {
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
<img
src={src}
alt="图片预览"
className={`preview-image ${isDragging ? 'dragging' : ''}`}
<div
className="preview-content"
style={{
transform: `translate(${position.x}px, ${position.y}px) scale(${scale})`,
cursor: scale > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default'
position: 'relative',
transform: `translate(${position.x}px, ${position.y}px)`,
width: 'fit-content',
height: 'fit-content'
}}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onDoubleClick={handleDoubleClick}
draggable={false}
/>
onClick={(e) => e.stopPropagation()}
>
{(isVideo || showLive) ? (
<video
src={showLive ? liveVideoPath : src}
controls={!showLive}
autoPlay
loop={showLive}
className="preview-image"
style={{
transform: `scale(${scale})`,
maxHeight: '90vh',
maxWidth: '90vw'
}}
/>
) : (
<img
src={src}
alt="图片预览"
className={`preview-image ${isDragging ? 'dragging' : ''}`}
style={{
transform: `scale(${scale})`,
maxHeight: '90vh',
maxWidth: '90vw',
cursor: scale > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default'
}}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onDoubleClick={handleDoubleClick}
draggable={false}
/>
)}
{liveVideoPath && !isVideo && (
<button
className={`live-photo-btn ${showLive ? 'active' : ''}`}
onClick={(e) => {
e.stopPropagation()
setShowLive(!showLive)
}}
title={showLive ? "显示照片" : "播放实况"}
>
<LivePhotoIcon size={20} />
<span></span>
</button>
)}
</div>
<button className="image-preview-close" onClick={onClose}>
<X size={20} />
</button>

View File

@@ -75,6 +75,18 @@
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
&.clickable {
cursor: pointer;
border-radius: 6px;
padding: 2px 8px;
transition: all 0.15s;
&:hover {
background: var(--bg-hover);
color: var(--primary);
}
}
}
.nav-btn {
@@ -97,9 +109,100 @@
}
}
}
.year-month-picker {
padding: 4px 0;
.year-selector {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
.year-label {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.nav-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
border-color: var(--primary);
color: var(--primary);
}
}
}
.month-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
.month-btn {
padding: 10px 0;
border: none;
background: transparent;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
color: var(--text-secondary);
transition: all 0.15s;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
&.active {
background: var(--primary);
color: #fff;
}
}
}
}
}
.calendar-grid {
position: relative;
&.loading {
.weekdays,
.days {
pointer-events: none;
}
}
.calendar-loading {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--text-secondary);
font-size: 13px;
.spin {
color: var(--primary);
animation: spin 1s linear infinite;
}
}
.weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
@@ -117,10 +220,10 @@
.days {
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-template-rows: repeat(6, 36px);
gap: 4px;
.day-cell {
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
@@ -129,12 +232,13 @@
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
position: relative;
&.empty {
cursor: default;
}
&:not(.empty):hover {
&:not(.empty):not(.no-message):hover {
background: var(--bg-hover);
}
@@ -149,10 +253,43 @@
font-weight: 600;
background: var(--primary-light);
}
// 无消息的日期 - 灰显且不可点击
&.no-message {
opacity: 0.3;
cursor: default;
pointer-events: none;
}
// 有消息的日期指示器小圆点
.message-dot {
position: absolute;
bottom: 3px;
left: 50%;
transform: translateX(-50%);
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--primary);
}
&.selected .message-dot {
background: rgba(255, 255, 255, 0.7);
}
}
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.quick-options {
display: flex;
gap: 8px;

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react'
import { X, ChevronLeft, ChevronRight, Calendar as CalendarIcon } from 'lucide-react'
import React, { useState, useMemo } from 'react'
import { X, ChevronLeft, ChevronRight, Calendar as CalendarIcon, Loader2 } from 'lucide-react'
import './JumpToDateDialog.scss'
interface JumpToDateDialogProps {
@@ -7,16 +7,24 @@ interface JumpToDateDialogProps {
onClose: () => void
onSelect: (date: Date) => void
currentDate?: Date
/** 有消息的日期集合,格式为 YYYY-MM-DD */
messageDates?: Set<string>
/** 是否正在加载消息日期 */
loadingDates?: boolean
}
const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
isOpen,
onClose,
onSelect,
currentDate = new Date()
currentDate = new Date(),
messageDates,
loadingDates = false
}) => {
const [calendarDate, setCalendarDate] = useState(new Date(currentDate))
const isValidDate = (d: any) => d instanceof Date && !isNaN(d.getTime())
const [calendarDate, setCalendarDate] = useState(isValidDate(currentDate) ? new Date(currentDate) : new Date())
const [selectedDate, setSelectedDate] = useState(new Date(currentDate))
const [showYearMonthPicker, setShowYearMonthPicker] = useState(false)
if (!isOpen) return null
@@ -48,7 +56,20 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
return days
}
/**
* 判断某天是否有消息
*/
const hasMessage = (day: number): boolean => {
if (!messageDates || messageDates.size === 0) return true // 未加载时默认全部可点击
const year = calendarDate.getFullYear()
const month = calendarDate.getMonth() + 1
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
return messageDates.has(dateStr)
}
const handleDateClick = (day: number) => {
// 如果已加载日期数据且该日期无消息,则不可点击
if (messageDates && messageDates.size > 0 && !hasMessage(day)) return
const newDate = new Date(calendarDate.getFullYear(), calendarDate.getMonth(), day)
setSelectedDate(newDate)
}
@@ -71,6 +92,28 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
calendarDate.getFullYear() === selectedDate.getFullYear()
}
/**
* 获取某天的 CSS 类名
*/
const getDayClassName = (day: number | null): string => {
if (day === null) return 'day-cell empty'
const classes = ['day-cell']
if (isSelected(day)) classes.push('selected')
if (isToday(day)) classes.push('today')
// 仅在已加载消息日期数据时区分有/无消息
if (messageDates && messageDates.size > 0) {
if (hasMessage(day)) {
classes.push('has-message')
} else {
classes.push('no-message')
}
}
return classes.join(' ')
}
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
const days = generateCalendar()
@@ -95,7 +138,7 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
>
<ChevronLeft size={18} />
</button>
<span className="current-month">
<span className="current-month clickable" onClick={() => setShowYearMonthPicker(!showYearMonthPicker)}>
{calendarDate.getFullYear()}{calendarDate.getMonth() + 1}
</span>
<button
@@ -106,22 +149,58 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
</button>
</div>
<div className="calendar-grid">
<div className="weekdays">
{showYearMonthPicker ? (
<div className="year-month-picker">
<div className="year-selector">
<button className="nav-btn" onClick={() => setCalendarDate(new Date(calendarDate.getFullYear() - 1, calendarDate.getMonth(), 1))}>
<ChevronLeft size={16} />
</button>
<span className="year-label">{calendarDate.getFullYear()}</span>
<button className="nav-btn" onClick={() => setCalendarDate(new Date(calendarDate.getFullYear() + 1, calendarDate.getMonth(), 1))}>
<ChevronRight size={16} />
</button>
</div>
<div className="month-grid">
{['一月','二月','三月','四月','五月','六月','七月','八月','九月','十月','十一月','十二月'].map((name, i) => (
<button
key={i}
className={`month-btn ${i === calendarDate.getMonth() ? 'active' : ''}`}
onClick={() => {
setCalendarDate(new Date(calendarDate.getFullYear(), i, 1))
setShowYearMonthPicker(false)
}}
>{name}</button>
))}
</div>
</div>
) : (
<div className={`calendar-grid ${loadingDates ? 'loading' : ''}`}>
{loadingDates && (
<div className="calendar-loading">
<Loader2 size={20} className="spin" />
<span>...</span>
</div>
)}
<div className="weekdays" style={{ visibility: loadingDates ? 'hidden' : 'visible' }}>
{weekdays.map(d => <div key={d} className="weekday">{d}</div>)}
</div>
<div className="days">
<div className="days" style={{ visibility: loadingDates ? 'hidden' : 'visible' }}>
{days.map((day, i) => (
<div
key={i}
className={`day-cell ${day === null ? 'empty' : ''} ${day !== null && isSelected(day) ? 'selected' : ''} ${day !== null && isToday(day) ? 'today' : ''}`}
className={getDayClassName(day)}
style={{ visibility: loadingDates ? 'hidden' : 'visible' }}
onClick={() => day !== null && handleDateClick(day)}
>
{day}
{day !== null && messageDates && messageDates.size > 0 && hasMessage(day) && (
<span className="message-dot" />
)}
</div>
))}
</div>
</div>
)}
</div>
<div className="quick-options">

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,185 @@
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
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,
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()
return (
<div className={mergedClassName} style={style} role="dialog" aria-label="跳转日期">
<div className="calendar-nav">
<button
className="nav-btn"
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))}
aria-label="上一月"
>
<ChevronLeft size={16} />
</button>
<span className="current-month">{calendarDate.getFullYear()}{calendarDate.getMonth() + 1}</span>
<button
className="nav-btn"
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))}
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

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

View File

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

View File

@@ -0,0 +1,144 @@
import { useState, useEffect, useRef } from 'react'
import { ArrowRight, Fingerprint, Lock, ScanFace, ShieldCheck } from 'lucide-react'
import './LockScreen.scss'
interface LockScreenProps {
onUnlock: () => void
avatar?: string
useHello?: boolean
}
export default function LockScreen({ onUnlock, avatar, useHello = false }: LockScreenProps) {
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [isVerifying, setIsVerifying] = useState(false)
const [isUnlocked, setIsUnlocked] = useState(false)
const [showHello, setShowHello] = useState(false)
const [helloAvailable, setHelloAvailable] = useState(false)
// 用于取消 WebAuthn 请求
const abortControllerRef = useRef<AbortController | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
// 快速检查配置并启动
quickStartHello()
inputRef.current?.focus()
return () => {
// 组件卸载时取消请求
abortControllerRef.current?.abort()
}
}, [])
const handleUnlock = () => {
setIsUnlocked(true)
setTimeout(() => {
onUnlock()
}, 1500)
}
const quickStartHello = async () => {
try {
if (useHello) {
setHelloAvailable(true)
setShowHello(true)
verifyHello()
}
} catch (e) {
console.error('Quick start hello failed', e)
}
}
const verifyHello = async () => {
if (isVerifying || isUnlocked) return
setIsVerifying(true)
setError('')
try {
const result = await window.electronAPI.auth.hello()
if (result.success) {
handleUnlock()
} else {
console.error('Hello verification failed:', result.error)
setError(result.error || '验证失败')
}
} catch (e: any) {
console.error('Hello verification error:', e)
setError(`验证失败: ${e.message || String(e)}`)
} finally {
setIsVerifying(false)
}
}
const handlePasswordSubmit = async (e?: React.FormEvent) => {
e?.preventDefault()
if (!password || isUnlocked) return
setIsVerifying(true)
setError('')
try {
// 发送原始密码到主进程,由主进程验证并解密密钥
const result = await window.electronAPI.auth.unlock(password)
if (result.success) {
handleUnlock()
} else {
setError(result.error || '密码错误')
setPassword('')
setIsVerifying(false)
}
} catch (e) {
setError('验证失败')
setIsVerifying(false)
}
}
return (
<div className={`lock-screen ${isUnlocked ? 'unlocked' : ''}`}>
<div className="lock-content">
<div className="lock-avatar">
{avatar ? (
<img src={avatar} alt="User" style={{ width: '100%', height: '100%', borderRadius: '50%' }} />
) : (
<Lock size={40} />
)}
</div>
<h2 className="lock-title">WeFlow </h2>
<form className="lock-form" onSubmit={handlePasswordSubmit}>
<div className="input-group">
<input
ref={inputRef}
type="password"
placeholder="输入应用密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
// 移除 disabled允许用户随时输入
/>
<button type="submit" className="submit-btn" disabled={!password}>
<ArrowRight size={18} />
</button>
</div>
{showHello && (
<button
type="button"
className={`hello-btn ${isVerifying ? 'loading' : ''}`}
onClick={verifyHello}
>
<Fingerprint size={20} />
{isVerifying ? '验证中...' : '使用 Windows Hello 解锁'}
</button>
)}
</form>
{error && <div className="lock-error">{error}</div>}
</div>
</div>
)
}

View File

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

View File

@@ -0,0 +1,223 @@
.notification-toast-container {
position: fixed;
z-index: 9999;
width: 320px;
background: var(--bg-secondary);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--border-light);
// 浅色模式下使用完全不透明背景,并禁用毛玻璃效果
[data-mode="light"] &,
:not([data-mode]) & {
background: rgba(255, 255, 255, 1);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
padding: 12px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
opacity: 0;
transform: scale(0.95);
pointer-events: none; // Allow clicking through when hidden
&.visible {
opacity: 1;
transform: scale(1);
pointer-events: auto;
}
&.static {
position: relative !important;
width: calc(100% - 4px) !important; // Leave 2px margin for anti-aliasing saftey
height: auto !important; // Fits content
min-height: 0;
top: 0 !important;
bottom: 0 !important;
left: 0 !important;
right: 0 !important;
transform: none !important;
margin: 2px !important; // 2px centered margin
border-radius: 12px !important; // Rounded corners
// Disable backdrop filter
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
// 独立通知窗口:默认使用浅色模式硬编码值,确保不依赖 <html> 上的主题属性
background: #ffffff;
color: #3d3d3d;
--text-primary: #3d3d3d;
--text-secondary: #666666;
--text-tertiary: #999999;
--border-light: rgba(0, 0, 0, 0.08);
// 深色模式覆盖
[data-mode="dark"] & {
background: var(--bg-secondary-solid, #282420);
color: var(--text-primary, #F0EEE9);
--text-primary: #F0EEE9;
--text-secondary: #b3b0aa;
--text-tertiary: #807d78;
--border-light: rgba(255, 255, 255, 0.1);
}
box-shadow: none !important; // NO SHADOW
border: 1px solid var(--border-light);
display: flex;
padding: 16px;
padding-right: 32px; // Make space for close button
box-sizing: border-box;
// Force close button to be visible but transparent background
.notification-close {
opacity: 1 !important;
top: 12px;
right: 12px;
background: transparent !important; // Transparent per user request
&:hover {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.1) !important; // Subtle hover effect
}
}
.notification-time {
top: 24px; // Match padding
right: 40px; // Left of close button (12px + 20px + 8px)
}
}
// Position variants
&.bottom-right {
bottom: 24px;
right: 24px;
transform: translate(0, 20px) scale(0.95);
&.visible {
transform: translate(0, 0) scale(1);
}
}
&.top-right {
top: 24px;
right: 24px;
transform: translate(0, -20px) scale(0.95);
&.visible {
transform: translate(0, 0) scale(1);
}
}
&.bottom-left {
bottom: 24px;
left: 24px;
transform: translate(0, 20px) scale(0.95);
&.visible {
transform: translate(0, 0) scale(1);
}
}
&.top-left {
top: 24px;
left: 24px;
transform: translate(0, -20px) scale(0.95);
&.visible {
transform: translate(0, 0) scale(1);
}
}
&:hover {
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.16) !important;
}
.notification-content {
display: flex;
align-items: flex-start;
gap: 12px;
}
.notification-avatar {
flex-shrink: 0;
}
.notification-text {
flex: 1;
min-width: 0;
.notification-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
.notification-title {
font-weight: 600;
font-size: 14px;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%; // 允许缩放
flex: 1; // 占据剩余空间
min-width: 0; // 关键:允许 flex 子项收缩到内容以下
margin-right: 60px; // Make space for absolute time + close button
}
.notification-time {
font-size: 12px;
color: var(--text-tertiary);
position: absolute;
top: 16px;
right: 36px; // Left of close button (8px + 20px + 8px)
font-variant-numeric: tabular-nums;
}
}
.notification-body {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-all;
}
}
.notification-close {
position: absolute;
top: 8px;
right: 8px;
width: 20px;
height: 20px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
opacity: 0;
transition: all 0.2s;
&:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
}
&:hover .notification-close {
opacity: 1;
}
}

View File

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

View File

@@ -0,0 +1,142 @@
// Shared styles for Report components (Heatmap, WordCloud)
// --- Heatmap ---
.heatmap-wrapper {
margin-top: 24px;
width: 100%;
}
.heatmap-header {
display: grid;
grid-template-columns: 28px 1fr;
gap: 3px;
margin-bottom: 6px;
color: var(--ar-text-sub); // Assumes --ar-text-sub is defined in parent context or globally
font-size: 10px;
}
.time-labels {
display: grid;
grid-template-columns: repeat(24, 1fr);
gap: 3px;
span {
text-align: center;
}
}
.heatmap {
display: grid;
grid-template-columns: 28px 1fr;
gap: 3px;
}
.heatmap-week-col {
display: grid;
grid-template-rows: repeat(7, 1fr);
gap: 3px;
font-size: 10px;
color: var(--ar-text-sub);
}
.week-label {
display: flex;
align-items: center;
}
.heatmap-grid {
display: grid;
grid-template-columns: repeat(24, 1fr);
gap: 3px;
}
.h-cell {
aspect-ratio: 1;
border-radius: 2px;
min-height: 10px;
transition: transform 0.15s;
&:hover {
transform: scale(1.3);
z-index: 1;
}
}
// --- Word Cloud ---
.word-cloud-wrapper {
margin: 24px auto 0;
padding: 0;
max-width: 520px;
display: flex;
justify-content: center;
--cloud-scale: clamp(0.72, 80vw / 520, 1);
}
.word-cloud-inner {
position: relative;
width: 520px;
height: 520px;
margin: 0;
border-radius: 50%;
transform: scale(var(--cloud-scale));
transform-origin: center;
&::before {
content: "";
position: absolute;
inset: -6%;
background:
radial-gradient(circle at 35% 45%, rgba(var(--ar-primary-rgb, 7, 193, 96), 0.12), transparent 55%),
radial-gradient(circle at 65% 50%, rgba(var(--ar-accent-rgb, 242, 170, 0), 0.10), transparent 58%),
radial-gradient(circle at 50% 65%, var(--bg-tertiary, rgba(0, 0, 0, 0.04)), transparent 60%);
filter: blur(18px);
border-radius: 50%;
pointer-events: none;
z-index: 0;
}
}
.word-tag {
display: inline-block;
padding: 0;
background: transparent;
border-radius: 0;
border: none;
line-height: 1.2;
white-space: nowrap;
transition: transform 0.2s ease, color 0.2s ease;
cursor: default;
color: var(--ar-text-main);
font-weight: 600;
opacity: 0;
animation: wordPopIn 0.55s ease forwards;
position: absolute;
z-index: 1;
transform: translate(-50%, -50%) scale(0.8);
&:hover {
transform: translate(-50%, -50%) scale(1.08);
color: var(--ar-primary);
z-index: 2;
}
}
@keyframes wordPopIn {
0% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.6);
}
100% {
opacity: var(--final-opacity, 1);
transform: translate(-50%, -50%) scale(1);
}
}
.word-cloud-note {
margin-top: 24px;
font-size: 14px !important;
color: var(--ar-text-sub) !important;
text-align: center;
}

View File

@@ -0,0 +1,51 @@
import React from 'react'
import './ReportComponents.scss'
interface ReportHeatmapProps {
data: number[][]
}
const ReportHeatmap: React.FC<ReportHeatmapProps> = ({ data }) => {
if (!data || data.length === 0) return null
const maxHeat = Math.max(...data.flat())
const weekLabels = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
return (
<div className="heatmap-wrapper">
<div className="heatmap-header">
<div></div>
<div className="time-labels">
{[0, 6, 12, 18].map(h => (
<span key={h} style={{ gridColumn: h + 1 }}>{h}</span>
))}
</div>
</div>
<div className="heatmap">
<div className="heatmap-week-col">
{weekLabels.map(w => <div key={w} className="week-label">{w}</div>)}
</div>
<div className="heatmap-grid">
{data.map((row, wi) =>
row.map((val, hi) => {
const alpha = maxHeat > 0 ? (val / maxHeat * 0.85 + 0.1).toFixed(2) : '0.1'
return (
<div
key={`${wi}-${hi}`}
className="h-cell"
style={{
backgroundColor: 'var(--primary)',
opacity: alpha
}}
title={`${weekLabels[wi]} ${hi}:00 - ${val}`}
/>
)
})
)}
</div>
</div>
</div>
)
}
export default ReportHeatmap

View File

@@ -0,0 +1,113 @@
import React from 'react'
import './ReportComponents.scss'
interface ReportWordCloudProps {
words: { phrase: string; count: number }[]
}
const ReportWordCloud: React.FC<ReportWordCloudProps> = ({ words }) => {
if (!words || words.length === 0) return null
const maxCount = words.length > 0 ? words[0].count : 1
const topWords = words.slice(0, 32)
const baseSize = 520
// 使用确定性随机数生成器
const seededRandom = (seed: number) => {
const x = Math.sin(seed) * 10000
return x - Math.floor(x)
}
// 计算词云位置
const placedItems: { x: number; y: number; w: number; h: number }[] = []
const canPlace = (x: number, y: number, w: number, h: number): boolean => {
const halfW = w / 2
const halfH = h / 2
const dx = x - 50
const dy = y - 50
const dist = Math.sqrt(dx * dx + dy * dy)
const maxR = 49 - Math.max(halfW, halfH)
if (dist > maxR) return false
const pad = 1.8
for (const p of placedItems) {
if ((x - halfW - pad) < (p.x + p.w / 2) &&
(x + halfW + pad) > (p.x - p.w / 2) &&
(y - halfH - pad) < (p.y + p.h / 2) &&
(y + halfH + pad) > (p.y - p.h / 2)) {
return false
}
}
return true
}
const wordItems = topWords.map((item, i) => {
const ratio = item.count / maxCount
const fontSize = Math.round(12 + Math.pow(ratio, 0.65) * 20)
const opacity = Math.min(1, Math.max(0.35, 0.35 + ratio * 0.65))
const delay = (i * 0.04).toFixed(2)
// 计算词语宽度
const charCount = Math.max(1, item.phrase.length)
const hasCjk = /[\u4e00-\u9fff]/.test(item.phrase)
const hasLatin = /[A-Za-z0-9]/.test(item.phrase)
const widthFactor = hasCjk && hasLatin ? 0.85 : hasCjk ? 0.98 : 0.6
const widthPx = fontSize * (charCount * widthFactor)
const heightPx = fontSize * 1.1
const widthPct = (widthPx / baseSize) * 100
const heightPct = (heightPx / baseSize) * 100
// 寻找位置
let x = 50, y = 50
let placedOk = false
const tries = i === 0 ? 1 : 420
for (let t = 0; t < tries; t++) {
if (i === 0) {
x = 50
y = 50
} else {
const idx = i + t * 0.28
const radius = Math.sqrt(idx) * 7.6 + (seededRandom(i * 1000 + t) * 1.2 - 0.6)
const angle = idx * 2.399963 + seededRandom(i * 2000 + t) * 0.35
x = 50 + radius * Math.cos(angle)
y = 50 + radius * Math.sin(angle)
}
if (canPlace(x, y, widthPct, heightPct)) {
placedOk = true
break
}
}
if (!placedOk) return null
placedItems.push({ x, y, w: widthPct, h: heightPct })
return (
<span
key={i}
className="word-tag"
style={{
'--final-opacity': opacity,
left: `${x.toFixed(2)}%`,
top: `${y.toFixed(2)}%`,
fontSize: `${fontSize}px`,
animationDelay: `${delay}s`,
} as React.CSSProperties}
title={`${item.phrase} (出现 ${item.count} 次)`}
>
{item.phrase}
</span>
)
}).filter(Boolean)
return (
<div className="word-cloud-wrapper">
<div className="word-cloud-inner">
{wordItems}
</div>
</div>
)
}
export default ReportWordCloud

View File

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

View File

@@ -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,119 @@
}
}
.sidebar-user-card-wrap {
position: relative;
margin: 0 12px 10px;
}
.sidebar-user-clear-trigger {
position: absolute;
left: 0;
right: 0;
bottom: calc(100% + 8px);
z-index: 12;
border: 1px solid rgba(255, 59, 48, 0.28);
border-radius: 10px;
background: var(--bg-secondary);
color: #d93025;
padding: 8px 10px;
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12);
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
&:hover {
background: rgba(255, 59, 48, 0.08);
border-color: rgba(255, 59, 48, 0.46);
}
}
.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 +183,7 @@
&.active {
background: var(--primary);
color: white;
color: var(--on-primary);
}
}
@@ -70,13 +196,46 @@
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 8px;
padding: 0 12px;
border-top: 1px solid var(--border-color);
padding-top: 12px;
margin-top: 8px;
@@ -103,4 +262,107 @@
background: var(--bg-tertiary);
color: var(--text-primary);
}
}
}
.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;
}
.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;
h3 {
margin: 0;
font-size: 16px;
color: var(--text-primary);
}
p {
margin: 10px 0 0;
font-size: 13px;
line-height: 1.6;
color: var(--text-secondary);
}
}
.sidebar-clear-options {
margin-top: 14px;
display: flex;
gap: 14px;
flex-wrap: wrap;
label {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text-primary);
}
}
.sidebar-clear-actions {
margin-top: 18px;
display: flex;
justify-content: flex-end;
gap: 10px;
button {
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 8px 14px;
font-size: 13px;
cursor: pointer;
background: var(--bg-secondary);
color: var(--text-primary);
}
button:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.danger {
border-color: #ef4444;
background: #ef4444;
color: #fff;
}
}
// 繁花如梦主题:侧边栏毛玻璃 + 激活项用主品牌色
[data-theme="blossom-dream"] .sidebar {
background: rgba(255, 255, 255, 0.6);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-right: 1px solid rgba(255, 255, 255, 0.4);
}
[data-theme="blossom-dream"][data-mode="dark"] .sidebar {
background: rgba(34, 30, 36, 0.75);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-right: 1px solid rgba(255, 255, 255, 0.06);
}
// 激活项:主品牌色纵向微渐变
[data-theme="blossom-dream"] .nav-item.active {
background: linear-gradient(180deg, #D4849A 0%, #C4748A 100%);
}
// 深色激活项:用藕粉色,背景深灰底 + 粉色文字/图标(高阶玩法)
[data-theme="blossom-dream"][data-mode="dark"] .nav-item.active {
background: rgba(209, 158, 187, 0.15);
color: #D19EBB;
border: 1px solid rgba(209, 158, 187, 0.2);
}

View File

@@ -1,15 +1,335 @@
import { useState } from 'react'
import { useState, useEffect, useRef } from 'react'
import { NavLink, useLocation } from 'react-router-dom'
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Bot, Aperture } from 'lucide-react'
import { Home, MessageSquare, BarChart3, Users, FileText, Settings, ChevronLeft, ChevronRight, Download, Aperture, UserCircle, Lock, ChevronUp, Trash2 } from 'lucide-react'
import { useAppStore } from '../stores/appStore'
import * as configService from '../services/config'
import { onExportSessionStatus, requestExportSessionStatus } from '../services/exportBridge'
import './Sidebar.scss'
interface SidebarUserProfile {
wxid: string
displayName: string
alias?: string
avatarUrl?: string
}
const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1'
interface SidebarUserProfileCache extends SidebarUserProfile {
updatedAt: number
}
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))
} catch {
// 忽略本地缓存失败,不影响主流程
}
}
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
}
function Sidebar() {
const location = useLocation()
const [collapsed, setCollapsed] = useState(false)
const [authEnabled, setAuthEnabled] = useState(false)
const [activeExportTaskCount, setActiveExportTaskCount] = useState(0)
const [userProfile, setUserProfile] = useState<SidebarUserProfile>({
wxid: '',
displayName: '未识别用户'
})
const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false)
const [showClearAccountDialog, setShowClearAccountDialog] = useState(false)
const [shouldClearCacheData, setShouldClearCacheData] = useState(false)
const [shouldClearExportData, setShouldClearExportData] = useState(false)
const [isClearingAccountData, setIsClearingAccountData] = useState(false)
const accountCardWrapRef = useRef<HTMLDivElement | null>(null)
const setLocked = useAppStore(state => state.setLocked)
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
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 fallbackDisplayName = resolvedWxid || '未识别用户'
// 第一阶段:先把 wxid/名称打上,保证侧边栏第一时间可见。
patchUserProfile({
wxid: resolvedWxid,
displayName: fallbackDisplayName
})
if (!resolvedWxidRaw && !resolvedWxid) return
// 第二阶段:后台补齐名称(不会阻塞首屏)。
void (async () => {
try {
let myContact: Awaited<ReturnType<typeof window.electronAPI.chat.getContact>> | null = null
for (const candidate of Array.from(new Set([resolvedWxidRaw, resolvedWxid, cleanedWxid].filter(Boolean)))) {
const contact = await window.electronAPI.chat.getContact(candidate)
if (!contact) continue
if (!myContact) myContact = contact
if (contact.remark || contact.nickName || contact.alias) {
myContact = contact
break
}
}
const fromContact = pickFirstValidName(
myContact?.remark,
myContact?.nickName,
myContact?.alias
)
if (fromContact) {
patchUserProfile({ displayName: fromContact }, resolvedWxid)
// 同步补充微信号alias
if (myContact?.alias) {
patchUserProfile({ alias: myContact.alias }, resolvedWxid)
}
return
}
const enrichTargets = Array.from(new Set([resolvedWxidRaw, resolvedWxid, cleanedWxid, 'self'].filter(Boolean)))
const enrichedResult = await window.electronAPI.chat.enrichSessionsContactInfo(enrichTargets)
const enrichedDisplayName = pickFirstValidName(
enrichedResult.contacts?.[resolvedWxidRaw]?.displayName,
enrichedResult.contacts?.[resolvedWxid]?.displayName,
enrichedResult.contacts?.[cleanedWxid]?.displayName,
enrichedResult.contacts?.self?.displayName,
myContact?.alias
)
const bestName = enrichedDisplayName
if (bestName) {
patchUserProfile({ displayName: bestName }, resolvedWxid)
}
// 降级分支也补充微信号
if (myContact?.alias) {
patchUserProfile({ alias: myContact.alias }, resolvedWxid)
}
} catch (nameError) {
console.error('加载侧边栏用户昵称失败:', nameError)
}
})()
// 第二阶段:后台补齐头像(不会阻塞首屏)。
void (async () => {
try {
const avatarResult = await window.electronAPI.chat.getMyAvatarUrl()
if (avatarResult.success && avatarResult.avatarUrl) {
patchUserProfile({ avatarUrl: avatarResult.avatarUrl }, resolvedWxid)
}
} catch (avatarError) {
console.error('加载侧边栏用户头像失败:', avatarError)
}
})()
} catch (error) {
console.error('加载侧边栏用户信息失败:', error)
}
}
const cachedProfile = readSidebarUserProfileCache()
if (cachedProfile) {
setUserProfile(prev => ({
...prev,
...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 isActive = (path: string) => {
return location.pathname === path || location.pathname.startsWith(`${path}/`)
}
const exportTaskBadge = activeExportTaskCount > 99 ? '99+' : `${activeExportTaskCount}`
const canConfirmClear = shouldClearCacheData || shouldClearExportData
const resetClearDialogState = () => {
setShouldClearCacheData(false)
setShouldClearExportData(false)
setShowClearAccountDialog(false)
}
const openClearAccountDialog = () => {
setIsAccountMenuOpen(false)
setShouldClearCacheData(false)
setShouldClearExportData(false)
setShowClearAccountDialog(true)
}
const handleConfirmClearAccountData = async () => {
if (!canConfirmClear || isClearingAccountData) return
setIsClearingAccountData(true)
try {
const result = await window.electronAPI.chat.clearCurrentAccountData({
clearCache: shouldClearCacheData,
clearExports: shouldClearExportData
})
if (!result.success) {
window.alert(result.error || '清理失败,请稍后重试。')
return
}
window.localStorage.removeItem(SIDEBAR_USER_PROFILE_CACHE_KEY)
setUserProfile({ wxid: '', displayName: '未识别用户' })
window.dispatchEvent(new Event('wxid-changed'))
const removedPaths = Array.isArray(result.removedPaths) ? result.removedPaths : []
const selectedScopes = [
shouldClearCacheData ? '缓存数据' : '',
shouldClearExportData ? '导出数据' : ''
].filter(Boolean)
const detailLines: string[] = [
`清理范围:${selectedScopes.join('、') || '未选择'}`,
`已清理项目:${removedPaths.length}`
]
if (removedPaths.length > 0) {
detailLines.push('', '清理明细(最多显示 8 项):')
for (const [index, path] of removedPaths.slice(0, 8).entries()) {
detailLines.push(`${index + 1}. ${path}`)
}
if (removedPaths.length > 8) {
detailLines.push(`... 其余 ${removedPaths.length - 8} 项已省略`)
}
}
if (result.warning) {
detailLines.push('', `注意:${result.warning}`)
}
const followupHint = shouldClearCacheData
? '若需再次获取数据,请手动登录微信客户端并重新在 WeFlow 完成配置。'
: '你可以继续使用当前登录状态,无需重新登录。'
window.alert(`账号数据清理完成。\n\n${detailLines.join('\n')}\n\n为保障数据安全WeFlow 已清除该账号本地缓存/导出相关数据。${followupHint}`)
resetClearDialogState()
if (shouldClearCacheData) {
window.location.reload()
}
} catch (error) {
console.error('清理账号数据失败:', error)
window.alert('清理失败,请稍后重试。')
} finally {
setIsClearingAccountData(false)
}
}
return (
<aside className={`sidebar ${collapsed ? 'collapsed' : ''}`}>
@@ -44,7 +364,15 @@ function Sidebar() {
<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
@@ -82,22 +410,72 @@ function Sidebar() {
className={`nav-item ${isActive('/export') ? 'active' : ''}`}
title={collapsed ? '导出' : undefined}
>
<span className="nav-icon"><Download size={20} /></span>
<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>
{/* 数据管理 */}
<NavLink
to="/data-management"
className={`nav-item ${isActive('/data-management') ? 'active' : ''}`}
title={collapsed ? '数据管理' : undefined}
>
<span className="nav-icon"><Database size={20} /></span>
<span className="nav-label"></span>
</NavLink>
</nav>
<div className="sidebar-footer">
<div className="sidebar-user-card-wrap" ref={accountCardWrapRef}>
{isAccountMenuOpen && (
<button
className="sidebar-user-clear-trigger"
onClick={openClearAccountDialog}
type="button"
>
<Trash2 size={14} />
<span></span>
</button>
)}
<div
className={`sidebar-user-card ${isAccountMenuOpen ? 'menu-open' : ''}`}
title={collapsed ? `${userProfile.displayName}${(userProfile.alias || userProfile.wxid) ? `\n${userProfile.alias || userProfile.wxid}` : ''}` : undefined}
onClick={() => setIsAccountMenuOpen(prev => !prev)}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
setIsAccountMenuOpen(prev => !prev)
}
}}
>
<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>
{authEnabled && (
<button
className="nav-item"
onClick={() => setLocked(true)}
title={collapsed ? '锁定' : undefined}
>
<span className="nav-icon"><Lock size={20} /></span>
<span className="nav-label"></span>
</button>
)}
<NavLink
to="/settings"
className={`nav-item ${isActive('/settings') ? 'active' : ''}`}
@@ -117,6 +495,49 @@ function Sidebar() {
{collapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
</button>
</div>
{showClearAccountDialog && (
<div className="sidebar-clear-dialog-overlay" onClick={() => !isClearingAccountData && resetClearDialogState()}>
<div className="sidebar-clear-dialog" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
<h3></h3>
<p>
weflow
weflow
</p>
<div className="sidebar-clear-options">
<label>
<input
type="checkbox"
checked={shouldClearCacheData}
onChange={(event) => setShouldClearCacheData(event.target.checked)}
disabled={isClearingAccountData}
/>
</label>
<label>
<input
type="checkbox"
checked={shouldClearExportData}
onChange={(event) => setShouldClearExportData(event.target.checked)}
disabled={isClearingAccountData}
/>
</label>
</div>
<div className="sidebar-clear-actions">
<button type="button" onClick={resetClearDialogState} disabled={isClearingAccountData}></button>
<button
type="button"
className="danger"
disabled={!canConfirmClear || isClearingAccountData}
onClick={handleConfirmClearAccountData}
>
{isClearingAccountData ? '清除中...' : '确认清除'}
</button>
</div>
</div>
</div>
)}
</aside>
)
}

View File

@@ -0,0 +1,199 @@
import React, { useState } from 'react'
import { Search, Calendar, User, X, Filter, Check } 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
}
interface SnsFilterPanelProps {
searchKeyword: string
setSearchKeyword: (val: string) => void
jumpTargetDate?: Date
setJumpTargetDate: (date?: Date) => void
onOpenJumpDialog: () => void
selectedUsernames: string[]
setSelectedUsernames: (val: string[]) => void
contacts: Contact[]
contactSearch: string
setContactSearch: (val: string) => void
loading?: boolean
}
export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
searchKeyword,
setSearchKeyword,
jumpTargetDate,
setJumpTargetDate,
onOpenJumpDialog,
selectedUsernames,
setSelectedUsernames,
contacts,
contactSearch,
setContactSearch,
loading
}) => {
const filteredContacts = contacts.filter(c =>
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 clearFilters = () => {
setSearchKeyword('')
setSelectedUsernames([])
setJumpTargetDate(undefined)
}
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) && (
<button className="reset-all-btn" onClick={clearFilters} title="重置所有筛选">
<RefreshCw size={14} />
</button>
)}
</div>
<div className="filter-widgets">
{/* Search Widget */}
<div className="filter-widget search-widget">
<div className="widget-header">
<Search size={14} />
<span></span>
</div>
<div className="input-group">
<input
type="text"
placeholder="搜索动态内容..."
value={searchKeyword}
onChange={e => setSearchKeyword(e.target.value)}
/>
{searchKeyword && (
<button className="clear-input-btn" onClick={() => setSearchKeyword('')}>
<X size={14} />
</button>
)}
</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>
)}
</div>
<div className="contact-search-bar">
<input
type="text"
placeholder="查找好友..."
value={contactSearch}
onChange={e => setContactSearch(e.target.value)}
/>
<Search size={14} className="search-icon" />
{contactSearch && (
<X size={14} className="clear-icon" onClick={() => setContactSearch('')} />
)}
</div>
<div className="contact-list-scroll">
{filteredContacts.map(contact => {
return (
<div
key={contact.username}
className={`contact-row ${selectedUsernames.includes(contact.username) ? 'selected' : ''}`}
onClick={() => toggleUserSelection(contact.username)}
>
<Avatar src={contact.avatarUrl} name={contact.displayName} size={36} shape="rounded" />
<div className="contact-meta">
<span className="contact-name">{contact.displayName}</span>
</div>
</div>
)
})}
{filteredContacts.length === 0 && (
<div className="empty-state">{getEmptyStateText()}</div>
)}
</div>
</div>
</div>
</aside>
)
}
function RefreshCw({ size, className }: { size?: number, className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size || 24}
height={size || 24}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="M23 4v6h-6"></path>
<path d="M1 20v-6h6"></path>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
</svg>
)
}

View File

@@ -0,0 +1,360 @@
import React, { useState, useRef } from 'react'
import { Play, Lock, Download, ImageOff } from 'lucide-react'
import { LivePhotoIcon } from '../../components/LivePhotoIcon'
import { RefreshCw } from 'lucide-react'
interface SnsMedia {
url: string
thumb: string
md5?: string
token?: string
key?: string
encIdx?: string
livePhoto?: {
url: string
thumb: string
token?: string
key?: string
encIdx?: string
}
}
interface SnsMediaGridProps {
mediaList: SnsMedia[]
postType?: number
onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void
onMediaDeleted?: () => void
}
const isSnsVideoUrl = (url?: string): boolean => {
if (!url) return false
const lower = url.toLowerCase()
return (lower.includes('snsvideodownload') || lower.includes('.mp4') || lower.includes('video')) && !lower.includes('vweixinthumb')
}
const extractVideoFrame = async (videoPath: string): Promise<string> => {
return new Promise((resolve, reject) => {
const video = document.createElement('video')
video.preload = 'auto'
video.src = videoPath
video.muted = true
video.currentTime = 0 // Initial reset
// video.crossOrigin = 'anonymous' // Not needed for file:// usually
const onSeeked = () => {
try {
const canvas = document.createElement('canvas')
canvas.width = video.videoWidth
canvas.height = video.videoHeight
const ctx = canvas.getContext('2d')
if (ctx) {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
const dataUrl = canvas.toDataURL('image/jpeg', 0.8)
resolve(dataUrl)
} else {
reject(new Error('Canvas context failed'))
}
} catch (e) {
reject(e)
} finally {
// Cleanup
video.removeEventListener('seeked', onSeeked)
video.src = ''
video.load()
}
}
video.onloadedmetadata = () => {
if (video.duration === Infinity || isNaN(video.duration)) {
// Determine duration failed, try a fixed small offset
video.currentTime = 1
} else {
video.currentTime = Math.max(0.1, video.duration / 2)
}
}
video.onseeked = onSeeked
video.onerror = (e) => {
reject(new Error('Video load failed'))
}
})
}
const MediaItem = ({ media, postType, onPreview, onMediaDeleted }: { media: SnsMedia; postType?: number; onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void; onMediaDeleted?: () => void }) => {
const [error, setError] = useState(false)
const [deleted, setDeleted] = useState(false)
const [loading, setLoading] = useState(true)
const markDeleted = () => { setDeleted(true); onMediaDeleted?.() }
const retryCount = useRef(0)
const [retryKey, setRetryKey] = useState(0)
const [thumbSrc, setThumbSrc] = useState<string>('')
const [videoPath, setVideoPath] = useState<string>('')
const [liveVideoPath, setLiveVideoPath] = useState<string>('')
const [isDecrypting, setIsDecrypting] = useState(false)
const [isGeneratingCover, setIsGeneratingCover] = useState(false)
const isVideo = isSnsVideoUrl(media.url)
const isLive = !!media.livePhoto
const targetUrl = media.thumb || media.url
// type 7 的朋友圈媒体不需要解密,直接使用原始 URL
const skipDecrypt = postType === 7
// 视频重试失败时重试最多2次耗尽才标记删除
const videoRetryOrDelete = () => {
if (retryCount.current < 2) {
retryCount.current++
setRetryKey(k => k + 1)
} else {
markDeleted()
}
}
// Simple effect to load image/decrypt
// Simple effect to load image/decrypt
React.useEffect(() => {
let cancelled = false
setLoading(true)
const load = async () => {
try {
if (!isVideo) {
// For images, we proxy to get the local path/base64
const result = await window.electronAPI.sns.proxyImage({
url: targetUrl,
key: skipDecrypt ? undefined : media.key
})
if (cancelled) return
if (result.success) {
if (result.dataUrl) setThumbSrc(result.dataUrl)
else if (result.videoPath) setThumbSrc(`file://${result.videoPath.replace(/\\/g, '/')}`)
} else {
markDeleted()
}
// Pre-load live photo video if needed
if (isLive && media.livePhoto?.url) {
window.electronAPI.sns.proxyImage({
url: media.livePhoto.url,
key: skipDecrypt ? undefined : (media.livePhoto.key || media.key)
}).then((res: any) => {
if (!cancelled && res.success && res.videoPath) {
setLiveVideoPath(`file://${res.videoPath.replace(/\\/g, '/')}`)
}
}).catch(() => { })
}
setLoading(false)
} else {
// Video logic: Decrypt -> Extract Frame
setIsGeneratingCover(true)
// First check if we already have it decryptable?
// Usually we need to call proxyImage with the video URL to decrypt it to cache
const result = await window.electronAPI.sns.proxyImage({
url: media.url,
key: skipDecrypt ? undefined : media.key
})
if (cancelled) return
if (result.success && result.videoPath) {
const localPath = `file://${result.videoPath.replace(/\\/g, '/')}`
setVideoPath(localPath)
try {
const coverDataUrl = await extractVideoFrame(localPath)
if (!cancelled) setThumbSrc(coverDataUrl)
} catch (err) {
console.error('Frame extraction failed', err)
// 封面提取失败,用视频路径作为 fallback让 <video> 标签显示
if (!cancelled) setThumbSrc(localPath)
}
} else {
videoRetryOrDelete()
}
setIsGeneratingCover(false)
setLoading(false)
}
} catch (e) {
console.error(e)
if (!cancelled) {
if (isVideo) {
videoRetryOrDelete()
} else {
markDeleted()
}
setLoading(false)
setIsGeneratingCover(false)
}
}
}
load()
return () => { cancelled = true }
}, [media, isVideo, isLive, targetUrl, retryKey])
const handlePreview = async (e: React.MouseEvent) => {
e.stopPropagation()
if (isVideo) {
// Decrypt video on demand if not already
if (!videoPath) {
setIsDecrypting(true)
try {
const res = await window.electronAPI.sns.proxyImage({
url: media.url,
key: skipDecrypt ? undefined : media.key
})
if (res.success && res.videoPath) {
const local = `file://${res.videoPath.replace(/\\/g, '/')}`
setVideoPath(local)
onPreview(local, true, undefined)
} else {
alert('视频解密失败')
}
} catch (e) {
console.error(e)
} finally {
setIsDecrypting(false)
}
} else {
onPreview(videoPath, true, undefined)
}
} else {
onPreview(thumbSrc || targetUrl, false, liveVideoPath)
}
}
const handleDownload = async (e: React.MouseEvent) => {
e.stopPropagation()
setLoading(true)
try {
const result = await window.electronAPI.sns.proxyImage({
url: media.url,
key: skipDecrypt ? undefined : media.key
})
if (result.success) {
const link = document.createElement('a')
link.download = `sns_media_${Date.now()}.${isVideo ? 'mp4' : 'jpg'}`
if (result.dataUrl) {
link.href = result.dataUrl
} else if (result.videoPath) {
// For local video files, we need to fetch as blob to force download behavior
// or just use the file protocol url if the browser supports it
try {
const response = await fetch(`file://${result.videoPath}`)
const blob = await response.blob()
const url = URL.createObjectURL(blob)
link.href = url
setTimeout(() => URL.revokeObjectURL(url), 60000)
} catch (err) {
console.error('Video fetch failed, falling back to direct link', err)
link.href = `file://${result.videoPath}`
}
}
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
} else {
alert('下载失败: 无法获取资源')
}
} catch (e) {
console.error('Download error:', e)
alert('下载出错')
} finally {
setLoading(false)
}
}
if (deleted) {
return (
<div className="sns-media-item deleted-media">
<div className="deleted-placeholder">
<ImageOff size={24} />
<span></span>
</div>
</div>
)
}
return (
<div
className={`sns-media-item ${isDecrypting ? 'decrypting' : ''}`}
onClick={handlePreview}
>
{(thumbSrc && !thumbSrc.startsWith('data:') && (thumbSrc.toLowerCase().endsWith('.mp4') || thumbSrc.includes('video'))) ? (
<video
key={thumbSrc}
src={`${thumbSrc}#t=0.1`}
className="media-image"
preload="auto"
muted
playsInline
disablePictureInPicture
disableRemotePlayback
onLoadedMetadata={(e) => {
e.currentTarget.currentTime = 0.1
}}
/>
) : thumbSrc ? (
<img
src={thumbSrc}
className="media-image"
loading="lazy"
onError={() => { if (!loading && !isVideo) markDeleted() }}
alt=""
/>
) : null}
{isGeneratingCover && (
<div className="media-decrypting-mask">
<RefreshCw className="spin" size={24} />
<span>...</span>
</div>
)}
{isVideo && (
<div className="media-badge video">
{/* If we have a cover, show Play. If decrypting for preview, show spin. Generating cover has its own mask. */}
{isDecrypting ? <RefreshCw className="spin" size={16} /> : <Play size={16} fill="currentColor" />}
</div>
)}
{isLive && !isVideo && (
<div className="media-badge live">
<LivePhotoIcon size={16} />
</div>
)}
<div className="media-download-btn" onClick={handleDownload} title="下载">
<Download size={16} />
</div>
</div>
)
}
export const SnsMediaGrid: React.FC<SnsMediaGridProps> = ({ mediaList, postType, onPreview, onMediaDeleted }) => {
if (!mediaList || mediaList.length === 0) return null
const count = mediaList.length
let gridClass = ''
if (count === 1) gridClass = 'grid-1'
else if (count === 2) gridClass = 'grid-2'
else if (count === 3) gridClass = 'grid-3'
else if (count === 4) gridClass = 'grid-4' // 2x2
else if (count <= 6) gridClass = 'grid-6' // 3 cols
else gridClass = 'grid-9' // 3x3
return (
<div className={`sns-media-grid ${gridClass}`}>
{mediaList.map((media, idx) => (
<MediaItem key={idx} media={media} postType={postType} onPreview={onPreview} onMediaDeleted={onMediaDeleted} />
))}
</div>
)
}

View File

@@ -0,0 +1,424 @@
import React, { useState, useMemo, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { Heart, ChevronRight, ImageIcon, Code, Trash2 } from 'lucide-react'
import { SnsPost, SnsLinkCardData } from '../../types/sns'
import { Avatar } from '../Avatar'
import { SnsMediaGrid } from './SnsMediaGrid'
import { getEmojiPath } from 'wechat-emojis'
// Helper functions (extracted from SnsPage.tsx but simplified/reused)
const LINK_XML_URL_TAGS = ['url', 'shorturl', 'weburl', 'webpageurl', 'jumpurl']
const LINK_XML_TITLE_TAGS = ['title', 'linktitle', 'webtitle']
const MEDIA_HOST_HINTS = ['mmsns.qpic.cn', 'vweixinthumb', 'snstimeline', 'snsvideodownload']
const isSnsVideoUrl = (url?: string): boolean => {
if (!url) return false
const lower = url.toLowerCase()
return (lower.includes('snsvideodownload') || lower.includes('.mp4') || lower.includes('video')) && !lower.includes('vweixinthumb')
}
const decodeHtmlEntities = (text: string): string => {
if (!text) return ''
return text
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;/gi, "'")
.trim()
}
const normalizeUrlCandidate = (raw: string): string | null => {
const value = decodeHtmlEntities(raw).replace(/[)\],.;]+$/, '').trim()
if (!value) return null
if (!/^https?:\/\//i.test(value)) return null
return value
}
const simplifyUrlForCompare = (value: string): string => {
const normalized = value.trim().toLowerCase().replace(/^https?:\/\//, '')
const [withoutQuery] = normalized.split('?')
return withoutQuery.replace(/\/+$/, '')
}
const getXmlTagValues = (xml: string, tags: string[]): string[] => {
if (!xml) return []
const results: string[] = []
for (const tag of tags) {
const reg = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'ig')
let match: RegExpExecArray | null
while ((match = reg.exec(xml)) !== null) {
if (match[1]) results.push(match[1])
}
}
return results
}
const getUrlLikeStrings = (text: string): string[] => {
if (!text) return []
return text.match(/https?:\/\/[^\s<>"']+/gi) || []
}
const isLikelyMediaAssetUrl = (url: string): boolean => {
const lower = url.toLowerCase()
return MEDIA_HOST_HINTS.some((hint) => lower.includes(hint))
}
const buildLinkCardData = (post: SnsPost): SnsLinkCardData | null => {
// type 3 是链接类型,直接用 media[0] 的 url 和 thumb
if (post.type === 3) {
const url = post.media[0]?.url || post.linkUrl
if (!url) return null
const titleCandidates = [
post.linkTitle || '',
...getXmlTagValues(post.rawXml || '', LINK_XML_TITLE_TAGS),
post.contentDesc || ''
]
const title = titleCandidates
.map((v) => decodeHtmlEntities(v))
.find((v) => Boolean(v) && !/^https?:\/\//i.test(v))
return { url, title: title || '网页链接', thumb: post.media[0]?.thumb }
}
const hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url))
if (hasVideoMedia) return null
const mediaValues = post.media
.flatMap((item) => [item.url, item.thumb])
.filter((value): value is string => Boolean(value))
const mediaSet = new Set(mediaValues.map((value) => simplifyUrlForCompare(value)))
const urlCandidates: string[] = [
post.linkUrl || '',
...getXmlTagValues(post.rawXml || '', LINK_XML_URL_TAGS),
...getUrlLikeStrings(post.rawXml || ''),
...getUrlLikeStrings(post.contentDesc || '')
]
const normalizedCandidates = urlCandidates
.map(normalizeUrlCandidate)
.filter((value): value is string => Boolean(value))
const dedupedCandidates: string[] = []
const seen = new Set<string>()
for (const candidate of normalizedCandidates) {
if (seen.has(candidate)) continue
seen.add(candidate)
dedupedCandidates.push(candidate)
}
const linkUrl = dedupedCandidates.find((candidate) => {
const simplified = simplifyUrlForCompare(candidate)
if (mediaSet.has(simplified)) return false
if (isLikelyMediaAssetUrl(candidate)) return false
return true
})
if (!linkUrl) return null
const titleCandidates = [
post.linkTitle || '',
...getXmlTagValues(post.rawXml || '', LINK_XML_TITLE_TAGS),
post.contentDesc || ''
]
const title = titleCandidates
.map((value) => decodeHtmlEntities(value))
.find((value) => Boolean(value) && !/^https?:\/\//i.test(value))
return {
url: linkUrl,
title: title || '网页链接',
thumb: post.media[0]?.thumb || post.media[0]?.url
}
}
const SnsLinkCard = ({ card }: { card: SnsLinkCardData }) => {
const [thumbFailed, setThumbFailed] = useState(false)
const hostname = useMemo(() => {
try {
return new URL(card.url).hostname.replace(/^www\./i, '')
} catch {
return card.url
}
}, [card.url])
const handleClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
try {
await window.electronAPI.shell.openExternal(card.url)
} catch (error) {
console.error('[SnsLinkCard] openExternal failed:', error)
}
}
return (
<button type="button" className="post-link-card" onClick={handleClick}>
<div className="link-thumb">
{card.thumb && !thumbFailed ? (
<img
src={card.thumb}
alt=""
referrerPolicy="no-referrer"
loading="lazy"
onError={() => setThumbFailed(true)}
/>
) : (
<div className="link-thumb-fallback">
<ImageIcon size={18} />
</div>
)}
</div>
<div className="link-meta">
<div className="link-title">{card.title}</div>
<div className="link-url">{hostname}</div>
</div>
<ChevronRight size={16} className="link-arrow" />
</button>
)
}
// 表情包内存缓存
const emojiLocalCache = new Map<string, string>()
// 评论表情包组件
const CommentEmoji: React.FC<{
emoji: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }
onPreview?: (src: string) => void
}> = ({ emoji, onPreview }) => {
const cacheKey = emoji.encryptUrl || emoji.url
const [localSrc, setLocalSrc] = useState<string>(() => emojiLocalCache.get(cacheKey) || '')
useEffect(() => {
if (!cacheKey) return
if (emojiLocalCache.has(cacheKey)) {
setLocalSrc(emojiLocalCache.get(cacheKey)!)
return
}
let cancelled = false
const load = async () => {
try {
const res = await window.electronAPI.sns.downloadEmoji({
url: emoji.url,
encryptUrl: emoji.encryptUrl,
aesKey: emoji.aesKey
})
if (cancelled) return
if (res.success && res.localPath) {
const fileUrl = res.localPath.startsWith('file:')
? res.localPath
: `file://${res.localPath.replace(/\\/g, '/')}`
emojiLocalCache.set(cacheKey, fileUrl)
setLocalSrc(fileUrl)
}
} catch { /* 静默失败 */ }
}
load()
return () => { cancelled = true }
}, [cacheKey])
if (!localSrc) return null
return (
<img
src={localSrc}
alt="emoji"
className="comment-custom-emoji"
draggable={false}
onClick={(e) => { e.stopPropagation(); onPreview?.(localSrc) }}
style={{
width: Math.min(emoji.width || 24, 30),
height: Math.min(emoji.height || 24, 30),
verticalAlign: 'middle',
marginLeft: 2,
borderRadius: 4,
cursor: onPreview ? 'pointer' : 'default'
}}
/>
)
}
interface SnsPostItemProps {
post: SnsPost
onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void
onDebug: (post: SnsPost) => void
onDelete?: (postId: string) => void
}
export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDebug, onDelete }) => {
const [mediaDeleted, setMediaDeleted] = useState(false)
const [dbDeleted, setDbDeleted] = useState(false)
const [deleting, setDeleting] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const linkCard = buildLinkCardData(post)
const hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url))
const showLinkCard = Boolean(linkCard) && post.media.length <= 1 && !hasVideoMedia
const showMediaGrid = post.media.length > 0 && !showLinkCard
const formatTime = (ts: number) => {
const date = new Date(ts * 1000)
const isCurrentYear = date.getFullYear() === new Date().getFullYear()
return date.toLocaleString('zh-CN', {
year: isCurrentYear ? undefined : 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
// 解析微信表情
const renderTextWithEmoji = (text: string) => {
if (!text) return text
const parts = text.split(/\[(.*?)\]/g)
return parts.map((part, index) => {
if (index % 2 === 1) {
// @ts-ignore
const path = getEmojiPath(part as any)
if (path) {
return <img key={index} src={`${import.meta.env.BASE_URL}${path}`} alt={`[${part}]`} className="inline-emoji" style={{ width: 22, height: 22, verticalAlign: 'bottom', margin: '0 1px' }} />
}
return `[${part}]`
}
return part
})
}
const handleDeleteClick = (e: React.MouseEvent) => {
e.stopPropagation()
if (deleting || dbDeleted) return
setShowDeleteConfirm(true)
}
const handleDeleteConfirm = async () => {
setShowDeleteConfirm(false)
setDeleting(true)
try {
const r = await window.electronAPI.sns.deleteSnsPost(post.tid ?? post.id)
if (r.success) {
setDbDeleted(true)
onDelete?.(post.id)
}
} finally {
setDeleting(false)
}
}
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>
<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>
<div className="post-header-actions">
{(mediaDeleted || dbDeleted) && (
<span className="post-deleted-badge">
<Trash2 size={12} />
<span></span>
</span>
)}
<button
className="icon-btn-ghost debug-btn delete-btn"
onClick={handleDeleteClick}
disabled={deleting || dbDeleted}
title="从数据库删除此条记录"
>
<Trash2 size={14} />
</button>
<button className="icon-btn-ghost debug-btn" onClick={(e) => {
e.stopPropagation();
onDebug(post);
}} title="查看原始数据">
<Code size={14} />
</button>
</div>
</div>
{post.contentDesc && (
<div className="post-text">{renderTextWithEmoji(decodeHtmlEntities(post.contentDesc))}</div>
)}
{showLinkCard && linkCard && (
<SnsLinkCard card={linkCard} />
)}
{showMediaGrid && (
<div className="post-media-container">
<SnsMediaGrid mediaList={post.media} postType={post.type} onPreview={onPreview} onMediaDeleted={[1, 54].includes(post.type ?? 0) ? () => setMediaDeleted(true) : undefined} />
</div>
)}
{(post.likes.length > 0 || post.comments.length > 0) && (
<div className="post-interactions">
{post.likes.length > 0 && (
<div className="likes-block">
<Heart size={14} className="like-icon" />
<span className="likes-text">{post.likes.join('、')}</span>
</div>
)}
{post.comments.length > 0 && (
<div className="comments-block">
{post.comments.map((c, idx) => (
<div key={idx} className="comment-row">
<span className="comment-user">{c.nickname}</span>
{c.refNickname && (
<>
<span className="reply-text"></span>
<span className="comment-user">{c.refNickname}</span>
</>
)}
<span className="comment-colon"></span>
{c.content && (
<span className="comment-content">{renderTextWithEmoji(c.content)}</span>
)}
{c.emojis && c.emojis.map((emoji, ei) => (
<CommentEmoji
key={ei}
emoji={emoji}
onPreview={(src) => onPreview(src)}
/>
))}
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
{/* 删除确认弹窗 - 用 Portal 挂到 body避免父级 transform 影响 fixed 定位 */}
{showDeleteConfirm && createPortal(
<div className="sns-confirm-overlay" onClick={() => setShowDeleteConfirm(false)}>
<div className="sns-confirm-dialog" onClick={(e) => e.stopPropagation()}>
<div className="sns-confirm-icon">
<Trash2 size={22} />
</div>
<div className="sns-confirm-title"></div>
<div className="sns-confirm-desc"></div>
<div className="sns-confirm-actions">
<button className="sns-confirm-cancel" onClick={() => setShowDeleteConfirm(false)}></button>
<button className="sns-confirm-ok" onClick={handleDeleteConfirm}></button>
</div>
</div>
</div>,
document.body
)}
</>
)
}

View File

@@ -10,6 +10,12 @@
gap: 8px;
}
// 繁花如梦:标题栏毛玻璃
[data-theme="blossom-dream"] .title-bar {
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
.title-logo {
width: 20px;
height: 20px;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,40 +9,40 @@ function AgreementPage() {
<div className="agreement-content">
{/* 协议内容 - 请替换为完整的协议文本 */}
<h2></h2>
<h3></h3>
<p>使WeFlowWeFlow使使</p>
<p>使WeFlowWeFlow使使</p>
<h3></h3>
<p>WeFlow是一款本地化的微信聊天记录查看与分析工具</p>
<h3>使</h3>
<p>1. 使</p>
<p>2. </p>
<p>3. </p>
<h3></h3>
<p>1. "现状"</p>
<p>2. 使使</p>
<p>3. </p>
<h3></h3>
<p></p>
<h2></h2>
<h3></h3>
<p></p>
<h3></h3>
<p></p>
<p></p>
<h3></h3>
<p>访</p>
<h3></h3>
<p>广</p>
<p className="agreement-footer-text">20251</p>
</div>
</div>

View File

@@ -45,6 +45,30 @@
font-weight: 600;
color: var(--primary);
}
.error-actions {
display: flex;
align-items: center;
gap: 8px;
}
}
.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 {
@@ -292,4 +316,215 @@
grid-column: span 1;
}
}
}
}
// 排除好友弹窗
.exclude-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(4px);
}
.exclude-modal {
width: 560px;
max-width: calc(100vw - 48px);
background: var(--card-bg);
border-radius: 16px;
border: 1px solid var(--border-color);
padding: 20px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
.exclude-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
h3 {
margin: 0;
font-size: 16px;
color: var(--text-primary);
}
}
.modal-close {
width: 32px;
height: 32px;
border-radius: 50%;
border: none;
background: var(--bg-tertiary);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--text-secondary);
transition: all 0.15s;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
}
.exclude-modal-search {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 10px;
border: 1px solid var(--border-color);
background: var(--bg-primary);
margin-bottom: 12px;
color: var(--text-tertiary);
input {
flex: 1;
border: none;
outline: none;
background: transparent;
color: var(--text-primary);
font-size: 13px;
}
.clear-search {
background: none;
border: none;
cursor: pointer;
color: var(--text-tertiary);
padding: 2px;
&:hover {
color: var(--text-primary);
}
}
}
.exclude-modal-body {
max-height: 420px;
overflow: auto;
padding-right: 4px;
}
.exclude-loading,
.exclude-error,
.exclude-empty {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--text-secondary);
padding: 24px 0;
font-size: 13px;
}
.exclude-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.exclude-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 10px;
border-radius: 10px;
cursor: pointer;
border: 1px solid transparent;
transition: all 0.15s;
background: var(--bg-primary);
&:hover {
background: var(--bg-tertiary);
}
&.active {
border-color: rgba(7, 193, 96, 0.4);
background: rgba(7, 193, 96, 0.08);
}
input {
margin: 0;
}
}
.exclude-avatar {
flex-shrink: 0;
}
.exclude-info {
display: flex;
flex-direction: column;
min-width: 0;
gap: 2px;
}
.exclude-name {
font-size: 14px;
color: var(--text-primary);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.exclude-username {
font-size: 12px;
color: var(--text-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.exclude-modal-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 16px;
}
.exclude-footer-left {
display: flex;
align-items: center;
gap: 12px;
}
.exclude-count {
font-size: 12px;
color: var(--text-tertiary);
}
.btn-text {
display: inline-flex;
align-items: center;
gap: 4px;
background: none;
border: none;
cursor: pointer;
font-size: 12px;
color: var(--text-secondary);
padding: 4px 8px;
border-radius: 6px;
transition: all 0.15s;
&:hover {
color: var(--primary);
background: var(--primary-light);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.exclude-actions {
display: flex;
gap: 8px;
}
}

View File

@@ -1,21 +1,51 @@
import { useState, useEffect, useCallback } from 'react'
import { useLocation } from 'react-router-dom'
import { Users, Clock, MessageSquare, Send, Inbox, Calendar, Loader2, RefreshCw, User, Medal } from 'lucide-react'
import { Users, Clock, MessageSquare, Send, Inbox, Calendar, Loader2, RefreshCw, Medal, UserMinus, Search, X } from 'lucide-react'
import ReactECharts from 'echarts-for-react'
import { useAnalyticsStore } from '../stores/analyticsStore'
import { useThemeStore } from '../stores/themeStore'
import './AnalyticsPage.scss'
import './DataManagementPage.scss'
import { Avatar } from '../components/Avatar'
interface ExcludeCandidate {
username: string
displayName: string
avatarUrl?: string
wechatId?: string
}
const normalizeUsername = (value: string) => value.trim().toLowerCase()
function AnalyticsPage() {
const [isLoading, setIsLoading] = useState(false)
const [loadingStatus, setLoadingStatus] = useState('')
const [error, setError] = useState<string | null>(null)
const [progress, setProgress] = useState(0)
const [isExcludeDialogOpen, setIsExcludeDialogOpen] = useState(false)
const [excludeCandidates, setExcludeCandidates] = useState<ExcludeCandidate[]>([])
const [excludeQuery, setExcludeQuery] = useState('')
const [excludeLoading, setExcludeLoading] = useState(false)
const [excludeError, setExcludeError] = useState<string | null>(null)
const [excludedUsernames, setExcludedUsernames] = useState<Set<string>>(new Set())
const [draftExcluded, setDraftExcluded] = useState<Set<string>>(new Set())
const themeMode = useThemeStore((state) => state.themeMode)
const { statistics, rankings, timeDistribution, isLoaded, setStatistics, setRankings, setTimeDistribution, markLoaded } = useAnalyticsStore()
const { statistics, rankings, timeDistribution, isLoaded, setStatistics, setRankings, setTimeDistribution, markLoaded, clearCache } = useAnalyticsStore()
const loadExcludedUsernames = useCallback(async () => {
try {
const result = await window.electronAPI.analytics.getExcludedUsernames()
if (result.success && result.data) {
setExcludedUsernames(new Set(result.data.map(normalizeUsername)))
} else {
setExcludedUsernames(new Set())
}
} catch (e) {
console.warn('加载排除名单失败', e)
setExcludedUsernames(new Set())
}
}, [])
const loadData = useCallback(async (forceRefresh = false) => {
if (isLoaded && !forceRefresh) return
setIsLoading(true)
@@ -66,13 +96,117 @@ function AnalyticsPage() {
useEffect(() => {
const handleChange = () => {
loadExcludedUsernames()
loadData(true)
}
window.addEventListener('wxid-changed', handleChange as EventListener)
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
}, [loadData])
}, [loadData, loadExcludedUsernames])
useEffect(() => {
loadExcludedUsernames()
}, [loadExcludedUsernames])
const handleRefresh = () => loadData(true)
const isNoSessionError = error?.includes('未找到消息会话') ?? false
const loadExcludeCandidates = useCallback(async () => {
setExcludeLoading(true)
setExcludeError(null)
try {
const result = await window.electronAPI.analytics.getExcludeCandidates()
if (result.success && result.data) {
setExcludeCandidates(result.data)
} else {
setExcludeError(result.error || '加载好友列表失败')
}
} catch (e) {
setExcludeError(String(e))
} finally {
setExcludeLoading(false)
}
}, [])
const openExcludeDialog = async () => {
setExcludeQuery('')
setDraftExcluded(new Set(excludedUsernames))
setIsExcludeDialogOpen(true)
await loadExcludeCandidates()
}
const toggleExcluded = (username: string) => {
const key = normalizeUsername(username)
setDraftExcluded((prev) => {
const next = new Set(prev)
if (next.has(key)) {
next.delete(key)
} else {
next.add(key)
}
return next
})
}
const toggleInvertSelection = () => {
setDraftExcluded((prev) => {
const allUsernames = new Set(excludeCandidates.map(c => normalizeUsername(c.username)))
const inverted = new Set<string>()
for (const u of allUsernames) {
if (!prev.has(u)) inverted.add(u)
}
return inverted
})
}
const handleApplyExcluded = async () => {
const payload = Array.from(draftExcluded)
setIsExcludeDialogOpen(false)
try {
const result = await window.electronAPI.analytics.setExcludedUsernames(payload)
if (!result.success) {
alert(result.error || '更新排除名单失败')
return
}
setExcludedUsernames(new Set((result.data || payload).map(normalizeUsername)))
clearCache()
await window.electronAPI.cache.clearAnalytics()
await loadData(true)
} catch (e) {
alert(`更新排除名单失败:${String(e)}`)
}
}
const handleResetExcluded = async () => {
try {
const result = await window.electronAPI.analytics.setExcludedUsernames([])
if (!result.success) {
setError(result.error || '重置排除好友失败')
return
}
setExcludedUsernames(new Set())
setDraftExcluded(new Set())
clearCache()
await window.electronAPI.cache.clearAnalytics()
await loadData(true)
} catch (e) {
setError(`重置排除好友失败: ${String(e)}`)
}
}
const visibleExcludeCandidates = excludeCandidates
.filter((candidate) => {
const query = excludeQuery.trim().toLowerCase()
if (!query) return true
const wechatId = candidate.wechatId || ''
const haystack = `${candidate.displayName} ${candidate.username} ${wechatId}`.toLowerCase()
return haystack.includes(query)
})
.sort((a, b) => {
const aSelected = draftExcluded.has(normalizeUsername(a.username))
const bSelected = draftExcluded.has(normalizeUsername(b.username))
if (aSelected !== bSelected) return aSelected ? -1 : 1
return a.displayName.localeCompare(b.displayName, 'zh')
})
const formatDate = (timestamp: number | null) => {
if (!timestamp) return '-'
@@ -239,6 +373,22 @@ function AnalyticsPage() {
)
}
if (error && !isLoaded && isNoSessionError && excludedUsernames.size > 0) {
return (
<div className="error-container">
<p>{error}</p>
<div className="error-actions">
<button className="btn btn-secondary" onClick={handleResetExcluded}>
</button>
<button className="btn btn-primary" onClick={() => loadData(true)}>
</button>
</div>
</div>
)
}
if (error && !isLoaded) {
return (<div className="error-container"><p>{error}</p><button className="btn btn-primary" onClick={() => loadData(true)}></button></div>)
}
@@ -248,10 +398,16 @@ function AnalyticsPage() {
<>
<div className="page-header">
<h1></h1>
<button className="btn btn-secondary" onClick={handleRefresh} disabled={isLoading}>
<RefreshCw size={16} className={isLoading ? 'spin' : ''} />
{isLoading ? '刷新中...' : '刷新'}
</button>
<div className="header-actions">
<button className="btn btn-secondary" onClick={handleRefresh} disabled={isLoading}>
<RefreshCw size={16} className={isLoading ? 'spin' : ''} />
{isLoading ? '刷新中...' : '刷新'}
</button>
<button className="btn btn-secondary" onClick={openExcludeDialog}>
<UserMinus size={16} />
{excludedUsernames.size > 0 ? ` (${excludedUsernames.size})` : ''}
</button>
</div>
</div>
<div className="page-scroll">
<section className="page-section">
@@ -317,6 +473,89 @@ function AnalyticsPage() {
</div>
</section>
</div>
{isExcludeDialogOpen && (
<div className="exclude-modal-overlay" onClick={() => setIsExcludeDialogOpen(false)}>
<div className="exclude-modal" onClick={e => e.stopPropagation()}>
<div className="exclude-modal-header">
<h3></h3>
<button className="modal-close" onClick={() => setIsExcludeDialogOpen(false)}>
<X size={18} />
</button>
</div>
<div className="exclude-modal-search">
<Search size={16} />
<input
type="text"
placeholder="搜索好友"
value={excludeQuery}
onChange={e => setExcludeQuery(e.target.value)}
disabled={excludeLoading}
/>
{excludeQuery && (
<button className="clear-search" onClick={() => setExcludeQuery('')}>
<X size={14} />
</button>
)}
</div>
<div className="exclude-modal-body">
{excludeLoading && (
<div className="exclude-loading">
<Loader2 size={20} className="spin" />
<span>...</span>
</div>
)}
{!excludeLoading && excludeError && (
<div className="exclude-error">{excludeError}</div>
)}
{!excludeLoading && !excludeError && (
<div className="exclude-list">
{visibleExcludeCandidates.map((candidate) => {
const isChecked = draftExcluded.has(normalizeUsername(candidate.username))
const wechatId = candidate.wechatId?.trim() || candidate.username
return (
<label key={candidate.username} className={`exclude-item ${isChecked ? 'active' : ''}`}>
<input
type="checkbox"
checked={isChecked}
onChange={() => toggleExcluded(candidate.username)}
/>
<div className="exclude-avatar">
<Avatar src={candidate.avatarUrl} name={candidate.displayName} size={32} />
</div>
<div className="exclude-info">
<span className="exclude-name">{candidate.displayName}</span>
<span className="exclude-username">{wechatId}</span>
</div>
</label>
)
})}
{visibleExcludeCandidates.length === 0 && (
<div className="exclude-empty">
{excludeQuery.trim() ? '未找到匹配好友' : '暂无可选好友'}
</div>
)}
</div>
)}
</div>
<div className="exclude-modal-footer">
<div className="exclude-footer-left">
<span className="exclude-count"> {draftExcluded.size} </span>
<button className="btn btn-text" onClick={toggleInvertSelection} disabled={excludeLoading}>
</button>
</div>
<div className="exclude-actions">
<button className="btn btn-secondary" onClick={() => setIsExcludeDialogOpen(false)}>
</button>
<button className="btn btn-primary" onClick={handleApplyExcluded} disabled={excludeLoading}>
</button>
</div>
</div>
</div>
</div>
)}
</>
)
}

View File

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

View File

@@ -5,6 +5,7 @@
justify-content: center;
min-height: 100%;
text-align: center;
padding: 40px 24px;
}
.header-icon {
@@ -25,6 +26,113 @@
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;
gap: 32px;
width: min(760px, 100%);
}
.report-section {
width: 100%;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 20px;
padding: 28px;
text-align: left;
}
.section-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
}
.section-title {
margin: 0;
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
}
.section-desc {
margin: 8px 0 0;
font-size: 14px;
color: var(--text-tertiary);
}
.section-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 999px;
background: color-mix(in srgb, var(--primary) 12%, transparent);
color: var(--primary);
border: 1px solid color-mix(in srgb, var(--primary) 30%, transparent);
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.section-hint {
margin: 12px 0 0;
font-size: 12px;
color: var(--text-tertiary);
}
.year-grid-with-status {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
}
.year-grid {
display: flex;
flex-wrap: wrap;
@@ -34,6 +142,44 @@
margin-bottom: 48px;
}
.report-section .year-grid {
justify-content: flex-start;
max-width: none;
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 {
width: 120px;
height: 100px;
@@ -104,6 +250,13 @@
opacity: 0.6;
cursor: not-allowed;
}
&.secondary {
background: var(--card-bg);
color: var(--text-primary);
border: 1px solid var(--border-color);
box-shadow: none;
}
}
.spin {
@@ -114,3 +267,7 @@
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes dot-ellipsis {
to { width: 1.4em; }
}

View File

@@ -1,44 +1,156 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Calendar, Loader2, Sparkles } from 'lucide-react'
import { Calendar, Loader2, Sparkles, Users } from 'lucide-react'
import './AnnualReportPage.scss'
type YearOption = number | 'all'
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()
const [availableYears, setAvailableYears] = useState<number[]>([])
const [selectedYear, setSelectedYear] = useState<number | null>(null)
const [selectedYear, setSelectedYear] = useState<YearOption | null>(null)
const [selectedPairYear, setSelectedPairYear] = useState<YearOption | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [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 = ''
const applyLoadPayload = (payload: YearsLoadPayload) => {
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')
} else {
setIsLoadingMoreYears(true)
setHasYearsLoadFinished(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 () => {
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) {
setLoadError(startResult.error || '加载年度数据失败')
setIsLoading(false)
setIsLoadingMoreYears(false)
return
}
taskId = startResult.taskId
if (startResult.snapshot) {
applyLoadPayload(startResult.snapshot)
}
} catch (e) {
console.error(e)
setLoadError(String(e))
setIsLoading(false)
setIsLoadingMoreYears(false)
}
}
void startLoad()
return () => {
disposed = true
stopListen()
}
}, [])
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(result.data[0])
} else if (!result.success) {
setLoadError(result.error || '加载年度数据失败')
}
} catch (e) {
console.error(e)
setLoadError(String(e))
} finally {
setIsLoading(false)
}
}
const handleGenerateReport = async () => {
if (!selectedYear) return
if (selectedYear === null) return
setIsGenerating(true)
try {
navigate(`/annual-report/view?year=${selectedYear}`)
const yearParam = selectedYear === 'all' ? 0 : selectedYear
navigate(`/annual-report/view?year=${yearParam}`)
} catch (e) {
console.error('生成报告失败:', e)
} finally {
@@ -46,16 +158,31 @@ function AnnualReportPage() {
}
}
if (isLoading) {
const handleGenerateDualReport = () => {
if (selectedPairYear === null) return
const yearParam = selectedPairYear === 'all' ? 0 : selectedPairYear
navigate(`/dual-report?year=${yearParam}`)
}
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 className="load-telemetry compact">
<p><span className="label"></span>{getStrategyLabel({ loadStrategy, loadPhase, hasYearsLoadFinished, hasSwitchedStrategy, nativeTimedOut })}</p>
<p><span className="label"></span>{loadStatusText || '正在加载年份数据...'}</p>
<p>
<span className="label"></span>{formatLoadElapsed(nativeElapsedMs)}{nativeTimedOut ? '(超时)' : ''} {' '}
<span className="label"></span>{formatLoadElapsed(scanElapsedMs)} {' '}
<span className="label"></span>{formatLoadElapsed(totalElapsedMs)}
</p>
</div>
</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 }} />
@@ -67,44 +194,164 @@ function AnnualReportPage() {
)
}
const yearOptions: YearOption[] = availableYears.length > 0
? ['all', ...availableYears]
: []
const getYearLabel = (value: YearOption | null) => {
if (!value) return ''
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" />
<h1 className="page-title"></h1>
<p className="page-desc"></p>
<div className="year-grid">
{availableYears.map(year => (
<div
key={year}
className={`year-card ${selectedYear === year ? 'selected' : ''}`}
onClick={() => setSelectedYear(year)}
>
<span className="year-number">{year}</span>
<span className="year-label"></span>
</div>
))}
<p className="page-desc"></p>
{loadedYearCount > 0 && (
<p className={`page-desc load-summary ${isYearStatusComplete ? 'complete' : 'loading'}`}>
{isYearStatusComplete ? (
<> {loadedYearCount} {formatLoadElapsed(totalElapsedMs)}</>
) : (
<>
{loadedYearCount} <span className="dot-ellipsis" aria-hidden="true">...</span>
{formatLoadElapsed(totalElapsedMs)}
</>
)}
</p>
)}
<div className={`load-telemetry ${isYearStatusComplete ? 'complete' : 'loading'}`}>
<p><span className="label"></span>{strategyLabel}</p>
<p>
<span className="label"></span>
{loadStatusText || (isYearStatusComplete ? '全部年份已加载完毕' : '正在加载年份数据...')}
</p>
<p>
<span className="label"></span>{formatLoadElapsed(nativeElapsedMs)}{nativeTimedOut ? '(超时)' : ''} {' '}
<span className="label"></span>{formatLoadElapsed(scanElapsedMs)} {' '}
<span className="label"></span>{formatLoadElapsed(totalElapsedMs)}
</p>
</div>
<button
className="generate-btn"
onClick={handleGenerateReport}
disabled={!selectedYear || isGenerating}
>
{isGenerating ? (
<>
<Loader2 size={20} className="spin" />
<span>...</span>
</>
) : (
<>
<Sparkles size={20} />
<span> {selectedYear} </span>
</>
)}
</button>
<div className="report-sections">
<section className="report-section">
<div className="section-header">
<div>
<h2 className="section-title"></h2>
<p className="section-desc"></p>
</div>
</div>
<div className="year-grid-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>
{renderYearLoadStatus()}
</div>
<button
className="generate-btn"
onClick={handleGenerateReport}
disabled={!selectedYear || isGenerating}
>
{isGenerating ? (
<>
<Loader2 size={20} className="spin" />
<span>...</span>
</>
) : (
<>
<Sparkles size={20} />
<span> {getYearLabel(selectedYear)} </span>
</>
)}
</button>
</section>
<section className="report-section">
<div className="section-header">
<div>
<h2 className="section-title"></h2>
<p className="section-desc"></p>
</div>
<div className="section-badge">
<Users size={16} />
<span></span>
</div>
</div>
<div className="year-grid-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>
{renderYearLoadStatus()}
</div>
<button
className="generate-btn secondary"
onClick={handleGenerateDualReport}
disabled={!selectedPairYear}
>
<Users size={20} />
<span></span>
</button>
<p className="section-hint"></p>
</section>
</div>
</div>
)
}
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

@@ -1,7 +1,9 @@
.annual-report-window {
// 使用全局主题变量,带回退值
--ar-primary: var(--primary, #07C160);
--ar-primary-rgb: var(--primary-rgb, 7, 193, 96);
--ar-accent: var(--accent, #F2AA00);
--ar-accent-rgb: 242, 170, 0;
--ar-text-main: var(--text-primary, #222222);
--ar-text-sub: var(--text-secondary, #555555);
--ar-bg-color: var(--bg-primary, #F9F8F6);
@@ -43,7 +45,7 @@
// 背景装饰圆点 - 毛玻璃效果
.bg-decoration {
position: absolute; // Changed from fixed
position: absolute;
inset: 0;
pointer-events: none;
z-index: 0;
@@ -53,10 +55,10 @@
.deco-circle {
position: absolute;
border-radius: 50%;
background: rgba(0, 0, 0, 0.03);
background: rgba(var(--ar-primary-rgb), 0.03);
backdrop-filter: blur(40px);
-webkit-backdrop-filter: blur(40px);
border: 1px solid rgba(0, 0, 0, 0.05);
border: 1px solid var(--border-color);
&.c1 {
width: 280px;
@@ -243,6 +245,7 @@
}
.exporting-snapshot {
.hero-title,
.label-text,
.hero-desc,
@@ -253,6 +256,11 @@
background: transparent !important;
box-shadow: none !important;
}
.deco-circle {
background: transparent !important;
border: none !important;
}
}
.section {
@@ -1279,3 +1287,135 @@
color: var(--ar-text-sub) !important;
text-align: center;
}
// 曾经的好朋友 视觉效果
.lost-friend-visual {
display: flex;
align-items: center;
justify-content: center;
gap: 32px;
margin: 64px auto 48px;
position: relative;
max-width: 480px;
.avatar-group {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
z-index: 2;
.avatar-label {
font-size: 13px;
color: var(--ar-text-sub);
font-weight: 500;
opacity: 0.6;
}
&.sender {
animation: fadeInRight 1s ease-out backwards;
}
&.receiver {
animation: fadeInLeft 1s ease-out backwards;
}
}
.fading-line {
position: relative;
flex: 1;
height: 2px;
min-width: 120px;
display: flex;
align-items: center;
justify-content: center;
.line-path {
width: 100%;
height: 100%;
background: linear-gradient(to right,
var(--ar-primary) 0%,
rgba(var(--ar-primary-rgb), 0.4) 50%,
rgba(var(--ar-primary-rgb), 0.05) 100%);
border-radius: 2px;
}
.line-glow {
position: absolute;
inset: -4px 0;
background: linear-gradient(to right,
rgba(var(--ar-primary-rgb), 0.2) 0%,
transparent 100%);
filter: blur(8px);
pointer-events: none;
}
.flow-particle {
position: absolute;
width: 40px;
height: 2px;
background: linear-gradient(to right, transparent, var(--ar-primary), transparent);
border-radius: 2px;
opacity: 0;
animation: flowAcross 4s infinite linear;
}
}
}
.hero-desc.fading {
opacity: 0.7;
font-style: italic;
font-size: 16px;
margin-top: 32px;
line-height: 1.8;
letter-spacing: 0.05em;
animation: fadeIn 1.5s ease-out 0.5s backwards;
}
@keyframes flowAcross {
0% {
left: -20%;
opacity: 0;
}
10% {
opacity: 0.8;
}
50% {
opacity: 0.4;
}
90% {
opacity: 0.1;
}
100% {
left: 120%;
opacity: 0;
}
}
@keyframes fadeInRight {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes fadeInLeft {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}

View File

@@ -71,6 +71,20 @@ interface AnnualReportData {
socialInitiative?: { initiatedChats: number; receivedChats: number; initiativeRate: number } | null
responseSpeed?: { avgResponseTime: number; fastestFriend: string; fastestTime: number } | null
topPhrases?: { phrase: string; count: number }[]
snsStats?: {
totalPosts: number
typeCounts?: Record<string, number>
topLikers: { username: string; displayName: string; avatarUrl?: string; count: number }[]
topLiked: { username: string; displayName: string; avatarUrl?: string; count: number }[]
}
lostFriend: {
username: string
displayName: string
avatarUrl?: string
earlyCount: number
lateCount: number
periodDesc: string
} | null
}
interface SectionInfo {
@@ -95,148 +109,8 @@ const Avatar = ({ url, name, size = 'md' }: { url?: string; name: string; size?:
)
}
// 热力图组件
const Heatmap = ({ data }: { data: number[][] }) => {
const maxHeat = Math.max(...data.flat())
const weekLabels = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
return (
<div className="heatmap-wrapper">
<div className="heatmap-header">
<div></div>
<div className="time-labels">
{[0, 6, 12, 18].map(h => (
<span key={h} style={{ gridColumn: h + 1 }}>{h}</span>
))}
</div>
</div>
<div className="heatmap">
<div className="heatmap-week-col">
{weekLabels.map(w => <div key={w} className="week-label">{w}</div>)}
</div>
<div className="heatmap-grid">
{data.map((row, wi) =>
row.map((val, hi) => {
const alpha = maxHeat > 0 ? (val / maxHeat * 0.85 + 0.1).toFixed(2) : '0.1'
return (
<div
key={`${wi}-${hi}`}
className="h-cell"
style={{ background: `rgba(7, 193, 96, ${alpha})` }}
title={`${weekLabels[wi]} ${hi}:00 - ${val}`}
/>
)
})
)}
</div>
</div>
</div>
)
}
// 词云组件
const WordCloud = ({ words }: { words: { phrase: string; count: number }[] }) => {
const maxCount = words.length > 0 ? words[0].count : 1
const topWords = words.slice(0, 32)
const baseSize = 520
// 使用确定性随机数生成器
const seededRandom = (seed: number) => {
const x = Math.sin(seed) * 10000
return x - Math.floor(x)
}
// 计算词云位置
const placedItems: { x: number; y: number; w: number; h: number }[] = []
const canPlace = (x: number, y: number, w: number, h: number): boolean => {
const halfW = w / 2
const halfH = h / 2
const dx = x - 50
const dy = y - 50
const dist = Math.sqrt(dx * dx + dy * dy)
const maxR = 49 - Math.max(halfW, halfH)
if (dist > maxR) return false
const pad = 1.8
for (const p of placedItems) {
if ((x - halfW - pad) < (p.x + p.w / 2) &&
(x + halfW + pad) > (p.x - p.w / 2) &&
(y - halfH - pad) < (p.y + p.h / 2) &&
(y + halfH + pad) > (p.y - p.h / 2)) {
return false
}
}
return true
}
const wordItems = topWords.map((item, i) => {
const ratio = item.count / maxCount
const fontSize = Math.round(12 + Math.pow(ratio, 0.65) * 20)
const opacity = Math.min(1, Math.max(0.35, 0.35 + ratio * 0.65))
const delay = (i * 0.04).toFixed(2)
// 计算词语宽度
const charCount = Math.max(1, item.phrase.length)
const hasCjk = /[\u4e00-\u9fff]/.test(item.phrase)
const hasLatin = /[A-Za-z0-9]/.test(item.phrase)
const widthFactor = hasCjk && hasLatin ? 0.85 : hasCjk ? 0.98 : 0.6
const widthPx = fontSize * (charCount * widthFactor)
const heightPx = fontSize * 1.1
const widthPct = (widthPx / baseSize) * 100
const heightPct = (heightPx / baseSize) * 100
// 寻找位置
let x = 50, y = 50
let placedOk = false
const tries = i === 0 ? 1 : 420
for (let t = 0; t < tries; t++) {
if (i === 0) {
x = 50
y = 50
} else {
const idx = i + t * 0.28
const radius = Math.sqrt(idx) * 7.6 + (seededRandom(i * 1000 + t) * 1.2 - 0.6)
const angle = idx * 2.399963 + seededRandom(i * 2000 + t) * 0.35
x = 50 + radius * Math.cos(angle)
y = 50 + radius * Math.sin(angle)
}
if (canPlace(x, y, widthPct, heightPct)) {
placedOk = true
break
}
}
if (!placedOk) return null
placedItems.push({ x, y, w: widthPct, h: heightPct })
return (
<span
key={i}
className="word-tag"
style={{
'--final-opacity': opacity,
left: `${x.toFixed(2)}%`,
top: `${y.toFixed(2)}%`,
fontSize: `${fontSize}px`,
animationDelay: `${delay}s`,
} as React.CSSProperties}
title={`${item.phrase} (出现 ${item.count} 次)`}
>
{item.phrase}
</span>
)
}).filter(Boolean)
return (
<div className="word-cloud-wrapper">
<div className="word-cloud-inner">
{wordItems}
</div>
</div>
)
}
import Heatmap from '../components/ReportHeatmap'
import WordCloud from '../components/ReportWordCloud'
function AnnualReportWindow() {
const [reportData, setReportData] = useState<AnnualReportData | null>(null)
@@ -274,6 +148,8 @@ function AnnualReportWindow() {
responseSpeed: useRef<HTMLElement>(null),
topPhrases: useRef<HTMLElement>(null),
ranking: useRef<HTMLElement>(null),
sns: useRef<HTMLElement>(null),
lostFriend: useRef<HTMLElement>(null),
ending: useRef<HTMLElement>(null),
}
@@ -282,7 +158,8 @@ function AnnualReportWindow() {
useEffect(() => {
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
const yearParam = params.get('year')
const year = yearParam ? parseInt(yearParam) : new Date().getFullYear()
const parsedYear = yearParam ? parseInt(yearParam, 10) : new Date().getFullYear()
const year = Number.isNaN(parsedYear) ? new Date().getFullYear() : parsedYear
generateReport(year)
}, [])
@@ -337,6 +214,11 @@ function AnnualReportWindow() {
return `${Math.round(seconds / 3600)}小时`
}
const formatYearLabel = (value: number, withSuffix: boolean = true) => {
if (value === 0) return '历史以来'
return withSuffix ? `${value}` : `${value}`
}
// 获取可用的板块列表
const getAvailableSections = (): SectionInfo[] => {
if (!reportData) return []
@@ -367,10 +249,16 @@ function AnnualReportWindow() {
if (reportData.responseSpeed) {
sections.push({ id: 'responseSpeed', name: '回应速度', ref: sectionRefs.responseSpeed })
}
if (reportData.lostFriend) {
sections.push({ id: 'lostFriend', name: '曾经的好朋友', ref: sectionRefs.lostFriend })
}
if (reportData.topPhrases && reportData.topPhrases.length > 0) {
sections.push({ id: 'topPhrases', name: '年度常用语', ref: sectionRefs.topPhrases })
}
sections.push({ id: 'ranking', name: '好友排行', ref: sectionRefs.ranking })
if (reportData.snsStats && reportData.snsStats.totalPosts > 0) {
sections.push({ id: 'sns', name: '朋友圈', ref: sectionRefs.sns })
}
sections.push({ id: 'ending', name: '尾声', ref: sectionRefs.ending })
return sections
}
@@ -595,7 +483,8 @@ function AnnualReportWindow() {
const dataUrl = outputCanvas.toDataURL('image/png')
const link = document.createElement('a')
link.download = `${reportData?.year}年度报告${filterIds ? '_自定义' : ''}.png`
const yearFilePrefix = reportData ? formatYearLabel(reportData.year, false) : ''
link.download = `${yearFilePrefix}年度报告${filterIds ? '_自定义' : ''}.png`
link.href = dataUrl
document.body.appendChild(link)
link.click()
@@ -658,11 +547,12 @@ function AnnualReportWindow() {
}
setExportProgress('正在写入文件...')
const yearFilePrefix = reportData ? formatYearLabel(reportData.year, false) : ''
const exportResult = await window.electronAPI.annualReport.exportImages({
baseDir: dirResult.filePaths[0],
folderName: `${reportData?.year}年度报告_分模块`,
folderName: `${yearFilePrefix}年度报告_分模块`,
images: exportedImages.map((img) => ({
name: `${reportData?.year}年度报告_${img.name}.png`,
name: `${yearFilePrefix}年度报告_${img.name}.png`,
dataUrl: img.data
}))
})
@@ -733,10 +623,14 @@ function AnnualReportWindow() {
)
}
const { year, totalMessages, totalFriends, coreFriends, monthlyTopFriends, peakDay, longestStreak, activityHeatmap, midnightKing, selfAvatarUrl, mutualFriend, socialInitiative, responseSpeed, topPhrases } = reportData
const { year, totalMessages, totalFriends, coreFriends, monthlyTopFriends, peakDay, longestStreak, activityHeatmap, midnightKing, selfAvatarUrl, mutualFriend, socialInitiative, responseSpeed, topPhrases, lostFriend } = reportData
const topFriend = coreFriends[0]
const mostActive = getMostActiveTime(activityHeatmap.data)
const socialStoryName = topFriend?.displayName || '好友'
const yearTitle = formatYearLabel(year, true)
const yearTitleShort = formatYearLabel(year, false)
const monthlyTitle = year === 0 ? '历史以来月度好友' : `${year}年月度好友`
const phrasesTitle = year === 0 ? '你在历史以来的常用语' : `你在${year}年的年度常用语`
return (
<div className="annual-report-window">
@@ -827,7 +721,7 @@ function AnnualReportWindow() {
{/* 封面 */}
<section className="section" ref={sectionRefs.cover}>
<div className="label-text">WEFLOW · ANNUAL REPORT</div>
<h1 className="hero-title">{year}<br /></h1>
<h1 className="hero-title">{yearTitle}<br /></h1>
<hr className="divider" />
<p className="hero-desc"><br /></p>
</section>
@@ -869,7 +763,7 @@ function AnnualReportWindow() {
{/* 月度好友 */}
<section className="section" ref={sectionRefs.monthlyFriends}>
<div className="label-text"></div>
<h2 className="hero-title">{year}</h2>
<h2 className="hero-title">{monthlyTitle}</h2>
<p className="hero-desc">12</p>
<div className="monthly-orbit">
{monthlyTopFriends.map((m, i) => (
@@ -883,7 +777,7 @@ function AnnualReportWindow() {
<Avatar url={selfAvatarUrl} name="我" size="lg" />
</div>
</div>
<p className="hero-desc"><br /></p>
<p className="hero-desc"><br /></p>
</section>
{/* 双向奔赴 */}
@@ -983,15 +877,15 @@ function AnnualReportWindow() {
{midnightKing && (
<section className="section" ref={sectionRefs.midnightKing}>
<div className="label-text"></div>
<h2 className="hero-title"></h2>
<p className="hero-desc"></p>
<h2 className="hero-title"></h2>
<p className="hero-desc"></p>
<div className="big-stat">
<span className="stat-num">{midnightKing.count}</span>
<span className="stat-unit"></span>
</div>
<p className="hero-desc">
<span className="hl">{midnightKing.displayName}</span>
<br />Ta的对话占深夜期间聊天的 <span className="gold">{midnightKing.percentage}%</span>
<span className="hl">{midnightKing.displayName}</span>
<br />Ta的对话占深夜期间聊天的 <span className="gold">{midnightKing.percentage}%</span>
</p>
</section>
)}
@@ -1012,11 +906,46 @@ function AnnualReportWindow() {
</section>
)}
{/* 曾经的好朋友 */}
{lostFriend && (
<section className="section" ref={sectionRefs.lostFriend}>
<div className="label-text"></div>
<h2 className="hero-title">{lostFriend.displayName}</h2>
<div className="big-stat">
<span className="stat-num">{formatNumber(lostFriend.earlyCount)}</span>
<span className="stat-unit"></span>
</div>
<p className="hero-desc">
<span className="hl">{lostFriend.periodDesc}</span>
<br />
</p>
<div className="lost-friend-visual">
<div className="avatar-group sender">
<Avatar url={lostFriend.avatarUrl} name={lostFriend.displayName} size="lg" />
<span className="avatar-label">TA</span>
</div>
<div className="fading-line">
<div className="line-path" />
<div className="line-glow" />
<div className="flow-particle" />
</div>
<div className="avatar-group receiver">
<Avatar url={selfAvatarUrl} name="我" size="lg" />
<span className="avatar-label"></span>
</div>
</div>
<p className="hero-desc fading">
<br />
</p>
</section>
)}
{/* 年度常用语 - 词云 */}
{topPhrases && topPhrases.length > 0 && (
<section className="section" ref={sectionRefs.topPhrases}>
<div className="label-text"></div>
<h2 className="hero-title">{year}</h2>
<h2 className="hero-title">{phrasesTitle}</h2>
<p className="hero-desc">
<br />
@@ -1029,6 +958,57 @@ function AnnualReportWindow() {
</section>
)}
{/* 朋友圈 */}
{reportData.snsStats && reportData.snsStats.totalPosts > 0 && (
<section className="section" ref={sectionRefs.sns}>
<div className="label-text"></div>
<h2 className="hero-title"></h2>
<p className="hero-desc">
</p>
<div className="big-stat">
<span className="stat-num">{reportData.snsStats.totalPosts}</span>
<span className="stat-unit"></span>
</div>
<div className="sns-stats-container" style={{ display: 'flex', gap: '60px', marginTop: '40px', justifyContent: 'center' }}>
{reportData.snsStats.topLikers.length > 0 && (
<div className="sns-sub-stat" style={{ textAlign: 'left' }}>
<h3 className="sub-title" style={{ fontSize: '18px', marginBottom: '16px', opacity: 0.8, borderBottom: '1px solid currentColor', paddingBottom: '8px' }}>Ta</h3>
<div className="mini-ranking">
{reportData.snsStats.topLikers.slice(0, 3).map((u, i) => (
<div key={i} className="mini-rank-item" style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '14px' }}>
<Avatar url={u.avatarUrl} name={u.displayName} size="sm" />
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span className="name" style={{ fontSize: '15px', fontWeight: 500, maxWidth: '120px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{u.displayName}</span>
</div>
<span className="count hl" style={{ fontSize: '14px', marginLeft: 'auto' }}>{u.count}</span>
</div>
))}
</div>
</div>
)}
{reportData.snsStats.topLiked.length > 0 && (
<div className="sns-sub-stat" style={{ textAlign: 'left' }}>
<h3 className="sub-title" style={{ fontSize: '18px', marginBottom: '16px', opacity: 0.8, borderBottom: '1px solid currentColor', paddingBottom: '8px' }}>Ta</h3>
<div className="mini-ranking">
{reportData.snsStats.topLiked.slice(0, 3).map((u, i) => (
<div key={i} className="mini-rank-item" style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '14px' }}>
<Avatar url={u.avatarUrl} name={u.displayName} size="sm" />
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span className="name" style={{ fontSize: '15px', fontWeight: 500, maxWidth: '120px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{u.displayName}</span>
</div>
<span className="count hl" style={{ fontSize: '14px', marginLeft: 'auto' }}>{u.count}</span>
</div>
))}
</div>
</div>
)}
</div>
</section>
)}
{/* 好友排行 */}
<section className="section" ref={sectionRefs.ranking}>
<div className="label-text"></div>
@@ -1085,7 +1065,7 @@ function AnnualReportWindow() {
<br />
<br />
</p>
<div className="ending-year">{year}</div>
<div className="ending-year">{yearTitleShort}</div>
<div className="ending-brand">WEFLOW</div>
</section>
</div>

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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