Compare commits

..

385 Commits

Author SHA1 Message Date
cc
61cc3e6f58 Merge pull request #376 from hicccc77/dev
Dev
2026-03-05 23:08:27 +08:00
cc
a3ab06509e 一些更新 2026-03-05 20:46:18 +08:00
xuncha
bcdb983b98 Merge pull request #367 from xunchahaha:dev
修复dev关闭还有进程
2026-03-05 14:43:25 +08:00
xuncha
7836c611b7 修复dev关闭还有进程 2026-03-05 14:43:03 +08:00
xuncha
2797d571e4 Merge pull request #366 from hicccc77/dev
Dev
2026-03-05 14:32:36 +08:00
xuncha
389fd0b1b0 Merge pull request #365 from xunchahaha:dev
Dev
2026-03-05 14:31:47 +08:00
xuncha
25630da1ce 图片解密++ 2026-03-05 14:31:15 +08:00
xuncha
ca972d3e28 导出页优化 2026-03-05 14:26:37 +08:00
xuncha
80420302c1 Merge branch 'hicccc77:dev' into dev 2026-03-05 14:03:06 +08:00
xuncha
9759d5f64f Merge pull request #362 from aits2026/codex/ts0301-01-export-opt
导出能力重构 + 聊天/SNS/年报协同优化
2026-03-05 14:01:55 +08:00
tisonhuang
17a9b6102e merge: resolve upstream/dev conflicts in export workflow branch 2026-03-05 13:55:42 +08:00
tisonhuang
7e7503035a refactor(export): remove scroll back-to-top affordance 2026-03-05 13:31:13 +08:00
tisonhuang
02a6b24517 refactor(export): remove redundant header select-all text action 2026-03-05 12:49:13 +08:00
tisonhuang
b3fee5b56d fix(export): show loading text for pending session message counts 2026-03-05 12:47:28 +08:00
tisonhuang
26d38acddb fix(export): increase default session list viewport to 10 rows 2026-03-05 12:34:23 +08:00
tisonhuang
8a30e9b663 refactor(export): merge bulk selection actions into header row 2026-03-05 12:31:29 +08:00
tisonhuang
46a2d04528 fix(export): hand off wheel scroll between page and session list 2026-03-05 12:25:11 +08:00
tisonhuang
6a85b82643 fix(export): restore virtualized contacts list and sticky controls 2026-03-05 12:18:28 +08:00
tisonhuang
b436bb63da feat(export): refine time range dialog mode switching 2026-03-05 12:10:07 +08:00
tisonhuang
b5cb4051ab feat(export): add yearly time range presets 2026-03-05 11:40:32 +08:00
tisonhuang
01f774db54 feat(export): revamp time range dialog with dual calendars 2026-03-05 11:36:56 +08:00
tisonhuang
c5a6d765ee fix(auth): avoid logout on export-only clear and harden db key auto-fetch 2026-03-05 11:15:44 +08:00
tisonhuang
459f23bbd6 feat(sidebar): add account data clear action and detail feedback 2026-03-05 10:57:15 +08:00
tisonhuang
360754737f feat(export): redesign time range selector with nested dialog 2026-03-05 10:48:21 +08:00
tisonhuang
36f1476782 feat(export): add session name prefix toggle in layout dropdown 2026-03-05 10:36:29 +08:00
tisonhuang
ecae83f659 docs(export): add session-coverage note in type export tooltip 2026-03-05 10:29:05 +08:00
tisonhuang
fbe5109ed9 refactor(export): simplify session export title and toolbar text 2026-03-05 10:26:09 +08:00
tisonhuang
4adedad0de fix(export): hide cache meta row and adjust info popover anchor 2026-03-05 10:24:01 +08:00
tisonhuang
28257ba66f style(export): place section info icons next to titles 2026-03-05 10:19:32 +08:00
tisonhuang
3062295069 feat(export): show selected count on batch export button 2026-03-05 10:16:51 +08:00
tisonhuang
3c231a7fde feat(export): add batch export section titles with info popovers 2026-03-05 10:13:58 +08:00
tisonhuang
0247b02f6e fix(sidebar): normalize self wxid and resolve real nickname 2026-03-05 09:46:25 +08:00
tisonhuang
8aaad71784 refactor(sns): remove contact post-count stats flow 2026-03-05 09:34:57 +08:00
tisonhuang
e795474917 fix(export): persist write layout across page switches 2026-03-05 09:20:52 +08:00
tisonhuang
49f99f57c9 fix(chat): render date popover in top portal for stable layering 2026-03-05 09:15:23 +08:00
cc
53398707aa 修复了导出页面一些小问题 2026-03-04 22:46:31 +08:00
xuncha
1d8a7d2e63 新增纯黑白样式 2026-03-04 21:26:20 +08:00
tisonhuang
313e2bc080 feat(export): add multi-select contacts list for batch export 2026-03-04 21:19:11 +08:00
tisonhuang
0037935280 fix(export): force json format and B write layout defaults 2026-03-04 21:19:11 +08:00
tisonhuang
7858b40ce4 feat(export): hide display-name section for selected batch dialogs 2026-03-04 21:19:11 +08:00
tisonhuang
ab6db27ea7 fix(export): show completed sessions progress in task card 2026-03-04 21:19:11 +08:00
tisonhuang
4568795081 perf(export): optimize task center modal responsiveness 2026-03-04 21:19:11 +08:00
tisonhuang
43643d1a83 feat(export): simplify export panel and page-scroll contacts list 2026-03-04 21:19:11 +08:00
tisonhuang
28e7de6ceb fix(chat): portalize standalone jump calendar to avoid translucent compositing 2026-03-04 21:19:11 +08:00
tisonhuang
c204855a71 fix(chat): hide export/transcribe/decrypt actions in standalone chat 2026-03-04 21:19:11 +08:00
tisonhuang
dab33c4e60 fix(chat): force opaque jump calendar in standalone window 2026-03-04 21:19:11 +08:00
tisonhuang
47f9c0a502 fix(chat): keep cross-day browsing after date jump 2026-03-04 21:19:11 +08:00
tisonhuang
d9a6fd2a42 style(chat): make jump calendar popover background fully opaque 2026-03-04 21:19:11 +08:00
tisonhuang
dcb91905ad style(chat): refine jump calendar date/count typography 2026-03-04 21:19:11 +08:00
tisonhuang
b6fd842d4e feat(export): add persistent session export records in detail panel 2026-03-04 21:19:11 +08:00
tisonhuang
4b57e3e350 feat(chat): replace jump date modal with inline calendar popover 2026-03-04 21:19:11 +08:00
tisonhuang
1652ebc4ad fix(chat): show group member count loading and failed states 2026-03-04 21:19:11 +08:00
tisonhuang
924ff1b6fc feat(export): narrow chat window and refine progress settle 2026-03-04 21:19:11 +08:00
tisonhuang
926ca72331 feat(export): add open-chat window from session list 2026-03-04 21:19:11 +08:00
tisonhuang
cf7190aaec refactor(export): remove task pause/stop and prioritize export by loaded message counts 2026-03-04 21:19:11 +08:00
tisonhuang
54d6cded53 perf(chat): restore session window from cache on switch back 2026-03-04 21:19:11 +08:00
tisonhuang
7a7e54ea5b perf(export): reuse pre-estimate cache during export run 2026-03-04 21:19:11 +08:00
tisonhuang
7b4aa23f35 perf(chat): speed up session switch and stabilize message cursor 2026-03-04 21:19:11 +08:00
tisonhuang
ac4482bc8b perf(export): reuse aggregated session stats for pre-export estimate 2026-03-04 21:19:11 +08:00
tisonhuang
0a7f2b15f1 fix(export): keep only total message count in session list 2026-03-04 21:19:11 +08:00
tisonhuang
95e0b83537 fix(export): recover total-count sorting after cache hydrate 2026-03-04 21:19:11 +08:00
tisonhuang
bb602af750 fix(stats): ensure accurate transfer red-packet and call counts in detail panels 2026-03-04 21:19:11 +08:00
tisonhuang
580242b9d2 perf(export): persist session list stats across app restarts 2026-03-04 21:19:11 +08:00
tisonhuang
2cc1b55cbf feat(stats): add transfer red-packet and call message counts in session details 2026-03-04 21:19:11 +08:00
tisonhuang
e1944783d0 feat(report): reuse years loading across page switches 2026-03-04 21:19:11 +08:00
tisonhuang
423d760f36 perf(export): order media stats by total message rank 2026-03-04 21:19:11 +08:00
tisonhuang
16e237b698 feat(report): improve years loading status messaging 2026-03-04 21:19:11 +08:00
tisonhuang
28d68d8a8e feat(report): stream available years loading 2026-03-04 21:19:11 +08:00
tisonhuang
d476fbbdae perf(export): prioritize visible content stats loading 2026-03-04 21:19:11 +08:00
tisonhuang
64542f2902 fix(export): compute missing stats when stale cache allowed 2026-03-04 21:19:11 +08:00
tisonhuang
56a59a5355 fix(report): speed up available years loading 2026-03-04 21:19:11 +08:00
tisonhuang
285ddeb62e perf(export): reduce reloads when switching back 2026-03-04 21:19:11 +08:00
tisonhuang
84ef51f16b perf(export): reuse list message count in detail 2026-03-04 21:19:11 +08:00
tisonhuang
fb1125136c feat(export): refine top card copy and sns header count 2026-03-04 21:19:11 +08:00
tisonhuang
55f7ff1842 perf(chat): reduce session detail stats latency 2026-03-04 21:19:11 +08:00
tisonhuang
ac1d2210da feat(export): sort session list by total messages 2026-03-04 21:19:11 +08:00
tisonhuang
ff92f355e2 feat: update chat service and simplify export contact rows 2026-03-04 21:19:11 +08:00
tisonhuang
4b8c8155fa perf(export): speed up session message count aggregation 2026-03-04 21:19:11 +08:00
tisonhuang
756a83191d feat(export): add session total message count column with staged loading 2026-03-04 21:19:11 +08:00
tisonhuang
b5eb8be15e perf(export): remove private relation stats and avatar backfill overhead 2026-03-04 21:19:11 +08:00
tisonhuang
38a023d0b6 feat(sns): show my post count in overview stats 2026-03-04 21:19:10 +08:00
tisonhuang
3a878dd019 feat(sns-export): add record owner to arkmejson header 2026-03-04 21:19:10 +08:00
tisonhuang
6314c0f1d6 feat(sns-export): split media export selection into image/live/video 2026-03-04 21:19:10 +08:00
tisonhuang
c5eed25f06 feat(export): add sns arkmejson format and consolidate export flow changes 2026-03-04 21:19:10 +08:00
tisonhuang
e1243522b0 feat(export): enrich arkme json for card/location/music 2026-03-04 21:19:10 +08:00
tisonhuang
d9108ac6ed feat(export): optimize text export and enrich arkme metadata 2026-03-04 21:19:10 +08:00
tisonhuang
302abe3e40 perf(export): return card stats from snapshot and refresh in background 2026-03-04 21:19:10 +08:00
tisonhuang
b6a2191e38 fix(export): prevent card stats poll overlap with frontend/backend singleflight 2026-03-04 21:19:10 +08:00
tisonhuang
84b54e43aa feat(export): add card stats diagnostics panel and log export 2026-03-04 21:19:10 +08:00
tisonhuang
e9971aa6c4 fix(chat): avoid detail auto refresh blocking group members load 2026-03-04 21:19:10 +08:00
tisonhuang
91f630209c feat(export): improve count accuracy and include pending updates 2026-03-04 21:19:10 +08:00
tisonhuang
b6878aefd6 feat(export): fast accurate content session counts on cards 2026-03-04 21:19:10 +08:00
tisonhuang
f0f70def8c fix(chat): restore group member friend badge fallback 2026-03-04 21:19:10 +08:00
tisonhuang
81bc5aefff fix(export): improve batch text progress and precheck interruptibility 2026-03-04 21:19:10 +08:00
tisonhuang
698d2c96d7 fix(chat): avoid group members sidebar stuck on first init 2026-03-04 21:19:10 +08:00
tisonhuang
ce683a539d fix(export): improve progress visibility and hard-stop control 2026-03-04 21:19:10 +08:00
tisonhuang
ac481c6b18 feat(export): optimize batch export flow and unify session detail typing 2026-03-04 21:19:10 +08:00
tisonhuang
750d6ad7eb feat(export): add text batch task performance breakdown 2026-03-04 21:19:10 +08:00
tisonhuang
7bd801cd01 feat(chat): add group members sidebar with owner/friend badges 2026-03-04 21:19:10 +08:00
tisonhuang
5cb364f754 perf(export): speed up batch text export pipeline 2026-03-04 21:19:10 +08:00
tisonhuang
04d1b0c694 feat(export): sync task badge globally and finalize export layout updates 2026-03-04 21:19:10 +08:00
tisonhuang
35028df817 fix(export): enforce english type folders for layout A 2026-03-04 21:19:10 +08:00
tisonhuang
2e8f55d7a8 feat(chat-export): open single export dialog in chat with init feedback 2026-03-04 21:19:10 +08:00
tisonhuang
815a440082 fix(export): place text exports in 聊天文本 dir for layout A 2026-03-04 21:19:10 +08:00
tisonhuang
2afcd528dc fix(export): make task center modal fully opaque 2026-03-04 21:19:10 +08:00
tisonhuang
8d68a59799 feat(export): modal task center with pause/stop controls 2026-03-04 21:19:10 +08:00
tisonhuang
51bc60776d feat(export): prefix text export filenames by session type 2026-03-04 21:19:10 +08:00
tisonhuang
43f4c966f9 feat(export): show running state on content and sns cards 2026-03-04 21:19:10 +08:00
tisonhuang
98a0233c4d feat(export): tailor content batch dialog and widen layout menu 2026-03-04 21:19:10 +08:00
tisonhuang
0545be3244 style(export): tighten write-layout trigger width 2026-03-04 21:19:10 +08:00
tisonhuang
4a67b22d8d feat(sns): progressively prune zero-post contacts 2026-03-04 21:19:10 +08:00
tisonhuang
5840bf710c style(export): keep top controls horizontal on narrow widths 2026-03-04 21:19:10 +08:00
tisonhuang
1b8e1c2aab fix(sns): make covered-user query resilient 2026-03-04 21:19:10 +08:00
tisonhuang
60aa949cca fix(export): allow page-level vertical scroll on short windows 2026-03-04 21:19:10 +08:00
tisonhuang
5b05b8927c style(export): tighten and auto-fit content cards 2026-03-04 21:19:10 +08:00
tisonhuang
d65d6d2396 fix(sns): add overview stats status and fallback resilience 2026-03-04 21:19:10 +08:00
tisonhuang
086ac8fdc9 feat(export): simplify media selection in detail dialog 2026-03-04 21:19:10 +08:00
tisonhuang
c6c7f128a9 feat(sns): limit right contacts to covered users 2026-03-04 21:19:10 +08:00
tisonhuang
36ec12fd0f fix(export): ignore invalid avatar in session detail fast 2026-03-04 21:19:10 +08:00
tisonhuang
e9fd751578 feat(export): show session avatar in detail header 2026-03-04 21:19:10 +08:00
tisonhuang
21a97b8871 feat(sns): cache page data and show count loading state 2026-03-04 21:19:10 +08:00
tisonhuang
b8ede4cfd0 fix(export): use solid background for detail drawer 2026-03-04 21:19:10 +08:00
tisonhuang
f47eba5764 fix(export): avoid overlap with window close controls 2026-03-04 21:19:10 +08:00
tisonhuang
1347136b54 feat(export): use window-level detail drawer overlay 2026-03-04 21:19:10 +08:00
tisonhuang
89f0758fbb fix(sns): keep header area always visible 2026-03-04 21:19:10 +08:00
tisonhuang
b5507b9f5d feat(export): add session detail sidebar entry 2026-03-04 21:19:10 +08:00
tisonhuang
204baa52ab feat(sns): show per-contact post counts in filter panel 2026-03-04 21:19:10 +08:00
tisonhuang
bc739dc4a0 style(sns): keep header and actions sticky 2026-03-04 21:19:10 +08:00
tisonhuang
64616b9136 feat(sns): add header overview stats line 2026-03-04 21:19:10 +08:00
tisonhuang
983783ea95 feat(export): add per-contact single export action button 2026-03-04 21:19:10 +08:00
tisonhuang
1414a4a9cf fix(export): style mirrored contacts list in export panel 2026-03-04 21:19:10 +08:00
tisonhuang
af7639aa73 feat(export): optimize dialog defaults and option cards 2026-03-04 21:19:10 +08:00
tisonhuang
dabc6a2d0a fix(export): align avatar loading pipeline with contacts 2026-03-04 21:19:10 +08:00
tisonhuang
d1ef159e87 fix(export): stabilize contact cache fallback and batched avatar enrich 2026-03-04 21:19:10 +08:00
tisonhuang
cc5c323ccb fix(export): fallback contacts cache scope and hydrate list from cache first 2026-03-04 21:19:10 +08:00
tisonhuang
d18a871429 fix(export): restore dialog scroll and adaptive format grid 2026-03-04 21:19:10 +08:00
tisonhuang
0a1f55f6a6 feat(export): reuse contacts cache for session names and avatars 2026-03-04 21:19:10 +08:00
tisonhuang
faeda030e9 feat(contacts): persist avatar cache with incremental refresh 2026-03-04 21:19:10 +08:00
tisonhuang
b3700c3a4c refactor(export): remove session stats columns and background counting 2026-03-04 21:19:10 +08:00
tisonhuang
01a221831f feat(export): move task center into top control row 2026-03-04 21:19:10 +08:00
tisonhuang
9cb41e01e2 fix(contacts): persist list cache and add load timeout diagnostics 2026-03-04 21:19:10 +08:00
tisonhuang
abdb4f62de fix(export): pause hidden export background loading to unblock contacts 2026-03-04 21:19:10 +08:00
tisonhuang
da7d354436 feat(counts): unify contacts and export tab counters 2026-03-04 21:19:10 +08:00
tisonhuang
794a306f89 perf(contacts): speed up directory loading and smooth list rendering 2026-03-04 21:19:10 +08:00
tisonhuang
ac61ee1833 perf(chat): add local session list and preview cache hydration 2026-03-04 21:19:10 +08:00
tisonhuang
a87d419868 fix(chat): collapse detail panel when switching sessions 2026-03-04 21:19:10 +08:00
tisonhuang
abbb7a0cb1 feat(chat): show export-table metrics in session detail sidebar 2026-03-04 21:19:10 +08:00
tisonhuang
a5ae22d2a5 perf(chat): split session detail into fast and extra loading 2026-03-04 21:19:10 +08:00
tisonhuang
22b6a07749 feat(chat): smooth loading with progressive session hydration 2026-03-04 21:19:10 +08:00
tisonhuang
dbdb2e2959 perf(export): prioritize active tab counts and avoid full-list warmup 2026-03-04 21:16:40 +08:00
tisonhuang
5147b3f0e4 perf(export): batch message count retrieval for large session lists 2026-03-04 21:16:40 +08:00
tisonhuang
a8eb0057e3 perf(export): keep page alive across route switches 2026-03-04 21:16:40 +08:00
tisonhuang
7604ff2ae4 perf(export): cache counts and speed sns/session stats 2026-03-04 21:16:40 +08:00
tisonhuang
bf9b5ba593 perf(export): prioritize totals and keep table visible 2026-03-04 21:16:40 +08:00
tisonhuang
d12c111684 perf(export): virtualize session table and prioritize metrics loading 2026-03-04 21:16:39 +08:00
tisonhuang
dffd3c9138 fix(export): batch session stats and avoid stale empty cache 2026-03-04 21:16:39 +08:00
tisonhuang
c34f7af6de chore(export): shorten card exported labels 2026-03-04 21:16:39 +08:00
tisonhuang
22c7048ef6 fix(sidebar): prefer valid nickname over wxid 2026-03-04 21:16:39 +08:00
tisonhuang
96aa9d0813 feat(export): adjust path actions and compact sns card 2026-03-04 21:16:39 +08:00
tisonhuang
d99c0ff8b2 perf(export): make write-layout dropdown instant 2026-03-04 21:16:39 +08:00
tisonhuang
c6e8bde078 feat(export): prioritize tab counts via lightweight api 2026-03-04 21:16:39 +08:00
tisonhuang
adff7b9e1e feat(export): refine task center and loading interactions 2026-03-04 21:16:39 +08:00
tisonhuang
b62c18fd84 perf(export): phase-load sessions and add strong skeleton states 2026-03-04 21:16:39 +08:00
tisonhuang
de7cbdf494 perf(sidebar): show cached user profile before async refresh 2026-03-04 21:16:39 +08:00
tisonhuang
0444ca143e fix(export): correct profile name, sns stats, avatars and sorting 2026-03-04 21:16:39 +08:00
tisonhuang
596baad296 feat(export): add sns stats card and conversation tab updates 2026-03-04 21:16:39 +08:00
tisonhuang
e686bb6247 feat(export): add batch session stats api for export board 2026-03-04 21:16:39 +08:00
tisonhuang
06d6f15e38 feat(export): redesign export board workflow 2026-03-04 21:16:39 +08:00
xuncha
d3adae42fe Merge pull request #352 from xunchahaha:main
同步ui
2026-03-02 22:23:33 +08:00
xuncha
39b38119c1 同步ui 2026-03-02 22:22:56 +08:00
xuncha
eace3e9467 查看消息内容 2026-03-02 21:36:44 +08:00
cc
366da8d38e 修复内存扫描问题 2026-03-02 20:30:38 +08:00
xuncha
a965890916 Merge pull request #349 from hicccc77/dev
Dev
2026-03-02 16:45:32 +08:00
xuncha
b07bbd68d7 Merge pull request #348 from xunchahaha:main
Main
2026-03-02 16:45:12 +08:00
xuncha
3d4a79aac6 修复图片解密 修复密钥获取 2026-03-02 16:44:09 +08:00
cc
e30c4cc644 Merge pull request #341 from hicccc77/dev
更新文档
2026-02-28 23:22:15 +08:00
cc
d317be3ad3 更新文档 2026-02-28 23:21:27 +08:00
cc
1b078bd2fd Merge pull request #340 from hicccc77/dev
Dev
2026-02-28 23:17:43 +08:00
cc
1d84ed1614 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-02-28 23:17:16 +08:00
cc
114476d74c 更新文档 2026-02-28 23:16:42 +08:00
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
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
113 changed files with 51526 additions and 7054 deletions

View File

@@ -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

10
.gitignore vendored
View File

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

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>
@@ -32,27 +33,52 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
> [!NOTE]
> 仅支持微信 **4.0 及以上**版本,确保你的微信版本符合要求
# 加入微信交流群
> 🎉 扫码加入微信群,与其他 WeFlow 用户一起交流问题和使用心得。
<p align="center">
<img src="mdassets/us.png" alt="WeFlow 微信交流群二维码" width="220" style="margin-right: 16px;"
</p>
## 主要功能
- 本地实时查看聊天记录
- 朋友圈图片、视频、**实况**的预览和解密
- 统计分析与群聊画像
- 年度报告与可视化概览
- 导出聊天记录为 HTML 等格式
- 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)
## 面向开发者
如果你想从源码构建或为项目贡献代码,请遵循以下步骤:
@@ -79,6 +105,7 @@ npm run build
## 致谢
- [密语 CipherTalk](https://github.com/ILoveBingLu/miyu) 为本项目提供了基础框架
- [WeChat-Channels-Video-File-Decryption](https://github.com/Evil0ctal/WeChat-Channels-Video-File-Decryption) 提供了视频解密相关的技术参考
## 支持我们

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

@@ -11,6 +11,7 @@ interface WorkerConfig {
resourcesPath?: string
userDataPath?: string
logEnabled?: boolean
excludeWords?: string[]
}
const config = workerData as WorkerConfig
@@ -29,6 +30,7 @@ async function run() {
dbPath: config.dbPath,
decryptKey: config.decryptKey,
wxid: config.myWxid,
excludeWords: config.excludeWords,
onProgress: (status: string, progress: number) => {
parentPort?.postMessage({
type: 'dualReport:progress',

File diff suppressed because it is too large Load Diff

View File

@@ -24,7 +24,15 @@ contextBridge.exposeInMainWorld('electronAPI', {
// 认证
auth: {
hello: (message?: string) => ipcRenderer.invoke('auth:hello', message)
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')
},
@@ -65,6 +73,15 @@ contextBridge.exposeInMainWorld('electronAPI', {
debug: (data: any) => ipcRenderer.send('log:debug', data)
},
diagnostics: {
getExportCardLogs: (options?: { limit?: number }) =>
ipcRenderer.invoke('diagnostics:getExportCardLogs', options),
clearExportCardLogs: () =>
ipcRenderer.invoke('diagnostics:clearExportCardLogs'),
exportExportCardLogs: (payload: { filePath: string; frontendLogs?: unknown[] }) =>
ipcRenderer.invoke('diagnostics:exportExportCardLogs', payload)
},
// 窗口控制
window: {
minimize: () => ipcRenderer.send('window:minimize'),
@@ -78,10 +95,12 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke('window:openVideoPlayerWindow', videoPath, videoWidth, videoHeight),
resizeToFitVideo: (videoWidth: number, videoHeight: number) =>
ipcRenderer.invoke('window:resizeToFitVideo', videoWidth, videoHeight),
openImageViewerWindow: (imagePath: string) =>
ipcRenderer.invoke('window:openImageViewerWindow', imagePath),
openImageViewerWindow: (imagePath: string, liveVideoPath?: string) =>
ipcRenderer.invoke('window:openImageViewerWindow', imagePath, liveVideoPath),
openChatHistoryWindow: (sessionId: string, messageId: number) =>
ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId)
ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId),
openSessionChatWindow: (sessionId: string) =>
ipcRenderer.invoke('window:openSessionChatWindow', sessionId)
},
// 数据库路径
@@ -105,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')
@@ -121,8 +141,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
chat: {
connect: () => ipcRenderer.invoke('chat:connect'),
getSessions: () => ipcRenderer.invoke('chat:getSessions'),
enrichSessionsContactInfo: (usernames: string[]) =>
ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames),
getSessionStatuses: (usernames: string[]) => ipcRenderer.invoke('chat:getSessionStatuses', usernames),
getExportTabCounts: () => ipcRenderer.invoke('chat:getExportTabCounts'),
getContactTypeCounts: () => ipcRenderer.invoke('chat:getContactTypeCounts'),
getSessionMessageCounts: (sessionIds: string[]) => ipcRenderer.invoke('chat:getSessionMessageCounts', sessionIds),
enrichSessionsContactInfo: (
usernames: string[],
options?: { skipDisplayName?: boolean; onlyMissingAvatar?: boolean }
) => ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames, options),
getMessages: (sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) =>
ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit, startTime, endTime, ascending),
getLatestMessages: (sessionId: string, limit?: number) =>
@@ -131,14 +157,34 @@ contextBridge.exposeInMainWorld('electronAPI', {
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) => {
@@ -186,7 +232,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
// 数据分析
analytics: {
getOverallStatistics: (force?: boolean) => ipcRenderer.invoke('analytics:getOverallStatistics', force),
getContactRankings: (limit?: number) => ipcRenderer.invoke('analytics:getContactRankings', limit),
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),
@@ -208,18 +255,44 @@ 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),
exportGroupMembers: (chatroomId: string, outputPath: string) => ipcRenderer.invoke('groupAnalytics:exportGroupMembers', chatroomId, outputPath)
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')
@@ -236,13 +309,15 @@ contextBridge.exposeInMainWorld('electronAPI', {
// 导出
export: {
getExportStats: (sessionIds: string[], options: any) =>
ipcRenderer.invoke('export:getExportStats', sessionIds, options),
exportSessions: (sessionIds: string[], outputDir: string, options: any) =>
ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options),
exportSession: (sessionId: string, outputPath: string, options: any) =>
ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options),
exportContacts: (outputDir: string, options: any) =>
ipcRenderer.invoke('export:exportContacts', outputDir, options),
onProgress: (callback: (payload: { current: number; total: number; currentSession: string; phase: string }) => void) => {
onProgress: (callback: (payload: { current: number; total: number; currentSession: string; currentSessionId?: string; phase: string }) => void) => {
ipcRenderer.on('export:progress', (_, payload) => callback(payload))
return () => ipcRenderer.removeAllListeners('export:progress')
}
@@ -263,7 +338,37 @@ 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),
getSnsUsernames: () => ipcRenderer.invoke('sns:getSnsUsernames'),
getExportStatsFast: () => ipcRenderer.invoke('sns:getExportStatsFast'),
getExportStats: () => ipcRenderer.invoke('sns:getExportStats'),
debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url),
proxyImage: (url: string) => ipcRenderer.invoke('sns:proxyImage', 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

@@ -31,6 +31,7 @@ export interface ContactRanking {
username: string
displayName: string
avatarUrl?: string
wechatId?: string
messageCount: number
sentCount: number
receivedCount: number
@@ -75,16 +76,12 @@ class AnalyticsService {
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(',')
if (!inList) continue
const sql = `
SELECT username, alias
FROM contact
WHERE username IN (${inList})
`
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>[]) {
@@ -576,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 }
@@ -586,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 || '聚合统计失败' }
}
@@ -594,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
@@ -608,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

@@ -85,7 +85,34 @@ export interface AnnualReportData {
} | null
}
export interface AvailableYearsLoadProgress {
years: number[]
strategy: 'cache' | 'native' | 'hybrid'
phase: 'cache' | 'native' | 'scan'
statusText: string
nativeElapsedMs: number
scanElapsedMs: number
totalElapsedMs: number
switched?: boolean
nativeTimedOut?: boolean
}
interface AvailableYearsLoadMeta {
strategy: 'cache' | 'native' | 'hybrid'
nativeElapsedMs: number
scanElapsedMs: number
totalElapsedMs: number
switched: boolean
nativeTimedOut: boolean
statusText: string
}
class AnnualReportService {
private readonly availableYearsCacheTtlMs = 10 * 60 * 1000
private readonly availableYearsScanConcurrency = 4
private readonly availableYearsColumnCache = new Map<string, string>()
private readonly availableYearsCache = new Map<string, { years: number[]; updatedAt: number }>()
constructor() {
}
@@ -116,7 +143,7 @@ class AnnualReportService {
}
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
const cleaned = suffixMatch ? suffixMatch[1] : trimmed
return cleaned
}
@@ -181,6 +208,234 @@ class AnnualReportService {
}
}
private quoteSqlIdentifier(identifier: string): string {
return `"${String(identifier || '').replace(/"/g, '""')}"`
}
private toUnixTimestamp(value: any): number {
const n = Number(value)
if (!Number.isFinite(n) || n <= 0) return 0
// 兼容毫秒级时间戳
const seconds = n > 1e12 ? Math.floor(n / 1000) : Math.floor(n)
return seconds > 0 ? seconds : 0
}
private addYearsFromRange(years: Set<number>, firstTs: number, lastTs: number): boolean {
let changed = false
const currentYear = new Date().getFullYear()
const minTs = firstTs > 0 ? firstTs : lastTs
const maxTs = lastTs > 0 ? lastTs : firstTs
if (minTs <= 0 || maxTs <= 0) return changed
const minYear = new Date(minTs * 1000).getFullYear()
const maxYear = new Date(maxTs * 1000).getFullYear()
for (let y = minYear; y <= maxYear; y++) {
if (y >= 2010 && y <= currentYear && !years.has(y)) {
years.add(y)
changed = true
}
}
return changed
}
private normalizeAvailableYears(years: Iterable<number>): number[] {
return Array.from(new Set(Array.from(years)))
.filter((y) => Number.isFinite(y))
.map((y) => Math.floor(y))
.sort((a, b) => b - a)
}
private async forEachWithConcurrency<T>(
items: T[],
concurrency: number,
handler: (item: T, index: number) => Promise<void>,
shouldStop?: () => boolean
): Promise<void> {
if (!items.length) return
const workerCount = Math.max(1, Math.min(concurrency, items.length))
let nextIndex = 0
const workers: Promise<void>[] = []
for (let i = 0; i < workerCount; i++) {
workers.push((async () => {
while (true) {
if (shouldStop?.()) break
const current = nextIndex
nextIndex += 1
if (current >= items.length) break
await handler(items[current], current)
}
})())
}
await Promise.all(workers)
}
private async detectTimeColumn(dbPath: string, tableName: string): Promise<string | null> {
const cacheKey = `${dbPath}\u0001${tableName}`
if (this.availableYearsColumnCache.has(cacheKey)) {
const cached = this.availableYearsColumnCache.get(cacheKey) || ''
return cached || null
}
const result = await wcdbService.execQuery('message', dbPath, `PRAGMA table_info(${this.quoteSqlIdentifier(tableName)})`)
if (!result.success || !Array.isArray(result.rows) || result.rows.length === 0) {
this.availableYearsColumnCache.set(cacheKey, '')
return null
}
const candidates = ['create_time', 'createtime', 'msg_create_time', 'msg_time', 'msgtime', 'time']
const columns = new Set<string>()
for (const row of result.rows as Record<string, any>[]) {
const name = String(row.name || row.column_name || row.columnName || '').trim().toLowerCase()
if (name) columns.add(name)
}
for (const candidate of candidates) {
if (columns.has(candidate)) {
this.availableYearsColumnCache.set(cacheKey, candidate)
return candidate
}
}
this.availableYearsColumnCache.set(cacheKey, '')
return null
}
private async getTableTimeRange(dbPath: string, tableName: string): Promise<{ first: number; last: number } | null> {
const cacheKey = `${dbPath}\u0001${tableName}`
const cachedColumn = this.availableYearsColumnCache.get(cacheKey)
const initialColumn = cachedColumn && cachedColumn.length > 0 ? cachedColumn : 'create_time'
const tried = new Set<string>()
const queryByColumn = async (column: string): Promise<{ first: number; last: number } | null> => {
const sql = `SELECT MIN(${this.quoteSqlIdentifier(column)}) AS first_ts, MAX(${this.quoteSqlIdentifier(column)}) AS last_ts FROM ${this.quoteSqlIdentifier(tableName)}`
const result = await wcdbService.execQuery('message', dbPath, sql)
if (!result.success || !Array.isArray(result.rows) || result.rows.length === 0) return null
const row = result.rows[0] as Record<string, any>
const first = this.toUnixTimestamp(row.first_ts ?? row.firstTs ?? row.min_ts ?? row.minTs)
const last = this.toUnixTimestamp(row.last_ts ?? row.lastTs ?? row.max_ts ?? row.maxTs)
return { first, last }
}
tried.add(initialColumn)
const quick = await queryByColumn(initialColumn)
if (quick) {
if (!cachedColumn) this.availableYearsColumnCache.set(cacheKey, initialColumn)
return quick
}
const detectedColumn = await this.detectTimeColumn(dbPath, tableName)
if (!detectedColumn || tried.has(detectedColumn)) {
return null
}
return queryByColumn(detectedColumn)
}
private async getAvailableYearsByTableScan(
sessionIds: string[],
options?: { onProgress?: (years: number[]) => void; shouldCancel?: () => boolean }
): Promise<number[]> {
const years = new Set<number>()
let lastEmittedSize = 0
const emitIfChanged = (force = false) => {
if (!options?.onProgress) return
const next = this.normalizeAvailableYears(years)
if (!force && next.length === lastEmittedSize) return
options.onProgress(next)
lastEmittedSize = next.length
}
const shouldCancel = () => options?.shouldCancel?.() === true
await this.forEachWithConcurrency(sessionIds, this.availableYearsScanConcurrency, async (sessionId) => {
if (shouldCancel()) return
const tableStats = await wcdbService.getMessageTableStats(sessionId)
if (!tableStats.success || !Array.isArray(tableStats.tables) || tableStats.tables.length === 0) {
return
}
for (const table of tableStats.tables as Record<string, any>[]) {
if (shouldCancel()) return
const tableName = String(table.table_name || table.name || '').trim()
const dbPath = String(table.db_path || table.dbPath || '').trim()
if (!tableName || !dbPath) continue
const range = await this.getTableTimeRange(dbPath, tableName)
if (!range) continue
const changed = this.addYearsFromRange(years, range.first, range.last)
if (changed) emitIfChanged()
}
}, shouldCancel)
emitIfChanged(true)
return this.normalizeAvailableYears(years)
}
private async getAvailableYearsByEdgeScan(
sessionIds: string[],
options?: { onProgress?: (years: number[]) => void; shouldCancel?: () => boolean }
): Promise<number[]> {
const years = new Set<number>()
let lastEmittedSize = 0
const shouldCancel = () => options?.shouldCancel?.() === true
const emitIfChanged = (force = false) => {
if (!options?.onProgress) return
const next = this.normalizeAvailableYears(years)
if (!force && next.length === lastEmittedSize) return
options.onProgress(next)
lastEmittedSize = next.length
}
for (const sessionId of sessionIds) {
if (shouldCancel()) break
const first = await this.getEdgeMessageTime(sessionId, true)
const last = await this.getEdgeMessageTime(sessionId, false)
const changed = this.addYearsFromRange(years, first || 0, last || 0)
if (changed) emitIfChanged()
}
emitIfChanged(true)
return this.normalizeAvailableYears(years)
}
private buildAvailableYearsCacheKey(dbPath: string, cleanedWxid: string): string {
return `${dbPath}\u0001${cleanedWxid}`
}
private getCachedAvailableYears(cacheKey: string): number[] | null {
const cached = this.availableYearsCache.get(cacheKey)
if (!cached) return null
if (Date.now() - cached.updatedAt > this.availableYearsCacheTtlMs) {
this.availableYearsCache.delete(cacheKey)
return null
}
return [...cached.years]
}
private setCachedAvailableYears(cacheKey: string, years: number[]): void {
const normalized = this.normalizeAvailableYears(years)
this.availableYearsCache.set(cacheKey, {
years: normalized,
updatedAt: Date.now()
})
if (this.availableYearsCache.size > 8) {
let oldestKey = ''
let oldestTime = Number.POSITIVE_INFINITY
for (const [key, val] of this.availableYearsCache) {
if (val.updatedAt < oldestTime) {
oldestTime = val.updatedAt
oldestKey = key
}
}
if (oldestKey) this.availableYearsCache.delete(oldestKey)
}
}
private decodeMessageContent(messageContent: any, compressContent: any): string {
let content = this.decodeMaybeCompressed(compressContent)
if (!content || content.length === 0) {
@@ -193,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)
@@ -355,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: '加载年度数据失败' } }
}
}
@@ -495,7 +942,7 @@ class AnnualReportService {
}
}
this.reportProgress('加载扩展统计... (初始化)', 30, onProgress)
this.reportProgress('加载扩展统计...', 30, onProgress)
const extras = await wcdbService.getAnnualReportExtras(sessionIds, actualStartTime, actualEndTime, peakDayBegin, peakDayEnd)
if (extras.success && extras.data) {
this.reportProgress('加载扩展统计... (解析热力图)', 32, onProgress)

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,10 +1,17 @@
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
@@ -31,8 +38,9 @@ interface ConfigSchema {
// 安全相关
authEnabled: boolean
authPassword: string // SHA-256 hash
authPassword: string // SHA-256 hashsafeStorage 加密)
authUseHello: boolean
authHelloSecret: string // 原始密码safeStorage 加密Hello 解锁时使用)
// 更新相关
ignoredUpdateVersion: string
@@ -42,12 +50,26 @@ interface ConfigSchema {
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 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()
@@ -83,35 +105,570 @@ export class ConfigService {
whisperDownloadSource: 'tsinghua',
autoTranscribeVoice: false,
transcribeLanguages: ['zh'],
exportDefaultConcurrency: 2,
exportDefaultConcurrency: 4,
analyticsExcludedUsernames: [],
authEnabled: false,
authPassword: '',
authUseHello: false,
authHelloSecret: '',
ignoredUpdateVersion: '',
notificationEnabled: true,
notificationPosition: 'top-right',
notificationFilterMode: 'all',
notificationFilterList: []
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

@@ -10,6 +10,7 @@ interface ContactExportOptions {
groups: boolean
officials: boolean
}
selectedUsernames?: string[]
}
/**
@@ -40,6 +41,11 @@ class ContactExportService {
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: '没有符合条件的联系人' }
}

View File

@@ -1,11 +1,15 @@
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 {
@@ -14,6 +18,9 @@ export interface DualReportFirstChat {
content: string
isSentByMe: boolean
senderUsername?: string
localType?: number
emojiMd5?: string
emojiCdnUrl?: string
}
export interface DualReportStats {
@@ -26,13 +33,17 @@ export interface DualReportStats {
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?: {
@@ -42,9 +53,19 @@ export interface DualReportData {
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 {
@@ -75,7 +96,7 @@ class DualReportService {
}
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
const cleaned = suffixMatch ? suffixMatch[1] : trimmed
return cleaned
}
@@ -106,11 +127,15 @@ class DualReportService {
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)
@@ -164,26 +189,258 @@ class DualReportService {
return `${month}/${day} ${hour}:${minute}`
}
private extractEmojiUrl(content: string): string | undefined {
if (!content) return undefined
const attrMatch = /cdnurl\s*=\s*['"]([^'"]+)['"]/i.exec(content)
if (attrMatch) {
let url = attrMatch[1].replace(/&amp;/g, '&')
try {
if (url.includes('%')) {
url = decodeURIComponent(url)
}
} catch { }
return url
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]
}
}
const tagMatch = /cdnurl[^>]*>([^<]+)/i.exec(content)
return tagMatch?.[1]
return undefined
}
private extractEmojiMd5(content: string): string | 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 match = /md5="([^"]+)"/i.exec(content) || /<md5>([^<]+)<\/md5>/i.exec(content)
return match?.[1]
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> {
@@ -245,10 +502,11 @@ class DualReportService {
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, onProgress } = params
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 }
@@ -267,189 +525,271 @@ class DualReportService {
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, 3, 0, 0)
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 content = this.decodeMessageContent(row.message_content, row.compress_content)
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(content || ''),
content: String(rawContent || ''),
isSentByMe: this.resolveIsSent(row, rawWxid, cleanedWxid),
senderUsername: row.sender_username || row.sender
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 msgContent = this.decodeMessageContent(row.message_content, row.compress_content)
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(msgContent || ''),
content: String(rawContent || ''),
isSentByMe: this.resolveIsSent(row, rawWxid, cleanedWxid),
createTime: msgTime,
createTimeStr: this.formatDateTime(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, 3, startTime, endTime)
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 msgContent = this.decodeMessageContent(row.message_content, row.compress_content)
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(msgContent || ''),
content: String(rawContent || ''),
isSentByMe: this.resolveIsSent(row, rawWxid, cleanedWxid),
createTime: msgTime,
createTimeStr: this.formatDateTime(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(this.decodeMessageContent(firstRow.message_content, firstRow.compress_content) || ''),
isSentByMe: this.resolveIsSent(firstRow, rawWxid, cleanedWxid),
content: String(rawContentYear || ''),
isSentByMe: this.resolveIsSent(firstRowYear, rawWxid, cleanedWxid),
friendName,
firstThreeMessages
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: 0,
totalWords: 0,
imageCount: 0,
voiceCount: 0,
emojiCount: 0
}
const wordCountMap = new Map<string, number>()
const myEmojiCounts = new Map<string, number>()
const friendEmojiCounts = new Map<string, number>()
const myEmojiUrlMap = new Map<string, string>()
const friendEmojiUrlMap = new Map<string, string>()
const messageCountResult = await wcdbService.getMessageCount(friendUsername)
const totalForProgress = messageCountResult.success && messageCountResult.count
? messageCountResult.count
: 0
let processed = 0
let lastProgressAt = 0
const cursorResult = await wcdbService.openMessageCursor(friendUsername, 1000, true, startTime, endTime)
if (!cursorResult.success || !cursorResult.cursor) {
return { success: false, error: cursorResult.error || '打开消息游标失败' }
totalMessages: counts.total || 0,
totalWords: counts.words || 0,
imageCount: counts.image || 0,
voiceCount: counts.voice || 0,
emojiCount: counts.emoji || 0
}
try {
let hasMore = true
while (hasMore) {
const batch = await wcdbService.fetchMessageBatch(cursorResult.cursor)
if (!batch.success || !batch.rows) break
for (const row of batch.rows) {
const localType = parseInt(row.local_type || row.type || '1', 10)
const isSent = this.resolveIsSent(row, rawWxid, cleanedWxid)
stats.totalMessages += 1
// Process Emojis to find top for me and friend
let myTopEmojiMd5: string | undefined
let myTopEmojiUrl: string | undefined
let myTopCount = -1
if (localType === 3) stats.imageCount += 1
if (localType === 34) stats.voiceCount += 1
if (localType === 47) {
stats.emojiCount += 1
const content = this.decodeMessageContent(row.message_content, row.compress_content)
const md5 = this.extractEmojiMd5(content)
const url = this.extractEmojiUrl(content)
if (md5) {
const targetMap = isSent ? myEmojiCounts : friendEmojiCounts
targetMap.set(md5, (targetMap.get(md5) || 0) + 1)
if (url) {
const urlMap = isSent ? myEmojiUrlMap : friendEmojiUrlMap
if (!urlMap.has(md5)) urlMap.set(md5, url)
}
}
}
let friendTopEmojiMd5: string | undefined
let friendTopEmojiUrl: string | undefined
let friendTopCount = -1
if (localType === 1 || localType === 244813135921) {
const content = this.decodeMessageContent(row.message_content, row.compress_content)
const text = String(content || '').trim()
if (text.length > 0) {
stats.totalWords += text.replace(/\s+/g, '').length
const normalized = text.replace(/\s+/g, ' ').trim()
if (normalized.length >= 2 &&
normalized.length <= 50 &&
!normalized.includes('http') &&
!normalized.includes('<') &&
!normalized.startsWith('[') &&
!normalized.startsWith('<?xml')) {
wordCountMap.set(normalized, (wordCountMap.get(normalized) || 0) + 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 (totalForProgress > 0) {
processed++
if (candidate.isMe) {
if (candidate.count > myTopCount) {
myTopCount = candidate.count
myTopEmojiMd5 = candidate.md5
myTopEmojiUrl = candidate.url
}
}
hasMore = batch.hasMore === true
const now = Date.now()
if (now - lastProgressAt > 200) {
if (totalForProgress > 0) {
const ratio = Math.min(1, processed / totalForProgress)
const progress = 30 + Math.floor(ratio * 50)
this.reportProgress('统计聊天数据...', progress, onProgress)
}
lastProgressAt = now
} else if (candidate.count > friendTopCount) {
friendTopCount = candidate.count
friendTopEmojiMd5 = candidate.md5
friendTopEmojiUrl = candidate.url
}
}
} finally {
await wcdbService.closeMessageCursor(cursorResult.cursor)
}
const pickTop = (map: Map<string, number>): string | undefined => {
let topKey: string | undefined
let topCount = -1
for (const [key, count] of map.entries()) {
if (count > topCount) {
topCount = count
topKey = key
}
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
}
return topKey
}
const myTopEmojiMd5 = pickTop(myEmojiCounts)
const friendTopEmojiMd5 = pickTop(friendEmojiCounts)
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.myTopEmojiUrl = myTopEmojiMd5 ? myEmojiUrlMap.get(myTopEmojiMd5) : undefined
stats.friendTopEmojiUrl = friendTopEmojiMd5 ? friendEmojiUrlMap.get(friendTopEmojiMd5) : undefined
stats.friendTopEmojiUrl = friendTopEmojiUrl
if (myTopCount >= 0) stats.myTopEmojiCount = myTopCount
if (friendTopCount >= 0) stats.friendTopEmojiCount = friendTopCount
this.reportProgress('生成常用语词云...', 85, onProgress)
const topPhrases = Array.from(wordCountMap.entries())
.filter(([_, count]) => count >= 2)
.sort((a, b) => b[1] - a[1])
.slice(0, 50)
.map(([phrase, count]) => ({ phrase, count }))
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
}
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 }

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) {
@@ -381,7 +391,7 @@ export class ImageDecryptService {
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
const cleaned = suffixMatch ? suffixMatch[1] : trimmed
return cleaned
}
@@ -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 })
@@ -583,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')
@@ -801,7 +830,7 @@ export class ImageDecryptService {
* 包含1. 微信旧版结构 filename.substr(0, 2)/...
* 2. 微信新版结构 msg/attach/{hash}/{YYYY-MM}/Img/filename
*/
private async fastProbabilisticSearch(root: string, datName: string): Promise<string | null> {
private async fastProbabilisticSearch(root: string, datName: string, _allowThumbnail?: boolean): Promise<string | null> {
const { promises: fs } = require('fs')
const { join } = require('path')
@@ -837,7 +866,7 @@ export class ImageDecryptService {
} catch { }
}
// --- 策略 B: 新版 Session 哈希路径猜测 ---
// --- 绛栫暐 B: 鏂扮増 Session 鍝堝笇璺緞鐚滄祴 ---
try {
const entries = await fs.readdir(root, { withFileTypes: true })
const sessionDirs = entries
@@ -890,7 +919,7 @@ export class ImageDecryptService {
/**
* 在同一目录下查找高清图变体
* 缩略图: xxx_t.dat -> 高清图: xxx_h.dat 或 xxx.dat
* 缩略图 xxx_t.dat -> 高清图 xxx_h.dat 或 xxx.dat
*/
private findHdVariantInSameDir(thumbPath: string): string | null {
try {
@@ -972,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')) {
@@ -1032,27 +1012,16 @@ 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 allRoots = this.getAllCacheRoots()
const normalizedKey = this.normalizeDatBase(cacheKey.toLowerCase())
@@ -1287,14 +1256,14 @@ export class ImageDecryptService {
private async ensureCacheIndexed(): Promise<void> {
if (this.cacheIndexed) return
if (this.cacheIndexing) return this.cacheIndexing
this.cacheIndexing = new Promise((resolve) => {
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 结构
this.indexCacheDir(root, 3, 0) // 增加深度到 3支持 sessionId/YYYY-MM 结构
} catch (e) {
this.logError('索引目录失败', e, { root })
}
@@ -1303,8 +1272,7 @@ export class ImageDecryptService {
this.logInfo('缓存索引完成', { entries: this.resolvedCache.size })
this.cacheIndexed = true
this.cacheIndexing = null
resolve()
})
})()
return this.cacheIndexing
}
@@ -1507,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)
}
@@ -1706,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[] = []
@@ -1787,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)
@@ -1846,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) {
// 忽略错误
this.log('缓存 hardlink.db 查询失败', { path: p, error: String(e) })
}
}
}
@@ -105,46 +124,45 @@ class VideoService {
// 方法2使用 wcdbService.execQuery 查询加密的 hardlink.db
if (dbPath) {
// 检查 dbPath 是否已经包含 wxid
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) {
// dbPath 已包含 wxid不需要再拼接
encryptedDbPaths.push(join(dbPath, 'db_storage', 'hardlink', 'hardlink.db'))
} else {
// dbPath 不包含 wxid需要拼接
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
}
@@ -170,64 +188,107 @@ class VideoService {
const dbPath = this.getDbPath()
const wxid = this.getMyWxid()
this.log('getVideoInfo 开始', { videoMd5, dbPath, wxid })
if (!dbPath || !wxid || !videoMd5) {
this.log('getVideoInfo: 参数缺失', { dbPath: !!dbPath, wxid: !!wxid, videoMd5: !!videoMd5 })
return { exists: false }
}
// 先尝试从数据库查询真正的视频文件名
const realVideoMd5 = await this.queryVideoFileName(videoMd5) || videoMd5
this.log('realVideoMd5', { input: videoMd5, resolved: realVideoMd5, changed: realVideoMd5 !== videoMd5 })
// 检查 dbPath 是否已经包含 wxid避免重复拼接
const dbPathLower = dbPath.toLowerCase()
const wxidLower = wxid.toLowerCase()
const cleanedWxid = this.cleanWxid(wxid)
let videoBaseDir: string
if (dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxid.toLowerCase())) {
// dbPath 已经包含 wxid直接使用
videoBaseDir = join(dbPath, 'msg', 'video')
} else {
// dbPath 不包含 wxid需要拼接
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) {
// 忽略错误
this.log('getVideoInfo 遍历出错', { error: String(e) })
}
this.log('getVideoInfo: 未找到视频', { videoMd5, realVideoMd5 })
return { exists: false }
}
@@ -235,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) => {
if (msg.type === 'partial') {
onPartial?.(msg.text)
} else if (msg.type === 'final') {
finalTranscript = msg.text
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
@@ -39,12 +83,15 @@ export class WcdbCore {
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
@@ -59,10 +106,23 @@ export class WcdbCore {
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
@@ -86,61 +146,94 @@ export class WcdbCore {
// 使用命名管道 IPC
startMonitor(callback: (type: string, json: string) => void): boolean {
if (!this.wcdbStartMonitorPipe) {
this.writeLog('startMonitor: wcdbStartMonitorPipe not available')
return false
}
this.monitorCallback = callback
try {
const result = this.wcdbStartMonitorPipe()
if (result !== 0) {
this.writeLog(`startMonitor: wcdbStartMonitorPipe failed with ${result}`)
return false
}
const net = require('net')
const PIPE_PATH = '\\\\.\\pipe\\weflow_monitor'
setTimeout(() => {
this.monitorPipeClient = net.createConnection(PIPE_PATH, () => {
this.writeLog('Monitor pipe connected')
})
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)
callback(parsed.action || 'update', line)
} catch {
callback('update', line)
}
}
// 从 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.monitorPipeClient.on('error', (err: Error) => {
this.writeLog(`Monitor pipe error: ${err.message}`)
})
this.monitorPipeClient.on('close', () => {
this.writeLog('Monitor pipe closed')
this.monitorPipeClient = null
})
}, 100)
this.writeLog('Monitor started via named pipe IPC')
this.connectMonitorPipe(pipePath)
return true
} catch (e) {
console.error('startMonitor failed:', 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
@@ -298,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)) {
@@ -382,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)')
@@ -429,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)')
@@ -456,6 +567,13 @@ 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)')
@@ -470,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)')
@@ -532,15 +657,45 @@ export class WcdbCore {
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)
@@ -550,6 +705,28 @@ export class WcdbCore {
this.wcdbVerifyUser = null
}
// wcdb_status wcdb_cloud_init(int32_t interval_seconds)
try {
this.wcdbCloudInit = this.lib.func('int32 wcdb_cloud_init(int32 intervalSeconds)')
} catch {
this.wcdbCloudInit = null
}
// wcdb_status wcdb_cloud_report(const char* stats_json)
try {
this.wcdbCloudReport = this.lib.func('int32 wcdb_cloud_report(const char* statsJson)')
} catch {
this.wcdbCloudReport = null
}
// void wcdb_cloud_stop()
try {
this.wcdbCloudStop = this.lib.func('void wcdb_cloud_stop()')
} catch {
this.wcdbCloudStop = null
}
// 初始化
const initResult = this.wcdbInit()
if (initResult !== 0) {
@@ -952,7 +1129,7 @@ export class WcdbCore {
}
try {
// 1. 打开游标 (使用 Ascending=1 从指定时间往后查)
const openRes = await this.openMessageCursorLite(sessionId, limit, true, minTime, 0)
const openRes = await this.openMessageCursor(sessionId, limit, true, minTime, 0)
if (!openRes.success || !openRes.cursor) {
return { success: false, error: openRes.error }
}
@@ -990,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 未连接' }
@@ -1186,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 未连接' }
@@ -1243,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 未连接' }
@@ -1525,12 +1789,20 @@ export class WcdbCore {
}
}
async execQuery(kind: string, path: string | null, sql: string): Promise<{ success: boolean; rows?: any[]; error?: string }> {
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)
if (result !== 0 || !outPtr[0]) {
@@ -1626,8 +1898,57 @@ export class WcdbCore {
}
/**
* 验证 Windows Hello
* 数据收集初始化
*/
async cloudInit(intervalSeconds: number = 600): Promise<{ success: boolean; error?: string }> {
if (!this.initialized) {
const initOk = await this.initialize()
if (!initOk) return { success: false, error: 'WCDB init failed' }
}
if (!this.wcdbCloudInit) {
return { success: false, error: 'Cloud init API not supported by DLL' }
}
try {
const result = this.wcdbCloudInit(intervalSeconds)
if (result !== 0) {
return { success: false, error: `Cloud init failed: ${result}` }
}
return { success: true }
} catch (e) {
return { success: false, error: String(e) }
}
}
async cloudReport(statsJson: string): Promise<{ success: boolean; error?: string }> {
if (!this.initialized) {
const initOk = await this.initialize()
if (!initOk) return { success: false, error: 'WCDB init failed' }
}
if (!this.wcdbCloudReport) {
return { success: false, error: 'Cloud report API not supported by DLL' }
}
try {
const result = this.wcdbCloudReport(statsJson || '')
if (result !== 0) {
return { success: false, error: `Cloud report failed: ${result}` }
}
return { success: true }
} catch (e) {
return { success: false, error: String(e) }
}
}
cloudStop(): { success: boolean; error?: string } {
if (!this.wcdbCloudStop) {
return { success: false, error: 'Cloud stop API not supported by DLL' }
}
try {
this.wcdbCloudStop()
return { success: true }
} catch (e) {
return { success: false, error: String(e) }
}
}
async verifyUser(message: string, hwnd?: string): Promise<{ success: boolean; error?: string }> {
if (!this.initialized) {
const initOk = await this.initialize()
@@ -1710,4 +2031,171 @@ export class WcdbCore {
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

@@ -136,7 +136,6 @@ export class WcdbService {
*/
setMonitor(callback: (type: string, json: string) => void): void {
this.monitorListener = callback;
// Notify worker to enable monitor
this.callWorker('setMonitor').catch(() => { });
}
@@ -219,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 })
}
/**
* 获取联系人昵称
*/
@@ -273,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 })
}
/**
* 获取消息元数据
*/
@@ -287,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 })
}
/**
* 获取聚合统计数据
*/
@@ -315,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 })
}
/**
* 获取群聊统计
*/
@@ -351,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 })
}
/**
@@ -406,6 +427,34 @@ export class WcdbService {
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 内部日志
*/
@@ -420,6 +469,43 @@ export class WcdbService {
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()

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

@@ -54,6 +54,9 @@ if (parentPort) {
case 'getMessageCount':
result = await core.getMessageCount(payload.sessionId)
break
case 'getMessageCounts':
result = await core.getMessageCounts(payload.sessionIds)
break
case 'getDisplayNames':
result = await core.getDisplayNames(payload.usernames)
break
@@ -78,12 +81,18 @@ if (parentPort) {
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
@@ -96,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
@@ -112,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)
@@ -138,12 +150,39 @@ if (parentPort) {
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

@@ -76,12 +76,10 @@ export async function showNotification(data: any) {
const isInList = filterList.includes(sessionId)
if (filterMode === 'whitelist' && !isInList) {
// 白名单模式:不在列表中则不显示
console.log('[NotificationWindow] Filtered by whitelist:', sessionId)
return
}
if (filterMode === 'blacklist' && isInList) {
// 黑名单模式:在列表中则不显示
console.log('[NotificationWindow] Filtered by blacklist:', sessionId)
return
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

1482
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "weflow",
"version": "1.5.0",
"version": "2.1.0",
"description": "WeFlow",
"main": "dist-electron/main.js",
"author": "cc",
@@ -10,9 +10,10 @@
},
"//": "二改不应改变此处的作者与应用信息",
"scripts": {
"postinstall": "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",
@@ -34,8 +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",
@@ -104,6 +107,10 @@
{
"from": "public/icon.ico",
"to": "icon.ico"
},
{
"from": "electron/assets/wasm/",
"to": "assets/wasm/"
}
],
"files": [
@@ -112,7 +119,8 @@
],
"asarUnpack": [
"node_modules/silk-wasm/**/*",
"node_modules/sherpa-onnx-node/**/*"
"node_modules/sherpa-onnx-node/**/*",
"node_modules/ffmpeg-static/**/*"
],
"extraFiles": [
{

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

@@ -24,8 +24,9 @@ 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'
@@ -33,6 +34,8 @@ 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()
@@ -58,7 +61,9 @@ function App() {
const isOnboardingWindow = location.pathname === '/onboarding-window'
const isVideoPlayerWindow = location.pathname === '/video-player-window'
const isChatHistoryWindow = location.pathname.startsWith('/chat-history/')
const isStandaloneChatWindow = location.pathname === '/chat-window'
const isNotificationWindow = location.pathname === '/notification-window'
const isExportRoute = location.pathname === '/export'
const [themeHydrated, setThemeHydrated] = useState(false)
// 锁定状态
@@ -73,6 +78,9 @@ function App() {
const [agreementChecked, setAgreementChecked] = useState(false)
const [agreementLoading, setAgreementLoading] = useState(true)
// 数据收集同意状态
const [showAnalyticsConsent, setShowAnalyticsConsent] = useState(false)
useEffect(() => {
const root = document.documentElement
const body = document.body
@@ -99,14 +107,27 @@ function App() {
// 应用主题
useEffect(() => {
document.documentElement.setAttribute('data-theme', currentTheme)
document.documentElement.setAttribute('data-mode', themeMode)
// 更新窗口控件颜色以适配主题
const symbolColor = themeMode === 'dark' ? '#ffffff' : '#1a1a1a'
if (!isOnboardingWindow && !isNotificationWindow) {
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 })
}
}
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])
// 读取已保存的主题设置
@@ -120,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) {
@@ -155,6 +176,14 @@ function App() {
const agreed = await configService.getAgreementAccepted()
if (!agreed) {
setShowAgreement(true)
} else {
// 协议已同意,检查数据收集同意状态
const consent = await configService.getAnalyticsConsent()
const denyCount = await configService.getAnalyticsDenyCount()
// 如果未设置同意状态且拒绝次数小于2次显示弹窗
if (consent === null && denyCount < 2) {
setShowAnalyticsConsent(true)
}
}
} catch (e) {
console.error('检查协议状态失败:', e)
@@ -165,25 +194,56 @@ function App() {
checkAgreement()
}, [])
// 初始化数据收集
useEffect(() => {
cloudControl.initCloudControl()
}, [])
// 记录页面访问
useEffect(() => {
const path = location.pathname
if (path && path !== '/') {
cloudControl.recordPage(path)
}
}, [location.pathname])
const handleAgree = async () => {
if (!agreementChecked) return
await configService.setAgreementAccepted(true)
setShowAgreement(false)
// 协议同意后,检查数据收集同意
const consent = await configService.getAnalyticsConsent()
if (consent === null) {
setShowAnalyticsConsent(true)
}
}
const handleDisagree = () => {
window.electronAPI.window.close()
}
const handleAnalyticsAllow = async () => {
await configService.setAnalyticsConsent(true)
setShowAnalyticsConsent(false)
}
const handleAnalyticsDeny = async () => {
const denyCount = await configService.getAnalyticsDenyCount()
await configService.setAnalyticsDenyCount(denyCount + 1)
setShowAnalyticsConsent(false)
}
// 监听启动时的更新通知
useEffect(() => {
if (isNotificationWindow) return // Skip updates in notification window
const removeUpdateListener = window.electronAPI?.app?.onUpdateAvailable?.((info: any) => {
// 发现新版本时自动打开更新弹窗
// 发现新版本时保存更新信息,锁定状态下不弹窗,解锁后再显示
if (info) {
setUpdateInfo({ ...info, hasUpdate: true })
setShowUpdateDialog(true)
if (!useAppStore.getState().isLocked) {
setShowUpdateDialog(true)
}
}
})
const removeProgressListener = window.electronAPI?.app?.onDownloadProgress?.((progress: any) => {
@@ -195,6 +255,13 @@ function App() {
}
}, [setUpdateInfo, setDownloadProgress, setShowUpdateDialog, isNotificationWindow])
// 解锁后显示暂存的更新弹窗
useEffect(() => {
if (!isLocked && updateInfo?.hasUpdate && !showUpdateDialog && !isDownloading) {
setShowUpdateDialog(true)
}
}, [isLocked])
const handleUpdateNow = async () => {
setShowUpdateDialog(false)
setIsDownloading(true)
@@ -289,7 +356,7 @@ function App() {
const checkLock = async () => {
// 并行获取配置,减少等待
const [enabled, useHello] = await Promise.all([
configService.getAuthEnabled(),
window.electronAPI.auth.verifyEnabled(),
configService.getAuthUseHello()
])
@@ -336,6 +403,12 @@ function App() {
return <ChatHistoryPage />
}
// 独立会话聊天窗口(仅显示聊天内容区域)
if (isStandaloneChatWindow) {
const sessionId = new URLSearchParams(location.search).get('sessionId') || ''
return <ChatPage standaloneSessionWindow initialSessionId={sessionId} />
}
// 独立通知窗口
if (isNotificationWindow) {
return <NotificationWindow />
@@ -344,6 +417,7 @@ function App() {
// 主窗口 - 完整布局
return (
<div className="app-container">
<div className="window-drag-region" aria-hidden="true" />
{isLocked && (
<LockScreen
onUnlock={() => setLocked(false)}
@@ -359,6 +433,10 @@ function App() {
{/* 全局会话监听与通知 */}
<GlobalSessionMonitor />
{/* 全局批量转写进度浮窗 */}
<BatchTranscribeGlobal />
<BatchImageDecryptGlobal />
{/* 用户协议弹窗 */}
{showAgreement && !agreementLoading && (
<div className="agreement-overlay">
@@ -410,6 +488,42 @@ function App() {
</div>
)}
{/* 数据收集同意弹窗 */}
{showAnalyticsConsent && !agreementLoading && (
<div className="agreement-overlay">
<div className="agreement-modal">
<div className="agreement-header">
<Shield size={32} />
<h2>使</h2>
</div>
<div className="agreement-content">
<div className="agreement-text">
<p> WeFlow 使</p>
<h4></h4>
<p> 使使使</p>
<p> </p>
<p> </p>
<h4></h4>
<p> </p>
<p> </p>
<p> </p>
<p> </p>
<p> </p>
</div>
</div>
<div className="agreement-footer">
<div className="agreement-actions">
<button className="btn btn-secondary" onClick={handleAnalyticsDeny}></button>
<button className="btn btn-primary" onClick={handleAnalyticsAllow}></button>
</div>
</div>
</div>
</div>
)}
{/* 更新提示对话框 */}
<UpdateDialog
open={showUpdateDialog}
@@ -425,10 +539,15 @@ function App() {
<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 />} />
@@ -438,7 +557,7 @@ function App() {
<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 />} />

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

@@ -14,7 +14,6 @@ export function GlobalSessionMonitor() {
} = useChatStore()
const sessionsRef = useRef(sessions)
// 保持 ref 同步
useEffect(() => {
sessionsRef.current = sessions
@@ -47,9 +46,10 @@ export function GlobalSessionMonitor() {
return () => {
removeListener()
}
} else {
}
return () => { }
}, []) // 空依赖数组 - 主要是静态的
}, [])
const refreshSessions = async () => {
try {
@@ -81,6 +81,11 @@ export function GlobalSessionMonitor() {
}
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) {
@@ -92,6 +97,10 @@ export function GlobalSessionMonitor() {
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')) {
// 如果是自己发的消息,不弹通知
@@ -140,6 +149,14 @@ export function GlobalSessionMonitor() {
}
}
// 新增:如果未读数量没有增加,说明可能是自己在其他设备回复(或者已读),不弹通知
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 || '[新消息]'
@@ -181,11 +198,12 @@ export function GlobalSessionMonitor() {
// 尝试丰富或获取联系人详情
const contact = await window.electronAPI.chat.getContact(newSession.username)
if (contact) {
if (contact.remark || contact.nickname) {
title = contact.remark || contact.nickname
if (contact.remark || contact.nickName) {
title = contact.remark || contact.nickName
}
if (contact.avatarUrl) {
avatarUrl = contact.avatarUrl
const avatarResult = await window.electronAPI.chat.getContactAvatar(newSession.username)
if (avatarResult?.avatarUrl) {
avatarUrl = avatarResult.avatarUrl
}
} else {
// 如果不在缓存/数据库中
@@ -205,8 +223,11 @@ export function GlobalSessionMonitor() {
if (title === newSession.username || title.startsWith('wxid_')) {
const retried = await window.electronAPI.chat.getContact(newSession.username)
if (retried) {
title = retried.remark || retried.nickname || title
avatarUrl = retried.avatarUrl || avatarUrl
title = retried.remark || retried.nickName || title
const retriedAvatar = await window.electronAPI.chat.getContactAvatar(newSession.username)
if (retriedAvatar?.avatarUrl) {
avatarUrl = retriedAvatar.avatarUrl
}
}
}
}
@@ -240,7 +261,8 @@ export function GlobalSessionMonitor() {
const handleActiveSessionRefresh = async (sessionId: string) => {
// 从 ChatPage 复制/调整的逻辑,以保持集中
const state = useChatStore.getState()
const lastMsg = state.messages[state.messages.length - 1]
const msgs = state.messages || []
const lastMsg = msgs[msgs.length - 1]
const minTime = lastMsg?.createTime || 0
try {

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

@@ -1,5 +1,4 @@
import { useState, useEffect, useRef } from 'react'
import * as configService from '../services/config'
import { ArrowRight, Fingerprint, Lock, ScanFace, ShieldCheck } from 'lucide-react'
import './LockScreen.scss'
@@ -9,14 +8,6 @@ interface LockScreenProps {
useHello?: boolean
}
async function sha256(message: string) {
const msgBuffer = new TextEncoder().encode(message)
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer)
const hashArray = Array.from(new Uint8Array(hashBuffer))
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
return hashHex
}
export default function LockScreen({ onUnlock, avatar, useHello = false }: LockScreenProps) {
const [password, setPassword] = useState('')
const [error, setError] = useState('')
@@ -49,19 +40,9 @@ export default function LockScreen({ onUnlock, avatar, useHello = false }: LockS
const quickStartHello = async () => {
try {
// 如果父组件已经告诉我们要用 Hello直接开始不等待 IPC
let shouldUseHello = useHello
// 为了稳健,如果 prop 没传(虽然现在都传了),再 check 一次 config
if (!shouldUseHello) {
shouldUseHello = await configService.getAuthUseHello()
}
if (shouldUseHello) {
// 标记为可用,显示按钮
if (useHello) {
setHelloAvailable(true)
setShowHello(true)
// 立即执行验证 (0延迟)
verifyHello()
}
} catch (e) {
@@ -96,25 +77,19 @@ export default function LockScreen({ onUnlock, avatar, useHello = false }: LockS
e?.preventDefault()
if (!password || isUnlocked) return
// 如果正在进行 Hello 验证它会自动失败或被取代UI上不用特意取消
// 因为 native 调用是模态的或者独立的,我们只要让 JS 状态不对锁住即可
// 不再检查 isVerifying因为我们允许打断 Hello
setIsVerifying(true)
setError('')
try {
const storedHash = await configService.getAuthPassword()
const inputHash = await sha256(password)
// 发送原始密码到主进程,由主进程验证并解密密钥
const result = await window.electronAPI.auth.unlock(password)
if (inputHash === storedHash) {
if (result.success) {
handleUnlock()
} else {
setError('密码错误')
setError(result.error || '密码错误')
setPassword('')
setIsVerifying(false)
// 如果密码错误,是否重新触发 Hello?
// 用户可能想重试密码,暂时不自动触发
}
} catch (e) {
setError('验证失败')

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

@@ -6,6 +6,15 @@
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;
@@ -39,12 +48,26 @@
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
// Ensure background is solid
background: var(--bg-secondary, #2c2c2c);
color: var(--text-primary, #ffffff);
// 独立通知窗口:默认使用浅色模式硬编码值,确保不依赖 <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, rgba(255, 255, 255, 0.1));
border: 1px solid var(--border-light);
display: flex;
padding: 16px;

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

@@ -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,11 +196,44 @@
flex-shrink: 0;
}
.nav-icon-with-badge {
position: relative;
}
.nav-label {
font-size: 14px;
font-weight: 500;
}
.nav-badge {
margin-left: auto;
min-width: 20px;
height: 20px;
border-radius: 999px;
padding: 0 6px;
background: #ff3b30;
color: #ffffff;
font-size: 11px;
font-weight: 700;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
box-shadow: 0 0 0 2px rgba(255, 59, 48, 0.18);
}
.nav-badge.icon-badge {
position: absolute;
top: -7px;
right: -10px;
margin-left: 0;
min-width: 16px;
height: 16px;
padding: 0 4px;
font-size: 10px;
box-shadow: 0 0 0 2px var(--bg-secondary);
}
.sidebar-footer {
padding: 0 12px;
border-top: 1px solid var(--border-color);
@@ -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,23 +1,335 @@
import { useState, useEffect } 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, UserCircle, Lock } 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(() => {
configService.getAuthEnabled().then(setAuthEnabled)
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' : ''}`}>
@@ -98,14 +410,61 @@ 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>
</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"
@@ -136,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

@@ -45,6 +45,12 @@
font-weight: 600;
color: var(--primary);
}
.error-actions {
display: flex;
align-items: center;
gap: 8px;
}
}
.page-header {
@@ -482,11 +488,41 @@
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

@@ -108,6 +108,7 @@ function AnalyticsPage() {
}, [loadExcludedUsernames])
const handleRefresh = () => loadData(true)
const isNoSessionError = error?.includes('未找到消息会话') ?? false
const loadExcludeCandidates = useCallback(async () => {
setExcludeLoading(true)
@@ -146,6 +147,17 @@ function AnalyticsPage() {
})
}
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)
@@ -164,6 +176,23 @@ function AnalyticsPage() {
}
}
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()
@@ -344,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>)
}
@@ -493,7 +538,12 @@ function AnalyticsPage() {
)}
</div>
<div className="exclude-modal-footer">
<span className="exclude-count"> {draftExcluded.size} </span>
<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)}>

View File

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

View File

@@ -4,6 +4,28 @@ 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()
@@ -11,32 +33,117 @@ function AnnualReportPage() {
const [selectedYear, setSelectedYear] = useState<YearOption | null>(null)
const [selectedPairYear, setSelectedPairYear] = useState<YearOption | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isLoadingMoreYears, setIsLoadingMoreYears] = useState(false)
const [hasYearsLoadFinished, setHasYearsLoadFinished] = useState(false)
const [loadStrategy, setLoadStrategy] = useState<'cache' | 'native' | 'hybrid'>('native')
const [loadPhase, setLoadPhase] = useState<'cache' | 'native' | 'scan' | 'done'>('native')
const [loadStatusText, setLoadStatusText] = useState('准备加载年份数据...')
const [nativeElapsedMs, setNativeElapsedMs] = useState(0)
const [scanElapsedMs, setScanElapsedMs] = useState(0)
const [totalElapsedMs, setTotalElapsedMs] = useState(0)
const [hasSwitchedStrategy, setHasSwitchedStrategy] = useState(false)
const [nativeTimedOut, setNativeTimedOut] = useState(false)
const [isGenerating, setIsGenerating] = useState(false)
const [loadError, setLoadError] = useState<string | null>(null)
useEffect(() => {
loadAvailableYears()
}, [])
let disposed = false
let taskId = ''
const loadAvailableYears = async () => {
setIsLoading(true)
setLoadError(null)
try {
const result = await window.electronAPI.annualReport.getAvailableYears()
if (result.success && result.data && result.data.length > 0) {
setAvailableYears(result.data)
setSelectedYear((prev) => prev ?? result.data[0])
setSelectedPairYear((prev) => prev ?? result.data[0])
} else if (!result.success) {
setLoadError(result.error || '加载年度数据失败')
const applyLoadPayload = (payload: YearsLoadPayload) => {
if (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)
}
} catch (e) {
console.error(e)
setLoadError(String(e))
} finally {
setIsLoading(false)
}
}
const stopListen = window.electronAPI.annualReport.onAvailableYearsProgress((payload) => {
if (disposed) return
if (taskId && payload.taskId !== taskId) return
if (!taskId) taskId = payload.taskId
applyLoadPayload(payload)
})
const startLoad = async () => {
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 handleGenerateReport = async () => {
if (selectedYear === null) return
@@ -57,16 +164,25 @@ function AnnualReportPage() {
navigate(`/dual-report?year=${yearParam}`)
}
if (isLoading) {
if (isLoading && availableYears.length === 0) {
return (
<div className="annual-report-page">
<Loader2 size={32} className="spin" style={{ color: 'var(--text-tertiary)' }} />
<p style={{ color: 'var(--text-tertiary)', marginTop: 16 }}>...</p>
<p style={{ color: 'var(--text-tertiary)', marginTop: 16 }}>...</p>
<div 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 }} />
@@ -87,11 +203,50 @@ function AnnualReportPage() {
return value === 'all' ? '全部时间' : `${value}`
}
const loadedYearCount = availableYears.length
const isYearStatusComplete = hasYearsLoadFinished
const strategyLabel = getStrategyLabel({ loadStrategy, loadPhase, hasYearsLoadFinished, hasSwitchedStrategy, nativeTimedOut })
const renderYearLoadStatus = () => (
<div className={`year-load-status ${isYearStatusComplete ? 'complete' : 'loading'}`}>
{isYearStatusComplete ? (
<></>
) : (
<>
<span className="dot-ellipsis" aria-hidden="true">...</span>
</>
)}
</div>
)
return (
<div className="annual-report-page">
<Sparkles size={32} className="header-icon" />
<h1 className="page-title"></h1>
<p className="page-desc"></p>
{loadedYearCount > 0 && (
<p className={`page-desc load-summary ${isYearStatusComplete ? 'complete' : 'loading'}`}>
{isYearStatusComplete ? (
<> {loadedYearCount} {formatLoadElapsed(totalElapsedMs)}</>
) : (
<>
{loadedYearCount} <span className="dot-ellipsis" aria-hidden="true">...</span>
{formatLoadElapsed(totalElapsedMs)}
</>
)}
</p>
)}
<div className={`load-telemetry ${isYearStatusComplete ? 'complete' : 'loading'}`}>
<p><span className="label"></span>{strategyLabel}</p>
<p>
<span className="label"></span>
{loadStatusText || (isYearStatusComplete ? '全部年份已加载完毕' : '正在加载年份数据...')}
</p>
<p>
<span className="label"></span>{formatLoadElapsed(nativeElapsedMs)}{nativeTimedOut ? '(超时)' : ''} {' '}
<span className="label"></span>{formatLoadElapsed(scanElapsedMs)} {' '}
<span className="label"></span>{formatLoadElapsed(totalElapsedMs)}
</p>
</div>
<div className="report-sections">
<section className="report-section">
@@ -102,17 +257,20 @@ function AnnualReportPage() {
</div>
</div>
<div className="year-grid">
{yearOptions.map(option => (
<div
key={option}
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedYear === option ? 'selected' : ''}`}
onClick={() => setSelectedYear(option)}
>
<span className="year-number">{option === 'all' ? '全部' : option}</span>
<span className="year-label">{option === 'all' ? '时间' : '年'}</span>
</div>
))}
<div className="year-grid-with-status">
<div className="year-grid">
{yearOptions.map(option => (
<div
key={option}
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedYear === option ? 'selected' : ''}`}
onClick={() => setSelectedYear(option)}
>
<span className="year-number">{option === 'all' ? '全部' : option}</span>
<span className="year-label">{option === 'all' ? '时间' : '年'}</span>
</div>
))}
</div>
{renderYearLoadStatus()}
</div>
<button
@@ -146,17 +304,20 @@ function AnnualReportPage() {
</div>
</div>
<div className="year-grid">
{yearOptions.map(option => (
<div
key={`pair-${option}`}
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedPairYear === option ? 'selected' : ''}`}
onClick={() => setSelectedPairYear(option)}
>
<span className="year-number">{option === 'all' ? '全部' : option}</span>
<span className="year-label">{option === 'all' ? '时间' : '年'}</span>
</div>
))}
<div className="year-grid-with-status">
<div className="year-grid">
{yearOptions.map(option => (
<div
key={`pair-${option}`}
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedPairYear === option ? 'selected' : ''}`}
onClick={() => setSelectedPairYear(option)}
>
<span className="year-number">{option === 'all' ? '全部' : option}</span>
<span className="year-label">{option === 'all' ? '时间' : '年'}</span>
</div>
))}
</div>
{renderYearLoadStatus()}
</div>
<button
@@ -174,4 +335,23 @@ function AnnualReportPage() {
)
}
function getStrategyLabel(params: {
loadStrategy: 'cache' | 'native' | 'hybrid'
loadPhase: 'cache' | 'native' | 'scan' | 'done'
hasYearsLoadFinished: boolean
hasSwitchedStrategy: boolean
nativeTimedOut: boolean
}): string {
const { loadStrategy, loadPhase, hasYearsLoadFinished, hasSwitchedStrategy, nativeTimedOut } = params
if (loadStrategy === 'cache') return '缓存模式(快速)'
if (hasYearsLoadFinished) {
if (loadStrategy === 'native') return '原生快速模式'
if (hasSwitchedStrategy || nativeTimedOut) return '混合策略(原生→扫表)'
return '扫表兼容模式'
}
if (loadPhase === 'native') return '原生快速模式(优先)'
if (loadPhase === 'scan') return '扫表兼容模式(回退)'
return '混合策略'
}
export default AnnualReportPage

View File

@@ -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,134 +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;
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;
}
.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;
}
.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;
}
.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;
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;
}
0% {
left: -20%;
opacity: 0;
}
10% {
opacity: 0.8;
}
10% {
opacity: 0.8;
}
50% {
opacity: 0.4;
}
50% {
opacity: 0.4;
}
90% {
opacity: 0.1;
}
90% {
opacity: 0.1;
}
100% {
left: 120%;
opacity: 0;
}
100% {
left: 120%;
opacity: 0;
}
}
@keyframes fadeInRight {
from {
opacity: 0;
transform: translateX(-20px);
}
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes fadeInLeft {
from {
opacity: 0;
transform: translateX(20px);
}
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
to {
opacity: 1;
transform: translateX(0);
}
}

View File

@@ -109,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)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -7,8 +7,8 @@
// 左侧联系人面板
.contacts-panel {
width: 380px;
min-width: 380px;
width: 350px;
min-width: 350px;
display: flex;
flex-direction: column;
border-right: 1px solid var(--border-color);
@@ -55,6 +55,11 @@
.spin {
animation: contactsSpin 1s linear infinite;
}
&.export-mode-btn.active {
background: var(--primary);
color: #fff;
}
}
}
@@ -110,11 +115,11 @@
}
.type-filters {
display: flex;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
padding: 0 20px 16px;
flex-wrap: nowrap;
overflow-x: auto;
max-width: 300px;
&::-webkit-scrollbar {
display: none;
@@ -143,6 +148,17 @@
svg {
opacity: 0.7;
transition: transform 0.2s;
flex-shrink: 0;
}
.chip-label {
min-width: 0;
}
.chip-count {
margin-left: auto;
text-align: right;
font-variant-numeric: tabular-nums;
}
&:hover {
@@ -172,6 +188,40 @@
padding: 0 20px 12px;
font-size: 13px;
color: var(--text-secondary);
.contacts-cache-meta {
margin-left: 10px;
color: var(--text-tertiary);
font-size: 12px;
&.syncing {
color: var(--primary);
}
}
.avatar-enrich-progress {
margin-left: 10px;
color: var(--text-tertiary);
font-size: 12px;
}
}
.selection-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 0 20px 12px;
.checkbox-item {
font-size: 13px;
color: var(--text-secondary);
}
.selection-count {
font-size: 12px;
color: var(--text-tertiary);
}
}
.loading-state,
@@ -190,10 +240,103 @@
}
}
.load-issue-state {
flex: 1;
padding: 14px 14px 18px;
overflow-y: auto;
}
.issue-card {
border: 1px solid color-mix(in srgb, var(--danger, #ef4444) 45%, var(--border-color));
background: color-mix(in srgb, var(--danger, #ef4444) 8%, var(--card-bg));
border-radius: 12px;
padding: 14px;
color: var(--text-primary);
.issue-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: color-mix(in srgb, var(--danger, #ef4444) 85%, var(--text-primary));
margin-bottom: 8px;
}
.issue-message {
margin: 0 0 8px;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
}
.issue-reason {
margin: 0;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
}
.issue-hints {
margin: 10px 0 0;
padding-left: 18px;
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.6;
}
.issue-actions {
margin-top: 12px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.issue-btn {
border: 1px solid var(--border-color);
background: var(--bg-secondary);
border-radius: 8px;
padding: 7px 10px;
font-size: 12px;
color: var(--text-secondary);
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
color: var(--text-primary);
border-color: var(--text-tertiary);
background: var(--bg-hover);
}
&.primary {
background: color-mix(in srgb, var(--primary) 14%, var(--bg-secondary));
border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color));
color: var(--primary);
}
}
.issue-diagnostics {
margin-top: 12px;
border-radius: 8px;
background: var(--bg-primary);
border: 1px dashed var(--border-color);
padding: 10px;
font-size: 12px;
line-height: 1.5;
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-word;
}
}
.contacts-list {
flex: 1;
overflow-y: auto;
padding: 0 12px 12px;
position: relative;
&::-webkit-scrollbar {
width: 6px;
@@ -206,19 +349,58 @@
}
}
.contacts-list-virtual {
position: relative;
min-height: 100%;
}
.contact-row {
position: absolute;
left: 0;
right: 0;
height: 76px;
padding-bottom: 4px;
will-change: transform;
}
.contact-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
height: 72px;
box-sizing: border-box;
border-radius: 10px;
transition: all 0.2s;
margin-bottom: 4px;
cursor: pointer;
margin-bottom: 0;
&:hover {
background: var(--bg-hover);
}
&.selected {
background: color-mix(in srgb, var(--primary) 12%, transparent);
}
&.active {
background: var(--bg-tertiary);
}
.contact-select {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
accent-color: var(--primary);
}
}
.contact-avatar {
width: 44px;
height: 44px;
@@ -297,6 +479,94 @@
}
}
// 右侧详情面板内的联系人资料
.detail-profile {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding-bottom: 24px;
border-bottom: 1px solid var(--border-color);
margin-bottom: 20px;
.detail-avatar {
width: 80px;
height: 80px;
border-radius: 16px;
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
img { width: 100%; height: 100%; object-fit: cover; }
span { color: #fff; font-size: 28px; font-weight: 600; }
}
.detail-name {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
}
.detail-info-list {
margin-bottom: 24px;
.detail-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 0;
font-size: 13px;
border-bottom: 1px solid var(--border-color);
&:last-child { border-bottom: none; }
}
.detail-label {
color: var(--text-tertiary);
min-width: 48px;
flex-shrink: 0;
}
.detail-value {
color: var(--text-primary);
word-break: break-all;
user-select: text;
}
}
.goto-chat-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px;
background: var(--primary);
color: #fff;
border: none;
border-radius: 10px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
&:hover { background: var(--primary-hover); }
}
.empty-detail {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: var(--text-tertiary);
font-size: 14px;
}
// 右侧设置面板
.settings-panel {
flex: 1;
@@ -548,4 +818,4 @@
to {
transform: rotate(360deg);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -132,26 +132,43 @@
.info {
display: flex;
flex-direction: column;
gap: 4px;
gap: 2px;
min-width: 0; // 允许 flex 子项缩小,配合 ellipsis
.name {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sub {
font-size: 12px;
color: var(--text-tertiary);
color: var(--text-secondary); // 从 tertiary 改为 secondary 以增强对比度
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
opacity: 0.8;
}
}
.meta {
text-align: right;
font-size: 12px;
color: var(--text-tertiary);
color: var(--text-secondary); // 改为 secondary
flex-shrink: 0;
.count {
font-weight: 600;
color: var(--text-primary);
font-size: 14px;
font-weight: 700;
color: var(--primary); // 使用主题色更醒目
margin-bottom: 2px;
}
.hint {
opacity: 0.7;
}
}
@@ -166,6 +183,11 @@
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@@ -7,6 +7,7 @@ interface ContactRanking {
username: string
displayName: string
avatarUrl?: string
wechatId?: string
messageCount: number
sentCount: number
receivedCount: number
@@ -15,28 +16,29 @@ interface ContactRanking {
function DualReportPage() {
const navigate = useNavigate()
const [year, setYear] = useState<number>(0)
const [year] = useState<number>(() => {
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
const yearParam = params.get('year')
const parsedYear = yearParam ? parseInt(yearParam, 10) : 0
return Number.isNaN(parsedYear) ? 0 : parsedYear
})
const [rankings, setRankings] = useState<ContactRanking[]>([])
const [isLoading, setIsLoading] = useState(true)
const [loadError, setLoadError] = useState<string | null>(null)
const [keyword, setKeyword] = useState('')
useEffect(() => {
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
const yearParam = params.get('year')
const parsedYear = yearParam ? parseInt(yearParam, 10) : 0
setYear(Number.isNaN(parsedYear) ? 0 : parsedYear)
}, [])
void loadRankings(year)
}, [year])
useEffect(() => {
loadRankings()
}, [])
const loadRankings = async () => {
const loadRankings = async (reportYear: number) => {
setIsLoading(true)
setLoadError(null)
try {
const result = await window.electronAPI.analytics.getContactRankings(200)
const isAllTime = reportYear <= 0
const beginTimestamp = isAllTime ? 0 : Math.floor(new Date(reportYear, 0, 1).getTime() / 1000)
const endTimestamp = isAllTime ? 0 : Math.floor(new Date(reportYear, 11, 31, 23, 59, 59).getTime() / 1000)
const result = await window.electronAPI.analytics.getContactRankings(200, beginTimestamp, endTimestamp)
if (result.success && result.data) {
setRankings(result.data)
} else {
@@ -55,7 +57,8 @@ function DualReportPage() {
if (!keyword.trim()) return rankings
const q = keyword.trim().toLowerCase()
return rankings.filter((item) => {
return item.displayName.toLowerCase().includes(q) || item.username.toLowerCase().includes(q)
const wechatId = (item.wechatId || '').toLowerCase()
return item.displayName.toLowerCase().includes(q) || wechatId.includes(q)
})
}, [rankings, keyword])
@@ -99,7 +102,7 @@ function DualReportPage() {
<input
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="搜索好友(昵称/备注/wxid"
placeholder="搜索好友(昵称/微信号"
/>
</div>
@@ -119,7 +122,7 @@ function DualReportPage() {
</div>
<div className="info">
<div className="name">{item.displayName}</div>
<div className="sub">{item.username}</div>
<div className="sub">{item.wechatId || '\u672A\u8bbe\u7f6e\u5fae\u4fe1\u53f7'}</div>
</div>
<div className="meta">
<div className="count">{item.messageCount.toLocaleString()} </div>

View File

@@ -8,6 +8,7 @@
font-size: clamp(26px, 5vw, 44px);
white-space: normal;
}
.dual-names {
font-size: clamp(24px, 4vw, 40px);
font-weight: 700;
@@ -30,9 +31,6 @@
}
.dual-info-card {
background: var(--ar-card-bg);
border: 1px solid var(--bg-tertiary, rgba(0, 0, 0, 0.05));
border-radius: 14px;
padding: 16px;
&.full {
@@ -60,14 +58,8 @@
}
.dual-message {
background: var(--ar-card-bg);
border-radius: 14px;
padding: 14px;
&.received {
background: var(--ar-card-bg-hover);
}
.message-meta {
font-size: 12px;
color: var(--ar-text-sub);
@@ -81,25 +73,15 @@
}
.first-chat-scene {
background: linear-gradient(180deg, #8f5b85 0%, #e38aa0 50%, #f6d0c8 100%);
border-radius: 20px;
padding: 28px 24px 24px;
color: #fff;
padding: 18px 16px 16px;
color: var(--ar-text-main);
position: relative;
overflow: hidden;
margin-top: 16px;
}
.first-chat-scene::before {
content: "";
position: absolute;
inset: 0;
background-image:
radial-gradient(circle at 20% 20%, rgba(255, 255, 255, 0.2), transparent 40%),
radial-gradient(circle at 80% 10%, rgba(255, 255, 255, 0.15), transparent 35%),
radial-gradient(circle at 50% 80%, rgba(255, 255, 255, 0.12), transparent 45%);
opacity: 0.6;
pointer-events: none;
display: none;
}
.scene-title {
@@ -107,6 +89,7 @@
font-weight: 700;
text-align: center;
margin-bottom: 8px;
color: var(--ar-text-main);
}
.scene-subtitle {
@@ -114,92 +97,192 @@
font-weight: 500;
text-align: center;
margin-bottom: 20px;
opacity: 0.95;
opacity: 0.9;
color: var(--ar-text-sub);
}
.scene-messages {
display: flex;
flex-direction: column;
gap: 14px;
gap: 16px;
}
.scene-message {
display: flex;
align-items: flex-end;
gap: 12px;
flex-direction: column;
align-items: center;
margin-bottom: 32px;
width: 100%;
&.sent {
&.system {
margin: 16px 0;
.system-msg-content {
background: rgba(255, 255, 255, 0.05);
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
color: rgba(255, 255, 255, 0.4);
text-align: center;
max-width: 80%;
}
}
.scene-meta {
font-size: 10px;
opacity: 0.65;
margin-bottom: 12px;
color: var(--text-tertiary);
text-align: center;
width: 100%;
}
.scene-body {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
max-width: 100%;
}
&.sent .scene-body {
flex-direction: row-reverse;
justify-content: flex-start;
}
&.received .scene-body {
flex-direction: row;
justify-content: flex-start;
}
}
.scene-avatar {
width: 40px;
height: 40px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.25);
width: 42px;
height: 42px;
border-radius: 50%;
background: var(--ar-card-bg);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
color: #fff;
color: var(--ar-text-sub);
border: 1px solid var(--bg-tertiary, rgba(0, 0, 0, 0.08));
overflow: hidden;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
.scene-content-wrapper {
display: flex;
flex-direction: column;
width: 100%;
max-width: min(78%, 720px);
}
.scene-message.sent .scene-content-wrapper {
align-items: flex-end;
}
.scene-bubble {
background: rgba(255, 255, 255, 0.85);
color: #5a4d5e;
color: var(--ar-text-main);
padding: 10px 14px;
border-radius: 14px;
max-width: 60%;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.12);
}
width: fit-content;
min-width: 40px;
max-width: 100%;
background: var(--ar-card-bg);
border-radius: 12px;
position: relative;
.scene-message.sent .scene-bubble {
background: rgba(255, 224, 168, 0.9);
color: #4a3a2f;
}
.scene-meta {
font-size: 11px;
opacity: 0.7;
margin-bottom: 4px;
&.no-bubble {
background: transparent;
padding: 0;
box-shadow: none;
}
}
.scene-content {
line-height: 1.5;
font-size: clamp(14px, 1.8vw, 16px);
word-break: break-all;
white-space: pre-wrap;
overflow-wrap: break-word;
line-break: auto;
.report-emoji-container {
display: inline-block;
vertical-align: middle;
margin: 2px 0;
.report-emoji-img {
max-width: 120px;
max-height: 120px;
border-radius: 4px;
display: block;
}
}
}
.scene-avatar.fallback {
font-size: 14px;
line-height: 1.4;
word-break: break-word;
}
.scene-avatar.with-image {
background: transparent;
color: transparent;
}
.scene-message.sent .scene-avatar {
background: rgba(255, 224, 168, 0.9);
color: #4a3a2f;
border-color: color-mix(in srgb, var(--primary) 30%, var(--bg-tertiary, rgba(0, 0, 0, 0.08)));
}
.dual-stat-grid {
display: grid;
grid-template-columns: repeat(5, minmax(140px, 1fr));
gap: 14px;
margin: 20px -28px 24px;
padding: 0 28px;
overflow: visible;
display: flex;
flex-wrap: nowrap;
gap: clamp(60px, 10vw, 120px);
margin: 48px 0 32px;
padding: 0;
justify-content: center;
align-items: flex-start;
&.bottom {
margin-top: 0;
margin-bottom: 48px;
gap: clamp(40px, 6vw, 80px);
}
}
.dual-stat-card {
background: var(--ar-card-bg);
border-radius: 14px;
padding: 14px 12px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
min-width: 140px;
max-width: 280px;
}
.stat-num {
font-size: clamp(20px, 2.8vw, 30px);
font-size: clamp(36px, 6vw, 64px);
font-weight: 800;
font-variant-numeric: tabular-nums;
color: var(--ar-primary);
line-height: 1;
white-space: nowrap;
&.small {
font-size: clamp(24px, 4vw, 40px);
}
}
.stat-unit {
font-size: 12px;
font-size: 14px;
margin-top: 4px;
opacity: 0.8;
}
.dual-stat-card.long .stat-num {
@@ -215,15 +298,12 @@
}
.emoji-card {
border: 1px solid var(--bg-tertiary, rgba(0, 0, 0, 0.08));
border-radius: 16px;
padding: 18px 16px;
display: flex;
flex-direction: column;
gap: 10px;
align-items: center;
justify-content: center;
background: var(--ar-card-bg);
img {
width: 64px;
@@ -250,4 +330,655 @@
text-align: center;
padding: 24px 0;
}
}
.initiative-container {
padding: 32px 0;
width: 100%;
background: transparent;
border: none;
}
.initiative-bar-wrapper {
display: flex;
align-items: center;
gap: 32px;
width: 100%;
padding: 24px 0;
margin-bottom: 24px;
position: relative;
}
.initiative-side {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
min-width: 80px;
z-index: 2;
.avatar-placeholder {
width: 54px;
height: 54px;
border-radius: 18px;
background: var(--bg-tertiary);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
color: var(--ar-text-sub);
font-size: 16px;
border: 1.5px solid rgba(255, 255, 255, 0.15);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.count {
font-size: 11px;
font-weight: 500;
opacity: 0.4;
color: var(--ar-text-sub);
}
.percent {
font-size: 14px;
color: var(--ar-text-main);
font-weight: 800;
opacity: 0.9;
}
}
.initiative-progress {
flex: 1;
height: 1px; // 线条样式
position: relative;
display: flex;
align-items: center;
.line-bg {
position: absolute;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg,
transparent 0%,
rgba(255, 255, 255, 0.1) 20%,
rgba(255, 255, 255, 0.1) 80%,
transparent 100%);
}
.initiative-indicator {
position: absolute;
width: 8px;
height: 8px;
background: #fff;
border-radius: 50%;
transform: translateX(-50%);
transition: left 1.5s cubic-bezier(0.16, 1, 0.3, 1);
box-shadow:
0 0 10px #fff,
0 0 20px rgba(255, 255, 255, 0.5),
0 0 30px var(--ar-primary);
z-index: 3;
&::before {
content: '';
position: absolute;
top: -4px;
left: -4px;
right: -4px;
bottom: -4px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 50%;
animation: pulse 2s infinite;
}
}
}
.initiative-desc {
text-align: center;
font-size: 14px;
color: var(--ar-text-sub);
letter-spacing: 1px;
opacity: 0.6;
background: transparent;
padding: 0;
margin: 0 auto;
font-style: italic;
}
@keyframes pulse {
0% {
transform: scale(1);
opacity: 0.8;
}
100% {
transform: scale(2);
opacity: 0;
}
}
.response-pulse-container {
width: 100%;
padding: 80px 0;
display: flex;
justify-content: center;
}
.pulse-visual {
position: relative;
width: 420px;
height: 240px;
display: flex;
align-items: center;
justify-content: center;
}
.pulse-hub {
position: relative;
z-index: 5;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 160px;
height: 160px;
background: radial-gradient(circle at center, rgba(255, 255, 255, 0.12) 0%, transparent 75%);
border-radius: 50%;
box-shadow: 0 0 40px rgba(255, 255, 255, 0.1);
.label {
font-size: 13px;
color: var(--ar-text-sub);
opacity: 0.6;
margin-bottom: 6px;
letter-spacing: 2px;
}
.value {
font-size: 54px;
font-weight: 950;
color: #fff;
line-height: 1;
text-shadow: 0 0 30px rgba(255, 255, 255, 0.5);
span {
font-size: 18px;
font-weight: 500;
margin-left: 4px;
opacity: 0.7;
}
}
}
.pulse-node {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
z-index: 4;
animation: floatNode 4s ease-in-out infinite;
&.left {
left: 0;
transform: translateX(-15%);
}
&.right {
right: 0;
transform: translateX(15%);
animation-delay: -2s;
}
.label {
font-size: 12px;
color: var(--ar-text-sub);
opacity: 0.5;
margin-bottom: 4px;
}
.value {
font-size: 24px;
font-weight: 800;
color: var(--ar-text-main);
opacity: 0.95;
span {
font-size: 13px;
margin-left: 2px;
opacity: 0.6;
}
}
}
.pulse-ripple {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border: 1.5px solid rgba(255, 255, 255, 0.08);
border-radius: 50%;
animation: ripplePulse 8s linear infinite;
pointer-events: none;
&.one {
animation-delay: 0s;
}
&.two {
animation-delay: 2.5s;
}
&.three {
animation-delay: 5s;
}
}
@keyframes ripplePulse {
0% {
width: 140px;
height: 140px;
opacity: 0.5;
}
100% {
width: 700px;
height: 700px;
opacity: 0;
}
}
@keyframes floatNode {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-16px);
}
}
.response-note {
text-align: center;
font-size: 14px;
color: var(--ar-text-sub);
opacity: 0.5;
margin-top: 32px;
font-style: italic;
max-width: none;
line-height: 1.6;
}
.streak-spark-visual.premium {
width: 100%;
height: 400px;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin: 20px 0;
overflow: visible;
.spark-ambient-glow {
position: absolute;
top: 40%;
left: 50%;
transform: translate(-50%, -50%);
width: 600px;
height: 480px;
background: radial-gradient(circle at center, rgba(242, 170, 0, 0.04) 0%, transparent 70%);
filter: blur(60px);
z-index: 1;
pointer-events: none;
}
}
.spark-core-wrapper {
position: relative;
width: 220px;
height: 280px;
display: flex;
align-items: center;
justify-content: center;
z-index: 5;
animation: flameSway 6s ease-in-out infinite;
transform-origin: bottom center;
}
.spark-flame-outer {
position: absolute;
width: 100%;
height: 100%;
background: radial-gradient(ellipse at 50% 85%, rgba(242, 170, 0, 0.15) 0%, transparent 75%);
border-radius: 50% 50% 20% 20% / 80% 80% 30% 30%;
filter: blur(25px);
animation: flickerOuter 4s infinite alternate;
}
.spark-flame-inner {
position: absolute;
bottom: 20%;
width: 140px;
height: 180px;
background: radial-gradient(ellipse at 50% 90%, rgba(255, 215, 0, 0.2) 0%, transparent 80%);
border-radius: 50% 50% 30% 30% / 85% 85% 25% 25%;
filter: blur(12px);
animation: flickerInner 3s infinite alternate-reverse;
}
.spark-core {
position: relative;
z-index: 10;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-bottom: 20px;
.spark-days {
font-size: 84px;
font-weight: 800;
color: rgba(255, 255, 255, 0.9);
line-height: 1;
letter-spacing: -1px;
text-shadow:
0 0 15px rgba(255, 255, 255, 0.4),
0 8px 30px rgba(0, 0, 0, 0.3);
}
.spark-label {
font-size: 14px;
font-weight: 800;
color: rgba(255, 255, 255, 0.4);
letter-spacing: 6px;
margin-top: 12px;
text-indent: 6px;
}
}
.streak-bridge.premium {
width: 100%;
max-width: 500px;
display: flex;
align-items: center;
gap: 0;
margin-top: -20px;
z-index: 20;
.bridge-date {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
width: 100px;
span {
font-size: 13px;
color: var(--ar-text-sub);
opacity: 0.6;
font-weight: 500;
letter-spacing: 0.2px;
position: absolute;
top: 24px;
white-space: nowrap;
}
.date-orb {
width: 6px;
height: 6px;
background: #fff;
border-radius: 50%;
box-shadow: 0 0 12px var(--ar-accent);
border: 1px solid rgba(252, 170, 0, 0.5);
}
}
.bridge-line {
flex: 1;
height: 40px;
position: relative;
display: flex;
align-items: center;
.line-string {
width: 100%;
height: 1.5px;
background: linear-gradient(90deg,
rgba(242, 170, 0, 0) 0%,
rgba(242, 170, 0, 0.6) 20%,
rgba(242, 170, 0, 0.6) 80%,
rgba(242, 170, 0, 0) 100%);
mask-image: radial-gradient(ellipse at center, black 60%, transparent 100%);
}
.line-glow {
position: absolute;
width: 100%;
height: 8px;
background: radial-gradient(ellipse at center, rgba(242, 170, 0, 0.2) 0%, transparent 80%);
filter: blur(4px);
animation: sparkFlicker 2s infinite alternate;
}
}
}
.spark-ember {
position: absolute;
background: #FFD700;
border-radius: 50%;
filter: blur(0.5px);
box-shadow: 0 0 6px #F2AA00;
opacity: 0;
z-index: 4;
&.one {
width: 3px;
height: 3px;
left: 46%;
animation: emberRise 5s infinite 0s;
}
&.two {
width: 2px;
height: 2px;
left: 53%;
animation: emberRise 4s infinite 1.2s;
}
&.three {
width: 4px;
height: 4px;
left: 50%;
animation: emberRise 6s infinite 2.5s;
}
&.four {
width: 2.5px;
height: 2.5px;
left: 48%;
animation: emberRise 5.5s infinite 3.8s;
}
}
@keyframes flameSway {
0%,
100% {
transform: rotate(-1deg) skewX(-1deg);
}
50% {
transform: rotate(1.5deg) skewX(1deg);
}
}
@keyframes flickerOuter {
0%,
100% {
opacity: 0.15;
filter: blur(25px);
}
50% {
opacity: 0.25;
filter: blur(30px);
}
}
@keyframes flickerInner {
0%,
100% {
transform: scale(1);
opacity: 0.2;
}
50% {
transform: scale(1.08);
opacity: 0.3;
}
}
@keyframes emberRise {
0% {
transform: translateY(100px) scale(1);
opacity: 0;
}
20% {
opacity: 0.8;
}
80% {
opacity: 0.3;
}
100% {
transform: translateY(-260px) scale(0.4);
opacity: 0;
}
}
@keyframes sparkFlicker {
0%,
100% {
transform: scale(1);
opacity: 0.9;
filter: brightness(1);
}
50% {
transform: scale(1.03);
opacity: 1;
filter: brightness(1.2);
}
}
@media (max-width: 960px) {
.pulse-visual {
transform: scale(0.85);
}
.scene-avatar {
width: 36px;
height: 36px;
font-size: 13px;
}
.scene-content-wrapper {
max-width: min(86%, 500px);
}
.scene-bubble {
max-width: 100%;
min-width: 56px;
}
}
// Word Cloud Tabs
.word-cloud-section {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
.word-cloud-tabs {
display: flex;
gap: 8px;
background: rgba(255, 255, 255, 0.08);
padding: 4px;
border-radius: 12px;
margin: 0 auto 32px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.tab-item {
padding: 8px 16px;
border-radius: 8px;
border: none;
background: transparent;
color: var(--ar-text-sub);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
&:hover {
color: var(--ar-text-main);
background: rgba(255, 255, 255, 0.05);
}
&.active {
background: var(--ar-card-bg);
color: var(--ar-primary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
font-weight: 600;
}
}
.word-cloud-container {
width: 100%;
&.fade-in {
animation: fadeIn 0.4s ease-out;
}
}
.empty-state {
text-align: center;
padding: 40px 0;
color: var(--ar-text-sub);
opacity: 0.6;
font-size: 14px;
background: rgba(255, 255, 255, 0.03);
border-radius: 16px;
border: 1px dashed rgba(255, 255, 255, 0.1);
margin-top: 20px;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
}

View File

@@ -1,4 +1,6 @@
import { useEffect, useState, type CSSProperties } from 'react'
import { useEffect, useState } from 'react'
import ReportHeatmap from '../components/ReportHeatmap'
import ReportWordCloud from '../components/ReportWordCloud'
import './AnnualReportWindow.scss'
import './DualReportWindow.scss'
@@ -7,19 +9,27 @@ interface DualReportMessage {
isSentByMe: boolean
createTime: number
createTimeStr: string
localType?: number
emojiMd5?: string
emojiCdnUrl?: string
}
interface DualReportData {
year: number
selfName: string
selfAvatarUrl?: string
friendUsername: string
friendName: string
friendAvatarUrl?: string
firstChat: {
createTime: number
createTimeStr: string
content: string
isSentByMe: boolean
senderUsername?: string
localType?: number
emojiMd5?: string
emojiCdnUrl?: string
} | null
firstChatMessages?: DualReportMessage[]
yearFirstChat?: {
@@ -29,6 +39,9 @@ interface DualReportData {
isSentByMe: boolean
friendName: string
firstThreeMessages: DualReportMessage[]
localType?: number
emojiMd5?: string
emojiCdnUrl?: string
} | null
stats: {
totalMessages: number
@@ -40,111 +53,17 @@ interface DualReportData {
friendTopEmojiMd5?: string
myTopEmojiUrl?: string
friendTopEmojiUrl?: string
myTopEmojiCount?: number
friendTopEmojiCount?: number
}
topPhrases: Array<{ phrase: string; count: number }>
}
const WordCloud = ({ words }: { words: { phrase: string; count: number }[] }) => {
if (!words || words.length === 0) {
return <div className="word-cloud-empty"></div>
}
const sortedWords = [...words].sort((a, b) => b.count - a.count)
const maxCount = sortedWords.length > 0 ? sortedWords[0].count : 1
const topWords = sortedWords.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 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>
)
myExclusivePhrases: Array<{ phrase: string; count: number }>
friendExclusivePhrases: Array<{ phrase: string; count: number }>
heatmap?: number[][]
initiative?: { initiated: number; received: number }
response?: { avg: number; fastest: number; slowest: number; count: number }
monthly?: Record<string, number>
streak?: { days: number; startDate: string; endDate: string }
}
function DualReportWindow() {
@@ -155,6 +74,7 @@ function DualReportWindow() {
const [loadingProgress, setLoadingProgress] = useState(0)
const [myEmojiUrl, setMyEmojiUrl] = useState<string | null>(null)
const [friendEmojiUrl, setFriendEmojiUrl] = useState<string | null>(null)
const [activeWordCloudTab, setActiveWordCloudTab] = useState<'shared' | 'my' | 'friend'>('shared')
useEffect(() => {
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
@@ -187,7 +107,16 @@ function DualReportWindow() {
setLoadingStage('完成')
if (result.success && result.data) {
setReportData(result.data)
const normalizedResponse = result.data.response
? {
...result.data.response,
slowest: result.data.response.slowest ?? result.data.response.avg
}
: undefined
setReportData({
...result.data,
response: normalizedResponse
})
setIsLoading(false)
} else {
setError(result.error || '生成报告失败')
@@ -203,6 +132,8 @@ function DualReportWindow() {
useEffect(() => {
const loadEmojis = async () => {
if (!reportData) return
setMyEmojiUrl(null)
setFriendEmojiUrl(null)
const stats = reportData.stats
if (stats.myTopEmojiUrl) {
const res = await window.electronAPI.chat.downloadEmoji(stats.myTopEmojiUrl, stats.myTopEmojiMd5)
@@ -273,12 +204,15 @@ function DualReportWindow() {
: null
const yearFirstChat = reportData.yearFirstChat
const stats = reportData.stats
const initiativeTotal = (reportData.initiative?.initiated || 0) + (reportData.initiative?.received || 0)
const initiatedPercent = initiativeTotal > 0 ? (reportData.initiative!.initiated / initiativeTotal) * 100 : 0
const receivedPercent = initiativeTotal > 0 ? (reportData.initiative!.received / initiativeTotal) * 100 : 0
const statItems = [
{ label: '总消息数', value: stats.totalMessages },
{ label: '总字数', value: stats.totalWords },
{ label: '图片', value: stats.imageCount },
{ label: '语音', value: stats.voiceCount },
{ label: '表情', value: stats.emojiCount },
{ label: '总消息数', value: stats.totalMessages, color: '#07C160' },
{ label: '总字数', value: stats.totalWords, color: '#10AEFF' },
{ label: '图片', value: stats.imageCount, color: '#FFC300' },
{ label: '语音', value: stats.voiceCount, color: '#FA5151' },
{ label: '表情', value: stats.emojiCount, color: '#FA9D3B' },
]
const decodeEntities = (text: string) => (
@@ -290,7 +224,28 @@ function DualReportWindow() {
.replace(/&apos;/g, "'")
)
const filterDisplayMessages = (messages: DualReportMessage[], maxActual: number = 3) => {
let actualCount = 0
const result: DualReportMessage[] = []
for (const msg of messages) {
const isSystem = msg.localType === 10000 || msg.localType === 10002
if (!isSystem) {
if (actualCount >= maxActual) break
actualCount++
}
result.push(msg)
}
return result
}
const stripCdata = (text: string) => text.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
const compactMessageText = (text: string) => (
text
.replace(/\r\n/g, '\n')
.replace(/\s*\n+\s*/g, ' ')
.replace(/\s{2,}/g, ' ')
.trim()
)
const extractXmlText = (content: string) => {
const titleMatch = content.match(/<title>([\s\S]*?)<\/title>/i)
@@ -304,16 +259,62 @@ function DualReportWindow() {
return ''
}
const formatMessageContent = (content?: string) => {
const raw = String(content || '').trim()
const formatMessageContent = (content?: string, localType?: number) => {
const isSystemMsg = localType === 10000 || localType === 10002
if (!isSystemMsg) {
if (localType === 3) return '[图片]'
if (localType === 34) return '[语音]'
if (localType === 43) return '[视频]'
if (localType === 47) return '[表情]'
if (localType === 42) return '[名片]'
if (localType === 48) return '[位置]'
if (localType === 49) return '[链接/文件]'
}
const raw = compactMessageText(String(content || '').trim())
if (!raw) return '(空)'
// 1. 尝试提取 XML 关键字段
const titleMatch = raw.match(/<title>([\s\S]*?)<\/title>/i)
if (titleMatch?.[1]) return compactMessageText(decodeEntities(stripCdata(titleMatch[1]).trim()))
const descMatch = raw.match(/<des>([\s\S]*?)<\/des>/i)
if (descMatch?.[1]) return compactMessageText(decodeEntities(stripCdata(descMatch[1]).trim()))
const summaryMatch = raw.match(/<summary>([\s\S]*?)<\/summary>/i)
if (summaryMatch?.[1]) return compactMessageText(decodeEntities(stripCdata(summaryMatch[1]).trim()))
// 2. 检查是否是 XML 结构
const hasXmlTag = /<\s*[a-zA-Z]+[^>]*>/.test(raw)
const looksLikeXml = /<\?xml|<msg\b|<appmsg\b|<sysmsg\b|<appattach\b|<emoji\b|<img\b|<voip\b/i.test(raw)
|| hasXmlTag
const looksLikeXml = /<\?xml|<msg\b|<appmsg\b|<sysmsg\b|<appattach\b|<emoji\b|<img\b|<voip\b/i.test(raw) || hasXmlTag
if (!looksLikeXml) return raw
const extracted = extractXmlText(raw)
if (!extracted) return 'XML消息'
return decodeEntities(stripCdata(extracted).trim()) || 'XML消息'
// 3. 最后的尝试:移除所有 XML 标签,看是否还有有意义的文本
const stripped = raw.replace(/<[^>]+>/g, '').trim()
if (stripped && stripped.length > 0 && stripped.length < 50) {
return compactMessageText(decodeEntities(stripped))
}
return '[多媒体消息]'
}
const ReportMessageItem = ({ msg }: { msg: DualReportMessage }) => {
if (msg.localType === 47 && (msg.emojiMd5 || msg.emojiCdnUrl)) {
const emojiUrl = msg.emojiCdnUrl || (msg.emojiMd5 ? `https://emoji.qpic.cn/wx_emoji/${msg.emojiMd5}/0` : '')
if (emojiUrl) {
return (
<div className="report-emoji-container">
<img src={emojiUrl} alt="表情" className="report-emoji-img" onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
(e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style');
}} />
<span style={{ display: 'none' }}>[]</span>
</div>
)
}
}
return <span>{formatMessageContent(msg.content, msg.localType)}</span>
}
const formatFullDate = (timestamp: number) => {
const d = new Date(timestamp)
@@ -325,6 +326,87 @@ function DualReportWindow() {
return `${year}/${month}/${day} ${hour}:${minute}`
}
const getMostActiveTime = (data: number[][]) => {
let maxHour = 0
let maxWeekday = 0
let maxVal = -1
data.forEach((row, weekday) => {
row.forEach((value, hour) => {
if (value > maxVal) {
maxVal = value
maxHour = hour
maxWeekday = weekday
}
})
})
const weekdayNames = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
return {
weekday: weekdayNames[maxWeekday] || '周一',
hour: maxHour,
value: Math.max(0, maxVal)
}
}
const mostActive = reportData.heatmap ? getMostActiveTime(reportData.heatmap) : null
const responseAvgMinutes = reportData.response ? Math.max(0, Math.round(reportData.response.avg / 60)) : 0
const getSceneAvatarUrl = (isSentByMe: boolean) => (isSentByMe ? reportData.selfAvatarUrl : reportData.friendAvatarUrl)
const getSceneAvatarFallback = (isSentByMe: boolean) => (isSentByMe ? '我' : reportData.friendName.substring(0, 1))
const renderSceneAvatar = (isSentByMe: boolean) => {
const avatarUrl = getSceneAvatarUrl(isSentByMe)
if (avatarUrl) {
return (
<div className="scene-avatar with-image">
<img src={avatarUrl} alt={isSentByMe ? 'me-avatar' : 'friend-avatar'} />
</div>
)
}
return <div className="scene-avatar fallback">{getSceneAvatarFallback(isSentByMe)}</div>
}
const renderMessageList = (messages: DualReportMessage[]) => {
const displayMsgs = filterDisplayMessages(messages)
let lastTime = 0
const TIME_THRESHOLD = 5 * 60 * 1000 // 5 分钟
return displayMsgs.map((msg, idx) => {
const isSystem = msg.localType === 10000 || msg.localType === 10002
const showTime = idx === 0 || (msg.createTime - lastTime > TIME_THRESHOLD)
lastTime = msg.createTime
if (isSystem) {
return (
<div key={idx} className="scene-message system">
{showTime && (
<div className="scene-meta">
{formatFullDate(msg.createTime).split(' ')[1]}
</div>
)}
<div className="system-msg-content">
<ReportMessageItem msg={msg} />
</div>
</div>
)
}
return (
<div key={idx} className={`scene-message ${msg.isSentByMe ? 'sent' : 'received'}`}>
{showTime && (
<div className="scene-meta">
{formatFullDate(msg.createTime).split(' ')[1]}
</div>
)}
<div className="scene-body">
{renderSceneAvatar(msg.isSentByMe)}
<div className="scene-content-wrapper">
<div className={`scene-bubble ${msg.localType === 47 ? 'no-bubble' : ''}`}>
<div className="scene-content"><ReportMessageItem msg={msg} /></div>
</div>
</div>
</div>
</div>
)
})
}
return (
<div className="annual-report-window dual-report-window">
<div className="drag-region" />
@@ -344,7 +426,7 @@ function DualReportWindow() {
<h1 className="hero-title dual-cover-title">{yearTitle}<br /></h1>
<hr className="divider" />
<div className="dual-names">
<span>{reportData.selfName}</span>
<span></span>
<span className="amp">&amp;</span>
<span>{reportData.friendName}</span>
</div>
@@ -355,105 +437,255 @@ function DualReportWindow() {
<div className="label-text"></div>
<h2 className="hero-title"></h2>
{firstChat ? (
<>
<div className="dual-info-grid">
<div className="dual-info-card">
<div className="info-label"></div>
<div className="info-value">{formatFullDate(firstChat.createTime)}</div>
</div>
<div className="dual-info-card">
<div className="info-label"></div>
<div className="info-value">{daysSince} </div>
</div>
</div>
<div className="first-chat-scene">
<div className="scene-title"></div>
<div className="scene-subtitle">{formatFullDate(firstChat.createTime).split(' ')[0]}</div>
{firstChatMessages.length > 0 ? (
<div className="dual-message-list">
{firstChatMessages.map((msg, idx) => (
<div
key={idx}
className={`dual-message ${msg.isSentByMe ? 'sent' : 'received'}`}
>
<div className="message-meta">
{msg.isSentByMe ? reportData.selfName : reportData.friendName} · {formatFullDate(msg.createTime)}
</div>
<div className="message-content">{formatMessageContent(msg.content)}</div>
</div>
))}
<div className="scene-messages">
{renderMessageList(firstChatMessages)}
</div>
) : null}
</>
) : (
<div className="hero-desc" style={{ textAlign: 'center' }}></div>
)}
<div className="scene-footer" style={{ marginTop: '20px', textAlign: 'center', fontSize: '14px', opacity: 0.6 }}>
{daysSince}
</div>
</div>
) : (
<p className="hero-desc"></p>
)}
</section>
{yearFirstChat ? (
{yearFirstChat && (!firstChat || yearFirstChat.createTime !== firstChat.createTime) ? (
<section className="section">
<div className="label-text"></div>
<h2 className="hero-title">
{reportData.year === 0 ? '你们的第一段对话' : `${reportData.year}年的第一段对话`}
</h2>
<div className="dual-info-grid">
<div className="dual-info-card">
<div className="info-label"></div>
<div className="info-value">{formatFullDate(yearFirstChat.createTime)}</div>
<div className="first-chat-scene">
<div className="scene-title"></div>
<div className="scene-subtitle">{formatFullDate(yearFirstChat.createTime).split(' ')[0]}</div>
<div className="scene-messages">
{renderMessageList(yearFirstChat.firstThreeMessages)}
</div>
<div className="dual-info-card">
<div className="info-label"></div>
<div className="info-value">{yearFirstChat.isSentByMe ? reportData.selfName : reportData.friendName}</div>
</div>
</div>
<div className="dual-message-list">
{yearFirstChat.firstThreeMessages.map((msg, idx) => (
<div key={idx} className={`dual-message ${msg.isSentByMe ? 'sent' : 'received'}`}>
<div className="message-meta">
{msg.isSentByMe ? reportData.selfName : reportData.friendName} · {formatFullDate(msg.createTime)}
</div>
<div className="message-content">{formatMessageContent(msg.content)}</div>
</div>
))}
</div>
</section>
) : null}
<section className="section">
{reportData.heatmap && (
<section className="section">
<div className="label-text"></div>
<h2 className="hero-title"></h2>
{mostActive && (
<p className="hero-desc active-time dual-active-time">
<span className="hl">{mostActive.weekday} {String(mostActive.hour).padStart(2, '0')}:00</span> {mostActive.value}
</p>
)}
<ReportHeatmap data={reportData.heatmap} />
</section>
)}
{reportData.initiative && (
<section className="section">
<div className="label-text"></div>
<h2 className="hero-title"></h2>
<div className="initiative-container">
<div className="initiative-bar-wrapper">
<div className="initiative-side">
<div className="avatar-placeholder">
{reportData.selfAvatarUrl ? <img src={reportData.selfAvatarUrl} alt="me-avatar" /> : '我'}
</div>
<div className="count">{reportData.initiative.initiated}</div>
<div className="percent">{initiatedPercent.toFixed(1)}%</div>
</div>
<div className="initiative-progress">
<div className="line-bg" />
<div
className="initiative-indicator"
style={{ left: `${initiatedPercent}%` }}
/>
</div>
<div className="initiative-side">
<div className="avatar-placeholder">
{reportData.friendAvatarUrl ? <img src={reportData.friendAvatarUrl} alt="friend-avatar" /> : reportData.friendName.substring(0, 1)}
</div>
<div className="count">{reportData.initiative.received}</div>
<div className="percent">{receivedPercent.toFixed(1)}%</div>
</div>
</div>
<div className="initiative-desc">
{reportData.initiative.initiated > reportData.initiative.received ? '每一个话题都是你对TA的在意' : 'TA总是那个率先打破沉默的人'}
</div>
</div>
</section>
)}
{reportData.response && (
<section className="section">
<div className="label-text"></div>
<h2 className="hero-title"></h2>
<div className="response-pulse-container">
<div className="pulse-visual">
<div className="pulse-ripple one" />
<div className="pulse-ripple two" />
<div className="pulse-ripple three" />
<div className="pulse-node left">
<div className="label"></div>
<div className="value">{reportData.response.fastest}<span></span></div>
</div>
<div className="pulse-hub">
<div className="label"></div>
<div className="value">{Math.round(reportData.response.avg / 60)}<span></span></div>
</div>
<div className="pulse-node right">
<div className="label"></div>
<div className="value">
{reportData.response.slowest > 3600
? (reportData.response.slowest / 3600).toFixed(1)
: Math.round(reportData.response.slowest / 60)}
<span>{reportData.response.slowest > 3600 ? '时' : '分'}</span>
</div>
</div>
</div>
</div>
<p className="hero-desc response-note">
{`${reportData.response.count} 次互动中,平均约 ${responseAvgMinutes} 分钟,最快 ${reportData.response.fastest} 秒。`}
</p>
</section>
)}
{reportData.streak && (
<section className="section">
<div className="label-text"></div>
<h2 className="hero-title"></h2>
<div className="streak-spark-visual premium">
<div className="spark-ambient-glow" />
<div className="spark-ember one" />
<div className="spark-ember two" />
<div className="spark-ember three" />
<div className="spark-ember four" />
<div className="spark-core-wrapper">
<div className="spark-flame-outer" />
<div className="spark-flame-inner" />
<div className="spark-core">
<div className="spark-days">{reportData.streak.days}</div>
<div className="spark-label">DAYS</div>
</div>
</div>
<div className="streak-bridge premium">
<div className="bridge-date start">
<div className="date-orb" />
<span>{reportData.streak.startDate}</span>
</div>
<div className="bridge-line">
<div className="line-glow" />
<div className="line-string" />
</div>
<div className="bridge-date end">
<span>{reportData.streak.endDate}</span>
<div className="date-orb" />
</div>
</div>
</div>
</section>
)}
<section className="section word-cloud-section">
<div className="label-text"></div>
<h2 className="hero-title">{yearTitle}</h2>
<WordCloud words={reportData.topPhrases} />
<div className="word-cloud-tabs">
<button
className={`tab-item ${activeWordCloudTab === 'shared' ? 'active' : ''}`}
onClick={() => setActiveWordCloudTab('shared')}
>
</button>
<button
className={`tab-item ${activeWordCloudTab === 'my' ? 'active' : ''}`}
onClick={() => setActiveWordCloudTab('my')}
>
</button>
<button
className={`tab-item ${activeWordCloudTab === 'friend' ? 'active' : ''}`}
onClick={() => setActiveWordCloudTab('friend')}
>
TA的专属
</button>
</div>
<div className={`word-cloud-container fade-in ${activeWordCloudTab}`}>
{activeWordCloudTab === 'shared' && <ReportWordCloud words={reportData.topPhrases} />}
{activeWordCloudTab === 'my' && (
reportData.myExclusivePhrases && reportData.myExclusivePhrases.length > 0 ? (
<ReportWordCloud words={reportData.myExclusivePhrases} />
) : (
<div className="empty-state"></div>
)
)}
{activeWordCloudTab === 'friend' && (
reportData.friendExclusivePhrases && reportData.friendExclusivePhrases.length > 0 ? (
<ReportWordCloud words={reportData.friendExclusivePhrases} />
) : (
<div className="empty-state"></div>
)
)}
</div>
</section>
<section className="section">
<div className="label-text"></div>
<h2 className="hero-title">{yearTitle}</h2>
<div className="dual-stat-grid">
{statItems.map((item) => {
const valueText = item.value.toLocaleString()
const isLong = valueText.length > 7
return (
<div key={item.label} className={`dual-stat-card ${isLong ? 'long' : ''}`}>
<div className="stat-num">{valueText}</div>
<div className="stat-unit">{item.label}</div>
</div>
)
})}
{statItems.slice(0, 2).map((item) => (
<div key={item.label} className="dual-stat-card">
<div className="stat-num">{item.value.toLocaleString()}</div>
<div className="stat-unit">{item.label}</div>
</div>
))}
</div>
<div className="dual-stat-grid bottom">
{statItems.slice(2).map((item) => (
<div key={item.label} className="dual-stat-card">
<div className="stat-num small">{item.value.toLocaleString()}</div>
<div className="stat-unit">{item.label}</div>
</div>
))}
</div>
<div className="emoji-row">
<div className="emoji-card">
<div className="emoji-title"></div>
{myEmojiUrl ? (
<img src={myEmojiUrl} alt="my-emoji" />
) : (
<div className="emoji-placeholder">{stats.myTopEmojiMd5 || '暂无'}</div>
)}
<img src={myEmojiUrl} alt="my-emoji" onError={(e) => {
(e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style');
(e.target as HTMLImageElement).style.display = 'none';
}} />
) : null}
<div className="emoji-placeholder" style={myEmojiUrl ? { display: 'none' } : undefined}>
{stats.myTopEmojiMd5 || '暂无'}
</div>
<div className="emoji-count">{stats.myTopEmojiCount ? `${stats.myTopEmojiCount}` : '暂无统计'}</div>
</div>
<div className="emoji-card">
<div className="emoji-title">{reportData.friendName}</div>
{friendEmojiUrl ? (
<img src={friendEmojiUrl} alt="friend-emoji" />
) : (
<div className="emoji-placeholder">{stats.friendTopEmojiMd5 || '暂无'}</div>
)}
<img src={friendEmojiUrl} alt="friend-emoji" onError={(e) => {
(e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style');
(e.target as HTMLImageElement).style.display = 'none';
}} />
) : null}
<div className="emoji-placeholder" style={friendEmojiUrl ? { display: 'none' } : undefined}>
{stats.friendTopEmojiMd5 || '暂无'}
</div>
<div className="emoji-count">{stats.friendTopEmojiCount ? `${stats.friendTopEmojiCount}` : '暂无统计'}</div>
</div>
</div>
</section>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -777,6 +777,344 @@
}
}
.member-export-panel {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 0;
.member-export-empty {
padding: 20px;
border-radius: 12px;
background: var(--bg-tertiary);
color: var(--text-secondary);
text-align: center;
font-size: 14px;
}
.member-export-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.member-export-field {
display: flex;
flex-direction: column;
gap: 6px;
position: relative;
> span {
font-size: 12px;
color: var(--text-secondary);
}
}
.select-trigger {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border-color);
border-radius: 9999px;
background: var(--bg-primary);
color: var(--text-primary);
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: var(--text-tertiary);
}
&.open {
border-color: var(--primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent);
}
}
.select-value {
flex: 1;
min-width: 0;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.select-dropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
background: color-mix(in srgb, var(--bg-primary) 85%, var(--bg-secondary));
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 6px;
box-shadow: var(--shadow-md);
z-index: 30;
max-height: 280px;
overflow-y: auto;
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
}
.select-option {
width: 100%;
text-align: left;
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border: none;
border-radius: 10px;
background: transparent;
cursor: pointer;
transition: all 0.15s;
color: var(--text-primary);
font-size: 13px;
&:hover {
background: var(--bg-tertiary);
}
&.active {
background: color-mix(in srgb, var(--primary) 12%, transparent);
color: var(--primary);
}
}
.option-label {
font-weight: 500;
}
.option-desc {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.4;
}
.member-select-trigger-value {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.member-select-dropdown {
padding: 8px;
}
.member-select-search {
display: flex;
align-items: center;
gap: 8px;
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 7px 9px;
margin-bottom: 8px;
background: var(--bg-tertiary);
svg {
color: var(--text-tertiary);
flex-shrink: 0;
}
input {
flex: 1;
min-width: 0;
border: none;
background: transparent;
outline: none;
color: var(--text-primary);
font-size: 12px;
}
}
.member-select-options {
display: flex;
flex-direction: column;
gap: 4px;
}
.member-select-empty {
padding: 10px 8px;
text-align: center;
font-size: 12px;
color: var(--text-tertiary);
}
.member-select-option {
display: grid;
grid-template-columns: 28px 1fr;
gap: 8px;
align-items: center;
padding: 8px 10px;
.member-option-main {
display: block;
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.member-option-meta {
grid-column: 2 / 3;
font-size: 11px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&.active {
.member-option-main,
.member-option-meta {
color: var(--primary);
}
}
}
.member-export-folder {
grid-column: 1 / -1;
}
.member-export-folder-row {
display: flex;
gap: 8px;
input {
flex: 1;
border: 1px solid var(--border-color);
border-radius: 10px;
background: var(--bg-tertiary);
color: var(--text-primary);
font-size: 13px;
padding: 8px 10px;
outline: none;
}
button {
border: none;
border-radius: 10px;
background: var(--bg-tertiary);
color: var(--text-primary);
padding: 0 12px;
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
&:hover {
background: var(--bg-hover);
}
}
}
.member-export-options {
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 14px;
background: rgba(255, 255, 255, 0.05);
display: flex;
flex-direction: column;
gap: 12px;
}
.member-export-chip-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.chip-group-label {
font-size: 12px;
color: var(--text-secondary);
}
.member-export-chip-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.export-filter-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 10px;
cursor: pointer;
user-select: none;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
transition: all 0.2s ease;
white-space: nowrap;
&:hover {
background: var(--bg-hover);
border-color: var(--text-tertiary);
color: var(--text-primary);
transform: translateY(-1px);
}
&.active {
background: var(--primary-light);
border-color: var(--primary);
color: var(--primary);
}
&.disabled,
&:disabled {
opacity: 0.45;
cursor: not-allowed;
transform: none;
&:hover {
background: var(--bg-secondary);
border-color: var(--border-color);
color: var(--text-secondary);
}
}
}
.member-export-actions {
display: flex;
justify-content: flex-end;
}
.member-export-start-btn {
display: inline-flex;
align-items: center;
gap: 8px;
border: none;
border-radius: 10px;
background: var(--primary);
color: #fff;
padding: 10px 16px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
&:hover {
opacity: 0.9;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
.rankings-list {
display: flex;
flex-direction: column;

View File

@@ -1,8 +1,10 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, User, Medal, Search, X, ChevronLeft, Copy, Check, Download } from 'lucide-react'
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { useLocation } from 'react-router-dom'
import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, Medal, Search, X, ChevronLeft, Copy, Check, Download, ChevronDown } from 'lucide-react'
import { Avatar } from '../components/Avatar'
import ReactECharts from 'echarts-for-react'
import DateRangePicker from '../components/DateRangePicker'
import * as configService from '../services/config'
import './GroupAnalyticsPage.scss'
interface GroupChatInfo {
@@ -27,9 +29,29 @@ interface GroupMessageRank {
messageCount: number
}
type AnalysisFunction = 'members' | 'ranking' | 'activeHours' | 'mediaStats'
type AnalysisFunction = 'members' | 'memberExport' | 'ranking' | 'activeHours' | 'mediaStats'
type MemberExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone'
interface MemberMessageExportOptions {
format: MemberExportFormat
exportAvatars: boolean
exportMedia: boolean
exportImages: boolean
exportVoices: boolean
exportVideos: boolean
exportEmojis: boolean
exportVoiceAsText: boolean
displayNamePreference: 'group-nickname' | 'remark' | 'nickname'
}
interface MemberExportFormatOption {
value: MemberExportFormat
label: string
desc: string
}
function GroupAnalyticsPage() {
const location = useLocation()
const [groups, setGroups] = useState<GroupChatInfo[]>([])
const [filteredGroups, setFilteredGroups] = useState<GroupChatInfo[]>([])
const [isLoading, setIsLoading] = useState(true)
@@ -44,10 +66,31 @@ function GroupAnalyticsPage() {
const [mediaStats, setMediaStats] = useState<{ typeCounts: Array<{ type: number; name: string; count: number }>; total: number } | null>(null)
const [functionLoading, setFunctionLoading] = useState(false)
const [isExportingMembers, setIsExportingMembers] = useState(false)
const [isExportingMemberMessages, setIsExportingMemberMessages] = useState(false)
const [selectedExportMemberUsername, setSelectedExportMemberUsername] = useState('')
const [exportFolder, setExportFolder] = useState('')
const [memberExportOptions, setMemberExportOptions] = useState<MemberMessageExportOptions>({
format: 'excel',
exportAvatars: true,
exportMedia: false,
exportImages: true,
exportVoices: true,
exportVideos: true,
exportEmojis: true,
exportVoiceAsText: false,
displayNamePreference: 'remark'
})
// 成员详情弹框
const [selectedMember, setSelectedMember] = useState<GroupMember | null>(null)
const [copiedField, setCopiedField] = useState<string | null>(null)
const [showMemberSelect, setShowMemberSelect] = useState(false)
const [showFormatSelect, setShowFormatSelect] = useState(false)
const [showDisplayNameSelect, setShowDisplayNameSelect] = useState(false)
const [memberSearchKeyword, setMemberSearchKeyword] = useState('')
const memberSelectDropdownRef = useRef<HTMLDivElement>(null)
const formatDropdownRef = useRef<HTMLDivElement>(null)
const displayNameDropdownRef = useRef<HTMLDivElement>(null)
// 时间范围
const [startDate, setStartDate] = useState<string>('')
@@ -58,10 +101,103 @@ function GroupAnalyticsPage() {
const [sidebarWidth, setSidebarWidth] = useState(300)
const [isResizing, setIsResizing] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const preselectAppliedRef = useRef(false)
const preselectGroupIds = useMemo(() => {
const state = location.state as { preselectGroupIds?: unknown; preselectGroupId?: unknown } | null
const rawList = Array.isArray(state?.preselectGroupIds)
? state.preselectGroupIds
: (typeof state?.preselectGroupId === 'string' ? [state.preselectGroupId] : [])
return rawList
.filter((item): item is string => typeof item === 'string')
.map(item => item.trim())
.filter(Boolean)
}, [location.state])
const memberExportFormatOptions = useMemo<MemberExportFormatOption[]>(() => ([
{ value: 'excel', label: 'Excel', desc: '电子表格,适合统计分析' },
{ value: 'txt', label: 'TXT', desc: '纯文本,通用格式' },
{ value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' },
{ value: 'arkme-json', label: 'Arkme JSON', desc: '紧凑 JSON支持 sender 去重与关系统计' },
{ value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' },
{ value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' },
{ value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' },
{ value: 'weclone', label: 'WeClone CSV', desc: 'WeClone 兼容字段格式CSV' }
]), [])
const displayNameOptions = useMemo<Array<{
value: MemberMessageExportOptions['displayNamePreference']
label: string
desc: string
}>>(() => ([
{ value: 'group-nickname', label: '群昵称优先', desc: '仅群聊有效,私聊显示备注/昵称' },
{ value: 'remark', label: '备注优先', desc: '有备注显示备注,否则显示昵称' },
{ value: 'nickname', label: '微信昵称', desc: '始终显示微信昵称' }
]), [])
const selectedExportMember = useMemo(
() => members.find(member => member.username === selectedExportMemberUsername) || null,
[members, selectedExportMemberUsername]
)
const selectedFormatOption = useMemo(
() => memberExportFormatOptions.find(option => option.value === memberExportOptions.format) || memberExportFormatOptions[0],
[memberExportFormatOptions, memberExportOptions.format]
)
const selectedDisplayNameOption = useMemo(
() => displayNameOptions.find(option => option.value === memberExportOptions.displayNamePreference) || displayNameOptions[0],
[displayNameOptions, memberExportOptions.displayNamePreference]
)
const filteredMemberOptions = useMemo(() => {
const keyword = memberSearchKeyword.trim().toLowerCase()
if (!keyword) return members
return members.filter(member => {
const fields = [
member.username,
member.displayName,
member.nickname,
member.remark,
member.alias
]
return fields.some(field => String(field || '').toLowerCase().includes(keyword))
})
}, [memberSearchKeyword, members])
const loadExportPath = useCallback(async () => {
try {
const savedPath = await configService.getExportPath()
if (savedPath) {
setExportFolder(savedPath)
return
}
const downloadsPath = await window.electronAPI.app.getDownloadsPath()
setExportFolder(downloadsPath)
} catch (e) {
console.error('加载导出路径失败:', e)
}
}, [])
const loadGroups = useCallback(async () => {
setIsLoading(true)
try {
const result = await window.electronAPI.groupAnalytics.getGroupChats()
if (result.success && result.data) {
setGroups(result.data)
setFilteredGroups(result.data)
}
} catch (e) {
console.error(e)
} finally {
setIsLoading(false)
}
}, [])
useEffect(() => {
loadGroups()
}, [])
loadExportPath()
}, [loadGroups, loadExportPath])
useEffect(() => {
preselectAppliedRef.current = false
}, [location.key, preselectGroupIds])
useEffect(() => {
if (searchQuery) {
@@ -71,6 +207,48 @@ function GroupAnalyticsPage() {
}
}, [searchQuery, groups])
useEffect(() => {
if (members.length === 0) {
setSelectedExportMemberUsername('')
return
}
const exists = members.some(member => member.username === selectedExportMemberUsername)
if (!exists) {
setSelectedExportMemberUsername(members[0].username)
}
}, [members, selectedExportMemberUsername])
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node
if (showMemberSelect && memberSelectDropdownRef.current && !memberSelectDropdownRef.current.contains(target)) {
setShowMemberSelect(false)
}
if (showFormatSelect && formatDropdownRef.current && !formatDropdownRef.current.contains(target)) {
setShowFormatSelect(false)
}
if (showDisplayNameSelect && displayNameDropdownRef.current && !displayNameDropdownRef.current.contains(target)) {
setShowDisplayNameSelect(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showDisplayNameSelect, showFormatSelect, showMemberSelect])
useEffect(() => {
if (preselectAppliedRef.current) return
if (groups.length === 0 || preselectGroupIds.length === 0) return
const matchedGroup = groups.find(group => preselectGroupIds.includes(group.username))
preselectAppliedRef.current = true
if (matchedGroup) {
setSelectedGroup(matchedGroup)
setSelectedFunction(null)
setSearchQuery('')
}
}, [groups, preselectGroupIds])
// 拖动调整宽度
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
@@ -92,27 +270,12 @@ function GroupAnalyticsPage() {
// 日期范围变化时自动刷新
useEffect(() => {
if (dateRangeReady && selectedGroup && selectedFunction && selectedFunction !== 'members') {
if (dateRangeReady && selectedGroup && selectedFunction && selectedFunction !== 'members' && selectedFunction !== 'memberExport') {
setDateRangeReady(false)
loadFunctionData(selectedFunction)
}
}, [dateRangeReady])
const loadGroups = useCallback(async () => {
setIsLoading(true)
try {
const result = await window.electronAPI.groupAnalytics.getGroupChats()
if (result.success && result.data) {
setGroups(result.data)
setFilteredGroups(result.data)
}
} catch (e) {
console.error(e)
} finally {
setIsLoading(false)
}
}, [])
useEffect(() => {
const handleChange = () => {
setGroups([])
@@ -124,15 +287,21 @@ function GroupAnalyticsPage() {
setActiveHours({})
setMediaStats(null)
void loadGroups()
void loadExportPath()
}
window.addEventListener('wxid-changed', handleChange as EventListener)
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
}, [loadGroups])
}, [loadExportPath, loadGroups])
const handleGroupSelect = (group: GroupChatInfo) => {
if (selectedGroup?.username !== group.username) {
setSelectedGroup(group)
setSelectedFunction(null)
setSelectedExportMemberUsername('')
setMemberSearchKeyword('')
setShowMemberSelect(false)
setShowFormatSelect(false)
setShowDisplayNameSelect(false)
}
}
@@ -158,6 +327,11 @@ function GroupAnalyticsPage() {
if (result.success && result.data) setMembers(result.data)
break
}
case 'memberExport': {
const result = await window.electronAPI.groupAnalytics.getGroupMembers(selectedGroup.username)
if (result.success && result.data) setMembers(result.data)
break
}
case 'ranking': {
const result = await window.electronAPI.groupAnalytics.getGroupMessageRanking(selectedGroup.username, 20, startTime, endTime)
if (result.success && result.data) setRankings(result.data)
@@ -253,6 +427,7 @@ function GroupAnalyticsPage() {
}
const handleDateRangeComplete = () => {
if (selectedFunction === 'memberExport') return
setDateRangeReady(true)
}
@@ -290,6 +465,86 @@ function GroupAnalyticsPage() {
}
}
const handleMemberExportFormatChange = (format: MemberExportFormat) => {
setMemberExportOptions(prev => {
const next = { ...prev, format }
if (format === 'html') {
return {
...next,
exportMedia: true,
exportImages: true,
exportVoices: true,
exportVideos: true,
exportEmojis: true
}
}
return next
})
}
const handleChooseExportFolder = async () => {
try {
const result = await window.electronAPI.dialog.openDirectory({
title: '选择导出目录'
})
if (!result.canceled && result.filePaths.length > 0) {
setExportFolder(result.filePaths[0])
await configService.setExportPath(result.filePaths[0])
}
} catch (e) {
console.error('选择导出目录失败:', e)
alert(`选择导出目录失败:${String(e)}`)
}
}
const handleExportMemberMessages = async () => {
if (!selectedGroup || !selectedExportMemberUsername || !exportFolder || isExportingMemberMessages) return
const member = members.find(item => item.username === selectedExportMemberUsername)
if (!member) {
alert('请先选择成员')
return
}
setIsExportingMemberMessages(true)
try {
const hasDateRange = Boolean(startDate && endDate)
const result = await window.electronAPI.export.exportSessions(
[selectedGroup.username],
exportFolder,
{
format: memberExportOptions.format,
dateRange: hasDateRange
? {
start: Math.floor(new Date(startDate).getTime() / 1000),
end: Math.floor(new Date(`${endDate}T23:59:59`).getTime() / 1000)
}
: null,
exportAvatars: memberExportOptions.exportAvatars,
exportMedia: memberExportOptions.exportMedia,
exportImages: memberExportOptions.exportMedia && memberExportOptions.exportImages,
exportVoices: memberExportOptions.exportMedia && memberExportOptions.exportVoices,
exportVideos: memberExportOptions.exportMedia && memberExportOptions.exportVideos,
exportEmojis: memberExportOptions.exportMedia && memberExportOptions.exportEmojis,
exportVoiceAsText: memberExportOptions.exportVoiceAsText,
sessionLayout: memberExportOptions.exportMedia ? 'per-session' : 'shared',
displayNamePreference: memberExportOptions.displayNamePreference,
senderUsername: member.username,
fileNameSuffix: sanitizeFileName(member.displayName || member.username)
}
)
if (result.success && (result.successCount ?? 0) > 0) {
alert(`导出成功:${member.displayName || member.username}`)
} else {
alert(`导出失败:${result.error || '未知错误'}`)
}
} catch (e) {
console.error('导出成员消息失败:', e)
alert(`导出失败:${String(e)}`)
} finally {
setIsExportingMemberMessages(false)
}
}
const handleCopy = async (text: string, field: string) => {
try {
await navigator.clipboard.writeText(text)
@@ -446,6 +701,10 @@ function GroupAnalyticsPage() {
<Users size={32} />
<span></span>
</div>
<div className="function-card" onClick={() => handleFunctionSelect('memberExport')}>
<Download size={32} />
<span></span>
</div>
<div className="function-card" onClick={() => handleFunctionSelect('ranking')}>
<BarChart3 size={32} />
<span></span>
@@ -466,6 +725,7 @@ function GroupAnalyticsPage() {
const getFunctionTitle = () => {
switch (selectedFunction) {
case 'members': return '群成员查看'
case 'memberExport': return '成员消息导出'
case 'ranking': return '群聊发言排行'
case 'activeHours': return '群聊活跃时段'
case 'mediaStats': return '媒体内容统计'
@@ -521,6 +781,234 @@ function GroupAnalyticsPage() {
))}
</div>
)}
{selectedFunction === 'memberExport' && (
<div className="member-export-panel">
{members.length === 0 ? (
<div className="member-export-empty"></div>
) : (
<>
<div className="member-export-grid">
<div className="member-export-field" ref={memberSelectDropdownRef}>
<span></span>
<button
type="button"
className={`select-trigger ${showMemberSelect ? 'open' : ''}`}
onClick={() => {
setShowMemberSelect(prev => !prev)
setShowFormatSelect(false)
setShowDisplayNameSelect(false)
}}
>
<div className="member-select-trigger-value">
<Avatar
src={selectedExportMember?.avatarUrl}
name={selectedExportMember?.displayName || selectedExportMember?.username || '?'}
size={24}
/>
<span className="select-value">{selectedExportMember?.displayName || selectedExportMember?.username || '请选择成员'}</span>
</div>
<ChevronDown size={16} />
</button>
{showMemberSelect && (
<div className="select-dropdown member-select-dropdown">
<div className="member-select-search">
<Search size={14} />
<input
type="text"
value={memberSearchKeyword}
onChange={e => setMemberSearchKeyword(e.target.value)}
placeholder="搜索 wxid / 昵称 / 备注 / 微信号"
/>
</div>
<div className="member-select-options">
{filteredMemberOptions.length === 0 ? (
<div className="member-select-empty"></div>
) : (
filteredMemberOptions.map(member => (
<button
key={member.username}
type="button"
className={`select-option member-select-option ${selectedExportMemberUsername === member.username ? 'active' : ''}`}
onClick={() => {
setSelectedExportMemberUsername(member.username)
setShowMemberSelect(false)
}}
>
<Avatar src={member.avatarUrl} name={member.displayName} size={28} />
<span className="member-option-main">{member.displayName || member.username}</span>
<span className="member-option-meta">
wxid: {member.username}
{member.alias ? ` · 微信号: ${member.alias}` : ''}
{member.remark ? ` · 备注: ${member.remark}` : ''}
{member.nickname ? ` · 昵称: ${member.nickname}` : ''}
</span>
</button>
))
)}
</div>
</div>
)}
</div>
<div className="member-export-field" ref={formatDropdownRef}>
<span></span>
<button
type="button"
className={`select-trigger ${showFormatSelect ? 'open' : ''}`}
onClick={() => {
setShowFormatSelect(prev => !prev)
setShowMemberSelect(false)
setShowDisplayNameSelect(false)
}}
>
<span className="select-value">{selectedFormatOption.label}</span>
<ChevronDown size={16} />
</button>
{showFormatSelect && (
<div className="select-dropdown">
{memberExportFormatOptions.map(option => (
<button
key={option.value}
type="button"
className={`select-option ${memberExportOptions.format === option.value ? 'active' : ''}`}
onClick={() => {
handleMemberExportFormatChange(option.value)
setShowFormatSelect(false)
}}
>
<span className="option-label">{option.label}</span>
<span className="option-desc">{option.desc}</span>
</button>
))}
</div>
)}
</div>
<div className="member-export-field member-export-folder">
<span></span>
<div className="member-export-folder-row">
<input value={exportFolder} readOnly placeholder="请选择导出目录" />
<button type="button" onClick={handleChooseExportFolder}>
</button>
</div>
</div>
</div>
<div className="member-export-options">
<div className="member-export-chip-group">
<span className="chip-group-label"></span>
<button
type="button"
className={`export-filter-chip ${memberExportOptions.exportMedia ? 'active' : ''}`}
onClick={() => setMemberExportOptions(prev => ({ ...prev, exportMedia: !prev.exportMedia }))}
>
</button>
</div>
<div className="member-export-chip-group">
<span className="chip-group-label"></span>
<div className="member-export-chip-list">
<button
type="button"
className={`export-filter-chip ${memberExportOptions.exportImages ? 'active' : ''} ${!memberExportOptions.exportMedia ? 'disabled' : ''}`}
disabled={!memberExportOptions.exportMedia}
onClick={() => setMemberExportOptions(prev => ({ ...prev, exportImages: !prev.exportImages }))}
>
</button>
<button
type="button"
className={`export-filter-chip ${memberExportOptions.exportVoices ? 'active' : ''} ${!memberExportOptions.exportMedia ? 'disabled' : ''}`}
disabled={!memberExportOptions.exportMedia}
onClick={() => setMemberExportOptions(prev => ({ ...prev, exportVoices: !prev.exportVoices }))}
>
</button>
<button
type="button"
className={`export-filter-chip ${memberExportOptions.exportVideos ? 'active' : ''} ${!memberExportOptions.exportMedia ? 'disabled' : ''}`}
disabled={!memberExportOptions.exportMedia}
onClick={() => setMemberExportOptions(prev => ({ ...prev, exportVideos: !prev.exportVideos }))}
>
</button>
<button
type="button"
className={`export-filter-chip ${memberExportOptions.exportEmojis ? 'active' : ''} ${!memberExportOptions.exportMedia ? 'disabled' : ''}`}
disabled={!memberExportOptions.exportMedia}
onClick={() => setMemberExportOptions(prev => ({ ...prev, exportEmojis: !prev.exportEmojis }))}
>
</button>
</div>
</div>
<div className="member-export-chip-group">
<span className="chip-group-label"></span>
<div className="member-export-chip-list">
<button
type="button"
className={`export-filter-chip ${memberExportOptions.exportVoiceAsText ? 'active' : ''}`}
onClick={() => setMemberExportOptions(prev => ({ ...prev, exportVoiceAsText: !prev.exportVoiceAsText }))}
>
</button>
<button
type="button"
className={`export-filter-chip ${memberExportOptions.exportAvatars ? 'active' : ''}`}
onClick={() => setMemberExportOptions(prev => ({ ...prev, exportAvatars: !prev.exportAvatars }))}
>
</button>
</div>
</div>
<div className="member-export-field" ref={displayNameDropdownRef}>
<span></span>
<button
type="button"
className={`select-trigger ${showDisplayNameSelect ? 'open' : ''}`}
onClick={() => {
setShowDisplayNameSelect(prev => !prev)
setShowMemberSelect(false)
setShowFormatSelect(false)
}}
>
<span className="select-value">{selectedDisplayNameOption.label}</span>
<ChevronDown size={16} />
</button>
{showDisplayNameSelect && (
<div className="select-dropdown">
{displayNameOptions.map(option => (
<button
key={option.value}
type="button"
className={`select-option ${memberExportOptions.displayNamePreference === option.value ? 'active' : ''}`}
onClick={() => {
setMemberExportOptions(prev => ({ ...prev, displayNamePreference: option.value }))
setShowDisplayNameSelect(false)
}}
>
<span className="option-label">{option.label}</span>
<span className="option-desc">{option.desc}</span>
</button>
))}
</div>
)}
</div>
</div>
<div className="member-export-actions">
<button
className="member-export-start-btn"
onClick={handleExportMemberMessages}
disabled={isExportingMemberMessages || !selectedExportMemberUsername || !exportFolder}
>
{isExportingMemberMessages ? <Loader2 size={16} className="spin" /> : <Download size={16} />}
<span>{isExportingMemberMessages ? '导出中...' : '开始导出'}</span>
</button>
</div>
</>
)}
</div>
)}
{selectedFunction === 'ranking' && (
<div className="rankings-list">
{rankings.map((item, index) => (

View File

@@ -29,7 +29,7 @@
.blob-1 {
width: 400px;
height: 400px;
background: rgba(139, 115, 85, 0.25);
background: rgba(var(--primary-rgb), 0.25);
top: -100px;
left: -50px;
animation-duration: 25s;
@@ -38,7 +38,7 @@
.blob-2 {
width: 350px;
height: 350px;
background: rgba(139, 115, 85, 0.15);
background: rgba(var(--primary-rgb), 0.15);
bottom: -50px;
right: -50px;
animation-duration: 30s;
@@ -74,7 +74,7 @@
margin: 0 0 16px;
color: var(--text-primary);
letter-spacing: -2px;
background: linear-gradient(135deg, var(--text-primary) 0%, rgba(139, 115, 85, 0.8) 100%);
background: linear-gradient(135deg, var(--primary) 0%, rgba(var(--primary-rgb), 0.6) 100%);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;

View File

@@ -46,6 +46,18 @@
background: var(--bg-tertiary);
color: var(--text-primary);
}
&:disabled {
cursor: default;
opacity: 1;
}
&.live-play-btn {
&.active {
background: rgba(var(--primary-rgb, 76, 132, 255), 0.16);
color: var(--primary, #4c84ff);
}
}
}
.scale-text {
@@ -78,14 +90,40 @@
cursor: grabbing;
}
img {
.media-wrapper {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
will-change: transform;
}
img,
video {
display: block;
max-width: none;
max-height: none;
object-fit: contain;
will-change: transform;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
pointer-events: auto;
}
.live-video {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: fill;
pointer-events: none;
opacity: 0;
will-change: opacity;
transition: opacity 0.3s ease-in-out;
}
.live-video.visible {
opacity: 1;
}
}
}

View File

@@ -1,18 +1,26 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { useSearchParams } from 'react-router-dom'
import { ZoomIn, ZoomOut, RotateCw, RotateCcw } from 'lucide-react'
import { LivePhotoIcon } from '../components/LivePhotoIcon'
import './ImageWindow.scss'
export default function ImageWindow() {
const [searchParams] = useSearchParams()
const imagePath = searchParams.get('imagePath')
const liveVideoPath = searchParams.get('liveVideoPath')
const hasLiveVideo = !!liveVideoPath
const [isPlayingLive, setIsPlayingLive] = useState(false)
const [isVideoVisible, setIsVideoVisible] = useState(false)
const videoRef = useRef<HTMLVideoElement>(null)
const liveCleanupTimerRef = useRef<number | null>(null)
const [scale, setScale] = useState(1)
const [rotation, setRotation] = useState(0)
const [position, setPosition] = useState({ x: 0, y: 0 })
const [initialScale, setInitialScale] = useState(1)
const viewportRef = useRef<HTMLDivElement>(null)
// 使用 ref 存储拖动状态,避免闭包问题
const dragStateRef = useRef({
isDragging: false,
@@ -22,11 +30,49 @@ export default function ImageWindow() {
startPosY: 0
})
const clearLiveCleanupTimer = useCallback(() => {
if (liveCleanupTimerRef.current !== null) {
window.clearTimeout(liveCleanupTimerRef.current)
liveCleanupTimerRef.current = null
}
}, [])
const stopLivePlayback = useCallback((immediate = false) => {
clearLiveCleanupTimer()
setIsVideoVisible(false)
if (immediate) {
if (videoRef.current) {
videoRef.current.pause()
videoRef.current.currentTime = 0
}
setIsPlayingLive(false)
return
}
liveCleanupTimerRef.current = window.setTimeout(() => {
if (videoRef.current) {
videoRef.current.pause()
videoRef.current.currentTime = 0
}
setIsPlayingLive(false)
liveCleanupTimerRef.current = null
}, 300)
}, [clearLiveCleanupTimer])
const handlePlayLiveVideo = useCallback(() => {
if (!liveVideoPath || isPlayingLive) return
clearLiveCleanupTimer()
setIsPlayingLive(true)
setIsVideoVisible(false)
}, [clearLiveCleanupTimer, liveVideoPath, isPlayingLive])
const handleZoomIn = () => setScale(prev => Math.min(prev + 0.25, 10))
const handleZoomOut = () => setScale(prev => Math.max(prev - 0.25, 0.1))
const handleRotate = () => setRotation(prev => (prev + 90) % 360)
const handleRotateCcw = () => setRotation(prev => (prev - 90 + 360) % 360)
// 重置视图
const handleReset = useCallback(() => {
setScale(1)
@@ -39,7 +85,7 @@ export default function ImageWindow() {
const img = e.currentTarget
const naturalWidth = img.naturalWidth
const naturalHeight = img.naturalHeight
if (viewportRef.current) {
const viewportWidth = viewportRef.current.clientWidth * 0.9
const viewportHeight = viewportRef.current.clientHeight * 0.9
@@ -51,14 +97,37 @@ export default function ImageWindow() {
}
}, [])
// 视频挂载后再播放,避免点击瞬间 ref 尚未就绪导致丢播
useEffect(() => {
if (!isPlayingLive || !videoRef.current) return
const timer = window.setTimeout(() => {
const video = videoRef.current
if (!video || !isPlayingLive || !video.paused) return
video.currentTime = 0
void video.play().catch(() => {
stopLivePlayback(true)
})
}, 0)
return () => window.clearTimeout(timer)
}, [isPlayingLive, stopLivePlayback])
useEffect(() => {
return () => {
clearLiveCleanupTimer()
}
}, [clearLiveCleanupTimer])
// 使用原生事件监听器处理拖动
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!dragStateRef.current.isDragging) return
const dx = e.clientX - dragStateRef.current.startX
const dy = e.clientY - dragStateRef.current.startY
setPosition({
x: dragStateRef.current.startPosX + dx,
y: dragStateRef.current.startPosY + dy
@@ -82,7 +151,7 @@ export default function ImageWindow() {
const handleMouseDown = (e: React.MouseEvent) => {
if (e.button !== 0) return
e.preventDefault()
dragStateRef.current = {
isDragging: true,
startX: e.clientX,
@@ -106,15 +175,25 @@ export default function ImageWindow() {
// 快捷键支持
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') window.electronAPI.window.close()
if (e.key === 'Escape') {
if (isPlayingLive) {
stopLivePlayback(true)
return
}
window.electronAPI.window.close()
}
if (e.key === '=' || e.key === '+') handleZoomIn()
if (e.key === '-') handleZoomOut()
if (e.key === 'r' || e.key === 'R') handleRotate()
if (e.key === '0') handleReset()
if (e.key === ' ' && hasLiveVideo) {
e.preventDefault()
handlePlayLiveVideo()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [handleReset])
}, [handleReset, hasLiveVideo, handlePlayLiveVideo, isPlayingLive, stopLivePlayback])
if (!imagePath) {
return (
@@ -131,6 +210,20 @@ export default function ImageWindow() {
<div className="title-bar">
<div className="window-drag-area"></div>
<div className="title-bar-controls">
{hasLiveVideo && (
<>
<button
onClick={handlePlayLiveVideo}
title={isPlayingLive ? '正在播放实况' : '播放实况 (空格)'}
className={`live-play-btn ${isPlayingLive ? 'active' : ''}`}
disabled={isPlayingLive}
>
<LivePhotoIcon size={16} />
<span style={{ fontSize: 13, marginLeft: 4 }}>Live</span>
</button>
<div className="divider"></div>
</>
)}
<button onClick={handleZoomOut} title="缩小 (-)"><ZoomOut size={16} /></button>
<span className="scale-text">{Math.round(displayScale * 100)}%</span>
<button onClick={handleZoomIn} title="放大 (+)"><ZoomIn size={16} /></button>
@@ -140,22 +233,38 @@ export default function ImageWindow() {
</div>
</div>
<div
className="image-viewport"
<div
className="image-viewport"
ref={viewportRef}
onWheel={handleWheel}
onDoubleClick={handleDoubleClick}
onMouseDown={handleMouseDown}
>
<img
src={imagePath}
alt="Preview"
<div
className="media-wrapper"
style={{
transform: `translate(${position.x}px, ${position.y}px) scale(${displayScale}) rotate(${rotation}deg)`
}}
onLoad={handleImageLoad}
draggable={false}
/>
>
<img
src={imagePath}
alt="Preview"
onLoad={handleImageLoad}
draggable={false}
/>
{hasLiveVideo && isPlayingLive && (
<video
ref={videoRef}
src={liveVideoPath || ''}
className={`live-video ${isVideoVisible ? 'visible' : ''}`}
autoPlay
playsInline
preload="auto"
onPlaying={() => setIsVideoVisible(true)}
onEnded={() => stopLivePlayback(false)}
/>
)}
</div>
</div>
</div>
)

View File

@@ -651,14 +651,80 @@
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 6px;
margin-top: 10px;
padding: 12px 16px;
background: var(--bg-primary);
border-radius: 12px;
border: 1px solid var(--border-color);
transition: all 0.2s;
&:hover {
border-color: var(--text-tertiary);
background: color-mix(in srgb, var(--bg-primary) 98%, var(--primary));
}
}
.log-status {
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
}
/* Premium Switch Style */
.switch {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
flex-shrink: 0;
input {
opacity: 0;
width: 0;
height: 0;
&:checked+.switch-slider {
background-color: var(--primary);
box-shadow: 0 0 8px color-mix(in srgb, var(--primary) 30%, transparent);
&::before {
transform: translateX(18px);
background-color: white;
}
}
&:focus+.switch-slider {
box-shadow: 0 0 1px var(--primary);
}
}
.switch-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--bg-tertiary);
transition: .4s cubic-bezier(0.4, 0, 0.2, 1);
border-radius: 24px;
border: 1px solid var(--border-color);
&::before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 2px;
bottom: 2px;
background-color: var(--text-tertiary);
transition: .4s cubic-bezier(0.4, 0, 0.2, 1);
border-radius: 50%;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
}
}
.language-checkboxes {
display: flex;
flex-wrap: wrap;
@@ -1125,6 +1191,111 @@
}
}
// 通用弹窗覆盖层
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.2s ease;
}
// API 警告弹窗
.api-warning-modal {
width: 420px;
background: var(--bg-primary);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.2);
animation: slideUp 0.25s ease;
.modal-header {
display: flex;
align-items: center;
gap: 10px;
padding: 20px 24px;
border-bottom: 1px solid var(--border-color);
svg {
color: var(--warning, #f59e0b);
}
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
}
.modal-body {
padding: 20px 24px;
.warning-text {
margin: 0 0 16px;
font-size: 14px;
color: var(--text-secondary);
line-height: 1.6;
}
.warning-list {
display: flex;
flex-direction: column;
gap: 10px;
.warning-item {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
.bullet {
color: var(--warning, #f59e0b);
font-weight: bold;
}
}
}
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 16px 24px;
border-top: 1px solid var(--border-color);
background: var(--bg-secondary);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// 协议弹窗
.agreement-overlay {
@@ -1555,4 +1726,517 @@
min-height: 100px;
font-size: 13px;
color: var(--text-tertiary);
// Add styles for the new model cards
}
.setting-control.vertical.has-border {
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 16px;
background: var(--bg-primary);
}
.model-status-card {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
}
.model-info {
flex: 1;
min-width: 0;
.model-name {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 6px;
}
.model-path {
display: flex;
flex-direction: column;
gap: 4px;
.status-indicator {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 500;
&.success {
color: #10b981;
}
&.warning {
color: #f59e0b;
}
}
.path-text {
font-size: 12px;
color: var(--text-tertiary);
font-family: monospace;
word-break: break-all;
}
}
}
.model-actions {
flex-shrink: 0;
.btn-download {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: var(--primary);
color: white;
border: none;
border-radius: 999px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 4px 12px color-mix(in srgb, var(--primary) 25%, transparent);
&:hover {
background: var(--primary-hover);
transform: translateY(-1px);
box-shadow: 0 6px 16px color-mix(in srgb, var(--primary) 35%, transparent);
}
&:active {
transform: translateY(0);
}
svg {
flex-shrink: 0;
}
}
.download-status {
display: flex;
flex-direction: column;
gap: 6px;
width: 280px;
.status-header,
.progress-info {
// specific layout class
display: flex;
justify-content: space-between;
align-items: center; // Align vertically
width: 100%;
}
.percent {
font-size: 14px;
font-weight: 700;
color: var(--primary);
font-family: 'Inter', system-ui, sans-serif;
}
.metrics,
.details {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-secondary);
font-family: var(--font-mono);
.speed {
color: var(--text-primary);
font-weight: 600;
opacity: 0.8;
}
}
}
.progress-bar-mini {
width: 100%;
height: 6px;
background: var(--bg-tertiary);
border-radius: 3px;
overflow: hidden;
border: 1px solid var(--border-color);
.fill {
height: 100%;
background: linear-gradient(90deg, var(--primary) 0%, color-mix(in srgb, var(--primary) 80%, white) 100%);
border-radius: 3px;
transition: width 0.3s ease;
position: relative;
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(90deg,
transparent,
rgba(255, 255, 255, 0.2),
transparent);
animation: progress-shimmer 2s infinite;
}
}
}
.spin {
animation: spin 1s linear infinite;
}
}
@keyframes progress-shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
.sub-setting {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border-color);
.sub-label {
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 8px;
}
}
.path-selector {
display: flex;
gap: 8px;
input {
margin-bottom: 0 !important;
flex: 1;
font-family: monospace;
font-size: 12px;
}
.btn-icon {
width: 38px;
height: 38px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 999px; // Circle
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
&:hover {
color: var(--text-primary);
background: var(--bg-tertiary);
}
&.danger:hover {
color: var(--danger);
background: rgba(220, 38, 38, 0.1);
border-color: rgba(220, 38, 38, 0.2);
}
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
// API 地址显示样式
.api-url-display {
display: flex;
gap: 8px;
margin-top: 8px;
input {
flex: 1;
font-family: 'SF Mono', 'Consolas', monospace;
font-size: 13px;
}
.btn {
flex-shrink: 0;
}
}
// API 服务设置样式
.status-badge {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
&.running {
background: rgba(34, 197, 94, 0.15);
color: #22c55e;
}
&.stopped {
background: rgba(156, 163, 175, 0.15);
color: var(--text-tertiary);
}
}
.api-url {
display: inline-block;
padding: 8px 14px;
background: var(--bg-tertiary);
border-radius: 6px;
font-family: 'SF Mono', 'Consolas', monospace;
font-size: 13px;
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.api-docs {
display: flex;
flex-direction: column;
gap: 12px;
}
.api-item {
padding: 12px 16px;
background: var(--bg-tertiary);
border-radius: 8px;
border: 1px solid var(--border-color);
.api-endpoint {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 6px;
.method {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
&.get {
background: rgba(34, 197, 94, 0.15);
color: #22c55e;
}
&.post {
background: rgba(59, 130, 246, 0.15);
color: #3b82f6;
}
}
code {
font-family: 'SF Mono', 'Consolas', monospace;
font-size: 13px;
color: var(--text-primary);
}
}
.api-desc {
font-size: 12px;
color: var(--text-secondary);
margin: 0 0 8px 0;
}
.api-params {
display: flex;
flex-wrap: wrap;
gap: 6px;
.param {
display: inline-block;
padding: 2px 8px;
background: var(--bg-secondary);
border-radius: 4px;
font-size: 11px;
color: var(--text-tertiary);
code {
color: var(--primary);
font-family: 'SF Mono', 'Consolas', monospace;
}
}
}
}
.code-block {
padding: 12px 16px;
background: var(--bg-tertiary);
border-radius: 8px;
border: 1px solid var(--border-color);
overflow-x: auto;
code {
font-family: 'SF Mono', 'Consolas', monospace;
font-size: 12px;
color: var(--text-primary);
white-space: nowrap;
}
}
.btn-sm {
padding: 4px 10px !important;
font-size: 12px !important;
svg {
width: 14px;
height: 14px;
}
}
// Analysis Settings Styling
.settings-section {
h2 {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 16px;
}
}
.setting-item {
margin-bottom: 20px;
}
.setting-label {
display: flex;
flex-direction: column;
margin-bottom: 8px;
span:first-child {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
}
.setting-desc {
font-size: 13px;
color: var(--text-tertiary);
margin-top: 2px;
}
}
.setting-control {
display: flex;
// textarea specific
textarea.form-input {
width: 100%;
padding: 12px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 12px;
color: var(--text-primary);
font-family: monospace;
font-size: 13px;
resize: vertical;
transition: all 0.2s;
outline: none;
&:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 10%, transparent);
}
&::placeholder {
color: var(--text-tertiary);
}
}
.button-group {
display: flex;
gap: 12px;
width: 100%;
margin-top: 12px;
}
}
.brute-force-progress {
margin-top: 12px;
padding: 14px 16px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 12px;
animation: slideUp 0.3s ease;
.status-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
.status-text {
font-size: 13px;
color: var(--text-primary);
font-weight: 500;
margin: 0;
// 增加文字呼吸灯效果,表明正在运行
animation: pulse 2s ease-in-out infinite;
}
.percent {
font-size: 14px;
color: var(--primary);
font-weight: 700;
font-family: var(--font-mono);
}
}
.progress-bar-container {
width: 100%;
height: 8px;
background: var(--bg-primary);
border-radius: 4px;
overflow: hidden;
border: 1px solid var(--border-color);
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.05);
.fill {
height: 100%;
background: linear-gradient(90deg, var(--primary) 0%, color-mix(in srgb, var(--primary) 60%, white) 100%);
border-radius: 4px;
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
// 流光扫过的高亮特效
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.3),
transparent
);
animation: progress-shimmer 1.5s infinite linear;
}
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -803,3 +803,79 @@
opacity: 1;
}
}
.brute-force-progress {
margin-top: 16px;
padding: 14px 16px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 12px;
animation: slideUp 0.3s ease;
.status-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
.status-text {
font-size: 13px;
color: var(--text-primary);
font-weight: 500;
margin: 0;
animation: pulse 2s ease-in-out infinite;
}
.percent {
font-size: 14px;
color: var(--primary);
font-weight: 700;
font-family: var(--font-mono);
}
}
.progress-bar-container {
width: 100%;
height: 8px;
background: var(--bg-primary);
border-radius: 4px;
overflow: hidden;
border: 1px solid var(--border-color);
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.05);
.fill {
height: 100%;
background: linear-gradient(90deg, var(--primary) 0%, color-mix(in srgb, var(--primary) 60%, white) 100%);
border-radius: 4px;
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.3),
transparent
);
animation: progress-shimmer 1.5s infinite linear;
}
}
}
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
@keyframes progress-shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}

View File

@@ -23,6 +23,18 @@ interface WelcomePageProps {
standalone?: boolean
}
const formatDbKeyFailureMessage = (error?: string, logs?: string[]): string => {
const base = String(error || '自动获取密钥失败').trim()
const tailLogs = Array.isArray(logs)
? logs
.map(item => String(item || '').trim())
.filter(Boolean)
.slice(-6)
: []
if (tailLogs.length === 0) return base
return `${base};最近状态:${tailLogs.join(' | ')}`
}
function WelcomePage({ standalone = false }: WelcomePageProps) {
const navigate = useNavigate()
const { isDbConnected, setDbConnected, setLoading } = useAppStore()
@@ -48,6 +60,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
const [dbKeyStatus, setDbKeyStatus] = useState('')
const [imageKeyStatus, setImageKeyStatus] = useState('')
const [isManualStartPrompt, setIsManualStartPrompt] = useState(false)
const [imageKeyPercent, setImageKeyPercent] = useState<number | null>(null)
// 安全相关 state
const [enableAuth, setEnableAuth] = useState(false)
@@ -111,8 +124,25 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => {
setDbKeyStatus(payload.message)
})
const removeImage = window.electronAPI.key.onImageKeyStatus((payload: { message: string }) => {
setImageKeyStatus(payload.message)
const removeImage = window.electronAPI.key.onImageKeyStatus((payload: { message: string, percent?: number }) => {
let msg = payload.message;
let pct = payload.percent;
// 解析文本中的百分比
if (pct === undefined) {
const match = msg.match(/\(([\d.]+)%\)/);
if (match) {
pct = parseFloat(match[1]);
msg = msg.replace(/\s*\([\d.]+%\)/, '');
}
}
setImageKeyStatus(msg);
if (pct !== undefined) {
setImageKeyPercent(pct);
} else if (msg.includes('启动多核') || msg.includes('定位') || msg.includes('准备')) {
setImageKeyPercent(0);
}
})
return () => {
removeDb?.()
@@ -274,7 +304,10 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
setIsManualStartPrompt(true)
setDbKeyStatus('需要手动启动微信')
} else {
setError(result.error || '自动获取密钥失败')
if (result.error?.includes('尚未完成登录')) {
setDbKeyStatus('请先在微信完成登录后重试')
}
setError(formatDbKeyFailureMessage(result.error, result.logs))
}
}
} catch (e) {
@@ -291,21 +324,16 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
const handleAutoGetImageKey = async () => {
if (isFetchingImageKey) return
if (!dbPath) {
setError('请先选择数据库目录')
return
}
if (!dbPath) { setError('请先选择数据库目录'); return }
setIsFetchingImageKey(true)
setError('')
setImageKeyPercent(0)
setImageKeyStatus('正在准备获取图片密钥...')
try {
// 拼接完整的账号目录,确保 KeyService 能准确找到模板文件
const accountPath = wxid ? `${dbPath}/${wxid}` : dbPath
const result = await window.electronAPI.key.autoGetImageKey(accountPath)
const result = await window.electronAPI.key.autoGetImageKey(accountPath, wxid)
if (result.success && result.aesKey) {
if (typeof result.xorKey === 'number') {
setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`)
}
if (typeof result.xorKey === 'number') setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`)
setImageAesKey(result.aesKey)
setImageKeyStatus('已获取图片密钥')
} else {
@@ -318,6 +346,30 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
}
}
const handleScanImageKeyFromMemory = async () => {
if (isFetchingImageKey) return
if (!dbPath) { setError('请先选择数据库目录'); return }
setIsFetchingImageKey(true)
setError('')
setImageKeyPercent(0)
setImageKeyStatus('正在扫描内存...')
try {
const accountPath = wxid ? `${dbPath}/${wxid}` : dbPath
const result = await window.electronAPI.key.scanImageKeyFromMemory(accountPath)
if (result.success && result.aesKey) {
if (typeof result.xorKey === 'number') setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`)
setImageAesKey(result.aesKey)
setImageKeyStatus('内存扫描成功,已获取图片密钥')
} else {
setError(result.error || '内存扫描获取图片密钥失败')
}
} catch (e) {
setError(`内存扫描失败: ${e}`)
} finally {
setIsFetchingImageKey(false)
}
}
const canGoNext = () => {
if (currentStep.id === 'intro') return true
if (currentStep.id === 'db') return Boolean(dbPath)
@@ -728,35 +780,40 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
{currentStep.id === 'image' && (
<div className="form-group">
<div className="field-hint" style={{ color: '#f59e0b', marginBottom: '12px' }}>
使
</div>
<div className="grid-2">
<div>
<label className="field-label"> XOR </label>
<input
type="text"
className="field-input"
placeholder="0x..."
value={imageXorKey}
onChange={(e) => setImageXorKey(e.target.value)}
/>
<input type="text" className="field-input" placeholder="0x..." value={imageXorKey} onChange={(e) => setImageXorKey(e.target.value)} />
</div>
<div>
<label className="field-label"> AES </label>
<input
type="text"
className="field-input"
placeholder="16位密钥"
value={imageAesKey}
onChange={(e) => setImageAesKey(e.target.value)}
/>
<input type="text" className="field-input" placeholder="16位密钥" value={imageAesKey} onChange={(e) => setImageAesKey(e.target.value)} />
</div>
</div>
<button className="btn btn-secondary btn-block mt-4" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey}>
{isFetchingImageKey ? '扫描中...' : '自动获取图片密钥'}
</button>
<div style={{ display: 'flex', gap: '8px', marginTop: '16px' }}>
<button className="btn btn-secondary btn-block" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey} title="从本地缓存快速计算(可能不准确)">
{isFetchingImageKey ? '获取中...' : '快速获取(缓存计算)'}
</button>
<button className="btn btn-primary btn-block" onClick={handleScanImageKeyFromMemory} disabled={isFetchingImageKey} title="扫描微信进程内存,准确率更高,需要微信正在运行">
{isFetchingImageKey ? '扫描中...' : '内存扫描(推荐)'}
</button>
</div>
{imageKeyStatus && <div className="status-message">{imageKeyStatus}</div>}
<div className="field-hint"></div>
{isFetchingImageKey ? (
<div className="brute-force-progress">
<div className="status-header">
<span className="status-text">{imageKeyStatus || '正在启动...'}</span>
</div>
</div>
) : (
imageKeyStatus && <div className="status-message" style={{ marginTop: '12px' }}>{imageKeyStatus}</div>
)}
<div className="field-hint" style={{ marginTop: '8px' }}> 2-3 </div>
</div>
)}
</div>

View File

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

View File

@@ -12,6 +12,7 @@ export const CONFIG_KEYS = {
LAST_SESSION: 'lastSession',
WINDOW_BOUNDS: 'windowBounds',
CACHE_PATH: 'cachePath',
EXPORT_PATH: 'exportPath',
AGREEMENT_ACCEPTED: 'agreementAccepted',
LOG_ENABLED: 'logEnabled',
@@ -31,6 +32,19 @@ export const CONFIG_KEYS = {
EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns',
EXPORT_DEFAULT_TXT_COLUMNS: 'exportDefaultTxtColumns',
EXPORT_DEFAULT_CONCURRENCY: 'exportDefaultConcurrency',
EXPORT_WRITE_LAYOUT: 'exportWriteLayout',
EXPORT_SESSION_NAME_PREFIX_ENABLED: 'exportSessionNamePrefixEnabled',
EXPORT_LAST_SESSION_RUN_MAP: 'exportLastSessionRunMap',
EXPORT_LAST_CONTENT_RUN_MAP: 'exportLastContentRunMap',
EXPORT_SESSION_RECORD_MAP: 'exportSessionRecordMap',
EXPORT_LAST_SNS_POST_COUNT: 'exportLastSnsPostCount',
EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP: 'exportSessionMessageCountCacheMap',
EXPORT_SESSION_CONTENT_METRIC_CACHE_MAP: 'exportSessionContentMetricCacheMap',
EXPORT_SNS_STATS_CACHE_MAP: 'exportSnsStatsCacheMap',
SNS_PAGE_CACHE_MAP: 'snsPageCacheMap',
CONTACTS_LOAD_TIMEOUT_MS: 'contactsLoadTimeoutMs',
CONTACTS_LIST_CACHE_MAP: 'contactsListCacheMap',
CONTACTS_AVATAR_CACHE_MAP: 'contactsAvatarCacheMap',
// 安全
AUTH_ENABLED: 'authEnabled',
@@ -44,7 +58,14 @@ export const CONFIG_KEYS = {
NOTIFICATION_ENABLED: 'notificationEnabled',
NOTIFICATION_POSITION: 'notificationPosition',
NOTIFICATION_FILTER_MODE: 'notificationFilterMode',
NOTIFICATION_FILTER_LIST: 'notificationFilterList'
NOTIFICATION_FILTER_LIST: 'notificationFilterList',
// 词云
WORD_CLOUD_EXCLUDE_WORDS: 'wordCloudExcludeWords',
// 数据收集
ANALYTICS_CONSENT: 'analyticsConsent',
ANALYTICS_DENY_COUNT: 'analyticsDenyCount'
} as const
export interface WxidConfig {
@@ -114,13 +135,13 @@ export async function setWxidConfig(wxid: string, configValue: WxidConfig): Prom
}
// 获取主题
export async function getTheme(): Promise<'light' | 'dark'> {
export async function getTheme(): Promise<'light' | 'dark' | 'system'> {
const value = await config.get(CONFIG_KEYS.THEME)
return (value as 'light' | 'dark') || 'light'
return (value as 'light' | 'dark' | 'system') || 'light'
}
// 设置主题
export async function setTheme(theme: 'light' | 'dark'): Promise<void> {
export async function setTheme(theme: 'light' | 'dark' | 'system'): Promise<void> {
await config.set(CONFIG_KEYS.THEME, theme)
}
@@ -159,6 +180,8 @@ export async function setCachePath(path: string): Promise<void> {
}
// 获取导出路径
export async function getExportPath(): Promise<string | null> {
const value = await config.get(CONFIG_KEYS.EXPORT_PATH)
@@ -380,6 +403,594 @@ export async function setExportDefaultConcurrency(concurrency: number): Promise<
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_CONCURRENCY, concurrency)
}
export type ExportWriteLayout = 'A' | 'B' | 'C'
export async function getExportWriteLayout(): Promise<ExportWriteLayout> {
const value = await config.get(CONFIG_KEYS.EXPORT_WRITE_LAYOUT)
if (value === 'A' || value === 'B' || value === 'C') return value
return 'B'
}
export async function setExportWriteLayout(layout: ExportWriteLayout): Promise<void> {
await config.set(CONFIG_KEYS.EXPORT_WRITE_LAYOUT, layout)
}
export async function getExportSessionNamePrefixEnabled(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.EXPORT_SESSION_NAME_PREFIX_ENABLED)
if (typeof value === 'boolean') return value
return true
}
export async function setExportSessionNamePrefixEnabled(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.EXPORT_SESSION_NAME_PREFIX_ENABLED, enabled)
}
export async function getExportLastSessionRunMap(): Promise<Record<string, number>> {
const value = await config.get(CONFIG_KEYS.EXPORT_LAST_SESSION_RUN_MAP)
if (!value || typeof value !== 'object') return {}
const entries = Object.entries(value as Record<string, unknown>)
const map: Record<string, number> = {}
for (const [sessionId, raw] of entries) {
if (typeof raw === 'number' && Number.isFinite(raw)) {
map[sessionId] = raw
}
}
return map
}
export async function setExportLastSessionRunMap(map: Record<string, number>): Promise<void> {
await config.set(CONFIG_KEYS.EXPORT_LAST_SESSION_RUN_MAP, map)
}
export async function getExportLastContentRunMap(): Promise<Record<string, number>> {
const value = await config.get(CONFIG_KEYS.EXPORT_LAST_CONTENT_RUN_MAP)
if (!value || typeof value !== 'object') return {}
const entries = Object.entries(value as Record<string, unknown>)
const map: Record<string, number> = {}
for (const [key, raw] of entries) {
if (typeof raw === 'number' && Number.isFinite(raw)) {
map[key] = raw
}
}
return map
}
export async function setExportLastContentRunMap(map: Record<string, number>): Promise<void> {
await config.set(CONFIG_KEYS.EXPORT_LAST_CONTENT_RUN_MAP, map)
}
export interface ExportSessionRecordEntry {
exportTime: number
content: string
outputDir: string
}
export async function getExportSessionRecordMap(): Promise<Record<string, ExportSessionRecordEntry[]>> {
const value = await config.get(CONFIG_KEYS.EXPORT_SESSION_RECORD_MAP)
if (!value || typeof value !== 'object') return {}
const map: Record<string, ExportSessionRecordEntry[]> = {}
const entries = Object.entries(value as Record<string, unknown>)
for (const [sessionId, rawList] of entries) {
if (!Array.isArray(rawList)) continue
const normalizedList: ExportSessionRecordEntry[] = []
for (const rawItem of rawList) {
if (!rawItem || typeof rawItem !== 'object') continue
const exportTime = Number((rawItem as Record<string, unknown>).exportTime)
const content = String((rawItem as Record<string, unknown>).content || '').trim()
const outputDir = String((rawItem as Record<string, unknown>).outputDir || '').trim()
if (!Number.isFinite(exportTime) || exportTime <= 0) continue
if (!content || !outputDir) continue
normalizedList.push({
exportTime: Math.floor(exportTime),
content,
outputDir
})
}
if (normalizedList.length > 0) {
map[sessionId] = normalizedList
}
}
return map
}
export async function setExportSessionRecordMap(map: Record<string, ExportSessionRecordEntry[]>): Promise<void> {
await config.set(CONFIG_KEYS.EXPORT_SESSION_RECORD_MAP, map)
}
export async function getExportLastSnsPostCount(): Promise<number> {
const value = await config.get(CONFIG_KEYS.EXPORT_LAST_SNS_POST_COUNT)
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {
return Math.floor(value)
}
return 0
}
export async function setExportLastSnsPostCount(count: number): Promise<void> {
const normalized = Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0
await config.set(CONFIG_KEYS.EXPORT_LAST_SNS_POST_COUNT, normalized)
}
export interface ExportSessionMessageCountCacheItem {
updatedAt: number
counts: Record<string, number>
}
export interface ExportSessionContentMetricCacheEntry {
totalMessages?: number
voiceMessages?: number
imageMessages?: number
videoMessages?: number
emojiMessages?: number
}
export interface ExportSessionContentMetricCacheItem {
updatedAt: number
metrics: Record<string, ExportSessionContentMetricCacheEntry>
}
export interface ExportSnsStatsCacheItem {
updatedAt: number
totalPosts: number
totalFriends: number
}
export interface SnsPageOverviewCache {
totalPosts: number
totalFriends: number
myPosts: number | null
earliestTime: number | null
latestTime: number | null
}
export interface SnsPageCacheItem {
updatedAt: number
overviewStats: SnsPageOverviewCache
posts: unknown[]
}
export interface ContactsListCacheContact {
username: string
displayName: string
remark?: string
nickname?: string
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
}
export interface ContactsListCacheItem {
updatedAt: number
contacts: ContactsListCacheContact[]
}
export interface ContactsAvatarCacheEntry {
avatarUrl: string
updatedAt: number
checkedAt: number
}
export interface ContactsAvatarCacheItem {
updatedAt: number
avatars: Record<string, ContactsAvatarCacheEntry>
}
export async function getExportSessionMessageCountCache(scopeKey: string): Promise<ExportSessionMessageCountCacheItem | null> {
if (!scopeKey) return null
const value = await config.get(CONFIG_KEYS.EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP)
if (!value || typeof value !== 'object') return null
const rawMap = value as Record<string, unknown>
const rawItem = rawMap[scopeKey]
if (!rawItem || typeof rawItem !== 'object') return null
const rawUpdatedAt = (rawItem as Record<string, unknown>).updatedAt
const rawCounts = (rawItem as Record<string, unknown>).counts
if (!rawCounts || typeof rawCounts !== 'object') return null
const counts: Record<string, number> = {}
for (const [sessionId, countRaw] of Object.entries(rawCounts as Record<string, unknown>)) {
if (typeof countRaw === 'number' && Number.isFinite(countRaw) && countRaw >= 0) {
counts[sessionId] = Math.floor(countRaw)
}
}
return {
updatedAt: typeof rawUpdatedAt === 'number' && Number.isFinite(rawUpdatedAt) ? rawUpdatedAt : 0,
counts
}
}
export async function setExportSessionMessageCountCache(scopeKey: string, counts: Record<string, number>): Promise<void> {
if (!scopeKey) return
const current = await config.get(CONFIG_KEYS.EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP)
const map = current && typeof current === 'object'
? { ...(current as Record<string, unknown>) }
: {}
const normalized: Record<string, number> = {}
for (const [sessionId, countRaw] of Object.entries(counts || {})) {
if (typeof countRaw === 'number' && Number.isFinite(countRaw) && countRaw >= 0) {
normalized[sessionId] = Math.floor(countRaw)
}
}
map[scopeKey] = {
updatedAt: Date.now(),
counts: normalized
}
await config.set(CONFIG_KEYS.EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP, map)
}
export async function getExportSessionContentMetricCache(scopeKey: string): Promise<ExportSessionContentMetricCacheItem | null> {
if (!scopeKey) return null
const value = await config.get(CONFIG_KEYS.EXPORT_SESSION_CONTENT_METRIC_CACHE_MAP)
if (!value || typeof value !== 'object') return null
const rawMap = value as Record<string, unknown>
const rawItem = rawMap[scopeKey]
if (!rawItem || typeof rawItem !== 'object') return null
const rawUpdatedAt = (rawItem as Record<string, unknown>).updatedAt
const rawMetrics = (rawItem as Record<string, unknown>).metrics
if (!rawMetrics || typeof rawMetrics !== 'object') return null
const metrics: Record<string, ExportSessionContentMetricCacheEntry> = {}
for (const [sessionId, rawMetric] of Object.entries(rawMetrics as Record<string, unknown>)) {
if (!rawMetric || typeof rawMetric !== 'object') continue
const source = rawMetric as Record<string, unknown>
const metric: ExportSessionContentMetricCacheEntry = {}
if (typeof source.totalMessages === 'number' && Number.isFinite(source.totalMessages) && source.totalMessages >= 0) {
metric.totalMessages = Math.floor(source.totalMessages)
}
if (typeof source.voiceMessages === 'number' && Number.isFinite(source.voiceMessages) && source.voiceMessages >= 0) {
metric.voiceMessages = Math.floor(source.voiceMessages)
}
if (typeof source.imageMessages === 'number' && Number.isFinite(source.imageMessages) && source.imageMessages >= 0) {
metric.imageMessages = Math.floor(source.imageMessages)
}
if (typeof source.videoMessages === 'number' && Number.isFinite(source.videoMessages) && source.videoMessages >= 0) {
metric.videoMessages = Math.floor(source.videoMessages)
}
if (typeof source.emojiMessages === 'number' && Number.isFinite(source.emojiMessages) && source.emojiMessages >= 0) {
metric.emojiMessages = Math.floor(source.emojiMessages)
}
if (Object.keys(metric).length === 0) continue
metrics[sessionId] = metric
}
return {
updatedAt: typeof rawUpdatedAt === 'number' && Number.isFinite(rawUpdatedAt) ? rawUpdatedAt : 0,
metrics
}
}
export async function setExportSessionContentMetricCache(
scopeKey: string,
metrics: Record<string, ExportSessionContentMetricCacheEntry>
): Promise<void> {
if (!scopeKey) return
const current = await config.get(CONFIG_KEYS.EXPORT_SESSION_CONTENT_METRIC_CACHE_MAP)
const map = current && typeof current === 'object'
? { ...(current as Record<string, unknown>) }
: {}
const normalized: Record<string, ExportSessionContentMetricCacheEntry> = {}
for (const [sessionId, rawMetric] of Object.entries(metrics || {})) {
if (!rawMetric || typeof rawMetric !== 'object') continue
const metric: ExportSessionContentMetricCacheEntry = {}
if (typeof rawMetric.totalMessages === 'number' && Number.isFinite(rawMetric.totalMessages) && rawMetric.totalMessages >= 0) {
metric.totalMessages = Math.floor(rawMetric.totalMessages)
}
if (typeof rawMetric.voiceMessages === 'number' && Number.isFinite(rawMetric.voiceMessages) && rawMetric.voiceMessages >= 0) {
metric.voiceMessages = Math.floor(rawMetric.voiceMessages)
}
if (typeof rawMetric.imageMessages === 'number' && Number.isFinite(rawMetric.imageMessages) && rawMetric.imageMessages >= 0) {
metric.imageMessages = Math.floor(rawMetric.imageMessages)
}
if (typeof rawMetric.videoMessages === 'number' && Number.isFinite(rawMetric.videoMessages) && rawMetric.videoMessages >= 0) {
metric.videoMessages = Math.floor(rawMetric.videoMessages)
}
if (typeof rawMetric.emojiMessages === 'number' && Number.isFinite(rawMetric.emojiMessages) && rawMetric.emojiMessages >= 0) {
metric.emojiMessages = Math.floor(rawMetric.emojiMessages)
}
if (Object.keys(metric).length === 0) continue
normalized[sessionId] = metric
}
map[scopeKey] = {
updatedAt: Date.now(),
metrics: normalized
}
await config.set(CONFIG_KEYS.EXPORT_SESSION_CONTENT_METRIC_CACHE_MAP, map)
}
export async function getExportSnsStatsCache(scopeKey: string): Promise<ExportSnsStatsCacheItem | null> {
if (!scopeKey) return null
const value = await config.get(CONFIG_KEYS.EXPORT_SNS_STATS_CACHE_MAP)
if (!value || typeof value !== 'object') return null
const rawMap = value as Record<string, unknown>
const rawItem = rawMap[scopeKey]
if (!rawItem || typeof rawItem !== 'object') return null
const raw = rawItem as Record<string, unknown>
const totalPosts = typeof raw.totalPosts === 'number' && Number.isFinite(raw.totalPosts) && raw.totalPosts >= 0
? Math.floor(raw.totalPosts)
: 0
const totalFriends = typeof raw.totalFriends === 'number' && Number.isFinite(raw.totalFriends) && raw.totalFriends >= 0
? Math.floor(raw.totalFriends)
: 0
const updatedAt = typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt)
? raw.updatedAt
: 0
return { updatedAt, totalPosts, totalFriends }
}
export async function setExportSnsStatsCache(
scopeKey: string,
stats: { totalPosts: number; totalFriends: number }
): Promise<void> {
if (!scopeKey) return
const current = await config.get(CONFIG_KEYS.EXPORT_SNS_STATS_CACHE_MAP)
const map = current && typeof current === 'object'
? { ...(current as Record<string, unknown>) }
: {}
map[scopeKey] = {
updatedAt: Date.now(),
totalPosts: Number.isFinite(stats.totalPosts) ? Math.max(0, Math.floor(stats.totalPosts)) : 0,
totalFriends: Number.isFinite(stats.totalFriends) ? Math.max(0, Math.floor(stats.totalFriends)) : 0
}
await config.set(CONFIG_KEYS.EXPORT_SNS_STATS_CACHE_MAP, map)
}
export async function getSnsPageCache(scopeKey: string): Promise<SnsPageCacheItem | null> {
if (!scopeKey) return null
const value = await config.get(CONFIG_KEYS.SNS_PAGE_CACHE_MAP)
if (!value || typeof value !== 'object') return null
const rawMap = value as Record<string, unknown>
const rawItem = rawMap[scopeKey]
if (!rawItem || typeof rawItem !== 'object') return null
const raw = rawItem as Record<string, unknown>
const rawOverview = raw.overviewStats
const rawPosts = raw.posts
if (!rawOverview || typeof rawOverview !== 'object' || !Array.isArray(rawPosts)) return null
const overviewObj = rawOverview as Record<string, unknown>
const normalizeNumber = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? Math.floor(v) : 0)
const normalizeNullableTimestamp = (v: unknown) => {
if (v === null || v === undefined) return null
if (typeof v === 'number' && Number.isFinite(v) && v > 0) return Math.floor(v)
return null
}
const normalizeNullableCount = (v: unknown) => {
if (v === null || v === undefined) return null
if (typeof v === 'number' && Number.isFinite(v) && v >= 0) return Math.floor(v)
return null
}
return {
updatedAt: typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt) ? raw.updatedAt : 0,
overviewStats: {
totalPosts: Math.max(0, normalizeNumber(overviewObj.totalPosts)),
totalFriends: Math.max(0, normalizeNumber(overviewObj.totalFriends)),
myPosts: normalizeNullableCount(overviewObj.myPosts),
earliestTime: normalizeNullableTimestamp(overviewObj.earliestTime),
latestTime: normalizeNullableTimestamp(overviewObj.latestTime)
},
posts: rawPosts
}
}
export async function setSnsPageCache(
scopeKey: string,
payload: { overviewStats: SnsPageOverviewCache; posts: unknown[] }
): Promise<void> {
if (!scopeKey) return
const current = await config.get(CONFIG_KEYS.SNS_PAGE_CACHE_MAP)
const map = current && typeof current === 'object'
? { ...(current as Record<string, unknown>) }
: {}
const normalizeNumber = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? Math.max(0, Math.floor(v)) : 0)
const normalizeNullableTimestamp = (v: unknown) => {
if (v === null || v === undefined) return null
if (typeof v === 'number' && Number.isFinite(v) && v > 0) return Math.floor(v)
return null
}
const normalizeNullableCount = (v: unknown) => {
if (v === null || v === undefined) return null
if (typeof v === 'number' && Number.isFinite(v) && v >= 0) return Math.floor(v)
return null
}
map[scopeKey] = {
updatedAt: Date.now(),
overviewStats: {
totalPosts: normalizeNumber(payload?.overviewStats?.totalPosts),
totalFriends: normalizeNumber(payload?.overviewStats?.totalFriends),
myPosts: normalizeNullableCount(payload?.overviewStats?.myPosts),
earliestTime: normalizeNullableTimestamp(payload?.overviewStats?.earliestTime),
latestTime: normalizeNullableTimestamp(payload?.overviewStats?.latestTime)
},
posts: Array.isArray(payload?.posts) ? payload.posts : []
}
await config.set(CONFIG_KEYS.SNS_PAGE_CACHE_MAP, map)
}
// 获取通讯录加载超时阈值(毫秒)
export async function getContactsLoadTimeoutMs(): Promise<number> {
const value = await config.get(CONFIG_KEYS.CONTACTS_LOAD_TIMEOUT_MS)
if (typeof value === 'number' && Number.isFinite(value) && value >= 1000 && value <= 60000) {
return Math.floor(value)
}
return 3000
}
// 设置通讯录加载超时阈值(毫秒)
export async function setContactsLoadTimeoutMs(timeoutMs: number): Promise<void> {
const normalized = Number.isFinite(timeoutMs)
? Math.min(60000, Math.max(1000, Math.floor(timeoutMs)))
: 3000
await config.set(CONFIG_KEYS.CONTACTS_LOAD_TIMEOUT_MS, normalized)
}
export async function getContactsListCache(scopeKey: string): Promise<ContactsListCacheItem | null> {
if (!scopeKey) return null
const value = await config.get(CONFIG_KEYS.CONTACTS_LIST_CACHE_MAP)
if (!value || typeof value !== 'object') return null
const rawMap = value as Record<string, unknown>
const rawItem = rawMap[scopeKey]
if (!rawItem || typeof rawItem !== 'object') return null
const rawUpdatedAt = (rawItem as Record<string, unknown>).updatedAt
const rawContacts = (rawItem as Record<string, unknown>).contacts
if (!Array.isArray(rawContacts)) return null
const contacts: ContactsListCacheContact[] = []
for (const raw of rawContacts) {
if (!raw || typeof raw !== 'object') continue
const item = raw as Record<string, unknown>
const username = typeof item.username === 'string' ? item.username.trim() : ''
if (!username) continue
const displayName = typeof item.displayName === 'string' ? item.displayName : username
const type = typeof item.type === 'string' ? item.type : 'other'
contacts.push({
username,
displayName,
remark: typeof item.remark === 'string' ? item.remark : undefined,
nickname: typeof item.nickname === 'string' ? item.nickname : undefined,
type: (type === 'friend' || type === 'group' || type === 'official' || type === 'former_friend' || type === 'other')
? type
: 'other'
})
}
return {
updatedAt: typeof rawUpdatedAt === 'number' && Number.isFinite(rawUpdatedAt) ? rawUpdatedAt : 0,
contacts
}
}
export async function setContactsListCache(scopeKey: string, contacts: ContactsListCacheContact[]): Promise<void> {
if (!scopeKey) return
const current = await config.get(CONFIG_KEYS.CONTACTS_LIST_CACHE_MAP)
const map = current && typeof current === 'object'
? { ...(current as Record<string, unknown>) }
: {}
const normalized: ContactsListCacheContact[] = []
for (const contact of contacts || []) {
const username = String(contact?.username || '').trim()
if (!username) continue
const displayName = String(contact?.displayName || username)
const type = contact?.type || 'other'
if (type !== 'friend' && type !== 'group' && type !== 'official' && type !== 'former_friend' && type !== 'other') {
continue
}
normalized.push({
username,
displayName,
remark: contact?.remark ? String(contact.remark) : undefined,
nickname: contact?.nickname ? String(contact.nickname) : undefined,
type
})
}
map[scopeKey] = {
updatedAt: Date.now(),
contacts: normalized
}
await config.set(CONFIG_KEYS.CONTACTS_LIST_CACHE_MAP, map)
}
export async function getContactsAvatarCache(scopeKey: string): Promise<ContactsAvatarCacheItem | null> {
if (!scopeKey) return null
const value = await config.get(CONFIG_KEYS.CONTACTS_AVATAR_CACHE_MAP)
if (!value || typeof value !== 'object') return null
const rawMap = value as Record<string, unknown>
const rawItem = rawMap[scopeKey]
if (!rawItem || typeof rawItem !== 'object') return null
const rawUpdatedAt = (rawItem as Record<string, unknown>).updatedAt
const rawAvatars = (rawItem as Record<string, unknown>).avatars
if (!rawAvatars || typeof rawAvatars !== 'object') return null
const avatars: Record<string, ContactsAvatarCacheEntry> = {}
for (const [rawUsername, rawEntry] of Object.entries(rawAvatars as Record<string, unknown>)) {
const username = rawUsername.trim()
if (!username) continue
if (typeof rawEntry === 'string') {
const avatarUrl = rawEntry.trim()
if (!avatarUrl) continue
avatars[username] = {
avatarUrl,
updatedAt: typeof rawUpdatedAt === 'number' && Number.isFinite(rawUpdatedAt) ? rawUpdatedAt : 0,
checkedAt: typeof rawUpdatedAt === 'number' && Number.isFinite(rawUpdatedAt) ? rawUpdatedAt : 0
}
continue
}
if (!rawEntry || typeof rawEntry !== 'object') continue
const entry = rawEntry as Record<string, unknown>
const avatarUrl = typeof entry.avatarUrl === 'string' ? entry.avatarUrl.trim() : ''
if (!avatarUrl) continue
const updatedAt = typeof entry.updatedAt === 'number' && Number.isFinite(entry.updatedAt)
? entry.updatedAt
: 0
const checkedAt = typeof entry.checkedAt === 'number' && Number.isFinite(entry.checkedAt)
? entry.checkedAt
: updatedAt
avatars[username] = {
avatarUrl,
updatedAt,
checkedAt
}
}
return {
updatedAt: typeof rawUpdatedAt === 'number' && Number.isFinite(rawUpdatedAt) ? rawUpdatedAt : 0,
avatars
}
}
export async function setContactsAvatarCache(
scopeKey: string,
avatars: Record<string, ContactsAvatarCacheEntry>
): Promise<void> {
if (!scopeKey) return
const current = await config.get(CONFIG_KEYS.CONTACTS_AVATAR_CACHE_MAP)
const map = current && typeof current === 'object'
? { ...(current as Record<string, unknown>) }
: {}
const normalized: Record<string, ContactsAvatarCacheEntry> = {}
for (const [rawUsername, rawEntry] of Object.entries(avatars || {})) {
const username = String(rawUsername || '').trim()
if (!username || !rawEntry || typeof rawEntry !== 'object') continue
const avatarUrl = String(rawEntry.avatarUrl || '').trim()
if (!avatarUrl) continue
const updatedAt = Number.isFinite(rawEntry.updatedAt)
? Math.max(0, Math.floor(rawEntry.updatedAt))
: Date.now()
const checkedAt = Number.isFinite(rawEntry.checkedAt)
? Math.max(0, Math.floor(rawEntry.checkedAt))
: updatedAt
normalized[username] = {
avatarUrl,
updatedAt,
checkedAt
}
}
map[scopeKey] = {
updatedAt: Date.now(),
avatars: normalized
}
await config.set(CONFIG_KEYS.CONTACTS_AVATAR_CACHE_MAP, map)
}
// === 安全相关 ===
export async function getAuthEnabled(): Promise<boolean> {
@@ -465,3 +1076,37 @@ export async function getNotificationFilterList(): Promise<string[]> {
export async function setNotificationFilterList(list: string[]): Promise<void> {
await config.set(CONFIG_KEYS.NOTIFICATION_FILTER_LIST, list)
}
// 获取词云排除词列表
export async function getWordCloudExcludeWords(): Promise<string[]> {
const value = await config.get(CONFIG_KEYS.WORD_CLOUD_EXCLUDE_WORDS)
return Array.isArray(value) ? value : []
}
// 设置词云排除词列表
export async function setWordCloudExcludeWords(words: string[]): Promise<void> {
await config.set(CONFIG_KEYS.WORD_CLOUD_EXCLUDE_WORDS, words)
}
// 获取数据收集同意状态
export async function getAnalyticsConsent(): Promise<boolean | null> {
const value = await config.get(CONFIG_KEYS.ANALYTICS_CONSENT)
if (typeof value === 'boolean') return value
return null
}
// 设置数据收集同意状态
export async function setAnalyticsConsent(consent: boolean): Promise<void> {
await config.set(CONFIG_KEYS.ANALYTICS_CONSENT, consent)
}
// 获取数据收集拒绝次数
export async function getAnalyticsDenyCount(): Promise<number> {
const value = await config.get(CONFIG_KEYS.ANALYTICS_DENY_COUNT)
return typeof value === 'number' ? value : 0
}
// 设置数据收集拒绝次数
export async function setAnalyticsDenyCount(count: number): Promise<void> {
await config.set(CONFIG_KEYS.ANALYTICS_DENY_COUNT, count)
}

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