Compare commits
447 Commits
v4.1.6
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe02ff0d84 | ||
|
|
33fde44cc3 | ||
|
|
fc9b1ead9e | ||
|
|
c5f629ac4a | ||
|
|
898d2c7f29 | ||
|
|
4aa0f517bf | ||
|
|
682f43bf2f | ||
|
|
bc2e7d616a | ||
|
|
ef2bbe5c22 | ||
|
|
4de4a74eca | ||
|
|
0ba1067123 | ||
|
|
b7c7ca4376 | ||
|
|
c91163abac | ||
|
|
f40f3225df | ||
|
|
3a99eb8338 | ||
|
|
a902ef70d9 | ||
|
|
c9498d5079 | ||
|
|
b9d8b303a1 | ||
|
|
c94405e5bb | ||
|
|
9697dcb703 | ||
|
|
55ee72225e | ||
|
|
e47eaf273e | ||
|
|
aa16c87afc | ||
|
|
bd439b7179 | ||
|
|
678c08b507 | ||
|
|
216a6011bd | ||
|
|
e12caa16a6 | ||
|
|
55885449a3 | ||
|
|
06c020d9ca | ||
|
|
da84623898 | ||
|
|
5221d427ed | ||
|
|
6c84e0c35a | ||
|
|
167ce3fae0 | ||
|
|
b8bcfa23be | ||
|
|
3bff868df1 | ||
|
|
74012ab252 | ||
|
|
574ba94e0e | ||
|
|
6fdeaacb5c | ||
|
|
a1ab0834b7 | ||
|
|
ade07b8578 | ||
|
|
00bd632ad9 | ||
|
|
e83fcfdc4c | ||
|
|
95dd2ea551 | ||
|
|
a36da9d565 | ||
|
|
111a1961bf | ||
|
|
fd4a214f9f | ||
|
|
f9122492db | ||
|
|
ab1d64e0c9 | ||
|
|
93bafbd9f7 | ||
|
|
419a53d6ec | ||
|
|
2b22975933 | ||
|
|
e049bfd606 | ||
|
|
a377669b73 | ||
|
|
5da4454af9 | ||
|
|
5588721566 | ||
|
|
2e77d9468a | ||
|
|
9f45c3f5eb | ||
|
|
1921d36e17 | ||
|
|
72569a520e | ||
|
|
00b63eed54 | ||
|
|
9af1a0ad56 | ||
|
|
7aeff80bf9 | ||
|
|
0d387f05de | ||
|
|
f40b039426 | ||
|
|
0cbba05263 | ||
|
|
1904aa918e | ||
|
|
a05cde93bd | ||
|
|
8f7ece7691 | ||
|
|
86daa8ef06 | ||
|
|
24eceef6cb | ||
|
|
4ba567ca09 | ||
|
|
4446d9439d | ||
|
|
6225df296c | ||
|
|
9f3736ef40 | ||
|
|
1be03734a4 | ||
|
|
7435ab49ab | ||
|
|
9c5426159d | ||
|
|
f3bb548626 | ||
|
|
34cdaa508c | ||
|
|
a734cedac1 | ||
|
|
5da98ddc8a | ||
|
|
e79d18da03 | ||
|
|
69a598f196 | ||
|
|
ac84606f20 | ||
|
|
b086507569 | ||
|
|
360f4917b1 | ||
|
|
89d0f22dac | ||
|
|
f4d63d01bd | ||
|
|
59a0b1bf16 | ||
|
|
48ca54a856 | ||
|
|
bf3dfbba0f | ||
|
|
bd1bd8a8aa | ||
|
|
7e1ca95bef | ||
|
|
b7cb2cd42d | ||
|
|
6359123323 | ||
|
|
f2f78bb4e2 | ||
|
|
716b21b0dd | ||
|
|
cde3590986 | ||
|
|
f89ad6ec15 | ||
|
|
4efa169313 | ||
|
|
933912f15d | ||
|
|
4e216ce036 | ||
|
|
567fcd3683 | ||
|
|
49ab0de7b3 | ||
|
|
0f34222954 | ||
|
|
caf5b0c9db | ||
|
|
f2d6188c53 | ||
|
|
b9af7ffc8c | ||
|
|
5bec4f3cd6 | ||
|
|
726edfa850 | ||
|
|
ff33242887 | ||
|
|
a26d5620ca | ||
|
|
8a3f1078f6 | ||
|
|
56b767ff46 | ||
|
|
102eb14b0b | ||
|
|
e57b9d07f1 | ||
|
|
3be90d00e5 | ||
|
|
efb5cd3586 | ||
|
|
86b1043134 | ||
|
|
36bed846b2 | ||
|
|
9d3d38fa7e | ||
|
|
ddf6b63aec | ||
|
|
079779c2c6 | ||
|
|
afa8bb5fe0 | ||
|
|
127668ae22 | ||
|
|
b00264d060 | ||
|
|
2e135587d4 | ||
|
|
571bffa923 | ||
|
|
bc355d43a0 | ||
|
|
e2a207be92 | ||
|
|
397cc888db | ||
|
|
22a2616534 | ||
|
|
d6c9a10766 | ||
|
|
657e8015b2 | ||
|
|
fc3612abb2 | ||
|
|
8d79a82ac2 | ||
|
|
234cf690f0 | ||
|
|
d768c8d08c | ||
|
|
e98e9cb7d9 | ||
|
|
8e8f1b3d22 | ||
|
|
5b6117ec28 | ||
|
|
33188485b7 | ||
|
|
08bd5e5435 | ||
|
|
714827a36d | ||
|
|
7a51d8cf64 | ||
|
|
902d2c9c74 | ||
|
|
d96000f0d9 | ||
|
|
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 | ||
|
|
b5a371da87 |
7
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "npm"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "daily"
|
||||||
|
target-branch: "dev"
|
||||||
212
.github/scripts/release-utils.sh
vendored
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
retry_cmd() {
|
||||||
|
local attempts="$1"
|
||||||
|
local delay_seconds="$2"
|
||||||
|
shift 2
|
||||||
|
|
||||||
|
local i
|
||||||
|
local exit_code
|
||||||
|
for ((i = 1; i <= attempts; i++)); do
|
||||||
|
if "$@"; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
exit_code=$?
|
||||||
|
if [ "$i" -lt "$attempts" ]; then
|
||||||
|
echo "Command failed (attempt $i/$attempts, exit=$exit_code): $*" >&2
|
||||||
|
echo "Retrying in ${delay_seconds}s..." >&2
|
||||||
|
sleep "$delay_seconds"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Command failed after $attempts attempts: $*" >&2
|
||||||
|
return "$exit_code"
|
||||||
|
}
|
||||||
|
|
||||||
|
capture_cmd_with_retry() {
|
||||||
|
local result_var="$1"
|
||||||
|
local attempts="$2"
|
||||||
|
local delay_seconds="$3"
|
||||||
|
shift 3
|
||||||
|
|
||||||
|
local i
|
||||||
|
local output=""
|
||||||
|
local exit_code=1
|
||||||
|
for ((i = 1; i <= attempts; i++)); do
|
||||||
|
if output="$("$@" 2>/dev/null)"; then
|
||||||
|
printf -v "$result_var" "%s" "$output"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
exit_code=$?
|
||||||
|
if [ "$i" -lt "$attempts" ]; then
|
||||||
|
echo "Capture command failed (attempt $i/$attempts, exit=$exit_code): $*" >&2
|
||||||
|
echo "Retrying in ${delay_seconds}s..." >&2
|
||||||
|
sleep "$delay_seconds"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Capture command failed after $attempts attempts: $*" >&2
|
||||||
|
return "$exit_code"
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_release_id() {
|
||||||
|
local repo="$1"
|
||||||
|
local tag="$2"
|
||||||
|
local attempts="${3:-12}"
|
||||||
|
local delay_seconds="${4:-2}"
|
||||||
|
|
||||||
|
local i
|
||||||
|
local release_id
|
||||||
|
for ((i = 1; i <= attempts; i++)); do
|
||||||
|
release_id="$(gh api "repos/$repo/releases/tags/$tag" --jq '.id' 2>/dev/null || true)"
|
||||||
|
if [[ "$release_id" =~ ^[0-9]+$ ]]; then
|
||||||
|
echo "$release_id"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [ "$i" -lt "$attempts" ]; then
|
||||||
|
echo "Release id for tag '$tag' is not ready yet (attempt $i/$attempts), retrying in ${delay_seconds}s..." >&2
|
||||||
|
sleep "$delay_seconds"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Unable to fetch release id for tag '$tag' after $attempts attempts." >&2
|
||||||
|
gh api "repos/$repo/releases/tags/$tag" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}' 2>/dev/null || true
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
settle_release_state() {
|
||||||
|
local repo="$1"
|
||||||
|
local release_id="$2"
|
||||||
|
local tag="$3"
|
||||||
|
local attempts="${4:-12}"
|
||||||
|
local delay_seconds="${5:-2}"
|
||||||
|
local endpoint="repos/$repo/releases/tags/$tag"
|
||||||
|
|
||||||
|
local i
|
||||||
|
local draft_state
|
||||||
|
local prerelease_state
|
||||||
|
for ((i = 1; i <= attempts; i++)); do
|
||||||
|
gh api --method PATCH "repos/$repo/releases/$release_id" -F draft=false -F prerelease=true >/dev/null 2>&1 || true
|
||||||
|
draft_state="$(gh api "$endpoint" --jq '.draft' 2>/dev/null || echo true)"
|
||||||
|
prerelease_state="$(gh api "$endpoint" --jq '.prerelease' 2>/dev/null || echo false)"
|
||||||
|
if [ "$draft_state" = "false" ] && [ "$prerelease_state" = "true" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [ "$i" -lt "$attempts" ]; then
|
||||||
|
echo "Release '$tag' state not settled yet (attempt $i/$attempts), retrying in ${delay_seconds}s..." >&2
|
||||||
|
sleep "$delay_seconds"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Failed to settle release state for tag '$tag'." >&2
|
||||||
|
gh api "$endpoint" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}' 2>/dev/null || true
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_release_absent() {
|
||||||
|
local repo="$1"
|
||||||
|
local tag="$2"
|
||||||
|
local attempts="${3:-12}"
|
||||||
|
local delay_seconds="${4:-2}"
|
||||||
|
|
||||||
|
local i
|
||||||
|
for ((i = 1; i <= attempts; i++)); do
|
||||||
|
if gh release view "$tag" --repo "$repo" >/dev/null 2>&1; then
|
||||||
|
if [ "$i" -lt "$attempts" ]; then
|
||||||
|
echo "Release '$tag' still exists (attempt $i/$attempts), waiting ${delay_seconds}s..." >&2
|
||||||
|
sleep "$delay_seconds"
|
||||||
|
fi
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Release '$tag' still exists after waiting." >&2
|
||||||
|
gh release view "$tag" --repo "$repo" --json url,isDraft,isPrerelease 2>/dev/null || true
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_git_tag_absent() {
|
||||||
|
local repo="$1"
|
||||||
|
local tag="$2"
|
||||||
|
local attempts="${3:-12}"
|
||||||
|
local delay_seconds="${4:-2}"
|
||||||
|
|
||||||
|
local i
|
||||||
|
for ((i = 1; i <= attempts; i++)); do
|
||||||
|
if gh api "repos/$repo/git/ref/tags/$tag" >/dev/null 2>&1; then
|
||||||
|
if [ "$i" -lt "$attempts" ]; then
|
||||||
|
echo "Git tag '$tag' still exists (attempt $i/$attempts), waiting ${delay_seconds}s..." >&2
|
||||||
|
sleep "$delay_seconds"
|
||||||
|
fi
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Git tag '$tag' still exists after waiting." >&2
|
||||||
|
gh api "repos/$repo/git/ref/tags/$tag" --jq '{ref: .ref, object: .object.sha}' 2>/dev/null || true
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
recreate_fixed_prerelease() {
|
||||||
|
local repo="$1"
|
||||||
|
local tag="$2"
|
||||||
|
local target_branch="$3"
|
||||||
|
local release_title="$4"
|
||||||
|
local release_notes="$5"
|
||||||
|
|
||||||
|
if gh release view "$tag" --repo "$repo" >/dev/null 2>&1; then
|
||||||
|
retry_cmd 5 3 gh release delete "$tag" --repo "$repo" --yes --cleanup-tag
|
||||||
|
fi
|
||||||
|
|
||||||
|
wait_for_release_absent "$repo" "$tag" 12 2
|
||||||
|
|
||||||
|
if gh api "repos/$repo/git/ref/tags/$tag" >/dev/null 2>&1; then
|
||||||
|
retry_cmd 5 2 gh api --method DELETE "repos/$repo/git/refs/tags/$tag"
|
||||||
|
fi
|
||||||
|
|
||||||
|
wait_for_git_tag_absent "$repo" "$tag" 12 2
|
||||||
|
|
||||||
|
local created="false"
|
||||||
|
local i
|
||||||
|
for ((i = 1; i <= 6; i++)); do
|
||||||
|
if gh release create "$tag" --repo "$repo" --title "$release_title" --notes "$release_notes" --prerelease --target "$target_branch"; then
|
||||||
|
created="true"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if gh release view "$tag" --repo "$repo" >/dev/null 2>&1; then
|
||||||
|
echo "Release '$tag' appears to exist after create failure; continue to settle state." >&2
|
||||||
|
created="true"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [ "$i" -lt 6 ]; then
|
||||||
|
echo "Create release '$tag' failed (attempt $i/6), retrying in 3s..." >&2
|
||||||
|
sleep 3
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$created" != "true" ]; then
|
||||||
|
echo "Failed to create release '$tag'." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local release_id
|
||||||
|
release_id="$(wait_for_release_id "$repo" "$tag" 12 2)"
|
||||||
|
settle_release_state "$repo" "$release_id" "$tag" 12 2
|
||||||
|
}
|
||||||
|
|
||||||
|
upload_release_assets_with_retry() {
|
||||||
|
local repo="$1"
|
||||||
|
local tag="$2"
|
||||||
|
shift 2
|
||||||
|
|
||||||
|
if [ "$#" -eq 0 ]; then
|
||||||
|
echo "No release assets provided for upload." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
wait_for_release_id "$repo" "$tag" 12 2 >/dev/null
|
||||||
|
retry_cmd 5 3 gh release upload "$tag" "$@" --repo "$repo" --clobber
|
||||||
|
}
|
||||||
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}`);
|
||||||
|
}
|
||||||
383
.github/workflows/dev-daily-fixed.yml
vendored
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
name: Dev Daily
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# GitHub Actions schedule uses UTC. 16:00 UTC = 北京时间次日 00:00
|
||||||
|
- cron: "0 16 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: dev-nightly-fixed-release
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
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
|
||||||
|
source .github/scripts/release-utils.sh
|
||||||
|
recreate_fixed_prerelease "$GITHUB_REPOSITORY" "$FIXED_DEV_TAG" "$TARGET_BRANCH" "Daily Dev Build" "开发版发布页"
|
||||||
|
|
||||||
|
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: Ensure mac key helpers are executable
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
for file in \
|
||||||
|
resources/key/macos/universal/xkey_helper \
|
||||||
|
resources/key/macos/universal/image_scan_helper \
|
||||||
|
resources/key/macos/universal/xkey_helper_macos \
|
||||||
|
resources/key/macos/universal/libwx_key.dylib
|
||||||
|
do
|
||||||
|
if [ -f "$file" ]; then
|
||||||
|
chmod +x "$file"
|
||||||
|
ls -l "$file"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
- 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: |
|
||||||
|
set -euo pipefail
|
||||||
|
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"
|
||||||
|
if ! npx electron-builder --mac dmg zip --arm64 --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-arm64.${ext}'; then
|
||||||
|
echo "::warning::DMG packaging failed (hdiutil instability on runner). Retrying with ZIP only."
|
||||||
|
npx electron-builder --mac zip --arm64 --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-arm64.${ext}'
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Upload macOS arm64 assets to fixed release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
source .github/scripts/release-utils.sh
|
||||||
|
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
|
||||||
|
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_DEV_TAG" "${assets[@]}"
|
||||||
|
|
||||||
|
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: |
|
||||||
|
set -euo pipefail
|
||||||
|
source .github/scripts/release-utils.sh
|
||||||
|
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
|
||||||
|
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_DEV_TAG" "${assets[@]}"
|
||||||
|
|
||||||
|
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: |
|
||||||
|
set -euo pipefail
|
||||||
|
source .github/scripts/release-utils.sh
|
||||||
|
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
|
||||||
|
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_DEV_TAG" "${assets[@]}"
|
||||||
|
|
||||||
|
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: |
|
||||||
|
set -euo pipefail
|
||||||
|
source .github/scripts/release-utils.sh
|
||||||
|
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
|
||||||
|
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_DEV_TAG" "${assets[@]}"
|
||||||
|
|
||||||
|
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 }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
TAG="${FIXED_DEV_TAG:-}"
|
||||||
|
if [ -z "$TAG" ]; then
|
||||||
|
echo "FIXED_DEV_TAG is empty, abort."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
REPO="$GITHUB_REPOSITORY"
|
||||||
|
RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG"
|
||||||
|
echo "Using release tag: $TAG"
|
||||||
|
|
||||||
|
if ! gh api "repos/$REPO/releases/tags/$TAG" >/dev/null 2>&1; then
|
||||||
|
echo "Release $TAG not found, skip notes update."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
ASSETS_JSON="$(gh api "repos/$REPO/releases/tags/$TAG")"
|
||||||
|
|
||||||
|
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$")"
|
||||||
|
if [ -z "$MAC_ASSET" ]; then
|
||||||
|
MAC_ASSET="$(pick_asset "dev-arm64[.]zip$")"
|
||||||
|
fi
|
||||||
|
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
|
||||||
|
|
||||||
|
update_release_notes() {
|
||||||
|
local attempts=5
|
||||||
|
local delay_seconds=2
|
||||||
|
local i
|
||||||
|
for ((i=1; i<=attempts; i++)); do
|
||||||
|
if gh release edit "$TAG" --repo "$REPO" --title "Daily Dev Build" --notes-file dev_release_notes.md --prerelease >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [ "$i" -lt "$attempts" ]; then
|
||||||
|
echo "Release update failed (attempt $i/$attempts), retry in ${delay_seconds}s..."
|
||||||
|
sleep "$delay_seconds"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
update_release_notes
|
||||||
|
source .github/scripts/release-utils.sh
|
||||||
|
RELEASE_REST_ID="$(wait_for_release_id "$REPO" "$TAG" 12 2)"
|
||||||
|
settle_release_state "$REPO" "$RELEASE_REST_ID" "$TAG" 12 2
|
||||||
|
gh api "repos/$REPO/releases/tags/$TAG" --jq '{isDraft: .draft, isPrerelease: .prerelease, url: .html_url}'
|
||||||
426
.github/workflows/preview-nightly-main.yml
vendored
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
name: Preview Nightly
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# GitHub Actions schedule uses UTC. 16:00 UTC = 北京时间次日 00:00
|
||||||
|
- cron: "0 16 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: preview-nightly-fixed-release
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
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
|
||||||
|
source .github/scripts/release-utils.sh
|
||||||
|
recreate_fixed_prerelease "$GITHUB_REPOSITORY" "$FIXED_PREVIEW_TAG" "$TARGET_BRANCH" "Preview Nightly Build" "预览版发布页"
|
||||||
|
|
||||||
|
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: Ensure mac key helpers are executable
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
for file in \
|
||||||
|
resources/key/macos/universal/xkey_helper \
|
||||||
|
resources/key/macos/universal/image_scan_helper \
|
||||||
|
resources/key/macos/universal/xkey_helper_macos \
|
||||||
|
resources/key/macos/universal/libwx_key.dylib
|
||||||
|
do
|
||||||
|
if [ -f "$file" ]; then
|
||||||
|
chmod +x "$file"
|
||||||
|
ls -l "$file"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
- 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: |
|
||||||
|
set -euo pipefail
|
||||||
|
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"
|
||||||
|
if ! npx electron-builder --mac dmg zip --arm64 --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-arm64.${ext}'; then
|
||||||
|
echo "::warning::DMG packaging failed (hdiutil instability on runner). Retrying with ZIP only."
|
||||||
|
npx electron-builder --mac zip --arm64 --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-arm64.${ext}'
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Upload macOS arm64 assets to fixed preview release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
source .github/scripts/release-utils.sh
|
||||||
|
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
|
||||||
|
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_PREVIEW_TAG" "${assets[@]}"
|
||||||
|
|
||||||
|
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: |
|
||||||
|
set -euo pipefail
|
||||||
|
source .github/scripts/release-utils.sh
|
||||||
|
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
|
||||||
|
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_PREVIEW_TAG" "${assets[@]}"
|
||||||
|
|
||||||
|
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: |
|
||||||
|
set -euo pipefail
|
||||||
|
source .github/scripts/release-utils.sh
|
||||||
|
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
|
||||||
|
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_PREVIEW_TAG" "${assets[@]}"
|
||||||
|
|
||||||
|
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: |
|
||||||
|
set -euo pipefail
|
||||||
|
source .github/scripts/release-utils.sh
|
||||||
|
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
|
||||||
|
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_PREVIEW_TAG" "${assets[@]}"
|
||||||
|
|
||||||
|
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:-}"
|
||||||
|
if [ -z "$TAG" ]; then
|
||||||
|
echo "FIXED_PREVIEW_TAG is empty, abort."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
CURRENT_PREVIEW_VERSION="${{ needs.prepare.outputs.preview_version }}"
|
||||||
|
REPO="$GITHUB_REPOSITORY"
|
||||||
|
RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG"
|
||||||
|
echo "Using release tag: $TAG"
|
||||||
|
|
||||||
|
if ! gh api "repos/$REPO/releases/tags/$TAG" >/dev/null 2>&1; then
|
||||||
|
echo "Release $TAG not found (possibly all publish jobs failed), skip notes update."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
ASSETS_JSON="$(gh api "repos/$REPO/releases/tags/$TAG")"
|
||||||
|
|
||||||
|
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$")"
|
||||||
|
if [ -z "$MAC_ASSET" ]; then
|
||||||
|
MAC_ASSET="$(pick_asset "[.]zip$")"
|
||||||
|
fi
|
||||||
|
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
|
||||||
|
|
||||||
|
update_release_notes() {
|
||||||
|
local attempts=5
|
||||||
|
local delay_seconds=2
|
||||||
|
local i
|
||||||
|
for ((i=1; i<=attempts; i++)); do
|
||||||
|
if gh release edit "$TAG" --repo "$REPO" --title "Preview Nightly Build" --notes-file preview_release_notes.md --prerelease >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [ "$i" -lt "$attempts" ]; then
|
||||||
|
echo "Release update failed (attempt $i/$attempts), retry in ${delay_seconds}s..."
|
||||||
|
sleep "$delay_seconds"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
update_release_notes
|
||||||
|
source .github/scripts/release-utils.sh
|
||||||
|
RELEASE_REST_ID="$(wait_for_release_id "$REPO" "$TAG" 12 2)"
|
||||||
|
settle_release_state "$REPO" "$RELEASE_REST_ID" "$TAG" 12 2
|
||||||
|
gh api "repos/$REPO/releases/tags/$TAG" --jq '{isDraft: .draft, isPrerelease: .prerelease, url: .html_url}'
|
||||||
174
.github/workflows/release.yml
vendored
@@ -10,6 +10,7 @@ permissions:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||||
|
ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release-mac-arm64:
|
release-mac-arm64:
|
||||||
@@ -26,7 +27,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: npm install
|
run: npm install
|
||||||
|
|
||||||
@@ -35,19 +35,49 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
VERSION=${GITHUB_REF_NAME#v}
|
VERSION=${GITHUB_REF_NAME#v}
|
||||||
echo "Syncing package.json version to $VERSION"
|
echo "Syncing package.json version to $VERSION"
|
||||||
npm version $VERSION --no-git-tag-version --allow-same-version
|
node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json','utf8'));p.version='$VERSION';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')"
|
||||||
|
|
||||||
- name: Build Frontend & Type Check
|
- name: Build Frontend & Type Check
|
||||||
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
npx tsc
|
npx tsc
|
||||||
npx vite build
|
npx vite build
|
||||||
|
|
||||||
- name: Package and Publish macOS arm64 (unsigned DMG)
|
- name: Package and Publish macOS arm64 (unsigned DMG + ZIP)
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||||
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
npx electron-builder --mac dmg --arm64 --publish always
|
set -euo pipefail
|
||||||
|
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"
|
||||||
|
if ! npx electron-builder --mac dmg zip --arm64 --publish always '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}'; then
|
||||||
|
echo "::warning::DMG packaging failed (hdiutil instability on runner). Retrying with ZIP only."
|
||||||
|
npx electron-builder --mac zip --arm64 --publish always '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}'
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Inject minimumVersion into latest yml
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
source .github/scripts/release-utils.sh
|
||||||
|
TAG=${GITHUB_REF_NAME}
|
||||||
|
REPO=${{ github.repository }}
|
||||||
|
MINIMUM_VERSION="4.1.7"
|
||||||
|
wait_for_release_id "$REPO" "$TAG" 12 2 >/dev/null
|
||||||
|
for YML_FILE in latest-mac.yml latest-arm64-mac.yml; do
|
||||||
|
if ! retry_cmd 5 3 gh release download "$TAG" --repo "$REPO" --pattern "$YML_FILE" --output "/tmp/$YML_FILE"; then
|
||||||
|
echo "Skip $YML_FILE because download failed after retries."
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if ! grep -q 'minimumVersion' "/tmp/$YML_FILE"; then
|
||||||
|
echo "minimumVersion: $MINIMUM_VERSION" >> "/tmp/$YML_FILE"
|
||||||
|
fi
|
||||||
|
retry_cmd 5 3 gh release upload "$TAG" --repo "$REPO" "/tmp/$YML_FILE" --clobber
|
||||||
|
done
|
||||||
|
|
||||||
release-linux:
|
release-linux:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -63,18 +93,23 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: npm install
|
run: npm install
|
||||||
|
|
||||||
|
- name: Ensure linux key helper is executable
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
[ -f "resources/key/linux/x64/xkey_helper" ] && chmod +x "resources/key/linux/x64/xkey_helper" || echo "File not found"
|
||||||
|
|
||||||
- name: Sync version with tag
|
- name: Sync version with tag
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
VERSION=${GITHUB_REF_NAME#v}
|
VERSION=${GITHUB_REF_NAME#v}
|
||||||
echo "Syncing package.json version to $VERSION"
|
echo "Syncing package.json version to $VERSION"
|
||||||
npm version $VERSION --no-git-tag-version --allow-same-version
|
node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json','utf8'));p.version='$VERSION';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')"
|
||||||
|
|
||||||
- name: Build Frontend & Type Check
|
- name: Build Frontend & Type Check
|
||||||
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
npx tsc
|
npx tsc
|
||||||
npx vite build
|
npx vite build
|
||||||
@@ -83,7 +118,24 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
npx electron-builder --linux --publish always
|
npx electron-builder --linux --publish always '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}'
|
||||||
|
|
||||||
|
- name: Inject minimumVersion into latest yml
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
source .github/scripts/release-utils.sh
|
||||||
|
TAG=${GITHUB_REF_NAME}
|
||||||
|
REPO=${{ github.repository }}
|
||||||
|
MINIMUM_VERSION="4.1.7"
|
||||||
|
wait_for_release_id "$REPO" "$TAG" 12 2 >/dev/null
|
||||||
|
retry_cmd 5 3 gh release download "$TAG" --repo "$REPO" --pattern "latest-linux.yml" --output "/tmp/latest-linux.yml" || true
|
||||||
|
if [ -f /tmp/latest-linux.yml ] && ! grep -q 'minimumVersion' /tmp/latest-linux.yml; then
|
||||||
|
echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest-linux.yml
|
||||||
|
retry_cmd 5 3 gh release upload "$TAG" --repo "$REPO" /tmp/latest-linux.yml --clobber
|
||||||
|
fi
|
||||||
|
|
||||||
release:
|
release:
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
@@ -99,7 +151,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: npm install
|
run: npm install
|
||||||
|
|
||||||
@@ -108,9 +159,10 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
VERSION=${GITHUB_REF_NAME#v}
|
VERSION=${GITHUB_REF_NAME#v}
|
||||||
echo "Syncing package.json version to $VERSION"
|
echo "Syncing package.json version to $VERSION"
|
||||||
npm version $VERSION --no-git-tag-version --allow-same-version
|
node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json','utf8'));p.version='$VERSION';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')"
|
||||||
|
|
||||||
- name: Build Frontend & Type Check
|
- name: Build Frontend & Type Check
|
||||||
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
npx tsc
|
npx tsc
|
||||||
npx vite build
|
npx vite build
|
||||||
@@ -119,7 +171,24 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
npx electron-builder --win nsis --x64 --publish always '--config.artifactName=${productName}-${version}-x64-Setup.${ext}'
|
npx electron-builder --win nsis --x64 --publish always '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}' '--config.artifactName=${productName}-${version}-x64-Setup.${ext}'
|
||||||
|
|
||||||
|
- name: Inject minimumVersion into latest yml
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
source .github/scripts/release-utils.sh
|
||||||
|
TAG=${GITHUB_REF_NAME}
|
||||||
|
REPO=${{ github.repository }}
|
||||||
|
MINIMUM_VERSION="4.1.7"
|
||||||
|
wait_for_release_id "$REPO" "$TAG" 12 2 >/dev/null
|
||||||
|
retry_cmd 5 3 gh release download "$TAG" --repo "$REPO" --pattern "latest.yml" --output "/tmp/latest.yml" || true
|
||||||
|
if [ -f /tmp/latest.yml ] && ! grep -q 'minimumVersion' /tmp/latest.yml; then
|
||||||
|
echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest.yml
|
||||||
|
retry_cmd 5 3 gh release upload "$TAG" --repo "$REPO" /tmp/latest.yml --clobber
|
||||||
|
fi
|
||||||
|
|
||||||
release-windows-arm64:
|
release-windows-arm64:
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
@@ -135,7 +204,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: npm install
|
run: npm install
|
||||||
|
|
||||||
@@ -144,9 +212,10 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
VERSION=${GITHUB_REF_NAME#v}
|
VERSION=${GITHUB_REF_NAME#v}
|
||||||
echo "Syncing package.json version to $VERSION"
|
echo "Syncing package.json version to $VERSION"
|
||||||
npm version $VERSION --no-git-tag-version --allow-same-version
|
node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json','utf8'));p.version='$VERSION';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')"
|
||||||
|
|
||||||
- name: Build Frontend & Type Check
|
- name: Build Frontend & Type Check
|
||||||
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
npx tsc
|
npx tsc
|
||||||
npx vite build
|
npx vite build
|
||||||
@@ -155,7 +224,24 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
npx electron-builder --win nsis --arm64 --publish always '--config.publish.channel=latest-arm64' '--config.artifactName=${productName}-${version}-arm64-Setup.${ext}'
|
npx electron-builder --win nsis --arm64 --publish always '--config.publish.owner=${{ github.repository_owner }}' '--config.publish.repo=${{ github.event.repository.name }}' '--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: |
|
||||||
|
set -euo pipefail
|
||||||
|
source .github/scripts/release-utils.sh
|
||||||
|
TAG=${GITHUB_REF_NAME}
|
||||||
|
REPO=${{ github.repository }}
|
||||||
|
MINIMUM_VERSION="4.1.7"
|
||||||
|
wait_for_release_id "$REPO" "$TAG" 12 2 >/dev/null
|
||||||
|
retry_cmd 5 3 gh release download "$TAG" --repo "$REPO" --pattern "latest-arm64.yml" --output "/tmp/latest-arm64.yml" || true
|
||||||
|
if [ -f /tmp/latest-arm64.yml ] && ! grep -q 'minimumVersion' /tmp/latest-arm64.yml; then
|
||||||
|
echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest-arm64.yml
|
||||||
|
retry_cmd 5 3 gh release upload "$TAG" --repo "$REPO" /tmp/latest-arm64.yml --clobber
|
||||||
|
fi
|
||||||
|
|
||||||
update-release-notes:
|
update-release-notes:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -172,12 +258,14 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
source .github/scripts/release-utils.sh
|
||||||
|
|
||||||
TAG="$GITHUB_REF_NAME"
|
TAG="$GITHUB_REF_NAME"
|
||||||
REPO="$GITHUB_REPOSITORY"
|
REPO="$GITHUB_REPOSITORY"
|
||||||
RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG"
|
RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG"
|
||||||
|
wait_for_release_id "$REPO" "$TAG" 12 2 >/dev/null
|
||||||
|
|
||||||
ASSETS_JSON="$(gh release view "$TAG" --repo "$REPO" --json assets)"
|
capture_cmd_with_retry ASSETS_JSON 8 3 gh release view "$TAG" --repo "$REPO" --json assets
|
||||||
|
|
||||||
pick_asset() {
|
pick_asset() {
|
||||||
local pattern="$1"
|
local pattern="$1"
|
||||||
@@ -190,7 +278,9 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
WINDOWS_ARM64_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("arm64.*\\.exe$"))][0] // ""')"
|
WINDOWS_ARM64_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("arm64.*\\.exe$"))][0] // ""')"
|
||||||
MAC_ASSET="$(pick_asset "\\.dmg$")"
|
MAC_ASSET="$(pick_asset "\\.dmg$")"
|
||||||
LINUX_DEB_ASSET="$(pick_asset "\\.deb$")"
|
if [ -z "$MAC_ASSET" ]; then
|
||||||
|
MAC_ASSET="$(pick_asset "arm64\\.zip$")"
|
||||||
|
fi
|
||||||
LINUX_TAR_ASSET="$(pick_asset "\\.tar\\.gz$")"
|
LINUX_TAR_ASSET="$(pick_asset "\\.tar\\.gz$")"
|
||||||
LINUX_APPIMAGE_ASSET="$(pick_asset "\\.AppImage$")"
|
LINUX_APPIMAGE_ASSET="$(pick_asset "\\.AppImage$")"
|
||||||
|
|
||||||
@@ -204,7 +294,6 @@ jobs:
|
|||||||
WINDOWS_URL="$(build_link "$WINDOWS_ASSET")"
|
WINDOWS_URL="$(build_link "$WINDOWS_ASSET")"
|
||||||
WINDOWS_ARM64_URL="$(build_link "$WINDOWS_ARM64_ASSET")"
|
WINDOWS_ARM64_URL="$(build_link "$WINDOWS_ARM64_ASSET")"
|
||||||
MAC_URL="$(build_link "$MAC_ASSET")"
|
MAC_URL="$(build_link "$MAC_ASSET")"
|
||||||
LINUX_DEB_URL="$(build_link "$LINUX_DEB_ASSET")"
|
|
||||||
LINUX_TAR_URL="$(build_link "$LINUX_TAR_ASSET")"
|
LINUX_TAR_URL="$(build_link "$LINUX_TAR_ASSET")"
|
||||||
LINUX_APPIMAGE_URL="$(build_link "$LINUX_APPIMAGE_ASSET")"
|
LINUX_APPIMAGE_URL="$(build_link "$LINUX_APPIMAGE_ASSET")"
|
||||||
|
|
||||||
@@ -216,20 +305,49 @@ jobs:
|
|||||||
[点击加入 Telegram 频道](https://t.me/weflow_cc)
|
[点击加入 Telegram 频道](https://t.me/weflow_cc)
|
||||||
|
|
||||||
## 下载
|
## 下载
|
||||||
- Windows x64(Win10+): ${WINDOWS_URL:-$RELEASE_PAGE}
|
- Windows x64(Win10+): [点击下载](${WINDOWS_URL:-$RELEASE_PAGE})
|
||||||
- Windows arm64: ${WINDOWS_ARM64_URL:-$RELEASE_PAGE}
|
- Windows arm64: [点击下载](${WINDOWS_ARM64_URL:-$RELEASE_PAGE})
|
||||||
- macOS(M系列芯片): ${MAC_URL:-$RELEASE_PAGE}
|
- macOS(M系列芯片): [点击下载](${MAC_URL:-$RELEASE_PAGE})
|
||||||
- Linux (.deb) (即将废弃): ${LINUX_DEB_URL:-$RELEASE_PAGE}
|
- Linux (.tar.gz): [点击下载](${LINUX_TAR_URL:-$RELEASE_PAGE})
|
||||||
- Linux (.tar.gz): ${LINUX_TAR_URL:-$RELEASE_PAGE}
|
- Linux (.AppImage): [点击下载](${LINUX_APPIMAGE_URL:-$RELEASE_PAGE})
|
||||||
- linux (.AppImage): ${LINUX_APPIMAGE_URL:-$RELEASE_PAGE}
|
|
||||||
|
|
||||||
## macOS 安装提示(未知来源)
|
## macOS 安装提示
|
||||||
- 若打开时提示“来自未知开发者”或“无法验证开发者”,请到「系统设置 -> 隐私与安全性」中允许打开该应用。
|
- 如果被系统提示已损坏,你需要在终端执行以下命令去除隔离标记:
|
||||||
- 如果仍被系统拦截,请在终端执行以下命令去除隔离标记:
|
- \`xattr -dr com.apple.quarantine "/Applications/WeFlow.app"\`
|
||||||
- `xattr -dr com.apple.quarantine "/Applications/WeFlow.app"`
|
|
||||||
- 执行后重新打开 WeFlow。
|
- 执行后重新打开 WeFlow。
|
||||||
|
|
||||||
> 如果某个平台链接暂时未生成,可进入完整发布页查看全部资源:$RELEASE_PAGE
|
> 如果某个平台链接暂时未生成,可进入[完整发布页]($RELEASE_PAGE)查看全部资源
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
gh release edit "$TAG" --repo "$REPO" --notes-file release_notes.md
|
retry_cmd 5 3 gh release edit "$TAG" --repo "$REPO" --notes-file release_notes.md
|
||||||
|
|
||||||
|
deploy-aur:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [release-linux]
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Update PKGBUILD version
|
||||||
|
run: |
|
||||||
|
NEW_VER=$(echo "${{ github.ref_name }}" | sed 's/^v//')
|
||||||
|
sed -i "s/^pkgver=.*/pkgver=${NEW_VER}/" resources/installer/linux/PKGBUILD
|
||||||
|
sed -i "s/^pkgrel=.*/pkgrel=1/" resources/installer/linux/PKGBUILD
|
||||||
|
|
||||||
|
- name: Publish AUR package
|
||||||
|
uses: KSXGitHub/github-actions-deploy-aur@master
|
||||||
|
with:
|
||||||
|
pkgname: weflow
|
||||||
|
pkgbuild: resources/installer/linux/PKGBUILD
|
||||||
|
updpkgsums: true
|
||||||
|
assets: |
|
||||||
|
resources/installer/linux/weflow.desktop
|
||||||
|
resources/installer/linux/icon.png
|
||||||
|
|
||||||
|
ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||||
|
commit_username: H3CoF6
|
||||||
|
commit_email: h3cof6@gmail.com
|
||||||
|
ssh_keyscan_types: ed25519
|
||||||
|
|||||||
5
.gitignore
vendored
@@ -56,6 +56,8 @@ Thumbs.db
|
|||||||
*.aps
|
*.aps
|
||||||
|
|
||||||
wcdb/
|
wcdb/
|
||||||
|
!resources/wcdb/
|
||||||
|
!resources/wcdb/**
|
||||||
xkey/
|
xkey/
|
||||||
server/
|
server/
|
||||||
*info
|
*info
|
||||||
@@ -71,3 +73,6 @@ resources/wx_send
|
|||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
/pnpm-workspace.yaml
|
/pnpm-workspace.yaml
|
||||||
wechat-research-site
|
wechat-research-site
|
||||||
|
.codex
|
||||||
|
weflow-web-offical
|
||||||
|
/Wedecrypt
|
||||||
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
@@ -1,3 +1 @@
|
|||||||
registry=https://registry.npmmirror.com
|
registry=https://registry.npmjs.org
|
||||||
electron-mirror=https://npmmirror.com/mirrors/electron/
|
|
||||||
electron-builder-binaries-mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
|
|
||||||
|
|||||||
42
README.md
@@ -1,32 +1,23 @@
|
|||||||
# WeFlow
|
# WeFlow
|
||||||
|
|
||||||
WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析与导出工具。它可以实时获取你的微信聊天记录并将其导出,还可以根据你的聊天记录为你生成独一无二的分析报告
|
WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析与导出工具。它可以实时获取你的微信聊天记录并将其导出,还可以根据你的聊天记录为你生成独一无二的分析报告。
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="app.png" alt="WeFlow" width="90%">
|
<img src="app.png" alt="WeFlow 应用预览" width="90%">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/hicccc77/WeFlow/stargazers">
|
<!-- 第一行修复样式 -->
|
||||||
<img src="https://img.shields.io/github/stars/hicccc77/WeFlow?style=flat-square" alt="Stargazers">
|
<a href="https://github.com/hicccc77/WeFlow/stargazers"><img src="https://img.shields.io/github/stars/hicccc77/WeFlow?style=flat&label=Stars&labelColor=1F2937&color=2563EB" alt="Stargazers"></a>
|
||||||
</a>
|
<a href="https://github.com/hicccc77/WeFlow/network/members"><img src="https://img.shields.io/github/forks/hicccc77/WeFlow?style=flat&label=Forks&labelColor=1F2937&color=7C3AED" alt="Forks"></a>
|
||||||
<a href="https://github.com/hicccc77/WeFlow/network/members">
|
<a href="https://github.com/hicccc77/WeFlow/issues"><img src="https://img.shields.io/github/issues/hicccc77/WeFlow?style=flat&label=Issues&labelColor=1F2937&color=D97706" alt="Issues"></a>
|
||||||
<img src="https://img.shields.io/github/forks/hicccc77/WeFlow?style=flat-square" alt="Forks">
|
<a href="https://github.com/hicccc77/WeFlow/releases"><img src="https://img.shields.io/github/downloads/hicccc77/WeFlow/total?style=flat&label=Downloads&labelColor=1F2937&color=059669" alt="Downloads"></a>
|
||||||
</a>
|
<br><br>
|
||||||
<a href="https://github.com/hicccc77/WeFlow/issues">
|
<!-- 第二行:电报矮一点(22px),排名高一点(32px),使用 vertical-align: middle 居中对齐 -->
|
||||||
<img src="https://img.shields.io/github/issues/hicccc77/WeFlow?style=flat-square" alt="Issues">
|
<a href="https://t.me/weflow_cc"><img src="https://img.shields.io/badge/Telegram-频道-1D9BF0?style=flat&logo=telegram&logoColor=white&labelColor=1F2937&color=1D9BF0" alt="Telegram Channel" style="height: 22px; vertical-align: middle;"></a>
|
||||||
<img src="https://gh-down-badges.linkof.link/hicccc77/WeFlow/" alt="Downloads" />
|
<a href="https://www.star-history.com/hicccc77/weflow"><img src="https://api.star-history.com/badge?repo=hicccc77/WeFlow&theme=dark" alt="Star History Rank" style="height: 32px; vertical-align: middle;"></a>
|
||||||
</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">
|
|
||||||
</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> 如果导出聊天记录后,想深入分析聊天内容可以试试 [ChatLab](https://chatlab.fun/)
|
> 如果导出聊天记录后,想深入分析聊天内容可以试试 [ChatLab](https://chatlab.fun/)
|
||||||
|
|
||||||
@@ -45,18 +36,18 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
|
|||||||
|
|
||||||
## 支持平台与设备
|
## 支持平台与设备
|
||||||
|
|
||||||
|
|
||||||
| 平台 | 设备/架构 | 安装包 |
|
| 平台 | 设备/架构 | 安装包 |
|
||||||
|------|----------|--------|
|
|------|----------|--------|
|
||||||
| Windows | Windows10+、x64(amd64) | `.exe` |
|
| Windows | Windows10+、x64(amd64) | `.exe` |
|
||||||
| macOS | Apple Silicon(M 系列,arm64) | `.dmg` |
|
| macOS | Apple Silicon(M 系列,arm64) | `.dmg` |
|
||||||
| Linux | x64 设备(amd64) | `.deb`、`.tar.gz` |
|
| Linux | x64 设备(amd64) | `.AppImage`、`.tar.gz` |
|
||||||
|
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
若你只想使用成品版本,可前往 [Releases](https://github.com/hicccc77/WeFlow/releases) 下载并安装。
|
若你只想使用成品版本,可前往 [Releases](https://github.com/hicccc77/WeFlow/releases) 下载并安装。
|
||||||
|
|
||||||
|
> ArchLinux 用户可以选择 `yay -S weflow` 快速安装
|
||||||
|
|
||||||
## 详细功能清单
|
## 详细功能清单
|
||||||
|
|
||||||
当前版本已支持以下能力:
|
当前版本已支持以下能力:
|
||||||
@@ -64,6 +55,7 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
|
|||||||
| 功能模块 | 说明 |
|
| 功能模块 | 说明 |
|
||||||
|---------|------|
|
|---------|------|
|
||||||
| **聊天** | 解密聊天中的图片、视频、实况(仅支持谷歌协议拍摄的实况);支持**修改**、删除**本地**消息;实时刷新最新消息,无需生成解密中间数据库 |
|
| **聊天** | 解密聊天中的图片、视频、实况(仅支持谷歌协议拍摄的实况);支持**修改**、删除**本地**消息;实时刷新最新消息,无需生成解密中间数据库 |
|
||||||
|
| **消息防撤回** | 防止其他人发送的消息被撤回 |
|
||||||
| **实时弹窗通知** | 新消息到达时提供桌面弹窗提醒,便于及时查看重要会话,提供黑白名单功能 |
|
| **实时弹窗通知** | 新消息到达时提供桌面弹窗提醒,便于及时查看重要会话,提供黑白名单功能 |
|
||||||
| **私聊分析** | 统计好友间消息数量;分析消息类型与发送比例;查看消息时段分布等 |
|
| **私聊分析** | 统计好友间消息数量;分析消息类型与发送比例;查看消息时段分布等 |
|
||||||
| **群聊分析** | 查看群成员详细信息;分析群内发言排行、活跃时段和媒体内容 |
|
| **群聊分析** | 查看群成员详细信息;分析群内发言排行、活跃时段和媒体内容 |
|
||||||
@@ -88,7 +80,6 @@ WeFlow 提供本地 HTTP API 服务,支持通过接口查询消息数据,可
|
|||||||
|
|
||||||
完整接口文档:[点击查看](docs/HTTP-API.md)
|
完整接口文档:[点击查看](docs/HTTP-API.md)
|
||||||
|
|
||||||
|
|
||||||
## 面向开发者
|
## 面向开发者
|
||||||
|
|
||||||
如果你想从源码构建或为项目贡献代码,请遵循以下步骤:
|
如果你想从源码构建或为项目贡献代码,请遵循以下步骤:
|
||||||
@@ -103,7 +94,6 @@ npm install
|
|||||||
|
|
||||||
# 3. 运行应用(开发模式)
|
# 3. 运行应用(开发模式)
|
||||||
npm run dev
|
npm run dev
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 致谢
|
## 致谢
|
||||||
@@ -115,10 +105,8 @@ npm run dev
|
|||||||
|
|
||||||
如果 WeFlow 确实帮到了你,可以考虑请我们喝杯咖啡:
|
如果 WeFlow 确实帮到了你,可以考虑请我们喝杯咖啡:
|
||||||
|
|
||||||
|
|
||||||
> TRC20 **Address:** `TZCtAw8CaeARWZBfvjidCnTcfnAtf6nvS6`
|
> TRC20 **Address:** `TZCtAw8CaeARWZBfvjidCnTcfnAtf6nvS6`
|
||||||
|
|
||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|
||||||
<a href="https://www.star-history.com/#hicccc77/WeFlow&type=date&legend=top-left">
|
<a href="https://www.star-history.com/#hicccc77/WeFlow&type=date&legend=top-left">
|
||||||
|
|||||||
335
docs/HTTP-API.md
@@ -1,6 +1,6 @@
|
|||||||
# WeFlow HTTP API / Push 文档
|
# WeFlow HTTP API / Push 文档
|
||||||
|
|
||||||
WeFlow 提供本地 HTTP API,便于外部脚本或工具读取聊天记录、会话、联系人、群成员和导出的媒体文件;也支持在检测到新消息后通过固定 SSE 地址主动推送消息事件。
|
WeFlow 提供本地 HTTP API(已支持GET 和 POST请求),便于外部脚本或工具读取聊天记录、会话、联系人、群成员和导出的媒体文件;也支持在检测到新消息后通过固定 SSE 地址主动推送消息事件。
|
||||||
|
|
||||||
## 启用方式
|
## 启用方式
|
||||||
|
|
||||||
@@ -11,17 +11,27 @@ WeFlow 提供本地 HTTP API,便于外部脚本或工具读取聊天记录、
|
|||||||
- 基础地址:`http://127.0.0.1:5031`
|
- 基础地址:`http://127.0.0.1:5031`
|
||||||
- 可选开启 `主动推送`,检测到新收到的消息后会通过 `GET /api/v1/push/messages` 推送给 SSE 订阅端
|
- 可选开启 `主动推送`,检测到新收到的消息后会通过 `GET /api/v1/push/messages` 推送给 SSE 订阅端
|
||||||
|
|
||||||
|
**状态记忆**:API 服务和主动推送的状态及端口会自动保存,重启 WeFlow 后会自动恢复运行。
|
||||||
|
|
||||||
|
## 鉴权规范
|
||||||
|
|
||||||
|
**鉴权规范 (Access Token)** 除健康检查接口外,所有 `/api/v1/*` 接口均受 Token 保护。支持三种传参方式(任选其一):
|
||||||
|
|
||||||
|
1. **HTTP Header (推荐)**: `Authorization: Bearer <您的Token>`
|
||||||
|
2. **Query 参数**: `?access_token=<您的Token>`(SSE 长连接推荐此方式)
|
||||||
|
3. **JSON Body**: `{"access_token": "<您的Token>"}`(仅限 POST 请求)
|
||||||
|
|
||||||
## 接口列表
|
## 接口列表
|
||||||
|
|
||||||
- `GET /health`
|
- `GET|POST /health`
|
||||||
- `GET /api/v1/health`
|
- `GET|POST /api/v1/health`
|
||||||
- `GET /api/v1/push/messages`
|
- `GET|POST /api/v1/push/messages`
|
||||||
- `GET /api/v1/messages`
|
- `GET|POST /api/v1/messages`
|
||||||
- `GET /api/v1/messages/new`
|
- `GET|POST /api/v1/sessions`
|
||||||
- `GET /api/v1/sessions`
|
- `GET /api/v1/sessions/:id/messages` (ChatLab Pull)
|
||||||
- `GET /api/v1/contacts`
|
- `GET|POST /api/v1/contacts`
|
||||||
- `GET /api/v1/group-members`
|
- `GET|POST /api/v1/group-members`
|
||||||
- `GET /api/v1/media/*`
|
- `GET|POST /api/v1/media/*`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -76,24 +86,27 @@ GET /api/v1/push/messages
|
|||||||
- `sourceName`
|
- `sourceName`
|
||||||
- `groupName`(仅群聊)
|
- `groupName`(仅群聊)
|
||||||
- `content`
|
- `content`
|
||||||
|
- `timestamp`(消息时间,秒级 Unix 时间戳)
|
||||||
|
|
||||||
### 示例
|
### 示例
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -N "http://127.0.0.1:5031/api/v1/push/messages"
|
curl -N "http://127.0.0.1:5031/api/v1/push/messages?access_token=YOUR_TOKEN
|
||||||
```
|
```
|
||||||
|
|
||||||
示例事件:
|
示例事件:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
event: message.new
|
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":"[图片]"}
|
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":"[图片]","timestamp":1760000123}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. 获取消息
|
## 3. 获取消息
|
||||||
|
|
||||||
|
> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json)
|
||||||
|
|
||||||
读取指定会话的消息,支持原始 JSON 和 ChatLab 格式。
|
读取指定会话的消息,支持原始 JSON 和 ChatLab 格式。
|
||||||
|
|
||||||
**请求**
|
**请求**
|
||||||
@@ -105,7 +118,7 @@ GET /api/v1/messages
|
|||||||
### 参数
|
### 参数
|
||||||
|
|
||||||
| 参数 | 类型 | 必填 | 说明 |
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
| --- | --- | --- | --- |
|
| --------- | ------ | ---- | ----------------------------------------------------- |
|
||||||
| `talker` | string | 是 | 会话 ID。私聊通常是对方 `wxid`,群聊是 `xxx@chatroom` |
|
| `talker` | string | 是 | 会话 ID。私聊通常是对方 `wxid`,群聊是 `xxx@chatroom` |
|
||||||
| `limit` | number | 否 | 返回条数,默认 `100`,范围 `1~10000` |
|
| `limit` | number | 否 | 返回条数,默认 `100`,范围 `1~10000` |
|
||||||
| `offset` | number | 否 | 分页偏移,默认 `0` |
|
| `offset` | number | 否 | 分页偏移,默认 `0` |
|
||||||
@@ -231,6 +244,8 @@ curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&media=1&image=1&
|
|||||||
|
|
||||||
## 4. 获取会话列表
|
## 4. 获取会话列表
|
||||||
|
|
||||||
|
> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json)
|
||||||
|
|
||||||
**请求**
|
**请求**
|
||||||
|
|
||||||
```http
|
```http
|
||||||
@@ -240,7 +255,7 @@ GET /api/v1/sessions
|
|||||||
### 参数
|
### 参数
|
||||||
|
|
||||||
| 参数 | 类型 | 必填 | 说明 |
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
| --- | --- | --- | --- |
|
| --------- | ------ | ---- | -------------------------------- |
|
||||||
| `keyword` | string | 否 | 匹配 `username` 或 `displayName` |
|
| `keyword` | string | 否 | 匹配 `username` 或 `displayName` |
|
||||||
| `limit` | number | 否 | 默认 `100` |
|
| `limit` | number | 否 | 默认 `100` |
|
||||||
|
|
||||||
@@ -274,8 +289,134 @@ GET /api/v1/sessions
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 4.1 获取会话列表(ChatLab 格式)
|
||||||
|
|
||||||
|
当 `format=chatlab` 时,返回 ChatLab Pull 协议兼容格式,可直接作为 ChatLab 远程数据源。
|
||||||
|
|
||||||
|
**请求**
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/sessions?format=chatlab
|
||||||
|
```
|
||||||
|
|
||||||
|
### 参数
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
| --------- | ------ | ---- | -------------------------------- |
|
||||||
|
| `format` | string | 是 | 设为 `chatlab` |
|
||||||
|
| `keyword` | string | 否 | 匹配 `username` 或 `displayName` |
|
||||||
|
| `limit` | number | 否 | 默认 `100` |
|
||||||
|
|
||||||
|
### 响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sessions": [
|
||||||
|
{
|
||||||
|
"id": "xxx@chatroom",
|
||||||
|
"name": "项目群",
|
||||||
|
"platform": "wechat",
|
||||||
|
"type": "group",
|
||||||
|
"messageCount": 58000,
|
||||||
|
"lastMessageAt": 1738713600
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
| --------------- | ----------------------------------- |
|
||||||
|
| `id` | 会话 ID(微信 username) |
|
||||||
|
| `name` | 会话显示名称 |
|
||||||
|
| `platform` | 固定 `wechat` |
|
||||||
|
| `type` | `group`(群聊)或 `private`(私聊) |
|
||||||
|
| `messageCount` | 消息数量(估算值,可能不精确) |
|
||||||
|
| `lastMessageAt` | 最后消息的秒级 Unix 时间戳 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4.2 拉取会话消息(ChatLab Pull)
|
||||||
|
|
||||||
|
返回 ChatLab 标准格式的聊天数据,支持增量拉取和分页。
|
||||||
|
|
||||||
|
**请求**
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/sessions/:id/messages
|
||||||
|
```
|
||||||
|
|
||||||
|
### 参数
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
| -------- | ------ | ---- | ---------------------------------------- |
|
||||||
|
| `:id` | string | 是 | 会话 ID(Path 参数) |
|
||||||
|
| `since` | number | 否 | 秒级 Unix 时间戳,仅返回此时间之后的消息 |
|
||||||
|
| `end` | number | 否 | 秒级 Unix 时间戳,时间上界 |
|
||||||
|
| `limit` | number | 否 | 单次返回上限,默认且最大 `5000` |
|
||||||
|
| `offset` | number | 否 | 分页偏移,默认 `0` |
|
||||||
|
|
||||||
|
### 响应
|
||||||
|
|
||||||
|
返回 ChatLab 标准 JSON 格式,外加 `sync` 分页块:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"chatlab": {
|
||||||
|
"version": "0.0.2",
|
||||||
|
"exportedAt": 1738713600,
|
||||||
|
"generator": "WeFlow"
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"name": "项目群",
|
||||||
|
"platform": "wechat",
|
||||||
|
"type": "group",
|
||||||
|
"groupId": "xxx@chatroom",
|
||||||
|
"ownerId": "wxid_xxx"
|
||||||
|
},
|
||||||
|
"members": [
|
||||||
|
{
|
||||||
|
"platformId": "wxid_a",
|
||||||
|
"accountName": "张三",
|
||||||
|
"groupNickname": "产品",
|
||||||
|
"avatar": "https://example.com/avatar.jpg"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"sender": "wxid_a",
|
||||||
|
"accountName": "张三",
|
||||||
|
"timestamp": 1738713600,
|
||||||
|
"type": 0,
|
||||||
|
"content": "你好",
|
||||||
|
"platformMessageId": "123456"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sync": {
|
||||||
|
"hasMore": true,
|
||||||
|
"nextSince": 1738713600,
|
||||||
|
"nextOffset": 5000,
|
||||||
|
"watermark": 1738714000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### sync 块
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
| ------------ | -------------------------------- |
|
||||||
|
| `hasMore` | 是否还有更多数据 |
|
||||||
|
| `nextSince` | 下次请求的 `since` 值 |
|
||||||
|
| `nextOffset` | 下次请求的 `offset` 值 |
|
||||||
|
| `watermark` | 本次拉取的时间上界(秒级时间戳) |
|
||||||
|
|
||||||
|
**ChatLab 对接方式**:在 ChatLab 设置中添加远程数据源,`baseUrl` 填 `http://127.0.0.1:5031/api/v1`,Token 填 WeFlow 中配置的 API Token。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 5. 获取联系人列表
|
## 5. 获取联系人列表
|
||||||
|
|
||||||
|
> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json)
|
||||||
|
|
||||||
**请求**
|
**请求**
|
||||||
|
|
||||||
```http
|
```http
|
||||||
@@ -285,7 +426,7 @@ GET /api/v1/contacts
|
|||||||
### 参数
|
### 参数
|
||||||
|
|
||||||
| 参数 | 类型 | 必填 | 说明 |
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
| --- | --- | --- | --- |
|
| --------- | ------ | ---- | ---------------------------------------------------- |
|
||||||
| `keyword` | string | 否 | 匹配 `username`、`nickname`、`remark`、`displayName` |
|
| `keyword` | string | 否 | 匹配 `username`、`nickname`、`remark`、`displayName` |
|
||||||
| `limit` | number | 否 | 默认 `100` |
|
| `limit` | number | 否 | 默认 `100` |
|
||||||
|
|
||||||
@@ -325,6 +466,8 @@ GET /api/v1/contacts
|
|||||||
|
|
||||||
## 6. 获取群成员列表
|
## 6. 获取群成员列表
|
||||||
|
|
||||||
|
> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json)
|
||||||
|
|
||||||
返回群成员的 `wxid`、群昵称、备注、微信号等信息。
|
返回群成员的 `wxid`、群昵称、备注、微信号等信息。
|
||||||
|
|
||||||
**请求**
|
**请求**
|
||||||
@@ -336,7 +479,7 @@ GET /api/v1/group-members
|
|||||||
### 参数
|
### 参数
|
||||||
|
|
||||||
| 参数 | 类型 | 必填 | 说明 |
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
| --- | --- | --- | --- |
|
| ---------------------- | ------ | ---- | ------------------------------- |
|
||||||
| `chatroomId` | string | 是 | 群 ID,兼容使用 `talker` 传入 |
|
| `chatroomId` | string | 是 | 群 ID,兼容使用 `talker` 传入 |
|
||||||
| `includeMessageCounts` | string | 否 | `1/true` 时附带成员发言数 |
|
| `includeMessageCounts` | string | 否 | `1/true` 时附带成员发言数 |
|
||||||
| `withCounts` | string | 否 | `includeMessageCounts` 的别名 |
|
| `withCounts` | string | 否 | `includeMessageCounts` 的别名 |
|
||||||
@@ -415,7 +558,125 @@ curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom&include
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. 访问导出媒体
|
## 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 地址。
|
通过消息接口启用 `media=1` 后,接口会先把图片、语音、视频、表情导出到本地缓存目录,再返回可访问的 HTTP 地址。
|
||||||
|
|
||||||
@@ -437,7 +698,7 @@ curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/emojis/emoji_300.gif"
|
|||||||
### 支持的 Content-Type
|
### 支持的 Content-Type
|
||||||
|
|
||||||
| 扩展名 | Content-Type |
|
| 扩展名 | Content-Type |
|
||||||
| --- | --- |
|
| ---------------- | ------------ |
|
||||||
| `.png` | `image/png` |
|
| `.png` | `image/png` |
|
||||||
| `.jpg` / `.jpeg` | `image/jpeg` |
|
| `.jpg` / `.jpeg` | `image/jpeg` |
|
||||||
| `.gif` | `image/gif` |
|
| `.gif` | `image/gif` |
|
||||||
@@ -456,24 +717,28 @@ curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/emojis/emoji_300.gif"
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. 使用示例
|
## 9. 使用示例
|
||||||
|
|
||||||
### PowerShell
|
### PowerShell
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
Invoke-RestMethod http://127.0.0.1:5031/health
|
$headers = @{ "Authorization" = "Bearer YOUR_TOKEN" }
|
||||||
Invoke-RestMethod http://127.0.0.1:5031/api/v1/sessions
|
$body = @{ talker = "wxid_xxx"; limit = 10 } | ConvertTo-Json
|
||||||
Invoke-RestMethod "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=10"
|
|
||||||
Invoke-RestMethod "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom&includeMessageCounts=1"
|
Invoke-RestMethod -Uri "http://127.0.0.1:5031/api/v1/messages" -Method POST -Headers $headers -Body $body -ContentType "application/json"
|
||||||
```
|
```
|
||||||
|
|
||||||
### cURL
|
### cURL
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://127.0.0.1:5031/health
|
# GET 带 Token Header
|
||||||
curl "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1"
|
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/contacts?keyword=张三"
|
|
||||||
curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom"
|
# 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
|
### Python
|
||||||
@@ -482,24 +747,26 @@ curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom"
|
|||||||
import requests
|
import requests
|
||||||
|
|
||||||
BASE_URL = "http://127.0.0.1:5031"
|
BASE_URL = "http://127.0.0.1:5031"
|
||||||
|
headers = {"Authorization": "Bearer YOUR_TOKEN", "Content-Type": "application/json"}
|
||||||
|
|
||||||
messages = requests.get(
|
# POST 方式获取消息
|
||||||
|
messages = requests.post(
|
||||||
f"{BASE_URL}/api/v1/messages",
|
f"{BASE_URL}/api/v1/messages",
|
||||||
params={"talker": "xxx@chatroom", "limit": 50}
|
json={"talker": "xxx@chatroom", "limit": 50},
|
||||||
|
headers=headers
|
||||||
).json()
|
).json()
|
||||||
|
|
||||||
|
# GET 方式获取群成员
|
||||||
members = requests.get(
|
members = requests.get(
|
||||||
f"{BASE_URL}/api/v1/group-members",
|
f"{BASE_URL}/api/v1/group-members",
|
||||||
params={"chatroomId": "xxx@chatroom", "includeMessageCounts": 1}
|
params={"chatroomId": "xxx@chatroom", "includeMessageCounts": 1},
|
||||||
|
headers=headers
|
||||||
).json()
|
).json()
|
||||||
|
|
||||||
print(messages)
|
|
||||||
print(members)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. 注意事项
|
## 10. 注意事项
|
||||||
|
|
||||||
1. API 仅监听本机 `127.0.0.1`,不对外网开放。
|
1. API 仅监听本机 `127.0.0.1`,不对外网开放。
|
||||||
2. 使用前需要先在 WeFlow 中完成数据库连接。
|
2. 使用前需要先在 WeFlow 中完成数据库连接。
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ interface ExportWorkerConfig {
|
|||||||
sessionIds: string[]
|
sessionIds: string[]
|
||||||
outputDir: string
|
outputDir: string
|
||||||
options: ExportOptions
|
options: ExportOptions
|
||||||
|
dbPath?: string
|
||||||
|
decryptKey?: string
|
||||||
|
myWxid?: string
|
||||||
resourcesPath?: string
|
resourcesPath?: string
|
||||||
userDataPath?: string
|
userDataPath?: string
|
||||||
logEnabled?: boolean
|
logEnabled?: boolean
|
||||||
@@ -29,6 +32,11 @@ async function run() {
|
|||||||
|
|
||||||
wcdbService.setPaths(config.resourcesPath || '', config.userDataPath || '')
|
wcdbService.setPaths(config.resourcesPath || '', config.userDataPath || '')
|
||||||
wcdbService.setLogEnabled(config.logEnabled === true)
|
wcdbService.setLogEnabled(config.logEnabled === true)
|
||||||
|
exportService.setRuntimeConfig({
|
||||||
|
dbPath: config.dbPath,
|
||||||
|
decryptKey: config.decryptKey,
|
||||||
|
myWxid: config.myWxid
|
||||||
|
})
|
||||||
|
|
||||||
const result = await exportService.exportSessions(
|
const result = await exportService.exportSessions(
|
||||||
Array.isArray(config.sessionIds) ? config.sessionIds : [],
|
Array.isArray(config.sessionIds) ? config.sessionIds : [],
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ function looksLikeMd5(value: string): boolean {
|
|||||||
|
|
||||||
function stripDatVariantSuffix(base: string): string {
|
function stripDatVariantSuffix(base: string): string {
|
||||||
const lower = base.toLowerCase()
|
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) {
|
for (const suffix of suffixes) {
|
||||||
if (lower.endsWith(suffix)) {
|
if (lower.endsWith(suffix)) {
|
||||||
return lower.slice(0, -suffix.length)
|
return lower.slice(0, -suffix.length)
|
||||||
@@ -71,8 +71,10 @@ function scoreDatName(fileName: string): number {
|
|||||||
const lower = fileName.toLowerCase()
|
const lower = fileName.toLowerCase()
|
||||||
const baseLower = lower.endsWith('.dat') ? lower.slice(0, -4) : lower
|
const baseLower = lower.endsWith('.dat') ? lower.slice(0, -4) : lower
|
||||||
if (baseLower.endsWith('_h') || baseLower.endsWith('.h')) return 600
|
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 (!hasXVariant(baseLower)) return 500
|
||||||
if (baseLower.endsWith('_hd') || baseLower.endsWith('.hd')) return 450
|
|
||||||
if (baseLower.endsWith('_c') || baseLower.endsWith('.c')) return 400
|
if (baseLower.endsWith('_c') || baseLower.endsWith('.c')) return 400
|
||||||
if (isThumbnailDat(lower)) return 100
|
if (isThumbnailDat(lower)) return 100
|
||||||
return 350
|
return 350
|
||||||
|
|||||||
1207
electron/main.ts
@@ -2,7 +2,7 @@ import { join, dirname } from 'path'
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 强制将本地资源目录添加到 PATH 最前端,确保优先加载本地 DLL
|
* 强制将本地资源目录添加到 PATH 最前端,确保优先加载本地 DLL
|
||||||
* 解决系统中存在冲突版本的 DLL 导致的应用崩溃问题
|
* 解决系统中存在冲突版本的数据服务导致的应用崩溃问题
|
||||||
*/
|
*/
|
||||||
function enforceLocalDllPriority() {
|
function enforceLocalDllPriority() {
|
||||||
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||||
@@ -35,5 +35,5 @@ function enforceLocalDllPriority() {
|
|||||||
try {
|
try {
|
||||||
enforceLocalDllPriority()
|
enforceLocalDllPriority()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[WeFlow] Failed to enforce local DLL priority:', e)
|
console.error('[WeFlow] Failed to enforce local service priority:', e)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { contextBridge, ipcRenderer } from 'electron'
|
import { contextBridge, ipcRenderer } from 'electron'
|
||||||
|
|
||||||
// 暴露给渲染进程的 API
|
// 暴露给渲染进程的 API
|
||||||
contextBridge.exposeInMainWorld('electronAPI', {
|
contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
@@ -19,6 +19,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
onShow: (callback: (event: any, data: any) => void) => {
|
onShow: (callback: (event: any, data: any) => void) => {
|
||||||
ipcRenderer.on('notification:show', callback)
|
ipcRenderer.on('notification:show', callback)
|
||||||
return () => ipcRenderer.removeAllListeners('notification:show')
|
return () => ipcRenderer.removeAllListeners('notification:show')
|
||||||
|
}, // 监听原本发送出来的navigate-to-session事件,跳转到具体的会话
|
||||||
|
onNavigateToSession: (callback: (sessionId: string) => void) => {
|
||||||
|
const listener = (_: any, sessionId: string) => callback(sessionId)
|
||||||
|
ipcRenderer.on('navigate-to-session', listener)
|
||||||
|
return () => ipcRenderer.removeListener('navigate-to-session', listener)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -53,6 +58,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
app: {
|
app: {
|
||||||
getDownloadsPath: () => ipcRenderer.invoke('app:getDownloadsPath'),
|
getDownloadsPath: () => ipcRenderer.invoke('app:getDownloadsPath'),
|
||||||
getVersion: () => ipcRenderer.invoke('app:getVersion'),
|
getVersion: () => ipcRenderer.invoke('app:getVersion'),
|
||||||
|
getLaunchAtStartupStatus: () => ipcRenderer.invoke('app:getLaunchAtStartupStatus'),
|
||||||
|
setLaunchAtStartup: (enabled: boolean) => ipcRenderer.invoke('app:setLaunchAtStartup', enabled),
|
||||||
checkForUpdates: () => ipcRenderer.invoke('app:checkForUpdates'),
|
checkForUpdates: () => ipcRenderer.invoke('app:checkForUpdates'),
|
||||||
downloadAndInstall: () => ipcRenderer.invoke('app:downloadAndInstall'),
|
downloadAndInstall: () => ipcRenderer.invoke('app:downloadAndInstall'),
|
||||||
ignoreUpdate: (version: string) => ipcRenderer.invoke('app:ignoreUpdate', version),
|
ignoreUpdate: (version: string) => ipcRenderer.invoke('app:ignoreUpdate', version),
|
||||||
@@ -64,7 +71,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
ipcRenderer.on('app:updateAvailable', (_, info) => callback(info))
|
ipcRenderer.on('app:updateAvailable', (_, info) => callback(info))
|
||||||
return () => ipcRenderer.removeAllListeners('app:updateAvailable')
|
return () => ipcRenderer.removeAllListeners('app:updateAvailable')
|
||||||
},
|
},
|
||||||
checkWayland: () => ipcRenderer.invoke('app:checkWayland'),
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// 日志
|
// 日志
|
||||||
@@ -104,7 +110,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
ipcRenderer.invoke('window:respondCloseConfirm', action),
|
ipcRenderer.invoke('window:respondCloseConfirm', action),
|
||||||
openAgreementWindow: () => ipcRenderer.invoke('window:openAgreementWindow'),
|
openAgreementWindow: () => ipcRenderer.invoke('window:openAgreementWindow'),
|
||||||
completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'),
|
completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'),
|
||||||
openOnboardingWindow: () => ipcRenderer.invoke('window:openOnboardingWindow'),
|
openOnboardingWindow: (options?: { mode?: 'add-account' }) => ipcRenderer.invoke('window:openOnboardingWindow', options),
|
||||||
setTitleBarOverlay: (options: { symbolColor: string }) => ipcRenderer.send('window:setTitleBarOverlay', options),
|
setTitleBarOverlay: (options: { symbolColor: string }) => ipcRenderer.send('window:setTitleBarOverlay', options),
|
||||||
openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) =>
|
openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) =>
|
||||||
ipcRenderer.invoke('window:openVideoPlayerWindow', videoPath, videoWidth, videoHeight),
|
ipcRenderer.invoke('window:openVideoPlayerWindow', videoPath, videoWidth, videoHeight),
|
||||||
@@ -188,6 +194,12 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
ipcRenderer.invoke('chat:updateMessage', sessionId, localId, createTime, newContent),
|
ipcRenderer.invoke('chat:updateMessage', sessionId, localId, createTime, newContent),
|
||||||
deleteMessage: (sessionId: string, localId: number, createTime: number, dbPathHint?: string) =>
|
deleteMessage: (sessionId: string, localId: number, createTime: number, dbPathHint?: string) =>
|
||||||
ipcRenderer.invoke('chat:deleteMessage', sessionId, localId, createTime, dbPathHint),
|
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) =>
|
resolveTransferDisplayNames: (chatroomId: string, payerUsername: string, receiverUsername: string) =>
|
||||||
ipcRenderer.invoke('chat:resolveTransferDisplayNames', chatroomId, payerUsername, receiverUsername),
|
ipcRenderer.invoke('chat:resolveTransferDisplayNames', chatroomId, payerUsername, receiverUsername),
|
||||||
getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'),
|
getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'),
|
||||||
@@ -218,6 +230,22 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
getAllImageMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllImageMessages', sessionId),
|
getAllImageMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllImageMessages', sessionId),
|
||||||
getMessageDates: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDates', sessionId),
|
getMessageDates: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDates', sessionId),
|
||||||
getMessageDateCounts: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDateCounts', sessionId),
|
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),
|
resolveVoiceCache: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:resolveVoiceCache', sessionId, msgId),
|
||||||
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId, createTime),
|
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId, createTime),
|
||||||
onVoiceTranscriptPartial: (callback: (payload: { sessionId?: string; msgId: string; createTime?: number; text: string }) => void) => {
|
onVoiceTranscriptPartial: (callback: (payload: { sessionId?: string; msgId: string; createTime?: number; text: string }) => void) => {
|
||||||
@@ -230,6 +258,24 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
ipcRenderer.invoke('chat:getMessage', sessionId, localId),
|
ipcRenderer.invoke('chat:getMessage', sessionId, localId),
|
||||||
searchMessages: (keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number) =>
|
searchMessages: (keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number) =>
|
||||||
ipcRenderer.invoke('chat:searchMessages', keyword, sessionId, limit, offset, beginTimestamp, endTimestamp),
|
ipcRenderer.invoke('chat:searchMessages', keyword, sessionId, limit, offset, beginTimestamp, endTimestamp),
|
||||||
|
getMyFootprintStats: (
|
||||||
|
beginTimestamp: number,
|
||||||
|
endTimestamp: number,
|
||||||
|
options?: {
|
||||||
|
myWxid?: string
|
||||||
|
privateSessionIds?: string[]
|
||||||
|
groupSessionIds?: string[]
|
||||||
|
mentionLimit?: number
|
||||||
|
privateLimit?: number
|
||||||
|
mentionMode?: 'text_at_me' | string
|
||||||
|
}
|
||||||
|
) => ipcRenderer.invoke('chat:getMyFootprintStats', beginTimestamp, endTimestamp, options),
|
||||||
|
exportMyFootprint: (
|
||||||
|
beginTimestamp: number,
|
||||||
|
endTimestamp: number,
|
||||||
|
format: 'csv' | 'json',
|
||||||
|
filePath: string
|
||||||
|
) => ipcRenderer.invoke('chat:exportMyFootprint', beginTimestamp, endTimestamp, format, filePath),
|
||||||
onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => {
|
onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => {
|
||||||
ipcRenderer.on('wcdb-change', callback)
|
ipcRenderer.on('wcdb-change', callback)
|
||||||
return () => ipcRenderer.removeListener('wcdb-change', callback)
|
return () => ipcRenderer.removeListener('wcdb-change', callback)
|
||||||
@@ -240,12 +286,41 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
|
|
||||||
// 图片解密
|
// 图片解密
|
||||||
image: {
|
image: {
|
||||||
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) =>
|
decrypt: (payload: {
|
||||||
|
sessionId?: string
|
||||||
|
imageMd5?: string
|
||||||
|
imageDatName?: string
|
||||||
|
createTime?: number
|
||||||
|
force?: boolean
|
||||||
|
preferFilePath?: boolean
|
||||||
|
hardlinkOnly?: boolean
|
||||||
|
disableUpdateCheck?: boolean
|
||||||
|
allowCacheIndex?: boolean
|
||||||
|
suppressEvents?: boolean
|
||||||
|
}) =>
|
||||||
ipcRenderer.invoke('image:decrypt', payload),
|
ipcRenderer.invoke('image:decrypt', payload),
|
||||||
resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }) =>
|
resolveCache: (payload: {
|
||||||
|
sessionId?: string
|
||||||
|
imageMd5?: string
|
||||||
|
imageDatName?: string
|
||||||
|
createTime?: number
|
||||||
|
preferFilePath?: boolean
|
||||||
|
hardlinkOnly?: boolean
|
||||||
|
disableUpdateCheck?: boolean
|
||||||
|
allowCacheIndex?: boolean
|
||||||
|
suppressEvents?: boolean
|
||||||
|
}) =>
|
||||||
ipcRenderer.invoke('image:resolveCache', payload),
|
ipcRenderer.invoke('image:resolveCache', payload),
|
||||||
preload: (payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>) =>
|
resolveCacheBatch: (
|
||||||
ipcRenderer.invoke('image:preload', payloads),
|
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; preferFilePath?: boolean; hardlinkOnly?: boolean }>,
|
||||||
|
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean; preferFilePath?: boolean; hardlinkOnly?: boolean; suppressEvents?: boolean }
|
||||||
|
) => ipcRenderer.invoke('image:resolveCacheBatch', payloads, options),
|
||||||
|
preload: (
|
||||||
|
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }>,
|
||||||
|
options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean }
|
||||||
|
) => ipcRenderer.invoke('image:preload', payloads, options),
|
||||||
|
preloadHardlinkMd5s: (md5List: string[]) =>
|
||||||
|
ipcRenderer.invoke('image:preloadHardlinkMd5s', md5List),
|
||||||
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => {
|
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => {
|
||||||
const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => callback(payload)
|
const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => callback(payload)
|
||||||
ipcRenderer.on('image:updateAvailable', listener)
|
ipcRenderer.on('image:updateAvailable', listener)
|
||||||
@@ -255,12 +330,33 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => callback(payload)
|
const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => callback(payload)
|
||||||
ipcRenderer.on('image:cacheResolved', listener)
|
ipcRenderer.on('image:cacheResolved', listener)
|
||||||
return () => ipcRenderer.removeListener('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: {
|
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)
|
parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content)
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -297,6 +393,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMessageRanking', chatroomId, limit, startTime, endTime),
|
getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMessageRanking', chatroomId, limit, startTime, endTime),
|
||||||
getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime),
|
getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime),
|
||||||
getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime),
|
getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime),
|
||||||
|
getGroupMemberAnalytics: (chatroomId: string, memberUsername: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMemberAnalytics', chatroomId, memberUsername, startTime, endTime),
|
||||||
getGroupMemberMessages: (
|
getGroupMemberMessages: (
|
||||||
chatroomId: string,
|
chatroomId: string,
|
||||||
memberUsername: string,
|
memberUsername: string,
|
||||||
@@ -315,6 +412,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
generateReport: (year: number) => ipcRenderer.invoke('annualReport:generateReport', year),
|
generateReport: (year: number) => ipcRenderer.invoke('annualReport:generateReport', year),
|
||||||
exportImages: (payload: { baseDir: string; folderName: string; images: Array<{ name: string; dataUrl: string }> }) =>
|
exportImages: (payload: { baseDir: string; folderName: string; images: Array<{ name: string; dataUrl: string }> }) =>
|
||||||
ipcRenderer.invoke('annualReport:exportImages', payload),
|
ipcRenderer.invoke('annualReport:exportImages', payload),
|
||||||
|
captureCurrentWindow: () => ipcRenderer.invoke('annualReport:captureCurrentWindow'),
|
||||||
onAvailableYearsProgress: (callback: (payload: {
|
onAvailableYearsProgress: (callback: (payload: {
|
||||||
taskId: string
|
taskId: string
|
||||||
years?: number[]
|
years?: number[]
|
||||||
@@ -409,7 +507,22 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
uninstallBlockDeleteTrigger: () => ipcRenderer.invoke('sns:uninstallBlockDeleteTrigger'),
|
uninstallBlockDeleteTrigger: () => ipcRenderer.invoke('sns:uninstallBlockDeleteTrigger'),
|
||||||
checkBlockDeleteTrigger: () => ipcRenderer.invoke('sns:checkBlockDeleteTrigger'),
|
checkBlockDeleteTrigger: () => ipcRenderer.invoke('sns:checkBlockDeleteTrigger'),
|
||||||
deleteSnsPost: (postId: string) => ipcRenderer.invoke('sns:deleteSnsPost', postId),
|
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)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
@@ -422,8 +535,34 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
|
|
||||||
// HTTP API 服务
|
// HTTP API 服务
|
||||||
http: {
|
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'),
|
stop: () => ipcRenderer.invoke('http:stop'),
|
||||||
status: () => ipcRenderer.invoke('http:status')
|
status: () => ipcRenderer.invoke('http:status')
|
||||||
|
},
|
||||||
|
|
||||||
|
// AI 见解
|
||||||
|
insight: {
|
||||||
|
testConnection: () => ipcRenderer.invoke('insight:testConnection'),
|
||||||
|
getTodayStats: () => ipcRenderer.invoke('insight:getTodayStats'),
|
||||||
|
triggerTest: () => ipcRenderer.invoke('insight:triggerTest'),
|
||||||
|
generateFootprintInsight: (payload: {
|
||||||
|
rangeLabel: string
|
||||||
|
summary: {
|
||||||
|
private_inbound_people?: number
|
||||||
|
private_replied_people?: number
|
||||||
|
private_outbound_people?: number
|
||||||
|
private_reply_rate?: number
|
||||||
|
mention_count?: number
|
||||||
|
mention_group_count?: number
|
||||||
|
}
|
||||||
|
privateSegments?: Array<{ displayName?: string; session_id?: string; incoming_count?: number; outgoing_count?: number; message_count?: number; replied?: boolean }>
|
||||||
|
mentionGroups?: Array<{ displayName?: string; session_id?: string; count?: number }>
|
||||||
|
}) => ipcRenderer.invoke('insight:generateFootprintInsight', payload)
|
||||||
|
},
|
||||||
|
|
||||||
|
social: {
|
||||||
|
saveWeiboCookie: (rawInput: string) => ipcRenderer.invoke('social:saveWeiboCookie', rawInput),
|
||||||
|
validateWeiboUid: (uid: string) => ipcRenderer.invoke('social:validateWeiboUid', uid)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ export interface AnnualReportData {
|
|||||||
initiatedChats: number
|
initiatedChats: number
|
||||||
receivedChats: number
|
receivedChats: number
|
||||||
initiativeRate: number
|
initiativeRate: number
|
||||||
|
topInitiatedFriend?: string
|
||||||
|
topInitiatedCount?: number
|
||||||
} | null
|
} | null
|
||||||
responseSpeed: {
|
responseSpeed: {
|
||||||
avgResponseTime: number
|
avgResponseTime: number
|
||||||
@@ -1135,7 +1137,7 @@ class AnnualReportService {
|
|||||||
|
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
if (now - lastProgressAt > 200) {
|
if (now - lastProgressAt > 200) {
|
||||||
let progress = 30
|
let progress: number
|
||||||
if (totalMessagesForProgress > 0) {
|
if (totalMessagesForProgress > 0) {
|
||||||
const ratio = Math.min(1, processedMessages / totalMessagesForProgress)
|
const ratio = Math.min(1, processedMessages / totalMessagesForProgress)
|
||||||
progress = 30 + Math.floor(ratio * 50)
|
progress = 30 + Math.floor(ratio * 50)
|
||||||
@@ -1190,7 +1192,9 @@ class AnnualReportService {
|
|||||||
topLiked: { username: string; displayName: string; avatarUrl?: string; count: number }[]
|
topLiked: { username: string; displayName: string; avatarUrl?: string; count: number }[]
|
||||||
} | undefined
|
} | undefined
|
||||||
|
|
||||||
const snsStats = await wcdbService.getSnsAnnualStats(actualStartTime, actualEndTime)
|
const snsBeginTime = isAllTime ? 0 : actualStartTime
|
||||||
|
const snsEndTime = isAllTime ? Math.floor(Date.now() / 1000) : actualEndTime
|
||||||
|
const snsStats = await wcdbService.getSnsAnnualStats(snsBeginTime, snsEndTime)
|
||||||
|
|
||||||
if (snsStats.success && snsStats.data) {
|
if (snsStats.success && snsStats.data) {
|
||||||
const d = snsStats.data
|
const d = snsStats.data
|
||||||
@@ -1217,6 +1221,20 @@ class AnnualReportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ALL YEARS 兼容:部分底层实现 begin/end 为 0 时会返回 0,兜底使用导出统计总数。
|
||||||
|
if (isAllTime && (!snsStatsResult || Number(snsStatsResult.totalPosts || 0) <= 0)) {
|
||||||
|
const snsExportStats = await wcdbService.getSnsExportStats(cleanedWxid || rawWxid)
|
||||||
|
if (snsExportStats.success && snsExportStats.data) {
|
||||||
|
const fallbackTotalPosts = Math.max(0, Number(snsExportStats.data.totalPosts || 0))
|
||||||
|
snsStatsResult = {
|
||||||
|
totalPosts: fallbackTotalPosts,
|
||||||
|
typeCounts: snsStatsResult?.typeCounts,
|
||||||
|
topLikers: snsStatsResult?.topLikers || [],
|
||||||
|
topLiked: snsStatsResult?.topLiked || []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.reportProgress('整理联系人信息...', 85, onProgress)
|
this.reportProgress('整理联系人信息...', 85, onProgress)
|
||||||
|
|
||||||
const contactIds = Array.from(contactStats.keys())
|
const contactIds = Array.from(contactStats.keys())
|
||||||
@@ -1346,16 +1364,27 @@ class AnnualReportService {
|
|||||||
let socialInitiative: AnnualReportData['socialInitiative'] = null
|
let socialInitiative: AnnualReportData['socialInitiative'] = null
|
||||||
let totalInitiated = 0
|
let totalInitiated = 0
|
||||||
let totalReceived = 0
|
let totalReceived = 0
|
||||||
for (const stats of conversationStarts.values()) {
|
let topInitiatedSessionId = ''
|
||||||
|
let topInitiatedCount = 0
|
||||||
|
for (const [sessionId, stats] of conversationStarts.entries()) {
|
||||||
totalInitiated += stats.initiated
|
totalInitiated += stats.initiated
|
||||||
totalReceived += stats.received
|
totalReceived += stats.received
|
||||||
|
if (stats.initiated > topInitiatedCount) {
|
||||||
|
topInitiatedCount = stats.initiated
|
||||||
|
topInitiatedSessionId = sessionId
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const totalConversations = totalInitiated + totalReceived
|
const totalConversations = totalInitiated + totalReceived
|
||||||
if (totalConversations > 0) {
|
if (totalConversations > 0) {
|
||||||
|
const topInitiatedInfo = topInitiatedSessionId ? contactInfoMap.get(topInitiatedSessionId) : null
|
||||||
socialInitiative = {
|
socialInitiative = {
|
||||||
initiatedChats: totalInitiated,
|
initiatedChats: totalInitiated,
|
||||||
receivedChats: totalReceived,
|
receivedChats: totalReceived,
|
||||||
initiativeRate: Math.round((totalInitiated / totalConversations) * 1000) / 10
|
initiativeRate: Math.round((totalInitiated / totalConversations) * 1000) / 10,
|
||||||
|
topInitiatedFriend: topInitiatedCount > 0
|
||||||
|
? (topInitiatedInfo?.displayName || topInitiatedSessionId)
|
||||||
|
: undefined,
|
||||||
|
topInitiatedCount: topInitiatedCount > 0 ? topInitiatedCount : undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
219
electron/services/avatarFileCacheService.ts
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
import https from "https";
|
||||||
|
import http, { IncomingMessage } from "http";
|
||||||
|
import { promises as fs } from "fs";
|
||||||
|
import { join } from "path";
|
||||||
|
import { ConfigService } from "./config";
|
||||||
|
|
||||||
|
// 头像文件缓存服务 - 复用项目已有的缓存目录结构
|
||||||
|
export class AvatarFileCacheService {
|
||||||
|
private static instance: AvatarFileCacheService | null = null;
|
||||||
|
|
||||||
|
// 头像文件缓存目录
|
||||||
|
private readonly cacheDir: string;
|
||||||
|
// 头像URL -> 本地文件路径的内存缓存(仅追踪正在下载的)
|
||||||
|
private readonly pendingDownloads: Map<string, Promise<string | null>> =
|
||||||
|
new Map();
|
||||||
|
// LRU 追踪:文件路径->最后访问时间
|
||||||
|
private readonly lruOrder: string[] = [];
|
||||||
|
private readonly maxCacheFiles = 100;
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
const basePath = ConfigService.getInstance().getCacheBasePath();
|
||||||
|
this.cacheDir = join(basePath, "avatar-files");
|
||||||
|
this.ensureCacheDir();
|
||||||
|
this.loadLruOrder();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getInstance(): AvatarFileCacheService {
|
||||||
|
if (!AvatarFileCacheService.instance) {
|
||||||
|
AvatarFileCacheService.instance = new AvatarFileCacheService();
|
||||||
|
}
|
||||||
|
return AvatarFileCacheService.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureCacheDir(): void {
|
||||||
|
// 同步确保目录存在(构造函数调用)
|
||||||
|
try {
|
||||||
|
fs.mkdir(this.cacheDir, { recursive: true }).catch(() => {});
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureCacheDirAsync(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fs.mkdir(this.cacheDir, { recursive: true });
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFilePath(url: string): string {
|
||||||
|
// 使用URL的hash作为文件名,避免特殊字符问题
|
||||||
|
const hash = this.hashString(url);
|
||||||
|
return join(this.cacheDir, `avatar_${hash}.png`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private hashString(str: string): string {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
const char = str.charCodeAt(i);
|
||||||
|
hash = (hash << 5) - hash + char;
|
||||||
|
hash = hash & hash; // 转换为32位整数
|
||||||
|
}
|
||||||
|
return Math.abs(hash).toString(16);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadLruOrder(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const entries = await fs.readdir(this.cacheDir);
|
||||||
|
// 按修改时间排序(旧的在前)
|
||||||
|
const filesWithTime: { file: string; mtime: number }[] = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.startsWith("avatar_") || !entry.endsWith(".png")) continue;
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(join(this.cacheDir, entry));
|
||||||
|
filesWithTime.push({ file: entry, mtime: stat.mtimeMs });
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
filesWithTime.sort((a, b) => a.mtime - b.mtime);
|
||||||
|
this.lruOrder.length = 0;
|
||||||
|
this.lruOrder.push(...filesWithTime.map((f) => f.file));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateLru(fileName: string): void {
|
||||||
|
const index = this.lruOrder.indexOf(fileName);
|
||||||
|
if (index > -1) {
|
||||||
|
this.lruOrder.splice(index, 1);
|
||||||
|
}
|
||||||
|
this.lruOrder.push(fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async evictIfNeeded(): Promise<void> {
|
||||||
|
while (this.lruOrder.length >= this.maxCacheFiles) {
|
||||||
|
const oldest = this.lruOrder.shift();
|
||||||
|
if (oldest) {
|
||||||
|
try {
|
||||||
|
await fs.rm(join(this.cacheDir, oldest));
|
||||||
|
console.log(`[AvatarFileCache] Evicted: ${oldest}`);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async downloadAvatar(url: string): Promise<string | null> {
|
||||||
|
const localPath = this.getFilePath(url);
|
||||||
|
|
||||||
|
// 检查文件是否已存在
|
||||||
|
try {
|
||||||
|
await fs.access(localPath);
|
||||||
|
const fileName = localPath.split("/").pop()!;
|
||||||
|
this.updateLru(fileName);
|
||||||
|
return localPath;
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
await this.ensureCacheDirAsync();
|
||||||
|
await this.evictIfNeeded();
|
||||||
|
|
||||||
|
return new Promise<string | null>((resolve) => {
|
||||||
|
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) {
|
||||||
|
resolve(null);
|
||||||
|
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);
|
||||||
|
const fileName = localPath.split("/").pop()!;
|
||||||
|
this.updateLru(fileName);
|
||||||
|
console.log(
|
||||||
|
`[AvatarFileCache] Downloaded: ${url.substring(0, 50)}... -> ${localPath}`,
|
||||||
|
);
|
||||||
|
resolve(localPath);
|
||||||
|
} catch {
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
res.on("error", () => resolve(null));
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = url.startsWith("https")
|
||||||
|
? https.get(url, options, callback)
|
||||||
|
: http.get(url, options, callback);
|
||||||
|
|
||||||
|
req.on("error", () => resolve(null));
|
||||||
|
req.setTimeout(10000, () => {
|
||||||
|
req.destroy();
|
||||||
|
resolve(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取头像本地文件路径,如果需要会下载
|
||||||
|
* 同一URL并发调用会复用同一个下载任务
|
||||||
|
*/
|
||||||
|
async getAvatarPath(url: string): Promise<string | null> {
|
||||||
|
if (!url) return null;
|
||||||
|
|
||||||
|
// 检查是否有正在进行的下载
|
||||||
|
const pending = this.pendingDownloads.get(url);
|
||||||
|
if (pending) {
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发起新下载
|
||||||
|
const downloadPromise = this.downloadAvatar(url);
|
||||||
|
this.pendingDownloads.set(url, downloadPromise);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await downloadPromise;
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
this.pendingDownloads.delete(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理所有缓存文件(App退出时调用)
|
||||||
|
async clearCache(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const entries = await fs.readdir(this.cacheDir);
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.startsWith("avatar_") && entry.endsWith(".png")) {
|
||||||
|
try {
|
||||||
|
await fs.rm(join(this.cacheDir, entry));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.lruOrder.length = 0;
|
||||||
|
console.log("[AvatarFileCache] Cache cleared");
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前缓存的文件数量
|
||||||
|
async getCacheCount(): Promise<number> {
|
||||||
|
try {
|
||||||
|
const entries = await fs.readdir(this.cacheDir);
|
||||||
|
return entries.filter(
|
||||||
|
(e) => e.startsWith("avatar_") && e.endsWith(".png"),
|
||||||
|
).length;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const avatarFileCache = AvatarFileCacheService.getInstance();
|
||||||
250
electron/services/bizService.ts
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
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
|
||||||
|
unread_count?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
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> = {}
|
||||||
|
const bizUnreadCount: Record<string, number> = {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sessionsRes = await chatService.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.lastTimestamp || session.sortTimestamp || 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
|
||||||
|
}
|
||||||
|
if (usernames.includes(uname)) {
|
||||||
|
const unread = Number(session.unreadCount ?? session.unread_count ?? 0)
|
||||||
|
bizUnreadCount[uname] = Number.isFinite(unread) ? Math.max(0, Math.floor(unread)) : 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} 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),
|
||||||
|
unread_count: bizUnreadCount[uname] || 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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()
|
||||||
@@ -15,15 +15,31 @@ class CloudControlService {
|
|||||||
private timer: NodeJS.Timeout | null = null
|
private timer: NodeJS.Timeout | null = null
|
||||||
private pages: Set<string> = new Set()
|
private pages: Set<string> = new Set()
|
||||||
private platformVersionCache: string | null = null
|
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() {
|
async init() {
|
||||||
|
if (this.initialized) return
|
||||||
|
this.initialized = true
|
||||||
this.deviceId = this.getDeviceId()
|
this.deviceId = this.getDeviceId()
|
||||||
await wcdbService.cloudInit(300)
|
await wcdbService.cloudInit(300)
|
||||||
await this.reportOnline()
|
this.enqueueCurrentReport()
|
||||||
|
await this.flushQueue(true)
|
||||||
this.timer = setInterval(() => {
|
this.scheduleNextFlush(this.nextDelayOverrideMs ?? undefined)
|
||||||
this.reportOnline()
|
this.nextDelayOverrideMs = null
|
||||||
}, 300000)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private getDeviceId(): string {
|
private getDeviceId(): string {
|
||||||
@@ -33,8 +49,8 @@ class CloudControlService {
|
|||||||
return crypto.createHash('md5').update(machineId).digest('hex')
|
return crypto.createHash('md5').update(machineId).digest('hex')
|
||||||
}
|
}
|
||||||
|
|
||||||
private async reportOnline() {
|
private buildCurrentReport(): UsageStats {
|
||||||
const data: UsageStats = {
|
return {
|
||||||
appVersion: app.getVersion(),
|
appVersion: app.getVersion(),
|
||||||
platform: this.getPlatformVersion(),
|
platform: this.getPlatformVersion(),
|
||||||
deviceId: this.deviceId,
|
deviceId: this.deviceId,
|
||||||
@@ -42,11 +58,69 @@ class CloudControlService {
|
|||||||
online: true,
|
online: true,
|
||||||
pages: Array.from(this.pages)
|
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()
|
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 {
|
private getPlatformVersion(): string {
|
||||||
if (this.platformVersionCache) {
|
if (this.platformVersionCache) {
|
||||||
return this.platformVersionCache
|
return this.platformVersionCache
|
||||||
@@ -144,12 +218,25 @@ class CloudControlService {
|
|||||||
this.pages.add(pageName)
|
this.pages.add(pageName)
|
||||||
}
|
}
|
||||||
|
|
||||||
stop() {
|
async stop(): Promise<void> {
|
||||||
if (this.timer) {
|
if (this.timer) {
|
||||||
clearInterval(this.timer)
|
clearTimeout(this.timer)
|
||||||
this.timer = null
|
this.timer = null
|
||||||
}
|
}
|
||||||
wcdbService.cloudStop()
|
this.pendingReports = []
|
||||||
|
this.flushInProgress = false
|
||||||
|
this.retryDelayMs = 5_000
|
||||||
|
this.consecutiveFailures = 0
|
||||||
|
this.circuitOpenedAt = 0
|
||||||
|
this.nextDelayOverrideMs = null
|
||||||
|
this.initialized = false
|
||||||
|
if (wcdbService.isReady()) {
|
||||||
|
try {
|
||||||
|
await wcdbService.cloudStop()
|
||||||
|
} catch {
|
||||||
|
// 忽略停止失败,避免阻塞主进程退出
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getLogs() {
|
async getLogs() {
|
||||||
@@ -158,4 +245,3 @@ class CloudControlService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const cloudControlService = new CloudControlService()
|
export const cloudControlService = new CloudControlService()
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { app, safeStorage } from 'electron'
|
import { app, safeStorage } from 'electron'
|
||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
import Store from 'electron-store'
|
import Store from 'electron-store'
|
||||||
|
import { expandHomePath } from '../utils/pathUtils'
|
||||||
|
|
||||||
// 加密前缀标记
|
// 加密前缀标记
|
||||||
const SAFE_PREFIX = 'safe:' // safeStorage 加密(普通模式)
|
const SAFE_PREFIX = 'safe:' // safeStorage 加密(普通模式)
|
||||||
|
const isSafeStorageAvailable = (): boolean => {
|
||||||
|
try {
|
||||||
|
return typeof safeStorage?.isEncryptionAvailable === 'function' && safeStorage.isEncryptionAvailable()
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
const LOCK_PREFIX = 'lock:' // 密码派生密钥加密(锁定模式)
|
const LOCK_PREFIX = 'lock:' // 密码派生密钥加密(锁定模式)
|
||||||
|
|
||||||
interface ConfigSchema {
|
interface ConfigSchema {
|
||||||
@@ -27,6 +35,7 @@ interface ConfigSchema {
|
|||||||
themeId: string
|
themeId: string
|
||||||
language: string
|
language: string
|
||||||
logEnabled: boolean
|
logEnabled: boolean
|
||||||
|
launchAtStartup?: boolean
|
||||||
llmModelPath: string
|
llmModelPath: string
|
||||||
whisperModelName: string
|
whisperModelName: string
|
||||||
whisperModelDir: string
|
whisperModelDir: string
|
||||||
@@ -34,7 +43,6 @@ interface ConfigSchema {
|
|||||||
autoTranscribeVoice: boolean
|
autoTranscribeVoice: boolean
|
||||||
transcribeLanguages: string[]
|
transcribeLanguages: string[]
|
||||||
exportDefaultConcurrency: number
|
exportDefaultConcurrency: number
|
||||||
exportDefaultImageDeepSearchOnMiss: boolean
|
|
||||||
analyticsExcludedUsernames: string[]
|
analyticsExcludedUsernames: string[]
|
||||||
|
|
||||||
// 安全相关
|
// 安全相关
|
||||||
@@ -45,6 +53,7 @@ interface ConfigSchema {
|
|||||||
|
|
||||||
// 更新相关
|
// 更新相关
|
||||||
ignoredUpdateVersion: string
|
ignoredUpdateVersion: string
|
||||||
|
updateChannel: 'auto' | 'stable' | 'preview' | 'dev'
|
||||||
|
|
||||||
// 通知
|
// 通知
|
||||||
notificationEnabled: boolean
|
notificationEnabled: boolean
|
||||||
@@ -52,13 +61,66 @@ interface ConfigSchema {
|
|||||||
notificationFilterMode: 'all' | 'whitelist' | 'blacklist'
|
notificationFilterMode: 'all' | 'whitelist' | 'blacklist'
|
||||||
notificationFilterList: string[]
|
notificationFilterList: string[]
|
||||||
messagePushEnabled: boolean
|
messagePushEnabled: boolean
|
||||||
|
messagePushFilterMode: 'all' | 'whitelist' | 'blacklist'
|
||||||
|
messagePushFilterList: string[]
|
||||||
|
httpApiEnabled: boolean
|
||||||
|
httpApiPort: number
|
||||||
|
httpApiHost: string
|
||||||
|
httpApiToken: string
|
||||||
windowCloseBehavior: 'ask' | 'tray' | 'quit'
|
windowCloseBehavior: 'ask' | 'tray' | 'quit'
|
||||||
quoteLayout: 'quote-top' | 'quote-bottom'
|
quoteLayout: 'quote-top' | 'quote-bottom'
|
||||||
wordCloudExcludeWords: string[]
|
wordCloudExcludeWords: string[]
|
||||||
|
exportWriteLayout: 'A' | 'B' | 'C'
|
||||||
|
exportAutomationTaskMap: Record<string, unknown>
|
||||||
|
|
||||||
|
// AI 见解
|
||||||
|
aiModelApiBaseUrl: string
|
||||||
|
aiModelApiKey: string
|
||||||
|
aiModelApiModel: string
|
||||||
|
aiModelApiMaxTokens: number
|
||||||
|
aiInsightEnabled: boolean
|
||||||
|
aiInsightApiBaseUrl: string
|
||||||
|
aiInsightApiKey: string
|
||||||
|
aiInsightApiModel: string
|
||||||
|
aiInsightSilenceDays: number
|
||||||
|
aiInsightAllowContext: boolean
|
||||||
|
aiInsightAllowSocialContext: boolean
|
||||||
|
aiInsightFilterMode: 'whitelist' | 'blacklist'
|
||||||
|
aiInsightFilterList: string[]
|
||||||
|
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
|
||||||
|
|
||||||
|
// AI 足迹
|
||||||
|
aiFootprintEnabled: boolean
|
||||||
|
aiFootprintSystemPrompt: string
|
||||||
|
/** 是否将 AI 见解调试日志输出到桌面 */
|
||||||
|
aiInsightDebugLogEnabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// 需要 safeStorage 加密的字段(普通模式)
|
// 需要 safeStorage 加密的字段(普通模式)
|
||||||
const ENCRYPTED_STRING_KEYS: Set<string> = new Set(['decryptKey', 'imageAesKey', 'authPassword'])
|
const ENCRYPTED_STRING_KEYS: Set<string> = new Set([
|
||||||
|
'decryptKey',
|
||||||
|
'imageAesKey',
|
||||||
|
'authPassword',
|
||||||
|
'httpApiToken',
|
||||||
|
'aiModelApiKey',
|
||||||
|
'aiInsightApiKey',
|
||||||
|
'aiInsightWeiboCookie'
|
||||||
|
])
|
||||||
const ENCRYPTED_BOOL_KEYS: Set<string> = new Set(['authEnabled', 'authUseHello'])
|
const ENCRYPTED_BOOL_KEYS: Set<string> = new Set(['authEnabled', 'authUseHello'])
|
||||||
const ENCRYPTED_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
|
const ENCRYPTED_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
|
||||||
|
|
||||||
@@ -108,21 +170,57 @@ export class ConfigService {
|
|||||||
autoTranscribeVoice: false,
|
autoTranscribeVoice: false,
|
||||||
transcribeLanguages: ['zh'],
|
transcribeLanguages: ['zh'],
|
||||||
exportDefaultConcurrency: 4,
|
exportDefaultConcurrency: 4,
|
||||||
exportDefaultImageDeepSearchOnMiss: true,
|
|
||||||
analyticsExcludedUsernames: [],
|
analyticsExcludedUsernames: [],
|
||||||
authEnabled: false,
|
authEnabled: false,
|
||||||
authPassword: '',
|
authPassword: '',
|
||||||
authUseHello: false,
|
authUseHello: false,
|
||||||
authHelloSecret: '',
|
authHelloSecret: '',
|
||||||
ignoredUpdateVersion: '',
|
ignoredUpdateVersion: '',
|
||||||
|
updateChannel: 'auto',
|
||||||
notificationEnabled: true,
|
notificationEnabled: true,
|
||||||
notificationPosition: 'top-right',
|
notificationPosition: 'top-right',
|
||||||
notificationFilterMode: 'all',
|
notificationFilterMode: 'all',
|
||||||
notificationFilterList: [],
|
notificationFilterList: [],
|
||||||
|
httpApiToken: '',
|
||||||
|
httpApiEnabled: false,
|
||||||
|
httpApiPort: 5031,
|
||||||
|
httpApiHost: '127.0.0.1',
|
||||||
messagePushEnabled: false,
|
messagePushEnabled: false,
|
||||||
|
messagePushFilterMode: 'all',
|
||||||
|
messagePushFilterList: [],
|
||||||
windowCloseBehavior: 'ask',
|
windowCloseBehavior: 'ask',
|
||||||
quoteLayout: 'quote-top',
|
quoteLayout: 'quote-top',
|
||||||
wordCloudExcludeWords: []
|
wordCloudExcludeWords: [],
|
||||||
|
exportWriteLayout: 'A',
|
||||||
|
exportAutomationTaskMap: {},
|
||||||
|
aiModelApiBaseUrl: '',
|
||||||
|
aiModelApiKey: '',
|
||||||
|
aiModelApiModel: 'gpt-4o-mini',
|
||||||
|
aiModelApiMaxTokens: 200,
|
||||||
|
aiInsightEnabled: false,
|
||||||
|
aiInsightApiBaseUrl: '',
|
||||||
|
aiInsightApiKey: '',
|
||||||
|
aiInsightApiModel: 'gpt-4o-mini',
|
||||||
|
aiInsightSilenceDays: 3,
|
||||||
|
aiInsightAllowContext: false,
|
||||||
|
aiInsightAllowSocialContext: false,
|
||||||
|
aiInsightFilterMode: 'whitelist',
|
||||||
|
aiInsightFilterList: [],
|
||||||
|
aiInsightWhitelistEnabled: false,
|
||||||
|
aiInsightWhitelist: [],
|
||||||
|
aiInsightCooldownMinutes: 120,
|
||||||
|
aiInsightScanIntervalHours: 4,
|
||||||
|
aiInsightContextCount: 40,
|
||||||
|
aiInsightSocialContextCount: 3,
|
||||||
|
aiInsightSystemPrompt: '',
|
||||||
|
aiInsightTelegramEnabled: false,
|
||||||
|
aiInsightTelegramToken: '',
|
||||||
|
aiInsightTelegramChatIds: '',
|
||||||
|
aiInsightWeiboCookie: '',
|
||||||
|
aiInsightWeiboBindings: {},
|
||||||
|
aiFootprintEnabled: false,
|
||||||
|
aiFootprintSystemPrompt: '',
|
||||||
|
aiInsightDebugLogEnabled: false
|
||||||
}
|
}
|
||||||
|
|
||||||
const storeOptions: any = {
|
const storeOptions: any = {
|
||||||
@@ -154,6 +252,7 @@ export class ConfigService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.migrateAuthFields()
|
this.migrateAuthFields()
|
||||||
|
this.migrateAiConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
// === 状态查询 ===
|
// === 状态查询 ===
|
||||||
@@ -203,6 +302,10 @@ export class ConfigService {
|
|||||||
return this.decryptWxidConfigs(raw as any) as ConfigSchema[K]
|
return this.decryptWxidConfigs(raw as any) as ConfigSchema[K]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (key === 'dbPath' && typeof raw === 'string') {
|
||||||
|
return expandHomePath(raw) as ConfigSchema[K]
|
||||||
|
}
|
||||||
|
|
||||||
return raw
|
return raw
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,8 +313,14 @@ export class ConfigService {
|
|||||||
let toStore = value
|
let toStore = value
|
||||||
const inLockMode = this.isLockMode() && this.unlockPassword
|
const inLockMode = this.isLockMode() && this.unlockPassword
|
||||||
|
|
||||||
|
if (key === 'dbPath' && typeof value === 'string') {
|
||||||
|
toStore = expandHomePath(value) as ConfigSchema[K]
|
||||||
|
}
|
||||||
|
|
||||||
if (ENCRYPTED_BOOL_KEYS.has(key)) {
|
if (ENCRYPTED_BOOL_KEYS.has(key)) {
|
||||||
toStore = this.safeEncrypt(String(value)) as ConfigSchema[K]
|
const boolValue = value === true || value === 'true'
|
||||||
|
// `false` 不需要写入 keychain,避免无意义触发 macOS 钥匙串弹窗
|
||||||
|
toStore = (boolValue ? this.safeEncrypt('true') : false) as ConfigSchema[K]
|
||||||
} else if (ENCRYPTED_NUMBER_KEYS.has(key)) {
|
} else if (ENCRYPTED_NUMBER_KEYS.has(key)) {
|
||||||
if (inLockMode && LOCKABLE_NUMBER_KEYS.has(key)) {
|
if (inLockMode && LOCKABLE_NUMBER_KEYS.has(key)) {
|
||||||
toStore = this.lockEncrypt(String(value), this.unlockPassword!) as ConfigSchema[K]
|
toStore = this.lockEncrypt(String(value), this.unlockPassword!) as ConfigSchema[K]
|
||||||
@@ -244,7 +353,7 @@ export class ConfigService {
|
|||||||
private safeEncrypt(plaintext: string): string {
|
private safeEncrypt(plaintext: string): string {
|
||||||
if (!plaintext) return ''
|
if (!plaintext) return ''
|
||||||
if (plaintext.startsWith(SAFE_PREFIX)) return plaintext
|
if (plaintext.startsWith(SAFE_PREFIX)) return plaintext
|
||||||
if (!safeStorage.isEncryptionAvailable()) return plaintext
|
if (!isSafeStorageAvailable()) return plaintext
|
||||||
const encrypted = safeStorage.encryptString(plaintext)
|
const encrypted = safeStorage.encryptString(plaintext)
|
||||||
return SAFE_PREFIX + encrypted.toString('base64')
|
return SAFE_PREFIX + encrypted.toString('base64')
|
||||||
}
|
}
|
||||||
@@ -252,7 +361,7 @@ export class ConfigService {
|
|||||||
private safeDecrypt(stored: string): string {
|
private safeDecrypt(stored: string): string {
|
||||||
if (!stored) return ''
|
if (!stored) return ''
|
||||||
if (!stored.startsWith(SAFE_PREFIX)) return stored
|
if (!stored.startsWith(SAFE_PREFIX)) return stored
|
||||||
if (!safeStorage.isEncryptionAvailable()) return ''
|
if (!isSafeStorageAvailable()) return ''
|
||||||
try {
|
try {
|
||||||
const buf = Buffer.from(stored.slice(SAFE_PREFIX.length), 'base64')
|
const buf = Buffer.from(stored.slice(SAFE_PREFIX.length), 'base64')
|
||||||
return safeStorage.decryptString(buf)
|
return safeStorage.decryptString(buf)
|
||||||
@@ -590,7 +699,7 @@ export class ConfigService {
|
|||||||
|
|
||||||
clearHelloSecret(): void {
|
clearHelloSecret(): void {
|
||||||
this.store.set('authHelloSecret', '' as any)
|
this.store.set('authHelloSecret', '' as any)
|
||||||
this.store.set('authUseHello', this.safeEncrypt('false') as any)
|
this.store.set('authUseHello', false as any)
|
||||||
}
|
}
|
||||||
|
|
||||||
// === 迁移 ===
|
// === 迁移 ===
|
||||||
@@ -599,13 +708,18 @@ export class ConfigService {
|
|||||||
// 将旧版明文 auth 字段迁移为 safeStorage 加密格式
|
// 将旧版明文 auth 字段迁移为 safeStorage 加密格式
|
||||||
// 如果已经是 safe: 或 lock: 前缀则跳过
|
// 如果已经是 safe: 或 lock: 前缀则跳过
|
||||||
const rawEnabled: any = this.store.get('authEnabled')
|
const rawEnabled: any = this.store.get('authEnabled')
|
||||||
if (typeof rawEnabled === 'boolean') {
|
if (rawEnabled === true || rawEnabled === 'true') {
|
||||||
this.store.set('authEnabled', this.safeEncrypt(String(rawEnabled)) as any)
|
this.store.set('authEnabled', this.safeEncrypt('true') as any)
|
||||||
|
} else if (rawEnabled === false || rawEnabled === 'false') {
|
||||||
|
// 保持 false 为明文布尔,避免冷启动访问 keychain
|
||||||
|
this.store.set('authEnabled', false as any)
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawUseHello: any = this.store.get('authUseHello')
|
const rawUseHello: any = this.store.get('authUseHello')
|
||||||
if (typeof rawUseHello === 'boolean') {
|
if (rawUseHello === true || rawUseHello === 'true') {
|
||||||
this.store.set('authUseHello', this.safeEncrypt(String(rawUseHello)) as any)
|
this.store.set('authUseHello', this.safeEncrypt('true') as any)
|
||||||
|
} else if (rawUseHello === false || rawUseHello === 'false') {
|
||||||
|
this.store.set('authUseHello', false as any)
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawPassword: any = this.store.get('authPassword')
|
const rawPassword: any = this.store.get('authPassword')
|
||||||
@@ -651,6 +765,26 @@ export class ConfigService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private migrateAiConfig(): void {
|
||||||
|
const sharedBaseUrl = String(this.get('aiModelApiBaseUrl') || '').trim()
|
||||||
|
const sharedApiKey = String(this.get('aiModelApiKey') || '').trim()
|
||||||
|
const sharedModel = String(this.get('aiModelApiModel') || '').trim()
|
||||||
|
|
||||||
|
const legacyBaseUrl = String(this.get('aiInsightApiBaseUrl') || '').trim()
|
||||||
|
const legacyApiKey = String(this.get('aiInsightApiKey') || '').trim()
|
||||||
|
const legacyModel = String(this.get('aiInsightApiModel') || '').trim()
|
||||||
|
|
||||||
|
if (!sharedBaseUrl && legacyBaseUrl) {
|
||||||
|
this.set('aiModelApiBaseUrl', legacyBaseUrl)
|
||||||
|
}
|
||||||
|
if (!sharedApiKey && legacyApiKey) {
|
||||||
|
this.set('aiModelApiKey', legacyApiKey)
|
||||||
|
}
|
||||||
|
if (!sharedModel && legacyModel) {
|
||||||
|
this.set('aiModelApiModel', legacyModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// === 验证 ===
|
// === 验证 ===
|
||||||
|
|
||||||
verifyAuthEnabled(): boolean {
|
verifyAuthEnabled(): boolean {
|
||||||
@@ -662,11 +796,9 @@ export class ConfigService {
|
|||||||
|
|
||||||
// 即使 authEnabled 被删除/篡改,如果密钥是 lock: 格式,说明曾开启过应用锁
|
// 即使 authEnabled 被删除/篡改,如果密钥是 lock: 格式,说明曾开启过应用锁
|
||||||
const rawDecryptKey: any = this.store.get('decryptKey')
|
const rawDecryptKey: any = this.store.get('decryptKey')
|
||||||
if (typeof rawDecryptKey === 'string' && rawDecryptKey.startsWith(LOCK_PREFIX)) {
|
return typeof rawDecryptKey === 'string' && rawDecryptKey.startsWith(LOCK_PREFIX);
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// === 工具方法 ===
|
// === 工具方法 ===
|
||||||
@@ -714,3 +846,4 @@ export class ConfigService {
|
|||||||
this.unlockPassword = null
|
this.unlockPassword = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { join, basename } from 'path'
|
|||||||
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
|
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
|
||||||
import { homedir } from 'os'
|
import { homedir } from 'os'
|
||||||
import { createDecipheriv } from 'crypto'
|
import { createDecipheriv } from 'crypto'
|
||||||
|
import { expandHomePath } from '../utils/pathUtils'
|
||||||
|
|
||||||
export interface WxidInfo {
|
export interface WxidInfo {
|
||||||
wxid: string
|
wxid: string
|
||||||
@@ -93,27 +94,39 @@ export class DbPathService {
|
|||||||
const possiblePaths: string[] = []
|
const possiblePaths: string[] = []
|
||||||
const home = homedir()
|
const home = homedir()
|
||||||
|
|
||||||
// macOS 微信路径(固定)
|
|
||||||
if (process.platform === 'darwin') {
|
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'))
|
possiblePaths.push(join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'xwechat_files'))
|
||||||
} else {
|
} else {
|
||||||
// Windows 微信4.x 数据目录
|
// Windows 微信4.x 数据目录
|
||||||
possiblePaths.push(join(home, 'Documents', 'xwechat_files'))
|
possiblePaths.push(join(home, 'Documents', 'xwechat_files'))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
for (const path of possiblePaths) {
|
for (const path of possiblePaths) {
|
||||||
if (existsSync(path)) {
|
if (!existsSync(path)) continue
|
||||||
const rootName = path.split(/[/\\]/).pop()?.toLowerCase()
|
|
||||||
if (rootName !== 'xwechat_files' && rootName !== 'wechat files') {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否有有效的账号目录
|
// 检查是否有有效的账号目录,或本身就是账号目录
|
||||||
const accounts = this.findAccountDirs(path)
|
const accounts = this.findAccountDirs(path)
|
||||||
if (accounts.length > 0) {
|
if (accounts.length > 0) {
|
||||||
return { success: true, path }
|
return { success: true, path }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果该目录本身就是账号目录(直接包含 db_storage 等)
|
||||||
|
if (this.isAccountDir(path)) {
|
||||||
|
return { success: true, path }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,13 +140,14 @@ export class DbPathService {
|
|||||||
* 查找账号目录(包含 db_storage 或图片目录)
|
* 查找账号目录(包含 db_storage 或图片目录)
|
||||||
*/
|
*/
|
||||||
findAccountDirs(rootPath: string): string[] {
|
findAccountDirs(rootPath: string): string[] {
|
||||||
|
const resolvedRootPath = expandHomePath(rootPath)
|
||||||
const accounts: string[] = []
|
const accounts: string[] = []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const entries = readdirSync(rootPath)
|
const entries = readdirSync(resolvedRootPath)
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const entryPath = join(rootPath, entry)
|
const entryPath = join(resolvedRootPath, entry)
|
||||||
let stat: ReturnType<typeof statSync>
|
let stat: ReturnType<typeof statSync>
|
||||||
try {
|
try {
|
||||||
stat = statSync(entryPath)
|
stat = statSync(entryPath)
|
||||||
@@ -204,13 +218,14 @@ export class DbPathService {
|
|||||||
* 扫描目录名候选(仅包含下划线的文件夹,排除 all_users)
|
* 扫描目录名候选(仅包含下划线的文件夹,排除 all_users)
|
||||||
*/
|
*/
|
||||||
scanWxidCandidates(rootPath: string): WxidInfo[] {
|
scanWxidCandidates(rootPath: string): WxidInfo[] {
|
||||||
|
const resolvedRootPath = expandHomePath(rootPath)
|
||||||
const wxids: WxidInfo[] = []
|
const wxids: WxidInfo[] = []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (existsSync(rootPath)) {
|
if (existsSync(resolvedRootPath)) {
|
||||||
const entries = readdirSync(rootPath)
|
const entries = readdirSync(resolvedRootPath)
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const entryPath = join(rootPath, entry)
|
const entryPath = join(resolvedRootPath, entry)
|
||||||
let stat: ReturnType<typeof statSync>
|
let stat: ReturnType<typeof statSync>
|
||||||
try { stat = statSync(entryPath) } catch { continue }
|
try { stat = statSync(entryPath) } catch { continue }
|
||||||
if (!stat.isDirectory()) continue
|
if (!stat.isDirectory()) continue
|
||||||
@@ -223,9 +238,9 @@ export class DbPathService {
|
|||||||
|
|
||||||
|
|
||||||
if (wxids.length === 0) {
|
if (wxids.length === 0) {
|
||||||
const rootName = basename(rootPath)
|
const rootName = basename(resolvedRootPath)
|
||||||
if (rootName.includes('_') && rootName.toLowerCase() !== 'all_users') {
|
if (rootName.includes('_') && rootName.toLowerCase() !== 'all_users') {
|
||||||
const rootStat = statSync(rootPath)
|
const rootStat = statSync(resolvedRootPath)
|
||||||
wxids.push({ wxid: rootName, modifiedTime: rootStat.mtimeMs })
|
wxids.push({ wxid: rootName, modifiedTime: rootStat.mtimeMs })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -236,7 +251,7 @@ export class DbPathService {
|
|||||||
return a.wxid.localeCompare(b.wxid)
|
return a.wxid.localeCompare(b.wxid)
|
||||||
});
|
});
|
||||||
|
|
||||||
const globalInfo = this.parseGlobalConfig(rootPath);
|
const globalInfo = this.parseGlobalConfig(resolvedRootPath);
|
||||||
if (globalInfo) {
|
if (globalInfo) {
|
||||||
for (const w of sorted) {
|
for (const w of sorted) {
|
||||||
if (w.wxid.startsWith(globalInfo.wxid) || sorted.length === 1) {
|
if (w.wxid.startsWith(globalInfo.wxid) || sorted.length === 1) {
|
||||||
@@ -254,19 +269,20 @@ export class DbPathService {
|
|||||||
* 扫描 wxid 列表
|
* 扫描 wxid 列表
|
||||||
*/
|
*/
|
||||||
scanWxids(rootPath: string): WxidInfo[] {
|
scanWxids(rootPath: string): WxidInfo[] {
|
||||||
|
const resolvedRootPath = expandHomePath(rootPath)
|
||||||
const wxids: WxidInfo[] = []
|
const wxids: WxidInfo[] = []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (this.isAccountDir(rootPath)) {
|
if (this.isAccountDir(resolvedRootPath)) {
|
||||||
const wxid = basename(rootPath)
|
const wxid = basename(resolvedRootPath)
|
||||||
const modifiedTime = this.getAccountModifiedTime(rootPath)
|
const modifiedTime = this.getAccountModifiedTime(resolvedRootPath)
|
||||||
return [{ wxid, modifiedTime }]
|
return [{ wxid, modifiedTime }]
|
||||||
}
|
}
|
||||||
|
|
||||||
const accounts = this.findAccountDirs(rootPath)
|
const accounts = this.findAccountDirs(resolvedRootPath)
|
||||||
|
|
||||||
for (const account of accounts) {
|
for (const account of accounts) {
|
||||||
const fullPath = join(rootPath, account)
|
const fullPath = join(resolvedRootPath, account)
|
||||||
const modifiedTime = this.getAccountModifiedTime(fullPath)
|
const modifiedTime = this.getAccountModifiedTime(fullPath)
|
||||||
wxids.push({ wxid: account, modifiedTime })
|
wxids.push({ wxid: account, modifiedTime })
|
||||||
}
|
}
|
||||||
@@ -277,7 +293,7 @@ export class DbPathService {
|
|||||||
return a.wxid.localeCompare(b.wxid)
|
return a.wxid.localeCompare(b.wxid)
|
||||||
});
|
});
|
||||||
|
|
||||||
const globalInfo = this.parseGlobalConfig(rootPath);
|
const globalInfo = this.parseGlobalConfig(resolvedRootPath);
|
||||||
if (globalInfo) {
|
if (globalInfo) {
|
||||||
for (const w of sorted) {
|
for (const w of sorted) {
|
||||||
if (w.wxid.startsWith(globalInfo.wxid) || sorted.length === 1) {
|
if (w.wxid.startsWith(globalInfo.wxid) || sorted.length === 1) {
|
||||||
@@ -295,6 +311,20 @@ export class DbPathService {
|
|||||||
getDefaultPath(): string {
|
getDefaultPath(): string {
|
||||||
const home = homedir()
|
const home = homedir()
|
||||||
if (process.platform === 'darwin') {
|
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, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'xwechat_files')
|
||||||
}
|
}
|
||||||
return join(home, 'Documents', 'xwechat_files')
|
return join(home, 'Documents', 'xwechat_files')
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ class ExportRecordService {
|
|||||||
|
|
||||||
private resolveFilePath(): string {
|
private resolveFilePath(): string {
|
||||||
if (this.filePath) return this.filePath
|
if (this.filePath) return this.filePath
|
||||||
const userDataPath = app.getPath('userData')
|
const workerUserDataPath = String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim()
|
||||||
|
const userDataPath = workerUserDataPath || app?.getPath?.('userData') || process.cwd()
|
||||||
fs.mkdirSync(userDataPath, { recursive: true })
|
fs.mkdirSync(userDataPath, { recursive: true })
|
||||||
this.filePath = path.join(userDataPath, 'weflow-export-records.json')
|
this.filePath = path.join(userDataPath, 'weflow-export-records.json')
|
||||||
return this.filePath
|
return this.filePath
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ConfigService } from './config'
|
|||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
import { chatService } from './chatService'
|
import { chatService } from './chatService'
|
||||||
import type { Message } from './chatService'
|
import type { Message } from './chatService'
|
||||||
|
import type { ChatStatistics } from './analyticsService'
|
||||||
|
|
||||||
export interface GroupChatInfo {
|
export interface GroupChatInfo {
|
||||||
username: string
|
username: string
|
||||||
@@ -49,6 +50,13 @@ export interface GroupMediaStats {
|
|||||||
total: number
|
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 {
|
export interface GroupMemberMessagesPage {
|
||||||
messages: Message[]
|
messages: Message[]
|
||||||
hasMore: boolean
|
hasMore: boolean
|
||||||
@@ -267,7 +275,7 @@ class GroupAnalyticsService {
|
|||||||
}
|
}
|
||||||
return this.buildTrustedGroupNicknameMap(Object.entries(dllResult.nicknames), candidates)
|
return this.buildTrustedGroupNicknameMap(Object.entries(dllResult.nicknames), candidates)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('getGroupNicknamesForRoom dll error:', e)
|
console.error('getGroupNicknamesForRoom service error:', e)
|
||||||
return new Map<string, string>()
|
return new Map<string, string>()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -797,7 +805,12 @@ class GroupAnalyticsService {
|
|||||||
return normalized > 10000000000 ? Math.floor(normalized / 1000) : normalized
|
return normalized > 10000000000 ? Math.floor(normalized / 1000) : normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractRowSenderUsername(row: Record<string, any>): string {
|
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 = [
|
const candidates = [
|
||||||
row.sender_username,
|
row.sender_username,
|
||||||
row.senderUsername,
|
row.senderUsername,
|
||||||
@@ -820,13 +833,33 @@ class GroupAnalyticsService {
|
|||||||
if (normalizedValue) return normalizedValue
|
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 ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseSingleMessageRow(row: Record<string, any>): Message | null {
|
private parseSingleMessageRow(row: Record<string, any>): Message | null {
|
||||||
try {
|
try {
|
||||||
const mapped = chatService.mapRowsToMessagesForApi([row])
|
const mapped = chatService.mapRowsToMessagesForApi([row])
|
||||||
return Array.isArray(mapped) && mapped.length > 0 ? mapped[0] : null
|
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 {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -881,7 +914,7 @@ class GroupAnalyticsService {
|
|||||||
if (rows.length === 0) break
|
if (rows.length === 0) break
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const senderFromRow = this.extractRowSenderUsername(row)
|
const senderFromRow = this.extractRowSenderUsername(row, String(this.configService.get('myWxid') || '').trim())
|
||||||
if (senderFromRow && !matchesTargetSender(senderFromRow)) {
|
if (senderFromRow && !matchesTargetSender(senderFromRow)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -987,7 +1020,7 @@ class GroupAnalyticsService {
|
|||||||
const row = rows[index]
|
const row = rows[index]
|
||||||
consumedRows += 1
|
consumedRows += 1
|
||||||
|
|
||||||
const senderFromRow = this.extractRowSenderUsername(row)
|
const senderFromRow = this.extractRowSenderUsername(row, String(this.configService.get('myWxid') || '').trim())
|
||||||
if (senderFromRow && !matchesTargetSender(senderFromRow)) {
|
if (senderFromRow && !matchesTargetSender(senderFromRow)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -1467,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(
|
async exportGroupMemberMessages(
|
||||||
chatroomId: string,
|
chatroomId: string,
|
||||||
memberUsername: string,
|
memberUsername: string,
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import * as http from 'http'
|
|||||||
import * as fs from 'fs'
|
import * as fs from 'fs'
|
||||||
import * as path from 'path'
|
import * as path from 'path'
|
||||||
import { URL } from 'url'
|
import { URL } from 'url'
|
||||||
|
import { timingSafeEqual } from 'crypto'
|
||||||
import { chatService, Message } from './chatService'
|
import { chatService, Message } from './chatService'
|
||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
import { ConfigService } from './config'
|
import { ConfigService } from './config'
|
||||||
import { videoService } from './videoService'
|
import { videoService } from './videoService'
|
||||||
import { imageDecryptService } from './imageDecryptService'
|
import { imageDecryptService } from './imageDecryptService'
|
||||||
import { groupAnalyticsService } from './groupAnalyticsService'
|
import { groupAnalyticsService } from './groupAnalyticsService'
|
||||||
|
import { snsService } from './snsService'
|
||||||
|
|
||||||
// ChatLab 格式定义
|
// ChatLab 格式定义
|
||||||
interface ChatLabHeader {
|
interface ChatLabHeader {
|
||||||
@@ -101,6 +103,7 @@ class HttpService {
|
|||||||
private server: http.Server | null = null
|
private server: http.Server | null = null
|
||||||
private configService: ConfigService
|
private configService: ConfigService
|
||||||
private port: number = 5031
|
private port: number = 5031
|
||||||
|
private host: string = '127.0.0.1'
|
||||||
private running: boolean = false
|
private running: boolean = false
|
||||||
private connections: Set<import('net').Socket> = new Set()
|
private connections: Set<import('net').Socket> = new Set()
|
||||||
private messagePushClients: Set<http.ServerResponse> = new Set()
|
private messagePushClients: Set<http.ServerResponse> = new Set()
|
||||||
@@ -114,12 +117,13 @@ class HttpService {
|
|||||||
/**
|
/**
|
||||||
* 启动 HTTP 服务
|
* 启动 HTTP 服务
|
||||||
*/
|
*/
|
||||||
async start(port: number = 5031): Promise<{ success: boolean; port?: number; error?: string }> {
|
async start(port: number = 5031, host: string = '127.0.0.1'): Promise<{ success: boolean; port?: number; error?: string }> {
|
||||||
if (this.running && this.server) {
|
if (this.running && this.server) {
|
||||||
return { success: true, port: this.port }
|
return { success: true, port: this.port }
|
||||||
}
|
}
|
||||||
|
|
||||||
this.port = port
|
this.port = port
|
||||||
|
this.host = host
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
this.server = http.createServer((req, res) => this.handleRequest(req, res))
|
this.server = http.createServer((req, res) => this.handleRequest(req, res))
|
||||||
@@ -153,10 +157,10 @@ class HttpService {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
this.server.listen(this.port, '127.0.0.1', () => {
|
this.server.listen(this.port, this.host, () => {
|
||||||
this.running = true
|
this.running = true
|
||||||
this.startMessagePushHeartbeat()
|
this.startMessagePushHeartbeat()
|
||||||
console.log(`[HttpService] HTTP API server started on http://127.0.0.1:${this.port}`)
|
console.log(`[HttpService] HTTP API server started on http://${this.host}:${this.port}`)
|
||||||
resolve({ success: true, port: this.port })
|
resolve({ success: true, port: this.port })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -225,7 +229,7 @@ class HttpService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getMessagePushStreamUrl(): string {
|
getMessagePushStreamUrl(): string {
|
||||||
return `http://127.0.0.1:${this.port}/api/v1/push/messages`
|
return `http://${this.host}:${this.port}/api/v1/push/messages`
|
||||||
}
|
}
|
||||||
|
|
||||||
broadcastMessagePush(payload: Record<string, unknown>): void {
|
broadcastMessagePush(payload: Record<string, unknown>): void {
|
||||||
@@ -246,14 +250,92 @@ class HttpService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async autoStart(): Promise<void> {
|
||||||
|
const enabled = this.configService.get('httpApiEnabled')
|
||||||
|
if (enabled) {
|
||||||
|
const port = Number(this.configService.get('httpApiPort')) || 5031
|
||||||
|
const host = String(this.configService.get('httpApiHost') || '127.0.0.1').trim() || '127.0.0.1'
|
||||||
|
try {
|
||||||
|
await this.start(port, host)
|
||||||
|
console.log(`[HttpService] Auto-started on port ${port}`)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[HttpService] Auto-start failed:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理 HTTP 请求
|
* 解析 POST 请求的 JSON Body
|
||||||
|
*/
|
||||||
|
private async parseBody(req: http.IncomingMessage): Promise<Record<string, any>> {
|
||||||
|
if (req.method !== 'POST') return {}
|
||||||
|
const MAX_BODY_SIZE = 10 * 1024 * 1024 // 10MB
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let body = ''
|
||||||
|
let bodySize = 0
|
||||||
|
req.on('data', chunk => {
|
||||||
|
bodySize += chunk.length
|
||||||
|
if (bodySize > MAX_BODY_SIZE) {
|
||||||
|
req.destroy()
|
||||||
|
resolve({})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body += chunk.toString()
|
||||||
|
})
|
||||||
|
req.on('end', () => {
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(body))
|
||||||
|
} catch {
|
||||||
|
resolve({})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
req.on('error', () => resolve({}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 鉴权拦截器
|
||||||
|
*/
|
||||||
|
private safeEqual(a: string, b: string): boolean {
|
||||||
|
const bufA = Buffer.from(a)
|
||||||
|
const bufB = Buffer.from(b)
|
||||||
|
if (bufA.length !== bufB.length) return false
|
||||||
|
return timingSafeEqual(bufA, bufB)
|
||||||
|
}
|
||||||
|
|
||||||
|
private verifyToken(req: http.IncomingMessage, url: URL, body: Record<string, any>): boolean {
|
||||||
|
const expectedToken = String(this.configService.get('httpApiToken') || '').trim()
|
||||||
|
if (!expectedToken) {
|
||||||
|
// token 未配置时拒绝所有请求,防止未授权访问
|
||||||
|
console.warn('[HttpService] Access denied: httpApiToken not configured')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const authHeader = req.headers.authorization
|
||||||
|
if (authHeader && authHeader.toLowerCase().startsWith('bearer ')) {
|
||||||
|
const token = authHeader.substring(7).trim()
|
||||||
|
if (this.safeEqual(token, expectedToken)) return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryToken = url.searchParams.get('access_token')
|
||||||
|
if (queryToken && this.safeEqual(queryToken.trim(), expectedToken)) return true
|
||||||
|
|
||||||
|
const bodyToken = body['access_token']
|
||||||
|
return !!(bodyToken && this.safeEqual(String(bodyToken).trim(), expectedToken))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 HTTP 请求 (重构后)
|
||||||
*/
|
*/
|
||||||
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||||
// 设置 CORS 头
|
// 仅允许本地来源的跨域请求
|
||||||
res.setHeader('Access-Control-Allow-Origin', '*')
|
const origin = req.headers.origin || ''
|
||||||
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS')
|
if (origin && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) {
|
||||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
|
res.setHeader('Access-Control-Allow-Origin', origin)
|
||||||
|
res.setHeader('Vary', 'Origin')
|
||||||
|
}
|
||||||
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS')
|
||||||
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
|
||||||
|
|
||||||
if (req.method === 'OPTIONS') {
|
if (req.method === 'OPTIONS') {
|
||||||
res.writeHead(204)
|
res.writeHead(204)
|
||||||
@@ -261,11 +343,25 @@ class HttpService {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL(req.url || '/', `http://127.0.0.1:${this.port}`)
|
const url = new URL(req.url || '/', `http://${this.host}:${this.port}`)
|
||||||
const pathname = url.pathname
|
const pathname = url.pathname
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 路由处理
|
const bodyParams = await this.parseBody(req)
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(bodyParams)) {
|
||||||
|
if (!url.searchParams.has(key)) {
|
||||||
|
url.searchParams.set(key, String(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname !== '/health' && pathname !== '/api/v1/health') {
|
||||||
|
if (!this.verifyToken(req, url, bodyParams)) {
|
||||||
|
this.sendError(res, 401, 'Unauthorized: Invalid or missing access_token')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (pathname === '/health' || pathname === '/api/v1/health') {
|
if (pathname === '/health' || pathname === '/api/v1/health') {
|
||||||
this.sendJson(res, { status: 'ok' })
|
this.sendJson(res, { status: 'ok' })
|
||||||
} else if (pathname === '/api/v1/push/messages') {
|
} else if (pathname === '/api/v1/push/messages') {
|
||||||
@@ -274,10 +370,48 @@ class HttpService {
|
|||||||
await this.handleMessages(url, res)
|
await this.handleMessages(url, res)
|
||||||
} else if (pathname === '/api/v1/sessions') {
|
} else if (pathname === '/api/v1/sessions') {
|
||||||
await this.handleSessions(url, res)
|
await this.handleSessions(url, res)
|
||||||
|
} else if (
|
||||||
|
pathname.startsWith('/api/v1/sessions/') &&
|
||||||
|
pathname.endsWith('/messages')
|
||||||
|
) {
|
||||||
|
const parts = pathname.split('/')
|
||||||
|
const sessionId = decodeURIComponent(parts[4] || '')
|
||||||
|
if (!sessionId) {
|
||||||
|
this.sendError(res, 400, 'Missing session ID')
|
||||||
|
} else {
|
||||||
|
await this.handlePullMessages(sessionId, url, res)
|
||||||
|
}
|
||||||
} else if (pathname === '/api/v1/contacts') {
|
} else if (pathname === '/api/v1/contacts') {
|
||||||
await this.handleContacts(url, res)
|
await this.handleContacts(url, res)
|
||||||
} else if (pathname === '/api/v1/group-members') {
|
} else if (pathname === '/api/v1/group-members') {
|
||||||
await this.handleGroupMembers(url, res)
|
await this.handleGroupMembers(url, res)
|
||||||
|
} else if (pathname === '/api/v1/sns/timeline') {
|
||||||
|
if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET')
|
||||||
|
await this.handleSnsTimeline(url, res)
|
||||||
|
} else if (pathname === '/api/v1/sns/usernames') {
|
||||||
|
if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET')
|
||||||
|
await this.handleSnsUsernames(res)
|
||||||
|
} else if (pathname === '/api/v1/sns/export/stats') {
|
||||||
|
if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET')
|
||||||
|
await this.handleSnsExportStats(url, res)
|
||||||
|
} else if (pathname === '/api/v1/sns/media/proxy') {
|
||||||
|
if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET')
|
||||||
|
await this.handleSnsMediaProxy(url, res)
|
||||||
|
} else if (pathname === '/api/v1/sns/export') {
|
||||||
|
if (req.method !== 'POST') return this.sendMethodNotAllowed(res, 'POST')
|
||||||
|
await this.handleSnsExport(url, res)
|
||||||
|
} else if (pathname === '/api/v1/sns/block-delete/status') {
|
||||||
|
if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET')
|
||||||
|
await this.handleSnsBlockDeleteStatus(res)
|
||||||
|
} else if (pathname === '/api/v1/sns/block-delete/install') {
|
||||||
|
if (req.method !== 'POST') return this.sendMethodNotAllowed(res, 'POST')
|
||||||
|
await this.handleSnsBlockDeleteInstall(res)
|
||||||
|
} else if (pathname === '/api/v1/sns/block-delete/uninstall') {
|
||||||
|
if (req.method !== 'POST') return this.sendMethodNotAllowed(res, 'POST')
|
||||||
|
await this.handleSnsBlockDeleteUninstall(res)
|
||||||
|
} else if (pathname.startsWith('/api/v1/sns/post/')) {
|
||||||
|
if (req.method !== 'DELETE') return this.sendMethodNotAllowed(res, 'DELETE')
|
||||||
|
await this.handleSnsDeletePost(pathname, res)
|
||||||
} else if (pathname.startsWith('/api/v1/media/')) {
|
} else if (pathname.startsWith('/api/v1/media/')) {
|
||||||
this.handleMediaRequest(pathname, res)
|
this.handleMediaRequest(pathname, res)
|
||||||
} else {
|
} else {
|
||||||
@@ -288,7 +422,6 @@ class HttpService {
|
|||||||
this.sendError(res, 500, String(error))
|
this.sendError(res, 500, String(error))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private startMessagePushHeartbeat(): void {
|
private startMessagePushHeartbeat(): void {
|
||||||
if (this.messagePushHeartbeatTimer) return
|
if (this.messagePushHeartbeatTimer) return
|
||||||
this.messagePushHeartbeatTimer = setInterval(() => {
|
this.messagePushHeartbeatTimer = setInterval(() => {
|
||||||
@@ -334,9 +467,15 @@ class HttpService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private handleMediaRequest(pathname: string, res: http.ServerResponse): void {
|
private handleMediaRequest(pathname: string, res: http.ServerResponse): void {
|
||||||
const mediaBasePath = this.getApiMediaExportPath()
|
const mediaBasePath = path.resolve(this.getApiMediaExportPath())
|
||||||
const relativePath = pathname.replace('/api/v1/media/', '')
|
const relativePath = pathname.replace('/api/v1/media/', '')
|
||||||
const fullPath = path.join(mediaBasePath, relativePath)
|
const fullPath = path.resolve(mediaBasePath, relativePath)
|
||||||
|
|
||||||
|
// 防止路径穿越攻击
|
||||||
|
if (!fullPath.startsWith(mediaBasePath + path.sep) && fullPath !== mediaBasePath) {
|
||||||
|
this.sendError(res, 403, 'Forbidden')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (!fs.existsSync(fullPath)) {
|
if (!fs.existsSync(fullPath)) {
|
||||||
this.sendError(res, 404, 'Media not found')
|
this.sendError(res, 404, 'Media not found')
|
||||||
@@ -490,6 +629,15 @@ class HttpService {
|
|||||||
return defaultValue
|
return defaultValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private parseStringListParam(value: string | null): string[] | undefined {
|
||||||
|
if (!value) return undefined
|
||||||
|
const values = value
|
||||||
|
.split(',')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
return values.length > 0 ? Array.from(new Set(values)) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
private parseMediaOptions(url: URL): ApiMediaOptions {
|
private parseMediaOptions(url: URL): ApiMediaOptions {
|
||||||
const mediaEnabled = this.parseBooleanParam(url, ['media', 'meiti'], false)
|
const mediaEnabled = this.parseBooleanParam(url, ['media', 'meiti'], false)
|
||||||
if (!mediaEnabled) {
|
if (!mediaEnabled) {
|
||||||
@@ -599,6 +747,7 @@ class HttpService {
|
|||||||
private async handleSessions(url: URL, res: http.ServerResponse): Promise<void> {
|
private async handleSessions(url: URL, res: http.ServerResponse): Promise<void> {
|
||||||
const keyword = (url.searchParams.get('keyword') || '').trim()
|
const keyword = (url.searchParams.get('keyword') || '').trim()
|
||||||
const limit = this.parseIntParam(url.searchParams.get('limit'), 100, 1, 10000)
|
const limit = this.parseIntParam(url.searchParams.get('limit'), 100, 1, 10000)
|
||||||
|
const format = (url.searchParams.get('format') || '').trim().toLowerCase()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sessions = await chatService.getSessions()
|
const sessions = await chatService.getSessions()
|
||||||
@@ -616,9 +765,22 @@ class HttpService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 应用 limit
|
|
||||||
const limitedSessions = filteredSessions.slice(0, limit)
|
const limitedSessions = filteredSessions.slice(0, limit)
|
||||||
|
|
||||||
|
if (format === 'chatlab') {
|
||||||
|
this.sendJson(res, {
|
||||||
|
sessions: limitedSessions.map(s => ({
|
||||||
|
id: s.username,
|
||||||
|
name: s.displayName || s.username,
|
||||||
|
platform: 'wechat',
|
||||||
|
type: s.username.endsWith('@chatroom') ? 'group' : 'private',
|
||||||
|
messageCount: s.messageCountHint || undefined,
|
||||||
|
lastMessageAt: s.lastTimestamp
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
this.sendJson(res, {
|
this.sendJson(res, {
|
||||||
success: true,
|
success: true,
|
||||||
count: limitedSessions.length,
|
count: limitedSessions.length,
|
||||||
@@ -635,6 +797,53 @@ class HttpService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ChatLab Pull: GET /api/v1/sessions/:id/messages?since=&limit=&offset=&end=
|
||||||
|
* 返回 ChatLab 标准格式 + sync 分页块
|
||||||
|
*/
|
||||||
|
private async handlePullMessages(sessionId: string, url: URL, res: http.ServerResponse): Promise<void> {
|
||||||
|
const PULL_MAX_LIMIT = 5000
|
||||||
|
const limit = this.parseIntParam(url.searchParams.get('limit'), PULL_MAX_LIMIT, 1, PULL_MAX_LIMIT)
|
||||||
|
const offset = this.parseIntParam(url.searchParams.get('offset'), 0, 0, Number.MAX_SAFE_INTEGER)
|
||||||
|
const sinceParam = url.searchParams.get('since')
|
||||||
|
const endParam = url.searchParams.get('end')
|
||||||
|
|
||||||
|
const startTime = sinceParam ? this.parseTimeParam(sinceParam) : 0
|
||||||
|
const endTime = endParam ? this.parseTimeParam(endParam, true) : 0
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.fetchMessagesBatch(sessionId, offset, limit, startTime, endTime, true)
|
||||||
|
if (!result.success || !result.messages) {
|
||||||
|
this.sendError(res, 500, result.error || 'Failed to get messages')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = result.messages
|
||||||
|
const hasMore = result.hasMore === true
|
||||||
|
|
||||||
|
const displayNames = await this.getDisplayNames([sessionId])
|
||||||
|
const talkerName = displayNames[sessionId] || sessionId
|
||||||
|
const chatLabData = await this.convertToChatLab(messages, sessionId, talkerName)
|
||||||
|
|
||||||
|
const lastTimestamp = messages.length > 0
|
||||||
|
? messages[messages.length - 1].createTime
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
this.sendJson(res, {
|
||||||
|
...chatLabData,
|
||||||
|
sync: {
|
||||||
|
hasMore,
|
||||||
|
nextSince: hasMore && lastTimestamp ? lastTimestamp : undefined,
|
||||||
|
nextOffset: hasMore ? offset + messages.length : undefined,
|
||||||
|
watermark: Math.floor(Date.now() / 1000)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[HttpService] handlePullMessages error:', error)
|
||||||
|
this.sendError(res, 500, String(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理联系人查询
|
* 处理联系人查询
|
||||||
* GET /api/v1/contacts?keyword=xxx&limit=100
|
* GET /api/v1/contacts?keyword=xxx&limit=100
|
||||||
@@ -721,6 +930,313 @@ class HttpService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handleSnsTimeline(url: URL, res: http.ServerResponse): Promise<void> {
|
||||||
|
const limit = this.parseIntParam(url.searchParams.get('limit'), 20, 1, 200)
|
||||||
|
const offset = this.parseIntParam(url.searchParams.get('offset'), 0, 0, Number.MAX_SAFE_INTEGER)
|
||||||
|
const usernames = this.parseStringListParam(url.searchParams.get('usernames'))
|
||||||
|
const keyword = (url.searchParams.get('keyword') || '').trim() || undefined
|
||||||
|
const resolveMedia = this.parseBooleanParam(url, ['media', 'resolveMedia', 'meiti'], true)
|
||||||
|
const inlineMedia = resolveMedia && this.parseBooleanParam(url, ['inline'], false)
|
||||||
|
const replaceMedia = resolveMedia && this.parseBooleanParam(url, ['replace'], true)
|
||||||
|
const startTimeRaw = this.parseTimeParam(url.searchParams.get('start'))
|
||||||
|
const endTimeRaw = this.parseTimeParam(url.searchParams.get('end'), true)
|
||||||
|
const startTime = startTimeRaw > 0 ? startTimeRaw : undefined
|
||||||
|
const endTime = endTimeRaw > 0 ? endTimeRaw : undefined
|
||||||
|
|
||||||
|
const result = await snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime)
|
||||||
|
if (!result.success) {
|
||||||
|
this.sendError(res, 500, result.error || 'Failed to get sns timeline')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let timeline = result.timeline || []
|
||||||
|
if (resolveMedia && timeline.length > 0) {
|
||||||
|
timeline = await this.enrichSnsTimelineMedia(timeline, inlineMedia, replaceMedia)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sendJson(res, {
|
||||||
|
success: true,
|
||||||
|
count: timeline.length,
|
||||||
|
timeline
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleSnsUsernames(res: http.ServerResponse): Promise<void> {
|
||||||
|
const result = await snsService.getSnsUsernames()
|
||||||
|
if (!result.success) {
|
||||||
|
this.sendError(res, 500, result.error || 'Failed to get sns usernames')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.sendJson(res, {
|
||||||
|
success: true,
|
||||||
|
usernames: result.usernames || []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleSnsExportStats(url: URL, res: http.ServerResponse): Promise<void> {
|
||||||
|
const fast = this.parseBooleanParam(url, ['fast'], false)
|
||||||
|
const result = fast
|
||||||
|
? await snsService.getExportStatsFast()
|
||||||
|
: await snsService.getExportStats()
|
||||||
|
if (!result.success) {
|
||||||
|
this.sendError(res, 500, result.error || 'Failed to get sns export stats')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.sendJson(res, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleSnsMediaProxy(url: URL, res: http.ServerResponse): Promise<void> {
|
||||||
|
const mediaUrl = (url.searchParams.get('url') || '').trim()
|
||||||
|
if (!mediaUrl) {
|
||||||
|
this.sendError(res, 400, 'Missing required parameter: url')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = this.toSnsMediaKey(url.searchParams.get('key'))
|
||||||
|
const result = await snsService.downloadImage(mediaUrl, key)
|
||||||
|
if (!result.success) {
|
||||||
|
this.sendError(res, 502, result.error || 'Failed to proxy sns media')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.data) {
|
||||||
|
res.setHeader('Content-Type', result.contentType || 'application/octet-stream')
|
||||||
|
res.setHeader('Content-Length', result.data.length)
|
||||||
|
res.writeHead(200)
|
||||||
|
res.end(result.data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.cachePath && fs.existsSync(result.cachePath)) {
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(result.cachePath)
|
||||||
|
res.setHeader('Content-Type', result.contentType || 'application/octet-stream')
|
||||||
|
res.setHeader('Content-Length', stat.size)
|
||||||
|
res.writeHead(200)
|
||||||
|
|
||||||
|
const stream = fs.createReadStream(result.cachePath)
|
||||||
|
stream.on('error', () => {
|
||||||
|
if (!res.headersSent) {
|
||||||
|
this.sendError(res, 500, 'Failed to read proxied sns media')
|
||||||
|
} else {
|
||||||
|
try { res.destroy() } catch {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
stream.pipe(res)
|
||||||
|
return
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[HttpService] Failed to stream sns media cache:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sendError(res, 502, result.error || 'Failed to proxy sns media')
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleSnsExport(url: URL, res: http.ServerResponse): Promise<void> {
|
||||||
|
const outputDir = String(url.searchParams.get('outputDir') || '').trim()
|
||||||
|
if (!outputDir) {
|
||||||
|
this.sendError(res, 400, 'Missing required field: outputDir')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawFormat = String(url.searchParams.get('format') || 'json').trim().toLowerCase()
|
||||||
|
const format = rawFormat === 'arkme-json' ? 'arkmejson' : rawFormat
|
||||||
|
if (!['json', 'html', 'arkmejson'].includes(format)) {
|
||||||
|
this.sendError(res, 400, 'Invalid format, supported: json/html/arkmejson')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const usernames = this.parseStringListParam(url.searchParams.get('usernames'))
|
||||||
|
const keyword = String(url.searchParams.get('keyword') || '').trim() || undefined
|
||||||
|
const startTimeRaw = this.parseTimeParam(url.searchParams.get('start'))
|
||||||
|
const endTimeRaw = this.parseTimeParam(url.searchParams.get('end'), true)
|
||||||
|
|
||||||
|
const options: {
|
||||||
|
outputDir: string
|
||||||
|
format: 'json' | 'html' | 'arkmejson'
|
||||||
|
usernames?: string[]
|
||||||
|
keyword?: string
|
||||||
|
exportMedia?: boolean
|
||||||
|
exportImages?: boolean
|
||||||
|
exportLivePhotos?: boolean
|
||||||
|
exportVideos?: boolean
|
||||||
|
startTime?: number
|
||||||
|
endTime?: number
|
||||||
|
} = {
|
||||||
|
outputDir,
|
||||||
|
format: format as 'json' | 'html' | 'arkmejson',
|
||||||
|
usernames,
|
||||||
|
keyword,
|
||||||
|
exportMedia: this.parseBooleanParam(url, ['exportMedia'], false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.searchParams.has('exportImages')) options.exportImages = this.parseBooleanParam(url, ['exportImages'], false)
|
||||||
|
if (url.searchParams.has('exportLivePhotos')) options.exportLivePhotos = this.parseBooleanParam(url, ['exportLivePhotos'], false)
|
||||||
|
if (url.searchParams.has('exportVideos')) options.exportVideos = this.parseBooleanParam(url, ['exportVideos'], false)
|
||||||
|
if (startTimeRaw > 0) options.startTime = startTimeRaw
|
||||||
|
if (endTimeRaw > 0) options.endTime = endTimeRaw
|
||||||
|
|
||||||
|
const result = await snsService.exportTimeline(options)
|
||||||
|
if (!result.success) {
|
||||||
|
this.sendError(res, 500, result.error || 'Failed to export sns timeline')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.sendJson(res, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleSnsBlockDeleteStatus(res: http.ServerResponse): Promise<void> {
|
||||||
|
const result = await snsService.checkSnsBlockDeleteTrigger()
|
||||||
|
if (!result.success) {
|
||||||
|
this.sendError(res, 500, result.error || 'Failed to check sns block-delete status')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.sendJson(res, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleSnsBlockDeleteInstall(res: http.ServerResponse): Promise<void> {
|
||||||
|
const result = await snsService.installSnsBlockDeleteTrigger()
|
||||||
|
if (!result.success) {
|
||||||
|
this.sendError(res, 500, result.error || 'Failed to install sns block-delete trigger')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.sendJson(res, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleSnsBlockDeleteUninstall(res: http.ServerResponse): Promise<void> {
|
||||||
|
const result = await snsService.uninstallSnsBlockDeleteTrigger()
|
||||||
|
if (!result.success) {
|
||||||
|
this.sendError(res, 500, result.error || 'Failed to uninstall sns block-delete trigger')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.sendJson(res, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleSnsDeletePost(pathname: string, res: http.ServerResponse): Promise<void> {
|
||||||
|
const postId = decodeURIComponent(pathname.replace('/api/v1/sns/post/', '')).trim()
|
||||||
|
if (!postId) {
|
||||||
|
this.sendError(res, 400, 'Missing required path parameter: postId')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await snsService.deleteSnsPost(postId)
|
||||||
|
if (!result.success) {
|
||||||
|
this.sendError(res, 500, result.error || 'Failed to delete sns post')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.sendJson(res, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
private toSnsMediaKey(value: unknown): string | number | undefined {
|
||||||
|
if (value == null) return undefined
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) return value
|
||||||
|
const text = String(value).trim()
|
||||||
|
if (!text) return undefined
|
||||||
|
if (/^-?\d+$/.test(text)) return Number(text)
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSnsMediaProxyUrl(rawUrl: string, key?: string | number): string | undefined {
|
||||||
|
const target = String(rawUrl || '').trim()
|
||||||
|
if (!target) return undefined
|
||||||
|
const params = new URLSearchParams({ url: target })
|
||||||
|
if (key !== undefined) params.set('key', String(key))
|
||||||
|
return `http://${this.host}:${this.port}/api/v1/sns/media/proxy?${params.toString()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveSnsMediaUrl(
|
||||||
|
rawUrl: string,
|
||||||
|
key: string | number | undefined,
|
||||||
|
inline: boolean
|
||||||
|
): Promise<{ resolvedUrl?: string; proxyUrl?: string }> {
|
||||||
|
const proxyUrl = this.buildSnsMediaProxyUrl(rawUrl, key)
|
||||||
|
if (!proxyUrl) return {}
|
||||||
|
if (!inline) return { resolvedUrl: proxyUrl, proxyUrl }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resolved = await snsService.proxyImage(rawUrl, key)
|
||||||
|
if (resolved.success && resolved.dataUrl) {
|
||||||
|
return { resolvedUrl: resolved.dataUrl, proxyUrl }
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[HttpService] resolveSnsMediaUrl inline failed:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { resolvedUrl: proxyUrl, proxyUrl }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async enrichSnsTimelineMedia(posts: any[], inline: boolean, replace: boolean): Promise<any[]> {
|
||||||
|
return Promise.all(
|
||||||
|
(posts || []).map(async (post) => {
|
||||||
|
const mediaList = Array.isArray(post?.media) ? post.media : []
|
||||||
|
if (mediaList.length === 0) return post
|
||||||
|
|
||||||
|
const nextMedia = await Promise.all(
|
||||||
|
mediaList.map(async (media: any) => {
|
||||||
|
const rawUrl = typeof media?.url === 'string' ? media.url : ''
|
||||||
|
const rawThumb = typeof media?.thumb === 'string' ? media.thumb : ''
|
||||||
|
const mediaKey = this.toSnsMediaKey(media?.key)
|
||||||
|
|
||||||
|
const [urlResolved, thumbResolved] = await Promise.all([
|
||||||
|
this.resolveSnsMediaUrl(rawUrl, mediaKey, inline),
|
||||||
|
this.resolveSnsMediaUrl(rawThumb, mediaKey, inline)
|
||||||
|
])
|
||||||
|
|
||||||
|
const nextItem: any = {
|
||||||
|
...media,
|
||||||
|
rawUrl,
|
||||||
|
rawThumb,
|
||||||
|
resolvedUrl: urlResolved.resolvedUrl,
|
||||||
|
resolvedThumbUrl: thumbResolved.resolvedUrl,
|
||||||
|
proxyUrl: urlResolved.proxyUrl,
|
||||||
|
proxyThumbUrl: thumbResolved.proxyUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
if (replace) {
|
||||||
|
nextItem.url = urlResolved.resolvedUrl || rawUrl
|
||||||
|
nextItem.thumb = thumbResolved.resolvedUrl || rawThumb
|
||||||
|
}
|
||||||
|
|
||||||
|
if (media?.livePhoto && typeof media.livePhoto === 'object') {
|
||||||
|
const livePhoto = media.livePhoto
|
||||||
|
const rawLiveUrl = typeof livePhoto.url === 'string' ? livePhoto.url : ''
|
||||||
|
const rawLiveThumb = typeof livePhoto.thumb === 'string' ? livePhoto.thumb : ''
|
||||||
|
const liveKey = this.toSnsMediaKey(livePhoto.key ?? mediaKey)
|
||||||
|
|
||||||
|
const [liveUrlResolved, liveThumbResolved] = await Promise.all([
|
||||||
|
this.resolveSnsMediaUrl(rawLiveUrl, liveKey, inline),
|
||||||
|
this.resolveSnsMediaUrl(rawLiveThumb, liveKey, inline)
|
||||||
|
])
|
||||||
|
|
||||||
|
const nextLive: any = {
|
||||||
|
...livePhoto,
|
||||||
|
rawUrl: rawLiveUrl,
|
||||||
|
rawThumb: rawLiveThumb,
|
||||||
|
resolvedUrl: liveUrlResolved.resolvedUrl,
|
||||||
|
resolvedThumbUrl: liveThumbResolved.resolvedUrl,
|
||||||
|
proxyUrl: liveUrlResolved.proxyUrl,
|
||||||
|
proxyThumbUrl: liveThumbResolved.proxyUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
if (replace) {
|
||||||
|
nextLive.url = liveUrlResolved.resolvedUrl || rawLiveUrl
|
||||||
|
nextLive.thumb = liveThumbResolved.resolvedUrl || rawLiveThumb
|
||||||
|
}
|
||||||
|
|
||||||
|
nextItem.livePhoto = nextLive
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextItem
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...post,
|
||||||
|
media: nextMedia
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private getApiMediaExportPath(): string {
|
private getApiMediaExportPath(): string {
|
||||||
return path.join(this.configService.getCacheBasePath(), 'api-media')
|
return path.join(this.configService.getCacheBasePath(), 'api-media')
|
||||||
}
|
}
|
||||||
@@ -764,6 +1280,30 @@ class HttpService {
|
|||||||
const sessionDir = path.join(this.getApiMediaExportPath(), this.sanitizeFileName(talker, 'session'))
|
const sessionDir = path.join(this.getApiMediaExportPath(), this.sanitizeFileName(talker, 'session'))
|
||||||
this.ensureDir(sessionDir)
|
this.ensureDir(sessionDir)
|
||||||
|
|
||||||
|
// 预热图片 hardlink 索引,减少逐条导出时的查找开销
|
||||||
|
if (options.exportImages) {
|
||||||
|
const imageMd5Set = new Set<string>()
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (msg.localType !== 3) continue
|
||||||
|
const imageMd5 = String(msg.imageMd5 || '').trim().toLowerCase()
|
||||||
|
if (imageMd5) {
|
||||||
|
imageMd5Set.add(imageMd5)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const imageDatName = String(msg.imageDatName || '').trim().toLowerCase()
|
||||||
|
if (/^[a-f0-9]{32}$/i.test(imageDatName)) {
|
||||||
|
imageMd5Set.add(imageDatName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (imageMd5Set.size > 0) {
|
||||||
|
try {
|
||||||
|
await imageDecryptService.preloadImageHardlinkMd5s(Array.from(imageMd5Set))
|
||||||
|
} catch {
|
||||||
|
// ignore preload failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
const exported = await this.exportMediaForMessage(msg, talker, sessionDir, options)
|
const exported = await this.exportMediaForMessage(msg, talker, sessionDir, options)
|
||||||
if (exported) {
|
if (exported) {
|
||||||
@@ -786,13 +1326,39 @@ class HttpService {
|
|||||||
sessionId: talker,
|
sessionId: talker,
|
||||||
imageMd5: msg.imageMd5,
|
imageMd5: msg.imageMd5,
|
||||||
imageDatName: msg.imageDatName,
|
imageDatName: msg.imageDatName,
|
||||||
force: true
|
createTime: msg.createTime,
|
||||||
|
force: true,
|
||||||
|
preferFilePath: true,
|
||||||
|
hardlinkOnly: true,
|
||||||
|
disableUpdateCheck: true,
|
||||||
|
suppressEvents: true
|
||||||
})
|
})
|
||||||
if (result.success && result.localPath) {
|
|
||||||
let imagePath = result.localPath
|
let imagePath = result.success ? result.localPath : undefined
|
||||||
|
if (!imagePath) {
|
||||||
|
try {
|
||||||
|
const cached = await imageDecryptService.resolveCachedImage({
|
||||||
|
sessionId: talker,
|
||||||
|
imageMd5: msg.imageMd5,
|
||||||
|
imageDatName: msg.imageDatName,
|
||||||
|
createTime: msg.createTime,
|
||||||
|
preferFilePath: true,
|
||||||
|
hardlinkOnly: true,
|
||||||
|
disableUpdateCheck: true,
|
||||||
|
suppressEvents: true
|
||||||
|
})
|
||||||
|
if (cached.success && cached.localPath) {
|
||||||
|
imagePath = cached.localPath
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore resolve failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imagePath) {
|
||||||
if (imagePath.startsWith('data:')) {
|
if (imagePath.startsWith('data:')) {
|
||||||
const base64Match = imagePath.match(/^data:[^;]+;base64,(.+)$/)
|
const base64Match = imagePath.match(/^data:[^;]+;base64,(.+)$/)
|
||||||
if (base64Match) {
|
if (!base64Match) return null
|
||||||
const imageBuffer = Buffer.from(base64Match[1], 'base64')
|
const imageBuffer = Buffer.from(base64Match[1], 'base64')
|
||||||
const ext = this.detectImageExt(imageBuffer)
|
const ext = this.detectImageExt(imageBuffer)
|
||||||
const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`)
|
const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`)
|
||||||
@@ -806,7 +1372,8 @@ class HttpService {
|
|||||||
const relativePath = `${this.sanitizeFileName(talker, 'session')}/images/${fileName}`
|
const relativePath = `${this.sanitizeFileName(talker, 'session')}/images/${fileName}`
|
||||||
return { kind: 'image', fileName, fullPath, relativePath }
|
return { kind: 'image', fileName, fullPath, relativePath }
|
||||||
}
|
}
|
||||||
} else if (fs.existsSync(imagePath)) {
|
|
||||||
|
if (fs.existsSync(imagePath)) {
|
||||||
const imageBuffer = fs.readFileSync(imagePath)
|
const imageBuffer = fs.readFileSync(imagePath)
|
||||||
const ext = this.detectImageExt(imageBuffer)
|
const ext = this.detectImageExt(imageBuffer)
|
||||||
const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`)
|
const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`)
|
||||||
@@ -895,7 +1462,7 @@ class HttpService {
|
|||||||
parsedContent: msg.parsedContent,
|
parsedContent: msg.parsedContent,
|
||||||
mediaType: media?.kind,
|
mediaType: media?.kind,
|
||||||
mediaFileName: media?.fileName,
|
mediaFileName: media?.fileName,
|
||||||
mediaUrl: media ? `http://127.0.0.1:${this.port}/api/v1/media/${media.relativePath}` : undefined,
|
mediaUrl: media ? `http://${this.host}:${this.port}/api/v1/media/${media.relativePath}` : undefined,
|
||||||
mediaLocalPath: media?.fullPath
|
mediaLocalPath: media?.fullPath
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1165,7 +1732,7 @@ class HttpService {
|
|||||||
type: this.mapMessageType(msg.localType, msg),
|
type: this.mapMessageType(msg.localType, msg),
|
||||||
content: this.getMessageContent(msg),
|
content: this.getMessageContent(msg),
|
||||||
platformMessageId: msg.serverId ? String(msg.serverId) : undefined,
|
platformMessageId: msg.serverId ? String(msg.serverId) : undefined,
|
||||||
mediaPath: mediaMap.get(msg.localId) ? `http://127.0.0.1:${this.port}/api/v1/media/${mediaMap.get(msg.localId)!.relativePath}` : undefined
|
mediaPath: mediaMap.get(msg.localId) ? `http://${this.host}:${this.port}/api/v1/media/${mediaMap.get(msg.localId)!.relativePath}` : undefined
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1382,6 +1949,11 @@ class HttpService {
|
|||||||
res.end(JSON.stringify(data, null, 2))
|
res.end(JSON.stringify(data, null, 2))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sendMethodNotAllowed(res: http.ServerResponse, allow: string): void {
|
||||||
|
res.setHeader('Allow', allow)
|
||||||
|
this.sendError(res, 405, `Method Not Allowed. Allowed: ${allow}`)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送错误响应
|
* 发送错误响应
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4,38 +4,63 @@ type PreloadImagePayload = {
|
|||||||
sessionId?: string
|
sessionId?: string
|
||||||
imageMd5?: string
|
imageMd5?: string
|
||||||
imageDatName?: string
|
imageDatName?: string
|
||||||
|
createTime?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type PreloadOptions = {
|
||||||
|
allowDecrypt?: boolean
|
||||||
|
allowCacheIndex?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type PreloadTask = PreloadImagePayload & {
|
type PreloadTask = PreloadImagePayload & {
|
||||||
key: string
|
key: string
|
||||||
|
allowDecrypt: boolean
|
||||||
|
allowCacheIndex: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ImagePreloadService {
|
export class ImagePreloadService {
|
||||||
private queue: PreloadTask[] = []
|
private queue: PreloadTask[] = []
|
||||||
private pending = new Set<string>()
|
private pending = new Set<string>()
|
||||||
private active = 0
|
private activeCache = 0
|
||||||
private readonly maxConcurrent = 2
|
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
|
if (!Array.isArray(payloads) || payloads.length === 0) return
|
||||||
|
const allowDecrypt = options?.allowDecrypt !== false
|
||||||
|
const allowCacheIndex = options?.allowCacheIndex !== false
|
||||||
for (const payload of payloads) {
|
for (const payload of payloads) {
|
||||||
|
if (!allowDecrypt && this.queue.length >= this.maxQueueSize) break
|
||||||
const cacheKey = payload.imageMd5 || payload.imageDatName
|
const cacheKey = payload.imageMd5 || payload.imageDatName
|
||||||
if (!cacheKey) continue
|
if (!cacheKey) continue
|
||||||
const key = `${payload.sessionId || 'unknown'}|${cacheKey}`
|
const key = `${payload.sessionId || 'unknown'}|${cacheKey}`
|
||||||
if (this.pending.has(key)) continue
|
if (this.pending.has(key)) continue
|
||||||
this.pending.add(key)
|
this.pending.add(key)
|
||||||
this.queue.push({ ...payload, key })
|
this.queue.push({ ...payload, key, allowDecrypt, allowCacheIndex })
|
||||||
}
|
}
|
||||||
this.processQueue()
|
this.processQueue()
|
||||||
}
|
}
|
||||||
|
|
||||||
private processQueue(): void {
|
private processQueue(): void {
|
||||||
while (this.active < this.maxConcurrent && this.queue.length > 0) {
|
while (this.queue.length > 0) {
|
||||||
const task = this.queue.shift()
|
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
|
if (!task) return
|
||||||
this.active += 1
|
|
||||||
|
if (task.allowDecrypt) this.activeDecrypt += 1
|
||||||
|
else this.activeCache += 1
|
||||||
|
|
||||||
void this.handleTask(task).finally(() => {
|
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.pending.delete(task.key)
|
||||||
this.processQueue()
|
this.processQueue()
|
||||||
})
|
})
|
||||||
@@ -49,13 +74,25 @@ export class ImagePreloadService {
|
|||||||
const cached = await imageDecryptService.resolveCachedImage({
|
const cached = await imageDecryptService.resolveCachedImage({
|
||||||
sessionId: task.sessionId,
|
sessionId: task.sessionId,
|
||||||
imageMd5: task.imageMd5,
|
imageMd5: task.imageMd5,
|
||||||
imageDatName: task.imageDatName
|
imageDatName: task.imageDatName,
|
||||||
|
createTime: task.createTime,
|
||||||
|
preferFilePath: true,
|
||||||
|
hardlinkOnly: true,
|
||||||
|
disableUpdateCheck: !task.allowDecrypt,
|
||||||
|
allowCacheIndex: task.allowCacheIndex,
|
||||||
|
suppressEvents: true
|
||||||
})
|
})
|
||||||
if (cached.success) return
|
if (cached.success) return
|
||||||
|
if (!task.allowDecrypt) return
|
||||||
await imageDecryptService.decryptImage({
|
await imageDecryptService.decryptImage({
|
||||||
sessionId: task.sessionId,
|
sessionId: task.sessionId,
|
||||||
imageMd5: task.imageMd5,
|
imageMd5: task.imageMd5,
|
||||||
imageDatName: task.imageDatName
|
imageDatName: task.imageDatName,
|
||||||
|
createTime: task.createTime,
|
||||||
|
preferFilePath: true,
|
||||||
|
hardlinkOnly: true,
|
||||||
|
disableUpdateCheck: true,
|
||||||
|
suppressEvents: true
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
// ignore preload failures
|
// ignore preload failures
|
||||||
|
|||||||
1309
electron/services/insightService.ts
Normal file
@@ -9,7 +9,7 @@ import crypto from 'crypto'
|
|||||||
const execFileAsync = promisify(execFile)
|
const execFileAsync = promisify(execFile)
|
||||||
|
|
||||||
type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] }
|
type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] }
|
||||||
type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; error?: string }
|
type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; verified?: boolean; error?: string }
|
||||||
|
|
||||||
export class KeyService {
|
export class KeyService {
|
||||||
private readonly isMac = process.platform === 'darwin'
|
private readonly isMac = process.platform === 'darwin'
|
||||||
@@ -61,6 +61,7 @@ export class KeyService {
|
|||||||
|
|
||||||
private getDllPath(): string {
|
private getDllPath(): string {
|
||||||
const isPackaged = typeof app !== 'undefined' && app ? app.isPackaged : process.env.NODE_ENV === 'production'
|
const isPackaged = typeof app !== 'undefined' && app ? app.isPackaged : process.env.NODE_ENV === 'production'
|
||||||
|
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||||
const candidates: string[] = []
|
const candidates: string[] = []
|
||||||
|
|
||||||
if (process.env.WX_KEY_DLL_PATH) {
|
if (process.env.WX_KEY_DLL_PATH) {
|
||||||
@@ -68,11 +69,20 @@ export class KeyService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isPackaged) {
|
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, 'resources', 'wx_key.dll'))
|
||||||
candidates.push(join(process.resourcesPath, 'wx_key.dll'))
|
candidates.push(join(process.resourcesPath, 'wx_key.dll'))
|
||||||
} else {
|
} else {
|
||||||
const cwd = process.cwd()
|
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(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'))
|
candidates.push(join(app.getAppPath(), 'resources', 'wx_key.dll'))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -684,10 +694,7 @@ export class KeyService {
|
|||||||
return { success: false, error: '获取密钥超时', logs }
|
return { success: false, error: '获取密钥超时', logs }
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Image Key (通过 DLL 从缓存目录获取 code,用前端 wxid 计算密钥) ---
|
|
||||||
|
|
||||||
private cleanWxid(wxid: string): string {
|
private cleanWxid(wxid: string): string {
|
||||||
// 截断到第二个下划线: wxid_g4pshorcc0r529_da6c → wxid_g4pshorcc0r529
|
|
||||||
const first = wxid.indexOf('_')
|
const first = wxid.indexOf('_')
|
||||||
if (first === -1) return wxid
|
if (first === -1) return wxid
|
||||||
const second = wxid.indexOf('_', first + 1)
|
const second = wxid.indexOf('_', first + 1)
|
||||||
@@ -807,7 +814,7 @@ export class KeyService {
|
|||||||
if (!this.verifyDerivedAesKey(aesKey, verifyCiphertext)) continue
|
if (!this.verifyDerivedAesKey(aesKey, verifyCiphertext)) continue
|
||||||
onProgress?.(`密钥获取成功 (wxid: ${candidateWxid}, code: ${code})`)
|
onProgress?.(`密钥获取成功 (wxid: ${candidateWxid}, code: ${code})`)
|
||||||
console.log('[ImageKey] 校验命中: wxid=', candidateWxid, 'code=', code)
|
console.log('[ImageKey] 校验命中: wxid=', candidateWxid, 'code=', code)
|
||||||
return { success: true, xorKey, aesKey }
|
return { success: true, xorKey, aesKey, verified: true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { success: false, error: '缓存 code 与当前账号 wxid 未匹配,请确认账号目录后重试,或使用内存扫描' }
|
return { success: false, error: '缓存 code 与当前账号 wxid 未匹配,请确认账号目录后重试,或使用内存扫描' }
|
||||||
@@ -819,7 +826,7 @@ export class KeyService {
|
|||||||
const { xorKey, aesKey } = this.deriveImageKeys(fallbackCode, fallbackWxid)
|
const { xorKey, aesKey } = this.deriveImageKeys(fallbackCode, fallbackWxid)
|
||||||
onProgress?.(`密钥获取成功 (wxid: ${fallbackWxid}, code: ${fallbackCode})`)
|
onProgress?.(`密钥获取成功 (wxid: ${fallbackWxid}, code: ${fallbackCode})`)
|
||||||
console.log('[ImageKey] 回退计算: wxid=', fallbackWxid, 'code=', fallbackCode)
|
console.log('[ImageKey] 回退计算: wxid=', fallbackWxid, 'code=', fallbackCode)
|
||||||
return { success: true, xorKey, aesKey }
|
return { success: true, xorKey, aesKey, verified: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 内存扫描备选方案(融合 Dart+Python 优点)---
|
// --- 内存扫描备选方案(融合 Dart+Python 优点)---
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { join } from 'path'
|
|||||||
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
|
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
|
||||||
import { execFile, exec, spawn } from 'child_process'
|
import { execFile, exec, spawn } from 'child_process'
|
||||||
import { promisify } from 'util'
|
import { promisify } from 'util'
|
||||||
|
import crypto from 'crypto'
|
||||||
import { createRequire } from 'module';
|
import { createRequire } from 'module';
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
@@ -10,28 +11,38 @@ const execFileAsync = promisify(execFile)
|
|||||||
const execAsync = promisify(exec)
|
const execAsync = promisify(exec)
|
||||||
|
|
||||||
type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] }
|
type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] }
|
||||||
type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; error?: string }
|
type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; verified?: boolean; error?: string }
|
||||||
|
|
||||||
export class KeyServiceLinux {
|
export class KeyServiceLinux {
|
||||||
private sudo: any
|
private sudo: any
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
try {
|
try {
|
||||||
this.sudo = require('sudo-prompt');
|
this.sudo = require('@vscode/sudo-prompt');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load sudo-prompt', e);
|
console.error('Failed to load @vscode/sudo-prompt', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private getHelperPath(): string {
|
private getHelperPath(): string {
|
||||||
const isPackaged = app.isPackaged
|
const isPackaged = app.isPackaged
|
||||||
|
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||||
const candidates: string[] = []
|
const candidates: string[] = []
|
||||||
if (process.env.WX_KEY_HELPER_PATH) candidates.push(process.env.WX_KEY_HELPER_PATH)
|
if (process.env.WX_KEY_HELPER_PATH) candidates.push(process.env.WX_KEY_HELPER_PATH)
|
||||||
if (isPackaged) {
|
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, 'resources', 'xkey_helper_linux'))
|
||||||
candidates.push(join(process.resourcesPath, 'xkey_helper_linux'))
|
candidates.push(join(process.resourcesPath, 'xkey_helper_linux'))
|
||||||
} else {
|
} 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(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'))
|
candidates.push(join(app.getAppPath(), '..', 'Xkey', 'build', 'xkey_helper_linux'))
|
||||||
}
|
}
|
||||||
for (const p of candidates) {
|
for (const p of candidates) {
|
||||||
@@ -88,7 +99,12 @@ export class KeyServiceLinux {
|
|||||||
'xwechat',
|
'xwechat',
|
||||||
'/opt/wechat/wechat',
|
'/opt/wechat/wechat',
|
||||||
'/usr/bin/wechat',
|
'/usr/bin/wechat',
|
||||||
'/opt/apps/com.tencent.wechat/files/wechat'
|
'/usr/local/bin/wechat',
|
||||||
|
'/usr/bin/wechat',
|
||||||
|
'/opt/apps/com.tencent.wechat/files/wechat',
|
||||||
|
'/usr/bin/wechat-bin',
|
||||||
|
'/usr/local/bin/wechat-bin',
|
||||||
|
'com.tencent.wechat'
|
||||||
]
|
]
|
||||||
|
|
||||||
for (const binName of wechatBins) {
|
for (const binName of wechatBins) {
|
||||||
@@ -142,7 +158,7 @@ export class KeyServiceLinux {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!pid) {
|
if (!pid) {
|
||||||
const err = '未能自动启动微信,或获取PID失败,请查看控制台日志或手动启动并登录。'
|
const err = '未能自动启动微信,或获取PID失败,请查看控制台日志或手动启动微信,看到登录窗口后点击确认。'
|
||||||
onStatus?.(err, 2)
|
onStatus?.(err, 2)
|
||||||
return { success: false, error: err }
|
return { success: false, error: err }
|
||||||
}
|
}
|
||||||
@@ -228,7 +244,14 @@ export class KeyServiceLinux {
|
|||||||
if (account && account.keys && account.keys.length > 0) {
|
if (account && account.keys && account.keys.length > 0) {
|
||||||
onProgress?.(`已找到匹配的图片密钥 (wxid: ${account.wxid})`);
|
onProgress?.(`已找到匹配的图片密钥 (wxid: ${account.wxid})`);
|
||||||
const keyObj = account.keys[0]
|
const keyObj = account.keys[0]
|
||||||
return { success: true, xorKey: keyObj.xorKey, aesKey: keyObj.aesKey }
|
const aesKey = String(keyObj.aesKey || '')
|
||||||
|
const verified = await this.verifyImageKeyByTemplate(accountPath, aesKey)
|
||||||
|
if (verified === true) {
|
||||||
|
onProgress?.('缓存密钥校验成功,已确认可用')
|
||||||
|
} else if (verified === false) {
|
||||||
|
onProgress?.('已从缓存计算密钥,但未通过本地模板校验')
|
||||||
|
}
|
||||||
|
return { success: true, xorKey: keyObj.xorKey, aesKey, verified: verified === true }
|
||||||
}
|
}
|
||||||
return { success: false, error: '未在缓存中找到匹配的图片密钥' }
|
return { success: false, error: '未在缓存中找到匹配的图片密钥' }
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -236,6 +259,35 @@ export class KeyServiceLinux {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async verifyImageKeyByTemplate(accountPath: string | undefined, aesKey: string): Promise<boolean | null> {
|
||||||
|
const normalizedPath = String(accountPath || '').trim()
|
||||||
|
if (!normalizedPath || !aesKey || aesKey.length < 16 || !existsSync(normalizedPath)) return null
|
||||||
|
try {
|
||||||
|
const template = await this._findTemplateData(normalizedPath, 32)
|
||||||
|
if (!template.ciphertext) return null
|
||||||
|
return this.verifyDerivedAesKey(aesKey, template.ciphertext)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private verifyDerivedAesKey(aesKey: string, ciphertext: Buffer): boolean {
|
||||||
|
try {
|
||||||
|
if (!aesKey || aesKey.length < 16 || ciphertext.length !== 16) return false
|
||||||
|
const decipher = crypto.createDecipheriv('aes-128-ecb', Buffer.from(aesKey, 'ascii').subarray(0, 16), null)
|
||||||
|
decipher.setAutoPadding(false)
|
||||||
|
const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()])
|
||||||
|
if (dec[0] === 0xFF && dec[1] === 0xD8 && dec[2] === 0xFF) return true
|
||||||
|
if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4E && dec[3] === 0x47) return true
|
||||||
|
if (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) return true
|
||||||
|
if (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true
|
||||||
|
if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true
|
||||||
|
return false
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async autoGetImageKeyByMemoryScan(
|
public async autoGetImageKeyByMemoryScan(
|
||||||
accountPath: string,
|
accountPath: string,
|
||||||
onProgress?: (msg: string) => void
|
onProgress?: (msg: string) => void
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { app, shell } from 'electron'
|
import { app, shell } from 'electron'
|
||||||
import { join, basename, dirname } from 'path'
|
import { join, basename, dirname } from 'path'
|
||||||
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
|
import { existsSync, readdirSync, readFileSync, statSync, chmodSync } from 'fs'
|
||||||
import { execFile, spawn } from 'child_process'
|
import { execFile, spawn } from 'child_process'
|
||||||
import { promisify } from 'util'
|
import { promisify } from 'util'
|
||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
import { homedir } from 'os'
|
import { homedir } from 'os'
|
||||||
|
|
||||||
type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] }
|
type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] }
|
||||||
type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; error?: string }
|
type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; verified?: boolean; error?: string }
|
||||||
const execFileAsync = promisify(execFile)
|
const execFileAsync = promisify(execFile)
|
||||||
|
|
||||||
export class KeyServiceMac {
|
export class KeyServiceMac {
|
||||||
@@ -27,6 +27,7 @@ export class KeyServiceMac {
|
|||||||
|
|
||||||
private getHelperPath(): string {
|
private getHelperPath(): string {
|
||||||
const isPackaged = app.isPackaged
|
const isPackaged = app.isPackaged
|
||||||
|
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||||
const candidates: string[] = []
|
const candidates: string[] = []
|
||||||
|
|
||||||
if (process.env.WX_KEY_HELPER_PATH) {
|
if (process.env.WX_KEY_HELPER_PATH) {
|
||||||
@@ -34,12 +35,21 @@ export class KeyServiceMac {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isPackaged) {
|
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, 'resources', 'xkey_helper'))
|
||||||
candidates.push(join(process.resourcesPath, 'xkey_helper'))
|
candidates.push(join(process.resourcesPath, 'xkey_helper'))
|
||||||
} else {
|
} else {
|
||||||
const cwd = process.cwd()
|
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, 'resources', 'xkey_helper'))
|
||||||
candidates.push(join(cwd, 'Xkey', 'build', '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'))
|
candidates.push(join(app.getAppPath(), 'resources', 'xkey_helper'))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,14 +62,24 @@ export class KeyServiceMac {
|
|||||||
|
|
||||||
private getImageScanHelperPath(): string {
|
private getImageScanHelperPath(): string {
|
||||||
const isPackaged = app.isPackaged
|
const isPackaged = app.isPackaged
|
||||||
|
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||||
const candidates: string[] = []
|
const candidates: string[] = []
|
||||||
|
|
||||||
if (isPackaged) {
|
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, 'resources', 'image_scan_helper'))
|
||||||
candidates.push(join(process.resourcesPath, 'image_scan_helper'))
|
candidates.push(join(process.resourcesPath, 'image_scan_helper'))
|
||||||
} else {
|
} else {
|
||||||
const cwd = process.cwd()
|
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(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'))
|
candidates.push(join(app.getAppPath(), 'resources', 'image_scan_helper'))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +92,7 @@ export class KeyServiceMac {
|
|||||||
|
|
||||||
private getDylibPath(): string {
|
private getDylibPath(): string {
|
||||||
const isPackaged = app.isPackaged
|
const isPackaged = app.isPackaged
|
||||||
|
const archDir = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||||
const candidates: string[] = []
|
const candidates: string[] = []
|
||||||
|
|
||||||
if (process.env.WX_KEY_DYLIB_PATH) {
|
if (process.env.WX_KEY_DYLIB_PATH) {
|
||||||
@@ -79,11 +100,20 @@ export class KeyServiceMac {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isPackaged) {
|
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, 'resources', 'libwx_key.dylib'))
|
||||||
candidates.push(join(process.resourcesPath, 'libwx_key.dylib'))
|
candidates.push(join(process.resourcesPath, 'libwx_key.dylib'))
|
||||||
} else {
|
} else {
|
||||||
const cwd = process.cwd()
|
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(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'))
|
candidates.push(join(app.getAppPath(), 'resources', 'libwx_key.dylib'))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,31 +403,81 @@ export class KeyServiceMac {
|
|||||||
return `'${String(text).replace(/'/g, `'\\''`)}'`
|
return `'${String(text).replace(/'/g, `'\\''`)}'`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private collectMacKeyArtifactPaths(primaryBinaryPath: string): string[] {
|
||||||
|
const baseDir = dirname(primaryBinaryPath)
|
||||||
|
const names = ['xkey_helper', 'image_scan_helper', 'xkey_helper_macos', 'libwx_key.dylib']
|
||||||
|
const unique: string[] = []
|
||||||
|
for (const name of names) {
|
||||||
|
const full = join(baseDir, name)
|
||||||
|
if (!existsSync(full)) continue
|
||||||
|
if (!unique.includes(full)) unique.push(full)
|
||||||
|
}
|
||||||
|
if (existsSync(primaryBinaryPath) && !unique.includes(primaryBinaryPath)) {
|
||||||
|
unique.unshift(primaryBinaryPath)
|
||||||
|
}
|
||||||
|
return unique
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureExecutableBitsBestEffort(paths: string[]): void {
|
||||||
|
for (const p of paths) {
|
||||||
|
try {
|
||||||
|
const mode = statSync(p).mode
|
||||||
|
if ((mode & 0o111) !== 0) continue
|
||||||
|
chmodSync(p, mode | 0o111)
|
||||||
|
} catch {
|
||||||
|
// ignore: 可能无权限(例如 /Applications 下 root-owned 的 .app)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureExecutableBitsWithElevation(paths: string[], timeoutMs: number): Promise<void> {
|
||||||
|
const existing = paths.filter(p => existsSync(p))
|
||||||
|
if (existing.length === 0) return
|
||||||
|
|
||||||
|
const quotedPaths = existing.map(p => this.shellSingleQuote(p)).join(' ')
|
||||||
|
const timeoutSec = Math.max(30, Math.ceil(timeoutMs / 1000))
|
||||||
|
const scriptLines = [
|
||||||
|
`set chmodCmd to "/bin/chmod +x ${quotedPaths}"`,
|
||||||
|
`set timeoutSec to ${timeoutSec}`,
|
||||||
|
'with timeout of timeoutSec seconds',
|
||||||
|
'do shell script chmodCmd with administrator privileges',
|
||||||
|
'end timeout'
|
||||||
|
]
|
||||||
|
|
||||||
|
await execFileAsync('/usr/bin/osascript', scriptLines.flatMap(line => ['-e', line]), {
|
||||||
|
timeout: timeoutMs + 10_000
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
private async getDbKeyByHelperElevated(
|
private async getDbKeyByHelperElevated(
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
onStatus?: (message: string, level: number) => void
|
onStatus?: (message: string, level: number) => void
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const helperPath = this.getHelperPath()
|
const helperPath = this.getHelperPath()
|
||||||
|
const artifactPaths = this.collectMacKeyArtifactPaths(helperPath)
|
||||||
|
this.ensureExecutableBitsBestEffort(artifactPaths)
|
||||||
const waitMs = Math.max(timeoutMs, 30_000)
|
const waitMs = Math.max(timeoutMs, 30_000)
|
||||||
const timeoutSec = Math.ceil(waitMs / 1000) + 30
|
const timeoutSec = Math.ceil(waitMs / 1000) + 30
|
||||||
const pid = await this.getWeChatPid()
|
const pid = await this.getWeChatPid()
|
||||||
|
const chmodPart = artifactPaths.length > 0
|
||||||
|
? `/bin/chmod +x ${artifactPaths.map(p => this.shellSingleQuote(p)).join(' ')}`
|
||||||
|
: ''
|
||||||
|
const runPart = `${this.shellSingleQuote(helperPath)} ${pid} ${waitMs}`
|
||||||
|
const privilegedCmd = chmodPart ? `${chmodPart} && ${runPart}` : runPart
|
||||||
// 用 AppleScript 的 quoted form 组装命令,避免复杂 shell 拼接导致整条失败
|
// 用 AppleScript 的 quoted form 组装命令,避免复杂 shell 拼接导致整条失败
|
||||||
// 通过 try/on error 回传详细错误,避免只看到 "Command failed"
|
// 通过 try/on error 回传详细错误,避免只看到 "Command failed"
|
||||||
const scriptLines = [
|
const scriptLines = [
|
||||||
`set helperPath to ${JSON.stringify(helperPath)}`,
|
`set cmd to ${JSON.stringify(privilegedCmd)}`,
|
||||||
`set cmd to quoted form of helperPath & " ${pid} ${waitMs}"`,
|
|
||||||
`set timeoutSec to ${timeoutSec}`,
|
`set timeoutSec to ${timeoutSec}`,
|
||||||
'try',
|
'try',
|
||||||
'with timeout of timeoutSec seconds',
|
'with timeout of timeoutSec seconds',
|
||||||
'set outText to do shell script cmd with administrator privileges',
|
'set outText to do shell script (cmd & " 2>&1") with administrator privileges',
|
||||||
'end timeout',
|
'end timeout',
|
||||||
'return "WF_OK::" & outText',
|
'return "WF_OK::" & outText',
|
||||||
'on error errMsg number errNum partial result pr',
|
'on error errMsg number errNum partial result pr',
|
||||||
'return "WF_ERR::" & errNum & "::" & errMsg & "::" & (pr as text)',
|
'return "WF_ERR::" & errNum & "::" & errMsg & "::" & (pr as text)',
|
||||||
'end try'
|
'end try'
|
||||||
]
|
]
|
||||||
onStatus?.('已准备就绪,现在登录微信或退出登录后重新登录微信', 0)
|
|
||||||
|
|
||||||
let stdout = ''
|
let stdout = ''
|
||||||
try {
|
try {
|
||||||
const result = await execFileAsync('/usr/bin/osascript', scriptLines.flatMap(line => ['-e', line]), {
|
const result = await execFileAsync('/usr/bin/osascript', scriptLines.flatMap(line => ['-e', line]), {
|
||||||
@@ -473,7 +553,19 @@ export class KeyServiceMac {
|
|||||||
if (code === 'HOOK_TARGET_ONLY') {
|
if (code === 'HOOK_TARGET_ONLY') {
|
||||||
return `已定位到目标函数地址(${detail || ''}),但当前原生 C++ 仅完成定位,尚未完成远程 Hook 回调取 key 流程。`
|
return `已定位到目标函数地址(${detail || ''}),但当前原生 C++ 仅完成定位,尚未完成远程 Hook 回调取 key 流程。`
|
||||||
}
|
}
|
||||||
if (code === 'SCAN_FAILED') return '内存扫描失败'
|
if (code === 'SCAN_FAILED') {
|
||||||
|
const normalizedDetail = (detail || '').trim()
|
||||||
|
if (!normalizedDetail) {
|
||||||
|
return '内存扫描失败:未匹配到可用特征。可能是当前微信版本更新导致,请升级 WeFlow 后重试。'
|
||||||
|
}
|
||||||
|
if (normalizedDetail.includes('Sink pattern not found')) {
|
||||||
|
return '内存扫描失败:未匹配到目标函数特征,可使用微信 4.1.8.100 版本尝试。'
|
||||||
|
}
|
||||||
|
if (normalizedDetail.includes('No suitable module found')) {
|
||||||
|
return '内存扫描失败:未找到可扫描的微信主模块。请确认微信已完整启动并保持前台,再重试。'
|
||||||
|
}
|
||||||
|
return `内存扫描失败:${normalizedDetail}`
|
||||||
|
}
|
||||||
return '未知错误'
|
return '未知错误'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -553,7 +645,7 @@ export class KeyServiceMac {
|
|||||||
const { xorKey, aesKey } = this.deriveImageKeys(code, candidateWxid)
|
const { xorKey, aesKey } = this.deriveImageKeys(code, candidateWxid)
|
||||||
if (!this.verifyDerivedAesKey(aesKey, template.ciphertext)) continue
|
if (!this.verifyDerivedAesKey(aesKey, template.ciphertext)) continue
|
||||||
onStatus?.(`密钥获取成功 (wxid: ${candidateWxid}, code: ${code})`)
|
onStatus?.(`密钥获取成功 (wxid: ${candidateWxid}, code: ${code})`)
|
||||||
return { success: true, xorKey, aesKey }
|
return { success: true, xorKey, aesKey, verified: true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -568,7 +660,7 @@ export class KeyServiceMac {
|
|||||||
const fallbackCode = codes[0]
|
const fallbackCode = codes[0]
|
||||||
const { xorKey, aesKey } = this.deriveImageKeys(fallbackCode, fallbackWxid)
|
const { xorKey, aesKey } = this.deriveImageKeys(fallbackCode, fallbackWxid)
|
||||||
onStatus?.(`密钥获取成功 (wxid: ${fallbackWxid}, code: ${fallbackCode})`)
|
onStatus?.(`密钥获取成功 (wxid: ${fallbackWxid}, code: ${fallbackCode})`)
|
||||||
return { success: true, xorKey, aesKey }
|
return { success: true, xorKey, aesKey, verified: false }
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
return { success: false, error: `自动获取图片密钥失败: ${e.message}` }
|
return { success: false, error: `自动获取图片密钥失败: ${e.message}` }
|
||||||
}
|
}
|
||||||
@@ -721,10 +813,12 @@ export class KeyServiceMac {
|
|||||||
try {
|
try {
|
||||||
const helperPath = this.getImageScanHelperPath()
|
const helperPath = this.getImageScanHelperPath()
|
||||||
const ciphertextHex = ciphertext.toString('hex')
|
const ciphertextHex = ciphertext.toString('hex')
|
||||||
|
const artifactPaths = this.collectMacKeyArtifactPaths(helperPath)
|
||||||
|
this.ensureExecutableBitsBestEffort(artifactPaths)
|
||||||
|
|
||||||
// 1) 直接运行 helper(有正式签名的 debugger entitlement 时可用)
|
// 1) 直接运行 helper(有正式签名的 debugger entitlement 时可用)
|
||||||
if (!this._needsElevation) {
|
if (!this._needsElevation) {
|
||||||
const direct = await this._spawnScanHelper(helperPath, pid, ciphertextHex, false)
|
const direct = await this._spawnScanHelper(helperPath, pid, ciphertextHex, false, artifactPaths)
|
||||||
if (direct.key) return direct.key
|
if (direct.key) return direct.key
|
||||||
if (direct.permissionError) {
|
if (direct.permissionError) {
|
||||||
console.warn('[KeyServiceMac] task_for_pid 权限不足,切换到 osascript 提权模式')
|
console.warn('[KeyServiceMac] task_for_pid 权限不足,切换到 osascript 提权模式')
|
||||||
@@ -735,7 +829,12 @@ export class KeyServiceMac {
|
|||||||
|
|
||||||
// 2) 通过 osascript 以管理员权限运行 helper(SIP 下 ad-hoc 签名无法获取 task_for_pid)
|
// 2) 通过 osascript 以管理员权限运行 helper(SIP 下 ad-hoc 签名无法获取 task_for_pid)
|
||||||
if (this._needsElevation) {
|
if (this._needsElevation) {
|
||||||
const elevated = await this._spawnScanHelper(helperPath, pid, ciphertextHex, true)
|
try {
|
||||||
|
await this.ensureExecutableBitsWithElevation(artifactPaths, 45_000)
|
||||||
|
} catch (e: any) {
|
||||||
|
console.warn('[KeyServiceMac] elevated chmod failed before image scan:', e?.message || e)
|
||||||
|
}
|
||||||
|
const elevated = await this._spawnScanHelper(helperPath, pid, ciphertextHex, true, artifactPaths)
|
||||||
if (elevated.key) return elevated.key
|
if (elevated.key) return elevated.key
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -838,12 +937,19 @@ export class KeyServiceMac {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _spawnScanHelper(
|
private _spawnScanHelper(
|
||||||
helperPath: string, pid: number, ciphertextHex: string, elevated: boolean
|
helperPath: string,
|
||||||
|
pid: number,
|
||||||
|
ciphertextHex: string,
|
||||||
|
elevated: boolean,
|
||||||
|
artifactPaths: string[] = []
|
||||||
): Promise<{ key: string | null; permissionError: boolean }> {
|
): Promise<{ key: string | null; permissionError: boolean }> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let child: ReturnType<typeof spawn>
|
let child: ReturnType<typeof spawn>
|
||||||
if (elevated) {
|
if (elevated) {
|
||||||
const shellCmd = `'${helperPath}' ${pid} ${ciphertextHex}`
|
const chmodPart = artifactPaths.length > 0
|
||||||
|
? `/bin/chmod +x ${artifactPaths.map(p => this.shellSingleQuote(p)).join(' ')} && `
|
||||||
|
: ''
|
||||||
|
const shellCmd = `${chmodPart}${this.shellSingleQuote(helperPath)} ${pid} ${ciphertextHex}`
|
||||||
child = spawn('/usr/bin/osascript', ['-e', `do shell script ${JSON.stringify(shellCmd)} with administrator privileges`],
|
child = spawn('/usr/bin/osascript', ['-e', `do shell script ${JSON.stringify(shellCmd)} with administrator privileges`],
|
||||||
{ stdio: ['ignore', 'pipe', 'pipe'] })
|
{ stdio: ['ignore', 'pipe', 'pipe'] })
|
||||||
} else {
|
} else {
|
||||||
@@ -935,10 +1041,17 @@ export class KeyServiceMac {
|
|||||||
private resolveXwechatRootFromPath(accountPath?: string): string | null {
|
private resolveXwechatRootFromPath(accountPath?: string): string | null {
|
||||||
const normalized = String(accountPath || '').replace(/\\/g, '/').replace(/\/+$/, '')
|
const normalized = String(accountPath || '').replace(/\\/g, '/').replace(/\/+$/, '')
|
||||||
if (!normalized) return null
|
if (!normalized) return null
|
||||||
|
|
||||||
|
// 旧路径:xwechat_files
|
||||||
const marker = '/xwechat_files'
|
const marker = '/xwechat_files'
|
||||||
const markerIdx = normalized.indexOf(marker)
|
const markerIdx = normalized.indexOf(marker)
|
||||||
if (markerIdx < 0) return null
|
if (markerIdx >= 0) return normalized.slice(0, markerIdx + marker.length)
|
||||||
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 {
|
private pushAccountIdCandidates(candidates: string[], value?: string): void {
|
||||||
@@ -1096,6 +1209,16 @@ export class KeyServiceMac {
|
|||||||
candidates.add(`${base}/app_data/net/kvcomm`)
|
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
|
let cursor = accountPath
|
||||||
for (let i = 0; i < 6; i++) {
|
for (let i = 0; i < 6; i++) {
|
||||||
candidates.add(join(cursor, 'net', 'kvcomm'))
|
candidates.add(join(cursor, 'net', 'kvcomm'))
|
||||||
|
|||||||
174
electron/services/linuxNotificationService.ts
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import { Notification } from "electron";
|
||||||
|
import { avatarFileCache, AvatarFileCacheService } from "./avatarFileCacheService";
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 avatarFileCache.getAvatarPath(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function shutdownLinuxNotificationService(): Promise<void> {
|
||||||
|
// 清理所有活动的通知
|
||||||
|
for (const [id, notification] of activeNotifications) {
|
||||||
|
try {
|
||||||
|
notification.close();
|
||||||
|
} catch {}
|
||||||
|
clearNotificationState(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理头像文件缓存
|
||||||
|
try {
|
||||||
|
await avatarFileCache.clearCache();
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
console.log("[LinuxNotification] Service shutdown complete");
|
||||||
|
}
|
||||||
@@ -2,6 +2,10 @@ import { ConfigService } from './config'
|
|||||||
import { chatService, type ChatSession, type Message } from './chatService'
|
import { chatService, type ChatSession, type Message } from './chatService'
|
||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
import { httpService } from './httpService'
|
import { httpService } from './httpService'
|
||||||
|
import { promises as fs } from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import { createHash } from 'crypto'
|
||||||
|
import { pathToFileURL } from 'url'
|
||||||
|
|
||||||
interface SessionBaseline {
|
interface SessionBaseline {
|
||||||
lastTimestamp: number
|
lastTimestamp: number
|
||||||
@@ -11,15 +15,19 @@ interface SessionBaseline {
|
|||||||
interface MessagePushPayload {
|
interface MessagePushPayload {
|
||||||
event: 'message.new'
|
event: 'message.new'
|
||||||
sessionId: string
|
sessionId: string
|
||||||
|
sessionType: 'private' | 'group' | 'official' | 'other'
|
||||||
messageKey: string
|
messageKey: string
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
sourceName: string
|
sourceName: string
|
||||||
groupName?: string
|
groupName?: string
|
||||||
content: string | null
|
content: string | null
|
||||||
|
timestamp: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const PUSH_CONFIG_KEYS = new Set([
|
const PUSH_CONFIG_KEYS = new Set([
|
||||||
'messagePushEnabled',
|
'messagePushEnabled',
|
||||||
|
'messagePushFilterMode',
|
||||||
|
'messagePushFilterList',
|
||||||
'dbPath',
|
'dbPath',
|
||||||
'decryptKey',
|
'decryptKey',
|
||||||
'myWxid'
|
'myWxid'
|
||||||
@@ -30,6 +38,8 @@ class MessagePushService {
|
|||||||
private readonly sessionBaseline = new Map<string, SessionBaseline>()
|
private readonly sessionBaseline = new Map<string, SessionBaseline>()
|
||||||
private readonly recentMessageKeys = new Map<string, number>()
|
private readonly recentMessageKeys = new Map<string, number>()
|
||||||
private readonly groupNicknameCache = new Map<string, { nicknames: Record<string, string>; updatedAt: number }>()
|
private readonly groupNicknameCache = new Map<string, { nicknames: Record<string, string>; updatedAt: number }>()
|
||||||
|
private readonly pushAvatarCacheDir: string
|
||||||
|
private readonly pushAvatarDataCache = new Map<string, string>()
|
||||||
private readonly debounceMs = 350
|
private readonly debounceMs = 350
|
||||||
private readonly recentMessageTtlMs = 10 * 60 * 1000
|
private readonly recentMessageTtlMs = 10 * 60 * 1000
|
||||||
private readonly groupNicknameCacheTtlMs = 5 * 60 * 1000
|
private readonly groupNicknameCacheTtlMs = 5 * 60 * 1000
|
||||||
@@ -38,9 +48,11 @@ class MessagePushService {
|
|||||||
private rerunRequested = false
|
private rerunRequested = false
|
||||||
private started = false
|
private started = false
|
||||||
private baselineReady = false
|
private baselineReady = false
|
||||||
|
private messageTableScanRequested = false
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.configService = ConfigService.getInstance()
|
this.configService = ConfigService.getInstance()
|
||||||
|
this.pushAvatarCacheDir = path.join(this.configService.getCacheBasePath(), 'push-avatar-files')
|
||||||
}
|
}
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
@@ -49,6 +61,13 @@ class MessagePushService {
|
|||||||
void this.refreshConfiguration('startup')
|
void this.refreshConfiguration('startup')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
this.started = false
|
||||||
|
this.processing = false
|
||||||
|
this.rerunRequested = false
|
||||||
|
this.resetRuntimeState()
|
||||||
|
}
|
||||||
|
|
||||||
handleDbMonitorChange(type: string, json: string): void {
|
handleDbMonitorChange(type: string, json: string): void {
|
||||||
if (!this.started) return
|
if (!this.started) return
|
||||||
if (!this.isPushEnabled()) return
|
if (!this.isPushEnabled()) return
|
||||||
@@ -60,12 +79,15 @@ class MessagePushService {
|
|||||||
payload = null
|
payload = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const tableName = String(payload?.table || '').trim().toLowerCase()
|
const tableName = String(payload?.table || '').trim()
|
||||||
if (tableName && tableName !== 'session') {
|
if (this.isSessionTableChange(tableName)) {
|
||||||
|
this.scheduleSync()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
this.scheduleSync()
|
if (!tableName || this.isMessageTableChange(tableName)) {
|
||||||
|
this.scheduleSync({ scanMessageBackedSessions: true })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleConfigChanged(key: string): Promise<void> {
|
async handleConfigChanged(key: string): Promise<void> {
|
||||||
@@ -91,6 +113,7 @@ class MessagePushService {
|
|||||||
this.recentMessageKeys.clear()
|
this.recentMessageKeys.clear()
|
||||||
this.groupNicknameCache.clear()
|
this.groupNicknameCache.clear()
|
||||||
this.baselineReady = false
|
this.baselineReady = false
|
||||||
|
this.messageTableScanRequested = false
|
||||||
if (this.debounceTimer) {
|
if (this.debounceTimer) {
|
||||||
clearTimeout(this.debounceTimer)
|
clearTimeout(this.debounceTimer)
|
||||||
this.debounceTimer = null
|
this.debounceTimer = null
|
||||||
@@ -121,7 +144,11 @@ class MessagePushService {
|
|||||||
this.baselineReady = true
|
this.baselineReady = true
|
||||||
}
|
}
|
||||||
|
|
||||||
private scheduleSync(): void {
|
private scheduleSync(options: { scanMessageBackedSessions?: boolean } = {}): void {
|
||||||
|
if (options.scanMessageBackedSessions) {
|
||||||
|
this.messageTableScanRequested = true
|
||||||
|
}
|
||||||
|
|
||||||
if (this.debounceTimer) {
|
if (this.debounceTimer) {
|
||||||
clearTimeout(this.debounceTimer)
|
clearTimeout(this.debounceTimer)
|
||||||
}
|
}
|
||||||
@@ -141,6 +168,8 @@ class MessagePushService {
|
|||||||
this.processing = true
|
this.processing = true
|
||||||
try {
|
try {
|
||||||
if (!this.isPushEnabled()) return
|
if (!this.isPushEnabled()) return
|
||||||
|
const scanMessageBackedSessions = this.messageTableScanRequested
|
||||||
|
this.messageTableScanRequested = false
|
||||||
|
|
||||||
const connectResult = await chatService.connect()
|
const connectResult = await chatService.connect()
|
||||||
if (!connectResult.success) {
|
if (!connectResult.success) {
|
||||||
@@ -163,27 +192,47 @@ class MessagePushService {
|
|||||||
const previousBaseline = new Map(this.sessionBaseline)
|
const previousBaseline = new Map(this.sessionBaseline)
|
||||||
this.setBaseline(sessions)
|
this.setBaseline(sessions)
|
||||||
|
|
||||||
const candidates = sessions.filter((session) => this.shouldInspectSession(previousBaseline.get(session.username), session))
|
const candidates = sessions.filter((session) => {
|
||||||
|
const previous = previousBaseline.get(session.username)
|
||||||
|
if (this.shouldInspectSession(previous, session)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return scanMessageBackedSessions && this.shouldScanMessageBackedSession(previous, session)
|
||||||
|
})
|
||||||
for (const session of candidates) {
|
for (const session of candidates) {
|
||||||
await this.pushSessionMessages(session, previousBaseline.get(session.username))
|
await this.pushSessionMessages(
|
||||||
|
session,
|
||||||
|
previousBaseline.get(session.username) || this.sessionBaseline.get(session.username)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
this.processing = false
|
this.processing = false
|
||||||
if (this.rerunRequested) {
|
if (this.rerunRequested) {
|
||||||
this.rerunRequested = false
|
this.rerunRequested = false
|
||||||
this.scheduleSync()
|
this.scheduleSync({ scanMessageBackedSessions: this.messageTableScanRequested })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private setBaseline(sessions: ChatSession[]): void {
|
private setBaseline(sessions: ChatSession[]): void {
|
||||||
|
const previousBaseline = new Map(this.sessionBaseline)
|
||||||
|
const nextBaseline = new Map<string, SessionBaseline>()
|
||||||
|
const nowSeconds = Math.floor(Date.now() / 1000)
|
||||||
this.sessionBaseline.clear()
|
this.sessionBaseline.clear()
|
||||||
for (const session of sessions) {
|
for (const session of sessions) {
|
||||||
this.sessionBaseline.set(session.username, {
|
const username = String(session.username || '').trim()
|
||||||
lastTimestamp: Number(session.lastTimestamp || 0),
|
if (!username) continue
|
||||||
|
const previous = previousBaseline.get(username)
|
||||||
|
const sessionTimestamp = Number(session.lastTimestamp || 0)
|
||||||
|
const initialTimestamp = sessionTimestamp > 0 ? sessionTimestamp : nowSeconds
|
||||||
|
nextBaseline.set(username, {
|
||||||
|
lastTimestamp: Math.max(sessionTimestamp, Number(previous?.lastTimestamp || 0), previous ? 0 : initialTimestamp),
|
||||||
unreadCount: Number(session.unreadCount || 0)
|
unreadCount: Number(session.unreadCount || 0)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
for (const [username, baseline] of nextBaseline.entries()) {
|
||||||
|
this.sessionBaseline.set(username, baseline)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private shouldInspectSession(previous: SessionBaseline | undefined, session: ChatSession): boolean {
|
private shouldInspectSession(previous: SessionBaseline | undefined, session: ChatSession): boolean {
|
||||||
@@ -204,16 +253,30 @@ class MessagePushService {
|
|||||||
return unreadCount > 0 && lastTimestamp > 0
|
return unreadCount > 0 && lastTimestamp > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lastTimestamp <= previous.lastTimestamp) {
|
return lastTimestamp > previous.lastTimestamp || unreadCount > previous.unreadCount
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldScanMessageBackedSession(previous: SessionBaseline | undefined, session: ChatSession): boolean {
|
||||||
|
const sessionId = String(session.username || '').trim()
|
||||||
|
if (!sessionId || sessionId.toLowerCase().includes('placeholder_foldgroup')) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// unread 未增长时,大概率是自己发送、其他设备已读或状态同步,不作为主动推送
|
const summary = String(session.summary || '').trim()
|
||||||
return unreadCount > previous.unreadCount
|
if (Number(session.lastMsgType || 0) === 10002 || summary.includes('撤回了一条消息')) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionType = this.getSessionType(sessionId, session)
|
||||||
|
if (sessionType === 'private') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return Boolean(previous) || Number(session.lastTimestamp || 0) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
private async pushSessionMessages(session: ChatSession, previous: SessionBaseline | undefined): Promise<void> {
|
private async pushSessionMessages(session: ChatSession, previous: SessionBaseline | undefined): Promise<void> {
|
||||||
const since = Math.max(0, Number(previous?.lastTimestamp || 0) - 1)
|
const since = Math.max(0, Number(previous?.lastTimestamp || 0))
|
||||||
const newMessagesResult = await chatService.getNewMessages(session.username, since, 1000)
|
const newMessagesResult = await chatService.getNewMessages(session.username, since, 1000)
|
||||||
if (!newMessagesResult.success || !newMessagesResult.messages || newMessagesResult.messages.length === 0) {
|
if (!newMessagesResult.success || !newMessagesResult.messages || newMessagesResult.messages.length === 0) {
|
||||||
return
|
return
|
||||||
@@ -224,7 +287,7 @@ class MessagePushService {
|
|||||||
if (!messageKey) continue
|
if (!messageKey) continue
|
||||||
if (message.isSend === 1) continue
|
if (message.isSend === 1) continue
|
||||||
|
|
||||||
if (previous && Number(message.createTime || 0) < Number(previous.lastTimestamp || 0)) {
|
if (previous && Number(message.createTime || 0) <= Number(previous.lastTimestamp || 0)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,9 +297,11 @@ class MessagePushService {
|
|||||||
|
|
||||||
const payload = await this.buildPayload(session, message)
|
const payload = await this.buildPayload(session, message)
|
||||||
if (!payload) continue
|
if (!payload) continue
|
||||||
|
if (!this.shouldPushPayload(payload)) continue
|
||||||
|
|
||||||
httpService.broadcastMessagePush(payload)
|
httpService.broadcastMessagePush(payload)
|
||||||
this.rememberMessageKey(messageKey)
|
this.rememberMessageKey(messageKey)
|
||||||
|
this.bumpSessionBaseline(session.username, message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,38 +311,166 @@ class MessagePushService {
|
|||||||
if (!sessionId || !messageKey) return null
|
if (!sessionId || !messageKey) return null
|
||||||
|
|
||||||
const isGroup = sessionId.endsWith('@chatroom')
|
const isGroup = sessionId.endsWith('@chatroom')
|
||||||
|
const sessionType = this.getSessionType(sessionId, session)
|
||||||
const content = this.getMessageDisplayContent(message)
|
const content = this.getMessageDisplayContent(message)
|
||||||
|
|
||||||
|
const createTime = Number(message.createTime || 0)
|
||||||
|
|
||||||
if (isGroup) {
|
if (isGroup) {
|
||||||
const groupInfo = await chatService.getContactAvatar(sessionId)
|
const groupInfo = await chatService.getContactAvatar(sessionId)
|
||||||
const groupName = session.displayName || groupInfo?.displayName || sessionId
|
const groupName = session.displayName || groupInfo?.displayName || sessionId
|
||||||
const sourceName = await this.resolveGroupSourceName(sessionId, message, session)
|
const sourceName = await this.resolveGroupSourceName(sessionId, message, session)
|
||||||
|
const avatarUrl = await this.normalizePushAvatarUrl(session.avatarUrl || groupInfo?.avatarUrl)
|
||||||
return {
|
return {
|
||||||
event: 'message.new',
|
event: 'message.new',
|
||||||
sessionId,
|
sessionId,
|
||||||
|
sessionType,
|
||||||
messageKey,
|
messageKey,
|
||||||
avatarUrl: session.avatarUrl || groupInfo?.avatarUrl,
|
avatarUrl,
|
||||||
groupName,
|
groupName,
|
||||||
sourceName,
|
sourceName,
|
||||||
content
|
content,
|
||||||
|
timestamp: createTime
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const contactInfo = await chatService.getContactAvatar(sessionId)
|
const contactInfo = await chatService.getContactAvatar(sessionId)
|
||||||
|
const avatarUrl = await this.normalizePushAvatarUrl(session.avatarUrl || contactInfo?.avatarUrl)
|
||||||
return {
|
return {
|
||||||
event: 'message.new',
|
event: 'message.new',
|
||||||
sessionId,
|
sessionId,
|
||||||
|
sessionType,
|
||||||
messageKey,
|
messageKey,
|
||||||
avatarUrl: session.avatarUrl || contactInfo?.avatarUrl,
|
avatarUrl,
|
||||||
sourceName: session.displayName || contactInfo?.displayName || sessionId,
|
sourceName: session.displayName || contactInfo?.displayName || sessionId,
|
||||||
content
|
content,
|
||||||
|
timestamp: createTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async normalizePushAvatarUrl(avatarUrl?: string): Promise<string | undefined> {
|
||||||
|
const normalized = String(avatarUrl || '').trim()
|
||||||
|
if (!normalized) return undefined
|
||||||
|
if (!normalized.startsWith('data:image/')) {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = this.pushAvatarDataCache.get(normalized)
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
|
const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/i.exec(normalized)
|
||||||
|
if (!match) return undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mimeType = match[1].toLowerCase()
|
||||||
|
const base64Data = match[2]
|
||||||
|
const imageBuffer = Buffer.from(base64Data, 'base64')
|
||||||
|
if (!imageBuffer.length) return undefined
|
||||||
|
|
||||||
|
const ext = this.getImageExtFromMime(mimeType)
|
||||||
|
const hash = createHash('sha1').update(normalized).digest('hex')
|
||||||
|
const filePath = path.join(this.pushAvatarCacheDir, `avatar_${hash}.${ext}`)
|
||||||
|
|
||||||
|
await fs.mkdir(this.pushAvatarCacheDir, { recursive: true })
|
||||||
|
try {
|
||||||
|
await fs.access(filePath)
|
||||||
|
} catch {
|
||||||
|
await fs.writeFile(filePath, imageBuffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileUrl = pathToFileURL(filePath).toString()
|
||||||
|
this.pushAvatarDataCache.set(normalized, fileUrl)
|
||||||
|
return fileUrl
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getImageExtFromMime(mimeType: string): string {
|
||||||
|
if (mimeType === 'image/png') return 'png'
|
||||||
|
if (mimeType === 'image/gif') return 'gif'
|
||||||
|
if (mimeType === 'image/webp') return 'webp'
|
||||||
|
return 'jpg'
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSessionType(sessionId: string, session: ChatSession): MessagePushPayload['sessionType'] {
|
||||||
|
if (sessionId.endsWith('@chatroom')) {
|
||||||
|
return 'group'
|
||||||
|
}
|
||||||
|
if (sessionId.startsWith('gh_') || session.type === 'official') {
|
||||||
|
return 'official'
|
||||||
|
}
|
||||||
|
if (session.type === 'friend') {
|
||||||
|
return 'private'
|
||||||
|
}
|
||||||
|
return 'other'
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldPushPayload(payload: MessagePushPayload): boolean {
|
||||||
|
const sessionId = String(payload.sessionId || '').trim()
|
||||||
|
const filterMode = this.getMessagePushFilterMode()
|
||||||
|
if (filterMode === 'all') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterList = this.getMessagePushFilterList()
|
||||||
|
const listed = filterList.has(sessionId)
|
||||||
|
if (filterMode === 'whitelist') {
|
||||||
|
return listed
|
||||||
|
}
|
||||||
|
return !listed
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMessagePushFilterMode(): 'all' | 'whitelist' | 'blacklist' {
|
||||||
|
const value = this.configService.get('messagePushFilterMode')
|
||||||
|
if (value === 'whitelist' || value === 'blacklist') return value
|
||||||
|
return 'all'
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMessagePushFilterList(): Set<string> {
|
||||||
|
const value = this.configService.get('messagePushFilterList')
|
||||||
|
if (!Array.isArray(value)) return new Set()
|
||||||
|
return new Set(value.map((item) => String(item || '').trim()).filter(Boolean))
|
||||||
|
}
|
||||||
|
|
||||||
|
private isSessionTableChange(tableName: string): boolean {
|
||||||
|
return String(tableName || '').trim().toLowerCase() === 'session'
|
||||||
|
}
|
||||||
|
|
||||||
|
private isMessageTableChange(tableName: string): boolean {
|
||||||
|
const normalized = String(tableName || '').trim().toLowerCase()
|
||||||
|
if (!normalized) return false
|
||||||
|
return normalized === 'message' ||
|
||||||
|
normalized === 'msg' ||
|
||||||
|
normalized.startsWith('message_') ||
|
||||||
|
normalized.startsWith('msg_') ||
|
||||||
|
normalized.includes('message')
|
||||||
|
}
|
||||||
|
|
||||||
|
private bumpSessionBaseline(sessionId: string, message: Message): void {
|
||||||
|
const key = String(sessionId || '').trim()
|
||||||
|
if (!key) return
|
||||||
|
|
||||||
|
const createTime = Number(message.createTime || 0)
|
||||||
|
if (!Number.isFinite(createTime) || createTime <= 0) return
|
||||||
|
|
||||||
|
const current = this.sessionBaseline.get(key) || { lastTimestamp: 0, unreadCount: 0 }
|
||||||
|
if (createTime > current.lastTimestamp) {
|
||||||
|
this.sessionBaseline.set(key, {
|
||||||
|
...current,
|
||||||
|
lastTimestamp: createTime
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private getMessageDisplayContent(message: Message): string | null {
|
private getMessageDisplayContent(message: Message): string | null {
|
||||||
|
const cleanOfficialPrefix = (value: string | null): string | null => {
|
||||||
|
if (!value) return value
|
||||||
|
return value.replace(/^\s*\[视频号\]\s*/u, '').trim() || value
|
||||||
|
}
|
||||||
switch (Number(message.localType || 0)) {
|
switch (Number(message.localType || 0)) {
|
||||||
case 1:
|
case 1:
|
||||||
return message.rawContent || null
|
return cleanOfficialPrefix(message.rawContent || null)
|
||||||
case 3:
|
case 3:
|
||||||
return '[图片]'
|
return '[图片]'
|
||||||
case 34:
|
case 34:
|
||||||
@@ -287,13 +480,13 @@ class MessagePushService {
|
|||||||
case 47:
|
case 47:
|
||||||
return '[表情]'
|
return '[表情]'
|
||||||
case 42:
|
case 42:
|
||||||
return message.cardNickname || '[名片]'
|
return cleanOfficialPrefix(message.cardNickname || '[名片]')
|
||||||
case 48:
|
case 48:
|
||||||
return '[位置]'
|
return '[位置]'
|
||||||
case 49:
|
case 49:
|
||||||
return message.linkTitle || message.fileName || '[消息]'
|
return cleanOfficialPrefix(message.linkTitle || message.fileName || '[消息]')
|
||||||
default:
|
default:
|
||||||
return message.parsedContent || message.rawContent || null
|
return cleanOfficialPrefix(message.parsedContent || message.rawContent || null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
110
electron/services/nativeImageDecrypt.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { existsSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
type NativeDecryptResult = {
|
||||||
|
data: Buffer
|
||||||
|
ext: string
|
||||||
|
isWxgf?: boolean
|
||||||
|
is_wxgf?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type NativeAddon = {
|
||||||
|
decryptDatNative: (inputPath: string, xorKey: number, aesKey?: string) => NativeDecryptResult
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedAddon: NativeAddon | null | undefined
|
||||||
|
|
||||||
|
function shouldEnableNative(): boolean {
|
||||||
|
return process.env.WEFLOW_IMAGE_NATIVE !== '0'
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandAsarCandidates(filePath: string): string[] {
|
||||||
|
if (!filePath.includes('app.asar') || filePath.includes('app.asar.unpacked')) {
|
||||||
|
return [filePath]
|
||||||
|
}
|
||||||
|
return [filePath.replace('app.asar', 'app.asar.unpacked'), filePath]
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlatformDir(): string {
|
||||||
|
if (process.platform === 'win32') return 'win32'
|
||||||
|
if (process.platform === 'darwin') return 'macos'
|
||||||
|
if (process.platform === 'linux') return 'linux'
|
||||||
|
return process.platform
|
||||||
|
}
|
||||||
|
|
||||||
|
function getArchDir(): string {
|
||||||
|
if (process.arch === 'x64') return 'x64'
|
||||||
|
if (process.arch === 'arm64') return 'arm64'
|
||||||
|
return process.arch
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAddonCandidates(): string[] {
|
||||||
|
const platformDir = getPlatformDir()
|
||||||
|
const archDir = getArchDir()
|
||||||
|
const cwd = process.cwd()
|
||||||
|
const fileNames = [
|
||||||
|
`weflow-image-native-${platformDir}-${archDir}.node`
|
||||||
|
]
|
||||||
|
const roots = [
|
||||||
|
join(cwd, 'resources', 'wedecrypt', platformDir, archDir),
|
||||||
|
...(process.resourcesPath
|
||||||
|
? [
|
||||||
|
join(process.resourcesPath, 'resources', 'wedecrypt', platformDir, archDir),
|
||||||
|
join(process.resourcesPath, 'wedecrypt', platformDir, archDir)
|
||||||
|
]
|
||||||
|
: [])
|
||||||
|
]
|
||||||
|
const candidates = roots.flatMap((root) => fileNames.map((name) => join(root, name)))
|
||||||
|
return Array.from(new Set(candidates.flatMap(expandAsarCandidates)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadAddon(): NativeAddon | null {
|
||||||
|
if (!shouldEnableNative()) return null
|
||||||
|
if (cachedAddon !== undefined) return cachedAddon
|
||||||
|
|
||||||
|
for (const candidate of getAddonCandidates()) {
|
||||||
|
if (!existsSync(candidate)) continue
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
const addon = require(candidate) as NativeAddon
|
||||||
|
if (addon && typeof addon.decryptDatNative === 'function') {
|
||||||
|
cachedAddon = addon
|
||||||
|
return addon
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// try next candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedAddon = null
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nativeAddonLocation(): string | null {
|
||||||
|
for (const candidate of getAddonCandidates()) {
|
||||||
|
if (existsSync(candidate)) return candidate
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decryptDatViaNative(
|
||||||
|
inputPath: string,
|
||||||
|
xorKey: number,
|
||||||
|
aesKey?: string
|
||||||
|
): { data: Buffer; ext: string; isWxgf: boolean } | null {
|
||||||
|
const addon = loadAddon()
|
||||||
|
if (!addon) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = addon.decryptDatNative(inputPath, xorKey, aesKey)
|
||||||
|
const isWxgf = Boolean(result?.isWxgf ?? result?.is_wxgf)
|
||||||
|
if (!result || !Buffer.isBuffer(result.data)) return null
|
||||||
|
const rawExt = typeof result.ext === 'string' && result.ext.trim()
|
||||||
|
? result.ext.trim().toLowerCase()
|
||||||
|
: ''
|
||||||
|
const ext = rawExt ? (rawExt.startsWith('.') ? rawExt : `.${rawExt}`) : ''
|
||||||
|
return { data: result.data, ext, isWxgf }
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
import { ConfigService } from './config'
|
import { ConfigService } from './config'
|
||||||
import { ContactCacheService } from './contactCacheService'
|
import { ContactCacheService } from './contactCacheService'
|
||||||
import { existsSync, mkdirSync } from 'fs'
|
import { app } from 'electron'
|
||||||
|
import { existsSync, mkdirSync, unlinkSync } from 'fs'
|
||||||
import { readFile, writeFile, mkdir } from 'fs/promises'
|
import { readFile, writeFile, mkdir } from 'fs/promises'
|
||||||
import { basename, join } from 'path'
|
import { basename, join } from 'path'
|
||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
@@ -173,8 +174,17 @@ const detectImageMime = (buf: Buffer, fallback: string = 'image/jpeg') => {
|
|||||||
// BMP
|
// BMP
|
||||||
if (buf[0] === 0x42 && buf[1] === 0x4d) return 'image/bmp'
|
if (buf[0] === 0x42 && buf[1] === 0x4d) return 'image/bmp'
|
||||||
|
|
||||||
// MP4: 00 00 00 18 / 20 / ... + 'ftyp'
|
// ISO BMFF 家族:优先识别 AVIF/HEIF,避免误判为 MP4
|
||||||
if (buf.length > 8 && buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70) return 'video/mp4'
|
if (buf.length > 12 && buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70) {
|
||||||
|
const ftypWindow = buf.subarray(8, Math.min(buf.length, 64)).toString('ascii').toLowerCase()
|
||||||
|
if (ftypWindow.includes('avif') || ftypWindow.includes('avis')) return 'image/avif'
|
||||||
|
if (
|
||||||
|
ftypWindow.includes('heic') || ftypWindow.includes('heix') ||
|
||||||
|
ftypWindow.includes('hevc') || ftypWindow.includes('hevx') ||
|
||||||
|
ftypWindow.includes('mif1') || ftypWindow.includes('msf1')
|
||||||
|
) return 'image/heic'
|
||||||
|
return 'video/mp4'
|
||||||
|
}
|
||||||
|
|
||||||
// Fallback logic for video
|
// Fallback logic for video
|
||||||
if (fallback.includes('video') || fallback.includes('mp4')) return 'video/mp4'
|
if (fallback.includes('video') || fallback.includes('mp4')) return 'video/mp4'
|
||||||
@@ -537,6 +547,32 @@ class SnsService {
|
|||||||
return raw.trim()
|
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 }> {
|
private async getExportStatsFromTimeline(myWxid?: string): Promise<{ totalPosts: number; totalFriends: number; myPosts: number | null }> {
|
||||||
const pageSize = 500
|
const pageSize = 500
|
||||||
const uniqueUsers = new Set<string>()
|
const uniqueUsers = new Set<string>()
|
||||||
@@ -775,14 +811,25 @@ class SnsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getSnsCacheDir(): string {
|
private getSnsCacheDir(): string {
|
||||||
const cachePath = this.configService.getCacheBasePath()
|
const configuredCachePath = String(this.configService.get('cachePath') || '').trim()
|
||||||
const snsCacheDir = join(cachePath, 'sns_cache')
|
const baseDir = configuredCachePath || join(app.getPath('documents'), 'WeFlow')
|
||||||
|
const snsCacheDir = join(baseDir, 'sns_cache')
|
||||||
if (!existsSync(snsCacheDir)) {
|
if (!existsSync(snsCacheDir)) {
|
||||||
mkdirSync(snsCacheDir, { recursive: true })
|
mkdirSync(snsCacheDir, { recursive: true })
|
||||||
}
|
}
|
||||||
return snsCacheDir
|
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 {
|
private getCacheFilePath(url: string): string {
|
||||||
const hash = crypto.createHash('md5').update(url).digest('hex')
|
const hash = crypto.createHash('md5').update(url).digest('hex')
|
||||||
const ext = isVideoUrl(url) ? '.mp4' : '.jpg'
|
const ext = isVideoUrl(url) ? '.mp4' : '.jpg'
|
||||||
@@ -794,7 +841,22 @@ class SnsService {
|
|||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return { success: false, error: result.error || '获取朋友圈联系人失败' }
|
return { success: false, error: result.error || '获取朋友圈联系人失败' }
|
||||||
}
|
}
|
||||||
return { success: true, usernames: result.usernames || [] }
|
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 }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 忽略回退错误,保持与原行为一致返回空数组
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, usernames: directUsernames }
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getExportStatsFromTableCount(myWxid?: string): Promise<{ totalPosts: number; totalFriends: number; myPosts: number | null }> {
|
private async getExportStatsFromTableCount(myWxid?: string): Promise<{ totalPosts: number; totalFriends: number; myPosts: number | null }> {
|
||||||
@@ -1021,14 +1083,14 @@ class SnsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 补全 DLL 返回的评论中缺失的 refNickname
|
* 补全数据服务返回的评论中缺失的 refNickname
|
||||||
* DLL 返回的 refCommentId 是被回复评论的 cmtid
|
*数据服务返回的 refCommentId 是被回复评论的 cmtid
|
||||||
* 评论按 cmtid 从小到大排列,cmtid 从 1 开始递增
|
* 评论按 cmtid 从小到大排列,cmtid 从 1 开始递增
|
||||||
*/
|
*/
|
||||||
private fixCommentRefs(comments: any[]): any[] {
|
private fixCommentRefs(comments: any[]): any[] {
|
||||||
if (!comments || comments.length === 0) return []
|
if (!comments || comments.length === 0) return []
|
||||||
|
|
||||||
// DLL 现在返回完整的评论数据(含 emojis、refNickname)
|
//数据服务现在返回完整的评论数据(含 emojis、refNickname)
|
||||||
// 此处做最终的格式化和兜底补全
|
// 此处做最终的格式化和兜底补全
|
||||||
const idToNickname = new Map<string, string>()
|
const idToNickname = new Map<string, string>()
|
||||||
comments.forEach((c, idx) => {
|
comments.forEach((c, idx) => {
|
||||||
@@ -1099,14 +1161,14 @@ class SnsService {
|
|||||||
} : undefined
|
} : undefined
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// DLL 已返回完整评论数据(含 emojis、refNickname)
|
//数据服务已返回完整评论数据(含 emojis、refNickname)
|
||||||
// 如果 DLL 评论缺少表情包信息,回退到从 rawXml 重新解析
|
// 如果数据服务评论缺少表情包信息,回退到从 rawXml 重新解析
|
||||||
const dllComments: any[] = post.comments || []
|
const dllComments: any[] = post.comments || []
|
||||||
const hasEmojisInDll = dllComments.some((c: any) => c.emojis && c.emojis.length > 0)
|
const hasEmojisInDll = dllComments.some((c: any) => c.emojis && c.emojis.length > 0)
|
||||||
|
|
||||||
let finalComments: any[]
|
let finalComments: any[]
|
||||||
if (dllComments.length > 0 && (hasEmojisInDll || !rawXml)) {
|
if (dllComments.length > 0 && (hasEmojisInDll || !rawXml)) {
|
||||||
// DLL 数据完整,直接使用
|
//数据服务数据完整,直接使用
|
||||||
finalComments = this.fixCommentRefs(dllComments)
|
finalComments = this.fixCommentRefs(dllComments)
|
||||||
} else if (rawXml) {
|
} else if (rawXml) {
|
||||||
// 回退:从 rawXml 重新解析(兼容旧版 DLL)
|
// 回退:从 rawXml 重新解析(兼容旧版 DLL)
|
||||||
@@ -1178,7 +1240,19 @@ class SnsService {
|
|||||||
const cacheKey = `${url}|${key ?? ''}`
|
const cacheKey = `${url}|${key ?? ''}`
|
||||||
|
|
||||||
if (this.imageCache.has(cacheKey)) {
|
if (this.imageCache.has(cacheKey)) {
|
||||||
return { success: true, dataUrl: this.imageCache.get(cacheKey) }
|
const cachedDataUrl = this.imageCache.get(cacheKey) || ''
|
||||||
|
const base64Part = cachedDataUrl.split(',')[1] || ''
|
||||||
|
if (base64Part) {
|
||||||
|
try {
|
||||||
|
const cachedBuf = Buffer.from(base64Part, 'base64')
|
||||||
|
if (detectImageMime(cachedBuf, '').startsWith('image/')) {
|
||||||
|
return { success: true, dataUrl: cachedDataUrl }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore and fall through to refetch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.imageCache.delete(cacheKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.fetchAndDecryptImage(url, key)
|
const result = await this.fetchAndDecryptImage(url, key)
|
||||||
@@ -1191,6 +1265,9 @@ class SnsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (result.data && result.contentType) {
|
if (result.data && result.contentType) {
|
||||||
|
if (!detectImageMime(result.data, '').startsWith('image/')) {
|
||||||
|
return { success: false, error: '无效图片数据(可能密钥不匹配或缓存损坏)' }
|
||||||
|
}
|
||||||
const dataUrl = `data:${result.contentType};base64,${result.data.toString('base64')}`
|
const dataUrl = `data:${result.contentType};base64,${result.data.toString('base64')}`
|
||||||
this.imageCache.set(cacheKey, dataUrl)
|
this.imageCache.set(cacheKey, dataUrl)
|
||||||
return { success: true, dataUrl }
|
return { success: true, dataUrl }
|
||||||
@@ -1199,7 +1276,7 @@ class SnsService {
|
|||||||
return { success: false, error: result.error }
|
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)
|
return this.fetchAndDecryptImage(url, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1791,7 +1868,7 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
|
|||||||
const isVideo = isVideoUrl(url)
|
const isVideo = isVideoUrl(url)
|
||||||
const cachePath = this.getCacheFilePath(url)
|
const cachePath = this.getCacheFilePath(url)
|
||||||
|
|
||||||
// 1. 尝试从磁盘缓存读取
|
// 1. 优先尝试从当前缓存目录读取
|
||||||
if (existsSync(cachePath)) {
|
if (existsSync(cachePath)) {
|
||||||
try {
|
try {
|
||||||
// 对于视频,不读取整个文件到内存,只确认存在即可
|
// 对于视频,不读取整个文件到内存,只确认存在即可
|
||||||
@@ -1800,8 +1877,13 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await readFile(cachePath)
|
const data = await readFile(cachePath)
|
||||||
|
if (!detectImageMime(data, '').startsWith('image/')) {
|
||||||
|
// 旧版本可能把未解密内容写入缓存;发现无效图片头时删除并重新拉取。
|
||||||
|
try { unlinkSync(cachePath) } catch { }
|
||||||
|
} else {
|
||||||
const contentType = detectImageMime(data)
|
const contentType = detectImageMime(data)
|
||||||
return { success: true, data, contentType, cachePath }
|
return { success: true, data, contentType, cachePath }
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(`[SnsService] 读取缓存失败: ${cachePath}`, e)
|
console.warn(`[SnsService] 读取缓存失败: ${cachePath}`, e)
|
||||||
}
|
}
|
||||||
@@ -1953,6 +2035,7 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
|
|||||||
const xEnc = String(res.headers['x-enc'] || '').trim()
|
const xEnc = String(res.headers['x-enc'] || '').trim()
|
||||||
|
|
||||||
let decoded = raw
|
let decoded = raw
|
||||||
|
const rawMagicMime = detectImageMime(raw, '')
|
||||||
|
|
||||||
// 图片逻辑
|
// 图片逻辑
|
||||||
const shouldDecrypt = (xEnc === '1' || !!key) && key !== undefined && key !== null && String(key).trim().length > 0
|
const shouldDecrypt = (xEnc === '1' || !!key) && key !== undefined && key !== null && String(key).trim().length > 0
|
||||||
@@ -1970,13 +2053,24 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
|
|||||||
decrypted[i] = raw[i] ^ keystream[i]
|
decrypted[i] = raw[i] ^ keystream[i]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const decryptedMagicMime = detectImageMime(decrypted, '')
|
||||||
|
if (decryptedMagicMime.startsWith('image/')) {
|
||||||
decoded = decrypted
|
decoded = decrypted
|
||||||
|
} else if (!rawMagicMime.startsWith('image/')) {
|
||||||
|
decoded = decrypted
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[SnsService] TS Decrypt Error:', e)
|
console.error('[SnsService] TS Decrypt Error:', e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const decodedMagicMime = detectImageMime(decoded, '')
|
||||||
|
if (!decodedMagicMime.startsWith('image/')) {
|
||||||
|
resolve({ success: false, error: '图片解密失败:无法识别图片格式' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 写入磁盘缓存
|
// 写入磁盘缓存
|
||||||
try {
|
try {
|
||||||
await writeFile(cachePath, decoded)
|
await writeFile(cachePath, decoded)
|
||||||
@@ -2010,6 +2104,15 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
|
|||||||
if (buf[0] === 0xFF && buf[1] === 0xD8 && buf[2] === 0xFF) return true
|
if (buf[0] === 0xFF && buf[1] === 0xD8 && buf[2] === 0xFF) return true
|
||||||
if (buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46
|
if (buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46
|
||||||
&& buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50) return true
|
&& buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50) return true
|
||||||
|
if (buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70) {
|
||||||
|
const ftypWindow = buf.subarray(8, Math.min(buf.length, 64)).toString('ascii').toLowerCase()
|
||||||
|
if (ftypWindow.includes('avif') || ftypWindow.includes('avis')) return true
|
||||||
|
if (
|
||||||
|
ftypWindow.includes('heic') || ftypWindow.includes('heix') ||
|
||||||
|
ftypWindow.includes('hevc') || ftypWindow.includes('hevx') ||
|
||||||
|
ftypWindow.includes('mif1') || ftypWindow.includes('msf1')
|
||||||
|
) return true
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2252,9 +2355,7 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
|
|||||||
|
|
||||||
const fs = require('fs')
|
const fs = require('fs')
|
||||||
const cacheKey = crypto.createHash('md5').update(url || encryptUrl!).digest('hex')
|
const cacheKey = crypto.createHash('md5').update(url || encryptUrl!).digest('hex')
|
||||||
const cachePath = this.configService.getCacheBasePath()
|
const emojiDir = this.getEmojiCacheDir()
|
||||||
const emojiDir = join(cachePath, 'sns_emoji_cache')
|
|
||||||
if (!existsSync(emojiDir)) mkdirSync(emojiDir, { recursive: true })
|
|
||||||
|
|
||||||
// 检查本地缓存
|
// 检查本地缓存
|
||||||
for (const ext of ['.gif', '.png', '.webp', '.jpg', '.jpeg']) {
|
for (const ext of ['.gif', '.png', '.webp', '.jpg', '.jpeg']) {
|
||||||
|
|||||||
367
electron/services/social/weiboService.ts
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
import https from 'https'
|
||||||
|
import { createHash } from 'crypto'
|
||||||
|
import { URL } from 'url'
|
||||||
|
|
||||||
|
const WEIBO_TIMEOUT_MS = 10_000
|
||||||
|
const WEIBO_MAX_POSTS = 5
|
||||||
|
const WEIBO_CACHE_TTL_MS = 30 * 60 * 1000
|
||||||
|
const WEIBO_USER_AGENT =
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36'
|
||||||
|
const WEIBO_MOBILE_USER_AGENT =
|
||||||
|
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1'
|
||||||
|
|
||||||
|
interface BrowserCookieEntry {
|
||||||
|
domain?: string
|
||||||
|
name?: string
|
||||||
|
value?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WeiboUserInfo {
|
||||||
|
id?: number | string
|
||||||
|
screen_name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WeiboWaterFallItem {
|
||||||
|
id?: number | string
|
||||||
|
idstr?: string
|
||||||
|
mblogid?: string
|
||||||
|
created_at?: string
|
||||||
|
text_raw?: string
|
||||||
|
isLongText?: boolean
|
||||||
|
user?: WeiboUserInfo
|
||||||
|
retweeted_status?: WeiboWaterFallItem
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WeiboWaterFallResponse {
|
||||||
|
ok?: number
|
||||||
|
data?: {
|
||||||
|
list?: WeiboWaterFallItem[]
|
||||||
|
next_cursor?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WeiboStatusShowResponse {
|
||||||
|
id?: number | string
|
||||||
|
idstr?: string
|
||||||
|
mblogid?: string
|
||||||
|
created_at?: string
|
||||||
|
text_raw?: string
|
||||||
|
user?: WeiboUserInfo
|
||||||
|
retweeted_status?: WeiboWaterFallItem
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MWeiboCard {
|
||||||
|
mblog?: WeiboWaterFallItem
|
||||||
|
card_group?: MWeiboCard[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MWeiboContainerResponse {
|
||||||
|
ok?: number
|
||||||
|
data?: {
|
||||||
|
cards?: MWeiboCard[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeiboRecentPost {
|
||||||
|
id: string
|
||||||
|
createdAt: string
|
||||||
|
url: string
|
||||||
|
text: string
|
||||||
|
screenName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CachedRecentPosts {
|
||||||
|
expiresAt: number
|
||||||
|
posts: WeiboRecentPost[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestJson<T>(url: string, options: { cookie?: string; referer?: string; userAgent?: string }): Promise<T> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let urlObj: URL
|
||||||
|
try {
|
||||||
|
urlObj = new URL(url)
|
||||||
|
} catch {
|
||||||
|
reject(new Error(`无效的微博请求地址:${url}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Accept: 'application/json, text/plain, */*',
|
||||||
|
Referer: options.referer || 'https://weibo.com',
|
||||||
|
'User-Agent': options.userAgent || WEIBO_USER_AGENT,
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
}
|
||||||
|
if (options.cookie) {
|
||||||
|
headers.Cookie = options.cookie
|
||||||
|
}
|
||||||
|
|
||||||
|
const req = https.request(
|
||||||
|
{
|
||||||
|
hostname: urlObj.hostname,
|
||||||
|
port: urlObj.port || 443,
|
||||||
|
path: urlObj.pathname + urlObj.search,
|
||||||
|
method: 'GET',
|
||||||
|
headers
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
let raw = ''
|
||||||
|
res.setEncoding('utf8')
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
raw += chunk
|
||||||
|
})
|
||||||
|
res.on('end', () => {
|
||||||
|
const statusCode = res.statusCode || 0
|
||||||
|
if (statusCode < 200 || statusCode >= 300) {
|
||||||
|
reject(new Error(`微博接口返回异常状态码 ${statusCode}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(raw) as T)
|
||||||
|
} catch {
|
||||||
|
reject(new Error('微博接口返回了非 JSON 响应'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
req.setTimeout(WEIBO_TIMEOUT_MS, () => {
|
||||||
|
req.destroy()
|
||||||
|
reject(new Error('微博请求超时'))
|
||||||
|
})
|
||||||
|
|
||||||
|
req.on('error', reject)
|
||||||
|
req.end()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCookieArray(entries: BrowserCookieEntry[]): string {
|
||||||
|
const picked = new Map<string, string>()
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const name = String(entry?.name || '').trim()
|
||||||
|
const value = String(entry?.value || '').trim()
|
||||||
|
const domain = String(entry?.domain || '').trim().toLowerCase()
|
||||||
|
|
||||||
|
if (!name || !value) continue
|
||||||
|
if (domain && !domain.includes('weibo.com') && !domain.includes('weibo.cn')) continue
|
||||||
|
|
||||||
|
picked.set(name, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(picked.entries())
|
||||||
|
.map(([name, value]) => `${name}=${value}`)
|
||||||
|
.join('; ')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeWeiboCookieInput(rawInput: string): string {
|
||||||
|
const trimmed = String(rawInput || '').trim()
|
||||||
|
if (!trimmed) return ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed) as unknown
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
const normalized = normalizeCookieArray(parsed as BrowserCookieEntry[])
|
||||||
|
if (normalized) return normalized
|
||||||
|
throw new Error('Cookie JSON 中未找到可用的微博 Cookie 项')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof SyntaxError)) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed.replace(/^Cookie:\s*/i, '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWeiboUid(input: string): string {
|
||||||
|
const trimmed = String(input || '').trim()
|
||||||
|
const directMatch = trimmed.match(/^\d{5,}$/)
|
||||||
|
if (directMatch) return directMatch[0]
|
||||||
|
|
||||||
|
const linkMatch = trimmed.match(/(?:weibo\.com|m\.weibo\.cn)\/u\/(\d{5,})/i)
|
||||||
|
if (linkMatch) return linkMatch[1]
|
||||||
|
|
||||||
|
throw new Error('请输入有效的微博 UID(纯数字)')
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeWeiboText(text: string): string {
|
||||||
|
return String(text || '')
|
||||||
|
.replace(/\u200b|\u200c|\u200d|\ufeff/g, '')
|
||||||
|
.replace(/https?:\/\/t\.cn\/[A-Za-z0-9]+/g, ' ')
|
||||||
|
.replace(/ +/g, ' ')
|
||||||
|
.replace(/\n{3,}/g, '\n\n')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeRetweetText(item: Pick<WeiboWaterFallItem, 'text_raw' | 'retweeted_status'>): string {
|
||||||
|
const baseText = sanitizeWeiboText(item.text_raw || '')
|
||||||
|
const retweetText = sanitizeWeiboText(item.retweeted_status?.text_raw || '')
|
||||||
|
if (!retweetText) return baseText
|
||||||
|
if (!baseText || baseText === '转发微博') return `转发:${retweetText}`
|
||||||
|
return `${baseText}\n\n转发内容:${retweetText}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCacheKey(uid: string, count: number, cookie: string): string {
|
||||||
|
const cookieHash = createHash('sha1').update(cookie).digest('hex')
|
||||||
|
return `${uid}:${count}:${cookieHash}`
|
||||||
|
}
|
||||||
|
|
||||||
|
class WeiboService {
|
||||||
|
private recentPostsCache = new Map<string, CachedRecentPosts>()
|
||||||
|
|
||||||
|
clearCache(): void {
|
||||||
|
this.recentPostsCache.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateUid(
|
||||||
|
uidInput: string,
|
||||||
|
cookieInput: string
|
||||||
|
): Promise<{ success: boolean; uid?: string; screenName?: string; error?: string }> {
|
||||||
|
try {
|
||||||
|
const uid = normalizeWeiboUid(uidInput)
|
||||||
|
const cookie = normalizeWeiboCookieInput(cookieInput)
|
||||||
|
if (!cookie) {
|
||||||
|
return { success: true, uid }
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeline = await this.fetchTimeline(uid, cookie)
|
||||||
|
const firstItem = timeline.data?.list?.[0]
|
||||||
|
if (!firstItem) {
|
||||||
|
return { success: false, error: '该微博账号暂无可读取的近期公开内容,或当前 Cookie 已失效' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
uid,
|
||||||
|
screenName: firstItem.user?.screen_name
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: (error as Error).message || '微博 UID 校验失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchRecentPosts(
|
||||||
|
uidInput: string,
|
||||||
|
cookieInput: string,
|
||||||
|
requestedCount: number
|
||||||
|
): Promise<WeiboRecentPost[]> {
|
||||||
|
const uid = normalizeWeiboUid(uidInput)
|
||||||
|
const cookie = normalizeWeiboCookieInput(cookieInput)
|
||||||
|
const hasCookie = Boolean(cookie)
|
||||||
|
|
||||||
|
const count = Math.max(1, Math.min(WEIBO_MAX_POSTS, Math.floor(Number(requestedCount) || 0)))
|
||||||
|
const cacheKey = buildCacheKey(uid, count, hasCookie ? cookie : '__no_cookie_mobile__')
|
||||||
|
const cached = this.recentPostsCache.get(cacheKey)
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
if (cached && cached.expiresAt > now) {
|
||||||
|
return cached.posts
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawItems = hasCookie
|
||||||
|
? (await this.fetchTimeline(uid, cookie)).data?.list || []
|
||||||
|
: await this.fetchMobileTimeline(uid)
|
||||||
|
const posts: WeiboRecentPost[] = []
|
||||||
|
|
||||||
|
for (const item of rawItems) {
|
||||||
|
if (posts.length >= count) break
|
||||||
|
|
||||||
|
const id = String(item.idstr || item.id || '').trim()
|
||||||
|
if (!id) continue
|
||||||
|
|
||||||
|
let text = mergeRetweetText(item)
|
||||||
|
if (item.isLongText && hasCookie) {
|
||||||
|
try {
|
||||||
|
const detail = await this.fetchDetail(id, cookie)
|
||||||
|
text = mergeRetweetText(detail)
|
||||||
|
} catch {
|
||||||
|
// 长文补抓失败时回退到列表摘要
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
text = sanitizeWeiboText(text)
|
||||||
|
if (!text) continue
|
||||||
|
|
||||||
|
posts.push({
|
||||||
|
id,
|
||||||
|
createdAt: String(item.created_at || ''),
|
||||||
|
url: `https://m.weibo.cn/detail/${id}`,
|
||||||
|
text,
|
||||||
|
screenName: item.user?.screen_name
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
this.recentPostsCache.set(cacheKey, {
|
||||||
|
expiresAt: now + WEIBO_CACHE_TTL_MS,
|
||||||
|
posts
|
||||||
|
})
|
||||||
|
|
||||||
|
return posts
|
||||||
|
}
|
||||||
|
|
||||||
|
private fetchTimeline(uid: string, cookie: string): Promise<WeiboWaterFallResponse> {
|
||||||
|
return requestJson<WeiboWaterFallResponse>(
|
||||||
|
`https://weibo.com/ajax/profile/getWaterFallContent?uid=${encodeURIComponent(uid)}`,
|
||||||
|
{
|
||||||
|
cookie,
|
||||||
|
referer: `https://weibo.com/u/${encodeURIComponent(uid)}`
|
||||||
|
}
|
||||||
|
).then((response) => {
|
||||||
|
if (response.ok !== 1 || !Array.isArray(response.data?.list)) {
|
||||||
|
throw new Error('微博时间线获取失败,请检查 Cookie 是否仍然有效')
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private fetchMobileTimeline(uid: string): Promise<WeiboWaterFallItem[]> {
|
||||||
|
const containerid = `107603${uid}`
|
||||||
|
return requestJson<MWeiboContainerResponse>(
|
||||||
|
`https://m.weibo.cn/api/container/getIndex?type=uid&value=${encodeURIComponent(uid)}&containerid=${encodeURIComponent(containerid)}`,
|
||||||
|
{
|
||||||
|
referer: `https://m.weibo.cn/u/${encodeURIComponent(uid)}`,
|
||||||
|
userAgent: WEIBO_MOBILE_USER_AGENT
|
||||||
|
}
|
||||||
|
).then((response) => {
|
||||||
|
if (response.ok !== 1 || !Array.isArray(response.data?.cards)) {
|
||||||
|
throw new Error('微博时间线获取失败,请稍后重试')
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: WeiboWaterFallItem[] = []
|
||||||
|
for (const card of response.data.cards) {
|
||||||
|
if (card?.mblog) rows.push(card.mblog)
|
||||||
|
if (Array.isArray(card?.card_group)) {
|
||||||
|
for (const subCard of card.card_group) {
|
||||||
|
if (subCard?.mblog) rows.push(subCard.mblog)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
throw new Error('该微博账号暂无可读取的近期公开内容')
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private fetchDetail(id: string, cookie: string): Promise<WeiboStatusShowResponse> {
|
||||||
|
return requestJson<WeiboStatusShowResponse>(
|
||||||
|
`https://weibo.com/ajax/statuses/show?id=${encodeURIComponent(id)}&isGetLongText=true`,
|
||||||
|
{
|
||||||
|
cookie,
|
||||||
|
referer: `https://weibo.com/detail/${encodeURIComponent(id)}`
|
||||||
|
}
|
||||||
|
).then((response) => {
|
||||||
|
if (!response || (!response.id && !response.idstr)) {
|
||||||
|
throw new Error('微博详情获取失败')
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const weiboService = new WeiboService()
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync } from 'fs'
|
import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync } from 'fs'
|
||||||
|
import { pathToFileURL } from 'url'
|
||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
import { ConfigService } from './config'
|
import { ConfigService } from './config'
|
||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
@@ -22,6 +23,8 @@ interface VideoIndexEntry {
|
|||||||
thumbPath?: string
|
thumbPath?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PosterFormat = 'dataUrl' | 'fileUrl'
|
||||||
|
|
||||||
class VideoService {
|
class VideoService {
|
||||||
private configService: ConfigService
|
private configService: ConfigService
|
||||||
private hardlinkResolveCache = new Map<string, TimedCacheEntry<string | null>>()
|
private hardlinkResolveCache = new Map<string, TimedCacheEntry<string | null>>()
|
||||||
@@ -249,19 +252,15 @@ class VideoService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async preloadVideoHardlinkMd5s(md5List: string[]): Promise<void> {
|
async preloadVideoHardlinkMd5s(md5List: string[]): Promise<void> {
|
||||||
const dbPath = this.getDbPath()
|
// 视频链路已改为直接使用 packed_info_data 提取出的文件名索引本地目录。
|
||||||
const wxid = this.getMyWxid()
|
// 该预热接口保留仅为兼容旧调用方,不再查询 hardlink.db。
|
||||||
const cleanedWxid = this.cleanWxid(wxid)
|
void md5List
|
||||||
if (!dbPath || !wxid) return
|
|
||||||
await this.resolveVideoHardlinks(md5List, dbPath, wxid, cleanedWxid)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private fileToPosterUrl(filePath: string | undefined, mimeType: string, posterFormat: PosterFormat): string | undefined {
|
||||||
* 将文件转换为 data URL
|
|
||||||
*/
|
|
||||||
private fileToDataUrl(filePath: string | undefined, mimeType: string): string | undefined {
|
|
||||||
try {
|
try {
|
||||||
if (!filePath || !existsSync(filePath)) return undefined
|
if (!filePath || !existsSync(filePath)) return undefined
|
||||||
|
if (posterFormat === 'fileUrl') return pathToFileURL(filePath).toString()
|
||||||
const buffer = readFileSync(filePath)
|
const buffer = readFileSync(filePath)
|
||||||
return `data:${mimeType};base64,${buffer.toString('base64')}`
|
return `data:${mimeType};base64,${buffer.toString('base64')}`
|
||||||
} catch {
|
} catch {
|
||||||
@@ -355,7 +354,12 @@ class VideoService {
|
|||||||
return index
|
return index
|
||||||
}
|
}
|
||||||
|
|
||||||
private getVideoInfoFromIndex(index: Map<string, VideoIndexEntry>, md5: string, includePoster = true): VideoInfo | null {
|
private getVideoInfoFromIndex(
|
||||||
|
index: Map<string, VideoIndexEntry>,
|
||||||
|
md5: string,
|
||||||
|
includePoster = true,
|
||||||
|
posterFormat: PosterFormat = 'dataUrl'
|
||||||
|
): VideoInfo | null {
|
||||||
const normalizedMd5 = String(md5 || '').trim().toLowerCase()
|
const normalizedMd5 = String(md5 || '').trim().toLowerCase()
|
||||||
if (!normalizedMd5) return null
|
if (!normalizedMd5) return null
|
||||||
|
|
||||||
@@ -379,8 +383,8 @@ class VideoService {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
videoUrl: entry.videoPath,
|
videoUrl: entry.videoPath,
|
||||||
coverUrl: this.fileToDataUrl(entry.coverPath, 'image/jpeg'),
|
coverUrl: this.fileToPosterUrl(entry.coverPath, 'image/jpeg', posterFormat),
|
||||||
thumbUrl: this.fileToDataUrl(entry.thumbPath, 'image/jpeg'),
|
thumbUrl: this.fileToPosterUrl(entry.thumbPath, 'image/jpeg', posterFormat),
|
||||||
exists: true
|
exists: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -388,7 +392,29 @@ class VideoService {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fallbackScanVideo(videoBaseDir: string, realVideoMd5: string, includePoster = true): VideoInfo | null {
|
private normalizeVideoLookupKey(value: string): string {
|
||||||
|
let text = String(value || '').trim().toLowerCase()
|
||||||
|
if (!text) return ''
|
||||||
|
text = text.replace(/^.*[\\/]/, '')
|
||||||
|
text = text.replace(/\.(?:mp4|mov|m4v|avi|mkv|flv|jpg|jpeg|png|gif|dat)$/i, '')
|
||||||
|
text = text.replace(/_thumb$/, '')
|
||||||
|
const direct = /^([a-f0-9]{16,64})(?:_raw)?$/i.exec(text)
|
||||||
|
if (direct) {
|
||||||
|
const suffix = /_raw$/i.test(text) ? '_raw' : ''
|
||||||
|
return `${direct[1].toLowerCase()}${suffix}`
|
||||||
|
}
|
||||||
|
const preferred32 = /([a-f0-9]{32})(?![a-f0-9])/i.exec(text)
|
||||||
|
if (preferred32?.[1]) return preferred32[1].toLowerCase()
|
||||||
|
const fallback = /([a-f0-9]{16,64})(?![a-f0-9])/i.exec(text)
|
||||||
|
return String(fallback?.[1] || '').toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fallbackScanVideo(
|
||||||
|
videoBaseDir: string,
|
||||||
|
realVideoMd5: string,
|
||||||
|
includePoster = true,
|
||||||
|
posterFormat: PosterFormat = 'dataUrl'
|
||||||
|
): VideoInfo | null {
|
||||||
try {
|
try {
|
||||||
const yearMonthDirs = readdirSync(videoBaseDir)
|
const yearMonthDirs = readdirSync(videoBaseDir)
|
||||||
.filter((dir) => {
|
.filter((dir) => {
|
||||||
@@ -416,8 +442,8 @@ class VideoService {
|
|||||||
const thumbPath = join(dirPath, `${baseMd5}_thumb.jpg`)
|
const thumbPath = join(dirPath, `${baseMd5}_thumb.jpg`)
|
||||||
return {
|
return {
|
||||||
videoUrl: videoPath,
|
videoUrl: videoPath,
|
||||||
coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'),
|
coverUrl: this.fileToPosterUrl(coverPath, 'image/jpeg', posterFormat),
|
||||||
thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'),
|
thumbUrl: this.fileToPosterUrl(thumbPath, 'image/jpeg', posterFormat),
|
||||||
exists: true
|
exists: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -427,14 +453,21 @@ class VideoService {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async ensurePoster(info: VideoInfo, includePoster: boolean, posterFormat: PosterFormat): Promise<VideoInfo> {
|
||||||
|
void posterFormat
|
||||||
|
if (!includePoster) return info
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据视频MD5获取视频文件信息
|
* 根据视频MD5获取视频文件信息
|
||||||
* 视频存放在: {数据库根目录}/{用户wxid}/msg/video/{年月}/
|
* 视频存放在: {数据库根目录}/{用户wxid}/msg/video/{年月}/
|
||||||
* 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg
|
* 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg
|
||||||
*/
|
*/
|
||||||
async getVideoInfo(videoMd5: string, options?: { includePoster?: boolean }): Promise<VideoInfo> {
|
async getVideoInfo(videoMd5: string, options?: { includePoster?: boolean; posterFormat?: PosterFormat }): Promise<VideoInfo> {
|
||||||
const normalizedMd5 = String(videoMd5 || '').trim().toLowerCase()
|
const normalizedMd5 = String(videoMd5 || '').trim().toLowerCase()
|
||||||
const includePoster = options?.includePoster !== false
|
const includePoster = options?.includePoster !== false
|
||||||
|
const posterFormat: PosterFormat = options?.posterFormat === 'fileUrl' ? 'fileUrl' : 'dataUrl'
|
||||||
const dbPath = this.getDbPath()
|
const dbPath = this.getDbPath()
|
||||||
const wxid = this.getMyWxid()
|
const wxid = this.getMyWxid()
|
||||||
|
|
||||||
@@ -446,7 +479,7 @@ class VideoService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const scopeKey = this.getScopeKey(dbPath, wxid)
|
const scopeKey = this.getScopeKey(dbPath, wxid)
|
||||||
const cacheKey = `${scopeKey}|${normalizedMd5}|poster=${includePoster ? 1 : 0}`
|
const cacheKey = `${scopeKey}|${normalizedMd5}|poster=${includePoster ? 1 : 0}|format=${posterFormat}`
|
||||||
|
|
||||||
const cachedInfo = this.readTimedCache(this.videoInfoCache, cacheKey)
|
const cachedInfo = this.readTimedCache(this.videoInfoCache, cacheKey)
|
||||||
if (cachedInfo) return cachedInfo
|
if (cachedInfo) return cachedInfo
|
||||||
@@ -455,7 +488,7 @@ class VideoService {
|
|||||||
if (pending) return pending
|
if (pending) return pending
|
||||||
|
|
||||||
const task = (async (): Promise<VideoInfo> => {
|
const task = (async (): Promise<VideoInfo> => {
|
||||||
const realVideoMd5 = await this.queryVideoFileName(normalizedMd5) || normalizedMd5
|
const realVideoMd5 = this.normalizeVideoLookupKey(normalizedMd5) || normalizedMd5
|
||||||
const videoBaseDir = this.resolveVideoBaseDir(dbPath, wxid)
|
const videoBaseDir = this.resolveVideoBaseDir(dbPath, wxid)
|
||||||
|
|
||||||
if (!existsSync(videoBaseDir)) {
|
if (!existsSync(videoBaseDir)) {
|
||||||
@@ -465,21 +498,23 @@ class VideoService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const index = this.getOrBuildVideoIndex(videoBaseDir)
|
const index = this.getOrBuildVideoIndex(videoBaseDir)
|
||||||
const indexed = this.getVideoInfoFromIndex(index, realVideoMd5, includePoster)
|
const indexed = this.getVideoInfoFromIndex(index, realVideoMd5, includePoster, posterFormat)
|
||||||
if (indexed) {
|
if (indexed) {
|
||||||
this.writeTimedCache(this.videoInfoCache, cacheKey, indexed, this.videoInfoCacheTtlMs, this.maxCacheEntries)
|
const withPoster = await this.ensurePoster(indexed, includePoster, posterFormat)
|
||||||
return indexed
|
this.writeTimedCache(this.videoInfoCache, cacheKey, withPoster, this.videoInfoCacheTtlMs, this.maxCacheEntries)
|
||||||
|
return withPoster
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallback = this.fallbackScanVideo(videoBaseDir, realVideoMd5, includePoster)
|
const fallback = this.fallbackScanVideo(videoBaseDir, realVideoMd5, includePoster, posterFormat)
|
||||||
if (fallback) {
|
if (fallback) {
|
||||||
this.writeTimedCache(this.videoInfoCache, cacheKey, fallback, this.videoInfoCacheTtlMs, this.maxCacheEntries)
|
const withPoster = await this.ensurePoster(fallback, includePoster, posterFormat)
|
||||||
return fallback
|
this.writeTimedCache(this.videoInfoCache, cacheKey, withPoster, this.videoInfoCacheTtlMs, this.maxCacheEntries)
|
||||||
|
return withPoster
|
||||||
}
|
}
|
||||||
|
|
||||||
const miss = { exists: false }
|
const miss = { exists: false }
|
||||||
this.writeTimedCache(this.videoInfoCache, cacheKey, miss, this.videoInfoCacheTtlMs, this.maxCacheEntries)
|
this.writeTimedCache(this.videoInfoCache, cacheKey, miss, this.videoInfoCacheTtlMs, this.maxCacheEntries)
|
||||||
this.log('getVideoInfo: 未找到视频', { inputMd5: normalizedMd5, resolvedMd5: realVideoMd5 })
|
this.log('getVideoInfo: 未找到视频', { lookupKey: normalizedMd5, normalizedKey: realVideoMd5 })
|
||||||
return miss
|
return miss
|
||||||
})()
|
})()
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,14 @@ export class VoiceTranscribeService {
|
|||||||
if (candidates.length === 0) {
|
if (candidates.length === 0) {
|
||||||
console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`)
|
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
|
return env
|
||||||
|
|||||||
@@ -25,9 +25,7 @@ export class WcdbService {
|
|||||||
private logEnabled = false
|
private logEnabled = false
|
||||||
private monitorListener: ((type: string, json: string) => void) | null = null
|
private monitorListener: ((type: string, json: string) => void) | null = null
|
||||||
|
|
||||||
constructor() {
|
constructor() {}
|
||||||
this.initWorker()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 初始化 Worker 线程
|
* 初始化 Worker 线程
|
||||||
@@ -80,7 +78,7 @@ export class WcdbService {
|
|||||||
// Worker 退出,需要 reject 所有 pending promises
|
// Worker 退出,需要 reject 所有 pending promises
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
console.error('WCDB Worker 异常退出,退出码:', code)
|
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) {
|
for (const [id, p] of this.pending) {
|
||||||
p.reject(new Error(errorMsg))
|
p.reject(new Error(errorMsg))
|
||||||
}
|
}
|
||||||
@@ -268,6 +266,37 @@ export class WcdbService {
|
|||||||
return this.callWorker('getMessagesByType', { sessionId, localType, ascending, limit, offset })
|
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 })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取联系人昵称
|
* 获取联系人昵称
|
||||||
*/
|
*/
|
||||||
@@ -417,6 +446,19 @@ export class WcdbService {
|
|||||||
return this.callWorker('getGroupStats', { chatroomId, beginTimestamp, endTimestamp })
|
return this.callWorker('getGroupStats', { chatroomId, beginTimestamp, endTimestamp })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getMyFootprintStats(options: {
|
||||||
|
beginTimestamp?: number
|
||||||
|
endTimestamp?: number
|
||||||
|
myWxid?: string
|
||||||
|
privateSessionIds?: string[]
|
||||||
|
groupSessionIds?: string[]
|
||||||
|
mentionLimit?: number
|
||||||
|
privateLimit?: number
|
||||||
|
mentionMode?: 'text_at_me' | string
|
||||||
|
}): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||||
|
return this.callWorker('getMyFootprintStats', { options })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 打开消息游标
|
* 打开消息游标
|
||||||
*/
|
*/
|
||||||
@@ -467,7 +509,7 @@ export class WcdbService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取表情包释义(严格 DLL 接口)
|
* 获取表情包释义(严格数据服务接口)
|
||||||
*/
|
*/
|
||||||
async getEmoticonCaptionStrict(md5: string): Promise<{ success: boolean; caption?: string; error?: string }> {
|
async getEmoticonCaptionStrict(md5: string): Promise<{ success: boolean; caption?: string; error?: string }> {
|
||||||
return this.callWorker('getEmoticonCaptionStrict', { md5 })
|
return this.callWorker('getEmoticonCaptionStrict', { md5 })
|
||||||
@@ -561,6 +603,24 @@ export class WcdbService {
|
|||||||
return this.callWorker('getSnsExportStats', { myWxid })
|
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 })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 安装朋友圈删除拦截
|
* 安装朋友圈删除拦截
|
||||||
*/
|
*/
|
||||||
@@ -590,7 +650,7 @@ export class WcdbService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取 DLL 内部日志
|
* 获取数据服务内部日志
|
||||||
*/
|
*/
|
||||||
async getLogs(): Promise<{ success: boolean; logs?: string[]; error?: string }> {
|
async getLogs(): Promise<{ success: boolean; logs?: string[]; error?: string }> {
|
||||||
return this.callWorker('getLogs')
|
return this.callWorker('getLogs')
|
||||||
|
|||||||
20
electron/utils/pathUtils.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { homedir } from 'os'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expand "~" prefix to current user's home directory.
|
||||||
|
* Examples:
|
||||||
|
* - "~" => "/Users/alex"
|
||||||
|
* - "~/Library/..." => "/Users/alex/Library/..."
|
||||||
|
*/
|
||||||
|
export function expandHomePath(inputPath: string): string {
|
||||||
|
const raw = String(inputPath || '').trim()
|
||||||
|
if (!raw) return raw
|
||||||
|
|
||||||
|
if (raw === '~') return homedir()
|
||||||
|
if (/^~[\\/]/.test(raw)) {
|
||||||
|
return `${homedir()}${raw.slice(1)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
@@ -80,6 +80,9 @@ if (parentPort) {
|
|||||||
case 'getMessagesByType':
|
case 'getMessagesByType':
|
||||||
result = await core.getMessagesByType(payload.sessionId, payload.localType, payload.ascending, payload.limit, payload.offset)
|
result = await core.getMessagesByType(payload.sessionId, payload.localType, payload.ascending, payload.limit, payload.offset)
|
||||||
break
|
break
|
||||||
|
case 'getMediaStream':
|
||||||
|
result = await core.getMediaStream(payload.options)
|
||||||
|
break
|
||||||
case 'getDisplayNames':
|
case 'getDisplayNames':
|
||||||
result = await core.getDisplayNames(payload.usernames)
|
result = await core.getDisplayNames(payload.usernames)
|
||||||
break
|
break
|
||||||
@@ -155,6 +158,9 @@ if (parentPort) {
|
|||||||
case 'getGroupStats':
|
case 'getGroupStats':
|
||||||
result = await core.getGroupStats(payload.chatroomId, payload.beginTimestamp, payload.endTimestamp)
|
result = await core.getGroupStats(payload.chatroomId, payload.beginTimestamp, payload.endTimestamp)
|
||||||
break
|
break
|
||||||
|
case 'getMyFootprintStats':
|
||||||
|
result = await core.getMyFootprintStats(payload.options || {})
|
||||||
|
break
|
||||||
case 'openMessageCursor':
|
case 'openMessageCursor':
|
||||||
result = await core.openMessageCursor(payload.sessionId, payload.batchSize, payload.ascending, payload.beginTimestamp, payload.endTimestamp)
|
result = await core.openMessageCursor(payload.sessionId, payload.batchSize, payload.ascending, payload.beginTimestamp, payload.endTimestamp)
|
||||||
break
|
break
|
||||||
@@ -230,6 +236,15 @@ if (parentPort) {
|
|||||||
case 'getSnsExportStats':
|
case 'getSnsExportStats':
|
||||||
result = await core.getSnsExportStats(payload.myWxid)
|
result = await core.getSnsExportStats(payload.myWxid)
|
||||||
break
|
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':
|
case 'installSnsBlockDeleteTrigger':
|
||||||
result = await core.installSnsBlockDeleteTrigger()
|
result = await core.installSnsBlockDeleteTrigger()
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -1,51 +1,74 @@
|
|||||||
import { BrowserWindow, ipcMain, screen } from 'electron'
|
import { BrowserWindow, ipcMain, screen } from "electron";
|
||||||
import { join } from 'path'
|
import { join } from "path";
|
||||||
import { ConfigService } from '../services/config'
|
import { ConfigService } from "../services/config";
|
||||||
|
|
||||||
let notificationWindow: BrowserWindow | null = null
|
// Linux D-Bus通知服务
|
||||||
let closeTimer: NodeJS.Timeout | null = null
|
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() {
|
export function destroyNotificationWindow() {
|
||||||
if (closeTimer) {
|
if (closeTimer) {
|
||||||
clearTimeout(closeTimer)
|
clearTimeout(closeTimer);
|
||||||
closeTimer = null
|
closeTimer = null;
|
||||||
|
}
|
||||||
|
lastNotificationData = null;
|
||||||
|
|
||||||
|
// Linux:关闭通知服务并清理缓存(fire-and-forget,不阻塞退出)
|
||||||
|
if (isLinux && linuxNotificationService) {
|
||||||
|
linuxNotificationService.shutdownLinuxNotificationService().catch((error) => {
|
||||||
|
console.warn("[NotificationWindow] Failed to shutdown Linux notification service:", error);
|
||||||
|
});
|
||||||
|
linuxNotificationService = null;
|
||||||
}
|
}
|
||||||
lastNotificationData = null
|
|
||||||
|
|
||||||
if (!notificationWindow || notificationWindow.isDestroyed()) {
|
if (!notificationWindow || notificationWindow.isDestroyed()) {
|
||||||
notificationWindow = null
|
notificationWindow = null;
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const win = notificationWindow
|
const win = notificationWindow;
|
||||||
notificationWindow = null
|
notificationWindow = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
win.destroy()
|
win.destroy();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[NotificationWindow] Failed to destroy window:', error)
|
console.warn("[NotificationWindow] Failed to destroy window:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createNotificationWindow() {
|
export function createNotificationWindow() {
|
||||||
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
||||||
return notificationWindow
|
return notificationWindow;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
const isDev = !!process.env.VITE_DEV_SERVER_URL;
|
||||||
const iconPath = isDev
|
const iconPath = isDev
|
||||||
? join(__dirname, '../../public/icon.ico')
|
? join(__dirname, "../../public/icon.ico")
|
||||||
: join(process.resourcesPath, 'icon.ico')
|
: join(process.resourcesPath, "icon.ico");
|
||||||
|
|
||||||
console.log('[NotificationWindow] Creating window...')
|
console.log("[NotificationWindow] Creating window...");
|
||||||
const width = 344
|
const width = 344;
|
||||||
const height = 114
|
const height = 114;
|
||||||
|
|
||||||
// Update default creation size
|
// Update default creation size
|
||||||
notificationWindow = new BrowserWindow({
|
notificationWindow = new BrowserWindow({
|
||||||
width: width,
|
width: width,
|
||||||
height: height,
|
height: height,
|
||||||
type: 'toolbar', // 有助于在某些操作系统上保持置顶
|
type: "toolbar", // 有助于在某些操作系统上保持置顶
|
||||||
frame: false,
|
frame: false,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
resizable: false,
|
resizable: false,
|
||||||
@@ -55,15 +78,15 @@ export function createNotificationWindow() {
|
|||||||
focusable: false, // 不抢占焦点
|
focusable: false, // 不抢占焦点
|
||||||
icon: iconPath,
|
icon: iconPath,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: join(__dirname, 'preload.js'), // FIX: Use correct relative path (same dir in dist)
|
preload: join(__dirname, "preload.js"), // FIX: Use correct relative path (same dir in dist)
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
// devTools: true // Enable DevTools
|
// devTools: true // Enable DevTools
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
// notificationWindow.webContents.openDevTools({ mode: 'detach' }) // DEBUG: Force Open DevTools
|
// notificationWindow.webContents.openDevTools({ mode: 'detach' }) // DEBUG: Force Open DevTools
|
||||||
notificationWindow.setIgnoreMouseEvents(true, { forward: true }) // 初始点击穿透
|
notificationWindow.setIgnoreMouseEvents(true, { forward: true }); // 初始点击穿透
|
||||||
|
|
||||||
// 处理鼠标事件 (如果需要从渲染进程转发,但目前特定区域处理?)
|
// 处理鼠标事件 (如果需要从渲染进程转发,但目前特定区域处理?)
|
||||||
// 实际上,我们希望窗口可点击。
|
// 实际上,我们希望窗口可点击。
|
||||||
@@ -71,134 +94,230 @@ export function createNotificationWindow() {
|
|||||||
|
|
||||||
const loadUrl = isDev
|
const loadUrl = isDev
|
||||||
? `${process.env.VITE_DEV_SERVER_URL}#/notification-window`
|
? `${process.env.VITE_DEV_SERVER_URL}#/notification-window`
|
||||||
: `file://${join(__dirname, '../dist/index.html')}#/notification-window`
|
: `file://${join(__dirname, "../dist/index.html")}#/notification-window`;
|
||||||
|
|
||||||
console.log('[NotificationWindow] Loading URL:', loadUrl)
|
console.log("[NotificationWindow] Loading URL:", loadUrl);
|
||||||
notificationWindow.loadURL(loadUrl)
|
notificationWindow.loadURL(loadUrl);
|
||||||
|
|
||||||
notificationWindow.on('closed', () => {
|
notificationWindow.on("closed", () => {
|
||||||
notificationWindow = null
|
notificationWindow = null;
|
||||||
})
|
});
|
||||||
|
|
||||||
return notificationWindow
|
return notificationWindow;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function showNotification(data: any) {
|
export async function showNotification(data: any) {
|
||||||
// 先检查配置
|
// 先检查配置
|
||||||
const config = ConfigService.getInstance()
|
const config = ConfigService.getInstance();
|
||||||
const enabled = await config.get('notificationEnabled')
|
const enabled = await config.get("notificationEnabled");
|
||||||
if (enabled === false) return // 默认为 true
|
if (enabled === false) return; // 默认为 true
|
||||||
|
|
||||||
// 检查会话过滤
|
// 检查会话过滤
|
||||||
const filterMode = config.get('notificationFilterMode') || 'all'
|
const filterMode = config.get("notificationFilterMode") || "all";
|
||||||
const filterList = config.get('notificationFilterList') || []
|
const filterList = config.get("notificationFilterList") || [];
|
||||||
const sessionId = data.sessionId
|
const sessionId = typeof data.sessionId === "string" ? data.sessionId : "";
|
||||||
|
// 系统通知(如 "WeFlow 准备就绪")不是聊天消息,不应受会话白/黑名单影响
|
||||||
|
const isSystemNotification = sessionId.startsWith("weflow-");
|
||||||
|
|
||||||
if (sessionId && filterMode !== 'all' && filterList.length > 0) {
|
if (!isSystemNotification && filterMode !== "all") {
|
||||||
const isInList = filterList.includes(sessionId)
|
const isInList = sessionId !== "" && filterList.includes(sessionId);
|
||||||
if (filterMode === 'whitelist' && !isInList) {
|
if (filterMode === "whitelist" && !isInList) {
|
||||||
// 白名单模式:不在列表中则不显示
|
// 白名单模式:不在列表中则不显示(空列表视为全部拦截)
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
if (filterMode === 'blacklist' && isInList) {
|
if (filterMode === "blacklist" && isInList) {
|
||||||
// 黑名单模式:在列表中则不显示
|
// 黑名单模式:在列表中则不显示
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let win = notificationWindow
|
// Linux 使用 D-Bus 通知
|
||||||
|
if (isLinux) {
|
||||||
|
await showLinuxNotification(data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let win = notificationWindow;
|
||||||
if (!win || win.isDestroyed()) {
|
if (!win || win.isDestroyed()) {
|
||||||
win = createNotificationWindow()
|
win = createNotificationWindow();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!win) return
|
if (!win) return;
|
||||||
|
|
||||||
// 确保加载完成
|
// 确保加载完成
|
||||||
if (win.webContents.isLoading()) {
|
if (win.webContents.isLoading()) {
|
||||||
win.once('ready-to-show', () => {
|
win.once("ready-to-show", () => {
|
||||||
showAndSend(win!, data)
|
showAndSend(win!, data);
|
||||||
})
|
});
|
||||||
} else {
|
} else {
|
||||||
showAndSend(win, data)
|
showAndSend(win, data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let lastNotificationData: any = null
|
// 显示Linux通知
|
||||||
|
async function showLinuxNotification(data: any) {
|
||||||
async function showAndSend(win: BrowserWindow, data: any) {
|
if (!linuxNotificationService) {
|
||||||
lastNotificationData = data
|
try {
|
||||||
const config = ConfigService.getInstance()
|
linuxNotificationService =
|
||||||
const position = (await config.get('notificationPosition')) || 'top-right'
|
await import("../services/linuxNotificationService");
|
||||||
|
} catch (error) {
|
||||||
// 更新位置
|
console.error(
|
||||||
const { width: screenWidth, height: screenHeight } = screen.getPrimaryDisplay().workAreaSize
|
"[NotificationWindow] Failed to load Linux notification service:",
|
||||||
const winWidth = position === 'top-center' ? 280 : 344
|
error,
|
||||||
const winHeight = 114
|
);
|
||||||
const padding = 20
|
return;
|
||||||
|
}
|
||||||
let x = 0
|
|
||||||
let y = 0
|
|
||||||
|
|
||||||
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))
|
const { showLinuxNotification: showNotification } = linuxNotificationService;
|
||||||
win.setSize(winWidth, winHeight) // 确保尺寸
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
// 更新位置
|
||||||
|
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;
|
||||||
|
|
||||||
|
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.setIgnoreMouseEvents(false);
|
||||||
win.showInactive() // 显示但不聚焦
|
win.showInactive(); // 显示但不聚焦
|
||||||
win.setAlwaysOnTop(true, 'screen-saver') // 最高层级
|
win.setAlwaysOnTop(true, "screen-saver"); // 最高层级
|
||||||
|
|
||||||
win.webContents.send('notification:show', { ...data, position })
|
win.webContents.send("notification:show", { ...data, position });
|
||||||
|
|
||||||
// 自动关闭计时器通常由渲染进程管理
|
// 自动关闭计时器通常由渲染进程管理
|
||||||
// 渲染进程发送 'notification:close' 来隐藏窗口
|
// 渲染进程发送 'notification:close' 来隐藏窗口
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerNotificationHandlers() {
|
// 注册通知处理
|
||||||
ipcMain.handle('notification:show', (_, data) => {
|
export async function registerNotificationHandlers() {
|
||||||
showNotification(data)
|
// Linux: 初始化D-Bus服务
|
||||||
})
|
if (isLinux) {
|
||||||
|
try {
|
||||||
|
const linuxNotificationModule =
|
||||||
|
await import("../services/linuxNotificationService");
|
||||||
|
linuxNotificationService = linuxNotificationModule;
|
||||||
|
|
||||||
ipcMain.handle('notification:close', () => {
|
// 初始化服务
|
||||||
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
await linuxNotificationModule.initLinuxNotificationService();
|
||||||
notificationWindow.hide()
|
|
||||||
notificationWindow.setIgnoreMouseEvents(true, { forward: true })
|
// 在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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ipcMain.handle("notification:show", (_, data) => {
|
||||||
|
showNotification(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("notification:close", () => {
|
||||||
|
if (isLinux && linuxNotificationService) {
|
||||||
|
// 注册通知点击回调函数。Linux通知通过D-Bus自动关闭,但我们可以根据需要进行跟踪
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
||||||
|
notificationWindow.hide();
|
||||||
|
notificationWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Handle renderer ready event (fix race condition)
|
// Handle renderer ready event (fix race condition)
|
||||||
ipcMain.on('notification:ready', (event) => {
|
ipcMain.on("notification:ready", (event) => {
|
||||||
console.log('[NotificationWindow] Renderer ready, checking cached data')
|
if (isLinux) {
|
||||||
if (lastNotificationData && notificationWindow && !notificationWindow.isDestroyed()) {
|
// Linux不需要通知窗口,拦截通知窗口渲染
|
||||||
console.log('[NotificationWindow] Re-sending cached data')
|
return;
|
||||||
notificationWindow.webContents.send('notification:show', lastNotificationData)
|
|
||||||
}
|
}
|
||||||
})
|
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
|
// Handle resize request from renderer
|
||||||
ipcMain.on('notification:resize', (event, { width, height }) => {
|
ipcMain.on("notification:resize", (event, { width, height }) => {
|
||||||
|
if (isLinux) {
|
||||||
|
// Linux 通知通过D-Bus自动调整大小
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
if (notificationWindow && !notificationWindow.isDestroyed()) {
|
||||||
// Enforce max-height if needed, or trust renderer
|
// Enforce max-height if needed, or trust renderer
|
||||||
// Ensure it doesn't go off screen bottom?
|
// Ensure it doesn't go off screen bottom?
|
||||||
@@ -213,12 +332,12 @@ export function registerNotificationHandlers() {
|
|||||||
// If bottom-right, y needs to prevent overflow.
|
// If bottom-right, y needs to prevent overflow.
|
||||||
|
|
||||||
// Ideally we get current config position
|
// Ideally we get current config position
|
||||||
const bounds = notificationWindow.getBounds()
|
const bounds = notificationWindow.getBounds();
|
||||||
// Check if we need to adjust Y?
|
// Check if we need to adjust Y?
|
||||||
// For now, let's just set the size as requested.
|
// For now, let's just set the size as requested.
|
||||||
notificationWindow.setSize(Math.round(width), Math.round(height))
|
notificationWindow.setSize(Math.round(width), Math.round(height));
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
// 'notification-clicked' 在 main.ts 中处理 (导航)
|
// 'notification-clicked' 在 main.ts 中处理 (导航)
|
||||||
}
|
}
|
||||||
|
|||||||
4114
package-lock.json
generated
95
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "weflow",
|
"name": "weflow",
|
||||||
"version": "2.1.0",
|
"version": "4.3.0",
|
||||||
"description": "WeFlow",
|
"description": "WeFlow",
|
||||||
"main": "dist-electron/main.js",
|
"main": "dist-electron/main.js",
|
||||||
"author": {
|
"author": {
|
||||||
@@ -13,19 +13,19 @@
|
|||||||
},
|
},
|
||||||
"//": "二改不应改变此处的作者与应用信息",
|
"//": "二改不应改变此处的作者与应用信息",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"postinstall": "electron-builder install-app-deps",
|
"postinstall": "electron-builder install-app-deps && node scripts/prepare-electron-runtime.cjs",
|
||||||
"rebuild": "electron-rebuild",
|
"rebuild": "electron-rebuild",
|
||||||
"dev": "vite",
|
"dev": "node scripts/prepare-electron-runtime.cjs && vite",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"build": "tsc && vite build && electron-builder",
|
"build": "tsc && vite build && electron-builder",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"electron:dev": "vite --mode electron",
|
"electron:dev": "node scripts/prepare-electron-runtime.cjs && vite --mode electron",
|
||||||
"electron:build": "npm run build"
|
"electron:build": "npm run build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"echarts": "^5.5.1",
|
"echarts": "^6.0.0",
|
||||||
"echarts-for-react": "^3.0.2",
|
"echarts-for-react": "^3.0.2",
|
||||||
"electron-store": "^10.0.0",
|
"electron-store": "^11.0.2",
|
||||||
"electron-updater": "^6.3.9",
|
"electron-updater": "^6.3.9",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"ffmpeg-static": "^5.3.0",
|
"ffmpeg-static": "^5.3.0",
|
||||||
@@ -34,11 +34,11 @@
|
|||||||
"jieba-wasm": "^2.2.0",
|
"jieba-wasm": "^2.2.0",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"koffi": "^2.9.0",
|
"koffi": "^2.9.0",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^1.8.0",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.1.1",
|
"react-router-dom": "^7.14.0",
|
||||||
"react-virtuoso": "^4.18.1",
|
"react-virtuoso": "^4.18.1",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"sherpa-onnx-node": "^1.10.38",
|
"sherpa-onnx-node": "^1.10.38",
|
||||||
@@ -52,15 +52,27 @@
|
|||||||
"@types/react": "^19.1.0",
|
"@types/react": "^19.1.0",
|
||||||
"@types/react-dom": "^19.1.0",
|
"@types/react-dom": "^19.1.0",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"electron": "^39.2.7",
|
"electron": "^41.1.1",
|
||||||
"electron-builder": "^25.1.8",
|
"electron-builder": "^26.8.1",
|
||||||
"sass": "^1.83.0",
|
"sass": "^1.98.0",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^6.0.2",
|
||||||
"vite": "^6.0.5",
|
"vite": "^7.0.0",
|
||||||
"vite-plugin-electron": "^0.28.8",
|
"vite-plugin-electron": "^0.28.8",
|
||||||
"vite-plugin-electron-renderer": "^0.14.6"
|
"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"
|
||||||
|
}
|
||||||
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"appId": "com.WeFlow.app",
|
"appId": "com.WeFlow.app",
|
||||||
"publish": {
|
"publish": {
|
||||||
@@ -84,24 +96,47 @@
|
|||||||
"gatekeeperAssess": false,
|
"gatekeeperAssess": false,
|
||||||
"entitlements": "electron/entitlements.mac.plist",
|
"entitlements": "electron/entitlements.mac.plist",
|
||||||
"entitlementsInherit": "electron/entitlements.mac.plist",
|
"entitlementsInherit": "electron/entitlements.mac.plist",
|
||||||
"icon": "resources/icon.icns"
|
"icon": "resources/icons/macos/icon.icns"
|
||||||
},
|
},
|
||||||
"win": {
|
"win": {
|
||||||
"target": [
|
"target": [
|
||||||
"nsis"
|
"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": {
|
"linux": {
|
||||||
"icon": "public/icon.png",
|
"icon": "public/icon.png",
|
||||||
"target": [
|
"target": [
|
||||||
"appimage",
|
"appimage",
|
||||||
"deb",
|
|
||||||
"tar.gz"
|
"tar.gz"
|
||||||
],
|
],
|
||||||
"category": "Utility",
|
"category": "Utility",
|
||||||
"executableName": "weflow",
|
"executableName": "weflow",
|
||||||
"synopsis": "WeFlow for Linux"
|
"synopsis": "WeFlow for Linux",
|
||||||
|
"extraFiles": [
|
||||||
|
{
|
||||||
|
"from": "resources/installer/linux/install.sh",
|
||||||
|
"to": "install.sh"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"nsis": {
|
"nsis": {
|
||||||
"oneClick": false,
|
"oneClick": false,
|
||||||
@@ -151,26 +186,14 @@
|
|||||||
"node_modules/sherpa-onnx-node/**/*",
|
"node_modules/sherpa-onnx-node/**/*",
|
||||||
"node_modules/sherpa-onnx-*/*",
|
"node_modules/sherpa-onnx-*/*",
|
||||||
"node_modules/sherpa-onnx-*/**/*",
|
"node_modules/sherpa-onnx-*/**/*",
|
||||||
"node_modules/ffmpeg-static/**/*"
|
"node_modules/ffmpeg-static/**/*",
|
||||||
|
"resources/wedecrypt/**/*.node"
|
||||||
],
|
],
|
||||||
"extraFiles": [
|
"icon": "resources/icons/macos/icon.icns"
|
||||||
{
|
|
||||||
"from": "resources/msvcp140.dll",
|
|
||||||
"to": "."
|
|
||||||
},
|
},
|
||||||
{
|
"overrides": {
|
||||||
"from": "resources/msvcp140_1.dll",
|
"picomatch": "^4.0.4",
|
||||||
"to": "."
|
"tar": "^7.5.13",
|
||||||
},
|
"immutable": "^5.1.5"
|
||||||
{
|
|
||||||
"from": "resources/vcruntime140.dll",
|
|
||||||
"to": "."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"from": "resources/vcruntime140_1.dll",
|
|
||||||
"to": "."
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"icon": "resources/icon.icns"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
public/icon.ico
|
Before Width: | Height: | Size: 212 KiB After Width: | Height: | Size: 364 KiB |
BIN
public/icon.png
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 570 KiB |
BIN
public/logo.png
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 570 KiB |
BIN
resources/fonts/annual-report/CormorantGaramond-Var.ttf
Normal file
BIN
resources/fonts/annual-report/Inter-Var.ttf
Normal file
BIN
resources/fonts/annual-report/NotoSerifSC-Var.ttf
Normal file
BIN
resources/fonts/annual-report/PlayfairDisplay-Var.ttf
Normal file
BIN
resources/fonts/annual-report/SpaceMono-Bold.ttf
Normal file
BIN
resources/fonts/annual-report/SpaceMono-Regular.ttf
Normal file
30
resources/installer/linux/PKGBUILD
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Maintainer: H3CoF6 <h3cof6@gmail.com>
|
||||||
|
pkgname=weflow
|
||||||
|
pkgver=4.3.0
|
||||||
|
pkgrel=1
|
||||||
|
pkgdesc="A local WeChat database decryption and analysis tool"
|
||||||
|
arch=('x86_64')
|
||||||
|
url="https://github.com/hicccc77/weflow"
|
||||||
|
license=('CC-BY-NC-SA-4.0')
|
||||||
|
depends=('alsa-lib' 'gtk3' 'nss' 'glibc')
|
||||||
|
options=('!strip' '!debug')
|
||||||
|
|
||||||
|
source=("WeFlow-${pkgver}-Setup.tar.gz::${url}/releases/download/v${pkgver}/WeFlow-${pkgver}-Setup.tar.gz"
|
||||||
|
"weflow.desktop"
|
||||||
|
"icon.png")
|
||||||
|
|
||||||
|
sha256sums=('2859aca2f57c42f4d1516ed229613623c57d3e78b9cb152fcb2b9c1096ab9340'
|
||||||
|
'2cf03766f5c2f1915ad136f060a66f5788ed32b06defe1956e406c73d7e733b7'
|
||||||
|
'b1c412d9c08ae683e231173c16fe73958ad1063f14c9b3852373385e4fcb6f33')
|
||||||
|
|
||||||
|
package() {
|
||||||
|
install -dm755 "${pkgdir}/opt/${pkgname}"
|
||||||
|
|
||||||
|
cp -a "${srcdir}/WeFlow-${pkgver}-Setup/"* "${pkgdir}/opt/${pkgname}/"
|
||||||
|
|
||||||
|
install -dm755 "${pkgdir}/usr/bin"
|
||||||
|
ln -s "/opt/${pkgname}/weflow" "${pkgdir}/usr/bin/${pkgname}"
|
||||||
|
|
||||||
|
install -Dm644 "${srcdir}/weflow.desktop" -t "${pkgdir}/usr/share/applications/"
|
||||||
|
install -Dm644 "${srcdir}/icon.png" "${pkgdir}/usr/share/pixmaps/${pkgname}.png"
|
||||||
|
}
|
||||||
BIN
resources/installer/linux/icon.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
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' 启动。"
|
||||||
9
resources/installer/linux/weflow.desktop
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Name=WeFlow
|
||||||
|
Comment=一个本地的微信聊天记录导出和年度报告应用
|
||||||
|
Exec=/usr/bin/weflow %U
|
||||||
|
Terminal=false
|
||||||
|
Type=Application
|
||||||
|
Icon=weflow
|
||||||
|
StartupWMClass=WeFlow
|
||||||
|
Categories=Utility;
|
||||||
BIN
resources/key/linux/x64/xkey_helper_linux
Executable 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
BIN
resources/key/macos/universal/xkey_helper
Normal file
BIN
resources/key/macos/universal/xkey_helper_macos
Normal file
BIN
resources/linux/libwcdb_api.so → resources/wcdb/linux/x64/libwcdb_api.so
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/win32/arm64/wcdb_api.dll
Normal file
BIN
resources/wcdb/win32/x64/wcdb_api.dll
Normal file
BIN
resources/wedecrypt/linux/x64/weflow-image-native-linux-x64.node
Normal file
BIN
resources/wedecrypt/win32/x64/weflow-image-native-win32-x64.node
Normal file
57
scripts/prepare-electron-runtime.cjs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const runtimeNames = [
|
||||||
|
'msvcp140.dll',
|
||||||
|
'msvcp140_1.dll',
|
||||||
|
'vcruntime140.dll',
|
||||||
|
'vcruntime140_1.dll',
|
||||||
|
];
|
||||||
|
|
||||||
|
function copyIfDifferent(sourcePath, targetPath) {
|
||||||
|
const source = fs.statSync(sourcePath);
|
||||||
|
const targetExists = fs.existsSync(targetPath);
|
||||||
|
|
||||||
|
if (targetExists) {
|
||||||
|
const target = fs.statSync(targetPath);
|
||||||
|
if (target.size === source.size && target.mtimeMs >= source.mtimeMs) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.copyFileSync(sourcePath, targetPath);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectRoot = path.resolve(__dirname, '..');
|
||||||
|
const sourceDir = path.join(projectRoot, 'resources', 'runtime', 'win32');
|
||||||
|
const targetDir = path.join(projectRoot, 'node_modules', 'electron', 'dist');
|
||||||
|
|
||||||
|
if (!fs.existsSync(sourceDir) || !fs.existsSync(targetDir)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let copiedCount = 0;
|
||||||
|
|
||||||
|
for (const name of runtimeNames) {
|
||||||
|
const sourcePath = path.join(sourceDir, name);
|
||||||
|
const targetPath = path.join(targetDir, name);
|
||||||
|
if (!fs.existsSync(sourcePath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (copyIfDifferent(sourcePath, targetPath)) {
|
||||||
|
copiedCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (copiedCount > 0) {
|
||||||
|
console.log(`[prepare-electron-runtime] synced ${copiedCount} runtime DLL(s) to ${targetDir}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
141
src/App.tsx
@@ -17,12 +17,16 @@ import AgreementPage from './pages/AgreementPage'
|
|||||||
import GroupAnalyticsPage from './pages/GroupAnalyticsPage'
|
import GroupAnalyticsPage from './pages/GroupAnalyticsPage'
|
||||||
import SettingsPage from './pages/SettingsPage'
|
import SettingsPage from './pages/SettingsPage'
|
||||||
import ExportPage from './pages/ExportPage'
|
import ExportPage from './pages/ExportPage'
|
||||||
|
import MyFootprintPage from './pages/MyFootprintPage'
|
||||||
import VideoWindow from './pages/VideoWindow'
|
import VideoWindow from './pages/VideoWindow'
|
||||||
import ImageWindow from './pages/ImageWindow'
|
import ImageWindow from './pages/ImageWindow'
|
||||||
import SnsPage from './pages/SnsPage'
|
import SnsPage from './pages/SnsPage'
|
||||||
|
import BizPage from './pages/BizPage'
|
||||||
import ContactsPage from './pages/ContactsPage'
|
import ContactsPage from './pages/ContactsPage'
|
||||||
|
import ResourcesPage from './pages/ResourcesPage'
|
||||||
import ChatHistoryPage from './pages/ChatHistoryPage'
|
import ChatHistoryPage from './pages/ChatHistoryPage'
|
||||||
import NotificationWindow from './pages/NotificationWindow'
|
import NotificationWindow from './pages/NotificationWindow'
|
||||||
|
import AccountManagementPage from './pages/AccountManagementPage'
|
||||||
|
|
||||||
import { useAppStore } from './stores/appStore'
|
import { useAppStore } from './stores/appStore'
|
||||||
import { themes, useThemeStore, type ThemeId, type ThemeMode } from './stores/themeStore'
|
import { themes, useThemeStore, type ThemeId, type ThemeMode } from './stores/themeStore'
|
||||||
@@ -35,8 +39,6 @@ import UpdateDialog from './components/UpdateDialog'
|
|||||||
import UpdateProgressCapsule from './components/UpdateProgressCapsule'
|
import UpdateProgressCapsule from './components/UpdateProgressCapsule'
|
||||||
import LockScreen from './components/LockScreen'
|
import LockScreen from './components/LockScreen'
|
||||||
import { GlobalSessionMonitor } from './components/GlobalSessionMonitor'
|
import { GlobalSessionMonitor } from './components/GlobalSessionMonitor'
|
||||||
import { BatchTranscribeGlobal } from './components/BatchTranscribeGlobal'
|
|
||||||
import { BatchImageDecryptGlobal } from './components/BatchImageDecryptGlobal'
|
|
||||||
import WindowCloseDialog from './components/WindowCloseDialog'
|
import WindowCloseDialog from './components/WindowCloseDialog'
|
||||||
|
|
||||||
function RouteStateRedirect({ to }: { to: string }) {
|
function RouteStateRedirect({ to }: { to: string }) {
|
||||||
@@ -78,6 +80,7 @@ function App() {
|
|||||||
const isChatHistoryWindow = location.pathname.startsWith('/chat-history/') || location.pathname.startsWith('/chat-history-inline/')
|
const isChatHistoryWindow = location.pathname.startsWith('/chat-history/') || location.pathname.startsWith('/chat-history-inline/')
|
||||||
const isStandaloneChatWindow = location.pathname === '/chat-window'
|
const isStandaloneChatWindow = location.pathname === '/chat-window'
|
||||||
const isNotificationWindow = location.pathname === '/notification-window'
|
const isNotificationWindow = location.pathname === '/notification-window'
|
||||||
|
const isAnnualReportWindow = location.pathname === '/annual-report/view'
|
||||||
const isSettingsRoute = location.pathname === '/settings'
|
const isSettingsRoute = location.pathname === '/settings'
|
||||||
const settingsRouteState = location.state as { backgroundLocation?: Location; initialTab?: unknown } | null
|
const settingsRouteState = location.state as { backgroundLocation?: Location; initialTab?: unknown } | null
|
||||||
const routeLocation = isSettingsRoute
|
const routeLocation = isSettingsRoute
|
||||||
@@ -103,44 +106,7 @@ function App() {
|
|||||||
|
|
||||||
// 数据收集同意状态
|
// 数据收集同意状态
|
||||||
const [showAnalyticsConsent, setShowAnalyticsConsent] = useState(false)
|
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(() => {
|
useEffect(() => {
|
||||||
if (location.pathname !== '/settings') {
|
if (location.pathname !== '/settings') {
|
||||||
@@ -162,7 +128,7 @@ function App() {
|
|||||||
const body = document.body
|
const body = document.body
|
||||||
const appRoot = document.getElementById('app')
|
const appRoot = document.getElementById('app')
|
||||||
|
|
||||||
if (isOnboardingWindow || isNotificationWindow) {
|
if (isOnboardingWindow || isNotificationWindow || isAnnualReportWindow) {
|
||||||
root.style.background = 'transparent'
|
root.style.background = 'transparent'
|
||||||
body.style.background = 'transparent'
|
body.style.background = 'transparent'
|
||||||
body.style.overflow = 'hidden'
|
body.style.overflow = 'hidden'
|
||||||
@@ -179,7 +145,7 @@ function App() {
|
|||||||
appRoot.style.overflow = ''
|
appRoot.style.overflow = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isOnboardingWindow])
|
}, [isOnboardingWindow, isNotificationWindow, isAnnualReportWindow])
|
||||||
|
|
||||||
// 应用主题
|
// 应用主题
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -200,7 +166,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
mq.addEventListener('change', handler)
|
mq.addEventListener('change', handler)
|
||||||
return () => mq.removeEventListener('change', handler)
|
return () => mq.removeEventListener('change', handler)
|
||||||
}, [currentTheme, themeMode, isOnboardingWindow, isNotificationWindow])
|
}, [currentTheme, themeMode, isOnboardingWindow, isNotificationWindow, isAnnualReportWindow])
|
||||||
|
|
||||||
// 读取已保存的主题设置
|
// 读取已保存的主题设置
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -252,6 +218,7 @@ function App() {
|
|||||||
// 协议已同意,检查数据收集同意状态
|
// 协议已同意,检查数据收集同意状态
|
||||||
const consent = await configService.getAnalyticsConsent()
|
const consent = await configService.getAnalyticsConsent()
|
||||||
const denyCount = await configService.getAnalyticsDenyCount()
|
const denyCount = await configService.getAnalyticsDenyCount()
|
||||||
|
setAnalyticsConsent(consent)
|
||||||
// 如果未设置同意状态且拒绝次数小于2次,显示弹窗
|
// 如果未设置同意状态且拒绝次数小于2次,显示弹窗
|
||||||
if (consent === null && denyCount < 2) {
|
if (consent === null && denyCount < 2) {
|
||||||
setShowAnalyticsConsent(true)
|
setShowAnalyticsConsent(true)
|
||||||
@@ -266,18 +233,21 @@ function App() {
|
|||||||
checkAgreement()
|
checkAgreement()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// 初始化数据收集
|
// 初始化数据收集(仅在用户同意后)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (analyticsConsent === true) {
|
||||||
cloudControl.initCloudControl()
|
cloudControl.initCloudControl()
|
||||||
}, [])
|
}
|
||||||
|
}, [analyticsConsent])
|
||||||
|
|
||||||
// 记录页面访问
|
// 记录页面访问(仅在用户同意后)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (analyticsConsent !== true) return
|
||||||
const path = location.pathname
|
const path = location.pathname
|
||||||
if (path && path !== '/') {
|
if (path && path !== '/') {
|
||||||
cloudControl.recordPage(path)
|
cloudControl.recordPage(path)
|
||||||
}
|
}
|
||||||
}, [location.pathname])
|
}, [location.pathname, analyticsConsent])
|
||||||
|
|
||||||
const handleAgree = async () => {
|
const handleAgree = async () => {
|
||||||
if (!agreementChecked) return
|
if (!agreementChecked) return
|
||||||
@@ -296,6 +266,7 @@ function App() {
|
|||||||
|
|
||||||
const handleAnalyticsAllow = async () => {
|
const handleAnalyticsAllow = async () => {
|
||||||
await configService.setAnalyticsConsent(true)
|
await configService.setAnalyticsConsent(true)
|
||||||
|
setAnalyticsConsent(true)
|
||||||
setShowAnalyticsConsent(false)
|
setShowAnalyticsConsent(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,10 +283,14 @@ function App() {
|
|||||||
const removeUpdateListener = window.electronAPI?.app?.onUpdateAvailable?.((info: any) => {
|
const removeUpdateListener = window.electronAPI?.app?.onUpdateAvailable?.((info: any) => {
|
||||||
// 发现新版本时保存更新信息,锁定状态下不弹窗,解锁后再显示
|
// 发现新版本时保存更新信息,锁定状态下不弹窗,解锁后再显示
|
||||||
if (info) {
|
if (info) {
|
||||||
setUpdateInfo({ ...info, hasUpdate: 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) {
|
if (!useAppStore.getState().isLocked) {
|
||||||
setShowUpdateDialog(true)
|
setShowUpdateDialog(true)
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const removeProgressListener = window.electronAPI?.app?.onDownloadProgress?.((progress: any) => {
|
const removeProgressListener = window.electronAPI?.app?.onDownloadProgress?.((progress: any) => {
|
||||||
@@ -327,6 +302,21 @@ function App() {
|
|||||||
}
|
}
|
||||||
}, [setUpdateInfo, setDownloadProgress, setShowUpdateDialog, isNotificationWindow])
|
}, [setUpdateInfo, setDownloadProgress, setShowUpdateDialog, isNotificationWindow])
|
||||||
|
|
||||||
|
// 监听通知点击导航事件
|
||||||
|
useEffect(() => {
|
||||||
|
if (isNotificationWindow) return
|
||||||
|
|
||||||
|
const removeListener = window.electronAPI?.notification?.onNavigateToSession?.((sessionId: string) => {
|
||||||
|
if (!sessionId) return
|
||||||
|
// 导航到聊天页面,通过URL参数让ChatPage接收sessionId
|
||||||
|
navigate(`/chat?sessionId=${encodeURIComponent(sessionId)}`, { replace: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
removeListener?.()
|
||||||
|
}
|
||||||
|
}, [navigate, isNotificationWindow])
|
||||||
|
|
||||||
// 解锁后显示暂存的更新弹窗
|
// 解锁后显示暂存的更新弹窗
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLocked && updateInfo?.hasUpdate && !showUpdateDialog && !isDownloading) {
|
if (!isLocked && updateInfo?.hasUpdate && !showUpdateDialog && !isDownloading) {
|
||||||
@@ -419,7 +409,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
// 如果错误信息包含 VC++ 或 DLL 相关内容,不清除配置,只提示用户
|
// 如果错误信息包含 VC++ 或数据服务相关内容,不清除配置,只提示用户
|
||||||
// 其他错误可能需要重新配置
|
// 其他错误可能需要重新配置
|
||||||
const errorMsg = result.error || ''
|
const errorMsg = result.error || ''
|
||||||
if (errorMsg.includes('Visual C++') ||
|
if (errorMsg.includes('Visual C++') ||
|
||||||
@@ -522,6 +512,11 @@ function App() {
|
|||||||
return <NotificationWindow />
|
return <NotificationWindow />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 独立年度报告全屏窗口
|
||||||
|
if (isAnnualReportWindow) {
|
||||||
|
return <AnnualReportWindow />
|
||||||
|
}
|
||||||
|
|
||||||
// 主窗口 - 完整布局
|
// 主窗口 - 完整布局
|
||||||
const handleCloseSettings = () => {
|
const handleCloseSettings = () => {
|
||||||
const backgroundLocation = settingsRouteState?.backgroundLocation ?? settingsBackgroundRef.current
|
const backgroundLocation = settingsRouteState?.backgroundLocation ?? settingsBackgroundRef.current
|
||||||
@@ -563,10 +558,6 @@ function App() {
|
|||||||
{/* 全局会话监听与通知 */}
|
{/* 全局会话监听与通知 */}
|
||||||
<GlobalSessionMonitor />
|
<GlobalSessionMonitor />
|
||||||
|
|
||||||
{/* 全局批量转写进度浮窗 */}
|
|
||||||
<BatchTranscribeGlobal />
|
|
||||||
<BatchImageDecryptGlobal />
|
|
||||||
|
|
||||||
{/* 用户协议弹窗 */}
|
{/* 用户协议弹窗 */}
|
||||||
{showAgreement && !agreementLoading && (
|
{showAgreement && !agreementLoading && (
|
||||||
<div className="agreement-overlay">
|
<div className="agreement-overlay">
|
||||||
@@ -580,9 +571,13 @@ function App() {
|
|||||||
<div className="agreement-notice">
|
<div className="agreement-notice">
|
||||||
<strong>这是免费软件,如果你是付费购买的话请骂死那个骗子。</strong>
|
<strong>这是免费软件,如果你是付费购买的话请骂死那个骗子。</strong>
|
||||||
<span className="agreement-notice-link">
|
<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">
|
<a href="https://github.com/hicccc77/WeFlow" target="_blank" rel="noreferrer">
|
||||||
https://github.com/hicccc77/WeFlow
|
GitHub 仓库
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -597,7 +592,7 @@ function App() {
|
|||||||
<p>因使用本软件产生的任何直接或间接损失,开发者不承担任何责任。请确保你的使用行为符合当地法律法规。</p>
|
<p>因使用本软件产生的任何直接或间接损失,开发者不承担任何责任。请确保你的使用行为符合当地法律法规。</p>
|
||||||
|
|
||||||
<h4>4. 隐私保护</h4>
|
<h4>4. 隐私保护</h4>
|
||||||
<p>本软件不收集任何用户数据。软件更新检测仅获取版本信息,不涉及任何个人隐私。</p>
|
<p>本软件不收集任何用户隐私数据。软件更新检测仅获取版本信息,不涉及任何个人隐私。</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="agreement-footer">
|
<div className="agreement-footer">
|
||||||
@@ -654,41 +649,15 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showWaylandWarning && (
|
|
||||||
<div className="agreement-overlay">
|
|
||||||
<div className="agreement-modal">
|
|
||||||
<div className="agreement-header">
|
|
||||||
<Shield size={32} />
|
|
||||||
<h2>环境兼容性提示 (Wayland)</h2>
|
|
||||||
</div>
|
|
||||||
<div className="agreement-content">
|
|
||||||
<div className="agreement-text">
|
|
||||||
<p>检测到您当前正在使用 <strong>Wayland</strong> 显示服务器。</p>
|
|
||||||
<p>在 Wayland 环境下,出于系统级的安全与设计机制,<strong>应用程序无法直接控制新弹出窗口的位置</strong>。</p>
|
|
||||||
<p>这可能导致某些独立窗口(如消息通知、图片查看器等)出现位置随机、或不受控制的情况。这是底层机制导致的,对此我们无能为力。</p>
|
|
||||||
<br />
|
|
||||||
<p>如果您觉得窗口位置异常严重影响了使用体验,建议尝试:</p>
|
|
||||||
<p>1. 在系统登录界面,将会话切换回 <strong>X11 (Xorg)</strong> 模式。</p>
|
|
||||||
<p>2. 修改您的桌面管理器 (WM/DE) 配置,强制指定该应用程序的窗口规则。</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="agreement-footer">
|
|
||||||
<div className="agreement-actions">
|
|
||||||
<button className="btn btn-primary" onClick={handleDismissWaylandWarning}>我知道了,不再提示</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 更新提示对话框 */}
|
{/* 更新提示对话框 */}
|
||||||
<UpdateDialog
|
<UpdateDialog
|
||||||
open={showUpdateDialog}
|
open={showUpdateDialog}
|
||||||
updateInfo={updateInfo}
|
updateInfo={updateInfo}
|
||||||
onClose={() => setShowUpdateDialog(false)}
|
onClose={() => { if (!(updateInfo as any)?.isMandatory) setShowUpdateDialog(false) }}
|
||||||
onUpdate={handleUpdateNow}
|
onUpdate={handleUpdateNow}
|
||||||
onIgnore={handleIgnoreUpdate}
|
onIgnore={handleIgnoreUpdate}
|
||||||
isDownloading={isDownloading}
|
isDownloading={isDownloading}
|
||||||
|
isMandatory={!!(updateInfo as any)?.isMandatory}
|
||||||
progress={downloadProgress}
|
progress={downloadProgress}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -710,6 +679,7 @@ function App() {
|
|||||||
<Routes location={routeLocation}>
|
<Routes location={routeLocation}>
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
<Route path="/home" element={<HomePage />} />
|
<Route path="/home" element={<HomePage />} />
|
||||||
|
<Route path="/account-management" element={<AccountManagementPage />} />
|
||||||
<Route path="/chat" element={<ChatPage />} />
|
<Route path="/chat" element={<ChatPage />} />
|
||||||
|
|
||||||
<Route path="/analytics" element={<ChatAnalyticsHubPage />} />
|
<Route path="/analytics" element={<ChatAnalyticsHubPage />} />
|
||||||
@@ -722,10 +692,13 @@ function App() {
|
|||||||
<Route path="/annual-report/view" element={<AnnualReportWindow />} />
|
<Route path="/annual-report/view" element={<AnnualReportWindow />} />
|
||||||
<Route path="/dual-report" element={<DualReportPage />} />
|
<Route path="/dual-report" element={<DualReportPage />} />
|
||||||
<Route path="/dual-report/view" element={<DualReportWindow />} />
|
<Route path="/dual-report/view" element={<DualReportWindow />} />
|
||||||
|
<Route path="/footprint" element={<MyFootprintPage />} />
|
||||||
|
|
||||||
<Route path="/export" element={<div className="export-route-anchor" aria-hidden="true" />} />
|
<Route path="/export" element={<div className="export-route-anchor" aria-hidden="true" />} />
|
||||||
<Route path="/sns" element={<SnsPage />} />
|
<Route path="/sns" element={<SnsPage />} />
|
||||||
|
<Route path="/biz" element={<BizPage />} />
|
||||||
<Route path="/contacts" element={<ContactsPage />} />
|
<Route path="/contacts" element={<ContactsPage />} />
|
||||||
|
<Route path="/resources" element={<ResourcesPage />} />
|
||||||
<Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} />
|
<Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} />
|
||||||
<Route path="/chat-history-inline/:payloadId" element={<ChatHistoryPage />} />
|
<Route path="/chat-history-inline/:payloadId" element={<ChatHistoryPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -54,10 +54,11 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(100% + 8px);
|
top: calc(100% + 8px);
|
||||||
right: 0;
|
right: 0;
|
||||||
background: var(--card-bg);
|
background: var(--bg-secondary-solid, var(--bg-primary, var(--card-bg)));
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
|
||||||
backdrop-filter: blur(20px);
|
backdrop-filter: none;
|
||||||
|
-webkit-backdrop-filter: none;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -29,6 +29,20 @@ function DateRangePicker({ startDate, endDate, onStartDateChange, onEndDateChang
|
|||||||
const [showYearMonthPicker, setShowYearMonthPicker] = useState(false)
|
const [showYearMonthPicker, setShowYearMonthPicker] = useState(false)
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const [internalStart, setInternalStart] = useState(startDate)
|
||||||
|
const [internalEnd, setInternalEnd] = useState(endDate)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setInternalStart(startDate)
|
||||||
|
setInternalEnd(endDate)
|
||||||
|
}, [startDate, endDate])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setSelectingStart(true)
|
||||||
|
}
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
// 点击外部关闭
|
// 点击外部关闭
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (e: MouseEvent) => {
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
@@ -63,8 +77,10 @@ function DateRangePicker({ startDate, endDate, onStartDateChange, onEndDateChang
|
|||||||
const end = new Date()
|
const end = new Date()
|
||||||
const start = new Date()
|
const start = new Date()
|
||||||
start.setDate(start.getDate() - days)
|
start.setDate(start.getDate() - days)
|
||||||
onStartDateChange(start.toISOString().split('T')[0])
|
const startStr = `${start.getFullYear()}-${String(start.getMonth() + 1).padStart(2, '0')}-${String(start.getDate()).padStart(2, '0')}`
|
||||||
onEndDateChange(end.toISOString().split('T')[0])
|
const endStr = `${end.getFullYear()}-${String(end.getMonth() + 1).padStart(2, '0')}-${String(end.getDate()).padStart(2, '0')}`
|
||||||
|
onStartDateChange(startStr)
|
||||||
|
onEndDateChange(endStr)
|
||||||
}
|
}
|
||||||
setIsOpen(false)
|
setIsOpen(false)
|
||||||
setTimeout(() => onRangeComplete?.(), 0)
|
setTimeout(() => onRangeComplete?.(), 0)
|
||||||
@@ -89,38 +105,46 @@ function DateRangePicker({ startDate, endDate, onStartDateChange, onEndDateChang
|
|||||||
const dateStr = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
const dateStr = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||||
|
|
||||||
if (selectingStart) {
|
if (selectingStart) {
|
||||||
onStartDateChange(dateStr)
|
setInternalStart(dateStr)
|
||||||
if (endDate && dateStr > endDate) {
|
if (internalEnd && dateStr > internalEnd) {
|
||||||
onEndDateChange('')
|
setInternalEnd('')
|
||||||
}
|
}
|
||||||
setSelectingStart(false)
|
setSelectingStart(false)
|
||||||
} else {
|
} else {
|
||||||
if (dateStr < startDate) {
|
let finalStart = internalStart
|
||||||
onStartDateChange(dateStr)
|
let finalEnd = dateStr
|
||||||
onEndDateChange(startDate)
|
|
||||||
} else {
|
if (dateStr < internalStart) {
|
||||||
onEndDateChange(dateStr)
|
finalStart = dateStr
|
||||||
|
finalEnd = internalStart
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setInternalStart(finalStart)
|
||||||
|
setInternalEnd(finalEnd)
|
||||||
|
|
||||||
setSelectingStart(true)
|
setSelectingStart(true)
|
||||||
setIsOpen(false)
|
setIsOpen(false)
|
||||||
|
|
||||||
|
onStartDateChange(finalStart)
|
||||||
|
onEndDateChange(finalEnd)
|
||||||
setTimeout(() => onRangeComplete?.(), 0)
|
setTimeout(() => onRangeComplete?.(), 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isInRange = (day: number) => {
|
const isInRange = (day: number) => {
|
||||||
if (!startDate || !endDate) return false
|
if (!internalStart || !internalEnd) return false
|
||||||
const dateStr = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
const dateStr = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||||
return dateStr >= startDate && dateStr <= endDate
|
return dateStr >= internalStart && dateStr <= internalEnd
|
||||||
}
|
}
|
||||||
|
|
||||||
const isStartDate = (day: number) => {
|
const isStartDate = (day: number) => {
|
||||||
const dateStr = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
const dateStr = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||||
return dateStr === startDate
|
return dateStr === internalStart
|
||||||
}
|
}
|
||||||
|
|
||||||
const isEndDate = (day: number) => {
|
const isEndDate = (day: number) => {
|
||||||
const dateStr = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
const dateStr = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||||
return dateStr === endDate
|
return dateStr === internalEnd
|
||||||
}
|
}
|
||||||
|
|
||||||
const isToday = (day: number) => {
|
const isToday = (day: number) => {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
z-index: 2400;
|
z-index: 9200;
|
||||||
}
|
}
|
||||||
|
|
||||||
.export-date-range-dialog {
|
.export-date-range-dialog {
|
||||||
@@ -192,6 +192,149 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.export-date-range-time-select {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&.open .export-date-range-time-trigger {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 1px rgba(var(--primary-rgb), 0.18);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-time-trigger {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 9px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: inherit;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease, color 0.15s ease;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 1px rgba(var(--primary-rgb), 0.18);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-time-trigger-value {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-time-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 24;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: color-mix(in srgb, var(--bg-primary) 88%, var(--bg-secondary));
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
padding: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
-webkit-backdrop-filter: blur(14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-time-dropdown-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-time-quick-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-time-quick-item,
|
||||||
|
.export-date-range-time-option {
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: rgba(var(--primary-rgb), 0.28);
|
||||||
|
background: rgba(var(--primary-rgb), 0.12);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-time-quick-item {
|
||||||
|
min-width: 52px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-time-columns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-time-column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-time-column-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-time-column-list {
|
||||||
|
max-height: 168px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 2px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date-range-time-option {
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
.export-date-range-calendar-nav {
|
.export-date-range-calendar-nav {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { Check, ChevronLeft, ChevronRight, X } from 'lucide-react'
|
import { Check, ChevronDown, ChevronLeft, ChevronRight, X } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
EXPORT_DATE_RANGE_PRESETS,
|
EXPORT_DATE_RANGE_PRESETS,
|
||||||
WEEKDAY_SHORT_LABELS,
|
WEEKDAY_SHORT_LABELS,
|
||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
createDateRangeByPreset,
|
createDateRangeByPreset,
|
||||||
createDefaultDateRange,
|
createDefaultDateRange,
|
||||||
formatCalendarMonthTitle,
|
formatCalendarMonthTitle,
|
||||||
formatDateInputValue,
|
|
||||||
isSameDay,
|
isSameDay,
|
||||||
parseDateInputValue,
|
parseDateInputValue,
|
||||||
startOfDay,
|
startOfDay,
|
||||||
@@ -37,6 +36,10 @@ interface ExportDateRangeDialogDraft extends ExportDateRangeSelection {
|
|||||||
panelMonth: Date
|
panelMonth: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const HOUR_OPTIONS = Array.from({ length: 24 }, (_, index) => `${index}`.padStart(2, '0'))
|
||||||
|
const MINUTE_OPTIONS = Array.from({ length: 60 }, (_, index) => `${index}`.padStart(2, '0'))
|
||||||
|
const QUICK_TIME_OPTIONS = ['00:00', '08:00', '12:00', '18:00', '23:59']
|
||||||
|
|
||||||
const resolveBounds = (minDate?: Date | null, maxDate?: Date | null): { minDate: Date; maxDate: Date } | null => {
|
const resolveBounds = (minDate?: Date | null, maxDate?: Date | null): { minDate: Date; maxDate: Date } | null => {
|
||||||
if (!(minDate instanceof Date) || Number.isNaN(minDate.getTime())) return null
|
if (!(minDate instanceof Date) || Number.isNaN(minDate.getTime())) return null
|
||||||
if (!(maxDate instanceof Date) || Number.isNaN(maxDate.getTime())) return null
|
if (!(maxDate instanceof Date) || Number.isNaN(maxDate.getTime())) return null
|
||||||
@@ -57,16 +60,42 @@ const clampSelectionToBounds = (
|
|||||||
const bounds = resolveBounds(minDate, maxDate)
|
const bounds = resolveBounds(minDate, maxDate)
|
||||||
if (!bounds) return cloneExportDateRangeSelection(value)
|
if (!bounds) return cloneExportDateRangeSelection(value)
|
||||||
|
|
||||||
const rawStart = value.useAllTime ? bounds.minDate : startOfDay(value.dateRange.start)
|
// For custom selections, only ensure end >= start, preserve time precision
|
||||||
const rawEnd = value.useAllTime ? bounds.maxDate : endOfDay(value.dateRange.end)
|
if (value.preset === 'custom' && !value.useAllTime) {
|
||||||
const nextStart = new Date(Math.min(Math.max(rawStart.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime()))
|
const { start, end } = value.dateRange
|
||||||
const nextEndCandidate = new Date(Math.min(Math.max(rawEnd.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime()))
|
if (end.getTime() < start.getTime()) {
|
||||||
const nextEnd = nextEndCandidate.getTime() < nextStart.getTime() ? endOfDay(nextStart) : nextEndCandidate
|
return {
|
||||||
const changed = nextStart.getTime() !== rawStart.getTime() || nextEnd.getTime() !== rawEnd.getTime()
|
...value,
|
||||||
|
dateRange: { start, end: start }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cloneExportDateRangeSelection(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// For useAllTime, use bounds directly
|
||||||
|
if (value.useAllTime) {
|
||||||
|
return {
|
||||||
|
preset: value.preset,
|
||||||
|
useAllTime: true,
|
||||||
|
dateRange: {
|
||||||
|
start: bounds.minDate,
|
||||||
|
end: bounds.maxDate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For preset selections (not custom), clamp dates to bounds and use default times
|
||||||
|
const nextStart = new Date(Math.min(Math.max(value.dateRange.start.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime()))
|
||||||
|
const nextEndCandidate = new Date(Math.min(Math.max(value.dateRange.end.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime()))
|
||||||
|
const nextEnd = nextEndCandidate.getTime() < nextStart.getTime() ? nextStart : nextEndCandidate
|
||||||
|
|
||||||
|
// Set default times: start at 00:00:00, end at 23:59:59
|
||||||
|
nextStart.setHours(0, 0, 0, 0)
|
||||||
|
nextEnd.setHours(23, 59, 59, 999)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
preset: value.useAllTime ? value.preset : (changed ? 'custom' : value.preset),
|
preset: value.preset,
|
||||||
useAllTime: value.useAllTime,
|
useAllTime: false,
|
||||||
dateRange: {
|
dateRange: {
|
||||||
start: nextStart,
|
start: nextStart,
|
||||||
end: nextEnd
|
end: nextEnd
|
||||||
@@ -95,62 +124,129 @@ export function ExportDateRangeDialog({
|
|||||||
onClose,
|
onClose,
|
||||||
onConfirm
|
onConfirm
|
||||||
}: ExportDateRangeDialogProps) {
|
}: ExportDateRangeDialogProps) {
|
||||||
|
// Helper: Format date only (YYYY-MM-DD) for the date input field
|
||||||
|
const formatDateOnly = (date: Date): string => {
|
||||||
|
const y = date.getFullYear()
|
||||||
|
const m = `${date.getMonth() + 1}`.padStart(2, '0')
|
||||||
|
const d = `${date.getDate()}`.padStart(2, '0')
|
||||||
|
return `${y}-${m}-${d}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Format time only (HH:mm) for the time input field
|
||||||
|
const formatTimeOnly = (date: Date): string => {
|
||||||
|
const h = `${date.getHours()}`.padStart(2, '0')
|
||||||
|
const m = `${date.getMinutes()}`.padStart(2, '0')
|
||||||
|
return `${h}:${m}`
|
||||||
|
}
|
||||||
|
|
||||||
const [draft, setDraft] = useState<ExportDateRangeDialogDraft>(() => buildDialogDraft(value, minDate, maxDate))
|
const [draft, setDraft] = useState<ExportDateRangeDialogDraft>(() => buildDialogDraft(value, minDate, maxDate))
|
||||||
const [activeBoundary, setActiveBoundary] = useState<ActiveBoundary>('start')
|
const [activeBoundary, setActiveBoundary] = useState<ActiveBoundary>('start')
|
||||||
const [dateInput, setDateInput] = useState({
|
const [dateInput, setDateInput] = useState({
|
||||||
start: formatDateInputValue(value.dateRange.start),
|
start: formatDateOnly(value.dateRange.start),
|
||||||
end: formatDateInputValue(value.dateRange.end)
|
end: formatDateOnly(value.dateRange.end)
|
||||||
})
|
})
|
||||||
const [dateInputError, setDateInputError] = useState({ start: false, end: false })
|
const [dateInputError, setDateInputError] = useState({ start: false, end: false })
|
||||||
|
|
||||||
|
// Default times: start at 00:00, end at 23:59
|
||||||
|
const [timeInput, setTimeInput] = useState({
|
||||||
|
start: '00:00',
|
||||||
|
end: '23:59'
|
||||||
|
})
|
||||||
|
const [openTimeDropdown, setOpenTimeDropdown] = useState<ActiveBoundary | null>(null)
|
||||||
|
const startTimeSelectRef = useRef<HTMLDivElement>(null)
|
||||||
|
const endTimeSelectRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
const nextDraft = buildDialogDraft(value, minDate, maxDate)
|
const nextDraft = buildDialogDraft(value, minDate, maxDate)
|
||||||
setDraft(nextDraft)
|
setDraft(nextDraft)
|
||||||
setActiveBoundary('start')
|
setActiveBoundary('start')
|
||||||
setDateInput({
|
setDateInput({
|
||||||
start: formatDateInputValue(nextDraft.dateRange.start),
|
start: formatDateOnly(nextDraft.dateRange.start),
|
||||||
end: formatDateInputValue(nextDraft.dateRange.end)
|
end: formatDateOnly(nextDraft.dateRange.end)
|
||||||
})
|
})
|
||||||
|
// For preset-based selections (not custom), use default times 00:00 and 23:59
|
||||||
|
// For custom selections, preserve the time from value.dateRange
|
||||||
|
if (nextDraft.useAllTime || nextDraft.preset !== 'custom') {
|
||||||
|
setTimeInput({
|
||||||
|
start: '00:00',
|
||||||
|
end: '23:59'
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setTimeInput({
|
||||||
|
start: formatTimeOnly(nextDraft.dateRange.start),
|
||||||
|
end: formatTimeOnly(nextDraft.dateRange.end)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setOpenTimeDropdown(null)
|
||||||
setDateInputError({ start: false, end: false })
|
setDateInputError({ start: false, end: false })
|
||||||
}, [maxDate, minDate, open, value])
|
}, [maxDate, minDate, open, value])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
setDateInput({
|
setDateInput({
|
||||||
start: formatDateInputValue(draft.dateRange.start),
|
start: formatDateOnly(draft.dateRange.start),
|
||||||
end: formatDateInputValue(draft.dateRange.end)
|
end: formatDateOnly(draft.dateRange.end)
|
||||||
})
|
})
|
||||||
|
// Don't sync timeInput here - it's controlled by the time picker
|
||||||
setDateInputError({ start: false, end: false })
|
setDateInputError({ start: false, end: false })
|
||||||
}, [draft.dateRange.end.getTime(), draft.dateRange.start.getTime(), open])
|
}, [draft.dateRange.end.getTime(), draft.dateRange.start.getTime(), open])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!openTimeDropdown) return
|
||||||
|
|
||||||
|
const handlePointerDown = (event: MouseEvent) => {
|
||||||
|
const target = event.target as Node
|
||||||
|
const activeContainer = openTimeDropdown === 'start'
|
||||||
|
? startTimeSelectRef.current
|
||||||
|
: endTimeSelectRef.current
|
||||||
|
if (!activeContainer?.contains(target)) {
|
||||||
|
setOpenTimeDropdown(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
setOpenTimeDropdown(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handlePointerDown)
|
||||||
|
document.addEventListener('keydown', handleEscape)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handlePointerDown)
|
||||||
|
document.removeEventListener('keydown', handleEscape)
|
||||||
|
}
|
||||||
|
}, [openTimeDropdown])
|
||||||
|
|
||||||
const bounds = useMemo(() => resolveBounds(minDate, maxDate), [maxDate, minDate])
|
const bounds = useMemo(() => resolveBounds(minDate, maxDate), [maxDate, minDate])
|
||||||
const clampStartDate = useCallback((targetDate: Date) => {
|
const clampStartDate = useCallback((targetDate: Date) => {
|
||||||
const start = startOfDay(targetDate)
|
if (!bounds) return targetDate
|
||||||
if (!bounds) return start
|
const min = bounds.minDate
|
||||||
if (start.getTime() < bounds.minDate.getTime()) return bounds.minDate
|
const max = bounds.maxDate
|
||||||
if (start.getTime() > bounds.maxDate.getTime()) return startOfDay(bounds.maxDate)
|
if (targetDate.getTime() < min.getTime()) return min
|
||||||
return start
|
if (targetDate.getTime() > max.getTime()) return max
|
||||||
|
return targetDate
|
||||||
}, [bounds])
|
}, [bounds])
|
||||||
const clampEndDate = useCallback((targetDate: Date) => {
|
const clampEndDate = useCallback((targetDate: Date) => {
|
||||||
const end = endOfDay(targetDate)
|
if (!bounds) return targetDate
|
||||||
if (!bounds) return end
|
const min = bounds.minDate
|
||||||
if (end.getTime() < bounds.minDate.getTime()) return endOfDay(bounds.minDate)
|
const max = bounds.maxDate
|
||||||
if (end.getTime() > bounds.maxDate.getTime()) return bounds.maxDate
|
if (targetDate.getTime() < min.getTime()) return min
|
||||||
return end
|
if (targetDate.getTime() > max.getTime()) return max
|
||||||
|
return targetDate
|
||||||
}, [bounds])
|
}, [bounds])
|
||||||
|
|
||||||
const setRangeStart = useCallback((targetDate: Date) => {
|
const setRangeStart = useCallback((targetDate: Date) => {
|
||||||
const start = clampStartDate(targetDate)
|
const start = clampStartDate(targetDate)
|
||||||
setDraft(prev => {
|
setDraft(prev => {
|
||||||
const nextEnd = prev.dateRange.end < start ? endOfDay(start) : prev.dateRange.end
|
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
preset: 'custom',
|
preset: 'custom',
|
||||||
useAllTime: false,
|
useAllTime: false,
|
||||||
dateRange: {
|
dateRange: {
|
||||||
start,
|
start,
|
||||||
end: nextEnd
|
end: prev.dateRange.end
|
||||||
},
|
},
|
||||||
panelMonth: toMonthStart(start)
|
panelMonth: toMonthStart(start)
|
||||||
}
|
}
|
||||||
@@ -161,14 +257,13 @@ export function ExportDateRangeDialog({
|
|||||||
const end = clampEndDate(targetDate)
|
const end = clampEndDate(targetDate)
|
||||||
setDraft(prev => {
|
setDraft(prev => {
|
||||||
const nextStart = prev.useAllTime ? clampStartDate(targetDate) : prev.dateRange.start
|
const nextStart = prev.useAllTime ? clampStartDate(targetDate) : prev.dateRange.start
|
||||||
const nextEnd = end < nextStart ? endOfDay(nextStart) : end
|
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
preset: 'custom',
|
preset: 'custom',
|
||||||
useAllTime: false,
|
useAllTime: false,
|
||||||
dateRange: {
|
dateRange: {
|
||||||
start: nextStart,
|
start: nextStart,
|
||||||
end: nextEnd
|
end: end
|
||||||
},
|
},
|
||||||
panelMonth: toMonthStart(targetDate)
|
panelMonth: toMonthStart(targetDate)
|
||||||
}
|
}
|
||||||
@@ -180,6 +275,11 @@ export function ExportDateRangeDialog({
|
|||||||
const previewRange = bounds
|
const previewRange = bounds
|
||||||
? { start: bounds.minDate, end: bounds.maxDate }
|
? { start: bounds.minDate, end: bounds.maxDate }
|
||||||
: createDefaultDateRange()
|
: createDefaultDateRange()
|
||||||
|
setTimeInput({
|
||||||
|
start: '00:00',
|
||||||
|
end: '23:59'
|
||||||
|
})
|
||||||
|
setOpenTimeDropdown(null)
|
||||||
setDraft(prev => ({
|
setDraft(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
preset,
|
preset,
|
||||||
@@ -196,6 +296,11 @@ export function ExportDateRangeDialog({
|
|||||||
useAllTime: false,
|
useAllTime: false,
|
||||||
dateRange: createDateRangeByPreset(preset)
|
dateRange: createDateRangeByPreset(preset)
|
||||||
}, minDate, maxDate).dateRange
|
}, minDate, maxDate).dateRange
|
||||||
|
setTimeInput({
|
||||||
|
start: '00:00',
|
||||||
|
end: '23:59'
|
||||||
|
})
|
||||||
|
setOpenTimeDropdown(null)
|
||||||
setDraft(prev => ({
|
setDraft(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
preset,
|
preset,
|
||||||
@@ -206,25 +311,149 @@ export function ExportDateRangeDialog({
|
|||||||
setActiveBoundary('start')
|
setActiveBoundary('start')
|
||||||
}, [bounds, maxDate, minDate])
|
}, [bounds, maxDate, minDate])
|
||||||
|
|
||||||
|
const parseTimeValue = (timeStr: string): { hours: number; minutes: number } | null => {
|
||||||
|
const matched = /^(\d{1,2}):(\d{2})$/.exec(timeStr.trim())
|
||||||
|
if (!matched) return null
|
||||||
|
const hours = Number(matched[1])
|
||||||
|
const minutes = Number(matched[2])
|
||||||
|
if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null
|
||||||
|
return { hours, minutes }
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateBoundaryTime = useCallback((boundary: ActiveBoundary, timeStr: string) => {
|
||||||
|
setTimeInput(prev => ({ ...prev, [boundary]: timeStr }))
|
||||||
|
|
||||||
|
const parsedTime = parseTimeValue(timeStr)
|
||||||
|
if (!parsedTime) return
|
||||||
|
|
||||||
|
setDraft(prev => {
|
||||||
|
const dateObj = boundary === 'start' ? prev.dateRange.start : prev.dateRange.end
|
||||||
|
const newDate = new Date(dateObj)
|
||||||
|
newDate.setHours(parsedTime.hours, parsedTime.minutes, 0, 0)
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
preset: 'custom',
|
||||||
|
useAllTime: false,
|
||||||
|
dateRange: {
|
||||||
|
...prev.dateRange,
|
||||||
|
[boundary]: newDate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggleTimeDropdown = useCallback((boundary: ActiveBoundary) => {
|
||||||
|
setActiveBoundary(boundary)
|
||||||
|
setOpenTimeDropdown(prev => (prev === boundary ? null : boundary))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleTimeColumnSelect = useCallback((boundary: ActiveBoundary, field: 'hour' | 'minute', value: string) => {
|
||||||
|
const parsedCurrent = parseTimeValue(timeInput[boundary]) ?? {
|
||||||
|
hours: boundary === 'start' ? 0 : 23,
|
||||||
|
minutes: boundary === 'start' ? 0 : 59
|
||||||
|
}
|
||||||
|
const nextHours = field === 'hour' ? Number(value) : parsedCurrent.hours
|
||||||
|
const nextMinutes = field === 'minute' ? Number(value) : parsedCurrent.minutes
|
||||||
|
updateBoundaryTime(boundary, `${`${nextHours}`.padStart(2, '0')}:${`${nextMinutes}`.padStart(2, '0')}`)
|
||||||
|
}, [timeInput, updateBoundaryTime])
|
||||||
|
|
||||||
|
const renderTimeDropdown = (boundary: ActiveBoundary) => {
|
||||||
|
const currentTime = timeInput[boundary]
|
||||||
|
const parsedCurrent = parseTimeValue(currentTime) ?? {
|
||||||
|
hours: boundary === 'start' ? 0 : 23,
|
||||||
|
minutes: boundary === 'start' ? 0 : 59
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="export-date-range-time-dropdown" onClick={(event) => event.stopPropagation()}>
|
||||||
|
<div className="export-date-range-time-dropdown-header">
|
||||||
|
<span>{boundary === 'start' ? '开始时间' : '结束时间'}</span>
|
||||||
|
<strong>{currentTime}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="export-date-range-time-quick-list">
|
||||||
|
{QUICK_TIME_OPTIONS.map(option => (
|
||||||
|
<button
|
||||||
|
key={`${boundary}-${option}`}
|
||||||
|
type="button"
|
||||||
|
className={`export-date-range-time-quick-item ${currentTime === option ? 'active' : ''}`}
|
||||||
|
onClick={() => updateBoundaryTime(boundary, option)}
|
||||||
|
>
|
||||||
|
{option}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="export-date-range-time-columns">
|
||||||
|
<div className="export-date-range-time-column">
|
||||||
|
<span className="export-date-range-time-column-label">小时</span>
|
||||||
|
<div className="export-date-range-time-column-list">
|
||||||
|
{HOUR_OPTIONS.map(option => (
|
||||||
|
<button
|
||||||
|
key={`${boundary}-hour-${option}`}
|
||||||
|
type="button"
|
||||||
|
className={`export-date-range-time-option ${parsedCurrent.hours === Number(option) ? 'active' : ''}`}
|
||||||
|
onClick={() => handleTimeColumnSelect(boundary, 'hour', option)}
|
||||||
|
>
|
||||||
|
{option}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="export-date-range-time-column">
|
||||||
|
<span className="export-date-range-time-column-label">分钟</span>
|
||||||
|
<div className="export-date-range-time-column-list">
|
||||||
|
{MINUTE_OPTIONS.map(option => (
|
||||||
|
<button
|
||||||
|
key={`${boundary}-minute-${option}`}
|
||||||
|
type="button"
|
||||||
|
className={`export-date-range-time-option ${parsedCurrent.minutes === Number(option) ? 'active' : ''}`}
|
||||||
|
onClick={() => handleTimeColumnSelect(boundary, 'minute', option)}
|
||||||
|
>
|
||||||
|
{option}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if date input string contains time (YYYY-MM-DD HH:mm format)
|
||||||
|
const dateInputHasTime = (dateStr: string): boolean => /^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}$/.test(dateStr.trim())
|
||||||
|
|
||||||
const commitStartFromInput = useCallback(() => {
|
const commitStartFromInput = useCallback(() => {
|
||||||
const parsed = parseDateInputValue(dateInput.start)
|
const parsedDate = parseDateInputValue(dateInput.start)
|
||||||
if (!parsed) {
|
if (!parsedDate) {
|
||||||
setDateInputError(prev => ({ ...prev, start: true }))
|
setDateInputError(prev => ({ ...prev, start: true }))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Only apply time picker value if date input doesn't contain time
|
||||||
|
if (!dateInputHasTime(dateInput.start)) {
|
||||||
|
const parsedTime = parseTimeValue(timeInput.start)
|
||||||
|
if (parsedTime) {
|
||||||
|
parsedDate.setHours(parsedTime.hours, parsedTime.minutes, 0, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
setDateInputError(prev => ({ ...prev, start: false }))
|
setDateInputError(prev => ({ ...prev, start: false }))
|
||||||
setRangeStart(parsed)
|
setRangeStart(parsedDate)
|
||||||
}, [dateInput.start, setRangeStart])
|
}, [dateInput.start, timeInput.start, setRangeStart])
|
||||||
|
|
||||||
const commitEndFromInput = useCallback(() => {
|
const commitEndFromInput = useCallback(() => {
|
||||||
const parsed = parseDateInputValue(dateInput.end)
|
const parsedDate = parseDateInputValue(dateInput.end)
|
||||||
if (!parsed) {
|
if (!parsedDate) {
|
||||||
setDateInputError(prev => ({ ...prev, end: true }))
|
setDateInputError(prev => ({ ...prev, end: true }))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Only apply time picker value if date input doesn't contain time
|
||||||
|
if (!dateInputHasTime(dateInput.end)) {
|
||||||
|
const parsedTime = parseTimeValue(timeInput.end)
|
||||||
|
if (parsedTime) {
|
||||||
|
parsedDate.setHours(parsedTime.hours, parsedTime.minutes, 0, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
setDateInputError(prev => ({ ...prev, end: false }))
|
setDateInputError(prev => ({ ...prev, end: false }))
|
||||||
setRangeEnd(parsed)
|
setRangeEnd(parsedDate)
|
||||||
}, [dateInput.end, setRangeEnd])
|
}, [dateInput.end, timeInput.end, setRangeEnd])
|
||||||
|
|
||||||
const shiftPanelMonth = useCallback((delta: number) => {
|
const shiftPanelMonth = useCallback((delta: number) => {
|
||||||
setDraft(prev => ({
|
setDraft(prev => ({
|
||||||
@@ -234,30 +463,50 @@ export function ExportDateRangeDialog({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleCalendarSelect = useCallback((targetDate: Date) => {
|
const handleCalendarSelect = useCallback((targetDate: Date) => {
|
||||||
|
// Use time from timeInput state (which is updated by the time picker)
|
||||||
|
const parseTime = (timeStr: string): { hours: number; minutes: number } => {
|
||||||
|
const matched = /^(\d{1,2}):(\d{2})$/.exec(timeStr.trim())
|
||||||
|
if (!matched) return { hours: 0, minutes: 0 }
|
||||||
|
return { hours: Number(matched[1]), minutes: Number(matched[2]) }
|
||||||
|
}
|
||||||
|
|
||||||
if (activeBoundary === 'start') {
|
if (activeBoundary === 'start') {
|
||||||
setRangeStart(targetDate)
|
const newStart = new Date(targetDate)
|
||||||
|
const time = parseTime(timeInput.start)
|
||||||
|
newStart.setHours(time.hours, time.minutes, 0, 0)
|
||||||
|
setRangeStart(newStart)
|
||||||
setActiveBoundary('end')
|
setActiveBoundary('end')
|
||||||
|
setOpenTimeDropdown(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setDraft(prev => {
|
|
||||||
const start = prev.useAllTime ? startOfDay(targetDate) : prev.dateRange.start
|
|
||||||
const pickedStart = startOfDay(targetDate)
|
const pickedStart = startOfDay(targetDate)
|
||||||
|
const start = draft.useAllTime ? startOfDay(targetDate) : draft.dateRange.start
|
||||||
const nextStart = pickedStart <= start ? pickedStart : start
|
const nextStart = pickedStart <= start ? pickedStart : start
|
||||||
const nextEnd = pickedStart <= start ? endOfDay(start) : endOfDay(targetDate)
|
|
||||||
return {
|
const newEnd = new Date(targetDate)
|
||||||
|
const time = parseTime(timeInput.end)
|
||||||
|
// If selecting same day or going backwards, use 23:59:59, otherwise use the time from timeInput
|
||||||
|
if (pickedStart <= start) {
|
||||||
|
newEnd.setHours(23, 59, 59, 999)
|
||||||
|
setTimeInput(prev => ({ ...prev, end: '23:59' }))
|
||||||
|
} else {
|
||||||
|
newEnd.setHours(time.hours, time.minutes, 59, 999)
|
||||||
|
}
|
||||||
|
|
||||||
|
setDraft(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
preset: 'custom',
|
preset: 'custom',
|
||||||
useAllTime: false,
|
useAllTime: false,
|
||||||
dateRange: {
|
dateRange: {
|
||||||
start: nextStart,
|
start: nextStart,
|
||||||
end: nextEnd
|
end: newEnd
|
||||||
},
|
},
|
||||||
panelMonth: toMonthStart(targetDate)
|
panelMonth: toMonthStart(targetDate)
|
||||||
}
|
}))
|
||||||
})
|
|
||||||
setActiveBoundary('start')
|
setActiveBoundary('start')
|
||||||
}, [activeBoundary, setRangeEnd, setRangeStart])
|
setOpenTimeDropdown(null)
|
||||||
|
}, [activeBoundary, draft.dateRange.start, draft.useAllTime, timeInput.end, timeInput.start, setRangeStart])
|
||||||
|
|
||||||
const isRangeModeActive = !draft.useAllTime
|
const isRangeModeActive = !draft.useAllTime
|
||||||
const modeText = isRangeModeActive
|
const modeText = isRangeModeActive
|
||||||
@@ -364,6 +613,23 @@ export function ExportDateRangeDialog({
|
|||||||
}}
|
}}
|
||||||
onBlur={commitStartFromInput}
|
onBlur={commitStartFromInput}
|
||||||
/>
|
/>
|
||||||
|
<div
|
||||||
|
className={`export-date-range-time-select ${openTimeDropdown === 'start' ? 'open' : ''}`}
|
||||||
|
ref={startTimeSelectRef}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="export-date-range-time-trigger"
|
||||||
|
onClick={() => toggleTimeDropdown('start')}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-expanded={openTimeDropdown === 'start'}
|
||||||
|
>
|
||||||
|
<span className="export-date-range-time-trigger-value">{timeInput.start}</span>
|
||||||
|
<ChevronDown size={14} />
|
||||||
|
</button>
|
||||||
|
{openTimeDropdown === 'start' && renderTimeDropdown('start')}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`export-date-range-boundary-card ${activeBoundary === 'end' ? 'active' : ''}`}
|
className={`export-date-range-boundary-card ${activeBoundary === 'end' ? 'active' : ''}`}
|
||||||
@@ -391,6 +657,23 @@ export function ExportDateRangeDialog({
|
|||||||
}}
|
}}
|
||||||
onBlur={commitEndFromInput}
|
onBlur={commitEndFromInput}
|
||||||
/>
|
/>
|
||||||
|
<div
|
||||||
|
className={`export-date-range-time-select ${openTimeDropdown === 'end' ? 'open' : ''}`}
|
||||||
|
ref={endTimeSelectRef}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="export-date-range-time-trigger"
|
||||||
|
onClick={() => toggleTimeDropdown('end')}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-expanded={openTimeDropdown === 'end'}
|
||||||
|
>
|
||||||
|
<span className="export-date-range-time-trigger-value">{timeInput.end}</span>
|
||||||
|
<ChevronDown size={14} />
|
||||||
|
</button>
|
||||||
|
{openTimeDropdown === 'end' && renderTimeDropdown('end')}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -453,7 +736,14 @@ export function ExportDateRangeDialog({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="export-date-range-dialog-btn primary"
|
className="export-date-range-dialog-btn primary"
|
||||||
onClick={() => onConfirm(cloneExportDateRangeSelection(draft))}
|
onClick={() => {
|
||||||
|
// Validate: end time should not be earlier than start time
|
||||||
|
if (draft.dateRange.end.getTime() < draft.dateRange.start.getTime()) {
|
||||||
|
setDateInputError({ start: true, end: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onConfirm(cloneExportDateRangeSelection(draft))
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
确认
|
确认
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface ExportDefaultsSettingsPatch {
|
|||||||
format?: string
|
format?: string
|
||||||
avatars?: boolean
|
avatars?: boolean
|
||||||
dateRange?: ExportDateRangeSelection
|
dateRange?: ExportDateRangeSelection
|
||||||
|
fileNamingMode?: configService.ExportFileNamingMode
|
||||||
media?: configService.ExportDefaultMediaConfig
|
media?: configService.ExportDefaultMediaConfig
|
||||||
voiceAsText?: boolean
|
voiceAsText?: boolean
|
||||||
excelCompactColumns?: boolean
|
excelCompactColumns?: boolean
|
||||||
@@ -44,6 +45,11 @@ const exportExcelColumnOptions = [
|
|||||||
{ value: 'full', label: '完整列', desc: '含发送者昵称/微信ID/备注' }
|
{ value: 'full', label: '完整列', desc: '含发送者昵称/微信ID/备注' }
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
const exportFileNamingModeOptions: Array<{ value: configService.ExportFileNamingMode; label: string; desc: string }> = [
|
||||||
|
{ value: 'classic', label: '简洁模式', desc: '示例:私聊_张三(兼容旧版)' },
|
||||||
|
{ value: 'date-range', label: '时间范围模式', desc: '示例:私聊_张三_20250101-20250331(推荐)' }
|
||||||
|
]
|
||||||
|
|
||||||
const exportConcurrencyOptions = [1, 2, 3, 4, 5, 6] as const
|
const exportConcurrencyOptions = [1, 2, 3, 4, 5, 6] as const
|
||||||
|
|
||||||
const getOptionLabel = (options: ReadonlyArray<{ value: string; label: string }>, value: string) => {
|
const getOptionLabel = (options: ReadonlyArray<{ value: string; label: string }>, value: string) => {
|
||||||
@@ -56,17 +62,21 @@ export function ExportDefaultsSettingsForm({
|
|||||||
layout = 'stacked'
|
layout = 'stacked'
|
||||||
}: ExportDefaultsSettingsFormProps) {
|
}: ExportDefaultsSettingsFormProps) {
|
||||||
const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false)
|
const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false)
|
||||||
|
const [showExportFileNamingModeSelect, setShowExportFileNamingModeSelect] = useState(false)
|
||||||
const [isExportDateRangeDialogOpen, setIsExportDateRangeDialogOpen] = useState(false)
|
const [isExportDateRangeDialogOpen, setIsExportDateRangeDialogOpen] = useState(false)
|
||||||
const exportExcelColumnsDropdownRef = useRef<HTMLDivElement>(null)
|
const exportExcelColumnsDropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
const exportFileNamingModeDropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const [exportDefaultFormat, setExportDefaultFormat] = useState('excel')
|
const [exportDefaultFormat, setExportDefaultFormat] = useState('excel')
|
||||||
const [exportDefaultAvatars, setExportDefaultAvatars] = useState(true)
|
const [exportDefaultAvatars, setExportDefaultAvatars] = useState(true)
|
||||||
const [exportDefaultDateRange, setExportDefaultDateRange] = useState<ExportDateRangeSelection>(() => createDefaultExportDateRangeSelection())
|
const [exportDefaultDateRange, setExportDefaultDateRange] = useState<ExportDateRangeSelection>(() => createDefaultExportDateRangeSelection())
|
||||||
|
const [exportDefaultFileNamingMode, setExportDefaultFileNamingMode] = useState<configService.ExportFileNamingMode>('classic')
|
||||||
const [exportDefaultMedia, setExportDefaultMedia] = useState<configService.ExportDefaultMediaConfig>({
|
const [exportDefaultMedia, setExportDefaultMedia] = useState<configService.ExportDefaultMediaConfig>({
|
||||||
images: true,
|
images: true,
|
||||||
videos: true,
|
videos: true,
|
||||||
voices: true,
|
voices: true,
|
||||||
emojis: true
|
emojis: true,
|
||||||
|
files: true
|
||||||
})
|
})
|
||||||
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false)
|
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false)
|
||||||
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
|
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
|
||||||
@@ -75,10 +85,11 @@ export function ExportDefaultsSettingsForm({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const [savedFormat, savedAvatars, savedDateRange, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedConcurrency] = await Promise.all([
|
const [savedFormat, savedAvatars, savedDateRange, savedFileNamingMode, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedConcurrency] = await Promise.all([
|
||||||
configService.getExportDefaultFormat(),
|
configService.getExportDefaultFormat(),
|
||||||
configService.getExportDefaultAvatars(),
|
configService.getExportDefaultAvatars(),
|
||||||
configService.getExportDefaultDateRange(),
|
configService.getExportDefaultDateRange(),
|
||||||
|
configService.getExportDefaultFileNamingMode(),
|
||||||
configService.getExportDefaultMedia(),
|
configService.getExportDefaultMedia(),
|
||||||
configService.getExportDefaultVoiceAsText(),
|
configService.getExportDefaultVoiceAsText(),
|
||||||
configService.getExportDefaultExcelCompactColumns(),
|
configService.getExportDefaultExcelCompactColumns(),
|
||||||
@@ -90,11 +101,13 @@ export function ExportDefaultsSettingsForm({
|
|||||||
setExportDefaultFormat(savedFormat || 'excel')
|
setExportDefaultFormat(savedFormat || 'excel')
|
||||||
setExportDefaultAvatars(savedAvatars ?? true)
|
setExportDefaultAvatars(savedAvatars ?? true)
|
||||||
setExportDefaultDateRange(resolveExportDateRangeConfig(savedDateRange))
|
setExportDefaultDateRange(resolveExportDateRangeConfig(savedDateRange))
|
||||||
|
setExportDefaultFileNamingMode(savedFileNamingMode ?? 'classic')
|
||||||
setExportDefaultMedia(savedMedia ?? {
|
setExportDefaultMedia(savedMedia ?? {
|
||||||
images: true,
|
images: true,
|
||||||
videos: true,
|
videos: true,
|
||||||
voices: true,
|
voices: true,
|
||||||
emojis: true
|
emojis: true,
|
||||||
|
files: true
|
||||||
})
|
})
|
||||||
setExportDefaultVoiceAsText(savedVoiceAsText ?? false)
|
setExportDefaultVoiceAsText(savedVoiceAsText ?? false)
|
||||||
setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true)
|
setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true)
|
||||||
@@ -112,15 +125,19 @@ export function ExportDefaultsSettingsForm({
|
|||||||
if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) {
|
if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) {
|
||||||
setShowExportExcelColumnsSelect(false)
|
setShowExportExcelColumnsSelect(false)
|
||||||
}
|
}
|
||||||
|
if (showExportFileNamingModeSelect && exportFileNamingModeDropdownRef.current && !exportFileNamingModeDropdownRef.current.contains(target)) {
|
||||||
|
setShowExportFileNamingModeSelect(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('mousedown', handleClickOutside)
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
}, [showExportExcelColumnsSelect])
|
}, [showExportExcelColumnsSelect, showExportFileNamingModeSelect])
|
||||||
|
|
||||||
const exportExcelColumnsValue = exportDefaultExcelCompactColumns ? 'compact' : 'full'
|
const exportExcelColumnsValue = exportDefaultExcelCompactColumns ? 'compact' : 'full'
|
||||||
const exportDateRangeLabel = useMemo(() => getExportDateRangeLabel(exportDefaultDateRange), [exportDefaultDateRange])
|
const exportDateRangeLabel = useMemo(() => getExportDateRangeLabel(exportDefaultDateRange), [exportDefaultDateRange])
|
||||||
const exportExcelColumnsLabel = useMemo(() => getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue), [exportExcelColumnsValue])
|
const exportExcelColumnsLabel = useMemo(() => getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue), [exportExcelColumnsValue])
|
||||||
|
const exportFileNamingModeLabel = useMemo(() => getOptionLabel(exportFileNamingModeOptions, exportDefaultFileNamingMode), [exportDefaultFileNamingMode])
|
||||||
|
|
||||||
const notify = (text: string, success = true) => {
|
const notify = (text: string, success = true) => {
|
||||||
onNotify?.(text, success)
|
onNotify?.(text, success)
|
||||||
@@ -222,6 +239,7 @@ export function ExportDefaultsSettingsForm({
|
|||||||
className={`settings-time-range-trigger ${isExportDateRangeDialogOpen ? 'open' : ''}`}
|
className={`settings-time-range-trigger ${isExportDateRangeDialogOpen ? 'open' : ''}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowExportExcelColumnsSelect(false)
|
setShowExportExcelColumnsSelect(false)
|
||||||
|
setShowExportFileNamingModeSelect(false)
|
||||||
setIsExportDateRangeDialogOpen(true)
|
setIsExportDateRangeDialogOpen(true)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -245,6 +263,50 @@ export function ExportDefaultsSettingsForm({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="form-copy">
|
||||||
|
<label>导出文件命名方式</label>
|
||||||
|
<span className="form-hint">控制导出文件名是否包含时间范围</span>
|
||||||
|
</div>
|
||||||
|
<div className="form-control">
|
||||||
|
<div className="select-field" ref={exportFileNamingModeDropdownRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`select-trigger ${showExportFileNamingModeSelect ? 'open' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setShowExportFileNamingModeSelect(!showExportFileNamingModeSelect)
|
||||||
|
setShowExportExcelColumnsSelect(false)
|
||||||
|
setIsExportDateRangeDialogOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="select-value">{exportFileNamingModeLabel}</span>
|
||||||
|
<ChevronDown size={16} />
|
||||||
|
</button>
|
||||||
|
{showExportFileNamingModeSelect && (
|
||||||
|
<div className="select-dropdown">
|
||||||
|
{exportFileNamingModeOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
className={`select-option ${exportDefaultFileNamingMode === option.value ? 'active' : ''}`}
|
||||||
|
onClick={async () => {
|
||||||
|
setExportDefaultFileNamingMode(option.value)
|
||||||
|
await configService.setExportDefaultFileNamingMode(option.value)
|
||||||
|
onDefaultsChanged?.({ fileNamingMode: option.value })
|
||||||
|
notify('已更新导出文件命名方式', true)
|
||||||
|
setShowExportFileNamingModeSelect(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="option-label">{option.label}</span>
|
||||||
|
<span className="option-desc">{option.desc}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<div className="form-copy">
|
<div className="form-copy">
|
||||||
<label>Excel 列显示</label>
|
<label>Excel 列显示</label>
|
||||||
@@ -257,6 +319,7 @@ export function ExportDefaultsSettingsForm({
|
|||||||
className={`select-trigger ${showExportExcelColumnsSelect ? 'open' : ''}`}
|
className={`select-trigger ${showExportExcelColumnsSelect ? 'open' : ''}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowExportExcelColumnsSelect(!showExportExcelColumnsSelect)
|
setShowExportExcelColumnsSelect(!showExportExcelColumnsSelect)
|
||||||
|
setShowExportFileNamingModeSelect(false)
|
||||||
setIsExportDateRangeDialogOpen(false)
|
setIsExportDateRangeDialogOpen(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -292,7 +355,7 @@ export function ExportDefaultsSettingsForm({
|
|||||||
<div className="form-group media-setting-group">
|
<div className="form-group media-setting-group">
|
||||||
<div className="form-copy">
|
<div className="form-copy">
|
||||||
<label>默认导出媒体内容</label>
|
<label>默认导出媒体内容</label>
|
||||||
<span className="form-hint">控制图片、视频、语音、表情包的默认导出开关</span>
|
<span className="form-hint">控制图片、视频、语音、表情包、文件的默认导出开关</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-control">
|
<div className="form-control">
|
||||||
<div className="media-default-grid">
|
<div className="media-default-grid">
|
||||||
@@ -352,6 +415,20 @@ export function ExportDefaultsSettingsForm({
|
|||||||
/>
|
/>
|
||||||
表情包
|
表情包
|
||||||
</label>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -75,6 +75,8 @@
|
|||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
&.clickable {
|
&.clickable {
|
||||||
cursor: pointer;
|
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 { X, ChevronLeft, ChevronRight, Calendar as CalendarIcon, Loader2 } from 'lucide-react'
|
||||||
import './JumpToDateDialog.scss'
|
import './JumpToDateDialog.scss'
|
||||||
|
|
||||||
@@ -21,10 +21,15 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
|
|||||||
messageDates,
|
messageDates,
|
||||||
loadingDates = false
|
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 isValidDate = (d: any) => d instanceof Date && !isNaN(d.getTime())
|
||||||
const [calendarDate, setCalendarDate] = useState(isValidDate(currentDate) ? new Date(currentDate) : new Date())
|
const [calendarDate, setCalendarDate] = useState(isValidDate(currentDate) ? new Date(currentDate) : new Date())
|
||||||
const [selectedDate, setSelectedDate] = useState(new Date(currentDate))
|
const [selectedDate, setSelectedDate] = useState(new Date(currentDate))
|
||||||
const [showYearMonthPicker, setShowYearMonthPicker] = useState(false)
|
const [viewMode, setViewMode] = useState<CalendarViewMode>('day')
|
||||||
|
const [yearPageStart, setYearPageStart] = useState<number>(
|
||||||
|
getYearPageStart((isValidDate(currentDate) ? new Date(currentDate) : new Date()).getFullYear())
|
||||||
|
)
|
||||||
|
|
||||||
if (!isOpen) return null
|
if (!isOpen) return null
|
||||||
|
|
||||||
@@ -116,6 +121,57 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
|
|||||||
|
|
||||||
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
|
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
|
||||||
const days = generateCalendar()
|
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 (
|
return (
|
||||||
<div className="jump-date-overlay" onClick={onClose}>
|
<div className="jump-date-overlay" onClick={onClose}>
|
||||||
@@ -134,45 +190,57 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
|
|||||||
<div className="calendar-nav">
|
<div className="calendar-nav">
|
||||||
<button
|
<button
|
||||||
className="nav-btn"
|
className="nav-btn"
|
||||||
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))}
|
onClick={handlePrev}
|
||||||
>
|
>
|
||||||
<ChevronLeft size={18} />
|
<ChevronLeft size={18} />
|
||||||
</button>
|
</button>
|
||||||
<span className="current-month clickable" onClick={() => setShowYearMonthPicker(!showYearMonthPicker)}>
|
<button
|
||||||
{calendarDate.getFullYear()}年{calendarDate.getMonth() + 1}月
|
className={`current-month ${viewMode === 'year' ? '' : 'clickable'}`.trim()}
|
||||||
</span>
|
onClick={handleTitleClick}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{navTitle}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="nav-btn"
|
className="nav-btn"
|
||||||
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))}
|
onClick={handleNext}
|
||||||
>
|
>
|
||||||
<ChevronRight size={18} />
|
<ChevronRight size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showYearMonthPicker ? (
|
{viewMode === 'month' ? (
|
||||||
<div className="year-month-picker">
|
<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">
|
<div className="month-grid">
|
||||||
{['一月','二月','三月','四月','五月','六月','七月','八月','九月','十月','十一月','十二月'].map((name, i) => (
|
{monthNames.map((name, i) => (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
className={`month-btn ${i === calendarDate.getMonth() ? 'active' : ''}`}
|
className={`month-btn ${i === calendarDate.getMonth() ? 'active' : ''}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCalendarDate(new Date(calendarDate.getFullYear(), i, 1))
|
updateCalendarDate(new Date(calendarDate.getFullYear(), i, 1))
|
||||||
setShowYearMonthPicker(false)
|
setViewMode('day')
|
||||||
}}
|
}}
|
||||||
>{name}</button>
|
>{name}</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</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' : ''}`}>
|
<div className={`calendar-grid ${loadingDates ? 'loading' : ''}`}>
|
||||||
{loadingDates && (
|
{loadingDates && (
|
||||||
@@ -208,18 +276,21 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
|
|||||||
const d = new Date()
|
const d = new Date()
|
||||||
setSelectedDate(d)
|
setSelectedDate(d)
|
||||||
setCalendarDate(new Date(d))
|
setCalendarDate(new Date(d))
|
||||||
|
setViewMode('day')
|
||||||
}}>今天</button>
|
}}>今天</button>
|
||||||
<button onClick={() => {
|
<button onClick={() => {
|
||||||
const d = new Date()
|
const d = new Date()
|
||||||
d.setDate(d.getDate() - 7)
|
d.setDate(d.getDate() - 7)
|
||||||
setSelectedDate(d)
|
setSelectedDate(d)
|
||||||
setCalendarDate(new Date(d))
|
setCalendarDate(new Date(d))
|
||||||
|
setViewMode('day')
|
||||||
}}>一周前</button>
|
}}>一周前</button>
|
||||||
<button onClick={() => {
|
<button onClick={() => {
|
||||||
const d = new Date()
|
const d = new Date()
|
||||||
d.setMonth(d.getMonth() - 1)
|
d.setMonth(d.getMonth() - 1)
|
||||||
setSelectedDate(d)
|
setSelectedDate(d)
|
||||||
setCalendarDate(new Date(d))
|
setCalendarDate(new Date(d))
|
||||||
|
setViewMode('day')
|
||||||
}}>一月前</button>
|
}}>一月前</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||