mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-05-28 07:36:44 +00:00
Compare commits
246 Commits
v4.3.1
...
nightly-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca6c479496 | ||
|
|
6d419dbe9e | ||
|
|
ca1ef91bff | ||
|
|
482259953c | ||
|
|
26eac85908 | ||
|
|
9cd5947401 | ||
|
|
e9e3844e3b | ||
|
|
8129c1227b | ||
|
|
aa4e3388fc | ||
|
|
33bffc10bc | ||
|
|
a98e4af9a8 | ||
|
|
eaa9dbea73 | ||
|
|
046482fccd | ||
|
|
7e6ce2e0c5 | ||
|
|
e26c0fce91 | ||
|
|
abbab85f24 | ||
|
|
d4f933b715 | ||
|
|
16608b2c8e | ||
|
|
405a81bcbb | ||
|
|
d5d64b2b50 | ||
|
|
cb72cc1b92 | ||
|
|
51214ac994 | ||
|
|
7f4f3c2eb0 | ||
|
|
0dc5efb635 | ||
|
|
fea00a6e36 | ||
|
|
c1be9bcd52 | ||
|
|
af9acb4a36 | ||
|
|
b6b930ebb9 | ||
|
|
796515d3e8 | ||
|
|
39e527a21a | ||
|
|
70aff53ef1 | ||
|
|
2d5832d6a9 | ||
|
|
604000ae51 | ||
|
|
762a2ec832 | ||
|
|
810a8e9761 | ||
|
|
b126f7a1db | ||
|
|
e41a1197cb | ||
|
|
3317362187 | ||
|
|
ae5d1d95ab | ||
|
|
0bd5610cf0 | ||
|
|
45a4247563 | ||
|
|
ff15dc6e9f | ||
|
|
0f0f5abb2a | ||
|
|
128055c4f4 | ||
|
|
f43005ae34 | ||
|
|
a6d652eec9 | ||
|
|
abde85a900 | ||
|
|
3f908a4dd3 | ||
|
|
961ae4dea8 | ||
|
|
50a575bf58 | ||
|
|
df0e638301 | ||
|
|
24ab0239df | ||
|
|
5319153879 | ||
|
|
4f13b609d4 | ||
|
|
ab7b27dd27 | ||
|
|
a0eee30f7d | ||
|
|
416b62fdf1 | ||
|
|
65247a01d3 | ||
|
|
b4758d690b | ||
|
|
98377beebe | ||
|
|
c09128b83e | ||
|
|
404b06ff16 | ||
|
|
6eb304ef94 | ||
|
|
fd0db6e306 | ||
|
|
a7fa088470 | ||
|
|
b314fc55f9 | ||
|
|
715718c3e5 | ||
|
|
72beca65bb | ||
|
|
7dc7888869 | ||
|
|
7233f4249d | ||
|
|
4271d29f2b | ||
|
|
86f966d469 | ||
|
|
99a3ccd228 | ||
|
|
a001f3327c | ||
|
|
2d14ba9078 | ||
|
|
1e3a496021 | ||
|
|
4cb799ca7f | ||
|
|
e61930107a | ||
|
|
becec65ee3 | ||
|
|
318b553d0e | ||
|
|
8946559d94 | ||
|
|
4ca0d23a2d | ||
|
|
4a57a503f5 | ||
|
|
d53ddb0ba7 | ||
|
|
1fc710ccef | ||
|
|
82200e5fd7 | ||
|
|
bdf285062f | ||
|
|
b1807b21e7 | ||
|
|
32feac7d5e | ||
|
|
d2e59db123 | ||
|
|
d27cef6358 | ||
|
|
1f0b2613bf | ||
|
|
9c7ed1729a | ||
|
|
52f58f6288 | ||
|
|
dfe0186267 | ||
|
|
fd9b7c4546 | ||
|
|
9f9ad337ab | ||
|
|
c596d24083 | ||
|
|
6cfc38c33a | ||
|
|
13cede13f9 | ||
|
|
440c1f166a | ||
|
|
106d19fc6c | ||
|
|
60a4011539 | ||
|
|
fd97920fb2 | ||
|
|
55a7ce7b66 | ||
|
|
7469337aeb | ||
|
|
338d0e2f20 | ||
|
|
a86a51c30c | ||
|
|
043332d297 | ||
|
|
608f74a3f9 | ||
|
|
551d05fe2e | ||
|
|
c9317f76a3 | ||
|
|
ffd533d865 | ||
|
|
1976edc483 | ||
|
|
606bc6ab66 | ||
|
|
27690ee7fa | ||
|
|
81ade84a77 | ||
|
|
bb42a7c0b2 | ||
|
|
87d894b1f9 | ||
|
|
1b75986987 | ||
|
|
32aab8d490 | ||
|
|
8e2a6ec933 | ||
|
|
fc3356ece2 | ||
|
|
cd1ecf0ef6 | ||
|
|
9e6bf0f21a | ||
|
|
9ea34d74c2 | ||
|
|
42d4982728 | ||
|
|
f07e23b144 | ||
|
|
6cf67828a2 | ||
|
|
5d64efdddf | ||
|
|
625e7ac8f1 | ||
|
|
a0b976e5d2 | ||
|
|
c3fd291d7a | ||
|
|
f63743cc87 | ||
|
|
bda1c0b6d7 | ||
|
|
69f834ca42 | ||
|
|
6cd01b0209 | ||
|
|
5129574729 | ||
|
|
2cbdb04157 | ||
|
|
2c01951791 | ||
|
|
7bb5b4f834 | ||
|
|
c167be53b3 | ||
|
|
a7ea22b1ae | ||
|
|
b74fda1f66 | ||
|
|
2acbe0fb08 | ||
|
|
17c13c2455 | ||
|
|
032aad6539 | ||
|
|
d3c738f9f1 | ||
|
|
d1741c931f | ||
|
|
b75de26178 | ||
|
|
255b857e67 | ||
|
|
c923327112 | ||
|
|
c25b231f9c | ||
|
|
fbc2c8d900 | ||
|
|
6304c9ed51 | ||
|
|
777f5b82db | ||
|
|
5802cf36c6 | ||
|
|
e3174370bb | ||
|
|
0f8a9602bd | ||
|
|
fe02ff0d84 | ||
|
|
dfec3dba41 | ||
|
|
30d54fcdb1 | ||
|
|
33fde44cc3 | ||
|
|
eca1411c68 | ||
|
|
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 | ||
|
|
59a0b1bf16 | ||
|
|
a26d5620ca | ||
|
|
8a3f1078f6 |
237
.github/scripts/release-utils.sh
vendored
Normal file
237
.github/scripts/release-utils.sh
vendored
Normal file
@@ -0,0 +1,237 @@
|
||||
#!/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
|
||||
local release_api_url
|
||||
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
|
||||
|
||||
release_id="$(gh release view "$tag" --repo "$repo" --json databaseId --jq '.databaseId // empty' 2>/dev/null || true)"
|
||||
if [[ "$release_id" =~ ^[0-9]+$ ]]; then
|
||||
echo "$release_id"
|
||||
return 0
|
||||
fi
|
||||
|
||||
release_api_url="$(gh release view "$tag" --repo "$repo" --json apiUrl --jq '.apiUrl // empty' 2>/dev/null || true)"
|
||||
if [[ "$release_api_url" =~ /releases/([0-9]+)$ ]]; then
|
||||
echo "${BASH_REMATCH[1]}"
|
||||
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 release view "$tag" --repo "$repo" --json databaseId,id,isDraft,isPrerelease,url 2>/dev/null || true
|
||||
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 release edit "$tag" --repo "$repo" --draft=false --prerelease >/dev/null 2>&1 || true
|
||||
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 || gh release view "$tag" --repo "$repo" --json isDraft --jq '.isDraft' 2>/dev/null || echo true)"
|
||||
prerelease_state="$(gh api "$endpoint" --jq '.prerelease' 2>/dev/null || gh release view "$tag" --repo "$repo" --json isPrerelease --jq '.isPrerelease' 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 release view "$tag" --repo "$repo" --json isDraft,isPrerelease,url 2>/dev/null || true
|
||||
gh api "$endpoint" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}' 2>/dev/null || true
|
||||
return 1
|
||||
}
|
||||
|
||||
print_release_state() {
|
||||
local repo="$1"
|
||||
local tag="$2"
|
||||
|
||||
gh api "repos/$repo/releases/tags/$tag" --jq '{isDraft: .draft, isPrerelease: .prerelease, url: .html_url}' 2>/dev/null \
|
||||
|| gh release view "$tag" --repo "$repo" --json isDraft,isPrerelease,url --jq '{isDraft: .isDraft, isPrerelease: .isPrerelease, url: .url}'
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
82
.github/workflows/dev-daily-fixed.yml
vendored
82
.github/workflows/dev-daily-fixed.yml
vendored
@@ -55,28 +55,8 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if gh release view "$FIXED_DEV_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
gh release delete "$FIXED_DEV_TAG" --repo "$GITHUB_REPOSITORY" --yes --cleanup-tag
|
||||
fi
|
||||
gh release create "$FIXED_DEV_TAG" --repo "$GITHUB_REPOSITORY" --title "Daily Dev Build" --notes "开发版发布页" --prerelease --target "$TARGET_BRANCH"
|
||||
RELEASE_REST_ID="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$FIXED_DEV_TAG" --jq '.id')"
|
||||
RELEASE_ENDPOINT="repos/$GITHUB_REPOSITORY/releases/tags/$FIXED_DEV_TAG"
|
||||
settled="false"
|
||||
for i in 1 2 3 4 5; do
|
||||
gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$RELEASE_REST_ID" -F draft=false -F prerelease=true >/dev/null 2>&1 || true
|
||||
DRAFT_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.draft' 2>/dev/null || echo true)"
|
||||
PRERELEASE_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.prerelease' 2>/dev/null || echo false)"
|
||||
if [ "$DRAFT_STATE" = "false" ] && [ "$PRERELEASE_STATE" = "true" ]; then
|
||||
settled="true"
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
if [ "$settled" != "true" ]; then
|
||||
echo "Failed to settle release state after create:"
|
||||
gh api "$RELEASE_ENDPOINT" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}'
|
||||
exit 1
|
||||
fi
|
||||
source .github/scripts/release-utils.sh
|
||||
recreate_fixed_prerelease "$GITHUB_REPOSITORY" "$FIXED_DEV_TAG" "$TARGET_BRANCH" "Daily Dev Build" "开发版发布页"
|
||||
|
||||
dev-mac-arm64:
|
||||
needs: prepare
|
||||
@@ -93,7 +73,6 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
@@ -126,15 +105,21 @@ jobs:
|
||||
- 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"
|
||||
npx electron-builder --mac dmg --arm64 --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-arm64.${ext}'
|
||||
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")
|
||||
@@ -143,7 +128,7 @@ jobs:
|
||||
echo "No release files found in ./release"
|
||||
exit 1
|
||||
fi
|
||||
gh release upload "$FIXED_DEV_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
|
||||
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_DEV_TAG" "${assets[@]}"
|
||||
|
||||
dev-linux:
|
||||
needs: prepare
|
||||
@@ -160,7 +145,6 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
@@ -183,6 +167,8 @@ jobs:
|
||||
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")
|
||||
@@ -191,7 +177,7 @@ jobs:
|
||||
echo "No release files found in ./release"
|
||||
exit 1
|
||||
fi
|
||||
gh release upload "$FIXED_DEV_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
|
||||
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_DEV_TAG" "${assets[@]}"
|
||||
|
||||
dev-win-x64:
|
||||
needs: prepare
|
||||
@@ -208,7 +194,6 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
@@ -231,6 +216,8 @@ jobs:
|
||||
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")
|
||||
@@ -239,7 +226,7 @@ jobs:
|
||||
echo "No release files found in ./release"
|
||||
exit 1
|
||||
fi
|
||||
gh release upload "$FIXED_DEV_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
|
||||
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_DEV_TAG" "${assets[@]}"
|
||||
|
||||
dev-win-arm64:
|
||||
needs: prepare
|
||||
@@ -256,7 +243,6 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
@@ -279,6 +265,8 @@ jobs:
|
||||
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")
|
||||
@@ -287,7 +275,7 @@ jobs:
|
||||
echo "No release files found in ./release"
|
||||
exit 1
|
||||
fi
|
||||
gh release upload "$FIXED_DEV_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
|
||||
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_DEV_TAG" "${assets[@]}"
|
||||
|
||||
update-dev-release-notes:
|
||||
needs:
|
||||
@@ -299,6 +287,12 @@ jobs:
|
||||
if: always() && needs.prepare.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out git repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ env.TARGET_BRANCH }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Update fixed dev release notes
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -330,6 +324,9 @@ jobs:
|
||||
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$")"
|
||||
|
||||
@@ -386,22 +383,7 @@ jobs:
|
||||
}
|
||||
|
||||
update_release_notes
|
||||
RELEASE_REST_ID="$(gh api "repos/$REPO/releases/tags/$TAG" --jq '.id')"
|
||||
RELEASE_ENDPOINT="repos/$REPO/releases/tags/$TAG"
|
||||
settled="false"
|
||||
for i in 1 2 3 4 5; do
|
||||
gh api --method PATCH "repos/$REPO/releases/$RELEASE_REST_ID" -F draft=false -F prerelease=true >/dev/null 2>&1 || true
|
||||
DRAFT_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.draft' 2>/dev/null || echo true)"
|
||||
PRERELEASE_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.prerelease' 2>/dev/null || echo false)"
|
||||
if [ "$DRAFT_STATE" = "false" ] && [ "$PRERELEASE_STATE" = "true" ]; then
|
||||
settled="true"
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
if [ "$settled" != "true" ]; then
|
||||
echo "Failed to settle release state after notes update:"
|
||||
gh api "$RELEASE_ENDPOINT" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}'
|
||||
exit 1
|
||||
fi
|
||||
gh api "repos/$REPO/releases/tags/$TAG" --jq '{isDraft: .draft, isPrerelease: .prerelease, url: .html_url}'
|
||||
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
|
||||
print_release_state "$REPO" "$TAG"
|
||||
|
||||
82
.github/workflows/preview-nightly-main.yml
vendored
82
.github/workflows/preview-nightly-main.yml
vendored
@@ -81,28 +81,8 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if gh release view "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
gh release delete "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" --yes --cleanup-tag
|
||||
fi
|
||||
gh release create "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" --title "Preview Nightly Build" --notes "预览版发布页" --prerelease --target "$TARGET_BRANCH"
|
||||
RELEASE_REST_ID="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$FIXED_PREVIEW_TAG" --jq '.id')"
|
||||
RELEASE_ENDPOINT="repos/$GITHUB_REPOSITORY/releases/tags/$FIXED_PREVIEW_TAG"
|
||||
settled="false"
|
||||
for i in 1 2 3 4 5; do
|
||||
gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$RELEASE_REST_ID" -F draft=false -F prerelease=true >/dev/null 2>&1 || true
|
||||
DRAFT_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.draft' 2>/dev/null || echo true)"
|
||||
PRERELEASE_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.prerelease' 2>/dev/null || echo false)"
|
||||
if [ "$DRAFT_STATE" = "false" ] && [ "$PRERELEASE_STATE" = "true" ]; then
|
||||
settled="true"
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
if [ "$settled" != "true" ]; then
|
||||
echo "Failed to settle release state after create:"
|
||||
gh api "$RELEASE_ENDPOINT" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}'
|
||||
exit 1
|
||||
fi
|
||||
source .github/scripts/release-utils.sh
|
||||
recreate_fixed_prerelease "$GITHUB_REPOSITORY" "$FIXED_PREVIEW_TAG" "$TARGET_BRANCH" "Preview Nightly Build" "预览版发布页"
|
||||
|
||||
preview-mac-arm64:
|
||||
needs: prepare
|
||||
@@ -120,7 +100,6 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
@@ -155,15 +134,21 @@ jobs:
|
||||
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"
|
||||
npx electron-builder --mac dmg --arm64 --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-arm64.${ext}'
|
||||
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")
|
||||
@@ -172,7 +157,7 @@ jobs:
|
||||
echo "No release files found in ./release"
|
||||
exit 1
|
||||
fi
|
||||
gh release upload "$FIXED_PREVIEW_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
|
||||
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_PREVIEW_TAG" "${assets[@]}"
|
||||
|
||||
preview-linux:
|
||||
needs: prepare
|
||||
@@ -190,7 +175,6 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
@@ -216,6 +200,8 @@ jobs:
|
||||
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")
|
||||
@@ -224,7 +210,7 @@ jobs:
|
||||
echo "No release files found in ./release"
|
||||
exit 1
|
||||
fi
|
||||
gh release upload "$FIXED_PREVIEW_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
|
||||
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_PREVIEW_TAG" "${assets[@]}"
|
||||
|
||||
preview-win-x64:
|
||||
needs: prepare
|
||||
@@ -242,7 +228,6 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
@@ -268,6 +253,8 @@ jobs:
|
||||
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")
|
||||
@@ -276,7 +263,7 @@ jobs:
|
||||
echo "No release files found in ./release"
|
||||
exit 1
|
||||
fi
|
||||
gh release upload "$FIXED_PREVIEW_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
|
||||
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_PREVIEW_TAG" "${assets[@]}"
|
||||
|
||||
preview-win-arm64:
|
||||
needs: prepare
|
||||
@@ -294,7 +281,6 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
@@ -320,6 +306,8 @@ jobs:
|
||||
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")
|
||||
@@ -328,7 +316,7 @@ jobs:
|
||||
echo "No release files found in ./release"
|
||||
exit 1
|
||||
fi
|
||||
gh release upload "$FIXED_PREVIEW_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
|
||||
upload_release_assets_with_retry "$GITHUB_REPOSITORY" "$FIXED_PREVIEW_TAG" "${assets[@]}"
|
||||
|
||||
update-preview-release-notes:
|
||||
needs:
|
||||
@@ -340,6 +328,12 @@ jobs:
|
||||
if: needs.prepare.outputs.should_build == 'true' && always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out git repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ env.TARGET_BRANCH }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Update preview release notes
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -375,6 +369,9 @@ jobs:
|
||||
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$")"
|
||||
|
||||
@@ -429,22 +426,7 @@ jobs:
|
||||
}
|
||||
|
||||
update_release_notes
|
||||
RELEASE_REST_ID="$(gh api "repos/$REPO/releases/tags/$TAG" --jq '.id')"
|
||||
RELEASE_ENDPOINT="repos/$REPO/releases/tags/$TAG"
|
||||
settled="false"
|
||||
for i in 1 2 3 4 5; do
|
||||
gh api --method PATCH "repos/$REPO/releases/$RELEASE_REST_ID" -F draft=false -F prerelease=true >/dev/null 2>&1 || true
|
||||
DRAFT_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.draft' 2>/dev/null || echo true)"
|
||||
PRERELEASE_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.prerelease' 2>/dev/null || echo false)"
|
||||
if [ "$DRAFT_STATE" = "false" ] && [ "$PRERELEASE_STATE" = "true" ]; then
|
||||
settled="true"
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
if [ "$settled" != "true" ]; then
|
||||
echo "Failed to settle release state after notes update:"
|
||||
gh api "$RELEASE_ENDPOINT" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}'
|
||||
exit 1
|
||||
fi
|
||||
gh api "repos/$REPO/releases/tags/$TAG" --jq '{isDraft: .draft, isPrerelease: .prerelease, url: .html_url}'
|
||||
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
|
||||
print_release_state "$REPO" "$TAG"
|
||||
|
||||
134
.github/workflows/release.yml
vendored
134
.github/workflows/release.yml
vendored
@@ -27,9 +27,8 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
run: npm install --ignore-scripts
|
||||
|
||||
- name: Ensure mac key helpers are executable
|
||||
shell: bash
|
||||
@@ -52,7 +51,7 @@ jobs:
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
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
|
||||
shell: bash
|
||||
@@ -60,30 +59,40 @@ jobs:
|
||||
npx tsc
|
||||
npx vite build
|
||||
|
||||
- name: Package and Publish macOS arm64 (unsigned DMG)
|
||||
- name: Package and Publish macOS arm64 (unsigned DMG + ZIP)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
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"
|
||||
npx electron-builder --mac dmg --arm64 --publish always
|
||||
if ! npx electron-builder --mac dmg zip --arm64 --publish always '--config.npmRebuild=false' '--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.npmRebuild=false' '--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
|
||||
gh release download "$TAG" --repo "$REPO" --pattern "$YML_FILE" --output "/tmp/$YML_FILE" 2>/dev/null || continue
|
||||
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
|
||||
gh release upload "$TAG" --repo "$REPO" "/tmp/$YML_FILE" --clobber
|
||||
retry_cmd 5 3 gh release upload "$TAG" --repo "$REPO" "/tmp/$YML_FILE" --clobber
|
||||
done
|
||||
|
||||
release-linux:
|
||||
@@ -100,7 +109,6 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
@@ -114,7 +122,7 @@ jobs:
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
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
|
||||
shell: bash
|
||||
@@ -126,20 +134,23 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
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"
|
||||
gh release download "$TAG" --repo "$REPO" --pattern "latest-linux.yml" --output "/tmp/latest-linux.yml" 2>/dev/null
|
||||
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
|
||||
gh release upload "$TAG" --repo "$REPO" /tmp/latest-linux.yml --clobber
|
||||
retry_cmd 5 3 gh release upload "$TAG" --repo "$REPO" /tmp/latest-linux.yml --clobber
|
||||
fi
|
||||
|
||||
release:
|
||||
@@ -156,7 +167,6 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
@@ -165,7 +175,7 @@ jobs:
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
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
|
||||
shell: bash
|
||||
@@ -177,20 +187,23 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
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"
|
||||
gh release download "$TAG" --repo "$REPO" --pattern "latest.yml" --output "/tmp/latest.yml" 2>/dev/null
|
||||
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
|
||||
gh release upload "$TAG" --repo "$REPO" /tmp/latest.yml --clobber
|
||||
retry_cmd 5 3 gh release upload "$TAG" --repo "$REPO" /tmp/latest.yml --clobber
|
||||
fi
|
||||
|
||||
release-windows-arm64:
|
||||
@@ -207,7 +220,6 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
@@ -216,7 +228,7 @@ jobs:
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
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
|
||||
shell: bash
|
||||
@@ -228,20 +240,23 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
npx electron-builder --win nsis --arm64 --publish always '--config.publish.channel=latest-arm64' '--config.artifactName=${productName}-${version}-arm64-Setup.${ext}'
|
||||
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"
|
||||
gh release download "$TAG" --repo "$REPO" --pattern "latest-arm64.yml" --output "/tmp/latest-arm64.yml" 2>/dev/null
|
||||
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
|
||||
gh release upload "$TAG" --repo "$REPO" /tmp/latest-arm64.yml --clobber
|
||||
retry_cmd 5 3 gh release upload "$TAG" --repo "$REPO" /tmp/latest-arm64.yml --clobber
|
||||
fi
|
||||
|
||||
update-release-notes:
|
||||
@@ -253,18 +268,25 @@ jobs:
|
||||
- release-windows-arm64
|
||||
|
||||
steps:
|
||||
- name: Check out git repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Generate release notes with platform download links
|
||||
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"
|
||||
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() {
|
||||
local pattern="$1"
|
||||
@@ -277,6 +299,9 @@ jobs:
|
||||
fi
|
||||
WINDOWS_ARM64_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("arm64.*\\.exe$"))][0] // ""')"
|
||||
MAC_ASSET="$(pick_asset "\\.dmg$")"
|
||||
if [ -z "$MAC_ASSET" ]; then
|
||||
MAC_ASSET="$(pick_asset "arm64\\.zip$")"
|
||||
fi
|
||||
LINUX_TAR_ASSET="$(pick_asset "\\.tar\\.gz$")"
|
||||
LINUX_APPIMAGE_ASSET="$(pick_asset "\\.AppImage$")"
|
||||
|
||||
@@ -315,23 +340,52 @@ jobs:
|
||||
> 如果某个平台链接暂时未生成,可进入[完整发布页]($RELEASE_PAGE)查看全部资源
|
||||
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
|
||||
runs-on: ubuntu-latest
|
||||
needs: [release-linux]
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
steps:
|
||||
- name: Check AUR credentials
|
||||
id: aur-credentials
|
||||
shell: bash
|
||||
env:
|
||||
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||
run: |
|
||||
if [ -z "${AUR_SSH_PRIVATE_KEY}" ]; then
|
||||
echo "::notice::AUR_SSH_PRIVATE_KEY is not configured; skipping AUR publish."
|
||||
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Publish AUR package
|
||||
uses: KSXGitHub/github-actions-deploy-aur@master
|
||||
with:
|
||||
pkgname: weflow
|
||||
ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||
commit_username: H3CoF6
|
||||
commit_email: h3cof6@gmail.com
|
||||
ssh_keyscan_types: ed25519
|
||||
- name: Checkout code
|
||||
if: steps.aur-credentials.outputs.enabled == 'true'
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Update PKGBUILD version
|
||||
if: steps.aur-credentials.outputs.enabled == 'true'
|
||||
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
|
||||
if: steps.aur-credentials.outputs.enabled == 'true'
|
||||
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
|
||||
resources/installer/linux/.gitignore
|
||||
|
||||
ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||
commit_username: H3CoF6
|
||||
commit_email: h3cof6@gmail.com
|
||||
ssh_keyscan_types: ed25519
|
||||
|
||||
96
.github/workflows/security-scan.yml
vendored
96
.github/workflows/security-scan.yml
vendored
@@ -1,96 +0,0 @@
|
||||
name: Security Scan
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 2 * * *' # 每天 UTC 02:00
|
||||
workflow_dispatch: # 手动触发
|
||||
pull_request: # PR 时触发
|
||||
branches: [ main, dev ]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
security-scan:
|
||||
name: Security Scan (${{ matrix.branch }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
branch:
|
||||
- main
|
||||
|
||||
steps:
|
||||
- name: Checkout ${{ matrix.branch }}
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ matrix.branch }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm' # 使用 npm 缓存加速
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
# 1. npm audit - 检查依赖漏洞
|
||||
- name: Dependency vulnerability audit
|
||||
run: npm audit --audit-level=moderate
|
||||
continue-on-error: true
|
||||
|
||||
# 2. CodeQL 静态分析
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: javascript, typescript
|
||||
queries: security-and-quality
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: '/language:javascript-typescript/branch:${{ matrix.branch }}'
|
||||
|
||||
# 3. 密钥/敏感信息扫描
|
||||
- name: Secret scanning with Gitleaks
|
||||
uses: gitleaks/gitleaks-action@v2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
continue-on-error: true
|
||||
|
||||
# 动态获取所有分支并扫描
|
||||
scan-all-branches:
|
||||
name: Scan additional branches
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Run npm audit on all branches
|
||||
run: |
|
||||
git branch -r | grep -v HEAD | sed 's|origin/||' | tr -d ' ' | while read branch; do
|
||||
echo "===== Auditing branch: $branch ====="
|
||||
git checkout "$branch" 2>/dev/null || continue
|
||||
# 尝试安装并审计
|
||||
npm ci --ignore-scripts --silent 2>/dev/null || npm install --ignore-scripts --silent 2>/dev/null || true
|
||||
npm audit --audit-level=moderate 2>/dev/null || true
|
||||
done
|
||||
continue-on-error: true
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -75,4 +75,6 @@ pnpm-lock.yaml
|
||||
wechat-research-site
|
||||
.codex
|
||||
weflow-web-offical
|
||||
Insight
|
||||
/Wedecrypt
|
||||
/scripts/syncwcdb.py
|
||||
/scripts/syncWedecrypt.py
|
||||
111
README.md
111
README.md
@@ -2,27 +2,33 @@
|
||||
|
||||
WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析与导出工具。它可以实时获取你的微信聊天记录并将其导出,还可以根据你的聊天记录为你生成独一无二的分析报告。
|
||||
|
||||
---
|
||||
|
||||
**WeFlow** is a fully local tool for viewing, analyzing, and exporting WeChat chat history in real time. It generates unique analysis reports based on your chat history.
|
||||
|
||||
<p align="center">
|
||||
<img src="app.png" alt="WeFlow 应用预览" width="90%">
|
||||
<img src="app.jpg" alt="WeFlow 应用预览" width="90%">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<!-- 第一行修复样式 -->
|
||||
<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 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/issues"><img src="https://img.shields.io/github/issues/hicccc77/WeFlow?style=flat&label=Issues&labelColor=1F2937&color=D97706" alt="Issues"></a>
|
||||
<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>
|
||||
<br><br>
|
||||
<!-- 第二行:电报矮一点(22px),排名高一点(32px),使用 vertical-align: middle 居中对齐 -->
|
||||
<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>
|
||||
<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>
|
||||
</p>
|
||||
|
||||
> [!TIP]
|
||||
> 如果导出聊天记录后,想深入分析聊天内容可以试试 [ChatLab](https://chatlab.fun/)
|
||||
>
|
||||
> If you want to analyze your exported chat content in depth, try [ChatLab](https://chatlab.fun/)
|
||||
|
||||
> [!NOTE]
|
||||
> 仅支持微信 **4.0 及以上**版本,确保你的微信版本符合要求
|
||||
>
|
||||
> Only supports WeChat **version 4.0 and above**. Please ensure your WeChat version meets the requirements.
|
||||
|
||||
## 主要功能
|
||||
|
||||
@@ -34,6 +40,18 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
|
||||
- HTTP API 接口(供开发者集成)
|
||||
- 查看完整能力清单:[详细功能](#详细功能清单)
|
||||
|
||||
---
|
||||
|
||||
**Key Features**
|
||||
|
||||
- View chat history locally in real-time
|
||||
- Preview and decrypt Moments photos, videos, and **Live Photos**
|
||||
- Statistical analysis and group chat insights
|
||||
- Annual reports and visual overviews
|
||||
- Export chat history to HTML and other formats
|
||||
- HTTP API (for developer integration)
|
||||
- View complete feature list: [Detailed Features](#详细功能清单)
|
||||
|
||||
## 支持平台与设备
|
||||
|
||||
| 平台 | 设备/架构 | 安装包 |
|
||||
@@ -42,12 +60,30 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
|
||||
| macOS | Apple Silicon(M 系列,arm64) | `.dmg` |
|
||||
| Linux | x64 设备(amd64) | `.AppImage`、`.tar.gz` |
|
||||
|
||||
---
|
||||
|
||||
**Supported Platforms & Devices**
|
||||
|
||||
| Platform | Device/Architecture | Package |
|
||||
|----------|---------------------|---------|
|
||||
| Windows | Windows 10+, x64 (amd64) | `.exe` |
|
||||
| macOS | Apple Silicon (M series, arm64) | `.dmg` |
|
||||
| Linux | x64 devices (amd64) | `.AppImage`, `.tar.gz` |
|
||||
|
||||
## 快速开始
|
||||
|
||||
若你只想使用成品版本,可前往 [Releases](https://github.com/hicccc77/WeFlow/releases) 下载并安装。
|
||||
|
||||
> ArchLinux 用户可以选择 `yay -S weflow` 快速安装
|
||||
|
||||
---
|
||||
|
||||
**Quick Start**
|
||||
|
||||
If you just want to use the pre-compiled application, go to [Releases](https://github.com/hicccc77/WeFlow/releases) to download and install.
|
||||
|
||||
> ArchLinux users can quickly install with `yay -S weflow`
|
||||
|
||||
## 详细功能清单
|
||||
|
||||
当前版本已支持以下能力:
|
||||
@@ -66,6 +102,26 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
|
||||
| **联系人** | 导出微信好友、群聊、公众号信息;尝试找回曾经的好友(功能尚不完善) |
|
||||
| **HTTP API 映射** | 将本地消息能力映射为 HTTP API,便于对接外部系统、自动化脚本与二次开发 |
|
||||
|
||||
---
|
||||
|
||||
**Detailed Feature List**
|
||||
|
||||
The current version supports the following capabilities:
|
||||
|
||||
| Feature Module | Description |
|
||||
|----------------|-------------|
|
||||
| **Chat** | Decrypt images, videos, and Live Photos in chats (only supports Live Photos captured with Google protocol); supports **modifying** and deleting **local** messages; real-time refresh of latest messages without generating decrypted intermediate databases |
|
||||
| **Anti-Recall** | Prevent messages sent by others from being recalled |
|
||||
| **Real-time Notifications** | Desktop popup notifications when new messages arrive, convenient for timely viewing of important conversations, with blacklist/whitelist functionality |
|
||||
| **Private Chat Analysis** | Statistics on message counts between friends; analysis of message types and sending ratios; view message time distribution, etc. |
|
||||
| **Group Chat Analysis** | View detailed group member information; analyze group activity rankings, active periods, and media content |
|
||||
| **Annual Report** | Generate annual reports by year, or long-term historical reports across years |
|
||||
| **Duo Report** | Select a specific friend and generate an exclusive analysis report based on your mutual chat history |
|
||||
| **Message Export** | Export WeChat chat history to multiple formats: JSON, HTML, TXT, Excel, CSV, PGSQL, ChatLab proprietary format, etc. |
|
||||
| **Moments** | Decrypt Moments photos, videos, and Live Photos; export Moments content; intercept deletion and hiding operations in Moments; bypass time-based access restrictions |
|
||||
| **Contacts** | Export WeChat friends, group chats, and official account information; attempt to recover deleted friends (work in progress) |
|
||||
| **HTTP API** | Map local message capabilities to HTTP API for easy integration with external systems, automation scripts, and secondary development |
|
||||
|
||||
## HTTP API
|
||||
|
||||
> [!WARNING]
|
||||
@@ -80,6 +136,20 @@ WeFlow 提供本地 HTTP API 服务,支持通过接口查询消息数据,可
|
||||
|
||||
完整接口文档:[点击查看](docs/HTTP-API.md)
|
||||
|
||||
---
|
||||
|
||||
> [!WARNING]
|
||||
> This feature is currently in its early stages, and the interface may change. Stay tuned for future updates.
|
||||
|
||||
WeFlow provides a local HTTP API service that supports querying message data through interfaces, which can be used for integration with other tools or secondary development.
|
||||
|
||||
- **Enable Method**: Settings → API Service → Start Service
|
||||
- **Default Port**: 5031
|
||||
- **Access Address**: `http://127.0.0.1:5031`
|
||||
- **Supported Formats**: Raw JSON or [ChatLab](https://chatlab.fun/) standard format
|
||||
|
||||
Complete API documentation: [Click to view](docs/HTTP-API.md)
|
||||
|
||||
## 面向开发者
|
||||
|
||||
如果你想从源码构建或为项目贡献代码,请遵循以下步骤:
|
||||
@@ -96,17 +166,50 @@ npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**For Developers**
|
||||
|
||||
If you want to build from source or contribute code to the project, please follow these steps:
|
||||
|
||||
```bash
|
||||
# 1. Clone the project locally
|
||||
git clone https://github.com/hicccc77/WeFlow.git
|
||||
cd WeFlow
|
||||
|
||||
# 2. Install project dependencies
|
||||
npm install
|
||||
|
||||
# 3. Run the application (development mode)
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 致谢
|
||||
|
||||
- [密语 CipherTalk](https://github.com/ILoveBingLu/miyu) 为本项目提供了基础框架
|
||||
- [WeChat-Channels-Video-File-Decryption](https://github.com/Evil0ctal/WeChat-Channels-Video-File-Decryption) 提供了视频解密相关的技术参考
|
||||
|
||||
---
|
||||
|
||||
**Acknowledgments**
|
||||
|
||||
- [CipherTalk](https://github.com/ILoveBingLu/miyu) provided the basic framework for this project
|
||||
- [WeChat-Channels-Video-File-Decryption](https://github.com/Evil0ctal/WeChat-Channels-Video-File-Decryption) provided technical references for video decryption
|
||||
|
||||
## 支持我们
|
||||
|
||||
如果 WeFlow 确实帮到了你,可以考虑请我们喝杯咖啡:
|
||||
|
||||
> TRC20 **Address:** `TZCtAw8CaeARWZBfvjidCnTcfnAtf6nvS6`
|
||||
|
||||
---
|
||||
|
||||
**Support Us**
|
||||
|
||||
If WeFlow has truly helped you, consider buying us a coffee:
|
||||
|
||||
> TRC20 **Address:** `TZCtAw8CaeARWZBfvjidCnTcfnAtf6nvS6`
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/#hicccc77/WeFlow&type=date&legend=top-left">
|
||||
@@ -123,4 +226,6 @@ npm run dev
|
||||
|
||||
**请负责任地使用本工具,遵守相关法律法规**
|
||||
|
||||
**Please use this tool responsibly and comply with relevant laws and regulations**
|
||||
|
||||
</div>
|
||||
|
||||
282
docs/HTTP-API.md
282
docs/HTTP-API.md
@@ -27,8 +27,8 @@ WeFlow 提供本地 HTTP API(已支持GET 和 POST请求),便于外部脚
|
||||
- `GET|POST /api/v1/health`
|
||||
- `GET|POST /api/v1/push/messages`
|
||||
- `GET|POST /api/v1/messages`
|
||||
- `GET|POST /api/v1/messages/new`
|
||||
- `GET|POST /api/v1/sessions`
|
||||
- `GET /api/v1/sessions/:id/messages` (ChatLab Pull)
|
||||
- `GET|POST /api/v1/contacts`
|
||||
- `GET|POST /api/v1/group-members`
|
||||
- `GET|POST /api/v1/media/*`
|
||||
@@ -74,18 +74,19 @@ GET /api/v1/push/messages
|
||||
- 需要先在设置页开启 `HTTP API 服务`
|
||||
- 同时需要开启 `主动推送`
|
||||
- 响应类型为 `text/event-stream`
|
||||
- 新消息事件名固定为 `message.new`
|
||||
- 建议接收端按 `messageKey` 去重
|
||||
- 事件名包含 `message.new` 和 `message.revoke`
|
||||
- 建议接收端按 `event + rawid` 去重
|
||||
|
||||
### 事件字段
|
||||
|
||||
- `event`
|
||||
- `sessionId`
|
||||
- `messageKey`
|
||||
- `rawid`
|
||||
- `avatarUrl`
|
||||
- `sourceName`
|
||||
- `groupName`(仅群聊)
|
||||
- `content`
|
||||
- `timestamp`(消息时间,秒级 Unix 时间戳)
|
||||
|
||||
### 示例
|
||||
|
||||
@@ -97,7 +98,14 @@ curl -N "http://127.0.0.1:5031/api/v1/push/messages?access_token=YOUR_TOKEN
|
||||
|
||||
```text
|
||||
event: message.new
|
||||
data: {"event":"message.new","sessionId":"xxx@chatroom","messageKey":"server:123456:1760000123:1760000123000:321:wxid_member:1","avatarUrl":"https://example.com/group.jpg","sourceName":"李四","groupName":"项目群","content":"[图片]"}
|
||||
data: {"event":"message.new","sessionId":"xxx@chatroom","sessionType":"group","rawid":"1234567890123456789","avatarUrl":"https://example.com/group.jpg","sourceName":"李四","groupName":"项目群","content":"[图片]","timestamp":1760000123}
|
||||
```
|
||||
|
||||
撤回事件示例:
|
||||
|
||||
```text
|
||||
event: message.revoke
|
||||
data: {"event":"message.revoke","sessionId":"wxid_xxx","sessionType":"other","rawid":"1234567890123456789","avatarUrl":"https://example.com/avatar.jpg","sourceName":"张三","content":"对方撤回了一条消息(rawid:1234567890123456789) 内容为“你好”","timestamp":1760000180}
|
||||
```
|
||||
|
||||
---
|
||||
@@ -116,21 +124,21 @@ GET /api/v1/messages
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `talker` | string | 是 | 会话 ID。私聊通常是对方 `wxid`,群聊是 `xxx@chatroom` |
|
||||
| `limit` | number | 否 | 返回条数,默认 `100`,范围 `1~10000` |
|
||||
| `offset` | number | 否 | 分页偏移,默认 `0` |
|
||||
| `start` | string | 否 | 开始时间,支持 `YYYYMMDD` 或时间戳 |
|
||||
| `end` | string | 否 | 结束时间,支持 `YYYYMMDD` 或时间戳 |
|
||||
| `keyword` | string | 否 | 基于消息显示文本过滤 |
|
||||
| `chatlab` | string | 否 | `1/true` 时输出 ChatLab 格式 |
|
||||
| `format` | string | 否 | `json` 或 `chatlab` |
|
||||
| `media` | string | 否 | `1/true` 时导出媒体并返回媒体地址,兼容别名 `meiti` |
|
||||
| `image` | string | 否 | 在 `media=1` 时控制图片导出,兼容别名 `tupian` |
|
||||
| `voice` | string | 否 | 在 `media=1` 时控制语音导出,兼容别名 `vioce` |
|
||||
| `video` | string | 否 | 在 `media=1` 时控制视频导出 |
|
||||
| `emoji` | string | 否 | 在 `media=1` 时控制表情导出 |
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --------- | ------ | ---- | ----------------------------------------------------- |
|
||||
| `talker` | string | 是 | 会话 ID。私聊通常是对方 `wxid`,群聊是 `xxx@chatroom` |
|
||||
| `limit` | number | 否 | 返回条数,默认 `100`,范围 `1~10000` |
|
||||
| `offset` | number | 否 | 分页偏移,默认 `0` |
|
||||
| `start` | string | 否 | 开始时间,支持 `YYYYMMDD` 或时间戳 |
|
||||
| `end` | string | 否 | 结束时间,支持 `YYYYMMDD` 或时间戳 |
|
||||
| `keyword` | string | 否 | 基于消息显示文本过滤 |
|
||||
| `chatlab` | string | 否 | `1/true` 时输出 ChatLab 格式 |
|
||||
| `format` | string | 否 | `json` 或 `chatlab` |
|
||||
| `media` | string | 否 | `1/true` 时导出媒体并返回媒体地址,兼容别名 `meiti` |
|
||||
| `image` | string | 否 | 在 `media=1` 时控制图片导出,兼容别名 `tupian` |
|
||||
| `voice` | string | 否 | 在 `media=1` 时控制语音导出,兼容别名 `vioce` |
|
||||
| `video` | string | 否 | 在 `media=1` 时控制视频导出 |
|
||||
| `emoji` | string | 否 | 在 `media=1` 时控制表情导出 |
|
||||
|
||||
### 示例
|
||||
|
||||
@@ -165,6 +173,8 @@ curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&media=1&image=1&
|
||||
- `content`
|
||||
- `rawContent`
|
||||
- `parsedContent`
|
||||
- `replyToMessageId`(引用回复目标消息的 `serverId`,仅引用消息返回)
|
||||
- `quote`(引用消息快照,包含被引用消息的 ID、发送者、内容和类型)
|
||||
- `mediaType`
|
||||
- `mediaFileName`
|
||||
- `mediaUrl`
|
||||
@@ -176,7 +186,7 @@ curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&media=1&image=1&
|
||||
{
|
||||
"success": true,
|
||||
"talker": "xxx@chatroom",
|
||||
"count": 2,
|
||||
"count": 3,
|
||||
"hasMore": true,
|
||||
"media": {
|
||||
"enabled": true,
|
||||
@@ -186,7 +196,7 @@ curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&media=1&image=1&
|
||||
"messages": [
|
||||
{
|
||||
"localId": 123,
|
||||
"serverId": "456",
|
||||
"serverId": "6116895530414915131",
|
||||
"localType": 1,
|
||||
"createTime": 1738713600,
|
||||
"isSend": 0,
|
||||
@@ -195,6 +205,25 @@ curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&media=1&image=1&
|
||||
"rawContent": "你好",
|
||||
"parsedContent": "你好"
|
||||
},
|
||||
{
|
||||
"localId": 125,
|
||||
"serverId": "6116895530414915133",
|
||||
"localType": 244813135921,
|
||||
"createTime": 1738713700,
|
||||
"isSend": 0,
|
||||
"senderUsername": "wxid_member",
|
||||
"content": "收到",
|
||||
"rawContent": "<msg>...</msg>",
|
||||
"parsedContent": "收到",
|
||||
"replyToMessageId": "6116895530414915131",
|
||||
"quote": {
|
||||
"platformMessageId": "6116895530414915131",
|
||||
"sender": "wxid_other",
|
||||
"accountName": "张三",
|
||||
"content": "你好",
|
||||
"type": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"localId": 124,
|
||||
"localType": 3,
|
||||
@@ -235,6 +264,7 @@ curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&media=1&image=1&
|
||||
- `messages[].type`
|
||||
- `messages[].content`
|
||||
- `messages[].platformMessageId`
|
||||
- `messages[].replyToMessageId`
|
||||
- `messages[].mediaPath`
|
||||
|
||||
群聊里 `groupNickname` 会优先来自群成员群昵称;若源数据缺失,则回退为空或展示名。
|
||||
@@ -253,10 +283,10 @@ GET /api/v1/sessions
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `keyword` | string | 否 | 匹配 `username` 或 `displayName` |
|
||||
| `limit` | number | 否 | 默认 `100` |
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --------- | ------ | ---- | -------------------------------- |
|
||||
| `keyword` | string | 否 | 匹配 `username` 或 `displayName` |
|
||||
| `limit` | number | 否 | 默认 `100` |
|
||||
|
||||
### 响应字段
|
||||
|
||||
@@ -288,6 +318,130 @@ 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. 获取联系人列表
|
||||
|
||||
> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json)
|
||||
@@ -300,10 +454,10 @@ GET /api/v1/contacts
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `keyword` | string | 否 | 匹配 `username`、`nickname`、`remark`、`displayName` |
|
||||
| `limit` | number | 否 | 默认 `100` |
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --------- | ------ | ---- | ---------------------------------------------------- |
|
||||
| `keyword` | string | 否 | 匹配 `username`、`nickname`、`remark`、`displayName` |
|
||||
| `limit` | number | 否 | 默认 `100` |
|
||||
|
||||
### 响应字段
|
||||
|
||||
@@ -353,12 +507,12 @@ GET /api/v1/group-members
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `chatroomId` | string | 是 | 群 ID,兼容使用 `talker` 传入 |
|
||||
| `includeMessageCounts` | string | 否 | `1/true` 时附带成员发言数 |
|
||||
| `withCounts` | string | 否 | `includeMessageCounts` 的别名 |
|
||||
| `forceRefresh` | string | 否 | `1/true` 时跳过内存缓存强制刷新 |
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| ---------------------- | ------ | ---- | ------------------------------- |
|
||||
| `chatroomId` | string | 是 | 群 ID,兼容使用 `talker` 传入 |
|
||||
| `includeMessageCounts` | string | 否 | `1/true` 时附带成员发言数 |
|
||||
| `withCounts` | string | 否 | `includeMessageCounts` 的别名 |
|
||||
| `forceRefresh` | string | 否 | `1/true` 时跳过内存缓存强制刷新 |
|
||||
|
||||
### 响应字段
|
||||
|
||||
@@ -443,17 +597,17 @@ 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` |
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| ----------- | ------ | ---- | ------------------------------------------------------------ |
|
||||
| `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` |
|
||||
|
||||
示例:
|
||||
|
||||
@@ -490,9 +644,9 @@ GET /api/v1/sns/export/stats
|
||||
|
||||
参数:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `fast` | number | 否 | `1` 使用快速统计(优先缓存) |
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| ------ | ------ | ---- | ---------------------------- |
|
||||
| `fast` | number | 否 | `1` 使用快速统计(优先缓存) |
|
||||
|
||||
### 7.4 朋友圈媒体代理
|
||||
|
||||
@@ -502,10 +656,10 @@ GET /api/v1/sns/media/proxy
|
||||
|
||||
参数:
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `url` | string | 是 | 媒体原始 URL |
|
||||
| `key` | string/number | 否 | 解密 key(部分资源需要) |
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
| ----- | ------------- | ---- | ------------------------ |
|
||||
| `url` | string | 是 | 媒体原始 URL |
|
||||
| `key` | string/number | 否 | 解密 key(部分资源需要) |
|
||||
|
||||
### 7.5 导出朋友圈
|
||||
|
||||
@@ -572,15 +726,15 @@ curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/emojis/emoji_300.gif"
|
||||
|
||||
### 支持的 Content-Type
|
||||
|
||||
| 扩展名 | Content-Type |
|
||||
| --- | --- |
|
||||
| `.png` | `image/png` |
|
||||
| 扩展名 | Content-Type |
|
||||
| ---------------- | ------------ |
|
||||
| `.png` | `image/png` |
|
||||
| `.jpg` / `.jpeg` | `image/jpeg` |
|
||||
| `.gif` | `image/gif` |
|
||||
| `.webp` | `image/webp` |
|
||||
| `.wav` | `audio/wav` |
|
||||
| `.mp3` | `audio/mpeg` |
|
||||
| `.mp4` | `video/mp4` |
|
||||
| `.gif` | `image/gif` |
|
||||
| `.webp` | `image/webp` |
|
||||
| `.wav` | `audio/wav` |
|
||||
| `.mp3` | `audio/mpeg` |
|
||||
| `.mp4` | `video/mp4` |
|
||||
|
||||
常见错误响应:
|
||||
|
||||
@@ -626,8 +780,8 @@ headers = {"Authorization": "Bearer YOUR_TOKEN", "Content-Type": "application/js
|
||||
|
||||
# POST 方式获取消息
|
||||
messages = requests.post(
|
||||
f"{BASE_URL}/api/v1/messages",
|
||||
json={"talker": "xxx@chatroom", "limit": 50},
|
||||
f"{BASE_URL}/api/v1/messages",
|
||||
json={"talker": "xxx@chatroom", "limit": 50},
|
||||
headers=headers
|
||||
).json()
|
||||
|
||||
|
||||
54
docs/MAC-KEY-FAQ.md
Normal file
54
docs/MAC-KEY-FAQ.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# macOS 微信密钥自动获取失败排障指南
|
||||
|
||||
如果你在 macOS 系统下,遇到了 WeFlow 自动获取微信数据库密钥失败的问题,这篇指南或许可以帮到你。
|
||||
|
||||
### 请立刻停止连续重试
|
||||
|
||||
当你看到下面这些报错时,请务必暂停操作,不要再去反复点击获取:
|
||||
|
||||
- SCAN_FAILED,通常伴随 No suitable module found 或 Sink pattern not found
|
||||
- HOOK_FAILED 或 Native Hook Failed
|
||||
- patch_breakpoint_failed
|
||||
- thread_get_state_failed
|
||||
|
||||
现在的 macOS 系统和微信防护机制非常敏锐。连续的重试动作不仅无法解决问题,反而容易被判定为异常行为,进而触发微信的安全模式或系统级的内存保护。
|
||||
|
||||
### 可能的尝试流程
|
||||
|
||||
根据大量社区用户的反馈,如果你已经遇到了获取失败的情况,按照下面的步骤顺序操作,通常都能顺利解决问题:
|
||||
|
||||
1. **降级微信版本**。找一个经过大家验证、兼容性更好的老版本,目前最推荐先退回到 4.1.7.57 或者 4.1.8.100。
|
||||
2. **彻底退出微信**。请使用快捷键 Command + Q 或在活动监视器中结束进程,而不仅仅是关闭窗口。
|
||||
3. **重启你的 Mac**。这一步极其关键,必须是真正的重新启动。注销或睡眠唤醒无法清除系统底层的拦截状态。
|
||||
4. **重新打开微信**。随便点击几下保持它在最前台,并且确保它是未登录的状态。
|
||||
5. **回到 WeFlow**。仅仅尝试一次“自动获取密钥”。
|
||||
6. **输入密码并登录**。先在弹窗中输入你的系统密码后,确认页面弹出允许登录了再登录微信
|
||||
7. **恢复日常使用**。只要成功拿到了密钥,你就可以放心地把微信更新回你平时爱用的最新版本。
|
||||
|
||||
### 常见报错与应对方法
|
||||
|
||||
为了方便排查,这里列出了几类最常见的报错及其背后的原因和对策:
|
||||
|
||||
**SCAN_FAILED: No suitable module found**
|
||||
这意味着微信的内存布局并不标准,或者目标模块没有被命中。你可以先确保微信完整启动并保持在前台。如果还是不行,请直接执行上面提到的“降级、重启电脑、获取、再升级”的完整流程。
|
||||
|
||||
**SCAN_FAILED: Sink pattern not found**
|
||||
这说明 WeFlow 还没有适配你当前正在使用的微信版本特征。最快的解决办法是直接降级到微信 4.1.7 或 4.1.8.100 版本再试。
|
||||
|
||||
**patch_breakpoint_failed 或 thread_get_state_failed**
|
||||
这类错误大多是因为调试断点注入或线程状态读取被 macOS 系统的安全机制拦截了。此时继续尝试毫无意义,彻底退出微信并重启电脑再试。
|
||||
|
||||
**task_for_pid:5**
|
||||
这是进程附加权限被系统拒绝的提示。请确保你使用的是打包好的 WeFlow.app,同时检查系统的签名与调试权限是否已经正确配置。
|
||||
|
||||
### 关于推荐版本的补充说明
|
||||
|
||||
截至 2026 年 4 月,综合社区的反馈来看,微信 4.1.7 和 4.1.8.100 版本在密钥获取流程中的表现最为稳定,成功率最高。
|
||||
|
||||
这并不意味着其他新版本绝对无法获取,只是作为当前的排障参考。未来 WeFlow 也会在后续的更新中逐步适配新版微信的特征,建议大家多留意项目的 Release 动态。
|
||||
|
||||
### 最后的几点建议
|
||||
|
||||
首次失败后,首要任务是排查原因,切忌盲目地连续点击自动获取。如果你在看到这篇文档前已经失败了好几次,最好的做法是直接清零重来:彻底退出微信,重启电脑,然后再进行下一次尝试。
|
||||
|
||||
最后,如果尝试了上述所有方法依然无法解决,请记得保存完整的报错文本,特别是 SCAN_FAILED 或 HOOK_FAILED 后面跟着的英文细节。把这些信息提交到[issue](https://github.com/hicccc77/WeFlow/issues/745),会大大加快定位和修复兼容性问题的速度。
|
||||
@@ -1,19 +1,132 @@
|
||||
import { parentPort, workerData } from 'worker_threads'
|
||||
import type { ExportOptions } from './services/exportService'
|
||||
|
||||
interface ExportWorkerConfig {
|
||||
sessionIds: string[]
|
||||
outputDir: string
|
||||
options: ExportOptions
|
||||
mode?: 'sessions' | 'single' | 'contacts'
|
||||
sessionIds?: string[]
|
||||
sessionId?: string
|
||||
outputDir?: string
|
||||
outputPath?: string
|
||||
options?: any
|
||||
taskId?: string
|
||||
dbPath?: string
|
||||
decryptKey?: string
|
||||
myWxid?: string
|
||||
imageXorKey?: unknown
|
||||
imageAesKey?: string
|
||||
resourcesPath?: string
|
||||
userDataPath?: string
|
||||
logEnabled?: boolean
|
||||
}
|
||||
|
||||
const config = workerData as ExportWorkerConfig
|
||||
const controlState = {
|
||||
pauseRequested: false,
|
||||
stopRequested: false
|
||||
}
|
||||
|
||||
const CREATED_PATH_FLUSH_INTERVAL_MS = 200
|
||||
const CREATED_PATH_BATCH_LIMIT = 256
|
||||
const PROGRESS_POST_INTERVAL_MS = 180
|
||||
let queuedCreatedFiles: string[] = []
|
||||
let queuedCreatedDirs: string[] = []
|
||||
let createdPathFlushTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let pendingProgress: any = null
|
||||
let progressPostTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let lastProgressPostedAt = 0
|
||||
|
||||
function flushCreatedPaths() {
|
||||
if (createdPathFlushTimer) {
|
||||
clearTimeout(createdPathFlushTimer)
|
||||
createdPathFlushTimer = null
|
||||
}
|
||||
const filePaths = queuedCreatedFiles
|
||||
const dirPaths = queuedCreatedDirs
|
||||
queuedCreatedFiles = []
|
||||
queuedCreatedDirs = []
|
||||
if (!parentPort) return
|
||||
if (filePaths.length > 0) {
|
||||
parentPort.postMessage({ type: 'export:createdFiles', filePaths })
|
||||
}
|
||||
if (dirPaths.length > 0) {
|
||||
parentPort.postMessage({ type: 'export:createdDirs', dirPaths })
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleCreatedPathFlush() {
|
||||
if (createdPathFlushTimer) return
|
||||
createdPathFlushTimer = setTimeout(flushCreatedPaths, CREATED_PATH_FLUSH_INTERVAL_MS)
|
||||
}
|
||||
|
||||
function queueCreatedFile(filePath: string) {
|
||||
const normalized = String(filePath || '').trim()
|
||||
if (!normalized) return
|
||||
queuedCreatedFiles.push(normalized)
|
||||
if (queuedCreatedFiles.length + queuedCreatedDirs.length >= CREATED_PATH_BATCH_LIMIT) {
|
||||
flushCreatedPaths()
|
||||
} else {
|
||||
scheduleCreatedPathFlush()
|
||||
}
|
||||
}
|
||||
|
||||
function queueCreatedDir(dirPath: string) {
|
||||
const normalized = String(dirPath || '').trim()
|
||||
if (!normalized) return
|
||||
queuedCreatedDirs.push(normalized)
|
||||
if (queuedCreatedFiles.length + queuedCreatedDirs.length >= CREATED_PATH_BATCH_LIMIT) {
|
||||
flushCreatedPaths()
|
||||
} else {
|
||||
scheduleCreatedPathFlush()
|
||||
}
|
||||
}
|
||||
|
||||
function flushProgress() {
|
||||
if (!pendingProgress) return
|
||||
if (progressPostTimer) {
|
||||
clearTimeout(progressPostTimer)
|
||||
progressPostTimer = null
|
||||
}
|
||||
parentPort?.postMessage({
|
||||
type: 'export:progress',
|
||||
data: pendingProgress
|
||||
})
|
||||
pendingProgress = null
|
||||
lastProgressPostedAt = Date.now()
|
||||
}
|
||||
|
||||
function queueProgress(progress: any) {
|
||||
pendingProgress = progress
|
||||
if (progress?.phase === 'complete') {
|
||||
flushProgress()
|
||||
return
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const elapsed = now - lastProgressPostedAt
|
||||
if (elapsed >= PROGRESS_POST_INTERVAL_MS) {
|
||||
flushProgress()
|
||||
return
|
||||
}
|
||||
|
||||
if (progressPostTimer) return
|
||||
progressPostTimer = setTimeout(flushProgress, PROGRESS_POST_INTERVAL_MS - elapsed)
|
||||
}
|
||||
|
||||
parentPort?.on('message', (message: any) => {
|
||||
if (!message || typeof message.type !== 'string') return
|
||||
if (message.type === 'export:pause') {
|
||||
controlState.pauseRequested = true
|
||||
return
|
||||
}
|
||||
if (message.type === 'export:resume') {
|
||||
controlState.pauseRequested = false
|
||||
return
|
||||
}
|
||||
if (message.type === 'export:cancel') {
|
||||
controlState.stopRequested = true
|
||||
controlState.pauseRequested = false
|
||||
}
|
||||
})
|
||||
|
||||
process.env.WEFLOW_WORKER = '1'
|
||||
if (config.resourcesPath) {
|
||||
process.env.WCDB_RESOURCES_PATH = config.resourcesPath
|
||||
@@ -35,20 +148,57 @@ async function run() {
|
||||
exportService.setRuntimeConfig({
|
||||
dbPath: config.dbPath,
|
||||
decryptKey: config.decryptKey,
|
||||
myWxid: config.myWxid
|
||||
myWxid: config.myWxid,
|
||||
imageXorKey: config.imageXorKey,
|
||||
imageAesKey: config.imageAesKey
|
||||
})
|
||||
|
||||
const result = await exportService.exportSessions(
|
||||
Array.isArray(config.sessionIds) ? config.sessionIds : [],
|
||||
String(config.outputDir || ''),
|
||||
config.options || { format: 'json' },
|
||||
(progress) => {
|
||||
parentPort?.postMessage({
|
||||
type: 'export:progress',
|
||||
data: progress
|
||||
})
|
||||
}
|
||||
)
|
||||
const onProgress = (progress: any) => queueProgress(progress)
|
||||
|
||||
const taskControl = config.taskId
|
||||
? {
|
||||
shouldPause: () => controlState.pauseRequested,
|
||||
shouldStop: () => controlState.stopRequested,
|
||||
recordCreatedFile: queueCreatedFile,
|
||||
recordCreatedDir: queueCreatedDir
|
||||
}
|
||||
: undefined
|
||||
|
||||
let result: any
|
||||
if (config.mode === 'contacts') {
|
||||
const [{ contactExportService }, { chatService }] = await Promise.all([
|
||||
import('./services/contactExportService'),
|
||||
import('./services/chatService')
|
||||
])
|
||||
chatService.setRuntimeConfig({
|
||||
dbPath: config.dbPath,
|
||||
decryptKey: config.decryptKey,
|
||||
myWxid: config.myWxid
|
||||
})
|
||||
result = await contactExportService.exportContacts(
|
||||
String(config.outputDir || ''),
|
||||
config.options || {}
|
||||
)
|
||||
} else if (config.mode === 'single') {
|
||||
result = await exportService.exportSessionToChatLab(
|
||||
String(config.sessionId || '').trim(),
|
||||
String(config.outputPath || '').trim(),
|
||||
config.options || { format: 'chatlab' },
|
||||
onProgress,
|
||||
taskControl
|
||||
)
|
||||
} else {
|
||||
result = await exportService.exportSessions(
|
||||
Array.isArray(config.sessionIds) ? config.sessionIds : [],
|
||||
String(config.outputDir || ''),
|
||||
config.options || { format: 'json' },
|
||||
onProgress,
|
||||
taskControl
|
||||
)
|
||||
}
|
||||
|
||||
flushProgress()
|
||||
flushCreatedPaths()
|
||||
|
||||
parentPort?.postMessage({
|
||||
type: 'export:result',
|
||||
@@ -57,6 +207,8 @@ async function run() {
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
flushProgress()
|
||||
flushCreatedPaths()
|
||||
parentPort?.postMessage({
|
||||
type: 'export:error',
|
||||
error: String(error)
|
||||
|
||||
947
electron/main.ts
947
electron/main.ts
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
|
||||
// 暴露给渲染进程的 API
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
@@ -13,7 +13,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
notification: {
|
||||
show: (data: any) => ipcRenderer.invoke('notification:show', data),
|
||||
close: () => ipcRenderer.invoke('notification:close'),
|
||||
click: (sessionId: string) => ipcRenderer.send('notification-clicked', sessionId),
|
||||
click: (payload: any) => ipcRenderer.send('notification-clicked', payload),
|
||||
ready: () => ipcRenderer.send('notification:ready'),
|
||||
resize: (width: number, height: number) => ipcRenderer.send('notification:resize', { width, height }),
|
||||
onShow: (callback: (event: any, data: any) => void) => {
|
||||
@@ -24,6 +24,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
const listener = (_: any, sessionId: string) => callback(sessionId)
|
||||
ipcRenderer.on('navigate-to-session', listener)
|
||||
return () => ipcRenderer.removeListener('navigate-to-session', listener)
|
||||
},
|
||||
onNavigateToRoute: (callback: (route: string) => void) => {
|
||||
const listener = (_: any, route: string) => callback(route)
|
||||
ipcRenderer.on('navigate-to-route', listener)
|
||||
return () => ipcRenderer.removeListener('navigate-to-route', listener)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -110,7 +115,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
ipcRenderer.invoke('window:respondCloseConfirm', action),
|
||||
openAgreementWindow: () => ipcRenderer.invoke('window:openAgreementWindow'),
|
||||
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),
|
||||
openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) =>
|
||||
ipcRenderer.invoke('window:openVideoPlayerWindow', videoPath, videoWidth, videoHeight),
|
||||
@@ -154,6 +159,17 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
|
||||
},
|
||||
|
||||
backup: {
|
||||
create: (payload: { outputPath: string; options?: { includeImages?: boolean; includeVideos?: boolean; includeFiles?: boolean } }) => ipcRenderer.invoke('backup:create', payload),
|
||||
inspect: (payload: { archivePath: string }) => ipcRenderer.invoke('backup:inspect', payload),
|
||||
restore: (payload: { archivePath: string }) => ipcRenderer.invoke('backup:restore', payload),
|
||||
onProgress: (callback: (progress: any) => void) => {
|
||||
const listener = (_: unknown, progress: any) => callback(progress)
|
||||
ipcRenderer.on('backup:progress', listener)
|
||||
return () => ipcRenderer.removeListener('backup:progress', listener)
|
||||
}
|
||||
},
|
||||
|
||||
// 密钥获取
|
||||
key: {
|
||||
autoGetDbKey: () => ipcRenderer.invoke('key:autoGetDbKey'),
|
||||
@@ -174,6 +190,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
chat: {
|
||||
connect: () => ipcRenderer.invoke('chat:connect'),
|
||||
getSessions: () => ipcRenderer.invoke('chat:getSessions'),
|
||||
markAllSessionsRead: () => ipcRenderer.invoke('chat:markAllSessionsRead'),
|
||||
getAntiRevokeSessions: () => ipcRenderer.invoke('chat:getAntiRevokeSessions'),
|
||||
getSessionStatuses: (usernames: string[]) => ipcRenderer.invoke('chat:getSessionStatuses', usernames),
|
||||
getExportTabCounts: () => ipcRenderer.invoke('chat:getExportTabCounts'),
|
||||
getContactTypeCounts: () => ipcRenderer.invoke('chat:getContactTypeCounts'),
|
||||
@@ -219,6 +237,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
allowStaleCache?: boolean
|
||||
preferAccurateSpecialTypes?: boolean
|
||||
cacheOnly?: boolean
|
||||
beginTimestamp?: number
|
||||
endTimestamp?: number
|
||||
}
|
||||
) => ipcRenderer.invoke('chat:getExportSessionStats', sessionIds, options),
|
||||
getGroupMyMessageCountHint: (chatroomId: string) =>
|
||||
@@ -286,24 +306,41 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
|
||||
// 图片解密
|
||||
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),
|
||||
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),
|
||||
resolveCacheBatch: (
|
||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
|
||||
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean }
|
||||
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 }>,
|
||||
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) => {
|
||||
const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => callback(payload)
|
||||
ipcRenderer.on('image:updateAvailable', listener)
|
||||
@@ -334,7 +371,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
}) => callback(payload)
|
||||
ipcRenderer.on('image:decryptProgress', listener)
|
||||
return () => ipcRenderer.removeListener('image:decryptProgress', listener)
|
||||
}
|
||||
},
|
||||
startAutoDownload: (whitelist: string[] | string) => ipcRenderer.invoke('image:startAutoDownload', whitelist),
|
||||
stopAutoDownload: () => ipcRenderer.invoke('image:stopAutoDownload'),
|
||||
getAutoDownloadStatus: () => ipcRenderer.invoke('image:getAutoDownloadStatus')
|
||||
},
|
||||
|
||||
// 视频
|
||||
@@ -343,6 +383,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content)
|
||||
},
|
||||
|
||||
process: {
|
||||
platform: process.platform,
|
||||
arch: process.arch
|
||||
},
|
||||
|
||||
// 数据分析
|
||||
analytics: {
|
||||
getOverallStatistics: (force?: boolean) => ipcRenderer.invoke('analytics:getOverallStatistics', force),
|
||||
@@ -395,6 +440,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
generateReport: (year: number) => ipcRenderer.invoke('annualReport:generateReport', year),
|
||||
exportImages: (payload: { baseDir: string; folderName: string; images: Array<{ name: string; dataUrl: string }> }) =>
|
||||
ipcRenderer.invoke('annualReport:exportImages', payload),
|
||||
captureCurrentWindow: () => ipcRenderer.invoke('annualReport:captureCurrentWindow'),
|
||||
onAvailableYearsProgress: (callback: (payload: {
|
||||
taskId: string
|
||||
years?: number[]
|
||||
@@ -431,8 +477,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
export: {
|
||||
getExportStats: (sessionIds: string[], options: any) =>
|
||||
ipcRenderer.invoke('export:getExportStats', sessionIds, options),
|
||||
exportSessions: (sessionIds: string[], outputDir: string, options: any) =>
|
||||
ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options),
|
||||
exportSessions: (sessionIds: string[], outputDir: string, options: any, controlOptions?: { taskId?: string }) =>
|
||||
ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options, controlOptions),
|
||||
pauseTask: (taskId: string) =>
|
||||
ipcRenderer.invoke('export:pauseTask', taskId),
|
||||
resumeTask: (taskId: string) =>
|
||||
ipcRenderer.invoke('export:resumeTask', taskId),
|
||||
cancelTask: (taskId: string) =>
|
||||
ipcRenderer.invoke('export:cancelTask', taskId),
|
||||
exportSession: (sessionId: string, outputPath: string, options: any) =>
|
||||
ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options),
|
||||
exportContacts: (outputDir: string, options: any) =>
|
||||
@@ -526,6 +578,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
insight: {
|
||||
testConnection: () => ipcRenderer.invoke('insight:testConnection'),
|
||||
getTodayStats: () => ipcRenderer.invoke('insight:getTodayStats'),
|
||||
listRecords: (filters?: any) => ipcRenderer.invoke('insight:listRecords', filters),
|
||||
getRecord: (id: string) => ipcRenderer.invoke('insight:getRecord', id),
|
||||
markRecordRead: (id: string) => ipcRenderer.invoke('insight:markRecordRead', id),
|
||||
clearRecords: (filters?: any) => ipcRenderer.invoke('insight:clearRecords', filters),
|
||||
triggerTest: () => ipcRenderer.invoke('insight:triggerTest'),
|
||||
generateFootprintInsight: (payload: {
|
||||
rangeLabel: string
|
||||
@@ -540,5 +596,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
73
electron/services/accountDirResolver.ts
Normal file
73
electron/services/accountDirResolver.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { existsSync, readdirSync, statSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
|
||||
const accountDirCache = new Map<string, string>()
|
||||
|
||||
const cleanAccountDirName = (dirName: string): string => {
|
||||
const trimmed = dirName.trim()
|
||||
if (!trimmed) return trimmed
|
||||
|
||||
if (trimmed.toLowerCase().startsWith('wxid_')) {
|
||||
const match = trimmed.match(/^(wxid_[^_]+)/i)
|
||||
if (match) return match[1]
|
||||
return trimmed
|
||||
}
|
||||
|
||||
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
||||
if (suffixMatch) return suffixMatch[1]
|
||||
|
||||
return trimmed
|
||||
}
|
||||
|
||||
const isDirectory = (path: string): boolean => {
|
||||
try {
|
||||
return statSync(path).isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const resolveAccountDir = (dbPath?: string, wxid?: string): string | null => {
|
||||
if (!dbPath || !wxid) return null
|
||||
|
||||
const cleanedWxid = cleanAccountDirName(wxid)
|
||||
const normalized = dbPath.replace(/[\\/]+$/, '')
|
||||
const cacheKey = `${normalized}|${cleanedWxid.toLowerCase()}`
|
||||
|
||||
const cached = accountDirCache.get(cacheKey)
|
||||
if (cached && existsSync(cached)) return cached
|
||||
if (cached && !existsSync(cached)) {
|
||||
accountDirCache.delete(cacheKey)
|
||||
}
|
||||
|
||||
const lowerWxid = cleanedWxid.toLowerCase()
|
||||
if (!lowerWxid.startsWith('wxid_')) {
|
||||
const direct = join(normalized, cleanedWxid)
|
||||
if (existsSync(direct) && isDirectory(direct)) {
|
||||
accountDirCache.set(cacheKey, direct)
|
||||
return direct
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = readdirSync(normalized)
|
||||
for (const entry of entries) {
|
||||
const entryPath = join(normalized, entry)
|
||||
if (!isDirectory(entryPath)) continue
|
||||
|
||||
const lowerEntry = entry.toLowerCase()
|
||||
const isExactMatch = lowerEntry === lowerWxid
|
||||
const isSuffixMatch = lowerEntry.startsWith(`${lowerWxid}_`)
|
||||
const shouldMatch = lowerWxid.startsWith('wxid_')
|
||||
? isSuffixMatch
|
||||
: (isExactMatch || isSuffixMatch)
|
||||
|
||||
if (shouldMatch) {
|
||||
accountDirCache.set(cacheKey, entryPath)
|
||||
return entryPath
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -103,8 +103,10 @@ class AnalyticsService {
|
||||
if (username === 'filehelper') return false
|
||||
if (username.startsWith('gh_')) return false
|
||||
|
||||
if (username.toLowerCase() === 'weixin') return false
|
||||
|
||||
const excludeList = [
|
||||
'weixin', 'qqmail', 'fmessage', 'medianote', 'floatbottle',
|
||||
'qqmail', 'fmessage', 'medianote', 'floatbottle',
|
||||
'newsapp', 'brandsessionholder', 'brandservicesessionholder',
|
||||
'notifymessage', 'opencustomerservicemsg', 'notification_messages',
|
||||
'userexperience_alarm', 'helper_folders', 'placeholder_foldgroup',
|
||||
@@ -125,13 +127,19 @@ class AnalyticsService {
|
||||
const wxid = this.configService.get('myWxid')
|
||||
const dbPath = this.configService.get('dbPath')
|
||||
const decryptKey = this.configService.get('decryptKey')
|
||||
|
||||
if (!wxid) return { success: false, error: '未配置微信ID' }
|
||||
if (!dbPath) return { success: false, error: '未配置数据库路径' }
|
||||
if (!decryptKey) return { success: false, error: '未配置解密密钥' }
|
||||
|
||||
const cleanedWxid = this.cleanAccountDirName(wxid)
|
||||
const ok = await wcdbService.open(dbPath, decryptKey, cleanedWxid)
|
||||
const accountDir = this.configService.getAccountDir(dbPath, wxid)
|
||||
if (!accountDir) return { success: false, error: '未找到账号目录' }
|
||||
|
||||
const ok = await wcdbService.open(accountDir, decryptKey)
|
||||
if (!ok) return { success: false, error: 'WCDB 打开失败' }
|
||||
|
||||
const cleanedWxid = this.cleanAccountDirName(wxid)
|
||||
|
||||
return { success: true, cleanedWxid }
|
||||
}
|
||||
|
||||
@@ -231,8 +239,7 @@ class AnalyticsService {
|
||||
}
|
||||
|
||||
private async computeAggregateByCursor(sessionIds: string[], beginTimestamp = 0, endTimestamp = 0): Promise<any> {
|
||||
const wxid = this.configService.get('myWxid')
|
||||
const cleanedWxid = wxid ? this.cleanAccountDirName(wxid) : ''
|
||||
const cleanedWxid = this.configService.getMyWxidCleaned() || ''
|
||||
|
||||
const aggregate = {
|
||||
total: 0,
|
||||
@@ -269,8 +276,7 @@ class AnalyticsService {
|
||||
const myWxidLower = cleanedWxid.toLowerCase()
|
||||
isSend = (
|
||||
senderLower === myWxidLower ||
|
||||
// 兼容非 wxid 开头的账号(如果文件夹名带后缀,如 custom_backup,而 sender 是 custom)
|
||||
(myWxidLower.startsWith(senderLower + '_'))
|
||||
senderLower.startsWith(myWxidLower + '_')
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { parentPort } from 'worker_threads'
|
||||
import { wcdbService } from './wcdbService'
|
||||
import { resolveAccountDir } from './accountDirResolver'
|
||||
|
||||
export interface TopContact {
|
||||
username: string
|
||||
@@ -59,6 +60,8 @@ export interface AnnualReportData {
|
||||
initiatedChats: number
|
||||
receivedChats: number
|
||||
initiativeRate: number
|
||||
topInitiatedFriend?: string
|
||||
topInitiatedCount?: number
|
||||
} | null
|
||||
responseSpeed: {
|
||||
avgResponseTime: number
|
||||
@@ -156,9 +159,13 @@ class AnnualReportService {
|
||||
if (!dbPath) return { success: false, error: '未配置数据库路径' }
|
||||
if (!decryptKey) return { success: false, error: '未配置解密密钥' }
|
||||
|
||||
const cleanedWxid = this.cleanAccountDirName(wxid)
|
||||
const ok = await wcdbService.open(dbPath, decryptKey, cleanedWxid)
|
||||
const accountDir = resolveAccountDir(dbPath, wxid)
|
||||
if (!accountDir) return { success: false, error: '未找到账号目录' }
|
||||
|
||||
const ok = await wcdbService.open(accountDir, decryptKey)
|
||||
if (!ok) return { success: false, error: 'WCDB 打开失败' }
|
||||
|
||||
const cleanedWxid = this.cleanAccountDirName(wxid)
|
||||
return { success: true, cleanedWxid, rawWxid: wxid }
|
||||
}
|
||||
|
||||
@@ -168,7 +175,7 @@ class AnnualReportService {
|
||||
const rows = sessionResult.sessions as Record<string, any>[]
|
||||
|
||||
const excludeList = [
|
||||
'weixin', 'qqmail', 'fmessage', 'medianote', 'floatbottle',
|
||||
'qqmail', 'fmessage', 'medianote', 'floatbottle',
|
||||
'newsapp', 'brandsessionholder', 'brandservicesessionholder',
|
||||
'notifymessage', 'opencustomerservicemsg', 'notification_messages',
|
||||
'userexperience_alarm', 'helper_folders', 'placeholder_foldgroup',
|
||||
@@ -183,6 +190,7 @@ class AnnualReportService {
|
||||
if (username === 'filehelper') return false
|
||||
if (username.startsWith('gh_')) return false
|
||||
if (username.toLowerCase() === cleanedWxid.toLowerCase()) return false
|
||||
if (username.toLowerCase() === 'weixin') return false
|
||||
|
||||
for (const prefix of excludeList) {
|
||||
if (username.startsWith(prefix) || username === prefix) return false
|
||||
@@ -1190,7 +1198,9 @@ class AnnualReportService {
|
||||
topLiked: { username: string; displayName: string; avatarUrl?: string; count: number }[]
|
||||
} | 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) {
|
||||
const d = snsStats.data
|
||||
@@ -1217,6 +1227,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)
|
||||
|
||||
const contactIds = Array.from(contactStats.keys())
|
||||
@@ -1346,16 +1370,27 @@ class AnnualReportService {
|
||||
let socialInitiative: AnnualReportData['socialInitiative'] = null
|
||||
let totalInitiated = 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
|
||||
totalReceived += stats.received
|
||||
if (stats.initiated > topInitiatedCount) {
|
||||
topInitiatedCount = stats.initiated
|
||||
topInitiatedSessionId = sessionId
|
||||
}
|
||||
}
|
||||
const totalConversations = totalInitiated + totalReceived
|
||||
if (totalConversations > 0) {
|
||||
const topInitiatedInfo = topInitiatedSessionId ? contactInfoMap.get(topInitiatedSessionId) : null
|
||||
socialInitiative = {
|
||||
initiatedChats: totalInitiated,
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
1084
electron/services/backupService.ts
Normal file
1084
electron/services/backupService.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -100,7 +100,7 @@ export class BizService {
|
||||
const contactInfoMap = enrichment.success && enrichment.contacts ? enrichment.contacts : {}
|
||||
|
||||
const root = this.configService.get('dbPath')
|
||||
const myWxid = this.configService.get('myWxid')
|
||||
const myWxid = this.configService.getMyWxidCleaned()
|
||||
const accountWxid = account || myWxid
|
||||
if (!root || !accountWxid) return []
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -218,7 +218,7 @@ class CloudControlService {
|
||||
this.pages.add(pageName)
|
||||
}
|
||||
|
||||
stop() {
|
||||
async stop(): Promise<void> {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = null
|
||||
@@ -230,7 +230,13 @@ class CloudControlService {
|
||||
this.circuitOpenedAt = 0
|
||||
this.nextDelayOverrideMs = null
|
||||
this.initialized = false
|
||||
wcdbService.cloudStop()
|
||||
if (wcdbService.isReady()) {
|
||||
try {
|
||||
await wcdbService.cloudStop()
|
||||
} catch {
|
||||
// 忽略停止失败,避免阻塞主进程退出
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getLogs() {
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
import { join } from 'path'
|
||||
import { app, safeStorage } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { existsSync, readdirSync, statSync } from 'fs'
|
||||
import crypto from 'crypto'
|
||||
import Store from 'electron-store'
|
||||
import { expandHomePath } from '../utils/pathUtils'
|
||||
|
||||
// 条件导入 electron(Worker 环境中不可用)
|
||||
let app: any = null
|
||||
let safeStorage: any = null
|
||||
const isWorkerThread = process.env.WEFLOW_WORKER === '1'
|
||||
if (!isWorkerThread) {
|
||||
try {
|
||||
const electron = require('electron')
|
||||
app = electron.app
|
||||
safeStorage = electron.safeStorage
|
||||
} catch {
|
||||
// Worker 环境中 electron 不可用
|
||||
}
|
||||
}
|
||||
|
||||
// 加密前缀标记
|
||||
const SAFE_PREFIX = 'safe:' // safeStorage 加密(普通模式)
|
||||
@@ -35,6 +50,7 @@ interface ConfigSchema {
|
||||
language: string
|
||||
logEnabled: boolean
|
||||
launchAtStartup?: boolean
|
||||
silentStartup?: boolean
|
||||
llmModelPath: string
|
||||
whisperModelName: string
|
||||
whisperModelDir: string
|
||||
@@ -42,7 +58,6 @@ interface ConfigSchema {
|
||||
autoTranscribeVoice: boolean
|
||||
transcribeLanguages: string[]
|
||||
exportDefaultConcurrency: number
|
||||
exportDefaultImageDeepSearchOnMiss: boolean
|
||||
analyticsExcludedUsernames: string[]
|
||||
|
||||
// 安全相关
|
||||
@@ -57,6 +72,7 @@ interface ConfigSchema {
|
||||
|
||||
// 通知
|
||||
notificationEnabled: boolean
|
||||
aiInsightNotificationEnabled: boolean
|
||||
notificationPosition: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'
|
||||
notificationFilterMode: 'all' | 'whitelist' | 'blacklist'
|
||||
notificationFilterList: string[]
|
||||
@@ -71,17 +87,28 @@ interface ConfigSchema {
|
||||
quoteLayout: 'quote-top' | 'quote-bottom'
|
||||
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
|
||||
aiInsightAllowMomentsContext: boolean
|
||||
aiInsightMomentsContextCount: number
|
||||
aiInsightMomentsBindings: Record<string, { enabled: boolean; updatedAt: number }>
|
||||
aiInsightAllowSocialContext: boolean
|
||||
aiInsightSocialContextCount: number
|
||||
aiInsightWeiboCookie: string
|
||||
aiInsightWeiboBindings: Record<string, { uid: string; screenName?: string; updatedAt: number }>
|
||||
aiInsightFilterMode: 'whitelist' | 'blacklist'
|
||||
aiInsightFilterList: string[]
|
||||
aiInsightWhitelistEnabled: boolean
|
||||
aiInsightWhitelist: string[]
|
||||
/** 活跃分析冷却时间(分钟),0 表示无冷却 */
|
||||
@@ -102,6 +129,10 @@ interface ConfigSchema {
|
||||
// AI 足迹
|
||||
aiFootprintEnabled: boolean
|
||||
aiFootprintSystemPrompt: string
|
||||
/** 是否将 AI 见解调试日志输出到桌面 */
|
||||
aiInsightDebugLogEnabled: boolean
|
||||
autoDownloadHighRes: boolean
|
||||
autoDownloadWhitelist: string[]
|
||||
}
|
||||
|
||||
// 需要 safeStorage 加密的字段(普通模式)
|
||||
@@ -111,7 +142,8 @@ const ENCRYPTED_STRING_KEYS: Set<string> = new Set([
|
||||
'authPassword',
|
||||
'httpApiToken',
|
||||
'aiModelApiKey',
|
||||
'aiInsightApiKey'
|
||||
'aiInsightApiKey',
|
||||
'aiInsightWeiboCookie'
|
||||
])
|
||||
const ENCRYPTED_BOOL_KEYS: Set<string> = new Set(['authEnabled', 'authUseHello'])
|
||||
const ENCRYPTED_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
|
||||
@@ -128,6 +160,9 @@ export class ConfigService {
|
||||
private unlockedKeys: Map<string, any> = new Map()
|
||||
private unlockPassword: string | null = null
|
||||
|
||||
// 账号目录缓存
|
||||
private accountDirCache: Map<string, string> = new Map()
|
||||
|
||||
static getInstance(): ConfigService {
|
||||
if (!ConfigService.instance) {
|
||||
ConfigService.instance = new ConfigService()
|
||||
@@ -155,6 +190,7 @@ export class ConfigService {
|
||||
themeId: 'cloud-dancer',
|
||||
language: 'zh-CN',
|
||||
logEnabled: false,
|
||||
silentStartup: false,
|
||||
llmModelPath: '',
|
||||
whisperModelName: 'base',
|
||||
whisperModelDir: '',
|
||||
@@ -162,7 +198,6 @@ export class ConfigService {
|
||||
autoTranscribeVoice: false,
|
||||
transcribeLanguages: ['zh'],
|
||||
exportDefaultConcurrency: 4,
|
||||
exportDefaultImageDeepSearchOnMiss: true,
|
||||
analyticsExcludedUsernames: [],
|
||||
authEnabled: false,
|
||||
authPassword: '',
|
||||
@@ -171,6 +206,7 @@ export class ConfigService {
|
||||
ignoredUpdateVersion: '',
|
||||
updateChannel: 'auto',
|
||||
notificationEnabled: true,
|
||||
aiInsightNotificationEnabled: true,
|
||||
notificationPosition: 'top-right',
|
||||
notificationFilterMode: 'all',
|
||||
notificationFilterList: [],
|
||||
@@ -185,26 +221,40 @@ export class ConfigService {
|
||||
quoteLayout: 'quote-top',
|
||||
wordCloudExcludeWords: [],
|
||||
exportWriteLayout: 'A',
|
||||
exportAutomationTaskMap: {},
|
||||
aiModelApiBaseUrl: '',
|
||||
aiModelApiKey: '',
|
||||
aiModelApiModel: 'gpt-4o-mini',
|
||||
aiModelApiMaxTokens: 1024,
|
||||
aiInsightEnabled: false,
|
||||
aiInsightApiBaseUrl: '',
|
||||
aiInsightApiKey: '',
|
||||
aiInsightApiModel: 'gpt-4o-mini',
|
||||
aiInsightSilenceDays: 3,
|
||||
aiInsightAllowContext: false,
|
||||
aiInsightAllowMomentsContext: false,
|
||||
aiInsightMomentsContextCount: 5,
|
||||
aiInsightMomentsBindings: {},
|
||||
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: ''
|
||||
aiFootprintSystemPrompt: '',
|
||||
aiInsightDebugLogEnabled: false,
|
||||
autoDownloadHighRes: false,
|
||||
autoDownloadWhitelist: []
|
||||
}
|
||||
|
||||
const storeOptions: any = {
|
||||
@@ -286,6 +336,10 @@ export class ConfigService {
|
||||
return this.decryptWxidConfigs(raw as any) as ConfigSchema[K]
|
||||
}
|
||||
|
||||
if (key === 'dbPath' && typeof raw === 'string') {
|
||||
return expandHomePath(raw) as ConfigSchema[K]
|
||||
}
|
||||
|
||||
return raw
|
||||
}
|
||||
|
||||
@@ -293,6 +347,10 @@ export class ConfigService {
|
||||
let toStore = value
|
||||
const inLockMode = this.isLockMode() && this.unlockPassword
|
||||
|
||||
if (key === 'dbPath' && typeof value === 'string') {
|
||||
toStore = expandHomePath(value) as ConfigSchema[K]
|
||||
}
|
||||
|
||||
if (ENCRYPTED_BOOL_KEYS.has(key)) {
|
||||
const boolValue = value === true || value === 'true'
|
||||
// `false` 不需要写入 keychain,避免无意义触发 macOS 钥匙串弹窗
|
||||
@@ -779,6 +837,14 @@ export class ConfigService {
|
||||
|
||||
// === 工具方法 ===
|
||||
|
||||
/**
|
||||
* 获取当前用户 wxid(清洗后,不带后缀)
|
||||
*/
|
||||
getMyWxidCleaned(): string {
|
||||
const wxid = this.get('myWxid')
|
||||
return wxid ? this.cleanAccountDirName(wxid) : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前 wxid 对应的图片密钥,优先从 wxidConfigs 中取,找不到则回退到全局配置
|
||||
*/
|
||||
@@ -800,6 +866,99 @@ export class ConfigService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理账号目录名称(移除后缀)
|
||||
*/
|
||||
private cleanAccountDirName(dirName: string): string {
|
||||
const trimmed = dirName.trim()
|
||||
if (!trimmed) return trimmed
|
||||
|
||||
// wxid_ 开头的特殊处理
|
||||
if (trimmed.toLowerCase().startsWith('wxid_')) {
|
||||
const match = trimmed.match(/^(wxid_[^_]+)/i)
|
||||
if (match) return match[1]
|
||||
return trimmed
|
||||
}
|
||||
|
||||
// 移除4位后缀
|
||||
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
||||
if (suffixMatch) return suffixMatch[1]
|
||||
|
||||
return trimmed
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否是目录
|
||||
*/
|
||||
private isDirectory(path: string): boolean {
|
||||
try {
|
||||
return statSync(path).isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取账号目录路径
|
||||
* 统一的账号目录解析方法,所有服务应该使用此方法而不是自己实现
|
||||
*
|
||||
* @param dbPath 数据库根目录(可选,默认从配置读取)
|
||||
* @param wxid 微信ID(可选,默认从配置读取)
|
||||
* @returns 账号目录的完整路径,如果找不到返回 null
|
||||
*/
|
||||
getAccountDir(dbPath?: string, wxid?: string): string | null {
|
||||
const actualDbPath = dbPath || this.get('dbPath')
|
||||
const actualWxid = wxid || this.get('myWxid')
|
||||
|
||||
if (!actualDbPath || !actualWxid) return null
|
||||
|
||||
const cleanedWxid = this.cleanAccountDirName(actualWxid)
|
||||
const normalized = actualDbPath.replace(/[\\/]+$/, '')
|
||||
const cacheKey = `${normalized}|${cleanedWxid.toLowerCase()}`
|
||||
|
||||
// 检查缓存
|
||||
const cached = this.accountDirCache.get(cacheKey)
|
||||
if (cached && existsSync(cached)) return cached
|
||||
if (cached && !existsSync(cached)) {
|
||||
this.accountDirCache.delete(cacheKey)
|
||||
}
|
||||
|
||||
// 尝试直接路径(非 wxid_ 开头的账号)
|
||||
const lowerWxid = cleanedWxid.toLowerCase()
|
||||
if (!lowerWxid.startsWith('wxid_')) {
|
||||
const direct = join(normalized, cleanedWxid)
|
||||
if (existsSync(direct) && this.isDirectory(direct)) {
|
||||
this.accountDirCache.set(cacheKey, direct)
|
||||
return direct
|
||||
}
|
||||
}
|
||||
|
||||
// 扫描目录查找匹配的账号目录
|
||||
try {
|
||||
const entries = readdirSync(normalized)
|
||||
for (const entry of entries) {
|
||||
const entryPath = join(normalized, entry)
|
||||
if (!this.isDirectory(entryPath)) continue
|
||||
|
||||
const lowerEntry = entry.toLowerCase()
|
||||
const isExactMatch = lowerEntry === lowerWxid
|
||||
const isSuffixMatch = lowerEntry.startsWith(`${lowerWxid}_`)
|
||||
|
||||
// wxid_ 开头只接受带后缀的目录;其他账号精确匹配或带后缀都可以
|
||||
const shouldMatch = lowerWxid.startsWith('wxid_')
|
||||
? isSuffixMatch
|
||||
: (isExactMatch || isSuffixMatch)
|
||||
|
||||
if (shouldMatch) {
|
||||
this.accountDirCache.set(cacheKey, entryPath)
|
||||
return entryPath
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private getUserDataPath(): string {
|
||||
const workerUserDataPath = String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim()
|
||||
if (workerUserDataPath) {
|
||||
@@ -822,3 +981,4 @@ export class ConfigService {
|
||||
this.unlockPassword = null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { join, basename } from 'path'
|
||||
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
|
||||
import { homedir } from 'os'
|
||||
import { createDecipheriv } from 'crypto'
|
||||
import { expandHomePath } from '../utils/pathUtils'
|
||||
|
||||
export interface WxidInfo {
|
||||
wxid: string
|
||||
@@ -139,13 +140,14 @@ export class DbPathService {
|
||||
* 查找账号目录(包含 db_storage 或图片目录)
|
||||
*/
|
||||
findAccountDirs(rootPath: string): string[] {
|
||||
const resolvedRootPath = expandHomePath(rootPath)
|
||||
const accounts: string[] = []
|
||||
|
||||
try {
|
||||
const entries = readdirSync(rootPath)
|
||||
const entries = readdirSync(resolvedRootPath)
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPath = join(rootPath, entry)
|
||||
const entryPath = join(resolvedRootPath, entry)
|
||||
let stat: ReturnType<typeof statSync>
|
||||
try {
|
||||
stat = statSync(entryPath)
|
||||
@@ -158,6 +160,16 @@ export class DbPathService {
|
||||
|
||||
// 检查是否有有效账号目录结构
|
||||
if (this.isAccountDir(entryPath)) {
|
||||
// 过滤掉不带后缀的 wxid_ 目录
|
||||
const lowerEntry = entry.toLowerCase()
|
||||
if (lowerEntry.startsWith('wxid_')) {
|
||||
// wxid_ 开头的目录必须带后缀(wxid_xxx_yyyy 格式)
|
||||
const parts = entry.split('_')
|
||||
if (parts.length <= 2) {
|
||||
// wxid_xxx 格式,跳过
|
||||
continue
|
||||
}
|
||||
}
|
||||
accounts.push(entry)
|
||||
}
|
||||
}
|
||||
@@ -216,28 +228,39 @@ export class DbPathService {
|
||||
* 扫描目录名候选(仅包含下划线的文件夹,排除 all_users)
|
||||
*/
|
||||
scanWxidCandidates(rootPath: string): WxidInfo[] {
|
||||
const resolvedRootPath = expandHomePath(rootPath)
|
||||
const wxids: WxidInfo[] = []
|
||||
|
||||
try {
|
||||
if (existsSync(rootPath)) {
|
||||
const entries = readdirSync(rootPath)
|
||||
if (existsSync(resolvedRootPath)) {
|
||||
const entries = readdirSync(resolvedRootPath)
|
||||
for (const entry of entries) {
|
||||
const entryPath = join(rootPath, entry)
|
||||
const entryPath = join(resolvedRootPath, entry)
|
||||
let stat: ReturnType<typeof statSync>
|
||||
try { stat = statSync(entryPath) } catch { continue }
|
||||
if (!stat.isDirectory()) continue
|
||||
const lower = entry.toLowerCase()
|
||||
if (lower === 'all_users') continue
|
||||
if (!entry.includes('_')) continue
|
||||
|
||||
// 过滤掉不带后缀的 wxid_ 目录
|
||||
if (lower.startsWith('wxid_')) {
|
||||
const parts = entry.split('_')
|
||||
if (parts.length <= 2) {
|
||||
// wxid_xxx 格式,跳过
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
wxids.push({ wxid: entry, modifiedTime: stat.mtimeMs })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (wxids.length === 0) {
|
||||
const rootName = basename(rootPath)
|
||||
const rootName = basename(resolvedRootPath)
|
||||
if (rootName.includes('_') && rootName.toLowerCase() !== 'all_users') {
|
||||
const rootStat = statSync(rootPath)
|
||||
const rootStat = statSync(resolvedRootPath)
|
||||
wxids.push({ wxid: rootName, modifiedTime: rootStat.mtimeMs })
|
||||
}
|
||||
}
|
||||
@@ -248,7 +271,7 @@ export class DbPathService {
|
||||
return a.wxid.localeCompare(b.wxid)
|
||||
});
|
||||
|
||||
const globalInfo = this.parseGlobalConfig(rootPath);
|
||||
const globalInfo = this.parseGlobalConfig(resolvedRootPath);
|
||||
if (globalInfo) {
|
||||
for (const w of sorted) {
|
||||
if (w.wxid.startsWith(globalInfo.wxid) || sorted.length === 1) {
|
||||
@@ -266,19 +289,20 @@ export class DbPathService {
|
||||
* 扫描 wxid 列表
|
||||
*/
|
||||
scanWxids(rootPath: string): WxidInfo[] {
|
||||
const resolvedRootPath = expandHomePath(rootPath)
|
||||
const wxids: WxidInfo[] = []
|
||||
|
||||
try {
|
||||
if (this.isAccountDir(rootPath)) {
|
||||
const wxid = basename(rootPath)
|
||||
const modifiedTime = this.getAccountModifiedTime(rootPath)
|
||||
if (this.isAccountDir(resolvedRootPath)) {
|
||||
const wxid = basename(resolvedRootPath)
|
||||
const modifiedTime = this.getAccountModifiedTime(resolvedRootPath)
|
||||
return [{ wxid, modifiedTime }]
|
||||
}
|
||||
|
||||
const accounts = this.findAccountDirs(rootPath)
|
||||
const accounts = this.findAccountDirs(resolvedRootPath)
|
||||
|
||||
for (const account of accounts) {
|
||||
const fullPath = join(rootPath, account)
|
||||
const fullPath = join(resolvedRootPath, account)
|
||||
const modifiedTime = this.getAccountModifiedTime(fullPath)
|
||||
wxids.push({ wxid: account, modifiedTime })
|
||||
}
|
||||
@@ -289,7 +313,7 @@ export class DbPathService {
|
||||
return a.wxid.localeCompare(b.wxid)
|
||||
});
|
||||
|
||||
const globalInfo = this.parseGlobalConfig(rootPath);
|
||||
const globalInfo = this.parseGlobalConfig(resolvedRootPath);
|
||||
if (globalInfo) {
|
||||
for (const w of sorted) {
|
||||
if (w.wxid.startsWith(globalInfo.wxid) || sorted.length === 1) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { parentPort } from 'worker_threads'
|
||||
import { wcdbService } from './wcdbService'
|
||||
import { resolveAccountDir } from './accountDirResolver'
|
||||
|
||||
|
||||
export interface DualReportMessage {
|
||||
@@ -109,9 +110,11 @@ class DualReportService {
|
||||
if (!dbPath) return { success: false, error: '未配置数据库路径' }
|
||||
if (!decryptKey) return { success: false, error: '未配置解密密钥' }
|
||||
|
||||
const cleanedWxid = this.cleanAccountDirName(wxid)
|
||||
const ok = await wcdbService.open(dbPath, decryptKey, cleanedWxid)
|
||||
const accountDir = resolveAccountDir(dbPath, wxid)
|
||||
if (!accountDir) return { success: false, error: '无法找到账号目录' }
|
||||
const ok = await wcdbService.open(accountDir, decryptKey)
|
||||
if (!ok) return { success: false, error: 'WCDB 打开失败' }
|
||||
const cleanedWxid = this.cleanAccountDirName(wxid)
|
||||
return { success: true, cleanedWxid, rawWxid: wxid }
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
210
electron/services/exportTaskControlService.ts
Normal file
210
electron/services/exportTaskControlService.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import * as path from 'path'
|
||||
import { rm, rmdir } from 'fs/promises'
|
||||
|
||||
export type ExportTaskControlState = 'running' | 'pause_requested' | 'cancel_requested'
|
||||
|
||||
export interface ExportTaskControlHooks {
|
||||
shouldPause: () => boolean
|
||||
shouldStop: () => boolean
|
||||
recordCreatedFile: (filePath: string) => void
|
||||
recordCreatedDir: (dirPath: string) => void
|
||||
}
|
||||
|
||||
interface ExportTaskManifest {
|
||||
outputDir: string
|
||||
files: Set<string>
|
||||
dirs: Set<string>
|
||||
}
|
||||
|
||||
interface ExportTaskControlRecord {
|
||||
state: ExportTaskControlState
|
||||
manifest: ExportTaskManifest
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface ExportTaskCleanupResult {
|
||||
success: boolean
|
||||
filesDeleted: number
|
||||
dirsDeleted: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
class ExportTaskControlService {
|
||||
private tasks = new Map<string, ExportTaskControlRecord>()
|
||||
|
||||
createControl(taskId: string, outputDir: string): ExportTaskControlHooks {
|
||||
this.registerTask(taskId, outputDir)
|
||||
return {
|
||||
shouldPause: () => this.getState(taskId) === 'pause_requested',
|
||||
shouldStop: () => this.getState(taskId) === 'cancel_requested',
|
||||
recordCreatedFile: (filePath: string) => this.recordCreatedFile(taskId, filePath),
|
||||
recordCreatedDir: (dirPath: string) => this.recordCreatedDir(taskId, dirPath)
|
||||
}
|
||||
}
|
||||
|
||||
registerTask(taskId: string, outputDir: string): void {
|
||||
const normalizedTaskId = this.normalizeTaskId(taskId)
|
||||
if (!normalizedTaskId) return
|
||||
|
||||
const normalizedOutputDir = path.resolve(String(outputDir || '').trim() || '.')
|
||||
const existing = this.tasks.get(normalizedTaskId)
|
||||
if (existing) {
|
||||
existing.state = 'running'
|
||||
existing.updatedAt = Date.now()
|
||||
if (!existing.manifest.outputDir) {
|
||||
existing.manifest.outputDir = normalizedOutputDir
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
this.tasks.set(normalizedTaskId, {
|
||||
state: 'running',
|
||||
manifest: {
|
||||
outputDir: normalizedOutputDir,
|
||||
files: new Set<string>(),
|
||||
dirs: new Set<string>()
|
||||
},
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
pauseTask(taskId: string): boolean {
|
||||
return this.setState(taskId, 'pause_requested')
|
||||
}
|
||||
|
||||
resumeTask(taskId: string): boolean {
|
||||
return this.setState(taskId, 'running')
|
||||
}
|
||||
|
||||
cancelTask(taskId: string): boolean {
|
||||
return this.setState(taskId, 'cancel_requested')
|
||||
}
|
||||
|
||||
getState(taskId: string): ExportTaskControlState | null {
|
||||
const normalizedTaskId = this.normalizeTaskId(taskId)
|
||||
if (!normalizedTaskId) return null
|
||||
return this.tasks.get(normalizedTaskId)?.state || null
|
||||
}
|
||||
|
||||
releaseTask(taskId: string): void {
|
||||
const normalizedTaskId = this.normalizeTaskId(taskId)
|
||||
if (!normalizedTaskId) return
|
||||
this.tasks.delete(normalizedTaskId)
|
||||
}
|
||||
|
||||
recordCreatedFile(taskId: string, filePath: string): void {
|
||||
const task = this.getTaskForManifestWrite(taskId, filePath)
|
||||
if (!task) return
|
||||
task.manifest.files.add(path.resolve(filePath))
|
||||
task.updatedAt = Date.now()
|
||||
}
|
||||
|
||||
recordCreatedDir(taskId: string, dirPath: string): void {
|
||||
const task = this.getTaskForManifestWrite(taskId, dirPath)
|
||||
if (!task) return
|
||||
task.manifest.dirs.add(path.resolve(dirPath))
|
||||
task.updatedAt = Date.now()
|
||||
}
|
||||
|
||||
async cleanupTask(taskId: string): Promise<ExportTaskCleanupResult> {
|
||||
const normalizedTaskId = this.normalizeTaskId(taskId)
|
||||
const task = normalizedTaskId ? this.tasks.get(normalizedTaskId) : undefined
|
||||
if (!task) {
|
||||
return { success: true, filesDeleted: 0, dirsDeleted: 0 }
|
||||
}
|
||||
|
||||
const outputDir = task.manifest.outputDir
|
||||
let filesDeleted = 0
|
||||
let dirsDeleted = 0
|
||||
const errors: string[] = []
|
||||
|
||||
const files = Array.from(task.manifest.files)
|
||||
.filter(filePath => this.isInsideOutputDir(filePath, outputDir))
|
||||
.sort((a, b) => b.length - a.length)
|
||||
|
||||
for (const filePath of files) {
|
||||
try {
|
||||
await rm(filePath, { force: true, recursive: false })
|
||||
filesDeleted++
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException | undefined)?.code
|
||||
if (code !== 'ENOENT') {
|
||||
errors.push(`${filePath}: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dirs = Array.from(task.manifest.dirs)
|
||||
.filter(dirPath => this.isInsideOutputDir(dirPath, outputDir) || this.isSamePath(dirPath, outputDir))
|
||||
.sort((a, b) => b.length - a.length)
|
||||
|
||||
for (const dirPath of dirs) {
|
||||
try {
|
||||
await rmdir(dirPath)
|
||||
dirsDeleted++
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException | undefined)?.code
|
||||
if (code !== 'ENOENT' && code !== 'ENOTEMPTY' && code !== 'EEXIST') {
|
||||
errors.push(`${dirPath}: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length === 0) {
|
||||
this.releaseTask(normalizedTaskId)
|
||||
return { success: true, filesDeleted, dirsDeleted }
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
filesDeleted,
|
||||
dirsDeleted,
|
||||
error: errors.slice(0, 3).join('; ')
|
||||
}
|
||||
}
|
||||
|
||||
private setState(taskId: string, state: ExportTaskControlState): boolean {
|
||||
const normalizedTaskId = this.normalizeTaskId(taskId)
|
||||
if (!normalizedTaskId) return false
|
||||
const task = this.tasks.get(normalizedTaskId)
|
||||
if (!task) return false
|
||||
task.state = state
|
||||
task.updatedAt = Date.now()
|
||||
return true
|
||||
}
|
||||
|
||||
private getTaskForManifestWrite(taskId: string, targetPath: string): ExportTaskControlRecord | null {
|
||||
const normalizedTaskId = this.normalizeTaskId(taskId)
|
||||
if (!normalizedTaskId) return null
|
||||
const task = this.tasks.get(normalizedTaskId)
|
||||
if (!task) return null
|
||||
if (!this.isInsideOutputDir(targetPath, task.manifest.outputDir) && !this.isSamePath(targetPath, task.manifest.outputDir)) {
|
||||
return null
|
||||
}
|
||||
return task
|
||||
}
|
||||
|
||||
private isInsideOutputDir(targetPath: string, outputDir: string): boolean {
|
||||
const resolvedTarget = path.resolve(targetPath)
|
||||
const resolvedOutputDir = path.resolve(outputDir)
|
||||
const relativePath = path.relative(resolvedOutputDir, resolvedTarget)
|
||||
return Boolean(relativePath) && !relativePath.startsWith('..') && !path.isAbsolute(relativePath)
|
||||
}
|
||||
|
||||
private isSamePath(left: string, right: string): boolean {
|
||||
const resolvedLeft = path.resolve(left)
|
||||
const resolvedRight = path.resolve(right)
|
||||
if (process.platform === 'win32') {
|
||||
return resolvedLeft.toLowerCase() === resolvedRight.toLowerCase()
|
||||
}
|
||||
return resolvedLeft === resolvedRight
|
||||
}
|
||||
|
||||
private normalizeTaskId(taskId: string): string {
|
||||
return String(taskId || '').trim()
|
||||
}
|
||||
}
|
||||
|
||||
export const exportTaskControlService = new ExportTaskControlService()
|
||||
@@ -251,7 +251,7 @@ class GroupAnalyticsService {
|
||||
}
|
||||
|
||||
private async ensureConnected(): Promise<{ success: boolean; error?: string }> {
|
||||
const wxid = this.configService.get('myWxid')
|
||||
const wxid = this.configService.getMyWxidCleaned()
|
||||
const dbPath = this.configService.get('dbPath')
|
||||
const decryptKey = this.configService.get('decryptKey')
|
||||
if (!wxid) return { success: false, error: '未配置微信ID' }
|
||||
@@ -259,7 +259,9 @@ class GroupAnalyticsService {
|
||||
if (!decryptKey) return { success: false, error: '未配置解密密钥' }
|
||||
|
||||
const cleanedWxid = this.cleanAccountDirName(wxid)
|
||||
const ok = await wcdbService.open(dbPath, decryptKey, cleanedWxid)
|
||||
const accountDir = this.configService.getAccountDir(dbPath, wxid)
|
||||
if (!accountDir) return { success: false, error: '无法找到账号目录' }
|
||||
const ok = await wcdbService.open(accountDir, decryptKey)
|
||||
if (!ok) return { success: false, error: 'WCDB 打开失败' }
|
||||
return { success: true }
|
||||
}
|
||||
@@ -1555,7 +1557,7 @@ class GroupAnalyticsService {
|
||||
const phraseCounts = new Map<string, number>()
|
||||
const emojiCounts = new Map<string, number>()
|
||||
|
||||
const myWxid = String(this.configService.get('myWxid') || '').trim()
|
||||
const myWxid = String(this.configService.getMyWxidCleaned() || '').trim()
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
203
electron/services/imageDownloadService.ts
Normal file
203
electron/services/imageDownloadService.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { app } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { existsSync } from 'fs'
|
||||
import { execFile } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
// import { ConfigService } from './config'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
export class ImageDownloadService {
|
||||
private static instance: ImageDownloadService
|
||||
private koffi: any = null
|
||||
private lib: any = null
|
||||
private initialized = false
|
||||
|
||||
private initImgHelper: any = null
|
||||
private uninstallImgHelper: any = null
|
||||
private getImgHelperError: any = null
|
||||
|
||||
private currentPid: number | null = null
|
||||
private pollTimer: NodeJS.Timeout | null = null
|
||||
private isHooked = false
|
||||
|
||||
private lastWhitelist: string[] = []
|
||||
|
||||
static getInstance(): ImageDownloadService {
|
||||
if (!ImageDownloadService.instance) {
|
||||
ImageDownloadService.instance = new ImageDownloadService()
|
||||
}
|
||||
return ImageDownloadService.instance
|
||||
}
|
||||
|
||||
private constructor() {
|
||||
}
|
||||
|
||||
private async ensureInitialized(): Promise<boolean> {
|
||||
if (this.initialized) return true
|
||||
if (process.platform !== 'win32' || process.arch !== 'x64') return false
|
||||
|
||||
try {
|
||||
this.koffi = require('koffi')
|
||||
const dllPath = this.getDllPath()
|
||||
if (!existsSync(dllPath)) return false
|
||||
|
||||
this.lib = this.koffi.load(dllPath)
|
||||
|
||||
this.initImgHelper = this.lib.func('bool InitImgHelper(uint32, const char*)')
|
||||
this.uninstallImgHelper = this.lib.func('void UninstallImgHelper()')
|
||||
this.getImgHelperError = this.lib.func('const char* GetImgHelperError()')
|
||||
|
||||
this.initialized = true
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('[ImageDownloadService] failed to initialize:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private getDllPath(): string {
|
||||
const isPackaged = app.isPackaged
|
||||
const candidates: string[] = []
|
||||
|
||||
if (isPackaged) {
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'image', 'win32', 'x64', 'img_helper.dll'))
|
||||
} else {
|
||||
candidates.push(join(process.cwd(), 'resources', 'image', 'win32', 'x64', 'img_helper.dll'))
|
||||
}
|
||||
|
||||
for (const path of candidates) {
|
||||
if (existsSync(path)) return path
|
||||
}
|
||||
return candidates[0]
|
||||
}
|
||||
|
||||
private async findMainWeChatPid(): Promise<number | null> {
|
||||
try {
|
||||
const script = `
|
||||
Get-CimInstance Win32_Process -Filter "Name = 'Weixin.exe'" |
|
||||
Select-Object ProcessId, CommandLine |
|
||||
ConvertTo-Json -Compress
|
||||
`;
|
||||
|
||||
const { stdout } = await execFileAsync('powershell', ['-NoProfile', '-Command', script])
|
||||
if (!stdout || !stdout.trim()) return null
|
||||
|
||||
let processes = JSON.parse(stdout.trim())
|
||||
if (!Array.isArray(processes)) processes = [processes]
|
||||
|
||||
const target = processes
|
||||
.filter((p: any) => p.CommandLine && p.CommandLine.toLowerCase().includes('weixin.exe'))
|
||||
.sort((a: any, b: any) => a.CommandLine.length - b.CommandLine.length)[0]
|
||||
|
||||
return target ? target.ProcessId : null;
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async startAutoDownload(whitelist: string[] | string = []): Promise<{ success: boolean; error?: string }> {
|
||||
if (!await this.ensureInitialized()) {
|
||||
return { success: false, error: '核心组件初始化失败' }
|
||||
}
|
||||
|
||||
if (this.isHooked) {
|
||||
await this.unhook()
|
||||
}
|
||||
|
||||
this.lastWhitelist = whitelist
|
||||
|
||||
if (!this.pollTimer) {
|
||||
this.pollTimer = setInterval(() => this.checkAndHook(this.lastWhitelist, false), 30000)
|
||||
}
|
||||
|
||||
return await this.checkAndHook(whitelist, true)
|
||||
}
|
||||
|
||||
async stopAutoDownload() {
|
||||
if (this.pollTimer) {
|
||||
clearInterval(this.pollTimer)
|
||||
this.pollTimer = null
|
||||
}
|
||||
await this.unhook()
|
||||
}
|
||||
|
||||
private async checkAndHook(whitelist: string[] | string = [], isManualStart = false): Promise<{ success: boolean; error?: string }> {
|
||||
const pid = await this.findMainWeChatPid()
|
||||
|
||||
if (!pid) {
|
||||
if (this.isHooked) {
|
||||
console.log('[ImageDownloadService] WeChat exited, unhooking')
|
||||
await this.unhook()
|
||||
}
|
||||
return { success: true, error: '等待微信启动' }
|
||||
}
|
||||
|
||||
if (this.isHooked && this.currentPid === pid) {
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
if (this.isHooked && this.currentPid !== pid) {
|
||||
console.log('[ImageDownloadService] WeChat PID changed, re-hooking')
|
||||
await this.unhook()
|
||||
}
|
||||
|
||||
console.log(`[ImageDownloadService] attempting to hook PID: ${pid}`)
|
||||
try {
|
||||
let whitelistBuffer: Buffer | null = null;
|
||||
if (typeof whitelist === 'string') {
|
||||
if (whitelist.length > 0) {
|
||||
whitelistBuffer = Buffer.from(whitelist, 'utf8');
|
||||
}
|
||||
} else if (Array.isArray(whitelist) && whitelist.length > 0) {
|
||||
whitelistBuffer = Buffer.from(whitelist.join('\0') + '\0\0', 'utf8');
|
||||
}
|
||||
|
||||
const success = this.initImgHelper(pid, whitelistBuffer)
|
||||
|
||||
if (success) {
|
||||
this.isHooked = true
|
||||
this.currentPid = pid
|
||||
console.log('[ImageDownloadService] hook successful')
|
||||
return { success: true }
|
||||
} else {
|
||||
const err = this.getImgHelperError()
|
||||
console.error(`[ImageDownloadService] hook failed: ${err}`)
|
||||
if (isManualStart && this.pollTimer) {
|
||||
clearInterval(this.pollTimer)
|
||||
this.pollTimer = null
|
||||
}
|
||||
return { success: false, error: err || 'Hook 失败' }
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('[ImageDownloadService] InitImgHelper call crashed:', e)
|
||||
if (isManualStart && this.pollTimer) {
|
||||
clearInterval(this.pollTimer)
|
||||
this.pollTimer = null
|
||||
}
|
||||
return { success: false, error: `调用异常: ${e.message || String(e)}` }
|
||||
}
|
||||
}
|
||||
|
||||
private async unhook() {
|
||||
if (this.isHooked && this.uninstallImgHelper) {
|
||||
try {
|
||||
this.uninstallImgHelper()
|
||||
} catch (e) {
|
||||
console.error('[ImageDownloadService] uninstall failed:', e)
|
||||
}
|
||||
}
|
||||
this.isHooked = false
|
||||
this.currentPid = null
|
||||
}
|
||||
|
||||
async getStatus() {
|
||||
return {
|
||||
isHooked: this.isHooked,
|
||||
pid: this.currentPid,
|
||||
supported: process.platform === 'win32' && process.arch === 'x64'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const imageDownloadService = ImageDownloadService.getInstance()
|
||||
@@ -4,6 +4,7 @@ type PreloadImagePayload = {
|
||||
sessionId?: string
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
createTime?: number
|
||||
}
|
||||
|
||||
type PreloadOptions = {
|
||||
@@ -74,15 +75,24 @@ export class ImagePreloadService {
|
||||
sessionId: task.sessionId,
|
||||
imageMd5: task.imageMd5,
|
||||
imageDatName: task.imageDatName,
|
||||
createTime: task.createTime,
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true,
|
||||
disableUpdateCheck: !task.allowDecrypt,
|
||||
allowCacheIndex: task.allowCacheIndex
|
||||
allowCacheIndex: task.allowCacheIndex,
|
||||
suppressEvents: true
|
||||
})
|
||||
if (cached.success) return
|
||||
if (!task.allowDecrypt) return
|
||||
await imageDecryptService.decryptImage({
|
||||
sessionId: task.sessionId,
|
||||
imageMd5: task.imageMd5,
|
||||
imageDatName: task.imageDatName
|
||||
imageDatName: task.imageDatName,
|
||||
createTime: task.createTime,
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true,
|
||||
disableUpdateCheck: true,
|
||||
suppressEvents: true
|
||||
})
|
||||
} catch {
|
||||
// ignore preload failures
|
||||
|
||||
292
electron/services/insightRecordService.ts
Normal file
292
electron/services/insightRecordService.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import { app } from 'electron'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { createHash, randomUUID } from 'crypto'
|
||||
import { ConfigService } from './config'
|
||||
|
||||
export type InsightRecordTriggerReason = 'activity' | 'silence' | 'test'
|
||||
|
||||
export interface InsightRecordLog {
|
||||
endpoint: string
|
||||
model: string
|
||||
maxTokens: number
|
||||
temperature: number
|
||||
triggerReason: InsightRecordTriggerReason
|
||||
allowContext: boolean
|
||||
contextCount: number
|
||||
systemPrompt: string
|
||||
userPrompt: string
|
||||
rawOutput: string
|
||||
finalInsight: string
|
||||
durationMs: number
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
export interface InsightRecord {
|
||||
id: string
|
||||
accountScope: string
|
||||
createdAt: number
|
||||
sessionId: string
|
||||
displayName: string
|
||||
avatarUrl?: string
|
||||
triggerReason: InsightRecordTriggerReason
|
||||
insight: string
|
||||
read: boolean
|
||||
log: InsightRecordLog
|
||||
}
|
||||
|
||||
export interface InsightRecordSummary {
|
||||
id: string
|
||||
createdAt: number
|
||||
sessionId: string
|
||||
displayName: string
|
||||
avatarUrl?: string
|
||||
triggerReason: InsightRecordTriggerReason
|
||||
insight: string
|
||||
read: boolean
|
||||
}
|
||||
|
||||
export interface InsightRecordContactFacet {
|
||||
sessionId: string
|
||||
displayName: string
|
||||
avatarUrl?: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface InsightRecordFilters {
|
||||
keyword?: string
|
||||
sessionId?: string
|
||||
startTime?: number
|
||||
endTime?: number
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
|
||||
export interface InsightRecordListResult {
|
||||
success: boolean
|
||||
records: InsightRecordSummary[]
|
||||
total: number
|
||||
todayCount: number
|
||||
unreadCount: number
|
||||
contacts: InsightRecordContactFacet[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
class InsightRecordService {
|
||||
private readonly maxRecordsPerScope = 1000
|
||||
private filePath: string | null = null
|
||||
private loaded = false
|
||||
private records: InsightRecord[] = []
|
||||
|
||||
private resolveFilePath(): string {
|
||||
if (this.filePath) return this.filePath
|
||||
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 })
|
||||
this.filePath = path.join(userDataPath, 'weflow-insight-records.json')
|
||||
return this.filePath
|
||||
}
|
||||
|
||||
private ensureLoaded(): void {
|
||||
if (this.loaded) return
|
||||
this.loaded = true
|
||||
const filePath = this.resolveFilePath()
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return
|
||||
const raw = fs.readFileSync(filePath, 'utf-8')
|
||||
const parsed = JSON.parse(raw)
|
||||
if (Array.isArray(parsed)) {
|
||||
this.records = parsed.filter((item) => item && typeof item === 'object') as InsightRecord[]
|
||||
} else if (Array.isArray(parsed?.records)) {
|
||||
this.records = parsed.records.filter((item: unknown) => item && typeof item === 'object') as InsightRecord[]
|
||||
}
|
||||
} catch {
|
||||
this.records = []
|
||||
}
|
||||
}
|
||||
|
||||
private persist(): void {
|
||||
try {
|
||||
const filePath = this.resolveFilePath()
|
||||
fs.writeFileSync(filePath, JSON.stringify({ version: 1, records: this.records }, null, 2), 'utf-8')
|
||||
} catch {
|
||||
// Keep insight generation non-blocking even if local persistence fails.
|
||||
}
|
||||
}
|
||||
|
||||
private getCurrentAccountScope(): string {
|
||||
const config = ConfigService.getInstance()
|
||||
const myWxid = String(config.getMyWxidCleaned() || '').trim()
|
||||
if (myWxid) return `wxid:${myWxid}`
|
||||
|
||||
const dbPath = String(config.get('dbPath') || '').trim()
|
||||
if (dbPath) {
|
||||
const hash = createHash('sha1').update(dbPath).digest('hex').slice(0, 16)
|
||||
return `db:${hash}`
|
||||
}
|
||||
return 'default'
|
||||
}
|
||||
|
||||
private getStartOfToday(): number {
|
||||
const date = new Date()
|
||||
date.setHours(0, 0, 0, 0)
|
||||
return date.getTime()
|
||||
}
|
||||
|
||||
private toSummary(record: InsightRecord): InsightRecordSummary {
|
||||
return {
|
||||
id: record.id,
|
||||
createdAt: record.createdAt,
|
||||
sessionId: record.sessionId,
|
||||
displayName: record.displayName,
|
||||
avatarUrl: record.avatarUrl,
|
||||
triggerReason: record.triggerReason,
|
||||
insight: record.insight,
|
||||
read: record.read
|
||||
}
|
||||
}
|
||||
|
||||
private getScopedRecords(): InsightRecord[] {
|
||||
this.ensureLoaded()
|
||||
const scope = this.getCurrentAccountScope()
|
||||
return this.records.filter((record) => record.accountScope === scope)
|
||||
}
|
||||
|
||||
addRecord(input: {
|
||||
sessionId: string
|
||||
displayName: string
|
||||
avatarUrl?: string
|
||||
triggerReason: InsightRecordTriggerReason
|
||||
insight: string
|
||||
log: InsightRecordLog
|
||||
}): InsightRecord {
|
||||
this.ensureLoaded()
|
||||
const scope = this.getCurrentAccountScope()
|
||||
const now = Date.now()
|
||||
const record: InsightRecord = {
|
||||
id: randomUUID(),
|
||||
accountScope: scope,
|
||||
createdAt: now,
|
||||
sessionId: input.sessionId,
|
||||
displayName: input.displayName,
|
||||
avatarUrl: input.avatarUrl,
|
||||
triggerReason: input.triggerReason,
|
||||
insight: input.insight,
|
||||
read: false,
|
||||
log: input.log
|
||||
}
|
||||
|
||||
this.records.push(record)
|
||||
const scopedRecords = this.records
|
||||
.filter((item) => item.accountScope === scope)
|
||||
.sort((a, b) => b.createdAt - a.createdAt)
|
||||
const keepIds = new Set(scopedRecords.slice(0, this.maxRecordsPerScope).map((item) => item.id))
|
||||
this.records = this.records.filter((item) => item.accountScope !== scope || keepIds.has(item.id))
|
||||
this.persist()
|
||||
return record
|
||||
}
|
||||
|
||||
listRecords(filters: InsightRecordFilters = {}): InsightRecordListResult {
|
||||
try {
|
||||
const allScoped = this.getScopedRecords()
|
||||
const todayStart = this.getStartOfToday()
|
||||
const contactsMap = new Map<string, InsightRecordContactFacet>()
|
||||
for (const record of allScoped) {
|
||||
const existing = contactsMap.get(record.sessionId)
|
||||
if (existing) {
|
||||
existing.count += 1
|
||||
} else {
|
||||
contactsMap.set(record.sessionId, {
|
||||
sessionId: record.sessionId,
|
||||
displayName: record.displayName,
|
||||
avatarUrl: record.avatarUrl,
|
||||
count: 1
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const keyword = String(filters.keyword || '').trim().toLowerCase()
|
||||
const sessionId = String(filters.sessionId || '').trim()
|
||||
const startTime = Number(filters.startTime || 0)
|
||||
const endTime = Number(filters.endTime || 0)
|
||||
const offset = Math.max(0, Math.floor(Number(filters.offset || 0)))
|
||||
const limit = Math.min(200, Math.max(1, Math.floor(Number(filters.limit || 100))))
|
||||
|
||||
const filtered = allScoped
|
||||
.filter((record) => {
|
||||
if (sessionId && record.sessionId !== sessionId) return false
|
||||
if (startTime > 0 && record.createdAt < startTime) return false
|
||||
if (endTime > 0 && record.createdAt > endTime) return false
|
||||
if (keyword) {
|
||||
const haystack = `${record.displayName}\n${record.sessionId}\n${record.insight}`.toLowerCase()
|
||||
if (!haystack.includes(keyword)) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
.sort((a, b) => b.createdAt - a.createdAt)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
records: filtered.slice(offset, offset + limit).map((record) => this.toSummary(record)),
|
||||
total: filtered.length,
|
||||
todayCount: allScoped.filter((record) => record.createdAt >= todayStart).length,
|
||||
unreadCount: allScoped.filter((record) => !record.read).length,
|
||||
contacts: Array.from(contactsMap.values()).sort((a, b) => b.count - a.count)
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
records: [],
|
||||
total: 0,
|
||||
todayCount: 0,
|
||||
unreadCount: 0,
|
||||
contacts: [],
|
||||
error: (error as Error).message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getRecord(id: string): { success: boolean; record?: InsightRecord; error?: string } {
|
||||
this.ensureLoaded()
|
||||
const normalizedId = String(id || '').trim()
|
||||
if (!normalizedId) return { success: false, error: '记录 ID 为空' }
|
||||
const scope = this.getCurrentAccountScope()
|
||||
const record = this.records.find((item) => item.id === normalizedId && item.accountScope === scope)
|
||||
if (!record) return { success: false, error: '未找到该见解记录' }
|
||||
return { success: true, record }
|
||||
}
|
||||
|
||||
markRecordRead(id: string): { success: boolean; error?: string } {
|
||||
this.ensureLoaded()
|
||||
const normalizedId = String(id || '').trim()
|
||||
const scope = this.getCurrentAccountScope()
|
||||
const record = this.records.find((item) => item.id === normalizedId && item.accountScope === scope)
|
||||
if (!record) return { success: false, error: '未找到该见解记录' }
|
||||
if (!record.read) {
|
||||
record.read = true
|
||||
this.persist()
|
||||
}
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
clearRecords(filters: InsightRecordFilters = {}): { success: boolean; removed: number; error?: string } {
|
||||
this.ensureLoaded()
|
||||
const scope = this.getCurrentAccountScope()
|
||||
const sessionId = String(filters.sessionId || '').trim()
|
||||
const startTime = Number(filters.startTime || 0)
|
||||
const endTime = Number(filters.endTime || 0)
|
||||
let removed = 0
|
||||
this.records = this.records.filter((record) => {
|
||||
if (record.accountScope !== scope) return true
|
||||
if (sessionId && record.sessionId !== sessionId) return true
|
||||
if (startTime > 0 && record.createdAt < startTime) return true
|
||||
if (endTime > 0 && record.createdAt > endTime) return true
|
||||
removed += 1
|
||||
return false
|
||||
})
|
||||
this.persist()
|
||||
return { success: true, removed }
|
||||
}
|
||||
}
|
||||
|
||||
export const insightRecordService = new InsightRecordService()
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/**
|
||||
* insightService.ts
|
||||
*
|
||||
* AI 见解后台服务:
|
||||
@@ -10,15 +10,18 @@
|
||||
* 设计原则:
|
||||
* - 不引入任何额外 npm 依赖,使用 Node 原生 https 模块调用 OpenAI 兼容 API
|
||||
* - 所有失败静默处理,不影响主流程
|
||||
* - 当日触发记录(sessionId + 时间列表)随 prompt 一起发送,让模型自行判断是否克制
|
||||
* - 触发频率、冷却与名单过滤均在本地完成,不把调度统计塞进模型 prompt
|
||||
*/
|
||||
|
||||
import https from 'https'
|
||||
import http from 'http'
|
||||
import { URL } from 'url'
|
||||
import { Notification } from 'electron'
|
||||
import { ConfigService } from './config'
|
||||
import { chatService, ChatSession, Message } from './chatService'
|
||||
import { snsService } from './snsService'
|
||||
import { weiboService } from './social/weiboService'
|
||||
import { showNotification } from '../windows/notificationWindow'
|
||||
import { insightRecordService, type InsightRecordLog, type InsightRecordTriggerReason } from './insightRecordService'
|
||||
|
||||
// ─── 常量 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -33,6 +36,11 @@ const SILENCE_SCAN_INITIAL_DELAY_MS = 3 * 60 * 1000
|
||||
|
||||
/** 单次 API 请求超时(毫秒) */
|
||||
const API_TIMEOUT_MS = 45_000
|
||||
const API_MAX_TOKENS_DEFAULT = 1024
|
||||
const API_MAX_TOKENS_MIN = 1
|
||||
const API_MAX_TOKENS_MAX = 2_000_000
|
||||
const API_TEMPERATURE = 0.7
|
||||
const INSIGHT_NOTIFICATION_AVATAR_URL = './assets/insight/AI_Insight.png'
|
||||
|
||||
/** 沉默天数阈值默认值 */
|
||||
const DEFAULT_SILENCE_DAYS = 3
|
||||
@@ -42,6 +50,16 @@ const INSIGHT_CONFIG_KEYS = new Set([
|
||||
'aiModelApiBaseUrl',
|
||||
'aiModelApiKey',
|
||||
'aiModelApiModel',
|
||||
'aiModelApiMaxTokens',
|
||||
'aiInsightFilterMode',
|
||||
'aiInsightFilterList',
|
||||
'aiInsightAllowMomentsContext',
|
||||
'aiInsightMomentsContextCount',
|
||||
'aiInsightMomentsBindings',
|
||||
'aiInsightAllowSocialContext',
|
||||
'aiInsightSocialContextCount',
|
||||
'aiInsightWeiboCookie',
|
||||
'aiInsightWeiboBindings',
|
||||
'dbPath',
|
||||
'decryptKey',
|
||||
'myWxid'
|
||||
@@ -58,19 +76,33 @@ interface SharedAiModelConfig {
|
||||
apiBaseUrl: string
|
||||
apiKey: string
|
||||
model: string
|
||||
maxTokens: number
|
||||
}
|
||||
|
||||
type InsightFilterMode = 'whitelist' | 'blacklist'
|
||||
|
||||
// ─── 日志 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type InsightLogLevel = 'INFO' | 'WARN' | 'ERROR'
|
||||
|
||||
function insightDebugLine(_level: InsightLogLevel, _message: string): void {
|
||||
// Desktop debug log export has been replaced by per-insight request logs.
|
||||
}
|
||||
|
||||
function insightDebugSection(_level: InsightLogLevel, _title: string, _payload: unknown): void {
|
||||
// Desktop debug log export has been replaced by per-insight request logs.
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅输出到 console,不落盘到文件。
|
||||
*/
|
||||
function insightLog(level: 'INFO' | 'WARN' | 'ERROR', message: string): void {
|
||||
function insightLog(level: InsightLogLevel, message: string): void {
|
||||
if (level === 'ERROR' || level === 'WARN') {
|
||||
console.warn(`[InsightService] ${message}`)
|
||||
} else {
|
||||
console.log(`[InsightService] ${message}`)
|
||||
}
|
||||
insightDebugLine(level, message)
|
||||
}
|
||||
|
||||
// ─── 工具函数 ─────────────────────────────────────────────────────────────────
|
||||
@@ -103,6 +135,32 @@ function formatTimestamp(ts: number): string {
|
||||
return new Date(ts).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function formatPromptCurrentTime(date: Date = new Date()): string {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
return `当前系统时间:${year}年${month}月${day}日 ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
function appendPromptCurrentTime(prompt: string): string {
|
||||
const base = String(prompt || '').trimEnd()
|
||||
if (!base) return formatPromptCurrentTime()
|
||||
return `${base}\n\n${formatPromptCurrentTime()}`
|
||||
}
|
||||
|
||||
function normalizeApiMaxTokens(value: unknown): number {
|
||||
const numeric = Number(value)
|
||||
if (!Number.isFinite(numeric)) return API_MAX_TOKENS_DEFAULT
|
||||
return Math.min(API_MAX_TOKENS_MAX, Math.max(API_MAX_TOKENS_MIN, Math.floor(numeric)))
|
||||
}
|
||||
|
||||
function normalizeSessionIdList(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return Array.from(new Set(value.map((item) => String(item || '').trim()).filter(Boolean)))
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用 OpenAI 兼容 API(非流式),返回模型第一条消息内容。
|
||||
* 使用 Node 原生 https/http 模块,无需任何第三方 SDK。
|
||||
@@ -112,7 +170,8 @@ function callApi(
|
||||
apiKey: string,
|
||||
model: string,
|
||||
messages: Array<{ role: string; content: string }>,
|
||||
timeoutMs: number = API_TIMEOUT_MS
|
||||
timeoutMs: number = API_TIMEOUT_MS,
|
||||
maxTokens: number = API_MAX_TOKENS_DEFAULT
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions')
|
||||
@@ -127,8 +186,8 @@ function callApi(
|
||||
const body = JSON.stringify({
|
||||
model,
|
||||
messages,
|
||||
max_tokens: 200,
|
||||
temperature: 0.7,
|
||||
max_tokens: normalizeApiMaxTokens(maxTokens),
|
||||
temperature: API_TEMPERATURE,
|
||||
stream: false
|
||||
})
|
||||
|
||||
@@ -255,6 +314,10 @@ class InsightService {
|
||||
if (!INSIGHT_CONFIG_KEYS.has(normalizedKey)) return
|
||||
|
||||
// 数据库相关配置变更后,丢弃缓存并强制下次重连
|
||||
if (normalizedKey === 'aiInsightAllowSocialContext' || normalizedKey === 'aiInsightSocialContextCount' || normalizedKey === 'aiInsightWeiboCookie' || normalizedKey === 'aiInsightWeiboBindings') {
|
||||
weiboService.clearCache()
|
||||
}
|
||||
|
||||
if (normalizedKey === 'dbPath' || normalizedKey === 'decryptKey' || normalizedKey === 'myWxid') {
|
||||
this.clearRuntimeCache()
|
||||
}
|
||||
@@ -287,6 +350,7 @@ class InsightService {
|
||||
this.lastSeenTimestamp.clear()
|
||||
this.todayTriggers.clear()
|
||||
this.todayDate = getStartOfDay()
|
||||
weiboService.clearCache()
|
||||
}
|
||||
|
||||
private clearTimers(): void {
|
||||
@@ -329,22 +393,44 @@ class InsightService {
|
||||
* 供设置页"测试连接"按钮调用。
|
||||
*/
|
||||
async testConnection(): Promise<{ success: boolean; message: string }> {
|
||||
const { apiBaseUrl, apiKey, model } = this.getSharedAiModelConfig()
|
||||
const { apiBaseUrl, apiKey, model, maxTokens } = this.getSharedAiModelConfig()
|
||||
|
||||
if (!apiBaseUrl || !apiKey) {
|
||||
return { success: false, message: '请先填写 API 地址和 API Key' }
|
||||
}
|
||||
|
||||
try {
|
||||
const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions')
|
||||
const requestMessages = [{ role: 'user', content: '请回复"连接成功"四个字。' }]
|
||||
insightDebugSection(
|
||||
'INFO',
|
||||
'AI 测试连接请求',
|
||||
[
|
||||
`Endpoint: ${endpoint}`,
|
||||
`Model: ${model}`,
|
||||
`Max Tokens: ${maxTokens}`,
|
||||
'',
|
||||
'用户提示词:',
|
||||
requestMessages[0].content
|
||||
].join('\n')
|
||||
)
|
||||
|
||||
const result = await callApi(
|
||||
apiBaseUrl,
|
||||
apiKey,
|
||||
model,
|
||||
[{ role: 'user', content: '请回复"连接成功"四个字。' }],
|
||||
15_000
|
||||
requestMessages,
|
||||
15_000,
|
||||
maxTokens
|
||||
)
|
||||
insightDebugSection('INFO', 'AI 测试连接输出原文', result)
|
||||
return { success: true, message: `连接成功,模型回复:${result.slice(0, 50)}` }
|
||||
} catch (e) {
|
||||
insightDebugSection(
|
||||
'ERROR',
|
||||
'AI 测试连接失败',
|
||||
`错误信息:${(e as Error).message}\n\n堆栈:\n${(e as Error).stack || '[无堆栈]'}`
|
||||
)
|
||||
return { success: false, message: `连接失败:${(e as Error).message}` }
|
||||
}
|
||||
}
|
||||
@@ -374,7 +460,7 @@ class InsightService {
|
||||
return id && !id.endsWith('@chatroom') && !id.toLowerCase().includes('placeholder') && this.isSessionAllowed(id)
|
||||
})
|
||||
if (!session) {
|
||||
return { success: false, message: '未找到任何私聊会话(若已启用白名单,请检查是否有勾选的私聊)' }
|
||||
return { success: false, message: '未找到任何可触发的私聊会话(请检查黑白名单模式与选择列表)' }
|
||||
}
|
||||
const sessionId = session.username?.trim() || ''
|
||||
const displayName = session.displayName || sessionId
|
||||
@@ -382,9 +468,15 @@ class InsightService {
|
||||
await this.generateInsightForSession({
|
||||
sessionId,
|
||||
displayName,
|
||||
triggerReason: 'activity'
|
||||
triggerReason: 'test'
|
||||
})
|
||||
return { success: true, message: `已向「${displayName}」发送测试见解,请查看右下角弹窗` }
|
||||
const notificationEnabled = this.config.get('aiInsightNotificationEnabled') !== false
|
||||
return {
|
||||
success: true,
|
||||
message: notificationEnabled
|
||||
? `已向「${displayName}」发送测试见解,请查看通知弹窗`
|
||||
: `已生成「${displayName}」的测试见解,AI 见解消息通知当前已关闭`
|
||||
}
|
||||
} catch (e) {
|
||||
return { success: false, message: `测试失败:${(e as Error).message}` }
|
||||
}
|
||||
@@ -422,7 +514,7 @@ class InsightService {
|
||||
return { success: false, message: '请先在设置中开启「AI 足迹总结」' }
|
||||
}
|
||||
|
||||
const { apiBaseUrl, apiKey, model } = this.getSharedAiModelConfig()
|
||||
const { apiBaseUrl, apiKey, model, maxTokens } = this.getSharedAiModelConfig()
|
||||
if (!apiBaseUrl || !apiKey) {
|
||||
return { success: false, message: '请先填写通用 AI 模型配置(API 地址和 Key)' }
|
||||
}
|
||||
@@ -462,7 +554,7 @@ class InsightService {
|
||||
const customPrompt = String(this.config.get('aiFootprintSystemPrompt') || '').trim()
|
||||
const systemPrompt = customPrompt || defaultSystemPrompt
|
||||
|
||||
const userPrompt = `统计范围:${rangeLabel}
|
||||
const userPromptBase = `统计范围:${rangeLabel}
|
||||
有聊天的人数:${Number(summary.private_inbound_people) || 0}
|
||||
我有回复的人数:${Number(summary.private_outbound_people) || 0}
|
||||
回复率:${(((Number(summary.private_reply_rate) || 0) * 100)).toFixed(1)}%
|
||||
@@ -476,6 +568,7 @@ ${topPrivateText}
|
||||
${topMentionText}
|
||||
|
||||
请给出足迹复盘(2-3句,含建议):`
|
||||
const userPrompt = appendPromptCurrentTime(userPromptBase)
|
||||
|
||||
try {
|
||||
const result = await callApi(
|
||||
@@ -486,9 +579,10 @@ ${topMentionText}
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt }
|
||||
],
|
||||
25_000
|
||||
25_000,
|
||||
maxTokens
|
||||
)
|
||||
const insight = result.trim().slice(0, 400)
|
||||
const insight = result.trim()
|
||||
if (!insight) return { success: false, message: '模型返回为空' }
|
||||
return { success: true, message: '生成成功', insight }
|
||||
} catch (error) {
|
||||
@@ -518,20 +612,129 @@ ${topMentionText}
|
||||
|| this.config.get('aiInsightApiModel')
|
||||
|| 'gpt-4o-mini'
|
||||
).trim() || 'gpt-4o-mini'
|
||||
const maxTokens = normalizeApiMaxTokens(this.config.get('aiModelApiMaxTokens'))
|
||||
|
||||
return { apiBaseUrl, apiKey, model }
|
||||
return { apiBaseUrl, apiKey, model, maxTokens }
|
||||
}
|
||||
|
||||
private looksLikeWxid(text: string): boolean {
|
||||
const normalized = String(text || '').trim()
|
||||
if (!normalized) return false
|
||||
return /^wxid_[a-z0-9]+$/i.test(normalized)
|
||||
|| /^[a-z0-9_]+@chatroom$/i.test(normalized)
|
||||
}
|
||||
|
||||
private looksLikeXmlPayload(text: string): boolean {
|
||||
const normalized = String(text || '').trim()
|
||||
if (!normalized) return false
|
||||
return /^(<\?xml|<msg\b|<appmsg\b|<img\b|<emoji\b|<voip\b|<sysmsg\b|<\?xml|<msg\b|<appmsg\b)/i.test(normalized)
|
||||
}
|
||||
|
||||
private normalizeInsightText(text: string): string {
|
||||
return String(text || '')
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\u0000/g, '')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
private formatInsightMessageTimestamp(createTime: number): string {
|
||||
const ms = createTime > 1_000_000_000_000 ? createTime : createTime * 1000
|
||||
const date = new Date(ms)
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||||
}
|
||||
|
||||
private async resolveInsightSessionDisplayName(sessionId: string, fallbackDisplayName: string): Promise<string> {
|
||||
const fallback = String(fallbackDisplayName || '').trim()
|
||||
if (fallback && !this.looksLikeWxid(fallback)) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
try {
|
||||
const sessions = await this.getSessionsCached()
|
||||
const matched = sessions.find((session) => String(session.username || '').trim() === sessionId)
|
||||
const cachedDisplayName = String(matched?.displayName || '').trim()
|
||||
if (cachedDisplayName && !this.looksLikeWxid(cachedDisplayName)) {
|
||||
return cachedDisplayName
|
||||
}
|
||||
} catch {
|
||||
// ignore display name lookup failures
|
||||
}
|
||||
|
||||
try {
|
||||
const contact = await chatService.getContactAvatar(sessionId)
|
||||
const contactDisplayName = String(contact?.displayName || '').trim()
|
||||
if (contactDisplayName && !this.looksLikeWxid(contactDisplayName)) {
|
||||
return contactDisplayName
|
||||
}
|
||||
} catch {
|
||||
// ignore display name lookup failures
|
||||
}
|
||||
|
||||
return fallback || sessionId
|
||||
}
|
||||
|
||||
private formatInsightMessageContent(message: Message): string {
|
||||
const parsedContent = this.normalizeInsightText(String(message.parsedContent || ''))
|
||||
const quotedPreview = this.normalizeInsightText(String(message.quotedContent || ''))
|
||||
const quotedSender = this.normalizeInsightText(String(message.quotedSender || ''))
|
||||
|
||||
if (quotedPreview) {
|
||||
const cleanQuotedSender = quotedSender && !this.looksLikeWxid(quotedSender) ? quotedSender : ''
|
||||
const quoteLabel = cleanQuotedSender ? `${cleanQuotedSender}:${quotedPreview}` : quotedPreview
|
||||
const replyText = parsedContent && parsedContent !== '[引用消息]' ? parsedContent : ''
|
||||
return replyText ? `${replyText}[引用 ${quoteLabel}]` : `[引用 ${quoteLabel}]`
|
||||
}
|
||||
|
||||
if (parsedContent) {
|
||||
return parsedContent
|
||||
}
|
||||
|
||||
const rawContent = this.normalizeInsightText(String(message.rawContent || ''))
|
||||
if (rawContent && !this.looksLikeXmlPayload(rawContent)) {
|
||||
return rawContent
|
||||
}
|
||||
|
||||
return '[其他消息]'
|
||||
}
|
||||
|
||||
private buildInsightContextSection(messages: Message[], peerDisplayName: string): string {
|
||||
if (!messages.length) return ''
|
||||
|
||||
const lines = messages.map((message) => {
|
||||
const senderName = message.isSend === 1 ? '我' : peerDisplayName
|
||||
const content = this.formatInsightMessageContent(message)
|
||||
return `${this.formatInsightMessageTimestamp(message.createTime)} '${senderName}'\n${content}`
|
||||
})
|
||||
|
||||
return `近期聊天记录(最近 ${lines.length} 条):\n\n${lines.join('\n\n')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断某个会话是否允许触发见解。
|
||||
* 若白名单未启用,则所有私聊会话均允许;
|
||||
* 若白名单已启用,则只有在白名单中的会话才允许。
|
||||
* white/black 模式二选一:
|
||||
* - whitelist:仅名单内允许
|
||||
* - blacklist:名单内屏蔽,其他允许
|
||||
*/
|
||||
private getInsightFilterConfig(): { mode: InsightFilterMode; list: string[] } {
|
||||
const modeRaw = String(this.config.get('aiInsightFilterMode') || '').trim().toLowerCase()
|
||||
const mode: InsightFilterMode = modeRaw === 'blacklist' ? 'blacklist' : 'whitelist'
|
||||
const list = normalizeSessionIdList(this.config.get('aiInsightFilterList'))
|
||||
return { mode, list }
|
||||
}
|
||||
|
||||
private isSessionAllowed(sessionId: string): boolean {
|
||||
const whitelistEnabled = this.config.get('aiInsightWhitelistEnabled') as boolean
|
||||
if (!whitelistEnabled) return true
|
||||
const whitelist = (this.config.get('aiInsightWhitelist') as string[]) || []
|
||||
return whitelist.includes(sessionId)
|
||||
const normalizedSessionId = String(sessionId || '').trim()
|
||||
if (!normalizedSessionId) return false
|
||||
const { mode, list } = this.getInsightFilterConfig()
|
||||
if (mode === 'whitelist') return list.includes(normalizedSessionId)
|
||||
return !list.includes(normalizedSessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -582,26 +785,108 @@ ${topMentionText}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录触发并返回该会话今日所有触发时间(用于组装 prompt)。
|
||||
* 记录成功推送的见解,用于设置页展示今日触发统计。
|
||||
*/
|
||||
private recordTrigger(sessionId: string): string[] {
|
||||
private recordTrigger(sessionId: string): void {
|
||||
this.resetIfNewDay()
|
||||
const existing = this.todayTriggers.get(sessionId) ?? { timestamps: [] }
|
||||
existing.timestamps.push(Date.now())
|
||||
this.todayTriggers.set(sessionId, existing)
|
||||
return existing.timestamps.map(formatTimestamp)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取今日全局已触发次数(所有会话合计),用于 prompt 中告知模型全局上下文。
|
||||
*/
|
||||
private getTodayTotalTriggerCount(): number {
|
||||
this.resetIfNewDay()
|
||||
let total = 0
|
||||
for (const record of this.todayTriggers.values()) {
|
||||
total += record.timestamps.length
|
||||
private formatWeiboTimestamp(raw: string): string {
|
||||
const parsed = Date.parse(String(raw || ''))
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return String(raw || '').trim()
|
||||
}
|
||||
return new Date(parsed).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
private formatMomentsTimestamp(raw: unknown): string {
|
||||
const numeric = Number(raw)
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||
return ''
|
||||
}
|
||||
const ms = numeric > 1_000_000_000_000 ? numeric : numeric * 1000
|
||||
return new Date(ms).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
private extractMomentReadableText(post: { contentDesc?: unknown; linkTitle?: unknown }): string {
|
||||
const contentDesc = this.normalizeInsightText(String(post.contentDesc || '')).replace(/\s+/g, ' ').trim()
|
||||
if (contentDesc) return contentDesc
|
||||
|
||||
const linkTitle = this.normalizeInsightText(String(post.linkTitle || '')).replace(/\s+/g, ' ').trim()
|
||||
if (linkTitle) return `[链接] ${linkTitle}`
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
private async getMomentsContextSection(sessionId: string): Promise<string> {
|
||||
const allowMomentsContext = this.config.get('aiInsightAllowMomentsContext') === true
|
||||
if (!allowMomentsContext) return ''
|
||||
|
||||
const bindings =
|
||||
(this.config.get('aiInsightMomentsBindings') as Record<string, { enabled?: boolean }> | undefined) || {}
|
||||
const isEnabledForSession = bindings[sessionId]?.enabled === true
|
||||
if (!isEnabledForSession) return ''
|
||||
|
||||
const countRaw = Number(this.config.get('aiInsightMomentsContextCount') || 5)
|
||||
const momentsCount = Math.max(1, Math.min(20, Math.floor(countRaw) || 5))
|
||||
|
||||
try {
|
||||
const result = await snsService.getTimeline(momentsCount, 0, [sessionId])
|
||||
const posts = result.success && Array.isArray(result.timeline) ? result.timeline : []
|
||||
if (posts.length === 0) return ''
|
||||
|
||||
const lines = posts
|
||||
.map((post) => {
|
||||
const text = this.extractMomentReadableText(post as { contentDesc?: unknown; linkTitle?: unknown })
|
||||
if (!text) return ''
|
||||
const shortText = text.length > 180 ? `${text.slice(0, 180)}...` : text
|
||||
const time = this.formatMomentsTimestamp((post as { createTime?: unknown }).createTime)
|
||||
return time ? `[朋友圈 ${time}] ${shortText}` : `[朋友圈] ${shortText}`
|
||||
})
|
||||
.filter(Boolean) as string[]
|
||||
|
||||
if (lines.length === 0) return ''
|
||||
insightLog('INFO', `已加载 ${lines.length} 条朋友圈内容 (sessionId=${sessionId})`)
|
||||
return `近期朋友圈内容(最近 ${lines.length} 条):\n${lines.join('\n')}`
|
||||
} catch (error) {
|
||||
insightLog('WARN', `拉取朋友圈内容失败 (sessionId=${sessionId}): ${(error as Error).message}`)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
private async getSocialContextSection(sessionId: string): Promise<string> {
|
||||
const allowSocialContext = this.config.get('aiInsightAllowSocialContext') === true
|
||||
if (!allowSocialContext) return ''
|
||||
|
||||
const rawCookie = String(this.config.get('aiInsightWeiboCookie') || '').trim()
|
||||
|
||||
const bindings =
|
||||
(this.config.get('aiInsightWeiboBindings') as Record<string, { uid?: string; screenName?: string }> | undefined) || {}
|
||||
const binding = bindings[sessionId]
|
||||
const uid = String(binding?.uid || '').trim()
|
||||
if (!uid) return ''
|
||||
|
||||
const socialCountRaw = Number(this.config.get('aiInsightSocialContextCount') || 3)
|
||||
const socialCount = Math.max(1, Math.min(5, Math.floor(socialCountRaw) || 3))
|
||||
|
||||
try {
|
||||
const posts = await weiboService.fetchRecentPosts(uid, rawCookie, socialCount)
|
||||
if (posts.length === 0) return ''
|
||||
|
||||
const lines = posts.map((post) => {
|
||||
const time = this.formatWeiboTimestamp(post.createdAt)
|
||||
const text = post.text.length > 180 ? `${post.text.slice(0, 180)}...` : post.text
|
||||
return `[微博 ${time}] ${text}`
|
||||
})
|
||||
insightLog('INFO', `已加载 ${lines.length} 条微博公开内容 (uid=${uid})`)
|
||||
return `近期公开社交平台内容(来源:微博,最近 ${lines.length} 条):\n${lines.join('\n')}`
|
||||
} catch (error) {
|
||||
insightLog('WARN', `拉取微博公开内容失败 (uid=${uid}): ${(error as Error).message}`)
|
||||
return ''
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// ── 沉默联系人扫描 ──────────────────────────────────────────────────────────
|
||||
@@ -699,8 +984,8 @@ ${topMentionText}
|
||||
* 1. 会话有真正的新消息(lastTimestamp 比上次见到的更新)
|
||||
* 2. 该会话距上次活跃分析已超过冷却期
|
||||
*
|
||||
* 白名单启用时:直接使用白名单里的 sessionId,完全跳过 getSessions()。
|
||||
* 白名单未启用时:从缓存拉取全量会话后过滤私聊。
|
||||
* whitelist 模式:直接使用名单里的 sessionId,完全跳过 getSessions()。
|
||||
* blacklist 模式:从缓存拉取会话后过滤名单。
|
||||
*/
|
||||
private async analyzeRecentActivity(): Promise<void> {
|
||||
if (!this.isEnabled()) return
|
||||
@@ -711,12 +996,11 @@ ${topMentionText}
|
||||
const now = Date.now()
|
||||
const cooldownMinutes = (this.config.get('aiInsightCooldownMinutes') as number) ?? 120
|
||||
const cooldownMs = cooldownMinutes * 60 * 1000
|
||||
const whitelistEnabled = this.config.get('aiInsightWhitelistEnabled') as boolean
|
||||
const whitelist = (this.config.get('aiInsightWhitelist') as string[]) || []
|
||||
const { mode: filterMode, list: filterList } = this.getInsightFilterConfig()
|
||||
|
||||
// 白名单启用且有勾选项时,直接用白名单 sessionId,无需查数据库全量会话列表。
|
||||
// whitelist 模式且有勾选项时,直接用名单 sessionId,无需查数据库全量会话列表。
|
||||
// 通过拉取该会话最新 1 条消息时间戳判断是否真正有新消息,开销极低。
|
||||
if (whitelistEnabled && whitelist.length > 0) {
|
||||
if (filterMode === 'whitelist' && filterList.length > 0) {
|
||||
// 确保数据库已连接(首次时连接,之后复用)
|
||||
if (!this.dbConnected) {
|
||||
const connectResult = await chatService.connect()
|
||||
@@ -724,8 +1008,8 @@ ${topMentionText}
|
||||
this.dbConnected = true
|
||||
}
|
||||
|
||||
for (const sessionId of whitelist) {
|
||||
if (!sessionId || sessionId.endsWith('@chatroom')) continue
|
||||
for (const sessionId of filterList) {
|
||||
if (!sessionId || sessionId.toLowerCase().includes('placeholder')) continue
|
||||
|
||||
// 冷却期检查(先过滤,减少不必要的 DB 查询)
|
||||
if (cooldownMs > 0) {
|
||||
@@ -762,16 +1046,22 @@ ${topMentionText}
|
||||
return
|
||||
}
|
||||
|
||||
// 白名单未启用:需要拉取全量会话列表,从中过滤私聊
|
||||
if (filterMode === 'whitelist' && filterList.length === 0) {
|
||||
insightLog('INFO', '白名单模式且名单为空,跳过活跃分析')
|
||||
return
|
||||
}
|
||||
|
||||
// blacklist 模式:拉取会话缓存后按过滤规则筛选
|
||||
const sessions = await this.getSessionsCached()
|
||||
if (sessions.length === 0) return
|
||||
|
||||
const privateSessions = sessions.filter((s) => {
|
||||
const candidateSessions = sessions.filter((s) => {
|
||||
const id = s.username?.trim() || ''
|
||||
return id && !id.endsWith('@chatroom') && !id.toLowerCase().includes('placeholder')
|
||||
if (!id || id.toLowerCase().includes('placeholder')) return false
|
||||
return this.isSessionAllowed(id)
|
||||
})
|
||||
|
||||
for (const session of privateSessions.slice(0, 10)) {
|
||||
for (const session of candidateSessions.slice(0, 10)) {
|
||||
const sessionId = session.username?.trim() || ''
|
||||
if (!sessionId) continue
|
||||
|
||||
@@ -807,16 +1097,24 @@ ${topMentionText}
|
||||
private async generateInsightForSession(params: {
|
||||
sessionId: string
|
||||
displayName: string
|
||||
triggerReason: 'activity' | 'silence'
|
||||
triggerReason: InsightRecordTriggerReason
|
||||
silentDays?: number
|
||||
}): Promise<void> {
|
||||
const { sessionId, displayName, triggerReason, silentDays } = params
|
||||
if (!sessionId) return
|
||||
if (!this.isEnabled()) return
|
||||
|
||||
const { apiBaseUrl, apiKey, model } = this.getSharedAiModelConfig()
|
||||
const { apiBaseUrl, apiKey, model, maxTokens } = this.getSharedAiModelConfig()
|
||||
const allowContext = this.config.get('aiInsightAllowContext') as boolean
|
||||
const contextCount = (this.config.get('aiInsightContextCount') as number) || 40
|
||||
const resolvedDisplayName = await this.resolveInsightSessionDisplayName(sessionId, displayName)
|
||||
let resolvedAvatarUrl: string | undefined
|
||||
try {
|
||||
const contact = await chatService.getContactAvatar(sessionId)
|
||||
resolvedAvatarUrl = String(contact?.avatarUrl || '').trim() || undefined
|
||||
} catch {
|
||||
resolvedAvatarUrl = undefined
|
||||
}
|
||||
|
||||
insightLog('INFO', `generateInsightForSession: sessionId=${sessionId}, reason=${triggerReason}, contextCount=${contextCount}, api=${apiBaseUrl ? '已配置' : '未配置'}`)
|
||||
|
||||
@@ -827,30 +1125,23 @@ ${topMentionText}
|
||||
|
||||
// ── 构建 prompt ────────────────────────────────────────────────────────────
|
||||
|
||||
// 今日触发统计(让模型具备时间与克制感)
|
||||
const sessionTriggerTimes = this.recordTrigger(sessionId)
|
||||
const totalTodayTriggers = this.getTodayTotalTriggerCount()
|
||||
|
||||
let contextSection = ''
|
||||
if (allowContext) {
|
||||
try {
|
||||
const msgsResult = await chatService.getLatestMessages(sessionId, contextCount)
|
||||
if (msgsResult.success && msgsResult.messages && msgsResult.messages.length > 0) {
|
||||
const messages: Message[] = msgsResult.messages
|
||||
const msgLines = messages.map((m) => {
|
||||
const sender = m.isSend === 1 ? '我' : (displayName || sessionId)
|
||||
const content = m.rawContent || m.parsedContent || '[非文字消息]'
|
||||
const time = new Date(Number(m.createTime) * 1000).toLocaleString('zh-CN')
|
||||
return `[${time}] ${sender}:${content}`
|
||||
})
|
||||
contextSection = `\n\n近期对话记录(最近 ${msgLines.length} 条):\n${msgLines.join('\n')}`
|
||||
insightLog('INFO', `已加载 ${msgLines.length} 条上下文消息`)
|
||||
contextSection = this.buildInsightContextSection(messages, resolvedDisplayName)
|
||||
insightLog('INFO', `已加载 ${messages.length} 条上下文消息`)
|
||||
}
|
||||
} catch (e) {
|
||||
insightLog('WARN', `拉取上下文失败: ${(e as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const momentsContextSection = await this.getMomentsContextSection(sessionId)
|
||||
const socialContextSection = await this.getSocialContextSection(sessionId)
|
||||
|
||||
// ── 默认 system prompt(稳定内容,有利于 provider 端 prompt cache 命中)────
|
||||
const DEFAULT_SYSTEM_PROMPT = `你是用户的私人关系观察助手,名叫"见解"。你的任务是主动提供有价值的观察和建议。
|
||||
|
||||
@@ -864,59 +1155,106 @@ ${topMentionText}
|
||||
const customPrompt = (this.config.get('aiInsightSystemPrompt') as string) || ''
|
||||
const systemPrompt = customPrompt.trim() || DEFAULT_SYSTEM_PROMPT
|
||||
|
||||
// 可变的上下文统计信息放在 user message 里,保持 system prompt 稳定不变
|
||||
// 这样 provider 端(Anthropic/OpenAI)能最大化命中 prompt cache,降低费用
|
||||
const triggerDesc =
|
||||
triggerReason === 'silence'
|
||||
? `你已经 ${silentDays} 天没有和「${displayName}」聊天了。`
|
||||
: `你最近和「${displayName}」有新的聊天动态。`
|
||||
|
||||
const todayStatsDesc =
|
||||
sessionTriggerTimes.length > 1
|
||||
? `今天你已经针对「${displayName}」收到过 ${sessionTriggerTimes.length - 1} 条见解(时间:${sessionTriggerTimes.slice(0, -1).join('、')}),请适当克制。`
|
||||
: `今天你还没有针对「${displayName}」发出过见解。`
|
||||
|
||||
const globalStatsDesc = `今天全部联系人合计已触发 ${totalTodayTriggers} 条见解。`
|
||||
|
||||
const userPrompt = `触发原因:${triggerDesc}
|
||||
时间统计:${todayStatsDesc} ${globalStatsDesc}${contextSection}
|
||||
|
||||
请给出你的见解(≤80字):`
|
||||
const userPromptBase = [
|
||||
triggerReason === 'silence' && silentDays
|
||||
? `已 ${silentDays} 天未联系「${resolvedDisplayName}」。`
|
||||
: '',
|
||||
contextSection,
|
||||
momentsContextSection,
|
||||
socialContextSection,
|
||||
'请给出你的见解(≤80字):'
|
||||
].filter(Boolean).join('\n\n')
|
||||
const userPrompt = appendPromptCurrentTime(userPromptBase)
|
||||
|
||||
const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions')
|
||||
const requestMessages = [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt }
|
||||
]
|
||||
|
||||
insightLog('INFO', `准备调用 API: ${endpoint},模型: ${model}`)
|
||||
insightDebugSection(
|
||||
'INFO',
|
||||
`AI 请求 ${resolvedDisplayName} (${sessionId})`,
|
||||
[
|
||||
`接口地址:${endpoint}`,
|
||||
`模型:${model}`,
|
||||
`Max Tokens:${maxTokens}`,
|
||||
`触发类型:${triggerReason}`,
|
||||
`上下文开关:${allowContext ? '开启' : '关闭'}`,
|
||||
`上下文条数:${contextCount}`,
|
||||
'',
|
||||
'系统提示词:',
|
||||
systemPrompt,
|
||||
'',
|
||||
'用户提示词:',
|
||||
userPrompt
|
||||
].join('\n')
|
||||
)
|
||||
|
||||
try {
|
||||
const apiStartedAt = Date.now()
|
||||
const result = await callApi(
|
||||
apiBaseUrl,
|
||||
apiKey,
|
||||
model,
|
||||
[
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt }
|
||||
]
|
||||
requestMessages,
|
||||
API_TIMEOUT_MS,
|
||||
maxTokens
|
||||
)
|
||||
const apiDurationMs = Date.now() - apiStartedAt
|
||||
|
||||
insightLog('INFO', `API 返回原文: ${result.slice(0, 150)}`)
|
||||
insightDebugSection('INFO', `AI 输出原文 ${resolvedDisplayName} (${sessionId})`, result)
|
||||
|
||||
// 模型主动选择跳过
|
||||
if (result.trim().toUpperCase() === 'SKIP' || result.trim().startsWith('SKIP')) {
|
||||
insightLog('INFO', `模型选择跳过 ${displayName}`)
|
||||
insightLog('INFO', `模型选择跳过 ${resolvedDisplayName}`)
|
||||
return
|
||||
}
|
||||
if (!this.isEnabled()) return
|
||||
|
||||
const insight = result.slice(0, 120)
|
||||
const notifTitle = `见解 · ${displayName}`
|
||||
const insight = result.trim()
|
||||
const notifTitle = `见解 · ${resolvedDisplayName}`
|
||||
const recordLog: InsightRecordLog = {
|
||||
endpoint,
|
||||
model,
|
||||
maxTokens,
|
||||
temperature: API_TEMPERATURE,
|
||||
triggerReason,
|
||||
allowContext,
|
||||
contextCount,
|
||||
systemPrompt,
|
||||
userPrompt,
|
||||
rawOutput: result,
|
||||
finalInsight: insight,
|
||||
durationMs: apiDurationMs,
|
||||
createdAt: Date.now()
|
||||
}
|
||||
const record = insightRecordService.addRecord({
|
||||
sessionId,
|
||||
displayName: resolvedDisplayName,
|
||||
avatarUrl: resolvedAvatarUrl,
|
||||
triggerReason,
|
||||
insight,
|
||||
log: recordLog
|
||||
})
|
||||
|
||||
insightLog('INFO', `推送通知 → ${displayName}: ${insight}`)
|
||||
const insightNotificationEnabled = this.config.get('aiInsightNotificationEnabled') !== false
|
||||
if (insightNotificationEnabled) {
|
||||
insightLog('INFO', `推送通知 → ${resolvedDisplayName}: ${insight}`)
|
||||
|
||||
// 渠道一:Electron 原生系统通知
|
||||
if (Notification.isSupported()) {
|
||||
const notif = new Notification({ title: notifTitle, body: insight, silent: false })
|
||||
notif.show()
|
||||
// 渠道一:应用内通知窗口。AI 见解使用独立通知开关,不受新消息通知开关和会话过滤影响。
|
||||
await showNotification({
|
||||
title: notifTitle,
|
||||
content: insight,
|
||||
avatarUrl: INSIGHT_NOTIFICATION_AVATAR_URL,
|
||||
sessionId,
|
||||
insightRecordId: record.id,
|
||||
channel: 'ai-insight'
|
||||
})
|
||||
} else {
|
||||
insightLog('WARN', '当前系统不支持原生通知')
|
||||
insightLog('INFO', `AI 见解消息通知已关闭,跳过应用通知 → ${resolvedDisplayName}: ${insight}`)
|
||||
}
|
||||
|
||||
// 渠道二:Telegram Bot 推送(可选)
|
||||
@@ -937,9 +1275,15 @@ ${topMentionText}
|
||||
}
|
||||
}
|
||||
|
||||
insightLog('INFO', `已为 ${displayName} 推送见解`)
|
||||
insightLog('INFO', `已完成 ${resolvedDisplayName} 的见解处理`)
|
||||
this.recordTrigger(sessionId)
|
||||
} catch (e) {
|
||||
insightLog('ERROR', `API 调用失败 (${displayName}): ${(e as Error).message}`)
|
||||
insightDebugSection(
|
||||
'ERROR',
|
||||
`AI 请求失败 ${resolvedDisplayName} (${sessionId})`,
|
||||
`错误信息:${(e as Error).message}\n\n堆栈:\n${(e as Error).stack || '[无堆栈]'}`
|
||||
)
|
||||
insightLog('ERROR', `API 调用失败 (${resolvedDisplayName}): ${(e as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -985,3 +1329,5 @@ ${topMentionText}
|
||||
}
|
||||
|
||||
export const insightService = new InsightService()
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import crypto from 'crypto'
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
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 {
|
||||
private readonly isMac = process.platform === 'darwin'
|
||||
@@ -814,7 +814,7 @@ export class KeyService {
|
||||
if (!this.verifyDerivedAesKey(aesKey, verifyCiphertext)) continue
|
||||
onProgress?.(`密钥获取成功 (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 未匹配,请确认账号目录后重试,或使用内存扫描' }
|
||||
@@ -826,7 +826,7 @@ export class KeyService {
|
||||
const { xorKey, aesKey } = this.deriveImageKeys(fallbackCode, fallbackWxid)
|
||||
onProgress?.(`密钥获取成功 (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 优点)---
|
||||
|
||||
@@ -3,6 +3,7 @@ import { join } from 'path'
|
||||
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
|
||||
import { execFile, exec, spawn } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import crypto from 'crypto'
|
||||
import { createRequire } from 'module';
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
@@ -10,7 +11,7 @@ const execFileAsync = promisify(execFile)
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] }
|
||||
type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; error?: string }
|
||||
type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; verified?: boolean; error?: string }
|
||||
|
||||
export class KeyServiceLinux {
|
||||
private sudo: any
|
||||
@@ -166,7 +167,7 @@ export class KeyServiceLinux {
|
||||
|
||||
await new Promise(r => setTimeout(r, 2000))
|
||||
|
||||
return await this.getDbKey(pid, onStatus)
|
||||
return await this.getDbKey(pid, onStatus, timeoutMs)
|
||||
} catch (err: any) {
|
||||
console.error('[Debug] 自动获取流程彻底崩溃:', err);
|
||||
const errMsg = '自动获取微信 PID 失败: ' + err.message
|
||||
@@ -175,7 +176,7 @@ export class KeyServiceLinux {
|
||||
}
|
||||
}
|
||||
|
||||
public async getDbKey(pid: number, onStatus?: (message: string, level: number) => void): Promise<DbKeyResult> {
|
||||
public async getDbKey(pid: number, onStatus?: (message: string, level: number) => void, timeoutMs = 180_000): Promise<DbKeyResult> {
|
||||
try {
|
||||
const helperPath = this.getHelperPath()
|
||||
|
||||
@@ -192,29 +193,63 @@ export class KeyServiceLinux {
|
||||
const targetAddr = scanRes.target_addr
|
||||
onStatus?.('基址扫描成功,正在请求管理员权限进行内存 Hook...', 0)
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
const options = { name: 'WeFlow' }
|
||||
const command = `"${helperPath}" db_hook ${pid} ${targetAddr}`
|
||||
if (!this.sudo || typeof this.sudo.exec !== 'function') {
|
||||
const err = 'Linux 授权组件 @vscode/sudo-prompt 未加载,请确认依赖已安装并重新启动 WeFlow'
|
||||
onStatus?.(err, 2)
|
||||
return { success: false, error: err }
|
||||
}
|
||||
|
||||
this.sudo.exec(command, options, (error, stdout) => {
|
||||
return await new Promise((resolve) => {
|
||||
const options = {
|
||||
name: 'WeFlow',
|
||||
env: {
|
||||
PATH: `${process.env.PATH || ''}:/bin:/usr/bin:/sbin:/usr/sbin:/usr/local/bin`
|
||||
}
|
||||
}
|
||||
const timeoutSec = Math.ceil((timeoutMs + 15_000) / 1000)
|
||||
const command = `timeout -k 5s ${timeoutSec}s "${helperPath}" db_hook ${pid} ${targetAddr} ${timeoutMs}`
|
||||
let settled = false
|
||||
const finish = (result: DbKeyResult) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(watchdog)
|
||||
resolve(result)
|
||||
}
|
||||
const watchdog = setTimeout(() => {
|
||||
execAsync(`kill -CONT ${pid}`).catch(() => {})
|
||||
const err = `Hook 等待超时(${Math.round(timeoutMs / 1000)} 秒)。请确认微信登录确认已完成,或重启微信后重试。`
|
||||
onStatus?.(err, 2)
|
||||
finish({ success: false, error: err })
|
||||
}, timeoutMs + 30_000)
|
||||
|
||||
onStatus?.('授权通过后请在手机上确认登录微信,正在等待密钥回调...', 0)
|
||||
|
||||
this.sudo.exec(command, options, (error, stdout, stderr) => {
|
||||
execAsync(`kill -CONT ${pid}`).catch(() => {})
|
||||
if (error) {
|
||||
onStatus?.('授权失败或被取消', 2)
|
||||
resolve({ success: false, error: `授权失败或被取消: ${error.message}` })
|
||||
const detail = String(stderr || '').trim()
|
||||
const message = detail ? `${error.message}: ${detail}` : error.message
|
||||
onStatus?.('授权失败或 Hook 执行失败', 2)
|
||||
finish({ success: false, error: `授权失败或 Hook 执行失败: ${message}` })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const hookRes = JSON.parse((stdout as string).trim())
|
||||
const output = String(stdout || '').trim()
|
||||
if (!output) {
|
||||
const detail = String(stderr || '').trim()
|
||||
throw new Error(detail ? `Hook 无输出: ${detail}` : 'Hook 无输出')
|
||||
}
|
||||
const hookRes = JSON.parse(output)
|
||||
if (hookRes.success) {
|
||||
onStatus?.('密钥获取成功', 1)
|
||||
resolve({ success: true, key: hookRes.key })
|
||||
finish({ success: true, key: hookRes.key })
|
||||
} else {
|
||||
onStatus?.(hookRes.result, 2)
|
||||
resolve({ success: false, error: hookRes.result })
|
||||
finish({ success: false, error: hookRes.result })
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
onStatus?.('解析 Hook 结果失败', 2)
|
||||
resolve({ success: false, error: '解析 Hook 结果失败' })
|
||||
finish({ success: false, error: e?.message || '解析 Hook 结果失败' })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -243,7 +278,14 @@ export class KeyServiceLinux {
|
||||
if (account && account.keys && account.keys.length > 0) {
|
||||
onProgress?.(`已找到匹配的图片密钥 (wxid: ${account.wxid})`);
|
||||
const keyObj = account.keys[0]
|
||||
return { success: true, xorKey: keyObj.xorKey, aesKey: keyObj.aesKey }
|
||||
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: '未在缓存中找到匹配的图片密钥' }
|
||||
} catch (err: any) {
|
||||
@@ -251,6 +293,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(
|
||||
accountPath: string,
|
||||
onProgress?: (msg: string) => void
|
||||
|
||||
@@ -7,7 +7,7 @@ import crypto from 'crypto'
|
||||
import { homedir } from 'os'
|
||||
|
||||
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)
|
||||
|
||||
export class KeyServiceMac {
|
||||
@@ -24,6 +24,9 @@ export class KeyServiceMac {
|
||||
private machVmReadOverwrite: any = null
|
||||
private machPortDeallocate: any = null
|
||||
private _needsElevation = false
|
||||
private restrictedFailureCount = 0
|
||||
private restrictedFailureAt = 0
|
||||
private readonly restrictedFailureWindowMs = 8 * 60_000
|
||||
|
||||
private getHelperPath(): string {
|
||||
const isPackaged = app.isPackaged
|
||||
@@ -186,18 +189,25 @@ export class KeyServiceMac {
|
||||
}
|
||||
|
||||
if (!parsed.success) {
|
||||
const errorMsg = this.mapDbKeyErrorMessage(parsed.code, parsed.detail)
|
||||
const errorMsg = this.enrichDbKeyErrorMessage(
|
||||
this.mapDbKeyErrorMessage(parsed.code, parsed.detail),
|
||||
parsed.code,
|
||||
parsed.detail
|
||||
)
|
||||
onStatus?.(errorMsg, 2)
|
||||
return { success: false, error: errorMsg }
|
||||
}
|
||||
|
||||
this.resetRestrictedFailureState()
|
||||
onStatus?.('密钥获取成功', 1)
|
||||
return { success: true, key: parsed.key }
|
||||
} catch (e: any) {
|
||||
console.error('[KeyServiceMac] Error:', e)
|
||||
console.error('[KeyServiceMac] Stack:', e.stack)
|
||||
onStatus?.('获取失败: ' + e.message, 2)
|
||||
return { success: false, error: e.message }
|
||||
const rawError = `${e?.message || e || ''}`.trim()
|
||||
const resolvedError = this.resolveUnexpectedDbKeyErrorMessage(rawError)
|
||||
onStatus?.(resolvedError, 2)
|
||||
return { success: false, error: resolvedError }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,6 +233,149 @@ export class KeyServiceMac {
|
||||
return this.parseDbKeyResult(helperResult)
|
||||
}
|
||||
|
||||
private resetRestrictedFailureState(): void {
|
||||
this.restrictedFailureCount = 0
|
||||
this.restrictedFailureAt = 0
|
||||
}
|
||||
|
||||
private markRestrictedFailureAndGetCount(): number {
|
||||
const now = Date.now()
|
||||
if (now - this.restrictedFailureAt > this.restrictedFailureWindowMs) {
|
||||
this.restrictedFailureCount = 0
|
||||
}
|
||||
this.restrictedFailureAt = now
|
||||
this.restrictedFailureCount += 1
|
||||
return this.restrictedFailureCount
|
||||
}
|
||||
|
||||
private isRestrictedEnvironmentFailure(code?: string, detail?: string): boolean {
|
||||
const normalizedCode = String(code || '').toUpperCase()
|
||||
const normalizedDetail = String(detail || '').toLowerCase()
|
||||
if (!normalizedCode && !normalizedDetail) return false
|
||||
|
||||
if (normalizedCode === 'SCAN_FAILED') {
|
||||
return normalizedDetail.includes('sink pattern not found')
|
||||
|| normalizedDetail.includes('no suitable module found')
|
||||
}
|
||||
|
||||
if (normalizedCode === 'HOOK_FAILED') {
|
||||
return normalizedDetail.includes('patch_breakpoint_failed')
|
||||
|| normalizedDetail.includes('thread_get_state_failed')
|
||||
|| normalizedDetail.includes('native hook failed')
|
||||
}
|
||||
|
||||
if (normalizedCode === 'ATTACH_FAILED') {
|
||||
return normalizedDetail.includes('task_for_pid:5')
|
||||
|| normalizedDetail.includes('thread_get_state_failed')
|
||||
}
|
||||
|
||||
return normalizedDetail.includes('patch_breakpoint_failed')
|
||||
|| normalizedDetail.includes('thread_get_state_failed')
|
||||
|| normalizedDetail.includes('sink pattern not found')
|
||||
|| normalizedDetail.includes('no suitable module found')
|
||||
}
|
||||
|
||||
private getMacRecoveryHint(isRepeatedFailure: boolean): string {
|
||||
const steps = isRepeatedFailure
|
||||
? '建议步骤:彻底退出微信 -> 重启电脑(冷启动)-> 降级微信到 4.1.7 -> 仅尝试一次自动获取 -> 成功后再升级微信。'
|
||||
: '建议步骤:降级微信到 4.1.7 -> 重启电脑(冷启动)-> 自动获取密钥 -> 成功后再升级微信。'
|
||||
return `${steps}\n请不要连续重试,以免触发微信安全模式或系统内存保护。`
|
||||
}
|
||||
|
||||
private simplifyDbKeyDetail(detail?: string): string {
|
||||
const raw = String(detail || '')
|
||||
.replace(/^WF_OK::/i, '')
|
||||
.replace(/^WF_ERR::/i, '')
|
||||
.replace(/\r?\n/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
if (!raw) return ''
|
||||
|
||||
const keys = [
|
||||
'No suitable module found',
|
||||
'Sink pattern not found',
|
||||
'patch_breakpoint_failed',
|
||||
'thread_get_state_failed',
|
||||
'task_for_pid:5',
|
||||
'attach_wait_timeout',
|
||||
'HOOK_TIMEOUT',
|
||||
'FRIDA_TIMEOUT'
|
||||
]
|
||||
for (const key of keys) {
|
||||
if (raw.includes(key)) return key
|
||||
}
|
||||
|
||||
const stripped = raw
|
||||
.replace(/\[xkey_helper\]/gi, ' ')
|
||||
.replace(/\[debug\]/gi, ' ')
|
||||
.replace(/\[\*\]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
if (!stripped) return ''
|
||||
return stripped.length > 140 ? `${stripped.slice(0, 140)}...` : stripped
|
||||
}
|
||||
|
||||
private extractDbKeyErrorFromAnyText(text?: string): { code?: string; detail?: string } {
|
||||
const raw = String(text || '')
|
||||
if (!raw) return {}
|
||||
|
||||
const explicit = raw.match(/ERROR:([A-Z_]+):([^\r\n]*)/)
|
||||
if (explicit) {
|
||||
return {
|
||||
code: explicit[1] || 'UNKNOWN',
|
||||
detail: this.simplifyDbKeyDetail(explicit[2] || '')
|
||||
}
|
||||
}
|
||||
|
||||
if (raw.includes('No suitable module found')) {
|
||||
return { code: 'SCAN_FAILED', detail: 'No suitable module found' }
|
||||
}
|
||||
if (raw.includes('Sink pattern not found')) {
|
||||
return { code: 'SCAN_FAILED', detail: 'Sink pattern not found' }
|
||||
}
|
||||
if (raw.includes('patch_breakpoint_failed')) {
|
||||
return { code: 'HOOK_FAILED', detail: 'patch_breakpoint_failed' }
|
||||
}
|
||||
if (raw.includes('thread_get_state_failed')) {
|
||||
return { code: 'HOOK_FAILED', detail: 'thread_get_state_failed' }
|
||||
}
|
||||
if (raw.includes('task_for_pid:5')) {
|
||||
return { code: 'ATTACH_FAILED', detail: 'task_for_pid:5' }
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
private resolveUnexpectedDbKeyErrorMessage(rawError?: string): string {
|
||||
const text = String(rawError || '').trim()
|
||||
const { code, detail } = this.extractDbKeyErrorFromAnyText(text)
|
||||
if (code) {
|
||||
const mapped = this.mapDbKeyErrorMessage(code, detail)
|
||||
return this.enrichDbKeyErrorMessage(mapped, code, detail)
|
||||
}
|
||||
|
||||
if (text.includes('helper timeout')) {
|
||||
return '获取密钥超时:请保持微信前台并进行一次会话操作后重试。'
|
||||
}
|
||||
if (text.includes('helper returned empty output') || text.includes('invalid json')) {
|
||||
return '获取失败:helper 未返回可识别结果,请彻底退出微信后重启电脑再试。'
|
||||
}
|
||||
if (text.includes('xkey_helper not found')) {
|
||||
return '获取失败:未找到 xkey_helper,请重新安装 WeFlow 后重试。'
|
||||
}
|
||||
return '自动获取密钥失败:环境可能受限或版本暂未适配,请稍后重试。'
|
||||
}
|
||||
|
||||
private enrichDbKeyErrorMessage(baseMessage: string, code?: string, detail?: string): string {
|
||||
if (!this.isRestrictedEnvironmentFailure(code, detail)) return baseMessage
|
||||
|
||||
const failureCount = this.markRestrictedFailureAndGetCount()
|
||||
if (failureCount >= 2) {
|
||||
return `${baseMessage}\n检测到连续失败,疑似已进入受限状态。请先彻底退出微信并重启电脑,再按下方步骤处理。\n${this.getMacRecoveryHint(true)}`
|
||||
}
|
||||
return `${baseMessage}\n${this.getMacRecoveryHint(false)}`
|
||||
}
|
||||
|
||||
private async getWeChatPid(): Promise<number> {
|
||||
try {
|
||||
// 优先使用 pgrep -x 精确匹配进程名
|
||||
@@ -478,8 +631,6 @@ export class KeyServiceMac {
|
||||
'return "WF_ERR::" & errNum & "::" & errMsg & "::" & (pr as text)',
|
||||
'end try'
|
||||
]
|
||||
onStatus?.('已准备就绪,现在登录微信或退出登录后重新登录微信', 0)
|
||||
|
||||
let stdout = ''
|
||||
try {
|
||||
const result = await execFileAsync('/usr/bin/osascript', scriptLines.flatMap(line => ['-e', line]), {
|
||||
@@ -500,7 +651,12 @@ export class KeyServiceMac {
|
||||
const errNum = parts[1] || 'unknown'
|
||||
const errMsg = parts[2] || 'unknown'
|
||||
const partial = parts.slice(3).join('::')
|
||||
throw new Error(`elevated helper failed: errNum=${errNum}, errMsg=${errMsg}, partial=${partial || '(empty)'}`)
|
||||
if (errNum === '-128' || String(errMsg).includes('User canceled')) {
|
||||
throw new Error('User canceled')
|
||||
}
|
||||
const inferred = this.extractDbKeyErrorFromAnyText(`${errMsg}\n${partial}`)
|
||||
if (inferred.code) return `ERROR:${inferred.code}:${inferred.detail || ''}`
|
||||
throw new Error(`elevated helper failed: errNum=${errNum}, errMsg=${this.simplifyDbKeyDetail(errMsg) || 'unknown'}`)
|
||||
}
|
||||
const normalizedOutput = joined.startsWith('WF_OK::') ? joined.slice('WF_OK::'.length) : joined
|
||||
|
||||
@@ -522,49 +678,57 @@ export class KeyServiceMac {
|
||||
// 其次找 result 字段
|
||||
const resultPayload = allJson.find(p => typeof p?.result === 'string')
|
||||
if (resultPayload) return resultPayload.result
|
||||
throw new Error('elevated helper returned invalid json: ' + lines[lines.length - 1])
|
||||
const inferred = this.extractDbKeyErrorFromAnyText(normalizedOutput)
|
||||
if (inferred.code) return `ERROR:${inferred.code}:${inferred.detail || ''}`
|
||||
throw new Error('elevated helper returned invalid output')
|
||||
}
|
||||
|
||||
private mapDbKeyErrorMessage(code?: string, detail?: string): string {
|
||||
const normalizedDetail = this.simplifyDbKeyDetail(detail)
|
||||
if (code === 'PROCESS_NOT_FOUND') return '微信进程未运行'
|
||||
if (code === 'ATTACH_FAILED') {
|
||||
const isDevElectron = process.execPath.includes('/node_modules/electron/')
|
||||
if ((detail || '').includes('task_for_pid:5')) {
|
||||
if (normalizedDetail.includes('task_for_pid:5')) {
|
||||
if (isDevElectron) {
|
||||
return `无法附加到微信进程(task_for_pid 被拒绝)。当前为开发环境 Electron:${process.execPath}\n建议使用打包后的 WeFlow.app(已携带调试 entitlements)再重试。`
|
||||
}
|
||||
return '无法附加到微信进程(task_for_pid 被系统拒绝)。请确认当前运行程序已正确签名并包含调试 entitlements。'
|
||||
return '无法附加到微信进程(task_for_pid 被系统拒绝)。请确认当前运行程序已正确签名并包含调试 entitlements,优先使用打包版 WeFlow.app。'
|
||||
}
|
||||
return `无法附加到进程 (${detail || ''})`
|
||||
if (normalizedDetail.includes('thread_get_state_failed')) {
|
||||
return `无法附加到进程:系统拒绝读取线程状态(${normalizedDetail})。`
|
||||
}
|
||||
return `无法附加到进程 (${normalizedDetail || ''})`
|
||||
}
|
||||
if (code === 'FRIDA_FAILED') {
|
||||
if ((detail || '').includes('FRIDA_TIMEOUT')) {
|
||||
if (normalizedDetail.includes('FRIDA_TIMEOUT')) {
|
||||
return '定位已成功但在等待时间内未捕获到密钥调用。请保持微信前台并进行一次会话/数据库访问后重试。'
|
||||
}
|
||||
return `Frida 语义定位失败 (${detail || ''})`
|
||||
return `Frida 语义定位失败 (${normalizedDetail || ''})`
|
||||
}
|
||||
if (code === 'HOOK_FAILED') {
|
||||
if ((detail || '').includes('HOOK_TIMEOUT')) {
|
||||
return 'Hook 已安装,但在等待时间内未触发目标函数。请保持微信前台并执行一次会话/数据库访问后重试。'
|
||||
if (normalizedDetail.includes('HOOK_TIMEOUT')) {
|
||||
return 'Hook 已安装,但在等待时间内未触发登录流程。请退出微信账号后重新登录,或在未登录状态下直接登录微信,完成一次登录流程后重试。'
|
||||
}
|
||||
if ((detail || '').includes('attach_wait_timeout')) {
|
||||
if (normalizedDetail.includes('attach_wait_timeout')) {
|
||||
return '附加调试器超时,未能进入 Hook 阶段。请确认微信处于可交互状态并重试。'
|
||||
}
|
||||
return `原生 Hook 失败 (${detail || ''})`
|
||||
if (normalizedDetail.includes('patch_breakpoint_failed') || normalizedDetail.includes('thread_get_state_failed')) {
|
||||
return `原生 Hook 失败:检测到系统调试权限或内存保护冲突(${normalizedDetail})。`
|
||||
}
|
||||
return `原生 Hook 失败 (${normalizedDetail || ''})`
|
||||
}
|
||||
if (code === 'HOOK_TARGET_ONLY') {
|
||||
return `已定位到目标函数地址(${detail || ''}),但当前原生 C++ 仅完成定位,尚未完成远程 Hook 回调取 key 流程。`
|
||||
return `已定位到目标函数地址(${normalizedDetail || ''}),但当前原生 C++ 仅完成定位,尚未完成远程 Hook 回调取 key 流程。`
|
||||
}
|
||||
if (code === 'SCAN_FAILED') {
|
||||
const normalizedDetail = (detail || '').trim()
|
||||
if (!normalizedDetail) {
|
||||
return '内存扫描失败:未匹配到可用特征。可能是当前微信版本更新导致,请升级 WeFlow 后重试。'
|
||||
}
|
||||
if (normalizedDetail.includes('Sink pattern not found')) {
|
||||
return '内存扫描失败:未匹配到目标函数特征,可使用微信 4.1.8.100 版本尝试。'
|
||||
return '内存扫描失败:未匹配到目标函数特征(Sink pattern not found),当前微信版本可能暂未适配。'
|
||||
}
|
||||
if (normalizedDetail.includes('No suitable module found')) {
|
||||
return '内存扫描失败:未找到可扫描的微信主模块。请确认微信已完整启动并保持前台,再重试。'
|
||||
return '内存扫描失败:未找到可扫描的微信主模块。请确认微信已完整启动并保持前台;若仍失败,优先尝试微信 4.1.7。'
|
||||
}
|
||||
return `内存扫描失败:${normalizedDetail}`
|
||||
}
|
||||
@@ -647,7 +811,7 @@ export class KeyServiceMac {
|
||||
const { xorKey, aesKey } = this.deriveImageKeys(code, candidateWxid)
|
||||
if (!this.verifyDerivedAesKey(aesKey, template.ciphertext)) continue
|
||||
onStatus?.(`密钥获取成功 (wxid: ${candidateWxid}, code: ${code})`)
|
||||
return { success: true, xorKey, aesKey }
|
||||
return { success: true, xorKey, aesKey, verified: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -662,7 +826,7 @@ export class KeyServiceMac {
|
||||
const fallbackCode = codes[0]
|
||||
const { xorKey, aesKey } = this.deriveImageKeys(fallbackCode, fallbackWxid)
|
||||
onStatus?.(`密钥获取成功 (wxid: ${fallbackWxid}, code: ${fallbackCode})`)
|
||||
return { success: true, xorKey, aesKey }
|
||||
return { success: true, xorKey, aesKey, verified: false }
|
||||
} catch (e: any) {
|
||||
return { success: false, error: `自动获取图片密钥失败: ${e.message}` }
|
||||
}
|
||||
|
||||
@@ -6,10 +6,13 @@ export interface LinuxNotificationData {
|
||||
title: string;
|
||||
content: string;
|
||||
avatarUrl?: string;
|
||||
channel?: string;
|
||||
insightRecordId?: string;
|
||||
targetRoute?: string;
|
||||
expireTimeout?: number;
|
||||
}
|
||||
|
||||
type NotificationCallback = (sessionId: string) => void;
|
||||
type NotificationCallback = (payload: unknown) => void;
|
||||
|
||||
let notificationCallbacks: NotificationCallback[] = [];
|
||||
let notificationCounter = 1;
|
||||
@@ -31,10 +34,10 @@ function clearNotificationState(notificationId: number): void {
|
||||
}
|
||||
}
|
||||
|
||||
function triggerNotificationCallback(sessionId: string): void {
|
||||
function triggerNotificationCallback(payload: unknown): void {
|
||||
for (const callback of notificationCallbacks) {
|
||||
try {
|
||||
callback(sessionId);
|
||||
callback(payload);
|
||||
} catch (error) {
|
||||
console.error("[LinuxNotification] Callback error:", error);
|
||||
}
|
||||
@@ -69,6 +72,15 @@ export async function showLinuxNotification(
|
||||
activeNotifications.set(notificationId, notification);
|
||||
|
||||
notification.on("click", () => {
|
||||
if (data.channel === "ai-insight" && data.insightRecordId) {
|
||||
triggerNotificationCallback({
|
||||
sessionId: data.sessionId,
|
||||
channel: data.channel,
|
||||
insightRecordId: data.insightRecordId,
|
||||
targetRoute: data.targetRoute,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (data.sessionId) {
|
||||
triggerNotificationCallback(data.sessionId);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export class MessageCacheService {
|
||||
private readonly cacheFilePath: string
|
||||
private cache: Record<string, SessionMessageCacheEntry> = {}
|
||||
private readonly sessionLimit = 150
|
||||
private readonly maxSessionEntries = 48
|
||||
|
||||
constructor(cacheBasePath?: string) {
|
||||
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
|
||||
@@ -36,6 +37,7 @@ export class MessageCacheService {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
this.cache = parsed
|
||||
this.pruneSessionEntries()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('MessageCacheService: 载入缓存失败', error)
|
||||
@@ -43,6 +45,19 @@ export class MessageCacheService {
|
||||
}
|
||||
}
|
||||
|
||||
private pruneSessionEntries(): void {
|
||||
const entries = Object.entries(this.cache || {})
|
||||
if (entries.length <= this.maxSessionEntries) return
|
||||
|
||||
entries.sort((left, right) => {
|
||||
const leftAt = Number(left[1]?.updatedAt || 0)
|
||||
const rightAt = Number(right[1]?.updatedAt || 0)
|
||||
return rightAt - leftAt
|
||||
})
|
||||
|
||||
this.cache = Object.fromEntries(entries.slice(0, this.maxSessionEntries))
|
||||
}
|
||||
|
||||
get(sessionId: string): SessionMessageCacheEntry | undefined {
|
||||
return this.cache[sessionId]
|
||||
}
|
||||
@@ -56,6 +71,7 @@ export class MessageCacheService {
|
||||
updatedAt: Date.now(),
|
||||
messages: trimmed
|
||||
}
|
||||
this.pruneSessionEntries()
|
||||
this.persist()
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
154
electron/services/nativeImageDecrypt.ts
Normal file
154
electron/services/nativeImageDecrypt.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { existsSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
|
||||
type NativeDecryptResult = {
|
||||
data: Buffer
|
||||
ext: string
|
||||
isWxgf?: boolean
|
||||
is_wxgf?: boolean
|
||||
version?: number
|
||||
aesSize?: number
|
||||
aes_size?: number
|
||||
xorSize?: number
|
||||
xor_size?: number
|
||||
rawSize?: number
|
||||
raw_size?: number
|
||||
flag?: number
|
||||
}
|
||||
|
||||
export type NativeDatMeta = {
|
||||
version?: number
|
||||
aesSize?: number
|
||||
aes_size?: number
|
||||
xorSize?: number
|
||||
xor_size?: number
|
||||
rawSize?: number
|
||||
raw_size?: number
|
||||
flag?: number
|
||||
}
|
||||
|
||||
type NativeAddon = {
|
||||
decryptDatNative: (inputPath: string, xorKey: number, aesKey?: string) => NativeDecryptResult
|
||||
encryptDatNative?: (inputPath: string, xorKey: number, aesKey?: string, meta?: NativeDatMeta) => Buffer
|
||||
}
|
||||
|
||||
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; meta: NativeDatMeta } | 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}`) : ''
|
||||
const meta: NativeDatMeta = {
|
||||
version: result.version,
|
||||
aes_size: result.aes_size ?? result.aesSize,
|
||||
xor_size: result.xor_size ?? result.xorSize,
|
||||
raw_size: result.raw_size ?? result.rawSize,
|
||||
flag: result.flag
|
||||
}
|
||||
return { data: result.data, ext, isWxgf, meta }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function encryptDatViaNative(
|
||||
inputPath: string,
|
||||
xorKey: number,
|
||||
aesKey?: string,
|
||||
meta?: NativeDatMeta
|
||||
): Buffer | null {
|
||||
const addon = loadAddon()
|
||||
if (!addon || typeof addon.encryptDatNative !== 'function') return null
|
||||
|
||||
try {
|
||||
const result = addon.encryptDatNative(inputPath, xorKey, aesKey, meta)
|
||||
return Buffer.isBuffer(result) ? result : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { wcdbService } from './wcdbService'
|
||||
import { ConfigService } from './config'
|
||||
import { ContactCacheService } from './contactCacheService'
|
||||
import { app } from 'electron'
|
||||
import { existsSync, mkdirSync } from 'fs'
|
||||
import { existsSync, mkdirSync, unlinkSync } from 'fs'
|
||||
import { readFile, writeFile, mkdir } from 'fs/promises'
|
||||
import { basename, join } from 'path'
|
||||
import crypto from 'crypto'
|
||||
@@ -174,8 +174,17 @@ const detectImageMime = (buf: Buffer, fallback: string = 'image/jpeg') => {
|
||||
// BMP
|
||||
if (buf[0] === 0x42 && buf[1] === 0x4d) return 'image/bmp'
|
||||
|
||||
// MP4: 00 00 00 18 / 20 / ... + 'ftyp'
|
||||
if (buf.length > 8 && buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70) return 'video/mp4'
|
||||
// ISO BMFF 家族:优先识别 AVIF/HEIF,避免误判为 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
|
||||
if (fallback.includes('video') || fallback.includes('mp4')) return 'video/mp4'
|
||||
@@ -315,6 +324,9 @@ class SnsService {
|
||||
private configService: ConfigService
|
||||
private contactCache: ContactCacheService
|
||||
private imageCache = new Map<string, string>()
|
||||
private imageCacheMeta = new Map<string, number>()
|
||||
private readonly imageCacheTtlMs = 15 * 60 * 1000
|
||||
private readonly imageCacheMaxEntries = 120
|
||||
private exportStatsCache: { totalPosts: number; totalFriends: number; myPosts: number | null; updatedAt: number } | null = null
|
||||
private userPostCountsCache: { counts: Record<string, number>; updatedAt: number } | null = null
|
||||
private readonly exportStatsCacheTtlMs = 5 * 60 * 1000
|
||||
@@ -327,6 +339,38 @@ class SnsService {
|
||||
this.contactCache = new ContactCacheService(this.configService.get('cachePath') as string)
|
||||
}
|
||||
|
||||
clearMemoryCache(): void {
|
||||
this.imageCache.clear()
|
||||
this.imageCacheMeta.clear()
|
||||
}
|
||||
|
||||
private pruneImageCache(now: number = Date.now()): void {
|
||||
for (const [key, updatedAt] of this.imageCacheMeta.entries()) {
|
||||
if (now - updatedAt > this.imageCacheTtlMs) {
|
||||
this.imageCacheMeta.delete(key)
|
||||
this.imageCache.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
while (this.imageCache.size > this.imageCacheMaxEntries) {
|
||||
const oldestKey = this.imageCache.keys().next().value as string | undefined
|
||||
if (!oldestKey) break
|
||||
this.imageCache.delete(oldestKey)
|
||||
this.imageCacheMeta.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
private rememberImageCache(cacheKey: string, dataUrl: string): void {
|
||||
if (!cacheKey || !dataUrl) return
|
||||
const now = Date.now()
|
||||
if (this.imageCache.has(cacheKey)) {
|
||||
this.imageCache.delete(cacheKey)
|
||||
}
|
||||
this.imageCache.set(cacheKey, dataUrl)
|
||||
this.imageCacheMeta.set(cacheKey, now)
|
||||
this.pruneImageCache(now)
|
||||
}
|
||||
|
||||
private toOptionalString(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') return undefined
|
||||
const trimmed = value.trim()
|
||||
@@ -870,7 +914,7 @@ class SnsService {
|
||||
const allowTimelineFallback = options?.allowTimelineFallback ?? true
|
||||
const preferCache = options?.preferCache ?? false
|
||||
const now = Date.now()
|
||||
const myWxid = this.toOptionalString(this.configService.get('myWxid'))
|
||||
const myWxid = this.toOptionalString(this.configService.getMyWxidCleaned())
|
||||
|
||||
try {
|
||||
if (preferCache && this.exportStatsCache && now - this.exportStatsCache.updatedAt <= this.exportStatsCacheTtlMs) {
|
||||
@@ -1230,8 +1274,27 @@ class SnsService {
|
||||
if (!url) return { success: false, error: 'url 不能为空' }
|
||||
const cacheKey = `${url}|${key ?? ''}`
|
||||
|
||||
if (this.imageCache.has(cacheKey)) {
|
||||
return { success: true, dataUrl: this.imageCache.get(cacheKey) }
|
||||
const cachedDataUrl = this.imageCache.get(cacheKey) || ''
|
||||
if (cachedDataUrl) {
|
||||
const cachedAt = this.imageCacheMeta.get(cacheKey) || 0
|
||||
if (cachedAt > 0 && Date.now() - cachedAt <= this.imageCacheTtlMs) {
|
||||
const base64Part = cachedDataUrl.split(',')[1] || ''
|
||||
if (base64Part) {
|
||||
try {
|
||||
const cachedBuf = Buffer.from(base64Part, 'base64')
|
||||
if (detectImageMime(cachedBuf, '').startsWith('image/')) {
|
||||
this.imageCache.delete(cacheKey)
|
||||
this.imageCache.set(cacheKey, cachedDataUrl)
|
||||
this.imageCacheMeta.set(cacheKey, Date.now())
|
||||
return { success: true, dataUrl: cachedDataUrl }
|
||||
}
|
||||
} catch {
|
||||
// ignore and fall through to refetch
|
||||
}
|
||||
}
|
||||
}
|
||||
this.imageCache.delete(cacheKey)
|
||||
this.imageCacheMeta.delete(cacheKey)
|
||||
}
|
||||
|
||||
const result = await this.fetchAndDecryptImage(url, key)
|
||||
@@ -1244,8 +1307,11 @@ class SnsService {
|
||||
}
|
||||
|
||||
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')}`
|
||||
this.imageCache.set(cacheKey, dataUrl)
|
||||
this.rememberImageCache(cacheKey, dataUrl)
|
||||
return { success: true, dataUrl }
|
||||
}
|
||||
}
|
||||
@@ -1274,6 +1340,8 @@ class SnsService {
|
||||
}, progressCallback?: (progress: { current: number; total: number; status: string }) => void, control?: {
|
||||
shouldPause?: () => boolean
|
||||
shouldStop?: () => boolean
|
||||
recordCreatedFile?: (filePath: string) => void
|
||||
recordCreatedDir?: (dirPath: string) => void
|
||||
}): Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; paused?: boolean; stopped?: boolean; error?: string }> {
|
||||
const { outputDir, format, usernames, keyword, startTime, endTime } = options
|
||||
const hasExplicitMediaSelection =
|
||||
@@ -1295,6 +1363,18 @@ class SnsService {
|
||||
if (control?.shouldPause?.()) return 'paused'
|
||||
return null
|
||||
}
|
||||
const ensureExportDir = (dirPath: string) => {
|
||||
const existed = existsSync(dirPath)
|
||||
if (!existed) {
|
||||
mkdirSync(dirPath, { recursive: true })
|
||||
control?.recordCreatedDir?.(dirPath)
|
||||
}
|
||||
}
|
||||
const recordCreatedFileBeforeWrite = (filePath: string) => {
|
||||
if (!existsSync(filePath)) {
|
||||
control?.recordCreatedFile?.(filePath)
|
||||
}
|
||||
}
|
||||
const buildInterruptedResult = (state: 'paused' | 'stopped', postCount: number, mediaCount: number) => (
|
||||
state === 'stopped'
|
||||
? { success: true, stopped: true, filePath: '', postCount, mediaCount }
|
||||
@@ -1303,9 +1383,7 @@ class SnsService {
|
||||
|
||||
try {
|
||||
// 确保输出目录存在
|
||||
if (!existsSync(outputDir)) {
|
||||
mkdirSync(outputDir, { recursive: true })
|
||||
}
|
||||
ensureExportDir(outputDir)
|
||||
|
||||
// 1. 分页加载全部帖子
|
||||
const allPosts: SnsPost[] = []
|
||||
@@ -1348,9 +1426,7 @@ class SnsService {
|
||||
const mediaDir = join(outputDir, 'media')
|
||||
|
||||
if (shouldExportMedia) {
|
||||
if (!existsSync(mediaDir)) {
|
||||
mkdirSync(mediaDir, { recursive: true })
|
||||
}
|
||||
ensureExportDir(mediaDir)
|
||||
|
||||
// 收集所有媒体下载任务
|
||||
const mediaTasks: Array<{
|
||||
@@ -1419,6 +1495,7 @@ class SnsService {
|
||||
} else {
|
||||
const result = await this.fetchAndDecryptImage(task.url, task.key)
|
||||
if (result.success && result.data) {
|
||||
recordCreatedFileBeforeWrite(filePath)
|
||||
await writeFile(filePath, result.data)
|
||||
if (task.kind === 'livephoto') {
|
||||
if (media.livePhoto) (media.livePhoto as any).localPath = `media/${fileName}`
|
||||
@@ -1428,6 +1505,7 @@ class SnsService {
|
||||
mediaCount++
|
||||
} else if (result.success && result.cachePath) {
|
||||
const cachedData = await readFile(result.cachePath)
|
||||
recordCreatedFileBeforeWrite(filePath)
|
||||
await writeFile(filePath, cachedData)
|
||||
if (task.kind === 'livephoto') {
|
||||
if (media.livePhoto) (media.livePhoto as any).localPath = `media/${fileName}`
|
||||
@@ -1465,7 +1543,7 @@ class SnsService {
|
||||
// 2.5 下载头像
|
||||
const avatarMap = new Map<string, string>()
|
||||
if (format === 'html') {
|
||||
if (!existsSync(mediaDir)) mkdirSync(mediaDir, { recursive: true })
|
||||
ensureExportDir(mediaDir)
|
||||
const uniqueUsers = [...new Map(allPosts.filter(p => p.avatarUrl).map(p => [p.username, p])).values()]
|
||||
let avatarDone = 0
|
||||
const avatarQueue = [...uniqueUsers]
|
||||
@@ -1482,6 +1560,7 @@ class SnsService {
|
||||
} else {
|
||||
const result = await this.fetchAndDecryptImage(post.avatarUrl!)
|
||||
if (result.success && result.data) {
|
||||
recordCreatedFileBeforeWrite(filePath)
|
||||
await writeFile(filePath, result.data)
|
||||
avatarMap.set(post.username, `media/${fileName}`)
|
||||
}
|
||||
@@ -1536,6 +1615,7 @@ class SnsService {
|
||||
linkUrl: (p as any).linkUrl
|
||||
}))
|
||||
}
|
||||
recordCreatedFileBeforeWrite(outputFilePath)
|
||||
await writeFile(outputFilePath, JSON.stringify(exportData, null, 2), 'utf-8')
|
||||
} else if (format === 'arkmejson') {
|
||||
outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.json`)
|
||||
@@ -1623,11 +1703,13 @@ class SnsService {
|
||||
},
|
||||
posts
|
||||
}
|
||||
recordCreatedFileBeforeWrite(outputFilePath)
|
||||
await writeFile(outputFilePath, JSON.stringify(exportData, null, 2), 'utf-8')
|
||||
} else {
|
||||
// HTML 格式
|
||||
outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.html`)
|
||||
const html = this.generateHtml(allPosts, { usernames, keyword }, avatarMap)
|
||||
recordCreatedFileBeforeWrite(outputFilePath)
|
||||
await writeFile(outputFilePath, html, 'utf-8')
|
||||
}
|
||||
|
||||
@@ -1853,8 +1935,13 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
|
||||
}
|
||||
|
||||
const data = await readFile(cachePath)
|
||||
const contentType = detectImageMime(data)
|
||||
return { success: true, data, contentType, cachePath }
|
||||
if (!detectImageMime(data, '').startsWith('image/')) {
|
||||
// 旧版本可能把未解密内容写入缓存;发现无效图片头时删除并重新拉取。
|
||||
try { unlinkSync(cachePath) } catch { }
|
||||
} else {
|
||||
const contentType = detectImageMime(data)
|
||||
return { success: true, data, contentType, cachePath }
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[SnsService] 读取缓存失败: ${cachePath}`, e)
|
||||
}
|
||||
@@ -2006,6 +2093,7 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
|
||||
const xEnc = String(res.headers['x-enc'] || '').trim()
|
||||
|
||||
let decoded = raw
|
||||
const rawMagicMime = detectImageMime(raw, '')
|
||||
|
||||
// 图片逻辑
|
||||
const shouldDecrypt = (xEnc === '1' || !!key) && key !== undefined && key !== null && String(key).trim().length > 0
|
||||
@@ -2023,13 +2111,24 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
|
||||
decrypted[i] = raw[i] ^ keystream[i]
|
||||
}
|
||||
|
||||
decoded = decrypted
|
||||
const decryptedMagicMime = detectImageMime(decrypted, '')
|
||||
if (decryptedMagicMime.startsWith('image/')) {
|
||||
decoded = decrypted
|
||||
} else if (!rawMagicMime.startsWith('image/')) {
|
||||
decoded = decrypted
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[SnsService] TS Decrypt Error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const decodedMagicMime = detectImageMime(decoded, '')
|
||||
if (!decodedMagicMime.startsWith('image/')) {
|
||||
resolve({ success: false, error: '图片解密失败:无法识别图片格式' })
|
||||
return
|
||||
}
|
||||
|
||||
// 写入磁盘缓存
|
||||
try {
|
||||
await writeFile(cachePath, decoded)
|
||||
@@ -2063,6 +2162,15 @@ window.addEventListener('scroll',function(){document.getElementById('btt').class
|
||||
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
|
||||
&& 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
|
||||
}
|
||||
|
||||
|
||||
367
electron/services/social/weiboService.ts
Normal file
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,8 +1,6 @@
|
||||
import { join } from 'path'
|
||||
import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync, unlinkSync } from 'fs'
|
||||
import { spawn } from 'child_process'
|
||||
import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync } from 'fs'
|
||||
import { pathToFileURL } from 'url'
|
||||
import crypto from 'crypto'
|
||||
import { app } from 'electron'
|
||||
import { ConfigService } from './config'
|
||||
import { wcdbService } from './wcdbService'
|
||||
@@ -27,48 +25,15 @@ interface VideoIndexEntry {
|
||||
|
||||
type PosterFormat = 'dataUrl' | 'fileUrl'
|
||||
|
||||
function getStaticFfmpegPath(): string | null {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const ffmpegStatic = require('ffmpeg-static')
|
||||
if (typeof ffmpegStatic === 'string') {
|
||||
let fixedPath = ffmpegStatic
|
||||
if (fixedPath.includes('app.asar') && !fixedPath.includes('app.asar.unpacked')) {
|
||||
fixedPath = fixedPath.replace('app.asar', 'app.asar.unpacked')
|
||||
}
|
||||
if (existsSync(fixedPath)) return fixedPath
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const ffmpegName = process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
|
||||
const devPath = join(process.cwd(), 'node_modules', 'ffmpeg-static', ffmpegName)
|
||||
if (existsSync(devPath)) return devPath
|
||||
|
||||
if (app.isPackaged) {
|
||||
const packedPath = join(process.resourcesPath, 'app.asar.unpacked', 'node_modules', 'ffmpeg-static', ffmpegName)
|
||||
if (existsSync(packedPath)) return packedPath
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
class VideoService {
|
||||
private configService: ConfigService
|
||||
private hardlinkResolveCache = new Map<string, TimedCacheEntry<string | null>>()
|
||||
private videoInfoCache = new Map<string, TimedCacheEntry<VideoInfo>>()
|
||||
private videoDirIndexCache = new Map<string, TimedCacheEntry<Map<string, VideoIndexEntry>>>()
|
||||
private pendingVideoInfo = new Map<string, Promise<VideoInfo>>()
|
||||
private pendingPosterExtract = new Map<string, Promise<string | null>>()
|
||||
private extractedPosterCache = new Map<string, TimedCacheEntry<string | null>>()
|
||||
private posterExtractRunning = 0
|
||||
private posterExtractQueue: Array<() => void> = []
|
||||
private readonly hardlinkCacheTtlMs = 10 * 60 * 1000
|
||||
private readonly videoInfoCacheTtlMs = 2 * 60 * 1000
|
||||
private readonly videoIndexCacheTtlMs = 90 * 1000
|
||||
private readonly extractedPosterCacheTtlMs = 15 * 60 * 1000
|
||||
private readonly maxPosterExtractConcurrency = 1
|
||||
private readonly maxCacheEntries = 2000
|
||||
private readonly maxIndexEntries = 6
|
||||
|
||||
@@ -131,7 +96,7 @@ class VideoService {
|
||||
* 获取当前用户的wxid
|
||||
*/
|
||||
private getMyWxid(): string {
|
||||
return this.configService.get('myWxid') || ''
|
||||
return this.configService.getMyWxidCleaned() || ''
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -166,6 +131,14 @@ class VideoService {
|
||||
if (dbPathContainsWxid) {
|
||||
return join(dbPath, 'msg', 'video')
|
||||
}
|
||||
|
||||
// 使用 ConfigService 的统一账号目录解析
|
||||
const accountDir = this.configService.getAccountDir(dbPath, wxid)
|
||||
if (accountDir) {
|
||||
return join(accountDir, 'msg', 'video')
|
||||
}
|
||||
|
||||
// 回退到原始逻辑
|
||||
return join(dbPath, wxid, 'msg', 'video')
|
||||
}
|
||||
|
||||
@@ -179,6 +152,13 @@ class VideoService {
|
||||
return [join(dbPath, 'db_storage', 'hardlink', 'hardlink.db')]
|
||||
}
|
||||
|
||||
// 使用 ConfigService 的统一账号目录解析
|
||||
const accountDir = this.configService.getAccountDir(dbPath, wxid)
|
||||
if (accountDir) {
|
||||
return [join(accountDir, 'db_storage', 'hardlink', 'hardlink.db')]
|
||||
}
|
||||
|
||||
// 回退到原始逻辑
|
||||
return [
|
||||
join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'),
|
||||
join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db')
|
||||
@@ -287,11 +267,9 @@ class VideoService {
|
||||
}
|
||||
|
||||
async preloadVideoHardlinkMd5s(md5List: string[]): Promise<void> {
|
||||
const dbPath = this.getDbPath()
|
||||
const wxid = this.getMyWxid()
|
||||
const cleanedWxid = this.cleanWxid(wxid)
|
||||
if (!dbPath || !wxid) return
|
||||
await this.resolveVideoHardlinks(md5List, dbPath, wxid, cleanedWxid)
|
||||
// 视频链路已改为直接使用 packed_info_data 提取出的文件名索引本地目录。
|
||||
// 该预热接口保留仅为兼容旧调用方,不再查询 hardlink.db。
|
||||
void md5List
|
||||
}
|
||||
|
||||
private fileToPosterUrl(filePath: string | undefined, mimeType: string, posterFormat: PosterFormat): string | undefined {
|
||||
@@ -429,6 +407,23 @@ class VideoService {
|
||||
return 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,
|
||||
@@ -473,154 +468,10 @@ class VideoService {
|
||||
return null
|
||||
}
|
||||
|
||||
private getFfmpegPath(): string {
|
||||
const staticPath = getStaticFfmpegPath()
|
||||
if (staticPath) return staticPath
|
||||
return 'ffmpeg'
|
||||
}
|
||||
|
||||
private async withPosterExtractSlot<T>(run: () => Promise<T>): Promise<T> {
|
||||
if (this.posterExtractRunning >= this.maxPosterExtractConcurrency) {
|
||||
await new Promise<void>((resolve) => {
|
||||
this.posterExtractQueue.push(resolve)
|
||||
})
|
||||
}
|
||||
this.posterExtractRunning += 1
|
||||
try {
|
||||
return await run()
|
||||
} finally {
|
||||
this.posterExtractRunning = Math.max(0, this.posterExtractRunning - 1)
|
||||
const next = this.posterExtractQueue.shift()
|
||||
if (next) next()
|
||||
}
|
||||
}
|
||||
|
||||
private async extractFirstFramePoster(videoPath: string, posterFormat: PosterFormat): Promise<string | null> {
|
||||
const normalizedPath = String(videoPath || '').trim()
|
||||
if (!normalizedPath || !existsSync(normalizedPath)) return null
|
||||
|
||||
const cacheKey = `${normalizedPath}|format=${posterFormat}`
|
||||
const cached = this.readTimedCache(this.extractedPosterCache, cacheKey)
|
||||
if (cached !== undefined) return cached
|
||||
|
||||
const pending = this.pendingPosterExtract.get(cacheKey)
|
||||
if (pending) return pending
|
||||
|
||||
const task = this.withPosterExtractSlot(() => new Promise<string | null>((resolve) => {
|
||||
const tmpDir = join(app.getPath('temp'), 'weflow_video_frames')
|
||||
try {
|
||||
if (!existsSync(tmpDir)) mkdirSync(tmpDir, { recursive: true })
|
||||
} catch {
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
|
||||
const stableHash = crypto.createHash('sha1').update(normalizedPath).digest('hex').slice(0, 24)
|
||||
const outputPath = join(tmpDir, `frame_${stableHash}.jpg`)
|
||||
if (posterFormat === 'fileUrl' && existsSync(outputPath)) {
|
||||
resolve(pathToFileURL(outputPath).toString())
|
||||
return
|
||||
}
|
||||
|
||||
const ffmpegPath = this.getFfmpegPath()
|
||||
const args = [
|
||||
'-hide_banner', '-loglevel', 'error', '-y',
|
||||
'-ss', '0',
|
||||
'-i', normalizedPath,
|
||||
'-frames:v', '1',
|
||||
'-q:v', '3',
|
||||
outputPath
|
||||
]
|
||||
|
||||
const errChunks: Buffer[] = []
|
||||
let done = false
|
||||
const finish = (value: string | null) => {
|
||||
if (done) return
|
||||
done = true
|
||||
if (posterFormat === 'dataUrl') {
|
||||
try {
|
||||
if (existsSync(outputPath)) unlinkSync(outputPath)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
resolve(value)
|
||||
}
|
||||
|
||||
const proc = spawn(ffmpegPath, args, {
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
windowsHide: true
|
||||
})
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
try { proc.kill('SIGKILL') } catch { /* ignore */ }
|
||||
finish(null)
|
||||
}, 12000)
|
||||
|
||||
proc.stderr.on('data', (chunk: Buffer) => errChunks.push(chunk))
|
||||
|
||||
proc.on('error', () => {
|
||||
clearTimeout(timer)
|
||||
finish(null)
|
||||
})
|
||||
|
||||
proc.on('close', (code: number) => {
|
||||
clearTimeout(timer)
|
||||
if (code !== 0 || !existsSync(outputPath)) {
|
||||
if (errChunks.length > 0) {
|
||||
this.log('extractFirstFrameDataUrl failed', {
|
||||
videoPath: normalizedPath,
|
||||
error: Buffer.concat(errChunks).toString().slice(0, 240)
|
||||
})
|
||||
}
|
||||
finish(null)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const jpgBuf = readFileSync(outputPath)
|
||||
if (!jpgBuf.length) {
|
||||
finish(null)
|
||||
return
|
||||
}
|
||||
if (posterFormat === 'fileUrl') {
|
||||
finish(pathToFileURL(outputPath).toString())
|
||||
return
|
||||
}
|
||||
finish(`data:image/jpeg;base64,${jpgBuf.toString('base64')}`)
|
||||
} catch {
|
||||
finish(null)
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
this.pendingPosterExtract.set(cacheKey, task)
|
||||
try {
|
||||
const result = await task
|
||||
this.writeTimedCache(
|
||||
this.extractedPosterCache,
|
||||
cacheKey,
|
||||
result,
|
||||
this.extractedPosterCacheTtlMs,
|
||||
this.maxCacheEntries
|
||||
)
|
||||
return result
|
||||
} finally {
|
||||
this.pendingPosterExtract.delete(cacheKey)
|
||||
}
|
||||
}
|
||||
|
||||
private async ensurePoster(info: VideoInfo, includePoster: boolean, posterFormat: PosterFormat): Promise<VideoInfo> {
|
||||
void posterFormat
|
||||
if (!includePoster) return info
|
||||
if (!info.exists || !info.videoUrl) return info
|
||||
if (info.coverUrl || info.thumbUrl) return info
|
||||
|
||||
const extracted = await this.extractFirstFramePoster(info.videoUrl, posterFormat)
|
||||
if (!extracted) return info
|
||||
return {
|
||||
...info,
|
||||
coverUrl: extracted,
|
||||
thumbUrl: extracted
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -652,7 +503,7 @@ class VideoService {
|
||||
if (pending) return pending
|
||||
|
||||
const task = (async (): Promise<VideoInfo> => {
|
||||
const realVideoMd5 = await this.queryVideoFileName(normalizedMd5) || normalizedMd5
|
||||
const realVideoMd5 = this.normalizeVideoLookupKey(normalizedMd5) || normalizedMd5
|
||||
const videoBaseDir = this.resolveVideoBaseDir(dbPath, wxid)
|
||||
|
||||
if (!existsSync(videoBaseDir)) {
|
||||
@@ -678,7 +529,7 @@ class VideoService {
|
||||
|
||||
const miss = { exists: false }
|
||||
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
|
||||
})()
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { join, dirname, basename } from 'path'
|
||||
import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import * as fzstd from 'fzstd'
|
||||
import { expandHomePath } from '../utils/pathUtils'
|
||||
|
||||
//数据服务初始化错误信息,用于帮助用户诊断问题
|
||||
let lastDllInitError: string | null = null
|
||||
@@ -10,6 +11,19 @@ export function getLastDllInitError(): string | null {
|
||||
return lastDllInitError
|
||||
}
|
||||
|
||||
function cleanAccountDirName(dirName: string): string {
|
||||
const trimmed = dirName.trim()
|
||||
if (!trimmed) return trimmed
|
||||
if (trimmed.toLowerCase().startsWith('wxid_')) {
|
||||
const match = trimmed.match(/^(wxid_[^_]+)/i)
|
||||
if (match) return match[1]
|
||||
return trimmed
|
||||
}
|
||||
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
||||
if (suffixMatch) return suffixMatch[1]
|
||||
return trimmed
|
||||
}
|
||||
|
||||
export class WcdbCore {
|
||||
private resourcesPath: string | null = null
|
||||
private userDataPath: string | null = null
|
||||
@@ -34,8 +48,10 @@ export class WcdbCore {
|
||||
private wcdbUpdateMessage: any = null
|
||||
private wcdbDeleteMessage: any = null
|
||||
private wcdbGetSessions: any = null
|
||||
private wcdbMarkAllSessionsRead: any = null
|
||||
private wcdbGetMessages: any = null
|
||||
private wcdbGetMessageCount: any = null
|
||||
private wcdbGetMessageByServerId: any = null
|
||||
private wcdbGetDisplayNames: any = null
|
||||
private wcdbGetAvatarUrls: any = null
|
||||
private wcdbGetGroupMemberCount: any = null
|
||||
@@ -90,6 +106,11 @@ export class WcdbCore {
|
||||
private wcdbGetSnsUsernames: any = null
|
||||
private wcdbGetSnsExportStats: any = null
|
||||
private wcdbGetMessageTableColumns: any = null
|
||||
private wcdbListTables: any = null
|
||||
private wcdbGetTableSchema: any = null
|
||||
private wcdbExportTableSnapshot: any = null
|
||||
private wcdbImportTableSnapshot: any = null
|
||||
private wcdbImportTableSnapshotWithSchema: any = null
|
||||
private wcdbGetMessageTableTimeRange: any = null
|
||||
private wcdbResolveImageHardlink: any = null
|
||||
private wcdbResolveImageHardlinkBatch: any = null
|
||||
@@ -481,7 +502,7 @@ export class WcdbCore {
|
||||
|
||||
private resolveDbStoragePath(basePath: string, wxid: string): string | null {
|
||||
if (!basePath) return null
|
||||
const normalized = basePath.replace(/[\\\\/]+$/, '')
|
||||
const normalized = expandHomePath(basePath).replace(/[\\\\/]+$/, '')
|
||||
if (normalized.toLowerCase().endsWith('db_storage') && existsSync(normalized)) {
|
||||
return normalized
|
||||
}
|
||||
@@ -804,12 +825,22 @@ export class WcdbCore {
|
||||
// wcdb_status wcdb_get_sessions(wcdb_handle handle, char** out_json)
|
||||
this.wcdbGetSessions = this.lib.func('int32 wcdb_get_sessions(int64 handle, _Out_ void** outJson)')
|
||||
|
||||
// wcdb_status wcdb_mark_all_sessions_read(wcdb_handle handle, char** out_error)
|
||||
try {
|
||||
this.wcdbMarkAllSessionsRead = this.lib.func('int32 wcdb_mark_all_sessions_read(int64 handle, _Out_ void** outError)')
|
||||
} catch {
|
||||
this.wcdbMarkAllSessionsRead = null
|
||||
}
|
||||
|
||||
// wcdb_status wcdb_get_messages(wcdb_handle handle, const char* username, int32_t limit, int32_t offset, char** out_json)
|
||||
this.wcdbGetMessages = this.lib.func('int32 wcdb_get_messages(int64 handle, const char* username, int32 limit, int32 offset, _Out_ void** outJson)')
|
||||
|
||||
// wcdb_status wcdb_get_message_count(wcdb_handle handle, const char* username, int32_t* out_count)
|
||||
this.wcdbGetMessageCount = this.lib.func('int32 wcdb_get_message_count(int64 handle, const char* username, _Out_ int32* outCount)')
|
||||
|
||||
// wcdb_status wcdb_get_message_by_svrid(wcdb_handle handle, const char* session_id, const char* svrid, char** out_json)
|
||||
this.wcdbGetMessageByServerId = this.lib.func('int32 wcdb_get_message_by_svrid(int64 handle, const char* sessionId, const char* svrid, _Out_ void** outJson)')
|
||||
|
||||
// wcdb_status wcdb_get_display_names(wcdb_handle handle, const char* usernames_json, char** out_json)
|
||||
this.wcdbGetDisplayNames = this.lib.func('int32 wcdb_get_display_names(int64 handle, const char* usernamesJson, _Out_ void** outJson)')
|
||||
|
||||
@@ -1089,6 +1120,31 @@ export class WcdbCore {
|
||||
} catch {
|
||||
this.wcdbGetMessageTableColumns = null
|
||||
}
|
||||
try {
|
||||
this.wcdbListTables = this.lib.func('int32 wcdb_list_tables(int64 handle, const char* kind, const char* dbPath, _Out_ void** outJson)')
|
||||
} catch {
|
||||
this.wcdbListTables = null
|
||||
}
|
||||
try {
|
||||
this.wcdbGetTableSchema = this.lib.func('int32 wcdb_get_table_schema(int64 handle, const char* kind, const char* dbPath, const char* tableName, _Out_ void** outJson)')
|
||||
} catch {
|
||||
this.wcdbGetTableSchema = null
|
||||
}
|
||||
try {
|
||||
this.wcdbExportTableSnapshot = this.lib.func('int32 wcdb_export_table_snapshot(int64 handle, const char* kind, const char* dbPath, const char* tableName, const char* outputPath, _Out_ void** outJson)')
|
||||
} catch {
|
||||
this.wcdbExportTableSnapshot = null
|
||||
}
|
||||
try {
|
||||
this.wcdbImportTableSnapshot = this.lib.func('int32 wcdb_import_table_snapshot(int64 handle, const char* kind, const char* dbPath, const char* tableName, const char* inputPath, _Out_ void** outJson)')
|
||||
} catch {
|
||||
this.wcdbImportTableSnapshot = null
|
||||
}
|
||||
try {
|
||||
this.wcdbImportTableSnapshotWithSchema = this.lib.func('int32 wcdb_import_table_snapshot_with_schema(int64 handle, const char* kind, const char* dbPath, const char* tableName, const char* inputPath, const char* createTableSql, _Out_ void** outJson)')
|
||||
} catch {
|
||||
this.wcdbImportTableSnapshotWithSchema = null
|
||||
}
|
||||
try {
|
||||
this.wcdbGetMessageTableTimeRange = this.lib.func('int32 wcdb_get_message_table_time_range(int64 handle, const char* dbPath, const char* tableName, _Out_ void** outJson)')
|
||||
} catch {
|
||||
@@ -1229,13 +1285,12 @@ export class WcdbCore {
|
||||
/**
|
||||
* 测试数据库连接
|
||||
*/
|
||||
async testConnection(dbPath: string, hexKey: string, wxid: string): Promise<{ success: boolean; error?: string; sessionCount?: number }> {
|
||||
async testConnection(accountDir: string, hexKey: string): Promise<{ success: boolean; error?: string; sessionCount?: number }> {
|
||||
try {
|
||||
// 如果当前已经有相同参数的活动连接,直接返回成功
|
||||
if (this.handle !== null &&
|
||||
this.currentPath === dbPath &&
|
||||
this.currentKey === hexKey &&
|
||||
this.currentWxid === wxid) {
|
||||
this.currentPath === accountDir &&
|
||||
this.currentKey === hexKey) {
|
||||
return { success: true, sessionCount: 0 }
|
||||
}
|
||||
|
||||
@@ -1253,9 +1308,9 @@ export class WcdbCore {
|
||||
}
|
||||
}
|
||||
|
||||
// 构建 db_storage 目录路径
|
||||
const dbStoragePath = this.resolveDbStoragePath(dbPath, wxid)
|
||||
this.writeLog(`testConnection dbPath=${dbPath} wxid=${wxid} dbStorage=${dbStoragePath || 'null'}`)
|
||||
// 直接使用账号目录
|
||||
const dbStoragePath = join(accountDir, 'db_storage')
|
||||
this.writeLog(`testConnection accountDir=${accountDir} dbStorage=${dbStoragePath}`)
|
||||
|
||||
if (!dbStoragePath || !existsSync(dbStoragePath)) {
|
||||
return { success: false, error: this.formatInitProtectionError(-3001) }
|
||||
@@ -1298,9 +1353,9 @@ export class WcdbCore {
|
||||
}
|
||||
|
||||
// 恢复测试前的连接(如果之前有活动连接)
|
||||
if (hadActiveConnection && prevPath && prevKey && prevWxid) {
|
||||
if (hadActiveConnection && prevPath && prevKey) {
|
||||
try {
|
||||
await this.open(prevPath, prevKey, prevWxid)
|
||||
await this.open(prevPath, prevKey)
|
||||
} catch {
|
||||
// 恢复失败则保持断开,由调用方处理
|
||||
}
|
||||
@@ -1505,7 +1560,7 @@ export class WcdbCore {
|
||||
/**
|
||||
* 打开数据库
|
||||
*/
|
||||
async open(dbPath: string, hexKey: string, wxid: string): Promise<boolean> {
|
||||
async open(accountDir: string, hexKey: string): Promise<boolean> {
|
||||
try {
|
||||
lastDllInitError = null
|
||||
if (!this.initialized) {
|
||||
@@ -1515,9 +1570,8 @@ export class WcdbCore {
|
||||
|
||||
// 检查是否已经是当前连接的参数,如果是则直接返回成功,实现"始终保持链接"
|
||||
if (this.handle !== null &&
|
||||
this.currentPath === dbPath &&
|
||||
this.currentKey === hexKey &&
|
||||
this.currentWxid === wxid) {
|
||||
this.currentPath === accountDir &&
|
||||
this.currentKey === hexKey) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1529,12 +1583,12 @@ export class WcdbCore {
|
||||
if (!initOk) return false
|
||||
}
|
||||
|
||||
const dbStoragePath = this.resolveDbStoragePath(dbPath, wxid)
|
||||
this.writeLog(`open dbPath=${dbPath} wxid=${wxid} dbStorage=${dbStoragePath || 'null'}`, true)
|
||||
const dbStoragePath = join(accountDir, 'db_storage')
|
||||
this.writeLog(`open accountDir=${accountDir} dbStorage=${dbStoragePath}`, true)
|
||||
|
||||
if (!dbStoragePath || !existsSync(dbStoragePath)) {
|
||||
console.error('数据库目录不存在:', dbPath)
|
||||
this.writeLog(`open failed: dbStorage not found for ${dbPath}`)
|
||||
console.error('数据库目录不存在:', accountDir)
|
||||
this.writeLog(`open failed: dbStorage not found for ${accountDir}`)
|
||||
lastDllInitError = this.formatInitProtectionError(-3001)
|
||||
return false
|
||||
}
|
||||
@@ -1565,8 +1619,12 @@ export class WcdbCore {
|
||||
return false
|
||||
}
|
||||
|
||||
// 从账号目录路径中提取 wxid(目录名)
|
||||
const rawWxid = basename(accountDir)
|
||||
const wxid = cleanAccountDirName(rawWxid)
|
||||
|
||||
this.handle = handle
|
||||
this.currentPath = dbPath
|
||||
this.currentPath = accountDir
|
||||
this.currentKey = hexKey
|
||||
this.currentWxid = wxid
|
||||
this.currentDbStoragePath = dbStoragePath
|
||||
@@ -1584,7 +1642,7 @@ export class WcdbCore {
|
||||
}
|
||||
this.writeLog(`open ok handle=${handle}`, true)
|
||||
await this.dumpDbStatus('open')
|
||||
await this.runPostOpenDiagnostics(dbPath, dbStoragePath, sessionDbPath, wxid)
|
||||
await this.runPostOpenDiagnostics(accountDir, dbStoragePath, sessionDbPath, wxid)
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('打开数据库异常:', e)
|
||||
@@ -1600,6 +1658,9 @@ export class WcdbCore {
|
||||
*/
|
||||
close(): void {
|
||||
if (this.handle !== null || this.initialized) {
|
||||
// 先停止监控与云控回调,避免 shutdown 后仍有 native 回调访问已释放资源。
|
||||
try { this.stopMonitor() } catch {}
|
||||
try { this.cloudStop() } catch {}
|
||||
try {
|
||||
// 不调用 closeAccount,直接 shutdown
|
||||
this.wcdbShutdown()
|
||||
@@ -1662,6 +1723,39 @@ export class WcdbCore {
|
||||
}
|
||||
}
|
||||
|
||||
async markAllSessionsRead(): Promise<{ success: boolean; error?: string }> {
|
||||
if (!this.ensureReady()) {
|
||||
return { success: false, error: 'WCDB 未连接' }
|
||||
}
|
||||
if (!this.wcdbMarkAllSessionsRead) {
|
||||
return { success: false, error: '当前数据服务版本不支持一键已读' }
|
||||
}
|
||||
try {
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
|
||||
const outPtr = [null as any]
|
||||
const result = this.wcdbMarkAllSessionsRead(this.handle, outPtr)
|
||||
let message = ''
|
||||
if (outPtr[0]) {
|
||||
try { message = this.koffi.decode(outPtr[0], 'char', -1) } catch { }
|
||||
try { this.wcdbFreeString(outPtr[0]) } catch { }
|
||||
}
|
||||
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
|
||||
if (result !== 0) {
|
||||
this.writeLog(`markAllSessionsRead failed: code=${result} error=${message}`)
|
||||
return { success: false, error: message || `一键已读失败: ${result}` }
|
||||
}
|
||||
this.clearMediaStreamSessionCache()
|
||||
this.writeLog('markAllSessionsRead ok')
|
||||
return { success: true }
|
||||
} catch (e) {
|
||||
this.writeLog(`markAllSessionsRead exception: ${String(e)}`)
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async getMessages(sessionId: string, limit: number, offset: number): Promise<{ success: boolean; messages?: any[]; error?: string }> {
|
||||
if (!this.ensureReady()) {
|
||||
return { success: false, error: 'WCDB 未连接' }
|
||||
@@ -1731,6 +1825,30 @@ export class WcdbCore {
|
||||
}
|
||||
}
|
||||
|
||||
async getMessageByServerId(sessionId: string, svrid: string): Promise<{ success: boolean; row?: any; error?: string }> {
|
||||
if (!this.ensureReady()) {
|
||||
return { success: false, error: 'WCDB 未连接' }
|
||||
}
|
||||
try {
|
||||
const outPtr = [null as any]
|
||||
const result = this.wcdbGetMessageByServerId(this.handle, sessionId, svrid, outPtr)
|
||||
if (result !== 0) {
|
||||
return { success: false, error: `查询消息失败: ${result}` }
|
||||
}
|
||||
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||
if (!jsonStr) {
|
||||
return { success: true, row: null }
|
||||
}
|
||||
const parsed = JSON.parse(jsonStr)
|
||||
if (!parsed || Object.keys(parsed).length === 0) {
|
||||
return { success: true, row: null }
|
||||
}
|
||||
return { success: true, row: parsed }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async getMessageCounts(sessionIds: string[]): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> {
|
||||
if (!this.ensureReady()) {
|
||||
return { success: false, error: 'WCDB 未连接' }
|
||||
@@ -2007,6 +2125,14 @@ export class WcdbCore {
|
||||
}
|
||||
return ''
|
||||
}
|
||||
const pickRaw = (row: Record<string, any>, keys: string[]): unknown => {
|
||||
for (const key of keys) {
|
||||
const value = row[key]
|
||||
if (value === null || value === undefined) continue
|
||||
return value
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
const extractXmlValue = (xml: string, tag: string): string => {
|
||||
if (!xml) return ''
|
||||
const regex = new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, 'i')
|
||||
@@ -2092,25 +2218,37 @@ export class WcdbCore {
|
||||
const md5Like = /([0-9a-fA-F]{16,64})/.exec(fileBase)
|
||||
return String(md5Like?.[1] || fileBase || '').trim().toLowerCase()
|
||||
}
|
||||
const decodePackedToPrintable = (raw: string): string => {
|
||||
const text = String(raw || '').trim()
|
||||
if (!text) return ''
|
||||
let buf: Buffer | null = null
|
||||
if (/^[a-fA-F0-9]+$/.test(text) && text.length % 2 === 0) {
|
||||
try {
|
||||
buf = Buffer.from(text, 'hex')
|
||||
} catch {
|
||||
buf = null
|
||||
const decodePackedInfoBuffer = (raw: unknown): Buffer | null => {
|
||||
if (!raw) return null
|
||||
if (Buffer.isBuffer(raw)) return raw
|
||||
if (raw instanceof Uint8Array) return Buffer.from(raw)
|
||||
if (Array.isArray(raw)) return Buffer.from(raw as any[])
|
||||
if (typeof raw === 'string') {
|
||||
const text = raw.trim()
|
||||
if (!text) return null
|
||||
const compactHex = text.replace(/\s+/g, '')
|
||||
if (/^[a-fA-F0-9]+$/.test(compactHex) && compactHex.length % 2 === 0) {
|
||||
try {
|
||||
return Buffer.from(compactHex, 'hex')
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!buf) {
|
||||
try {
|
||||
const base64 = Buffer.from(text, 'base64')
|
||||
if (base64.length > 0) buf = base64
|
||||
if (base64.length > 0) return base64
|
||||
} catch {
|
||||
buf = null
|
||||
// ignore
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (typeof raw === 'object' && raw !== null && Array.isArray((raw as any).data)) {
|
||||
return Buffer.from((raw as any).data)
|
||||
}
|
||||
return null
|
||||
}
|
||||
const decodePackedToPrintable = (raw: unknown): string => {
|
||||
const buf = decodePackedInfoBuffer(raw)
|
||||
if (!buf || buf.length === 0) return ''
|
||||
const printable: number[] = []
|
||||
for (const byte of buf) {
|
||||
@@ -2125,6 +2263,46 @@ export class WcdbCore {
|
||||
const match = /([a-fA-F0-9]{32})/.exec(input)
|
||||
return String(match?.[1] || '').toLowerCase()
|
||||
}
|
||||
const normalizeVideoFileToken = (value: unknown): 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()
|
||||
}
|
||||
const extractVideoFileNameFromPackedRaw = (raw: unknown): string => {
|
||||
const buf = decodePackedInfoBuffer(raw)
|
||||
if (!buf || buf.length === 0) return ''
|
||||
const candidates: string[] = []
|
||||
let current = ''
|
||||
for (const byte of buf) {
|
||||
const isHex =
|
||||
(byte >= 0x30 && byte <= 0x39) ||
|
||||
(byte >= 0x41 && byte <= 0x46) ||
|
||||
(byte >= 0x61 && byte <= 0x66)
|
||||
if (isHex) {
|
||||
current += String.fromCharCode(byte)
|
||||
continue
|
||||
}
|
||||
if (current.length >= 16) candidates.push(current)
|
||||
current = ''
|
||||
}
|
||||
if (current.length >= 16) candidates.push(current)
|
||||
if (candidates.length === 0) return ''
|
||||
const exact32 = candidates.find((item) => item.length === 32)
|
||||
if (exact32) return exact32.toLowerCase()
|
||||
const fallback = candidates.find((item) => item.length >= 16 && item.length <= 64)
|
||||
return String(fallback || '').toLowerCase()
|
||||
}
|
||||
const extractImageDatName = (row: Record<string, any>, content: string): string => {
|
||||
const direct = pickString(row, [
|
||||
'image_path',
|
||||
@@ -2143,7 +2321,7 @@ export class WcdbCore {
|
||||
const normalizedXml = normalizeDatBase(xmlCandidate)
|
||||
if (normalizedXml) return normalizedXml
|
||||
|
||||
const packedRaw = pickString(row, [
|
||||
const packedRaw = pickRaw(row, [
|
||||
'packed_info_data',
|
||||
'packedInfoData',
|
||||
'packed_info_blob',
|
||||
@@ -2168,7 +2346,7 @@ export class WcdbCore {
|
||||
return ''
|
||||
}
|
||||
const extractPackedPayload = (row: Record<string, any>): string => {
|
||||
const packedRaw = pickString(row, [
|
||||
const packedRaw = pickRaw(row, [
|
||||
'packed_info_data',
|
||||
'packedInfoData',
|
||||
'packed_info_blob',
|
||||
@@ -2323,6 +2501,20 @@ export class WcdbCore {
|
||||
const packedPayload = extractPackedPayload(row)
|
||||
const imageMd5ByColumn = pickString(row, ['image_md5', 'imageMd5'])
|
||||
const videoMd5ByColumn = pickString(row, ['video_md5', 'videoMd5', 'raw_md5', 'rawMd5'])
|
||||
const packedRaw = pickRaw(row, [
|
||||
'packed_info_data',
|
||||
'packedInfoData',
|
||||
'packed_info_blob',
|
||||
'packedInfoBlob',
|
||||
'packed_info',
|
||||
'packedInfo',
|
||||
'BytesExtra',
|
||||
'bytes_extra',
|
||||
'WCDB_CT_packed_info',
|
||||
'reserved0',
|
||||
'Reserved0',
|
||||
'WCDB_CT_Reserved0'
|
||||
])
|
||||
|
||||
let content = ''
|
||||
let imageMd5: string | undefined
|
||||
@@ -2338,10 +2530,17 @@ export class WcdbCore {
|
||||
if (!imageDatName) imageDatName = extractImageDatName(row, content) || undefined
|
||||
}
|
||||
} else if (localType === 43) {
|
||||
videoMd5 = videoMd5ByColumn || extractHexMd5(packedPayload) || undefined
|
||||
videoMd5 =
|
||||
extractVideoFileNameFromPackedRaw(packedRaw) ||
|
||||
normalizeVideoFileToken(videoMd5ByColumn) ||
|
||||
extractHexMd5(packedPayload) ||
|
||||
undefined
|
||||
if (!videoMd5) {
|
||||
content = decodeContentIfNeeded()
|
||||
videoMd5 = extractVideoMd5(content) || extractHexMd5(packedPayload) || undefined
|
||||
videoMd5 =
|
||||
normalizeVideoFileToken(extractVideoMd5(content)) ||
|
||||
extractHexMd5(packedPayload) ||
|
||||
undefined
|
||||
} else if (useRawMessageContent) {
|
||||
// 占位态标题只依赖简单 XML,已带 md5 时不做额外解压
|
||||
content = rawMessageContent
|
||||
@@ -2817,6 +3016,96 @@ export class WcdbCore {
|
||||
}
|
||||
}
|
||||
|
||||
async listTables(kind: string, dbPath: string = ''): Promise<{ success: boolean; tables?: string[]; error?: string }> {
|
||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||
if (!this.wcdbListTables) return { success: false, error: '接口未就绪' }
|
||||
try {
|
||||
const outPtr = [null as any]
|
||||
const result = this.wcdbListTables(this.handle, kind, dbPath || '', outPtr)
|
||||
if (result !== 0 || !outPtr[0]) return { success: false, error: `获取表列表失败: ${result}` }
|
||||
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||
if (!jsonStr) return { success: false, error: '解析表列表失败' }
|
||||
const tables = JSON.parse(jsonStr)
|
||||
return { success: true, tables: Array.isArray(tables) ? tables.map((c: any) => String(c || '')).filter(Boolean) : [] }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async getTableSchema(kind: string, dbPath: string, tableName: string): Promise<{ success: boolean; schema?: string; error?: string }> {
|
||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||
if (!this.wcdbGetTableSchema) return { success: false, error: '接口未就绪' }
|
||||
try {
|
||||
const outPtr = [null as any]
|
||||
const result = this.wcdbGetTableSchema(this.handle, kind, dbPath || '', tableName, outPtr)
|
||||
const jsonStr = outPtr[0] ? this.decodeJsonPtr(outPtr[0]) : ''
|
||||
const data = jsonStr ? JSON.parse(jsonStr) : {}
|
||||
if (result !== 0 || data?.success === false) return { success: false, error: data?.error || `获取表结构失败: ${result}` }
|
||||
return { success: true, schema: String(data?.schema || '') }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async exportTableSnapshot(kind: string, dbPath: string, tableName: string, outputPath: string): Promise<{ success: boolean; rows?: number; columns?: number; error?: string }> {
|
||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||
if (!this.wcdbExportTableSnapshot) return { success: false, error: '接口未就绪' }
|
||||
try {
|
||||
const outPtr = [null as any]
|
||||
const result = this.wcdbExportTableSnapshot(this.handle, kind, dbPath || '', tableName, outputPath, outPtr)
|
||||
const jsonStr = outPtr[0] ? this.decodeJsonPtr(outPtr[0]) : ''
|
||||
const data = jsonStr ? JSON.parse(jsonStr) : {}
|
||||
if (result !== 0 || data?.success === false) return { success: false, error: data?.error || `导出表快照失败: ${result}` }
|
||||
return { success: true, rows: Number(data?.rows || 0), columns: Number(data?.columns || 0) }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async importTableSnapshot(kind: string, dbPath: string, tableName: string, inputPath: string): Promise<{ success: boolean; rows?: number; inserted?: number; ignored?: number; malformed?: number; columns?: number; error?: string }> {
|
||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||
if (!this.wcdbImportTableSnapshot) return { success: false, error: '接口未就绪' }
|
||||
try {
|
||||
const outPtr = [null as any]
|
||||
const result = this.wcdbImportTableSnapshot(this.handle, kind, dbPath || '', tableName, inputPath, outPtr)
|
||||
const jsonStr = outPtr[0] ? this.decodeJsonPtr(outPtr[0]) : ''
|
||||
const data = jsonStr ? JSON.parse(jsonStr) : {}
|
||||
if (result !== 0 || data?.success === false) return { success: false, error: data?.error || `导入表快照失败: ${result}` }
|
||||
return {
|
||||
success: true,
|
||||
rows: Number(data?.rows || 0),
|
||||
inserted: Number(data?.inserted || 0),
|
||||
ignored: Number(data?.ignored || 0),
|
||||
malformed: Number(data?.malformed || 0),
|
||||
columns: Number(data?.columns || 0)
|
||||
}
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async importTableSnapshotWithSchema(kind: string, dbPath: string, tableName: string, inputPath: string, createTableSql: string): Promise<{ success: boolean; rows?: number; inserted?: number; ignored?: number; malformed?: number; columns?: number; error?: string }> {
|
||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||
if (!this.wcdbImportTableSnapshotWithSchema) return { success: false, error: '接口未就绪' }
|
||||
try {
|
||||
const outPtr = [null as any]
|
||||
const result = this.wcdbImportTableSnapshotWithSchema(this.handle, kind, dbPath || '', tableName, inputPath, createTableSql || '', outPtr)
|
||||
const jsonStr = outPtr[0] ? this.decodeJsonPtr(outPtr[0]) : ''
|
||||
const data = jsonStr ? JSON.parse(jsonStr) : {}
|
||||
if (result !== 0 || data?.success === false) return { success: false, error: data?.error || `导入表快照失败: ${result}` }
|
||||
return {
|
||||
success: true,
|
||||
rows: Number(data?.rows || 0),
|
||||
inserted: Number(data?.inserted || 0),
|
||||
ignored: Number(data?.ignored || 0),
|
||||
malformed: Number(data?.malformed || 0),
|
||||
columns: Number(data?.columns || 0)
|
||||
}
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async getMessageTableTimeRange(dbPath: string, tableName: string): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||
if (!this.wcdbGetMessageTableTimeRange) return { success: false, error: '接口未就绪' }
|
||||
|
||||
@@ -25,9 +25,7 @@ export class WcdbService {
|
||||
private logEnabled = false
|
||||
private monitorListener: ((type: string, json: string) => void) | null = null
|
||||
|
||||
constructor() {
|
||||
this.initWorker()
|
||||
}
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* 初始化 Worker 线程
|
||||
@@ -94,6 +92,9 @@ export class WcdbService {
|
||||
this.setPaths(this.resourcesPath, this.userDataPath)
|
||||
}
|
||||
this.setLogEnabled(this.logEnabled)
|
||||
if (this.monitorListener) {
|
||||
this.callWorker<{ success?: boolean }>('setMonitor').catch(() => { })
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
// Failed to create worker
|
||||
@@ -153,15 +154,17 @@ export class WcdbService {
|
||||
/**
|
||||
* 测试数据库连接
|
||||
*/
|
||||
async testConnection(dbPath: string, hexKey: string, wxid: string): Promise<{ success: boolean; error?: string; sessionCount?: number }> {
|
||||
return this.callWorker('testConnection', { dbPath, hexKey, wxid })
|
||||
async testConnection(accountDir: string, hexKey: string): Promise<{ success: boolean; error?: string; sessionCount?: number }> {
|
||||
return this.callWorker('testConnection', { accountDir, hexKey })
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开数据库
|
||||
* @param accountDir 账号目录的完整路径
|
||||
* @param hexKey 解密密钥
|
||||
*/
|
||||
async open(dbPath: string, hexKey: string, wxid: string): Promise<boolean> {
|
||||
return this.callWorker('open', { dbPath, hexKey, wxid })
|
||||
async open(accountDir: string, hexKey: string): Promise<boolean> {
|
||||
return this.callWorker('open', { accountDir, hexKey })
|
||||
}
|
||||
|
||||
async getLastInitError(): Promise<string | null> {
|
||||
@@ -201,6 +204,10 @@ export class WcdbService {
|
||||
return this.callWorker('getSessions')
|
||||
}
|
||||
|
||||
async markAllSessionsRead(): Promise<{ success: boolean; error?: string }> {
|
||||
return this.callWorker('markAllSessionsRead')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息列表
|
||||
*/
|
||||
@@ -222,6 +229,13 @@ export class WcdbService {
|
||||
return this.callWorker('getMessageCount', { sessionId })
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 server_id 查询单条消息
|
||||
*/
|
||||
async getMessageByServerId(sessionId: string, svrid: string): Promise<{ success: boolean; row?: any; error?: string }> {
|
||||
return this.callWorker('getMessageByServerId', { sessionId, svrid })
|
||||
}
|
||||
|
||||
async getMessageCounts(sessionIds: string[]): Promise<{ success: boolean; counts?: Record<string, number>; error?: string }> {
|
||||
return this.callWorker('getMessageCounts', { sessionIds })
|
||||
}
|
||||
@@ -368,6 +382,26 @@ export class WcdbService {
|
||||
return this.callWorker('getMessageTableColumns', { dbPath, tableName })
|
||||
}
|
||||
|
||||
async listTables(kind: string, dbPath: string = ''): Promise<{ success: boolean; tables?: string[]; error?: string }> {
|
||||
return this.callWorker('listTables', { kind, dbPath })
|
||||
}
|
||||
|
||||
async getTableSchema(kind: string, dbPath: string, tableName: string): Promise<{ success: boolean; schema?: string; error?: string }> {
|
||||
return this.callWorker('getTableSchema', { kind, dbPath, tableName })
|
||||
}
|
||||
|
||||
async exportTableSnapshot(kind: string, dbPath: string, tableName: string, outputPath: string): Promise<{ success: boolean; rows?: number; columns?: number; error?: string }> {
|
||||
return this.callWorker('exportTableSnapshot', { kind, dbPath, tableName, outputPath })
|
||||
}
|
||||
|
||||
async importTableSnapshot(kind: string, dbPath: string, tableName: string, inputPath: string): Promise<{ success: boolean; rows?: number; inserted?: number; ignored?: number; malformed?: number; columns?: number; error?: string }> {
|
||||
return this.callWorker('importTableSnapshot', { kind, dbPath, tableName, inputPath })
|
||||
}
|
||||
|
||||
async importTableSnapshotWithSchema(kind: string, dbPath: string, tableName: string, inputPath: string, createTableSql: string): Promise<{ success: boolean; rows?: number; inserted?: number; ignored?: number; malformed?: number; columns?: number; error?: string }> {
|
||||
return this.callWorker('importTableSnapshotWithSchema', { kind, dbPath, tableName, inputPath, createTableSql })
|
||||
}
|
||||
|
||||
async getMessageTableTimeRange(dbPath: string, tableName: string): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||
return this.callWorker('getMessageTableTimeRange', { dbPath, tableName })
|
||||
}
|
||||
|
||||
20
electron/utils/pathUtils.ts
Normal file
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
|
||||
}
|
||||
|
||||
@@ -32,10 +32,10 @@ if (parentPort) {
|
||||
break
|
||||
}
|
||||
case 'testConnection':
|
||||
result = await core.testConnection(payload.dbPath, payload.hexKey, payload.wxid)
|
||||
result = await core.testConnection(payload.accountDir, payload.hexKey)
|
||||
break
|
||||
case 'open':
|
||||
result = await core.open(payload.dbPath, payload.hexKey, payload.wxid)
|
||||
result = await core.open(payload.accountDir, payload.hexKey)
|
||||
break
|
||||
case 'getLastInitError':
|
||||
result = core.getLastInitError()
|
||||
@@ -50,6 +50,9 @@ if (parentPort) {
|
||||
case 'getSessions':
|
||||
result = await core.getSessions()
|
||||
break
|
||||
case 'markAllSessionsRead':
|
||||
result = await core.markAllSessionsRead()
|
||||
break
|
||||
case 'getMessages':
|
||||
result = await core.getMessages(payload.sessionId, payload.limit, payload.offset)
|
||||
break
|
||||
@@ -59,6 +62,9 @@ if (parentPort) {
|
||||
case 'getMessageCount':
|
||||
result = await core.getMessageCount(payload.sessionId)
|
||||
break
|
||||
case 'getMessageByServerId':
|
||||
result = await core.getMessageByServerId(payload.sessionId, payload.svrid)
|
||||
break
|
||||
case 'getMessageCounts':
|
||||
result = await core.getMessageCounts(payload.sessionIds)
|
||||
break
|
||||
@@ -116,6 +122,21 @@ if (parentPort) {
|
||||
case 'getMessageTableColumns':
|
||||
result = await core.getMessageTableColumns(payload.dbPath, payload.tableName)
|
||||
break
|
||||
case 'listTables':
|
||||
result = await core.listTables(payload.kind, payload.dbPath)
|
||||
break
|
||||
case 'getTableSchema':
|
||||
result = await core.getTableSchema(payload.kind, payload.dbPath, payload.tableName)
|
||||
break
|
||||
case 'exportTableSnapshot':
|
||||
result = await core.exportTableSnapshot(payload.kind, payload.dbPath, payload.tableName, payload.outputPath)
|
||||
break
|
||||
case 'importTableSnapshot':
|
||||
result = await core.importTableSnapshot(payload.kind, payload.dbPath, payload.tableName, payload.inputPath)
|
||||
break
|
||||
case 'importTableSnapshotWithSchema':
|
||||
result = await core.importTableSnapshotWithSchema(payload.kind, payload.dbPath, payload.tableName, payload.inputPath, payload.createTableSql)
|
||||
break
|
||||
case 'getMessageTableTimeRange':
|
||||
result = await core.getMessageTableTimeRange(payload.dbPath, payload.tableName)
|
||||
break
|
||||
|
||||
@@ -9,10 +9,10 @@ let linuxNotificationService:
|
||||
| null = null;
|
||||
|
||||
// 用于处理通知点击的回调函数(在Linux上用于导航到会话)
|
||||
let onNotificationNavigate: ((sessionId: string) => void) | null = null;
|
||||
let onNotificationNavigate: ((payload: unknown) => void) | null = null;
|
||||
|
||||
export function setNotificationNavigateHandler(
|
||||
callback: (sessionId: string) => void,
|
||||
callback: (payload: unknown) => void,
|
||||
) {
|
||||
onNotificationNavigate = callback;
|
||||
}
|
||||
@@ -109,25 +109,33 @@ export function createNotificationWindow() {
|
||||
export async function showNotification(data: any) {
|
||||
// 先检查配置
|
||||
const config = ConfigService.getInstance();
|
||||
const enabled = await config.get("notificationEnabled");
|
||||
if (enabled === false) return; // 默认为 true
|
||||
|
||||
// 检查会话过滤
|
||||
const filterMode = config.get("notificationFilterMode") || "all";
|
||||
const filterList = config.get("notificationFilterList") || [];
|
||||
const sessionId = typeof data.sessionId === "string" ? data.sessionId : "";
|
||||
// 系统通知(如 "WeFlow 准备就绪")不是聊天消息,不应受会话白/黑名单影响
|
||||
const isSystemNotification = sessionId.startsWith("weflow-");
|
||||
const channel = typeof data.channel === "string" ? data.channel : "";
|
||||
const isAiInsightNotification = channel === "ai-insight";
|
||||
|
||||
if (!isSystemNotification && filterMode !== "all") {
|
||||
const isInList = sessionId !== "" && filterList.includes(sessionId);
|
||||
if (filterMode === "whitelist" && !isInList) {
|
||||
// 白名单模式:不在列表中则不显示(空列表视为全部拦截)
|
||||
return;
|
||||
}
|
||||
if (filterMode === "blacklist" && isInList) {
|
||||
// 黑名单模式:在列表中则不显示
|
||||
return;
|
||||
if (isAiInsightNotification) {
|
||||
const enabled = await config.get("aiInsightNotificationEnabled");
|
||||
if (enabled === false) return; // 默认为 true
|
||||
} else {
|
||||
const enabled = await config.get("notificationEnabled");
|
||||
if (enabled === false) return; // 默认为 true
|
||||
|
||||
// 检查会话过滤
|
||||
const filterMode = config.get("notificationFilterMode") || "all";
|
||||
const filterList = config.get("notificationFilterList") || [];
|
||||
// 系统通知(如 "WeFlow 准备就绪")不是聊天消息,不应受会话白/黑名单影响
|
||||
const isSystemNotification = sessionId.startsWith("weflow-");
|
||||
|
||||
if (!isSystemNotification && filterMode !== "all") {
|
||||
const isInList = sessionId !== "" && filterList.includes(sessionId);
|
||||
if (filterMode === "whitelist" && !isInList) {
|
||||
// 白名单模式:不在列表中则不显示(空列表视为全部拦截)
|
||||
return;
|
||||
}
|
||||
if (filterMode === "blacklist" && isInList) {
|
||||
// 黑名单模式:在列表中则不显示
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,6 +184,9 @@ async function showLinuxNotification(data: any) {
|
||||
content: data.content,
|
||||
avatarUrl: data.avatarUrl,
|
||||
sessionId: data.sessionId,
|
||||
channel: data.channel,
|
||||
insightRecordId: data.insightRecordId,
|
||||
targetRoute: data.targetRoute,
|
||||
expireTimeout: 5000,
|
||||
};
|
||||
|
||||
@@ -249,14 +260,14 @@ export async function registerNotificationHandlers() {
|
||||
await linuxNotificationModule.initLinuxNotificationService();
|
||||
|
||||
// 在Linux上注册通知点击回调
|
||||
linuxNotificationModule.onNotificationAction((sessionId: string) => {
|
||||
linuxNotificationModule.onNotificationAction((payload: unknown) => {
|
||||
console.log(
|
||||
"[NotificationWindow] Linux notification clicked, sessionId:",
|
||||
sessionId,
|
||||
payload,
|
||||
);
|
||||
// 如果设置了导航处理程序,则使用该处理程序;否则,回退到ipcMain方法。
|
||||
if (onNotificationNavigate) {
|
||||
onNotificationNavigate(sessionId);
|
||||
onNotificationNavigate(payload);
|
||||
} else {
|
||||
// 如果尚未设置处理程序,则通过ipcMain发出事件
|
||||
// 正常流程中不应该发生这种情况,因为我们在初始化之前设置了处理程序。
|
||||
|
||||
1662
package-lock.json
generated
1662
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
30
package.json
30
package.json
@@ -13,13 +13,13 @@
|
||||
},
|
||||
"//": "二改不应改变此处的作者与应用信息",
|
||||
"scripts": {
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"postinstall": "electron-builder install-app-deps && node scripts/prepare-electron-runtime.cjs",
|
||||
"rebuild": "electron-rebuild",
|
||||
"dev": "vite",
|
||||
"dev": "node scripts/prepare-electron-runtime.cjs && vite",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsc && vite build && electron-builder",
|
||||
"preview": "vite preview",
|
||||
"electron:dev": "vite --mode electron",
|
||||
"electron:dev": "node scripts/prepare-electron-runtime.cjs && vite --mode electron",
|
||||
"electron:build": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -35,14 +35,14 @@
|
||||
"jieba-wasm": "^2.2.0",
|
||||
"jszip": "^3.10.1",
|
||||
"koffi": "^2.9.0",
|
||||
"lucide-react": "^1.7.0",
|
||||
"lucide-react": "^1.8.0",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.14.0",
|
||||
"react-virtuoso": "^4.18.1",
|
||||
"react-virtuoso": "^4.18.5",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sherpa-onnx-node": "^1.12.35",
|
||||
"sherpa-onnx-node": "^1.10.38",
|
||||
"silk-wasm": "^3.7.1",
|
||||
"wechat-emojis": "^1.0.2",
|
||||
"zustand": "^5.0.2"
|
||||
@@ -51,14 +51,15 @@
|
||||
"@electron/rebuild": "^4.0.2",
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"electron": "^41.1.1",
|
||||
"electron-builder": "^26.8.1",
|
||||
"sass": "^1.99.0",
|
||||
"esbuild": "^0.28.0",
|
||||
"sass": "^1.98.0",
|
||||
"sharp": "^0.34.5",
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^7.3.2",
|
||||
"vite-plugin-electron": "^0.29.1",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.10",
|
||||
"vite-plugin-electron": "^0.28.8",
|
||||
"vite-plugin-electron-renderer": "^0.14.6"
|
||||
},
|
||||
"pnpm": {
|
||||
@@ -70,9 +71,7 @@
|
||||
"lodash": ">=4.17.21",
|
||||
"brace-expansion": ">=1.1.11",
|
||||
"picomatch": ">=2.3.1",
|
||||
"ajv": ">=8.18.0",
|
||||
"ajv-keywords@3>ajv": "^6.12.6",
|
||||
"@develar/schema-utils>ajv": "^6.12.6"
|
||||
"ajv": ">=8.18.0"
|
||||
}
|
||||
},
|
||||
"build": {
|
||||
@@ -188,7 +187,8 @@
|
||||
"node_modules/sherpa-onnx-node/**/*",
|
||||
"node_modules/sherpa-onnx-*/*",
|
||||
"node_modules/sherpa-onnx-*/**/*",
|
||||
"node_modules/ffmpeg-static/**/*"
|
||||
"node_modules/ffmpeg-static/**/*",
|
||||
"resources/wedecrypt/**/*.node"
|
||||
],
|
||||
"icon": "resources/icons/macos/icon.icns"
|
||||
},
|
||||
|
||||
BIN
public/assets/insight/AI_Insight.png
Normal file
BIN
public/assets/insight/AI_Insight.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.4 MiB |
@@ -4,246 +4,478 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>WeFlow</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
<script>
|
||||
(function initSplashMode() {
|
||||
var params = new URLSearchParams(window.location.search || "");
|
||||
var mode = params.get("themeMode") || params.get("mode") || "system";
|
||||
var themeId = params.get("themeId") || "cloud-dancer";
|
||||
var mq = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)");
|
||||
var resolved = mode === "dark" || (mode === "system" && mq && mq.matches) ? "dark" : "light";
|
||||
|
||||
html, body {
|
||||
width: 100%; height: 100%;
|
||||
background: transparent;
|
||||
document.documentElement.setAttribute("data-theme", themeId);
|
||||
document.documentElement.setAttribute("data-theme-mode", mode);
|
||||
document.documentElement.setAttribute("data-mode", resolved);
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
:root {
|
||||
--surface-start: #ffffff;
|
||||
--surface-end: #f8f9fc;
|
||||
--accent: #5b6abf;
|
||||
--accent-rgb: 91, 106, 191;
|
||||
--ambient-glow: rgba(91, 106, 191, 0.08);
|
||||
--text: #1a1b1e;
|
||||
--text-muted: #5f6368;
|
||||
--text-faint: #9aa0a6;
|
||||
--border-subtle: rgba(0, 0, 0, 0.05);
|
||||
--loader-track: rgba(0, 0, 0, 0.06);
|
||||
--shadow-window:
|
||||
0 24px 60px rgba(23, 27, 38, 0.10),
|
||||
0 4px 12px rgba(23, 27, 38, 0.04),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 1);
|
||||
--radius-window: 24px;
|
||||
--ease-ambient: cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
--font: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
|
||||
[data-mode="dark"] {
|
||||
--surface-start: #14171d;
|
||||
--surface-end: #0b0d10;
|
||||
--accent: #7c8deb;
|
||||
--accent-rgb: 124, 141, 235;
|
||||
--ambient-glow: rgba(124, 141, 235, 0.08);
|
||||
--text: #f0f0f0;
|
||||
--text-muted: #8b92a5;
|
||||
--text-faint: #4e5569;
|
||||
--border-subtle: rgba(255, 255, 255, 0.06);
|
||||
--loader-track: rgba(255, 255, 255, 0.09);
|
||||
--shadow-window:
|
||||
0 24px 80px rgba(0, 0, 0, 0.60),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
--radius-window: 20px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', sans-serif;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
body {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.splash {
|
||||
width: 100%; height: 100%;
|
||||
border-radius: 20px;
|
||||
.splash-shell {
|
||||
width: 600px;
|
||||
height: 380px;
|
||||
max-width: calc(100vw - 64px);
|
||||
max-height: calc(100vh - 64px);
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-window);
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: linear-gradient(145deg, var(--surface-start), var(--surface-end));
|
||||
box-shadow: var(--shadow-window);
|
||||
isolation: isolate;
|
||||
animation: windowAppear 800ms var(--ease-ambient) both;
|
||||
}
|
||||
|
||||
/* 品牌区 */
|
||||
.brand {
|
||||
padding: 48px 52px 0;
|
||||
.splash-shell::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
left: -50%;
|
||||
top: -50%;
|
||||
background: radial-gradient(circle at 50% 40%, var(--ambient-glow) 0%, transparent 44%);
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.brand-stage {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-top: -20px;
|
||||
text-align: center;
|
||||
animation: contentIn 560ms var(--ease-ambient) 90ms both;
|
||||
}
|
||||
|
||||
.logo-core {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin-bottom: 24px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.logo-image {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
border-radius: 20px;
|
||||
animation: logoBreathe 3200ms ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.app-name {
|
||||
font-size: 24px;
|
||||
line-height: 1.18;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--text);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
[data-mode="dark"] .app-name {
|
||||
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.50);
|
||||
}
|
||||
|
||||
.app-desc {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
[data-mode="dark"] .app-desc {
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.status-row {
|
||||
position: absolute;
|
||||
left: 32px;
|
||||
right: 32px;
|
||||
bottom: 24px;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
color: var(--text-faint);
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
font-variant-numeric: tabular-nums;
|
||||
animation: contentIn 560ms var(--ease-ambient) 170ms both;
|
||||
}
|
||||
|
||||
.progress-text-wrap {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
animation: fadeIn 0.4s ease both;
|
||||
gap: 6px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
.logo {
|
||||
width: 56px; height: 56px;
|
||||
border-radius: 14px;
|
||||
|
||||
[data-mode="dark"] .progress-text-wrap {
|
||||
color: var(--text-faint);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 6px rgba(var(--accent-rgb), 0.42);
|
||||
animation: dotPulse 1700ms ease-in-out infinite;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.version {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.app-name {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.app-desc {
|
||||
font-size: 12px;
|
||||
margin-top: 5px;
|
||||
opacity: 0.6;
|
||||
color: var(--text-faint);
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0;
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.spacer { flex: 1; }
|
||||
|
||||
/* 底部进度区 */
|
||||
.bottom {
|
||||
padding: 0 48px 40px;
|
||||
animation: fadeIn 0.4s ease 0.1s both;
|
||||
[data-mode="dark"] .version {
|
||||
opacity: 0.50;
|
||||
}
|
||||
|
||||
/* 进度条轨道 */
|
||||
.progress-track {
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
margin-bottom: 12px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 进度条填充 */
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
border-radius: 2px;
|
||||
position: relative;
|
||||
transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 扫光:只在有进度时显示,不循环 */
|
||||
.progress-fill::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0;
|
||||
width: 100%; height: 100%;
|
||||
background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.5) 50%, transparent 100%);
|
||||
animation: sweep 1.2s ease-out forwards;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 3;
|
||||
height: 3px;
|
||||
background: var(--loader-track);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-mode="dark"] .progress-track {
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 0%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
border-radius: 0 999px 999px 0;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 18px rgba(var(--accent-rgb), 0.34);
|
||||
overflow: hidden;
|
||||
transition: width 440ms var(--ease-ambient);
|
||||
}
|
||||
|
||||
.progress-fill::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -7px;
|
||||
right: -18px;
|
||||
width: 44px;
|
||||
height: 15px;
|
||||
border-radius: 999px;
|
||||
background: rgba(var(--accent-rgb), 0.34);
|
||||
filter: blur(8px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 等待阶段:进度条末端呼吸光点 */
|
||||
.progress-fill.waiting::before {
|
||||
content: '';
|
||||
.progress-fill::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -1px; right: -2px;
|
||||
width: 6px; height: 4px;
|
||||
border-radius: 50%;
|
||||
background: inherit;
|
||||
filter: blur(2px);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
inset: -1px 0;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.54), transparent);
|
||||
opacity: 0;
|
||||
transform: translateX(-100%);
|
||||
animation: spectralGlide 1200ms ease-out;
|
||||
}
|
||||
|
||||
.bottom-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.progress-text {
|
||||
font-size: 11px;
|
||||
opacity: 0.38;
|
||||
}
|
||||
.version {
|
||||
font-size: 11px;
|
||||
opacity: 0.25;
|
||||
.progress-fill.waiting::before {
|
||||
opacity: 0.65;
|
||||
animation: leadingGlow 1300ms ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.splash-shell,
|
||||
.brand-stage,
|
||||
.status-row,
|
||||
.logo-image,
|
||||
.status-dot,
|
||||
.progress-fill,
|
||||
.progress-fill::before,
|
||||
.progress-fill::after {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
left: 0 !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
}
|
||||
@keyframes sweep {
|
||||
0% { opacity: 0; transform: translateX(-100%); }
|
||||
20% { opacity: 1; }
|
||||
80% { opacity: 1; }
|
||||
100% { opacity: 0; transform: translateX(100%); }
|
||||
|
||||
@keyframes windowAppear {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.97) translateY(12px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 0.4; transform: scaleX(1); }
|
||||
50% { opacity: 1; transform: scaleX(1.8); }
|
||||
|
||||
@keyframes contentIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes logoBreathe {
|
||||
0% {
|
||||
opacity: 0.94;
|
||||
transform: translateY(0);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dotPulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.38;
|
||||
transform: scale(0.84);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.18);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes leadingGlow {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.38;
|
||||
transform: scaleX(0.78);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.86;
|
||||
transform: scaleX(1.28);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spectralGlide {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
22%,
|
||||
66% {
|
||||
opacity: 0.58;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="splash" id="splash">
|
||||
<div class="brand">
|
||||
<img class="logo" src="./logo.png" alt="WeFlow" />
|
||||
<div class="brand-text">
|
||||
<div class="app-name" id="appName">WeFlow</div>
|
||||
<div class="app-desc" id="appDesc">微信聊天记录管理工具</div>
|
||||
<main class="splash-shell" id="splash" role="status" aria-live="polite">
|
||||
<section class="brand-stage" aria-label="WeFlow">
|
||||
<div class="logo-core" aria-hidden="true">
|
||||
<img class="logo-image" src="./logo.png" alt="">
|
||||
</div>
|
||||
|
||||
<h1 class="app-name">WeFlow</h1>
|
||||
<p class="app-desc">微信聊天记录管理工具</p>
|
||||
</section>
|
||||
|
||||
<div class="status-row">
|
||||
<div class="progress-text-wrap">
|
||||
<div class="status-dot" aria-hidden="true"></div>
|
||||
<div class="progress-text" id="progressText">正在预加载会话逻辑...</div>
|
||||
</div>
|
||||
<div class="version" id="versionText"></div>
|
||||
</div>
|
||||
|
||||
<div class="spacer"></div>
|
||||
|
||||
<div class="bottom">
|
||||
<div class="progress-track" id="progressTrack">
|
||||
<div class="progress-fill" id="progressFill"></div>
|
||||
</div>
|
||||
<div class="bottom-row">
|
||||
<div class="progress-text" id="progressText">正在启动...</div>
|
||||
<div class="version" id="versionText"></div>
|
||||
</div>
|
||||
<div class="progress-track" aria-hidden="true">
|
||||
<div class="progress-fill" id="progressFill"></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
var themes = {
|
||||
'cloud-dancer': {
|
||||
light: { primary: '#8B7355', bg: '#F0EEE9', bgEnd: '#E5E1DA', text: '#3d3d3d', desc: '#8B7355' },
|
||||
dark: { primary: '#C9A86C', bg: '#1a1816', bgEnd: '#252220', text: '#F0EEE9', desc: '#C9A86C' }
|
||||
},
|
||||
'corundum-blue': {
|
||||
light: { primary: '#4A6670', bg: '#E8EEF0', bgEnd: '#D8E4E8', text: '#3d3d3d', desc: '#4A6670' },
|
||||
dark: { primary: '#6A9AAA', bg: '#141a1c', bgEnd: '#1e2a2e', text: '#E0EEF2', desc: '#6A9AAA' }
|
||||
},
|
||||
'kiwi-green': {
|
||||
light: { primary: '#7A9A5C', bg: '#E8F0E4', bgEnd: '#D8E8D2', text: '#3d3d3d', desc: '#7A9A5C' },
|
||||
dark: { primary: '#9ABA7C', bg: '#161a14', bgEnd: '#222a1e', text: '#E8F0E4', desc: '#9ABA7C' }
|
||||
},
|
||||
'spicy-red': {
|
||||
light: { primary: '#8B4049', bg: '#F0E8E8', bgEnd: '#E8D8D8', text: '#3d3d3d', desc: '#8B4049' },
|
||||
dark: { primary: '#C06068', bg: '#1a1416', bgEnd: '#261e20', text: '#F2E8EA', desc: '#C06068' }
|
||||
},
|
||||
'teal-water': {
|
||||
light: { primary: '#5A8A8A', bg: '#E4F0F0', bgEnd: '#D2E8E8', text: '#3d3d3d', desc: '#5A8A8A' },
|
||||
dark: { primary: '#7ABAAA', bg: '#121a1a', bgEnd: '#1a2626', text: '#E0F2EE', desc: '#7ABAAA' }
|
||||
},
|
||||
'blossom-dream': {
|
||||
light: { primary: '#D4849A', primaryEnd: '#D4849A', bg: '#FCF9FB', bgMid: '#F8F2F8', bgEnd: '#F2F6FB', text: '#2E2633', desc: '#D4849A' },
|
||||
dark: { primary: '#C670C3', primaryEnd: '#8A60C0', bg: '#120B16', bgMid: '#1A1020', bgEnd: '#0E0B18', text: '#F2EAF4', desc: '#C670C3' }
|
||||
}
|
||||
};
|
||||
var themeModeQuery = null;
|
||||
var systemModeQuery = null;
|
||||
|
||||
function applyTheme(themeId, mode) {
|
||||
var t = themes[themeId] || themes['cloud-dancer'];
|
||||
var isDark = mode === 'dark';
|
||||
if (mode === 'system') isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
var c = isDark ? t.dark : t.light;
|
||||
|
||||
var el = document.getElementById('splash');
|
||||
var fill = document.getElementById('progressFill');
|
||||
|
||||
if (themeId === 'blossom-dream') {
|
||||
if (isDark) {
|
||||
// 深色
|
||||
el.style.background =
|
||||
'radial-gradient(ellipse 60% 50% at 100% 0%, ' + c.primary + '28 0%, transparent 70%), ' +
|
||||
'linear-gradient(150deg, ' + c.bg + ' 0%, ' + c.bgMid + ' 45%, ' + c.bgEnd + ' 100%)';
|
||||
} else {
|
||||
// 浅色
|
||||
el.style.background = 'linear-gradient(150deg, ' + c.bg + ' 0%, ' + c.bgMid + ' 45%, ' + c.bgEnd + ' 100%)';
|
||||
}
|
||||
// 进度条
|
||||
fill.style.background = 'linear-gradient(90deg, ' + c.primary + ' 0%, ' + c.primaryEnd + ' 100%)';
|
||||
} else {
|
||||
if (isDark) {
|
||||
el.style.background =
|
||||
'radial-gradient(ellipse 60% 50% at 100% 0%, ' + c.primary + '22 0%, transparent 70%), ' +
|
||||
'linear-gradient(145deg, ' + c.bg + ' 0%, ' + c.bgEnd + ' 100%)';
|
||||
} else {
|
||||
el.style.background = 'linear-gradient(150deg, ' + c.bg + ' 0%, ' + c.bgEnd + ' 100%)';
|
||||
}
|
||||
fill.style.background = c.primary;
|
||||
}
|
||||
|
||||
document.getElementById('appName').style.color = c.text;
|
||||
document.getElementById('appDesc').style.color = c.desc;
|
||||
document.getElementById('progressText').style.color = c.text;
|
||||
document.getElementById('versionText').style.color = c.text;
|
||||
document.getElementById('progressTrack').style.background = c.primary + (isDark ? '25' : '18');
|
||||
function resolveMode(mode) {
|
||||
if (mode === "dark" || mode === "light") return mode;
|
||||
return window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
|
||||
function syncSystemModeListener(mode) {
|
||||
if (!window.matchMedia) return;
|
||||
var nextQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
|
||||
if (systemModeQuery && systemModeQuery !== nextQuery && systemModeQuery.removeEventListener) {
|
||||
systemModeQuery.removeEventListener("change", handleSystemModeChange);
|
||||
}
|
||||
|
||||
systemModeQuery = nextQuery;
|
||||
themeModeQuery = mode;
|
||||
|
||||
if (mode === "system" && nextQuery.addEventListener) {
|
||||
nextQuery.addEventListener("change", handleSystemModeChange);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSystemModeChange() {
|
||||
if (themeModeQuery === "system") {
|
||||
document.documentElement.setAttribute("data-mode", resolveMode("system"));
|
||||
}
|
||||
}
|
||||
|
||||
function applyTheme(themeId, mode) {
|
||||
var safeThemeId = String(themeId || "cloud-dancer");
|
||||
var safeMode = mode === "light" || mode === "dark" || mode === "system" ? mode : "system";
|
||||
var resolvedMode = resolveMode(safeMode);
|
||||
|
||||
document.documentElement.setAttribute("data-theme", safeThemeId);
|
||||
document.documentElement.setAttribute("data-theme-mode", safeMode);
|
||||
document.documentElement.setAttribute("data-mode", resolvedMode);
|
||||
syncSystemModeListener(safeMode);
|
||||
}
|
||||
|
||||
// percent: 实际进度值;waiting: 是否处于等待阶段
|
||||
function updateProgress(percent, text, waiting) {
|
||||
var fill = document.getElementById('progressFill');
|
||||
var label = document.getElementById('progressText');
|
||||
var fill = document.getElementById("progressFill");
|
||||
var label = document.getElementById("progressText");
|
||||
var safePercent = Math.max(0, Math.min(100, Number(percent) || 0));
|
||||
|
||||
if (fill) {
|
||||
fill.style.width = percent + '%';
|
||||
fill.style.width = safePercent + "%";
|
||||
if (waiting) {
|
||||
fill.classList.add('waiting');
|
||||
fill.classList.add("waiting");
|
||||
} else {
|
||||
fill.classList.remove('waiting');
|
||||
// 触发扫光:重置动画
|
||||
fill.style.animation = 'none';
|
||||
fill.classList.remove("waiting");
|
||||
fill.style.animation = "none";
|
||||
fill.offsetHeight;
|
||||
fill.style.animation = '';
|
||||
fill.style.animation = "";
|
||||
}
|
||||
}
|
||||
|
||||
if (label && text) label.textContent = text;
|
||||
}
|
||||
|
||||
function setVersion(ver) {
|
||||
var el = document.getElementById('versionText');
|
||||
if (el) el.textContent = 'v' + ver;
|
||||
function setVersion(version) {
|
||||
var el = document.getElementById("versionText");
|
||||
if (!el) return;
|
||||
var text = String(version || "").trim();
|
||||
el.textContent = text ? "v" + text.replace(/^v/i, "") : "";
|
||||
}
|
||||
|
||||
applyTheme('cloud-dancer', 'light');
|
||||
(function bootstrapSplash() {
|
||||
var params = new URLSearchParams(window.location.search || "");
|
||||
applyTheme(params.get("themeId") || "cloud-dancer", params.get("themeMode") || "system");
|
||||
updateProgress(0, "", false);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
BIN
resources/fonts/annual-report/CormorantGaramond-Var.ttf
Normal file
BIN
resources/fonts/annual-report/CormorantGaramond-Var.ttf
Normal file
Binary file not shown.
BIN
resources/fonts/annual-report/Inter-Var.ttf
Normal file
BIN
resources/fonts/annual-report/Inter-Var.ttf
Normal file
Binary file not shown.
BIN
resources/fonts/annual-report/NotoSerifSC-Var.ttf
Normal file
BIN
resources/fonts/annual-report/NotoSerifSC-Var.ttf
Normal file
Binary file not shown.
BIN
resources/fonts/annual-report/PlayfairDisplay-Var.ttf
Normal file
BIN
resources/fonts/annual-report/PlayfairDisplay-Var.ttf
Normal file
Binary file not shown.
BIN
resources/fonts/annual-report/SpaceMono-Bold.ttf
Normal file
BIN
resources/fonts/annual-report/SpaceMono-Bold.ttf
Normal file
Binary file not shown.
BIN
resources/fonts/annual-report/SpaceMono-Regular.ttf
Normal file
BIN
resources/fonts/annual-report/SpaceMono-Regular.ttf
Normal file
Binary file not shown.
1
resources/image/README.md
Normal file
1
resources/image/README.md
Normal file
@@ -0,0 +1 @@
|
||||
> 目前只适配了x64 win32平台,其它平台同样原理,但是代码还没写(
|
||||
BIN
resources/image/win32/x64/img_helper.dll
Normal file
BIN
resources/image/win32/x64/img_helper.dll
Normal file
Binary file not shown.
6
resources/installer/linux/.gitignore
vendored
Normal file
6
resources/installer/linux/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
*.tar.gz
|
||||
*.tar.xz
|
||||
*.zip
|
||||
src/
|
||||
pkg/
|
||||
weflow-*/
|
||||
30
resources/installer/linux/PKGBUILD
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
BIN
resources/installer/linux/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
9
resources/installer/linux/weflow.desktop
Normal file
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;
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
resources/wedecrypt/linux/x64/weflow-image-native-linux-x64.node
Normal file
BIN
resources/wedecrypt/linux/x64/weflow-image-native-linux-x64.node
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
resources/wedecrypt/win32/x64/weflow-image-native-win32-x64.node
Normal file
BIN
resources/wedecrypt/win32/x64/weflow-image-native-win32-x64.node
Normal file
Binary file not shown.
57
scripts/prepare-electron-runtime.cjs
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();
|
||||
98
src/App.scss
98
src/App.scss
@@ -3,56 +3,15 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-primary);
|
||||
animation: appFadeIn 0.35s ease-out;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// 繁花如梦:底色层(::before)+ 光晕层(::after)分离,避免 blur 吃掉边缘
|
||||
[data-theme="blossom-dream"] .app-container {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
// ::before 纯底色,不模糊
|
||||
[data-theme="blossom-dream"] .app-container::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: -2;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
// ::after 光晕层,模糊叠加在底色上
|
||||
[data-theme="blossom-dream"] .app-container::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
background:
|
||||
radial-gradient(ellipse 55% 45% at 15% 20%, var(--blossom-pink) 0%, transparent 70%),
|
||||
radial-gradient(ellipse 50% 40% at 85% 75%, var(--blossom-peach) 0%, transparent 65%),
|
||||
radial-gradient(ellipse 45% 50% at 80% 10%, var(--blossom-blue) 0%, transparent 60%);
|
||||
filter: blur(80px);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
// 深色模式光晕更克制
|
||||
[data-theme="blossom-dream"][data-mode="dark"] .app-container::after {
|
||||
background:
|
||||
radial-gradient(ellipse 55% 45% at 15% 20%, var(--blossom-pink) 0%, transparent 70%),
|
||||
radial-gradient(ellipse 50% 40% at 85% 75%, var(--blossom-purple) 0%, transparent 65%),
|
||||
radial-gradient(ellipse 45% 50% at 80% 10%, var(--blossom-blue) 0%, transparent 60%);
|
||||
filter: blur(100px);
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.window-drag-region {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 150px; // 预留系统最小化/最大化/关闭按钮区域
|
||||
right: 150px;
|
||||
height: 40px;
|
||||
-webkit-app-region: drag;
|
||||
pointer-events: auto;
|
||||
@@ -68,8 +27,9 @@
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 24px;
|
||||
padding: 24px 32px;
|
||||
position: relative;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.export-keepalive-page {
|
||||
@@ -84,18 +44,7 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
@keyframes appFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新提示条
|
||||
// ---- Update banner ----
|
||||
.update-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -107,7 +56,7 @@
|
||||
|
||||
.update-text {
|
||||
flex: 1;
|
||||
|
||||
|
||||
strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -124,7 +73,7 @@
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
@@ -143,7 +92,7 @@
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s;
|
||||
transition: opacity 0.15s;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
@@ -178,29 +127,31 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 用户协议弹窗
|
||||
// ---- Agreement modal ----
|
||||
.agreement-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.agreement-modal {
|
||||
width: 520px;
|
||||
max-height: 80vh;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.agreement-header {
|
||||
@@ -241,8 +192,8 @@
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 160, 0, 0.35);
|
||||
background: rgba(255, 160, 0, 0.12);
|
||||
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||
background: rgba(245, 158, 11, 0.08);
|
||||
color: var(--text-primary);
|
||||
|
||||
strong {
|
||||
@@ -291,19 +242,6 @@
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.agreement-footer {
|
||||
@@ -347,21 +285,21 @@
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--border-color);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
color: var(--on-primary);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
transition: opacity 0.15s;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
|
||||
45
src/App.tsx
45
src/App.tsx
@@ -26,6 +26,9 @@ import ContactsPage from './pages/ContactsPage'
|
||||
import ResourcesPage from './pages/ResourcesPage'
|
||||
import ChatHistoryPage from './pages/ChatHistoryPage'
|
||||
import NotificationWindow from './pages/NotificationWindow'
|
||||
import AccountManagementPage from './pages/AccountManagementPage'
|
||||
import BackupPage from './pages/BackupPage'
|
||||
import InsightInboxPage from './pages/InsightInboxPage'
|
||||
|
||||
import { useAppStore } from './stores/appStore'
|
||||
import { themes, useThemeStore, type ThemeId, type ThemeMode } from './stores/themeStore'
|
||||
@@ -38,8 +41,6 @@ import UpdateDialog from './components/UpdateDialog'
|
||||
import UpdateProgressCapsule from './components/UpdateProgressCapsule'
|
||||
import LockScreen from './components/LockScreen'
|
||||
import { GlobalSessionMonitor } from './components/GlobalSessionMonitor'
|
||||
import { BatchTranscribeGlobal } from './components/BatchTranscribeGlobal'
|
||||
import { BatchImageDecryptGlobal } from './components/BatchImageDecryptGlobal'
|
||||
import WindowCloseDialog from './components/WindowCloseDialog'
|
||||
|
||||
function RouteStateRedirect({ to }: { to: string }) {
|
||||
@@ -81,6 +82,8 @@ function App() {
|
||||
const isChatHistoryWindow = location.pathname.startsWith('/chat-history/') || location.pathname.startsWith('/chat-history-inline/')
|
||||
const isStandaloneChatWindow = location.pathname === '/chat-window'
|
||||
const isNotificationWindow = location.pathname === '/notification-window'
|
||||
const isAnnualReportWindow = location.pathname === '/annual-report/view'
|
||||
const isDualReportWindow = location.pathname === '/dual-report/view'
|
||||
const isSettingsRoute = location.pathname === '/settings'
|
||||
const settingsRouteState = location.state as { backgroundLocation?: Location; initialTab?: unknown } | null
|
||||
const routeLocation = isSettingsRoute
|
||||
@@ -128,7 +131,7 @@ function App() {
|
||||
const body = document.body
|
||||
const appRoot = document.getElementById('app')
|
||||
|
||||
if (isOnboardingWindow || isNotificationWindow) {
|
||||
if (isOnboardingWindow || isNotificationWindow || isAnnualReportWindow || isDualReportWindow) {
|
||||
root.style.background = 'transparent'
|
||||
body.style.background = 'transparent'
|
||||
body.style.overflow = 'hidden'
|
||||
@@ -145,9 +148,9 @@ function App() {
|
||||
appRoot.style.overflow = ''
|
||||
}
|
||||
}
|
||||
}, [isOnboardingWindow])
|
||||
}, [isOnboardingWindow, isNotificationWindow, isAnnualReportWindow, isDualReportWindow])
|
||||
|
||||
// 应用主题
|
||||
// 应用主题 (accent color + light/dark mode)
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const applyMode = (mode: ThemeMode, systemDark?: boolean) => {
|
||||
@@ -166,7 +169,7 @@ function App() {
|
||||
}
|
||||
mq.addEventListener('change', handler)
|
||||
return () => mq.removeEventListener('change', handler)
|
||||
}, [currentTheme, themeMode, isOnboardingWindow, isNotificationWindow])
|
||||
}, [currentTheme, themeMode, isOnboardingWindow, isNotificationWindow, isAnnualReportWindow, isDualReportWindow])
|
||||
|
||||
// 读取已保存的主题设置
|
||||
useEffect(() => {
|
||||
@@ -317,6 +320,19 @@ function App() {
|
||||
}
|
||||
}, [navigate, isNotificationWindow])
|
||||
|
||||
useEffect(() => {
|
||||
if (isNotificationWindow) return
|
||||
|
||||
const removeListener = window.electronAPI?.notification?.onNavigateToRoute?.((route: string) => {
|
||||
if (!route || !route.startsWith('/')) return
|
||||
navigate(route, { replace: true })
|
||||
})
|
||||
|
||||
return () => {
|
||||
removeListener?.()
|
||||
}
|
||||
}, [navigate, isNotificationWindow])
|
||||
|
||||
// 解锁后显示暂存的更新弹窗
|
||||
useEffect(() => {
|
||||
if (!isLocked && updateInfo?.hasUpdate && !showUpdateDialog && !isDownloading) {
|
||||
@@ -512,6 +528,16 @@ function App() {
|
||||
return <NotificationWindow />
|
||||
}
|
||||
|
||||
// 独立年度报告全屏窗口
|
||||
if (isAnnualReportWindow) {
|
||||
return <AnnualReportWindow />
|
||||
}
|
||||
|
||||
// 独立双人报告全屏窗口
|
||||
if (isDualReportWindow) {
|
||||
return <DualReportWindow />
|
||||
}
|
||||
|
||||
// 主窗口 - 完整布局
|
||||
const handleCloseSettings = () => {
|
||||
const backgroundLocation = settingsRouteState?.backgroundLocation ?? settingsBackgroundRef.current
|
||||
@@ -553,10 +579,6 @@ function App() {
|
||||
{/* 全局会话监听与通知 */}
|
||||
<GlobalSessionMonitor />
|
||||
|
||||
{/* 全局批量转写进度浮窗 */}
|
||||
<BatchTranscribeGlobal />
|
||||
<BatchImageDecryptGlobal />
|
||||
|
||||
{/* 用户协议弹窗 */}
|
||||
{showAgreement && !agreementLoading && (
|
||||
<div className="agreement-overlay">
|
||||
@@ -678,6 +700,7 @@ function App() {
|
||||
<Routes location={routeLocation}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/home" element={<HomePage />} />
|
||||
<Route path="/account-management" element={<AccountManagementPage />} />
|
||||
<Route path="/chat" element={<ChatPage />} />
|
||||
|
||||
<Route path="/analytics" element={<ChatAnalyticsHubPage />} />
|
||||
@@ -694,9 +717,11 @@ function App() {
|
||||
|
||||
<Route path="/export" element={<div className="export-route-anchor" aria-hidden="true" />} />
|
||||
<Route path="/sns" element={<SnsPage />} />
|
||||
<Route path="/insight-inbox" element={<InsightInboxPage />} />
|
||||
<Route path="/biz" element={<BizPage />} />
|
||||
<Route path="/contacts" element={<ContactsPage />} />
|
||||
<Route path="/resources" element={<ResourcesPage />} />
|
||||
<Route path="/backup" element={<BackupPage />} />
|
||||
<Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} />
|
||||
<Route path="/chat-history-inline/:payloadId" element={<ChatHistoryPage />} />
|
||||
</Routes>
|
||||
|
||||
@@ -5,6 +5,21 @@ import './Avatar.scss'
|
||||
|
||||
// 全局缓存已成功加载过的头像 URL,用于控制后续是否显示动画
|
||||
const loadedAvatarCache = new Set<string>()
|
||||
const MAX_LOADED_AVATAR_CACHE_SIZE = 3000
|
||||
|
||||
const rememberLoadedAvatar = (src: string): void => {
|
||||
if (!src) return
|
||||
if (loadedAvatarCache.has(src)) {
|
||||
loadedAvatarCache.delete(src)
|
||||
}
|
||||
loadedAvatarCache.add(src)
|
||||
|
||||
while (loadedAvatarCache.size > MAX_LOADED_AVATAR_CACHE_SIZE) {
|
||||
const oldest = loadedAvatarCache.values().next().value as string | undefined
|
||||
if (!oldest) break
|
||||
loadedAvatarCache.delete(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
interface AvatarProps {
|
||||
src?: string
|
||||
@@ -123,7 +138,7 @@ export const Avatar = React.memo(function Avatar({
|
||||
onLoad={() => {
|
||||
if (src) {
|
||||
avatarLoadQueue.clearFailed(src)
|
||||
loadedAvatarCache.add(src)
|
||||
rememberLoadedAvatar(src)
|
||||
}
|
||||
setImageLoaded(true)
|
||||
setImageError(false)
|
||||
|
||||
@@ -4,28 +4,29 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-height: 28px;
|
||||
min-height: 32px;
|
||||
padding: 4px 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-analysis-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
gap: 4px;
|
||||
padding: 4px 8px 4px 4px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
@@ -33,12 +34,13 @@
|
||||
.chat-analysis-breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
color: var(--text-tertiary);
|
||||
|
||||
.chat-analysis-breadcrumb-separator {
|
||||
opacity: 0.6;
|
||||
opacity: 0.5;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,25 +51,27 @@
|
||||
.chat-analysis-current-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
transition: color 0.2s ease;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
|
||||
.current {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
svg {
|
||||
transition: transform 0.2s ease;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@@ -78,34 +82,33 @@
|
||||
|
||||
.chat-analysis-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 10px);
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
min-width: 120px;
|
||||
padding: 6px;
|
||||
background: var(--card-bg);
|
||||
padding: 4px;
|
||||
background: var(--bg-secondary-solid, var(--bg-secondary));
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.12);
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow-md);
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.chat-analysis-menu-item {
|
||||
width: 100%;
|
||||
display: block;
|
||||
padding: 9px 12px;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s ease, color 0.2s ease;
|
||||
transition: background 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,13 +6,12 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
z-index: 2400;
|
||||
z-index: 9200;
|
||||
}
|
||||
|
||||
.export-date-range-dialog {
|
||||
width: min(480px, calc(100vw - 32px));
|
||||
max-height: calc(100vh - 64px);
|
||||
overflow-y: auto;
|
||||
max-height: calc(100vh - 80px);
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary-solid, var(--bg-primary));
|
||||
@@ -21,12 +20,14 @@
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
box-shadow: 0 22px 48px rgba(0, 0, 0, 0.16);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.export-date-range-dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
@@ -35,6 +36,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
.export-date-range-dialog-content {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding-right: 2px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.export-date-range-dialog-close-btn {
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
@@ -439,6 +460,7 @@
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.export-date-range-dialog-btn {
|
||||
|
||||
@@ -565,6 +565,7 @@ export function ExportDateRangeDialog({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="export-date-range-dialog-content">
|
||||
<div className="export-date-range-preset-list">
|
||||
{EXPORT_DATE_RANGE_PRESETS.map((preset) => {
|
||||
const active = isPresetActive(preset.value)
|
||||
@@ -728,6 +729,7 @@ export function ExportDateRangeDialog({
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="export-date-range-dialog-actions">
|
||||
<button type="button" className="export-date-range-dialog-btn secondary" onClick={onClose}>
|
||||
|
||||
@@ -457,3 +457,130 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UI rebuild polish for the modal variant used by ExportPage.
|
||||
.export-defaults-settings-form.layout-split {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
|
||||
.form-group {
|
||||
grid-template-columns: minmax(176px, 0.82fr) minmax(0, 1.18fr);
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
padding: 12px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--bg-secondary) 82%, var(--bg-primary));
|
||||
}
|
||||
|
||||
.form-group:first-child,
|
||||
.form-group:last-child {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.form-copy {
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
label {
|
||||
margin-bottom: 3px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.select-field,
|
||||
.settings-time-range-field,
|
||||
.log-toggle-line,
|
||||
.media-default-grid,
|
||||
.concurrency-inline-options {
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.select-trigger,
|
||||
.settings-time-range-trigger {
|
||||
border-radius: 12px;
|
||||
background: var(--bg-primary);
|
||||
min-height: 42px;
|
||||
padding: 9px 12px;
|
||||
}
|
||||
|
||||
.log-toggle-line {
|
||||
border-radius: 12px;
|
||||
background: var(--bg-primary);
|
||||
min-height: 42px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.concurrency-inline-options {
|
||||
grid-template-columns: repeat(6, minmax(38px, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.concurrency-option {
|
||||
min-width: 0;
|
||||
min-height: 36px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.format-setting-group {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.format-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.format-card {
|
||||
min-height: 68px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.format-label,
|
||||
.format-desc {
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.media-default-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(78px, 1fr));
|
||||
gap: 8px;
|
||||
|
||||
label {
|
||||
min-height: 36px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.export-defaults-settings-form.layout-split {
|
||||
.form-group {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.format-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(156px, 1fr));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export function GlobalSessionMonitor() {
|
||||
// 去重辅助函数:获取消息 key
|
||||
const getMessageKey = (msg: Message) => {
|
||||
if (msg.messageKey) return msg.messageKey
|
||||
return `fallback:${msg.serverId || 0}:${msg.createTime}:${msg.sortSeq || 0}:${msg.localId || 0}:${msg.senderUsername || ''}:${msg.localType || 0}`
|
||||
return `fallback:${msg._db_path || ''}:${msg.serverId || 0}:${msg.createTime}:${msg.sortSeq || 0}:${msg.localId || 0}:${msg.senderUsername || ''}:${msg.localType || 0}`
|
||||
}
|
||||
|
||||
// 处理数据库变更
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Bot, User } from 'lucide-react'
|
||||
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'ai';
|
||||
content: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: ChatMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* 优化后的消息气泡组件
|
||||
* 使用 React.memo 避免不必要的重新渲染
|
||||
*/
|
||||
export const MessageBubble = React.memo<MessageBubbleProps>(({ message }) => {
|
||||
return (
|
||||
<div className={`message-row ${message.role}`}>
|
||||
<div className="avatar">
|
||||
{message.role === 'ai' ? <Bot size={24} /> : <User size={24} />}
|
||||
</div>
|
||||
<div className="bubble">
|
||||
<div className="content">{message.content}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}, (prevProps, nextProps) => {
|
||||
// 自定义比较函数:只有内容或ID变化时才重新渲染
|
||||
return prevProps.message.content === nextProps.message.content &&
|
||||
prevProps.message.id === nextProps.message.id
|
||||
})
|
||||
|
||||
MessageBubble.displayName = 'MessageBubble'
|
||||
@@ -7,6 +7,9 @@ import './NotificationToast.scss'
|
||||
export interface NotificationData {
|
||||
id: string
|
||||
sessionId: string
|
||||
channel?: string
|
||||
insightRecordId?: string
|
||||
targetRoute?: string
|
||||
avatarUrl?: string
|
||||
title: string
|
||||
content: string
|
||||
@@ -16,7 +19,7 @@ export interface NotificationData {
|
||||
interface NotificationToastProps {
|
||||
data: NotificationData | null
|
||||
onClose: () => void
|
||||
onClick: (sessionId: string) => void
|
||||
onClick: (data: NotificationData) => void
|
||||
duration?: number
|
||||
position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'
|
||||
isStatic?: boolean
|
||||
@@ -64,7 +67,7 @@ export function NotificationToast({
|
||||
setIsVisible(false)
|
||||
setTimeout(() => {
|
||||
onClose()
|
||||
onClick(currentData.sessionId)
|
||||
onClick(currentData)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ interface RouteGuardProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const PUBLIC_ROUTES = ['/', '/home', '/settings']
|
||||
const PUBLIC_ROUTES = ['/', '/home', '/settings', '/account-management']
|
||||
|
||||
function RouteGuard({ children }: RouteGuardProps) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
// Redesigned sidebar — premium feel with left accent bar, refined spacing
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
background: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border-color);
|
||||
width: var(--sidebar-width, 260px);
|
||||
background: var(--bg-sidebar, var(--bg-secondary));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px 0;
|
||||
transition: width 0.25s ease;
|
||||
padding: 0;
|
||||
transition: width 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid var(--border-color);
|
||||
|
||||
&.collapsed {
|
||||
width: 64px;
|
||||
width: 68px;
|
||||
|
||||
.sidebar-user-card-wrap {
|
||||
margin: 0 8px 8px;
|
||||
@@ -21,28 +24,166 @@
|
||||
.user-meta {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.user-menu-caret {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-menu,
|
||||
.sidebar-footer {
|
||||
.nav-menu {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 0 8px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-badge:not(.icon-badge) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
justify-content: center;
|
||||
padding: 10px;
|
||||
gap: 0;
|
||||
|
||||
&::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Navigation ----
|
||||
.nav-menu {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
padding: 12px 10px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 9px 14px;
|
||||
border-radius: 10px;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
white-space: nowrap;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
margin: 1px 0;
|
||||
|
||||
// Left accent bar for active state
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%) scaleY(0);
|
||||
width: 3px;
|
||||
height: 16px;
|
||||
border-radius: 0 2px 2px 0;
|
||||
background: var(--primary);
|
||||
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
|
||||
&::before {
|
||||
transform: translateY(-50%) scaleY(1);
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.nav-icon-with-badge {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav-badge {
|
||||
margin-left: auto;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 999px;
|
||||
padding: 0 6px;
|
||||
background: #ef4444;
|
||||
color: #ffffff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.nav-badge.icon-badge {
|
||||
position: absolute;
|
||||
top: -7px;
|
||||
right: -10px;
|
||||
margin-left: 0;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
font-size: 10px;
|
||||
box-shadow: 0 0 0 2px var(--bg-sidebar, var(--bg-secondary));
|
||||
}
|
||||
|
||||
// ---- Footer ----
|
||||
.sidebar-footer {
|
||||
padding: 4px 10px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: 8px;
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
// ---- User card ----
|
||||
.sidebar-user-card-wrap {
|
||||
position: relative;
|
||||
margin: 0 12px 10px;
|
||||
margin: 0 10px 10px;
|
||||
--sidebar-user-menu-width: 172px;
|
||||
}
|
||||
|
||||
@@ -55,16 +196,16 @@
|
||||
z-index: 12;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
background: var(--bg-secondary-solid, var(--bg-primary));
|
||||
background: var(--bg-secondary-solid, var(--bg-secondary));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 6px;
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12);
|
||||
gap: 2px;
|
||||
padding: 4px;
|
||||
box-shadow: var(--shadow-md);
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.95);
|
||||
transform: translateY(6px) scale(0.97);
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
|
||||
&.open {
|
||||
opacity: 1;
|
||||
@@ -76,10 +217,10 @@
|
||||
.sidebar-user-menu-item {
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
padding: 9px 10px;
|
||||
padding: 8px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
@@ -87,54 +228,53 @@
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.2s ease, color 0.2s ease;
|
||||
transition: background 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: #d93025;
|
||||
color: #ef4444;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 59, 48, 0.08);
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-user-card {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
background: var(--bg-secondary);
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 56px;
|
||||
min-height: 52px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
|
||||
border: none;
|
||||
transition: background 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(99, 102, 241, 0.32);
|
||||
background: var(--bg-tertiary);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
&.menu-open {
|
||||
border-color: rgba(99, 102, 241, 0.44);
|
||||
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.12);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
|
||||
background: var(--primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 0 0 2px var(--bg-sidebar, var(--bg-secondary));
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
@@ -144,7 +284,7 @@
|
||||
|
||||
span {
|
||||
color: var(--on-primary);
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
@@ -164,7 +304,7 @@
|
||||
}
|
||||
|
||||
.user-wxid {
|
||||
margin-top: 2px;
|
||||
margin-top: 1px;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
@@ -175,386 +315,10 @@
|
||||
.user-menu-caret {
|
||||
color: var(--text-tertiary);
|
||||
display: inline-flex;
|
||||
transition: transform 0.2s ease, color 0.2s ease;
|
||||
transition: transform 0.15s ease;
|
||||
|
||||
&.open {
|
||||
transform: rotate(180deg);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 9999px;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--primary);
|
||||
color: var(--on-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-icon-with-badge {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav-badge {
|
||||
margin-left: auto;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 999px;
|
||||
padding: 0 6px;
|
||||
background: #ff3b30;
|
||||
color: #ffffff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
box-shadow: 0 0 0 2px rgba(255, 59, 48, 0.18);
|
||||
}
|
||||
|
||||
.nav-badge.icon-badge {
|
||||
position: absolute;
|
||||
top: -7px;
|
||||
right: -10px;
|
||||
margin-left: 0;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
font-size: 10px;
|
||||
box-shadow: 0 0 0 2px var(--bg-secondary);
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 0 12px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: 12px;
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.sidebar-dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1100;
|
||||
padding: 20px;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-dialog {
|
||||
width: min(420px, 100%);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.24);
|
||||
padding: 18px 18px 16px;
|
||||
animation: slideUp 0.25s ease;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 10px 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-wxid-list {
|
||||
margin-top: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-wxid-item {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: rgba(99, 102, 241, 0.32);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
&.current {
|
||||
border-color: rgba(99, 102, 241, 0.5);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.wxid-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--on-primary);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.wxid-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.wxid-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.wxid-id {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.current-badge {
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
background: var(--primary);
|
||||
color: var(--on-primary);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-dialog-actions {
|
||||
margin-top: 18px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
|
||||
button {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-clear-dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1100;
|
||||
padding: 20px;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar-clear-dialog {
|
||||
width: min(460px, 100%);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.24);
|
||||
padding: 18px 18px 16px;
|
||||
animation: slideUp 0.25s ease;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 10px 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-clear-options {
|
||||
margin-top: 14px;
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-clear-actions {
|
||||
margin-top: 18px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
|
||||
button {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.danger {
|
||||
border-color: #ef4444;
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
// 繁花如梦主题:侧边栏毛玻璃 + 激活项用主品牌色
|
||||
[data-theme="blossom-dream"] .sidebar {
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
[data-theme="blossom-dream"][data-mode="dark"] .sidebar {
|
||||
background: rgba(34, 30, 36, 0.75);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
// 激活项:主品牌色纵向微渐变
|
||||
[data-theme="blossom-dream"] .nav-item.active {
|
||||
background: linear-gradient(180deg, #D4849A 0%, #C4748A 100%);
|
||||
}
|
||||
|
||||
// 深色激活项:用藕粉色,背景深灰底 + 粉色文字/图标(高阶玩法)
|
||||
[data-theme="blossom-dream"][data-mode="dark"] .nav-item.active {
|
||||
background: rgba(209, 158, 187, 0.15);
|
||||
color: #D19EBB;
|
||||
border: 1px solid rgba(209, 158, 187, 0.2);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { NavLink, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Home, MessageSquare, BarChart3, FileText, Settings, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, RefreshCw, FolderClosed, Footprints } from 'lucide-react'
|
||||
import { Home, MessageSquare, BarChart3, FileText, Settings, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, FolderClosed, Footprints, Users, ArchiveRestore, Sparkles } from 'lucide-react'
|
||||
import { useAppStore } from '../stores/appStore'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import { useAnalyticsStore } from '../stores/analyticsStore'
|
||||
import * as configService from '../services/config'
|
||||
import { onExportSessionStatus, requestExportSessionStatus } from '../services/exportBridge'
|
||||
import { UserRound } from 'lucide-react'
|
||||
|
||||
import './Sidebar.scss'
|
||||
|
||||
@@ -19,6 +16,8 @@ interface SidebarUserProfile {
|
||||
|
||||
const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1'
|
||||
const ACCOUNT_PROFILES_CACHE_KEY = 'account_profiles_cache_v1'
|
||||
const DEFAULT_DISPLAY_NAME = '微信用户'
|
||||
const DEFAULT_SUBTITLE = '微信账号'
|
||||
|
||||
interface SidebarUserProfileCache extends SidebarUserProfile {
|
||||
updatedAt: number
|
||||
@@ -33,24 +32,16 @@ interface AccountProfilesCache {
|
||||
}
|
||||
}
|
||||
|
||||
interface WxidOption {
|
||||
wxid: string
|
||||
modifiedTime: number
|
||||
nickname?: string
|
||||
displayName?: string
|
||||
avatarUrl?: string
|
||||
}
|
||||
|
||||
const readSidebarUserProfileCache = (): SidebarUserProfile | null => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(SIDEBAR_USER_PROFILE_CACHE_KEY)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw) as SidebarUserProfileCache
|
||||
if (!parsed || typeof parsed !== 'object') return null
|
||||
if (!parsed.wxid || !parsed.displayName) return null
|
||||
if (!parsed.wxid) return null
|
||||
return {
|
||||
wxid: parsed.wxid,
|
||||
displayName: parsed.displayName,
|
||||
displayName: typeof parsed.displayName === 'string' ? parsed.displayName : '',
|
||||
alias: parsed.alias,
|
||||
avatarUrl: parsed.avatarUrl
|
||||
}
|
||||
@@ -60,7 +51,7 @@ const readSidebarUserProfileCache = (): SidebarUserProfile | null => {
|
||||
}
|
||||
|
||||
const writeSidebarUserProfileCache = (profile: SidebarUserProfile): void => {
|
||||
if (!profile.wxid || !profile.displayName) return
|
||||
if (!profile.wxid) return
|
||||
try {
|
||||
const payload: SidebarUserProfileCache = {
|
||||
...profile,
|
||||
@@ -115,17 +106,11 @@ function Sidebar({ collapsed }: SidebarProps) {
|
||||
const [activeExportTaskCount, setActiveExportTaskCount] = useState(0)
|
||||
const [userProfile, setUserProfile] = useState<SidebarUserProfile>({
|
||||
wxid: '',
|
||||
displayName: '未识别用户'
|
||||
displayName: DEFAULT_DISPLAY_NAME
|
||||
})
|
||||
const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false)
|
||||
const [showSwitchAccountDialog, setShowSwitchAccountDialog] = useState(false)
|
||||
const [wxidOptions, setWxidOptions] = useState<WxidOption[]>([])
|
||||
const [isSwitchingAccount, setIsSwitchingAccount] = useState(false)
|
||||
const accountCardWrapRef = useRef<HTMLDivElement | null>(null)
|
||||
const setLocked = useAppStore(state => state.setLocked)
|
||||
const isDbConnected = useAppStore(state => state.isDbConnected)
|
||||
const resetChatStore = useChatStore(state => state.reset)
|
||||
const clearAnalyticsStoreCache = useAnalyticsStore(state => state.clearCache)
|
||||
|
||||
useEffect(() => {
|
||||
window.electronAPI.auth.verifyEnabled().then(setAuthEnabled)
|
||||
@@ -164,18 +149,20 @@ function Sidebar({ collapsed }: SidebarProps) {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false
|
||||
let loadSeq = 0
|
||||
|
||||
const loadCurrentUser = async () => {
|
||||
const patchUserProfile = (patch: Partial<SidebarUserProfile>, expectedWxid?: string) => {
|
||||
const seq = ++loadSeq
|
||||
const patchUserProfile = (patch: Partial<SidebarUserProfile>) => {
|
||||
if (disposed || seq !== loadSeq) return
|
||||
setUserProfile(prev => {
|
||||
if (expectedWxid && prev.wxid && prev.wxid !== expectedWxid) {
|
||||
return prev
|
||||
}
|
||||
const next: SidebarUserProfile = {
|
||||
...prev,
|
||||
...patch
|
||||
}
|
||||
if (!next.displayName) {
|
||||
next.displayName = next.wxid || '未识别用户'
|
||||
if (typeof next.displayName !== 'string' || next.displayName.length === 0) {
|
||||
next.displayName = DEFAULT_DISPLAY_NAME
|
||||
}
|
||||
writeSidebarUserProfileCache(next)
|
||||
return next
|
||||
@@ -184,11 +171,33 @@ function Sidebar({ collapsed }: SidebarProps) {
|
||||
|
||||
try {
|
||||
const wxid = await configService.getMyWxid()
|
||||
if (disposed || seq !== loadSeq) return
|
||||
const resolvedWxidRaw = String(wxid || '').trim()
|
||||
const cleanedWxid = normalizeAccountId(resolvedWxidRaw)
|
||||
const resolvedWxid = cleanedWxid || resolvedWxidRaw
|
||||
|
||||
if (!resolvedWxidRaw && !resolvedWxid) return
|
||||
if (!resolvedWxidRaw && !resolvedWxid) {
|
||||
window.localStorage.removeItem(SIDEBAR_USER_PROFILE_CACHE_KEY)
|
||||
patchUserProfile({
|
||||
wxid: '',
|
||||
displayName: DEFAULT_DISPLAY_NAME,
|
||||
alias: undefined,
|
||||
avatarUrl: undefined
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setUserProfile((prev) => {
|
||||
if (prev.wxid === resolvedWxid) return prev
|
||||
const seeded: SidebarUserProfile = {
|
||||
wxid: resolvedWxid,
|
||||
displayName: DEFAULT_DISPLAY_NAME,
|
||||
alias: undefined,
|
||||
avatarUrl: undefined
|
||||
}
|
||||
writeSidebarUserProfileCache(seeded)
|
||||
return seeded
|
||||
})
|
||||
|
||||
const wxidCandidates = new Set<string>([
|
||||
resolvedWxidRaw.toLowerCase(),
|
||||
@@ -197,14 +206,13 @@ function Sidebar({ collapsed }: SidebarProps) {
|
||||
].filter(Boolean))
|
||||
|
||||
const normalizeName = (value?: string | null): string | undefined => {
|
||||
if (!value) return undefined
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return undefined
|
||||
const lowered = trimmed.toLowerCase()
|
||||
if (typeof value !== 'string') return undefined
|
||||
if (value.length === 0) return undefined
|
||||
const lowered = value.trim().toLowerCase()
|
||||
if (lowered === 'self') return undefined
|
||||
if (lowered.startsWith('wxid_')) return undefined
|
||||
if (wxidCandidates.has(lowered)) return undefined
|
||||
return trimmed
|
||||
return value
|
||||
}
|
||||
|
||||
const pickFirstValidName = (...candidates: Array<string | null | undefined>): string | undefined => {
|
||||
@@ -229,18 +237,20 @@ function Sidebar({ collapsed }: SidebarProps) {
|
||||
})(),
|
||||
window.electronAPI.chat.getMyAvatarUrl()
|
||||
])
|
||||
if (disposed || seq !== loadSeq) return
|
||||
|
||||
const myContact = contactResult.status === 'fulfilled' ? contactResult.value : null
|
||||
const displayName = pickFirstValidName(
|
||||
myContact?.remark,
|
||||
myContact?.nickName,
|
||||
myContact?.alias
|
||||
) || resolvedWxid || '未识别用户'
|
||||
) || DEFAULT_DISPLAY_NAME
|
||||
const alias = normalizeName(myContact?.alias)
|
||||
|
||||
patchUserProfile({
|
||||
wxid: resolvedWxid,
|
||||
displayName,
|
||||
alias: myContact?.alias,
|
||||
alias,
|
||||
avatarUrl: avatarResult.status === 'fulfilled' && avatarResult.value.success
|
||||
? avatarResult.value.avatarUrl
|
||||
: undefined
|
||||
@@ -257,118 +267,28 @@ function Sidebar({ collapsed }: SidebarProps) {
|
||||
|
||||
void loadCurrentUser()
|
||||
const onWxidChanged = () => { void loadCurrentUser() }
|
||||
const onWindowFocus = () => { void loadCurrentUser() }
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
void loadCurrentUser()
|
||||
}
|
||||
}
|
||||
window.addEventListener('wxid-changed', onWxidChanged as EventListener)
|
||||
return () => window.removeEventListener('wxid-changed', onWxidChanged as EventListener)
|
||||
window.addEventListener('focus', onWindowFocus)
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
return () => {
|
||||
disposed = true
|
||||
loadSeq += 1
|
||||
window.removeEventListener('wxid-changed', onWxidChanged as EventListener)
|
||||
window.removeEventListener('focus', onWindowFocus)
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const getAvatarLetter = (name: string): string => {
|
||||
if (!name) return '?'
|
||||
return [...name][0] || '?'
|
||||
}
|
||||
|
||||
const openSwitchAccountDialog = async () => {
|
||||
setIsAccountMenuOpen(false)
|
||||
if (!isDbConnected) {
|
||||
window.alert('数据库未连接,无法切换账号')
|
||||
return
|
||||
}
|
||||
const dbPath = await configService.getDbPath()
|
||||
if (!dbPath) {
|
||||
window.alert('请先在设置中配置数据库路径')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const wxids = await window.electronAPI.dbPath.scanWxids(dbPath)
|
||||
const accountsCache = readAccountProfilesCache()
|
||||
console.log('[切换账号] 账号缓存:', accountsCache)
|
||||
|
||||
const enrichedWxids = wxids.map((option: WxidOption) => {
|
||||
const normalizedWxid = normalizeAccountId(option.wxid)
|
||||
const cached = accountsCache[option.wxid] || accountsCache[normalizedWxid]
|
||||
|
||||
let displayName = option.nickname || option.wxid
|
||||
let avatarUrl = option.avatarUrl
|
||||
|
||||
if (option.wxid === userProfile.wxid || normalizedWxid === userProfile.wxid) {
|
||||
displayName = userProfile.displayName || displayName
|
||||
avatarUrl = userProfile.avatarUrl || avatarUrl
|
||||
}
|
||||
|
||||
else if (cached) {
|
||||
displayName = cached.displayName || displayName
|
||||
avatarUrl = cached.avatarUrl || avatarUrl
|
||||
}
|
||||
|
||||
return {
|
||||
...option,
|
||||
displayName,
|
||||
avatarUrl
|
||||
}
|
||||
})
|
||||
|
||||
setWxidOptions(enrichedWxids)
|
||||
setShowSwitchAccountDialog(true)
|
||||
} catch (error) {
|
||||
console.error('扫描账号失败:', error)
|
||||
window.alert('扫描账号失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSwitchAccount = async (selectedWxid: string) => {
|
||||
if (!selectedWxid || isSwitchingAccount) return
|
||||
setIsSwitchingAccount(true)
|
||||
try {
|
||||
console.log('[切换账号] 开始切换到:', selectedWxid)
|
||||
const currentWxid = userProfile.wxid
|
||||
if (currentWxid === selectedWxid) {
|
||||
console.log('[切换账号] 已经是当前账号,跳过')
|
||||
setShowSwitchAccountDialog(false)
|
||||
setIsSwitchingAccount(false)
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[切换账号] 设置新 wxid')
|
||||
await configService.setMyWxid(selectedWxid)
|
||||
|
||||
console.log('[切换账号] 获取账号配置')
|
||||
const wxidConfig = await configService.getWxidConfig(selectedWxid)
|
||||
console.log('[切换账号] 配置内容:', wxidConfig)
|
||||
if (wxidConfig?.decryptKey) {
|
||||
console.log('[切换账号] 设置 decryptKey')
|
||||
await configService.setDecryptKey(wxidConfig.decryptKey)
|
||||
}
|
||||
if (typeof wxidConfig?.imageXorKey === 'number') {
|
||||
console.log('[切换账号] 设置 imageXorKey:', wxidConfig.imageXorKey)
|
||||
await configService.setImageXorKey(wxidConfig.imageXorKey)
|
||||
}
|
||||
if (wxidConfig?.imageAesKey) {
|
||||
console.log('[切换账号] 设置 imageAesKey')
|
||||
await configService.setImageAesKey(wxidConfig.imageAesKey)
|
||||
}
|
||||
|
||||
console.log('[切换账号] 检查数据库连接状态')
|
||||
console.log('[切换账号] 数据库连接状态:', isDbConnected)
|
||||
if (isDbConnected) {
|
||||
console.log('[切换账号] 关闭数据库连接')
|
||||
await window.electronAPI.chat.close()
|
||||
}
|
||||
|
||||
console.log('[切换账号] 清除缓存')
|
||||
window.localStorage.removeItem(SIDEBAR_USER_PROFILE_CACHE_KEY)
|
||||
clearAnalyticsStoreCache()
|
||||
resetChatStore()
|
||||
|
||||
console.log('[切换账号] 触发 wxid-changed 事件')
|
||||
window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: selectedWxid } }))
|
||||
|
||||
console.log('[切换账号] 切换成功')
|
||||
setShowSwitchAccountDialog(false)
|
||||
} catch (error) {
|
||||
console.error('[切换账号] 失败:', error)
|
||||
window.alert('切换账号失败,请稍后重试')
|
||||
} finally {
|
||||
setIsSwitchingAccount(false)
|
||||
}
|
||||
if (!name) return '微'
|
||||
const visible = name.trim()
|
||||
return (visible && [...visible][0]) || '微'
|
||||
}
|
||||
|
||||
const openSettingsFromAccountMenu = () => {
|
||||
@@ -380,6 +300,11 @@ function Sidebar({ collapsed }: SidebarProps) {
|
||||
})
|
||||
}
|
||||
|
||||
const openAccountManagement = () => {
|
||||
setIsAccountMenuOpen(false)
|
||||
navigate('/account-management')
|
||||
}
|
||||
|
||||
const isActive = (path: string) => {
|
||||
return location.pathname === path || location.pathname.startsWith(`${path}/`)
|
||||
}
|
||||
@@ -419,6 +344,15 @@ function Sidebar({ collapsed }: SidebarProps) {
|
||||
<span className="nav-label">朋友圈</span>
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
to="/insight-inbox"
|
||||
className={`nav-item ${isActive('/insight-inbox') ? 'active' : ''}`}
|
||||
title={collapsed ? '灵感信箱' : undefined}
|
||||
>
|
||||
<span className="nav-icon"><Sparkles size={20} /></span>
|
||||
<span className="nav-label">灵感信箱</span>
|
||||
</NavLink>
|
||||
|
||||
{/* 通讯录 */}
|
||||
<NavLink
|
||||
to="/contacts"
|
||||
@@ -487,6 +421,15 @@ function Sidebar({ collapsed }: SidebarProps) {
|
||||
)}
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
to="/backup"
|
||||
className={`nav-item ${isActive('/backup') ? 'active' : ''}`}
|
||||
title={collapsed ? '数据库备份' : undefined}
|
||||
>
|
||||
<span className="nav-icon"><ArchiveRestore size={20} /></span>
|
||||
<span className="nav-label">数据库备份</span>
|
||||
</NavLink>
|
||||
|
||||
|
||||
</nav>
|
||||
|
||||
@@ -515,12 +458,12 @@ function Sidebar({ collapsed }: SidebarProps) {
|
||||
<div className={`sidebar-user-menu ${isAccountMenuOpen ? 'open' : ''}`} role="menu" aria-label="账号菜单">
|
||||
<button
|
||||
className="sidebar-user-menu-item"
|
||||
onClick={openSwitchAccountDialog}
|
||||
onClick={openAccountManagement}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
<span>切换账号</span>
|
||||
<Users size={14} />
|
||||
<span>账号管理</span>
|
||||
</button>
|
||||
<button
|
||||
className="sidebar-user-menu-item"
|
||||
@@ -534,7 +477,7 @@ function Sidebar({ collapsed }: SidebarProps) {
|
||||
</div>
|
||||
<div
|
||||
className={`sidebar-user-card ${isAccountMenuOpen ? 'menu-open' : ''}`}
|
||||
title={collapsed ? `${userProfile.displayName}${(userProfile.alias || userProfile.wxid) ? `\n${userProfile.alias || userProfile.wxid}` : ''}` : undefined}
|
||||
title={collapsed ? `${userProfile.displayName}${(userProfile.alias) ? `\n${userProfile.alias}` : ''}` : undefined}
|
||||
onClick={() => setIsAccountMenuOpen(prev => !prev)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
@@ -549,8 +492,8 @@ function Sidebar({ collapsed }: SidebarProps) {
|
||||
{userProfile.avatarUrl ? <img src={userProfile.avatarUrl} alt="" /> : <span>{getAvatarLetter(userProfile.displayName)}</span>}
|
||||
</div>
|
||||
<div className="user-meta">
|
||||
<div className="user-name">{userProfile.displayName}</div>
|
||||
<div className="user-wxid">{userProfile.alias || userProfile.wxid || 'wxid 未识别'}</div>
|
||||
<div className="user-name">{userProfile.displayName || DEFAULT_DISPLAY_NAME}</div>
|
||||
<div className="user-wxid">{userProfile.alias || DEFAULT_SUBTITLE}</div>
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<span className={`user-menu-caret ${isAccountMenuOpen ? 'open' : ''}`}>
|
||||
@@ -561,44 +504,6 @@ function Sidebar({ collapsed }: SidebarProps) {
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{showSwitchAccountDialog && (
|
||||
<div className="sidebar-dialog-overlay" onClick={() => !isSwitchingAccount && setShowSwitchAccountDialog(false)}>
|
||||
<div className="sidebar-dialog" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
|
||||
<h3>切换账号</h3>
|
||||
<p>选择要切换的微信账号</p>
|
||||
<div className="sidebar-wxid-list">
|
||||
{wxidOptions.map((option) => (
|
||||
<button
|
||||
key={option.wxid}
|
||||
className={`sidebar-wxid-item ${userProfile.wxid === option.wxid ? 'current' : ''}`}
|
||||
onClick={() => handleSwitchAccount(option.wxid)}
|
||||
disabled={isSwitchingAccount}
|
||||
type="button"
|
||||
>
|
||||
<div className="wxid-avatar">
|
||||
{option.avatarUrl ? (
|
||||
<img src={option.avatarUrl} alt="" />
|
||||
) : (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--bg-tertiary)', borderRadius: '6px', color: 'var(--text-tertiary)' }}>
|
||||
<UserRound size={16} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="wxid-info">
|
||||
<div className="wxid-name">{option.displayName}</div>
|
||||
{option.displayName !== option.wxid && <div className="wxid-id">{option.wxid}</div>}
|
||||
</div>
|
||||
{userProfile.wxid === option.wxid && <span className="current-badge">当前</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="sidebar-dialog-actions">
|
||||
<button type="button" onClick={() => setShowSwitchAccountDialog(false)} disabled={isSwitchingAccount}>取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,16 +6,16 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px 16px;
|
||||
background: rgba(15, 23, 42, 0.38);
|
||||
background: rgba(15, 23, 42, 0.28);
|
||||
}
|
||||
|
||||
.contact-sns-dialog {
|
||||
width: min(760px, 100%);
|
||||
max-height: min(86vh, 860px);
|
||||
border-radius: 14px;
|
||||
width: min(720px, 100%);
|
||||
max-height: min(84vh, 820px);
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary-solid, #ffffff);
|
||||
box-shadow: 0 22px 46px rgba(0, 0, 0, 0.24);
|
||||
box-shadow: 0 18px 34px rgba(0, 0, 0, 0.18);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
@@ -29,7 +29,7 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 14px 16px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
@@ -41,9 +41,9 @@
|
||||
}
|
||||
|
||||
.contact-sns-dialog-avatar {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 10px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
@@ -69,7 +69,7 @@
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -79,7 +79,7 @@
|
||||
|
||||
.contact-sns-dialog-username {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -88,7 +88,7 @@
|
||||
|
||||
.contact-sns-dialog-stats {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@@ -111,9 +111,9 @@
|
||||
border-radius: 8px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-secondary);
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
height: 30px;
|
||||
padding: 0 9px;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
@@ -134,8 +134,8 @@
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
width: 248px;
|
||||
max-height: calc((28px * 15) + 16px);
|
||||
width: 228px;
|
||||
max-height: calc((26px * 15) + 16px);
|
||||
overflow-y: auto;
|
||||
border: 1px solid color-mix(in srgb, var(--primary) 30%, var(--border-color));
|
||||
border-radius: 10px;
|
||||
@@ -220,26 +220,20 @@
|
||||
}
|
||||
|
||||
.contact-sns-dialog-tip {
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 88%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-primary) 78%, var(--bg-secondary));
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
word-break: break-word;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.contact-sns-dialog-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 12px 16px 14px;
|
||||
padding: 10px 12px 12px;
|
||||
}
|
||||
|
||||
.contact-sns-dialog-posts-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.contact-sns-dialog-posts-list .post-header-actions {
|
||||
@@ -247,9 +241,9 @@
|
||||
}
|
||||
|
||||
.contact-sns-dialog-status {
|
||||
padding: 20px 12px;
|
||||
padding: 16px 10px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
|
||||
&.empty {
|
||||
@@ -264,8 +258,8 @@
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
border-radius: 10px;
|
||||
padding: 9px 18px;
|
||||
font-size: 13px;
|
||||
padding: 8px 14px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
@@ -282,15 +276,15 @@
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.contact-sns-dialog-overlay {
|
||||
padding: 12px 8px;
|
||||
padding: 10px 8px;
|
||||
}
|
||||
|
||||
.contact-sns-dialog {
|
||||
width: min(100vw - 16px, 760px);
|
||||
width: min(100vw - 16px, 720px);
|
||||
max-height: calc(100vh - 24px);
|
||||
|
||||
.contact-sns-dialog-header {
|
||||
padding: 12px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.contact-sns-dialog-header-actions {
|
||||
@@ -300,18 +294,13 @@
|
||||
.contact-sns-dialog-rank-btn {
|
||||
height: 26px;
|
||||
padding: 0 8px;
|
||||
font-size: 11px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.contact-sns-dialog-rank-panel {
|
||||
width: min(78vw, 232px);
|
||||
}
|
||||
|
||||
.contact-sns-dialog-tip {
|
||||
padding: 10px 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.contact-sns-dialog-body {
|
||||
padding: 10px 10px 12px;
|
||||
}
|
||||
|
||||
@@ -538,10 +538,6 @@ export function ContactSnsTimelineDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="contact-sns-dialog-tip">
|
||||
在微信桌面客户端中打开这个人的朋友圈浏览,可快速把其朋友圈同步到这里。若你在乎这个人,一定要试试~
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="contact-sns-dialog-body"
|
||||
onScroll={handleBodyScroll}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react'
|
||||
import { Search, User, X, Loader2, CheckSquare, Square, Download } from 'lucide-react'
|
||||
import { Virtuoso } from 'react-virtuoso'
|
||||
import { Avatar } from '../Avatar'
|
||||
|
||||
interface Contact {
|
||||
@@ -51,10 +52,14 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
|
||||
onClearSelectedContacts,
|
||||
onExportSelectedContacts
|
||||
}) => {
|
||||
const filteredContacts = contacts.filter(c =>
|
||||
(c.displayName || '').toLowerCase().includes(contactSearch.toLowerCase()) ||
|
||||
c.username.toLowerCase().includes(contactSearch.toLowerCase())
|
||||
)
|
||||
const filteredContacts = React.useMemo(() => {
|
||||
const keyword = contactSearch.trim().toLowerCase()
|
||||
if (!keyword) return contacts
|
||||
return contacts.filter(c =>
|
||||
(c.displayName || '').toLowerCase().includes(keyword) ||
|
||||
c.username.toLowerCase().includes(keyword)
|
||||
)
|
||||
}, [contacts, contactSearch])
|
||||
const selectedContactLookup = React.useMemo(
|
||||
() => new Set(selectedContactUsernames),
|
||||
[selectedContactUsernames]
|
||||
@@ -85,10 +90,52 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
|
||||
return '没有找到联系人'
|
||||
}
|
||||
|
||||
const renderContactRow = React.useCallback((_: number, contact: Contact) => {
|
||||
const isPostCountReady = contact.postCountStatus === 'ready'
|
||||
const isSelected = selectedContactLookup.has(contact.username)
|
||||
const isActive = activeContactUsername === contact.username
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`contact-row${isSelected ? ' is-selected' : ''}${isActive ? ' is-active' : ''}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`contact-select-btn${isSelected ? ' checked' : ''}`}
|
||||
onClick={() => onToggleContactSelected(contact)}
|
||||
title={isSelected ? `取消选择 ${contact.displayName}` : `选择 ${contact.displayName}`}
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
{isSelected ? <CheckSquare size={14} /> : <Square size={14} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="contact-main-btn"
|
||||
onClick={() => onOpenContactTimeline(contact)}
|
||||
title={`查看 ${contact.displayName} 的朋友圈`}
|
||||
>
|
||||
<Avatar src={contact.avatarUrl} name={contact.displayName} size={28} shape="rounded" />
|
||||
<div className="contact-meta">
|
||||
<span className="contact-name">{contact.displayName}</span>
|
||||
</div>
|
||||
<div className="contact-post-count-wrap">
|
||||
{isPostCountReady ? (
|
||||
<span className="contact-post-count">{Math.max(0, Math.floor(Number(contact.postCount || 0)))}条</span>
|
||||
) : (
|
||||
<span className="contact-post-count-loading" title="统计中">
|
||||
<Loader2 size={12} className="spinning" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}, [activeContactUsername, onOpenContactTimeline, onToggleContactSelected, selectedContactLookup])
|
||||
|
||||
return (
|
||||
<aside className="sns-filter-panel">
|
||||
<div className="filter-header">
|
||||
<h3>筛选条件</h3>
|
||||
<h3>筛选</h3>
|
||||
{(searchKeyword || contactSearch) && (
|
||||
<button className="reset-all-btn" onClick={clearFilters} title="重置所有筛选">
|
||||
<RefreshCw size={14} />
|
||||
@@ -101,12 +148,12 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
|
||||
<div className="filter-widget search-widget">
|
||||
<div className="widget-header">
|
||||
<Search size={14} />
|
||||
<span>关键词搜索</span>
|
||||
<span>关键词</span>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索动态内容..."
|
||||
placeholder="搜索动态"
|
||||
value={searchKeyword}
|
||||
onChange={e => setSearchKeyword(e.target.value)}
|
||||
/>
|
||||
@@ -130,7 +177,7 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
|
||||
<div className="contact-search-bar">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="查找好友..."
|
||||
placeholder="查找联系人"
|
||||
value={contactSearch}
|
||||
onChange={e => setContactSearch(e.target.value)}
|
||||
/>
|
||||
@@ -162,53 +209,17 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="contact-interaction-hint">
|
||||
点左侧可多选下载,点右侧可查看单人详情
|
||||
</div>
|
||||
|
||||
<div className="contact-list-scroll">
|
||||
{filteredContacts.map(contact => {
|
||||
const isPostCountReady = contact.postCountStatus === 'ready'
|
||||
const isSelected = selectedContactLookup.has(contact.username)
|
||||
const isActive = activeContactUsername === contact.username
|
||||
return (
|
||||
<div
|
||||
key={contact.username}
|
||||
className={`contact-row${isSelected ? ' is-selected' : ''}${isActive ? ' is-active' : ''}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`contact-select-btn${isSelected ? ' checked' : ''}`}
|
||||
onClick={() => onToggleContactSelected(contact)}
|
||||
title={isSelected ? `取消选择 ${contact.displayName}` : `选择 ${contact.displayName}`}
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
{isSelected ? <CheckSquare size={16} /> : <Square size={16} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="contact-main-btn"
|
||||
onClick={() => onOpenContactTimeline(contact)}
|
||||
title={`查看 ${contact.displayName} 的朋友圈`}
|
||||
>
|
||||
<Avatar src={contact.avatarUrl} name={contact.displayName} size={36} shape="rounded" />
|
||||
<div className="contact-meta">
|
||||
<span className="contact-name">{contact.displayName}</span>
|
||||
</div>
|
||||
<div className="contact-post-count-wrap">
|
||||
{isPostCountReady ? (
|
||||
<span className="contact-post-count">{Math.max(0, Math.floor(Number(contact.postCount || 0)))}条</span>
|
||||
) : (
|
||||
<span className="contact-post-count-loading" title="统计中">
|
||||
<Loader2 size={13} className="spinning" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{filteredContacts.length === 0 && (
|
||||
{filteredContacts.length > 0 ? (
|
||||
<Virtuoso
|
||||
className="contact-list-virtuoso"
|
||||
data={filteredContacts}
|
||||
computeItemKey={(_, contact) => contact.username}
|
||||
fixedItemHeight={40}
|
||||
itemContent={renderContactRow}
|
||||
overscan={320}
|
||||
/>
|
||||
) : (
|
||||
<div className="empty-state">{getEmptyStateText()}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useMemo, useEffect } from 'react'
|
||||
import React, { useState, useMemo, useEffect, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Heart, ChevronRight, ImageIcon, Code, Trash2, MapPin } from 'lucide-react'
|
||||
import { SnsPost, SnsLinkCardData, SnsLocation } from '../../types/sns'
|
||||
@@ -8,6 +8,7 @@ import { getEmojiPath } from 'wechat-emojis'
|
||||
|
||||
// Helper functions (extracted from SnsPage.tsx but simplified/reused)
|
||||
const LINK_XML_URL_TAGS = ['url', 'shorturl', 'weburl', 'webpageurl', 'jumpurl']
|
||||
const LINK_XML_DIRECT_URL_TAGS = ['contentUrl', ...LINK_XML_URL_TAGS]
|
||||
const LINK_XML_TITLE_TAGS = ['title', 'linktitle', 'webtitle']
|
||||
const MEDIA_HOST_HINTS = ['mmsns.qpic.cn', 'vweixinthumb', 'snstimeline', 'snsvideodownload']
|
||||
|
||||
@@ -29,6 +30,13 @@ const decodeHtmlEntities = (text: string): string => {
|
||||
.trim()
|
||||
}
|
||||
|
||||
const normalizeRawXmlForParsing = (xml: string): string => {
|
||||
if (!xml) return ''
|
||||
return decodeHtmlEntities(xml)
|
||||
.replace(/\\+"/g, '"')
|
||||
.replace(/\\+'/g, "'")
|
||||
}
|
||||
|
||||
const normalizeUrlCandidate = (raw: string): string | null => {
|
||||
const value = decodeHtmlEntities(raw).replace(/[)\],.;]+$/, '').trim()
|
||||
if (!value) return null
|
||||
@@ -43,12 +51,13 @@ const simplifyUrlForCompare = (value: string): string => {
|
||||
}
|
||||
|
||||
const getXmlTagValues = (xml: string, tags: string[]): string[] => {
|
||||
if (!xml) return []
|
||||
const normalizedXml = normalizeRawXmlForParsing(xml)
|
||||
if (!normalizedXml) return []
|
||||
const results: string[] = []
|
||||
for (const tag of tags) {
|
||||
const reg = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'ig')
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = reg.exec(xml)) !== null) {
|
||||
while ((match = reg.exec(normalizedXml)) !== null) {
|
||||
if (match[1]) results.push(match[1])
|
||||
}
|
||||
}
|
||||
@@ -65,20 +74,87 @@ const isLikelyMediaAssetUrl = (url: string): boolean => {
|
||||
return MEDIA_HOST_HINTS.some((hint) => lower.includes(hint))
|
||||
}
|
||||
|
||||
const normalizeSnsAssetUrl = (url: string, token?: string, encIdx?: string): string => {
|
||||
const base = decodeHtmlEntities(url).trim()
|
||||
if (!base) return ''
|
||||
|
||||
let fixed = base.replace(/^http:\/\//i, 'https://')
|
||||
|
||||
const normalizedToken = decodeHtmlEntities(String(token || '')).trim()
|
||||
const normalizedEncIdx = decodeHtmlEntities(String(encIdx || '')).trim()
|
||||
const effectiveIdx = normalizedEncIdx || (normalizedToken ? '1' : '')
|
||||
const appendParams: string[] = []
|
||||
if (normalizedToken && !/[?&]token=/i.test(fixed)) {
|
||||
appendParams.push(`token=${normalizedToken}`)
|
||||
}
|
||||
if (effectiveIdx && !/[?&]idx=/i.test(fixed)) {
|
||||
appendParams.push(`idx=${effectiveIdx}`)
|
||||
}
|
||||
if (appendParams.length > 0) {
|
||||
const connector = fixed.includes('?') ? '&' : '?'
|
||||
fixed = `${fixed}${connector}${appendParams.join('&')}`
|
||||
}
|
||||
return fixed
|
||||
}
|
||||
|
||||
const extractCardThumbMetaFromXml = (xml: string): { thumb?: string; thumbKey?: string } => {
|
||||
const normalizedXml = normalizeRawXmlForParsing(xml)
|
||||
if (!normalizedXml) return {}
|
||||
const mediaMatch = normalizedXml.match(/<media>([\s\S]*?)<\/media>/i)
|
||||
if (!mediaMatch?.[1]) return {}
|
||||
|
||||
const mediaXml = mediaMatch[1]
|
||||
const thumbMatch = mediaXml.match(/<thumb([^>]*)>([^<]+)<\/thumb>/i)
|
||||
if (!thumbMatch) return {}
|
||||
|
||||
const attrs = thumbMatch[1] || ''
|
||||
const getAttr = (name: string): string | undefined => {
|
||||
const reg = new RegExp(`${name}\\s*=\\s*(?:\"([^\"]+)\"|'([^']+)'|([^\\s>]+))`, 'i')
|
||||
const m = attrs.match(reg)
|
||||
return decodeHtmlEntities((m?.[1] || m?.[2] || m?.[3] || '').trim()) || undefined
|
||||
}
|
||||
const thumbRawUrl = thumbMatch[2] || ''
|
||||
const thumbToken = getAttr('token')
|
||||
const thumbKey = getAttr('key')
|
||||
const thumbEncIdx = getAttr('enc_idx')
|
||||
const thumb = normalizeSnsAssetUrl(thumbRawUrl, thumbToken, thumbEncIdx)
|
||||
|
||||
return {
|
||||
thumb: thumb || undefined,
|
||||
thumbKey: thumbKey ? decodeHtmlEntities(thumbKey).trim() : undefined
|
||||
}
|
||||
}
|
||||
|
||||
const pickCardTitle = (post: SnsPost): string => {
|
||||
const titleCandidates = [
|
||||
post.linkTitle || '',
|
||||
...getXmlTagValues(post.rawXml || '', LINK_XML_TITLE_TAGS),
|
||||
post.contentDesc || ''
|
||||
]
|
||||
return titleCandidates
|
||||
.map((value) => decodeHtmlEntities(value))
|
||||
.find((value) => Boolean(value) && !/^https?:\/\//i.test(value)) || '网页链接'
|
||||
}
|
||||
|
||||
const buildLinkCardData = (post: SnsPost): SnsLinkCardData | null => {
|
||||
// type 3 是链接类型,直接用 media[0] 的 url 和 thumb
|
||||
if (post.type === 3) {
|
||||
const url = post.media[0]?.url || post.linkUrl
|
||||
if (!url) return null
|
||||
const titleCandidates = [
|
||||
post.linkTitle || '',
|
||||
...getXmlTagValues(post.rawXml || '', LINK_XML_TITLE_TAGS),
|
||||
post.contentDesc || ''
|
||||
// type 3 / 5 是链接卡片类型,优先按卡片链接解析
|
||||
if (post.type === 3 || post.type === 5) {
|
||||
const thumbMeta = extractCardThumbMetaFromXml(post.rawXml || '')
|
||||
const directUrlCandidates = [
|
||||
post.linkUrl || '',
|
||||
...getXmlTagValues(post.rawXml || '', LINK_XML_DIRECT_URL_TAGS),
|
||||
...post.media.map((item) => item.url || '')
|
||||
]
|
||||
const title = titleCandidates
|
||||
.map((v) => decodeHtmlEntities(v))
|
||||
.find((v) => Boolean(v) && !/^https?:\/\//i.test(v))
|
||||
return { url, title: title || '网页链接', thumb: post.media[0]?.thumb }
|
||||
const url = directUrlCandidates
|
||||
.map(normalizeUrlCandidate)
|
||||
.find((value): value is string => Boolean(value))
|
||||
if (!url) return null
|
||||
return {
|
||||
url,
|
||||
title: pickCardTitle(post),
|
||||
thumb: thumbMeta.thumb || post.media[0]?.thumb || post.media[0]?.url,
|
||||
thumbKey: thumbMeta.thumbKey || post.media[0]?.key
|
||||
}
|
||||
}
|
||||
|
||||
const hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url))
|
||||
@@ -117,19 +193,9 @@ const buildLinkCardData = (post: SnsPost): SnsLinkCardData | null => {
|
||||
|
||||
if (!linkUrl) return null
|
||||
|
||||
const titleCandidates = [
|
||||
post.linkTitle || '',
|
||||
...getXmlTagValues(post.rawXml || '', LINK_XML_TITLE_TAGS),
|
||||
post.contentDesc || ''
|
||||
]
|
||||
|
||||
const title = titleCandidates
|
||||
.map((value) => decodeHtmlEntities(value))
|
||||
.find((value) => Boolean(value) && !/^https?:\/\//i.test(value))
|
||||
|
||||
return {
|
||||
url: linkUrl,
|
||||
title: title || '网页链接',
|
||||
title: pickCardTitle(post),
|
||||
thumb: post.media[0]?.thumb || post.media[0]?.url
|
||||
}
|
||||
}
|
||||
@@ -158,8 +224,11 @@ const buildLocationText = (location?: SnsLocation): string => {
|
||||
return primary || region
|
||||
}
|
||||
|
||||
const SnsLinkCard = ({ card }: { card: SnsLinkCardData }) => {
|
||||
const SnsLinkCard = ({ card, thumbKey }: { card: SnsLinkCardData; thumbKey?: string }) => {
|
||||
const [thumbFailed, setThumbFailed] = useState(false)
|
||||
const [thumbSrc, setThumbSrc] = useState(card.thumb || '')
|
||||
const [reloadNonce, setReloadNonce] = useState(0)
|
||||
const retryCountRef = useRef(0)
|
||||
const hostname = useMemo(() => {
|
||||
try {
|
||||
return new URL(card.url).hostname.replace(/^www\./i, '')
|
||||
@@ -168,6 +237,58 @@ const SnsLinkCard = ({ card }: { card: SnsLinkCardData }) => {
|
||||
}
|
||||
}, [card.url])
|
||||
|
||||
useEffect(() => {
|
||||
retryCountRef.current = 0
|
||||
}, [card.thumb, thumbKey])
|
||||
|
||||
const scheduleRetry = () => {
|
||||
if (retryCountRef.current >= 2) return
|
||||
retryCountRef.current += 1
|
||||
window.setTimeout(() => {
|
||||
setReloadNonce((v) => v + 1)
|
||||
}, 900)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const rawThumb = card.thumb || ''
|
||||
setThumbFailed(false)
|
||||
setThumbSrc(rawThumb)
|
||||
if (!rawThumb) return
|
||||
|
||||
let cancelled = false
|
||||
const loadThumb = async () => {
|
||||
try {
|
||||
const result = await window.electronAPI.sns.proxyImage({
|
||||
url: rawThumb,
|
||||
key: thumbKey
|
||||
})
|
||||
if (cancelled) return
|
||||
if (!result.success) {
|
||||
console.warn('[SnsLinkCard] thumb decrypt failed', {
|
||||
url: rawThumb,
|
||||
key: thumbKey,
|
||||
error: result.error
|
||||
})
|
||||
scheduleRetry()
|
||||
return
|
||||
}
|
||||
if (result.dataUrl) {
|
||||
setThumbSrc(result.dataUrl)
|
||||
return
|
||||
}
|
||||
if (result.videoPath) {
|
||||
setThumbSrc(`file://${result.videoPath.replace(/\\/g, '/')}`)
|
||||
}
|
||||
} catch {
|
||||
// noop: keep raw thumb fallback
|
||||
scheduleRetry()
|
||||
}
|
||||
}
|
||||
|
||||
loadThumb()
|
||||
return () => { cancelled = true }
|
||||
}, [card.thumb, thumbKey, reloadNonce])
|
||||
|
||||
const handleClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation()
|
||||
try {
|
||||
@@ -180,13 +301,31 @@ const SnsLinkCard = ({ card }: { card: SnsLinkCardData }) => {
|
||||
return (
|
||||
<button type="button" className="post-link-card" onClick={handleClick}>
|
||||
<div className="link-thumb">
|
||||
{card.thumb && !thumbFailed ? (
|
||||
{thumbSrc && !thumbFailed ? (
|
||||
<img
|
||||
src={card.thumb}
|
||||
src={thumbSrc}
|
||||
alt=""
|
||||
referrerPolicy="no-referrer"
|
||||
loading="lazy"
|
||||
onError={() => setThumbFailed(true)}
|
||||
onError={() => {
|
||||
const rawThumb = card.thumb || ''
|
||||
if (thumbSrc !== rawThumb && rawThumb) {
|
||||
console.warn('[SnsLinkCard] thumb render failed, fallback raw thumb', {
|
||||
failedSrc: thumbSrc,
|
||||
rawThumb,
|
||||
key: thumbKey
|
||||
})
|
||||
setThumbSrc(rawThumb)
|
||||
return
|
||||
}
|
||||
console.warn('[SnsLinkCard] thumb render failed, fallback exhausted', {
|
||||
failedSrc: thumbSrc,
|
||||
rawThumb,
|
||||
key: thumbKey
|
||||
})
|
||||
setThumbFailed(true)
|
||||
scheduleRetry()
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="link-thumb-fallback">
|
||||
@@ -278,9 +417,11 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const linkCard = buildLinkCardData(post)
|
||||
const linkCardThumbKey = linkCard?.thumbKey || post.media[0]?.key
|
||||
const locationText = useMemo(() => buildLocationText(post.location), [post.location])
|
||||
const hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url))
|
||||
const showLinkCard = Boolean(linkCard) && post.media.length <= 1 && !hasVideoMedia
|
||||
const isLinkCardType = post.type === 3 || post.type === 5
|
||||
const showLinkCard = Boolean(linkCard) && !hasVideoMedia && (isLinkCardType || post.media.length <= 1)
|
||||
const showMediaGrid = post.media.length > 0 && !showLinkCard
|
||||
|
||||
const formatTime = (ts: number) => {
|
||||
@@ -352,7 +493,7 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
|
||||
<Avatar
|
||||
src={post.avatarUrl}
|
||||
name={post.nickname}
|
||||
size={48}
|
||||
size={36}
|
||||
shape="rounded"
|
||||
/>
|
||||
</button>
|
||||
@@ -412,7 +553,7 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
|
||||
)}
|
||||
|
||||
{showLinkCard && linkCard && (
|
||||
<SnsLinkCard card={linkCard} />
|
||||
<SnsLinkCard card={linkCard} thumbKey={linkCardThumbKey} />
|
||||
)}
|
||||
|
||||
{showMediaGrid && (
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
.title-bar {
|
||||
height: 41px;
|
||||
background: var(--bg-secondary);
|
||||
height: 48px;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-right: 8px;
|
||||
-webkit-app-region: drag;
|
||||
flex-shrink: 0;
|
||||
gap: 8px;
|
||||
@@ -14,12 +13,6 @@
|
||||
z-index: 2101;
|
||||
}
|
||||
|
||||
// 繁花如梦:标题栏毛玻璃
|
||||
[data-theme="blossom-dream"] .title-bar {
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.title-brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -33,16 +26,15 @@
|
||||
}
|
||||
|
||||
.titles {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.title-sidebar-toggle {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
@@ -52,11 +44,11 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease, color 0.2s ease;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
-webkit-app-region: no-drag;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
@@ -64,26 +56,26 @@
|
||||
.title-window-controls {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: 2px;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.title-window-control-btn {
|
||||
width: 28px;
|
||||
width: 36px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease, color 0.2s ease;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@@ -107,14 +99,14 @@
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
border-radius: 4px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@@ -124,8 +116,8 @@
|
||||
}
|
||||
|
||||
&.live-play-btn.active {
|
||||
background: rgba(var(--primary-rgb, 76, 132, 255), 0.16);
|
||||
color: var(--primary, #4c84ff);
|
||||
background: var(--primary-light);
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
274
src/pages/AccountManagementPage.scss
Normal file
274
src/pages/AccountManagementPage.scss
Normal file
@@ -0,0 +1,274 @@
|
||||
.account-management-page {
|
||||
padding: 22px 24px;
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.account-management-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 6px 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.account-management-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.account-management-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
background: var(--bg-secondary);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.account-notice {
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
border: 1px solid transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.notice-action {
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.2s ease, background 0.2s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.account-notice.success {
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
color: #15803d;
|
||||
border-color: rgba(34, 197, 94, 0.25);
|
||||
}
|
||||
|
||||
.account-notice.error {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
color: #b91c1c;
|
||||
border-color: rgba(239, 68, 68, 0.25);
|
||||
}
|
||||
|
||||
.account-notice.info {
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
color: #1d4ed8;
|
||||
border-color: rgba(59, 130, 246, 0.25);
|
||||
}
|
||||
|
||||
.account-empty {
|
||||
border: 1px dashed var(--border-color);
|
||||
border-radius: 12px;
|
||||
background: var(--bg-secondary);
|
||||
padding: 18px 14px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.account-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.account-card {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 14px;
|
||||
background: var(--bg-secondary);
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
&.is-current {
|
||||
border-color: color-mix(in srgb, var(--primary) 60%, var(--border-color));
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--primary) 25%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.account-avatar {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--on-primary);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.account-main {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.account-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.account-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-radius: 999px;
|
||||
padding: 1px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
|
||||
&.current {
|
||||
color: #0f766e;
|
||||
background: rgba(20, 184, 166, 0.14);
|
||||
}
|
||||
|
||||
&.ok {
|
||||
color: #166534;
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
}
|
||||
|
||||
&.warn {
|
||||
color: #b45309;
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.account-meta {
|
||||
margin-top: 3px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.meta-tip {
|
||||
margin-left: 6px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.account-card-actions {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
|
||||
.btn {
|
||||
min-width: 104px;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.account-management-footer {
|
||||
margin-top: 2px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.account-management-page {
|
||||
.btn-danger {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
color: #b91c1c;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.account-management-summary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.account-card {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.account-card-actions {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
612
src/pages/AccountManagementPage.tsx
Normal file
612
src/pages/AccountManagementPage.tsx
Normal file
@@ -0,0 +1,612 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { RefreshCw, UserPlus, Trash2, ArrowRightLeft, CheckCircle2, Database } from 'lucide-react'
|
||||
import { useAppStore } from '../stores/appStore'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import { useAnalyticsStore } from '../stores/analyticsStore'
|
||||
import * as configService from '../services/config'
|
||||
import './AccountManagementPage.scss'
|
||||
|
||||
interface ScannedWxidOption {
|
||||
wxid: string
|
||||
modifiedTime: number
|
||||
nickname?: string
|
||||
avatarUrl?: string
|
||||
}
|
||||
|
||||
interface ManagedAccountItem {
|
||||
wxid: string
|
||||
normalizedWxid: string
|
||||
displayName: string
|
||||
avatarUrl?: string
|
||||
modifiedTime?: number
|
||||
configUpdatedAt?: number
|
||||
hasConfig: boolean
|
||||
isCurrent: boolean
|
||||
fromScan: boolean
|
||||
}
|
||||
|
||||
type AccountProfileCacheEntry = {
|
||||
displayName?: string
|
||||
avatarUrl?: string
|
||||
updatedAt?: number
|
||||
}
|
||||
|
||||
interface DeleteUndoState {
|
||||
targetWxid: string
|
||||
deletedConfigEntries: Array<[string, configService.WxidConfig]>
|
||||
deletedProfileEntries: Array<[string, AccountProfileCacheEntry]>
|
||||
previousCurrentWxid: string
|
||||
shouldRestoreAsCurrent: boolean
|
||||
previousDbConnected: boolean
|
||||
}
|
||||
|
||||
type NoticeState =
|
||||
| { type: 'success' | 'error' | 'info'; text: string }
|
||||
| null
|
||||
|
||||
const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1'
|
||||
const ACCOUNT_PROFILES_CACHE_KEY = 'account_profiles_cache_v1'
|
||||
|
||||
const HIDDEN_DELETED_ACCOUNT_NORM_IDS_KEY = 'weflow_account_mgmt_hidden_deleted_norm_v1'
|
||||
|
||||
const readHiddenDeletedAccountNormIds = (): Set<string> => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(HIDDEN_DELETED_ACCOUNT_NORM_IDS_KEY)
|
||||
if (!raw) return new Set()
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!Array.isArray(parsed)) return new Set()
|
||||
return new Set(parsed.filter((x): x is string => typeof x === 'string' && x.length > 0))
|
||||
} catch {
|
||||
return new Set()
|
||||
}
|
||||
}
|
||||
|
||||
const writeHiddenDeletedAccountNormIds = (ids: Set<string>): void => {
|
||||
try {
|
||||
window.localStorage.setItem(HIDDEN_DELETED_ACCOUNT_NORM_IDS_KEY, JSON.stringify(Array.from(ids)))
|
||||
} catch {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
const addHiddenDeletedAccountNormId = (normalized: string): void => {
|
||||
if (!normalized) return
|
||||
const next = readHiddenDeletedAccountNormIds()
|
||||
next.add(normalized)
|
||||
writeHiddenDeletedAccountNormIds(next)
|
||||
}
|
||||
|
||||
const removeHiddenDeletedAccountNormId = (normalized: string): void => {
|
||||
if (!normalized) return
|
||||
const next = readHiddenDeletedAccountNormIds()
|
||||
if (!next.delete(normalized)) return
|
||||
writeHiddenDeletedAccountNormIds(next)
|
||||
}
|
||||
|
||||
const DEFAULT_ACCOUNT_DISPLAY_NAME = '微信用户'
|
||||
|
||||
const normalizeAccountId = (value?: string | null): string => {
|
||||
const trimmed = String(value || '').trim()
|
||||
if (!trimmed) return ''
|
||||
if (trimmed.toLowerCase().startsWith('wxid_')) {
|
||||
const match = trimmed.match(/^(wxid_[^_]+)/i)
|
||||
return match?.[1] || trimmed
|
||||
}
|
||||
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
||||
return suffixMatch ? suffixMatch[1] : trimmed
|
||||
}
|
||||
|
||||
const resolveAccountDisplayName = (
|
||||
candidates: Array<unknown>,
|
||||
wxidCandidates: Set<string>
|
||||
): string => {
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate !== 'string') continue
|
||||
if (candidate.length === 0) continue
|
||||
const normalized = candidate.trim().toLowerCase()
|
||||
if (normalized.startsWith('wxid_')) continue
|
||||
if (normalized && wxidCandidates.has(normalized)) continue
|
||||
return candidate
|
||||
}
|
||||
return DEFAULT_ACCOUNT_DISPLAY_NAME
|
||||
}
|
||||
|
||||
const resolveAccountAvatarText = (displayName?: string): string => {
|
||||
if (typeof displayName !== 'string' || displayName.length === 0) return '微'
|
||||
const visible = displayName.trim()
|
||||
return (visible && [...visible][0]) || '微'
|
||||
}
|
||||
|
||||
const readAccountProfilesCache = (): Record<string, AccountProfileCacheEntry> => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(ACCOUNT_PROFILES_CACHE_KEY)
|
||||
if (!raw) return {}
|
||||
const parsed = JSON.parse(raw)
|
||||
return parsed && typeof parsed === 'object' ? parsed as Record<string, AccountProfileCacheEntry> : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function AccountManagementPage() {
|
||||
const isDbConnected = useAppStore(state => state.isDbConnected)
|
||||
const setDbConnected = useAppStore(state => state.setDbConnected)
|
||||
const resetChatStore = useChatStore(state => state.reset)
|
||||
const clearAnalyticsStoreCache = useAnalyticsStore(state => state.clearCache)
|
||||
|
||||
const [dbPath, setDbPath] = useState('')
|
||||
const [currentWxid, setCurrentWxid] = useState('')
|
||||
const [accounts, setAccounts] = useState<ManagedAccountItem[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [workingWxid, setWorkingWxid] = useState('')
|
||||
const [notice, setNotice] = useState<NoticeState>(null)
|
||||
const [deleteUndoState, setDeleteUndoState] = useState<DeleteUndoState | null>(null)
|
||||
|
||||
const loadAccounts = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const [path, rawCurrentWxid, wxidConfigs] = await Promise.all([
|
||||
configService.getDbPath(),
|
||||
configService.getMyWxid(),
|
||||
configService.getWxidConfigs()
|
||||
])
|
||||
const nextDbPath = String(path || '').trim()
|
||||
const nextCurrentWxid = String(rawCurrentWxid || '').trim()
|
||||
const normalizedCurrent = normalizeAccountId(nextCurrentWxid) || nextCurrentWxid
|
||||
setDbPath(nextDbPath)
|
||||
setCurrentWxid(nextCurrentWxid)
|
||||
|
||||
let scannedWxids: ScannedWxidOption[] = []
|
||||
if (nextDbPath) {
|
||||
try {
|
||||
const scanned = await window.electronAPI.dbPath.scanWxids(nextDbPath)
|
||||
scannedWxids = Array.isArray(scanned) ? scanned as ScannedWxidOption[] : []
|
||||
} catch {
|
||||
scannedWxids = []
|
||||
}
|
||||
}
|
||||
|
||||
const accountProfileCache = readAccountProfilesCache()
|
||||
const configEntries = Object.entries(wxidConfigs || {})
|
||||
const configByNormalized = new Map<string, { key: string; value: configService.WxidConfig }>()
|
||||
for (const [wxid, cfg] of configEntries) {
|
||||
const normalized = normalizeAccountId(wxid) || wxid
|
||||
if (!normalized) continue
|
||||
const previous = configByNormalized.get(normalized)
|
||||
if (!previous || Number(cfg?.updatedAt || 0) > Number(previous.value?.updatedAt || 0)) {
|
||||
configByNormalized.set(normalized, { key: wxid, value: cfg || {} })
|
||||
}
|
||||
}
|
||||
|
||||
const merged = new Map<string, ManagedAccountItem>()
|
||||
for (const scanned of scannedWxids) {
|
||||
const normalized = normalizeAccountId(scanned.wxid) || scanned.wxid
|
||||
if (!normalized) continue
|
||||
const cached = accountProfileCache[scanned.wxid] || accountProfileCache[normalized]
|
||||
const matchedConfig = configByNormalized.get(normalized)
|
||||
const wxidCandidates = new Set<string>([
|
||||
String(scanned.wxid || '').trim().toLowerCase(),
|
||||
String(normalized || '').trim().toLowerCase()
|
||||
].filter(Boolean))
|
||||
const displayName = resolveAccountDisplayName(
|
||||
[scanned.nickname, cached?.displayName],
|
||||
wxidCandidates
|
||||
)
|
||||
merged.set(normalized, {
|
||||
wxid: scanned.wxid,
|
||||
normalizedWxid: normalized,
|
||||
displayName,
|
||||
avatarUrl: scanned.avatarUrl || cached?.avatarUrl,
|
||||
modifiedTime: Number(scanned.modifiedTime || 0),
|
||||
configUpdatedAt: Number(matchedConfig?.value?.updatedAt || 0),
|
||||
hasConfig: Boolean(matchedConfig),
|
||||
isCurrent: Boolean(normalizedCurrent) && normalized === normalizedCurrent,
|
||||
fromScan: true
|
||||
})
|
||||
}
|
||||
|
||||
for (const [normalized, matchedConfig] of configByNormalized.entries()) {
|
||||
if (merged.has(normalized)) continue
|
||||
const wxid = matchedConfig.key
|
||||
const cached = accountProfileCache[wxid] || accountProfileCache[normalized]
|
||||
const wxidCandidates = new Set<string>([
|
||||
String(wxid || '').trim().toLowerCase(),
|
||||
String(normalized || '').trim().toLowerCase()
|
||||
].filter(Boolean))
|
||||
const displayName = resolveAccountDisplayName(
|
||||
[cached?.displayName],
|
||||
wxidCandidates
|
||||
)
|
||||
merged.set(normalized, {
|
||||
wxid,
|
||||
normalizedWxid: normalized,
|
||||
displayName,
|
||||
avatarUrl: cached?.avatarUrl,
|
||||
modifiedTime: 0,
|
||||
configUpdatedAt: Number(matchedConfig.value?.updatedAt || 0),
|
||||
hasConfig: true,
|
||||
isCurrent: Boolean(normalizedCurrent) && normalized === normalizedCurrent,
|
||||
fromScan: false
|
||||
})
|
||||
}
|
||||
|
||||
// 被「删除配置」移除的账号:微信目录仍在扫描结果里会出现无配置条目,持久化隐藏避免误导;
|
||||
// 若后续再次保存该账号配置,则自动恢复展示。
|
||||
const hiddenDeletedNormIds = readHiddenDeletedAccountNormIds()
|
||||
for (const [normalized, item] of Array.from(merged.entries())) {
|
||||
if (!hiddenDeletedNormIds.has(normalized)) continue
|
||||
if (item.hasConfig) {
|
||||
hiddenDeletedNormIds.delete(normalized)
|
||||
writeHiddenDeletedAccountNormIds(hiddenDeletedNormIds)
|
||||
continue
|
||||
}
|
||||
merged.delete(normalized)
|
||||
}
|
||||
|
||||
const nextAccounts = Array.from(merged.values()).sort((a, b) => {
|
||||
if (a.isCurrent && !b.isCurrent) return -1
|
||||
if (!a.isCurrent && b.isCurrent) return 1
|
||||
const scanDiff = Number(b.modifiedTime || 0) - Number(a.modifiedTime || 0)
|
||||
if (scanDiff !== 0) return scanDiff
|
||||
return Number(b.configUpdatedAt || 0) - Number(a.configUpdatedAt || 0)
|
||||
})
|
||||
setAccounts(nextAccounts)
|
||||
} catch (error) {
|
||||
console.error('加载账号列表失败:', error)
|
||||
setNotice({ type: 'error', text: '加载账号列表失败,请稍后重试' })
|
||||
setAccounts([])
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadAccounts()
|
||||
const onWxidChanged = () => { void loadAccounts() }
|
||||
const onWindowFocus = () => { void loadAccounts() }
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
void loadAccounts()
|
||||
}
|
||||
}
|
||||
window.addEventListener('wxid-changed', onWxidChanged as EventListener)
|
||||
window.addEventListener('focus', onWindowFocus)
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
return () => {
|
||||
window.removeEventListener('wxid-changed', onWxidChanged as EventListener)
|
||||
window.removeEventListener('focus', onWindowFocus)
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
}
|
||||
}, [loadAccounts])
|
||||
|
||||
const clearRuntimeCacheState = useCallback(async () => {
|
||||
if (isDbConnected) {
|
||||
await window.electronAPI.chat.close()
|
||||
}
|
||||
window.localStorage.removeItem(SIDEBAR_USER_PROFILE_CACHE_KEY)
|
||||
clearAnalyticsStoreCache()
|
||||
resetChatStore()
|
||||
}, [clearAnalyticsStoreCache, isDbConnected, resetChatStore])
|
||||
|
||||
const applyWxidConfig = useCallback(async (wxid: string, wxidConfig: configService.WxidConfig | null) => {
|
||||
await configService.setMyWxid(wxid)
|
||||
await configService.setDecryptKey(wxidConfig?.decryptKey || '')
|
||||
await configService.setImageXorKey(typeof wxidConfig?.imageXorKey === 'number' ? wxidConfig.imageXorKey : 0)
|
||||
await configService.setImageAesKey(wxidConfig?.imageAesKey || '')
|
||||
}, [])
|
||||
|
||||
const handleSwitchAccount = useCallback(async (wxid: string) => {
|
||||
if (!wxid || workingWxid) return
|
||||
const targetNormalized = normalizeAccountId(wxid) || wxid
|
||||
const currentNormalized = normalizeAccountId(currentWxid) || currentWxid
|
||||
if (targetNormalized && currentNormalized && targetNormalized === currentNormalized) return
|
||||
|
||||
setWorkingWxid(wxid)
|
||||
setNotice(null)
|
||||
setDeleteUndoState(null)
|
||||
try {
|
||||
const allConfigs = await configService.getWxidConfigs()
|
||||
const configEntries = Object.entries(allConfigs || {})
|
||||
const matched = configEntries.find(([key]) => {
|
||||
const normalized = normalizeAccountId(key) || key
|
||||
return key === wxid || normalized === targetNormalized
|
||||
})
|
||||
const targetConfig = matched?.[1] || null
|
||||
await applyWxidConfig(wxid, targetConfig)
|
||||
await clearRuntimeCacheState()
|
||||
window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid } }))
|
||||
setNotice({ type: 'success', text: `已切换到账号「${wxid}」` })
|
||||
await loadAccounts()
|
||||
} catch (error) {
|
||||
console.error('切换账号失败:', error)
|
||||
setNotice({ type: 'error', text: '切换账号失败,请稍后重试' })
|
||||
} finally {
|
||||
setWorkingWxid('')
|
||||
}
|
||||
}, [applyWxidConfig, clearRuntimeCacheState, currentWxid, loadAccounts, workingWxid])
|
||||
|
||||
const handleAddAccount = useCallback(async () => {
|
||||
if (workingWxid) return
|
||||
setNotice(null)
|
||||
setDeleteUndoState(null)
|
||||
try {
|
||||
await window.electronAPI.window.openOnboardingWindow({ mode: 'add-account' })
|
||||
await loadAccounts()
|
||||
const latestWxid = String(await configService.getMyWxid() || '').trim()
|
||||
window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: latestWxid } }))
|
||||
} catch (error) {
|
||||
console.error('打开添加账号引导失败:', error)
|
||||
setNotice({ type: 'error', text: '打开添加账号引导失败,请稍后重试' })
|
||||
}
|
||||
}, [loadAccounts, workingWxid])
|
||||
|
||||
const handleDeleteAccountConfig = useCallback(async (targetWxid: string) => {
|
||||
if (!targetWxid || workingWxid) return
|
||||
|
||||
const normalizedTarget = normalizeAccountId(targetWxid) || targetWxid
|
||||
|
||||
setWorkingWxid(targetWxid)
|
||||
setNotice(null)
|
||||
setDeleteUndoState(null)
|
||||
try {
|
||||
const allConfigs = await configService.getWxidConfigs()
|
||||
const nextConfigs: Record<string, configService.WxidConfig> = { ...allConfigs }
|
||||
const matchedKeys = Object.keys(nextConfigs).filter((key) => {
|
||||
const normalized = normalizeAccountId(key) || key
|
||||
return key === targetWxid || normalized === normalizedTarget
|
||||
})
|
||||
|
||||
if (matchedKeys.length === 0) {
|
||||
setNotice({ type: 'info', text: `账号「${targetWxid}」暂无可删除配置` })
|
||||
return
|
||||
}
|
||||
|
||||
const deletedConfigEntries: Array<[string, configService.WxidConfig]> = matchedKeys.map((key) => [key, nextConfigs[key] || {}])
|
||||
for (const key of matchedKeys) {
|
||||
delete nextConfigs[key]
|
||||
}
|
||||
await configService.setWxidConfigs(nextConfigs)
|
||||
|
||||
const accountProfileCache = readAccountProfilesCache()
|
||||
const deletedProfileEntries: Array<[string, AccountProfileCacheEntry]> = []
|
||||
for (const key of Object.keys(accountProfileCache)) {
|
||||
const normalized = normalizeAccountId(key) || key
|
||||
if (key === targetWxid || normalized === normalizedTarget) {
|
||||
deletedProfileEntries.push([key, accountProfileCache[key]])
|
||||
delete accountProfileCache[key]
|
||||
}
|
||||
}
|
||||
window.localStorage.setItem(ACCOUNT_PROFILES_CACHE_KEY, JSON.stringify(accountProfileCache))
|
||||
|
||||
const currentNormalized = normalizeAccountId(currentWxid) || currentWxid
|
||||
const isDeletingCurrent = Boolean(currentNormalized && currentNormalized === normalizedTarget)
|
||||
const undoPayload: DeleteUndoState = {
|
||||
targetWxid,
|
||||
deletedConfigEntries,
|
||||
deletedProfileEntries,
|
||||
previousCurrentWxid: currentWxid,
|
||||
shouldRestoreAsCurrent: isDeletingCurrent,
|
||||
previousDbConnected: isDbConnected
|
||||
}
|
||||
|
||||
if (isDeletingCurrent) {
|
||||
await clearRuntimeCacheState()
|
||||
|
||||
const remainingEntries = Object.entries(nextConfigs)
|
||||
.filter(([wxid]) => Boolean(String(wxid || '').trim()))
|
||||
.sort((a, b) => Number(b[1]?.updatedAt || 0) - Number(a[1]?.updatedAt || 0))
|
||||
|
||||
if (remainingEntries.length > 0) {
|
||||
const [nextWxid, nextConfig] = remainingEntries[0]
|
||||
await applyWxidConfig(nextWxid, nextConfig || null)
|
||||
window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: nextWxid } }))
|
||||
addHiddenDeletedAccountNormId(normalizedTarget)
|
||||
setDeleteUndoState(undoPayload)
|
||||
setNotice({ type: 'success', text: `已删除「${targetWxid}」配置,并切换到「${nextWxid}」` })
|
||||
await loadAccounts()
|
||||
return
|
||||
}
|
||||
|
||||
await configService.setMyWxid('')
|
||||
await configService.setDecryptKey('')
|
||||
await configService.setImageXorKey(0)
|
||||
await configService.setImageAesKey('')
|
||||
setDbConnected(false)
|
||||
window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: '' } }))
|
||||
addHiddenDeletedAccountNormId(normalizedTarget)
|
||||
setDeleteUndoState(undoPayload)
|
||||
setNotice({ type: 'info', text: `已删除「${targetWxid}」配置,当前无可用账号配置,可撤回或添加账号` })
|
||||
await loadAccounts()
|
||||
return
|
||||
}
|
||||
|
||||
addHiddenDeletedAccountNormId(normalizedTarget)
|
||||
setDeleteUndoState(undoPayload)
|
||||
setNotice({ type: 'success', text: `已删除账号「${targetWxid}」配置` })
|
||||
await loadAccounts()
|
||||
} catch (error) {
|
||||
console.error('删除账号配置失败:', error)
|
||||
setNotice({ type: 'error', text: '删除账号配置失败,请稍后重试' })
|
||||
} finally {
|
||||
setWorkingWxid('')
|
||||
}
|
||||
}, [applyWxidConfig, clearRuntimeCacheState, currentWxid, isDbConnected, loadAccounts, setDbConnected, workingWxid])
|
||||
|
||||
const handleUndoDelete = useCallback(async () => {
|
||||
if (!deleteUndoState || workingWxid) return
|
||||
|
||||
setWorkingWxid(`undo:${deleteUndoState.targetWxid}`)
|
||||
setNotice(null)
|
||||
try {
|
||||
const currentConfigs = await configService.getWxidConfigs()
|
||||
const restoredConfigs: Record<string, configService.WxidConfig> = { ...currentConfigs }
|
||||
for (const [key, configValue] of deleteUndoState.deletedConfigEntries) {
|
||||
restoredConfigs[key] = configValue || {}
|
||||
}
|
||||
await configService.setWxidConfigs(restoredConfigs)
|
||||
removeHiddenDeletedAccountNormId(normalizeAccountId(deleteUndoState.targetWxid) || deleteUndoState.targetWxid)
|
||||
|
||||
const accountProfileCache = readAccountProfilesCache()
|
||||
for (const [key, profile] of deleteUndoState.deletedProfileEntries) {
|
||||
accountProfileCache[key] = profile
|
||||
}
|
||||
window.localStorage.setItem(ACCOUNT_PROFILES_CACHE_KEY, JSON.stringify(accountProfileCache))
|
||||
|
||||
if (deleteUndoState.shouldRestoreAsCurrent && deleteUndoState.previousCurrentWxid) {
|
||||
const previousNormalized = normalizeAccountId(deleteUndoState.previousCurrentWxid) || deleteUndoState.previousCurrentWxid
|
||||
const restoreConfigEntry = Object.entries(restoredConfigs)
|
||||
.filter(([key]) => {
|
||||
const normalized = normalizeAccountId(key) || key
|
||||
return key === deleteUndoState.previousCurrentWxid || normalized === previousNormalized
|
||||
})
|
||||
.sort((a, b) => Number(b[1]?.updatedAt || 0) - Number(a[1]?.updatedAt || 0))[0]
|
||||
const restoreConfig = restoreConfigEntry?.[1] || null
|
||||
|
||||
await clearRuntimeCacheState()
|
||||
await applyWxidConfig(deleteUndoState.previousCurrentWxid, restoreConfig)
|
||||
if (deleteUndoState.previousDbConnected) {
|
||||
setDbConnected(true, dbPath || undefined)
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: deleteUndoState.previousCurrentWxid } }))
|
||||
}
|
||||
|
||||
setNotice({ type: 'success', text: `已撤回删除,账号「${deleteUndoState.targetWxid}」配置已恢复` })
|
||||
setDeleteUndoState(null)
|
||||
await loadAccounts()
|
||||
} catch (error) {
|
||||
console.error('撤回删除失败:', error)
|
||||
setNotice({ type: 'error', text: '撤回删除失败,请稍后重试' })
|
||||
} finally {
|
||||
setWorkingWxid('')
|
||||
}
|
||||
}, [applyWxidConfig, clearRuntimeCacheState, dbPath, deleteUndoState, loadAccounts, setDbConnected, workingWxid])
|
||||
|
||||
const currentAccountLabel = useMemo(() => {
|
||||
if (!currentWxid) return '未设置'
|
||||
return currentWxid
|
||||
}, [currentWxid])
|
||||
|
||||
const formatTime = (value?: number): string => {
|
||||
const ts = Number(value || 0)
|
||||
if (!ts) return '未知'
|
||||
const date = new Date(ts)
|
||||
if (Number.isNaN(date.getTime())) return '未知'
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hour = String(date.getHours()).padStart(2, '0')
|
||||
const minute = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hour}:${minute}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="account-management-page">
|
||||
<header className="account-management-header">
|
||||
<div>
|
||||
<h2>账号管理</h2>
|
||||
<p>统一管理切换账号、添加账号、删除账号配置。</p>
|
||||
</div>
|
||||
<div className="account-management-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => void loadAccounts()} disabled={isLoading || Boolean(workingWxid)}>
|
||||
<RefreshCw size={16} /> {isLoading ? '刷新中...' : '刷新'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" onClick={handleAddAccount} disabled={Boolean(workingWxid)}>
|
||||
<UserPlus size={16} /> 添加账号
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="account-management-summary">
|
||||
<div className="summary-item">
|
||||
<span className="summary-label">数据库目录</span>
|
||||
<span className="summary-value">{dbPath || '未配置'}</span>
|
||||
</div>
|
||||
<div className="summary-item">
|
||||
<span className="summary-label">当前账号</span>
|
||||
<span className="summary-value">{currentAccountLabel}</span>
|
||||
</div>
|
||||
<div className="summary-item">
|
||||
<span className="summary-label">账号数量</span>
|
||||
<span className="summary-value">{accounts.length}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{notice && (
|
||||
<div className={`account-notice ${notice.type}`}>
|
||||
<span>{notice.text}</span>
|
||||
{deleteUndoState && (notice.type === 'success' || notice.type === 'info') && (
|
||||
<button
|
||||
type="button"
|
||||
className="notice-action"
|
||||
onClick={() => void handleUndoDelete()}
|
||||
disabled={Boolean(workingWxid)}
|
||||
>
|
||||
撤回
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{accounts.length === 0 ? (
|
||||
<div className="account-empty">
|
||||
<Database size={20} />
|
||||
<span>未发现可管理账号,请先添加账号或检查数据库目录。</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="account-list">
|
||||
{accounts.map((account) => (
|
||||
<article key={account.normalizedWxid} className={`account-card ${account.isCurrent ? 'is-current' : ''}`}>
|
||||
<div className="account-avatar">
|
||||
{account.avatarUrl ? <img src={account.avatarUrl} alt="" /> : <span>{resolveAccountAvatarText(account.displayName)}</span>}
|
||||
</div>
|
||||
<div className="account-main">
|
||||
<div className="account-title-row">
|
||||
<h3>{account.displayName}</h3>
|
||||
{account.isCurrent && (
|
||||
<span className="account-badge current">
|
||||
<CheckCircle2 size={12} /> 当前
|
||||
</span>
|
||||
)}
|
||||
{account.hasConfig ? (
|
||||
<span className="account-badge ok">已保存配置</span>
|
||||
) : (
|
||||
<span className="account-badge warn">未保存配置</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="account-meta">wxid: {account.wxid}</div>
|
||||
<div className="account-meta">
|
||||
最近数据更新时间: {formatTime(account.modifiedTime)} · 配置更新时间: {formatTime(account.configUpdatedAt)}
|
||||
{!account.fromScan && <span className="meta-tip">(仅配置记录)</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="account-card-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => void handleSwitchAccount(account.wxid)}
|
||||
disabled={Boolean(workingWxid) || account.isCurrent || !account.hasConfig || !account.fromScan}
|
||||
>
|
||||
<ArrowRightLeft size={14} /> {account.isCurrent ? '当前账号' : (!account.hasConfig ? '无配置' : (account.fromScan ? '切换' : '无数据'))}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
onClick={() => void handleDeleteAccountConfig(account.wxid)}
|
||||
disabled={Boolean(workingWxid) || !account.hasConfig}
|
||||
>
|
||||
<Trash2 size={14} /> 删除配置
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<footer className="account-management-footer">
|
||||
删除仅影响 WeFlow 本地配置,不会删除微信原始数据文件。
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccountManagementPage
|
||||
@@ -10,7 +10,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 加载和错误状态
|
||||
// Loading and error states
|
||||
.loading-container,
|
||||
.error-container {
|
||||
display: flex;
|
||||
@@ -23,7 +23,7 @@
|
||||
color: var(--text-secondary);
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
animation: analyticsSpin 1s linear infinite;
|
||||
}
|
||||
|
||||
p.loading-status {
|
||||
@@ -33,13 +33,12 @@
|
||||
}
|
||||
|
||||
.progress-bar-wrapper {
|
||||
width: 300px;
|
||||
height: 8px;
|
||||
width: 280px;
|
||||
height: 4px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.progress-bar-fill {
|
||||
@@ -47,9 +46,9 @@
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
background: var(--primary-gradient);
|
||||
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 0 10px rgba(139, 115, 85, 0.3);
|
||||
background: var(--primary);
|
||||
transition: width 0.3s ease;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.progress-percent {
|
||||
@@ -65,57 +64,82 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
@keyframes analyticsSpin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
// Page scroll content
|
||||
.page-scroll {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.page-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 统计卡片
|
||||
// Stats overview cards
|
||||
.stats-overview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
gap: 14px;
|
||||
padding: 18px 16px;
|
||||
background: var(--card-bg);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
|
||||
.stat-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--primary-light);
|
||||
border-radius: 12px;
|
||||
border-radius: 10px;
|
||||
color: var(--primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
gap: 2px;
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
@@ -125,23 +149,23 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 24px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
|
||||
svg {
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Charts
|
||||
.charts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
@@ -155,30 +179,30 @@
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 16px;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
}
|
||||
|
||||
// Rankings
|
||||
.rankings-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.ranking-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-primary);
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
transition: background 0.2s;
|
||||
transition: background 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.rank {
|
||||
@@ -196,13 +220,13 @@
|
||||
|
||||
&.top {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
color: var(--on-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.contact-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
|
||||
@@ -228,8 +252,8 @@
|
||||
position: absolute;
|
||||
right: -4px;
|
||||
bottom: -4px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -239,24 +263,21 @@
|
||||
&.medal-1 {
|
||||
background: linear-gradient(135deg, #ffd700, #ffb800);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 4px rgba(255, 184, 0, 0.4);
|
||||
}
|
||||
|
||||
&.medal-2 {
|
||||
background: linear-gradient(135deg, #c0c0c0, #a8a8a8);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 4px rgba(168, 168, 168, 0.4);
|
||||
}
|
||||
|
||||
&.medal-3 {
|
||||
background: linear-gradient(135deg, #cd7f32, #b87333);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 4px rgba(184, 115, 51, 0.4);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -265,7 +286,7 @@
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
|
||||
.contact-name {
|
||||
@@ -284,14 +305,14 @@
|
||||
}
|
||||
|
||||
.message-count {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式
|
||||
// Responsive
|
||||
@media (max-width: 1200px) {
|
||||
.stats-overview {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
@@ -312,11 +333,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 排除好友弹窗
|
||||
// Exclude friends modal
|
||||
.exclude-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -325,13 +346,13 @@
|
||||
}
|
||||
|
||||
.exclude-modal {
|
||||
width: 560px;
|
||||
width: 520px;
|
||||
max-width: calc(100vw - 48px);
|
||||
background: var(--card-bg);
|
||||
background: var(--bg-secondary-solid, var(--bg-secondary));
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 20px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
|
||||
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.2);
|
||||
|
||||
.exclude-modal-header {
|
||||
display: flex;
|
||||
@@ -342,6 +363,7 @@
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
@@ -349,14 +371,14 @@
|
||||
.modal-close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: var(--bg-tertiary);
|
||||
background: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
color: var(--text-tertiary);
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
@@ -370,7 +392,7 @@
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
margin-bottom: 12px;
|
||||
@@ -399,7 +421,7 @@
|
||||
}
|
||||
|
||||
.exclude-modal-body {
|
||||
max-height: 420px;
|
||||
max-height: 380px;
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
@@ -419,7 +441,7 @@
|
||||
.exclude-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.exclude-item {
|
||||
@@ -427,23 +449,23 @@
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.15s;
|
||||
background: var(--bg-primary);
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: rgba(7, 193, 96, 0.4);
|
||||
background: rgba(7, 193, 96, 0.08);
|
||||
border-color: rgba(16, 163, 127, 0.3);
|
||||
background: rgba(16, 163, 127, 0.06);
|
||||
}
|
||||
|
||||
input {
|
||||
margin: 0;
|
||||
accent-color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -455,7 +477,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.exclude-name {
|
||||
@@ -479,7 +501,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 16px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.exclude-footer-left {
|
||||
|
||||
@@ -1,146 +1,116 @@
|
||||
.analytics-entry-page {
|
||||
.analytics-welcome-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.analytics-welcome-container {
|
||||
.analytics-welcome-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 0;
|
||||
padding: 40px;
|
||||
background: var(--bg-primary);
|
||||
padding: 40px 24px;
|
||||
animation: welcomeFadeIn 0.4s ease-out;
|
||||
}
|
||||
|
||||
.analytics-welcome-content {
|
||||
text-align: center;
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.analytics-welcome-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
margin: 0 auto 20px;
|
||||
background: var(--primary-light);
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.analytics-welcome-content h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 10px;
|
||||
color: var(--text-primary);
|
||||
animation: fadeIn 0.4s ease-out;
|
||||
overflow-y: auto;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
&.analytics-welcome-container--mode {
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--border-color);
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(7, 193, 96, 0.06), transparent 48%),
|
||||
var(--bg-primary);
|
||||
.analytics-welcome-content p {
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 32px;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.analytics-welcome-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.analytics-welcome-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 16px 18px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: var(--text-secondary);
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--text-tertiary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.welcome-content {
|
||||
text-align: center;
|
||||
max-width: 600px;
|
||||
|
||||
.icon-wrapper {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin: 0 auto 24px;
|
||||
background: rgba(7, 193, 96, 0.1);
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #07c160;
|
||||
|
||||
svg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
margin-bottom: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 40px;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.action-cards {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 30px 20px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-align: center;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
border-color: #07c160;
|
||||
box-shadow: 0 4px 12px rgba(7, 193, 96, 0.1);
|
||||
|
||||
.card-icon {
|
||||
color: #07c160;
|
||||
background: rgba(7, 193, 96, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
filter: grayscale(100%);
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 12px;
|
||||
background: var(--bg-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
}
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.analytics-welcome-container {
|
||||
padding: 28px 18px;
|
||||
.analytics-welcome-card-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.welcome-content {
|
||||
.action-cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.analytics-welcome-card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.analytics-welcome-card-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 540px) {
|
||||
.analytics-welcome-actions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
@keyframes welcomeFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
|
||||
@@ -6,12 +6,6 @@ import './AnalyticsWelcomePage.scss'
|
||||
|
||||
function AnalyticsWelcomePage() {
|
||||
const navigate = useNavigate()
|
||||
// 检查是否有任何缓存数据加载或基本的存储状态表明它已准备好。
|
||||
// 实际上,如果 store 没有持久化,`isLoaded` 可能会在应用刷新时重置。
|
||||
// 如果用户点击“加载缓存”但缓存为空,AnalyticsPage 的逻辑(loadData 不带 force)将尝试从后端缓存加载。
|
||||
// 如果后端缓存也为空,则会重新计算。
|
||||
|
||||
// 我们也可以检查 `lastLoadTime` 来显示“上次更新:xxx”(如果已持久化)。
|
||||
const { lastLoadTime } = useAnalyticsStore()
|
||||
|
||||
const handleLoadCache = () => {
|
||||
@@ -28,35 +22,37 @@ function AnalyticsWelcomePage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="analytics-entry-page">
|
||||
<div className="analytics-welcome-shell">
|
||||
<ChatAnalysisHeader currentMode="private" />
|
||||
|
||||
<div className="analytics-welcome-container analytics-welcome-container--mode">
|
||||
<div className="welcome-content">
|
||||
<div className="icon-wrapper">
|
||||
<BarChart2 size={40} />
|
||||
<div className="analytics-welcome-body">
|
||||
<div className="analytics-welcome-content">
|
||||
<div className="analytics-welcome-icon">
|
||||
<BarChart2 size={32} />
|
||||
</div>
|
||||
<h1>私聊数据分析</h1>
|
||||
<p>
|
||||
WeFlow 可以分析你的好友聊天记录,生成详细的统计报表。<br />
|
||||
你可以选择加载上次的分析结果,或者重新开始一次新的私聊分析。
|
||||
分析你的好友聊天记录,生成详细统计报表。<br />
|
||||
选择加载上次结果或开始新分析。
|
||||
</p>
|
||||
|
||||
<div className="action-cards">
|
||||
<button onClick={handleLoadCache}>
|
||||
<div className="card-icon">
|
||||
<History size={24} />
|
||||
<div className="analytics-welcome-actions">
|
||||
<button className="analytics-welcome-card" onClick={handleLoadCache} type="button">
|
||||
<History size={20} />
|
||||
<div className="analytics-welcome-card-text">
|
||||
<span className="analytics-welcome-card-title">加载缓存</span>
|
||||
<span className="analytics-welcome-card-meta">
|
||||
上次更新: {formatLastTime(lastLoadTime)}
|
||||
</span>
|
||||
</div>
|
||||
<h3>加载缓存</h3>
|
||||
<span>查看上次分析结果<br />(上次更新: {formatLastTime(lastLoadTime)})</span>
|
||||
</button>
|
||||
|
||||
<button onClick={handleNewAnalysis}>
|
||||
<div className="card-icon">
|
||||
<RefreshCcw size={24} />
|
||||
<button className="analytics-welcome-card" onClick={handleNewAnalysis} type="button">
|
||||
<RefreshCcw size={20} />
|
||||
<div className="analytics-welcome-card-text">
|
||||
<span className="analytics-welcome-card-title">新的分析</span>
|
||||
<span className="analytics-welcome-card-meta">重新扫描并计算数据</span>
|
||||
</div>
|
||||
<h3>新的分析</h3>
|
||||
<span>重新扫描并计算数据<br />(可能需要几分钟)</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
.annual-report-page {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -6,6 +7,12 @@
|
||||
min-height: 100%;
|
||||
text-align: center;
|
||||
padding: 40px 24px;
|
||||
animation: reportFadeIn 0.35s ease-out;
|
||||
}
|
||||
|
||||
.annual-report-page.report-route-transitioning > :not(.report-launch-overlay) {
|
||||
animation: report-page-exit 420ms cubic-bezier(0.4, 0, 0.2, 1) both;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
@@ -14,40 +21,43 @@
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 32px;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 12px;
|
||||
margin: 0 0 10px;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
font-size: 15px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 48px;
|
||||
margin: 0 0 40px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.page-desc.load-summary {
|
||||
margin: 0 0 28px;
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
|
||||
.page-desc.load-summary.complete {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
// ---- Load telemetry ----
|
||||
.load-telemetry {
|
||||
width: min(760px, 100%);
|
||||
padding: 12px 14px;
|
||||
margin: 0 0 28px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent);
|
||||
background: color-mix(in srgb, var(--card-bg) 92%, transparent);
|
||||
width: min(620px, 100%);
|
||||
padding: 12px 16px;
|
||||
margin: 0 0 24px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--card-bg);
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
|
||||
p {
|
||||
margin: 4px 0;
|
||||
margin: 3px 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
@@ -56,31 +66,32 @@
|
||||
}
|
||||
|
||||
.load-telemetry.loading {
|
||||
border-color: color-mix(in srgb, var(--primary) 30%, var(--border-color));
|
||||
border-color: color-mix(in srgb, var(--primary) 25%, var(--border-color));
|
||||
}
|
||||
|
||||
.load-telemetry.complete {
|
||||
border-color: color-mix(in srgb, var(--primary) 40%, var(--border-color));
|
||||
border-color: color-mix(in srgb, var(--primary) 35%, var(--border-color));
|
||||
}
|
||||
|
||||
.load-telemetry.compact {
|
||||
margin: 12px 0 0;
|
||||
width: min(560px, 100%);
|
||||
width: min(500px, 100%);
|
||||
}
|
||||
|
||||
// ---- Report sections ----
|
||||
.report-sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
width: min(760px, 100%);
|
||||
gap: 20px;
|
||||
width: min(620px, 100%);
|
||||
}
|
||||
|
||||
.report-section {
|
||||
width: 100%;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 20px;
|
||||
padding: 28px;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@@ -89,57 +100,57 @@
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.section-desc {
|
||||
margin: 8px 0 0;
|
||||
font-size: 14px;
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.section-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
gap: 5px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||
background: var(--primary-light);
|
||||
color: var(--primary);
|
||||
border: 1px solid color-mix(in srgb, var(--primary) 30%, transparent);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.section-hint {
|
||||
margin: 12px 0 0;
|
||||
margin: 10px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
// ---- Year cards ----
|
||||
.year-grid-with-status {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.year-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
max-width: 600px;
|
||||
margin-bottom: 48px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.report-section .year-grid {
|
||||
@@ -163,7 +174,7 @@
|
||||
}
|
||||
|
||||
.year-load-status.complete {
|
||||
color: color-mix(in srgb, var(--primary) 80%, var(--text-secondary));
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.dot-ellipsis {
|
||||
@@ -181,27 +192,33 @@
|
||||
}
|
||||
|
||||
.year-card {
|
||||
width: 120px;
|
||||
height: 100px;
|
||||
width: 88px;
|
||||
height: 64px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--card-bg);
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.15s ease;
|
||||
gap: 2px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
|
||||
border-color: var(--text-tertiary);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
pointer-events: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: var(--primary);
|
||||
background: var(--primary-light);
|
||||
box-shadow: 0 0 0 1px var(--primary);
|
||||
|
||||
.year-number {
|
||||
color: var(--primary);
|
||||
@@ -209,60 +226,100 @@
|
||||
}
|
||||
|
||||
.year-number {
|
||||
font-size: 32px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.year-label {
|
||||
font-size: 14px;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Generate button ----
|
||||
.generate-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 16px 40px;
|
||||
background: linear-gradient(135deg, var(--primary) 0%, color-mix(in srgb, var(--primary) 80%, #000) 100%);
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 12px 24px;
|
||||
background: var(--primary);
|
||||
border: none;
|
||||
border-radius: 50px;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
border-radius: 10px;
|
||||
color: var(--on-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 4px 16px color-mix(in srgb, var(--primary) 30%, transparent);
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px color-mix(in srgb, var(--primary) 40%, transparent);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&.is-pending {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&.secondary {
|
||||
background: var(--card-bg);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: none;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Launch overlay ----
|
||||
.report-launch-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: color-mix(in srgb, var(--bg-primary) 80%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
animation: report-launch-overlay-in 350ms ease-out both;
|
||||
}
|
||||
|
||||
.launch-core {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
text-align: center;
|
||||
color: var(--text-primary);
|
||||
animation: report-launch-core-in 350ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
||||
}
|
||||
|
||||
.launch-title {
|
||||
margin: 4px 0 0;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.launch-subtitle {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
// ---- Animations ----
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
@@ -271,3 +328,43 @@
|
||||
@keyframes dot-ellipsis {
|
||||
to { width: 1.4em; }
|
||||
}
|
||||
|
||||
@keyframes report-page-exit {
|
||||
from {
|
||||
opacity: 1;
|
||||
filter: blur(0);
|
||||
transform: scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
filter: blur(6px);
|
||||
transform: scale(0.985);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes report-launch-overlay-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes report-launch-core-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(14px) scale(0.97);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes reportFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Calendar, Loader2, Sparkles, Users } from 'lucide-react'
|
||||
import {
|
||||
@@ -25,6 +25,8 @@ type YearsLoadPayload = {
|
||||
nativeTimedOut?: boolean
|
||||
}
|
||||
|
||||
const REPORT_LAUNCH_DELAY_MS = 420
|
||||
|
||||
const formatLoadElapsed = (ms: number) => {
|
||||
const totalSeconds = Math.max(0, ms) / 1000
|
||||
if (totalSeconds < 60) return `${totalSeconds.toFixed(1)}s`
|
||||
@@ -50,7 +52,10 @@ function AnnualReportPage() {
|
||||
const [hasSwitchedStrategy, setHasSwitchedStrategy] = useState(false)
|
||||
const [nativeTimedOut, setNativeTimedOut] = useState(false)
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
const [isRouteTransitioning, setIsRouteTransitioning] = useState(false)
|
||||
const [launchingYearLabel, setLaunchingYearLabel] = useState('')
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const launchTimerRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false
|
||||
@@ -186,21 +191,37 @@ function AnnualReportPage() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleGenerateReport = async () => {
|
||||
if (selectedYear === null) return
|
||||
setIsGenerating(true)
|
||||
try {
|
||||
const yearParam = selectedYear === 'all' ? 0 : selectedYear
|
||||
navigate(`/annual-report/view?year=${yearParam}`)
|
||||
} catch (e) {
|
||||
console.error('生成报告失败:', e)
|
||||
} finally {
|
||||
setIsGenerating(false)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (launchTimerRef.current !== null) {
|
||||
window.clearTimeout(launchTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleGenerateReport = () => {
|
||||
if (selectedYear === null || isRouteTransitioning) return
|
||||
const yearParam = selectedYear === 'all' ? 0 : selectedYear
|
||||
const yearLabel = selectedYear === 'all' ? '全部时间' : `${selectedYear}年`
|
||||
setIsGenerating(true)
|
||||
setIsRouteTransitioning(true)
|
||||
setLaunchingYearLabel(yearLabel)
|
||||
if (launchTimerRef.current !== null) {
|
||||
window.clearTimeout(launchTimerRef.current)
|
||||
}
|
||||
launchTimerRef.current = window.setTimeout(() => {
|
||||
try {
|
||||
navigate(`/annual-report/view?year=${yearParam}`)
|
||||
} catch (e) {
|
||||
console.error('生成报告失败:', e)
|
||||
setIsGenerating(false)
|
||||
setIsRouteTransitioning(false)
|
||||
}
|
||||
}, REPORT_LAUNCH_DELAY_MS)
|
||||
}
|
||||
|
||||
const handleGenerateDualReport = () => {
|
||||
if (selectedPairYear === null) return
|
||||
if (selectedPairYear === null || isRouteTransitioning) return
|
||||
const yearParam = selectedPairYear === 'all' ? 0 : selectedPairYear
|
||||
navigate(`/dual-report?year=${yearParam}`)
|
||||
}
|
||||
@@ -251,7 +272,7 @@ function AnnualReportPage() {
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="annual-report-page">
|
||||
<div className={`annual-report-page ${isRouteTransitioning ? 'report-route-transitioning' : ''}`}>
|
||||
<Sparkles size={32} className="header-icon" />
|
||||
<h1 className="page-title">年度报告</h1>
|
||||
<p className="page-desc">选择年份,回顾你在微信里的点点滴滴</p>
|
||||
@@ -270,8 +291,11 @@ function AnnualReportPage() {
|
||||
{yearOptions.map(option => (
|
||||
<div
|
||||
key={option}
|
||||
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedYear === option ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedYear(option)}
|
||||
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedYear === option ? 'selected' : ''} ${isRouteTransitioning ? 'disabled' : ''}`}
|
||||
onClick={() => {
|
||||
if (isRouteTransitioning) return
|
||||
setSelectedYear(option)
|
||||
}}
|
||||
>
|
||||
<span className="year-number">{option === 'all' ? '全部' : option}</span>
|
||||
<span className="year-label">{option === 'all' ? '时间' : '年'}</span>
|
||||
@@ -281,14 +305,14 @@ function AnnualReportPage() {
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="generate-btn"
|
||||
className={`generate-btn ${isRouteTransitioning ? 'is-pending' : ''}`}
|
||||
onClick={handleGenerateReport}
|
||||
disabled={!selectedYear || isGenerating}
|
||||
disabled={!selectedYear || isGenerating || isRouteTransitioning}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<Loader2 size={20} className="spin" />
|
||||
<span>正在生成...</span>
|
||||
<span>{isRouteTransitioning ? '正在进入报告...' : '正在生成...'}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -316,8 +340,11 @@ function AnnualReportPage() {
|
||||
{yearOptions.map(option => (
|
||||
<div
|
||||
key={`pair-${option}`}
|
||||
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedPairYear === option ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedPairYear(option)}
|
||||
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedPairYear === option ? 'selected' : ''} ${isRouteTransitioning ? 'disabled' : ''}`}
|
||||
onClick={() => {
|
||||
if (isRouteTransitioning) return
|
||||
setSelectedPairYear(option)
|
||||
}}
|
||||
>
|
||||
<span className="year-number">{option === 'all' ? '全部' : option}</span>
|
||||
<span className="year-label">{option === 'all' ? '时间' : '年'}</span>
|
||||
@@ -327,9 +354,9 @@ function AnnualReportPage() {
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="generate-btn secondary"
|
||||
className={`generate-btn secondary ${isRouteTransitioning ? 'is-pending' : ''}`}
|
||||
onClick={handleGenerateDualReport}
|
||||
disabled={!selectedPairYear}
|
||||
disabled={!selectedPairYear || isRouteTransitioning}
|
||||
>
|
||||
<Users size={20} />
|
||||
<span>选择好友并生成报告</span>
|
||||
@@ -337,6 +364,16 @@ function AnnualReportPage() {
|
||||
<p className="section-hint">从聊天排行中选择好友生成双人报告</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{isRouteTransitioning && (
|
||||
<div className="report-launch-overlay" role="status" aria-live="polite">
|
||||
<div className="launch-core">
|
||||
<Loader2 size={30} className="spin" />
|
||||
<p className="launch-title">正在进入{launchingYearLabel}年度报告</p>
|
||||
<p className="launch-subtitle">正在整理你的聊天记忆...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user