mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 15:25:50 +00:00
Compare commits
689 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c7a63e616 | ||
|
|
ab7a487e78 | ||
|
|
f01e2efd3f | ||
|
|
3f4a4f7581 | ||
|
|
7f78925bd7 | ||
|
|
8cbd3b9625 | ||
|
|
9fac12ce3c | ||
|
|
ee050aa5fa | ||
|
|
a179f13031 | ||
|
|
f3fc5760fc | ||
|
|
d4e04a003c | ||
|
|
2604be38f0 | ||
|
|
06a10f77ae | ||
|
|
73f1355011 | ||
|
|
659b9f9680 | ||
|
|
539f854dbf | ||
|
|
45d4e74c98 | ||
|
|
1d0b101352 | ||
|
|
ed96eeccee | ||
|
|
29d49360f5 | ||
|
|
849cac6a40 | ||
|
|
262b3622dd | ||
|
|
2692ac2408 | ||
|
|
c2502a09a9 | ||
|
|
2ea7c72fc6 | ||
|
|
42aafae29b | ||
|
|
61101382d1 | ||
|
|
ba5a791b2d | ||
|
|
ba189aec6f | ||
|
|
4b17d20325 | ||
|
|
b52bdcf4b3 | ||
|
|
8e8c14a51f | ||
|
|
80786c572a | ||
|
|
a331f45f87 | ||
|
|
4c70ebcaf9 | ||
|
|
7760358c02 | ||
|
|
a163ea377c | ||
|
|
3fabf961e5 | ||
|
|
6f3b60ef2c | ||
|
|
4a27653039 | ||
|
|
d5b1f5fb1c | ||
|
|
816770d407 | ||
|
|
8dfd39810d | ||
|
|
b2ee143e1c | ||
|
|
94b0a9f89b | ||
|
|
a0a50ff7d1 | ||
|
|
7ccdae23fa | ||
|
|
0bf57502e6 | ||
|
|
2888c369d7 | ||
|
|
bedb872034 | ||
|
|
cd42e76659 | ||
|
|
1b49aa2d39 | ||
|
|
4423c895c7 | ||
|
|
f9c574ddd9 | ||
|
|
60dc911228 | ||
|
|
ed25a0e395 | ||
|
|
7590623d26 | ||
|
|
043e518cce | ||
|
|
de7f7bc8de | ||
|
|
b8079f11d0 | ||
|
|
7c5b3f2241 | ||
|
|
48e5ce807d | ||
|
|
35e9ea13de | ||
|
|
958677c5b1 | ||
|
|
21a9904b81 | ||
|
|
bc979767d6 | ||
|
|
e933209ea7 | ||
|
|
ae6cd88d9e | ||
|
|
7ffc0c3484 | ||
|
|
0f450154cf | ||
|
|
e32b4c7406 | ||
|
|
d45179a4b0 | ||
|
|
0816fafc02 | ||
|
|
db4cf015c2 | ||
|
|
48c4197b16 | ||
|
|
a0fb109839 | ||
|
|
4c32bf5934 | ||
|
|
19beb846bf | ||
|
|
661b6e46cc | ||
|
|
19d7330d3a | ||
|
|
75f70c2ae0 | ||
|
|
fb00b12d13 | ||
|
|
0f8f202fbb | ||
|
|
f4ad6bf263 | ||
|
|
be7d173746 | ||
|
|
e0b2f152b0 | ||
|
|
d0457a2782 | ||
|
|
ee684021db | ||
|
|
61eef27740 | ||
|
|
774ac7f2fa | ||
|
|
6dcc597b0c | ||
|
|
5bd332369f | ||
|
|
f2c0799854 | ||
|
|
dea77cc268 | ||
|
|
1f5b1e2bb9 | ||
|
|
da68b0fdae | ||
|
|
1680acb22c | ||
|
|
56a8859eaf | ||
|
|
fee8c3f0ee | ||
|
|
faa22966e4 | ||
|
|
3c72f3b1c5 | ||
|
|
7497b48531 | ||
|
|
70fddac2d5 | ||
|
|
8f65124830 | ||
|
|
bb9b7bcf9f | ||
|
|
4bd2c90554 | ||
|
|
bd6b23f413 | ||
|
|
85b5943b9e | ||
|
|
0f5ed083df | ||
|
|
486ca220a2 | ||
|
|
a19bf5fac2 | ||
|
|
5cf8ce4385 | ||
|
|
8026d19d8f | ||
|
|
d64abe4ee3 | ||
|
|
89acfafbd2 | ||
|
|
072c49a037 | ||
|
|
7fad75fad0 | ||
|
|
79e40f6a53 | ||
|
|
f2b1b07f58 | ||
|
|
999ddaeb9a | ||
|
|
d730ae5bef | ||
|
|
bf48e865ac | ||
|
|
7e05909404 | ||
|
|
7a1c944fe6 | ||
|
|
66a2b3224f | ||
|
|
7bcdecaceb | ||
|
|
6beefb9fc0 | ||
|
|
579b63b036 | ||
|
|
1f676254a9 | ||
|
|
eac81ac82b | ||
|
|
8c1b043769 | ||
|
|
eb870d94c2 | ||
|
|
c18b62ffb9 | ||
|
|
02f724bfc3 | ||
|
|
e12ea371c0 | ||
|
|
9a1726c249 | ||
|
|
50f2eaee3b | ||
|
|
6b1229fcf2 | ||
|
|
ef97202867 | ||
|
|
5494490ff8 | ||
|
|
bd4c4878f1 | ||
|
|
6a7851a1cc | ||
|
|
0eac4e2a44 | ||
|
|
053e2cdc64 | ||
|
|
7024b86d00 | ||
|
|
ae75820b77 | ||
|
|
a800c71cba | ||
|
|
55cce56230 | ||
|
|
128f1ca043 | ||
|
|
2f25fd1239 | ||
|
|
c0ad450960 | ||
|
|
0845ee6775 | ||
|
|
ffcdb10802 | ||
|
|
fe5b63eed8 | ||
|
|
f3ca6c3fa7 | ||
|
|
904bc45652 | ||
|
|
845d6b2e2c | ||
|
|
5deacf45cb | ||
|
|
e9bc303e0e | ||
|
|
caaf1e8d0d | ||
|
|
b96e757379 | ||
|
|
53a52d8561 | ||
|
|
32424e46b8 | ||
|
|
1e3829899a | ||
|
|
b6df41e05b | ||
|
|
f4fd5bb797 | ||
|
|
ecc538a932 | ||
|
|
6741a94c1b | ||
|
|
7be2c69256 | ||
|
|
2b97b6ac9d | ||
|
|
512b47a386 | ||
|
|
d6b95036b5 | ||
|
|
e4c188da75 | ||
|
|
edfe28b9ef | ||
|
|
c111ed4f91 | ||
|
|
318c296ee9 | ||
|
|
998b2ce3d7 | ||
|
|
ba5f8928f7 | ||
|
|
641abc57b9 | ||
|
|
0a23ed6ef4 | ||
|
|
8e69e1ec58 | ||
|
|
d50bffad3e | ||
|
|
db71bc3f19 | ||
|
|
f2a9d7097f | ||
|
|
a4b0a25dab | ||
|
|
3af530a15e | ||
|
|
11c7277878 | ||
|
|
6eae60ba54 | ||
|
|
2d711cca80 | ||
|
|
b274c99b91 | ||
|
|
4e66074603 | ||
|
|
42fbc479c9 | ||
|
|
f47610b98a | ||
|
|
cda45ce64c | ||
|
|
009a0d64b8 | ||
|
|
3afb0da017 | ||
|
|
bdc7f8a8a8 | ||
|
|
69a72f24ed | ||
|
|
ee0e71d50e | ||
|
|
39ba175651 | ||
|
|
731f022669 | ||
|
|
8d5527990b | ||
|
|
1ff536c2f7 | ||
|
|
27a18f1fc6 | ||
|
|
8921b90392 | ||
|
|
6cd925b062 | ||
|
|
28a344c63c | ||
|
|
a9b5fa0fae | ||
|
|
65212201ad | ||
|
|
d8c3ba34a8 | ||
|
|
63be8a35ad | ||
|
|
53ef4e11f9 | ||
|
|
c9a6451407 | ||
|
|
9d07a3a7bd | ||
|
|
bd4296199a | ||
|
|
b9e0535f63 | ||
|
|
6e371d75c8 | ||
|
|
7697f382ef | ||
|
|
4c551a8c91 | ||
|
|
56227c69f7 | ||
|
|
5acd3d86c8 | ||
|
|
d7f7139f36 | ||
|
|
1c5cacf1ce | ||
|
|
0a603116ef | ||
|
|
809b28a994 | ||
|
|
f7610a3570 | ||
|
|
bff9e87096 | ||
|
|
d872a8af20 | ||
|
|
4966cdbfac | ||
|
|
cb3eb83eac | ||
|
|
5daa7bce73 | ||
|
|
4e80f93b30 | ||
|
|
2776a1a5ce | ||
|
|
4f402d6a6a | ||
|
|
d544da6e4d | ||
|
|
0e42c19d3b | ||
|
|
0a2bd3d46a | ||
|
|
86d2dade11 | ||
|
|
19ab4409a3 | ||
|
|
3af90bd6e9 | ||
|
|
cfb0cff1a3 | ||
|
|
c08d6cd668 | ||
|
|
a53bebefd7 | ||
|
|
8e0c3306e8 | ||
|
|
f4364b3bd3 | ||
|
|
5b5757a1d7 | ||
|
|
f165f4911b | ||
|
|
b81b538d9a | ||
|
|
2f32c8e092 | ||
|
|
d101a79bf8 | ||
|
|
caea10a190 | ||
|
|
1445202a0d | ||
|
|
6f62ac4ffb | ||
|
|
e87bbe7223 | ||
|
|
e7e2c40c68 | ||
|
|
78b6d445fa | ||
|
|
c212355860 | ||
|
|
c223c20b38 | ||
|
|
524a9cda35 | ||
|
|
8bee66d404 | ||
|
|
142b00499b | ||
|
|
b0ea6c0ea2 | ||
|
|
67fd53a503 | ||
|
|
29529271fb | ||
|
|
4489a0f702 | ||
|
|
0d9fcc731a | ||
|
|
fe1c8862e6 | ||
|
|
092450e4f8 | ||
|
|
da054de708 | ||
|
|
dfac3c57cc | ||
|
|
0f3ecdc4ee | ||
|
|
24c47c3aa3 | ||
|
|
f53de9fe0b | ||
|
|
ee4d1f5689 | ||
|
|
122ad73c2e | ||
|
|
6ad1e6c3f3 | ||
|
|
c899fa72b8 | ||
|
|
e209bd68d4 | ||
|
|
96ac655d92 | ||
|
|
1d97b19774 | ||
|
|
11c7de3568 | ||
|
|
38d899fa94 | ||
|
|
37796c98c9 | ||
|
|
5b2e48badd | ||
|
|
627aa35f88 | ||
|
|
74e974177c | ||
|
|
6911132c95 | ||
|
|
f1affc7d63 | ||
|
|
bea824aee9 | ||
|
|
cbdd5b3a24 | ||
|
|
c02bc753fd | ||
|
|
d4915e1a62 | ||
|
|
2d4a5fc62f | ||
|
|
94a010c9b2 | ||
|
|
a6a202f6ff | ||
|
|
2127fdd443 | ||
|
|
3b3fd8b35c | ||
|
|
95d0937015 | ||
|
|
b070b4f659 | ||
|
|
a8c05fd26c | ||
|
|
ecd64f62bc | ||
|
|
5affd4e57b | ||
|
|
76d69ab7dd | ||
|
|
1d1b38210a | ||
|
|
836032d93e | ||
|
|
dc3e285917 | ||
|
|
e54eb8fea2 | ||
|
|
177dbaa5ff | ||
|
|
1d08ab945d | ||
|
|
10ce7d772c | ||
|
|
e1a23ac606 | ||
|
|
439259ec57 | ||
|
|
a0dda0b866 | ||
|
|
6913defc12 | ||
|
|
f3e2fdd4fc | ||
|
|
5c44b35045 | ||
|
|
cebb6426f8 | ||
|
|
f05e50e63e | ||
|
|
f8ef3f18ff | ||
|
|
47dbc540ac | ||
|
|
766d5ed2af | ||
|
|
783b408611 | ||
|
|
24c91269a0 | ||
|
|
e786026049 | ||
|
|
566b0cf6e5 | ||
|
|
b17844e837 | ||
|
|
5c93c4db57 | ||
|
|
57e8a96a4a | ||
|
|
438581834e | ||
|
|
58cfd49859 | ||
|
|
4a1933e924 | ||
|
|
6ded8c5ab5 | ||
|
|
edf38aad48 | ||
|
|
f4caa51da5 | ||
|
|
9575ba2a9f | ||
|
|
af2fe91f81 | ||
|
|
c641c86598 | ||
|
|
0599de372a | ||
|
|
1c89ee2797 | ||
|
|
5fd846bfc8 | ||
|
|
02aefcf155 | ||
|
|
e92983dd80 | ||
|
|
03f65317a9 | ||
|
|
21cb09fbde | ||
|
|
6c1e7f6f12 | ||
|
|
344dd3343b | ||
|
|
cacb9e449c | ||
|
|
18313141f4 | ||
|
|
ecd73ae0d6 | ||
|
|
7ad754df03 | ||
|
|
cfc601e19a | ||
|
|
9984f9c206 | ||
|
|
39e59a4077 | ||
|
|
d735ed19cb | ||
|
|
f4037a1ccf | ||
|
|
3e917e2062 | ||
|
|
919357a374 | ||
|
|
5b6be864fd | ||
|
|
98a3b06e56 | ||
|
|
6253def76c | ||
|
|
450e5f7e61 | ||
|
|
d2ec9c680d | ||
|
|
56d7ad6999 | ||
|
|
97024395c1 | ||
|
|
10342be2be | ||
|
|
51a3ee4a9b | ||
|
|
8779bbc532 | ||
|
|
90b33ef444 | ||
|
|
3fa0b36426 | ||
|
|
60a64cd777 | ||
|
|
c543fabdf4 | ||
|
|
64b96f00f7 | ||
|
|
86b372de68 | ||
|
|
c108070696 | ||
|
|
80a193a394 | ||
|
|
b9c16dbee4 | ||
|
|
6e870ef300 | ||
|
|
cf45ae30ac | ||
|
|
38a0453cbb | ||
|
|
92d37abbc5 | ||
|
|
39662038f7 | ||
|
|
75b58d0423 | ||
|
|
1814808df1 | ||
|
|
fe57d80a00 | ||
|
|
8cb855328d | ||
|
|
a62ba8e167 | ||
|
|
4f40b4af49 | ||
|
|
8d9a042489 | ||
|
|
ef05466d6d | ||
|
|
0a5cf005a1 | ||
|
|
f6c365bdf1 | ||
|
|
bc2ab60c59 | ||
|
|
ad217d4a3b | ||
|
|
61cc3e6f58 | ||
|
|
a3ab06509e | ||
|
|
54684ea3c9 | ||
|
|
3de4951c96 | ||
|
|
05c551d7ac | ||
|
|
7cea8b4fb3 | ||
|
|
ba2cdbf8cf | ||
|
|
3e004867be | ||
|
|
edaef53712 | ||
|
|
933842f6af | ||
|
|
2eff82891e | ||
|
|
c625756ab4 | ||
|
|
2140a220e2 | ||
|
|
7ead55d801 | ||
|
|
4e0038c813 | ||
|
|
d07e4c8ecd | ||
|
|
63fd42ff05 | ||
|
|
d5dbcd3f80 | ||
|
|
c301f36912 | ||
|
|
9dd5ee2365 | ||
|
|
3388b7a122 | ||
|
|
38af8de469 | ||
|
|
db0ebc6c33 | ||
|
|
7cc2961538 | ||
|
|
835ec4782c | ||
|
|
e6942bc201 | ||
|
|
ebabe1560f | ||
|
|
4da697f507 | ||
|
|
f18fb83a92 | ||
|
|
e050402787 | ||
|
|
b3dd0e25fa | ||
|
|
a5358b82f6 | ||
|
|
2a9f0f24fd | ||
|
|
5945942acd | ||
|
|
bcdb983b98 | ||
|
|
7836c611b7 | ||
|
|
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 |
114
.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal file
114
.github/ISSUE_TEMPLATE/bug.yml
vendored
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
name: "报告 Bug"
|
||||||
|
description: "代码出现了非预期的问题、崩溃或报错"
|
||||||
|
title: "[Bug]: "
|
||||||
|
labels: ["type: bug", "status: needs info"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
请提供尽可能详细的信息,帮助我们快速定位和修复问题。
|
||||||
|
- type: checkboxes
|
||||||
|
id: pre-check
|
||||||
|
attributes:
|
||||||
|
label: 提交前确认
|
||||||
|
description: 请务必确认以下事项
|
||||||
|
options:
|
||||||
|
- label: 我已搜索过现有的 Issues,确认这不是重复问题
|
||||||
|
required: true
|
||||||
|
- label: 我使用的是最新版本
|
||||||
|
required: true
|
||||||
|
- label: 我已阅读过相关文档
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: platform
|
||||||
|
attributes:
|
||||||
|
label: 使用平台
|
||||||
|
description: 选择出现问题的平台
|
||||||
|
options:
|
||||||
|
- Windows
|
||||||
|
- macOS
|
||||||
|
- Linux
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: severity
|
||||||
|
attributes:
|
||||||
|
label: 问题严重程度
|
||||||
|
description: 这个问题对你的使用造成了多大影响?
|
||||||
|
options:
|
||||||
|
- 严重崩溃或数据丢失(无法使用)
|
||||||
|
- 核心功能受影响(在下一个常规发布中必须修复)
|
||||||
|
- 边缘场景或轻微问题(等待空闲时修复)
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: 问题描述
|
||||||
|
description: 清晰描述你遇到的问题,包括实际发生了什么
|
||||||
|
placeholder: 例如:当我点击发送按钮时,应用程序崩溃并显示白屏
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: reproduction
|
||||||
|
attributes:
|
||||||
|
label: 复现步骤
|
||||||
|
description: 提供详细的操作步骤,让我们能够重现这个问题
|
||||||
|
placeholder: |
|
||||||
|
1. 打开应用并登录账号
|
||||||
|
2. 进入聊天页面
|
||||||
|
3. 点击发送按钮
|
||||||
|
4. 观察到应用崩溃
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: expected-behavior
|
||||||
|
attributes:
|
||||||
|
label: 预期行为
|
||||||
|
description: 描述你期望的正确行为应该是什么样的
|
||||||
|
placeholder: 例如:点击发送按钮后,消息应该正常发送并显示在聊天窗口中
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: actual-behavior
|
||||||
|
attributes:
|
||||||
|
label: 实际行为
|
||||||
|
description: 描述实际发生的错误行为
|
||||||
|
placeholder: 例如:点击后应用直接崩溃,显示白屏
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: 错误日志或截图
|
||||||
|
description: 粘贴控制台错误信息、崩溃日志,或拖入截图
|
||||||
|
placeholder: 请粘贴完整的错误堆栈信息
|
||||||
|
render: shell
|
||||||
|
- type: input
|
||||||
|
id: os
|
||||||
|
attributes:
|
||||||
|
label: 操作系统版本
|
||||||
|
description: 例如:Windows 11 24H2、macOS 15.0、Ubuntu 24.04
|
||||||
|
placeholder: Windows 11 24H2
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: app-version
|
||||||
|
attributes:
|
||||||
|
label: 应用版本
|
||||||
|
description: 在关于页面或设置中查看版本号
|
||||||
|
placeholder: v1.2.3
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: architecture
|
||||||
|
attributes:
|
||||||
|
label: 系统架构
|
||||||
|
description: 例如:x64、arm64
|
||||||
|
placeholder: x64
|
||||||
|
- type: textarea
|
||||||
|
id: additional-context
|
||||||
|
attributes:
|
||||||
|
label: 补充信息
|
||||||
|
description: 其他可能有助于定位问题的信息
|
||||||
|
placeholder: 例如:这个问题是在某次更新后开始出现的,或者只在特定网络环境下出现
|
||||||
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name:🤔 找不到合适的模板?
|
||||||
|
url: https://t.me/weflow_cc
|
||||||
|
about: 如果你的问题不属于上述任何分类,请前往我们的 Telegram 频道与我们交流。
|
||||||
67
.github/ISSUE_TEMPLATE/docs.yml
vendored
Normal file
67
.github/ISSUE_TEMPLATE/docs.yml
vendored
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
name: "文档反馈"
|
||||||
|
description: "文档存在错别字、描述不清晰或缺少必要的示例"
|
||||||
|
title: "[Docs]: "
|
||||||
|
labels: ["type: docs"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
优秀的文档和代码一样重要。感谢你帮助我们完善文档!
|
||||||
|
- type: dropdown
|
||||||
|
id: doc-type
|
||||||
|
attributes:
|
||||||
|
label: 文档类型
|
||||||
|
description: 问题出现在哪类文档中?
|
||||||
|
options:
|
||||||
|
- README 或项目说明
|
||||||
|
- 安装部署文档
|
||||||
|
- 使用教程
|
||||||
|
- API 文档
|
||||||
|
- 开发者文档
|
||||||
|
- 其他
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: doc-link
|
||||||
|
attributes:
|
||||||
|
label: 文档位置
|
||||||
|
description: 提供文档的 URL 或文件路径
|
||||||
|
placeholder: 例如:docs/installation.md 或 https://github.com/xxx/xxx/wiki/xxx
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: issue-type
|
||||||
|
attributes:
|
||||||
|
label: 问题类型
|
||||||
|
description: 文档存在什么问题?
|
||||||
|
options:
|
||||||
|
- 错别字或语法错误
|
||||||
|
- 内容过时或不准确
|
||||||
|
- 描述不清晰或有歧义
|
||||||
|
- 缺少必要的示例代码
|
||||||
|
- 缺少重要的说明或警告
|
||||||
|
- 链接失效或错误
|
||||||
|
- 其他
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: issue-desc
|
||||||
|
attributes:
|
||||||
|
label: 问题描述
|
||||||
|
description: 详细说明文档中存在的问题
|
||||||
|
placeholder: 例如:第 3 步中的命令拼写错误,应该是 "npm install" 而不是 "npm instal"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: suggestion
|
||||||
|
attributes:
|
||||||
|
label: 修改建议
|
||||||
|
description: 你认为应该如何修改?
|
||||||
|
placeholder: 例如:建议将"安装依赖"部分补充完整的命令示例,并说明不同操作系统的差异
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: additional
|
||||||
|
attributes:
|
||||||
|
label: 补充说明
|
||||||
|
description: 其他需要补充的信息
|
||||||
78
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
Normal file
78
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
name: "功能与体验优化"
|
||||||
|
description: "对现有的功能逻辑进行优化,或改进用户体验"
|
||||||
|
title: "[Enhancement]: "
|
||||||
|
labels: ["type: enhancement"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
持续优化是项目进步的动力!请告诉我们哪个现有功能可以做得更好。
|
||||||
|
- type: checkboxes
|
||||||
|
id: pre-check
|
||||||
|
attributes:
|
||||||
|
label: 提交前确认
|
||||||
|
options:
|
||||||
|
- label: 我已搜索过现有的 Issues,确认这个优化建议尚未被提出
|
||||||
|
required: true
|
||||||
|
- label: 这是对现有功能的改进,而不是全新功能
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: category
|
||||||
|
attributes:
|
||||||
|
label: 优化类别
|
||||||
|
description: 这个优化主要属于哪个方面?
|
||||||
|
options:
|
||||||
|
- 性能优化(速度、内存、资源占用)
|
||||||
|
- 交互体验(操作流程、界面布局)
|
||||||
|
- 视觉设计(样式、动画、美观度)
|
||||||
|
- 易用性(降低使用门槛、减少操作步骤)
|
||||||
|
- 稳定性(减少崩溃、提高可靠性)
|
||||||
|
- 其他
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: target
|
||||||
|
attributes:
|
||||||
|
label: 目标功能或模块
|
||||||
|
description: 你希望优化的具体功能或页面是哪个?
|
||||||
|
placeholder: 例如:聊天页面的消息加载、设置页面的布局、文件上传功能
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: current-behavior
|
||||||
|
attributes:
|
||||||
|
label: 当前表现
|
||||||
|
description: 描述当前功能的不足之处或存在的问题
|
||||||
|
placeholder: 例如:消息列表滚动时会出现明显卡顿,加载 100 条消息需要 3 秒
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: improvement
|
||||||
|
attributes:
|
||||||
|
label: 优化建议
|
||||||
|
description: 详细说明你的优化方案和预期效果
|
||||||
|
placeholder: 例如:建议使用虚拟滚动技术,只渲染可见区域的消息,预计可将加载时间缩短到 0.5 秒以内
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: benefits
|
||||||
|
attributes:
|
||||||
|
label: 优化收益
|
||||||
|
description: 这个优化会带来什么具体好处?
|
||||||
|
placeholder: 例如:提升 80% 的加载速度、减少 50% 的内存占用、降低用户操作步骤从 5 步到 2 步
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: impact
|
||||||
|
attributes:
|
||||||
|
label: 影响范围
|
||||||
|
description: 这个优化会影响哪些用户或场景?
|
||||||
|
placeholder: 例如:所有用户在查看历史消息时都会受益,尤其是群聊消息较多的场景
|
||||||
|
- type: checkboxes
|
||||||
|
id: contribution
|
||||||
|
attributes:
|
||||||
|
label: 参与贡献
|
||||||
|
options:
|
||||||
|
- label: 我愿意提交 Pull Request 来实现这个优化
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
71
.github/ISSUE_TEMPLATE/feature.yml
vendored
Normal file
71
.github/ISSUE_TEMPLATE/feature.yml
vendored
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
name: "全新功能请求"
|
||||||
|
description: "提议一个目前项目中完全没有的新特性"
|
||||||
|
title: "[Feature]: "
|
||||||
|
labels: ["type: feature"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
感谢你为项目提供新想法!详细的需求描述能极大提高该功能被采纳的几率。
|
||||||
|
- type: checkboxes
|
||||||
|
id: pre-check
|
||||||
|
attributes:
|
||||||
|
label: 提交前确认
|
||||||
|
options:
|
||||||
|
- label: 我已搜索过现有的 Issues 和 Pull Requests,确认这个功能尚未被提出或实现
|
||||||
|
required: true
|
||||||
|
- label: 这是一个全新的功能,而不是对现有功能的改进
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: priority
|
||||||
|
attributes:
|
||||||
|
label: 功能优先级
|
||||||
|
description: 你认为这个功能有多重要?
|
||||||
|
options:
|
||||||
|
- 高优先级(核心功能缺失,严重影响使用体验)
|
||||||
|
- 中优先级(有助于提升使用体验)
|
||||||
|
- 低优先级(锦上添花的功能)
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: 问题或痛点
|
||||||
|
description: 【为什么需要】你现在做某件事遇到了什么困难?缺少什么能力?
|
||||||
|
placeholder: 例如:目前无法批量导出聊天记录,每次只能手动复制单条消息,处理 100 条消息需要半小时
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: solution
|
||||||
|
attributes:
|
||||||
|
label: 期望的解决方案
|
||||||
|
description: 【怎么实现】详细描述功能的操作流程、界面位置、可选参数等
|
||||||
|
placeholder: 例如:在聊天窗口右键菜单添加"导出记录",点击后弹窗可选时间范围、导出格式(TXT/JSON)、筛选用户,最后保存到本地
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: use-case
|
||||||
|
attributes:
|
||||||
|
label: 使用场景
|
||||||
|
description: 【什么时候用】你会在哪些具体情况下使用这个功能?
|
||||||
|
placeholder: 例如:每周五整理工作讨论记录;保存客户沟通记录作为合同依据;备份重要群聊内容
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: 替代方案
|
||||||
|
description: 你目前使用什么临时方案?或者有没有考虑过其他实现方式?
|
||||||
|
placeholder: 例如:目前只能手动截图或逐条复制粘贴
|
||||||
|
- type: textarea
|
||||||
|
id: reference
|
||||||
|
attributes:
|
||||||
|
label: 参考示例
|
||||||
|
description: 其他应用中是否有类似功能可以参考?
|
||||||
|
placeholder: 例如:微信的聊天记录导出功能、Telegram 的导出数据功能
|
||||||
|
- type: checkboxes
|
||||||
|
id: contribution
|
||||||
|
attributes:
|
||||||
|
label: 参与贡献
|
||||||
|
options:
|
||||||
|
- label: 我愿意提交 Pull Request 来实现这个功能
|
||||||
71
.github/ISSUE_TEMPLATE/question.yml
vendored
Normal file
71
.github/ISSUE_TEMPLATE/question.yml
vendored
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
name: "使用答疑"
|
||||||
|
description: "关于如何配置、如何使用项目的求助"
|
||||||
|
title: "[Question]: "
|
||||||
|
labels: ["type: question"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
在提问之前,请确保你已经仔细阅读过我们的官方文档。
|
||||||
|
- type: checkboxes
|
||||||
|
id: pre-check
|
||||||
|
attributes:
|
||||||
|
label: 提交前确认
|
||||||
|
options:
|
||||||
|
- label: 我已阅读过相关文档
|
||||||
|
required: true
|
||||||
|
- label: 我已搜索过现有的 Issues,没有找到类似问题
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: question-type
|
||||||
|
attributes:
|
||||||
|
label: 问题类型
|
||||||
|
description: 你的问题属于哪个方面?
|
||||||
|
options:
|
||||||
|
- 安装部署问题
|
||||||
|
- 配置相关问题
|
||||||
|
- 功能使用问题
|
||||||
|
- API 调用问题
|
||||||
|
- 错误排查问题
|
||||||
|
- 其他
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: question
|
||||||
|
attributes:
|
||||||
|
label: 问题描述
|
||||||
|
description: 清晰描述你遇到的问题或疑问
|
||||||
|
placeholder: 例如:我在 Windows 系统上安装后无法启动应用,双击图标没有任何反应
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: attempts
|
||||||
|
attributes:
|
||||||
|
label: 已尝试的方法
|
||||||
|
description: 你已经尝试过哪些解决方法?
|
||||||
|
placeholder: 例如:我尝试过重新安装、以管理员身份运行、关闭防火墙,但问题依然存在
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: environment
|
||||||
|
attributes:
|
||||||
|
label: 运行环境
|
||||||
|
description: 提供你的系统环境信息
|
||||||
|
placeholder: |
|
||||||
|
操作系统:Windows 11
|
||||||
|
应用版本:v1.2.3
|
||||||
|
系统架构:x64
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: code-snippet
|
||||||
|
attributes:
|
||||||
|
label: 相关配置或代码
|
||||||
|
description: 如果涉及配置或代码问题,请粘贴相关内容
|
||||||
|
placeholder: 粘贴你的配置文件或代码片段
|
||||||
|
render: javascript
|
||||||
|
- type: textarea
|
||||||
|
id: screenshots
|
||||||
|
attributes:
|
||||||
|
label: 截图或日志
|
||||||
|
description: 如有必要,请提供截图或错误日志
|
||||||
84
.github/workflows/issue-auto-assign.yml
vendored
Normal file
84
.github/workflows/issue-auto-assign.yml
vendored
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
name: Issue Auto Assign
|
||||||
|
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types: [opened, edited, reopened]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
assign-by-platform:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Assign issue by selected platform
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
env:
|
||||||
|
ASSIGNEE_WINDOWS: ${{ vars.ISSUE_ASSIGNEE_WINDOWS }}
|
||||||
|
ASSIGNEE_MACOS: ${{ vars.ISSUE_ASSIGNEE_MACOS }}
|
||||||
|
ASSIGNEE_LINUX: ${{ vars.ISSUE_ASSIGNEE_LINUX || 'H3CoF6' }}
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const issue = context.payload.issue;
|
||||||
|
if (!issue) {
|
||||||
|
core.info("No issue payload.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels = (issue.labels || []).map((l) => l.name);
|
||||||
|
if (!labels.includes("type: bug")) {
|
||||||
|
core.info("Skip non-bug issue.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = issue.body || "";
|
||||||
|
const match = body.match(/###\s*(?:使用平台|平台|Platform)\s*\r?\n+([^\r\n]+)/i);
|
||||||
|
if (!match) {
|
||||||
|
core.info("No platform field found in issue body.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawPlatform = match[1].trim().toLowerCase();
|
||||||
|
let platformKey = null;
|
||||||
|
if (rawPlatform.includes("windows")) platformKey = "windows";
|
||||||
|
if (rawPlatform.includes("macos")) platformKey = "macos";
|
||||||
|
if (rawPlatform.includes("linux")) platformKey = "linux";
|
||||||
|
|
||||||
|
if (!platformKey) {
|
||||||
|
core.info(`Unrecognized platform value: ${rawPlatform}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseAssignees = (value) =>
|
||||||
|
(value || "")
|
||||||
|
.split(",")
|
||||||
|
.map((v) => v.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const assigneeMap = {
|
||||||
|
windows: parseAssignees(process.env.ASSIGNEE_WINDOWS),
|
||||||
|
macos: parseAssignees(process.env.ASSIGNEE_MACOS),
|
||||||
|
linux: parseAssignees(process.env.ASSIGNEE_LINUX),
|
||||||
|
};
|
||||||
|
|
||||||
|
const candidates = assigneeMap[platformKey] || [];
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
core.info(`No assignee configured for platform: ${platformKey}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = new Set((issue.assignees || []).map((a) => a.login));
|
||||||
|
const toAdd = candidates.filter((u) => !existing.has(u));
|
||||||
|
if (toAdd.length === 0) {
|
||||||
|
core.info("All configured assignees already assigned.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await github.rest.issues.addAssignees({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: issue.number,
|
||||||
|
assignees: toAdd,
|
||||||
|
});
|
||||||
|
|
||||||
|
core.info(`Assigned issue #${issue.number} to: ${toAdd.join(", ")}`);
|
||||||
189
.github/workflows/release.yml
vendored
189
.github/workflows/release.yml
vendored
@@ -8,24 +8,104 @@ on:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
release-mac-arm64:
|
||||||
|
runs-on: macos-14
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out git repository
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Sync version with tag
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
VERSION=${GITHUB_REF_NAME#v}
|
||||||
|
echo "Syncing package.json version to $VERSION"
|
||||||
|
npm version $VERSION --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
|
- name: Build Frontend & Type Check
|
||||||
|
run: |
|
||||||
|
npx tsc
|
||||||
|
npx vite build
|
||||||
|
|
||||||
|
- name: Package and Publish macOS arm64 (unsigned DMG)
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
WF_SIGN_PRIVATE_KEY: ${{ secrets.WF_SIGN_PRIVATE_KEY }}
|
||||||
|
WF_SIGNING_REQUIRED: "1"
|
||||||
|
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||||
|
run: |
|
||||||
|
npx electron-builder --mac dmg --arm64 --publish always
|
||||||
|
|
||||||
|
release-linux:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out git repository
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Sync version with tag
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
VERSION=${GITHUB_REF_NAME#v}
|
||||||
|
echo "Syncing package.json version to $VERSION"
|
||||||
|
npm version $VERSION --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
|
- name: Build Frontend & Type Check
|
||||||
|
run: |
|
||||||
|
npx tsc
|
||||||
|
npx vite build
|
||||||
|
|
||||||
|
- name: Package and Publish Linux
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
WF_SIGN_PRIVATE_KEY: ${{ secrets.WF_SIGN_PRIVATE_KEY }}
|
||||||
|
WF_SIGNING_REQUIRED: "1"
|
||||||
|
run: |
|
||||||
|
npx electron-builder --linux --publish always
|
||||||
|
|
||||||
release:
|
release:
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out git repository
|
- name: Check out git repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
node-version: 22.12
|
node-version: 24
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: npm ci
|
run: npm install
|
||||||
|
|
||||||
- name: Sync version with tag
|
- name: Sync version with tag
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -42,20 +122,113 @@ jobs:
|
|||||||
- name: Package and Publish
|
- name: Package and Publish
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
WF_SIGN_PRIVATE_KEY: ${{ secrets.WF_SIGN_PRIVATE_KEY }}
|
||||||
|
WF_SIGNING_REQUIRED: "1"
|
||||||
run: |
|
run: |
|
||||||
npx electron-builder --publish always
|
npx electron-builder --publish always
|
||||||
|
|
||||||
- name: Update Release Notes
|
release-windows-arm64:
|
||||||
|
runs-on: windows-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out git repository
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Sync version with tag
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
VERSION=${GITHUB_REF_NAME#v}
|
||||||
|
echo "Syncing package.json version to $VERSION"
|
||||||
|
npm version $VERSION --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
|
- name: Build Frontend & Type Check
|
||||||
|
run: |
|
||||||
|
npx tsc
|
||||||
|
npx vite build
|
||||||
|
|
||||||
|
- name: Package and Publish Windows arm64
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
WF_SIGN_PRIVATE_KEY: ${{ secrets.WF_SIGN_PRIVATE_KEY }}
|
||||||
|
WF_SIGNING_REQUIRED: "1"
|
||||||
|
run: |
|
||||||
|
npx electron-builder --win nsis --arm64 --publish always '--config.artifactName=${productName}-${version}-arm64-Setup.${ext}'
|
||||||
|
|
||||||
|
update-release-notes:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs:
|
||||||
|
- release-mac-arm64
|
||||||
|
- release-linux
|
||||||
|
- release
|
||||||
|
- release-windows-arm64
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Generate release notes with platform download links
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
cat <<EOF > release_notes.md
|
set -euo pipefail
|
||||||
|
|
||||||
|
TAG="$GITHUB_REF_NAME"
|
||||||
|
REPO="$GITHUB_REPOSITORY"
|
||||||
|
RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG"
|
||||||
|
|
||||||
|
ASSETS_JSON="$(gh release view "$TAG" --repo "$REPO" --json assets)"
|
||||||
|
|
||||||
|
pick_asset() {
|
||||||
|
local pattern="$1"
|
||||||
|
echo "$ASSETS_JSON" | jq -r --arg p "$pattern" '[.assets[].name | select(test($p))][0] // ""'
|
||||||
|
}
|
||||||
|
|
||||||
|
WINDOWS_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("\\.exe$")) | select(test("arm64") | not)][0] // ""')"
|
||||||
|
WINDOWS_ARM64_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("arm64.*\\.exe$"))][0] // ""')"
|
||||||
|
MAC_ASSET="$(pick_asset "\\.dmg$")"
|
||||||
|
LINUX_DEB_ASSET="$(pick_asset "\\.deb$")"
|
||||||
|
LINUX_TAR_ASSET="$(pick_asset "\\.tar\\.gz$")"
|
||||||
|
LINUX_APPIMAGE_ASSET="$(pick_asset "\\.AppImage$")"
|
||||||
|
|
||||||
|
build_link() {
|
||||||
|
local name="$1"
|
||||||
|
if [ -n "$name" ]; then
|
||||||
|
echo "https://github.com/$REPO/releases/download/$TAG/$name"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
WINDOWS_URL="$(build_link "$WINDOWS_ASSET")"
|
||||||
|
WINDOWS_ARM64_URL="$(build_link "$WINDOWS_ARM64_ASSET")"
|
||||||
|
MAC_URL="$(build_link "$MAC_ASSET")"
|
||||||
|
LINUX_DEB_URL="$(build_link "$LINUX_DEB_ASSET")"
|
||||||
|
LINUX_TAR_URL="$(build_link "$LINUX_TAR_ASSET")"
|
||||||
|
LINUX_APPIMAGE_URL="$(build_link "$LINUX_APPIMAGE_ASSET")"
|
||||||
|
|
||||||
|
cat > release_notes.md <<EOF
|
||||||
## 更新日志
|
## 更新日志
|
||||||
修复了一些已知问题
|
修复了一些已知问题
|
||||||
|
|
||||||
## 查看更多日志/获取最新动态
|
## 查看更多日志/获取最新动态
|
||||||
[点击加入 Telegram 频道](https://t.me/weflow_cc)
|
[点击加入 Telegram 频道](https://t.me/weflow_cc)
|
||||||
|
|
||||||
|
## 下载
|
||||||
|
- Windows x64(Win10+): ${WINDOWS_URL:-$RELEASE_PAGE}
|
||||||
|
- Windows arm64: ${WINDOWS_ARM64_URL:-$RELEASE_PAGE}
|
||||||
|
- macOS(M系列芯片): ${MAC_URL:-$RELEASE_PAGE}
|
||||||
|
- Linux (.deb) (即将废弃): ${LINUX_DEB_URL:-$RELEASE_PAGE}
|
||||||
|
- Linux (.tar.gz): ${LINUX_TAR_URL:-$RELEASE_PAGE}
|
||||||
|
- linux (.AppImage): ${LINUX_APPIMAGE_URL:-$RELEASE_PAGE}
|
||||||
|
|
||||||
|
> 如果某个平台链接暂时未生成,可进入完整发布页查看全部资源:$RELEASE_PAGE
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
gh release edit "$GITHUB_REF_NAME" --notes-file release_notes.md
|
gh release edit "$TAG" --repo "$REPO" --notes-file release_notes.md
|
||||||
|
|||||||
13
.gitignore
vendored
13
.gitignore
vendored
@@ -56,8 +56,17 @@ Thumbs.db
|
|||||||
*.aps
|
*.aps
|
||||||
|
|
||||||
wcdb/
|
wcdb/
|
||||||
|
xkey/
|
||||||
|
server/
|
||||||
*info
|
*info
|
||||||
概述.md
|
|
||||||
chatlab-format.md
|
chatlab-format.md
|
||||||
*.bak
|
*.bak
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
|
AGENT.md
|
||||||
|
.claude/
|
||||||
|
CLAUDE.md
|
||||||
|
.agents/
|
||||||
|
resources/wx_send
|
||||||
|
概述.md
|
||||||
|
pnpm-lock.yaml
|
||||||
|
/pnpm-workspace.yaml
|
||||||
|
|||||||
4
.npmrc
4
.npmrc
@@ -1,3 +1,3 @@
|
|||||||
registry=https://registry.npmmirror.com
|
registry=https://registry.npmmirror.com
|
||||||
electron_mirror=https://npmmirror.com/mirrors/electron/
|
electron-mirror=https://npmmirror.com/mirrors/electron/
|
||||||
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
|
electron-builder-binaries-mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||||
|
|||||||
44
README.md
44
README.md
@@ -19,6 +19,7 @@ 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/weflow_cc">
|
<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">
|
||||||
@@ -40,7 +41,38 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
|
|||||||
- 年度报告与可视化概览
|
- 年度报告与可视化概览
|
||||||
- 导出聊天记录为 HTML 等格式
|
- 导出聊天记录为 HTML 等格式
|
||||||
- HTTP API 接口(供开发者集成)
|
- HTTP API 接口(供开发者集成)
|
||||||
|
- 查看完整能力清单:[详细功能](#详细功能清单)
|
||||||
|
|
||||||
|
## 支持平台与设备
|
||||||
|
|
||||||
|
|
||||||
|
| 平台 | 设备/架构 | 安装包 |
|
||||||
|
|------|----------|--------|
|
||||||
|
| Windows | Windows10+、x64(amd64) | `.exe` |
|
||||||
|
| macOS | Apple Silicon(M 系列,arm64) | `.dmg` |
|
||||||
|
| Linux | x64 设备(amd64) | `.deb`、`.tar.gz` |
|
||||||
|
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
若你只想使用成品版本,可前往 [Releases](https://github.com/hicccc77/WeFlow/releases) 下载并安装。
|
||||||
|
|
||||||
|
## 详细功能清单
|
||||||
|
|
||||||
|
当前版本已支持以下能力:
|
||||||
|
|
||||||
|
| 功能模块 | 说明 |
|
||||||
|
|---------|------|
|
||||||
|
| **聊天** | 解密聊天中的图片、视频、实况(仅支持谷歌协议拍摄的实况);支持**修改**、删除**本地**消息;实时刷新最新消息,无需生成解密中间数据库 |
|
||||||
|
| **实时弹窗通知** | 新消息到达时提供桌面弹窗提醒,便于及时查看重要会话,提供黑白名单功能 |
|
||||||
|
| **私聊分析** | 统计好友间消息数量;分析消息类型与发送比例;查看消息时段分布等 |
|
||||||
|
| **群聊分析** | 查看群成员详细信息;分析群内发言排行、活跃时段和媒体内容 |
|
||||||
|
| **年度报告** | 生成按年统计的年度报告,或跨年度的长期历史报告 |
|
||||||
|
| **双人报告** | 选择指定好友,基于双方聊天记录生成专属分析报告 |
|
||||||
|
| **消息导出** | 将微信聊天记录导出为多种格式:JSON、HTML、TXT、Excel、CSV、PGSQL、ChatLab专属格式等 |
|
||||||
|
| **朋友圈** | 解密朋友圈图片、视频、实况;导出朋友圈内容;拦截朋友圈的删除与隐藏操作;突破时间访问限制 |
|
||||||
|
| **联系人** | 导出微信好友、群聊、公众号信息;尝试找回曾经的好友(功能尚不完善) |
|
||||||
|
| **HTTP API 映射** | 将本地消息能力映射为 HTTP API,便于对接外部系统、自动化脚本与二次开发 |
|
||||||
|
|
||||||
## HTTP API
|
## HTTP API
|
||||||
|
|
||||||
@@ -54,13 +86,9 @@ WeFlow 提供本地 HTTP API 服务,支持通过接口查询消息数据,可
|
|||||||
- **访问地址**:`http://127.0.0.1:5031`
|
- **访问地址**:`http://127.0.0.1:5031`
|
||||||
- **支持格式**:原始 JSON 或 [ChatLab](https://chatlab.fun/) 标准格式
|
- **支持格式**:原始 JSON 或 [ChatLab](https://chatlab.fun/) 标准格式
|
||||||
|
|
||||||
📖 完整接口文档:[点击查看](docs/HTTP-API.md)
|
完整接口文档:[点击查看](docs/HTTP-API.md)
|
||||||
|
|
||||||
|
|
||||||
## 快速开始
|
|
||||||
|
|
||||||
若你只想使用成品版本,可前往 Release 下载并安装。
|
|
||||||
|
|
||||||
## 面向开发者
|
## 面向开发者
|
||||||
|
|
||||||
如果你想从源码构建或为项目贡献代码,请遵循以下步骤:
|
如果你想从源码构建或为项目贡献代码,请遵循以下步骤:
|
||||||
@@ -76,14 +104,8 @@ npm install
|
|||||||
# 3. 运行应用(开发模式)
|
# 3. 运行应用(开发模式)
|
||||||
npm run dev
|
npm run dev
|
||||||
|
|
||||||
# 4. 打包可执行文件
|
|
||||||
npm run build
|
|
||||||
```
|
```
|
||||||
|
|
||||||
打包产物在 `release` 目录下。
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 致谢
|
## 致谢
|
||||||
|
|
||||||
- [密语 CipherTalk](https://github.com/ILoveBingLu/miyu) 为本项目提供了基础框架
|
- [密语 CipherTalk](https://github.com/ILoveBingLu/miyu) 为本项目提供了基础框架
|
||||||
|
|||||||
574
docs/HTTP-API.md
574
docs/HTTP-API.md
@@ -1,33 +1,46 @@
|
|||||||
# WeFlow HTTP API 接口文档
|
# WeFlow HTTP API / Push 文档
|
||||||
|
|
||||||
WeFlow 提供 HTTP API 服务,支持通过 HTTP 接口查询消息数据,支持 [ChatLab](https://github.com/nichuanfang/chatlab-format) 标准化格式输出。
|
WeFlow 提供本地 HTTP API,便于外部脚本或工具读取聊天记录、会话、联系人、群成员和导出的媒体文件;也支持在检测到新消息后通过固定 SSE 地址主动推送消息事件。
|
||||||
|
|
||||||
## 启用 API 服务
|
## 启用方式
|
||||||
|
|
||||||
在设置页面 → API 服务 → 点击「启动服务」按钮。
|
在应用设置页启用 `API 服务`。
|
||||||
|
|
||||||
默认端口:`5031`
|
- 默认监听地址:`127.0.0.1`
|
||||||
|
- 默认端口:`5031`
|
||||||
## 基础地址
|
- 基础地址:`http://127.0.0.1:5031`
|
||||||
|
- 可选开启 `主动推送`,检测到新收到的消息后会通过 `GET /api/v1/push/messages` 推送给 SSE 订阅端
|
||||||
```
|
|
||||||
http://127.0.0.1:5031
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 接口列表
|
## 接口列表
|
||||||
|
|
||||||
### 1. 健康检查
|
- `GET /health`
|
||||||
|
- `GET /api/v1/health`
|
||||||
|
- `GET /api/v1/push/messages`
|
||||||
|
- `GET /api/v1/messages`
|
||||||
|
- `GET /api/v1/messages/new`
|
||||||
|
- `GET /api/v1/sessions`
|
||||||
|
- `GET /api/v1/contacts`
|
||||||
|
- `GET /api/v1/group-members`
|
||||||
|
- `GET /api/v1/media/*`
|
||||||
|
|
||||||
检查 API 服务是否正常运行。
|
---
|
||||||
|
|
||||||
|
## 1. 健康检查
|
||||||
|
|
||||||
**请求**
|
**请求**
|
||||||
```
|
|
||||||
|
```http
|
||||||
GET /health
|
GET /health
|
||||||
```
|
```
|
||||||
|
|
||||||
|
或
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/health
|
||||||
|
```
|
||||||
|
|
||||||
**响应**
|
**响应**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"status": "ok"
|
"status": "ok"
|
||||||
@@ -36,158 +49,223 @@ GET /health
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 2. 获取消息列表
|
## 2. 主动推送
|
||||||
|
|
||||||
获取指定会话的消息,支持 ChatLab 格式输出。
|
通过 SSE 长连接接收新消息事件,端口与 HTTP API 共用。
|
||||||
|
|
||||||
**请求**
|
**请求**
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/push/messages
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 说明
|
||||||
|
|
||||||
|
- 需要先在设置页开启 `HTTP API 服务`
|
||||||
|
- 同时需要开启 `主动推送`
|
||||||
|
- 响应类型为 `text/event-stream`
|
||||||
|
- 新消息事件名固定为 `message.new`
|
||||||
|
- 建议接收端按 `messageKey` 去重
|
||||||
|
|
||||||
|
### 事件字段
|
||||||
|
|
||||||
|
- `event`
|
||||||
|
- `sessionId`
|
||||||
|
- `messageKey`
|
||||||
|
- `avatarUrl`
|
||||||
|
- `sourceName`
|
||||||
|
- `groupName`(仅群聊)
|
||||||
|
- `content`
|
||||||
|
|
||||||
|
### 示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -N "http://127.0.0.1:5031/api/v1/push/messages"
|
||||||
|
```
|
||||||
|
|
||||||
|
示例事件:
|
||||||
|
|
||||||
|
```text
|
||||||
|
event: message.new
|
||||||
|
data: {"event":"message.new","sessionId":"xxx@chatroom","messageKey":"server:123456:1760000123:1760000123000:321:wxid_member:1","avatarUrl":"https://example.com/group.jpg","sourceName":"李四","groupName":"项目群","content":"[图片]"}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 获取消息
|
||||||
|
|
||||||
|
读取指定会话的消息,支持原始 JSON 和 ChatLab 格式。
|
||||||
|
|
||||||
|
**请求**
|
||||||
|
|
||||||
|
```http
|
||||||
GET /api/v1/messages
|
GET /api/v1/messages
|
||||||
```
|
```
|
||||||
|
|
||||||
**参数**
|
### 参数
|
||||||
|
|
||||||
| 参数名 | 类型 | 必填 | 说明 |
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|--------|------|------|------|
|
| --- | --- | --- | --- |
|
||||||
| `talker` | string | ✅ | 会话 ID(wxid 或群 ID) |
|
| `talker` | string | 是 | 会话 ID。私聊通常是对方 `wxid`,群聊是 `xxx@chatroom` |
|
||||||
| `limit` | number | ❌ | 返回数量限制,默认 100,范围 `1~10000` |
|
| `limit` | number | 否 | 返回条数,默认 `100`,范围 `1~10000` |
|
||||||
| `offset` | number | ❌ | 偏移量,用于分页,默认 0 |
|
| `offset` | number | 否 | 分页偏移,默认 `0` |
|
||||||
| `start` | string | ❌ | 开始时间,格式 YYYYMMDD |
|
| `start` | string | 否 | 开始时间,支持 `YYYYMMDD` 或时间戳 |
|
||||||
| `end` | string | ❌ | 结束时间,格式 YYYYMMDD |
|
| `end` | string | 否 | 结束时间,支持 `YYYYMMDD` 或时间戳 |
|
||||||
| `keyword` | string | ❌ | 关键词过滤(基于消息显示文本) |
|
| `keyword` | string | 否 | 基于消息显示文本过滤 |
|
||||||
| `chatlab` | string | ❌ | 设为 `1` 则输出 ChatLab 格式 |
|
| `chatlab` | string | 否 | `1/true` 时输出 ChatLab 格式 |
|
||||||
| `format` | string | ❌ | 输出格式:`json`(默认)或 `chatlab` |
|
| `format` | string | 否 | `json` 或 `chatlab` |
|
||||||
| `media` | string | ❌ | 设为 `1` 时导出媒体并返回媒体路径(兼容别名 `meiti`);`0` 时媒体返回占位符 |
|
| `media` | string | 否 | `1/true` 时导出媒体并返回媒体地址,兼容别名 `meiti` |
|
||||||
| `image` | string | ❌ | 在 `media=1` 时控制图片导出,`1/0`(兼容别名 `tupian`) |
|
| `image` | string | 否 | 在 `media=1` 时控制图片导出,兼容别名 `tupian` |
|
||||||
| `voice` | string | ❌ | 在 `media=1` 时控制语音导出,`1/0`(兼容别名 `vioce`) |
|
| `voice` | string | 否 | 在 `media=1` 时控制语音导出,兼容别名 `vioce` |
|
||||||
| `video` | string | ❌ | 在 `media=1` 时控制视频导出,`1/0` |
|
| `video` | string | 否 | 在 `media=1` 时控制视频导出 |
|
||||||
| `emoji` | string | ❌ | 在 `media=1` 时控制表情导出,`1/0` |
|
| `emoji` | string | 否 | 在 `media=1` 时控制表情导出 |
|
||||||
|
|
||||||
默认媒体导出目录:`%USERPROFILE%\\Documents\\WeFlow\\api-media`
|
### 示例
|
||||||
|
|
||||||
**示例请求**
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 获取消息(原始格式)
|
curl "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=20"
|
||||||
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=50
|
curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&chatlab=1"
|
||||||
|
curl "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&start=20260101&end=20260131"
|
||||||
# 获取消息(ChatLab 格式)
|
curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&media=1&image=1&voice=0&video=0&emoji=0"
|
||||||
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`
|
||||||
|
- `talker`
|
||||||
|
- `count`
|
||||||
|
- `hasMore`
|
||||||
|
- `media.enabled`
|
||||||
|
- `media.exportPath`
|
||||||
|
- `media.count`
|
||||||
|
- `messages`
|
||||||
|
|
||||||
|
单条消息字段:
|
||||||
|
|
||||||
|
- `localId`
|
||||||
|
- `serverId`
|
||||||
|
- `localType`
|
||||||
|
- `createTime`
|
||||||
|
- `isSend`
|
||||||
|
- `senderUsername`
|
||||||
|
- `content`
|
||||||
|
- `rawContent`
|
||||||
|
- `parsedContent`
|
||||||
|
- `mediaType`
|
||||||
|
- `mediaFileName`
|
||||||
|
- `mediaUrl`
|
||||||
|
- `mediaLocalPath`
|
||||||
|
|
||||||
|
**示例响应**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"talker": "wxid_xxx",
|
"talker": "xxx@chatroom",
|
||||||
"count": 50,
|
"count": 2,
|
||||||
"hasMore": true,
|
"hasMore": true,
|
||||||
"media": {
|
"media": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"exportPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media",
|
"exportPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media",
|
||||||
"count": 12
|
"count": 1
|
||||||
},
|
},
|
||||||
"messages": [
|
"messages": [
|
||||||
{
|
{
|
||||||
"localId": 123,
|
"localId": 123,
|
||||||
|
"serverId": "456",
|
||||||
|
"localType": 1,
|
||||||
|
"createTime": 1738713600,
|
||||||
|
"isSend": 0,
|
||||||
|
"senderUsername": "wxid_member",
|
||||||
|
"content": "你好",
|
||||||
|
"rawContent": "你好",
|
||||||
|
"parsedContent": "你好"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"localId": 124,
|
||||||
"localType": 3,
|
"localType": 3,
|
||||||
|
"createTime": 1738713660,
|
||||||
|
"isSend": 0,
|
||||||
|
"senderUsername": "wxid_member",
|
||||||
"content": "[图片]",
|
"content": "[图片]",
|
||||||
"createTime": 1738713600000,
|
|
||||||
"senderUsername": "wxid_sender",
|
|
||||||
"mediaType": "image",
|
"mediaType": "image",
|
||||||
"mediaFileName": "image_123.jpg",
|
"mediaFileName": "abc123.jpg",
|
||||||
"mediaPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\wxid_xxx\\images\\image_123.jpg"
|
"mediaUrl": "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/images/abc123.jpg",
|
||||||
|
"mediaLocalPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\xxx@chatroom\\images\\abc123.jpg"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**响应(ChatLab 格式)**
|
### ChatLab 响应
|
||||||
```json
|
|
||||||
{
|
当 `chatlab=1` 或 `format=chatlab` 时,返回 ChatLab 结构:
|
||||||
"chatlab": {
|
|
||||||
"version": "0.0.2",
|
- `chatlab.version`
|
||||||
"exportedAt": 1738713600000,
|
- `chatlab.exportedAt`
|
||||||
"generator": "WeFlow",
|
- `chatlab.generator`
|
||||||
"description": "Exported from WeFlow"
|
- `meta.name`
|
||||||
},
|
- `meta.platform`
|
||||||
"meta": {
|
- `meta.type`
|
||||||
"name": "会话名称",
|
- `meta.groupId`
|
||||||
"platform": "wechat",
|
- `meta.groupAvatar`
|
||||||
"type": "private",
|
- `meta.ownerId`
|
||||||
"ownerId": "wxid_me"
|
- `members[].platformId`
|
||||||
},
|
- `members[].accountName`
|
||||||
"members": [
|
- `members[].groupNickname`
|
||||||
{
|
- `members[].avatar`
|
||||||
"platformId": "wxid_xxx",
|
- `messages[].sender`
|
||||||
"accountName": "用户名",
|
- `messages[].accountName`
|
||||||
"groupNickname": "群昵称"
|
- `messages[].groupNickname`
|
||||||
}
|
- `messages[].timestamp`
|
||||||
],
|
- `messages[].type`
|
||||||
"messages": [
|
- `messages[].content`
|
||||||
{
|
- `messages[].platformMessageId`
|
||||||
"sender": "wxid_xxx",
|
- `messages[].mediaPath`
|
||||||
"accountName": "用户名",
|
|
||||||
"timestamp": 1738713600000,
|
群聊里 `groupNickname` 会优先来自群成员群昵称;若源数据缺失,则回退为空或展示名。
|
||||||
"type": 0,
|
|
||||||
"content": "消息内容",
|
|
||||||
"mediaPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\wxid_xxx\\images\\image_123.jpg"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"media": {
|
|
||||||
"enabled": true,
|
|
||||||
"exportPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media",
|
|
||||||
"count": 12
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 3. 获取会话列表
|
## 4. 获取会话列表
|
||||||
|
|
||||||
获取所有会话列表。
|
|
||||||
|
|
||||||
**请求**
|
**请求**
|
||||||
```
|
|
||||||
|
```http
|
||||||
GET /api/v1/sessions
|
GET /api/v1/sessions
|
||||||
```
|
```
|
||||||
|
|
||||||
**参数**
|
### 参数
|
||||||
|
|
||||||
| 参数名 | 类型 | 必填 | 说明 |
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|--------|------|------|------|
|
| --- | --- | --- | --- |
|
||||||
| `keyword` | string | ❌ | 搜索关键词,匹配会话名或 ID |
|
| `keyword` | string | 否 | 匹配 `username` 或 `displayName` |
|
||||||
| `limit` | number | ❌ | 返回数量限制,默认 100 |
|
| `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
|
- `success`
|
||||||
```
|
- `count`
|
||||||
|
- `sessions[].username`
|
||||||
|
- `sessions[].displayName`
|
||||||
|
- `sessions[].type`
|
||||||
|
- `sessions[].lastTimestamp`
|
||||||
|
- `sessions[].unreadCount`
|
||||||
|
|
||||||
|
**示例响应**
|
||||||
|
|
||||||
**响应**
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"count": 50,
|
"count": 1,
|
||||||
"total": 100,
|
|
||||||
"sessions": [
|
"sessions": [
|
||||||
{
|
{
|
||||||
"username": "wxid_xxx",
|
"username": "xxx@chatroom",
|
||||||
"displayName": "用户名",
|
"displayName": "项目群",
|
||||||
"lastMessage": "最后一条消息",
|
"type": 2,
|
||||||
"lastTime": 1738713600000,
|
"lastTimestamp": 1738713600,
|
||||||
"unreadCount": 0
|
"unreadCount": 0
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -196,40 +274,48 @@ GET http://127.0.0.1:5031/api/v1/sessions?keyword=工作群&limit=20
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 4. 获取联系人列表
|
## 5. 获取联系人列表
|
||||||
|
|
||||||
获取所有联系人信息。
|
|
||||||
|
|
||||||
**请求**
|
**请求**
|
||||||
```
|
|
||||||
|
```http
|
||||||
GET /api/v1/contacts
|
GET /api/v1/contacts
|
||||||
```
|
```
|
||||||
|
|
||||||
**参数**
|
### 参数
|
||||||
|
|
||||||
| 参数名 | 类型 | 必填 | 说明 |
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|--------|------|------|------|
|
| --- | --- | --- | --- |
|
||||||
| `keyword` | string | ❌ | 搜索关键词 |
|
| `keyword` | string | 否 | 匹配 `username`、`nickname`、`remark`、`displayName` |
|
||||||
| `limit` | number | ❌ | 返回数量限制,默认 100 |
|
| `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=张三
|
- `success`
|
||||||
```
|
- `count`
|
||||||
|
- `contacts[].username`
|
||||||
|
- `contacts[].displayName`
|
||||||
|
- `contacts[].remark`
|
||||||
|
- `contacts[].nickname`
|
||||||
|
- `contacts[].alias`
|
||||||
|
- `contacts[].avatarUrl`
|
||||||
|
- `contacts[].type`
|
||||||
|
|
||||||
|
**示例响应**
|
||||||
|
|
||||||
**响应**
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"count": 50,
|
"count": 1,
|
||||||
"contacts": [
|
"contacts": [
|
||||||
{
|
{
|
||||||
"userName": "wxid_xxx",
|
"username": "wxid_xxx",
|
||||||
"alias": "微信号",
|
"displayName": "张三",
|
||||||
"nickName": "昵称",
|
"remark": "客户张三",
|
||||||
"remark": "备注名"
|
"nickname": "张三",
|
||||||
|
"alias": "zhangsan",
|
||||||
|
"avatarUrl": "https://example.com/avatar.jpg",
|
||||||
|
"type": "friend"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -237,60 +323,157 @@ GET http://127.0.0.1:5031/api/v1/contacts?keyword=张三
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ChatLab 格式说明
|
## 6. 获取群成员列表
|
||||||
|
|
||||||
ChatLab 是一种标准化的聊天记录交换格式,版本 0.0.2。
|
返回群成员的 `wxid`、群昵称、备注、微信号等信息。
|
||||||
|
|
||||||
### 消息类型映射
|
**请求**
|
||||||
|
|
||||||
| ChatLab Type | 值 | 说明 |
|
```http
|
||||||
|--------------|-----|------|
|
GET /api/v1/group-members
|
||||||
| TEXT | 0 | 文本消息 |
|
```
|
||||||
| IMAGE | 1 | 图片 |
|
|
||||||
| VOICE | 2 | 语音 |
|
### 参数
|
||||||
| VIDEO | 3 | 视频 |
|
|
||||||
| FILE | 4 | 文件 |
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
| EMOJI | 5 | 表情 |
|
| --- | --- | --- | --- |
|
||||||
| LINK | 7 | 链接 |
|
| `chatroomId` | string | 是 | 群 ID,兼容使用 `talker` 传入 |
|
||||||
| LOCATION | 8 | 位置 |
|
| `includeMessageCounts` | string | 否 | `1/true` 时附带成员发言数 |
|
||||||
| RED_PACKET | 20 | 红包 |
|
| `withCounts` | string | 否 | `includeMessageCounts` 的别名 |
|
||||||
| TRANSFER | 21 | 转账 |
|
| `forceRefresh` | string | 否 | `1/true` 时跳过内存缓存强制刷新 |
|
||||||
| CALL | 23 | 通话 |
|
|
||||||
| SYSTEM | 80 | 系统消息 |
|
### 响应字段
|
||||||
| RECALL | 81 | 撤回消息 |
|
|
||||||
| OTHER | 99 | 其他 |
|
- `success`
|
||||||
|
- `chatroomId`
|
||||||
|
- `count`
|
||||||
|
- `fromCache`
|
||||||
|
- `updatedAt`
|
||||||
|
- `members[].wxid`
|
||||||
|
- `members[].displayName`
|
||||||
|
- `members[].nickname`
|
||||||
|
- `members[].remark`
|
||||||
|
- `members[].alias`
|
||||||
|
- `members[].groupNickname`
|
||||||
|
- `members[].avatarUrl`
|
||||||
|
- `members[].isOwner`
|
||||||
|
- `members[].isFriend`
|
||||||
|
- `members[].messageCount`
|
||||||
|
|
||||||
|
**示例请求**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom"
|
||||||
|
curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom&includeMessageCounts=1&forceRefresh=1"
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例响应**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"chatroomId": "xxx@chatroom",
|
||||||
|
"count": 2,
|
||||||
|
"fromCache": false,
|
||||||
|
"updatedAt": 1760000000000,
|
||||||
|
"members": [
|
||||||
|
{
|
||||||
|
"wxid": "wxid_member_a",
|
||||||
|
"displayName": "客户A",
|
||||||
|
"nickname": "阿甲",
|
||||||
|
"remark": "客户A",
|
||||||
|
"alias": "kehua",
|
||||||
|
"groupNickname": "甲方",
|
||||||
|
"avatarUrl": "https://example.com/a.jpg",
|
||||||
|
"isOwner": true,
|
||||||
|
"isFriend": true,
|
||||||
|
"messageCount": 128
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"wxid": "wxid_member_b",
|
||||||
|
"displayName": "李四",
|
||||||
|
"nickname": "李四",
|
||||||
|
"remark": "",
|
||||||
|
"alias": "",
|
||||||
|
"groupNickname": "",
|
||||||
|
"avatarUrl": "",
|
||||||
|
"isOwner": false,
|
||||||
|
"isFriend": false,
|
||||||
|
"messageCount": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- `displayName` 是当前应用内的主展示名。
|
||||||
|
- `groupNickname` 是成员在该群里的群昵称。
|
||||||
|
- `remark` 是你对该联系人的备注。
|
||||||
|
- `alias` 是微信号。
|
||||||
|
- 当微信源数据里没有群昵称时,`groupNickname` 会为空。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 使用示例
|
## 7. 访问导出媒体
|
||||||
|
|
||||||
|
通过消息接口启用 `media=1` 后,接口会先把图片、语音、视频、表情导出到本地缓存目录,再返回可访问的 HTTP 地址。
|
||||||
|
|
||||||
|
**请求**
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/media/{relativePath}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/images/abc123.jpg"
|
||||||
|
curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/voices/voice_100.wav"
|
||||||
|
curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/videos/video_200.mp4"
|
||||||
|
curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/emojis/emoji_300.gif"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 支持的 Content-Type
|
||||||
|
|
||||||
|
| 扩展名 | Content-Type |
|
||||||
|
| --- | --- |
|
||||||
|
| `.png` | `image/png` |
|
||||||
|
| `.jpg` / `.jpeg` | `image/jpeg` |
|
||||||
|
| `.gif` | `image/gif` |
|
||||||
|
| `.webp` | `image/webp` |
|
||||||
|
| `.wav` | `audio/wav` |
|
||||||
|
| `.mp3` | `audio/mpeg` |
|
||||||
|
| `.mp4` | `video/mp4` |
|
||||||
|
|
||||||
|
常见错误响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Media not found"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 使用示例
|
||||||
|
|
||||||
### PowerShell
|
### PowerShell
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# 健康检查
|
|
||||||
Invoke-RestMethod http://127.0.0.1:5031/health
|
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/sessions
|
||||||
|
|
||||||
# 获取消息
|
|
||||||
Invoke-RestMethod "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=10"
|
Invoke-RestMethod "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=10"
|
||||||
|
Invoke-RestMethod "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom&includeMessageCounts=1"
|
||||||
# 获取 ChatLab 格式
|
|
||||||
Invoke-RestMethod "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1" | ConvertTo-Json -Depth 10
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### cURL
|
### cURL
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 健康检查
|
|
||||||
curl http://127.0.0.1:5031/health
|
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"
|
curl "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1"
|
||||||
|
curl "http://127.0.0.1:5031/api/v1/contacts?keyword=张三"
|
||||||
|
curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Python
|
### Python
|
||||||
@@ -300,39 +483,26 @@ import requests
|
|||||||
|
|
||||||
BASE_URL = "http://127.0.0.1:5031"
|
BASE_URL = "http://127.0.0.1:5031"
|
||||||
|
|
||||||
# 获取会话列表
|
messages = requests.get(
|
||||||
sessions = requests.get(f"{BASE_URL}/api/v1/sessions").json()
|
f"{BASE_URL}/api/v1/messages",
|
||||||
print(sessions)
|
params={"talker": "xxx@chatroom", "limit": 50}
|
||||||
|
).json()
|
||||||
|
|
||||||
|
members = requests.get(
|
||||||
|
f"{BASE_URL}/api/v1/group-members",
|
||||||
|
params={"chatroomId": "xxx@chatroom", "includeMessageCounts": 1}
|
||||||
|
).json()
|
||||||
|
|
||||||
# 获取消息
|
|
||||||
messages = requests.get(f"{BASE_URL}/api/v1/messages", params={
|
|
||||||
"talker": "wxid_xxx",
|
|
||||||
"limit": 100,
|
|
||||||
"chatlab": 1
|
|
||||||
}).json()
|
|
||||||
print(messages)
|
print(messages)
|
||||||
```
|
print(members)
|
||||||
|
|
||||||
### 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);
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 注意事项
|
## 9. 注意事项
|
||||||
|
|
||||||
1. API 仅监听本地地址 `127.0.0.1`,不对外网开放
|
1. API 仅监听本机 `127.0.0.1`,不对外网开放。
|
||||||
2. 需要先连接数据库才能查询数据
|
2. 使用前需要先在 WeFlow 中完成数据库连接。
|
||||||
3. 时间参数格式为 `YYYYMMDD`(如 20260205)
|
3. `start` 和 `end` 支持 `YYYYMMDD` 与时间戳;纯 `YYYYMMDD` 的 `end` 会扩展到当天 `23:59:59`。
|
||||||
4. 支持 CORS,可从浏览器前端直接调用
|
4. 群成员的 `groupNickname` 依赖微信源数据;源数据缺失时不会自动补出。
|
||||||
|
5. 媒体访问链接只有在对应消息已经通过 `media=1` 导出后才可访问。
|
||||||
|
|||||||
14
electron/entitlements.mac.plist
Normal file
14
electron/entitlements.mac.plist
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.cs.debugger</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.get-task-allow</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.disable-library-validation</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
56
electron/exportWorker.ts
Normal file
56
electron/exportWorker.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { parentPort, workerData } from 'worker_threads'
|
||||||
|
import type { ExportOptions } from './services/exportService'
|
||||||
|
|
||||||
|
interface ExportWorkerConfig {
|
||||||
|
sessionIds: string[]
|
||||||
|
outputDir: string
|
||||||
|
options: ExportOptions
|
||||||
|
resourcesPath?: string
|
||||||
|
userDataPath?: string
|
||||||
|
logEnabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = workerData as ExportWorkerConfig
|
||||||
|
process.env.WEFLOW_WORKER = '1'
|
||||||
|
if (config.resourcesPath) {
|
||||||
|
process.env.WCDB_RESOURCES_PATH = config.resourcesPath
|
||||||
|
}
|
||||||
|
if (config.userDataPath) {
|
||||||
|
process.env.WEFLOW_USER_DATA_PATH = config.userDataPath
|
||||||
|
process.env.WEFLOW_CONFIG_CWD = config.userDataPath
|
||||||
|
}
|
||||||
|
process.env.WEFLOW_PROJECT_NAME = process.env.WEFLOW_PROJECT_NAME || 'WeFlow'
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const [{ wcdbService }, { exportService }] = await Promise.all([
|
||||||
|
import('./services/wcdbService'),
|
||||||
|
import('./services/exportService')
|
||||||
|
])
|
||||||
|
|
||||||
|
wcdbService.setPaths(config.resourcesPath || '', config.userDataPath || '')
|
||||||
|
wcdbService.setLogEnabled(config.logEnabled === true)
|
||||||
|
|
||||||
|
const result = await exportService.exportSessions(
|
||||||
|
Array.isArray(config.sessionIds) ? config.sessionIds : [],
|
||||||
|
String(config.outputDir || ''),
|
||||||
|
config.options || { format: 'json' },
|
||||||
|
(progress) => {
|
||||||
|
parentPort?.postMessage({
|
||||||
|
type: 'export:progress',
|
||||||
|
data: progress
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
parentPort?.postMessage({
|
||||||
|
type: 'export:result',
|
||||||
|
data: result
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch((error) => {
|
||||||
|
parentPort?.postMessage({
|
||||||
|
type: 'export:error',
|
||||||
|
error: String(error)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -10,7 +10,7 @@ type WorkerPayload = {
|
|||||||
thumbOnly: boolean
|
thumbOnly: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type Candidate = { score: number; path: string; isThumb: boolean; hasX: boolean }
|
type Candidate = { score: number; path: string; isThumb: boolean }
|
||||||
|
|
||||||
const payload = workerData as WorkerPayload
|
const payload = workerData as WorkerPayload
|
||||||
|
|
||||||
@@ -18,16 +18,26 @@ function looksLikeMd5(value: string): boolean {
|
|||||||
return /^[a-fA-F0-9]{16,32}$/.test(value)
|
return /^[a-fA-F0-9]{16,32}$/.test(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stripDatVariantSuffix(base: string): string {
|
||||||
|
const lower = base.toLowerCase()
|
||||||
|
const suffixes = ['_thumb', '.thumb', '_hd', '.hd', '_h', '.h', '_t', '.t', '_c', '.c']
|
||||||
|
for (const suffix of suffixes) {
|
||||||
|
if (lower.endsWith(suffix)) {
|
||||||
|
return lower.slice(0, -suffix.length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (/[._][a-z]$/.test(lower)) {
|
||||||
|
return lower.slice(0, -2)
|
||||||
|
}
|
||||||
|
return lower
|
||||||
|
}
|
||||||
|
|
||||||
function hasXVariant(baseLower: string): boolean {
|
function hasXVariant(baseLower: string): boolean {
|
||||||
return /[._][a-z]$/.test(baseLower)
|
return stripDatVariantSuffix(baseLower) !== baseLower
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasImageVariantSuffix(baseLower: string): boolean {
|
function hasImageVariantSuffix(baseLower: string): boolean {
|
||||||
return /[._][a-z]$/.test(baseLower)
|
return stripDatVariantSuffix(baseLower) !== baseLower
|
||||||
}
|
|
||||||
|
|
||||||
function isLikelyImageDatBase(baseLower: string): boolean {
|
|
||||||
return hasImageVariantSuffix(baseLower) || looksLikeMd5(baseLower)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeDatBase(name: string): string {
|
function normalizeDatBase(name: string): string {
|
||||||
@@ -35,10 +45,17 @@ function normalizeDatBase(name: string): string {
|
|||||||
if (base.endsWith('.dat') || base.endsWith('.jpg')) {
|
if (base.endsWith('.dat') || base.endsWith('.jpg')) {
|
||||||
base = base.slice(0, -4)
|
base = base.slice(0, -4)
|
||||||
}
|
}
|
||||||
while (/[._][a-z]$/.test(base)) {
|
while (true) {
|
||||||
base = base.slice(0, -2)
|
const stripped = stripDatVariantSuffix(base)
|
||||||
|
if (stripped === base) {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
base = stripped
|
||||||
}
|
}
|
||||||
return base
|
}
|
||||||
|
|
||||||
|
function isLikelyImageDatBase(baseLower: string): boolean {
|
||||||
|
return hasImageVariantSuffix(baseLower) || looksLikeMd5(normalizeDatBase(baseLower))
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchesDatName(fileName: string, datName: string): boolean {
|
function matchesDatName(fileName: string, datName: string): boolean {
|
||||||
@@ -47,25 +64,23 @@ function matchesDatName(fileName: string, datName: string): boolean {
|
|||||||
const normalizedBase = normalizeDatBase(base)
|
const normalizedBase = normalizeDatBase(base)
|
||||||
const normalizedTarget = normalizeDatBase(datName.toLowerCase())
|
const normalizedTarget = normalizeDatBase(datName.toLowerCase())
|
||||||
if (normalizedBase === normalizedTarget) return true
|
if (normalizedBase === normalizedTarget) return true
|
||||||
const pattern = new RegExp(`^${datName}(?:[._][a-z])?\\.dat$`)
|
return lower.endsWith('.dat') && lower.includes(normalizedTarget)
|
||||||
if (pattern.test(lower)) return true
|
|
||||||
return lower.endsWith('.dat') && lower.includes(datName)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function scoreDatName(fileName: string): number {
|
function scoreDatName(fileName: string): number {
|
||||||
if (fileName.includes('.t.dat') || fileName.includes('_t.dat')) return 1
|
const lower = fileName.toLowerCase()
|
||||||
if (fileName.includes('.c.dat') || fileName.includes('_c.dat')) return 1
|
const baseLower = lower.endsWith('.dat') ? lower.slice(0, -4) : lower
|
||||||
return 2
|
if (baseLower.endsWith('_h') || baseLower.endsWith('.h')) return 600
|
||||||
|
if (!hasXVariant(baseLower)) return 500
|
||||||
|
if (baseLower.endsWith('_hd') || baseLower.endsWith('.hd')) return 450
|
||||||
|
if (baseLower.endsWith('_c') || baseLower.endsWith('.c')) return 400
|
||||||
|
if (isThumbnailDat(lower)) return 100
|
||||||
|
return 350
|
||||||
}
|
}
|
||||||
|
|
||||||
function isThumbnailDat(fileName: string): boolean {
|
function isThumbnailDat(fileName: string): boolean {
|
||||||
return fileName.includes('.t.dat') || fileName.includes('_t.dat')
|
|
||||||
}
|
|
||||||
|
|
||||||
function isHdDat(fileName: string): boolean {
|
|
||||||
const lower = fileName.toLowerCase()
|
const lower = fileName.toLowerCase()
|
||||||
const base = lower.endsWith('.dat') ? lower.slice(0, -4) : lower
|
return lower.includes('.t.dat') || lower.includes('_t.dat') || lower.includes('_thumb.dat')
|
||||||
return base.endsWith('_hd') || base.endsWith('_h')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function walkForDat(
|
function walkForDat(
|
||||||
@@ -105,20 +120,15 @@ function walkForDat(
|
|||||||
if (!lower.endsWith('.dat')) continue
|
if (!lower.endsWith('.dat')) continue
|
||||||
const baseLower = lower.slice(0, -4)
|
const baseLower = lower.slice(0, -4)
|
||||||
if (!isLikelyImageDatBase(baseLower)) continue
|
if (!isLikelyImageDatBase(baseLower)) continue
|
||||||
if (!hasXVariant(baseLower)) continue
|
|
||||||
if (!matchesDatName(lower, datName)) continue
|
if (!matchesDatName(lower, datName)) continue
|
||||||
// 排除高清图片格式 (_hd, _h)
|
|
||||||
if (isHdDat(lower)) continue
|
|
||||||
matchedBases.add(baseLower)
|
matchedBases.add(baseLower)
|
||||||
const isThumb = isThumbnailDat(lower)
|
const isThumb = isThumbnailDat(lower)
|
||||||
if (!allowThumbnail && isThumb) continue
|
if (!allowThumbnail && isThumb) continue
|
||||||
if (thumbOnly && !isThumb) continue
|
if (thumbOnly && !isThumb) continue
|
||||||
const score = scoreDatName(lower)
|
|
||||||
candidates.push({
|
candidates.push({
|
||||||
score,
|
score: scoreDatName(lower),
|
||||||
path: entryPath,
|
path: entryPath,
|
||||||
isThumb,
|
isThumb
|
||||||
hasX: hasXVariant(baseLower)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,10 +136,8 @@ function walkForDat(
|
|||||||
return { path: null, matchedBases: Array.from(matchedBases).slice(0, 20) }
|
return { path: null, matchedBases: Array.from(matchedBases).slice(0, 20) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const withX = candidates.filter((item) => item.hasX)
|
const nonThumb = candidates.filter((item) => !item.isThumb)
|
||||||
const basePool = withX.length ? withX : candidates
|
const finalPool = thumbOnly ? candidates : (nonThumb.length ? nonThumb : candidates)
|
||||||
const nonThumb = basePool.filter((item) => !item.isThumb)
|
|
||||||
const finalPool = thumbOnly ? basePool : (nonThumb.length ? nonThumb : basePool)
|
|
||||||
|
|
||||||
let best: { score: number; path: string } | null = null
|
let best: { score: number; path: string } | null = null
|
||||||
for (const item of finalPool) {
|
for (const item of finalPool) {
|
||||||
|
|||||||
1617
electron/main.ts
1617
electron/main.ts
File diff suppressed because it is too large
Load Diff
@@ -24,7 +24,15 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
|
|
||||||
// 认证
|
// 认证
|
||||||
auth: {
|
auth: {
|
||||||
hello: (message?: string) => ipcRenderer.invoke('auth:hello', message)
|
hello: (message?: string) => ipcRenderer.invoke('auth:hello', message),
|
||||||
|
verifyEnabled: () => ipcRenderer.invoke('auth:verifyEnabled'),
|
||||||
|
unlock: (password: string) => ipcRenderer.invoke('auth:unlock', password),
|
||||||
|
enableLock: (password: string) => ipcRenderer.invoke('auth:enableLock', password),
|
||||||
|
disableLock: (password: string) => ipcRenderer.invoke('auth:disableLock', password),
|
||||||
|
changePassword: (oldPassword: string, newPassword: string) => ipcRenderer.invoke('auth:changePassword', oldPassword, newPassword),
|
||||||
|
setHelloSecret: (password: string) => ipcRenderer.invoke('auth:setHelloSecret', password),
|
||||||
|
clearHelloSecret: () => ipcRenderer.invoke('auth:clearHelloSecret'),
|
||||||
|
isLockMode: () => ipcRenderer.invoke('auth:isLockMode')
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
@@ -55,21 +63,45 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
onUpdateAvailable: (callback: (info: { version: string; releaseNotes: string }) => void) => {
|
onUpdateAvailable: (callback: (info: { version: string; releaseNotes: string }) => void) => {
|
||||||
ipcRenderer.on('app:updateAvailable', (_, info) => callback(info))
|
ipcRenderer.on('app:updateAvailable', (_, info) => callback(info))
|
||||||
return () => ipcRenderer.removeAllListeners('app:updateAvailable')
|
return () => ipcRenderer.removeAllListeners('app:updateAvailable')
|
||||||
}
|
},
|
||||||
|
checkWayland: () => ipcRenderer.invoke('app:checkWayland'),
|
||||||
},
|
},
|
||||||
|
|
||||||
// 日志
|
// 日志
|
||||||
log: {
|
log: {
|
||||||
getPath: () => ipcRenderer.invoke('log:getPath'),
|
getPath: () => ipcRenderer.invoke('log:getPath'),
|
||||||
read: () => ipcRenderer.invoke('log:read'),
|
read: () => ipcRenderer.invoke('log:read'),
|
||||||
|
clear: () => ipcRenderer.invoke('log:clear'),
|
||||||
debug: (data: any) => ipcRenderer.send('log:debug', data)
|
debug: (data: any) => ipcRenderer.send('log:debug', data)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
diagnostics: {
|
||||||
|
getExportCardLogs: (options?: { limit?: number }) =>
|
||||||
|
ipcRenderer.invoke('diagnostics:getExportCardLogs', options),
|
||||||
|
clearExportCardLogs: () =>
|
||||||
|
ipcRenderer.invoke('diagnostics:clearExportCardLogs'),
|
||||||
|
exportExportCardLogs: (payload: { filePath: string; frontendLogs?: unknown[] }) =>
|
||||||
|
ipcRenderer.invoke('diagnostics:exportExportCardLogs', payload)
|
||||||
|
},
|
||||||
|
|
||||||
// 窗口控制
|
// 窗口控制
|
||||||
window: {
|
window: {
|
||||||
minimize: () => ipcRenderer.send('window:minimize'),
|
minimize: () => ipcRenderer.send('window:minimize'),
|
||||||
maximize: () => ipcRenderer.send('window:maximize'),
|
maximize: () => ipcRenderer.send('window:maximize'),
|
||||||
|
isMaximized: () => ipcRenderer.invoke('window:isMaximized'),
|
||||||
|
onMaximizeStateChanged: (callback: (isMaximized: boolean) => void) => {
|
||||||
|
const listener = (_: unknown, isMaximized: boolean) => callback(isMaximized)
|
||||||
|
ipcRenderer.on('window:maximizeStateChanged', listener)
|
||||||
|
return () => ipcRenderer.removeListener('window:maximizeStateChanged', listener)
|
||||||
|
},
|
||||||
close: () => ipcRenderer.send('window:close'),
|
close: () => ipcRenderer.send('window:close'),
|
||||||
|
onCloseConfirmRequested: (callback: (payload: { canMinimizeToTray: boolean }) => void) => {
|
||||||
|
const listener = (_: unknown, payload: { canMinimizeToTray: boolean }) => callback(payload)
|
||||||
|
ipcRenderer.on('window:confirmCloseRequested', listener)
|
||||||
|
return () => ipcRenderer.removeListener('window:confirmCloseRequested', listener)
|
||||||
|
},
|
||||||
|
respondCloseConfirm: (action: 'tray' | 'quit' | 'cancel') =>
|
||||||
|
ipcRenderer.invoke('window:respondCloseConfirm', action),
|
||||||
openAgreementWindow: () => ipcRenderer.invoke('window:openAgreementWindow'),
|
openAgreementWindow: () => ipcRenderer.invoke('window:openAgreementWindow'),
|
||||||
completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'),
|
completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'),
|
||||||
openOnboardingWindow: () => ipcRenderer.invoke('window:openOnboardingWindow'),
|
openOnboardingWindow: () => ipcRenderer.invoke('window:openOnboardingWindow'),
|
||||||
@@ -78,10 +110,24 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
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) =>
|
openImageViewerWindow: (imagePath: string, liveVideoPath?: string) =>
|
||||||
ipcRenderer.invoke('window:openImageViewerWindow', imagePath),
|
ipcRenderer.invoke('window:openImageViewerWindow', imagePath, liveVideoPath),
|
||||||
openChatHistoryWindow: (sessionId: string, messageId: number) =>
|
openChatHistoryWindow: (sessionId: string, messageId: number) =>
|
||||||
ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId)
|
ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId),
|
||||||
|
openChatHistoryPayloadWindow: (payload: { sessionId: string; title?: string; recordList: any[] }) =>
|
||||||
|
ipcRenderer.invoke('window:openChatHistoryPayloadWindow', payload),
|
||||||
|
getChatHistoryPayload: (payloadId: string) =>
|
||||||
|
ipcRenderer.invoke('window:getChatHistoryPayload', payloadId),
|
||||||
|
openSessionChatWindow: (
|
||||||
|
sessionId: string,
|
||||||
|
options?: {
|
||||||
|
source?: 'chat' | 'export'
|
||||||
|
initialDisplayName?: string
|
||||||
|
initialAvatarUrl?: string
|
||||||
|
initialContactType?: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
|
||||||
|
}
|
||||||
|
) =>
|
||||||
|
ipcRenderer.invoke('window:openSessionChatWindow', sessionId, options)
|
||||||
},
|
},
|
||||||
|
|
||||||
// 数据库路径
|
// 数据库路径
|
||||||
@@ -105,7 +151,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')
|
||||||
@@ -121,8 +168,14 @@ 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) =>
|
||||||
@@ -140,25 +193,43 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
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
|
||||||
|
cacheOnly?: boolean
|
||||||
|
}
|
||||||
|
) => ipcRenderer.invoke('chat:getExportSessionStats', sessionIds, options),
|
||||||
|
getGroupMyMessageCountHint: (chatroomId: string) =>
|
||||||
|
ipcRenderer.invoke('chat:getGroupMyMessageCountHint', chatroomId),
|
||||||
getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId),
|
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),
|
getAllVoiceMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllVoiceMessages', sessionId),
|
||||||
|
getAllImageMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllImageMessages', sessionId),
|
||||||
getMessageDates: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDates', 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: { sessionId?: string; msgId: string; createTime?: number; text: string }) => void) => {
|
||||||
const listener = (_: any, payload: { msgId: string; text: string }) => callback(payload)
|
const listener = (_: any, payload: { sessionId?: string; msgId: string; createTime?: number; text: string }) => callback(payload)
|
||||||
ipcRenderer.on('chat:voiceTranscriptPartial', listener)
|
ipcRenderer.on('chat:voiceTranscriptPartial', listener)
|
||||||
return () => ipcRenderer.removeListener('chat:voiceTranscriptPartial', listener)
|
return () => ipcRenderer.removeListener('chat:voiceTranscriptPartial', listener)
|
||||||
},
|
},
|
||||||
execQuery: (kind: string, path: string | null, sql: string) =>
|
|
||||||
ipcRenderer.invoke('chat:execQuery', kind, path, sql),
|
|
||||||
getContacts: () => ipcRenderer.invoke('chat:getContacts'),
|
getContacts: () => ipcRenderer.invoke('chat:getContacts'),
|
||||||
getMessage: (sessionId: string, localId: number) =>
|
getMessage: (sessionId: string, localId: number) =>
|
||||||
ipcRenderer.invoke('chat:getMessage', sessionId, localId),
|
ipcRenderer.invoke('chat:getMessage', sessionId, localId),
|
||||||
|
searchMessages: (keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number) =>
|
||||||
|
ipcRenderer.invoke('chat:searchMessages', keyword, sessionId, limit, offset, beginTimestamp, endTimestamp),
|
||||||
onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => {
|
onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => {
|
||||||
ipcRenderer.on('wcdb-change', callback)
|
ipcRenderer.on('wcdb-change', callback)
|
||||||
return () => ipcRenderer.removeListener('wcdb-change', callback)
|
return () => ipcRenderer.removeListener('wcdb-change', callback)
|
||||||
@@ -176,12 +247,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
preload: (payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>) =>
|
preload: (payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>) =>
|
||||||
ipcRenderer.invoke('image:preload', payloads),
|
ipcRenderer.invoke('image:preload', payloads),
|
||||||
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => {
|
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => {
|
||||||
ipcRenderer.on('image:updateAvailable', (_, payload) => callback(payload))
|
const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => callback(payload)
|
||||||
return () => ipcRenderer.removeAllListeners('image:updateAvailable')
|
ipcRenderer.on('image:updateAvailable', listener)
|
||||||
|
return () => ipcRenderer.removeListener('image:updateAvailable', listener)
|
||||||
},
|
},
|
||||||
onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => {
|
onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => {
|
||||||
ipcRenderer.on('image:cacheResolved', (_, payload) => callback(payload))
|
const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => callback(payload)
|
||||||
return () => ipcRenderer.removeAllListeners('image:cacheResolved')
|
ipcRenderer.on('image:cacheResolved', listener)
|
||||||
|
return () => ipcRenderer.removeListener('image:cacheResolved', listener)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -217,9 +290,18 @@ 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),
|
||||||
|
getGroupMemberMessages: (
|
||||||
|
chatroomId: string,
|
||||||
|
memberUsername: string,
|
||||||
|
options?: { startTime?: number; endTime?: number; limit?: number; cursor?: number }
|
||||||
|
) => ipcRenderer.invoke('groupAnalytics:getGroupMemberMessages', chatroomId, memberUsername, options),
|
||||||
exportGroupMembers: (chatroomId: string, outputPath: string) => ipcRenderer.invoke('groupAnalytics:exportGroupMembers', chatroomId, outputPath),
|
exportGroupMembers: (chatroomId: string, outputPath: string) => ipcRenderer.invoke('groupAnalytics:exportGroupMembers', chatroomId, outputPath),
|
||||||
exportGroupMemberMessages: (chatroomId: string, memberUsername: string, outputPath: string, startTime?: number, endTime?: number) =>
|
exportGroupMemberMessages: (chatroomId: string, memberUsername: string, outputPath: string, startTime?: number, endTime?: number) =>
|
||||||
ipcRenderer.invoke('groupAnalytics:exportGroupMemberMessages', chatroomId, memberUsername, outputPath, startTime, endTime)
|
ipcRenderer.invoke('groupAnalytics:exportGroupMemberMessages', chatroomId, memberUsername, outputPath, startTime, endTime)
|
||||||
@@ -228,9 +310,29 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
// 年度报告
|
// 年度报告
|
||||||
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')
|
||||||
@@ -255,7 +357,20 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options),
|
ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options),
|
||||||
exportContacts: (outputDir: string, options: any) =>
|
exportContacts: (outputDir: string, options: any) =>
|
||||||
ipcRenderer.invoke('export:exportContacts', outputDir, options),
|
ipcRenderer.invoke('export:exportContacts', outputDir, options),
|
||||||
onProgress: (callback: (payload: { current: number; total: number; currentSession: string; phase: string }) => void) => {
|
onProgress: (callback: (payload: {
|
||||||
|
current: number
|
||||||
|
total: number
|
||||||
|
currentSession: string
|
||||||
|
currentSessionId?: string
|
||||||
|
phase: string
|
||||||
|
phaseProgress?: number
|
||||||
|
phaseTotal?: number
|
||||||
|
phaseLabel?: string
|
||||||
|
collectedMessages?: number
|
||||||
|
exportedMessages?: number
|
||||||
|
estimatedTotalMessages?: number
|
||||||
|
writtenFiles?: number
|
||||||
|
}) => 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')
|
||||||
}
|
}
|
||||||
@@ -276,30 +391,33 @@ 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'),
|
||||||
|
getUserPostCounts: () => ipcRenderer.invoke('sns:getUserPostCounts'),
|
||||||
|
getExportStatsFast: () => ipcRenderer.invoke('sns:getExportStatsFast'),
|
||||||
|
getExportStats: () => ipcRenderer.invoke('sns:getExportStats'),
|
||||||
|
getUserPostStats: (username: string) => ipcRenderer.invoke('sns:getUserPostStats', username),
|
||||||
debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url),
|
debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url),
|
||||||
proxyImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:proxyImage', payload),
|
proxyImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:proxyImage', payload),
|
||||||
downloadImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:downloadImage', 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)
|
||||||
},
|
},
|
||||||
|
|
||||||
// Llama AI
|
|
||||||
llama: {
|
// 数据收集
|
||||||
loadModel: (modelPath: string) => ipcRenderer.invoke('llama:loadModel', modelPath),
|
cloud: {
|
||||||
createSession: (systemPrompt?: string) => ipcRenderer.invoke('llama:createSession', systemPrompt),
|
init: () => ipcRenderer.invoke('cloud:init'),
|
||||||
chat: (message: string, options?: any) => ipcRenderer.invoke('llama:chat', message, options),
|
recordPage: (pageName: string) => ipcRenderer.invoke('cloud:recordPage', pageName),
|
||||||
downloadModel: (url: string, savePath: string) => ipcRenderer.invoke('llama:downloadModel', url, savePath),
|
getLogs: () => ipcRenderer.invoke('cloud:getLogs')
|
||||||
getModelsPath: () => ipcRenderer.invoke('llama:getModelsPath'),
|
|
||||||
checkFileExists: (filePath: string) => ipcRenderer.invoke('llama:checkFileExists', filePath),
|
|
||||||
getModelStatus: (modelPath: string) => ipcRenderer.invoke('llama:getModelStatus', modelPath),
|
|
||||||
onToken: (callback: (token: string) => void) => {
|
|
||||||
const listener = (_: any, token: string) => callback(token)
|
|
||||||
ipcRenderer.on('llama:token', listener)
|
|
||||||
return () => ipcRenderer.removeListener('llama:token', listener)
|
|
||||||
},
|
|
||||||
onDownloadProgress: (callback: (payload: { downloaded: number; total: number; speed: number }) => void) => {
|
|
||||||
const listener = (_: any, payload: { downloaded: number; total: number; speed: number }) => callback(payload)
|
|
||||||
ipcRenderer.on('llama:downloadProgress', listener)
|
|
||||||
return () => ipcRenderer.removeListener('llama:downloadProgress', listener)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// HTTP API 服务
|
// HTTP API 服务
|
||||||
|
|||||||
@@ -68,33 +68,14 @@ class AnalyticsService {
|
|||||||
return new Set(this.getExcludedUsernamesList())
|
return new Set(this.getExcludedUsernamesList())
|
||||||
}
|
}
|
||||||
|
|
||||||
private escapeSqlValue(value: string): string {
|
|
||||||
return value.replace(/'/g, "''")
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getAliasMap(usernames: string[]): Promise<Record<string, string>> {
|
private async getAliasMap(usernames: string[]): Promise<Record<string, string>> {
|
||||||
const map: Record<string, string> = {}
|
const map: Record<string, string> = {}
|
||||||
if (usernames.length === 0) return map
|
if (usernames.length === 0) return map
|
||||||
|
|
||||||
const chunkSize = 200
|
const result = await wcdbService.getContactAliasMap(usernames)
|
||||||
for (let i = 0; i < usernames.length; i += chunkSize) {
|
if (!result.success || !result.map) return map
|
||||||
const chunk = usernames.slice(i, i + chunkSize)
|
for (const [username, alias] of Object.entries(result.map)) {
|
||||||
const inList = chunk.map((u) => `'${this.escapeSqlValue(u)}'`).join(',')
|
if (username && alias) map[username] = alias
|
||||||
if (!inList) continue
|
|
||||||
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
|
return map
|
||||||
|
|||||||
@@ -85,7 +85,34 @@ export interface AnnualReportData {
|
|||||||
} | null
|
} | 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() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +208,235 @@ 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.getMessageTableColumns(dbPath, tableName)
|
||||||
|
if (!result.success || !Array.isArray(result.columns) || result.columns.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 columnName of result.columns) {
|
||||||
|
const name = String(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 result = await wcdbService.getMessageTableTimeRange(dbPath, tableName)
|
||||||
|
if (!result.success || !result.data) return null
|
||||||
|
const row = result.data as Record<string, any>
|
||||||
|
const actualColumn = String(row.column || '').trim().toLowerCase()
|
||||||
|
if (column && actualColumn && column.toLowerCase() !== actualColumn) return null
|
||||||
|
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) {
|
||||||
@@ -359,38 +615,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: '加载年度数据失败' } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
161
electron/services/cloudControlService.ts
Normal file
161
electron/services/cloudControlService.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
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()
|
||||||
|
private platformVersionCache: string | null = null
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
this.deviceId = this.getDeviceId()
|
||||||
|
await wcdbService.cloudInit(300)
|
||||||
|
await this.reportOnline()
|
||||||
|
|
||||||
|
this.timer = setInterval(() => {
|
||||||
|
this.reportOnline()
|
||||||
|
}, 300000)
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDeviceId(): string {
|
||||||
|
const crypto = require('crypto')
|
||||||
|
const os = require('os')
|
||||||
|
const machineId = os.hostname() + os.platform() + os.arch()
|
||||||
|
return crypto.createHash('md5').update(machineId).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
private async reportOnline() {
|
||||||
|
const data: UsageStats = {
|
||||||
|
appVersion: app.getVersion(),
|
||||||
|
platform: this.getPlatformVersion(),
|
||||||
|
deviceId: this.deviceId,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
online: true,
|
||||||
|
pages: Array.from(this.pages)
|
||||||
|
}
|
||||||
|
|
||||||
|
await wcdbService.cloudReport(JSON.stringify(data))
|
||||||
|
this.pages.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
private getPlatformVersion(): string {
|
||||||
|
if (this.platformVersionCache) {
|
||||||
|
return this.platformVersionCache
|
||||||
|
}
|
||||||
|
|
||||||
|
const os = require('os')
|
||||||
|
const fs = require('fs')
|
||||||
|
const platform = process.platform
|
||||||
|
|
||||||
|
if (platform === 'win32') {
|
||||||
|
const release = os.release()
|
||||||
|
const parts = release.split('.')
|
||||||
|
const major = parseInt(parts[0])
|
||||||
|
const minor = parseInt(parts[1] || '0')
|
||||||
|
const build = parseInt(parts[2] || '0')
|
||||||
|
|
||||||
|
// Windows 11 是 10.0.22000+,且主版本必须是 10.0
|
||||||
|
if (major === 10 && minor === 0 && build >= 22000) {
|
||||||
|
this.platformVersionCache = 'Windows 11'
|
||||||
|
return this.platformVersionCache
|
||||||
|
} else if (major === 10) {
|
||||||
|
this.platformVersionCache = 'Windows 10'
|
||||||
|
return this.platformVersionCache
|
||||||
|
}
|
||||||
|
this.platformVersionCache = `Windows ${release}`
|
||||||
|
return this.platformVersionCache
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform === 'darwin') {
|
||||||
|
// `os.release()` returns Darwin kernel version (e.g. 25.3.0),
|
||||||
|
// while cloud reporting expects the macOS product version (e.g. 26.3).
|
||||||
|
const macVersion = typeof process.getSystemVersion === 'function' ? process.getSystemVersion() : os.release()
|
||||||
|
this.platformVersionCache = `macOS ${macVersion}`
|
||||||
|
return this.platformVersionCache
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform === 'linux') {
|
||||||
|
try {
|
||||||
|
const osReleasePaths = ['/etc/os-release', '/usr/lib/os-release']
|
||||||
|
for (const filePath of osReleasePaths) {
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = fs.readFileSync(filePath, 'utf8')
|
||||||
|
const values: Record<string, string> = {}
|
||||||
|
|
||||||
|
for (const line of content.split('\n')) {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const separatorIndex = trimmed.indexOf('=')
|
||||||
|
if (separatorIndex <= 0) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = trimmed.slice(0, separatorIndex)
|
||||||
|
let value = trimmed.slice(separatorIndex + 1).trim()
|
||||||
|
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\''))) {
|
||||||
|
value = value.slice(1, -1)
|
||||||
|
}
|
||||||
|
values[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.PRETTY_NAME) {
|
||||||
|
this.platformVersionCache = values.PRETTY_NAME
|
||||||
|
return this.platformVersionCache
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.NAME && values.VERSION_ID) {
|
||||||
|
this.platformVersionCache = `${values.NAME} ${values.VERSION_ID}`
|
||||||
|
return this.platformVersionCache
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.NAME) {
|
||||||
|
this.platformVersionCache = values.NAME
|
||||||
|
return this.platformVersionCache
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[CloudControl] Failed to detect Linux distro version:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.platformVersionCache = `Linux ${os.release()}`
|
||||||
|
return this.platformVersionCache
|
||||||
|
}
|
||||||
|
|
||||||
|
this.platformVersionCache = platform
|
||||||
|
return this.platformVersionCache
|
||||||
|
}
|
||||||
|
|
||||||
|
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,24 @@
|
|||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { app } from 'electron'
|
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 }>
|
||||||
|
exportPath?: string;
|
||||||
// 缓存相关
|
// 缓存相关
|
||||||
cachePath: string
|
cachePath: string
|
||||||
|
|
||||||
lastOpenedDb: string
|
lastOpenedDb: string
|
||||||
lastSession: string
|
lastSession: string
|
||||||
|
|
||||||
@@ -30,28 +34,45 @@ interface ConfigSchema {
|
|||||||
autoTranscribeVoice: boolean
|
autoTranscribeVoice: boolean
|
||||||
transcribeLanguages: string[]
|
transcribeLanguages: string[]
|
||||||
exportDefaultConcurrency: number
|
exportDefaultConcurrency: number
|
||||||
|
exportDefaultImageDeepSearchOnMiss: boolean
|
||||||
analyticsExcludedUsernames: string[]
|
analyticsExcludedUsernames: string[]
|
||||||
|
|
||||||
// 安全相关
|
// 安全相关
|
||||||
authEnabled: boolean
|
authEnabled: boolean
|
||||||
authPassword: string // SHA-256 hash
|
authPassword: string // SHA-256 hash(safeStorage 加密)
|
||||||
authUseHello: boolean
|
authUseHello: boolean
|
||||||
|
authHelloSecret: string // 原始密码(safeStorage 加密,Hello 解锁时使用)
|
||||||
|
|
||||||
// 更新相关
|
// 更新相关
|
||||||
ignoredUpdateVersion: string
|
ignoredUpdateVersion: string
|
||||||
|
|
||||||
// 通知
|
// 通知
|
||||||
notificationEnabled: boolean
|
notificationEnabled: boolean
|
||||||
notificationPosition: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'
|
notificationPosition: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'
|
||||||
notificationFilterMode: 'all' | 'whitelist' | 'blacklist'
|
notificationFilterMode: 'all' | 'whitelist' | 'blacklist'
|
||||||
notificationFilterList: string[]
|
notificationFilterList: string[]
|
||||||
|
messagePushEnabled: boolean
|
||||||
|
windowCloseBehavior: 'ask' | 'tray' | 'quit'
|
||||||
wordCloudExcludeWords: 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 static instance: ConfigService
|
private static instance: ConfigService
|
||||||
private store!: Store<ConfigSchema>
|
private store!: Store<ConfigSchema>
|
||||||
|
|
||||||
|
// 锁定模式运行时状态
|
||||||
|
private unlockedKeys: Map<string, any> = new Map()
|
||||||
|
private unlockPassword: string | null = null
|
||||||
|
|
||||||
static getInstance(): ConfigService {
|
static getInstance(): ConfigService {
|
||||||
if (!ConfigService.instance) {
|
if (!ConfigService.instance) {
|
||||||
ConfigService.instance = new ConfigService()
|
ConfigService.instance = new ConfigService()
|
||||||
@@ -64,68 +85,630 @@ export class ConfigService {
|
|||||||
return ConfigService.instance
|
return ConfigService.instance
|
||||||
}
|
}
|
||||||
ConfigService.instance = this
|
ConfigService.instance = this
|
||||||
this.store = new Store<ConfigSchema>({
|
const defaults: ConfigSchema = {
|
||||||
|
dbPath: '',
|
||||||
|
decryptKey: '',
|
||||||
|
myWxid: '',
|
||||||
|
onboardingDone: false,
|
||||||
|
imageXorKey: 0,
|
||||||
|
imageAesKey: '',
|
||||||
|
wxidConfigs: {},
|
||||||
|
cachePath: '',
|
||||||
|
lastOpenedDb: '',
|
||||||
|
lastSession: '',
|
||||||
|
theme: 'system',
|
||||||
|
themeId: 'cloud-dancer',
|
||||||
|
language: 'zh-CN',
|
||||||
|
logEnabled: false,
|
||||||
|
llmModelPath: '',
|
||||||
|
whisperModelName: 'base',
|
||||||
|
whisperModelDir: '',
|
||||||
|
whisperDownloadSource: 'tsinghua',
|
||||||
|
autoTranscribeVoice: false,
|
||||||
|
transcribeLanguages: ['zh'],
|
||||||
|
exportDefaultConcurrency: 4,
|
||||||
|
exportDefaultImageDeepSearchOnMiss: true,
|
||||||
|
analyticsExcludedUsernames: [],
|
||||||
|
authEnabled: false,
|
||||||
|
authPassword: '',
|
||||||
|
authUseHello: false,
|
||||||
|
authHelloSecret: '',
|
||||||
|
ignoredUpdateVersion: '',
|
||||||
|
notificationEnabled: true,
|
||||||
|
notificationPosition: 'top-right',
|
||||||
|
notificationFilterMode: 'all',
|
||||||
|
notificationFilterList: [],
|
||||||
|
messagePushEnabled: false,
|
||||||
|
windowCloseBehavior: 'ask',
|
||||||
|
wordCloudExcludeWords: []
|
||||||
|
}
|
||||||
|
|
||||||
|
const storeOptions: any = {
|
||||||
name: 'WeFlow-config',
|
name: 'WeFlow-config',
|
||||||
defaults: {
|
defaults,
|
||||||
dbPath: '',
|
projectName: String(process.env.WEFLOW_PROJECT_NAME || 'WeFlow').trim() || 'WeFlow'
|
||||||
decryptKey: '',
|
}
|
||||||
myWxid: '',
|
const runningInWorker = process.env.WEFLOW_WORKER === '1'
|
||||||
onboardingDone: false,
|
if (runningInWorker) {
|
||||||
imageXorKey: 0,
|
const cwd = String(process.env.WEFLOW_CONFIG_CWD || process.env.WEFLOW_USER_DATA_PATH || '').trim()
|
||||||
imageAesKey: '',
|
if (cwd) {
|
||||||
wxidConfigs: {},
|
storeOptions.cwd = cwd
|
||||||
cachePath: '',
|
|
||||||
|
|
||||||
lastOpenedDb: '',
|
|
||||||
lastSession: '',
|
|
||||||
theme: 'system',
|
|
||||||
themeId: 'cloud-dancer',
|
|
||||||
language: 'zh-CN',
|
|
||||||
logEnabled: false,
|
|
||||||
llmModelPath: '',
|
|
||||||
whisperModelName: 'base',
|
|
||||||
whisperModelDir: '',
|
|
||||||
whisperDownloadSource: 'tsinghua',
|
|
||||||
autoTranscribeVoice: false,
|
|
||||||
transcribeLanguages: ['zh'],
|
|
||||||
exportDefaultConcurrency: 2,
|
|
||||||
analyticsExcludedUsernames: [],
|
|
||||||
|
|
||||||
authEnabled: false,
|
|
||||||
authPassword: '',
|
|
||||||
authUseHello: false,
|
|
||||||
|
|
||||||
ignoredUpdateVersion: '',
|
|
||||||
notificationEnabled: true,
|
|
||||||
notificationPosition: 'top-right',
|
|
||||||
notificationFilterMode: 'all',
|
|
||||||
notificationFilterList: [],
|
|
||||||
wordCloudExcludeWords: []
|
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.store = new Store<ConfigSchema>(storeOptions)
|
||||||
|
} catch (error) {
|
||||||
|
const message = String((error as Error)?.message || error || '')
|
||||||
|
if (message.includes('projectName')) {
|
||||||
|
const fallbackOptions = {
|
||||||
|
...storeOptions,
|
||||||
|
projectName: 'WeFlow',
|
||||||
|
cwd: storeOptions.cwd || process.env.WEFLOW_CONFIG_CWD || process.env.WEFLOW_USER_DATA_PATH || process.cwd()
|
||||||
|
}
|
||||||
|
this.store = new Store<ConfigSchema>(fallbackOptions)
|
||||||
|
} else {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// === 加密/解密工具 ===
|
||||||
|
|
||||||
|
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')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getUserDataPath(): string {
|
||||||
|
const workerUserDataPath = String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim()
|
||||||
|
if (workerUserDataPath) {
|
||||||
|
return workerUserDataPath
|
||||||
|
}
|
||||||
|
return app?.getPath?.('userData') || process.cwd()
|
||||||
}
|
}
|
||||||
|
|
||||||
getCacheBasePath(): string {
|
getCacheBasePath(): string {
|
||||||
const configured = this.get('cachePath')
|
return join(this.getUserDataPath(), 'cache')
|
||||||
if (configured && configured.trim().length > 0) {
|
|
||||||
return configured
|
|
||||||
}
|
|
||||||
return join(app.getPath('documents'), 'WeFlow')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getAll(): ConfigSchema {
|
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,13 +1,90 @@
|
|||||||
import { join, basename } from 'path'
|
import { join, basename } from 'path'
|
||||||
import { existsSync, readdirSync, statSync } from 'fs'
|
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
|
||||||
import { homedir } from 'os'
|
import { homedir } from 'os'
|
||||||
|
import { createDecipheriv } from 'crypto'
|
||||||
|
|
||||||
export interface WxidInfo {
|
export interface WxidInfo {
|
||||||
wxid: string
|
wxid: string
|
||||||
modifiedTime: number
|
modifiedTime: number
|
||||||
|
nickname?: string
|
||||||
|
avatarUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DbPathService {
|
export class DbPathService {
|
||||||
|
private readVarint(buf: Buffer, offset: number): { value: number, length: number } {
|
||||||
|
let value = 0;
|
||||||
|
let length = 0;
|
||||||
|
let shift = 0;
|
||||||
|
while (offset < buf.length && shift < 32) {
|
||||||
|
const b = buf[offset++];
|
||||||
|
value |= (b & 0x7f) << shift;
|
||||||
|
length++;
|
||||||
|
if ((b & 0x80) === 0) break;
|
||||||
|
shift += 7;
|
||||||
|
}
|
||||||
|
return { value, length };
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractMmkvString(buf: Buffer, keyName: string): string {
|
||||||
|
const keyBuf = Buffer.from(keyName, 'utf8');
|
||||||
|
const idx = buf.indexOf(keyBuf);
|
||||||
|
if (idx === -1) return '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
let offset = idx + keyBuf.length;
|
||||||
|
const v1 = this.readVarint(buf, offset);
|
||||||
|
offset += v1.length;
|
||||||
|
const v2 = this.readVarint(buf, offset);
|
||||||
|
offset += v2.length;
|
||||||
|
|
||||||
|
// 合理性检查
|
||||||
|
if (v2.value > 0 && v2.value <= 10000 && offset + v2.value <= buf.length) {
|
||||||
|
return buf.toString('utf8', offset, offset + v2.value);
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseGlobalConfig(rootPath: string): { wxid: string, nickname: string, avatarUrl: string } | null {
|
||||||
|
try {
|
||||||
|
const configPath = join(rootPath, 'all_users', 'config', 'global_config');
|
||||||
|
if (!existsSync(configPath)) return null;
|
||||||
|
|
||||||
|
const fullData = readFileSync(configPath);
|
||||||
|
if (fullData.length <= 4) return null;
|
||||||
|
const encryptedData = fullData.subarray(4);
|
||||||
|
|
||||||
|
const key = Buffer.alloc(16, 0);
|
||||||
|
Buffer.from('xwechat_crypt_key').copy(key); // 直接硬编码,iv更是不重要
|
||||||
|
const iv = Buffer.alloc(16, 0);
|
||||||
|
|
||||||
|
const decipher = createDecipheriv('aes-128-cfb', key, iv);
|
||||||
|
decipher.setAutoPadding(false);
|
||||||
|
const decrypted = Buffer.concat([decipher.update(encryptedData), decipher.final()]);
|
||||||
|
|
||||||
|
const wxid = this.extractMmkvString(decrypted, 'mmkv_key_user_name');
|
||||||
|
const nickname = this.extractMmkvString(decrypted, 'mmkv_key_nick_name');
|
||||||
|
let avatarUrl = this.extractMmkvString(decrypted, 'mmkv_key_head_img_url');
|
||||||
|
|
||||||
|
if (!avatarUrl && decrypted.includes('http')) {
|
||||||
|
const httpIdx = decrypted.indexOf('http');
|
||||||
|
const nullIdx = decrypted.indexOf(0x00, httpIdx);
|
||||||
|
if (nullIdx !== -1) {
|
||||||
|
avatarUrl = decrypted.toString('utf8', httpIdx, nullIdx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wxid || nickname) {
|
||||||
|
return { wxid, nickname, avatarUrl };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('解析 global_config 失败:', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 自动检测微信数据库根目录
|
* 自动检测微信数据库根目录
|
||||||
*/
|
*/
|
||||||
@@ -16,8 +93,13 @@ export class DbPathService {
|
|||||||
const possiblePaths: string[] = []
|
const possiblePaths: string[] = []
|
||||||
const home = homedir()
|
const home = homedir()
|
||||||
|
|
||||||
// 微信4.x 数据目录
|
// macOS 微信路径(固定)
|
||||||
possiblePaths.push(join(home, 'Documents', 'xwechat_files'))
|
if (process.platform === 'darwin') {
|
||||||
|
possiblePaths.push(join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'xwechat_files'))
|
||||||
|
} else {
|
||||||
|
// Windows 微信4.x 数据目录
|
||||||
|
possiblePaths.push(join(home, 'Documents', 'xwechat_files'))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
for (const path of possiblePaths) {
|
for (const path of possiblePaths) {
|
||||||
@@ -130,21 +212,16 @@ export class DbPathService {
|
|||||||
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>
|
||||||
try {
|
try { stat = statSync(entryPath) } catch { continue }
|
||||||
stat = statSync(entryPath)
|
|
||||||
} catch {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!stat.isDirectory()) continue
|
if (!stat.isDirectory()) continue
|
||||||
const lower = entry.toLowerCase()
|
const lower = entry.toLowerCase()
|
||||||
if (lower === 'all_users') continue
|
if (lower === 'all_users') continue
|
||||||
if (!entry.includes('_')) continue
|
if (!entry.includes('_')) continue
|
||||||
|
|
||||||
wxids.push({ wxid: entry, modifiedTime: stat.mtimeMs })
|
wxids.push({ wxid: entry, modifiedTime: stat.mtimeMs })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (wxids.length === 0) {
|
if (wxids.length === 0) {
|
||||||
const rootName = basename(rootPath)
|
const rootName = basename(rootPath)
|
||||||
if (rootName.includes('_') && rootName.toLowerCase() !== 'all_users') {
|
if (rootName.includes('_') && rootName.toLowerCase() !== 'all_users') {
|
||||||
@@ -154,12 +231,25 @@ export class DbPathService {
|
|||||||
}
|
}
|
||||||
} catch { }
|
} catch { }
|
||||||
|
|
||||||
return wxids.sort((a, b) => {
|
const sorted = 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)
|
||||||
})
|
});
|
||||||
|
|
||||||
|
const globalInfo = this.parseGlobalConfig(rootPath);
|
||||||
|
if (globalInfo) {
|
||||||
|
for (const w of sorted) {
|
||||||
|
if (w.wxid.startsWith(globalInfo.wxid) || sorted.length === 1) {
|
||||||
|
w.nickname = globalInfo.nickname;
|
||||||
|
w.avatarUrl = globalInfo.avatarUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sorted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 扫描 wxid 列表
|
* 扫描 wxid 列表
|
||||||
*/
|
*/
|
||||||
@@ -182,10 +272,21 @@ export class DbPathService {
|
|||||||
}
|
}
|
||||||
} catch { }
|
} catch { }
|
||||||
|
|
||||||
return wxids.sort((a, b) => {
|
const sorted = 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)
|
||||||
})
|
});
|
||||||
|
|
||||||
|
const globalInfo = this.parseGlobalConfig(rootPath);
|
||||||
|
if (globalInfo) {
|
||||||
|
for (const w of sorted) {
|
||||||
|
if (w.wxid.startsWith(globalInfo.wxid) || sorted.length === 1) {
|
||||||
|
w.nickname = globalInfo.nickname;
|
||||||
|
w.avatarUrl = globalInfo.avatarUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sorted;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -193,6 +294,9 @@ export class DbPathService {
|
|||||||
*/
|
*/
|
||||||
getDefaultPath(): string {
|
getDefaultPath(): string {
|
||||||
const home = homedir()
|
const home = homedir()
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
return join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'xwechat_files')
|
||||||
|
}
|
||||||
return join(home, 'Documents', 'xwechat_files')
|
return join(home, 'Documents', 'xwechat_files')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -186,6 +186,33 @@ body {
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quoted-message {
|
||||||
|
border-left: 3px solid rgba(79, 70, 229, 0.35);
|
||||||
|
background: rgba(79, 70, 229, 0.06);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.sent .quoted-message {
|
||||||
|
background: rgba(37, 99, 235, 0.08);
|
||||||
|
border-left-color: rgba(37, 99, 235, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quoted-sender {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #374151;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quoted-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #4b5563;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
.message-link-card {
|
.message-link-card {
|
||||||
color: #2563eb;
|
color: #2563eb;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
|
|||||||
@@ -186,6 +186,33 @@ body {
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quoted-message {
|
||||||
|
border-left: 3px solid rgba(79, 70, 229, 0.35);
|
||||||
|
background: rgba(79, 70, 229, 0.06);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.sent .quoted-message {
|
||||||
|
background: rgba(37, 99, 235, 0.08);
|
||||||
|
border-left-color: rgba(37, 99, 235, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quoted-sender {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #374151;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quoted-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #4b5563;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
.message-link-card {
|
.message-link-card {
|
||||||
color: #2563eb;
|
color: #2563eb;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
|
|||||||
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
@@ -21,6 +21,12 @@ export interface GroupMember {
|
|||||||
alias?: string
|
alias?: string
|
||||||
remark?: string
|
remark?: string
|
||||||
groupNickname?: string
|
groupNickname?: string
|
||||||
|
isOwner?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupMembersPanelEntry extends GroupMember {
|
||||||
|
isFriend: boolean
|
||||||
|
messageCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GroupMessageRank {
|
export interface GroupMessageRank {
|
||||||
@@ -43,8 +49,34 @@ export interface GroupMediaStats {
|
|||||||
total: number
|
total: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GroupMemberMessagesPage {
|
||||||
|
messages: Message[]
|
||||||
|
hasMore: boolean
|
||||||
|
nextCursor: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GroupMemberContactInfo {
|
||||||
|
remark: string
|
||||||
|
nickName: string
|
||||||
|
alias: string
|
||||||
|
username: string
|
||||||
|
userName: string
|
||||||
|
encryptUsername: string
|
||||||
|
encryptUserName: string
|
||||||
|
localType: number
|
||||||
|
}
|
||||||
|
|
||||||
class GroupAnalyticsService {
|
class GroupAnalyticsService {
|
||||||
private configService: ConfigService
|
private configService: ConfigService
|
||||||
|
private readonly groupMembersPanelCacheTtlMs = 10 * 60 * 1000
|
||||||
|
private readonly groupMembersPanelMembersTimeoutMs = 12 * 1000
|
||||||
|
private readonly groupMembersPanelFullTimeoutMs = 25 * 1000
|
||||||
|
private readonly groupMembersPanelCache = new Map<string, { updatedAt: number; data: GroupMembersPanelEntry[] }>()
|
||||||
|
private readonly groupMembersPanelInFlight = new Map<
|
||||||
|
string,
|
||||||
|
Promise<{ success: boolean; data?: GroupMembersPanelEntry[]; error?: string; fromCache?: boolean; updatedAt?: number }>
|
||||||
|
>()
|
||||||
|
private readonly friendExcludeNames = new Set(['medianote', 'floatbottle', 'qmessage', 'qqmail', 'fmessage'])
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.configService = new ConfigService()
|
this.configService = new ConfigService()
|
||||||
@@ -89,6 +121,127 @@ class GroupAnalyticsService {
|
|||||||
return cleaned
|
return cleaned
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolveMemberUsername(
|
||||||
|
candidate: unknown,
|
||||||
|
memberLookup: Map<string, string>
|
||||||
|
): string | null {
|
||||||
|
if (typeof candidate !== 'string') return null
|
||||||
|
const raw = candidate.trim()
|
||||||
|
if (!raw) return null
|
||||||
|
if (memberLookup.has(raw)) return memberLookup.get(raw) || null
|
||||||
|
const cleaned = this.cleanAccountDirName(raw)
|
||||||
|
if (memberLookup.has(cleaned)) return memberLookup.get(cleaned) || null
|
||||||
|
|
||||||
|
const parts = raw.split(/[,\s;|]+/).filter(Boolean)
|
||||||
|
for (const part of parts) {
|
||||||
|
if (memberLookup.has(part)) return memberLookup.get(part) || null
|
||||||
|
const normalizedPart = this.cleanAccountDirName(part)
|
||||||
|
if (memberLookup.has(normalizedPart)) return memberLookup.get(normalizedPart) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((raw.startsWith('{') || raw.startsWith('[')) && raw.length < 4096) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
return this.extractOwnerUsername(parsed, memberLookup, 0)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractOwnerUsername(
|
||||||
|
value: unknown,
|
||||||
|
memberLookup: Map<string, string>,
|
||||||
|
depth: number
|
||||||
|
): string | null {
|
||||||
|
if (depth > 4 || value == null) return null
|
||||||
|
if (Buffer.isBuffer(value) || value instanceof Uint8Array) return null
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return this.resolveMemberUsername(value, memberLookup)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const item of value) {
|
||||||
|
const owner = this.extractOwnerUsername(item, memberLookup, depth + 1)
|
||||||
|
if (owner) return owner
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value !== 'object') return null
|
||||||
|
const row = value as Record<string, unknown>
|
||||||
|
|
||||||
|
for (const [key, entry] of Object.entries(row)) {
|
||||||
|
const keyLower = key.toLowerCase()
|
||||||
|
if (!keyLower.includes('owner') && !keyLower.includes('host') && !keyLower.includes('creator')) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof entry === 'boolean') {
|
||||||
|
if (entry && typeof row.username === 'string') {
|
||||||
|
const owner = this.resolveMemberUsername(row.username, memberLookup)
|
||||||
|
if (owner) return owner
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const owner = this.extractOwnerUsername(entry, memberLookup, depth + 1)
|
||||||
|
if (owner) return owner
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private async detectGroupOwnerUsername(
|
||||||
|
chatroomId: string,
|
||||||
|
members: Array<{ username: string; [key: string]: unknown }>
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
const memberLookup = new Map<string, string>()
|
||||||
|
for (const member of members) {
|
||||||
|
const username = String(member.username || '').trim()
|
||||||
|
if (!username) continue
|
||||||
|
const cleaned = this.cleanAccountDirName(username)
|
||||||
|
memberLookup.set(username, username)
|
||||||
|
memberLookup.set(cleaned, username)
|
||||||
|
}
|
||||||
|
if (memberLookup.size === 0) return undefined
|
||||||
|
|
||||||
|
const tryResolve = (candidate: unknown): string | undefined => {
|
||||||
|
const owner = this.extractOwnerUsername(candidate, memberLookup, 0)
|
||||||
|
return owner || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const member of members) {
|
||||||
|
const owner = tryResolve(member)
|
||||||
|
if (owner) return owner
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const groupContact = await wcdbService.getContact(chatroomId)
|
||||||
|
if (groupContact.success && groupContact.contact) {
|
||||||
|
const owner = tryResolve(groupContact.contact)
|
||||||
|
if (owner) return owner
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const roomExt = await wcdbService.getChatRoomExtBuffer(chatroomId)
|
||||||
|
if (roomExt.success && roomExt.extBuffer) {
|
||||||
|
const owner = tryResolve({ ext_buffer: roomExt.extBuffer })
|
||||||
|
if (owner) return owner
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
private async ensureConnected(): Promise<{ success: boolean; error?: string }> {
|
private async ensureConnected(): Promise<{ success: boolean; error?: string }> {
|
||||||
const wxid = this.configService.get('myWxid')
|
const wxid = this.configService.get('myWxid')
|
||||||
const dbPath = this.configService.get('dbPath')
|
const dbPath = this.configService.get('dbPath')
|
||||||
@@ -107,20 +260,46 @@ class GroupAnalyticsService {
|
|||||||
* 从 DLL 获取群成员的群昵称
|
* 从 DLL 获取群成员的群昵称
|
||||||
*/
|
*/
|
||||||
private async getGroupNicknamesForRoom(chatroomId: string, candidates: string[] = []): Promise<Map<string, string>> {
|
private async getGroupNicknamesForRoom(chatroomId: string, candidates: string[] = []): Promise<Map<string, string>> {
|
||||||
|
const nicknameMap = new Map<string, string>()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const escapedChatroomId = chatroomId.replace(/'/g, "''")
|
const dllResult = await wcdbService.getGroupNicknames(chatroomId)
|
||||||
const sql = `SELECT ext_buffer FROM chat_room WHERE username='${escapedChatroomId}' LIMIT 1`
|
if (dllResult.success && dllResult.nicknames) {
|
||||||
const result = await wcdbService.execQuery('contact', null, sql)
|
this.mergeGroupNicknameEntries(nicknameMap, Object.entries(dllResult.nicknames))
|
||||||
if (!result.success || !result.rows || result.rows.length === 0) {
|
}
|
||||||
return new Map<string, string>()
|
} catch (e) {
|
||||||
|
console.error('getGroupNicknamesForRoom dll error:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await wcdbService.getChatRoomExtBuffer(chatroomId)
|
||||||
|
if (!result.success || !result.extBuffer) {
|
||||||
|
return nicknameMap
|
||||||
}
|
}
|
||||||
|
|
||||||
const extBuffer = this.decodeExtBuffer((result.rows[0] as any).ext_buffer)
|
const extBuffer = this.decodeExtBuffer(result.extBuffer)
|
||||||
if (!extBuffer) return new Map<string, string>()
|
if (!extBuffer) return nicknameMap
|
||||||
return this.parseGroupNicknamesFromExtBuffer(extBuffer, candidates)
|
this.mergeGroupNicknameEntries(nicknameMap, this.parseGroupNicknamesFromExtBuffer(extBuffer, candidates).entries())
|
||||||
|
return nicknameMap
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('getGroupNicknamesForRoom error:', e)
|
console.error('getGroupNicknamesForRoom error:', e)
|
||||||
return new Map<string, string>()
|
return nicknameMap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mergeGroupNicknameEntries(
|
||||||
|
target: Map<string, string>,
|
||||||
|
entries: Iterable<[string, string]>
|
||||||
|
): void {
|
||||||
|
for (const [memberIdRaw, nicknameRaw] of entries) {
|
||||||
|
const nickname = this.normalizeGroupNickname(nicknameRaw || '')
|
||||||
|
if (!nickname) continue
|
||||||
|
for (const alias of this.buildIdCandidates([memberIdRaw])) {
|
||||||
|
if (!alias) continue
|
||||||
|
if (!target.has(alias)) target.set(alias, nickname)
|
||||||
|
const lower = alias.toLowerCase()
|
||||||
|
if (!target.has(lower)) target.set(lower, nickname)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,6 +475,193 @@ class GroupAnalyticsService {
|
|||||||
return Array.from(set)
|
return Array.from(set)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private toNonNegativeInteger(value: unknown): number {
|
||||||
|
const parsed = Number(value)
|
||||||
|
if (!Number.isFinite(parsed)) return 0
|
||||||
|
return Math.max(0, Math.floor(parsed))
|
||||||
|
}
|
||||||
|
|
||||||
|
private pickStringField(row: Record<string, unknown>, keys: string[]): string {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = row[key]
|
||||||
|
if (value == null) continue
|
||||||
|
const text = String(value).trim()
|
||||||
|
if (text) return text
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
private pickIntegerField(row: Record<string, unknown>, keys: string[], fallback: number = 0): number {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = row[key]
|
||||||
|
if (value == null || value === '') continue
|
||||||
|
const parsed = Number(value)
|
||||||
|
if (Number.isFinite(parsed)) return Math.floor(parsed)
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildGroupMembersPanelCacheKey(chatroomId: string, includeMessageCounts: boolean): string {
|
||||||
|
const dbPath = String(this.configService.get('dbPath') || '').trim()
|
||||||
|
const wxid = this.cleanAccountDirName(String(this.configService.get('myWxid') || '').trim())
|
||||||
|
const mode = includeMessageCounts ? 'full' : 'members'
|
||||||
|
return `${dbPath}::${wxid}::${chatroomId}::${mode}`
|
||||||
|
}
|
||||||
|
|
||||||
|
private pruneGroupMembersPanelCache(maxEntries: number = 80): void {
|
||||||
|
if (this.groupMembersPanelCache.size <= maxEntries) return
|
||||||
|
const entries = Array.from(this.groupMembersPanelCache.entries())
|
||||||
|
.sort((a, b) => a[1].updatedAt - b[1].updatedAt)
|
||||||
|
const removeCount = this.groupMembersPanelCache.size - maxEntries
|
||||||
|
for (let i = 0; i < removeCount; i += 1) {
|
||||||
|
this.groupMembersPanelCache.delete(entries[i][0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async withPromiseTimeout<T>(
|
||||||
|
promise: Promise<T>,
|
||||||
|
timeoutMs: number,
|
||||||
|
timeoutResult: T
|
||||||
|
): Promise<T> {
|
||||||
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
||||||
|
return promise
|
||||||
|
}
|
||||||
|
|
||||||
|
let timeoutTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
const timeoutPromise = new Promise<T>((resolve) => {
|
||||||
|
timeoutTimer = setTimeout(() => {
|
||||||
|
resolve(timeoutResult)
|
||||||
|
}, timeoutMs)
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await Promise.race([promise, timeoutPromise])
|
||||||
|
} finally {
|
||||||
|
if (timeoutTimer) {
|
||||||
|
clearTimeout(timeoutTimer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildGroupMemberContactLookup(usernames: string[]): Promise<Map<string, GroupMemberContactInfo>> {
|
||||||
|
const lookup = new Map<string, GroupMemberContactInfo>()
|
||||||
|
const candidates = this.buildIdCandidates(usernames)
|
||||||
|
if (candidates.length === 0) return lookup
|
||||||
|
|
||||||
|
const appendContactsToLookup = (rows: Record<string, unknown>[]) => {
|
||||||
|
for (const row of rows) {
|
||||||
|
const contact: GroupMemberContactInfo = {
|
||||||
|
remark: this.pickStringField(row, ['remark', 'WCDB_CT_remark']),
|
||||||
|
nickName: this.pickStringField(row, ['nick_name', 'nickName', 'WCDB_CT_nick_name']),
|
||||||
|
alias: this.pickStringField(row, ['alias', 'WCDB_CT_alias']),
|
||||||
|
username: this.pickStringField(row, ['username', 'WCDB_CT_username']),
|
||||||
|
userName: this.pickStringField(row, ['user_name', 'userName', 'WCDB_CT_user_name']),
|
||||||
|
encryptUsername: this.pickStringField(row, ['encrypt_username', 'encryptUsername', 'WCDB_CT_encrypt_username']),
|
||||||
|
encryptUserName: this.pickStringField(row, ['encrypt_user_name', 'encryptUserName', 'WCDB_CT_encrypt_user_name']),
|
||||||
|
localType: this.pickIntegerField(row, ['local_type', 'localType', 'WCDB_CT_local_type'], 0)
|
||||||
|
}
|
||||||
|
const lookupKeys = this.buildIdCandidates([
|
||||||
|
contact.username,
|
||||||
|
contact.userName,
|
||||||
|
contact.encryptUsername,
|
||||||
|
contact.encryptUserName,
|
||||||
|
contact.alias
|
||||||
|
])
|
||||||
|
for (const key of lookupKeys) {
|
||||||
|
const normalized = key.toLowerCase()
|
||||||
|
if (!lookup.has(normalized)) {
|
||||||
|
lookup.set(normalized, contact)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const batchSize = 200
|
||||||
|
for (let i = 0; i < candidates.length; i += batchSize) {
|
||||||
|
const batch = candidates.slice(i, i + batchSize)
|
||||||
|
if (batch.length === 0) continue
|
||||||
|
|
||||||
|
const result = await wcdbService.getContactsCompact(batch)
|
||||||
|
if (!result.success || !result.contacts) continue
|
||||||
|
appendContactsToLookup(result.contacts as Record<string, unknown>[])
|
||||||
|
}
|
||||||
|
return lookup
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveContactByCandidates(
|
||||||
|
lookup: Map<string, GroupMemberContactInfo>,
|
||||||
|
candidates: Array<string | undefined | null>
|
||||||
|
): GroupMemberContactInfo | undefined {
|
||||||
|
const ids = this.buildIdCandidates(candidates)
|
||||||
|
for (const id of ids) {
|
||||||
|
const hit = lookup.get(id.toLowerCase())
|
||||||
|
if (hit) return hit
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildGroupMessageCountLookup(chatroomId: string): Promise<Map<string, number>> {
|
||||||
|
const lookup = new Map<string, number>()
|
||||||
|
const result = await wcdbService.getGroupStats(chatroomId, 0, 0)
|
||||||
|
if (!result.success || !result.data) return lookup
|
||||||
|
|
||||||
|
const sessionData = result.data?.sessions?.[chatroomId]
|
||||||
|
if (!sessionData || !sessionData.senders) return lookup
|
||||||
|
|
||||||
|
const idMap = result.data.idMap || {}
|
||||||
|
for (const [senderId, rawCount] of Object.entries(sessionData.senders as Record<string, number>)) {
|
||||||
|
const username = String(idMap[senderId] || senderId || '').trim()
|
||||||
|
if (!username) continue
|
||||||
|
const count = this.toNonNegativeInteger(rawCount)
|
||||||
|
const keys = this.buildIdCandidates([username])
|
||||||
|
for (const key of keys) {
|
||||||
|
const normalized = key.toLowerCase()
|
||||||
|
const prev = lookup.get(normalized) || 0
|
||||||
|
if (count > prev) {
|
||||||
|
lookup.set(normalized, count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lookup
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveMessageCountByCandidates(
|
||||||
|
lookup: Map<string, number>,
|
||||||
|
candidates: Array<string | undefined | null>
|
||||||
|
): number {
|
||||||
|
let maxCount = 0
|
||||||
|
const ids = this.buildIdCandidates(candidates)
|
||||||
|
for (const id of ids) {
|
||||||
|
const count = lookup.get(id.toLowerCase())
|
||||||
|
if (typeof count === 'number' && count > maxCount) {
|
||||||
|
maxCount = count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return maxCount
|
||||||
|
}
|
||||||
|
|
||||||
|
private isFriendMember(wxid: string, contact?: GroupMemberContactInfo): boolean {
|
||||||
|
const normalizedWxid = String(wxid || '').trim().toLowerCase()
|
||||||
|
if (!normalizedWxid) return false
|
||||||
|
if (normalizedWxid.includes('@chatroom') || normalizedWxid.startsWith('gh_')) return false
|
||||||
|
if (this.friendExcludeNames.has(normalizedWxid)) return false
|
||||||
|
if (!contact) return false
|
||||||
|
return contact.localType === 1
|
||||||
|
}
|
||||||
|
|
||||||
|
private sortGroupMembersPanelEntries(members: GroupMembersPanelEntry[]): GroupMembersPanelEntry[] {
|
||||||
|
return members.sort((a, b) => {
|
||||||
|
const ownerDiff = Number(Boolean(b.isOwner)) - Number(Boolean(a.isOwner))
|
||||||
|
if (ownerDiff !== 0) return ownerDiff
|
||||||
|
|
||||||
|
const friendDiff = Number(Boolean(b.isFriend)) - Number(Boolean(a.isFriend))
|
||||||
|
if (friendDiff !== 0) return friendDiff
|
||||||
|
|
||||||
|
if (a.messageCount !== b.messageCount) return b.messageCount - a.messageCount
|
||||||
|
return a.displayName.localeCompare(b.displayName, 'zh-Hans-CN')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
private resolveGroupNicknameByCandidates(groupNicknames: Map<string, string>, candidates: string[]): string {
|
private resolveGroupNicknameByCandidates(groupNicknames: Map<string, string>, candidates: string[]): string {
|
||||||
const idCandidates = this.buildIdCandidates(candidates)
|
const idCandidates = this.buildIdCandidates(candidates)
|
||||||
if (idCandidates.length === 0) return ''
|
if (idCandidates.length === 0) return ''
|
||||||
@@ -396,36 +762,246 @@ class GroupAnalyticsService {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private normalizeCursorTimestamp(value: number): number {
|
||||||
|
if (!Number.isFinite(value) || value <= 0) return 0
|
||||||
|
const normalized = Math.floor(value)
|
||||||
|
return normalized > 10000000000 ? Math.floor(normalized / 1000) : normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractRowSenderUsername(row: Record<string, any>): string {
|
||||||
|
const candidates = [
|
||||||
|
row.sender_username,
|
||||||
|
row.senderUsername,
|
||||||
|
row.sender,
|
||||||
|
row.WCDB_CT_sender_username
|
||||||
|
]
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const value = String(candidate || '').trim()
|
||||||
|
if (value) return value
|
||||||
|
}
|
||||||
|
for (const [key, value] of Object.entries(row)) {
|
||||||
|
const normalizedKey = key.toLowerCase()
|
||||||
|
if (
|
||||||
|
normalizedKey === 'sender_username' ||
|
||||||
|
normalizedKey === 'senderusername' ||
|
||||||
|
normalizedKey === 'sender' ||
|
||||||
|
normalizedKey === 'wcdb_ct_sender_username'
|
||||||
|
) {
|
||||||
|
const normalizedValue = String(value || '').trim()
|
||||||
|
if (normalizedValue) return normalizedValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseSingleMessageRow(row: Record<string, any>): Message | null {
|
||||||
|
try {
|
||||||
|
const mapped = chatService.mapRowsToMessagesForApi([row])
|
||||||
|
return Array.isArray(mapped) && mapped.length > 0 ? mapped[0] : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async openMemberMessageCursor(
|
||||||
|
chatroomId: string,
|
||||||
|
batchSize: number,
|
||||||
|
ascending: boolean,
|
||||||
|
startTime: number,
|
||||||
|
endTime: number
|
||||||
|
): Promise<{ success: boolean; cursor?: number; error?: string }> {
|
||||||
|
const beginTimestamp = this.normalizeCursorTimestamp(startTime)
|
||||||
|
const endTimestamp = this.normalizeCursorTimestamp(endTime)
|
||||||
|
const liteResult = await wcdbService.openMessageCursorLite(chatroomId, batchSize, ascending, beginTimestamp, endTimestamp)
|
||||||
|
if (liteResult.success && liteResult.cursor) return liteResult
|
||||||
|
return wcdbService.openMessageCursor(chatroomId, batchSize, ascending, beginTimestamp, endTimestamp)
|
||||||
|
}
|
||||||
|
|
||||||
private async collectMessagesByMember(
|
private async collectMessagesByMember(
|
||||||
chatroomId: string,
|
chatroomId: string,
|
||||||
memberUsername: string,
|
memberUsername: string,
|
||||||
startTime: number,
|
startTime: number,
|
||||||
endTime: number
|
endTime: number
|
||||||
): Promise<{ success: boolean; data?: Message[]; error?: string }> {
|
): Promise<{ success: boolean; data?: Message[]; error?: string }> {
|
||||||
const batchSize = 500
|
const batchSize = 800
|
||||||
const matchedMessages: Message[] = []
|
const matchedMessages: Message[] = []
|
||||||
let offset = 0
|
const senderMatchCache = new Map<string, boolean>()
|
||||||
|
const matchesTargetSender = (sender: string | null | undefined): boolean => {
|
||||||
|
const key = String(sender || '').trim().toLowerCase()
|
||||||
|
if (!key) return false
|
||||||
|
const cached = senderMatchCache.get(key)
|
||||||
|
if (typeof cached === 'boolean') return cached
|
||||||
|
const matched = this.isSameAccountIdentity(memberUsername, sender)
|
||||||
|
senderMatchCache.set(key, matched)
|
||||||
|
return matched
|
||||||
|
}
|
||||||
|
|
||||||
while (true) {
|
const cursorResult = await this.openMemberMessageCursor(chatroomId, batchSize, true, startTime, endTime)
|
||||||
const batch = await chatService.getMessages(chatroomId, offset, batchSize, startTime, endTime, true)
|
if (!cursorResult.success || !cursorResult.cursor) {
|
||||||
if (!batch.success || !batch.messages) {
|
return { success: false, error: cursorResult.error || '创建群消息游标失败' }
|
||||||
return { success: false, error: batch.error || '获取群消息失败' }
|
}
|
||||||
}
|
|
||||||
|
|
||||||
for (const message of batch.messages) {
|
const cursor = cursorResult.cursor
|
||||||
if (this.isSameAccountIdentity(memberUsername, message.senderUsername)) {
|
try {
|
||||||
matchedMessages.push(message)
|
while (true) {
|
||||||
|
const batch = await wcdbService.fetchMessageBatch(cursor)
|
||||||
|
if (!batch.success) {
|
||||||
|
return { success: false, error: batch.error || '获取群消息失败' }
|
||||||
}
|
}
|
||||||
}
|
const rows = Array.isArray(batch.rows) ? batch.rows as Record<string, any>[] : []
|
||||||
|
if (rows.length === 0) break
|
||||||
|
|
||||||
const fetchedCount = batch.messages.length
|
for (const row of rows) {
|
||||||
if (fetchedCount <= 0 || !batch.hasMore) break
|
const senderFromRow = this.extractRowSenderUsername(row)
|
||||||
offset += fetchedCount
|
if (senderFromRow && !matchesTargetSender(senderFromRow)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const message = this.parseSingleMessageRow(row)
|
||||||
|
if (!message) continue
|
||||||
|
if (matchesTargetSender(message.senderUsername)) {
|
||||||
|
matchedMessages.push(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!batch.hasMore) break
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await wcdbService.closeMessageCursor(cursor)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true, data: matchedMessages }
|
return { success: true, data: matchedMessages }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getGroupMemberMessages(
|
||||||
|
chatroomId: string,
|
||||||
|
memberUsername: string,
|
||||||
|
options?: { startTime?: number; endTime?: number; limit?: number; cursor?: number }
|
||||||
|
): Promise<{ success: boolean; data?: GroupMemberMessagesPage; error?: string }> {
|
||||||
|
try {
|
||||||
|
const conn = await this.ensureConnected()
|
||||||
|
if (!conn.success) return { success: false, error: conn.error }
|
||||||
|
|
||||||
|
const normalizedChatroomId = String(chatroomId || '').trim()
|
||||||
|
const normalizedMemberUsername = String(memberUsername || '').trim()
|
||||||
|
if (!normalizedChatroomId) return { success: false, error: '群聊ID不能为空' }
|
||||||
|
if (!normalizedMemberUsername) return { success: false, error: '成员ID不能为空' }
|
||||||
|
|
||||||
|
const startTimeValue = Number.isFinite(options?.startTime) && typeof options?.startTime === 'number'
|
||||||
|
? Math.max(0, Math.floor(options.startTime))
|
||||||
|
: 0
|
||||||
|
const endTimeValue = Number.isFinite(options?.endTime) && typeof options?.endTime === 'number'
|
||||||
|
? Math.max(0, Math.floor(options.endTime))
|
||||||
|
: 0
|
||||||
|
const limit = Number.isFinite(options?.limit) && typeof options?.limit === 'number'
|
||||||
|
? Math.max(1, Math.min(100, Math.floor(options.limit)))
|
||||||
|
: 50
|
||||||
|
let cursor = Number.isFinite(options?.cursor) && typeof options?.cursor === 'number'
|
||||||
|
? Math.max(0, Math.floor(options.cursor))
|
||||||
|
: 0
|
||||||
|
|
||||||
|
const matchedMessages: Message[] = []
|
||||||
|
const senderMatchCache = new Map<string, boolean>()
|
||||||
|
const matchesTargetSender = (sender: string | null | undefined): boolean => {
|
||||||
|
const key = String(sender || '').trim().toLowerCase()
|
||||||
|
if (!key) return false
|
||||||
|
const cached = senderMatchCache.get(key)
|
||||||
|
if (typeof cached === 'boolean') return cached
|
||||||
|
const matched = this.isSameAccountIdentity(normalizedMemberUsername, sender)
|
||||||
|
senderMatchCache.set(key, matched)
|
||||||
|
return matched
|
||||||
|
}
|
||||||
|
const batchSize = Math.max(limit * 4, 240)
|
||||||
|
let hasMore = false
|
||||||
|
|
||||||
|
const cursorResult = await this.openMemberMessageCursor(
|
||||||
|
normalizedChatroomId,
|
||||||
|
batchSize,
|
||||||
|
false,
|
||||||
|
startTimeValue,
|
||||||
|
endTimeValue
|
||||||
|
)
|
||||||
|
if (!cursorResult.success || !cursorResult.cursor) {
|
||||||
|
return { success: false, error: cursorResult.error || '创建群成员消息游标失败' }
|
||||||
|
}
|
||||||
|
|
||||||
|
let consumedRows = 0
|
||||||
|
const dbCursor = cursorResult.cursor
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (matchedMessages.length < limit) {
|
||||||
|
const batch = await wcdbService.fetchMessageBatch(dbCursor)
|
||||||
|
if (!batch.success) {
|
||||||
|
return { success: false, error: batch.error || '获取群成员消息失败' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = Array.isArray(batch.rows) ? batch.rows as Record<string, any>[] : []
|
||||||
|
if (rows.length === 0) {
|
||||||
|
hasMore = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
let startIndex = 0
|
||||||
|
if (cursor > consumedRows) {
|
||||||
|
const skipCount = Math.min(cursor - consumedRows, rows.length)
|
||||||
|
consumedRows += skipCount
|
||||||
|
startIndex = skipCount
|
||||||
|
if (startIndex >= rows.length) {
|
||||||
|
if (!batch.hasMore) {
|
||||||
|
hasMore = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = startIndex; index < rows.length; index += 1) {
|
||||||
|
const row = rows[index]
|
||||||
|
consumedRows += 1
|
||||||
|
|
||||||
|
const senderFromRow = this.extractRowSenderUsername(row)
|
||||||
|
if (senderFromRow && !matchesTargetSender(senderFromRow)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = this.parseSingleMessageRow(row)
|
||||||
|
if (!message) continue
|
||||||
|
if (!matchesTargetSender(message.senderUsername)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
matchedMessages.push(message)
|
||||||
|
if (matchedMessages.length >= limit) {
|
||||||
|
cursor = consumedRows
|
||||||
|
hasMore = index < rows.length - 1 || batch.hasMore === true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchedMessages.length >= limit) break
|
||||||
|
|
||||||
|
cursor = consumedRows
|
||||||
|
if (!batch.hasMore) {
|
||||||
|
hasMore = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await wcdbService.closeMessageCursor(dbCursor)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
messages: matchedMessages,
|
||||||
|
hasMore,
|
||||||
|
nextCursor: cursor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getGroupChats(): Promise<{ success: boolean; data?: GroupChatInfo[]; error?: string }> {
|
async getGroupChats(): Promise<{ success: boolean; data?: GroupChatInfo[]; error?: string }> {
|
||||||
try {
|
try {
|
||||||
const conn = await this.ensureConnected()
|
const conn = await this.ensureConnected()
|
||||||
@@ -483,6 +1059,167 @@ class GroupAnalyticsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async loadGroupMembersPanelDataFresh(
|
||||||
|
chatroomId: string,
|
||||||
|
includeMessageCounts: boolean
|
||||||
|
): Promise<{ success: boolean; data?: GroupMembersPanelEntry[]; error?: string }> {
|
||||||
|
const membersResult = await wcdbService.getGroupMembers(chatroomId)
|
||||||
|
if (!membersResult.success || !membersResult.members) {
|
||||||
|
return { success: false, error: membersResult.error || '获取群成员失败' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const members = membersResult.members as Array<{
|
||||||
|
username: string
|
||||||
|
avatarUrl?: string
|
||||||
|
originalName?: string
|
||||||
|
[key: string]: unknown
|
||||||
|
}>
|
||||||
|
if (members.length === 0) return { success: true, data: [] }
|
||||||
|
|
||||||
|
const usernames = members
|
||||||
|
.map((member) => String(member.username || '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
if (usernames.length === 0) return { success: true, data: [] }
|
||||||
|
|
||||||
|
const displayNamesPromise = wcdbService.getDisplayNames(usernames)
|
||||||
|
const contactLookupPromise = this.buildGroupMemberContactLookup(usernames)
|
||||||
|
const ownerPromise = this.detectGroupOwnerUsername(chatroomId, members)
|
||||||
|
const messageCountLookupPromise = includeMessageCounts
|
||||||
|
? this.buildGroupMessageCountLookup(chatroomId)
|
||||||
|
: Promise.resolve(new Map<string, number>())
|
||||||
|
|
||||||
|
const [displayNames, contactLookup, ownerUsername, messageCountLookup] = await Promise.all([
|
||||||
|
displayNamesPromise,
|
||||||
|
contactLookupPromise,
|
||||||
|
ownerPromise,
|
||||||
|
messageCountLookupPromise
|
||||||
|
])
|
||||||
|
|
||||||
|
const nicknameCandidates = this.buildIdCandidates([
|
||||||
|
...members.map((member) => member.username),
|
||||||
|
...members.map((member) => member.originalName),
|
||||||
|
...Array.from(contactLookup.values()).map((contact) => contact?.username),
|
||||||
|
...Array.from(contactLookup.values()).map((contact) => contact?.userName),
|
||||||
|
...Array.from(contactLookup.values()).map((contact) => contact?.encryptUsername),
|
||||||
|
...Array.from(contactLookup.values()).map((contact) => contact?.encryptUserName),
|
||||||
|
...Array.from(contactLookup.values()).map((contact) => contact?.alias)
|
||||||
|
])
|
||||||
|
const groupNicknames = await this.getGroupNicknamesForRoom(chatroomId, nicknameCandidates)
|
||||||
|
const myWxid = this.cleanAccountDirName(this.configService.get('myWxid') || '')
|
||||||
|
let myGroupMessageCountHint: number | undefined
|
||||||
|
|
||||||
|
const data: GroupMembersPanelEntry[] = members
|
||||||
|
.map((member) => {
|
||||||
|
const wxid = String(member.username || '').trim()
|
||||||
|
if (!wxid) return null
|
||||||
|
|
||||||
|
const contact = this.resolveContactByCandidates(contactLookup, [wxid, member.originalName])
|
||||||
|
const nickname = contact?.nickName || ''
|
||||||
|
const remark = contact?.remark || ''
|
||||||
|
const alias = contact?.alias || ''
|
||||||
|
const normalizedWxid = this.cleanAccountDirName(wxid)
|
||||||
|
const lookupCandidates = this.buildIdCandidates([
|
||||||
|
wxid,
|
||||||
|
member.originalName as string | undefined,
|
||||||
|
contact?.username,
|
||||||
|
contact?.userName,
|
||||||
|
contact?.encryptUsername,
|
||||||
|
contact?.encryptUserName,
|
||||||
|
alias
|
||||||
|
])
|
||||||
|
if (normalizedWxid === myWxid) {
|
||||||
|
lookupCandidates.push(myWxid)
|
||||||
|
}
|
||||||
|
const groupNickname = this.resolveGroupNicknameByCandidates(groupNicknames, lookupCandidates)
|
||||||
|
const displayName = displayNames.success && displayNames.map ? (displayNames.map[wxid] || wxid) : wxid
|
||||||
|
|
||||||
|
return {
|
||||||
|
username: wxid,
|
||||||
|
displayName,
|
||||||
|
nickname,
|
||||||
|
alias,
|
||||||
|
remark,
|
||||||
|
groupNickname,
|
||||||
|
avatarUrl: member.avatarUrl,
|
||||||
|
isOwner: Boolean(ownerUsername && ownerUsername === wxid),
|
||||||
|
isFriend: this.isFriendMember(wxid, contact),
|
||||||
|
messageCount: this.resolveMessageCountByCandidates(messageCountLookup, lookupCandidates)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((member): member is GroupMembersPanelEntry => Boolean(member))
|
||||||
|
|
||||||
|
if (includeMessageCounts && myWxid) {
|
||||||
|
const selfEntry = data.find((member) => this.cleanAccountDirName(member.username) === myWxid)
|
||||||
|
if (selfEntry && Number.isFinite(selfEntry.messageCount)) {
|
||||||
|
myGroupMessageCountHint = Math.max(0, Math.floor(selfEntry.messageCount))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (includeMessageCounts && Number.isFinite(myGroupMessageCountHint)) {
|
||||||
|
void chatService.setGroupMyMessageCountHint(chatroomId, myGroupMessageCountHint as number)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, data: this.sortGroupMembersPanelEntries(data) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async getGroupMembersPanelData(
|
||||||
|
chatroomId: string,
|
||||||
|
options?: { forceRefresh?: boolean; includeMessageCounts?: boolean }
|
||||||
|
): Promise<{ success: boolean; data?: GroupMembersPanelEntry[]; error?: string; fromCache?: boolean; updatedAt?: number }> {
|
||||||
|
try {
|
||||||
|
const normalizedChatroomId = String(chatroomId || '').trim()
|
||||||
|
if (!normalizedChatroomId) return { success: false, error: '群聊ID不能为空' }
|
||||||
|
|
||||||
|
const forceRefresh = Boolean(options?.forceRefresh)
|
||||||
|
const includeMessageCounts = options?.includeMessageCounts !== false
|
||||||
|
const cacheKey = this.buildGroupMembersPanelCacheKey(normalizedChatroomId, includeMessageCounts)
|
||||||
|
const now = Date.now()
|
||||||
|
const cached = this.groupMembersPanelCache.get(cacheKey)
|
||||||
|
if (!forceRefresh && cached && now - cached.updatedAt < this.groupMembersPanelCacheTtlMs) {
|
||||||
|
return { success: true, data: cached.data, fromCache: true, updatedAt: cached.updatedAt }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!forceRefresh) {
|
||||||
|
const pending = this.groupMembersPanelInFlight.get(cacheKey)
|
||||||
|
if (pending) return pending
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestPromise = (async () => {
|
||||||
|
const conn = await this.ensureConnected()
|
||||||
|
if (!conn.success) return { success: false, error: conn.error }
|
||||||
|
|
||||||
|
const timeoutMs = includeMessageCounts
|
||||||
|
? this.groupMembersPanelFullTimeoutMs
|
||||||
|
: this.groupMembersPanelMembersTimeoutMs
|
||||||
|
const fresh = await this.withPromiseTimeout(
|
||||||
|
this.loadGroupMembersPanelDataFresh(normalizedChatroomId, includeMessageCounts),
|
||||||
|
timeoutMs,
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: includeMessageCounts
|
||||||
|
? '群成员发言统计加载超时,请稍后重试'
|
||||||
|
: '群成员列表加载超时,请稍后重试'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (!fresh.success || !fresh.data) {
|
||||||
|
return { success: false, error: fresh.error || '获取群成员面板数据失败' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedAt = Date.now()
|
||||||
|
this.groupMembersPanelCache.set(cacheKey, { updatedAt, data: fresh.data })
|
||||||
|
this.pruneGroupMembersPanelCache()
|
||||||
|
return { success: true, data: fresh.data, fromCache: false, updatedAt }
|
||||||
|
})().finally(() => {
|
||||||
|
this.groupMembersPanelInFlight.delete(cacheKey)
|
||||||
|
})
|
||||||
|
|
||||||
|
this.groupMembersPanelInFlight.set(cacheKey, requestPromise)
|
||||||
|
return await requestPromise
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getGroupMembers(chatroomId: string): Promise<{ success: boolean; data?: GroupMember[]; error?: string }> {
|
async getGroupMembers(chatroomId: string): Promise<{ success: boolean; data?: GroupMember[]; error?: string }> {
|
||||||
try {
|
try {
|
||||||
const conn = await this.ensureConnected()
|
const conn = await this.ensureConnected()
|
||||||
@@ -497,6 +1234,7 @@ class GroupAnalyticsService {
|
|||||||
username: string
|
username: string
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
originalName?: string
|
originalName?: string
|
||||||
|
[key: string]: unknown
|
||||||
}>
|
}>
|
||||||
const usernames = members.map((m) => m.username).filter(Boolean)
|
const usernames = members.map((m) => m.username).filter(Boolean)
|
||||||
|
|
||||||
@@ -543,6 +1281,7 @@ class GroupAnalyticsService {
|
|||||||
const groupNicknames = await this.getGroupNicknamesForRoom(chatroomId, nicknameCandidates)
|
const groupNicknames = await this.getGroupNicknamesForRoom(chatroomId, nicknameCandidates)
|
||||||
|
|
||||||
const myWxid = this.cleanAccountDirName(this.configService.get('myWxid') || '')
|
const myWxid = this.cleanAccountDirName(this.configService.get('myWxid') || '')
|
||||||
|
const ownerUsername = await this.detectGroupOwnerUsername(chatroomId, members)
|
||||||
const data: GroupMember[] = members.map((m) => {
|
const data: GroupMember[] = members.map((m) => {
|
||||||
const wxid = m.username || ''
|
const wxid = m.username || ''
|
||||||
const displayName = displayNames.success && displayNames.map ? (displayNames.map[wxid] || wxid) : wxid
|
const displayName = displayNames.success && displayNames.map ? (displayNames.map[wxid] || wxid) : wxid
|
||||||
@@ -572,7 +1311,8 @@ class GroupAnalyticsService {
|
|||||||
alias,
|
alias,
|
||||||
remark,
|
remark,
|
||||||
groupNickname,
|
groupNickname,
|
||||||
avatarUrl: m.avatarUrl
|
avatarUrl: m.avatarUrl,
|
||||||
|
isOwner: Boolean(ownerUsername && ownerUsername === wxid)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ import { chatService, Message } from './chatService'
|
|||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
import { ConfigService } from './config'
|
import { ConfigService } from './config'
|
||||||
import { videoService } from './videoService'
|
import { videoService } from './videoService'
|
||||||
|
import { imageDecryptService } from './imageDecryptService'
|
||||||
|
import { groupAnalyticsService } from './groupAnalyticsService'
|
||||||
|
|
||||||
// ChatLab 格式定义
|
// ChatLab 格式定义
|
||||||
interface ChatLabHeader {
|
interface ChatLabHeader {
|
||||||
@@ -69,6 +71,7 @@ interface ApiExportedMedia {
|
|||||||
kind: MediaKind
|
kind: MediaKind
|
||||||
fileName: string
|
fileName: string
|
||||||
fullPath: string
|
fullPath: string
|
||||||
|
relativePath: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChatLab 消息类型映射
|
// ChatLab 消息类型映射
|
||||||
@@ -100,6 +103,9 @@ class HttpService {
|
|||||||
private port: number = 5031
|
private port: number = 5031
|
||||||
private running: boolean = false
|
private running: boolean = false
|
||||||
private connections: Set<import('net').Socket> = new Set()
|
private connections: Set<import('net').Socket> = new Set()
|
||||||
|
private messagePushClients: Set<http.ServerResponse> = new Set()
|
||||||
|
private messagePushHeartbeatTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
private connectionMutex: boolean = false
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.configService = ConfigService.getInstance()
|
this.configService = ConfigService.getInstance()
|
||||||
@@ -120,9 +126,20 @@ class HttpService {
|
|||||||
|
|
||||||
// 跟踪所有连接,以便关闭时能强制断开
|
// 跟踪所有连接,以便关闭时能强制断开
|
||||||
this.server.on('connection', (socket) => {
|
this.server.on('connection', (socket) => {
|
||||||
this.connections.add(socket)
|
// 使用互斥锁防止并发修改
|
||||||
|
if (!this.connectionMutex) {
|
||||||
|
this.connectionMutex = true
|
||||||
|
this.connections.add(socket)
|
||||||
|
this.connectionMutex = false
|
||||||
|
}
|
||||||
|
|
||||||
socket.on('close', () => {
|
socket.on('close', () => {
|
||||||
this.connections.delete(socket)
|
// 使用互斥锁防止并发修改
|
||||||
|
if (!this.connectionMutex) {
|
||||||
|
this.connectionMutex = true
|
||||||
|
this.connections.delete(socket)
|
||||||
|
this.connectionMutex = false
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -138,6 +155,7 @@ class HttpService {
|
|||||||
|
|
||||||
this.server.listen(this.port, '127.0.0.1', () => {
|
this.server.listen(this.port, '127.0.0.1', () => {
|
||||||
this.running = true
|
this.running = true
|
||||||
|
this.startMessagePushHeartbeat()
|
||||||
console.log(`[HttpService] HTTP API server started on http://127.0.0.1:${this.port}`)
|
console.log(`[HttpService] HTTP API server started on http://127.0.0.1:${this.port}`)
|
||||||
resolve({ success: true, port: this.port })
|
resolve({ success: true, port: this.port })
|
||||||
})
|
})
|
||||||
@@ -150,11 +168,30 @@ class HttpService {
|
|||||||
async stop(): Promise<void> {
|
async stop(): Promise<void> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
if (this.server) {
|
if (this.server) {
|
||||||
// 强制关闭所有活动连接
|
for (const client of this.messagePushClients) {
|
||||||
for (const socket of this.connections) {
|
try {
|
||||||
socket.destroy()
|
client.end()
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
|
this.messagePushClients.clear()
|
||||||
|
if (this.messagePushHeartbeatTimer) {
|
||||||
|
clearInterval(this.messagePushHeartbeatTimer)
|
||||||
|
this.messagePushHeartbeatTimer = null
|
||||||
|
}
|
||||||
|
// 使用互斥锁保护连接集合操作
|
||||||
|
this.connectionMutex = true
|
||||||
|
const socketsToClose = Array.from(this.connections)
|
||||||
this.connections.clear()
|
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.server.close(() => {
|
||||||
this.running = false
|
this.running = false
|
||||||
@@ -187,6 +224,28 @@ class HttpService {
|
|||||||
return this.getApiMediaExportPath()
|
return this.getApiMediaExportPath()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getMessagePushStreamUrl(): string {
|
||||||
|
return `http://127.0.0.1:${this.port}/api/v1/push/messages`
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastMessagePush(payload: Record<string, unknown>): void {
|
||||||
|
if (!this.running || this.messagePushClients.size === 0) return
|
||||||
|
const eventBody = `event: message.new\ndata: ${JSON.stringify(payload)}\n\n`
|
||||||
|
|
||||||
|
for (const client of Array.from(this.messagePushClients)) {
|
||||||
|
try {
|
||||||
|
if (client.writableEnded || client.destroyed) {
|
||||||
|
this.messagePushClients.delete(client)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
client.write(eventBody)
|
||||||
|
} catch {
|
||||||
|
this.messagePushClients.delete(client)
|
||||||
|
try { client.end() } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理 HTTP 请求
|
* 处理 HTTP 请求
|
||||||
*/
|
*/
|
||||||
@@ -209,12 +268,18 @@ class HttpService {
|
|||||||
// 路由处理
|
// 路由处理
|
||||||
if (pathname === '/health' || pathname === '/api/v1/health') {
|
if (pathname === '/health' || pathname === '/api/v1/health') {
|
||||||
this.sendJson(res, { status: 'ok' })
|
this.sendJson(res, { status: 'ok' })
|
||||||
|
} else if (pathname === '/api/v1/push/messages') {
|
||||||
|
this.handleMessagePushStream(req, res)
|
||||||
} else if (pathname === '/api/v1/messages') {
|
} else if (pathname === '/api/v1/messages') {
|
||||||
await this.handleMessages(url, res)
|
await this.handleMessages(url, res)
|
||||||
} else if (pathname === '/api/v1/sessions') {
|
} else if (pathname === '/api/v1/sessions') {
|
||||||
await this.handleSessions(url, res)
|
await this.handleSessions(url, res)
|
||||||
} else if (pathname === '/api/v1/contacts') {
|
} else if (pathname === '/api/v1/contacts') {
|
||||||
await this.handleContacts(url, res)
|
await this.handleContacts(url, res)
|
||||||
|
} else if (pathname === '/api/v1/group-members') {
|
||||||
|
await this.handleGroupMembers(url, res)
|
||||||
|
} else if (pathname.startsWith('/api/v1/media/')) {
|
||||||
|
this.handleMediaRequest(pathname, res)
|
||||||
} else {
|
} else {
|
||||||
this.sendError(res, 404, 'Not Found')
|
this.sendError(res, 404, 'Not Found')
|
||||||
}
|
}
|
||||||
@@ -224,6 +289,84 @@ class HttpService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private startMessagePushHeartbeat(): void {
|
||||||
|
if (this.messagePushHeartbeatTimer) return
|
||||||
|
this.messagePushHeartbeatTimer = setInterval(() => {
|
||||||
|
for (const client of Array.from(this.messagePushClients)) {
|
||||||
|
try {
|
||||||
|
if (client.writableEnded || client.destroyed) {
|
||||||
|
this.messagePushClients.delete(client)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
client.write(': ping\n\n')
|
||||||
|
} catch {
|
||||||
|
this.messagePushClients.delete(client)
|
||||||
|
try { client.end() } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 25000)
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleMessagePushStream(req: http.IncomingMessage, res: http.ServerResponse): void {
|
||||||
|
if (this.configService.get('messagePushEnabled') !== true) {
|
||||||
|
this.sendError(res, 403, 'Message push is disabled')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||||
|
'Cache-Control': 'no-cache, no-transform',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
'X-Accel-Buffering': 'no'
|
||||||
|
})
|
||||||
|
res.flushHeaders?.()
|
||||||
|
res.write(`event: ready\ndata: ${JSON.stringify({ success: true, stream: this.getMessagePushStreamUrl() })}\n\n`)
|
||||||
|
|
||||||
|
this.messagePushClients.add(res)
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
this.messagePushClients.delete(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.on('close', cleanup)
|
||||||
|
res.on('close', cleanup)
|
||||||
|
res.on('error', cleanup)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
* 批量获取消息(循环游标直到满足 limit)
|
||||||
* 绕过 chatService 的单 batch 限制,直接操作 wcdbService 游标
|
* 绕过 chatService 的单 batch 限制,直接操作 wcdbService 游标
|
||||||
@@ -281,6 +424,7 @@ class HttpService {
|
|||||||
const trimmedRows = allRows.slice(0, limit)
|
const trimmedRows = allRows.slice(0, limit)
|
||||||
const finalHasMore = hasMore || allRows.length > limit
|
const finalHasMore = hasMore || allRows.length > limit
|
||||||
const messages = chatService.mapRowsToMessagesForApi(trimmedRows)
|
const messages = chatService.mapRowsToMessagesForApi(trimmedRows)
|
||||||
|
await this.backfillMissingSenderUsernames(talker, messages)
|
||||||
return { success: true, messages, hasMore: finalHasMore }
|
return { success: true, messages, hasMore: finalHasMore }
|
||||||
} finally {
|
} finally {
|
||||||
await wcdbService.closeMessageCursor(cursor)
|
await wcdbService.closeMessageCursor(cursor)
|
||||||
@@ -300,6 +444,41 @@ class HttpService {
|
|||||||
return Math.min(Math.max(parsed, min), max)
|
return Math.min(Math.max(parsed, min), max)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async backfillMissingSenderUsernames(talker: string, messages: Message[]): Promise<void> {
|
||||||
|
if (!talker.endsWith('@chatroom')) return
|
||||||
|
|
||||||
|
const targets = messages.filter((msg) => !String(msg.senderUsername || '').trim())
|
||||||
|
if (targets.length === 0) return
|
||||||
|
|
||||||
|
const myWxid = (this.configService.get('myWxid') || '').trim()
|
||||||
|
for (const msg of targets) {
|
||||||
|
const localId = Number(msg.localId || 0)
|
||||||
|
if (Number.isFinite(localId) && localId > 0) {
|
||||||
|
try {
|
||||||
|
const detail = await wcdbService.getMessageById(talker, localId)
|
||||||
|
if (detail.success && detail.message) {
|
||||||
|
const hydrated = chatService.mapRowsToMessagesForApi([detail.message])[0]
|
||||||
|
if (hydrated?.senderUsername) {
|
||||||
|
msg.senderUsername = hydrated.senderUsername
|
||||||
|
}
|
||||||
|
if ((msg.isSend === null || msg.isSend === undefined) && hydrated?.isSend !== undefined) {
|
||||||
|
msg.isSend = hydrated.isSend
|
||||||
|
}
|
||||||
|
if (!msg.rawContent && hydrated?.rawContent) {
|
||||||
|
msg.rawContent = hydrated.rawContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[HttpService] backfill sender failed:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!msg.senderUsername && msg.isSend === 1 && myWxid) {
|
||||||
|
msg.senderUsername = myWxid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private parseBooleanParam(url: URL, keys: string[], defaultValue: boolean = false): boolean {
|
private parseBooleanParam(url: URL, keys: string[], defaultValue: boolean = false): boolean {
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const raw = url.searchParams.get(key)
|
const raw = url.searchParams.get(key)
|
||||||
@@ -359,7 +538,7 @@ class HttpService {
|
|||||||
const queryOffset = keyword ? 0 : offset
|
const queryOffset = keyword ? 0 : offset
|
||||||
const queryLimit = keyword ? 10000 : limit
|
const queryLimit = keyword ? 10000 : limit
|
||||||
|
|
||||||
const result = await this.fetchMessagesBatch(talker, queryOffset, queryLimit, startTime, endTime, true)
|
const result = await this.fetchMessagesBatch(talker, queryOffset, queryLimit, startTime, endTime, false)
|
||||||
if (!result.success || !result.messages) {
|
if (!result.success || !result.messages) {
|
||||||
this.sendError(res, 500, result.error || 'Failed to get messages')
|
this.sendError(res, 500, result.error || 'Failed to get messages')
|
||||||
return
|
return
|
||||||
@@ -494,6 +673,54 @@ class HttpService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理群成员查询
|
||||||
|
* GET /api/v1/group-members?chatroomId=xxx@chatroom&includeMessageCounts=1&forceRefresh=0
|
||||||
|
*/
|
||||||
|
private async handleGroupMembers(url: URL, res: http.ServerResponse): Promise<void> {
|
||||||
|
const chatroomId = (url.searchParams.get('chatroomId') || url.searchParams.get('talker') || '').trim()
|
||||||
|
const includeMessageCounts = this.parseBooleanParam(url, ['includeMessageCounts', 'withCounts'], false)
|
||||||
|
const forceRefresh = this.parseBooleanParam(url, ['forceRefresh'], false)
|
||||||
|
|
||||||
|
if (!chatroomId) {
|
||||||
|
this.sendError(res, 400, 'Missing chatroomId')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await groupAnalyticsService.getGroupMembersPanelData(chatroomId, {
|
||||||
|
forceRefresh,
|
||||||
|
includeMessageCounts
|
||||||
|
})
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
this.sendError(res, 500, result.error || 'Failed to get group members')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sendJson(res, {
|
||||||
|
success: true,
|
||||||
|
chatroomId,
|
||||||
|
count: result.data.length,
|
||||||
|
fromCache: result.fromCache,
|
||||||
|
updatedAt: result.updatedAt,
|
||||||
|
members: result.data.map((member) => ({
|
||||||
|
wxid: member.username,
|
||||||
|
displayName: member.displayName,
|
||||||
|
nickname: member.nickname || '',
|
||||||
|
remark: member.remark || '',
|
||||||
|
alias: member.alias || '',
|
||||||
|
groupNickname: member.groupNickname || '',
|
||||||
|
avatarUrl: member.avatarUrl,
|
||||||
|
isOwner: Boolean(member.isOwner),
|
||||||
|
isFriend: Boolean(member.isFriend),
|
||||||
|
messageCount: Number.isFinite(member.messageCount) ? member.messageCount : 0
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
this.sendError(res, 500, String(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private getApiMediaExportPath(): string {
|
private getApiMediaExportPath(): string {
|
||||||
return path.join(this.configService.getCacheBasePath(), 'api-media')
|
return path.join(this.configService.getCacheBasePath(), 'api-media')
|
||||||
}
|
}
|
||||||
@@ -555,19 +782,44 @@ class HttpService {
|
|||||||
): Promise<ApiExportedMedia | null> {
|
): Promise<ApiExportedMedia | null> {
|
||||||
try {
|
try {
|
||||||
if (msg.localType === 3 && options.exportImages) {
|
if (msg.localType === 3 && options.exportImages) {
|
||||||
const result = await chatService.getImageData(talker, String(msg.localId))
|
const result = await imageDecryptService.decryptImage({
|
||||||
if (result.success && result.data) {
|
sessionId: talker,
|
||||||
const imageBuffer = Buffer.from(result.data, 'base64')
|
imageMd5: msg.imageMd5,
|
||||||
const ext = this.detectImageExt(imageBuffer)
|
imageDatName: msg.imageDatName,
|
||||||
const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`)
|
force: true
|
||||||
const fileName = `${fileBase}${ext}`
|
})
|
||||||
const targetDir = path.join(sessionDir, 'images')
|
if (result.success && result.localPath) {
|
||||||
const fullPath = path.join(targetDir, fileName)
|
let imagePath = result.localPath
|
||||||
this.ensureDir(targetDir)
|
if (imagePath.startsWith('data:')) {
|
||||||
if (!fs.existsSync(fullPath)) {
|
const base64Match = imagePath.match(/^data:[^;]+;base64,(.+)$/)
|
||||||
fs.writeFileSync(fullPath, imageBuffer)
|
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 }
|
||||||
}
|
}
|
||||||
return { kind: 'image', fileName, fullPath }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -586,7 +838,8 @@ class HttpService {
|
|||||||
if (!fs.existsSync(fullPath)) {
|
if (!fs.existsSync(fullPath)) {
|
||||||
fs.writeFileSync(fullPath, Buffer.from(result.data, 'base64'))
|
fs.writeFileSync(fullPath, Buffer.from(result.data, 'base64'))
|
||||||
}
|
}
|
||||||
return { kind: 'voice', fileName, fullPath }
|
const relativePath = `${this.sanitizeFileName(talker, 'session')}/voices/${fileName}`
|
||||||
|
return { kind: 'voice', fileName, fullPath, relativePath }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -601,7 +854,8 @@ class HttpService {
|
|||||||
if (!fs.existsSync(fullPath)) {
|
if (!fs.existsSync(fullPath)) {
|
||||||
fs.copyFileSync(info.videoUrl, fullPath)
|
fs.copyFileSync(info.videoUrl, fullPath)
|
||||||
}
|
}
|
||||||
return { kind: 'video', fileName, fullPath }
|
const relativePath = `${this.sanitizeFileName(talker, 'session')}/videos/${fileName}`
|
||||||
|
return { kind: 'video', fileName, fullPath, relativePath }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -616,7 +870,8 @@ class HttpService {
|
|||||||
if (!fs.existsSync(fullPath)) {
|
if (!fs.existsSync(fullPath)) {
|
||||||
fs.copyFileSync(result.localPath, fullPath)
|
fs.copyFileSync(result.localPath, fullPath)
|
||||||
}
|
}
|
||||||
return { kind: 'emoji', fileName, fullPath }
|
const relativePath = `${this.sanitizeFileName(talker, 'session')}/emojis/${fileName}`
|
||||||
|
return { kind: 'emoji', fileName, fullPath, relativePath }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -640,7 +895,8 @@ class HttpService {
|
|||||||
parsedContent: msg.parsedContent,
|
parsedContent: msg.parsedContent,
|
||||||
mediaType: media?.kind,
|
mediaType: media?.kind,
|
||||||
mediaFileName: media?.fileName,
|
mediaFileName: media?.fileName,
|
||||||
mediaPath: media?.fullPath
|
mediaUrl: media ? `http://127.0.0.1:${this.port}/api/v1/media/${media.relativePath}` : undefined,
|
||||||
|
mediaLocalPath: media?.fullPath
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -674,6 +930,20 @@ class HttpService {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private normalizeAccountId(value: string): string {
|
||||||
|
const trimmed = String(value || '').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})$/)
|
||||||
|
return suffixMatch ? suffixMatch[1] : trimmed
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取显示名称
|
* 获取显示名称
|
||||||
*/
|
*/
|
||||||
@@ -690,6 +960,110 @@ class HttpService {
|
|||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getAvatarUrls(usernames: string[]): Promise<Record<string, string>> {
|
||||||
|
const lookupUsernames = Array.from(new Set(
|
||||||
|
usernames.flatMap((username) => {
|
||||||
|
const normalized = String(username || '').trim()
|
||||||
|
if (!normalized) return []
|
||||||
|
const cleaned = this.normalizeAccountId(normalized)
|
||||||
|
return cleaned && cleaned !== normalized ? [normalized, cleaned] : [normalized]
|
||||||
|
})
|
||||||
|
))
|
||||||
|
|
||||||
|
if (lookupUsernames.length === 0) return {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await wcdbService.getAvatarUrls(lookupUsernames)
|
||||||
|
if (result.success && result.map) {
|
||||||
|
const avatarMap: Record<string, string> = {}
|
||||||
|
for (const [username, avatarUrl] of Object.entries(result.map)) {
|
||||||
|
const normalizedUsername = String(username || '').trim()
|
||||||
|
const normalizedAvatarUrl = String(avatarUrl || '').trim()
|
||||||
|
if (!normalizedUsername || !normalizedAvatarUrl) continue
|
||||||
|
|
||||||
|
avatarMap[normalizedUsername] = normalizedAvatarUrl
|
||||||
|
avatarMap[normalizedUsername.toLowerCase()] = normalizedAvatarUrl
|
||||||
|
|
||||||
|
const cleaned = this.normalizeAccountId(normalizedUsername)
|
||||||
|
if (cleaned) {
|
||||||
|
avatarMap[cleaned] = normalizedAvatarUrl
|
||||||
|
avatarMap[cleaned.toLowerCase()] = normalizedAvatarUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return avatarMap
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[HttpService] Failed to get avatar urls:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveAvatarUrl(avatarMap: Record<string, string>, candidates: Array<string | undefined | null>): string | undefined {
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const normalized = String(candidate || '').trim()
|
||||||
|
if (!normalized) continue
|
||||||
|
|
||||||
|
const cleaned = this.normalizeAccountId(normalized)
|
||||||
|
const avatarUrl = avatarMap[normalized]
|
||||||
|
|| avatarMap[normalized.toLowerCase()]
|
||||||
|
|| avatarMap[cleaned]
|
||||||
|
|| avatarMap[cleaned.toLowerCase()]
|
||||||
|
|
||||||
|
if (avatarUrl) return avatarUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
private lookupGroupNickname(groupNicknamesMap: Map<string, string>, sender: string): string {
|
||||||
|
if (!sender) return ''
|
||||||
|
const cleaned = this.normalizeAccountId(sender)
|
||||||
|
return groupNicknamesMap.get(sender)
|
||||||
|
|| groupNicknamesMap.get(sender.toLowerCase())
|
||||||
|
|| groupNicknamesMap.get(cleaned)
|
||||||
|
|| groupNicknamesMap.get(cleaned.toLowerCase())
|
||||||
|
|| ''
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveChatLabSenderInfo(
|
||||||
|
msg: Message,
|
||||||
|
talkerId: string,
|
||||||
|
talkerName: string,
|
||||||
|
myWxid: string,
|
||||||
|
isGroup: boolean,
|
||||||
|
senderNames: Record<string, string>,
|
||||||
|
groupNicknamesMap: Map<string, string>
|
||||||
|
): { sender: string; accountName: string; groupNickname?: string } {
|
||||||
|
let sender = String(msg.senderUsername || '').trim()
|
||||||
|
let usedUnknownPlaceholder = false
|
||||||
|
const sameAsMe = sender && myWxid && sender.toLowerCase() === myWxid.toLowerCase()
|
||||||
|
const isSelf = msg.isSend === 1 || sameAsMe
|
||||||
|
|
||||||
|
if (!sender && isSelf && myWxid) {
|
||||||
|
sender = myWxid
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sender) {
|
||||||
|
if (msg.localType === 10000 || msg.localType === 266287972401) {
|
||||||
|
sender = talkerId
|
||||||
|
} else {
|
||||||
|
sender = `unknown_sender_${msg.localId || msg.createTime || 0}`
|
||||||
|
usedUnknownPlaceholder = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupNickname = isGroup ? this.lookupGroupNickname(groupNicknamesMap, sender) : ''
|
||||||
|
const displayName = senderNames[sender] || groupNickname || (usedUnknownPlaceholder ? '' : sender)
|
||||||
|
const accountName = isSelf ? '我' : (displayName || '未知发送者')
|
||||||
|
|
||||||
|
return {
|
||||||
|
sender,
|
||||||
|
accountName,
|
||||||
|
groupNickname: groupNickname || undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 转换为 ChatLab 格式
|
* 转换为 ChatLab 格式
|
||||||
*/
|
*/
|
||||||
@@ -701,6 +1075,7 @@ class HttpService {
|
|||||||
): Promise<ChatLabData> {
|
): Promise<ChatLabData> {
|
||||||
const isGroup = talkerId.endsWith('@chatroom')
|
const isGroup = talkerId.endsWith('@chatroom')
|
||||||
const myWxid = this.configService.get('myWxid') || ''
|
const myWxid = this.configService.get('myWxid') || ''
|
||||||
|
const normalizedMyWxid = this.normalizeAccountId(myWxid).toLowerCase()
|
||||||
|
|
||||||
// 收集所有发送者
|
// 收集所有发送者
|
||||||
const senderSet = new Set<string>()
|
const senderSet = new Set<string>()
|
||||||
@@ -719,7 +1094,21 @@ class HttpService {
|
|||||||
try {
|
try {
|
||||||
const result = await wcdbService.getGroupNicknames(talkerId)
|
const result = await wcdbService.getGroupNicknames(talkerId)
|
||||||
if (result.success && result.nicknames) {
|
if (result.success && result.nicknames) {
|
||||||
groupNicknamesMap = new Map(Object.entries(result.nicknames))
|
groupNicknamesMap = new Map()
|
||||||
|
for (const [memberIdRaw, nicknameRaw] of Object.entries(result.nicknames)) {
|
||||||
|
const memberId = String(memberIdRaw || '').trim()
|
||||||
|
const nickname = String(nicknameRaw || '').trim()
|
||||||
|
if (!memberId || !nickname) continue
|
||||||
|
|
||||||
|
groupNicknamesMap.set(memberId, nickname)
|
||||||
|
groupNicknamesMap.set(memberId.toLowerCase(), nickname)
|
||||||
|
|
||||||
|
const cleaned = this.normalizeAccountId(memberId)
|
||||||
|
if (cleaned) {
|
||||||
|
groupNicknamesMap.set(cleaned, nickname)
|
||||||
|
groupNicknamesMap.set(cleaned.toLowerCase(), nickname)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[HttpService] Failed to get group nicknames:', e)
|
console.error('[HttpService] Failed to get group nicknames:', e)
|
||||||
@@ -729,41 +1118,50 @@ class HttpService {
|
|||||||
// 构建成员列表
|
// 构建成员列表
|
||||||
const memberMap = new Map<string, ChatLabMember>()
|
const memberMap = new Map<string, ChatLabMember>()
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
const sender = msg.senderUsername || ''
|
const senderInfo = this.resolveChatLabSenderInfo(msg, talkerId, talkerName, myWxid, isGroup, senderNames, groupNicknamesMap)
|
||||||
if (sender && !memberMap.has(sender)) {
|
if (!memberMap.has(senderInfo.sender)) {
|
||||||
const displayName = senderNames[sender] || sender
|
memberMap.set(senderInfo.sender, {
|
||||||
const isSelf = sender === myWxid || sender.toLowerCase() === myWxid.toLowerCase()
|
platformId: senderInfo.sender,
|
||||||
// 获取群昵称(尝试多种方式)
|
accountName: senderInfo.accountName,
|
||||||
const groupNickname = isGroup
|
groupNickname: senderInfo.groupNickname
|
||||||
? (groupNicknamesMap.get(sender) || groupNicknamesMap.get(sender.toLowerCase()) || '')
|
|
||||||
: ''
|
|
||||||
memberMap.set(sender, {
|
|
||||||
platformId: sender,
|
|
||||||
accountName: isSelf ? '我' : displayName,
|
|
||||||
groupNickname: groupNickname || undefined
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [memberAvatarMap, myAvatarResult, sessionAvatarInfo] = await Promise.all([
|
||||||
|
this.getAvatarUrls(Array.from(memberMap.keys()).filter((sender) => !sender.startsWith('unknown_sender_'))),
|
||||||
|
myWxid
|
||||||
|
? chatService.getMyAvatarUrl()
|
||||||
|
: Promise.resolve<{ success: boolean; avatarUrl?: string }>({ success: true }),
|
||||||
|
isGroup ? chatService.getContactAvatar(talkerId) : Promise.resolve(null)
|
||||||
|
])
|
||||||
|
|
||||||
|
for (const [sender, member] of memberMap.entries()) {
|
||||||
|
if (sender.startsWith('unknown_sender_')) continue
|
||||||
|
|
||||||
|
const normalizedSender = this.normalizeAccountId(sender).toLowerCase()
|
||||||
|
const isSelfMember = Boolean(normalizedMyWxid && normalizedSender && normalizedSender === normalizedMyWxid)
|
||||||
|
const avatarUrl = (isSelfMember ? myAvatarResult.avatarUrl : undefined)
|
||||||
|
|| this.resolveAvatarUrl(memberAvatarMap, isSelfMember ? [sender, myWxid] : [sender])
|
||||||
|
|
||||||
|
if (avatarUrl) {
|
||||||
|
member.avatar = avatarUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 转换消息
|
// 转换消息
|
||||||
const chatLabMessages: ChatLabMessage[] = messages.map(msg => {
|
const chatLabMessages: ChatLabMessage[] = messages.map(msg => {
|
||||||
const sender = msg.senderUsername || ''
|
const senderInfo = this.resolveChatLabSenderInfo(msg, talkerId, talkerName, myWxid, isGroup, senderNames, groupNicknamesMap)
|
||||||
const isSelf = msg.isSend === 1 || sender === myWxid
|
|
||||||
const accountName = isSelf ? '我' : (senderNames[sender] || sender)
|
|
||||||
// 获取该发送者的群昵称
|
|
||||||
const groupNickname = isGroup
|
|
||||||
? (groupNicknamesMap.get(sender) || groupNicknamesMap.get(sender.toLowerCase()) || '')
|
|
||||||
: ''
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sender,
|
sender: senderInfo.sender,
|
||||||
accountName,
|
accountName: senderInfo.accountName,
|
||||||
groupNickname: groupNickname || undefined,
|
groupNickname: senderInfo.groupNickname,
|
||||||
timestamp: msg.createTime,
|
timestamp: msg.createTime,
|
||||||
type: this.mapMessageType(msg.localType, msg),
|
type: this.mapMessageType(msg.localType, msg),
|
||||||
content: this.getMessageContent(msg),
|
content: this.getMessageContent(msg),
|
||||||
platformMessageId: msg.serverId ? String(msg.serverId) : undefined,
|
platformMessageId: msg.serverId ? String(msg.serverId) : undefined,
|
||||||
mediaPath: mediaMap.get(msg.localId)?.fullPath
|
mediaPath: mediaMap.get(msg.localId) ? `http://127.0.0.1:${this.port}/api/v1/media/${mediaMap.get(msg.localId)!.relativePath}` : undefined
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -778,6 +1176,7 @@ class HttpService {
|
|||||||
platform: 'wechat',
|
platform: 'wechat',
|
||||||
type: isGroup ? 'group' : 'private',
|
type: isGroup ? 'group' : 'private',
|
||||||
groupId: isGroup ? talkerId : undefined,
|
groupId: isGroup ? talkerId : undefined,
|
||||||
|
groupAvatar: isGroup ? sessionAvatarInfo?.avatarUrl : undefined,
|
||||||
ownerId: myWxid || undefined
|
ownerId: myWxid || undefined
|
||||||
},
|
},
|
||||||
members: Array.from(memberMap.values()),
|
members: Array.from(memberMap.values()),
|
||||||
@@ -827,7 +1226,7 @@ class HttpService {
|
|||||||
* 映射 Type 49 子类型
|
* 映射 Type 49 子类型
|
||||||
*/
|
*/
|
||||||
private mapType49(msg: Message): number {
|
private mapType49(msg: Message): number {
|
||||||
const xmlType = msg.xmlType
|
const xmlType = this.resolveType49Subtype(msg)
|
||||||
|
|
||||||
switch (xmlType) {
|
switch (xmlType) {
|
||||||
case '5': // 链接
|
case '5': // 链接
|
||||||
@@ -851,10 +1250,97 @@ class HttpService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extractType49Subtype(rawContent: string): string {
|
||||||
|
const content = String(rawContent || '')
|
||||||
|
if (!content) return ''
|
||||||
|
|
||||||
|
const appmsgMatch = /<appmsg[\s\S]*?>([\s\S]*?)<\/appmsg>/i.exec(content)
|
||||||
|
if (appmsgMatch) {
|
||||||
|
const appmsgInner = appmsgMatch[1]
|
||||||
|
.replace(/<refermsg[\s\S]*?<\/refermsg>/gi, '')
|
||||||
|
.replace(/<patMsg[\s\S]*?<\/patMsg>/gi, '')
|
||||||
|
const typeMatch = /<type>([\s\S]*?)<\/type>/i.exec(appmsgInner)
|
||||||
|
if (typeMatch) {
|
||||||
|
return typeMatch[1].replace(/<!\[CDATA\[/g, '').replace(/\]\]>/g, '').trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackMatch = /<type>([\s\S]*?)<\/type>/i.exec(content)
|
||||||
|
if (fallbackMatch) {
|
||||||
|
return fallbackMatch[1].replace(/<!\[CDATA\[/g, '').replace(/\]\]>/g, '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveType49Subtype(msg: Message): string {
|
||||||
|
const xmlType = String(msg.xmlType || '').trim()
|
||||||
|
if (xmlType) return xmlType
|
||||||
|
|
||||||
|
const extractedType = this.extractType49Subtype(msg.rawContent)
|
||||||
|
if (extractedType) return extractedType
|
||||||
|
|
||||||
|
switch (msg.appMsgKind) {
|
||||||
|
case 'official-link':
|
||||||
|
case 'link':
|
||||||
|
return '5'
|
||||||
|
case 'file':
|
||||||
|
return '6'
|
||||||
|
case 'chat-record':
|
||||||
|
return '19'
|
||||||
|
case 'miniapp':
|
||||||
|
return '33'
|
||||||
|
case 'quote':
|
||||||
|
return '57'
|
||||||
|
case 'transfer':
|
||||||
|
return '2000'
|
||||||
|
case 'red-packet':
|
||||||
|
return '2001'
|
||||||
|
case 'music':
|
||||||
|
return '3'
|
||||||
|
default:
|
||||||
|
if (msg.linkUrl) return '5'
|
||||||
|
if (msg.fileName) return '6'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getType49Content(msg: Message): string {
|
||||||
|
const subtype = this.resolveType49Subtype(msg)
|
||||||
|
const title = msg.linkTitle || msg.fileName || ''
|
||||||
|
|
||||||
|
switch (subtype) {
|
||||||
|
case '5':
|
||||||
|
case '49':
|
||||||
|
return title ? `[链接] ${title}` : '[链接]'
|
||||||
|
case '6':
|
||||||
|
return title ? `[文件] ${title}` : '[文件]'
|
||||||
|
case '19':
|
||||||
|
return title ? `[聊天记录] ${title}` : '[聊天记录]'
|
||||||
|
case '33':
|
||||||
|
case '36':
|
||||||
|
return title ? `[小程序] ${title}` : '[小程序]'
|
||||||
|
case '57':
|
||||||
|
return msg.parsedContent || title || '[引用消息]'
|
||||||
|
case '2000':
|
||||||
|
return title ? `[转账] ${title}` : '[转账]'
|
||||||
|
case '2001':
|
||||||
|
return title ? `[红包] ${title}` : '[红包]'
|
||||||
|
case '3':
|
||||||
|
return title ? `[音乐] ${title}` : '[音乐]'
|
||||||
|
default:
|
||||||
|
return msg.parsedContent || title || '[消息]'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取消息内容
|
* 获取消息内容
|
||||||
*/
|
*/
|
||||||
private getMessageContent(msg: Message): string | null {
|
private getMessageContent(msg: Message): string | null {
|
||||||
|
if (msg.localType === 49) {
|
||||||
|
return this.getType49Content(msg)
|
||||||
|
}
|
||||||
|
|
||||||
// 优先使用已解析的内容
|
// 优先使用已解析的内容
|
||||||
if (msg.parsedContent) {
|
if (msg.parsedContent) {
|
||||||
return msg.parsedContent
|
return msg.parsedContent
|
||||||
@@ -877,7 +1363,7 @@ class HttpService {
|
|||||||
case 48:
|
case 48:
|
||||||
return '[位置]'
|
return '[位置]'
|
||||||
case 49:
|
case 49:
|
||||||
return msg.linkTitle || msg.fileName || '[消息]'
|
return this.getType49Content(msg)
|
||||||
default:
|
default:
|
||||||
return msg.rawContent || null
|
return msg.rawContent || null
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
364
electron/services/keyServiceLinux.ts
Normal file
364
electron/services/keyServiceLinux.ts
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
import { app } from 'electron'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
|
||||||
|
import { execFile, exec, spawn } from 'child_process'
|
||||||
|
import { promisify } from 'util'
|
||||||
|
import { createRequire } from 'module';
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile)
|
||||||
|
const execAsync = promisify(exec)
|
||||||
|
|
||||||
|
type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] }
|
||||||
|
type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; error?: string }
|
||||||
|
|
||||||
|
export class KeyServiceLinux {
|
||||||
|
private sudo: any
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
try {
|
||||||
|
this.sudo = require('sudo-prompt');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load sudo-prompt', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getHelperPath(): string {
|
||||||
|
const isPackaged = app.isPackaged
|
||||||
|
const candidates: string[] = []
|
||||||
|
if (process.env.WX_KEY_HELPER_PATH) candidates.push(process.env.WX_KEY_HELPER_PATH)
|
||||||
|
if (isPackaged) {
|
||||||
|
candidates.push(join(process.resourcesPath, 'resources', 'xkey_helper_linux'))
|
||||||
|
candidates.push(join(process.resourcesPath, 'xkey_helper_linux'))
|
||||||
|
} else {
|
||||||
|
candidates.push(join(app.getAppPath(), 'resources', 'xkey_helper_linux'))
|
||||||
|
candidates.push(join(app.getAppPath(), '..', 'Xkey', 'build', 'xkey_helper_linux'))
|
||||||
|
}
|
||||||
|
for (const p of candidates) {
|
||||||
|
if (existsSync(p)) return p
|
||||||
|
}
|
||||||
|
throw new Error('找不到 xkey_helper_linux,请检查路径')
|
||||||
|
}
|
||||||
|
|
||||||
|
public async autoGetDbKey(
|
||||||
|
timeoutMs = 60_000,
|
||||||
|
onStatus?: (message: string, level: number) => void
|
||||||
|
): Promise<DbKeyResult> {
|
||||||
|
try {
|
||||||
|
// 1. 构造一个包含常用系统命令路径的环境变量,防止打包后找不到命令
|
||||||
|
const envWithPath = {
|
||||||
|
...process.env,
|
||||||
|
PATH: `${process.env.PATH || ''}:/bin:/usr/bin:/sbin:/usr/sbin:/usr/local/bin`
|
||||||
|
};
|
||||||
|
|
||||||
|
onStatus?.('正在尝试结束当前微信进程...', 0)
|
||||||
|
console.log('[Debug] 开始执行进程清理逻辑...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { stdout, stderr } = await execAsync('killall -9 wechat wechat-bin xwechat', { env: envWithPath });
|
||||||
|
console.log(`[Debug] killall 成功退出. stdout: ${stdout}, stderr: ${stderr}`);
|
||||||
|
} catch (err: any) {
|
||||||
|
// 命令如果没找到进程通常会返回 code 1,这也是正常的,但我们需要记录下来
|
||||||
|
console.log(`[Debug] killall 报错或未找到进程: ${err.message}`);
|
||||||
|
|
||||||
|
// Fallback: 尝试使用 pkill 兜底
|
||||||
|
try {
|
||||||
|
console.log('[Debug] 尝试使用备用命令 pkill...');
|
||||||
|
await execAsync('pkill -9 -x "wechat|wechat-bin|xwechat"', { env: envWithPath });
|
||||||
|
console.log('[Debug] pkill 执行完成');
|
||||||
|
} catch (e: any) {
|
||||||
|
console.log(`[Debug] pkill 报错或未找到进程: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 稍微等待进程完全退出
|
||||||
|
await new Promise(r => setTimeout(r, 1000))
|
||||||
|
|
||||||
|
onStatus?.('正在尝试拉起微信...', 0)
|
||||||
|
|
||||||
|
const cleanEnv = { ...process.env };
|
||||||
|
delete cleanEnv.ELECTRON_RUN_AS_NODE;
|
||||||
|
delete cleanEnv.ELECTRON_NO_ATTACH_CONSOLE;
|
||||||
|
delete cleanEnv.APPDIR;
|
||||||
|
delete cleanEnv.APPIMAGE;
|
||||||
|
|
||||||
|
const wechatBins = [
|
||||||
|
'wechat',
|
||||||
|
'wechat-bin',
|
||||||
|
'xwechat',
|
||||||
|
'/opt/wechat/wechat',
|
||||||
|
'/usr/bin/wechat',
|
||||||
|
'/opt/apps/com.tencent.wechat/files/wechat'
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const binName of wechatBins) {
|
||||||
|
try {
|
||||||
|
const child = spawn(binName, [], {
|
||||||
|
detached: true,
|
||||||
|
stdio: 'ignore',
|
||||||
|
env: cleanEnv
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('error', (err) => {
|
||||||
|
console.log(`[Debug] 拉起 ${binName} 失败:`, err.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
child.unref();
|
||||||
|
console.log(`[Debug] 尝试拉起 ${binName} 完毕`);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.log(`[Debug] 尝试拉起 ${binName} 发生异常:`, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onStatus?.('等待微信进程出现...', 0)
|
||||||
|
let pid = 0
|
||||||
|
for (let i = 0; i < 15; i++) { // 最多等 15 秒
|
||||||
|
await new Promise(r => setTimeout(r, 1000))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { stdout } = await execAsync('pidof wechat wechat-bin xwechat', { env: envWithPath });
|
||||||
|
const pids = stdout.trim().split(/\s+/).filter(p => p);
|
||||||
|
if (pids.length > 0) {
|
||||||
|
pid = parseInt(pids[0], 10);
|
||||||
|
console.log(`[Debug] 第 ${i + 1} 秒,通过 pidof 成功获取 PID: ${pid}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.log(`[Debug] 第 ${i + 1} 秒,pidof 失败: ${err.message.split('\n')[0]}`);
|
||||||
|
|
||||||
|
// Fallback: 使用 pgrep 兜底
|
||||||
|
try {
|
||||||
|
const { stdout: pgrepOut } = await execAsync('pgrep -x "wechat|wechat-bin|xwechat"', { env: envWithPath });
|
||||||
|
const pids = pgrepOut.trim().split(/\s+/).filter(p => p);
|
||||||
|
if (pids.length > 0) {
|
||||||
|
pid = parseInt(pids[0], 10);
|
||||||
|
console.log(`[Debug] 第 ${i + 1} 秒,通过 pgrep 成功获取 PID: ${pid}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.log(`[Debug] 第 ${i + 1} 秒,pgrep 也失败: ${e.message.split('\n')[0]}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pid) {
|
||||||
|
const err = '未能自动启动微信,或获取PID失败,请查看控制台日志或手动启动并登录。'
|
||||||
|
onStatus?.(err, 2)
|
||||||
|
return { success: false, error: err }
|
||||||
|
}
|
||||||
|
|
||||||
|
onStatus?.(`捕获到微信 PID: ${pid},准备获取密钥...`, 0)
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r, 2000))
|
||||||
|
|
||||||
|
return await this.getDbKey(pid, onStatus)
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[Debug] 自动获取流程彻底崩溃:', err);
|
||||||
|
const errMsg = '自动获取微信 PID 失败: ' + err.message
|
||||||
|
onStatus?.(errMsg, 2)
|
||||||
|
return { success: false, error: errMsg }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getDbKey(pid: number, onStatus?: (message: string, level: number) => void): Promise<DbKeyResult> {
|
||||||
|
try {
|
||||||
|
const helperPath = this.getHelperPath()
|
||||||
|
|
||||||
|
onStatus?.('正在扫描数据库基址...', 0)
|
||||||
|
const { stdout: scanOut } = await execFileAsync(helperPath, ['db_scan', pid.toString()])
|
||||||
|
const scanRes = JSON.parse(scanOut.trim())
|
||||||
|
|
||||||
|
if (!scanRes.success) {
|
||||||
|
const err = scanRes.result || '扫描失败,请确保微信已完全登录'
|
||||||
|
onStatus?.(err, 2)
|
||||||
|
return { success: false, error: err }
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetAddr = scanRes.target_addr
|
||||||
|
onStatus?.('基址扫描成功,正在请求管理员权限进行内存 Hook...', 0)
|
||||||
|
|
||||||
|
return await new Promise((resolve) => {
|
||||||
|
const options = { name: 'WeFlow' }
|
||||||
|
const command = `"${helperPath}" db_hook ${pid} ${targetAddr}`
|
||||||
|
|
||||||
|
this.sudo.exec(command, options, (error, stdout) => {
|
||||||
|
execAsync(`kill -CONT ${pid}`).catch(() => {})
|
||||||
|
if (error) {
|
||||||
|
onStatus?.('授权失败或被取消', 2)
|
||||||
|
resolve({ success: false, error: `授权失败或被取消: ${error.message}` })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const hookRes = JSON.parse((stdout as string).trim())
|
||||||
|
if (hookRes.success) {
|
||||||
|
onStatus?.('密钥获取成功', 1)
|
||||||
|
resolve({ success: true, key: hookRes.key })
|
||||||
|
} else {
|
||||||
|
onStatus?.(hookRes.result, 2)
|
||||||
|
resolve({ success: false, error: hookRes.result })
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
onStatus?.('解析 Hook 结果失败', 2)
|
||||||
|
resolve({ success: false, error: '解析 Hook 结果失败' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} catch (err: any) {
|
||||||
|
onStatus?.(err.message, 2)
|
||||||
|
return { success: false, error: err.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async autoGetImageKey(
|
||||||
|
accountPath?: string,
|
||||||
|
onProgress?: (msg: string) => void,
|
||||||
|
wxid?: string
|
||||||
|
): Promise<ImageKeyResult> {
|
||||||
|
try {
|
||||||
|
onProgress?.('正在初始化缓存扫描...');
|
||||||
|
const helperPath = this.getHelperPath()
|
||||||
|
const { stdout } = await execFileAsync(helperPath, ['image_local'])
|
||||||
|
const res = JSON.parse(stdout.trim())
|
||||||
|
if (!res.success) return { success: false, error: res.result }
|
||||||
|
|
||||||
|
const accounts = res.data.accounts || []
|
||||||
|
let account = accounts.find((a: any) => a.wxid === wxid)
|
||||||
|
if (!account && accounts.length > 0) account = accounts[0]
|
||||||
|
|
||||||
|
if (account && account.keys && account.keys.length > 0) {
|
||||||
|
onProgress?.(`已找到匹配的图片密钥 (wxid: ${account.wxid})`);
|
||||||
|
const keyObj = account.keys[0]
|
||||||
|
return { success: true, xorKey: keyObj.xorKey, aesKey: keyObj.aesKey }
|
||||||
|
}
|
||||||
|
return { success: false, error: '未在缓存中找到匹配的图片密钥' }
|
||||||
|
} catch (err: any) {
|
||||||
|
return { success: false, error: err.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async autoGetImageKeyByMemoryScan(
|
||||||
|
accountPath: string,
|
||||||
|
onProgress?: (msg: string) => void
|
||||||
|
): Promise<ImageKeyResult> {
|
||||||
|
try {
|
||||||
|
onProgress?.('正在查找模板文件...')
|
||||||
|
let result = await this._findTemplateData(accountPath, 32)
|
||||||
|
let { ciphertext, xorKey } = result
|
||||||
|
|
||||||
|
if (ciphertext && xorKey === null) {
|
||||||
|
onProgress?.('未找到有效密钥,尝试扫描更多文件...')
|
||||||
|
result = await this._findTemplateData(accountPath, 100)
|
||||||
|
xorKey = result.xorKey
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ciphertext) return { success: false, error: '未找到 V2 模板文件,请先在微信中查看几张图片' }
|
||||||
|
if (xorKey === null) return { success: false, error: '未能从模板文件中计算出有效的 XOR 密钥' }
|
||||||
|
|
||||||
|
onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`)
|
||||||
|
|
||||||
|
// 2. 找微信 PID
|
||||||
|
const { stdout } = await execAsync('pidof wechat wechat-bin xwechat').catch(() => ({ stdout: '' }))
|
||||||
|
const pids = stdout.trim().split(/\s+/).filter(p => p)
|
||||||
|
if (pids.length === 0) return { success: false, error: '微信未运行,无法扫描内存' }
|
||||||
|
const pid = parseInt(pids[0], 10)
|
||||||
|
|
||||||
|
onProgress?.(`已找到微信进程 PID=${pid},正在提权扫描进程内存...`);
|
||||||
|
|
||||||
|
// 3. 将 Buffer 转换为 hex 传递给 helper
|
||||||
|
const ciphertextHex = ciphertext.toString('hex')
|
||||||
|
const helperPath = this.getHelperPath()
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`[Debug] 准备执行 Helper: ${helperPath} image_mem ${pid} ${ciphertextHex}`);
|
||||||
|
|
||||||
|
const { stdout: memOut, stderr } = await execFileAsync(helperPath, ['image_mem', pid.toString(), ciphertextHex])
|
||||||
|
|
||||||
|
console.log(`[Debug] Helper stdout: ${memOut}`);
|
||||||
|
if (stderr) {
|
||||||
|
console.warn(`[Debug] Helper stderr: ${stderr}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!memOut || memOut.trim() === '') {
|
||||||
|
return { success: false, error: 'Helper 返回为空,请检查是否有足够的权限(如需sudo)读取进程内存。' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = JSON.parse(memOut.trim())
|
||||||
|
|
||||||
|
if (res.success) {
|
||||||
|
onProgress?.('内存扫描成功');
|
||||||
|
return { success: true, xorKey, aesKey: res.key }
|
||||||
|
}
|
||||||
|
return { success: false, error: res.result || '未知错误' }
|
||||||
|
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[Debug] 执行或解析 Helper 时发生崩溃:', err);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `内存扫描失败: ${err.message}\nstdout: ${err.stdout || '无'}\nstderr: ${err.stderr || '无'}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
return { success: false, error: `内存扫描失败: ${err.message}` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _findTemplateData(userDir: string, limit: number = 32): Promise<{ ciphertext: Buffer | null; xorKey: number | null }> {
|
||||||
|
const V2_MAGIC = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07])
|
||||||
|
|
||||||
|
// 递归收集 *_t.dat 文件
|
||||||
|
const collect = (dir: string, results: string[], maxFiles: number) => {
|
||||||
|
if (results.length >= maxFiles) return
|
||||||
|
try {
|
||||||
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
if (results.length >= maxFiles) break
|
||||||
|
const full = join(dir, entry.name)
|
||||||
|
if (entry.isDirectory()) collect(full, results, maxFiles)
|
||||||
|
else if (entry.isFile() && entry.name.endsWith('_t.dat')) results.push(full)
|
||||||
|
}
|
||||||
|
} catch { /* 忽略无权限目录 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const files: string[] = []
|
||||||
|
collect(userDir, files, limit)
|
||||||
|
|
||||||
|
// 按修改时间降序
|
||||||
|
files.sort((a, b) => {
|
||||||
|
try { return statSync(b).mtimeMs - statSync(a).mtimeMs } catch { return 0 }
|
||||||
|
})
|
||||||
|
|
||||||
|
let ciphertext: Buffer | null = null
|
||||||
|
const tailCounts: Record<string, number> = {}
|
||||||
|
|
||||||
|
for (const f of files.slice(0, 32)) {
|
||||||
|
try {
|
||||||
|
const data = readFileSync(f)
|
||||||
|
if (data.length < 8) continue
|
||||||
|
|
||||||
|
// 统计末尾两字节用于 XOR 密钥
|
||||||
|
if (data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 2) {
|
||||||
|
const key = `${data[data.length - 2]}_${data[data.length - 1]}`
|
||||||
|
tailCounts[key] = (tailCounts[key] ?? 0) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取密文(取第一个有效的)
|
||||||
|
if (!ciphertext && data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 0x1F) {
|
||||||
|
ciphertext = data.subarray(0xF, 0x1F)
|
||||||
|
}
|
||||||
|
} catch { /* 忽略 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算 XOR 密钥
|
||||||
|
let xorKey: number | null = null
|
||||||
|
let maxCount = 0
|
||||||
|
for (const [key, count] of Object.entries(tailCounts)) {
|
||||||
|
if (count > maxCount) {
|
||||||
|
maxCount = count
|
||||||
|
const [x, y] = key.split('_').map(Number)
|
||||||
|
const k = x ^ 0xFF
|
||||||
|
if (k === (y ^ 0xD9)) xorKey = k
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ciphertext, xorKey }
|
||||||
|
}
|
||||||
|
}
|
||||||
1194
electron/services/keyServiceMac.ts
Normal file
1194
electron/services/keyServiceMac.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,371 +0,0 @@
|
|||||||
import fs from "fs";
|
|
||||||
import { app, BrowserWindow } from "electron";
|
|
||||||
import path from "path";
|
|
||||||
import { ConfigService } from './config';
|
|
||||||
|
|
||||||
// Define interfaces locally to avoid static import of types that might not be available or cause issues
|
|
||||||
type LlamaModel = any;
|
|
||||||
type LlamaContext = any;
|
|
||||||
type LlamaChatSession = any;
|
|
||||||
|
|
||||||
export class LlamaService {
|
|
||||||
private _model: LlamaModel | null = null;
|
|
||||||
private _context: LlamaContext | null = null;
|
|
||||||
private _sequence: any = null;
|
|
||||||
private _session: LlamaChatSession | null = null;
|
|
||||||
private _llama: any = null;
|
|
||||||
private _nodeLlamaCpp: any = null;
|
|
||||||
private configService = new ConfigService();
|
|
||||||
private _initialized = false;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
// 延迟初始化,只在需要时初始化
|
|
||||||
}
|
|
||||||
|
|
||||||
public async init() {
|
|
||||||
if (this._initialized) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Dynamic import to handle ESM module in CJS context
|
|
||||||
this._nodeLlamaCpp = await import("node-llama-cpp");
|
|
||||||
this._llama = await this._nodeLlamaCpp.getLlama();
|
|
||||||
this._initialized = true;
|
|
||||||
console.log("[LlamaService] Llama initialized");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[LlamaService] Failed to initialize Llama:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async loadModel(modelPath: string) {
|
|
||||||
if (!this._llama) await this.init();
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log("[LlamaService] Loading model from:", modelPath);
|
|
||||||
if (!this._llama) {
|
|
||||||
throw new Error("Llama not initialized");
|
|
||||||
}
|
|
||||||
this._model = await this._llama.loadModel({
|
|
||||||
modelPath: modelPath,
|
|
||||||
gpuLayers: 'max', // Offload all layers to GPU if possible
|
|
||||||
useMlock: false // Disable mlock to avoid "VirtualLock" errors (common on Windows)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!this._model) throw new Error("Failed to load model");
|
|
||||||
|
|
||||||
this._context = await this._model.createContext({
|
|
||||||
contextSize: 8192, // Balanced context size for better performance
|
|
||||||
batchSize: 2048 // Increase batch size for better prompt processing speed
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!this._context) throw new Error("Failed to create context");
|
|
||||||
|
|
||||||
this._sequence = this._context.getSequence();
|
|
||||||
|
|
||||||
const { LlamaChatSession } = this._nodeLlamaCpp;
|
|
||||||
this._session = new LlamaChatSession({
|
|
||||||
contextSequence: this._sequence
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("[LlamaService] Model loaded successfully");
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[LlamaService] Failed to load model:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async createSession(systemPrompt?: string) {
|
|
||||||
if (!this._context) throw new Error("Model not loaded");
|
|
||||||
if (!this._nodeLlamaCpp) await this.init();
|
|
||||||
|
|
||||||
const { LlamaChatSession } = this._nodeLlamaCpp;
|
|
||||||
|
|
||||||
if (!this._sequence) {
|
|
||||||
this._sequence = this._context.getSequence();
|
|
||||||
}
|
|
||||||
|
|
||||||
this._session = new LlamaChatSession({
|
|
||||||
contextSequence: this._sequence,
|
|
||||||
systemPrompt: systemPrompt
|
|
||||||
});
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async chat(message: string, options: { thinking?: boolean } = {}, onToken: (token: string) => void) {
|
|
||||||
if (!this._session) throw new Error("Session not initialized");
|
|
||||||
|
|
||||||
const thinking = options.thinking ?? false;
|
|
||||||
|
|
||||||
// Sampling parameters based on mode
|
|
||||||
const samplingParams = thinking ? {
|
|
||||||
temperature: 0.6,
|
|
||||||
topP: 0.95,
|
|
||||||
topK: 20,
|
|
||||||
repeatPenalty: 1.5 // PresencePenalty=1.5
|
|
||||||
} : {
|
|
||||||
temperature: 0.7,
|
|
||||||
topP: 0.8,
|
|
||||||
topK: 20,
|
|
||||||
repeatPenalty: 1.5
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await this._session.prompt(message, {
|
|
||||||
...samplingParams,
|
|
||||||
onTextChunk: (chunk: string) => {
|
|
||||||
onToken(chunk);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return response;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[LlamaService] Chat error:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getModelStatus(modelPath: string) {
|
|
||||||
try {
|
|
||||||
const exists = fs.existsSync(modelPath);
|
|
||||||
if (!exists) {
|
|
||||||
return { exists: false, path: modelPath };
|
|
||||||
}
|
|
||||||
const stats = fs.statSync(modelPath);
|
|
||||||
return {
|
|
||||||
exists: true,
|
|
||||||
path: modelPath,
|
|
||||||
size: stats.size
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return { exists: false, error: String(error) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private resolveModelDir(): string {
|
|
||||||
const configured = this.configService.get('whisperModelDir') as string | undefined;
|
|
||||||
if (configured) return configured;
|
|
||||||
return path.join(app.getPath('documents'), 'WeFlow', 'models');
|
|
||||||
}
|
|
||||||
|
|
||||||
public async downloadModel(url: string, savePath: string, onProgress: (payload: { downloaded: number; total: number; speed: number }) => void): Promise<void> {
|
|
||||||
// Ensure directory exists
|
|
||||||
const dir = path.dirname(savePath);
|
|
||||||
if (!fs.existsSync(dir)) {
|
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
console.info(`[LlamaService] Multi-threaded download check for: ${savePath}`);
|
|
||||||
|
|
||||||
if (fs.existsSync(savePath)) {
|
|
||||||
fs.unlinkSync(savePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Get total size and check range support
|
|
||||||
let probeResult;
|
|
||||||
try {
|
|
||||||
probeResult = await this.probeUrl(url);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("[LlamaService] Probe failed, falling back to single-thread.", err);
|
|
||||||
return this.downloadSingleThread(url, savePath, onProgress);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { totalSize, acceptRanges, finalUrl } = probeResult;
|
|
||||||
console.log(`[LlamaService] Total size: ${totalSize}, Accept-Ranges: ${acceptRanges}`);
|
|
||||||
|
|
||||||
if (totalSize <= 0 || !acceptRanges) {
|
|
||||||
console.warn("[LlamaService] Ranges not supported or size unknown, falling back to single-thread.");
|
|
||||||
return this.downloadSingleThread(finalUrl, savePath, onProgress);
|
|
||||||
}
|
|
||||||
|
|
||||||
const threadCount = 4;
|
|
||||||
const chunkSize = Math.ceil(totalSize / threadCount);
|
|
||||||
const fd = fs.openSync(savePath, 'w');
|
|
||||||
|
|
||||||
let downloadedLength = 0;
|
|
||||||
let lastDownloadedLength = 0;
|
|
||||||
let lastTime = Date.now();
|
|
||||||
let speed = 0;
|
|
||||||
|
|
||||||
const speedInterval = setInterval(() => {
|
|
||||||
const now = Date.now();
|
|
||||||
const duration = (now - lastTime) / 1000;
|
|
||||||
if (duration > 0) {
|
|
||||||
speed = (downloadedLength - lastDownloadedLength) / duration;
|
|
||||||
lastDownloadedLength = downloadedLength;
|
|
||||||
lastTime = now;
|
|
||||||
onProgress({ downloaded: downloadedLength, total: 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) => {
|
|
||||||
downloadedLength += bytes;
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(promises);
|
|
||||||
console.log("[LlamaService] Multi-threaded download complete");
|
|
||||||
|
|
||||||
// Final progress update
|
|
||||||
onProgress({ downloaded: totalSize, total: totalSize, speed: 0 });
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[LlamaService] Multi-threaded download failed:", err);
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
clearInterval(speedInterval);
|
|
||||||
fs.closeSync(fd);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async probeUrl(url: string): Promise<{ totalSize: number, acceptRanges: boolean, finalUrl: string }> {
|
|
||||||
const protocol = url.startsWith('https') ? require('https') : require('http');
|
|
||||||
const options = {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
||||||
'Referer': 'https://www.modelscope.cn/',
|
|
||||||
'Range': 'bytes=0-0'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const req = protocol.get(url, options, (res: any) => {
|
|
||||||
if ([301, 302, 307, 308].includes(res.statusCode)) {
|
|
||||||
const location = res.headers.location;
|
|
||||||
const nextUrl = new URL(location, url).href;
|
|
||||||
this.probeUrl(nextUrl).then(resolve).catch(reject);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res.statusCode !== 206 && res.statusCode !== 200) {
|
|
||||||
reject(new Error(`Probe failed: HTTP ${res.statusCode}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentRange = res.headers['content-range'];
|
|
||||||
let totalSize = 0;
|
|
||||||
if (contentRange) {
|
|
||||||
const parts = contentRange.split('/');
|
|
||||||
totalSize = parseInt(parts[parts.length - 1], 10);
|
|
||||||
} else {
|
|
||||||
totalSize = parseInt(res.headers['content-length'] || '0', 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
const acceptRanges = res.headers['accept-ranges'] === 'bytes' || !!contentRange;
|
|
||||||
resolve({ totalSize, acceptRanges, finalUrl: url });
|
|
||||||
res.destroy();
|
|
||||||
});
|
|
||||||
req.on('error', reject);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async downloadChunk(url: string, fd: number, start: number, end: number, onData: (bytes: number) => void): Promise<void> {
|
|
||||||
const protocol = url.startsWith('https') ? require('https') : require('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://www.modelscope.cn/',
|
|
||||||
'Range': `bytes=${start}-${end}`
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const req = protocol.get(url, options, (res: any) => {
|
|
||||||
if (res.statusCode !== 206) {
|
|
||||||
reject(new Error(`Chunk download failed: HTTP ${res.statusCode}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let currentOffset = start;
|
|
||||||
res.on('data', (chunk: Buffer) => {
|
|
||||||
try {
|
|
||||||
fs.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, savePath: string, onProgress: (payload: { downloaded: number; total: number; speed: number }) => void): Promise<void> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const protocol = url.startsWith('https') ? require('https') : require('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://www.modelscope.cn/'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const request = protocol.get(url, options, (response: any) => {
|
|
||||||
if ([301, 302, 307, 308].includes(response.statusCode)) {
|
|
||||||
const location = response.headers.location;
|
|
||||||
const nextUrl = new URL(location, url).href;
|
|
||||||
this.downloadSingleThread(nextUrl, savePath, onProgress).then(resolve).catch(reject);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.statusCode !== 200) {
|
|
||||||
reject(new Error(`Fallback download failed: HTTP ${response.statusCode}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalLength = parseInt(response.headers['content-length'] || '0', 10);
|
|
||||||
let downloadedLength = 0;
|
|
||||||
let lastDownloadedLength = 0;
|
|
||||||
let lastTime = Date.now();
|
|
||||||
let speed = 0;
|
|
||||||
|
|
||||||
const fileStream = fs.createWriteStream(savePath);
|
|
||||||
response.pipe(fileStream);
|
|
||||||
|
|
||||||
const speedInterval = setInterval(() => {
|
|
||||||
const now = Date.now();
|
|
||||||
const duration = (now - lastTime) / 1000;
|
|
||||||
if (duration > 0) {
|
|
||||||
speed = (downloadedLength - lastDownloadedLength) / duration;
|
|
||||||
lastDownloadedLength = downloadedLength;
|
|
||||||
lastTime = now;
|
|
||||||
onProgress({ downloaded: downloadedLength, total: totalLength, speed });
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
response.on('data', (chunk: any) => {
|
|
||||||
downloadedLength += chunk.length;
|
|
||||||
});
|
|
||||||
|
|
||||||
fileStream.on('finish', () => {
|
|
||||||
clearInterval(speedInterval);
|
|
||||||
fileStream.close();
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
fileStream.on('error', (err: any) => {
|
|
||||||
clearInterval(speedInterval);
|
|
||||||
fs.unlink(savePath, () => { });
|
|
||||||
reject(err);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
request.on('error', reject);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public getModelsPath() {
|
|
||||||
return this.resolveModelDir();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const llamaService = new LlamaService();
|
|
||||||
371
electron/services/messagePushService.ts
Normal file
371
electron/services/messagePushService.ts
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
import { ConfigService } from './config'
|
||||||
|
import { chatService, type ChatSession, type Message } from './chatService'
|
||||||
|
import { wcdbService } from './wcdbService'
|
||||||
|
import { httpService } from './httpService'
|
||||||
|
|
||||||
|
interface SessionBaseline {
|
||||||
|
lastTimestamp: number
|
||||||
|
unreadCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessagePushPayload {
|
||||||
|
event: 'message.new'
|
||||||
|
sessionId: string
|
||||||
|
messageKey: string
|
||||||
|
avatarUrl?: string
|
||||||
|
sourceName: string
|
||||||
|
groupName?: string
|
||||||
|
content: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const PUSH_CONFIG_KEYS = new Set([
|
||||||
|
'messagePushEnabled',
|
||||||
|
'dbPath',
|
||||||
|
'decryptKey',
|
||||||
|
'myWxid'
|
||||||
|
])
|
||||||
|
|
||||||
|
class MessagePushService {
|
||||||
|
private readonly configService: ConfigService
|
||||||
|
private readonly sessionBaseline = new Map<string, SessionBaseline>()
|
||||||
|
private readonly recentMessageKeys = new Map<string, number>()
|
||||||
|
private readonly groupNicknameCache = new Map<string, { nicknames: Record<string, string>; updatedAt: number }>()
|
||||||
|
private readonly debounceMs = 350
|
||||||
|
private readonly recentMessageTtlMs = 10 * 60 * 1000
|
||||||
|
private readonly groupNicknameCacheTtlMs = 5 * 60 * 1000
|
||||||
|
private debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
private processing = false
|
||||||
|
private rerunRequested = false
|
||||||
|
private started = false
|
||||||
|
private baselineReady = false
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.configService = ConfigService.getInstance()
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
if (this.started) return
|
||||||
|
this.started = true
|
||||||
|
void this.refreshConfiguration('startup')
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDbMonitorChange(type: string, json: string): void {
|
||||||
|
if (!this.started) return
|
||||||
|
if (!this.isPushEnabled()) return
|
||||||
|
|
||||||
|
let payload: Record<string, unknown> | null = null
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(json)
|
||||||
|
} catch {
|
||||||
|
payload = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableName = String(payload?.table || '').trim().toLowerCase()
|
||||||
|
if (tableName && tableName !== 'session') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.scheduleSync()
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleConfigChanged(key: string): Promise<void> {
|
||||||
|
if (!PUSH_CONFIG_KEYS.has(String(key || '').trim())) return
|
||||||
|
if (key === 'dbPath' || key === 'decryptKey' || key === 'myWxid') {
|
||||||
|
this.resetRuntimeState()
|
||||||
|
chatService.close()
|
||||||
|
}
|
||||||
|
await this.refreshConfiguration(`config:${key}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
handleConfigCleared(): void {
|
||||||
|
this.resetRuntimeState()
|
||||||
|
chatService.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
private isPushEnabled(): boolean {
|
||||||
|
return this.configService.get('messagePushEnabled') === true
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetRuntimeState(): void {
|
||||||
|
this.sessionBaseline.clear()
|
||||||
|
this.recentMessageKeys.clear()
|
||||||
|
this.groupNicknameCache.clear()
|
||||||
|
this.baselineReady = false
|
||||||
|
if (this.debounceTimer) {
|
||||||
|
clearTimeout(this.debounceTimer)
|
||||||
|
this.debounceTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshConfiguration(reason: string): Promise<void> {
|
||||||
|
if (!this.isPushEnabled()) {
|
||||||
|
this.resetRuntimeState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectResult = await chatService.connect()
|
||||||
|
if (!connectResult.success) {
|
||||||
|
console.warn(`[MessagePushService] Bootstrap connect failed (${reason}):`, connectResult.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.bootstrapBaseline()
|
||||||
|
}
|
||||||
|
|
||||||
|
private async bootstrapBaseline(): Promise<void> {
|
||||||
|
const sessionsResult = await chatService.getSessions()
|
||||||
|
if (!sessionsResult.success || !sessionsResult.sessions) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.setBaseline(sessionsResult.sessions as ChatSession[])
|
||||||
|
this.baselineReady = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleSync(): void {
|
||||||
|
if (this.debounceTimer) {
|
||||||
|
clearTimeout(this.debounceTimer)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.debounceTimer = setTimeout(() => {
|
||||||
|
this.debounceTimer = null
|
||||||
|
void this.flushPendingChanges()
|
||||||
|
}, this.debounceMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async flushPendingChanges(): Promise<void> {
|
||||||
|
if (this.processing) {
|
||||||
|
this.rerunRequested = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.processing = true
|
||||||
|
try {
|
||||||
|
if (!this.isPushEnabled()) return
|
||||||
|
|
||||||
|
const connectResult = await chatService.connect()
|
||||||
|
if (!connectResult.success) {
|
||||||
|
console.warn('[MessagePushService] Sync connect failed:', connectResult.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionsResult = await chatService.getSessions()
|
||||||
|
if (!sessionsResult.success || !sessionsResult.sessions) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions = sessionsResult.sessions as ChatSession[]
|
||||||
|
if (!this.baselineReady) {
|
||||||
|
this.setBaseline(sessions)
|
||||||
|
this.baselineReady = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousBaseline = new Map(this.sessionBaseline)
|
||||||
|
this.setBaseline(sessions)
|
||||||
|
|
||||||
|
const candidates = sessions.filter((session) => this.shouldInspectSession(previousBaseline.get(session.username), session))
|
||||||
|
for (const session of candidates) {
|
||||||
|
await this.pushSessionMessages(session, previousBaseline.get(session.username))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.processing = false
|
||||||
|
if (this.rerunRequested) {
|
||||||
|
this.rerunRequested = false
|
||||||
|
this.scheduleSync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setBaseline(sessions: ChatSession[]): void {
|
||||||
|
this.sessionBaseline.clear()
|
||||||
|
for (const session of sessions) {
|
||||||
|
this.sessionBaseline.set(session.username, {
|
||||||
|
lastTimestamp: Number(session.lastTimestamp || 0),
|
||||||
|
unreadCount: Number(session.unreadCount || 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldInspectSession(previous: SessionBaseline | undefined, session: ChatSession): boolean {
|
||||||
|
const sessionId = String(session.username || '').trim()
|
||||||
|
if (!sessionId || sessionId.toLowerCase().includes('placeholder_foldgroup')) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = String(session.summary || '').trim()
|
||||||
|
if (Number(session.lastMsgType || 0) === 10002 || summary.includes('撤回了一条消息')) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastTimestamp = Number(session.lastTimestamp || 0)
|
||||||
|
const unreadCount = Number(session.unreadCount || 0)
|
||||||
|
|
||||||
|
if (!previous) {
|
||||||
|
return unreadCount > 0 && lastTimestamp > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastTimestamp <= previous.lastTimestamp) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// unread 未增长时,大概率是自己发送、其他设备已读或状态同步,不作为主动推送
|
||||||
|
return unreadCount > previous.unreadCount
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pushSessionMessages(session: ChatSession, previous: SessionBaseline | undefined): Promise<void> {
|
||||||
|
const since = Math.max(0, Number(previous?.lastTimestamp || 0) - 1)
|
||||||
|
const newMessagesResult = await chatService.getNewMessages(session.username, since, 1000)
|
||||||
|
if (!newMessagesResult.success || !newMessagesResult.messages || newMessagesResult.messages.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const message of newMessagesResult.messages) {
|
||||||
|
const messageKey = String(message.messageKey || '').trim()
|
||||||
|
if (!messageKey) continue
|
||||||
|
if (message.isSend === 1) continue
|
||||||
|
|
||||||
|
if (previous && Number(message.createTime || 0) < Number(previous.lastTimestamp || 0)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isRecentMessage(messageKey)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await this.buildPayload(session, message)
|
||||||
|
if (!payload) continue
|
||||||
|
|
||||||
|
httpService.broadcastMessagePush(payload)
|
||||||
|
this.rememberMessageKey(messageKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildPayload(session: ChatSession, message: Message): Promise<MessagePushPayload | null> {
|
||||||
|
const sessionId = String(session.username || '').trim()
|
||||||
|
const messageKey = String(message.messageKey || '').trim()
|
||||||
|
if (!sessionId || !messageKey) return null
|
||||||
|
|
||||||
|
const isGroup = sessionId.endsWith('@chatroom')
|
||||||
|
const content = this.getMessageDisplayContent(message)
|
||||||
|
|
||||||
|
if (isGroup) {
|
||||||
|
const groupInfo = await chatService.getContactAvatar(sessionId)
|
||||||
|
const groupName = session.displayName || groupInfo?.displayName || sessionId
|
||||||
|
const sourceName = await this.resolveGroupSourceName(sessionId, message, session)
|
||||||
|
return {
|
||||||
|
event: 'message.new',
|
||||||
|
sessionId,
|
||||||
|
messageKey,
|
||||||
|
avatarUrl: session.avatarUrl || groupInfo?.avatarUrl,
|
||||||
|
groupName,
|
||||||
|
sourceName,
|
||||||
|
content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const contactInfo = await chatService.getContactAvatar(sessionId)
|
||||||
|
return {
|
||||||
|
event: 'message.new',
|
||||||
|
sessionId,
|
||||||
|
messageKey,
|
||||||
|
avatarUrl: session.avatarUrl || contactInfo?.avatarUrl,
|
||||||
|
sourceName: session.displayName || contactInfo?.displayName || sessionId,
|
||||||
|
content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMessageDisplayContent(message: Message): string | null {
|
||||||
|
switch (Number(message.localType || 0)) {
|
||||||
|
case 1:
|
||||||
|
return message.rawContent || null
|
||||||
|
case 3:
|
||||||
|
return '[图片]'
|
||||||
|
case 34:
|
||||||
|
return '[语音]'
|
||||||
|
case 43:
|
||||||
|
return '[视频]'
|
||||||
|
case 47:
|
||||||
|
return '[表情]'
|
||||||
|
case 42:
|
||||||
|
return message.cardNickname || '[名片]'
|
||||||
|
case 48:
|
||||||
|
return '[位置]'
|
||||||
|
case 49:
|
||||||
|
return message.linkTitle || message.fileName || '[消息]'
|
||||||
|
default:
|
||||||
|
return message.parsedContent || message.rawContent || null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveGroupSourceName(chatroomId: string, message: Message, session: ChatSession): Promise<string> {
|
||||||
|
const senderUsername = String(message.senderUsername || '').trim()
|
||||||
|
if (!senderUsername) {
|
||||||
|
return session.lastSenderDisplayName || '未知发送者'
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupNicknames = await this.getGroupNicknames(chatroomId)
|
||||||
|
const normalizedSender = this.normalizeAccountId(senderUsername)
|
||||||
|
const nickname = groupNicknames[senderUsername]
|
||||||
|
|| groupNicknames[senderUsername.toLowerCase()]
|
||||||
|
|| groupNicknames[normalizedSender]
|
||||||
|
|| groupNicknames[normalizedSender.toLowerCase()]
|
||||||
|
|
||||||
|
if (nickname) {
|
||||||
|
return nickname
|
||||||
|
}
|
||||||
|
|
||||||
|
const contactInfo = await chatService.getContactAvatar(senderUsername)
|
||||||
|
return contactInfo?.displayName || senderUsername
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getGroupNicknames(chatroomId: string): Promise<Record<string, string>> {
|
||||||
|
const cacheKey = String(chatroomId || '').trim()
|
||||||
|
if (!cacheKey) return {}
|
||||||
|
|
||||||
|
const cached = this.groupNicknameCache.get(cacheKey)
|
||||||
|
if (cached && Date.now() - cached.updatedAt < this.groupNicknameCacheTtlMs) {
|
||||||
|
return cached.nicknames
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await wcdbService.getGroupNicknames(cacheKey)
|
||||||
|
const nicknames = result.success && result.nicknames ? result.nicknames : {}
|
||||||
|
this.groupNicknameCache.set(cacheKey, { nicknames, updatedAt: Date.now() })
|
||||||
|
return nicknames
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeAccountId(value: string): string {
|
||||||
|
const trimmed = String(value || '').trim()
|
||||||
|
if (!trimmed) return trimmed
|
||||||
|
|
||||||
|
if (trimmed.toLowerCase().startsWith('wxid_')) {
|
||||||
|
const match = trimmed.match(/^(wxid_[^_]+)/i)
|
||||||
|
return match ? match[1] : trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
||||||
|
return suffixMatch ? suffixMatch[1] : trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
private isRecentMessage(messageKey: string): boolean {
|
||||||
|
this.pruneRecentMessageKeys()
|
||||||
|
const timestamp = this.recentMessageKeys.get(messageKey)
|
||||||
|
return typeof timestamp === 'number' && Date.now() - timestamp < this.recentMessageTtlMs
|
||||||
|
}
|
||||||
|
|
||||||
|
private rememberMessageKey(messageKey: string): void {
|
||||||
|
this.recentMessageKeys.set(messageKey, Date.now())
|
||||||
|
this.pruneRecentMessageKeys()
|
||||||
|
}
|
||||||
|
|
||||||
|
private pruneRecentMessageKeys(): void {
|
||||||
|
const now = Date.now()
|
||||||
|
for (const [key, timestamp] of this.recentMessageKeys.entries()) {
|
||||||
|
if (now - timestamp > this.recentMessageTtlMs) {
|
||||||
|
this.recentMessageKeys.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export const messagePushService = new MessagePushService()
|
||||||
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,279 +1,557 @@
|
|||||||
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 { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
|
|
||||||
export interface VideoInfo {
|
export interface VideoInfo {
|
||||||
videoUrl?: string // 视频文件路径(用于 readFile)
|
videoUrl?: string // 视频文件路径(用于 readFile)
|
||||||
coverUrl?: string // 封面 data URL
|
coverUrl?: string // 封面 data URL
|
||||||
thumbUrl?: string // 缩略图 data URL
|
thumbUrl?: string // 缩略图 data URL
|
||||||
exists: boolean
|
exists: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimedCacheEntry<T> {
|
||||||
|
value: T
|
||||||
|
expiresAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VideoIndexEntry {
|
||||||
|
videoPath?: string
|
||||||
|
coverPath?: string
|
||||||
|
thumbPath?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
class VideoService {
|
class VideoService {
|
||||||
private configService: ConfigService
|
private configService: ConfigService
|
||||||
|
private hardlinkResolveCache = new Map<string, TimedCacheEntry<string | null>>()
|
||||||
|
private videoInfoCache = new Map<string, TimedCacheEntry<VideoInfo>>()
|
||||||
|
private videoDirIndexCache = new Map<string, TimedCacheEntry<Map<string, VideoIndexEntry>>>()
|
||||||
|
private pendingVideoInfo = new Map<string, Promise<VideoInfo>>()
|
||||||
|
private readonly hardlinkCacheTtlMs = 10 * 60 * 1000
|
||||||
|
private readonly videoInfoCacheTtlMs = 2 * 60 * 1000
|
||||||
|
private readonly videoIndexCacheTtlMs = 90 * 1000
|
||||||
|
private readonly maxCacheEntries = 2000
|
||||||
|
private readonly maxIndexEntries = 6
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
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 { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private readTimedCache<T>(cache: Map<string, TimedCacheEntry<T>>, key: string): T | undefined {
|
||||||
|
const hit = cache.get(key)
|
||||||
|
if (!hit) return undefined
|
||||||
|
if (hit.expiresAt <= Date.now()) {
|
||||||
|
cache.delete(key)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return hit.value
|
||||||
|
}
|
||||||
|
|
||||||
|
private writeTimedCache<T>(
|
||||||
|
cache: Map<string, TimedCacheEntry<T>>,
|
||||||
|
key: string,
|
||||||
|
value: T,
|
||||||
|
ttlMs: number,
|
||||||
|
maxEntries: number
|
||||||
|
): void {
|
||||||
|
cache.set(key, { value, expiresAt: Date.now() + ttlMs })
|
||||||
|
if (cache.size <= maxEntries) return
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
for (const [cacheKey, entry] of cache) {
|
||||||
|
if (entry.expiresAt <= now) {
|
||||||
|
cache.delete(cacheKey)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
while (cache.size > maxEntries) {
|
||||||
* 获取数据库根目录
|
const oldestKey = cache.keys().next().value as string | undefined
|
||||||
*/
|
if (!oldestKey) break
|
||||||
private getDbPath(): string {
|
cache.delete(oldestKey)
|
||||||
return this.configService.get('dbPath') || ''
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取数据库根目录
|
||||||
|
*/
|
||||||
|
private getDbPath(): string {
|
||||||
|
return this.configService.get('dbPath') || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前用户的wxid
|
||||||
|
*/
|
||||||
|
private getMyWxid(): string {
|
||||||
|
return this.configService.get('myWxid') || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理 wxid 目录名(去掉后缀)
|
||||||
|
*/
|
||||||
|
private cleanWxid(wxid: string): string {
|
||||||
|
const trimmed = wxid.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})$/)
|
||||||
* 获取当前用户的wxid
|
if (suffixMatch) return suffixMatch[1]
|
||||||
*/
|
|
||||||
private getMyWxid(): string {
|
return trimmed
|
||||||
return this.configService.get('myWxid') || ''
|
}
|
||||||
|
|
||||||
|
private getScopeKey(dbPath: string, wxid: string): string {
|
||||||
|
return `${dbPath}::${this.cleanWxid(wxid)}`.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveVideoBaseDir(dbPath: string, wxid: string): string {
|
||||||
|
const cleanedWxid = this.cleanWxid(wxid)
|
||||||
|
const dbPathLower = dbPath.toLowerCase()
|
||||||
|
const wxidLower = wxid.toLowerCase()
|
||||||
|
const cleanedWxidLower = cleanedWxid.toLowerCase()
|
||||||
|
const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower)
|
||||||
|
if (dbPathContainsWxid) {
|
||||||
|
return join(dbPath, 'msg', 'video')
|
||||||
|
}
|
||||||
|
return join(dbPath, wxid, 'msg', 'video')
|
||||||
|
}
|
||||||
|
|
||||||
|
private getHardlinkDbPaths(dbPath: string, wxid: string, cleanedWxid: string): string[] {
|
||||||
|
const dbPathLower = dbPath.toLowerCase()
|
||||||
|
const wxidLower = wxid.toLowerCase()
|
||||||
|
const cleanedWxidLower = cleanedWxid.toLowerCase()
|
||||||
|
const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower)
|
||||||
|
|
||||||
|
if (dbPathContainsWxid) {
|
||||||
|
return [join(dbPath, 'db_storage', 'hardlink', 'hardlink.db')]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
return [
|
||||||
* 获取缓存目录(解密后的数据库存放位置)
|
join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'),
|
||||||
*/
|
join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db')
|
||||||
private getCachePath(): string {
|
]
|
||||||
return this.configService.getCacheBasePath()
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 video_hardlink_info_v4 表查询视频文件名
|
||||||
|
* 使用 wcdb 专属接口查询加密的 hardlink.db
|
||||||
|
*/
|
||||||
|
private async resolveVideoHardlinks(
|
||||||
|
md5List: string[],
|
||||||
|
dbPath: string,
|
||||||
|
wxid: string,
|
||||||
|
cleanedWxid: string
|
||||||
|
): Promise<Map<string, string>> {
|
||||||
|
const scopeKey = this.getScopeKey(dbPath, wxid)
|
||||||
|
const normalizedList = Array.from(
|
||||||
|
new Set((md5List || []).map((item) => String(item || '').trim().toLowerCase()).filter(Boolean))
|
||||||
|
)
|
||||||
|
const resolvedMap = new Map<string, string>()
|
||||||
|
const unresolvedSet = new Set(normalizedList)
|
||||||
|
|
||||||
|
for (const md5 of normalizedList) {
|
||||||
|
const cacheKey = `${scopeKey}|${md5}`
|
||||||
|
const cached = this.readTimedCache(this.hardlinkResolveCache, cacheKey)
|
||||||
|
if (cached === undefined) continue
|
||||||
|
if (cached) resolvedMap.set(md5, cached)
|
||||||
|
unresolvedSet.delete(md5)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
if (unresolvedSet.size === 0) return resolvedMap
|
||||||
* 清理 wxid 目录名(去掉后缀)
|
|
||||||
*/
|
|
||||||
private cleanWxid(wxid: string): string {
|
|
||||||
const trimmed = wxid.trim()
|
|
||||||
if (!trimmed) return trimmed
|
|
||||||
|
|
||||||
if (trimmed.toLowerCase().startsWith('wxid_')) {
|
const encryptedDbPaths = this.getHardlinkDbPaths(dbPath, wxid, cleanedWxid)
|
||||||
const match = trimmed.match(/^(wxid_[^_]+)/i)
|
for (const p of encryptedDbPaths) {
|
||||||
if (match) return match[1]
|
if (!existsSync(p) || unresolvedSet.size === 0) continue
|
||||||
return trimmed
|
const unresolved = Array.from(unresolvedSet)
|
||||||
}
|
const requests = unresolved.map((md5) => ({ md5, dbPath: p }))
|
||||||
|
try {
|
||||||
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
const batchResult = await wcdbService.resolveVideoHardlinkMd5Batch(requests)
|
||||||
if (suffixMatch) return suffixMatch[1]
|
if (batchResult.success && Array.isArray(batchResult.rows)) {
|
||||||
|
for (const row of batchResult.rows) {
|
||||||
return trimmed
|
const index = Number.isFinite(Number(row?.index)) ? Math.floor(Number(row?.index)) : -1
|
||||||
}
|
const inputMd5 = index >= 0 && index < requests.length
|
||||||
|
? requests[index].md5
|
||||||
/**
|
: String(row?.md5 || '').trim().toLowerCase()
|
||||||
* 从 video_hardlink_info_v4 表查询视频文件名
|
if (!inputMd5) continue
|
||||||
* 优先使用 cachePath 中解密后的 hardlink.db(使用 better-sqlite3)
|
const resolvedMd5 = row?.success && row?.data?.resolved_md5
|
||||||
* 如果失败,则尝试使用 wcdbService.execQuery 查询加密的 hardlink.db
|
? String(row.data.resolved_md5).trim().toLowerCase()
|
||||||
*/
|
: ''
|
||||||
private async queryVideoFileName(md5: string): Promise<string | undefined> {
|
if (!resolvedMd5) continue
|
||||||
const cachePath = this.getCachePath()
|
const cacheKey = `${scopeKey}|${inputMd5}`
|
||||||
const dbPath = this.getDbPath()
|
this.writeTimedCache(this.hardlinkResolveCache, cacheKey, resolvedMd5, this.hardlinkCacheTtlMs, this.maxCacheEntries)
|
||||||
const wxid = this.getMyWxid()
|
resolvedMap.set(inputMd5, resolvedMd5)
|
||||||
const cleanedWxid = this.cleanWxid(wxid)
|
unresolvedSet.delete(inputMd5)
|
||||||
|
}
|
||||||
if (!wxid) return undefined
|
|
||||||
|
|
||||||
// 方法1:优先在 cachePath 下查找解密后的 hardlink.db
|
|
||||||
if (cachePath) {
|
|
||||||
const cacheDbPaths = [
|
|
||||||
join(cachePath, cleanedWxid, 'hardlink.db'),
|
|
||||||
join(cachePath, wxid, 'hardlink.db'),
|
|
||||||
join(cachePath, 'hardlink.db'),
|
|
||||||
join(cachePath, 'databases', cleanedWxid, 'hardlink.db'),
|
|
||||||
join(cachePath, 'databases', wxid, 'hardlink.db')
|
|
||||||
]
|
|
||||||
|
|
||||||
for (const p of cacheDbPaths) {
|
|
||||||
if (existsSync(p)) {
|
|
||||||
try {
|
|
||||||
const db = new Database(p, { readonly: true })
|
|
||||||
const row = db.prepare(`
|
|
||||||
SELECT file_name, md5 FROM video_hardlink_info_v4
|
|
||||||
WHERE md5 = ?
|
|
||||||
LIMIT 1
|
|
||||||
`).get(md5) as { file_name: string; md5: string } | undefined
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
if (row?.file_name) {
|
|
||||||
const realMd5 = row.file_name.replace(/\.[^.]+$/, '')
|
|
||||||
return realMd5
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// 忽略错误
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 方法2:使用 wcdbService.execQuery 查询加密的 hardlink.db
|
|
||||||
if (dbPath) {
|
|
||||||
// 检查 dbPath 是否已经包含 wxid
|
|
||||||
const dbPathLower = dbPath.toLowerCase()
|
|
||||||
const wxidLower = wxid.toLowerCase()
|
|
||||||
const cleanedWxidLower = cleanedWxid.toLowerCase()
|
|
||||||
const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower)
|
|
||||||
|
|
||||||
const encryptedDbPaths: string[] = []
|
|
||||||
if (dbPathContainsWxid) {
|
|
||||||
// dbPath 已包含 wxid,不需要再拼接
|
|
||||||
encryptedDbPaths.push(join(dbPath, 'db_storage', 'hardlink', 'hardlink.db'))
|
|
||||||
} else {
|
|
||||||
// dbPath 不包含 wxid,需要拼接
|
|
||||||
encryptedDbPaths.push(join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'))
|
|
||||||
encryptedDbPaths.push(join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db'))
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const p of encryptedDbPaths) {
|
|
||||||
if (existsSync(p)) {
|
|
||||||
try {
|
|
||||||
const escapedMd5 = md5.replace(/'/g, "''")
|
|
||||||
|
|
||||||
// 用 md5 字段查询,获取 file_name
|
|
||||||
const sql = `SELECT file_name FROM video_hardlink_info_v4 WHERE md5 = '${escapedMd5}' LIMIT 1`
|
|
||||||
|
|
||||||
const result = await wcdbService.execQuery('media', p, sql)
|
|
||||||
|
|
||||||
if (result.success && result.rows && result.rows.length > 0) {
|
|
||||||
const row = result.rows[0]
|
|
||||||
if (row?.file_name) {
|
|
||||||
// 提取不带扩展名的文件名作为实际视频 MD5
|
|
||||||
const realMd5 = String(row.file_name).replace(/\.[^.]+$/, '')
|
|
||||||
return realMd5
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// 忽略错误
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 将文件转换为 data URL
|
|
||||||
*/
|
|
||||||
private fileToDataUrl(filePath: string, mimeType: string): string | undefined {
|
|
||||||
try {
|
|
||||||
if (!existsSync(filePath)) return undefined
|
|
||||||
const buffer = readFileSync(filePath)
|
|
||||||
return `data:${mimeType};base64,${buffer.toString('base64')}`
|
|
||||||
} catch {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 根据视频MD5获取视频文件信息
|
|
||||||
* 视频存放在: {数据库根目录}/{用户wxid}/msg/video/{年月}/
|
|
||||||
* 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg
|
|
||||||
*/
|
|
||||||
async getVideoInfo(videoMd5: string): Promise<VideoInfo> {
|
|
||||||
const dbPath = this.getDbPath()
|
|
||||||
const wxid = this.getMyWxid()
|
|
||||||
|
|
||||||
if (!dbPath || !wxid || !videoMd5) {
|
|
||||||
return { exists: false }
|
|
||||||
}
|
|
||||||
|
|
||||||
// 先尝试从数据库查询真正的视频文件名
|
|
||||||
const realVideoMd5 = await this.queryVideoFileName(videoMd5) || videoMd5
|
|
||||||
|
|
||||||
// 检查 dbPath 是否已经包含 wxid,避免重复拼接
|
|
||||||
const dbPathLower = dbPath.toLowerCase()
|
|
||||||
const wxidLower = wxid.toLowerCase()
|
|
||||||
const cleanedWxid = this.cleanWxid(wxid)
|
|
||||||
|
|
||||||
let videoBaseDir: string
|
|
||||||
if (dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxid.toLowerCase())) {
|
|
||||||
// dbPath 已经包含 wxid,直接使用
|
|
||||||
videoBaseDir = join(dbPath, 'msg', 'video')
|
|
||||||
} else {
|
} else {
|
||||||
// dbPath 不包含 wxid,需要拼接
|
// 兼容不支持批量接口的版本,回退单条请求。
|
||||||
videoBaseDir = join(dbPath, wxid, 'msg', 'video')
|
for (const req of requests) {
|
||||||
|
try {
|
||||||
|
const single = await wcdbService.resolveVideoHardlinkMd5(req.md5, req.dbPath)
|
||||||
|
const resolvedMd5 = single.success && single.data?.resolved_md5
|
||||||
|
? String(single.data.resolved_md5).trim().toLowerCase()
|
||||||
|
: ''
|
||||||
|
if (!resolvedMd5) continue
|
||||||
|
const cacheKey = `${scopeKey}|${req.md5}`
|
||||||
|
this.writeTimedCache(this.hardlinkResolveCache, cacheKey, resolvedMd5, this.hardlinkCacheTtlMs, this.maxCacheEntries)
|
||||||
|
resolvedMap.set(req.md5, resolvedMd5)
|
||||||
|
unresolvedSet.delete(req.md5)
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
if (!existsSync(videoBaseDir)) {
|
this.log('resolveVideoHardlinks 批量查询失败', { path: p, error: String(e) })
|
||||||
return { exists: false }
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 遍历年月目录查找视频文件
|
|
||||||
try {
|
|
||||||
const allDirs = readdirSync(videoBaseDir)
|
|
||||||
|
|
||||||
// 支持多种目录格式: YYYY-MM, YYYYMM, 或其他
|
|
||||||
const yearMonthDirs = allDirs
|
|
||||||
.filter(dir => {
|
|
||||||
const dirPath = join(videoBaseDir, dir)
|
|
||||||
return statSync(dirPath).isDirectory()
|
|
||||||
})
|
|
||||||
.sort((a, b) => b.localeCompare(a)) // 从最新的目录开始查找
|
|
||||||
|
|
||||||
for (const yearMonth of yearMonthDirs) {
|
|
||||||
const dirPath = join(videoBaseDir, yearMonth)
|
|
||||||
|
|
||||||
const videoPath = join(dirPath, `${realVideoMd5}.mp4`)
|
|
||||||
const coverPath = join(dirPath, `${realVideoMd5}.jpg`)
|
|
||||||
const thumbPath = join(dirPath, `${realVideoMd5}_thumb.jpg`)
|
|
||||||
|
|
||||||
// 检查视频文件是否存在
|
|
||||||
if (existsSync(videoPath)) {
|
|
||||||
return {
|
|
||||||
videoUrl: videoPath, // 返回文件路径,前端通过 readFile 读取
|
|
||||||
coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'),
|
|
||||||
thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'),
|
|
||||||
exists: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// 忽略错误
|
|
||||||
}
|
|
||||||
|
|
||||||
return { exists: false }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
for (const md5 of unresolvedSet) {
|
||||||
* 根据消息内容解析视频MD5
|
const cacheKey = `${scopeKey}|${md5}`
|
||||||
*/
|
this.writeTimedCache(this.hardlinkResolveCache, cacheKey, null, this.hardlinkCacheTtlMs, this.maxCacheEntries)
|
||||||
parseVideoMd5(content: string): string | undefined {
|
}
|
||||||
|
|
||||||
// 打印前500字符看看 XML 结构
|
return resolvedMap
|
||||||
|
}
|
||||||
|
|
||||||
if (!content) return undefined
|
private async queryVideoFileName(md5: string): Promise<string | undefined> {
|
||||||
|
const normalizedMd5 = String(md5 || '').trim().toLowerCase()
|
||||||
|
const dbPath = this.getDbPath()
|
||||||
|
const wxid = this.getMyWxid()
|
||||||
|
const cleanedWxid = this.cleanWxid(wxid)
|
||||||
|
|
||||||
|
this.log('queryVideoFileName 开始', { md5: normalizedMd5, wxid, cleanedWxid, dbPath })
|
||||||
|
|
||||||
|
if (!normalizedMd5 || !wxid || !dbPath) {
|
||||||
|
this.log('queryVideoFileName: 参数缺失', { hasMd5: !!normalizedMd5, hasWxid: !!wxid, hasDbPath: !!dbPath })
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedMap = await this.resolveVideoHardlinks([normalizedMd5], dbPath, wxid, cleanedWxid)
|
||||||
|
const resolved = resolvedMap.get(normalizedMd5)
|
||||||
|
if (resolved) {
|
||||||
|
this.log('queryVideoFileName 命中', { input: normalizedMd5, resolved })
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
async preloadVideoHardlinkMd5s(md5List: string[]): Promise<void> {
|
||||||
|
const dbPath = this.getDbPath()
|
||||||
|
const wxid = this.getMyWxid()
|
||||||
|
const cleanedWxid = this.cleanWxid(wxid)
|
||||||
|
if (!dbPath || !wxid) return
|
||||||
|
await this.resolveVideoHardlinks(md5List, dbPath, wxid, cleanedWxid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将文件转换为 data URL
|
||||||
|
*/
|
||||||
|
private fileToDataUrl(filePath: string | undefined, mimeType: string): string | undefined {
|
||||||
|
try {
|
||||||
|
if (!filePath || !existsSync(filePath)) return undefined
|
||||||
|
const buffer = readFileSync(filePath)
|
||||||
|
return `data:${mimeType};base64,${buffer.toString('base64')}`
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getOrBuildVideoIndex(videoBaseDir: string): Map<string, VideoIndexEntry> {
|
||||||
|
const cached = this.readTimedCache(this.videoDirIndexCache, videoBaseDir)
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
|
const index = new Map<string, VideoIndexEntry>()
|
||||||
|
const ensureEntry = (key: string): VideoIndexEntry => {
|
||||||
|
let entry = index.get(key)
|
||||||
|
if (!entry) {
|
||||||
|
entry = {}
|
||||||
|
index.set(key, entry)
|
||||||
|
}
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const yearMonthDirs = readdirSync(videoBaseDir)
|
||||||
|
.filter((dir) => {
|
||||||
|
const dirPath = join(videoBaseDir, dir)
|
||||||
|
try {
|
||||||
|
return statSync(dirPath).isDirectory()
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.localeCompare(a))
|
||||||
|
|
||||||
|
for (const yearMonth of yearMonthDirs) {
|
||||||
|
const dirPath = join(videoBaseDir, yearMonth)
|
||||||
|
let files: string[] = []
|
||||||
try {
|
try {
|
||||||
// 提取所有可能的 md5 值进行日志
|
files = readdirSync(dirPath)
|
||||||
const allMd5s: string[] = []
|
} catch {
|
||||||
const md5Regex = /(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]+)['"]/gi
|
continue
|
||||||
let match
|
|
||||||
while ((match = md5Regex.exec(content)) !== null) {
|
|
||||||
allMd5s.push(`${match[0]}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 提取 md5(用于查询 hardlink.db)
|
|
||||||
// 注意:不是 rawmd5,rawmd5 是另一个值
|
|
||||||
// 格式: md5="xxx" 或 <md5>xxx</md5>
|
|
||||||
|
|
||||||
// 尝试从videomsg标签中提取md5
|
|
||||||
const videoMsgMatch = /<videomsg[^>]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
|
||||||
if (videoMsgMatch) {
|
|
||||||
return videoMsgMatch[1].toLowerCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
const attrMatch = /\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
|
||||||
if (attrMatch) {
|
|
||||||
return attrMatch[1].toLowerCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
const md5Match = /<md5>([a-fA-F0-9]+)<\/md5>/i.exec(content)
|
|
||||||
if (md5Match) {
|
|
||||||
return md5Match[1].toLowerCase()
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[VideoService] 解析视频MD5失败:', e)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined
|
for (const file of files) {
|
||||||
|
const lower = file.toLowerCase()
|
||||||
|
const fullPath = join(dirPath, file)
|
||||||
|
|
||||||
|
if (lower.endsWith('.mp4')) {
|
||||||
|
const md5 = lower.slice(0, -4)
|
||||||
|
const entry = ensureEntry(md5)
|
||||||
|
if (!entry.videoPath) entry.videoPath = fullPath
|
||||||
|
if (md5.endsWith('_raw')) {
|
||||||
|
const baseMd5 = md5.replace(/_raw$/, '')
|
||||||
|
const baseEntry = ensureEntry(baseMd5)
|
||||||
|
if (!baseEntry.videoPath) baseEntry.videoPath = fullPath
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lower.endsWith('.jpg')) continue
|
||||||
|
const jpgBase = lower.slice(0, -4)
|
||||||
|
if (jpgBase.endsWith('_thumb')) {
|
||||||
|
const baseMd5 = jpgBase.slice(0, -6)
|
||||||
|
const entry = ensureEntry(baseMd5)
|
||||||
|
if (!entry.thumbPath) entry.thumbPath = fullPath
|
||||||
|
} else {
|
||||||
|
const entry = ensureEntry(jpgBase)
|
||||||
|
if (!entry.coverPath) entry.coverPath = fullPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, entry] of index) {
|
||||||
|
if (!key.endsWith('_raw')) continue
|
||||||
|
const baseKey = key.replace(/_raw$/, '')
|
||||||
|
const baseEntry = index.get(baseKey)
|
||||||
|
if (!baseEntry) continue
|
||||||
|
if (!entry.coverPath) entry.coverPath = baseEntry.coverPath
|
||||||
|
if (!entry.thumbPath) entry.thumbPath = baseEntry.thumbPath
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.log('构建视频索引失败', { videoBaseDir, error: String(e) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.writeTimedCache(
|
||||||
|
this.videoDirIndexCache,
|
||||||
|
videoBaseDir,
|
||||||
|
index,
|
||||||
|
this.videoIndexCacheTtlMs,
|
||||||
|
this.maxIndexEntries
|
||||||
|
)
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
|
private getVideoInfoFromIndex(index: Map<string, VideoIndexEntry>, md5: string, includePoster = true): VideoInfo | null {
|
||||||
|
const normalizedMd5 = String(md5 || '').trim().toLowerCase()
|
||||||
|
if (!normalizedMd5) return null
|
||||||
|
|
||||||
|
const candidates = [normalizedMd5]
|
||||||
|
const baseMd5 = normalizedMd5.replace(/_raw$/, '')
|
||||||
|
if (baseMd5 !== normalizedMd5) {
|
||||||
|
candidates.push(baseMd5)
|
||||||
|
} else {
|
||||||
|
candidates.push(`${normalizedMd5}_raw`)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of candidates) {
|
||||||
|
const entry = index.get(key)
|
||||||
|
if (!entry?.videoPath) continue
|
||||||
|
if (!existsSync(entry.videoPath)) continue
|
||||||
|
if (!includePoster) {
|
||||||
|
return {
|
||||||
|
videoUrl: entry.videoPath,
|
||||||
|
exists: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
videoUrl: entry.videoPath,
|
||||||
|
coverUrl: this.fileToDataUrl(entry.coverPath, 'image/jpeg'),
|
||||||
|
thumbUrl: this.fileToDataUrl(entry.thumbPath, 'image/jpeg'),
|
||||||
|
exists: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fallbackScanVideo(videoBaseDir: string, realVideoMd5: string, includePoster = true): VideoInfo | null {
|
||||||
|
try {
|
||||||
|
const yearMonthDirs = readdirSync(videoBaseDir)
|
||||||
|
.filter((dir) => {
|
||||||
|
const dirPath = join(videoBaseDir, dir)
|
||||||
|
try {
|
||||||
|
return statSync(dirPath).isDirectory()
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.localeCompare(a))
|
||||||
|
|
||||||
|
for (const yearMonth of yearMonthDirs) {
|
||||||
|
const dirPath = join(videoBaseDir, yearMonth)
|
||||||
|
const videoPath = join(dirPath, `${realVideoMd5}.mp4`)
|
||||||
|
if (!existsSync(videoPath)) continue
|
||||||
|
if (!includePoster) {
|
||||||
|
return {
|
||||||
|
videoUrl: videoPath,
|
||||||
|
exists: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const baseMd5 = realVideoMd5.replace(/_raw$/, '')
|
||||||
|
const coverPath = join(dirPath, `${baseMd5}.jpg`)
|
||||||
|
const thumbPath = join(dirPath, `${baseMd5}_thumb.jpg`)
|
||||||
|
return {
|
||||||
|
videoUrl: videoPath,
|
||||||
|
coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'),
|
||||||
|
thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'),
|
||||||
|
exists: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.log('fallback 扫描视频目录失败', { error: String(e) })
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据视频MD5获取视频文件信息
|
||||||
|
* 视频存放在: {数据库根目录}/{用户wxid}/msg/video/{年月}/
|
||||||
|
* 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg
|
||||||
|
*/
|
||||||
|
async getVideoInfo(videoMd5: string, options?: { includePoster?: boolean }): Promise<VideoInfo> {
|
||||||
|
const normalizedMd5 = String(videoMd5 || '').trim().toLowerCase()
|
||||||
|
const includePoster = options?.includePoster !== false
|
||||||
|
const dbPath = this.getDbPath()
|
||||||
|
const wxid = this.getMyWxid()
|
||||||
|
|
||||||
|
this.log('getVideoInfo 开始', { videoMd5: normalizedMd5, dbPath, wxid })
|
||||||
|
|
||||||
|
if (!dbPath || !wxid || !normalizedMd5) {
|
||||||
|
this.log('getVideoInfo: 参数缺失', { hasDbPath: !!dbPath, hasWxid: !!wxid, hasVideoMd5: !!normalizedMd5 })
|
||||||
|
return { exists: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const scopeKey = this.getScopeKey(dbPath, wxid)
|
||||||
|
const cacheKey = `${scopeKey}|${normalizedMd5}|poster=${includePoster ? 1 : 0}`
|
||||||
|
|
||||||
|
const cachedInfo = this.readTimedCache(this.videoInfoCache, cacheKey)
|
||||||
|
if (cachedInfo) return cachedInfo
|
||||||
|
|
||||||
|
const pending = this.pendingVideoInfo.get(cacheKey)
|
||||||
|
if (pending) return pending
|
||||||
|
|
||||||
|
const task = (async (): Promise<VideoInfo> => {
|
||||||
|
const realVideoMd5 = await this.queryVideoFileName(normalizedMd5) || normalizedMd5
|
||||||
|
const videoBaseDir = this.resolveVideoBaseDir(dbPath, wxid)
|
||||||
|
|
||||||
|
if (!existsSync(videoBaseDir)) {
|
||||||
|
const miss = { exists: false }
|
||||||
|
this.writeTimedCache(this.videoInfoCache, cacheKey, miss, this.videoInfoCacheTtlMs, this.maxCacheEntries)
|
||||||
|
return miss
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = this.getOrBuildVideoIndex(videoBaseDir)
|
||||||
|
const indexed = this.getVideoInfoFromIndex(index, realVideoMd5, includePoster)
|
||||||
|
if (indexed) {
|
||||||
|
this.writeTimedCache(this.videoInfoCache, cacheKey, indexed, this.videoInfoCacheTtlMs, this.maxCacheEntries)
|
||||||
|
return indexed
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallback = this.fallbackScanVideo(videoBaseDir, realVideoMd5, includePoster)
|
||||||
|
if (fallback) {
|
||||||
|
this.writeTimedCache(this.videoInfoCache, cacheKey, fallback, this.videoInfoCacheTtlMs, this.maxCacheEntries)
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
const miss = { exists: false }
|
||||||
|
this.writeTimedCache(this.videoInfoCache, cacheKey, miss, this.videoInfoCacheTtlMs, this.maxCacheEntries)
|
||||||
|
this.log('getVideoInfo: 未找到视频', { inputMd5: normalizedMd5, resolvedMd5: realVideoMd5 })
|
||||||
|
return miss
|
||||||
|
})()
|
||||||
|
|
||||||
|
this.pendingVideoInfo.set(cacheKey, task)
|
||||||
|
try {
|
||||||
|
return await task
|
||||||
|
} finally {
|
||||||
|
this.pendingVideoInfo.delete(cacheKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据消息内容解析视频MD5
|
||||||
|
*/
|
||||||
|
parseVideoMd5(content: string): string | undefined {
|
||||||
|
if (!content) return undefined
|
||||||
|
|
||||||
|
// 打印原始 XML 前 800 字符,帮助排查自己发的视频结构
|
||||||
|
this.log('parseVideoMd5 原始内容', { preview: content.slice(0, 800) })
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 收集所有 md5 相关属性,方便对比
|
||||||
|
const allMd5Attrs: string[] = []
|
||||||
|
const md5Regex = /(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]*)['"]/gi
|
||||||
|
let match
|
||||||
|
while ((match = md5Regex.exec(content)) !== null) {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方法2:从 <videomsg rawmd5="..."> 提取(自己发的视频,没有 md5 只有 rawmd5)
|
||||||
|
const rawMd5Match = /<videomsg[^>]*\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
||||||
|
if (rawMd5Match) {
|
||||||
|
this.log('parseVideoMd5 命中 videomsg rawmd5 属性(自发视频)', { rawmd5: rawMd5Match[1] })
|
||||||
|
return rawMd5Match[1].toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方法3:任意属性 md5="..."(非 rawmd5/cdnthumbaeskey 等)
|
||||||
|
const attrMatch = /(?<![a-z])md5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
||||||
|
if (attrMatch) {
|
||||||
|
this.log('parseVideoMd5 命中通用 md5 属性', { md5: attrMatch[1] })
|
||||||
|
return attrMatch[1].toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方法4:<md5>...</md5> 标签
|
||||||
|
const md5TagMatch = /<md5>([a-fA-F0-9]+)<\/md5>/i.exec(content)
|
||||||
|
if (md5TagMatch) {
|
||||||
|
this.log('parseVideoMd5 命中 md5 标签', { md5: md5TagMatch[1] })
|
||||||
|
return md5TagMatch[1].toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方法5:兜底取 rawmd5 属性(任意位置)
|
||||||
|
const rawMd5Fallback = /\srawmd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content)
|
||||||
|
if (rawMd5Fallback) {
|
||||||
|
this.log('parseVideoMd5 兜底命中 rawmd5', { rawmd5: rawMd5Fallback[1] })
|
||||||
|
return rawMd5Fallback[1].toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.log('parseVideoMd5 未提取到任何 md5', { contentLength: content.length })
|
||||||
|
} catch (e) {
|
||||||
|
this.log('parseVideoMd5 异常', { error: String(e) })
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const videoService = new VideoService()
|
export const videoService = new VideoService()
|
||||||
|
|||||||
@@ -48,6 +48,38 @@ export class VoiceTranscribeService {
|
|||||||
private recognizer: OfflineRecognizer | null = null
|
private recognizer: OfflineRecognizer | null = null
|
||||||
private isInitializing = false
|
private isInitializing = false
|
||||||
|
|
||||||
|
private buildTranscribeWorkerEnv(): NodeJS.ProcessEnv {
|
||||||
|
const env: NodeJS.ProcessEnv = { ...process.env }
|
||||||
|
const platform = process.platform === 'win32' ? 'win' : process.platform
|
||||||
|
const platformPkg = `sherpa-onnx-${platform}-${process.arch}`
|
||||||
|
const candidates = [
|
||||||
|
join(__dirname, '..', 'node_modules', platformPkg),
|
||||||
|
join(__dirname, 'node_modules', platformPkg),
|
||||||
|
join(process.cwd(), 'node_modules', platformPkg),
|
||||||
|
process.resourcesPath ? join(process.resourcesPath, 'app.asar.unpacked', 'node_modules', platformPkg) : ''
|
||||||
|
].filter((item): item is string => Boolean(item) && existsSync(item))
|
||||||
|
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
const key = 'DYLD_LIBRARY_PATH'
|
||||||
|
const existing = env[key] || ''
|
||||||
|
const merged = [...candidates, ...existing.split(':').filter(Boolean)]
|
||||||
|
env[key] = Array.from(new Set(merged)).join(':')
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`)
|
||||||
|
}
|
||||||
|
} else if (process.platform === 'linux') {
|
||||||
|
const key = 'LD_LIBRARY_PATH'
|
||||||
|
const existing = env[key] || ''
|
||||||
|
const merged = [...candidates, ...existing.split(':').filter(Boolean)]
|
||||||
|
env[key] = Array.from(new Set(merged)).join(':')
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
private resolveModelDir(): string {
|
private resolveModelDir(): string {
|
||||||
const configured = this.configService.get('whisperModelDir') as string | undefined
|
const configured = this.configService.get('whisperModelDir') as string | undefined
|
||||||
if (configured) return configured
|
if (configured) return configured
|
||||||
@@ -206,17 +238,20 @@ export class VoiceTranscribeService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { Worker } = require('worker_threads')
|
const { fork } = require('child_process')
|
||||||
const workerPath = join(__dirname, 'transcribeWorker.js')
|
const workerPath = join(__dirname, 'transcribeWorker.js')
|
||||||
|
|
||||||
const worker = new Worker(workerPath, {
|
const worker = fork(workerPath, [], {
|
||||||
workerData: {
|
env: this.buildTranscribeWorkerEnv(),
|
||||||
modelPath,
|
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
|
||||||
tokensPath,
|
serialization: 'advanced'
|
||||||
wavData,
|
})
|
||||||
sampleRate: 16000,
|
worker.send({
|
||||||
languages: supportedLanguages
|
modelPath,
|
||||||
}
|
tokensPath,
|
||||||
|
wavData,
|
||||||
|
sampleRate: 16000,
|
||||||
|
languages: supportedLanguages
|
||||||
})
|
})
|
||||||
|
|
||||||
let finalTranscript = ''
|
let finalTranscript = ''
|
||||||
@@ -227,17 +262,31 @@ export class VoiceTranscribeService {
|
|||||||
} else if (msg.type === 'final') {
|
} else if (msg.type === 'final') {
|
||||||
finalTranscript = msg.text
|
finalTranscript = msg.text
|
||||||
resolve({ success: true, transcript: finalTranscript })
|
resolve({ success: true, transcript: finalTranscript })
|
||||||
worker.terminate()
|
worker.disconnect()
|
||||||
|
worker.kill()
|
||||||
} else if (msg.type === 'error') {
|
} else if (msg.type === 'error') {
|
||||||
console.error('[VoiceTranscribe] Worker 错误:', msg.error)
|
console.error('[VoiceTranscribe] Worker 错误:', msg.error)
|
||||||
resolve({ success: false, error: msg.error })
|
resolve({ success: false, error: msg.error })
|
||||||
worker.terminate()
|
worker.disconnect()
|
||||||
|
worker.kill()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
worker.on('error', (err: Error) => resolve({ success: false, error: String(err) }))
|
worker.on('error', (err: Error) => resolve({ success: false, error: String(err) }))
|
||||||
worker.on('exit', (code: number) => {
|
worker.on('exit', (code: number | null, signal: string | null) => {
|
||||||
if (code !== 0) resolve({ success: false, error: `Worker exited with code ${code}` })
|
if (code === null || signal === 'SIGSEGV') {
|
||||||
|
|
||||||
|
console.error(`[VoiceTranscribe] Worker 异常崩溃,信号: ${signal}。可能是由于底层 C++ 运行库在当前系统上发生段错误。`);
|
||||||
|
resolve({
|
||||||
|
success: false,
|
||||||
|
error: 'SEGFAULT_ERROR'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code !== 0) {
|
||||||
|
resolve({ success: false, error: `Worker exited with code ${code}` });
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -458,8 +507,18 @@ export class VoiceTranscribeService {
|
|||||||
|
|
||||||
writer.on('error', (err) => {
|
writer.on('error', (err) => {
|
||||||
clearInterval(speedInterval)
|
clearInterval(speedInterval)
|
||||||
|
// 确保在错误情况下也关闭文件句柄
|
||||||
|
writer.destroy()
|
||||||
reject(err)
|
reject(err)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
response.on('error', (err) => {
|
||||||
|
clearInterval(speedInterval)
|
||||||
|
// 确保在响应错误时也关闭文件句柄
|
||||||
|
writer.destroy()
|
||||||
|
reject(err)
|
||||||
|
})
|
||||||
|
|
||||||
response.pipe(writer)
|
response.pipe(writer)
|
||||||
})
|
})
|
||||||
request.on('error', reject)
|
request.on('error', reject)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -136,8 +136,7 @@ export class WcdbService {
|
|||||||
*/
|
*/
|
||||||
setMonitor(callback: (type: string, json: string) => void): void {
|
setMonitor(callback: (type: string, json: string) => void): void {
|
||||||
this.monitorListener = callback;
|
this.monitorListener = callback;
|
||||||
// Notify worker to enable monitor
|
this.callWorker<{ success?: boolean }>('setMonitor').catch(() => { });
|
||||||
this.callWorker('setMonitor').catch(() => { });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -175,10 +174,10 @@ export class WcdbService {
|
|||||||
/**
|
/**
|
||||||
* 关闭服务
|
* 关闭服务
|
||||||
*/
|
*/
|
||||||
shutdown(): void {
|
async shutdown(): Promise<void> {
|
||||||
this.close()
|
try { await this.close() } catch {}
|
||||||
if (this.worker) {
|
if (this.worker) {
|
||||||
this.worker.terminate()
|
try { await this.worker.terminate() } catch {}
|
||||||
this.worker = null
|
this.worker = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -219,6 +218,52 @@ 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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSessionMessageCounts(sessionIds: string[]): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> {
|
||||||
|
return this.callWorker('getSessionMessageCounts', { sessionIds })
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSessionMessageTypeStats(
|
||||||
|
sessionId: string,
|
||||||
|
beginTimestamp: number = 0,
|
||||||
|
endTimestamp: number = 0
|
||||||
|
): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||||
|
return this.callWorker('getSessionMessageTypeStats', { sessionId, beginTimestamp, endTimestamp })
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSessionMessageTypeStatsBatch(
|
||||||
|
sessionIds: string[],
|
||||||
|
options?: {
|
||||||
|
beginTimestamp?: number
|
||||||
|
endTimestamp?: number
|
||||||
|
quickMode?: boolean
|
||||||
|
includeGroupSenderCount?: boolean
|
||||||
|
}
|
||||||
|
): Promise<{ success: boolean; data?: Record<string, any>; error?: string }> {
|
||||||
|
return this.callWorker('getSessionMessageTypeStatsBatch', { sessionIds, options })
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSessionMessageDateCounts(sessionId: string): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> {
|
||||||
|
return this.callWorker('getSessionMessageDateCounts', { sessionId })
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSessionMessageDateCountsBatch(sessionIds: string[]): Promise<{ success: boolean; data?: Record<string, Record<string, number>>; error?: string }> {
|
||||||
|
return this.callWorker('getSessionMessageDateCountsBatch', { sessionIds })
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMessagesByType(
|
||||||
|
sessionId: string,
|
||||||
|
localType: number,
|
||||||
|
ascending = false,
|
||||||
|
limit = 0,
|
||||||
|
offset = 0
|
||||||
|
): Promise<{ success: boolean; rows?: any[]; error?: string }> {
|
||||||
|
return this.callWorker('getMessagesByType', { sessionId, localType, ascending, limit, offset })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取联系人昵称
|
* 获取联系人昵称
|
||||||
*/
|
*/
|
||||||
@@ -284,6 +329,14 @@ export class WcdbService {
|
|||||||
return this.callWorker('getMessageMeta', { dbPath, tableName, limit, offset })
|
return this.callWorker('getMessageMeta', { dbPath, tableName, limit, offset })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getMessageTableColumns(dbPath: string, tableName: string): Promise<{ success: boolean; columns?: string[]; error?: string }> {
|
||||||
|
return this.callWorker('getMessageTableColumns', { dbPath, tableName })
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMessageTableTimeRange(dbPath: string, tableName: string): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||||
|
return this.callWorker('getMessageTableTimeRange', { dbPath, tableName })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取联系人详情
|
* 获取联系人详情
|
||||||
*/
|
*/
|
||||||
@@ -291,6 +344,33 @@ 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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
async getContactTypeCounts(): Promise<{ success: boolean; counts?: { private: number; group: number; official: number; former_friend: number }; error?: string }> {
|
||||||
|
return this.callWorker('getContactTypeCounts')
|
||||||
|
}
|
||||||
|
|
||||||
|
async getContactsCompact(usernames: string[] = []): Promise<{ success: boolean; contacts?: any[]; error?: string }> {
|
||||||
|
return this.callWorker('getContactsCompact', { usernames })
|
||||||
|
}
|
||||||
|
|
||||||
|
async getContactAliasMap(usernames: string[]): Promise<{ success: boolean; map?: Record<string, string>; error?: string }> {
|
||||||
|
return this.callWorker('getContactAliasMap', { usernames })
|
||||||
|
}
|
||||||
|
|
||||||
|
async getContactFriendFlags(usernames: string[]): Promise<{ success: boolean; map?: Record<string, boolean>; error?: string }> {
|
||||||
|
return this.callWorker('getContactFriendFlags', { usernames })
|
||||||
|
}
|
||||||
|
|
||||||
|
async getChatRoomExtBuffer(chatroomId: string): Promise<{ success: boolean; extBuffer?: string; error?: string }> {
|
||||||
|
return this.callWorker('getChatRoomExtBuffer', { chatroomId })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取聚合统计数据
|
* 获取聚合统计数据
|
||||||
*/
|
*/
|
||||||
@@ -362,10 +442,10 @@ export class WcdbService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 执行 SQL 查询
|
* 执行 SQL 查询(仅主进程内部使用:fallback/diagnostic/低频兼容)
|
||||||
*/
|
*/
|
||||||
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 })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -375,6 +455,20 @@ export class WcdbService {
|
|||||||
return this.callWorker('getEmoticonCdnUrl', { dbPath, md5 })
|
return this.callWorker('getEmoticonCdnUrl', { dbPath, md5 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取表情包释义
|
||||||
|
*/
|
||||||
|
async getEmoticonCaption(dbPath: string, md5: string): Promise<{ success: boolean; caption?: string; error?: string }> {
|
||||||
|
return this.callWorker('getEmoticonCaption', { dbPath, md5 })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取表情包释义(严格 DLL 接口)
|
||||||
|
*/
|
||||||
|
async getEmoticonCaptionStrict(md5: string): Promise<{ success: boolean; caption?: string; error?: string }> {
|
||||||
|
return this.callWorker('getEmoticonCaptionStrict', { md5 })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 列出消息数据库
|
* 列出消息数据库
|
||||||
*/
|
*/
|
||||||
@@ -396,6 +490,10 @@ export class WcdbService {
|
|||||||
return this.callWorker('getMessageById', { sessionId, localId })
|
return this.callWorker('getMessageById', { sessionId, localId })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async searchMessages(keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number): Promise<{ success: boolean; messages?: any[]; error?: string }> {
|
||||||
|
return this.callWorker('searchMessages', { keyword, sessionId, limit, offset, beginTimestamp, endTimestamp })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取语音数据
|
* 获取语音数据
|
||||||
*/
|
*/
|
||||||
@@ -403,6 +501,40 @@ export class WcdbService {
|
|||||||
return this.callWorker('getVoiceData', { sessionId, createTime, candidates, localId, svrId })
|
return this.callWorker('getVoiceData', { sessionId, createTime, candidates, localId, svrId })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getVoiceDataBatch(
|
||||||
|
requests: Array<{ session_id: string; create_time: number; local_id?: number; svr_id?: string | number; candidates?: string[] }>
|
||||||
|
): Promise<{ success: boolean; rows?: Array<{ index: number; hex?: string }>; error?: string }> {
|
||||||
|
return this.callWorker('getVoiceDataBatch', { requests })
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMediaSchemaSummary(dbPath: string): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||||
|
return this.callWorker('getMediaSchemaSummary', { dbPath })
|
||||||
|
}
|
||||||
|
|
||||||
|
async getHeadImageBuffers(usernames: string[]): Promise<{ success: boolean; map?: Record<string, string>; error?: string }> {
|
||||||
|
return this.callWorker('getHeadImageBuffers', { usernames })
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveImageHardlink(md5: string, accountDir?: string): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||||
|
return this.callWorker('resolveImageHardlink', { md5, accountDir })
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveImageHardlinkBatch(
|
||||||
|
requests: Array<{ md5: string; accountDir?: string }>
|
||||||
|
): Promise<{ success: boolean; rows?: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }>; error?: string }> {
|
||||||
|
return this.callWorker('resolveImageHardlinkBatch', { requests })
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveVideoHardlinkMd5(md5: string, dbPath?: string): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||||
|
return this.callWorker('resolveVideoHardlinkMd5', { md5, dbPath })
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveVideoHardlinkMd5Batch(
|
||||||
|
requests: Array<{ md5: string; dbPath?: string }>
|
||||||
|
): Promise<{ success: boolean; rows?: Array<{ index: number; md5: string; success: boolean; data?: any; error?: string }>; error?: string }> {
|
||||||
|
return this.callWorker('resolveVideoHardlinkMd5Batch', { requests })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取朋友圈
|
* 获取朋友圈
|
||||||
*/
|
*/
|
||||||
@@ -417,6 +549,42 @@ export class WcdbService {
|
|||||||
return this.callWorker('getSnsAnnualStats', { beginTimestamp, endTimestamp })
|
return this.callWorker('getSnsAnnualStats', { beginTimestamp, endTimestamp })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getSnsUsernames(): Promise<{ success: boolean; usernames?: string[]; error?: string }> {
|
||||||
|
return this.callWorker('getSnsUsernames')
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSnsExportStats(myWxid?: string): Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }> {
|
||||||
|
return this.callWorker('getSnsExportStats', { myWxid })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安装朋友圈删除拦截
|
||||||
|
*/
|
||||||
|
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 内部日志
|
* 获取 DLL 内部日志
|
||||||
*/
|
*/
|
||||||
@@ -445,6 +613,27 @@ export class WcdbService {
|
|||||||
return this.callWorker('deleteMessage', { sessionId, localId, createTime, dbPathHint })
|
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', {})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,56 @@
|
|||||||
import { parentPort, workerData } from 'worker_threads'
|
import { parentPort, workerData } from 'worker_threads'
|
||||||
|
import { existsSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
interface WorkerParams {
|
interface WorkerParams {
|
||||||
modelPath: string
|
modelPath: string
|
||||||
tokensPath: string
|
tokensPath: string
|
||||||
wavData: Buffer
|
wavData: Buffer | Uint8Array | { type: 'Buffer'; data: number[] }
|
||||||
sampleRate: number
|
sampleRate: number
|
||||||
languages?: string[]
|
languages?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function appendLibrarySearchPath(libDir: string): void {
|
||||||
|
if (!existsSync(libDir)) return
|
||||||
|
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
const current = process.env.DYLD_LIBRARY_PATH || ''
|
||||||
|
const paths = current.split(':').filter(Boolean)
|
||||||
|
if (!paths.includes(libDir)) {
|
||||||
|
process.env.DYLD_LIBRARY_PATH = [libDir, ...paths].join(':')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform === 'linux') {
|
||||||
|
const current = process.env.LD_LIBRARY_PATH || ''
|
||||||
|
const paths = current.split(':').filter(Boolean)
|
||||||
|
if (!paths.includes(libDir)) {
|
||||||
|
process.env.LD_LIBRARY_PATH = [libDir, ...paths].join(':')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareSherpaRuntimeEnv(): void {
|
||||||
|
const platform = process.platform === 'win32' ? 'win' : process.platform
|
||||||
|
const platformPkg = `sherpa-onnx-${platform}-${process.arch}`
|
||||||
|
const resourcesPath = (process as any).resourcesPath as string | undefined
|
||||||
|
|
||||||
|
const candidates = [
|
||||||
|
// Dev: /project/dist-electron -> /project/node_modules/...
|
||||||
|
join(__dirname, '..', 'node_modules', platformPkg),
|
||||||
|
// Fallback for alternate layouts
|
||||||
|
join(__dirname, 'node_modules', platformPkg),
|
||||||
|
join(process.cwd(), 'node_modules', platformPkg),
|
||||||
|
// Packaged app: Resources/app.asar.unpacked/node_modules/...
|
||||||
|
resourcesPath ? join(resourcesPath, 'app.asar.unpacked', 'node_modules', platformPkg) : ''
|
||||||
|
].filter(Boolean)
|
||||||
|
|
||||||
|
for (const dir of candidates) {
|
||||||
|
appendLibrarySearchPath(dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 语言标记映射
|
// 语言标记映射
|
||||||
const LANGUAGE_TAGS: Record<string, string> = {
|
const LANGUAGE_TAGS: Record<string, string> = {
|
||||||
'zh': '<|zh|>',
|
'zh': '<|zh|>',
|
||||||
@@ -95,22 +138,60 @@ function isLanguageAllowed(result: any, allowedLanguages: string[]): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
if (!parentPort) {
|
const isForkProcess = !parentPort
|
||||||
return;
|
const emit = (msg: any) => {
|
||||||
|
if (parentPort) {
|
||||||
|
parentPort.postMessage(msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (typeof process.send === 'function') {
|
||||||
|
process.send(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeBuffer = (data: WorkerParams['wavData']): Buffer => {
|
||||||
|
if (Buffer.isBuffer(data)) return data
|
||||||
|
if (data instanceof Uint8Array) return Buffer.from(data)
|
||||||
|
if (data && typeof data === 'object' && (data as any).type === 'Buffer' && Array.isArray((data as any).data)) {
|
||||||
|
return Buffer.from((data as any).data)
|
||||||
|
}
|
||||||
|
return Buffer.alloc(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const readParams = async (): Promise<WorkerParams | null> => {
|
||||||
|
if (parentPort) {
|
||||||
|
return workerData as WorkerParams
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let settled = false
|
||||||
|
const finish = (value: WorkerParams | null) => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
resolve(value)
|
||||||
|
}
|
||||||
|
process.once('message', (msg) => finish(msg as WorkerParams))
|
||||||
|
process.once('disconnect', () => finish(null))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
prepareSherpaRuntimeEnv()
|
||||||
|
const params = await readParams()
|
||||||
|
if (!params) return
|
||||||
|
|
||||||
// 动态加载以捕获可能的加载错误(如 C++ 运行库缺失等)
|
// 动态加载以捕获可能的加载错误(如 C++ 运行库缺失等)
|
||||||
let sherpa: any;
|
let sherpa: any;
|
||||||
try {
|
try {
|
||||||
sherpa = require('sherpa-onnx-node');
|
sherpa = require('sherpa-onnx-node');
|
||||||
} catch (requireError) {
|
} catch (requireError) {
|
||||||
parentPort.postMessage({ type: 'error', error: 'Failed to load speech engine: ' + String(requireError) });
|
emit({ type: 'error', error: 'Failed to load speech engine: ' + String(requireError) });
|
||||||
|
if (isForkProcess) process.exit(1)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { modelPath, tokensPath, wavData: rawWavData, sampleRate, languages } = workerData as WorkerParams
|
const { modelPath, tokensPath, wavData: rawWavData, sampleRate, languages } = params
|
||||||
const wavData = Buffer.from(rawWavData);
|
const wavData = normalizeBuffer(rawWavData);
|
||||||
// 确保有有效的语言列表,默认只允许中文
|
// 确保有有效的语言列表,默认只允许中文
|
||||||
let allowedLanguages = languages || ['zh']
|
let allowedLanguages = languages || ['zh']
|
||||||
if (allowedLanguages.length === 0) {
|
if (allowedLanguages.length === 0) {
|
||||||
@@ -151,16 +232,18 @@ async function run() {
|
|||||||
if (isLanguageAllowed(result, allowedLanguages)) {
|
if (isLanguageAllowed(result, allowedLanguages)) {
|
||||||
const processedText = richTranscribePostProcess(result.text)
|
const processedText = richTranscribePostProcess(result.text)
|
||||||
|
|
||||||
parentPort.postMessage({ type: 'final', text: processedText })
|
emit({ type: 'final', text: processedText })
|
||||||
|
if (isForkProcess) process.exit(0)
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
parentPort.postMessage({ type: 'final', text: '' })
|
emit({ type: 'final', text: '' })
|
||||||
|
if (isForkProcess) process.exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
parentPort.postMessage({ type: 'error', error: String(error) })
|
emit({ type: 'error', error: String(error) })
|
||||||
|
if (isForkProcess) process.exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
run();
|
run();
|
||||||
|
|
||||||
|
|||||||
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,15 +20,17 @@ if (parentPort) {
|
|||||||
result = { success: true }
|
result = { success: true }
|
||||||
break
|
break
|
||||||
case 'setMonitor':
|
case 'setMonitor':
|
||||||
core.setMonitor((type, json) => {
|
{
|
||||||
|
const monitorOk = core.setMonitor((type, json) => {
|
||||||
parentPort!.postMessage({
|
parentPort!.postMessage({
|
||||||
id: -1,
|
id: -1,
|
||||||
type: 'monitor',
|
type: 'monitor',
|
||||||
payload: { type, json }
|
payload: { type, json }
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
result = { success: true }
|
result = { success: monitorOk }
|
||||||
break
|
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
|
||||||
@@ -54,6 +56,27 @@ if (parentPort) {
|
|||||||
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 'getSessionMessageCounts':
|
||||||
|
result = await core.getSessionMessageCounts(payload.sessionIds)
|
||||||
|
break
|
||||||
|
case 'getSessionMessageTypeStats':
|
||||||
|
result = await core.getSessionMessageTypeStats(payload.sessionId, payload.beginTimestamp, payload.endTimestamp)
|
||||||
|
break
|
||||||
|
case 'getSessionMessageTypeStatsBatch':
|
||||||
|
result = await core.getSessionMessageTypeStatsBatch(payload.sessionIds, payload.options)
|
||||||
|
break
|
||||||
|
case 'getSessionMessageDateCounts':
|
||||||
|
result = await core.getSessionMessageDateCounts(payload.sessionId)
|
||||||
|
break
|
||||||
|
case 'getSessionMessageDateCountsBatch':
|
||||||
|
result = await core.getSessionMessageDateCountsBatch(payload.sessionIds)
|
||||||
|
break
|
||||||
|
case 'getMessagesByType':
|
||||||
|
result = await core.getMessagesByType(payload.sessionId, payload.localType, payload.ascending, payload.limit, payload.offset)
|
||||||
|
break
|
||||||
case 'getDisplayNames':
|
case 'getDisplayNames':
|
||||||
result = await core.getDisplayNames(payload.usernames)
|
result = await core.getDisplayNames(payload.usernames)
|
||||||
break
|
break
|
||||||
@@ -84,9 +107,33 @@ if (parentPort) {
|
|||||||
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 'getMessageTableColumns':
|
||||||
|
result = await core.getMessageTableColumns(payload.dbPath, payload.tableName)
|
||||||
|
break
|
||||||
|
case 'getMessageTableTimeRange':
|
||||||
|
result = await core.getMessageTableTimeRange(payload.dbPath, payload.tableName)
|
||||||
|
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 'getContactTypeCounts':
|
||||||
|
result = await core.getContactTypeCounts()
|
||||||
|
break
|
||||||
|
case 'getContactsCompact':
|
||||||
|
result = await core.getContactsCompact(payload.usernames)
|
||||||
|
break
|
||||||
|
case 'getContactAliasMap':
|
||||||
|
result = await core.getContactAliasMap(payload.usernames)
|
||||||
|
break
|
||||||
|
case 'getContactFriendFlags':
|
||||||
|
result = await core.getContactFriendFlags(payload.usernames)
|
||||||
|
break
|
||||||
|
case 'getChatRoomExtBuffer':
|
||||||
|
result = await core.getChatRoomExtBuffer(payload.chatroomId)
|
||||||
|
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
|
||||||
@@ -118,11 +165,17 @@ 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)
|
||||||
break
|
break
|
||||||
|
case 'getEmoticonCaption':
|
||||||
|
result = await core.getEmoticonCaption(payload.dbPath, payload.md5)
|
||||||
|
break
|
||||||
|
case 'getEmoticonCaptionStrict':
|
||||||
|
result = await core.getEmoticonCaptionStrict(payload.md5)
|
||||||
|
break
|
||||||
case 'listMessageDbs':
|
case 'listMessageDbs':
|
||||||
result = await core.listMessageDbs()
|
result = await core.listMessageDbs()
|
||||||
break
|
break
|
||||||
@@ -132,18 +185,60 @@ if (parentPort) {
|
|||||||
case 'getMessageById':
|
case 'getMessageById':
|
||||||
result = await core.getMessageById(payload.sessionId, payload.localId)
|
result = await core.getMessageById(payload.sessionId, payload.localId)
|
||||||
break
|
break
|
||||||
|
case 'searchMessages':
|
||||||
|
result = await core.searchMessages(payload.keyword, payload.sessionId, payload.limit, payload.offset, payload.beginTimestamp, payload.endTimestamp)
|
||||||
|
break
|
||||||
case 'getVoiceData':
|
case 'getVoiceData':
|
||||||
result = await core.getVoiceData(payload.sessionId, payload.createTime, payload.candidates, payload.localId, payload.svrId)
|
result = await core.getVoiceData(payload.sessionId, payload.createTime, payload.candidates, payload.localId, payload.svrId)
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
console.error('[wcdbWorker] getVoiceData failed:', result.error)
|
console.error('[wcdbWorker] getVoiceData failed:', result.error)
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
|
case 'getVoiceDataBatch':
|
||||||
|
result = await core.getVoiceDataBatch(payload.requests)
|
||||||
|
break
|
||||||
|
case 'getMediaSchemaSummary':
|
||||||
|
result = await core.getMediaSchemaSummary(payload.dbPath)
|
||||||
|
break
|
||||||
|
case 'getHeadImageBuffers':
|
||||||
|
result = await core.getHeadImageBuffers(payload.usernames)
|
||||||
|
break
|
||||||
|
case 'resolveImageHardlink':
|
||||||
|
result = await core.resolveImageHardlink(payload.md5, payload.accountDir)
|
||||||
|
break
|
||||||
|
case 'resolveImageHardlinkBatch':
|
||||||
|
result = await core.resolveImageHardlinkBatch(payload.requests)
|
||||||
|
break
|
||||||
|
case 'resolveVideoHardlinkMd5':
|
||||||
|
result = await core.resolveVideoHardlinkMd5(payload.md5, payload.dbPath)
|
||||||
|
break
|
||||||
|
case 'resolveVideoHardlinkMd5Batch':
|
||||||
|
result = await core.resolveVideoHardlinkMd5Batch(payload.requests)
|
||||||
|
break
|
||||||
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':
|
case 'getSnsAnnualStats':
|
||||||
result = await core.getSnsAnnualStats(payload.beginTimestamp, payload.endTimestamp)
|
result = await core.getSnsAnnualStats(payload.beginTimestamp, payload.endTimestamp)
|
||||||
break
|
break
|
||||||
|
case 'getSnsUsernames':
|
||||||
|
result = await core.getSnsUsernames()
|
||||||
|
break
|
||||||
|
case 'getSnsExportStats':
|
||||||
|
result = await core.getSnsExportStats(payload.myWxid)
|
||||||
|
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':
|
case 'getLogs':
|
||||||
result = await core.getLogs()
|
result = await core.getLogs()
|
||||||
break
|
break
|
||||||
@@ -156,7 +251,15 @@ if (parentPort) {
|
|||||||
case 'deleteMessage':
|
case 'deleteMessage':
|
||||||
result = await core.deleteMessage(payload.sessionId, payload.localId, payload.createTime, payload.dbPathHint)
|
result = await core.deleteMessage(payload.sessionId, payload.localId, payload.createTime, payload.dbPathHint)
|
||||||
break
|
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}` }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,28 @@ import { ConfigService } from '../services/config'
|
|||||||
let notificationWindow: BrowserWindow | null = null
|
let notificationWindow: BrowserWindow | null = null
|
||||||
let closeTimer: NodeJS.Timeout | null = null
|
let closeTimer: NodeJS.Timeout | null = null
|
||||||
|
|
||||||
|
export function destroyNotificationWindow() {
|
||||||
|
if (closeTimer) {
|
||||||
|
clearTimeout(closeTimer)
|
||||||
|
closeTimer = null
|
||||||
|
}
|
||||||
|
lastNotificationData = null
|
||||||
|
|
||||||
|
if (!notificationWindow || notificationWindow.isDestroyed()) {
|
||||||
|
notificationWindow = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const win = notificationWindow
|
||||||
|
notificationWindow = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
win.destroy()
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[NotificationWindow] Failed to destroy window:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function createNotificationWindow() {
|
export function createNotificationWindow() {
|
||||||
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
||||||
return notificationWindow
|
return notificationWindow
|
||||||
@@ -110,7 +132,7 @@ async function showAndSend(win: BrowserWindow, data: any) {
|
|||||||
|
|
||||||
// 更新位置
|
// 更新位置
|
||||||
const { width: screenWidth, height: screenHeight } = screen.getPrimaryDisplay().workAreaSize
|
const { width: screenWidth, height: screenHeight } = screen.getPrimaryDisplay().workAreaSize
|
||||||
const winWidth = 344
|
const winWidth = position === 'top-center' ? 280 : 344
|
||||||
const winHeight = 114
|
const winHeight = 114
|
||||||
const padding = 20
|
const padding = 20
|
||||||
|
|
||||||
@@ -118,6 +140,10 @@ async function showAndSend(win: BrowserWindow, data: any) {
|
|||||||
let y = 0
|
let y = 0
|
||||||
|
|
||||||
switch (position) {
|
switch (position) {
|
||||||
|
case 'top-center':
|
||||||
|
x = (screenWidth - winWidth) / 2
|
||||||
|
y = padding
|
||||||
|
break
|
||||||
case 'top-right':
|
case 'top-right':
|
||||||
x = screenWidth - winWidth - padding
|
x = screenWidth - winWidth - padding
|
||||||
y = padding
|
y = padding
|
||||||
@@ -144,7 +170,7 @@ async function showAndSend(win: BrowserWindow, data: any) {
|
|||||||
win.showInactive() // 显示但不聚焦
|
win.showInactive() // 显示但不聚焦
|
||||||
win.setAlwaysOnTop(true, 'screen-saver') // 最高层级
|
win.setAlwaysOnTop(true, 'screen-saver') // 最高层级
|
||||||
|
|
||||||
win.webContents.send('notification:show', data)
|
win.webContents.send('notification:show', { ...data, position })
|
||||||
|
|
||||||
// 自动关闭计时器通常由渲染进程管理
|
// 自动关闭计时器通常由渲染进程管理
|
||||||
// 渲染进程发送 'notification:close' 来隐藏窗口
|
// 渲染进程发送 'notification:close' 来隐藏窗口
|
||||||
|
|||||||
2152
package-lock.json
generated
2152
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
44
package.json
44
package.json
@@ -3,7 +3,10 @@
|
|||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"description": "WeFlow",
|
"description": "WeFlow",
|
||||||
"main": "dist-electron/main.js",
|
"main": "dist-electron/main.js",
|
||||||
"author": "cc",
|
"author": {
|
||||||
|
"name": "cc",
|
||||||
|
"email": "yccccccy@proton.me"
|
||||||
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/hicccc77/WeFlow"
|
"url": "https://github.com/hicccc77/WeFlow"
|
||||||
@@ -13,13 +16,13 @@
|
|||||||
"postinstall": "electron-builder install-app-deps",
|
"postinstall": "electron-builder install-app-deps",
|
||||||
"rebuild": "electron-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",
|
||||||
"electron:build": "npm run build"
|
"electron:build": "npm run build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^12.5.0",
|
|
||||||
"echarts": "^5.5.1",
|
"echarts": "^5.5.1",
|
||||||
"echarts-for-react": "^3.0.2",
|
"echarts-for-react": "^3.0.2",
|
||||||
"electron-store": "^10.0.0",
|
"electron-store": "^10.0.0",
|
||||||
@@ -32,7 +35,6 @@
|
|||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"koffi": "^2.9.0",
|
"koffi": "^2.9.0",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"node-llama-cpp": "^3.15.1",
|
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
@@ -41,12 +43,12 @@
|
|||||||
"remark-gfm": "^4.0.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",
|
||||||
|
"sudo-prompt": "^9.2.1",
|
||||||
"wechat-emojis": "^1.0.2",
|
"wechat-emojis": "^1.0.2",
|
||||||
"zustand": "^5.0.2"
|
"zustand": "^5.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@electron/rebuild": "^4.0.2",
|
"@electron/rebuild": "^4.0.2",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
|
||||||
"@types/react": "^19.1.0",
|
"@types/react": "^19.1.0",
|
||||||
"@types/react-dom": "^19.1.0",
|
"@types/react-dom": "^19.1.0",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
@@ -61,6 +63,8 @@
|
|||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"appId": "com.WeFlow.app",
|
"appId": "com.WeFlow.app",
|
||||||
|
"afterPack": "scripts/afterPack-sign-manifest.cjs",
|
||||||
|
"afterSign": "scripts/afterPack-sign-manifest.cjs",
|
||||||
"publish": {
|
"publish": {
|
||||||
"provider": "github",
|
"provider": "github",
|
||||||
"owner": "hicccc77",
|
"owner": "hicccc77",
|
||||||
@@ -72,12 +76,35 @@
|
|||||||
"directories": {
|
"directories": {
|
||||||
"output": "release"
|
"output": "release"
|
||||||
},
|
},
|
||||||
|
"mac": {
|
||||||
|
"target": [
|
||||||
|
"dmg",
|
||||||
|
"zip"
|
||||||
|
],
|
||||||
|
"category": "public.app-category.utilities",
|
||||||
|
"hardenedRuntime": false,
|
||||||
|
"gatekeeperAssess": false,
|
||||||
|
"entitlements": "electron/entitlements.mac.plist",
|
||||||
|
"entitlementsInherit": "electron/entitlements.mac.plist",
|
||||||
|
"icon": "resources/icon.icns"
|
||||||
|
},
|
||||||
"win": {
|
"win": {
|
||||||
"target": [
|
"target": [
|
||||||
"nsis"
|
"nsis"
|
||||||
],
|
],
|
||||||
"icon": "public/icon.ico"
|
"icon": "public/icon.ico"
|
||||||
},
|
},
|
||||||
|
"linux": {
|
||||||
|
"icon": "public/icon.png",
|
||||||
|
"target": [
|
||||||
|
"appimage",
|
||||||
|
"deb",
|
||||||
|
"tar.gz"
|
||||||
|
],
|
||||||
|
"category": "Utility",
|
||||||
|
"executableName": "weflow",
|
||||||
|
"synopsis": "WeFlow for Linux"
|
||||||
|
},
|
||||||
"nsis": {
|
"nsis": {
|
||||||
"oneClick": false,
|
"oneClick": false,
|
||||||
"differentialPackage": false,
|
"differentialPackage": false,
|
||||||
@@ -108,6 +135,10 @@
|
|||||||
"from": "public/icon.ico",
|
"from": "public/icon.ico",
|
||||||
"to": "icon.ico"
|
"to": "icon.ico"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"from": "public/icon.png",
|
||||||
|
"to": "icon.png"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"from": "electron/assets/wasm/",
|
"from": "electron/assets/wasm/",
|
||||||
"to": "assets/wasm/"
|
"to": "assets/wasm/"
|
||||||
@@ -120,6 +151,8 @@
|
|||||||
"asarUnpack": [
|
"asarUnpack": [
|
||||||
"node_modules/silk-wasm/**/*",
|
"node_modules/silk-wasm/**/*",
|
||||||
"node_modules/sherpa-onnx-node/**/*",
|
"node_modules/sherpa-onnx-node/**/*",
|
||||||
|
"node_modules/sherpa-onnx-*/*",
|
||||||
|
"node_modules/sherpa-onnx-*/**/*",
|
||||||
"node_modules/ffmpeg-static/**/*"
|
"node_modules/ffmpeg-static/**/*"
|
||||||
],
|
],
|
||||||
"extraFiles": [
|
"extraFiles": [
|
||||||
@@ -139,6 +172,7 @@
|
|||||||
"from": "resources/vcruntime140_1.dll",
|
"from": "resources/vcruntime140_1.dll",
|
||||||
"to": "."
|
"to": "."
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"icon": "resources/icon.icns"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
public/icon.png
Normal file
BIN
public/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
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>
|
||||||
BIN
resources/arm64/WCDB.dll
Normal file
BIN
resources/arm64/WCDB.dll
Normal file
Binary file not shown.
BIN
resources/arm64/wcdb_api.dll
Normal file
BIN
resources/arm64/wcdb_api.dll
Normal file
Binary file not shown.
BIN
resources/icon.icns
Normal file
BIN
resources/icon.icns
Normal file
Binary file not shown.
10
resources/image_scan_entitlements.plist
Normal file
10
resources/image_scan_entitlements.plist
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.cs.debugger</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
BIN
resources/image_scan_helper
Executable file
BIN
resources/image_scan_helper
Executable file
Binary file not shown.
77
resources/image_scan_helper.c
Normal file
77
resources/image_scan_helper.c
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
/*
|
||||||
|
* image_scan_helper - 轻量包装程序
|
||||||
|
* 加载 libwx_key.dylib 并调用 ScanMemoryForImageKey
|
||||||
|
* 用法: image_scan_helper <pid> <ciphertext_hex>
|
||||||
|
* 输出: JSON {"success":true,"aesKey":"..."} 或 {"success":false,"error":"..."}
|
||||||
|
*/
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <dlfcn.h>
|
||||||
|
#include <libgen.h>
|
||||||
|
#include <mach-o/dyld.h>
|
||||||
|
|
||||||
|
typedef const char* (*ScanMemoryForImageKeyFn)(int pid, const char* ciphertext);
|
||||||
|
typedef void (*FreeStringFn)(const char* str);
|
||||||
|
|
||||||
|
int main(int argc, char* argv[]) {
|
||||||
|
if (argc != 3) {
|
||||||
|
fprintf(stderr, "Usage: %s <pid> <ciphertext_hex>\n", argv[0]);
|
||||||
|
printf("{\"success\":false,\"error\":\"invalid arguments\"}\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
int pid = atoi(argv[1]);
|
||||||
|
const char* ciphertext_hex = argv[2];
|
||||||
|
|
||||||
|
if (pid <= 0) {
|
||||||
|
printf("{\"success\":false,\"error\":\"invalid pid\"}\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 定位 dylib: 与自身同目录下的 libwx_key.dylib */
|
||||||
|
char exe_path[4096];
|
||||||
|
uint32_t size = sizeof(exe_path);
|
||||||
|
if (_NSGetExecutablePath(exe_path, &size) != 0) {
|
||||||
|
printf("{\"success\":false,\"error\":\"cannot get executable path\"}\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
char* dir = dirname(exe_path);
|
||||||
|
char dylib_path[4096];
|
||||||
|
snprintf(dylib_path, sizeof(dylib_path), "%s/libwx_key.dylib", dir);
|
||||||
|
|
||||||
|
void* handle = dlopen(dylib_path, RTLD_LAZY);
|
||||||
|
if (!handle) {
|
||||||
|
printf("{\"success\":false,\"error\":\"dlopen failed: %s\"}\n", dlerror());
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
ScanMemoryForImageKeyFn scan_fn = (ScanMemoryForImageKeyFn)dlsym(handle, "ScanMemoryForImageKey");
|
||||||
|
if (!scan_fn) {
|
||||||
|
printf("{\"success\":false,\"error\":\"symbol not found: ScanMemoryForImageKey\"}\n");
|
||||||
|
dlclose(handle);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
FreeStringFn free_fn = (FreeStringFn)dlsym(handle, "FreeString");
|
||||||
|
|
||||||
|
fprintf(stderr, "[image_scan_helper] calling ScanMemoryForImageKey(pid=%d, ciphertext=%s)\n", pid, ciphertext_hex);
|
||||||
|
|
||||||
|
const char* result = scan_fn(pid, ciphertext_hex);
|
||||||
|
|
||||||
|
if (result && strlen(result) > 0) {
|
||||||
|
/* 检查是否是错误 */
|
||||||
|
if (strncmp(result, "ERROR", 5) == 0) {
|
||||||
|
printf("{\"success\":false,\"error\":\"%s\"}\n", result);
|
||||||
|
} else {
|
||||||
|
printf("{\"success\":true,\"aesKey\":\"%s\"}\n", result);
|
||||||
|
}
|
||||||
|
if (free_fn) free_fn(result);
|
||||||
|
} else {
|
||||||
|
printf("{\"success\":false,\"error\":\"no key found\"}\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
dlclose(handle);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
BIN
resources/libwcdb_api.dylib
Executable file
BIN
resources/libwcdb_api.dylib
Executable file
Binary file not shown.
BIN
resources/libwx_key.dylib
Executable file
BIN
resources/libwx_key.dylib
Executable file
Binary file not shown.
BIN
resources/linux/libwcdb_api.so
Executable file
BIN
resources/linux/libwcdb_api.so
Executable file
Binary file not shown.
BIN
resources/macos/libWCDB.dylib
Executable file
BIN
resources/macos/libWCDB.dylib
Executable file
Binary file not shown.
BIN
resources/macos/libwcdb_api.dylib
Executable file
BIN
resources/macos/libwcdb_api.dylib
Executable file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
resources/xkey_helper
Executable file
BIN
resources/xkey_helper
Executable file
Binary file not shown.
BIN
resources/xkey_helper_linux
Executable file
BIN
resources/xkey_helper_linux
Executable file
Binary file not shown.
289
scripts/afterPack-sign-manifest.cjs
Normal file
289
scripts/afterPack-sign-manifest.cjs
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/* eslint-disable no-console */
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const crypto = require('node:crypto');
|
||||||
|
|
||||||
|
const MANIFEST_NAME = '.wf_manifest.json';
|
||||||
|
const SIGNATURE_NAME = '.wf_manifest.sig';
|
||||||
|
const MODULE_FILENAME = {
|
||||||
|
win32: 'wcdb_api.dll',
|
||||||
|
darwin: 'libwcdb_api.dylib',
|
||||||
|
linux: 'libwcdb_api.so',
|
||||||
|
};
|
||||||
|
|
||||||
|
function readTextIfExists(filePath) {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(filePath)) return null;
|
||||||
|
return fs.readFileSync(filePath, 'utf8');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadEnvFile(projectDir, fileName) {
|
||||||
|
const envPath = path.join(projectDir, fileName);
|
||||||
|
const content = readTextIfExists(envPath);
|
||||||
|
if (!content) return false;
|
||||||
|
|
||||||
|
for (const line of content.split(/\r?\n/)) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||||
|
const eq = trimmed.indexOf('=');
|
||||||
|
if (eq <= 0) continue;
|
||||||
|
const key = trimmed.slice(0, eq).trim();
|
||||||
|
let value = trimmed.slice(eq + 1).trim();
|
||||||
|
if (!key || process.env[key] !== undefined) continue;
|
||||||
|
if (
|
||||||
|
(value.startsWith('"') && value.endsWith('"')) ||
|
||||||
|
(value.startsWith("'") && value.endsWith("'"))
|
||||||
|
) {
|
||||||
|
value = value.slice(1, -1);
|
||||||
|
}
|
||||||
|
process.env[key] = value;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureSigningEnv() {
|
||||||
|
const projectDir = process.cwd();
|
||||||
|
if (!process.env.WF_SIGN_PRIVATE_KEY) {
|
||||||
|
loadEnvFile(projectDir, '.env.local');
|
||||||
|
loadEnvFile(projectDir, '.env');
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyB64 = (process.env.WF_SIGN_PRIVATE_KEY || '').trim();
|
||||||
|
const required = (process.env.WF_SIGNING_REQUIRED || '').trim() === '1';
|
||||||
|
if (!keyB64) {
|
||||||
|
if (required) {
|
||||||
|
throw new Error(
|
||||||
|
'WF_SIGN_PRIVATE_KEY is missing (WF_SIGNING_REQUIRED=1). ' +
|
||||||
|
'Set it in CI Secret or .env.local for local build.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return keyB64;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlatform(context) {
|
||||||
|
const raw = (
|
||||||
|
context?.electronPlatformName ||
|
||||||
|
context?.packager?.platform?.name ||
|
||||||
|
process.platform
|
||||||
|
);
|
||||||
|
return normalizePlatformTag(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePlatformTag(rawPlatform) {
|
||||||
|
const p = String(rawPlatform || '').toLowerCase();
|
||||||
|
if (p === 'darwin' || p === 'mac' || p === 'macos' || p === 'osx') return 'darwin';
|
||||||
|
if (p === 'win32' || p === 'win' || p === 'windows') return 'win32';
|
||||||
|
if (p === 'linux') return 'linux';
|
||||||
|
return p || process.platform;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProductFilename(context) {
|
||||||
|
return (
|
||||||
|
context?.packager?.appInfo?.productFilename ||
|
||||||
|
context?.packager?.config?.productName ||
|
||||||
|
'WeFlow'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMacAppBundle(appOutDir, productFilename) {
|
||||||
|
const candidates = [];
|
||||||
|
if (String(appOutDir).toLowerCase().endsWith('.app')) {
|
||||||
|
candidates.push(appOutDir);
|
||||||
|
} else {
|
||||||
|
candidates.push(path.join(appOutDir, `${productFilename}.app`));
|
||||||
|
if (fs.existsSync(appOutDir) && fs.statSync(appOutDir).isDirectory()) {
|
||||||
|
const appDirs = fs
|
||||||
|
.readdirSync(appOutDir, { withFileTypes: true })
|
||||||
|
.filter((e) => e.isDirectory() && e.name.toLowerCase().endsWith('.app'))
|
||||||
|
.map((e) => path.join(appOutDir, e.name));
|
||||||
|
candidates.push(...appDirs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const bundleDir of candidates) {
|
||||||
|
const resourcesPath = path.join(bundleDir, 'Contents', 'Resources');
|
||||||
|
if (fs.existsSync(resourcesPath) && fs.statSync(resourcesPath).isDirectory()) {
|
||||||
|
return bundleDir;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResourcesDir(appOutDir, platform, productFilename) {
|
||||||
|
if (platform === 'darwin') {
|
||||||
|
const bundleDir = resolveMacAppBundle(appOutDir, productFilename);
|
||||||
|
if (!bundleDir) return path.join(appOutDir, 'resources');
|
||||||
|
return path.join(bundleDir, 'Contents', 'Resources');
|
||||||
|
}
|
||||||
|
return path.join(appOutDir, 'resources');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRel(baseDir, filePath) {
|
||||||
|
return path.relative(baseDir, filePath).split(path.sep).join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256FileHex(filePath) {
|
||||||
|
const data = fs.readFileSync(filePath);
|
||||||
|
return crypto.createHash('sha256').update(data).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function findFirstExisting(paths) {
|
||||||
|
for (const p of paths) {
|
||||||
|
if (p && fs.existsSync(p) && fs.statSync(p).isFile()) return p;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findExecutablePath({ appOutDir, platform, productFilename, executableName }) {
|
||||||
|
if (platform === 'win32') {
|
||||||
|
return findFirstExisting([
|
||||||
|
path.join(appOutDir, `${productFilename}.exe`),
|
||||||
|
path.join(appOutDir, `${executableName || ''}.exe`),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform === 'darwin') {
|
||||||
|
const bundleDir = resolveMacAppBundle(appOutDir, productFilename) || appOutDir;
|
||||||
|
const macOsDir = path.join(bundleDir, 'Contents', 'MacOS');
|
||||||
|
const preferred = findFirstExisting([path.join(macOsDir, productFilename)]);
|
||||||
|
if (preferred) return preferred;
|
||||||
|
if (!fs.existsSync(macOsDir)) return null;
|
||||||
|
const files = fs
|
||||||
|
.readdirSync(macOsDir)
|
||||||
|
.map((name) => path.join(macOsDir, name))
|
||||||
|
.filter((p) => fs.statSync(p).isFile());
|
||||||
|
return files[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return findFirstExisting([
|
||||||
|
path.join(appOutDir, executableName || ''),
|
||||||
|
path.join(appOutDir, productFilename),
|
||||||
|
path.join(appOutDir, productFilename.toLowerCase()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findByBasenameRecursive(rootDir, basename) {
|
||||||
|
if (!fs.existsSync(rootDir)) return null;
|
||||||
|
const stack = [rootDir];
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const dir = stack.pop();
|
||||||
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
stack.push(full);
|
||||||
|
} else if (entry.isFile() && entry.name.toLowerCase() === basename.toLowerCase()) {
|
||||||
|
return full;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getModulePath(resourcesDir, appOutDir, platform) {
|
||||||
|
const filename = MODULE_FILENAME[platform] || MODULE_FILENAME[process.platform];
|
||||||
|
if (!filename) return null;
|
||||||
|
|
||||||
|
const direct = findFirstExisting([
|
||||||
|
path.join(resourcesDir, 'resources', filename),
|
||||||
|
path.join(resourcesDir, filename),
|
||||||
|
]);
|
||||||
|
if (direct) return direct;
|
||||||
|
|
||||||
|
const inResources = findByBasenameRecursive(resourcesDir, filename);
|
||||||
|
if (inResources) return inResources;
|
||||||
|
|
||||||
|
return findByBasenameRecursive(appOutDir, filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
function signDetachedEd25519(payloadUtf8, privateKeyDerB64) {
|
||||||
|
const privateKeyDer = Buffer.from(privateKeyDerB64, 'base64');
|
||||||
|
const keyObject = crypto.createPrivateKey({
|
||||||
|
key: privateKeyDer,
|
||||||
|
format: 'der',
|
||||||
|
type: 'pkcs8',
|
||||||
|
});
|
||||||
|
return crypto.sign(null, Buffer.from(payloadUtf8, 'utf8'), keyObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = async function afterPack(context) {
|
||||||
|
const privateKeyDerB64 = ensureSigningEnv();
|
||||||
|
if (!privateKeyDerB64) {
|
||||||
|
console.log('[wf-sign] skip: WF_SIGN_PRIVATE_KEY not provided and signing not required.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const appOutDir = context?.appOutDir;
|
||||||
|
if (!appOutDir || !fs.existsSync(appOutDir)) {
|
||||||
|
throw new Error(`[wf-sign] invalid appOutDir: ${String(appOutDir)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const platform = String(getPlatform(context)).toLowerCase();
|
||||||
|
const productFilename = getProductFilename(context);
|
||||||
|
const executableName = context?.packager?.config?.linux?.executableName || '';
|
||||||
|
const resourcesDir = getResourcesDir(appOutDir, platform, productFilename);
|
||||||
|
if (!fs.existsSync(resourcesDir)) {
|
||||||
|
throw new Error(
|
||||||
|
`[wf-sign] resources directory not found: ${resourcesDir}; platform=${platform}; appOutDir=${appOutDir}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const exePath = findExecutablePath({
|
||||||
|
appOutDir,
|
||||||
|
platform,
|
||||||
|
productFilename,
|
||||||
|
executableName,
|
||||||
|
});
|
||||||
|
if (!exePath) {
|
||||||
|
throw new Error(
|
||||||
|
`[wf-sign] executable not found. platform=${platform}, appOutDir=${appOutDir}, productFilename=${productFilename}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const modulePath = getModulePath(resourcesDir, appOutDir, platform);
|
||||||
|
if (!modulePath) {
|
||||||
|
throw new Error(
|
||||||
|
`[wf-sign] ${MODULE_FILENAME[platform] || 'wcdb_api'} not found under resources: ${resourcesDir}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifest = {
|
||||||
|
schema: 1,
|
||||||
|
platform,
|
||||||
|
version: context?.packager?.appInfo?.version || '',
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
targets: [
|
||||||
|
{
|
||||||
|
id: 'exe',
|
||||||
|
path: normalizeRel(resourcesDir, exePath),
|
||||||
|
sha256: sha256FileHex(exePath),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'module',
|
||||||
|
path: normalizeRel(resourcesDir, modulePath),
|
||||||
|
sha256: sha256FileHex(modulePath),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const payload = `${JSON.stringify(manifest, null, 2)}\n`;
|
||||||
|
const signature = signDetachedEd25519(payload, privateKeyDerB64).toString('base64');
|
||||||
|
|
||||||
|
const manifestPath = path.join(resourcesDir, MANIFEST_NAME);
|
||||||
|
const signaturePath = path.join(resourcesDir, SIGNATURE_NAME);
|
||||||
|
fs.writeFileSync(manifestPath, payload, 'utf8');
|
||||||
|
fs.writeFileSync(signaturePath, `${signature}\n`, 'utf8');
|
||||||
|
|
||||||
|
console.log(`[wf-sign] manifest: ${manifestPath}`);
|
||||||
|
console.log(`[wf-sign] signature: ${signaturePath}`);
|
||||||
|
console.log(`[wf-sign] exe: ${manifest.targets[0].path}`);
|
||||||
|
console.log(`[wf-sign] exe.sha256: ${manifest.targets[0].sha256}`);
|
||||||
|
console.log(`[wf-sign] module: ${manifest.targets[1].path}`);
|
||||||
|
console.log(`[wf-sign] module.sha256: ${manifest.targets[1].sha256}`);
|
||||||
|
};
|
||||||
55
src/App.scss
55
src/App.scss
@@ -4,6 +4,48 @@
|
|||||||
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 {
|
.window-drag-region {
|
||||||
@@ -27,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 {
|
||||||
|
|||||||
333
src/App.tsx
333
src/App.tsx
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { Routes, Route, useNavigate, useLocation } from 'react-router-dom'
|
import { Routes, Route, Navigate, useNavigate, useLocation, type Location } from 'react-router-dom'
|
||||||
import TitleBar from './components/TitleBar'
|
import TitleBar from './components/TitleBar'
|
||||||
import Sidebar from './components/Sidebar'
|
import Sidebar from './components/Sidebar'
|
||||||
import RouteGuard from './components/RouteGuard'
|
import RouteGuard from './components/RouteGuard'
|
||||||
@@ -8,6 +8,7 @@ import HomePage from './pages/HomePage'
|
|||||||
import ChatPage from './pages/ChatPage'
|
import ChatPage from './pages/ChatPage'
|
||||||
import AnalyticsPage from './pages/AnalyticsPage'
|
import AnalyticsPage from './pages/AnalyticsPage'
|
||||||
import AnalyticsWelcomePage from './pages/AnalyticsWelcomePage'
|
import AnalyticsWelcomePage from './pages/AnalyticsWelcomePage'
|
||||||
|
import ChatAnalyticsHubPage from './pages/ChatAnalyticsHubPage'
|
||||||
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 DualReportPage from './pages/DualReportPage'
|
||||||
@@ -22,11 +23,11 @@ import SnsPage from './pages/SnsPage'
|
|||||||
import ContactsPage from './pages/ContactsPage'
|
import ContactsPage from './pages/ContactsPage'
|
||||||
import ChatHistoryPage from './pages/ChatHistoryPage'
|
import ChatHistoryPage from './pages/ChatHistoryPage'
|
||||||
import NotificationWindow from './pages/NotificationWindow'
|
import NotificationWindow from './pages/NotificationWindow'
|
||||||
import AIChatPage from './pages/AIChatPage'
|
|
||||||
|
|
||||||
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'
|
||||||
|
|
||||||
@@ -35,10 +36,25 @@ import UpdateProgressCapsule from './components/UpdateProgressCapsule'
|
|||||||
import LockScreen from './components/LockScreen'
|
import LockScreen from './components/LockScreen'
|
||||||
import { GlobalSessionMonitor } from './components/GlobalSessionMonitor'
|
import { GlobalSessionMonitor } from './components/GlobalSessionMonitor'
|
||||||
import { BatchTranscribeGlobal } from './components/BatchTranscribeGlobal'
|
import { BatchTranscribeGlobal } from './components/BatchTranscribeGlobal'
|
||||||
|
import { BatchImageDecryptGlobal } from './components/BatchImageDecryptGlobal'
|
||||||
|
import WindowCloseDialog from './components/WindowCloseDialog'
|
||||||
|
|
||||||
|
function RouteStateRedirect({ to }: { to: string }) {
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
return <Navigate to={to} replace state={location.state} />
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
const settingsBackgroundRef = useRef<Location>({
|
||||||
|
pathname: '/home',
|
||||||
|
search: '',
|
||||||
|
hash: '',
|
||||||
|
state: null,
|
||||||
|
key: 'settings-fallback'
|
||||||
|
} as Location)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
setDbConnected,
|
setDbConnected,
|
||||||
@@ -59,9 +75,19 @@ function App() {
|
|||||||
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 isChatHistoryWindow = location.pathname.startsWith('/chat-history/') || location.pathname.startsWith('/chat-history-inline/')
|
||||||
|
const isStandaloneChatWindow = location.pathname === '/chat-window'
|
||||||
const isNotificationWindow = location.pathname === '/notification-window'
|
const isNotificationWindow = location.pathname === '/notification-window'
|
||||||
|
const isSettingsRoute = location.pathname === '/settings'
|
||||||
|
const settingsRouteState = location.state as { backgroundLocation?: Location; initialTab?: unknown } | null
|
||||||
|
const routeLocation = isSettingsRoute
|
||||||
|
? settingsRouteState?.backgroundLocation ?? settingsBackgroundRef.current
|
||||||
|
: location
|
||||||
|
const isExportRoute = routeLocation.pathname === '/export'
|
||||||
const [themeHydrated, setThemeHydrated] = useState(false)
|
const [themeHydrated, setThemeHydrated] = useState(false)
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||||
|
const [showCloseDialog, setShowCloseDialog] = useState(false)
|
||||||
|
const [canMinimizeToTray, setCanMinimizeToTray] = useState(false)
|
||||||
|
|
||||||
// 锁定状态
|
// 锁定状态
|
||||||
// const [isLocked, setIsLocked] = useState(false) // Moved to store
|
// const [isLocked, setIsLocked] = useState(false) // Moved to store
|
||||||
@@ -75,6 +101,62 @@ function App() {
|
|||||||
const [agreementChecked, setAgreementChecked] = useState(false)
|
const [agreementChecked, setAgreementChecked] = useState(false)
|
||||||
const [agreementLoading, setAgreementLoading] = useState(true)
|
const [agreementLoading, setAgreementLoading] = useState(true)
|
||||||
|
|
||||||
|
// 数据收集同意状态
|
||||||
|
const [showAnalyticsConsent, setShowAnalyticsConsent] = useState(false)
|
||||||
|
|
||||||
|
const [showWaylandWarning, setShowWaylandWarning] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkWaylandStatus = async () => {
|
||||||
|
try {
|
||||||
|
// 防止在非客户端环境报错,先检查 API 是否存在
|
||||||
|
if (!window.electronAPI?.app?.checkWayland) return
|
||||||
|
|
||||||
|
// 通过 configService 检查是否已经弹过窗
|
||||||
|
const hasWarned = await window.electronAPI.config.get('waylandWarningShown')
|
||||||
|
|
||||||
|
if (!hasWarned) {
|
||||||
|
const isWayland = await window.electronAPI.app.checkWayland()
|
||||||
|
if (isWayland) {
|
||||||
|
setShowWaylandWarning(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('检查 Wayland 状态失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只有在协议同意之后并且已经进入主应用流程才检查
|
||||||
|
if (!isAgreementWindow && !isOnboardingWindow && !agreementLoading) {
|
||||||
|
checkWaylandStatus()
|
||||||
|
}
|
||||||
|
}, [isAgreementWindow, isOnboardingWindow, agreementLoading])
|
||||||
|
|
||||||
|
const handleDismissWaylandWarning = async () => {
|
||||||
|
try {
|
||||||
|
// 记录到本地配置中,下次不再提示
|
||||||
|
await window.electronAPI.config.set('waylandWarningShown', true)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('保存 Wayland 提示状态失败:', e)
|
||||||
|
}
|
||||||
|
setShowWaylandWarning(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (location.pathname !== '/settings') {
|
||||||
|
settingsBackgroundRef.current = location
|
||||||
|
}
|
||||||
|
}, [location])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const removeCloseConfirmListener = window.electronAPI.window.onCloseConfirmRequested((payload) => {
|
||||||
|
setCanMinimizeToTray(Boolean(payload.canMinimizeToTray))
|
||||||
|
setShowCloseDialog(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => removeCloseConfirmListener()
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement
|
const root = document.documentElement
|
||||||
const body = document.body
|
const body = document.body
|
||||||
@@ -101,14 +183,23 @@ 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 && !isNotificationWindow) {
|
|
||||||
window.electronAPI.window.setTitleBarOverlay({ symbolColor })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyMode(themeMode)
|
||||||
|
|
||||||
|
// 监听系统主题变化
|
||||||
|
const handler = (e: MediaQueryListEvent) => {
|
||||||
|
if (useThemeStore.getState().themeMode === 'system') {
|
||||||
|
applyMode('system', e.matches)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mq.addEventListener('change', handler)
|
||||||
|
return () => mq.removeEventListener('change', handler)
|
||||||
}, [currentTheme, themeMode, isOnboardingWindow, isNotificationWindow])
|
}, [currentTheme, themeMode, isOnboardingWindow, isNotificationWindow])
|
||||||
|
|
||||||
// 读取已保存的主题设置
|
// 读取已保存的主题设置
|
||||||
@@ -122,7 +213,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) {
|
||||||
@@ -157,6 +248,14 @@ 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()
|
||||||
|
const denyCount = await configService.getAnalyticsDenyCount()
|
||||||
|
// 如果未设置同意状态且拒绝次数小于2次,显示弹窗
|
||||||
|
if (consent === null && denyCount < 2) {
|
||||||
|
setShowAnalyticsConsent(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('检查协议状态失败:', e)
|
console.error('检查协议状态失败:', e)
|
||||||
@@ -167,25 +266,56 @@ 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 () => {
|
||||||
|
const denyCount = await configService.getAnalyticsDenyCount()
|
||||||
|
await configService.setAnalyticsDenyCount(denyCount + 1)
|
||||||
|
setShowAnalyticsConsent(false)
|
||||||
|
}
|
||||||
|
|
||||||
// 监听启动时的更新通知
|
// 监听启动时的更新通知
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isNotificationWindow) return // Skip updates in notification window
|
if (isNotificationWindow) return // Skip updates in notification window
|
||||||
|
|
||||||
const removeUpdateListener = window.electronAPI?.app?.onUpdateAvailable?.((info: any) => {
|
const removeUpdateListener = window.electronAPI?.app?.onUpdateAvailable?.((info: any) => {
|
||||||
// 发现新版本时自动打开更新弹窗
|
// 发现新版本时保存更新信息,锁定状态下不弹窗,解锁后再显示
|
||||||
if (info) {
|
if (info) {
|
||||||
setUpdateInfo({ ...info, hasUpdate: true })
|
setUpdateInfo({ ...info, hasUpdate: true })
|
||||||
setShowUpdateDialog(true)
|
if (!useAppStore.getState().isLocked) {
|
||||||
|
setShowUpdateDialog(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const removeProgressListener = window.electronAPI?.app?.onDownloadProgress?.((progress: any) => {
|
const removeProgressListener = window.electronAPI?.app?.onDownloadProgress?.((progress: any) => {
|
||||||
@@ -197,6 +327,13 @@ function App() {
|
|||||||
}
|
}
|
||||||
}, [setUpdateInfo, setDownloadProgress, setShowUpdateDialog, isNotificationWindow])
|
}, [setUpdateInfo, setDownloadProgress, setShowUpdateDialog, isNotificationWindow])
|
||||||
|
|
||||||
|
// 解锁后显示暂存的更新弹窗
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLocked && updateInfo?.hasUpdate && !showUpdateDialog && !isDownloading) {
|
||||||
|
setShowUpdateDialog(true)
|
||||||
|
}
|
||||||
|
}, [isLocked])
|
||||||
|
|
||||||
const handleUpdateNow = async () => {
|
const handleUpdateNow = async () => {
|
||||||
setShowUpdateDialog(false)
|
setShowUpdateDialog(false)
|
||||||
setIsDownloading(true)
|
setIsDownloading(true)
|
||||||
@@ -228,6 +365,26 @@ function App() {
|
|||||||
setUpdateInfo(null)
|
setUpdateInfo(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleWindowCloseAction = async (
|
||||||
|
action: 'tray' | 'quit' | 'cancel',
|
||||||
|
rememberChoice = false
|
||||||
|
) => {
|
||||||
|
setShowCloseDialog(false)
|
||||||
|
if (rememberChoice && action !== 'cancel') {
|
||||||
|
try {
|
||||||
|
await configService.setWindowCloseBehavior(action)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存关闭偏好失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await window.electronAPI.window.respondCloseConfirm(action)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('处理关闭确认失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 启动时自动检查配置并连接数据库
|
// 启动时自动检查配置并连接数据库
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAgreementWindow || isOnboardingWindow) return
|
if (isAgreementWindow || isOnboardingWindow) return
|
||||||
@@ -291,7 +448,7 @@ function App() {
|
|||||||
const checkLock = async () => {
|
const checkLock = async () => {
|
||||||
// 并行获取配置,减少等待
|
// 并行获取配置,减少等待
|
||||||
const [enabled, useHello] = await Promise.all([
|
const [enabled, useHello] = await Promise.all([
|
||||||
configService.getAuthEnabled(),
|
window.electronAPI.auth.verifyEnabled(),
|
||||||
configService.getAuthUseHello()
|
configService.getAuthUseHello()
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -313,6 +470,8 @@ function App() {
|
|||||||
checkLock()
|
checkLock()
|
||||||
}, [isAgreementWindow, isOnboardingWindow, isVideoPlayerWindow])
|
}, [isAgreementWindow, isOnboardingWindow, isVideoPlayerWindow])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 独立协议窗口
|
// 独立协议窗口
|
||||||
if (isAgreementWindow) {
|
if (isAgreementWindow) {
|
||||||
return <AgreementPage />
|
return <AgreementPage />
|
||||||
@@ -338,12 +497,51 @@ function App() {
|
|||||||
return <ChatHistoryPage />
|
return <ChatHistoryPage />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 独立会话聊天窗口(仅显示聊天内容区域)
|
||||||
|
if (isStandaloneChatWindow) {
|
||||||
|
const params = new URLSearchParams(location.search)
|
||||||
|
const sessionId = params.get('sessionId') || ''
|
||||||
|
const standaloneSource = params.get('source')
|
||||||
|
const standaloneInitialDisplayName = params.get('initialDisplayName')
|
||||||
|
const standaloneInitialAvatarUrl = params.get('initialAvatarUrl')
|
||||||
|
const standaloneInitialContactType = params.get('initialContactType')
|
||||||
|
return (
|
||||||
|
<ChatPage
|
||||||
|
standaloneSessionWindow
|
||||||
|
initialSessionId={sessionId}
|
||||||
|
standaloneSource={standaloneSource}
|
||||||
|
standaloneInitialDisplayName={standaloneInitialDisplayName}
|
||||||
|
standaloneInitialAvatarUrl={standaloneInitialAvatarUrl}
|
||||||
|
standaloneInitialContactType={standaloneInitialContactType}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 独立通知窗口
|
// 独立通知窗口
|
||||||
if (isNotificationWindow) {
|
if (isNotificationWindow) {
|
||||||
return <NotificationWindow />
|
return <NotificationWindow />
|
||||||
}
|
}
|
||||||
|
|
||||||
// 主窗口 - 完整布局
|
// 主窗口 - 完整布局
|
||||||
|
const handleCloseSettings = () => {
|
||||||
|
const backgroundLocation = settingsRouteState?.backgroundLocation ?? settingsBackgroundRef.current
|
||||||
|
if (backgroundLocation.pathname === '/settings') {
|
||||||
|
navigate('/home', { replace: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
navigate(
|
||||||
|
{
|
||||||
|
pathname: backgroundLocation.pathname,
|
||||||
|
search: backgroundLocation.search,
|
||||||
|
hash: backgroundLocation.hash
|
||||||
|
},
|
||||||
|
{
|
||||||
|
replace: true,
|
||||||
|
state: backgroundLocation.state
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-container">
|
<div className="app-container">
|
||||||
<div className="window-drag-region" aria-hidden="true" />
|
<div className="window-drag-region" aria-hidden="true" />
|
||||||
@@ -354,7 +552,10 @@ function App() {
|
|||||||
useHello={lockUseHello}
|
useHello={lockUseHello}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<TitleBar />
|
<TitleBar
|
||||||
|
sidebarCollapsed={sidebarCollapsed}
|
||||||
|
onToggleSidebar={() => setSidebarCollapsed((prev) => !prev)}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 全局悬浮进度胶囊 (处理:新版本提示、下载进度、错误提示) */}
|
{/* 全局悬浮进度胶囊 (处理:新版本提示、下载进度、错误提示) */}
|
||||||
<UpdateProgressCapsule />
|
<UpdateProgressCapsule />
|
||||||
@@ -364,6 +565,7 @@ function App() {
|
|||||||
|
|
||||||
{/* 全局批量转写进度浮窗 */}
|
{/* 全局批量转写进度浮窗 */}
|
||||||
<BatchTranscribeGlobal />
|
<BatchTranscribeGlobal />
|
||||||
|
<BatchImageDecryptGlobal />
|
||||||
|
|
||||||
{/* 用户协议弹窗 */}
|
{/* 用户协议弹窗 */}
|
||||||
{showAgreement && !agreementLoading && (
|
{showAgreement && !agreementLoading && (
|
||||||
@@ -416,6 +618,69 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 数据收集同意弹窗 */}
|
||||||
|
{showAnalyticsConsent && !agreementLoading && (
|
||||||
|
<div className="agreement-overlay">
|
||||||
|
<div className="agreement-modal">
|
||||||
|
<div className="agreement-header">
|
||||||
|
<Shield size={32} />
|
||||||
|
<h2>使用数据收集说明</h2>
|
||||||
|
</div>
|
||||||
|
<div className="agreement-content">
|
||||||
|
<div className="agreement-text">
|
||||||
|
<p>为了持续改进 WeFlow 并提供更好的用户体验,我们希望收集一些匿名的使用数据。</p>
|
||||||
|
|
||||||
|
<h4>我们会收集什么?</h4>
|
||||||
|
<p>• 功能使用情况(如哪些功能被使用、使用频率)</p>
|
||||||
|
<p>• 应用性能数据(如加载时间、错误日志)</p>
|
||||||
|
<p>• 设备基本信息(如操作系统版本、应用版本)</p>
|
||||||
|
|
||||||
|
<h4>我们不会收集什么?</h4>
|
||||||
|
<p>• 你的聊天记录内容</p>
|
||||||
|
<p>• 个人身份信息</p>
|
||||||
|
<p>• 联系人信息</p>
|
||||||
|
<p>• 任何可以识别你身份的数据</p>
|
||||||
|
<p>• 一切你担心会涉及隐藏的数据</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="agreement-footer">
|
||||||
|
<div className="agreement-actions">
|
||||||
|
<button className="btn btn-secondary" onClick={handleAnalyticsDeny}>不允许</button>
|
||||||
|
<button className="btn btn-primary" onClick={handleAnalyticsAllow}>允许</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showWaylandWarning && (
|
||||||
|
<div className="agreement-overlay">
|
||||||
|
<div className="agreement-modal">
|
||||||
|
<div className="agreement-header">
|
||||||
|
<Shield size={32} />
|
||||||
|
<h2>环境兼容性提示 (Wayland)</h2>
|
||||||
|
</div>
|
||||||
|
<div className="agreement-content">
|
||||||
|
<div className="agreement-text">
|
||||||
|
<p>检测到您当前正在使用 <strong>Wayland</strong> 显示服务器。</p>
|
||||||
|
<p>在 Wayland 环境下,出于系统级的安全与设计机制,<strong>应用程序无法直接控制新弹出窗口的位置</strong>。</p>
|
||||||
|
<p>这可能导致某些独立窗口(如消息通知、图片查看器等)出现位置随机、或不受控制的情况。这是底层机制导致的,对此我们无能为力。</p>
|
||||||
|
<br />
|
||||||
|
<p>如果您觉得窗口位置异常严重影响了使用体验,建议尝试:</p>
|
||||||
|
<p>1. 在系统登录界面,将会话切换回 <strong>X11 (Xorg)</strong> 模式。</p>
|
||||||
|
<p>2. 修改您的桌面管理器 (WM/DE) 配置,强制指定该应用程序的窗口规则。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="agreement-footer">
|
||||||
|
<div className="agreement-actions">
|
||||||
|
<button className="btn btn-primary" onClick={handleDismissWaylandWarning}>我知道了,不再提示</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 更新提示对话框 */}
|
{/* 更新提示对话框 */}
|
||||||
<UpdateDialog
|
<UpdateDialog
|
||||||
open={showUpdateDialog}
|
open={showUpdateDialog}
|
||||||
@@ -427,32 +692,50 @@ function App() {
|
|||||||
progress={downloadProgress}
|
progress={downloadProgress}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<WindowCloseDialog
|
||||||
|
open={showCloseDialog}
|
||||||
|
canMinimizeToTray={canMinimizeToTray}
|
||||||
|
onSelect={(action, rememberChoice) => handleWindowCloseAction(action, rememberChoice)}
|
||||||
|
onCancel={() => handleWindowCloseAction('cancel')}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="main-layout">
|
<div className="main-layout">
|
||||||
<Sidebar />
|
<Sidebar collapsed={sidebarCollapsed} />
|
||||||
<main className="content">
|
<main className="content">
|
||||||
<RouteGuard>
|
<RouteGuard>
|
||||||
<Routes>
|
<div className={`export-keepalive-page ${isExportRoute ? 'active' : 'hidden'}`} aria-hidden={!isExportRoute}>
|
||||||
|
<ExportPage />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Routes location={routeLocation}>
|
||||||
<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="/ai-chat" element={<AIChatPage />} />
|
|
||||||
<Route path="/analytics" element={<AnalyticsWelcomePage />} />
|
<Route path="/analytics" element={<ChatAnalyticsHubPage />} />
|
||||||
<Route path="/analytics/view" element={<AnalyticsPage />} />
|
<Route path="/analytics/private" element={<AnalyticsWelcomePage />} />
|
||||||
<Route path="/group-analytics" element={<GroupAnalyticsPage />} />
|
<Route path="/analytics/private/view" element={<AnalyticsPage />} />
|
||||||
|
<Route path="/analytics/group" element={<GroupAnalyticsPage />} />
|
||||||
|
<Route path="/analytics/view" element={<RouteStateRedirect to="/analytics/private/view" />} />
|
||||||
|
<Route path="/group-analytics" element={<RouteStateRedirect to="/analytics/group" />} />
|
||||||
<Route path="/annual-report" element={<AnnualReportPage />} />
|
<Route path="/annual-report" element={<AnnualReportPage />} />
|
||||||
<Route path="/annual-report/view" element={<AnnualReportWindow />} />
|
<Route path="/annual-report/view" element={<AnnualReportWindow />} />
|
||||||
<Route path="/dual-report" element={<DualReportPage />} />
|
<Route path="/dual-report" element={<DualReportPage />} />
|
||||||
<Route path="/dual-report/view" element={<DualReportWindow />} />
|
<Route path="/dual-report/view" element={<DualReportWindow />} />
|
||||||
|
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/export" element={<div className="export-route-anchor" aria-hidden="true" />} />
|
||||||
<Route path="/export" element={<ExportPage />} />
|
|
||||||
<Route path="/sns" element={<SnsPage />} />
|
<Route path="/sns" element={<SnsPage />} />
|
||||||
<Route path="/contacts" element={<ContactsPage />} />
|
<Route path="/contacts" element={<ContactsPage />} />
|
||||||
<Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} />
|
<Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} />
|
||||||
|
<Route path="/chat-history-inline/:payloadId" element={<ChatHistoryPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</RouteGuard>
|
</RouteGuard>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isSettingsRoute && (
|
||||||
|
<SettingsPage onClose={handleCloseSettings} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,21 @@
|
|||||||
border-radius: inherit;
|
border-radius: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.avatar-loading {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-tertiary, #999);
|
||||||
|
background-color: var(--bg-tertiary, #e0e0e0);
|
||||||
|
border-radius: inherit;
|
||||||
|
|
||||||
|
.avatar-loading-icon {
|
||||||
|
animation: avatar-spin 0.9s linear infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Loading Skeleton */
|
/* Loading Skeleton */
|
||||||
.avatar-skeleton {
|
.avatar-skeleton {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -76,4 +91,14 @@
|
|||||||
background-position: -200% 0;
|
background-position: -200% 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
@keyframes avatar-spin {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||||
import { User } from 'lucide-react'
|
import { Loader2, User } from 'lucide-react'
|
||||||
import { avatarLoadQueue } from '../utils/AvatarLoadQueue'
|
import { avatarLoadQueue } from '../utils/AvatarLoadQueue'
|
||||||
import './Avatar.scss'
|
import './Avatar.scss'
|
||||||
|
|
||||||
@@ -13,6 +13,7 @@ interface AvatarProps {
|
|||||||
shape?: 'circle' | 'square' | 'rounded'
|
shape?: 'circle' | 'square' | 'rounded'
|
||||||
className?: string
|
className?: string
|
||||||
lazy?: boolean
|
lazy?: boolean
|
||||||
|
loading?: boolean
|
||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,12 +24,14 @@ export const Avatar = React.memo(function Avatar({
|
|||||||
shape = 'rounded',
|
shape = 'rounded',
|
||||||
className = '',
|
className = '',
|
||||||
lazy = true,
|
lazy = true,
|
||||||
|
loading = false,
|
||||||
onClick
|
onClick
|
||||||
}: AvatarProps) {
|
}: AvatarProps) {
|
||||||
// 如果 URL 已在缓存中,则直接标记为已加载,不显示骨架屏和淡入动画
|
// 如果 URL 已在缓存中,则直接标记为已加载,不显示骨架屏和淡入动画
|
||||||
const isCached = useMemo(() => src ? loadedAvatarCache.has(src) : false, [src])
|
const isCached = useMemo(() => src ? loadedAvatarCache.has(src) : false, [src])
|
||||||
|
const isFailed = useMemo(() => src ? avatarLoadQueue.hasFailed(src) : false, [src])
|
||||||
const [imageLoaded, setImageLoaded] = useState(isCached)
|
const [imageLoaded, setImageLoaded] = useState(isCached)
|
||||||
const [imageError, setImageError] = useState(false)
|
const [imageError, setImageError] = useState(isFailed)
|
||||||
const [shouldLoad, setShouldLoad] = useState(!lazy || isCached)
|
const [shouldLoad, setShouldLoad] = useState(!lazy || isCached)
|
||||||
const [isInQueue, setIsInQueue] = useState(false)
|
const [isInQueue, setIsInQueue] = useState(false)
|
||||||
const imgRef = useRef<HTMLImageElement>(null)
|
const imgRef = useRef<HTMLImageElement>(null)
|
||||||
@@ -42,7 +45,7 @@ export const Avatar = React.memo(function Avatar({
|
|||||||
|
|
||||||
// Intersection Observer for lazy loading
|
// Intersection Observer for lazy loading
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!lazy || shouldLoad || isInQueue || !src || !containerRef.current || isCached) return
|
if (!lazy || shouldLoad || isInQueue || !src || !containerRef.current || isCached || imageError || isFailed) return
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
@@ -50,10 +53,11 @@ export const Avatar = React.memo(function Avatar({
|
|||||||
if (entry.isIntersecting && !isInQueue) {
|
if (entry.isIntersecting && !isInQueue) {
|
||||||
setIsInQueue(true)
|
setIsInQueue(true)
|
||||||
avatarLoadQueue.enqueue(src).then(() => {
|
avatarLoadQueue.enqueue(src).then(() => {
|
||||||
|
setImageError(false)
|
||||||
setShouldLoad(true)
|
setShouldLoad(true)
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
// 加载失败不要立刻显示错误,让浏览器渲染去报错
|
setImageError(true)
|
||||||
setShouldLoad(true)
|
setShouldLoad(false)
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
setIsInQueue(false)
|
setIsInQueue(false)
|
||||||
})
|
})
|
||||||
@@ -67,14 +71,18 @@ export const Avatar = React.memo(function Avatar({
|
|||||||
observer.observe(containerRef.current)
|
observer.observe(containerRef.current)
|
||||||
|
|
||||||
return () => observer.disconnect()
|
return () => observer.disconnect()
|
||||||
}, [src, lazy, shouldLoad, isInQueue, isCached])
|
}, [src, lazy, shouldLoad, isInQueue, isCached, imageError, isFailed])
|
||||||
|
|
||||||
// Reset state when src changes
|
// Reset state when src changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cached = src ? loadedAvatarCache.has(src) : false
|
const cached = src ? loadedAvatarCache.has(src) : false
|
||||||
|
const failed = src ? avatarLoadQueue.hasFailed(src) : false
|
||||||
setImageLoaded(cached)
|
setImageLoaded(cached)
|
||||||
setImageError(false)
|
setImageError(failed)
|
||||||
if (lazy && !cached) {
|
if (failed) {
|
||||||
|
setShouldLoad(false)
|
||||||
|
setIsInQueue(false)
|
||||||
|
} else if (lazy && !cached) {
|
||||||
setShouldLoad(false)
|
setShouldLoad(false)
|
||||||
setIsInQueue(false)
|
setIsInQueue(false)
|
||||||
} else {
|
} else {
|
||||||
@@ -95,6 +103,7 @@ export const Avatar = React.memo(function Avatar({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasValidUrl = !!src && !imageError && shouldLoad
|
const hasValidUrl = !!src && !imageError && shouldLoad
|
||||||
|
const shouldShowLoadingPlaceholder = loading && !hasValidUrl && !imageError
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -112,13 +121,30 @@ export const Avatar = React.memo(function Avatar({
|
|||||||
alt={name || 'avatar'}
|
alt={name || 'avatar'}
|
||||||
className={`avatar-image ${imageLoaded ? 'loaded' : ''} ${isCached ? 'instant' : ''}`}
|
className={`avatar-image ${imageLoaded ? 'loaded' : ''} ${isCached ? 'instant' : ''}`}
|
||||||
onLoad={() => {
|
onLoad={() => {
|
||||||
if (src) loadedAvatarCache.add(src)
|
if (src) {
|
||||||
|
avatarLoadQueue.clearFailed(src)
|
||||||
|
loadedAvatarCache.add(src)
|
||||||
|
}
|
||||||
setImageLoaded(true)
|
setImageLoaded(true)
|
||||||
|
setImageError(false)
|
||||||
|
}}
|
||||||
|
onError={() => {
|
||||||
|
if (src) {
|
||||||
|
avatarLoadQueue.markFailed(src)
|
||||||
|
loadedAvatarCache.delete(src)
|
||||||
|
}
|
||||||
|
setImageLoaded(false)
|
||||||
|
setImageError(true)
|
||||||
|
setShouldLoad(false)
|
||||||
}}
|
}}
|
||||||
onError={() => setImageError(true)}
|
|
||||||
loading={lazy ? "lazy" : "eager"}
|
loading={lazy ? "lazy" : "eager"}
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
) : shouldShowLoadingPlaceholder ? (
|
||||||
|
<div className="avatar-loading">
|
||||||
|
<Loader2 size="50%" className="avatar-loading-icon" />
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="avatar-placeholder">
|
<div className="avatar-placeholder">
|
||||||
{name ? <span className="avatar-letter">{getAvatarLetter()}</span> : <User size="50%" />}
|
{name ? <span className="avatar-letter">{getAvatarLetter()}</span> : <User size="50%" />}
|
||||||
|
|||||||
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
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { Loader2, X, CheckCircle, XCircle, AlertCircle, Clock } from 'lucide-react'
|
import { Loader2, X, CheckCircle, XCircle, AlertCircle, Clock, Mic } from 'lucide-react'
|
||||||
import { useBatchTranscribeStore } from '../stores/batchTranscribeStore'
|
import { useBatchTranscribeStore } from '../stores/batchTranscribeStore'
|
||||||
import '../styles/batchTranscribe.scss'
|
import '../styles/batchTranscribe.scss'
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@ export const BatchTranscribeGlobal: React.FC = () => {
|
|||||||
result,
|
result,
|
||||||
sessionName,
|
sessionName,
|
||||||
startTime,
|
startTime,
|
||||||
|
taskType,
|
||||||
setShowToast,
|
setShowToast,
|
||||||
setShowResult
|
setShowResult
|
||||||
} = useBatchTranscribeStore()
|
} = useBatchTranscribeStore()
|
||||||
@@ -64,7 +65,7 @@ export const BatchTranscribeGlobal: React.FC = () => {
|
|||||||
<div className="batch-progress-toast-header">
|
<div className="batch-progress-toast-header">
|
||||||
<div className="batch-progress-toast-title">
|
<div className="batch-progress-toast-title">
|
||||||
<Loader2 size={14} className="spin" />
|
<Loader2 size={14} className="spin" />
|
||||||
<span>批量转写中{sessionName ? `(${sessionName})` : ''}</span>
|
<span>{taskType === 'decrypt' ? '批量解密语音中' : '批量转写中'}{sessionName ? `(${sessionName})` : ''}</span>
|
||||||
</div>
|
</div>
|
||||||
<button className="batch-progress-toast-close" onClick={() => setShowToast(false)} title="最小化">
|
<button className="batch-progress-toast-close" onClick={() => setShowToast(false)} title="最小化">
|
||||||
<X size={14} />
|
<X size={14} />
|
||||||
@@ -108,8 +109,8 @@ export const BatchTranscribeGlobal: React.FC = () => {
|
|||||||
<div className="batch-modal-overlay" onClick={() => setShowResult(false)}>
|
<div className="batch-modal-overlay" onClick={() => setShowResult(false)}>
|
||||||
<div className="batch-modal-content batch-result-modal" onClick={(e) => e.stopPropagation()}>
|
<div className="batch-modal-content batch-result-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="batch-modal-header">
|
<div className="batch-modal-header">
|
||||||
<CheckCircle size={20} />
|
{taskType === 'decrypt' ? <Mic size={20} /> : <CheckCircle size={20} />}
|
||||||
<h3>转写完成</h3>
|
<h3>{taskType === 'decrypt' ? '语音解密完成' : '转写完成'}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="batch-modal-body">
|
<div className="batch-modal-body">
|
||||||
<div className="result-summary">
|
<div className="result-summary">
|
||||||
@@ -129,7 +130,7 @@ export const BatchTranscribeGlobal: React.FC = () => {
|
|||||||
{result.fail > 0 && (
|
{result.fail > 0 && (
|
||||||
<div className="result-tip">
|
<div className="result-tip">
|
||||||
<AlertCircle size={16} />
|
<AlertCircle size={16} />
|
||||||
<span>部分语音转写失败,可能是语音文件损坏或网络问题</span>
|
<span>{taskType === 'decrypt' ? '部分语音解密失败,可能是语音未缓存或文件损坏' : '部分语音转写失败,可能是语音文件损坏或网络问题'}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
136
src/components/ChatAnalysisHeader.scss
Normal file
136
src/components/ChatAnalysisHeader.scss
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
.chat-analysis-header {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 4px 0;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-analysis-back {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-analysis-breadcrumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
|
||||||
|
.chat-analysis-breadcrumb-separator {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-analysis-dropdown {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-analysis-current-trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
|
||||||
|
.current {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.open svg {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-analysis-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 10px);
|
||||||
|
right: 0;
|
||||||
|
min-width: 120px;
|
||||||
|
padding: 6px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.12);
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-analysis-menu-item {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background 0.2s ease, color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-analysis-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-left: auto;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.chat-analysis-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-analysis-breadcrumb {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
row-gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-analysis-actions {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
105
src/components/ChatAnalysisHeader.tsx
Normal file
105
src/components/ChatAnalysisHeader.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { ChevronDown, ChevronLeft } from 'lucide-react'
|
||||||
|
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import './ChatAnalysisHeader.scss'
|
||||||
|
|
||||||
|
export type ChatAnalysisMode = 'private' | 'group'
|
||||||
|
|
||||||
|
interface ChatAnalysisHeaderProps {
|
||||||
|
currentMode: ChatAnalysisMode
|
||||||
|
actions?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODE_CONFIG: Record<ChatAnalysisMode, { label: string; path: string }> = {
|
||||||
|
private: {
|
||||||
|
label: '私聊分析',
|
||||||
|
path: '/analytics/private'
|
||||||
|
},
|
||||||
|
group: {
|
||||||
|
label: '群聊分析',
|
||||||
|
path: '/analytics/group'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChatAnalysisHeader({ currentMode, actions }: ChatAnalysisHeaderProps) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const currentLabel = MODE_CONFIG[currentMode].label
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
|
const dropdownRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const alternateMode = useMemo(
|
||||||
|
() => (currentMode === 'private' ? 'group' : 'private'),
|
||||||
|
[currentMode]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!menuOpen) return
|
||||||
|
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (!dropdownRef.current?.contains(event.target as Node)) {
|
||||||
|
setMenuOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
setMenuOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
document.addEventListener('keydown', handleEscape)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
document.removeEventListener('keydown', handleEscape)
|
||||||
|
}
|
||||||
|
}, [menuOpen])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chat-analysis-header">
|
||||||
|
<div className="chat-analysis-breadcrumb">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="chat-analysis-back"
|
||||||
|
onClick={() => navigate('/analytics')}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={16} />
|
||||||
|
<span>聊天分析</span>
|
||||||
|
</button>
|
||||||
|
<span className="chat-analysis-breadcrumb-separator">/</span>
|
||||||
|
<div className="chat-analysis-dropdown" ref={dropdownRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`chat-analysis-current-trigger ${menuOpen ? 'open' : ''}`}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={menuOpen}
|
||||||
|
onClick={() => setMenuOpen((prev) => !prev)}
|
||||||
|
>
|
||||||
|
<span className="current">{currentLabel}</span>
|
||||||
|
<ChevronDown size={14} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{menuOpen && (
|
||||||
|
<div className="chat-analysis-menu" role="menu" aria-label="切换聊天分析类型">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
className="chat-analysis-menu-item"
|
||||||
|
onClick={() => {
|
||||||
|
setMenuOpen(false)
|
||||||
|
navigate(MODE_CONFIG[alternateMode].path)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{MODE_CONFIG[alternateMode].label}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{actions ? <div className="chat-analysis-actions">{actions}</div> : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ChatAnalysisHeader
|
||||||
123
src/components/ConfirmDialog.scss
Normal file
123
src/components/ConfirmDialog.scss
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
.confirm-dialog-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 100;
|
||||||
|
animation: fadeIn 0.2s ease-out;
|
||||||
|
|
||||||
|
.confirm-dialog {
|
||||||
|
width: 480px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
position: relative;
|
||||||
|
animation: slideUp 0.2s ease-out;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-title {
|
||||||
|
padding: 40px 40px 16px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-content {
|
||||||
|
padding: 0 40px 24px;
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-actions {
|
||||||
|
padding: 0 40px 40px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
&.btn-cancel {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.btn-confirm {
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--on-primary);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
transform: translateY(20px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/components/ConfirmDialog.tsx
Normal file
32
src/components/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { X } from 'lucide-react'
|
||||||
|
import './ConfirmDialog.scss'
|
||||||
|
|
||||||
|
interface ConfirmDialogProps {
|
||||||
|
open: boolean
|
||||||
|
title?: string
|
||||||
|
message: string
|
||||||
|
onConfirm: () => void
|
||||||
|
onCancel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConfirmDialog({ open, title, message, onConfirm, onCancel }: ConfirmDialogProps) {
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="confirm-dialog-overlay" onClick={onCancel}>
|
||||||
|
<div className="confirm-dialog" onClick={e => e.stopPropagation()}>
|
||||||
|
<button className="close-btn" onClick={onCancel}>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
{title && <div className="dialog-title">{title}</div>}
|
||||||
|
<div className="dialog-content">
|
||||||
|
<p style={{ whiteSpace: 'pre-line' }}>{message}</p>
|
||||||
|
</div>
|
||||||
|
<div className="dialog-actions">
|
||||||
|
<button className="btn-cancel" onClick={onCancel}>取消</button>
|
||||||
|
<button className="btn-confirm" onClick={onConfirm}>开始获取</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -139,6 +139,18 @@
|
|||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,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)
|
||||||
|
|
||||||
// 点击外部关闭
|
// 点击外部关闭
|
||||||
@@ -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>
|
||||||
|
|||||||
41
src/components/ErrorBoundary.tsx
Normal file
41
src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Component, ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode
|
||||||
|
fallback?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean
|
||||||
|
error?: Error
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorBoundary extends Component<Props, State> {
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props)
|
||||||
|
this.state = { hasError: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { hasError: true, error }
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: any) {
|
||||||
|
console.error('ErrorBoundary caught:', error, errorInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return this.props.fallback || (
|
||||||
|
<div style={{ padding: '20px', textAlign: 'center', color: '#999' }}>
|
||||||
|
<p>消息渲染出错</p>
|
||||||
|
<p style={{ fontSize: '12px', marginTop: '8px' }}>
|
||||||
|
{this.state.error?.message || '未知错误'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children
|
||||||
|
}
|
||||||
|
}
|
||||||
337
src/components/Export/ExportDateRangeDialog.scss
Normal file
337
src/components/Export/ExportDateRangeDialog.scss
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
.export-date-range-dialog-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
z-index: 2400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-dialog {
|
||||||
|
width: min(480px, calc(100vw - 32px));
|
||||||
|
max-height: calc(100vh - 64px);
|
||||||
|
overflow-y: auto;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-secondary-solid, var(--bg-primary));
|
||||||
|
padding: 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
box-shadow: 0 22px 48px rgba(0, 0, 0, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-dialog-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-dialog-close-btn {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-preset-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
gap: 4px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
height: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-preset-item {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 0 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: rgba(var(--primary-rgb), 0.08);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-mode-banner {
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
|
||||||
|
&.range {
|
||||||
|
border-color: rgba(var(--primary-rgb), 0.4);
|
||||||
|
background: rgba(var(--primary-rgb), 0.1);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-boundary-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-boundary-card {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: rgba(var(--primary-rgb), 0.08);
|
||||||
|
box-shadow: 0 0 0 1px rgba(var(--primary-rgb), 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.boundary-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-selection-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-calendar-panel {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: linear-gradient(180deg, rgba(var(--primary-rgb), 0.04), transparent 28%), var(--bg-secondary);
|
||||||
|
padding: 10px;
|
||||||
|
|
||||||
|
&.single {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-calendar-panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-calendar-date-label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-date-input {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 9px;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 1px rgba(var(--primary-rgb), 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.invalid {
|
||||||
|
border-color: #e84d4d;
|
||||||
|
box-shadow: 0 0 0 1px rgba(232, 77, 77, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-calendar-nav {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
|
||||||
|
button {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-calendar-weekdays {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-calendar-days {
|
||||||
|
margin-top: 6px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-calendar-day {
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 10px;
|
||||||
|
min-height: 34px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease, transform 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: rgba(var(--primary-rgb), 0.28);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled:hover {
|
||||||
|
border-color: transparent;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.outside {
|
||||||
|
color: var(--text-quaternary);
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.35;
|
||||||
|
transform: none;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.in-range {
|
||||||
|
background: rgba(var(--primary-rgb), 0.1);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.range-start,
|
||||||
|
&.range-end {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active-boundary {
|
||||||
|
box-shadow: 0 0 0 2px rgba(var(--primary-rgb), 0.22);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-dialog-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-dialog-btn {
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&.primary {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.secondary {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.export-date-range-boundary-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
465
src/components/Export/ExportDateRangeDialog.tsx
Normal file
465
src/components/Export/ExportDateRangeDialog.tsx
Normal file
@@ -0,0 +1,465 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { Check, ChevronLeft, ChevronRight, X } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
EXPORT_DATE_RANGE_PRESETS,
|
||||||
|
WEEKDAY_SHORT_LABELS,
|
||||||
|
addMonths,
|
||||||
|
buildCalendarCells,
|
||||||
|
cloneExportDateRangeSelection,
|
||||||
|
createDateRangeByPreset,
|
||||||
|
createDefaultDateRange,
|
||||||
|
formatCalendarMonthTitle,
|
||||||
|
formatDateInputValue,
|
||||||
|
isSameDay,
|
||||||
|
parseDateInputValue,
|
||||||
|
startOfDay,
|
||||||
|
endOfDay,
|
||||||
|
toMonthStart,
|
||||||
|
type ExportDateRangePreset,
|
||||||
|
type ExportDateRangeSelection
|
||||||
|
} from '../../utils/exportDateRange'
|
||||||
|
import './ExportDateRangeDialog.scss'
|
||||||
|
|
||||||
|
interface ExportDateRangeDialogProps {
|
||||||
|
open: boolean
|
||||||
|
value: ExportDateRangeSelection
|
||||||
|
title?: string
|
||||||
|
minDate?: Date | null
|
||||||
|
maxDate?: Date | null
|
||||||
|
onClose: () => void
|
||||||
|
onConfirm: (value: ExportDateRangeSelection) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActiveBoundary = 'start' | 'end'
|
||||||
|
|
||||||
|
interface ExportDateRangeDialogDraft extends ExportDateRangeSelection {
|
||||||
|
panelMonth: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveBounds = (minDate?: Date | null, maxDate?: Date | null): { minDate: Date; maxDate: Date } | null => {
|
||||||
|
if (!(minDate instanceof Date) || Number.isNaN(minDate.getTime())) return null
|
||||||
|
if (!(maxDate instanceof Date) || Number.isNaN(maxDate.getTime())) return null
|
||||||
|
const normalizedMin = startOfDay(minDate)
|
||||||
|
const normalizedMax = endOfDay(maxDate)
|
||||||
|
if (normalizedMin.getTime() > normalizedMax.getTime()) return null
|
||||||
|
return {
|
||||||
|
minDate: normalizedMin,
|
||||||
|
maxDate: normalizedMax
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clampSelectionToBounds = (
|
||||||
|
value: ExportDateRangeSelection,
|
||||||
|
minDate?: Date | null,
|
||||||
|
maxDate?: Date | null
|
||||||
|
): ExportDateRangeSelection => {
|
||||||
|
const bounds = resolveBounds(minDate, maxDate)
|
||||||
|
if (!bounds) return cloneExportDateRangeSelection(value)
|
||||||
|
|
||||||
|
const rawStart = value.useAllTime ? bounds.minDate : startOfDay(value.dateRange.start)
|
||||||
|
const rawEnd = value.useAllTime ? bounds.maxDate : endOfDay(value.dateRange.end)
|
||||||
|
const nextStart = new Date(Math.min(Math.max(rawStart.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime()))
|
||||||
|
const nextEndCandidate = new Date(Math.min(Math.max(rawEnd.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime()))
|
||||||
|
const nextEnd = nextEndCandidate.getTime() < nextStart.getTime() ? endOfDay(nextStart) : nextEndCandidate
|
||||||
|
const changed = nextStart.getTime() !== rawStart.getTime() || nextEnd.getTime() !== rawEnd.getTime()
|
||||||
|
|
||||||
|
return {
|
||||||
|
preset: value.useAllTime ? value.preset : (changed ? 'custom' : value.preset),
|
||||||
|
useAllTime: value.useAllTime,
|
||||||
|
dateRange: {
|
||||||
|
start: nextStart,
|
||||||
|
end: nextEnd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildDialogDraft = (
|
||||||
|
value: ExportDateRangeSelection,
|
||||||
|
minDate?: Date | null,
|
||||||
|
maxDate?: Date | null
|
||||||
|
): ExportDateRangeDialogDraft => {
|
||||||
|
const nextValue = clampSelectionToBounds(value, minDate, maxDate)
|
||||||
|
return {
|
||||||
|
...nextValue,
|
||||||
|
panelMonth: toMonthStart(nextValue.dateRange.start)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExportDateRangeDialog({
|
||||||
|
open,
|
||||||
|
value,
|
||||||
|
title = '时间范围设置',
|
||||||
|
minDate,
|
||||||
|
maxDate,
|
||||||
|
onClose,
|
||||||
|
onConfirm
|
||||||
|
}: ExportDateRangeDialogProps) {
|
||||||
|
const [draft, setDraft] = useState<ExportDateRangeDialogDraft>(() => buildDialogDraft(value, minDate, maxDate))
|
||||||
|
const [activeBoundary, setActiveBoundary] = useState<ActiveBoundary>('start')
|
||||||
|
const [dateInput, setDateInput] = useState({
|
||||||
|
start: formatDateInputValue(value.dateRange.start),
|
||||||
|
end: formatDateInputValue(value.dateRange.end)
|
||||||
|
})
|
||||||
|
const [dateInputError, setDateInputError] = useState({ start: false, end: false })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const nextDraft = buildDialogDraft(value, minDate, maxDate)
|
||||||
|
setDraft(nextDraft)
|
||||||
|
setActiveBoundary('start')
|
||||||
|
setDateInput({
|
||||||
|
start: formatDateInputValue(nextDraft.dateRange.start),
|
||||||
|
end: formatDateInputValue(nextDraft.dateRange.end)
|
||||||
|
})
|
||||||
|
setDateInputError({ start: false, end: false })
|
||||||
|
}, [maxDate, minDate, open, value])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
setDateInput({
|
||||||
|
start: formatDateInputValue(draft.dateRange.start),
|
||||||
|
end: formatDateInputValue(draft.dateRange.end)
|
||||||
|
})
|
||||||
|
setDateInputError({ start: false, end: false })
|
||||||
|
}, [draft.dateRange.end.getTime(), draft.dateRange.start.getTime(), open])
|
||||||
|
|
||||||
|
const bounds = useMemo(() => resolveBounds(minDate, maxDate), [maxDate, minDate])
|
||||||
|
const clampStartDate = useCallback((targetDate: Date) => {
|
||||||
|
const start = startOfDay(targetDate)
|
||||||
|
if (!bounds) return start
|
||||||
|
if (start.getTime() < bounds.minDate.getTime()) return bounds.minDate
|
||||||
|
if (start.getTime() > bounds.maxDate.getTime()) return startOfDay(bounds.maxDate)
|
||||||
|
return start
|
||||||
|
}, [bounds])
|
||||||
|
const clampEndDate = useCallback((targetDate: Date) => {
|
||||||
|
const end = endOfDay(targetDate)
|
||||||
|
if (!bounds) return end
|
||||||
|
if (end.getTime() < bounds.minDate.getTime()) return endOfDay(bounds.minDate)
|
||||||
|
if (end.getTime() > bounds.maxDate.getTime()) return bounds.maxDate
|
||||||
|
return end
|
||||||
|
}, [bounds])
|
||||||
|
|
||||||
|
const setRangeStart = useCallback((targetDate: Date) => {
|
||||||
|
const start = clampStartDate(targetDate)
|
||||||
|
setDraft(prev => {
|
||||||
|
const nextEnd = prev.dateRange.end < start ? endOfDay(start) : prev.dateRange.end
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
preset: 'custom',
|
||||||
|
useAllTime: false,
|
||||||
|
dateRange: {
|
||||||
|
start,
|
||||||
|
end: nextEnd
|
||||||
|
},
|
||||||
|
panelMonth: toMonthStart(start)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [clampStartDate])
|
||||||
|
|
||||||
|
const setRangeEnd = useCallback((targetDate: Date) => {
|
||||||
|
const end = clampEndDate(targetDate)
|
||||||
|
setDraft(prev => {
|
||||||
|
const nextStart = prev.useAllTime ? clampStartDate(targetDate) : prev.dateRange.start
|
||||||
|
const nextEnd = end < nextStart ? endOfDay(nextStart) : end
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
preset: 'custom',
|
||||||
|
useAllTime: false,
|
||||||
|
dateRange: {
|
||||||
|
start: nextStart,
|
||||||
|
end: nextEnd
|
||||||
|
},
|
||||||
|
panelMonth: toMonthStart(targetDate)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [clampEndDate, clampStartDate])
|
||||||
|
|
||||||
|
const applyPreset = useCallback((preset: Exclude<ExportDateRangePreset, 'custom'>) => {
|
||||||
|
if (preset === 'all') {
|
||||||
|
const previewRange = bounds
|
||||||
|
? { start: bounds.minDate, end: bounds.maxDate }
|
||||||
|
: createDefaultDateRange()
|
||||||
|
setDraft(prev => ({
|
||||||
|
...prev,
|
||||||
|
preset,
|
||||||
|
useAllTime: true,
|
||||||
|
dateRange: previewRange,
|
||||||
|
panelMonth: toMonthStart(previewRange.start)
|
||||||
|
}))
|
||||||
|
setActiveBoundary('start')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = clampSelectionToBounds({
|
||||||
|
preset,
|
||||||
|
useAllTime: false,
|
||||||
|
dateRange: createDateRangeByPreset(preset)
|
||||||
|
}, minDate, maxDate).dateRange
|
||||||
|
setDraft(prev => ({
|
||||||
|
...prev,
|
||||||
|
preset,
|
||||||
|
useAllTime: false,
|
||||||
|
dateRange: range,
|
||||||
|
panelMonth: toMonthStart(range.start)
|
||||||
|
}))
|
||||||
|
setActiveBoundary('start')
|
||||||
|
}, [bounds, maxDate, minDate])
|
||||||
|
|
||||||
|
const commitStartFromInput = useCallback(() => {
|
||||||
|
const parsed = parseDateInputValue(dateInput.start)
|
||||||
|
if (!parsed) {
|
||||||
|
setDateInputError(prev => ({ ...prev, start: true }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setDateInputError(prev => ({ ...prev, start: false }))
|
||||||
|
setRangeStart(parsed)
|
||||||
|
}, [dateInput.start, setRangeStart])
|
||||||
|
|
||||||
|
const commitEndFromInput = useCallback(() => {
|
||||||
|
const parsed = parseDateInputValue(dateInput.end)
|
||||||
|
if (!parsed) {
|
||||||
|
setDateInputError(prev => ({ ...prev, end: true }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setDateInputError(prev => ({ ...prev, end: false }))
|
||||||
|
setRangeEnd(parsed)
|
||||||
|
}, [dateInput.end, setRangeEnd])
|
||||||
|
|
||||||
|
const shiftPanelMonth = useCallback((delta: number) => {
|
||||||
|
setDraft(prev => ({
|
||||||
|
...prev,
|
||||||
|
panelMonth: addMonths(prev.panelMonth, delta)
|
||||||
|
}))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleCalendarSelect = useCallback((targetDate: Date) => {
|
||||||
|
if (activeBoundary === 'start') {
|
||||||
|
setRangeStart(targetDate)
|
||||||
|
setActiveBoundary('end')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setDraft(prev => {
|
||||||
|
const start = prev.useAllTime ? startOfDay(targetDate) : prev.dateRange.start
|
||||||
|
const pickedStart = startOfDay(targetDate)
|
||||||
|
const nextStart = pickedStart <= start ? pickedStart : start
|
||||||
|
const nextEnd = pickedStart <= start ? endOfDay(start) : endOfDay(targetDate)
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
preset: 'custom',
|
||||||
|
useAllTime: false,
|
||||||
|
dateRange: {
|
||||||
|
start: nextStart,
|
||||||
|
end: nextEnd
|
||||||
|
},
|
||||||
|
panelMonth: toMonthStart(targetDate)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setActiveBoundary('start')
|
||||||
|
}, [activeBoundary, setRangeEnd, setRangeStart])
|
||||||
|
|
||||||
|
const isRangeModeActive = !draft.useAllTime
|
||||||
|
const modeText = isRangeModeActive
|
||||||
|
? '当前导出模式:按时间范围导出'
|
||||||
|
: '当前导出模式:全部时间导出,选择下方日期会切换为自定义时间范围'
|
||||||
|
|
||||||
|
const isPresetActive = useCallback((preset: ExportDateRangePreset): boolean => {
|
||||||
|
if (preset === 'all') return draft.useAllTime
|
||||||
|
return !draft.useAllTime && draft.preset === preset
|
||||||
|
}, [draft])
|
||||||
|
|
||||||
|
const calendarCells = useMemo(() => buildCalendarCells(draft.panelMonth), [draft.panelMonth])
|
||||||
|
const minPanelMonth = bounds ? toMonthStart(bounds.minDate) : null
|
||||||
|
const maxPanelMonth = bounds ? toMonthStart(bounds.maxDate) : null
|
||||||
|
const canShiftPrev = !minPanelMonth || draft.panelMonth.getTime() > minPanelMonth.getTime()
|
||||||
|
const canShiftNext = !maxPanelMonth || draft.panelMonth.getTime() < maxPanelMonth.getTime()
|
||||||
|
|
||||||
|
const isStartSelected = useCallback((date: Date) => (
|
||||||
|
!draft.useAllTime && isSameDay(date, draft.dateRange.start)
|
||||||
|
), [draft])
|
||||||
|
|
||||||
|
const isEndSelected = useCallback((date: Date) => (
|
||||||
|
!draft.useAllTime && isSameDay(date, draft.dateRange.end)
|
||||||
|
), [draft])
|
||||||
|
|
||||||
|
const isDateInRange = useCallback((date: Date) => (
|
||||||
|
!draft.useAllTime &&
|
||||||
|
startOfDay(date).getTime() >= startOfDay(draft.dateRange.start).getTime() &&
|
||||||
|
startOfDay(date).getTime() <= startOfDay(draft.dateRange.end).getTime()
|
||||||
|
), [draft])
|
||||||
|
|
||||||
|
const isDateSelectable = useCallback((date: Date) => {
|
||||||
|
if (!bounds) return true
|
||||||
|
const target = startOfDay(date).getTime()
|
||||||
|
return target >= startOfDay(bounds.minDate).getTime() && target <= startOfDay(bounds.maxDate).getTime()
|
||||||
|
}, [bounds])
|
||||||
|
|
||||||
|
const hintText = draft.useAllTime
|
||||||
|
? '选择开始或结束日期后,会自动切换为自定义时间范围'
|
||||||
|
: (activeBoundary === 'start' ? '下一次点击将设置开始日期' : '下一次点击将设置结束日期')
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="export-date-range-dialog-overlay" onClick={onClose}>
|
||||||
|
<div className="export-date-range-dialog" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
|
||||||
|
<div className="export-date-range-dialog-header">
|
||||||
|
<h4>{title}</h4>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="export-date-range-dialog-close-btn"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="关闭时间范围设置"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="export-date-range-preset-list">
|
||||||
|
{EXPORT_DATE_RANGE_PRESETS.map((preset) => {
|
||||||
|
const active = isPresetActive(preset.value)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={preset.value}
|
||||||
|
type="button"
|
||||||
|
className={`export-date-range-preset-item ${active ? 'active' : ''}`}
|
||||||
|
onClick={() => applyPreset(preset.value)}
|
||||||
|
>
|
||||||
|
<span>{preset.label}</span>
|
||||||
|
{active && <Check size={14} />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`export-date-range-mode-banner ${isRangeModeActive ? 'range' : 'all'}`}>
|
||||||
|
{modeText}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="export-date-range-boundary-row">
|
||||||
|
<div
|
||||||
|
className={`export-date-range-boundary-card ${activeBoundary === 'start' ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveBoundary('start')}
|
||||||
|
>
|
||||||
|
<span className="boundary-label">开始</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={`export-date-range-date-input ${dateInputError.start ? 'invalid' : ''}`}
|
||||||
|
value={dateInput.start}
|
||||||
|
placeholder="YYYY-MM-DD"
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextValue = event.target.value
|
||||||
|
setDateInput(prev => ({ ...prev, start: nextValue }))
|
||||||
|
if (dateInputError.start) {
|
||||||
|
setDateInputError(prev => ({ ...prev, start: false }))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onFocus={() => setActiveBoundary('start')}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key !== 'Enter') return
|
||||||
|
event.preventDefault()
|
||||||
|
commitStartFromInput()
|
||||||
|
}}
|
||||||
|
onBlur={commitStartFromInput}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`export-date-range-boundary-card ${activeBoundary === 'end' ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveBoundary('end')}
|
||||||
|
>
|
||||||
|
<span className="boundary-label">结束</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={`export-date-range-date-input ${dateInputError.end ? 'invalid' : ''}`}
|
||||||
|
value={dateInput.end}
|
||||||
|
placeholder="YYYY-MM-DD"
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextValue = event.target.value
|
||||||
|
setDateInput(prev => ({ ...prev, end: nextValue }))
|
||||||
|
if (dateInputError.end) {
|
||||||
|
setDateInputError(prev => ({ ...prev, end: false }))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onFocus={() => setActiveBoundary('end')}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key !== 'Enter') return
|
||||||
|
event.preventDefault()
|
||||||
|
commitEndFromInput()
|
||||||
|
}}
|
||||||
|
onBlur={commitEndFromInput}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="export-date-range-selection-hint">{hintText}</div>
|
||||||
|
|
||||||
|
<section className="export-date-range-calendar-panel single">
|
||||||
|
<div className="export-date-range-calendar-panel-header">
|
||||||
|
<div className="export-date-range-calendar-date-label">
|
||||||
|
<span>选择日期范围</span>
|
||||||
|
<strong>{formatCalendarMonthTitle(draft.panelMonth)}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="export-date-range-calendar-nav">
|
||||||
|
<button type="button" onClick={() => shiftPanelMonth(-1)} aria-label="上个月" disabled={!canShiftPrev}>
|
||||||
|
<ChevronLeft size={14} />
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => shiftPanelMonth(1)} aria-label="下个月" disabled={!canShiftNext}>
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="export-date-range-calendar-weekdays">
|
||||||
|
{WEEKDAY_SHORT_LABELS.map(label => (
|
||||||
|
<span key={`weekday-${label}`}>{label}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="export-date-range-calendar-days">
|
||||||
|
{calendarCells.map((cell) => {
|
||||||
|
const startSelected = isStartSelected(cell.date)
|
||||||
|
const endSelected = isEndSelected(cell.date)
|
||||||
|
const inRange = isDateInRange(cell.date)
|
||||||
|
const selectable = isDateSelectable(cell.date)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={cell.date.getTime()}
|
||||||
|
type="button"
|
||||||
|
disabled={!selectable}
|
||||||
|
className={[
|
||||||
|
'export-date-range-calendar-day',
|
||||||
|
cell.inCurrentMonth ? '' : 'outside',
|
||||||
|
selectable ? '' : 'disabled',
|
||||||
|
inRange ? 'in-range' : '',
|
||||||
|
startSelected ? 'range-start' : '',
|
||||||
|
endSelected ? 'range-end' : '',
|
||||||
|
activeBoundary === 'start' && startSelected ? 'active-boundary' : '',
|
||||||
|
activeBoundary === 'end' && endSelected ? 'active-boundary' : ''
|
||||||
|
].filter(Boolean).join(' ')}
|
||||||
|
onClick={() => handleCalendarSelect(cell.date)}
|
||||||
|
>
|
||||||
|
{cell.date.getDate()}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="export-date-range-dialog-actions">
|
||||||
|
<button type="button" className="export-date-range-dialog-btn secondary" onClick={onClose}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="export-date-range-dialog-btn primary"
|
||||||
|
onClick={() => onConfirm(cloneExportDateRangeSelection(draft))}
|
||||||
|
>
|
||||||
|
确认
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)
|
||||||
|
}
|
||||||
459
src/components/Export/ExportDefaultsSettingsForm.scss
Normal file
459
src/components/Export/ExportDefaultsSettingsForm.scss
Normal file
@@ -0,0 +1,459 @@
|
|||||||
|
.export-defaults-settings-form {
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-field {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-trigger {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.open {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-value {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: color-mix(in srgb, var(--bg-primary) 85%, var(--bg-secondary));
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 6px;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
z-index: 120;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
-webkit-backdrop-filter: blur(14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-option {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-label {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(156px, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-card {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
text-align: left;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: flex-start;
|
||||||
|
transition: border-color 0.2s ease, background 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: rgba(var(--primary-rgb), 0.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-desc {
|
||||||
|
margin-top: 1px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-option.active .option-desc {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-time-range-field {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-time-range-trigger {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: rgba(var(--primary-rgb), 0.45);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.open {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-time-range-value {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-time-range-arrow {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-toggle-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-default-grid {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='checkbox'] {
|
||||||
|
margin: 0;
|
||||||
|
accent-color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-status {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.concurrency-inline-options {
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.concurrency-option {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s ease, background 0.2s ease, color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: rgba(var(--primary-rgb), 0.08);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
width: 48px;
|
||||||
|
height: 28px;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-input {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
&:checked + .switch-slider {
|
||||||
|
background: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:checked + .switch-slider::before {
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus + .switch-slider {
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-slider {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 999px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
left: 3px;
|
||||||
|
top: 3px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.18);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.layout-split {
|
||||||
|
.form-group {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
|
||||||
|
gap: 18px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 14px 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group:first-child {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-copy {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
margin-bottom: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-field,
|
||||||
|
.settings-time-range-field {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 360px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-toggle-line {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 360px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-default-grid {
|
||||||
|
max-width: 360px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.concurrency-inline-options {
|
||||||
|
max-width: 360px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-setting-group {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-setting-group .form-control {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-grid {
|
||||||
|
max-width: none;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.export-defaults-settings-form.layout-split {
|
||||||
|
.media-setting-group {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-setting-group .form-control {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-default-grid {
|
||||||
|
max-width: none;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.export-defaults-settings-form.layout-split {
|
||||||
|
.form-group {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-field,
|
||||||
|
.settings-time-range-field,
|
||||||
|
.log-toggle-line,
|
||||||
|
.media-default-grid,
|
||||||
|
.concurrency-inline-options,
|
||||||
|
.format-grid {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-default-grid {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(156px, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
389
src/components/Export/ExportDefaultsSettingsForm.tsx
Normal file
389
src/components/Export/ExportDefaultsSettingsForm.tsx
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { ChevronDown } from 'lucide-react'
|
||||||
|
import * as configService from '../../services/config'
|
||||||
|
import { ExportDateRangeDialog } from './ExportDateRangeDialog'
|
||||||
|
import {
|
||||||
|
createDefaultExportDateRangeSelection,
|
||||||
|
getExportDateRangeLabel,
|
||||||
|
resolveExportDateRangeConfig,
|
||||||
|
serializeExportDateRangeConfig,
|
||||||
|
type ExportDateRangeSelection
|
||||||
|
} from '../../utils/exportDateRange'
|
||||||
|
import './ExportDefaultsSettingsForm.scss'
|
||||||
|
|
||||||
|
export interface ExportDefaultsSettingsPatch {
|
||||||
|
format?: string
|
||||||
|
avatars?: boolean
|
||||||
|
dateRange?: ExportDateRangeSelection
|
||||||
|
media?: configService.ExportDefaultMediaConfig
|
||||||
|
voiceAsText?: boolean
|
||||||
|
excelCompactColumns?: boolean
|
||||||
|
concurrency?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExportDefaultsSettingsFormProps {
|
||||||
|
onNotify?: (text: string, success: boolean) => void
|
||||||
|
onDefaultsChanged?: (patch: ExportDefaultsSettingsPatch) => void
|
||||||
|
layout?: 'stacked' | 'split'
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportFormatOptions = [
|
||||||
|
{ value: 'excel', label: 'Excel', desc: '电子表格,适合统计分析' },
|
||||||
|
{ value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' },
|
||||||
|
{ value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' },
|
||||||
|
{ value: 'txt', label: 'TXT', desc: '纯文本,通用格式' },
|
||||||
|
{ value: 'arkme-json', label: 'Arkme JSON', desc: '紧凑 JSON,支持 sender 去重与关系统计' },
|
||||||
|
{ value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' },
|
||||||
|
{ value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' },
|
||||||
|
{ value: 'weclone', label: 'WeClone CSV', desc: 'WeClone 兼容字段格式(CSV)' },
|
||||||
|
{ value: 'sql', label: 'PostgreSQL', desc: '数据库脚本,便于导入到数据库' }
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const exportExcelColumnOptions = [
|
||||||
|
{ value: 'compact', label: '精简列', desc: '序号、时间、发送者身份、消息类型、内容' },
|
||||||
|
{ value: 'full', label: '完整列', desc: '含发送者昵称/微信ID/备注' }
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const exportConcurrencyOptions = [1, 2, 3, 4, 5, 6] as const
|
||||||
|
|
||||||
|
const getOptionLabel = (options: ReadonlyArray<{ value: string; label: string }>, value: string) => {
|
||||||
|
return options.find((option) => option.value === value)?.label ?? value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExportDefaultsSettingsForm({
|
||||||
|
onNotify,
|
||||||
|
onDefaultsChanged,
|
||||||
|
layout = 'stacked'
|
||||||
|
}: ExportDefaultsSettingsFormProps) {
|
||||||
|
const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false)
|
||||||
|
const [isExportDateRangeDialogOpen, setIsExportDateRangeDialogOpen] = useState(false)
|
||||||
|
const exportExcelColumnsDropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const [exportDefaultFormat, setExportDefaultFormat] = useState('excel')
|
||||||
|
const [exportDefaultAvatars, setExportDefaultAvatars] = useState(true)
|
||||||
|
const [exportDefaultDateRange, setExportDefaultDateRange] = useState<ExportDateRangeSelection>(() => createDefaultExportDateRangeSelection())
|
||||||
|
const [exportDefaultMedia, setExportDefaultMedia] = useState<configService.ExportDefaultMediaConfig>({
|
||||||
|
images: true,
|
||||||
|
videos: true,
|
||||||
|
voices: true,
|
||||||
|
emojis: true
|
||||||
|
})
|
||||||
|
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false)
|
||||||
|
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
|
||||||
|
const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
void (async () => {
|
||||||
|
const [savedFormat, savedAvatars, savedDateRange, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedConcurrency] = await Promise.all([
|
||||||
|
configService.getExportDefaultFormat(),
|
||||||
|
configService.getExportDefaultAvatars(),
|
||||||
|
configService.getExportDefaultDateRange(),
|
||||||
|
configService.getExportDefaultMedia(),
|
||||||
|
configService.getExportDefaultVoiceAsText(),
|
||||||
|
configService.getExportDefaultExcelCompactColumns(),
|
||||||
|
configService.getExportDefaultConcurrency()
|
||||||
|
])
|
||||||
|
|
||||||
|
if (cancelled) return
|
||||||
|
|
||||||
|
setExportDefaultFormat(savedFormat || 'excel')
|
||||||
|
setExportDefaultAvatars(savedAvatars ?? true)
|
||||||
|
setExportDefaultDateRange(resolveExportDateRangeConfig(savedDateRange))
|
||||||
|
setExportDefaultMedia(savedMedia ?? {
|
||||||
|
images: true,
|
||||||
|
videos: true,
|
||||||
|
voices: true,
|
||||||
|
emojis: true
|
||||||
|
})
|
||||||
|
setExportDefaultVoiceAsText(savedVoiceAsText ?? false)
|
||||||
|
setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true)
|
||||||
|
setExportDefaultConcurrency(savedConcurrency ?? 2)
|
||||||
|
})()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
const target = e.target as Node
|
||||||
|
if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) {
|
||||||
|
setShowExportExcelColumnsSelect(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}, [showExportExcelColumnsSelect])
|
||||||
|
|
||||||
|
const exportExcelColumnsValue = exportDefaultExcelCompactColumns ? 'compact' : 'full'
|
||||||
|
const exportDateRangeLabel = useMemo(() => getExportDateRangeLabel(exportDefaultDateRange), [exportDefaultDateRange])
|
||||||
|
const exportExcelColumnsLabel = useMemo(() => getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue), [exportExcelColumnsValue])
|
||||||
|
|
||||||
|
const notify = (text: string, success = true) => {
|
||||||
|
onNotify?.(text, success)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`export-defaults-settings-form ${layout === 'split' ? 'layout-split' : 'layout-stacked'}`}>
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="form-copy">
|
||||||
|
<label>导出并发数</label>
|
||||||
|
<span className="form-hint">导出多个会话时的最大并发(1~6)</span>
|
||||||
|
</div>
|
||||||
|
<div className="form-control">
|
||||||
|
<div className="concurrency-inline-options" role="radiogroup" aria-label="导出并发数">
|
||||||
|
{exportConcurrencyOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option}
|
||||||
|
type="button"
|
||||||
|
className={`concurrency-option ${exportDefaultConcurrency === option ? 'active' : ''}`}
|
||||||
|
aria-pressed={exportDefaultConcurrency === option}
|
||||||
|
onClick={async () => {
|
||||||
|
setExportDefaultConcurrency(option)
|
||||||
|
await configService.setExportDefaultConcurrency(option)
|
||||||
|
onDefaultsChanged?.({ concurrency: option })
|
||||||
|
notify(`已将导出并发数设为 ${option}`, true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group format-setting-group">
|
||||||
|
<div className="form-copy">
|
||||||
|
<label>聊天消息默认导出格式</label>
|
||||||
|
<span className="form-hint">导出页面默认选中的格式</span>
|
||||||
|
</div>
|
||||||
|
<div className="form-control">
|
||||||
|
<div className="format-grid">
|
||||||
|
{exportFormatOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
className={`format-card ${exportDefaultFormat === option.value ? 'active' : ''}`}
|
||||||
|
onClick={async () => {
|
||||||
|
setExportDefaultFormat(option.value)
|
||||||
|
await configService.setExportDefaultFormat(option.value)
|
||||||
|
onDefaultsChanged?.({ format: option.value })
|
||||||
|
notify('已更新导出格式默认值', true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="format-label">{option.label}</span>
|
||||||
|
<span className="format-desc">{option.desc}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="form-copy">
|
||||||
|
<label>聊天消息导出带头像</label>
|
||||||
|
<span className="form-hint">开启后导出的聊天消息对应的文件中会带头像信息。</span>
|
||||||
|
</div>
|
||||||
|
<div className="form-control">
|
||||||
|
<div className="log-toggle-line">
|
||||||
|
<span className="log-status">{exportDefaultAvatars ? '已开启' : '已关闭'}</span>
|
||||||
|
<label className="switch" htmlFor="shared-export-default-avatars">
|
||||||
|
<input
|
||||||
|
id="shared-export-default-avatars"
|
||||||
|
className="switch-input"
|
||||||
|
type="checkbox"
|
||||||
|
checked={exportDefaultAvatars}
|
||||||
|
onChange={async (e) => {
|
||||||
|
const enabled = e.target.checked
|
||||||
|
setExportDefaultAvatars(enabled)
|
||||||
|
await configService.setExportDefaultAvatars(enabled)
|
||||||
|
onDefaultsChanged?.({ avatars: enabled })
|
||||||
|
notify(enabled ? '已开启聊天消息导出带头像' : '已关闭聊天消息导出带头像', true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="switch-slider" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="form-copy">
|
||||||
|
<label>默认导出时间范围</label>
|
||||||
|
<span className="form-hint">控制导出页面的默认时间选择</span>
|
||||||
|
</div>
|
||||||
|
<div className="form-control">
|
||||||
|
<div className="settings-time-range-field">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`settings-time-range-trigger ${isExportDateRangeDialogOpen ? 'open' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setShowExportExcelColumnsSelect(false)
|
||||||
|
setIsExportDateRangeDialogOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="settings-time-range-value">{exportDateRangeLabel}</span>
|
||||||
|
<span className="settings-time-range-arrow">></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ExportDateRangeDialog
|
||||||
|
open={isExportDateRangeDialogOpen}
|
||||||
|
value={exportDefaultDateRange}
|
||||||
|
onClose={() => setIsExportDateRangeDialogOpen(false)}
|
||||||
|
onConfirm={async (nextSelection) => {
|
||||||
|
setExportDefaultDateRange(nextSelection)
|
||||||
|
await configService.setExportDefaultDateRange(serializeExportDateRangeConfig(nextSelection))
|
||||||
|
onDefaultsChanged?.({ dateRange: nextSelection })
|
||||||
|
notify('已更新默认导出时间范围', true)
|
||||||
|
setIsExportDateRangeDialogOpen(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="form-copy">
|
||||||
|
<label>Excel 列显示</label>
|
||||||
|
<span className="form-hint">控制 Excel 导出的列字段</span>
|
||||||
|
</div>
|
||||||
|
<div className="form-control">
|
||||||
|
<div className="select-field" ref={exportExcelColumnsDropdownRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`select-trigger ${showExportExcelColumnsSelect ? 'open' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setShowExportExcelColumnsSelect(!showExportExcelColumnsSelect)
|
||||||
|
setIsExportDateRangeDialogOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="select-value">{exportExcelColumnsLabel}</span>
|
||||||
|
<ChevronDown size={16} />
|
||||||
|
</button>
|
||||||
|
{showExportExcelColumnsSelect && (
|
||||||
|
<div className="select-dropdown">
|
||||||
|
{exportExcelColumnOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
className={`select-option ${exportExcelColumnsValue === option.value ? 'active' : ''}`}
|
||||||
|
onClick={async () => {
|
||||||
|
const compact = option.value === 'compact'
|
||||||
|
setExportDefaultExcelCompactColumns(compact)
|
||||||
|
await configService.setExportDefaultExcelCompactColumns(compact)
|
||||||
|
onDefaultsChanged?.({ excelCompactColumns: compact })
|
||||||
|
notify(compact ? '已启用精简列' : '已启用完整列', true)
|
||||||
|
setShowExportExcelColumnsSelect(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="option-label">{option.label}</span>
|
||||||
|
<span className="option-desc">{option.desc}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group media-setting-group">
|
||||||
|
<div className="form-copy">
|
||||||
|
<label>默认导出媒体内容</label>
|
||||||
|
<span className="form-hint">控制图片、视频、语音、表情包的默认导出开关</span>
|
||||||
|
</div>
|
||||||
|
<div className="form-control">
|
||||||
|
<div className="media-default-grid">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={exportDefaultMedia.images}
|
||||||
|
onChange={async (e) => {
|
||||||
|
const next = { ...exportDefaultMedia, images: e.target.checked }
|
||||||
|
setExportDefaultMedia(next)
|
||||||
|
await configService.setExportDefaultMedia(next)
|
||||||
|
onDefaultsChanged?.({ media: next })
|
||||||
|
notify(`已${e.target.checked ? '开启' : '关闭'}默认导出图片`, true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
图片
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={exportDefaultMedia.voices}
|
||||||
|
onChange={async (e) => {
|
||||||
|
const next = { ...exportDefaultMedia, voices: e.target.checked }
|
||||||
|
setExportDefaultMedia(next)
|
||||||
|
await configService.setExportDefaultMedia(next)
|
||||||
|
onDefaultsChanged?.({ media: next })
|
||||||
|
notify(`已${e.target.checked ? '开启' : '关闭'}默认导出语音`, true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
语音
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={exportDefaultMedia.videos}
|
||||||
|
onChange={async (e) => {
|
||||||
|
const next = { ...exportDefaultMedia, videos: e.target.checked }
|
||||||
|
setExportDefaultMedia(next)
|
||||||
|
await configService.setExportDefaultMedia(next)
|
||||||
|
onDefaultsChanged?.({ media: next })
|
||||||
|
notify(`已${e.target.checked ? '开启' : '关闭'}默认导出视频`, true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
视频
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={exportDefaultMedia.emojis}
|
||||||
|
onChange={async (e) => {
|
||||||
|
const next = { ...exportDefaultMedia, emojis: e.target.checked }
|
||||||
|
setExportDefaultMedia(next)
|
||||||
|
await configService.setExportDefaultMedia(next)
|
||||||
|
onDefaultsChanged?.({ media: next })
|
||||||
|
notify(`已${e.target.checked ? '开启' : '关闭'}默认导出表情包`, true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
表情包
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="form-copy">
|
||||||
|
<label>默认语音转文字</label>
|
||||||
|
<span className="form-hint">导出时默认将语音转写为文字</span>
|
||||||
|
</div>
|
||||||
|
<div className="form-control">
|
||||||
|
<div className="log-toggle-line">
|
||||||
|
<span className="log-status">{exportDefaultVoiceAsText ? '已开启' : '已关闭'}</span>
|
||||||
|
<label className="switch" htmlFor="shared-export-default-voice-as-text">
|
||||||
|
<input
|
||||||
|
id="shared-export-default-voice-as-text"
|
||||||
|
className="switch-input"
|
||||||
|
type="checkbox"
|
||||||
|
checked={exportDefaultVoiceAsText}
|
||||||
|
onChange={async (e) => {
|
||||||
|
const enabled = e.target.checked
|
||||||
|
setExportDefaultVoiceAsText(enabled)
|
||||||
|
await configService.setExportDefaultVoiceAsText(enabled)
|
||||||
|
onDefaultsChanged?.({ voiceAsText: enabled })
|
||||||
|
notify(enabled ? '已开启默认语音转文字' : '已关闭默认语音转文字', true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="switch-slider" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { useChatStore } from '../stores/chatStore'
|
import { useChatStore } from '../stores/chatStore'
|
||||||
import type { ChatSession } from '../types/models'
|
import type { ChatSession, Message } from '../types/models'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
export function GlobalSessionMonitor() {
|
export function GlobalSessionMonitor() {
|
||||||
@@ -14,16 +14,15 @@ export function GlobalSessionMonitor() {
|
|||||||
} = useChatStore()
|
} = useChatStore()
|
||||||
|
|
||||||
const sessionsRef = useRef(sessions)
|
const sessionsRef = useRef(sessions)
|
||||||
|
|
||||||
// 保持 ref 同步
|
// 保持 ref 同步
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
sessionsRef.current = sessions
|
sessionsRef.current = sessions
|
||||||
}, [sessions])
|
}, [sessions])
|
||||||
|
|
||||||
// 去重辅助函数:获取消息 key
|
// 去重辅助函数:获取消息 key
|
||||||
const getMessageKey = (msg: any) => {
|
const getMessageKey = (msg: Message) => {
|
||||||
if (msg.localId && msg.localId > 0) return `l:${msg.localId}`
|
if (msg.messageKey) return msg.messageKey
|
||||||
return `t:${msg.createTime}:${msg.sortSeq || 0}:${msg.serverId || 0}`
|
return `fallback:${msg.serverId || 0}:${msg.createTime}:${msg.sortSeq || 0}:${msg.localId || 0}:${msg.senderUsername || ''}:${msg.localType || 0}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理数据库变更
|
// 处理数据库变更
|
||||||
@@ -49,7 +48,7 @@ export function GlobalSessionMonitor() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return () => { }
|
return () => { }
|
||||||
}, []) // 空依赖数组 - 主要是静态的
|
}, [])
|
||||||
|
|
||||||
const refreshSessions = async () => {
|
const refreshSessions = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -97,6 +96,10 @@ export function GlobalSessionMonitor() {
|
|||||||
if (!isCurrentSession && (!oldSession || newSession.lastTimestamp > oldSession.lastTimestamp)) {
|
if (!isCurrentSession && (!oldSession || newSession.lastTimestamp > oldSession.lastTimestamp)) {
|
||||||
// 这是新消息事件
|
// 这是新消息事件
|
||||||
|
|
||||||
|
// 免打扰、折叠群、折叠入口不弹通知
|
||||||
|
if (newSession.isMuted || newSession.isFolded) continue
|
||||||
|
if (newSession.username.toLowerCase().includes('placeholder_foldgroup')) continue
|
||||||
|
|
||||||
// 1. 群聊过滤自己发送的消息
|
// 1. 群聊过滤自己发送的消息
|
||||||
if (newSession.username.includes('@chatroom')) {
|
if (newSession.username.includes('@chatroom')) {
|
||||||
// 如果是自己发的消息,不弹通知
|
// 如果是自己发的消息,不弹通知
|
||||||
@@ -194,11 +197,12 @@ export function GlobalSessionMonitor() {
|
|||||||
// 尝试丰富或获取联系人详情
|
// 尝试丰富或获取联系人详情
|
||||||
const contact = await window.electronAPI.chat.getContact(newSession.username)
|
const contact = await window.electronAPI.chat.getContact(newSession.username)
|
||||||
if (contact) {
|
if (contact) {
|
||||||
if (contact.remark || contact.nickname) {
|
if (contact.remark || contact.nickName) {
|
||||||
title = contact.remark || contact.nickname
|
title = contact.remark || contact.nickName
|
||||||
}
|
}
|
||||||
if (contact.avatarUrl) {
|
const avatarResult = await window.electronAPI.chat.getContactAvatar(newSession.username)
|
||||||
avatarUrl = contact.avatarUrl
|
if (avatarResult?.avatarUrl) {
|
||||||
|
avatarUrl = avatarResult.avatarUrl
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 如果不在缓存/数据库中
|
// 如果不在缓存/数据库中
|
||||||
@@ -218,8 +222,11 @@ export function GlobalSessionMonitor() {
|
|||||||
if (title === newSession.username || title.startsWith('wxid_')) {
|
if (title === newSession.username || title.startsWith('wxid_')) {
|
||||||
const retried = await window.electronAPI.chat.getContact(newSession.username)
|
const retried = await window.electronAPI.chat.getContact(newSession.username)
|
||||||
if (retried) {
|
if (retried) {
|
||||||
title = retried.remark || retried.nickname || title
|
title = retried.remark || retried.nickName || title
|
||||||
avatarUrl = retried.avatarUrl || avatarUrl
|
const retriedAvatar = await window.electronAPI.chat.getContactAvatar(newSession.username)
|
||||||
|
if (retriedAvatar?.avatarUrl) {
|
||||||
|
avatarUrl = retriedAvatar.avatarUrl
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -253,13 +260,19 @@ export function GlobalSessionMonitor() {
|
|||||||
const handleActiveSessionRefresh = async (sessionId: string) => {
|
const handleActiveSessionRefresh = async (sessionId: string) => {
|
||||||
// 从 ChatPage 复制/调整的逻辑,以保持集中
|
// 从 ChatPage 复制/调整的逻辑,以保持集中
|
||||||
const state = useChatStore.getState()
|
const state = useChatStore.getState()
|
||||||
const lastMsg = state.messages[state.messages.length - 1]
|
const msgs = state.messages || []
|
||||||
|
const lastMsg = msgs[msgs.length - 1]
|
||||||
const minTime = lastMsg?.createTime || 0
|
const minTime = lastMsg?.createTime || 0
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await (window.electronAPI.chat as any).getNewMessages(sessionId, minTime)
|
const result = await (window.electronAPI.chat as any).getNewMessages(sessionId, minTime)
|
||||||
if (result.success && result.messages && result.messages.length > 0) {
|
if (result.success && result.messages && result.messages.length > 0) {
|
||||||
appendMessages(result.messages, false) // 追加到末尾
|
const latestMessages = useChatStore.getState().messages || []
|
||||||
|
const existingKeys = new Set(latestMessages.map(getMessageKey))
|
||||||
|
const newMessages = result.messages.filter((msg: Message) => !existingKeys.has(getMessageKey(msg)))
|
||||||
|
if (newMessages.length > 0) {
|
||||||
|
appendMessages(newMessages, false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('后台活跃会话刷新失败:', e)
|
console.warn('后台活跃会话刷新失败:', e)
|
||||||
|
|||||||
@@ -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,6 +109,70 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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 {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
|
|||||||
const isValidDate = (d: any) => d instanceof Date && !isNaN(d.getTime())
|
const isValidDate = (d: any) => d instanceof Date && !isNaN(d.getTime())
|
||||||
const [calendarDate, setCalendarDate] = useState(isValidDate(currentDate) ? new Date(currentDate) : new Date())
|
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
|
||||||
|
|
||||||
@@ -137,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
|
||||||
@@ -148,6 +149,31 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showYearMonthPicker ? (
|
||||||
|
<div className="year-month-picker">
|
||||||
|
<div className="year-selector">
|
||||||
|
<button className="nav-btn" onClick={() => setCalendarDate(new Date(calendarDate.getFullYear() - 1, calendarDate.getMonth(), 1))}>
|
||||||
|
<ChevronLeft size={16} />
|
||||||
|
</button>
|
||||||
|
<span className="year-label">{calendarDate.getFullYear()}年</span>
|
||||||
|
<button className="nav-btn" onClick={() => setCalendarDate(new Date(calendarDate.getFullYear() + 1, calendarDate.getMonth(), 1))}>
|
||||||
|
<ChevronRight size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="month-grid">
|
||||||
|
{['一月','二月','三月','四月','五月','六月','七月','八月','九月','十月','十一月','十二月'].map((name, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
className={`month-btn ${i === calendarDate.getMonth() ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setCalendarDate(new Date(calendarDate.getFullYear(), i, 1))
|
||||||
|
setShowYearMonthPicker(false)
|
||||||
|
}}
|
||||||
|
>{name}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className={`calendar-grid ${loadingDates ? 'loading' : ''}`}>
|
<div className={`calendar-grid ${loadingDates ? 'loading' : ''}`}>
|
||||||
{loadingDates && (
|
{loadingDates && (
|
||||||
<div className="calendar-loading">
|
<div className="calendar-loading">
|
||||||
@@ -174,6 +200,7 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="quick-options">
|
<div className="quick-options">
|
||||||
|
|||||||
170
src/components/JumpToDatePopover.scss
Normal file
170
src/components/JumpToDatePopover.scss
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
.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: var(--primary, #07c160);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-date-popover .day-cell.selected .day-count {
|
||||||
|
color: color-mix(in srgb, #ffffff 78%, var(--primary, #07c160) 22%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-date-popover .day-count-loading {
|
||||||
|
position: static;
|
||||||
|
margin-top: 1px;
|
||||||
|
color: var(--primary, #07c160);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-date-popover .day-cell.selected .day-count-loading {
|
||||||
|
color: color-mix(in srgb, #ffffff 78%, var(--primary, #07c160) 22%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-date-popover .spin {
|
||||||
|
animation: jump-date-spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes jump-date-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
191
src/components/JumpToDatePopover.tsx
Normal file
191
src/components/JumpToDatePopover.tsx
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'
|
||||||
|
import './JumpToDatePopover.scss'
|
||||||
|
|
||||||
|
interface JumpToDatePopoverProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onSelect: (date: Date) => void
|
||||||
|
onMonthChange?: (date: Date) => void
|
||||||
|
className?: string
|
||||||
|
style?: React.CSSProperties
|
||||||
|
currentDate?: Date
|
||||||
|
messageDates?: Set<string>
|
||||||
|
hasLoadedMessageDates?: boolean
|
||||||
|
messageDateCounts?: Record<string, number>
|
||||||
|
loadingDates?: boolean
|
||||||
|
loadingDateCounts?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSelect,
|
||||||
|
onMonthChange,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
currentDate = new Date(),
|
||||||
|
messageDates,
|
||||||
|
hasLoadedMessageDates = false,
|
||||||
|
messageDateCounts,
|
||||||
|
loadingDates = false,
|
||||||
|
loadingDateCounts = false
|
||||||
|
}) => {
|
||||||
|
const [calendarDate, setCalendarDate] = useState<Date>(new Date(currentDate))
|
||||||
|
const [selectedDate, setSelectedDate] = useState<Date>(new Date(currentDate))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return
|
||||||
|
const normalized = new Date(currentDate)
|
||||||
|
setCalendarDate(normalized)
|
||||||
|
setSelectedDate(normalized)
|
||||||
|
}, [isOpen, currentDate])
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
const getDaysInMonth = (date: Date): number => {
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = date.getMonth()
|
||||||
|
return new Date(year, month + 1, 0).getDate()
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFirstDayOfMonth = (date: Date): number => {
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = date.getMonth()
|
||||||
|
return new Date(year, month, 1).getDay()
|
||||||
|
}
|
||||||
|
|
||||||
|
const toDateKey = (day: number): string => {
|
||||||
|
const year = calendarDate.getFullYear()
|
||||||
|
const month = calendarDate.getMonth() + 1
|
||||||
|
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasMessage = (day: number): boolean => {
|
||||||
|
if (!hasLoadedMessageDates) return true
|
||||||
|
if (!messageDates || messageDates.size === 0) return false
|
||||||
|
return messageDates.has(toDateKey(day))
|
||||||
|
}
|
||||||
|
|
||||||
|
const isToday = (day: number): boolean => {
|
||||||
|
const today = new Date()
|
||||||
|
return day === today.getDate()
|
||||||
|
&& calendarDate.getMonth() === today.getMonth()
|
||||||
|
&& calendarDate.getFullYear() === today.getFullYear()
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSelected = (day: number): boolean => {
|
||||||
|
return day === selectedDate.getDate()
|
||||||
|
&& calendarDate.getMonth() === selectedDate.getMonth()
|
||||||
|
&& calendarDate.getFullYear() === selectedDate.getFullYear()
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateCalendar = (): Array<number | null> => {
|
||||||
|
const daysInMonth = getDaysInMonth(calendarDate)
|
||||||
|
const firstDay = getFirstDayOfMonth(calendarDate)
|
||||||
|
const days: Array<number | null> = []
|
||||||
|
|
||||||
|
for (let i = 0; i < firstDay; i++) {
|
||||||
|
days.push(null)
|
||||||
|
}
|
||||||
|
for (let i = 1; i <= daysInMonth; i++) {
|
||||||
|
days.push(i)
|
||||||
|
}
|
||||||
|
return days
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDateClick = (day: number) => {
|
||||||
|
if (hasLoadedMessageDates && !hasMessage(day)) return
|
||||||
|
const targetDate = new Date(calendarDate.getFullYear(), calendarDate.getMonth(), day)
|
||||||
|
setSelectedDate(targetDate)
|
||||||
|
onSelect(targetDate)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDayClassName = (day: number | null): string => {
|
||||||
|
if (day === null) return 'day-cell empty'
|
||||||
|
const classes = ['day-cell']
|
||||||
|
if (isToday(day)) classes.push('today')
|
||||||
|
if (isSelected(day)) classes.push('selected')
|
||||||
|
if (hasLoadedMessageDates && !hasMessage(day)) classes.push('no-message')
|
||||||
|
return classes.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
|
||||||
|
const days = generateCalendar()
|
||||||
|
const mergedClassName = ['jump-date-popover', className || ''].join(' ').trim()
|
||||||
|
const updateCalendarDate = (nextDate: Date) => {
|
||||||
|
setCalendarDate(nextDate)
|
||||||
|
onMonthChange?.(nextDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={mergedClassName} style={style} role="dialog" aria-label="跳转日期">
|
||||||
|
<div className="calendar-nav">
|
||||||
|
<button
|
||||||
|
className="nav-btn"
|
||||||
|
onClick={() => updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))}
|
||||||
|
aria-label="上一月"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={16} />
|
||||||
|
</button>
|
||||||
|
<span className="current-month">{calendarDate.getFullYear()}年{calendarDate.getMonth() + 1}月</span>
|
||||||
|
<button
|
||||||
|
className="nav-btn"
|
||||||
|
onClick={() => updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))}
|
||||||
|
aria-label="下一月"
|
||||||
|
>
|
||||||
|
<ChevronRight size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="status-line">
|
||||||
|
{loadingDates && (
|
||||||
|
<span className="status-item">
|
||||||
|
<Loader2 size={12} className="spin" />
|
||||||
|
<span>日期加载中</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!loadingDates && loadingDateCounts && (
|
||||||
|
<span className="status-item">
|
||||||
|
<Loader2 size={12} className="spin" />
|
||||||
|
<span>条数加载中</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="calendar-grid">
|
||||||
|
<div className="weekdays">
|
||||||
|
{weekdays.map(day => (
|
||||||
|
<div key={day} className="weekday">{day}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="days">
|
||||||
|
{days.map((day, index) => {
|
||||||
|
if (day === null) return <div key={index} className="day-cell empty" />
|
||||||
|
const dateKey = toDateKey(day)
|
||||||
|
const hasMessageOnDay = hasMessage(day)
|
||||||
|
const count = Number(messageDateCounts?.[dateKey] || 0)
|
||||||
|
const showCount = count > 0
|
||||||
|
const showCountLoading = hasMessageOnDay && loadingDateCounts && !showCount
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
className={getDayClassName(day)}
|
||||||
|
onClick={() => handleDateClick(day)}
|
||||||
|
disabled={hasLoadedMessageDates && !hasMessageOnDay}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="day-number">{day}</span>
|
||||||
|
{showCount && <span className="day-count">{count}</span>}
|
||||||
|
{showCountLoading && <Loader2 size={11} className="day-count-loading spin" />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default JumpToDatePopover
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import * as configService from '../services/config'
|
|
||||||
import { ArrowRight, Fingerprint, Lock, ScanFace, ShieldCheck } from 'lucide-react'
|
import { ArrowRight, Fingerprint, Lock, ScanFace, ShieldCheck } from 'lucide-react'
|
||||||
import './LockScreen.scss'
|
import './LockScreen.scss'
|
||||||
|
|
||||||
@@ -9,14 +8,6 @@ interface LockScreenProps {
|
|||||||
useHello?: boolean
|
useHello?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sha256(message: string) {
|
|
||||||
const msgBuffer = new TextEncoder().encode(message)
|
|
||||||
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer)
|
|
||||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
|
||||||
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
|
|
||||||
return hashHex
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function LockScreen({ onUnlock, avatar, useHello = false }: LockScreenProps) {
|
export default function LockScreen({ onUnlock, avatar, useHello = false }: LockScreenProps) {
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
@@ -49,19 +40,9 @@ export default function LockScreen({ onUnlock, avatar, useHello = false }: LockS
|
|||||||
|
|
||||||
const quickStartHello = async () => {
|
const quickStartHello = async () => {
|
||||||
try {
|
try {
|
||||||
// 如果父组件已经告诉我们要用 Hello,直接开始,不等待 IPC
|
if (useHello) {
|
||||||
let shouldUseHello = useHello
|
|
||||||
|
|
||||||
// 为了稳健,如果 prop 没传(虽然现在都传了),再 check 一次 config
|
|
||||||
if (!shouldUseHello) {
|
|
||||||
shouldUseHello = await configService.getAuthUseHello()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldUseHello) {
|
|
||||||
// 标记为可用,显示按钮
|
|
||||||
setHelloAvailable(true)
|
setHelloAvailable(true)
|
||||||
setShowHello(true)
|
setShowHello(true)
|
||||||
// 立即执行验证 (0延迟)
|
|
||||||
verifyHello()
|
verifyHello()
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -96,25 +77,19 @@ export default function LockScreen({ onUnlock, avatar, useHello = false }: LockS
|
|||||||
e?.preventDefault()
|
e?.preventDefault()
|
||||||
if (!password || isUnlocked) return
|
if (!password || isUnlocked) return
|
||||||
|
|
||||||
// 如果正在进行 Hello 验证,它会自动失败或被取代,UI上不用特意取消
|
|
||||||
// 因为 native 调用是模态的或者独立的,我们只要让 JS 状态不对锁住即可
|
|
||||||
|
|
||||||
// 不再检查 isVerifying,因为我们允许打断 Hello
|
|
||||||
setIsVerifying(true)
|
setIsVerifying(true)
|
||||||
setError('')
|
setError('')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const storedHash = await configService.getAuthPassword()
|
// 发送原始密码到主进程,由主进程验证并解密密钥
|
||||||
const inputHash = await sha256(password)
|
const result = await window.electronAPI.auth.unlock(password)
|
||||||
|
|
||||||
if (inputHash === storedHash) {
|
if (result.success) {
|
||||||
handleUnlock()
|
handleUnlock()
|
||||||
} else {
|
} else {
|
||||||
setError('密码错误')
|
setError(result.error || '密码错误')
|
||||||
setPassword('')
|
setPassword('')
|
||||||
setIsVerifying(false)
|
setIsVerifying(false)
|
||||||
// 如果密码错误,是否重新触发 Hello?
|
|
||||||
// 用户可能想重试密码,暂时不自动触发
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError('验证失败')
|
setError('验证失败')
|
||||||
|
|||||||
@@ -6,6 +6,15 @@
|
|||||||
backdrop-filter: blur(20px);
|
backdrop-filter: blur(20px);
|
||||||
-webkit-backdrop-filter: blur(20px);
|
-webkit-backdrop-filter: blur(20px);
|
||||||
border: 1px solid var(--border-light);
|
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;
|
border-radius: 12px;
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
@@ -39,12 +48,26 @@
|
|||||||
backdrop-filter: none !important;
|
backdrop-filter: none !important;
|
||||||
-webkit-backdrop-filter: none !important;
|
-webkit-backdrop-filter: none !important;
|
||||||
|
|
||||||
// Ensure background is solid
|
// 独立通知窗口:默认使用浅色模式硬编码值,确保不依赖 <html> 上的主题属性
|
||||||
background: var(--bg-secondary, #2c2c2c);
|
background: #ffffff;
|
||||||
color: var(--text-primary, #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
|
box-shadow: none !important; // NO SHADOW
|
||||||
border: 1px solid var(--border-light, rgba(255, 255, 255, 0.1));
|
border: 1px solid var(--border-light);
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
@@ -111,6 +134,25 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.top-center {
|
||||||
|
top: 24px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -20px) scale(0.95);
|
||||||
|
|
||||||
|
&.visible {
|
||||||
|
transform: translate(-50%, 0) scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 灵动岛样式
|
||||||
|
border-radius: 40px !important;
|
||||||
|
padding: 12px 16px;
|
||||||
|
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.2);
|
||||||
|
|
||||||
|
&.static {
|
||||||
|
border-radius: 40px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.16) !important;
|
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.16) !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ interface NotificationToastProps {
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
onClick: (sessionId: string) => void
|
onClick: (sessionId: string) => void
|
||||||
duration?: number
|
duration?: number
|
||||||
position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'
|
position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'
|
||||||
isStatic?: boolean
|
isStatic?: boolean
|
||||||
initialVisible?: boolean
|
initialVisible?: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,150 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-user-card-wrap {
|
||||||
|
position: relative;
|
||||||
|
margin: 0 12px 10px;
|
||||||
|
--sidebar-user-menu-width: 172px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-user-menu {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: auto;
|
||||||
|
bottom: calc(100% + 8px);
|
||||||
|
width: max(100%, var(--sidebar-user-menu-width));
|
||||||
|
z-index: 12;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--bg-secondary-solid, var(--bg-primary));
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px;
|
||||||
|
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px) scale(0.95);
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||||
|
|
||||||
|
&.open {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-user-menu-item {
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 9px 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.2s ease, color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.danger {
|
||||||
|
color: #d93025;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 59, 48, 0.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-user-card {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 56px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: rgba(99, 102, 241, 0.32);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.menu-open {
|
||||||
|
border-color: rgba(99, 102, 241, 0.44);
|
||||||
|
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: var(--on-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-meta {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-wxid {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu-caret {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
display: inline-flex;
|
||||||
|
transition: transform 0.2s ease, color 0.2s ease;
|
||||||
|
|
||||||
|
&.open {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.nav-menu {
|
.nav-menu {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -57,7 +214,7 @@
|
|||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
background: var(--primary);
|
background: var(--primary);
|
||||||
color: white;
|
color: var(--on-primary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,11 +227,44 @@
|
|||||||
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 12px;
|
padding: 0 12px;
|
||||||
border-top: 1px solid var(--border-color);
|
border-top: 1px solid var(--border-color);
|
||||||
@@ -85,22 +275,286 @@
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.collapse-btn {
|
.sidebar-dialog-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(15, 23, 42, 0.3);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 100%;
|
z-index: 1100;
|
||||||
padding: 8px;
|
padding: 20px;
|
||||||
border: none;
|
animation: fadeIn 0.2s ease;
|
||||||
background: transparent;
|
}
|
||||||
color: var(--text-tertiary);
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 9999px;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
margin-top: 4px;
|
|
||||||
|
|
||||||
&:hover {
|
@keyframes fadeIn {
|
||||||
background: var(--bg-tertiary);
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-dialog {
|
||||||
|
width: min(420px, 100%);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.24);
|
||||||
|
padding: 18px 18px 16px;
|
||||||
|
animation: slideUp 0.25s ease;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
p {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px) scale(0.95);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-wxid-list {
|
||||||
|
margin-top: 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-wxid-item {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
border-color: rgba(99, 102, 241, 0.32);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.current {
|
||||||
|
border-color: rgba(99, 102, 241, 0.5);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxid-avatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: var(--on-primary);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxid-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxid-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxid-id {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-badge {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--on-primary);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-dialog-actions {
|
||||||
|
margin-top: 18px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-clear-dialog-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(15, 23, 42, 0.3);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1100;
|
||||||
|
padding: 20px;
|
||||||
|
animation: fadeIn 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-clear-dialog {
|
||||||
|
width: min(460px, 100%);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.24);
|
||||||
|
padding: 18px 18px 16px;
|
||||||
|
animation: slideUp 0.25s ease;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-clear-options {
|
||||||
|
margin-top: 14px;
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-clear-actions {
|
||||||
|
margin-top: 18px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger {
|
||||||
|
border-color: #ef4444;
|
||||||
|
background: #ef4444;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 繁花如梦主题:侧边栏毛玻璃 + 激活项用主品牌色
|
||||||
|
[data-theme="blossom-dream"] .sidebar {
|
||||||
|
background: rgba(255, 255, 255, 0.6);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="blossom-dream"][data-mode="dark"] .sidebar {
|
||||||
|
background: rgba(34, 30, 36, 0.75);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 激活项:主品牌色纵向微渐变
|
||||||
|
[data-theme="blossom-dream"] .nav-item.active {
|
||||||
|
background: linear-gradient(180deg, #D4849A 0%, #C4748A 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 深色激活项:用藕粉色,背景深灰底 + 粉色文字/图标(高阶玩法)
|
||||||
|
[data-theme="blossom-dream"][data-mode="dark"] .nav-item.active {
|
||||||
|
background: rgba(209, 158, 187, 0.15);
|
||||||
|
color: #D19EBB;
|
||||||
|
border: 1px solid rgba(209, 158, 187, 0.2);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,142 +1,585 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { NavLink, useLocation } from 'react-router-dom'
|
import { NavLink, useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Aperture, UserCircle, Lock } from 'lucide-react'
|
import { Home, MessageSquare, BarChart3, FileText, Settings, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, RefreshCw } from 'lucide-react'
|
||||||
import { useAppStore } from '../stores/appStore'
|
import { useAppStore } from '../stores/appStore'
|
||||||
|
import { useChatStore } from '../stores/chatStore'
|
||||||
|
import { useAnalyticsStore } from '../stores/analyticsStore'
|
||||||
import * as configService from '../services/config'
|
import * as configService from '../services/config'
|
||||||
|
import { onExportSessionStatus, requestExportSessionStatus } from '../services/exportBridge'
|
||||||
|
import { UserRound } from 'lucide-react'
|
||||||
|
|
||||||
import './Sidebar.scss'
|
import './Sidebar.scss'
|
||||||
|
|
||||||
function Sidebar() {
|
interface SidebarUserProfile {
|
||||||
|
wxid: string
|
||||||
|
displayName: string
|
||||||
|
alias?: string
|
||||||
|
avatarUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1'
|
||||||
|
const ACCOUNT_PROFILES_CACHE_KEY = 'account_profiles_cache_v1'
|
||||||
|
|
||||||
|
interface SidebarUserProfileCache extends SidebarUserProfile {
|
||||||
|
updatedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AccountProfilesCache {
|
||||||
|
[wxid: string]: {
|
||||||
|
displayName: string
|
||||||
|
avatarUrl?: string
|
||||||
|
alias?: string
|
||||||
|
updatedAt: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WxidOption {
|
||||||
|
wxid: string
|
||||||
|
modifiedTime: number
|
||||||
|
nickname?: string
|
||||||
|
displayName?: string
|
||||||
|
avatarUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const readSidebarUserProfileCache = (): SidebarUserProfile | null => {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(SIDEBAR_USER_PROFILE_CACHE_KEY)
|
||||||
|
if (!raw) return null
|
||||||
|
const parsed = JSON.parse(raw) as SidebarUserProfileCache
|
||||||
|
if (!parsed || typeof parsed !== 'object') return null
|
||||||
|
if (!parsed.wxid || !parsed.displayName) return null
|
||||||
|
return {
|
||||||
|
wxid: parsed.wxid,
|
||||||
|
displayName: parsed.displayName,
|
||||||
|
alias: parsed.alias,
|
||||||
|
avatarUrl: parsed.avatarUrl
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const writeSidebarUserProfileCache = (profile: SidebarUserProfile): void => {
|
||||||
|
if (!profile.wxid || !profile.displayName) return
|
||||||
|
try {
|
||||||
|
const payload: SidebarUserProfileCache = {
|
||||||
|
...profile,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
}
|
||||||
|
window.localStorage.setItem(SIDEBAR_USER_PROFILE_CACHE_KEY, JSON.stringify(payload))
|
||||||
|
|
||||||
|
// 同时写入账号缓存池
|
||||||
|
const accountsCache = readAccountProfilesCache()
|
||||||
|
accountsCache[profile.wxid] = {
|
||||||
|
displayName: profile.displayName,
|
||||||
|
avatarUrl: profile.avatarUrl,
|
||||||
|
alias: profile.alias,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
}
|
||||||
|
window.localStorage.setItem(ACCOUNT_PROFILES_CACHE_KEY, JSON.stringify(accountsCache))
|
||||||
|
} catch {
|
||||||
|
// 忽略本地缓存失败,不影响主流程
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const readAccountProfilesCache = (): AccountProfilesCache => {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(ACCOUNT_PROFILES_CACHE_KEY)
|
||||||
|
if (!raw) return {}
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
return typeof parsed === 'object' && parsed ? parsed : {}
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeAccountId = (value?: string | null): string => {
|
||||||
|
const trimmed = String(value || '').trim()
|
||||||
|
if (!trimmed) return ''
|
||||||
|
if (trimmed.toLowerCase().startsWith('wxid_')) {
|
||||||
|
const match = trimmed.match(/^(wxid_[^_]+)/i)
|
||||||
|
return match?.[1] || trimmed
|
||||||
|
}
|
||||||
|
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
||||||
|
return suffixMatch ? suffixMatch[1] : trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
collapsed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function Sidebar({ collapsed }: SidebarProps) {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const [collapsed, setCollapsed] = useState(false)
|
const navigate = useNavigate()
|
||||||
const [authEnabled, setAuthEnabled] = 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 [showSwitchAccountDialog, setShowSwitchAccountDialog] = useState(false)
|
||||||
|
const [wxidOptions, setWxidOptions] = useState<WxidOption[]>([])
|
||||||
|
const [isSwitchingAccount, setIsSwitchingAccount] = useState(false)
|
||||||
|
const accountCardWrapRef = useRef<HTMLDivElement | null>(null)
|
||||||
const setLocked = useAppStore(state => state.setLocked)
|
const setLocked = useAppStore(state => state.setLocked)
|
||||||
|
const isDbConnected = useAppStore(state => state.isDbConnected)
|
||||||
|
const resetChatStore = useChatStore(state => state.reset)
|
||||||
|
const clearAnalyticsStoreCache = useAnalyticsStore(state => state.clearCache)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
configService.getAuthEnabled().then(setAuthEnabled)
|
window.electronAPI.auth.verifyEnabled().then(setAuthEnabled)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (!isAccountMenuOpen) return
|
||||||
|
const target = event.target as Node | null
|
||||||
|
if (accountCardWrapRef.current && target && !accountCardWrapRef.current.contains(target)) {
|
||||||
|
setIsAccountMenuOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}, [isAccountMenuOpen])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = onExportSessionStatus((payload) => {
|
||||||
|
const countFromPayload = typeof payload?.activeTaskCount === 'number'
|
||||||
|
? payload.activeTaskCount
|
||||||
|
: Array.isArray(payload?.inProgressSessionIds)
|
||||||
|
? payload.inProgressSessionIds.length
|
||||||
|
: 0
|
||||||
|
const normalized = Math.max(0, Math.floor(countFromPayload))
|
||||||
|
setActiveExportTaskCount(normalized)
|
||||||
|
})
|
||||||
|
|
||||||
|
requestExportSessionStatus()
|
||||||
|
const timer = window.setTimeout(() => requestExportSessionStatus(), 120)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe()
|
||||||
|
window.clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadCurrentUser = async () => {
|
||||||
|
const patchUserProfile = (patch: Partial<SidebarUserProfile>, expectedWxid?: string) => {
|
||||||
|
setUserProfile(prev => {
|
||||||
|
if (expectedWxid && prev.wxid && prev.wxid !== expectedWxid) {
|
||||||
|
return prev
|
||||||
|
}
|
||||||
|
const next: SidebarUserProfile = {
|
||||||
|
...prev,
|
||||||
|
...patch
|
||||||
|
}
|
||||||
|
if (!next.displayName) {
|
||||||
|
next.displayName = next.wxid || '未识别用户'
|
||||||
|
}
|
||||||
|
writeSidebarUserProfileCache(next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const wxid = await configService.getMyWxid()
|
||||||
|
const resolvedWxidRaw = String(wxid || '').trim()
|
||||||
|
const cleanedWxid = normalizeAccountId(resolvedWxidRaw)
|
||||||
|
const resolvedWxid = cleanedWxid || resolvedWxidRaw
|
||||||
|
|
||||||
|
if (!resolvedWxidRaw && !resolvedWxid) return
|
||||||
|
|
||||||
|
const wxidCandidates = new Set<string>([
|
||||||
|
resolvedWxidRaw.toLowerCase(),
|
||||||
|
resolvedWxid.trim().toLowerCase(),
|
||||||
|
cleanedWxid.trim().toLowerCase()
|
||||||
|
].filter(Boolean))
|
||||||
|
|
||||||
|
const normalizeName = (value?: string | null): string | undefined => {
|
||||||
|
if (!value) return undefined
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (!trimmed) return undefined
|
||||||
|
const lowered = trimmed.toLowerCase()
|
||||||
|
if (lowered === 'self') return undefined
|
||||||
|
if (lowered.startsWith('wxid_')) return undefined
|
||||||
|
if (wxidCandidates.has(lowered)) return undefined
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickFirstValidName = (...candidates: Array<string | null | undefined>): string | undefined => {
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const normalized = normalizeName(candidate)
|
||||||
|
if (normalized) return normalized
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// 并行获取名称和头像
|
||||||
|
const [contactResult, avatarResult] = await Promise.allSettled([
|
||||||
|
(async () => {
|
||||||
|
const candidates = Array.from(new Set([resolvedWxidRaw, resolvedWxid, cleanedWxid].filter(Boolean)))
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const contact = await window.electronAPI.chat.getContact(candidate)
|
||||||
|
if (contact?.remark || contact?.nickName || contact?.alias) {
|
||||||
|
return contact
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})(),
|
||||||
|
window.electronAPI.chat.getMyAvatarUrl()
|
||||||
|
])
|
||||||
|
|
||||||
|
const myContact = contactResult.status === 'fulfilled' ? contactResult.value : null
|
||||||
|
const displayName = pickFirstValidName(
|
||||||
|
myContact?.remark,
|
||||||
|
myContact?.nickName,
|
||||||
|
myContact?.alias
|
||||||
|
) || resolvedWxid || '未识别用户'
|
||||||
|
|
||||||
|
patchUserProfile({
|
||||||
|
wxid: resolvedWxid,
|
||||||
|
displayName,
|
||||||
|
alias: myContact?.alias,
|
||||||
|
avatarUrl: avatarResult.status === 'fulfilled' && avatarResult.value.success
|
||||||
|
? avatarResult.value.avatarUrl
|
||||||
|
: undefined
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载侧边栏用户信息失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cachedProfile = readSidebarUserProfileCache()
|
||||||
|
if (cachedProfile) {
|
||||||
|
setUserProfile(cachedProfile)
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadCurrentUser()
|
||||||
|
const onWxidChanged = () => { void loadCurrentUser() }
|
||||||
|
window.addEventListener('wxid-changed', onWxidChanged as EventListener)
|
||||||
|
return () => window.removeEventListener('wxid-changed', onWxidChanged as EventListener)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const getAvatarLetter = (name: string): string => {
|
||||||
|
if (!name) return '?'
|
||||||
|
return [...name][0] || '?'
|
||||||
|
}
|
||||||
|
|
||||||
|
const openSwitchAccountDialog = async () => {
|
||||||
|
setIsAccountMenuOpen(false)
|
||||||
|
if (!isDbConnected) {
|
||||||
|
window.alert('数据库未连接,无法切换账号')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const dbPath = await configService.getDbPath()
|
||||||
|
if (!dbPath) {
|
||||||
|
window.alert('请先在设置中配置数据库路径')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const wxids = await window.electronAPI.dbPath.scanWxids(dbPath)
|
||||||
|
const accountsCache = readAccountProfilesCache()
|
||||||
|
console.log('[切换账号] 账号缓存:', accountsCache)
|
||||||
|
|
||||||
|
const enrichedWxids = wxids.map((option: WxidOption) => {
|
||||||
|
const normalizedWxid = normalizeAccountId(option.wxid)
|
||||||
|
const cached = accountsCache[option.wxid] || accountsCache[normalizedWxid]
|
||||||
|
|
||||||
|
let displayName = option.nickname || option.wxid
|
||||||
|
let avatarUrl = option.avatarUrl
|
||||||
|
|
||||||
|
if (option.wxid === userProfile.wxid || normalizedWxid === userProfile.wxid) {
|
||||||
|
displayName = userProfile.displayName || displayName
|
||||||
|
avatarUrl = userProfile.avatarUrl || avatarUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
else if (cached) {
|
||||||
|
displayName = cached.displayName || displayName
|
||||||
|
avatarUrl = cached.avatarUrl || avatarUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...option,
|
||||||
|
displayName,
|
||||||
|
avatarUrl
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
setWxidOptions(enrichedWxids)
|
||||||
|
setShowSwitchAccountDialog(true)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('扫描账号失败:', error)
|
||||||
|
window.alert('扫描账号失败,请稍后重试')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSwitchAccount = async (selectedWxid: string) => {
|
||||||
|
if (!selectedWxid || isSwitchingAccount) return
|
||||||
|
setIsSwitchingAccount(true)
|
||||||
|
try {
|
||||||
|
console.log('[切换账号] 开始切换到:', selectedWxid)
|
||||||
|
const currentWxid = userProfile.wxid
|
||||||
|
if (currentWxid === selectedWxid) {
|
||||||
|
console.log('[切换账号] 已经是当前账号,跳过')
|
||||||
|
setShowSwitchAccountDialog(false)
|
||||||
|
setIsSwitchingAccount(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[切换账号] 设置新 wxid')
|
||||||
|
await configService.setMyWxid(selectedWxid)
|
||||||
|
|
||||||
|
console.log('[切换账号] 获取账号配置')
|
||||||
|
const wxidConfig = await configService.getWxidConfig(selectedWxid)
|
||||||
|
console.log('[切换账号] 配置内容:', wxidConfig)
|
||||||
|
if (wxidConfig?.decryptKey) {
|
||||||
|
console.log('[切换账号] 设置 decryptKey')
|
||||||
|
await configService.setDecryptKey(wxidConfig.decryptKey)
|
||||||
|
}
|
||||||
|
if (typeof wxidConfig?.imageXorKey === 'number') {
|
||||||
|
console.log('[切换账号] 设置 imageXorKey:', wxidConfig.imageXorKey)
|
||||||
|
await configService.setImageXorKey(wxidConfig.imageXorKey)
|
||||||
|
}
|
||||||
|
if (wxidConfig?.imageAesKey) {
|
||||||
|
console.log('[切换账号] 设置 imageAesKey')
|
||||||
|
await configService.setImageAesKey(wxidConfig.imageAesKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[切换账号] 检查数据库连接状态')
|
||||||
|
console.log('[切换账号] 数据库连接状态:', isDbConnected)
|
||||||
|
if (isDbConnected) {
|
||||||
|
console.log('[切换账号] 关闭数据库连接')
|
||||||
|
await window.electronAPI.chat.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[切换账号] 清除缓存')
|
||||||
|
window.localStorage.removeItem(SIDEBAR_USER_PROFILE_CACHE_KEY)
|
||||||
|
clearAnalyticsStoreCache()
|
||||||
|
resetChatStore()
|
||||||
|
|
||||||
|
console.log('[切换账号] 触发 wxid-changed 事件')
|
||||||
|
window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: selectedWxid } }))
|
||||||
|
|
||||||
|
console.log('[切换账号] 切换成功')
|
||||||
|
setShowSwitchAccountDialog(false)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[切换账号] 失败:', error)
|
||||||
|
window.alert('切换账号失败,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
setIsSwitchingAccount(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openSettingsFromAccountMenu = () => {
|
||||||
|
setIsAccountMenuOpen(false)
|
||||||
|
navigate('/settings', {
|
||||||
|
state: {
|
||||||
|
backgroundLocation: location
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
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}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={`sidebar ${collapsed ? 'collapsed' : ''}`}>
|
<>
|
||||||
<nav className="nav-menu">
|
<aside className={`sidebar ${collapsed ? 'collapsed' : ''}`}>
|
||||||
{/* 首页 */}
|
<nav className="nav-menu">
|
||||||
<NavLink
|
{/* 首页 */}
|
||||||
to="/home"
|
<NavLink
|
||||||
className={`nav-item ${isActive('/home') ? 'active' : ''}`}
|
to="/home"
|
||||||
title={collapsed ? '首页' : undefined}
|
className={`nav-item ${isActive('/home') ? 'active' : ''}`}
|
||||||
>
|
title={collapsed ? '首页' : undefined}
|
||||||
<span className="nav-icon"><Home size={20} /></span>
|
>
|
||||||
<span className="nav-label">首页</span>
|
<span className="nav-icon"><Home size={20} /></span>
|
||||||
</NavLink>
|
<span className="nav-label">首页</span>
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
{/* 聊天 */}
|
{/* 聊天 */}
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/chat"
|
to="/chat"
|
||||||
className={`nav-item ${isActive('/chat') ? 'active' : ''}`}
|
className={`nav-item ${isActive('/chat') ? 'active' : ''}`}
|
||||||
title={collapsed ? '聊天' : undefined}
|
title={collapsed ? '聊天' : undefined}
|
||||||
>
|
>
|
||||||
<span className="nav-icon"><MessageSquare size={20} /></span>
|
<span className="nav-icon"><MessageSquare size={20} /></span>
|
||||||
<span className="nav-label">聊天</span>
|
<span className="nav-label">聊天</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
{/* 朋友圈 */}
|
{/* 朋友圈 */}
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/sns"
|
to="/sns"
|
||||||
className={`nav-item ${isActive('/sns') ? 'active' : ''}`}
|
className={`nav-item ${isActive('/sns') ? 'active' : ''}`}
|
||||||
title={collapsed ? '朋友圈' : undefined}
|
title={collapsed ? '朋友圈' : undefined}
|
||||||
>
|
>
|
||||||
<span className="nav-icon"><Aperture size={20} /></span>
|
<span className="nav-icon"><Aperture size={20} /></span>
|
||||||
<span className="nav-label">朋友圈</span>
|
<span className="nav-label">朋友圈</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
{/* 通讯录 */}
|
{/* 通讯录 */}
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/contacts"
|
to="/contacts"
|
||||||
className={`nav-item ${isActive('/contacts') ? 'active' : ''}`}
|
className={`nav-item ${isActive('/contacts') ? 'active' : ''}`}
|
||||||
title={collapsed ? '通讯录' : undefined}
|
title={collapsed ? '通讯录' : undefined}
|
||||||
>
|
>
|
||||||
<span className="nav-icon"><UserCircle size={20} /></span>
|
<span className="nav-icon"><UserCircle size={20} /></span>
|
||||||
<span className="nav-label">通讯录</span>
|
<span className="nav-label">通讯录</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
{/* 私聊分析 */}
|
{/* 聊天分析 */}
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/analytics"
|
to="/analytics"
|
||||||
className={`nav-item ${isActive('/analytics') ? 'active' : ''}`}
|
className={`nav-item ${isActive('/analytics') ? 'active' : ''}`}
|
||||||
title={collapsed ? '私聊分析' : undefined}
|
title={collapsed ? '聊天分析' : undefined}
|
||||||
>
|
>
|
||||||
<span className="nav-icon"><BarChart3 size={20} /></span>
|
<span className="nav-icon"><BarChart3 size={20} /></span>
|
||||||
<span className="nav-label">私聊分析</span>
|
<span className="nav-label">聊天分析</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
{/* 群聊分析 */}
|
{/* 年度报告 */}
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/group-analytics"
|
to="/annual-report"
|
||||||
className={`nav-item ${isActive('/group-analytics') ? 'active' : ''}`}
|
className={`nav-item ${isActive('/annual-report') ? 'active' : ''}`}
|
||||||
title={collapsed ? '群聊分析' : undefined}
|
title={collapsed ? '年度报告' : undefined}
|
||||||
>
|
>
|
||||||
<span className="nav-icon"><Users size={20} /></span>
|
<span className="nav-icon"><FileText size={20} /></span>
|
||||||
<span className="nav-label">群聊分析</span>
|
<span className="nav-label">年度报告</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
{/* 年度报告 */}
|
{/* 导出 */}
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/annual-report"
|
to="/export"
|
||||||
className={`nav-item ${isActive('/annual-report') ? 'active' : ''}`}
|
className={`nav-item ${isActive('/export') ? 'active' : ''}`}
|
||||||
title={collapsed ? '年度报告' : undefined}
|
title={collapsed ? '导出' : undefined}
|
||||||
>
|
>
|
||||||
<span className="nav-icon"><FileText size={20} /></span>
|
<span className="nav-icon nav-icon-with-badge">
|
||||||
<span className="nav-label">年度报告</span>
|
<Download size={20} />
|
||||||
</NavLink>
|
{collapsed && activeExportTaskCount > 0 && (
|
||||||
|
<span className="nav-badge icon-badge">{exportTaskBadge}</span>
|
||||||
{/* 导出 */}
|
)}
|
||||||
<NavLink
|
</span>
|
||||||
to="/export"
|
<span className="nav-label">导出</span>
|
||||||
className={`nav-item ${isActive('/export') ? 'active' : ''}`}
|
{!collapsed && activeExportTaskCount > 0 && (
|
||||||
title={collapsed ? '导出' : undefined}
|
<span className="nav-badge">{exportTaskBadge}</span>
|
||||||
>
|
)}
|
||||||
<span className="nav-icon"><Download size={20} /></span>
|
</NavLink>
|
||||||
<span className="nav-label">导出</span>
|
|
||||||
</NavLink>
|
|
||||||
|
|
||||||
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="sidebar-footer">
|
<div className="sidebar-footer">
|
||||||
{authEnabled && (
|
|
||||||
<button
|
<button
|
||||||
className="nav-item"
|
className="nav-item"
|
||||||
onClick={() => setLocked(true)}
|
onClick={() => {
|
||||||
title={collapsed ? '锁定' : undefined}
|
if (authEnabled) {
|
||||||
|
setLocked(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
navigate('/settings', {
|
||||||
|
state: {
|
||||||
|
initialTab: 'security',
|
||||||
|
backgroundLocation: location
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
title={collapsed ? (authEnabled ? '锁定' : '未锁定') : undefined}
|
||||||
>
|
>
|
||||||
<span className="nav-icon"><Lock size={20} /></span>
|
<span className="nav-icon">{authEnabled ? <Lock size={20} /> : <LockOpen size={20} />}</span>
|
||||||
<span className="nav-label">锁定</span>
|
<span className="nav-label">{authEnabled ? '锁定' : '未锁定'}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
|
|
||||||
<NavLink
|
<div className="sidebar-user-card-wrap" ref={accountCardWrapRef}>
|
||||||
to="/settings"
|
<div className={`sidebar-user-menu ${isAccountMenuOpen ? 'open' : ''}`} role="menu" aria-label="账号菜单">
|
||||||
className={`nav-item ${isActive('/settings') ? 'active' : ''}`}
|
<button
|
||||||
title={collapsed ? '设置' : undefined}
|
className="sidebar-user-menu-item"
|
||||||
>
|
onClick={openSwitchAccountDialog}
|
||||||
<span className="nav-icon">
|
type="button"
|
||||||
<Settings size={20} />
|
role="menuitem"
|
||||||
</span>
|
>
|
||||||
<span className="nav-label">设置</span>
|
<RefreshCw size={14} />
|
||||||
</NavLink>
|
<span>切换账号</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="sidebar-user-menu-item"
|
||||||
|
onClick={openSettingsFromAccountMenu}
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
>
|
||||||
|
<Settings size={14} />
|
||||||
|
<span>设置</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`sidebar-user-card ${isAccountMenuOpen ? 'menu-open' : ''}`}
|
||||||
|
title={collapsed ? `${userProfile.displayName}${(userProfile.alias || userProfile.wxid) ? `\n${userProfile.alias || userProfile.wxid}` : ''}` : undefined}
|
||||||
|
onClick={() => setIsAccountMenuOpen(prev => !prev)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault()
|
||||||
|
setIsAccountMenuOpen(prev => !prev)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="user-avatar">
|
||||||
|
{userProfile.avatarUrl ? <img src={userProfile.avatarUrl} alt="" /> : <span>{getAvatarLetter(userProfile.displayName)}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="user-meta">
|
||||||
|
<div className="user-name">{userProfile.displayName}</div>
|
||||||
|
<div className="user-wxid">{userProfile.alias || userProfile.wxid || 'wxid 未识别'}</div>
|
||||||
|
</div>
|
||||||
|
{!collapsed && (
|
||||||
|
<span className={`user-menu-caret ${isAccountMenuOpen ? 'open' : ''}`}>
|
||||||
|
<ChevronUp size={14} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
<button
|
{showSwitchAccountDialog && (
|
||||||
className="collapse-btn"
|
<div className="sidebar-dialog-overlay" onClick={() => !isSwitchingAccount && setShowSwitchAccountDialog(false)}>
|
||||||
onClick={() => setCollapsed(!collapsed)}
|
<div className="sidebar-dialog" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
|
||||||
title={collapsed ? '展开菜单' : '收起菜单'}
|
<h3>切换账号</h3>
|
||||||
>
|
<p>选择要切换的微信账号</p>
|
||||||
{collapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
|
<div className="sidebar-wxid-list">
|
||||||
</button>
|
{wxidOptions.map((option) => (
|
||||||
</div>
|
<button
|
||||||
</aside>
|
key={option.wxid}
|
||||||
|
className={`sidebar-wxid-item ${userProfile.wxid === option.wxid ? 'current' : ''}`}
|
||||||
|
onClick={() => handleSwitchAccount(option.wxid)}
|
||||||
|
disabled={isSwitchingAccount}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div className="wxid-avatar">
|
||||||
|
{option.avatarUrl ? (
|
||||||
|
<img src={option.avatarUrl} alt="" />
|
||||||
|
) : (
|
||||||
|
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--bg-tertiary)', borderRadius: '6px', color: 'var(--text-tertiary)' }}>
|
||||||
|
<UserRound size={16} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="wxid-info">
|
||||||
|
<div className="wxid-name">{option.displayName}</div>
|
||||||
|
{option.displayName !== option.wxid && <div className="wxid-id">{option.wxid}</div>}
|
||||||
|
</div>
|
||||||
|
{userProfile.wxid === option.wxid && <span className="current-badge">当前</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="sidebar-dialog-actions">
|
||||||
|
<button type="button" onClick={() => setShowSwitchAccountDialog(false)} disabled={isSwitchingAccount}>取消</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
329
src/components/Sns/ContactSnsTimelineDialog.scss
Normal file
329
src/components/Sns/ContactSnsTimelineDialog.scss
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
.contact-sns-dialog-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1200;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px 16px;
|
||||||
|
background: rgba(15, 23, 42, 0.38);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog {
|
||||||
|
width: min(760px, 100%);
|
||||||
|
max-height: min(86vh, 860px);
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-secondary-solid, #ffffff);
|
||||||
|
box-shadow: 0 22px 46px rgba(0, 0, 0, 0.24);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.spin {
|
||||||
|
animation: contactSnsDialogSpin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-header-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-avatar {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-meta {
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-username {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-stats {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-rank-switch {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-rank-btn {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: var(--primary);
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 52%, var(--border-color));
|
||||||
|
background: color-mix(in srgb, var(--primary) 10%, var(--bg-primary));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-rank-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
right: 0;
|
||||||
|
width: 248px;
|
||||||
|
max-height: calc((28px * 15) + 16px);
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--primary) 30%, var(--border-color));
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
box-shadow: 0 14px 26px rgba(0, 0, 0, 0.18);
|
||||||
|
padding: 8px;
|
||||||
|
z-index: 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-rank-empty {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
line-height: 1.5;
|
||||||
|
text-align: center;
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-rank-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 4px 0 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-rank-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 20px minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: 7px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-rank-index {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-rank-name {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-rank-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-close-btn {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 7px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-tip {
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 88%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--bg-primary) 78%, var(--bg-secondary));
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px 16px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-posts-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-posts-list .post-header-actions {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-status {
|
||||||
|
padding: 20px 12px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
|
||||||
|
&.empty {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-load-more {
|
||||||
|
display: block;
|
||||||
|
margin: 12px auto 0;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 9px 18px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color));
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.contact-sns-dialog-overlay {
|
||||||
|
padding: 12px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog {
|
||||||
|
width: min(100vw - 16px, 760px);
|
||||||
|
max-height: calc(100vh - 24px);
|
||||||
|
|
||||||
|
.contact-sns-dialog-header {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-header-actions {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-rank-btn {
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-rank-panel {
|
||||||
|
width: min(78vw, 232px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-tip {
|
||||||
|
padding: 10px 12px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-sns-dialog-body {
|
||||||
|
padding: 10px 10px 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes contactSnsDialogSpin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
593
src/components/Sns/ContactSnsTimelineDialog.tsx
Normal file
593
src/components/Sns/ContactSnsTimelineDialog.tsx
Normal file
@@ -0,0 +1,593 @@
|
|||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { Loader2, X } from 'lucide-react'
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { SnsPostItem } from './SnsPostItem'
|
||||||
|
import type { SnsPost } from '../../types/sns'
|
||||||
|
import {
|
||||||
|
type ContactSnsRankItem,
|
||||||
|
type ContactSnsRankMode,
|
||||||
|
type ContactSnsTimelineTarget,
|
||||||
|
getAvatarLetter
|
||||||
|
} from './contactSnsTimeline'
|
||||||
|
import './ContactSnsTimelineDialog.scss'
|
||||||
|
|
||||||
|
const TIMELINE_PAGE_SIZE = 20
|
||||||
|
const SNS_RANK_PAGE_SIZE = 50
|
||||||
|
const SNS_RANK_DISPLAY_LIMIT = 15
|
||||||
|
|
||||||
|
interface ContactSnsRankCacheEntry {
|
||||||
|
likes: ContactSnsRankItem[]
|
||||||
|
comments: ContactSnsRankItem[]
|
||||||
|
totalPosts: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContactSnsTimelineDialogProps {
|
||||||
|
target: ContactSnsTimelineTarget | null
|
||||||
|
onClose: () => void
|
||||||
|
initialTotalPosts?: number | null
|
||||||
|
initialTotalPostsLoading?: boolean
|
||||||
|
isProtected?: boolean
|
||||||
|
onDeletePost?: (postId: string, username: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeTotalPosts = (value?: number | null): number | null => {
|
||||||
|
if (!Number.isFinite(value)) return null
|
||||||
|
return Math.max(0, Math.floor(Number(value)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatYmdDateFromSeconds = (timestamp?: number): string => {
|
||||||
|
if (!timestamp || !Number.isFinite(timestamp)) return '—'
|
||||||
|
const date = new Date(timestamp * 1000)
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = `${date.getMonth() + 1}`.padStart(2, '0')
|
||||||
|
const day = `${date.getDate()}`.padStart(2, '0')
|
||||||
|
return `${year}-${month}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildContactSnsRankings = (posts: SnsPost[]): { likes: ContactSnsRankItem[]; comments: ContactSnsRankItem[] } => {
|
||||||
|
const likeMap = new Map<string, ContactSnsRankItem>()
|
||||||
|
const commentMap = new Map<string, ContactSnsRankItem>()
|
||||||
|
|
||||||
|
for (const post of posts) {
|
||||||
|
const createTime = Number(post?.createTime) || 0
|
||||||
|
const likes = Array.isArray(post?.likes) ? post.likes : []
|
||||||
|
const comments = Array.isArray(post?.comments) ? post.comments : []
|
||||||
|
|
||||||
|
for (const likeNameRaw of likes) {
|
||||||
|
const name = String(likeNameRaw || '').trim() || '未知用户'
|
||||||
|
const current = likeMap.get(name)
|
||||||
|
if (current) {
|
||||||
|
current.count += 1
|
||||||
|
if (createTime > current.latestTime) current.latestTime = createTime
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
likeMap.set(name, { name, count: 1, latestTime: createTime })
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const comment of comments) {
|
||||||
|
const name = String(comment?.nickname || '').trim() || '未知用户'
|
||||||
|
const current = commentMap.get(name)
|
||||||
|
if (current) {
|
||||||
|
current.count += 1
|
||||||
|
if (createTime > current.latestTime) current.latestTime = createTime
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
commentMap.set(name, { name, count: 1, latestTime: createTime })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorter = (left: ContactSnsRankItem, right: ContactSnsRankItem): number => {
|
||||||
|
if (right.count !== left.count) return right.count - left.count
|
||||||
|
if (right.latestTime !== left.latestTime) return right.latestTime - left.latestTime
|
||||||
|
return left.name.localeCompare(right.name, 'zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
likes: [...likeMap.values()].sort(sorter),
|
||||||
|
comments: [...commentMap.values()].sort(sorter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContactSnsTimelineDialog({
|
||||||
|
target,
|
||||||
|
onClose,
|
||||||
|
initialTotalPosts = null,
|
||||||
|
initialTotalPostsLoading = false,
|
||||||
|
isProtected = false,
|
||||||
|
onDeletePost
|
||||||
|
}: ContactSnsTimelineDialogProps) {
|
||||||
|
const [timelinePosts, setTimelinePosts] = useState<SnsPost[]>([])
|
||||||
|
const [timelineLoading, setTimelineLoading] = useState(false)
|
||||||
|
const [timelineLoadingMore, setTimelineLoadingMore] = useState(false)
|
||||||
|
const [timelineHasMore, setTimelineHasMore] = useState(false)
|
||||||
|
const [timelineTotalPosts, setTimelineTotalPosts] = useState<number | null>(null)
|
||||||
|
const [timelineStatsLoading, setTimelineStatsLoading] = useState(false)
|
||||||
|
const [rankMode, setRankMode] = useState<ContactSnsRankMode | null>(null)
|
||||||
|
const [likeRankings, setLikeRankings] = useState<ContactSnsRankItem[]>([])
|
||||||
|
const [commentRankings, setCommentRankings] = useState<ContactSnsRankItem[]>([])
|
||||||
|
const [rankLoading, setRankLoading] = useState(false)
|
||||||
|
const [rankError, setRankError] = useState<string | null>(null)
|
||||||
|
const [rankLoadedPosts, setRankLoadedPosts] = useState(0)
|
||||||
|
const [rankTotalPosts, setRankTotalPosts] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const timelinePostsRef = useRef<SnsPost[]>([])
|
||||||
|
const timelineLoadingRef = useRef(false)
|
||||||
|
const timelineRequestTokenRef = useRef(0)
|
||||||
|
const totalPostsRequestTokenRef = useRef(0)
|
||||||
|
const rankRequestTokenRef = useRef(0)
|
||||||
|
const rankLoadingRef = useRef(false)
|
||||||
|
const rankCacheRef = useRef<Record<string, ContactSnsRankCacheEntry>>({})
|
||||||
|
|
||||||
|
const targetUsername = String(target?.username || '').trim()
|
||||||
|
const targetDisplayName = target?.displayName || targetUsername
|
||||||
|
const targetAvatarUrl = target?.avatarUrl
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
timelinePostsRef.current = timelinePosts
|
||||||
|
}, [timelinePosts])
|
||||||
|
|
||||||
|
const loadTimelinePosts = useCallback(async (nextTarget: ContactSnsTimelineTarget, options?: { reset?: boolean }) => {
|
||||||
|
const reset = Boolean(options?.reset)
|
||||||
|
if (timelineLoadingRef.current) return
|
||||||
|
|
||||||
|
timelineLoadingRef.current = true
|
||||||
|
if (reset) {
|
||||||
|
setTimelineLoading(true)
|
||||||
|
setTimelineLoadingMore(false)
|
||||||
|
setTimelineHasMore(false)
|
||||||
|
} else {
|
||||||
|
setTimelineLoadingMore(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestToken = ++timelineRequestTokenRef.current
|
||||||
|
|
||||||
|
try {
|
||||||
|
let endTime: number | undefined
|
||||||
|
if (!reset && timelinePostsRef.current.length > 0) {
|
||||||
|
endTime = timelinePostsRef.current[timelinePostsRef.current.length - 1].createTime - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await window.electronAPI.sns.getTimeline(
|
||||||
|
TIMELINE_PAGE_SIZE,
|
||||||
|
0,
|
||||||
|
[nextTarget.username],
|
||||||
|
'',
|
||||||
|
undefined,
|
||||||
|
endTime
|
||||||
|
)
|
||||||
|
if (requestToken !== timelineRequestTokenRef.current) return
|
||||||
|
|
||||||
|
if (!result.success || !Array.isArray(result.timeline)) {
|
||||||
|
if (reset) {
|
||||||
|
setTimelinePosts([])
|
||||||
|
setTimelineHasMore(false)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeline = [...(result.timeline as SnsPost[])].sort((left, right) => right.createTime - left.createTime)
|
||||||
|
if (reset) {
|
||||||
|
setTimelinePosts(timeline)
|
||||||
|
setTimelineHasMore(timeline.length >= TIMELINE_PAGE_SIZE)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingIds = new Set(timelinePostsRef.current.map((post) => post.id))
|
||||||
|
const uniqueOlder = timeline.filter((post) => !existingIds.has(post.id))
|
||||||
|
if (uniqueOlder.length > 0) {
|
||||||
|
const merged = [...timelinePostsRef.current, ...uniqueOlder].sort((left, right) => right.createTime - left.createTime)
|
||||||
|
setTimelinePosts(merged)
|
||||||
|
}
|
||||||
|
if (timeline.length < TIMELINE_PAGE_SIZE) {
|
||||||
|
setTimelineHasMore(false)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载联系人朋友圈失败:', error)
|
||||||
|
if (requestToken === timelineRequestTokenRef.current && reset) {
|
||||||
|
setTimelinePosts([])
|
||||||
|
setTimelineHasMore(false)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (requestToken === timelineRequestTokenRef.current) {
|
||||||
|
timelineLoadingRef.current = false
|
||||||
|
setTimelineLoading(false)
|
||||||
|
setTimelineLoadingMore(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadTimelineTotalPosts = useCallback(async (nextTarget: ContactSnsTimelineTarget) => {
|
||||||
|
const requestToken = ++totalPostsRequestTokenRef.current
|
||||||
|
setTimelineStatsLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.sns.getUserPostCounts()
|
||||||
|
if (requestToken !== totalPostsRequestTokenRef.current) return
|
||||||
|
|
||||||
|
if (!result.success || !result.counts) {
|
||||||
|
setTimelineTotalPosts(null)
|
||||||
|
setRankTotalPosts(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawCount = Number(result.counts[nextTarget.username] || 0)
|
||||||
|
const normalized = Number.isFinite(rawCount) ? Math.max(0, Math.floor(rawCount)) : 0
|
||||||
|
setTimelineTotalPosts(normalized)
|
||||||
|
setRankTotalPosts(normalized)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载联系人朋友圈条数失败:', error)
|
||||||
|
if (requestToken !== totalPostsRequestTokenRef.current) return
|
||||||
|
setTimelineTotalPosts(null)
|
||||||
|
setRankTotalPosts(null)
|
||||||
|
} finally {
|
||||||
|
if (requestToken === totalPostsRequestTokenRef.current) {
|
||||||
|
setTimelineStatsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadRankings = useCallback(async (nextTarget: ContactSnsTimelineTarget) => {
|
||||||
|
const normalizedUsername = String(nextTarget?.username || '').trim()
|
||||||
|
if (!normalizedUsername || rankLoadingRef.current) return
|
||||||
|
|
||||||
|
const normalizedKnownTotal = normalizeTotalPosts(timelineTotalPosts)
|
||||||
|
const cached = rankCacheRef.current[normalizedUsername]
|
||||||
|
|
||||||
|
if (cached && (normalizedKnownTotal === null || cached.totalPosts === normalizedKnownTotal)) {
|
||||||
|
setLikeRankings(cached.likes)
|
||||||
|
setCommentRankings(cached.comments)
|
||||||
|
setRankLoadedPosts(cached.totalPosts)
|
||||||
|
setRankTotalPosts(cached.totalPosts)
|
||||||
|
setRankError(null)
|
||||||
|
setRankLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rankLoadingRef.current = true
|
||||||
|
const requestToken = ++rankRequestTokenRef.current
|
||||||
|
setRankLoading(true)
|
||||||
|
setRankError(null)
|
||||||
|
setRankLoadedPosts(0)
|
||||||
|
setRankTotalPosts(normalizedKnownTotal)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const allPosts: SnsPost[] = []
|
||||||
|
let endTime: number | undefined
|
||||||
|
let hasMore = true
|
||||||
|
|
||||||
|
while (hasMore) {
|
||||||
|
const result = await window.electronAPI.sns.getTimeline(
|
||||||
|
SNS_RANK_PAGE_SIZE,
|
||||||
|
0,
|
||||||
|
[normalizedUsername],
|
||||||
|
'',
|
||||||
|
undefined,
|
||||||
|
endTime
|
||||||
|
)
|
||||||
|
if (requestToken !== rankRequestTokenRef.current) return
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || '加载朋友圈排行失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
const pagePosts = Array.isArray(result.timeline)
|
||||||
|
? [...(result.timeline as SnsPost[])].sort((left, right) => right.createTime - left.createTime)
|
||||||
|
: []
|
||||||
|
if (pagePosts.length === 0) {
|
||||||
|
hasMore = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
allPosts.push(...pagePosts)
|
||||||
|
setRankLoadedPosts(allPosts.length)
|
||||||
|
if (normalizedKnownTotal === null) {
|
||||||
|
setRankTotalPosts(allPosts.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
endTime = pagePosts[pagePosts.length - 1].createTime - 1
|
||||||
|
hasMore = pagePosts.length >= SNS_RANK_PAGE_SIZE
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestToken !== rankRequestTokenRef.current) return
|
||||||
|
|
||||||
|
const rankings = buildContactSnsRankings(allPosts)
|
||||||
|
const totalPosts = allPosts.length
|
||||||
|
rankCacheRef.current[normalizedUsername] = {
|
||||||
|
likes: rankings.likes,
|
||||||
|
comments: rankings.comments,
|
||||||
|
totalPosts
|
||||||
|
}
|
||||||
|
setLikeRankings(rankings.likes)
|
||||||
|
setCommentRankings(rankings.comments)
|
||||||
|
setRankLoadedPosts(totalPosts)
|
||||||
|
setRankTotalPosts(totalPosts)
|
||||||
|
setRankError(null)
|
||||||
|
} catch (error) {
|
||||||
|
if (requestToken !== rankRequestTokenRef.current) return
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
setLikeRankings([])
|
||||||
|
setCommentRankings([])
|
||||||
|
setRankError(message || '加载朋友圈排行失败')
|
||||||
|
} finally {
|
||||||
|
if (requestToken === rankRequestTokenRef.current) {
|
||||||
|
rankLoadingRef.current = false
|
||||||
|
setRankLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [timelineTotalPosts])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!targetUsername) return
|
||||||
|
|
||||||
|
totalPostsRequestTokenRef.current += 1
|
||||||
|
rankRequestTokenRef.current += 1
|
||||||
|
rankLoadingRef.current = false
|
||||||
|
setRankMode(null)
|
||||||
|
setLikeRankings([])
|
||||||
|
setCommentRankings([])
|
||||||
|
setRankLoading(false)
|
||||||
|
setRankError(null)
|
||||||
|
setRankLoadedPosts(0)
|
||||||
|
setRankTotalPosts(null)
|
||||||
|
setTimelinePosts([])
|
||||||
|
setTimelineTotalPosts(null)
|
||||||
|
setTimelineStatsLoading(false)
|
||||||
|
setTimelineHasMore(false)
|
||||||
|
setTimelineLoadingMore(false)
|
||||||
|
setTimelineLoading(false)
|
||||||
|
|
||||||
|
void loadTimelinePosts({
|
||||||
|
username: targetUsername,
|
||||||
|
displayName: targetDisplayName,
|
||||||
|
avatarUrl: targetAvatarUrl
|
||||||
|
}, { reset: true })
|
||||||
|
}, [loadTimelinePosts, targetAvatarUrl, targetDisplayName, targetUsername])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!targetUsername) return
|
||||||
|
|
||||||
|
const normalizedTotal = normalizeTotalPosts(initialTotalPosts)
|
||||||
|
if (normalizedTotal !== null) {
|
||||||
|
setTimelineTotalPosts(normalizedTotal)
|
||||||
|
setRankTotalPosts(normalizedTotal)
|
||||||
|
setTimelineStatsLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (initialTotalPostsLoading) {
|
||||||
|
setTimelineTotalPosts(null)
|
||||||
|
setRankTotalPosts(null)
|
||||||
|
setTimelineStatsLoading(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadTimelineTotalPosts({
|
||||||
|
username: targetUsername,
|
||||||
|
displayName: targetDisplayName,
|
||||||
|
avatarUrl: targetAvatarUrl
|
||||||
|
})
|
||||||
|
}, [
|
||||||
|
initialTotalPosts,
|
||||||
|
initialTotalPostsLoading,
|
||||||
|
loadTimelineTotalPosts,
|
||||||
|
targetAvatarUrl,
|
||||||
|
targetDisplayName,
|
||||||
|
targetUsername
|
||||||
|
])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (timelineTotalPosts === null) return
|
||||||
|
if (timelinePosts.length >= timelineTotalPosts) {
|
||||||
|
setTimelineHasMore(false)
|
||||||
|
}
|
||||||
|
}, [timelinePosts.length, timelineTotalPosts])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!rankMode || !targetUsername) return
|
||||||
|
void loadRankings({
|
||||||
|
username: targetUsername,
|
||||||
|
displayName: targetDisplayName,
|
||||||
|
avatarUrl: targetAvatarUrl
|
||||||
|
})
|
||||||
|
}, [loadRankings, rankMode, targetAvatarUrl, targetDisplayName, targetUsername])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!targetUsername) return
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [onClose, targetUsername])
|
||||||
|
|
||||||
|
const timelineStatsText = useMemo(() => {
|
||||||
|
const loadedCount = timelinePosts.length
|
||||||
|
const loadPart = timelineStatsLoading
|
||||||
|
? `已加载 ${loadedCount} / 总数统计中...`
|
||||||
|
: timelineTotalPosts === null
|
||||||
|
? `已加载 ${loadedCount} 条`
|
||||||
|
: `已加载 ${loadedCount} / 共 ${timelineTotalPosts} 条`
|
||||||
|
|
||||||
|
if (timelineLoading && loadedCount === 0) return `${loadPart} | 加载中...`
|
||||||
|
if (loadedCount === 0) return loadPart
|
||||||
|
|
||||||
|
const latest = timelinePosts[0]?.createTime
|
||||||
|
const earliest = timelinePosts[timelinePosts.length - 1]?.createTime
|
||||||
|
return `${loadPart} | ${formatYmdDateFromSeconds(earliest)} ~ ${formatYmdDateFromSeconds(latest)}`
|
||||||
|
}, [timelineLoading, timelinePosts, timelineStatsLoading, timelineTotalPosts])
|
||||||
|
|
||||||
|
const activeRankings = useMemo(() => {
|
||||||
|
if (rankMode === 'likes') return likeRankings
|
||||||
|
if (rankMode === 'comments') return commentRankings
|
||||||
|
return []
|
||||||
|
}, [commentRankings, likeRankings, rankMode])
|
||||||
|
|
||||||
|
const loadMore = useCallback(() => {
|
||||||
|
if (!targetUsername || timelineLoading || timelineLoadingMore || !timelineHasMore) return
|
||||||
|
void loadTimelinePosts({
|
||||||
|
username: targetUsername,
|
||||||
|
displayName: targetDisplayName,
|
||||||
|
avatarUrl: targetAvatarUrl
|
||||||
|
}, { reset: false })
|
||||||
|
}, [
|
||||||
|
loadTimelinePosts,
|
||||||
|
targetAvatarUrl,
|
||||||
|
targetDisplayName,
|
||||||
|
targetUsername,
|
||||||
|
timelineHasMore,
|
||||||
|
timelineLoading,
|
||||||
|
timelineLoadingMore
|
||||||
|
])
|
||||||
|
|
||||||
|
const handleBodyScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
|
||||||
|
const element = event.currentTarget
|
||||||
|
const remaining = element.scrollHeight - element.scrollTop - element.clientHeight
|
||||||
|
if (remaining <= 160) {
|
||||||
|
loadMore()
|
||||||
|
}
|
||||||
|
}, [loadMore])
|
||||||
|
|
||||||
|
const toggleRankMode = useCallback((mode: ContactSnsRankMode) => {
|
||||||
|
setRankMode((previous) => (previous === mode ? null : mode))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!target) return null
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="contact-sns-dialog-overlay" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="contact-sns-dialog"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="联系人朋友圈"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="contact-sns-dialog-header">
|
||||||
|
<div className="contact-sns-dialog-header-main">
|
||||||
|
<div className="contact-sns-dialog-avatar">
|
||||||
|
{targetAvatarUrl ? (
|
||||||
|
<img src={targetAvatarUrl} alt="" />
|
||||||
|
) : (
|
||||||
|
<span>{getAvatarLetter(targetDisplayName)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="contact-sns-dialog-meta">
|
||||||
|
<h4>{targetDisplayName}</h4>
|
||||||
|
<div className="contact-sns-dialog-username">@{targetUsername}</div>
|
||||||
|
<div className="contact-sns-dialog-stats">{timelineStatsText}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="contact-sns-dialog-header-actions">
|
||||||
|
<div className="contact-sns-dialog-rank-switch">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`contact-sns-dialog-rank-btn ${rankMode === 'likes' ? 'active' : ''}`}
|
||||||
|
onClick={() => toggleRankMode('likes')}
|
||||||
|
>
|
||||||
|
点赞排行
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`contact-sns-dialog-rank-btn ${rankMode === 'comments' ? 'active' : ''}`}
|
||||||
|
onClick={() => toggleRankMode('comments')}
|
||||||
|
>
|
||||||
|
评论排行
|
||||||
|
</button>
|
||||||
|
{rankMode && (
|
||||||
|
<div
|
||||||
|
className="contact-sns-dialog-rank-panel"
|
||||||
|
role="region"
|
||||||
|
aria-label={rankMode === 'likes' ? '点赞排行' : '评论排行'}
|
||||||
|
>
|
||||||
|
{rankLoading && (
|
||||||
|
<div className="contact-sns-dialog-rank-loading">
|
||||||
|
<Loader2 size={12} className="spin" />
|
||||||
|
<span>
|
||||||
|
{rankTotalPosts !== null && rankTotalPosts > 0
|
||||||
|
? `统计中,已加载 ${rankLoadedPosts} / ${rankTotalPosts} 条`
|
||||||
|
: `统计中,已加载 ${rankLoadedPosts} 条`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!rankLoading && rankError ? (
|
||||||
|
<div className="contact-sns-dialog-rank-empty">{rankError}</div>
|
||||||
|
) : !rankLoading && activeRankings.length === 0 ? (
|
||||||
|
<div className="contact-sns-dialog-rank-empty">
|
||||||
|
{rankMode === 'likes' ? '暂无点赞数据' : '暂无评论数据'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
activeRankings.slice(0, SNS_RANK_DISPLAY_LIMIT).map((item, index) => (
|
||||||
|
<div className="contact-sns-dialog-rank-row" key={`${rankMode}-${item.name}`}>
|
||||||
|
<span className="contact-sns-dialog-rank-index">{index + 1}</span>
|
||||||
|
<span className="contact-sns-dialog-rank-name" title={item.name}>{item.name}</span>
|
||||||
|
<span className="contact-sns-dialog-rank-count">
|
||||||
|
{item.count.toLocaleString('zh-CN')}
|
||||||
|
{rankMode === 'likes' ? '次' : '条'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button className="contact-sns-dialog-close-btn" type="button" onClick={onClose}>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="contact-sns-dialog-tip">
|
||||||
|
在微信桌面客户端中打开这个人的朋友圈浏览,可快速把其朋友圈同步到这里。若你在乎这个人,一定要试试~
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="contact-sns-dialog-body"
|
||||||
|
onScroll={handleBodyScroll}
|
||||||
|
>
|
||||||
|
{timelinePosts.length > 0 && (
|
||||||
|
<div className="contact-sns-dialog-posts-list">
|
||||||
|
{timelinePosts.map((post) => (
|
||||||
|
<SnsPostItem
|
||||||
|
key={post.id}
|
||||||
|
post={{ ...post, isProtected }}
|
||||||
|
onPreview={(src, isVideo, liveVideoPath) => {
|
||||||
|
if (isVideo) {
|
||||||
|
void window.electronAPI.window.openVideoPlayerWindow(src)
|
||||||
|
} else {
|
||||||
|
void window.electronAPI.window.openImageViewerWindow(src, liveVideoPath || undefined)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDebug={() => {}}
|
||||||
|
onDelete={onDeletePost}
|
||||||
|
hideAuthorMeta
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{timelineLoading && (
|
||||||
|
<div className="contact-sns-dialog-status">正在加载该联系人的朋友圈...</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!timelineLoading && timelinePosts.length === 0 && (
|
||||||
|
<div className="contact-sns-dialog-status empty">该联系人暂无朋友圈</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!timelineLoading && timelineHasMore && (
|
||||||
|
<button
|
||||||
|
className="contact-sns-dialog-load-more"
|
||||||
|
type="button"
|
||||||
|
onClick={loadMore}
|
||||||
|
disabled={timelineLoadingMore}
|
||||||
|
>
|
||||||
|
{timelineLoadingMore ? '正在加载...' : '加载更多'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)
|
||||||
|
}
|
||||||
227
src/components/Sns/SnsFilterPanel.tsx
Normal file
227
src/components/Sns/SnsFilterPanel.tsx
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Search, User, X, Loader2, CheckSquare, Square, Download } from 'lucide-react'
|
||||||
|
import { Avatar } from '../Avatar'
|
||||||
|
|
||||||
|
interface Contact {
|
||||||
|
username: string
|
||||||
|
displayName: string
|
||||||
|
avatarUrl?: string
|
||||||
|
postCount?: number
|
||||||
|
postCountStatus?: 'idle' | 'loading' | 'ready'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContactsCountProgress {
|
||||||
|
resolved: number
|
||||||
|
total: number
|
||||||
|
running: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SnsFilterPanelProps {
|
||||||
|
searchKeyword: string
|
||||||
|
setSearchKeyword: (val: string) => void
|
||||||
|
totalFriendsLabel?: string
|
||||||
|
contacts: Contact[]
|
||||||
|
contactSearch: string
|
||||||
|
setContactSearch: (val: string) => void
|
||||||
|
loading?: boolean
|
||||||
|
contactsCountProgress?: ContactsCountProgress
|
||||||
|
selectedContactUsernames: string[]
|
||||||
|
activeContactUsername?: string
|
||||||
|
onOpenContactTimeline: (contact: Contact) => void
|
||||||
|
onToggleContactSelected: (contact: Contact) => void
|
||||||
|
onClearSelectedContacts: () => void
|
||||||
|
onExportSelectedContacts: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
|
||||||
|
searchKeyword,
|
||||||
|
setSearchKeyword,
|
||||||
|
totalFriendsLabel,
|
||||||
|
contacts,
|
||||||
|
contactSearch,
|
||||||
|
setContactSearch,
|
||||||
|
loading,
|
||||||
|
contactsCountProgress,
|
||||||
|
selectedContactUsernames,
|
||||||
|
activeContactUsername,
|
||||||
|
onOpenContactTimeline,
|
||||||
|
onToggleContactSelected,
|
||||||
|
onClearSelectedContacts,
|
||||||
|
onExportSelectedContacts
|
||||||
|
}) => {
|
||||||
|
const filteredContacts = contacts.filter(c =>
|
||||||
|
(c.displayName || '').toLowerCase().includes(contactSearch.toLowerCase()) ||
|
||||||
|
c.username.toLowerCase().includes(contactSearch.toLowerCase())
|
||||||
|
)
|
||||||
|
const selectedContactLookup = React.useMemo(
|
||||||
|
() => new Set(selectedContactUsernames),
|
||||||
|
[selectedContactUsernames]
|
||||||
|
)
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setSearchKeyword('')
|
||||||
|
setContactSearch('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const getEmptyStateText = () => {
|
||||||
|
if (loading && contacts.length === 0) {
|
||||||
|
return '正在加载联系人...'
|
||||||
|
}
|
||||||
|
if (contacts.length === 0) {
|
||||||
|
return '暂无好友或曾经的好友'
|
||||||
|
}
|
||||||
|
return '没有找到联系人'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="sns-filter-panel">
|
||||||
|
<div className="filter-header">
|
||||||
|
<h3>筛选条件</h3>
|
||||||
|
{(searchKeyword || contactSearch) && (
|
||||||
|
<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>
|
||||||
|
{/* Contact Widget */}
|
||||||
|
<div className="filter-widget contact-widget">
|
||||||
|
<div className="widget-header">
|
||||||
|
<User size={14} />
|
||||||
|
<span>联系人</span>
|
||||||
|
{totalFriendsLabel && (
|
||||||
|
<span className="widget-header-summary">{totalFriendsLabel}</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>
|
||||||
|
|
||||||
|
{contactsCountProgress && contactsCountProgress.total > 0 && (
|
||||||
|
<div className="contact-count-progress">
|
||||||
|
{contactsCountProgress.running
|
||||||
|
? `朋友圈条数统计中 ${contactsCountProgress.resolved}/${contactsCountProgress.total}`
|
||||||
|
: `朋友圈条数已统计 ${contactsCountProgress.total}/${contactsCountProgress.total}`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="contact-interaction-hint">
|
||||||
|
点左侧可多选下载,点右侧可查看单人详情
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="contact-list-scroll">
|
||||||
|
{filteredContacts.map(contact => {
|
||||||
|
const isPostCountReady = contact.postCountStatus === 'ready'
|
||||||
|
const isSelected = selectedContactLookup.has(contact.username)
|
||||||
|
const isActive = activeContactUsername === contact.username
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={contact.username}
|
||||||
|
className={`contact-row${isSelected ? ' is-selected' : ''}${isActive ? ' is-active' : ''}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`contact-select-btn${isSelected ? ' checked' : ''}`}
|
||||||
|
onClick={() => onToggleContactSelected(contact)}
|
||||||
|
title={isSelected ? `取消选择 ${contact.displayName}` : `选择 ${contact.displayName}`}
|
||||||
|
aria-pressed={isSelected}
|
||||||
|
>
|
||||||
|
{isSelected ? <CheckSquare size={16} /> : <Square size={16} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="contact-main-btn"
|
||||||
|
onClick={() => onOpenContactTimeline(contact)}
|
||||||
|
title={`查看 ${contact.displayName} 的朋友圈`}
|
||||||
|
>
|
||||||
|
<Avatar src={contact.avatarUrl} name={contact.displayName} size={36} shape="rounded" />
|
||||||
|
<div className="contact-meta">
|
||||||
|
<span className="contact-name">{contact.displayName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="contact-post-count-wrap">
|
||||||
|
{isPostCountReady ? (
|
||||||
|
<span className="contact-post-count">{Math.max(0, Math.floor(Number(contact.postCount || 0)))}条</span>
|
||||||
|
) : (
|
||||||
|
<span className="contact-post-count-loading" title="统计中">
|
||||||
|
<Loader2 size={13} className="spinning" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{filteredContacts.length === 0 && (
|
||||||
|
<div className="empty-state">{getEmptyStateText()}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedContactUsernames.length > 0 && (
|
||||||
|
<div className="contact-batch-bar">
|
||||||
|
<span className="contact-batch-summary">已选 {selectedContactUsernames.length} 人</span>
|
||||||
|
<button type="button" className="contact-batch-btn" onClick={onClearSelectedContacts}>
|
||||||
|
清空
|
||||||
|
</button>
|
||||||
|
<button type="button" className="contact-batch-btn primary" onClick={onExportSelectedContacts}>
|
||||||
|
<Download size={14} />
|
||||||
|
<span>下载所选</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
483
src/components/Sns/SnsPostItem.tsx
Normal file
483
src/components/Sns/SnsPostItem.tsx
Normal file
@@ -0,0 +1,483 @@
|
|||||||
|
import React, { useState, useMemo, useEffect } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { Heart, ChevronRight, ImageIcon, Code, Trash2, MapPin } from 'lucide-react'
|
||||||
|
import { SnsPost, SnsLinkCardData, SnsLocation } 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 buildLocationText = (location?: SnsLocation): string => {
|
||||||
|
if (!location) return ''
|
||||||
|
|
||||||
|
const normalize = (value?: string): string => (
|
||||||
|
decodeHtmlEntities(String(value || '')).replace(/\s+/g, ' ').trim()
|
||||||
|
)
|
||||||
|
|
||||||
|
const primary = [
|
||||||
|
normalize(location.poiName),
|
||||||
|
normalize(location.poiAddressName),
|
||||||
|
normalize(location.label),
|
||||||
|
normalize(location.poiAddress)
|
||||||
|
].find(Boolean) || ''
|
||||||
|
|
||||||
|
const region = [normalize(location.country), normalize(location.city)]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
|
||||||
|
if (primary && region && !primary.includes(region)) {
|
||||||
|
return `${primary} · ${region}`
|
||||||
|
}
|
||||||
|
return primary || region
|
||||||
|
}
|
||||||
|
|
||||||
|
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, username: string) => void
|
||||||
|
onOpenAuthorPosts?: (post: SnsPost) => void
|
||||||
|
hideAuthorMeta?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDebug, onDelete, onOpenAuthorPosts, hideAuthorMeta = false }) => {
|
||||||
|
const [mediaDeleted, setMediaDeleted] = useState(false)
|
||||||
|
const [dbDeleted, setDbDeleted] = useState(false)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||||
|
const linkCard = buildLinkCardData(post)
|
||||||
|
const locationText = useMemo(() => buildLocationText(post.location), [post.location])
|
||||||
|
const hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url))
|
||||||
|
const showLinkCard = Boolean(linkCard) && post.media.length <= 1 && !hasVideoMedia
|
||||||
|
const 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, post.username)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenAuthorPosts = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onOpenAuthorPosts?.(post)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={`sns-post-item ${(mediaDeleted || dbDeleted) ? 'post-deleted' : ''}`}>
|
||||||
|
{!hideAuthorMeta && (
|
||||||
|
<div className="post-avatar-col">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="author-trigger-btn avatar-trigger"
|
||||||
|
onClick={handleOpenAuthorPosts}
|
||||||
|
title="查看该发布者的全部朋友圈"
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
src={post.avatarUrl}
|
||||||
|
name={post.nickname}
|
||||||
|
size={48}
|
||||||
|
shape="rounded"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="post-content-col">
|
||||||
|
<div className="post-header-row">
|
||||||
|
{hideAuthorMeta ? (
|
||||||
|
<span className="post-time post-time-standalone">{formatTime(post.createTime)}</span>
|
||||||
|
) : (
|
||||||
|
<div className="post-author-info">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="author-trigger-btn author-name-trigger"
|
||||||
|
onClick={handleOpenAuthorPosts}
|
||||||
|
title="查看该发布者的全部朋友圈"
|
||||||
|
>
|
||||||
|
<span className="author-name">{decodeHtmlEntities(post.nickname)}</span>
|
||||||
|
</button>
|
||||||
|
<span className="post-time">{formatTime(post.createTime)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="post-header-actions">
|
||||||
|
{(mediaDeleted || dbDeleted) && (
|
||||||
|
<span className="post-deleted-badge">
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{locationText && (
|
||||||
|
<div className="post-location" title={locationText}>
|
||||||
|
<MapPin size={14} />
|
||||||
|
<span className="post-location-text">{locationText}</span>
|
||||||
|
</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
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user