mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-08 07:25:51 +00:00
Compare commits
508 Commits
v3.2.0
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03936bdf41 | ||
|
|
902d2c9c74 | ||
|
|
dcad30bc39 | ||
|
|
73ee524d1f | ||
|
|
4af8334f50 | ||
|
|
43fed79204 | ||
|
|
b356814ebb | ||
|
|
0acad9927a | ||
|
|
5bc46fadfc | ||
|
|
3090306394 | ||
|
|
ec95a16c7a | ||
|
|
45d3f735a9 | ||
|
|
0734b64cc8 | ||
|
|
70ad21cb46 | ||
|
|
9181490d0f | ||
|
|
01fc5cd1a0 | ||
|
|
b12ffff310 | ||
|
|
835359edf8 | ||
|
|
88817cf95e | ||
|
|
88d41f6857 | ||
|
|
ec9c1bbbba | ||
|
|
f9313392f1 | ||
|
|
2db8af3668 | ||
|
|
c56ba6e0a1 | ||
|
|
f1dcc84991 | ||
|
|
e8aaae5616 | ||
|
|
45deb99e3d | ||
|
|
28f6f966b9 | ||
|
|
7bd569feca | ||
|
|
056f2c1833 | ||
|
|
b821d370f9 | ||
|
|
60248b28f8 | ||
|
|
d128bedffa | ||
|
|
489b545965 | ||
|
|
36533d07f8 | ||
|
|
625e4f8e6a | ||
|
|
c4774e1ce1 | ||
|
|
e1682f99d2 | ||
|
|
a23461bfce | ||
|
|
73fc36e63a | ||
|
|
4beddb7a62 | ||
|
|
b130165831 | ||
|
|
9adffc3cd7 | ||
|
|
a52619c4d5 | ||
|
|
cf40d3ad63 | ||
|
|
f7f6252d0b | ||
|
|
14a2475fb1 | ||
|
|
76a55998c2 | ||
|
|
1ec8d54e96 | ||
|
|
62395b275d | ||
|
|
57fad47f27 | ||
|
|
20c5381211 | ||
|
|
b8cd9a8c38 | ||
|
|
4335abe31b | ||
|
|
e5f7b54a7b | ||
|
|
ea1ef03b98 | ||
|
|
8d374d4f49 | ||
|
|
f910e17e53 | ||
|
|
35a76aa04f | ||
|
|
5fce21d799 | ||
|
|
a32696ee13 | ||
|
|
b573baec80 | ||
|
|
0d4feceffc | ||
|
|
92abe73f0a | ||
|
|
7fa26b0716 | ||
|
|
dc49bf3877 | ||
|
|
d825dada59 | ||
|
|
74a08732fe | ||
|
|
7033a77d71 | ||
|
|
3b26e0c014 | ||
|
|
81ec51be33 | ||
|
|
fbecda9f1e | ||
|
|
b6950d4027 | ||
|
|
f31327b528 | ||
|
|
c4c7df2608 | ||
|
|
b8bf29277a | ||
|
|
867f85e8f2 | ||
|
|
7fb98d764a | ||
|
|
792621d982 | ||
|
|
337fe21d18 | ||
|
|
c92b50b6ec | ||
|
|
f83117df20 | ||
|
|
b7b7260838 | ||
|
|
dd960d30ff | ||
|
|
89f3ec57f5 | ||
|
|
95f1e73a39 | ||
|
|
aa029fe113 | ||
|
|
5971757a28 | ||
|
|
1e16ea887b | ||
|
|
837f15c5e8 | ||
|
|
f71ff7392c | ||
|
|
97ba95e2be | ||
|
|
6aae23180f | ||
|
|
49e82e43e4 | ||
|
|
301c490893 | ||
|
|
93a9df48f4 | ||
|
|
209b91bfef | ||
|
|
1049f55118 | ||
|
|
ba7785a359 | ||
|
|
e6c821d3ee | ||
|
|
17a7741697 | ||
|
|
f00525d21a | ||
|
|
f5c79c1fab | ||
|
|
4fc0a92651 | ||
|
|
585ec39f8e | ||
|
|
a0189fdd0a | ||
|
|
ede31732b3 | ||
|
|
a60381522d | ||
|
|
64010ad86b | ||
|
|
e628154b78 | ||
|
|
e5baf5e994 | ||
|
|
05fdbab496 | ||
|
|
512b1f6455 | ||
|
|
5615d83f04 | ||
|
|
ee38918516 | ||
|
|
d1b8d86a20 | ||
|
|
25ef7c5d8a | ||
|
|
db429abf5b | ||
|
|
19d5ae7e15 | ||
|
|
fcbd613f4a | ||
|
|
5fae370c55 | ||
|
|
f2dbe6ee8f | ||
|
|
0175a6998b | ||
|
|
758de9949b | ||
|
|
81b8960d41 | ||
|
|
5b25619b24 | ||
|
|
62e23aaf23 | ||
|
|
aac8eed898 | ||
|
|
108980befb | ||
|
|
6a4cd00d51 | ||
|
|
a6c899c098 | ||
|
|
28170d31df | ||
|
|
ce8d272d6e | ||
|
|
0047685f54 | ||
|
|
2cc0fc64a4 | ||
|
|
67642cebfd | ||
|
|
327dc85d14 | ||
|
|
8c4f42bab1 | ||
|
|
40c29e494c | ||
|
|
0235ec7edc | ||
|
|
fa2a000624 | ||
|
|
861b24cef1 | ||
|
|
ee1977384e | ||
|
|
5d08505f62 | ||
|
|
ab21124327 | ||
|
|
1df792ec9c | ||
|
|
a8fa6e5987 | ||
|
|
1d69c5a78d | ||
|
|
0ae7ba3e11 | ||
|
|
c421ca7f2f | ||
|
|
ea4fff5b10 | ||
|
|
e0b0e38271 | ||
|
|
510b956649 | ||
|
|
17b8af4bc4 | ||
|
|
617b400884 | ||
|
|
a58518ccb5 | ||
|
|
cdd17d919e | ||
|
|
4580cef7f2 | ||
|
|
6f9c765bab | ||
|
|
5b56b2e0be | ||
|
|
b0cc811807 | ||
|
|
eb540d5c13 | ||
|
|
e308293cf6 | ||
|
|
9ed4659c5c | ||
|
|
f5f2b76914 | ||
|
|
551a065497 | ||
|
|
88d7e38d82 | ||
|
|
65e6cb22dd | ||
|
|
689a396f6e | ||
|
|
512ea84850 | ||
|
|
1542e583f7 | ||
|
|
c488dcc3c6 | ||
|
|
1594e0e24b | ||
|
|
1e1fa77621 | ||
|
|
3270c17514 | ||
|
|
e635942e3d | ||
|
|
64dc2858a7 | ||
|
|
d05496bb3d | ||
|
|
bb94553fff | ||
|
|
113216b7ba | ||
|
|
55181edaa8 | ||
|
|
4f01a7b577 | ||
|
|
ea21111037 | ||
|
|
fba9f1de42 | ||
|
|
7d0b8db7a6 | ||
|
|
12faa31e34 | ||
|
|
74945b1752 | ||
|
|
a7c66517d2 | ||
|
|
adf187ddf5 | ||
|
|
614d897dd2 | ||
|
|
ec9a7d68e6 | ||
|
|
79dd91b270 | ||
|
|
b26bcd7603 | ||
|
|
a65468191b | ||
|
|
4ed5271703 | ||
|
|
f2afe2a977 | ||
|
|
430b0f30c9 | ||
|
|
8aac9a795e | ||
|
|
b9405765f9 | ||
|
|
90856b3812 | ||
|
|
1e78af3c25 | ||
|
|
4be232b951 | ||
|
|
59d5c2762d | ||
|
|
21a55439ec | ||
|
|
c4a35f5c15 | ||
|
|
b42f761011 | ||
|
|
46e2e64e65 | ||
|
|
54ed35ffb3 | ||
|
|
7b8bd747ad | ||
|
|
3e379957e1 | ||
|
|
b64525487e | ||
|
|
e544f5c862 | ||
|
|
669759c52e | ||
|
|
4a13d3209b | ||
|
|
be069e9aed | ||
|
|
0b20ee1aa2 | ||
|
|
e4872a78f5 | ||
|
|
4692216325 | ||
|
|
69128062fe | ||
|
|
f81ba3028d | ||
|
|
73a948c528 | ||
|
|
6d652130e6 | ||
|
|
9e6f8077f7 | ||
|
|
40342ca824 | ||
|
|
71238d4a01 | ||
|
|
4da9f1e6cf | ||
|
|
93b55fe370 | ||
|
|
ee5e7d2586 | ||
|
|
4f4e09c3de | ||
|
|
d537d81f1c | ||
|
|
26c6700152 | ||
|
|
49fb96d7a3 | ||
|
|
d256ee5696 | ||
|
|
bd70a7bfa8 | ||
|
|
3fb09bad0d | ||
|
|
06079659af | ||
|
|
22d8049c2c | ||
|
|
5f6b0e8960 | ||
|
|
9b8da7774d | ||
|
|
eabed55a7a | ||
|
|
32cc74f99c | ||
|
|
ffc4cc3d96 | ||
|
|
007cf57efd | ||
|
|
c6dba71197 | ||
|
|
8aa162e294 | ||
|
|
51d6dec7ff | ||
|
|
f1b2762769 | ||
|
|
d126be2aa5 | ||
|
|
ea034ee76a | ||
|
|
39634a690c | ||
|
|
a7001eb6da | ||
|
|
71e3540f18 | ||
|
|
4ea4020faa | ||
|
|
78cadfd352 | ||
|
|
da15f829d3 | ||
|
|
bb60694013 | ||
|
|
b3758d2baf | ||
|
|
bc794e9a44 | ||
|
|
c80115d0f7 | ||
|
|
6277576249 | ||
|
|
2201d369fa | ||
|
|
9f4e4790f5 | ||
|
|
501e373e38 | ||
|
|
b2cf7c92d5 | ||
|
|
e92e13c045 | ||
|
|
f3dec958b0 | ||
|
|
c88a1c5848 | ||
|
|
0cf8ea8166 | ||
|
|
74b830dd79 | ||
|
|
8668c168a7 | ||
|
|
8b8c5f33ce | ||
|
|
2fcbb026df | ||
|
|
66ee72380d | ||
|
|
4f16345351 | ||
|
|
5110618996 | ||
|
|
bf51368cf4 | ||
|
|
d6054745d6 | ||
|
|
a4731f25f8 | ||
|
|
6c4507e495 | ||
|
|
c8e0160d5c | ||
|
|
ac40a81901 | ||
|
|
0162769d22 | ||
|
|
fa55755921 | ||
|
|
ca38a68a75 | ||
|
|
64be2dd562 | ||
|
|
ea2abb6c72 | ||
|
|
011e2ff37a | ||
|
|
cfa335564a | ||
|
|
3d1493b0a6 | ||
|
|
a46b52e603 | ||
|
|
3c0683b9f8 | ||
|
|
3214c2804e | ||
|
|
83f50cbaee | ||
|
|
61ef10de9b | ||
|
|
73f36d6b29 | ||
|
|
666a1a3296 | ||
|
|
acec2e95a2 | ||
|
|
d26e7e78a1 | ||
|
|
77e5c44673 | ||
|
|
619cc84d15 | ||
|
|
22b85439d3 | ||
|
|
64995c25a8 | ||
|
|
1655b5ae78 | ||
|
|
3d3f6d058e | ||
|
|
104a04c5de | ||
|
|
e12193aa40 | ||
|
|
51101387f7 | ||
|
|
641a3bf2ab | ||
|
|
58f22f4bb2 | ||
|
|
562eac4249 | ||
|
|
2e1c0e6c54 | ||
|
|
7759868664 | ||
|
|
e92df66bef | ||
|
|
354f3fd8e2 | ||
|
|
1201ea33db | ||
|
|
f8e99a34c7 | ||
|
|
1cef17174b | ||
|
|
73cabf2acd | ||
|
|
49770f9e8d | ||
|
|
e32261d274 | ||
|
|
3c7a63e616 | ||
|
|
ab7a487e78 | ||
|
|
f01e2efd3f | ||
|
|
3f4a4f7581 | ||
|
|
7f78925bd7 | ||
|
|
d16423818d | ||
|
|
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 | ||
|
|
11c7277878 | ||
|
|
b5a371da87 |
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: 如有必要,请提供截图或错误日志
|
||||
7
.github/dependabot.yml
vendored
Normal file
7
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
target-branch: "dev"
|
||||
134
.github/workflows/anti-spam.yml
vendored
Normal file
134
.github/workflows/anti-spam.yml
vendored
Normal file
@@ -0,0 +1,134 @@
|
||||
name: Anti-Spam
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
check-spam:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check for spam
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const issue = context.payload.issue;
|
||||
const title = (issue.title || '').toLowerCase();
|
||||
const body = (issue.body || '').toLowerCase();
|
||||
const text = title + ' ' + body;
|
||||
|
||||
// 博彩/赌球类
|
||||
const gamblingPatterns = [
|
||||
/世界杯.*买球/, /买球.*世界杯/,
|
||||
/世界杯.*下注/, /世界杯.*竞猜/,
|
||||
/世界杯.*投注/, /世界杯.*押注/,
|
||||
/世界杯.*彩票/, /世界杯.*平台/,
|
||||
/世界杯.*app/, /世界杯.*软件/,
|
||||
/世界杯.*网站/, /世界杯.*网址/,
|
||||
/足球.*买球/, /买球.*足球/,
|
||||
/足球.*投注/, /足球.*押注/,
|
||||
/足球.*竞猜/, /足球.*平台/,
|
||||
/篮球.*买球/, /篮球.*投注/,
|
||||
/体育.*投注/, /体育.*竞猜/,
|
||||
/体育.*买球/, /体育.*押注/,
|
||||
/赌球/, /赌博.*网站/, /赌博.*平台/,
|
||||
/博彩/, /博彩.*网站/, /博彩.*平台/,
|
||||
/正规.*买球/, /官方.*买球/,
|
||||
/买球.*网站/, /买球.*app/,
|
||||
/买球.*软件/, /买球.*网址/,
|
||||
/买球.*平台/, /买球.*技巧/,
|
||||
/投注.*网站/, /投注.*平台/,
|
||||
/押注.*网站/, /押注.*平台/,
|
||||
/竞猜.*网站/, /竞猜.*平台/,
|
||||
/彩票.*网站/, /彩票.*平台/,
|
||||
/欧洲杯.*买球/, /欧冠.*买球/,
|
||||
/nba.*买球/, /nba.*投注/,
|
||||
];
|
||||
|
||||
// 色情/交友类
|
||||
const adultPatterns = [
|
||||
/约炮/, /一夜情/, /外围/,
|
||||
/包养/, /援交/, /陪聊/,
|
||||
/成人.*网站/, /成人.*视频/,
|
||||
/av.*网站/, /黄色.*网站/,
|
||||
];
|
||||
|
||||
// 贷款/金融诈骗类
|
||||
const financePatterns = [
|
||||
/秒到账.*贷款/, /无抵押.*贷款/,
|
||||
/征信.*贷款/, /黑户.*贷款/,
|
||||
/快速.*放款/, /私人.*放贷/,
|
||||
/刷单/, /兼职.*日入/, /兼职.*月入/,
|
||||
/网赚/, /躺赚/, /被动收入.*平台/,
|
||||
/虚拟货币.*投资/, /usdt.*投资/,
|
||||
/炒币.*平台/, /数字货币.*平台/,
|
||||
];
|
||||
|
||||
// 垃圾推广类
|
||||
const spamPromoPatterns = [
|
||||
/代刷/, /粉丝.*购买/, /涨粉/,
|
||||
/seo.*优化/, /快速排名/,
|
||||
/微商/, /代理.*招募/,
|
||||
];
|
||||
|
||||
// 账号特征检测(新账号 + 无 contribution)
|
||||
const allPatterns = [
|
||||
...gamblingPatterns,
|
||||
...adultPatterns,
|
||||
...financePatterns,
|
||||
...spamPromoPatterns,
|
||||
];
|
||||
|
||||
const isSpam = allPatterns.some(pattern => pattern.test(text));
|
||||
|
||||
// 额外检测:标题超短且含可疑关键词(常见于批量刷单)
|
||||
const suspiciousShort = title.length < 10 && /(买球|投注|博彩|赌博|下注|押注)/.test(title);
|
||||
|
||||
if (isSpam || suspiciousShort) {
|
||||
// 确保 spam label 存在
|
||||
try {
|
||||
await github.rest.issues.createLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name: 'spam',
|
||||
color: 'e4e669',
|
||||
description: 'Spam issue'
|
||||
});
|
||||
} catch (e) {
|
||||
// label 已存在,忽略
|
||||
}
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: '此 issue 已被自动识别为垃圾内容并关闭。\n\nThis issue has been automatically identified as spam and closed.'
|
||||
});
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: ['spam']
|
||||
});
|
||||
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
state: 'closed',
|
||||
state_reason: 'not_planned'
|
||||
});
|
||||
|
||||
await github.rest.issues.lock({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
lock_reason: 'spam'
|
||||
});
|
||||
|
||||
console.log(`Closed spam issue #${issue.number}: ${issue.title}`);
|
||||
}
|
||||
337
.github/workflows/dev-daily-fixed.yml
vendored
Normal file
337
.github/workflows/dev-daily-fixed.yml
vendored
Normal file
@@ -0,0 +1,337 @@
|
||||
name: Dev Daily
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# GitHub Actions schedule uses UTC. 16:00 UTC = 北京时间次日 00:00
|
||||
- cron: "0 16 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
FIXED_DEV_TAG: nightly-dev
|
||||
TARGET_BRANCH: dev
|
||||
ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
dev_version: ${{ steps.meta.outputs.dev_version }}
|
||||
steps:
|
||||
- name: Check out git repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ env.TARGET_BRANCH }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Generate daily dev version
|
||||
id: meta
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
YEAR_2="$(TZ=Asia/Shanghai date +%y)"
|
||||
MONTH="$(TZ=Asia/Shanghai date +%-m)"
|
||||
DAY="$(TZ=Asia/Shanghai date +%-d)"
|
||||
DEV_VERSION="${YEAR_2}.${MONTH}.${DAY}"
|
||||
echo "dev_version=$DEV_VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "Dev version: $DEV_VERSION"
|
||||
|
||||
- name: Recreate fixed prerelease
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if gh release view "$FIXED_DEV_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
gh release delete "$FIXED_DEV_TAG" --repo "$GITHUB_REPOSITORY" --yes --cleanup-tag
|
||||
fi
|
||||
gh release create "$FIXED_DEV_TAG" --repo "$GITHUB_REPOSITORY" --title "Daily Dev Build" --notes "开发版发布页" --prerelease --target "$TARGET_BRANCH"
|
||||
RELEASE_REST_ID="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$FIXED_DEV_TAG" --jq '.id')"
|
||||
gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$RELEASE_REST_ID" -f draft=false -f prerelease=true >/dev/null
|
||||
|
||||
dev-mac-arm64:
|
||||
needs: prepare
|
||||
runs-on: macos-14
|
||||
steps:
|
||||
- name: Check out git repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ env.TARGET_BRANCH }}
|
||||
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: Set dev version
|
||||
shell: bash
|
||||
run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Build Frontend & Type Check
|
||||
shell: bash
|
||||
run: |
|
||||
npx tsc
|
||||
npx vite build
|
||||
|
||||
- name: Package macOS arm64 dev artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
export ELECTRON_BUILDER_BINARIES_MIRROR="https://github.com/electron-userland/electron-builder-binaries/releases/download/"
|
||||
echo "Using ELECTRON_BUILDER_BINARIES_MIRROR=$ELECTRON_BUILDER_BINARIES_MIRROR"
|
||||
npx electron-builder --mac dmg --arm64 --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-arm64.${ext}'
|
||||
|
||||
- name: Upload macOS arm64 assets to fixed release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
assets=()
|
||||
while IFS= read -r file; do
|
||||
assets+=("$file")
|
||||
done < <(find release -maxdepth 1 -type f | sort)
|
||||
if [ "${#assets[@]}" -eq 0 ]; then
|
||||
echo "No release files found in ./release"
|
||||
exit 1
|
||||
fi
|
||||
gh release upload "$FIXED_DEV_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
|
||||
|
||||
dev-linux:
|
||||
needs: prepare
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out git repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ env.TARGET_BRANCH }}
|
||||
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: Set dev version
|
||||
shell: bash
|
||||
run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Build Frontend & Type Check
|
||||
shell: bash
|
||||
run: |
|
||||
npx tsc
|
||||
npx vite build
|
||||
|
||||
- name: Package Linux dev artifacts
|
||||
run: |
|
||||
npx electron-builder --linux --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-linux.${ext}'
|
||||
|
||||
- name: Upload Linux assets to fixed release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
assets=()
|
||||
while IFS= read -r file; do
|
||||
assets+=("$file")
|
||||
done < <(find release -maxdepth 1 -type f | sort)
|
||||
if [ "${#assets[@]}" -eq 0 ]; then
|
||||
echo "No release files found in ./release"
|
||||
exit 1
|
||||
fi
|
||||
gh release upload "$FIXED_DEV_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
|
||||
|
||||
dev-win-x64:
|
||||
needs: prepare
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Check out git repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ env.TARGET_BRANCH }}
|
||||
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: Set dev version
|
||||
shell: bash
|
||||
run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Build Frontend & Type Check
|
||||
shell: bash
|
||||
run: |
|
||||
npx tsc
|
||||
npx vite build
|
||||
|
||||
- name: Package Windows x64 dev artifacts
|
||||
run: |
|
||||
npx electron-builder --win nsis --x64 --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-x64-Setup.${ext}'
|
||||
|
||||
- name: Upload Windows x64 assets to fixed release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
assets=()
|
||||
while IFS= read -r file; do
|
||||
assets+=("$file")
|
||||
done < <(find release -maxdepth 1 -type f | sort)
|
||||
if [ "${#assets[@]}" -eq 0 ]; then
|
||||
echo "No release files found in ./release"
|
||||
exit 1
|
||||
fi
|
||||
gh release upload "$FIXED_DEV_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
|
||||
|
||||
dev-win-arm64:
|
||||
needs: prepare
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Check out git repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ env.TARGET_BRANCH }}
|
||||
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: Set dev version
|
||||
shell: bash
|
||||
run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Build Frontend & Type Check
|
||||
shell: bash
|
||||
run: |
|
||||
npx tsc
|
||||
npx vite build
|
||||
|
||||
- name: Package Windows arm64 dev artifacts
|
||||
run: |
|
||||
npx electron-builder --win nsis --arm64 --publish never '--config.publish.channel=dev-arm64' '--config.artifactName=${productName}-dev-arm64-Setup.${ext}'
|
||||
|
||||
- name: Upload Windows arm64 assets to fixed release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
assets=()
|
||||
while IFS= read -r file; do
|
||||
assets+=("$file")
|
||||
done < <(find release -maxdepth 1 -type f | sort)
|
||||
if [ "${#assets[@]}" -eq 0 ]; then
|
||||
echo "No release files found in ./release"
|
||||
exit 1
|
||||
fi
|
||||
gh release upload "$FIXED_DEV_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
|
||||
|
||||
update-dev-release-notes:
|
||||
needs:
|
||||
- prepare
|
||||
- dev-mac-arm64
|
||||
- dev-linux
|
||||
- dev-win-x64
|
||||
- dev-win-arm64
|
||||
if: always() && needs.prepare.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Update fixed dev release notes
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
FIXED_DEV_TAG: ${{ env.FIXED_DEV_TAG }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
TAG="$FIXED_DEV_TAG"
|
||||
REPO="$GITHUB_REPOSITORY"
|
||||
RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG"
|
||||
|
||||
if ! gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then
|
||||
echo "Release $TAG not found, skip notes update."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
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="$(pick_asset "dev-x64-Setup[.]exe$")"
|
||||
WINDOWS_ARM64_ASSET="$(pick_asset "dev-arm64-Setup[.]exe$")"
|
||||
MAC_ASSET="$(pick_asset "dev-arm64[.]dmg$")"
|
||||
LINUX_TAR_ASSET="$(pick_asset "dev-linux[.]tar[.]gz$")"
|
||||
LINUX_APPIMAGE_ASSET="$(pick_asset "dev-linux[.]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_TAR_URL="$(build_link "$LINUX_TAR_ASSET")"
|
||||
LINUX_APPIMAGE_URL="$(build_link "$LINUX_APPIMAGE_ASSET")"
|
||||
|
||||
cat > dev_release_notes.md <<EOF
|
||||
## Daily Dev Build
|
||||
- 该发布页为 **开发版**。
|
||||
- 当前构建版本:\`${{ needs.prepare.outputs.dev_version }}\`
|
||||
- 此版本为每日构建的开发版,包含最新的功能和修复,但可能不够稳定,仅推荐用于测试和验证。
|
||||
|
||||
## 下载
|
||||
- Windows x64: [点击下载](${WINDOWS_URL:-$RELEASE_PAGE})
|
||||
- Windows arm64: [点击下载](${WINDOWS_ARM64_URL:-$RELEASE_PAGE})
|
||||
- macOS(Apple Silicon): [点击下载](${MAC_URL:-$RELEASE_PAGE})
|
||||
- Linux (.tar.gz): [点击下载](${LINUX_TAR_URL:-$RELEASE_PAGE})
|
||||
- Linux (.AppImage): [点击下载](${LINUX_APPIMAGE_URL:-$RELEASE_PAGE})
|
||||
|
||||
## macOS 安装提示
|
||||
- 如果被系统提示已损坏,你需要在终端执行以下命令去除隔离标记:
|
||||
- \`xattr -dr com.apple.quarantine "/Applications/WeFlow.app"\`
|
||||
- 执行后重新打开 WeFlow。
|
||||
|
||||
## 说明
|
||||
- 该发布页的同名资源会被后续构建覆盖,请勿将其视作长期归档版本。
|
||||
- 如某个平台资源暂未生成,请进入[发布页]($RELEASE_PAGE)查看最新状态
|
||||
EOF
|
||||
|
||||
RELEASE_REST_ID="$(gh api "repos/$REPO/releases/tags/$TAG" --jq '.id')"
|
||||
jq -n --rawfile body dev_release_notes.md \
|
||||
'{name:"Daily Dev Build", body:$body, draft:false, prerelease:true}' \
|
||||
> release_update_payload.json
|
||||
gh api --method PATCH "repos/$REPO/releases/$RELEASE_REST_ID" --input release_update_payload.json >/dev/null
|
||||
gh release view "$TAG" --repo "$REPO" --json isDraft,isPrerelease,url
|
||||
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(", ")}`);
|
||||
379
.github/workflows/preview-nightly-main.yml
vendored
Normal file
379
.github/workflows/preview-nightly-main.yml
vendored
Normal file
@@ -0,0 +1,379 @@
|
||||
name: Preview Nightly
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# GitHub Actions schedule uses UTC. 16:00 UTC = 北京时间次日 00:00
|
||||
- cron: "0 16 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
FIXED_PREVIEW_TAG: nightly-preview
|
||||
TARGET_BRANCH: main
|
||||
ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
should_build: ${{ steps.meta.outputs.should_build }}
|
||||
preview_version: ${{ steps.meta.outputs.preview_version }}
|
||||
steps:
|
||||
- name: Check out git repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ env.TARGET_BRANCH }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Decide whether to build and generate preview version
|
||||
id: meta
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
git fetch origin main --depth=1
|
||||
COMMITS_24H="$(git rev-list --count --since='24 hours ago' origin/main)"
|
||||
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
SHOULD_BUILD=true
|
||||
elif [ "$COMMITS_24H" -gt 0 ]; then
|
||||
SHOULD_BUILD=true
|
||||
else
|
||||
SHOULD_BUILD=false
|
||||
fi
|
||||
|
||||
YEAR_2="$(TZ=Asia/Shanghai date +%y)"
|
||||
YEARLY_RUN_COUNT=1
|
||||
LAST_VERSION="$(gh release view "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" --json body --jq '.body' 2>/dev/null | grep -Eo '0\.[0-9]{2}\.[0-9]+' | head -n 1 || true)"
|
||||
if [[ "$LAST_VERSION" =~ ^0\.([0-9]{2})\.([0-9]+)$ ]]; then
|
||||
LAST_YEAR="${BASH_REMATCH[1]}"
|
||||
LAST_COUNT="${BASH_REMATCH[2]}"
|
||||
if [ "$LAST_YEAR" = "$YEAR_2" ]; then
|
||||
YEARLY_RUN_COUNT=$((LAST_COUNT + 1))
|
||||
fi
|
||||
fi
|
||||
|
||||
PREVIEW_VERSION="0.${YEAR_2}.${YEARLY_RUN_COUNT}"
|
||||
|
||||
echo "should_build=$SHOULD_BUILD" >> "$GITHUB_OUTPUT"
|
||||
echo "preview_version=$PREVIEW_VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "Preview version: $PREVIEW_VERSION (commits in last 24h on main: $COMMITS_24H, yearly count: $YEARLY_RUN_COUNT)"
|
||||
|
||||
- name: Recreate fixed preview prerelease
|
||||
if: steps.meta.outputs.should_build == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if gh release view "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
gh release delete "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" --yes --cleanup-tag
|
||||
fi
|
||||
gh release create "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" --title "Preview Nightly Build" --notes "预览版发布页" --prerelease --target "$TARGET_BRANCH"
|
||||
RELEASE_REST_ID="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$FIXED_PREVIEW_TAG" --jq '.id')"
|
||||
gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$RELEASE_REST_ID" -f draft=false -f prerelease=true >/dev/null
|
||||
|
||||
preview-mac-arm64:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.should_build == 'true'
|
||||
runs-on: macos-14
|
||||
steps:
|
||||
- name: Check out git repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ env.TARGET_BRANCH }}
|
||||
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: Set preview version
|
||||
shell: bash
|
||||
run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Build Frontend & Type Check
|
||||
shell: bash
|
||||
run: |
|
||||
npx tsc
|
||||
npx vite build
|
||||
|
||||
- name: Package macOS arm64 preview artifacts
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
shell: bash
|
||||
run: |
|
||||
export ELECTRON_BUILDER_BINARIES_MIRROR="https://github.com/electron-userland/electron-builder-binaries/releases/download/"
|
||||
echo "Using ELECTRON_BUILDER_BINARIES_MIRROR=$ELECTRON_BUILDER_BINARIES_MIRROR"
|
||||
npx electron-builder --mac dmg --arm64 --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-arm64.${ext}'
|
||||
|
||||
- name: Upload macOS arm64 assets to fixed preview release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
assets=()
|
||||
while IFS= read -r file; do
|
||||
assets+=("$file")
|
||||
done < <(find release -maxdepth 1 -type f | sort)
|
||||
if [ "${#assets[@]}" -eq 0 ]; then
|
||||
echo "No release files found in ./release"
|
||||
exit 1
|
||||
fi
|
||||
gh release upload "$FIXED_PREVIEW_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
|
||||
|
||||
preview-linux:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.should_build == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out git repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ env.TARGET_BRANCH }}
|
||||
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: Set preview version
|
||||
shell: bash
|
||||
run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Build Frontend & Type Check
|
||||
shell: bash
|
||||
run: |
|
||||
npx tsc
|
||||
npx vite build
|
||||
|
||||
- name: Package Linux preview artifacts
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
npx electron-builder --linux --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-linux.${ext}'
|
||||
|
||||
- name: Upload Linux assets to fixed preview release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
assets=()
|
||||
while IFS= read -r file; do
|
||||
assets+=("$file")
|
||||
done < <(find release -maxdepth 1 -type f | sort)
|
||||
if [ "${#assets[@]}" -eq 0 ]; then
|
||||
echo "No release files found in ./release"
|
||||
exit 1
|
||||
fi
|
||||
gh release upload "$FIXED_PREVIEW_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
|
||||
|
||||
preview-win-x64:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.should_build == 'true'
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Check out git repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ env.TARGET_BRANCH }}
|
||||
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: Set preview version
|
||||
shell: bash
|
||||
run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Build Frontend & Type Check
|
||||
shell: bash
|
||||
run: |
|
||||
npx tsc
|
||||
npx vite build
|
||||
|
||||
- name: Package Windows x64 preview artifacts
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
npx electron-builder --win nsis --x64 --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-x64-Setup.${ext}'
|
||||
|
||||
- name: Upload Windows x64 assets to fixed preview release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
assets=()
|
||||
while IFS= read -r file; do
|
||||
assets+=("$file")
|
||||
done < <(find release -maxdepth 1 -type f | sort)
|
||||
if [ "${#assets[@]}" -eq 0 ]; then
|
||||
echo "No release files found in ./release"
|
||||
exit 1
|
||||
fi
|
||||
gh release upload "$FIXED_PREVIEW_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
|
||||
|
||||
preview-win-arm64:
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.should_build == 'true'
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Check out git repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ env.TARGET_BRANCH }}
|
||||
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: Set preview version
|
||||
shell: bash
|
||||
run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Build Frontend & Type Check
|
||||
shell: bash
|
||||
run: |
|
||||
npx tsc
|
||||
npx vite build
|
||||
|
||||
- name: Package Windows arm64 preview artifacts
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
npx electron-builder --win nsis --arm64 --publish never '--config.publish.channel=preview-arm64' '--config.artifactName=${productName}-preview-arm64-Setup.${ext}'
|
||||
|
||||
- name: Upload Windows arm64 assets to fixed preview release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
assets=()
|
||||
while IFS= read -r file; do
|
||||
assets+=("$file")
|
||||
done < <(find release -maxdepth 1 -type f | sort)
|
||||
if [ "${#assets[@]}" -eq 0 ]; then
|
||||
echo "No release files found in ./release"
|
||||
exit 1
|
||||
fi
|
||||
gh release upload "$FIXED_PREVIEW_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
|
||||
|
||||
update-preview-release-notes:
|
||||
needs:
|
||||
- prepare
|
||||
- preview-mac-arm64
|
||||
- preview-linux
|
||||
- preview-win-x64
|
||||
- preview-win-arm64
|
||||
if: needs.prepare.outputs.should_build == 'true' && always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Update preview release notes
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
TAG="$FIXED_PREVIEW_TAG"
|
||||
CURRENT_PREVIEW_VERSION="${{ needs.prepare.outputs.preview_version }}"
|
||||
REPO="$GITHUB_REPOSITORY"
|
||||
RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG"
|
||||
|
||||
if ! gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then
|
||||
echo "Release $TAG not found (possibly all publish jobs failed), skip notes update."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
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="$(pick_asset "x64.*[.]exe$")"
|
||||
if [ -z "$WINDOWS_ASSET" ]; then
|
||||
WINDOWS_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("[.]exe$")) | select(test("arm64") | not)][0] // ""')"
|
||||
fi
|
||||
WINDOWS_ARM64_ASSET="$(pick_asset "arm64.*[.]exe$")"
|
||||
MAC_ASSET="$(pick_asset "[.]dmg$")"
|
||||
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_TAR_URL="$(build_link "$LINUX_TAR_ASSET")"
|
||||
LINUX_APPIMAGE_URL="$(build_link "$LINUX_APPIMAGE_ASSET")"
|
||||
|
||||
cat > preview_release_notes.md <<EOF
|
||||
## Preview Nightly 说明
|
||||
- 该版本为 **预览版**,用于提前体验即将发布的功能与修复。
|
||||
- 可能包含尚未完全稳定的改动,不建议长期使用
|
||||
- 当前版本号:\`$CURRENT_PREVIEW_VERSION\`
|
||||
|
||||
## 下载
|
||||
- Windows x64: [点击下载](${WINDOWS_URL:-$RELEASE_PAGE})
|
||||
- Windows arm64: [点击下载](${WINDOWS_ARM64_URL:-$RELEASE_PAGE})
|
||||
- macOS(Apple Silicon): [点击下载](${MAC_URL:-$RELEASE_PAGE})
|
||||
- Linux (.tar.gz): [点击下载](${LINUX_TAR_URL:-$RELEASE_PAGE})
|
||||
- Linux (.AppImage): [点击下载](${LINUX_APPIMAGE_URL:-$RELEASE_PAGE})
|
||||
|
||||
## macOS 安装提示
|
||||
- 如果被系统提示已损坏,你需要在终端执行以下命令去除隔离标记:
|
||||
- \`xattr -dr com.apple.quarantine "/Applications/WeFlow.app"\`
|
||||
- 执行后重新打开 WeFlow。
|
||||
|
||||
> 如某个平台链接暂未生成,请前往[发布页]($RELEASE_PAGE)查看最新资源
|
||||
EOF
|
||||
|
||||
RELEASE_REST_ID="$(gh api "repos/$REPO/releases/tags/$TAG" --jq '.id')"
|
||||
jq -n --rawfile body preview_release_notes.md \
|
||||
'{name:"Preview Nightly Build", body:$body, draft:false, prerelease:true}' \
|
||||
> release_update_payload.json
|
||||
gh api --method PATCH "repos/$REPO/releases/$RELEASE_REST_ID" --input release_update_payload.json >/dev/null
|
||||
gh release view "$TAG" --repo "$REPO" --json isDraft,isPrerelease,url
|
||||
209
.github/workflows/release.yml
vendored
209
.github/workflows/release.yml
vendored
@@ -10,6 +10,7 @@ permissions:
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/
|
||||
|
||||
jobs:
|
||||
release-mac-arm64:
|
||||
@@ -28,7 +29,7 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm ci
|
||||
run: npm install
|
||||
|
||||
- name: Sync version with tag
|
||||
shell: bash
|
||||
@@ -38,6 +39,7 @@ jobs:
|
||||
npm version $VERSION --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Build Frontend & Type Check
|
||||
shell: bash
|
||||
run: |
|
||||
npx tsc
|
||||
npx vite build
|
||||
@@ -46,23 +48,78 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
shell: bash
|
||||
run: |
|
||||
export ELECTRON_BUILDER_BINARIES_MIRROR="https://github.com/electron-userland/electron-builder-binaries/releases/download/"
|
||||
echo "Using ELECTRON_BUILDER_BINARIES_MIRROR=$ELECTRON_BUILDER_BINARIES_MIRROR"
|
||||
npx electron-builder --mac dmg --arm64 --publish always
|
||||
|
||||
- name: Update Release Notes
|
||||
- name: Inject minimumVersion into latest yml
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
cat <<EOF > release_notes.md
|
||||
## 更新日志
|
||||
修复了一些已知问题
|
||||
TAG=${GITHUB_REF_NAME}
|
||||
REPO=${{ github.repository }}
|
||||
MINIMUM_VERSION="4.1.7"
|
||||
for YML_FILE in latest-mac.yml latest-arm64-mac.yml; do
|
||||
gh release download "$TAG" --repo "$REPO" --pattern "$YML_FILE" --output "/tmp/$YML_FILE" 2>/dev/null || continue
|
||||
if ! grep -q 'minimumVersion' "/tmp/$YML_FILE"; then
|
||||
echo "minimumVersion: $MINIMUM_VERSION" >> "/tmp/$YML_FILE"
|
||||
fi
|
||||
gh release upload "$TAG" --repo "$REPO" "/tmp/$YML_FILE" --clobber
|
||||
done
|
||||
|
||||
## 查看更多日志/获取最新动态
|
||||
[点击加入 Telegram 频道](https://t.me/weflow_cc)
|
||||
EOF
|
||||
release-linux:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
gh release edit "$GITHUB_REF_NAME" --notes-file release_notes.md
|
||||
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
|
||||
shell: bash
|
||||
run: |
|
||||
npx tsc
|
||||
npx vite build
|
||||
|
||||
- name: Package and Publish Linux
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
npx electron-builder --linux --publish always
|
||||
|
||||
- name: Inject minimumVersion into latest yml
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
TAG=${GITHUB_REF_NAME}
|
||||
REPO=${{ github.repository }}
|
||||
MINIMUM_VERSION="4.1.7"
|
||||
gh release download "$TAG" --repo "$REPO" --pattern "latest-linux.yml" --output "/tmp/latest-linux.yml" 2>/dev/null
|
||||
if [ -f /tmp/latest-linux.yml ] && ! grep -q 'minimumVersion' /tmp/latest-linux.yml; then
|
||||
echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest-linux.yml
|
||||
gh release upload "$TAG" --repo "$REPO" /tmp/latest-linux.yml --clobber
|
||||
fi
|
||||
|
||||
release:
|
||||
runs-on: windows-latest
|
||||
@@ -80,7 +137,7 @@ jobs:
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm ci
|
||||
run: npm install
|
||||
|
||||
- name: Sync version with tag
|
||||
shell: bash
|
||||
@@ -90,6 +147,7 @@ jobs:
|
||||
npm version $VERSION --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Build Frontend & Type Check
|
||||
shell: bash
|
||||
run: |
|
||||
npx tsc
|
||||
npx vite build
|
||||
@@ -98,19 +156,142 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
npx electron-builder --publish always
|
||||
npx electron-builder --win nsis --x64 --publish always '--config.artifactName=${productName}-${version}-x64-Setup.${ext}'
|
||||
|
||||
- name: Update Release Notes
|
||||
- name: Inject minimumVersion into latest yml
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
cat <<EOF > release_notes.md
|
||||
TAG=${GITHUB_REF_NAME}
|
||||
REPO=${{ github.repository }}
|
||||
MINIMUM_VERSION="4.1.7"
|
||||
gh release download "$TAG" --repo "$REPO" --pattern "latest.yml" --output "/tmp/latest.yml" 2>/dev/null
|
||||
if [ -f /tmp/latest.yml ] && ! grep -q 'minimumVersion' /tmp/latest.yml; then
|
||||
echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest.yml
|
||||
gh release upload "$TAG" --repo "$REPO" /tmp/latest.yml --clobber
|
||||
fi
|
||||
|
||||
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
|
||||
shell: bash
|
||||
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.publish.channel=latest-arm64' '--config.artifactName=${productName}-${version}-arm64-Setup.${ext}'
|
||||
|
||||
- name: Inject minimumVersion into latest yml
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
TAG=${GITHUB_REF_NAME}
|
||||
REPO=${{ github.repository }}
|
||||
MINIMUM_VERSION="4.1.7"
|
||||
gh release download "$TAG" --repo "$REPO" --pattern "latest-arm64.yml" --output "/tmp/latest-arm64.yml" 2>/dev/null
|
||||
if [ -f /tmp/latest-arm64.yml ] && ! grep -q 'minimumVersion' /tmp/latest-arm64.yml; then
|
||||
echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest-arm64.yml
|
||||
gh release upload "$TAG" --repo "$REPO" /tmp/latest-arm64.yml --clobber
|
||||
fi
|
||||
|
||||
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: |
|
||||
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("x64.*\\.exe$"))][0] // ""')"
|
||||
if [ -z "$WINDOWS_ASSET" ]; then
|
||||
WINDOWS_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("\\.exe$")) | select(test("arm64") | not)][0] // ""')"
|
||||
fi
|
||||
WINDOWS_ARM64_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("arm64.*\\.exe$"))][0] // ""')"
|
||||
MAC_ASSET="$(pick_asset "\\.dmg$")"
|
||||
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_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 (.tar.gz): [点击下载](${LINUX_TAR_URL:-$RELEASE_PAGE})
|
||||
- Linux (.AppImage): [点击下载](${LINUX_APPIMAGE_URL:-$RELEASE_PAGE})
|
||||
|
||||
## macOS 安装提示
|
||||
- 如果被系统提示已损坏,你需要在终端执行以下命令去除隔离标记:
|
||||
- \`xattr -dr com.apple.quarantine "/Applications/WeFlow.app"\`
|
||||
- 执行后重新打开 WeFlow。
|
||||
|
||||
> 如果某个平台链接暂时未生成,可进入[完整发布页]($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
|
||||
|
||||
96
.github/workflows/security-scan.yml
vendored
Normal file
96
.github/workflows/security-scan.yml
vendored
Normal file
@@ -0,0 +1,96 @@
|
||||
name: Security Scan
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 2 * * *' # 每天 UTC 02:00
|
||||
workflow_dispatch: # 手动触发
|
||||
pull_request: # PR 时触发
|
||||
branches: [ main, dev ]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
security-scan:
|
||||
name: Security Scan (${{ matrix.branch }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
branch:
|
||||
- main
|
||||
|
||||
steps:
|
||||
- name: Checkout ${{ matrix.branch }}
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ matrix.branch }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm' # 使用 npm 缓存加速
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
# 1. npm audit - 检查依赖漏洞
|
||||
- name: Dependency vulnerability audit
|
||||
run: npm audit --audit-level=moderate
|
||||
continue-on-error: true
|
||||
|
||||
# 2. CodeQL 静态分析
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: javascript, typescript
|
||||
queries: security-and-quality
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: '/language:javascript-typescript/branch:${{ matrix.branch }}'
|
||||
|
||||
# 3. 密钥/敏感信息扫描
|
||||
- name: Secret scanning with Gitleaks
|
||||
uses: gitleaks/gitleaks-action@v2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
continue-on-error: true
|
||||
|
||||
# 动态获取所有分支并扫描
|
||||
scan-all-branches:
|
||||
name: Scan additional branches
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Run npm audit on all branches
|
||||
run: |
|
||||
git branch -r | grep -v HEAD | sed 's|origin/||' | tr -d ' ' | while read branch; do
|
||||
echo "===== Auditing branch: $branch ====="
|
||||
git checkout "$branch" 2>/dev/null || continue
|
||||
# 尝试安装并审计
|
||||
npm ci --ignore-scripts --silent 2>/dev/null || npm install --ignore-scripts --silent 2>/dev/null || true
|
||||
npm audit --audit-level=moderate 2>/dev/null || true
|
||||
done
|
||||
continue-on-error: true
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -56,15 +56,23 @@ Thumbs.db
|
||||
*.aps
|
||||
|
||||
wcdb/
|
||||
!resources/wcdb/
|
||||
!resources/wcdb/**
|
||||
xkey/
|
||||
server/
|
||||
*info
|
||||
chatlab-format.md
|
||||
*.bak
|
||||
AGENTS.md
|
||||
AGENT.md
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
.agents/
|
||||
resources/wx_send
|
||||
概述.md
|
||||
pnpm-lock.yaml
|
||||
/pnpm-workspace.yaml
|
||||
wechat-research-site
|
||||
.codex
|
||||
weflow-web-offical
|
||||
Insight
|
||||
|
||||
23
.gitleaks.toml
Normal file
23
.gitleaks.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
title = "Gitleaks Config"
|
||||
|
||||
[extend]
|
||||
# 继承默认规则
|
||||
useDefault = true
|
||||
|
||||
# 排除误报路径
|
||||
[[rules]]
|
||||
id = "curl-auth-header"
|
||||
[rules.allowlist]
|
||||
paths = [
|
||||
'''docs/HTTP-API\.md'''
|
||||
]
|
||||
regexes = [
|
||||
'''YOUR_TOKEN'''
|
||||
]
|
||||
|
||||
[[rules]]
|
||||
id = "generic-api-key"
|
||||
[rules.allowlist]
|
||||
paths = [
|
||||
'''src/pages/ChatPage\.tsx'''
|
||||
]
|
||||
4
.npmrc
4
.npmrc
@@ -1,3 +1 @@
|
||||
registry=https://registry.npmmirror.com
|
||||
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||
registry=https://registry.npmjs.org
|
||||
|
||||
25
README.md
25
README.md
@@ -19,7 +19,9 @@ 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://github.com/hicccc77/WeFlow/releases">
|
||||
<img src="https://img.shields.io/github/downloads/hicccc77/WeFlow/total?style=flat-square" 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">
|
||||
@@ -43,9 +45,21 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
|
||||
- HTTP API 接口(供开发者集成)
|
||||
- 查看完整能力清单:[详细功能](#详细功能清单)
|
||||
|
||||
## 支持平台与设备
|
||||
|
||||
|
||||
| 平台 | 设备/架构 | 安装包 |
|
||||
|------|----------|--------|
|
||||
| Windows | Windows10+、x64(amd64) | `.exe` |
|
||||
| macOS | Apple Silicon(M 系列,arm64) | `.dmg` |
|
||||
| Linux | x64 设备(amd64) | `.AppImage`、`.tar.gz` |
|
||||
|
||||
|
||||
## 快速开始
|
||||
|
||||
若你只想使用成品版本,可前往 Release 下载并安装。
|
||||
若你只想使用成品版本,可前往 [Releases](https://github.com/hicccc77/WeFlow/releases) 下载并安装。
|
||||
|
||||
> ArchLinux 用户可以选择 `yay -S weflow` 快速安装
|
||||
|
||||
## 详细功能清单
|
||||
|
||||
@@ -54,6 +68,7 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
|
||||
| 功能模块 | 说明 |
|
||||
|---------|------|
|
||||
| **聊天** | 解密聊天中的图片、视频、实况(仅支持谷歌协议拍摄的实况);支持**修改**、删除**本地**消息;实时刷新最新消息,无需生成解密中间数据库 |
|
||||
| **消息防撤回** | 防止其他人发送的消息被撤回 |
|
||||
| **实时弹窗通知** | 新消息到达时提供桌面弹窗提醒,便于及时查看重要会话,提供黑白名单功能 |
|
||||
| **私聊分析** | 统计好友间消息数量;分析消息类型与发送比例;查看消息时段分布等 |
|
||||
| **群聊分析** | 查看群成员详细信息;分析群内发言排行、活跃时段和媒体内容 |
|
||||
@@ -94,14 +109,8 @@ npm install
|
||||
# 3. 运行应用(开发模式)
|
||||
npm run dev
|
||||
|
||||
# 4. 打包可执行文件
|
||||
npm run build
|
||||
```
|
||||
|
||||
打包产物在 `release` 目录下。
|
||||
|
||||
|
||||
|
||||
## 致谢
|
||||
|
||||
- [密语 CipherTalk](https://github.com/ILoveBingLu/miyu) 为本项目提供了基础框架
|
||||
|
||||
765
docs/HTTP-API.md
765
docs/HTTP-API.md
@@ -1,33 +1,56 @@
|
||||
# WeFlow HTTP API 接口文档
|
||||
# WeFlow HTTP API / Push 文档
|
||||
|
||||
WeFlow 提供 HTTP API 服务,支持通过 HTTP 接口查询消息数据,支持 [ChatLab](https://github.com/nichuanfang/chatlab-format) 标准化格式输出。
|
||||
WeFlow 提供本地 HTTP API(已支持GET 和 POST请求),便于外部脚本或工具读取聊天记录、会话、联系人、群成员和导出的媒体文件;也支持在检测到新消息后通过固定 SSE 地址主动推送消息事件。
|
||||
|
||||
## 启用 API 服务
|
||||
## 启用方式
|
||||
|
||||
在设置页面 → API 服务 → 点击「启动服务」按钮。
|
||||
在应用设置页启用 `API 服务`。
|
||||
|
||||
默认端口:`5031`
|
||||
- 默认监听地址:`127.0.0.1`
|
||||
- 默认端口:`5031`
|
||||
- 基础地址:`http://127.0.0.1:5031`
|
||||
- 可选开启 `主动推送`,检测到新收到的消息后会通过 `GET /api/v1/push/messages` 推送给 SSE 订阅端
|
||||
|
||||
## 基础地址
|
||||
**状态记忆**:API 服务和主动推送的状态及端口会自动保存,重启 WeFlow 后会自动恢复运行。
|
||||
|
||||
```
|
||||
http://127.0.0.1:5031
|
||||
```
|
||||
## 鉴权规范
|
||||
|
||||
---
|
||||
**鉴权规范 (Access Token)** 除健康检查接口外,所有 `/api/v1/*` 接口均受 Token 保护。支持三种传参方式(任选其一):
|
||||
|
||||
1. **HTTP Header (推荐)**: `Authorization: Bearer <您的Token>`
|
||||
2. **Query 参数**: `?access_token=<您的Token>`(SSE 长连接推荐此方式)
|
||||
3. **JSON Body**: `{"access_token": "<您的Token>"}`(仅限 POST 请求)
|
||||
|
||||
## 接口列表
|
||||
|
||||
### 1. 健康检查
|
||||
- `GET|POST /health`
|
||||
- `GET|POST /api/v1/health`
|
||||
- `GET|POST /api/v1/push/messages`
|
||||
- `GET|POST /api/v1/messages`
|
||||
- `GET|POST /api/v1/messages/new`
|
||||
- `GET|POST /api/v1/sessions`
|
||||
- `GET|POST /api/v1/contacts`
|
||||
- `GET|POST /api/v1/group-members`
|
||||
- `GET|POST /api/v1/media/*`
|
||||
|
||||
检查 API 服务是否正常运行。
|
||||
---
|
||||
|
||||
## 1. 健康检查
|
||||
|
||||
**请求**
|
||||
```
|
||||
|
||||
```http
|
||||
GET /health
|
||||
```
|
||||
|
||||
或
|
||||
|
||||
```http
|
||||
GET /api/v1/health
|
||||
```
|
||||
|
||||
**响应**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok"
|
||||
@@ -36,211 +59,227 @@ GET /health
|
||||
|
||||
---
|
||||
|
||||
### 2. 获取消息列表
|
||||
## 2. 主动推送
|
||||
|
||||
获取指定会话的消息,支持 ChatLab 格式输出。
|
||||
通过 SSE 长连接接收新消息事件,端口与 HTTP API 共用。
|
||||
|
||||
**请求**
|
||||
|
||||
```http
|
||||
GET /api/v1/push/messages
|
||||
```
|
||||
|
||||
### 说明
|
||||
|
||||
- 需要先在设置页开启 `HTTP API 服务`
|
||||
- 同时需要开启 `主动推送`
|
||||
- 响应类型为 `text/event-stream`
|
||||
- 新消息事件名固定为 `message.new`
|
||||
- 建议接收端按 `messageKey` 去重
|
||||
|
||||
### 事件字段
|
||||
|
||||
- `event`
|
||||
- `sessionId`
|
||||
- `messageKey`
|
||||
- `avatarUrl`
|
||||
- `sourceName`
|
||||
- `groupName`(仅群聊)
|
||||
- `content`
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
curl -N "http://127.0.0.1:5031/api/v1/push/messages?access_token=YOUR_TOKEN
|
||||
```
|
||||
|
||||
示例事件:
|
||||
|
||||
```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. 获取消息
|
||||
|
||||
> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json)
|
||||
|
||||
读取指定会话的消息,支持原始 JSON 和 ChatLab 格式。
|
||||
|
||||
**请求**
|
||||
|
||||
```http
|
||||
GET /api/v1/messages
|
||||
```
|
||||
|
||||
**参数**
|
||||
### 参数
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| `talker` | string | ✅ | 会话 ID(wxid 或群 ID) |
|
||||
| `limit` | number | ❌ | 返回数量限制,默认 100,范围 `1~10000` |
|
||||
| `offset` | number | ❌ | 偏移量,用于分页,默认 0 |
|
||||
| `start` | string | ❌ | 开始时间,格式 YYYYMMDD |
|
||||
| `end` | string | ❌ | 结束时间,格式 YYYYMMDD |
|
||||
| `keyword` | string | ❌ | 关键词过滤(基于消息显示文本) |
|
||||
| `chatlab` | string | ❌ | 设为 `1` 则输出 ChatLab 格式 |
|
||||
| `format` | string | ❌ | 输出格式:`json`(默认)或 `chatlab` |
|
||||
| `media` | string | ❌ | 设为 `1` 时导出媒体并返回媒体路径(兼容别名 `meiti`);`0` 时媒体返回占位符 |
|
||||
| `image` | string | ❌ | 在 `media=1` 时控制图片导出,`1/0`(兼容别名 `tupian`) |
|
||||
| `voice` | string | ❌ | 在 `media=1` 时控制语音导出,`1/0`(兼容别名 `vioce`) |
|
||||
| `video` | string | ❌ | 在 `media=1` 时控制视频导出,`1/0` |
|
||||
| `emoji` | string | ❌ | 在 `media=1` 时控制表情导出,`1/0` |
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `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` 时控制表情导出 |
|
||||
|
||||
默认媒体导出目录:`%USERPROFILE%\\Documents\\WeFlow\\api-media`
|
||||
|
||||
**示例请求**
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
# 获取消息(原始格式)
|
||||
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=50
|
||||
|
||||
# 获取消息(ChatLab 格式)
|
||||
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1
|
||||
|
||||
# 带时间范围查询
|
||||
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&start=20260101&end=20260205&limit=100
|
||||
|
||||
# 开启媒体导出(只导出图片和语音)
|
||||
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&media=1&image=1&voice=1&video=0&emoji=0
|
||||
|
||||
# 关键词过滤
|
||||
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&keyword=项目进度&limit=50
|
||||
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,
|
||||
"talker": "wxid_xxx",
|
||||
"count": 50,
|
||||
"talker": "xxx@chatroom",
|
||||
"count": 2,
|
||||
"hasMore": true,
|
||||
"media": {
|
||||
"enabled": true,
|
||||
"exportPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media",
|
||||
"count": 12
|
||||
"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": "[图片]",
|
||||
"createTime": 1738713600000,
|
||||
"senderUsername": "wxid_sender",
|
||||
"mediaType": "image",
|
||||
"mediaFileName": "image_123.jpg",
|
||||
"mediaUrl": "http://127.0.0.1:5031/api/v1/media/wxid_xxx/images/image_123.jpg",
|
||||
"mediaLocalPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\wxid_xxx\\images\\image_123.jpg"
|
||||
"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 格式)**
|
||||
```json
|
||||
{
|
||||
"chatlab": {
|
||||
"version": "0.0.2",
|
||||
"exportedAt": 1738713600000,
|
||||
"generator": "WeFlow",
|
||||
"description": "Exported from WeFlow"
|
||||
},
|
||||
"meta": {
|
||||
"name": "会话名称",
|
||||
"platform": "wechat",
|
||||
"type": "private",
|
||||
"ownerId": "wxid_me"
|
||||
},
|
||||
"members": [
|
||||
{
|
||||
"platformId": "wxid_xxx",
|
||||
"accountName": "用户名",
|
||||
"groupNickname": "群昵称"
|
||||
}
|
||||
],
|
||||
"messages": [
|
||||
{
|
||||
"sender": "wxid_xxx",
|
||||
"accountName": "用户名",
|
||||
"timestamp": 1738713600000,
|
||||
"type": 0,
|
||||
"content": "消息内容",
|
||||
"mediaPath": "http://127.0.0.1:5031/api/v1/media/wxid_xxx/images/image_123.jpg"
|
||||
}
|
||||
],
|
||||
"media": {
|
||||
"enabled": true,
|
||||
"exportPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media",
|
||||
"count": 12
|
||||
}
|
||||
}
|
||||
```
|
||||
### 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` 会优先来自群成员群昵称;若源数据缺失,则回退为空或展示名。
|
||||
|
||||
---
|
||||
|
||||
### 3. 访问导出媒体文件
|
||||
## 4. 获取会话列表
|
||||
|
||||
通过 HTTP 直接访问已导出的媒体文件(图片、语音、视频、表情)。
|
||||
> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json)
|
||||
|
||||
**请求**
|
||||
```
|
||||
GET /api/v1/media/{relativePath}
|
||||
```
|
||||
|
||||
**路径参数**
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| `relativePath` | string | ✅ | 媒体文件的相对路径,如 `wxid_xxx/images/image_123.jpg` |
|
||||
|
||||
**支持的媒体类型**
|
||||
|
||||
| 扩展名 | Content-Type |
|
||||
|--------|-------------|
|
||||
| `.png` | image/png |
|
||||
| `.jpg` / `.jpeg` | image/jpeg |
|
||||
| `.gif` | image/gif |
|
||||
| `.webp` | image/webp |
|
||||
| `.wav` | audio/wav |
|
||||
| `.mp3` | audio/mpeg |
|
||||
| `.mp4` | video/mp4 |
|
||||
|
||||
**示例请求**
|
||||
```bash
|
||||
# 访问导出的图片
|
||||
GET http://127.0.0.1:5031/api/v1/media/wxid_xxx/images/image_123.jpg
|
||||
|
||||
# 访问导出的语音
|
||||
GET http://127.0.0.1:5031/api/v1/media/wxid_xxx/voices/voice_456.wav
|
||||
|
||||
# 访问导出的视频
|
||||
GET http://127.0.0.1:5031/api/v1/media/wxid_xxx/videos/video_789.mp4
|
||||
```
|
||||
|
||||
**响应**
|
||||
|
||||
成功时直接返回文件内容,`Content-Type` 根据文件扩展名自动设置。
|
||||
|
||||
失败时返回:
|
||||
```json
|
||||
{ "error": "Media not found" }
|
||||
```
|
||||
|
||||
> 注意:媒体文件需要先通过消息接口的 `media=1` 参数导出后才能访问。
|
||||
|
||||
---
|
||||
|
||||
### 4. 获取会话列表
|
||||
|
||||
获取所有会话列表。
|
||||
|
||||
**请求**
|
||||
```
|
||||
```http
|
||||
GET /api/v1/sessions
|
||||
```
|
||||
|
||||
**参数**
|
||||
### 参数
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| `keyword` | string | ❌ | 搜索关键词,匹配会话名或 ID |
|
||||
| `limit` | number | ❌ | 返回数量限制,默认 100 |
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `keyword` | string | 否 | 匹配 `username` 或 `displayName` |
|
||||
| `limit` | number | 否 | 默认 `100` |
|
||||
|
||||
**示例请求**
|
||||
```bash
|
||||
GET http://127.0.0.1:5031/api/v1/sessions
|
||||
### 响应字段
|
||||
|
||||
GET http://127.0.0.1:5031/api/v1/sessions?keyword=工作群&limit=20
|
||||
```
|
||||
- `success`
|
||||
- `count`
|
||||
- `sessions[].username`
|
||||
- `sessions[].displayName`
|
||||
- `sessions[].type`
|
||||
- `sessions[].lastTimestamp`
|
||||
- `sessions[].unreadCount`
|
||||
|
||||
**示例响应**
|
||||
|
||||
**响应**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"count": 50,
|
||||
"total": 100,
|
||||
"count": 1,
|
||||
"sessions": [
|
||||
{
|
||||
"username": "wxid_xxx",
|
||||
"displayName": "用户名",
|
||||
"lastMessage": "最后一条消息",
|
||||
"lastTime": 1738713600000,
|
||||
"username": "xxx@chatroom",
|
||||
"displayName": "项目群",
|
||||
"type": 2,
|
||||
"lastTimestamp": 1738713600,
|
||||
"unreadCount": 0
|
||||
}
|
||||
]
|
||||
@@ -249,40 +288,50 @@ GET http://127.0.0.1:5031/api/v1/sessions?keyword=工作群&limit=20
|
||||
|
||||
---
|
||||
|
||||
### 4. 获取联系人列表
|
||||
## 5. 获取联系人列表
|
||||
|
||||
获取所有联系人信息。
|
||||
> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json)
|
||||
|
||||
**请求**
|
||||
```
|
||||
|
||||
```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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -290,60 +339,281 @@ GET http://127.0.0.1:5031/api/v1/contacts?keyword=张三
|
||||
|
||||
---
|
||||
|
||||
## ChatLab 格式说明
|
||||
## 6. 获取群成员列表
|
||||
|
||||
ChatLab 是一种标准化的聊天记录交换格式,版本 0.0.2。
|
||||
> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json)
|
||||
|
||||
### 消息类型映射
|
||||
返回群成员的 `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. 朋友圈接口
|
||||
|
||||
### 7.1 获取朋友圈时间线
|
||||
|
||||
```http
|
||||
GET /api/v1/sns/timeline
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `limit` | number | 否 | 返回数量,默认 20,范围 `1~200` |
|
||||
| `offset` | number | 否 | 偏移量,默认 0 |
|
||||
| `usernames` | string | 否 | 发布者过滤,逗号分隔,如 `wxid_a,wxid_b` |
|
||||
| `keyword` | string | 否 | 关键词过滤(正文) |
|
||||
| `start` | string | 否 | 开始时间,支持 `YYYYMMDD` 或秒/毫秒时间戳 |
|
||||
| `end` | string | 否 | 结束时间,支持 `YYYYMMDD` 或秒/毫秒时间戳 |
|
||||
| `media` | number | 否 | 是否返回可直接访问的媒体地址,默认 `1` |
|
||||
| `replace` | number | 否 | `media=1` 时,是否用解析地址覆盖 `media.url/thumb`,默认 `1` |
|
||||
| `inline` | number | 否 | `media=1` 时,是否内联返回 `data:` URL,默认 `0` |
|
||||
|
||||
示例:
|
||||
|
||||
```bash
|
||||
curl "http://127.0.0.1:5031/api/v1/sns/timeline?limit=5"
|
||||
curl "http://127.0.0.1:5031/api/v1/sns/timeline?usernames=wxid_a,wxid_b&keyword=旅行"
|
||||
curl "http://127.0.0.1:5031/api/v1/sns/timeline?limit=3&media=1&replace=1"
|
||||
curl "http://127.0.0.1:5031/api/v1/sns/timeline?limit=3&media=1&inline=1"
|
||||
```
|
||||
|
||||
媒体字段说明(`media=1`):
|
||||
|
||||
- `media[].url/thumb`:你应该优先直接使用的字段。
|
||||
- `replace=1`(默认)时,`media[].url/thumb` 会直接被替换成可访问地址,等价于 `resolvedUrl/resolvedThumbUrl`。
|
||||
- `replace=0` 时,`media[].url/thumb` 仍保留微信原始地址;这时再结合下面的 `raw/proxy/resolved` 字段自己决定用哪一个。
|
||||
- `media[].rawUrl/rawThumb`:原始朋友圈地址
|
||||
- `media[].proxyUrl/proxyThumbUrl`:可直接访问的代理地址
|
||||
- `media[].resolvedUrl/resolvedThumbUrl`:最终可用地址(`inline=1` 时可能是 `data:` URL)
|
||||
- `media[].token/key/encIdx`:微信源数据里的访问/解密参数。通常不需要你自己处理;如果你手动调用 `/api/v1/sns/media/proxy`,把当前条目的 `url` 和 `key` 原样传回即可。
|
||||
- `media[].livePhoto`:实况图的视频部分。外层 `media[].url/thumb` 仍是封面图,`livePhoto` 内部会再提供一组自己的 `url/thumb/raw*/proxy*/resolved*` 字段。
|
||||
- `media=0` 时,不会补充 `raw*/proxy*/resolved*`,接口只返回原始 `url/thumb` 以及源字段(如 `key/token/encIdx`)。
|
||||
|
||||
### 7.2 获取朋友圈发布者
|
||||
|
||||
```http
|
||||
GET /api/v1/sns/usernames
|
||||
```
|
||||
|
||||
### 7.3 获取朋友圈导出统计
|
||||
|
||||
```http
|
||||
GET /api/v1/sns/export/stats
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `fast` | number | 否 | `1` 使用快速统计(优先缓存) |
|
||||
|
||||
### 7.4 朋友圈媒体代理
|
||||
|
||||
```http
|
||||
GET /api/v1/sns/media/proxy
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `url` | string | 是 | 媒体原始 URL |
|
||||
| `key` | string/number | 否 | 解密 key(部分资源需要) |
|
||||
|
||||
### 7.5 导出朋友圈
|
||||
|
||||
```http
|
||||
POST /api/v1/sns/export
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
Body 示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"outputDir": "C:\\Users\\Alice\\Desktop\\sns-export",
|
||||
"format": "json",
|
||||
"usernames": "wxid_a,wxid_b",
|
||||
"keyword": "旅行",
|
||||
"exportMedia": true,
|
||||
"exportImages": true,
|
||||
"exportLivePhotos": true,
|
||||
"exportVideos": true,
|
||||
"start": "20250101",
|
||||
"end": "20251231"
|
||||
}
|
||||
```
|
||||
|
||||
`format` 支持:`json`、`html`、`arkmejson`(兼容写法:`arkme-json`)。
|
||||
|
||||
### 7.6 朋友圈防删开关
|
||||
|
||||
```http
|
||||
GET /api/v1/sns/block-delete/status
|
||||
POST /api/v1/sns/block-delete/install
|
||||
POST /api/v1/sns/block-delete/uninstall
|
||||
```
|
||||
|
||||
### 7.7 删除单条朋友圈
|
||||
|
||||
```http
|
||||
DELETE /api/v1/sns/post/{postId}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 访问导出媒体
|
||||
|
||||
> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json)
|
||||
|
||||
通过消息接口启用 `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"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 使用示例
|
||||
|
||||
### PowerShell
|
||||
|
||||
```powershell
|
||||
# 健康检查
|
||||
Invoke-RestMethod http://127.0.0.1:5031/health
|
||||
$headers = @{ "Authorization" = "Bearer YOUR_TOKEN" }
|
||||
$body = @{ talker = "wxid_xxx"; limit = 10 } | ConvertTo-Json
|
||||
|
||||
# 获取会话列表
|
||||
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 -Uri "http://127.0.0.1:5031/api/v1/messages" -Method POST -Headers $headers -Body $body -ContentType "application/json"
|
||||
```
|
||||
|
||||
### cURL
|
||||
|
||||
```bash
|
||||
# 健康检查
|
||||
curl http://127.0.0.1:5031/health
|
||||
# GET 带 Token Header
|
||||
curl -H "Authorization: Bearer YOUR_TOKEN" "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx"
|
||||
|
||||
# 获取会话列表
|
||||
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"
|
||||
# POST 带 JSON Body
|
||||
curl -X POST http://127.0.0.1:5031/api/v1/messages \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"talker": "xxx@chatroom", "chatlab": true}'
|
||||
```
|
||||
|
||||
### Python
|
||||
@@ -352,40 +622,29 @@ curl "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1"
|
||||
import requests
|
||||
|
||||
BASE_URL = "http://127.0.0.1:5031"
|
||||
headers = {"Authorization": "Bearer YOUR_TOKEN", "Content-Type": "application/json"}
|
||||
|
||||
# 获取会话列表
|
||||
sessions = requests.get(f"{BASE_URL}/api/v1/sessions").json()
|
||||
print(sessions)
|
||||
# POST 方式获取消息
|
||||
messages = requests.post(
|
||||
f"{BASE_URL}/api/v1/messages",
|
||||
json={"talker": "xxx@chatroom", "limit": 50},
|
||||
headers=headers
|
||||
).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);
|
||||
# GET 方式获取群成员
|
||||
members = requests.get(
|
||||
f"{BASE_URL}/api/v1/group-members",
|
||||
params={"chatroomId": "xxx@chatroom", "includeMessageCounts": 1},
|
||||
headers=headers
|
||||
).json()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
## 10. 注意事项
|
||||
|
||||
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` 导出后才可访问。
|
||||
|
||||
64
electron/exportWorker.ts
Normal file
64
electron/exportWorker.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { parentPort, workerData } from 'worker_threads'
|
||||
import type { ExportOptions } from './services/exportService'
|
||||
|
||||
interface ExportWorkerConfig {
|
||||
sessionIds: string[]
|
||||
outputDir: string
|
||||
options: ExportOptions
|
||||
dbPath?: string
|
||||
decryptKey?: string
|
||||
myWxid?: string
|
||||
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)
|
||||
exportService.setRuntimeConfig({
|
||||
dbPath: config.dbPath,
|
||||
decryptKey: config.decryptKey,
|
||||
myWxid: config.myWxid
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -20,7 +20,7 @@ function looksLikeMd5(value: string): boolean {
|
||||
|
||||
function stripDatVariantSuffix(base: string): string {
|
||||
const lower = base.toLowerCase()
|
||||
const suffixes = ['_thumb', '.thumb', '_hd', '.hd', '_h', '.h', '_t', '.t', '_c', '.c']
|
||||
const suffixes = ['_thumb', '.thumb', '_hd', '.hd', '_h', '.h', '_b', '.b', '_w', '.w', '_t', '.t', '_c', '.c']
|
||||
for (const suffix of suffixes) {
|
||||
if (lower.endsWith(suffix)) {
|
||||
return lower.slice(0, -suffix.length)
|
||||
@@ -71,8 +71,10 @@ function scoreDatName(fileName: string): number {
|
||||
const lower = fileName.toLowerCase()
|
||||
const baseLower = lower.endsWith('.dat') ? lower.slice(0, -4) : lower
|
||||
if (baseLower.endsWith('_h') || baseLower.endsWith('.h')) return 600
|
||||
if (baseLower.endsWith('_hd') || baseLower.endsWith('.hd')) return 550
|
||||
if (baseLower.endsWith('_b') || baseLower.endsWith('.b')) return 520
|
||||
if (baseLower.endsWith('_w') || baseLower.endsWith('.w')) return 510
|
||||
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
|
||||
|
||||
1226
electron/main.ts
1226
electron/main.ts
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@ import { join, dirname } from 'path'
|
||||
|
||||
/**
|
||||
* 强制将本地资源目录添加到 PATH 最前端,确保优先加载本地 DLL
|
||||
* 解决系统中存在冲突版本的 DLL 导致的应用崩溃问题
|
||||
* 解决系统中存在冲突版本的数据服务导致的应用崩溃问题
|
||||
*/
|
||||
function enforceLocalDllPriority() {
|
||||
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||
@@ -35,5 +35,5 @@ function enforceLocalDllPriority() {
|
||||
try {
|
||||
enforceLocalDllPriority()
|
||||
} catch (e) {
|
||||
console.error('[WeFlow] Failed to enforce local DLL priority:', e)
|
||||
console.error('[WeFlow] Failed to enforce local service priority:', e)
|
||||
}
|
||||
|
||||
@@ -53,6 +53,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
app: {
|
||||
getDownloadsPath: () => ipcRenderer.invoke('app:getDownloadsPath'),
|
||||
getVersion: () => ipcRenderer.invoke('app:getVersion'),
|
||||
getLaunchAtStartupStatus: () => ipcRenderer.invoke('app:getLaunchAtStartupStatus'),
|
||||
setLaunchAtStartup: (enabled: boolean) => ipcRenderer.invoke('app:setLaunchAtStartup', enabled),
|
||||
checkForUpdates: () => ipcRenderer.invoke('app:checkForUpdates'),
|
||||
downloadAndInstall: () => ipcRenderer.invoke('app:downloadAndInstall'),
|
||||
ignoreUpdate: (version: string) => ipcRenderer.invoke('app:ignoreUpdate', version),
|
||||
@@ -63,7 +65,8 @@ 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'),
|
||||
},
|
||||
|
||||
// 日志
|
||||
@@ -94,6 +97,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
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'),
|
||||
@@ -106,6 +116,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
ipcRenderer.invoke('window:openImageViewerWindow', imagePath, liveVideoPath),
|
||||
openChatHistoryWindow: (sessionId: string, messageId: number) =>
|
||||
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?: {
|
||||
@@ -176,6 +190,12 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
ipcRenderer.invoke('chat:updateMessage', sessionId, localId, createTime, newContent),
|
||||
deleteMessage: (sessionId: string, localId: number, createTime: number, dbPathHint?: string) =>
|
||||
ipcRenderer.invoke('chat:deleteMessage', sessionId, localId, createTime, dbPathHint),
|
||||
checkAntiRevokeTriggers: (sessionIds: string[]) =>
|
||||
ipcRenderer.invoke('chat:checkAntiRevokeTriggers', sessionIds),
|
||||
installAntiRevokeTriggers: (sessionIds: string[]) =>
|
||||
ipcRenderer.invoke('chat:installAntiRevokeTriggers', sessionIds),
|
||||
uninstallAntiRevokeTriggers: (sessionIds: string[]) =>
|
||||
ipcRenderer.invoke('chat:uninstallAntiRevokeTriggers', sessionIds),
|
||||
resolveTransferDisplayNames: (chatroomId: string, payerUsername: string, receiverUsername: string) =>
|
||||
ipcRenderer.invoke('chat:resolveTransferDisplayNames', chatroomId, payerUsername, receiverUsername),
|
||||
getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'),
|
||||
@@ -206,18 +226,34 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
getAllImageMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllImageMessages', sessionId),
|
||||
getMessageDates: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDates', sessionId),
|
||||
getMessageDateCounts: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDateCounts', sessionId),
|
||||
getResourceMessages: (options?: {
|
||||
sessionId?: string
|
||||
types?: Array<'image' | 'video' | 'voice' | 'file'>
|
||||
beginTimestamp?: number
|
||||
endTimestamp?: number
|
||||
limit?: number
|
||||
offset?: number
|
||||
}) => ipcRenderer.invoke('chat:getResourceMessages', options),
|
||||
getMediaStream: (options?: {
|
||||
sessionId?: string
|
||||
mediaType?: 'image' | 'video' | 'all'
|
||||
beginTimestamp?: number
|
||||
endTimestamp?: number
|
||||
limit?: number
|
||||
offset?: number
|
||||
}) => ipcRenderer.invoke('chat:getMediaStream', options),
|
||||
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'),
|
||||
getContacts: (options?: { lite?: boolean }) => ipcRenderer.invoke('chat:getContacts', options),
|
||||
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)
|
||||
@@ -230,23 +266,58 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
image: {
|
||||
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) =>
|
||||
ipcRenderer.invoke('image:decrypt', payload),
|
||||
resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }) =>
|
||||
resolveCache: (payload: {
|
||||
sessionId?: string
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
disableUpdateCheck?: boolean
|
||||
allowCacheIndex?: boolean
|
||||
}) =>
|
||||
ipcRenderer.invoke('image:resolveCache', payload),
|
||||
preload: (payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>) =>
|
||||
ipcRenderer.invoke('image:preload', payloads),
|
||||
resolveCacheBatch: (
|
||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
|
||||
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean }
|
||||
) => ipcRenderer.invoke('image:resolveCacheBatch', payloads, options),
|
||||
preload: (
|
||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
|
||||
options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean }
|
||||
) => ipcRenderer.invoke('image:preload', payloads, options),
|
||||
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)
|
||||
},
|
||||
onDecryptProgress: (callback: (payload: {
|
||||
cacheKey: string
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
stage: 'queued' | 'locating' | 'decrypting' | 'writing' | 'done' | 'failed'
|
||||
progress: number
|
||||
status: 'running' | 'done' | 'error'
|
||||
message?: string
|
||||
}) => void) => {
|
||||
const listener = (_: unknown, payload: {
|
||||
cacheKey: string
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
stage: 'queued' | 'locating' | 'decrypting' | 'writing' | 'done' | 'failed'
|
||||
progress: number
|
||||
status: 'running' | 'done' | 'error'
|
||||
message?: string
|
||||
}) => callback(payload)
|
||||
ipcRenderer.on('image:decryptProgress', listener)
|
||||
return () => ipcRenderer.removeListener('image:decryptProgress', listener)
|
||||
}
|
||||
},
|
||||
|
||||
// 视频
|
||||
video: {
|
||||
getVideoInfo: (videoMd5: string) => ipcRenderer.invoke('video:getVideoInfo', videoMd5),
|
||||
getVideoInfo: (videoMd5: string, options?: { includePoster?: boolean; posterFormat?: 'dataUrl' | 'fileUrl' }) => ipcRenderer.invoke('video:getVideoInfo', videoMd5, options),
|
||||
parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content)
|
||||
},
|
||||
|
||||
@@ -283,6 +354,12 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
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),
|
||||
getGroupMemberAnalytics: (chatroomId: string, memberUsername: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMemberAnalytics', chatroomId, memberUsername, startTime, endTime),
|
||||
getGroupMemberMessages: (
|
||||
chatroomId: string,
|
||||
memberUsername: string,
|
||||
options?: { startTime?: number; endTime?: number; limit?: number; cursor?: number }
|
||||
) => ipcRenderer.invoke('groupAnalytics:getGroupMemberMessages', chatroomId, memberUsername, options),
|
||||
exportGroupMembers: (chatroomId: string, outputPath: string) => ipcRenderer.invoke('groupAnalytics:exportGroupMembers', chatroomId, outputPath),
|
||||
exportGroupMemberMessages: (chatroomId: string, memberUsername: string, outputPath: string, startTime?: number, endTime?: number) =>
|
||||
ipcRenderer.invoke('groupAnalytics:exportGroupMemberMessages', chatroomId, memberUsername, outputPath, startTime, endTime)
|
||||
@@ -338,7 +415,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; currentSessionId?: 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')
|
||||
}
|
||||
@@ -377,7 +467,22 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
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)
|
||||
downloadEmoji: (params: { url: string; encryptUrl?: string; aesKey?: string }) => ipcRenderer.invoke('sns:downloadEmoji', params),
|
||||
getCacheMigrationStatus: () => ipcRenderer.invoke('sns:getCacheMigrationStatus'),
|
||||
startCacheMigration: () => ipcRenderer.invoke('sns:startCacheMigration'),
|
||||
onCacheMigrationProgress: (callback: (payload: any) => void) => {
|
||||
const listener = (_event: unknown, payload: any) => callback(payload)
|
||||
ipcRenderer.on('sns:cacheMigrationProgress', listener)
|
||||
return () => ipcRenderer.removeListener('sns:cacheMigrationProgress', listener)
|
||||
}
|
||||
},
|
||||
|
||||
biz: {
|
||||
listAccounts: (account?: string) => ipcRenderer.invoke('biz:listAccounts', account),
|
||||
listMessages: (username: string, account?: string, limit?: number, offset?: number) =>
|
||||
ipcRenderer.invoke('biz:listMessages', username, account, limit, offset),
|
||||
listPayRecords: (account?: string, limit?: number, offset?: number) =>
|
||||
ipcRenderer.invoke('biz:listPayRecords', account, limit, offset)
|
||||
},
|
||||
|
||||
|
||||
@@ -390,8 +495,15 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
|
||||
// HTTP API 服务
|
||||
http: {
|
||||
start: (port?: number) => ipcRenderer.invoke('http:start', port),
|
||||
start: (port?: number, host?: string) => ipcRenderer.invoke('http:start', port, host),
|
||||
stop: () => ipcRenderer.invoke('http:stop'),
|
||||
status: () => ipcRenderer.invoke('http:status')
|
||||
},
|
||||
|
||||
// AI 见解
|
||||
insight: {
|
||||
testConnection: () => ipcRenderer.invoke('insight:testConnection'),
|
||||
getTodayStats: () => ipcRenderer.invoke('insight:getTodayStats'),
|
||||
triggerTest: () => ipcRenderer.invoke('insight:triggerTest')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -68,29 +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
|
||||
|
||||
// C++ 层不支持参数绑定,直接内联转义后的字符串值
|
||||
const chunkSize = 200
|
||||
for (let i = 0; i < usernames.length; i += chunkSize) {
|
||||
const chunk = usernames.slice(i, i + chunkSize)
|
||||
const inList = chunk.map((u) => `'${this.escapeSqlValue(u)}'`).join(',')
|
||||
const sql = `SELECT username, alias FROM contact WHERE username IN (${inList})`
|
||||
const result = await wcdbService.execQuery('contact', null, sql)
|
||||
if (!result.success || !result.rows) continue
|
||||
for (const row of result.rows as Record<string, any>[]) {
|
||||
const username = row.username || ''
|
||||
const alias = row.alias || ''
|
||||
if (username && alias) {
|
||||
map[username] = alias
|
||||
}
|
||||
}
|
||||
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
|
||||
|
||||
@@ -278,16 +278,16 @@ class AnnualReportService {
|
||||
return cached || null
|
||||
}
|
||||
|
||||
const result = await wcdbService.execQuery('message', dbPath, `PRAGMA table_info(${this.quoteSqlIdentifier(tableName)})`)
|
||||
if (!result.success || !Array.isArray(result.rows) || result.rows.length === 0) {
|
||||
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 row of result.rows as Record<string, any>[]) {
|
||||
const name = String(row.name || row.column_name || row.columnName || '').trim().toLowerCase()
|
||||
for (const columnName of result.columns) {
|
||||
const name = String(columnName || '').trim().toLowerCase()
|
||||
if (name) columns.add(name)
|
||||
}
|
||||
|
||||
@@ -309,10 +309,11 @@ class AnnualReportService {
|
||||
const tried = new Set<string>()
|
||||
|
||||
const queryByColumn = async (column: string): Promise<{ first: number; last: number } | null> => {
|
||||
const sql = `SELECT MIN(${this.quoteSqlIdentifier(column)}) AS first_ts, MAX(${this.quoteSqlIdentifier(column)}) AS last_ts FROM ${this.quoteSqlIdentifier(tableName)}`
|
||||
const result = await wcdbService.execQuery('message', dbPath, sql)
|
||||
if (!result.success || !Array.isArray(result.rows) || result.rows.length === 0) return null
|
||||
const row = result.rows[0] as Record<string, any>
|
||||
const 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 }
|
||||
@@ -1134,7 +1135,7 @@ class AnnualReportService {
|
||||
|
||||
const now = Date.now()
|
||||
if (now - lastProgressAt > 200) {
|
||||
let progress = 30
|
||||
let progress: number
|
||||
if (totalMessagesForProgress > 0) {
|
||||
const ratio = Math.min(1, processedMessages / totalMessagesForProgress)
|
||||
progress = 30 + Math.floor(ratio * 50)
|
||||
|
||||
243
electron/services/bizService.ts
Normal file
243
electron/services/bizService.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { join } from 'path'
|
||||
import { readdirSync, existsSync } from 'fs'
|
||||
import { wcdbService } from './wcdbService'
|
||||
import { ConfigService } from './config'
|
||||
import { chatService, Message } from './chatService'
|
||||
import { ipcMain } from 'electron'
|
||||
import { createHash } from 'crypto'
|
||||
|
||||
export interface BizAccount {
|
||||
username: string
|
||||
name: string
|
||||
avatar: string
|
||||
type: number
|
||||
last_time: number
|
||||
formatted_last_time: string
|
||||
}
|
||||
|
||||
export interface BizMessage {
|
||||
local_id: number
|
||||
create_time: number
|
||||
title: string
|
||||
des: string
|
||||
url: string
|
||||
cover: string
|
||||
content_list: any[]
|
||||
}
|
||||
|
||||
export interface BizPayRecord {
|
||||
local_id: number
|
||||
create_time: number
|
||||
title: string
|
||||
description: string
|
||||
merchant_name: string
|
||||
merchant_icon: string
|
||||
timestamp: number
|
||||
formatted_time: string
|
||||
}
|
||||
|
||||
export class BizService {
|
||||
private configService: ConfigService
|
||||
|
||||
constructor() {
|
||||
this.configService = new ConfigService()
|
||||
}
|
||||
|
||||
private extractXmlValue(xml: string, tagName: string): string {
|
||||
const regex = new RegExp(`<${tagName}>([\\s\\S]*?)</${tagName}>`, 'i')
|
||||
const match = regex.exec(xml)
|
||||
if (match) {
|
||||
return match[1].replace(/<!\[CDATA\[/g, '').replace(/\]\]>/g, '').trim()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
private parseBizContentList(xmlStr: string): any[] {
|
||||
if (!xmlStr) return []
|
||||
const contentList: any[] = []
|
||||
try {
|
||||
const itemRegex = /<item>([\s\S]*?)<\/item>/gi
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = itemRegex.exec(xmlStr)) !== null) {
|
||||
const itemXml = match[1]
|
||||
const itemStruct = {
|
||||
title: this.extractXmlValue(itemXml, 'title'),
|
||||
url: this.extractXmlValue(itemXml, 'url'),
|
||||
cover: this.extractXmlValue(itemXml, 'cover') || this.extractXmlValue(itemXml, 'thumburl'),
|
||||
summary: this.extractXmlValue(itemXml, 'summary') || this.extractXmlValue(itemXml, 'digest')
|
||||
}
|
||||
if (itemStruct.title) contentList.push(itemStruct)
|
||||
}
|
||||
} catch (e) { }
|
||||
return contentList
|
||||
}
|
||||
|
||||
private parsePayXml(xmlStr: string): any {
|
||||
if (!xmlStr) return null
|
||||
try {
|
||||
const title = this.extractXmlValue(xmlStr, 'title')
|
||||
const description = this.extractXmlValue(xmlStr, 'des')
|
||||
const merchantName = this.extractXmlValue(xmlStr, 'display_name') || '微信支付'
|
||||
const merchantIcon = this.extractXmlValue(xmlStr, 'icon_url')
|
||||
const pubTime = parseInt(this.extractXmlValue(xmlStr, 'pub_time') || '0')
|
||||
if (!title && !description) return null
|
||||
return { title, description, merchant_name: merchantName, merchant_icon: merchantIcon, timestamp: pubTime }
|
||||
} catch (e) { return null }
|
||||
}
|
||||
|
||||
async listAccounts(account?: string): Promise<BizAccount[]> {
|
||||
try {
|
||||
// 1. 获取公众号联系人列表
|
||||
const contactsResult = await chatService.getContacts({ lite: true })
|
||||
if (!contactsResult.success || !contactsResult.contacts) return []
|
||||
|
||||
const officialContacts = contactsResult.contacts.filter(c => c.type === 'official')
|
||||
const usernames = officialContacts.map(c => c.username)
|
||||
|
||||
// 获取头像和昵称等补充信息
|
||||
const enrichment = await chatService.enrichSessionsContactInfo(usernames)
|
||||
const contactInfoMap = enrichment.success && enrichment.contacts ? enrichment.contacts : {}
|
||||
|
||||
const root = this.configService.get('dbPath')
|
||||
const myWxid = this.configService.get('myWxid')
|
||||
const accountWxid = account || myWxid
|
||||
if (!root || !accountWxid) return []
|
||||
|
||||
const bizLatestTime: Record<string, number> = {}
|
||||
|
||||
try {
|
||||
const sessionsRes = await wcdbService.getSessions()
|
||||
if (sessionsRes.success && sessionsRes.sessions) {
|
||||
for (const session of sessionsRes.sessions) {
|
||||
const uname = session.username || session.strUsrName || session.userName || session.id
|
||||
// 适配日志中发现的字段,注意转为整型数字
|
||||
const timeStr = session.last_timestamp || session.sort_timestamp || session.nTime || session.timestamp || '0'
|
||||
const time = parseInt(timeStr.toString(), 10)
|
||||
|
||||
if (usernames.includes(uname) && time > 0) {
|
||||
bizLatestTime[uname] = time
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取 Sessions 失败:', e)
|
||||
}
|
||||
|
||||
// 3. 格式化时间显示
|
||||
const formatBizTime = (ts: number) => {
|
||||
if (!ts) return ''
|
||||
const date = new Date(ts * 1000)
|
||||
const now = new Date()
|
||||
const isToday = date.toDateString() === now.toDateString()
|
||||
if (isToday) return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||
|
||||
const yesterday = new Date(now)
|
||||
yesterday.setDate(now.getDate() - 1)
|
||||
if (date.toDateString() === yesterday.toDateString()) return '昨天'
|
||||
|
||||
const isThisYear = date.getFullYear() === now.getFullYear()
|
||||
if (isThisYear) return `${date.getMonth() + 1}/${date.getDate()}`
|
||||
|
||||
return `${date.getFullYear().toString().slice(-2)}/${date.getMonth() + 1}/${date.getDate()}`
|
||||
}
|
||||
|
||||
// 4. 组装数据
|
||||
const result: BizAccount[] = officialContacts.map(contact => {
|
||||
const uname = contact.username
|
||||
const info = contactInfoMap[uname]
|
||||
const lastTime = bizLatestTime[uname] || 0
|
||||
return {
|
||||
username: uname,
|
||||
name: info?.displayName || contact.displayName || uname,
|
||||
avatar: info?.avatarUrl || '',
|
||||
type: 0,
|
||||
last_time: lastTime,
|
||||
formatted_last_time: formatBizTime(lastTime)
|
||||
}
|
||||
})
|
||||
|
||||
// 5. 补充公众号类型 (订阅号/服务号)
|
||||
const contactDbPath = join(root, accountWxid, 'db_storage', 'contact', 'contact.db')
|
||||
if (existsSync(contactDbPath)) {
|
||||
const bizInfoRes = await wcdbService.execQuery('contact', contactDbPath, 'SELECT username, type FROM biz_info')
|
||||
if (bizInfoRes.success && bizInfoRes.rows) {
|
||||
const typeMap: Record<string, number> = {}
|
||||
for (const r of bizInfoRes.rows) typeMap[r.username] = r.type
|
||||
for (const acc of result) if (typeMap[acc.username] !== undefined) acc.type = typeMap[acc.username]
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 排序输出
|
||||
return result
|
||||
.filter(acc => !acc.name.includes('广告'))
|
||||
.sort((a, b) => {
|
||||
if (a.username === 'gh_3dfda90e39d6') return -1 // 微信支付置顶
|
||||
if (b.username === 'gh_3dfda90e39d6') return 1
|
||||
return b.last_time - a.last_time // 按最新时间降序排列
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('获取账号列表发生错误:', e)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async listMessages(username: string, account?: string, limit: number = 20, offset: number = 0): Promise<BizMessage[]> {
|
||||
try {
|
||||
// 仅保留核心路径:利用 chatService 的自动路由能力
|
||||
const res = await chatService.getMessages(username, offset, limit)
|
||||
if (!res.success || !res.messages) return []
|
||||
|
||||
return res.messages.map(msg => {
|
||||
const bizMsg: BizMessage = {
|
||||
local_id: msg.localId,
|
||||
create_time: msg.createTime,
|
||||
title: msg.linkTitle || msg.parsedContent || '',
|
||||
des: msg.appMsgDesc || '',
|
||||
url: msg.linkUrl || '',
|
||||
cover: msg.linkThumb || msg.appMsgThumbUrl || '',
|
||||
content_list: []
|
||||
}
|
||||
if (msg.rawContent) {
|
||||
bizMsg.content_list = this.parseBizContentList(msg.rawContent)
|
||||
if (bizMsg.content_list.length > 0 && !bizMsg.title) {
|
||||
bizMsg.title = bizMsg.content_list[0].title
|
||||
bizMsg.cover = bizMsg.cover || bizMsg.content_list[0].cover
|
||||
}
|
||||
}
|
||||
return bizMsg
|
||||
})
|
||||
} catch (e) { return [] }
|
||||
}
|
||||
|
||||
async listPayRecords(account?: string, limit: number = 20, offset: number = 0): Promise<BizPayRecord[]> {
|
||||
const username = 'gh_3dfda90e39d6'
|
||||
try {
|
||||
const res = await chatService.getMessages(username, offset, limit)
|
||||
if (!res.success || !res.messages) return []
|
||||
|
||||
const records: BizPayRecord[] = []
|
||||
for (const msg of res.messages) {
|
||||
if (!msg.rawContent) continue
|
||||
const parsedData = this.parsePayXml(msg.rawContent)
|
||||
if (parsedData) {
|
||||
records.push({
|
||||
local_id: msg.localId,
|
||||
create_time: msg.createTime,
|
||||
...parsedData,
|
||||
timestamp: parsedData.timestamp || msg.createTime,
|
||||
formatted_time: new Date((parsedData.timestamp || msg.createTime) * 1000).toLocaleString()
|
||||
})
|
||||
}
|
||||
}
|
||||
return records
|
||||
} catch (e) { return [] }
|
||||
}
|
||||
|
||||
registerHandlers() {
|
||||
ipcMain.handle('biz:listAccounts', (_, account) => this.listAccounts(account))
|
||||
ipcMain.handle('biz:listMessages', (_, username, account, limit, offset) => this.listMessages(username, account, limit, offset))
|
||||
ipcMain.handle('biz:listPayRecords', (_, account, limit, offset) => this.listPayRecords(account, limit, offset))
|
||||
}
|
||||
}
|
||||
|
||||
export const bizService = new BizService()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,15 +14,32 @@ class CloudControlService {
|
||||
private deviceId: string = ''
|
||||
private timer: NodeJS.Timeout | null = null
|
||||
private pages: Set<string> = new Set()
|
||||
private platformVersionCache: string | null = null
|
||||
private pendingReports: UsageStats[] = []
|
||||
private flushInProgress = false
|
||||
private retryDelayMs = 5_000
|
||||
private consecutiveFailures = 0
|
||||
private circuitOpenedAt = 0
|
||||
private nextDelayOverrideMs: number | null = null
|
||||
private initialized = false
|
||||
|
||||
private static readonly BASE_FLUSH_MS = 300_000
|
||||
private static readonly JITTER_MS = 30_000
|
||||
private static readonly MAX_BUFFER_REPORTS = 200
|
||||
private static readonly MAX_BATCH_REPORTS = 20
|
||||
private static readonly MAX_RETRY_MS = 120_000
|
||||
private static readonly CIRCUIT_FAIL_THRESHOLD = 5
|
||||
private static readonly CIRCUIT_COOLDOWN_MS = 120_000
|
||||
|
||||
async init() {
|
||||
if (this.initialized) return
|
||||
this.initialized = true
|
||||
this.deviceId = this.getDeviceId()
|
||||
await wcdbService.cloudInit(300)
|
||||
await this.reportOnline()
|
||||
|
||||
this.timer = setInterval(() => {
|
||||
this.reportOnline()
|
||||
}, 300000)
|
||||
this.enqueueCurrentReport()
|
||||
await this.flushQueue(true)
|
||||
this.scheduleNextFlush(this.nextDelayOverrideMs ?? undefined)
|
||||
this.nextDelayOverrideMs = null
|
||||
}
|
||||
|
||||
private getDeviceId(): string {
|
||||
@@ -32,8 +49,8 @@ class CloudControlService {
|
||||
return crypto.createHash('md5').update(machineId).digest('hex')
|
||||
}
|
||||
|
||||
private async reportOnline() {
|
||||
const data: UsageStats = {
|
||||
private buildCurrentReport(): UsageStats {
|
||||
return {
|
||||
appVersion: app.getVersion(),
|
||||
platform: this.getPlatformVersion(),
|
||||
deviceId: this.deviceId,
|
||||
@@ -41,13 +58,76 @@ class CloudControlService {
|
||||
online: true,
|
||||
pages: Array.from(this.pages)
|
||||
}
|
||||
}
|
||||
|
||||
await wcdbService.cloudReport(JSON.stringify(data))
|
||||
private enqueueCurrentReport() {
|
||||
const report = this.buildCurrentReport()
|
||||
this.pendingReports.push(report)
|
||||
if (this.pendingReports.length > CloudControlService.MAX_BUFFER_REPORTS) {
|
||||
this.pendingReports.splice(0, this.pendingReports.length - CloudControlService.MAX_BUFFER_REPORTS)
|
||||
}
|
||||
this.pages.clear()
|
||||
}
|
||||
|
||||
private isCircuitOpen(nowMs: number): boolean {
|
||||
if (this.circuitOpenedAt <= 0) return false
|
||||
return nowMs-this.circuitOpenedAt < CloudControlService.CIRCUIT_COOLDOWN_MS
|
||||
}
|
||||
|
||||
private scheduleNextFlush(delayMs?: number) {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
const jitter = Math.floor(Math.random() * CloudControlService.JITTER_MS)
|
||||
const nextDelay = Math.max(1_000, Number(delayMs) > 0 ? Number(delayMs) : CloudControlService.BASE_FLUSH_MS + jitter)
|
||||
this.timer = setTimeout(() => {
|
||||
this.enqueueCurrentReport()
|
||||
this.flushQueue(false).finally(() => {
|
||||
this.scheduleNextFlush(this.nextDelayOverrideMs ?? undefined)
|
||||
this.nextDelayOverrideMs = null
|
||||
})
|
||||
}, nextDelay)
|
||||
}
|
||||
|
||||
private async flushQueue(force: boolean) {
|
||||
if (this.flushInProgress) return
|
||||
if (this.pendingReports.length === 0) return
|
||||
const now = Date.now()
|
||||
if (!force && this.isCircuitOpen(now)) {
|
||||
return
|
||||
}
|
||||
this.flushInProgress = true
|
||||
try {
|
||||
while (this.pendingReports.length > 0) {
|
||||
const batch = this.pendingReports.slice(0, CloudControlService.MAX_BATCH_REPORTS)
|
||||
const result = await wcdbService.cloudReport(JSON.stringify(batch))
|
||||
if (!result || result.success !== true) {
|
||||
this.consecutiveFailures += 1
|
||||
this.retryDelayMs = Math.min(CloudControlService.MAX_RETRY_MS, this.retryDelayMs * 2)
|
||||
if (this.consecutiveFailures >= CloudControlService.CIRCUIT_FAIL_THRESHOLD) {
|
||||
this.circuitOpenedAt = Date.now()
|
||||
}
|
||||
this.nextDelayOverrideMs = this.retryDelayMs
|
||||
return
|
||||
}
|
||||
this.pendingReports.splice(0, batch.length)
|
||||
this.consecutiveFailures = 0
|
||||
this.retryDelayMs = 5_000
|
||||
this.circuitOpenedAt = 0
|
||||
}
|
||||
} finally {
|
||||
this.flushInProgress = false
|
||||
}
|
||||
}
|
||||
|
||||
private getPlatformVersion(): string {
|
||||
if (this.platformVersionCache) {
|
||||
return this.platformVersionCache
|
||||
}
|
||||
|
||||
const os = require('os')
|
||||
const fs = require('fs')
|
||||
const platform = process.platform
|
||||
|
||||
if (platform === 'win32') {
|
||||
@@ -59,21 +139,79 @@ class CloudControlService {
|
||||
|
||||
// Windows 11 是 10.0.22000+,且主版本必须是 10.0
|
||||
if (major === 10 && minor === 0 && build >= 22000) {
|
||||
return 'Windows 11'
|
||||
this.platformVersionCache = 'Windows 11'
|
||||
return this.platformVersionCache
|
||||
} else if (major === 10) {
|
||||
return 'Windows 10'
|
||||
this.platformVersionCache = 'Windows 10'
|
||||
return this.platformVersionCache
|
||||
}
|
||||
return `Windows ${release}`
|
||||
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()
|
||||
return `macOS ${macVersion}`
|
||||
this.platformVersionCache = `macOS ${macVersion}`
|
||||
return this.platformVersionCache
|
||||
}
|
||||
|
||||
return platform
|
||||
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) {
|
||||
@@ -82,9 +220,16 @@ class CloudControlService {
|
||||
|
||||
stop() {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer)
|
||||
clearTimeout(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
this.pendingReports = []
|
||||
this.flushInProgress = false
|
||||
this.retryDelayMs = 5_000
|
||||
this.consecutiveFailures = 0
|
||||
this.circuitOpenedAt = 0
|
||||
this.nextDelayOverrideMs = null
|
||||
this.initialized = false
|
||||
wcdbService.cloudStop()
|
||||
}
|
||||
|
||||
@@ -94,4 +239,3 @@ class CloudControlService {
|
||||
}
|
||||
|
||||
export const cloudControlService = new CloudControlService()
|
||||
|
||||
|
||||
@@ -5,6 +5,13 @@ import Store from 'electron-store'
|
||||
|
||||
// 加密前缀标记
|
||||
const SAFE_PREFIX = 'safe:' // safeStorage 加密(普通模式)
|
||||
const isSafeStorageAvailable = (): boolean => {
|
||||
try {
|
||||
return typeof safeStorage?.isEncryptionAvailable === 'function' && safeStorage.isEncryptionAvailable()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const LOCK_PREFIX = 'lock:' // 密码派生密钥加密(锁定模式)
|
||||
|
||||
interface ConfigSchema {
|
||||
@@ -16,7 +23,7 @@ interface ConfigSchema {
|
||||
imageXorKey: number
|
||||
imageAesKey: string
|
||||
wxidConfigs: Record<string, { decryptKey?: string; imageXorKey?: number; imageAesKey?: string; updatedAt?: number }>
|
||||
|
||||
exportPath?: string;
|
||||
// 缓存相关
|
||||
cachePath: string
|
||||
lastOpenedDb: string
|
||||
@@ -27,6 +34,7 @@ interface ConfigSchema {
|
||||
themeId: string
|
||||
language: string
|
||||
logEnabled: boolean
|
||||
launchAtStartup?: boolean
|
||||
llmModelPath: string
|
||||
whisperModelName: string
|
||||
whisperModelDir: string
|
||||
@@ -34,6 +42,7 @@ interface ConfigSchema {
|
||||
autoTranscribeVoice: boolean
|
||||
transcribeLanguages: string[]
|
||||
exportDefaultConcurrency: number
|
||||
exportDefaultImageDeepSearchOnMiss: boolean
|
||||
analyticsExcludedUsernames: string[]
|
||||
|
||||
// 安全相关
|
||||
@@ -44,17 +53,50 @@ interface ConfigSchema {
|
||||
|
||||
// 更新相关
|
||||
ignoredUpdateVersion: string
|
||||
updateChannel: 'auto' | 'stable' | 'preview' | 'dev'
|
||||
|
||||
// 通知
|
||||
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
|
||||
httpApiEnabled: boolean
|
||||
httpApiPort: number
|
||||
httpApiHost: string
|
||||
httpApiToken: string
|
||||
windowCloseBehavior: 'ask' | 'tray' | 'quit'
|
||||
quoteLayout: 'quote-top' | 'quote-bottom'
|
||||
wordCloudExcludeWords: string[]
|
||||
exportWriteLayout: 'A' | 'B' | 'C'
|
||||
|
||||
// AI 见解
|
||||
aiInsightEnabled: boolean
|
||||
aiInsightApiBaseUrl: string
|
||||
aiInsightApiKey: string
|
||||
aiInsightApiModel: string
|
||||
aiInsightSilenceDays: number
|
||||
aiInsightAllowContext: boolean
|
||||
aiInsightWhitelistEnabled: boolean
|
||||
aiInsightWhitelist: string[]
|
||||
/** 活跃分析冷却时间(分钟),0 表示无冷却 */
|
||||
aiInsightCooldownMinutes: number
|
||||
/** 沉默联系人扫描间隔(小时) */
|
||||
aiInsightScanIntervalHours: number
|
||||
/** 发送上下文时的最大消息条数 */
|
||||
aiInsightContextCount: number
|
||||
/** 自定义 system prompt,空字符串表示使用内置默认值 */
|
||||
aiInsightSystemPrompt: string
|
||||
/** 是否启用 Telegram 推送 */
|
||||
aiInsightTelegramEnabled: boolean
|
||||
/** Telegram Bot Token */
|
||||
aiInsightTelegramToken: string
|
||||
/** Telegram 接收 Chat ID,逗号分隔,支持多个 */
|
||||
aiInsightTelegramChatIds: string
|
||||
}
|
||||
|
||||
// 需要 safeStorage 加密的字段(普通模式)
|
||||
const ENCRYPTED_STRING_KEYS: Set<string> = new Set(['decryptKey', 'imageAesKey', 'authPassword'])
|
||||
const ENCRYPTED_STRING_KEYS: Set<string> = new Set(['decryptKey', 'imageAesKey', 'authPassword', 'httpApiToken', 'aiInsightApiKey'])
|
||||
const ENCRYPTED_BOOL_KEYS: Set<string> = new Set(['authEnabled', 'authUseHello'])
|
||||
const ENCRYPTED_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
|
||||
|
||||
@@ -82,43 +124,94 @@ 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: '',
|
||||
updateChannel: 'auto',
|
||||
notificationEnabled: true,
|
||||
notificationPosition: 'top-right',
|
||||
notificationFilterMode: 'all',
|
||||
notificationFilterList: [],
|
||||
httpApiToken: '',
|
||||
httpApiEnabled: false,
|
||||
httpApiPort: 5031,
|
||||
httpApiHost: '127.0.0.1',
|
||||
messagePushEnabled: false,
|
||||
windowCloseBehavior: 'ask',
|
||||
quoteLayout: 'quote-top',
|
||||
wordCloudExcludeWords: [],
|
||||
exportWriteLayout: 'A',
|
||||
aiInsightEnabled: false,
|
||||
aiInsightApiBaseUrl: '',
|
||||
aiInsightApiKey: '',
|
||||
aiInsightApiModel: 'gpt-4o-mini',
|
||||
aiInsightSilenceDays: 3,
|
||||
aiInsightAllowContext: false,
|
||||
aiInsightWhitelistEnabled: false,
|
||||
aiInsightWhitelist: [],
|
||||
aiInsightCooldownMinutes: 120,
|
||||
aiInsightScanIntervalHours: 4,
|
||||
aiInsightContextCount: 40,
|
||||
aiInsightSystemPrompt: '',
|
||||
aiInsightTelegramEnabled: false,
|
||||
aiInsightTelegramToken: '',
|
||||
aiInsightTelegramChatIds: ''
|
||||
}
|
||||
|
||||
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: 4,
|
||||
analyticsExcludedUsernames: [],
|
||||
authEnabled: false,
|
||||
authPassword: '',
|
||||
authUseHello: false,
|
||||
authHelloSecret: '',
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -210,7 +303,7 @@ export class ConfigService {
|
||||
private safeEncrypt(plaintext: string): string {
|
||||
if (!plaintext) return ''
|
||||
if (plaintext.startsWith(SAFE_PREFIX)) return plaintext
|
||||
if (!safeStorage.isEncryptionAvailable()) return plaintext
|
||||
if (!isSafeStorageAvailable()) return plaintext
|
||||
const encrypted = safeStorage.encryptString(plaintext)
|
||||
return SAFE_PREFIX + encrypted.toString('base64')
|
||||
}
|
||||
@@ -218,7 +311,7 @@ export class ConfigService {
|
||||
private safeDecrypt(stored: string): string {
|
||||
if (!stored) return ''
|
||||
if (!stored.startsWith(SAFE_PREFIX)) return stored
|
||||
if (!safeStorage.isEncryptionAvailable()) return ''
|
||||
if (!isSafeStorageAvailable()) return ''
|
||||
try {
|
||||
const buf = Buffer.from(stored.slice(SAFE_PREFIX.length), 'base64')
|
||||
return safeStorage.decryptString(buf)
|
||||
@@ -628,17 +721,15 @@ export class ConfigService {
|
||||
|
||||
// 即使 authEnabled 被删除/篡改,如果密钥是 lock: 格式,说明曾开启过应用锁
|
||||
const rawDecryptKey: any = this.store.get('decryptKey')
|
||||
if (typeof rawDecryptKey === 'string' && rawDecryptKey.startsWith(LOCK_PREFIX)) {
|
||||
return true
|
||||
}
|
||||
return typeof rawDecryptKey === 'string' && rawDecryptKey.startsWith(LOCK_PREFIX);
|
||||
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// === 工具方法 ===
|
||||
|
||||
/**
|
||||
* 获取当前 wxid 对应的图片密钥,优先从 wxidConfigs 中取,找不到则回退到全局配置
|
||||
* 获取当前 wxid 对应的图片密钥,优先从 wxidConfigs 中取,找不到则回退到全局<EFBFBD><EFBFBD>置
|
||||
*/
|
||||
getImageKeysForCurrentWxid(): { xorKey: unknown; aesKey: string } {
|
||||
const wxid = this.get('myWxid')
|
||||
@@ -658,8 +749,16 @@ export class ConfigService {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
return join(app.getPath('userData'), 'cache')
|
||||
return join(this.getUserDataPath(), 'cache')
|
||||
}
|
||||
|
||||
getAll(): Partial<ConfigSchema> {
|
||||
|
||||
@@ -93,6 +93,9 @@ class ContactExportService {
|
||||
displayName: c.displayName,
|
||||
remark: c.remark,
|
||||
nickname: c.nickname,
|
||||
alias: c.alias,
|
||||
labels: Array.isArray(c.labels) ? c.labels : [],
|
||||
detailDescription: c.detailDescription,
|
||||
type: c.type
|
||||
}))
|
||||
}
|
||||
@@ -103,12 +106,15 @@ class ContactExportService {
|
||||
* 导出为CSV格式
|
||||
*/
|
||||
private async exportToCSV(contacts: any[], outputPath: string): Promise<void> {
|
||||
const headers = ['用户名', '显示名称', '备注', '昵称', '类型']
|
||||
const headers = ['用户名', '显示名称', '备注', '昵称', '微信号', '标签', '详细描述', '类型']
|
||||
const rows = contacts.map(c => [
|
||||
c.username || '',
|
||||
c.displayName || '',
|
||||
c.remark || '',
|
||||
c.nickname || '',
|
||||
c.alias || '',
|
||||
Array.isArray(c.labels) ? c.labels.join(' | ') : '',
|
||||
c.detailDescription || '',
|
||||
this.getTypeLabel(c.type)
|
||||
])
|
||||
|
||||
@@ -137,9 +143,13 @@ class ContactExportService {
|
||||
lines.push(`NICKNAME:${c.nickname}`)
|
||||
}
|
||||
|
||||
// 备注
|
||||
if (c.remark) {
|
||||
lines.push(`NOTE:${c.remark}`)
|
||||
const noteParts = [
|
||||
c.remark ? String(c.remark) : '',
|
||||
Array.isArray(c.labels) && c.labels.length > 0 ? `标签: ${c.labels.join(', ')}` : '',
|
||||
c.detailDescription ? `详细描述: ${c.detailDescription}` : ''
|
||||
].filter(Boolean)
|
||||
if (noteParts.length > 0) {
|
||||
lines.push(`NOTE:${noteParts.join('\\n')}`)
|
||||
}
|
||||
|
||||
// 微信ID
|
||||
|
||||
9440
electron/services/contactRegionLookupData.ts
Normal file
9440
electron/services/contactRegionLookupData.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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,27 +93,39 @@ export class DbPathService {
|
||||
const possiblePaths: string[] = []
|
||||
const home = homedir()
|
||||
|
||||
// macOS 微信路径(固定)
|
||||
if (process.platform === 'darwin') {
|
||||
// macOS 微信 4.0.5+ 新路径(优先检测)
|
||||
const appSupportBase = join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Library', 'Application Support', 'com.tencent.xinWeChat')
|
||||
if (existsSync(appSupportBase)) {
|
||||
try {
|
||||
const entries = readdirSync(appSupportBase)
|
||||
for (const entry of entries) {
|
||||
// 匹配形如 2.0b4.0.9 的版本目录
|
||||
if (/^\d+\.\d+b\d+\.\d+/.test(entry) || /^\d+\.\d+\.\d+/.test(entry)) {
|
||||
possiblePaths.push(join(appSupportBase, entry))
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
// macOS 旧路径兜底
|
||||
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) {
|
||||
if (existsSync(path)) {
|
||||
const rootName = path.split(/[/\\]/).pop()?.toLowerCase()
|
||||
if (rootName !== 'xwechat_files' && rootName !== 'wechat files') {
|
||||
continue
|
||||
}
|
||||
if (!existsSync(path)) continue
|
||||
|
||||
// 检查是否有有效的账号目录
|
||||
const accounts = this.findAccountDirs(path)
|
||||
if (accounts.length > 0) {
|
||||
return { success: true, path }
|
||||
}
|
||||
// 检查是否有有效的账号目录,或本身就是账号目录
|
||||
const accounts = this.findAccountDirs(path)
|
||||
if (accounts.length > 0) {
|
||||
return { success: true, path }
|
||||
}
|
||||
|
||||
// 如果该目录本身就是账号目录(直接包含 db_storage 等)
|
||||
if (this.isAccountDir(path)) {
|
||||
return { success: true, path }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,21 +224,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') {
|
||||
@@ -159,12 +243,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 列表
|
||||
*/
|
||||
@@ -187,10 +284,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -199,6 +307,20 @@ export class DbPathService {
|
||||
getDefaultPath(): string {
|
||||
const home = homedir()
|
||||
if (process.platform === 'darwin') {
|
||||
// 优先返回 4.0.5+ 新路径
|
||||
const appSupportBase = join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Library', 'Application Support', 'com.tencent.xinWeChat')
|
||||
if (existsSync(appSupportBase)) {
|
||||
try {
|
||||
const entries = readdirSync(appSupportBase)
|
||||
for (const entry of entries) {
|
||||
if (/^\d+\.\d+b\d+\.\d+/.test(entry) || /^\d+\.\d+\.\d+/.test(entry)) {
|
||||
const candidate = join(appSupportBase, entry)
|
||||
if (existsSync(candidate)) return candidate
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
// 旧版本路径兜底
|
||||
return join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'xwechat_files')
|
||||
}
|
||||
return join(home, 'Documents', 'xwechat_files')
|
||||
|
||||
@@ -186,6 +186,33 @@ 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;
|
||||
|
||||
@@ -186,6 +186,33 @@ 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;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ import { ConfigService } from './config'
|
||||
import { wcdbService } from './wcdbService'
|
||||
import { chatService } from './chatService'
|
||||
import type { Message } from './chatService'
|
||||
import type { ChatStatistics } from './analyticsService'
|
||||
|
||||
export interface GroupChatInfo {
|
||||
username: string
|
||||
@@ -49,6 +50,19 @@ export interface GroupMediaStats {
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface GroupMemberAnalytics {
|
||||
statistics: ChatStatistics
|
||||
timeDistribution: Record<number, number>
|
||||
commonPhrases?: Array<{ phrase: string; count: number }>
|
||||
commonEmojis?: Array<{ emoji: string; count: number }>
|
||||
}
|
||||
|
||||
export interface GroupMemberMessagesPage {
|
||||
messages: Message[]
|
||||
hasMore: boolean
|
||||
nextCursor: number
|
||||
}
|
||||
|
||||
interface GroupMemberContactInfo {
|
||||
remark: string
|
||||
nickName: string
|
||||
@@ -224,10 +238,9 @@ class GroupAnalyticsService {
|
||||
}
|
||||
|
||||
try {
|
||||
const escapedChatroomId = chatroomId.replace(/'/g, "''")
|
||||
const roomResult = await wcdbService.execQuery('contact', null, `SELECT * FROM chat_room WHERE username='${escapedChatroomId}' LIMIT 1`)
|
||||
if (roomResult.success && roomResult.rows && roomResult.rows.length > 0) {
|
||||
const owner = tryResolve(roomResult.rows[0])
|
||||
const roomExt = await wcdbService.getChatRoomExtBuffer(chatroomId)
|
||||
if (roomExt.success && roomExt.extBuffer) {
|
||||
const owner = tryResolve({ ext_buffer: roomExt.extBuffer })
|
||||
if (owner) return owner
|
||||
}
|
||||
} catch {
|
||||
@@ -252,26 +265,78 @@ class GroupAnalyticsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 DLL 获取群成员的群昵称
|
||||
* 从后端获取群成员群昵称,并在前端进行唯一性净化防串号。
|
||||
*/
|
||||
private async getGroupNicknamesForRoom(chatroomId: string, candidates: string[] = []): Promise<Map<string, string>> {
|
||||
try {
|
||||
const escapedChatroomId = chatroomId.replace(/'/g, "''")
|
||||
const sql = `SELECT ext_buffer FROM chat_room WHERE username='${escapedChatroomId}' LIMIT 1`
|
||||
const result = await wcdbService.execQuery('contact', null, sql)
|
||||
if (!result.success || !result.rows || result.rows.length === 0) {
|
||||
const dllResult = await wcdbService.getGroupNicknames(chatroomId)
|
||||
if (!dllResult.success || !dllResult.nicknames) {
|
||||
return new Map<string, string>()
|
||||
}
|
||||
|
||||
const extBuffer = this.decodeExtBuffer((result.rows[0] as any).ext_buffer)
|
||||
if (!extBuffer) return new Map<string, string>()
|
||||
return this.parseGroupNicknamesFromExtBuffer(extBuffer, candidates)
|
||||
return this.buildTrustedGroupNicknameMap(Object.entries(dllResult.nicknames), candidates)
|
||||
} catch (e) {
|
||||
console.error('getGroupNicknamesForRoom error:', e)
|
||||
console.error('getGroupNicknamesForRoom service error:', e)
|
||||
return new Map<string, string>()
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeGroupNicknameIdentity(value: string): string {
|
||||
const raw = String(value || '').trim()
|
||||
if (!raw) return ''
|
||||
return raw.toLowerCase()
|
||||
}
|
||||
|
||||
private buildTrustedGroupNicknameMap(
|
||||
entries: Iterable<[string, string]>,
|
||||
candidates: string[] = []
|
||||
): Map<string, string> {
|
||||
const candidateSet = new Set(
|
||||
this.buildGroupNicknameIdCandidates(candidates)
|
||||
.map((id) => this.normalizeGroupNicknameIdentity(id))
|
||||
.filter(Boolean)
|
||||
)
|
||||
|
||||
const buckets = new Map<string, Set<string>>()
|
||||
for (const [memberIdRaw, nicknameRaw] of entries) {
|
||||
const identity = this.normalizeGroupNicknameIdentity(memberIdRaw || '')
|
||||
if (!identity) continue
|
||||
if (candidateSet.size > 0 && !candidateSet.has(identity)) continue
|
||||
|
||||
const nickname = this.normalizeGroupNickname(nicknameRaw || '')
|
||||
if (!nickname) continue
|
||||
|
||||
const slot = buckets.get(identity)
|
||||
if (slot) {
|
||||
slot.add(nickname)
|
||||
} else {
|
||||
buckets.set(identity, new Set([nickname]))
|
||||
}
|
||||
}
|
||||
|
||||
const trusted = new Map<string, string>()
|
||||
for (const [identity, nicknameSet] of buckets.entries()) {
|
||||
if (nicknameSet.size !== 1) continue
|
||||
trusted.set(identity, Array.from(nicknameSet)[0])
|
||||
}
|
||||
return trusted
|
||||
}
|
||||
|
||||
private mergeGroupNicknameEntries(
|
||||
target: Map<string, string>,
|
||||
entries: Iterable<[string, string]>
|
||||
): void {
|
||||
for (const [memberIdRaw, nicknameRaw] of entries) {
|
||||
const nickname = this.normalizeGroupNickname(nicknameRaw || '')
|
||||
if (!nickname) continue
|
||||
for (const alias of this.buildIdCandidates([memberIdRaw])) {
|
||||
if (!alias) continue
|
||||
if (!target.has(alias)) target.set(alias, nickname)
|
||||
const lower = alias.toLowerCase()
|
||||
if (!target.has(lower)) target.set(lower, nickname)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private looksLikeHex(s: string): boolean {
|
||||
if (s.length % 2 !== 0) return false
|
||||
return /^[0-9a-fA-F]+$/.test(s)
|
||||
@@ -444,6 +509,16 @@ class GroupAnalyticsService {
|
||||
return Array.from(set)
|
||||
}
|
||||
|
||||
private buildGroupNicknameIdCandidates(values: Array<string | undefined | null>): string[] {
|
||||
const set = new Set<string>()
|
||||
for (const rawValue of values) {
|
||||
const raw = String(rawValue || '').trim()
|
||||
if (!raw) continue
|
||||
set.add(raw)
|
||||
}
|
||||
return Array.from(set)
|
||||
}
|
||||
|
||||
private toNonNegativeInteger(value: unknown): number {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed)) return 0
|
||||
@@ -550,19 +625,9 @@ class GroupAnalyticsService {
|
||||
const batch = candidates.slice(i, i + batchSize)
|
||||
if (batch.length === 0) continue
|
||||
|
||||
const inList = batch.map((username) => `'${username.replace(/'/g, "''")}'`).join(',')
|
||||
const lightweightSql = `
|
||||
SELECT username, user_name, encrypt_username, encrypt_user_name, remark, nick_name, alias, local_type
|
||||
FROM contact
|
||||
WHERE username IN (${inList})
|
||||
`
|
||||
let result = await wcdbService.execQuery('contact', null, lightweightSql)
|
||||
if (!result.success || !result.rows) {
|
||||
// 兼容历史/变体列名,轻查询失败时回退全字段查询,避免好友标识丢失
|
||||
result = await wcdbService.execQuery('contact', null, `SELECT * FROM contact WHERE username IN (${inList})`)
|
||||
}
|
||||
if (!result.success || !result.rows) continue
|
||||
appendContactsToLookup(result.rows as Record<string, unknown>[])
|
||||
const result = await wcdbService.getContactsCompact(batch)
|
||||
if (!result.success || !result.contacts) continue
|
||||
appendContactsToLookup(result.contacts as Record<string, unknown>[])
|
||||
}
|
||||
return lookup
|
||||
}
|
||||
@@ -642,30 +707,23 @@ class GroupAnalyticsService {
|
||||
}
|
||||
|
||||
private resolveGroupNicknameByCandidates(groupNicknames: Map<string, string>, candidates: string[]): string {
|
||||
const idCandidates = this.buildIdCandidates(candidates)
|
||||
const idCandidates = this.buildGroupNicknameIdCandidates(candidates)
|
||||
if (idCandidates.length === 0) return ''
|
||||
|
||||
let resolved = ''
|
||||
for (const id of idCandidates) {
|
||||
const exact = this.normalizeGroupNickname(groupNicknames.get(id) || '')
|
||||
if (exact) return exact
|
||||
}
|
||||
|
||||
for (const id of idCandidates) {
|
||||
const lower = id.toLowerCase()
|
||||
let found = ''
|
||||
let matched = 0
|
||||
for (const [key, value] of groupNicknames.entries()) {
|
||||
if (String(key || '').toLowerCase() !== lower) continue
|
||||
const normalized = this.normalizeGroupNickname(value || '')
|
||||
if (!normalized) continue
|
||||
found = normalized
|
||||
matched += 1
|
||||
if (matched > 1) return ''
|
||||
const normalizedId = this.normalizeGroupNicknameIdentity(id)
|
||||
if (!normalizedId) continue
|
||||
const candidateNickname = this.normalizeGroupNickname(groupNicknames.get(normalizedId) || '')
|
||||
if (!candidateNickname) continue
|
||||
if (!resolved) {
|
||||
resolved = candidateNickname
|
||||
continue
|
||||
}
|
||||
if (matched === 1 && found) return found
|
||||
if (resolved !== candidateNickname) return ''
|
||||
}
|
||||
|
||||
return ''
|
||||
return resolved
|
||||
}
|
||||
|
||||
private sanitizeWorksheetName(name: string): string {
|
||||
@@ -741,36 +799,271 @@ class GroupAnalyticsService {
|
||||
return ''
|
||||
}
|
||||
|
||||
private normalizeCursorTimestamp(value: number): number {
|
||||
if (!Number.isFinite(value) || value <= 0) return 0
|
||||
const normalized = Math.floor(value)
|
||||
return normalized > 10000000000 ? Math.floor(normalized / 1000) : normalized
|
||||
}
|
||||
|
||||
private extractRowSenderUsername(row: Record<string, any>, myWxid?: string): string {
|
||||
const isSendRaw = row.computed_is_send ?? row.is_send ?? row.isSend ?? row.WCDB_CT_is_send
|
||||
if (isSendRaw != null && parseInt(isSendRaw, 10) === 1 && myWxid) {
|
||||
return myWxid
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
row.sender_username,
|
||||
row.senderUsername,
|
||||
row.sender,
|
||||
row.WCDB_CT_sender_username
|
||||
]
|
||||
for (const candidate of candidates) {
|
||||
const value = String(candidate || '').trim()
|
||||
if (value) return value
|
||||
}
|
||||
for (const [key, value] of Object.entries(row)) {
|
||||
const normalizedKey = key.toLowerCase()
|
||||
if (
|
||||
normalizedKey === 'sender_username' ||
|
||||
normalizedKey === 'senderusername' ||
|
||||
normalizedKey === 'sender' ||
|
||||
normalizedKey === 'wcdb_ct_sender_username'
|
||||
) {
|
||||
const normalizedValue = String(value || '').trim()
|
||||
if (normalizedValue) return normalizedValue
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: fast extract from raw content to avoid full parse
|
||||
const rawContent = String(row.StrContent || row.message_content || row.content || row.msg_content || '').trim()
|
||||
if (rawContent) {
|
||||
const match = /^\s*([a-zA-Z0-9_@-]{4,}):(?!\/\/)\s*(?:\r?\n|<br\s*\/?>)/i.exec(rawContent)
|
||||
if (match && match[1]) {
|
||||
return match[1].trim()
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
private parseSingleMessageRow(row: Record<string, any>): Message | null {
|
||||
try {
|
||||
const mapped = chatService.mapRowsToMessagesForApi([row])
|
||||
if (Array.isArray(mapped) && mapped.length > 0) {
|
||||
const msg = mapped[0]
|
||||
if (!msg.localType) {
|
||||
msg.localType = parseInt(row.Type || row.type || row.local_type || row.msg_type || '0', 10)
|
||||
}
|
||||
if (!msg.createTime) {
|
||||
msg.createTime = parseInt(row.CreateTime || row.create_time || row.createTime || row.msg_time || '0', 10)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private async openMemberMessageCursor(
|
||||
chatroomId: string,
|
||||
batchSize: number,
|
||||
ascending: boolean,
|
||||
startTime: number,
|
||||
endTime: number
|
||||
): Promise<{ success: boolean; cursor?: number; error?: string }> {
|
||||
const beginTimestamp = this.normalizeCursorTimestamp(startTime)
|
||||
const endTimestamp = this.normalizeCursorTimestamp(endTime)
|
||||
const liteResult = await wcdbService.openMessageCursorLite(chatroomId, batchSize, ascending, beginTimestamp, endTimestamp)
|
||||
if (liteResult.success && liteResult.cursor) return liteResult
|
||||
return wcdbService.openMessageCursor(chatroomId, batchSize, ascending, beginTimestamp, endTimestamp)
|
||||
}
|
||||
|
||||
private async collectMessagesByMember(
|
||||
chatroomId: string,
|
||||
memberUsername: string,
|
||||
startTime: number,
|
||||
endTime: number
|
||||
): Promise<{ success: boolean; data?: Message[]; error?: string }> {
|
||||
const batchSize = 500
|
||||
const batchSize = 800
|
||||
const matchedMessages: Message[] = []
|
||||
let offset = 0
|
||||
const senderMatchCache = new Map<string, boolean>()
|
||||
const matchesTargetSender = (sender: string | null | undefined): boolean => {
|
||||
const key = String(sender || '').trim().toLowerCase()
|
||||
if (!key) return false
|
||||
const cached = senderMatchCache.get(key)
|
||||
if (typeof cached === 'boolean') return cached
|
||||
const matched = this.isSameAccountIdentity(memberUsername, sender)
|
||||
senderMatchCache.set(key, matched)
|
||||
return matched
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const batch = await chatService.getMessages(chatroomId, offset, batchSize, startTime, endTime, true)
|
||||
if (!batch.success || !batch.messages) {
|
||||
return { success: false, error: batch.error || '获取群消息失败' }
|
||||
}
|
||||
const cursorResult = await this.openMemberMessageCursor(chatroomId, batchSize, true, startTime, endTime)
|
||||
if (!cursorResult.success || !cursorResult.cursor) {
|
||||
return { success: false, error: cursorResult.error || '创建群消息游标失败' }
|
||||
}
|
||||
|
||||
for (const message of batch.messages) {
|
||||
if (this.isSameAccountIdentity(memberUsername, message.senderUsername)) {
|
||||
matchedMessages.push(message)
|
||||
const cursor = cursorResult.cursor
|
||||
try {
|
||||
while (true) {
|
||||
const batch = await wcdbService.fetchMessageBatch(cursor)
|
||||
if (!batch.success) {
|
||||
return { success: false, error: batch.error || '获取群消息失败' }
|
||||
}
|
||||
}
|
||||
const rows = Array.isArray(batch.rows) ? batch.rows as Record<string, any>[] : []
|
||||
if (rows.length === 0) break
|
||||
|
||||
const fetchedCount = batch.messages.length
|
||||
if (fetchedCount <= 0 || !batch.hasMore) break
|
||||
offset += fetchedCount
|
||||
for (const row of rows) {
|
||||
const senderFromRow = this.extractRowSenderUsername(row, String(this.configService.get('myWxid') || '').trim())
|
||||
if (senderFromRow && !matchesTargetSender(senderFromRow)) {
|
||||
continue
|
||||
}
|
||||
const message = this.parseSingleMessageRow(row)
|
||||
if (!message) continue
|
||||
if (matchesTargetSender(message.senderUsername)) {
|
||||
matchedMessages.push(message)
|
||||
}
|
||||
}
|
||||
|
||||
if (!batch.hasMore) break
|
||||
}
|
||||
} finally {
|
||||
await wcdbService.closeMessageCursor(cursor)
|
||||
}
|
||||
|
||||
return { success: true, data: matchedMessages }
|
||||
}
|
||||
|
||||
async getGroupMemberMessages(
|
||||
chatroomId: string,
|
||||
memberUsername: string,
|
||||
options?: { startTime?: number; endTime?: number; limit?: number; cursor?: number }
|
||||
): Promise<{ success: boolean; data?: GroupMemberMessagesPage; error?: string }> {
|
||||
try {
|
||||
const conn = await this.ensureConnected()
|
||||
if (!conn.success) return { success: false, error: conn.error }
|
||||
|
||||
const normalizedChatroomId = String(chatroomId || '').trim()
|
||||
const normalizedMemberUsername = String(memberUsername || '').trim()
|
||||
if (!normalizedChatroomId) return { success: false, error: '群聊ID不能为空' }
|
||||
if (!normalizedMemberUsername) return { success: false, error: '成员ID不能为空' }
|
||||
|
||||
const startTimeValue = Number.isFinite(options?.startTime) && typeof options?.startTime === 'number'
|
||||
? Math.max(0, Math.floor(options.startTime))
|
||||
: 0
|
||||
const endTimeValue = Number.isFinite(options?.endTime) && typeof options?.endTime === 'number'
|
||||
? Math.max(0, Math.floor(options.endTime))
|
||||
: 0
|
||||
const limit = Number.isFinite(options?.limit) && typeof options?.limit === 'number'
|
||||
? Math.max(1, Math.min(100, Math.floor(options.limit)))
|
||||
: 50
|
||||
let cursor = Number.isFinite(options?.cursor) && typeof options?.cursor === 'number'
|
||||
? Math.max(0, Math.floor(options.cursor))
|
||||
: 0
|
||||
|
||||
const matchedMessages: Message[] = []
|
||||
const senderMatchCache = new Map<string, boolean>()
|
||||
const matchesTargetSender = (sender: string | null | undefined): boolean => {
|
||||
const key = String(sender || '').trim().toLowerCase()
|
||||
if (!key) return false
|
||||
const cached = senderMatchCache.get(key)
|
||||
if (typeof cached === 'boolean') return cached
|
||||
const matched = this.isSameAccountIdentity(normalizedMemberUsername, sender)
|
||||
senderMatchCache.set(key, matched)
|
||||
return matched
|
||||
}
|
||||
const batchSize = Math.max(limit * 4, 240)
|
||||
let hasMore = false
|
||||
|
||||
const cursorResult = await this.openMemberMessageCursor(
|
||||
normalizedChatroomId,
|
||||
batchSize,
|
||||
false,
|
||||
startTimeValue,
|
||||
endTimeValue
|
||||
)
|
||||
if (!cursorResult.success || !cursorResult.cursor) {
|
||||
return { success: false, error: cursorResult.error || '创建群成员消息游标失败' }
|
||||
}
|
||||
|
||||
let consumedRows = 0
|
||||
const dbCursor = cursorResult.cursor
|
||||
|
||||
try {
|
||||
while (matchedMessages.length < limit) {
|
||||
const batch = await wcdbService.fetchMessageBatch(dbCursor)
|
||||
if (!batch.success) {
|
||||
return { success: false, error: batch.error || '获取群成员消息失败' }
|
||||
}
|
||||
|
||||
const rows = Array.isArray(batch.rows) ? batch.rows as Record<string, any>[] : []
|
||||
if (rows.length === 0) {
|
||||
hasMore = false
|
||||
break
|
||||
}
|
||||
|
||||
let startIndex = 0
|
||||
if (cursor > consumedRows) {
|
||||
const skipCount = Math.min(cursor - consumedRows, rows.length)
|
||||
consumedRows += skipCount
|
||||
startIndex = skipCount
|
||||
if (startIndex >= rows.length) {
|
||||
if (!batch.hasMore) {
|
||||
hasMore = false
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
for (let index = startIndex; index < rows.length; index += 1) {
|
||||
const row = rows[index]
|
||||
consumedRows += 1
|
||||
|
||||
const senderFromRow = this.extractRowSenderUsername(row, String(this.configService.get('myWxid') || '').trim())
|
||||
if (senderFromRow && !matchesTargetSender(senderFromRow)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const message = this.parseSingleMessageRow(row)
|
||||
if (!message) continue
|
||||
if (!matchesTargetSender(message.senderUsername)) {
|
||||
continue
|
||||
}
|
||||
|
||||
matchedMessages.push(message)
|
||||
if (matchedMessages.length >= limit) {
|
||||
cursor = consumedRows
|
||||
hasMore = index < rows.length - 1 || batch.hasMore === true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedMessages.length >= limit) break
|
||||
|
||||
cursor = consumedRows
|
||||
if (!batch.hasMore) {
|
||||
hasMore = false
|
||||
break
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await wcdbService.closeMessageCursor(dbCursor)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
messages: matchedMessages,
|
||||
hasMore,
|
||||
nextCursor: cursor
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async getGroupChats(): Promise<{ success: boolean; data?: GroupChatInfo[]; error?: string }> {
|
||||
try {
|
||||
const conn = await this.ensureConnected()
|
||||
@@ -1207,6 +1500,154 @@ class GroupAnalyticsService {
|
||||
}
|
||||
}
|
||||
|
||||
async getGroupMemberAnalytics(
|
||||
chatroomId: string,
|
||||
memberUsername: string,
|
||||
startTime?: number,
|
||||
endTime?: number
|
||||
): Promise<{ success: boolean; data?: GroupMemberAnalytics; error?: string }> {
|
||||
try {
|
||||
const conn = await this.ensureConnected()
|
||||
if (!conn.success) return { success: false, error: conn.error }
|
||||
|
||||
const normalizedChatroomId = String(chatroomId || '').trim()
|
||||
const normalizedMemberUsername = String(memberUsername || '').trim()
|
||||
|
||||
const batchSize = 10000
|
||||
const senderMatchCache = new Map<string, boolean>()
|
||||
const matchesTargetSender = (sender: string | null | undefined): boolean => {
|
||||
const key = String(sender || '').trim().toLowerCase()
|
||||
if (!key) return false
|
||||
const cached = senderMatchCache.get(key)
|
||||
if (typeof cached === 'boolean') return cached
|
||||
const matched = this.isSameAccountIdentity(normalizedMemberUsername, sender)
|
||||
senderMatchCache.set(key, matched)
|
||||
return matched
|
||||
}
|
||||
|
||||
const cursorResult = await this.openMemberMessageCursor(normalizedChatroomId, batchSize, true, startTime || 0, endTime || 0)
|
||||
if (!cursorResult.success || !cursorResult.cursor) {
|
||||
return { success: false, error: cursorResult.error || '创建游标失败' }
|
||||
}
|
||||
|
||||
const cursor = cursorResult.cursor
|
||||
const stats: ChatStatistics = {
|
||||
totalMessages: 0,
|
||||
textMessages: 0,
|
||||
imageMessages: 0,
|
||||
voiceMessages: 0,
|
||||
videoMessages: 0,
|
||||
emojiMessages: 0,
|
||||
otherMessages: 0,
|
||||
sentMessages: 0, // In group, we only fetch messages of this member, so sentMessages = totalMessages
|
||||
receivedMessages: 0, // No meaning here
|
||||
firstMessageTime: null,
|
||||
lastMessageTime: null,
|
||||
activeDays: 0,
|
||||
messageTypeCounts: {}
|
||||
}
|
||||
|
||||
const hourlyDistribution: Record<number, number> = {}
|
||||
for (let i = 0; i < 24; i++) hourlyDistribution[i] = 0
|
||||
const dailySet = new Set<string>()
|
||||
const textTypes = [1, 244813135921]
|
||||
|
||||
const phraseCounts = new Map<string, number>()
|
||||
const emojiCounts = new Map<string, number>()
|
||||
|
||||
const myWxid = String(this.configService.get('myWxid') || '').trim()
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const batch = await wcdbService.fetchMessageBatch(cursor)
|
||||
if (!batch.success) {
|
||||
return { success: false, error: batch.error || '获取分析数据失败' }
|
||||
}
|
||||
const rows = Array.isArray(batch.rows) ? batch.rows as Record<string, any>[] : []
|
||||
if (rows.length === 0) break
|
||||
|
||||
for (const row of rows) {
|
||||
let senderFromRow = this.extractRowSenderUsername(row, myWxid)
|
||||
|
||||
const isSendRaw = row.computed_is_send ?? row.is_send ?? row.isSend ?? row.WCDB_CT_is_send
|
||||
const isSend = isSendRaw != null ? parseInt(isSendRaw, 10) === 1 : false
|
||||
|
||||
if (isSend) {
|
||||
senderFromRow = myWxid
|
||||
}
|
||||
|
||||
if (!senderFromRow || !matchesTargetSender(senderFromRow)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const msgType = parseInt(row.Type || row.type || row.local_type || row.msg_type || '0', 10)
|
||||
const createTime = parseInt(row.CreateTime || row.create_time || row.createTime || row.msg_time || '0', 10)
|
||||
|
||||
let content = String(row.StrContent || row.message_content || row.content || row.msg_content || '')
|
||||
if (content) {
|
||||
content = content.replace(/^\s*([a-zA-Z0-9_@-]{4,}):(?!\/\/)\s*(?:\r?\n|<br\s*\/?>)/i, '')
|
||||
}
|
||||
|
||||
stats.totalMessages++
|
||||
if (textTypes.includes(msgType)) {
|
||||
stats.textMessages++
|
||||
if (content) {
|
||||
const text = content.trim()
|
||||
if (text && text.length <= 20) {
|
||||
phraseCounts.set(text, (phraseCounts.get(text) || 0) + 1)
|
||||
}
|
||||
const emojiMatches = text.match(/\[.*?\]/g)
|
||||
if (emojiMatches) {
|
||||
for (const em of emojiMatches) {
|
||||
emojiCounts.set(em, (emojiCounts.get(em) || 0) + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (msgType === 3) stats.imageMessages++
|
||||
else if (msgType === 34) stats.voiceMessages++
|
||||
else if (msgType === 43) stats.videoMessages++
|
||||
else if (msgType === 47) stats.emojiMessages++
|
||||
else stats.otherMessages++
|
||||
|
||||
stats.sentMessages++
|
||||
|
||||
stats.messageTypeCounts[msgType] = (stats.messageTypeCounts[msgType] || 0) + 1
|
||||
|
||||
if (createTime > 0) {
|
||||
if (stats.firstMessageTime === null || createTime < stats.firstMessageTime) stats.firstMessageTime = createTime
|
||||
if (stats.lastMessageTime === null || createTime > stats.lastMessageTime) stats.lastMessageTime = createTime
|
||||
|
||||
const d = new Date(createTime * 1000)
|
||||
const hour = d.getHours()
|
||||
hourlyDistribution[hour]++
|
||||
dailySet.add(`${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`)
|
||||
}
|
||||
}
|
||||
if (!batch.hasMore) break
|
||||
}
|
||||
} finally {
|
||||
await wcdbService.closeMessageCursor(cursor)
|
||||
}
|
||||
|
||||
stats.activeDays = dailySet.size
|
||||
|
||||
const commonPhrases = Array.from(phraseCounts.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10)
|
||||
.map(([phrase, count]) => ({ phrase, count }))
|
||||
|
||||
const commonEmojis = Array.from(emojiCounts.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10)
|
||||
.map(([emoji, count]) => ({ emoji, count }))
|
||||
|
||||
return { success: true, data: { statistics: stats, timeDistribution: hourlyDistribution, commonPhrases, commonEmojis } }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async exportGroupMemberMessages(
|
||||
chatroomId: string,
|
||||
memberUsername: string,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -55,14 +55,24 @@ type DecryptResult = {
|
||||
isThumb?: boolean // 是否是缩略图(没有高清图时返回缩略图)
|
||||
}
|
||||
|
||||
type HardlinkState = {
|
||||
imageTable?: string
|
||||
dirTable?: string
|
||||
type DecryptProgressStage = 'queued' | 'locating' | 'decrypting' | 'writing' | 'done' | 'failed'
|
||||
|
||||
type CachedImagePayload = {
|
||||
sessionId?: string
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
preferFilePath?: boolean
|
||||
disableUpdateCheck?: boolean
|
||||
allowCacheIndex?: boolean
|
||||
}
|
||||
|
||||
type DecryptImagePayload = CachedImagePayload & {
|
||||
force?: boolean
|
||||
hardlinkOnly?: boolean
|
||||
}
|
||||
|
||||
export class ImageDecryptService {
|
||||
private configService = new ConfigService()
|
||||
private hardlinkCache = new Map<string, HardlinkState>()
|
||||
private resolvedCache = new Map<string, string>()
|
||||
private pending = new Map<string, Promise<DecryptResult>>()
|
||||
private readonly defaultV1AesKey = 'cfcd208495d565ef'
|
||||
@@ -106,8 +116,10 @@ export class ImageDecryptService {
|
||||
}
|
||||
}
|
||||
|
||||
async resolveCachedImage(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }): Promise<DecryptResult & { hasUpdate?: boolean }> {
|
||||
await this.ensureCacheIndexed()
|
||||
async resolveCachedImage(payload: CachedImagePayload): Promise<DecryptResult & { hasUpdate?: boolean }> {
|
||||
if (payload.allowCacheIndex !== false) {
|
||||
await this.ensureCacheIndexed()
|
||||
}
|
||||
const cacheKeys = this.getCacheKeys(payload)
|
||||
const cacheKey = cacheKeys[0]
|
||||
if (!cacheKey) {
|
||||
@@ -116,16 +128,18 @@ export class ImageDecryptService {
|
||||
for (const key of cacheKeys) {
|
||||
const cached = this.resolvedCache.get(key)
|
||||
if (cached && existsSync(cached) && this.isImageFile(cached)) {
|
||||
const dataUrl = this.fileToDataUrl(cached)
|
||||
const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath)
|
||||
const isThumb = this.isThumbnailPath(cached)
|
||||
const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false
|
||||
if (isThumb) {
|
||||
this.triggerUpdateCheck(payload, key, cached)
|
||||
if (!payload.disableUpdateCheck) {
|
||||
this.triggerUpdateCheck(payload, key, cached)
|
||||
}
|
||||
} else {
|
||||
this.updateFlags.delete(key)
|
||||
}
|
||||
this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(cached))
|
||||
return { success: true, localPath: dataUrl || this.filePathToUrl(cached), hasUpdate }
|
||||
this.emitCacheResolved(payload, key, this.resolveEmitPath(cached, payload.preferFilePath))
|
||||
return { success: true, localPath, hasUpdate }
|
||||
}
|
||||
if (cached && !this.isImageFile(cached)) {
|
||||
this.resolvedCache.delete(key)
|
||||
@@ -136,35 +150,71 @@ export class ImageDecryptService {
|
||||
const existing = this.findCachedOutput(key, false, payload.sessionId)
|
||||
if (existing) {
|
||||
this.cacheResolvedPaths(key, payload.imageMd5, payload.imageDatName, existing)
|
||||
const dataUrl = this.fileToDataUrl(existing)
|
||||
const localPath = this.resolveLocalPathForPayload(existing, payload.preferFilePath)
|
||||
const isThumb = this.isThumbnailPath(existing)
|
||||
const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false
|
||||
if (isThumb) {
|
||||
this.triggerUpdateCheck(payload, key, existing)
|
||||
if (!payload.disableUpdateCheck) {
|
||||
this.triggerUpdateCheck(payload, key, existing)
|
||||
}
|
||||
} else {
|
||||
this.updateFlags.delete(key)
|
||||
}
|
||||
this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(existing))
|
||||
return { success: true, localPath: dataUrl || this.filePathToUrl(existing), hasUpdate }
|
||||
this.emitCacheResolved(payload, key, this.resolveEmitPath(existing, payload.preferFilePath))
|
||||
return { success: true, localPath, hasUpdate }
|
||||
}
|
||||
}
|
||||
this.logInfo('未找到缓存', { md5: payload.imageMd5, datName: payload.imageDatName })
|
||||
return { success: false, error: '未找到缓存图片' }
|
||||
}
|
||||
|
||||
async decryptImage(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }): Promise<DecryptResult> {
|
||||
await this.ensureCacheIndexed()
|
||||
const cacheKey = payload.imageMd5 || payload.imageDatName
|
||||
async decryptImage(payload: DecryptImagePayload): Promise<DecryptResult> {
|
||||
if (!payload.hardlinkOnly) {
|
||||
await this.ensureCacheIndexed()
|
||||
}
|
||||
const cacheKeys = this.getCacheKeys(payload)
|
||||
const cacheKey = cacheKeys[0]
|
||||
if (!cacheKey) {
|
||||
return { success: false, error: '缺少图片标识' }
|
||||
}
|
||||
this.emitDecryptProgress(payload, cacheKey, 'queued', 4, 'running')
|
||||
|
||||
if (payload.force) {
|
||||
for (const key of cacheKeys) {
|
||||
const cached = this.resolvedCache.get(key)
|
||||
if (cached && existsSync(cached) && this.isImageFile(cached) && !this.isThumbnailPath(cached)) {
|
||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, cached)
|
||||
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
|
||||
const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath)
|
||||
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(cached, payload.preferFilePath))
|
||||
this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done')
|
||||
return { success: true, localPath }
|
||||
}
|
||||
if (cached && !this.isImageFile(cached)) {
|
||||
this.resolvedCache.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
if (!payload.hardlinkOnly) {
|
||||
for (const key of cacheKeys) {
|
||||
const existingHd = this.findCachedOutput(key, true, payload.sessionId)
|
||||
if (!existingHd || this.isThumbnailPath(existingHd)) continue
|
||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existingHd)
|
||||
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
|
||||
const localPath = this.resolveLocalPathForPayload(existingHd, payload.preferFilePath)
|
||||
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existingHd, payload.preferFilePath))
|
||||
this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done')
|
||||
return { success: true, localPath }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!payload.force) {
|
||||
const cached = this.resolvedCache.get(cacheKey)
|
||||
if (cached && existsSync(cached) && this.isImageFile(cached)) {
|
||||
const dataUrl = this.fileToDataUrl(cached)
|
||||
const localPath = dataUrl || this.filePathToUrl(cached)
|
||||
this.emitCacheResolved(payload, cacheKey, localPath)
|
||||
const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath)
|
||||
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(cached, payload.preferFilePath))
|
||||
this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done')
|
||||
return { success: true, localPath }
|
||||
}
|
||||
if (cached && !this.isImageFile(cached)) {
|
||||
@@ -173,7 +223,10 @@ export class ImageDecryptService {
|
||||
}
|
||||
|
||||
const pending = this.pending.get(cacheKey)
|
||||
if (pending) return pending
|
||||
if (pending) {
|
||||
this.emitDecryptProgress(payload, cacheKey, 'queued', 8, 'running')
|
||||
return pending
|
||||
}
|
||||
|
||||
const task = this.decryptImageInternal(payload, cacheKey)
|
||||
this.pending.set(cacheKey, task)
|
||||
@@ -184,67 +237,152 @@ export class ImageDecryptService {
|
||||
}
|
||||
}
|
||||
|
||||
async preloadImageHardlinkMd5s(md5List: string[]): Promise<void> {
|
||||
const normalizedList = Array.from(
|
||||
new Set((md5List || []).map((item) => String(item || '').trim().toLowerCase()).filter(Boolean))
|
||||
)
|
||||
if (normalizedList.length === 0) return
|
||||
|
||||
const wxid = this.configService.get('myWxid')
|
||||
const dbPath = this.configService.get('dbPath')
|
||||
if (!wxid || !dbPath) return
|
||||
|
||||
const accountDir = this.resolveAccountDir(dbPath, wxid)
|
||||
if (!accountDir) return
|
||||
|
||||
try {
|
||||
const ready = await this.ensureWcdbReady()
|
||||
if (!ready) return
|
||||
const requests = normalizedList.map((md5) => ({ md5, accountDir }))
|
||||
const result = await wcdbService.resolveImageHardlinkBatch(requests)
|
||||
if (!result.success || !Array.isArray(result.rows)) return
|
||||
|
||||
for (const row of result.rows) {
|
||||
const md5 = String(row?.md5 || '').trim().toLowerCase()
|
||||
if (!md5) continue
|
||||
const fullPath = String(row?.data?.full_path || '').trim()
|
||||
if (!fullPath || !existsSync(fullPath)) continue
|
||||
this.cacheDatPath(accountDir, md5, fullPath)
|
||||
const fileName = String(row?.data?.file_name || '').trim().toLowerCase()
|
||||
if (fileName) {
|
||||
this.cacheDatPath(accountDir, fileName, fullPath)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore preload failures
|
||||
}
|
||||
}
|
||||
|
||||
private async decryptImageInternal(
|
||||
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean },
|
||||
payload: DecryptImagePayload,
|
||||
cacheKey: string
|
||||
): Promise<DecryptResult> {
|
||||
this.logInfo('开始解密图片', { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force })
|
||||
this.logInfo('开始解密图片', { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force, hardlinkOnly: payload.hardlinkOnly === true })
|
||||
this.emitDecryptProgress(payload, cacheKey, 'locating', 14, 'running')
|
||||
try {
|
||||
const wxid = this.configService.get('myWxid')
|
||||
const dbPath = this.configService.get('dbPath')
|
||||
if (!wxid || !dbPath) {
|
||||
this.logError('配置缺失', undefined, { wxid: !!wxid, dbPath: !!dbPath })
|
||||
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '配置缺失')
|
||||
return { success: false, error: '未配置账号或数据库路径' }
|
||||
}
|
||||
|
||||
const accountDir = this.resolveAccountDir(dbPath, wxid)
|
||||
if (!accountDir) {
|
||||
this.logError('未找到账号目录', undefined, { dbPath, wxid })
|
||||
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '账号目录缺失')
|
||||
return { success: false, error: '未找到账号目录' }
|
||||
}
|
||||
|
||||
const datPath = await this.resolveDatPath(
|
||||
accountDir,
|
||||
payload.imageMd5,
|
||||
payload.imageDatName,
|
||||
payload.sessionId,
|
||||
{ allowThumbnail: !payload.force, skipResolvedCache: Boolean(payload.force) }
|
||||
)
|
||||
let datPath: string | null = null
|
||||
let usedHdAttempt = false
|
||||
let fallbackToThumbnail = false
|
||||
|
||||
// 如果要求高清图但没找到,直接返回提示
|
||||
if (!datPath && payload.force) {
|
||||
this.logError('未找到高清图', undefined, { md5: payload.imageMd5, datName: payload.imageDatName })
|
||||
return { success: false, error: '未找到高清图,请在微信中点开该图片查看后重试' }
|
||||
// force=true 时先尝试高清;若高清缺失则回退到缩略图,避免直接失败。
|
||||
if (payload.force) {
|
||||
usedHdAttempt = true
|
||||
datPath = await this.resolveDatPath(
|
||||
accountDir,
|
||||
payload.imageMd5,
|
||||
payload.imageDatName,
|
||||
payload.sessionId,
|
||||
{
|
||||
allowThumbnail: false,
|
||||
skipResolvedCache: true,
|
||||
hardlinkOnly: payload.hardlinkOnly === true
|
||||
}
|
||||
)
|
||||
if (!datPath) {
|
||||
datPath = await this.resolveDatPath(
|
||||
accountDir,
|
||||
payload.imageMd5,
|
||||
payload.imageDatName,
|
||||
payload.sessionId,
|
||||
{
|
||||
allowThumbnail: true,
|
||||
skipResolvedCache: true,
|
||||
hardlinkOnly: payload.hardlinkOnly === true
|
||||
}
|
||||
)
|
||||
fallbackToThumbnail = Boolean(datPath)
|
||||
if (fallbackToThumbnail) {
|
||||
this.logInfo('高清缺失,回退解密缩略图', {
|
||||
md5: payload.imageMd5,
|
||||
datName: payload.imageDatName
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
datPath = await this.resolveDatPath(
|
||||
accountDir,
|
||||
payload.imageMd5,
|
||||
payload.imageDatName,
|
||||
payload.sessionId,
|
||||
{
|
||||
allowThumbnail: true,
|
||||
skipResolvedCache: false,
|
||||
hardlinkOnly: payload.hardlinkOnly === true
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (!datPath) {
|
||||
this.logError('未找到DAT文件', undefined, { md5: payload.imageMd5, datName: payload.imageDatName })
|
||||
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '未找到DAT文件')
|
||||
if (usedHdAttempt) {
|
||||
return { success: false, error: '未找到图片文件,请在微信中点开该图片后重试' }
|
||||
}
|
||||
return { success: false, error: '未找到图片文件' }
|
||||
}
|
||||
|
||||
this.logInfo('找到DAT文件', { datPath })
|
||||
this.emitDecryptProgress(payload, cacheKey, 'locating', 34, 'running')
|
||||
|
||||
if (!extname(datPath).toLowerCase().includes('dat')) {
|
||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, datPath)
|
||||
const dataUrl = this.fileToDataUrl(datPath)
|
||||
const localPath = dataUrl || this.filePathToUrl(datPath)
|
||||
const localPath = this.resolveLocalPathForPayload(datPath, payload.preferFilePath)
|
||||
const isThumb = this.isThumbnailPath(datPath)
|
||||
this.emitCacheResolved(payload, cacheKey, localPath)
|
||||
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(datPath, payload.preferFilePath))
|
||||
this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done')
|
||||
return { success: true, localPath, isThumb }
|
||||
}
|
||||
|
||||
// 查找已缓存的解密文件
|
||||
const existing = this.findCachedOutput(cacheKey, payload.force, payload.sessionId)
|
||||
if (existing) {
|
||||
this.logInfo('找到已解密文件', { existing, isHd: this.isHdPath(existing) })
|
||||
const isHd = this.isHdPath(existing)
|
||||
// 如果要求高清但找到的是缩略图,继续解密高清图
|
||||
if (!(payload.force && !isHd)) {
|
||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existing)
|
||||
const dataUrl = this.fileToDataUrl(existing)
|
||||
const localPath = dataUrl || this.filePathToUrl(existing)
|
||||
const isThumb = this.isThumbnailPath(existing)
|
||||
this.emitCacheResolved(payload, cacheKey, localPath)
|
||||
return { success: true, localPath, isThumb }
|
||||
// 查找已缓存的解密文件(hardlink-only 模式下跳过全缓存目录扫描)
|
||||
if (!payload.hardlinkOnly) {
|
||||
const existing = this.findCachedOutput(cacheKey, payload.force, payload.sessionId)
|
||||
if (existing) {
|
||||
this.logInfo('找到已解密文件', { existing, isHd: this.isHdPath(existing) })
|
||||
const isHd = this.isHdPath(existing)
|
||||
// 如果要求高清但找到的是缩略图,继续解密高清图
|
||||
if (!(payload.force && !isHd)) {
|
||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existing)
|
||||
const localPath = this.resolveLocalPathForPayload(existing, payload.preferFilePath)
|
||||
const isThumb = this.isThumbnailPath(existing)
|
||||
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existing, payload.preferFilePath))
|
||||
this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done')
|
||||
return { success: true, localPath, isThumb }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,6 +402,7 @@ export class ImageDecryptService {
|
||||
}
|
||||
}
|
||||
if (Number.isNaN(xorKey) || (!xorKey && xorKey !== 0)) {
|
||||
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '缺少解密密钥')
|
||||
return { success: false, error: '未配置图片解密密钥' }
|
||||
}
|
||||
|
||||
@@ -271,7 +410,9 @@ export class ImageDecryptService {
|
||||
const aesKey = this.resolveAesKey(aesKeyRaw)
|
||||
|
||||
this.logInfo('开始解密DAT文件', { datPath, xorKey, hasAesKey: !!aesKey })
|
||||
this.emitDecryptProgress(payload, cacheKey, 'decrypting', 58, 'running')
|
||||
let decrypted = await this.decryptDatAuto(datPath, xorKey, aesKey)
|
||||
this.emitDecryptProgress(payload, cacheKey, 'decrypting', 78, 'running')
|
||||
|
||||
// 检查是否是 wxgf 格式,如果是则尝试提取真实图片数据
|
||||
const wxgfResult = await this.unwrapWxgf(decrypted)
|
||||
@@ -287,10 +428,12 @@ export class ImageDecryptService {
|
||||
const finalExt = ext || '.jpg'
|
||||
|
||||
const outputPath = this.getCacheOutputPathFromDat(datPath, finalExt, payload.sessionId)
|
||||
this.emitDecryptProgress(payload, cacheKey, 'writing', 90, 'running')
|
||||
await writeFile(outputPath, decrypted)
|
||||
this.logInfo('解密成功', { outputPath, size: decrypted.length })
|
||||
|
||||
if (finalExt === '.hevc') {
|
||||
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', 'wxgf转换失败')
|
||||
return {
|
||||
success: false,
|
||||
error: '此图片为微信新格式(wxgf),ffmpeg 转换失败,请检查日志',
|
||||
@@ -302,13 +445,19 @@ export class ImageDecryptService {
|
||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath)
|
||||
if (!isThumb) {
|
||||
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
|
||||
} else {
|
||||
this.triggerUpdateCheck(payload, cacheKey, outputPath)
|
||||
}
|
||||
const dataUrl = this.bufferToDataUrl(decrypted, finalExt)
|
||||
const localPath = dataUrl || this.filePathToUrl(outputPath)
|
||||
this.emitCacheResolved(payload, cacheKey, localPath)
|
||||
const localPath = payload.preferFilePath
|
||||
? outputPath
|
||||
: (this.bufferToDataUrl(decrypted, finalExt) || this.filePathToUrl(outputPath))
|
||||
const emitPath = this.resolveEmitPath(outputPath, payload.preferFilePath)
|
||||
this.emitCacheResolved(payload, cacheKey, emitPath)
|
||||
this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done')
|
||||
return { success: true, localPath, isThumb }
|
||||
} catch (e) {
|
||||
this.logError('解密失败', e, { md5: payload.imageMd5, datName: payload.imageDatName })
|
||||
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', String(e))
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
@@ -400,15 +549,17 @@ export class ImageDecryptService {
|
||||
imageMd5?: string,
|
||||
imageDatName?: string,
|
||||
sessionId?: string,
|
||||
options?: { allowThumbnail?: boolean; skipResolvedCache?: boolean }
|
||||
options?: { allowThumbnail?: boolean; skipResolvedCache?: boolean; hardlinkOnly?: boolean }
|
||||
): Promise<string | null> {
|
||||
const allowThumbnail = options?.allowThumbnail ?? true
|
||||
const skipResolvedCache = options?.skipResolvedCache ?? false
|
||||
const hardlinkOnly = options?.hardlinkOnly ?? false
|
||||
this.logInfo('[ImageDecrypt] resolveDatPath', {
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
allowThumbnail,
|
||||
skipResolvedCache
|
||||
skipResolvedCache,
|
||||
hardlinkOnly
|
||||
})
|
||||
|
||||
if (!skipResolvedCache) {
|
||||
@@ -433,13 +584,17 @@ export class ImageDecryptService {
|
||||
}
|
||||
|
||||
// 1. 通过 MD5 快速定位 (MsgAttach 目录)
|
||||
if (imageMd5) {
|
||||
if (!hardlinkOnly && allowThumbnail && imageMd5) {
|
||||
const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageMd5, allowThumbnail)
|
||||
if (res) return res
|
||||
if (imageDatName && imageDatName !== imageMd5 && this.looksLikeMd5(imageDatName)) {
|
||||
const datNameRes = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageDatName, allowThumbnail)
|
||||
if (datNameRes) return datNameRes
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 如果 imageDatName 看起来像 MD5,也尝试快速定位
|
||||
if (!imageMd5 && imageDatName && this.looksLikeMd5(imageDatName)) {
|
||||
if (!hardlinkOnly && allowThumbnail && !imageMd5 && imageDatName && this.looksLikeMd5(imageDatName)) {
|
||||
const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageDatName, allowThumbnail)
|
||||
if (res) return res
|
||||
}
|
||||
@@ -478,14 +633,14 @@ export class ImageDecryptService {
|
||||
if (allowThumbnail || !isThumb) {
|
||||
this.logInfo('[ImageDecrypt] hardlink hit (datName)', { imageMd5: imageDatName, path: preferredPath })
|
||||
this.cacheDatPath(accountDir, imageDatName, preferredPath)
|
||||
if (imageMd5) this.cacheDatPath(accountDir, imageMd5, preferredPath)
|
||||
this.cacheDatPath(accountDir, imageMd5, preferredPath)
|
||||
return preferredPath
|
||||
}
|
||||
// 找到缩略图但要求高清图,尝试同目录查找高清图变体
|
||||
const hdPath = this.findHdVariantInSameDir(preferredPath)
|
||||
if (hdPath) {
|
||||
this.cacheDatPath(accountDir, imageDatName, hdPath)
|
||||
if (imageMd5) this.cacheDatPath(accountDir, imageMd5, hdPath)
|
||||
this.cacheDatPath(accountDir, imageMd5, hdPath)
|
||||
return hdPath
|
||||
}
|
||||
return null
|
||||
@@ -516,41 +671,58 @@ export class ImageDecryptService {
|
||||
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
|
||||
}
|
||||
|
||||
// 如果要求高清图但 hardlink 没找到,也不要搜索了(搜索太慢)
|
||||
if (!allowThumbnail) {
|
||||
if (hardlinkOnly) {
|
||||
this.logInfo('[ImageDecrypt] resolveDatPath miss (hardlink-only)', { imageMd5, imageDatName })
|
||||
return null
|
||||
}
|
||||
|
||||
if (!imageDatName) return null
|
||||
const searchNames = Array.from(
|
||||
new Set([imageDatName, imageMd5].map((item) => String(item || '').trim()).filter(Boolean))
|
||||
)
|
||||
if (searchNames.length === 0) return null
|
||||
|
||||
if (!skipResolvedCache) {
|
||||
const cached = this.resolvedCache.get(imageDatName)
|
||||
if (cached && existsSync(cached)) {
|
||||
const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail)
|
||||
if (allowThumbnail || !this.isThumbnailPath(preferred)) return preferred
|
||||
// 缓存的是缩略图,尝试找高清图
|
||||
const hdPath = this.findHdVariantInSameDir(preferred)
|
||||
if (hdPath) return hdPath
|
||||
for (const searchName of searchNames) {
|
||||
const cached = this.resolvedCache.get(searchName)
|
||||
if (cached && existsSync(cached)) {
|
||||
const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail)
|
||||
if (allowThumbnail || !this.isThumbnailPath(preferred)) return preferred
|
||||
// 缓存的是缩略图,尝试找高清图
|
||||
const hdPath = this.findHdVariantInSameDir(preferred)
|
||||
if (hdPath) return hdPath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const datPath = await this.searchDatFile(accountDir, imageDatName, allowThumbnail)
|
||||
if (datPath) {
|
||||
this.logInfo('[ImageDecrypt] searchDatFile hit', { imageDatName, path: datPath })
|
||||
this.resolvedCache.set(imageDatName, datPath)
|
||||
this.cacheDatPath(accountDir, imageDatName, datPath)
|
||||
return datPath
|
||||
}
|
||||
const normalized = this.normalizeDatBase(imageDatName)
|
||||
if (normalized !== imageDatName.toLowerCase()) {
|
||||
const normalizedPath = await this.searchDatFile(accountDir, normalized, allowThumbnail)
|
||||
if (normalizedPath) {
|
||||
this.logInfo('[ImageDecrypt] searchDatFile hit (normalized)', { imageDatName, normalized, path: normalizedPath })
|
||||
this.resolvedCache.set(imageDatName, normalizedPath)
|
||||
this.cacheDatPath(accountDir, imageDatName, normalizedPath)
|
||||
return normalizedPath
|
||||
for (const searchName of searchNames) {
|
||||
const datPath = await this.searchDatFile(accountDir, searchName, allowThumbnail)
|
||||
if (datPath) {
|
||||
this.logInfo('[ImageDecrypt] searchDatFile hit', { imageDatName, searchName, path: datPath })
|
||||
if (imageDatName) this.resolvedCache.set(imageDatName, datPath)
|
||||
if (imageMd5) this.resolvedCache.set(imageMd5, datPath)
|
||||
this.cacheDatPath(accountDir, searchName, datPath)
|
||||
if (imageDatName && imageDatName !== searchName) this.cacheDatPath(accountDir, imageDatName, datPath)
|
||||
if (imageMd5 && imageMd5 !== searchName) this.cacheDatPath(accountDir, imageMd5, datPath)
|
||||
return datPath
|
||||
}
|
||||
}
|
||||
this.logInfo('[ImageDecrypt] resolveDatPath miss', { imageDatName, normalized })
|
||||
|
||||
for (const searchName of searchNames) {
|
||||
const normalized = this.normalizeDatBase(searchName)
|
||||
if (normalized !== searchName.toLowerCase()) {
|
||||
const normalizedPath = await this.searchDatFile(accountDir, normalized, allowThumbnail)
|
||||
if (normalizedPath) {
|
||||
this.logInfo('[ImageDecrypt] searchDatFile hit (normalized)', { imageDatName, searchName, normalized, path: normalizedPath })
|
||||
if (imageDatName) this.resolvedCache.set(imageDatName, normalizedPath)
|
||||
if (imageMd5) this.resolvedCache.set(imageMd5, normalizedPath)
|
||||
this.cacheDatPath(accountDir, searchName, normalizedPath)
|
||||
if (imageDatName && imageDatName !== searchName) this.cacheDatPath(accountDir, imageDatName, normalizedPath)
|
||||
if (imageMd5 && imageMd5 !== searchName) this.cacheDatPath(accountDir, imageMd5, normalizedPath)
|
||||
return normalizedPath
|
||||
}
|
||||
}
|
||||
}
|
||||
this.logInfo('[ImageDecrypt] resolveDatPath miss', { imageDatName, imageMd5, searchNames })
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -650,45 +822,19 @@ export class ImageDecryptService {
|
||||
|
||||
private async resolveHardlinkPath(accountDir: string, md5: string, _sessionId?: string): Promise<string | null> {
|
||||
try {
|
||||
const hardlinkPath = this.resolveHardlinkDbPath(accountDir)
|
||||
if (!hardlinkPath) {
|
||||
return null
|
||||
}
|
||||
|
||||
const ready = await this.ensureWcdbReady()
|
||||
if (!ready) {
|
||||
this.logInfo('[ImageDecrypt] hardlink db not ready')
|
||||
return null
|
||||
}
|
||||
|
||||
const state = await this.getHardlinkState(accountDir, hardlinkPath)
|
||||
if (!state.imageTable) {
|
||||
this.logInfo('[ImageDecrypt] hardlink table missing', { hardlinkPath })
|
||||
return null
|
||||
}
|
||||
const resolveResult = await wcdbService.resolveImageHardlink(md5, accountDir)
|
||||
if (!resolveResult.success || !resolveResult.data) return null
|
||||
const fileName = String(resolveResult.data.file_name || '').trim()
|
||||
const fullPath = String(resolveResult.data.full_path || '').trim()
|
||||
if (!fileName) return null
|
||||
|
||||
const escapedMd5 = this.escapeSqlString(md5)
|
||||
const rowResult = await wcdbService.execQuery(
|
||||
'media',
|
||||
hardlinkPath,
|
||||
`SELECT dir1, dir2, file_name FROM ${state.imageTable} WHERE lower(md5) = lower('${escapedMd5}') LIMIT 1`
|
||||
)
|
||||
const row = rowResult.success && rowResult.rows ? rowResult.rows[0] : null
|
||||
|
||||
if (!row) {
|
||||
this.logInfo('[ImageDecrypt] hardlink row miss', { md5, table: state.imageTable })
|
||||
return null
|
||||
}
|
||||
|
||||
const dir1 = this.getRowValue(row, 'dir1')
|
||||
const dir2 = this.getRowValue(row, 'dir2')
|
||||
const fileName = this.getRowValue(row, 'file_name') ?? this.getRowValue(row, 'fileName')
|
||||
if (dir1 === undefined || dir2 === undefined || !fileName) {
|
||||
this.logInfo('[ImageDecrypt] hardlink row incomplete', { row })
|
||||
return null
|
||||
}
|
||||
|
||||
const lowerFileName = fileName.toLowerCase()
|
||||
const lowerFileName = String(fileName).toLowerCase()
|
||||
if (lowerFileName.endsWith('.dat')) {
|
||||
const baseLower = lowerFileName.slice(0, -4)
|
||||
if (!this.isLikelyImageDatBase(baseLower) && !this.looksLikeMd5(baseLower)) {
|
||||
@@ -697,57 +843,11 @@ export class ImageDecryptService {
|
||||
}
|
||||
}
|
||||
|
||||
// dir1 和 dir2 是 rowid,需要从 dir2id 表查询对应的目录名
|
||||
let dir1Name: string | null = null
|
||||
let dir2Name: string | null = null
|
||||
|
||||
if (state.dirTable) {
|
||||
try {
|
||||
// 通过 rowid 查询目录名
|
||||
const dir1Result = await wcdbService.execQuery(
|
||||
'media',
|
||||
hardlinkPath,
|
||||
`SELECT username FROM ${state.dirTable} WHERE rowid = ${Number(dir1)} LIMIT 1`
|
||||
)
|
||||
if (dir1Result.success && dir1Result.rows && dir1Result.rows.length > 0) {
|
||||
const value = this.getRowValue(dir1Result.rows[0], 'username')
|
||||
if (value) dir1Name = String(value)
|
||||
}
|
||||
|
||||
const dir2Result = await wcdbService.execQuery(
|
||||
'media',
|
||||
hardlinkPath,
|
||||
`SELECT username FROM ${state.dirTable} WHERE rowid = ${Number(dir2)} LIMIT 1`
|
||||
)
|
||||
if (dir2Result.success && dir2Result.rows && dir2Result.rows.length > 0) {
|
||||
const value = this.getRowValue(dir2Result.rows[0], 'username')
|
||||
if (value) dir2Name = String(value)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (fullPath && existsSync(fullPath)) {
|
||||
this.logInfo('[ImageDecrypt] hardlink path hit', { fullPath })
|
||||
return fullPath
|
||||
}
|
||||
|
||||
if (!dir1Name || !dir2Name) {
|
||||
this.logInfo('[ImageDecrypt] hardlink dir resolve miss', { dir1, dir2, dir1Name, dir2Name })
|
||||
return null
|
||||
}
|
||||
|
||||
// 构建路径: msg/attach/{dir1Name}/{dir2Name}/Img/{fileName}
|
||||
const possiblePaths = [
|
||||
join(accountDir, 'msg', 'attach', dir1Name, dir2Name, 'Img', fileName),
|
||||
join(accountDir, 'msg', 'attach', dir1Name, dir2Name, 'mg', fileName),
|
||||
join(accountDir, 'msg', 'attach', dir1Name, dir2Name, fileName),
|
||||
]
|
||||
|
||||
for (const fullPath of possiblePaths) {
|
||||
if (existsSync(fullPath)) {
|
||||
this.logInfo('[ImageDecrypt] hardlink path hit', { fullPath })
|
||||
return fullPath
|
||||
}
|
||||
}
|
||||
|
||||
this.logInfo('[ImageDecrypt] hardlink path miss', { possiblePaths })
|
||||
this.logInfo('[ImageDecrypt] hardlink path miss', { fullPath, md5 })
|
||||
return null
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -755,35 +855,6 @@ export class ImageDecryptService {
|
||||
return null
|
||||
}
|
||||
|
||||
private async getHardlinkState(accountDir: string, hardlinkPath: string): Promise<HardlinkState> {
|
||||
const cached = this.hardlinkCache.get(hardlinkPath)
|
||||
if (cached) return cached
|
||||
|
||||
const imageResult = await wcdbService.execQuery(
|
||||
'media',
|
||||
hardlinkPath,
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'image_hardlink_info%' ORDER BY name DESC LIMIT 1"
|
||||
)
|
||||
const dirResult = await wcdbService.execQuery(
|
||||
'media',
|
||||
hardlinkPath,
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'dir2id%' LIMIT 1"
|
||||
)
|
||||
const imageTable = imageResult.success && imageResult.rows && imageResult.rows.length > 0
|
||||
? this.getRowValue(imageResult.rows[0], 'name')
|
||||
: undefined
|
||||
const dirTable = dirResult.success && dirResult.rows && dirResult.rows.length > 0
|
||||
? this.getRowValue(dirResult.rows[0], 'name')
|
||||
: undefined
|
||||
const state: HardlinkState = {
|
||||
imageTable: imageTable ? String(imageTable) : undefined,
|
||||
dirTable: dirTable ? String(dirTable) : undefined
|
||||
}
|
||||
this.logInfo('[ImageDecrypt] hardlink state', { hardlinkPath, imageTable: state.imageTable, dirTable: state.dirTable })
|
||||
this.hardlinkCache.set(hardlinkPath, state)
|
||||
return state
|
||||
}
|
||||
|
||||
private async ensureWcdbReady(): Promise<boolean> {
|
||||
if (wcdbService.isReady()) return true
|
||||
const dbPath = this.configService.get('dbPath')
|
||||
@@ -889,7 +960,8 @@ export class ImageDecryptService {
|
||||
|
||||
const now = new Date()
|
||||
const months: string[] = []
|
||||
for (let i = 0; i < 2; i++) {
|
||||
// Imported mobile history can live in older YYYY-MM buckets; keep this bounded but wider than "recent 2 months".
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
|
||||
const mStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||||
months.push(mStr)
|
||||
@@ -985,7 +1057,7 @@ export class ImageDecryptService {
|
||||
|
||||
private stripDatVariantSuffix(base: string): string {
|
||||
const lower = base.toLowerCase()
|
||||
const suffixes = ['_thumb', '.thumb', '_hd', '.hd', '_h', '.h', '_t', '.t', '_c', '.c']
|
||||
const suffixes = ['_thumb', '.thumb', '_hd', '.hd', '_h', '.h', '_b', '.b', '_w', '.w', '_t', '.t', '_c', '.c']
|
||||
for (const suffix of suffixes) {
|
||||
if (lower.endsWith(suffix)) {
|
||||
return lower.slice(0, -suffix.length)
|
||||
@@ -1001,8 +1073,10 @@ export class ImageDecryptService {
|
||||
const lower = name.toLowerCase()
|
||||
const baseLower = lower.endsWith('.dat') || lower.endsWith('.jpg') ? lower.slice(0, -4) : lower
|
||||
if (baseLower.endsWith('_h') || baseLower.endsWith('.h')) return 600
|
||||
if (baseLower.endsWith('_hd') || baseLower.endsWith('.hd')) return 550
|
||||
if (baseLower.endsWith('_b') || baseLower.endsWith('.b')) return 520
|
||||
if (baseLower.endsWith('_w') || baseLower.endsWith('.w')) return 510
|
||||
if (!this.hasXVariant(baseLower)) return 500
|
||||
if (baseLower.endsWith('_hd') || baseLower.endsWith('.hd')) return 450
|
||||
if (baseLower.endsWith('_c') || baseLower.endsWith('.c')) return 400
|
||||
if (this.isThumbnailDat(lower)) return 100
|
||||
return 350
|
||||
@@ -1013,9 +1087,13 @@ export class ImageDecryptService {
|
||||
const names = [
|
||||
`${baseName}_h.dat`,
|
||||
`${baseName}.h.dat`,
|
||||
`${baseName}.dat`,
|
||||
`${baseName}_hd.dat`,
|
||||
`${baseName}.hd.dat`,
|
||||
`${baseName}_b.dat`,
|
||||
`${baseName}.b.dat`,
|
||||
`${baseName}_w.dat`,
|
||||
`${baseName}.w.dat`,
|
||||
`${baseName}.dat`,
|
||||
`${baseName}_c.dat`,
|
||||
`${baseName}.c.dat`
|
||||
]
|
||||
@@ -1299,6 +1377,31 @@ export class ImageDecryptService {
|
||||
}
|
||||
}
|
||||
|
||||
private emitDecryptProgress(
|
||||
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string },
|
||||
cacheKey: string,
|
||||
stage: DecryptProgressStage,
|
||||
progress: number,
|
||||
status: 'running' | 'done' | 'error',
|
||||
message?: string
|
||||
): void {
|
||||
const safeProgress = Math.max(0, Math.min(100, Math.floor(progress)))
|
||||
const event = {
|
||||
cacheKey,
|
||||
imageMd5: payload.imageMd5,
|
||||
imageDatName: payload.imageDatName,
|
||||
stage,
|
||||
progress: safeProgress,
|
||||
status,
|
||||
message: message || ''
|
||||
}
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (!win.isDestroyed()) {
|
||||
win.webContents.send('image:decryptProgress', event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureCacheIndexed(): Promise<void> {
|
||||
if (this.cacheIndexed) return
|
||||
if (this.cacheIndexing) return this.cacheIndexing
|
||||
@@ -1567,6 +1670,16 @@ export class ImageDecryptService {
|
||||
return `data:${mimeType};base64,${buffer.toString('base64')}`
|
||||
}
|
||||
|
||||
private resolveLocalPathForPayload(filePath: string, preferFilePath?: boolean): string {
|
||||
if (preferFilePath) return filePath
|
||||
return this.resolveEmitPath(filePath, false)
|
||||
}
|
||||
|
||||
private resolveEmitPath(filePath: string, preferFilePath?: boolean): string {
|
||||
if (preferFilePath) return this.filePathToUrl(filePath)
|
||||
return this.fileToDataUrl(filePath) || this.filePathToUrl(filePath)
|
||||
}
|
||||
|
||||
private fileToDataUrl(filePath: string): string | null {
|
||||
try {
|
||||
const ext = extname(filePath).toLowerCase()
|
||||
@@ -1958,7 +2071,6 @@ export class ImageDecryptService {
|
||||
|
||||
async clearCache(): Promise<{ success: boolean; error?: string }> {
|
||||
this.resolvedCache.clear()
|
||||
this.hardlinkCache.clear()
|
||||
this.pending.clear()
|
||||
this.updateFlags.clear()
|
||||
this.cacheIndexed = false
|
||||
|
||||
@@ -6,36 +6,60 @@ type PreloadImagePayload = {
|
||||
imageDatName?: string
|
||||
}
|
||||
|
||||
type PreloadOptions = {
|
||||
allowDecrypt?: boolean
|
||||
allowCacheIndex?: boolean
|
||||
}
|
||||
|
||||
type PreloadTask = PreloadImagePayload & {
|
||||
key: string
|
||||
allowDecrypt: boolean
|
||||
allowCacheIndex: boolean
|
||||
}
|
||||
|
||||
export class ImagePreloadService {
|
||||
private queue: PreloadTask[] = []
|
||||
private pending = new Set<string>()
|
||||
private active = 0
|
||||
private readonly maxConcurrent = 2
|
||||
private activeCache = 0
|
||||
private activeDecrypt = 0
|
||||
private readonly maxCacheConcurrent = 8
|
||||
private readonly maxDecryptConcurrent = 2
|
||||
private readonly maxQueueSize = 320
|
||||
|
||||
enqueue(payloads: PreloadImagePayload[]): void {
|
||||
enqueue(payloads: PreloadImagePayload[], options?: PreloadOptions): void {
|
||||
if (!Array.isArray(payloads) || payloads.length === 0) return
|
||||
const allowDecrypt = options?.allowDecrypt !== false
|
||||
const allowCacheIndex = options?.allowCacheIndex !== false
|
||||
for (const payload of payloads) {
|
||||
if (!allowDecrypt && this.queue.length >= this.maxQueueSize) break
|
||||
const cacheKey = payload.imageMd5 || payload.imageDatName
|
||||
if (!cacheKey) continue
|
||||
const key = `${payload.sessionId || 'unknown'}|${cacheKey}`
|
||||
if (this.pending.has(key)) continue
|
||||
this.pending.add(key)
|
||||
this.queue.push({ ...payload, key })
|
||||
this.queue.push({ ...payload, key, allowDecrypt, allowCacheIndex })
|
||||
}
|
||||
this.processQueue()
|
||||
}
|
||||
|
||||
private processQueue(): void {
|
||||
while (this.active < this.maxConcurrent && this.queue.length > 0) {
|
||||
const task = this.queue.shift()
|
||||
while (this.queue.length > 0) {
|
||||
const taskIndex = this.queue.findIndex((task) => (
|
||||
task.allowDecrypt
|
||||
? this.activeDecrypt < this.maxDecryptConcurrent
|
||||
: this.activeCache < this.maxCacheConcurrent
|
||||
))
|
||||
if (taskIndex < 0) return
|
||||
|
||||
const task = this.queue.splice(taskIndex, 1)[0]
|
||||
if (!task) return
|
||||
this.active += 1
|
||||
|
||||
if (task.allowDecrypt) this.activeDecrypt += 1
|
||||
else this.activeCache += 1
|
||||
|
||||
void this.handleTask(task).finally(() => {
|
||||
this.active -= 1
|
||||
if (task.allowDecrypt) this.activeDecrypt = Math.max(0, this.activeDecrypt - 1)
|
||||
else this.activeCache = Math.max(0, this.activeCache - 1)
|
||||
this.pending.delete(task.key)
|
||||
this.processQueue()
|
||||
})
|
||||
@@ -49,9 +73,12 @@ export class ImagePreloadService {
|
||||
const cached = await imageDecryptService.resolveCachedImage({
|
||||
sessionId: task.sessionId,
|
||||
imageMd5: task.imageMd5,
|
||||
imageDatName: task.imageDatName
|
||||
imageDatName: task.imageDatName,
|
||||
disableUpdateCheck: !task.allowDecrypt,
|
||||
allowCacheIndex: task.allowCacheIndex
|
||||
})
|
||||
if (cached.success) return
|
||||
if (!task.allowDecrypt) return
|
||||
await imageDecryptService.decryptImage({
|
||||
sessionId: task.sessionId,
|
||||
imageMd5: task.imageMd5,
|
||||
|
||||
829
electron/services/insightService.ts
Normal file
829
electron/services/insightService.ts
Normal file
@@ -0,0 +1,829 @@
|
||||
/**
|
||||
* insightService.ts
|
||||
*
|
||||
* AI 见解后台服务:
|
||||
* 1. 监听 DB 变更事件(debounce 500ms 防抖,避免开机/重连时爆发大量事件阻塞主线程)
|
||||
* 2. 沉默联系人扫描(独立 setInterval,每 4 小时一次)
|
||||
* 3. 触发后拉取真实聊天上下文(若用户授权),组装 prompt 调用单一 AI 模型
|
||||
* 4. 输出 ≤80 字见解,通过现有 showNotification 弹出右下角通知
|
||||
*
|
||||
* 设计原则:
|
||||
* - 不引入任何额外 npm 依赖,使用 Node 原生 https 模块调用 OpenAI 兼容 API
|
||||
* - 所有失败静默处理,不影响主流程
|
||||
* - 当日触发记录(sessionId + 时间列表)随 prompt 一起发送,让模型自行判断是否克制
|
||||
*/
|
||||
|
||||
import https from 'https'
|
||||
import http from 'http'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { URL } from 'url'
|
||||
import { app, Notification } from 'electron'
|
||||
import { ConfigService } from './config'
|
||||
import { chatService, ChatSession, Message } from './chatService'
|
||||
|
||||
// ─── 常量 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* DB 变更防抖延迟(毫秒)。
|
||||
* 设为 2s:微信写库通常是批量操作,500ms 过短会在开机/重连时产生大量连续触发。
|
||||
*/
|
||||
const DB_CHANGE_DEBOUNCE_MS = 2000
|
||||
|
||||
/** 首次沉默扫描延迟(毫秒),避免启动期间抢占资源 */
|
||||
const SILENCE_SCAN_INITIAL_DELAY_MS = 3 * 60 * 1000
|
||||
|
||||
/** 单次 API 请求超时(毫秒) */
|
||||
const API_TIMEOUT_MS = 45_000
|
||||
|
||||
/** 沉默天数阈值默认值 */
|
||||
const DEFAULT_SILENCE_DAYS = 3
|
||||
|
||||
// ─── 类型 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface TodayTriggerRecord {
|
||||
/** 该会话今日触发的时间戳列表(毫秒) */
|
||||
timestamps: number[]
|
||||
}
|
||||
|
||||
// ─── 桌面日志 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 将日志同时输出到 console 和桌面上的 weflow-insight.log 文件。
|
||||
* 文件名带当天日期,每天自动换一个新文件,旧文件保留。
|
||||
*/
|
||||
function insightLog(level: 'INFO' | 'WARN' | 'ERROR', message: string): void {
|
||||
const now = new Date()
|
||||
const dateStr = now.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }).replace(/\//g, '-')
|
||||
const timeStr = now.toLocaleTimeString('zh-CN', { hour12: false })
|
||||
const line = `[${dateStr} ${timeStr}] [${level}] ${message}\n`
|
||||
|
||||
// 同步到 console
|
||||
if (level === 'ERROR' || level === 'WARN') {
|
||||
console.warn(`[InsightService] ${message}`)
|
||||
} else {
|
||||
console.log(`[InsightService] ${message}`)
|
||||
}
|
||||
|
||||
// 异步写入桌面日志文件,避免同步磁盘 I/O 阻塞 Electron 主线程事件循环
|
||||
try {
|
||||
const desktopPath = app.getPath('desktop')
|
||||
const logFile = path.join(desktopPath, `weflow-insight-${dateStr}.log`)
|
||||
fs.appendFile(logFile, line, 'utf-8', () => { /* 失败静默处理 */ })
|
||||
} catch {
|
||||
// getPath 失败时静默处理
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 工具函数 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 绝对拼接 baseUrl 与路径,避免 Node.js URL 相对路径陷阱。
|
||||
*
|
||||
* 例如:
|
||||
* baseUrl = "https://api.ohmygpt.com/v1"
|
||||
* path = "/chat/completions"
|
||||
* 结果为 "https://api.ohmygpt.com/v1/chat/completions"
|
||||
*
|
||||
* 如果 baseUrl 末尾没有斜杠,直接用字符串拼接(而非 new URL(path, base)),
|
||||
* 因为 new URL("chat/completions", "https://api.example.com/v1") 会错误地
|
||||
* 丢弃 v1,变成 https://api.example.com/chat/completions。
|
||||
*/
|
||||
function buildApiUrl(baseUrl: string, path: string): string {
|
||||
const base = baseUrl.replace(/\/+$/, '') // 去掉末尾斜杠
|
||||
const suffix = path.startsWith('/') ? path : `/${path}`
|
||||
return `${base}${suffix}`
|
||||
}
|
||||
|
||||
function getStartOfDay(date: Date = new Date()): number {
|
||||
const d = new Date(date)
|
||||
d.setHours(0, 0, 0, 0)
|
||||
return d.getTime()
|
||||
}
|
||||
|
||||
function formatTimestamp(ts: number): string {
|
||||
return new Date(ts).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用 OpenAI 兼容 API(非流式),返回模型第一条消息内容。
|
||||
* 使用 Node 原生 https/http 模块,无需任何第三方 SDK。
|
||||
*/
|
||||
function callApi(
|
||||
apiBaseUrl: string,
|
||||
apiKey: string,
|
||||
model: string,
|
||||
messages: Array<{ role: string; content: string }>,
|
||||
timeoutMs: number = API_TIMEOUT_MS
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions')
|
||||
let urlObj: URL
|
||||
try {
|
||||
urlObj = new URL(endpoint)
|
||||
} catch (e) {
|
||||
reject(new Error(`无效的 API URL: ${endpoint}`))
|
||||
return
|
||||
}
|
||||
|
||||
const body = JSON.stringify({
|
||||
model,
|
||||
messages,
|
||||
max_tokens: 200,
|
||||
temperature: 0.7,
|
||||
stream: false
|
||||
})
|
||||
|
||||
const options = {
|
||||
hostname: urlObj.hostname,
|
||||
port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
|
||||
path: urlObj.pathname + urlObj.search,
|
||||
method: 'POST' as const,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(body).toString(),
|
||||
Authorization: `Bearer ${apiKey}`
|
||||
}
|
||||
}
|
||||
|
||||
const isHttps = urlObj.protocol === 'https:'
|
||||
const requestFn = isHttps ? https.request : http.request
|
||||
const req = requestFn(options, (res) => {
|
||||
let data = ''
|
||||
res.on('data', (chunk) => { data += chunk })
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const parsed = JSON.parse(data)
|
||||
const content = parsed?.choices?.[0]?.message?.content
|
||||
if (typeof content === 'string' && content.trim()) {
|
||||
resolve(content.trim())
|
||||
} else {
|
||||
reject(new Error(`API 返回格式异常: ${data.slice(0, 200)}`))
|
||||
}
|
||||
} catch (e) {
|
||||
reject(new Error(`JSON 解析失败: ${data.slice(0, 200)}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
req.setTimeout(timeoutMs, () => {
|
||||
req.destroy()
|
||||
reject(new Error('API 请求超时'))
|
||||
})
|
||||
|
||||
req.on('error', (e) => reject(e))
|
||||
req.write(body)
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
// ─── InsightService 主类 ──────────────────────────────────────────────────────
|
||||
|
||||
class InsightService {
|
||||
private readonly config: ConfigService
|
||||
|
||||
/** DB 变更防抖定时器 */
|
||||
private dbDebounceTimer: NodeJS.Timeout | null = null
|
||||
|
||||
/** 沉默扫描定时器 */
|
||||
private silenceScanTimer: NodeJS.Timeout | null = null
|
||||
private silenceInitialDelayTimer: NodeJS.Timeout | null = null
|
||||
|
||||
/** 是否正在处理中(防重入) */
|
||||
private processing = false
|
||||
|
||||
/**
|
||||
* 当日触发记录:sessionId -> TodayTriggerRecord
|
||||
* 每天 00:00 之后自动重置(通过检查日期实现)
|
||||
*/
|
||||
private todayTriggers: Map<string, TodayTriggerRecord> = new Map()
|
||||
private todayDate = getStartOfDay()
|
||||
|
||||
/**
|
||||
* 活跃分析冷却记录:sessionId -> 上次分析时间戳(毫秒)
|
||||
* 同一会话 2 小时内不重复触发活跃分析,防止 DB 频繁变更时爆量调用 API。
|
||||
*/
|
||||
private lastActivityAnalysis: Map<string, number> = new Map()
|
||||
|
||||
/**
|
||||
* 跟踪每个会话上次见到的最新消息时间戳,用于判断是否有真正的新消息。
|
||||
* sessionId -> lastMessageTimestamp(秒,与微信 DB 保持一致)
|
||||
*/
|
||||
private lastSeenTimestamp: Map<string, number> = new Map()
|
||||
|
||||
/**
|
||||
* 本地会话快照缓存,避免 analyzeRecentActivity 在每次 DB 变更时都做全量读取。
|
||||
* 首次调用时填充,此后只在沉默扫描里刷新(沉默扫描间隔更长,更合适做全量刷新)。
|
||||
*/
|
||||
private sessionCache: ChatSession[] | null = null
|
||||
/** sessionCache 最后刷新时间戳(ms),超过 15 分钟强制重新拉取 */
|
||||
private sessionCacheAt = 0
|
||||
/** 缓存 TTL 设为 15 分钟,大幅减少 connect() + getSessions() 调用频率 */
|
||||
private static readonly SESSION_CACHE_TTL_MS = 15 * 60 * 1000
|
||||
/** 数据库是否已连接(避免重复调用 chatService.connect()) */
|
||||
private dbConnected = false
|
||||
|
||||
private started = false
|
||||
|
||||
constructor() {
|
||||
this.config = ConfigService.getInstance()
|
||||
}
|
||||
|
||||
// ── 公开 API ────────────────────────────────────────────────────────────────
|
||||
|
||||
start(): void {
|
||||
if (this.started) return
|
||||
this.started = true
|
||||
insightLog('INFO', '已启动')
|
||||
this.scheduleSilenceScan()
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.started = false
|
||||
this.dbConnected = false
|
||||
this.sessionCache = null
|
||||
this.sessionCacheAt = 0
|
||||
if (this.dbDebounceTimer !== null) {
|
||||
clearTimeout(this.dbDebounceTimer)
|
||||
this.dbDebounceTimer = null
|
||||
}
|
||||
if (this.silenceScanTimer !== null) {
|
||||
clearTimeout(this.silenceScanTimer)
|
||||
this.silenceScanTimer = null
|
||||
}
|
||||
if (this.silenceInitialDelayTimer !== null) {
|
||||
clearTimeout(this.silenceInitialDelayTimer)
|
||||
this.silenceInitialDelayTimer = null
|
||||
}
|
||||
insightLog('INFO', '已停止')
|
||||
}
|
||||
|
||||
/**
|
||||
* 由 main.ts 在 addDbMonitorListener 回调中调用。
|
||||
* 加入 2s 防抖,防止开机/重连时大量事件并发阻塞主线程。
|
||||
* 如果当前正在处理中,直接忽略此次事件(不创建新的 timer),避免 timer 堆积。
|
||||
*/
|
||||
handleDbMonitorChange(_type: string, _json: string): void {
|
||||
if (!this.started) return
|
||||
if (!this.isEnabled()) return
|
||||
// 正在处理时忽略新事件,避免 timer 堆积
|
||||
if (this.processing) return
|
||||
|
||||
if (this.dbDebounceTimer !== null) {
|
||||
clearTimeout(this.dbDebounceTimer)
|
||||
}
|
||||
this.dbDebounceTimer = setTimeout(() => {
|
||||
this.dbDebounceTimer = null
|
||||
void this.analyzeRecentActivity()
|
||||
}, DB_CHANGE_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
* 测<><E6B58B><EFBFBD> API 连接,返回 { success, message }。
|
||||
* 供设置页"测试连接"按钮调用。
|
||||
*/
|
||||
async testConnection(): Promise<{ success: boolean; message: string }> {
|
||||
const apiBaseUrl = this.config.get('aiInsightApiBaseUrl') as string
|
||||
const apiKey = this.config.get('aiInsightApiKey') as string
|
||||
const model = (this.config.get('aiInsightApiModel') as string) || 'gpt-4o-mini'
|
||||
|
||||
if (!apiBaseUrl || !apiKey) {
|
||||
return { success: false, message: '请先填写 API 地址和 API Key' }
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await callApi(
|
||||
apiBaseUrl,
|
||||
apiKey,
|
||||
model,
|
||||
[{ role: 'user', content: '请回复"连接成功"四个字。' }],
|
||||
15_000
|
||||
)
|
||||
return { success: true, message: `连接成功,模型回复:${result.slice(0, 50)}` }
|
||||
} catch (e) {
|
||||
return { success: false, message: `连接失败:${(e as Error).message}` }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制立即对最近一个私聊会话触发一次见解(忽略冷却,用于测试)。
|
||||
* 返回触发结果描述,供设置页展示。
|
||||
*/
|
||||
async triggerTest(): Promise<{ success: boolean; message: string }> {
|
||||
insightLog('INFO', '手动触发测试见解...')
|
||||
const apiBaseUrl = this.config.get('aiInsightApiBaseUrl') as string
|
||||
const apiKey = this.config.get('aiInsightApiKey') as string
|
||||
if (!apiBaseUrl || !apiKey) {
|
||||
return { success: false, message: '请先填写 API 地址和 Key' }
|
||||
}
|
||||
try {
|
||||
const connectResult = await chatService.connect()
|
||||
if (!connectResult.success) {
|
||||
return { success: false, message: '数据库连接失败,请先在"数据库连接"页完成配置' }
|
||||
}
|
||||
const sessionsResult = await chatService.getSessions()
|
||||
if (!sessionsResult.success || !sessionsResult.sessions || sessionsResult.sessions.length === 0) {
|
||||
return { success: false, message: '未找到任何会话,请确认数据库已正确连接' }
|
||||
}
|
||||
// 找第一个允许的私聊
|
||||
const session = (sessionsResult.sessions as ChatSession[]).find((s) => {
|
||||
const id = s.username?.trim() || ''
|
||||
return id && !id.endsWith('@chatroom') && !id.toLowerCase().includes('placeholder') && this.isSessionAllowed(id)
|
||||
})
|
||||
if (!session) {
|
||||
return { success: false, message: '未找到任何私聊会话(若已启用白名单,请检查是否有勾选的私聊)' }
|
||||
}
|
||||
const sessionId = session.username?.trim() || ''
|
||||
const displayName = session.displayName || sessionId
|
||||
insightLog('INFO', `测试目标会话:${displayName} (${sessionId})`)
|
||||
await this.generateInsightForSession({
|
||||
sessionId,
|
||||
displayName,
|
||||
triggerReason: 'activity'
|
||||
})
|
||||
return { success: true, message: `已向「${displayName}」发送测试见解,请查看右下角弹窗` }
|
||||
} catch (e) {
|
||||
return { success: false, message: `测试失败:${(e as Error).message}` }
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取今日触发统计(供设置页展示) */
|
||||
getTodayStats(): { sessionId: string; count: number; times: string[] }[] {
|
||||
this.resetIfNewDay()
|
||||
const result: { sessionId: string; count: number; times: string[] }[] = []
|
||||
for (const [sessionId, record] of this.todayTriggers.entries()) {
|
||||
result.push({
|
||||
sessionId,
|
||||
count: record.timestamps.length,
|
||||
times: record.timestamps.map(formatTimestamp)
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ── 私有方法 ────────────────────────────────────────────────────────────────
|
||||
|
||||
private isEnabled(): boolean {
|
||||
return this.config.get('aiInsightEnabled') === true
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断某个会话是否允许触发见解。
|
||||
* 若白名单未启用,则所有私聊会话均允许;
|
||||
* 若白名单已启用,则只有在白名单中的会话才允许。
|
||||
*/
|
||||
private isSessionAllowed(sessionId: string): boolean {
|
||||
const whitelistEnabled = this.config.get('aiInsightWhitelistEnabled') as boolean
|
||||
if (!whitelistEnabled) return true
|
||||
const whitelist = (this.config.get('aiInsightWhitelist') as string[]) || []
|
||||
return whitelist.includes(sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话列表,优先使用缓存(15 分钟 TTL)。
|
||||
* 缓存命中时完全跳过数据库访问,避免频繁 connect() + getSessions() 消耗 CPU。
|
||||
* forceRefresh=true 时强制重新拉取(仅用于沉默扫描等低频场景)。
|
||||
*/
|
||||
private async getSessionsCached(forceRefresh = false): Promise<ChatSession[]> {
|
||||
const now = Date.now()
|
||||
// 缓存命中:直接返回,零数据库操作
|
||||
if (
|
||||
!forceRefresh &&
|
||||
this.sessionCache !== null &&
|
||||
now - this.sessionCacheAt < InsightService.SESSION_CACHE_TTL_MS
|
||||
) {
|
||||
return this.sessionCache
|
||||
}
|
||||
// 缓存未命中或强制刷新:连接数据库并拉取
|
||||
try {
|
||||
// 只在首次或强制刷新时调用 connect(),避免重复建立连接
|
||||
if (!this.dbConnected || forceRefresh) {
|
||||
const connectResult = await chatService.connect()
|
||||
if (!connectResult.success) {
|
||||
insightLog('WARN', '数据库连接失败,使用旧缓存')
|
||||
return this.sessionCache ?? []
|
||||
}
|
||||
this.dbConnected = true
|
||||
}
|
||||
const result = await chatService.getSessions()
|
||||
if (result.success && result.sessions) {
|
||||
this.sessionCache = result.sessions as ChatSession[]
|
||||
this.sessionCacheAt = now
|
||||
}
|
||||
} catch (e) {
|
||||
insightLog('WARN', `获取会话缓存失败: ${(e as Error).message}`)
|
||||
// 连接可能已断开,下次强制重连
|
||||
this.dbConnected = false
|
||||
}
|
||||
return this.sessionCache ?? []
|
||||
}
|
||||
|
||||
private resetIfNewDay(): void {
|
||||
const todayStart = getStartOfDay()
|
||||
if (todayStart > this.todayDate) {
|
||||
this.todayDate = todayStart
|
||||
this.todayTriggers.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录触发并返回该会话今日所有触发时间(用于组装 prompt)。
|
||||
*/
|
||||
private recordTrigger(sessionId: string): string[] {
|
||||
this.resetIfNewDay()
|
||||
const existing = this.todayTriggers.get(sessionId) ?? { timestamps: [] }
|
||||
existing.timestamps.push(Date.now())
|
||||
this.todayTriggers.set(sessionId, existing)
|
||||
return existing.timestamps.map(formatTimestamp)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取今日全局已触发次数(所有会话合计),用于 prompt 中告知模<E79FA5><E6A8A1><EFBFBD>全局上下文。
|
||||
*/
|
||||
private getTodayTotalTriggerCount(): number {
|
||||
this.resetIfNewDay()
|
||||
let total = 0
|
||||
for (const record of this.todayTriggers.values()) {
|
||||
total += record.timestamps.length
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// ── 沉默联系人扫描 ──────────────────────────────────────────────────────────
|
||||
|
||||
private scheduleSilenceScan(): void {
|
||||
// 等待扫描完成后再安排下一次,避免并发堆积
|
||||
const scheduleNext = () => {
|
||||
if (!this.started) return
|
||||
const intervalHours = (this.config.get('aiInsightScanIntervalHours') as number) || 4
|
||||
const intervalMs = Math.max(0.1, intervalHours) * 60 * 60 * 1000
|
||||
insightLog('INFO', `下次沉默扫描将在 ${intervalHours} 小时后执行`)
|
||||
this.silenceScanTimer = setTimeout(async () => {
|
||||
this.silenceScanTimer = null
|
||||
await this.runSilenceScan()
|
||||
scheduleNext()
|
||||
}, intervalMs)
|
||||
}
|
||||
|
||||
this.silenceInitialDelayTimer = setTimeout(async () => {
|
||||
this.silenceInitialDelayTimer = null
|
||||
await this.runSilenceScan()
|
||||
scheduleNext()
|
||||
}, SILENCE_SCAN_INITIAL_DELAY_MS)
|
||||
}
|
||||
|
||||
private async runSilenceScan(): Promise<void> {
|
||||
if (!this.isEnabled()) {
|
||||
insightLog('INFO', '沉默扫描:AI 见解未启用,跳过')
|
||||
return
|
||||
}
|
||||
if (this.processing) {
|
||||
insightLog('INFO', '沉默扫描:正在处理中,跳过本次')
|
||||
return
|
||||
}
|
||||
|
||||
this.processing = true
|
||||
insightLog('INFO', '开始沉默联系人扫描...')
|
||||
try {
|
||||
const silenceDays = (this.config.get('aiInsightSilenceDays') as number) || DEFAULT_SILENCE_DAYS
|
||||
const thresholdMs = silenceDays * 24 * 60 * 60 * 1000
|
||||
const now = Date.now()
|
||||
|
||||
insightLog('INFO', `沉默阈值:${silenceDays} 天`)
|
||||
|
||||
// 沉默扫描间隔较长,强制刷新缓存以获取最新数据
|
||||
const sessions = await this.getSessionsCached(true)
|
||||
if (sessions.length === 0) {
|
||||
insightLog('WARN', '获取会话列表失败,跳过沉默扫描')
|
||||
return
|
||||
}
|
||||
|
||||
insightLog('INFO', `共 ${sessions.length} 个会话,开始过滤...`)
|
||||
|
||||
let silentCount = 0
|
||||
for (const session of sessions) {
|
||||
const sessionId = session.username?.trim() || ''
|
||||
if (!sessionId || sessionId.endsWith('@chatroom')) continue
|
||||
if (sessionId.toLowerCase().includes('placeholder')) continue
|
||||
if (!this.isSessionAllowed(sessionId)) continue
|
||||
|
||||
const lastTimestamp = (session.lastTimestamp || 0) * 1000
|
||||
if (!lastTimestamp || lastTimestamp <= 0) continue
|
||||
|
||||
const silentMs = now - lastTimestamp
|
||||
if (silentMs < thresholdMs) continue
|
||||
|
||||
silentCount++
|
||||
const silentDays = Math.floor(silentMs / (24 * 60 * 60 * 1000))
|
||||
insightLog('INFO', `发现沉默联系人:${session.displayName || sessionId},已沉默 ${silentDays} 天`)
|
||||
|
||||
await this.generateInsightForSession({
|
||||
sessionId,
|
||||
displayName: session.displayName || session.username,
|
||||
triggerReason: 'silence',
|
||||
silentDays
|
||||
})
|
||||
}
|
||||
insightLog('INFO', `沉默扫描完成,共发现 ${silentCount} 个沉默联系人`)
|
||||
} catch (e) {
|
||||
insightLog('ERROR', `沉默扫描出错: ${(e as Error).message}`)
|
||||
} finally {
|
||||
this.processing = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── 活跃会话分析 ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 在 DB 变更防抖后执行,分析最近活跃的会话。
|
||||
*
|
||||
* 触发条件(必须同时满足):
|
||||
* 1. 会话有真正的新消息(lastTimestamp 比上次见到的更新)
|
||||
* 2. 该会话距上次活跃分析已超过冷却期
|
||||
*
|
||||
* 白名单启用时:直接使用白名单里的 sessionId,完全跳过 getSessions()。
|
||||
* 白名单未启用时:从缓存拉取全量会话后过滤私聊。
|
||||
*/
|
||||
private async analyzeRecentActivity(): Promise<void> {
|
||||
if (!this.isEnabled()) return
|
||||
if (this.processing) return
|
||||
|
||||
this.processing = true
|
||||
try {
|
||||
const now = Date.now()
|
||||
const cooldownMinutes = (this.config.get('aiInsightCooldownMinutes') as number) ?? 120
|
||||
const cooldownMs = cooldownMinutes * 60 * 1000
|
||||
const whitelistEnabled = this.config.get('aiInsightWhitelistEnabled') as boolean
|
||||
const whitelist = (this.config.get('aiInsightWhitelist') as string[]) || []
|
||||
|
||||
// 白名单启用且有勾选项时,直接用白名单 sessionId,无需查数据库全量会话列表。
|
||||
// 通过拉取该会话最新 1 条消息时间戳判断是否真正有新消息,开销极低。
|
||||
if (whitelistEnabled && whitelist.length > 0) {
|
||||
// 确保数据库已连接(首次时连接,之后复用)
|
||||
if (!this.dbConnected) {
|
||||
const connectResult = await chatService.connect()
|
||||
if (!connectResult.success) return
|
||||
this.dbConnected = true
|
||||
}
|
||||
|
||||
for (const sessionId of whitelist) {
|
||||
if (!sessionId || sessionId.endsWith('@chatroom')) continue
|
||||
|
||||
// 冷却期检查(先过滤,减少不必要的 DB 查询)
|
||||
if (cooldownMs > 0) {
|
||||
const lastAnalysis = this.lastActivityAnalysis.get(sessionId) ?? 0
|
||||
if (cooldownMs - (now - lastAnalysis) > 0) continue
|
||||
}
|
||||
|
||||
// 拉取最新 1 条消息,用时间戳判断是否有新消息,避免全量 getSessions()
|
||||
try {
|
||||
const msgsResult = await chatService.getLatestMessages(sessionId, 1)
|
||||
if (!msgsResult.success || !msgsResult.messages || msgsResult.messages.length === 0) continue
|
||||
|
||||
const latestMsg = msgsResult.messages[0]
|
||||
const latestTs = Number(latestMsg.createTime) || 0
|
||||
const lastSeen = this.lastSeenTimestamp.get(sessionId) ?? 0
|
||||
|
||||
if (latestTs <= lastSeen) continue // 没有新消息
|
||||
this.lastSeenTimestamp.set(sessionId, latestTs)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
insightLog('INFO', `白名单会话 ${sessionId} 有新消息,准备生成见解...`)
|
||||
this.lastActivityAnalysis.set(sessionId, now)
|
||||
|
||||
// displayName 使用白名单 sessionId,generateInsightForSession 内部会从上下文里获取真实名称
|
||||
await this.generateInsightForSession({
|
||||
sessionId,
|
||||
displayName: sessionId,
|
||||
triggerReason: 'activity'
|
||||
})
|
||||
break // 每次最多处理 1 个会话
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 白名单未启用:需要拉取全量会话列表,从中过滤私聊
|
||||
const sessions = await this.getSessionsCached()
|
||||
if (sessions.length === 0) return
|
||||
|
||||
const privateSessions = sessions.filter((s) => {
|
||||
const id = s.username?.trim() || ''
|
||||
return id && !id.endsWith('@chatroom') && !id.toLowerCase().includes('placeholder')
|
||||
})
|
||||
|
||||
for (const session of privateSessions.slice(0, 10)) {
|
||||
const sessionId = session.username?.trim() || ''
|
||||
if (!sessionId) continue
|
||||
|
||||
const currentTimestamp = session.lastTimestamp || 0
|
||||
const lastSeen = this.lastSeenTimestamp.get(sessionId) ?? 0
|
||||
if (currentTimestamp <= lastSeen) continue
|
||||
this.lastSeenTimestamp.set(sessionId, currentTimestamp)
|
||||
|
||||
if (cooldownMs > 0) {
|
||||
const lastAnalysis = this.lastActivityAnalysis.get(sessionId) ?? 0
|
||||
if (cooldownMs - (now - lastAnalysis) > 0) continue
|
||||
}
|
||||
|
||||
insightLog('INFO', `${session.displayName || sessionId} 有新消息,准备生成见解...`)
|
||||
this.lastActivityAnalysis.set(sessionId, now)
|
||||
|
||||
await this.generateInsightForSession({
|
||||
sessionId,
|
||||
displayName: session.displayName || session.username,
|
||||
triggerReason: 'activity'
|
||||
})
|
||||
break
|
||||
}
|
||||
} catch (e) {
|
||||
insightLog('ERROR', `活跃分析出错: ${(e as Error).message}`)
|
||||
} finally {
|
||||
this.processing = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── 核心见解生成 ────────────────────────────────────────────────────────────
|
||||
|
||||
private async generateInsightForSession(params: {
|
||||
sessionId: string
|
||||
displayName: string
|
||||
triggerReason: 'activity' | 'silence'
|
||||
silentDays?: number
|
||||
}): Promise<void> {
|
||||
const { sessionId, displayName, triggerReason, silentDays } = params
|
||||
if (!sessionId) return
|
||||
|
||||
const apiBaseUrl = this.config.get('aiInsightApiBaseUrl') as string
|
||||
const apiKey = this.config.get('aiInsightApiKey') as string
|
||||
const model = (this.config.get('aiInsightApiModel') as string) || 'gpt-4o-mini'
|
||||
const allowContext = this.config.get('aiInsightAllowContext') as boolean
|
||||
const contextCount = (this.config.get('aiInsightContextCount') as number) || 40
|
||||
|
||||
insightLog('INFO', `generateInsightForSession: sessionId=${sessionId}, reason=${triggerReason}, contextCount=${contextCount}, api=${apiBaseUrl ? '已配置' : '未配置'}`)
|
||||
|
||||
if (!apiBaseUrl || !apiKey) {
|
||||
insightLog('WARN', 'API 地址或 Key 未配置,跳过见解生成')
|
||||
return
|
||||
}
|
||||
|
||||
// ── 构建 prompt ─────────────<E29480><E29480><EFBFBD>───────────────────────────────<E29480><E29480><EFBFBD>────────────
|
||||
|
||||
// 今日触发统计(让模型具备时间与克制感)
|
||||
const sessionTriggerTimes = this.recordTrigger(sessionId)
|
||||
const totalTodayTriggers = this.getTodayTotalTriggerCount()
|
||||
|
||||
let contextSection = ''
|
||||
if (allowContext) {
|
||||
try {
|
||||
const msgsResult = await chatService.getLatestMessages(sessionId, contextCount)
|
||||
if (msgsResult.success && msgsResult.messages && msgsResult.messages.length > 0) {
|
||||
const messages: Message[] = msgsResult.messages
|
||||
const msgLines = messages.map((m) => {
|
||||
const sender = m.isSend === 1 ? '我' : (displayName || sessionId)
|
||||
const content = m.rawContent || m.parsedContent || '[非文字消息]'
|
||||
const time = new Date(Number(m.createTime) * 1000).toLocaleString('zh-CN')
|
||||
return `[${time}] ${sender}:${content}`
|
||||
})
|
||||
contextSection = `\n\n近期对话记录(最近 ${msgLines.length} 条):\n${msgLines.join('\n')}`
|
||||
insightLog('INFO', `已加载 ${msgLines.length} 条上下文消息`)
|
||||
}
|
||||
} catch (e) {
|
||||
insightLog('WARN', `拉取上下文失败: ${(e as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 默认 system prompt(稳定内容,有利于 provider 端 prompt cache 命中)────
|
||||
const DEFAULT_SYSTEM_PROMPT = `你是用户的私人关系观察助手,名叫"见解"。你的任务是主动提供有价值的观察和建议。
|
||||
|
||||
要求:
|
||||
1. 必须给出见解。基于聊天记录分析对方情绪、话题趋势、关系动态,或给出回复建议、聊天话题推荐。
|
||||
2. 控制在 80 字以内,直接、具体、一针见血。不要废话。
|
||||
3. 输出纯文本,不使用 Markdown。
|
||||
4. 只有在完全没有任何可说的内容时(比如对话只有一条"嗯"),才回复"SKIP"。绝大多数情况下你应该输出见解。`
|
||||
|
||||
// 优先使用用户自定义 prompt,为空则使用默认值
|
||||
const customPrompt = (this.config.get('aiInsightSystemPrompt') as string) || ''
|
||||
const systemPrompt = customPrompt.trim() || DEFAULT_SYSTEM_PROMPT
|
||||
|
||||
// 可变的上下文统计信息放在 user message 里,保持 system prompt 稳定不变
|
||||
// 这样 provider 端(Anthropic/OpenAI)能最大化命中 prompt cache,降低费用
|
||||
const triggerDesc =
|
||||
triggerReason === 'silence'
|
||||
? `你已经 ${silentDays} 天没有和「${displayName}」聊天了。`
|
||||
: `你最近和「${displayName}」有新的聊天动态。`
|
||||
|
||||
const todayStatsDesc =
|
||||
sessionTriggerTimes.length > 1
|
||||
? `今天你已经针对「${displayName}」收到过 ${sessionTriggerTimes.length - 1} 条见解(时间:${sessionTriggerTimes.slice(0, -1).join('、')}),请适当克制。`
|
||||
: `今天你还没有针对「${displayName}」发出过见解。`
|
||||
|
||||
const globalStatsDesc = `今天全部联系人合计已触发 ${totalTodayTriggers} 条见解。`
|
||||
|
||||
const userPrompt = `触发原因:${triggerDesc}
|
||||
时间统计:${todayStatsDesc} ${globalStatsDesc}${contextSection}
|
||||
|
||||
请给出你的见解(≤80字):`
|
||||
|
||||
const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions')
|
||||
insightLog('INFO', `准备调用 API: ${endpoint},模型: ${model}`)
|
||||
|
||||
try {
|
||||
const result = await callApi(
|
||||
apiBaseUrl,
|
||||
apiKey,
|
||||
model,
|
||||
[
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt }
|
||||
]
|
||||
)
|
||||
|
||||
insightLog('INFO', `API 返回原文: ${result.slice(0, 150)}`)
|
||||
|
||||
// 模型主动选择跳过
|
||||
if (result.trim().toUpperCase() === 'SKIP' || result.trim().startsWith('SKIP')) {
|
||||
insightLog('INFO', `模型选择跳过 ${displayName}`)
|
||||
return
|
||||
}
|
||||
|
||||
const insight = result.slice(0, 120)
|
||||
const notifTitle = `见解 · ${displayName}`
|
||||
|
||||
insightLog('INFO', `推送通知 → ${displayName}: ${insight}`)
|
||||
|
||||
// 渠道一:Electron 原生系统通知
|
||||
if (Notification.isSupported()) {
|
||||
const notif = new Notification({ title: notifTitle, body: insight, silent: false })
|
||||
notif.show()
|
||||
} else {
|
||||
insightLog('WARN', '当前系统不支持原生通知')
|
||||
}
|
||||
|
||||
// 渠道二:Telegram Bot 推送(可选)
|
||||
const telegramEnabled = this.config.get('aiInsightTelegramEnabled') as boolean
|
||||
if (telegramEnabled) {
|
||||
const telegramToken = (this.config.get('aiInsightTelegramToken') as string) || ''
|
||||
const telegramChatIds = (this.config.get('aiInsightTelegramChatIds') as string) || ''
|
||||
if (telegramToken && telegramChatIds) {
|
||||
const chatIds = telegramChatIds.split(',').map((s) => s.trim()).filter(Boolean)
|
||||
const telegramText = `【WeFlow】 ${notifTitle}\n\n${insight}`
|
||||
for (const chatId of chatIds) {
|
||||
this.sendTelegram(telegramToken, chatId, telegramText).catch((e) => {
|
||||
insightLog('WARN', `Telegram 推送失败 (chatId=${chatId}): ${(e as Error).message}`)
|
||||
})
|
||||
}
|
||||
} else {
|
||||
insightLog('WARN', 'Telegram 已启用但 Token 或 Chat ID 未填写,跳过')
|
||||
}
|
||||
}
|
||||
|
||||
insightLog('INFO', `已为 ${displayName} 推送见解`)
|
||||
} catch (e) {
|
||||
insightLog('ERROR', `API 调用失败 (${displayName}): ${(e as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 Telegram Bot API 发送消息。
|
||||
* 使用 Node 原生 https 模块,无需第三方依赖。
|
||||
*/
|
||||
private sendTelegram(token: string, chatId: string, text: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const body = JSON.stringify({ chat_id: chatId, text, parse_mode: 'HTML' })
|
||||
const options = {
|
||||
hostname: 'api.telegram.org',
|
||||
port: 443,
|
||||
path: `/bot${token}/sendMessage`,
|
||||
method: 'POST' as const,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(body).toString()
|
||||
}
|
||||
}
|
||||
const req = https.request(options, (res) => {
|
||||
let data = ''
|
||||
res.on('data', (chunk) => { data += chunk })
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const parsed = JSON.parse(data)
|
||||
if (parsed.ok) {
|
||||
resolve()
|
||||
} else {
|
||||
reject(new Error(parsed.description || '未知错误'))
|
||||
}
|
||||
} catch {
|
||||
reject(new Error(`响应解析失败: ${data.slice(0, 100)}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
req.setTimeout(15_000, () => { req.destroy(); reject(new Error('Telegram 请求超时')) })
|
||||
req.on('error', reject)
|
||||
req.write(body)
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const insightService = new InsightService()
|
||||
@@ -61,6 +61,7 @@ export class KeyService {
|
||||
|
||||
private getDllPath(): string {
|
||||
const isPackaged = typeof app !== 'undefined' && app ? app.isPackaged : process.env.NODE_ENV === 'production'
|
||||
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||
const candidates: string[] = []
|
||||
|
||||
if (process.env.WX_KEY_DLL_PATH) {
|
||||
@@ -68,11 +69,20 @@ export class KeyService {
|
||||
}
|
||||
|
||||
if (isPackaged) {
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'win32', archDir, 'wx_key.dll'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'win32', 'x64', 'wx_key.dll'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'win32', 'wx_key.dll'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'wx_key.dll'))
|
||||
candidates.push(join(process.resourcesPath, 'wx_key.dll'))
|
||||
} else {
|
||||
const cwd = process.cwd()
|
||||
candidates.push(join(cwd, 'resources', 'key', 'win32', archDir, 'wx_key.dll'))
|
||||
candidates.push(join(cwd, 'resources', 'key', 'win32', 'x64', 'wx_key.dll'))
|
||||
candidates.push(join(cwd, 'resources', 'key', 'win32', 'wx_key.dll'))
|
||||
candidates.push(join(cwd, 'resources', 'wx_key.dll'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'win32', archDir, 'wx_key.dll'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'win32', 'x64', 'wx_key.dll'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'win32', 'wx_key.dll'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'wx_key.dll'))
|
||||
}
|
||||
|
||||
@@ -606,34 +616,14 @@ export class KeyService {
|
||||
|
||||
const logs: string[] = []
|
||||
|
||||
onStatus?.('正在定位微信安装路径...', 0)
|
||||
let wechatPath = await this.findWeChatInstallPath()
|
||||
if (!wechatPath) {
|
||||
const err = '未找到微信安装路径,请确认已安装PC微信'
|
||||
onStatus?.('正在查找微信进程...', 0)
|
||||
const pid = await this.findWeChatPid()
|
||||
if (!pid) {
|
||||
const err = '未找到微信进程,请先启动微信'
|
||||
onStatus?.(err, 2)
|
||||
return { success: false, error: err }
|
||||
}
|
||||
|
||||
onStatus?.('正在关闭微信以进行获取...', 0)
|
||||
const closed = await this.killWeChatProcesses()
|
||||
if (!closed) {
|
||||
const err = '无法自动关闭微信,请手动退出后重试'
|
||||
onStatus?.(err, 2)
|
||||
return { success: false, error: err }
|
||||
}
|
||||
|
||||
onStatus?.('正在启动微信...', 0)
|
||||
const sub = spawn(wechatPath, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
cwd: dirname(wechatPath)
|
||||
})
|
||||
sub.unref()
|
||||
|
||||
onStatus?.('等待微信界面就绪...', 0)
|
||||
const pid = await this.waitForWeChatWindow()
|
||||
if (!pid) return { success: false, error: '启动微信失败或等待界面就绪超时' }
|
||||
|
||||
onStatus?.(`检测到微信窗口 (PID: ${pid}),正在获取...`, 0)
|
||||
onStatus?.('正在检测微信界面组件...', 0)
|
||||
await this.waitForWeChatWindowComponents(pid, 15000)
|
||||
@@ -704,10 +694,7 @@ export class KeyService {
|
||||
return { success: false, error: '获取密钥超时', logs }
|
||||
}
|
||||
|
||||
// --- Image Key (通过 DLL 从缓存目录获取 code,用前端 wxid 计算密钥) ---
|
||||
|
||||
private cleanWxid(wxid: string): string {
|
||||
// 截断到第二个下划线: wxid_g4pshorcc0r529_da6c → wxid_g4pshorcc0r529
|
||||
const first = wxid.indexOf('_')
|
||||
if (first === -1) return wxid
|
||||
const second = wxid.indexOf('_', first + 1)
|
||||
|
||||
374
electron/services/keyServiceLinux.ts
Normal file
374
electron/services/keyServiceLinux.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
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('@vscode/sudo-prompt');
|
||||
} catch (e) {
|
||||
console.error('Failed to load @vscode/sudo-prompt', e);
|
||||
}
|
||||
}
|
||||
|
||||
private getHelperPath(): string {
|
||||
const isPackaged = app.isPackaged
|
||||
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||
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', 'key', 'linux', archDir, 'xkey_helper_linux'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'linux', 'x64', 'xkey_helper_linux'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'linux', 'xkey_helper_linux'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'xkey_helper_linux'))
|
||||
candidates.push(join(process.resourcesPath, 'xkey_helper_linux'))
|
||||
} else {
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'linux', archDir, 'xkey_helper_linux'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'linux', 'x64', 'xkey_helper_linux'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'linux', 'xkey_helper_linux'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'xkey_helper_linux'))
|
||||
candidates.push(join(process.cwd(), 'resources', 'key', 'linux', archDir, 'xkey_helper_linux'))
|
||||
candidates.push(join(process.cwd(), 'resources', 'key', 'linux', 'x64', 'xkey_helper_linux'))
|
||||
candidates.push(join(process.cwd(), 'resources', 'key', 'linux', '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 }
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ export class KeyServiceMac {
|
||||
|
||||
private getHelperPath(): string {
|
||||
const isPackaged = app.isPackaged
|
||||
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||
const candidates: string[] = []
|
||||
|
||||
if (process.env.WX_KEY_HELPER_PATH) {
|
||||
@@ -34,12 +35,21 @@ export class KeyServiceMac {
|
||||
}
|
||||
|
||||
if (isPackaged) {
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', archDir, 'xkey_helper'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'universal', 'xkey_helper'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'xkey_helper'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'xkey_helper'))
|
||||
candidates.push(join(process.resourcesPath, 'xkey_helper'))
|
||||
} else {
|
||||
const cwd = process.cwd()
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', archDir, 'xkey_helper'))
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', 'universal', 'xkey_helper'))
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', 'xkey_helper'))
|
||||
candidates.push(join(cwd, 'resources', 'xkey_helper'))
|
||||
candidates.push(join(cwd, 'Xkey', 'build', 'xkey_helper'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', archDir, 'xkey_helper'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'universal', 'xkey_helper'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'xkey_helper'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'xkey_helper'))
|
||||
}
|
||||
|
||||
@@ -52,14 +62,24 @@ export class KeyServiceMac {
|
||||
|
||||
private getImageScanHelperPath(): string {
|
||||
const isPackaged = app.isPackaged
|
||||
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||
const candidates: string[] = []
|
||||
|
||||
if (isPackaged) {
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', archDir, 'image_scan_helper'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'universal', 'image_scan_helper'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'image_scan_helper'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'image_scan_helper'))
|
||||
candidates.push(join(process.resourcesPath, 'image_scan_helper'))
|
||||
} else {
|
||||
const cwd = process.cwd()
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', archDir, 'image_scan_helper'))
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', 'universal', 'image_scan_helper'))
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', 'image_scan_helper'))
|
||||
candidates.push(join(cwd, 'resources', 'image_scan_helper'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', archDir, 'image_scan_helper'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'universal', 'image_scan_helper'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'image_scan_helper'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'image_scan_helper'))
|
||||
}
|
||||
|
||||
@@ -72,6 +92,7 @@ export class KeyServiceMac {
|
||||
|
||||
private getDylibPath(): string {
|
||||
const isPackaged = app.isPackaged
|
||||
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||
const candidates: string[] = []
|
||||
|
||||
if (process.env.WX_KEY_DYLIB_PATH) {
|
||||
@@ -79,11 +100,20 @@ export class KeyServiceMac {
|
||||
}
|
||||
|
||||
if (isPackaged) {
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', archDir, 'libwx_key.dylib'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'universal', 'libwx_key.dylib'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'key', 'macos', 'libwx_key.dylib'))
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'libwx_key.dylib'))
|
||||
candidates.push(join(process.resourcesPath, 'libwx_key.dylib'))
|
||||
} else {
|
||||
const cwd = process.cwd()
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', archDir, 'libwx_key.dylib'))
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', 'universal', 'libwx_key.dylib'))
|
||||
candidates.push(join(cwd, 'resources', 'key', 'macos', 'libwx_key.dylib'))
|
||||
candidates.push(join(cwd, 'resources', 'libwx_key.dylib'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', archDir, 'libwx_key.dylib'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'universal', 'libwx_key.dylib'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'key', 'macos', 'libwx_key.dylib'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'libwx_key.dylib'))
|
||||
}
|
||||
|
||||
@@ -116,11 +146,30 @@ export class KeyServiceMac {
|
||||
}
|
||||
}
|
||||
|
||||
private async checkSipStatus(): Promise<{ enabled: boolean; error?: string }> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('/usr/bin/csrutil', ['status'])
|
||||
const enabled = stdout.toLowerCase().includes('enabled')
|
||||
return { enabled }
|
||||
} catch (e: any) {
|
||||
return { enabled: false, error: e.message }
|
||||
}
|
||||
}
|
||||
|
||||
async autoGetDbKey(
|
||||
timeoutMs = 60_000,
|
||||
onStatus?: (message: string, level: number) => void
|
||||
): Promise<DbKeyResult> {
|
||||
try {
|
||||
// 检测 SIP 状态
|
||||
const sipStatus = await this.checkSipStatus()
|
||||
if (sipStatus.enabled) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'SIP (系统完整性保护) 已开启,无法获取密钥。请关闭 SIP 后重试。\n\n关闭方法:\n1. Intel 芯片:重启 Mac 并按住 Command + R 进入恢复模式\n2. Apple 芯片(M 系列):关机后长按开机(指纹)键,选择“设置(选项)”进入恢复模式\n3. 打开终端,输入: csrutil disable\n4. 重启电脑'
|
||||
}
|
||||
}
|
||||
|
||||
onStatus?.('正在获取数据库密钥...', 0)
|
||||
onStatus?.('正在请求管理员授权并执行 helper...', 0)
|
||||
let parsed: { success: boolean; key?: string; code?: string; detail?: string; raw: string }
|
||||
@@ -243,6 +292,7 @@ export class KeyServiceMac {
|
||||
): Promise<string> {
|
||||
const helperPath = this.getHelperPath()
|
||||
const waitMs = Math.max(timeoutMs, 30_000)
|
||||
const timeoutSec = Math.ceil(waitMs / 1000) + 30
|
||||
const pid = await this.getWeChatPid()
|
||||
onStatus?.(`已找到微信进程 PID=${pid},正在定位目标函数...`, 0)
|
||||
// 最佳努力清理同路径残留 helper(普通权限)
|
||||
@@ -359,12 +409,22 @@ export class KeyServiceMac {
|
||||
): Promise<string> {
|
||||
const helperPath = this.getHelperPath()
|
||||
const waitMs = Math.max(timeoutMs, 30_000)
|
||||
const timeoutSec = Math.ceil(waitMs / 1000) + 30
|
||||
const pid = await this.getWeChatPid()
|
||||
// 用 AppleScript 的 quoted form 组装命令,避免复杂 shell 拼接导致整条失败
|
||||
// 通过 try/on error 回传详细错误,避免只看到 "Command failed"
|
||||
const scriptLines = [
|
||||
`set helperPath to ${JSON.stringify(helperPath)}`,
|
||||
`set cmd to quoted form of helperPath & " ${pid} ${waitMs}"`,
|
||||
'do shell script cmd with administrator privileges'
|
||||
`set timeoutSec to ${timeoutSec}`,
|
||||
'try',
|
||||
'with timeout of timeoutSec seconds',
|
||||
'set outText to do shell script (cmd & " 2>&1") with administrator privileges',
|
||||
'end timeout',
|
||||
'return "WF_OK::" & outText',
|
||||
'on error errMsg number errNum partial result pr',
|
||||
'return "WF_ERR::" & errNum & "::" & errMsg & "::" & (pr as text)',
|
||||
'end try'
|
||||
]
|
||||
onStatus?.('已准备就绪,现在登录微信或退出登录后重新登录微信', 0)
|
||||
|
||||
@@ -380,18 +440,37 @@ export class KeyServiceMac {
|
||||
}
|
||||
|
||||
const lines = String(stdout).split(/\r?\n/).map(x => x.trim()).filter(Boolean)
|
||||
const last = lines[lines.length - 1]
|
||||
if (!last) throw new Error('elevated helper returned empty output')
|
||||
if (!lines.length) throw new Error('elevated helper returned empty output')
|
||||
const joined = lines.join('\n')
|
||||
|
||||
let payload: any
|
||||
try {
|
||||
payload = JSON.parse(last)
|
||||
} catch {
|
||||
throw new Error('elevated helper returned invalid json: ' + last)
|
||||
if (joined.startsWith('WF_ERR::')) {
|
||||
const parts = joined.split('::')
|
||||
const errNum = parts[1] || 'unknown'
|
||||
const errMsg = parts[2] || 'unknown'
|
||||
const partial = parts.slice(3).join('::')
|
||||
throw new Error(`elevated helper failed: errNum=${errNum}, errMsg=${errMsg}, partial=${partial || '(empty)'}`)
|
||||
}
|
||||
if (payload?.success === true && typeof payload?.key === 'string') return payload.key
|
||||
if (typeof payload?.result === 'string') return payload.result
|
||||
throw new Error('elevated helper json missing key/result')
|
||||
const normalizedOutput = joined.startsWith('WF_OK::') ? joined.slice('WF_OK::'.length) : joined
|
||||
|
||||
// 从所有行里提取所有 JSON 对象(同一行可能有多个拼接),找含 key/result 的那个
|
||||
const extractJsonObjects = (s: string): any[] => {
|
||||
const results: any[] = []
|
||||
const re = /\{[^{}]*\}/g
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = re.exec(s)) !== null) {
|
||||
try { results.push(JSON.parse(m[0])) } catch { }
|
||||
}
|
||||
return results
|
||||
}
|
||||
const fullOutput = normalizedOutput
|
||||
const allJson = extractJsonObjects(fullOutput)
|
||||
// 优先找 success=true && key 字段
|
||||
const successPayload = allJson.find(p => p?.success === true && typeof p?.key === 'string')
|
||||
if (successPayload) return successPayload.key
|
||||
// 其次找 result 字段
|
||||
const resultPayload = allJson.find(p => typeof p?.result === 'string')
|
||||
if (resultPayload) return resultPayload.result
|
||||
throw new Error('elevated helper returned invalid json: ' + lines[lines.length - 1])
|
||||
}
|
||||
|
||||
private mapDbKeyErrorMessage(code?: string, detail?: string): string {
|
||||
@@ -479,26 +558,39 @@ export class KeyServiceMac {
|
||||
|
||||
const wxidCandidates = this.collectWxidCandidates(accountPath, wxid)
|
||||
if (wxidCandidates.length === 0) {
|
||||
return { success: false, error: '未找到可用的 wxid 候选,请先选择正确的账号目录' }
|
||||
return { success: false, error: '未找到可用的账号候选,请先选择正确的账号目录' }
|
||||
}
|
||||
|
||||
const accountPathCandidates = this.collectAccountPathCandidates(accountPath)
|
||||
|
||||
// 使用模板密文做验真,避免 wxid 不匹配导致快速方案算错
|
||||
let verifyCiphertext: Buffer | null = null
|
||||
if (accountPath && existsSync(accountPath)) {
|
||||
const template = await this._findTemplateData(accountPath, 32)
|
||||
verifyCiphertext = template.ciphertext
|
||||
}
|
||||
if (verifyCiphertext) {
|
||||
if (accountPathCandidates.length > 0) {
|
||||
onStatus?.(`正在校验候选 wxid(${wxidCandidates.length} 个)...`)
|
||||
for (const candidateWxid of wxidCandidates) {
|
||||
for (const code of codes) {
|
||||
const { xorKey, aesKey } = this.deriveImageKeys(code, candidateWxid)
|
||||
if (!this.verifyDerivedAesKey(aesKey, verifyCiphertext)) continue
|
||||
onStatus?.(`密钥获取成功 (wxid: ${candidateWxid}, code: ${code})`)
|
||||
return { success: true, xorKey, aesKey }
|
||||
for (const candidateAccountPath of accountPathCandidates) {
|
||||
if (!existsSync(candidateAccountPath)) continue
|
||||
const template = await this._findTemplateData(candidateAccountPath, 32)
|
||||
if (!template.ciphertext) continue
|
||||
|
||||
const accountDirWxid = basename(candidateAccountPath)
|
||||
const orderedWxids: string[] = []
|
||||
this.pushAccountIdCandidates(orderedWxids, accountDirWxid)
|
||||
for (const candidate of wxidCandidates) {
|
||||
this.pushAccountIdCandidates(orderedWxids, candidate)
|
||||
}
|
||||
|
||||
for (const candidateWxid of orderedWxids) {
|
||||
for (const code of codes) {
|
||||
const { xorKey, aesKey } = this.deriveImageKeys(code, candidateWxid)
|
||||
if (!this.verifyDerivedAesKey(aesKey, template.ciphertext)) continue
|
||||
onStatus?.(`密钥获取成功 (wxid: ${candidateWxid}, code: ${code})`)
|
||||
return { success: true, xorKey, aesKey }
|
||||
}
|
||||
}
|
||||
}
|
||||
return { success: false, error: '缓存 code 与当前账号 wxid 未匹配,请确认账号目录后重试,或使用内存扫描' }
|
||||
return {
|
||||
success: false,
|
||||
error: '缓存 code 与当前账号 wxid 未匹配。若数据库密钥获取后微信刚刚崩溃并重启,可能当前选中的账号目录已经不是最新会话;请先重新扫描 wxid,或直接使用内存扫描。'
|
||||
}
|
||||
}
|
||||
|
||||
// 无法获取模板密文时,回退为历史策略(优先级最高候选 + 第一条 code)
|
||||
@@ -533,16 +625,21 @@ export class KeyServiceMac {
|
||||
|
||||
onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`)
|
||||
|
||||
// 2. 找微信 PID
|
||||
const pid = await this.findWeChatPid()
|
||||
if (!pid) return { success: false, error: '微信进程未运行,请先启动微信' }
|
||||
|
||||
onProgress?.(`已找到微信进程 PID=${pid},正在扫描内存...`)
|
||||
|
||||
// 3. 持续轮询内存扫描
|
||||
// 2. 持续轮询微信 PID 与内存扫描,兼容微信崩溃后重启 PID 变化
|
||||
const deadline = Date.now() + 60_000
|
||||
let scanCount = 0
|
||||
let lastPid: number | null = null
|
||||
while (Date.now() < deadline) {
|
||||
const pid = await this.findWeChatPid()
|
||||
if (!pid) {
|
||||
onProgress?.('暂未检测到微信主进程,请确认微信已经重新打开...')
|
||||
await new Promise(r => setTimeout(r, 2000))
|
||||
continue
|
||||
}
|
||||
if (lastPid !== pid) {
|
||||
lastPid = pid
|
||||
onProgress?.(`已找到微信进程 PID=${pid},正在扫描内存...`)
|
||||
}
|
||||
scanCount++
|
||||
onProgress?.(`第 ${scanCount} 次扫描内存,请在微信中打开图片大图...`)
|
||||
const aesKey = await this._scanMemoryForAesKey(pid, ciphertext, onProgress)
|
||||
@@ -755,7 +852,7 @@ export class KeyServiceMac {
|
||||
}
|
||||
|
||||
const current = chunk.subarray(0, bytesRead)
|
||||
const data = trailing ? Buffer.concat([trailing, current]) : current
|
||||
const data: Buffer = trailing ? Buffer.concat([trailing, current]) : current
|
||||
const key = this._searchAsciiKey(data, ciphertext) || this._searchUtf16Key(data, ciphertext)
|
||||
if (key) return key
|
||||
// 兜底:兼容旧 C++ 的滑窗 16-byte 扫描(严格规则 miss 时仍可命中)
|
||||
@@ -784,8 +881,8 @@ export class KeyServiceMac {
|
||||
}
|
||||
const tag = elevated ? '[image_scan_helper:elevated]' : '[image_scan_helper]'
|
||||
let stdout = '', stderr = ''
|
||||
child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString() })
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
child.stdout?.on('data', (chunk: Buffer) => { stdout += chunk.toString() })
|
||||
child.stderr?.on('data', (chunk: Buffer) => {
|
||||
stderr += chunk.toString()
|
||||
console.log(tag, chunk.toString().trim())
|
||||
})
|
||||
@@ -810,11 +907,8 @@ export class KeyServiceMac {
|
||||
}
|
||||
|
||||
private async findWeChatPid(): Promise<number | null> {
|
||||
const { execSync } = await import('child_process')
|
||||
try {
|
||||
const output = execSync('pgrep -x WeChat', { encoding: 'utf8' })
|
||||
const pid = parseInt(output.trim())
|
||||
return isNaN(pid) ? null : pid
|
||||
return await this.getWeChatPid()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
@@ -831,12 +925,77 @@ export class KeyServiceMac {
|
||||
this.machPortDeallocate = null
|
||||
}
|
||||
|
||||
private normalizeAccountId(value: string): 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
|
||||
}
|
||||
|
||||
private isIgnoredAccountName(value: string): boolean {
|
||||
const lowered = String(value || '').trim().toLowerCase()
|
||||
if (!lowered) return true
|
||||
return lowered === 'xwechat_files' ||
|
||||
lowered === 'all_users' ||
|
||||
lowered === 'backup' ||
|
||||
lowered === 'wmpf' ||
|
||||
lowered === 'app_data'
|
||||
}
|
||||
|
||||
private isReasonableAccountId(value: string): boolean {
|
||||
const trimmed = String(value || '').trim()
|
||||
if (!trimmed) return false
|
||||
if (trimmed.includes('/') || trimmed.includes('\\')) return false
|
||||
return !this.isIgnoredAccountName(trimmed)
|
||||
}
|
||||
|
||||
private isAccountDirPath(entryPath: string): boolean {
|
||||
return existsSync(join(entryPath, 'db_storage')) ||
|
||||
existsSync(join(entryPath, 'msg')) ||
|
||||
existsSync(join(entryPath, 'FileStorage', 'Image')) ||
|
||||
existsSync(join(entryPath, 'FileStorage', 'Image2'))
|
||||
}
|
||||
|
||||
private resolveXwechatRootFromPath(accountPath?: string): string | null {
|
||||
const normalized = String(accountPath || '').replace(/\\/g, '/').replace(/\/+$/, '')
|
||||
if (!normalized) return null
|
||||
|
||||
// 旧路径:xwechat_files
|
||||
const marker = '/xwechat_files'
|
||||
const markerIdx = normalized.indexOf(marker)
|
||||
if (markerIdx >= 0) return normalized.slice(0, markerIdx + marker.length)
|
||||
|
||||
// 新路径(微信 4.0.5+):Application Support/com.tencent.xinWeChat/2.0b4.0.9
|
||||
const newMarkerMatch = normalized.match(/^(.*\/com\.tencent\.xinWeChat\/(?:\d+\.\d+b\d+\.\d+|\d+\.\d+\.\d+))(\/|$)/)
|
||||
if (newMarkerMatch) return newMarkerMatch[1]
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private pushAccountIdCandidates(candidates: string[], value?: string): void {
|
||||
const pushUnique = (item: string) => {
|
||||
const trimmed = String(item || '').trim()
|
||||
if (!trimmed || candidates.includes(trimmed)) return
|
||||
candidates.push(trimmed)
|
||||
}
|
||||
|
||||
const raw = String(value || '').trim()
|
||||
if (!this.isReasonableAccountId(raw)) return
|
||||
pushUnique(raw)
|
||||
const normalized = this.normalizeAccountId(raw)
|
||||
if (normalized && normalized !== raw && this.isReasonableAccountId(normalized)) {
|
||||
pushUnique(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
private cleanWxid(wxid: string): string {
|
||||
const first = wxid.indexOf('_')
|
||||
if (first === -1) return wxid
|
||||
const second = wxid.indexOf('_', first + 1)
|
||||
if (second === -1) return wxid
|
||||
return wxid.substring(0, second)
|
||||
return this.normalizeAccountId(wxid)
|
||||
}
|
||||
|
||||
private deriveImageKeys(code: number, wxid: string): { xorKey: number; aesKey: string } {
|
||||
@@ -849,32 +1008,59 @@ export class KeyServiceMac {
|
||||
|
||||
private collectWxidCandidates(accountPath?: string, wxidParam?: string): string[] {
|
||||
const candidates: string[] = []
|
||||
const pushUnique = (value: string) => {
|
||||
const v = String(value || '').trim()
|
||||
if (!v || candidates.includes(v)) return
|
||||
candidates.push(v)
|
||||
}
|
||||
|
||||
// 1) 显式传参优先
|
||||
if (wxidParam && wxidParam.startsWith('wxid_')) pushUnique(wxidParam)
|
||||
this.pushAccountIdCandidates(candidates, wxidParam)
|
||||
|
||||
if (accountPath) {
|
||||
const normalized = accountPath.replace(/\\/g, '/').replace(/\/+$/, '')
|
||||
const dirName = basename(normalized)
|
||||
// 2) 当前目录名为 wxid_*
|
||||
if (dirName.startsWith('wxid_')) pushUnique(dirName)
|
||||
// 2) 当前目录名本身就是账号目录
|
||||
this.pushAccountIdCandidates(candidates, dirName)
|
||||
|
||||
// 3) 从 xwechat_files 根目录枚举全部 wxid_* 目录
|
||||
const marker = '/xwechat_files'
|
||||
const markerIdx = normalized.indexOf(marker)
|
||||
if (markerIdx >= 0) {
|
||||
const root = normalized.slice(0, markerIdx + marker.length)
|
||||
// 3) 从 xwechat_files 根目录枚举全部账号目录
|
||||
const root = this.resolveXwechatRootFromPath(accountPath)
|
||||
if (root) {
|
||||
if (existsSync(root)) {
|
||||
try {
|
||||
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue
|
||||
if (!entry.name.startsWith('wxid_')) continue
|
||||
pushUnique(entry.name)
|
||||
const entryPath = join(root, entry.name)
|
||||
if (!this.isAccountDirPath(entryPath)) continue
|
||||
this.pushAccountIdCandidates(candidates, entry.name)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.length === 0) candidates.push('unknown')
|
||||
return candidates
|
||||
}
|
||||
|
||||
private collectAccountPathCandidates(accountPath?: string): string[] {
|
||||
const candidates: string[] = []
|
||||
const pushUnique = (value?: string) => {
|
||||
const v = String(value || '').trim()
|
||||
if (!v || candidates.includes(v)) return
|
||||
candidates.push(v)
|
||||
}
|
||||
|
||||
if (accountPath) pushUnique(accountPath)
|
||||
|
||||
if (accountPath) {
|
||||
const root = this.resolveXwechatRootFromPath(accountPath)
|
||||
if (root) {
|
||||
if (existsSync(root)) {
|
||||
try {
|
||||
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue
|
||||
const entryPath = join(root, entry.name)
|
||||
if (!this.isAccountDirPath(entryPath)) continue
|
||||
if (!this.isReasonableAccountId(entry.name)) continue
|
||||
pushUnique(entryPath)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -883,7 +1069,6 @@ export class KeyServiceMac {
|
||||
}
|
||||
}
|
||||
|
||||
pushUnique('unknown')
|
||||
return candidates
|
||||
}
|
||||
|
||||
@@ -948,6 +1133,16 @@ export class KeyServiceMac {
|
||||
candidates.add(`${base}/app_data/net/kvcomm`)
|
||||
}
|
||||
|
||||
// 微信 4.0.5+ 新路径推导:版本目录同级的 net/kvcomm
|
||||
const newMarkerMatch = normalized.match(/^(.*\/com\.tencent\.xinWeChat\/(?:\d+\.\d+b\d+\.\d+|\d+\.\d+\.\d+))/)
|
||||
if (newMarkerMatch) {
|
||||
const versionBase = newMarkerMatch[1]
|
||||
candidates.add(`${versionBase}/net/kvcomm`)
|
||||
// 上级目录也尝试
|
||||
const parentBase = versionBase.replace(/\/[^\/]+$/, '')
|
||||
candidates.add(`${parentBase}/net/kvcomm`)
|
||||
}
|
||||
|
||||
let cursor = accountPath
|
||||
for (let i = 0; i < 6; i++) {
|
||||
candidates.add(join(cursor, 'net', 'kvcomm'))
|
||||
|
||||
250
electron/services/linuxNotificationService.ts
Normal file
250
electron/services/linuxNotificationService.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import https from "https";
|
||||
import http, { IncomingMessage } from "http";
|
||||
import { promises as fs } from "fs";
|
||||
import { join } from "path";
|
||||
import { app, Notification } from "electron";
|
||||
|
||||
export interface LinuxNotificationData {
|
||||
sessionId?: string;
|
||||
title: string;
|
||||
content: string;
|
||||
avatarUrl?: string;
|
||||
expireTimeout?: number;
|
||||
}
|
||||
|
||||
type NotificationCallback = (sessionId: string) => void;
|
||||
|
||||
let notificationCallbacks: NotificationCallback[] = [];
|
||||
let notificationCounter = 1;
|
||||
const activeNotifications: Map<number, Notification> = new Map();
|
||||
const closeTimers: Map<number, NodeJS.Timeout> = new Map();
|
||||
|
||||
// 头像缓存:url->localFilePath
|
||||
const avatarCache: Map<string, string> = new Map();
|
||||
// 缓存目录
|
||||
let avatarCacheDir: string | null = null;
|
||||
|
||||
function nextNotificationId(): number {
|
||||
const id = notificationCounter;
|
||||
notificationCounter += 1;
|
||||
return id;
|
||||
}
|
||||
|
||||
function clearNotificationState(notificationId: number): void {
|
||||
activeNotifications.delete(notificationId);
|
||||
const timer = closeTimers.get(notificationId);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
closeTimers.delete(notificationId);
|
||||
}
|
||||
}
|
||||
|
||||
// 确保缓存目录存在
|
||||
async function ensureCacheDir(): Promise<string> {
|
||||
if (!avatarCacheDir) {
|
||||
avatarCacheDir = join(app.getPath("temp"), "weflow-avatars");
|
||||
try {
|
||||
await fs.mkdir(avatarCacheDir, { recursive: true });
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[LinuxNotification] Failed to create avatar cache dir:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
return avatarCacheDir;
|
||||
}
|
||||
|
||||
// 下载头像到本地临时文件
|
||||
async function downloadAvatarToLocal(url: string): Promise<string | null> {
|
||||
// 检查缓存
|
||||
if (avatarCache.has(url)) {
|
||||
return avatarCache.get(url) || null;
|
||||
}
|
||||
|
||||
try {
|
||||
const cacheDir = await ensureCacheDir();
|
||||
// 生成唯一文件名
|
||||
const fileName = `avatar_${Date.now()}_${Math.random().toString(36).substring(2, 8)}.png`;
|
||||
const localPath = join(cacheDir, fileName);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
// 微信 CDN 需要特殊的请求头才能下载图片
|
||||
const options = {
|
||||
headers: {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351",
|
||||
Referer: "https://servicewechat.com/",
|
||||
Accept:
|
||||
"image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
|
||||
"Accept-Encoding": "gzip, deflate, br",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
};
|
||||
|
||||
const callback = (res: IncomingMessage) => {
|
||||
if (res.statusCode !== 200) {
|
||||
reject(new Error(`HTTP ${res.statusCode}`));
|
||||
return;
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
res.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on("end", async () => {
|
||||
try {
|
||||
const buffer = Buffer.concat(chunks);
|
||||
await fs.writeFile(localPath, buffer);
|
||||
avatarCache.set(url, localPath);
|
||||
resolve();
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
res.on("error", reject);
|
||||
};
|
||||
|
||||
const req = url.startsWith("https")
|
||||
? https.get(url, options, callback)
|
||||
: http.get(url, options, callback);
|
||||
|
||||
req.on("error", reject);
|
||||
req.setTimeout(10000, () => {
|
||||
req.destroy();
|
||||
reject(new Error("Download timeout"));
|
||||
});
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[LinuxNotification] Avatar downloaded: ${url} -> ${localPath}`,
|
||||
);
|
||||
return localPath;
|
||||
} catch (error) {
|
||||
console.error("[LinuxNotification] Failed to download avatar:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function triggerNotificationCallback(sessionId: string): void {
|
||||
for (const callback of notificationCallbacks) {
|
||||
try {
|
||||
callback(sessionId);
|
||||
} catch (error) {
|
||||
console.error("[LinuxNotification] Callback error:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function showLinuxNotification(
|
||||
data: LinuxNotificationData,
|
||||
): Promise<number | null> {
|
||||
if (process.platform !== "linux") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!Notification.isSupported()) {
|
||||
console.warn("[LinuxNotification] Notification API is not supported");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
let iconPath: string | undefined;
|
||||
if (data.avatarUrl) {
|
||||
iconPath = (await downloadAvatarToLocal(data.avatarUrl)) || undefined;
|
||||
}
|
||||
|
||||
const notification = new Notification({
|
||||
title: data.title,
|
||||
body: data.content,
|
||||
icon: iconPath,
|
||||
});
|
||||
|
||||
const notificationId = nextNotificationId();
|
||||
activeNotifications.set(notificationId, notification);
|
||||
|
||||
notification.on("click", () => {
|
||||
if (data.sessionId) {
|
||||
triggerNotificationCallback(data.sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
notification.on("close", () => {
|
||||
clearNotificationState(notificationId);
|
||||
});
|
||||
|
||||
notification.on("failed", (_, error) => {
|
||||
console.error("[LinuxNotification] Notification failed:", error);
|
||||
clearNotificationState(notificationId);
|
||||
});
|
||||
|
||||
const expireTimeout = data.expireTimeout ?? 5000;
|
||||
if (expireTimeout > 0) {
|
||||
const timer = setTimeout(() => {
|
||||
const currentNotification = activeNotifications.get(notificationId);
|
||||
if (currentNotification) {
|
||||
currentNotification.close();
|
||||
}
|
||||
}, expireTimeout);
|
||||
closeTimers.set(notificationId, timer);
|
||||
}
|
||||
|
||||
notification.show();
|
||||
|
||||
console.log(
|
||||
`[LinuxNotification] Shown notification ${notificationId}: ${data.title}`,
|
||||
);
|
||||
|
||||
return notificationId;
|
||||
} catch (error) {
|
||||
console.error("[LinuxNotification] Failed to show notification:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeLinuxNotification(
|
||||
notificationId: number,
|
||||
): Promise<void> {
|
||||
const notification = activeNotifications.get(notificationId);
|
||||
if (!notification) return;
|
||||
notification.close();
|
||||
clearNotificationState(notificationId);
|
||||
}
|
||||
|
||||
export async function getCapabilities(): Promise<string[]> {
|
||||
if (process.platform !== "linux") {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!Notification.isSupported()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return ["native-notification", "click"];
|
||||
}
|
||||
|
||||
export function onNotificationAction(callback: NotificationCallback): void {
|
||||
notificationCallbacks.push(callback);
|
||||
}
|
||||
|
||||
export function removeNotificationCallback(
|
||||
callback: NotificationCallback,
|
||||
): void {
|
||||
const index = notificationCallbacks.indexOf(callback);
|
||||
if (index > -1) {
|
||||
notificationCallbacks.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
export async function initLinuxNotificationService(): Promise<void> {
|
||||
if (process.platform !== "linux") {
|
||||
console.log("[LinuxNotification] Not on Linux, skipping init");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Notification.isSupported()) {
|
||||
console.warn("[LinuxNotification] Notification API is not supported");
|
||||
return;
|
||||
}
|
||||
|
||||
const caps = await getCapabilities();
|
||||
console.log("[LinuxNotification] Service initialized with native API:", caps);
|
||||
}
|
||||
379
electron/services/messagePushService.ts
Normal file
379
electron/services/messagePushService.ts
Normal file
@@ -0,0 +1,379 @@
|
||||
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 senderKey = senderUsername.toLowerCase()
|
||||
const nickname = groupNicknames[senderKey]
|
||||
|
||||
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
|
||||
? this.sanitizeGroupNicknames(result.nicknames)
|
||||
: {}
|
||||
this.groupNicknameCache.set(cacheKey, { nicknames, updatedAt: Date.now() })
|
||||
return nicknames
|
||||
}
|
||||
|
||||
private sanitizeGroupNicknames(nicknames: Record<string, string>): Record<string, string> {
|
||||
const buckets = new Map<string, Set<string>>()
|
||||
for (const [memberIdRaw, nicknameRaw] of Object.entries(nicknames || {})) {
|
||||
const memberId = String(memberIdRaw || '').trim().toLowerCase()
|
||||
const nickname = String(nicknameRaw || '').trim()
|
||||
if (!memberId || !nickname) continue
|
||||
const slot = buckets.get(memberId)
|
||||
if (slot) {
|
||||
slot.add(nickname)
|
||||
} else {
|
||||
buckets.set(memberId, new Set([nickname]))
|
||||
}
|
||||
}
|
||||
|
||||
const trusted: Record<string, string> = {}
|
||||
for (const [memberId, nicknameSet] of buckets.entries()) {
|
||||
if (nicknameSet.size !== 1) continue
|
||||
trusted[memberId] = Array.from(nicknameSet)[0]
|
||||
}
|
||||
return trusted
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -1,6 +1,7 @@
|
||||
import { wcdbService } from './wcdbService'
|
||||
import { ConfigService } from './config'
|
||||
import { ContactCacheService } from './contactCacheService'
|
||||
import { app } from 'electron'
|
||||
import { existsSync, mkdirSync } from 'fs'
|
||||
import { readFile, writeFile, mkdir } from 'fs/promises'
|
||||
import { basename, join } from 'path'
|
||||
@@ -27,6 +28,17 @@ export interface SnsMedia {
|
||||
livePhoto?: SnsLivePhoto
|
||||
}
|
||||
|
||||
export interface SnsLocation {
|
||||
latitude?: number
|
||||
longitude?: number
|
||||
city?: string
|
||||
country?: string
|
||||
poiName?: string
|
||||
poiAddress?: string
|
||||
poiAddressName?: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
export interface SnsPost {
|
||||
id: string
|
||||
tid?: string // 数据库主键(雪花 ID),用于精确删除
|
||||
@@ -39,6 +51,7 @@ export interface SnsPost {
|
||||
media: SnsMedia[]
|
||||
likes: string[]
|
||||
comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string; emojis?: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[] }[]
|
||||
location?: SnsLocation
|
||||
rawXml?: string
|
||||
linkTitle?: string
|
||||
linkUrl?: string
|
||||
@@ -287,6 +300,17 @@ function parseCommentsFromXml(xml: string): ParsedCommentItem[] {
|
||||
return comments
|
||||
}
|
||||
|
||||
const decodeXmlText = (text: string): string => {
|
||||
if (!text) return ''
|
||||
return text
|
||||
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
|
||||
.replace(/&/gi, '&')
|
||||
.replace(/</gi, '<')
|
||||
.replace(/>/gi, '>')
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/gi, "'")
|
||||
}
|
||||
|
||||
class SnsService {
|
||||
private configService: ConfigService
|
||||
private contactCache: ContactCacheService
|
||||
@@ -514,6 +538,32 @@ class SnsService {
|
||||
return raw.trim()
|
||||
}
|
||||
|
||||
private async collectSnsUsernamesFromTimeline(maxRounds: number = 2000): Promise<string[]> {
|
||||
const pageSize = 500
|
||||
const uniqueUsers = new Set<string>()
|
||||
let offset = 0
|
||||
|
||||
for (let round = 0; round < maxRounds; round++) {
|
||||
const result = await wcdbService.getSnsTimeline(pageSize, offset, undefined, undefined, 0, 0)
|
||||
if (!result.success || !Array.isArray(result.timeline)) {
|
||||
throw new Error(result.error || '获取朋友圈发布者失败')
|
||||
}
|
||||
|
||||
const rows = result.timeline
|
||||
if (rows.length === 0) break
|
||||
|
||||
for (const row of rows) {
|
||||
const username = this.pickTimelineUsername(row)
|
||||
if (username) uniqueUsers.add(username)
|
||||
}
|
||||
|
||||
if (rows.length < pageSize) break
|
||||
offset += rows.length
|
||||
}
|
||||
|
||||
return Array.from(uniqueUsers)
|
||||
}
|
||||
|
||||
private async getExportStatsFromTimeline(myWxid?: string): Promise<{ totalPosts: number; totalFriends: number; myPosts: number | null }> {
|
||||
const pageSize = 500
|
||||
const uniqueUsers = new Set<string>()
|
||||
@@ -647,15 +697,130 @@ class SnsService {
|
||||
return { media, videoKey }
|
||||
}
|
||||
|
||||
private toOptionalNumber(value: unknown): number | undefined {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value
|
||||
if (typeof value !== 'string') return undefined
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return undefined
|
||||
const parsed = Number.parseFloat(trimmed)
|
||||
return Number.isFinite(parsed) ? parsed : undefined
|
||||
}
|
||||
|
||||
private normalizeLocation(input: unknown): SnsLocation | undefined {
|
||||
if (!input || typeof input !== 'object') return undefined
|
||||
|
||||
const row = input as Record<string, unknown>
|
||||
const normalizeText = (value: unknown): string | undefined => {
|
||||
if (typeof value !== 'string') return undefined
|
||||
return this.toOptionalString(decodeXmlText(value))
|
||||
}
|
||||
|
||||
const location: SnsLocation = {}
|
||||
const latitude = this.toOptionalNumber(row.latitude ?? row.lat ?? row.x)
|
||||
const longitude = this.toOptionalNumber(row.longitude ?? row.lng ?? row.y)
|
||||
const city = normalizeText(row.city)
|
||||
const country = normalizeText(row.country)
|
||||
const poiName = normalizeText(row.poiName ?? row.poiname)
|
||||
const poiAddress = normalizeText(row.poiAddress ?? row.poiaddress)
|
||||
const poiAddressName = normalizeText(row.poiAddressName ?? row.poiaddressname)
|
||||
const label = normalizeText(row.label)
|
||||
|
||||
if (latitude !== undefined) location.latitude = latitude
|
||||
if (longitude !== undefined) location.longitude = longitude
|
||||
if (city) location.city = city
|
||||
if (country) location.country = country
|
||||
if (poiName) location.poiName = poiName
|
||||
if (poiAddress) location.poiAddress = poiAddress
|
||||
if (poiAddressName) location.poiAddressName = poiAddressName
|
||||
if (label) location.label = label
|
||||
|
||||
return Object.keys(location).length > 0 ? location : undefined
|
||||
}
|
||||
|
||||
private parseLocationFromXml(xml: string): SnsLocation | undefined {
|
||||
if (!xml) return undefined
|
||||
|
||||
try {
|
||||
const locationTagMatch = xml.match(/<location\b([^>]*)>/i)
|
||||
const locationAttrs = locationTagMatch?.[1] || ''
|
||||
const readAttr = (name: string): string | undefined => {
|
||||
if (!locationAttrs) return undefined
|
||||
const match = locationAttrs.match(new RegExp(`${name}\\s*=\\s*["']([\\s\\S]*?)["']`, 'i'))
|
||||
if (!match?.[1]) return undefined
|
||||
return this.toOptionalString(decodeXmlText(match[1]))
|
||||
}
|
||||
const readTag = (name: string): string | undefined => {
|
||||
const match = xml.match(new RegExp(`<${name}>([\\s\\S]*?)<\\/${name}>`, 'i'))
|
||||
if (!match?.[1]) return undefined
|
||||
return this.toOptionalString(decodeXmlText(match[1]))
|
||||
}
|
||||
|
||||
const location: SnsLocation = {}
|
||||
const latitude = this.toOptionalNumber(readAttr('latitude') || readAttr('x') || readTag('latitude') || readTag('x'))
|
||||
const longitude = this.toOptionalNumber(readAttr('longitude') || readAttr('y') || readTag('longitude') || readTag('y'))
|
||||
const city = readAttr('city') || readTag('city')
|
||||
const country = readAttr('country') || readTag('country')
|
||||
const poiName = readAttr('poiName') || readAttr('poiname') || readTag('poiName') || readTag('poiname')
|
||||
const poiAddress = readAttr('poiAddress') || readAttr('poiaddress') || readTag('poiAddress') || readTag('poiaddress')
|
||||
const poiAddressName = readAttr('poiAddressName') || readAttr('poiaddressname') || readTag('poiAddressName') || readTag('poiaddressname')
|
||||
const label = readAttr('label') || readTag('label')
|
||||
|
||||
if (latitude !== undefined) location.latitude = latitude
|
||||
if (longitude !== undefined) location.longitude = longitude
|
||||
if (city) location.city = city
|
||||
if (country) location.country = country
|
||||
if (poiName) location.poiName = poiName
|
||||
if (poiAddress) location.poiAddress = poiAddress
|
||||
if (poiAddressName) location.poiAddressName = poiAddressName
|
||||
if (label) location.label = label
|
||||
|
||||
return Object.keys(location).length > 0 ? location : undefined
|
||||
} catch (e) {
|
||||
console.error('[SnsService] 解析位置 XML 失败:', e)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
private mergeLocation(primary?: SnsLocation, fallback?: SnsLocation): SnsLocation | undefined {
|
||||
if (!primary && !fallback) return undefined
|
||||
|
||||
const merged: SnsLocation = {}
|
||||
const setValue = <K extends keyof SnsLocation>(key: K, value: SnsLocation[K] | undefined) => {
|
||||
if (value !== undefined) merged[key] = value
|
||||
}
|
||||
|
||||
setValue('latitude', primary?.latitude ?? fallback?.latitude)
|
||||
setValue('longitude', primary?.longitude ?? fallback?.longitude)
|
||||
setValue('city', primary?.city ?? fallback?.city)
|
||||
setValue('country', primary?.country ?? fallback?.country)
|
||||
setValue('poiName', primary?.poiName ?? fallback?.poiName)
|
||||
setValue('poiAddress', primary?.poiAddress ?? fallback?.poiAddress)
|
||||
setValue('poiAddressName', primary?.poiAddressName ?? fallback?.poiAddressName)
|
||||
setValue('label', primary?.label ?? fallback?.label)
|
||||
|
||||
return Object.keys(merged).length > 0 ? merged : undefined
|
||||
}
|
||||
|
||||
private getSnsCacheDir(): string {
|
||||
const cachePath = this.configService.getCacheBasePath()
|
||||
const snsCacheDir = join(cachePath, 'sns_cache')
|
||||
const configuredCachePath = String(this.configService.get('cachePath') || '').trim()
|
||||
const baseDir = configuredCachePath || join(app.getPath('documents'), 'WeFlow')
|
||||
const snsCacheDir = join(baseDir, 'sns_cache')
|
||||
if (!existsSync(snsCacheDir)) {
|
||||
mkdirSync(snsCacheDir, { recursive: true })
|
||||
}
|
||||
return snsCacheDir
|
||||
}
|
||||
|
||||
private getEmojiCacheDir(): string {
|
||||
const configuredCachePath = String(this.configService.get('cachePath') || '').trim()
|
||||
const baseDir = configuredCachePath || join(app.getPath('documents'), 'WeFlow')
|
||||
const emojiDir = join(baseDir, 'Emojis')
|
||||
if (!existsSync(emojiDir)) {
|
||||
mkdirSync(emojiDir, { recursive: true })
|
||||
}
|
||||
return emojiDir
|
||||
}
|
||||
|
||||
private getCacheFilePath(url: string): string {
|
||||
const hash = crypto.createHash('md5').update(url).digest('hex')
|
||||
const ext = isVideoUrl(url) ? '.mp4' : '.jpg'
|
||||
@@ -663,100 +828,39 @@ class SnsService {
|
||||
}
|
||||
|
||||
async getSnsUsernames(): Promise<{ success: boolean; usernames?: string[]; error?: string }> {
|
||||
const collect = (rows?: any[]): string[] => {
|
||||
if (!Array.isArray(rows)) return []
|
||||
const usernames: string[] = []
|
||||
for (const row of rows) {
|
||||
const raw = row?.user_name ?? row?.userName ?? row?.username ?? Object.values(row || {})[0]
|
||||
const username = typeof raw === 'string' ? raw.trim() : String(raw || '').trim()
|
||||
if (username) usernames.push(username)
|
||||
const result = await wcdbService.getSnsUsernames()
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || '获取朋友圈联系人失败' }
|
||||
}
|
||||
const directUsernames = Array.isArray(result.usernames) ? result.usernames : []
|
||||
if (directUsernames.length > 0) {
|
||||
return { success: true, usernames: directUsernames }
|
||||
}
|
||||
|
||||
// 回退:通过 timeline 分页拉取收集用户名,兼容底层接口暂时返回空数组的场景。
|
||||
try {
|
||||
const timelineUsers = await this.collectSnsUsernamesFromTimeline()
|
||||
if (timelineUsers.length > 0) {
|
||||
return { success: true, usernames: timelineUsers }
|
||||
}
|
||||
return usernames
|
||||
} catch {
|
||||
// 忽略回退错误,保持与原行为一致返回空数组
|
||||
}
|
||||
|
||||
const primary = await wcdbService.execQuery(
|
||||
'sns',
|
||||
null,
|
||||
"SELECT DISTINCT user_name FROM SnsTimeLine WHERE user_name IS NOT NULL AND user_name <> ''"
|
||||
)
|
||||
const fallback = await wcdbService.execQuery(
|
||||
'sns',
|
||||
null,
|
||||
"SELECT DISTINCT userName FROM SnsTimeLine WHERE userName IS NOT NULL AND userName <> ''"
|
||||
)
|
||||
|
||||
const merged = Array.from(new Set([
|
||||
...collect(primary.rows),
|
||||
...collect(fallback.rows)
|
||||
]))
|
||||
|
||||
// 任一查询成功且拿到用户名即视为成功,避免因为列名差异导致误判为空。
|
||||
if (merged.length > 0) {
|
||||
return { success: true, usernames: merged }
|
||||
}
|
||||
|
||||
// 两条查询都成功但无数据,说明确实没有朋友圈发布者。
|
||||
if (primary.success || fallback.success) {
|
||||
return { success: true, usernames: [] }
|
||||
}
|
||||
|
||||
return { success: false, error: primary.error || fallback.error || '获取朋友圈联系人失败' }
|
||||
return { success: true, usernames: directUsernames }
|
||||
}
|
||||
|
||||
private async getExportStatsFromTableCount(myWxid?: string): Promise<{ totalPosts: number; totalFriends: number; myPosts: number | null }> {
|
||||
let totalPosts = 0
|
||||
let totalFriends = 0
|
||||
let myPosts: number | null = null
|
||||
|
||||
const postCountResult = await wcdbService.execQuery('sns', null, 'SELECT COUNT(1) AS total FROM SnsTimeLine')
|
||||
if (postCountResult.success && postCountResult.rows && postCountResult.rows.length > 0) {
|
||||
totalPosts = this.parseCountValue(postCountResult.rows[0])
|
||||
}
|
||||
|
||||
if (totalPosts > 0) {
|
||||
const friendCountPrimary = await wcdbService.execQuery(
|
||||
'sns',
|
||||
null,
|
||||
"SELECT COUNT(DISTINCT user_name) AS total FROM SnsTimeLine WHERE user_name IS NOT NULL AND user_name <> ''"
|
||||
)
|
||||
if (friendCountPrimary.success && friendCountPrimary.rows && friendCountPrimary.rows.length > 0) {
|
||||
totalFriends = this.parseCountValue(friendCountPrimary.rows[0])
|
||||
} else {
|
||||
const friendCountFallback = await wcdbService.execQuery(
|
||||
'sns',
|
||||
null,
|
||||
"SELECT COUNT(DISTINCT userName) AS total FROM SnsTimeLine WHERE userName IS NOT NULL AND userName <> ''"
|
||||
)
|
||||
if (friendCountFallback.success && friendCountFallback.rows && friendCountFallback.rows.length > 0) {
|
||||
totalFriends = this.parseCountValue(friendCountFallback.rows[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedMyWxid = this.toOptionalString(myWxid)
|
||||
if (normalizedMyWxid) {
|
||||
const myPostPrimary = await wcdbService.execQuery(
|
||||
'sns',
|
||||
null,
|
||||
"SELECT COUNT(1) AS total FROM SnsTimeLine WHERE user_name = ?",
|
||||
[normalizedMyWxid]
|
||||
)
|
||||
if (myPostPrimary.success && myPostPrimary.rows && myPostPrimary.rows.length > 0) {
|
||||
myPosts = this.parseCountValue(myPostPrimary.rows[0])
|
||||
} else {
|
||||
const myPostFallback = await wcdbService.execQuery(
|
||||
'sns',
|
||||
null,
|
||||
"SELECT COUNT(1) AS total FROM SnsTimeLine WHERE userName = ?",
|
||||
[normalizedMyWxid]
|
||||
)
|
||||
if (myPostFallback.success && myPostFallback.rows && myPostFallback.rows.length > 0) {
|
||||
myPosts = this.parseCountValue(myPostFallback.rows[0])
|
||||
}
|
||||
}
|
||||
const result = await wcdbService.getSnsExportStats(normalizedMyWxid || undefined)
|
||||
if (!result.success || !result.data) {
|
||||
return { totalPosts: 0, totalFriends: 0, myPosts: normalizedMyWxid ? 0 : null }
|
||||
}
|
||||
return {
|
||||
totalPosts: Number(result.data.totalPosts || 0),
|
||||
totalFriends: Number(result.data.totalFriends || 0),
|
||||
myPosts: result.data.myPosts === null || result.data.myPosts === undefined ? null : Number(result.data.myPosts || 0)
|
||||
}
|
||||
|
||||
return { totalPosts, totalFriends, myPosts }
|
||||
}
|
||||
|
||||
async getExportStats(options?: {
|
||||
@@ -970,14 +1074,14 @@ class SnsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 补全 DLL 返回的评论中缺失的 refNickname
|
||||
* DLL 返回的 refCommentId 是被回复评论的 cmtid
|
||||
* 补全数据服务返回的评论中缺失的 refNickname
|
||||
*数据服务返回的 refCommentId 是被回复评论的 cmtid
|
||||
* 评论按 cmtid 从小到大排列,cmtid 从 1 开始递增
|
||||
*/
|
||||
private fixCommentRefs(comments: any[]): any[] {
|
||||
if (!comments || comments.length === 0) return []
|
||||
|
||||
// DLL 现在返回完整的评论数据(含 emojis、refNickname)
|
||||
//数据服务现在返回完整的评论数据(含 emojis、refNickname)
|
||||
// 此处做最终的格式化和兜底补全
|
||||
const idToNickname = new Map<string, string>()
|
||||
comments.forEach((c, idx) => {
|
||||
@@ -1024,7 +1128,12 @@ class SnsService {
|
||||
const enrichedTimeline = result.timeline.map((post: any) => {
|
||||
const contact = this.contactCache.get(post.username)
|
||||
const isVideoPost = post.type === 15
|
||||
const videoKey = extractVideoKey(post.rawXml || '')
|
||||
const rawXml = post.rawXml || ''
|
||||
const videoKey = extractVideoKey(rawXml)
|
||||
const location = this.mergeLocation(
|
||||
this.normalizeLocation((post as { location?: unknown }).location),
|
||||
this.parseLocationFromXml(rawXml)
|
||||
)
|
||||
|
||||
const fixedMedia = (post.media || []).map((m: any) => ({
|
||||
url: fixSnsUrl(m.url, m.token, isVideoPost),
|
||||
@@ -1043,15 +1152,14 @@ class SnsService {
|
||||
} : undefined
|
||||
}))
|
||||
|
||||
// DLL 已返回完整评论数据(含 emojis、refNickname)
|
||||
// 如果 DLL 评论缺少表情包信息,回退到从 rawXml 重新解析
|
||||
//数据服务已返回完整评论数据(含 emojis、refNickname)
|
||||
// 如果数据服务评论缺少表情包信息,回退到从 rawXml 重新解析
|
||||
const dllComments: any[] = post.comments || []
|
||||
const hasEmojisInDll = dllComments.some((c: any) => c.emojis && c.emojis.length > 0)
|
||||
const rawXml = post.rawXml || ''
|
||||
|
||||
let finalComments: any[]
|
||||
if (dllComments.length > 0 && (hasEmojisInDll || !rawXml)) {
|
||||
// DLL 数据完整,直接使用
|
||||
//数据服务数据完整,直接使用
|
||||
finalComments = this.fixCommentRefs(dllComments)
|
||||
} else if (rawXml) {
|
||||
// 回退:从 rawXml 重新解析(兼容旧版 DLL)
|
||||
@@ -1066,7 +1174,8 @@ class SnsService {
|
||||
avatarUrl: contact?.avatarUrl,
|
||||
nickname: post.nickname || contact?.displayName || post.username,
|
||||
media: fixedMedia,
|
||||
comments: finalComments
|
||||
comments: finalComments,
|
||||
location
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1143,7 +1252,7 @@ class SnsService {
|
||||
return { success: false, error: result.error }
|
||||
}
|
||||
|
||||
async downloadImage(url: string, key?: string | number): Promise<{ success: boolean; data?: Buffer; contentType?: string; error?: string }> {
|
||||
async downloadImage(url: string, key?: string | number): Promise<{ success: boolean; data?: Buffer; contentType?: string; cachePath?: string; error?: string }> {
|
||||
return this.fetchAndDecryptImage(url, key)
|
||||
}
|
||||
|
||||
@@ -1422,6 +1531,7 @@ class SnsService {
|
||||
})),
|
||||
likes: p.likes,
|
||||
comments: p.comments,
|
||||
location: p.location,
|
||||
linkTitle: (p as any).linkTitle,
|
||||
linkUrl: (p as any).linkUrl
|
||||
}))
|
||||
@@ -1473,6 +1583,7 @@ class SnsService {
|
||||
})),
|
||||
likes: post.likes,
|
||||
comments: post.comments,
|
||||
location: post.location,
|
||||
likesDetail,
|
||||
commentsDetail,
|
||||
linkTitle: (post as any).linkTitle,
|
||||
@@ -1555,6 +1666,27 @@ class SnsService {
|
||||
const ch = name.charAt(0)
|
||||
return escapeHtml(ch || '?')
|
||||
}
|
||||
const normalizeLocationText = (value?: string): string => (
|
||||
decodeXmlText(String(value || '')).replace(/\s+/g, ' ').trim()
|
||||
)
|
||||
const resolveLocationText = (location?: SnsLocation): string => {
|
||||
if (!location) return ''
|
||||
const primaryCandidates = [
|
||||
normalizeLocationText(location.poiName),
|
||||
normalizeLocationText(location.poiAddressName),
|
||||
normalizeLocationText(location.label),
|
||||
normalizeLocationText(location.poiAddress)
|
||||
].filter(Boolean)
|
||||
const primary = primaryCandidates[0] || ''
|
||||
const region = [
|
||||
normalizeLocationText(location.country),
|
||||
normalizeLocationText(location.city)
|
||||
].filter(Boolean).join(' ')
|
||||
if (primary && region && !primary.includes(region)) {
|
||||
return `${primary} · ${region}`
|
||||
}
|
||||
return primary || region
|
||||
}
|
||||
|
||||
let filterInfo = ''
|
||||
if (filters.keyword) filterInfo += `关键词: "${escapeHtml(filters.keyword)}" `
|
||||
@@ -1578,6 +1710,10 @@ class SnsService {
|
||||
const linkHtml = post.linkTitle && post.linkUrl
|
||||
? `<a class="lk" href="${escapeHtml(post.linkUrl)}" target="_blank"><span class="lk-t">${escapeHtml(post.linkTitle)}</span><span class="lk-a">›</span></a>`
|
||||
: ''
|
||||
const locationText = resolveLocationText(post.location)
|
||||
const locationHtml = locationText
|
||||
? `<div class="loc"><span class="loc-i">📍</span><span class="loc-t">${escapeHtml(locationText)}</span></div>`
|
||||
: ''
|
||||
|
||||
const likesHtml = post.likes.length > 0
|
||||
? `<div class="interactions"><div class="likes">♥ ${post.likes.map(l => `<span>${escapeHtml(l)}</span>`).join('、')}</div></div>`
|
||||
@@ -1600,6 +1736,7 @@ ${avatarHtml}
|
||||
<div class="body">
|
||||
<div class="hd"><span class="nick">${escapeHtml(post.nickname)}</span><span class="tm">${formatTime(post.createTime)}</span></div>
|
||||
${post.contentDesc ? `<div class="txt">${escapeHtml(post.contentDesc)}</div>` : ''}
|
||||
${locationHtml}
|
||||
${mediaHtml ? `<div class="mg ${gridClass}">${mediaHtml}</div>` : ''}
|
||||
${linkHtml}
|
||||
${likesHtml}
|
||||
@@ -1635,6 +1772,9 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hira
|
||||
.nick{font-size:15px;font-weight:700;color:var(--accent);margin-bottom:2px}
|
||||
.tm{font-size:12px;color:var(--t3)}
|
||||
.txt{font-size:15px;line-height:1.6;white-space:pre-wrap;word-break:break-word;margin-bottom:12px}
|
||||
.loc{display:flex;align-items:flex-start;gap:6px;font-size:13px;color:var(--t2);margin:-4px 0 12px}
|
||||
.loc-i{line-height:1.3}
|
||||
.loc-t{line-height:1.45;word-break:break-word}
|
||||
|
||||
/* 媒体网格 */
|
||||
.mg{display:grid;gap:6px;margin-bottom:12px;max-width:320px}
|
||||
@@ -1704,7 +1844,7 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
|
||||
const isVideo = isVideoUrl(url)
|
||||
const cachePath = this.getCacheFilePath(url)
|
||||
|
||||
// 1. 尝试从磁盘缓存读取
|
||||
// 1. 优先尝试从当前缓存目录读取
|
||||
if (existsSync(cachePath)) {
|
||||
try {
|
||||
// 对于视频,不读取整个文件到内存,只确认存在即可
|
||||
@@ -2165,9 +2305,7 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
|
||||
|
||||
const fs = require('fs')
|
||||
const cacheKey = crypto.createHash('md5').update(url || encryptUrl!).digest('hex')
|
||||
const cachePath = this.configService.getCacheBasePath()
|
||||
const emojiDir = join(cachePath, 'sns_emoji_cache')
|
||||
if (!existsSync(emojiDir)) mkdirSync(emojiDir, { recursive: true })
|
||||
const emojiDir = this.getEmojiCacheDir()
|
||||
|
||||
// 检查本地缓存
|
||||
for (const ext of ['.gif', '.png', '.webp', '.jpg', '.jpeg']) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -75,6 +75,14 @@ export class VoiceTranscribeService {
|
||||
if (candidates.length === 0) {
|
||||
console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`)
|
||||
}
|
||||
} else if (process.platform === 'win32') {
|
||||
// Windows: 把 sherpa-onnx 所在目录加到 PATH,否则 native module 找不到依赖
|
||||
const existing = env['PATH'] || ''
|
||||
const merged = [...candidates, ...existing.split(';').filter(Boolean)]
|
||||
env['PATH'] = Array.from(new Set(merged)).join(';')
|
||||
if (candidates.length === 0) {
|
||||
console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`)
|
||||
}
|
||||
}
|
||||
|
||||
return env
|
||||
@@ -273,8 +281,20 @@ export class VoiceTranscribeService {
|
||||
})
|
||||
|
||||
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) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -80,7 +80,7 @@ export class WcdbService {
|
||||
// Worker 退出,需要 reject 所有 pending promises
|
||||
if (code !== 0) {
|
||||
console.error('WCDB Worker 异常退出,退出码:', code)
|
||||
const errorMsg = `Worker 异常退出 (退出码: ${code})。可能是 DLL 加载失败,请检查是否安装了 Visual C++ Redistributable。`
|
||||
const errorMsg = `Worker 异常退出 (退出码: ${code})。可能是数据服务加载失败,请检查是否安装了 Visual C++ Redistributable。`
|
||||
for (const [id, p] of this.pending) {
|
||||
p.reject(new Error(errorMsg))
|
||||
}
|
||||
@@ -164,6 +164,10 @@ export class WcdbService {
|
||||
return this.callWorker('open', { dbPath, hexKey, wxid })
|
||||
}
|
||||
|
||||
async getLastInitError(): Promise<string | null> {
|
||||
return this.callWorker('getLastInitError')
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭数据库连接
|
||||
*/
|
||||
@@ -174,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
|
||||
}
|
||||
}
|
||||
@@ -222,6 +226,79 @@ export class WcdbService {
|
||||
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 })
|
||||
}
|
||||
|
||||
async getMediaStream(options?: {
|
||||
sessionId?: string
|
||||
mediaType?: 'image' | 'video' | 'all'
|
||||
beginTimestamp?: number
|
||||
endTimestamp?: number
|
||||
limit?: number
|
||||
offset?: number
|
||||
}): Promise<{
|
||||
success: boolean
|
||||
items?: Array<{
|
||||
sessionId: string
|
||||
sessionDisplayName?: string
|
||||
mediaType: 'image' | 'video'
|
||||
localId: number
|
||||
serverId?: string
|
||||
createTime: number
|
||||
localType: number
|
||||
senderUsername?: string
|
||||
isSend?: number | null
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
videoMd5?: string
|
||||
content?: string
|
||||
}>
|
||||
hasMore?: boolean
|
||||
nextOffset?: number
|
||||
error?: string
|
||||
}> {
|
||||
return this.callWorker('getMediaStream', { options })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取联系人昵称
|
||||
*/
|
||||
@@ -287,6 +364,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 })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取联系人详情
|
||||
*/
|
||||
@@ -301,6 +386,26 @@ export class WcdbService {
|
||||
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 })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取聚合统计数据
|
||||
*/
|
||||
@@ -372,7 +477,7 @@ export class WcdbService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 SQL 查询(支持参数化查询)
|
||||
* 执行 SQL 查询(仅主进程内部使用:fallback/diagnostic/低频兼容)
|
||||
*/
|
||||
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 })
|
||||
@@ -385,6 +490,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 })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取表情包释义(严格数据服务接口)
|
||||
*/
|
||||
async getEmoticonCaptionStrict(md5: string): Promise<{ success: boolean; caption?: string; error?: string }> {
|
||||
return this.callWorker('getEmoticonCaptionStrict', { md5 })
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出消息数据库
|
||||
*/
|
||||
@@ -406,6 +525,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 })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取语音数据
|
||||
*/
|
||||
@@ -413,6 +536,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 })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取朋友圈
|
||||
*/
|
||||
@@ -427,6 +584,32 @@ 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 checkMessageAntiRevokeTriggers(
|
||||
sessionIds: string[]
|
||||
): Promise<{ success: boolean; rows?: Array<{ sessionId: string; success: boolean; installed?: boolean; error?: string }>; error?: string }> {
|
||||
return this.callWorker('checkMessageAntiRevokeTriggers', { sessionIds })
|
||||
}
|
||||
|
||||
async installMessageAntiRevokeTriggers(
|
||||
sessionIds: string[]
|
||||
): Promise<{ success: boolean; rows?: Array<{ sessionId: string; success: boolean; alreadyInstalled?: boolean; error?: string }>; error?: string }> {
|
||||
return this.callWorker('installMessageAntiRevokeTriggers', { sessionIds })
|
||||
}
|
||||
|
||||
async uninstallMessageAntiRevokeTriggers(
|
||||
sessionIds: string[]
|
||||
): Promise<{ success: boolean; rows?: Array<{ sessionId: string; success: boolean; error?: string }>; error?: string }> {
|
||||
return this.callWorker('uninstallMessageAntiRevokeTriggers', { sessionIds })
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装朋友圈删除拦截
|
||||
*/
|
||||
@@ -456,7 +639,7 @@ export class WcdbService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 DLL 内部日志
|
||||
* 获取数据服务内部日志
|
||||
*/
|
||||
async getLogs(): Promise<{ success: boolean; logs?: string[]; error?: string }> {
|
||||
return this.callWorker('getLogs')
|
||||
|
||||
@@ -37,6 +37,9 @@ if (parentPort) {
|
||||
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 }
|
||||
@@ -59,6 +62,27 @@ if (parentPort) {
|
||||
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 'getMediaStream':
|
||||
result = await core.getMediaStream(payload.options)
|
||||
break
|
||||
case 'getDisplayNames':
|
||||
result = await core.getDisplayNames(payload.usernames)
|
||||
break
|
||||
@@ -89,12 +113,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
|
||||
@@ -131,6 +176,12 @@ if (parentPort) {
|
||||
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
|
||||
@@ -140,18 +191,57 @@ 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 'checkMessageAntiRevokeTriggers':
|
||||
result = await core.checkMessageAntiRevokeTriggers(payload.sessionIds)
|
||||
break
|
||||
case 'installMessageAntiRevokeTriggers':
|
||||
result = await core.installMessageAntiRevokeTriggers(payload.sessionIds)
|
||||
break
|
||||
case 'uninstallMessageAntiRevokeTriggers':
|
||||
result = await core.uninstallMessageAntiRevokeTriggers(payload.sessionIds)
|
||||
break
|
||||
case 'installSnsBlockDeleteTrigger':
|
||||
result = await core.installSnsBlockDeleteTrigger()
|
||||
break
|
||||
|
||||
@@ -1,220 +1,333 @@
|
||||
import { BrowserWindow, ipcMain, screen } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { ConfigService } from '../services/config'
|
||||
import { BrowserWindow, ipcMain, screen } from "electron";
|
||||
import { join } from "path";
|
||||
import { ConfigService } from "../services/config";
|
||||
|
||||
let notificationWindow: BrowserWindow | null = null
|
||||
let closeTimer: NodeJS.Timeout | null = null
|
||||
// Linux D-Bus通知服务
|
||||
const isLinux = process.platform === "linux";
|
||||
let linuxNotificationService:
|
||||
| typeof import("../services/linuxNotificationService")
|
||||
| null = null;
|
||||
|
||||
// 用于处理通知点击的回调函数(在Linux上用于导航到会话)
|
||||
let onNotificationNavigate: ((sessionId: string) => void) | null = null;
|
||||
|
||||
export function setNotificationNavigateHandler(
|
||||
callback: (sessionId: string) => void,
|
||||
) {
|
||||
onNotificationNavigate = callback;
|
||||
}
|
||||
|
||||
let notificationWindow: BrowserWindow | null = null;
|
||||
let closeTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
export function destroyNotificationWindow() {
|
||||
if (closeTimer) {
|
||||
clearTimeout(closeTimer)
|
||||
closeTimer = null
|
||||
}
|
||||
lastNotificationData = null
|
||||
if (closeTimer) {
|
||||
clearTimeout(closeTimer);
|
||||
closeTimer = null;
|
||||
}
|
||||
lastNotificationData = null;
|
||||
|
||||
if (!notificationWindow || notificationWindow.isDestroyed()) {
|
||||
notificationWindow = null
|
||||
return
|
||||
}
|
||||
if (!notificationWindow || notificationWindow.isDestroyed()) {
|
||||
notificationWindow = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const win = notificationWindow
|
||||
notificationWindow = null
|
||||
const win = notificationWindow;
|
||||
notificationWindow = null;
|
||||
|
||||
try {
|
||||
win.destroy()
|
||||
} catch (error) {
|
||||
console.warn('[NotificationWindow] Failed to destroy window:', error)
|
||||
}
|
||||
try {
|
||||
win.destroy();
|
||||
} catch (error) {
|
||||
console.warn("[NotificationWindow] Failed to destroy window:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export function createNotificationWindow() {
|
||||
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
||||
return notificationWindow
|
||||
}
|
||||
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
||||
return notificationWindow;
|
||||
}
|
||||
|
||||
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||
const iconPath = isDev
|
||||
? join(__dirname, '../../public/icon.ico')
|
||||
: join(process.resourcesPath, 'icon.ico')
|
||||
const isDev = !!process.env.VITE_DEV_SERVER_URL;
|
||||
const iconPath = isDev
|
||||
? join(__dirname, "../../public/icon.ico")
|
||||
: join(process.resourcesPath, "icon.ico");
|
||||
|
||||
console.log('[NotificationWindow] Creating window...')
|
||||
const width = 344
|
||||
const height = 114
|
||||
console.log("[NotificationWindow] Creating window...");
|
||||
const width = 344;
|
||||
const height = 114;
|
||||
|
||||
// Update default creation size
|
||||
notificationWindow = new BrowserWindow({
|
||||
width: width,
|
||||
height: height,
|
||||
type: 'toolbar', // 有助于在某些操作系统上保持置顶
|
||||
frame: false,
|
||||
transparent: true,
|
||||
resizable: false,
|
||||
show: false,
|
||||
alwaysOnTop: true,
|
||||
skipTaskbar: true,
|
||||
focusable: false, // 不抢占焦点
|
||||
icon: iconPath,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, 'preload.js'), // FIX: Use correct relative path (same dir in dist)
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
// devTools: true // Enable DevTools
|
||||
}
|
||||
})
|
||||
// Update default creation size
|
||||
notificationWindow = new BrowserWindow({
|
||||
width: width,
|
||||
height: height,
|
||||
type: "toolbar", // 有助于在某些操作系统上保持置顶
|
||||
frame: false,
|
||||
transparent: true,
|
||||
resizable: false,
|
||||
show: false,
|
||||
alwaysOnTop: true,
|
||||
skipTaskbar: true,
|
||||
focusable: false, // 不抢占焦点
|
||||
icon: iconPath,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, "preload.js"), // FIX: Use correct relative path (same dir in dist)
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
// devTools: true // Enable DevTools
|
||||
},
|
||||
});
|
||||
|
||||
// notificationWindow.webContents.openDevTools({ mode: 'detach' }) // DEBUG: Force Open DevTools
|
||||
notificationWindow.setIgnoreMouseEvents(true, { forward: true }) // 初始点击穿透
|
||||
// notificationWindow.webContents.openDevTools({ mode: 'detach' }) // DEBUG: Force Open DevTools
|
||||
notificationWindow.setIgnoreMouseEvents(true, { forward: true }); // 初始点击穿透
|
||||
|
||||
// 处理鼠标事件 (如果需要从渲染进程转发,但目前特定区域处理?)
|
||||
// 实际上,我们希望窗口可点击。
|
||||
// 我们将在显示时将忽略鼠标事件设为 false。
|
||||
// 处理鼠标事件 (如果需要从渲染进程转发,但目前特定区域处理?)
|
||||
// 实际上,我们希望窗口可点击。
|
||||
// 我们将在显示时将忽略鼠标事件设为 false。
|
||||
|
||||
const loadUrl = isDev
|
||||
? `${process.env.VITE_DEV_SERVER_URL}#/notification-window`
|
||||
: `file://${join(__dirname, '../dist/index.html')}#/notification-window`
|
||||
const loadUrl = isDev
|
||||
? `${process.env.VITE_DEV_SERVER_URL}#/notification-window`
|
||||
: `file://${join(__dirname, "../dist/index.html")}#/notification-window`;
|
||||
|
||||
console.log('[NotificationWindow] Loading URL:', loadUrl)
|
||||
notificationWindow.loadURL(loadUrl)
|
||||
console.log("[NotificationWindow] Loading URL:", loadUrl);
|
||||
notificationWindow.loadURL(loadUrl);
|
||||
|
||||
notificationWindow.on('closed', () => {
|
||||
notificationWindow = null
|
||||
})
|
||||
notificationWindow.on("closed", () => {
|
||||
notificationWindow = null;
|
||||
});
|
||||
|
||||
return notificationWindow
|
||||
return notificationWindow;
|
||||
}
|
||||
|
||||
export async function showNotification(data: any) {
|
||||
// 先检查配置
|
||||
const config = ConfigService.getInstance()
|
||||
const enabled = await config.get('notificationEnabled')
|
||||
if (enabled === false) return // 默认为 true
|
||||
// 先检查配置
|
||||
const config = ConfigService.getInstance();
|
||||
const enabled = await config.get("notificationEnabled");
|
||||
if (enabled === false) return; // 默认为 true
|
||||
|
||||
// 检查会话过滤
|
||||
const filterMode = config.get('notificationFilterMode') || 'all'
|
||||
const filterList = config.get('notificationFilterList') || []
|
||||
const sessionId = data.sessionId
|
||||
// 检查会话过滤
|
||||
const filterMode = config.get("notificationFilterMode") || "all";
|
||||
const filterList = config.get("notificationFilterList") || [];
|
||||
const sessionId = data.sessionId;
|
||||
|
||||
if (sessionId && filterMode !== 'all' && filterList.length > 0) {
|
||||
const isInList = filterList.includes(sessionId)
|
||||
if (filterMode === 'whitelist' && !isInList) {
|
||||
// 白名单模式:不在列表中则不显示
|
||||
return
|
||||
}
|
||||
if (filterMode === 'blacklist' && isInList) {
|
||||
// 黑名单模式:在列表中则不显示
|
||||
return
|
||||
}
|
||||
if (sessionId && filterMode !== "all" && filterList.length > 0) {
|
||||
const isInList = filterList.includes(sessionId);
|
||||
if (filterMode === "whitelist" && !isInList) {
|
||||
// 白名单模式:不在列表中则不显示
|
||||
return;
|
||||
}
|
||||
|
||||
let win = notificationWindow
|
||||
if (!win || win.isDestroyed()) {
|
||||
win = createNotificationWindow()
|
||||
if (filterMode === "blacklist" && isInList) {
|
||||
// 黑名单模式:在列表中则不显示
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!win) return
|
||||
// Linux 使用 D-Bus 通知
|
||||
if (isLinux) {
|
||||
await showLinuxNotification(data);
|
||||
return;
|
||||
}
|
||||
|
||||
// 确保加载完成
|
||||
if (win.webContents.isLoading()) {
|
||||
win.once('ready-to-show', () => {
|
||||
showAndSend(win!, data)
|
||||
})
|
||||
} else {
|
||||
showAndSend(win, data)
|
||||
}
|
||||
let win = notificationWindow;
|
||||
if (!win || win.isDestroyed()) {
|
||||
win = createNotificationWindow();
|
||||
}
|
||||
|
||||
if (!win) return;
|
||||
|
||||
// 确保加载完成
|
||||
if (win.webContents.isLoading()) {
|
||||
win.once("ready-to-show", () => {
|
||||
showAndSend(win!, data);
|
||||
});
|
||||
} else {
|
||||
showAndSend(win, data);
|
||||
}
|
||||
}
|
||||
|
||||
let lastNotificationData: any = null
|
||||
// 显示Linux通知
|
||||
async function showLinuxNotification(data: any) {
|
||||
if (!linuxNotificationService) {
|
||||
try {
|
||||
linuxNotificationService =
|
||||
await import("../services/linuxNotificationService");
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[NotificationWindow] Failed to load Linux notification service:",
|
||||
error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const { showLinuxNotification: showNotification } = linuxNotificationService;
|
||||
|
||||
const notificationData = {
|
||||
title: data.title,
|
||||
content: data.content,
|
||||
avatarUrl: data.avatarUrl,
|
||||
sessionId: data.sessionId,
|
||||
expireTimeout: 5000,
|
||||
};
|
||||
|
||||
showNotification(notificationData);
|
||||
}
|
||||
|
||||
let lastNotificationData: any = null;
|
||||
|
||||
async function showAndSend(win: BrowserWindow, data: any) {
|
||||
lastNotificationData = data
|
||||
const config = ConfigService.getInstance()
|
||||
const position = (await config.get('notificationPosition')) || 'top-right'
|
||||
lastNotificationData = data;
|
||||
const config = ConfigService.getInstance();
|
||||
const position = (await config.get("notificationPosition")) || "top-right";
|
||||
|
||||
// 更新位置
|
||||
const { width: screenWidth, height: screenHeight } = screen.getPrimaryDisplay().workAreaSize
|
||||
const winWidth = 344
|
||||
const winHeight = 114
|
||||
const padding = 20
|
||||
// 更新位置
|
||||
const { width: screenWidth, height: screenHeight } =
|
||||
screen.getPrimaryDisplay().workAreaSize;
|
||||
const winWidth = position === "top-center" ? 280 : 344;
|
||||
const winHeight = 114;
|
||||
const padding = 20;
|
||||
|
||||
let x = 0
|
||||
let y = 0
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
|
||||
switch (position) {
|
||||
case 'top-right':
|
||||
x = screenWidth - winWidth - padding
|
||||
y = padding
|
||||
break
|
||||
case 'bottom-right':
|
||||
x = screenWidth - winWidth - padding
|
||||
y = screenHeight - winHeight - padding
|
||||
break
|
||||
case 'top-left':
|
||||
x = padding
|
||||
y = padding
|
||||
break
|
||||
case 'bottom-left':
|
||||
x = padding
|
||||
y = screenHeight - winHeight - padding
|
||||
break
|
||||
switch (position) {
|
||||
case "top-center":
|
||||
x = (screenWidth - winWidth) / 2;
|
||||
y = padding;
|
||||
break;
|
||||
case "top-right":
|
||||
x = screenWidth - winWidth - padding;
|
||||
y = padding;
|
||||
break;
|
||||
case "bottom-right":
|
||||
x = screenWidth - winWidth - padding;
|
||||
y = screenHeight - winHeight - padding;
|
||||
break;
|
||||
case "top-left":
|
||||
x = padding;
|
||||
y = padding;
|
||||
break;
|
||||
case "bottom-left":
|
||||
x = padding;
|
||||
y = screenHeight - winHeight - padding;
|
||||
break;
|
||||
}
|
||||
|
||||
win.setPosition(Math.floor(x), Math.floor(y));
|
||||
win.setSize(winWidth, winHeight); // 确保尺寸
|
||||
|
||||
// 设为可交互
|
||||
win.setIgnoreMouseEvents(false);
|
||||
win.showInactive(); // 显示但不聚焦
|
||||
win.setAlwaysOnTop(true, "screen-saver"); // 最高层级
|
||||
|
||||
win.webContents.send("notification:show", { ...data, position });
|
||||
|
||||
// 自动关闭计时器通常由渲染进程管理
|
||||
// 渲染进程发送 'notification:close' 来隐藏窗口
|
||||
}
|
||||
|
||||
// 注册通知处理
|
||||
export async function registerNotificationHandlers() {
|
||||
// Linux: 初始化D-Bus服务
|
||||
if (isLinux) {
|
||||
try {
|
||||
const linuxNotificationModule =
|
||||
await import("../services/linuxNotificationService");
|
||||
linuxNotificationService = linuxNotificationModule;
|
||||
|
||||
// 初始化服务
|
||||
await linuxNotificationModule.initLinuxNotificationService();
|
||||
|
||||
// 在Linux上注册通知点击回调
|
||||
linuxNotificationModule.onNotificationAction((sessionId: string) => {
|
||||
console.log(
|
||||
"[NotificationWindow] Linux notification clicked, sessionId:",
|
||||
sessionId,
|
||||
);
|
||||
// 如果设置了导航处理程序,则使用该处理程序;否则,回退到ipcMain方法。
|
||||
if (onNotificationNavigate) {
|
||||
onNotificationNavigate(sessionId);
|
||||
} else {
|
||||
// 如果尚未设置处理程序,则通过ipcMain发出事件
|
||||
// 正常流程中不应该发生这种情况,因为我们在初始化之前设置了处理程序。
|
||||
console.warn(
|
||||
"[NotificationWindow] onNotificationNavigate not set yet",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(
|
||||
"[NotificationWindow] Linux notification service initialized",
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[NotificationWindow] Failed to initialize Linux notification service:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
win.setPosition(Math.floor(x), Math.floor(y))
|
||||
win.setSize(winWidth, winHeight) // 确保尺寸
|
||||
ipcMain.handle("notification:show", (_, data) => {
|
||||
showNotification(data);
|
||||
});
|
||||
|
||||
// 设为可交互
|
||||
win.setIgnoreMouseEvents(false)
|
||||
win.showInactive() // 显示但不聚焦
|
||||
win.setAlwaysOnTop(true, 'screen-saver') // 最高层级
|
||||
ipcMain.handle("notification:close", () => {
|
||||
if (isLinux && linuxNotificationService) {
|
||||
// 注册通知点击回调函数。Linux通知通过D-Bus自动关闭,但我们可以根据需要进行跟踪
|
||||
return;
|
||||
}
|
||||
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
||||
notificationWindow.hide();
|
||||
notificationWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||
}
|
||||
});
|
||||
|
||||
win.webContents.send('notification:show', data)
|
||||
// Handle renderer ready event (fix race condition)
|
||||
ipcMain.on("notification:ready", (event) => {
|
||||
if (isLinux) {
|
||||
// Linux不需要通知窗口,拦截通知窗口渲染
|
||||
return;
|
||||
}
|
||||
console.log("[NotificationWindow] Renderer ready, checking cached data");
|
||||
if (
|
||||
lastNotificationData &&
|
||||
notificationWindow &&
|
||||
!notificationWindow.isDestroyed()
|
||||
) {
|
||||
console.log("[NotificationWindow] Re-sending cached data");
|
||||
notificationWindow.webContents.send(
|
||||
"notification:show",
|
||||
lastNotificationData,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// 自动关闭计时器通常由渲染进程管理
|
||||
// 渲染进程发送 'notification:close' 来隐藏窗口
|
||||
}
|
||||
|
||||
export function registerNotificationHandlers() {
|
||||
ipcMain.handle('notification:show', (_, data) => {
|
||||
showNotification(data)
|
||||
})
|
||||
|
||||
ipcMain.handle('notification:close', () => {
|
||||
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
||||
notificationWindow.hide()
|
||||
notificationWindow.setIgnoreMouseEvents(true, { forward: true })
|
||||
}
|
||||
})
|
||||
|
||||
// Handle renderer ready event (fix race condition)
|
||||
ipcMain.on('notification:ready', (event) => {
|
||||
console.log('[NotificationWindow] Renderer ready, checking cached data')
|
||||
if (lastNotificationData && notificationWindow && !notificationWindow.isDestroyed()) {
|
||||
console.log('[NotificationWindow] Re-sending cached data')
|
||||
notificationWindow.webContents.send('notification:show', lastNotificationData)
|
||||
}
|
||||
})
|
||||
|
||||
// Handle resize request from renderer
|
||||
ipcMain.on('notification:resize', (event, { width, height }) => {
|
||||
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
||||
// Enforce max-height if needed, or trust renderer
|
||||
// Ensure it doesn't go off screen bottom?
|
||||
// Logic in showAndSend handles position, but we need to keep anchor point (top-right usually).
|
||||
// If we resize, we should re-calculate position to keep it anchored?
|
||||
// Actually, setSize changes size. If it's top-right, x/y stays same -> window grows down. That's fine for top-right.
|
||||
// If bottom-right, growing down pushes it off screen.
|
||||
|
||||
// Simple version: just setSize. For V1 we assume Top-Right.
|
||||
// But wait, the config supports bottom-right.
|
||||
// We can re-call setPosition or just let it be.
|
||||
// If bottom-right, y needs to prevent overflow.
|
||||
|
||||
// Ideally we get current config position
|
||||
const bounds = notificationWindow.getBounds()
|
||||
// Check if we need to adjust Y?
|
||||
// For now, let's just set the size as requested.
|
||||
notificationWindow.setSize(Math.round(width), Math.round(height))
|
||||
}
|
||||
})
|
||||
|
||||
// 'notification-clicked' 在 main.ts 中处理 (导航)
|
||||
// Handle resize request from renderer
|
||||
ipcMain.on("notification:resize", (event, { width, height }) => {
|
||||
if (isLinux) {
|
||||
// Linux 通知通过D-Bus自动调整大小
|
||||
return;
|
||||
}
|
||||
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
||||
// Enforce max-height if needed, or trust renderer
|
||||
// Ensure it doesn't go off screen bottom?
|
||||
// Logic in showAndSend handles position, but we need to keep anchor point (top-right usually).
|
||||
// If we resize, we should re-calculate position to keep it anchored?
|
||||
// Actually, setSize changes size. If it's top-right, x/y stays same -> window grows down. That's fine for top-right.
|
||||
// If bottom-right, growing down pushes it off screen.
|
||||
|
||||
// Simple version: just setSize. For V1 we assume Top-Right.
|
||||
// But wait, the config supports bottom-right.
|
||||
// We can re-call setPosition or just let it be.
|
||||
// If bottom-right, y needs to prevent overflow.
|
||||
|
||||
// Ideally we get current config position
|
||||
const bounds = notificationWindow.getBounds();
|
||||
// Check if we need to adjust Y?
|
||||
// For now, let's just set the size as requested.
|
||||
notificationWindow.setSize(Math.round(width), Math.round(height));
|
||||
}
|
||||
});
|
||||
|
||||
// 'notification-clicked' 在 main.ts 中处理 (导航)
|
||||
}
|
||||
|
||||
4127
package-lock.json
generated
4127
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
111
package.json
111
package.json
@@ -1,9 +1,12 @@
|
||||
{
|
||||
"name": "weflow",
|
||||
"version": "2.1.0",
|
||||
"version": "4.3.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"
|
||||
@@ -20,9 +23,10 @@
|
||||
"electron:build": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"echarts": "^5.5.1",
|
||||
"@vscode/sudo-prompt": "^9.3.2",
|
||||
"echarts": "^6.0.0",
|
||||
"echarts-for-react": "^3.0.2",
|
||||
"electron-store": "^10.0.0",
|
||||
"electron-store": "^11.0.2",
|
||||
"electron-updater": "^6.3.9",
|
||||
"exceljs": "^4.4.0",
|
||||
"ffmpeg-static": "^5.3.0",
|
||||
@@ -31,14 +35,14 @@
|
||||
"jieba-wasm": "^2.2.0",
|
||||
"jszip": "^3.10.1",
|
||||
"koffi": "^2.9.0",
|
||||
"lucide-react": "^0.562.0",
|
||||
"lucide-react": "^1.7.0",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"react-router-dom": "^7.14.0",
|
||||
"react-virtuoso": "^4.18.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sherpa-onnx-node": "^1.10.38",
|
||||
"sherpa-onnx-node": "^1.12.35",
|
||||
"silk-wasm": "^3.7.1",
|
||||
"wechat-emojis": "^1.0.2",
|
||||
"zustand": "^5.0.2"
|
||||
@@ -48,15 +52,29 @@
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"electron": "^39.2.7",
|
||||
"electron-builder": "^25.1.8",
|
||||
"sass": "^1.83.0",
|
||||
"electron": "^41.1.1",
|
||||
"electron-builder": "^26.8.1",
|
||||
"sass": "^1.99.0",
|
||||
"sharp": "^0.34.5",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^6.0.5",
|
||||
"vite-plugin-electron": "^0.28.8",
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^7.3.2",
|
||||
"vite-plugin-electron": "^0.29.1",
|
||||
"vite-plugin-electron-renderer": "^0.14.6"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"tar": ">=6.2.1",
|
||||
"minimatch": ">=3.1.2",
|
||||
"rollup": ">=4.0.0",
|
||||
"immutable": ">=4.0.0",
|
||||
"lodash": ">=4.17.21",
|
||||
"brace-expansion": ">=1.1.11",
|
||||
"picomatch": ">=2.3.1",
|
||||
"ajv": ">=8.18.0",
|
||||
"ajv-keywords@3>ajv": "^6.12.6",
|
||||
"@develar/schema-utils>ajv": "^6.12.6"
|
||||
}
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.WeFlow.app",
|
||||
"publish": {
|
||||
@@ -80,13 +98,47 @@
|
||||
"gatekeeperAssess": false,
|
||||
"entitlements": "electron/entitlements.mac.plist",
|
||||
"entitlementsInherit": "electron/entitlements.mac.plist",
|
||||
"icon": "resources/icon.icns"
|
||||
"icon": "resources/icons/macos/icon.icns"
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
"nsis"
|
||||
],
|
||||
"icon": "public/icon.ico"
|
||||
"icon": "public/icon.ico",
|
||||
"extraFiles": [
|
||||
{
|
||||
"from": "resources/runtime/win32/msvcp140.dll",
|
||||
"to": "."
|
||||
},
|
||||
{
|
||||
"from": "resources/runtime/win32/msvcp140_1.dll",
|
||||
"to": "."
|
||||
},
|
||||
{
|
||||
"from": "resources/runtime/win32/vcruntime140.dll",
|
||||
"to": "."
|
||||
},
|
||||
{
|
||||
"from": "resources/runtime/win32/vcruntime140_1.dll",
|
||||
"to": "."
|
||||
}
|
||||
]
|
||||
},
|
||||
"linux": {
|
||||
"icon": "public/icon.png",
|
||||
"target": [
|
||||
"appimage",
|
||||
"tar.gz"
|
||||
],
|
||||
"category": "Utility",
|
||||
"executableName": "weflow",
|
||||
"synopsis": "WeFlow for Linux",
|
||||
"extraFiles": [
|
||||
{
|
||||
"from": "resources/installer/linux/install.sh",
|
||||
"to": "install.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
@@ -118,6 +170,10 @@
|
||||
"from": "public/icon.ico",
|
||||
"to": "icon.ico"
|
||||
},
|
||||
{
|
||||
"from": "public/icon.png",
|
||||
"to": "icon.png"
|
||||
},
|
||||
{
|
||||
"from": "electron/assets/wasm/",
|
||||
"to": "assets/wasm/"
|
||||
@@ -134,24 +190,11 @@
|
||||
"node_modules/sherpa-onnx-*/**/*",
|
||||
"node_modules/ffmpeg-static/**/*"
|
||||
],
|
||||
"extraFiles": [
|
||||
{
|
||||
"from": "resources/msvcp140.dll",
|
||||
"to": "."
|
||||
},
|
||||
{
|
||||
"from": "resources/msvcp140_1.dll",
|
||||
"to": "."
|
||||
},
|
||||
{
|
||||
"from": "resources/vcruntime140.dll",
|
||||
"to": "."
|
||||
},
|
||||
{
|
||||
"from": "resources/vcruntime140_1.dll",
|
||||
"to": "."
|
||||
}
|
||||
],
|
||||
"icon": "resources/icon.icns"
|
||||
"icon": "resources/icons/macos/icon.icns"
|
||||
},
|
||||
"overrides": {
|
||||
"picomatch": "^4.0.4",
|
||||
"tar": "^7.5.13",
|
||||
"immutable": "^5.1.5"
|
||||
}
|
||||
}
|
||||
BIN
public/icon.ico
BIN
public/icon.ico
Binary file not shown.
|
Before Width: | Height: | Size: 212 KiB After Width: | Height: | Size: 364 KiB |
BIN
public/icon.png
Normal file
BIN
public/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 570 KiB |
BIN
public/logo.png
BIN
public/logo.png
Binary file not shown.
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 570 KiB |
59
resources/installer/linux/install.sh
Normal file
59
resources/installer/linux/install.sh
Normal file
@@ -0,0 +1,59 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
APP_NAME="weflow"
|
||||
APP_EXEC="weflow"
|
||||
OPT_DIR="/opt/$APP_NAME"
|
||||
BIN_LINK="/usr/bin/$APP_NAME"
|
||||
DESKTOP_DIR="/usr/share/applications"
|
||||
ICON_DIR="/usr/share/pixmaps"
|
||||
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "❌ 请使用 root 权限运行此脚本 (例如: sudo ./install.sh)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "🚀 开始安装 $APP_NAME..."
|
||||
|
||||
echo "📦 正在复制文件到 $OPT_DIR..."
|
||||
rm -rf "$OPT_DIR"
|
||||
mkdir -p "$OPT_DIR"
|
||||
cp -r ./* "$OPT_DIR/"
|
||||
chmod -R 755 "$OPT_DIR"
|
||||
chmod +x "$OPT_DIR/$APP_EXEC"
|
||||
|
||||
echo "🔗 正在创建软链接 $BIN_LINK..."
|
||||
ln -sf "$OPT_DIR/$APP_EXEC" "$BIN_LINK"
|
||||
|
||||
echo "📝 正在创建桌面快捷方式..."
|
||||
cat <<EOF >"$DESKTOP_DIR/${APP_NAME}.desktop"
|
||||
[Desktop Entry]
|
||||
Name=WeFlow
|
||||
Exec=$OPT_DIR/$APP_EXEC %U
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Icon=$APP_NAME
|
||||
StartupWMClass=WeFlow
|
||||
Comment=A local WeChat database decryption and analysis tool
|
||||
Categories=Utility;
|
||||
EOF
|
||||
chmod 644 "$DESKTOP_DIR/${APP_NAME}.desktop"
|
||||
|
||||
echo "🖼️ 正在安装图标..."
|
||||
if [ -f "$OPT_DIR/resources/icon.png" ]; then
|
||||
cp "$OPT_DIR/resources/icon.png" "$ICON_DIR/${APP_NAME}.png"
|
||||
chmod 644 "$ICON_DIR/${APP_NAME}.png"
|
||||
elif [ -f "$OPT_DIR/icon.png" ]; then
|
||||
cp "$OPT_DIR/icon.png" "$ICON_DIR/${APP_NAME}.png"
|
||||
chmod 644 "$ICON_DIR/${APP_NAME}.png"
|
||||
else
|
||||
echo "⚠️ 警告: 未找到图标文件,跳过图标安装。"
|
||||
fi
|
||||
|
||||
if command -v update-desktop-database >/dev/null 2>&1; then
|
||||
echo "🔄 更新桌面数据库..."
|
||||
update-desktop-database "$DESKTOP_DIR"
|
||||
fi
|
||||
|
||||
echo "✅ 安装完成!你现在可以在应用菜单中找到 WeFlow,或者在终端输入 'weflow' 启动。"
|
||||
BIN
resources/key/linux/x64/xkey_helper_linux
Normal file
BIN
resources/key/linux/x64/xkey_helper_linux
Normal file
Binary file not shown.
0
resources/image_scan_helper → resources/key/macos/universal/image_scan_helper
Executable file → Normal file
0
resources/image_scan_helper → resources/key/macos/universal/image_scan_helper
Executable file → Normal file
0
resources/libwx_key.dylib → resources/key/macos/universal/libwx_key.dylib
Executable file → Normal file
0
resources/libwx_key.dylib → resources/key/macos/universal/libwx_key.dylib
Executable file → Normal file
BIN
resources/key/macos/universal/xkey_helper
Normal file
BIN
resources/key/macos/universal/xkey_helper
Normal file
Binary file not shown.
BIN
resources/key/macos/universal/xkey_helper_macos
Normal file
BIN
resources/key/macos/universal/xkey_helper_macos
Normal file
Binary file not shown.
Binary file not shown.
BIN
resources/wcdb/linux/x64/libwcdb_api.so
Normal file
BIN
resources/wcdb/linux/x64/libwcdb_api.so
Normal file
Binary file not shown.
0
resources/macos/libWCDB.dylib → resources/wcdb/macos/universal/libWCDB.dylib
Executable file → Normal file
0
resources/macos/libWCDB.dylib → resources/wcdb/macos/universal/libWCDB.dylib
Executable file → Normal file
BIN
resources/wcdb/macos/universal/libwcdb_api.dylib
Normal file
BIN
resources/wcdb/macos/universal/libwcdb_api.dylib
Normal file
Binary file not shown.
BIN
resources/wcdb/win32/arm64/WCDB.dll
Normal file
BIN
resources/wcdb/win32/arm64/WCDB.dll
Normal file
Binary file not shown.
BIN
resources/wcdb/win32/arm64/wcdb_api.dll
Normal file
BIN
resources/wcdb/win32/arm64/wcdb_api.dll
Normal file
Binary file not shown.
BIN
resources/wcdb/win32/x64/wcdb_api.dll
Normal file
BIN
resources/wcdb/win32/x64/wcdb_api.dll
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
156
src/App.tsx
156
src/App.tsx
@@ -20,7 +20,9 @@ import ExportPage from './pages/ExportPage'
|
||||
import VideoWindow from './pages/VideoWindow'
|
||||
import ImageWindow from './pages/ImageWindow'
|
||||
import SnsPage from './pages/SnsPage'
|
||||
import BizPage from './pages/BizPage'
|
||||
import ContactsPage from './pages/ContactsPage'
|
||||
import ResourcesPage from './pages/ResourcesPage'
|
||||
import ChatHistoryPage from './pages/ChatHistoryPage'
|
||||
import NotificationWindow from './pages/NotificationWindow'
|
||||
|
||||
@@ -37,6 +39,7 @@ 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()
|
||||
@@ -74,7 +77,7 @@ 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'
|
||||
@@ -85,6 +88,8 @@ function App() {
|
||||
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
|
||||
@@ -100,6 +105,45 @@ function App() {
|
||||
|
||||
// 数据收集同意状态
|
||||
const [showAnalyticsConsent, setShowAnalyticsConsent] = useState(false)
|
||||
const [analyticsConsent, setAnalyticsConsent] = useState<boolean | null>(null)
|
||||
|
||||
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') {
|
||||
@@ -107,6 +151,15 @@ function App() {
|
||||
}
|
||||
}, [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
|
||||
@@ -202,6 +255,7 @@ function App() {
|
||||
// 协议已同意,检查数据收集同意状态
|
||||
const consent = await configService.getAnalyticsConsent()
|
||||
const denyCount = await configService.getAnalyticsDenyCount()
|
||||
setAnalyticsConsent(consent)
|
||||
// 如果未设置同意状态且拒绝次数小于2次,显示弹窗
|
||||
if (consent === null && denyCount < 2) {
|
||||
setShowAnalyticsConsent(true)
|
||||
@@ -216,18 +270,21 @@ function App() {
|
||||
checkAgreement()
|
||||
}, [])
|
||||
|
||||
// 初始化数据收集
|
||||
// 初始化数据收集(仅在用户同意后)
|
||||
useEffect(() => {
|
||||
cloudControl.initCloudControl()
|
||||
}, [])
|
||||
if (analyticsConsent === true) {
|
||||
cloudControl.initCloudControl()
|
||||
}
|
||||
}, [analyticsConsent])
|
||||
|
||||
// 记录页面访问
|
||||
// 记录页面访问(仅在用户同意后)
|
||||
useEffect(() => {
|
||||
if (analyticsConsent !== true) return
|
||||
const path = location.pathname
|
||||
if (path && path !== '/') {
|
||||
cloudControl.recordPage(path)
|
||||
}
|
||||
}, [location.pathname])
|
||||
}, [location.pathname, analyticsConsent])
|
||||
|
||||
const handleAgree = async () => {
|
||||
if (!agreementChecked) return
|
||||
@@ -246,6 +303,7 @@ function App() {
|
||||
|
||||
const handleAnalyticsAllow = async () => {
|
||||
await configService.setAnalyticsConsent(true)
|
||||
setAnalyticsConsent(true)
|
||||
setShowAnalyticsConsent(false)
|
||||
}
|
||||
|
||||
@@ -262,10 +320,14 @@ function App() {
|
||||
const removeUpdateListener = window.electronAPI?.app?.onUpdateAvailable?.((info: any) => {
|
||||
// 发现新版本时保存更新信息,锁定状态下不弹窗,解锁后再显示
|
||||
if (info) {
|
||||
setUpdateInfo({ ...info, hasUpdate: true })
|
||||
if (!useAppStore.getState().isLocked) {
|
||||
setShowUpdateDialog(true)
|
||||
}
|
||||
window.electronAPI.app.getVersion().then((currentVersion: string) => {
|
||||
const isMandatory = !!(info.minimumVersion && currentVersion &&
|
||||
currentVersion.localeCompare(info.minimumVersion, undefined, { numeric: true, sensitivity: 'base' }) <= 0)
|
||||
setUpdateInfo({ ...info, hasUpdate: true, isMandatory })
|
||||
if (!useAppStore.getState().isLocked) {
|
||||
setShowUpdateDialog(true)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
const removeProgressListener = window.electronAPI?.app?.onDownloadProgress?.((progress: any) => {
|
||||
@@ -315,6 +377,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
|
||||
@@ -349,7 +431,7 @@ function App() {
|
||||
}
|
||||
} else {
|
||||
|
||||
// 如果错误信息包含 VC++ 或 DLL 相关内容,不清除配置,只提示用户
|
||||
// 如果错误信息包含 VC++ 或数据服务相关内容,不清除配置,只提示用户
|
||||
// 其他错误可能需要重新配置
|
||||
const errorMsg = result.error || ''
|
||||
if (errorMsg.includes('Visual C++') ||
|
||||
@@ -400,6 +482,8 @@ function App() {
|
||||
checkLock()
|
||||
}, [isAgreementWindow, isOnboardingWindow, isVideoPlayerWindow])
|
||||
|
||||
|
||||
|
||||
// 独立协议窗口
|
||||
if (isAgreementWindow) {
|
||||
return <AgreementPage />
|
||||
@@ -508,9 +592,13 @@ function App() {
|
||||
<div className="agreement-notice">
|
||||
<strong>这是免费软件,如果你是付费购买的话请骂死那个骗子。</strong>
|
||||
<span className="agreement-notice-link">
|
||||
我们唯一的官方网站:
|
||||
官方网站:
|
||||
<a href="https://weflow.top" target="_blank" rel="noreferrer">
|
||||
https://weflow.top
|
||||
</a>
|
||||
·
|
||||
<a href="https://github.com/hicccc77/WeFlow" target="_blank" rel="noreferrer">
|
||||
https://github.com/hicccc77/WeFlow
|
||||
GitHub 仓库
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
@@ -525,7 +613,7 @@ function App() {
|
||||
<p>因使用本软件产生的任何直接或间接损失,开发者不承担任何责任。请确保你的使用行为符合当地法律法规。</p>
|
||||
|
||||
<h4>4. 隐私保护</h4>
|
||||
<p>本软件不收集任何用户数据。软件更新检测仅获取版本信息,不涉及任何个人隐私。</p>
|
||||
<p>本软件不收集任何用户隐私数据。软件更新检测仅获取版本信息,不涉及任何个人隐私。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="agreement-footer">
|
||||
@@ -582,17 +670,52 @@ function App() {
|
||||
</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}
|
||||
updateInfo={updateInfo}
|
||||
onClose={() => setShowUpdateDialog(false)}
|
||||
onClose={() => { if (!(updateInfo as any)?.isMandatory) setShowUpdateDialog(false) }}
|
||||
onUpdate={handleUpdateNow}
|
||||
onIgnore={handleIgnoreUpdate}
|
||||
isDownloading={isDownloading}
|
||||
isMandatory={!!(updateInfo as any)?.isMandatory}
|
||||
progress={downloadProgress}
|
||||
/>
|
||||
|
||||
<WindowCloseDialog
|
||||
open={showCloseDialog}
|
||||
canMinimizeToTray={canMinimizeToTray}
|
||||
onSelect={(action, rememberChoice) => handleWindowCloseAction(action, rememberChoice)}
|
||||
onCancel={() => handleWindowCloseAction('cancel')}
|
||||
/>
|
||||
|
||||
<div className="main-layout">
|
||||
<Sidebar collapsed={sidebarCollapsed} />
|
||||
<main className="content">
|
||||
@@ -619,8 +742,11 @@ function App() {
|
||||
|
||||
<Route path="/export" element={<div className="export-route-anchor" aria-hidden="true" />} />
|
||||
<Route path="/sns" element={<SnsPage />} />
|
||||
<Route path="/biz" element={<BizPage />} />
|
||||
<Route path="/contacts" element={<ContactsPage />} />
|
||||
<Route path="/resources" element={<ResourcesPage />} />
|
||||
<Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} />
|
||||
<Route path="/chat-history-inline/:payloadId" element={<ChatHistoryPage />} />
|
||||
</Routes>
|
||||
</RouteGuard>
|
||||
</main>
|
||||
|
||||
@@ -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%" />}
|
||||
|
||||
@@ -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>
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -13,13 +13,14 @@
|
||||
width: min(480px, calc(100vw - 32px));
|
||||
max-height: calc(100vh - 64px);
|
||||
overflow-y: auto;
|
||||
border-radius: 12px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary-solid, var(--bg-primary));
|
||||
padding: 12px;
|
||||
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 {
|
||||
@@ -83,8 +84,8 @@
|
||||
}
|
||||
|
||||
.export-date-range-mode-banner {
|
||||
border-radius: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 10px;
|
||||
padding: 7px 10px;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
border: 1px solid var(--border-color);
|
||||
@@ -98,47 +99,92 @@
|
||||
}
|
||||
}
|
||||
|
||||
.export-date-range-calendar-grid {
|
||||
.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: 8px;
|
||||
background: var(--bg-secondary);
|
||||
padding: 7px;
|
||||
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: flex-start;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.export-date-range-calendar-date-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
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: 6px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
height: 24px;
|
||||
padding: 0 7px;
|
||||
font-size: 11px;
|
||||
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;
|
||||
@@ -149,28 +195,36 @@
|
||||
.export-date-range-calendar-nav {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--text-primary);
|
||||
|
||||
button {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 5px;
|
||||
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: 6px;
|
||||
margin-top: 10px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 2px;
|
||||
gap: 4px;
|
||||
|
||||
span {
|
||||
text-align: center;
|
||||
@@ -180,32 +234,61 @@
|
||||
}
|
||||
|
||||
.export-date-range-calendar-days {
|
||||
margin-top: 4px;
|
||||
margin-top: 6px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 2px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.export-date-range-calendar-day {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
min-height: 20px;
|
||||
border-radius: 10px;
|
||||
min-height: 34px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 10px;
|
||||
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.75;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: var(--primary);
|
||||
background: rgba(var(--primary-rgb), 0.14);
|
||||
&.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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,8 +330,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.export-date-range-calendar-grid {
|
||||
@media (max-width: 640px) {
|
||||
.export-date-range-boundary-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Check, X } from 'lucide-react'
|
||||
import { Check, ChevronLeft, ChevronRight, X } from 'lucide-react'
|
||||
import {
|
||||
EXPORT_DATE_RANGE_PRESETS,
|
||||
WEEKDAY_SHORT_LABELS,
|
||||
@@ -25,29 +25,78 @@ 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 {
|
||||
startPanelMonth: Date
|
||||
endPanelMonth: Date
|
||||
panelMonth: Date
|
||||
}
|
||||
|
||||
const buildDialogDraft = (value: ExportDateRangeSelection): ExportDateRangeDialogDraft => ({
|
||||
...cloneExportDateRangeSelection(value),
|
||||
startPanelMonth: toMonthStart(value.dateRange.start),
|
||||
endPanelMonth: toMonthStart(value.dateRange.end)
|
||||
})
|
||||
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))
|
||||
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)
|
||||
@@ -56,14 +105,15 @@ export function ExportDateRangeDialog({
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const nextDraft = buildDialogDraft(value)
|
||||
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 })
|
||||
}, [open, value])
|
||||
}, [maxDate, minDate, open, value])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
@@ -74,33 +124,24 @@ export function ExportDateRangeDialog({
|
||||
setDateInputError({ start: false, end: false })
|
||||
}, [draft.dateRange.end.getTime(), draft.dateRange.start.getTime(), open])
|
||||
|
||||
const applyPreset = useCallback((preset: Exclude<ExportDateRangePreset, 'custom'>) => {
|
||||
if (preset === 'all') {
|
||||
const previewRange = createDefaultDateRange()
|
||||
setDraft(prev => ({
|
||||
...prev,
|
||||
preset,
|
||||
useAllTime: true,
|
||||
dateRange: previewRange,
|
||||
startPanelMonth: toMonthStart(previewRange.start),
|
||||
endPanelMonth: toMonthStart(previewRange.end)
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
const range = createDateRangeByPreset(preset)
|
||||
setDraft(prev => ({
|
||||
...prev,
|
||||
preset,
|
||||
useAllTime: false,
|
||||
dateRange: range,
|
||||
startPanelMonth: toMonthStart(range.start),
|
||||
endPanelMonth: toMonthStart(range.end)
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const updateDraftStart = useCallback((targetDate: Date) => {
|
||||
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 {
|
||||
@@ -111,16 +152,15 @@ export function ExportDateRangeDialog({
|
||||
start,
|
||||
end: nextEnd
|
||||
},
|
||||
startPanelMonth: toMonthStart(start),
|
||||
endPanelMonth: toMonthStart(nextEnd)
|
||||
panelMonth: toMonthStart(start)
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
}, [clampStartDate])
|
||||
|
||||
const updateDraftEnd = useCallback((targetDate: Date) => {
|
||||
const end = endOfDay(targetDate)
|
||||
const setRangeEnd = useCallback((targetDate: Date) => {
|
||||
const end = clampEndDate(targetDate)
|
||||
setDraft(prev => {
|
||||
const nextStart = prev.useAllTime ? startOfDay(targetDate) : prev.dateRange.start
|
||||
const nextStart = prev.useAllTime ? clampStartDate(targetDate) : prev.dateRange.start
|
||||
const nextEnd = end < nextStart ? endOfDay(nextStart) : end
|
||||
return {
|
||||
...prev,
|
||||
@@ -130,11 +170,41 @@ export function ExportDateRangeDialog({
|
||||
start: nextStart,
|
||||
end: nextEnd
|
||||
},
|
||||
startPanelMonth: toMonthStart(nextStart),
|
||||
endPanelMonth: toMonthStart(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)
|
||||
@@ -143,8 +213,8 @@ export function ExportDateRangeDialog({
|
||||
return
|
||||
}
|
||||
setDateInputError(prev => ({ ...prev, start: false }))
|
||||
updateDraftStart(parsed)
|
||||
}, [dateInput.start, updateDraftStart])
|
||||
setRangeStart(parsed)
|
||||
}, [dateInput.start, setRangeStart])
|
||||
|
||||
const commitEndFromInput = useCallback(() => {
|
||||
const parsed = parseDateInputValue(dateInput.end)
|
||||
@@ -153,29 +223,81 @@ export function ExportDateRangeDialog({
|
||||
return
|
||||
}
|
||||
setDateInputError(prev => ({ ...prev, end: false }))
|
||||
updateDraftEnd(parsed)
|
||||
}, [dateInput.end, updateDraftEnd])
|
||||
setRangeEnd(parsed)
|
||||
}, [dateInput.end, setRangeEnd])
|
||||
|
||||
const shiftPanelMonth = useCallback((panel: 'start' | 'end', delta: number) => {
|
||||
setDraft(prev => (
|
||||
panel === 'start'
|
||||
? { ...prev, startPanelMonth: addMonths(prev.startPanelMonth, delta) }
|
||||
: { ...prev, endPanelMonth: addMonths(prev.endPanelMonth, delta) }
|
||||
))
|
||||
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 startPanelCells = useMemo(() => buildCalendarCells(draft.startPanelMonth), [draft.startPanelMonth])
|
||||
const endPanelCells = useMemo(() => buildCalendarCells(draft.endPanelMonth), [draft.endPanelMonth])
|
||||
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
|
||||
|
||||
@@ -215,112 +337,115 @@ export function ExportDateRangeDialog({
|
||||
{modeText}
|
||||
</div>
|
||||
|
||||
<div className="export-date-range-calendar-grid">
|
||||
<section className="export-date-range-calendar-panel">
|
||||
<div className="export-date-range-calendar-panel-header">
|
||||
<div className="export-date-range-calendar-date-label">
|
||||
<span>起始日期</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 }))
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter') return
|
||||
event.preventDefault()
|
||||
commitStartFromInput()
|
||||
}}
|
||||
onBlur={commitStartFromInput}
|
||||
/>
|
||||
</div>
|
||||
<div className="export-date-range-calendar-nav">
|
||||
<button type="button" onClick={() => shiftPanelMonth('start', -1)} aria-label="上个月">‹</button>
|
||||
<span>{formatCalendarMonthTitle(draft.startPanelMonth)}</span>
|
||||
<button type="button" onClick={() => shiftPanelMonth('start', 1)} aria-label="下个月">›</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="export-date-range-calendar-weekdays">
|
||||
{WEEKDAY_SHORT_LABELS.map(label => (
|
||||
<span key={`start-weekday-${label}`}>{label}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="export-date-range-calendar-days">
|
||||
{startPanelCells.map((cell) => {
|
||||
const selected = !draft.useAllTime && isSameDay(cell.date, draft.dateRange.start)
|
||||
return (
|
||||
<button
|
||||
key={`start-${cell.date.getTime()}`}
|
||||
type="button"
|
||||
className={`export-date-range-calendar-day ${cell.inCurrentMonth ? '' : 'outside'} ${selected ? 'selected' : ''}`}
|
||||
onClick={() => updateDraftStart(cell.date)}
|
||||
>
|
||||
{cell.date.getDate()}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="export-date-range-calendar-panel">
|
||||
<div className="export-date-range-calendar-panel-header">
|
||||
<div className="export-date-range-calendar-date-label">
|
||||
<span>截止日期</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 }))
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter') return
|
||||
event.preventDefault()
|
||||
commitEndFromInput()
|
||||
}}
|
||||
onBlur={commitEndFromInput}
|
||||
/>
|
||||
</div>
|
||||
<div className="export-date-range-calendar-nav">
|
||||
<button type="button" onClick={() => shiftPanelMonth('end', -1)} aria-label="上个月">‹</button>
|
||||
<span>{formatCalendarMonthTitle(draft.endPanelMonth)}</span>
|
||||
<button type="button" onClick={() => shiftPanelMonth('end', 1)} aria-label="下个月">›</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="export-date-range-calendar-weekdays">
|
||||
{WEEKDAY_SHORT_LABELS.map(label => (
|
||||
<span key={`end-weekday-${label}`}>{label}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="export-date-range-calendar-days">
|
||||
{endPanelCells.map((cell) => {
|
||||
const selected = !draft.useAllTime && isSameDay(cell.date, draft.dateRange.end)
|
||||
return (
|
||||
<button
|
||||
key={`end-${cell.date.getTime()}`}
|
||||
type="button"
|
||||
className={`export-date-range-calendar-day ${cell.inCurrentMonth ? '' : 'outside'} ${selected ? 'selected' : ''}`}
|
||||
onClick={() => updateDraftEnd(cell.date)}
|
||||
>
|
||||
{cell.date.getDate()}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
<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}>
|
||||
取消
|
||||
|
||||
@@ -66,7 +66,8 @@ export function ExportDefaultsSettingsForm({
|
||||
images: true,
|
||||
videos: true,
|
||||
voices: true,
|
||||
emojis: true
|
||||
emojis: true,
|
||||
files: true
|
||||
})
|
||||
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false)
|
||||
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
|
||||
@@ -94,7 +95,8 @@ export function ExportDefaultsSettingsForm({
|
||||
images: true,
|
||||
videos: true,
|
||||
voices: true,
|
||||
emojis: true
|
||||
emojis: true,
|
||||
files: true
|
||||
})
|
||||
setExportDefaultVoiceAsText(savedVoiceAsText ?? false)
|
||||
setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true)
|
||||
@@ -292,7 +294,7 @@ export function ExportDefaultsSettingsForm({
|
||||
<div className="form-group media-setting-group">
|
||||
<div className="form-copy">
|
||||
<label>默认导出媒体内容</label>
|
||||
<span className="form-hint">控制图片、视频、语音、表情包的默认导出开关</span>
|
||||
<span className="form-hint">控制图片、视频、语音、表情包、文件的默认导出开关</span>
|
||||
</div>
|
||||
<div className="form-control">
|
||||
<div className="media-default-grid">
|
||||
@@ -352,6 +354,20 @@ export function ExportDefaultsSettingsForm({
|
||||
/>
|
||||
表情包
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={exportDefaultMedia.files}
|
||||
onChange={async (e) => {
|
||||
const next = { ...exportDefaultMedia, files: e.target.checked }
|
||||
setExportDefaultMedia(next)
|
||||
await configService.setExportDefaultMedia(next)
|
||||
onDefaultsChanged?.({ media: next })
|
||||
notify(`已${e.target.checked ? '开启' : '关闭'}默认导出文件`, true)
|
||||
}}
|
||||
/>
|
||||
文件
|
||||
</label>
|
||||
</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() {
|
||||
@@ -20,9 +20,9 @@ export function GlobalSessionMonitor() {
|
||||
}, [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}`
|
||||
}
|
||||
|
||||
// 处理数据库变更
|
||||
@@ -267,7 +267,12 @@ export function GlobalSessionMonitor() {
|
||||
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,8 @@
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
border: none;
|
||||
background: transparent;
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
@@ -172,6 +174,33 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.year-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 6px;
|
||||
|
||||
.year-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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useMemo } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { X, ChevronLeft, ChevronRight, Calendar as CalendarIcon, Loader2 } from 'lucide-react'
|
||||
import './JumpToDateDialog.scss'
|
||||
|
||||
@@ -21,10 +21,15 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
|
||||
messageDates,
|
||||
loadingDates = false
|
||||
}) => {
|
||||
type CalendarViewMode = 'day' | 'month' | 'year'
|
||||
const getYearPageStart = (year: number): number => Math.floor(year / 12) * 12
|
||||
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)
|
||||
const [viewMode, setViewMode] = useState<CalendarViewMode>('day')
|
||||
const [yearPageStart, setYearPageStart] = useState<number>(
|
||||
getYearPageStart((isValidDate(currentDate) ? new Date(currentDate) : new Date()).getFullYear())
|
||||
)
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
@@ -116,6 +121,57 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
|
||||
|
||||
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
|
||||
const days = generateCalendar()
|
||||
const monthNames = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月']
|
||||
|
||||
const updateCalendarDate = (nextDate: Date) => {
|
||||
setCalendarDate(nextDate)
|
||||
}
|
||||
|
||||
const openMonthView = () => setViewMode('month')
|
||||
const openYearView = () => {
|
||||
setYearPageStart(getYearPageStart(calendarDate.getFullYear()))
|
||||
setViewMode('year')
|
||||
}
|
||||
|
||||
const handleTitleClick = () => {
|
||||
if (viewMode === 'day') {
|
||||
openMonthView()
|
||||
return
|
||||
}
|
||||
if (viewMode === 'month') {
|
||||
openYearView()
|
||||
}
|
||||
}
|
||||
|
||||
const handlePrev = () => {
|
||||
if (viewMode === 'day') {
|
||||
updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))
|
||||
return
|
||||
}
|
||||
if (viewMode === 'month') {
|
||||
updateCalendarDate(new Date(calendarDate.getFullYear() - 1, calendarDate.getMonth(), 1))
|
||||
return
|
||||
}
|
||||
setYearPageStart((prev) => prev - 12)
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
if (viewMode === 'day') {
|
||||
updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))
|
||||
return
|
||||
}
|
||||
if (viewMode === 'month') {
|
||||
updateCalendarDate(new Date(calendarDate.getFullYear() + 1, calendarDate.getMonth(), 1))
|
||||
return
|
||||
}
|
||||
setYearPageStart((prev) => prev + 12)
|
||||
}
|
||||
|
||||
const navTitle = viewMode === 'day'
|
||||
? `${calendarDate.getFullYear()}年${calendarDate.getMonth() + 1}月`
|
||||
: viewMode === 'month'
|
||||
? `${calendarDate.getFullYear()}年`
|
||||
: `${yearPageStart}年 - ${yearPageStart + 11}年`
|
||||
|
||||
return (
|
||||
<div className="jump-date-overlay" onClick={onClose}>
|
||||
@@ -134,45 +190,57 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
|
||||
<div className="calendar-nav">
|
||||
<button
|
||||
className="nav-btn"
|
||||
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))}
|
||||
onClick={handlePrev}
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
<span className="current-month clickable" onClick={() => setShowYearMonthPicker(!showYearMonthPicker)}>
|
||||
{calendarDate.getFullYear()}年{calendarDate.getMonth() + 1}月
|
||||
</span>
|
||||
<button
|
||||
className={`current-month ${viewMode === 'year' ? '' : 'clickable'}`.trim()}
|
||||
onClick={handleTitleClick}
|
||||
type="button"
|
||||
>
|
||||
{navTitle}
|
||||
</button>
|
||||
<button
|
||||
className="nav-btn"
|
||||
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))}
|
||||
onClick={handleNext}
|
||||
>
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showYearMonthPicker ? (
|
||||
{viewMode === 'month' ? (
|
||||
<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) => (
|
||||
{monthNames.map((name, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className={`month-btn ${i === calendarDate.getMonth() ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setCalendarDate(new Date(calendarDate.getFullYear(), i, 1))
|
||||
setShowYearMonthPicker(false)
|
||||
updateCalendarDate(new Date(calendarDate.getFullYear(), i, 1))
|
||||
setViewMode('day')
|
||||
}}
|
||||
>{name}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : viewMode === 'year' ? (
|
||||
<div className="year-month-picker">
|
||||
<div className="year-grid">
|
||||
{Array.from({ length: 12 }, (_, i) => yearPageStart + i).map((year) => (
|
||||
<button
|
||||
key={year}
|
||||
className={`year-btn ${year === calendarDate.getFullYear() ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
updateCalendarDate(new Date(year, calendarDate.getMonth(), 1))
|
||||
setViewMode('month')
|
||||
}}
|
||||
>
|
||||
{year}年
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`calendar-grid ${loadingDates ? 'loading' : ''}`}>
|
||||
{loadingDates && (
|
||||
@@ -208,18 +276,21 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
|
||||
const d = new Date()
|
||||
setSelectedDate(d)
|
||||
setCalendarDate(new Date(d))
|
||||
setViewMode('day')
|
||||
}}>今天</button>
|
||||
<button onClick={() => {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - 7)
|
||||
setSelectedDate(d)
|
||||
setCalendarDate(new Date(d))
|
||||
setViewMode('day')
|
||||
}}>一周前</button>
|
||||
<button onClick={() => {
|
||||
const d = new Date()
|
||||
d.setMonth(d.getMonth() - 1)
|
||||
setSelectedDate(d)
|
||||
setCalendarDate(new Date(d))
|
||||
setViewMode('day')
|
||||
}}>一月前</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -28,6 +28,20 @@
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 8px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.jump-date-popover .current-month.clickable {
|
||||
cursor: pointer;
|
||||
transition: all 0.18s ease;
|
||||
}
|
||||
|
||||
.jump-date-popover .current-month.clickable:hover {
|
||||
color: var(--primary);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.jump-date-popover .nav-btn {
|
||||
@@ -83,6 +97,37 @@
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.jump-date-popover .month-grid,
|
||||
.jump-date-popover .year-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 6px;
|
||||
min-height: 256px;
|
||||
}
|
||||
|
||||
.jump-date-popover .month-cell,
|
||||
.jump-date-popover .year-cell {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.18s ease;
|
||||
}
|
||||
|
||||
.jump-date-popover .month-cell:hover,
|
||||
.jump-date-popover .year-cell:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.jump-date-popover .month-cell.active,
|
||||
.jump-date-popover .year-cell.active {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.jump-date-popover .day-cell {
|
||||
position: relative;
|
||||
border: 1px solid transparent;
|
||||
@@ -137,18 +182,22 @@
|
||||
margin-top: 1px;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
color: #16a34a;
|
||||
color: var(--primary, #07c160);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.jump-date-popover .day-cell.selected .day-count {
|
||||
color: #86efac;
|
||||
color: color-mix(in srgb, #ffffff 78%, var(--primary, #07c160) 22%);
|
||||
}
|
||||
|
||||
.jump-date-popover .day-count-loading {
|
||||
position: static;
|
||||
margin-top: 1px;
|
||||
color: #22c55e;
|
||||
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 {
|
||||
|
||||
@@ -31,14 +31,20 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
|
||||
loadingDates = false,
|
||||
loadingDateCounts = false
|
||||
}) => {
|
||||
type CalendarViewMode = 'day' | 'month' | 'year'
|
||||
const getYearPageStart = (year: number): number => Math.floor(year / 12) * 12
|
||||
const [calendarDate, setCalendarDate] = useState<Date>(new Date(currentDate))
|
||||
const [selectedDate, setSelectedDate] = useState<Date>(new Date(currentDate))
|
||||
const [viewMode, setViewMode] = useState<CalendarViewMode>('day')
|
||||
const [yearPageStart, setYearPageStart] = useState<number>(getYearPageStart(new Date(currentDate).getFullYear()))
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const normalized = new Date(currentDate)
|
||||
setCalendarDate(normalized)
|
||||
setSelectedDate(normalized)
|
||||
setViewMode('day')
|
||||
setYearPageStart(getYearPageStart(normalized.getFullYear()))
|
||||
}, [isOpen, currentDate])
|
||||
|
||||
if (!isOpen) return null
|
||||
@@ -114,25 +120,78 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
|
||||
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
|
||||
const days = generateCalendar()
|
||||
const mergedClassName = ['jump-date-popover', className || ''].join(' ').trim()
|
||||
|
||||
const updateCalendarDate = (nextDate: Date) => {
|
||||
setCalendarDate(nextDate)
|
||||
onMonthChange?.(nextDate)
|
||||
}
|
||||
|
||||
const openMonthView = () => setViewMode('month')
|
||||
const openYearView = () => {
|
||||
setYearPageStart(getYearPageStart(calendarDate.getFullYear()))
|
||||
setViewMode('year')
|
||||
}
|
||||
|
||||
const handleTitleClick = () => {
|
||||
if (viewMode === 'day') {
|
||||
openMonthView()
|
||||
return
|
||||
}
|
||||
if (viewMode === 'month') {
|
||||
openYearView()
|
||||
}
|
||||
}
|
||||
|
||||
const handlePrev = () => {
|
||||
if (viewMode === 'day') {
|
||||
updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))
|
||||
return
|
||||
}
|
||||
if (viewMode === 'month') {
|
||||
updateCalendarDate(new Date(calendarDate.getFullYear() - 1, calendarDate.getMonth(), 1))
|
||||
return
|
||||
}
|
||||
setYearPageStart((prev) => prev - 12)
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
if (viewMode === 'day') {
|
||||
updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))
|
||||
return
|
||||
}
|
||||
if (viewMode === 'month') {
|
||||
updateCalendarDate(new Date(calendarDate.getFullYear() + 1, calendarDate.getMonth(), 1))
|
||||
return
|
||||
}
|
||||
setYearPageStart((prev) => prev + 12)
|
||||
}
|
||||
|
||||
const navTitle = viewMode === 'day'
|
||||
? `${calendarDate.getFullYear()}年${calendarDate.getMonth() + 1}月`
|
||||
: viewMode === 'month'
|
||||
? `${calendarDate.getFullYear()}年`
|
||||
: `${yearPageStart}年 - ${yearPageStart + 11}年`
|
||||
|
||||
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))}
|
||||
onClick={handlePrev}
|
||||
aria-label="上一月"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<span className="current-month">{calendarDate.getFullYear()}年{calendarDate.getMonth() + 1}月</span>
|
||||
<button
|
||||
className={`current-month ${viewMode === 'year' ? '' : 'clickable'}`.trim()}
|
||||
onClick={handleTitleClick}
|
||||
type="button"
|
||||
>
|
||||
{navTitle}
|
||||
</button>
|
||||
<button
|
||||
className="nav-btn"
|
||||
onClick={() => updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))}
|
||||
onClick={handleNext}
|
||||
aria-label="下一月"
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
@@ -154,36 +213,74 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="calendar-grid">
|
||||
<div className="weekdays">
|
||||
{weekdays.map(day => (
|
||||
<div key={day} className="weekday">{day}</div>
|
||||
{viewMode === 'day' && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{viewMode === 'month' && (
|
||||
<div className="month-grid">
|
||||
{['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'].map((name, monthIndex) => (
|
||||
<button
|
||||
key={name}
|
||||
className={`month-cell ${monthIndex === calendarDate.getMonth() ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
updateCalendarDate(new Date(calendarDate.getFullYear(), monthIndex, 1))
|
||||
setViewMode('day')
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</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>
|
||||
)
|
||||
})}
|
||||
)}
|
||||
|
||||
{viewMode === 'year' && (
|
||||
<div className="year-grid">
|
||||
{Array.from({ length: 12 }, (_, i) => yearPageStart + i).map((year) => (
|
||||
<button
|
||||
key={year}
|
||||
className={`year-cell ${year === calendarDate.getFullYear() ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
updateCalendarDate(new Date(year, calendarDate.getMonth(), 1))
|
||||
setViewMode('month')
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{year}年
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -134,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
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
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 { Home, MessageSquare, BarChart3, FileText, Settings, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, RefreshCw, FolderClosed } 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'
|
||||
|
||||
@@ -35,6 +36,7 @@ interface AccountProfilesCache {
|
||||
interface WxidOption {
|
||||
wxid: string
|
||||
modifiedTime: number
|
||||
nickname?: string
|
||||
displayName?: string
|
||||
avatarUrl?: string
|
||||
}
|
||||
@@ -280,26 +282,28 @@ function Sidebar({ collapsed }: SidebarProps) {
|
||||
const accountsCache = readAccountProfilesCache()
|
||||
console.log('[切换账号] 账号缓存:', accountsCache)
|
||||
|
||||
const enrichedWxids = wxids.map(option => {
|
||||
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) {
|
||||
return {
|
||||
...option,
|
||||
displayName: userProfile.displayName,
|
||||
avatarUrl: userProfile.avatarUrl
|
||||
}
|
||||
displayName = userProfile.displayName || displayName
|
||||
avatarUrl = userProfile.avatarUrl || avatarUrl
|
||||
}
|
||||
if (cached) {
|
||||
console.log('[切换账号] 使用缓存:', option.wxid, cached)
|
||||
return {
|
||||
...option,
|
||||
displayName: cached.displayName,
|
||||
avatarUrl: cached.avatarUrl
|
||||
}
|
||||
|
||||
else if (cached) {
|
||||
displayName = cached.displayName || displayName
|
||||
avatarUrl = cached.avatarUrl || avatarUrl
|
||||
}
|
||||
|
||||
return {
|
||||
...option,
|
||||
displayName,
|
||||
avatarUrl
|
||||
}
|
||||
return { ...option, displayName: option.wxid }
|
||||
})
|
||||
|
||||
setWxidOptions(enrichedWxids)
|
||||
@@ -425,6 +429,16 @@ function Sidebar({ collapsed }: SidebarProps) {
|
||||
<span className="nav-label">通讯录</span>
|
||||
</NavLink>
|
||||
|
||||
{/* 资源浏览 */}
|
||||
<NavLink
|
||||
to="/resources"
|
||||
className={`nav-item ${isActive('/resources') ? 'active' : ''}`}
|
||||
title={collapsed ? '资源浏览' : undefined}
|
||||
>
|
||||
<span className="nav-icon"><FolderClosed size={20} /></span>
|
||||
<span className="nav-label">资源浏览</span>
|
||||
</NavLink>
|
||||
|
||||
{/* 聊天分析 */}
|
||||
<NavLink
|
||||
to="/analytics"
|
||||
@@ -553,11 +567,17 @@ function Sidebar({ collapsed }: SidebarProps) {
|
||||
type="button"
|
||||
>
|
||||
<div className="wxid-avatar">
|
||||
{option.avatarUrl ? <img src={option.avatarUrl} alt="" /> : <span>{getAvatarLetter(option.displayName || option.wxid)}</span>}
|
||||
{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 || option.wxid}</div>
|
||||
<div className="wxid-id">{option.wxid}</div>
|
||||
<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>
|
||||
|
||||
@@ -29,6 +29,7 @@ interface SnsFilterPanelProps {
|
||||
activeContactUsername?: string
|
||||
onOpenContactTimeline: (contact: Contact) => void
|
||||
onToggleContactSelected: (contact: Contact) => void
|
||||
onToggleFilteredContacts: (usernames: string[], shouldSelect: boolean) => void
|
||||
onClearSelectedContacts: () => void
|
||||
onExportSelectedContacts: () => void
|
||||
}
|
||||
@@ -46,6 +47,7 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
|
||||
activeContactUsername,
|
||||
onOpenContactTimeline,
|
||||
onToggleContactSelected,
|
||||
onToggleFilteredContacts,
|
||||
onClearSelectedContacts,
|
||||
onExportSelectedContacts
|
||||
}) => {
|
||||
@@ -57,6 +59,16 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
|
||||
() => new Set(selectedContactUsernames),
|
||||
[selectedContactUsernames]
|
||||
)
|
||||
const filteredContactUsernames = React.useMemo(
|
||||
() => filteredContacts.map((contact) => contact.username),
|
||||
[filteredContacts]
|
||||
)
|
||||
const selectedFilteredCount = React.useMemo(
|
||||
() => filteredContactUsernames.filter((username) => selectedContactLookup.has(username)).length,
|
||||
[filteredContactUsernames, selectedContactLookup]
|
||||
)
|
||||
const hasFilteredContacts = filteredContactUsernames.length > 0
|
||||
const allFilteredSelected = hasFilteredContacts && selectedFilteredCount === filteredContactUsernames.length
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchKeyword('')
|
||||
@@ -128,6 +140,20 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="contact-selection-toolbar">
|
||||
<span className="contact-selection-summary">
|
||||
当前 {filteredContactUsernames.length} 人,已选 {selectedFilteredCount} 人
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`contact-selection-toggle${allFilteredSelected ? ' active' : ''}`}
|
||||
onClick={() => onToggleFilteredContacts(filteredContactUsernames, !allFilteredSelected)}
|
||||
disabled={!hasFilteredContacts}
|
||||
>
|
||||
{allFilteredSelected ? '取消全选' : '全选'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{contactsCountProgress && contactsCountProgress.total > 0 && (
|
||||
<div className="contact-count-progress">
|
||||
{contactsCountProgress.running
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user