mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 23:35:49 +00:00
Compare commits
708 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cef17174b | ||
|
|
73cabf2acd | ||
|
|
49770f9e8d | ||
|
|
e32261d274 | ||
|
|
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 | ||
|
|
74fc0e4e88 | ||
|
|
a873366342 | ||
|
|
c4dc266f93 | ||
|
|
96ff783bbd | ||
|
|
804a65f52b | ||
|
|
e88c859f4f | ||
|
|
c1a393eaf6 | ||
|
|
15e08dc529 | ||
|
|
e55bcaf7eb | ||
|
|
4e64c6ad6e | ||
|
|
5a15e1a1d6 | ||
|
|
ba07d47496 | ||
|
|
25325e80ee | ||
|
|
89783b4d45 | ||
|
|
d5f0094025 |
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(", ")}`);
|
||||
181
.github/workflows/release.yml
vendored
181
.github/workflows/release.yml
vendored
@@ -8,24 +8,100 @@ on:
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
release-mac-arm64:
|
||||
runs-on: macos-14
|
||||
|
||||
steps:
|
||||
- name: Check out git repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm 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 }}
|
||||
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 }}
|
||||
run: |
|
||||
npx electron-builder --linux --publish always
|
||||
|
||||
release:
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- name: Check out git repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22.12
|
||||
node-version: 24
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm ci
|
||||
run: npm install
|
||||
|
||||
- name: Sync version with tag
|
||||
shell: bash
|
||||
@@ -45,17 +121,106 @@ jobs:
|
||||
run: |
|
||||
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 }}
|
||||
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:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
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)
|
||||
|
||||
## 下载
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
wcdb/
|
||||
xkey/
|
||||
server/
|
||||
*info
|
||||
概述.md
|
||||
chatlab-format.md
|
||||
*.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
|
||||
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||
electron-mirror=https://npmmirror.com/mirrors/electron/
|
||||
electron-builder-binaries-mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||
|
||||
44
README.md
44
README.md
@@ -19,6 +19,7 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
|
||||
</a>
|
||||
<a href="https://github.com/hicccc77/WeFlow/issues">
|
||||
<img src="https://img.shields.io/github/issues/hicccc77/WeFlow?style=flat-square" alt="Issues">
|
||||
<img src="https://gh-down-badges.linkof.link/hicccc77/WeFlow/" alt="Downloads" />
|
||||
</a>
|
||||
<a href="https://t.me/weflow_cc">
|
||||
<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 等格式
|
||||
- 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
|
||||
|
||||
@@ -54,13 +86,9 @@ WeFlow 提供本地 HTTP API 服务,支持通过接口查询消息数据,可
|
||||
- **访问地址**:`http://127.0.0.1:5031`
|
||||
- **支持格式**:原始 JSON 或 [ChatLab](https://chatlab.fun/) 标准格式
|
||||
|
||||
📖 完整接口文档:[点击查看](docs/HTTP-API.md)
|
||||
完整接口文档:[点击查看](docs/HTTP-API.md)
|
||||
|
||||
|
||||
## 快速开始
|
||||
|
||||
若你只想使用成品版本,可前往 Release 下载并安装。
|
||||
|
||||
## 面向开发者
|
||||
|
||||
如果你想从源码构建或为项目贡献代码,请遵循以下步骤:
|
||||
@@ -76,14 +104,8 @@ npm install
|
||||
# 3. 运行应用(开发模式)
|
||||
npm run dev
|
||||
|
||||
# 4. 打包可执行文件
|
||||
npm run build
|
||||
```
|
||||
|
||||
打包产物在 `release` 目录下。
|
||||
|
||||
|
||||
|
||||
## 致谢
|
||||
|
||||
- [密语 CipherTalk](https://github.com/ILoveBingLu/miyu) 为本项目提供了基础框架
|
||||
|
||||
584
docs/HTTP-API.md
584
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`
|
||||
|
||||
## 基础地址
|
||||
|
||||
```
|
||||
http://127.0.0.1:5031
|
||||
```
|
||||
|
||||
---
|
||||
- 默认监听地址:`127.0.0.1`
|
||||
- 默认端口:`5031`
|
||||
- 基础地址:`http://127.0.0.1:5031`
|
||||
- 可选开启 `主动推送`,检测到新收到的消息后会通过 `GET /api/v1/push/messages` 推送给 SSE 订阅端
|
||||
|
||||
## 接口列表
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
或
|
||||
|
||||
```http
|
||||
GET /api/v1/health
|
||||
```
|
||||
|
||||
**响应**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok"
|
||||
@@ -36,132 +49,223 @@ GET /health
|
||||
|
||||
---
|
||||
|
||||
### 2. 获取消息列表
|
||||
## 2. 主动推送
|
||||
|
||||
获取指定会话的消息,支持 ChatLab 格式输出。
|
||||
通过 SSE 长连接接收新消息事件,端口与 HTTP API 共用。
|
||||
|
||||
**请求**
|
||||
```
|
||||
GET /api/v1/messages
|
||||
|
||||
```http
|
||||
GET /api/v1/push/messages
|
||||
```
|
||||
|
||||
**参数**
|
||||
### 说明
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| `talker` | string | ✅ | 会话 ID(wxid 或群 ID) |
|
||||
| `limit` | number | ❌ | 返回数量限制,默认 100 |
|
||||
| `offset` | number | ❌ | 偏移量,用于分页,默认 0 |
|
||||
| `start` | string | ❌ | 开始时间,格式 YYYYMMDD |
|
||||
| `end` | string | ❌ | 结束时间,格式 YYYYMMDD |
|
||||
| `chatlab` | string | ❌ | 设为 `1` 则输出 ChatLab 格式 |
|
||||
| `format` | string | ❌ | 输出格式:`json`(默认)或 `chatlab` |
|
||||
- 需要先在设置页开启 `HTTP API 服务`
|
||||
- 同时需要开启 `主动推送`
|
||||
- 响应类型为 `text/event-stream`
|
||||
- 新消息事件名固定为 `message.new`
|
||||
- 建议接收端按 `messageKey` 去重
|
||||
|
||||
**示例请求**
|
||||
### 事件字段
|
||||
|
||||
- `event`
|
||||
- `sessionId`
|
||||
- `messageKey`
|
||||
- `avatarUrl`
|
||||
- `sourceName`
|
||||
- `groupName`(仅群聊)
|
||||
- `content`
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
# 获取消息(原始格式)
|
||||
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=50
|
||||
|
||||
# 获取消息(ChatLab 格式)
|
||||
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1
|
||||
|
||||
# 带时间范围查询
|
||||
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&start=20260101&end=20260205&limit=100
|
||||
curl -N "http://127.0.0.1:5031/api/v1/push/messages"
|
||||
```
|
||||
|
||||
**响应(原始格式)**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"talker": "wxid_xxx",
|
||||
"count": 50,
|
||||
"hasMore": true,
|
||||
"messages": [
|
||||
{
|
||||
"localId": 123,
|
||||
"talker": "wxid_xxx",
|
||||
"type": 1,
|
||||
"content": "消息内容",
|
||||
"createTime": 1738713600000,
|
||||
"isSelf": false,
|
||||
"sender": "wxid_sender"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
示例事件:
|
||||
|
||||
**响应(ChatLab 格式)**
|
||||
```json
|
||||
{
|
||||
"chatlab": {
|
||||
"version": "0.0.2",
|
||||
"exportedAt": 1738713600000,
|
||||
"generator": "WeFlow",
|
||||
"description": "Exported from WeFlow"
|
||||
},
|
||||
"meta": {
|
||||
"name": "会话名称",
|
||||
"platform": "wechat",
|
||||
"type": "private",
|
||||
"ownerId": "wxid_me"
|
||||
},
|
||||
"members": [
|
||||
{
|
||||
"platformId": "wxid_xxx",
|
||||
"accountName": "用户名",
|
||||
"groupNickname": "群昵称"
|
||||
}
|
||||
],
|
||||
"messages": [
|
||||
{
|
||||
"sender": "wxid_xxx",
|
||||
"accountName": "用户名",
|
||||
"timestamp": 1738713600000,
|
||||
"type": 0,
|
||||
"content": "消息内容"
|
||||
}
|
||||
]
|
||||
}
|
||||
```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. 获取会话列表
|
||||
## 3. 获取消息
|
||||
|
||||
获取所有会话列表。
|
||||
读取指定会话的消息,支持原始 JSON 和 ChatLab 格式。
|
||||
|
||||
**请求**
|
||||
```
|
||||
GET /api/v1/sessions
|
||||
|
||||
```http
|
||||
GET /api/v1/messages
|
||||
```
|
||||
|
||||
**参数**
|
||||
### 参数
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| `keyword` | string | ❌ | 搜索关键词,匹配会话名或 ID |
|
||||
| `limit` | number | ❌ | 返回数量限制,默认 100 |
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `talker` | string | 是 | 会话 ID。私聊通常是对方 `wxid`,群聊是 `xxx@chatroom` |
|
||||
| `limit` | number | 否 | 返回条数,默认 `100`,范围 `1~10000` |
|
||||
| `offset` | number | 否 | 分页偏移,默认 `0` |
|
||||
| `start` | string | 否 | 开始时间,支持 `YYYYMMDD` 或时间戳 |
|
||||
| `end` | string | 否 | 结束时间,支持 `YYYYMMDD` 或时间戳 |
|
||||
| `keyword` | string | 否 | 基于消息显示文本过滤 |
|
||||
| `chatlab` | string | 否 | `1/true` 时输出 ChatLab 格式 |
|
||||
| `format` | string | 否 | `json` 或 `chatlab` |
|
||||
| `media` | string | 否 | `1/true` 时导出媒体并返回媒体地址,兼容别名 `meiti` |
|
||||
| `image` | string | 否 | 在 `media=1` 时控制图片导出,兼容别名 `tupian` |
|
||||
| `voice` | string | 否 | 在 `media=1` 时控制语音导出,兼容别名 `vioce` |
|
||||
| `video` | string | 否 | 在 `media=1` 时控制视频导出 |
|
||||
| `emoji` | string | 否 | 在 `media=1` 时控制表情导出 |
|
||||
|
||||
### 示例
|
||||
|
||||
**示例请求**
|
||||
```bash
|
||||
GET http://127.0.0.1:5031/api/v1/sessions
|
||||
|
||||
GET http://127.0.0.1:5031/api/v1/sessions?keyword=工作群&limit=20
|
||||
curl "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=20"
|
||||
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"
|
||||
curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&media=1&image=1&voice=0&video=0&emoji=0"
|
||||
```
|
||||
|
||||
**响应**
|
||||
### 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
|
||||
{
|
||||
"success": true,
|
||||
"count": 50,
|
||||
"total": 100,
|
||||
"talker": "xxx@chatroom",
|
||||
"count": 2,
|
||||
"hasMore": true,
|
||||
"media": {
|
||||
"enabled": true,
|
||||
"exportPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media",
|
||||
"count": 1
|
||||
},
|
||||
"messages": [
|
||||
{
|
||||
"localId": 123,
|
||||
"serverId": "456",
|
||||
"localType": 1,
|
||||
"createTime": 1738713600,
|
||||
"isSend": 0,
|
||||
"senderUsername": "wxid_member",
|
||||
"content": "你好",
|
||||
"rawContent": "你好",
|
||||
"parsedContent": "你好"
|
||||
},
|
||||
{
|
||||
"localId": 124,
|
||||
"localType": 3,
|
||||
"createTime": 1738713660,
|
||||
"isSend": 0,
|
||||
"senderUsername": "wxid_member",
|
||||
"content": "[图片]",
|
||||
"mediaType": "image",
|
||||
"mediaFileName": "abc123.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=1` 或 `format=chatlab` 时,返回 ChatLab 结构:
|
||||
|
||||
- `chatlab.version`
|
||||
- `chatlab.exportedAt`
|
||||
- `chatlab.generator`
|
||||
- `meta.name`
|
||||
- `meta.platform`
|
||||
- `meta.type`
|
||||
- `meta.groupId`
|
||||
- `meta.groupAvatar`
|
||||
- `meta.ownerId`
|
||||
- `members[].platformId`
|
||||
- `members[].accountName`
|
||||
- `members[].groupNickname`
|
||||
- `members[].avatar`
|
||||
- `messages[].sender`
|
||||
- `messages[].accountName`
|
||||
- `messages[].groupNickname`
|
||||
- `messages[].timestamp`
|
||||
- `messages[].type`
|
||||
- `messages[].content`
|
||||
- `messages[].platformMessageId`
|
||||
- `messages[].mediaPath`
|
||||
|
||||
群聊里 `groupNickname` 会优先来自群成员群昵称;若源数据缺失,则回退为空或展示名。
|
||||
|
||||
---
|
||||
|
||||
## 4. 获取会话列表
|
||||
|
||||
**请求**
|
||||
|
||||
```http
|
||||
GET /api/v1/sessions
|
||||
```
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `keyword` | string | 否 | 匹配 `username` 或 `displayName` |
|
||||
| `limit` | number | 否 | 默认 `100` |
|
||||
|
||||
### 响应字段
|
||||
|
||||
- `success`
|
||||
- `count`
|
||||
- `sessions[].username`
|
||||
- `sessions[].displayName`
|
||||
- `sessions[].type`
|
||||
- `sessions[].lastTimestamp`
|
||||
- `sessions[].unreadCount`
|
||||
|
||||
**示例响应**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"count": 1,
|
||||
"sessions": [
|
||||
{
|
||||
"username": "wxid_xxx",
|
||||
"displayName": "用户名",
|
||||
"lastMessage": "最后一条消息",
|
||||
"lastTime": 1738713600000,
|
||||
"username": "xxx@chatroom",
|
||||
"displayName": "项目群",
|
||||
"type": 2,
|
||||
"lastTimestamp": 1738713600,
|
||||
"unreadCount": 0
|
||||
}
|
||||
]
|
||||
@@ -170,40 +274,48 @@ GET http://127.0.0.1:5031/api/v1/sessions?keyword=工作群&limit=20
|
||||
|
||||
---
|
||||
|
||||
### 4. 获取联系人列表
|
||||
|
||||
获取所有联系人信息。
|
||||
## 5. 获取联系人列表
|
||||
|
||||
**请求**
|
||||
```
|
||||
|
||||
```http
|
||||
GET /api/v1/contacts
|
||||
```
|
||||
|
||||
**参数**
|
||||
### 参数
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| `keyword` | string | ❌ | 搜索关键词 |
|
||||
| `limit` | number | ❌ | 返回数量限制,默认 100 |
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `keyword` | string | 否 | 匹配 `username`、`nickname`、`remark`、`displayName` |
|
||||
| `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
|
||||
{
|
||||
"success": true,
|
||||
"count": 50,
|
||||
"count": 1,
|
||||
"contacts": [
|
||||
{
|
||||
"userName": "wxid_xxx",
|
||||
"alias": "微信号",
|
||||
"nickName": "昵称",
|
||||
"remark": "备注名"
|
||||
"username": "wxid_xxx",
|
||||
"displayName": "张三",
|
||||
"remark": "客户张三",
|
||||
"nickname": "张三",
|
||||
"alias": "zhangsan",
|
||||
"avatarUrl": "https://example.com/avatar.jpg",
|
||||
"type": "friend"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -211,60 +323,157 @@ GET http://127.0.0.1:5031/api/v1/contacts?keyword=张三
|
||||
|
||||
---
|
||||
|
||||
## ChatLab 格式说明
|
||||
## 6. 获取群成员列表
|
||||
|
||||
ChatLab 是一种标准化的聊天记录交换格式,版本 0.0.2。
|
||||
返回群成员的 `wxid`、群昵称、备注、微信号等信息。
|
||||
|
||||
### 消息类型映射
|
||||
**请求**
|
||||
|
||||
| ChatLab Type | 值 | 说明 |
|
||||
|--------------|-----|------|
|
||||
| TEXT | 0 | 文本消息 |
|
||||
| IMAGE | 1 | 图片 |
|
||||
| VOICE | 2 | 语音 |
|
||||
| VIDEO | 3 | 视频 |
|
||||
| FILE | 4 | 文件 |
|
||||
| EMOJI | 5 | 表情 |
|
||||
| LINK | 7 | 链接 |
|
||||
| LOCATION | 8 | 位置 |
|
||||
| RED_PACKET | 20 | 红包 |
|
||||
| TRANSFER | 21 | 转账 |
|
||||
| CALL | 23 | 通话 |
|
||||
| SYSTEM | 80 | 系统消息 |
|
||||
| RECALL | 81 | 撤回消息 |
|
||||
| OTHER | 99 | 其他 |
|
||||
```http
|
||||
GET /api/v1/group-members
|
||||
```
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `chatroomId` | string | 是 | 群 ID,兼容使用 `talker` 传入 |
|
||||
| `includeMessageCounts` | string | 否 | `1/true` 时附带成员发言数 |
|
||||
| `withCounts` | string | 否 | `includeMessageCounts` 的别名 |
|
||||
| `forceRefresh` | string | 否 | `1/true` 时跳过内存缓存强制刷新 |
|
||||
|
||||
### 响应字段
|
||||
|
||||
- `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
|
||||
# 健康检查
|
||||
Invoke-RestMethod http://127.0.0.1:5031/health
|
||||
|
||||
# 获取会话列表
|
||||
Invoke-RestMethod http://127.0.0.1:5031/api/v1/sessions
|
||||
|
||||
# 获取消息
|
||||
Invoke-RestMethod "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=10"
|
||||
|
||||
# 获取 ChatLab 格式
|
||||
Invoke-RestMethod "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1" | ConvertTo-Json -Depth 10
|
||||
Invoke-RestMethod "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom&includeMessageCounts=1"
|
||||
```
|
||||
|
||||
### cURL
|
||||
|
||||
```bash
|
||||
# 健康检查
|
||||
curl http://127.0.0.1:5031/health
|
||||
|
||||
# 获取会话列表
|
||||
curl http://127.0.0.1:5031/api/v1/sessions
|
||||
|
||||
# 获取消息(ChatLab 格式)
|
||||
curl "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1"
|
||||
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
|
||||
@@ -274,39 +483,26 @@ import requests
|
||||
|
||||
BASE_URL = "http://127.0.0.1:5031"
|
||||
|
||||
# 获取会话列表
|
||||
sessions = requests.get(f"{BASE_URL}/api/v1/sessions").json()
|
||||
print(sessions)
|
||||
messages = requests.get(
|
||||
f"{BASE_URL}/api/v1/messages",
|
||||
params={"talker": "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)
|
||||
```
|
||||
|
||||
### 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);
|
||||
print(members)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
## 9. 注意事项
|
||||
|
||||
1. API 仅监听本地地址 `127.0.0.1`,不对外网开放
|
||||
2. 需要先连接数据库才能查询数据
|
||||
3. 时间参数格式为 `YYYYMMDD`(如 20260205)
|
||||
4. 支持 CORS,可从浏览器前端直接调用
|
||||
1. API 仅监听本机 `127.0.0.1`,不对外网开放。
|
||||
2. 使用前需要先在 WeFlow 中完成数据库连接。
|
||||
3. `start` 和 `end` 支持 `YYYYMMDD` 与时间戳;纯 `YYYYMMDD` 的 `end` 会扩展到当天 `23:59:59`。
|
||||
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
|
||||
}
|
||||
|
||||
type Candidate = { score: number; path: string; isThumb: boolean; hasX: boolean }
|
||||
type Candidate = { score: number; path: string; isThumb: boolean }
|
||||
|
||||
const payload = workerData as WorkerPayload
|
||||
|
||||
@@ -18,16 +18,26 @@ function looksLikeMd5(value: string): boolean {
|
||||
return /^[a-fA-F0-9]{16,32}$/.test(value)
|
||||
}
|
||||
|
||||
function stripDatVariantSuffix(base: string): string {
|
||||
const lower = base.toLowerCase()
|
||||
const suffixes = ['_thumb', '.thumb', '_hd', '.hd', '_h', '.h', '_t', '.t', '_c', '.c']
|
||||
for (const suffix of suffixes) {
|
||||
if (lower.endsWith(suffix)) {
|
||||
return lower.slice(0, -suffix.length)
|
||||
}
|
||||
}
|
||||
if (/[._][a-z]$/.test(lower)) {
|
||||
return lower.slice(0, -2)
|
||||
}
|
||||
return lower
|
||||
}
|
||||
|
||||
function hasXVariant(baseLower: string): boolean {
|
||||
return /[._][a-z]$/.test(baseLower)
|
||||
return stripDatVariantSuffix(baseLower) !== baseLower
|
||||
}
|
||||
|
||||
function hasImageVariantSuffix(baseLower: string): boolean {
|
||||
return /[._][a-z]$/.test(baseLower)
|
||||
}
|
||||
|
||||
function isLikelyImageDatBase(baseLower: string): boolean {
|
||||
return hasImageVariantSuffix(baseLower) || looksLikeMd5(baseLower)
|
||||
return stripDatVariantSuffix(baseLower) !== baseLower
|
||||
}
|
||||
|
||||
function normalizeDatBase(name: string): string {
|
||||
@@ -35,10 +45,17 @@ function normalizeDatBase(name: string): string {
|
||||
if (base.endsWith('.dat') || base.endsWith('.jpg')) {
|
||||
base = base.slice(0, -4)
|
||||
}
|
||||
while (/[._][a-z]$/.test(base)) {
|
||||
base = base.slice(0, -2)
|
||||
while (true) {
|
||||
const stripped = stripDatVariantSuffix(base)
|
||||
if (stripped === base) {
|
||||
return base
|
||||
}
|
||||
base = stripped
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
function isLikelyImageDatBase(baseLower: string): boolean {
|
||||
return hasImageVariantSuffix(baseLower) || looksLikeMd5(normalizeDatBase(baseLower))
|
||||
}
|
||||
|
||||
function matchesDatName(fileName: string, datName: string): boolean {
|
||||
@@ -47,25 +64,23 @@ function matchesDatName(fileName: string, datName: string): boolean {
|
||||
const normalizedBase = normalizeDatBase(base)
|
||||
const normalizedTarget = normalizeDatBase(datName.toLowerCase())
|
||||
if (normalizedBase === normalizedTarget) return true
|
||||
const pattern = new RegExp(`^${datName}(?:[._][a-z])?\\.dat$`)
|
||||
if (pattern.test(lower)) return true
|
||||
return lower.endsWith('.dat') && lower.includes(datName)
|
||||
return lower.endsWith('.dat') && lower.includes(normalizedTarget)
|
||||
}
|
||||
|
||||
function scoreDatName(fileName: string): number {
|
||||
if (fileName.includes('.t.dat') || fileName.includes('_t.dat')) return 1
|
||||
if (fileName.includes('.c.dat') || fileName.includes('_c.dat')) return 1
|
||||
return 2
|
||||
const lower = fileName.toLowerCase()
|
||||
const baseLower = lower.endsWith('.dat') ? lower.slice(0, -4) : lower
|
||||
if (baseLower.endsWith('_h') || baseLower.endsWith('.h')) return 600
|
||||
if (!hasXVariant(baseLower)) return 500
|
||||
if (baseLower.endsWith('_hd') || baseLower.endsWith('.hd')) return 450
|
||||
if (baseLower.endsWith('_c') || baseLower.endsWith('.c')) return 400
|
||||
if (isThumbnailDat(lower)) return 100
|
||||
return 350
|
||||
}
|
||||
|
||||
function isThumbnailDat(fileName: string): boolean {
|
||||
return fileName.includes('.t.dat') || fileName.includes('_t.dat')
|
||||
}
|
||||
|
||||
function isHdDat(fileName: string): boolean {
|
||||
const lower = fileName.toLowerCase()
|
||||
const base = lower.endsWith('.dat') ? lower.slice(0, -4) : lower
|
||||
return base.endsWith('_hd') || base.endsWith('_h')
|
||||
return lower.includes('.t.dat') || lower.includes('_t.dat') || lower.includes('_thumb.dat')
|
||||
}
|
||||
|
||||
function walkForDat(
|
||||
@@ -105,20 +120,15 @@ function walkForDat(
|
||||
if (!lower.endsWith('.dat')) continue
|
||||
const baseLower = lower.slice(0, -4)
|
||||
if (!isLikelyImageDatBase(baseLower)) continue
|
||||
if (!hasXVariant(baseLower)) continue
|
||||
if (!matchesDatName(lower, datName)) continue
|
||||
// 排除高清图片格式 (_hd, _h)
|
||||
if (isHdDat(lower)) continue
|
||||
matchedBases.add(baseLower)
|
||||
const isThumb = isThumbnailDat(lower)
|
||||
if (!allowThumbnail && isThumb) continue
|
||||
if (thumbOnly && !isThumb) continue
|
||||
const score = scoreDatName(lower)
|
||||
candidates.push({
|
||||
score,
|
||||
score: scoreDatName(lower),
|
||||
path: entryPath,
|
||||
isThumb,
|
||||
hasX: hasXVariant(baseLower)
|
||||
isThumb
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -126,10 +136,8 @@ function walkForDat(
|
||||
return { path: null, matchedBases: Array.from(matchedBases).slice(0, 20) }
|
||||
}
|
||||
|
||||
const withX = candidates.filter((item) => item.hasX)
|
||||
const basePool = withX.length ? withX : candidates
|
||||
const nonThumb = basePool.filter((item) => !item.isThumb)
|
||||
const finalPool = thumbOnly ? basePool : (nonThumb.length ? nonThumb : basePool)
|
||||
const nonThumb = candidates.filter((item) => !item.isThumb)
|
||||
const finalPool = thumbOnly ? candidates : (nonThumb.length ? nonThumb : candidates)
|
||||
|
||||
let best: { score: number; path: string } | null = null
|
||||
for (const item of finalPool) {
|
||||
|
||||
1627
electron/main.ts
1627
electron/main.ts
File diff suppressed because it is too large
Load Diff
@@ -24,7 +24,15 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
|
||||
// 认证
|
||||
auth: {
|
||||
hello: (message?: string) => ipcRenderer.invoke('auth:hello', message)
|
||||
hello: (message?: string) => ipcRenderer.invoke('auth:hello', message),
|
||||
verifyEnabled: () => ipcRenderer.invoke('auth:verifyEnabled'),
|
||||
unlock: (password: string) => ipcRenderer.invoke('auth:unlock', password),
|
||||
enableLock: (password: string) => ipcRenderer.invoke('auth:enableLock', password),
|
||||
disableLock: (password: string) => ipcRenderer.invoke('auth:disableLock', password),
|
||||
changePassword: (oldPassword: string, newPassword: string) => ipcRenderer.invoke('auth:changePassword', oldPassword, newPassword),
|
||||
setHelloSecret: (password: string) => ipcRenderer.invoke('auth:setHelloSecret', password),
|
||||
clearHelloSecret: () => ipcRenderer.invoke('auth:clearHelloSecret'),
|
||||
isLockMode: () => ipcRenderer.invoke('auth:isLockMode')
|
||||
},
|
||||
|
||||
|
||||
@@ -55,21 +63,45 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
onUpdateAvailable: (callback: (info: { version: string; releaseNotes: string }) => void) => {
|
||||
ipcRenderer.on('app:updateAvailable', (_, info) => callback(info))
|
||||
return () => ipcRenderer.removeAllListeners('app:updateAvailable')
|
||||
}
|
||||
},
|
||||
checkWayland: () => ipcRenderer.invoke('app:checkWayland'),
|
||||
},
|
||||
|
||||
// 日志
|
||||
log: {
|
||||
getPath: () => ipcRenderer.invoke('log:getPath'),
|
||||
read: () => ipcRenderer.invoke('log:read'),
|
||||
clear: () => ipcRenderer.invoke('log:clear'),
|
||||
debug: (data: any) => ipcRenderer.send('log:debug', data)
|
||||
},
|
||||
|
||||
diagnostics: {
|
||||
getExportCardLogs: (options?: { limit?: number }) =>
|
||||
ipcRenderer.invoke('diagnostics:getExportCardLogs', options),
|
||||
clearExportCardLogs: () =>
|
||||
ipcRenderer.invoke('diagnostics:clearExportCardLogs'),
|
||||
exportExportCardLogs: (payload: { filePath: string; frontendLogs?: unknown[] }) =>
|
||||
ipcRenderer.invoke('diagnostics:exportExportCardLogs', payload)
|
||||
},
|
||||
|
||||
// 窗口控制
|
||||
window: {
|
||||
minimize: () => ipcRenderer.send('window:minimize'),
|
||||
maximize: () => ipcRenderer.send('window:maximize'),
|
||||
isMaximized: () => ipcRenderer.invoke('window:isMaximized'),
|
||||
onMaximizeStateChanged: (callback: (isMaximized: boolean) => void) => {
|
||||
const listener = (_: unknown, isMaximized: boolean) => callback(isMaximized)
|
||||
ipcRenderer.on('window:maximizeStateChanged', listener)
|
||||
return () => ipcRenderer.removeListener('window:maximizeStateChanged', listener)
|
||||
},
|
||||
close: () => ipcRenderer.send('window:close'),
|
||||
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'),
|
||||
completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'),
|
||||
openOnboardingWindow: () => ipcRenderer.invoke('window:openOnboardingWindow'),
|
||||
@@ -78,10 +110,24 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
ipcRenderer.invoke('window:openVideoPlayerWindow', videoPath, videoWidth, videoHeight),
|
||||
resizeToFitVideo: (videoWidth: number, videoHeight: number) =>
|
||||
ipcRenderer.invoke('window:resizeToFitVideo', videoWidth, videoHeight),
|
||||
openImageViewerWindow: (imagePath: string) =>
|
||||
ipcRenderer.invoke('window:openImageViewerWindow', imagePath),
|
||||
openImageViewerWindow: (imagePath: string, liveVideoPath?: string) =>
|
||||
ipcRenderer.invoke('window:openImageViewerWindow', imagePath, liveVideoPath),
|
||||
openChatHistoryWindow: (sessionId: string, messageId: number) =>
|
||||
ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId)
|
||||
ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId),
|
||||
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: {
|
||||
autoGetDbKey: () => ipcRenderer.invoke('key:autoGetDbKey'),
|
||||
autoGetImageKey: (manualDir?: string) => ipcRenderer.invoke('key:autoGetImageKey', manualDir),
|
||||
autoGetImageKey: (manualDir?: string, wxid?: string) => ipcRenderer.invoke('key:autoGetImageKey', manualDir, wxid),
|
||||
scanImageKeyFromMemory: (userDir: string) => ipcRenderer.invoke('key:scanImageKeyFromMemory', userDir),
|
||||
onDbKeyStatus: (callback: (payload: { message: string; level: number }) => void) => {
|
||||
ipcRenderer.on('key:dbKeyStatus', (_, payload) => callback(payload))
|
||||
return () => ipcRenderer.removeAllListeners('key:dbKeyStatus')
|
||||
@@ -121,8 +168,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
chat: {
|
||||
connect: () => ipcRenderer.invoke('chat:connect'),
|
||||
getSessions: () => ipcRenderer.invoke('chat:getSessions'),
|
||||
enrichSessionsContactInfo: (usernames: string[]) =>
|
||||
ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames),
|
||||
getSessionStatuses: (usernames: string[]) => ipcRenderer.invoke('chat:getSessionStatuses', usernames),
|
||||
getExportTabCounts: () => ipcRenderer.invoke('chat:getExportTabCounts'),
|
||||
getContactTypeCounts: () => ipcRenderer.invoke('chat:getContactTypeCounts'),
|
||||
getSessionMessageCounts: (sessionIds: string[]) => ipcRenderer.invoke('chat:getSessionMessageCounts', sessionIds),
|
||||
enrichSessionsContactInfo: (
|
||||
usernames: string[],
|
||||
options?: { skipDisplayName?: boolean; onlyMissingAvatar?: boolean }
|
||||
) => ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames, options),
|
||||
getMessages: (sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) =>
|
||||
ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit, startTime, endTime, ascending),
|
||||
getLatestMessages: (sessionId: string, limit?: number) =>
|
||||
@@ -140,25 +193,43 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'),
|
||||
downloadEmoji: (cdnUrl: string, md5?: string) => ipcRenderer.invoke('chat:downloadEmoji', cdnUrl, md5),
|
||||
getCachedMessages: (sessionId: string) => ipcRenderer.invoke('chat:getCachedMessages', sessionId),
|
||||
clearCurrentAccountData: (options: { clearCache?: boolean; clearExports?: boolean }) =>
|
||||
ipcRenderer.invoke('chat:clearCurrentAccountData', options),
|
||||
close: () => ipcRenderer.invoke('chat:close'),
|
||||
getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId),
|
||||
getSessionDetailFast: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetailFast', sessionId),
|
||||
getSessionDetailExtra: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetailExtra', sessionId),
|
||||
getExportSessionStats: (
|
||||
sessionIds: string[],
|
||||
options?: {
|
||||
includeRelations?: boolean
|
||||
forceRefresh?: boolean
|
||||
allowStaleCache?: boolean
|
||||
preferAccurateSpecialTypes?: boolean
|
||||
cacheOnly?: boolean
|
||||
}
|
||||
) => ipcRenderer.invoke('chat:getExportSessionStats', sessionIds, options),
|
||||
getGroupMyMessageCountHint: (chatroomId: string) =>
|
||||
ipcRenderer.invoke('chat:getGroupMyMessageCountHint', chatroomId),
|
||||
getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId),
|
||||
getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) =>
|
||||
ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime, serverId),
|
||||
getAllVoiceMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllVoiceMessages', sessionId),
|
||||
getAllImageMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllImageMessages', sessionId),
|
||||
getMessageDates: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDates', sessionId),
|
||||
getMessageDateCounts: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDateCounts', sessionId),
|
||||
resolveVoiceCache: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:resolveVoiceCache', sessionId, msgId),
|
||||
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId, createTime),
|
||||
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => {
|
||||
const listener = (_: any, payload: { msgId: string; text: string }) => callback(payload)
|
||||
onVoiceTranscriptPartial: (callback: (payload: { sessionId?: string; msgId: string; createTime?: number; text: string }) => void) => {
|
||||
const listener = (_: any, payload: { sessionId?: string; msgId: string; createTime?: number; text: string }) => callback(payload)
|
||||
ipcRenderer.on('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'),
|
||||
getMessage: (sessionId: string, localId: number) =>
|
||||
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) => {
|
||||
ipcRenderer.on('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 }>) =>
|
||||
ipcRenderer.invoke('image:preload', payloads),
|
||||
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => {
|
||||
ipcRenderer.on('image:updateAvailable', (_, payload) => callback(payload))
|
||||
return () => ipcRenderer.removeAllListeners('image:updateAvailable')
|
||||
const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => callback(payload)
|
||||
ipcRenderer.on('image:updateAvailable', listener)
|
||||
return () => ipcRenderer.removeListener('image:updateAvailable', listener)
|
||||
},
|
||||
onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => {
|
||||
ipcRenderer.on('image:cacheResolved', (_, payload) => callback(payload))
|
||||
return () => ipcRenderer.removeAllListeners('image:cacheResolved')
|
||||
const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => callback(payload)
|
||||
ipcRenderer.on('image:cacheResolved', listener)
|
||||
return () => ipcRenderer.removeListener('image:cacheResolved', listener)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -217,18 +290,49 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
groupAnalytics: {
|
||||
getGroupChats: () => ipcRenderer.invoke('groupAnalytics:getGroupChats'),
|
||||
getGroupMembers: (chatroomId: string) => ipcRenderer.invoke('groupAnalytics:getGroupMembers', chatroomId),
|
||||
getGroupMembersPanelData: (
|
||||
chatroomId: string,
|
||||
options?: { forceRefresh?: boolean; includeMessageCounts?: boolean }
|
||||
) => ipcRenderer.invoke('groupAnalytics:getGroupMembersPanelData', chatroomId, options),
|
||||
getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMessageRanking', chatroomId, limit, startTime, endTime),
|
||||
getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime),
|
||||
getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime),
|
||||
exportGroupMembers: (chatroomId: string, outputPath: string) => ipcRenderer.invoke('groupAnalytics:exportGroupMembers', chatroomId, outputPath)
|
||||
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),
|
||||
exportGroupMemberMessages: (chatroomId: string, memberUsername: string, outputPath: string, startTime?: number, endTime?: number) =>
|
||||
ipcRenderer.invoke('groupAnalytics:exportGroupMemberMessages', chatroomId, memberUsername, outputPath, startTime, endTime)
|
||||
},
|
||||
|
||||
// 年度报告
|
||||
annualReport: {
|
||||
getAvailableYears: () => ipcRenderer.invoke('annualReport:getAvailableYears'),
|
||||
startAvailableYearsLoad: () => ipcRenderer.invoke('annualReport:startAvailableYearsLoad'),
|
||||
cancelAvailableYearsLoad: (taskId: string) => ipcRenderer.invoke('annualReport:cancelAvailableYearsLoad', taskId),
|
||||
generateReport: (year: number) => ipcRenderer.invoke('annualReport:generateReport', year),
|
||||
exportImages: (payload: { baseDir: string; folderName: string; images: Array<{ name: string; dataUrl: string }> }) =>
|
||||
ipcRenderer.invoke('annualReport:exportImages', payload),
|
||||
onAvailableYearsProgress: (callback: (payload: {
|
||||
taskId: string
|
||||
years?: number[]
|
||||
done: boolean
|
||||
error?: string
|
||||
canceled?: boolean
|
||||
strategy?: 'cache' | 'native' | 'hybrid'
|
||||
phase?: 'cache' | 'native' | 'scan' | 'done'
|
||||
statusText?: string
|
||||
nativeElapsedMs?: number
|
||||
scanElapsedMs?: number
|
||||
totalElapsedMs?: number
|
||||
switched?: boolean
|
||||
nativeTimedOut?: boolean
|
||||
}) => void) => {
|
||||
ipcRenderer.on('annualReport:availableYearsProgress', (_, payload) => callback(payload))
|
||||
return () => ipcRenderer.removeAllListeners('annualReport:availableYearsProgress')
|
||||
},
|
||||
onProgress: (callback: (payload: { status: string; progress: number }) => void) => {
|
||||
ipcRenderer.on('annualReport:progress', (_, payload) => callback(payload))
|
||||
return () => ipcRenderer.removeAllListeners('annualReport:progress')
|
||||
@@ -253,7 +357,20 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options),
|
||||
exportContacts: (outputDir: string, options: any) =>
|
||||
ipcRenderer.invoke('export:exportContacts', outputDir, options),
|
||||
onProgress: (callback: (payload: { current: number; total: number; currentSession: string; phase: string }) => void) => {
|
||||
onProgress: (callback: (payload: {
|
||||
current: number
|
||||
total: number
|
||||
currentSession: string
|
||||
currentSessionId?: string
|
||||
phase: string
|
||||
phaseProgress?: number
|
||||
phaseTotal?: number
|
||||
phaseLabel?: string
|
||||
collectedMessages?: number
|
||||
exportedMessages?: number
|
||||
estimatedTotalMessages?: number
|
||||
writtenFiles?: number
|
||||
}) => void) => {
|
||||
ipcRenderer.on('export:progress', (_, payload) => callback(payload))
|
||||
return () => ipcRenderer.removeAllListeners('export:progress')
|
||||
}
|
||||
@@ -274,30 +391,33 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
sns: {
|
||||
getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) =>
|
||||
ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime),
|
||||
getSnsUsernames: () => ipcRenderer.invoke('sns:getSnsUsernames'),
|
||||
getUserPostCounts: () => ipcRenderer.invoke('sns:getUserPostCounts'),
|
||||
getExportStatsFast: () => ipcRenderer.invoke('sns:getExportStatsFast'),
|
||||
getExportStats: () => ipcRenderer.invoke('sns:getExportStats'),
|
||||
getUserPostStats: (username: string) => ipcRenderer.invoke('sns:getUserPostStats', username),
|
||||
debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url),
|
||||
proxyImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:proxyImage', payload),
|
||||
downloadImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:downloadImage', payload)
|
||||
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),
|
||||
createSession: (systemPrompt?: string) => ipcRenderer.invoke('llama:createSession', systemPrompt),
|
||||
chat: (message: string, options?: any) => ipcRenderer.invoke('llama:chat', message, options),
|
||||
downloadModel: (url: string, savePath: string) => ipcRenderer.invoke('llama:downloadModel', url, savePath),
|
||||
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)
|
||||
}
|
||||
|
||||
// 数据收集
|
||||
cloud: {
|
||||
init: () => ipcRenderer.invoke('cloud:init'),
|
||||
recordPage: (pageName: string) => ipcRenderer.invoke('cloud:recordPage', pageName),
|
||||
getLogs: () => ipcRenderer.invoke('cloud:getLogs')
|
||||
},
|
||||
|
||||
// HTTP API 服务
|
||||
|
||||
@@ -68,33 +68,14 @@ class AnalyticsService {
|
||||
return new Set(this.getExcludedUsernamesList())
|
||||
}
|
||||
|
||||
private escapeSqlValue(value: string): string {
|
||||
return value.replace(/'/g, "''")
|
||||
}
|
||||
|
||||
private async getAliasMap(usernames: string[]): Promise<Record<string, string>> {
|
||||
const map: Record<string, string> = {}
|
||||
if (usernames.length === 0) return map
|
||||
|
||||
const chunkSize = 200
|
||||
for (let i = 0; i < usernames.length; i += chunkSize) {
|
||||
const chunk = usernames.slice(i, i + chunkSize)
|
||||
const inList = chunk.map((u) => `'${this.escapeSqlValue(u)}'`).join(',')
|
||||
if (!inList) continue
|
||||
const sql = `
|
||||
SELECT username, alias
|
||||
FROM contact
|
||||
WHERE username IN (${inList})
|
||||
`
|
||||
const 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
|
||||
}
|
||||
}
|
||||
const result = await wcdbService.getContactAliasMap(usernames)
|
||||
if (!result.success || !result.map) return map
|
||||
for (const [username, alias] of Object.entries(result.map)) {
|
||||
if (username && alias) map[username] = alias
|
||||
}
|
||||
|
||||
return map
|
||||
|
||||
@@ -85,7 +85,34 @@ export interface AnnualReportData {
|
||||
} | null
|
||||
}
|
||||
|
||||
export interface AvailableYearsLoadProgress {
|
||||
years: number[]
|
||||
strategy: 'cache' | 'native' | 'hybrid'
|
||||
phase: 'cache' | 'native' | 'scan'
|
||||
statusText: string
|
||||
nativeElapsedMs: number
|
||||
scanElapsedMs: number
|
||||
totalElapsedMs: number
|
||||
switched?: boolean
|
||||
nativeTimedOut?: boolean
|
||||
}
|
||||
|
||||
interface AvailableYearsLoadMeta {
|
||||
strategy: 'cache' | 'native' | 'hybrid'
|
||||
nativeElapsedMs: number
|
||||
scanElapsedMs: number
|
||||
totalElapsedMs: number
|
||||
switched: boolean
|
||||
nativeTimedOut: boolean
|
||||
statusText: string
|
||||
}
|
||||
|
||||
class AnnualReportService {
|
||||
private readonly availableYearsCacheTtlMs = 10 * 60 * 1000
|
||||
private readonly availableYearsScanConcurrency = 4
|
||||
private readonly availableYearsColumnCache = new Map<string, string>()
|
||||
private readonly availableYearsCache = new Map<string, { years: number[]; updatedAt: number }>()
|
||||
|
||||
constructor() {
|
||||
}
|
||||
|
||||
@@ -181,6 +208,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 {
|
||||
let content = this.decodeMaybeCompressed(compressContent)
|
||||
if (!content || content.length === 0) {
|
||||
@@ -359,38 +615,226 @@ class AnnualReportService {
|
||||
return { sessionId: bestSessionId, days: bestDays, start: bestStart, end: bestEnd }
|
||||
}
|
||||
|
||||
async getAvailableYears(params: { dbPath: string; decryptKey: string; wxid: string }): Promise<{ success: boolean; data?: number[]; error?: string }> {
|
||||
async getAvailableYears(params: {
|
||||
dbPath: string
|
||||
decryptKey: string
|
||||
wxid: string
|
||||
onProgress?: (payload: AvailableYearsLoadProgress) => void
|
||||
shouldCancel?: () => boolean
|
||||
nativeTimeoutMs?: number
|
||||
}): Promise<{ success: boolean; data?: number[]; error?: string; meta?: AvailableYearsLoadMeta }> {
|
||||
try {
|
||||
const isCancelled = () => params.shouldCancel?.() === true
|
||||
const totalStartedAt = Date.now()
|
||||
let nativeElapsedMs = 0
|
||||
let scanElapsedMs = 0
|
||||
let switched = false
|
||||
let nativeTimedOut = false
|
||||
let latestYears: number[] = []
|
||||
|
||||
const emitProgress = (payload: {
|
||||
years?: number[]
|
||||
strategy: 'cache' | 'native' | 'hybrid'
|
||||
phase: 'cache' | 'native' | 'scan'
|
||||
statusText: string
|
||||
switched?: boolean
|
||||
nativeTimedOut?: boolean
|
||||
}) => {
|
||||
if (!params.onProgress) return
|
||||
if (Array.isArray(payload.years)) latestYears = payload.years
|
||||
params.onProgress({
|
||||
years: latestYears,
|
||||
strategy: payload.strategy,
|
||||
phase: payload.phase,
|
||||
statusText: payload.statusText,
|
||||
nativeElapsedMs,
|
||||
scanElapsedMs,
|
||||
totalElapsedMs: Date.now() - totalStartedAt,
|
||||
switched: payload.switched ?? switched,
|
||||
nativeTimedOut: payload.nativeTimedOut ?? nativeTimedOut
|
||||
})
|
||||
}
|
||||
|
||||
const buildMeta = (
|
||||
strategy: 'cache' | 'native' | 'hybrid',
|
||||
statusText: string
|
||||
): AvailableYearsLoadMeta => ({
|
||||
strategy,
|
||||
nativeElapsedMs,
|
||||
scanElapsedMs,
|
||||
totalElapsedMs: Date.now() - totalStartedAt,
|
||||
switched,
|
||||
nativeTimedOut,
|
||||
statusText
|
||||
})
|
||||
|
||||
const conn = await this.ensureConnectedWithConfig(params.dbPath, params.decryptKey, params.wxid)
|
||||
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
|
||||
|
||||
const sessionIds = await this.getPrivateSessions(conn.cleanedWxid)
|
||||
if (sessionIds.length === 0) {
|
||||
return { success: false, error: '未找到消息会话' }
|
||||
}
|
||||
|
||||
const fastYears = await wcdbService.getAvailableYears(sessionIds)
|
||||
if (fastYears.success && fastYears.data) {
|
||||
return { success: true, data: fastYears.data }
|
||||
}
|
||||
|
||||
const years = new Set<number>()
|
||||
for (const sessionId of sessionIds) {
|
||||
const first = await this.getEdgeMessageTime(sessionId, true)
|
||||
const last = await this.getEdgeMessageTime(sessionId, false)
|
||||
if (!first && !last) continue
|
||||
|
||||
const minYear = new Date((first || last || 0) * 1000).getFullYear()
|
||||
const maxYear = new Date((last || first || 0) * 1000).getFullYear()
|
||||
for (let y = minYear; y <= maxYear; y++) {
|
||||
if (y >= 2010 && y <= new Date().getFullYear()) years.add(y)
|
||||
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error, meta: buildMeta('hybrid', '连接数据库失败') }
|
||||
if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') }
|
||||
const cacheKey = this.buildAvailableYearsCacheKey(params.dbPath, conn.cleanedWxid)
|
||||
const cached = this.getCachedAvailableYears(cacheKey)
|
||||
if (cached) {
|
||||
latestYears = cached
|
||||
emitProgress({
|
||||
years: cached,
|
||||
strategy: 'cache',
|
||||
phase: 'cache',
|
||||
statusText: '命中缓存,已快速加载年份数据'
|
||||
})
|
||||
return {
|
||||
success: true,
|
||||
data: cached,
|
||||
meta: buildMeta('cache', '命中缓存,已快速加载年份数据')
|
||||
}
|
||||
}
|
||||
|
||||
const sortedYears = Array.from(years).sort((a, b) => b - a)
|
||||
return { success: true, data: sortedYears }
|
||||
const sessionIds = await this.getPrivateSessions(conn.cleanedWxid)
|
||||
if (sessionIds.length === 0) {
|
||||
return { success: false, error: '未找到消息会话', meta: buildMeta('hybrid', '未找到消息会话') }
|
||||
}
|
||||
if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') }
|
||||
|
||||
const nativeTimeoutMs = Math.max(1000, Math.floor(params.nativeTimeoutMs || 5000))
|
||||
const nativeStartedAt = Date.now()
|
||||
let nativeTicker: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
emitProgress({
|
||||
strategy: 'native',
|
||||
phase: 'native',
|
||||
statusText: '正在使用原生快速模式加载年份...'
|
||||
})
|
||||
nativeTicker = setInterval(() => {
|
||||
nativeElapsedMs = Date.now() - nativeStartedAt
|
||||
emitProgress({
|
||||
strategy: 'native',
|
||||
phase: 'native',
|
||||
statusText: '正在使用原生快速模式加载年份...'
|
||||
})
|
||||
}, 120)
|
||||
|
||||
const nativeRace = await Promise.race([
|
||||
wcdbService.getAvailableYears(sessionIds)
|
||||
.then((result) => ({ kind: 'result' as const, result }))
|
||||
.catch((error) => ({ kind: 'error' as const, error: String(error) })),
|
||||
new Promise<{ kind: 'timeout' }>((resolve) => setTimeout(() => resolve({ kind: 'timeout' }), nativeTimeoutMs))
|
||||
])
|
||||
|
||||
if (nativeTicker) {
|
||||
clearInterval(nativeTicker)
|
||||
nativeTicker = null
|
||||
}
|
||||
nativeElapsedMs = Math.max(nativeElapsedMs, Date.now() - nativeStartedAt)
|
||||
|
||||
if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') }
|
||||
|
||||
if (nativeRace.kind === 'result' && nativeRace.result.success && Array.isArray(nativeRace.result.data) && nativeRace.result.data.length > 0) {
|
||||
const years = this.normalizeAvailableYears(nativeRace.result.data)
|
||||
latestYears = years
|
||||
this.setCachedAvailableYears(cacheKey, years)
|
||||
emitProgress({
|
||||
years,
|
||||
strategy: 'native',
|
||||
phase: 'native',
|
||||
statusText: '原生快速模式加载完成'
|
||||
})
|
||||
return {
|
||||
success: true,
|
||||
data: years,
|
||||
meta: buildMeta('native', '原生快速模式加载完成')
|
||||
}
|
||||
}
|
||||
|
||||
switched = true
|
||||
nativeTimedOut = nativeRace.kind === 'timeout'
|
||||
emitProgress({
|
||||
strategy: 'hybrid',
|
||||
phase: 'native',
|
||||
statusText: nativeTimedOut
|
||||
? '原生快速模式超时,已自动切换到扫表兼容模式...'
|
||||
: '原生快速模式不可用,已自动切换到扫表兼容模式...',
|
||||
switched: true,
|
||||
nativeTimedOut
|
||||
})
|
||||
|
||||
const scanStartedAt = Date.now()
|
||||
let scanTicker: ReturnType<typeof setInterval> | null = null
|
||||
scanTicker = setInterval(() => {
|
||||
scanElapsedMs = Date.now() - scanStartedAt
|
||||
emitProgress({
|
||||
strategy: 'hybrid',
|
||||
phase: 'scan',
|
||||
statusText: nativeTimedOut
|
||||
? '原生已超时,正在使用扫表兼容模式加载年份...'
|
||||
: '正在使用扫表兼容模式加载年份...',
|
||||
switched: true,
|
||||
nativeTimedOut
|
||||
})
|
||||
}, 120)
|
||||
|
||||
let years = await this.getAvailableYearsByTableScan(sessionIds, {
|
||||
onProgress: (items) => {
|
||||
latestYears = items
|
||||
scanElapsedMs = Date.now() - scanStartedAt
|
||||
emitProgress({
|
||||
years: items,
|
||||
strategy: 'hybrid',
|
||||
phase: 'scan',
|
||||
statusText: nativeTimedOut
|
||||
? '原生已超时,正在使用扫表兼容模式加载年份...'
|
||||
: '正在使用扫表兼容模式加载年份...',
|
||||
switched: true,
|
||||
nativeTimedOut
|
||||
})
|
||||
},
|
||||
shouldCancel: params.shouldCancel
|
||||
})
|
||||
|
||||
if (isCancelled()) {
|
||||
if (scanTicker) clearInterval(scanTicker)
|
||||
return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') }
|
||||
}
|
||||
if (years.length === 0) {
|
||||
years = await this.getAvailableYearsByEdgeScan(sessionIds, {
|
||||
onProgress: (items) => {
|
||||
latestYears = items
|
||||
scanElapsedMs = Date.now() - scanStartedAt
|
||||
emitProgress({
|
||||
years: items,
|
||||
strategy: 'hybrid',
|
||||
phase: 'scan',
|
||||
statusText: '扫表结果为空,正在执行游标兜底扫描...',
|
||||
switched: true,
|
||||
nativeTimedOut
|
||||
})
|
||||
},
|
||||
shouldCancel: params.shouldCancel
|
||||
})
|
||||
}
|
||||
if (scanTicker) {
|
||||
clearInterval(scanTicker)
|
||||
scanTicker = null
|
||||
}
|
||||
scanElapsedMs = Math.max(scanElapsedMs, Date.now() - scanStartedAt)
|
||||
|
||||
if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') }
|
||||
|
||||
this.setCachedAvailableYears(cacheKey, years)
|
||||
latestYears = years
|
||||
emitProgress({
|
||||
years,
|
||||
strategy: 'hybrid',
|
||||
phase: 'scan',
|
||||
statusText: '扫表兼容模式加载完成',
|
||||
switched: true,
|
||||
nativeTimedOut
|
||||
})
|
||||
return {
|
||||
success: true,
|
||||
data: years,
|
||||
meta: buildMeta('hybrid', '扫表兼容模式加载完成')
|
||||
}
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
return { success: false, error: String(e), meta: { strategy: 'hybrid', nativeElapsedMs: 0, scanElapsedMs: 0, totalElapsedMs: 0, switched: false, nativeTimedOut: false, statusText: '加载年度数据失败' } }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
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 { app } from 'electron'
|
||||
import { app, safeStorage } from 'electron'
|
||||
import crypto from 'crypto'
|
||||
import Store from 'electron-store'
|
||||
|
||||
// 加密前缀标记
|
||||
const SAFE_PREFIX = 'safe:' // safeStorage 加密(普通模式)
|
||||
const LOCK_PREFIX = 'lock:' // 密码派生密钥加密(锁定模式)
|
||||
|
||||
interface ConfigSchema {
|
||||
// 数据库相关
|
||||
dbPath: string // 数据库根目录 (xwechat_files)
|
||||
decryptKey: string // 解密密钥
|
||||
myWxid: string // 当前用户 wxid
|
||||
dbPath: string
|
||||
decryptKey: string
|
||||
myWxid: string
|
||||
onboardingDone: boolean
|
||||
imageXorKey: number
|
||||
imageAesKey: string
|
||||
wxidConfigs: Record<string, { decryptKey?: string; imageXorKey?: number; imageAesKey?: string; updatedAt?: number }>
|
||||
|
||||
exportPath?: string;
|
||||
// 缓存相关
|
||||
cachePath: string
|
||||
|
||||
lastOpenedDb: string
|
||||
lastSession: string
|
||||
|
||||
@@ -30,28 +34,45 @@ interface ConfigSchema {
|
||||
autoTranscribeVoice: boolean
|
||||
transcribeLanguages: string[]
|
||||
exportDefaultConcurrency: number
|
||||
exportDefaultImageDeepSearchOnMiss: boolean
|
||||
analyticsExcludedUsernames: string[]
|
||||
|
||||
// 安全相关
|
||||
authEnabled: boolean
|
||||
authPassword: string // SHA-256 hash
|
||||
authPassword: string // SHA-256 hash(safeStorage 加密)
|
||||
authUseHello: boolean
|
||||
authHelloSecret: string // 原始密码(safeStorage 加密,Hello 解锁时使用)
|
||||
|
||||
// 更新相关
|
||||
ignoredUpdateVersion: string
|
||||
|
||||
// 通知
|
||||
notificationEnabled: boolean
|
||||
notificationPosition: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'
|
||||
notificationPosition: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'
|
||||
notificationFilterMode: 'all' | 'whitelist' | 'blacklist'
|
||||
notificationFilterList: string[]
|
||||
messagePushEnabled: boolean
|
||||
windowCloseBehavior: 'ask' | 'tray' | 'quit'
|
||||
wordCloudExcludeWords: string[]
|
||||
}
|
||||
|
||||
// 需要 safeStorage 加密的字段(普通模式)
|
||||
const ENCRYPTED_STRING_KEYS: Set<string> = new Set(['decryptKey', 'imageAesKey', 'authPassword'])
|
||||
const ENCRYPTED_BOOL_KEYS: Set<string> = new Set(['authEnabled', 'authUseHello'])
|
||||
const ENCRYPTED_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
|
||||
|
||||
// 需要与密码绑定的敏感密钥字段(锁定模式时用 lock: 加密)
|
||||
const LOCKABLE_STRING_KEYS: Set<string> = new Set(['decryptKey', 'imageAesKey'])
|
||||
const LOCKABLE_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
|
||||
|
||||
export class ConfigService {
|
||||
private static instance: ConfigService
|
||||
private store!: Store<ConfigSchema>
|
||||
|
||||
// 锁定模式运行时状态
|
||||
private unlockedKeys: Map<string, any> = new Map()
|
||||
private unlockPassword: string | null = null
|
||||
|
||||
static getInstance(): ConfigService {
|
||||
if (!ConfigService.instance) {
|
||||
ConfigService.instance = new ConfigService()
|
||||
@@ -64,68 +85,630 @@ export class ConfigService {
|
||||
return ConfigService.instance
|
||||
}
|
||||
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',
|
||||
defaults: {
|
||||
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: 2,
|
||||
analyticsExcludedUsernames: [],
|
||||
|
||||
authEnabled: false,
|
||||
authPassword: '',
|
||||
authUseHello: false,
|
||||
|
||||
ignoredUpdateVersion: '',
|
||||
notificationEnabled: true,
|
||||
notificationPosition: 'top-right',
|
||||
notificationFilterMode: 'all',
|
||||
notificationFilterList: [],
|
||||
wordCloudExcludeWords: []
|
||||
defaults,
|
||||
projectName: String(process.env.WEFLOW_PROJECT_NAME || 'WeFlow').trim() || 'WeFlow'
|
||||
}
|
||||
const runningInWorker = process.env.WEFLOW_WORKER === '1'
|
||||
if (runningInWorker) {
|
||||
const cwd = String(process.env.WEFLOW_CONFIG_CWD || process.env.WEFLOW_USER_DATA_PATH || '').trim()
|
||||
if (cwd) {
|
||||
storeOptions.cwd = cwd
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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] {
|
||||
return this.store.get(key)
|
||||
const raw = this.store.get(key)
|
||||
|
||||
if (ENCRYPTED_BOOL_KEYS.has(key)) {
|
||||
const str = typeof raw === 'string' ? raw : ''
|
||||
if (!str || !str.startsWith(SAFE_PREFIX)) return raw
|
||||
return (this.safeDecrypt(str) === 'true') as ConfigSchema[K]
|
||||
}
|
||||
|
||||
if (ENCRYPTED_NUMBER_KEYS.has(key)) {
|
||||
const str = typeof raw === 'string' ? raw : ''
|
||||
if (!str) return raw
|
||||
if (str.startsWith(LOCK_PREFIX)) {
|
||||
const cached = this.unlockedKeys.get(key as string)
|
||||
return (cached !== undefined ? cached : 0) as ConfigSchema[K]
|
||||
}
|
||||
if (!str.startsWith(SAFE_PREFIX)) return raw
|
||||
const num = Number(this.safeDecrypt(str))
|
||||
return (Number.isFinite(num) ? num : 0) as ConfigSchema[K]
|
||||
}
|
||||
|
||||
if (ENCRYPTED_STRING_KEYS.has(key) && typeof raw === 'string') {
|
||||
if (key === 'authPassword') return this.safeDecrypt(raw) as ConfigSchema[K]
|
||||
if (raw.startsWith(LOCK_PREFIX)) {
|
||||
const cached = this.unlockedKeys.get(key as string)
|
||||
return (cached !== undefined ? cached : '') as ConfigSchema[K]
|
||||
}
|
||||
return this.safeDecrypt(raw) as ConfigSchema[K]
|
||||
}
|
||||
|
||||
if (key === 'wxidConfigs' && raw && typeof raw === 'object') {
|
||||
return this.decryptWxidConfigs(raw as any) as ConfigSchema[K]
|
||||
}
|
||||
|
||||
return raw
|
||||
}
|
||||
|
||||
set<K extends keyof ConfigSchema>(key: K, value: ConfigSchema[K]): void {
|
||||
this.store.set(key, value)
|
||||
let toStore = value
|
||||
const inLockMode = this.isLockMode() && this.unlockPassword
|
||||
|
||||
if (ENCRYPTED_BOOL_KEYS.has(key)) {
|
||||
toStore = this.safeEncrypt(String(value)) as ConfigSchema[K]
|
||||
} else if (ENCRYPTED_NUMBER_KEYS.has(key)) {
|
||||
if (inLockMode && LOCKABLE_NUMBER_KEYS.has(key)) {
|
||||
toStore = this.lockEncrypt(String(value), this.unlockPassword!) as ConfigSchema[K]
|
||||
this.unlockedKeys.set(key as string, value)
|
||||
} else {
|
||||
toStore = this.safeEncrypt(String(value)) as ConfigSchema[K]
|
||||
}
|
||||
} else if (ENCRYPTED_STRING_KEYS.has(key) && typeof value === 'string') {
|
||||
if (key === 'authPassword') {
|
||||
toStore = this.safeEncrypt(value) as ConfigSchema[K]
|
||||
} else if (inLockMode && LOCKABLE_STRING_KEYS.has(key)) {
|
||||
toStore = this.lockEncrypt(value, this.unlockPassword!) as ConfigSchema[K]
|
||||
this.unlockedKeys.set(key as string, value)
|
||||
} else {
|
||||
toStore = this.safeEncrypt(value) as ConfigSchema[K]
|
||||
}
|
||||
} else if (key === 'wxidConfigs' && value && typeof value === 'object') {
|
||||
if (inLockMode) {
|
||||
toStore = this.lockEncryptWxidConfigs(value as any) as ConfigSchema[K]
|
||||
} else {
|
||||
toStore = this.encryptWxidConfigs(value as any) as ConfigSchema[K]
|
||||
}
|
||||
}
|
||||
|
||||
this.store.set(key, toStore)
|
||||
}
|
||||
|
||||
// === 加密/解密工具 ===
|
||||
|
||||
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 {
|
||||
const configured = this.get('cachePath')
|
||||
if (configured && configured.trim().length > 0) {
|
||||
return configured
|
||||
}
|
||||
return join(app.getPath('documents'), 'WeFlow')
|
||||
return join(this.getUserDataPath(), 'cache')
|
||||
}
|
||||
|
||||
getAll(): ConfigSchema {
|
||||
getAll(): Partial<ConfigSchema> {
|
||||
return this.store.store
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.store.clear()
|
||||
this.unlockedKeys.clear()
|
||||
this.unlockPassword = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ interface ContactExportOptions {
|
||||
groups: boolean
|
||||
officials: boolean
|
||||
}
|
||||
selectedUsernames?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,6 +41,11 @@ class ContactExportService {
|
||||
return true
|
||||
})
|
||||
|
||||
if (Array.isArray(options.selectedUsernames) && options.selectedUsernames.length > 0) {
|
||||
const selectedSet = new Set(options.selectedUsernames)
|
||||
contacts = contacts.filter(c => selectedSet.has(c.username))
|
||||
}
|
||||
|
||||
if (contacts.length === 0) {
|
||||
return { success: false, error: '没有符合条件的联系人' }
|
||||
}
|
||||
|
||||
@@ -1,13 +1,90 @@
|
||||
import { join, basename } from 'path'
|
||||
import { existsSync, readdirSync, statSync } from 'fs'
|
||||
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
|
||||
import { homedir } from 'os'
|
||||
import { createDecipheriv } from 'crypto'
|
||||
|
||||
export interface WxidInfo {
|
||||
wxid: string
|
||||
modifiedTime: number
|
||||
nickname?: string
|
||||
avatarUrl?: string
|
||||
}
|
||||
|
||||
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 home = homedir()
|
||||
|
||||
// 微信4.x 数据目录
|
||||
possiblePaths.push(join(home, 'Documents', 'xwechat_files'))
|
||||
// macOS 微信路径(固定)
|
||||
if (process.platform === 'darwin') {
|
||||
possiblePaths.push(join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'xwechat_files'))
|
||||
} else {
|
||||
// Windows 微信4.x 数据目录
|
||||
possiblePaths.push(join(home, 'Documents', 'xwechat_files'))
|
||||
}
|
||||
|
||||
|
||||
for (const path of possiblePaths) {
|
||||
@@ -130,21 +212,16 @@ export class DbPathService {
|
||||
for (const entry of entries) {
|
||||
const entryPath = join(rootPath, entry)
|
||||
let stat: ReturnType<typeof statSync>
|
||||
try {
|
||||
stat = statSync(entryPath)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
try { stat = statSync(entryPath) } catch { continue }
|
||||
if (!stat.isDirectory()) continue
|
||||
const lower = entry.toLowerCase()
|
||||
if (lower === 'all_users') continue
|
||||
if (!entry.includes('_')) continue
|
||||
|
||||
wxids.push({ wxid: entry, modifiedTime: stat.mtimeMs })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (wxids.length === 0) {
|
||||
const rootName = basename(rootPath)
|
||||
if (rootName.includes('_') && rootName.toLowerCase() !== 'all_users') {
|
||||
@@ -154,12 +231,25 @@ export class DbPathService {
|
||||
}
|
||||
} catch { }
|
||||
|
||||
return wxids.sort((a, b) => {
|
||||
const sorted = wxids.sort((a, b) => {
|
||||
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
|
||||
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 列表
|
||||
*/
|
||||
@@ -182,10 +272,21 @@ export class DbPathService {
|
||||
}
|
||||
} catch { }
|
||||
|
||||
return wxids.sort((a, b) => {
|
||||
const sorted = wxids.sort((a, b) => {
|
||||
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
|
||||
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 {
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
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,44 @@ body {
|
||||
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 {
|
||||
color: #2563eb;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.message-link-card:hover {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.inline-emoji {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
|
||||
@@ -186,6 +186,44 @@ body {
|
||||
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 {
|
||||
color: #2563eb;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.message-link-card:hover {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.inline-emoji {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
|
||||
95
electron/services/exportRecordService.ts
Normal file
95
electron/services/exportRecordService.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { app } from 'electron'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
export interface ExportRecord {
|
||||
exportTime: number
|
||||
format: string
|
||||
messageCount: number
|
||||
sourceLatestMessageTimestamp?: number
|
||||
outputPath?: string
|
||||
}
|
||||
|
||||
type RecordStore = Record<string, ExportRecord[]>
|
||||
|
||||
class ExportRecordService {
|
||||
private filePath: string | null = null
|
||||
private loaded = false
|
||||
private store: RecordStore = {}
|
||||
|
||||
private resolveFilePath(): string {
|
||||
if (this.filePath) return this.filePath
|
||||
const userDataPath = app.getPath('userData')
|
||||
fs.mkdirSync(userDataPath, { recursive: true })
|
||||
this.filePath = path.join(userDataPath, 'weflow-export-records.json')
|
||||
return this.filePath
|
||||
}
|
||||
|
||||
private ensureLoaded(): void {
|
||||
if (this.loaded) return
|
||||
this.loaded = true
|
||||
const filePath = this.resolveFilePath()
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return
|
||||
const raw = fs.readFileSync(filePath, 'utf-8')
|
||||
const parsed = JSON.parse(raw)
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
this.store = parsed as RecordStore
|
||||
}
|
||||
} catch {
|
||||
this.store = {}
|
||||
}
|
||||
}
|
||||
|
||||
private persist(): void {
|
||||
try {
|
||||
const filePath = this.resolveFilePath()
|
||||
fs.writeFileSync(filePath, JSON.stringify(this.store), 'utf-8')
|
||||
} catch {
|
||||
// ignore persist errors to avoid blocking export flow
|
||||
}
|
||||
}
|
||||
|
||||
getLatestRecord(sessionId: string, format: string): ExportRecord | null {
|
||||
this.ensureLoaded()
|
||||
const records = this.store[sessionId]
|
||||
if (!records || records.length === 0) return null
|
||||
for (let i = records.length - 1; i >= 0; i--) {
|
||||
const record = records[i]
|
||||
if (record && record.format === format) return record
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
saveRecord(
|
||||
sessionId: string,
|
||||
format: string,
|
||||
messageCount: number,
|
||||
extra?: {
|
||||
sourceLatestMessageTimestamp?: number
|
||||
outputPath?: string
|
||||
}
|
||||
): void {
|
||||
this.ensureLoaded()
|
||||
const normalizedSessionId = String(sessionId || '').trim()
|
||||
if (!normalizedSessionId) return
|
||||
if (!this.store[normalizedSessionId]) {
|
||||
this.store[normalizedSessionId] = []
|
||||
}
|
||||
const list = this.store[normalizedSessionId]
|
||||
list.push({
|
||||
exportTime: Date.now(),
|
||||
format,
|
||||
messageCount,
|
||||
sourceLatestMessageTimestamp: extra?.sourceLatestMessageTimestamp,
|
||||
outputPath: extra?.outputPath
|
||||
})
|
||||
// keep the latest 30 records per session
|
||||
if (list.length > 30) {
|
||||
this.store[normalizedSessionId] = list.slice(-30)
|
||||
}
|
||||
this.persist()
|
||||
}
|
||||
}
|
||||
|
||||
export const exportRecordService = new ExportRecordService()
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
204
electron/services/groupMyMessageCountCacheService.ts
Normal file
204
electron/services/groupMyMessageCountCacheService.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { join, dirname } from 'path'
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
|
||||
import { ConfigService } from './config'
|
||||
|
||||
const CACHE_VERSION = 1
|
||||
const MAX_GROUP_ENTRIES_PER_SCOPE = 3000
|
||||
const MAX_SCOPE_ENTRIES = 12
|
||||
|
||||
export interface GroupMyMessageCountCacheEntry {
|
||||
updatedAt: number
|
||||
messageCount: number
|
||||
}
|
||||
|
||||
interface GroupMyMessageCountScopeMap {
|
||||
[chatroomId: string]: GroupMyMessageCountCacheEntry
|
||||
}
|
||||
|
||||
interface GroupMyMessageCountCacheStore {
|
||||
version: number
|
||||
scopes: Record<string, GroupMyMessageCountScopeMap>
|
||||
}
|
||||
|
||||
function toNonNegativeInt(value: unknown): number | undefined {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return undefined
|
||||
return Math.max(0, Math.floor(value))
|
||||
}
|
||||
|
||||
function normalizeEntry(raw: unknown): GroupMyMessageCountCacheEntry | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const source = raw as Record<string, unknown>
|
||||
const updatedAt = toNonNegativeInt(source.updatedAt)
|
||||
const messageCount = toNonNegativeInt(source.messageCount)
|
||||
if (updatedAt === undefined || messageCount === undefined) return null
|
||||
return {
|
||||
updatedAt,
|
||||
messageCount
|
||||
}
|
||||
}
|
||||
|
||||
export class GroupMyMessageCountCacheService {
|
||||
private readonly cacheFilePath: string
|
||||
private store: GroupMyMessageCountCacheStore = {
|
||||
version: CACHE_VERSION,
|
||||
scopes: {}
|
||||
}
|
||||
|
||||
constructor(cacheBasePath?: string) {
|
||||
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
|
||||
? cacheBasePath
|
||||
: ConfigService.getInstance().getCacheBasePath()
|
||||
this.cacheFilePath = join(basePath, 'group-my-message-counts.json')
|
||||
this.ensureCacheDir()
|
||||
this.load()
|
||||
}
|
||||
|
||||
private ensureCacheDir(): void {
|
||||
const dir = dirname(this.cacheFilePath)
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
private load(): void {
|
||||
if (!existsSync(this.cacheFilePath)) return
|
||||
try {
|
||||
const raw = readFileSync(this.cacheFilePath, 'utf8')
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||
return
|
||||
}
|
||||
|
||||
const payload = parsed as Record<string, unknown>
|
||||
const scopesRaw = payload.scopes
|
||||
if (!scopesRaw || typeof scopesRaw !== 'object') {
|
||||
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||
return
|
||||
}
|
||||
|
||||
const scopes: Record<string, GroupMyMessageCountScopeMap> = {}
|
||||
for (const [scopeKey, scopeValue] of Object.entries(scopesRaw as Record<string, unknown>)) {
|
||||
if (!scopeValue || typeof scopeValue !== 'object') continue
|
||||
const normalizedScope: GroupMyMessageCountScopeMap = {}
|
||||
for (const [chatroomId, entryRaw] of Object.entries(scopeValue as Record<string, unknown>)) {
|
||||
const entry = normalizeEntry(entryRaw)
|
||||
if (!entry) continue
|
||||
normalizedScope[chatroomId] = entry
|
||||
}
|
||||
if (Object.keys(normalizedScope).length > 0) {
|
||||
scopes[scopeKey] = normalizedScope
|
||||
}
|
||||
}
|
||||
|
||||
this.store = {
|
||||
version: CACHE_VERSION,
|
||||
scopes
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('GroupMyMessageCountCacheService: 载入缓存失败', error)
|
||||
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||
}
|
||||
}
|
||||
|
||||
get(scopeKey: string, chatroomId: string): GroupMyMessageCountCacheEntry | undefined {
|
||||
if (!scopeKey || !chatroomId) return undefined
|
||||
const scope = this.store.scopes[scopeKey]
|
||||
if (!scope) return undefined
|
||||
const entry = normalizeEntry(scope[chatroomId])
|
||||
if (!entry) {
|
||||
delete scope[chatroomId]
|
||||
if (Object.keys(scope).length === 0) {
|
||||
delete this.store.scopes[scopeKey]
|
||||
}
|
||||
this.persist()
|
||||
return undefined
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
set(scopeKey: string, chatroomId: string, entry: GroupMyMessageCountCacheEntry): void {
|
||||
if (!scopeKey || !chatroomId) return
|
||||
const normalized = normalizeEntry(entry)
|
||||
if (!normalized) return
|
||||
|
||||
if (!this.store.scopes[scopeKey]) {
|
||||
this.store.scopes[scopeKey] = {}
|
||||
}
|
||||
|
||||
const existing = this.store.scopes[scopeKey][chatroomId]
|
||||
if (existing && existing.updatedAt > normalized.updatedAt) {
|
||||
return
|
||||
}
|
||||
|
||||
this.store.scopes[scopeKey][chatroomId] = normalized
|
||||
this.trimScope(scopeKey)
|
||||
this.trimScopes()
|
||||
this.persist()
|
||||
}
|
||||
|
||||
delete(scopeKey: string, chatroomId: string): void {
|
||||
if (!scopeKey || !chatroomId) return
|
||||
const scope = this.store.scopes[scopeKey]
|
||||
if (!scope) return
|
||||
if (!(chatroomId in scope)) return
|
||||
delete scope[chatroomId]
|
||||
if (Object.keys(scope).length === 0) {
|
||||
delete this.store.scopes[scopeKey]
|
||||
}
|
||||
this.persist()
|
||||
}
|
||||
|
||||
clearScope(scopeKey: string): void {
|
||||
if (!scopeKey) return
|
||||
if (!this.store.scopes[scopeKey]) return
|
||||
delete this.store.scopes[scopeKey]
|
||||
this.persist()
|
||||
}
|
||||
|
||||
clearAll(): void {
|
||||
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||
try {
|
||||
rmSync(this.cacheFilePath, { force: true })
|
||||
} catch (error) {
|
||||
console.error('GroupMyMessageCountCacheService: 清理缓存失败', error)
|
||||
}
|
||||
}
|
||||
|
||||
private trimScope(scopeKey: string): void {
|
||||
const scope = this.store.scopes[scopeKey]
|
||||
if (!scope) return
|
||||
const entries = Object.entries(scope)
|
||||
if (entries.length <= MAX_GROUP_ENTRIES_PER_SCOPE) return
|
||||
entries.sort((a, b) => b[1].updatedAt - a[1].updatedAt)
|
||||
const trimmed: GroupMyMessageCountScopeMap = {}
|
||||
for (const [chatroomId, entry] of entries.slice(0, MAX_GROUP_ENTRIES_PER_SCOPE)) {
|
||||
trimmed[chatroomId] = entry
|
||||
}
|
||||
this.store.scopes[scopeKey] = trimmed
|
||||
}
|
||||
|
||||
private trimScopes(): void {
|
||||
const scopeEntries = Object.entries(this.store.scopes)
|
||||
if (scopeEntries.length <= MAX_SCOPE_ENTRIES) return
|
||||
scopeEntries.sort((a, b) => {
|
||||
const aUpdatedAt = Math.max(...Object.values(a[1]).map((entry) => entry.updatedAt), 0)
|
||||
const bUpdatedAt = Math.max(...Object.values(b[1]).map((entry) => entry.updatedAt), 0)
|
||||
return bUpdatedAt - aUpdatedAt
|
||||
})
|
||||
|
||||
const trimmedScopes: Record<string, GroupMyMessageCountScopeMap> = {}
|
||||
for (const [scopeKey, scopeMap] of scopeEntries.slice(0, MAX_SCOPE_ENTRIES)) {
|
||||
trimmedScopes[scopeKey] = scopeMap
|
||||
}
|
||||
this.store.scopes = trimmedScopes
|
||||
}
|
||||
|
||||
private persist(): void {
|
||||
try {
|
||||
writeFileSync(this.cacheFilePath, JSON.stringify(this.store), 'utf8')
|
||||
} catch (error) {
|
||||
console.error('GroupMyMessageCountCacheService: 保存缓存失败', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
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 { existsSync, readdirSync, statSync, readFileSync } from 'fs'
|
||||
import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync } from 'fs'
|
||||
import { app } from 'electron'
|
||||
import { ConfigService } from './config'
|
||||
import Database from 'better-sqlite3'
|
||||
import { wcdbService } from './wcdbService'
|
||||
|
||||
export interface VideoInfo {
|
||||
videoUrl?: string // 视频文件路径(用于 readFile)
|
||||
coverUrl?: string // 封面 data URL
|
||||
thumbUrl?: string // 缩略图 data URL
|
||||
exists: boolean
|
||||
videoUrl?: string // 视频文件路径(用于 readFile)
|
||||
coverUrl?: string // 封面 data URL
|
||||
thumbUrl?: string // 缩略图 data URL
|
||||
exists: boolean
|
||||
}
|
||||
|
||||
interface TimedCacheEntry<T> {
|
||||
value: T
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
interface VideoIndexEntry {
|
||||
videoPath?: string
|
||||
coverPath?: string
|
||||
thumbPath?: string
|
||||
}
|
||||
|
||||
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() {
|
||||
this.configService = new ConfigService()
|
||||
constructor() {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据库根目录
|
||||
*/
|
||||
private getDbPath(): string {
|
||||
return this.configService.get('dbPath') || ''
|
||||
while (cache.size > maxEntries) {
|
||||
const oldestKey = cache.keys().next().value as string | undefined
|
||||
if (!oldestKey) break
|
||||
cache.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据库根目录
|
||||
*/
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前用户的wxid
|
||||
*/
|
||||
private getMyWxid(): string {
|
||||
return this.configService.get('myWxid') || ''
|
||||
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
||||
if (suffixMatch) return suffixMatch[1]
|
||||
|
||||
return trimmed
|
||||
}
|
||||
|
||||
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')]
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存目录(解密后的数据库存放位置)
|
||||
*/
|
||||
private getCachePath(): string {
|
||||
return this.configService.getCacheBasePath()
|
||||
return [
|
||||
join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'),
|
||||
join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db')
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理 wxid 目录名(去掉后缀)
|
||||
*/
|
||||
private cleanWxid(wxid: string): string {
|
||||
const trimmed = wxid.trim()
|
||||
if (!trimmed) return trimmed
|
||||
if (unresolvedSet.size === 0) return resolvedMap
|
||||
|
||||
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})$/)
|
||||
if (suffixMatch) return suffixMatch[1]
|
||||
|
||||
return trimmed
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 video_hardlink_info_v4 表查询视频文件名
|
||||
* 优先使用 cachePath 中解密后的 hardlink.db(使用 better-sqlite3)
|
||||
* 如果失败,则尝试使用 wcdbService.execQuery 查询加密的 hardlink.db
|
||||
*/
|
||||
private async queryVideoFileName(md5: string): Promise<string | undefined> {
|
||||
const cachePath = this.getCachePath()
|
||||
const dbPath = this.getDbPath()
|
||||
const wxid = this.getMyWxid()
|
||||
const cleanedWxid = this.cleanWxid(wxid)
|
||||
|
||||
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')
|
||||
const encryptedDbPaths = this.getHardlinkDbPaths(dbPath, wxid, cleanedWxid)
|
||||
for (const p of encryptedDbPaths) {
|
||||
if (!existsSync(p) || unresolvedSet.size === 0) continue
|
||||
const unresolved = Array.from(unresolvedSet)
|
||||
const requests = unresolved.map((md5) => ({ md5, dbPath: p }))
|
||||
try {
|
||||
const batchResult = await wcdbService.resolveVideoHardlinkMd5Batch(requests)
|
||||
if (batchResult.success && Array.isArray(batchResult.rows)) {
|
||||
for (const row of batchResult.rows) {
|
||||
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()
|
||||
if (!inputMd5) continue
|
||||
const resolvedMd5 = row?.success && row?.data?.resolved_md5
|
||||
? String(row.data.resolved_md5).trim().toLowerCase()
|
||||
: ''
|
||||
if (!resolvedMd5) continue
|
||||
const cacheKey = `${scopeKey}|${inputMd5}`
|
||||
this.writeTimedCache(this.hardlinkResolveCache, cacheKey, resolvedMd5, this.hardlinkCacheTtlMs, this.maxCacheEntries)
|
||||
resolvedMap.set(inputMd5, resolvedMd5)
|
||||
unresolvedSet.delete(inputMd5)
|
||||
}
|
||||
} 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 { }
|
||||
}
|
||||
}
|
||||
|
||||
if (!existsSync(videoBaseDir)) {
|
||||
return { exists: false }
|
||||
}
|
||||
|
||||
// 遍历年月目录查找视频文件
|
||||
try {
|
||||
const allDirs = readdirSync(videoBaseDir)
|
||||
|
||||
// 支持多种目录格式: YYYY-MM, YYYYMM, 或其他
|
||||
const yearMonthDirs = allDirs
|
||||
.filter(dir => {
|
||||
const dirPath = join(videoBaseDir, dir)
|
||||
return statSync(dirPath).isDirectory()
|
||||
})
|
||||
.sort((a, b) => b.localeCompare(a)) // 从最新的目录开始查找
|
||||
|
||||
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 }
|
||||
} catch (e) {
|
||||
this.log('resolveVideoHardlinks 批量查询失败', { path: p, error: String(e) })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据消息内容解析视频MD5
|
||||
*/
|
||||
parseVideoMd5(content: string): string | undefined {
|
||||
for (const md5 of unresolvedSet) {
|
||||
const cacheKey = `${scopeKey}|${md5}`
|
||||
this.writeTimedCache(this.hardlinkResolveCache, cacheKey, null, this.hardlinkCacheTtlMs, this.maxCacheEntries)
|
||||
}
|
||||
|
||||
// 打印前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 {
|
||||
// 提取所有可能的 md5 值进行日志
|
||||
const allMd5s: string[] = []
|
||||
const md5Regex = /(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]+)['"]/gi
|
||||
let match
|
||||
while ((match = md5Regex.exec(content)) !== null) {
|
||||
allMd5s.push(`${match[0]}`)
|
||||
}
|
||||
|
||||
// 提取 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)
|
||||
files = readdirSync(dirPath)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
@@ -48,6 +48,38 @@ export class VoiceTranscribeService {
|
||||
private recognizer: OfflineRecognizer | null = null
|
||||
private isInitializing = false
|
||||
|
||||
private buildTranscribeWorkerEnv(): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = { ...process.env }
|
||||
const platform = process.platform === 'win32' ? 'win' : process.platform
|
||||
const platformPkg = `sherpa-onnx-${platform}-${process.arch}`
|
||||
const candidates = [
|
||||
join(__dirname, '..', 'node_modules', platformPkg),
|
||||
join(__dirname, 'node_modules', platformPkg),
|
||||
join(process.cwd(), 'node_modules', platformPkg),
|
||||
process.resourcesPath ? join(process.resourcesPath, 'app.asar.unpacked', 'node_modules', platformPkg) : ''
|
||||
].filter((item): item is string => Boolean(item) && existsSync(item))
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
const key = 'DYLD_LIBRARY_PATH'
|
||||
const existing = env[key] || ''
|
||||
const merged = [...candidates, ...existing.split(':').filter(Boolean)]
|
||||
env[key] = Array.from(new Set(merged)).join(':')
|
||||
if (candidates.length === 0) {
|
||||
console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`)
|
||||
}
|
||||
} else if (process.platform === 'linux') {
|
||||
const key = 'LD_LIBRARY_PATH'
|
||||
const existing = env[key] || ''
|
||||
const merged = [...candidates, ...existing.split(':').filter(Boolean)]
|
||||
env[key] = Array.from(new Set(merged)).join(':')
|
||||
if (candidates.length === 0) {
|
||||
console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`)
|
||||
}
|
||||
}
|
||||
|
||||
return env
|
||||
}
|
||||
|
||||
private resolveModelDir(): string {
|
||||
const configured = this.configService.get('whisperModelDir') as string | undefined
|
||||
if (configured) return configured
|
||||
@@ -206,17 +238,20 @@ export class VoiceTranscribeService {
|
||||
}
|
||||
}
|
||||
|
||||
const { Worker } = require('worker_threads')
|
||||
const { fork } = require('child_process')
|
||||
const workerPath = join(__dirname, 'transcribeWorker.js')
|
||||
|
||||
const worker = new Worker(workerPath, {
|
||||
workerData: {
|
||||
modelPath,
|
||||
tokensPath,
|
||||
wavData,
|
||||
sampleRate: 16000,
|
||||
languages: supportedLanguages
|
||||
}
|
||||
const worker = fork(workerPath, [], {
|
||||
env: this.buildTranscribeWorkerEnv(),
|
||||
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
|
||||
serialization: 'advanced'
|
||||
})
|
||||
worker.send({
|
||||
modelPath,
|
||||
tokensPath,
|
||||
wavData,
|
||||
sampleRate: 16000,
|
||||
languages: supportedLanguages
|
||||
})
|
||||
|
||||
let finalTranscript = ''
|
||||
@@ -227,17 +262,31 @@ export class VoiceTranscribeService {
|
||||
} else if (msg.type === 'final') {
|
||||
finalTranscript = msg.text
|
||||
resolve({ success: true, transcript: finalTranscript })
|
||||
worker.terminate()
|
||||
worker.disconnect()
|
||||
worker.kill()
|
||||
} else if (msg.type === 'error') {
|
||||
console.error('[VoiceTranscribe] Worker 错误:', msg.error)
|
||||
resolve({ success: false, error: msg.error })
|
||||
worker.terminate()
|
||||
worker.disconnect()
|
||||
worker.kill()
|
||||
}
|
||||
})
|
||||
|
||||
worker.on('error', (err: Error) => resolve({ success: false, error: String(err) }))
|
||||
worker.on('exit', (code: number) => {
|
||||
if (code !== 0) resolve({ success: false, error: `Worker exited with code ${code}` })
|
||||
worker.on('exit', (code: number | null, signal: string | null) => {
|
||||
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) {
|
||||
@@ -458,8 +507,18 @@ export class VoiceTranscribeService {
|
||||
|
||||
writer.on('error', (err) => {
|
||||
clearInterval(speedInterval)
|
||||
// 确保在错误情况下也关闭文件句柄
|
||||
writer.destroy()
|
||||
reject(err)
|
||||
})
|
||||
|
||||
response.on('error', (err) => {
|
||||
clearInterval(speedInterval)
|
||||
// 确保在响应错误时也关闭文件句柄
|
||||
writer.destroy()
|
||||
reject(err)
|
||||
})
|
||||
|
||||
response.pipe(writer)
|
||||
})
|
||||
request.on('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 {
|
||||
this.monitorListener = callback;
|
||||
// Notify worker to enable monitor
|
||||
this.callWorker('setMonitor').catch(() => { });
|
||||
this.callWorker<{ success?: boolean }>('setMonitor').catch(() => { });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,6 +164,10 @@ export class WcdbService {
|
||||
return this.callWorker('open', { dbPath, hexKey, wxid })
|
||||
}
|
||||
|
||||
async getLastInitError(): Promise<string | null> {
|
||||
return this.callWorker('getLastInitError')
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭数据库连接
|
||||
*/
|
||||
@@ -175,10 +178,10 @@ export class WcdbService {
|
||||
/**
|
||||
* 关闭服务
|
||||
*/
|
||||
shutdown(): void {
|
||||
this.close()
|
||||
async shutdown(): Promise<void> {
|
||||
try { await this.close() } catch {}
|
||||
if (this.worker) {
|
||||
this.worker.terminate()
|
||||
try { await this.worker.terminate() } catch {}
|
||||
this.worker = null
|
||||
}
|
||||
}
|
||||
@@ -219,6 +222,52 @@ export class WcdbService {
|
||||
return this.callWorker('getMessageCount', { sessionId })
|
||||
}
|
||||
|
||||
async getMessageCounts(sessionIds: string[]): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> {
|
||||
return this.callWorker('getMessageCounts', { sessionIds })
|
||||
}
|
||||
|
||||
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 +333,14 @@ export class WcdbService {
|
||||
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 +348,33 @@ export class WcdbService {
|
||||
return this.callWorker('getContact', { username })
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量获取联系人 extra_buffer 状态(isFolded/isMuted)
|
||||
*/
|
||||
async getContactStatus(usernames: string[]): Promise<{ success: boolean; map?: Record<string, { isFolded: boolean; isMuted: boolean }>; error?: string }> {
|
||||
return this.callWorker('getContactStatus', { usernames })
|
||||
}
|
||||
|
||||
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 +446,10 @@ export class WcdbService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 SQL 查询
|
||||
* 执行 SQL 查询(仅主进程内部使用:fallback/diagnostic/低频兼容)
|
||||
*/
|
||||
async execQuery(kind: string, path: string | null, sql: string): Promise<{ success: boolean; rows?: any[]; error?: string }> {
|
||||
return this.callWorker('execQuery', { kind, path, sql })
|
||||
async execQuery(kind: string, path: string | null, sql: string, params: any[] = []): Promise<{ success: boolean; rows?: any[]; error?: string }> {
|
||||
return this.callWorker('execQuery', { kind, path, sql, params })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -375,6 +459,20 @@ export class WcdbService {
|
||||
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 +494,10 @@ export class WcdbService {
|
||||
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 +505,40 @@ export class WcdbService {
|
||||
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 +553,42 @@ export class WcdbService {
|
||||
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 内部日志
|
||||
*/
|
||||
@@ -445,6 +617,27 @@ export class WcdbService {
|
||||
return this.callWorker('deleteMessage', { sessionId, localId, createTime, dbPathHint })
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据收集:初始化
|
||||
*/
|
||||
async cloudInit(intervalSeconds: number): Promise<{ success: boolean; error?: string }> {
|
||||
return this.callWorker('cloudInit', { intervalSeconds })
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据收集:上报数据
|
||||
*/
|
||||
async cloudReport(statsJson: string): Promise<{ success: boolean; error?: string }> {
|
||||
return this.callWorker('cloudReport', { statsJson })
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据收集:停止
|
||||
*/
|
||||
cloudStop(): Promise<{ success: boolean; error?: string }> {
|
||||
return this.callWorker('cloudStop', {})
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -1,13 +1,56 @@
|
||||
import { parentPort, workerData } from 'worker_threads'
|
||||
import { existsSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
|
||||
interface WorkerParams {
|
||||
modelPath: string
|
||||
tokensPath: string
|
||||
wavData: Buffer
|
||||
wavData: Buffer | Uint8Array | { type: 'Buffer'; data: number[] }
|
||||
sampleRate: number
|
||||
languages?: string[]
|
||||
}
|
||||
|
||||
function appendLibrarySearchPath(libDir: string): void {
|
||||
if (!existsSync(libDir)) return
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
const current = process.env.DYLD_LIBRARY_PATH || ''
|
||||
const paths = current.split(':').filter(Boolean)
|
||||
if (!paths.includes(libDir)) {
|
||||
process.env.DYLD_LIBRARY_PATH = [libDir, ...paths].join(':')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
const current = process.env.LD_LIBRARY_PATH || ''
|
||||
const paths = current.split(':').filter(Boolean)
|
||||
if (!paths.includes(libDir)) {
|
||||
process.env.LD_LIBRARY_PATH = [libDir, ...paths].join(':')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function prepareSherpaRuntimeEnv(): void {
|
||||
const platform = process.platform === 'win32' ? 'win' : process.platform
|
||||
const platformPkg = `sherpa-onnx-${platform}-${process.arch}`
|
||||
const resourcesPath = (process as any).resourcesPath as string | undefined
|
||||
|
||||
const candidates = [
|
||||
// Dev: /project/dist-electron -> /project/node_modules/...
|
||||
join(__dirname, '..', 'node_modules', platformPkg),
|
||||
// Fallback for alternate layouts
|
||||
join(__dirname, 'node_modules', platformPkg),
|
||||
join(process.cwd(), 'node_modules', platformPkg),
|
||||
// Packaged app: Resources/app.asar.unpacked/node_modules/...
|
||||
resourcesPath ? join(resourcesPath, 'app.asar.unpacked', 'node_modules', platformPkg) : ''
|
||||
].filter(Boolean)
|
||||
|
||||
for (const dir of candidates) {
|
||||
appendLibrarySearchPath(dir)
|
||||
}
|
||||
}
|
||||
|
||||
// 语言标记映射
|
||||
const LANGUAGE_TAGS: Record<string, string> = {
|
||||
'zh': '<|zh|>',
|
||||
@@ -95,22 +138,60 @@ function isLanguageAllowed(result: any, allowedLanguages: string[]): boolean {
|
||||
}
|
||||
|
||||
async function run() {
|
||||
if (!parentPort) {
|
||||
return;
|
||||
const isForkProcess = !parentPort
|
||||
const emit = (msg: any) => {
|
||||
if (parentPort) {
|
||||
parentPort.postMessage(msg)
|
||||
return
|
||||
}
|
||||
if (typeof process.send === 'function') {
|
||||
process.send(msg)
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeBuffer = (data: WorkerParams['wavData']): Buffer => {
|
||||
if (Buffer.isBuffer(data)) return data
|
||||
if (data instanceof Uint8Array) return Buffer.from(data)
|
||||
if (data && typeof data === 'object' && (data as any).type === 'Buffer' && Array.isArray((data as any).data)) {
|
||||
return Buffer.from((data as any).data)
|
||||
}
|
||||
return Buffer.alloc(0)
|
||||
}
|
||||
|
||||
const readParams = async (): Promise<WorkerParams | null> => {
|
||||
if (parentPort) {
|
||||
return workerData as WorkerParams
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let settled = false
|
||||
const finish = (value: WorkerParams | null) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
resolve(value)
|
||||
}
|
||||
process.once('message', (msg) => finish(msg as WorkerParams))
|
||||
process.once('disconnect', () => finish(null))
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
prepareSherpaRuntimeEnv()
|
||||
const params = await readParams()
|
||||
if (!params) return
|
||||
|
||||
// 动态加载以捕获可能的加载错误(如 C++ 运行库缺失等)
|
||||
let sherpa: any;
|
||||
try {
|
||||
sherpa = require('sherpa-onnx-node');
|
||||
} catch (requireError) {
|
||||
parentPort.postMessage({ type: 'error', error: 'Failed to load speech engine: ' + String(requireError) });
|
||||
emit({ type: 'error', error: 'Failed to load speech engine: ' + String(requireError) });
|
||||
if (isForkProcess) process.exit(1)
|
||||
return;
|
||||
}
|
||||
|
||||
const { modelPath, tokensPath, wavData: rawWavData, sampleRate, languages } = workerData as WorkerParams
|
||||
const wavData = Buffer.from(rawWavData);
|
||||
const { modelPath, tokensPath, wavData: rawWavData, sampleRate, languages } = params
|
||||
const wavData = normalizeBuffer(rawWavData);
|
||||
// 确保有有效的语言列表,默认只允许中文
|
||||
let allowedLanguages = languages || ['zh']
|
||||
if (allowedLanguages.length === 0) {
|
||||
@@ -151,16 +232,18 @@ async function run() {
|
||||
if (isLanguageAllowed(result, allowedLanguages)) {
|
||||
const processedText = richTranscribePostProcess(result.text)
|
||||
|
||||
parentPort.postMessage({ type: 'final', text: processedText })
|
||||
emit({ type: 'final', text: processedText })
|
||||
if (isForkProcess) process.exit(0)
|
||||
} else {
|
||||
|
||||
parentPort.postMessage({ type: 'final', text: '' })
|
||||
emit({ type: 'final', text: '' })
|
||||
if (isForkProcess) process.exit(0)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
parentPort.postMessage({ type: 'error', error: String(error) })
|
||||
emit({ type: 'error', error: String(error) })
|
||||
if (isForkProcess) process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
|
||||
|
||||
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,21 +20,26 @@ if (parentPort) {
|
||||
result = { success: true }
|
||||
break
|
||||
case 'setMonitor':
|
||||
core.setMonitor((type, json) => {
|
||||
{
|
||||
const monitorOk = core.setMonitor((type, json) => {
|
||||
parentPort!.postMessage({
|
||||
id: -1,
|
||||
type: 'monitor',
|
||||
payload: { type, json }
|
||||
})
|
||||
})
|
||||
result = { success: true }
|
||||
result = { success: monitorOk }
|
||||
break
|
||||
}
|
||||
case 'testConnection':
|
||||
result = await core.testConnection(payload.dbPath, payload.hexKey, payload.wxid)
|
||||
break
|
||||
case 'open':
|
||||
result = await core.open(payload.dbPath, payload.hexKey, payload.wxid)
|
||||
break
|
||||
case 'getLastInitError':
|
||||
result = core.getLastInitError()
|
||||
break
|
||||
case 'close':
|
||||
core.close()
|
||||
result = { success: true }
|
||||
@@ -54,6 +59,27 @@ if (parentPort) {
|
||||
case 'getMessageCount':
|
||||
result = await core.getMessageCount(payload.sessionId)
|
||||
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':
|
||||
result = await core.getDisplayNames(payload.usernames)
|
||||
break
|
||||
@@ -84,9 +110,33 @@ if (parentPort) {
|
||||
case 'getMessageMeta':
|
||||
result = await core.getMessageMeta(payload.dbPath, payload.tableName, payload.limit, payload.offset)
|
||||
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':
|
||||
result = await core.getContact(payload.username)
|
||||
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':
|
||||
result = await core.getAggregateStats(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp)
|
||||
break
|
||||
@@ -118,11 +168,17 @@ if (parentPort) {
|
||||
result = await core.closeMessageCursor(payload.cursor)
|
||||
break
|
||||
case 'execQuery':
|
||||
result = await core.execQuery(payload.kind, payload.path, payload.sql)
|
||||
result = await core.execQuery(payload.kind, payload.path, payload.sql, payload.params)
|
||||
break
|
||||
case 'getEmoticonCdnUrl':
|
||||
result = await core.getEmoticonCdnUrl(payload.dbPath, payload.md5)
|
||||
break
|
||||
case 'getEmoticonCaption':
|
||||
result = await core.getEmoticonCaption(payload.dbPath, payload.md5)
|
||||
break
|
||||
case 'getEmoticonCaptionStrict':
|
||||
result = await core.getEmoticonCaptionStrict(payload.md5)
|
||||
break
|
||||
case 'listMessageDbs':
|
||||
result = await core.listMessageDbs()
|
||||
break
|
||||
@@ -132,18 +188,60 @@ if (parentPort) {
|
||||
case 'getMessageById':
|
||||
result = await core.getMessageById(payload.sessionId, payload.localId)
|
||||
break
|
||||
case 'searchMessages':
|
||||
result = await core.searchMessages(payload.keyword, payload.sessionId, payload.limit, payload.offset, payload.beginTimestamp, payload.endTimestamp)
|
||||
break
|
||||
case 'getVoiceData':
|
||||
result = await core.getVoiceData(payload.sessionId, payload.createTime, payload.candidates, payload.localId, payload.svrId)
|
||||
if (!result.success) {
|
||||
console.error('[wcdbWorker] getVoiceData failed:', result.error)
|
||||
}
|
||||
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':
|
||||
result = await core.getSnsTimeline(payload.limit, payload.offset, payload.usernames, payload.keyword, payload.startTime, payload.endTime)
|
||||
break
|
||||
case 'getSnsAnnualStats':
|
||||
result = await core.getSnsAnnualStats(payload.beginTimestamp, payload.endTimestamp)
|
||||
break
|
||||
case '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':
|
||||
result = await core.getLogs()
|
||||
break
|
||||
@@ -156,7 +254,15 @@ if (parentPort) {
|
||||
case 'deleteMessage':
|
||||
result = await core.deleteMessage(payload.sessionId, payload.localId, payload.createTime, payload.dbPathHint)
|
||||
break
|
||||
|
||||
case 'cloudInit':
|
||||
result = await core.cloudInit(payload.intervalSeconds)
|
||||
break
|
||||
case 'cloudReport':
|
||||
result = await core.cloudReport(payload.statsJson)
|
||||
break
|
||||
case 'cloudStop':
|
||||
result = core.cloudStop()
|
||||
break
|
||||
default:
|
||||
result = { success: false, error: `Unknown method: ${type}` }
|
||||
}
|
||||
|
||||
@@ -5,6 +5,28 @@ import { ConfigService } from '../services/config'
|
||||
let notificationWindow: BrowserWindow | null = null
|
||||
let closeTimer: NodeJS.Timeout | null = null
|
||||
|
||||
export function destroyNotificationWindow() {
|
||||
if (closeTimer) {
|
||||
clearTimeout(closeTimer)
|
||||
closeTimer = null
|
||||
}
|
||||
lastNotificationData = null
|
||||
|
||||
if (!notificationWindow || notificationWindow.isDestroyed()) {
|
||||
notificationWindow = null
|
||||
return
|
||||
}
|
||||
|
||||
const win = notificationWindow
|
||||
notificationWindow = null
|
||||
|
||||
try {
|
||||
win.destroy()
|
||||
} catch (error) {
|
||||
console.warn('[NotificationWindow] Failed to destroy window:', error)
|
||||
}
|
||||
}
|
||||
|
||||
export function createNotificationWindow() {
|
||||
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
||||
return notificationWindow
|
||||
@@ -110,7 +132,7 @@ async function showAndSend(win: BrowserWindow, data: any) {
|
||||
|
||||
// 更新位置
|
||||
const { width: screenWidth, height: screenHeight } = screen.getPrimaryDisplay().workAreaSize
|
||||
const winWidth = 344
|
||||
const winWidth = position === 'top-center' ? 280 : 344
|
||||
const winHeight = 114
|
||||
const padding = 20
|
||||
|
||||
@@ -118,6 +140,10 @@ async function showAndSend(win: BrowserWindow, data: any) {
|
||||
let y = 0
|
||||
|
||||
switch (position) {
|
||||
case 'top-center':
|
||||
x = (screenWidth - winWidth) / 2
|
||||
y = padding
|
||||
break
|
||||
case 'top-right':
|
||||
x = screenWidth - winWidth - padding
|
||||
y = padding
|
||||
@@ -144,7 +170,7 @@ async function showAndSend(win: BrowserWindow, data: any) {
|
||||
win.showInactive() // 显示但不聚焦
|
||||
win.setAlwaysOnTop(true, 'screen-saver') // 最高层级
|
||||
|
||||
win.webContents.send('notification:show', data)
|
||||
win.webContents.send('notification:show', { ...data, position })
|
||||
|
||||
// 自动关闭计时器通常由渲染进程管理
|
||||
// 渲染进程发送 'notification:close' 来隐藏窗口
|
||||
|
||||
2156
package-lock.json
generated
2156
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
44
package.json
44
package.json
@@ -1,9 +1,12 @@
|
||||
{
|
||||
"name": "weflow",
|
||||
"version": "2.0.1",
|
||||
"version": "2.1.0",
|
||||
"description": "WeFlow",
|
||||
"main": "dist-electron/main.js",
|
||||
"author": "cc",
|
||||
"author": {
|
||||
"name": "cc",
|
||||
"email": "yccccccy@proton.me"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/hicccc77/WeFlow"
|
||||
@@ -13,13 +16,13 @@
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"rebuild": "electron-rebuild",
|
||||
"dev": "vite",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsc && vite build && electron-builder",
|
||||
"preview": "vite preview",
|
||||
"electron:dev": "vite --mode electron",
|
||||
"electron:build": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.5.0",
|
||||
"echarts": "^5.5.1",
|
||||
"echarts-for-react": "^3.0.2",
|
||||
"electron-store": "^10.0.0",
|
||||
@@ -32,7 +35,6 @@
|
||||
"jszip": "^3.10.1",
|
||||
"koffi": "^2.9.0",
|
||||
"lucide-react": "^0.562.0",
|
||||
"node-llama-cpp": "^3.15.1",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
@@ -41,12 +43,12 @@
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sherpa-onnx-node": "^1.10.38",
|
||||
"silk-wasm": "^3.7.1",
|
||||
"sudo-prompt": "^9.2.1",
|
||||
"wechat-emojis": "^1.0.2",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron/rebuild": "^4.0.2",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
@@ -72,12 +74,35 @@
|
||||
"directories": {
|
||||
"output": "release"
|
||||
},
|
||||
"mac": {
|
||||
"target": [
|
||||
"dmg",
|
||||
"zip"
|
||||
],
|
||||
"category": "public.app-category.utilities",
|
||||
"hardenedRuntime": false,
|
||||
"gatekeeperAssess": false,
|
||||
"entitlements": "electron/entitlements.mac.plist",
|
||||
"entitlementsInherit": "electron/entitlements.mac.plist",
|
||||
"icon": "resources/icon.icns"
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
"nsis"
|
||||
],
|
||||
"icon": "public/icon.ico"
|
||||
},
|
||||
"linux": {
|
||||
"icon": "public/icon.png",
|
||||
"target": [
|
||||
"appimage",
|
||||
"deb",
|
||||
"tar.gz"
|
||||
],
|
||||
"category": "Utility",
|
||||
"executableName": "weflow",
|
||||
"synopsis": "WeFlow for Linux"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"differentialPackage": false,
|
||||
@@ -108,6 +133,10 @@
|
||||
"from": "public/icon.ico",
|
||||
"to": "icon.ico"
|
||||
},
|
||||
{
|
||||
"from": "public/icon.png",
|
||||
"to": "icon.png"
|
||||
},
|
||||
{
|
||||
"from": "electron/assets/wasm/",
|
||||
"to": "assets/wasm/"
|
||||
@@ -120,6 +149,8 @@
|
||||
"asarUnpack": [
|
||||
"node_modules/silk-wasm/**/*",
|
||||
"node_modules/sherpa-onnx-node/**/*",
|
||||
"node_modules/sherpa-onnx-*/*",
|
||||
"node_modules/sherpa-onnx-*/**/*",
|
||||
"node_modules/ffmpeg-static/**/*"
|
||||
],
|
||||
"extraFiles": [
|
||||
@@ -139,6 +170,7 @@
|
||||
"from": "resources/vcruntime140_1.dll",
|
||||
"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.
55
src/App.scss
55
src/App.scss
@@ -4,6 +4,48 @@
|
||||
flex-direction: column;
|
||||
background: var(--bg-primary);
|
||||
animation: appFadeIn 0.35s ease-out;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// 繁花如梦:底色层(::before)+ 光晕层(::after)分离,避免 blur 吃掉边缘
|
||||
[data-theme="blossom-dream"] .app-container {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
// ::before 纯底色,不模糊
|
||||
[data-theme="blossom-dream"] .app-container::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: -2;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
// ::after 光晕层,模糊叠加在底色上
|
||||
[data-theme="blossom-dream"] .app-container::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
background:
|
||||
radial-gradient(ellipse 55% 45% at 15% 20%, var(--blossom-pink) 0%, transparent 70%),
|
||||
radial-gradient(ellipse 50% 40% at 85% 75%, var(--blossom-peach) 0%, transparent 65%),
|
||||
radial-gradient(ellipse 45% 50% at 80% 10%, var(--blossom-blue) 0%, transparent 60%);
|
||||
filter: blur(80px);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
// 深色模式光晕更克制
|
||||
[data-theme="blossom-dream"][data-mode="dark"] .app-container::after {
|
||||
background:
|
||||
radial-gradient(ellipse 55% 45% at 15% 20%, var(--blossom-pink) 0%, transparent 70%),
|
||||
radial-gradient(ellipse 50% 40% at 85% 75%, var(--blossom-purple) 0%, transparent 65%),
|
||||
radial-gradient(ellipse 45% 50% at 80% 10%, var(--blossom-blue) 0%, transparent 60%);
|
||||
filter: blur(100px);
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.window-drag-region {
|
||||
@@ -27,6 +69,19 @@
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.export-keepalive-page {
|
||||
height: 100%;
|
||||
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.export-route-anchor {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@keyframes appFadeIn {
|
||||
|
||||
333
src/App.tsx
333
src/App.tsx
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Routes, Route, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Routes, Route, Navigate, useNavigate, useLocation, type Location } from 'react-router-dom'
|
||||
import TitleBar from './components/TitleBar'
|
||||
import Sidebar from './components/Sidebar'
|
||||
import RouteGuard from './components/RouteGuard'
|
||||
@@ -8,6 +8,7 @@ import HomePage from './pages/HomePage'
|
||||
import ChatPage from './pages/ChatPage'
|
||||
import AnalyticsPage from './pages/AnalyticsPage'
|
||||
import AnalyticsWelcomePage from './pages/AnalyticsWelcomePage'
|
||||
import ChatAnalyticsHubPage from './pages/ChatAnalyticsHubPage'
|
||||
import AnnualReportPage from './pages/AnnualReportPage'
|
||||
import AnnualReportWindow from './pages/AnnualReportWindow'
|
||||
import DualReportPage from './pages/DualReportPage'
|
||||
@@ -22,11 +23,11 @@ import SnsPage from './pages/SnsPage'
|
||||
import ContactsPage from './pages/ContactsPage'
|
||||
import ChatHistoryPage from './pages/ChatHistoryPage'
|
||||
import NotificationWindow from './pages/NotificationWindow'
|
||||
import AIChatPage from './pages/AIChatPage'
|
||||
|
||||
import { useAppStore } from './stores/appStore'
|
||||
import { themes, useThemeStore, type ThemeId } from './stores/themeStore'
|
||||
import { themes, useThemeStore, type ThemeId, type ThemeMode } from './stores/themeStore'
|
||||
import * as configService from './services/config'
|
||||
import * as cloudControl from './services/cloudControl'
|
||||
import { Download, X, Shield } from 'lucide-react'
|
||||
import './App.scss'
|
||||
|
||||
@@ -35,10 +36,25 @@ import UpdateProgressCapsule from './components/UpdateProgressCapsule'
|
||||
import LockScreen from './components/LockScreen'
|
||||
import { GlobalSessionMonitor } from './components/GlobalSessionMonitor'
|
||||
import { BatchTranscribeGlobal } from './components/BatchTranscribeGlobal'
|
||||
import { BatchImageDecryptGlobal } from './components/BatchImageDecryptGlobal'
|
||||
import WindowCloseDialog from './components/WindowCloseDialog'
|
||||
|
||||
function RouteStateRedirect({ to }: { to: string }) {
|
||||
const location = useLocation()
|
||||
|
||||
return <Navigate to={to} replace state={location.state} />
|
||||
}
|
||||
|
||||
function App() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const settingsBackgroundRef = useRef<Location>({
|
||||
pathname: '/home',
|
||||
search: '',
|
||||
hash: '',
|
||||
state: null,
|
||||
key: 'settings-fallback'
|
||||
} as Location)
|
||||
|
||||
const {
|
||||
setDbConnected,
|
||||
@@ -59,9 +75,19 @@ function App() {
|
||||
const isAgreementWindow = location.pathname === '/agreement-window'
|
||||
const isOnboardingWindow = location.pathname === '/onboarding-window'
|
||||
const isVideoPlayerWindow = location.pathname === '/video-player-window'
|
||||
const isChatHistoryWindow = location.pathname.startsWith('/chat-history/')
|
||||
const 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 isSettingsRoute = location.pathname === '/settings'
|
||||
const settingsRouteState = location.state as { backgroundLocation?: Location; initialTab?: unknown } | null
|
||||
const routeLocation = isSettingsRoute
|
||||
? settingsRouteState?.backgroundLocation ?? settingsBackgroundRef.current
|
||||
: location
|
||||
const isExportRoute = routeLocation.pathname === '/export'
|
||||
const [themeHydrated, setThemeHydrated] = useState(false)
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||
const [showCloseDialog, setShowCloseDialog] = useState(false)
|
||||
const [canMinimizeToTray, setCanMinimizeToTray] = useState(false)
|
||||
|
||||
// 锁定状态
|
||||
// const [isLocked, setIsLocked] = useState(false) // Moved to store
|
||||
@@ -75,6 +101,62 @@ function App() {
|
||||
const [agreementChecked, setAgreementChecked] = useState(false)
|
||||
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(() => {
|
||||
const root = document.documentElement
|
||||
const body = document.body
|
||||
@@ -101,14 +183,23 @@ function App() {
|
||||
|
||||
// 应用主题
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', currentTheme)
|
||||
document.documentElement.setAttribute('data-mode', themeMode)
|
||||
|
||||
// 更新窗口控件颜色以适配主题
|
||||
const symbolColor = themeMode === 'dark' ? '#ffffff' : '#1a1a1a'
|
||||
if (!isOnboardingWindow && !isNotificationWindow) {
|
||||
window.electronAPI.window.setTitleBarOverlay({ symbolColor })
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const applyMode = (mode: ThemeMode, systemDark?: boolean) => {
|
||||
const effectiveMode = mode === 'system' ? (systemDark ?? mq.matches ? 'dark' : 'light') : mode
|
||||
document.documentElement.setAttribute('data-theme', currentTheme)
|
||||
document.documentElement.setAttribute('data-mode', effectiveMode)
|
||||
}
|
||||
|
||||
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])
|
||||
|
||||
// 读取已保存的主题设置
|
||||
@@ -122,7 +213,7 @@ function App() {
|
||||
if (savedThemeId && themes.some((theme) => theme.id === savedThemeId)) {
|
||||
setTheme(savedThemeId as ThemeId)
|
||||
}
|
||||
if (savedThemeMode === 'light' || savedThemeMode === 'dark') {
|
||||
if (savedThemeMode === 'light' || savedThemeMode === 'dark' || savedThemeMode === 'system') {
|
||||
setThemeMode(savedThemeMode)
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -157,6 +248,14 @@ function App() {
|
||||
const agreed = await configService.getAgreementAccepted()
|
||||
if (!agreed) {
|
||||
setShowAgreement(true)
|
||||
} else {
|
||||
// 协议已同意,检查数据收集同意状态
|
||||
const consent = await configService.getAnalyticsConsent()
|
||||
const denyCount = await configService.getAnalyticsDenyCount()
|
||||
// 如果未设置同意状态且拒绝次数小于2次,显示弹窗
|
||||
if (consent === null && denyCount < 2) {
|
||||
setShowAnalyticsConsent(true)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('检查协议状态失败:', e)
|
||||
@@ -167,25 +266,56 @@ function App() {
|
||||
checkAgreement()
|
||||
}, [])
|
||||
|
||||
// 初始化数据收集
|
||||
useEffect(() => {
|
||||
cloudControl.initCloudControl()
|
||||
}, [])
|
||||
|
||||
// 记录页面访问
|
||||
useEffect(() => {
|
||||
const path = location.pathname
|
||||
if (path && path !== '/') {
|
||||
cloudControl.recordPage(path)
|
||||
}
|
||||
}, [location.pathname])
|
||||
|
||||
const handleAgree = async () => {
|
||||
if (!agreementChecked) return
|
||||
await configService.setAgreementAccepted(true)
|
||||
setShowAgreement(false)
|
||||
// 协议同意后,检查数据收集同意
|
||||
const consent = await configService.getAnalyticsConsent()
|
||||
if (consent === null) {
|
||||
setShowAnalyticsConsent(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDisagree = () => {
|
||||
window.electronAPI.window.close()
|
||||
}
|
||||
|
||||
const handleAnalyticsAllow = async () => {
|
||||
await configService.setAnalyticsConsent(true)
|
||||
setShowAnalyticsConsent(false)
|
||||
}
|
||||
|
||||
const handleAnalyticsDeny = async () => {
|
||||
const denyCount = await configService.getAnalyticsDenyCount()
|
||||
await configService.setAnalyticsDenyCount(denyCount + 1)
|
||||
setShowAnalyticsConsent(false)
|
||||
}
|
||||
|
||||
// 监听启动时的更新通知
|
||||
useEffect(() => {
|
||||
if (isNotificationWindow) return // Skip updates in notification window
|
||||
|
||||
const removeUpdateListener = window.electronAPI?.app?.onUpdateAvailable?.((info: any) => {
|
||||
// 发现新版本时自动打开更新弹窗
|
||||
// 发现新版本时保存更新信息,锁定状态下不弹窗,解锁后再显示
|
||||
if (info) {
|
||||
setUpdateInfo({ ...info, hasUpdate: true })
|
||||
setShowUpdateDialog(true)
|
||||
if (!useAppStore.getState().isLocked) {
|
||||
setShowUpdateDialog(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
const removeProgressListener = window.electronAPI?.app?.onDownloadProgress?.((progress: any) => {
|
||||
@@ -197,6 +327,13 @@ function App() {
|
||||
}
|
||||
}, [setUpdateInfo, setDownloadProgress, setShowUpdateDialog, isNotificationWindow])
|
||||
|
||||
// 解锁后显示暂存的更新弹窗
|
||||
useEffect(() => {
|
||||
if (!isLocked && updateInfo?.hasUpdate && !showUpdateDialog && !isDownloading) {
|
||||
setShowUpdateDialog(true)
|
||||
}
|
||||
}, [isLocked])
|
||||
|
||||
const handleUpdateNow = async () => {
|
||||
setShowUpdateDialog(false)
|
||||
setIsDownloading(true)
|
||||
@@ -228,6 +365,26 @@ function App() {
|
||||
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(() => {
|
||||
if (isAgreementWindow || isOnboardingWindow) return
|
||||
@@ -291,7 +448,7 @@ function App() {
|
||||
const checkLock = async () => {
|
||||
// 并行获取配置,减少等待
|
||||
const [enabled, useHello] = await Promise.all([
|
||||
configService.getAuthEnabled(),
|
||||
window.electronAPI.auth.verifyEnabled(),
|
||||
configService.getAuthUseHello()
|
||||
])
|
||||
|
||||
@@ -313,6 +470,8 @@ function App() {
|
||||
checkLock()
|
||||
}, [isAgreementWindow, isOnboardingWindow, isVideoPlayerWindow])
|
||||
|
||||
|
||||
|
||||
// 独立协议窗口
|
||||
if (isAgreementWindow) {
|
||||
return <AgreementPage />
|
||||
@@ -338,12 +497,51 @@ function App() {
|
||||
return <ChatHistoryPage />
|
||||
}
|
||||
|
||||
// 独立会话聊天窗口(仅显示聊天内容区域)
|
||||
if (isStandaloneChatWindow) {
|
||||
const params = new URLSearchParams(location.search)
|
||||
const sessionId = params.get('sessionId') || ''
|
||||
const standaloneSource = params.get('source')
|
||||
const standaloneInitialDisplayName = params.get('initialDisplayName')
|
||||
const standaloneInitialAvatarUrl = params.get('initialAvatarUrl')
|
||||
const standaloneInitialContactType = params.get('initialContactType')
|
||||
return (
|
||||
<ChatPage
|
||||
standaloneSessionWindow
|
||||
initialSessionId={sessionId}
|
||||
standaloneSource={standaloneSource}
|
||||
standaloneInitialDisplayName={standaloneInitialDisplayName}
|
||||
standaloneInitialAvatarUrl={standaloneInitialAvatarUrl}
|
||||
standaloneInitialContactType={standaloneInitialContactType}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// 独立通知窗口
|
||||
if (isNotificationWindow) {
|
||||
return <NotificationWindow />
|
||||
}
|
||||
|
||||
// 主窗口 - 完整布局
|
||||
const handleCloseSettings = () => {
|
||||
const backgroundLocation = settingsRouteState?.backgroundLocation ?? settingsBackgroundRef.current
|
||||
if (backgroundLocation.pathname === '/settings') {
|
||||
navigate('/home', { replace: true })
|
||||
return
|
||||
}
|
||||
navigate(
|
||||
{
|
||||
pathname: backgroundLocation.pathname,
|
||||
search: backgroundLocation.search,
|
||||
hash: backgroundLocation.hash
|
||||
},
|
||||
{
|
||||
replace: true,
|
||||
state: backgroundLocation.state
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-container">
|
||||
<div className="window-drag-region" aria-hidden="true" />
|
||||
@@ -354,7 +552,10 @@ function App() {
|
||||
useHello={lockUseHello}
|
||||
/>
|
||||
)}
|
||||
<TitleBar />
|
||||
<TitleBar
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
onToggleSidebar={() => setSidebarCollapsed((prev) => !prev)}
|
||||
/>
|
||||
|
||||
{/* 全局悬浮进度胶囊 (处理:新版本提示、下载进度、错误提示) */}
|
||||
<UpdateProgressCapsule />
|
||||
@@ -364,6 +565,7 @@ function App() {
|
||||
|
||||
{/* 全局批量转写进度浮窗 */}
|
||||
<BatchTranscribeGlobal />
|
||||
<BatchImageDecryptGlobal />
|
||||
|
||||
{/* 用户协议弹窗 */}
|
||||
{showAgreement && !agreementLoading && (
|
||||
@@ -416,6 +618,69 @@ function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 数据收集同意弹窗 */}
|
||||
{showAnalyticsConsent && !agreementLoading && (
|
||||
<div className="agreement-overlay">
|
||||
<div className="agreement-modal">
|
||||
<div className="agreement-header">
|
||||
<Shield size={32} />
|
||||
<h2>使用数据收集说明</h2>
|
||||
</div>
|
||||
<div className="agreement-content">
|
||||
<div className="agreement-text">
|
||||
<p>为了持续改进 WeFlow 并提供更好的用户体验,我们希望收集一些匿名的使用数据。</p>
|
||||
|
||||
<h4>我们会收集什么?</h4>
|
||||
<p>• 功能使用情况(如哪些功能被使用、使用频率)</p>
|
||||
<p>• 应用性能数据(如加载时间、错误日志)</p>
|
||||
<p>• 设备基本信息(如操作系统版本、应用版本)</p>
|
||||
|
||||
<h4>我们不会收集什么?</h4>
|
||||
<p>• 你的聊天记录内容</p>
|
||||
<p>• 个人身份信息</p>
|
||||
<p>• 联系人信息</p>
|
||||
<p>• 任何可以识别你身份的数据</p>
|
||||
<p>• 一切你担心会涉及隐藏的数据</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div className="agreement-footer">
|
||||
<div className="agreement-actions">
|
||||
<button className="btn btn-secondary" onClick={handleAnalyticsDeny}>不允许</button>
|
||||
<button className="btn btn-primary" onClick={handleAnalyticsAllow}>允许</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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
|
||||
open={showUpdateDialog}
|
||||
@@ -427,32 +692,50 @@ function App() {
|
||||
progress={downloadProgress}
|
||||
/>
|
||||
|
||||
<WindowCloseDialog
|
||||
open={showCloseDialog}
|
||||
canMinimizeToTray={canMinimizeToTray}
|
||||
onSelect={(action, rememberChoice) => handleWindowCloseAction(action, rememberChoice)}
|
||||
onCancel={() => handleWindowCloseAction('cancel')}
|
||||
/>
|
||||
|
||||
<div className="main-layout">
|
||||
<Sidebar />
|
||||
<Sidebar collapsed={sidebarCollapsed} />
|
||||
<main className="content">
|
||||
<RouteGuard>
|
||||
<Routes>
|
||||
<div className={`export-keepalive-page ${isExportRoute ? 'active' : 'hidden'}`} aria-hidden={!isExportRoute}>
|
||||
<ExportPage />
|
||||
</div>
|
||||
|
||||
<Routes location={routeLocation}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/home" element={<HomePage />} />
|
||||
<Route path="/chat" element={<ChatPage />} />
|
||||
<Route path="/ai-chat" element={<AIChatPage />} />
|
||||
<Route path="/analytics" element={<AnalyticsWelcomePage />} />
|
||||
<Route path="/analytics/view" element={<AnalyticsPage />} />
|
||||
<Route path="/group-analytics" element={<GroupAnalyticsPage />} />
|
||||
|
||||
<Route path="/analytics" element={<ChatAnalyticsHubPage />} />
|
||||
<Route path="/analytics/private" element={<AnalyticsWelcomePage />} />
|
||||
<Route path="/analytics/private/view" element={<AnalyticsPage />} />
|
||||
<Route path="/analytics/group" element={<GroupAnalyticsPage />} />
|
||||
<Route path="/analytics/view" element={<RouteStateRedirect to="/analytics/private/view" />} />
|
||||
<Route path="/group-analytics" element={<RouteStateRedirect to="/analytics/group" />} />
|
||||
<Route path="/annual-report" element={<AnnualReportPage />} />
|
||||
<Route path="/annual-report/view" element={<AnnualReportWindow />} />
|
||||
<Route path="/dual-report" element={<DualReportPage />} />
|
||||
<Route path="/dual-report/view" element={<DualReportWindow />} />
|
||||
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/export" element={<ExportPage />} />
|
||||
<Route path="/export" element={<div className="export-route-anchor" aria-hidden="true" />} />
|
||||
<Route path="/sns" element={<SnsPage />} />
|
||||
<Route path="/contacts" element={<ContactsPage />} />
|
||||
<Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} />
|
||||
<Route path="/chat-history-inline/:payloadId" element={<ChatHistoryPage />} />
|
||||
</Routes>
|
||||
</RouteGuard>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{isSettingsRoute && (
|
||||
<SettingsPage onClose={handleCloseSettings} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -50,6 +50,21 @@
|
||||
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 */
|
||||
.avatar-skeleton {
|
||||
position: absolute;
|
||||
@@ -76,4 +91,14 @@
|
||||
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 { User } from 'lucide-react'
|
||||
import { Loader2, User } from 'lucide-react'
|
||||
import { avatarLoadQueue } from '../utils/AvatarLoadQueue'
|
||||
import './Avatar.scss'
|
||||
|
||||
@@ -13,6 +13,7 @@ interface AvatarProps {
|
||||
shape?: 'circle' | 'square' | 'rounded'
|
||||
className?: string
|
||||
lazy?: boolean
|
||||
loading?: boolean
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
@@ -23,12 +24,14 @@ export const Avatar = React.memo(function Avatar({
|
||||
shape = 'rounded',
|
||||
className = '',
|
||||
lazy = true,
|
||||
loading = false,
|
||||
onClick
|
||||
}: AvatarProps) {
|
||||
// 如果 URL 已在缓存中,则直接标记为已加载,不显示骨架屏和淡入动画
|
||||
const isCached = useMemo(() => src ? loadedAvatarCache.has(src) : false, [src])
|
||||
const isFailed = useMemo(() => src ? avatarLoadQueue.hasFailed(src) : false, [src])
|
||||
const [imageLoaded, setImageLoaded] = useState(isCached)
|
||||
const [imageError, setImageError] = useState(false)
|
||||
const [imageError, setImageError] = useState(isFailed)
|
||||
const [shouldLoad, setShouldLoad] = useState(!lazy || isCached)
|
||||
const [isInQueue, setIsInQueue] = useState(false)
|
||||
const imgRef = useRef<HTMLImageElement>(null)
|
||||
@@ -42,7 +45,7 @@ export const Avatar = React.memo(function Avatar({
|
||||
|
||||
// Intersection Observer for lazy loading
|
||||
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(
|
||||
(entries) => {
|
||||
@@ -50,10 +53,11 @@ export const Avatar = React.memo(function Avatar({
|
||||
if (entry.isIntersecting && !isInQueue) {
|
||||
setIsInQueue(true)
|
||||
avatarLoadQueue.enqueue(src).then(() => {
|
||||
setImageError(false)
|
||||
setShouldLoad(true)
|
||||
}).catch(() => {
|
||||
// 加载失败不要立刻显示错误,让浏览器渲染去报错
|
||||
setShouldLoad(true)
|
||||
setImageError(true)
|
||||
setShouldLoad(false)
|
||||
}).finally(() => {
|
||||
setIsInQueue(false)
|
||||
})
|
||||
@@ -67,14 +71,18 @@ export const Avatar = React.memo(function Avatar({
|
||||
observer.observe(containerRef.current)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [src, lazy, shouldLoad, isInQueue, isCached])
|
||||
}, [src, lazy, shouldLoad, isInQueue, isCached, imageError, isFailed])
|
||||
|
||||
// Reset state when src changes
|
||||
useEffect(() => {
|
||||
const cached = src ? loadedAvatarCache.has(src) : false
|
||||
const failed = src ? avatarLoadQueue.hasFailed(src) : false
|
||||
setImageLoaded(cached)
|
||||
setImageError(false)
|
||||
if (lazy && !cached) {
|
||||
setImageError(failed)
|
||||
if (failed) {
|
||||
setShouldLoad(false)
|
||||
setIsInQueue(false)
|
||||
} else if (lazy && !cached) {
|
||||
setShouldLoad(false)
|
||||
setIsInQueue(false)
|
||||
} else {
|
||||
@@ -95,6 +103,7 @@ export const Avatar = React.memo(function Avatar({
|
||||
}
|
||||
|
||||
const hasValidUrl = !!src && !imageError && shouldLoad
|
||||
const shouldShowLoadingPlaceholder = loading && !hasValidUrl && !imageError
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -112,13 +121,30 @@ export const Avatar = React.memo(function Avatar({
|
||||
alt={name || 'avatar'}
|
||||
className={`avatar-image ${imageLoaded ? 'loaded' : ''} ${isCached ? 'instant' : ''}`}
|
||||
onLoad={() => {
|
||||
if (src) loadedAvatarCache.add(src)
|
||||
if (src) {
|
||||
avatarLoadQueue.clearFailed(src)
|
||||
loadedAvatarCache.add(src)
|
||||
}
|
||||
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"}
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</>
|
||||
) : shouldShowLoadingPlaceholder ? (
|
||||
<div className="avatar-loading">
|
||||
<Loader2 size="50%" className="avatar-loading-icon" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="avatar-placeholder">
|
||||
{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 { 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 '../styles/batchTranscribe.scss'
|
||||
|
||||
@@ -17,6 +17,7 @@ export const BatchTranscribeGlobal: React.FC = () => {
|
||||
result,
|
||||
sessionName,
|
||||
startTime,
|
||||
taskType,
|
||||
setShowToast,
|
||||
setShowResult
|
||||
} = useBatchTranscribeStore()
|
||||
@@ -64,7 +65,7 @@ export const BatchTranscribeGlobal: React.FC = () => {
|
||||
<div className="batch-progress-toast-header">
|
||||
<div className="batch-progress-toast-title">
|
||||
<Loader2 size={14} className="spin" />
|
||||
<span>批量转写中{sessionName ? `(${sessionName})` : ''}</span>
|
||||
<span>{taskType === 'decrypt' ? '批量解密语音中' : '批量转写中'}{sessionName ? `(${sessionName})` : ''}</span>
|
||||
</div>
|
||||
<button className="batch-progress-toast-close" onClick={() => setShowToast(false)} title="最小化">
|
||||
<X size={14} />
|
||||
@@ -108,8 +109,8 @@ export const BatchTranscribeGlobal: React.FC = () => {
|
||||
<div className="batch-modal-overlay" onClick={() => setShowResult(false)}>
|
||||
<div className="batch-modal-content batch-result-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="batch-modal-header">
|
||||
<CheckCircle size={20} />
|
||||
<h3>转写完成</h3>
|
||||
{taskType === 'decrypt' ? <Mic size={20} /> : <CheckCircle size={20} />}
|
||||
<h3>{taskType === 'decrypt' ? '语音解密完成' : '转写完成'}</h3>
|
||||
</div>
|
||||
<div className="batch-modal-body">
|
||||
<div className="result-summary">
|
||||
@@ -129,7 +130,7 @@ export const BatchTranscribeGlobal: React.FC = () => {
|
||||
{result.fail > 0 && (
|
||||
<div className="result-tip">
|
||||
<AlertCircle size={16} />
|
||||
<span>部分语音转写失败,可能是语音文件损坏或网络问题</span>
|
||||
<span>{taskType === 'decrypt' ? '部分语音解密失败,可能是语音未缓存或文件损坏' : '部分语音转写失败,可能是语音文件损坏或网络问题'}</span>
|
||||
</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-weight: 600;
|
||||
color: var(--text-primary);
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
padding: 2px 8px;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,4 +224,68 @@
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.year-month-picker {
|
||||
padding: 4px 0;
|
||||
|
||||
.year-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.year-label {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.month-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 6px;
|
||||
|
||||
.month-btn {
|
||||
padding: 8px 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ function DateRangePicker({ startDate, endDate, onStartDateChange, onEndDateChang
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date())
|
||||
const [selectingStart, setSelectingStart] = useState(true)
|
||||
const [showYearMonthPicker, setShowYearMonthPicker] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// 点击外部关闭
|
||||
@@ -185,12 +186,38 @@ function DateRangePicker({ startDate, endDate, onStartDateChange, onEndDateChang
|
||||
<button className="nav-btn" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1))}>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<span className="month-year">{currentMonth.getFullYear()}年 {MONTH_NAMES[currentMonth.getMonth()]}</span>
|
||||
<span className="month-year clickable" onClick={() => setShowYearMonthPicker(!showYearMonthPicker)}>
|
||||
{currentMonth.getFullYear()}年 {MONTH_NAMES[currentMonth.getMonth()]}
|
||||
</span>
|
||||
<button className="nav-btn" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1))}>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{renderCalendar()}
|
||||
{showYearMonthPicker ? (
|
||||
<div className="year-month-picker">
|
||||
<div className="year-selector">
|
||||
<button className="nav-btn" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() - 1, currentMonth.getMonth()))}>
|
||||
<ChevronLeft size={14} />
|
||||
</button>
|
||||
<span className="year-label">{currentMonth.getFullYear()}年</span>
|
||||
<button className="nav-btn" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() + 1, currentMonth.getMonth()))}>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="month-grid">
|
||||
{MONTH_NAMES.map((name, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className={`month-btn ${i === currentMonth.getMonth() ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setCurrentMonth(new Date(currentMonth.getFullYear(), i))
|
||||
setShowYearMonthPicker(false)
|
||||
}}
|
||||
>{name}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : renderCalendar()}
|
||||
<div className="selection-hint">
|
||||
{selectingStart ? '请选择开始日期' : '请选择结束日期'}
|
||||
</div>
|
||||
|
||||
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 { useChatStore } from '../stores/chatStore'
|
||||
import type { ChatSession } from '../types/models'
|
||||
import type { ChatSession, Message } from '../types/models'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
export function GlobalSessionMonitor() {
|
||||
@@ -14,16 +14,15 @@ export function GlobalSessionMonitor() {
|
||||
} = useChatStore()
|
||||
|
||||
const sessionsRef = useRef(sessions)
|
||||
|
||||
// 保持 ref 同步
|
||||
useEffect(() => {
|
||||
sessionsRef.current = sessions
|
||||
}, [sessions])
|
||||
|
||||
// 去重辅助函数:获取消息 key
|
||||
const getMessageKey = (msg: any) => {
|
||||
if (msg.localId && msg.localId > 0) return `l:${msg.localId}`
|
||||
return `t:${msg.createTime}:${msg.sortSeq || 0}:${msg.serverId || 0}`
|
||||
const getMessageKey = (msg: Message) => {
|
||||
if (msg.messageKey) return msg.messageKey
|
||||
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 () => { }
|
||||
}, []) // 空依赖数组 - 主要是静态的
|
||||
}, [])
|
||||
|
||||
const refreshSessions = async () => {
|
||||
try {
|
||||
@@ -97,6 +96,10 @@ export function GlobalSessionMonitor() {
|
||||
if (!isCurrentSession && (!oldSession || newSession.lastTimestamp > oldSession.lastTimestamp)) {
|
||||
// 这是新消息事件
|
||||
|
||||
// 免打扰、折叠群、折叠入口不弹通知
|
||||
if (newSession.isMuted || newSession.isFolded) continue
|
||||
if (newSession.username.toLowerCase().includes('placeholder_foldgroup')) continue
|
||||
|
||||
// 1. 群聊过滤自己发送的消息
|
||||
if (newSession.username.includes('@chatroom')) {
|
||||
// 如果是自己发的消息,不弹通知
|
||||
@@ -194,11 +197,12 @@ export function GlobalSessionMonitor() {
|
||||
// 尝试丰富或获取联系人详情
|
||||
const contact = await window.electronAPI.chat.getContact(newSession.username)
|
||||
if (contact) {
|
||||
if (contact.remark || contact.nickname) {
|
||||
title = contact.remark || contact.nickname
|
||||
if (contact.remark || contact.nickName) {
|
||||
title = contact.remark || contact.nickName
|
||||
}
|
||||
if (contact.avatarUrl) {
|
||||
avatarUrl = contact.avatarUrl
|
||||
const avatarResult = await window.electronAPI.chat.getContactAvatar(newSession.username)
|
||||
if (avatarResult?.avatarUrl) {
|
||||
avatarUrl = avatarResult.avatarUrl
|
||||
}
|
||||
} else {
|
||||
// 如果不在缓存/数据库中
|
||||
@@ -218,8 +222,11 @@ export function GlobalSessionMonitor() {
|
||||
if (title === newSession.username || title.startsWith('wxid_')) {
|
||||
const retried = await window.electronAPI.chat.getContact(newSession.username)
|
||||
if (retried) {
|
||||
title = retried.remark || retried.nickname || title
|
||||
avatarUrl = retried.avatarUrl || avatarUrl
|
||||
title = retried.remark || retried.nickName || title
|
||||
const retriedAvatar = await window.electronAPI.chat.getContactAvatar(newSession.username)
|
||||
if (retriedAvatar?.avatarUrl) {
|
||||
avatarUrl = retriedAvatar.avatarUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -253,13 +260,19 @@ export function GlobalSessionMonitor() {
|
||||
const handleActiveSessionRefresh = async (sessionId: string) => {
|
||||
// 从 ChatPage 复制/调整的逻辑,以保持集中
|
||||
const state = useChatStore.getState()
|
||||
const lastMsg = state.messages[state.messages.length - 1]
|
||||
const msgs = state.messages || []
|
||||
const lastMsg = msgs[msgs.length - 1]
|
||||
const minTime = lastMsg?.createTime || 0
|
||||
|
||||
try {
|
||||
const result = await (window.electronAPI.chat as any).getNewMessages(sessionId, minTime)
|
||||
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) {
|
||||
console.warn('后台活跃会话刷新失败:', e)
|
||||
|
||||
@@ -75,6 +75,18 @@
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
padding: 2px 8px;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
@@ -97,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 {
|
||||
|
||||
@@ -24,6 +24,7 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
|
||||
const isValidDate = (d: any) => d instanceof Date && !isNaN(d.getTime())
|
||||
const [calendarDate, setCalendarDate] = useState(isValidDate(currentDate) ? new Date(currentDate) : new Date())
|
||||
const [selectedDate, setSelectedDate] = useState(new Date(currentDate))
|
||||
const [showYearMonthPicker, setShowYearMonthPicker] = useState(false)
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
@@ -137,7 +138,7 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
<span className="current-month">
|
||||
<span className="current-month clickable" onClick={() => setShowYearMonthPicker(!showYearMonthPicker)}>
|
||||
{calendarDate.getFullYear()}年{calendarDate.getMonth() + 1}月
|
||||
</span>
|
||||
<button
|
||||
@@ -148,6 +149,31 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
|
||||
</button>
|
||||
</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' : ''}`}>
|
||||
{loadingDates && (
|
||||
<div className="calendar-loading">
|
||||
@@ -174,6 +200,7 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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 * as configService from '../services/config'
|
||||
import { ArrowRight, Fingerprint, Lock, ScanFace, ShieldCheck } from 'lucide-react'
|
||||
import './LockScreen.scss'
|
||||
|
||||
@@ -9,14 +8,6 @@ interface LockScreenProps {
|
||||
useHello?: boolean
|
||||
}
|
||||
|
||||
async function sha256(message: string) {
|
||||
const msgBuffer = new TextEncoder().encode(message)
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer)
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
|
||||
return hashHex
|
||||
}
|
||||
|
||||
export default function LockScreen({ onUnlock, avatar, useHello = false }: LockScreenProps) {
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
@@ -49,19 +40,9 @@ export default function LockScreen({ onUnlock, avatar, useHello = false }: LockS
|
||||
|
||||
const quickStartHello = async () => {
|
||||
try {
|
||||
// 如果父组件已经告诉我们要用 Hello,直接开始,不等待 IPC
|
||||
let shouldUseHello = useHello
|
||||
|
||||
// 为了稳健,如果 prop 没传(虽然现在都传了),再 check 一次 config
|
||||
if (!shouldUseHello) {
|
||||
shouldUseHello = await configService.getAuthUseHello()
|
||||
}
|
||||
|
||||
if (shouldUseHello) {
|
||||
// 标记为可用,显示按钮
|
||||
if (useHello) {
|
||||
setHelloAvailable(true)
|
||||
setShowHello(true)
|
||||
// 立即执行验证 (0延迟)
|
||||
verifyHello()
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -96,25 +77,19 @@ export default function LockScreen({ onUnlock, avatar, useHello = false }: LockS
|
||||
e?.preventDefault()
|
||||
if (!password || isUnlocked) return
|
||||
|
||||
// 如果正在进行 Hello 验证,它会自动失败或被取代,UI上不用特意取消
|
||||
// 因为 native 调用是模态的或者独立的,我们只要让 JS 状态不对锁住即可
|
||||
|
||||
// 不再检查 isVerifying,因为我们允许打断 Hello
|
||||
setIsVerifying(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const storedHash = await configService.getAuthPassword()
|
||||
const inputHash = await sha256(password)
|
||||
// 发送原始密码到主进程,由主进程验证并解密密钥
|
||||
const result = await window.electronAPI.auth.unlock(password)
|
||||
|
||||
if (inputHash === storedHash) {
|
||||
if (result.success) {
|
||||
handleUnlock()
|
||||
} else {
|
||||
setError('密码错误')
|
||||
setError(result.error || '密码错误')
|
||||
setPassword('')
|
||||
setIsVerifying(false)
|
||||
// 如果密码错误,是否重新触发 Hello?
|
||||
// 用户可能想重试密码,暂时不自动触发
|
||||
}
|
||||
} catch (e) {
|
||||
setError('验证失败')
|
||||
|
||||
@@ -6,6 +6,15 @@
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--border-light);
|
||||
|
||||
// 浅色模式下使用完全不透明背景,并禁用毛玻璃效果
|
||||
[data-mode="light"] &,
|
||||
:not([data-mode]) & {
|
||||
background: rgba(255, 255, 255, 1);
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||
padding: 12px;
|
||||
@@ -39,12 +48,26 @@
|
||||
backdrop-filter: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
|
||||
// Ensure background is solid
|
||||
background: var(--bg-secondary, #2c2c2c);
|
||||
color: var(--text-primary, #ffffff);
|
||||
// 独立通知窗口:默认使用浅色模式硬编码值,确保不依赖 <html> 上的主题属性
|
||||
background: #ffffff;
|
||||
color: #3d3d3d;
|
||||
--text-primary: #3d3d3d;
|
||||
--text-secondary: #666666;
|
||||
--text-tertiary: #999999;
|
||||
--border-light: rgba(0, 0, 0, 0.08);
|
||||
|
||||
// 深色模式覆盖
|
||||
[data-mode="dark"] & {
|
||||
background: var(--bg-secondary-solid, #282420);
|
||||
color: var(--text-primary, #F0EEE9);
|
||||
--text-primary: #F0EEE9;
|
||||
--text-secondary: #b3b0aa;
|
||||
--text-tertiary: #807d78;
|
||||
--border-light: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
box-shadow: none !important; // NO SHADOW
|
||||
border: 1px solid var(--border-light, rgba(255, 255, 255, 0.1));
|
||||
border: 1px solid var(--border-light);
|
||||
|
||||
display: flex;
|
||||
padding: 16px;
|
||||
@@ -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 {
|
||||
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.16) !important;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ interface NotificationToastProps {
|
||||
onClose: () => void
|
||||
onClick: (sessionId: string) => void
|
||||
duration?: number
|
||||
position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'
|
||||
position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'
|
||||
isStatic?: boolean
|
||||
initialVisible?: boolean
|
||||
}
|
||||
|
||||
@@ -10,6 +10,19 @@
|
||||
&.collapsed {
|
||||
width: 64px;
|
||||
|
||||
.sidebar-user-card-wrap {
|
||||
margin: 0 8px 8px;
|
||||
}
|
||||
|
||||
.sidebar-user-card {
|
||||
padding: 8px 0;
|
||||
justify-content: center;
|
||||
|
||||
.user-meta {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-menu,
|
||||
.sidebar-footer {
|
||||
padding: 0 8px;
|
||||
@@ -27,6 +40,150 @@
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-user-card-wrap {
|
||||
position: relative;
|
||||
margin: 0 12px 10px;
|
||||
--sidebar-user-menu-width: 172px;
|
||||
}
|
||||
|
||||
.sidebar-user-menu {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: auto;
|
||||
bottom: calc(100% + 8px);
|
||||
width: max(100%, var(--sidebar-user-menu-width));
|
||||
z-index: 12;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
background: var(--bg-secondary-solid, var(--bg-primary));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 6px;
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12);
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.95);
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
|
||||
&.open {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-user-menu-item {
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
padding: 9px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.2s ease, color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: #d93025;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 59, 48, 0.08);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-user-card {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
background: var(--bg-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 56px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(99, 102, 241, 0.32);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
&.menu-open {
|
||||
border-color: rgba(99, 102, 241, 0.44);
|
||||
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.12);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--on-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.user-meta {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.user-wxid {
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.user-menu-caret {
|
||||
color: var(--text-tertiary);
|
||||
display: inline-flex;
|
||||
transition: transform 0.2s ease, color 0.2s ease;
|
||||
|
||||
&.open {
|
||||
transform: rotate(180deg);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -57,7 +214,7 @@
|
||||
|
||||
&.active {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
color: var(--on-primary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,11 +227,44 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-icon-with-badge {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav-badge {
|
||||
margin-left: auto;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 999px;
|
||||
padding: 0 6px;
|
||||
background: #ff3b30;
|
||||
color: #ffffff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
box-shadow: 0 0 0 2px rgba(255, 59, 48, 0.18);
|
||||
}
|
||||
|
||||
.nav-badge.icon-badge {
|
||||
position: absolute;
|
||||
top: -7px;
|
||||
right: -10px;
|
||||
margin-left: 0;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
font-size: 10px;
|
||||
box-shadow: 0 0 0 2px var(--bg-secondary);
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 0 12px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
@@ -85,22 +275,286 @@
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
.sidebar-dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
border-radius: 9999px;
|
||||
transition: all 0.2s ease;
|
||||
margin-top: 4px;
|
||||
z-index: 1100;
|
||||
padding: 20px;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-dialog {
|
||||
width: min(420px, 100%);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.24);
|
||||
padding: 18px 18px 16px;
|
||||
animation: slideUp 0.25s ease;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 10px 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-wxid-list {
|
||||
margin-top: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-wxid-item {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: rgba(99, 102, 241, 0.32);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
&.current {
|
||||
border-color: rgba(99, 102, 241, 0.5);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.wxid-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--on-primary);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.wxid-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.wxid-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.wxid-id {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.current-badge {
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
background: var(--primary);
|
||||
color: var(--on-primary);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-dialog-actions {
|
||||
margin-top: 18px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
|
||||
button {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-clear-dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1100;
|
||||
padding: 20px;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar-clear-dialog {
|
||||
width: min(460px, 100%);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.24);
|
||||
padding: 18px 18px 16px;
|
||||
animation: slideUp 0.25s ease;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 10px 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-clear-options {
|
||||
margin-top: 14px;
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-clear-actions {
|
||||
margin-top: 18px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
|
||||
button {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.danger {
|
||||
border-color: #ef4444;
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
// 繁花如梦主题:侧边栏毛玻璃 + 激活项用主品牌色
|
||||
[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 { NavLink, useLocation } from 'react-router-dom'
|
||||
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Aperture, UserCircle, Lock } from 'lucide-react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { NavLink, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Home, MessageSquare, BarChart3, FileText, Settings, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, RefreshCw } from 'lucide-react'
|
||||
import { useAppStore } from '../stores/appStore'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import { useAnalyticsStore } from '../stores/analyticsStore'
|
||||
import * as configService from '../services/config'
|
||||
import { onExportSessionStatus, requestExportSessionStatus } from '../services/exportBridge'
|
||||
import { UserRound } from 'lucide-react'
|
||||
|
||||
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 [collapsed, setCollapsed] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const [authEnabled, setAuthEnabled] = useState(false)
|
||||
const [activeExportTaskCount, setActiveExportTaskCount] = useState(0)
|
||||
const [userProfile, setUserProfile] = useState<SidebarUserProfile>({
|
||||
wxid: '',
|
||||
displayName: '未识别用户'
|
||||
})
|
||||
const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false)
|
||||
const [showSwitchAccountDialog, setShowSwitchAccountDialog] = useState(false)
|
||||
const [wxidOptions, setWxidOptions] = useState<WxidOption[]>([])
|
||||
const [isSwitchingAccount, setIsSwitchingAccount] = useState(false)
|
||||
const accountCardWrapRef = useRef<HTMLDivElement | null>(null)
|
||||
const setLocked = useAppStore(state => state.setLocked)
|
||||
const isDbConnected = useAppStore(state => state.isDbConnected)
|
||||
const resetChatStore = useChatStore(state => state.reset)
|
||||
const clearAnalyticsStoreCache = useAnalyticsStore(state => state.clearCache)
|
||||
|
||||
useEffect(() => {
|
||||
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) => {
|
||||
return location.pathname === path || location.pathname.startsWith(`${path}/`)
|
||||
}
|
||||
const exportTaskBadge = activeExportTaskCount > 99 ? '99+' : `${activeExportTaskCount}`
|
||||
|
||||
return (
|
||||
<aside className={`sidebar ${collapsed ? 'collapsed' : ''}`}>
|
||||
<nav className="nav-menu">
|
||||
{/* 首页 */}
|
||||
<NavLink
|
||||
to="/home"
|
||||
className={`nav-item ${isActive('/home') ? 'active' : ''}`}
|
||||
title={collapsed ? '首页' : undefined}
|
||||
>
|
||||
<span className="nav-icon"><Home size={20} /></span>
|
||||
<span className="nav-label">首页</span>
|
||||
</NavLink>
|
||||
<>
|
||||
<aside className={`sidebar ${collapsed ? 'collapsed' : ''}`}>
|
||||
<nav className="nav-menu">
|
||||
{/* 首页 */}
|
||||
<NavLink
|
||||
to="/home"
|
||||
className={`nav-item ${isActive('/home') ? 'active' : ''}`}
|
||||
title={collapsed ? '首页' : undefined}
|
||||
>
|
||||
<span className="nav-icon"><Home size={20} /></span>
|
||||
<span className="nav-label">首页</span>
|
||||
</NavLink>
|
||||
|
||||
{/* 聊天 */}
|
||||
<NavLink
|
||||
to="/chat"
|
||||
className={`nav-item ${isActive('/chat') ? 'active' : ''}`}
|
||||
title={collapsed ? '聊天' : undefined}
|
||||
>
|
||||
<span className="nav-icon"><MessageSquare size={20} /></span>
|
||||
<span className="nav-label">聊天</span>
|
||||
</NavLink>
|
||||
{/* 聊天 */}
|
||||
<NavLink
|
||||
to="/chat"
|
||||
className={`nav-item ${isActive('/chat') ? 'active' : ''}`}
|
||||
title={collapsed ? '聊天' : undefined}
|
||||
>
|
||||
<span className="nav-icon"><MessageSquare size={20} /></span>
|
||||
<span className="nav-label">聊天</span>
|
||||
</NavLink>
|
||||
|
||||
{/* 朋友圈 */}
|
||||
<NavLink
|
||||
to="/sns"
|
||||
className={`nav-item ${isActive('/sns') ? 'active' : ''}`}
|
||||
title={collapsed ? '朋友圈' : undefined}
|
||||
>
|
||||
<span className="nav-icon"><Aperture size={20} /></span>
|
||||
<span className="nav-label">朋友圈</span>
|
||||
</NavLink>
|
||||
{/* 朋友圈 */}
|
||||
<NavLink
|
||||
to="/sns"
|
||||
className={`nav-item ${isActive('/sns') ? 'active' : ''}`}
|
||||
title={collapsed ? '朋友圈' : undefined}
|
||||
>
|
||||
<span className="nav-icon"><Aperture size={20} /></span>
|
||||
<span className="nav-label">朋友圈</span>
|
||||
</NavLink>
|
||||
|
||||
{/* 通讯录 */}
|
||||
<NavLink
|
||||
to="/contacts"
|
||||
className={`nav-item ${isActive('/contacts') ? 'active' : ''}`}
|
||||
title={collapsed ? '通讯录' : undefined}
|
||||
>
|
||||
<span className="nav-icon"><UserCircle size={20} /></span>
|
||||
<span className="nav-label">通讯录</span>
|
||||
</NavLink>
|
||||
{/* 通讯录 */}
|
||||
<NavLink
|
||||
to="/contacts"
|
||||
className={`nav-item ${isActive('/contacts') ? 'active' : ''}`}
|
||||
title={collapsed ? '通讯录' : undefined}
|
||||
>
|
||||
<span className="nav-icon"><UserCircle size={20} /></span>
|
||||
<span className="nav-label">通讯录</span>
|
||||
</NavLink>
|
||||
|
||||
{/* 私聊分析 */}
|
||||
<NavLink
|
||||
to="/analytics"
|
||||
className={`nav-item ${isActive('/analytics') ? 'active' : ''}`}
|
||||
title={collapsed ? '私聊分析' : undefined}
|
||||
>
|
||||
<span className="nav-icon"><BarChart3 size={20} /></span>
|
||||
<span className="nav-label">私聊分析</span>
|
||||
</NavLink>
|
||||
{/* 聊天分析 */}
|
||||
<NavLink
|
||||
to="/analytics"
|
||||
className={`nav-item ${isActive('/analytics') ? 'active' : ''}`}
|
||||
title={collapsed ? '聊天分析' : undefined}
|
||||
>
|
||||
<span className="nav-icon"><BarChart3 size={20} /></span>
|
||||
<span className="nav-label">聊天分析</span>
|
||||
</NavLink>
|
||||
|
||||
{/* 群聊分析 */}
|
||||
<NavLink
|
||||
to="/group-analytics"
|
||||
className={`nav-item ${isActive('/group-analytics') ? 'active' : ''}`}
|
||||
title={collapsed ? '群聊分析' : undefined}
|
||||
>
|
||||
<span className="nav-icon"><Users size={20} /></span>
|
||||
<span className="nav-label">群聊分析</span>
|
||||
</NavLink>
|
||||
{/* 年度报告 */}
|
||||
<NavLink
|
||||
to="/annual-report"
|
||||
className={`nav-item ${isActive('/annual-report') ? 'active' : ''}`}
|
||||
title={collapsed ? '年度报告' : undefined}
|
||||
>
|
||||
<span className="nav-icon"><FileText size={20} /></span>
|
||||
<span className="nav-label">年度报告</span>
|
||||
</NavLink>
|
||||
|
||||
{/* 年度报告 */}
|
||||
<NavLink
|
||||
to="/annual-report"
|
||||
className={`nav-item ${isActive('/annual-report') ? 'active' : ''}`}
|
||||
title={collapsed ? '年度报告' : undefined}
|
||||
>
|
||||
<span className="nav-icon"><FileText size={20} /></span>
|
||||
<span className="nav-label">年度报告</span>
|
||||
</NavLink>
|
||||
|
||||
{/* 导出 */}
|
||||
<NavLink
|
||||
to="/export"
|
||||
className={`nav-item ${isActive('/export') ? 'active' : ''}`}
|
||||
title={collapsed ? '导出' : undefined}
|
||||
>
|
||||
<span className="nav-icon"><Download size={20} /></span>
|
||||
<span className="nav-label">导出</span>
|
||||
</NavLink>
|
||||
{/* 导出 */}
|
||||
<NavLink
|
||||
to="/export"
|
||||
className={`nav-item ${isActive('/export') ? 'active' : ''}`}
|
||||
title={collapsed ? '导出' : undefined}
|
||||
>
|
||||
<span className="nav-icon nav-icon-with-badge">
|
||||
<Download size={20} />
|
||||
{collapsed && activeExportTaskCount > 0 && (
|
||||
<span className="nav-badge icon-badge">{exportTaskBadge}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="nav-label">导出</span>
|
||||
{!collapsed && activeExportTaskCount > 0 && (
|
||||
<span className="nav-badge">{exportTaskBadge}</span>
|
||||
)}
|
||||
</NavLink>
|
||||
|
||||
|
||||
</nav>
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
{authEnabled && (
|
||||
<div className="sidebar-footer">
|
||||
<button
|
||||
className="nav-item"
|
||||
onClick={() => setLocked(true)}
|
||||
title={collapsed ? '锁定' : undefined}
|
||||
onClick={() => {
|
||||
if (authEnabled) {
|
||||
setLocked(true)
|
||||
return
|
||||
}
|
||||
navigate('/settings', {
|
||||
state: {
|
||||
initialTab: 'security',
|
||||
backgroundLocation: location
|
||||
}
|
||||
})
|
||||
}}
|
||||
title={collapsed ? (authEnabled ? '锁定' : '未锁定') : undefined}
|
||||
>
|
||||
<span className="nav-icon"><Lock size={20} /></span>
|
||||
<span className="nav-label">锁定</span>
|
||||
<span className="nav-icon">{authEnabled ? <Lock size={20} /> : <LockOpen size={20} />}</span>
|
||||
<span className="nav-label">{authEnabled ? '锁定' : '未锁定'}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<NavLink
|
||||
to="/settings"
|
||||
className={`nav-item ${isActive('/settings') ? 'active' : ''}`}
|
||||
title={collapsed ? '设置' : undefined}
|
||||
>
|
||||
<span className="nav-icon">
|
||||
<Settings size={20} />
|
||||
</span>
|
||||
<span className="nav-label">设置</span>
|
||||
</NavLink>
|
||||
<div className="sidebar-user-card-wrap" ref={accountCardWrapRef}>
|
||||
<div className={`sidebar-user-menu ${isAccountMenuOpen ? 'open' : ''}`} role="menu" aria-label="账号菜单">
|
||||
<button
|
||||
className="sidebar-user-menu-item"
|
||||
onClick={openSwitchAccountDialog}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
<span>切换账号</span>
|
||||
</button>
|
||||
<button
|
||||
className="sidebar-user-menu-item"
|
||||
onClick={openSettingsFromAccountMenu}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
>
|
||||
<Settings size={14} />
|
||||
<span>设置</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className={`sidebar-user-card ${isAccountMenuOpen ? 'menu-open' : ''}`}
|
||||
title={collapsed ? `${userProfile.displayName}${(userProfile.alias || userProfile.wxid) ? `\n${userProfile.alias || userProfile.wxid}` : ''}` : undefined}
|
||||
onClick={() => setIsAccountMenuOpen(prev => !prev)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
setIsAccountMenuOpen(prev => !prev)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="user-avatar">
|
||||
{userProfile.avatarUrl ? <img src={userProfile.avatarUrl} alt="" /> : <span>{getAvatarLetter(userProfile.displayName)}</span>}
|
||||
</div>
|
||||
<div className="user-meta">
|
||||
<div className="user-name">{userProfile.displayName}</div>
|
||||
<div className="user-wxid">{userProfile.alias || userProfile.wxid || 'wxid 未识别'}</div>
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<span className={`user-menu-caret ${isAccountMenuOpen ? 'open' : ''}`}>
|
||||
<ChevronUp size={14} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<button
|
||||
className="collapse-btn"
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
title={collapsed ? '展开菜单' : '收起菜单'}
|
||||
>
|
||||
{collapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
{showSwitchAccountDialog && (
|
||||
<div className="sidebar-dialog-overlay" onClick={() => !isSwitchingAccount && setShowSwitchAccountDialog(false)}>
|
||||
<div className="sidebar-dialog" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
|
||||
<h3>切换账号</h3>
|
||||
<p>选择要切换的微信账号</p>
|
||||
<div className="sidebar-wxid-list">
|
||||
{wxidOptions.map((option) => (
|
||||
<button
|
||||
key={option.wxid}
|
||||
className={`sidebar-wxid-item ${userProfile.wxid === option.wxid ? 'current' : ''}`}
|
||||
onClick={() => handleSwitchAccount(option.wxid)}
|
||||
disabled={isSwitchingAccount}
|
||||
type="button"
|
||||
>
|
||||
<div className="wxid-avatar">
|
||||
{option.avatarUrl ? (
|
||||
<img src={option.avatarUrl} alt="" />
|
||||
) : (
|
||||
<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