Merge branch 'hicccc77:main' into main
This commit is contained in:
43 files changed
+2966
-1732
No files matched your search
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
retry_cmd() {
|
||||
local attempts="$1"
|
||||
local delay_seconds="$2"
|
||||
shift 2
|
||||
|
||||
local i
|
||||
local exit_code
|
||||
for ((i = 1; i <= attempts; i++)); do
|
||||
if "$@"; then
|
||||
return 0
|
||||
fi
|
||||
exit_code=$?
|
||||
if [ "$i" -lt "$attempts" ]; then
|
||||
echo "Command failed (attempt $i/$attempts, exit=$exit_code): $*" >&2
|
||||
echo "Retrying in ${delay_seconds}s..." >&2
|
||||
sleep "$delay_seconds"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Command failed after $attempts attempts: $*" >&2
|
||||
return "$exit_code"
|
||||
}
|
||||
|
||||
capture_cmd_with_retry() {
|
||||
local result_var="$1"
|
||||
local attempts="$2"
|
||||
local delay_seconds="$3"
|
||||
shift 3
|
||||
|
||||
local i
|
||||
local output=""
|
||||
local exit_code=1
|
||||
for ((i = 1; i <= attempts; i++)); do
|
||||
if output="$("$@" 2>/dev/null)"; then
|
||||
printf -v "$result_var" "%s" "$output"
|
||||
return 0
|
||||
fi
|
||||
exit_code=$?
|
||||
if [ "$i" -lt "$attempts" ]; then
|
||||
echo "Capture command failed (attempt $i/$attempts, exit=$exit_code): $*" >&2
|
||||
echo "Retrying in ${delay_seconds}s..." >&2
|
||||
sleep "$delay_seconds"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Capture command failed after $attempts attempts: $*" >&2
|
||||
return "$exit_code"
|
||||
}
|
||||
|
||||
wait_for_release_id() {
|
||||
local repo="$1"
|
||||
local tag="$2"
|
||||
local attempts="${3:-12}"
|
||||
local delay_seconds="${4:-2}"
|
||||
|
||||
local i
|
||||
local release_id
|
||||
for ((i = 1; i <= attempts; i++)); do
|
||||
release_id="$(gh api "repos/$repo/releases/tags/$tag" --jq '.id' 2>/dev/null || true)"
|
||||
if [[ "$release_id" =~ ^[0-9]+$ ]]; then
|
||||
echo "$release_id"
|
||||
return 0
|
||||
fi
|
||||
if [ "$i" -lt "$attempts" ]; then
|
||||
echo "Release id for tag '$tag' is not ready yet (attempt $i/$attempts), retrying in ${delay_seconds}s..." >&2
|
||||
sleep "$delay_seconds"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Unable to fetch release id for tag '$tag' after $attempts attempts." >&2
|
||||
gh api "repos/$repo/releases/tags/$tag" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}' 2>/dev/null || true
|
||||
return 1
|
||||
}
|
||||
|
||||
settle_release_state() {
|
||||
local repo="$1"
|
||||
local release_id="$2"
|
||||
local tag="$3"
|
||||
local attempts="${4:-12}"
|
||||
local delay_seconds="${5:-2}"
|
||||
local endpoint="repos/$repo/releases/tags/$tag"
|
||||
|
||||
local i
|
||||
local draft_state
|
||||
local prerelease_state
|
||||
for ((i = 1; i <= attempts; i++)); do
|
||||
gh api --method PATCH "repos/$repo/releases/$release_id" -F draft=false -F prerelease=true >/dev/null 2>&1 || true
|
||||
draft_state="$(gh api "$endpoint" --jq '.draft' 2>/dev/null || echo true)"
|
||||
prerelease_state="$(gh api "$endpoint" --jq '.prerelease' 2>/dev/null || echo false)"
|
||||
if [ "$draft_state" = "false" ] && [ "$prerelease_state" = "true" ]; then
|
||||
return 0
|
||||
fi
|
||||
if [ "$i" -lt "$attempts" ]; then
|
||||
echo "Release '$tag' state not settled yet (attempt $i/$attempts), retrying in ${delay_seconds}s..." >&2
|
||||
sleep "$delay_seconds"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Failed to settle release state for tag '$tag'." >&2
|
||||
gh api "$endpoint" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}' 2>/dev/null || true
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_for_release_absent() {
|
||||
local repo="$1"
|
||||
local tag="$2"
|
||||
local attempts="${3:-12}"
|
||||
local delay_seconds="${4:-2}"
|
||||
|
||||
local i
|
||||
for ((i = 1; i <= attempts; i++)); do
|
||||
if gh release view "$tag" --repo "$repo" >/dev/null 2>&1; then
|
||||
if [ "$i" -lt "$attempts" ]; then
|
||||
echo "Release '$tag' still exists (attempt $i/$attempts), waiting ${delay_seconds}s..." >&2
|
||||
sleep "$delay_seconds"
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
return 0
|
||||
done
|
||||
|
||||
echo "Release '$tag' still exists after waiting." >&2
|
||||
gh release view "$tag" --repo "$repo" --json url,isDraft,isPrerelease 2>/dev/null || true
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_for_git_tag_absent() {
|
||||
local repo="$1"
|
||||
local tag="$2"
|
||||
local attempts="${3:-12}"
|
||||
local delay_seconds="${4:-2}"
|
||||
|
||||
local i
|
||||
for ((i = 1; i <= attempts; i++)); do
|
||||
if gh api "repos/$repo/git/ref/tags/$tag" >/dev/null 2>&1; then
|
||||
if [ "$i" -lt "$attempts" ]; then
|
||||
echo "Git tag '$tag' still exists (attempt $i/$attempts), waiting ${delay_seconds}s..." >&2
|
||||
sleep "$delay_seconds"
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
return 0
|
||||
done
|
||||
|
||||
echo "Git tag '$tag' still exists after waiting." >&2
|
||||
gh api "repos/$repo/git/ref/tags/$tag" --jq '{ref: .ref, object: .object.sha}' 2>/dev/null || true
|
||||
return 1
|
||||
}
|
||||
|
||||
recreate_fixed_prerelease() {
|
||||
local repo="$1"
|
||||
local tag="$2"
|
||||
local target_branch="$3"
|
||||
local release_title="$4"
|
||||
local release_notes="$5"
|
||||
|
||||
if gh release view "$tag" --repo "$repo" >/dev/null 2>&1; then
|
||||
retry_cmd 5 3 gh release delete "$tag" --repo "$repo" --yes --cleanup-tag
|
||||
fi
|
||||
|
||||
wait_for_release_absent "$repo" "$tag" 12 2
|
||||
|
||||
if gh api "repos/$repo/git/ref/tags/$tag" >/dev/null 2>&1; then
|
||||
retry_cmd 5 2 gh api --method DELETE "repos/$repo/git/refs/tags/$tag"
|
||||
fi
|
||||
|
||||
wait_for_git_tag_absent "$repo" "$tag" 12 2
|
||||
|
||||
local created="false"
|
||||
local i
|
||||
for ((i = 1; i <= 6; i++)); do
|
||||
if gh release create "$tag" --repo "$repo" --title "$release_title" --notes "$release_notes" --prerelease --target "$target_branch"; then
|
||||
created="true"
|
||||
break
|
||||
fi
|
||||
if gh release view "$tag" --repo "$repo" >/dev/null 2>&1; then
|
||||
echo "Release '$tag' appears to exist after create failure; continue to settle state." >&2
|
||||
created="true"
|
||||
break
|
||||
fi
|
||||
if [ "$i" -lt 6 ]; then
|
||||
echo "Create release '$tag' failed (attempt $i/6), retrying in 3s..." >&2
|
||||
sleep 3
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$created" != "true" ]; then
|
||||
echo "Failed to create release '$tag'." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local release_id
|
||||
release_id="$(wait_for_release_id "$repo" "$tag" 12 2)"
|
||||
settle_release_state "$repo" "$release_id" "$tag" 12 2
|
||||
}
|
||||
|
||||
upload_release_assets_with_retry() {
|
||||
local repo="$1"
|
||||
local tag="$2"
|
||||
shift 2
|
||||
|
||||
if [ "$#" -eq 0 ]; then
|
||||
echo "No release assets provided for upload." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
wait_for_release_id "$repo" "$tag" 12 2 >/dev/null
|
||||
retry_cmd 5 3 gh release upload "$tag" "$@" --repo "$repo" --clobber
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -135,6 +114,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")
|
||||
@@ -143,7 +124,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 +141,6 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
@@ -183,6 +163,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 +173,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 +190,6 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
@@ -231,6 +212,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 +222,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 +239,6 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
@@ -279,6 +261,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 +271,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:
|
||||
@@ -386,22 +370,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
|
||||
source .github/scripts/release-utils.sh
|
||||
RELEASE_REST_ID="$(wait_for_release_id "$REPO" "$TAG" 12 2)"
|
||||
settle_release_state "$REPO" "$RELEASE_REST_ID" "$TAG" 12 2
|
||||
gh api "repos/$REPO/releases/tags/$TAG" --jq '{isDraft: .draft, isPrerelease: .prerelease, url: .html_url}'
|
||||
@@ -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
|
||||
|
||||
@@ -164,6 +143,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")
|
||||
@@ -172,7 +153,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 +171,6 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
@@ -216,6 +196,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 +206,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 +224,6 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
@@ -268,6 +249,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 +259,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 +277,6 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
@@ -320,6 +302,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 +312,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:
|
||||
@@ -429,22 +413,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
|
||||
source .github/scripts/release-utils.sh
|
||||
RELEASE_REST_ID="$(wait_for_release_id "$REPO" "$TAG" 12 2)"
|
||||
settle_release_state "$REPO" "$RELEASE_REST_ID" "$TAG" 12 2
|
||||
gh api "repos/$REPO/releases/tags/$TAG" --jq '{isDraft: .draft, isPrerelease: .prerelease, url: .html_url}'
|
||||
@@ -27,7 +27,6 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
@@ -59,15 +58,21 @@ jobs:
|
||||
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:
|
||||
@@ -84,7 +89,6 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
@@ -117,13 +121,16 @@ jobs:
|
||||
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 || true
|
||||
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:
|
||||
@@ -140,7 +147,6 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
@@ -168,13 +174,16 @@ jobs:
|
||||
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 || true
|
||||
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:
|
||||
@@ -191,7 +200,6 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
|
||||
@@ -219,13 +227,16 @@ jobs:
|
||||
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 || true
|
||||
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:
|
||||
@@ -243,12 +254,14 @@ jobs:
|
||||
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"
|
||||
@@ -299,7 +312,7 @@ 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
|
||||
|
||||
+1
-1
@@ -75,4 +75,4 @@ pnpm-lock.yaml
|
||||
wechat-research-site
|
||||
.codex
|
||||
weflow-web-offical
|
||||
Insight
|
||||
/Wedecrypt
|
||||
+130
-28
@@ -372,6 +372,7 @@ if (process.platform === 'darwin') {
|
||||
let mainWindowReady = false
|
||||
let shouldShowMain = true
|
||||
let isAppQuitting = false
|
||||
let shutdownPromise: Promise<void> | null = null
|
||||
let tray: Tray | null = null
|
||||
let isClosePromptVisible = false
|
||||
const chatHistoryPayloadStore = new Map<string, { sessionId: string; title?: string; recordList: any[] }>()
|
||||
@@ -2663,15 +2664,30 @@ function registerIpcHandlers() {
|
||||
// 私聊克隆
|
||||
|
||||
|
||||
ipcMain.handle('image:decrypt', async (_, payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => {
|
||||
ipcMain.handle('image:decrypt', async (_, payload: {
|
||||
sessionId?: string
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
createTime?: number
|
||||
force?: boolean
|
||||
preferFilePath?: boolean
|
||||
hardlinkOnly?: boolean
|
||||
disableUpdateCheck?: boolean
|
||||
allowCacheIndex?: boolean
|
||||
suppressEvents?: boolean
|
||||
}) => {
|
||||
return imageDecryptService.decryptImage(payload)
|
||||
})
|
||||
ipcMain.handle('image:resolveCache', async (_, payload: {
|
||||
sessionId?: string
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
createTime?: number
|
||||
preferFilePath?: boolean
|
||||
hardlinkOnly?: boolean
|
||||
disableUpdateCheck?: boolean
|
||||
allowCacheIndex?: boolean
|
||||
suppressEvents?: boolean
|
||||
}) => {
|
||||
return imageDecryptService.resolveCachedImage(payload)
|
||||
})
|
||||
@@ -2679,17 +2695,84 @@ function registerIpcHandlers() {
|
||||
'image:resolveCacheBatch',
|
||||
async (
|
||||
_,
|
||||
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
|
||||
suppressEvents?: boolean
|
||||
}>,
|
||||
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean; preferFilePath?: boolean; hardlinkOnly?: boolean; suppressEvents?: boolean }
|
||||
) => {
|
||||
const list = Array.isArray(payloads) ? payloads : []
|
||||
const rows = await Promise.all(list.map(async (payload) => {
|
||||
return imageDecryptService.resolveCachedImage({
|
||||
...payload,
|
||||
disableUpdateCheck: options?.disableUpdateCheck === true,
|
||||
allowCacheIndex: options?.allowCacheIndex !== false
|
||||
})
|
||||
}))
|
||||
if (list.length === 0) return { success: true, rows: [] }
|
||||
|
||||
const maxConcurrentRaw = Number(process.env.WEFLOW_IMAGE_RESOLVE_BATCH_CONCURRENCY || 10)
|
||||
const maxConcurrent = Number.isFinite(maxConcurrentRaw)
|
||||
? Math.max(1, Math.min(Math.floor(maxConcurrentRaw), 48))
|
||||
: 10
|
||||
const workerCount = Math.min(maxConcurrent, list.length)
|
||||
|
||||
const rows: Array<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }> = new Array(list.length)
|
||||
let cursor = 0
|
||||
const dedupe = new Map<string, Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }>>()
|
||||
|
||||
const makeDedupeKey = (payload: typeof list[number]): string => {
|
||||
const sessionId = String(payload.sessionId || '').trim().toLowerCase()
|
||||
const imageMd5 = String(payload.imageMd5 || '').trim().toLowerCase()
|
||||
const imageDatName = String(payload.imageDatName || '').trim().toLowerCase()
|
||||
const createTime = Number(payload.createTime || 0) || 0
|
||||
const preferFilePath = payload.preferFilePath ?? options?.preferFilePath === true
|
||||
const hardlinkOnly = payload.hardlinkOnly ?? options?.hardlinkOnly === true
|
||||
const allowCacheIndex = options?.allowCacheIndex !== false
|
||||
const disableUpdateCheck = options?.disableUpdateCheck === true
|
||||
const suppressEvents = payload.suppressEvents ?? options?.suppressEvents === true
|
||||
return [
|
||||
sessionId,
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
String(createTime),
|
||||
preferFilePath ? 'pf1' : 'pf0',
|
||||
hardlinkOnly ? 'hl1' : 'hl0',
|
||||
allowCacheIndex ? 'ci1' : 'ci0',
|
||||
disableUpdateCheck ? 'du1' : 'du0',
|
||||
suppressEvents ? 'se1' : 'se0'
|
||||
].join('|')
|
||||
}
|
||||
|
||||
const resolveOne = (payload: typeof list[number]) => imageDecryptService.resolveCachedImage({
|
||||
...payload,
|
||||
preferFilePath: payload.preferFilePath ?? options?.preferFilePath === true,
|
||||
hardlinkOnly: payload.hardlinkOnly ?? options?.hardlinkOnly === true,
|
||||
disableUpdateCheck: options?.disableUpdateCheck === true,
|
||||
allowCacheIndex: options?.allowCacheIndex !== false,
|
||||
suppressEvents: payload.suppressEvents ?? options?.suppressEvents === true
|
||||
})
|
||||
|
||||
const worker = async () => {
|
||||
while (true) {
|
||||
const index = cursor
|
||||
cursor += 1
|
||||
if (index >= list.length) return
|
||||
const payload = list[index]
|
||||
const key = makeDedupeKey(payload)
|
||||
const existing = dedupe.get(key)
|
||||
if (existing) {
|
||||
rows[index] = await existing
|
||||
continue
|
||||
}
|
||||
const task = resolveOne(payload).catch((error) => ({
|
||||
success: false,
|
||||
error: String(error)
|
||||
}))
|
||||
dedupe.set(key, task)
|
||||
rows[index] = await task
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from({ length: workerCount }, () => worker()))
|
||||
return { success: true, rows }
|
||||
}
|
||||
)
|
||||
@@ -2697,12 +2780,19 @@ function registerIpcHandlers() {
|
||||
'image:preload',
|
||||
async (
|
||||
_,
|
||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
|
||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }>,
|
||||
options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean }
|
||||
) => {
|
||||
imagePreloadService.enqueue(payloads || [], options)
|
||||
return true
|
||||
})
|
||||
ipcMain.handle(
|
||||
'image:preloadHardlinkMd5s',
|
||||
async (_, md5List?: string[]) => {
|
||||
await imageDecryptService.preloadImageHardlinkMd5s(Array.isArray(md5List) ? md5List : [])
|
||||
return true
|
||||
}
|
||||
)
|
||||
|
||||
// Windows Hello
|
||||
ipcMain.handle('auth:hello', async (event, message?: string) => {
|
||||
@@ -3780,23 +3870,35 @@ app.whenReady().then(async () => {
|
||||
})
|
||||
})
|
||||
|
||||
app.on('before-quit', async () => {
|
||||
isAppQuitting = true
|
||||
// 销毁 tray 图标
|
||||
if (tray) { try { tray.destroy() } catch {} tray = null }
|
||||
// 通知窗使用 hide 而非 close,退出时主动销毁,避免残留窗口阻塞进程退出。
|
||||
destroyNotificationWindow()
|
||||
insightService.stop()
|
||||
// 兜底:5秒后强制退出,防止某个异步任务卡住导致进程残留
|
||||
const forceExitTimer = setTimeout(() => {
|
||||
console.warn('[App] Force exit after timeout')
|
||||
app.exit(0)
|
||||
}, 5000)
|
||||
forceExitTimer.unref()
|
||||
// 停止 HTTP 服务器,释放 TCP 端口占用,避免进程无法退出
|
||||
try { await httpService.stop() } catch {}
|
||||
// 终止 wcdb Worker 线程,避免线程阻止进程退出
|
||||
try { await wcdbService.shutdown() } catch {}
|
||||
const shutdownAppServices = async (): Promise<void> => {
|
||||
if (shutdownPromise) return shutdownPromise
|
||||
shutdownPromise = (async () => {
|
||||
isAppQuitting = true
|
||||
// 销毁 tray 图标
|
||||
if (tray) { try { tray.destroy() } catch {} tray = null }
|
||||
// 通知窗使用 hide 而非 close,退出时主动销毁,避免残留窗口阻塞进程退出。
|
||||
destroyNotificationWindow()
|
||||
messagePushService.stop()
|
||||
insightService.stop()
|
||||
// 兜底:5秒后强制退出,防止某个异步任务卡住导致进程残留
|
||||
const forceExitTimer = setTimeout(() => {
|
||||
console.warn('[App] Force exit after timeout')
|
||||
app.exit(0)
|
||||
}, 5000)
|
||||
forceExitTimer.unref()
|
||||
try { await cloudControlService.stop() } catch {}
|
||||
// 停止 chatService(内部会关闭 cursor 与 DB),避免退出阶段仍触发监控回调
|
||||
try { chatService.close() } catch {}
|
||||
// 停止 HTTP 服务器,释放 TCP 端口占用,避免进程无法退出
|
||||
try { await httpService.stop() } catch {}
|
||||
// 终止 wcdb Worker 线程,避免线程阻止进程退出
|
||||
try { await wcdbService.shutdown() } catch {}
|
||||
})()
|
||||
return shutdownPromise
|
||||
}
|
||||
|
||||
app.on('before-quit', () => {
|
||||
void shutdownAppServices()
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
|
||||
+21
-4
@@ -286,24 +286,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)
|
||||
|
||||
@@ -486,7 +486,7 @@ class ChatService {
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
private toCodeOnlyMessage(rawMessage?: string, fallbackCode = -3999): string {
|
||||
private toCodeOnlyMessage(rawMessage?: string | null, fallbackCode = -3999): string {
|
||||
const code = this.extractErrorCode(rawMessage) ?? fallbackCode
|
||||
return `错误码: ${code}`
|
||||
}
|
||||
@@ -7105,13 +7105,23 @@ class ChatService {
|
||||
return { success: false, error: '未找到消息' }
|
||||
}
|
||||
const msg = msgResult.message
|
||||
const rawImageInfo = msg.rawContent ? this.parseImageInfo(msg.rawContent) : {}
|
||||
const imageMd5 = msg.imageMd5 || rawImageInfo.md5
|
||||
const imageDatName = msg.imageDatName
|
||||
|
||||
// 2. 使用 imageDecryptService 解密图片
|
||||
if (!imageMd5 && !imageDatName) {
|
||||
return { success: false, error: '图片缺少 md5/datName,无法定位原文件' }
|
||||
}
|
||||
|
||||
// 2. 使用 imageDecryptService 解密图片(仅使用真实图片标识)
|
||||
const result = await this.imageDecryptService.decryptImage({
|
||||
sessionId,
|
||||
imageMd5: msg.imageMd5,
|
||||
imageDatName: msg.imageDatName || String(msg.localId),
|
||||
force: false
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
createTime: msg.createTime,
|
||||
force: false,
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true
|
||||
})
|
||||
|
||||
if (!result.success || !result.localPath) {
|
||||
@@ -8358,7 +8368,6 @@ class ChatService {
|
||||
if (normalized.length === 0) return []
|
||||
|
||||
// 规避 native options_json 可能存在的固定缓冲上限:按 payload 字节安全分块。
|
||||
// 这不是降级或裁剪范围,而是完整遍历所有群并做结果合并。
|
||||
const maxBytesRaw = Number(process.env.WEFLOW_MY_FOOTPRINT_GROUP_OPTIONS_MAX_BYTES || 900)
|
||||
const maxBytes = Number.isFinite(maxBytesRaw) && maxBytesRaw >= 512
|
||||
? Math.floor(maxBytesRaw)
|
||||
@@ -9325,7 +9334,7 @@ class ChatService {
|
||||
latest_ts: this.toSafeInt(item?.latest_ts, 0),
|
||||
anchor_local_id: this.toSafeInt(item?.anchor_local_id, 0),
|
||||
anchor_create_time: this.toSafeInt(item?.anchor_create_time, 0)
|
||||
})).filter((item) => item.session_id)
|
||||
})).filter((item: MyFootprintPrivateSession) => item.session_id)
|
||||
|
||||
const private_segments: MyFootprintPrivateSegment[] = privateSegmentsRaw.map((item: any) => ({
|
||||
session_id: String(item?.session_id || '').trim(),
|
||||
@@ -9344,7 +9353,7 @@ class ChatService {
|
||||
anchor_create_time: this.toSafeInt(item?.anchor_create_time, 0),
|
||||
displayName: String(item?.displayName || '').trim() || undefined,
|
||||
avatarUrl: String(item?.avatarUrl || '').trim() || undefined
|
||||
})).filter((item) => item.session_id && item.start_ts > 0)
|
||||
})).filter((item: MyFootprintPrivateSegment) => item.session_id && item.start_ts > 0)
|
||||
|
||||
const mentions: MyFootprintMentionItem[] = mentionsRaw.map((item: any) => ({
|
||||
session_id: String(item?.session_id || '').trim(),
|
||||
@@ -9353,13 +9362,13 @@ class ChatService {
|
||||
sender_username: String(item?.sender_username || '').trim(),
|
||||
message_content: String(item?.message_content || ''),
|
||||
source: String(item?.source || '')
|
||||
})).filter((item) => item.session_id)
|
||||
})).filter((item: MyFootprintMentionItem) => item.session_id)
|
||||
|
||||
const mention_groups: MyFootprintMentionGroup[] = mentionGroupsRaw.map((item: any) => ({
|
||||
session_id: String(item?.session_id || '').trim(),
|
||||
count: this.toSafeInt(item?.count, 0),
|
||||
latest_ts: this.toSafeInt(item?.latest_ts, 0)
|
||||
})).filter((item) => item.session_id)
|
||||
})).filter((item: MyFootprintMentionGroup) => item.session_id)
|
||||
|
||||
const diagnostics: MyFootprintDiagnostics = {
|
||||
truncated: Boolean(diagnosticsRaw.truncated),
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { app, safeStorage } from 'electron'
|
||||
import crypto from 'crypto'
|
||||
import Store from 'electron-store'
|
||||
import { expandHomePath } from '../utils/pathUtils'
|
||||
|
||||
// 加密前缀标记
|
||||
const SAFE_PREFIX = 'safe:' // safeStorage 加密(普通模式)
|
||||
@@ -42,7 +43,6 @@ interface ConfigSchema {
|
||||
autoTranscribeVoice: boolean
|
||||
transcribeLanguages: string[]
|
||||
exportDefaultConcurrency: number
|
||||
exportDefaultImageDeepSearchOnMiss: boolean
|
||||
analyticsExcludedUsernames: string[]
|
||||
|
||||
// 安全相关
|
||||
@@ -168,7 +168,6 @@ export class ConfigService {
|
||||
autoTranscribeVoice: false,
|
||||
transcribeLanguages: ['zh'],
|
||||
exportDefaultConcurrency: 4,
|
||||
exportDefaultImageDeepSearchOnMiss: true,
|
||||
analyticsExcludedUsernames: [],
|
||||
authEnabled: false,
|
||||
authPassword: '',
|
||||
@@ -299,6 +298,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
|
||||
}
|
||||
|
||||
@@ -306,6 +309,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 钥匙串弹窗
|
||||
|
||||
@@ -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)
|
||||
@@ -216,13 +218,14 @@ 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
|
||||
@@ -235,9 +238,9 @@ export class DbPathService {
|
||||
|
||||
|
||||
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 +251,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 +269,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 +293,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) {
|
||||
|
||||
@@ -108,7 +108,6 @@ export interface ExportOptions {
|
||||
sessionNameWithTypePrefix?: boolean
|
||||
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
|
||||
exportConcurrency?: number
|
||||
imageDeepSearchOnMiss?: boolean
|
||||
}
|
||||
|
||||
const TXT_COLUMN_DEFINITIONS: Array<{ id: string; label: string }> = [
|
||||
@@ -443,8 +442,8 @@ class ExportService {
|
||||
let lastSessionId = ''
|
||||
let lastCollected = 0
|
||||
let lastExported = 0
|
||||
const MIN_PROGRESS_EMIT_INTERVAL_MS = 250
|
||||
const MESSAGE_PROGRESS_DELTA_THRESHOLD = 500
|
||||
const MIN_PROGRESS_EMIT_INTERVAL_MS = 400
|
||||
const MESSAGE_PROGRESS_DELTA_THRESHOLD = 1200
|
||||
|
||||
const commit = (progress: ExportProgress) => {
|
||||
onProgress(progress)
|
||||
@@ -1092,8 +1091,7 @@ class ExportService {
|
||||
private getImageMissingRunCacheKey(
|
||||
sessionId: string,
|
||||
imageMd5?: unknown,
|
||||
imageDatName?: unknown,
|
||||
imageDeepSearchOnMiss = true
|
||||
imageDatName?: unknown
|
||||
): string | null {
|
||||
const normalizedSessionId = String(sessionId || '').trim()
|
||||
const normalizedImageMd5 = String(imageMd5 || '').trim().toLowerCase()
|
||||
@@ -1105,8 +1103,7 @@ class ExportService {
|
||||
const secondaryToken = normalizedImageMd5 && normalizedImageDatName && normalizedImageDatName !== normalizedImageMd5
|
||||
? normalizedImageDatName
|
||||
: ''
|
||||
const lookupMode = imageDeepSearchOnMiss ? 'deep' : 'hardlink'
|
||||
return `${lookupMode}\u001f${normalizedSessionId}\u001f${primaryToken}\u001f${secondaryToken}`
|
||||
return `${normalizedSessionId}\u001f${primaryToken}\u001f${secondaryToken}`
|
||||
}
|
||||
|
||||
private normalizeEmojiMd5(value: unknown): string | undefined {
|
||||
@@ -3583,7 +3580,6 @@ class ExportService {
|
||||
exportVoiceAsText?: boolean
|
||||
includeVideoPoster?: boolean
|
||||
includeVoiceWithTranscript?: boolean
|
||||
imageDeepSearchOnMiss?: boolean
|
||||
dirCache?: Set<string>
|
||||
}
|
||||
): Promise<MediaExportItem | null> {
|
||||
@@ -3596,8 +3592,7 @@ class ExportService {
|
||||
sessionId,
|
||||
mediaRootDir,
|
||||
mediaRelativePrefix,
|
||||
options.dirCache,
|
||||
options.imageDeepSearchOnMiss !== false
|
||||
options.dirCache
|
||||
)
|
||||
if (result) {
|
||||
}
|
||||
@@ -3654,8 +3649,7 @@ class ExportService {
|
||||
sessionId: string,
|
||||
mediaRootDir: string,
|
||||
mediaRelativePrefix: string,
|
||||
dirCache?: Set<string>,
|
||||
imageDeepSearchOnMiss = true
|
||||
dirCache?: Set<string>
|
||||
): Promise<MediaExportItem | null> {
|
||||
try {
|
||||
const imagesDir = path.join(mediaRootDir, mediaRelativePrefix, 'images')
|
||||
@@ -3675,8 +3669,7 @@ class ExportService {
|
||||
const missingRunCacheKey = this.getImageMissingRunCacheKey(
|
||||
sessionId,
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
imageDeepSearchOnMiss
|
||||
imageDatName
|
||||
)
|
||||
if (missingRunCacheKey && this.mediaRunMissingImageKeys.has(missingRunCacheKey)) {
|
||||
return null
|
||||
@@ -3686,26 +3679,31 @@ class ExportService {
|
||||
sessionId,
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
createTime: msg.createTime,
|
||||
force: true, // 导出优先高清,失败再回退缩略图
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: !imageDeepSearchOnMiss
|
||||
hardlinkOnly: true,
|
||||
disableUpdateCheck: true,
|
||||
allowCacheIndex: !imageMd5,
|
||||
suppressEvents: true
|
||||
})
|
||||
|
||||
if (!result.success || !result.localPath) {
|
||||
console.log(`[Export] 图片解密失败 (localId=${msg.localId}): imageMd5=${imageMd5}, imageDatName=${imageDatName}, error=${result.error || '未知'}`)
|
||||
if (!imageDeepSearchOnMiss) {
|
||||
console.log(`[Export] 未命中 hardlink(已关闭缺图深度搜索)→ 将显示 [图片] 占位符`)
|
||||
if (missingRunCacheKey) {
|
||||
this.mediaRunMissingImageKeys.add(missingRunCacheKey)
|
||||
}
|
||||
return null
|
||||
if (result.failureKind === 'decrypt_failed') {
|
||||
console.log(`[Export] 图片解密失败 (localId=${msg.localId}): imageMd5=${imageMd5}, imageDatName=${imageDatName}, error=${result.error || '未知'}`)
|
||||
} else {
|
||||
console.log(`[Export] 图片本地无数据 (localId=${msg.localId}): imageMd5=${imageMd5}, imageDatName=${imageDatName}, error=${result.error || '未知'}`)
|
||||
}
|
||||
// 尝试获取缩略图
|
||||
const thumbResult = await imageDecryptService.resolveCachedImage({
|
||||
sessionId,
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
preferFilePath: true
|
||||
createTime: msg.createTime,
|
||||
preferFilePath: true,
|
||||
disableUpdateCheck: true,
|
||||
allowCacheIndex: !imageMd5,
|
||||
suppressEvents: true
|
||||
})
|
||||
if (thumbResult.success && thumbResult.localPath) {
|
||||
console.log(`[Export] 使用缩略图替代 (localId=${msg.localId}): ${thumbResult.localPath}`)
|
||||
@@ -5302,7 +5300,6 @@ class ExportService {
|
||||
maxFileSizeMb: options.maxFileSizeMb,
|
||||
exportVoiceAsText: options.exportVoiceAsText,
|
||||
includeVideoPoster: options.format === 'html',
|
||||
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
|
||||
dirCache: mediaDirCache
|
||||
})
|
||||
mediaCache.set(mediaKey, mediaItem)
|
||||
@@ -5813,7 +5810,6 @@ class ExportService {
|
||||
maxFileSizeMb: options.maxFileSizeMb,
|
||||
exportVoiceAsText: options.exportVoiceAsText,
|
||||
includeVideoPoster: options.format === 'html',
|
||||
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
|
||||
dirCache: mediaDirCache
|
||||
})
|
||||
mediaCache.set(mediaKey, mediaItem)
|
||||
@@ -6685,7 +6681,6 @@ class ExportService {
|
||||
maxFileSizeMb: options.maxFileSizeMb,
|
||||
exportVoiceAsText: options.exportVoiceAsText,
|
||||
includeVideoPoster: options.format === 'html',
|
||||
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
|
||||
dirCache: mediaDirCache
|
||||
})
|
||||
mediaCache.set(mediaKey, mediaItem)
|
||||
@@ -7436,7 +7431,6 @@ class ExportService {
|
||||
maxFileSizeMb: options.maxFileSizeMb,
|
||||
exportVoiceAsText: options.exportVoiceAsText,
|
||||
includeVideoPoster: options.format === 'html',
|
||||
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
|
||||
dirCache: mediaDirCache
|
||||
})
|
||||
mediaCache.set(mediaKey, mediaItem)
|
||||
@@ -7816,7 +7810,6 @@ class ExportService {
|
||||
maxFileSizeMb: options.maxFileSizeMb,
|
||||
exportVoiceAsText: options.exportVoiceAsText,
|
||||
includeVideoPoster: options.format === 'html',
|
||||
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
|
||||
dirCache: mediaDirCache
|
||||
})
|
||||
mediaCache.set(mediaKey, mediaItem)
|
||||
@@ -8240,7 +8233,6 @@ class ExportService {
|
||||
includeVideoPoster: options.format === 'html',
|
||||
includeVoiceWithTranscript: true,
|
||||
exportVideos: options.exportVideos,
|
||||
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
|
||||
dirCache: mediaDirCache
|
||||
})
|
||||
mediaCache.set(mediaKey, mediaItem)
|
||||
|
||||
@@ -1208,6 +1208,30 @@ class HttpService {
|
||||
const sessionDir = path.join(this.getApiMediaExportPath(), this.sanitizeFileName(talker, 'session'))
|
||||
this.ensureDir(sessionDir)
|
||||
|
||||
// 预热图片 hardlink 索引,减少逐条导出时的查找开销
|
||||
if (options.exportImages) {
|
||||
const imageMd5Set = new Set<string>()
|
||||
for (const msg of messages) {
|
||||
if (msg.localType !== 3) continue
|
||||
const imageMd5 = String(msg.imageMd5 || '').trim().toLowerCase()
|
||||
if (imageMd5) {
|
||||
imageMd5Set.add(imageMd5)
|
||||
continue
|
||||
}
|
||||
const imageDatName = String(msg.imageDatName || '').trim().toLowerCase()
|
||||
if (/^[a-f0-9]{32}$/i.test(imageDatName)) {
|
||||
imageMd5Set.add(imageDatName)
|
||||
}
|
||||
}
|
||||
if (imageMd5Set.size > 0) {
|
||||
try {
|
||||
await imageDecryptService.preloadImageHardlinkMd5s(Array.from(imageMd5Set))
|
||||
} catch {
|
||||
// ignore preload failures
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const msg of messages) {
|
||||
const exported = await this.exportMediaForMessage(msg, talker, sessionDir, options)
|
||||
if (exported) {
|
||||
@@ -1230,27 +1254,54 @@ class HttpService {
|
||||
sessionId: talker,
|
||||
imageMd5: msg.imageMd5,
|
||||
imageDatName: msg.imageDatName,
|
||||
force: true
|
||||
createTime: msg.createTime,
|
||||
force: true,
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true,
|
||||
disableUpdateCheck: true,
|
||||
suppressEvents: true
|
||||
})
|
||||
if (result.success && result.localPath) {
|
||||
let imagePath = result.localPath
|
||||
|
||||
let imagePath = result.success ? result.localPath : undefined
|
||||
if (!imagePath) {
|
||||
try {
|
||||
const cached = await imageDecryptService.resolveCachedImage({
|
||||
sessionId: talker,
|
||||
imageMd5: msg.imageMd5,
|
||||
imageDatName: msg.imageDatName,
|
||||
createTime: msg.createTime,
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true,
|
||||
disableUpdateCheck: true,
|
||||
suppressEvents: true
|
||||
})
|
||||
if (cached.success && cached.localPath) {
|
||||
imagePath = cached.localPath
|
||||
}
|
||||
} catch {
|
||||
// ignore resolve failures
|
||||
}
|
||||
}
|
||||
|
||||
if (imagePath) {
|
||||
if (imagePath.startsWith('data:')) {
|
||||
const base64Match = imagePath.match(/^data:[^;]+;base64,(.+)$/)
|
||||
if (base64Match) {
|
||||
const imageBuffer = Buffer.from(base64Match[1], 'base64')
|
||||
const ext = this.detectImageExt(imageBuffer)
|
||||
const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`)
|
||||
const fileName = `${fileBase}${ext}`
|
||||
const targetDir = path.join(sessionDir, 'images')
|
||||
const fullPath = path.join(targetDir, fileName)
|
||||
this.ensureDir(targetDir)
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
fs.writeFileSync(fullPath, imageBuffer)
|
||||
}
|
||||
const relativePath = `${this.sanitizeFileName(talker, 'session')}/images/${fileName}`
|
||||
return { kind: 'image', fileName, fullPath, relativePath }
|
||||
if (!base64Match) return null
|
||||
const imageBuffer = Buffer.from(base64Match[1], 'base64')
|
||||
const ext = this.detectImageExt(imageBuffer)
|
||||
const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`)
|
||||
const fileName = `${fileBase}${ext}`
|
||||
const targetDir = path.join(sessionDir, 'images')
|
||||
const fullPath = path.join(targetDir, fileName)
|
||||
this.ensureDir(targetDir)
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
fs.writeFileSync(fullPath, imageBuffer)
|
||||
}
|
||||
} else if (fs.existsSync(imagePath)) {
|
||||
const relativePath = `${this.sanitizeFileName(talker, 'session')}/images/${fileName}`
|
||||
return { kind: 'image', fileName, fullPath, relativePath }
|
||||
}
|
||||
|
||||
if (fs.existsSync(imagePath)) {
|
||||
const imageBuffer = fs.readFileSync(imagePath)
|
||||
const ext = this.detectImageExt(imageBuffer)
|
||||
const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`)
|
||||
|
||||
@@ -3,10 +3,11 @@ import { basename, dirname, extname, join } from 'path'
|
||||
import { pathToFileURL } from 'url'
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, appendFileSync } from 'fs'
|
||||
import { writeFile, rm, readdir } from 'fs/promises'
|
||||
import { homedir, tmpdir } from 'os'
|
||||
import crypto from 'crypto'
|
||||
import { Worker } from 'worker_threads'
|
||||
import { ConfigService } from './config'
|
||||
import { wcdbService } from './wcdbService'
|
||||
import { decryptDatViaNative, nativeAddonLocation } from './nativeImageDecrypt'
|
||||
|
||||
// 获取 ffmpeg-static 的路径
|
||||
function getStaticFfmpegPath(): string | null {
|
||||
@@ -34,7 +35,7 @@ function getStaticFfmpegPath(): string | null {
|
||||
}
|
||||
|
||||
// 方法3: 打包后的路径
|
||||
if (app.isPackaged) {
|
||||
if (app?.isPackaged) {
|
||||
const resourcesPath = process.resourcesPath
|
||||
const packedPath = join(resourcesPath, 'app.asar.unpacked', 'node_modules', 'ffmpeg-static', 'ffmpeg.exe')
|
||||
if (existsSync(packedPath)) {
|
||||
@@ -52,6 +53,7 @@ type DecryptResult = {
|
||||
success: boolean
|
||||
localPath?: string
|
||||
error?: string
|
||||
failureKind?: 'not_found' | 'decrypt_failed'
|
||||
isThumb?: boolean // 是否是缩略图(没有高清图时返回缩略图)
|
||||
}
|
||||
|
||||
@@ -61,24 +63,41 @@ type CachedImagePayload = {
|
||||
sessionId?: string
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
createTime?: number
|
||||
preferFilePath?: boolean
|
||||
hardlinkOnly?: boolean
|
||||
disableUpdateCheck?: boolean
|
||||
allowCacheIndex?: boolean
|
||||
suppressEvents?: boolean
|
||||
}
|
||||
|
||||
type DecryptImagePayload = CachedImagePayload & {
|
||||
force?: boolean
|
||||
hardlinkOnly?: boolean
|
||||
}
|
||||
|
||||
export class ImageDecryptService {
|
||||
private configService = new ConfigService()
|
||||
private resolvedCache = new Map<string, string>()
|
||||
private pending = new Map<string, Promise<DecryptResult>>()
|
||||
private readonly defaultV1AesKey = 'cfcd208495d565ef'
|
||||
private cacheIndexed = false
|
||||
private cacheIndexing: Promise<void> | null = null
|
||||
private updateFlags = new Map<string, boolean>()
|
||||
private nativeLogged = false
|
||||
private datNameScanMissAt = new Map<string, number>()
|
||||
private readonly datNameScanMissTtlMs = 1200
|
||||
private readonly accountDirCache = new Map<string, string>()
|
||||
private cacheRootPath: string | null = null
|
||||
private readonly ensuredDirs = new Set<string>()
|
||||
|
||||
private shouldEmitImageEvents(payload?: { suppressEvents?: boolean }): boolean {
|
||||
if (payload?.suppressEvents === true) return false
|
||||
// 导出 worker 场景不需要向渲染层广播逐条图片事件,避免事件风暴拖慢主界面。
|
||||
if (process.env.WEFLOW_WORKER === '1') return false
|
||||
return true
|
||||
}
|
||||
|
||||
private shouldCheckImageUpdate(payload?: { disableUpdateCheck?: boolean; suppressEvents?: boolean }): boolean {
|
||||
if (payload?.disableUpdateCheck === true) return false
|
||||
return this.shouldEmitImageEvents(payload)
|
||||
}
|
||||
|
||||
private logInfo(message: string, meta?: Record<string, unknown>): void {
|
||||
if (!this.configService.get('logEnabled')) return
|
||||
@@ -106,7 +125,7 @@ export class ImageDecryptService {
|
||||
|
||||
private writeLog(line: string): void {
|
||||
try {
|
||||
const logDir = join(app.getPath('userData'), 'logs')
|
||||
const logDir = join(this.getUserDataPath(), 'logs')
|
||||
if (!existsSync(logDir)) {
|
||||
mkdirSync(logDir, { recursive: true })
|
||||
}
|
||||
@@ -117,28 +136,29 @@ export class ImageDecryptService {
|
||||
}
|
||||
|
||||
async resolveCachedImage(payload: CachedImagePayload): Promise<DecryptResult & { hasUpdate?: boolean }> {
|
||||
if (payload.allowCacheIndex !== false) {
|
||||
await this.ensureCacheIndexed()
|
||||
}
|
||||
const cacheKeys = this.getCacheKeys(payload)
|
||||
const cacheKey = cacheKeys[0]
|
||||
if (!cacheKey) {
|
||||
return { success: false, error: '缺少图片标识' }
|
||||
return { success: false, error: '缺少图片标识', failureKind: 'not_found' }
|
||||
}
|
||||
for (const key of cacheKeys) {
|
||||
const cached = this.resolvedCache.get(key)
|
||||
if (cached && existsSync(cached) && this.isImageFile(cached)) {
|
||||
const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath)
|
||||
const isThumb = this.isThumbnailPath(cached)
|
||||
const upgraded = this.isThumbnailPath(cached)
|
||||
? await this.tryPromoteThumbnailCache(payload, key, cached)
|
||||
: null
|
||||
const finalPath = upgraded || cached
|
||||
const localPath = this.resolveLocalPathForPayload(finalPath, payload.preferFilePath)
|
||||
const isThumb = this.isThumbnailPath(finalPath)
|
||||
const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false
|
||||
if (isThumb) {
|
||||
if (!payload.disableUpdateCheck) {
|
||||
this.triggerUpdateCheck(payload, key, cached)
|
||||
if (this.shouldCheckImageUpdate(payload)) {
|
||||
this.triggerUpdateCheck(payload, key, finalPath)
|
||||
}
|
||||
} else {
|
||||
this.updateFlags.delete(key)
|
||||
}
|
||||
this.emitCacheResolved(payload, key, this.resolveEmitPath(cached, payload.preferFilePath))
|
||||
this.emitCacheResolved(payload, key, this.resolveEmitPath(finalPath, payload.preferFilePath))
|
||||
return { success: true, localPath, hasUpdate }
|
||||
}
|
||||
if (cached && !this.isImageFile(cached)) {
|
||||
@@ -146,36 +166,53 @@ export class ImageDecryptService {
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of cacheKeys) {
|
||||
const existing = this.findCachedOutput(key, false, payload.sessionId)
|
||||
if (existing) {
|
||||
this.cacheResolvedPaths(key, payload.imageMd5, payload.imageDatName, existing)
|
||||
const localPath = this.resolveLocalPathForPayload(existing, payload.preferFilePath)
|
||||
const isThumb = this.isThumbnailPath(existing)
|
||||
const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false
|
||||
if (isThumb) {
|
||||
if (!payload.disableUpdateCheck) {
|
||||
this.triggerUpdateCheck(payload, key, existing)
|
||||
const accountDir = this.resolveCurrentAccountDir()
|
||||
if (accountDir) {
|
||||
const datPath = await this.resolveDatPath(
|
||||
accountDir,
|
||||
payload.imageMd5,
|
||||
payload.imageDatName,
|
||||
payload.sessionId,
|
||||
payload.createTime,
|
||||
{
|
||||
allowThumbnail: true,
|
||||
skipResolvedCache: false,
|
||||
hardlinkOnly: true,
|
||||
allowDatNameScanFallback: payload.allowCacheIndex !== false
|
||||
}
|
||||
)
|
||||
if (datPath) {
|
||||
const existing = this.findCachedOutputByDatPath(datPath, payload.sessionId, false)
|
||||
if (existing) {
|
||||
const upgraded = this.isThumbnailPath(existing)
|
||||
? await this.tryPromoteThumbnailCache(payload, cacheKey, existing)
|
||||
: null
|
||||
const finalPath = upgraded || existing
|
||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, finalPath)
|
||||
const localPath = this.resolveLocalPathForPayload(finalPath, payload.preferFilePath)
|
||||
const isThumb = this.isThumbnailPath(finalPath)
|
||||
const hasUpdate = isThumb ? (this.updateFlags.get(cacheKey) ?? false) : false
|
||||
if (isThumb) {
|
||||
if (this.shouldCheckImageUpdate(payload)) {
|
||||
this.triggerUpdateCheck(payload, cacheKey, finalPath)
|
||||
}
|
||||
} else {
|
||||
this.updateFlags.delete(cacheKey)
|
||||
}
|
||||
} else {
|
||||
this.updateFlags.delete(key)
|
||||
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(finalPath, payload.preferFilePath))
|
||||
return { success: true, localPath, hasUpdate }
|
||||
}
|
||||
this.emitCacheResolved(payload, key, this.resolveEmitPath(existing, payload.preferFilePath))
|
||||
return { success: true, localPath, hasUpdate }
|
||||
}
|
||||
}
|
||||
this.logInfo('未找到缓存', { md5: payload.imageMd5, datName: payload.imageDatName })
|
||||
return { success: false, error: '未找到缓存图片' }
|
||||
return { success: false, error: '未找到缓存图片', failureKind: 'not_found' }
|
||||
}
|
||||
|
||||
async decryptImage(payload: DecryptImagePayload): Promise<DecryptResult> {
|
||||
if (!payload.hardlinkOnly) {
|
||||
await this.ensureCacheIndexed()
|
||||
}
|
||||
const cacheKeys = this.getCacheKeys(payload)
|
||||
const cacheKey = cacheKeys[0]
|
||||
if (!cacheKey) {
|
||||
return { success: false, error: '缺少图片标识' }
|
||||
return { success: false, error: '缺少图片标识', failureKind: 'not_found' }
|
||||
}
|
||||
this.emitDecryptProgress(payload, cacheKey, 'queued', 4, 'running')
|
||||
|
||||
@@ -195,25 +232,17 @@ export class ImageDecryptService {
|
||||
}
|
||||
}
|
||||
|
||||
if (!payload.hardlinkOnly) {
|
||||
for (const key of cacheKeys) {
|
||||
const existingHd = this.findCachedOutput(key, true, payload.sessionId)
|
||||
if (!existingHd || this.isThumbnailPath(existingHd)) continue
|
||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existingHd)
|
||||
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
|
||||
const localPath = this.resolveLocalPathForPayload(existingHd, payload.preferFilePath)
|
||||
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existingHd, payload.preferFilePath))
|
||||
this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done')
|
||||
return { success: true, localPath }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!payload.force) {
|
||||
const cached = this.resolvedCache.get(cacheKey)
|
||||
if (cached && existsSync(cached) && this.isImageFile(cached)) {
|
||||
const localPath = this.resolveLocalPathForPayload(cached, payload.preferFilePath)
|
||||
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(cached, payload.preferFilePath))
|
||||
const upgraded = this.isThumbnailPath(cached)
|
||||
? await this.tryPromoteThumbnailCache(payload, cacheKey, cached)
|
||||
: null
|
||||
const finalPath = upgraded || cached
|
||||
const localPath = this.resolveLocalPathForPayload(finalPath, payload.preferFilePath)
|
||||
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(finalPath, payload.preferFilePath))
|
||||
this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done')
|
||||
return { success: true, localPath }
|
||||
}
|
||||
@@ -260,13 +289,13 @@ export class ImageDecryptService {
|
||||
for (const row of result.rows) {
|
||||
const md5 = String(row?.md5 || '').trim().toLowerCase()
|
||||
if (!md5) continue
|
||||
const fullPath = String(row?.data?.full_path || '').trim()
|
||||
if (!fullPath || !existsSync(fullPath)) continue
|
||||
this.cacheDatPath(accountDir, md5, fullPath)
|
||||
const fileName = String(row?.data?.file_name || '').trim().toLowerCase()
|
||||
if (fileName) {
|
||||
this.cacheDatPath(accountDir, fileName, fullPath)
|
||||
}
|
||||
const fullPath = String(row?.data?.full_path || '').trim()
|
||||
if (!fileName || !fullPath) continue
|
||||
const selectedPath = this.normalizeHardlinkDatPathByFileName(fullPath, fileName)
|
||||
if (!selectedPath || !existsSync(selectedPath)) continue
|
||||
this.cacheDatPath(accountDir, md5, selectedPath)
|
||||
this.cacheDatPath(accountDir, fileName, selectedPath)
|
||||
}
|
||||
} catch {
|
||||
// ignore preload failures
|
||||
@@ -285,14 +314,14 @@ export class ImageDecryptService {
|
||||
if (!wxid || !dbPath) {
|
||||
this.logError('配置缺失', undefined, { wxid: !!wxid, dbPath: !!dbPath })
|
||||
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '配置缺失')
|
||||
return { success: false, error: '未配置账号或数据库路径' }
|
||||
return { success: false, error: '未配置账号或数据库路径', failureKind: 'not_found' }
|
||||
}
|
||||
|
||||
const accountDir = this.resolveAccountDir(dbPath, wxid)
|
||||
if (!accountDir) {
|
||||
this.logError('未找到账号目录', undefined, { dbPath, wxid })
|
||||
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '账号目录缺失')
|
||||
return { success: false, error: '未找到账号目录' }
|
||||
return { success: false, error: '未找到账号目录', failureKind: 'not_found' }
|
||||
}
|
||||
|
||||
let datPath: string | null = null
|
||||
@@ -307,10 +336,12 @@ export class ImageDecryptService {
|
||||
payload.imageMd5,
|
||||
payload.imageDatName,
|
||||
payload.sessionId,
|
||||
payload.createTime,
|
||||
{
|
||||
allowThumbnail: false,
|
||||
skipResolvedCache: true,
|
||||
hardlinkOnly: payload.hardlinkOnly === true
|
||||
skipResolvedCache: false,
|
||||
hardlinkOnly: payload.hardlinkOnly === true,
|
||||
allowDatNameScanFallback: payload.allowCacheIndex !== false
|
||||
}
|
||||
)
|
||||
if (!datPath) {
|
||||
@@ -319,10 +350,12 @@ export class ImageDecryptService {
|
||||
payload.imageMd5,
|
||||
payload.imageDatName,
|
||||
payload.sessionId,
|
||||
payload.createTime,
|
||||
{
|
||||
allowThumbnail: true,
|
||||
skipResolvedCache: true,
|
||||
hardlinkOnly: payload.hardlinkOnly === true
|
||||
skipResolvedCache: false,
|
||||
hardlinkOnly: payload.hardlinkOnly === true,
|
||||
allowDatNameScanFallback: payload.allowCacheIndex !== false
|
||||
}
|
||||
)
|
||||
fallbackToThumbnail = Boolean(datPath)
|
||||
@@ -339,10 +372,12 @@ export class ImageDecryptService {
|
||||
payload.imageMd5,
|
||||
payload.imageDatName,
|
||||
payload.sessionId,
|
||||
payload.createTime,
|
||||
{
|
||||
allowThumbnail: true,
|
||||
skipResolvedCache: false,
|
||||
hardlinkOnly: payload.hardlinkOnly === true
|
||||
hardlinkOnly: payload.hardlinkOnly === true,
|
||||
allowDatNameScanFallback: payload.allowCacheIndex !== false
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -351,9 +386,9 @@ export class ImageDecryptService {
|
||||
this.logError('未找到DAT文件', undefined, { md5: payload.imageMd5, datName: payload.imageDatName })
|
||||
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '未找到DAT文件')
|
||||
if (usedHdAttempt) {
|
||||
return { success: false, error: '未找到图片文件,请在微信中点开该图片后重试' }
|
||||
return { success: false, error: '未找到图片文件,请在微信中点开该图片后重试', failureKind: 'not_found' }
|
||||
}
|
||||
return { success: false, error: '未找到图片文件' }
|
||||
return { success: false, error: '未找到图片文件', failureKind: 'not_found' }
|
||||
}
|
||||
|
||||
this.logInfo('找到DAT文件', { datPath })
|
||||
@@ -368,21 +403,18 @@ export class ImageDecryptService {
|
||||
return { success: true, localPath, isThumb }
|
||||
}
|
||||
|
||||
// 查找已缓存的解密文件(hardlink-only 模式下跳过全缓存目录扫描)
|
||||
if (!payload.hardlinkOnly) {
|
||||
const existing = this.findCachedOutput(cacheKey, payload.force, payload.sessionId)
|
||||
if (existing) {
|
||||
this.logInfo('找到已解密文件', { existing, isHd: this.isHdPath(existing) })
|
||||
const isHd = this.isHdPath(existing)
|
||||
// 如果要求高清但找到的是缩略图,继续解密高清图
|
||||
if (!(payload.force && !isHd)) {
|
||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existing)
|
||||
const localPath = this.resolveLocalPathForPayload(existing, payload.preferFilePath)
|
||||
const isThumb = this.isThumbnailPath(existing)
|
||||
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existing, payload.preferFilePath))
|
||||
this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done')
|
||||
return { success: true, localPath, isThumb }
|
||||
}
|
||||
const preferHdCache = Boolean(payload.force && !fallbackToThumbnail)
|
||||
const existingFast = this.findCachedOutputByDatPath(datPath, payload.sessionId, preferHdCache)
|
||||
if (existingFast) {
|
||||
this.logInfo('找到已解密文件(按DAT快速命中)', { existing: existingFast, isHd: this.isHdPath(existingFast) })
|
||||
const isHd = this.isHdPath(existingFast)
|
||||
if (!(payload.force && !isHd)) {
|
||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existingFast)
|
||||
const localPath = this.resolveLocalPathForPayload(existingFast, payload.preferFilePath)
|
||||
const isThumb = this.isThumbnailPath(existingFast)
|
||||
this.emitCacheResolved(payload, cacheKey, this.resolveEmitPath(existingFast, payload.preferFilePath))
|
||||
this.emitDecryptProgress(payload, cacheKey, 'done', 100, 'done')
|
||||
return { success: true, localPath, isThumb }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,50 +435,55 @@ export class ImageDecryptService {
|
||||
}
|
||||
if (Number.isNaN(xorKey) || (!xorKey && xorKey !== 0)) {
|
||||
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '缺少解密密钥')
|
||||
return { success: false, error: '未配置图片解密密钥' }
|
||||
return { success: false, error: '未配置图片解密密钥', failureKind: 'not_found' }
|
||||
}
|
||||
|
||||
const aesKeyRaw = imageKeys.aesKey
|
||||
const aesKey = this.resolveAesKey(aesKeyRaw)
|
||||
const aesKeyText = typeof aesKeyRaw === 'string' ? aesKeyRaw.trim() : ''
|
||||
const aesKeyForNative = aesKeyText || undefined
|
||||
|
||||
this.logInfo('开始解密DAT文件', { datPath, xorKey, hasAesKey: !!aesKey })
|
||||
this.logInfo('开始解密DAT文件(仅Rust原生)', { datPath, xorKey, hasAesKey: Boolean(aesKeyForNative) })
|
||||
this.emitDecryptProgress(payload, cacheKey, 'decrypting', 58, 'running')
|
||||
let decrypted = await this.decryptDatAuto(datPath, xorKey, aesKey)
|
||||
const nativeResult = this.tryDecryptDatWithNative(datPath, xorKey, aesKeyForNative)
|
||||
if (!nativeResult) {
|
||||
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', 'Rust原生解密不可用')
|
||||
return { success: false, error: 'Rust原生解密不可用或解密失败,请检查 native 模块与密钥配置', failureKind: 'not_found' }
|
||||
}
|
||||
let decrypted: Buffer = nativeResult.data
|
||||
this.emitDecryptProgress(payload, cacheKey, 'decrypting', 78, 'running')
|
||||
|
||||
// 检查是否是 wxgf 格式,如果是则尝试提取真实图片数据
|
||||
// 统一走原有 wxgf/ffmpeg 流程,确保行为与历史版本一致
|
||||
const wxgfResult = await this.unwrapWxgf(decrypted)
|
||||
decrypted = wxgfResult.data
|
||||
|
||||
let ext = this.detectImageExtension(decrypted)
|
||||
const detectedExt = this.detectImageExtension(decrypted)
|
||||
|
||||
// 如果是 wxgf 格式且没检测到扩展名
|
||||
if (wxgfResult.isWxgf && !ext) {
|
||||
ext = '.hevc'
|
||||
// 如果解密产物无法识别为图片,归类为“解密失败”。
|
||||
if (!detectedExt) {
|
||||
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', '解密后不是有效图片')
|
||||
return {
|
||||
success: false,
|
||||
error: '解密后不是有效图片',
|
||||
failureKind: 'decrypt_failed',
|
||||
isThumb: this.isThumbnailPath(datPath)
|
||||
}
|
||||
}
|
||||
|
||||
const finalExt = ext || '.jpg'
|
||||
const finalExt = detectedExt
|
||||
|
||||
const outputPath = this.getCacheOutputPathFromDat(datPath, finalExt, payload.sessionId)
|
||||
this.emitDecryptProgress(payload, cacheKey, 'writing', 90, 'running')
|
||||
await writeFile(outputPath, decrypted)
|
||||
this.logInfo('解密成功', { outputPath, size: decrypted.length })
|
||||
|
||||
if (finalExt === '.hevc') {
|
||||
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', 'wxgf转换失败')
|
||||
return {
|
||||
success: false,
|
||||
error: '此图片为微信新格式(wxgf),ffmpeg 转换失败,请检查日志',
|
||||
isThumb: this.isThumbnailPath(datPath)
|
||||
}
|
||||
}
|
||||
|
||||
const isThumb = this.isThumbnailPath(datPath)
|
||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath)
|
||||
if (!isThumb) {
|
||||
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
|
||||
} else {
|
||||
this.triggerUpdateCheck(payload, cacheKey, outputPath)
|
||||
if (this.shouldCheckImageUpdate(payload)) {
|
||||
this.triggerUpdateCheck(payload, cacheKey, outputPath)
|
||||
}
|
||||
}
|
||||
const localPath = payload.preferFilePath
|
||||
? outputPath
|
||||
@@ -458,18 +495,30 @@ export class ImageDecryptService {
|
||||
} catch (e) {
|
||||
this.logError('解密失败', e, { md5: payload.imageMd5, datName: payload.imageDatName })
|
||||
this.emitDecryptProgress(payload, cacheKey, 'failed', 100, 'error', String(e))
|
||||
return { success: false, error: String(e) }
|
||||
return { success: false, error: String(e), failureKind: 'not_found' }
|
||||
}
|
||||
}
|
||||
|
||||
private resolveAccountDir(dbPath: string, wxid: string): string | null {
|
||||
const cleanedWxid = this.cleanAccountDirName(wxid)
|
||||
const normalized = dbPath.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)
|
||||
}
|
||||
|
||||
const direct = join(normalized, cleanedWxid)
|
||||
if (existsSync(direct)) return direct
|
||||
if (existsSync(direct)) {
|
||||
this.accountDirCache.set(cacheKey, direct)
|
||||
return direct
|
||||
}
|
||||
|
||||
if (this.isAccountDir(normalized)) return normalized
|
||||
if (this.isAccountDir(normalized)) {
|
||||
this.accountDirCache.set(cacheKey, normalized)
|
||||
return normalized
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = readdirSync(normalized)
|
||||
@@ -479,7 +528,10 @@ export class ImageDecryptService {
|
||||
if (!this.isDirectory(entryPath)) continue
|
||||
const lowerEntry = entry.toLowerCase()
|
||||
if (lowerEntry === lowerWxid || lowerEntry.startsWith(`${lowerWxid}_`)) {
|
||||
if (this.isAccountDir(entryPath)) return entryPath
|
||||
if (this.isAccountDir(entryPath)) {
|
||||
this.accountDirCache.set(cacheKey, entryPath)
|
||||
return entryPath
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
@@ -487,6 +539,13 @@ export class ImageDecryptService {
|
||||
return null
|
||||
}
|
||||
|
||||
private resolveCurrentAccountDir(): string | null {
|
||||
const wxid = this.configService.get('myWxid')
|
||||
const dbPath = this.configService.get('dbPath')
|
||||
if (!wxid || !dbPath) return null
|
||||
return this.resolveAccountDir(dbPath, wxid)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取解密后的缓存目录(用于查找 hardlink.db)
|
||||
*/
|
||||
@@ -549,206 +608,119 @@ export class ImageDecryptService {
|
||||
imageMd5?: string,
|
||||
imageDatName?: string,
|
||||
sessionId?: string,
|
||||
options?: { allowThumbnail?: boolean; skipResolvedCache?: boolean; hardlinkOnly?: boolean }
|
||||
createTime?: number,
|
||||
options?: { allowThumbnail?: boolean; skipResolvedCache?: boolean; hardlinkOnly?: boolean; allowDatNameScanFallback?: boolean }
|
||||
): Promise<string | null> {
|
||||
const allowThumbnail = options?.allowThumbnail ?? true
|
||||
const skipResolvedCache = options?.skipResolvedCache ?? false
|
||||
const hardlinkOnly = options?.hardlinkOnly ?? false
|
||||
const allowDatNameScanFallback = options?.allowDatNameScanFallback ?? true
|
||||
this.logInfo('[ImageDecrypt] resolveDatPath', {
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
createTime,
|
||||
allowThumbnail,
|
||||
skipResolvedCache,
|
||||
hardlinkOnly
|
||||
hardlinkOnly,
|
||||
allowDatNameScanFallback
|
||||
})
|
||||
|
||||
if (!skipResolvedCache) {
|
||||
if (imageMd5) {
|
||||
const cached = this.resolvedCache.get(imageMd5)
|
||||
if (cached && existsSync(cached)) {
|
||||
const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail)
|
||||
this.cacheDatPath(accountDir, imageMd5, preferred)
|
||||
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, preferred)
|
||||
return preferred
|
||||
}
|
||||
}
|
||||
if (imageDatName) {
|
||||
const cached = this.resolvedCache.get(imageDatName)
|
||||
if (cached && existsSync(cached)) {
|
||||
const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail)
|
||||
this.cacheDatPath(accountDir, imageDatName, preferred)
|
||||
if (imageMd5) this.cacheDatPath(accountDir, imageMd5, preferred)
|
||||
return preferred
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 通过 MD5 快速定位 (MsgAttach 目录)
|
||||
if (!hardlinkOnly && allowThumbnail && imageMd5) {
|
||||
const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageMd5, allowThumbnail)
|
||||
if (res) return res
|
||||
if (imageDatName && imageDatName !== imageMd5 && this.looksLikeMd5(imageDatName)) {
|
||||
const datNameRes = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageDatName, allowThumbnail)
|
||||
if (datNameRes) return datNameRes
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 如果 imageDatName 看起来像 MD5,也尝试快速定位
|
||||
if (!hardlinkOnly && allowThumbnail && !imageMd5 && imageDatName && this.looksLikeMd5(imageDatName)) {
|
||||
const res = await this.fastProbabilisticSearch(join(accountDir, 'msg', 'attach'), imageDatName, allowThumbnail)
|
||||
if (res) return res
|
||||
}
|
||||
|
||||
// 优先通过 hardlink.db 查询
|
||||
if (imageMd5) {
|
||||
this.logInfo('[ImageDecrypt] hardlink lookup (md5)', { imageMd5, sessionId })
|
||||
const hardlinkPath = await this.resolveHardlinkPath(accountDir, imageMd5, sessionId)
|
||||
if (hardlinkPath) {
|
||||
const preferredPath = this.getPreferredDatVariantPath(hardlinkPath, allowThumbnail)
|
||||
const isThumb = this.isThumbnailPath(preferredPath)
|
||||
if (allowThumbnail || !isThumb) {
|
||||
this.logInfo('[ImageDecrypt] hardlink hit', { imageMd5, path: preferredPath })
|
||||
this.cacheDatPath(accountDir, imageMd5, preferredPath)
|
||||
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, preferredPath)
|
||||
return preferredPath
|
||||
}
|
||||
// hardlink 找到的是缩略图,但要求高清图
|
||||
// 尝试在同一目录下查找高清图变体(快速查找,不遍历)
|
||||
const hdPath = this.findHdVariantInSameDir(preferredPath)
|
||||
if (hdPath) {
|
||||
this.cacheDatPath(accountDir, imageMd5, hdPath)
|
||||
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdPath)
|
||||
return hdPath
|
||||
}
|
||||
// 没找到高清图,返回 null(不进行全局搜索)
|
||||
const lookupMd5s = this.collectHardlinkLookupMd5s(imageMd5, imageDatName)
|
||||
const fallbackDatName = String(imageDatName || imageMd5 || '').trim().toLowerCase() || undefined
|
||||
if (lookupMd5s.length === 0) {
|
||||
if (!allowDatNameScanFallback) {
|
||||
this.logInfo('[ImageDecrypt] resolveDatPath skip datName scan (no hardlink md5)', {
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
sessionId,
|
||||
createTime
|
||||
})
|
||||
return null
|
||||
}
|
||||
this.logInfo('[ImageDecrypt] hardlink miss (md5)', { imageMd5 })
|
||||
if (imageDatName && this.looksLikeMd5(imageDatName) && imageDatName !== imageMd5) {
|
||||
this.logInfo('[ImageDecrypt] hardlink fallback (datName)', { imageDatName, sessionId })
|
||||
const fallbackPath = await this.resolveHardlinkPath(accountDir, imageDatName, sessionId)
|
||||
if (fallbackPath) {
|
||||
const preferredPath = this.getPreferredDatVariantPath(fallbackPath, allowThumbnail)
|
||||
const isThumb = this.isThumbnailPath(preferredPath)
|
||||
if (allowThumbnail || !isThumb) {
|
||||
this.logInfo('[ImageDecrypt] hardlink hit (datName)', { imageMd5: imageDatName, path: preferredPath })
|
||||
this.cacheDatPath(accountDir, imageDatName, preferredPath)
|
||||
this.cacheDatPath(accountDir, imageMd5, preferredPath)
|
||||
return preferredPath
|
||||
}
|
||||
// 找到缩略图但要求高清图,尝试同目录查找高清图变体
|
||||
const hdPath = this.findHdVariantInSameDir(preferredPath)
|
||||
if (hdPath) {
|
||||
this.cacheDatPath(accountDir, imageDatName, hdPath)
|
||||
this.cacheDatPath(accountDir, imageMd5, hdPath)
|
||||
return hdPath
|
||||
}
|
||||
return null
|
||||
}
|
||||
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
|
||||
const packedDatFallback = this.resolveDatPathFromParsedDatName(accountDir, fallbackDatName, sessionId, createTime, allowThumbnail)
|
||||
if (packedDatFallback) {
|
||||
if (imageMd5) this.cacheDatPath(accountDir, imageMd5, packedDatFallback)
|
||||
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, packedDatFallback)
|
||||
const normalizedFileName = basename(packedDatFallback).toLowerCase()
|
||||
if (normalizedFileName) this.cacheDatPath(accountDir, normalizedFileName, packedDatFallback)
|
||||
this.logInfo('[ImageDecrypt] datName fallback hit (no hardlink md5)', {
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
selectedPath: packedDatFallback
|
||||
})
|
||||
return packedDatFallback
|
||||
}
|
||||
}
|
||||
|
||||
if (!imageMd5 && imageDatName && this.looksLikeMd5(imageDatName)) {
|
||||
this.logInfo('[ImageDecrypt] hardlink lookup (datName)', { imageDatName, sessionId })
|
||||
const hardlinkPath = await this.resolveHardlinkPath(accountDir, imageDatName, sessionId)
|
||||
if (hardlinkPath) {
|
||||
const preferredPath = this.getPreferredDatVariantPath(hardlinkPath, allowThumbnail)
|
||||
const isThumb = this.isThumbnailPath(preferredPath)
|
||||
if (allowThumbnail || !isThumb) {
|
||||
this.logInfo('[ImageDecrypt] hardlink hit', { imageMd5: imageDatName, path: preferredPath })
|
||||
this.cacheDatPath(accountDir, imageDatName, preferredPath)
|
||||
return preferredPath
|
||||
}
|
||||
// hardlink 找到的是缩略图,但要求高清图
|
||||
const hdPath = this.findHdVariantInSameDir(preferredPath)
|
||||
if (hdPath) {
|
||||
this.cacheDatPath(accountDir, imageDatName, hdPath)
|
||||
return hdPath
|
||||
}
|
||||
return null
|
||||
}
|
||||
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
|
||||
}
|
||||
|
||||
if (hardlinkOnly) {
|
||||
this.logInfo('[ImageDecrypt] resolveDatPath miss (hardlink-only)', { imageMd5, imageDatName })
|
||||
this.logInfo('[ImageDecrypt] resolveDatPath miss (no hardlink md5)', { imageMd5, imageDatName })
|
||||
return null
|
||||
}
|
||||
|
||||
const searchNames = Array.from(
|
||||
new Set([imageDatName, imageMd5].map((item) => String(item || '').trim()).filter(Boolean))
|
||||
)
|
||||
if (searchNames.length === 0) return null
|
||||
|
||||
if (!skipResolvedCache) {
|
||||
for (const searchName of searchNames) {
|
||||
const cached = this.resolvedCache.get(searchName)
|
||||
if (cached && existsSync(cached)) {
|
||||
const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail)
|
||||
if (allowThumbnail || !this.isThumbnailPath(preferred)) return preferred
|
||||
// 缓存的是缩略图,尝试找高清图
|
||||
const hdPath = this.findHdVariantInSameDir(preferred)
|
||||
if (hdPath) return hdPath
|
||||
}
|
||||
const cacheCandidates = Array.from(new Set([
|
||||
...lookupMd5s,
|
||||
String(imageMd5 || '').trim().toLowerCase(),
|
||||
String(imageDatName || '').trim().toLowerCase()
|
||||
].filter(Boolean)))
|
||||
for (const cacheKey of cacheCandidates) {
|
||||
const scopedKey = `${accountDir}|${cacheKey}`
|
||||
const cached = this.resolvedCache.get(scopedKey)
|
||||
if (!cached) continue
|
||||
if (!existsSync(cached)) continue
|
||||
if (!allowThumbnail && this.isThumbnailPath(cached)) continue
|
||||
return cached
|
||||
}
|
||||
}
|
||||
|
||||
for (const searchName of searchNames) {
|
||||
const datPath = await this.searchDatFile(accountDir, searchName, allowThumbnail)
|
||||
if (datPath) {
|
||||
this.logInfo('[ImageDecrypt] searchDatFile hit', { imageDatName, searchName, path: datPath })
|
||||
if (imageDatName) this.resolvedCache.set(imageDatName, datPath)
|
||||
if (imageMd5) this.resolvedCache.set(imageMd5, datPath)
|
||||
this.cacheDatPath(accountDir, searchName, datPath)
|
||||
if (imageDatName && imageDatName !== searchName) this.cacheDatPath(accountDir, imageDatName, datPath)
|
||||
if (imageMd5 && imageMd5 !== searchName) this.cacheDatPath(accountDir, imageMd5, datPath)
|
||||
return datPath
|
||||
}
|
||||
for (const lookupMd5 of lookupMd5s) {
|
||||
this.logInfo('[ImageDecrypt] hardlink lookup', { lookupMd5, sessionId, hardlinkOnly })
|
||||
const hardlinkPath = await this.resolveHardlinkPath(accountDir, lookupMd5, sessionId)
|
||||
if (!hardlinkPath) continue
|
||||
if (!allowThumbnail && this.isThumbnailPath(hardlinkPath)) continue
|
||||
|
||||
this.cacheDatPath(accountDir, lookupMd5, hardlinkPath)
|
||||
if (imageMd5) this.cacheDatPath(accountDir, imageMd5, hardlinkPath)
|
||||
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hardlinkPath)
|
||||
const normalizedFileName = basename(hardlinkPath).toLowerCase()
|
||||
if (normalizedFileName) this.cacheDatPath(accountDir, normalizedFileName, hardlinkPath)
|
||||
return hardlinkPath
|
||||
}
|
||||
|
||||
for (const searchName of searchNames) {
|
||||
const normalized = this.normalizeDatBase(searchName)
|
||||
if (normalized !== searchName.toLowerCase()) {
|
||||
const normalizedPath = await this.searchDatFile(accountDir, normalized, allowThumbnail)
|
||||
if (normalizedPath) {
|
||||
this.logInfo('[ImageDecrypt] searchDatFile hit (normalized)', { imageDatName, searchName, normalized, path: normalizedPath })
|
||||
if (imageDatName) this.resolvedCache.set(imageDatName, normalizedPath)
|
||||
if (imageMd5) this.resolvedCache.set(imageMd5, normalizedPath)
|
||||
this.cacheDatPath(accountDir, searchName, normalizedPath)
|
||||
if (imageDatName && imageDatName !== searchName) this.cacheDatPath(accountDir, imageDatName, normalizedPath)
|
||||
if (imageMd5 && imageMd5 !== searchName) this.cacheDatPath(accountDir, imageMd5, normalizedPath)
|
||||
return normalizedPath
|
||||
}
|
||||
}
|
||||
if (!allowDatNameScanFallback) {
|
||||
this.logInfo('[ImageDecrypt] resolveDatPath skip datName fallback after hardlink miss', {
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
sessionId,
|
||||
createTime,
|
||||
lookupMd5s
|
||||
})
|
||||
return null
|
||||
}
|
||||
this.logInfo('[ImageDecrypt] resolveDatPath miss', { imageDatName, imageMd5, searchNames })
|
||||
|
||||
const packedDatFallback = this.resolveDatPathFromParsedDatName(accountDir, fallbackDatName, sessionId, createTime, allowThumbnail)
|
||||
if (packedDatFallback) {
|
||||
if (imageMd5) this.cacheDatPath(accountDir, imageMd5, packedDatFallback)
|
||||
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, packedDatFallback)
|
||||
const normalizedFileName = basename(packedDatFallback).toLowerCase()
|
||||
if (normalizedFileName) this.cacheDatPath(accountDir, normalizedFileName, packedDatFallback)
|
||||
this.logInfo('[ImageDecrypt] datName fallback hit (hardlink miss)', {
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
lookupMd5s,
|
||||
selectedPath: packedDatFallback
|
||||
})
|
||||
return packedDatFallback
|
||||
}
|
||||
|
||||
this.logInfo('[ImageDecrypt] resolveDatPath miss (hardlink + datName fallback)', {
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
lookupMd5s
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
private async resolveThumbnailDatPath(
|
||||
accountDir: string,
|
||||
imageMd5?: string,
|
||||
imageDatName?: string,
|
||||
sessionId?: string
|
||||
): Promise<string | null> {
|
||||
if (imageMd5) {
|
||||
const hardlinkPath = await this.resolveHardlinkPath(accountDir, imageMd5, sessionId)
|
||||
if (hardlinkPath && this.isThumbnailPath(hardlinkPath)) return hardlinkPath
|
||||
}
|
||||
|
||||
if (!imageMd5 && imageDatName && this.looksLikeMd5(imageDatName)) {
|
||||
const hardlinkPath = await this.resolveHardlinkPath(accountDir, imageDatName, sessionId)
|
||||
if (hardlinkPath && this.isThumbnailPath(hardlinkPath)) return hardlinkPath
|
||||
}
|
||||
|
||||
if (!imageDatName) return null
|
||||
return this.searchDatFile(accountDir, imageDatName, true, true)
|
||||
}
|
||||
|
||||
private async checkHasUpdate(
|
||||
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string },
|
||||
cacheKey: string,
|
||||
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number },
|
||||
_cacheKey: string,
|
||||
cachedPath: string
|
||||
): Promise<boolean> {
|
||||
if (!cachedPath || !existsSync(cachedPath)) return false
|
||||
@@ -760,40 +732,98 @@ export class ImageDecryptService {
|
||||
const accountDir = this.resolveAccountDir(dbPath, wxid)
|
||||
if (!accountDir) return false
|
||||
|
||||
const quickDir = this.getCachedDatDir(accountDir, payload.imageDatName, payload.imageMd5)
|
||||
if (quickDir) {
|
||||
const baseName = payload.imageDatName || payload.imageMd5 || cacheKey
|
||||
const candidate = this.findNonThumbnailVariantInDir(quickDir, baseName)
|
||||
if (candidate) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const thumbPath = await this.resolveThumbnailDatPath(
|
||||
const hdPath = await this.resolveDatPath(
|
||||
accountDir,
|
||||
payload.imageMd5,
|
||||
payload.imageDatName,
|
||||
payload.sessionId
|
||||
payload.sessionId,
|
||||
payload.createTime,
|
||||
{ allowThumbnail: false, skipResolvedCache: true, hardlinkOnly: true, allowDatNameScanFallback: false }
|
||||
)
|
||||
if (thumbPath) {
|
||||
const baseName = payload.imageDatName || payload.imageMd5 || cacheKey
|
||||
const candidate = this.findNonThumbnailVariantInDir(dirname(thumbPath), baseName)
|
||||
if (candidate) {
|
||||
return true
|
||||
}
|
||||
const searchHit = await this.searchDatFileInDir(dirname(thumbPath), baseName, false)
|
||||
if (searchHit && this.isNonThumbnailVariantDat(searchHit)) {
|
||||
return true
|
||||
}
|
||||
return Boolean(hdPath)
|
||||
}
|
||||
|
||||
private async tryPromoteThumbnailCache(
|
||||
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; preferFilePath?: boolean },
|
||||
cacheKey: string,
|
||||
cachedPath: string
|
||||
): Promise<string | null> {
|
||||
if (!cachedPath || !existsSync(cachedPath)) return null
|
||||
if (!this.isImageFile(cachedPath)) return null
|
||||
if (!this.isThumbnailPath(cachedPath)) return null
|
||||
|
||||
const accountDir = this.resolveCurrentAccountDir()
|
||||
if (!accountDir) return null
|
||||
|
||||
const hdDatPath = await this.resolveDatPath(
|
||||
accountDir,
|
||||
payload.imageMd5,
|
||||
payload.imageDatName,
|
||||
payload.sessionId,
|
||||
payload.createTime,
|
||||
{ allowThumbnail: false, skipResolvedCache: true, hardlinkOnly: true, allowDatNameScanFallback: false }
|
||||
)
|
||||
if (!hdDatPath) return null
|
||||
|
||||
const existingHd = this.findCachedOutputByDatPath(hdDatPath, payload.sessionId, true)
|
||||
if (existingHd && existsSync(existingHd) && this.isImageFile(existingHd) && !this.isThumbnailPath(existingHd)) {
|
||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, existingHd)
|
||||
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
|
||||
this.removeThumbnailCacheFile(cachedPath, existingHd)
|
||||
this.logInfo('[ImageDecrypt] thumbnail cache upgraded', {
|
||||
cacheKey,
|
||||
oldPath: cachedPath,
|
||||
newPath: existingHd,
|
||||
mode: 'existing'
|
||||
})
|
||||
return existingHd
|
||||
}
|
||||
return false
|
||||
|
||||
const upgraded = await this.decryptImage({
|
||||
sessionId: payload.sessionId,
|
||||
imageMd5: payload.imageMd5,
|
||||
imageDatName: payload.imageDatName,
|
||||
createTime: payload.createTime,
|
||||
preferFilePath: true,
|
||||
force: true,
|
||||
hardlinkOnly: true,
|
||||
disableUpdateCheck: true
|
||||
})
|
||||
if (!upgraded.success) return null
|
||||
|
||||
const cachedResult = this.resolvedCache.get(cacheKey)
|
||||
const upgradedPath = (cachedResult && existsSync(cachedResult))
|
||||
? cachedResult
|
||||
: String(upgraded.localPath || '').trim()
|
||||
if (!upgradedPath || !existsSync(upgradedPath)) return null
|
||||
if (!this.isImageFile(upgradedPath) || this.isThumbnailPath(upgradedPath)) return null
|
||||
|
||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, upgradedPath)
|
||||
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
|
||||
this.removeThumbnailCacheFile(cachedPath, upgradedPath)
|
||||
this.logInfo('[ImageDecrypt] thumbnail cache upgraded', {
|
||||
cacheKey,
|
||||
oldPath: cachedPath,
|
||||
newPath: upgradedPath,
|
||||
mode: 're-decrypt'
|
||||
})
|
||||
return upgradedPath
|
||||
}
|
||||
|
||||
private removeThumbnailCacheFile(oldPath: string, keepPath?: string): void {
|
||||
if (!oldPath) return
|
||||
if (keepPath && oldPath === keepPath) return
|
||||
if (!existsSync(oldPath)) return
|
||||
if (!this.isThumbnailPath(oldPath)) return
|
||||
void rm(oldPath, { force: true }).catch(() => { })
|
||||
}
|
||||
|
||||
private triggerUpdateCheck(
|
||||
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string },
|
||||
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; disableUpdateCheck?: boolean; suppressEvents?: boolean },
|
||||
cacheKey: string,
|
||||
cachedPath: string
|
||||
): void {
|
||||
if (!this.shouldCheckImageUpdate(payload)) return
|
||||
if (this.updateFlags.get(cacheKey)) return
|
||||
void this.checkHasUpdate(payload, cacheKey, cachedPath).then((hasUpdate) => {
|
||||
if (!hasUpdate) return
|
||||
@@ -804,50 +834,409 @@ export class ImageDecryptService {
|
||||
|
||||
|
||||
|
||||
private resolveHardlinkDbPath(accountDir: string): string | null {
|
||||
const wxid = this.configService.get('myWxid')
|
||||
const cacheDir = wxid ? this.getDecryptedCacheDir(wxid) : null
|
||||
const candidates = [
|
||||
join(accountDir, 'db_storage', 'hardlink', 'hardlink.db'),
|
||||
join(accountDir, 'hardlink.db'),
|
||||
cacheDir ? join(cacheDir, 'hardlink.db') : null
|
||||
].filter(Boolean) as string[]
|
||||
this.logInfo('[ImageDecrypt] hardlink db probe', { accountDir, cacheDir, candidates })
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) return candidate
|
||||
private collectHardlinkLookupMd5s(imageMd5?: string, imageDatName?: string): string[] {
|
||||
const keys: string[] = []
|
||||
const pushMd5 = (value?: string) => {
|
||||
const normalized = String(value || '').trim().toLowerCase()
|
||||
if (!normalized) return
|
||||
if (!this.looksLikeMd5(normalized)) return
|
||||
if (!keys.includes(normalized)) keys.push(normalized)
|
||||
}
|
||||
this.logInfo('[ImageDecrypt] hardlink db missing', { accountDir, cacheDir, candidates })
|
||||
|
||||
pushMd5(imageMd5)
|
||||
|
||||
const datNameRaw = String(imageDatName || '').trim().toLowerCase()
|
||||
if (!datNameRaw) return keys
|
||||
pushMd5(datNameRaw)
|
||||
const datNameNoExt = datNameRaw.endsWith('.dat') ? datNameRaw.slice(0, -4) : datNameRaw
|
||||
pushMd5(datNameNoExt)
|
||||
pushMd5(this.normalizeDatBase(datNameNoExt))
|
||||
return keys
|
||||
}
|
||||
|
||||
private resolveDatPathFromParsedDatName(
|
||||
accountDir: string,
|
||||
imageDatName?: string,
|
||||
sessionId?: string,
|
||||
createTime?: number,
|
||||
allowThumbnail = true
|
||||
): string | null {
|
||||
const datNameRaw = String(imageDatName || '').trim().toLowerCase()
|
||||
if (!datNameRaw) return null
|
||||
const datNameNoExt = datNameRaw.endsWith('.dat') ? datNameRaw.slice(0, -4) : datNameRaw
|
||||
const baseMd5 = this.normalizeDatBase(datNameNoExt)
|
||||
if (!this.looksLikeMd5(baseMd5)) return null
|
||||
|
||||
const monthKey = this.resolveYearMonthFromCreateTime(createTime)
|
||||
const missKey = `${accountDir}|scan|${String(sessionId || '').trim()}|${monthKey}|${baseMd5}|${allowThumbnail ? 'all' : 'hd'}`
|
||||
const lastMiss = this.datNameScanMissAt.get(missKey) || 0
|
||||
if (lastMiss && (Date.now() - lastMiss) < this.datNameScanMissTtlMs) {
|
||||
return null
|
||||
}
|
||||
|
||||
const sessionMonthCandidates = this.collectDatCandidatesFromSessionMonth(accountDir, baseMd5, sessionId, createTime)
|
||||
if (sessionMonthCandidates.length > 0) {
|
||||
const orderedSessionMonth = this.sortDatCandidatePaths(sessionMonthCandidates, baseMd5)
|
||||
for (const candidatePath of orderedSessionMonth) {
|
||||
if (!allowThumbnail && this.isThumbnailPath(candidatePath)) continue
|
||||
this.datNameScanMissAt.delete(missKey)
|
||||
this.logInfo('[ImageDecrypt] datName fallback selected (session-month)', {
|
||||
accountDir,
|
||||
sessionId,
|
||||
imageDatName: datNameRaw,
|
||||
createTime,
|
||||
monthKey,
|
||||
baseMd5,
|
||||
allowThumbnail,
|
||||
selectedPath: candidatePath
|
||||
})
|
||||
return candidatePath
|
||||
}
|
||||
}
|
||||
|
||||
const hasPreciseContext = Boolean(String(sessionId || '').trim() && monthKey)
|
||||
if (hasPreciseContext) {
|
||||
this.datNameScanMissAt.set(missKey, Date.now())
|
||||
this.logInfo('[ImageDecrypt] datName fallback precise scan miss', {
|
||||
accountDir,
|
||||
sessionId,
|
||||
imageDatName: datNameRaw,
|
||||
createTime,
|
||||
monthKey,
|
||||
baseMd5,
|
||||
allowThumbnail
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const candidates = this.collectDatCandidatesFromAccountDir(accountDir, baseMd5)
|
||||
if (candidates.length === 0) {
|
||||
this.datNameScanMissAt.set(missKey, Date.now())
|
||||
this.logInfo('[ImageDecrypt] datName fallback scan miss', {
|
||||
accountDir,
|
||||
sessionId,
|
||||
imageDatName: datNameRaw,
|
||||
createTime,
|
||||
monthKey,
|
||||
baseMd5,
|
||||
allowThumbnail
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const ordered = this.sortDatCandidatePaths(candidates, baseMd5)
|
||||
for (const candidatePath of ordered) {
|
||||
if (!allowThumbnail && this.isThumbnailPath(candidatePath)) continue
|
||||
this.datNameScanMissAt.delete(missKey)
|
||||
this.logInfo('[ImageDecrypt] datName fallback selected', {
|
||||
accountDir,
|
||||
sessionId,
|
||||
imageDatName: datNameRaw,
|
||||
createTime,
|
||||
monthKey,
|
||||
baseMd5,
|
||||
allowThumbnail,
|
||||
selectedPath: candidatePath
|
||||
})
|
||||
return candidatePath
|
||||
}
|
||||
|
||||
this.datNameScanMissAt.set(missKey, Date.now())
|
||||
return null
|
||||
}
|
||||
|
||||
private resolveYearMonthFromCreateTime(createTime?: number): string {
|
||||
const raw = Number(createTime)
|
||||
if (!Number.isFinite(raw) || raw <= 0) return ''
|
||||
const ts = raw > 1e12 ? raw : raw * 1000
|
||||
const d = new Date(ts)
|
||||
if (Number.isNaN(d.getTime())) return ''
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
return `${y}-${m}`
|
||||
}
|
||||
|
||||
private collectDatCandidatesFromSessionMonth(
|
||||
accountDir: string,
|
||||
baseMd5: string,
|
||||
sessionId?: string,
|
||||
createTime?: number
|
||||
): string[] {
|
||||
const normalizedSessionId = String(sessionId || '').trim()
|
||||
const monthKey = this.resolveYearMonthFromCreateTime(createTime)
|
||||
if (!normalizedSessionId || !monthKey) return []
|
||||
|
||||
const attachRoots = this.getAttachScanRoots(accountDir)
|
||||
const cacheRoots = this.getMessageCacheScanRoots(accountDir)
|
||||
const sessionDirs = this.getAttachSessionDirCandidates(normalizedSessionId)
|
||||
const candidates = new Set<string>()
|
||||
const budget = { remaining: 600 }
|
||||
const targetDirs: Array<{ dir: string; depth: number }> = []
|
||||
|
||||
for (const root of attachRoots) {
|
||||
for (const sessionDir of sessionDirs) {
|
||||
targetDirs.push({ dir: join(root, sessionDir, monthKey), depth: 2 })
|
||||
targetDirs.push({ dir: join(root, sessionDir, monthKey, 'Img'), depth: 1 })
|
||||
targetDirs.push({ dir: join(root, sessionDir, monthKey, 'Image'), depth: 1 })
|
||||
}
|
||||
}
|
||||
|
||||
for (const root of cacheRoots) {
|
||||
for (const sessionDir of sessionDirs) {
|
||||
targetDirs.push({ dir: join(root, monthKey, 'Message', sessionDir, 'Bubble'), depth: 1 })
|
||||
targetDirs.push({ dir: join(root, monthKey, 'Message', sessionDir), depth: 2 })
|
||||
}
|
||||
}
|
||||
|
||||
for (const target of targetDirs) {
|
||||
if (budget.remaining <= 0) break
|
||||
this.scanDatCandidatesUnderRoot(target.dir, baseMd5, target.depth, candidates, budget)
|
||||
}
|
||||
|
||||
return Array.from(candidates)
|
||||
}
|
||||
|
||||
private getAttachScanRoots(accountDir: string): string[] {
|
||||
const roots: string[] = []
|
||||
const push = (value: string) => {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!normalized) return
|
||||
if (!roots.includes(normalized)) roots.push(normalized)
|
||||
}
|
||||
|
||||
push(join(accountDir, 'msg', 'attach'))
|
||||
push(join(accountDir, 'attach'))
|
||||
const parent = dirname(accountDir)
|
||||
if (parent && parent !== accountDir) {
|
||||
push(join(parent, 'msg', 'attach'))
|
||||
push(join(parent, 'attach'))
|
||||
}
|
||||
return roots
|
||||
}
|
||||
|
||||
private getMessageCacheScanRoots(accountDir: string): string[] {
|
||||
const roots: string[] = []
|
||||
const push = (value: string) => {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!normalized) return
|
||||
if (!roots.includes(normalized)) roots.push(normalized)
|
||||
}
|
||||
|
||||
push(join(accountDir, 'cache'))
|
||||
const parent = dirname(accountDir)
|
||||
if (parent && parent !== accountDir) {
|
||||
push(join(parent, 'cache'))
|
||||
}
|
||||
return roots
|
||||
}
|
||||
|
||||
private getAttachSessionDirCandidates(sessionId: string): string[] {
|
||||
const normalized = String(sessionId || '').trim()
|
||||
if (!normalized) return []
|
||||
const lower = normalized.toLowerCase()
|
||||
const cleaned = this.cleanAccountDirName(normalized)
|
||||
const inputs = Array.from(new Set([normalized, lower, cleaned, cleaned.toLowerCase()].filter(Boolean)))
|
||||
const results: string[] = []
|
||||
const push = (value: string) => {
|
||||
if (!value) return
|
||||
if (!results.includes(value)) results.push(value)
|
||||
}
|
||||
|
||||
for (const item of inputs) {
|
||||
push(item)
|
||||
const md5 = crypto.createHash('md5').update(item).digest('hex').toLowerCase()
|
||||
push(md5)
|
||||
push(md5.slice(0, 16))
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
private collectDatCandidatesFromAccountDir(accountDir: string, baseMd5: string): string[] {
|
||||
const roots = this.getDatScanRoots(accountDir)
|
||||
const candidates = new Set<string>()
|
||||
const budget = { remaining: 1400 }
|
||||
|
||||
for (const item of roots) {
|
||||
if (budget.remaining <= 0) break
|
||||
this.scanDatCandidatesUnderRoot(item.root, baseMd5, item.maxDepth, candidates, budget)
|
||||
}
|
||||
|
||||
if (candidates.size === 0 && budget.remaining <= 0) {
|
||||
this.logInfo('[ImageDecrypt] datName fallback budget exhausted', {
|
||||
accountDir,
|
||||
baseMd5,
|
||||
roots: roots.map((item) => item.root)
|
||||
})
|
||||
}
|
||||
|
||||
return Array.from(candidates)
|
||||
}
|
||||
|
||||
private getDatScanRoots(accountDir: string): Array<{ root: string; maxDepth: number }> {
|
||||
const roots: Array<{ root: string; maxDepth: number }> = []
|
||||
const push = (root: string, maxDepth: number) => {
|
||||
const normalized = String(root || '').trim()
|
||||
if (!normalized) return
|
||||
if (roots.some((item) => item.root === normalized)) return
|
||||
roots.push({ root: normalized, maxDepth })
|
||||
}
|
||||
|
||||
push(join(accountDir, 'attach'), 4)
|
||||
push(join(accountDir, 'msg', 'attach'), 4)
|
||||
push(join(accountDir, 'FileStorage', 'Image'), 3)
|
||||
push(join(accountDir, 'FileStorage', 'Image2'), 3)
|
||||
push(join(accountDir, 'FileStorage', 'MsgImg'), 3)
|
||||
|
||||
return roots
|
||||
}
|
||||
|
||||
private scanDatCandidatesUnderRoot(
|
||||
rootDir: string,
|
||||
baseMd5: string,
|
||||
maxDepth: number,
|
||||
out: Set<string>,
|
||||
budget: { remaining: number }
|
||||
): void {
|
||||
if (!rootDir || maxDepth < 0 || budget.remaining <= 0) return
|
||||
if (!existsSync(rootDir) || !this.isDirectory(rootDir)) return
|
||||
|
||||
const stack: Array<{ dir: string; depth: number }> = [{ dir: rootDir, depth: 0 }]
|
||||
while (stack.length > 0 && budget.remaining > 0) {
|
||||
const current = stack.pop()
|
||||
if (!current) break
|
||||
budget.remaining -= 1
|
||||
|
||||
let entries: Array<{ name: string; isFile: () => boolean; isDirectory: () => boolean }>
|
||||
try {
|
||||
entries = readdirSync(current.dir, { withFileTypes: true })
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue
|
||||
const name = String(entry.name || '')
|
||||
if (!this.isHardlinkCandidateName(name, baseMd5)) continue
|
||||
const fullPath = join(current.dir, name)
|
||||
if (existsSync(fullPath)) out.add(fullPath)
|
||||
}
|
||||
|
||||
if (current.depth >= maxDepth) continue
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue
|
||||
const name = String(entry.name || '')
|
||||
if (!name || name === '.' || name === '..') continue
|
||||
if (name.startsWith('.')) continue
|
||||
stack.push({ dir: join(current.dir, name), depth: current.depth + 1 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sortDatCandidatePaths(paths: string[], baseMd5: string): string[] {
|
||||
const list = Array.from(new Set(paths.filter(Boolean)))
|
||||
list.sort((a, b) => {
|
||||
const nameA = basename(a).toLowerCase()
|
||||
const nameB = basename(b).toLowerCase()
|
||||
const priorityA = this.getHardlinkCandidatePriority(nameA, baseMd5)
|
||||
const priorityB = this.getHardlinkCandidatePriority(nameB, baseMd5)
|
||||
if (priorityA !== priorityB) return priorityA - priorityB
|
||||
|
||||
let sizeA = 0
|
||||
let sizeB = 0
|
||||
try {
|
||||
sizeA = statSync(a).size
|
||||
} catch { }
|
||||
try {
|
||||
sizeB = statSync(b).size
|
||||
} catch { }
|
||||
if (sizeA !== sizeB) return sizeB - sizeA
|
||||
|
||||
let mtimeA = 0
|
||||
let mtimeB = 0
|
||||
try {
|
||||
mtimeA = statSync(a).mtimeMs
|
||||
} catch { }
|
||||
try {
|
||||
mtimeB = statSync(b).mtimeMs
|
||||
} catch { }
|
||||
if (mtimeA !== mtimeB) return mtimeB - mtimeA
|
||||
return nameA.localeCompare(nameB)
|
||||
})
|
||||
return list
|
||||
}
|
||||
|
||||
private isHardlinkCandidateName(fileName: string, baseMd5: string): boolean {
|
||||
const lower = String(fileName || '').trim().toLowerCase()
|
||||
if (!lower.endsWith('.dat')) return false
|
||||
const base = lower.slice(0, -4)
|
||||
if (base === baseMd5) return true
|
||||
if (base.startsWith(`${baseMd5}_`) || base.startsWith(`${baseMd5}.`)) return true
|
||||
if (base.length === baseMd5.length + 1 && base.startsWith(baseMd5)) return true
|
||||
return this.normalizeDatBase(base) === baseMd5
|
||||
}
|
||||
|
||||
private getHardlinkCandidatePriority(fileName: string, _baseMd5: string): number {
|
||||
const lower = String(fileName || '').trim().toLowerCase()
|
||||
if (!lower.endsWith('.dat')) return 999
|
||||
|
||||
const base = lower.slice(0, -4)
|
||||
if (
|
||||
base.endsWith('_h') ||
|
||||
base.endsWith('.h') ||
|
||||
base.endsWith('_hd') ||
|
||||
base.endsWith('.hd')
|
||||
) {
|
||||
return 0
|
||||
}
|
||||
if (base.endsWith('_b') || base.endsWith('.b')) return 1
|
||||
if (this.isThumbnailDat(lower)) return 3
|
||||
return 2
|
||||
}
|
||||
|
||||
private normalizeHardlinkDatPathByFileName(fullPath: string, fileName: string): string {
|
||||
const normalizedPath = String(fullPath || '').trim()
|
||||
const normalizedFileName = String(fileName || '').trim().toLowerCase()
|
||||
if (!normalizedPath || !normalizedFileName) return normalizedPath
|
||||
if (!normalizedFileName.endsWith('.dat')) return normalizedPath
|
||||
const normalizedBase = this.normalizeDatBase(normalizedFileName.slice(0, -4))
|
||||
if (!this.looksLikeMd5(normalizedBase)) return ''
|
||||
|
||||
// 最新策略:只要 hardlink 有记录,始终直接使用其记录路径(包括无后缀 DAT)。
|
||||
return normalizedPath
|
||||
}
|
||||
|
||||
private async resolveHardlinkPath(accountDir: string, md5: string, _sessionId?: string): Promise<string | null> {
|
||||
try {
|
||||
const normalizedMd5 = String(md5 || '').trim().toLowerCase()
|
||||
if (!this.looksLikeMd5(normalizedMd5)) return null
|
||||
const ready = await this.ensureWcdbReady()
|
||||
if (!ready) {
|
||||
this.logInfo('[ImageDecrypt] hardlink db not ready')
|
||||
return null
|
||||
}
|
||||
|
||||
const resolveResult = await wcdbService.resolveImageHardlink(md5, accountDir)
|
||||
const resolveResult = await wcdbService.resolveImageHardlink(normalizedMd5, accountDir)
|
||||
if (!resolveResult.success || !resolveResult.data) return null
|
||||
const fileName = String(resolveResult.data.file_name || '').trim()
|
||||
const fullPath = String(resolveResult.data.full_path || '').trim()
|
||||
if (!fileName) return null
|
||||
if (!fileName || !fullPath) return null
|
||||
|
||||
const lowerFileName = String(fileName).toLowerCase()
|
||||
if (lowerFileName.endsWith('.dat')) {
|
||||
const baseLower = lowerFileName.slice(0, -4)
|
||||
if (!this.isLikelyImageDatBase(baseLower) && !this.looksLikeMd5(baseLower)) {
|
||||
const normalizedBase = this.normalizeDatBase(lowerFileName.slice(0, -4))
|
||||
if (!this.looksLikeMd5(normalizedBase)) {
|
||||
this.logInfo('[ImageDecrypt] hardlink fileName rejected', { fileName })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
if (fullPath && existsSync(fullPath)) {
|
||||
this.logInfo('[ImageDecrypt] hardlink path hit', { fullPath })
|
||||
return fullPath
|
||||
const selectedPath = this.normalizeHardlinkDatPathByFileName(fullPath, fileName)
|
||||
if (existsSync(selectedPath)) {
|
||||
this.logInfo('[ImageDecrypt] hardlink path hit', { md5: normalizedMd5, fileName, fullPath, selectedPath })
|
||||
return selectedPath
|
||||
}
|
||||
this.logInfo('[ImageDecrypt] hardlink path miss', { fullPath, md5 })
|
||||
|
||||
this.logInfo('[ImageDecrypt] hardlink path miss', { md5: normalizedMd5, fileName, fullPath, selectedPath })
|
||||
return null
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -879,182 +1268,6 @@ export class ImageDecryptService {
|
||||
return value.replace(/'/g, "''")
|
||||
}
|
||||
|
||||
private async searchDatFile(
|
||||
accountDir: string,
|
||||
datName: string,
|
||||
allowThumbnail = true,
|
||||
thumbOnly = false
|
||||
): Promise<string | null> {
|
||||
const key = `${accountDir}|${datName}`
|
||||
const cached = this.resolvedCache.get(key)
|
||||
if (cached && existsSync(cached)) {
|
||||
const preferred = this.getPreferredDatVariantPath(cached, allowThumbnail)
|
||||
if (allowThumbnail || !this.isThumbnailPath(preferred)) return preferred
|
||||
}
|
||||
|
||||
const root = join(accountDir, 'msg', 'attach')
|
||||
if (!existsSync(root)) return null
|
||||
|
||||
// 优化1:快速概率性查找
|
||||
// 包含:1. 基于文件名的前缀猜测 (旧版)
|
||||
// 2. 基于日期的最近月份扫描 (新版无索引时)
|
||||
const fastHit = await this.fastProbabilisticSearch(root, datName, allowThumbnail)
|
||||
if (fastHit) {
|
||||
this.resolvedCache.set(key, fastHit)
|
||||
return fastHit
|
||||
}
|
||||
|
||||
// 优化2:兜底扫描 (异步非阻塞)
|
||||
const found = await this.walkForDatInWorker(root, datName.toLowerCase(), 8, allowThumbnail, thumbOnly)
|
||||
if (found) {
|
||||
this.resolvedCache.set(key, found)
|
||||
return found
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于文件名的哈希特征猜测可能的路径
|
||||
* 包含:1. 微信旧版结构 filename.substr(0, 2)/...
|
||||
* 2. 微信新版结构 msg/attach/{hash}/{YYYY-MM}/Img/filename
|
||||
*/
|
||||
private async fastProbabilisticSearch(root: string, datName: string, allowThumbnail = true): Promise<string | null> {
|
||||
const { promises: fs } = require('fs')
|
||||
const { join } = require('path')
|
||||
|
||||
try {
|
||||
// --- 策略 A: 旧版路径猜测 (msg/attach/xx/yy/...) ---
|
||||
const lowerName = datName.toLowerCase()
|
||||
const baseName = this.normalizeDatBase(lowerName)
|
||||
const targetNames = this.buildPreferredDatNames(baseName, allowThumbnail)
|
||||
|
||||
const candidates: string[] = []
|
||||
if (/^[a-f0-9]{32}$/.test(baseName)) {
|
||||
const dir1 = baseName.substring(0, 2)
|
||||
const dir2 = baseName.substring(2, 4)
|
||||
for (const targetName of targetNames) {
|
||||
candidates.push(
|
||||
join(root, dir1, dir2, targetName),
|
||||
join(root, dir1, dir2, 'Img', targetName),
|
||||
join(root, dir1, dir2, 'mg', targetName),
|
||||
join(root, dir1, dir2, 'Image', targetName)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for (const path of candidates) {
|
||||
try {
|
||||
await fs.access(path)
|
||||
return path
|
||||
} catch { }
|
||||
}
|
||||
|
||||
// --- 策略 B: 新版 Session 哈希路径猜测 ---
|
||||
try {
|
||||
const entries = await fs.readdir(root, { withFileTypes: true })
|
||||
const sessionDirs = entries
|
||||
.filter((e: any) => e.isDirectory() && e.name.length === 32 && /^[a-f0-9]+$/i.test(e.name))
|
||||
.map((e: any) => e.name)
|
||||
|
||||
if (sessionDirs.length === 0) return null
|
||||
|
||||
const now = new Date()
|
||||
const months: string[] = []
|
||||
// Imported mobile history can live in older YYYY-MM buckets; keep this bounded but wider than "recent 2 months".
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
|
||||
const mStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||||
months.push(mStr)
|
||||
}
|
||||
|
||||
const batchSize = 20
|
||||
for (let i = 0; i < sessionDirs.length; i += batchSize) {
|
||||
const batch = sessionDirs.slice(i, i + batchSize)
|
||||
const tasks = batch.map(async (sessDir: string) => {
|
||||
for (const month of months) {
|
||||
const subDirs = ['Img', 'Image']
|
||||
for (const sub of subDirs) {
|
||||
const dirPath = join(root, sessDir, month, sub)
|
||||
try { await fs.access(dirPath) } catch { continue }
|
||||
for (const name of targetNames) {
|
||||
const p = join(dirPath, name)
|
||||
try { await fs.access(p); return p } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
const results = await Promise.all(tasks)
|
||||
const hit = results.find(r => r !== null)
|
||||
if (hit) return hit
|
||||
}
|
||||
} catch { }
|
||||
|
||||
} catch { }
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 在同一目录下查找高清图变体
|
||||
* 优先 `_h`,再回退其他非缩略图变体
|
||||
*/
|
||||
private findHdVariantInSameDir(thumbPath: string): string | null {
|
||||
try {
|
||||
const dir = dirname(thumbPath)
|
||||
const fileName = basename(thumbPath)
|
||||
return this.findPreferredDatVariantInDir(dir, fileName, false)
|
||||
} catch { }
|
||||
return null
|
||||
}
|
||||
|
||||
private async searchDatFileInDir(
|
||||
dirPath: string,
|
||||
datName: string,
|
||||
allowThumbnail = true
|
||||
): Promise<string | null> {
|
||||
if (!existsSync(dirPath)) return null
|
||||
return await this.walkForDatInWorker(dirPath, datName.toLowerCase(), 3, allowThumbnail, false)
|
||||
}
|
||||
|
||||
private async walkForDatInWorker(
|
||||
root: string,
|
||||
datName: string,
|
||||
maxDepth = 4,
|
||||
allowThumbnail = true,
|
||||
thumbOnly = false
|
||||
): Promise<string | null> {
|
||||
const workerPath = join(__dirname, 'imageSearchWorker.js')
|
||||
return await new Promise((resolve) => {
|
||||
const worker = new Worker(workerPath, {
|
||||
workerData: { root, datName, maxDepth, allowThumbnail, thumbOnly }
|
||||
})
|
||||
|
||||
const cleanup = () => {
|
||||
worker.removeAllListeners()
|
||||
}
|
||||
|
||||
worker.on('message', (msg: any) => {
|
||||
if (msg && msg.type === 'done') {
|
||||
cleanup()
|
||||
void worker.terminate()
|
||||
resolve(msg.path || null)
|
||||
return
|
||||
}
|
||||
if (msg && msg.type === 'error') {
|
||||
cleanup()
|
||||
void worker.terminate()
|
||||
resolve(null)
|
||||
}
|
||||
})
|
||||
|
||||
worker.on('error', () => {
|
||||
cleanup()
|
||||
void worker.terminate()
|
||||
resolve(null)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private stripDatVariantSuffix(base: string): string {
|
||||
const lower = base.toLowerCase()
|
||||
const suffixes = ['_thumb', '.thumb', '_hd', '.hd', '_h', '.h', '_b', '.b', '_w', '.w', '_t', '.t', '_c', '.c']
|
||||
@@ -1069,77 +1282,6 @@ export class ImageDecryptService {
|
||||
return lower
|
||||
}
|
||||
|
||||
private getDatVariantPriority(name: string): number {
|
||||
const lower = name.toLowerCase()
|
||||
const baseLower = lower.endsWith('.dat') || lower.endsWith('.jpg') ? lower.slice(0, -4) : lower
|
||||
if (baseLower.endsWith('_h') || baseLower.endsWith('.h')) return 600
|
||||
if (baseLower.endsWith('_hd') || baseLower.endsWith('.hd')) return 550
|
||||
if (baseLower.endsWith('_b') || baseLower.endsWith('.b')) return 520
|
||||
if (baseLower.endsWith('_w') || baseLower.endsWith('.w')) return 510
|
||||
if (!this.hasXVariant(baseLower)) return 500
|
||||
if (baseLower.endsWith('_c') || baseLower.endsWith('.c')) return 400
|
||||
if (this.isThumbnailDat(lower)) return 100
|
||||
return 350
|
||||
}
|
||||
|
||||
private buildPreferredDatNames(baseName: string, allowThumbnail: boolean): string[] {
|
||||
if (!baseName) return []
|
||||
const names = [
|
||||
`${baseName}_h.dat`,
|
||||
`${baseName}.h.dat`,
|
||||
`${baseName}_hd.dat`,
|
||||
`${baseName}.hd.dat`,
|
||||
`${baseName}_b.dat`,
|
||||
`${baseName}.b.dat`,
|
||||
`${baseName}_w.dat`,
|
||||
`${baseName}.w.dat`,
|
||||
`${baseName}.dat`,
|
||||
`${baseName}_c.dat`,
|
||||
`${baseName}.c.dat`
|
||||
]
|
||||
if (allowThumbnail) {
|
||||
names.push(
|
||||
`${baseName}_thumb.dat`,
|
||||
`${baseName}.thumb.dat`,
|
||||
`${baseName}_t.dat`,
|
||||
`${baseName}.t.dat`
|
||||
)
|
||||
}
|
||||
return Array.from(new Set(names))
|
||||
}
|
||||
|
||||
private findPreferredDatVariantInDir(dirPath: string, baseName: string, allowThumbnail: boolean): string | null {
|
||||
let entries: string[]
|
||||
try {
|
||||
entries = readdirSync(dirPath)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const target = this.normalizeDatBase(baseName.toLowerCase())
|
||||
let bestPath: string | null = null
|
||||
let bestScore = Number.NEGATIVE_INFINITY
|
||||
for (const entry of entries) {
|
||||
const lower = entry.toLowerCase()
|
||||
if (!lower.endsWith('.dat')) continue
|
||||
if (!allowThumbnail && this.isThumbnailDat(lower)) continue
|
||||
const baseLower = lower.slice(0, -4)
|
||||
if (this.normalizeDatBase(baseLower) !== target) continue
|
||||
const score = this.getDatVariantPriority(lower)
|
||||
if (score > bestScore) {
|
||||
bestScore = score
|
||||
bestPath = join(dirPath, entry)
|
||||
}
|
||||
}
|
||||
return bestPath
|
||||
}
|
||||
|
||||
private getPreferredDatVariantPath(datPath: string, allowThumbnail: boolean): string {
|
||||
const lower = datPath.toLowerCase()
|
||||
if (!lower.endsWith('.dat')) return datPath
|
||||
const preferred = this.findPreferredDatVariantInDir(dirname(datPath), basename(datPath), allowThumbnail)
|
||||
return preferred || datPath
|
||||
}
|
||||
|
||||
private normalizeDatBase(name: string): string {
|
||||
let base = name.toLowerCase()
|
||||
if (base.endsWith('.dat') || base.endsWith('.jpg')) {
|
||||
@@ -1154,122 +1296,6 @@ export class ImageDecryptService {
|
||||
}
|
||||
}
|
||||
|
||||
private hasImageVariantSuffix(baseLower: string): boolean {
|
||||
return this.stripDatVariantSuffix(baseLower) !== baseLower
|
||||
}
|
||||
|
||||
private isLikelyImageDatBase(baseLower: string): boolean {
|
||||
return this.hasImageVariantSuffix(baseLower) || this.looksLikeMd5(this.normalizeDatBase(baseLower))
|
||||
}
|
||||
|
||||
|
||||
|
||||
private findCachedOutput(cacheKey: string, preferHd: boolean = false, sessionId?: string): string | null {
|
||||
const allRoots = this.getAllCacheRoots()
|
||||
const normalizedKey = this.normalizeDatBase(cacheKey.toLowerCase())
|
||||
const extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp']
|
||||
|
||||
// 遍历所有可能的缓存根路径
|
||||
for (const root of allRoots) {
|
||||
// 策略1: 新目录结构 Images/{sessionId}/{YYYY-MM}/{file}_hd.jpg
|
||||
if (sessionId) {
|
||||
const sessionDir = join(root, this.sanitizeDirName(sessionId))
|
||||
if (existsSync(sessionDir)) {
|
||||
try {
|
||||
const dateDirs = readdirSync(sessionDir, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory() && /^\d{4}-\d{2}$/.test(d.name))
|
||||
.map(d => d.name)
|
||||
.sort()
|
||||
.reverse() // 最新的日期优先
|
||||
|
||||
for (const dateDir of dateDirs) {
|
||||
const imageDir = join(sessionDir, dateDir)
|
||||
const hit = this.findCachedOutputInDir(imageDir, normalizedKey, extensions, preferHd)
|
||||
if (hit) return hit
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
|
||||
// 策略2: 遍历所有 sessionId 目录查找(如果没有指定 sessionId)
|
||||
try {
|
||||
const sessionDirs = readdirSync(root, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory())
|
||||
.map(d => d.name)
|
||||
|
||||
for (const session of sessionDirs) {
|
||||
const sessionDir = join(root, session)
|
||||
// 检查是否是日期目录结构
|
||||
try {
|
||||
const subDirs = readdirSync(sessionDir, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory() && /^\d{4}-\d{2}$/.test(d.name))
|
||||
.map(d => d.name)
|
||||
|
||||
for (const dateDir of subDirs) {
|
||||
const imageDir = join(sessionDir, dateDir)
|
||||
const hit = this.findCachedOutputInDir(imageDir, normalizedKey, extensions, preferHd)
|
||||
if (hit) return hit
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
} catch { }
|
||||
|
||||
// 策略3: 旧目录结构 Images/{normalizedKey}/{normalizedKey}_thumb.jpg
|
||||
const oldImageDir = join(root, normalizedKey)
|
||||
if (existsSync(oldImageDir)) {
|
||||
const hit = this.findCachedOutputInDir(oldImageDir, normalizedKey, extensions, preferHd)
|
||||
if (hit) return hit
|
||||
}
|
||||
|
||||
// 策略4: 最旧的平铺结构 Images/{file}.jpg
|
||||
for (const ext of extensions) {
|
||||
const candidate = join(root, `${cacheKey}${ext}`)
|
||||
if (existsSync(candidate)) return candidate
|
||||
}
|
||||
for (const ext of extensions) {
|
||||
const candidate = join(root, `${cacheKey}_t${ext}`)
|
||||
if (existsSync(candidate)) return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private findCachedOutputInDir(
|
||||
dirPath: string,
|
||||
normalizedKey: string,
|
||||
extensions: string[],
|
||||
preferHd: boolean
|
||||
): string | null {
|
||||
// 先检查并删除旧的 .hevc 文件(ffmpeg 转换失败时遗留的)
|
||||
const hevcThumb = join(dirPath, `${normalizedKey}_thumb.hevc`)
|
||||
const hevcHd = join(dirPath, `${normalizedKey}_hd.hevc`)
|
||||
try {
|
||||
if (existsSync(hevcThumb)) {
|
||||
require('fs').unlinkSync(hevcThumb)
|
||||
}
|
||||
if (existsSync(hevcHd)) {
|
||||
require('fs').unlinkSync(hevcHd)
|
||||
}
|
||||
} catch { }
|
||||
|
||||
for (const ext of extensions) {
|
||||
if (preferHd) {
|
||||
const hdPath = join(dirPath, `${normalizedKey}_hd${ext}`)
|
||||
if (existsSync(hdPath)) return hdPath
|
||||
}
|
||||
const thumbPath = join(dirPath, `${normalizedKey}_thumb${ext}`)
|
||||
if (existsSync(thumbPath)) return thumbPath
|
||||
|
||||
// 允许返回 _hd 格式(因为它有 _hd 变体后缀)
|
||||
if (!preferHd) {
|
||||
const hdPath = join(dirPath, `${normalizedKey}_hd${ext}`)
|
||||
if (existsSync(hdPath)) return hdPath
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private getCacheOutputPathFromDat(datPath: string, ext: string, sessionId?: string): string {
|
||||
const name = basename(datPath)
|
||||
const lower = name.toLowerCase()
|
||||
@@ -1285,13 +1311,57 @@ export class ImageDecryptService {
|
||||
const contactDir = this.sanitizeDirName(sessionId || 'unknown')
|
||||
const timeDir = this.resolveTimeDir(datPath)
|
||||
const outputDir = join(this.getCacheRoot(), contactDir, timeDir)
|
||||
if (!existsSync(outputDir)) {
|
||||
mkdirSync(outputDir, { recursive: true })
|
||||
}
|
||||
this.ensureDir(outputDir)
|
||||
|
||||
return join(outputDir, `${normalizedBase}${suffix}${ext}`)
|
||||
}
|
||||
|
||||
private buildCacheOutputCandidatesFromDat(datPath: string, sessionId?: string, preferHd = false): string[] {
|
||||
const name = basename(datPath)
|
||||
const lower = name.toLowerCase()
|
||||
const base = lower.endsWith('.dat') ? name.slice(0, -4) : name
|
||||
const normalizedBase = this.normalizeDatBase(base)
|
||||
const suffixes = preferHd ? ['_hd', '_thumb'] : ['_thumb', '_hd']
|
||||
const extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp']
|
||||
|
||||
const root = this.getCacheRoot()
|
||||
const contactDir = this.sanitizeDirName(sessionId || 'unknown')
|
||||
const timeDir = this.resolveTimeDir(datPath)
|
||||
const currentDir = join(root, contactDir, timeDir)
|
||||
const legacyDir = join(root, normalizedBase)
|
||||
const candidates: string[] = []
|
||||
|
||||
for (const suffix of suffixes) {
|
||||
for (const ext of extensions) {
|
||||
candidates.push(join(currentDir, `${normalizedBase}${suffix}${ext}`))
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容旧目录结构
|
||||
for (const suffix of suffixes) {
|
||||
for (const ext of extensions) {
|
||||
candidates.push(join(legacyDir, `${normalizedBase}${suffix}${ext}`))
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容最旧平铺结构
|
||||
for (const ext of extensions) {
|
||||
candidates.push(join(root, `${normalizedBase}${ext}`))
|
||||
candidates.push(join(root, `${normalizedBase}_t${ext}`))
|
||||
candidates.push(join(root, `${normalizedBase}_hd${ext}`))
|
||||
}
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
private findCachedOutputByDatPath(datPath: string, sessionId?: string, preferHd = false): string | null {
|
||||
const candidates = this.buildCacheOutputCandidatesFromDat(datPath, sessionId, preferHd)
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) return candidate
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private cacheResolvedPaths(cacheKey: string, imageMd5: string | undefined, imageDatName: string | undefined, outputPath: string): void {
|
||||
this.resolvedCache.set(cacheKey, outputPath)
|
||||
if (imageMd5 && imageMd5 !== cacheKey) {
|
||||
@@ -1334,43 +1404,37 @@ export class ImageDecryptService {
|
||||
if (imageDatName) this.updateFlags.delete(imageDatName)
|
||||
}
|
||||
|
||||
private getCachedDatDir(accountDir: string, imageDatName?: string, imageMd5?: string): string | null {
|
||||
const keys = [
|
||||
imageDatName ? `${accountDir}|${imageDatName}` : null,
|
||||
imageDatName ? `${accountDir}|${this.normalizeDatBase(imageDatName)}` : null,
|
||||
imageMd5 ? `${accountDir}|${imageMd5}` : null
|
||||
].filter(Boolean) as string[]
|
||||
for (const key of keys) {
|
||||
const cached = this.resolvedCache.get(key)
|
||||
if (cached && existsSync(cached)) return dirname(cached)
|
||||
private getActiveWindowsSafely(): Array<{ isDestroyed: () => boolean; webContents: { send: (channel: string, payload: unknown) => void } }> {
|
||||
try {
|
||||
const getter = (BrowserWindow as unknown as { getAllWindows?: () => any[] } | undefined)?.getAllWindows
|
||||
if (typeof getter !== 'function') return []
|
||||
const windows = getter()
|
||||
if (!Array.isArray(windows)) return []
|
||||
return windows.filter((win) => (
|
||||
win &&
|
||||
typeof win.isDestroyed === 'function' &&
|
||||
win.webContents &&
|
||||
typeof win.webContents.send === 'function'
|
||||
))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private findNonThumbnailVariantInDir(dirPath: string, baseName: string): string | null {
|
||||
return this.findPreferredDatVariantInDir(dirPath, baseName, false)
|
||||
}
|
||||
|
||||
private isNonThumbnailVariantDat(datPath: string): boolean {
|
||||
const lower = basename(datPath).toLowerCase()
|
||||
if (!lower.endsWith('.dat')) return false
|
||||
if (this.isThumbnailDat(lower)) return false
|
||||
const baseLower = lower.slice(0, -4)
|
||||
return this.isLikelyImageDatBase(baseLower)
|
||||
}
|
||||
|
||||
private emitImageUpdate(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }, cacheKey: string): void {
|
||||
private emitImageUpdate(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; suppressEvents?: boolean }, cacheKey: string): void {
|
||||
if (!this.shouldEmitImageEvents(payload)) return
|
||||
const message = { cacheKey, imageMd5: payload.imageMd5, imageDatName: payload.imageDatName }
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
for (const win of this.getActiveWindowsSafely()) {
|
||||
if (!win.isDestroyed()) {
|
||||
win.webContents.send('image:updateAvailable', message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private emitCacheResolved(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }, cacheKey: string, localPath: string): void {
|
||||
private emitCacheResolved(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; suppressEvents?: boolean }, cacheKey: string, localPath: string): void {
|
||||
if (!this.shouldEmitImageEvents(payload)) return
|
||||
const message = { cacheKey, imageMd5: payload.imageMd5, imageDatName: payload.imageDatName, localPath }
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
for (const win of this.getActiveWindowsSafely()) {
|
||||
if (!win.isDestroyed()) {
|
||||
win.webContents.send('image:cacheResolved', message)
|
||||
}
|
||||
@@ -1378,13 +1442,14 @@ export class ImageDecryptService {
|
||||
}
|
||||
|
||||
private emitDecryptProgress(
|
||||
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string },
|
||||
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; suppressEvents?: boolean },
|
||||
cacheKey: string,
|
||||
stage: DecryptProgressStage,
|
||||
progress: number,
|
||||
status: 'running' | 'done' | 'error',
|
||||
message?: string
|
||||
): void {
|
||||
if (!this.shouldEmitImageEvents(payload)) return
|
||||
const safeProgress = Math.max(0, Math.min(100, Math.floor(progress)))
|
||||
const event = {
|
||||
cacheKey,
|
||||
@@ -1395,261 +1460,56 @@ export class ImageDecryptService {
|
||||
status,
|
||||
message: message || ''
|
||||
}
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
for (const win of this.getActiveWindowsSafely()) {
|
||||
if (!win.isDestroyed()) {
|
||||
win.webContents.send('image:decryptProgress', event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureCacheIndexed(): Promise<void> {
|
||||
if (this.cacheIndexed) return
|
||||
if (this.cacheIndexing) return this.cacheIndexing
|
||||
this.cacheIndexing = (async () => {
|
||||
// 扫描所有可能的缓存根目录
|
||||
const allRoots = this.getAllCacheRoots()
|
||||
this.logInfo('开始索引缓存', { roots: allRoots.length })
|
||||
|
||||
for (const root of allRoots) {
|
||||
try {
|
||||
this.indexCacheDir(root, 3, 0) // 增加深度到 3,支持 sessionId/YYYY-MM 结构
|
||||
} catch (e) {
|
||||
this.logError('索引目录失败', e, { root })
|
||||
}
|
||||
}
|
||||
|
||||
this.logInfo('缓存索引完成', { entries: this.resolvedCache.size })
|
||||
this.cacheIndexed = true
|
||||
this.cacheIndexing = null
|
||||
})()
|
||||
return this.cacheIndexing
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有可能的缓存根路径(用于查找已缓存的图片)
|
||||
* 包含当前路径、配置路径、旧版本路径
|
||||
*/
|
||||
private getAllCacheRoots(): string[] {
|
||||
const roots: string[] = []
|
||||
const configured = this.configService.get('cachePath')
|
||||
const documentsPath = app.getPath('documents')
|
||||
|
||||
// 主要路径(当前使用的)
|
||||
const mainRoot = this.getCacheRoot()
|
||||
roots.push(mainRoot)
|
||||
|
||||
// 如果配置了自定义路径,也检查其下的 Images
|
||||
if (configured) {
|
||||
roots.push(join(configured, 'Images'))
|
||||
roots.push(join(configured, 'images'))
|
||||
}
|
||||
|
||||
// 默认路径
|
||||
roots.push(join(documentsPath, 'WeFlow', 'Images'))
|
||||
roots.push(join(documentsPath, 'WeFlow', 'images'))
|
||||
|
||||
// 兼容旧路径(如果有的话)
|
||||
roots.push(join(documentsPath, 'WeFlowData', 'Images'))
|
||||
|
||||
// 去重并过滤存在的路径
|
||||
const uniqueRoots = Array.from(new Set(roots))
|
||||
const existingRoots = uniqueRoots.filter(r => existsSync(r))
|
||||
|
||||
return existingRoots
|
||||
}
|
||||
|
||||
private indexCacheDir(root: string, maxDepth: number, depth: number): void {
|
||||
let entries: string[]
|
||||
try {
|
||||
entries = readdirSync(root)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp']
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(root, entry)
|
||||
let stat: ReturnType<typeof statSync>
|
||||
try {
|
||||
stat = statSync(fullPath)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (stat.isDirectory()) {
|
||||
if (depth < maxDepth) {
|
||||
this.indexCacheDir(fullPath, maxDepth, depth + 1)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (!stat.isFile()) continue
|
||||
const lower = entry.toLowerCase()
|
||||
const ext = extensions.find((item) => lower.endsWith(item))
|
||||
if (!ext) continue
|
||||
const base = entry.slice(0, -ext.length)
|
||||
this.addCacheIndex(base, fullPath)
|
||||
const normalized = this.normalizeDatBase(base)
|
||||
if (normalized && normalized !== base.toLowerCase()) {
|
||||
this.addCacheIndex(normalized, fullPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private addCacheIndex(key: string, path: string): void {
|
||||
const normalizedKey = key.toLowerCase()
|
||||
const existing = this.resolvedCache.get(normalizedKey)
|
||||
if (existing) {
|
||||
const existingIsThumb = this.isThumbnailPath(existing)
|
||||
const candidateIsThumb = this.isThumbnailPath(path)
|
||||
if (!existingIsThumb && candidateIsThumb) return
|
||||
}
|
||||
this.resolvedCache.set(normalizedKey, path)
|
||||
}
|
||||
|
||||
private getCacheRoot(): string {
|
||||
const configured = this.configService.get('cachePath')
|
||||
const root = configured
|
||||
? join(configured, 'Images')
|
||||
: join(app.getPath('documents'), 'WeFlow', 'Images')
|
||||
if (!existsSync(root)) {
|
||||
mkdirSync(root, { recursive: true })
|
||||
let root = this.cacheRootPath
|
||||
if (!root) {
|
||||
const configured = this.configService.get('cachePath')
|
||||
root = configured
|
||||
? join(configured, 'Images')
|
||||
: join(this.getDocumentsPath(), 'WeFlow', 'Images')
|
||||
this.cacheRootPath = root
|
||||
}
|
||||
this.ensureDir(root)
|
||||
return root
|
||||
}
|
||||
|
||||
private resolveAesKey(aesKeyRaw: string): Buffer | null {
|
||||
const trimmed = aesKeyRaw?.trim() ?? ''
|
||||
if (!trimmed) return null
|
||||
return this.asciiKey16(trimmed)
|
||||
private ensureDir(dirPath: string): void {
|
||||
if (!dirPath) return
|
||||
if (this.ensuredDirs.has(dirPath) && existsSync(dirPath)) return
|
||||
if (!existsSync(dirPath)) {
|
||||
mkdirSync(dirPath, { recursive: true })
|
||||
}
|
||||
this.ensuredDirs.add(dirPath)
|
||||
}
|
||||
|
||||
private async decryptDatAuto(datPath: string, xorKey: number, aesKey: Buffer | null): Promise<Buffer> {
|
||||
const version = this.getDatVersion(datPath)
|
||||
|
||||
if (version === 0) {
|
||||
return this.decryptDatV3(datPath, xorKey)
|
||||
}
|
||||
if (version === 1) {
|
||||
const key = this.asciiKey16(this.defaultV1AesKey)
|
||||
return this.decryptDatV4(datPath, xorKey, key)
|
||||
}
|
||||
// version === 2
|
||||
if (!aesKey || aesKey.length !== 16) {
|
||||
throw new Error('请到设置配置图片解密密钥')
|
||||
}
|
||||
return this.decryptDatV4(datPath, xorKey, aesKey)
|
||||
}
|
||||
|
||||
private getDatVersion(inputPath: string): number {
|
||||
if (!existsSync(inputPath)) {
|
||||
throw new Error('文件不存在')
|
||||
}
|
||||
const bytes = readFileSync(inputPath)
|
||||
if (bytes.length < 6) {
|
||||
return 0
|
||||
}
|
||||
const signature = bytes.subarray(0, 6)
|
||||
if (this.compareBytes(signature, Buffer.from([0x07, 0x08, 0x56, 0x31, 0x08, 0x07]))) {
|
||||
return 1
|
||||
}
|
||||
if (this.compareBytes(signature, Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07]))) {
|
||||
return 2
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
private decryptDatV3(inputPath: string, xorKey: number): Buffer {
|
||||
const data = readFileSync(inputPath)
|
||||
const out = Buffer.alloc(data.length)
|
||||
for (let i = 0; i < data.length; i += 1) {
|
||||
out[i] = data[i] ^ xorKey
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private decryptDatV4(inputPath: string, xorKey: number, aesKey: Buffer): Buffer {
|
||||
const bytes = readFileSync(inputPath)
|
||||
if (bytes.length < 0x0f) {
|
||||
throw new Error('文件太小,无法解析')
|
||||
}
|
||||
|
||||
const header = bytes.subarray(0, 0x0f)
|
||||
const data = bytes.subarray(0x0f)
|
||||
const aesSize = this.bytesToInt32(header.subarray(6, 10))
|
||||
const xorSize = this.bytesToInt32(header.subarray(10, 14))
|
||||
|
||||
// AES 数据需要对齐到 16 字节(PKCS7 填充)
|
||||
// 当 aesSize % 16 === 0 时,仍需要额外 16 字节的填充
|
||||
const remainder = ((aesSize % 16) + 16) % 16
|
||||
const alignedAesSize = aesSize + (16 - remainder)
|
||||
|
||||
if (alignedAesSize > data.length) {
|
||||
throw new Error('文件格式异常:AES 数据长度超过文件实际长度')
|
||||
}
|
||||
|
||||
const aesData = data.subarray(0, alignedAesSize)
|
||||
let unpadded: Buffer = Buffer.alloc(0)
|
||||
if (aesData.length > 0) {
|
||||
const decipher = crypto.createDecipheriv('aes-128-ecb', aesKey, null)
|
||||
decipher.setAutoPadding(false)
|
||||
const decrypted = Buffer.concat([decipher.update(aesData), decipher.final()])
|
||||
|
||||
// 使用 PKCS7 填充移除
|
||||
unpadded = this.strictRemovePadding(decrypted)
|
||||
}
|
||||
|
||||
const remaining = data.subarray(alignedAesSize)
|
||||
if (xorSize < 0 || xorSize > remaining.length) {
|
||||
throw new Error('文件格式异常:XOR 数据长度不合法')
|
||||
}
|
||||
|
||||
let rawData = Buffer.alloc(0)
|
||||
let xoredData = Buffer.alloc(0)
|
||||
if (xorSize > 0) {
|
||||
const rawLength = remaining.length - xorSize
|
||||
if (rawLength < 0) {
|
||||
throw new Error('文件格式异常:原始数据长度小于XOR长度')
|
||||
}
|
||||
rawData = remaining.subarray(0, rawLength)
|
||||
const xorData = remaining.subarray(rawLength)
|
||||
xoredData = Buffer.alloc(xorData.length)
|
||||
for (let i = 0; i < xorData.length; i += 1) {
|
||||
xoredData[i] = xorData[i] ^ xorKey
|
||||
}
|
||||
} else {
|
||||
rawData = remaining
|
||||
xoredData = Buffer.alloc(0)
|
||||
}
|
||||
|
||||
return Buffer.concat([unpadded, rawData, xoredData])
|
||||
}
|
||||
|
||||
private bytesToInt32(bytes: Buffer): number {
|
||||
if (bytes.length !== 4) {
|
||||
throw new Error('需要 4 个字节')
|
||||
}
|
||||
return bytes[0] | (bytes[1] << 8) | (bytes[2] << 16) | (bytes[3] << 24)
|
||||
}
|
||||
|
||||
asciiKey16(keyString: string): Buffer {
|
||||
if (keyString.length < 16) {
|
||||
throw new Error('AES密钥至少需要 16 个字符')
|
||||
}
|
||||
return Buffer.from(keyString, 'ascii').subarray(0, 16)
|
||||
}
|
||||
|
||||
private strictRemovePadding(data: Buffer): Buffer {
|
||||
if (!data.length) {
|
||||
throw new Error('解密结果为空,填充非法')
|
||||
}
|
||||
const paddingLength = data[data.length - 1]
|
||||
if (paddingLength === 0 || paddingLength > 16 || paddingLength > data.length) {
|
||||
throw new Error('PKCS7 填充长度非法')
|
||||
}
|
||||
for (let i = data.length - paddingLength; i < data.length; i += 1) {
|
||||
if (data[i] !== paddingLength) {
|
||||
throw new Error('PKCS7 填充内容非法')
|
||||
private tryDecryptDatWithNative(
|
||||
datPath: string,
|
||||
xorKey: number,
|
||||
aesKey?: string
|
||||
): { data: Buffer; ext: string; isWxgf: boolean } | null {
|
||||
const result = decryptDatViaNative(datPath, xorKey, aesKey)
|
||||
if (!this.nativeLogged) {
|
||||
this.nativeLogged = true
|
||||
if (result) {
|
||||
this.logInfo('Rust 原生解密已启用', {
|
||||
addonPath: nativeAddonLocation(),
|
||||
source: 'native'
|
||||
})
|
||||
} else {
|
||||
this.logInfo('Rust 原生解密不可用', {
|
||||
addonPath: nativeAddonLocation(),
|
||||
source: 'native_unavailable'
|
||||
})
|
||||
}
|
||||
}
|
||||
return data.subarray(0, data.length - paddingLength)
|
||||
return result
|
||||
}
|
||||
|
||||
private detectImageExtension(buffer: Buffer): string | null {
|
||||
@@ -1723,91 +1583,6 @@ export class ImageDecryptService {
|
||||
return ext === '.gif' || ext === '.png' || ext === '.jpg' || ext === '.jpeg' || ext === '.webp'
|
||||
}
|
||||
|
||||
private compareBytes(a: Buffer, b: Buffer): boolean {
|
||||
if (a.length !== b.length) return false
|
||||
for (let i = 0; i < a.length; i += 1) {
|
||||
if (a[i] !== b[i]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 保留原有的批量检测 XOR 密钥方法(用于兼容)
|
||||
async batchDetectXorKey(dirPath: string, maxFiles: number = 100): Promise<number | null> {
|
||||
const keyCount: Map<number, number> = new Map()
|
||||
let filesChecked = 0
|
||||
|
||||
const V1_SIGNATURE = Buffer.from([0x07, 0x08, 0x56, 0x31, 0x08, 0x07])
|
||||
const V2_SIGNATURE = Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07])
|
||||
const IMAGE_SIGNATURES: { [key: string]: Buffer } = {
|
||||
jpg: Buffer.from([0xFF, 0xD8, 0xFF]),
|
||||
png: Buffer.from([0x89, 0x50, 0x4E, 0x47]),
|
||||
gif: Buffer.from([0x47, 0x49, 0x46, 0x38]),
|
||||
bmp: Buffer.from([0x42, 0x4D]),
|
||||
webp: Buffer.from([0x52, 0x49, 0x46, 0x46])
|
||||
}
|
||||
|
||||
const detectXorKeyFromV3 = (header: Buffer): number | null => {
|
||||
for (const [, signature] of Object.entries(IMAGE_SIGNATURES)) {
|
||||
const xorKey = header[0] ^ signature[0]
|
||||
let valid = true
|
||||
for (let i = 0; i < signature.length && i < header.length; i++) {
|
||||
if ((header[i] ^ xorKey) !== signature[i]) {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if (valid) return xorKey
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const scanDir = (dir: string) => {
|
||||
if (filesChecked >= maxFiles) return
|
||||
try {
|
||||
const entries = readdirSync(dir, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
if (filesChecked >= maxFiles) return
|
||||
const fullPath = join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
scanDir(fullPath)
|
||||
} else if (entry.name.endsWith('.dat')) {
|
||||
try {
|
||||
const header = Buffer.alloc(16)
|
||||
const fd = require('fs').openSync(fullPath, 'r')
|
||||
require('fs').readSync(fd, header, 0, 16, 0)
|
||||
require('fs').closeSync(fd)
|
||||
|
||||
if (header.subarray(0, 6).equals(V1_SIGNATURE) || header.subarray(0, 6).equals(V2_SIGNATURE)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const key = detectXorKeyFromV3(header)
|
||||
if (key !== null) {
|
||||
keyCount.set(key, (keyCount.get(key) || 0) + 1)
|
||||
filesChecked++
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
scanDir(dirPath)
|
||||
|
||||
if (keyCount.size === 0) return null
|
||||
|
||||
let maxCount = 0
|
||||
let mostCommonKey: number | null = null
|
||||
keyCount.forEach((count, key) => {
|
||||
if (count > maxCount) {
|
||||
maxCount = count
|
||||
mostCommonKey = key
|
||||
}
|
||||
})
|
||||
|
||||
return mostCommonKey
|
||||
}
|
||||
|
||||
/**
|
||||
* 解包 wxgf 格式
|
||||
* wxgf 是微信的图片格式,内部使用 HEVC 编码
|
||||
@@ -1857,45 +1632,39 @@ export class ImageDecryptService {
|
||||
* 从 wxgf 数据中提取 HEVC NALU 裸流
|
||||
*/
|
||||
private extractHevcNalu(buffer: Buffer): Buffer | null {
|
||||
const nalUnits: Buffer[] = []
|
||||
const starts: number[] = []
|
||||
let i = 4
|
||||
|
||||
while (i < buffer.length - 4) {
|
||||
if (buffer[i] === 0x00 && buffer[i + 1] === 0x00 &&
|
||||
buffer[i + 2] === 0x00 && buffer[i + 3] === 0x01) {
|
||||
let nalStart = i
|
||||
let nalEnd = buffer.length
|
||||
while (i < buffer.length - 3) {
|
||||
const hasPrefix4 = buffer[i] === 0x00 && buffer[i + 1] === 0x00 &&
|
||||
buffer[i + 2] === 0x00 && buffer[i + 3] === 0x01
|
||||
const hasPrefix3 = buffer[i] === 0x00 && buffer[i + 1] === 0x00 &&
|
||||
buffer[i + 2] === 0x01
|
||||
|
||||
for (let j = i + 4; j < buffer.length - 3; j++) {
|
||||
if (buffer[j] === 0x00 && buffer[j + 1] === 0x00) {
|
||||
if (buffer[j + 2] === 0x01 ||
|
||||
(buffer[j + 2] === 0x00 && j + 3 < buffer.length && buffer[j + 3] === 0x01)) {
|
||||
nalEnd = j
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nalUnit = buffer.subarray(nalStart, nalEnd)
|
||||
if (nalUnit.length > 3) {
|
||||
nalUnits.push(nalUnit)
|
||||
}
|
||||
i = nalEnd
|
||||
} else {
|
||||
i++
|
||||
if (hasPrefix4 || hasPrefix3) {
|
||||
starts.push(i)
|
||||
i += hasPrefix4 ? 4 : 3
|
||||
continue
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
|
||||
if (nalUnits.length === 0) {
|
||||
for (let j = 4; j < buffer.length - 4; j++) {
|
||||
if (buffer[j] === 0x00 && buffer[j + 1] === 0x00 &&
|
||||
buffer[j + 2] === 0x00 && buffer[j + 3] === 0x01) {
|
||||
return buffer.subarray(j)
|
||||
}
|
||||
}
|
||||
return null
|
||||
if (starts.length === 0) return null
|
||||
|
||||
const nalUnits: Buffer[] = []
|
||||
for (let index = 0; index < starts.length; index += 1) {
|
||||
const start = starts[index]
|
||||
const end = index + 1 < starts.length ? starts[index + 1] : buffer.length
|
||||
const hasPrefix4 = buffer[start] === 0x00 && buffer[start + 1] === 0x00 &&
|
||||
buffer[start + 2] === 0x00 && buffer[start + 3] === 0x01
|
||||
const prefixLength = hasPrefix4 ? 4 : 3
|
||||
const payloadStart = start + prefixLength
|
||||
if (payloadStart >= end) continue
|
||||
nalUnits.push(Buffer.from([0x00, 0x00, 0x00, 0x01]))
|
||||
nalUnits.push(buffer.subarray(payloadStart, end))
|
||||
}
|
||||
|
||||
if (nalUnits.length === 0) return null
|
||||
return Buffer.concat(nalUnits)
|
||||
}
|
||||
|
||||
@@ -1921,11 +1690,11 @@ export class ImageDecryptService {
|
||||
const ffmpeg = this.getFfmpegPath()
|
||||
this.logInfo('ffmpeg 转换开始', { ffmpegPath: ffmpeg, hevcSize: hevcData.length })
|
||||
|
||||
const tmpDir = join(app.getPath('temp'), 'weflow_hevc')
|
||||
const tmpDir = join(this.getTempPath(), 'weflow_hevc')
|
||||
if (!existsSync(tmpDir)) mkdirSync(tmpDir, { recursive: true })
|
||||
const ts = Date.now()
|
||||
const tmpInput = join(tmpDir, `hevc_${ts}.hevc`)
|
||||
const tmpOutput = join(tmpDir, `hevc_${ts}.jpg`)
|
||||
const uniqueId = `${process.pid}_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`
|
||||
const tmpInput = join(tmpDir, `hevc_${uniqueId}.hevc`)
|
||||
const tmpOutput = join(tmpDir, `hevc_${uniqueId}.jpg`)
|
||||
|
||||
try {
|
||||
await writeFile(tmpInput, hevcData)
|
||||
@@ -1933,6 +1702,7 @@ export class ImageDecryptService {
|
||||
// 依次尝试: 1) -f hevc 裸流 2) 不指定格式让 ffmpeg 自动检测
|
||||
const attempts: { label: string; inputArgs: string[] }[] = [
|
||||
{ label: 'hevc raw', inputArgs: ['-f', 'hevc', '-i', tmpInput] },
|
||||
{ label: 'h265 raw', inputArgs: ['-f', 'h265', '-i', tmpInput] },
|
||||
{ label: 'auto detect', inputArgs: ['-i', tmpInput] },
|
||||
]
|
||||
|
||||
@@ -1961,6 +1731,7 @@ export class ImageDecryptService {
|
||||
|
||||
const args = [
|
||||
'-hide_banner', '-loglevel', 'error',
|
||||
'-y',
|
||||
...inputArgs,
|
||||
'-vframes', '1', '-q:v', '2', '-f', 'image2', tmpOutput
|
||||
]
|
||||
@@ -2015,11 +1786,6 @@ export class ImageDecryptService {
|
||||
return lower.includes('_t.dat') || lower.includes('.t.dat') || lower.includes('_thumb.dat')
|
||||
}
|
||||
|
||||
private hasXVariant(base: string): boolean {
|
||||
const lower = base.toLowerCase()
|
||||
return this.stripDatVariantSuffix(lower) !== lower
|
||||
}
|
||||
|
||||
private isHdPath(p: string): boolean {
|
||||
return p.toLowerCase().includes('_hd') || p.toLowerCase().includes('_h')
|
||||
}
|
||||
@@ -2044,42 +1810,43 @@ export class ImageDecryptService {
|
||||
}
|
||||
}
|
||||
|
||||
// 保留原有的解密到文件方法(用于兼容)
|
||||
async decryptToFile(inputPath: string, outputPath: string, xorKey: number, aesKey?: Buffer): Promise<void> {
|
||||
const version = this.getDatVersion(inputPath)
|
||||
let decrypted: Buffer
|
||||
|
||||
if (version === 0) {
|
||||
decrypted = this.decryptDatV3(inputPath, xorKey)
|
||||
} else if (version === 1) {
|
||||
const key = this.asciiKey16(this.defaultV1AesKey)
|
||||
decrypted = this.decryptDatV4(inputPath, xorKey, key)
|
||||
} else {
|
||||
if (!aesKey || aesKey.length !== 16) {
|
||||
throw new Error('V4版本需要 16 字节 AES 密钥')
|
||||
}
|
||||
decrypted = this.decryptDatV4(inputPath, xorKey, aesKey)
|
||||
private getElectronPath(name: 'userData' | 'documents' | 'temp'): string | null {
|
||||
try {
|
||||
const getter = (app as unknown as { getPath?: (n: string) => string } | undefined)?.getPath
|
||||
if (typeof getter !== 'function') return null
|
||||
const value = getter(name)
|
||||
return typeof value === 'string' && value.trim() ? value : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const outputDir = dirname(outputPath)
|
||||
if (!existsSync(outputDir)) {
|
||||
mkdirSync(outputDir, { recursive: true })
|
||||
}
|
||||
private getUserDataPath(): string {
|
||||
const workerUserDataPath = String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim()
|
||||
if (workerUserDataPath) return workerUserDataPath
|
||||
return this.getElectronPath('userData') || process.cwd()
|
||||
}
|
||||
|
||||
await writeFile(outputPath, decrypted)
|
||||
private getDocumentsPath(): string {
|
||||
return this.getElectronPath('documents') || join(homedir(), 'Documents')
|
||||
}
|
||||
|
||||
private getTempPath(): string {
|
||||
return this.getElectronPath('temp') || tmpdir()
|
||||
}
|
||||
|
||||
async clearCache(): Promise<{ success: boolean; error?: string }> {
|
||||
this.resolvedCache.clear()
|
||||
this.pending.clear()
|
||||
this.updateFlags.clear()
|
||||
this.cacheIndexed = false
|
||||
this.cacheIndexing = null
|
||||
this.accountDirCache.clear()
|
||||
this.ensuredDirs.clear()
|
||||
this.cacheRootPath = null
|
||||
|
||||
const configured = this.configService.get('cachePath')
|
||||
const root = configured
|
||||
? join(configured, 'Images')
|
||||
: join(app.getPath('documents'), 'WeFlow', 'Images')
|
||||
: join(this.getDocumentsPath(), 'WeFlow', 'Images')
|
||||
|
||||
try {
|
||||
if (!existsSync(root)) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -478,8 +478,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]), {
|
||||
|
||||
@@ -53,6 +53,13 @@ class MessagePushService {
|
||||
void this.refreshConfiguration('startup')
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.started = false
|
||||
this.processing = false
|
||||
this.rerunRequested = false
|
||||
this.resetRuntimeState()
|
||||
}
|
||||
|
||||
handleDbMonitorChange(type: string, json: string): void {
|
||||
if (!this.started) return
|
||||
if (!this.isPushEnabled()) return
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { existsSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
|
||||
type NativeDecryptResult = {
|
||||
data: Buffer
|
||||
ext: string
|
||||
isWxgf?: boolean
|
||||
is_wxgf?: boolean
|
||||
}
|
||||
|
||||
type NativeAddon = {
|
||||
decryptDatNative: (inputPath: string, xorKey: number, aesKey?: string) => NativeDecryptResult
|
||||
}
|
||||
|
||||
let cachedAddon: NativeAddon | null | undefined
|
||||
|
||||
function shouldEnableNative(): boolean {
|
||||
return process.env.WEFLOW_IMAGE_NATIVE !== '0'
|
||||
}
|
||||
|
||||
function expandAsarCandidates(filePath: string): string[] {
|
||||
if (!filePath.includes('app.asar') || filePath.includes('app.asar.unpacked')) {
|
||||
return [filePath]
|
||||
}
|
||||
return [filePath.replace('app.asar', 'app.asar.unpacked'), filePath]
|
||||
}
|
||||
|
||||
function getPlatformDir(): string {
|
||||
if (process.platform === 'win32') return 'win32'
|
||||
if (process.platform === 'darwin') return 'macos'
|
||||
if (process.platform === 'linux') return 'linux'
|
||||
return process.platform
|
||||
}
|
||||
|
||||
function getArchDir(): string {
|
||||
if (process.arch === 'x64') return 'x64'
|
||||
if (process.arch === 'arm64') return 'arm64'
|
||||
return process.arch
|
||||
}
|
||||
|
||||
function getAddonCandidates(): string[] {
|
||||
const platformDir = getPlatformDir()
|
||||
const archDir = getArchDir()
|
||||
const cwd = process.cwd()
|
||||
const fileNames = [
|
||||
`weflow-image-native-${platformDir}-${archDir}.node`
|
||||
]
|
||||
const roots = [
|
||||
join(cwd, 'resources', 'wedecrypt', platformDir, archDir),
|
||||
...(process.resourcesPath
|
||||
? [
|
||||
join(process.resourcesPath, 'resources', 'wedecrypt', platformDir, archDir),
|
||||
join(process.resourcesPath, 'wedecrypt', platformDir, archDir)
|
||||
]
|
||||
: [])
|
||||
]
|
||||
const candidates = roots.flatMap((root) => fileNames.map((name) => join(root, name)))
|
||||
return Array.from(new Set(candidates.flatMap(expandAsarCandidates)))
|
||||
}
|
||||
|
||||
function loadAddon(): NativeAddon | null {
|
||||
if (!shouldEnableNative()) return null
|
||||
if (cachedAddon !== undefined) return cachedAddon
|
||||
|
||||
for (const candidate of getAddonCandidates()) {
|
||||
if (!existsSync(candidate)) continue
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const addon = require(candidate) as NativeAddon
|
||||
if (addon && typeof addon.decryptDatNative === 'function') {
|
||||
cachedAddon = addon
|
||||
return addon
|
||||
}
|
||||
} catch {
|
||||
// try next candidate
|
||||
}
|
||||
}
|
||||
|
||||
cachedAddon = null
|
||||
return null
|
||||
}
|
||||
|
||||
export function nativeAddonLocation(): string | null {
|
||||
for (const candidate of getAddonCandidates()) {
|
||||
if (existsSync(candidate)) return candidate
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function decryptDatViaNative(
|
||||
inputPath: string,
|
||||
xorKey: number,
|
||||
aesKey?: string
|
||||
): { data: Buffer; ext: string; isWxgf: boolean } | null {
|
||||
const addon = loadAddon()
|
||||
if (!addon) return null
|
||||
|
||||
try {
|
||||
const result = addon.decryptDatNative(inputPath, xorKey, aesKey)
|
||||
const isWxgf = Boolean(result?.isWxgf ?? result?.is_wxgf)
|
||||
if (!result || !Buffer.isBuffer(result.data)) return null
|
||||
const rawExt = typeof result.ext === 'string' && result.ext.trim()
|
||||
? result.ext.trim().toLowerCase()
|
||||
: ''
|
||||
const ext = rawExt ? (rawExt.startsWith('.') ? rawExt : `.${rawExt}`) : ''
|
||||
return { data: result.data, ext, isWxgf }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -481,7 +482,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
|
||||
}
|
||||
@@ -1600,6 +1601,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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Generated
+15
-14
@@ -9,7 +9,6 @@
|
||||
"version": "4.3.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@vscode/sudo-prompt": "^9.3.2",
|
||||
"echarts": "^6.0.0",
|
||||
"echarts-for-react": "^3.0.2",
|
||||
"electron-store": "^11.0.2",
|
||||
@@ -28,8 +27,9 @@
|
||||
"react-router-dom": "^7.14.0",
|
||||
"react-virtuoso": "^4.18.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sherpa-onnx-node": "^1.12.35",
|
||||
"sherpa-onnx-node": "^1.10.38",
|
||||
"silk-wasm": "^3.7.1",
|
||||
"sudo-prompt": "^9.2.1",
|
||||
"wechat-emojis": "^1.0.2",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
@@ -40,11 +40,11 @@
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"electron": "^41.1.1",
|
||||
"electron-builder": "^26.8.1",
|
||||
"sass": "^1.99.0",
|
||||
"sass": "^1.98.0",
|
||||
"sharp": "^0.34.5",
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^7.3.2",
|
||||
"vite-plugin-electron": "^0.29.1",
|
||||
"vite": "^7.0.0",
|
||||
"vite-plugin-electron": "^0.28.8",
|
||||
"vite-plugin-electron-renderer": "^0.14.6"
|
||||
}
|
||||
},
|
||||
@@ -3050,12 +3050,6 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vscode/sudo-prompt": {
|
||||
"version": "9.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@vscode/sudo-prompt/-/sudo-prompt-9.3.2.tgz",
|
||||
"integrity": "sha512-gcXoCN00METUNFeQOFJ+C9xUI0DKB+0EGMVg7wbVYRHBw2Eq3fKisDZOkRdOz3kqXRKOENMfShPOmypw1/8nOw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xmldom/xmldom": {
|
||||
"version": "0.8.12",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz",
|
||||
@@ -9462,6 +9456,13 @@
|
||||
"inline-style-parser": "0.2.7"
|
||||
}
|
||||
},
|
||||
"node_modules/sudo-prompt": {
|
||||
"version": "9.2.1",
|
||||
"resolved": "https://registry.npmjs.org/sudo-prompt/-/sudo-prompt-9.2.1.tgz",
|
||||
"integrity": "sha512-Mu7R0g4ig9TUuGSxJavny5Rv0egCEtpZRNMrZaYS1vxkiIxGiGUwoezU3LazIQ+KE04hTrTfNPgxU5gzi7F5Pw==",
|
||||
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sumchecker": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",
|
||||
@@ -10140,9 +10141,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite-plugin-electron": {
|
||||
"version": "0.29.1",
|
||||
"resolved": "https://registry.npmjs.org/vite-plugin-electron/-/vite-plugin-electron-0.29.1.tgz",
|
||||
"integrity": "sha512-AejNed5BgHFnuw8h5puTa61C6vdP4ydbsbo/uVjH1fTdHAlCDz1+o6pDQ/scQj1udDrGvH01+vTbzQh/vMnR9w==",
|
||||
"version": "0.28.8",
|
||||
"resolved": "https://registry.npmjs.org/vite-plugin-electron/-/vite-plugin-electron-0.28.8.tgz",
|
||||
"integrity": "sha512-ir+B21oSGK9j23OEvt4EXyco9xDCaF6OGFe0V/8Zc0yL2+HMyQ6mmNQEIhXsEsZCSfIowBpwQBeHH4wVsfraeg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
|
||||
+11
-10
@@ -9,7 +9,7 @@
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Jasonzhu1207/WeFlow"
|
||||
"url": "https://github.com/hicccc77/WeFlow"
|
||||
},
|
||||
"//": "二改不应改变此处的作者与应用信息",
|
||||
"scripts": {
|
||||
@@ -77,7 +77,7 @@
|
||||
"appId": "com.WeFlow.app",
|
||||
"publish": {
|
||||
"provider": "github",
|
||||
"owner": "Jasonzhu1207",
|
||||
"owner": "hicccc77",
|
||||
"repo": "WeFlow",
|
||||
"releaseType": "release"
|
||||
},
|
||||
@@ -96,7 +96,7 @@
|
||||
"gatekeeperAssess": false,
|
||||
"entitlements": "electron/entitlements.mac.plist",
|
||||
"entitlementsInherit": "electron/entitlements.mac.plist",
|
||||
"icon": "resources/icon.icns"
|
||||
"icon": "resources/icons/macos/icon.icns"
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
@@ -105,19 +105,19 @@
|
||||
"icon": "public/icon.ico",
|
||||
"extraFiles": [
|
||||
{
|
||||
"from": "resources/msvcp140.dll",
|
||||
"from": "resources/runtime/win32/msvcp140.dll",
|
||||
"to": "."
|
||||
},
|
||||
{
|
||||
"from": "resources/msvcp140_1.dll",
|
||||
"from": "resources/runtime/win32/msvcp140_1.dll",
|
||||
"to": "."
|
||||
},
|
||||
{
|
||||
"from": "resources/vcruntime140.dll",
|
||||
"from": "resources/runtime/win32/vcruntime140.dll",
|
||||
"to": "."
|
||||
},
|
||||
{
|
||||
"from": "resources/vcruntime140_1.dll",
|
||||
"from": "resources/runtime/win32/vcruntime140_1.dll",
|
||||
"to": "."
|
||||
}
|
||||
]
|
||||
@@ -133,7 +133,7 @@
|
||||
"synopsis": "WeFlow for Linux",
|
||||
"extraFiles": [
|
||||
{
|
||||
"from": "resources/linux/install.sh",
|
||||
"from": "resources/installer/linux/install.sh",
|
||||
"to": "install.sh"
|
||||
}
|
||||
]
|
||||
@@ -186,9 +186,10 @@
|
||||
"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/icon.icns"
|
||||
"icon": "resources/icons/macos/icon.icns"
|
||||
},
|
||||
"overrides": {
|
||||
"picomatch": "^4.0.4",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -39,8 +39,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 }) {
|
||||
@@ -554,10 +552,6 @@ function App() {
|
||||
{/* 全局会话监听与通知 */}
|
||||
<GlobalSessionMonitor />
|
||||
|
||||
{/* 全局批量转写进度浮窗 */}
|
||||
<BatchTranscribeGlobal />
|
||||
<BatchImageDecryptGlobal />
|
||||
|
||||
{/* 用户协议弹窗 */}
|
||||
{showAgreement && !agreementLoading && (
|
||||
<div className="agreement-overlay">
|
||||
|
||||
+377
-97
@@ -29,6 +29,7 @@ import {
|
||||
onSingleExportDialogStatus,
|
||||
requestExportSessionStatus
|
||||
} from '../services/exportBridge'
|
||||
import '../styles/batchTranscribe.scss'
|
||||
import './ChatPage.scss'
|
||||
|
||||
// 系统消息类型常量
|
||||
@@ -154,6 +155,21 @@ function hasRenderableChatRecordName(value?: string): boolean {
|
||||
return value !== undefined && value !== null && String(value).length > 0
|
||||
}
|
||||
|
||||
function toRenderableImageSrc(path?: string): string | undefined {
|
||||
const raw = String(path || '').trim()
|
||||
if (!raw) return undefined
|
||||
if (/^(data:|blob:|https?:|file:)/i.test(raw)) return raw
|
||||
|
||||
const normalized = raw.replace(/\\/g, '/')
|
||||
if (/^[a-zA-Z]:\//.test(normalized)) {
|
||||
return encodeURI(`file:///${normalized}`)
|
||||
}
|
||||
if (normalized.startsWith('/')) {
|
||||
return encodeURI(`file://${normalized}`)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
function getChatRecordPreviewText(item: ChatRecordItem): string {
|
||||
const text = normalizeChatRecordText(item.datadesc) || normalizeChatRecordText(item.datatitle)
|
||||
if (item.datatype === 17) {
|
||||
@@ -1355,34 +1371,30 @@ function ChatPage(props: ChatPageProps) {
|
||||
const {
|
||||
isBatchTranscribing,
|
||||
runningBatchVoiceTaskType,
|
||||
batchTranscribeProgress,
|
||||
startTranscribe,
|
||||
updateProgress,
|
||||
finishTranscribe,
|
||||
setShowBatchProgress
|
||||
updateTranscribeTaskStatus,
|
||||
finishTranscribe
|
||||
} = useBatchTranscribeStore(useShallow((state) => ({
|
||||
isBatchTranscribing: state.isBatchTranscribing,
|
||||
runningBatchVoiceTaskType: state.taskType,
|
||||
batchTranscribeProgress: state.progress,
|
||||
startTranscribe: state.startTranscribe,
|
||||
updateProgress: state.updateProgress,
|
||||
finishTranscribe: state.finishTranscribe,
|
||||
setShowBatchProgress: state.setShowToast
|
||||
updateTranscribeTaskStatus: state.setTaskStatus,
|
||||
finishTranscribe: state.finishTranscribe
|
||||
})))
|
||||
const {
|
||||
isBatchDecrypting,
|
||||
batchDecryptProgress,
|
||||
startDecrypt,
|
||||
updateDecryptProgress,
|
||||
finishDecrypt,
|
||||
setShowBatchDecryptToast
|
||||
updateDecryptTaskStatus,
|
||||
finishDecrypt
|
||||
} = useBatchImageDecryptStore(useShallow((state) => ({
|
||||
isBatchDecrypting: state.isBatchDecrypting,
|
||||
batchDecryptProgress: state.progress,
|
||||
startDecrypt: state.startDecrypt,
|
||||
updateDecryptProgress: state.updateProgress,
|
||||
finishDecrypt: state.finishDecrypt,
|
||||
setShowBatchDecryptToast: state.setShowToast
|
||||
updateDecryptTaskStatus: state.setTaskStatus,
|
||||
finishDecrypt: state.finishDecrypt
|
||||
})))
|
||||
const [showBatchConfirm, setShowBatchConfirm] = useState(false)
|
||||
const [batchVoiceCount, setBatchVoiceCount] = useState(0)
|
||||
@@ -4853,7 +4865,7 @@ function ChatPage(props: ChatPageProps) {
|
||||
const candidates = [...head, ...tail]
|
||||
const queued = preloadImageKeysRef.current
|
||||
const seen = new Set<string>()
|
||||
const payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }> = []
|
||||
const payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }> = []
|
||||
for (const msg of candidates) {
|
||||
if (payloads.length >= maxPreload) break
|
||||
if (msg.localType !== 3) continue
|
||||
@@ -4867,11 +4879,14 @@ function ChatPage(props: ChatPageProps) {
|
||||
payloads.push({
|
||||
sessionId: currentSessionId,
|
||||
imageMd5: msg.imageMd5 || undefined,
|
||||
imageDatName: msg.imageDatName
|
||||
imageDatName: msg.imageDatName,
|
||||
createTime: msg.createTime
|
||||
})
|
||||
}
|
||||
if (payloads.length > 0) {
|
||||
window.electronAPI.image.preload(payloads).catch(() => { })
|
||||
window.electronAPI.image.preload(payloads, {
|
||||
allowCacheIndex: false
|
||||
}).catch(() => { })
|
||||
}
|
||||
}, [currentSessionId, messages])
|
||||
|
||||
@@ -5712,22 +5727,74 @@ function ChatPage(props: ChatPageProps) {
|
||||
if (!session) return
|
||||
|
||||
const taskType = batchVoiceTaskType
|
||||
startTranscribe(voiceMessages.length, session.displayName || session.username, taskType)
|
||||
|
||||
if (taskType === 'transcribe') {
|
||||
// 检查模型状态
|
||||
const modelStatus = await window.electronAPI.whisper.getModelStatus()
|
||||
if (!modelStatus?.exists) {
|
||||
alert('SenseVoice 模型未下载,请先在设置中下载模型')
|
||||
finishTranscribe(0, 0)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const totalVoices = voiceMessages.length
|
||||
const taskVerb = taskType === 'decrypt' ? '语音解密' : '语音转写'
|
||||
let successCount = 0
|
||||
let failCount = 0
|
||||
let completedCount = 0
|
||||
const concurrency = taskType === 'decrypt' ? 12 : 10
|
||||
const controlState = {
|
||||
cancelRequested: false,
|
||||
pauseRequested: false,
|
||||
pauseAnnounced: false,
|
||||
resumeWaiters: [] as Array<() => void>
|
||||
}
|
||||
const resolveResumeWaiters = () => {
|
||||
const waiters = [...controlState.resumeWaiters]
|
||||
controlState.resumeWaiters.length = 0
|
||||
waiters.forEach(resolve => resolve())
|
||||
}
|
||||
const waitIfPaused = async () => {
|
||||
while (controlState.pauseRequested && !controlState.cancelRequested) {
|
||||
if (!controlState.pauseAnnounced) {
|
||||
controlState.pauseAnnounced = true
|
||||
updateTranscribeTaskStatus(
|
||||
`${taskVerb}任务已中断,等待继续...`,
|
||||
`${completedCount} / ${totalVoices}`,
|
||||
'paused'
|
||||
)
|
||||
}
|
||||
await new Promise<void>(resolve => {
|
||||
controlState.resumeWaiters.push(resolve)
|
||||
})
|
||||
}
|
||||
if (controlState.pauseAnnounced && !controlState.cancelRequested) {
|
||||
controlState.pauseAnnounced = false
|
||||
updateTranscribeTaskStatus(
|
||||
`继续${taskVerb}(${completedCount}/${totalVoices})`,
|
||||
`${completedCount} / ${totalVoices}`,
|
||||
'running'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
startTranscribe(totalVoices, session.displayName || session.username, taskType, 'chat', {
|
||||
cancelable: true,
|
||||
resumable: true,
|
||||
onPause: () => {
|
||||
controlState.pauseRequested = true
|
||||
updateTranscribeTaskStatus(
|
||||
`${taskVerb}中断请求已发出,当前处理完成后暂停...`,
|
||||
`${completedCount} / ${totalVoices}`,
|
||||
'pause_requested'
|
||||
)
|
||||
},
|
||||
onResume: () => {
|
||||
controlState.pauseRequested = false
|
||||
resolveResumeWaiters()
|
||||
},
|
||||
onCancel: () => {
|
||||
controlState.cancelRequested = true
|
||||
controlState.pauseRequested = false
|
||||
resolveResumeWaiters()
|
||||
updateTranscribeTaskStatus(
|
||||
`${taskVerb}停止请求已发出,当前处理完成后结束...`,
|
||||
`${completedCount} / ${totalVoices}`,
|
||||
'cancel_requested'
|
||||
)
|
||||
}
|
||||
})
|
||||
updateTranscribeTaskStatus(`正在准备${taskVerb}任务...`, `0 / ${totalVoices}`, 'running')
|
||||
|
||||
const runOne = async (msg: Message) => {
|
||||
try {
|
||||
@@ -5751,20 +5818,74 @@ function ChatPage(props: ChatPageProps) {
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < voiceMessages.length; i += concurrency) {
|
||||
const batch = voiceMessages.slice(i, i + concurrency)
|
||||
const results = await Promise.all(batch.map(msg => runOne(msg)))
|
||||
try {
|
||||
if (taskType === 'transcribe') {
|
||||
updateTranscribeTaskStatus('正在检查转写模型...', `0 / ${totalVoices}`)
|
||||
const modelStatus = await window.electronAPI.whisper.getModelStatus()
|
||||
if (!modelStatus?.exists) {
|
||||
alert('SenseVoice 模型未下载,请先在设置中下载模型')
|
||||
updateTranscribeTaskStatus('转写模型缺失,任务已停止', `0 / ${totalVoices}`)
|
||||
finishTranscribe(0, totalVoices)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
results.forEach(result => {
|
||||
updateTranscribeTaskStatus(`正在${taskVerb}(0/${totalVoices})`, `0 / ${totalVoices}`)
|
||||
const pool = new Set<Promise<void>>()
|
||||
|
||||
const runOneTracked = async (msg: Message) => {
|
||||
if (controlState.cancelRequested) return
|
||||
const result = await runOne(msg)
|
||||
if (result.success) successCount++
|
||||
else failCount++
|
||||
completedCount++
|
||||
updateProgress(completedCount, voiceMessages.length)
|
||||
})
|
||||
}
|
||||
updateProgress(completedCount, totalVoices)
|
||||
}
|
||||
|
||||
finishTranscribe(successCount, failCount)
|
||||
}, [sessions, currentSessionId, batchSelectedDates, batchVoiceMessages, batchVoiceTaskType, startTranscribe, updateProgress, finishTranscribe])
|
||||
for (const msg of voiceMessages) {
|
||||
if (controlState.cancelRequested) break
|
||||
await waitIfPaused()
|
||||
if (controlState.cancelRequested) break
|
||||
if (pool.size >= concurrency) {
|
||||
await Promise.race(pool)
|
||||
if (controlState.cancelRequested) break
|
||||
await waitIfPaused()
|
||||
if (controlState.cancelRequested) break
|
||||
}
|
||||
let p: Promise<void> = Promise.resolve()
|
||||
p = runOneTracked(msg).finally(() => {
|
||||
pool.delete(p)
|
||||
})
|
||||
pool.add(p)
|
||||
}
|
||||
|
||||
while (pool.size > 0) {
|
||||
await Promise.race(pool)
|
||||
}
|
||||
|
||||
if (controlState.cancelRequested) {
|
||||
const remaining = Math.max(0, totalVoices - completedCount)
|
||||
finishTranscribe(successCount, failCount, {
|
||||
status: 'canceled',
|
||||
detail: `${taskVerb}任务已中断:已完成 ${completedCount}/${totalVoices}(成功 ${successCount},失败 ${failCount},未处理 ${remaining})`,
|
||||
progressText: `${completedCount} / ${totalVoices}`
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
finishTranscribe(successCount, failCount, {
|
||||
status: failCount > 0 ? 'failed' : 'completed'
|
||||
})
|
||||
} catch (error) {
|
||||
const remaining = Math.max(0, totalVoices - completedCount)
|
||||
failCount += remaining
|
||||
updateTranscribeTaskStatus(`${taskVerb}过程中发生异常,正在结束任务...`, `${completedCount} / ${totalVoices}`)
|
||||
finishTranscribe(successCount, failCount, {
|
||||
status: 'failed'
|
||||
})
|
||||
alert(`批量${taskVerb}失败:${String(error)}`)
|
||||
}
|
||||
}, [sessions, currentSessionId, batchSelectedDates, batchVoiceMessages, batchVoiceTaskType, startTranscribe, updateTranscribeTaskStatus, updateProgress, finishTranscribe])
|
||||
|
||||
// 批量转写:按日期的消息数量
|
||||
const batchCountByDate = useMemo(() => {
|
||||
@@ -5827,45 +5948,175 @@ function ChatPage(props: ChatPageProps) {
|
||||
setBatchImageDates([])
|
||||
setBatchImageSelectedDates(new Set())
|
||||
|
||||
startDecrypt(images.length, session.displayName || session.username)
|
||||
|
||||
const totalImages = images.length
|
||||
let successCount = 0
|
||||
let failCount = 0
|
||||
let notFoundCount = 0
|
||||
let decryptFailedCount = 0
|
||||
let completed = 0
|
||||
const controlState = {
|
||||
cancelRequested: false,
|
||||
pauseRequested: false,
|
||||
pauseAnnounced: false,
|
||||
resumeWaiters: [] as Array<() => void>
|
||||
}
|
||||
const resolveResumeWaiters = () => {
|
||||
const waiters = [...controlState.resumeWaiters]
|
||||
controlState.resumeWaiters.length = 0
|
||||
waiters.forEach(resolve => resolve())
|
||||
}
|
||||
const waitIfPaused = async () => {
|
||||
while (controlState.pauseRequested && !controlState.cancelRequested) {
|
||||
if (!controlState.pauseAnnounced) {
|
||||
controlState.pauseAnnounced = true
|
||||
updateDecryptTaskStatus(
|
||||
'图片批量解密任务已中断,等待继续...',
|
||||
`${completed} / ${totalImages}`,
|
||||
'paused'
|
||||
)
|
||||
}
|
||||
await new Promise<void>(resolve => {
|
||||
controlState.resumeWaiters.push(resolve)
|
||||
})
|
||||
}
|
||||
if (controlState.pauseAnnounced && !controlState.cancelRequested) {
|
||||
controlState.pauseAnnounced = false
|
||||
updateDecryptTaskStatus(
|
||||
`继续批量解密图片(${completed}/${totalImages})`,
|
||||
`${completed} / ${totalImages}`,
|
||||
'running'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
startDecrypt(totalImages, session.displayName || session.username, 'chat', {
|
||||
cancelable: true,
|
||||
resumable: true,
|
||||
onPause: () => {
|
||||
controlState.pauseRequested = true
|
||||
updateDecryptTaskStatus(
|
||||
'图片解密中断请求已发出,当前处理完成后暂停...',
|
||||
`${completed} / ${totalImages}`,
|
||||
'pause_requested'
|
||||
)
|
||||
},
|
||||
onResume: () => {
|
||||
controlState.pauseRequested = false
|
||||
resolveResumeWaiters()
|
||||
},
|
||||
onCancel: () => {
|
||||
controlState.cancelRequested = true
|
||||
controlState.pauseRequested = false
|
||||
resolveResumeWaiters()
|
||||
updateDecryptTaskStatus(
|
||||
'图片解密停止请求已发出,当前处理完成后结束...',
|
||||
`${completed} / ${totalImages}`,
|
||||
'cancel_requested'
|
||||
)
|
||||
}
|
||||
})
|
||||
updateDecryptTaskStatus('正在准备批量图片解密任务...', `0 / ${totalImages}`, 'running')
|
||||
|
||||
const hardlinkMd5Set = new Set<string>()
|
||||
for (const img of images) {
|
||||
const imageMd5 = String(img.imageMd5 || '').trim().toLowerCase()
|
||||
if (imageMd5) {
|
||||
hardlinkMd5Set.add(imageMd5)
|
||||
continue
|
||||
}
|
||||
const imageDatName = String(img.imageDatName || '').trim().toLowerCase()
|
||||
if (/^[a-f0-9]{32}$/i.test(imageDatName)) {
|
||||
hardlinkMd5Set.add(imageDatName)
|
||||
}
|
||||
}
|
||||
if (hardlinkMd5Set.size > 0) {
|
||||
await waitIfPaused()
|
||||
if (controlState.cancelRequested) {
|
||||
const remaining = Math.max(0, totalImages - completed)
|
||||
finishDecrypt(successCount, failCount, {
|
||||
status: 'canceled',
|
||||
detail: `图片批量解密已中断:已处理 ${completed}/${totalImages}(成功 ${successCount},未找到 ${notFoundCount},解密失败 ${decryptFailedCount},未处理 ${remaining})`,
|
||||
progressText: `成功 ${successCount} / 未找到 ${notFoundCount} / 解密失败 ${decryptFailedCount}`
|
||||
})
|
||||
return
|
||||
}
|
||||
updateDecryptTaskStatus(
|
||||
`正在预热图片索引(${hardlinkMd5Set.size} 个标识)...`,
|
||||
`0 / ${totalImages}`
|
||||
)
|
||||
try {
|
||||
await window.electronAPI.image.preloadHardlinkMd5s(Array.from(hardlinkMd5Set))
|
||||
} catch {
|
||||
// ignore preload failures and continue decrypt
|
||||
}
|
||||
}
|
||||
updateDecryptTaskStatus(`开始批量解密图片(0/${totalImages})`, `0 / ${totalImages}`)
|
||||
|
||||
const concurrency = batchDecryptConcurrency
|
||||
|
||||
const decryptOne = async (img: typeof images[0]) => {
|
||||
if (controlState.cancelRequested) return
|
||||
try {
|
||||
const r = await window.electronAPI.image.decrypt({
|
||||
sessionId: session.username,
|
||||
imageMd5: img.imageMd5,
|
||||
imageDatName: img.imageDatName,
|
||||
force: true
|
||||
createTime: img.createTime,
|
||||
force: true,
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true,
|
||||
disableUpdateCheck: true,
|
||||
suppressEvents: true
|
||||
})
|
||||
if (r?.success) successCount++
|
||||
else failCount++
|
||||
else {
|
||||
failCount++
|
||||
if (r?.failureKind === 'decrypt_failed') decryptFailedCount++
|
||||
else notFoundCount++
|
||||
}
|
||||
} catch {
|
||||
failCount++
|
||||
notFoundCount++
|
||||
}
|
||||
completed++
|
||||
updateDecryptProgress(completed, images.length)
|
||||
updateDecryptProgress(completed, totalImages)
|
||||
}
|
||||
|
||||
// 并发池:同时跑 concurrency 个任务
|
||||
const pool = new Set<Promise<void>>()
|
||||
for (const img of images) {
|
||||
const p = decryptOne(img).then(() => { pool.delete(p) })
|
||||
pool.add(p)
|
||||
if (controlState.cancelRequested) break
|
||||
await waitIfPaused()
|
||||
if (controlState.cancelRequested) break
|
||||
if (pool.size >= concurrency) {
|
||||
await Promise.race(pool)
|
||||
if (controlState.cancelRequested) break
|
||||
await waitIfPaused()
|
||||
if (controlState.cancelRequested) break
|
||||
}
|
||||
let p: Promise<void> = Promise.resolve()
|
||||
p = decryptOne(img).then(() => { pool.delete(p) })
|
||||
pool.add(p)
|
||||
}
|
||||
if (pool.size > 0) {
|
||||
await Promise.all(pool)
|
||||
while (pool.size > 0) {
|
||||
await Promise.race(pool)
|
||||
}
|
||||
|
||||
finishDecrypt(successCount, failCount)
|
||||
}, [batchImageMessages, batchImageSelectedDates, batchDecryptConcurrency, currentSessionId, finishDecrypt, sessions, startDecrypt, updateDecryptProgress])
|
||||
if (controlState.cancelRequested) {
|
||||
const remaining = Math.max(0, totalImages - completed)
|
||||
finishDecrypt(successCount, failCount, {
|
||||
status: 'canceled',
|
||||
detail: `图片批量解密已中断:已处理 ${completed}/${totalImages}(成功 ${successCount},未找到 ${notFoundCount},解密失败 ${decryptFailedCount},未处理 ${remaining})`,
|
||||
progressText: `成功 ${successCount} / 未找到 ${notFoundCount} / 解密失败 ${decryptFailedCount}`
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
finishDecrypt(successCount, failCount, {
|
||||
status: decryptFailedCount > 0 ? 'failed' : 'completed',
|
||||
detail: `图片批量解密完成:成功 ${successCount},未找到 ${notFoundCount},解密失败 ${decryptFailedCount}`,
|
||||
progressText: `成功 ${successCount} / 未找到 ${notFoundCount} / 解密失败 ${decryptFailedCount}`
|
||||
})
|
||||
}, [batchImageMessages, batchImageSelectedDates, batchDecryptConcurrency, currentSessionId, finishDecrypt, sessions, startDecrypt, updateDecryptTaskStatus, updateDecryptProgress])
|
||||
|
||||
const batchImageCountByDate = useMemo(() => {
|
||||
const map = new Map<string, number>()
|
||||
@@ -6600,16 +6851,10 @@ function ChatPage(props: ChatPageProps) {
|
||||
{!standaloneSessionWindow && (
|
||||
<button
|
||||
className={`icon-btn batch-transcribe-btn${isBatchTranscribing ? ' transcribing' : ''}`}
|
||||
onClick={() => {
|
||||
if (isBatchTranscribing) {
|
||||
setShowBatchProgress(true)
|
||||
} else {
|
||||
handleBatchTranscribe()
|
||||
}
|
||||
}}
|
||||
onClick={handleBatchTranscribe}
|
||||
disabled={!currentSessionId}
|
||||
title={isBatchTranscribing
|
||||
? `${runningBatchVoiceTaskType === 'decrypt' ? '批量语音解密' : '批量转写'}中 (${batchTranscribeProgress.current}/${batchTranscribeProgress.total}),点击查看进度`
|
||||
? `${runningBatchVoiceTaskType === 'decrypt' ? '批量语音解密' : '批量转写'}中,可在导出页任务中心查看进度`
|
||||
: '批量语音处理(解密/转文字)'}
|
||||
>
|
||||
{isBatchTranscribing ? (
|
||||
@@ -6622,16 +6867,10 @@ function ChatPage(props: ChatPageProps) {
|
||||
{!standaloneSessionWindow && (
|
||||
<button
|
||||
className={`icon-btn batch-decrypt-btn${isBatchDecrypting ? ' transcribing' : ''}`}
|
||||
onClick={() => {
|
||||
if (isBatchDecrypting) {
|
||||
setShowBatchDecryptToast(true)
|
||||
} else {
|
||||
handleBatchDecrypt()
|
||||
}
|
||||
}}
|
||||
onClick={handleBatchDecrypt}
|
||||
disabled={!currentSessionId}
|
||||
title={isBatchDecrypting
|
||||
? `批量解密中 (${batchDecryptProgress.current}/${batchDecryptProgress.total}),点击查看进度`
|
||||
? '批量解密中,可在导出页任务中心查看进度'
|
||||
: '批量解密图片'}
|
||||
>
|
||||
{isBatchDecrypting ? (
|
||||
@@ -7309,8 +7548,8 @@ function ChatPage(props: ChatPageProps) {
|
||||
<AlertCircle size={16} />
|
||||
<span>
|
||||
{batchVoiceTaskType === 'decrypt'
|
||||
? '批量解密会预先缓存语音数据,之后播放和转写会更快。解密过程中可以继续使用其他功能。'
|
||||
: '批量转写可能需要较长时间,转写过程中可以继续使用其他功能。已转写过的语音会自动跳过。'}
|
||||
? '批量解密会预先缓存语音数据,之后播放和转写会更快。解密过程中可以继续使用其他功能,进度会写入导出页任务中心。'
|
||||
: '批量转写可能需要较长时间,转写过程中可以继续使用其他功能。已转写过的语音会自动跳过,进度会写入导出页任务中心。'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -7377,17 +7616,17 @@ function ChatPage(props: ChatPageProps) {
|
||||
className={`batch-concurrency-trigger ${showConcurrencyDropdown ? 'open' : ''}`}
|
||||
onClick={() => setShowConcurrencyDropdown(!showConcurrencyDropdown)}
|
||||
>
|
||||
<span>{batchDecryptConcurrency === 1 ? '1(最慢,最稳)' : batchDecryptConcurrency === 6 ? '6(推荐)' : batchDecryptConcurrency === 20 ? '20(最快,可能卡顿)' : String(batchDecryptConcurrency)}</span>
|
||||
<span>{batchDecryptConcurrency === 1 ? '1' : batchDecryptConcurrency === 6 ? '6' : batchDecryptConcurrency === 20 ? '20' : String(batchDecryptConcurrency)}</span>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
{showConcurrencyDropdown && (
|
||||
<div className="batch-concurrency-dropdown">
|
||||
{[
|
||||
{ value: 1, label: '1(最慢,最稳)' },
|
||||
{ value: 1, label: '1' },
|
||||
{ value: 3, label: '3' },
|
||||
{ value: 6, label: '6(推荐)' },
|
||||
{ value: 6, label: '6' },
|
||||
{ value: 10, label: '10' },
|
||||
{ value: 20, label: '20(最快,可能卡顿)' },
|
||||
{ value: 20, label: '20' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
@@ -7405,7 +7644,7 @@ function ChatPage(props: ChatPageProps) {
|
||||
</div>
|
||||
<div className="batch-warning">
|
||||
<AlertCircle size={16} />
|
||||
<span>批量解密可能需要较长时间,进行中会在右下角显示非阻塞进度浮层。</span>
|
||||
<span>批量解密可能需要较长时间,进度会自动写入导出页任务中心(含准备阶段状态)。</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="batch-modal-footer">
|
||||
@@ -7768,7 +8007,13 @@ const emojiDataUrlCache = new Map<string, string>()
|
||||
const imageDataUrlCache = new Map<string, string>()
|
||||
const voiceDataUrlCache = new Map<string, string>()
|
||||
const voiceTranscriptCache = new Map<string, string>()
|
||||
type SharedImageDecryptResult = { success: boolean; localPath?: string; liveVideoPath?: string; error?: string }
|
||||
type SharedImageDecryptResult = {
|
||||
success: boolean
|
||||
localPath?: string
|
||||
liveVideoPath?: string
|
||||
error?: string
|
||||
failureKind?: 'not_found' | 'decrypt_failed'
|
||||
}
|
||||
const imageDecryptInFlight = new Map<string, Promise<SharedImageDecryptResult>>()
|
||||
const senderAvatarCache = new Map<string, { avatarUrl?: string; displayName?: string }>()
|
||||
const senderAvatarLoading = new Map<string, Promise<{ avatarUrl?: string; displayName?: string } | null>>()
|
||||
@@ -7882,7 +8127,7 @@ function MessageBubble({
|
||||
)
|
||||
const imageCacheKey = message.imageMd5 || message.imageDatName || `local:${message.localId}`
|
||||
const [imageLocalPath, setImageLocalPath] = useState<string | undefined>(
|
||||
() => imageDataUrlCache.get(imageCacheKey)
|
||||
() => toRenderableImageSrc(imageDataUrlCache.get(imageCacheKey))
|
||||
)
|
||||
const voiceIdentityKey = buildVoiceCacheIdentity(session.username, message)
|
||||
const voiceCacheKey = `voice:${voiceIdentityKey}`
|
||||
@@ -7904,6 +8149,7 @@ function MessageBubble({
|
||||
const imageUpdateCheckedRef = useRef<string | null>(null)
|
||||
const imageClickTimerRef = useRef<number | null>(null)
|
||||
const imageContainerRef = useRef<HTMLDivElement>(null)
|
||||
const imageElementRef = useRef<HTMLImageElement | null>(null)
|
||||
const emojiContainerRef = useRef<HTMLDivElement>(null)
|
||||
const imageResizeBaselineRef = useRef<number | null>(null)
|
||||
const emojiResizeBaselineRef = useRef<number | null>(null)
|
||||
@@ -8260,19 +8506,27 @@ function MessageBubble({
|
||||
sessionId: session.username,
|
||||
imageMd5: message.imageMd5 || undefined,
|
||||
imageDatName: message.imageDatName,
|
||||
force: forceUpdate
|
||||
createTime: message.createTime,
|
||||
force: forceUpdate,
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true
|
||||
}) as SharedImageDecryptResult
|
||||
})
|
||||
if (result.success && result.localPath) {
|
||||
imageDataUrlCache.set(imageCacheKey, result.localPath)
|
||||
if (imageLocalPath !== result.localPath) {
|
||||
const renderPath = toRenderableImageSrc(result.localPath)
|
||||
if (!renderPath) {
|
||||
if (!silent) setImageError(true)
|
||||
return { success: false }
|
||||
}
|
||||
imageDataUrlCache.set(imageCacheKey, renderPath)
|
||||
if (imageLocalPath !== renderPath) {
|
||||
captureImageResizeBaseline()
|
||||
lockImageStageHeight()
|
||||
}
|
||||
setImageLocalPath(result.localPath)
|
||||
setImageLocalPath(renderPath)
|
||||
setImageHasUpdate(false)
|
||||
if (result.liveVideoPath) setImageLiveVideoPath(result.liveVideoPath)
|
||||
return result
|
||||
return { ...result, localPath: renderPath }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8297,7 +8551,7 @@ function MessageBubble({
|
||||
imageDecryptPendingRef.current = false
|
||||
}
|
||||
return { success: false }
|
||||
}, [isImage, message.imageMd5, message.imageDatName, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64, imageLocalPath, captureImageResizeBaseline, lockImageStageHeight])
|
||||
}, [isImage, message.imageMd5, message.imageDatName, message.createTime, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64, imageLocalPath, captureImageResizeBaseline, lockImageStageHeight])
|
||||
|
||||
const triggerForceHd = useCallback(() => {
|
||||
if (!message.imageMd5 && !message.imageDatName) return
|
||||
@@ -8352,24 +8606,29 @@ function MessageBubble({
|
||||
const resolved = await window.electronAPI.image.resolveCache({
|
||||
sessionId: session.username,
|
||||
imageMd5: message.imageMd5 || undefined,
|
||||
imageDatName: message.imageDatName
|
||||
imageDatName: message.imageDatName,
|
||||
createTime: message.createTime,
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true
|
||||
})
|
||||
if (resolved?.success && resolved.localPath) {
|
||||
finalImagePath = resolved.localPath
|
||||
const renderPath = toRenderableImageSrc(resolved.localPath)
|
||||
if (!renderPath) return
|
||||
finalImagePath = renderPath
|
||||
finalLiveVideoPath = resolved.liveVideoPath || finalLiveVideoPath
|
||||
imageDataUrlCache.set(imageCacheKey, resolved.localPath)
|
||||
if (imageLocalPath !== resolved.localPath) {
|
||||
imageDataUrlCache.set(imageCacheKey, renderPath)
|
||||
if (imageLocalPath !== renderPath) {
|
||||
captureImageResizeBaseline()
|
||||
lockImageStageHeight()
|
||||
}
|
||||
setImageLocalPath(resolved.localPath)
|
||||
setImageLocalPath(renderPath)
|
||||
if (resolved.liveVideoPath) setImageLiveVideoPath(resolved.liveVideoPath)
|
||||
setImageHasUpdate(Boolean(resolved.hasUpdate))
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
void window.electronAPI.window.openImageViewerWindow(finalImagePath, finalLiveVideoPath)
|
||||
void window.electronAPI.window.openImageViewerWindow(toRenderableImageSrc(finalImagePath) || finalImagePath, finalLiveVideoPath)
|
||||
}, [
|
||||
imageLiveVideoPath,
|
||||
imageLocalPath,
|
||||
@@ -8378,6 +8637,7 @@ function MessageBubble({
|
||||
lockImageStageHeight,
|
||||
message.imageDatName,
|
||||
message.imageMd5,
|
||||
message.createTime,
|
||||
requestImageDecrypt,
|
||||
session.username
|
||||
])
|
||||
@@ -8391,8 +8651,19 @@ function MessageBubble({
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setImageLoaded(false)
|
||||
}, [imageLocalPath])
|
||||
if (!isImage) return
|
||||
if (!imageLocalPath) {
|
||||
setImageLoaded(false)
|
||||
return
|
||||
}
|
||||
|
||||
// 某些 file:// 缓存图在 src 切换时可能不会稳定触发 onLoad,
|
||||
// 这里用 complete/naturalWidth 做一次兜底,避免图片进入 pending 隐身态。
|
||||
const img = imageElementRef.current
|
||||
if (img && img.complete && img.naturalWidth > 0) {
|
||||
setImageLoaded(true)
|
||||
}
|
||||
}, [isImage, imageLocalPath])
|
||||
|
||||
useEffect(() => {
|
||||
if (imageLoading) return
|
||||
@@ -8401,7 +8672,7 @@ function MessageBubble({
|
||||
}, [imageError, imageLoading, imageLocalPath])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isImage || imageLoading) return
|
||||
if (!isImage || imageLoading || !imageInView) return
|
||||
if (!message.imageMd5 && !message.imageDatName) return
|
||||
if (imageUpdateCheckedRef.current === imageCacheKey) return
|
||||
imageUpdateCheckedRef.current = imageCacheKey
|
||||
@@ -8409,15 +8680,21 @@ function MessageBubble({
|
||||
window.electronAPI.image.resolveCache({
|
||||
sessionId: session.username,
|
||||
imageMd5: message.imageMd5 || undefined,
|
||||
imageDatName: message.imageDatName
|
||||
imageDatName: message.imageDatName,
|
||||
createTime: message.createTime,
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true,
|
||||
allowCacheIndex: false
|
||||
}).then((result: { success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string }) => {
|
||||
if (cancelled) return
|
||||
if (result.success && result.localPath) {
|
||||
imageDataUrlCache.set(imageCacheKey, result.localPath)
|
||||
if (!imageLocalPath || imageLocalPath !== result.localPath) {
|
||||
const renderPath = toRenderableImageSrc(result.localPath)
|
||||
if (!renderPath) return
|
||||
imageDataUrlCache.set(imageCacheKey, renderPath)
|
||||
if (!imageLocalPath || imageLocalPath !== renderPath) {
|
||||
captureImageResizeBaseline()
|
||||
lockImageStageHeight()
|
||||
setImageLocalPath(result.localPath)
|
||||
setImageLocalPath(renderPath)
|
||||
setImageError(false)
|
||||
}
|
||||
if (result.liveVideoPath) setImageLiveVideoPath(result.liveVideoPath)
|
||||
@@ -8427,7 +8704,7 @@ function MessageBubble({
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [isImage, imageLocalPath, imageLoading, message.imageMd5, message.imageDatName, imageCacheKey, session.username, captureImageResizeBaseline, lockImageStageHeight])
|
||||
}, [isImage, imageInView, imageLocalPath, imageLoading, message.imageMd5, message.imageDatName, message.createTime, imageCacheKey, session.username, captureImageResizeBaseline, lockImageStageHeight])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isImage) return
|
||||
@@ -8455,15 +8732,17 @@ function MessageBubble({
|
||||
(payload.imageMd5 && payload.imageMd5 === message.imageMd5) ||
|
||||
(payload.imageDatName && payload.imageDatName === message.imageDatName)
|
||||
if (matchesCacheKey) {
|
||||
const renderPath = toRenderableImageSrc(payload.localPath)
|
||||
if (!renderPath) return
|
||||
const cachedPath = imageDataUrlCache.get(imageCacheKey)
|
||||
if (cachedPath !== payload.localPath) {
|
||||
imageDataUrlCache.set(imageCacheKey, payload.localPath)
|
||||
if (cachedPath !== renderPath) {
|
||||
imageDataUrlCache.set(imageCacheKey, renderPath)
|
||||
}
|
||||
if (imageLocalPath !== payload.localPath) {
|
||||
if (imageLocalPath !== renderPath) {
|
||||
captureImageResizeBaseline()
|
||||
lockImageStageHeight()
|
||||
}
|
||||
setImageLocalPath((prev) => (prev === payload.localPath ? prev : payload.localPath))
|
||||
setImageLocalPath((prev) => (prev === renderPath ? prev : renderPath))
|
||||
setImageError(false)
|
||||
}
|
||||
})
|
||||
@@ -9093,6 +9372,7 @@ function MessageBubble({
|
||||
<>
|
||||
<div className="image-message-wrapper">
|
||||
<img
|
||||
ref={imageElementRef}
|
||||
src={imageLocalPath}
|
||||
alt="图片"
|
||||
className={`image-message ${imageLoaded ? 'ready' : 'pending'}`}
|
||||
|
||||
@@ -410,6 +410,17 @@
|
||||
background: rgba(245, 158, 11, 0.14);
|
||||
}
|
||||
|
||||
&.status-pause_requested,
|
||||
&.status-paused {
|
||||
color: #b45309;
|
||||
background: rgba(245, 158, 11, 0.14);
|
||||
}
|
||||
|
||||
&.status-canceled {
|
||||
color: #64748b;
|
||||
background: rgba(148, 163, 184, 0.2);
|
||||
}
|
||||
|
||||
&.status-completed {
|
||||
color: #166534;
|
||||
background: rgba(34, 197, 94, 0.14);
|
||||
@@ -5817,8 +5828,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* 终止时间选择器 */
|
||||
.automation-stopat-picker {
|
||||
/* 首次触发/终止时间选择器 */
|
||||
.automation-stopat-picker,
|
||||
.automation-first-trigger-picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
@@ -5884,4 +5896,4 @@
|
||||
font-size: 11px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
+461
-129
@@ -22,6 +22,8 @@ import {
|
||||
MessageSquare,
|
||||
MessageSquareText,
|
||||
Mic,
|
||||
Pause,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Square,
|
||||
@@ -48,6 +50,8 @@ import {
|
||||
import {
|
||||
requestCancelBackgroundTask,
|
||||
requestCancelBackgroundTasks,
|
||||
requestPauseBackgroundTask,
|
||||
requestResumeBackgroundTask,
|
||||
subscribeBackgroundTasks
|
||||
} from '../services/backgroundTaskMonitor'
|
||||
import { useContactTypeCountsStore } from '../stores/contactTypeCountsStore'
|
||||
@@ -105,7 +109,6 @@ interface ExportOptions {
|
||||
txtColumns: string[]
|
||||
displayNamePreference: DisplayNamePreference
|
||||
exportConcurrency: number
|
||||
imageDeepSearchOnMiss: boolean
|
||||
}
|
||||
|
||||
interface SessionRow extends AppChatSession {
|
||||
@@ -209,6 +212,8 @@ interface AutomationTaskDraft {
|
||||
dateRangeConfig: ExportAutomationDateRangeConfig | string | null
|
||||
intervalDays: number
|
||||
intervalHours: number
|
||||
firstTriggerAtEnabled: boolean
|
||||
firstTriggerAtValue: string
|
||||
stopAtEnabled: boolean
|
||||
stopAtValue: string
|
||||
maxRunsEnabled: boolean
|
||||
@@ -218,6 +223,7 @@ interface AutomationTaskDraft {
|
||||
const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content']
|
||||
const DETAIL_PRECISE_REFRESH_COOLDOWN_MS = 10 * 60 * 1000
|
||||
const TASK_PERFORMANCE_UPDATE_MIN_INTERVAL_MS = 900
|
||||
const EXPORT_PROGRESS_UI_FLUSH_INTERVAL_MS = 320
|
||||
const SESSION_MEDIA_METRIC_PREFETCH_ROWS = 10
|
||||
const SESSION_MEDIA_METRIC_BATCH_SIZE = 8
|
||||
const SESSION_MEDIA_METRIC_BACKGROUND_FEED_SIZE = 48
|
||||
@@ -249,6 +255,8 @@ const backgroundTaskSourceLabels: Record<string, string> = {
|
||||
|
||||
const backgroundTaskStatusLabels: Record<BackgroundTaskRecord['status'], string> = {
|
||||
running: '运行中',
|
||||
pause_requested: '中断中',
|
||||
paused: '已中断',
|
||||
cancel_requested: '停止中',
|
||||
completed: '已完成',
|
||||
failed: '失败',
|
||||
@@ -322,6 +330,69 @@ const createEmptyProgress = (): TaskProgress => ({
|
||||
mediaBytesWritten: 0
|
||||
})
|
||||
|
||||
const areStringArraysEqual = (left: string[], right: string[]): boolean => {
|
||||
if (left === right) return true
|
||||
if (left.length !== right.length) return false
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
if (left[index] !== right[index]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const areTaskProgressEqual = (left: TaskProgress, right: TaskProgress): boolean => (
|
||||
left.current === right.current &&
|
||||
left.total === right.total &&
|
||||
left.currentName === right.currentName &&
|
||||
left.phase === right.phase &&
|
||||
left.phaseLabel === right.phaseLabel &&
|
||||
left.phaseProgress === right.phaseProgress &&
|
||||
left.phaseTotal === right.phaseTotal &&
|
||||
left.exportedMessages === right.exportedMessages &&
|
||||
left.estimatedTotalMessages === right.estimatedTotalMessages &&
|
||||
left.collectedMessages === right.collectedMessages &&
|
||||
left.writtenFiles === right.writtenFiles &&
|
||||
left.mediaDoneFiles === right.mediaDoneFiles &&
|
||||
left.mediaCacheHitFiles === right.mediaCacheHitFiles &&
|
||||
left.mediaCacheMissFiles === right.mediaCacheMissFiles &&
|
||||
left.mediaCacheFillFiles === right.mediaCacheFillFiles &&
|
||||
left.mediaDedupReuseFiles === right.mediaDedupReuseFiles &&
|
||||
left.mediaBytesWritten === right.mediaBytesWritten
|
||||
)
|
||||
|
||||
const normalizeProgressFloat = (value: unknown, digits = 3): number => {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed)) return 0
|
||||
const factor = 10 ** digits
|
||||
return Math.round(parsed * factor) / factor
|
||||
}
|
||||
|
||||
const normalizeProgressInt = (value: unknown): number => {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed)) return 0
|
||||
return Math.max(0, Math.floor(parsed))
|
||||
}
|
||||
|
||||
const buildProgressPayloadSignature = (payload: ExportProgress): string => ([
|
||||
String(payload.phase || ''),
|
||||
String(payload.currentSessionId || ''),
|
||||
String(payload.currentSession || ''),
|
||||
String(payload.phaseLabel || ''),
|
||||
normalizeProgressFloat(payload.current, 4),
|
||||
normalizeProgressFloat(payload.total, 4),
|
||||
normalizeProgressFloat(payload.phaseProgress, 2),
|
||||
normalizeProgressFloat(payload.phaseTotal, 2),
|
||||
normalizeProgressInt(payload.collectedMessages),
|
||||
normalizeProgressInt(payload.exportedMessages),
|
||||
normalizeProgressInt(payload.estimatedTotalMessages),
|
||||
normalizeProgressInt(payload.writtenFiles),
|
||||
normalizeProgressInt(payload.mediaDoneFiles),
|
||||
normalizeProgressInt(payload.mediaCacheHitFiles),
|
||||
normalizeProgressInt(payload.mediaCacheMissFiles),
|
||||
normalizeProgressInt(payload.mediaCacheFillFiles),
|
||||
normalizeProgressInt(payload.mediaDedupReuseFiles),
|
||||
normalizeProgressInt(payload.mediaBytesWritten)
|
||||
].join('|'))
|
||||
|
||||
const createEmptyTaskPerformance = (): TaskPerformance => ({
|
||||
stages: {
|
||||
collect: 0,
|
||||
@@ -336,6 +407,15 @@ const isTextBatchTask = (task: ExportTask): boolean => (
|
||||
task.payload.scope === 'content' && task.payload.contentType === 'text'
|
||||
)
|
||||
|
||||
const isImageExportTask = (task: ExportTask): boolean => {
|
||||
if (task.payload.scope === 'sns') {
|
||||
return Boolean(task.payload.snsOptions?.exportImages)
|
||||
}
|
||||
if (task.payload.scope !== 'content') return false
|
||||
if (task.payload.contentType === 'image') return true
|
||||
return Boolean(task.payload.options?.exportImages)
|
||||
}
|
||||
|
||||
const resolvePerfStageByPhase = (phase?: ExportProgress['phase']): TaskPerfStage => {
|
||||
if (phase === 'preparing') return 'collect'
|
||||
if (phase === 'writing') return 'write'
|
||||
@@ -500,6 +580,35 @@ const getTaskStatusLabel = (task: ExportTask): string => {
|
||||
return '失败'
|
||||
}
|
||||
|
||||
const resolveBackgroundTaskCardClass = (status: BackgroundTaskRecord['status']): 'running' | 'paused' | 'stopped' | 'success' | 'error' => {
|
||||
if (status === 'running') return 'running'
|
||||
if (status === 'pause_requested' || status === 'paused') return 'paused'
|
||||
if (status === 'cancel_requested' || status === 'canceled') return 'stopped'
|
||||
if (status === 'completed') return 'success'
|
||||
return 'error'
|
||||
}
|
||||
|
||||
const parseBackgroundTaskProgress = (progressText?: string): { current: number; total: number; ratio: number | null } => {
|
||||
const normalized = String(progressText || '').trim()
|
||||
if (!normalized) {
|
||||
return { current: 0, total: 0, ratio: null }
|
||||
}
|
||||
const match = normalized.match(/(\d+)\s*\/\s*(\d+)/)
|
||||
if (!match) {
|
||||
return { current: 0, total: 0, ratio: null }
|
||||
}
|
||||
const current = Math.max(0, Math.floor(Number(match[1]) || 0))
|
||||
const total = Math.max(0, Math.floor(Number(match[2]) || 0))
|
||||
if (total <= 0) {
|
||||
return { current, total, ratio: null }
|
||||
}
|
||||
return {
|
||||
current,
|
||||
total,
|
||||
ratio: Math.max(0, Math.min(1, current / total))
|
||||
}
|
||||
}
|
||||
|
||||
const formatAbsoluteDate = (timestamp: number): string => {
|
||||
const d = new Date(timestamp)
|
||||
const y = d.getFullYear()
|
||||
@@ -635,6 +744,11 @@ type ContactsDataSource = 'cache' | 'network' | null
|
||||
|
||||
const normalizeAutomationIntervalDays = (value: unknown): number => Math.max(0, Math.floor(Number(value) || 0))
|
||||
const normalizeAutomationIntervalHours = (value: unknown): number => Math.max(0, Math.min(23, Math.floor(Number(value) || 0)))
|
||||
const normalizeAutomationFirstTriggerAt = (value: unknown): number => {
|
||||
const numeric = Math.floor(Number(value) || 0)
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) return 0
|
||||
return numeric
|
||||
}
|
||||
|
||||
const resolveAutomationIntervalMs = (schedule: ExportAutomationSchedule): number => {
|
||||
const days = normalizeAutomationIntervalDays(schedule.intervalDays)
|
||||
@@ -644,6 +758,16 @@ const resolveAutomationIntervalMs = (schedule: ExportAutomationSchedule): number
|
||||
return totalHours * 60 * 60 * 1000
|
||||
}
|
||||
|
||||
const resolveAutomationInitialTriggerAt = (task: ExportAutomationTask): number | null => {
|
||||
const intervalMs = resolveAutomationIntervalMs(task.schedule)
|
||||
if (intervalMs <= 0) return null
|
||||
const firstTriggerAt = normalizeAutomationFirstTriggerAt(task.schedule.firstTriggerAt)
|
||||
if (firstTriggerAt > 0) return firstTriggerAt
|
||||
const createdAt = Math.max(0, Math.floor(Number(task.createdAt || 0)))
|
||||
if (!createdAt) return null
|
||||
return createdAt + intervalMs
|
||||
}
|
||||
|
||||
const formatAutomationScheduleLabel = (schedule: ExportAutomationSchedule): string => {
|
||||
const days = normalizeAutomationIntervalDays(schedule.intervalDays)
|
||||
const hours = normalizeAutomationIntervalHours(schedule.intervalHours)
|
||||
@@ -657,12 +781,60 @@ const resolveAutomationDueScheduleKey = (task: ExportAutomationTask, now: Date):
|
||||
const intervalMs = resolveAutomationIntervalMs(task.schedule)
|
||||
if (intervalMs <= 0) return null
|
||||
const nowMs = now.getTime()
|
||||
const anchorAt = Math.max(
|
||||
0,
|
||||
Number(task.runState?.lastTriggeredAt || 0) || Number(task.createdAt || 0)
|
||||
)
|
||||
if (nowMs < anchorAt + intervalMs) return null
|
||||
return `interval:${anchorAt}:${Math.floor((nowMs - anchorAt) / intervalMs)}`
|
||||
const lastTriggeredAt = Math.max(0, Math.floor(Number(task.runState?.lastTriggeredAt || 0)))
|
||||
if (lastTriggeredAt > 0) {
|
||||
if (nowMs < lastTriggeredAt + intervalMs) return null
|
||||
return `interval:${lastTriggeredAt}:${Math.floor((nowMs - lastTriggeredAt) / intervalMs)}`
|
||||
}
|
||||
const initialTriggerAt = resolveAutomationInitialTriggerAt(task)
|
||||
if (!initialTriggerAt) return null
|
||||
if (nowMs < initialTriggerAt) return null
|
||||
return `first:${initialTriggerAt}`
|
||||
}
|
||||
|
||||
const resolveAutomationFirstTriggerSummary = (task: ExportAutomationTask): string => {
|
||||
const firstTriggerAt = normalizeAutomationFirstTriggerAt(task.schedule.firstTriggerAt)
|
||||
if (firstTriggerAt <= 0) return '未指定(默认按创建时间+间隔)'
|
||||
return new Date(firstTriggerAt).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
const buildAutomationSchedule = (
|
||||
intervalDays: number,
|
||||
intervalHours: number,
|
||||
firstTriggerAt: number
|
||||
): ExportAutomationSchedule => ({
|
||||
type: 'interval',
|
||||
intervalDays,
|
||||
intervalHours,
|
||||
firstTriggerAt: firstTriggerAt > 0 ? firstTriggerAt : undefined
|
||||
})
|
||||
|
||||
const buildAutomationDatePart = (timestamp: number): string => {
|
||||
const date = new Date(timestamp)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
const year = date.getFullYear()
|
||||
const month = `${date.getMonth() + 1}`.padStart(2, '0')
|
||||
const day = `${date.getDate()}`.padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
const buildAutomationTodayDatePart = (): string => buildAutomationDatePart(Date.now())
|
||||
|
||||
const normalizeAutomationDatePart = (value: string): string => {
|
||||
const text = String(value || '').trim()
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : ''
|
||||
}
|
||||
|
||||
const normalizeAutomationTimePart = (value: string): string => {
|
||||
const text = String(value || '').trim()
|
||||
if (!/^\d{2}:\d{2}$/.test(text)) return '00:00'
|
||||
const [hoursText, minutesText] = text.split(':')
|
||||
const hours = Math.floor(Number(hoursText))
|
||||
const minutes = Math.floor(Number(minutesText))
|
||||
if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return '00:00'
|
||||
const safeHours = Math.min(23, Math.max(0, hours))
|
||||
const safeMinutes = Math.min(59, Math.max(0, minutes))
|
||||
return `${`${safeHours}`.padStart(2, '0')}:${`${safeMinutes}`.padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const toDateTimeLocalValue = (timestamp: number): string => {
|
||||
@@ -803,9 +975,9 @@ const formatAutomationStopCondition = (task: ExportAutomationTask): string => {
|
||||
const resolveAutomationNextTriggerAt = (task: ExportAutomationTask): number | null => {
|
||||
const intervalMs = resolveAutomationIntervalMs(task.schedule)
|
||||
if (intervalMs <= 0) return null
|
||||
const anchorAt = Math.max(0, Number(task.runState?.lastTriggeredAt || 0) || Number(task.createdAt || 0))
|
||||
if (!anchorAt) return null
|
||||
return anchorAt + intervalMs
|
||||
const lastTriggeredAt = Math.max(0, Math.floor(Number(task.runState?.lastTriggeredAt || 0)))
|
||||
if (lastTriggeredAt > 0) return lastTriggeredAt + intervalMs
|
||||
return resolveAutomationInitialTriggerAt(task)
|
||||
}
|
||||
|
||||
const formatAutomationCurrentState = (
|
||||
@@ -1589,25 +1761,40 @@ const SectionInfoTooltip = memo(function SectionInfoTooltip({
|
||||
interface TaskCenterModalProps {
|
||||
isOpen: boolean
|
||||
tasks: ExportTask[]
|
||||
chatBackgroundTasks: BackgroundTaskRecord[]
|
||||
taskRunningCount: number
|
||||
taskQueuedCount: number
|
||||
expandedPerfTaskId: string | null
|
||||
nowTick: number
|
||||
onClose: () => void
|
||||
onTogglePerfTask: (taskId: string) => void
|
||||
onPauseBackgroundTask: (taskId: string) => void
|
||||
onResumeBackgroundTask: (taskId: string) => void
|
||||
onCancelBackgroundTask: (taskId: string) => void
|
||||
}
|
||||
|
||||
const TaskCenterModal = memo(function TaskCenterModal({
|
||||
isOpen,
|
||||
tasks,
|
||||
chatBackgroundTasks,
|
||||
taskRunningCount,
|
||||
taskQueuedCount,
|
||||
expandedPerfTaskId,
|
||||
nowTick,
|
||||
onClose,
|
||||
onTogglePerfTask
|
||||
onTogglePerfTask,
|
||||
onPauseBackgroundTask,
|
||||
onResumeBackgroundTask,
|
||||
onCancelBackgroundTask
|
||||
}: TaskCenterModalProps) {
|
||||
if (!isOpen) return null
|
||||
const chatActiveTaskCount = chatBackgroundTasks.filter(task => (
|
||||
task.status === 'running' ||
|
||||
task.status === 'pause_requested' ||
|
||||
task.status === 'paused' ||
|
||||
task.status === 'cancel_requested'
|
||||
)).length
|
||||
const totalTaskCount = tasks.length + chatBackgroundTasks.length
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
@@ -1624,7 +1811,7 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
||||
<div className="task-center-modal-header">
|
||||
<div className="task-center-modal-title">
|
||||
<h3>任务中心</h3>
|
||||
<span>进行中 {taskRunningCount} · 排队 {taskQueuedCount} · 总计 {tasks.length}</span>
|
||||
<span>导出进行中 {taskRunningCount} · 排队 {taskQueuedCount} · 聊天后台 {chatActiveTaskCount} · 总计 {totalTaskCount}</span>
|
||||
</div>
|
||||
<button
|
||||
className="close-icon-btn"
|
||||
@@ -1636,8 +1823,8 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
||||
</button>
|
||||
</div>
|
||||
<div className="task-center-modal-body">
|
||||
{tasks.length === 0 ? (
|
||||
<div className="task-empty">暂无任务。点击会话导出或卡片导出后会在这里创建任务。</div>
|
||||
{totalTaskCount === 0 ? (
|
||||
<div className="task-empty">暂无任务。导出任务和聊天页批量语音/图片任务都会显示在这里。</div>
|
||||
) : (
|
||||
<div className="task-list">
|
||||
{tasks.map(task => {
|
||||
@@ -1705,6 +1892,24 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
||||
const currentSessionRatio = task.progress.phaseTotal > 0
|
||||
? Math.max(0, Math.min(1, task.progress.phaseProgress / task.progress.phaseTotal))
|
||||
: null
|
||||
const imageTask = isImageExportTask(task)
|
||||
const imageTimingElapsedMs = imageTask
|
||||
? Math.max(0, (
|
||||
typeof task.finishedAt === 'number'
|
||||
? task.finishedAt
|
||||
: nowTick
|
||||
) - (task.startedAt || task.createdAt))
|
||||
: 0
|
||||
const imageTimingAvgMs = imageTask && mediaDoneFiles > 0
|
||||
? Math.floor(imageTimingElapsedMs / Math.max(1, mediaDoneFiles))
|
||||
: 0
|
||||
const imageTimingLabel = imageTask
|
||||
? (
|
||||
mediaDoneFiles > 0
|
||||
? `图片耗时 ${formatDurationMs(imageTimingElapsedMs)} · 平均 ${imageTimingAvgMs}ms/张`
|
||||
: `图片耗时 ${formatDurationMs(imageTimingElapsedMs)}`
|
||||
)
|
||||
: ''
|
||||
return (
|
||||
<div key={task.id} className={`task-card ${task.status}`}>
|
||||
<div className="task-main">
|
||||
@@ -1734,6 +1939,11 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{imageTimingLabel && task.status !== 'queued' && (
|
||||
<div className="task-perf-summary">
|
||||
<span>{imageTimingLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
{canShowPerfDetail && stageTotals && (
|
||||
<div className="task-perf-summary">
|
||||
<span>累计耗时 {formatDurationMs(stageTotalMs)}</span>
|
||||
@@ -1802,6 +2012,70 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{chatBackgroundTasks.map(task => {
|
||||
const taskCardClass = resolveBackgroundTaskCardClass(task.status)
|
||||
const progress = parseBackgroundTaskProgress(task.progressText)
|
||||
const canPause = task.resumable && task.status === 'running'
|
||||
const canResume = task.resumable && (task.status === 'paused' || task.status === 'pause_requested')
|
||||
const canCancel = task.cancelable && (
|
||||
task.status === 'running' ||
|
||||
task.status === 'pause_requested' ||
|
||||
task.status === 'paused' ||
|
||||
task.status === 'cancel_requested'
|
||||
)
|
||||
return (
|
||||
<div key={task.id} className={`task-card ${taskCardClass}`}>
|
||||
<div className="task-main">
|
||||
<div className="task-title">{task.title}</div>
|
||||
<div className="task-meta">
|
||||
<span className={`task-status ${taskCardClass}`}>{backgroundTaskStatusLabels[task.status]}</span>
|
||||
<span>{backgroundTaskSourceLabels[task.sourcePage] || backgroundTaskSourceLabels.other}</span>
|
||||
<span>{new Date(task.startedAt).toLocaleString('zh-CN')}</span>
|
||||
</div>
|
||||
{progress.ratio !== null && (
|
||||
<div className="task-progress-bar">
|
||||
<div
|
||||
className="task-progress-fill"
|
||||
style={{ width: `${progress.ratio * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="task-progress-text">
|
||||
{task.detail || '任务进行中'}
|
||||
{task.progressText ? ` · ${task.progressText}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="task-actions">
|
||||
{canPause && (
|
||||
<button
|
||||
className="task-action-btn"
|
||||
type="button"
|
||||
onClick={() => onPauseBackgroundTask(task.id)}
|
||||
>
|
||||
<Pause size={14} /> 中断
|
||||
</button>
|
||||
)}
|
||||
{canResume && (
|
||||
<button
|
||||
className="task-action-btn primary"
|
||||
type="button"
|
||||
onClick={() => onResumeBackgroundTask(task.id)}
|
||||
>
|
||||
<Play size={14} /> 继续
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="task-action-btn danger"
|
||||
type="button"
|
||||
onClick={() => onCancelBackgroundTask(task.id)}
|
||||
disabled={!canCancel || task.status === 'cancel_requested'}
|
||||
>
|
||||
{task.status === 'cancel_requested' ? '停止中' : '停止'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1903,7 +2177,6 @@ function ExportPage() {
|
||||
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false)
|
||||
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
|
||||
const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2)
|
||||
const [exportDefaultImageDeepSearchOnMiss, setExportDefaultImageDeepSearchOnMiss] = useState(true)
|
||||
|
||||
const [options, setOptions] = useState<ExportOptions>({
|
||||
format: 'json',
|
||||
@@ -1924,8 +2197,7 @@ function ExportPage() {
|
||||
excelCompactColumns: true,
|
||||
txtColumns: defaultTxtColumns,
|
||||
displayNamePreference: 'remark',
|
||||
exportConcurrency: 2,
|
||||
imageDeepSearchOnMiss: true
|
||||
exportConcurrency: 2
|
||||
})
|
||||
|
||||
const [exportDialog, setExportDialog] = useState<ExportDialogState>({
|
||||
@@ -2622,7 +2894,7 @@ function ExportPage() {
|
||||
automationTasksReadyRef.current = false
|
||||
let isReady = true
|
||||
try {
|
||||
const [savedPath, savedFormat, savedAvatars, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedImageDeepSearchOnMiss, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, savedSessionNameWithTypePrefix, savedDefaultDateRange, savedFileNamingMode, exportCacheScope] = await Promise.all([
|
||||
const [savedPath, savedFormat, savedAvatars, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, savedSessionNameWithTypePrefix, savedDefaultDateRange, savedFileNamingMode, exportCacheScope] = await Promise.all([
|
||||
configService.getExportPath(),
|
||||
configService.getExportDefaultFormat(),
|
||||
configService.getExportDefaultAvatars(),
|
||||
@@ -2631,7 +2903,6 @@ function ExportPage() {
|
||||
configService.getExportDefaultExcelCompactColumns(),
|
||||
configService.getExportDefaultTxtColumns(),
|
||||
configService.getExportDefaultConcurrency(),
|
||||
configService.getExportDefaultImageDeepSearchOnMiss(),
|
||||
configService.getExportLastSessionRunMap(),
|
||||
configService.getExportLastContentRunMap(),
|
||||
configService.getExportSessionRecordMap(),
|
||||
@@ -2671,7 +2942,6 @@ function ExportPage() {
|
||||
setExportDefaultVoiceAsText(savedVoiceAsText ?? false)
|
||||
setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true)
|
||||
setExportDefaultConcurrency(savedConcurrency ?? 2)
|
||||
setExportDefaultImageDeepSearchOnMiss(savedImageDeepSearchOnMiss ?? true)
|
||||
setExportDefaultFileNamingMode(savedFileNamingMode ?? 'classic')
|
||||
setAutomationTasks(automationTaskItem?.tasks || [])
|
||||
automationTasksReadyRef.current = true
|
||||
@@ -2709,8 +2979,7 @@ function ExportPage() {
|
||||
exportVoiceAsText: savedVoiceAsText ?? prev.exportVoiceAsText,
|
||||
excelCompactColumns: savedExcelCompactColumns ?? prev.excelCompactColumns,
|
||||
txtColumns,
|
||||
exportConcurrency: savedConcurrency ?? prev.exportConcurrency,
|
||||
imageDeepSearchOnMiss: savedImageDeepSearchOnMiss ?? prev.imageDeepSearchOnMiss
|
||||
exportConcurrency: savedConcurrency ?? prev.exportConcurrency
|
||||
}))
|
||||
} catch (error) {
|
||||
isReady = false
|
||||
@@ -4491,8 +4760,7 @@ function ExportPage() {
|
||||
maxFileSizeMb: prev.maxFileSizeMb,
|
||||
exportVoiceAsText: exportDefaultVoiceAsText,
|
||||
excelCompactColumns: exportDefaultExcelCompactColumns,
|
||||
exportConcurrency: exportDefaultConcurrency,
|
||||
imageDeepSearchOnMiss: exportDefaultImageDeepSearchOnMiss
|
||||
exportConcurrency: exportDefaultConcurrency
|
||||
}
|
||||
|
||||
if (payload.scope === 'sns') {
|
||||
@@ -4527,8 +4795,7 @@ function ExportPage() {
|
||||
exportDefaultAvatars,
|
||||
exportDefaultMedia,
|
||||
exportDefaultVoiceAsText,
|
||||
exportDefaultConcurrency,
|
||||
exportDefaultImageDeepSearchOnMiss
|
||||
exportDefaultConcurrency
|
||||
])
|
||||
|
||||
const closeExportDialog = useCallback(() => {
|
||||
@@ -4755,7 +5022,6 @@ function ExportPage() {
|
||||
txtColumns: options.txtColumns,
|
||||
displayNamePreference: options.displayNamePreference,
|
||||
exportConcurrency: options.exportConcurrency,
|
||||
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
|
||||
fileNamingMode: exportDefaultFileNamingMode,
|
||||
sessionLayout,
|
||||
sessionNameWithTypePrefix,
|
||||
@@ -4834,6 +5100,7 @@ function ExportPage() {
|
||||
|
||||
const openEditAutomationTaskDraft = useCallback((task: ExportAutomationTask) => {
|
||||
const schedule = task.schedule
|
||||
const firstTriggerAt = normalizeAutomationFirstTriggerAt(schedule.firstTriggerAt)
|
||||
const stopAt = Number(task.stopCondition?.endAt || 0)
|
||||
const maxRuns = Number(task.stopCondition?.maxRuns || 0)
|
||||
const resolvedRange = resolveAutomationDateRangeSelection(task.template.dateRangeConfig as any, new Date())
|
||||
@@ -4854,6 +5121,8 @@ function ExportPage() {
|
||||
dateRangeConfig: task.template.dateRangeConfig,
|
||||
intervalDays: normalizeAutomationIntervalDays(schedule.intervalDays),
|
||||
intervalHours: normalizeAutomationIntervalHours(schedule.intervalHours),
|
||||
firstTriggerAtEnabled: firstTriggerAt > 0,
|
||||
firstTriggerAtValue: firstTriggerAt > 0 ? toDateTimeLocalValue(firstTriggerAt) : '',
|
||||
stopAtEnabled: stopAt > 0,
|
||||
stopAtValue: stopAt > 0 ? toDateTimeLocalValue(stopAt) : '',
|
||||
maxRunsEnabled: maxRuns > 0,
|
||||
@@ -4959,7 +5228,18 @@ function ExportPage() {
|
||||
window.alert('执行间隔不能为 0,请至少设置天数或小时')
|
||||
return
|
||||
}
|
||||
const schedule: ExportAutomationSchedule = { type: 'interval', intervalDays, intervalHours }
|
||||
const firstTriggerAtTimestamp = automationTaskDraft.firstTriggerAtEnabled
|
||||
? parseDateTimeLocalValue(automationTaskDraft.firstTriggerAtValue)
|
||||
: null
|
||||
if (automationTaskDraft.firstTriggerAtEnabled && !firstTriggerAtTimestamp) {
|
||||
window.alert('请填写有效的首次触发时间')
|
||||
return
|
||||
}
|
||||
const schedule = buildAutomationSchedule(
|
||||
intervalDays,
|
||||
intervalHours,
|
||||
firstTriggerAtTimestamp && firstTriggerAtTimestamp > 0 ? firstTriggerAtTimestamp : 0
|
||||
)
|
||||
const stopAtTimestamp = automationTaskDraft.stopAtEnabled
|
||||
? parseDateTimeLocalValue(automationTaskDraft.stopAtValue)
|
||||
: null
|
||||
@@ -5146,14 +5426,10 @@ function ExportPage() {
|
||||
const settledSessionIdsFromProgress = new Set<string>()
|
||||
const sessionMessageProgress = new Map<string, { exported: number; total: number; knownTotal: boolean }>()
|
||||
let queuedProgressPayload: ExportProgress | null = null
|
||||
let queuedProgressRaf: number | null = null
|
||||
let queuedProgressSignature = ''
|
||||
let queuedProgressTimer: number | null = null
|
||||
|
||||
const clearQueuedProgress = () => {
|
||||
if (queuedProgressRaf !== null) {
|
||||
window.cancelAnimationFrame(queuedProgressRaf)
|
||||
queuedProgressRaf = null
|
||||
}
|
||||
if (queuedProgressTimer !== null) {
|
||||
window.clearTimeout(queuedProgressTimer)
|
||||
queuedProgressTimer = null
|
||||
@@ -5205,6 +5481,7 @@ function ExportPage() {
|
||||
if (!queuedProgressPayload) return
|
||||
const payload = queuedProgressPayload
|
||||
queuedProgressPayload = null
|
||||
queuedProgressSignature = ''
|
||||
const now = Date.now()
|
||||
const currentSessionId = String(payload.currentSessionId || '').trim()
|
||||
updateTask(next.id, task => {
|
||||
@@ -5261,77 +5538,71 @@ function ExportPage() {
|
||||
const mediaBytesWritten = Number.isFinite(payload.mediaBytesWritten)
|
||||
? Math.max(prevMediaBytesWritten, Math.max(0, Math.floor(Number(payload.mediaBytesWritten || 0))))
|
||||
: prevMediaBytesWritten
|
||||
const nextProgress: TaskProgress = {
|
||||
current: payload.current,
|
||||
total: payload.total,
|
||||
currentName: payload.currentSession || '',
|
||||
phase: payload.phase,
|
||||
phaseLabel: payload.phaseLabel || '',
|
||||
phaseProgress: payload.phaseProgress || 0,
|
||||
phaseTotal: payload.phaseTotal || 0,
|
||||
exportedMessages: Math.max(task.progress.exportedMessages, aggregatedMessageProgress.exported),
|
||||
estimatedTotalMessages: aggregatedMessageProgress.estimated > 0
|
||||
? Math.max(task.progress.estimatedTotalMessages, aggregatedMessageProgress.estimated)
|
||||
: (task.progress.estimatedTotalMessages > 0 ? task.progress.estimatedTotalMessages : 0),
|
||||
collectedMessages: Math.max(task.progress.collectedMessages, collectedMessages),
|
||||
writtenFiles,
|
||||
mediaDoneFiles,
|
||||
mediaCacheHitFiles,
|
||||
mediaCacheMissFiles,
|
||||
mediaCacheFillFiles,
|
||||
mediaDedupReuseFiles,
|
||||
mediaBytesWritten
|
||||
}
|
||||
const hasSettledListChanged = !areStringArraysEqual(settledSessionIds, nextSettledSessionIds)
|
||||
const hasProgressChanged = !areTaskProgressEqual(task.progress, nextProgress)
|
||||
const hasPerformanceChanged = performance !== task.performance
|
||||
if (!hasSettledListChanged && !hasProgressChanged && !hasPerformanceChanged) {
|
||||
return task
|
||||
}
|
||||
return {
|
||||
...task,
|
||||
progress: {
|
||||
current: payload.current,
|
||||
total: payload.total,
|
||||
currentName: payload.currentSession,
|
||||
phase: payload.phase,
|
||||
phaseLabel: payload.phaseLabel || '',
|
||||
phaseProgress: payload.phaseProgress || 0,
|
||||
phaseTotal: payload.phaseTotal || 0,
|
||||
exportedMessages: Math.max(task.progress.exportedMessages, aggregatedMessageProgress.exported),
|
||||
estimatedTotalMessages: aggregatedMessageProgress.estimated > 0
|
||||
? Math.max(task.progress.estimatedTotalMessages, aggregatedMessageProgress.estimated)
|
||||
: (task.progress.estimatedTotalMessages > 0 ? task.progress.estimatedTotalMessages : 0),
|
||||
collectedMessages: Math.max(task.progress.collectedMessages, collectedMessages),
|
||||
writtenFiles,
|
||||
mediaDoneFiles,
|
||||
mediaCacheHitFiles,
|
||||
mediaCacheMissFiles,
|
||||
mediaCacheFillFiles,
|
||||
mediaDedupReuseFiles,
|
||||
mediaBytesWritten
|
||||
},
|
||||
settledSessionIds: nextSettledSessionIds,
|
||||
performance
|
||||
progress: hasProgressChanged ? nextProgress : task.progress,
|
||||
settledSessionIds: hasSettledListChanged ? nextSettledSessionIds : settledSessionIds,
|
||||
performance: hasPerformanceChanged ? performance : task.performance
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const queueProgressUpdate = (payload: ExportProgress) => {
|
||||
const signature = buildProgressPayloadSignature(payload)
|
||||
if (queuedProgressPayload && signature === queuedProgressSignature) {
|
||||
return
|
||||
}
|
||||
queuedProgressPayload = payload
|
||||
queuedProgressSignature = signature
|
||||
if (payload.phase === 'complete') {
|
||||
clearQueuedProgress()
|
||||
flushQueuedProgress()
|
||||
return
|
||||
}
|
||||
if (queuedProgressRaf !== null || queuedProgressTimer !== null) return
|
||||
queuedProgressRaf = window.requestAnimationFrame(() => {
|
||||
queuedProgressRaf = null
|
||||
queuedProgressTimer = window.setTimeout(() => {
|
||||
queuedProgressTimer = null
|
||||
flushQueuedProgress()
|
||||
}, 180)
|
||||
})
|
||||
if (queuedProgressTimer !== null) return
|
||||
queuedProgressTimer = window.setTimeout(() => {
|
||||
queuedProgressTimer = null
|
||||
flushQueuedProgress()
|
||||
}, EXPORT_PROGRESS_UI_FLUSH_INTERVAL_MS)
|
||||
}
|
||||
if (next.payload.scope === 'sns') {
|
||||
progressUnsubscribeRef.current = window.electronAPI.sns.onExportProgress((payload) => {
|
||||
updateTask(next.id, task => {
|
||||
if (task.status !== 'running') return task
|
||||
return {
|
||||
...task,
|
||||
progress: {
|
||||
current: payload.current || 0,
|
||||
total: payload.total || 0,
|
||||
currentName: '',
|
||||
phase: 'exporting',
|
||||
phaseLabel: payload.status || '',
|
||||
phaseProgress: payload.total > 0 ? payload.current : 0,
|
||||
phaseTotal: payload.total || 0,
|
||||
exportedMessages: payload.total > 0 ? Math.max(0, Math.floor(payload.current || 0)) : task.progress.exportedMessages,
|
||||
estimatedTotalMessages: payload.total > 0 ? Math.max(0, Math.floor(payload.total || 0)) : task.progress.estimatedTotalMessages,
|
||||
collectedMessages: task.progress.collectedMessages,
|
||||
writtenFiles: task.progress.writtenFiles,
|
||||
mediaDoneFiles: task.progress.mediaDoneFiles,
|
||||
mediaCacheHitFiles: task.progress.mediaCacheHitFiles,
|
||||
mediaCacheMissFiles: task.progress.mediaCacheMissFiles,
|
||||
mediaCacheFillFiles: task.progress.mediaCacheFillFiles,
|
||||
mediaDedupReuseFiles: task.progress.mediaDedupReuseFiles,
|
||||
mediaBytesWritten: task.progress.mediaBytesWritten
|
||||
}
|
||||
}
|
||||
queueProgressUpdate({
|
||||
current: Number(payload.current || 0),
|
||||
total: Number(payload.total || 0),
|
||||
currentSession: '',
|
||||
currentSessionId: '',
|
||||
phase: 'exporting',
|
||||
phaseLabel: String(payload.status || ''),
|
||||
phaseProgress: payload.total > 0 ? Number(payload.current || 0) : 0,
|
||||
phaseTotal: Number(payload.total || 0)
|
||||
})
|
||||
})
|
||||
} else {
|
||||
@@ -5656,6 +5927,8 @@ function ExportPage() {
|
||||
dateRangeConfig: serializeExportDateRangeConfig(normalizedRangeSelection),
|
||||
intervalDays: 1,
|
||||
intervalHours: 0,
|
||||
firstTriggerAtEnabled: false,
|
||||
firstTriggerAtValue: '',
|
||||
stopAtEnabled: false,
|
||||
stopAtValue: '',
|
||||
maxRunsEnabled: false,
|
||||
@@ -5691,8 +5964,6 @@ function ExportPage() {
|
||||
await configService.setExportDefaultExcelCompactColumns(options.excelCompactColumns)
|
||||
await configService.setExportDefaultTxtColumns(options.txtColumns)
|
||||
await configService.setExportDefaultConcurrency(options.exportConcurrency)
|
||||
await configService.setExportDefaultImageDeepSearchOnMiss(options.imageDeepSearchOnMiss)
|
||||
setExportDefaultImageDeepSearchOnMiss(options.imageDeepSearchOnMiss)
|
||||
}
|
||||
|
||||
const openSingleExport = useCallback((session: SessionRow) => {
|
||||
@@ -7336,11 +7607,23 @@ function ExportPage() {
|
||||
const handleCancelBackgroundTask = useCallback((taskId: string) => {
|
||||
requestCancelBackgroundTask(taskId)
|
||||
}, [])
|
||||
const handlePauseBackgroundTask = useCallback((taskId: string) => {
|
||||
requestPauseBackgroundTask(taskId)
|
||||
}, [])
|
||||
const handleResumeBackgroundTask = useCallback((taskId: string) => {
|
||||
requestResumeBackgroundTask(taskId)
|
||||
}, [])
|
||||
const handleCancelAllNonExportTasks = useCallback(() => {
|
||||
requestCancelBackgroundTasks(task => (
|
||||
task.sourcePage !== 'export' &&
|
||||
task.sourcePage !== 'chat' &&
|
||||
task.cancelable &&
|
||||
(task.status === 'running' || task.status === 'cancel_requested')
|
||||
(
|
||||
task.status === 'running' ||
|
||||
task.status === 'pause_requested' ||
|
||||
task.status === 'paused' ||
|
||||
task.status === 'cancel_requested'
|
||||
)
|
||||
))
|
||||
}, [])
|
||||
|
||||
@@ -7393,14 +7676,6 @@ function ExportPage() {
|
||||
const useCollapsedSessionFormatSelector = isSessionScopeDialog || isContentTextDialog
|
||||
const shouldShowFormatSection = !isContentScopeDialog || isContentTextDialog
|
||||
const shouldShowMediaSection = !isContentScopeDialog
|
||||
const shouldRenderImageDeepSearchToggle = exportDialog.scope !== 'sns' && (
|
||||
isSessionScopeDialog ||
|
||||
(isContentScopeDialog && exportDialog.contentType === 'image')
|
||||
)
|
||||
const shouldShowImageDeepSearchToggle = exportDialog.scope !== 'sns' && (
|
||||
(isSessionScopeDialog && options.exportImages) ||
|
||||
(isContentScopeDialog && exportDialog.contentType === 'image')
|
||||
)
|
||||
const avatarExportStatusLabel = options.exportAvatars ? '已开启聊天消息导出带头像' : '已关闭聊天消息导出带头像'
|
||||
const contentTextDialogSummary = '此模式只导出聊天文本,不包含图片语音视频表情包等多媒体文件。'
|
||||
const activeDialogFormatLabel = exportDialog.scope === 'sns'
|
||||
@@ -7496,7 +7771,18 @@ function ExportPage() {
|
||||
const isSnsCardStatsLoading = !hasSeededSnsStats
|
||||
const taskRunningCount = tasks.filter(task => task.status === 'running').length
|
||||
const taskQueuedCount = tasks.filter(task => task.status === 'queued').length
|
||||
const taskCenterAlertCount = taskRunningCount + taskQueuedCount
|
||||
const chatBackgroundTasks = useMemo(() => (
|
||||
backgroundTasks.filter(task => task.sourcePage === 'chat')
|
||||
), [backgroundTasks])
|
||||
const chatBackgroundActiveTaskCount = useMemo(() => (
|
||||
chatBackgroundTasks.filter(task => (
|
||||
task.status === 'running' ||
|
||||
task.status === 'pause_requested' ||
|
||||
task.status === 'paused' ||
|
||||
task.status === 'cancel_requested'
|
||||
)).length
|
||||
), [chatBackgroundTasks])
|
||||
const taskCenterAlertCount = taskRunningCount + taskQueuedCount + chatBackgroundActiveTaskCount
|
||||
const hasFilteredContacts = filteredContacts.length > 0
|
||||
const optionalMetricColumnCount = (shouldShowSnsColumn ? 1 : 0) + (shouldShowMutualFriendsColumn ? 1 : 0)
|
||||
const contactsMetricColumnCount = 4 + optionalMetricColumnCount
|
||||
@@ -7511,15 +7797,25 @@ function ExportPage() {
|
||||
width: `${Math.max(contactsHorizontalScrollMetrics.contentWidth, contactsHorizontalScrollMetrics.viewportWidth)}px`
|
||||
}), [contactsHorizontalScrollMetrics.contentWidth, contactsHorizontalScrollMetrics.viewportWidth])
|
||||
const nonExportBackgroundTasks = useMemo(() => (
|
||||
backgroundTasks.filter(task => task.sourcePage !== 'export')
|
||||
backgroundTasks.filter(task => task.sourcePage !== 'export' && task.sourcePage !== 'chat')
|
||||
), [backgroundTasks])
|
||||
const runningNonExportTaskCount = useMemo(() => (
|
||||
nonExportBackgroundTasks.filter(task => task.status === 'running' || task.status === 'cancel_requested').length
|
||||
nonExportBackgroundTasks.filter(task => (
|
||||
task.status === 'running' ||
|
||||
task.status === 'pause_requested' ||
|
||||
task.status === 'paused' ||
|
||||
task.status === 'cancel_requested'
|
||||
)).length
|
||||
), [nonExportBackgroundTasks])
|
||||
const cancelableNonExportTaskCount = useMemo(() => (
|
||||
nonExportBackgroundTasks.filter(task => (
|
||||
task.cancelable &&
|
||||
(task.status === 'running' || task.status === 'cancel_requested')
|
||||
(
|
||||
task.status === 'running' ||
|
||||
task.status === 'pause_requested' ||
|
||||
task.status === 'paused' ||
|
||||
task.status === 'cancel_requested'
|
||||
)
|
||||
)).length
|
||||
), [nonExportBackgroundTasks])
|
||||
const nonExportBackgroundTasksUpdatedAt = useMemo(() => (
|
||||
@@ -8139,12 +8435,16 @@ function ExportPage() {
|
||||
<TaskCenterModal
|
||||
isOpen={isTaskCenterOpen}
|
||||
tasks={tasks}
|
||||
chatBackgroundTasks={chatBackgroundTasks}
|
||||
taskRunningCount={taskRunningCount}
|
||||
taskQueuedCount={taskQueuedCount}
|
||||
expandedPerfTaskId={expandedPerfTaskId}
|
||||
nowTick={nowTick}
|
||||
onClose={closeTaskCenter}
|
||||
onTogglePerfTask={toggleTaskPerfDetail}
|
||||
onPauseBackgroundTask={handlePauseBackgroundTask}
|
||||
onResumeBackgroundTask={handleResumeBackgroundTask}
|
||||
onCancelBackgroundTask={handleCancelBackgroundTask}
|
||||
/>
|
||||
|
||||
{isAutomationModalOpen && createPortal(
|
||||
@@ -8220,6 +8520,7 @@ function ExportPage() {
|
||||
{queueState === 'queued' && <span className="automation-task-status queued">排队中</span>}
|
||||
</div>
|
||||
<p>{formatAutomationScheduleLabel(task.schedule)}</p>
|
||||
<p>首次触发:{resolveAutomationFirstTriggerSummary(task)}</p>
|
||||
<p>时间范围:{formatAutomationRangeLabel(task.template.dateRangeConfig as any)}</p>
|
||||
<p>会话范围:{task.sessionIds.length} 个</p>
|
||||
<p>导出目录:{task.outputDir || `${exportFolder || '未设置'}(全局)`}</p>
|
||||
@@ -8333,6 +8634,52 @@ function ExportPage() {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="automation-form-field">
|
||||
<span>首次触发时间(可选)</span>
|
||||
<label className="automation-inline-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={automationTaskDraft.firstTriggerAtEnabled}
|
||||
onChange={(event) => setAutomationTaskDraft((prev) => prev ? {
|
||||
...prev,
|
||||
firstTriggerAtEnabled: event.target.checked
|
||||
} : prev)}
|
||||
/>
|
||||
指定第一次触发时间
|
||||
</label>
|
||||
{automationTaskDraft.firstTriggerAtEnabled && (
|
||||
<div className="automation-first-trigger-picker">
|
||||
<input
|
||||
type="date"
|
||||
className="automation-stopat-date"
|
||||
value={automationTaskDraft.firstTriggerAtValue ? automationTaskDraft.firstTriggerAtValue.slice(0, 10) : ''}
|
||||
onChange={(event) => {
|
||||
const datePart = normalizeAutomationDatePart(event.target.value)
|
||||
const timePart = normalizeAutomationTimePart(automationTaskDraft.firstTriggerAtValue?.slice(11) || '00:00')
|
||||
setAutomationTaskDraft((prev) => prev ? {
|
||||
...prev,
|
||||
firstTriggerAtValue: datePart ? `${datePart}T${timePart}` : ''
|
||||
} : prev)
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="time"
|
||||
className="automation-stopat-time"
|
||||
value={automationTaskDraft.firstTriggerAtValue ? normalizeAutomationTimePart(automationTaskDraft.firstTriggerAtValue.slice(11)) : '00:00'}
|
||||
onChange={(event) => {
|
||||
const timePart = normalizeAutomationTimePart(event.target.value)
|
||||
const datePart = normalizeAutomationDatePart(automationTaskDraft.firstTriggerAtValue?.slice(0, 10))
|
||||
|| buildAutomationTodayDatePart()
|
||||
setAutomationTaskDraft((prev) => prev ? {
|
||||
...prev,
|
||||
firstTriggerAtValue: `${datePart}T${timePart}`
|
||||
} : prev)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="automation-form-field">
|
||||
<span>导出时间范围(按触发时间动态计算)</span>
|
||||
<div className="automation-segment-row">
|
||||
@@ -8473,7 +8820,11 @@ function ExportPage() {
|
||||
</label>
|
||||
|
||||
<div className="automation-draft-summary">
|
||||
会话:{automationTaskDraft.sessionIds.length} 个 · 间隔:{automationTaskDraft.intervalDays} 天 {automationTaskDraft.intervalHours} 小时 · 时间:{formatAutomationRangeLabel(automationTaskDraft.dateRangeConfig as any, automationRangeSelection)} · 条件:有新消息才导出
|
||||
会话:{automationTaskDraft.sessionIds.length} 个 · 间隔:{automationTaskDraft.intervalDays} 天 {automationTaskDraft.intervalHours} 小时 · 首次:{
|
||||
automationTaskDraft.firstTriggerAtEnabled
|
||||
? (automationTaskDraft.firstTriggerAtValue ? automationTaskDraft.firstTriggerAtValue.replace('T', ' ') : '未设置')
|
||||
: '默认按创建时间+间隔'
|
||||
} · 时间:{formatAutomationRangeLabel(automationTaskDraft.dateRangeConfig as any, automationRangeSelection)} · 条件:有新消息才导出
|
||||
</div>
|
||||
</div>
|
||||
<div className="automation-editor-actions">
|
||||
@@ -8946,7 +9297,12 @@ function ExportPage() {
|
||||
type="button"
|
||||
className="session-load-detail-task-stop-btn"
|
||||
onClick={() => handleCancelBackgroundTask(task.id)}
|
||||
disabled={!task.cancelable || (task.status !== 'running' && task.status !== 'cancel_requested')}
|
||||
disabled={!task.cancelable || (
|
||||
task.status !== 'running' &&
|
||||
task.status !== 'pause_requested' &&
|
||||
task.status !== 'paused' &&
|
||||
task.status !== 'cancel_requested'
|
||||
)}
|
||||
>
|
||||
停止
|
||||
</button>
|
||||
@@ -9710,30 +10066,6 @@ function ExportPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shouldRenderImageDeepSearchToggle && (
|
||||
<div className={`dialog-collapse-slot ${shouldShowImageDeepSearchToggle ? 'open' : ''}`} aria-hidden={!shouldShowImageDeepSearchToggle}>
|
||||
<div className="dialog-collapse-inner">
|
||||
<div className="dialog-section">
|
||||
<div className="dialog-switch-row">
|
||||
<div className="dialog-switch-copy">
|
||||
<h4>缺图时深度搜索</h4>
|
||||
<div className="format-note">关闭后仅尝试 hardlink 命中,未命中将直接显示占位符,导出速度更快。</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`dialog-switch ${options.imageDeepSearchOnMiss ? 'on' : ''}`}
|
||||
aria-pressed={options.imageDeepSearchOnMiss}
|
||||
aria-label="切换缺图时深度搜索"
|
||||
onClick={() => setOptions(prev => ({ ...prev, imageDeepSearchOnMiss: !prev.imageDeepSearchOnMiss }))}
|
||||
>
|
||||
<span className="dialog-switch-thumb" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSessionScopeDialog && (
|
||||
<div className="dialog-section">
|
||||
<div className="dialog-switch-row">
|
||||
|
||||
+138
-51
@@ -44,6 +44,7 @@ const INITIAL_IMAGE_PRELOAD_END = 48
|
||||
const INITIAL_IMAGE_RESOLVE_END = 12
|
||||
const TASK_PROGRESS_UPDATE_MIN_INTERVAL_MS = 250
|
||||
const TASK_PROGRESS_UPDATE_MAX_STEPS = 100
|
||||
const BATCH_IMAGE_DECRYPT_CONCURRENCY = 8
|
||||
|
||||
const GridList = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(function GridList(props, ref) {
|
||||
const { className = '', ...rest } = props
|
||||
@@ -71,6 +72,20 @@ function getRangeTimestampEnd(date: string): number | undefined {
|
||||
return Number.isFinite(n) ? n : undefined
|
||||
}
|
||||
|
||||
function normalizeMediaToken(value?: string): string {
|
||||
return String(value || '').trim().toLowerCase()
|
||||
}
|
||||
|
||||
function getSafeImageDatName(item: Pick<MediaStreamItem, 'imageDatName' | 'imageMd5'>): string {
|
||||
const datName = normalizeMediaToken(item.imageDatName)
|
||||
if (!datName) return ''
|
||||
return datName
|
||||
}
|
||||
|
||||
function hasImageLocator(item: Pick<MediaStreamItem, 'imageDatName' | 'imageMd5'>): boolean {
|
||||
return Boolean(normalizeMediaToken(item.imageMd5) || getSafeImageDatName(item))
|
||||
}
|
||||
|
||||
function getItemKey(item: MediaStreamItem): string {
|
||||
const sessionId = String(item.sessionId || '').trim().toLowerCase()
|
||||
const localId = Number(item.localId || 0)
|
||||
@@ -84,7 +99,7 @@ function getItemKey(item: MediaStreamItem): string {
|
||||
const mediaId = String(
|
||||
item.mediaType === 'video'
|
||||
? (item.videoMd5 || '')
|
||||
: (item.imageMd5 || item.imageDatName || '')
|
||||
: (item.imageMd5 || getSafeImageDatName(item) || '')
|
||||
).trim().toLowerCase()
|
||||
return `${sessionId}|${createTime}|${localType}|${serverId}|${mediaId}`
|
||||
}
|
||||
@@ -658,19 +673,20 @@ function ResourcesPage() {
|
||||
const to = Math.min(displayItems.length - 1, end)
|
||||
if (to < from) return
|
||||
const now = Date.now()
|
||||
const payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }> = []
|
||||
const payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }> = []
|
||||
const itemKeys: string[] = []
|
||||
for (let i = from; i <= to; i += 1) {
|
||||
const item = displayItems[i]
|
||||
if (!item || item.mediaType !== 'image') continue
|
||||
const itemKey = getItemKey(item)
|
||||
if (previewPathMapRef.current[itemKey] || previewPatchRef.current[itemKey]) continue
|
||||
if (!item.imageMd5 && !item.imageDatName) continue
|
||||
if (!hasImageLocator(item)) continue
|
||||
if ((imageCacheMissUntilRef.current[itemKey] || 0) > now) continue
|
||||
payloads.push({
|
||||
sessionId: item.sessionId,
|
||||
imageMd5: item.imageMd5 || undefined,
|
||||
imageDatName: item.imageDatName || undefined
|
||||
imageMd5: normalizeMediaToken(item.imageMd5) || undefined,
|
||||
imageDatName: getSafeImageDatName(item) || undefined,
|
||||
createTime: Number(item.createTime || 0) || undefined
|
||||
})
|
||||
itemKeys.push(itemKey)
|
||||
if (payloads.length >= MAX_IMAGE_CACHE_RESOLVE_PER_TICK) break
|
||||
@@ -686,7 +702,10 @@ function ResourcesPage() {
|
||||
try {
|
||||
const result = await window.electronAPI.image.resolveCacheBatch(payloads, {
|
||||
disableUpdateCheck: true,
|
||||
allowCacheIndex: false
|
||||
allowCacheIndex: true,
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true,
|
||||
suppressEvents: true
|
||||
})
|
||||
const rows = Array.isArray(result?.rows) ? result.rows : []
|
||||
const pathPatch: Record<string, string> = {}
|
||||
@@ -733,30 +752,31 @@ function ResourcesPage() {
|
||||
if (to < from) return
|
||||
|
||||
const now = Date.now()
|
||||
const payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }> = []
|
||||
const payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }> = []
|
||||
const dedup = new Set<string>()
|
||||
for (let i = from; i <= to; i += 1) {
|
||||
const item = displayItems[i]
|
||||
if (!item || item.mediaType !== 'image') continue
|
||||
const itemKey = getItemKey(item)
|
||||
if (previewPathMapRef.current[itemKey] || previewPatchRef.current[itemKey]) continue
|
||||
if (!item.imageMd5 && !item.imageDatName) continue
|
||||
if (!hasImageLocator(item)) continue
|
||||
if ((imagePreloadUntilRef.current[itemKey] || 0) > now) continue
|
||||
const dedupKey = `${item.sessionId || ''}|${item.imageMd5 || ''}|${item.imageDatName || ''}`
|
||||
const dedupKey = `${item.sessionId || ''}|${normalizeMediaToken(item.imageMd5)}|${getSafeImageDatName(item)}`
|
||||
if (dedup.has(dedupKey)) continue
|
||||
dedup.add(dedupKey)
|
||||
imagePreloadUntilRef.current[itemKey] = now + 12000
|
||||
payloads.push({
|
||||
sessionId: item.sessionId,
|
||||
imageMd5: item.imageMd5 || undefined,
|
||||
imageDatName: item.imageDatName || undefined
|
||||
imageMd5: normalizeMediaToken(item.imageMd5) || undefined,
|
||||
imageDatName: getSafeImageDatName(item) || undefined,
|
||||
createTime: Number(item.createTime || 0) || undefined
|
||||
})
|
||||
if (payloads.length >= MAX_IMAGE_CACHE_PRELOAD_PER_TICK) break
|
||||
}
|
||||
if (payloads.length === 0) return
|
||||
void window.electronAPI.image.preload(payloads, {
|
||||
allowDecrypt: false,
|
||||
allowCacheIndex: false
|
||||
allowCacheIndex: true
|
||||
})
|
||||
}, [displayItems])
|
||||
|
||||
@@ -954,11 +974,14 @@ function ResourcesPage() {
|
||||
}, '批量删除确认')
|
||||
}, [batchBusy, selectedItems, showAlert, showConfirm])
|
||||
|
||||
const decryptImage = useCallback(async (item: MediaStreamItem): Promise<string | undefined> => {
|
||||
const decryptImage = useCallback(async (
|
||||
item: MediaStreamItem,
|
||||
options?: { allowCacheIndex?: boolean }
|
||||
): Promise<string | undefined> => {
|
||||
if (item.mediaType !== 'image') return
|
||||
|
||||
const key = getItemKey(item)
|
||||
if (!item.imageMd5 && !item.imageDatName) {
|
||||
if (!hasImageLocator(item)) {
|
||||
showAlert('当前图片缺少解密所需字段(imageMd5/imageDatName)', '无法解密')
|
||||
return
|
||||
}
|
||||
@@ -972,12 +995,21 @@ function ResourcesPage() {
|
||||
try {
|
||||
const result = await window.electronAPI.image.decrypt({
|
||||
sessionId: item.sessionId,
|
||||
imageMd5: item.imageMd5 || undefined,
|
||||
imageDatName: item.imageDatName || undefined,
|
||||
force: true
|
||||
imageMd5: normalizeMediaToken(item.imageMd5) || undefined,
|
||||
imageDatName: getSafeImageDatName(item) || undefined,
|
||||
createTime: Number(item.createTime || 0) || undefined,
|
||||
force: true,
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true,
|
||||
allowCacheIndex: options?.allowCacheIndex ?? true,
|
||||
suppressEvents: true
|
||||
})
|
||||
if (!result?.success) {
|
||||
showAlert(`解密失败:${result?.error || '未知错误'}`, '解密失败')
|
||||
if (result?.failureKind === 'decrypt_failed') {
|
||||
showAlert(`解密失败:${result?.error || '解密后不是有效图片'}`, '解密失败')
|
||||
} else {
|
||||
showAlert(`本地无数据:${result?.error || '未找到原始 DAT 文件'}`, '未找到本地数据')
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -991,8 +1023,13 @@ function ResourcesPage() {
|
||||
try {
|
||||
const resolved = await window.electronAPI.image.resolveCache({
|
||||
sessionId: item.sessionId,
|
||||
imageMd5: item.imageMd5 || undefined,
|
||||
imageDatName: item.imageDatName || undefined
|
||||
imageMd5: normalizeMediaToken(item.imageMd5) || undefined,
|
||||
imageDatName: getSafeImageDatName(item) || undefined,
|
||||
createTime: Number(item.createTime || 0) || undefined,
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true,
|
||||
allowCacheIndex: true,
|
||||
suppressEvents: true
|
||||
})
|
||||
if (resolved?.success && resolved.localPath) {
|
||||
const localPath = resolved.localPath
|
||||
@@ -1007,7 +1044,7 @@ function ResourcesPage() {
|
||||
setActionMessage('图片解密完成')
|
||||
return undefined
|
||||
} catch (e) {
|
||||
showAlert(`解密失败:${String(e)}`, '解密失败')
|
||||
showAlert(`本地无数据:${String(e)}`, '未找到本地数据')
|
||||
return undefined
|
||||
} finally {
|
||||
setDecryptingKeys((prev) => {
|
||||
@@ -1027,8 +1064,13 @@ function ResourcesPage() {
|
||||
try {
|
||||
const resolved = await window.electronAPI.image.resolveCache({
|
||||
sessionId: item.sessionId,
|
||||
imageMd5: item.imageMd5 || undefined,
|
||||
imageDatName: item.imageDatName || undefined
|
||||
imageMd5: normalizeMediaToken(item.imageMd5) || undefined,
|
||||
imageDatName: getSafeImageDatName(item) || undefined,
|
||||
createTime: Number(item.createTime || 0) || undefined,
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true,
|
||||
allowCacheIndex: true,
|
||||
suppressEvents: true
|
||||
})
|
||||
if (resolved?.success && resolved.localPath) {
|
||||
localPath = resolved.localPath
|
||||
@@ -1046,8 +1088,13 @@ function ResourcesPage() {
|
||||
try {
|
||||
const resolved = await window.electronAPI.image.resolveCache({
|
||||
sessionId: item.sessionId,
|
||||
imageMd5: item.imageMd5 || undefined,
|
||||
imageDatName: item.imageDatName || undefined
|
||||
imageMd5: normalizeMediaToken(item.imageMd5) || undefined,
|
||||
imageDatName: getSafeImageDatName(item) || undefined,
|
||||
createTime: Number(item.createTime || 0) || undefined,
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true,
|
||||
allowCacheIndex: true,
|
||||
suppressEvents: true
|
||||
})
|
||||
if (resolved?.success && resolved.localPath) {
|
||||
localPath = resolved.localPath
|
||||
@@ -1077,7 +1124,8 @@ function ResourcesPage() {
|
||||
|
||||
setBatchBusy(true)
|
||||
let success = 0
|
||||
let failed = 0
|
||||
let notFound = 0
|
||||
let decryptFailed = 0
|
||||
const previewPatch: Record<string, string> = {}
|
||||
const updatePatch: Record<string, boolean> = {}
|
||||
const taskId = registerBackgroundTask({
|
||||
@@ -1105,32 +1153,71 @@ function ResourcesPage() {
|
||||
lastProgressBucket = bucket
|
||||
lastProgressUpdateAt = now
|
||||
}
|
||||
const hardlinkMd5Set = new Set<string>()
|
||||
for (const item of imageItems) {
|
||||
if (!item.imageMd5 && !item.imageDatName) {
|
||||
failed += 1
|
||||
completed += 1
|
||||
updateTaskProgress()
|
||||
if (!hasImageLocator(item)) continue
|
||||
const imageMd5 = normalizeMediaToken(item.imageMd5)
|
||||
if (imageMd5) {
|
||||
hardlinkMd5Set.add(imageMd5)
|
||||
continue
|
||||
}
|
||||
const result = await window.electronAPI.image.decrypt({
|
||||
sessionId: item.sessionId,
|
||||
imageMd5: item.imageMd5 || undefined,
|
||||
imageDatName: item.imageDatName || undefined,
|
||||
force: true
|
||||
})
|
||||
if (!result?.success) {
|
||||
failed += 1
|
||||
} else {
|
||||
success += 1
|
||||
if (result.localPath) {
|
||||
const key = getItemKey(item)
|
||||
previewPatch[key] = result.localPath
|
||||
updatePatch[key] = isLikelyThumbnailPreview(result.localPath)
|
||||
const imageDatName = getSafeImageDatName(item)
|
||||
if (/^[a-f0-9]{32}$/i.test(imageDatName)) {
|
||||
hardlinkMd5Set.add(imageDatName)
|
||||
}
|
||||
}
|
||||
if (hardlinkMd5Set.size > 0) {
|
||||
try {
|
||||
await window.electronAPI.image.preloadHardlinkMd5s(Array.from(hardlinkMd5Set))
|
||||
} catch {
|
||||
// ignore preload failures and continue decrypt
|
||||
}
|
||||
}
|
||||
|
||||
const concurrency = Math.max(1, Math.min(BATCH_IMAGE_DECRYPT_CONCURRENCY, imageItems.length))
|
||||
let cursor = 0
|
||||
const worker = async () => {
|
||||
while (true) {
|
||||
const index = cursor
|
||||
cursor += 1
|
||||
if (index >= imageItems.length) return
|
||||
const item = imageItems[index]
|
||||
try {
|
||||
if (!hasImageLocator(item)) {
|
||||
notFound += 1
|
||||
continue
|
||||
}
|
||||
const result = await window.electronAPI.image.decrypt({
|
||||
sessionId: item.sessionId,
|
||||
imageMd5: normalizeMediaToken(item.imageMd5) || undefined,
|
||||
imageDatName: getSafeImageDatName(item) || undefined,
|
||||
createTime: Number(item.createTime || 0) || undefined,
|
||||
force: true,
|
||||
preferFilePath: true,
|
||||
hardlinkOnly: true,
|
||||
allowCacheIndex: true,
|
||||
suppressEvents: true
|
||||
})
|
||||
if (!result?.success) {
|
||||
if (result?.failureKind === 'decrypt_failed') decryptFailed += 1
|
||||
else notFound += 1
|
||||
} else {
|
||||
success += 1
|
||||
if (result.localPath) {
|
||||
const key = getItemKey(item)
|
||||
previewPatch[key] = result.localPath
|
||||
updatePatch[key] = isLikelyThumbnailPreview(result.localPath)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
notFound += 1
|
||||
} finally {
|
||||
completed += 1
|
||||
updateTaskProgress()
|
||||
}
|
||||
}
|
||||
completed += 1
|
||||
updateTaskProgress()
|
||||
}
|
||||
await Promise.all(Array.from({ length: concurrency }, () => worker()))
|
||||
updateTaskProgress(true)
|
||||
|
||||
if (Object.keys(previewPatch).length > 0) {
|
||||
@@ -1139,11 +1226,11 @@ function ResourcesPage() {
|
||||
if (Object.keys(updatePatch).length > 0) {
|
||||
setPreviewUpdateMap((prev) => ({ ...prev, ...updatePatch }))
|
||||
}
|
||||
setActionMessage(`批量解密完成:成功 ${success},失败 ${failed}`)
|
||||
showAlert(`批量解密完成:成功 ${success},失败 ${failed}`, '批量解密完成')
|
||||
finishBackgroundTask(taskId, success > 0 || failed === 0 ? 'completed' : 'failed', {
|
||||
detail: `资源页图片批量解密完成:成功 ${success},失败 ${failed}`,
|
||||
progressText: `成功 ${success} / 失败 ${failed}`
|
||||
setActionMessage(`批量解密完成:成功 ${success},未找到 ${notFound},解密失败 ${decryptFailed}`)
|
||||
showAlert(`批量解密完成:成功 ${success},未找到 ${notFound},解密失败 ${decryptFailed}`, '批量解密完成')
|
||||
finishBackgroundTask(taskId, decryptFailed > 0 ? 'failed' : 'completed', {
|
||||
detail: `资源页图片批量解密完成:成功 ${success},未找到 ${notFound},解密失败 ${decryptFailed}`,
|
||||
progressText: `成功 ${success} / 未找到 ${notFound} / 解密失败 ${decryptFailed}`
|
||||
})
|
||||
} catch (e) {
|
||||
finishBackgroundTask(taskId, 'failed', {
|
||||
|
||||
+16
-33
@@ -56,43 +56,28 @@ const normalizeDbKeyStatusMessage = (message: string): string => {
|
||||
return message
|
||||
}
|
||||
|
||||
const isDbKeyReadyMessage = (message: string): boolean => (
|
||||
message.includes('现在可以登录')
|
||||
|| message.includes('Hook安装成功')
|
||||
|| message.includes('已准备就绪,现在登录微信或退出登录后重新登录微信')
|
||||
)
|
||||
const isDbKeyReadyMessage = (message: string): boolean => {
|
||||
if (isWindows) {
|
||||
return message.includes('现在可以登录')
|
||||
|| message.includes('Hook安装成功')
|
||||
|| message.includes('已准备就绪,现在登录微信或退出登录后重新登录微信')
|
||||
}
|
||||
return message.includes('现在可以登录')
|
||||
}
|
||||
|
||||
const pickWxidByAnchorTime = (
|
||||
wxids: Array<{ wxid: string; modifiedTime: number }>,
|
||||
anchorTime?: number
|
||||
const pickLatestWxid = (
|
||||
wxids: Array<{ wxid: string; modifiedTime: number }>
|
||||
): string => {
|
||||
if (!Array.isArray(wxids) || wxids.length === 0) return ''
|
||||
const fallbackWxid = wxids[0]?.wxid || ''
|
||||
if (!anchorTime || !Number.isFinite(anchorTime)) return fallbackWxid
|
||||
|
||||
const valid = wxids.filter(item => Number.isFinite(item.modifiedTime) && item.modifiedTime > 0)
|
||||
if (valid.length === 0) return fallbackWxid
|
||||
|
||||
const anchor = Number(anchorTime)
|
||||
const nearWindowMs = 10 * 60 * 1000
|
||||
|
||||
const near = valid
|
||||
.filter(item => Math.abs(item.modifiedTime - anchor) <= nearWindowMs)
|
||||
.sort((a, b) => {
|
||||
const diffGap = Math.abs(a.modifiedTime - anchor) - Math.abs(b.modifiedTime - anchor)
|
||||
if (diffGap !== 0) return diffGap
|
||||
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
|
||||
return a.wxid.localeCompare(b.wxid)
|
||||
})
|
||||
if (near.length > 0) return near[0].wxid
|
||||
|
||||
const closest = valid.sort((a, b) => {
|
||||
const diffGap = Math.abs(a.modifiedTime - anchor) - Math.abs(b.modifiedTime - anchor)
|
||||
if (diffGap !== 0) return diffGap
|
||||
const latest = [...valid].sort((a, b) => {
|
||||
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
|
||||
return a.wxid.localeCompare(b.wxid)
|
||||
})
|
||||
return closest[0]?.wxid || fallbackWxid
|
||||
return latest[0]?.wxid || fallbackWxid
|
||||
}
|
||||
|
||||
function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
@@ -434,7 +419,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleScanWxid = async (silent = false, anchorTime?: number) => {
|
||||
const handleScanWxid = async (silent = false) => {
|
||||
if (!dbPath) {
|
||||
if (!silent) setError('请先选择数据库目录')
|
||||
return
|
||||
@@ -446,9 +431,8 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
const wxids = await window.electronAPI.dbPath.scanWxids(dbPath)
|
||||
setWxidOptions(wxids)
|
||||
if (wxids.length > 0) {
|
||||
// 密钥成功后使用成功时刻作为锚点,自动选择最接近该时刻的活跃账号;
|
||||
// 其余场景保持“时间最新”优先。
|
||||
const selectedWxid = pickWxidByAnchorTime(wxids, anchorTime)
|
||||
// 自动获取密钥后,始终优先选择最近活跃(modifiedTime 最新)的账号。
|
||||
const selectedWxid = pickLatestWxid(wxids)
|
||||
setWxid(selectedWxid || wxids[0].wxid)
|
||||
if (!silent) setError('')
|
||||
} else {
|
||||
@@ -501,8 +485,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
setHasReacquiredDbKey(true)
|
||||
setDbKeyStatus('密钥获取成功')
|
||||
setError('')
|
||||
const keySuccessAt = Date.now()
|
||||
await handleScanWxid(true, keySuccessAt)
|
||||
await handleScanWxid(true)
|
||||
} else {
|
||||
if (isAddAccountMode) {
|
||||
setHasReacquiredDbKey(false)
|
||||
|
||||
@@ -9,10 +9,12 @@ type BackgroundTaskListener = (tasks: BackgroundTaskRecord[]) => void
|
||||
|
||||
const tasks = new Map<string, BackgroundTaskRecord>()
|
||||
const cancelHandlers = new Map<string, () => void | Promise<void>>()
|
||||
const pauseHandlers = new Map<string, () => void | Promise<void>>()
|
||||
const resumeHandlers = new Map<string, () => void | Promise<void>>()
|
||||
const listeners = new Set<BackgroundTaskListener>()
|
||||
let taskSequence = 0
|
||||
|
||||
const ACTIVE_STATUSES = new Set<BackgroundTaskStatus>(['running', 'cancel_requested'])
|
||||
const ACTIVE_STATUSES = new Set<BackgroundTaskStatus>(['running', 'pause_requested', 'paused', 'cancel_requested'])
|
||||
const MAX_SETTLED_TASKS = 24
|
||||
|
||||
const buildTaskId = (): string => {
|
||||
@@ -34,6 +36,9 @@ const pruneSettledTasks = () => {
|
||||
|
||||
for (const staleTask of settledTasks.slice(MAX_SETTLED_TASKS)) {
|
||||
tasks.delete(staleTask.id)
|
||||
cancelHandlers.delete(staleTask.id)
|
||||
pauseHandlers.delete(staleTask.id)
|
||||
resumeHandlers.delete(staleTask.id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +69,9 @@ export const registerBackgroundTask = (input: BackgroundTaskInput): string => {
|
||||
detail: input.detail,
|
||||
progressText: input.progressText,
|
||||
cancelable: input.cancelable !== false,
|
||||
resumable: input.resumable === true,
|
||||
cancelRequested: false,
|
||||
pauseRequested: false,
|
||||
status: 'running',
|
||||
startedAt: now,
|
||||
updatedAt: now
|
||||
@@ -72,6 +79,12 @@ export const registerBackgroundTask = (input: BackgroundTaskInput): string => {
|
||||
if (input.onCancel) {
|
||||
cancelHandlers.set(taskId, input.onCancel)
|
||||
}
|
||||
if (input.onPause) {
|
||||
pauseHandlers.set(taskId, input.onPause)
|
||||
}
|
||||
if (input.onResume) {
|
||||
resumeHandlers.set(taskId, input.onResume)
|
||||
}
|
||||
pruneSettledTasks()
|
||||
notifyListeners()
|
||||
return taskId
|
||||
@@ -87,6 +100,9 @@ export const updateBackgroundTask = (taskId: string, patch: BackgroundTaskUpdate
|
||||
...patch,
|
||||
status: nextStatus,
|
||||
updatedAt: nextUpdatedAt,
|
||||
pauseRequested: nextStatus === 'paused' || nextStatus === 'pause_requested'
|
||||
? true
|
||||
: (nextStatus === 'running' ? false : existing.pauseRequested),
|
||||
finishedAt: ACTIVE_STATUSES.has(nextStatus) ? undefined : (existing.finishedAt || nextUpdatedAt)
|
||||
})
|
||||
pruneSettledTasks()
|
||||
@@ -107,9 +123,12 @@ export const finishBackgroundTask = (
|
||||
status,
|
||||
updatedAt: now,
|
||||
finishedAt: now,
|
||||
cancelRequested: status === 'canceled' ? true : existing.cancelRequested
|
||||
cancelRequested: status === 'canceled' ? true : existing.cancelRequested,
|
||||
pauseRequested: false
|
||||
})
|
||||
cancelHandlers.delete(taskId)
|
||||
pauseHandlers.delete(taskId)
|
||||
resumeHandlers.delete(taskId)
|
||||
pruneSettledTasks()
|
||||
notifyListeners()
|
||||
}
|
||||
@@ -121,6 +140,7 @@ export const requestCancelBackgroundTask = (taskId: string): boolean => {
|
||||
...existing,
|
||||
status: 'cancel_requested',
|
||||
cancelRequested: true,
|
||||
pauseRequested: false,
|
||||
detail: existing.detail || '停止请求已发出,当前查询完成后会结束后续加载',
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
@@ -132,6 +152,46 @@ export const requestCancelBackgroundTask = (taskId: string): boolean => {
|
||||
return true
|
||||
}
|
||||
|
||||
export const requestPauseBackgroundTask = (taskId: string): boolean => {
|
||||
const existing = tasks.get(taskId)
|
||||
if (!existing || !existing.resumable) return false
|
||||
if (existing.status !== 'running' && existing.status !== 'pause_requested') return false
|
||||
tasks.set(taskId, {
|
||||
...existing,
|
||||
status: 'pause_requested',
|
||||
pauseRequested: true,
|
||||
detail: existing.detail || '中断请求已发出,当前处理完成后会暂停',
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
const pauseHandler = pauseHandlers.get(taskId)
|
||||
if (pauseHandler) {
|
||||
void Promise.resolve(pauseHandler()).catch(() => {})
|
||||
}
|
||||
notifyListeners()
|
||||
return true
|
||||
}
|
||||
|
||||
export const requestResumeBackgroundTask = (taskId: string): boolean => {
|
||||
const existing = tasks.get(taskId)
|
||||
if (!existing || !existing.resumable) return false
|
||||
if (existing.status !== 'paused' && existing.status !== 'pause_requested') return false
|
||||
tasks.set(taskId, {
|
||||
...existing,
|
||||
status: 'running',
|
||||
cancelRequested: false,
|
||||
pauseRequested: false,
|
||||
detail: existing.detail || '任务已继续',
|
||||
updatedAt: Date.now(),
|
||||
finishedAt: undefined
|
||||
})
|
||||
const resumeHandler = resumeHandlers.get(taskId)
|
||||
if (resumeHandler) {
|
||||
void Promise.resolve(resumeHandler()).catch(() => {})
|
||||
}
|
||||
notifyListeners()
|
||||
return true
|
||||
}
|
||||
|
||||
export const requestCancelBackgroundTasks = (predicate: (task: BackgroundTaskRecord) => boolean): number => {
|
||||
let canceledCount = 0
|
||||
for (const task of tasks.values()) {
|
||||
@@ -147,3 +207,8 @@ export const isBackgroundTaskCancelRequested = (taskId: string): boolean => {
|
||||
const task = tasks.get(taskId)
|
||||
return Boolean(task?.cancelRequested)
|
||||
}
|
||||
|
||||
export const isBackgroundTaskPauseRequested = (taskId: string): boolean => {
|
||||
const task = tasks.get(taskId)
|
||||
return Boolean(task?.pauseRequested)
|
||||
}
|
||||
+7
-14
@@ -37,7 +37,6 @@ export const CONFIG_KEYS = {
|
||||
EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns',
|
||||
EXPORT_DEFAULT_TXT_COLUMNS: 'exportDefaultTxtColumns',
|
||||
EXPORT_DEFAULT_CONCURRENCY: 'exportDefaultConcurrency',
|
||||
EXPORT_DEFAULT_IMAGE_DEEP_SEARCH_ON_MISS: 'exportDefaultImageDeepSearchOnMiss',
|
||||
EXPORT_WRITE_LAYOUT: 'exportWriteLayout',
|
||||
EXPORT_SESSION_NAME_PREFIX_ENABLED: 'exportSessionNamePrefixEnabled',
|
||||
EXPORT_LAST_SESSION_RUN_MAP: 'exportLastSessionRunMap',
|
||||
@@ -559,18 +558,6 @@ export async function setExportDefaultConcurrency(concurrency: number): Promise<
|
||||
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_CONCURRENCY, concurrency)
|
||||
}
|
||||
|
||||
// 获取缺图时是否深度搜索(默认导出行为)
|
||||
export async function getExportDefaultImageDeepSearchOnMiss(): Promise<boolean | null> {
|
||||
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_IMAGE_DEEP_SEARCH_ON_MISS)
|
||||
if (typeof value === 'boolean') return value
|
||||
return null
|
||||
}
|
||||
|
||||
// 设置缺图时是否深度搜索(默认导出行为)
|
||||
export async function setExportDefaultImageDeepSearchOnMiss(enabled: boolean): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_IMAGE_DEEP_SEARCH_ON_MISS, enabled)
|
||||
}
|
||||
|
||||
export type ExportWriteLayout = 'A' | 'B' | 'C'
|
||||
|
||||
export async function getExportWriteLayout(): Promise<ExportWriteLayout> {
|
||||
@@ -713,11 +700,17 @@ const normalizeAutomationTask = (raw: unknown): ExportAutomationTask | null => {
|
||||
if (scheduleType === 'interval') {
|
||||
const rawDays = Math.max(0, normalizeAutomationNumeric(scheduleObj.intervalDays, 0))
|
||||
const rawHours = Math.max(0, normalizeAutomationNumeric(scheduleObj.intervalHours, 0))
|
||||
const rawFirstTriggerAt = Math.max(0, normalizeAutomationNumeric(scheduleObj.firstTriggerAt, 0))
|
||||
const totalHours = (rawDays * 24) + rawHours
|
||||
if (totalHours <= 0) return null
|
||||
const intervalDays = Math.floor(totalHours / 24)
|
||||
const intervalHours = totalHours % 24
|
||||
schedule = { type: 'interval', intervalDays, intervalHours }
|
||||
schedule = {
|
||||
type: 'interval',
|
||||
intervalDays,
|
||||
intervalHours,
|
||||
firstTriggerAt: rawFirstTriggerAt > 0 ? rawFirstTriggerAt : undefined
|
||||
}
|
||||
}
|
||||
if (!schedule) return null
|
||||
|
||||
|
||||
@@ -4,7 +4,21 @@ import {
|
||||
registerBackgroundTask,
|
||||
updateBackgroundTask
|
||||
} from '../services/backgroundTaskMonitor'
|
||||
import type { BackgroundTaskSourcePage } from '../types/backgroundTask'
|
||||
import type { BackgroundTaskSourcePage, BackgroundTaskStatus } from '../types/backgroundTask'
|
||||
|
||||
interface BatchDecryptTaskControls {
|
||||
cancelable?: boolean
|
||||
resumable?: boolean
|
||||
onCancel?: () => void | Promise<void>
|
||||
onPause?: () => void | Promise<void>
|
||||
onResume?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
interface BatchDecryptFinishOptions {
|
||||
status?: Extract<BackgroundTaskStatus, 'completed' | 'failed' | 'canceled'>
|
||||
detail?: string
|
||||
progressText?: string
|
||||
}
|
||||
|
||||
export interface BatchImageDecryptState {
|
||||
isBatchDecrypting: boolean
|
||||
@@ -16,9 +30,15 @@ export interface BatchImageDecryptState {
|
||||
sessionName: string
|
||||
taskId: string | null
|
||||
|
||||
startDecrypt: (total: number, sessionName: string, sourcePage?: BackgroundTaskSourcePage) => void
|
||||
startDecrypt: (
|
||||
total: number,
|
||||
sessionName: string,
|
||||
sourcePage?: BackgroundTaskSourcePage,
|
||||
controls?: BatchDecryptTaskControls
|
||||
) => void
|
||||
updateProgress: (current: number, total: number) => void
|
||||
finishDecrypt: (success: number, fail: number) => void
|
||||
setTaskStatus: (detail: string, progressText?: string, status?: BackgroundTaskStatus) => void
|
||||
finishDecrypt: (success: number, fail: number, options?: BatchDecryptFinishOptions) => void
|
||||
setShowToast: (show: boolean) => void
|
||||
setShowResultToast: (show: boolean) => void
|
||||
reset: () => void
|
||||
@@ -53,7 +73,7 @@ export const useBatchImageDecryptStore = create<BatchImageDecryptState>((set, ge
|
||||
sessionName: '',
|
||||
taskId: null,
|
||||
|
||||
startDecrypt: (total, sessionName, sourcePage = 'chat') => {
|
||||
startDecrypt: (total, sessionName, sourcePage = 'chat', controls) => {
|
||||
const previousTaskId = get().taskId
|
||||
if (previousTaskId) {
|
||||
taskProgressUpdateMeta.delete(previousTaskId)
|
||||
@@ -73,7 +93,11 @@ export const useBatchImageDecryptStore = create<BatchImageDecryptState>((set, ge
|
||||
title,
|
||||
detail: `正在解密图片(${normalizedProgress.current}/${normalizedProgress.total})`,
|
||||
progressText: `${normalizedProgress.current} / ${normalizedProgress.total}`,
|
||||
cancelable: false
|
||||
cancelable: controls?.cancelable !== false,
|
||||
resumable: controls?.resumable === true,
|
||||
onCancel: controls?.onCancel,
|
||||
onPause: controls?.onPause,
|
||||
onResume: controls?.onResume
|
||||
})
|
||||
taskProgressUpdateMeta.set(taskId, {
|
||||
lastAt: Date.now(),
|
||||
@@ -97,6 +121,7 @@ export const useBatchImageDecryptStore = create<BatchImageDecryptState>((set, ge
|
||||
const previousProgress = get().progress
|
||||
const normalizedProgress = clampProgress(current, total)
|
||||
const taskId = get().taskId
|
||||
let shouldCommitUi = true
|
||||
if (taskId) {
|
||||
const now = Date.now()
|
||||
const meta = taskProgressUpdateMeta.get(taskId)
|
||||
@@ -105,7 +130,9 @@ export const useBatchImageDecryptStore = create<BatchImageDecryptState>((set, ge
|
||||
const intervalReached = !meta || (now - meta.lastAt >= TASK_PROGRESS_UPDATE_MIN_INTERVAL_MS)
|
||||
const crossedBucket = !meta || bucket !== meta.lastBucket
|
||||
const isFinal = normalizedProgress.total > 0 && normalizedProgress.current >= normalizedProgress.total
|
||||
if (crossedBucket || intervalReached || isFinal) {
|
||||
const shouldPublish = crossedBucket || intervalReached || isFinal
|
||||
shouldCommitUi = shouldPublish
|
||||
if (shouldPublish) {
|
||||
updateBackgroundTask(taskId, {
|
||||
detail: `正在解密图片(${normalizedProgress.current}/${normalizedProgress.total})`,
|
||||
progressText: `${normalizedProgress.current} / ${normalizedProgress.total}`
|
||||
@@ -117,26 +144,38 @@ export const useBatchImageDecryptStore = create<BatchImageDecryptState>((set, ge
|
||||
})
|
||||
}
|
||||
}
|
||||
if (
|
||||
if (shouldCommitUi && (
|
||||
previousProgress.current !== normalizedProgress.current ||
|
||||
previousProgress.total !== normalizedProgress.total
|
||||
) {
|
||||
)) {
|
||||
set({
|
||||
progress: normalizedProgress
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
finishDecrypt: (success, fail) => {
|
||||
setTaskStatus: (detail, progressText, status) => {
|
||||
const taskId = get().taskId
|
||||
if (!taskId) return
|
||||
const normalizedDetail = String(detail || '').trim()
|
||||
if (!normalizedDetail) return
|
||||
updateBackgroundTask(taskId, {
|
||||
detail: normalizedDetail,
|
||||
progressText,
|
||||
status
|
||||
})
|
||||
},
|
||||
|
||||
finishDecrypt: (success, fail, options) => {
|
||||
const taskId = get().taskId
|
||||
const normalizedSuccess = Number.isFinite(success) ? Math.max(0, Math.floor(success)) : 0
|
||||
const normalizedFail = Number.isFinite(fail) ? Math.max(0, Math.floor(fail)) : 0
|
||||
if (taskId) {
|
||||
taskProgressUpdateMeta.delete(taskId)
|
||||
const status = normalizedSuccess > 0 || normalizedFail === 0 ? 'completed' : 'failed'
|
||||
const status = options?.status || (normalizedSuccess > 0 || normalizedFail === 0 ? 'completed' : 'failed')
|
||||
finishBackgroundTask(taskId, status, {
|
||||
detail: `图片批量解密完成:成功 ${normalizedSuccess},失败 ${normalizedFail}`,
|
||||
progressText: `成功 ${normalizedSuccess} / 失败 ${normalizedFail}`
|
||||
detail: options?.detail || `图片批量解密完成:成功 ${normalizedSuccess},失败 ${normalizedFail}`,
|
||||
progressText: options?.progressText || `成功 ${normalizedSuccess} / 失败 ${normalizedFail}`
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,27 @@
|
||||
import { create } from 'zustand'
|
||||
import {
|
||||
finishBackgroundTask,
|
||||
registerBackgroundTask,
|
||||
updateBackgroundTask
|
||||
} from '../services/backgroundTaskMonitor'
|
||||
import type { BackgroundTaskSourcePage, BackgroundTaskStatus } from '../types/backgroundTask'
|
||||
|
||||
export type BatchVoiceTaskType = 'transcribe' | 'decrypt'
|
||||
|
||||
interface BatchVoiceTaskControls {
|
||||
cancelable?: boolean
|
||||
resumable?: boolean
|
||||
onCancel?: () => void | Promise<void>
|
||||
onPause?: () => void | Promise<void>
|
||||
onResume?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
interface BatchVoiceTaskFinishOptions {
|
||||
status?: Extract<BackgroundTaskStatus, 'completed' | 'failed' | 'canceled'>
|
||||
detail?: string
|
||||
progressText?: string
|
||||
}
|
||||
|
||||
export interface BatchTranscribeState {
|
||||
/** 是否正在批量转写 */
|
||||
isBatchTranscribing: boolean
|
||||
@@ -18,17 +38,44 @@ export interface BatchTranscribeState {
|
||||
/** 当前转写的会话名 */
|
||||
startTime: number
|
||||
sessionName: string
|
||||
taskId: string | null
|
||||
|
||||
// Actions
|
||||
startTranscribe: (total: number, sessionName: string, taskType?: BatchVoiceTaskType) => void
|
||||
startTranscribe: (
|
||||
total: number,
|
||||
sessionName: string,
|
||||
taskType?: BatchVoiceTaskType,
|
||||
sourcePage?: BackgroundTaskSourcePage,
|
||||
controls?: BatchVoiceTaskControls
|
||||
) => void
|
||||
updateProgress: (current: number, total: number) => void
|
||||
finishTranscribe: (success: number, fail: number) => void
|
||||
setTaskStatus: (detail: string, progressText?: string, status?: BackgroundTaskStatus) => void
|
||||
finishTranscribe: (success: number, fail: number, options?: BatchVoiceTaskFinishOptions) => void
|
||||
setShowToast: (show: boolean) => void
|
||||
setShowResult: (show: boolean) => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
export const useBatchTranscribeStore = create<BatchTranscribeState>((set) => ({
|
||||
const clampProgress = (current: number, total: number): { current: number; total: number } => {
|
||||
const normalizedTotal = Number.isFinite(total) ? Math.max(0, Math.floor(total)) : 0
|
||||
const normalizedCurrentRaw = Number.isFinite(current) ? Math.max(0, Math.floor(current)) : 0
|
||||
const normalizedCurrent = normalizedTotal > 0
|
||||
? Math.min(normalizedCurrentRaw, normalizedTotal)
|
||||
: normalizedCurrentRaw
|
||||
return { current: normalizedCurrent, total: normalizedTotal }
|
||||
}
|
||||
|
||||
const TASK_PROGRESS_UPDATE_MIN_INTERVAL_MS = 250
|
||||
const TASK_PROGRESS_UPDATE_MAX_STEPS = 100
|
||||
|
||||
const taskProgressUpdateMeta = new Map<string, { lastAt: number; lastBucket: number; step: number }>()
|
||||
|
||||
const calcProgressStep = (total: number): number => {
|
||||
if (total <= 0) return 1
|
||||
return Math.max(1, Math.floor(total / TASK_PROGRESS_UPDATE_MAX_STEPS))
|
||||
}
|
||||
|
||||
export const useBatchTranscribeStore = create<BatchTranscribeState>((set, get) => ({
|
||||
isBatchTranscribing: false,
|
||||
taskType: 'transcribe',
|
||||
progress: { current: 0, total: 0 },
|
||||
@@ -37,41 +84,151 @@ export const useBatchTranscribeStore = create<BatchTranscribeState>((set) => ({
|
||||
result: { success: 0, fail: 0 },
|
||||
sessionName: '',
|
||||
startTime: 0,
|
||||
taskId: null,
|
||||
|
||||
startTranscribe: (total, sessionName, taskType = 'transcribe') => set({
|
||||
isBatchTranscribing: true,
|
||||
taskType,
|
||||
showToast: true,
|
||||
progress: { current: 0, total },
|
||||
showResult: false,
|
||||
result: { success: 0, fail: 0 },
|
||||
sessionName,
|
||||
startTime: Date.now()
|
||||
}),
|
||||
startTranscribe: (total, sessionName, taskType = 'transcribe', sourcePage = 'chat', controls) => {
|
||||
const previousTaskId = get().taskId
|
||||
if (previousTaskId) {
|
||||
taskProgressUpdateMeta.delete(previousTaskId)
|
||||
finishBackgroundTask(previousTaskId, 'canceled', {
|
||||
detail: '已被新的语音批量任务替换',
|
||||
progressText: '已替换'
|
||||
})
|
||||
}
|
||||
|
||||
updateProgress: (current, total) => set({
|
||||
progress: { current, total }
|
||||
}),
|
||||
const normalizedProgress = clampProgress(0, total)
|
||||
const normalizedSessionName = String(sessionName || '').trim()
|
||||
const taskLabel = taskType === 'decrypt' ? '语音批量解密' : '语音批量转写'
|
||||
const title = normalizedSessionName
|
||||
? `${taskLabel}(${normalizedSessionName})`
|
||||
: taskLabel
|
||||
const taskId = registerBackgroundTask({
|
||||
sourcePage,
|
||||
title,
|
||||
detail: `正在准备${taskType === 'decrypt' ? '语音解密' : '语音转写'}任务...`,
|
||||
progressText: `${normalizedProgress.current} / ${normalizedProgress.total}`,
|
||||
cancelable: controls?.cancelable !== false,
|
||||
resumable: controls?.resumable === true,
|
||||
onCancel: controls?.onCancel,
|
||||
onPause: controls?.onPause,
|
||||
onResume: controls?.onResume
|
||||
})
|
||||
taskProgressUpdateMeta.set(taskId, {
|
||||
lastAt: Date.now(),
|
||||
lastBucket: 0,
|
||||
step: calcProgressStep(normalizedProgress.total)
|
||||
})
|
||||
|
||||
finishTranscribe: (success, fail) => set({
|
||||
isBatchTranscribing: false,
|
||||
showToast: false,
|
||||
showResult: true,
|
||||
result: { success, fail },
|
||||
startTime: 0
|
||||
}),
|
||||
set({
|
||||
isBatchTranscribing: true,
|
||||
taskType,
|
||||
showToast: false,
|
||||
progress: normalizedProgress,
|
||||
showResult: false,
|
||||
result: { success: 0, fail: 0 },
|
||||
sessionName: normalizedSessionName,
|
||||
startTime: Date.now(),
|
||||
taskId
|
||||
})
|
||||
},
|
||||
|
||||
updateProgress: (current, total) => {
|
||||
const previousProgress = get().progress
|
||||
const normalizedProgress = clampProgress(current, total)
|
||||
const taskId = get().taskId
|
||||
let shouldCommitUi = true
|
||||
if (taskId) {
|
||||
const now = Date.now()
|
||||
const meta = taskProgressUpdateMeta.get(taskId)
|
||||
const step = meta?.step || calcProgressStep(normalizedProgress.total)
|
||||
const bucket = Math.floor(normalizedProgress.current / step)
|
||||
const intervalReached = !meta || (now - meta.lastAt >= TASK_PROGRESS_UPDATE_MIN_INTERVAL_MS)
|
||||
const crossedBucket = !meta || bucket !== meta.lastBucket
|
||||
const isFinal = normalizedProgress.total > 0 && normalizedProgress.current >= normalizedProgress.total
|
||||
const shouldPublish = crossedBucket || intervalReached || isFinal
|
||||
shouldCommitUi = shouldPublish
|
||||
if (shouldPublish) {
|
||||
const taskVerb = get().taskType === 'decrypt' ? '解密语音' : '转写语音'
|
||||
updateBackgroundTask(taskId, {
|
||||
detail: `正在${taskVerb}(${normalizedProgress.current}/${normalizedProgress.total})`,
|
||||
progressText: `${normalizedProgress.current} / ${normalizedProgress.total}`
|
||||
})
|
||||
taskProgressUpdateMeta.set(taskId, {
|
||||
lastAt: now,
|
||||
lastBucket: bucket,
|
||||
step
|
||||
})
|
||||
}
|
||||
}
|
||||
if (shouldCommitUi && (
|
||||
previousProgress.current !== normalizedProgress.current ||
|
||||
previousProgress.total !== normalizedProgress.total
|
||||
)) {
|
||||
set({
|
||||
progress: normalizedProgress
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
setTaskStatus: (detail, progressText, status) => {
|
||||
const taskId = get().taskId
|
||||
if (!taskId) return
|
||||
const normalizedDetail = String(detail || '').trim()
|
||||
if (!normalizedDetail) return
|
||||
updateBackgroundTask(taskId, {
|
||||
detail: normalizedDetail,
|
||||
progressText,
|
||||
status
|
||||
})
|
||||
},
|
||||
|
||||
finishTranscribe: (success, fail, options) => {
|
||||
const taskId = get().taskId
|
||||
const normalizedSuccess = Number.isFinite(success) ? Math.max(0, Math.floor(success)) : 0
|
||||
const normalizedFail = Number.isFinite(fail) ? Math.max(0, Math.floor(fail)) : 0
|
||||
const taskType = get().taskType
|
||||
if (taskId) {
|
||||
taskProgressUpdateMeta.delete(taskId)
|
||||
const status = options?.status || (normalizedSuccess > 0 || normalizedFail === 0 ? 'completed' : 'failed')
|
||||
const taskLabel = taskType === 'decrypt' ? '语音批量解密' : '语音批量转写'
|
||||
finishBackgroundTask(taskId, status, {
|
||||
detail: options?.detail || `${taskLabel}完成:成功 ${normalizedSuccess},失败 ${normalizedFail}`,
|
||||
progressText: options?.progressText || `成功 ${normalizedSuccess} / 失败 ${normalizedFail}`
|
||||
})
|
||||
}
|
||||
|
||||
set({
|
||||
isBatchTranscribing: false,
|
||||
showToast: false,
|
||||
showResult: false,
|
||||
result: { success: normalizedSuccess, fail: normalizedFail },
|
||||
startTime: 0,
|
||||
taskId: null
|
||||
})
|
||||
},
|
||||
|
||||
setShowToast: (show) => set({ showToast: show }),
|
||||
setShowResult: (show) => set({ showResult: show }),
|
||||
|
||||
reset: () => set({
|
||||
isBatchTranscribing: false,
|
||||
taskType: 'transcribe',
|
||||
progress: { current: 0, total: 0 },
|
||||
showToast: false,
|
||||
showResult: false,
|
||||
result: { success: 0, fail: 0 },
|
||||
sessionName: '',
|
||||
startTime: 0
|
||||
})
|
||||
reset: () => {
|
||||
const taskId = get().taskId
|
||||
if (taskId) {
|
||||
taskProgressUpdateMeta.delete(taskId)
|
||||
finishBackgroundTask(taskId, 'canceled', {
|
||||
detail: '语音批量任务已重置',
|
||||
progressText: '已停止'
|
||||
})
|
||||
}
|
||||
set({
|
||||
isBatchTranscribing: false,
|
||||
taskType: 'transcribe',
|
||||
progress: { current: 0, total: 0 },
|
||||
showToast: false,
|
||||
showResult: false,
|
||||
result: { success: 0, fail: 0 },
|
||||
sessionName: '',
|
||||
startTime: 0,
|
||||
taskId: null
|
||||
})
|
||||
}
|
||||
}))
|
||||
@@ -9,6 +9,8 @@ export type BackgroundTaskSourcePage =
|
||||
|
||||
export type BackgroundTaskStatus =
|
||||
| 'running'
|
||||
| 'pause_requested'
|
||||
| 'paused'
|
||||
| 'cancel_requested'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
@@ -21,7 +23,9 @@ export interface BackgroundTaskRecord {
|
||||
detail?: string
|
||||
progressText?: string
|
||||
cancelable: boolean
|
||||
resumable: boolean
|
||||
cancelRequested: boolean
|
||||
pauseRequested: boolean
|
||||
status: BackgroundTaskStatus
|
||||
startedAt: number
|
||||
updatedAt: number
|
||||
@@ -34,7 +38,10 @@ export interface BackgroundTaskInput {
|
||||
detail?: string
|
||||
progressText?: string
|
||||
cancelable?: boolean
|
||||
resumable?: boolean
|
||||
onCancel?: () => void | Promise<void>
|
||||
onPause?: () => void | Promise<void>
|
||||
onResume?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
export interface BackgroundTaskUpdate {
|
||||
|
||||
Vendored
+22
-7
@@ -505,26 +505,42 @@ export interface ElectronAPI {
|
||||
}
|
||||
|
||||
image: {
|
||||
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => Promise<{ success: boolean; localPath?: string; liveVideoPath?: string; error?: string }>
|
||||
decrypt: (payload: {
|
||||
sessionId?: string
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
createTime?: number
|
||||
force?: boolean
|
||||
preferFilePath?: boolean
|
||||
hardlinkOnly?: boolean
|
||||
disableUpdateCheck?: boolean
|
||||
allowCacheIndex?: boolean
|
||||
suppressEvents?: boolean
|
||||
}) => Promise<{ success: boolean; localPath?: string; liveVideoPath?: string; error?: string; failureKind?: 'not_found' | 'decrypt_failed' }>
|
||||
resolveCache: (payload: {
|
||||
sessionId?: string
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
createTime?: number
|
||||
preferFilePath?: boolean
|
||||
hardlinkOnly?: boolean
|
||||
disableUpdateCheck?: boolean
|
||||
allowCacheIndex?: boolean
|
||||
}) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string }>
|
||||
suppressEvents?: boolean
|
||||
}) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string; failureKind?: 'not_found' | 'decrypt_failed' }>
|
||||
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 }
|
||||
) => Promise<{
|
||||
success: boolean
|
||||
rows?: Array<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }>
|
||||
rows?: Array<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string; failureKind?: 'not_found' | 'decrypt_failed' }>
|
||||
error?: string
|
||||
}>
|
||||
preload: (
|
||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
|
||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }>,
|
||||
options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean }
|
||||
) => Promise<boolean>
|
||||
preloadHardlinkMd5s: (md5List: string[]) => Promise<boolean>
|
||||
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => () => void
|
||||
onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => () => void
|
||||
onDecryptProgress: (callback: (payload: {
|
||||
@@ -1135,7 +1151,6 @@ export interface ExportOptions {
|
||||
sessionNameWithTypePrefix?: boolean
|
||||
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
|
||||
exportConcurrency?: number
|
||||
imageDeepSearchOnMiss?: boolean
|
||||
}
|
||||
|
||||
export interface ExportProgress {
|
||||
|
||||
@@ -8,6 +8,7 @@ export type ExportAutomationSchedule =
|
||||
type: 'interval'
|
||||
intervalDays: number
|
||||
intervalHours: number
|
||||
firstTriggerAt?: number
|
||||
}
|
||||
|
||||
export interface ExportAutomationCondition {
|
||||
|
||||
Reference in new issue
Block a user