mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-26 15:45:51 +00:00
Compare commits
505 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2797d571e4 | ||
|
|
389fd0b1b0 | ||
|
|
25630da1ce | ||
|
|
ca972d3e28 | ||
|
|
80420302c1 | ||
|
|
9759d5f64f | ||
|
|
17a9b6102e | ||
|
|
7e7503035a | ||
|
|
02a6b24517 | ||
|
|
b3fee5b56d | ||
|
|
26d38acddb | ||
|
|
8a30e9b663 | ||
|
|
46a2d04528 | ||
|
|
6a85b82643 | ||
|
|
b436bb63da | ||
|
|
b5cb4051ab | ||
|
|
01f774db54 | ||
|
|
c5a6d765ee | ||
|
|
459f23bbd6 | ||
|
|
360754737f | ||
|
|
36f1476782 | ||
|
|
ecae83f659 | ||
|
|
fbe5109ed9 | ||
|
|
4adedad0de | ||
|
|
28257ba66f | ||
|
|
3062295069 | ||
|
|
3c231a7fde | ||
|
|
0247b02f6e | ||
|
|
8aaad71784 | ||
|
|
e795474917 | ||
|
|
49f99f57c9 | ||
|
|
53398707aa | ||
|
|
1d8a7d2e63 | ||
|
|
313e2bc080 | ||
|
|
0037935280 | ||
|
|
7858b40ce4 | ||
|
|
ab6db27ea7 | ||
|
|
4568795081 | ||
|
|
43643d1a83 | ||
|
|
28e7de6ceb | ||
|
|
c204855a71 | ||
|
|
dab33c4e60 | ||
|
|
47f9c0a502 | ||
|
|
d9a6fd2a42 | ||
|
|
dcb91905ad | ||
|
|
b6fd842d4e | ||
|
|
4b57e3e350 | ||
|
|
1652ebc4ad | ||
|
|
924ff1b6fc | ||
|
|
926ca72331 | ||
|
|
cf7190aaec | ||
|
|
54d6cded53 | ||
|
|
7a7e54ea5b | ||
|
|
7b4aa23f35 | ||
|
|
ac4482bc8b | ||
|
|
0a7f2b15f1 | ||
|
|
95e0b83537 | ||
|
|
bb602af750 | ||
|
|
580242b9d2 | ||
|
|
2cc1b55cbf | ||
|
|
e1944783d0 | ||
|
|
423d760f36 | ||
|
|
16e237b698 | ||
|
|
28d68d8a8e | ||
|
|
d476fbbdae | ||
|
|
64542f2902 | ||
|
|
56a59a5355 | ||
|
|
285ddeb62e | ||
|
|
84ef51f16b | ||
|
|
fb1125136c | ||
|
|
55f7ff1842 | ||
|
|
ac1d2210da | ||
|
|
ff92f355e2 | ||
|
|
4b8c8155fa | ||
|
|
756a83191d | ||
|
|
b5eb8be15e | ||
|
|
38a023d0b6 | ||
|
|
3a878dd019 | ||
|
|
6314c0f1d6 | ||
|
|
c5eed25f06 | ||
|
|
e1243522b0 | ||
|
|
d9108ac6ed | ||
|
|
302abe3e40 | ||
|
|
b6a2191e38 | ||
|
|
84b54e43aa | ||
|
|
e9971aa6c4 | ||
|
|
91f630209c | ||
|
|
b6878aefd6 | ||
|
|
f0f70def8c | ||
|
|
81bc5aefff | ||
|
|
698d2c96d7 | ||
|
|
ce683a539d | ||
|
|
ac481c6b18 | ||
|
|
750d6ad7eb | ||
|
|
7bd801cd01 | ||
|
|
5cb364f754 | ||
|
|
04d1b0c694 | ||
|
|
35028df817 | ||
|
|
2e8f55d7a8 | ||
|
|
815a440082 | ||
|
|
2afcd528dc | ||
|
|
8d68a59799 | ||
|
|
51bc60776d | ||
|
|
43f4c966f9 | ||
|
|
98a0233c4d | ||
|
|
0545be3244 | ||
|
|
4a67b22d8d | ||
|
|
5840bf710c | ||
|
|
1b8e1c2aab | ||
|
|
60aa949cca | ||
|
|
5b05b8927c | ||
|
|
d65d6d2396 | ||
|
|
086ac8fdc9 | ||
|
|
c6c7f128a9 | ||
|
|
36ec12fd0f | ||
|
|
e9fd751578 | ||
|
|
21a97b8871 | ||
|
|
b8ede4cfd0 | ||
|
|
f47eba5764 | ||
|
|
1347136b54 | ||
|
|
89f0758fbb | ||
|
|
b5507b9f5d | ||
|
|
204baa52ab | ||
|
|
bc739dc4a0 | ||
|
|
64616b9136 | ||
|
|
983783ea95 | ||
|
|
1414a4a9cf | ||
|
|
af7639aa73 | ||
|
|
dabc6a2d0a | ||
|
|
d1ef159e87 | ||
|
|
cc5c323ccb | ||
|
|
d18a871429 | ||
|
|
0a1f55f6a6 | ||
|
|
faeda030e9 | ||
|
|
b3700c3a4c | ||
|
|
01a221831f | ||
|
|
9cb41e01e2 | ||
|
|
abdb4f62de | ||
|
|
da7d354436 | ||
|
|
794a306f89 | ||
|
|
ac61ee1833 | ||
|
|
a87d419868 | ||
|
|
abbb7a0cb1 | ||
|
|
a5ae22d2a5 | ||
|
|
22b6a07749 | ||
|
|
dbdb2e2959 | ||
|
|
5147b3f0e4 | ||
|
|
a8eb0057e3 | ||
|
|
7604ff2ae4 | ||
|
|
bf9b5ba593 | ||
|
|
d12c111684 | ||
|
|
dffd3c9138 | ||
|
|
c34f7af6de | ||
|
|
22c7048ef6 | ||
|
|
96aa9d0813 | ||
|
|
d99c0ff8b2 | ||
|
|
c6e8bde078 | ||
|
|
adff7b9e1e | ||
|
|
b62c18fd84 | ||
|
|
de7cbdf494 | ||
|
|
0444ca143e | ||
|
|
596baad296 | ||
|
|
e686bb6247 | ||
|
|
06d6f15e38 | ||
|
|
d3adae42fe | ||
|
|
39b38119c1 | ||
|
|
eace3e9467 | ||
|
|
366da8d38e | ||
|
|
a965890916 | ||
|
|
b07bbd68d7 | ||
|
|
3d4a79aac6 | ||
|
|
e30c4cc644 | ||
|
|
d317be3ad3 | ||
|
|
1b078bd2fd | ||
|
|
1d84ed1614 | ||
|
|
114476d74c | ||
|
|
fb8663fb24 | ||
|
|
3a9be771b4 | ||
|
|
b2ef8f5cd2 | ||
|
|
83d501ae9b | ||
|
|
c555566c9d | ||
|
|
264f9a380b | ||
|
|
33d5951a14 | ||
|
|
68c4e43e05 | ||
|
|
54510f1c18 | ||
|
|
940234c743 | ||
|
|
b31ab46d11 | ||
|
|
c359821844 | ||
|
|
d49cf08e21 | ||
|
|
0f4cd23989 | ||
|
|
e12451911b | ||
|
|
b26f8cc43c | ||
|
|
d63c37cd78 | ||
|
|
c88aa2c9d8 | ||
|
|
4d5c744583 | ||
|
|
5033c5c7b7 | ||
|
|
5a1f2ffac7 | ||
|
|
8eecb592e6 | ||
|
|
fb188d6aaa | ||
|
|
0d33fe8fe4 | ||
|
|
5b3b8b5bc3 | ||
|
|
17de7f2e56 | ||
|
|
03aec7a34e | ||
|
|
266d68be22 | ||
|
|
bfbdefe773 | ||
|
|
5e96cdb1d6 | ||
|
|
19ee47ceb2 | ||
|
|
2823607146 | ||
|
|
1869abd9df | ||
|
|
f070d184ea | ||
|
|
d59d552aae | ||
|
|
a370531f1d | ||
|
|
9ae1b455f4 | ||
|
|
ec0eb64ffd | ||
|
|
f31886e1ab | ||
|
|
7365831ec1 | ||
|
|
4a09b682b2 | ||
|
|
afbd52a91e | ||
|
|
1c6e14acb4 | ||
|
|
6968936c8f | ||
|
|
a571278145 | ||
|
|
e4e25394e2 | ||
|
|
fe47d7b9e3 | ||
|
|
4bb5bc6e32 | ||
|
|
49d951e96a | ||
|
|
9585a02959 | ||
|
|
a51fa5e4a2 | ||
|
|
bc0671440c | ||
|
|
1a07c3970f | ||
|
|
83c07b27f9 | ||
|
|
fbcf7d2fc3 | ||
|
|
b547ac1aed | ||
|
|
411f8a8d61 | ||
|
|
b3741a5cf4 | ||
|
|
b1cf524612 | ||
|
|
364c920fff | ||
|
|
e89ccee5f4 | ||
|
|
6a86e69cd4 | ||
|
|
ab2c086e93 | ||
|
|
b9c65e634c | ||
|
|
b7852a8c07 | ||
|
|
4b9d94eb62 | ||
|
|
70481fd468 | ||
|
|
52c67f4d23 | ||
|
|
d3618f3065 | ||
|
|
29472beee8 | ||
|
|
acaac507b1 | ||
|
|
f25c23b2b3 | ||
|
|
5ab0466a87 | ||
|
|
d49c44f3be | ||
|
|
4577b4e955 | ||
|
|
dafde2eaba | ||
|
|
db4fab9130 | ||
|
|
9aee578707 | ||
|
|
6d74eb65ae | ||
|
|
6e8ae3a12b | ||
|
|
a4be7f9005 | ||
|
|
587ee630d7 | ||
|
|
6952a5f680 | ||
|
|
b263ecd45c | ||
|
|
74fc0e4e88 | ||
|
|
a873366342 | ||
|
|
c4dc266f93 | ||
|
|
96ff783bbd | ||
|
|
804a65f52b | ||
|
|
e88c859f4f | ||
|
|
c1a393eaf6 | ||
|
|
15e08dc529 | ||
|
|
e55bcaf7eb | ||
|
|
4e64c6ad6e | ||
|
|
5a15e1a1d6 | ||
|
|
ba07d47496 | ||
|
|
25325e80ee | ||
|
|
89783b4d45 | ||
|
|
d5f0094025 | ||
|
|
b4f37451be | ||
|
|
84ea378815 | ||
|
|
72d4db1f27 | ||
|
|
21ea879d97 | ||
|
|
a5baef2240 | ||
|
|
bbecf54aba | ||
|
|
5f868d193c | ||
|
|
62b035ab39 | ||
|
|
ff5ee33e08 | ||
|
|
8e28016e5e | ||
|
|
f17a18cb6d | ||
|
|
999f45e5f5 | ||
|
|
3e303fadd7 | ||
|
|
3b7590d8ce | ||
|
|
fabbada580 | ||
|
|
6e434d37dc | ||
|
|
904da80f81 | ||
|
|
2a4bd52f0a | ||
|
|
b4248d4a12 | ||
|
|
75b056d5ba | ||
|
|
e87e12c939 | ||
|
|
5cb7e3bc73 | ||
|
|
1930b91a5b | ||
|
|
ea0dad132c | ||
|
|
5b7b94f507 | ||
|
|
28e38f73f8 | ||
|
|
d43c0ef209 | ||
|
|
6394384be0 | ||
|
|
4f0af3d0cb | ||
|
|
2a6f833718 | ||
|
|
c8835f4d4c | ||
|
|
fff1a1c177 | ||
|
|
8fee96d0e1 | ||
|
|
fdb3d63006 | ||
|
|
071d239892 | ||
|
|
94eb9abe9d | ||
|
|
1031c4013e | ||
|
|
2b5bb34392 | ||
|
|
e28ef9b783 | ||
|
|
e3c17010c1 | ||
|
|
2389aaf314 | ||
|
|
4f1dd7a5fb | ||
|
|
4b203a93b6 | ||
|
|
f219b1a580 | ||
|
|
004ee5bbf0 | ||
|
|
5640db9cbd | ||
|
|
52b26533a2 | ||
|
|
d334a214a4 | ||
|
|
1aab8dfc4e | ||
|
|
e56ee1ff4a | ||
|
|
0393e7aff7 | ||
|
|
c988e4accf | ||
|
|
63ac715792 | ||
|
|
fe0e2e6592 | ||
|
|
ca1a386146 | ||
|
|
7c9d0a39c3 | ||
|
|
a5777027b1 | ||
|
|
c3e911e6fa | ||
|
|
4d03110df2 | ||
|
|
8cb640f565 | ||
|
|
494bd4f539 | ||
|
|
38169691cd | ||
|
|
bd995bc736 | ||
|
|
6e05e74d5e | ||
|
|
d3a1db4efe | ||
|
|
a19f2a57c3 | ||
|
|
666a53f6ba | ||
|
|
b156a08f0d | ||
|
|
9c76aa2189 | ||
|
|
a54c95b6ac | ||
|
|
9cb0ada1b7 | ||
|
|
54378a132f | ||
|
|
4d1632a9b9 | ||
|
|
1eab835458 | ||
|
|
fcbc7fead8 | ||
|
|
ec783e4ccc | ||
|
|
b6f97b102c | ||
|
|
e4ce9a3bd7 | ||
|
|
64d5e721af | ||
|
|
d7419669d6 | ||
|
|
ff2f6799c8 | ||
|
|
2d573896f9 | ||
|
|
ab15190c44 | ||
|
|
551995df68 | ||
|
|
8483babd10 | ||
|
|
79648cd9d5 | ||
|
|
04d690dcf1 | ||
|
|
0b308803bf | ||
|
|
419d5aace3 | ||
|
|
84005f2d43 | ||
|
|
a166079084 | ||
|
|
a70d8fe6c8 | ||
|
|
34cd337146 | ||
|
|
c9216aabad | ||
|
|
79d6aef480 | ||
|
|
8134d62056 | ||
|
|
8664ebf6f5 | ||
|
|
7b832ac2ef | ||
|
|
5934fc33ce | ||
|
|
b6d10f79de | ||
|
|
f90822694f | ||
|
|
123a088a39 | ||
|
|
9283594dd0 | ||
|
|
638246e74d | ||
|
|
f506407f67 | ||
|
|
216f201327 | ||
|
|
a557f2ada3 | ||
|
|
e15e4cc3c8 | ||
|
|
2555c46b6d | ||
|
|
fdfd59fbdf | ||
|
|
0e1c3f9364 | ||
|
|
f9bb18d97f | ||
|
|
b7339b6a35 | ||
|
|
26abc30695 | ||
|
|
1f0f824b01 | ||
|
|
cb37f534ac | ||
|
|
50903b35cf | ||
|
|
c07ef66324 | ||
|
|
6bc802e77b | ||
|
|
898c86c23f | ||
|
|
7612353389 | ||
|
|
8b37f20b0f | ||
|
|
0054509ef2 | ||
|
|
e0f22f58c8 | ||
|
|
6f41cb34ed | ||
|
|
ddbb0c3b26 | ||
|
|
f40f885af3 | ||
|
|
5413d7e2c8 | ||
|
|
53f0e299e0 | ||
|
|
65365107f5 | ||
|
|
cffeeb26ec | ||
|
|
26d4751e80 | ||
|
|
b8120a5119 | ||
|
|
68a13cefc3 | ||
|
|
cd4b8f3702 | ||
|
|
c5956ba203 | ||
|
|
f456357e01 | ||
|
|
4ef821f45f | ||
|
|
912c78e9e9 | ||
|
|
bfcd154a25 | ||
|
|
a1c8ba48b0 | ||
|
|
f93369489d | ||
|
|
014f57f152 | ||
|
|
3f1eb58af4 | ||
|
|
97f0077e95 | ||
|
|
3d9b1b0f8c | ||
|
|
cf292ca9e2 | ||
|
|
97f14030de | ||
|
|
2cfe0d8ee8 | ||
|
|
a760f45823 | ||
|
|
baa949a301 | ||
|
|
c29bbab25f | ||
|
|
29981e1232 | ||
|
|
2d043cd929 | ||
|
|
d6dca0e5f7 | ||
|
|
d47166e6f9 | ||
|
|
6e3bb9e361 | ||
|
|
b8dbc3caf1 | ||
|
|
c1145c8f89 | ||
|
|
0cba8e6d89 | ||
|
|
f6f468dff3 | ||
|
|
04fc5f9104 | ||
|
|
3c9ab6763c | ||
|
|
f360333ab4 | ||
|
|
834aa6eecb | ||
|
|
2400cc8b55 | ||
|
|
e4ed7faca9 | ||
|
|
8012aa49ee | ||
|
|
7225358b91 | ||
|
|
39688e8e0c | ||
|
|
592ca6128f | ||
|
|
7cd27d8905 | ||
|
|
bca387c54b | ||
|
|
e7e4ffd53f | ||
|
|
04e0bf6b29 | ||
|
|
dadd9d799c | ||
|
|
b3aaea16f2 | ||
|
|
f3994a1a72 | ||
|
|
26fbfd2c98 | ||
|
|
3c51dee9a6 | ||
|
|
b9fa0cc215 | ||
|
|
21f748a2dc | ||
|
|
87fe130791 | ||
|
|
ff1bc279f2 | ||
|
|
77689ec528 | ||
|
|
5ea0b65905 | ||
|
|
eac6b053ee | ||
|
|
d52abfddbf | ||
|
|
8f2e403837 | ||
|
|
17c9436c30 | ||
|
|
9969c073e5 | ||
|
|
dc83297854 | ||
|
|
b6c9f2b32b | ||
|
|
e63f901478 | ||
|
|
893cdb4d92 | ||
|
|
d99ec05e81 | ||
|
|
c8f726eddc | ||
|
|
4e57a30c90 | ||
|
|
0a88275669 | ||
|
|
2a45cf1276 | ||
|
|
d63f1e0d79 | ||
|
|
f55507cd99 | ||
|
|
836b0f9df4 | ||
|
|
b09068f1f7 | ||
|
|
714a9400d5 | ||
|
|
13dd2fca21 | ||
|
|
5d1f834b61 | ||
|
|
3ca86224eb | ||
|
|
f10e974f36 | ||
|
|
76c40e4118 | ||
|
|
5307f55840 | ||
|
|
3405f26d10 | ||
|
|
85d82bfd09 | ||
|
|
e557ee224e | ||
|
|
88544c4a5d | ||
|
|
b66fc32068 | ||
|
|
7ac3c281a3 | ||
|
|
28616493ce | ||
|
|
d68e4fe880 | ||
|
|
1fd676d63e | ||
|
|
9f31ac0529 | ||
|
|
3c32ad5ca8 | ||
|
|
879d84b597 | ||
|
|
ab3551fb91 | ||
|
|
b9d1ea316f | ||
|
|
7762bd37c9 | ||
|
|
2e61902556 | ||
|
|
9e8072c337 | ||
|
|
827e77c9a3 | ||
|
|
1f03d35253 |
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
|||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 22.12
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
@@ -54,8 +54,8 @@ jobs:
|
|||||||
## 更新日志
|
## 更新日志
|
||||||
修复了一些已知问题
|
修复了一些已知问题
|
||||||
|
|
||||||
## 加入我们的群
|
## 查看更多日志/获取最新动态
|
||||||
[点击加入 Telegram 群](https://t.me/+hn3QzNc4DbA0MzNl)
|
[点击加入 Telegram 频道](https://t.me/weflow_cc)
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
gh release edit "$GITHUB_REF_NAME" --notes-file release_notes.md
|
gh release edit "$GITHUB_REF_NAME" --notes-file release_notes.md
|
||||||
|
|||||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -56,4 +56,13 @@ Thumbs.db
|
|||||||
*.aps
|
*.aps
|
||||||
|
|
||||||
wcdb/
|
wcdb/
|
||||||
|
xkey/
|
||||||
|
server/
|
||||||
*info
|
*info
|
||||||
|
chatlab-format.md
|
||||||
|
*.bak
|
||||||
|
AGENTS.md
|
||||||
|
.claude/
|
||||||
|
.agents/
|
||||||
|
resources/wx_send
|
||||||
|
概述.md
|
||||||
95
README.md
95
README.md
@@ -19,9 +19,10 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
|
|||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/hicccc77/WeFlow/issues">
|
<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://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>
|
||||||
<a href="https://t.me/+hn3QzNc4DbA0MzNl">
|
<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">
|
<img src="https://img.shields.io/badge/Telegram%20频道-0088cc?style=flat-square&logo=telegram&logoColor=0088cc&labelColor=white" alt="Telegram">
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -29,34 +30,55 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
|
|||||||
> [!TIP]
|
> [!TIP]
|
||||||
> 如果导出聊天记录后,想深入分析聊天内容可以试试 [ChatLab](https://chatlab.fun/)
|
> 如果导出聊天记录后,想深入分析聊天内容可以试试 [ChatLab](https://chatlab.fun/)
|
||||||
|
|
||||||
> [!TIP]
|
> [!NOTE]
|
||||||
> 仅支持微信 **4.0** 及以上版本
|
> 仅支持微信 **4.0 及以上**版本,确保你的微信版本符合要求
|
||||||
|
|
||||||
# 加入微信交流群
|
|
||||||
|
|
||||||
> 🎉 扫码加入微信群,与其他 WeFlow 用户一起交流问题和使用心得。
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<img src="2wm.png" alt="WeFlow 微信交流群二维码(一群)" width="220" style="margin-right: 16px;">
|
|
||||||
<img src="3wm.png" alt="WeFlow 微信交流群二维码(二群)" width="220">
|
|
||||||
</p>
|
|
||||||
<p align="center">一群满了加二群</p>
|
|
||||||
|
|
||||||
## 主要功能
|
## 主要功能
|
||||||
|
|
||||||
- 本地实时查看聊天记录
|
- 本地实时查看聊天记录
|
||||||
|
- 朋友圈图片、视频、**实况**的预览和解密
|
||||||
- 统计分析与群聊画像
|
- 统计分析与群聊画像
|
||||||
- 年度报告与可视化概览
|
- 年度报告与可视化概览
|
||||||
- 导出聊天记录为 HTML 等格式
|
- 导出聊天记录为 HTML 等格式
|
||||||
- 本地解密与数据库管理
|
- HTTP API 接口(供开发者集成)
|
||||||
|
- 查看完整能力清单:[详细功能](#详细功能清单)
|
||||||
> [!NOTE]
|
|
||||||
> ⚠️ 本工具仅适配微信 **4.0 及以上**版本,请确保你的微信版本符合要求
|
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
若你只想使用成品版本,可前往 Release 下载并安装。
|
若你只想使用成品版本,可前往 Release 下载并安装。
|
||||||
|
|
||||||
|
## 详细功能清单
|
||||||
|
|
||||||
|
当前版本已支持以下能力:
|
||||||
|
|
||||||
|
| 功能模块 | 说明 |
|
||||||
|
|---------|------|
|
||||||
|
| **聊天** | 解密聊天中的图片、视频、实况(仅支持谷歌协议拍摄的实况);支持**修改**、删除**本地**消息;实时刷新最新消息,无需生成解密中间数据库 |
|
||||||
|
| **实时弹窗通知** | 新消息到达时提供桌面弹窗提醒,便于及时查看重要会话,提供黑白名单功能 |
|
||||||
|
| **私聊分析** | 统计好友间消息数量;分析消息类型与发送比例;查看消息时段分布等 |
|
||||||
|
| **群聊分析** | 查看群成员详细信息;分析群内发言排行、活跃时段和媒体内容 |
|
||||||
|
| **年度报告** | 生成按年统计的年度报告,或跨年度的长期历史报告 |
|
||||||
|
| **双人报告** | 选择指定好友,基于双方聊天记录生成专属分析报告 |
|
||||||
|
| **消息导出** | 将微信聊天记录导出为多种格式:JSON、HTML、TXT、Excel、CSV、PGSQL、ChatLab专属格式等 |
|
||||||
|
| **朋友圈** | 解密朋友圈图片、视频、实况;导出朋友圈内容;拦截朋友圈的删除与隐藏操作;突破时间访问限制 |
|
||||||
|
| **联系人** | 导出微信好友、群聊、公众号信息;尝试找回曾经的好友(功能尚不完善) |
|
||||||
|
| **HTTP API 映射** | 将本地消息能力映射为 HTTP API,便于对接外部系统、自动化脚本与二次开发 |
|
||||||
|
|
||||||
|
## HTTP API
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> 此功能目前处于早期阶段,接口可能会有变动,请等待后续更新完善。
|
||||||
|
|
||||||
|
WeFlow 提供本地 HTTP API 服务,支持通过接口查询消息数据,可用于与其他工具集成或二次开发。
|
||||||
|
|
||||||
|
- **启用方式**:设置 → API 服务 → 启动服务
|
||||||
|
- **默认端口**:5031
|
||||||
|
- **访问地址**:`http://127.0.0.1:5031`
|
||||||
|
- **支持格式**:原始 JSON 或 [ChatLab](https://chatlab.fun/) 标准格式
|
||||||
|
|
||||||
|
完整接口文档:[点击查看](docs/HTTP-API.md)
|
||||||
|
|
||||||
|
|
||||||
## 面向开发者
|
## 面向开发者
|
||||||
|
|
||||||
如果你想从源码构建或为项目贡献代码,请遵循以下步骤:
|
如果你想从源码构建或为项目贡献代码,请遵循以下步骤:
|
||||||
@@ -78,38 +100,19 @@ npm run build
|
|||||||
|
|
||||||
打包产物在 `release` 目录下。
|
打包产物在 `release` 目录下。
|
||||||
|
|
||||||
## 技术栈
|
|
||||||
|
|
||||||
- **前端**: React 19 + TypeScript + Zustand
|
|
||||||
- **桌面**: Electron 39
|
|
||||||
- **构建**: Vite + electron-builder
|
|
||||||
- **数据库**: better-sqlite3 + WCDB DLL
|
|
||||||
- **样式**: SCSS + CSS Variables
|
|
||||||
|
|
||||||
## 项目结构
|
|
||||||
|
|
||||||
```
|
|
||||||
WeFlow/
|
|
||||||
├── electron/ # Electron 主进程
|
|
||||||
│ ├── main.ts # 主进程入口
|
|
||||||
│ ├── preload.ts # 预加载脚本
|
|
||||||
│ └── services/ # 后端服务
|
|
||||||
│ ├── chatService.ts # 聊天数据服务
|
|
||||||
│ ├── wcdbService.ts # 数据库服务
|
|
||||||
│ └── ...
|
|
||||||
├── src/ # React 前端
|
|
||||||
│ ├── components/ # 通用组件
|
|
||||||
│ ├── pages/ # 页面组件
|
|
||||||
│ ├── stores/ # Zustand 状态管理
|
|
||||||
│ ├── services/ # 前端服务
|
|
||||||
│ └── types/ # TypeScript 类型定义
|
|
||||||
├── public/ # 静态资源
|
|
||||||
└── resources/ # 打包资源
|
|
||||||
```
|
|
||||||
|
|
||||||
## 致谢
|
## 致谢
|
||||||
|
|
||||||
- [密语 CipherTalk](https://github.com/ILoveBingLu/miyu) 为本项目提供了基础框架
|
- [密语 CipherTalk](https://github.com/ILoveBingLu/miyu) 为本项目提供了基础框架
|
||||||
|
- [WeChat-Channels-Video-File-Decryption](https://github.com/Evil0ctal/WeChat-Channels-Video-File-Decryption) 提供了视频解密相关的技术参考
|
||||||
|
|
||||||
|
## 支持我们
|
||||||
|
|
||||||
|
如果 WeFlow 确实帮到了你,可以考虑请我们喝杯咖啡:
|
||||||
|
|
||||||
|
|
||||||
|
> TRC20 **Address:** `TZCtAw8CaeARWZBfvjidCnTcfnAtf6nvS6`
|
||||||
|
|
||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
@@ -128,6 +131,4 @@ WeFlow/
|
|||||||
|
|
||||||
**请负责任地使用本工具,遵守相关法律法规**
|
**请负责任地使用本工具,遵守相关法律法规**
|
||||||
|
|
||||||
我们总是在向前走,却很少有机会回头看看
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
391
docs/HTTP-API.md
Normal file
391
docs/HTTP-API.md
Normal 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 | ✅ | 会话 ID(wxid 或群 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,可从浏览器前端直接调用
|
||||||
4707
electron/assets/wasm/wasm_video_decode.js
Normal file
4707
electron/assets/wasm/wasm_video_decode.js
Normal file
File diff suppressed because it is too large
Load Diff
BIN
electron/assets/wasm/wasm_video_decode.wasm
Normal file
BIN
electron/assets/wasm/wasm_video_decode.wasm
Normal file
Binary file not shown.
47
electron/dualReportWorker.ts
Normal file
47
electron/dualReportWorker.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { parentPort, workerData } from 'worker_threads'
|
||||||
|
import { wcdbService } from './services/wcdbService'
|
||||||
|
import { dualReportService } from './services/dualReportService'
|
||||||
|
|
||||||
|
interface WorkerConfig {
|
||||||
|
year: number
|
||||||
|
friendUsername: string
|
||||||
|
dbPath: string
|
||||||
|
decryptKey: string
|
||||||
|
myWxid: string
|
||||||
|
resourcesPath?: string
|
||||||
|
userDataPath?: string
|
||||||
|
logEnabled?: boolean
|
||||||
|
excludeWords?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = workerData as WorkerConfig
|
||||||
|
process.env.WEFLOW_WORKER = '1'
|
||||||
|
if (config.resourcesPath) {
|
||||||
|
process.env.WCDB_RESOURCES_PATH = config.resourcesPath
|
||||||
|
}
|
||||||
|
|
||||||
|
wcdbService.setPaths(config.resourcesPath || '', config.userDataPath || '')
|
||||||
|
wcdbService.setLogEnabled(config.logEnabled === true)
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const result = await dualReportService.generateReportWithConfig({
|
||||||
|
year: config.year,
|
||||||
|
friendUsername: config.friendUsername,
|
||||||
|
dbPath: config.dbPath,
|
||||||
|
decryptKey: config.decryptKey,
|
||||||
|
wxid: config.myWxid,
|
||||||
|
excludeWords: config.excludeWords,
|
||||||
|
onProgress: (status: string, progress: number) => {
|
||||||
|
parentPort?.postMessage({
|
||||||
|
type: 'dualReport:progress',
|
||||||
|
data: { status, progress }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
parentPort?.postMessage({ type: 'dualReport:result', data: result })
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch((err) => {
|
||||||
|
parentPort?.postMessage({ type: 'dualReport:error', error: String(err) })
|
||||||
|
})
|
||||||
1422
electron/main.ts
1422
electron/main.ts
File diff suppressed because it is too large
Load Diff
24
electron/nodert.d.ts
vendored
Normal file
24
electron/nodert.d.ts
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
declare module '@nodert-win10-rs4/windows.security.credentials.ui' {
|
||||||
|
export enum UserConsentVerificationResult {
|
||||||
|
Verified = 0,
|
||||||
|
DeviceNotPresent = 1,
|
||||||
|
NotConfiguredForUser = 2,
|
||||||
|
DisabledByPolicy = 3,
|
||||||
|
DeviceBusy = 4,
|
||||||
|
RetriesExhausted = 5,
|
||||||
|
Canceled = 6
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum UserConsentVerifierAvailability {
|
||||||
|
Available = 0,
|
||||||
|
DeviceNotPresent = 1,
|
||||||
|
NotConfiguredForUser = 2,
|
||||||
|
DisabledByPolicy = 3,
|
||||||
|
DeviceBusy = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UserConsentVerifier {
|
||||||
|
static checkAvailabilityAsync(callback: (err: Error | null, availability: UserConsentVerifierAvailability) => void): void;
|
||||||
|
static requestVerificationAsync(message: string, callback: (err: Error | null, result: UserConsentVerificationResult) => void): void;
|
||||||
|
}
|
||||||
|
}
|
||||||
39
electron/preload-env.ts
Normal file
39
electron/preload-env.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { join, dirname } from 'path'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 强制将本地资源目录添加到 PATH 最前端,确保优先加载本地 DLL
|
||||||
|
* 解决系统中存在冲突版本的 DLL 导致的应用崩溃问题
|
||||||
|
*/
|
||||||
|
function enforceLocalDllPriority() {
|
||||||
|
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||||
|
const sep = process.platform === 'win32' ? ';' : ':'
|
||||||
|
|
||||||
|
let possiblePaths: string[] = []
|
||||||
|
|
||||||
|
if (isDev) {
|
||||||
|
// 开发环境
|
||||||
|
possiblePaths.push(join(process.cwd(), 'resources'))
|
||||||
|
} else {
|
||||||
|
// 生产环境
|
||||||
|
possiblePaths.push(dirname(process.execPath))
|
||||||
|
if (process.resourcesPath) {
|
||||||
|
possiblePaths.push(process.resourcesPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dllPaths = possiblePaths.join(sep)
|
||||||
|
|
||||||
|
if (process.env.PATH) {
|
||||||
|
process.env.PATH = dllPaths + sep + process.env.PATH
|
||||||
|
} else {
|
||||||
|
process.env.PATH = dllPaths
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
enforceLocalDllPriority()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[WeFlow] Failed to enforce local DLL priority:', e)
|
||||||
|
}
|
||||||
@@ -9,6 +9,32 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
clear: () => ipcRenderer.invoke('config:clear')
|
clear: () => ipcRenderer.invoke('config:clear')
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 通知
|
||||||
|
notification: {
|
||||||
|
show: (data: any) => ipcRenderer.invoke('notification:show', data),
|
||||||
|
close: () => ipcRenderer.invoke('notification:close'),
|
||||||
|
click: (sessionId: string) => ipcRenderer.send('notification-clicked', sessionId),
|
||||||
|
ready: () => ipcRenderer.send('notification:ready'),
|
||||||
|
resize: (width: number, height: number) => ipcRenderer.send('notification:resize', { width, height }),
|
||||||
|
onShow: (callback: (event: any, data: any) => void) => {
|
||||||
|
ipcRenderer.on('notification:show', callback)
|
||||||
|
return () => ipcRenderer.removeAllListeners('notification:show')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 认证
|
||||||
|
auth: {
|
||||||
|
hello: (message?: string) => ipcRenderer.invoke('auth:hello', message),
|
||||||
|
verifyEnabled: () => ipcRenderer.invoke('auth:verifyEnabled'),
|
||||||
|
unlock: (password: string) => ipcRenderer.invoke('auth:unlock', password),
|
||||||
|
enableLock: (password: string) => ipcRenderer.invoke('auth:enableLock', password),
|
||||||
|
disableLock: (password: string) => ipcRenderer.invoke('auth:disableLock', password),
|
||||||
|
changePassword: (oldPassword: string, newPassword: string) => ipcRenderer.invoke('auth:changePassword', oldPassword, newPassword),
|
||||||
|
setHelloSecret: (password: string) => ipcRenderer.invoke('auth:setHelloSecret', password),
|
||||||
|
clearHelloSecret: () => ipcRenderer.invoke('auth:clearHelloSecret'),
|
||||||
|
isLockMode: () => ipcRenderer.invoke('auth:isLockMode')
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
// 对话框
|
// 对话框
|
||||||
dialog: {
|
dialog: {
|
||||||
@@ -29,7 +55,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
getVersion: () => ipcRenderer.invoke('app:getVersion'),
|
getVersion: () => ipcRenderer.invoke('app:getVersion'),
|
||||||
checkForUpdates: () => ipcRenderer.invoke('app:checkForUpdates'),
|
checkForUpdates: () => ipcRenderer.invoke('app:checkForUpdates'),
|
||||||
downloadAndInstall: () => ipcRenderer.invoke('app:downloadAndInstall'),
|
downloadAndInstall: () => ipcRenderer.invoke('app:downloadAndInstall'),
|
||||||
onDownloadProgress: (callback: (progress: number) => void) => {
|
ignoreUpdate: (version: string) => ipcRenderer.invoke('app:ignoreUpdate', version),
|
||||||
|
onDownloadProgress: (callback: (progress: any) => void) => {
|
||||||
ipcRenderer.on('app:downloadProgress', (_, progress) => callback(progress))
|
ipcRenderer.on('app:downloadProgress', (_, progress) => callback(progress))
|
||||||
return () => ipcRenderer.removeAllListeners('app:downloadProgress')
|
return () => ipcRenderer.removeAllListeners('app:downloadProgress')
|
||||||
},
|
},
|
||||||
@@ -42,7 +69,17 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
// 日志
|
// 日志
|
||||||
log: {
|
log: {
|
||||||
getPath: () => ipcRenderer.invoke('log:getPath'),
|
getPath: () => ipcRenderer.invoke('log:getPath'),
|
||||||
read: () => ipcRenderer.invoke('log:read')
|
read: () => ipcRenderer.invoke('log:read'),
|
||||||
|
debug: (data: any) => ipcRenderer.send('log:debug', data)
|
||||||
|
},
|
||||||
|
|
||||||
|
diagnostics: {
|
||||||
|
getExportCardLogs: (options?: { limit?: number }) =>
|
||||||
|
ipcRenderer.invoke('diagnostics:getExportCardLogs', options),
|
||||||
|
clearExportCardLogs: () =>
|
||||||
|
ipcRenderer.invoke('diagnostics:clearExportCardLogs'),
|
||||||
|
exportExportCardLogs: (payload: { filePath: string; frontendLogs?: unknown[] }) =>
|
||||||
|
ipcRenderer.invoke('diagnostics:exportExportCardLogs', payload)
|
||||||
},
|
},
|
||||||
|
|
||||||
// 窗口控制
|
// 窗口控制
|
||||||
@@ -57,13 +94,20 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) =>
|
openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) =>
|
||||||
ipcRenderer.invoke('window:openVideoPlayerWindow', videoPath, videoWidth, videoHeight),
|
ipcRenderer.invoke('window:openVideoPlayerWindow', videoPath, videoWidth, videoHeight),
|
||||||
resizeToFitVideo: (videoWidth: number, videoHeight: number) =>
|
resizeToFitVideo: (videoWidth: number, videoHeight: number) =>
|
||||||
ipcRenderer.invoke('window:resizeToFitVideo', videoWidth, videoHeight)
|
ipcRenderer.invoke('window:resizeToFitVideo', videoWidth, videoHeight),
|
||||||
|
openImageViewerWindow: (imagePath: string, liveVideoPath?: string) =>
|
||||||
|
ipcRenderer.invoke('window:openImageViewerWindow', imagePath, liveVideoPath),
|
||||||
|
openChatHistoryWindow: (sessionId: string, messageId: number) =>
|
||||||
|
ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId),
|
||||||
|
openSessionChatWindow: (sessionId: string) =>
|
||||||
|
ipcRenderer.invoke('window:openSessionChatWindow', sessionId)
|
||||||
},
|
},
|
||||||
|
|
||||||
// 数据库路径
|
// 数据库路径
|
||||||
dbPath: {
|
dbPath: {
|
||||||
autoDetect: () => ipcRenderer.invoke('dbpath:autoDetect'),
|
autoDetect: () => ipcRenderer.invoke('dbpath:autoDetect'),
|
||||||
scanWxids: (rootPath: string) => ipcRenderer.invoke('dbpath:scanWxids', rootPath),
|
scanWxids: (rootPath: string) => ipcRenderer.invoke('dbpath:scanWxids', rootPath),
|
||||||
|
scanWxidCandidates: (rootPath: string) => ipcRenderer.invoke('dbpath:scanWxidCandidates', rootPath),
|
||||||
getDefault: () => ipcRenderer.invoke('dbpath:getDefault')
|
getDefault: () => ipcRenderer.invoke('dbpath:getDefault')
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -80,7 +124,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
// 密钥获取
|
// 密钥获取
|
||||||
key: {
|
key: {
|
||||||
autoGetDbKey: () => ipcRenderer.invoke('key:autoGetDbKey'),
|
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) => {
|
onDbKeyStatus: (callback: (payload: { message: string; level: number }) => void) => {
|
||||||
ipcRenderer.on('key:dbKeyStatus', (_, payload) => callback(payload))
|
ipcRenderer.on('key:dbKeyStatus', (_, payload) => callback(payload))
|
||||||
return () => ipcRenderer.removeAllListeners('key:dbKeyStatus')
|
return () => ipcRenderer.removeAllListeners('key:dbKeyStatus')
|
||||||
@@ -96,22 +141,50 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
chat: {
|
chat: {
|
||||||
connect: () => ipcRenderer.invoke('chat:connect'),
|
connect: () => ipcRenderer.invoke('chat:connect'),
|
||||||
getSessions: () => ipcRenderer.invoke('chat:getSessions'),
|
getSessions: () => ipcRenderer.invoke('chat:getSessions'),
|
||||||
enrichSessionsContactInfo: (usernames: string[]) =>
|
getSessionStatuses: (usernames: string[]) => ipcRenderer.invoke('chat:getSessionStatuses', usernames),
|
||||||
ipcRenderer.invoke('chat:enrichSessionsContactInfo', 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) =>
|
getMessages: (sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) =>
|
||||||
ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit, startTime, endTime, ascending),
|
ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit, startTime, endTime, ascending),
|
||||||
getLatestMessages: (sessionId: string, limit?: number) =>
|
getLatestMessages: (sessionId: string, limit?: number) =>
|
||||||
ipcRenderer.invoke('chat:getLatestMessages', sessionId, limit),
|
ipcRenderer.invoke('chat:getLatestMessages', sessionId, limit),
|
||||||
|
getNewMessages: (sessionId: string, minTime: number, limit?: number) =>
|
||||||
|
ipcRenderer.invoke('chat:getNewMessages', sessionId, minTime, limit),
|
||||||
getContact: (username: string) => ipcRenderer.invoke('chat:getContact', username),
|
getContact: (username: string) => ipcRenderer.invoke('chat:getContact', username),
|
||||||
getContactAvatar: (username: string) => ipcRenderer.invoke('chat:getContactAvatar', 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'),
|
getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'),
|
||||||
downloadEmoji: (cdnUrl: string, md5?: string) => ipcRenderer.invoke('chat:downloadEmoji', cdnUrl, md5),
|
downloadEmoji: (cdnUrl: string, md5?: string) => ipcRenderer.invoke('chat:downloadEmoji', cdnUrl, md5),
|
||||||
getCachedMessages: (sessionId: string) => ipcRenderer.invoke('chat:getCachedMessages', sessionId),
|
getCachedMessages: (sessionId: string) => ipcRenderer.invoke('chat:getCachedMessages', sessionId),
|
||||||
|
clearCurrentAccountData: (options: { clearCache?: boolean; clearExports?: boolean }) =>
|
||||||
|
ipcRenderer.invoke('chat:clearCurrentAccountData', options),
|
||||||
close: () => ipcRenderer.invoke('chat:close'),
|
close: () => ipcRenderer.invoke('chat:close'),
|
||||||
getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId),
|
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),
|
getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId),
|
||||||
getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) =>
|
getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) =>
|
||||||
ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime, serverId),
|
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),
|
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),
|
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId, createTime),
|
||||||
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => {
|
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => {
|
||||||
@@ -120,7 +193,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
return () => ipcRenderer.removeListener('chat:voiceTranscriptPartial', listener)
|
return () => ipcRenderer.removeListener('chat:voiceTranscriptPartial', listener)
|
||||||
},
|
},
|
||||||
execQuery: (kind: string, path: string | null, sql: string) =>
|
execQuery: (kind: string, path: string | null, sql: string) =>
|
||||||
ipcRenderer.invoke('chat:execQuery', kind, path, sql)
|
ipcRenderer.invoke('chat:execQuery', kind, path, sql),
|
||||||
|
getContacts: () => ipcRenderer.invoke('chat:getContacts'),
|
||||||
|
getMessage: (sessionId: string, localId: number) =>
|
||||||
|
ipcRenderer.invoke('chat:getMessage', sessionId, localId),
|
||||||
|
onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => {
|
||||||
|
ipcRenderer.on('wcdb-change', callback)
|
||||||
|
return () => ipcRenderer.removeListener('wcdb-change', callback)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
@@ -151,9 +231,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
|
|
||||||
// 数据分析
|
// 数据分析
|
||||||
analytics: {
|
analytics: {
|
||||||
getOverallStatistics: () => ipcRenderer.invoke('analytics:getOverallStatistics'),
|
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'),
|
getTimeDistribution: () => ipcRenderer.invoke('analytics:getTimeDistribution'),
|
||||||
|
getExcludedUsernames: () => ipcRenderer.invoke('analytics:getExcludedUsernames'),
|
||||||
|
setExcludedUsernames: (usernames: string[]) => ipcRenderer.invoke('analytics:setExcludedUsernames', usernames),
|
||||||
|
getExcludeCandidates: () => ipcRenderer.invoke('analytics:getExcludeCandidates'),
|
||||||
onProgress: (callback: (payload: { status: string; progress: number }) => void) => {
|
onProgress: (callback: (payload: { status: string; progress: number }) => void) => {
|
||||||
ipcRenderer.on('analytics:progress', (_, payload) => callback(payload))
|
ipcRenderer.on('analytics:progress', (_, payload) => callback(payload))
|
||||||
return () => ipcRenderer.removeAllListeners('analytics:progress')
|
return () => ipcRenderer.removeAllListeners('analytics:progress')
|
||||||
@@ -171,30 +255,69 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
groupAnalytics: {
|
groupAnalytics: {
|
||||||
getGroupChats: () => ipcRenderer.invoke('groupAnalytics:getGroupChats'),
|
getGroupChats: () => ipcRenderer.invoke('groupAnalytics:getGroupChats'),
|
||||||
getGroupMembers: (chatroomId: string) => ipcRenderer.invoke('groupAnalytics:getGroupMembers', chatroomId),
|
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),
|
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),
|
getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime),
|
||||||
getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime)
|
getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime),
|
||||||
|
exportGroupMembers: (chatroomId: string, outputPath: string) => ipcRenderer.invoke('groupAnalytics:exportGroupMembers', chatroomId, outputPath),
|
||||||
|
exportGroupMemberMessages: (chatroomId: string, memberUsername: string, outputPath: string, startTime?: number, endTime?: number) =>
|
||||||
|
ipcRenderer.invoke('groupAnalytics:exportGroupMemberMessages', chatroomId, memberUsername, outputPath, startTime, endTime)
|
||||||
},
|
},
|
||||||
|
|
||||||
// 年度报告
|
// 年度报告
|
||||||
annualReport: {
|
annualReport: {
|
||||||
getAvailableYears: () => ipcRenderer.invoke('annualReport:getAvailableYears'),
|
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),
|
generateReport: (year: number) => ipcRenderer.invoke('annualReport:generateReport', year),
|
||||||
exportImages: (payload: { baseDir: string; folderName: string; images: Array<{ name: string; dataUrl: string }> }) =>
|
exportImages: (payload: { baseDir: string; folderName: string; images: Array<{ name: string; dataUrl: string }> }) =>
|
||||||
ipcRenderer.invoke('annualReport:exportImages', payload),
|
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) => {
|
onProgress: (callback: (payload: { status: string; progress: number }) => void) => {
|
||||||
ipcRenderer.on('annualReport:progress', (_, payload) => callback(payload))
|
ipcRenderer.on('annualReport:progress', (_, payload) => callback(payload))
|
||||||
return () => ipcRenderer.removeAllListeners('annualReport:progress')
|
return () => ipcRenderer.removeAllListeners('annualReport:progress')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
dualReport: {
|
||||||
|
generateReport: (payload: { friendUsername: string; year: number }) =>
|
||||||
|
ipcRenderer.invoke('dualReport:generateReport', payload),
|
||||||
|
onProgress: (callback: (payload: { status: string; progress: number }) => void) => {
|
||||||
|
ipcRenderer.on('dualReport:progress', (_, payload) => callback(payload))
|
||||||
|
return () => ipcRenderer.removeAllListeners('dualReport:progress')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// 导出
|
// 导出
|
||||||
export: {
|
export: {
|
||||||
|
getExportStats: (sessionIds: string[], options: any) =>
|
||||||
|
ipcRenderer.invoke('export:getExportStats', sessionIds, options),
|
||||||
exportSessions: (sessionIds: string[], outputDir: string, options: any) =>
|
exportSessions: (sessionIds: string[], outputDir: string, options: any) =>
|
||||||
ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options),
|
ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options),
|
||||||
exportSession: (sessionId: string, outputPath: string, options: any) =>
|
exportSession: (sessionId: string, outputPath: string, options: any) =>
|
||||||
ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options),
|
ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options),
|
||||||
onProgress: (callback: (payload: { current: number; total: number; currentSession: string; phase: string }) => void) => {
|
exportContacts: (outputDir: string, options: any) =>
|
||||||
|
ipcRenderer.invoke('export:exportContacts', outputDir, options),
|
||||||
|
onProgress: (callback: (payload: { current: number; total: number; currentSession: string; currentSessionId?: string; phase: string }) => void) => {
|
||||||
ipcRenderer.on('export:progress', (_, payload) => callback(payload))
|
ipcRenderer.on('export:progress', (_, payload) => callback(payload))
|
||||||
return () => ipcRenderer.removeAllListeners('export:progress')
|
return () => ipcRenderer.removeAllListeners('export:progress')
|
||||||
}
|
}
|
||||||
@@ -214,6 +337,38 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
// 朋友圈
|
// 朋友圈
|
||||||
sns: {
|
sns: {
|
||||||
getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) =>
|
getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) =>
|
||||||
ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime)
|
ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime),
|
||||||
|
getSnsUsernames: () => ipcRenderer.invoke('sns:getSnsUsernames'),
|
||||||
|
getExportStatsFast: () => ipcRenderer.invoke('sns:getExportStatsFast'),
|
||||||
|
getExportStats: () => ipcRenderer.invoke('sns:getExportStats'),
|
||||||
|
debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url),
|
||||||
|
proxyImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:proxyImage', payload),
|
||||||
|
downloadImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:downloadImage', payload),
|
||||||
|
exportTimeline: (options: any) => ipcRenderer.invoke('sns:exportTimeline', options),
|
||||||
|
onExportProgress: (callback: (payload: any) => void) => {
|
||||||
|
ipcRenderer.on('sns:exportProgress', (_, payload) => callback(payload))
|
||||||
|
return () => ipcRenderer.removeAllListeners('sns:exportProgress')
|
||||||
|
},
|
||||||
|
selectExportDir: () => ipcRenderer.invoke('sns:selectExportDir'),
|
||||||
|
installBlockDeleteTrigger: () => ipcRenderer.invoke('sns:installBlockDeleteTrigger'),
|
||||||
|
uninstallBlockDeleteTrigger: () => ipcRenderer.invoke('sns:uninstallBlockDeleteTrigger'),
|
||||||
|
checkBlockDeleteTrigger: () => ipcRenderer.invoke('sns:checkBlockDeleteTrigger'),
|
||||||
|
deleteSnsPost: (postId: string) => ipcRenderer.invoke('sns:deleteSnsPost', postId),
|
||||||
|
downloadEmoji: (params: { url: string; encryptUrl?: string; aesKey?: string }) => ipcRenderer.invoke('sns:downloadEmoji', params)
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
// 数据收集
|
||||||
|
cloud: {
|
||||||
|
init: () => ipcRenderer.invoke('cloud:init'),
|
||||||
|
recordPage: (pageName: string) => ipcRenderer.invoke('cloud:recordPage', pageName),
|
||||||
|
getLogs: () => ipcRenderer.invoke('cloud:getLogs')
|
||||||
|
},
|
||||||
|
|
||||||
|
// HTTP API 服务
|
||||||
|
http: {
|
||||||
|
start: (port?: number) => ipcRenderer.invoke('http:start', port),
|
||||||
|
stop: () => ipcRenderer.invoke('http:stop'),
|
||||||
|
status: () => ipcRenderer.invoke('http:status')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { wcdbService } from './wcdbService'
|
|||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { readFile, writeFile, rm } from 'fs/promises'
|
import { readFile, writeFile, rm } from 'fs/promises'
|
||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
|
import { createHash } from 'crypto'
|
||||||
|
|
||||||
export interface ChatStatistics {
|
export interface ChatStatistics {
|
||||||
totalMessages: number
|
totalMessages: number
|
||||||
@@ -30,6 +31,7 @@ export interface ContactRanking {
|
|||||||
username: string
|
username: string
|
||||||
displayName: string
|
displayName: string
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
|
wechatId?: string
|
||||||
messageCount: number
|
messageCount: number
|
||||||
sentCount: number
|
sentCount: number
|
||||||
receivedCount: number
|
receivedCount: number
|
||||||
@@ -46,6 +48,54 @@ class AnalyticsService {
|
|||||||
this.configService = new ConfigService()
|
this.configService = new ConfigService()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private normalizeUsername(username: string): string {
|
||||||
|
return username.trim().toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeExcludedUsernames(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) return []
|
||||||
|
const normalized = value
|
||||||
|
.map((item) => typeof item === 'string' ? item.trim().toLowerCase() : '')
|
||||||
|
.filter((item) => item.length > 0)
|
||||||
|
return Array.from(new Set(normalized))
|
||||||
|
}
|
||||||
|
|
||||||
|
private getExcludedUsernamesList(): string[] {
|
||||||
|
return this.normalizeExcludedUsernames(this.configService.get('analyticsExcludedUsernames'))
|
||||||
|
}
|
||||||
|
|
||||||
|
private getExcludedUsernamesSet(): Set<string> {
|
||||||
|
return new Set(this.getExcludedUsernamesList())
|
||||||
|
}
|
||||||
|
|
||||||
|
private escapeSqlValue(value: string): string {
|
||||||
|
return value.replace(/'/g, "''")
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAliasMap(usernames: string[]): Promise<Record<string, string>> {
|
||||||
|
const map: Record<string, string> = {}
|
||||||
|
if (usernames.length === 0) return map
|
||||||
|
|
||||||
|
// C++ 层不支持参数绑定,直接内联转义后的字符串值
|
||||||
|
const chunkSize = 200
|
||||||
|
for (let i = 0; i < usernames.length; i += chunkSize) {
|
||||||
|
const chunk = usernames.slice(i, i + chunkSize)
|
||||||
|
const inList = chunk.map((u) => `'${this.escapeSqlValue(u)}'`).join(',')
|
||||||
|
const sql = `SELECT username, alias FROM contact WHERE username IN (${inList})`
|
||||||
|
const result = await wcdbService.execQuery('contact', null, sql)
|
||||||
|
if (!result.success || !result.rows) continue
|
||||||
|
for (const row of result.rows as Record<string, any>[]) {
|
||||||
|
const username = row.username || ''
|
||||||
|
const alias = row.alias || ''
|
||||||
|
if (username && alias) {
|
||||||
|
map[username] = alias
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
private cleanAccountDirName(name: string): string {
|
private cleanAccountDirName(name: string): string {
|
||||||
const trimmed = name.trim()
|
const trimmed = name.trim()
|
||||||
if (!trimmed) return trimmed
|
if (!trimmed) return trimmed
|
||||||
@@ -54,7 +104,11 @@ class AnalyticsService {
|
|||||||
if (match) return match[1]
|
if (match) return match[1]
|
||||||
return trimmed
|
return trimmed
|
||||||
}
|
}
|
||||||
return trimmed
|
|
||||||
|
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
||||||
|
const cleaned = suffixMatch ? suffixMatch[1] : trimmed
|
||||||
|
|
||||||
|
return cleaned
|
||||||
}
|
}
|
||||||
|
|
||||||
private isPrivateSession(username: string, cleanedWxid: string): boolean {
|
private isPrivateSession(username: string, cleanedWxid: string): boolean {
|
||||||
@@ -97,13 +151,15 @@ class AnalyticsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async getPrivateSessions(
|
private async getPrivateSessions(
|
||||||
cleanedWxid: string
|
cleanedWxid: string,
|
||||||
|
excludedUsernames?: Set<string>
|
||||||
): Promise<{ usernames: string[]; numericIds: string[] }> {
|
): Promise<{ usernames: string[]; numericIds: string[] }> {
|
||||||
const sessionResult = await wcdbService.getSessions()
|
const sessionResult = await wcdbService.getSessions()
|
||||||
if (!sessionResult.success || !sessionResult.sessions) {
|
if (!sessionResult.success || !sessionResult.sessions) {
|
||||||
return { usernames: [], numericIds: [] }
|
return { usernames: [], numericIds: [] }
|
||||||
}
|
}
|
||||||
const rows = sessionResult.sessions as Record<string, any>[]
|
const rows = sessionResult.sessions as Record<string, any>[]
|
||||||
|
const excluded = excludedUsernames ?? this.getExcludedUsernamesSet()
|
||||||
|
|
||||||
const sample = rows[0]
|
const sample = rows[0]
|
||||||
void sample
|
void sample
|
||||||
@@ -124,7 +180,11 @@ class AnalyticsService {
|
|||||||
return { username, idValue }
|
return { username, idValue }
|
||||||
})
|
})
|
||||||
const usernames = sessions.map((s) => s.username)
|
const usernames = sessions.map((s) => s.username)
|
||||||
const privateSessions = sessions.filter((s) => this.isPrivateSession(s.username, cleanedWxid))
|
const privateSessions = sessions.filter((s) => {
|
||||||
|
if (!this.isPrivateSession(s.username, cleanedWxid)) return false
|
||||||
|
if (excluded.size === 0) return true
|
||||||
|
return !excluded.has(this.normalizeUsername(s.username))
|
||||||
|
})
|
||||||
const privateUsernames = privateSessions.map((s) => s.username)
|
const privateUsernames = privateSessions.map((s) => s.username)
|
||||||
const numericIds = privateSessions
|
const numericIds = privateSessions
|
||||||
.map((s) => s.idValue)
|
.map((s) => s.idValue)
|
||||||
@@ -177,11 +237,18 @@ class AnalyticsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildAggregateCacheKey(sessionIds: string[], beginTimestamp: number, endTimestamp: number): string {
|
private buildAggregateCacheKey(sessionIds: string[], beginTimestamp: number, endTimestamp: number): string {
|
||||||
const sample = sessionIds.slice(0, 5).join(',')
|
if (sessionIds.length === 0) {
|
||||||
return `${beginTimestamp}-${endTimestamp}-${sessionIds.length}-${sample}`
|
return `${beginTimestamp}-${endTimestamp}-0-empty`
|
||||||
|
}
|
||||||
|
const normalized = Array.from(new Set(sessionIds.map((id) => String(id)))).sort()
|
||||||
|
const hash = createHash('sha1').update(normalized.join('|')).digest('hex').slice(0, 12)
|
||||||
|
return `${beginTimestamp}-${endTimestamp}-${normalized.length}-${hash}`
|
||||||
}
|
}
|
||||||
|
|
||||||
private async computeAggregateByCursor(sessionIds: string[], beginTimestamp = 0, endTimestamp = 0): Promise<any> {
|
private async computeAggregateByCursor(sessionIds: string[], beginTimestamp = 0, endTimestamp = 0): Promise<any> {
|
||||||
|
const wxid = this.configService.get('myWxid')
|
||||||
|
const cleanedWxid = wxid ? this.cleanAccountDirName(wxid) : ''
|
||||||
|
|
||||||
const aggregate = {
|
const aggregate = {
|
||||||
total: 0,
|
total: 0,
|
||||||
sent: 0,
|
sent: 0,
|
||||||
@@ -206,8 +273,22 @@ class AnalyticsService {
|
|||||||
if (endTimestamp > 0 && createTime > endTimestamp) return
|
if (endTimestamp > 0 && createTime > endTimestamp) return
|
||||||
|
|
||||||
const localType = parseInt(row.local_type || row.type || '1', 10)
|
const localType = parseInt(row.local_type || row.type || '1', 10)
|
||||||
const isSendRaw = row.computed_is_send ?? row.is_send ?? row.isSend ?? 0
|
const isSendRaw = row.computed_is_send ?? row.is_send ?? row.isSend
|
||||||
const isSend = String(isSendRaw) === '1' || isSendRaw === 1 || isSendRaw === true
|
let isSend = String(isSendRaw) === '1' || isSendRaw === 1 || isSendRaw === true
|
||||||
|
|
||||||
|
// 如果底层没有提供 is_send,则根据发送者用户名推断
|
||||||
|
const senderUsername = row.sender_username || row.senderUsername || row.sender
|
||||||
|
if (isSendRaw === undefined || isSendRaw === null) {
|
||||||
|
if (senderUsername && (cleanedWxid)) {
|
||||||
|
const senderLower = String(senderUsername).toLowerCase()
|
||||||
|
const myWxidLower = cleanedWxid.toLowerCase()
|
||||||
|
isSend = (
|
||||||
|
senderLower === myWxidLower ||
|
||||||
|
// 兼容非 wxid 开头的账号(如果文件夹名带后缀,如 custom_backup,而 sender 是 custom)
|
||||||
|
(myWxidLower.startsWith(senderLower + '_'))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
aggregate.total += 1
|
aggregate.total += 1
|
||||||
sessionStat.total += 1
|
sessionStat.total += 1
|
||||||
@@ -369,6 +450,65 @@ class AnalyticsService {
|
|||||||
void results
|
void results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getExcludedUsernames(): Promise<{ success: boolean; data?: string[]; error?: string }> {
|
||||||
|
try {
|
||||||
|
return { success: true, data: this.getExcludedUsernamesList() }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setExcludedUsernames(usernames: string[]): Promise<{ success: boolean; data?: string[]; error?: string }> {
|
||||||
|
try {
|
||||||
|
const normalized = this.normalizeExcludedUsernames(usernames)
|
||||||
|
this.configService.set('analyticsExcludedUsernames', normalized)
|
||||||
|
await this.clearCache()
|
||||||
|
return { success: true, data: normalized }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getExcludeCandidates(): Promise<{ success: boolean; data?: Array<{ username: string; displayName: string; avatarUrl?: string; wechatId?: string }>; error?: string }> {
|
||||||
|
try {
|
||||||
|
const conn = await this.ensureConnected()
|
||||||
|
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
|
||||||
|
|
||||||
|
const excluded = this.getExcludedUsernamesSet()
|
||||||
|
const sessionInfo = await this.getPrivateSessions(conn.cleanedWxid, new Set())
|
||||||
|
|
||||||
|
const usernames = new Set<string>(sessionInfo.usernames)
|
||||||
|
for (const name of excluded) usernames.add(name)
|
||||||
|
|
||||||
|
if (usernames.size === 0) {
|
||||||
|
return { success: true, data: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const usernameList = Array.from(usernames)
|
||||||
|
const [displayNames, avatarUrls, aliasMap] = await Promise.all([
|
||||||
|
wcdbService.getDisplayNames(usernameList),
|
||||||
|
wcdbService.getAvatarUrls(usernameList),
|
||||||
|
this.getAliasMap(usernameList)
|
||||||
|
])
|
||||||
|
|
||||||
|
const entries = usernameList.map((username) => {
|
||||||
|
const displayName = displayNames.success && displayNames.map
|
||||||
|
? (displayNames.map[username] || username)
|
||||||
|
: username
|
||||||
|
const avatarUrl = avatarUrls.success && avatarUrls.map
|
||||||
|
? avatarUrls.map[username]
|
||||||
|
: undefined
|
||||||
|
const alias = aliasMap[username]
|
||||||
|
const wechatId = alias || (!username.startsWith('wxid_') ? username : '')
|
||||||
|
return { username, displayName, avatarUrl, wechatId }
|
||||||
|
})
|
||||||
|
|
||||||
|
return { success: true, data: entries }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getOverallStatistics(force = false): Promise<{ success: boolean; data?: ChatStatistics; error?: string }> {
|
async getOverallStatistics(force = false): Promise<{ success: boolean; data?: ChatStatistics; error?: string }> {
|
||||||
try {
|
try {
|
||||||
const conn = await this.ensureConnected()
|
const conn = await this.ensureConnected()
|
||||||
@@ -433,7 +573,11 @@ class AnalyticsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getContactRankings(limit: number = 20): Promise<{ success: boolean; data?: ContactRanking[]; error?: string }> {
|
async getContactRankings(
|
||||||
|
limit: number = 20,
|
||||||
|
beginTimestamp: number = 0,
|
||||||
|
endTimestamp: number = 0
|
||||||
|
): Promise<{ success: boolean; data?: ContactRanking[]; error?: string }> {
|
||||||
try {
|
try {
|
||||||
const conn = await this.ensureConnected()
|
const conn = await this.ensureConnected()
|
||||||
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
|
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
|
||||||
@@ -443,7 +587,7 @@ class AnalyticsService {
|
|||||||
return { success: false, error: '未找到消息会话' }
|
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) {
|
if (!result.success || !result.data) {
|
||||||
return { success: false, error: result.error || '聚合统计失败' }
|
return { success: false, error: result.error || '聚合统计失败' }
|
||||||
}
|
}
|
||||||
@@ -451,9 +595,10 @@ class AnalyticsService {
|
|||||||
const d = result.data
|
const d = result.data
|
||||||
const sessions = this.normalizeAggregateSessions(d.sessions, d.idMap)
|
const sessions = this.normalizeAggregateSessions(d.sessions, d.idMap)
|
||||||
const usernames = Object.keys(sessions)
|
const usernames = Object.keys(sessions)
|
||||||
const [displayNames, avatarUrls] = await Promise.all([
|
const [displayNames, avatarUrls, aliasMap] = await Promise.all([
|
||||||
wcdbService.getDisplayNames(usernames),
|
wcdbService.getDisplayNames(usernames),
|
||||||
wcdbService.getAvatarUrls(usernames)
|
wcdbService.getAvatarUrls(usernames),
|
||||||
|
this.getAliasMap(usernames)
|
||||||
])
|
])
|
||||||
|
|
||||||
const rankings: ContactRanking[] = usernames
|
const rankings: ContactRanking[] = usernames
|
||||||
@@ -465,10 +610,13 @@ class AnalyticsService {
|
|||||||
const avatarUrl = avatarUrls.success && avatarUrls.map
|
const avatarUrl = avatarUrls.success && avatarUrls.map
|
||||||
? avatarUrls.map[username]
|
? avatarUrls.map[username]
|
||||||
: undefined
|
: undefined
|
||||||
|
const alias = aliasMap[username] || ''
|
||||||
|
const wechatId = alias || (!username.startsWith('wxid_') ? username : '')
|
||||||
return {
|
return {
|
||||||
username,
|
username,
|
||||||
displayName,
|
displayName,
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
|
wechatId,
|
||||||
messageCount: stat.total,
|
messageCount: stat.total,
|
||||||
sentCount: stat.sent,
|
sentCount: stat.sent,
|
||||||
receivedCount: stat.received,
|
receivedCount: stat.received,
|
||||||
|
|||||||
@@ -69,9 +69,50 @@ export interface AnnualReportData {
|
|||||||
phrase: string
|
phrase: string
|
||||||
count: number
|
count: number
|
||||||
}[]
|
}[]
|
||||||
|
snsStats?: {
|
||||||
|
totalPosts: number
|
||||||
|
typeCounts?: Record<string, number>
|
||||||
|
topLikers: { username: string; displayName: string; avatarUrl?: string; count: number }[]
|
||||||
|
topLiked: { username: string; displayName: string; avatarUrl?: string; count: number }[]
|
||||||
|
}
|
||||||
|
lostFriend: {
|
||||||
|
username: string
|
||||||
|
displayName: string
|
||||||
|
avatarUrl?: string
|
||||||
|
earlyCount: number
|
||||||
|
lateCount: number
|
||||||
|
periodDesc: string
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AvailableYearsLoadProgress {
|
||||||
|
years: number[]
|
||||||
|
strategy: 'cache' | 'native' | 'hybrid'
|
||||||
|
phase: 'cache' | 'native' | 'scan'
|
||||||
|
statusText: string
|
||||||
|
nativeElapsedMs: number
|
||||||
|
scanElapsedMs: number
|
||||||
|
totalElapsedMs: number
|
||||||
|
switched?: boolean
|
||||||
|
nativeTimedOut?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AvailableYearsLoadMeta {
|
||||||
|
strategy: 'cache' | 'native' | 'hybrid'
|
||||||
|
nativeElapsedMs: number
|
||||||
|
scanElapsedMs: number
|
||||||
|
totalElapsedMs: number
|
||||||
|
switched: boolean
|
||||||
|
nativeTimedOut: boolean
|
||||||
|
statusText: string
|
||||||
}
|
}
|
||||||
|
|
||||||
class AnnualReportService {
|
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() {
|
constructor() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,8 +142,9 @@ class AnnualReportService {
|
|||||||
return trimmed
|
return trimmed
|
||||||
}
|
}
|
||||||
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
||||||
if (suffixMatch) return suffixMatch[1]
|
const cleaned = suffixMatch ? suffixMatch[1] : trimmed
|
||||||
return trimmed
|
|
||||||
|
return cleaned
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ensureConnectedWithConfig(
|
private async ensureConnectedWithConfig(
|
||||||
@@ -166,6 +208,234 @@ class AnnualReportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private quoteSqlIdentifier(identifier: string): string {
|
||||||
|
return `"${String(identifier || '').replace(/"/g, '""')}"`
|
||||||
|
}
|
||||||
|
|
||||||
|
private toUnixTimestamp(value: any): number {
|
||||||
|
const n = Number(value)
|
||||||
|
if (!Number.isFinite(n) || n <= 0) return 0
|
||||||
|
// 兼容毫秒级时间戳
|
||||||
|
const seconds = n > 1e12 ? Math.floor(n / 1000) : Math.floor(n)
|
||||||
|
return seconds > 0 ? seconds : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private addYearsFromRange(years: Set<number>, firstTs: number, lastTs: number): boolean {
|
||||||
|
let changed = false
|
||||||
|
const currentYear = new Date().getFullYear()
|
||||||
|
const minTs = firstTs > 0 ? firstTs : lastTs
|
||||||
|
const maxTs = lastTs > 0 ? lastTs : firstTs
|
||||||
|
if (minTs <= 0 || maxTs <= 0) return changed
|
||||||
|
|
||||||
|
const minYear = new Date(minTs * 1000).getFullYear()
|
||||||
|
const maxYear = new Date(maxTs * 1000).getFullYear()
|
||||||
|
for (let y = minYear; y <= maxYear; y++) {
|
||||||
|
if (y >= 2010 && y <= currentYear && !years.has(y)) {
|
||||||
|
years.add(y)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeAvailableYears(years: Iterable<number>): number[] {
|
||||||
|
return Array.from(new Set(Array.from(years)))
|
||||||
|
.filter((y) => Number.isFinite(y))
|
||||||
|
.map((y) => Math.floor(y))
|
||||||
|
.sort((a, b) => b - a)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async forEachWithConcurrency<T>(
|
||||||
|
items: T[],
|
||||||
|
concurrency: number,
|
||||||
|
handler: (item: T, index: number) => Promise<void>,
|
||||||
|
shouldStop?: () => boolean
|
||||||
|
): Promise<void> {
|
||||||
|
if (!items.length) return
|
||||||
|
const workerCount = Math.max(1, Math.min(concurrency, items.length))
|
||||||
|
let nextIndex = 0
|
||||||
|
const workers: Promise<void>[] = []
|
||||||
|
|
||||||
|
for (let i = 0; i < workerCount; i++) {
|
||||||
|
workers.push((async () => {
|
||||||
|
while (true) {
|
||||||
|
if (shouldStop?.()) break
|
||||||
|
const current = nextIndex
|
||||||
|
nextIndex += 1
|
||||||
|
if (current >= items.length) break
|
||||||
|
await handler(items[current], current)
|
||||||
|
}
|
||||||
|
})())
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(workers)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async detectTimeColumn(dbPath: string, tableName: string): Promise<string | null> {
|
||||||
|
const cacheKey = `${dbPath}\u0001${tableName}`
|
||||||
|
if (this.availableYearsColumnCache.has(cacheKey)) {
|
||||||
|
const cached = this.availableYearsColumnCache.get(cacheKey) || ''
|
||||||
|
return cached || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await wcdbService.execQuery('message', dbPath, `PRAGMA table_info(${this.quoteSqlIdentifier(tableName)})`)
|
||||||
|
if (!result.success || !Array.isArray(result.rows) || result.rows.length === 0) {
|
||||||
|
this.availableYearsColumnCache.set(cacheKey, '')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = ['create_time', 'createtime', 'msg_create_time', 'msg_time', 'msgtime', 'time']
|
||||||
|
const columns = new Set<string>()
|
||||||
|
for (const row of result.rows as Record<string, any>[]) {
|
||||||
|
const name = String(row.name || row.column_name || row.columnName || '').trim().toLowerCase()
|
||||||
|
if (name) columns.add(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (columns.has(candidate)) {
|
||||||
|
this.availableYearsColumnCache.set(cacheKey, candidate)
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.availableYearsColumnCache.set(cacheKey, '')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getTableTimeRange(dbPath: string, tableName: string): Promise<{ first: number; last: number } | null> {
|
||||||
|
const cacheKey = `${dbPath}\u0001${tableName}`
|
||||||
|
const cachedColumn = this.availableYearsColumnCache.get(cacheKey)
|
||||||
|
const initialColumn = cachedColumn && cachedColumn.length > 0 ? cachedColumn : 'create_time'
|
||||||
|
const tried = new Set<string>()
|
||||||
|
|
||||||
|
const queryByColumn = async (column: string): Promise<{ first: number; last: number } | null> => {
|
||||||
|
const sql = `SELECT MIN(${this.quoteSqlIdentifier(column)}) AS first_ts, MAX(${this.quoteSqlIdentifier(column)}) AS last_ts FROM ${this.quoteSqlIdentifier(tableName)}`
|
||||||
|
const result = await wcdbService.execQuery('message', dbPath, sql)
|
||||||
|
if (!result.success || !Array.isArray(result.rows) || result.rows.length === 0) return null
|
||||||
|
const row = result.rows[0] as Record<string, any>
|
||||||
|
const first = this.toUnixTimestamp(row.first_ts ?? row.firstTs ?? row.min_ts ?? row.minTs)
|
||||||
|
const last = this.toUnixTimestamp(row.last_ts ?? row.lastTs ?? row.max_ts ?? row.maxTs)
|
||||||
|
return { first, last }
|
||||||
|
}
|
||||||
|
|
||||||
|
tried.add(initialColumn)
|
||||||
|
const quick = await queryByColumn(initialColumn)
|
||||||
|
if (quick) {
|
||||||
|
if (!cachedColumn) this.availableYearsColumnCache.set(cacheKey, initialColumn)
|
||||||
|
return quick
|
||||||
|
}
|
||||||
|
|
||||||
|
const detectedColumn = await this.detectTimeColumn(dbPath, tableName)
|
||||||
|
if (!detectedColumn || tried.has(detectedColumn)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return queryByColumn(detectedColumn)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAvailableYearsByTableScan(
|
||||||
|
sessionIds: string[],
|
||||||
|
options?: { onProgress?: (years: number[]) => void; shouldCancel?: () => boolean }
|
||||||
|
): Promise<number[]> {
|
||||||
|
const years = new Set<number>()
|
||||||
|
let lastEmittedSize = 0
|
||||||
|
|
||||||
|
const emitIfChanged = (force = false) => {
|
||||||
|
if (!options?.onProgress) return
|
||||||
|
const next = this.normalizeAvailableYears(years)
|
||||||
|
if (!force && next.length === lastEmittedSize) return
|
||||||
|
options.onProgress(next)
|
||||||
|
lastEmittedSize = next.length
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldCancel = () => options?.shouldCancel?.() === true
|
||||||
|
|
||||||
|
await this.forEachWithConcurrency(sessionIds, this.availableYearsScanConcurrency, async (sessionId) => {
|
||||||
|
if (shouldCancel()) return
|
||||||
|
const tableStats = await wcdbService.getMessageTableStats(sessionId)
|
||||||
|
if (!tableStats.success || !Array.isArray(tableStats.tables) || tableStats.tables.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const table of tableStats.tables as Record<string, any>[]) {
|
||||||
|
if (shouldCancel()) return
|
||||||
|
const tableName = String(table.table_name || table.name || '').trim()
|
||||||
|
const dbPath = String(table.db_path || table.dbPath || '').trim()
|
||||||
|
if (!tableName || !dbPath) continue
|
||||||
|
|
||||||
|
const range = await this.getTableTimeRange(dbPath, tableName)
|
||||||
|
if (!range) continue
|
||||||
|
const changed = this.addYearsFromRange(years, range.first, range.last)
|
||||||
|
if (changed) emitIfChanged()
|
||||||
|
}
|
||||||
|
}, shouldCancel)
|
||||||
|
|
||||||
|
emitIfChanged(true)
|
||||||
|
return this.normalizeAvailableYears(years)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAvailableYearsByEdgeScan(
|
||||||
|
sessionIds: string[],
|
||||||
|
options?: { onProgress?: (years: number[]) => void; shouldCancel?: () => boolean }
|
||||||
|
): Promise<number[]> {
|
||||||
|
const years = new Set<number>()
|
||||||
|
let lastEmittedSize = 0
|
||||||
|
const shouldCancel = () => options?.shouldCancel?.() === true
|
||||||
|
|
||||||
|
const emitIfChanged = (force = false) => {
|
||||||
|
if (!options?.onProgress) return
|
||||||
|
const next = this.normalizeAvailableYears(years)
|
||||||
|
if (!force && next.length === lastEmittedSize) return
|
||||||
|
options.onProgress(next)
|
||||||
|
lastEmittedSize = next.length
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const sessionId of sessionIds) {
|
||||||
|
if (shouldCancel()) break
|
||||||
|
const first = await this.getEdgeMessageTime(sessionId, true)
|
||||||
|
const last = await this.getEdgeMessageTime(sessionId, false)
|
||||||
|
const changed = this.addYearsFromRange(years, first || 0, last || 0)
|
||||||
|
if (changed) emitIfChanged()
|
||||||
|
}
|
||||||
|
emitIfChanged(true)
|
||||||
|
return this.normalizeAvailableYears(years)
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildAvailableYearsCacheKey(dbPath: string, cleanedWxid: string): string {
|
||||||
|
return `${dbPath}\u0001${cleanedWxid}`
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCachedAvailableYears(cacheKey: string): number[] | null {
|
||||||
|
const cached = this.availableYearsCache.get(cacheKey)
|
||||||
|
if (!cached) return null
|
||||||
|
if (Date.now() - cached.updatedAt > this.availableYearsCacheTtlMs) {
|
||||||
|
this.availableYearsCache.delete(cacheKey)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return [...cached.years]
|
||||||
|
}
|
||||||
|
|
||||||
|
private setCachedAvailableYears(cacheKey: string, years: number[]): void {
|
||||||
|
const normalized = this.normalizeAvailableYears(years)
|
||||||
|
|
||||||
|
this.availableYearsCache.set(cacheKey, {
|
||||||
|
years: normalized,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (this.availableYearsCache.size > 8) {
|
||||||
|
let oldestKey = ''
|
||||||
|
let oldestTime = Number.POSITIVE_INFINITY
|
||||||
|
for (const [key, val] of this.availableYearsCache) {
|
||||||
|
if (val.updatedAt < oldestTime) {
|
||||||
|
oldestTime = val.updatedAt
|
||||||
|
oldestKey = key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (oldestKey) this.availableYearsCache.delete(oldestKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private decodeMessageContent(messageContent: any, compressContent: any): string {
|
private decodeMessageContent(messageContent: any, compressContent: any): string {
|
||||||
let content = this.decodeMaybeCompressed(compressContent)
|
let content = this.decodeMaybeCompressed(compressContent)
|
||||||
if (!content || content.length === 0) {
|
if (!content || content.length === 0) {
|
||||||
@@ -178,11 +448,15 @@ class AnnualReportService {
|
|||||||
if (!raw) return ''
|
if (!raw) return ''
|
||||||
if (typeof raw === 'string') {
|
if (typeof raw === 'string') {
|
||||||
if (raw.length === 0) return ''
|
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')
|
const bytes = Buffer.from(raw, 'hex')
|
||||||
if (bytes.length > 0) return this.decodeBinaryContent(bytes)
|
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 {
|
try {
|
||||||
const bytes = Buffer.from(raw, 'base64')
|
const bytes = Buffer.from(raw, 'base64')
|
||||||
return this.decodeBinaryContent(bytes)
|
return this.decodeBinaryContent(bytes)
|
||||||
@@ -340,38 +614,226 @@ class AnnualReportService {
|
|||||||
return { sessionId: bestSessionId, days: bestDays, start: bestStart, end: bestEnd }
|
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 {
|
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)
|
const conn = await this.ensureConnectedWithConfig(params.dbPath, params.decryptKey, params.wxid)
|
||||||
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
|
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error, meta: buildMeta('hybrid', '连接数据库失败') }
|
||||||
|
if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') }
|
||||||
const sessionIds = await this.getPrivateSessions(conn.cleanedWxid)
|
const cacheKey = this.buildAvailableYearsCacheKey(params.dbPath, conn.cleanedWxid)
|
||||||
if (sessionIds.length === 0) {
|
const cached = this.getCachedAvailableYears(cacheKey)
|
||||||
return { success: false, error: '未找到消息会话' }
|
if (cached) {
|
||||||
}
|
latestYears = cached
|
||||||
|
emitProgress({
|
||||||
const fastYears = await wcdbService.getAvailableYears(sessionIds)
|
years: cached,
|
||||||
if (fastYears.success && fastYears.data) {
|
strategy: 'cache',
|
||||||
return { success: true, data: fastYears.data }
|
phase: 'cache',
|
||||||
}
|
statusText: '命中缓存,已快速加载年份数据'
|
||||||
|
})
|
||||||
const years = new Set<number>()
|
return {
|
||||||
for (const sessionId of sessionIds) {
|
success: true,
|
||||||
const first = await this.getEdgeMessageTime(sessionId, true)
|
data: cached,
|
||||||
const last = await this.getEdgeMessageTime(sessionId, false)
|
meta: buildMeta('cache', '命中缓存,已快速加载年份数据')
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortedYears = Array.from(years).sort((a, b) => b - a)
|
const sessionIds = await this.getPrivateSessions(conn.cleanedWxid)
|
||||||
return { success: true, data: sortedYears }
|
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) {
|
} catch (e) {
|
||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e), meta: { strategy: 'hybrid', nativeElapsedMs: 0, scanElapsedMs: 0, totalElapsedMs: 0, switched: false, nativeTimedOut: false, statusText: '加载年度数据失败' } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,8 +859,15 @@ class AnnualReportService {
|
|||||||
|
|
||||||
this.reportProgress('加载会话列表...', 15, onProgress)
|
this.reportProgress('加载会话列表...', 15, onProgress)
|
||||||
|
|
||||||
const startTime = Math.floor(new Date(year, 0, 1).getTime() / 1000)
|
const isAllTime = year <= 0
|
||||||
const endTime = Math.floor(new Date(year, 11, 31, 23, 59, 59).getTime() / 1000)
|
const reportYear = isAllTime ? 0 : year
|
||||||
|
const startTime = isAllTime ? 0 : Math.floor(new Date(year, 0, 1).getTime() / 1000)
|
||||||
|
const endTime = isAllTime ? 0 : Math.floor(new Date(year, 11, 31, 23, 59, 59).getTime() / 1000)
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
// 全局统计始终使用自然年范围 (Jan 1st - Now/YearEnd)
|
||||||
|
const actualStartTime = startTime
|
||||||
|
const actualEndTime = endTime
|
||||||
|
|
||||||
let totalMessages = 0
|
let totalMessages = 0
|
||||||
const contactStats = new Map<string, { sent: number; received: number }>()
|
const contactStats = new Map<string, { sent: number; received: number }>()
|
||||||
@@ -420,7 +889,7 @@ class AnnualReportService {
|
|||||||
const CONVERSATION_GAP = 3600
|
const CONVERSATION_GAP = 3600
|
||||||
|
|
||||||
this.reportProgress('统计会话消息...', 20, onProgress)
|
this.reportProgress('统计会话消息...', 20, onProgress)
|
||||||
const result = await wcdbService.getAnnualReportStats(sessionIds, startTime, endTime)
|
const result = await wcdbService.getAnnualReportStats(sessionIds, actualStartTime, actualEndTime)
|
||||||
if (!result.success || !result.data) {
|
if (!result.success || !result.data) {
|
||||||
return { success: false, error: result.error ? `基础统计失败: ${result.error}` : '基础统计失败' }
|
return { success: false, error: result.error ? `基础统计失败: ${result.error}` : '基础统计失败' }
|
||||||
}
|
}
|
||||||
@@ -473,8 +942,8 @@ class AnnualReportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.reportProgress('加载扩展统计... (初始化)', 30, onProgress)
|
this.reportProgress('加载扩展统计...', 30, onProgress)
|
||||||
const extras = await wcdbService.getAnnualReportExtras(sessionIds, startTime, endTime, peakDayBegin, peakDayEnd)
|
const extras = await wcdbService.getAnnualReportExtras(sessionIds, actualStartTime, actualEndTime, peakDayBegin, peakDayEnd)
|
||||||
if (extras.success && extras.data) {
|
if (extras.success && extras.data) {
|
||||||
this.reportProgress('加载扩展统计... (解析热力图)', 32, onProgress)
|
this.reportProgress('加载扩展统计... (解析热力图)', 32, onProgress)
|
||||||
const extrasData = extras.data as any
|
const extrasData = extras.data as any
|
||||||
@@ -554,7 +1023,7 @@ class AnnualReportService {
|
|||||||
// 为保持功能完整,我们进行深度集成的轻量遍历:
|
// 为保持功能完整,我们进行深度集成的轻量遍历:
|
||||||
for (let i = 0; i < sessionIds.length; i++) {
|
for (let i = 0; i < sessionIds.length; i++) {
|
||||||
const sessionId = sessionIds[i]
|
const sessionId = sessionIds[i]
|
||||||
const cursor = await wcdbService.openMessageCursorLite(sessionId, 1000, true, startTime, endTime)
|
const cursor = await wcdbService.openMessageCursorLite(sessionId, 1000, true, actualStartTime, actualEndTime)
|
||||||
if (!cursor.success || !cursor.cursor) continue
|
if (!cursor.success || !cursor.cursor) continue
|
||||||
|
|
||||||
let lastDayIndex: number | null = null
|
let lastDayIndex: number | null = null
|
||||||
@@ -575,9 +1044,22 @@ class AnnualReportService {
|
|||||||
if (!createTime) continue
|
if (!createTime) continue
|
||||||
|
|
||||||
const isSendRaw = row.computed_is_send ?? row.is_send ?? '0'
|
const isSendRaw = row.computed_is_send ?? row.is_send ?? '0'
|
||||||
const isSent = parseInt(isSendRaw, 10) === 1
|
let isSent = parseInt(isSendRaw, 10) === 1
|
||||||
const localType = parseInt(row.local_type || row.type || '1', 10)
|
const localType = parseInt(row.local_type || row.type || '1', 10)
|
||||||
|
|
||||||
|
// 兼容逻辑
|
||||||
|
if (isSendRaw === undefined || isSendRaw === null || isSendRaw === '0') {
|
||||||
|
const sender = String(row.sender_username || row.sender || row.talker || '').toLowerCase()
|
||||||
|
if (sender) {
|
||||||
|
const rawLower = rawWxid.toLowerCase()
|
||||||
|
const cleanedLower = cleanedWxid.toLowerCase()
|
||||||
|
if (sender === rawLower || sender === cleanedLower ||
|
||||||
|
rawLower.startsWith(sender + '_') || cleanedLower.startsWith(sender + '_')) {
|
||||||
|
isSent = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 响应速度 & 对话发起
|
// 响应速度 & 对话发起
|
||||||
if (!conversationStarts.has(sessionId)) {
|
if (!conversationStarts.has(sessionId)) {
|
||||||
conversationStarts.set(sessionId, { initiated: 0, received: 0 })
|
conversationStarts.set(sessionId, { initiated: 0, received: 0 })
|
||||||
@@ -689,7 +1171,7 @@ class AnnualReportService {
|
|||||||
|
|
||||||
if (!streakComputedInLoop) {
|
if (!streakComputedInLoop) {
|
||||||
this.reportProgress('计算连续聊天...', 45, onProgress)
|
this.reportProgress('计算连续聊天...', 45, onProgress)
|
||||||
const streakResult = await this.computeLongestStreak(sessionIds, startTime, endTime, onProgress, 45, 75)
|
const streakResult = await this.computeLongestStreak(sessionIds, actualStartTime, actualEndTime, onProgress, 45, 75)
|
||||||
if (streakResult.days > longestStreakDays) {
|
if (streakResult.days > longestStreakDays) {
|
||||||
longestStreakDays = streakResult.days
|
longestStreakDays = streakResult.days
|
||||||
longestStreakSessionId = streakResult.sessionId
|
longestStreakSessionId = streakResult.sessionId
|
||||||
@@ -698,6 +1180,42 @@ class AnnualReportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取朋友圈统计
|
||||||
|
this.reportProgress('分析朋友圈数据...', 75, onProgress)
|
||||||
|
let snsStatsResult: {
|
||||||
|
totalPosts: number
|
||||||
|
typeCounts?: Record<string, number>
|
||||||
|
topLikers: { username: string; displayName: string; avatarUrl?: string; count: number }[]
|
||||||
|
topLiked: { username: string; displayName: string; avatarUrl?: string; count: number }[]
|
||||||
|
} | undefined
|
||||||
|
|
||||||
|
const snsStats = await wcdbService.getSnsAnnualStats(actualStartTime, actualEndTime)
|
||||||
|
|
||||||
|
if (snsStats.success && snsStats.data) {
|
||||||
|
const d = snsStats.data
|
||||||
|
const usersToFetch = new Set<string>()
|
||||||
|
d.topLikers?.forEach((u: any) => usersToFetch.add(u.username))
|
||||||
|
d.topLiked?.forEach((u: any) => usersToFetch.add(u.username))
|
||||||
|
|
||||||
|
const snsUserIds = Array.from(usersToFetch)
|
||||||
|
const [snsDisplayNames, snsAvatarUrls] = await Promise.all([
|
||||||
|
wcdbService.getDisplayNames(snsUserIds),
|
||||||
|
wcdbService.getAvatarUrls(snsUserIds)
|
||||||
|
])
|
||||||
|
|
||||||
|
const getSnsUserInfo = (username: string) => ({
|
||||||
|
displayName: snsDisplayNames.success && snsDisplayNames.map ? (snsDisplayNames.map[username] || username) : username,
|
||||||
|
avatarUrl: snsAvatarUrls.success && snsAvatarUrls.map ? snsAvatarUrls.map[username] : undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
snsStatsResult = {
|
||||||
|
totalPosts: d.totalPosts || 0,
|
||||||
|
typeCounts: d.typeCounts,
|
||||||
|
topLikers: (d.topLikers || []).map((u: any) => ({ ...u, ...getSnsUserInfo(u.username) })),
|
||||||
|
topLiked: (d.topLiked || []).map((u: any) => ({ ...u, ...getSnsUserInfo(u.username) }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.reportProgress('整理联系人信息...', 85, onProgress)
|
this.reportProgress('整理联系人信息...', 85, onProgress)
|
||||||
|
|
||||||
const contactIds = Array.from(contactStats.keys())
|
const contactIds = Array.from(contactStats.keys())
|
||||||
@@ -901,8 +1419,130 @@ class AnnualReportService {
|
|||||||
.slice(0, 32)
|
.slice(0, 32)
|
||||||
.map(([phrase, count]) => ({ phrase, count }))
|
.map(([phrase, count]) => ({ phrase, count }))
|
||||||
|
|
||||||
|
// 曾经的好朋友 (Once Best Friend / Lost Friend)
|
||||||
|
let lostFriend: AnnualReportData['lostFriend'] = null
|
||||||
|
let maxEarlyCount = 80 // 最低门槛
|
||||||
|
let bestEarlyCount = 0
|
||||||
|
let bestLateCount = 0
|
||||||
|
let bestSid = ''
|
||||||
|
let bestPeriodDesc = ''
|
||||||
|
|
||||||
|
const currentMonthIndex = new Date().getMonth() + 1 // 1-12
|
||||||
|
|
||||||
|
const currentYearNum = now.getFullYear()
|
||||||
|
|
||||||
|
if (isAllTime) {
|
||||||
|
const days = Object.keys(d.daily).sort()
|
||||||
|
if (days.length >= 2) {
|
||||||
|
const firstDay = Math.floor(new Date(days[0]).getTime() / 1000)
|
||||||
|
const lastDay = Math.floor(new Date(days[days.length - 1]).getTime() / 1000)
|
||||||
|
const midPoint = Math.floor((firstDay + lastDay) / 2)
|
||||||
|
|
||||||
|
this.reportProgress('分析历史趋势 (1/2)...', 86, onProgress)
|
||||||
|
const earlyRes = await wcdbService.getAggregateStats(sessionIds, 0, midPoint)
|
||||||
|
this.reportProgress('分析历史趋势 (2/2)...', 88, onProgress)
|
||||||
|
const lateRes = await wcdbService.getAggregateStats(sessionIds, midPoint, 0)
|
||||||
|
|
||||||
|
if (earlyRes.success && lateRes.success && earlyRes.data) {
|
||||||
|
const earlyData = earlyRes.data.sessions || {}
|
||||||
|
const lateData = (lateRes.data?.sessions) || {}
|
||||||
|
for (const sid of sessionIds) {
|
||||||
|
const e = earlyData[sid] || { sent: 0, received: 0 }
|
||||||
|
const l = lateData[sid] || { sent: 0, received: 0 }
|
||||||
|
const early = (e.sent || 0) + (e.received || 0)
|
||||||
|
const late = (l.sent || 0) + (l.received || 0)
|
||||||
|
if (early > 100 && early > late * 5) {
|
||||||
|
// 选择前期消息量最多的
|
||||||
|
if (early > maxEarlyCount) {
|
||||||
|
maxEarlyCount = early
|
||||||
|
bestEarlyCount = early
|
||||||
|
bestLateCount = late
|
||||||
|
bestSid = sid
|
||||||
|
bestPeriodDesc = '这段时间以来'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (year === currentYearNum) {
|
||||||
|
// 当前年份:独立获取过去12个月的滚动数据
|
||||||
|
this.reportProgress('分析近期好友趋势...', 86, onProgress)
|
||||||
|
// 往前数12个月的起点、中点、终点
|
||||||
|
const rollingStart = Math.floor(new Date(now.getFullYear(), now.getMonth() - 11, 1).getTime() / 1000)
|
||||||
|
const rollingMid = Math.floor(new Date(now.getFullYear(), now.getMonth() - 5, 1).getTime() / 1000)
|
||||||
|
const rollingEnd = Math.floor(now.getTime() / 1000)
|
||||||
|
|
||||||
|
const earlyRes = await wcdbService.getAggregateStats(sessionIds, rollingStart, rollingMid - 1)
|
||||||
|
const lateRes = await wcdbService.getAggregateStats(sessionIds, rollingMid, rollingEnd)
|
||||||
|
|
||||||
|
if (earlyRes.success && lateRes.success && earlyRes.data) {
|
||||||
|
const earlyData = earlyRes.data.sessions || {}
|
||||||
|
const lateData = lateRes.data?.sessions || {}
|
||||||
|
for (const sid of sessionIds) {
|
||||||
|
const e = earlyData[sid] || { sent: 0, received: 0 }
|
||||||
|
const l = lateData[sid] || { sent: 0, received: 0 }
|
||||||
|
const early = (e.sent || 0) + (e.received || 0)
|
||||||
|
const late = (l.sent || 0) + (l.received || 0)
|
||||||
|
if (early > 80 && early > late * 5) {
|
||||||
|
// 选择前期消息量最多的
|
||||||
|
if (early > maxEarlyCount) {
|
||||||
|
maxEarlyCount = early
|
||||||
|
bestEarlyCount = early
|
||||||
|
bestLateCount = late
|
||||||
|
bestSid = sid
|
||||||
|
bestPeriodDesc = '去年的这个时候'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 指定完整年份 (1-6 vs 7-12)
|
||||||
|
for (const [sid, stat] of Object.entries(d.sessions)) {
|
||||||
|
const s = stat as any
|
||||||
|
const mWeights = s.monthly || {}
|
||||||
|
let early = 0
|
||||||
|
let late = 0
|
||||||
|
for (let m = 1; m <= 6; m++) early += mWeights[m] || 0
|
||||||
|
for (let m = 7; m <= 12; m++) late += mWeights[m] || 0
|
||||||
|
|
||||||
|
if (early > 80 && early > late * 5) {
|
||||||
|
// 选择前期消息量最多的
|
||||||
|
if (early > maxEarlyCount) {
|
||||||
|
maxEarlyCount = early
|
||||||
|
bestEarlyCount = early
|
||||||
|
bestLateCount = late
|
||||||
|
bestSid = sid
|
||||||
|
bestPeriodDesc = `${year}年上半年`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestSid) {
|
||||||
|
let info = contactInfoMap.get(bestSid)
|
||||||
|
// 如果 contactInfoMap 中没有该联系人,则单独获取
|
||||||
|
if (!info) {
|
||||||
|
const [displayNameRes, avatarUrlRes] = await Promise.all([
|
||||||
|
wcdbService.getDisplayNames([bestSid]),
|
||||||
|
wcdbService.getAvatarUrls([bestSid])
|
||||||
|
])
|
||||||
|
info = {
|
||||||
|
displayName: displayNameRes.success && displayNameRes.map ? (displayNameRes.map[bestSid] || bestSid) : bestSid,
|
||||||
|
avatarUrl: avatarUrlRes.success && avatarUrlRes.map ? avatarUrlRes.map[bestSid] : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lostFriend = {
|
||||||
|
username: bestSid,
|
||||||
|
displayName: info?.displayName || bestSid,
|
||||||
|
avatarUrl: info?.avatarUrl,
|
||||||
|
earlyCount: bestEarlyCount,
|
||||||
|
lateCount: bestLateCount,
|
||||||
|
periodDesc: bestPeriodDesc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const reportData: AnnualReportData = {
|
const reportData: AnnualReportData = {
|
||||||
year,
|
year: reportYear,
|
||||||
totalMessages,
|
totalMessages,
|
||||||
totalFriends: contactStats.size,
|
totalFriends: contactStats.size,
|
||||||
coreFriends,
|
coreFriends,
|
||||||
@@ -915,7 +1555,9 @@ class AnnualReportService {
|
|||||||
mutualFriend,
|
mutualFriend,
|
||||||
socialInitiative,
|
socialInitiative,
|
||||||
responseSpeed,
|
responseSpeed,
|
||||||
topPhrases
|
topPhrases,
|
||||||
|
snsStats: snsStatsResult,
|
||||||
|
lostFriend
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true, data: reportData }
|
return { success: true, data: reportData }
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
68
electron/services/cloudControlService.ts
Normal file
68
electron/services/cloudControlService.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { app } from 'electron'
|
||||||
|
import { wcdbService } from './wcdbService'
|
||||||
|
|
||||||
|
interface UsageStats {
|
||||||
|
appVersion: string
|
||||||
|
platform: string
|
||||||
|
deviceId: string
|
||||||
|
timestamp: number
|
||||||
|
online: boolean
|
||||||
|
pages: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
class CloudControlService {
|
||||||
|
private deviceId: string = ''
|
||||||
|
private timer: NodeJS.Timeout | null = null
|
||||||
|
private pages: Set<string> = new Set()
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
this.deviceId = this.getDeviceId()
|
||||||
|
await wcdbService.cloudInit(300)
|
||||||
|
await this.reportOnline()
|
||||||
|
|
||||||
|
this.timer = setInterval(() => {
|
||||||
|
this.reportOnline()
|
||||||
|
}, 300000)
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDeviceId(): string {
|
||||||
|
const crypto = require('crypto')
|
||||||
|
const os = require('os')
|
||||||
|
const machineId = os.hostname() + os.platform() + os.arch()
|
||||||
|
return crypto.createHash('md5').update(machineId).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
private async reportOnline() {
|
||||||
|
const data: UsageStats = {
|
||||||
|
appVersion: app.getVersion(),
|
||||||
|
platform: process.platform,
|
||||||
|
deviceId: this.deviceId,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
online: true,
|
||||||
|
pages: Array.from(this.pages)
|
||||||
|
}
|
||||||
|
|
||||||
|
await wcdbService.cloudReport(JSON.stringify(data))
|
||||||
|
this.pages.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
recordPage(pageName: string) {
|
||||||
|
this.pages.add(pageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if (this.timer) {
|
||||||
|
clearInterval(this.timer)
|
||||||
|
this.timer = null
|
||||||
|
}
|
||||||
|
wcdbService.cloudStop()
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLogs() {
|
||||||
|
return wcdbService.getLogs()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const cloudControlService = new CloudControlService()
|
||||||
|
|
||||||
|
|
||||||
@@ -1,20 +1,27 @@
|
|||||||
|
import { join } from 'path'
|
||||||
|
import { app, safeStorage } from 'electron'
|
||||||
|
import crypto from 'crypto'
|
||||||
import Store from 'electron-store'
|
import Store from 'electron-store'
|
||||||
|
|
||||||
|
// 加密前缀标记
|
||||||
|
const SAFE_PREFIX = 'safe:' // safeStorage 加密(普通模式)
|
||||||
|
const LOCK_PREFIX = 'lock:' // 密码派生密钥加密(锁定模式)
|
||||||
|
|
||||||
interface ConfigSchema {
|
interface ConfigSchema {
|
||||||
// 数据库相关
|
// 数据库相关
|
||||||
dbPath: string // 数据库根目录 (xwechat_files)
|
dbPath: string
|
||||||
decryptKey: string // 解密密钥
|
decryptKey: string
|
||||||
myWxid: string // 当前用户 wxid
|
myWxid: string
|
||||||
onboardingDone: boolean
|
onboardingDone: boolean
|
||||||
imageXorKey: number
|
imageXorKey: number
|
||||||
imageAesKey: string
|
imageAesKey: string
|
||||||
wxidConfigs: Record<string, { decryptKey?: string; imageXorKey?: number; imageAesKey?: string; updatedAt?: number }>
|
wxidConfigs: Record<string, { decryptKey?: string; imageXorKey?: number; imageAesKey?: string; updatedAt?: number }>
|
||||||
|
|
||||||
// 缓存相关
|
// 缓存相关
|
||||||
cachePath: string
|
cachePath: string
|
||||||
lastOpenedDb: string
|
lastOpenedDb: string
|
||||||
lastSession: string
|
lastSession: string
|
||||||
|
|
||||||
// 界面相关
|
// 界面相关
|
||||||
theme: 'light' | 'dark' | 'system'
|
theme: 'light' | 'dark' | 'system'
|
||||||
themeId: string
|
themeId: string
|
||||||
@@ -26,12 +33,55 @@ interface ConfigSchema {
|
|||||||
whisperDownloadSource: string
|
whisperDownloadSource: string
|
||||||
autoTranscribeVoice: boolean
|
autoTranscribeVoice: boolean
|
||||||
transcribeLanguages: string[]
|
transcribeLanguages: string[]
|
||||||
|
exportDefaultConcurrency: number
|
||||||
|
analyticsExcludedUsernames: string[]
|
||||||
|
|
||||||
|
// 安全相关
|
||||||
|
authEnabled: boolean
|
||||||
|
authPassword: string // SHA-256 hash(safeStorage 加密)
|
||||||
|
authUseHello: boolean
|
||||||
|
authHelloSecret: string // 原始密码(safeStorage 加密,Hello 解锁时使用)
|
||||||
|
|
||||||
|
// 更新相关
|
||||||
|
ignoredUpdateVersion: string
|
||||||
|
|
||||||
|
// 通知
|
||||||
|
notificationEnabled: boolean
|
||||||
|
notificationPosition: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'
|
||||||
|
notificationFilterMode: 'all' | 'whitelist' | 'blacklist'
|
||||||
|
notificationFilterList: string[]
|
||||||
|
wordCloudExcludeWords: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 需要 safeStorage 加密的字段(普通模式)
|
||||||
|
const ENCRYPTED_STRING_KEYS: Set<string> = new Set(['decryptKey', 'imageAesKey', 'authPassword'])
|
||||||
|
const ENCRYPTED_BOOL_KEYS: Set<string> = new Set(['authEnabled', 'authUseHello'])
|
||||||
|
const ENCRYPTED_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
|
||||||
|
|
||||||
|
// 需要与密码绑定的敏感密钥字段(锁定模式时用 lock: 加密)
|
||||||
|
const LOCKABLE_STRING_KEYS: Set<string> = new Set(['decryptKey', 'imageAesKey'])
|
||||||
|
const LOCKABLE_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
|
||||||
|
|
||||||
export class ConfigService {
|
export class ConfigService {
|
||||||
private store: Store<ConfigSchema>
|
private static instance: ConfigService
|
||||||
|
private store!: Store<ConfigSchema>
|
||||||
|
|
||||||
|
// 锁定模式运行时状态
|
||||||
|
private unlockedKeys: Map<string, any> = new Map()
|
||||||
|
private unlockPassword: string | null = null
|
||||||
|
|
||||||
|
static getInstance(): ConfigService {
|
||||||
|
if (!ConfigService.instance) {
|
||||||
|
ConfigService.instance = new ConfigService()
|
||||||
|
}
|
||||||
|
return ConfigService.instance
|
||||||
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
if (ConfigService.instance) {
|
||||||
|
return ConfigService.instance
|
||||||
|
}
|
||||||
|
ConfigService.instance = this
|
||||||
this.store = new Store<ConfigSchema>({
|
this.store = new Store<ConfigSchema>({
|
||||||
name: 'WeFlow-config',
|
name: 'WeFlow-config',
|
||||||
defaults: {
|
defaults: {
|
||||||
@@ -54,24 +104,571 @@ export class ConfigService {
|
|||||||
whisperModelDir: '',
|
whisperModelDir: '',
|
||||||
whisperDownloadSource: 'tsinghua',
|
whisperDownloadSource: 'tsinghua',
|
||||||
autoTranscribeVoice: false,
|
autoTranscribeVoice: false,
|
||||||
transcribeLanguages: ['zh']
|
transcribeLanguages: ['zh'],
|
||||||
|
exportDefaultConcurrency: 4,
|
||||||
|
analyticsExcludedUsernames: [],
|
||||||
|
authEnabled: false,
|
||||||
|
authPassword: '',
|
||||||
|
authUseHello: false,
|
||||||
|
authHelloSecret: '',
|
||||||
|
ignoredUpdateVersion: '',
|
||||||
|
notificationEnabled: true,
|
||||||
|
notificationPosition: 'top-right',
|
||||||
|
notificationFilterMode: 'all',
|
||||||
|
notificationFilterList: [],
|
||||||
|
wordCloudExcludeWords: []
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
this.migrateAuthFields()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === 状态查询 ===
|
||||||
|
|
||||||
|
isLockMode(): boolean {
|
||||||
|
const raw: any = this.store.get('decryptKey')
|
||||||
|
return typeof raw === 'string' && raw.startsWith(LOCK_PREFIX)
|
||||||
|
}
|
||||||
|
|
||||||
|
isUnlocked(): boolean {
|
||||||
|
return !this.isLockMode() || this.unlockedKeys.size > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// === get / set ===
|
||||||
|
|
||||||
get<K extends keyof ConfigSchema>(key: K): ConfigSchema[K] {
|
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 {
|
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')
|
||||||
|
|
||||||
|
// 存储密码 hash(safeStorage 加密)
|
||||||
|
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
|
return this.store.store
|
||||||
}
|
}
|
||||||
|
|
||||||
clear(): void {
|
clear(): void {
|
||||||
this.store.clear()
|
this.store.clear()
|
||||||
|
this.unlockedKeys.clear()
|
||||||
|
this.unlockPassword = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { join, dirname } from 'path'
|
import { join, dirname } from 'path'
|
||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
|
||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
|
import { ConfigService } from './config'
|
||||||
|
|
||||||
export interface ContactCacheEntry {
|
export interface ContactCacheEntry {
|
||||||
displayName?: string
|
displayName?: string
|
||||||
@@ -15,7 +16,7 @@ export class ContactCacheService {
|
|||||||
constructor(cacheBasePath?: string) {
|
constructor(cacheBasePath?: string) {
|
||||||
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
|
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
|
||||||
? cacheBasePath
|
? cacheBasePath
|
||||||
: join(app.getPath('documents'), 'WeFlow')
|
: ConfigService.getInstance().getCacheBasePath()
|
||||||
this.cacheFilePath = join(basePath, 'contacts.json')
|
this.cacheFilePath = join(basePath, 'contacts.json')
|
||||||
this.ensureCacheDir()
|
this.ensureCacheDir()
|
||||||
this.loadCache()
|
this.loadCache()
|
||||||
|
|||||||
165
electron/services/contactExportService.ts
Normal file
165
electron/services/contactExportService.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import * as fs from 'fs'
|
||||||
|
import * as path from 'path'
|
||||||
|
import { chatService } from './chatService'
|
||||||
|
|
||||||
|
interface ContactExportOptions {
|
||||||
|
format: 'json' | 'csv' | 'vcf'
|
||||||
|
exportAvatars: boolean
|
||||||
|
contactTypes: {
|
||||||
|
friends: boolean
|
||||||
|
groups: boolean
|
||||||
|
officials: boolean
|
||||||
|
}
|
||||||
|
selectedUsernames?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 联系人导出服务
|
||||||
|
*/
|
||||||
|
class ContactExportService {
|
||||||
|
/**
|
||||||
|
* 导出联系人
|
||||||
|
*/
|
||||||
|
async exportContacts(
|
||||||
|
outputDir: string,
|
||||||
|
options: ContactExportOptions
|
||||||
|
): Promise<{ success: boolean; successCount?: number; error?: string }> {
|
||||||
|
try {
|
||||||
|
// 获取所有联系人
|
||||||
|
const contactsResult = await chatService.getContacts()
|
||||||
|
if (!contactsResult.success || !contactsResult.contacts) {
|
||||||
|
return { success: false, error: contactsResult.error || '获取联系人失败' }
|
||||||
|
}
|
||||||
|
|
||||||
|
let contacts = contactsResult.contacts
|
||||||
|
|
||||||
|
// 根据类型过滤
|
||||||
|
contacts = contacts.filter(c => {
|
||||||
|
if (c.type === 'friend' && !options.contactTypes.friends) return false
|
||||||
|
if (c.type === 'group' && !options.contactTypes.groups) return false
|
||||||
|
if (c.type === 'official' && !options.contactTypes.officials) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
if (Array.isArray(options.selectedUsernames) && options.selectedUsernames.length > 0) {
|
||||||
|
const selectedSet = new Set(options.selectedUsernames)
|
||||||
|
contacts = contacts.filter(c => selectedSet.has(c.username))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contacts.length === 0) {
|
||||||
|
return { success: false, error: '没有符合条件的联系人' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保输出目录存在
|
||||||
|
if (!fs.existsSync(outputDir)) {
|
||||||
|
fs.mkdirSync(outputDir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5)
|
||||||
|
let outputPath: string
|
||||||
|
|
||||||
|
switch (options.format) {
|
||||||
|
case 'json':
|
||||||
|
outputPath = path.join(outputDir, `contacts_${timestamp}.json`)
|
||||||
|
await this.exportToJSON(contacts, outputPath)
|
||||||
|
break
|
||||||
|
case 'csv':
|
||||||
|
outputPath = path.join(outputDir, `contacts_${timestamp}.csv`)
|
||||||
|
await this.exportToCSV(contacts, outputPath)
|
||||||
|
break
|
||||||
|
case 'vcf':
|
||||||
|
outputPath = path.join(outputDir, `contacts_${timestamp}.vcf`)
|
||||||
|
await this.exportToVCF(contacts, outputPath)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
return { success: false, error: '不支持的导出格式' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, successCount: contacts.length }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出为JSON格式
|
||||||
|
*/
|
||||||
|
private async exportToJSON(contacts: any[], outputPath: string): Promise<void> {
|
||||||
|
const data = {
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
count: contacts.length,
|
||||||
|
contacts: contacts.map(c => ({
|
||||||
|
username: c.username,
|
||||||
|
displayName: c.displayName,
|
||||||
|
remark: c.remark,
|
||||||
|
nickname: c.nickname,
|
||||||
|
type: c.type
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
fs.writeFileSync(outputPath, JSON.stringify(data, null, 2), 'utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出为CSV格式
|
||||||
|
*/
|
||||||
|
private async exportToCSV(contacts: any[], outputPath: string): Promise<void> {
|
||||||
|
const headers = ['用户名', '显示名称', '备注', '昵称', '类型']
|
||||||
|
const rows = contacts.map(c => [
|
||||||
|
c.username || '',
|
||||||
|
c.displayName || '',
|
||||||
|
c.remark || '',
|
||||||
|
c.nickname || '',
|
||||||
|
this.getTypeLabel(c.type)
|
||||||
|
])
|
||||||
|
|
||||||
|
const csvContent = [
|
||||||
|
headers.join(','),
|
||||||
|
...rows.map(row => row.map(cell => `"${cell}"`).join(','))
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
fs.writeFileSync(outputPath, '\uFEFF' + csvContent, 'utf-8') // 添加BOM以支持Excel
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出为VCF格式(vCard)
|
||||||
|
*/
|
||||||
|
private async exportToVCF(contacts: any[], outputPath: string): Promise<void> {
|
||||||
|
const vcards = contacts
|
||||||
|
.filter(c => c.type === 'friend') // VCF通常只用于个人联系人
|
||||||
|
.map(c => {
|
||||||
|
const lines = ['BEGIN:VCARD', 'VERSION:3.0']
|
||||||
|
|
||||||
|
// 全名
|
||||||
|
lines.push(`FN:${c.displayName || c.username}`)
|
||||||
|
|
||||||
|
// 昵称
|
||||||
|
if (c.nickname) {
|
||||||
|
lines.push(`NICKNAME:${c.nickname}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 备注
|
||||||
|
if (c.remark) {
|
||||||
|
lines.push(`NOTE:${c.remark}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 微信ID
|
||||||
|
lines.push(`X-WECHAT-ID:${c.username}`)
|
||||||
|
|
||||||
|
lines.push('END:VCARD')
|
||||||
|
return lines.join('\r\n')
|
||||||
|
})
|
||||||
|
|
||||||
|
fs.writeFileSync(outputPath, vcards.join('\r\n\r\n'), 'utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTypeLabel(type: string): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'friend': return '好友'
|
||||||
|
case 'group': return '群聊'
|
||||||
|
case 'official': return '公众号'
|
||||||
|
default: return '其他'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const contactExportService = new ContactExportService()
|
||||||
@@ -18,8 +18,7 @@ export class DbPathService {
|
|||||||
|
|
||||||
// 微信4.x 数据目录
|
// 微信4.x 数据目录
|
||||||
possiblePaths.push(join(home, 'Documents', 'xwechat_files'))
|
possiblePaths.push(join(home, 'Documents', 'xwechat_files'))
|
||||||
// 旧版微信数据目录
|
|
||||||
possiblePaths.push(join(home, 'Documents', 'WeChat Files'))
|
|
||||||
|
|
||||||
for (const path of possiblePaths) {
|
for (const path of possiblePaths) {
|
||||||
if (existsSync(path)) {
|
if (existsSync(path)) {
|
||||||
@@ -27,7 +26,7 @@ export class DbPathService {
|
|||||||
if (rootName !== 'xwechat_files' && rootName !== 'wechat files') {
|
if (rootName !== 'xwechat_files' && rootName !== 'wechat files') {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否有有效的账号目录
|
// 检查是否有有效的账号目录
|
||||||
const accounts = this.findAccountDirs(path)
|
const accounts = this.findAccountDirs(path)
|
||||||
if (accounts.length > 0) {
|
if (accounts.length > 0) {
|
||||||
@@ -47,10 +46,10 @@ export class DbPathService {
|
|||||||
*/
|
*/
|
||||||
findAccountDirs(rootPath: string): string[] {
|
findAccountDirs(rootPath: string): string[] {
|
||||||
const accounts: string[] = []
|
const accounts: string[] = []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const entries = readdirSync(rootPath)
|
const entries = readdirSync(rootPath)
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const entryPath = join(rootPath, entry)
|
const entryPath = join(rootPath, entry)
|
||||||
let stat: ReturnType<typeof statSync>
|
let stat: ReturnType<typeof statSync>
|
||||||
@@ -59,7 +58,7 @@ export class DbPathService {
|
|||||||
} catch {
|
} catch {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stat.isDirectory()) {
|
if (stat.isDirectory()) {
|
||||||
if (!this.isPotentialAccountName(entry)) continue
|
if (!this.isPotentialAccountName(entry)) continue
|
||||||
|
|
||||||
@@ -69,8 +68,8 @@ export class DbPathService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch { }
|
||||||
|
|
||||||
return accounts
|
return accounts
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,12 +118,54 @@ export class DbPathService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扫描目录名候选(仅包含下划线的文件夹,排除 all_users)
|
||||||
|
*/
|
||||||
|
scanWxidCandidates(rootPath: string): WxidInfo[] {
|
||||||
|
const wxids: WxidInfo[] = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (existsSync(rootPath)) {
|
||||||
|
const entries = readdirSync(rootPath)
|
||||||
|
for (const entry of entries) {
|
||||||
|
const entryPath = join(rootPath, entry)
|
||||||
|
let stat: ReturnType<typeof statSync>
|
||||||
|
try {
|
||||||
|
stat = statSync(entryPath)
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stat.isDirectory()) continue
|
||||||
|
const lower = entry.toLowerCase()
|
||||||
|
if (lower === 'all_users') continue
|
||||||
|
if (!entry.includes('_')) continue
|
||||||
|
|
||||||
|
wxids.push({ wxid: entry, modifiedTime: stat.mtimeMs })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wxids.length === 0) {
|
||||||
|
const rootName = basename(rootPath)
|
||||||
|
if (rootName.includes('_') && rootName.toLowerCase() !== 'all_users') {
|
||||||
|
const rootStat = statSync(rootPath)
|
||||||
|
wxids.push({ wxid: rootName, modifiedTime: rootStat.mtimeMs })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
|
||||||
|
return wxids.sort((a, b) => {
|
||||||
|
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
|
||||||
|
return a.wxid.localeCompare(b.wxid)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 扫描 wxid 列表
|
* 扫描 wxid 列表
|
||||||
*/
|
*/
|
||||||
scanWxids(rootPath: string): WxidInfo[] {
|
scanWxids(rootPath: string): WxidInfo[] {
|
||||||
const wxids: WxidInfo[] = []
|
const wxids: WxidInfo[] = []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (this.isAccountDir(rootPath)) {
|
if (this.isAccountDir(rootPath)) {
|
||||||
const wxid = basename(rootPath)
|
const wxid = basename(rootPath)
|
||||||
@@ -133,14 +174,14 @@ export class DbPathService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const accounts = this.findAccountDirs(rootPath)
|
const accounts = this.findAccountDirs(rootPath)
|
||||||
|
|
||||||
for (const account of accounts) {
|
for (const account of accounts) {
|
||||||
const fullPath = join(rootPath, account)
|
const fullPath = join(rootPath, account)
|
||||||
const modifiedTime = this.getAccountModifiedTime(fullPath)
|
const modifiedTime = this.getAccountModifiedTime(fullPath)
|
||||||
wxids.push({ wxid: account, modifiedTime })
|
wxids.push({ wxid: account, modifiedTime })
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch { }
|
||||||
|
|
||||||
return wxids.sort((a, b) => {
|
return wxids.sort((a, b) => {
|
||||||
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
|
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
|
||||||
return a.wxid.localeCompare(b.wxid)
|
return a.wxid.localeCompare(b.wxid)
|
||||||
|
|||||||
802
electron/services/dualReportService.ts
Normal file
802
electron/services/dualReportService.ts
Normal file
@@ -0,0 +1,802 @@
|
|||||||
|
import { parentPort } from 'worker_threads'
|
||||||
|
import { wcdbService } from './wcdbService'
|
||||||
|
|
||||||
|
|
||||||
|
export interface DualReportMessage {
|
||||||
|
content: string
|
||||||
|
isSentByMe: boolean
|
||||||
|
createTime: number
|
||||||
|
createTimeStr: string
|
||||||
|
localType?: number
|
||||||
|
emojiMd5?: string
|
||||||
|
emojiCdnUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DualReportFirstChat {
|
||||||
|
createTime: number
|
||||||
|
createTimeStr: string
|
||||||
|
content: string
|
||||||
|
isSentByMe: boolean
|
||||||
|
senderUsername?: string
|
||||||
|
localType?: number
|
||||||
|
emojiMd5?: string
|
||||||
|
emojiCdnUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DualReportStats {
|
||||||
|
totalMessages: number
|
||||||
|
totalWords: number
|
||||||
|
imageCount: number
|
||||||
|
voiceCount: number
|
||||||
|
emojiCount: number
|
||||||
|
myTopEmojiMd5?: string
|
||||||
|
friendTopEmojiMd5?: string
|
||||||
|
myTopEmojiUrl?: string
|
||||||
|
friendTopEmojiUrl?: string
|
||||||
|
myTopEmojiCount?: number
|
||||||
|
friendTopEmojiCount?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DualReportData {
|
||||||
|
year: number
|
||||||
|
selfName: string
|
||||||
|
selfAvatarUrl?: string
|
||||||
|
friendUsername: string
|
||||||
|
friendName: string
|
||||||
|
friendAvatarUrl?: string
|
||||||
|
firstChat: DualReportFirstChat | null
|
||||||
|
firstChatMessages?: DualReportMessage[]
|
||||||
|
yearFirstChat?: {
|
||||||
|
createTime: number
|
||||||
|
createTimeStr: string
|
||||||
|
content: string
|
||||||
|
isSentByMe: boolean
|
||||||
|
friendName: string
|
||||||
|
firstThreeMessages: DualReportMessage[]
|
||||||
|
localType?: number
|
||||||
|
emojiMd5?: string
|
||||||
|
emojiCdnUrl?: string
|
||||||
|
} | null
|
||||||
|
stats: DualReportStats
|
||||||
|
topPhrases: Array<{ phrase: string; count: number }>
|
||||||
|
myExclusivePhrases: Array<{ phrase: string; count: number }>
|
||||||
|
friendExclusivePhrases: Array<{ phrase: string; count: number }>
|
||||||
|
heatmap?: number[][]
|
||||||
|
initiative?: { initiated: number; received: number }
|
||||||
|
response?: { avg: number; fastest: number; count: number }
|
||||||
|
monthly?: Record<string, number>
|
||||||
|
streak?: { days: number; startDate: string; endDate: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
class DualReportService {
|
||||||
|
private broadcastProgress(status: string, progress: number) {
|
||||||
|
if (parentPort) {
|
||||||
|
parentPort.postMessage({
|
||||||
|
type: 'dualReport:progress',
|
||||||
|
data: { status, progress }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private reportProgress(status: string, progress: number, onProgress?: (status: string, progress: number) => void) {
|
||||||
|
if (onProgress) {
|
||||||
|
onProgress(status, progress)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.broadcastProgress(status, progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanAccountDirName(dirName: string): string {
|
||||||
|
const trimmed = dirName.trim()
|
||||||
|
if (!trimmed) return trimmed
|
||||||
|
if (trimmed.toLowerCase().startsWith('wxid_')) {
|
||||||
|
const match = trimmed.match(/^(wxid_[^_]+)/i)
|
||||||
|
if (match) return match[1]
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
||||||
|
const cleaned = suffixMatch ? suffixMatch[1] : trimmed
|
||||||
|
|
||||||
|
return cleaned
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureConnectedWithConfig(
|
||||||
|
dbPath: string,
|
||||||
|
decryptKey: string,
|
||||||
|
wxid: string
|
||||||
|
): Promise<{ success: boolean; cleanedWxid?: string; rawWxid?: string; error?: string }> {
|
||||||
|
if (!wxid) return { success: false, error: '未配置微信ID' }
|
||||||
|
if (!dbPath) return { success: false, error: '未配置数据库路径' }
|
||||||
|
if (!decryptKey) return { success: false, error: '未配置解密密钥' }
|
||||||
|
|
||||||
|
const cleanedWxid = this.cleanAccountDirName(wxid)
|
||||||
|
const ok = await wcdbService.open(dbPath, decryptKey, cleanedWxid)
|
||||||
|
if (!ok) return { success: false, error: 'WCDB 打开失败' }
|
||||||
|
return { success: true, cleanedWxid, rawWxid: wxid }
|
||||||
|
}
|
||||||
|
|
||||||
|
private decodeMessageContent(messageContent: any, compressContent: any): string {
|
||||||
|
let content = this.decodeMaybeCompressed(compressContent)
|
||||||
|
if (!content || content.length === 0) {
|
||||||
|
content = this.decodeMaybeCompressed(messageContent)
|
||||||
|
}
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
private decodeMaybeCompressed(raw: any): string {
|
||||||
|
if (!raw) return ''
|
||||||
|
if (typeof raw === 'string') {
|
||||||
|
if (raw.length === 0) return ''
|
||||||
|
// 只有当字符串足够长(超过16字符)且看起来像 hex 时才尝试解码
|
||||||
|
// 短字符串(如 "123456" 等纯数字)容易被误判为 hex
|
||||||
|
if (raw.length > 16 && this.looksLikeHex(raw)) {
|
||||||
|
const bytes = Buffer.from(raw, 'hex')
|
||||||
|
if (bytes.length > 0) return this.decodeBinaryContent(bytes)
|
||||||
|
}
|
||||||
|
// 只有当字符串足够长(超过16字符)且看起来像 base64 时才尝试解码
|
||||||
|
// 短字符串(如 "test", "home" 等)容易被误判为 base64
|
||||||
|
if (raw.length > 16 && this.looksLikeBase64(raw)) {
|
||||||
|
try {
|
||||||
|
const bytes = Buffer.from(raw, 'base64')
|
||||||
|
return this.decodeBinaryContent(bytes)
|
||||||
|
} catch {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
private decodeBinaryContent(data: Buffer): string {
|
||||||
|
if (data.length === 0) return ''
|
||||||
|
try {
|
||||||
|
if (data.length >= 4) {
|
||||||
|
const magic = data.readUInt32LE(0)
|
||||||
|
if (magic === 0xFD2FB528) {
|
||||||
|
const fzstd = require('fzstd')
|
||||||
|
const decompressed = fzstd.decompress(data)
|
||||||
|
return Buffer.from(decompressed).toString('utf-8')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const decoded = data.toString('utf-8')
|
||||||
|
const replacementCount = (decoded.match(/\uFFFD/g) || []).length
|
||||||
|
if (replacementCount < decoded.length * 0.2) {
|
||||||
|
return decoded.replace(/\uFFFD/g, '')
|
||||||
|
}
|
||||||
|
return data.toString('latin1')
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private looksLikeHex(s: string): boolean {
|
||||||
|
if (s.length % 2 !== 0) return false
|
||||||
|
return /^[0-9a-fA-F]+$/.test(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
private looksLikeBase64(s: string): boolean {
|
||||||
|
if (s.length % 4 !== 0) return false
|
||||||
|
return /^[A-Za-z0-9+/=]+$/.test(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatDateTime(milliseconds: number): string {
|
||||||
|
const dt = new Date(milliseconds)
|
||||||
|
const month = String(dt.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(dt.getDate()).padStart(2, '0')
|
||||||
|
const hour = String(dt.getHours()).padStart(2, '0')
|
||||||
|
const minute = String(dt.getMinutes()).padStart(2, '0')
|
||||||
|
return `${month}/${day} ${hour}:${minute}`
|
||||||
|
}
|
||||||
|
|
||||||
|
private getRecordField(record: Record<string, any> | undefined | null, keys: string[]): any {
|
||||||
|
if (!record) return undefined
|
||||||
|
for (const key of keys) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(record, key) && record[key] !== undefined && record[key] !== null) {
|
||||||
|
return record[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
private coerceNumber(raw: any): number {
|
||||||
|
if (raw === undefined || raw === null || raw === '') return NaN
|
||||||
|
if (typeof raw === 'number') return raw
|
||||||
|
if (typeof raw === 'bigint') return Number(raw)
|
||||||
|
if (Buffer.isBuffer(raw)) return parseInt(raw.toString('utf-8'), 10)
|
||||||
|
if (raw instanceof Uint8Array) return parseInt(Buffer.from(raw).toString('utf-8'), 10)
|
||||||
|
const parsed = parseInt(String(raw), 10)
|
||||||
|
return Number.isFinite(parsed) ? parsed : NaN
|
||||||
|
}
|
||||||
|
|
||||||
|
private coerceString(raw: any): string {
|
||||||
|
if (raw === undefined || raw === null) return ''
|
||||||
|
if (typeof raw === 'string') return raw
|
||||||
|
if (Buffer.isBuffer(raw)) return this.decodeBinaryContent(raw)
|
||||||
|
if (raw instanceof Uint8Array) return this.decodeBinaryContent(Buffer.from(raw))
|
||||||
|
return String(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
private coerceBoolean(raw: any): boolean | undefined {
|
||||||
|
if (raw === undefined || raw === null || raw === '') return undefined
|
||||||
|
if (typeof raw === 'boolean') return raw
|
||||||
|
if (typeof raw === 'number') return raw !== 0
|
||||||
|
|
||||||
|
const normalized = String(raw).trim().toLowerCase()
|
||||||
|
if (!normalized) return undefined
|
||||||
|
|
||||||
|
if (['1', 'true', 'yes', 'me', 'self', 'mine', 'sent', 'out', 'outgoing'].includes(normalized)) return true
|
||||||
|
if (['0', 'false', 'no', 'friend', 'peer', 'other', 'recv', 'received', 'in', 'incoming'].includes(normalized)) return false
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeEmojiMd5(raw: string): string | undefined {
|
||||||
|
if (!raw) return undefined
|
||||||
|
const trimmed = raw.trim()
|
||||||
|
if (!trimmed) return undefined
|
||||||
|
const match = /([a-fA-F0-9]{16,64})/.exec(trimmed)
|
||||||
|
return match ? match[1].toLowerCase() : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeEmojiUrl(raw: string): string | undefined {
|
||||||
|
if (!raw) return undefined
|
||||||
|
let url = raw.trim().replace(/&/g, '&')
|
||||||
|
if (!url) return undefined
|
||||||
|
try {
|
||||||
|
if (url.includes('%')) {
|
||||||
|
url = decodeURIComponent(url)
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
return url || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractEmojiUrl(content: string | undefined): string | undefined {
|
||||||
|
if (!content) return undefined
|
||||||
|
const direct = this.normalizeEmojiUrl(content)
|
||||||
|
if (direct && /^https?:\/\//i.test(direct)) return direct
|
||||||
|
|
||||||
|
const attrMatch = /(?:cdnurl|thumburl)\s*=\s*['"]([^'"]+)['"]/i.exec(content)
|
||||||
|
|| /(?:cdnurl|thumburl)\s*=\s*([^'"\s>]+)/i.exec(content)
|
||||||
|
if (attrMatch) return this.normalizeEmojiUrl(attrMatch[1])
|
||||||
|
|
||||||
|
const tagMatch = /<(?:cdnurl|thumburl)>([^<]+)<\/(?:cdnurl|thumburl)>/i.exec(content)
|
||||||
|
|| /(?:cdnurl|thumburl)[^>]*>([^<]+)/i.exec(content)
|
||||||
|
return this.normalizeEmojiUrl(tagMatch?.[1] || '')
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractEmojiMd5(content: string | undefined): string | undefined {
|
||||||
|
if (!content) return undefined
|
||||||
|
const direct = this.normalizeEmojiMd5(content)
|
||||||
|
if (direct && direct.length >= 24) return direct
|
||||||
|
|
||||||
|
const match = /md5\s*=\s*['"]([a-fA-F0-9]{16,64})['"]/i.exec(content)
|
||||||
|
|| /md5\s*=\s*([a-fA-F0-9]{16,64})/i.exec(content)
|
||||||
|
|| /<md5>([a-fA-F0-9]{16,64})<\/md5>/i.exec(content)
|
||||||
|
return this.normalizeEmojiMd5(match?.[1] || '')
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveEmojiOwner(item: any, content: string): boolean | undefined {
|
||||||
|
const sentFlag = this.coerceBoolean(this.getRecordField(item, [
|
||||||
|
'isMe',
|
||||||
|
'is_me',
|
||||||
|
'isSent',
|
||||||
|
'is_sent',
|
||||||
|
'isSend',
|
||||||
|
'is_send',
|
||||||
|
'fromMe',
|
||||||
|
'from_me'
|
||||||
|
]))
|
||||||
|
if (sentFlag !== undefined) return sentFlag
|
||||||
|
|
||||||
|
const sideRaw = this.coerceString(this.getRecordField(item, ['side', 'sender', 'from', 'owner', 'role', 'direction'])).trim().toLowerCase()
|
||||||
|
if (sideRaw) {
|
||||||
|
if (['me', 'self', 'mine', 'out', 'outgoing', 'sent'].includes(sideRaw)) return true
|
||||||
|
if (['friend', 'peer', 'other', 'in', 'incoming', 'received', 'recv'].includes(sideRaw)) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefixMatch = /^\s*([01])\s*:\s*/.exec(content)
|
||||||
|
if (prefixMatch) return prefixMatch[1] === '1'
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
private stripEmojiOwnerPrefix(content: string): string {
|
||||||
|
if (!content) return ''
|
||||||
|
return content.replace(/^\s*[01]\s*:\s*/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseEmojiCandidate(item: any): { isMe?: boolean; md5?: string; url?: string; count: number } {
|
||||||
|
const rawContent = this.coerceString(this.getRecordField(item, [
|
||||||
|
'content',
|
||||||
|
'xml',
|
||||||
|
'message_content',
|
||||||
|
'messageContent',
|
||||||
|
'msg',
|
||||||
|
'payload',
|
||||||
|
'raw'
|
||||||
|
]))
|
||||||
|
const content = this.stripEmojiOwnerPrefix(rawContent)
|
||||||
|
|
||||||
|
const countRaw = this.getRecordField(item, ['count', 'cnt', 'times', 'total', 'num'])
|
||||||
|
const parsedCount = this.coerceNumber(countRaw)
|
||||||
|
const count = Number.isFinite(parsedCount) && parsedCount > 0 ? parsedCount : 0
|
||||||
|
|
||||||
|
const directMd5 = this.normalizeEmojiMd5(this.coerceString(this.getRecordField(item, [
|
||||||
|
'md5',
|
||||||
|
'emojiMd5',
|
||||||
|
'emoji_md5',
|
||||||
|
'emd5'
|
||||||
|
])))
|
||||||
|
const md5 = directMd5 || this.extractEmojiMd5(content)
|
||||||
|
|
||||||
|
const directUrl = this.normalizeEmojiUrl(this.coerceString(this.getRecordField(item, [
|
||||||
|
'cdnUrl',
|
||||||
|
'cdnurl',
|
||||||
|
'emojiUrl',
|
||||||
|
'emoji_url',
|
||||||
|
'url',
|
||||||
|
'thumbUrl',
|
||||||
|
'thumburl'
|
||||||
|
])))
|
||||||
|
const url = directUrl || this.extractEmojiUrl(content)
|
||||||
|
|
||||||
|
return {
|
||||||
|
isMe: this.resolveEmojiOwner(item, rawContent),
|
||||||
|
md5,
|
||||||
|
url,
|
||||||
|
count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getRowInt(row: Record<string, any>, keys: string[], fallback = 0): number {
|
||||||
|
const raw = this.getRecordField(row, keys)
|
||||||
|
const parsed = this.coerceNumber(raw)
|
||||||
|
return Number.isFinite(parsed) ? parsed : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
private decodeRowMessageContent(row: Record<string, any>): string {
|
||||||
|
const messageContent = this.getRecordField(row, [
|
||||||
|
'message_content',
|
||||||
|
'messageContent',
|
||||||
|
'content',
|
||||||
|
'msg_content',
|
||||||
|
'msgContent',
|
||||||
|
'WCDB_CT_message_content',
|
||||||
|
'WCDB_CT_messageContent'
|
||||||
|
])
|
||||||
|
const compressContent = this.getRecordField(row, [
|
||||||
|
'compress_content',
|
||||||
|
'compressContent',
|
||||||
|
'compressed_content',
|
||||||
|
'WCDB_CT_compress_content',
|
||||||
|
'WCDB_CT_compressContent'
|
||||||
|
])
|
||||||
|
return this.decodeMessageContent(messageContent, compressContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async scanEmojiTopFallback(
|
||||||
|
sessionId: string,
|
||||||
|
beginTimestamp: number,
|
||||||
|
endTimestamp: number,
|
||||||
|
rawWxid: string,
|
||||||
|
cleanedWxid: string
|
||||||
|
): Promise<{ my?: { md5: string; url?: string; count: number }; friend?: { md5: string; url?: string; count: number } }> {
|
||||||
|
const cursorResult = await wcdbService.openMessageCursor(sessionId, 500, true, beginTimestamp, endTimestamp)
|
||||||
|
if (!cursorResult.success || !cursorResult.cursor) return {}
|
||||||
|
|
||||||
|
const tallyMap = new Map<string, { isMe: boolean; md5: string; url?: string; count: number }>()
|
||||||
|
try {
|
||||||
|
let hasMore = true
|
||||||
|
while (hasMore) {
|
||||||
|
const batch = await wcdbService.fetchMessageBatch(cursorResult.cursor)
|
||||||
|
if (!batch.success || !Array.isArray(batch.rows)) break
|
||||||
|
|
||||||
|
for (const row of batch.rows) {
|
||||||
|
const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 0)
|
||||||
|
if (localType !== 47) continue
|
||||||
|
|
||||||
|
const rawContent = this.decodeRowMessageContent(row)
|
||||||
|
const content = this.stripEmojiOwnerPrefix(rawContent)
|
||||||
|
const directMd5 = this.normalizeEmojiMd5(this.coerceString(this.getRecordField(row, ['emoji_md5', 'emojiMd5', 'md5'])))
|
||||||
|
const md5 = directMd5 || this.extractEmojiMd5(content)
|
||||||
|
if (!md5) continue
|
||||||
|
|
||||||
|
const directUrl = this.normalizeEmojiUrl(this.coerceString(this.getRecordField(row, [
|
||||||
|
'emoji_cdn_url',
|
||||||
|
'emojiCdnUrl',
|
||||||
|
'cdnurl',
|
||||||
|
'cdn_url',
|
||||||
|
'emoji_url',
|
||||||
|
'emojiUrl',
|
||||||
|
'url',
|
||||||
|
'thumburl',
|
||||||
|
'thumb_url'
|
||||||
|
])))
|
||||||
|
const url = directUrl || this.extractEmojiUrl(content)
|
||||||
|
const isMe = this.resolveIsSent(row, rawWxid, cleanedWxid)
|
||||||
|
const mapKey = `${isMe ? '1' : '0'}:${md5}`
|
||||||
|
const existing = tallyMap.get(mapKey)
|
||||||
|
if (existing) {
|
||||||
|
existing.count += 1
|
||||||
|
if (!existing.url && url) existing.url = url
|
||||||
|
} else {
|
||||||
|
tallyMap.set(mapKey, { isMe, md5, url, count: 1 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hasMore = batch.hasMore === true
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await wcdbService.closeMessageCursor(cursorResult.cursor)
|
||||||
|
}
|
||||||
|
|
||||||
|
let myTop: { md5: string; url?: string; count: number } | undefined
|
||||||
|
let friendTop: { md5: string; url?: string; count: number } | undefined
|
||||||
|
for (const entry of tallyMap.values()) {
|
||||||
|
if (entry.isMe) {
|
||||||
|
if (!myTop || entry.count > myTop.count) {
|
||||||
|
myTop = { md5: entry.md5, url: entry.url, count: entry.count }
|
||||||
|
}
|
||||||
|
} else if (!friendTop || entry.count > friendTop.count) {
|
||||||
|
friendTop = { md5: entry.md5, url: entry.url, count: entry.count }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { my: myTop, friend: friendTop }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getDisplayName(username: string, fallback: string): Promise<string> {
|
||||||
|
const result = await wcdbService.getDisplayNames([username])
|
||||||
|
if (result.success && result.map) {
|
||||||
|
return result.map[username] || fallback
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveIsSent(row: any, rawWxid?: string, cleanedWxid?: string): boolean {
|
||||||
|
const isSendRaw = row.computed_is_send ?? row.is_send
|
||||||
|
if (isSendRaw !== undefined && isSendRaw !== null) {
|
||||||
|
return parseInt(isSendRaw, 10) === 1
|
||||||
|
}
|
||||||
|
const sender = String(row.sender_username || row.sender || row.talker || '').toLowerCase()
|
||||||
|
if (!sender) return false
|
||||||
|
const rawLower = rawWxid ? rawWxid.toLowerCase() : ''
|
||||||
|
const cleanedLower = cleanedWxid ? cleanedWxid.toLowerCase() : ''
|
||||||
|
return !!(
|
||||||
|
sender === rawLower ||
|
||||||
|
sender === cleanedLower ||
|
||||||
|
(rawLower && rawLower.startsWith(sender + '_')) ||
|
||||||
|
(cleanedLower && cleanedLower.startsWith(sender + '_'))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getFirstMessages(
|
||||||
|
sessionId: string,
|
||||||
|
limit: number,
|
||||||
|
beginTimestamp: number,
|
||||||
|
endTimestamp: number
|
||||||
|
): Promise<any[]> {
|
||||||
|
const safeBegin = Math.max(0, beginTimestamp || 0)
|
||||||
|
const safeEnd = endTimestamp && endTimestamp > 0 ? endTimestamp : Math.floor(Date.now() / 1000)
|
||||||
|
const cursorResult = await wcdbService.openMessageCursor(sessionId, Math.max(1, limit), true, safeBegin, safeEnd)
|
||||||
|
if (!cursorResult.success || !cursorResult.cursor) return []
|
||||||
|
try {
|
||||||
|
const rows: any[] = []
|
||||||
|
let hasMore = true
|
||||||
|
while (hasMore && rows.length < limit) {
|
||||||
|
const batch = await wcdbService.fetchMessageBatch(cursorResult.cursor)
|
||||||
|
if (!batch.success || !batch.rows) break
|
||||||
|
for (const row of batch.rows) {
|
||||||
|
rows.push(row)
|
||||||
|
if (rows.length >= limit) break
|
||||||
|
}
|
||||||
|
hasMore = batch.hasMore === true
|
||||||
|
}
|
||||||
|
return rows.slice(0, limit)
|
||||||
|
} finally {
|
||||||
|
await wcdbService.closeMessageCursor(cursorResult.cursor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateReportWithConfig(params: {
|
||||||
|
year: number
|
||||||
|
friendUsername: string
|
||||||
|
dbPath: string
|
||||||
|
decryptKey: string
|
||||||
|
wxid: string
|
||||||
|
excludeWords?: string[]
|
||||||
|
onProgress?: (status: string, progress: number) => void
|
||||||
|
}): Promise<{ success: boolean; data?: DualReportData; error?: string }> {
|
||||||
|
try {
|
||||||
|
const { year, friendUsername, dbPath, decryptKey, wxid, excludeWords, onProgress } = params
|
||||||
|
this.reportProgress('正在连接数据库...', 5, onProgress)
|
||||||
|
const conn = await this.ensureConnectedWithConfig(dbPath, decryptKey, wxid)
|
||||||
|
if (!conn.success || !conn.cleanedWxid || !conn.rawWxid) return { success: false, error: conn.error }
|
||||||
|
|
||||||
|
const cleanedWxid = conn.cleanedWxid
|
||||||
|
const rawWxid = conn.rawWxid
|
||||||
|
|
||||||
|
const reportYear = year <= 0 ? 0 : year
|
||||||
|
const isAllTime = reportYear === 0
|
||||||
|
const startTime = isAllTime ? 0 : Math.floor(new Date(reportYear, 0, 1).getTime() / 1000)
|
||||||
|
const endTime = isAllTime ? 0 : Math.floor(new Date(reportYear, 11, 31, 23, 59, 59).getTime() / 1000)
|
||||||
|
|
||||||
|
this.reportProgress('加载联系人信息...', 10, onProgress)
|
||||||
|
const friendName = await this.getDisplayName(friendUsername, friendUsername)
|
||||||
|
let myName = await this.getDisplayName(rawWxid, rawWxid)
|
||||||
|
if (myName === rawWxid && cleanedWxid && cleanedWxid !== rawWxid) {
|
||||||
|
myName = await this.getDisplayName(cleanedWxid, rawWxid)
|
||||||
|
}
|
||||||
|
const avatarCandidates = Array.from(new Set([
|
||||||
|
friendUsername,
|
||||||
|
rawWxid,
|
||||||
|
cleanedWxid
|
||||||
|
].filter(Boolean) as string[]))
|
||||||
|
let selfAvatarUrl: string | undefined
|
||||||
|
let friendAvatarUrl: string | undefined
|
||||||
|
const avatarResult = await wcdbService.getAvatarUrls(avatarCandidates)
|
||||||
|
if (avatarResult.success && avatarResult.map) {
|
||||||
|
selfAvatarUrl = avatarResult.map[rawWxid] || avatarResult.map[cleanedWxid]
|
||||||
|
friendAvatarUrl = avatarResult.map[friendUsername]
|
||||||
|
}
|
||||||
|
|
||||||
|
this.reportProgress('获取首条聊天记录...', 15, onProgress)
|
||||||
|
const firstRows = await this.getFirstMessages(friendUsername, 10, 0, 0)
|
||||||
|
let firstChat: DualReportFirstChat | null = null
|
||||||
|
if (firstRows.length > 0) {
|
||||||
|
const row = firstRows[0]
|
||||||
|
const createTime = parseInt(row.create_time || '0', 10) * 1000
|
||||||
|
const rawContent = this.decodeMessageContent(row.message_content, row.compress_content)
|
||||||
|
const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType'], 0)
|
||||||
|
let emojiMd5: string | undefined
|
||||||
|
let emojiCdnUrl: string | undefined
|
||||||
|
if (localType === 47) {
|
||||||
|
const stripped = this.stripEmojiOwnerPrefix(rawContent)
|
||||||
|
emojiMd5 = this.normalizeEmojiMd5(this.coerceString(this.getRecordField(row, ['emoji_md5', 'emojiMd5', 'md5']))) || this.extractEmojiMd5(stripped)
|
||||||
|
emojiCdnUrl = this.normalizeEmojiUrl(this.coerceString(this.getRecordField(row, ['emoji_cdn_url', 'emojiCdnUrl', 'cdnurl']))) || this.extractEmojiUrl(stripped)
|
||||||
|
}
|
||||||
|
|
||||||
|
firstChat = {
|
||||||
|
createTime,
|
||||||
|
createTimeStr: this.formatDateTime(createTime),
|
||||||
|
content: String(rawContent || ''),
|
||||||
|
isSentByMe: this.resolveIsSent(row, rawWxid, cleanedWxid),
|
||||||
|
senderUsername: row.sender_username || row.sender,
|
||||||
|
localType,
|
||||||
|
emojiMd5,
|
||||||
|
emojiCdnUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const firstChatMessages: DualReportMessage[] = firstRows.map((row) => {
|
||||||
|
const msgTime = parseInt(row.create_time || '0', 10) * 1000
|
||||||
|
const rawContent = this.decodeMessageContent(row.message_content, row.compress_content)
|
||||||
|
const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType'], 0)
|
||||||
|
let emojiMd5: string | undefined
|
||||||
|
let emojiCdnUrl: string | undefined
|
||||||
|
if (localType === 47) {
|
||||||
|
const stripped = this.stripEmojiOwnerPrefix(rawContent)
|
||||||
|
emojiMd5 = this.normalizeEmojiMd5(this.coerceString(this.getRecordField(row, ['emoji_md5', 'emojiMd5', 'md5']))) || this.extractEmojiMd5(stripped)
|
||||||
|
emojiCdnUrl = this.normalizeEmojiUrl(this.coerceString(this.getRecordField(row, ['emoji_cdn_url', 'emojiCdnUrl', 'cdnurl']))) || this.extractEmojiUrl(stripped)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: String(rawContent || ''),
|
||||||
|
isSentByMe: this.resolveIsSent(row, rawWxid, cleanedWxid),
|
||||||
|
createTime: msgTime,
|
||||||
|
createTimeStr: this.formatDateTime(msgTime),
|
||||||
|
localType,
|
||||||
|
emojiMd5,
|
||||||
|
emojiCdnUrl
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let yearFirstChat: DualReportData['yearFirstChat'] = null
|
||||||
|
if (!isAllTime) {
|
||||||
|
this.reportProgress('获取今年首次聊天...', 20, onProgress)
|
||||||
|
const firstYearRows = await this.getFirstMessages(friendUsername, 10, startTime, endTime)
|
||||||
|
if (firstYearRows.length > 0) {
|
||||||
|
const firstRow = firstYearRows[0]
|
||||||
|
const createTime = parseInt(firstRow.create_time || '0', 10) * 1000
|
||||||
|
const firstThreeMessages: DualReportMessage[] = firstYearRows.map((row) => {
|
||||||
|
const msgTime = parseInt(row.create_time || '0', 10) * 1000
|
||||||
|
const rawContent = this.decodeMessageContent(row.message_content, row.compress_content)
|
||||||
|
const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType'], 0)
|
||||||
|
let emojiMd5: string | undefined
|
||||||
|
let emojiCdnUrl: string | undefined
|
||||||
|
if (localType === 47) {
|
||||||
|
const stripped = this.stripEmojiOwnerPrefix(rawContent)
|
||||||
|
emojiMd5 = this.normalizeEmojiMd5(this.coerceString(this.getRecordField(row, ['emoji_md5', 'emojiMd5', 'md5']))) || this.extractEmojiMd5(stripped)
|
||||||
|
emojiCdnUrl = this.normalizeEmojiUrl(this.coerceString(this.getRecordField(row, ['emoji_cdn_url', 'emojiCdnUrl', 'cdnurl']))) || this.extractEmojiUrl(stripped)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: String(rawContent || ''),
|
||||||
|
isSentByMe: this.resolveIsSent(row, rawWxid, cleanedWxid),
|
||||||
|
createTime: msgTime,
|
||||||
|
createTimeStr: this.formatDateTime(msgTime),
|
||||||
|
localType,
|
||||||
|
emojiMd5,
|
||||||
|
emojiCdnUrl
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const firstRowYear = firstYearRows[0]
|
||||||
|
const rawContentYear = this.decodeMessageContent(firstRowYear.message_content, firstRowYear.compress_content)
|
||||||
|
const localTypeYear = this.getRowInt(firstRowYear, ['local_type', 'localType', 'type', 'msg_type', 'msgType'], 0)
|
||||||
|
let emojiMd5Year: string | undefined
|
||||||
|
let emojiCdnUrlYear: string | undefined
|
||||||
|
if (localTypeYear === 47) {
|
||||||
|
const stripped = this.stripEmojiOwnerPrefix(rawContentYear)
|
||||||
|
emojiMd5Year = this.normalizeEmojiMd5(this.coerceString(this.getRecordField(firstRowYear, ['emoji_md5', 'emojiMd5', 'md5']))) || this.extractEmojiMd5(stripped)
|
||||||
|
emojiCdnUrlYear = this.normalizeEmojiUrl(this.coerceString(this.getRecordField(firstRowYear, ['emoji_cdn_url', 'emojiCdnUrl', 'cdnurl']))) || this.extractEmojiUrl(stripped)
|
||||||
|
}
|
||||||
|
|
||||||
|
yearFirstChat = {
|
||||||
|
createTime,
|
||||||
|
createTimeStr: this.formatDateTime(createTime),
|
||||||
|
content: String(rawContentYear || ''),
|
||||||
|
isSentByMe: this.resolveIsSent(firstRowYear, rawWxid, cleanedWxid),
|
||||||
|
friendName,
|
||||||
|
firstThreeMessages,
|
||||||
|
localType: localTypeYear,
|
||||||
|
emojiMd5: emojiMd5Year,
|
||||||
|
emojiCdnUrl: emojiCdnUrlYear
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.reportProgress('统计聊天数据...', 30, onProgress)
|
||||||
|
|
||||||
|
const statsResult = await wcdbService.getDualReportStats(friendUsername, startTime, endTime)
|
||||||
|
if (!statsResult.success || !statsResult.data) {
|
||||||
|
return { success: false, error: statsResult.error || '获取双人报告统计失败' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const cppData = statsResult.data
|
||||||
|
const counts = cppData.counts || {}
|
||||||
|
|
||||||
|
const stats: DualReportStats = {
|
||||||
|
totalMessages: counts.total || 0,
|
||||||
|
totalWords: counts.words || 0,
|
||||||
|
imageCount: counts.image || 0,
|
||||||
|
voiceCount: counts.voice || 0,
|
||||||
|
emojiCount: counts.emoji || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process Emojis to find top for me and friend
|
||||||
|
let myTopEmojiMd5: string | undefined
|
||||||
|
let myTopEmojiUrl: string | undefined
|
||||||
|
let myTopCount = -1
|
||||||
|
|
||||||
|
let friendTopEmojiMd5: string | undefined
|
||||||
|
let friendTopEmojiUrl: string | undefined
|
||||||
|
let friendTopCount = -1
|
||||||
|
|
||||||
|
if (cppData.emojis && Array.isArray(cppData.emojis)) {
|
||||||
|
for (const item of cppData.emojis) {
|
||||||
|
const candidate = this.parseEmojiCandidate(item)
|
||||||
|
if (!candidate.md5 || candidate.isMe === undefined || candidate.count <= 0) continue
|
||||||
|
|
||||||
|
if (candidate.isMe) {
|
||||||
|
if (candidate.count > myTopCount) {
|
||||||
|
myTopCount = candidate.count
|
||||||
|
myTopEmojiMd5 = candidate.md5
|
||||||
|
myTopEmojiUrl = candidate.url
|
||||||
|
}
|
||||||
|
} else if (candidate.count > friendTopCount) {
|
||||||
|
friendTopCount = candidate.count
|
||||||
|
friendTopEmojiMd5 = candidate.md5
|
||||||
|
friendTopEmojiUrl = candidate.url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const needsEmojiFallback = stats.emojiCount > 0 && (!myTopEmojiMd5 || !friendTopEmojiMd5)
|
||||||
|
if (needsEmojiFallback) {
|
||||||
|
const fallback = await this.scanEmojiTopFallback(friendUsername, startTime, endTime, rawWxid, cleanedWxid)
|
||||||
|
|
||||||
|
if (!myTopEmojiMd5 && fallback.my?.md5) {
|
||||||
|
myTopEmojiMd5 = fallback.my.md5
|
||||||
|
myTopEmojiUrl = myTopEmojiUrl || fallback.my.url
|
||||||
|
myTopCount = fallback.my.count
|
||||||
|
}
|
||||||
|
if (!friendTopEmojiMd5 && fallback.friend?.md5) {
|
||||||
|
friendTopEmojiMd5 = fallback.friend.md5
|
||||||
|
friendTopEmojiUrl = friendTopEmojiUrl || fallback.friend.url
|
||||||
|
friendTopCount = fallback.friend.count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [myEmojiUrlResult, friendEmojiUrlResult] = await Promise.all([
|
||||||
|
myTopEmojiMd5 && !myTopEmojiUrl ? wcdbService.getEmoticonCdnUrl(dbPath, myTopEmojiMd5) : Promise.resolve(null),
|
||||||
|
friendTopEmojiMd5 && !friendTopEmojiUrl ? wcdbService.getEmoticonCdnUrl(dbPath, friendTopEmojiMd5) : Promise.resolve(null)
|
||||||
|
])
|
||||||
|
if (myEmojiUrlResult?.success && myEmojiUrlResult.url) myTopEmojiUrl = myEmojiUrlResult.url
|
||||||
|
if (friendEmojiUrlResult?.success && friendEmojiUrlResult.url) friendTopEmojiUrl = friendEmojiUrlResult.url
|
||||||
|
|
||||||
|
stats.myTopEmojiMd5 = myTopEmojiMd5
|
||||||
|
stats.myTopEmojiUrl = myTopEmojiUrl
|
||||||
|
stats.friendTopEmojiMd5 = friendTopEmojiMd5
|
||||||
|
stats.friendTopEmojiUrl = friendTopEmojiUrl
|
||||||
|
if (myTopCount >= 0) stats.myTopEmojiCount = myTopCount
|
||||||
|
if (friendTopCount >= 0) stats.friendTopEmojiCount = friendTopCount
|
||||||
|
|
||||||
|
if (friendTopCount >= 0) stats.friendTopEmojiCount = friendTopCount
|
||||||
|
|
||||||
|
const excludeSet = new Set(excludeWords || [])
|
||||||
|
|
||||||
|
const filterPhrases = (list: any[]) => {
|
||||||
|
return (list || []).filter((p: any) => !excludeSet.has(p.phrase))
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanPhrases = filterPhrases(cppData.phrases)
|
||||||
|
const cleanMyPhrases = filterPhrases(cppData.myPhrases)
|
||||||
|
const cleanFriendPhrases = filterPhrases(cppData.friendPhrases)
|
||||||
|
|
||||||
|
const topPhrases = cleanPhrases.map((p: any) => ({
|
||||||
|
phrase: p.phrase,
|
||||||
|
count: p.count
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 计算专属词汇:一方频繁使用而另一方很少使用的词
|
||||||
|
const myPhraseMap = new Map<string, number>()
|
||||||
|
const friendPhraseMap = new Map<string, number>()
|
||||||
|
for (const p of cleanMyPhrases) {
|
||||||
|
myPhraseMap.set(p.phrase, p.count)
|
||||||
|
}
|
||||||
|
for (const p of cleanFriendPhrases) {
|
||||||
|
friendPhraseMap.set(p.phrase, p.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 专属词汇:该方使用占比 >= 75% 且至少出现 2 次
|
||||||
|
const myExclusivePhrases: Array<{ phrase: string; count: number }> = []
|
||||||
|
const friendExclusivePhrases: Array<{ phrase: string; count: number }> = []
|
||||||
|
|
||||||
|
for (const [phrase, myCount] of myPhraseMap) {
|
||||||
|
const friendCount = friendPhraseMap.get(phrase) || 0
|
||||||
|
const total = myCount + friendCount
|
||||||
|
if (myCount >= 2 && total > 0 && myCount / total >= 0.75) {
|
||||||
|
myExclusivePhrases.push({ phrase, count: myCount })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [phrase, friendCount] of friendPhraseMap) {
|
||||||
|
const myCount = myPhraseMap.get(phrase) || 0
|
||||||
|
const total = myCount + friendCount
|
||||||
|
if (friendCount >= 2 && total > 0 && friendCount / total >= 0.75) {
|
||||||
|
friendExclusivePhrases.push({ phrase, count: friendCount })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按频率排序,取前 20
|
||||||
|
myExclusivePhrases.sort((a, b) => b.count - a.count)
|
||||||
|
friendExclusivePhrases.sort((a, b) => b.count - a.count)
|
||||||
|
if (myExclusivePhrases.length > 20) myExclusivePhrases.length = 20
|
||||||
|
if (friendExclusivePhrases.length > 20) friendExclusivePhrases.length = 20
|
||||||
|
|
||||||
|
const reportData: DualReportData = {
|
||||||
|
year: reportYear,
|
||||||
|
selfName: myName,
|
||||||
|
selfAvatarUrl,
|
||||||
|
friendUsername,
|
||||||
|
friendName,
|
||||||
|
friendAvatarUrl,
|
||||||
|
firstChat,
|
||||||
|
firstChatMessages,
|
||||||
|
yearFirstChat,
|
||||||
|
stats,
|
||||||
|
topPhrases,
|
||||||
|
myExclusivePhrases,
|
||||||
|
friendExclusivePhrases,
|
||||||
|
heatmap: cppData.heatmap,
|
||||||
|
initiative: cppData.initiative,
|
||||||
|
response: cppData.response,
|
||||||
|
monthly: cppData.monthly,
|
||||||
|
streak: cppData.streak
|
||||||
|
} as any
|
||||||
|
|
||||||
|
this.reportProgress('双人报告生成完成', 100, onProgress)
|
||||||
|
return { success: true, data: reportData }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dualReportService = new DualReportService()
|
||||||
354
electron/services/exportCardDiagnosticsService.ts
Normal file
354
electron/services/exportCardDiagnosticsService.ts
Normal 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()
|
||||||
229
electron/services/exportContentStatsCacheService.ts
Normal file
229
electron/services/exportContentStatsCacheService.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,83 +25,87 @@ body {
|
|||||||
|
|
||||||
.page {
|
.page {
|
||||||
max-width: 1080px;
|
max-width: 1080px;
|
||||||
margin: 32px auto 60px;
|
margin: 0 auto;
|
||||||
padding: 0 20px;
|
padding: 8px 20px;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
background: var(--card);
|
background: var(--card);
|
||||||
border-radius: var(--radius);
|
border-radius: 12px;
|
||||||
box-shadow: var(--shadow);
|
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06);
|
||||||
padding: 24px;
|
padding: 12px 20px;
|
||||||
margin-bottom: 24px;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: 24px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 0 0 8px;
|
margin: 0;
|
||||||
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta {
|
.meta {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
display: flex;
|
display: inline;
|
||||||
flex-wrap: wrap;
|
margin-left: 12px;
|
||||||
gap: 12px;
|
}
|
||||||
|
|
||||||
|
.meta span {
|
||||||
|
margin-right: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls {
|
.controls {
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
||||||
gap: 16px;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.control label {
|
.controls input,
|
||||||
font-size: 13px;
|
.controls button {
|
||||||
color: var(--muted);
|
border-radius: 8px;
|
||||||
}
|
|
||||||
|
|
||||||
.control input,
|
|
||||||
.control select,
|
|
||||||
.control button {
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
padding: 10px 12px;
|
padding: 6px 10px;
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.control button {
|
.controls input[type="search"] {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls input[type="datetime-local"] {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls button {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform 0.1s ease;
|
padding: 6px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.control button:active {
|
.controls button:active {
|
||||||
transform: scale(0.98);
|
transform: scale(0.98);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats {
|
.stats {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
display: flex;
|
margin-left: auto;
|
||||||
align-items: flex-end;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-list {
|
.message-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 18px;
|
gap: 12px;
|
||||||
|
padding: 4px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message {
|
.message {
|
||||||
@@ -182,6 +186,17 @@ body {
|
|||||||
word-break: break-word;
|
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 {
|
.inline-emoji {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
@@ -248,50 +263,11 @@ body {
|
|||||||
cursor: zoom-out;
|
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 {
|
.highlight {
|
||||||
outline: 2px solid var(--accent);
|
outline: 2px solid var(--accent);
|
||||||
outline-offset: 4px;
|
outline-offset: 4px;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
|
transition: outline-color 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
@@ -299,3 +275,30 @@ body[data-theme="teal-water"] {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
padding: 40px;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,83 +25,87 @@ body {
|
|||||||
|
|
||||||
.page {
|
.page {
|
||||||
max-width: 1080px;
|
max-width: 1080px;
|
||||||
margin: 32px auto 60px;
|
margin: 0 auto;
|
||||||
padding: 0 20px;
|
padding: 8px 20px;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
background: var(--card);
|
background: var(--card);
|
||||||
border-radius: var(--radius);
|
border-radius: 12px;
|
||||||
box-shadow: var(--shadow);
|
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06);
|
||||||
padding: 24px;
|
padding: 12px 20px;
|
||||||
margin-bottom: 24px;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: 24px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 0 0 8px;
|
margin: 0;
|
||||||
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta {
|
.meta {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
display: flex;
|
display: inline;
|
||||||
flex-wrap: wrap;
|
margin-left: 12px;
|
||||||
gap: 12px;
|
}
|
||||||
|
|
||||||
|
.meta span {
|
||||||
|
margin-right: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls {
|
.controls {
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
||||||
gap: 16px;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.control label {
|
.controls input,
|
||||||
font-size: 13px;
|
.controls button {
|
||||||
color: var(--muted);
|
border-radius: 8px;
|
||||||
}
|
|
||||||
|
|
||||||
.control input,
|
|
||||||
.control select,
|
|
||||||
.control button {
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
padding: 10px 12px;
|
padding: 6px 10px;
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.control button {
|
.controls input[type="search"] {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls input[type="datetime-local"] {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls button {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform 0.1s ease;
|
padding: 6px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.control button:active {
|
.controls button:active {
|
||||||
transform: scale(0.98);
|
transform: scale(0.98);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats {
|
.stats {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
display: flex;
|
margin-left: auto;
|
||||||
align-items: flex-end;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-list {
|
.message-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 18px;
|
gap: 12px;
|
||||||
|
padding: 4px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message {
|
.message {
|
||||||
@@ -182,6 +186,17 @@ body {
|
|||||||
word-break: break-word;
|
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 {
|
.inline-emoji {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
@@ -248,50 +263,11 @@ body {
|
|||||||
cursor: zoom-out;
|
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 {
|
.highlight {
|
||||||
outline: 2px solid var(--accent);
|
outline: 2px solid var(--accent);
|
||||||
outline-offset: 4px;
|
outline-offset: 4px;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
|
transition: outline-color 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
@@ -299,4 +275,32 @@ body[data-theme="teal-water"] {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
padding: 40px;
|
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;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
95
electron/services/exportRecordService.ts
Normal file
95
electron/services/exportRecordService.ts
Normal 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
204
electron/services/groupMyMessageCountCacheService.ts
Normal file
204
electron/services/groupMyMessageCountCacheService.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
994
electron/services/httpService.ts
Normal file
994
electron/services/httpService.ts
Normal 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()
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { app, BrowserWindow } from 'electron'
|
import { app, BrowserWindow } from 'electron'
|
||||||
import { basename, dirname, extname, join } from 'path'
|
import { basename, dirname, extname, join } from 'path'
|
||||||
import { pathToFileURL } from 'url'
|
import { pathToFileURL } from 'url'
|
||||||
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, appendFileSync } from 'fs'
|
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
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
const ffmpegStatic = require('ffmpeg-static')
|
const ffmpegStatic = require('ffmpeg-static')
|
||||||
|
|
||||||
if (typeof ffmpegStatic === 'string' && existsSync(ffmpegStatic)) {
|
if (typeof ffmpegStatic === 'string') {
|
||||||
return ffmpegStatic
|
// 修复:如果路径包含 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: 手动构建路径(开发环境)
|
// 方法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)和十进制格式
|
// 支持十六进制格式(如 0x53)和十进制格式
|
||||||
let xorKey: number
|
let xorKey: number
|
||||||
if (typeof xorKeyRaw === 'number') {
|
if (typeof xorKeyRaw === 'number') {
|
||||||
@@ -257,7 +267,7 @@ export class ImageDecryptService {
|
|||||||
return { success: false, error: '未配置图片解密密钥' }
|
return { success: false, error: '未配置图片解密密钥' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const aesKeyRaw = this.configService.get('imageAesKey')
|
const aesKeyRaw = imageKeys.aesKey
|
||||||
const aesKey = this.resolveAesKey(aesKeyRaw)
|
const aesKey = this.resolveAesKey(aesKeyRaw)
|
||||||
|
|
||||||
this.logInfo('开始解密DAT文件', { datPath, xorKey, hasAesKey: !!aesKey })
|
this.logInfo('开始解密DAT文件', { datPath, xorKey, hasAesKey: !!aesKey })
|
||||||
@@ -280,14 +290,14 @@ export class ImageDecryptService {
|
|||||||
await writeFile(outputPath, decrypted)
|
await writeFile(outputPath, decrypted)
|
||||||
this.logInfo('解密成功', { outputPath, size: decrypted.length })
|
this.logInfo('解密成功', { outputPath, size: decrypted.length })
|
||||||
|
|
||||||
// 对于 hevc 格式,返回错误提示
|
|
||||||
if (finalExt === '.hevc') {
|
if (finalExt === '.hevc') {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: '此图片为微信新格式(wxgf),需要安装 ffmpeg 才能显示',
|
error: '此图片为微信新格式(wxgf),ffmpeg 转换失败,请检查日志',
|
||||||
isThumb: this.isThumbnailPath(datPath)
|
isThumb: this.isThumbnailPath(datPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isThumb = this.isThumbnailPath(datPath)
|
const isThumb = this.isThumbnailPath(datPath)
|
||||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath)
|
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath)
|
||||||
if (!isThumb) {
|
if (!isThumb) {
|
||||||
@@ -380,9 +390,9 @@ export class ImageDecryptService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
||||||
if (suffixMatch) return suffixMatch[1]
|
const cleaned = suffixMatch ? suffixMatch[1] : trimmed
|
||||||
|
|
||||||
return trimmed
|
return cleaned
|
||||||
}
|
}
|
||||||
|
|
||||||
private async resolveDatPath(
|
private async resolveDatPath(
|
||||||
@@ -395,14 +405,35 @@ export class ImageDecryptService {
|
|||||||
const allowThumbnail = options?.allowThumbnail ?? true
|
const allowThumbnail = options?.allowThumbnail ?? true
|
||||||
const skipResolvedCache = options?.skipResolvedCache ?? false
|
const skipResolvedCache = options?.skipResolvedCache ?? false
|
||||||
this.logInfo('[ImageDecrypt] resolveDatPath', {
|
this.logInfo('[ImageDecrypt] resolveDatPath', {
|
||||||
accountDir,
|
|
||||||
imageMd5,
|
imageMd5,
|
||||||
imageDatName,
|
imageDatName,
|
||||||
sessionId,
|
|
||||||
allowThumbnail,
|
allowThumbnail,
|
||||||
skipResolvedCache
|
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 查询
|
// 优先通过 hardlink.db 查询
|
||||||
if (imageMd5) {
|
if (imageMd5) {
|
||||||
this.logInfo('[ImageDecrypt] hardlink lookup (md5)', { imageMd5, sessionId })
|
this.logInfo('[ImageDecrypt] hardlink lookup (md5)', { imageMd5, sessionId })
|
||||||
@@ -415,10 +446,16 @@ export class ImageDecryptService {
|
|||||||
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hardlinkPath)
|
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hardlinkPath)
|
||||||
return hardlinkPath
|
return hardlinkPath
|
||||||
}
|
}
|
||||||
// hardlink 找到的是缩略图,但要求高清图,直接返回 null,不再搜索
|
// hardlink 找到的是缩略图,但要求高清图
|
||||||
if (!allowThumbnail && isThumb) {
|
// 尝试在同一目录下查找高清图变体(快速查找,不遍历)
|
||||||
return null
|
const hdPath = this.findHdVariantInSameDir(hardlinkPath)
|
||||||
|
if (hdPath) {
|
||||||
|
this.cacheDatPath(accountDir, imageMd5, hdPath)
|
||||||
|
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdPath)
|
||||||
|
return hdPath
|
||||||
}
|
}
|
||||||
|
// 没找到高清图,返回 null(不进行全局搜索)
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
this.logInfo('[ImageDecrypt] hardlink miss (md5)', { imageMd5 })
|
this.logInfo('[ImageDecrypt] hardlink miss (md5)', { imageMd5 })
|
||||||
if (imageDatName && this.looksLikeMd5(imageDatName) && imageDatName !== imageMd5) {
|
if (imageDatName && this.looksLikeMd5(imageDatName) && imageDatName !== imageMd5) {
|
||||||
@@ -431,9 +468,13 @@ export class ImageDecryptService {
|
|||||||
this.cacheDatPath(accountDir, imageDatName, fallbackPath)
|
this.cacheDatPath(accountDir, imageDatName, fallbackPath)
|
||||||
return fallbackPath
|
return fallbackPath
|
||||||
}
|
}
|
||||||
if (!allowThumbnail && isThumb) {
|
// 找到缩略图但要求高清图,尝试同目录查找高清图变体
|
||||||
return null
|
const hdPath = this.findHdVariantInSameDir(fallbackPath)
|
||||||
|
if (hdPath) {
|
||||||
|
this.cacheDatPath(accountDir, imageDatName, hdPath)
|
||||||
|
return hdPath
|
||||||
}
|
}
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
|
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
|
||||||
}
|
}
|
||||||
@@ -449,10 +490,13 @@ export class ImageDecryptService {
|
|||||||
this.cacheDatPath(accountDir, imageDatName, hardlinkPath)
|
this.cacheDatPath(accountDir, imageDatName, hardlinkPath)
|
||||||
return hardlinkPath
|
return hardlinkPath
|
||||||
}
|
}
|
||||||
// hardlink 找到的是缩略图,但要求高清图,直接返回 null
|
// hardlink 找到的是缩略图,但要求高清图
|
||||||
if (!allowThumbnail && isThumb) {
|
const hdPath = this.findHdVariantInSameDir(hardlinkPath)
|
||||||
return null
|
if (hdPath) {
|
||||||
|
this.cacheDatPath(accountDir, imageDatName, hdPath)
|
||||||
|
return hdPath
|
||||||
}
|
}
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
|
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
|
||||||
}
|
}
|
||||||
@@ -467,6 +511,9 @@ export class ImageDecryptService {
|
|||||||
const cached = this.resolvedCache.get(imageDatName)
|
const cached = this.resolvedCache.get(imageDatName)
|
||||||
if (cached && existsSync(cached)) {
|
if (cached && existsSync(cached)) {
|
||||||
if (allowThumbnail || !this.isThumbnailPath(cached)) return cached
|
if (allowThumbnail || !this.isThumbnailPath(cached)) return cached
|
||||||
|
// 缓存的是缩略图,尝试找高清图
|
||||||
|
const hdPath = this.findHdVariantInSameDir(cached)
|
||||||
|
if (hdPath) return hdPath
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -567,9 +614,7 @@ export class ImageDecryptService {
|
|||||||
}).catch(() => { })
|
}).catch(() => { })
|
||||||
}
|
}
|
||||||
|
|
||||||
private looksLikeMd5(value: string): boolean {
|
|
||||||
return /^[a-fA-F0-9]{16,32}$/.test(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
private resolveHardlinkDbPath(accountDir: string): string | null {
|
private resolveHardlinkDbPath(accountDir: string): string | null {
|
||||||
const wxid = this.configService.get('myWxid')
|
const wxid = this.configService.get('myWxid')
|
||||||
@@ -761,6 +806,17 @@ export class ImageDecryptService {
|
|||||||
|
|
||||||
const root = join(accountDir, 'msg', 'attach')
|
const root = join(accountDir, 'msg', 'attach')
|
||||||
if (!existsSync(root)) return null
|
if (!existsSync(root)) return null
|
||||||
|
|
||||||
|
// 优化1:快速概率性查找
|
||||||
|
// 包含:1. 基于文件名的前缀猜测 (旧版)
|
||||||
|
// 2. 基于日期的最近月份扫描 (新版无索引时)
|
||||||
|
const fastHit = await this.fastProbabilisticSearch(root, datName)
|
||||||
|
if (fastHit) {
|
||||||
|
this.resolvedCache.set(key, fastHit)
|
||||||
|
return fastHit
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优化2:兜底扫描 (异步非阻塞)
|
||||||
const found = await this.walkForDatInWorker(root, datName.toLowerCase(), 8, allowThumbnail, thumbOnly)
|
const found = await this.walkForDatInWorker(root, datName.toLowerCase(), 8, allowThumbnail, thumbOnly)
|
||||||
if (found) {
|
if (found) {
|
||||||
this.resolvedCache.set(key, found)
|
this.resolvedCache.set(key, found)
|
||||||
@@ -769,6 +825,134 @@ export class ImageDecryptService {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基于文件名的哈希特征猜测可能的路径
|
||||||
|
* 包含:1. 微信旧版结构 filename.substr(0, 2)/...
|
||||||
|
* 2. 微信新版结构 msg/attach/{hash}/{YYYY-MM}/Img/filename
|
||||||
|
*/
|
||||||
|
private async fastProbabilisticSearch(root: string, datName: string, _allowThumbnail?: boolean): Promise<string | null> {
|
||||||
|
const { promises: fs } = require('fs')
|
||||||
|
const { join } = require('path')
|
||||||
|
|
||||||
|
try {
|
||||||
|
// --- 策略 A: 旧版路径猜测 (msg/attach/xx/yy/...) ---
|
||||||
|
const lowerName = datName.toLowerCase()
|
||||||
|
let baseName = lowerName
|
||||||
|
if (baseName.endsWith('.dat')) {
|
||||||
|
baseName = baseName.slice(0, -4)
|
||||||
|
if (baseName.endsWith('_t') || baseName.endsWith('.t') || baseName.endsWith('_hd')) {
|
||||||
|
baseName = baseName.slice(0, -3)
|
||||||
|
} else if (baseName.endsWith('_thumb')) {
|
||||||
|
baseName = baseName.slice(0, -6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates: string[] = []
|
||||||
|
if (/^[a-f0-9]{32}$/.test(baseName)) {
|
||||||
|
const dir1 = baseName.substring(0, 2)
|
||||||
|
const dir2 = baseName.substring(2, 4)
|
||||||
|
candidates.push(
|
||||||
|
join(root, dir1, dir2, datName),
|
||||||
|
join(root, dir1, dir2, 'Img', datName),
|
||||||
|
join(root, dir1, dir2, 'mg', datName),
|
||||||
|
join(root, dir1, dir2, 'Image', datName)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const path of candidates) {
|
||||||
|
try {
|
||||||
|
await fs.access(path)
|
||||||
|
return path
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 绛栫暐 B: 鏂扮増 Session 鍝堝笇璺緞鐚滄祴 ---
|
||||||
|
try {
|
||||||
|
const entries = await fs.readdir(root, { withFileTypes: true })
|
||||||
|
const sessionDirs = entries
|
||||||
|
.filter((e: any) => e.isDirectory() && e.name.length === 32 && /^[a-f0-9]+$/i.test(e.name))
|
||||||
|
.map((e: any) => e.name)
|
||||||
|
|
||||||
|
if (sessionDirs.length === 0) return null
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const months: string[] = []
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
|
||||||
|
const mStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||||||
|
months.push(mStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetNames = [datName]
|
||||||
|
if (baseName !== lowerName) {
|
||||||
|
targetNames.push(`${baseName}.dat`)
|
||||||
|
targetNames.push(`${baseName}_t.dat`)
|
||||||
|
targetNames.push(`${baseName}_thumb.dat`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const batchSize = 20
|
||||||
|
for (let i = 0; i < sessionDirs.length; i += batchSize) {
|
||||||
|
const batch = sessionDirs.slice(i, i + batchSize)
|
||||||
|
const tasks = batch.map(async (sessDir: string) => {
|
||||||
|
for (const month of months) {
|
||||||
|
const subDirs = ['Img', 'Image']
|
||||||
|
for (const sub of subDirs) {
|
||||||
|
const dirPath = join(root, sessDir, month, sub)
|
||||||
|
try { await fs.access(dirPath) } catch { continue }
|
||||||
|
for (const name of targetNames) {
|
||||||
|
const p = join(dirPath, name)
|
||||||
|
try { await fs.access(p); return p } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
const results = await Promise.all(tasks)
|
||||||
|
const hit = results.find(r => r !== null)
|
||||||
|
if (hit) return hit
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
|
||||||
|
} catch { }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在同一目录下查找高清图变体
|
||||||
|
* 缩略图 xxx_t.dat -> 高清图 xxx_h.dat 或 xxx.dat
|
||||||
|
*/
|
||||||
|
private findHdVariantInSameDir(thumbPath: string): string | null {
|
||||||
|
try {
|
||||||
|
const dir = dirname(thumbPath)
|
||||||
|
const fileName = basename(thumbPath).toLowerCase()
|
||||||
|
|
||||||
|
// 提取基础名称(去掉 _t.dat 或 .t.dat)
|
||||||
|
let baseName = fileName
|
||||||
|
if (baseName.endsWith('_t.dat')) {
|
||||||
|
baseName = baseName.slice(0, -6)
|
||||||
|
} else if (baseName.endsWith('.t.dat')) {
|
||||||
|
baseName = baseName.slice(0, -6)
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试查找高清图变体
|
||||||
|
const variants = [
|
||||||
|
`${baseName}_h.dat`,
|
||||||
|
`${baseName}.h.dat`,
|
||||||
|
`${baseName}.dat`
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const variant of variants) {
|
||||||
|
const variantPath = join(dir, variant)
|
||||||
|
if (existsSync(variantPath)) {
|
||||||
|
return variantPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
private async searchDatFileInDir(
|
private async searchDatFileInDir(
|
||||||
dirPath: string,
|
dirPath: string,
|
||||||
datName: string,
|
datName: string,
|
||||||
@@ -817,55 +1001,6 @@ export class ImageDecryptService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private matchesDatName(fileName: string, datName: string): boolean {
|
|
||||||
const lower = fileName.toLowerCase()
|
|
||||||
const base = lower.endsWith('.dat') ? lower.slice(0, -4) : lower
|
|
||||||
const normalizedBase = this.normalizeDatBase(base)
|
|
||||||
const normalizedTarget = this.normalizeDatBase(datName.toLowerCase())
|
|
||||||
if (normalizedBase === normalizedTarget) return true
|
|
||||||
const pattern = new RegExp(`^${datName}(?:[._][a-z])?\\.dat$`, 'i')
|
|
||||||
if (pattern.test(lower)) return true
|
|
||||||
return lower.endsWith('.dat') && lower.includes(datName)
|
|
||||||
}
|
|
||||||
|
|
||||||
private scoreDatName(fileName: string): number {
|
|
||||||
if (fileName.includes('.t.dat') || fileName.includes('_t.dat')) return 1
|
|
||||||
if (fileName.includes('.c.dat') || fileName.includes('_c.dat')) return 1
|
|
||||||
return 2
|
|
||||||
}
|
|
||||||
|
|
||||||
private isThumbnailDat(fileName: string): boolean {
|
|
||||||
return fileName.includes('.t.dat') || fileName.includes('_t.dat')
|
|
||||||
}
|
|
||||||
|
|
||||||
private hasXVariant(baseLower: string): boolean {
|
|
||||||
return /[._][a-z]$/.test(baseLower)
|
|
||||||
}
|
|
||||||
|
|
||||||
private isThumbnailPath(filePath: string): boolean {
|
|
||||||
const lower = basename(filePath).toLowerCase()
|
|
||||||
if (this.isThumbnailDat(lower)) return true
|
|
||||||
const ext = extname(lower)
|
|
||||||
const base = ext ? lower.slice(0, -ext.length) : lower
|
|
||||||
// 支持新命名 _thumb 和旧命名 _t
|
|
||||||
return base.endsWith('_t') || base.endsWith('_thumb')
|
|
||||||
}
|
|
||||||
|
|
||||||
private isHdPath(filePath: string): boolean {
|
|
||||||
const lower = basename(filePath).toLowerCase()
|
|
||||||
const ext = extname(lower)
|
|
||||||
const base = ext ? lower.slice(0, -ext.length) : lower
|
|
||||||
return base.endsWith('_hd') || base.endsWith('_h')
|
|
||||||
}
|
|
||||||
|
|
||||||
private hasImageVariantSuffix(baseLower: string): boolean {
|
|
||||||
return /[._][a-z]$/.test(baseLower)
|
|
||||||
}
|
|
||||||
|
|
||||||
private isLikelyImageDatBase(baseLower: string): boolean {
|
|
||||||
return this.hasImageVariantSuffix(baseLower) || this.looksLikeMd5(baseLower)
|
|
||||||
}
|
|
||||||
|
|
||||||
private normalizeDatBase(name: string): string {
|
private normalizeDatBase(name: string): string {
|
||||||
let base = name.toLowerCase()
|
let base = name.toLowerCase()
|
||||||
if (base.endsWith('.dat') || base.endsWith('.jpg')) {
|
if (base.endsWith('.dat') || base.endsWith('.jpg')) {
|
||||||
@@ -877,64 +1012,82 @@ export class ImageDecryptService {
|
|||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
|
||||||
private sanitizeDirName(name: string): string {
|
private hasImageVariantSuffix(baseLower: string): boolean {
|
||||||
const trimmed = name.trim()
|
return /[._][a-z]$/.test(baseLower)
|
||||||
if (!trimmed) return 'unknown'
|
|
||||||
return trimmed.replace(/[<>:"/\\|?*]/g, '_')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveTimeDir(datPath: string): string {
|
private isLikelyImageDatBase(baseLower: string): boolean {
|
||||||
const parts = datPath.split(/[\\/]+/)
|
return this.hasImageVariantSuffix(baseLower) || this.looksLikeMd5(baseLower)
|
||||||
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 findCachedOutput(cacheKey: string, preferHd: boolean = false, sessionId?: string): string | null {
|
private findCachedOutput(cacheKey: string, preferHd: boolean = false, sessionId?: string): string | null {
|
||||||
const root = this.getCacheRoot()
|
const allRoots = this.getAllCacheRoots()
|
||||||
const normalizedKey = this.normalizeDatBase(cacheKey.toLowerCase())
|
const normalizedKey = this.normalizeDatBase(cacheKey.toLowerCase())
|
||||||
const extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp']
|
const extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp']
|
||||||
|
|
||||||
if (sessionId) {
|
// 遍历所有可能的缓存根路径
|
||||||
const sessionDir = join(root, this.sanitizeDirName(sessionId))
|
for (const root of allRoots) {
|
||||||
if (existsSync(sessionDir)) {
|
// 策略1: 新目录结构 Images/{sessionId}/{YYYY-MM}/{file}_hd.jpg
|
||||||
try {
|
if (sessionId) {
|
||||||
const sessionEntries = readdirSync(sessionDir)
|
const sessionDir = join(root, this.sanitizeDirName(sessionId))
|
||||||
for (const entry of sessionEntries) {
|
if (existsSync(sessionDir)) {
|
||||||
const timeDir = join(sessionDir, entry)
|
try {
|
||||||
if (!this.isDirectory(timeDir)) continue
|
const dateDirs = readdirSync(sessionDir, { withFileTypes: true })
|
||||||
const hit = this.findCachedOutputInDir(timeDir, normalizedKey, extensions, preferHd)
|
.filter(d => d.isDirectory() && /^\d{4}-\d{2}$/.test(d.name))
|
||||||
if (hit) return hit
|
.map(d => d.name)
|
||||||
}
|
.sort()
|
||||||
} catch {
|
.reverse() // 最新的日期优先
|
||||||
// ignore
|
|
||||||
|
for (const dateDir of dateDirs) {
|
||||||
|
const imageDir = join(sessionDir, dateDir)
|
||||||
|
const hit = this.findCachedOutputInDir(imageDir, normalizedKey, extensions, preferHd)
|
||||||
|
if (hit) return hit
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 新目录结构: Images/{normalizedKey}/{normalizedKey}_thumb.jpg 或 _hd.jpg
|
// 策略2: 遍历所有 sessionId 目录查找(如果没有指定 sessionId)
|
||||||
const imageDir = join(root, normalizedKey)
|
try {
|
||||||
if (existsSync(imageDir)) {
|
const sessionDirs = readdirSync(root, { withFileTypes: true })
|
||||||
const hit = this.findCachedOutputInDir(imageDir, normalizedKey, extensions, preferHd)
|
.filter(d => d.isDirectory())
|
||||||
if (hit) return hit
|
.map(d => d.name)
|
||||||
}
|
|
||||||
|
|
||||||
// 兼容旧的平铺结构
|
for (const session of sessionDirs) {
|
||||||
for (const ext of extensions) {
|
const sessionDir = join(root, session)
|
||||||
const candidate = join(root, `${cacheKey}${ext}`)
|
// 检查是否是日期目录结构
|
||||||
if (existsSync(candidate)) return candidate
|
try {
|
||||||
}
|
const subDirs = readdirSync(sessionDir, { withFileTypes: true })
|
||||||
for (const ext of extensions) {
|
.filter(d => d.isDirectory() && /^\d{4}-\d{2}$/.test(d.name))
|
||||||
const candidate = join(root, `${cacheKey}_t${ext}`)
|
.map(d => d.name)
|
||||||
if (existsSync(candidate)) return candidate
|
|
||||||
|
for (const dateDir of subDirs) {
|
||||||
|
const imageDir = join(sessionDir, dateDir)
|
||||||
|
const hit = this.findCachedOutputInDir(imageDir, normalizedKey, extensions, preferHd)
|
||||||
|
if (hit) return hit
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
|
||||||
|
// 策略3: 旧目录结构 Images/{normalizedKey}/{normalizedKey}_thumb.jpg
|
||||||
|
const oldImageDir = join(root, normalizedKey)
|
||||||
|
if (existsSync(oldImageDir)) {
|
||||||
|
const hit = this.findCachedOutputInDir(oldImageDir, normalizedKey, extensions, preferHd)
|
||||||
|
if (hit) return hit
|
||||||
|
}
|
||||||
|
|
||||||
|
// 策略4: 最旧的平铺结构 Images/{file}.jpg
|
||||||
|
for (const ext of extensions) {
|
||||||
|
const candidate = join(root, `${cacheKey}${ext}`)
|
||||||
|
if (existsSync(candidate)) return candidate
|
||||||
|
}
|
||||||
|
for (const ext of extensions) {
|
||||||
|
const candidate = join(root, `${cacheKey}_t${ext}`)
|
||||||
|
if (existsSync(candidate)) return candidate
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
@@ -1103,23 +1256,59 @@ export class ImageDecryptService {
|
|||||||
private async ensureCacheIndexed(): Promise<void> {
|
private async ensureCacheIndexed(): Promise<void> {
|
||||||
if (this.cacheIndexed) return
|
if (this.cacheIndexed) return
|
||||||
if (this.cacheIndexing) return this.cacheIndexing
|
if (this.cacheIndexing) return this.cacheIndexing
|
||||||
this.cacheIndexing = new Promise((resolve) => {
|
this.cacheIndexing = (async () => {
|
||||||
const root = this.getCacheRoot()
|
// 扫描所有可能的缓存根目录
|
||||||
try {
|
const allRoots = this.getAllCacheRoots()
|
||||||
this.indexCacheDir(root, 2, 0)
|
this.logInfo('开始索引缓存', { roots: allRoots.length })
|
||||||
} catch {
|
|
||||||
this.cacheIndexed = true
|
for (const root of allRoots) {
|
||||||
this.cacheIndexing = null
|
try {
|
||||||
resolve()
|
this.indexCacheDir(root, 3, 0) // 增加深度到 3,支持 sessionId/YYYY-MM 结构
|
||||||
return
|
} catch (e) {
|
||||||
|
this.logError('索引目录失败', e, { root })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.logInfo('缓存索引完成', { entries: this.resolvedCache.size })
|
||||||
this.cacheIndexed = true
|
this.cacheIndexed = true
|
||||||
this.cacheIndexing = null
|
this.cacheIndexing = null
|
||||||
resolve()
|
})()
|
||||||
})
|
|
||||||
return this.cacheIndexing
|
return this.cacheIndexing
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有可能的缓存根路径(用于查找已缓存的图片)
|
||||||
|
* 包含当前路径、配置路径、旧版本路径
|
||||||
|
*/
|
||||||
|
private getAllCacheRoots(): string[] {
|
||||||
|
const roots: string[] = []
|
||||||
|
const configured = this.configService.get('cachePath')
|
||||||
|
const documentsPath = app.getPath('documents')
|
||||||
|
|
||||||
|
// 主要路径(当前使用的)
|
||||||
|
const mainRoot = this.getCacheRoot()
|
||||||
|
roots.push(mainRoot)
|
||||||
|
|
||||||
|
// 如果配置了自定义路径,也检查其下的 Images
|
||||||
|
if (configured) {
|
||||||
|
roots.push(join(configured, 'Images'))
|
||||||
|
roots.push(join(configured, 'images'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认路径
|
||||||
|
roots.push(join(documentsPath, 'WeFlow', 'Images'))
|
||||||
|
roots.push(join(documentsPath, 'WeFlow', 'images'))
|
||||||
|
|
||||||
|
// 兼容旧路径(如果有的话)
|
||||||
|
roots.push(join(documentsPath, 'WeFlowData', 'Images'))
|
||||||
|
|
||||||
|
// 去重并过滤存在的路径
|
||||||
|
const uniqueRoots = Array.from(new Set(roots))
|
||||||
|
const existingRoots = uniqueRoots.filter(r => existsSync(r))
|
||||||
|
|
||||||
|
return existingRoots
|
||||||
|
}
|
||||||
|
|
||||||
private indexCacheDir(root: string, maxDepth: number, depth: number): void {
|
private indexCacheDir(root: string, maxDepth: number, depth: number): void {
|
||||||
let entries: string[]
|
let entries: string[]
|
||||||
try {
|
try {
|
||||||
@@ -1286,14 +1475,14 @@ export class ImageDecryptService {
|
|||||||
|
|
||||||
private bytesToInt32(bytes: Buffer): number {
|
private bytesToInt32(bytes: Buffer): number {
|
||||||
if (bytes.length !== 4) {
|
if (bytes.length !== 4) {
|
||||||
throw new Error('需要4个字节')
|
throw new Error('需要 4 个字节')
|
||||||
}
|
}
|
||||||
return bytes[0] | (bytes[1] << 8) | (bytes[2] << 16) | (bytes[3] << 24)
|
return bytes[0] | (bytes[1] << 8) | (bytes[2] << 16) | (bytes[3] << 24)
|
||||||
}
|
}
|
||||||
|
|
||||||
asciiKey16(keyString: string): Buffer {
|
asciiKey16(keyString: string): Buffer {
|
||||||
if (keyString.length < 16) {
|
if (keyString.length < 16) {
|
||||||
throw new Error('AES密钥至少需要16个字符')
|
throw new Error('AES密钥至少需要 16 个字符')
|
||||||
}
|
}
|
||||||
return Buffer.from(keyString, 'ascii').subarray(0, 16)
|
return Buffer.from(keyString, 'ascii').subarray(0, 16)
|
||||||
}
|
}
|
||||||
@@ -1485,25 +1674,28 @@ export class ImageDecryptService {
|
|||||||
|
|
||||||
// 提取 HEVC NALU 裸流
|
// 提取 HEVC NALU 裸流
|
||||||
const hevcData = this.extractHevcNalu(buffer)
|
const hevcData = this.extractHevcNalu(buffer)
|
||||||
if (!hevcData || hevcData.length < 100) {
|
// 优先用提取的 NALU 裸流,提取失败则跳过 wxgf 头部直接用原始数据
|
||||||
return { data: buffer, isWxgf: true }
|
const feedData = (hevcData && hevcData.length >= 100) ? hevcData : buffer.subarray(4)
|
||||||
}
|
this.logInfo('unwrapWxgf: 准备 ffmpeg 转换', {
|
||||||
|
naluExtracted: !!(hevcData && hevcData.length >= 100),
|
||||||
|
feedSize: feedData.length
|
||||||
|
})
|
||||||
|
|
||||||
// 尝试用 ffmpeg 转换
|
// 尝试用 ffmpeg 转换
|
||||||
try {
|
try {
|
||||||
const jpgData = await this.convertHevcToJpg(hevcData)
|
const jpgData = await this.convertHevcToJpg(feedData)
|
||||||
if (jpgData && jpgData.length > 0) {
|
if (jpgData && jpgData.length > 0) {
|
||||||
return { data: jpgData, isWxgf: false }
|
return { data: jpgData, isWxgf: false }
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (e) {
|
||||||
// ffmpeg 转换失败
|
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 {
|
private extractHevcNalu(buffer: Buffer): Buffer | null {
|
||||||
const nalUnits: Buffer[] = []
|
const nalUnits: Buffer[] = []
|
||||||
@@ -1566,53 +1758,133 @@ export class ImageDecryptService {
|
|||||||
/**
|
/**
|
||||||
* 使用 ffmpeg 将 HEVC 裸流转换为 JPG
|
* 使用 ffmpeg 将 HEVC 裸流转换为 JPG
|
||||||
*/
|
*/
|
||||||
private convertHevcToJpg(hevcData: Buffer): Promise<Buffer | null> {
|
private async convertHevcToJpg(hevcData: Buffer): Promise<Buffer | null> {
|
||||||
const ffmpeg = this.getFfmpegPath()
|
const ffmpeg = this.getFfmpegPath()
|
||||||
this.logInfo('ffmpeg 转换开始', { ffmpegPath: ffmpeg, hevcSize: hevcData.length })
|
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) => {
|
return new Promise((resolve) => {
|
||||||
const { spawn } = require('child_process')
|
const { spawn } = require('child_process')
|
||||||
const chunks: Buffer[] = []
|
|
||||||
const errChunks: Buffer[] = []
|
const errChunks: Buffer[] = []
|
||||||
|
|
||||||
const proc = spawn(ffmpeg, [
|
const args = [
|
||||||
'-hide_banner',
|
'-hide_banner', '-loglevel', 'error',
|
||||||
'-loglevel', 'error',
|
...inputArgs,
|
||||||
'-f', 'hevc',
|
'-vframes', '1', '-q:v', '2', '-f', 'image2', tmpOutput
|
||||||
'-i', 'pipe:0',
|
]
|
||||||
'-vframes', '1',
|
this.logInfo(`ffmpeg 尝试 [${label}]`, { args: args.join(' ') })
|
||||||
'-q:v', '3',
|
|
||||||
'-f', 'mjpeg',
|
const proc = spawn(ffmpeg, args, {
|
||||||
'pipe:1'
|
stdio: ['ignore', 'ignore', 'pipe'],
|
||||||
], {
|
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
|
||||||
windowsHide: true
|
windowsHide: true
|
||||||
})
|
})
|
||||||
|
|
||||||
proc.stdout.on('data', (chunk: Buffer) => chunks.push(chunk))
|
|
||||||
proc.stderr.on('data', (chunk: Buffer) => errChunks.push(chunk))
|
proc.stderr.on('data', (chunk: Buffer) => errChunks.push(chunk))
|
||||||
|
|
||||||
proc.on('close', (code: number) => {
|
const timer = setTimeout(() => {
|
||||||
if (code === 0 && chunks.length > 0) {
|
proc.kill('SIGKILL')
|
||||||
this.logInfo('ffmpeg 转换成功', { outputSize: Buffer.concat(chunks).length })
|
this.logError(`ffmpeg [${label}] 超时(15s)`)
|
||||||
resolve(Buffer.concat(chunks))
|
resolve(null)
|
||||||
} else {
|
}, 15000)
|
||||||
const errMsg = Buffer.concat(errChunks).toString()
|
|
||||||
this.logInfo('ffmpeg 转换失败', { code, error: errMsg })
|
|
||||||
resolve(null)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
proc.on('error', (err: Error) => {
|
proc.on('close', (code: number) => {
|
||||||
this.logInfo('ffmpeg 进程错误', { error: err.message })
|
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)
|
resolve(null)
|
||||||
})
|
})
|
||||||
|
|
||||||
proc.stdin.write(hevcData)
|
proc.on('error', (err: Error) => {
|
||||||
proc.stdin.end()
|
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> {
|
async decryptToFile(inputPath: string, outputPath: string, xorKey: number, aesKey?: Buffer): Promise<void> {
|
||||||
const version = this.getDatVersion(inputPath)
|
const version = this.getDatVersion(inputPath)
|
||||||
@@ -1625,7 +1897,7 @@ export class ImageDecryptService {
|
|||||||
decrypted = this.decryptDatV4(inputPath, xorKey, key)
|
decrypted = this.decryptDatV4(inputPath, xorKey, key)
|
||||||
} else {
|
} else {
|
||||||
if (!aesKey || aesKey.length !== 16) {
|
if (!aesKey || aesKey.length !== 16) {
|
||||||
throw new Error('V4版本需要16字节AES密钥')
|
throw new Error('V4版本需要 16 字节 AES 密钥')
|
||||||
}
|
}
|
||||||
decrypted = this.decryptDatV4(inputPath, xorKey, aesKey)
|
decrypted = this.decryptDatV4(inputPath, xorKey, aesKey)
|
||||||
}
|
}
|
||||||
|
|||||||
127
electron/services/isaac64.ts
Normal file
127
electron/services/isaac64.ts
Normal 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
@@ -1,6 +1,7 @@
|
|||||||
import { join, dirname } from 'path'
|
import { join, dirname } from 'path'
|
||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
|
||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
|
import { ConfigService } from './config'
|
||||||
|
|
||||||
export interface SessionMessageCacheEntry {
|
export interface SessionMessageCacheEntry {
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
@@ -15,7 +16,7 @@ export class MessageCacheService {
|
|||||||
constructor(cacheBasePath?: string) {
|
constructor(cacheBasePath?: string) {
|
||||||
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
|
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
|
||||||
? cacheBasePath
|
? cacheBasePath
|
||||||
: join(app.getPath('documents'), 'WeFlow')
|
: ConfigService.getInstance().getCacheBasePath()
|
||||||
this.cacheFilePath = join(basePath, 'session-messages.json')
|
this.cacheFilePath = join(basePath, 'session-messages.json')
|
||||||
this.ensureCacheDir()
|
this.ensureCacheDir()
|
||||||
this.loadCache()
|
this.loadCache()
|
||||||
|
|||||||
293
electron/services/sessionStatsCacheService.ts
Normal file
293
electron/services/sessionStatsCacheService.ts
Normal 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
@@ -1,5 +1,6 @@
|
|||||||
import { join } from 'path'
|
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 { ConfigService } from './config'
|
||||||
import Database from 'better-sqlite3'
|
import Database from 'better-sqlite3'
|
||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
@@ -18,6 +19,16 @@ class VideoService {
|
|||||||
this.configService = new ConfigService()
|
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 {
|
private getCachePath(): string {
|
||||||
return this.configService.get('cachePath') || ''
|
return this.configService.getCacheBasePath()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,7 +80,12 @@ class VideoService {
|
|||||||
const wxid = this.getMyWxid()
|
const wxid = this.getMyWxid()
|
||||||
const cleanedWxid = this.cleanWxid(wxid)
|
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
|
// 方法1:优先在 cachePath 下查找解密后的 hardlink.db
|
||||||
if (cachePath) {
|
if (cachePath) {
|
||||||
@@ -84,20 +100,23 @@ class VideoService {
|
|||||||
for (const p of cacheDbPaths) {
|
for (const p of cacheDbPaths) {
|
||||||
if (existsSync(p)) {
|
if (existsSync(p)) {
|
||||||
try {
|
try {
|
||||||
|
this.log('尝试缓存 hardlink.db', { path: p })
|
||||||
const db = new Database(p, { readonly: true })
|
const db = new Database(p, { readonly: true })
|
||||||
const row = db.prepare(`
|
const row = db.prepare(`
|
||||||
SELECT file_name, md5 FROM video_hardlink_info_v4
|
SELECT file_name, md5 FROM video_hardlink_info_v4
|
||||||
WHERE md5 = ?
|
WHERE md5 = ?
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`).get(md5) as { file_name: string; md5: string } | undefined
|
`).get(md5) as { file_name: string; md5: string } | undefined
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
if (row?.file_name) {
|
if (row?.file_name) {
|
||||||
const realMd5 = row.file_name.replace(/\.[^.]+$/, '')
|
const realMd5 = row.file_name.replace(/\.[^.]+$/, '')
|
||||||
|
this.log('缓存 hardlink.db 命中', { file_name: row.file_name, realMd5 })
|
||||||
return realMd5
|
return realMd5
|
||||||
}
|
}
|
||||||
|
this.log('缓存 hardlink.db 未命中', { path: p })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Silently fail
|
this.log('缓存 hardlink.db 查询失败', { path: p, error: String(e) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,34 +124,45 @@ class VideoService {
|
|||||||
|
|
||||||
// 方法2:使用 wcdbService.execQuery 查询加密的 hardlink.db
|
// 方法2:使用 wcdbService.execQuery 查询加密的 hardlink.db
|
||||||
if (dbPath) {
|
if (dbPath) {
|
||||||
const encryptedDbPaths = [
|
const dbPathLower = dbPath.toLowerCase()
|
||||||
join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'),
|
const wxidLower = wxid.toLowerCase()
|
||||||
join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db')
|
const cleanedWxidLower = cleanedWxid.toLowerCase()
|
||||||
]
|
const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower)
|
||||||
|
|
||||||
|
const encryptedDbPaths: string[] = []
|
||||||
|
if (dbPathContainsWxid) {
|
||||||
|
encryptedDbPaths.push(join(dbPath, 'db_storage', 'hardlink', 'hardlink.db'))
|
||||||
|
} else {
|
||||||
|
encryptedDbPaths.push(join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'))
|
||||||
|
encryptedDbPaths.push(join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db'))
|
||||||
|
}
|
||||||
|
|
||||||
for (const p of encryptedDbPaths) {
|
for (const p of encryptedDbPaths) {
|
||||||
if (existsSync(p)) {
|
if (existsSync(p)) {
|
||||||
try {
|
try {
|
||||||
|
this.log('尝试加密 hardlink.db', { path: p })
|
||||||
const escapedMd5 = md5.replace(/'/g, "''")
|
const escapedMd5 = md5.replace(/'/g, "''")
|
||||||
|
|
||||||
// 用 md5 字段查询,获取 file_name
|
|
||||||
const sql = `SELECT file_name FROM video_hardlink_info_v4 WHERE md5 = '${escapedMd5}' LIMIT 1`
|
const sql = `SELECT file_name FROM video_hardlink_info_v4 WHERE md5 = '${escapedMd5}' LIMIT 1`
|
||||||
|
|
||||||
const result = await wcdbService.execQuery('media', p, sql)
|
const result = await wcdbService.execQuery('media', p, sql)
|
||||||
|
|
||||||
if (result.success && result.rows && result.rows.length > 0) {
|
if (result.success && result.rows && result.rows.length > 0) {
|
||||||
const row = result.rows[0]
|
const row = result.rows[0]
|
||||||
if (row?.file_name) {
|
if (row?.file_name) {
|
||||||
// 提取不带扩展名的文件名作为实际视频 MD5
|
|
||||||
const realMd5 = String(row.file_name).replace(/\.[^.]+$/, '')
|
const realMd5 = String(row.file_name).replace(/\.[^.]+$/, '')
|
||||||
|
this.log('加密 hardlink.db 命中', { file_name: row.file_name, realMd5 })
|
||||||
return realMd5
|
return realMd5
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.log('加密 hardlink.db 未命中', { path: p, result: JSON.stringify(result).slice(0, 200) })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
this.log('加密 hardlink.db 查询失败', { path: p, error: String(e) })
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
this.log('加密 hardlink.db 不存在', { path: p })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.log('queryVideoFileName: 所有方法均未找到', { md5 })
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,56 +185,110 @@ class VideoService {
|
|||||||
* 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg
|
* 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg
|
||||||
*/
|
*/
|
||||||
async getVideoInfo(videoMd5: string): Promise<VideoInfo> {
|
async getVideoInfo(videoMd5: string): Promise<VideoInfo> {
|
||||||
|
|
||||||
const dbPath = this.getDbPath()
|
const dbPath = this.getDbPath()
|
||||||
const wxid = this.getMyWxid()
|
const wxid = this.getMyWxid()
|
||||||
|
|
||||||
|
this.log('getVideoInfo 开始', { videoMd5, dbPath, wxid })
|
||||||
|
|
||||||
if (!dbPath || !wxid || !videoMd5) {
|
if (!dbPath || !wxid || !videoMd5) {
|
||||||
|
this.log('getVideoInfo: 参数缺失', { dbPath: !!dbPath, wxid: !!wxid, videoMd5: !!videoMd5 })
|
||||||
return { exists: false }
|
return { exists: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 先尝试从数据库查询真正的视频文件名
|
// 先尝试从数据库查询真正的视频文件名
|
||||||
const realVideoMd5 = await this.queryVideoFileName(videoMd5) || videoMd5
|
const realVideoMd5 = await this.queryVideoFileName(videoMd5) || videoMd5
|
||||||
|
this.log('realVideoMd5', { input: videoMd5, resolved: realVideoMd5, changed: realVideoMd5 !== videoMd5 })
|
||||||
|
|
||||||
const videoBaseDir = join(dbPath, wxid, 'msg', 'video')
|
// 检查 dbPath 是否已经包含 wxid,避免重复拼接
|
||||||
|
const dbPathLower = dbPath.toLowerCase()
|
||||||
|
const wxidLower = wxid.toLowerCase()
|
||||||
|
const cleanedWxid = this.cleanWxid(wxid)
|
||||||
|
|
||||||
|
let videoBaseDir: string
|
||||||
|
if (dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxid.toLowerCase())) {
|
||||||
|
videoBaseDir = join(dbPath, 'msg', 'video')
|
||||||
|
} else {
|
||||||
|
videoBaseDir = join(dbPath, wxid, 'msg', 'video')
|
||||||
|
}
|
||||||
|
|
||||||
|
this.log('videoBaseDir', { videoBaseDir, exists: existsSync(videoBaseDir) })
|
||||||
|
|
||||||
if (!existsSync(videoBaseDir)) {
|
if (!existsSync(videoBaseDir)) {
|
||||||
|
this.log('getVideoInfo: videoBaseDir 不存在')
|
||||||
return { exists: false }
|
return { exists: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 遍历年月目录查找视频文件
|
// 遍历年月目录查找视频文件
|
||||||
try {
|
try {
|
||||||
const allDirs = readdirSync(videoBaseDir)
|
const allDirs = readdirSync(videoBaseDir)
|
||||||
|
|
||||||
// 支持多种目录格式: YYYY-MM, YYYYMM, 或其他
|
|
||||||
const yearMonthDirs = allDirs
|
const yearMonthDirs = allDirs
|
||||||
.filter(dir => {
|
.filter(dir => {
|
||||||
const dirPath = join(videoBaseDir, dir)
|
const dirPath = join(videoBaseDir, dir)
|
||||||
return statSync(dirPath).isDirectory()
|
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) {
|
for (const yearMonth of yearMonthDirs) {
|
||||||
const dirPath = join(videoBaseDir, yearMonth)
|
const dirPath = join(videoBaseDir, yearMonth)
|
||||||
|
|
||||||
const videoPath = join(dirPath, `${realVideoMd5}.mp4`)
|
const videoPath = join(dirPath, `${realVideoMd5}.mp4`)
|
||||||
const coverPath = join(dirPath, `${realVideoMd5}.jpg`)
|
|
||||||
const thumbPath = join(dirPath, `${realVideoMd5}_thumb.jpg`)
|
|
||||||
|
|
||||||
// 检查视频文件是否存在
|
|
||||||
if (existsSync(videoPath)) {
|
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 {
|
return {
|
||||||
videoUrl: videoPath, // 返回文件路径,前端通过 readFile 读取
|
videoUrl: videoPath,
|
||||||
coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'),
|
coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'),
|
||||||
thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'),
|
thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'),
|
||||||
exists: true
|
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) {
|
} catch (e) {
|
||||||
console.error('[VideoService] Error searching for video:', e)
|
this.log('getVideoInfo 遍历出错', { error: String(e) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.log('getVideoInfo: 未找到视频', { videoMd5, realVideoMd5 })
|
||||||
return { exists: false }
|
return { exists: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,41 +296,59 @@ class VideoService {
|
|||||||
* 根据消息内容解析视频MD5
|
* 根据消息内容解析视频MD5
|
||||||
*/
|
*/
|
||||||
parseVideoMd5(content: string): string | undefined {
|
parseVideoMd5(content: string): string | undefined {
|
||||||
|
|
||||||
// 打印前500字符看看 XML 结构
|
|
||||||
|
|
||||||
if (!content) return undefined
|
if (!content) return undefined
|
||||||
|
|
||||||
|
// 打印原始 XML 前 800 字符,帮助排查自己发的视频结构
|
||||||
|
this.log('parseVideoMd5 原始内容', { preview: content.slice(0, 800) })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 提取所有可能的 md5 值进行日志
|
// 收集所有 md5 相关属性,方便对比
|
||||||
const allMd5s: string[] = []
|
const allMd5Attrs: string[] = []
|
||||||
const md5Regex = /(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]+)['"]/gi
|
const md5Regex = /(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]*)['"]/gi
|
||||||
let match
|
let match
|
||||||
while ((match = md5Regex.exec(content)) !== null) {
|
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)
|
// 方法2:从 <videomsg rawmd5="..."> 提取(自己发的视频,没有 md5 只有 rawmd5)
|
||||||
// 注意:不是 rawmd5,rawmd5 是另一个值
|
const rawMd5Match = /<videomsg[^>]*\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
||||||
// 格式: md5="xxx" 或 <md5>xxx</md5>
|
if (rawMd5Match) {
|
||||||
|
this.log('parseVideoMd5 命中 videomsg rawmd5 属性(自发视频)', { rawmd5: rawMd5Match[1] })
|
||||||
// 尝试从videomsg标签中提取md5
|
return rawMd5Match[1].toLowerCase()
|
||||||
const videoMsgMatch = /<videomsg[^>]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
|
||||||
if (videoMsgMatch) {
|
|
||||||
return videoMsgMatch[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) {
|
if (attrMatch) {
|
||||||
|
this.log('parseVideoMd5 命中通用 md5 属性', { md5: attrMatch[1] })
|
||||||
return attrMatch[1].toLowerCase()
|
return attrMatch[1].toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
const md5Match = /<md5>([a-fA-F0-9]+)<\/md5>/i.exec(content)
|
// 方法4:<md5>...</md5> 标签
|
||||||
if (md5Match) {
|
const md5TagMatch = /<md5>([a-fA-F0-9]+)<\/md5>/i.exec(content)
|
||||||
return md5Match[1].toLowerCase()
|
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) {
|
} catch (e) {
|
||||||
console.error('[VideoService] 解析视频MD5失败:', e)
|
this.log('parseVideoMd5 异常', { error: String(e) })
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined
|
return undefined
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { app } from 'electron'
|
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 { join } from 'path'
|
||||||
import * as https from 'https'
|
import * as https from 'https'
|
||||||
import * as http from 'http'
|
import * as http from 'http'
|
||||||
@@ -24,6 +24,7 @@ type DownloadProgress = {
|
|||||||
downloadedBytes: number
|
downloadedBytes: number
|
||||||
totalBytes?: number
|
totalBytes?: number
|
||||||
percent?: number
|
percent?: number
|
||||||
|
speed?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const SENSEVOICE_MODEL: ModelInfo = {
|
const SENSEVOICE_MODEL: ModelInfo = {
|
||||||
@@ -123,44 +124,44 @@ export class VoiceTranscribeService {
|
|||||||
percent: 0
|
percent: 0
|
||||||
})
|
})
|
||||||
|
|
||||||
// 下载模型文件 (40%)
|
// 下载模型文件 (80% 权重)
|
||||||
console.info('[VoiceTranscribe] 开始下载模型文件...')
|
console.info('[VoiceTranscribe] 开始下载模型文件...')
|
||||||
await this.downloadToFile(
|
await this.downloadToFile(
|
||||||
MODEL_DOWNLOAD_URLS.model,
|
MODEL_DOWNLOAD_URLS.model,
|
||||||
modelPath,
|
modelPath,
|
||||||
'model',
|
'model',
|
||||||
(downloaded, total) => {
|
(downloaded, total, speed) => {
|
||||||
const percent = total ? (downloaded / total) * 40 : undefined
|
const percent = total ? (downloaded / total) * 80 : 0
|
||||||
onProgress?.({
|
onProgress?.({
|
||||||
modelName: SENSEVOICE_MODEL.name,
|
modelName: SENSEVOICE_MODEL.name,
|
||||||
downloadedBytes: downloaded,
|
downloadedBytes: downloaded,
|
||||||
totalBytes: SENSEVOICE_MODEL.sizeBytes,
|
totalBytes: SENSEVOICE_MODEL.sizeBytes,
|
||||||
percent
|
percent,
|
||||||
|
speed
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// 下载 tokens 文件 (30%)
|
// 下载 tokens 文件 (20% 权重)
|
||||||
console.info('[VoiceTranscribe] 开始下载 tokens 文件...')
|
console.info('[VoiceTranscribe] 开始下载 tokens 文件...')
|
||||||
await this.downloadToFile(
|
await this.downloadToFile(
|
||||||
MODEL_DOWNLOAD_URLS.tokens,
|
MODEL_DOWNLOAD_URLS.tokens,
|
||||||
tokensPath,
|
tokensPath,
|
||||||
'tokens',
|
'tokens',
|
||||||
(downloaded, total) => {
|
(downloaded, total, speed) => {
|
||||||
const modelSize = existsSync(modelPath) ? statSync(modelPath).size : 0
|
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?.({
|
onProgress?.({
|
||||||
modelName: SENSEVOICE_MODEL.name,
|
modelName: SENSEVOICE_MODEL.name,
|
||||||
downloadedBytes: modelSize + downloaded,
|
downloadedBytes: modelSize + downloaded,
|
||||||
totalBytes: SENSEVOICE_MODEL.sizeBytes,
|
totalBytes: SENSEVOICE_MODEL.sizeBytes,
|
||||||
percent
|
percent,
|
||||||
|
speed
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
console.info('[VoiceTranscribe] 模型下载完成')
|
console.info('[VoiceTranscribe] 模型下载完成')
|
||||||
|
|
||||||
console.info('[VoiceTranscribe] 所有文件下载完成')
|
|
||||||
return { success: true, modelPath, tokensPath }
|
return { success: true, modelPath, tokensPath }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const modelPath = this.resolveModelPath(SENSEVOICE_MODEL.files.model)
|
const modelPath = this.resolveModelPath(SENSEVOICE_MODEL.files.model)
|
||||||
@@ -180,7 +181,7 @@ export class VoiceTranscribeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 转写 WAV 音频数据 (后台 Worker Threads 版本)
|
* 转写 WAV 音频数据
|
||||||
*/
|
*/
|
||||||
async transcribeWavBuffer(
|
async transcribeWavBuffer(
|
||||||
wavData: Buffer,
|
wavData: Buffer,
|
||||||
@@ -197,18 +198,15 @@ export class VoiceTranscribeService {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取配置的语言列表,如果没有传入则从配置读取
|
|
||||||
let supportedLanguages = languages
|
let supportedLanguages = languages
|
||||||
if (!supportedLanguages || supportedLanguages.length === 0) {
|
if (!supportedLanguages || supportedLanguages.length === 0) {
|
||||||
supportedLanguages = this.configService.get('transcribeLanguages')
|
supportedLanguages = this.configService.get('transcribeLanguages')
|
||||||
// 如果配置中也没有或为空,使用默认值
|
|
||||||
if (!supportedLanguages || supportedLanguages.length === 0) {
|
if (!supportedLanguages || supportedLanguages.length === 0) {
|
||||||
supportedLanguages = ['zh', 'yue']
|
supportedLanguages = ['zh', 'yue']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { Worker } = require('worker_threads')
|
const { Worker } = require('worker_threads')
|
||||||
// main.js 和 transcribeWorker.js 同在 dist-electron 目录下
|
|
||||||
const workerPath = join(__dirname, 'transcribeWorker.js')
|
const workerPath = join(__dirname, 'transcribeWorker.js')
|
||||||
|
|
||||||
const worker = new Worker(workerPath, {
|
const worker = new Worker(workerPath, {
|
||||||
@@ -224,12 +222,10 @@ export class VoiceTranscribeService {
|
|||||||
let finalTranscript = ''
|
let finalTranscript = ''
|
||||||
|
|
||||||
worker.on('message', (msg: any) => {
|
worker.on('message', (msg: any) => {
|
||||||
console.log('[VoiceTranscribe] Worker 消息:', msg)
|
|
||||||
if (msg.type === 'partial') {
|
if (msg.type === 'partial') {
|
||||||
onPartial?.(msg.text)
|
onPartial?.(msg.text)
|
||||||
} else if (msg.type === 'final') {
|
} else if (msg.type === 'final') {
|
||||||
finalTranscript = msg.text
|
finalTranscript = msg.text
|
||||||
console.log('[VoiceTranscribe] 最终文本:', finalTranscript)
|
|
||||||
resolve({ success: true, transcript: finalTranscript })
|
resolve({ success: true, transcript: finalTranscript })
|
||||||
worker.terminate()
|
worker.terminate()
|
||||||
} else if (msg.type === 'error') {
|
} else if (msg.type === 'error') {
|
||||||
@@ -239,15 +235,9 @@ export class VoiceTranscribeService {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
worker.on('error', (err: Error) => {
|
worker.on('error', (err: Error) => resolve({ success: false, error: String(err) }))
|
||||||
resolve({ success: false, error: String(err) })
|
|
||||||
})
|
|
||||||
|
|
||||||
worker.on('exit', (code: number) => {
|
worker.on('exit', (code: number) => {
|
||||||
if (code !== 0) {
|
if (code !== 0) resolve({ success: false, error: `Worker exited with code ${code}` })
|
||||||
console.error(`[VoiceTranscribe] Worker stopped with exit code ${code}`)
|
|
||||||
resolve({ success: false, error: `Worker exited with code ${code}` })
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -257,121 +247,240 @@ export class VoiceTranscribeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 下载文件
|
* 下载文件 (支持多线程)
|
||||||
*/
|
*/
|
||||||
private downloadToFile(
|
private async downloadToFile(
|
||||||
url: string,
|
url: string,
|
||||||
targetPath: string,
|
targetPath: string,
|
||||||
fileName: string,
|
fileName: string,
|
||||||
onProgress?: (downloaded: number, total?: number) => void,
|
onProgress?: (downloaded: number, total?: number, speed?: number) => void
|
||||||
remainingRedirects = 5
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
if (existsSync(targetPath)) {
|
||||||
const protocol = url.startsWith('https') ? https : http
|
unlinkSync(targetPath)
|
||||||
console.info(`[VoiceTranscribe] 下载 ${fileName}:`, url)
|
}
|
||||||
|
|
||||||
const options = {
|
console.info(`[VoiceTranscribe] 准备下载 ${fileName}: ${url}`)
|
||||||
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'
|
// 1. 探测支持情况
|
||||||
},
|
let probeResult
|
||||||
timeout: 30000 // 30秒连接超时
|
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) => {
|
await Promise.all(promises)
|
||||||
console.info(`[VoiceTranscribe] ${fileName} 响应状态:`, response.statusCode)
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 处理重定向
|
private async probeUrl(url: string, remainingRedirects = 5): Promise<{ totalSize: number, acceptRanges: boolean, finalUrl: string }> {
|
||||||
if ([301, 302, 303, 307, 308].includes(response.statusCode || 0) && response.headers.location) {
|
return new Promise((resolve, reject) => {
|
||||||
if (remainingRedirects <= 0) {
|
const protocol = url.startsWith('https') ? https : http
|
||||||
reject(new Error('重定向次数过多'))
|
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
|
return
|
||||||
}
|
}
|
||||||
console.info(`[VoiceTranscribe] 重定向到:`, response.headers.location)
|
}
|
||||||
this.downloadToFile(response.headers.location, targetPath, fileName, onProgress, remainingRedirects - 1)
|
|
||||||
.then(resolve)
|
if (res.statusCode !== 206 && res.statusCode !== 200) {
|
||||||
.catch(reject)
|
reject(new Error(`Probe failed: HTTP ${res.statusCode}`))
|
||||||
return
|
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) {
|
if (response.statusCode !== 200) {
|
||||||
reject(new Error(`下载失败: HTTP ${response.statusCode}`))
|
reject(new Error(`Fallback download failed: HTTP ${response.statusCode}`))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalBytes = Number(response.headers['content-length'] || 0) || undefined
|
const totalBytes = Number(response.headers['content-length'] || 0) || undefined
|
||||||
let downloadedBytes = 0
|
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)
|
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) => {
|
response.on('data', (chunk) => {
|
||||||
lastDataTime = Date.now()
|
|
||||||
downloadedBytes += chunk.length
|
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', () => {
|
writer.on('finish', () => {
|
||||||
clearInterval(dataTimeout)
|
clearInterval(speedInterval)
|
||||||
writer.close()
|
writer.close()
|
||||||
console.info(`[VoiceTranscribe] ${fileName} 下载完成:`, targetPath)
|
|
||||||
resolve()
|
resolve()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
writer.on('error', (err) => {
|
||||||
|
clearInterval(speedInterval)
|
||||||
|
// 确保在错误情况下也关闭文件句柄
|
||||||
|
writer.destroy()
|
||||||
|
reject(err)
|
||||||
|
})
|
||||||
|
|
||||||
|
response.on('error', (err) => {
|
||||||
|
clearInterval(speedInterval)
|
||||||
|
// 确保在响应错误时也关闭文件句柄
|
||||||
|
writer.destroy()
|
||||||
|
reject(err)
|
||||||
|
})
|
||||||
|
|
||||||
response.pipe(writer)
|
response.pipe(writer)
|
||||||
})
|
})
|
||||||
|
request.on('error', reject)
|
||||||
request.on('timeout', () => {
|
|
||||||
request.destroy()
|
|
||||||
console.error(`[VoiceTranscribe] ${fileName} 连接超时`)
|
|
||||||
reject(new Error('连接超时'))
|
|
||||||
})
|
|
||||||
|
|
||||||
request.on('error', (error) => {
|
|
||||||
console.error(`[VoiceTranscribe] ${fileName} 请求错误:`, error)
|
|
||||||
reject(error)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 清理资源
|
|
||||||
*/
|
|
||||||
dispose() {
|
dispose() {
|
||||||
if (this.recognizer) {
|
if (this.recognizer) {
|
||||||
try {
|
this.recognizer = null
|
||||||
// sherpa-onnx 的 recognizer 可能需要手动释放
|
|
||||||
this.recognizer = null
|
|
||||||
} catch (error) {
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const voiceTranscribeService = new VoiceTranscribeService()
|
export const voiceTranscribeService = new VoiceTranscribeService()
|
||||||
|
|
||||||
|
|||||||
180
electron/services/wasmService.ts
Normal file
180
electron/services/wasmService.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'
|
import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs'
|
||||||
|
|
||||||
// DLL 初始化错误信息,用于帮助用户诊断问题
|
// DLL 初始化错误信息,用于帮助用户诊断问题
|
||||||
let lastDllInitError: string | null = null
|
let lastDllInitError: string | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 extra_buffer(protobuf)中的免打扰状态
|
||||||
|
* - 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 {
|
export function getLastDllInitError(): string | null {
|
||||||
return lastDllInitError
|
return lastDllInitError
|
||||||
}
|
}
|
||||||
@@ -27,6 +69,8 @@ export class WcdbCore {
|
|||||||
private wcdbCloseAccount: any = null
|
private wcdbCloseAccount: any = null
|
||||||
private wcdbSetMyWxid: any = null
|
private wcdbSetMyWxid: any = null
|
||||||
private wcdbFreeString: any = null
|
private wcdbFreeString: any = null
|
||||||
|
private wcdbUpdateMessage: any = null
|
||||||
|
private wcdbDeleteMessage: any = null
|
||||||
private wcdbGetSessions: any = null
|
private wcdbGetSessions: any = null
|
||||||
private wcdbGetMessages: any = null
|
private wcdbGetMessages: any = null
|
||||||
private wcdbGetMessageCount: any = null
|
private wcdbGetMessageCount: any = null
|
||||||
@@ -35,15 +79,19 @@ export class WcdbCore {
|
|||||||
private wcdbGetGroupMemberCount: any = null
|
private wcdbGetGroupMemberCount: any = null
|
||||||
private wcdbGetGroupMemberCounts: any = null
|
private wcdbGetGroupMemberCounts: any = null
|
||||||
private wcdbGetGroupMembers: any = null
|
private wcdbGetGroupMembers: any = null
|
||||||
|
private wcdbGetGroupNicknames: any = null
|
||||||
private wcdbGetMessageTables: any = null
|
private wcdbGetMessageTables: any = null
|
||||||
private wcdbGetMessageMeta: any = null
|
private wcdbGetMessageMeta: any = null
|
||||||
private wcdbGetContact: any = null
|
private wcdbGetContact: any = null
|
||||||
|
private wcdbGetContactStatus: any = null
|
||||||
private wcdbGetMessageTableStats: any = null
|
private wcdbGetMessageTableStats: any = null
|
||||||
private wcdbGetAggregateStats: any = null
|
private wcdbGetAggregateStats: any = null
|
||||||
private wcdbGetAvailableYears: any = null
|
private wcdbGetAvailableYears: any = null
|
||||||
private wcdbGetAnnualReportStats: any = null
|
private wcdbGetAnnualReportStats: any = null
|
||||||
private wcdbGetAnnualReportExtras: any = null
|
private wcdbGetAnnualReportExtras: any = null
|
||||||
|
private wcdbGetDualReportStats: any = null
|
||||||
private wcdbGetGroupStats: any = null
|
private wcdbGetGroupStats: any = null
|
||||||
|
private wcdbGetMessageDates: any = null
|
||||||
private wcdbOpenMessageCursor: any = null
|
private wcdbOpenMessageCursor: any = null
|
||||||
private wcdbOpenMessageCursorLite: any = null
|
private wcdbOpenMessageCursorLite: any = null
|
||||||
private wcdbFetchMessageBatch: any = null
|
private wcdbFetchMessageBatch: any = null
|
||||||
@@ -57,6 +105,25 @@ export class WcdbCore {
|
|||||||
private wcdbGetDbStatus: any = null
|
private wcdbGetDbStatus: any = null
|
||||||
private wcdbGetVoiceData: any = null
|
private wcdbGetVoiceData: any = null
|
||||||
private wcdbGetSnsTimeline: 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 avatarUrlCache: Map<string, { url?: string; updatedAt: number }> = new Map()
|
||||||
private readonly avatarCacheTtlMs = 10 * 60 * 1000
|
private readonly avatarCacheTtlMs = 10 * 60 * 1000
|
||||||
private logTimer: NodeJS.Timeout | null = null
|
private logTimer: NodeJS.Timeout | null = null
|
||||||
@@ -76,6 +143,113 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 使用命名管道 IPC
|
||||||
|
startMonitor(callback: (type: string, json: string) => void): boolean {
|
||||||
|
if (!this.wcdbStartMonitorPipe) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
this.monitorCallback = callback
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = this.wcdbStartMonitorPipe()
|
||||||
|
if (result !== 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 DLL 获取动态管道名(含 PID)
|
||||||
|
let pipePath = '\\\\.\\pipe\\weflow_monitor'
|
||||||
|
if (this.wcdbGetMonitorPipeName) {
|
||||||
|
try {
|
||||||
|
const namePtr = [null as any]
|
||||||
|
if (this.wcdbGetMonitorPipeName(namePtr) === 0 && namePtr[0]) {
|
||||||
|
pipePath = this.koffi.decode(namePtr[0], 'char', -1)
|
||||||
|
this.wcdbFreeString(namePtr[0])
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.connectMonitorPipe(pipePath)
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[wcdbCore] startMonitor exception:', e)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 连接命名管道,支持断开后自动重连
|
||||||
|
private connectMonitorPipe(pipePath: string) {
|
||||||
|
this.monitorPipePath = pipePath
|
||||||
|
const net = require('net')
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!this.monitorCallback) return
|
||||||
|
|
||||||
|
this.monitorPipeClient = net.createConnection(this.monitorPipePath, () => {
|
||||||
|
})
|
||||||
|
|
||||||
|
let buffer = ''
|
||||||
|
this.monitorPipeClient.on('data', (data: Buffer) => {
|
||||||
|
buffer += data.toString('utf8')
|
||||||
|
const lines = buffer.split('\n')
|
||||||
|
buffer = lines.pop() || ''
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.trim()) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(line)
|
||||||
|
this.monitorCallback?.(parsed.action || 'update', line)
|
||||||
|
} catch {
|
||||||
|
this.monitorCallback?.('update', line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
this.monitorPipeClient.on('error', () => {
|
||||||
|
})
|
||||||
|
|
||||||
|
this.monitorPipeClient.on('close', () => {
|
||||||
|
this.monitorPipeClient = null
|
||||||
|
this.scheduleReconnect()
|
||||||
|
})
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定时重连
|
||||||
|
private scheduleReconnect() {
|
||||||
|
if (this.monitorReconnectTimer || !this.monitorCallback) return
|
||||||
|
this.monitorReconnectTimer = setTimeout(() => {
|
||||||
|
this.monitorReconnectTimer = null
|
||||||
|
if (this.monitorCallback && !this.monitorPipeClient) {
|
||||||
|
this.connectMonitorPipe(this.monitorPipePath)
|
||||||
|
}
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
stopMonitor(): void {
|
||||||
|
this.monitorCallback = null
|
||||||
|
if (this.monitorReconnectTimer) {
|
||||||
|
clearTimeout(this.monitorReconnectTimer)
|
||||||
|
this.monitorReconnectTimer = null
|
||||||
|
}
|
||||||
|
if (this.monitorPipeClient) {
|
||||||
|
this.monitorPipeClient.destroy()
|
||||||
|
this.monitorPipeClient = null
|
||||||
|
}
|
||||||
|
if (this.wcdbStopMonitorPipe) {
|
||||||
|
this.wcdbStopMonitorPipe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保留旧方法签名以兼容
|
||||||
|
setMonitor(callback: (type: string, json: string) => void): boolean {
|
||||||
|
return this.startMonitor(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取 DLL 路径
|
* 获取 DLL 路径
|
||||||
*/
|
*/
|
||||||
@@ -110,7 +284,7 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private isLogEnabled(): boolean {
|
private isLogEnabled(): boolean {
|
||||||
if (process.env.WEFLOW_WORKER === '1') return false
|
// 移除 Worker 线程的日志禁用逻辑,允许在 Worker 中记录日志
|
||||||
if (process.env.WCDB_LOG_ENABLED === '1') return true
|
if (process.env.WCDB_LOG_ENABLED === '1') return true
|
||||||
return this.logEnabled
|
return this.logEnabled
|
||||||
}
|
}
|
||||||
@@ -119,7 +293,7 @@ export class WcdbCore {
|
|||||||
if (!force && !this.isLogEnabled()) return
|
if (!force && !this.isLogEnabled()) return
|
||||||
const line = `[${new Date().toISOString()}] ${message}`
|
const line = `[${new Date().toISOString()}] ${message}`
|
||||||
// 同时输出到控制台和文件
|
// 同时输出到控制台和文件
|
||||||
console.log('[WCDB]', message)
|
|
||||||
try {
|
try {
|
||||||
const base = this.userDataPath || process.env.WCDB_LOG_DIR || process.cwd()
|
const base = this.userDataPath || process.env.WCDB_LOG_DIR || process.cwd()
|
||||||
const dir = join(base, 'logs')
|
const dir = join(base, 'logs')
|
||||||
@@ -217,9 +391,6 @@ export class WcdbCore {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关键修复:显式预加载依赖库 WCDB.dll 和 SDL2.dll
|
|
||||||
// Windows 加载器默认不会查找子目录中的依赖,必须先将其加载到内存
|
|
||||||
// 这可以解决部分用户因为 VC++ 运行时或 DLL 依赖问题导致的闪退
|
|
||||||
const dllDir = dirname(dllPath)
|
const dllDir = dirname(dllPath)
|
||||||
const wcdbCorePath = join(dllDir, 'WCDB.dll')
|
const wcdbCorePath = join(dllDir, 'WCDB.dll')
|
||||||
if (existsSync(wcdbCorePath)) {
|
if (existsSync(wcdbCorePath)) {
|
||||||
@@ -247,13 +418,36 @@ export class WcdbCore {
|
|||||||
// InitProtection (Added for security)
|
// InitProtection (Added for security)
|
||||||
try {
|
try {
|
||||||
this.wcdbInitProtection = this.lib.func('bool InitProtection(const char* resourcePath)')
|
this.wcdbInitProtection = this.lib.func('bool InitProtection(const char* resourcePath)')
|
||||||
const protectionOk = this.wcdbInitProtection(dllDir)
|
|
||||||
|
// 尝试多个可能的资源路径
|
||||||
|
const resourcePaths = [
|
||||||
|
dllDir, // DLL 所在目录
|
||||||
|
dirname(dllDir), // 上级目录
|
||||||
|
this.resourcesPath, // 配置的资源路径
|
||||||
|
join(process.cwd(), 'resources') // 开发环境
|
||||||
|
].filter(Boolean)
|
||||||
|
|
||||||
|
let protectionOk = false
|
||||||
|
for (const resPath of resourcePaths) {
|
||||||
|
try {
|
||||||
|
//
|
||||||
|
protectionOk = this.wcdbInitProtection(resPath)
|
||||||
|
if (protectionOk) {
|
||||||
|
//
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// console.warn(`[WCDB] InitProtection 失败 (${resPath}):`, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!protectionOk) {
|
if (!protectionOk) {
|
||||||
console.error('Core security check failed')
|
// console.warn('[WCDB] Core security check failed - 继续运行但可能不稳定')
|
||||||
return false
|
// this.writeLog('InitProtection 失败,继续运行')
|
||||||
|
// 不返回 false,允许继续运行
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('InitProtection symbol not found:', e)
|
// console.warn('InitProtection symbol not found:', e)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 定义类型
|
// 定义类型
|
||||||
@@ -278,6 +472,20 @@ export class WcdbCore {
|
|||||||
this.wcdbSetMyWxid = null
|
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)
|
// void wcdb_free_string(char* ptr)
|
||||||
this.wcdbFreeString = this.lib.func('void wcdb_free_string(void* ptr)')
|
this.wcdbFreeString = this.lib.func('void wcdb_free_string(void* ptr)')
|
||||||
|
|
||||||
@@ -309,6 +517,13 @@ export class WcdbCore {
|
|||||||
// wcdb_status wcdb_get_group_members(wcdb_handle handle, const char* chatroom_id, char** out_json)
|
// wcdb_status wcdb_get_group_members(wcdb_handle handle, const char* chatroom_id, char** out_json)
|
||||||
this.wcdbGetGroupMembers = this.lib.func('int32 wcdb_get_group_members(int64 handle, const char* chatroomId, _Out_ void** outJson)')
|
this.wcdbGetGroupMembers = this.lib.func('int32 wcdb_get_group_members(int64 handle, const char* chatroomId, _Out_ void** outJson)')
|
||||||
|
|
||||||
|
// wcdb_status wcdb_get_group_nicknames(wcdb_handle handle, const char* chatroom_id, char** out_json)
|
||||||
|
try {
|
||||||
|
this.wcdbGetGroupNicknames = this.lib.func('int32 wcdb_get_group_nicknames(int64 handle, const char* chatroomId, _Out_ void** outJson)')
|
||||||
|
} catch {
|
||||||
|
this.wcdbGetGroupNicknames = null
|
||||||
|
}
|
||||||
|
|
||||||
// wcdb_status wcdb_get_message_tables(wcdb_handle handle, const char* session_id, char** out_json)
|
// wcdb_status wcdb_get_message_tables(wcdb_handle handle, const char* session_id, char** out_json)
|
||||||
this.wcdbGetMessageTables = this.lib.func('int32 wcdb_get_message_tables(int64 handle, const char* sessionId, _Out_ void** outJson)')
|
this.wcdbGetMessageTables = this.lib.func('int32 wcdb_get_message_tables(int64 handle, const char* sessionId, _Out_ void** outJson)')
|
||||||
|
|
||||||
@@ -318,6 +533,13 @@ export class WcdbCore {
|
|||||||
// wcdb_status wcdb_get_contact(wcdb_handle handle, const char* username, char** out_json)
|
// 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)')
|
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)
|
// 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)')
|
this.wcdbGetMessageTableStats = this.lib.func('int32 wcdb_get_message_table_stats(int64 handle, const char* sessionId, _Out_ void** outJson)')
|
||||||
|
|
||||||
@@ -345,6 +567,20 @@ export class WcdbCore {
|
|||||||
this.wcdbGetAnnualReportExtras = null
|
this.wcdbGetAnnualReportExtras = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wcdb_status wcdb_get_dual_report_stats(wcdb_handle handle, const char* session_id, int32_t begin_timestamp, int32_t end_timestamp, char** out_json)
|
||||||
|
try {
|
||||||
|
this.wcdbGetDualReportStats = this.lib.func('int32 wcdb_get_dual_report_stats(int64 handle, const char* sessionId, int32 begin, int32 end, _Out_ void** outJson)')
|
||||||
|
} catch {
|
||||||
|
this.wcdbGetDualReportStats = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// wcdb_status wcdb_get_logs(char** out_json)
|
||||||
|
try {
|
||||||
|
this.wcdbGetLogs = this.lib.func('int32 wcdb_get_logs(_Out_ void** outJson)')
|
||||||
|
} catch {
|
||||||
|
this.wcdbGetLogs = null
|
||||||
|
}
|
||||||
|
|
||||||
// wcdb_status wcdb_get_group_stats(wcdb_handle handle, const char* chatroom_id, int32_t begin_timestamp, int32_t end_timestamp, char** out_json)
|
// wcdb_status wcdb_get_group_stats(wcdb_handle handle, const char* chatroom_id, int32_t begin_timestamp, int32_t end_timestamp, char** out_json)
|
||||||
try {
|
try {
|
||||||
this.wcdbGetGroupStats = this.lib.func('int32 wcdb_get_group_stats(int64 handle, const char* chatroomId, int32 begin, int32 end, _Out_ void** outJson)')
|
this.wcdbGetGroupStats = this.lib.func('int32 wcdb_get_group_stats(int64 handle, const char* chatroomId, int32 begin, int32 end, _Out_ void** outJson)')
|
||||||
@@ -352,6 +588,13 @@ export class WcdbCore {
|
|||||||
this.wcdbGetGroupStats = null
|
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)
|
// 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)')
|
this.wcdbOpenMessageCursor = this.lib.func('int32 wcdb_open_message_cursor(int64 handle, const char* sessionId, int32 batchSize, int32 ascending, int32 beginTimestamp, int32 endTimestamp, _Out_ int64* outCursor)')
|
||||||
|
|
||||||
@@ -407,6 +650,83 @@ export class WcdbCore {
|
|||||||
this.wcdbGetSnsTimeline = null
|
this.wcdbGetSnsTimeline = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wcdb_status wcdb_get_sns_annual_stats(wcdb_handle handle, int32_t begin_timestamp, int32_t end_timestamp, char** out_json)
|
||||||
|
try {
|
||||||
|
this.wcdbGetSnsAnnualStats = this.lib.func('int32 wcdb_get_sns_annual_stats(int64 handle, int32 begin, int32 end, _Out_ void** outJson)')
|
||||||
|
} catch {
|
||||||
|
this.wcdbGetSnsAnnualStats = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// wcdb_status wcdb_install_sns_block_delete_trigger(wcdb_handle handle, char** out_error)
|
||||||
|
try {
|
||||||
|
this.wcdbInstallSnsBlockDeleteTrigger = this.lib.func('int32 wcdb_install_sns_block_delete_trigger(int64 handle, _Out_ void** outError)')
|
||||||
|
} catch {
|
||||||
|
this.wcdbInstallSnsBlockDeleteTrigger = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// wcdb_status wcdb_uninstall_sns_block_delete_trigger(wcdb_handle handle, char** out_error)
|
||||||
|
try {
|
||||||
|
this.wcdbUninstallSnsBlockDeleteTrigger = this.lib.func('int32 wcdb_uninstall_sns_block_delete_trigger(int64 handle, _Out_ void** outError)')
|
||||||
|
} catch {
|
||||||
|
this.wcdbUninstallSnsBlockDeleteTrigger = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// wcdb_status wcdb_check_sns_block_delete_trigger(wcdb_handle handle, int32_t* out_installed)
|
||||||
|
try {
|
||||||
|
this.wcdbCheckSnsBlockDeleteTrigger = this.lib.func('int32 wcdb_check_sns_block_delete_trigger(int64 handle, _Out_ int32* outInstalled)')
|
||||||
|
} catch {
|
||||||
|
this.wcdbCheckSnsBlockDeleteTrigger = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// wcdb_status wcdb_delete_sns_post(wcdb_handle handle, const char* post_id, char** out_error)
|
||||||
|
try {
|
||||||
|
this.wcdbDeleteSnsPost = this.lib.func('int32 wcdb_delete_sns_post(int64 handle, const char* postId, _Out_ void** outError)')
|
||||||
|
} catch {
|
||||||
|
this.wcdbDeleteSnsPost = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Named pipe IPC for monitoring (replaces callback)
|
||||||
|
try {
|
||||||
|
this.wcdbStartMonitorPipe = this.lib.func('int32 wcdb_start_monitor_pipe()')
|
||||||
|
this.wcdbStopMonitorPipe = this.lib.func('void wcdb_stop_monitor_pipe()')
|
||||||
|
this.wcdbGetMonitorPipeName = this.lib.func('int32 wcdb_get_monitor_pipe_name(_Out_ void** outName)')
|
||||||
|
this.writeLog('Monitor pipe functions loaded')
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to load monitor pipe functions:', e)
|
||||||
|
this.wcdbStartMonitorPipe = null
|
||||||
|
this.wcdbStopMonitorPipe = null
|
||||||
|
this.wcdbGetMonitorPipeName = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// void VerifyUser(int64_t hwnd_ptr, const char* message, char* out_result, int max_len)
|
||||||
|
try {
|
||||||
|
this.wcdbVerifyUser = this.lib.func('void VerifyUser(int64 hwnd, const char* message, _Out_ char* outResult, int maxLen)')
|
||||||
|
} catch {
|
||||||
|
this.wcdbVerifyUser = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// wcdb_status wcdb_cloud_init(int32_t interval_seconds)
|
||||||
|
try {
|
||||||
|
this.wcdbCloudInit = this.lib.func('int32 wcdb_cloud_init(int32 intervalSeconds)')
|
||||||
|
} catch {
|
||||||
|
this.wcdbCloudInit = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// wcdb_status wcdb_cloud_report(const char* stats_json)
|
||||||
|
try {
|
||||||
|
this.wcdbCloudReport = this.lib.func('int32 wcdb_cloud_report(const char* statsJson)')
|
||||||
|
} catch {
|
||||||
|
this.wcdbCloudReport = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// void wcdb_cloud_stop()
|
||||||
|
try {
|
||||||
|
this.wcdbCloudStop = this.lib.func('void wcdb_cloud_stop()')
|
||||||
|
} catch {
|
||||||
|
this.wcdbCloudStop = null
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// 初始化
|
// 初始化
|
||||||
const initResult = this.wcdbInit()
|
const initResult = this.wcdbInit()
|
||||||
if (initResult !== 0) {
|
if (initResult !== 0) {
|
||||||
@@ -800,6 +1120,37 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定时间之后的新消息
|
||||||
|
*/
|
||||||
|
async getNewMessages(sessionId: string, minTime: number, limit: number = 1000): Promise<{ success: boolean; messages?: any[]; error?: string }> {
|
||||||
|
if (!this.ensureReady()) {
|
||||||
|
return { success: false, error: 'WCDB 未连接' }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// 1. 打开游标 (使用 Ascending=1 从指定时间往后查)
|
||||||
|
const openRes = await this.openMessageCursor(sessionId, limit, true, minTime, 0)
|
||||||
|
if (!openRes.success || !openRes.cursor) {
|
||||||
|
return { success: false, error: openRes.error }
|
||||||
|
}
|
||||||
|
|
||||||
|
const cursor = openRes.cursor
|
||||||
|
try {
|
||||||
|
// 2. 获取批次
|
||||||
|
const fetchRes = await this.fetchMessageBatch(cursor)
|
||||||
|
if (!fetchRes.success) {
|
||||||
|
return { success: false, error: fetchRes.error }
|
||||||
|
}
|
||||||
|
return { success: true, messages: fetchRes.rows }
|
||||||
|
} finally {
|
||||||
|
// 3. 关闭游标
|
||||||
|
await this.closeMessageCursor(cursor)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getMessageCount(sessionId: string): Promise<{ success: boolean; count?: number; error?: string }> {
|
async getMessageCount(sessionId: string): Promise<{ success: boolean; count?: number; error?: string }> {
|
||||||
if (!this.ensureReady()) {
|
if (!this.ensureReady()) {
|
||||||
return { success: false, error: 'WCDB 未连接' }
|
return { success: false, error: 'WCDB 未连接' }
|
||||||
@@ -816,6 +1167,40 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getMessageCounts(sessionIds: string[]): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> {
|
||||||
|
if (!this.ensureReady()) {
|
||||||
|
return { success: false, error: 'WCDB 未连接' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedSessionIds = Array.from(
|
||||||
|
new Set(
|
||||||
|
(sessionIds || [])
|
||||||
|
.map((id) => String(id || '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (normalizedSessionIds.length === 0) {
|
||||||
|
return { success: true, counts: {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const counts: Record<string, number> = {}
|
||||||
|
for (let i = 0; i < normalizedSessionIds.length; i += 1) {
|
||||||
|
const sessionId = normalizedSessionIds[i]
|
||||||
|
const outCount = [0]
|
||||||
|
const result = this.wcdbGetMessageCount(this.handle, sessionId, outCount)
|
||||||
|
counts[sessionId] = result === 0 && Number.isFinite(outCount[0]) ? Math.max(0, Math.floor(outCount[0])) : 0
|
||||||
|
|
||||||
|
if (i > 0 && i % 160 === 0) {
|
||||||
|
await new Promise(resolve => setImmediate(resolve))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { success: true, counts }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getDisplayNames(usernames: string[]): Promise<{ success: boolean; map?: Record<string, string>; error?: string }> {
|
async getDisplayNames(usernames: string[]): Promise<{ success: boolean; map?: Record<string, string>; error?: string }> {
|
||||||
if (!this.ensureReady()) {
|
if (!this.ensureReady()) {
|
||||||
return { success: false, error: 'WCDB 未连接' }
|
return { success: false, error: 'WCDB 未连接' }
|
||||||
@@ -971,6 +1356,28 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getGroupNicknames(chatroomId: string): Promise<{ success: boolean; nicknames?: Record<string, string>; error?: string }> {
|
||||||
|
if (!this.ensureReady()) {
|
||||||
|
return { success: false, error: 'WCDB 未连接' }
|
||||||
|
}
|
||||||
|
if (!this.wcdbGetGroupNicknames) {
|
||||||
|
return { success: false, error: '当前 DLL 版本不支持获取群昵称接口' }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const outPtr = [null as any]
|
||||||
|
const result = this.wcdbGetGroupNicknames(this.handle, chatroomId, outPtr)
|
||||||
|
if (result !== 0 || !outPtr[0]) {
|
||||||
|
return { success: false, error: `获取群昵称失败: ${result}` }
|
||||||
|
}
|
||||||
|
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||||
|
if (!jsonStr) return { success: false, error: '解析群昵称失败' }
|
||||||
|
const nicknames = JSON.parse(jsonStr)
|
||||||
|
return { success: true, nicknames }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getMessageTables(sessionId: string): Promise<{ success: boolean; tables?: any[]; error?: string }> {
|
async getMessageTables(sessionId: string): Promise<{ success: boolean; tables?: any[]; error?: string }> {
|
||||||
if (!this.ensureReady()) {
|
if (!this.ensureReady()) {
|
||||||
return { success: false, error: 'WCDB 未连接' }
|
return { success: false, error: 'WCDB 未连接' }
|
||||||
@@ -990,6 +1397,29 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getMessageDates(sessionId: string): Promise<{ success: boolean; dates?: string[]; error?: string }> {
|
||||||
|
if (!this.ensureReady()) {
|
||||||
|
return { success: false, error: 'WCDB 未连接' }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (!this.wcdbGetMessageDates) {
|
||||||
|
return { success: false, error: 'DLL 不支持 getMessageDates' }
|
||||||
|
}
|
||||||
|
const outPtr = [null as any]
|
||||||
|
const result = this.wcdbGetMessageDates(this.handle, sessionId, outPtr)
|
||||||
|
if (result !== 0 || !outPtr[0]) {
|
||||||
|
// 空结果也可能是正常的(无消息)
|
||||||
|
return { success: true, dates: [] }
|
||||||
|
}
|
||||||
|
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||||
|
if (!jsonStr) return { success: false, error: '解析日期列表失败' }
|
||||||
|
const dates = JSON.parse(jsonStr)
|
||||||
|
return { success: true, dates }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getMessageTableStats(sessionId: string): Promise<{ success: boolean; tables?: any[]; error?: string }> {
|
async getMessageTableStats(sessionId: string): Promise<{ success: boolean; tables?: any[]; error?: string }> {
|
||||||
if (!this.ensureReady()) {
|
if (!this.ensureReady()) {
|
||||||
return { success: false, error: 'WCDB 未连接' }
|
return { success: false, error: 'WCDB 未连接' }
|
||||||
@@ -1047,6 +1477,36 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getContactStatus(usernames: string[]): Promise<{ success: boolean; map?: Record<string, { isFolded: boolean; isMuted: boolean }>; error?: string }> {
|
||||||
|
if (!this.ensureReady()) {
|
||||||
|
return { success: false, error: 'WCDB 未连接' }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// 分批查询,避免 SQL 过长(execQuery 不支持参数绑定,直接拼 SQL)
|
||||||
|
const BATCH = 200
|
||||||
|
const map: Record<string, { isFolded: boolean; isMuted: boolean }> = {}
|
||||||
|
for (let i = 0; i < usernames.length; i += BATCH) {
|
||||||
|
const batch = usernames.slice(i, i + BATCH)
|
||||||
|
const inList = batch.map(u => `'${u.replace(/'/g, "''")}'`).join(',')
|
||||||
|
const sql = `SELECT username, flag, extra_buffer FROM contact WHERE username IN (${inList})`
|
||||||
|
const result = await this.execQuery('contact', null, sql)
|
||||||
|
if (!result.success || !result.rows) continue
|
||||||
|
for (const row of result.rows) {
|
||||||
|
const uname: string = row.username
|
||||||
|
// 折叠:flag bit 28 (0x10000000)
|
||||||
|
const flag = parseInt(row.flag ?? '0', 10)
|
||||||
|
const isFolded = (flag & 0x10000000) !== 0
|
||||||
|
// 免打扰:extra_buffer field 12 非0
|
||||||
|
const { isMuted } = parseExtraBuffer(row.extra_buffer)
|
||||||
|
map[uname] = { isFolded, isMuted }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { success: true, map }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getAggregateStats(sessionIds: string[], beginTimestamp: number = 0, endTimestamp: number = 0): Promise<{ success: boolean; data?: any; error?: string }> {
|
async getAggregateStats(sessionIds: string[], beginTimestamp: number = 0, endTimestamp: number = 0): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||||
if (!this.ensureReady()) {
|
if (!this.ensureReady()) {
|
||||||
return { success: false, error: 'WCDB 未连接' }
|
return { success: false, error: 'WCDB 未连接' }
|
||||||
@@ -1312,13 +1772,39 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async execQuery(kind: string, path: string | null, sql: string): Promise<{ success: boolean; rows?: any[]; error?: string }> {
|
async getLogs(): Promise<{ success: boolean; logs?: string[]; error?: string }> {
|
||||||
|
if (!this.lib) return { success: false, error: 'DLL 未加载' }
|
||||||
|
if (!this.wcdbGetLogs) return { success: false, error: '接口未就绪' }
|
||||||
|
try {
|
||||||
|
const outPtr = [null as any]
|
||||||
|
const result = this.wcdbGetLogs(outPtr)
|
||||||
|
if (result !== 0 || !outPtr[0]) {
|
||||||
|
return { success: false, error: `获取日志失败: ${result}` }
|
||||||
|
}
|
||||||
|
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||||
|
if (!jsonStr) return { success: false, error: '解析日志失败' }
|
||||||
|
return { success: true, logs: JSON.parse(jsonStr) }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async execQuery(kind: string, path: string | null, sql: string, params: any[] = []): Promise<{ success: boolean; rows?: any[]; error?: string }> {
|
||||||
if (!this.ensureReady()) {
|
if (!this.ensureReady()) {
|
||||||
return { success: false, error: 'WCDB 未连接' }
|
return { success: false, error: 'WCDB 未连接' }
|
||||||
}
|
}
|
||||||
try {
|
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 outPtr = [null as any]
|
||||||
const result = this.wcdbExecQuery(this.handle, kind, path, sql, outPtr)
|
const result = this.wcdbExecQuery(this.handle, kind, path || '', sql, outPtr)
|
||||||
if (result !== 0 || !outPtr[0]) {
|
if (result !== 0 || !outPtr[0]) {
|
||||||
return { success: false, error: `执行查询失败: ${result}` }
|
return { success: false, error: `执行查询失败: ${result}` }
|
||||||
}
|
}
|
||||||
@@ -1411,6 +1897,88 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据收集初始化
|
||||||
|
*/
|
||||||
|
async cloudInit(intervalSeconds: number = 600): Promise<{ success: boolean; error?: string }> {
|
||||||
|
if (!this.initialized) {
|
||||||
|
const initOk = await this.initialize()
|
||||||
|
if (!initOk) return { success: false, error: 'WCDB init failed' }
|
||||||
|
}
|
||||||
|
if (!this.wcdbCloudInit) {
|
||||||
|
return { success: false, error: 'Cloud init API not supported by DLL' }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = this.wcdbCloudInit(intervalSeconds)
|
||||||
|
if (result !== 0) {
|
||||||
|
return { success: false, error: `Cloud init failed: ${result}` }
|
||||||
|
}
|
||||||
|
return { success: true }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cloudReport(statsJson: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
if (!this.initialized) {
|
||||||
|
const initOk = await this.initialize()
|
||||||
|
if (!initOk) return { success: false, error: 'WCDB init failed' }
|
||||||
|
}
|
||||||
|
if (!this.wcdbCloudReport) {
|
||||||
|
return { success: false, error: 'Cloud report API not supported by DLL' }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = this.wcdbCloudReport(statsJson || '')
|
||||||
|
if (result !== 0) {
|
||||||
|
return { success: false, error: `Cloud report failed: ${result}` }
|
||||||
|
}
|
||||||
|
return { success: true }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cloudStop(): { success: boolean; error?: string } {
|
||||||
|
if (!this.wcdbCloudStop) {
|
||||||
|
return { success: false, error: 'Cloud stop API not supported by DLL' }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.wcdbCloudStop()
|
||||||
|
return { success: true }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async verifyUser(message: string, hwnd?: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
if (!this.initialized) {
|
||||||
|
const initOk = await this.initialize()
|
||||||
|
if (!initOk) return { success: false, error: 'WCDB 初始化失败' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.wcdbVerifyUser) {
|
||||||
|
return { success: false, error: 'Binding not found: VerifyUser' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
try {
|
||||||
|
// Allocate buffer for result JSON
|
||||||
|
const maxLen = 1024
|
||||||
|
const outBuf = Buffer.alloc(maxLen)
|
||||||
|
|
||||||
|
// Call native function
|
||||||
|
const hwndVal = hwnd ? BigInt(hwnd) : BigInt(0)
|
||||||
|
this.wcdbVerifyUser(hwndVal, message || '', outBuf, maxLen)
|
||||||
|
|
||||||
|
// Parse result
|
||||||
|
const jsonStr = this.koffi.decode(outBuf, 'char', -1)
|
||||||
|
const result = JSON.parse(jsonStr)
|
||||||
|
resolve(result)
|
||||||
|
} catch (e) {
|
||||||
|
resolve({ success: false, error: String(e) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async getSnsTimeline(limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: any[]; error?: string }> {
|
async getSnsTimeline(limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: any[]; error?: string }> {
|
||||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||||
if (!this.wcdbGetSnsTimeline) return { success: false, error: '当前 DLL 版本不支持获取朋友圈' }
|
if (!this.wcdbGetSnsTimeline) return { success: false, error: '当前 DLL 版本不支持获取朋友圈' }
|
||||||
@@ -1438,4 +2006,196 @@ export class WcdbCore {
|
|||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getSnsAnnualStats(beginTimestamp: number, endTimestamp: number): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||||
|
if (!this.ensureReady()) {
|
||||||
|
return { success: false, error: 'WCDB 未连接' }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (!this.wcdbGetSnsAnnualStats) {
|
||||||
|
return { success: false, error: 'wcdbGetSnsAnnualStats 未找到' }
|
||||||
|
}
|
||||||
|
await new Promise(resolve => setImmediate(resolve))
|
||||||
|
const outPtr = [null as any]
|
||||||
|
const result = this.wcdbGetSnsAnnualStats(this.handle, beginTimestamp, endTimestamp, outPtr)
|
||||||
|
await new Promise(resolve => setImmediate(resolve))
|
||||||
|
|
||||||
|
if (result !== 0 || !outPtr[0]) {
|
||||||
|
return { success: false, error: `getSnsAnnualStats failed: ${result}` }
|
||||||
|
}
|
||||||
|
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||||
|
if (!jsonStr) return { success: false, error: 'Failed to decode JSON' }
|
||||||
|
return { success: true, data: JSON.parse(jsonStr) }
|
||||||
|
} catch (e) {
|
||||||
|
console.error('getSnsAnnualStats 异常:', e)
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 为朋友圈安装删除
|
||||||
|
*/
|
||||||
|
async installSnsBlockDeleteTrigger(): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> {
|
||||||
|
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||||
|
if (!this.wcdbInstallSnsBlockDeleteTrigger) return { success: false, error: '当前 DLL 版本不支持此功能' }
|
||||||
|
try {
|
||||||
|
const outPtr = [null]
|
||||||
|
const status = this.wcdbInstallSnsBlockDeleteTrigger(this.handle, outPtr)
|
||||||
|
let msg = ''
|
||||||
|
if (outPtr[0]) {
|
||||||
|
try { msg = this.koffi.decode(outPtr[0], 'char', -1) } catch { }
|
||||||
|
try { this.wcdbFreeString(outPtr[0]) } catch { }
|
||||||
|
}
|
||||||
|
if (status === 1) {
|
||||||
|
// DLL 返回 1 表示已安装
|
||||||
|
return { success: true, alreadyInstalled: true }
|
||||||
|
}
|
||||||
|
if (status !== 0) {
|
||||||
|
return { success: false, error: msg || `DLL error ${status}` }
|
||||||
|
}
|
||||||
|
return { success: true, alreadyInstalled: false }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭朋友圈删除拦截
|
||||||
|
*/
|
||||||
|
async uninstallSnsBlockDeleteTrigger(): Promise<{ success: boolean; error?: string }> {
|
||||||
|
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||||
|
if (!this.wcdbUninstallSnsBlockDeleteTrigger) return { success: false, error: '当前 DLL 版本不支持此功能' }
|
||||||
|
try {
|
||||||
|
const outPtr = [null]
|
||||||
|
const status = this.wcdbUninstallSnsBlockDeleteTrigger(this.handle, outPtr)
|
||||||
|
let msg = ''
|
||||||
|
if (outPtr[0]) {
|
||||||
|
try { msg = this.koffi.decode(outPtr[0], 'char', -1) } catch { }
|
||||||
|
try { this.wcdbFreeString(outPtr[0]) } catch { }
|
||||||
|
}
|
||||||
|
if (status !== 0) {
|
||||||
|
return { success: false, error: msg || `DLL error ${status}` }
|
||||||
|
}
|
||||||
|
return { success: true }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询朋友圈删除拦截是否已安装
|
||||||
|
*/
|
||||||
|
async checkSnsBlockDeleteTrigger(): Promise<{ success: boolean; installed?: boolean; error?: string }> {
|
||||||
|
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||||
|
if (!this.wcdbCheckSnsBlockDeleteTrigger) return { success: false, error: '当前 DLL 版本不支持此功能' }
|
||||||
|
try {
|
||||||
|
const outInstalled = [0]
|
||||||
|
const status = this.wcdbCheckSnsBlockDeleteTrigger(this.handle, outInstalled)
|
||||||
|
if (status !== 0) {
|
||||||
|
return { success: false, error: `DLL error ${status}` }
|
||||||
|
}
|
||||||
|
return { success: true, installed: outInstalled[0] === 1 }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSnsPost(postId: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||||
|
if (!this.wcdbDeleteSnsPost) return { success: false, error: '当前 DLL 版本不支持此功能' }
|
||||||
|
try {
|
||||||
|
const outPtr = [null]
|
||||||
|
const status = this.wcdbDeleteSnsPost(this.handle, postId, outPtr)
|
||||||
|
let msg = ''
|
||||||
|
if (outPtr[0]) {
|
||||||
|
try { msg = this.koffi.decode(outPtr[0], 'char', -1) } catch { }
|
||||||
|
try { this.wcdbFreeString(outPtr[0]) } catch { }
|
||||||
|
}
|
||||||
|
if (status !== 0) {
|
||||||
|
return { success: false, error: msg || `DLL error ${status}` }
|
||||||
|
}
|
||||||
|
return { success: true }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDualReportStats(sessionId: string, beginTimestamp: number = 0, endTimestamp: number = 0): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||||
|
if (!this.ensureReady()) {
|
||||||
|
return { success: false, error: 'WCDB 未连接' }
|
||||||
|
}
|
||||||
|
if (!this.wcdbGetDualReportStats) {
|
||||||
|
return { success: false, error: '未支持双人报告统计' }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { begin, end } = this.normalizeRange(beginTimestamp, endTimestamp)
|
||||||
|
const outPtr = [null as any]
|
||||||
|
const result = this.wcdbGetDualReportStats(this.handle, sessionId, begin, end, outPtr)
|
||||||
|
if (result !== 0 || !outPtr[0]) {
|
||||||
|
return { success: false, error: `获取双人报告统计失败: ${result}` }
|
||||||
|
}
|
||||||
|
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||||
|
if (!jsonStr) return { success: false, error: '解析双人报告统计失败' }
|
||||||
|
const data = JSON.parse(jsonStr)
|
||||||
|
return { success: true, data }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 修改消息内容
|
||||||
|
*/
|
||||||
|
async updateMessage(sessionId: string, localId: number, createTime: number, newContent: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
if (!this.initialized || !this.wcdbUpdateMessage) return { success: false, error: 'WCDB Not Initialized or Method Missing' }
|
||||||
|
if (!this.handle) return { success: false, error: 'Not Connected' }
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
try {
|
||||||
|
const outError = [null as any]
|
||||||
|
const result = this.wcdbUpdateMessage(this.handle, sessionId, localId, createTime, newContent, outError)
|
||||||
|
|
||||||
|
if (result !== 0) {
|
||||||
|
let errorMsg = 'Unknown Error'
|
||||||
|
if (outError[0]) {
|
||||||
|
errorMsg = this.decodeJsonPtr(outError[0]) || 'Unknown Error (Decode Failed)'
|
||||||
|
}
|
||||||
|
resolve({ success: false, error: errorMsg })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve({ success: true })
|
||||||
|
} catch (e) {
|
||||||
|
resolve({ success: false, error: String(e) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除消息
|
||||||
|
*/
|
||||||
|
async deleteMessage(sessionId: string, localId: number, createTime: number, dbPathHint?: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
if (!this.initialized || !this.wcdbDeleteMessage) return { success: false, error: 'WCDB Not Initialized or Method Missing' }
|
||||||
|
if (!this.handle) return { success: false, error: 'Not Connected' }
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
try {
|
||||||
|
const outError = [null as any]
|
||||||
|
const result = this.wcdbDeleteMessage(this.handle, sessionId, localId, createTime || 0, dbPathHint || '', outError)
|
||||||
|
|
||||||
|
if (result !== 0) {
|
||||||
|
let errorMsg = 'Unknown Error'
|
||||||
|
if (outError[0]) {
|
||||||
|
errorMsg = this.decodeJsonPtr(outError[0]) || 'Unknown Error (Decode Failed)'
|
||||||
|
}
|
||||||
|
console.error(`[WcdbCore] deleteMessage fail: code=${result}, error=${errorMsg}`)
|
||||||
|
resolve({ success: false, error: errorMsg })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve({ success: true })
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[WcdbCore] deleteMessage exception:`, e)
|
||||||
|
resolve({ success: false, error: String(e) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export class WcdbService {
|
|||||||
private resourcesPath: string | null = null
|
private resourcesPath: string | null = null
|
||||||
private userDataPath: string | null = null
|
private userDataPath: string | null = null
|
||||||
private logEnabled = false
|
private logEnabled = false
|
||||||
|
private monitorListener: ((type: string, json: string) => void) | null = null
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.initWorker()
|
this.initWorker()
|
||||||
@@ -47,8 +48,16 @@ export class WcdbService {
|
|||||||
try {
|
try {
|
||||||
this.worker = new Worker(finalPath)
|
this.worker = new Worker(finalPath)
|
||||||
|
|
||||||
this.worker.on('message', (msg: WorkerMessage) => {
|
this.worker.on('message', (msg: any) => {
|
||||||
const { id, result, error } = msg
|
const { id, result, error, type, payload } = msg
|
||||||
|
|
||||||
|
if (type === 'monitor') {
|
||||||
|
if (this.monitorListener) {
|
||||||
|
this.monitorListener(payload.type, payload.json)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const p = this.pending.get(id)
|
const p = this.pending.get(id)
|
||||||
if (p) {
|
if (p) {
|
||||||
this.pending.delete(id)
|
this.pending.delete(id)
|
||||||
@@ -122,6 +131,14 @@ export class WcdbService {
|
|||||||
this.callWorker('setLogEnabled', { enabled }).catch(() => { })
|
this.callWorker('setLogEnabled', { enabled }).catch(() => { })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置数据库监控回调
|
||||||
|
*/
|
||||||
|
setMonitor(callback: (type: string, json: string) => void): void {
|
||||||
|
this.monitorListener = callback;
|
||||||
|
this.callWorker('setMonitor').catch(() => { });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查服务是否就绪
|
* 检查服务是否就绪
|
||||||
*/
|
*/
|
||||||
@@ -187,6 +204,13 @@ export class WcdbService {
|
|||||||
return this.callWorker('getMessages', { sessionId, limit, offset })
|
return this.callWorker('getMessages', { sessionId, limit, offset })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取新消息(增量刷新)
|
||||||
|
*/
|
||||||
|
async getNewMessages(sessionId: string, minTime: number, limit: number = 1000): Promise<{ success: boolean; messages?: any[]; error?: string }> {
|
||||||
|
return this.callWorker('getNewMessages', { sessionId, minTime, limit })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取消息总数
|
* 获取消息总数
|
||||||
*/
|
*/
|
||||||
@@ -194,6 +218,10 @@ export class WcdbService {
|
|||||||
return this.callWorker('getMessageCount', { sessionId })
|
return this.callWorker('getMessageCount', { sessionId })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getMessageCounts(sessionIds: string[]): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> {
|
||||||
|
return this.callWorker('getMessageCounts', { sessionIds })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取联系人昵称
|
* 获取联系人昵称
|
||||||
*/
|
*/
|
||||||
@@ -229,6 +257,11 @@ export class WcdbService {
|
|||||||
return this.callWorker('getGroupMembers', { chatroomId })
|
return this.callWorker('getGroupMembers', { chatroomId })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取群成员群名片昵称
|
||||||
|
async getGroupNicknames(chatroomId: string): Promise<{ success: boolean; nicknames?: Record<string, string>; error?: string }> {
|
||||||
|
return this.callWorker('getGroupNicknames', { chatroomId })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取消息表列表
|
* 获取消息表列表
|
||||||
*/
|
*/
|
||||||
@@ -243,6 +276,10 @@ export class WcdbService {
|
|||||||
return this.callWorker('getMessageTableStats', { sessionId })
|
return this.callWorker('getMessageTableStats', { sessionId })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getMessageDates(sessionId: string): Promise<{ success: boolean; dates?: string[]; error?: string }> {
|
||||||
|
return this.callWorker('getMessageDates', { sessionId })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取消息元数据
|
* 获取消息元数据
|
||||||
*/
|
*/
|
||||||
@@ -257,6 +294,13 @@ export class WcdbService {
|
|||||||
return this.callWorker('getContact', { username })
|
return this.callWorker('getContact', { username })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量获取联系人 extra_buffer 状态(isFolded/isMuted)
|
||||||
|
*/
|
||||||
|
async getContactStatus(usernames: string[]): Promise<{ success: boolean; map?: Record<string, { isFolded: boolean; isMuted: boolean }>; error?: string }> {
|
||||||
|
return this.callWorker('getContactStatus', { usernames })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取聚合统计数据
|
* 获取聚合统计数据
|
||||||
*/
|
*/
|
||||||
@@ -285,6 +329,13 @@ export class WcdbService {
|
|||||||
return this.callWorker('getAnnualReportExtras', { sessionIds, beginTimestamp, endTimestamp, peakDayBegin, peakDayEnd })
|
return this.callWorker('getAnnualReportExtras', { sessionIds, beginTimestamp, endTimestamp, peakDayBegin, peakDayEnd })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取双人报告统计数据
|
||||||
|
*/
|
||||||
|
async getDualReportStats(sessionId: string, beginTimestamp: number, endTimestamp: number): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||||
|
return this.callWorker('getDualReportStats', { sessionId, beginTimestamp, endTimestamp })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取群聊统计
|
* 获取群聊统计
|
||||||
*/
|
*/
|
||||||
@@ -321,10 +372,10 @@ export class WcdbService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 执行 SQL 查询
|
* 执行 SQL 查询(支持参数化查询)
|
||||||
*/
|
*/
|
||||||
async execQuery(kind: string, path: string | null, sql: string): Promise<{ success: boolean; rows?: any[]; error?: string }> {
|
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 })
|
return this.callWorker('execQuery', { kind, path, sql, params })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -369,6 +420,92 @@ export class WcdbService {
|
|||||||
return this.callWorker('getSnsTimeline', { limit, offset, usernames, keyword, startTime, endTime })
|
return this.callWorker('getSnsTimeline', { limit, offset, usernames, keyword, startTime, endTime })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取朋友圈年度统计
|
||||||
|
*/
|
||||||
|
async getSnsAnnualStats(beginTimestamp: number, endTimestamp: number): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||||
|
return this.callWorker('getSnsAnnualStats', { beginTimestamp, endTimestamp })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安装朋友圈删除拦截
|
||||||
|
*/
|
||||||
|
async installSnsBlockDeleteTrigger(): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> {
|
||||||
|
return this.callWorker('installSnsBlockDeleteTrigger')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 卸载朋友圈删除拦截
|
||||||
|
*/
|
||||||
|
async uninstallSnsBlockDeleteTrigger(): Promise<{ success: boolean; error?: string }> {
|
||||||
|
return this.callWorker('uninstallSnsBlockDeleteTrigger')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询朋友圈删除拦截是否已安装
|
||||||
|
*/
|
||||||
|
async checkSnsBlockDeleteTrigger(): Promise<{ success: boolean; installed?: boolean; error?: string }> {
|
||||||
|
return this.callWorker('checkSnsBlockDeleteTrigger')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从数据库直接删除朋友圈记录
|
||||||
|
*/
|
||||||
|
async deleteSnsPost(postId: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
return this.callWorker('deleteSnsPost', { postId })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 DLL 内部日志
|
||||||
|
*/
|
||||||
|
async getLogs(): Promise<{ success: boolean; logs?: string[]; error?: string }> {
|
||||||
|
return this.callWorker('getLogs')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 Windows Hello
|
||||||
|
*/
|
||||||
|
async verifyUser(message: string, hwnd?: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
return this.callWorker('verifyUser', { message, hwnd })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 修改消息内容
|
||||||
|
*/
|
||||||
|
async updateMessage(sessionId: string, localId: number, createTime: number, newContent: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
return this.callWorker('updateMessage', { sessionId, localId, createTime, newContent })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除消息
|
||||||
|
*/
|
||||||
|
async deleteMessage(sessionId: string, localId: number, createTime: number, dbPathHint?: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
return this.callWorker('deleteMessage', { sessionId, localId, createTime, dbPathHint })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据收集:初始化
|
||||||
|
*/
|
||||||
|
async cloudInit(intervalSeconds: number): Promise<{ success: boolean; error?: string }> {
|
||||||
|
return this.callWorker('cloudInit', { intervalSeconds })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据收集:上报数据
|
||||||
|
*/
|
||||||
|
async cloudReport(statsJson: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
return this.callWorker('cloudReport', { statsJson })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据收集:停止
|
||||||
|
*/
|
||||||
|
cloudStop(): Promise<{ success: boolean; error?: string }> {
|
||||||
|
return this.callWorker('cloudStop', {})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const wcdbService = new WcdbService()
|
export const wcdbService = new WcdbService()
|
||||||
|
|||||||
32
electron/services/windowsHelloService.ts
Normal file
32
electron/services/windowsHelloService.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { wcdbService } from './wcdbService'
|
||||||
|
import { BrowserWindow } from 'electron'
|
||||||
|
|
||||||
|
export class WindowsHelloService {
|
||||||
|
private verificationPromise: Promise<{ success: boolean; error?: string }> | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 Windows Hello
|
||||||
|
* @param message 提示信息
|
||||||
|
*/
|
||||||
|
async verify(message: string = '请验证您的身份以解锁 WeFlow', targetWindow?: BrowserWindow): Promise<{ success: boolean; error?: string }> {
|
||||||
|
// Prevent concurrent verification requests
|
||||||
|
if (this.verificationPromise) {
|
||||||
|
return this.verificationPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取窗口句柄: 优先使用传入的窗口,否则尝试获取焦点窗口,最后兜底主窗口
|
||||||
|
const window = targetWindow || BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows()[0]
|
||||||
|
const hwndBuffer = window?.getNativeWindowHandle()
|
||||||
|
// Convert buffer to int string for transport
|
||||||
|
const hwndStr = hwndBuffer ? BigInt('0x' + hwndBuffer.toString('hex')).toString() : undefined
|
||||||
|
|
||||||
|
this.verificationPromise = wcdbService.verifyUser(message, hwndStr)
|
||||||
|
.finally(() => {
|
||||||
|
this.verificationPromise = null
|
||||||
|
})
|
||||||
|
|
||||||
|
return this.verificationPromise
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const windowsHelloService = new WindowsHelloService()
|
||||||
@@ -80,17 +80,17 @@ function isLanguageAllowed(result: any, allowedLanguages: string[]): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const langTag = result.lang
|
const langTag = result.lang
|
||||||
console.log('[TranscribeWorker] 检测到语言标记:', langTag)
|
|
||||||
|
|
||||||
// 检查是否在允许的语言列表中
|
// 检查是否在允许的语言列表中
|
||||||
for (const lang of allowedLanguages) {
|
for (const lang of allowedLanguages) {
|
||||||
if (LANGUAGE_TAGS[lang] === langTag) {
|
if (LANGUAGE_TAGS[lang] === langTag) {
|
||||||
console.log('[TranscribeWorker] 语言匹配,允许:', lang)
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[TranscribeWorker] 语言不在白名单中,过滤掉')
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,7 +117,7 @@ async function run() {
|
|||||||
allowedLanguages = ['zh']
|
allowedLanguages = ['zh']
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[TranscribeWorker] 使用的语言白名单:', allowedLanguages)
|
|
||||||
|
|
||||||
// 1. 初始化识别器 (SenseVoiceSmall)
|
// 1. 初始化识别器 (SenseVoiceSmall)
|
||||||
const recognizerConfig = {
|
const recognizerConfig = {
|
||||||
@@ -145,15 +145,15 @@ async function run() {
|
|||||||
recognizer.decode(stream)
|
recognizer.decode(stream)
|
||||||
const result = recognizer.getResult(stream)
|
const result = recognizer.getResult(stream)
|
||||||
|
|
||||||
console.log('[TranscribeWorker] 识别完成 - 结果对象:', JSON.stringify(result, null, 2))
|
|
||||||
|
|
||||||
// 3. 检查语言是否在白名单中
|
// 3. 检查语言是否在白名单中
|
||||||
if (isLanguageAllowed(result, allowedLanguages)) {
|
if (isLanguageAllowed(result, allowedLanguages)) {
|
||||||
const processedText = richTranscribePostProcess(result.text)
|
const processedText = richTranscribePostProcess(result.text)
|
||||||
console.log('[TranscribeWorker] 语言匹配,返回文本:', processedText)
|
|
||||||
parentPort.postMessage({ type: 'final', text: processedText })
|
parentPort.postMessage({ type: 'final', text: processedText })
|
||||||
} else {
|
} else {
|
||||||
console.log('[TranscribeWorker] 语言不匹配,返回空文本')
|
|
||||||
parentPort.postMessage({ type: 'final', text: '' })
|
parentPort.postMessage({ type: 'final', text: '' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
114
electron/utils/LRUCache.ts
Normal file
114
electron/utils/LRUCache.ts
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,16 @@ if (parentPort) {
|
|||||||
core.setLogEnabled(payload.enabled)
|
core.setLogEnabled(payload.enabled)
|
||||||
result = { success: true }
|
result = { success: true }
|
||||||
break
|
break
|
||||||
|
case 'setMonitor':
|
||||||
|
core.setMonitor((type, json) => {
|
||||||
|
parentPort!.postMessage({
|
||||||
|
id: -1,
|
||||||
|
type: 'monitor',
|
||||||
|
payload: { type, json }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
result = { success: true }
|
||||||
|
break
|
||||||
case 'testConnection':
|
case 'testConnection':
|
||||||
result = await core.testConnection(payload.dbPath, payload.hexKey, payload.wxid)
|
result = await core.testConnection(payload.dbPath, payload.hexKey, payload.wxid)
|
||||||
break
|
break
|
||||||
@@ -38,9 +48,15 @@ if (parentPort) {
|
|||||||
case 'getMessages':
|
case 'getMessages':
|
||||||
result = await core.getMessages(payload.sessionId, payload.limit, payload.offset)
|
result = await core.getMessages(payload.sessionId, payload.limit, payload.offset)
|
||||||
break
|
break
|
||||||
|
case 'getNewMessages':
|
||||||
|
result = await core.getNewMessages(payload.sessionId, payload.minTime, payload.limit)
|
||||||
|
break
|
||||||
case 'getMessageCount':
|
case 'getMessageCount':
|
||||||
result = await core.getMessageCount(payload.sessionId)
|
result = await core.getMessageCount(payload.sessionId)
|
||||||
break
|
break
|
||||||
|
case 'getMessageCounts':
|
||||||
|
result = await core.getMessageCounts(payload.sessionIds)
|
||||||
|
break
|
||||||
case 'getDisplayNames':
|
case 'getDisplayNames':
|
||||||
result = await core.getDisplayNames(payload.usernames)
|
result = await core.getDisplayNames(payload.usernames)
|
||||||
break
|
break
|
||||||
@@ -56,18 +72,27 @@ if (parentPort) {
|
|||||||
case 'getGroupMembers':
|
case 'getGroupMembers':
|
||||||
result = await core.getGroupMembers(payload.chatroomId)
|
result = await core.getGroupMembers(payload.chatroomId)
|
||||||
break
|
break
|
||||||
|
case 'getGroupNicknames':
|
||||||
|
result = await core.getGroupNicknames(payload.chatroomId)
|
||||||
|
break
|
||||||
case 'getMessageTables':
|
case 'getMessageTables':
|
||||||
result = await core.getMessageTables(payload.sessionId)
|
result = await core.getMessageTables(payload.sessionId)
|
||||||
break
|
break
|
||||||
case 'getMessageTableStats':
|
case 'getMessageTableStats':
|
||||||
result = await core.getMessageTableStats(payload.sessionId)
|
result = await core.getMessageTableStats(payload.sessionId)
|
||||||
break
|
break
|
||||||
|
case 'getMessageDates':
|
||||||
|
result = await core.getMessageDates(payload.sessionId)
|
||||||
|
break
|
||||||
case 'getMessageMeta':
|
case 'getMessageMeta':
|
||||||
result = await core.getMessageMeta(payload.dbPath, payload.tableName, payload.limit, payload.offset)
|
result = await core.getMessageMeta(payload.dbPath, payload.tableName, payload.limit, payload.offset)
|
||||||
break
|
break
|
||||||
case 'getContact':
|
case 'getContact':
|
||||||
result = await core.getContact(payload.username)
|
result = await core.getContact(payload.username)
|
||||||
break
|
break
|
||||||
|
case 'getContactStatus':
|
||||||
|
result = await core.getContactStatus(payload.usernames)
|
||||||
|
break
|
||||||
case 'getAggregateStats':
|
case 'getAggregateStats':
|
||||||
result = await core.getAggregateStats(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp)
|
result = await core.getAggregateStats(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp)
|
||||||
break
|
break
|
||||||
@@ -80,6 +105,9 @@ if (parentPort) {
|
|||||||
case 'getAnnualReportExtras':
|
case 'getAnnualReportExtras':
|
||||||
result = await core.getAnnualReportExtras(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp, payload.peakDayBegin, payload.peakDayEnd)
|
result = await core.getAnnualReportExtras(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp, payload.peakDayBegin, payload.peakDayEnd)
|
||||||
break
|
break
|
||||||
|
case 'getDualReportStats':
|
||||||
|
result = await core.getDualReportStats(payload.sessionId, payload.beginTimestamp, payload.endTimestamp)
|
||||||
|
break
|
||||||
case 'getGroupStats':
|
case 'getGroupStats':
|
||||||
result = await core.getGroupStats(payload.chatroomId, payload.beginTimestamp, payload.endTimestamp)
|
result = await core.getGroupStats(payload.chatroomId, payload.beginTimestamp, payload.endTimestamp)
|
||||||
break
|
break
|
||||||
@@ -96,7 +124,7 @@ if (parentPort) {
|
|||||||
result = await core.closeMessageCursor(payload.cursor)
|
result = await core.closeMessageCursor(payload.cursor)
|
||||||
break
|
break
|
||||||
case 'execQuery':
|
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
|
break
|
||||||
case 'getEmoticonCdnUrl':
|
case 'getEmoticonCdnUrl':
|
||||||
result = await core.getEmoticonCdnUrl(payload.dbPath, payload.md5)
|
result = await core.getEmoticonCdnUrl(payload.dbPath, payload.md5)
|
||||||
@@ -119,6 +147,42 @@ if (parentPort) {
|
|||||||
case 'getSnsTimeline':
|
case 'getSnsTimeline':
|
||||||
result = await core.getSnsTimeline(payload.limit, payload.offset, payload.usernames, payload.keyword, payload.startTime, payload.endTime)
|
result = await core.getSnsTimeline(payload.limit, payload.offset, payload.usernames, payload.keyword, payload.startTime, payload.endTime)
|
||||||
break
|
break
|
||||||
|
case 'getSnsAnnualStats':
|
||||||
|
result = await core.getSnsAnnualStats(payload.beginTimestamp, payload.endTimestamp)
|
||||||
|
break
|
||||||
|
case 'installSnsBlockDeleteTrigger':
|
||||||
|
result = await core.installSnsBlockDeleteTrigger()
|
||||||
|
break
|
||||||
|
case 'uninstallSnsBlockDeleteTrigger':
|
||||||
|
result = await core.uninstallSnsBlockDeleteTrigger()
|
||||||
|
break
|
||||||
|
case 'checkSnsBlockDeleteTrigger':
|
||||||
|
result = await core.checkSnsBlockDeleteTrigger()
|
||||||
|
break
|
||||||
|
case 'deleteSnsPost':
|
||||||
|
result = await core.deleteSnsPost(payload.postId)
|
||||||
|
break
|
||||||
|
case 'getLogs':
|
||||||
|
result = await core.getLogs()
|
||||||
|
break
|
||||||
|
case 'verifyUser':
|
||||||
|
result = await core.verifyUser(payload.message, payload.hwnd)
|
||||||
|
break
|
||||||
|
case 'updateMessage':
|
||||||
|
result = await core.updateMessage(payload.sessionId, payload.localId, payload.createTime, payload.newContent)
|
||||||
|
break
|
||||||
|
case 'deleteMessage':
|
||||||
|
result = await core.deleteMessage(payload.sessionId, payload.localId, payload.createTime, payload.dbPathHint)
|
||||||
|
break
|
||||||
|
case 'cloudInit':
|
||||||
|
result = await core.cloudInit(payload.intervalSeconds)
|
||||||
|
break
|
||||||
|
case 'cloudReport':
|
||||||
|
result = await core.cloudReport(payload.statsJson)
|
||||||
|
break
|
||||||
|
case 'cloudStop':
|
||||||
|
result = core.cloudStop()
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
result = { success: false, error: `Unknown method: ${type}` }
|
result = { success: false, error: `Unknown method: ${type}` }
|
||||||
}
|
}
|
||||||
|
|||||||
198
electron/windows/notificationWindow.ts
Normal file
198
electron/windows/notificationWindow.ts
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import { BrowserWindow, ipcMain, screen } from 'electron'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { ConfigService } from '../services/config'
|
||||||
|
|
||||||
|
let notificationWindow: BrowserWindow | null = null
|
||||||
|
let closeTimer: NodeJS.Timeout | null = null
|
||||||
|
|
||||||
|
export function createNotificationWindow() {
|
||||||
|
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
||||||
|
return notificationWindow
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||||
|
const iconPath = isDev
|
||||||
|
? join(__dirname, '../../public/icon.ico')
|
||||||
|
: join(process.resourcesPath, 'icon.ico')
|
||||||
|
|
||||||
|
console.log('[NotificationWindow] Creating window...')
|
||||||
|
const width = 344
|
||||||
|
const height = 114
|
||||||
|
|
||||||
|
// Update default creation size
|
||||||
|
notificationWindow = new BrowserWindow({
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
type: 'toolbar', // 有助于在某些操作系统上保持置顶
|
||||||
|
frame: false,
|
||||||
|
transparent: true,
|
||||||
|
resizable: false,
|
||||||
|
show: false,
|
||||||
|
alwaysOnTop: true,
|
||||||
|
skipTaskbar: true,
|
||||||
|
focusable: false, // 不抢占焦点
|
||||||
|
icon: iconPath,
|
||||||
|
webPreferences: {
|
||||||
|
preload: join(__dirname, 'preload.js'), // FIX: Use correct relative path (same dir in dist)
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
// devTools: true // Enable DevTools
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// notificationWindow.webContents.openDevTools({ mode: 'detach' }) // DEBUG: Force Open DevTools
|
||||||
|
notificationWindow.setIgnoreMouseEvents(true, { forward: true }) // 初始点击穿透
|
||||||
|
|
||||||
|
// 处理鼠标事件 (如果需要从渲染进程转发,但目前特定区域处理?)
|
||||||
|
// 实际上,我们希望窗口可点击。
|
||||||
|
// 我们将在显示时将忽略鼠标事件设为 false。
|
||||||
|
|
||||||
|
const loadUrl = isDev
|
||||||
|
? `${process.env.VITE_DEV_SERVER_URL}#/notification-window`
|
||||||
|
: `file://${join(__dirname, '../dist/index.html')}#/notification-window`
|
||||||
|
|
||||||
|
console.log('[NotificationWindow] Loading URL:', loadUrl)
|
||||||
|
notificationWindow.loadURL(loadUrl)
|
||||||
|
|
||||||
|
notificationWindow.on('closed', () => {
|
||||||
|
notificationWindow = null
|
||||||
|
})
|
||||||
|
|
||||||
|
return notificationWindow
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function showNotification(data: any) {
|
||||||
|
// 先检查配置
|
||||||
|
const config = ConfigService.getInstance()
|
||||||
|
const enabled = await config.get('notificationEnabled')
|
||||||
|
if (enabled === false) return // 默认为 true
|
||||||
|
|
||||||
|
// 检查会话过滤
|
||||||
|
const filterMode = config.get('notificationFilterMode') || 'all'
|
||||||
|
const filterList = config.get('notificationFilterList') || []
|
||||||
|
const sessionId = data.sessionId
|
||||||
|
|
||||||
|
if (sessionId && filterMode !== 'all' && filterList.length > 0) {
|
||||||
|
const isInList = filterList.includes(sessionId)
|
||||||
|
if (filterMode === 'whitelist' && !isInList) {
|
||||||
|
// 白名单模式:不在列表中则不显示
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (filterMode === 'blacklist' && isInList) {
|
||||||
|
// 黑名单模式:在列表中则不显示
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let win = notificationWindow
|
||||||
|
if (!win || win.isDestroyed()) {
|
||||||
|
win = createNotificationWindow()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!win) return
|
||||||
|
|
||||||
|
// 确保加载完成
|
||||||
|
if (win.webContents.isLoading()) {
|
||||||
|
win.once('ready-to-show', () => {
|
||||||
|
showAndSend(win!, data)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
showAndSend(win, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastNotificationData: any = null
|
||||||
|
|
||||||
|
async function showAndSend(win: BrowserWindow, data: any) {
|
||||||
|
lastNotificationData = data
|
||||||
|
const config = ConfigService.getInstance()
|
||||||
|
const position = (await config.get('notificationPosition')) || 'top-right'
|
||||||
|
|
||||||
|
// 更新位置
|
||||||
|
const { width: screenWidth, height: screenHeight } = screen.getPrimaryDisplay().workAreaSize
|
||||||
|
const winWidth = 344
|
||||||
|
const winHeight = 114
|
||||||
|
const padding = 20
|
||||||
|
|
||||||
|
let x = 0
|
||||||
|
let y = 0
|
||||||
|
|
||||||
|
switch (position) {
|
||||||
|
case 'top-right':
|
||||||
|
x = screenWidth - winWidth - padding
|
||||||
|
y = padding
|
||||||
|
break
|
||||||
|
case 'bottom-right':
|
||||||
|
x = screenWidth - winWidth - padding
|
||||||
|
y = screenHeight - winHeight - padding
|
||||||
|
break
|
||||||
|
case 'top-left':
|
||||||
|
x = padding
|
||||||
|
y = padding
|
||||||
|
break
|
||||||
|
case 'bottom-left':
|
||||||
|
x = padding
|
||||||
|
y = screenHeight - winHeight - padding
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
win.setPosition(Math.floor(x), Math.floor(y))
|
||||||
|
win.setSize(winWidth, winHeight) // 确保尺寸
|
||||||
|
|
||||||
|
// 设为可交互
|
||||||
|
win.setIgnoreMouseEvents(false)
|
||||||
|
win.showInactive() // 显示但不聚焦
|
||||||
|
win.setAlwaysOnTop(true, 'screen-saver') // 最高层级
|
||||||
|
|
||||||
|
win.webContents.send('notification:show', data)
|
||||||
|
|
||||||
|
// 自动关闭计时器通常由渲染进程管理
|
||||||
|
// 渲染进程发送 'notification:close' 来隐藏窗口
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerNotificationHandlers() {
|
||||||
|
ipcMain.handle('notification:show', (_, data) => {
|
||||||
|
showNotification(data)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('notification:close', () => {
|
||||||
|
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
||||||
|
notificationWindow.hide()
|
||||||
|
notificationWindow.setIgnoreMouseEvents(true, { forward: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle renderer ready event (fix race condition)
|
||||||
|
ipcMain.on('notification:ready', (event) => {
|
||||||
|
console.log('[NotificationWindow] Renderer ready, checking cached data')
|
||||||
|
if (lastNotificationData && notificationWindow && !notificationWindow.isDestroyed()) {
|
||||||
|
console.log('[NotificationWindow] Re-sending cached data')
|
||||||
|
notificationWindow.webContents.send('notification:show', lastNotificationData)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle resize request from renderer
|
||||||
|
ipcMain.on('notification:resize', (event, { width, height }) => {
|
||||||
|
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
||||||
|
// Enforce max-height if needed, or trust renderer
|
||||||
|
// Ensure it doesn't go off screen bottom?
|
||||||
|
// Logic in showAndSend handles position, but we need to keep anchor point (top-right usually).
|
||||||
|
// If we resize, we should re-calculate position to keep it anchored?
|
||||||
|
// Actually, setSize changes size. If it's top-right, x/y stays same -> window grows down. That's fine for top-right.
|
||||||
|
// If bottom-right, growing down pushes it off screen.
|
||||||
|
|
||||||
|
// Simple version: just setSize. For V1 we assume Top-Right.
|
||||||
|
// But wait, the config supports bottom-right.
|
||||||
|
// We can re-call setPosition or just let it be.
|
||||||
|
// If bottom-right, y needs to prevent overflow.
|
||||||
|
|
||||||
|
// Ideally we get current config position
|
||||||
|
const bounds = notificationWindow.getBounds()
|
||||||
|
// Check if we need to adjust Y?
|
||||||
|
// For now, let's just set the size as requested.
|
||||||
|
notificationWindow.setSize(Math.round(width), Math.round(height))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 'notification-clicked' 在 main.ts 中处理 (导航)
|
||||||
|
}
|
||||||
@@ -47,11 +47,11 @@ ManifestDPIAware true
|
|||||||
DetailPrint "Visual C++ Redistributable 安装成功"
|
DetailPrint "Visual C++ Redistributable 安装成功"
|
||||||
MessageBox MB_OK|MB_ICONINFORMATION "Visual C++ 运行库安装成功!"
|
MessageBox MB_OK|MB_ICONINFORMATION "Visual C++ 运行库安装成功!"
|
||||||
${Else}
|
${Else}
|
||||||
MessageBox MB_OK|MB_ICONEXCLAMATION "Visual C++ 运行库安装失败,您可能需要手动安装。"
|
MessageBox MB_OK|MB_ICONEXCLAMATION "Visual C++ 运行库安装失败,你可能需要手动安装。"
|
||||||
${EndIf}
|
${EndIf}
|
||||||
Delete "$TEMP\vc_redist.x64.exe"
|
Delete "$TEMP\vc_redist.x64.exe"
|
||||||
${Else}
|
${Else}
|
||||||
MessageBox MB_OK|MB_ICONEXCLAMATION "下载失败:$0$\n$\n您可以稍后手动下载安装 Visual C++ Redistributable。"
|
MessageBox MB_OK|MB_ICONEXCLAMATION "下载失败:$0$\n$\n你可以稍后手动下载安装 Visual C++ Redistributable。"
|
||||||
${EndIf}
|
${EndIf}
|
||||||
Goto doneVC
|
Goto doneVC
|
||||||
|
|
||||||
|
|||||||
1493
package-lock.json
generated
1493
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
25
package.json
25
package.json
@@ -1,14 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "weflow",
|
"name": "weflow",
|
||||||
"version": "1.4.0",
|
"version": "2.1.0",
|
||||||
"description": "WeFlow",
|
"description": "WeFlow",
|
||||||
"main": "dist-electron/main.js",
|
"main": "dist-electron/main.js",
|
||||||
"author": "cc",
|
"author": "cc",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/hicccc77/WeFlow"
|
||||||
|
},
|
||||||
"//": "二改不应改变此处的作者与应用信息",
|
"//": "二改不应改变此处的作者与应用信息",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"postinstall": "echo 'No native modules to rebuild'",
|
"postinstall": "electron-builder install-app-deps",
|
||||||
"rebuild": "echo 'No native modules to rebuild'",
|
"rebuild": "electron-rebuild",
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
"build": "tsc && vite build && electron-builder",
|
"build": "tsc && vite build && electron-builder",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"electron:dev": "vite --mode electron",
|
"electron:dev": "vite --mode electron",
|
||||||
@@ -30,7 +35,10 @@
|
|||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.1.1",
|
"react-router-dom": "^7.1.1",
|
||||||
|
"react-virtuoso": "^4.18.1",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"sherpa-onnx-node": "^1.10.38",
|
"sherpa-onnx-node": "^1.10.38",
|
||||||
"silk-wasm": "^3.7.1",
|
"silk-wasm": "^3.7.1",
|
||||||
"wechat-emojis": "^1.0.2",
|
"wechat-emojis": "^1.0.2",
|
||||||
@@ -55,6 +63,8 @@
|
|||||||
"appId": "com.WeFlow.app",
|
"appId": "com.WeFlow.app",
|
||||||
"publish": {
|
"publish": {
|
||||||
"provider": "github",
|
"provider": "github",
|
||||||
|
"owner": "hicccc77",
|
||||||
|
"repo": "WeFlow",
|
||||||
"releaseType": "release"
|
"releaseType": "release"
|
||||||
},
|
},
|
||||||
"productName": "WeFlow",
|
"productName": "WeFlow",
|
||||||
@@ -97,6 +107,10 @@
|
|||||||
{
|
{
|
||||||
"from": "public/icon.ico",
|
"from": "public/icon.ico",
|
||||||
"to": "icon.ico"
|
"to": "icon.ico"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "electron/assets/wasm/",
|
||||||
|
"to": "assets/wasm/"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"files": [
|
"files": [
|
||||||
@@ -105,7 +119,8 @@
|
|||||||
],
|
],
|
||||||
"asarUnpack": [
|
"asarUnpack": [
|
||||||
"node_modules/silk-wasm/**/*",
|
"node_modules/silk-wasm/**/*",
|
||||||
"node_modules/sherpa-onnx-node/**/*"
|
"node_modules/sherpa-onnx-node/**/*",
|
||||||
|
"node_modules/ffmpeg-static/**/*"
|
||||||
],
|
],
|
||||||
"extraFiles": [
|
"extraFiles": [
|
||||||
{
|
{
|
||||||
@@ -126,4 +141,4 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
249
public/splash.html
Normal file
249
public/splash.html
Normal 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.
66
src/App.scss
66
src/App.scss
@@ -4,6 +4,59 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
animation: appFadeIn 0.35s ease-out;
|
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 {
|
.main-layout {
|
||||||
@@ -16,6 +69,19 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-keepalive-page {
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
&.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-route-anchor {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes appFadeIn {
|
@keyframes appFadeIn {
|
||||||
|
|||||||
320
src/App.tsx
320
src/App.tsx
@@ -10,46 +10,83 @@ import AnalyticsPage from './pages/AnalyticsPage'
|
|||||||
import AnalyticsWelcomePage from './pages/AnalyticsWelcomePage'
|
import AnalyticsWelcomePage from './pages/AnalyticsWelcomePage'
|
||||||
import AnnualReportPage from './pages/AnnualReportPage'
|
import AnnualReportPage from './pages/AnnualReportPage'
|
||||||
import AnnualReportWindow from './pages/AnnualReportWindow'
|
import AnnualReportWindow from './pages/AnnualReportWindow'
|
||||||
|
import DualReportPage from './pages/DualReportPage'
|
||||||
|
import DualReportWindow from './pages/DualReportWindow'
|
||||||
import AgreementPage from './pages/AgreementPage'
|
import AgreementPage from './pages/AgreementPage'
|
||||||
import GroupAnalyticsPage from './pages/GroupAnalyticsPage'
|
import GroupAnalyticsPage from './pages/GroupAnalyticsPage'
|
||||||
import DataManagementPage from './pages/DataManagementPage'
|
|
||||||
import SettingsPage from './pages/SettingsPage'
|
import SettingsPage from './pages/SettingsPage'
|
||||||
import ExportPage from './pages/ExportPage'
|
import ExportPage from './pages/ExportPage'
|
||||||
import VideoWindow from './pages/VideoWindow'
|
import VideoWindow from './pages/VideoWindow'
|
||||||
|
import ImageWindow from './pages/ImageWindow'
|
||||||
import SnsPage from './pages/SnsPage'
|
import SnsPage from './pages/SnsPage'
|
||||||
|
import ContactsPage from './pages/ContactsPage'
|
||||||
|
import ChatHistoryPage from './pages/ChatHistoryPage'
|
||||||
|
import NotificationWindow from './pages/NotificationWindow'
|
||||||
|
|
||||||
import { useAppStore } from './stores/appStore'
|
import { 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 configService from './services/config'
|
||||||
|
import * as cloudControl from './services/cloudControl'
|
||||||
import { Download, X, Shield } from 'lucide-react'
|
import { Download, X, Shield } from 'lucide-react'
|
||||||
import './App.scss'
|
import './App.scss'
|
||||||
|
|
||||||
|
import UpdateDialog from './components/UpdateDialog'
|
||||||
|
import UpdateProgressCapsule from './components/UpdateProgressCapsule'
|
||||||
|
import LockScreen from './components/LockScreen'
|
||||||
|
import { GlobalSessionMonitor } from './components/GlobalSessionMonitor'
|
||||||
|
import { BatchTranscribeGlobal } from './components/BatchTranscribeGlobal'
|
||||||
|
import { BatchImageDecryptGlobal } from './components/BatchImageDecryptGlobal'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const { setDbConnected } = useAppStore()
|
|
||||||
|
const {
|
||||||
|
setDbConnected,
|
||||||
|
updateInfo,
|
||||||
|
setUpdateInfo,
|
||||||
|
isDownloading,
|
||||||
|
setIsDownloading,
|
||||||
|
downloadProgress,
|
||||||
|
setDownloadProgress,
|
||||||
|
showUpdateDialog,
|
||||||
|
setShowUpdateDialog,
|
||||||
|
setUpdateError,
|
||||||
|
isLocked,
|
||||||
|
setLocked
|
||||||
|
} = useAppStore()
|
||||||
|
|
||||||
const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore()
|
const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore()
|
||||||
const isAgreementWindow = location.pathname === '/agreement-window'
|
const isAgreementWindow = location.pathname === '/agreement-window'
|
||||||
const isOnboardingWindow = location.pathname === '/onboarding-window'
|
const isOnboardingWindow = location.pathname === '/onboarding-window'
|
||||||
const isVideoPlayerWindow = location.pathname === '/video-player-window'
|
const isVideoPlayerWindow = location.pathname === '/video-player-window'
|
||||||
|
const isChatHistoryWindow = location.pathname.startsWith('/chat-history/')
|
||||||
|
const isStandaloneChatWindow = location.pathname === '/chat-window'
|
||||||
|
const isNotificationWindow = location.pathname === '/notification-window'
|
||||||
|
const isExportRoute = location.pathname === '/export'
|
||||||
const [themeHydrated, setThemeHydrated] = useState(false)
|
const [themeHydrated, setThemeHydrated] = useState(false)
|
||||||
|
|
||||||
|
// 锁定状态
|
||||||
|
// const [isLocked, setIsLocked] = useState(false) // Moved to store
|
||||||
|
const [lockAvatar, setLockAvatar] = useState<string | undefined>(
|
||||||
|
localStorage.getItem('app_lock_avatar') || undefined
|
||||||
|
)
|
||||||
|
const [lockUseHello, setLockUseHello] = useState(false)
|
||||||
|
|
||||||
// 协议同意状态
|
// 协议同意状态
|
||||||
const [showAgreement, setShowAgreement] = useState(false)
|
const [showAgreement, setShowAgreement] = useState(false)
|
||||||
const [agreementChecked, setAgreementChecked] = useState(false)
|
const [agreementChecked, setAgreementChecked] = useState(false)
|
||||||
const [agreementLoading, setAgreementLoading] = useState(true)
|
const [agreementLoading, setAgreementLoading] = useState(true)
|
||||||
|
|
||||||
// 更新提示状态
|
// 数据收集同意状态
|
||||||
const [updateInfo, setUpdateInfo] = useState<{ version: string; releaseNotes: string } | null>(null)
|
const [showAnalyticsConsent, setShowAnalyticsConsent] = useState(false)
|
||||||
const [isDownloading, setIsDownloading] = useState(false)
|
|
||||||
const [downloadProgress, setDownloadProgress] = useState(0)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement
|
const root = document.documentElement
|
||||||
const body = document.body
|
const body = document.body
|
||||||
const appRoot = document.getElementById('app')
|
const appRoot = document.getElementById('app')
|
||||||
|
|
||||||
if (isOnboardingWindow) {
|
if (isOnboardingWindow || isNotificationWindow) {
|
||||||
root.style.background = 'transparent'
|
root.style.background = 'transparent'
|
||||||
body.style.background = 'transparent'
|
body.style.background = 'transparent'
|
||||||
body.style.overflow = 'hidden'
|
body.style.overflow = 'hidden'
|
||||||
@@ -70,15 +107,28 @@ function App() {
|
|||||||
|
|
||||||
// 应用主题
|
// 应用主题
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.setAttribute('data-theme', currentTheme)
|
const mq = window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
document.documentElement.setAttribute('data-mode', themeMode)
|
const applyMode = (mode: ThemeMode, systemDark?: boolean) => {
|
||||||
|
const effectiveMode = mode === 'system' ? (systemDark ?? mq.matches ? 'dark' : 'light') : mode
|
||||||
// 更新窗口控件颜色以适配主题
|
document.documentElement.setAttribute('data-theme', currentTheme)
|
||||||
const symbolColor = themeMode === 'dark' ? '#ffffff' : '#1a1a1a'
|
document.documentElement.setAttribute('data-mode', effectiveMode)
|
||||||
if (!isOnboardingWindow) {
|
const symbolColor = effectiveMode === 'dark' ? '#ffffff' : '#1a1a1a'
|
||||||
window.electronAPI.window.setTitleBarOverlay({ symbolColor })
|
if (!isOnboardingWindow && !isNotificationWindow) {
|
||||||
|
window.electronAPI.window.setTitleBarOverlay({ symbolColor })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [currentTheme, themeMode, isOnboardingWindow])
|
|
||||||
|
applyMode(themeMode)
|
||||||
|
|
||||||
|
// 监听系统主题变化
|
||||||
|
const handler = (e: MediaQueryListEvent) => {
|
||||||
|
if (useThemeStore.getState().themeMode === 'system') {
|
||||||
|
applyMode('system', e.matches)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mq.addEventListener('change', handler)
|
||||||
|
return () => mq.removeEventListener('change', handler)
|
||||||
|
}, [currentTheme, themeMode, isOnboardingWindow, isNotificationWindow])
|
||||||
|
|
||||||
// 读取已保存的主题设置
|
// 读取已保存的主题设置
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -91,7 +141,7 @@ function App() {
|
|||||||
if (savedThemeId && themes.some((theme) => theme.id === savedThemeId)) {
|
if (savedThemeId && themes.some((theme) => theme.id === savedThemeId)) {
|
||||||
setTheme(savedThemeId as ThemeId)
|
setTheme(savedThemeId as ThemeId)
|
||||||
}
|
}
|
||||||
if (savedThemeMode === 'light' || savedThemeMode === 'dark') {
|
if (savedThemeMode === 'light' || savedThemeMode === 'dark' || savedThemeMode === 'system') {
|
||||||
setThemeMode(savedThemeMode)
|
setThemeMode(savedThemeMode)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -126,6 +176,12 @@ function App() {
|
|||||||
const agreed = await configService.getAgreementAccepted()
|
const agreed = await configService.getAgreementAccepted()
|
||||||
if (!agreed) {
|
if (!agreed) {
|
||||||
setShowAgreement(true)
|
setShowAgreement(true)
|
||||||
|
} else {
|
||||||
|
// 协议已同意,检查数据收集同意状态
|
||||||
|
const consent = await configService.getAnalyticsConsent()
|
||||||
|
if (consent === null) {
|
||||||
|
setShowAnalyticsConsent(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('检查协议状态失败:', e)
|
console.error('检查协议状态失败:', e)
|
||||||
@@ -136,38 +192,97 @@ function App() {
|
|||||||
checkAgreement()
|
checkAgreement()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// 初始化数据收集
|
||||||
|
useEffect(() => {
|
||||||
|
cloudControl.initCloudControl()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 记录页面访问
|
||||||
|
useEffect(() => {
|
||||||
|
const path = location.pathname
|
||||||
|
if (path && path !== '/') {
|
||||||
|
cloudControl.recordPage(path)
|
||||||
|
}
|
||||||
|
}, [location.pathname])
|
||||||
|
|
||||||
const handleAgree = async () => {
|
const handleAgree = async () => {
|
||||||
if (!agreementChecked) return
|
if (!agreementChecked) return
|
||||||
await configService.setAgreementAccepted(true)
|
await configService.setAgreementAccepted(true)
|
||||||
setShowAgreement(false)
|
setShowAgreement(false)
|
||||||
|
// 协议同意后,检查数据收集同意
|
||||||
|
const consent = await configService.getAnalyticsConsent()
|
||||||
|
if (consent === null) {
|
||||||
|
setShowAnalyticsConsent(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDisagree = () => {
|
const handleDisagree = () => {
|
||||||
window.electronAPI.window.close()
|
window.electronAPI.window.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleAnalyticsAllow = async () => {
|
||||||
|
await configService.setAnalyticsConsent(true)
|
||||||
|
setShowAnalyticsConsent(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAnalyticsDeny = async () => {
|
||||||
|
await configService.setAnalyticsConsent(false)
|
||||||
|
window.electronAPI.window.close()
|
||||||
|
}
|
||||||
|
|
||||||
// 监听启动时的更新通知
|
// 监听启动时的更新通知
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const removeUpdateListener = window.electronAPI.app.onUpdateAvailable?.((info) => {
|
if (isNotificationWindow) return // Skip updates in notification window
|
||||||
setUpdateInfo(info)
|
|
||||||
|
const removeUpdateListener = window.electronAPI?.app?.onUpdateAvailable?.((info: any) => {
|
||||||
|
// 发现新版本时保存更新信息,锁定状态下不弹窗,解锁后再显示
|
||||||
|
if (info) {
|
||||||
|
setUpdateInfo({ ...info, hasUpdate: true })
|
||||||
|
if (!useAppStore.getState().isLocked) {
|
||||||
|
setShowUpdateDialog(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
const removeProgressListener = window.electronAPI.app.onDownloadProgress?.((progress) => {
|
const removeProgressListener = window.electronAPI?.app?.onDownloadProgress?.((progress: any) => {
|
||||||
setDownloadProgress(progress)
|
setDownloadProgress(progress)
|
||||||
})
|
})
|
||||||
return () => {
|
return () => {
|
||||||
removeUpdateListener?.()
|
removeUpdateListener?.()
|
||||||
removeProgressListener?.()
|
removeProgressListener?.()
|
||||||
}
|
}
|
||||||
}, [])
|
}, [setUpdateInfo, setDownloadProgress, setShowUpdateDialog, isNotificationWindow])
|
||||||
|
|
||||||
|
// 解锁后显示暂存的更新弹窗
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLocked && updateInfo?.hasUpdate && !showUpdateDialog && !isDownloading) {
|
||||||
|
setShowUpdateDialog(true)
|
||||||
|
}
|
||||||
|
}, [isLocked])
|
||||||
|
|
||||||
const handleUpdateNow = async () => {
|
const handleUpdateNow = async () => {
|
||||||
|
setShowUpdateDialog(false)
|
||||||
setIsDownloading(true)
|
setIsDownloading(true)
|
||||||
setDownloadProgress(0)
|
setDownloadProgress({ percent: 0 })
|
||||||
try {
|
try {
|
||||||
await window.electronAPI.app.downloadAndInstall()
|
await window.electronAPI.app.downloadAndInstall()
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
console.error('更新失败:', e)
|
console.error('更新失败:', e)
|
||||||
setIsDownloading(false)
|
setIsDownloading(false)
|
||||||
|
// Extract clean error message if possible
|
||||||
|
const errorMsg = e.message || String(e)
|
||||||
|
setUpdateError(errorMsg.includes('暂时禁用') ? '自动更新已暂时禁用' : errorMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleIgnoreUpdate = async () => {
|
||||||
|
if (!updateInfo || !updateInfo.version) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await window.electronAPI.app.ignoreUpdate(updateInfo.version)
|
||||||
|
setShowUpdateDialog(false)
|
||||||
|
setUpdateInfo(null)
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('忽略更新失败:', e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,18 +312,18 @@ function App() {
|
|||||||
if (!onboardingDone) {
|
if (!onboardingDone) {
|
||||||
await configService.setOnboardingDone(true)
|
await configService.setOnboardingDone(true)
|
||||||
}
|
}
|
||||||
console.log('检测到已保存的配置,正在自动连接...')
|
|
||||||
const result = await window.electronAPI.chat.connect()
|
const result = await window.electronAPI.chat.connect()
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
console.log('自动连接成功')
|
|
||||||
setDbConnected(true, dbPath)
|
setDbConnected(true, dbPath)
|
||||||
// 如果当前在欢迎页,跳转到首页
|
// 如果当前在欢迎页,跳转到首页
|
||||||
if (window.location.hash === '#/' || window.location.hash === '') {
|
if (window.location.hash === '#/' || window.location.hash === '') {
|
||||||
navigate('/home')
|
navigate('/home')
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log('自动连接失败:', result.error)
|
|
||||||
// 如果错误信息包含 VC++ 或 DLL 相关内容,不清除配置,只提示用户
|
// 如果错误信息包含 VC++ 或 DLL 相关内容,不清除配置,只提示用户
|
||||||
// 其他错误可能需要重新配置
|
// 其他错误可能需要重新配置
|
||||||
const errorMsg = result.error || ''
|
const errorMsg = result.error || ''
|
||||||
@@ -231,6 +346,35 @@ function App() {
|
|||||||
autoConnect()
|
autoConnect()
|
||||||
}, [isAgreementWindow, isOnboardingWindow, navigate, setDbConnected])
|
}, [isAgreementWindow, isOnboardingWindow, navigate, setDbConnected])
|
||||||
|
|
||||||
|
// 检查应用锁
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAgreementWindow || isOnboardingWindow || isVideoPlayerWindow) return
|
||||||
|
|
||||||
|
const checkLock = async () => {
|
||||||
|
// 并行获取配置,减少等待
|
||||||
|
const [enabled, useHello] = await Promise.all([
|
||||||
|
window.electronAPI.auth.verifyEnabled(),
|
||||||
|
configService.getAuthUseHello()
|
||||||
|
])
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
setLockUseHello(useHello)
|
||||||
|
setLocked(true)
|
||||||
|
// 尝试获取头像
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.chat.getMyAvatarUrl()
|
||||||
|
if (result && result.success && result.avatarUrl) {
|
||||||
|
setLockAvatar(result.avatarUrl)
|
||||||
|
localStorage.setItem('app_lock_avatar', result.avatarUrl)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('获取锁屏头像失败', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
checkLock()
|
||||||
|
}, [isAgreementWindow, isOnboardingWindow, isVideoPlayerWindow])
|
||||||
|
|
||||||
// 独立协议窗口
|
// 独立协议窗口
|
||||||
if (isAgreementWindow) {
|
if (isAgreementWindow) {
|
||||||
return <AgreementPage />
|
return <AgreementPage />
|
||||||
@@ -245,11 +389,51 @@ function App() {
|
|||||||
return <VideoWindow />
|
return <VideoWindow />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 独立图片查看窗口
|
||||||
|
const isImageViewerWindow = location.pathname === '/image-viewer-window'
|
||||||
|
if (isImageViewerWindow) {
|
||||||
|
return <ImageWindow />
|
||||||
|
}
|
||||||
|
|
||||||
|
// 独立聊天记录窗口
|
||||||
|
if (isChatHistoryWindow) {
|
||||||
|
return <ChatHistoryPage />
|
||||||
|
}
|
||||||
|
|
||||||
|
// 独立会话聊天窗口(仅显示聊天内容区域)
|
||||||
|
if (isStandaloneChatWindow) {
|
||||||
|
const sessionId = new URLSearchParams(location.search).get('sessionId') || ''
|
||||||
|
return <ChatPage standaloneSessionWindow initialSessionId={sessionId} />
|
||||||
|
}
|
||||||
|
|
||||||
|
// 独立通知窗口
|
||||||
|
if (isNotificationWindow) {
|
||||||
|
return <NotificationWindow />
|
||||||
|
}
|
||||||
|
|
||||||
// 主窗口 - 完整布局
|
// 主窗口 - 完整布局
|
||||||
return (
|
return (
|
||||||
<div className="app-container">
|
<div className="app-container">
|
||||||
|
<div className="window-drag-region" aria-hidden="true" />
|
||||||
|
{isLocked && (
|
||||||
|
<LockScreen
|
||||||
|
onUnlock={() => setLocked(false)}
|
||||||
|
avatar={lockAvatar}
|
||||||
|
useHello={lockUseHello}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<TitleBar />
|
<TitleBar />
|
||||||
|
|
||||||
|
{/* 全局悬浮进度胶囊 (处理:新版本提示、下载进度、错误提示) */}
|
||||||
|
<UpdateProgressCapsule />
|
||||||
|
|
||||||
|
{/* 全局会话监听与通知 */}
|
||||||
|
<GlobalSessionMonitor />
|
||||||
|
|
||||||
|
{/* 全局批量转写进度浮窗 */}
|
||||||
|
<BatchTranscribeGlobal />
|
||||||
|
<BatchImageDecryptGlobal />
|
||||||
|
|
||||||
{/* 用户协议弹窗 */}
|
{/* 用户协议弹窗 */}
|
||||||
{showAgreement && !agreementLoading && (
|
{showAgreement && !agreementLoading && (
|
||||||
<div className="agreement-overlay">
|
<div className="agreement-overlay">
|
||||||
@@ -271,13 +455,13 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="agreement-text">
|
<div className="agreement-text">
|
||||||
<h4>1. 数据安全</h4>
|
<h4>1. 数据安全</h4>
|
||||||
<p>本软件所有数据处理均在本地完成,不会上传任何聊天记录、个人信息到服务器。您的数据完全由您自己掌控。</p>
|
<p>本软件所有数据处理均在本地完成,不会上传任何聊天记录、个人信息到服务器。你的数据完全由你自己掌控。</p>
|
||||||
|
|
||||||
<h4>2. 使用须知</h4>
|
<h4>2. 使用须知</h4>
|
||||||
<p>本软件仅供个人学习研究使用,请勿用于任何非法用途。使用本软件解密、查看、分析的数据应为您本人所有或已获得授权。</p>
|
<p>本软件仅供个人学习研究使用,请勿用于任何非法用途。使用本软件解密、查看、分析的数据应为你本人所有或已获得授权。</p>
|
||||||
|
|
||||||
<h4>3. 免责声明</h4>
|
<h4>3. 免责声明</h4>
|
||||||
<p>因使用本软件产生的任何直接或间接损失,开发者不承担任何责任。请确保您的使用行为符合当地法律法规。</p>
|
<p>因使用本软件产生的任何直接或间接损失,开发者不承担任何责任。请确保你的使用行为符合当地法律法规。</p>
|
||||||
|
|
||||||
<h4>4. 隐私保护</h4>
|
<h4>4. 隐私保护</h4>
|
||||||
<p>本软件不收集任何用户数据。软件更新检测仅获取版本信息,不涉及任何个人隐私。</p>
|
<p>本软件不收集任何用户数据。软件更新检测仅获取版本信息,不涉及任何个人隐私。</p>
|
||||||
@@ -301,49 +485,79 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 更新提示条 */}
|
{/* 数据收集同意弹窗 */}
|
||||||
{updateInfo && (
|
{showAnalyticsConsent && !agreementLoading && (
|
||||||
<div className="update-banner">
|
<div className="agreement-overlay">
|
||||||
<span className="update-text">
|
<div className="agreement-modal">
|
||||||
发现新版本 <strong>v{updateInfo.version}</strong>
|
<div className="agreement-header">
|
||||||
</span>
|
<Shield size={32} />
|
||||||
{isDownloading ? (
|
<h2>使用数据收集说明</h2>
|
||||||
<div className="update-progress">
|
|
||||||
<div className="progress-bar">
|
|
||||||
<div className="progress-fill" style={{ width: `${downloadProgress}%` }} />
|
|
||||||
</div>
|
|
||||||
<span>{downloadProgress.toFixed(0)}%</span>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<div className="agreement-content">
|
||||||
<>
|
<div className="agreement-text">
|
||||||
<button className="update-btn" onClick={handleUpdateNow}>
|
<p>为了持续改进 WeFlow 并提供更好的用户体验,我们希望收集一些匿名的使用数据。</p>
|
||||||
<Download size={14} /> 立即更新
|
|
||||||
</button>
|
<h4>我们会收集什么?</h4>
|
||||||
<button className="dismiss-btn" onClick={dismissUpdate}>
|
<p>• 功能使用情况(如哪些功能被使用、使用频率)</p>
|
||||||
<X size={14} />
|
<p>• 应用性能数据(如加载时间、错误日志)</p>
|
||||||
</button>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 更新提示对话框 */}
|
||||||
|
<UpdateDialog
|
||||||
|
open={showUpdateDialog}
|
||||||
|
updateInfo={updateInfo}
|
||||||
|
onClose={() => setShowUpdateDialog(false)}
|
||||||
|
onUpdate={handleUpdateNow}
|
||||||
|
onIgnore={handleIgnoreUpdate}
|
||||||
|
isDownloading={isDownloading}
|
||||||
|
progress={downloadProgress}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="main-layout">
|
<div className="main-layout">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main className="content">
|
<main className="content">
|
||||||
<RouteGuard>
|
<RouteGuard>
|
||||||
|
<div className={`export-keepalive-page ${isExportRoute ? 'active' : 'hidden'}`} aria-hidden={!isExportRoute}>
|
||||||
|
<ExportPage />
|
||||||
|
</div>
|
||||||
|
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
<Route path="/home" element={<HomePage />} />
|
<Route path="/home" element={<HomePage />} />
|
||||||
<Route path="/chat" element={<ChatPage />} />
|
<Route path="/chat" element={<ChatPage />} />
|
||||||
|
|
||||||
<Route path="/analytics" element={<AnalyticsWelcomePage />} />
|
<Route path="/analytics" element={<AnalyticsWelcomePage />} />
|
||||||
<Route path="/analytics/view" element={<AnalyticsPage />} />
|
<Route path="/analytics/view" element={<AnalyticsPage />} />
|
||||||
<Route path="/group-analytics" element={<GroupAnalyticsPage />} />
|
<Route path="/group-analytics" element={<GroupAnalyticsPage />} />
|
||||||
<Route path="/annual-report" element={<AnnualReportPage />} />
|
<Route path="/annual-report" element={<AnnualReportPage />} />
|
||||||
<Route path="/annual-report/view" element={<AnnualReportWindow />} />
|
<Route path="/annual-report/view" element={<AnnualReportWindow />} />
|
||||||
<Route path="/data-management" element={<DataManagementPage />} />
|
<Route path="/dual-report" element={<DualReportPage />} />
|
||||||
|
<Route path="/dual-report/view" element={<DualReportWindow />} />
|
||||||
|
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/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="/sns" element={<SnsPage />} />
|
||||||
|
<Route path="/contacts" element={<ContactsPage />} />
|
||||||
|
<Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</RouteGuard>
|
</RouteGuard>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
133
src/components/BatchImageDecryptGlobal.tsx
Normal file
133
src/components/BatchImageDecryptGlobal.tsx
Normal 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
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
147
src/components/BatchTranscribeGlobal.tsx
Normal file
147
src/components/BatchTranscribeGlobal.tsx
Normal 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
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -70,6 +70,7 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(-8px);
|
transform: translateY(-8px);
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
@@ -138,12 +139,25 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
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 {
|
.calendar-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(7, 1fr);
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
grid-template-rows: auto repeat(6, 32px);
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +170,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.calendar-day {
|
.calendar-day {
|
||||||
aspect-ratio: 1;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -211,4 +224,68 @@
|
|||||||
padding-top: 12px;
|
padding-top: 12px;
|
||||||
border-top: 1px solid var(--border-color);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ function DateRangePicker({ startDate, endDate, onStartDateChange, onEndDateChang
|
|||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const [currentMonth, setCurrentMonth] = useState(new Date())
|
const [currentMonth, setCurrentMonth] = useState(new Date())
|
||||||
const [selectingStart, setSelectingStart] = useState(true)
|
const [selectingStart, setSelectingStart] = useState(true)
|
||||||
|
const [showYearMonthPicker, setShowYearMonthPicker] = useState(false)
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
// 点击外部关闭
|
// 点击外部关闭
|
||||||
@@ -86,7 +87,7 @@ function DateRangePicker({ startDate, endDate, onStartDateChange, onEndDateChang
|
|||||||
|
|
||||||
const handleDateClick = (day: number) => {
|
const handleDateClick = (day: number) => {
|
||||||
const dateStr = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
const dateStr = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||||
|
|
||||||
if (selectingStart) {
|
if (selectingStart) {
|
||||||
onStartDateChange(dateStr)
|
onStartDateChange(dateStr)
|
||||||
if (endDate && dateStr > endDate) {
|
if (endDate && dateStr > endDate) {
|
||||||
@@ -125,8 +126,8 @@ function DateRangePicker({ startDate, endDate, onStartDateChange, onEndDateChang
|
|||||||
const isToday = (day: number) => {
|
const isToday = (day: number) => {
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
return currentMonth.getFullYear() === today.getFullYear() &&
|
return currentMonth.getFullYear() === today.getFullYear() &&
|
||||||
currentMonth.getMonth() === today.getMonth() &&
|
currentMonth.getMonth() === today.getMonth() &&
|
||||||
day === today.getDate()
|
day === today.getDate()
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderCalendar = () => {
|
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))}>
|
<button className="nav-btn" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1))}>
|
||||||
<ChevronLeft size={16} />
|
<ChevronLeft size={16} />
|
||||||
</button>
|
</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))}>
|
<button className="nav-btn" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1))}>
|
||||||
<ChevronRight size={16} />
|
<ChevronRight size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<div className="selection-hint">
|
||||||
{selectingStart ? '请选择开始日期' : '请选择结束日期'}
|
{selectingStart ? '请选择开始日期' : '请选择结束日期'}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
280
src/components/GlobalSessionMonitor.tsx
Normal file
280
src/components/GlobalSessionMonitor.tsx
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { useChatStore } from '../stores/chatStore'
|
||||||
|
import type { ChatSession } from '../types/models'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
|
export function GlobalSessionMonitor() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const {
|
||||||
|
sessions,
|
||||||
|
setSessions,
|
||||||
|
currentSessionId,
|
||||||
|
appendMessages,
|
||||||
|
messages
|
||||||
|
} = useChatStore()
|
||||||
|
|
||||||
|
const sessionsRef = useRef(sessions)
|
||||||
|
// 保持 ref 同步
|
||||||
|
useEffect(() => {
|
||||||
|
sessionsRef.current = sessions
|
||||||
|
}, [sessions])
|
||||||
|
|
||||||
|
// 去重辅助函数:获取消息 key
|
||||||
|
const getMessageKey = (msg: any) => {
|
||||||
|
if (msg.localId && msg.localId > 0) return `l:${msg.localId}`
|
||||||
|
return `t:${msg.createTime}:${msg.sortSeq || 0}:${msg.serverId || 0}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理数据库变更
|
||||||
|
useEffect(() => {
|
||||||
|
const handleDbChange = (_event: any, data: { type: string; json: string }) => {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(data.json)
|
||||||
|
const tableName = payload.table
|
||||||
|
|
||||||
|
// 只关注 Session 表
|
||||||
|
if (tableName === 'Session' || tableName === 'session') {
|
||||||
|
refreshSessions()
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('解析数据库变更失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.electronAPI.chat.onWcdbChange) {
|
||||||
|
const removeListener = window.electronAPI.chat.onWcdbChange(handleDbChange)
|
||||||
|
return () => {
|
||||||
|
removeListener()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
}
|
||||||
|
return () => { }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const refreshSessions = async () => {
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.chat.getSessions()
|
||||||
|
if (result.success && result.sessions && Array.isArray(result.sessions)) {
|
||||||
|
const newSessions = result.sessions as ChatSession[]
|
||||||
|
const oldSessions = sessionsRef.current
|
||||||
|
|
||||||
|
// 1. 检测变更并通知
|
||||||
|
checkForNewMessages(oldSessions, newSessions)
|
||||||
|
|
||||||
|
// 2. 更新 store
|
||||||
|
setSessions(newSessions)
|
||||||
|
|
||||||
|
// 3. 如果在活跃会话中,增量刷新消息
|
||||||
|
const currentId = useChatStore.getState().currentSessionId
|
||||||
|
if (currentId) {
|
||||||
|
const currentSessionNew = newSessions.find(s => s.username === currentId)
|
||||||
|
const currentSessionOld = oldSessions.find(s => s.username === currentId)
|
||||||
|
|
||||||
|
if (currentSessionNew && (!currentSessionOld || currentSessionNew.lastTimestamp > currentSessionOld.lastTimestamp)) {
|
||||||
|
void handleActiveSessionRefresh(currentId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('全局会话刷新失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkForNewMessages = async (oldSessions: ChatSession[], newSessions: ChatSession[]) => {
|
||||||
|
if (!oldSessions || oldSessions.length === 0) {
|
||||||
|
console.log('[NotificationFilter] Skipping check on initial load (empty baseline)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldMap = new Map(oldSessions.map(s => [s.username, s]))
|
||||||
|
|
||||||
|
for (const newSession of newSessions) {
|
||||||
|
const oldSession = oldMap.get(newSession.username)
|
||||||
|
|
||||||
|
// 条件: 新会话或时间戳更新
|
||||||
|
const isCurrentSession = newSession.username === useChatStore.getState().currentSessionId
|
||||||
|
|
||||||
|
if (!isCurrentSession && (!oldSession || newSession.lastTimestamp > oldSession.lastTimestamp)) {
|
||||||
|
// 这是新消息事件
|
||||||
|
|
||||||
|
// 免打扰、折叠群、折叠入口不弹通知
|
||||||
|
if (newSession.isMuted || newSession.isFolded) continue
|
||||||
|
if (newSession.username.toLowerCase().includes('placeholder_foldgroup')) continue
|
||||||
|
|
||||||
|
// 1. 群聊过滤自己发送的消息
|
||||||
|
if (newSession.username.includes('@chatroom')) {
|
||||||
|
// 如果是自己发的消息,不弹通知
|
||||||
|
// 注意:lastMsgSender 需要后端支持返回
|
||||||
|
// 使用宽松比较以处理 wxid_ 前缀差异
|
||||||
|
if (newSession.lastMsgSender && newSession.selfWxid) {
|
||||||
|
const sender = newSession.lastMsgSender.replace(/^wxid_/, '');
|
||||||
|
const self = newSession.selfWxid.replace(/^wxid_/, '');
|
||||||
|
|
||||||
|
// 使用主进程日志打印,方便用户查看
|
||||||
|
const debugInfo = {
|
||||||
|
type: 'NotificationFilter',
|
||||||
|
username: newSession.username,
|
||||||
|
lastMsgSender: newSession.lastMsgSender,
|
||||||
|
selfWxid: newSession.selfWxid,
|
||||||
|
senderClean: sender,
|
||||||
|
selfClean: self,
|
||||||
|
match: sender === self
|
||||||
|
};
|
||||||
|
|
||||||
|
if (window.electronAPI.log?.debug) {
|
||||||
|
window.electronAPI.log.debug(debugInfo);
|
||||||
|
} else {
|
||||||
|
console.log('[NotificationFilter]', debugInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sender === self) {
|
||||||
|
if (window.electronAPI.log?.debug) {
|
||||||
|
window.electronAPI.log.debug('[NotificationFilter] Filtered own message');
|
||||||
|
} else {
|
||||||
|
console.log('[NotificationFilter] Filtered own message');
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const missingInfo = {
|
||||||
|
type: 'NotificationFilter Missing info',
|
||||||
|
lastMsgSender: newSession.lastMsgSender,
|
||||||
|
selfWxid: newSession.selfWxid
|
||||||
|
};
|
||||||
|
if (window.electronAPI.log?.debug) {
|
||||||
|
window.electronAPI.log.debug(missingInfo);
|
||||||
|
} else {
|
||||||
|
console.log('[NotificationFilter] Missing info:', missingInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增:如果未读数量没有增加,说明可能是自己在其他设备回复(或者已读),不弹通知
|
||||||
|
const oldUnread = oldSession ? oldSession.unreadCount : 0
|
||||||
|
const newUnread = newSession.unreadCount
|
||||||
|
if (newUnread <= oldUnread) {
|
||||||
|
// 仅仅是状态同步(如自己在手机上发消息 or 已读),跳过通知
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let title = newSession.displayName || newSession.username
|
||||||
|
let avatarUrl = newSession.avatarUrl
|
||||||
|
let content = newSession.summary || '[新消息]'
|
||||||
|
|
||||||
|
if (newSession.username.includes('@chatroom')) {
|
||||||
|
// 1. 群聊过滤自己发送的消息
|
||||||
|
// 辅助函数:清理 wxid 后缀 (如 _8602)
|
||||||
|
const cleanWxid = (id: string) => {
|
||||||
|
if (!id) return '';
|
||||||
|
const trimmed = id.trim();
|
||||||
|
// 仅移除末尾的 _xxxx (4位字母数字)
|
||||||
|
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/);
|
||||||
|
return suffixMatch ? suffixMatch[1] : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newSession.lastMsgSender && newSession.selfWxid) {
|
||||||
|
const senderClean = cleanWxid(newSession.lastMsgSender);
|
||||||
|
const selfClean = cleanWxid(newSession.selfWxid);
|
||||||
|
const match = senderClean === selfClean;
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 群聊显示发送者名字 (放在内容中: "Name: Message")
|
||||||
|
// 标题保持为群聊名称 (title 变量)
|
||||||
|
if (newSession.lastSenderDisplayName) {
|
||||||
|
content = `${newSession.lastSenderDisplayName}: ${content}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修复 "Random User" 的逻辑 (缺少具体信息)
|
||||||
|
// 如果标题看起来像 wxid 或没有头像,尝试获取信息
|
||||||
|
const needsEnrichment = !newSession.displayName || !newSession.avatarUrl || newSession.displayName === newSession.username
|
||||||
|
|
||||||
|
if (needsEnrichment && newSession.username) {
|
||||||
|
try {
|
||||||
|
// 尝试丰富或获取联系人详情
|
||||||
|
const contact = await window.electronAPI.chat.getContact(newSession.username)
|
||||||
|
if (contact) {
|
||||||
|
if (contact.remark || contact.nickName) {
|
||||||
|
title = contact.remark || contact.nickName
|
||||||
|
}
|
||||||
|
const avatarResult = await window.electronAPI.chat.getContactAvatar(newSession.username)
|
||||||
|
if (avatarResult?.avatarUrl) {
|
||||||
|
avatarUrl = avatarResult.avatarUrl
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 如果不在缓存/数据库中
|
||||||
|
const enrichResult = await window.electronAPI.chat.enrichSessionsContactInfo([newSession.username])
|
||||||
|
if (enrichResult.success && enrichResult.contacts) {
|
||||||
|
const enrichedContact = enrichResult.contacts[newSession.username]
|
||||||
|
if (enrichedContact) {
|
||||||
|
if (enrichedContact.displayName) {
|
||||||
|
title = enrichedContact.displayName
|
||||||
|
}
|
||||||
|
if (enrichedContact.avatarUrl) {
|
||||||
|
avatarUrl = enrichedContact.avatarUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 如果仍然没有有效名称,再尝试一次获取
|
||||||
|
if (title === newSession.username || title.startsWith('wxid_')) {
|
||||||
|
const retried = await window.electronAPI.chat.getContact(newSession.username)
|
||||||
|
if (retried) {
|
||||||
|
title = retried.remark || retried.nickName || title
|
||||||
|
const retriedAvatar = await window.electronAPI.chat.getContactAvatar(newSession.username)
|
||||||
|
if (retriedAvatar?.avatarUrl) {
|
||||||
|
avatarUrl = retriedAvatar.avatarUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('获取通知的联系人信息失败', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 最终检查:如果标题仍是 wxid 格式,则跳过通知(避免显示乱跳用户)
|
||||||
|
// 群聊例外,因为群聊 username 包含 @chatroom
|
||||||
|
const isGroupChat = newSession.username.includes('@chatroom')
|
||||||
|
const isWxidTitle = title.startsWith('wxid_') && title === newSession.username
|
||||||
|
if (isWxidTitle && !isGroupChat) {
|
||||||
|
console.warn('[NotificationFilter] 跳过无法识别的用户通知:', newSession.username)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用 IPC 以显示独立窗口通知
|
||||||
|
window.electronAPI.notification?.show({
|
||||||
|
title: title,
|
||||||
|
content: content,
|
||||||
|
avatarUrl: avatarUrl,
|
||||||
|
sessionId: newSession.username
|
||||||
|
})
|
||||||
|
|
||||||
|
// 我们不再为 Toast 设置本地状态
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleActiveSessionRefresh = async (sessionId: string) => {
|
||||||
|
// 从 ChatPage 复制/调整的逻辑,以保持集中
|
||||||
|
const state = useChatStore.getState()
|
||||||
|
const msgs = state.messages || []
|
||||||
|
const lastMsg = msgs[msgs.length - 1]
|
||||||
|
const minTime = lastMsg?.createTime || 0
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await (window.electronAPI.chat as any).getNewMessages(sessionId, minTime)
|
||||||
|
if (result.success && result.messages && result.messages.length > 0) {
|
||||||
|
appendMessages(result.messages, false) // 追加到末尾
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('后台活跃会话刷新失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 此组件不再渲染 UI
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -14,12 +14,21 @@
|
|||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
transition: transform 0.15s ease-out;
|
transition: transform 0.15s ease-out;
|
||||||
|
|
||||||
&.dragging {
|
&.dragging {
|
||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preview-content {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: fit-content;
|
||||||
|
height: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
.image-preview-close {
|
.image-preview-close {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 40px;
|
bottom: 40px;
|
||||||
@@ -44,3 +53,38 @@
|
|||||||
transform: translateX(-50%) scale(1.1);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,36 +1,41 @@
|
|||||||
import React, { useState, useRef, useCallback, useEffect } from 'react'
|
import React, { useState, useRef, useCallback, useEffect } from 'react'
|
||||||
import { X } from 'lucide-react'
|
import { X } from 'lucide-react'
|
||||||
|
import { LivePhotoIcon } from './LivePhotoIcon'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import './ImagePreview.scss'
|
import './ImagePreview.scss'
|
||||||
|
|
||||||
interface ImagePreviewProps {
|
interface ImagePreviewProps {
|
||||||
src: string
|
src: string
|
||||||
|
isVideo?: boolean
|
||||||
|
liveVideoPath?: string
|
||||||
onClose: () => void
|
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 [scale, setScale] = useState(1)
|
||||||
const [position, setPosition] = useState({ x: 0, y: 0 })
|
const [position, setPosition] = useState({ x: 0, y: 0 })
|
||||||
const [isDragging, setIsDragging] = useState(false)
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
|
const [showLive, setShowLive] = useState(false)
|
||||||
const dragStart = useRef({ x: 0, y: 0 })
|
const dragStart = useRef({ x: 0, y: 0 })
|
||||||
const positionStart = useRef({ x: 0, y: 0 })
|
const positionStart = useRef({ x: 0, y: 0 })
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
// 滚轮缩放
|
// 滚轮缩放
|
||||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||||
|
if (showLive) return // 播放实况时禁止缩放? 或者支持缩放? 暂定禁止以简化
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const delta = e.deltaY > 0 ? 0.9 : 1.1
|
const delta = e.deltaY > 0 ? 0.9 : 1.1
|
||||||
setScale(prev => Math.min(Math.max(prev * delta, 0.5), 5))
|
setScale(prev => Math.min(Math.max(prev * delta, 0.5), 5))
|
||||||
}, [])
|
}, [showLive])
|
||||||
|
|
||||||
// 开始拖动
|
// 开始拖动
|
||||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||||
if (scale <= 1) return
|
if (showLive || scale <= 1) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setIsDragging(true)
|
setIsDragging(true)
|
||||||
dragStart.current = { x: e.clientX, y: e.clientY }
|
dragStart.current = { x: e.clientX, y: e.clientY }
|
||||||
positionStart.current = { ...position }
|
positionStart.current = { ...position }
|
||||||
}, [scale, position])
|
}, [scale, position, showLive])
|
||||||
|
|
||||||
// 拖动中
|
// 拖动中
|
||||||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||||
@@ -79,19 +84,62 @@ export const ImagePreview: React.FC<ImagePreviewProps> = ({ src, onClose }) => {
|
|||||||
onMouseUp={handleMouseUp}
|
onMouseUp={handleMouseUp}
|
||||||
onMouseLeave={handleMouseUp}
|
onMouseLeave={handleMouseUp}
|
||||||
>
|
>
|
||||||
<img
|
<div
|
||||||
src={src}
|
className="preview-content"
|
||||||
alt="图片预览"
|
|
||||||
className={`preview-image ${isDragging ? 'dragging' : ''}`}
|
|
||||||
style={{
|
style={{
|
||||||
transform: `translate(${position.x}px, ${position.y}px) scale(${scale})`,
|
position: 'relative',
|
||||||
cursor: scale > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default'
|
transform: `translate(${position.x}px, ${position.y}px)`,
|
||||||
|
width: 'fit-content',
|
||||||
|
height: 'fit-content'
|
||||||
}}
|
}}
|
||||||
onWheel={handleWheel}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onMouseDown={handleMouseDown}
|
>
|
||||||
onDoubleClick={handleDoubleClick}
|
{(isVideo || showLive) ? (
|
||||||
draggable={false}
|
<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}>
|
<button className="image-preview-close" onClick={onClose}>
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -75,6 +75,18 @@
|
|||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
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 {
|
.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 {
|
.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 {
|
.weekdays {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(7, 1fr);
|
grid-template-columns: repeat(7, 1fr);
|
||||||
@@ -117,10 +220,10 @@
|
|||||||
.days {
|
.days {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(7, 1fr);
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
grid-template-rows: repeat(6, 36px);
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
|
||||||
.day-cell {
|
.day-cell {
|
||||||
aspect-ratio: 1;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -129,12 +232,13 @@
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
&.empty {
|
&.empty {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:not(.empty):hover {
|
&:not(.empty):not(.no-message):hover {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,10 +253,43 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
background: var(--primary-light);
|
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 {
|
.quick-options {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState, useMemo } from 'react'
|
||||||
import { X, ChevronLeft, ChevronRight, Calendar as CalendarIcon } from 'lucide-react'
|
import { X, ChevronLeft, ChevronRight, Calendar as CalendarIcon, Loader2 } from 'lucide-react'
|
||||||
import './JumpToDateDialog.scss'
|
import './JumpToDateDialog.scss'
|
||||||
|
|
||||||
interface JumpToDateDialogProps {
|
interface JumpToDateDialogProps {
|
||||||
@@ -7,16 +7,24 @@ interface JumpToDateDialogProps {
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSelect: (date: Date) => void
|
onSelect: (date: Date) => void
|
||||||
currentDate?: Date
|
currentDate?: Date
|
||||||
|
/** 有消息的日期集合,格式为 YYYY-MM-DD */
|
||||||
|
messageDates?: Set<string>
|
||||||
|
/** 是否正在加载消息日期 */
|
||||||
|
loadingDates?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
|
const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
onSelect,
|
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 [selectedDate, setSelectedDate] = useState(new Date(currentDate))
|
||||||
|
const [showYearMonthPicker, setShowYearMonthPicker] = useState(false)
|
||||||
|
|
||||||
if (!isOpen) return null
|
if (!isOpen) return null
|
||||||
|
|
||||||
@@ -48,7 +56,20 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
|
|||||||
return days
|
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) => {
|
const handleDateClick = (day: number) => {
|
||||||
|
// 如果已加载日期数据且该日期无消息,则不可点击
|
||||||
|
if (messageDates && messageDates.size > 0 && !hasMessage(day)) return
|
||||||
const newDate = new Date(calendarDate.getFullYear(), calendarDate.getMonth(), day)
|
const newDate = new Date(calendarDate.getFullYear(), calendarDate.getMonth(), day)
|
||||||
setSelectedDate(newDate)
|
setSelectedDate(newDate)
|
||||||
}
|
}
|
||||||
@@ -71,6 +92,28 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
|
|||||||
calendarDate.getFullYear() === selectedDate.getFullYear()
|
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 weekdays = ['日', '一', '二', '三', '四', '五', '六']
|
||||||
const days = generateCalendar()
|
const days = generateCalendar()
|
||||||
|
|
||||||
@@ -95,7 +138,7 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
|
|||||||
>
|
>
|
||||||
<ChevronLeft size={18} />
|
<ChevronLeft size={18} />
|
||||||
</button>
|
</button>
|
||||||
<span className="current-month">
|
<span className="current-month clickable" onClick={() => setShowYearMonthPicker(!showYearMonthPicker)}>
|
||||||
{calendarDate.getFullYear()}年{calendarDate.getMonth() + 1}月
|
{calendarDate.getFullYear()}年{calendarDate.getMonth() + 1}月
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
@@ -106,22 +149,58 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="calendar-grid">
|
{showYearMonthPicker ? (
|
||||||
<div className="weekdays">
|
<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>)}
|
{weekdays.map(d => <div key={d} className="weekday">{d}</div>)}
|
||||||
</div>
|
</div>
|
||||||
<div className="days">
|
<div className="days" style={{ visibility: loadingDates ? 'hidden' : 'visible' }}>
|
||||||
{days.map((day, i) => (
|
{days.map((day, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
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)}
|
onClick={() => day !== null && handleDateClick(day)}
|
||||||
>
|
>
|
||||||
{day}
|
{day}
|
||||||
|
{day !== null && messageDates && messageDates.size > 0 && hasMessage(day) && (
|
||||||
|
<span className="message-dot" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="quick-options">
|
<div className="quick-options">
|
||||||
|
|||||||
166
src/components/JumpToDatePopover.scss
Normal file
166
src/components/JumpToDatePopover.scss
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
185
src/components/JumpToDatePopover.tsx
Normal file
185
src/components/JumpToDatePopover.tsx
Normal 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
|
||||||
29
src/components/LivePhotoIcon.tsx
Normal file
29
src/components/LivePhotoIcon.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface LivePhotoIconProps {
|
||||||
|
size?: number | string;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LivePhotoIcon: React.FC<LivePhotoIconProps> = ({ size = 24, className = '', style = {} }) => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
version="1.1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
<g stroke="none" strokeWidth="1" fill="none" fillRule="evenodd" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<g stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle fill="currentColor" stroke="none" cx="12" cy="12" r="2.5"></circle>
|
||||||
|
<circle cx="12" cy="12" r="5.5"></circle>
|
||||||
|
<circle cx="12" cy="12" r="9" strokeDasharray="1 3.7"></circle>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
185
src/components/LockScreen.scss
Normal file
185
src/components/LockScreen.scss
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
.lock-screen {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
transition: all 0.5s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
backdrop-filter: blur(25px) saturate(180%);
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
// 让背景带一点透明度以增强毛玻璃效果
|
||||||
|
opacity: 1;
|
||||||
|
|
||||||
|
&.unlocked {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
backdrop-filter: blur(0) saturate(100%);
|
||||||
|
transform: scale(1.02);
|
||||||
|
|
||||||
|
.lock-content {
|
||||||
|
transform: translateY(-20px) scale(0.95);
|
||||||
|
filter: blur(10px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lock-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
width: 320px;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
animation: fadeIn 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
|
.lock-avatar {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||||
|
border: 4px solid var(--bg-total);
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lock-title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lock-form {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
height: 48px;
|
||||||
|
padding: 0 16px;
|
||||||
|
padding-right: 48px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background-color: var(--bg-input);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 16px;
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 2px var(--primary-color-alpha);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
top: 8px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hello-btn {
|
||||||
|
width: 100%;
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--bg-hover);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.loading {
|
||||||
|
opacity: 0.7;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lock-error {
|
||||||
|
margin-top: 16px;
|
||||||
|
color: #ff4d4f;
|
||||||
|
font-size: 14px;
|
||||||
|
animation: shake 0.5s ease-in-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shake {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
10%,
|
||||||
|
30%,
|
||||||
|
50%,
|
||||||
|
70%,
|
||||||
|
90% {
|
||||||
|
transform: translateX(-4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
20%,
|
||||||
|
40%,
|
||||||
|
60%,
|
||||||
|
80% {
|
||||||
|
transform: translateX(4px);
|
||||||
|
}
|
||||||
|
}
|
||||||
144
src/components/LockScreen.tsx
Normal file
144
src/components/LockScreen.tsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { ArrowRight, Fingerprint, Lock, ScanFace, ShieldCheck } from 'lucide-react'
|
||||||
|
import './LockScreen.scss'
|
||||||
|
|
||||||
|
interface LockScreenProps {
|
||||||
|
onUnlock: () => void
|
||||||
|
avatar?: string
|
||||||
|
useHello?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LockScreen({ onUnlock, avatar, useHello = false }: LockScreenProps) {
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [isVerifying, setIsVerifying] = useState(false)
|
||||||
|
const [isUnlocked, setIsUnlocked] = useState(false)
|
||||||
|
const [showHello, setShowHello] = useState(false)
|
||||||
|
const [helloAvailable, setHelloAvailable] = useState(false)
|
||||||
|
|
||||||
|
// 用于取消 WebAuthn 请求
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 快速检查配置并启动
|
||||||
|
quickStartHello()
|
||||||
|
inputRef.current?.focus()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// 组件卸载时取消请求
|
||||||
|
abortControllerRef.current?.abort()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleUnlock = () => {
|
||||||
|
setIsUnlocked(true)
|
||||||
|
setTimeout(() => {
|
||||||
|
onUnlock()
|
||||||
|
}, 1500)
|
||||||
|
}
|
||||||
|
|
||||||
|
const quickStartHello = async () => {
|
||||||
|
try {
|
||||||
|
if (useHello) {
|
||||||
|
setHelloAvailable(true)
|
||||||
|
setShowHello(true)
|
||||||
|
verifyHello()
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Quick start hello failed', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const verifyHello = async () => {
|
||||||
|
if (isVerifying || isUnlocked) return
|
||||||
|
|
||||||
|
setIsVerifying(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.auth.hello()
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
handleUnlock()
|
||||||
|
} else {
|
||||||
|
console.error('Hello verification failed:', result.error)
|
||||||
|
setError(result.error || '验证失败')
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Hello verification error:', e)
|
||||||
|
setError(`验证失败: ${e.message || String(e)}`)
|
||||||
|
} finally {
|
||||||
|
setIsVerifying(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePasswordSubmit = async (e?: React.FormEvent) => {
|
||||||
|
e?.preventDefault()
|
||||||
|
if (!password || isUnlocked) return
|
||||||
|
|
||||||
|
setIsVerifying(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 发送原始密码到主进程,由主进程验证并解密密钥
|
||||||
|
const result = await window.electronAPI.auth.unlock(password)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
handleUnlock()
|
||||||
|
} else {
|
||||||
|
setError(result.error || '密码错误')
|
||||||
|
setPassword('')
|
||||||
|
setIsVerifying(false)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError('验证失败')
|
||||||
|
setIsVerifying(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`lock-screen ${isUnlocked ? 'unlocked' : ''}`}>
|
||||||
|
<div className="lock-content">
|
||||||
|
<div className="lock-avatar">
|
||||||
|
{avatar ? (
|
||||||
|
<img src={avatar} alt="User" style={{ width: '100%', height: '100%', borderRadius: '50%' }} />
|
||||||
|
) : (
|
||||||
|
<Lock size={40} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="lock-title">WeFlow 已锁定</h2>
|
||||||
|
|
||||||
|
<form className="lock-form" onSubmit={handlePasswordSubmit}>
|
||||||
|
<div className="input-group">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="password"
|
||||||
|
placeholder="输入应用密码"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
// 移除 disabled,允许用户随时输入
|
||||||
|
/>
|
||||||
|
<button type="submit" className="submit-btn" disabled={!password}>
|
||||||
|
<ArrowRight size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showHello && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`hello-btn ${isVerifying ? 'loading' : ''}`}
|
||||||
|
onClick={verifyHello}
|
||||||
|
>
|
||||||
|
<Fingerprint size={20} />
|
||||||
|
{isVerifying ? '验证中...' : '使用 Windows Hello 解锁'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{error && <div className="lock-error">{error}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
36
src/components/MessageBubble.tsx
Normal file
36
src/components/MessageBubble.tsx
Normal 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'
|
||||||
223
src/components/NotificationToast.scss
Normal file
223
src/components/NotificationToast.scss
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
.notification-toast-container {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 9999;
|
||||||
|
width: 320px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
|
||||||
|
// 浅色模式下使用完全不透明背景,并禁用毛玻璃效果
|
||||||
|
[data-mode="light"] &,
|
||||||
|
:not([data-mode]) & {
|
||||||
|
background: rgba(255, 255, 255, 1);
|
||||||
|
backdrop-filter: none;
|
||||||
|
-webkit-backdrop-filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||||
|
padding: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.95);
|
||||||
|
pointer-events: none; // Allow clicking through when hidden
|
||||||
|
|
||||||
|
&.visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.static {
|
||||||
|
position: relative !important;
|
||||||
|
width: calc(100% - 4px) !important; // Leave 2px margin for anti-aliasing saftey
|
||||||
|
height: auto !important; // Fits content
|
||||||
|
min-height: 0;
|
||||||
|
top: 0 !important;
|
||||||
|
bottom: 0 !important;
|
||||||
|
left: 0 !important;
|
||||||
|
right: 0 !important;
|
||||||
|
transform: none !important;
|
||||||
|
margin: 2px !important; // 2px centered margin
|
||||||
|
border-radius: 12px !important; // Rounded corners
|
||||||
|
|
||||||
|
|
||||||
|
// Disable backdrop filter
|
||||||
|
backdrop-filter: none !important;
|
||||||
|
-webkit-backdrop-filter: none !important;
|
||||||
|
|
||||||
|
// 独立通知窗口:默认使用浅色模式硬编码值,确保不依赖 <html> 上的主题属性
|
||||||
|
background: #ffffff;
|
||||||
|
color: #3d3d3d;
|
||||||
|
--text-primary: #3d3d3d;
|
||||||
|
--text-secondary: #666666;
|
||||||
|
--text-tertiary: #999999;
|
||||||
|
--border-light: rgba(0, 0, 0, 0.08);
|
||||||
|
|
||||||
|
// 深色模式覆盖
|
||||||
|
[data-mode="dark"] & {
|
||||||
|
background: var(--bg-secondary-solid, #282420);
|
||||||
|
color: var(--text-primary, #F0EEE9);
|
||||||
|
--text-primary: #F0EEE9;
|
||||||
|
--text-secondary: #b3b0aa;
|
||||||
|
--text-tertiary: #807d78;
|
||||||
|
--border-light: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
box-shadow: none !important; // NO SHADOW
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
padding: 16px;
|
||||||
|
padding-right: 32px; // Make space for close button
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
// Force close button to be visible but transparent background
|
||||||
|
.notification-close {
|
||||||
|
opacity: 1 !important;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
background: transparent !important; // Transparent per user request
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: rgba(255, 255, 255, 0.1) !important; // Subtle hover effect
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-time {
|
||||||
|
top: 24px; // Match padding
|
||||||
|
right: 40px; // Left of close button (12px + 20px + 8px)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position variants
|
||||||
|
&.bottom-right {
|
||||||
|
bottom: 24px;
|
||||||
|
right: 24px;
|
||||||
|
transform: translate(0, 20px) scale(0.95);
|
||||||
|
|
||||||
|
&.visible {
|
||||||
|
transform: translate(0, 0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.top-right {
|
||||||
|
top: 24px;
|
||||||
|
right: 24px;
|
||||||
|
transform: translate(0, -20px) scale(0.95);
|
||||||
|
|
||||||
|
&.visible {
|
||||||
|
transform: translate(0, 0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.bottom-left {
|
||||||
|
bottom: 24px;
|
||||||
|
left: 24px;
|
||||||
|
transform: translate(0, 20px) scale(0.95);
|
||||||
|
|
||||||
|
&.visible {
|
||||||
|
transform: translate(0, 0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.top-left {
|
||||||
|
top: 24px;
|
||||||
|
left: 24px;
|
||||||
|
transform: translate(0, -20px) scale(0.95);
|
||||||
|
|
||||||
|
&.visible {
|
||||||
|
transform: translate(0, 0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.16) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-avatar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
.notification-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
|
||||||
|
.notification-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 100%; // 允许缩放
|
||||||
|
flex: 1; // 占据剩余空间
|
||||||
|
min-width: 0; // 关键:允许 flex 子项收缩到内容以下
|
||||||
|
margin-right: 60px; // Make space for absolute time + close button
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
right: 36px; // Left of close button (8px + 20px + 8px)
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-body {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.4;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .notification-close {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
108
src/components/NotificationToast.tsx
Normal file
108
src/components/NotificationToast.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import { Avatar } from './Avatar'
|
||||||
|
import './NotificationToast.scss'
|
||||||
|
|
||||||
|
export interface NotificationData {
|
||||||
|
id: string
|
||||||
|
sessionId: string
|
||||||
|
avatarUrl?: string
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NotificationToastProps {
|
||||||
|
data: NotificationData | null
|
||||||
|
onClose: () => void
|
||||||
|
onClick: (sessionId: string) => void
|
||||||
|
duration?: number
|
||||||
|
position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'
|
||||||
|
isStatic?: boolean
|
||||||
|
initialVisible?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotificationToast({
|
||||||
|
data,
|
||||||
|
onClose,
|
||||||
|
onClick,
|
||||||
|
duration = 5000,
|
||||||
|
position = 'top-right',
|
||||||
|
isStatic = false,
|
||||||
|
initialVisible = false
|
||||||
|
}: NotificationToastProps) {
|
||||||
|
const [isVisible, setIsVisible] = useState(initialVisible)
|
||||||
|
const [currentData, setCurrentData] = useState<NotificationData | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data) {
|
||||||
|
setCurrentData(data)
|
||||||
|
setIsVisible(true)
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setIsVisible(false)
|
||||||
|
// clean up data after animation
|
||||||
|
setTimeout(onClose, 300)
|
||||||
|
}, duration)
|
||||||
|
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
} else {
|
||||||
|
setIsVisible(false)
|
||||||
|
}
|
||||||
|
}, [data, duration, onClose])
|
||||||
|
|
||||||
|
if (!currentData) return null
|
||||||
|
|
||||||
|
const handleClose = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setIsVisible(false)
|
||||||
|
setTimeout(onClose, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
setIsVisible(false)
|
||||||
|
setTimeout(() => {
|
||||||
|
onClose()
|
||||||
|
onClick(currentData.sessionId)
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<div
|
||||||
|
className={`notification-toast-container ${position} ${isVisible ? 'visible' : ''} ${isStatic ? 'static' : ''}`}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
<div className="notification-content">
|
||||||
|
<div className="notification-avatar">
|
||||||
|
<Avatar
|
||||||
|
src={currentData.avatarUrl}
|
||||||
|
name={currentData.title}
|
||||||
|
size={40}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="notification-text">
|
||||||
|
<div className="notification-header">
|
||||||
|
<span className="notification-title">{currentData.title}</span>
|
||||||
|
<span className="notification-time">
|
||||||
|
{new Date(currentData.timestamp * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="notification-body">
|
||||||
|
{currentData.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="notification-close" onClick={handleClose}>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isStatic) {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
// Portal to document.body to ensure it's on top
|
||||||
|
return createPortal(content, document.body)
|
||||||
|
}
|
||||||
142
src/components/ReportComponents.scss
Normal file
142
src/components/ReportComponents.scss
Normal 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;
|
||||||
|
}
|
||||||
51
src/components/ReportHeatmap.tsx
Normal file
51
src/components/ReportHeatmap.tsx
Normal 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
|
||||||
113
src/components/ReportWordCloud.tsx
Normal file
113
src/components/ReportWordCloud.tsx
Normal 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
|
||||||
@@ -6,8 +6,7 @@ interface RouteGuardProps {
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
// 不需要数据库连接的页面
|
const PUBLIC_ROUTES = ['/', '/home', '/settings']
|
||||||
const PUBLIC_ROUTES = ['/', '/home', '/settings', '/data-management']
|
|
||||||
|
|
||||||
function RouteGuard({ children }: RouteGuardProps) {
|
function RouteGuard({ children }: RouteGuardProps) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|||||||
@@ -10,6 +10,19 @@
|
|||||||
&.collapsed {
|
&.collapsed {
|
||||||
width: 64px;
|
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,
|
.nav-menu,
|
||||||
.sidebar-footer {
|
.sidebar-footer {
|
||||||
padding: 0 8px;
|
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 {
|
.nav-menu {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -57,7 +183,7 @@
|
|||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
background: var(--primary);
|
background: var(--primary);
|
||||||
color: white;
|
color: var(--on-primary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,13 +196,46 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-icon-with-badge {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
.nav-label {
|
.nav-label {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
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 {
|
.sidebar-footer {
|
||||||
padding: 0 8px;
|
padding: 0 12px;
|
||||||
border-top: 1px solid var(--border-color);
|
border-top: 1px solid var(--border-color);
|
||||||
padding-top: 12px;
|
padding-top: 12px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
@@ -103,4 +262,107 @@
|
|||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
color: var(--text-primary);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,335 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { NavLink, useLocation } from 'react-router-dom'
|
import { NavLink, useLocation } from 'react-router-dom'
|
||||||
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Bot, Aperture } from 'lucide-react'
|
import { Home, MessageSquare, BarChart3, Users, FileText, Settings, ChevronLeft, ChevronRight, Download, Aperture, UserCircle, Lock, ChevronUp, Trash2 } from 'lucide-react'
|
||||||
|
import { useAppStore } from '../stores/appStore'
|
||||||
|
import * as configService from '../services/config'
|
||||||
|
import { onExportSessionStatus, requestExportSessionStatus } from '../services/exportBridge'
|
||||||
|
|
||||||
import './Sidebar.scss'
|
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() {
|
function Sidebar() {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const [collapsed, setCollapsed] = useState(false)
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
|
const [authEnabled, setAuthEnabled] = useState(false)
|
||||||
|
const [activeExportTaskCount, setActiveExportTaskCount] = useState(0)
|
||||||
|
const [userProfile, setUserProfile] = useState<SidebarUserProfile>({
|
||||||
|
wxid: '',
|
||||||
|
displayName: '未识别用户'
|
||||||
|
})
|
||||||
|
const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false)
|
||||||
|
const [showClearAccountDialog, setShowClearAccountDialog] = useState(false)
|
||||||
|
const [shouldClearCacheData, setShouldClearCacheData] = useState(false)
|
||||||
|
const [shouldClearExportData, setShouldClearExportData] = useState(false)
|
||||||
|
const [isClearingAccountData, setIsClearingAccountData] = useState(false)
|
||||||
|
const accountCardWrapRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const setLocked = useAppStore(state => state.setLocked)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.electronAPI.auth.verifyEnabled().then(setAuthEnabled)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (!isAccountMenuOpen) return
|
||||||
|
const target = event.target as Node | null
|
||||||
|
if (accountCardWrapRef.current && target && !accountCardWrapRef.current.contains(target)) {
|
||||||
|
setIsAccountMenuOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}, [isAccountMenuOpen])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = onExportSessionStatus((payload) => {
|
||||||
|
const countFromPayload = typeof payload?.activeTaskCount === 'number'
|
||||||
|
? payload.activeTaskCount
|
||||||
|
: Array.isArray(payload?.inProgressSessionIds)
|
||||||
|
? payload.inProgressSessionIds.length
|
||||||
|
: 0
|
||||||
|
const normalized = Math.max(0, Math.floor(countFromPayload))
|
||||||
|
setActiveExportTaskCount(normalized)
|
||||||
|
})
|
||||||
|
|
||||||
|
requestExportSessionStatus()
|
||||||
|
const timer = window.setTimeout(() => requestExportSessionStatus(), 120)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe()
|
||||||
|
window.clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadCurrentUser = async () => {
|
||||||
|
const patchUserProfile = (patch: Partial<SidebarUserProfile>, expectedWxid?: string) => {
|
||||||
|
setUserProfile(prev => {
|
||||||
|
if (expectedWxid && prev.wxid && prev.wxid !== expectedWxid) {
|
||||||
|
return prev
|
||||||
|
}
|
||||||
|
const next: SidebarUserProfile = {
|
||||||
|
...prev,
|
||||||
|
...patch
|
||||||
|
}
|
||||||
|
if (!next.displayName) {
|
||||||
|
next.displayName = next.wxid || '未识别用户'
|
||||||
|
}
|
||||||
|
writeSidebarUserProfileCache(next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const wxid = await configService.getMyWxid()
|
||||||
|
const resolvedWxidRaw = String(wxid || '').trim()
|
||||||
|
const cleanedWxid = normalizeAccountId(resolvedWxidRaw)
|
||||||
|
const resolvedWxid = cleanedWxid || resolvedWxidRaw
|
||||||
|
const wxidCandidates = new Set<string>([
|
||||||
|
resolvedWxidRaw.toLowerCase(),
|
||||||
|
resolvedWxid.trim().toLowerCase(),
|
||||||
|
cleanedWxid.trim().toLowerCase()
|
||||||
|
].filter(Boolean))
|
||||||
|
|
||||||
|
const normalizeName = (value?: string | null): string | undefined => {
|
||||||
|
if (!value) return undefined
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (!trimmed) return undefined
|
||||||
|
const lowered = trimmed.toLowerCase()
|
||||||
|
if (lowered === 'self') return undefined
|
||||||
|
if (lowered.startsWith('wxid_')) return undefined
|
||||||
|
if (wxidCandidates.has(lowered)) return undefined
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickFirstValidName = (...candidates: Array<string | null | undefined>): string | undefined => {
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const normalized = normalizeName(candidate)
|
||||||
|
if (normalized) return normalized
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackDisplayName = resolvedWxid || '未识别用户'
|
||||||
|
|
||||||
|
// 第一阶段:先把 wxid/名称打上,保证侧边栏第一时间可见。
|
||||||
|
patchUserProfile({
|
||||||
|
wxid: resolvedWxid,
|
||||||
|
displayName: fallbackDisplayName
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!resolvedWxidRaw && !resolvedWxid) return
|
||||||
|
|
||||||
|
// 第二阶段:后台补齐名称(不会阻塞首屏)。
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
let myContact: Awaited<ReturnType<typeof window.electronAPI.chat.getContact>> | null = null
|
||||||
|
for (const candidate of Array.from(new Set([resolvedWxidRaw, resolvedWxid, cleanedWxid].filter(Boolean)))) {
|
||||||
|
const contact = await window.electronAPI.chat.getContact(candidate)
|
||||||
|
if (!contact) continue
|
||||||
|
if (!myContact) myContact = contact
|
||||||
|
if (contact.remark || contact.nickName || contact.alias) {
|
||||||
|
myContact = contact
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const fromContact = pickFirstValidName(
|
||||||
|
myContact?.remark,
|
||||||
|
myContact?.nickName,
|
||||||
|
myContact?.alias
|
||||||
|
)
|
||||||
|
|
||||||
|
if (fromContact) {
|
||||||
|
patchUserProfile({ displayName: fromContact }, resolvedWxid)
|
||||||
|
// 同步补充微信号(alias)
|
||||||
|
if (myContact?.alias) {
|
||||||
|
patchUserProfile({ alias: myContact.alias }, resolvedWxid)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const enrichTargets = Array.from(new Set([resolvedWxidRaw, resolvedWxid, cleanedWxid, 'self'].filter(Boolean)))
|
||||||
|
const enrichedResult = await window.electronAPI.chat.enrichSessionsContactInfo(enrichTargets)
|
||||||
|
const enrichedDisplayName = pickFirstValidName(
|
||||||
|
enrichedResult.contacts?.[resolvedWxidRaw]?.displayName,
|
||||||
|
enrichedResult.contacts?.[resolvedWxid]?.displayName,
|
||||||
|
enrichedResult.contacts?.[cleanedWxid]?.displayName,
|
||||||
|
enrichedResult.contacts?.self?.displayName,
|
||||||
|
myContact?.alias
|
||||||
|
)
|
||||||
|
const bestName = enrichedDisplayName
|
||||||
|
if (bestName) {
|
||||||
|
patchUserProfile({ displayName: bestName }, resolvedWxid)
|
||||||
|
}
|
||||||
|
// 降级分支也补充微信号
|
||||||
|
if (myContact?.alias) {
|
||||||
|
patchUserProfile({ alias: myContact.alias }, resolvedWxid)
|
||||||
|
}
|
||||||
|
} catch (nameError) {
|
||||||
|
console.error('加载侧边栏用户昵称失败:', nameError)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
// 第二阶段:后台补齐头像(不会阻塞首屏)。
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const avatarResult = await window.electronAPI.chat.getMyAvatarUrl()
|
||||||
|
if (avatarResult.success && avatarResult.avatarUrl) {
|
||||||
|
patchUserProfile({ avatarUrl: avatarResult.avatarUrl }, resolvedWxid)
|
||||||
|
}
|
||||||
|
} catch (avatarError) {
|
||||||
|
console.error('加载侧边栏用户头像失败:', avatarError)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载侧边栏用户信息失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cachedProfile = readSidebarUserProfileCache()
|
||||||
|
if (cachedProfile) {
|
||||||
|
setUserProfile(prev => ({
|
||||||
|
...prev,
|
||||||
|
...cachedProfile
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadCurrentUser()
|
||||||
|
const onWxidChanged = () => { void loadCurrentUser() }
|
||||||
|
window.addEventListener('wxid-changed', onWxidChanged as EventListener)
|
||||||
|
return () => window.removeEventListener('wxid-changed', onWxidChanged as EventListener)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const getAvatarLetter = (name: string): string => {
|
||||||
|
if (!name) return '?'
|
||||||
|
return [...name][0] || '?'
|
||||||
|
}
|
||||||
|
|
||||||
const isActive = (path: string) => {
|
const isActive = (path: string) => {
|
||||||
return location.pathname === path || location.pathname.startsWith(`${path}/`)
|
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 (
|
return (
|
||||||
<aside className={`sidebar ${collapsed ? 'collapsed' : ''}`}>
|
<aside className={`sidebar ${collapsed ? 'collapsed' : ''}`}>
|
||||||
@@ -44,7 +364,15 @@ function Sidebar() {
|
|||||||
<span className="nav-label">朋友圈</span>
|
<span className="nav-label">朋友圈</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
|
{/* 通讯录 */}
|
||||||
|
<NavLink
|
||||||
|
to="/contacts"
|
||||||
|
className={`nav-item ${isActive('/contacts') ? 'active' : ''}`}
|
||||||
|
title={collapsed ? '通讯录' : undefined}
|
||||||
|
>
|
||||||
|
<span className="nav-icon"><UserCircle size={20} /></span>
|
||||||
|
<span className="nav-label">通讯录</span>
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
{/* 私聊分析 */}
|
{/* 私聊分析 */}
|
||||||
<NavLink
|
<NavLink
|
||||||
@@ -82,22 +410,72 @@ function Sidebar() {
|
|||||||
className={`nav-item ${isActive('/export') ? 'active' : ''}`}
|
className={`nav-item ${isActive('/export') ? 'active' : ''}`}
|
||||||
title={collapsed ? '导出' : undefined}
|
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>
|
<span className="nav-label">导出</span>
|
||||||
|
{!collapsed && activeExportTaskCount > 0 && (
|
||||||
|
<span className="nav-badge">{exportTaskBadge}</span>
|
||||||
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
{/* 数据管理 */}
|
|
||||||
<NavLink
|
|
||||||
to="/data-management"
|
|
||||||
className={`nav-item ${isActive('/data-management') ? 'active' : ''}`}
|
|
||||||
title={collapsed ? '数据管理' : undefined}
|
|
||||||
>
|
|
||||||
<span className="nav-icon"><Database size={20} /></span>
|
|
||||||
<span className="nav-label">数据管理</span>
|
|
||||||
</NavLink>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="sidebar-footer">
|
<div className="sidebar-footer">
|
||||||
|
<div className="sidebar-user-card-wrap" ref={accountCardWrapRef}>
|
||||||
|
{isAccountMenuOpen && (
|
||||||
|
<button
|
||||||
|
className="sidebar-user-clear-trigger"
|
||||||
|
onClick={openClearAccountDialog}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
<span>清除此账号所有数据</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={`sidebar-user-card ${isAccountMenuOpen ? 'menu-open' : ''}`}
|
||||||
|
title={collapsed ? `${userProfile.displayName}${(userProfile.alias || userProfile.wxid) ? `\n${userProfile.alias || userProfile.wxid}` : ''}` : undefined}
|
||||||
|
onClick={() => setIsAccountMenuOpen(prev => !prev)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault()
|
||||||
|
setIsAccountMenuOpen(prev => !prev)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="user-avatar">
|
||||||
|
{userProfile.avatarUrl ? <img src={userProfile.avatarUrl} alt="" /> : <span>{getAvatarLetter(userProfile.displayName)}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="user-meta">
|
||||||
|
<div className="user-name">{userProfile.displayName}</div>
|
||||||
|
<div className="user-wxid">{userProfile.alias || userProfile.wxid || 'wxid 未识别'}</div>
|
||||||
|
</div>
|
||||||
|
{!collapsed && (
|
||||||
|
<span className={`user-menu-caret ${isAccountMenuOpen ? 'open' : ''}`}>
|
||||||
|
<ChevronUp size={14} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{authEnabled && (
|
||||||
|
<button
|
||||||
|
className="nav-item"
|
||||||
|
onClick={() => setLocked(true)}
|
||||||
|
title={collapsed ? '锁定' : undefined}
|
||||||
|
>
|
||||||
|
<span className="nav-icon"><Lock size={20} /></span>
|
||||||
|
<span className="nav-label">锁定</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/settings"
|
to="/settings"
|
||||||
className={`nav-item ${isActive('/settings') ? 'active' : ''}`}
|
className={`nav-item ${isActive('/settings') ? 'active' : ''}`}
|
||||||
@@ -117,6 +495,49 @@ function Sidebar() {
|
|||||||
{collapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
|
{collapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</aside>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
199
src/components/Sns/SnsFilterPanel.tsx
Normal file
199
src/components/Sns/SnsFilterPanel.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
360
src/components/Sns/SnsMediaGrid.tsx
Normal file
360
src/components/Sns/SnsMediaGrid.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
424
src/components/Sns/SnsPostItem.tsx
Normal file
424
src/components/Sns/SnsPostItem.tsx
Normal 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(/&/gi, '&')
|
||||||
|
.replace(/</gi, '<')
|
||||||
|
.replace(/>/gi, '>')
|
||||||
|
.replace(/"/gi, '"')
|
||||||
|
.replace(/'/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
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,6 +10,12 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 繁花如梦:标题栏毛玻璃
|
||||||
|
[data-theme="blossom-dream"] .title-bar {
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
}
|
||||||
|
|
||||||
.title-logo {
|
.title-logo {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import './TitleBar.scss'
|
import './TitleBar.scss'
|
||||||
|
|
||||||
function TitleBar() {
|
interface TitleBarProps {
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function TitleBar({ title }: TitleBarProps = {}) {
|
||||||
return (
|
return (
|
||||||
<div className="title-bar">
|
<div className="title-bar">
|
||||||
<img src="./logo.png" alt="WeFlow" className="title-logo" />
|
<img src="./logo.png" alt="WeFlow" className="title-logo" />
|
||||||
<span className="titles">WeFlow</span>
|
<span className="titles">{title || 'WeFlow'}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
274
src/components/UpdateDialog.scss
Normal file
274
src/components/UpdateDialog.scss
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
.update-dialog-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 9999;
|
||||||
|
animation: fadeIn 0.3s ease-out;
|
||||||
|
|
||||||
|
.update-dialog {
|
||||||
|
width: 680px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 24px;
|
||||||
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
animation: slideUp 0.3s ease-out;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
/* Top Section (White/Gradient) */
|
||||||
|
.dialog-header {
|
||||||
|
background: #ffffff;
|
||||||
|
padding: 40px 20px 30px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
/* Subtle radial gradient effect in top left as seen in image */
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -50px;
|
||||||
|
left: -50px;
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
background: radial-gradient(circle, rgba(255, 235, 220, 0.4) 0%, rgba(255, 255, 255, 0) 70%);
|
||||||
|
opacity: 0.8;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-tag {
|
||||||
|
background: #f0eee9;
|
||||||
|
color: #8c7b6e;
|
||||||
|
padding: 4px 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #333333;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 15px;
|
||||||
|
color: #999999;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content Section (Light Gray) */
|
||||||
|
.dialog-content {
|
||||||
|
background: #f2f2f2;
|
||||||
|
padding: 24px 40px 40px;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.update-notes-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 20px 0;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
|
||||||
|
.icon-box {
|
||||||
|
background: #fbfbfb; // Beige-ish white
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-right: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #8c7b6e;
|
||||||
|
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.03);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-box {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #333333;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666666;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
margin: 8px 0 0 18px;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
li {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666666;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-section {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
|
||||||
|
.progress-info-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-bg {
|
||||||
|
height: 6px;
|
||||||
|
background: #e0e0e0;
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.progress-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: #000000;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.btn-ignore {
|
||||||
|
background: transparent;
|
||||||
|
color: #666666;
|
||||||
|
border: 1px solid #d0d0d0;
|
||||||
|
padding: 16px 32px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-color: #999999;
|
||||||
|
color: #333333;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-update {
|
||||||
|
background: #000000;
|
||||||
|
color: #ffffff;
|
||||||
|
border: none;
|
||||||
|
padding: 16px 48px;
|
||||||
|
border-radius: 20px; // Pill shape
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
border: none;
|
||||||
|
color: #999;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
z-index: 10;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
color: #333;
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
transform: translateY(20px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
139
src/components/UpdateDialog.tsx
Normal file
139
src/components/UpdateDialog.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { Quote, X } from 'lucide-react'
|
||||||
|
import './UpdateDialog.scss'
|
||||||
|
|
||||||
|
interface UpdateInfo {
|
||||||
|
version?: string
|
||||||
|
releaseNotes?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateDialogProps {
|
||||||
|
open: boolean
|
||||||
|
updateInfo: UpdateInfo | null
|
||||||
|
onClose: () => void
|
||||||
|
onUpdate: () => void
|
||||||
|
onIgnore?: () => void
|
||||||
|
isDownloading: boolean
|
||||||
|
progress: number | {
|
||||||
|
percent: number
|
||||||
|
bytesPerSecond?: number
|
||||||
|
transferred?: number
|
||||||
|
total?: number
|
||||||
|
remaining?: number // seconds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const UpdateDialog: React.FC<UpdateDialogProps> = ({
|
||||||
|
open,
|
||||||
|
updateInfo,
|
||||||
|
onClose,
|
||||||
|
onUpdate,
|
||||||
|
onIgnore,
|
||||||
|
isDownloading,
|
||||||
|
progress
|
||||||
|
}) => {
|
||||||
|
if (!open || !updateInfo) return null
|
||||||
|
|
||||||
|
// Safe normalize progress
|
||||||
|
const safeProgress = typeof progress === 'number' ? { percent: progress } : (progress || { percent: 0 })
|
||||||
|
const percent = safeProgress.percent || 0
|
||||||
|
const bytesPerSecond = safeProgress.bytesPerSecond
|
||||||
|
const total = safeProgress.total
|
||||||
|
const transferred = safeProgress.transferred
|
||||||
|
const remaining = safeProgress.remaining
|
||||||
|
|
||||||
|
// Format bytes
|
||||||
|
const formatBytes = (bytes: number) => {
|
||||||
|
if (!Number.isFinite(bytes) || bytes === 0) return '0 B'
|
||||||
|
const k = 1024
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
const unitIndex = Math.max(0, Math.min(i, sizes.length - 1))
|
||||||
|
return parseFloat((bytes / Math.pow(k, unitIndex)).toFixed(1)) + ' ' + sizes[unitIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format speed
|
||||||
|
const formatSpeed = (bytesPerSecond: number) => {
|
||||||
|
return `${formatBytes(bytesPerSecond)}/s`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format time
|
||||||
|
const formatTime = (seconds: number) => {
|
||||||
|
if (!Number.isFinite(seconds)) return '计算中...'
|
||||||
|
if (seconds < 60) return `${Math.ceil(seconds)} 秒`
|
||||||
|
const minutes = Math.floor(seconds / 60)
|
||||||
|
const remainingSeconds = Math.ceil(seconds % 60)
|
||||||
|
return `${minutes} 分 ${remainingSeconds} 秒`
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="update-dialog-overlay">
|
||||||
|
<div className="update-dialog">
|
||||||
|
{!isDownloading && (
|
||||||
|
<button className="close-btn" onClick={onClose}>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="dialog-header">
|
||||||
|
<div className="version-tag">
|
||||||
|
新版本 {updateInfo.version}
|
||||||
|
</div>
|
||||||
|
<h2>欢迎体验全新的 WeFlow</h2>
|
||||||
|
<div className="subtitle">我们带来了一些改进</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dialog-content">
|
||||||
|
<div className="update-notes-container">
|
||||||
|
<div className="icon-box">
|
||||||
|
<Quote size={20} />
|
||||||
|
</div>
|
||||||
|
<div className="text-box">
|
||||||
|
<h3>优化</h3>
|
||||||
|
{updateInfo.releaseNotes ? (
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: updateInfo.releaseNotes }} />
|
||||||
|
) : (
|
||||||
|
<p>修复了一些已知问题,提升了稳定性。</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isDownloading ? (
|
||||||
|
<div className="progress-section">
|
||||||
|
<div className="progress-info-row">
|
||||||
|
<span>{bytesPerSecond ? formatSpeed(bytesPerSecond) : '下载中...'}</span>
|
||||||
|
<span>{total ? `${formatBytes(transferred || 0)} / ${formatBytes(total)}` : `${percent.toFixed(1)}%`}</span>
|
||||||
|
{remaining !== undefined && <span>剩余 {formatTime(remaining)}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="progress-bar-bg">
|
||||||
|
<div
|
||||||
|
className="progress-bar-fill"
|
||||||
|
style={{ width: `${percent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fallback status text if detailed info is missing */}
|
||||||
|
{(!bytesPerSecond && !total) && (
|
||||||
|
<div className="status-text">{percent.toFixed(0)}% 已下载</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="actions">
|
||||||
|
{onIgnore && (
|
||||||
|
<button className="btn-ignore" onClick={onIgnore}>
|
||||||
|
忽略本次更新
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className="btn-update" onClick={onUpdate}>
|
||||||
|
开启新旅程
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UpdateDialog
|
||||||
192
src/components/UpdateProgressCapsule.scss
Normal file
192
src/components/UpdateProgressCapsule.scss
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
.update-progress-capsule {
|
||||||
|
position: fixed;
|
||||||
|
top: 38px; // Just below title bar
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 9998;
|
||||||
|
cursor: pointer;
|
||||||
|
animation: capsuleSlideDown 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
.capsule-content {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.12);
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.capsule-content {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
padding: 8px 18px;
|
||||||
|
border-radius: 24px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.12);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
height: 40px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
|
||||||
|
.icon-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-primary);
|
||||||
|
|
||||||
|
.download-icon {
|
||||||
|
animation: capsulePulse 2s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
|
.percent-text {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speed-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-weight: 500;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
font-size: 15px;
|
||||||
|
color: #ff4d4f;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.available-text {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bg {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--primary);
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.capsule-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 4px;
|
||||||
|
margin-left: -4px;
|
||||||
|
margin-right: -8px;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.5;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// State Modifiers
|
||||||
|
&.state-available {
|
||||||
|
.capsule-content {
|
||||||
|
background: var(--primary);
|
||||||
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
.icon-wrapper {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-wrapper {
|
||||||
|
.available-text {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.capsule-close {
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.state-downloading {
|
||||||
|
.capsule-content {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.state-error {
|
||||||
|
.capsule-content {
|
||||||
|
background: #fff1f0;
|
||||||
|
border-color: #ffa39e;
|
||||||
|
|
||||||
|
.icon-wrapper {
|
||||||
|
color: #ff4d4f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-wrapper .error-text {
|
||||||
|
color: #cf1322;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capsule-close {
|
||||||
|
color: #cf1322;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes capsuleSlideDown {
|
||||||
|
from {
|
||||||
|
transform: translate(-50%, -40px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes capsulePulse {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
transform: translateY(2px);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
118
src/components/UpdateProgressCapsule.tsx
Normal file
118
src/components/UpdateProgressCapsule.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { useAppStore } from '../stores/appStore'
|
||||||
|
import { Download, X, AlertCircle, Info } from 'lucide-react'
|
||||||
|
import './UpdateProgressCapsule.scss'
|
||||||
|
|
||||||
|
const UpdateProgressCapsule: React.FC = () => {
|
||||||
|
const {
|
||||||
|
isDownloading,
|
||||||
|
downloadProgress,
|
||||||
|
showUpdateDialog,
|
||||||
|
setShowUpdateDialog,
|
||||||
|
updateInfo,
|
||||||
|
setUpdateInfo,
|
||||||
|
updateError,
|
||||||
|
setUpdateError
|
||||||
|
} = useAppStore()
|
||||||
|
|
||||||
|
// Control visibility
|
||||||
|
// If dialog is open, we usually hide the capsule UNLESS we want it as a mini-indicator
|
||||||
|
// For now, let's hide it if the dialog is open
|
||||||
|
if (showUpdateDialog) return null
|
||||||
|
|
||||||
|
// State mapping
|
||||||
|
const hasError = !!updateError
|
||||||
|
const hasUpdate = !!updateInfo && updateInfo.hasUpdate
|
||||||
|
|
||||||
|
if (!hasError && !isDownloading && !hasUpdate) return null
|
||||||
|
|
||||||
|
// Safe normalize progress
|
||||||
|
const safeProgress = typeof downloadProgress === 'number' ? { percent: downloadProgress } : (downloadProgress || { percent: 0 })
|
||||||
|
const percent = safeProgress.percent || 0
|
||||||
|
const bytesPerSecond = safeProgress.bytesPerSecond
|
||||||
|
|
||||||
|
const formatBytes = (bytes: number) => {
|
||||||
|
if (!Number.isFinite(bytes) || bytes === 0) return '0 B'
|
||||||
|
const k = 1024
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
const unitIndex = Math.max(0, Math.min(i, sizes.length - 1))
|
||||||
|
return parseFloat((bytes / Math.pow(k, unitIndex)).toFixed(1)) + ' ' + sizes[unitIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatSpeed = (bps: number) => {
|
||||||
|
return `${formatBytes(bps)}/s`
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (hasError) {
|
||||||
|
setUpdateError(null)
|
||||||
|
} else if (hasUpdate && !isDownloading) {
|
||||||
|
setUpdateInfo(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine appearance class and content
|
||||||
|
let capsuleClass = 'update-progress-capsule'
|
||||||
|
let content = null
|
||||||
|
|
||||||
|
if (hasError) {
|
||||||
|
capsuleClass += ' state-error'
|
||||||
|
content = (
|
||||||
|
<>
|
||||||
|
<div className="icon-wrapper">
|
||||||
|
<AlertCircle size={14} />
|
||||||
|
</div>
|
||||||
|
<div className="info-wrapper">
|
||||||
|
<span className="error-text">更新失败: {updateError}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
} else if (isDownloading) {
|
||||||
|
capsuleClass += ' state-downloading'
|
||||||
|
content = (
|
||||||
|
<>
|
||||||
|
<div className="icon-wrapper">
|
||||||
|
<Download size={14} className="download-icon" />
|
||||||
|
</div>
|
||||||
|
<div className="info-wrapper">
|
||||||
|
<span className="percent-text">{percent.toFixed(0)}%</span>
|
||||||
|
{bytesPerSecond > 0 && (
|
||||||
|
<span className="speed-text">{formatSpeed(bytesPerSecond)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="progress-bg">
|
||||||
|
<div className="progress-fill" style={{ width: `${percent}%` }} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
} else if (hasUpdate) {
|
||||||
|
capsuleClass += ' state-available'
|
||||||
|
content = (
|
||||||
|
<>
|
||||||
|
<div className="icon-wrapper">
|
||||||
|
<Info size={14} />
|
||||||
|
</div>
|
||||||
|
<div className="info-wrapper">
|
||||||
|
<span className="available-text">发现新版本 v{updateInfo?.version}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={capsuleClass} onClick={() => setShowUpdateDialog(true)}>
|
||||||
|
<div className="capsule-content">
|
||||||
|
{content}
|
||||||
|
{!isDownloading && (
|
||||||
|
<button className="capsule-close" onClick={handleClose}>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UpdateProgressCapsule
|
||||||
@@ -23,7 +23,7 @@ export const VoiceTranscribeDialog: React.FC<VoiceTranscribeDialogProps> = ({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeListener = window.electronAPI.whisper.onDownloadProgress((payload) => {
|
const removeListener = window.electronAPI.whisper.onDownloadProgress((payload: { modelName: string; downloadedBytes: number; totalBytes?: number; percent?: number }) => {
|
||||||
if (payload.percent !== undefined) {
|
if (payload.percent !== undefined) {
|
||||||
setDownloadProgress(payload.percent)
|
setDownloadProgress(payload.percent)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,40 +9,40 @@ function AgreementPage() {
|
|||||||
<div className="agreement-content">
|
<div className="agreement-content">
|
||||||
{/* 协议内容 - 请替换为完整的协议文本 */}
|
{/* 协议内容 - 请替换为完整的协议文本 */}
|
||||||
<h2>用户协议</h2>
|
<h2>用户协议</h2>
|
||||||
|
|
||||||
<h3>一、总则</h3>
|
<h3>一、总则</h3>
|
||||||
<p>欢迎使用WeFlow(WeFlow)软件。请在使用本软件前仔细阅读本协议。一旦您开始使用本软件,即表示您已充分理解并同意本协议的全部内容。</p>
|
<p>欢迎使用WeFlow(WeFlow)软件。请在使用本软件前仔细阅读本协议。一旦你开始使用本软件,即表示你已充分理解并同意本协议的全部内容。</p>
|
||||||
|
|
||||||
<h3>二、软件说明</h3>
|
<h3>二、软件说明</h3>
|
||||||
<p>WeFlow是一款本地化的微信聊天记录查看与分析工具,所有数据处理均在用户本地设备上完成。</p>
|
<p>WeFlow是一款本地化的微信聊天记录查看与分析工具,所有数据处理均在用户本地设备上完成。</p>
|
||||||
|
|
||||||
<h3>三、使用条款</h3>
|
<h3>三、使用条款</h3>
|
||||||
<p>1. 本软件仅供个人学习、研究使用,严禁用于任何商业用途或非法目的。</p>
|
<p>1. 本软件仅供个人学习、研究使用,严禁用于任何商业用途或非法目的。</p>
|
||||||
<p>2. 用户应确保所查看、分析的数据为本人所有或已获得合法授权。</p>
|
<p>2. 用户应确保所查看、分析的数据为本人所有或已获得合法授权。</p>
|
||||||
<p>3. 用户不得利用本软件侵犯他人隐私、窃取他人信息或从事其他违法活动。</p>
|
<p>3. 用户不得利用本软件侵犯他人隐私、窃取他人信息或从事其他违法活动。</p>
|
||||||
|
|
||||||
<h3>四、免责声明</h3>
|
<h3>四、免责声明</h3>
|
||||||
<p>1. 本软件按"现状"提供,开发者不对软件的适用性、可靠性、准确性作任何明示或暗示的保证。</p>
|
<p>1. 本软件按"现状"提供,开发者不对软件的适用性、可靠性、准确性作任何明示或暗示的保证。</p>
|
||||||
<p>2. 因使用或无法使用本软件而产生的任何直接、间接、偶然、特殊或后果性损害,开发者不承担任何责任。</p>
|
<p>2. 因使用或无法使用本软件而产生的任何直接、间接、偶然、特殊或后果性损害,开发者不承担任何责任。</p>
|
||||||
<p>3. 用户因违反本协议或相关法律法规而产生的一切后果由用户自行承担。</p>
|
<p>3. 用户因违反本协议或相关法律法规而产生的一切后果由用户自行承担。</p>
|
||||||
|
|
||||||
<h3>五、知识产权</h3>
|
<h3>五、知识产权</h3>
|
||||||
<p>本软件的所有权、知识产权及相关权益均归开发者所有。未经授权,不得复制、修改、传播本软件。</p>
|
<p>本软件的所有权、知识产权及相关权益均归开发者所有。未经授权,不得复制、修改、传播本软件。</p>
|
||||||
|
|
||||||
<h2>隐私政策</h2>
|
<h2>隐私政策</h2>
|
||||||
|
|
||||||
<h3>一、数据收集</h3>
|
<h3>一、数据收集</h3>
|
||||||
<p>本软件不收集、不上传、不存储任何用户个人信息或聊天数据。所有数据处理均在本地完成。</p>
|
<p>本软件不收集、不上传、不存储任何用户个人信息或聊天数据。所有数据处理均在本地完成。</p>
|
||||||
|
|
||||||
<h3>二、数据安全</h3>
|
<h3>二、数据安全</h3>
|
||||||
<p>您的聊天记录和个人数据完全存储在您的本地设备上,本软件不会将任何数据传输至外部服务器。</p>
|
<p>你的聊天记录和个人数据完全存储在你的本地设备上,本软件不会将任何数据传输至外部服务器。</p>
|
||||||
|
|
||||||
<h3>三、网络请求</h3>
|
<h3>三、网络请求</h3>
|
||||||
<p>本软件仅在检查更新时会访问更新服务器获取版本信息,不涉及任何用户数据的传输。</p>
|
<p>本软件仅在检查更新时会访问更新服务器获取版本信息,不涉及任何用户数据的传输。</p>
|
||||||
|
|
||||||
<h3>四、第三方服务</h3>
|
<h3>四、第三方服务</h3>
|
||||||
<p>本软件不集成任何第三方数据分析、广告或追踪服务。</p>
|
<p>本软件不集成任何第三方数据分析、广告或追踪服务。</p>
|
||||||
|
|
||||||
<p className="agreement-footer-text">最后更新日期:2025年1月</p>
|
<p className="agreement-footer-text">最后更新日期:2025年1月</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -45,6 +45,30 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.error-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
@@ -292,4 +316,215 @@
|
|||||||
grid-column: span 1;
|
grid-column: span 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 排除好友弹窗
|
||||||
|
.exclude-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.exclude-modal {
|
||||||
|
width: 560px;
|
||||||
|
max-width: calc(100vw - 48px);
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
|
||||||
|
|
||||||
|
.exclude-modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: all 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.exclude-modal-search {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
|
||||||
|
input {
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-search {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
padding: 2px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.exclude-modal-body {
|
||||||
|
max-height: 420px;
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exclude-loading,
|
||||||
|
.exclude-error,
|
||||||
|
.exclude-empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 24px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exclude-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exclude-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: all 0.15s;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: rgba(7, 193, 96, 0.4);
|
||||||
|
background: rgba(7, 193, 96, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.exclude-avatar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exclude-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exclude-name {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exclude-username {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exclude-modal-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exclude-footer-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exclude-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-text {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--primary);
|
||||||
|
background: var(--primary-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.exclude-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,21 +1,51 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
import { Users, Clock, MessageSquare, Send, Inbox, Calendar, Loader2, RefreshCw, User, Medal } from 'lucide-react'
|
import { Users, Clock, MessageSquare, Send, Inbox, Calendar, Loader2, RefreshCw, Medal, UserMinus, Search, X } from 'lucide-react'
|
||||||
import ReactECharts from 'echarts-for-react'
|
import ReactECharts from 'echarts-for-react'
|
||||||
import { useAnalyticsStore } from '../stores/analyticsStore'
|
import { useAnalyticsStore } from '../stores/analyticsStore'
|
||||||
import { useThemeStore } from '../stores/themeStore'
|
import { useThemeStore } from '../stores/themeStore'
|
||||||
import './AnalyticsPage.scss'
|
import './AnalyticsPage.scss'
|
||||||
import './DataManagementPage.scss'
|
|
||||||
import { Avatar } from '../components/Avatar'
|
import { Avatar } from '../components/Avatar'
|
||||||
|
|
||||||
|
interface ExcludeCandidate {
|
||||||
|
username: string
|
||||||
|
displayName: string
|
||||||
|
avatarUrl?: string
|
||||||
|
wechatId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeUsername = (value: string) => value.trim().toLowerCase()
|
||||||
|
|
||||||
function AnalyticsPage() {
|
function AnalyticsPage() {
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [loadingStatus, setLoadingStatus] = useState('')
|
const [loadingStatus, setLoadingStatus] = useState('')
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [progress, setProgress] = useState(0)
|
const [progress, setProgress] = useState(0)
|
||||||
|
const [isExcludeDialogOpen, setIsExcludeDialogOpen] = useState(false)
|
||||||
|
const [excludeCandidates, setExcludeCandidates] = useState<ExcludeCandidate[]>([])
|
||||||
|
const [excludeQuery, setExcludeQuery] = useState('')
|
||||||
|
const [excludeLoading, setExcludeLoading] = useState(false)
|
||||||
|
const [excludeError, setExcludeError] = useState<string | null>(null)
|
||||||
|
const [excludedUsernames, setExcludedUsernames] = useState<Set<string>>(new Set())
|
||||||
|
const [draftExcluded, setDraftExcluded] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
const themeMode = useThemeStore((state) => state.themeMode)
|
const themeMode = useThemeStore((state) => state.themeMode)
|
||||||
const { statistics, rankings, timeDistribution, isLoaded, setStatistics, setRankings, setTimeDistribution, markLoaded } = useAnalyticsStore()
|
const { statistics, rankings, timeDistribution, isLoaded, setStatistics, setRankings, setTimeDistribution, markLoaded, clearCache } = useAnalyticsStore()
|
||||||
|
|
||||||
|
const loadExcludedUsernames = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.analytics.getExcludedUsernames()
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setExcludedUsernames(new Set(result.data.map(normalizeUsername)))
|
||||||
|
} else {
|
||||||
|
setExcludedUsernames(new Set())
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('加载排除名单失败', e)
|
||||||
|
setExcludedUsernames(new Set())
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const loadData = useCallback(async (forceRefresh = false) => {
|
const loadData = useCallback(async (forceRefresh = false) => {
|
||||||
if (isLoaded && !forceRefresh) return
|
if (isLoaded && !forceRefresh) return
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
@@ -66,13 +96,117 @@ function AnalyticsPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleChange = () => {
|
const handleChange = () => {
|
||||||
|
loadExcludedUsernames()
|
||||||
loadData(true)
|
loadData(true)
|
||||||
}
|
}
|
||||||
window.addEventListener('wxid-changed', handleChange as EventListener)
|
window.addEventListener('wxid-changed', handleChange as EventListener)
|
||||||
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
|
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
|
||||||
}, [loadData])
|
}, [loadData, loadExcludedUsernames])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadExcludedUsernames()
|
||||||
|
}, [loadExcludedUsernames])
|
||||||
|
|
||||||
const handleRefresh = () => loadData(true)
|
const handleRefresh = () => loadData(true)
|
||||||
|
const isNoSessionError = error?.includes('未找到消息会话') ?? false
|
||||||
|
|
||||||
|
const loadExcludeCandidates = useCallback(async () => {
|
||||||
|
setExcludeLoading(true)
|
||||||
|
setExcludeError(null)
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.analytics.getExcludeCandidates()
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setExcludeCandidates(result.data)
|
||||||
|
} else {
|
||||||
|
setExcludeError(result.error || '加载好友列表失败')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setExcludeError(String(e))
|
||||||
|
} finally {
|
||||||
|
setExcludeLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const openExcludeDialog = async () => {
|
||||||
|
setExcludeQuery('')
|
||||||
|
setDraftExcluded(new Set(excludedUsernames))
|
||||||
|
setIsExcludeDialogOpen(true)
|
||||||
|
await loadExcludeCandidates()
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleExcluded = (username: string) => {
|
||||||
|
const key = normalizeUsername(username)
|
||||||
|
setDraftExcluded((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(key)) {
|
||||||
|
next.delete(key)
|
||||||
|
} else {
|
||||||
|
next.add(key)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleInvertSelection = () => {
|
||||||
|
setDraftExcluded((prev) => {
|
||||||
|
const allUsernames = new Set(excludeCandidates.map(c => normalizeUsername(c.username)))
|
||||||
|
const inverted = new Set<string>()
|
||||||
|
for (const u of allUsernames) {
|
||||||
|
if (!prev.has(u)) inverted.add(u)
|
||||||
|
}
|
||||||
|
return inverted
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleApplyExcluded = async () => {
|
||||||
|
const payload = Array.from(draftExcluded)
|
||||||
|
setIsExcludeDialogOpen(false)
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.analytics.setExcludedUsernames(payload)
|
||||||
|
if (!result.success) {
|
||||||
|
alert(result.error || '更新排除名单失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setExcludedUsernames(new Set((result.data || payload).map(normalizeUsername)))
|
||||||
|
clearCache()
|
||||||
|
await window.electronAPI.cache.clearAnalytics()
|
||||||
|
await loadData(true)
|
||||||
|
} catch (e) {
|
||||||
|
alert(`更新排除名单失败:${String(e)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResetExcluded = async () => {
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.analytics.setExcludedUsernames([])
|
||||||
|
if (!result.success) {
|
||||||
|
setError(result.error || '重置排除好友失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setExcludedUsernames(new Set())
|
||||||
|
setDraftExcluded(new Set())
|
||||||
|
clearCache()
|
||||||
|
await window.electronAPI.cache.clearAnalytics()
|
||||||
|
await loadData(true)
|
||||||
|
} catch (e) {
|
||||||
|
setError(`重置排除好友失败: ${String(e)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleExcludeCandidates = excludeCandidates
|
||||||
|
.filter((candidate) => {
|
||||||
|
const query = excludeQuery.trim().toLowerCase()
|
||||||
|
if (!query) return true
|
||||||
|
const wechatId = candidate.wechatId || ''
|
||||||
|
const haystack = `${candidate.displayName} ${candidate.username} ${wechatId}`.toLowerCase()
|
||||||
|
return haystack.includes(query)
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aSelected = draftExcluded.has(normalizeUsername(a.username))
|
||||||
|
const bSelected = draftExcluded.has(normalizeUsername(b.username))
|
||||||
|
if (aSelected !== bSelected) return aSelected ? -1 : 1
|
||||||
|
return a.displayName.localeCompare(b.displayName, 'zh')
|
||||||
|
})
|
||||||
|
|
||||||
const formatDate = (timestamp: number | null) => {
|
const formatDate = (timestamp: number | null) => {
|
||||||
if (!timestamp) return '-'
|
if (!timestamp) return '-'
|
||||||
@@ -239,6 +373,22 @@ function AnalyticsPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (error && !isLoaded && isNoSessionError && excludedUsernames.size > 0) {
|
||||||
|
return (
|
||||||
|
<div className="error-container">
|
||||||
|
<p>{error}</p>
|
||||||
|
<div className="error-actions">
|
||||||
|
<button className="btn btn-secondary" onClick={handleResetExcluded}>
|
||||||
|
重置排除好友
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-primary" onClick={() => loadData(true)}>
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (error && !isLoaded) {
|
if (error && !isLoaded) {
|
||||||
return (<div className="error-container"><p>{error}</p><button className="btn btn-primary" onClick={() => loadData(true)}>重试</button></div>)
|
return (<div className="error-container"><p>{error}</p><button className="btn btn-primary" onClick={() => loadData(true)}>重试</button></div>)
|
||||||
}
|
}
|
||||||
@@ -248,10 +398,16 @@ function AnalyticsPage() {
|
|||||||
<>
|
<>
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<h1>私聊分析</h1>
|
<h1>私聊分析</h1>
|
||||||
<button className="btn btn-secondary" onClick={handleRefresh} disabled={isLoading}>
|
<div className="header-actions">
|
||||||
<RefreshCw size={16} className={isLoading ? 'spin' : ''} />
|
<button className="btn btn-secondary" onClick={handleRefresh} disabled={isLoading}>
|
||||||
{isLoading ? '刷新中...' : '刷新'}
|
<RefreshCw size={16} className={isLoading ? 'spin' : ''} />
|
||||||
</button>
|
{isLoading ? '刷新中...' : '刷新'}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary" onClick={openExcludeDialog}>
|
||||||
|
<UserMinus size={16} />
|
||||||
|
排除好友{excludedUsernames.size > 0 ? ` (${excludedUsernames.size})` : ''}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="page-scroll">
|
<div className="page-scroll">
|
||||||
<section className="page-section">
|
<section className="page-section">
|
||||||
@@ -317,6 +473,89 @@ function AnalyticsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
{isExcludeDialogOpen && (
|
||||||
|
<div className="exclude-modal-overlay" onClick={() => setIsExcludeDialogOpen(false)}>
|
||||||
|
<div className="exclude-modal" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="exclude-modal-header">
|
||||||
|
<h3>选择不统计的好友</h3>
|
||||||
|
<button className="modal-close" onClick={() => setIsExcludeDialogOpen(false)}>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="exclude-modal-search">
|
||||||
|
<Search size={16} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索好友"
|
||||||
|
value={excludeQuery}
|
||||||
|
onChange={e => setExcludeQuery(e.target.value)}
|
||||||
|
disabled={excludeLoading}
|
||||||
|
/>
|
||||||
|
{excludeQuery && (
|
||||||
|
<button className="clear-search" onClick={() => setExcludeQuery('')}>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="exclude-modal-body">
|
||||||
|
{excludeLoading && (
|
||||||
|
<div className="exclude-loading">
|
||||||
|
<Loader2 size={20} className="spin" />
|
||||||
|
<span>正在加载好友列表...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!excludeLoading && excludeError && (
|
||||||
|
<div className="exclude-error">{excludeError}</div>
|
||||||
|
)}
|
||||||
|
{!excludeLoading && !excludeError && (
|
||||||
|
<div className="exclude-list">
|
||||||
|
{visibleExcludeCandidates.map((candidate) => {
|
||||||
|
const isChecked = draftExcluded.has(normalizeUsername(candidate.username))
|
||||||
|
const wechatId = candidate.wechatId?.trim() || candidate.username
|
||||||
|
return (
|
||||||
|
<label key={candidate.username} className={`exclude-item ${isChecked ? 'active' : ''}`}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isChecked}
|
||||||
|
onChange={() => toggleExcluded(candidate.username)}
|
||||||
|
/>
|
||||||
|
<div className="exclude-avatar">
|
||||||
|
<Avatar src={candidate.avatarUrl} name={candidate.displayName} size={32} />
|
||||||
|
</div>
|
||||||
|
<div className="exclude-info">
|
||||||
|
<span className="exclude-name">{candidate.displayName}</span>
|
||||||
|
<span className="exclude-username">{wechatId}</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{visibleExcludeCandidates.length === 0 && (
|
||||||
|
<div className="exclude-empty">
|
||||||
|
{excludeQuery.trim() ? '未找到匹配好友' : '暂无可选好友'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="exclude-modal-footer">
|
||||||
|
<div className="exclude-footer-left">
|
||||||
|
<span className="exclude-count">已排除 {draftExcluded.size} 人</span>
|
||||||
|
<button className="btn btn-text" onClick={toggleInvertSelection} disabled={excludeLoading}>
|
||||||
|
反选
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="exclude-actions">
|
||||||
|
<button className="btn btn-secondary" onClick={() => setIsExcludeDialogOpen(false)}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-primary" onClick={handleApplyExcluded} disabled={excludeLoading}>
|
||||||
|
应用
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ function AnalyticsWelcomePage() {
|
|||||||
</div>
|
</div>
|
||||||
<h1>私聊数据分析</h1>
|
<h1>私聊数据分析</h1>
|
||||||
<p>
|
<p>
|
||||||
WeFlow 可以分析您的聊天记录,生成详细的统计报表。<br />
|
WeFlow 可以分析你的聊天记录,生成详细的统计报表。<br />
|
||||||
您可以选择加载上次的分析结果(速度快),或者开始新的分析(数据最新)。
|
你可以选择加载上次的分析结果(速度快),或者开始新的分析(数据最新)。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="action-cards">
|
<div className="action-cards">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
padding: 40px 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-icon {
|
.header-icon {
|
||||||
@@ -25,6 +26,113 @@
|
|||||||
margin: 0 0 48px;
|
margin: 0 0 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page-desc.load-summary {
|
||||||
|
margin: 0 0 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-desc.load-summary.complete {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-telemetry {
|
||||||
|
width: min(760px, 100%);
|
||||||
|
padding: 12px 14px;
|
||||||
|
margin: 0 0 28px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--card-bg) 92%, transparent);
|
||||||
|
text-align: left;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-telemetry.loading {
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 30%, var(--border-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-telemetry.complete {
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 40%, var(--border-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-telemetry.compact {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
width: min(560px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-sections {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 32px;
|
||||||
|
width: min(760px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-section {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 28px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-desc {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||||
|
color: var(--primary);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--primary) 30%, transparent);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-hint {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.year-grid-with-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
.year-grid {
|
.year-grid {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -34,6 +142,44 @@
|
|||||||
margin-bottom: 48px;
|
margin-bottom: 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.report-section .year-grid {
|
||||||
|
justify-content: flex-start;
|
||||||
|
max-width: none;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.year-grid-with-status .year-grid {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.year-load-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-top: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.year-load-status.complete {
|
||||||
|
color: color-mix(in srgb, var(--primary) 80%, var(--text-secondary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot-ellipsis {
|
||||||
|
display: inline-block;
|
||||||
|
width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
vertical-align: bottom;
|
||||||
|
animation: dot-ellipsis 1.2s steps(4, end) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.year-load-status.complete .dot-ellipsis,
|
||||||
|
.page-desc.load-summary.complete .dot-ellipsis {
|
||||||
|
animation: none;
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.year-card {
|
.year-card {
|
||||||
width: 120px;
|
width: 120px;
|
||||||
height: 100px;
|
height: 100px;
|
||||||
@@ -104,6 +250,13 @@
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.secondary {
|
||||||
|
background: var(--card-bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.spin {
|
.spin {
|
||||||
@@ -114,3 +267,7 @@
|
|||||||
from { transform: rotate(0deg); }
|
from { transform: rotate(0deg); }
|
||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes dot-ellipsis {
|
||||||
|
to { width: 1.4em; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,44 +1,156 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Calendar, Loader2, Sparkles } from 'lucide-react'
|
import { Calendar, Loader2, Sparkles, Users } from 'lucide-react'
|
||||||
import './AnnualReportPage.scss'
|
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() {
|
function AnnualReportPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [availableYears, setAvailableYears] = useState<number[]>([])
|
const [availableYears, setAvailableYears] = useState<number[]>([])
|
||||||
const [selectedYear, setSelectedYear] = useState<number | null>(null)
|
const [selectedYear, setSelectedYear] = useState<YearOption | null>(null)
|
||||||
|
const [selectedPairYear, setSelectedPairYear] = useState<YearOption | null>(null)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [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 [isGenerating, setIsGenerating] = useState(false)
|
||||||
const [loadError, setLoadError] = useState<string | null>(null)
|
const [loadError, setLoadError] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadAvailableYears()
|
let disposed = false
|
||||||
|
let taskId = ''
|
||||||
|
|
||||||
|
const applyLoadPayload = (payload: YearsLoadPayload) => {
|
||||||
|
if (payload.strategy) setLoadStrategy(payload.strategy)
|
||||||
|
if (payload.phase) setLoadPhase(payload.phase)
|
||||||
|
if (typeof payload.statusText === 'string' && payload.statusText) setLoadStatusText(payload.statusText)
|
||||||
|
if (typeof payload.nativeElapsedMs === 'number' && Number.isFinite(payload.nativeElapsedMs)) {
|
||||||
|
setNativeElapsedMs(Math.max(0, payload.nativeElapsedMs))
|
||||||
|
}
|
||||||
|
if (typeof payload.scanElapsedMs === 'number' && Number.isFinite(payload.scanElapsedMs)) {
|
||||||
|
setScanElapsedMs(Math.max(0, payload.scanElapsedMs))
|
||||||
|
}
|
||||||
|
if (typeof payload.totalElapsedMs === 'number' && Number.isFinite(payload.totalElapsedMs)) {
|
||||||
|
setTotalElapsedMs(Math.max(0, payload.totalElapsedMs))
|
||||||
|
}
|
||||||
|
if (typeof payload.switched === 'boolean') setHasSwitchedStrategy(payload.switched)
|
||||||
|
if (typeof payload.nativeTimedOut === 'boolean') setNativeTimedOut(payload.nativeTimedOut)
|
||||||
|
|
||||||
|
const years = Array.isArray(payload.years) ? payload.years : []
|
||||||
|
if (years.length > 0) {
|
||||||
|
setAvailableYears(years)
|
||||||
|
setSelectedYear((prev) => {
|
||||||
|
if (prev === 'all') return prev
|
||||||
|
if (typeof prev === 'number' && years.includes(prev)) return prev
|
||||||
|
return years[0]
|
||||||
|
})
|
||||||
|
setSelectedPairYear((prev) => {
|
||||||
|
if (prev === 'all') return prev
|
||||||
|
if (typeof prev === 'number' && years.includes(prev)) return prev
|
||||||
|
return years[0]
|
||||||
|
})
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.error && !payload.canceled) {
|
||||||
|
setLoadError(payload.error || '加载年度数据失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.done) {
|
||||||
|
setIsLoading(false)
|
||||||
|
setIsLoadingMoreYears(false)
|
||||||
|
setHasYearsLoadFinished(true)
|
||||||
|
setLoadPhase('done')
|
||||||
|
} else {
|
||||||
|
setIsLoadingMoreYears(true)
|
||||||
|
setHasYearsLoadFinished(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopListen = window.electronAPI.annualReport.onAvailableYearsProgress((payload) => {
|
||||||
|
if (disposed) return
|
||||||
|
if (taskId && payload.taskId !== taskId) return
|
||||||
|
if (!taskId) taskId = payload.taskId
|
||||||
|
applyLoadPayload(payload)
|
||||||
|
})
|
||||||
|
|
||||||
|
const startLoad = async () => {
|
||||||
|
setIsLoading(true)
|
||||||
|
setIsLoadingMoreYears(true)
|
||||||
|
setHasYearsLoadFinished(false)
|
||||||
|
setLoadStrategy('native')
|
||||||
|
setLoadPhase('native')
|
||||||
|
setLoadStatusText('准备使用原生快速模式加载年份...')
|
||||||
|
setNativeElapsedMs(0)
|
||||||
|
setScanElapsedMs(0)
|
||||||
|
setTotalElapsedMs(0)
|
||||||
|
setHasSwitchedStrategy(false)
|
||||||
|
setNativeTimedOut(false)
|
||||||
|
setLoadError(null)
|
||||||
|
try {
|
||||||
|
const startResult = await window.electronAPI.annualReport.startAvailableYearsLoad()
|
||||||
|
if (!startResult.success || !startResult.taskId) {
|
||||||
|
setLoadError(startResult.error || '加载年度数据失败')
|
||||||
|
setIsLoading(false)
|
||||||
|
setIsLoadingMoreYears(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
taskId = startResult.taskId
|
||||||
|
if (startResult.snapshot) {
|
||||||
|
applyLoadPayload(startResult.snapshot)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
setLoadError(String(e))
|
||||||
|
setIsLoading(false)
|
||||||
|
setIsLoadingMoreYears(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void startLoad()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true
|
||||||
|
stopListen()
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const loadAvailableYears = async () => {
|
|
||||||
setIsLoading(true)
|
|
||||||
setLoadError(null)
|
|
||||||
try {
|
|
||||||
const result = await window.electronAPI.annualReport.getAvailableYears()
|
|
||||||
if (result.success && result.data && result.data.length > 0) {
|
|
||||||
setAvailableYears(result.data)
|
|
||||||
setSelectedYear(result.data[0])
|
|
||||||
} else if (!result.success) {
|
|
||||||
setLoadError(result.error || '加载年度数据失败')
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
setLoadError(String(e))
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleGenerateReport = async () => {
|
const handleGenerateReport = async () => {
|
||||||
if (!selectedYear) return
|
if (selectedYear === null) return
|
||||||
setIsGenerating(true)
|
setIsGenerating(true)
|
||||||
try {
|
try {
|
||||||
navigate(`/annual-report/view?year=${selectedYear}`)
|
const yearParam = selectedYear === 'all' ? 0 : selectedYear
|
||||||
|
navigate(`/annual-report/view?year=${yearParam}`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('生成报告失败:', e)
|
console.error('生成报告失败:', e)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -46,16 +158,31 @@ function AnnualReportPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
const handleGenerateDualReport = () => {
|
||||||
|
if (selectedPairYear === null) return
|
||||||
|
const yearParam = selectedPairYear === 'all' ? 0 : selectedPairYear
|
||||||
|
navigate(`/dual-report?year=${yearParam}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading && availableYears.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="annual-report-page">
|
<div className="annual-report-page">
|
||||||
<Loader2 size={32} className="spin" style={{ color: 'var(--text-tertiary)' }} />
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (availableYears.length === 0) {
|
if (availableYears.length === 0 && !isLoadingMoreYears) {
|
||||||
return (
|
return (
|
||||||
<div className="annual-report-page">
|
<div className="annual-report-page">
|
||||||
<Calendar size={64} style={{ color: 'var(--text-tertiary)', opacity: 0.5 }} />
|
<Calendar size={64} style={{ color: 'var(--text-tertiary)', opacity: 0.5 }} />
|
||||||
@@ -67,44 +194,164 @@ function AnnualReportPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const yearOptions: YearOption[] = availableYears.length > 0
|
||||||
|
? ['all', ...availableYears]
|
||||||
|
: []
|
||||||
|
|
||||||
|
const getYearLabel = (value: YearOption | null) => {
|
||||||
|
if (!value) return ''
|
||||||
|
return value === 'all' ? '全部时间' : `${value} 年`
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadedYearCount = availableYears.length
|
||||||
|
const isYearStatusComplete = hasYearsLoadFinished
|
||||||
|
const strategyLabel = getStrategyLabel({ loadStrategy, loadPhase, hasYearsLoadFinished, hasSwitchedStrategy, nativeTimedOut })
|
||||||
|
const renderYearLoadStatus = () => (
|
||||||
|
<div className={`year-load-status ${isYearStatusComplete ? 'complete' : 'loading'}`}>
|
||||||
|
{isYearStatusComplete ? (
|
||||||
|
<>全部年份已加载完毕</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
更多年份加载中<span className="dot-ellipsis" aria-hidden="true">...</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="annual-report-page">
|
<div className="annual-report-page">
|
||||||
<Sparkles size={32} className="header-icon" />
|
<Sparkles size={32} className="header-icon" />
|
||||||
<h1 className="page-title">年度报告</h1>
|
<h1 className="page-title">年度报告</h1>
|
||||||
<p className="page-desc">选择年份,生成你的微信聊天年度回顾</p>
|
<p className="page-desc">选择年份,回顾你在微信里的点点滴滴</p>
|
||||||
|
{loadedYearCount > 0 && (
|
||||||
<div className="year-grid">
|
<p className={`page-desc load-summary ${isYearStatusComplete ? 'complete' : 'loading'}`}>
|
||||||
{availableYears.map(year => (
|
{isYearStatusComplete ? (
|
||||||
<div
|
<>已显示 {loadedYearCount} 个年份,年份数据已全部加载完毕。总耗时 {formatLoadElapsed(totalElapsedMs)}</>
|
||||||
key={year}
|
) : (
|
||||||
className={`year-card ${selectedYear === year ? 'selected' : ''}`}
|
<>
|
||||||
onClick={() => setSelectedYear(year)}
|
已显示 {loadedYearCount} 个年份,正在补充更多年份<span className="dot-ellipsis" aria-hidden="true">...</span>
|
||||||
>
|
(已耗时 {formatLoadElapsed(totalElapsedMs)})
|
||||||
<span className="year-number">{year}</span>
|
</>
|
||||||
<span className="year-label">年</span>
|
)}
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<button
|
<div className="report-sections">
|
||||||
className="generate-btn"
|
<section className="report-section">
|
||||||
onClick={handleGenerateReport}
|
<div className="section-header">
|
||||||
disabled={!selectedYear || isGenerating}
|
<div>
|
||||||
>
|
<h2 className="section-title">总年度报告</h2>
|
||||||
{isGenerating ? (
|
<p className="section-desc">包含所有会话与消息</p>
|
||||||
<>
|
</div>
|
||||||
<Loader2 size={20} className="spin" />
|
</div>
|
||||||
<span>正在生成...</span>
|
|
||||||
</>
|
<div className="year-grid-with-status">
|
||||||
) : (
|
<div className="year-grid">
|
||||||
<>
|
{yearOptions.map(option => (
|
||||||
<Sparkles size={20} />
|
<div
|
||||||
<span>生成 {selectedYear} 年度报告</span>
|
key={option}
|
||||||
</>
|
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedYear === option ? 'selected' : ''}`}
|
||||||
)}
|
onClick={() => setSelectedYear(option)}
|
||||||
</button>
|
>
|
||||||
|
<span className="year-number">{option === 'all' ? '全部' : option}</span>
|
||||||
|
<span className="year-label">{option === 'all' ? '时间' : '年'}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{renderYearLoadStatus()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="generate-btn"
|
||||||
|
onClick={handleGenerateReport}
|
||||||
|
disabled={!selectedYear || isGenerating}
|
||||||
|
>
|
||||||
|
{isGenerating ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={20} className="spin" />
|
||||||
|
<span>正在生成...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Sparkles size={20} />
|
||||||
|
<span>生成 {getYearLabel(selectedYear)} 年度报告</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="report-section">
|
||||||
|
<div className="section-header">
|
||||||
|
<div>
|
||||||
|
<h2 className="section-title">双人年度报告</h2>
|
||||||
|
<p className="section-desc">选择一位好友,只看你们的私聊</p>
|
||||||
|
</div>
|
||||||
|
<div className="section-badge">
|
||||||
|
<Users size={16} />
|
||||||
|
<span>私聊</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="year-grid-with-status">
|
||||||
|
<div className="year-grid">
|
||||||
|
{yearOptions.map(option => (
|
||||||
|
<div
|
||||||
|
key={`pair-${option}`}
|
||||||
|
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedPairYear === option ? 'selected' : ''}`}
|
||||||
|
onClick={() => setSelectedPairYear(option)}
|
||||||
|
>
|
||||||
|
<span className="year-number">{option === 'all' ? '全部' : option}</span>
|
||||||
|
<span className="year-label">{option === 'all' ? '时间' : '年'}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{renderYearLoadStatus()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="generate-btn secondary"
|
||||||
|
onClick={handleGenerateDualReport}
|
||||||
|
disabled={!selectedPairYear}
|
||||||
|
>
|
||||||
|
<Users size={20} />
|
||||||
|
<span>选择好友并生成报告</span>
|
||||||
|
</button>
|
||||||
|
<p className="section-hint">从聊天排行中选择好友生成双人报告</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getStrategyLabel(params: {
|
||||||
|
loadStrategy: 'cache' | 'native' | 'hybrid'
|
||||||
|
loadPhase: 'cache' | 'native' | 'scan' | 'done'
|
||||||
|
hasYearsLoadFinished: boolean
|
||||||
|
hasSwitchedStrategy: boolean
|
||||||
|
nativeTimedOut: boolean
|
||||||
|
}): string {
|
||||||
|
const { loadStrategy, loadPhase, hasYearsLoadFinished, hasSwitchedStrategy, nativeTimedOut } = params
|
||||||
|
if (loadStrategy === 'cache') return '缓存模式(快速)'
|
||||||
|
if (hasYearsLoadFinished) {
|
||||||
|
if (loadStrategy === 'native') return '原生快速模式'
|
||||||
|
if (hasSwitchedStrategy || nativeTimedOut) return '混合策略(原生→扫表)'
|
||||||
|
return '扫表兼容模式'
|
||||||
|
}
|
||||||
|
if (loadPhase === 'native') return '原生快速模式(优先)'
|
||||||
|
if (loadPhase === 'scan') return '扫表兼容模式(回退)'
|
||||||
|
return '混合策略'
|
||||||
|
}
|
||||||
|
|
||||||
export default AnnualReportPage
|
export default AnnualReportPage
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
.annual-report-window {
|
.annual-report-window {
|
||||||
// 使用全局主题变量,带回退值
|
// 使用全局主题变量,带回退值
|
||||||
--ar-primary: var(--primary, #07C160);
|
--ar-primary: var(--primary, #07C160);
|
||||||
|
--ar-primary-rgb: var(--primary-rgb, 7, 193, 96);
|
||||||
--ar-accent: var(--accent, #F2AA00);
|
--ar-accent: var(--accent, #F2AA00);
|
||||||
|
--ar-accent-rgb: 242, 170, 0;
|
||||||
--ar-text-main: var(--text-primary, #222222);
|
--ar-text-main: var(--text-primary, #222222);
|
||||||
--ar-text-sub: var(--text-secondary, #555555);
|
--ar-text-sub: var(--text-secondary, #555555);
|
||||||
--ar-bg-color: var(--bg-primary, #F9F8F6);
|
--ar-bg-color: var(--bg-primary, #F9F8F6);
|
||||||
@@ -43,7 +45,7 @@
|
|||||||
|
|
||||||
// 背景装饰圆点 - 毛玻璃效果
|
// 背景装饰圆点 - 毛玻璃效果
|
||||||
.bg-decoration {
|
.bg-decoration {
|
||||||
position: absolute; // Changed from fixed
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
@@ -53,10 +55,10 @@
|
|||||||
.deco-circle {
|
.deco-circle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: rgba(0, 0, 0, 0.03);
|
background: rgba(var(--ar-primary-rgb), 0.03);
|
||||||
backdrop-filter: blur(40px);
|
backdrop-filter: blur(40px);
|
||||||
-webkit-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 {
|
&.c1 {
|
||||||
width: 280px;
|
width: 280px;
|
||||||
@@ -243,6 +245,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.exporting-snapshot {
|
.exporting-snapshot {
|
||||||
|
|
||||||
.hero-title,
|
.hero-title,
|
||||||
.label-text,
|
.label-text,
|
||||||
.hero-desc,
|
.hero-desc,
|
||||||
@@ -253,6 +256,11 @@
|
|||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.deco-circle {
|
||||||
|
background: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.section {
|
.section {
|
||||||
@@ -1279,3 +1287,135 @@
|
|||||||
color: var(--ar-text-sub) !important;
|
color: var(--ar-text-sub) !important;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 曾经的好朋友 视觉效果
|
||||||
|
.lost-friend-visual {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 32px;
|
||||||
|
margin: 64px auto 48px;
|
||||||
|
position: relative;
|
||||||
|
max-width: 480px;
|
||||||
|
|
||||||
|
.avatar-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
z-index: 2;
|
||||||
|
|
||||||
|
.avatar-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--ar-text-sub);
|
||||||
|
font-weight: 500;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.sender {
|
||||||
|
animation: fadeInRight 1s ease-out backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.receiver {
|
||||||
|
animation: fadeInLeft 1s ease-out backwards;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fading-line {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
height: 2px;
|
||||||
|
min-width: 120px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.line-path {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(to right,
|
||||||
|
var(--ar-primary) 0%,
|
||||||
|
rgba(var(--ar-primary-rgb), 0.4) 50%,
|
||||||
|
rgba(var(--ar-primary-rgb), 0.05) 100%);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-glow {
|
||||||
|
position: absolute;
|
||||||
|
inset: -4px 0;
|
||||||
|
background: linear-gradient(to right,
|
||||||
|
rgba(var(--ar-primary-rgb), 0.2) 0%,
|
||||||
|
transparent 100%);
|
||||||
|
filter: blur(8px);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-particle {
|
||||||
|
position: absolute;
|
||||||
|
width: 40px;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(to right, transparent, var(--ar-primary), transparent);
|
||||||
|
border-radius: 2px;
|
||||||
|
opacity: 0;
|
||||||
|
animation: flowAcross 4s infinite linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-desc.fading {
|
||||||
|
opacity: 0.7;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-top: 32px;
|
||||||
|
line-height: 1.8;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
animation: fadeIn 1.5s ease-out 0.5s backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes flowAcross {
|
||||||
|
0% {
|
||||||
|
left: -20%;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
10% {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
90% {
|
||||||
|
opacity: 0.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
left: 120%;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInRight {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInLeft {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,6 +71,20 @@ interface AnnualReportData {
|
|||||||
socialInitiative?: { initiatedChats: number; receivedChats: number; initiativeRate: number } | null
|
socialInitiative?: { initiatedChats: number; receivedChats: number; initiativeRate: number } | null
|
||||||
responseSpeed?: { avgResponseTime: number; fastestFriend: string; fastestTime: number } | null
|
responseSpeed?: { avgResponseTime: number; fastestFriend: string; fastestTime: number } | null
|
||||||
topPhrases?: { phrase: string; count: number }[]
|
topPhrases?: { phrase: string; count: number }[]
|
||||||
|
snsStats?: {
|
||||||
|
totalPosts: number
|
||||||
|
typeCounts?: Record<string, number>
|
||||||
|
topLikers: { username: string; displayName: string; avatarUrl?: string; count: number }[]
|
||||||
|
topLiked: { username: string; displayName: string; avatarUrl?: string; count: number }[]
|
||||||
|
}
|
||||||
|
lostFriend: {
|
||||||
|
username: string
|
||||||
|
displayName: string
|
||||||
|
avatarUrl?: string
|
||||||
|
earlyCount: number
|
||||||
|
lateCount: number
|
||||||
|
periodDesc: string
|
||||||
|
} | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SectionInfo {
|
interface SectionInfo {
|
||||||
@@ -95,148 +109,8 @@ const Avatar = ({ url, name, size = 'md' }: { url?: string; name: string; size?:
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 热力图组件
|
import Heatmap from '../components/ReportHeatmap'
|
||||||
const Heatmap = ({ data }: { data: number[][] }) => {
|
import WordCloud from '../components/ReportWordCloud'
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AnnualReportWindow() {
|
function AnnualReportWindow() {
|
||||||
const [reportData, setReportData] = useState<AnnualReportData | null>(null)
|
const [reportData, setReportData] = useState<AnnualReportData | null>(null)
|
||||||
@@ -274,6 +148,8 @@ function AnnualReportWindow() {
|
|||||||
responseSpeed: useRef<HTMLElement>(null),
|
responseSpeed: useRef<HTMLElement>(null),
|
||||||
topPhrases: useRef<HTMLElement>(null),
|
topPhrases: useRef<HTMLElement>(null),
|
||||||
ranking: useRef<HTMLElement>(null),
|
ranking: useRef<HTMLElement>(null),
|
||||||
|
sns: useRef<HTMLElement>(null),
|
||||||
|
lostFriend: useRef<HTMLElement>(null),
|
||||||
ending: useRef<HTMLElement>(null),
|
ending: useRef<HTMLElement>(null),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,7 +158,8 @@ function AnnualReportWindow() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
||||||
const yearParam = params.get('year')
|
const yearParam = params.get('year')
|
||||||
const year = yearParam ? parseInt(yearParam) : new Date().getFullYear()
|
const parsedYear = yearParam ? parseInt(yearParam, 10) : new Date().getFullYear()
|
||||||
|
const year = Number.isNaN(parsedYear) ? new Date().getFullYear() : parsedYear
|
||||||
generateReport(year)
|
generateReport(year)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -337,6 +214,11 @@ function AnnualReportWindow() {
|
|||||||
return `${Math.round(seconds / 3600)}小时`
|
return `${Math.round(seconds / 3600)}小时`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatYearLabel = (value: number, withSuffix: boolean = true) => {
|
||||||
|
if (value === 0) return '历史以来'
|
||||||
|
return withSuffix ? `${value}年` : `${value}`
|
||||||
|
}
|
||||||
|
|
||||||
// 获取可用的板块列表
|
// 获取可用的板块列表
|
||||||
const getAvailableSections = (): SectionInfo[] => {
|
const getAvailableSections = (): SectionInfo[] => {
|
||||||
if (!reportData) return []
|
if (!reportData) return []
|
||||||
@@ -367,10 +249,16 @@ function AnnualReportWindow() {
|
|||||||
if (reportData.responseSpeed) {
|
if (reportData.responseSpeed) {
|
||||||
sections.push({ id: 'responseSpeed', name: '回应速度', ref: sectionRefs.responseSpeed })
|
sections.push({ id: 'responseSpeed', name: '回应速度', ref: sectionRefs.responseSpeed })
|
||||||
}
|
}
|
||||||
|
if (reportData.lostFriend) {
|
||||||
|
sections.push({ id: 'lostFriend', name: '曾经的好朋友', ref: sectionRefs.lostFriend })
|
||||||
|
}
|
||||||
if (reportData.topPhrases && reportData.topPhrases.length > 0) {
|
if (reportData.topPhrases && reportData.topPhrases.length > 0) {
|
||||||
sections.push({ id: 'topPhrases', name: '年度常用语', ref: sectionRefs.topPhrases })
|
sections.push({ id: 'topPhrases', name: '年度常用语', ref: sectionRefs.topPhrases })
|
||||||
}
|
}
|
||||||
sections.push({ id: 'ranking', name: '好友排行', ref: sectionRefs.ranking })
|
sections.push({ id: 'ranking', name: '好友排行', ref: sectionRefs.ranking })
|
||||||
|
if (reportData.snsStats && reportData.snsStats.totalPosts > 0) {
|
||||||
|
sections.push({ id: 'sns', name: '朋友圈', ref: sectionRefs.sns })
|
||||||
|
}
|
||||||
sections.push({ id: 'ending', name: '尾声', ref: sectionRefs.ending })
|
sections.push({ id: 'ending', name: '尾声', ref: sectionRefs.ending })
|
||||||
return sections
|
return sections
|
||||||
}
|
}
|
||||||
@@ -595,7 +483,8 @@ function AnnualReportWindow() {
|
|||||||
|
|
||||||
const dataUrl = outputCanvas.toDataURL('image/png')
|
const dataUrl = outputCanvas.toDataURL('image/png')
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
link.download = `${reportData?.year}年度报告${filterIds ? '_自定义' : ''}.png`
|
const yearFilePrefix = reportData ? formatYearLabel(reportData.year, false) : ''
|
||||||
|
link.download = `${yearFilePrefix}年度报告${filterIds ? '_自定义' : ''}.png`
|
||||||
link.href = dataUrl
|
link.href = dataUrl
|
||||||
document.body.appendChild(link)
|
document.body.appendChild(link)
|
||||||
link.click()
|
link.click()
|
||||||
@@ -658,11 +547,12 @@ function AnnualReportWindow() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setExportProgress('正在写入文件...')
|
setExportProgress('正在写入文件...')
|
||||||
|
const yearFilePrefix = reportData ? formatYearLabel(reportData.year, false) : ''
|
||||||
const exportResult = await window.electronAPI.annualReport.exportImages({
|
const exportResult = await window.electronAPI.annualReport.exportImages({
|
||||||
baseDir: dirResult.filePaths[0],
|
baseDir: dirResult.filePaths[0],
|
||||||
folderName: `${reportData?.year}年度报告_分模块`,
|
folderName: `${yearFilePrefix}年度报告_分模块`,
|
||||||
images: exportedImages.map((img) => ({
|
images: exportedImages.map((img) => ({
|
||||||
name: `${reportData?.year}年度报告_${img.name}.png`,
|
name: `${yearFilePrefix}年度报告_${img.name}.png`,
|
||||||
dataUrl: img.data
|
dataUrl: img.data
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
@@ -733,10 +623,14 @@ function AnnualReportWindow() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { year, totalMessages, totalFriends, coreFriends, monthlyTopFriends, peakDay, longestStreak, activityHeatmap, midnightKing, selfAvatarUrl, mutualFriend, socialInitiative, responseSpeed, topPhrases } = reportData
|
const { year, totalMessages, totalFriends, coreFriends, monthlyTopFriends, peakDay, longestStreak, activityHeatmap, midnightKing, selfAvatarUrl, mutualFriend, socialInitiative, responseSpeed, topPhrases, lostFriend } = reportData
|
||||||
const topFriend = coreFriends[0]
|
const topFriend = coreFriends[0]
|
||||||
const mostActive = getMostActiveTime(activityHeatmap.data)
|
const mostActive = getMostActiveTime(activityHeatmap.data)
|
||||||
const socialStoryName = topFriend?.displayName || '好友'
|
const socialStoryName = topFriend?.displayName || '好友'
|
||||||
|
const yearTitle = formatYearLabel(year, true)
|
||||||
|
const yearTitleShort = formatYearLabel(year, false)
|
||||||
|
const monthlyTitle = year === 0 ? '历史以来月度好友' : `${year}年月度好友`
|
||||||
|
const phrasesTitle = year === 0 ? '你在历史以来的常用语' : `你在${year}年的年度常用语`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="annual-report-window">
|
<div className="annual-report-window">
|
||||||
@@ -827,7 +721,7 @@ function AnnualReportWindow() {
|
|||||||
{/* 封面 */}
|
{/* 封面 */}
|
||||||
<section className="section" ref={sectionRefs.cover}>
|
<section className="section" ref={sectionRefs.cover}>
|
||||||
<div className="label-text">WEFLOW · ANNUAL REPORT</div>
|
<div className="label-text">WEFLOW · ANNUAL REPORT</div>
|
||||||
<h1 className="hero-title">{year}年<br />微信聊天报告</h1>
|
<h1 className="hero-title">{yearTitle}<br />微信聊天报告</h1>
|
||||||
<hr className="divider" />
|
<hr className="divider" />
|
||||||
<p className="hero-desc">每一条消息背后<br />都藏着一段独特的故事</p>
|
<p className="hero-desc">每一条消息背后<br />都藏着一段独特的故事</p>
|
||||||
</section>
|
</section>
|
||||||
@@ -869,7 +763,7 @@ function AnnualReportWindow() {
|
|||||||
{/* 月度好友 */}
|
{/* 月度好友 */}
|
||||||
<section className="section" ref={sectionRefs.monthlyFriends}>
|
<section className="section" ref={sectionRefs.monthlyFriends}>
|
||||||
<div className="label-text">月度好友</div>
|
<div className="label-text">月度好友</div>
|
||||||
<h2 className="hero-title">{year}年月度好友</h2>
|
<h2 className="hero-title">{monthlyTitle}</h2>
|
||||||
<p className="hero-desc">根据12个月的聊天习惯</p>
|
<p className="hero-desc">根据12个月的聊天习惯</p>
|
||||||
<div className="monthly-orbit">
|
<div className="monthly-orbit">
|
||||||
{monthlyTopFriends.map((m, i) => (
|
{monthlyTopFriends.map((m, i) => (
|
||||||
@@ -883,7 +777,7 @@ function AnnualReportWindow() {
|
|||||||
<Avatar url={selfAvatarUrl} name="我" size="lg" />
|
<Avatar url={selfAvatarUrl} name="我" size="lg" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="hero-desc">只要你想<br />我一直在</p>
|
<p className="hero-desc">你只管说<br />我一直在</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* 双向奔赴 */}
|
{/* 双向奔赴 */}
|
||||||
@@ -983,15 +877,15 @@ function AnnualReportWindow() {
|
|||||||
{midnightKing && (
|
{midnightKing && (
|
||||||
<section className="section" ref={sectionRefs.midnightKing}>
|
<section className="section" ref={sectionRefs.midnightKing}>
|
||||||
<div className="label-text">深夜好友</div>
|
<div className="label-text">深夜好友</div>
|
||||||
<h2 className="hero-title">当城市睡去</h2>
|
<h2 className="hero-title">月光下的你</h2>
|
||||||
<p className="hero-desc">这一年你留下了</p>
|
<p className="hero-desc">在这一年你留下了</p>
|
||||||
<div className="big-stat">
|
<div className="big-stat">
|
||||||
<span className="stat-num">{midnightKing.count}</span>
|
<span className="stat-num">{midnightKing.count}</span>
|
||||||
<span className="stat-unit">条深夜的消息</span>
|
<span className="stat-unit">条深夜的消息</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="hero-desc">
|
<p className="hero-desc">
|
||||||
其中 <span className="hl">{midnightKing.displayName}</span> 常常在深夜中陪着你。
|
其中 <span className="hl">{midnightKing.displayName}</span> 常常在深夜中陪着你胡思乱想。
|
||||||
<br />你和Ta的对话占深夜期间聊天的 <span className="gold">{midnightKing.percentage}%</span>。
|
<br />你和Ta的对话占你深夜期间聊天的 <span className="gold">{midnightKing.percentage}%</span>。
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
@@ -1012,11 +906,46 @@ function AnnualReportWindow() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 曾经的好朋友 */}
|
||||||
|
{lostFriend && (
|
||||||
|
<section className="section" ref={sectionRefs.lostFriend}>
|
||||||
|
<div className="label-text">曾经的好朋友</div>
|
||||||
|
<h2 className="hero-title">{lostFriend.displayName}</h2>
|
||||||
|
<div className="big-stat">
|
||||||
|
<span className="stat-num">{formatNumber(lostFriend.earlyCount)}</span>
|
||||||
|
<span className="stat-unit">条消息</span>
|
||||||
|
</div>
|
||||||
|
<p className="hero-desc">
|
||||||
|
在 <span className="hl">{lostFriend.periodDesc}</span>
|
||||||
|
<br />你们曾有聊不完的话题
|
||||||
|
</p>
|
||||||
|
<div className="lost-friend-visual">
|
||||||
|
<div className="avatar-group sender">
|
||||||
|
<Avatar url={lostFriend.avatarUrl} name={lostFriend.displayName} size="lg" />
|
||||||
|
<span className="avatar-label">TA</span>
|
||||||
|
</div>
|
||||||
|
<div className="fading-line">
|
||||||
|
<div className="line-path" />
|
||||||
|
<div className="line-glow" />
|
||||||
|
<div className="flow-particle" />
|
||||||
|
</div>
|
||||||
|
<div className="avatar-group receiver">
|
||||||
|
<Avatar url={selfAvatarUrl} name="我" size="lg" />
|
||||||
|
<span className="avatar-label">我</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="hero-desc fading">
|
||||||
|
人类发明后悔
|
||||||
|
<br />来证明拥有的珍贵
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 年度常用语 - 词云 */}
|
{/* 年度常用语 - 词云 */}
|
||||||
{topPhrases && topPhrases.length > 0 && (
|
{topPhrases && topPhrases.length > 0 && (
|
||||||
<section className="section" ref={sectionRefs.topPhrases}>
|
<section className="section" ref={sectionRefs.topPhrases}>
|
||||||
<div className="label-text">年度常用语</div>
|
<div className="label-text">年度常用语</div>
|
||||||
<h2 className="hero-title">你在{year}年的年度常用语</h2>
|
<h2 className="hero-title">{phrasesTitle}</h2>
|
||||||
<p className="hero-desc">
|
<p className="hero-desc">
|
||||||
这一年,你说得最多的是:
|
这一年,你说得最多的是:
|
||||||
<br />
|
<br />
|
||||||
@@ -1029,6 +958,57 @@ function AnnualReportWindow() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 朋友圈 */}
|
||||||
|
{reportData.snsStats && reportData.snsStats.totalPosts > 0 && (
|
||||||
|
<section className="section" ref={sectionRefs.sns}>
|
||||||
|
<div className="label-text">朋友圈</div>
|
||||||
|
<h2 className="hero-title">记录生活时刻</h2>
|
||||||
|
<p className="hero-desc">
|
||||||
|
这一年,你发布了
|
||||||
|
</p>
|
||||||
|
<div className="big-stat">
|
||||||
|
<span className="stat-num">{reportData.snsStats.totalPosts}</span>
|
||||||
|
<span className="stat-unit">条朋友圈</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sns-stats-container" style={{ display: 'flex', gap: '60px', marginTop: '40px', justifyContent: 'center' }}>
|
||||||
|
{reportData.snsStats.topLikers.length > 0 && (
|
||||||
|
<div className="sns-sub-stat" style={{ textAlign: 'left' }}>
|
||||||
|
<h3 className="sub-title" style={{ fontSize: '18px', marginBottom: '16px', opacity: 0.8, borderBottom: '1px solid currentColor', paddingBottom: '8px' }}>更关心你的Ta</h3>
|
||||||
|
<div className="mini-ranking">
|
||||||
|
{reportData.snsStats.topLikers.slice(0, 3).map((u, i) => (
|
||||||
|
<div key={i} className="mini-rank-item" style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '14px' }}>
|
||||||
|
<Avatar url={u.avatarUrl} name={u.displayName} size="sm" />
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<span className="name" style={{ fontSize: '15px', fontWeight: 500, maxWidth: '120px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{u.displayName}</span>
|
||||||
|
</div>
|
||||||
|
<span className="count hl" style={{ fontSize: '14px', marginLeft: 'auto' }}>{u.count}赞</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{reportData.snsStats.topLiked.length > 0 && (
|
||||||
|
<div className="sns-sub-stat" style={{ textAlign: 'left' }}>
|
||||||
|
<h3 className="sub-title" style={{ fontSize: '18px', marginBottom: '16px', opacity: 0.8, borderBottom: '1px solid currentColor', paddingBottom: '8px' }}>你最关心的Ta</h3>
|
||||||
|
<div className="mini-ranking">
|
||||||
|
{reportData.snsStats.topLiked.slice(0, 3).map((u, i) => (
|
||||||
|
<div key={i} className="mini-rank-item" style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '14px' }}>
|
||||||
|
<Avatar url={u.avatarUrl} name={u.displayName} size="sm" />
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<span className="name" style={{ fontSize: '15px', fontWeight: 500, maxWidth: '120px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{u.displayName}</span>
|
||||||
|
</div>
|
||||||
|
<span className="count hl" style={{ fontSize: '14px', marginLeft: 'auto' }}>{u.count}赞</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 好友排行 */}
|
{/* 好友排行 */}
|
||||||
<section className="section" ref={sectionRefs.ranking}>
|
<section className="section" ref={sectionRefs.ranking}>
|
||||||
<div className="label-text">好友排行</div>
|
<div className="label-text">好友排行</div>
|
||||||
@@ -1085,7 +1065,7 @@ function AnnualReportWindow() {
|
|||||||
<br />愿新的一年,
|
<br />愿新的一年,
|
||||||
<br />所有期待,皆有回声。
|
<br />所有期待,皆有回声。
|
||||||
</p>
|
</p>
|
||||||
<div className="ending-year">{year}</div>
|
<div className="ending-year">{yearTitleShort}</div>
|
||||||
<div className="ending-brand">WEFLOW</div>
|
<div className="ending-brand">WEFLOW</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
132
src/pages/ChatHistoryPage.scss
Normal file
132
src/pages/ChatHistoryPage.scss
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
.chat-history-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
|
||||||
|
.history-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.status-msg {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
&.error {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.empty {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
|
||||||
|
.sender {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 18px 18px 18px 4px;
|
||||||
|
word-wrap: break-word;
|
||||||
|
max-width: 100%;
|
||||||
|
display: inline-block;
|
||||||
|
|
||||||
|
&.image-bubble {
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-content {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-content {
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 300px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-tip {
|
||||||
|
padding: 8px 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-placeholder {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
250
src/pages/ChatHistoryPage.tsx
Normal file
250
src/pages/ChatHistoryPage.tsx
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useParams, useLocation } from 'react-router-dom'
|
||||||
|
import { ChatRecordItem } from '../types/models'
|
||||||
|
import TitleBar from '../components/TitleBar'
|
||||||
|
import './ChatHistoryPage.scss'
|
||||||
|
|
||||||
|
export default function ChatHistoryPage() {
|
||||||
|
const params = useParams<{ sessionId: string; messageId: string }>()
|
||||||
|
const location = useLocation()
|
||||||
|
const [recordList, setRecordList] = useState<ChatRecordItem[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [title, setTitle] = useState('聊天记录')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
// 简单的 XML 标签内容提取
|
||||||
|
const extractXmlValue = (xml: string, tag: string): string => {
|
||||||
|
const match = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`).exec(xml)
|
||||||
|
return match ? match[1] : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 简单的 HTML 实体解码
|
||||||
|
const decodeHtmlEntities = (text?: string): string | undefined => {
|
||||||
|
if (!text) return text
|
||||||
|
return text
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 前端兜底解析合并转发聊天记录
|
||||||
|
const parseChatHistory = (content: string): ChatRecordItem[] | undefined => {
|
||||||
|
try {
|
||||||
|
const type = extractXmlValue(content, 'type')
|
||||||
|
if (type !== '19') return undefined
|
||||||
|
|
||||||
|
const match = /<recorditem>[\s\S]*?<!\[CDATA\[([\s\S]*?)\]\]>[\s\S]*?<\/recorditem>/.exec(content)
|
||||||
|
if (!match) return undefined
|
||||||
|
|
||||||
|
const innerXml = match[1]
|
||||||
|
const items: ChatRecordItem[] = []
|
||||||
|
const itemRegex = /<dataitem\s+(.*?)>([\s\S]*?)<\/dataitem>/g
|
||||||
|
let itemMatch: RegExpExecArray | null
|
||||||
|
|
||||||
|
while ((itemMatch = itemRegex.exec(innerXml)) !== null) {
|
||||||
|
const attrs = itemMatch[1]
|
||||||
|
const body = itemMatch[2]
|
||||||
|
|
||||||
|
const datatypeMatch = /datatype="(\d+)"/.exec(attrs)
|
||||||
|
const datatype = datatypeMatch ? parseInt(datatypeMatch[1]) : 0
|
||||||
|
|
||||||
|
const sourcename = extractXmlValue(body, 'sourcename')
|
||||||
|
const sourcetime = extractXmlValue(body, 'sourcetime')
|
||||||
|
const sourceheadurl = extractXmlValue(body, 'sourceheadurl')
|
||||||
|
const datadesc = extractXmlValue(body, 'datadesc')
|
||||||
|
const datatitle = extractXmlValue(body, 'datatitle')
|
||||||
|
const fileext = extractXmlValue(body, 'fileext')
|
||||||
|
const datasize = parseInt(extractXmlValue(body, 'datasize') || '0')
|
||||||
|
const messageuuid = extractXmlValue(body, 'messageuuid')
|
||||||
|
|
||||||
|
const dataurl = extractXmlValue(body, 'dataurl')
|
||||||
|
const datathumburl = extractXmlValue(body, 'datathumburl') || extractXmlValue(body, 'thumburl')
|
||||||
|
const datacdnurl = extractXmlValue(body, 'datacdnurl') || extractXmlValue(body, 'cdnurl')
|
||||||
|
const aeskey = extractXmlValue(body, 'aeskey') || extractXmlValue(body, 'qaeskey')
|
||||||
|
const md5 = extractXmlValue(body, 'md5') || extractXmlValue(body, 'datamd5')
|
||||||
|
const imgheight = parseInt(extractXmlValue(body, 'imgheight') || '0')
|
||||||
|
const imgwidth = parseInt(extractXmlValue(body, 'imgwidth') || '0')
|
||||||
|
const duration = parseInt(extractXmlValue(body, 'duration') || '0')
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
datatype,
|
||||||
|
sourcename,
|
||||||
|
sourcetime,
|
||||||
|
sourceheadurl,
|
||||||
|
datadesc: decodeHtmlEntities(datadesc),
|
||||||
|
datatitle: decodeHtmlEntities(datatitle),
|
||||||
|
fileext,
|
||||||
|
datasize,
|
||||||
|
messageuuid,
|
||||||
|
dataurl: decodeHtmlEntities(dataurl),
|
||||||
|
datathumburl: decodeHtmlEntities(datathumburl),
|
||||||
|
datacdnurl: decodeHtmlEntities(datacdnurl),
|
||||||
|
aeskey: decodeHtmlEntities(aeskey),
|
||||||
|
md5,
|
||||||
|
imgheight,
|
||||||
|
imgwidth,
|
||||||
|
duration
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.length > 0 ? items : undefined
|
||||||
|
} catch (e) {
|
||||||
|
console.error('前端解析聊天记录失败:', e)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统一从路由参数或 pathname 中解析 sessionId / messageId
|
||||||
|
const getIds = () => {
|
||||||
|
const sessionId = params.sessionId || ''
|
||||||
|
const messageId = params.messageId || ''
|
||||||
|
|
||||||
|
if (sessionId && messageId) {
|
||||||
|
return { sid: sessionId, mid: messageId }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 独立窗口场景下没有 Route 包裹,用 pathname 手动解析
|
||||||
|
const match = /^\/chat-history\/([^/]+)\/([^/]+)/.exec(location.pathname)
|
||||||
|
if (match) {
|
||||||
|
return { sid: match[1], mid: match[2] }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sid: '', mid: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadData = async () => {
|
||||||
|
const { sid, mid } = getIds()
|
||||||
|
if (!sid || !mid) {
|
||||||
|
setError('无效的聊天记录链接')
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.chat.getMessage(sid, parseInt(mid, 10))
|
||||||
|
if (result.success && result.message) {
|
||||||
|
const msg = result.message
|
||||||
|
// 优先使用后端解析好的列表
|
||||||
|
let records: ChatRecordItem[] | undefined = msg.chatRecordList
|
||||||
|
|
||||||
|
// 如果后端没有解析到,则在前端兜底解析一次
|
||||||
|
if ((!records || records.length === 0) && msg.content) {
|
||||||
|
records = parseChatHistory(msg.content) || []
|
||||||
|
}
|
||||||
|
|
||||||
|
if (records && records.length > 0) {
|
||||||
|
setRecordList(records)
|
||||||
|
const match = /<title>(.*?)<\/title>/.exec(msg.content || '')
|
||||||
|
if (match) setTitle(match[1])
|
||||||
|
} else {
|
||||||
|
setError('暂时无法解析这条聊天记录')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError(result.error || '获取消息失败')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
setError('加载详情失败')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadData()
|
||||||
|
}, [params.sessionId, params.messageId, location.pathname])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chat-history-page">
|
||||||
|
<TitleBar title={title} />
|
||||||
|
<div className="history-list">
|
||||||
|
{loading ? (
|
||||||
|
<div className="status-msg">加载中...</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="status-msg error">{error}</div>
|
||||||
|
) : recordList.length === 0 ? (
|
||||||
|
<div className="status-msg empty">暂无可显示的聊天记录</div>
|
||||||
|
) : (
|
||||||
|
recordList.map((item, i) => (
|
||||||
|
<HistoryItem key={i} item={item} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HistoryItem({ item }: { item: ChatRecordItem }) {
|
||||||
|
// sourcetime 在合并转发里有两种格式:
|
||||||
|
// 1) 时间戳(秒) 2) 已格式化的字符串 "2026-01-21 09:56:46"
|
||||||
|
let time = ''
|
||||||
|
if (item.sourcetime) {
|
||||||
|
if (/^\d+$/.test(item.sourcetime)) {
|
||||||
|
time = new Date(parseInt(item.sourcetime, 10) * 1000).toLocaleString()
|
||||||
|
} else {
|
||||||
|
time = item.sourcetime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
if (item.datatype === 1) {
|
||||||
|
// 文本消息
|
||||||
|
return <div className="text-content">{item.datadesc || ''}</div>
|
||||||
|
}
|
||||||
|
if (item.datatype === 3) {
|
||||||
|
// 图片
|
||||||
|
const src = item.datathumburl || item.datacdnurl
|
||||||
|
if (src) {
|
||||||
|
return (
|
||||||
|
<div className="media-content">
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt="图片"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
onError={(e) => {
|
||||||
|
const target = e.target as HTMLImageElement
|
||||||
|
target.style.display = 'none'
|
||||||
|
const placeholder = document.createElement('div')
|
||||||
|
placeholder.className = 'media-tip'
|
||||||
|
placeholder.textContent = '图片无法加载'
|
||||||
|
target.parentElement?.appendChild(placeholder)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return <div className="media-placeholder">[图片]</div>
|
||||||
|
}
|
||||||
|
if (item.datatype === 43) {
|
||||||
|
return <div className="media-placeholder">[视频] {item.datatitle}</div>
|
||||||
|
}
|
||||||
|
if (item.datatype === 34) {
|
||||||
|
return <div className="media-placeholder">[语音] {item.duration ? (item.duration / 1000).toFixed(0) + '"' : ''}</div>
|
||||||
|
}
|
||||||
|
// Fallback
|
||||||
|
return <div className="text-content">{item.datadesc || item.datatitle || '[不支持的消息类型]'}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="history-item">
|
||||||
|
<div className="avatar">
|
||||||
|
{item.sourceheadurl ? (
|
||||||
|
<img src={item.sourceheadurl} alt="" referrerPolicy="no-referrer" />
|
||||||
|
) : (
|
||||||
|
<div className="avatar-placeholder">
|
||||||
|
{item.sourcename?.slice(0, 1)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="content-wrapper">
|
||||||
|
<div className="header">
|
||||||
|
<span className="sender">{item.sourcename || '未知发送者'}</span>
|
||||||
|
<span className="time">{time}</span>
|
||||||
|
</div>
|
||||||
|
<div className={`bubble ${item.datatype === 3 ? 'image-bubble' : ''}`}>
|
||||||
|
{renderContent()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user