mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-22 15:09:04 +00:00
Merge branch 'hicccc77:main' into main
This commit is contained in:
212
.github/scripts/release-utils.sh
vendored
Normal file
212
.github/scripts/release-utils.sh
vendored
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
retry_cmd() {
|
||||||
|
local attempts="$1"
|
||||||
|
local delay_seconds="$2"
|
||||||
|
shift 2
|
||||||
|
|
||||||
|
local i
|
||||||
|
local exit_code
|
||||||
|
for ((i = 1; i <= attempts; i++)); do
|
||||||
|
if "$@"; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
exit_code=$?
|
||||||
|
if [ "$i" -lt "$attempts" ]; then
|
||||||
|
echo "Command failed (attempt $i/$attempts, exit=$exit_code): $*" >&2
|
||||||
|
echo "Retrying in ${delay_seconds}s..." >&2
|
||||||
|
sleep "$delay_seconds"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Command failed after $attempts attempts: $*" >&2
|
||||||
|
return "$exit_code"
|
||||||
|
}
|
||||||
|
|
||||||
|
capture_cmd_with_retry() {
|
||||||
|
local result_var="$1"
|
||||||
|
local attempts="$2"
|
||||||
|
local delay_seconds="$3"
|
||||||
|
shift 3
|
||||||
|
|
||||||
|
local i
|
||||||
|
local output=""
|
||||||
|
local exit_code=1
|
||||||
|
for ((i = 1; i <= attempts; i++)); do
|
||||||
|
if output="$("$@" 2>/dev/null)"; then
|
||||||
|
printf -v "$result_var" "%s" "$output"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
exit_code=$?
|
||||||
|
if [ "$i" -lt "$attempts" ]; then
|
||||||
|
echo "Capture command failed (attempt $i/$attempts, exit=$exit_code): $*" >&2
|
||||||
|
echo "Retrying in ${delay_seconds}s..." >&2
|
||||||
|
sleep "$delay_seconds"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Capture command failed after $attempts attempts: $*" >&2
|
||||||
|
return "$exit_code"
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_release_id() {
|
||||||
|
local repo="$1"
|
||||||
|
local tag="$2"
|
||||||
|
local attempts="${3:-12}"
|
||||||
|
local delay_seconds="${4:-2}"
|
||||||
|
|
||||||
|
local i
|
||||||
|
local release_id
|
||||||
|
for ((i = 1; i <= attempts; i++)); do
|
||||||
|
release_id="$(gh api "repos/$repo/releases/tags/$tag" --jq '.id' 2>/dev/null || true)"
|
||||||
|
if [[ "$release_id" =~ ^[0-9]+$ ]]; then
|
||||||
|
echo "$release_id"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [ "$i" -lt "$attempts" ]; then
|
||||||
|
echo "Release id for tag '$tag' is not ready yet (attempt $i/$attempts), retrying in ${delay_seconds}s..." >&2
|
||||||
|
sleep "$delay_seconds"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Unable to fetch release id for tag '$tag' after $attempts attempts." >&2
|
||||||
|
gh api "repos/$repo/releases/tags/$tag" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}' 2>/dev/null || true
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
settle_release_state() {
|
||||||
|
local repo="$1"
|
||||||
|
local release_id="$2"
|
||||||
|
local tag="$3"
|
||||||
|
local attempts="${4:-12}"
|
||||||
|
local delay_seconds="${5:-2}"
|
||||||
|
local endpoint="repos/$repo/releases/tags/$tag"
|
||||||
|
|
||||||
|
local i
|
||||||
|
local draft_state
|
||||||
|
local prerelease_state
|
||||||
|
for ((i = 1; i <= attempts; i++)); do
|
||||||
|
gh api --method PATCH "repos/$repo/releases/$release_id" -F draft=false -F prerelease=true >/dev/null 2>&1 || true
|
||||||
|
draft_state="$(gh api "$endpoint" --jq '.draft' 2>/dev/null || echo true)"
|
||||||
|
prerelease_state="$(gh api "$endpoint" --jq '.prerelease' 2>/dev/null || echo false)"
|
||||||
|
if [ "$draft_state" = "false" ] && [ "$prerelease_state" = "true" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [ "$i" -lt "$attempts" ]; then
|
||||||
|
echo "Release '$tag' state not settled yet (attempt $i/$attempts), retrying in ${delay_seconds}s..." >&2
|
||||||
|
sleep "$delay_seconds"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Failed to settle release state for tag '$tag'." >&2
|
||||||
|
gh api "$endpoint" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}' 2>/dev/null || true
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_release_absent() {
|
||||||
|
local repo="$1"
|
||||||
|
local tag="$2"
|
||||||
|
local attempts="${3:-12}"
|
||||||
|
local delay_seconds="${4:-2}"
|
||||||
|
|
||||||
|
local i
|
||||||
|
for ((i = 1; i <= attempts; i++)); do
|
||||||
|
if gh release view "$tag" --repo "$repo" >/dev/null 2>&1; then
|
||||||
|
if [ "$i" -lt "$attempts" ]; then
|
||||||
|
echo "Release '$tag' still exists (attempt $i/$attempts), waiting ${delay_seconds}s..." >&2
|
||||||
|
sleep "$delay_seconds"
|
||||||
|
fi
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Release '$tag' still exists after waiting." >&2
|
||||||
|
gh release view "$tag" --repo "$repo" --json url,isDraft,isPrerelease 2>/dev/null || true
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_git_tag_absent() {
|
||||||
|
local repo="$1"
|
||||||
|
local tag="$2"
|
||||||
|
local attempts="${3:-12}"
|
||||||
|
local delay_seconds="${4:-2}"
|
||||||
|
|
||||||
|
local i
|
||||||
|
for ((i = 1; i <= attempts; i++)); do
|
||||||
|
if gh api "repos/$repo/git/ref/tags/$tag" >/dev/null 2>&1; then
|
||||||
|
if [ "$i" -lt "$attempts" ]; then
|
||||||
|
echo "Git tag '$tag' still exists (attempt $i/$attempts), waiting ${delay_seconds}s..." >&2
|
||||||
|
sleep "$delay_seconds"
|
||||||
|
fi
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Git tag '$tag' still exists after waiting." >&2
|
||||||
|
gh api "repos/$repo/git/ref/tags/$tag" --jq '{ref: .ref, object: .object.sha}' 2>/dev/null || true
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
recreate_fixed_prerelease() {
|
||||||
|
local repo="$1"
|
||||||
|
local tag="$2"
|
||||||
|
local target_branch="$3"
|
||||||
|
local release_title="$4"
|
||||||
|
local release_notes="$5"
|
||||||
|
|
||||||
|
if gh release view "$tag" --repo "$repo" >/dev/null 2>&1; then
|
||||||
|
retry_cmd 5 3 gh release delete "$tag" --repo "$repo" --yes --cleanup-tag
|
||||||
|
fi
|
||||||
|
|
||||||
|
wait_for_release_absent "$repo" "$tag" 12 2
|
||||||
|
|
||||||
|
if gh api "repos/$repo/git/ref/tags/$tag" >/dev/null 2>&1; then
|
||||||
|
retry_cmd 5 2 gh api --method DELETE "repos/$repo/git/refs/tags/$tag"
|
||||||
|
fi
|
||||||
|
|
||||||
|
wait_for_git_tag_absent "$repo" "$tag" 12 2
|
||||||
|
|
||||||
|
local created="false"
|
||||||
|
local i
|
||||||
|
for ((i = 1; i <= 6; i++)); do
|
||||||
|
if gh release create "$tag" --repo "$repo" --title "$release_title" --notes "$release_notes" --prerelease --target "$target_branch"; then
|
||||||
|
created="true"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if gh release view "$tag" --repo "$repo" >/dev/null 2>&1; then
|
||||||
|
echo "Release '$tag' appears to exist after create failure; continue to settle state." >&2
|
||||||
|
created="true"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [ "$i" -lt 6 ]; then
|
||||||
|
echo "Create release '$tag' failed (attempt $i/6), retrying in 3s..." >&2
|
||||||
|
sleep 3
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$created" != "true" ]; then
|
||||||
|
echo "Failed to create release '$tag'." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local release_id
|
||||||
|
release_id="$(wait_for_release_id "$repo" "$tag" 12 2)"
|
||||||
|
settle_release_state "$repo" "$release_id" "$tag" 12 2
|
||||||
|
}
|
||||||
|
|
||||||
|
upload_release_assets_with_retry() {
|
||||||
|
local repo="$1"
|
||||||
|
local tag="$2"
|
||||||
|
shift 2
|
||||||
|
|
||||||
|
if [ "$#" -eq 0 ]; then
|
||||||
|
echo "No release assets provided for upload." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
wait_for_release_id "$repo" "$tag" 12 2 >/dev/null
|
||||||
|
retry_cmd 5 3 gh release upload "$tag" "$@" --repo "$repo" --clobber
|
||||||
|
}
|
||||||
65
.github/workflows/dev-daily-fixed.yml
vendored
65
.github/workflows/dev-daily-fixed.yml
vendored
@@ -55,28 +55,8 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
if gh release view "$FIXED_DEV_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
source .github/scripts/release-utils.sh
|
||||||
gh release delete "$FIXED_DEV_TAG" --repo "$GITHUB_REPOSITORY" --yes --cleanup-tag
|
recreate_fixed_prerelease "$GITHUB_REPOSITORY" "$FIXED_DEV_TAG" "$TARGET_BRANCH" "Daily Dev Build" "开发版发布页"
|
||||||
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
|
|
||||||
|
|
||||||
dev-mac-arm64:
|
dev-mac-arm64:
|
||||||
needs: prepare
|
needs: prepare
|
||||||
@@ -93,7 +73,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: npm install
|
run: npm install
|
||||||
|
|
||||||
@@ -135,6 +114,8 @@ jobs:
|
|||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
source .github/scripts/release-utils.sh
|
||||||
assets=()
|
assets=()
|
||||||
while IFS= read -r file; do
|
while IFS= read -r file; do
|
||||||
assets+=("$file")
|
assets+=("$file")
|
||||||
@@ -143,7 +124,7 @@ jobs:
|
|||||||
echo "No release files found in ./release"
|
echo "No release files found in ./release"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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:
|
dev-linux:
|
||||||
needs: prepare
|
needs: prepare
|
||||||
@@ -160,7 +141,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: npm install
|
run: npm install
|
||||||
|
|
||||||
@@ -183,6 +163,8 @@ jobs:
|
|||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
source .github/scripts/release-utils.sh
|
||||||
assets=()
|
assets=()
|
||||||
while IFS= read -r file; do
|
while IFS= read -r file; do
|
||||||
assets+=("$file")
|
assets+=("$file")
|
||||||
@@ -191,7 +173,7 @@ jobs:
|
|||||||
echo "No release files found in ./release"
|
echo "No release files found in ./release"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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:
|
dev-win-x64:
|
||||||
needs: prepare
|
needs: prepare
|
||||||
@@ -208,7 +190,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: npm install
|
run: npm install
|
||||||
|
|
||||||
@@ -231,6 +212,8 @@ jobs:
|
|||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
source .github/scripts/release-utils.sh
|
||||||
assets=()
|
assets=()
|
||||||
while IFS= read -r file; do
|
while IFS= read -r file; do
|
||||||
assets+=("$file")
|
assets+=("$file")
|
||||||
@@ -239,7 +222,7 @@ jobs:
|
|||||||
echo "No release files found in ./release"
|
echo "No release files found in ./release"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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:
|
dev-win-arm64:
|
||||||
needs: prepare
|
needs: prepare
|
||||||
@@ -256,7 +239,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: npm install
|
run: npm install
|
||||||
|
|
||||||
@@ -279,6 +261,8 @@ jobs:
|
|||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
source .github/scripts/release-utils.sh
|
||||||
assets=()
|
assets=()
|
||||||
while IFS= read -r file; do
|
while IFS= read -r file; do
|
||||||
assets+=("$file")
|
assets+=("$file")
|
||||||
@@ -287,7 +271,7 @@ jobs:
|
|||||||
echo "No release files found in ./release"
|
echo "No release files found in ./release"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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:
|
update-dev-release-notes:
|
||||||
needs:
|
needs:
|
||||||
@@ -386,22 +370,7 @@ jobs:
|
|||||||
}
|
}
|
||||||
|
|
||||||
update_release_notes
|
update_release_notes
|
||||||
RELEASE_REST_ID="$(gh api "repos/$REPO/releases/tags/$TAG" --jq '.id')"
|
source .github/scripts/release-utils.sh
|
||||||
RELEASE_ENDPOINT="repos/$REPO/releases/tags/$TAG"
|
RELEASE_REST_ID="$(wait_for_release_id "$REPO" "$TAG" 12 2)"
|
||||||
settled="false"
|
settle_release_state "$REPO" "$RELEASE_REST_ID" "$TAG" 12 2
|
||||||
for i in 1 2 3 4 5; do
|
|
||||||
gh api --method PATCH "repos/$REPO/releases/$RELEASE_REST_ID" -F draft=false -F prerelease=true >/dev/null 2>&1 || true
|
|
||||||
DRAFT_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.draft' 2>/dev/null || echo true)"
|
|
||||||
PRERELEASE_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.prerelease' 2>/dev/null || echo false)"
|
|
||||||
if [ "$DRAFT_STATE" = "false" ] && [ "$PRERELEASE_STATE" = "true" ]; then
|
|
||||||
settled="true"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
if [ "$settled" != "true" ]; then
|
|
||||||
echo "Failed to settle release state after notes update:"
|
|
||||||
gh api "$RELEASE_ENDPOINT" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}'
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
gh api "repos/$REPO/releases/tags/$TAG" --jq '{isDraft: .draft, isPrerelease: .prerelease, url: .html_url}'
|
gh api "repos/$REPO/releases/tags/$TAG" --jq '{isDraft: .draft, isPrerelease: .prerelease, url: .html_url}'
|
||||||
|
|||||||
65
.github/workflows/preview-nightly-main.yml
vendored
65
.github/workflows/preview-nightly-main.yml
vendored
@@ -81,28 +81,8 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
if gh release view "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
source .github/scripts/release-utils.sh
|
||||||
gh release delete "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" --yes --cleanup-tag
|
recreate_fixed_prerelease "$GITHUB_REPOSITORY" "$FIXED_PREVIEW_TAG" "$TARGET_BRANCH" "Preview Nightly Build" "预览版发布页"
|
||||||
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
|
|
||||||
|
|
||||||
preview-mac-arm64:
|
preview-mac-arm64:
|
||||||
needs: prepare
|
needs: prepare
|
||||||
@@ -120,7 +100,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: npm install
|
run: npm install
|
||||||
|
|
||||||
@@ -164,6 +143,8 @@ jobs:
|
|||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
source .github/scripts/release-utils.sh
|
||||||
assets=()
|
assets=()
|
||||||
while IFS= read -r file; do
|
while IFS= read -r file; do
|
||||||
assets+=("$file")
|
assets+=("$file")
|
||||||
@@ -172,7 +153,7 @@ jobs:
|
|||||||
echo "No release files found in ./release"
|
echo "No release files found in ./release"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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:
|
preview-linux:
|
||||||
needs: prepare
|
needs: prepare
|
||||||
@@ -190,7 +171,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: npm install
|
run: npm install
|
||||||
|
|
||||||
@@ -216,6 +196,8 @@ jobs:
|
|||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
source .github/scripts/release-utils.sh
|
||||||
assets=()
|
assets=()
|
||||||
while IFS= read -r file; do
|
while IFS= read -r file; do
|
||||||
assets+=("$file")
|
assets+=("$file")
|
||||||
@@ -224,7 +206,7 @@ jobs:
|
|||||||
echo "No release files found in ./release"
|
echo "No release files found in ./release"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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:
|
preview-win-x64:
|
||||||
needs: prepare
|
needs: prepare
|
||||||
@@ -242,7 +224,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: npm install
|
run: npm install
|
||||||
|
|
||||||
@@ -268,6 +249,8 @@ jobs:
|
|||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
source .github/scripts/release-utils.sh
|
||||||
assets=()
|
assets=()
|
||||||
while IFS= read -r file; do
|
while IFS= read -r file; do
|
||||||
assets+=("$file")
|
assets+=("$file")
|
||||||
@@ -276,7 +259,7 @@ jobs:
|
|||||||
echo "No release files found in ./release"
|
echo "No release files found in ./release"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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:
|
preview-win-arm64:
|
||||||
needs: prepare
|
needs: prepare
|
||||||
@@ -294,7 +277,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: npm install
|
run: npm install
|
||||||
|
|
||||||
@@ -320,6 +302,8 @@ jobs:
|
|||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
source .github/scripts/release-utils.sh
|
||||||
assets=()
|
assets=()
|
||||||
while IFS= read -r file; do
|
while IFS= read -r file; do
|
||||||
assets+=("$file")
|
assets+=("$file")
|
||||||
@@ -328,7 +312,7 @@ jobs:
|
|||||||
echo "No release files found in ./release"
|
echo "No release files found in ./release"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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:
|
update-preview-release-notes:
|
||||||
needs:
|
needs:
|
||||||
@@ -429,22 +413,7 @@ jobs:
|
|||||||
}
|
}
|
||||||
|
|
||||||
update_release_notes
|
update_release_notes
|
||||||
RELEASE_REST_ID="$(gh api "repos/$REPO/releases/tags/$TAG" --jq '.id')"
|
source .github/scripts/release-utils.sh
|
||||||
RELEASE_ENDPOINT="repos/$REPO/releases/tags/$TAG"
|
RELEASE_REST_ID="$(wait_for_release_id "$REPO" "$TAG" 12 2)"
|
||||||
settled="false"
|
settle_release_state "$REPO" "$RELEASE_REST_ID" "$TAG" 12 2
|
||||||
for i in 1 2 3 4 5; do
|
|
||||||
gh api --method PATCH "repos/$REPO/releases/$RELEASE_REST_ID" -F draft=false -F prerelease=true >/dev/null 2>&1 || true
|
|
||||||
DRAFT_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.draft' 2>/dev/null || echo true)"
|
|
||||||
PRERELEASE_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.prerelease' 2>/dev/null || echo false)"
|
|
||||||
if [ "$DRAFT_STATE" = "false" ] && [ "$PRERELEASE_STATE" = "true" ]; then
|
|
||||||
settled="true"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
if [ "$settled" != "true" ]; then
|
|
||||||
echo "Failed to settle release state after notes update:"
|
|
||||||
gh api "$RELEASE_ENDPOINT" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}'
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
gh api "repos/$REPO/releases/tags/$TAG" --jq '{isDraft: .draft, isPrerelease: .prerelease, url: .html_url}'
|
gh api "repos/$REPO/releases/tags/$TAG" --jq '{isDraft: .draft, isPrerelease: .prerelease, url: .html_url}'
|
||||||
|
|||||||
41
.github/workflows/release.yml
vendored
41
.github/workflows/release.yml
vendored
@@ -27,7 +27,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: npm install
|
run: npm install
|
||||||
|
|
||||||
@@ -59,15 +58,21 @@ jobs:
|
|||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
source .github/scripts/release-utils.sh
|
||||||
TAG=${GITHUB_REF_NAME}
|
TAG=${GITHUB_REF_NAME}
|
||||||
REPO=${{ github.repository }}
|
REPO=${{ github.repository }}
|
||||||
MINIMUM_VERSION="4.1.7"
|
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
|
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
|
if ! grep -q 'minimumVersion' "/tmp/$YML_FILE"; then
|
||||||
echo "minimumVersion: $MINIMUM_VERSION" >> "/tmp/$YML_FILE"
|
echo "minimumVersion: $MINIMUM_VERSION" >> "/tmp/$YML_FILE"
|
||||||
fi
|
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
|
done
|
||||||
|
|
||||||
release-linux:
|
release-linux:
|
||||||
@@ -84,7 +89,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: npm install
|
run: npm install
|
||||||
|
|
||||||
@@ -117,13 +121,16 @@ jobs:
|
|||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
source .github/scripts/release-utils.sh
|
||||||
TAG=${GITHUB_REF_NAME}
|
TAG=${GITHUB_REF_NAME}
|
||||||
REPO=${{ github.repository }}
|
REPO=${{ github.repository }}
|
||||||
MINIMUM_VERSION="4.1.7"
|
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
|
if [ -f /tmp/latest-linux.yml ] && ! grep -q 'minimumVersion' /tmp/latest-linux.yml; then
|
||||||
echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest-linux.yml
|
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
|
fi
|
||||||
|
|
||||||
release:
|
release:
|
||||||
@@ -140,7 +147,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: npm install
|
run: npm install
|
||||||
|
|
||||||
@@ -168,13 +174,16 @@ jobs:
|
|||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
source .github/scripts/release-utils.sh
|
||||||
TAG=${GITHUB_REF_NAME}
|
TAG=${GITHUB_REF_NAME}
|
||||||
REPO=${{ github.repository }}
|
REPO=${{ github.repository }}
|
||||||
MINIMUM_VERSION="4.1.7"
|
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
|
if [ -f /tmp/latest.yml ] && ! grep -q 'minimumVersion' /tmp/latest.yml; then
|
||||||
echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest.yml
|
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
|
fi
|
||||||
|
|
||||||
release-windows-arm64:
|
release-windows-arm64:
|
||||||
@@ -191,7 +200,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: npm install
|
run: npm install
|
||||||
|
|
||||||
@@ -219,13 +227,16 @@ jobs:
|
|||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
source .github/scripts/release-utils.sh
|
||||||
TAG=${GITHUB_REF_NAME}
|
TAG=${GITHUB_REF_NAME}
|
||||||
REPO=${{ github.repository }}
|
REPO=${{ github.repository }}
|
||||||
MINIMUM_VERSION="4.1.7"
|
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
|
if [ -f /tmp/latest-arm64.yml ] && ! grep -q 'minimumVersion' /tmp/latest-arm64.yml; then
|
||||||
echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest-arm64.yml
|
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
|
fi
|
||||||
|
|
||||||
update-release-notes:
|
update-release-notes:
|
||||||
@@ -243,12 +254,14 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
source .github/scripts/release-utils.sh
|
||||||
|
|
||||||
TAG="$GITHUB_REF_NAME"
|
TAG="$GITHUB_REF_NAME"
|
||||||
REPO="$GITHUB_REPOSITORY"
|
REPO="$GITHUB_REPOSITORY"
|
||||||
RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG"
|
RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG"
|
||||||
|
wait_for_release_id "$REPO" "$TAG" 12 2 >/dev/null
|
||||||
|
|
||||||
ASSETS_JSON="$(gh release view "$TAG" --repo "$REPO" --json assets)"
|
capture_cmd_with_retry ASSETS_JSON 8 3 gh release view "$TAG" --repo "$REPO" --json assets
|
||||||
|
|
||||||
pick_asset() {
|
pick_asset() {
|
||||||
local pattern="$1"
|
local pattern="$1"
|
||||||
@@ -299,7 +312,7 @@ jobs:
|
|||||||
> 如果某个平台链接暂时未生成,可进入[完整发布页]($RELEASE_PAGE)查看全部资源
|
> 如果某个平台链接暂时未生成,可进入[完整发布页]($RELEASE_PAGE)查看全部资源
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
gh release edit "$TAG" --repo "$REPO" --notes-file release_notes.md
|
retry_cmd 5 3 gh release edit "$TAG" --repo "$REPO" --notes-file release_notes.md
|
||||||
|
|
||||||
deploy-aur:
|
deploy-aur:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -75,4 +75,4 @@ pnpm-lock.yaml
|
|||||||
wechat-research-site
|
wechat-research-site
|
||||||
.codex
|
.codex
|
||||||
weflow-web-offical
|
weflow-web-offical
|
||||||
Insight
|
/Wedecrypt
|
||||||
118
electron/main.ts
118
electron/main.ts
@@ -372,6 +372,7 @@ if (process.platform === 'darwin') {
|
|||||||
let mainWindowReady = false
|
let mainWindowReady = false
|
||||||
let shouldShowMain = true
|
let shouldShowMain = true
|
||||||
let isAppQuitting = false
|
let isAppQuitting = false
|
||||||
|
let shutdownPromise: Promise<void> | null = null
|
||||||
let tray: Tray | null = null
|
let tray: Tray | null = null
|
||||||
let isClosePromptVisible = false
|
let isClosePromptVisible = false
|
||||||
const chatHistoryPayloadStore = new Map<string, { sessionId: string; title?: string; recordList: any[] }>()
|
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)
|
return imageDecryptService.decryptImage(payload)
|
||||||
})
|
})
|
||||||
ipcMain.handle('image:resolveCache', async (_, payload: {
|
ipcMain.handle('image:resolveCache', async (_, payload: {
|
||||||
sessionId?: string
|
sessionId?: string
|
||||||
imageMd5?: string
|
imageMd5?: string
|
||||||
imageDatName?: string
|
imageDatName?: string
|
||||||
|
createTime?: number
|
||||||
|
preferFilePath?: boolean
|
||||||
|
hardlinkOnly?: boolean
|
||||||
disableUpdateCheck?: boolean
|
disableUpdateCheck?: boolean
|
||||||
allowCacheIndex?: boolean
|
allowCacheIndex?: boolean
|
||||||
|
suppressEvents?: boolean
|
||||||
}) => {
|
}) => {
|
||||||
return imageDecryptService.resolveCachedImage(payload)
|
return imageDecryptService.resolveCachedImage(payload)
|
||||||
})
|
})
|
||||||
@@ -2679,17 +2695,84 @@ function registerIpcHandlers() {
|
|||||||
'image:resolveCacheBatch',
|
'image:resolveCacheBatch',
|
||||||
async (
|
async (
|
||||||
_,
|
_,
|
||||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
|
payloads: Array<{
|
||||||
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean }
|
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 list = Array.isArray(payloads) ? payloads : []
|
||||||
const rows = await Promise.all(list.map(async (payload) => {
|
if (list.length === 0) return { success: true, rows: [] }
|
||||||
return imageDecryptService.resolveCachedImage({
|
|
||||||
|
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,
|
...payload,
|
||||||
|
preferFilePath: payload.preferFilePath ?? options?.preferFilePath === true,
|
||||||
|
hardlinkOnly: payload.hardlinkOnly ?? options?.hardlinkOnly === true,
|
||||||
disableUpdateCheck: options?.disableUpdateCheck === true,
|
disableUpdateCheck: options?.disableUpdateCheck === true,
|
||||||
allowCacheIndex: options?.allowCacheIndex !== false
|
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 }
|
return { success: true, rows }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -2697,12 +2780,19 @@ function registerIpcHandlers() {
|
|||||||
'image:preload',
|
'image:preload',
|
||||||
async (
|
async (
|
||||||
_,
|
_,
|
||||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
|
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }>,
|
||||||
options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean }
|
options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean }
|
||||||
) => {
|
) => {
|
||||||
imagePreloadService.enqueue(payloads || [], options)
|
imagePreloadService.enqueue(payloads || [], options)
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
ipcMain.handle(
|
||||||
|
'image:preloadHardlinkMd5s',
|
||||||
|
async (_, md5List?: string[]) => {
|
||||||
|
await imageDecryptService.preloadImageHardlinkMd5s(Array.isArray(md5List) ? md5List : [])
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// Windows Hello
|
// Windows Hello
|
||||||
ipcMain.handle('auth:hello', async (event, message?: string) => {
|
ipcMain.handle('auth:hello', async (event, message?: string) => {
|
||||||
@@ -3780,12 +3870,15 @@ app.whenReady().then(async () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
app.on('before-quit', async () => {
|
const shutdownAppServices = async (): Promise<void> => {
|
||||||
|
if (shutdownPromise) return shutdownPromise
|
||||||
|
shutdownPromise = (async () => {
|
||||||
isAppQuitting = true
|
isAppQuitting = true
|
||||||
// 销毁 tray 图标
|
// 销毁 tray 图标
|
||||||
if (tray) { try { tray.destroy() } catch {} tray = null }
|
if (tray) { try { tray.destroy() } catch {} tray = null }
|
||||||
// 通知窗使用 hide 而非 close,退出时主动销毁,避免残留窗口阻塞进程退出。
|
// 通知窗使用 hide 而非 close,退出时主动销毁,避免残留窗口阻塞进程退出。
|
||||||
destroyNotificationWindow()
|
destroyNotificationWindow()
|
||||||
|
messagePushService.stop()
|
||||||
insightService.stop()
|
insightService.stop()
|
||||||
// 兜底:5秒后强制退出,防止某个异步任务卡住导致进程残留
|
// 兜底:5秒后强制退出,防止某个异步任务卡住导致进程残留
|
||||||
const forceExitTimer = setTimeout(() => {
|
const forceExitTimer = setTimeout(() => {
|
||||||
@@ -3793,10 +3886,19 @@ app.on('before-quit', async () => {
|
|||||||
app.exit(0)
|
app.exit(0)
|
||||||
}, 5000)
|
}, 5000)
|
||||||
forceExitTimer.unref()
|
forceExitTimer.unref()
|
||||||
|
try { await cloudControlService.stop() } catch {}
|
||||||
|
// 停止 chatService(内部会关闭 cursor 与 DB),避免退出阶段仍触发监控回调
|
||||||
|
try { chatService.close() } catch {}
|
||||||
// 停止 HTTP 服务器,释放 TCP 端口占用,避免进程无法退出
|
// 停止 HTTP 服务器,释放 TCP 端口占用,避免进程无法退出
|
||||||
try { await httpService.stop() } catch {}
|
try { await httpService.stop() } catch {}
|
||||||
// 终止 wcdb Worker 线程,避免线程阻止进程退出
|
// 终止 wcdb Worker 线程,避免线程阻止进程退出
|
||||||
try { await wcdbService.shutdown() } catch {}
|
try { await wcdbService.shutdown() } catch {}
|
||||||
|
})()
|
||||||
|
return shutdownPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
app.on('before-quit', () => {
|
||||||
|
void shutdownAppServices()
|
||||||
})
|
})
|
||||||
|
|
||||||
app.on('window-all-closed', () => {
|
app.on('window-all-closed', () => {
|
||||||
|
|||||||
@@ -286,24 +286,41 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
|
|
||||||
// 图片解密
|
// 图片解密
|
||||||
image: {
|
image: {
|
||||||
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) =>
|
decrypt: (payload: {
|
||||||
|
sessionId?: string
|
||||||
|
imageMd5?: string
|
||||||
|
imageDatName?: string
|
||||||
|
createTime?: number
|
||||||
|
force?: boolean
|
||||||
|
preferFilePath?: boolean
|
||||||
|
hardlinkOnly?: boolean
|
||||||
|
disableUpdateCheck?: boolean
|
||||||
|
allowCacheIndex?: boolean
|
||||||
|
suppressEvents?: boolean
|
||||||
|
}) =>
|
||||||
ipcRenderer.invoke('image:decrypt', payload),
|
ipcRenderer.invoke('image:decrypt', payload),
|
||||||
resolveCache: (payload: {
|
resolveCache: (payload: {
|
||||||
sessionId?: string
|
sessionId?: string
|
||||||
imageMd5?: string
|
imageMd5?: string
|
||||||
imageDatName?: string
|
imageDatName?: string
|
||||||
|
createTime?: number
|
||||||
|
preferFilePath?: boolean
|
||||||
|
hardlinkOnly?: boolean
|
||||||
disableUpdateCheck?: boolean
|
disableUpdateCheck?: boolean
|
||||||
allowCacheIndex?: boolean
|
allowCacheIndex?: boolean
|
||||||
|
suppressEvents?: boolean
|
||||||
}) =>
|
}) =>
|
||||||
ipcRenderer.invoke('image:resolveCache', payload),
|
ipcRenderer.invoke('image:resolveCache', payload),
|
||||||
resolveCacheBatch: (
|
resolveCacheBatch: (
|
||||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
|
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; preferFilePath?: boolean; hardlinkOnly?: boolean }>,
|
||||||
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean }
|
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean; preferFilePath?: boolean; hardlinkOnly?: boolean; suppressEvents?: boolean }
|
||||||
) => ipcRenderer.invoke('image:resolveCacheBatch', payloads, options),
|
) => ipcRenderer.invoke('image:resolveCacheBatch', payloads, options),
|
||||||
preload: (
|
preload: (
|
||||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
|
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }>,
|
||||||
options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean }
|
options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean }
|
||||||
) => ipcRenderer.invoke('image:preload', payloads, options),
|
) => ipcRenderer.invoke('image:preload', payloads, options),
|
||||||
|
preloadHardlinkMd5s: (md5List: string[]) =>
|
||||||
|
ipcRenderer.invoke('image:preloadHardlinkMd5s', md5List),
|
||||||
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => {
|
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => {
|
||||||
const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => callback(payload)
|
const listener = (_: unknown, payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => callback(payload)
|
||||||
ipcRenderer.on('image:updateAvailable', listener)
|
ipcRenderer.on('image:updateAvailable', listener)
|
||||||
|
|||||||
@@ -486,7 +486,7 @@ class ChatService {
|
|||||||
return Number.isFinite(parsed) ? parsed : null
|
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
|
const code = this.extractErrorCode(rawMessage) ?? fallbackCode
|
||||||
return `错误码: ${code}`
|
return `错误码: ${code}`
|
||||||
}
|
}
|
||||||
@@ -7105,13 +7105,23 @@ class ChatService {
|
|||||||
return { success: false, error: '未找到消息' }
|
return { success: false, error: '未找到消息' }
|
||||||
}
|
}
|
||||||
const msg = msgResult.message
|
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({
|
const result = await this.imageDecryptService.decryptImage({
|
||||||
sessionId,
|
sessionId,
|
||||||
imageMd5: msg.imageMd5,
|
imageMd5,
|
||||||
imageDatName: msg.imageDatName || String(msg.localId),
|
imageDatName,
|
||||||
force: false
|
createTime: msg.createTime,
|
||||||
|
force: false,
|
||||||
|
preferFilePath: true,
|
||||||
|
hardlinkOnly: true
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!result.success || !result.localPath) {
|
if (!result.success || !result.localPath) {
|
||||||
@@ -8358,7 +8368,6 @@ class ChatService {
|
|||||||
if (normalized.length === 0) return []
|
if (normalized.length === 0) return []
|
||||||
|
|
||||||
// 规避 native options_json 可能存在的固定缓冲上限:按 payload 字节安全分块。
|
// 规避 native options_json 可能存在的固定缓冲上限:按 payload 字节安全分块。
|
||||||
// 这不是降级或裁剪范围,而是完整遍历所有群并做结果合并。
|
|
||||||
const maxBytesRaw = Number(process.env.WEFLOW_MY_FOOTPRINT_GROUP_OPTIONS_MAX_BYTES || 900)
|
const maxBytesRaw = Number(process.env.WEFLOW_MY_FOOTPRINT_GROUP_OPTIONS_MAX_BYTES || 900)
|
||||||
const maxBytes = Number.isFinite(maxBytesRaw) && maxBytesRaw >= 512
|
const maxBytes = Number.isFinite(maxBytesRaw) && maxBytesRaw >= 512
|
||||||
? Math.floor(maxBytesRaw)
|
? Math.floor(maxBytesRaw)
|
||||||
@@ -9325,7 +9334,7 @@ class ChatService {
|
|||||||
latest_ts: this.toSafeInt(item?.latest_ts, 0),
|
latest_ts: this.toSafeInt(item?.latest_ts, 0),
|
||||||
anchor_local_id: this.toSafeInt(item?.anchor_local_id, 0),
|
anchor_local_id: this.toSafeInt(item?.anchor_local_id, 0),
|
||||||
anchor_create_time: this.toSafeInt(item?.anchor_create_time, 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) => ({
|
const private_segments: MyFootprintPrivateSegment[] = privateSegmentsRaw.map((item: any) => ({
|
||||||
session_id: String(item?.session_id || '').trim(),
|
session_id: String(item?.session_id || '').trim(),
|
||||||
@@ -9344,7 +9353,7 @@ class ChatService {
|
|||||||
anchor_create_time: this.toSafeInt(item?.anchor_create_time, 0),
|
anchor_create_time: this.toSafeInt(item?.anchor_create_time, 0),
|
||||||
displayName: String(item?.displayName || '').trim() || undefined,
|
displayName: String(item?.displayName || '').trim() || undefined,
|
||||||
avatarUrl: String(item?.avatarUrl || '').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) => ({
|
const mentions: MyFootprintMentionItem[] = mentionsRaw.map((item: any) => ({
|
||||||
session_id: String(item?.session_id || '').trim(),
|
session_id: String(item?.session_id || '').trim(),
|
||||||
@@ -9353,13 +9362,13 @@ class ChatService {
|
|||||||
sender_username: String(item?.sender_username || '').trim(),
|
sender_username: String(item?.sender_username || '').trim(),
|
||||||
message_content: String(item?.message_content || ''),
|
message_content: String(item?.message_content || ''),
|
||||||
source: String(item?.source || '')
|
source: String(item?.source || '')
|
||||||
})).filter((item) => item.session_id)
|
})).filter((item: MyFootprintMentionItem) => item.session_id)
|
||||||
|
|
||||||
const mention_groups: MyFootprintMentionGroup[] = mentionGroupsRaw.map((item: any) => ({
|
const mention_groups: MyFootprintMentionGroup[] = mentionGroupsRaw.map((item: any) => ({
|
||||||
session_id: String(item?.session_id || '').trim(),
|
session_id: String(item?.session_id || '').trim(),
|
||||||
count: this.toSafeInt(item?.count, 0),
|
count: this.toSafeInt(item?.count, 0),
|
||||||
latest_ts: this.toSafeInt(item?.latest_ts, 0)
|
latest_ts: this.toSafeInt(item?.latest_ts, 0)
|
||||||
})).filter((item) => item.session_id)
|
})).filter((item: MyFootprintMentionGroup) => item.session_id)
|
||||||
|
|
||||||
const diagnostics: MyFootprintDiagnostics = {
|
const diagnostics: MyFootprintDiagnostics = {
|
||||||
truncated: Boolean(diagnosticsRaw.truncated),
|
truncated: Boolean(diagnosticsRaw.truncated),
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ class CloudControlService {
|
|||||||
this.pages.add(pageName)
|
this.pages.add(pageName)
|
||||||
}
|
}
|
||||||
|
|
||||||
stop() {
|
async stop(): Promise<void> {
|
||||||
if (this.timer) {
|
if (this.timer) {
|
||||||
clearTimeout(this.timer)
|
clearTimeout(this.timer)
|
||||||
this.timer = null
|
this.timer = null
|
||||||
@@ -230,7 +230,13 @@ class CloudControlService {
|
|||||||
this.circuitOpenedAt = 0
|
this.circuitOpenedAt = 0
|
||||||
this.nextDelayOverrideMs = null
|
this.nextDelayOverrideMs = null
|
||||||
this.initialized = false
|
this.initialized = false
|
||||||
wcdbService.cloudStop()
|
if (wcdbService.isReady()) {
|
||||||
|
try {
|
||||||
|
await wcdbService.cloudStop()
|
||||||
|
} catch {
|
||||||
|
// 忽略停止失败,避免阻塞主进程退出
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getLogs() {
|
async getLogs() {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { app, safeStorage } from 'electron'
|
import { app, safeStorage } from 'electron'
|
||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
import Store from 'electron-store'
|
import Store from 'electron-store'
|
||||||
|
import { expandHomePath } from '../utils/pathUtils'
|
||||||
|
|
||||||
// 加密前缀标记
|
// 加密前缀标记
|
||||||
const SAFE_PREFIX = 'safe:' // safeStorage 加密(普通模式)
|
const SAFE_PREFIX = 'safe:' // safeStorage 加密(普通模式)
|
||||||
@@ -42,7 +43,6 @@ interface ConfigSchema {
|
|||||||
autoTranscribeVoice: boolean
|
autoTranscribeVoice: boolean
|
||||||
transcribeLanguages: string[]
|
transcribeLanguages: string[]
|
||||||
exportDefaultConcurrency: number
|
exportDefaultConcurrency: number
|
||||||
exportDefaultImageDeepSearchOnMiss: boolean
|
|
||||||
analyticsExcludedUsernames: string[]
|
analyticsExcludedUsernames: string[]
|
||||||
|
|
||||||
// 安全相关
|
// 安全相关
|
||||||
@@ -168,7 +168,6 @@ export class ConfigService {
|
|||||||
autoTranscribeVoice: false,
|
autoTranscribeVoice: false,
|
||||||
transcribeLanguages: ['zh'],
|
transcribeLanguages: ['zh'],
|
||||||
exportDefaultConcurrency: 4,
|
exportDefaultConcurrency: 4,
|
||||||
exportDefaultImageDeepSearchOnMiss: true,
|
|
||||||
analyticsExcludedUsernames: [],
|
analyticsExcludedUsernames: [],
|
||||||
authEnabled: false,
|
authEnabled: false,
|
||||||
authPassword: '',
|
authPassword: '',
|
||||||
@@ -299,6 +298,10 @@ export class ConfigService {
|
|||||||
return this.decryptWxidConfigs(raw as any) as ConfigSchema[K]
|
return this.decryptWxidConfigs(raw as any) as ConfigSchema[K]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (key === 'dbPath' && typeof raw === 'string') {
|
||||||
|
return expandHomePath(raw) as ConfigSchema[K]
|
||||||
|
}
|
||||||
|
|
||||||
return raw
|
return raw
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,6 +309,10 @@ export class ConfigService {
|
|||||||
let toStore = value
|
let toStore = value
|
||||||
const inLockMode = this.isLockMode() && this.unlockPassword
|
const inLockMode = this.isLockMode() && this.unlockPassword
|
||||||
|
|
||||||
|
if (key === 'dbPath' && typeof value === 'string') {
|
||||||
|
toStore = expandHomePath(value) as ConfigSchema[K]
|
||||||
|
}
|
||||||
|
|
||||||
if (ENCRYPTED_BOOL_KEYS.has(key)) {
|
if (ENCRYPTED_BOOL_KEYS.has(key)) {
|
||||||
const boolValue = value === true || value === 'true'
|
const boolValue = value === true || value === 'true'
|
||||||
// `false` 不需要写入 keychain,避免无意义触发 macOS 钥匙串弹窗
|
// `false` 不需要写入 keychain,避免无意义触发 macOS 钥匙串弹窗
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { join, basename } from 'path'
|
|||||||
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
|
import { existsSync, readdirSync, statSync, readFileSync } from 'fs'
|
||||||
import { homedir } from 'os'
|
import { homedir } from 'os'
|
||||||
import { createDecipheriv } from 'crypto'
|
import { createDecipheriv } from 'crypto'
|
||||||
|
import { expandHomePath } from '../utils/pathUtils'
|
||||||
|
|
||||||
export interface WxidInfo {
|
export interface WxidInfo {
|
||||||
wxid: string
|
wxid: string
|
||||||
@@ -139,13 +140,14 @@ export class DbPathService {
|
|||||||
* 查找账号目录(包含 db_storage 或图片目录)
|
* 查找账号目录(包含 db_storage 或图片目录)
|
||||||
*/
|
*/
|
||||||
findAccountDirs(rootPath: string): string[] {
|
findAccountDirs(rootPath: string): string[] {
|
||||||
|
const resolvedRootPath = expandHomePath(rootPath)
|
||||||
const accounts: string[] = []
|
const accounts: string[] = []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const entries = readdirSync(rootPath)
|
const entries = readdirSync(resolvedRootPath)
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const entryPath = join(rootPath, entry)
|
const entryPath = join(resolvedRootPath, entry)
|
||||||
let stat: ReturnType<typeof statSync>
|
let stat: ReturnType<typeof statSync>
|
||||||
try {
|
try {
|
||||||
stat = statSync(entryPath)
|
stat = statSync(entryPath)
|
||||||
@@ -216,13 +218,14 @@ export class DbPathService {
|
|||||||
* 扫描目录名候选(仅包含下划线的文件夹,排除 all_users)
|
* 扫描目录名候选(仅包含下划线的文件夹,排除 all_users)
|
||||||
*/
|
*/
|
||||||
scanWxidCandidates(rootPath: string): WxidInfo[] {
|
scanWxidCandidates(rootPath: string): WxidInfo[] {
|
||||||
|
const resolvedRootPath = expandHomePath(rootPath)
|
||||||
const wxids: WxidInfo[] = []
|
const wxids: WxidInfo[] = []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (existsSync(rootPath)) {
|
if (existsSync(resolvedRootPath)) {
|
||||||
const entries = readdirSync(rootPath)
|
const entries = readdirSync(resolvedRootPath)
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const entryPath = join(rootPath, entry)
|
const entryPath = join(resolvedRootPath, entry)
|
||||||
let stat: ReturnType<typeof statSync>
|
let stat: ReturnType<typeof statSync>
|
||||||
try { stat = statSync(entryPath) } catch { continue }
|
try { stat = statSync(entryPath) } catch { continue }
|
||||||
if (!stat.isDirectory()) continue
|
if (!stat.isDirectory()) continue
|
||||||
@@ -235,9 +238,9 @@ export class DbPathService {
|
|||||||
|
|
||||||
|
|
||||||
if (wxids.length === 0) {
|
if (wxids.length === 0) {
|
||||||
const rootName = basename(rootPath)
|
const rootName = basename(resolvedRootPath)
|
||||||
if (rootName.includes('_') && rootName.toLowerCase() !== 'all_users') {
|
if (rootName.includes('_') && rootName.toLowerCase() !== 'all_users') {
|
||||||
const rootStat = statSync(rootPath)
|
const rootStat = statSync(resolvedRootPath)
|
||||||
wxids.push({ wxid: rootName, modifiedTime: rootStat.mtimeMs })
|
wxids.push({ wxid: rootName, modifiedTime: rootStat.mtimeMs })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -248,7 +251,7 @@ export class DbPathService {
|
|||||||
return a.wxid.localeCompare(b.wxid)
|
return a.wxid.localeCompare(b.wxid)
|
||||||
});
|
});
|
||||||
|
|
||||||
const globalInfo = this.parseGlobalConfig(rootPath);
|
const globalInfo = this.parseGlobalConfig(resolvedRootPath);
|
||||||
if (globalInfo) {
|
if (globalInfo) {
|
||||||
for (const w of sorted) {
|
for (const w of sorted) {
|
||||||
if (w.wxid.startsWith(globalInfo.wxid) || sorted.length === 1) {
|
if (w.wxid.startsWith(globalInfo.wxid) || sorted.length === 1) {
|
||||||
@@ -266,19 +269,20 @@ export class DbPathService {
|
|||||||
* 扫描 wxid 列表
|
* 扫描 wxid 列表
|
||||||
*/
|
*/
|
||||||
scanWxids(rootPath: string): WxidInfo[] {
|
scanWxids(rootPath: string): WxidInfo[] {
|
||||||
|
const resolvedRootPath = expandHomePath(rootPath)
|
||||||
const wxids: WxidInfo[] = []
|
const wxids: WxidInfo[] = []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (this.isAccountDir(rootPath)) {
|
if (this.isAccountDir(resolvedRootPath)) {
|
||||||
const wxid = basename(rootPath)
|
const wxid = basename(resolvedRootPath)
|
||||||
const modifiedTime = this.getAccountModifiedTime(rootPath)
|
const modifiedTime = this.getAccountModifiedTime(resolvedRootPath)
|
||||||
return [{ wxid, modifiedTime }]
|
return [{ wxid, modifiedTime }]
|
||||||
}
|
}
|
||||||
|
|
||||||
const accounts = this.findAccountDirs(rootPath)
|
const accounts = this.findAccountDirs(resolvedRootPath)
|
||||||
|
|
||||||
for (const account of accounts) {
|
for (const account of accounts) {
|
||||||
const fullPath = join(rootPath, account)
|
const fullPath = join(resolvedRootPath, account)
|
||||||
const modifiedTime = this.getAccountModifiedTime(fullPath)
|
const modifiedTime = this.getAccountModifiedTime(fullPath)
|
||||||
wxids.push({ wxid: account, modifiedTime })
|
wxids.push({ wxid: account, modifiedTime })
|
||||||
}
|
}
|
||||||
@@ -289,7 +293,7 @@ export class DbPathService {
|
|||||||
return a.wxid.localeCompare(b.wxid)
|
return a.wxid.localeCompare(b.wxid)
|
||||||
});
|
});
|
||||||
|
|
||||||
const globalInfo = this.parseGlobalConfig(rootPath);
|
const globalInfo = this.parseGlobalConfig(resolvedRootPath);
|
||||||
if (globalInfo) {
|
if (globalInfo) {
|
||||||
for (const w of sorted) {
|
for (const w of sorted) {
|
||||||
if (w.wxid.startsWith(globalInfo.wxid) || sorted.length === 1) {
|
if (w.wxid.startsWith(globalInfo.wxid) || sorted.length === 1) {
|
||||||
|
|||||||
@@ -108,7 +108,6 @@ export interface ExportOptions {
|
|||||||
sessionNameWithTypePrefix?: boolean
|
sessionNameWithTypePrefix?: boolean
|
||||||
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
|
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
|
||||||
exportConcurrency?: number
|
exportConcurrency?: number
|
||||||
imageDeepSearchOnMiss?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const TXT_COLUMN_DEFINITIONS: Array<{ id: string; label: string }> = [
|
const TXT_COLUMN_DEFINITIONS: Array<{ id: string; label: string }> = [
|
||||||
@@ -443,8 +442,8 @@ class ExportService {
|
|||||||
let lastSessionId = ''
|
let lastSessionId = ''
|
||||||
let lastCollected = 0
|
let lastCollected = 0
|
||||||
let lastExported = 0
|
let lastExported = 0
|
||||||
const MIN_PROGRESS_EMIT_INTERVAL_MS = 250
|
const MIN_PROGRESS_EMIT_INTERVAL_MS = 400
|
||||||
const MESSAGE_PROGRESS_DELTA_THRESHOLD = 500
|
const MESSAGE_PROGRESS_DELTA_THRESHOLD = 1200
|
||||||
|
|
||||||
const commit = (progress: ExportProgress) => {
|
const commit = (progress: ExportProgress) => {
|
||||||
onProgress(progress)
|
onProgress(progress)
|
||||||
@@ -1092,8 +1091,7 @@ class ExportService {
|
|||||||
private getImageMissingRunCacheKey(
|
private getImageMissingRunCacheKey(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
imageMd5?: unknown,
|
imageMd5?: unknown,
|
||||||
imageDatName?: unknown,
|
imageDatName?: unknown
|
||||||
imageDeepSearchOnMiss = true
|
|
||||||
): string | null {
|
): string | null {
|
||||||
const normalizedSessionId = String(sessionId || '').trim()
|
const normalizedSessionId = String(sessionId || '').trim()
|
||||||
const normalizedImageMd5 = String(imageMd5 || '').trim().toLowerCase()
|
const normalizedImageMd5 = String(imageMd5 || '').trim().toLowerCase()
|
||||||
@@ -1105,8 +1103,7 @@ class ExportService {
|
|||||||
const secondaryToken = normalizedImageMd5 && normalizedImageDatName && normalizedImageDatName !== normalizedImageMd5
|
const secondaryToken = normalizedImageMd5 && normalizedImageDatName && normalizedImageDatName !== normalizedImageMd5
|
||||||
? normalizedImageDatName
|
? normalizedImageDatName
|
||||||
: ''
|
: ''
|
||||||
const lookupMode = imageDeepSearchOnMiss ? 'deep' : 'hardlink'
|
return `${normalizedSessionId}\u001f${primaryToken}\u001f${secondaryToken}`
|
||||||
return `${lookupMode}\u001f${normalizedSessionId}\u001f${primaryToken}\u001f${secondaryToken}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeEmojiMd5(value: unknown): string | undefined {
|
private normalizeEmojiMd5(value: unknown): string | undefined {
|
||||||
@@ -3583,7 +3580,6 @@ class ExportService {
|
|||||||
exportVoiceAsText?: boolean
|
exportVoiceAsText?: boolean
|
||||||
includeVideoPoster?: boolean
|
includeVideoPoster?: boolean
|
||||||
includeVoiceWithTranscript?: boolean
|
includeVoiceWithTranscript?: boolean
|
||||||
imageDeepSearchOnMiss?: boolean
|
|
||||||
dirCache?: Set<string>
|
dirCache?: Set<string>
|
||||||
}
|
}
|
||||||
): Promise<MediaExportItem | null> {
|
): Promise<MediaExportItem | null> {
|
||||||
@@ -3596,8 +3592,7 @@ class ExportService {
|
|||||||
sessionId,
|
sessionId,
|
||||||
mediaRootDir,
|
mediaRootDir,
|
||||||
mediaRelativePrefix,
|
mediaRelativePrefix,
|
||||||
options.dirCache,
|
options.dirCache
|
||||||
options.imageDeepSearchOnMiss !== false
|
|
||||||
)
|
)
|
||||||
if (result) {
|
if (result) {
|
||||||
}
|
}
|
||||||
@@ -3654,8 +3649,7 @@ class ExportService {
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
mediaRootDir: string,
|
mediaRootDir: string,
|
||||||
mediaRelativePrefix: string,
|
mediaRelativePrefix: string,
|
||||||
dirCache?: Set<string>,
|
dirCache?: Set<string>
|
||||||
imageDeepSearchOnMiss = true
|
|
||||||
): Promise<MediaExportItem | null> {
|
): Promise<MediaExportItem | null> {
|
||||||
try {
|
try {
|
||||||
const imagesDir = path.join(mediaRootDir, mediaRelativePrefix, 'images')
|
const imagesDir = path.join(mediaRootDir, mediaRelativePrefix, 'images')
|
||||||
@@ -3675,8 +3669,7 @@ class ExportService {
|
|||||||
const missingRunCacheKey = this.getImageMissingRunCacheKey(
|
const missingRunCacheKey = this.getImageMissingRunCacheKey(
|
||||||
sessionId,
|
sessionId,
|
||||||
imageMd5,
|
imageMd5,
|
||||||
imageDatName,
|
imageDatName
|
||||||
imageDeepSearchOnMiss
|
|
||||||
)
|
)
|
||||||
if (missingRunCacheKey && this.mediaRunMissingImageKeys.has(missingRunCacheKey)) {
|
if (missingRunCacheKey && this.mediaRunMissingImageKeys.has(missingRunCacheKey)) {
|
||||||
return null
|
return null
|
||||||
@@ -3686,26 +3679,31 @@ class ExportService {
|
|||||||
sessionId,
|
sessionId,
|
||||||
imageMd5,
|
imageMd5,
|
||||||
imageDatName,
|
imageDatName,
|
||||||
|
createTime: msg.createTime,
|
||||||
force: true, // 导出优先高清,失败再回退缩略图
|
force: true, // 导出优先高清,失败再回退缩略图
|
||||||
preferFilePath: true,
|
preferFilePath: true,
|
||||||
hardlinkOnly: !imageDeepSearchOnMiss
|
hardlinkOnly: true,
|
||||||
|
disableUpdateCheck: true,
|
||||||
|
allowCacheIndex: !imageMd5,
|
||||||
|
suppressEvents: true
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!result.success || !result.localPath) {
|
if (!result.success || !result.localPath) {
|
||||||
|
if (result.failureKind === 'decrypt_failed') {
|
||||||
console.log(`[Export] 图片解密失败 (localId=${msg.localId}): imageMd5=${imageMd5}, imageDatName=${imageDatName}, error=${result.error || '未知'}`)
|
console.log(`[Export] 图片解密失败 (localId=${msg.localId}): imageMd5=${imageMd5}, imageDatName=${imageDatName}, error=${result.error || '未知'}`)
|
||||||
if (!imageDeepSearchOnMiss) {
|
} else {
|
||||||
console.log(`[Export] 未命中 hardlink(已关闭缺图深度搜索)→ 将显示 [图片] 占位符`)
|
console.log(`[Export] 图片本地无数据 (localId=${msg.localId}): imageMd5=${imageMd5}, imageDatName=${imageDatName}, error=${result.error || '未知'}`)
|
||||||
if (missingRunCacheKey) {
|
|
||||||
this.mediaRunMissingImageKeys.add(missingRunCacheKey)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
// 尝试获取缩略图
|
// 尝试获取缩略图
|
||||||
const thumbResult = await imageDecryptService.resolveCachedImage({
|
const thumbResult = await imageDecryptService.resolveCachedImage({
|
||||||
sessionId,
|
sessionId,
|
||||||
imageMd5,
|
imageMd5,
|
||||||
imageDatName,
|
imageDatName,
|
||||||
preferFilePath: true
|
createTime: msg.createTime,
|
||||||
|
preferFilePath: true,
|
||||||
|
disableUpdateCheck: true,
|
||||||
|
allowCacheIndex: !imageMd5,
|
||||||
|
suppressEvents: true
|
||||||
})
|
})
|
||||||
if (thumbResult.success && thumbResult.localPath) {
|
if (thumbResult.success && thumbResult.localPath) {
|
||||||
console.log(`[Export] 使用缩略图替代 (localId=${msg.localId}): ${thumbResult.localPath}`)
|
console.log(`[Export] 使用缩略图替代 (localId=${msg.localId}): ${thumbResult.localPath}`)
|
||||||
@@ -5302,7 +5300,6 @@ class ExportService {
|
|||||||
maxFileSizeMb: options.maxFileSizeMb,
|
maxFileSizeMb: options.maxFileSizeMb,
|
||||||
exportVoiceAsText: options.exportVoiceAsText,
|
exportVoiceAsText: options.exportVoiceAsText,
|
||||||
includeVideoPoster: options.format === 'html',
|
includeVideoPoster: options.format === 'html',
|
||||||
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
|
|
||||||
dirCache: mediaDirCache
|
dirCache: mediaDirCache
|
||||||
})
|
})
|
||||||
mediaCache.set(mediaKey, mediaItem)
|
mediaCache.set(mediaKey, mediaItem)
|
||||||
@@ -5813,7 +5810,6 @@ class ExportService {
|
|||||||
maxFileSizeMb: options.maxFileSizeMb,
|
maxFileSizeMb: options.maxFileSizeMb,
|
||||||
exportVoiceAsText: options.exportVoiceAsText,
|
exportVoiceAsText: options.exportVoiceAsText,
|
||||||
includeVideoPoster: options.format === 'html',
|
includeVideoPoster: options.format === 'html',
|
||||||
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
|
|
||||||
dirCache: mediaDirCache
|
dirCache: mediaDirCache
|
||||||
})
|
})
|
||||||
mediaCache.set(mediaKey, mediaItem)
|
mediaCache.set(mediaKey, mediaItem)
|
||||||
@@ -6685,7 +6681,6 @@ class ExportService {
|
|||||||
maxFileSizeMb: options.maxFileSizeMb,
|
maxFileSizeMb: options.maxFileSizeMb,
|
||||||
exportVoiceAsText: options.exportVoiceAsText,
|
exportVoiceAsText: options.exportVoiceAsText,
|
||||||
includeVideoPoster: options.format === 'html',
|
includeVideoPoster: options.format === 'html',
|
||||||
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
|
|
||||||
dirCache: mediaDirCache
|
dirCache: mediaDirCache
|
||||||
})
|
})
|
||||||
mediaCache.set(mediaKey, mediaItem)
|
mediaCache.set(mediaKey, mediaItem)
|
||||||
@@ -7436,7 +7431,6 @@ class ExportService {
|
|||||||
maxFileSizeMb: options.maxFileSizeMb,
|
maxFileSizeMb: options.maxFileSizeMb,
|
||||||
exportVoiceAsText: options.exportVoiceAsText,
|
exportVoiceAsText: options.exportVoiceAsText,
|
||||||
includeVideoPoster: options.format === 'html',
|
includeVideoPoster: options.format === 'html',
|
||||||
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
|
|
||||||
dirCache: mediaDirCache
|
dirCache: mediaDirCache
|
||||||
})
|
})
|
||||||
mediaCache.set(mediaKey, mediaItem)
|
mediaCache.set(mediaKey, mediaItem)
|
||||||
@@ -7816,7 +7810,6 @@ class ExportService {
|
|||||||
maxFileSizeMb: options.maxFileSizeMb,
|
maxFileSizeMb: options.maxFileSizeMb,
|
||||||
exportVoiceAsText: options.exportVoiceAsText,
|
exportVoiceAsText: options.exportVoiceAsText,
|
||||||
includeVideoPoster: options.format === 'html',
|
includeVideoPoster: options.format === 'html',
|
||||||
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
|
|
||||||
dirCache: mediaDirCache
|
dirCache: mediaDirCache
|
||||||
})
|
})
|
||||||
mediaCache.set(mediaKey, mediaItem)
|
mediaCache.set(mediaKey, mediaItem)
|
||||||
@@ -8240,7 +8233,6 @@ class ExportService {
|
|||||||
includeVideoPoster: options.format === 'html',
|
includeVideoPoster: options.format === 'html',
|
||||||
includeVoiceWithTranscript: true,
|
includeVoiceWithTranscript: true,
|
||||||
exportVideos: options.exportVideos,
|
exportVideos: options.exportVideos,
|
||||||
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
|
|
||||||
dirCache: mediaDirCache
|
dirCache: mediaDirCache
|
||||||
})
|
})
|
||||||
mediaCache.set(mediaKey, mediaItem)
|
mediaCache.set(mediaKey, mediaItem)
|
||||||
|
|||||||
@@ -1208,6 +1208,30 @@ class HttpService {
|
|||||||
const sessionDir = path.join(this.getApiMediaExportPath(), this.sanitizeFileName(talker, 'session'))
|
const sessionDir = path.join(this.getApiMediaExportPath(), this.sanitizeFileName(talker, 'session'))
|
||||||
this.ensureDir(sessionDir)
|
this.ensureDir(sessionDir)
|
||||||
|
|
||||||
|
// 预热图片 hardlink 索引,减少逐条导出时的查找开销
|
||||||
|
if (options.exportImages) {
|
||||||
|
const imageMd5Set = new Set<string>()
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (msg.localType !== 3) continue
|
||||||
|
const imageMd5 = String(msg.imageMd5 || '').trim().toLowerCase()
|
||||||
|
if (imageMd5) {
|
||||||
|
imageMd5Set.add(imageMd5)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const imageDatName = String(msg.imageDatName || '').trim().toLowerCase()
|
||||||
|
if (/^[a-f0-9]{32}$/i.test(imageDatName)) {
|
||||||
|
imageMd5Set.add(imageDatName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (imageMd5Set.size > 0) {
|
||||||
|
try {
|
||||||
|
await imageDecryptService.preloadImageHardlinkMd5s(Array.from(imageMd5Set))
|
||||||
|
} catch {
|
||||||
|
// ignore preload failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
const exported = await this.exportMediaForMessage(msg, talker, sessionDir, options)
|
const exported = await this.exportMediaForMessage(msg, talker, sessionDir, options)
|
||||||
if (exported) {
|
if (exported) {
|
||||||
@@ -1230,13 +1254,39 @@ class HttpService {
|
|||||||
sessionId: talker,
|
sessionId: talker,
|
||||||
imageMd5: msg.imageMd5,
|
imageMd5: msg.imageMd5,
|
||||||
imageDatName: msg.imageDatName,
|
imageDatName: msg.imageDatName,
|
||||||
force: true
|
createTime: msg.createTime,
|
||||||
|
force: true,
|
||||||
|
preferFilePath: true,
|
||||||
|
hardlinkOnly: true,
|
||||||
|
disableUpdateCheck: true,
|
||||||
|
suppressEvents: true
|
||||||
})
|
})
|
||||||
if (result.success && result.localPath) {
|
|
||||||
let imagePath = result.localPath
|
let imagePath = result.success ? result.localPath : undefined
|
||||||
|
if (!imagePath) {
|
||||||
|
try {
|
||||||
|
const cached = await imageDecryptService.resolveCachedImage({
|
||||||
|
sessionId: talker,
|
||||||
|
imageMd5: msg.imageMd5,
|
||||||
|
imageDatName: msg.imageDatName,
|
||||||
|
createTime: msg.createTime,
|
||||||
|
preferFilePath: true,
|
||||||
|
hardlinkOnly: true,
|
||||||
|
disableUpdateCheck: true,
|
||||||
|
suppressEvents: true
|
||||||
|
})
|
||||||
|
if (cached.success && cached.localPath) {
|
||||||
|
imagePath = cached.localPath
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore resolve failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imagePath) {
|
||||||
if (imagePath.startsWith('data:')) {
|
if (imagePath.startsWith('data:')) {
|
||||||
const base64Match = imagePath.match(/^data:[^;]+;base64,(.+)$/)
|
const base64Match = imagePath.match(/^data:[^;]+;base64,(.+)$/)
|
||||||
if (base64Match) {
|
if (!base64Match) return null
|
||||||
const imageBuffer = Buffer.from(base64Match[1], 'base64')
|
const imageBuffer = Buffer.from(base64Match[1], 'base64')
|
||||||
const ext = this.detectImageExt(imageBuffer)
|
const ext = this.detectImageExt(imageBuffer)
|
||||||
const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`)
|
const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`)
|
||||||
@@ -1250,7 +1300,8 @@ class HttpService {
|
|||||||
const relativePath = `${this.sanitizeFileName(talker, 'session')}/images/${fileName}`
|
const relativePath = `${this.sanitizeFileName(talker, 'session')}/images/${fileName}`
|
||||||
return { kind: 'image', fileName, fullPath, relativePath }
|
return { kind: 'image', fileName, fullPath, relativePath }
|
||||||
}
|
}
|
||||||
} else if (fs.existsSync(imagePath)) {
|
|
||||||
|
if (fs.existsSync(imagePath)) {
|
||||||
const imageBuffer = fs.readFileSync(imagePath)
|
const imageBuffer = fs.readFileSync(imagePath)
|
||||||
const ext = this.detectImageExt(imageBuffer)
|
const ext = this.detectImageExt(imageBuffer)
|
||||||
const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`)
|
const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ type PreloadImagePayload = {
|
|||||||
sessionId?: string
|
sessionId?: string
|
||||||
imageMd5?: string
|
imageMd5?: string
|
||||||
imageDatName?: string
|
imageDatName?: string
|
||||||
|
createTime?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type PreloadOptions = {
|
type PreloadOptions = {
|
||||||
@@ -74,15 +75,24 @@ export class ImagePreloadService {
|
|||||||
sessionId: task.sessionId,
|
sessionId: task.sessionId,
|
||||||
imageMd5: task.imageMd5,
|
imageMd5: task.imageMd5,
|
||||||
imageDatName: task.imageDatName,
|
imageDatName: task.imageDatName,
|
||||||
|
createTime: task.createTime,
|
||||||
|
preferFilePath: true,
|
||||||
|
hardlinkOnly: true,
|
||||||
disableUpdateCheck: !task.allowDecrypt,
|
disableUpdateCheck: !task.allowDecrypt,
|
||||||
allowCacheIndex: task.allowCacheIndex
|
allowCacheIndex: task.allowCacheIndex,
|
||||||
|
suppressEvents: true
|
||||||
})
|
})
|
||||||
if (cached.success) return
|
if (cached.success) return
|
||||||
if (!task.allowDecrypt) return
|
if (!task.allowDecrypt) return
|
||||||
await imageDecryptService.decryptImage({
|
await imageDecryptService.decryptImage({
|
||||||
sessionId: task.sessionId,
|
sessionId: task.sessionId,
|
||||||
imageMd5: task.imageMd5,
|
imageMd5: task.imageMd5,
|
||||||
imageDatName: task.imageDatName
|
imageDatName: task.imageDatName,
|
||||||
|
createTime: task.createTime,
|
||||||
|
preferFilePath: true,
|
||||||
|
hardlinkOnly: true,
|
||||||
|
disableUpdateCheck: true,
|
||||||
|
suppressEvents: true
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
// ignore preload failures
|
// ignore preload failures
|
||||||
|
|||||||
@@ -478,8 +478,6 @@ export class KeyServiceMac {
|
|||||||
'return "WF_ERR::" & errNum & "::" & errMsg & "::" & (pr as text)',
|
'return "WF_ERR::" & errNum & "::" & errMsg & "::" & (pr as text)',
|
||||||
'end try'
|
'end try'
|
||||||
]
|
]
|
||||||
onStatus?.('已准备就绪,现在登录微信或退出登录后重新登录微信', 0)
|
|
||||||
|
|
||||||
let stdout = ''
|
let stdout = ''
|
||||||
try {
|
try {
|
||||||
const result = await execFileAsync('/usr/bin/osascript', scriptLines.flatMap(line => ['-e', line]), {
|
const result = await execFileAsync('/usr/bin/osascript', scriptLines.flatMap(line => ['-e', line]), {
|
||||||
|
|||||||
@@ -53,6 +53,13 @@ class MessagePushService {
|
|||||||
void this.refreshConfiguration('startup')
|
void this.refreshConfiguration('startup')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
this.started = false
|
||||||
|
this.processing = false
|
||||||
|
this.rerunRequested = false
|
||||||
|
this.resetRuntimeState()
|
||||||
|
}
|
||||||
|
|
||||||
handleDbMonitorChange(type: string, json: string): void {
|
handleDbMonitorChange(type: string, json: string): void {
|
||||||
if (!this.started) return
|
if (!this.started) return
|
||||||
if (!this.isPushEnabled()) return
|
if (!this.isPushEnabled()) return
|
||||||
|
|||||||
110
electron/services/nativeImageDecrypt.ts
Normal file
110
electron/services/nativeImageDecrypt.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { existsSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
type NativeDecryptResult = {
|
||||||
|
data: Buffer
|
||||||
|
ext: string
|
||||||
|
isWxgf?: boolean
|
||||||
|
is_wxgf?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type NativeAddon = {
|
||||||
|
decryptDatNative: (inputPath: string, xorKey: number, aesKey?: string) => NativeDecryptResult
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedAddon: NativeAddon | null | undefined
|
||||||
|
|
||||||
|
function shouldEnableNative(): boolean {
|
||||||
|
return process.env.WEFLOW_IMAGE_NATIVE !== '0'
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandAsarCandidates(filePath: string): string[] {
|
||||||
|
if (!filePath.includes('app.asar') || filePath.includes('app.asar.unpacked')) {
|
||||||
|
return [filePath]
|
||||||
|
}
|
||||||
|
return [filePath.replace('app.asar', 'app.asar.unpacked'), filePath]
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlatformDir(): string {
|
||||||
|
if (process.platform === 'win32') return 'win32'
|
||||||
|
if (process.platform === 'darwin') return 'macos'
|
||||||
|
if (process.platform === 'linux') return 'linux'
|
||||||
|
return process.platform
|
||||||
|
}
|
||||||
|
|
||||||
|
function getArchDir(): string {
|
||||||
|
if (process.arch === 'x64') return 'x64'
|
||||||
|
if (process.arch === 'arm64') return 'arm64'
|
||||||
|
return process.arch
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAddonCandidates(): string[] {
|
||||||
|
const platformDir = getPlatformDir()
|
||||||
|
const archDir = getArchDir()
|
||||||
|
const cwd = process.cwd()
|
||||||
|
const fileNames = [
|
||||||
|
`weflow-image-native-${platformDir}-${archDir}.node`
|
||||||
|
]
|
||||||
|
const roots = [
|
||||||
|
join(cwd, 'resources', 'wedecrypt', platformDir, archDir),
|
||||||
|
...(process.resourcesPath
|
||||||
|
? [
|
||||||
|
join(process.resourcesPath, 'resources', 'wedecrypt', platformDir, archDir),
|
||||||
|
join(process.resourcesPath, 'wedecrypt', platformDir, archDir)
|
||||||
|
]
|
||||||
|
: [])
|
||||||
|
]
|
||||||
|
const candidates = roots.flatMap((root) => fileNames.map((name) => join(root, name)))
|
||||||
|
return Array.from(new Set(candidates.flatMap(expandAsarCandidates)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadAddon(): NativeAddon | null {
|
||||||
|
if (!shouldEnableNative()) return null
|
||||||
|
if (cachedAddon !== undefined) return cachedAddon
|
||||||
|
|
||||||
|
for (const candidate of getAddonCandidates()) {
|
||||||
|
if (!existsSync(candidate)) continue
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
const addon = require(candidate) as NativeAddon
|
||||||
|
if (addon && typeof addon.decryptDatNative === 'function') {
|
||||||
|
cachedAddon = addon
|
||||||
|
return addon
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// try next candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedAddon = null
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nativeAddonLocation(): string | null {
|
||||||
|
for (const candidate of getAddonCandidates()) {
|
||||||
|
if (existsSync(candidate)) return candidate
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decryptDatViaNative(
|
||||||
|
inputPath: string,
|
||||||
|
xorKey: number,
|
||||||
|
aesKey?: string
|
||||||
|
): { data: Buffer; ext: string; isWxgf: boolean } | null {
|
||||||
|
const addon = loadAddon()
|
||||||
|
if (!addon) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = addon.decryptDatNative(inputPath, xorKey, aesKey)
|
||||||
|
const isWxgf = Boolean(result?.isWxgf ?? result?.is_wxgf)
|
||||||
|
if (!result || !Buffer.isBuffer(result.data)) return null
|
||||||
|
const rawExt = typeof result.ext === 'string' && result.ext.trim()
|
||||||
|
? result.ext.trim().toLowerCase()
|
||||||
|
: ''
|
||||||
|
const ext = rawExt ? (rawExt.startsWith('.') ? rawExt : `.${rawExt}`) : ''
|
||||||
|
return { data: result.data, ext, isWxgf }
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { join, dirname, basename } from 'path'
|
|||||||
import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs'
|
import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs'
|
||||||
import { tmpdir } from 'os'
|
import { tmpdir } from 'os'
|
||||||
import * as fzstd from 'fzstd'
|
import * as fzstd from 'fzstd'
|
||||||
|
import { expandHomePath } from '../utils/pathUtils'
|
||||||
|
|
||||||
//数据服务初始化错误信息,用于帮助用户诊断问题
|
//数据服务初始化错误信息,用于帮助用户诊断问题
|
||||||
let lastDllInitError: string | null = null
|
let lastDllInitError: string | null = null
|
||||||
@@ -481,7 +482,7 @@ export class WcdbCore {
|
|||||||
|
|
||||||
private resolveDbStoragePath(basePath: string, wxid: string): string | null {
|
private resolveDbStoragePath(basePath: string, wxid: string): string | null {
|
||||||
if (!basePath) return null
|
if (!basePath) return null
|
||||||
const normalized = basePath.replace(/[\\\\/]+$/, '')
|
const normalized = expandHomePath(basePath).replace(/[\\\\/]+$/, '')
|
||||||
if (normalized.toLowerCase().endsWith('db_storage') && existsSync(normalized)) {
|
if (normalized.toLowerCase().endsWith('db_storage') && existsSync(normalized)) {
|
||||||
return normalized
|
return normalized
|
||||||
}
|
}
|
||||||
@@ -1600,6 +1601,9 @@ export class WcdbCore {
|
|||||||
*/
|
*/
|
||||||
close(): void {
|
close(): void {
|
||||||
if (this.handle !== null || this.initialized) {
|
if (this.handle !== null || this.initialized) {
|
||||||
|
// 先停止监控与云控回调,避免 shutdown 后仍有 native 回调访问已释放资源。
|
||||||
|
try { this.stopMonitor() } catch {}
|
||||||
|
try { this.cloudStop() } catch {}
|
||||||
try {
|
try {
|
||||||
// 不调用 closeAccount,直接 shutdown
|
// 不调用 closeAccount,直接 shutdown
|
||||||
this.wcdbShutdown()
|
this.wcdbShutdown()
|
||||||
|
|||||||
20
electron/utils/pathUtils.ts
Normal file
20
electron/utils/pathUtils.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { homedir } from 'os'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expand "~" prefix to current user's home directory.
|
||||||
|
* Examples:
|
||||||
|
* - "~" => "/Users/alex"
|
||||||
|
* - "~/Library/..." => "/Users/alex/Library/..."
|
||||||
|
*/
|
||||||
|
export function expandHomePath(inputPath: string): string {
|
||||||
|
const raw = String(inputPath || '').trim()
|
||||||
|
if (!raw) return raw
|
||||||
|
|
||||||
|
if (raw === '~') return homedir()
|
||||||
|
if (/^~[\\/]/.test(raw)) {
|
||||||
|
return `${homedir()}${raw.slice(1)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
29
package-lock.json
generated
29
package-lock.json
generated
@@ -9,7 +9,6 @@
|
|||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vscode/sudo-prompt": "^9.3.2",
|
|
||||||
"echarts": "^6.0.0",
|
"echarts": "^6.0.0",
|
||||||
"echarts-for-react": "^3.0.2",
|
"echarts-for-react": "^3.0.2",
|
||||||
"electron-store": "^11.0.2",
|
"electron-store": "^11.0.2",
|
||||||
@@ -28,8 +27,9 @@
|
|||||||
"react-router-dom": "^7.14.0",
|
"react-router-dom": "^7.14.0",
|
||||||
"react-virtuoso": "^4.18.1",
|
"react-virtuoso": "^4.18.1",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"sherpa-onnx-node": "^1.12.35",
|
"sherpa-onnx-node": "^1.10.38",
|
||||||
"silk-wasm": "^3.7.1",
|
"silk-wasm": "^3.7.1",
|
||||||
|
"sudo-prompt": "^9.2.1",
|
||||||
"wechat-emojis": "^1.0.2",
|
"wechat-emojis": "^1.0.2",
|
||||||
"zustand": "^5.0.2"
|
"zustand": "^5.0.2"
|
||||||
},
|
},
|
||||||
@@ -40,11 +40,11 @@
|
|||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"electron": "^41.1.1",
|
"electron": "^41.1.1",
|
||||||
"electron-builder": "^26.8.1",
|
"electron-builder": "^26.8.1",
|
||||||
"sass": "^1.99.0",
|
"sass": "^1.98.0",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
"vite": "^7.3.2",
|
"vite": "^7.0.0",
|
||||||
"vite-plugin-electron": "^0.29.1",
|
"vite-plugin-electron": "^0.28.8",
|
||||||
"vite-plugin-electron-renderer": "^0.14.6"
|
"vite-plugin-electron-renderer": "^0.14.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -3050,12 +3050,6 @@
|
|||||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
"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": {
|
"node_modules/@xmldom/xmldom": {
|
||||||
"version": "0.8.12",
|
"version": "0.8.12",
|
||||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz",
|
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz",
|
||||||
@@ -9462,6 +9456,13 @@
|
|||||||
"inline-style-parser": "0.2.7"
|
"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": {
|
"node_modules/sumchecker": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",
|
||||||
@@ -10140,9 +10141,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite-plugin-electron": {
|
"node_modules/vite-plugin-electron": {
|
||||||
"version": "0.29.1",
|
"version": "0.28.8",
|
||||||
"resolved": "https://registry.npmjs.org/vite-plugin-electron/-/vite-plugin-electron-0.29.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite-plugin-electron/-/vite-plugin-electron-0.28.8.tgz",
|
||||||
"integrity": "sha512-AejNed5BgHFnuw8h5puTa61C6vdP4ydbsbo/uVjH1fTdHAlCDz1+o6pDQ/scQj1udDrGvH01+vTbzQh/vMnR9w==",
|
"integrity": "sha512-ir+B21oSGK9j23OEvt4EXyco9xDCaF6OGFe0V/8Zc0yL2+HMyQ6mmNQEIhXsEsZCSfIowBpwQBeHH4wVsfraeg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
|||||||
21
package.json
21
package.json
@@ -9,7 +9,7 @@
|
|||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/Jasonzhu1207/WeFlow"
|
"url": "https://github.com/hicccc77/WeFlow"
|
||||||
},
|
},
|
||||||
"//": "二改不应改变此处的作者与应用信息",
|
"//": "二改不应改变此处的作者与应用信息",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
"appId": "com.WeFlow.app",
|
"appId": "com.WeFlow.app",
|
||||||
"publish": {
|
"publish": {
|
||||||
"provider": "github",
|
"provider": "github",
|
||||||
"owner": "Jasonzhu1207",
|
"owner": "hicccc77",
|
||||||
"repo": "WeFlow",
|
"repo": "WeFlow",
|
||||||
"releaseType": "release"
|
"releaseType": "release"
|
||||||
},
|
},
|
||||||
@@ -96,7 +96,7 @@
|
|||||||
"gatekeeperAssess": false,
|
"gatekeeperAssess": false,
|
||||||
"entitlements": "electron/entitlements.mac.plist",
|
"entitlements": "electron/entitlements.mac.plist",
|
||||||
"entitlementsInherit": "electron/entitlements.mac.plist",
|
"entitlementsInherit": "electron/entitlements.mac.plist",
|
||||||
"icon": "resources/icon.icns"
|
"icon": "resources/icons/macos/icon.icns"
|
||||||
},
|
},
|
||||||
"win": {
|
"win": {
|
||||||
"target": [
|
"target": [
|
||||||
@@ -105,19 +105,19 @@
|
|||||||
"icon": "public/icon.ico",
|
"icon": "public/icon.ico",
|
||||||
"extraFiles": [
|
"extraFiles": [
|
||||||
{
|
{
|
||||||
"from": "resources/msvcp140.dll",
|
"from": "resources/runtime/win32/msvcp140.dll",
|
||||||
"to": "."
|
"to": "."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "resources/msvcp140_1.dll",
|
"from": "resources/runtime/win32/msvcp140_1.dll",
|
||||||
"to": "."
|
"to": "."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "resources/vcruntime140.dll",
|
"from": "resources/runtime/win32/vcruntime140.dll",
|
||||||
"to": "."
|
"to": "."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "resources/vcruntime140_1.dll",
|
"from": "resources/runtime/win32/vcruntime140_1.dll",
|
||||||
"to": "."
|
"to": "."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -133,7 +133,7 @@
|
|||||||
"synopsis": "WeFlow for Linux",
|
"synopsis": "WeFlow for Linux",
|
||||||
"extraFiles": [
|
"extraFiles": [
|
||||||
{
|
{
|
||||||
"from": "resources/linux/install.sh",
|
"from": "resources/installer/linux/install.sh",
|
||||||
"to": "install.sh"
|
"to": "install.sh"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -186,9 +186,10 @@
|
|||||||
"node_modules/sherpa-onnx-node/**/*",
|
"node_modules/sherpa-onnx-node/**/*",
|
||||||
"node_modules/sherpa-onnx-*/*",
|
"node_modules/sherpa-onnx-*/*",
|
||||||
"node_modules/sherpa-onnx-*/**/*",
|
"node_modules/sherpa-onnx-*/**/*",
|
||||||
"node_modules/ffmpeg-static/**/*"
|
"node_modules/ffmpeg-static/**/*",
|
||||||
|
"resources/wedecrypt/**/*.node"
|
||||||
],
|
],
|
||||||
"icon": "resources/icon.icns"
|
"icon": "resources/icons/macos/icon.icns"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"picomatch": "^4.0.4",
|
"picomatch": "^4.0.4",
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
resources/wedecrypt/linux/x64/weflow-image-native-linux-x64.node
Normal file
BIN
resources/wedecrypt/linux/x64/weflow-image-native-linux-x64.node
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
resources/wedecrypt/win32/x64/weflow-image-native-win32-x64.node
Normal file
BIN
resources/wedecrypt/win32/x64/weflow-image-native-win32-x64.node
Normal file
Binary file not shown.
@@ -39,8 +39,6 @@ import UpdateDialog from './components/UpdateDialog'
|
|||||||
import UpdateProgressCapsule from './components/UpdateProgressCapsule'
|
import UpdateProgressCapsule from './components/UpdateProgressCapsule'
|
||||||
import LockScreen from './components/LockScreen'
|
import LockScreen from './components/LockScreen'
|
||||||
import { GlobalSessionMonitor } from './components/GlobalSessionMonitor'
|
import { GlobalSessionMonitor } from './components/GlobalSessionMonitor'
|
||||||
import { BatchTranscribeGlobal } from './components/BatchTranscribeGlobal'
|
|
||||||
import { BatchImageDecryptGlobal } from './components/BatchImageDecryptGlobal'
|
|
||||||
import WindowCloseDialog from './components/WindowCloseDialog'
|
import WindowCloseDialog from './components/WindowCloseDialog'
|
||||||
|
|
||||||
function RouteStateRedirect({ to }: { to: string }) {
|
function RouteStateRedirect({ to }: { to: string }) {
|
||||||
@@ -554,10 +552,6 @@ function App() {
|
|||||||
{/* 全局会话监听与通知 */}
|
{/* 全局会话监听与通知 */}
|
||||||
<GlobalSessionMonitor />
|
<GlobalSessionMonitor />
|
||||||
|
|
||||||
{/* 全局批量转写进度浮窗 */}
|
|
||||||
<BatchTranscribeGlobal />
|
|
||||||
<BatchImageDecryptGlobal />
|
|
||||||
|
|
||||||
{/* 用户协议弹窗 */}
|
{/* 用户协议弹窗 */}
|
||||||
{showAgreement && !agreementLoading && (
|
{showAgreement && !agreementLoading && (
|
||||||
<div className="agreement-overlay">
|
<div className="agreement-overlay">
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
onSingleExportDialogStatus,
|
onSingleExportDialogStatus,
|
||||||
requestExportSessionStatus
|
requestExportSessionStatus
|
||||||
} from '../services/exportBridge'
|
} from '../services/exportBridge'
|
||||||
|
import '../styles/batchTranscribe.scss'
|
||||||
import './ChatPage.scss'
|
import './ChatPage.scss'
|
||||||
|
|
||||||
// 系统消息类型常量
|
// 系统消息类型常量
|
||||||
@@ -154,6 +155,21 @@ function hasRenderableChatRecordName(value?: string): boolean {
|
|||||||
return value !== undefined && value !== null && String(value).length > 0
|
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 {
|
function getChatRecordPreviewText(item: ChatRecordItem): string {
|
||||||
const text = normalizeChatRecordText(item.datadesc) || normalizeChatRecordText(item.datatitle)
|
const text = normalizeChatRecordText(item.datadesc) || normalizeChatRecordText(item.datatitle)
|
||||||
if (item.datatype === 17) {
|
if (item.datatype === 17) {
|
||||||
@@ -1355,34 +1371,30 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
const {
|
const {
|
||||||
isBatchTranscribing,
|
isBatchTranscribing,
|
||||||
runningBatchVoiceTaskType,
|
runningBatchVoiceTaskType,
|
||||||
batchTranscribeProgress,
|
|
||||||
startTranscribe,
|
startTranscribe,
|
||||||
updateProgress,
|
updateProgress,
|
||||||
finishTranscribe,
|
updateTranscribeTaskStatus,
|
||||||
setShowBatchProgress
|
finishTranscribe
|
||||||
} = useBatchTranscribeStore(useShallow((state) => ({
|
} = useBatchTranscribeStore(useShallow((state) => ({
|
||||||
isBatchTranscribing: state.isBatchTranscribing,
|
isBatchTranscribing: state.isBatchTranscribing,
|
||||||
runningBatchVoiceTaskType: state.taskType,
|
runningBatchVoiceTaskType: state.taskType,
|
||||||
batchTranscribeProgress: state.progress,
|
|
||||||
startTranscribe: state.startTranscribe,
|
startTranscribe: state.startTranscribe,
|
||||||
updateProgress: state.updateProgress,
|
updateProgress: state.updateProgress,
|
||||||
finishTranscribe: state.finishTranscribe,
|
updateTranscribeTaskStatus: state.setTaskStatus,
|
||||||
setShowBatchProgress: state.setShowToast
|
finishTranscribe: state.finishTranscribe
|
||||||
})))
|
})))
|
||||||
const {
|
const {
|
||||||
isBatchDecrypting,
|
isBatchDecrypting,
|
||||||
batchDecryptProgress,
|
|
||||||
startDecrypt,
|
startDecrypt,
|
||||||
updateDecryptProgress,
|
updateDecryptProgress,
|
||||||
finishDecrypt,
|
updateDecryptTaskStatus,
|
||||||
setShowBatchDecryptToast
|
finishDecrypt
|
||||||
} = useBatchImageDecryptStore(useShallow((state) => ({
|
} = useBatchImageDecryptStore(useShallow((state) => ({
|
||||||
isBatchDecrypting: state.isBatchDecrypting,
|
isBatchDecrypting: state.isBatchDecrypting,
|
||||||
batchDecryptProgress: state.progress,
|
|
||||||
startDecrypt: state.startDecrypt,
|
startDecrypt: state.startDecrypt,
|
||||||
updateDecryptProgress: state.updateProgress,
|
updateDecryptProgress: state.updateProgress,
|
||||||
finishDecrypt: state.finishDecrypt,
|
updateDecryptTaskStatus: state.setTaskStatus,
|
||||||
setShowBatchDecryptToast: state.setShowToast
|
finishDecrypt: state.finishDecrypt
|
||||||
})))
|
})))
|
||||||
const [showBatchConfirm, setShowBatchConfirm] = useState(false)
|
const [showBatchConfirm, setShowBatchConfirm] = useState(false)
|
||||||
const [batchVoiceCount, setBatchVoiceCount] = useState(0)
|
const [batchVoiceCount, setBatchVoiceCount] = useState(0)
|
||||||
@@ -4853,7 +4865,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
const candidates = [...head, ...tail]
|
const candidates = [...head, ...tail]
|
||||||
const queued = preloadImageKeysRef.current
|
const queued = preloadImageKeysRef.current
|
||||||
const seen = new Set<string>()
|
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) {
|
for (const msg of candidates) {
|
||||||
if (payloads.length >= maxPreload) break
|
if (payloads.length >= maxPreload) break
|
||||||
if (msg.localType !== 3) continue
|
if (msg.localType !== 3) continue
|
||||||
@@ -4867,11 +4879,14 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
payloads.push({
|
payloads.push({
|
||||||
sessionId: currentSessionId,
|
sessionId: currentSessionId,
|
||||||
imageMd5: msg.imageMd5 || undefined,
|
imageMd5: msg.imageMd5 || undefined,
|
||||||
imageDatName: msg.imageDatName
|
imageDatName: msg.imageDatName,
|
||||||
|
createTime: msg.createTime
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (payloads.length > 0) {
|
if (payloads.length > 0) {
|
||||||
window.electronAPI.image.preload(payloads).catch(() => { })
|
window.electronAPI.image.preload(payloads, {
|
||||||
|
allowCacheIndex: false
|
||||||
|
}).catch(() => { })
|
||||||
}
|
}
|
||||||
}, [currentSessionId, messages])
|
}, [currentSessionId, messages])
|
||||||
|
|
||||||
@@ -5712,22 +5727,74 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
if (!session) return
|
if (!session) return
|
||||||
|
|
||||||
const taskType = batchVoiceTaskType
|
const taskType = batchVoiceTaskType
|
||||||
startTranscribe(voiceMessages.length, session.displayName || session.username, taskType)
|
const totalVoices = voiceMessages.length
|
||||||
|
const taskVerb = taskType === 'decrypt' ? '语音解密' : '语音转写'
|
||||||
if (taskType === 'transcribe') {
|
|
||||||
// 检查模型状态
|
|
||||||
const modelStatus = await window.electronAPI.whisper.getModelStatus()
|
|
||||||
if (!modelStatus?.exists) {
|
|
||||||
alert('SenseVoice 模型未下载,请先在设置中下载模型')
|
|
||||||
finishTranscribe(0, 0)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let successCount = 0
|
let successCount = 0
|
||||||
let failCount = 0
|
let failCount = 0
|
||||||
let completedCount = 0
|
let completedCount = 0
|
||||||
const concurrency = taskType === 'decrypt' ? 12 : 10
|
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) => {
|
const runOne = async (msg: Message) => {
|
||||||
try {
|
try {
|
||||||
@@ -5751,20 +5818,74 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < voiceMessages.length; i += concurrency) {
|
try {
|
||||||
const batch = voiceMessages.slice(i, i + concurrency)
|
if (taskType === 'transcribe') {
|
||||||
const results = await Promise.all(batch.map(msg => runOne(msg)))
|
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++
|
if (result.success) successCount++
|
||||||
else failCount++
|
else failCount++
|
||||||
completedCount++
|
completedCount++
|
||||||
updateProgress(completedCount, voiceMessages.length)
|
updateProgress(completedCount, totalVoices)
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
finishTranscribe(successCount, failCount)
|
for (const msg of voiceMessages) {
|
||||||
}, [sessions, currentSessionId, batchSelectedDates, batchVoiceMessages, batchVoiceTaskType, startTranscribe, updateProgress, finishTranscribe])
|
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(() => {
|
const batchCountByDate = useMemo(() => {
|
||||||
@@ -5827,45 +5948,175 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
setBatchImageDates([])
|
setBatchImageDates([])
|
||||||
setBatchImageSelectedDates(new Set())
|
setBatchImageSelectedDates(new Set())
|
||||||
|
|
||||||
startDecrypt(images.length, session.displayName || session.username)
|
const totalImages = images.length
|
||||||
|
|
||||||
let successCount = 0
|
let successCount = 0
|
||||||
let failCount = 0
|
let failCount = 0
|
||||||
|
let notFoundCount = 0
|
||||||
|
let decryptFailedCount = 0
|
||||||
let completed = 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 concurrency = batchDecryptConcurrency
|
||||||
|
|
||||||
const decryptOne = async (img: typeof images[0]) => {
|
const decryptOne = async (img: typeof images[0]) => {
|
||||||
|
if (controlState.cancelRequested) return
|
||||||
try {
|
try {
|
||||||
const r = await window.electronAPI.image.decrypt({
|
const r = await window.electronAPI.image.decrypt({
|
||||||
sessionId: session.username,
|
sessionId: session.username,
|
||||||
imageMd5: img.imageMd5,
|
imageMd5: img.imageMd5,
|
||||||
imageDatName: img.imageDatName,
|
imageDatName: img.imageDatName,
|
||||||
force: true
|
createTime: img.createTime,
|
||||||
|
force: true,
|
||||||
|
preferFilePath: true,
|
||||||
|
hardlinkOnly: true,
|
||||||
|
disableUpdateCheck: true,
|
||||||
|
suppressEvents: true
|
||||||
})
|
})
|
||||||
if (r?.success) successCount++
|
if (r?.success) successCount++
|
||||||
else failCount++
|
else {
|
||||||
|
failCount++
|
||||||
|
if (r?.failureKind === 'decrypt_failed') decryptFailedCount++
|
||||||
|
else notFoundCount++
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
failCount++
|
failCount++
|
||||||
|
notFoundCount++
|
||||||
}
|
}
|
||||||
completed++
|
completed++
|
||||||
updateDecryptProgress(completed, images.length)
|
updateDecryptProgress(completed, totalImages)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 并发池:同时跑 concurrency 个任务
|
|
||||||
const pool = new Set<Promise<void>>()
|
const pool = new Set<Promise<void>>()
|
||||||
for (const img of images) {
|
for (const img of images) {
|
||||||
const p = decryptOne(img).then(() => { pool.delete(p) })
|
if (controlState.cancelRequested) break
|
||||||
pool.add(p)
|
await waitIfPaused()
|
||||||
|
if (controlState.cancelRequested) break
|
||||||
if (pool.size >= concurrency) {
|
if (pool.size >= concurrency) {
|
||||||
await Promise.race(pool)
|
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) {
|
while (pool.size > 0) {
|
||||||
await Promise.all(pool)
|
await Promise.race(pool)
|
||||||
}
|
}
|
||||||
|
|
||||||
finishDecrypt(successCount, failCount)
|
if (controlState.cancelRequested) {
|
||||||
}, [batchImageMessages, batchImageSelectedDates, batchDecryptConcurrency, currentSessionId, finishDecrypt, sessions, startDecrypt, updateDecryptProgress])
|
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 batchImageCountByDate = useMemo(() => {
|
||||||
const map = new Map<string, number>()
|
const map = new Map<string, number>()
|
||||||
@@ -6600,16 +6851,10 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
{!standaloneSessionWindow && (
|
{!standaloneSessionWindow && (
|
||||||
<button
|
<button
|
||||||
className={`icon-btn batch-transcribe-btn${isBatchTranscribing ? ' transcribing' : ''}`}
|
className={`icon-btn batch-transcribe-btn${isBatchTranscribing ? ' transcribing' : ''}`}
|
||||||
onClick={() => {
|
onClick={handleBatchTranscribe}
|
||||||
if (isBatchTranscribing) {
|
|
||||||
setShowBatchProgress(true)
|
|
||||||
} else {
|
|
||||||
handleBatchTranscribe()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={!currentSessionId}
|
disabled={!currentSessionId}
|
||||||
title={isBatchTranscribing
|
title={isBatchTranscribing
|
||||||
? `${runningBatchVoiceTaskType === 'decrypt' ? '批量语音解密' : '批量转写'}中 (${batchTranscribeProgress.current}/${batchTranscribeProgress.total}),点击查看进度`
|
? `${runningBatchVoiceTaskType === 'decrypt' ? '批量语音解密' : '批量转写'}中,可在导出页任务中心查看进度`
|
||||||
: '批量语音处理(解密/转文字)'}
|
: '批量语音处理(解密/转文字)'}
|
||||||
>
|
>
|
||||||
{isBatchTranscribing ? (
|
{isBatchTranscribing ? (
|
||||||
@@ -6622,16 +6867,10 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
{!standaloneSessionWindow && (
|
{!standaloneSessionWindow && (
|
||||||
<button
|
<button
|
||||||
className={`icon-btn batch-decrypt-btn${isBatchDecrypting ? ' transcribing' : ''}`}
|
className={`icon-btn batch-decrypt-btn${isBatchDecrypting ? ' transcribing' : ''}`}
|
||||||
onClick={() => {
|
onClick={handleBatchDecrypt}
|
||||||
if (isBatchDecrypting) {
|
|
||||||
setShowBatchDecryptToast(true)
|
|
||||||
} else {
|
|
||||||
handleBatchDecrypt()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={!currentSessionId}
|
disabled={!currentSessionId}
|
||||||
title={isBatchDecrypting
|
title={isBatchDecrypting
|
||||||
? `批量解密中 (${batchDecryptProgress.current}/${batchDecryptProgress.total}),点击查看进度`
|
? '批量解密中,可在导出页任务中心查看进度'
|
||||||
: '批量解密图片'}
|
: '批量解密图片'}
|
||||||
>
|
>
|
||||||
{isBatchDecrypting ? (
|
{isBatchDecrypting ? (
|
||||||
@@ -7309,8 +7548,8 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
<AlertCircle size={16} />
|
<AlertCircle size={16} />
|
||||||
<span>
|
<span>
|
||||||
{batchVoiceTaskType === 'decrypt'
|
{batchVoiceTaskType === 'decrypt'
|
||||||
? '批量解密会预先缓存语音数据,之后播放和转写会更快。解密过程中可以继续使用其他功能。'
|
? '批量解密会预先缓存语音数据,之后播放和转写会更快。解密过程中可以继续使用其他功能,进度会写入导出页任务中心。'
|
||||||
: '批量转写可能需要较长时间,转写过程中可以继续使用其他功能。已转写过的语音会自动跳过。'}
|
: '批量转写可能需要较长时间,转写过程中可以继续使用其他功能。已转写过的语音会自动跳过,进度会写入导出页任务中心。'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -7377,17 +7616,17 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
className={`batch-concurrency-trigger ${showConcurrencyDropdown ? 'open' : ''}`}
|
className={`batch-concurrency-trigger ${showConcurrencyDropdown ? 'open' : ''}`}
|
||||||
onClick={() => setShowConcurrencyDropdown(!showConcurrencyDropdown)}
|
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} />
|
<ChevronDown size={14} />
|
||||||
</button>
|
</button>
|
||||||
{showConcurrencyDropdown && (
|
{showConcurrencyDropdown && (
|
||||||
<div className="batch-concurrency-dropdown">
|
<div className="batch-concurrency-dropdown">
|
||||||
{[
|
{[
|
||||||
{ value: 1, label: '1(最慢,最稳)' },
|
{ value: 1, label: '1' },
|
||||||
{ value: 3, label: '3' },
|
{ value: 3, label: '3' },
|
||||||
{ value: 6, label: '6(推荐)' },
|
{ value: 6, label: '6' },
|
||||||
{ value: 10, label: '10' },
|
{ value: 10, label: '10' },
|
||||||
{ value: 20, label: '20(最快,可能卡顿)' },
|
{ value: 20, label: '20' },
|
||||||
].map(opt => (
|
].map(opt => (
|
||||||
<button
|
<button
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
@@ -7405,7 +7644,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="batch-warning">
|
<div className="batch-warning">
|
||||||
<AlertCircle size={16} />
|
<AlertCircle size={16} />
|
||||||
<span>批量解密可能需要较长时间,进行中会在右下角显示非阻塞进度浮层。</span>
|
<span>批量解密可能需要较长时间,进度会自动写入导出页任务中心(含准备阶段状态)。</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="batch-modal-footer">
|
<div className="batch-modal-footer">
|
||||||
@@ -7768,7 +8007,13 @@ const emojiDataUrlCache = new Map<string, string>()
|
|||||||
const imageDataUrlCache = new Map<string, string>()
|
const imageDataUrlCache = new Map<string, string>()
|
||||||
const voiceDataUrlCache = new Map<string, string>()
|
const voiceDataUrlCache = new Map<string, string>()
|
||||||
const voiceTranscriptCache = 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 imageDecryptInFlight = new Map<string, Promise<SharedImageDecryptResult>>()
|
||||||
const senderAvatarCache = new Map<string, { avatarUrl?: string; displayName?: string }>()
|
const senderAvatarCache = new Map<string, { avatarUrl?: string; displayName?: string }>()
|
||||||
const senderAvatarLoading = new Map<string, Promise<{ avatarUrl?: string; displayName?: string } | null>>()
|
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 imageCacheKey = message.imageMd5 || message.imageDatName || `local:${message.localId}`
|
||||||
const [imageLocalPath, setImageLocalPath] = useState<string | undefined>(
|
const [imageLocalPath, setImageLocalPath] = useState<string | undefined>(
|
||||||
() => imageDataUrlCache.get(imageCacheKey)
|
() => toRenderableImageSrc(imageDataUrlCache.get(imageCacheKey))
|
||||||
)
|
)
|
||||||
const voiceIdentityKey = buildVoiceCacheIdentity(session.username, message)
|
const voiceIdentityKey = buildVoiceCacheIdentity(session.username, message)
|
||||||
const voiceCacheKey = `voice:${voiceIdentityKey}`
|
const voiceCacheKey = `voice:${voiceIdentityKey}`
|
||||||
@@ -7904,6 +8149,7 @@ function MessageBubble({
|
|||||||
const imageUpdateCheckedRef = useRef<string | null>(null)
|
const imageUpdateCheckedRef = useRef<string | null>(null)
|
||||||
const imageClickTimerRef = useRef<number | null>(null)
|
const imageClickTimerRef = useRef<number | null>(null)
|
||||||
const imageContainerRef = useRef<HTMLDivElement>(null)
|
const imageContainerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const imageElementRef = useRef<HTMLImageElement | null>(null)
|
||||||
const emojiContainerRef = useRef<HTMLDivElement>(null)
|
const emojiContainerRef = useRef<HTMLDivElement>(null)
|
||||||
const imageResizeBaselineRef = useRef<number | null>(null)
|
const imageResizeBaselineRef = useRef<number | null>(null)
|
||||||
const emojiResizeBaselineRef = useRef<number | null>(null)
|
const emojiResizeBaselineRef = useRef<number | null>(null)
|
||||||
@@ -8260,19 +8506,27 @@ function MessageBubble({
|
|||||||
sessionId: session.username,
|
sessionId: session.username,
|
||||||
imageMd5: message.imageMd5 || undefined,
|
imageMd5: message.imageMd5 || undefined,
|
||||||
imageDatName: message.imageDatName,
|
imageDatName: message.imageDatName,
|
||||||
force: forceUpdate
|
createTime: message.createTime,
|
||||||
|
force: forceUpdate,
|
||||||
|
preferFilePath: true,
|
||||||
|
hardlinkOnly: true
|
||||||
}) as SharedImageDecryptResult
|
}) as SharedImageDecryptResult
|
||||||
})
|
})
|
||||||
if (result.success && result.localPath) {
|
if (result.success && result.localPath) {
|
||||||
imageDataUrlCache.set(imageCacheKey, result.localPath)
|
const renderPath = toRenderableImageSrc(result.localPath)
|
||||||
if (imageLocalPath !== result.localPath) {
|
if (!renderPath) {
|
||||||
|
if (!silent) setImageError(true)
|
||||||
|
return { success: false }
|
||||||
|
}
|
||||||
|
imageDataUrlCache.set(imageCacheKey, renderPath)
|
||||||
|
if (imageLocalPath !== renderPath) {
|
||||||
captureImageResizeBaseline()
|
captureImageResizeBaseline()
|
||||||
lockImageStageHeight()
|
lockImageStageHeight()
|
||||||
}
|
}
|
||||||
setImageLocalPath(result.localPath)
|
setImageLocalPath(renderPath)
|
||||||
setImageHasUpdate(false)
|
setImageHasUpdate(false)
|
||||||
if (result.liveVideoPath) setImageLiveVideoPath(result.liveVideoPath)
|
if (result.liveVideoPath) setImageLiveVideoPath(result.liveVideoPath)
|
||||||
return result
|
return { ...result, localPath: renderPath }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8297,7 +8551,7 @@ function MessageBubble({
|
|||||||
imageDecryptPendingRef.current = false
|
imageDecryptPendingRef.current = false
|
||||||
}
|
}
|
||||||
return { success: 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(() => {
|
const triggerForceHd = useCallback(() => {
|
||||||
if (!message.imageMd5 && !message.imageDatName) return
|
if (!message.imageMd5 && !message.imageDatName) return
|
||||||
@@ -8352,24 +8606,29 @@ function MessageBubble({
|
|||||||
const resolved = await window.electronAPI.image.resolveCache({
|
const resolved = await window.electronAPI.image.resolveCache({
|
||||||
sessionId: session.username,
|
sessionId: session.username,
|
||||||
imageMd5: message.imageMd5 || undefined,
|
imageMd5: message.imageMd5 || undefined,
|
||||||
imageDatName: message.imageDatName
|
imageDatName: message.imageDatName,
|
||||||
|
createTime: message.createTime,
|
||||||
|
preferFilePath: true,
|
||||||
|
hardlinkOnly: true
|
||||||
})
|
})
|
||||||
if (resolved?.success && resolved.localPath) {
|
if (resolved?.success && resolved.localPath) {
|
||||||
finalImagePath = resolved.localPath
|
const renderPath = toRenderableImageSrc(resolved.localPath)
|
||||||
|
if (!renderPath) return
|
||||||
|
finalImagePath = renderPath
|
||||||
finalLiveVideoPath = resolved.liveVideoPath || finalLiveVideoPath
|
finalLiveVideoPath = resolved.liveVideoPath || finalLiveVideoPath
|
||||||
imageDataUrlCache.set(imageCacheKey, resolved.localPath)
|
imageDataUrlCache.set(imageCacheKey, renderPath)
|
||||||
if (imageLocalPath !== resolved.localPath) {
|
if (imageLocalPath !== renderPath) {
|
||||||
captureImageResizeBaseline()
|
captureImageResizeBaseline()
|
||||||
lockImageStageHeight()
|
lockImageStageHeight()
|
||||||
}
|
}
|
||||||
setImageLocalPath(resolved.localPath)
|
setImageLocalPath(renderPath)
|
||||||
if (resolved.liveVideoPath) setImageLiveVideoPath(resolved.liveVideoPath)
|
if (resolved.liveVideoPath) setImageLiveVideoPath(resolved.liveVideoPath)
|
||||||
setImageHasUpdate(Boolean(resolved.hasUpdate))
|
setImageHasUpdate(Boolean(resolved.hasUpdate))
|
||||||
}
|
}
|
||||||
} catch { }
|
} catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
void window.electronAPI.window.openImageViewerWindow(finalImagePath, finalLiveVideoPath)
|
void window.electronAPI.window.openImageViewerWindow(toRenderableImageSrc(finalImagePath) || finalImagePath, finalLiveVideoPath)
|
||||||
}, [
|
}, [
|
||||||
imageLiveVideoPath,
|
imageLiveVideoPath,
|
||||||
imageLocalPath,
|
imageLocalPath,
|
||||||
@@ -8378,6 +8637,7 @@ function MessageBubble({
|
|||||||
lockImageStageHeight,
|
lockImageStageHeight,
|
||||||
message.imageDatName,
|
message.imageDatName,
|
||||||
message.imageMd5,
|
message.imageMd5,
|
||||||
|
message.createTime,
|
||||||
requestImageDecrypt,
|
requestImageDecrypt,
|
||||||
session.username
|
session.username
|
||||||
])
|
])
|
||||||
@@ -8391,8 +8651,19 @@ function MessageBubble({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isImage) return
|
||||||
|
if (!imageLocalPath) {
|
||||||
setImageLoaded(false)
|
setImageLoaded(false)
|
||||||
}, [imageLocalPath])
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 某些 file:// 缓存图在 src 切换时可能不会稳定触发 onLoad,
|
||||||
|
// 这里用 complete/naturalWidth 做一次兜底,避免图片进入 pending 隐身态。
|
||||||
|
const img = imageElementRef.current
|
||||||
|
if (img && img.complete && img.naturalWidth > 0) {
|
||||||
|
setImageLoaded(true)
|
||||||
|
}
|
||||||
|
}, [isImage, imageLocalPath])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (imageLoading) return
|
if (imageLoading) return
|
||||||
@@ -8401,7 +8672,7 @@ function MessageBubble({
|
|||||||
}, [imageError, imageLoading, imageLocalPath])
|
}, [imageError, imageLoading, imageLocalPath])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isImage || imageLoading) return
|
if (!isImage || imageLoading || !imageInView) return
|
||||||
if (!message.imageMd5 && !message.imageDatName) return
|
if (!message.imageMd5 && !message.imageDatName) return
|
||||||
if (imageUpdateCheckedRef.current === imageCacheKey) return
|
if (imageUpdateCheckedRef.current === imageCacheKey) return
|
||||||
imageUpdateCheckedRef.current = imageCacheKey
|
imageUpdateCheckedRef.current = imageCacheKey
|
||||||
@@ -8409,15 +8680,21 @@ function MessageBubble({
|
|||||||
window.electronAPI.image.resolveCache({
|
window.electronAPI.image.resolveCache({
|
||||||
sessionId: session.username,
|
sessionId: session.username,
|
||||||
imageMd5: message.imageMd5 || undefined,
|
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 }) => {
|
}).then((result: { success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string }) => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
if (result.success && result.localPath) {
|
if (result.success && result.localPath) {
|
||||||
imageDataUrlCache.set(imageCacheKey, result.localPath)
|
const renderPath = toRenderableImageSrc(result.localPath)
|
||||||
if (!imageLocalPath || imageLocalPath !== result.localPath) {
|
if (!renderPath) return
|
||||||
|
imageDataUrlCache.set(imageCacheKey, renderPath)
|
||||||
|
if (!imageLocalPath || imageLocalPath !== renderPath) {
|
||||||
captureImageResizeBaseline()
|
captureImageResizeBaseline()
|
||||||
lockImageStageHeight()
|
lockImageStageHeight()
|
||||||
setImageLocalPath(result.localPath)
|
setImageLocalPath(renderPath)
|
||||||
setImageError(false)
|
setImageError(false)
|
||||||
}
|
}
|
||||||
if (result.liveVideoPath) setImageLiveVideoPath(result.liveVideoPath)
|
if (result.liveVideoPath) setImageLiveVideoPath(result.liveVideoPath)
|
||||||
@@ -8427,7 +8704,7 @@ function MessageBubble({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
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(() => {
|
useEffect(() => {
|
||||||
if (!isImage) return
|
if (!isImage) return
|
||||||
@@ -8455,15 +8732,17 @@ function MessageBubble({
|
|||||||
(payload.imageMd5 && payload.imageMd5 === message.imageMd5) ||
|
(payload.imageMd5 && payload.imageMd5 === message.imageMd5) ||
|
||||||
(payload.imageDatName && payload.imageDatName === message.imageDatName)
|
(payload.imageDatName && payload.imageDatName === message.imageDatName)
|
||||||
if (matchesCacheKey) {
|
if (matchesCacheKey) {
|
||||||
|
const renderPath = toRenderableImageSrc(payload.localPath)
|
||||||
|
if (!renderPath) return
|
||||||
const cachedPath = imageDataUrlCache.get(imageCacheKey)
|
const cachedPath = imageDataUrlCache.get(imageCacheKey)
|
||||||
if (cachedPath !== payload.localPath) {
|
if (cachedPath !== renderPath) {
|
||||||
imageDataUrlCache.set(imageCacheKey, payload.localPath)
|
imageDataUrlCache.set(imageCacheKey, renderPath)
|
||||||
}
|
}
|
||||||
if (imageLocalPath !== payload.localPath) {
|
if (imageLocalPath !== renderPath) {
|
||||||
captureImageResizeBaseline()
|
captureImageResizeBaseline()
|
||||||
lockImageStageHeight()
|
lockImageStageHeight()
|
||||||
}
|
}
|
||||||
setImageLocalPath((prev) => (prev === payload.localPath ? prev : payload.localPath))
|
setImageLocalPath((prev) => (prev === renderPath ? prev : renderPath))
|
||||||
setImageError(false)
|
setImageError(false)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -9093,6 +9372,7 @@ function MessageBubble({
|
|||||||
<>
|
<>
|
||||||
<div className="image-message-wrapper">
|
<div className="image-message-wrapper">
|
||||||
<img
|
<img
|
||||||
|
ref={imageElementRef}
|
||||||
src={imageLocalPath}
|
src={imageLocalPath}
|
||||||
alt="图片"
|
alt="图片"
|
||||||
className={`image-message ${imageLoaded ? 'ready' : 'pending'}`}
|
className={`image-message ${imageLoaded ? 'ready' : 'pending'}`}
|
||||||
|
|||||||
@@ -410,6 +410,17 @@
|
|||||||
background: rgba(245, 158, 11, 0.14);
|
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 {
|
&.status-completed {
|
||||||
color: #166534;
|
color: #166534;
|
||||||
background: rgba(34, 197, 94, 0.14);
|
background: rgba(34, 197, 94, 0.14);
|
||||||
@@ -5817,8 +5828,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 终止时间选择器 */
|
/* 首次触发/终止时间选择器 */
|
||||||
.automation-stopat-picker {
|
.automation-stopat-picker,
|
||||||
|
.automation-first-trigger-picker {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import {
|
|||||||
MessageSquare,
|
MessageSquare,
|
||||||
MessageSquareText,
|
MessageSquareText,
|
||||||
Mic,
|
Mic,
|
||||||
|
Pause,
|
||||||
|
Play,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
Square,
|
Square,
|
||||||
@@ -48,6 +50,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
requestCancelBackgroundTask,
|
requestCancelBackgroundTask,
|
||||||
requestCancelBackgroundTasks,
|
requestCancelBackgroundTasks,
|
||||||
|
requestPauseBackgroundTask,
|
||||||
|
requestResumeBackgroundTask,
|
||||||
subscribeBackgroundTasks
|
subscribeBackgroundTasks
|
||||||
} from '../services/backgroundTaskMonitor'
|
} from '../services/backgroundTaskMonitor'
|
||||||
import { useContactTypeCountsStore } from '../stores/contactTypeCountsStore'
|
import { useContactTypeCountsStore } from '../stores/contactTypeCountsStore'
|
||||||
@@ -105,7 +109,6 @@ interface ExportOptions {
|
|||||||
txtColumns: string[]
|
txtColumns: string[]
|
||||||
displayNamePreference: DisplayNamePreference
|
displayNamePreference: DisplayNamePreference
|
||||||
exportConcurrency: number
|
exportConcurrency: number
|
||||||
imageDeepSearchOnMiss: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SessionRow extends AppChatSession {
|
interface SessionRow extends AppChatSession {
|
||||||
@@ -209,6 +212,8 @@ interface AutomationTaskDraft {
|
|||||||
dateRangeConfig: ExportAutomationDateRangeConfig | string | null
|
dateRangeConfig: ExportAutomationDateRangeConfig | string | null
|
||||||
intervalDays: number
|
intervalDays: number
|
||||||
intervalHours: number
|
intervalHours: number
|
||||||
|
firstTriggerAtEnabled: boolean
|
||||||
|
firstTriggerAtValue: string
|
||||||
stopAtEnabled: boolean
|
stopAtEnabled: boolean
|
||||||
stopAtValue: string
|
stopAtValue: string
|
||||||
maxRunsEnabled: boolean
|
maxRunsEnabled: boolean
|
||||||
@@ -218,6 +223,7 @@ interface AutomationTaskDraft {
|
|||||||
const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content']
|
const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content']
|
||||||
const DETAIL_PRECISE_REFRESH_COOLDOWN_MS = 10 * 60 * 1000
|
const DETAIL_PRECISE_REFRESH_COOLDOWN_MS = 10 * 60 * 1000
|
||||||
const TASK_PERFORMANCE_UPDATE_MIN_INTERVAL_MS = 900
|
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_PREFETCH_ROWS = 10
|
||||||
const SESSION_MEDIA_METRIC_BATCH_SIZE = 8
|
const SESSION_MEDIA_METRIC_BATCH_SIZE = 8
|
||||||
const SESSION_MEDIA_METRIC_BACKGROUND_FEED_SIZE = 48
|
const SESSION_MEDIA_METRIC_BACKGROUND_FEED_SIZE = 48
|
||||||
@@ -249,6 +255,8 @@ const backgroundTaskSourceLabels: Record<string, string> = {
|
|||||||
|
|
||||||
const backgroundTaskStatusLabels: Record<BackgroundTaskRecord['status'], string> = {
|
const backgroundTaskStatusLabels: Record<BackgroundTaskRecord['status'], string> = {
|
||||||
running: '运行中',
|
running: '运行中',
|
||||||
|
pause_requested: '中断中',
|
||||||
|
paused: '已中断',
|
||||||
cancel_requested: '停止中',
|
cancel_requested: '停止中',
|
||||||
completed: '已完成',
|
completed: '已完成',
|
||||||
failed: '失败',
|
failed: '失败',
|
||||||
@@ -322,6 +330,69 @@ const createEmptyProgress = (): TaskProgress => ({
|
|||||||
mediaBytesWritten: 0
|
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 => ({
|
const createEmptyTaskPerformance = (): TaskPerformance => ({
|
||||||
stages: {
|
stages: {
|
||||||
collect: 0,
|
collect: 0,
|
||||||
@@ -336,6 +407,15 @@ const isTextBatchTask = (task: ExportTask): boolean => (
|
|||||||
task.payload.scope === 'content' && task.payload.contentType === 'text'
|
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 => {
|
const resolvePerfStageByPhase = (phase?: ExportProgress['phase']): TaskPerfStage => {
|
||||||
if (phase === 'preparing') return 'collect'
|
if (phase === 'preparing') return 'collect'
|
||||||
if (phase === 'writing') return 'write'
|
if (phase === 'writing') return 'write'
|
||||||
@@ -500,6 +580,35 @@ const getTaskStatusLabel = (task: ExportTask): string => {
|
|||||||
return '失败'
|
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 formatAbsoluteDate = (timestamp: number): string => {
|
||||||
const d = new Date(timestamp)
|
const d = new Date(timestamp)
|
||||||
const y = d.getFullYear()
|
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 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 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 resolveAutomationIntervalMs = (schedule: ExportAutomationSchedule): number => {
|
||||||
const days = normalizeAutomationIntervalDays(schedule.intervalDays)
|
const days = normalizeAutomationIntervalDays(schedule.intervalDays)
|
||||||
@@ -644,6 +758,16 @@ const resolveAutomationIntervalMs = (schedule: ExportAutomationSchedule): number
|
|||||||
return totalHours * 60 * 60 * 1000
|
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 formatAutomationScheduleLabel = (schedule: ExportAutomationSchedule): string => {
|
||||||
const days = normalizeAutomationIntervalDays(schedule.intervalDays)
|
const days = normalizeAutomationIntervalDays(schedule.intervalDays)
|
||||||
const hours = normalizeAutomationIntervalHours(schedule.intervalHours)
|
const hours = normalizeAutomationIntervalHours(schedule.intervalHours)
|
||||||
@@ -657,12 +781,60 @@ const resolveAutomationDueScheduleKey = (task: ExportAutomationTask, now: Date):
|
|||||||
const intervalMs = resolveAutomationIntervalMs(task.schedule)
|
const intervalMs = resolveAutomationIntervalMs(task.schedule)
|
||||||
if (intervalMs <= 0) return null
|
if (intervalMs <= 0) return null
|
||||||
const nowMs = now.getTime()
|
const nowMs = now.getTime()
|
||||||
const anchorAt = Math.max(
|
const lastTriggeredAt = Math.max(0, Math.floor(Number(task.runState?.lastTriggeredAt || 0)))
|
||||||
0,
|
if (lastTriggeredAt > 0) {
|
||||||
Number(task.runState?.lastTriggeredAt || 0) || Number(task.createdAt || 0)
|
if (nowMs < lastTriggeredAt + intervalMs) return null
|
||||||
)
|
return `interval:${lastTriggeredAt}:${Math.floor((nowMs - lastTriggeredAt) / intervalMs)}`
|
||||||
if (nowMs < anchorAt + intervalMs) return null
|
}
|
||||||
return `interval:${anchorAt}:${Math.floor((nowMs - anchorAt) / 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 => {
|
const toDateTimeLocalValue = (timestamp: number): string => {
|
||||||
@@ -803,9 +975,9 @@ const formatAutomationStopCondition = (task: ExportAutomationTask): string => {
|
|||||||
const resolveAutomationNextTriggerAt = (task: ExportAutomationTask): number | null => {
|
const resolveAutomationNextTriggerAt = (task: ExportAutomationTask): number | null => {
|
||||||
const intervalMs = resolveAutomationIntervalMs(task.schedule)
|
const intervalMs = resolveAutomationIntervalMs(task.schedule)
|
||||||
if (intervalMs <= 0) return null
|
if (intervalMs <= 0) return null
|
||||||
const anchorAt = Math.max(0, Number(task.runState?.lastTriggeredAt || 0) || Number(task.createdAt || 0))
|
const lastTriggeredAt = Math.max(0, Math.floor(Number(task.runState?.lastTriggeredAt || 0)))
|
||||||
if (!anchorAt) return null
|
if (lastTriggeredAt > 0) return lastTriggeredAt + intervalMs
|
||||||
return anchorAt + intervalMs
|
return resolveAutomationInitialTriggerAt(task)
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatAutomationCurrentState = (
|
const formatAutomationCurrentState = (
|
||||||
@@ -1589,25 +1761,40 @@ const SectionInfoTooltip = memo(function SectionInfoTooltip({
|
|||||||
interface TaskCenterModalProps {
|
interface TaskCenterModalProps {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
tasks: ExportTask[]
|
tasks: ExportTask[]
|
||||||
|
chatBackgroundTasks: BackgroundTaskRecord[]
|
||||||
taskRunningCount: number
|
taskRunningCount: number
|
||||||
taskQueuedCount: number
|
taskQueuedCount: number
|
||||||
expandedPerfTaskId: string | null
|
expandedPerfTaskId: string | null
|
||||||
nowTick: number
|
nowTick: number
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onTogglePerfTask: (taskId: string) => void
|
onTogglePerfTask: (taskId: string) => void
|
||||||
|
onPauseBackgroundTask: (taskId: string) => void
|
||||||
|
onResumeBackgroundTask: (taskId: string) => void
|
||||||
|
onCancelBackgroundTask: (taskId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const TaskCenterModal = memo(function TaskCenterModal({
|
const TaskCenterModal = memo(function TaskCenterModal({
|
||||||
isOpen,
|
isOpen,
|
||||||
tasks,
|
tasks,
|
||||||
|
chatBackgroundTasks,
|
||||||
taskRunningCount,
|
taskRunningCount,
|
||||||
taskQueuedCount,
|
taskQueuedCount,
|
||||||
expandedPerfTaskId,
|
expandedPerfTaskId,
|
||||||
nowTick,
|
nowTick,
|
||||||
onClose,
|
onClose,
|
||||||
onTogglePerfTask
|
onTogglePerfTask,
|
||||||
|
onPauseBackgroundTask,
|
||||||
|
onResumeBackgroundTask,
|
||||||
|
onCancelBackgroundTask
|
||||||
}: TaskCenterModalProps) {
|
}: TaskCenterModalProps) {
|
||||||
if (!isOpen) return null
|
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(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
@@ -1624,7 +1811,7 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
|||||||
<div className="task-center-modal-header">
|
<div className="task-center-modal-header">
|
||||||
<div className="task-center-modal-title">
|
<div className="task-center-modal-title">
|
||||||
<h3>任务中心</h3>
|
<h3>任务中心</h3>
|
||||||
<span>进行中 {taskRunningCount} · 排队 {taskQueuedCount} · 总计 {tasks.length}</span>
|
<span>导出进行中 {taskRunningCount} · 排队 {taskQueuedCount} · 聊天后台 {chatActiveTaskCount} · 总计 {totalTaskCount}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="close-icon-btn"
|
className="close-icon-btn"
|
||||||
@@ -1636,8 +1823,8 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="task-center-modal-body">
|
<div className="task-center-modal-body">
|
||||||
{tasks.length === 0 ? (
|
{totalTaskCount === 0 ? (
|
||||||
<div className="task-empty">暂无任务。点击会话导出或卡片导出后会在这里创建任务。</div>
|
<div className="task-empty">暂无任务。导出任务和聊天页批量语音/图片任务都会显示在这里。</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="task-list">
|
<div className="task-list">
|
||||||
{tasks.map(task => {
|
{tasks.map(task => {
|
||||||
@@ -1705,6 +1892,24 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
|||||||
const currentSessionRatio = task.progress.phaseTotal > 0
|
const currentSessionRatio = task.progress.phaseTotal > 0
|
||||||
? Math.max(0, Math.min(1, task.progress.phaseProgress / task.progress.phaseTotal))
|
? Math.max(0, Math.min(1, task.progress.phaseProgress / task.progress.phaseTotal))
|
||||||
: null
|
: 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 (
|
return (
|
||||||
<div key={task.id} className={`task-card ${task.status}`}>
|
<div key={task.id} className={`task-card ${task.status}`}>
|
||||||
<div className="task-main">
|
<div className="task-main">
|
||||||
@@ -1734,6 +1939,11 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{imageTimingLabel && task.status !== 'queued' && (
|
||||||
|
<div className="task-perf-summary">
|
||||||
|
<span>{imageTimingLabel}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{canShowPerfDetail && stageTotals && (
|
{canShowPerfDetail && stageTotals && (
|
||||||
<div className="task-perf-summary">
|
<div className="task-perf-summary">
|
||||||
<span>累计耗时 {formatDurationMs(stageTotalMs)}</span>
|
<span>累计耗时 {formatDurationMs(stageTotalMs)}</span>
|
||||||
@@ -1802,6 +2012,70 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
|||||||
</div>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1903,7 +2177,6 @@ function ExportPage() {
|
|||||||
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false)
|
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false)
|
||||||
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
|
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
|
||||||
const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2)
|
const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2)
|
||||||
const [exportDefaultImageDeepSearchOnMiss, setExportDefaultImageDeepSearchOnMiss] = useState(true)
|
|
||||||
|
|
||||||
const [options, setOptions] = useState<ExportOptions>({
|
const [options, setOptions] = useState<ExportOptions>({
|
||||||
format: 'json',
|
format: 'json',
|
||||||
@@ -1924,8 +2197,7 @@ function ExportPage() {
|
|||||||
excelCompactColumns: true,
|
excelCompactColumns: true,
|
||||||
txtColumns: defaultTxtColumns,
|
txtColumns: defaultTxtColumns,
|
||||||
displayNamePreference: 'remark',
|
displayNamePreference: 'remark',
|
||||||
exportConcurrency: 2,
|
exportConcurrency: 2
|
||||||
imageDeepSearchOnMiss: true
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const [exportDialog, setExportDialog] = useState<ExportDialogState>({
|
const [exportDialog, setExportDialog] = useState<ExportDialogState>({
|
||||||
@@ -2622,7 +2894,7 @@ function ExportPage() {
|
|||||||
automationTasksReadyRef.current = false
|
automationTasksReadyRef.current = false
|
||||||
let isReady = true
|
let isReady = true
|
||||||
try {
|
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.getExportPath(),
|
||||||
configService.getExportDefaultFormat(),
|
configService.getExportDefaultFormat(),
|
||||||
configService.getExportDefaultAvatars(),
|
configService.getExportDefaultAvatars(),
|
||||||
@@ -2631,7 +2903,6 @@ function ExportPage() {
|
|||||||
configService.getExportDefaultExcelCompactColumns(),
|
configService.getExportDefaultExcelCompactColumns(),
|
||||||
configService.getExportDefaultTxtColumns(),
|
configService.getExportDefaultTxtColumns(),
|
||||||
configService.getExportDefaultConcurrency(),
|
configService.getExportDefaultConcurrency(),
|
||||||
configService.getExportDefaultImageDeepSearchOnMiss(),
|
|
||||||
configService.getExportLastSessionRunMap(),
|
configService.getExportLastSessionRunMap(),
|
||||||
configService.getExportLastContentRunMap(),
|
configService.getExportLastContentRunMap(),
|
||||||
configService.getExportSessionRecordMap(),
|
configService.getExportSessionRecordMap(),
|
||||||
@@ -2671,7 +2942,6 @@ function ExportPage() {
|
|||||||
setExportDefaultVoiceAsText(savedVoiceAsText ?? false)
|
setExportDefaultVoiceAsText(savedVoiceAsText ?? false)
|
||||||
setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true)
|
setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true)
|
||||||
setExportDefaultConcurrency(savedConcurrency ?? 2)
|
setExportDefaultConcurrency(savedConcurrency ?? 2)
|
||||||
setExportDefaultImageDeepSearchOnMiss(savedImageDeepSearchOnMiss ?? true)
|
|
||||||
setExportDefaultFileNamingMode(savedFileNamingMode ?? 'classic')
|
setExportDefaultFileNamingMode(savedFileNamingMode ?? 'classic')
|
||||||
setAutomationTasks(automationTaskItem?.tasks || [])
|
setAutomationTasks(automationTaskItem?.tasks || [])
|
||||||
automationTasksReadyRef.current = true
|
automationTasksReadyRef.current = true
|
||||||
@@ -2709,8 +2979,7 @@ function ExportPage() {
|
|||||||
exportVoiceAsText: savedVoiceAsText ?? prev.exportVoiceAsText,
|
exportVoiceAsText: savedVoiceAsText ?? prev.exportVoiceAsText,
|
||||||
excelCompactColumns: savedExcelCompactColumns ?? prev.excelCompactColumns,
|
excelCompactColumns: savedExcelCompactColumns ?? prev.excelCompactColumns,
|
||||||
txtColumns,
|
txtColumns,
|
||||||
exportConcurrency: savedConcurrency ?? prev.exportConcurrency,
|
exportConcurrency: savedConcurrency ?? prev.exportConcurrency
|
||||||
imageDeepSearchOnMiss: savedImageDeepSearchOnMiss ?? prev.imageDeepSearchOnMiss
|
|
||||||
}))
|
}))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
isReady = false
|
isReady = false
|
||||||
@@ -4491,8 +4760,7 @@ function ExportPage() {
|
|||||||
maxFileSizeMb: prev.maxFileSizeMb,
|
maxFileSizeMb: prev.maxFileSizeMb,
|
||||||
exportVoiceAsText: exportDefaultVoiceAsText,
|
exportVoiceAsText: exportDefaultVoiceAsText,
|
||||||
excelCompactColumns: exportDefaultExcelCompactColumns,
|
excelCompactColumns: exportDefaultExcelCompactColumns,
|
||||||
exportConcurrency: exportDefaultConcurrency,
|
exportConcurrency: exportDefaultConcurrency
|
||||||
imageDeepSearchOnMiss: exportDefaultImageDeepSearchOnMiss
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.scope === 'sns') {
|
if (payload.scope === 'sns') {
|
||||||
@@ -4527,8 +4795,7 @@ function ExportPage() {
|
|||||||
exportDefaultAvatars,
|
exportDefaultAvatars,
|
||||||
exportDefaultMedia,
|
exportDefaultMedia,
|
||||||
exportDefaultVoiceAsText,
|
exportDefaultVoiceAsText,
|
||||||
exportDefaultConcurrency,
|
exportDefaultConcurrency
|
||||||
exportDefaultImageDeepSearchOnMiss
|
|
||||||
])
|
])
|
||||||
|
|
||||||
const closeExportDialog = useCallback(() => {
|
const closeExportDialog = useCallback(() => {
|
||||||
@@ -4755,7 +5022,6 @@ function ExportPage() {
|
|||||||
txtColumns: options.txtColumns,
|
txtColumns: options.txtColumns,
|
||||||
displayNamePreference: options.displayNamePreference,
|
displayNamePreference: options.displayNamePreference,
|
||||||
exportConcurrency: options.exportConcurrency,
|
exportConcurrency: options.exportConcurrency,
|
||||||
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
|
|
||||||
fileNamingMode: exportDefaultFileNamingMode,
|
fileNamingMode: exportDefaultFileNamingMode,
|
||||||
sessionLayout,
|
sessionLayout,
|
||||||
sessionNameWithTypePrefix,
|
sessionNameWithTypePrefix,
|
||||||
@@ -4834,6 +5100,7 @@ function ExportPage() {
|
|||||||
|
|
||||||
const openEditAutomationTaskDraft = useCallback((task: ExportAutomationTask) => {
|
const openEditAutomationTaskDraft = useCallback((task: ExportAutomationTask) => {
|
||||||
const schedule = task.schedule
|
const schedule = task.schedule
|
||||||
|
const firstTriggerAt = normalizeAutomationFirstTriggerAt(schedule.firstTriggerAt)
|
||||||
const stopAt = Number(task.stopCondition?.endAt || 0)
|
const stopAt = Number(task.stopCondition?.endAt || 0)
|
||||||
const maxRuns = Number(task.stopCondition?.maxRuns || 0)
|
const maxRuns = Number(task.stopCondition?.maxRuns || 0)
|
||||||
const resolvedRange = resolveAutomationDateRangeSelection(task.template.dateRangeConfig as any, new Date())
|
const resolvedRange = resolveAutomationDateRangeSelection(task.template.dateRangeConfig as any, new Date())
|
||||||
@@ -4854,6 +5121,8 @@ function ExportPage() {
|
|||||||
dateRangeConfig: task.template.dateRangeConfig,
|
dateRangeConfig: task.template.dateRangeConfig,
|
||||||
intervalDays: normalizeAutomationIntervalDays(schedule.intervalDays),
|
intervalDays: normalizeAutomationIntervalDays(schedule.intervalDays),
|
||||||
intervalHours: normalizeAutomationIntervalHours(schedule.intervalHours),
|
intervalHours: normalizeAutomationIntervalHours(schedule.intervalHours),
|
||||||
|
firstTriggerAtEnabled: firstTriggerAt > 0,
|
||||||
|
firstTriggerAtValue: firstTriggerAt > 0 ? toDateTimeLocalValue(firstTriggerAt) : '',
|
||||||
stopAtEnabled: stopAt > 0,
|
stopAtEnabled: stopAt > 0,
|
||||||
stopAtValue: stopAt > 0 ? toDateTimeLocalValue(stopAt) : '',
|
stopAtValue: stopAt > 0 ? toDateTimeLocalValue(stopAt) : '',
|
||||||
maxRunsEnabled: maxRuns > 0,
|
maxRunsEnabled: maxRuns > 0,
|
||||||
@@ -4959,7 +5228,18 @@ function ExportPage() {
|
|||||||
window.alert('执行间隔不能为 0,请至少设置天数或小时')
|
window.alert('执行间隔不能为 0,请至少设置天数或小时')
|
||||||
return
|
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
|
const stopAtTimestamp = automationTaskDraft.stopAtEnabled
|
||||||
? parseDateTimeLocalValue(automationTaskDraft.stopAtValue)
|
? parseDateTimeLocalValue(automationTaskDraft.stopAtValue)
|
||||||
: null
|
: null
|
||||||
@@ -5146,14 +5426,10 @@ function ExportPage() {
|
|||||||
const settledSessionIdsFromProgress = new Set<string>()
|
const settledSessionIdsFromProgress = new Set<string>()
|
||||||
const sessionMessageProgress = new Map<string, { exported: number; total: number; knownTotal: boolean }>()
|
const sessionMessageProgress = new Map<string, { exported: number; total: number; knownTotal: boolean }>()
|
||||||
let queuedProgressPayload: ExportProgress | null = null
|
let queuedProgressPayload: ExportProgress | null = null
|
||||||
let queuedProgressRaf: number | null = null
|
let queuedProgressSignature = ''
|
||||||
let queuedProgressTimer: number | null = null
|
let queuedProgressTimer: number | null = null
|
||||||
|
|
||||||
const clearQueuedProgress = () => {
|
const clearQueuedProgress = () => {
|
||||||
if (queuedProgressRaf !== null) {
|
|
||||||
window.cancelAnimationFrame(queuedProgressRaf)
|
|
||||||
queuedProgressRaf = null
|
|
||||||
}
|
|
||||||
if (queuedProgressTimer !== null) {
|
if (queuedProgressTimer !== null) {
|
||||||
window.clearTimeout(queuedProgressTimer)
|
window.clearTimeout(queuedProgressTimer)
|
||||||
queuedProgressTimer = null
|
queuedProgressTimer = null
|
||||||
@@ -5205,6 +5481,7 @@ function ExportPage() {
|
|||||||
if (!queuedProgressPayload) return
|
if (!queuedProgressPayload) return
|
||||||
const payload = queuedProgressPayload
|
const payload = queuedProgressPayload
|
||||||
queuedProgressPayload = null
|
queuedProgressPayload = null
|
||||||
|
queuedProgressSignature = ''
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const currentSessionId = String(payload.currentSessionId || '').trim()
|
const currentSessionId = String(payload.currentSessionId || '').trim()
|
||||||
updateTask(next.id, task => {
|
updateTask(next.id, task => {
|
||||||
@@ -5261,12 +5538,10 @@ function ExportPage() {
|
|||||||
const mediaBytesWritten = Number.isFinite(payload.mediaBytesWritten)
|
const mediaBytesWritten = Number.isFinite(payload.mediaBytesWritten)
|
||||||
? Math.max(prevMediaBytesWritten, Math.max(0, Math.floor(Number(payload.mediaBytesWritten || 0))))
|
? Math.max(prevMediaBytesWritten, Math.max(0, Math.floor(Number(payload.mediaBytesWritten || 0))))
|
||||||
: prevMediaBytesWritten
|
: prevMediaBytesWritten
|
||||||
return {
|
const nextProgress: TaskProgress = {
|
||||||
...task,
|
|
||||||
progress: {
|
|
||||||
current: payload.current,
|
current: payload.current,
|
||||||
total: payload.total,
|
total: payload.total,
|
||||||
currentName: payload.currentSession,
|
currentName: payload.currentSession || '',
|
||||||
phase: payload.phase,
|
phase: payload.phase,
|
||||||
phaseLabel: payload.phaseLabel || '',
|
phaseLabel: payload.phaseLabel || '',
|
||||||
phaseProgress: payload.phaseProgress || 0,
|
phaseProgress: payload.phaseProgress || 0,
|
||||||
@@ -5283,55 +5558,51 @@ function ExportPage() {
|
|||||||
mediaCacheFillFiles,
|
mediaCacheFillFiles,
|
||||||
mediaDedupReuseFiles,
|
mediaDedupReuseFiles,
|
||||||
mediaBytesWritten
|
mediaBytesWritten
|
||||||
},
|
}
|
||||||
settledSessionIds: nextSettledSessionIds,
|
const hasSettledListChanged = !areStringArraysEqual(settledSessionIds, nextSettledSessionIds)
|
||||||
performance
|
const hasProgressChanged = !areTaskProgressEqual(task.progress, nextProgress)
|
||||||
|
const hasPerformanceChanged = performance !== task.performance
|
||||||
|
if (!hasSettledListChanged && !hasProgressChanged && !hasPerformanceChanged) {
|
||||||
|
return task
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...task,
|
||||||
|
progress: hasProgressChanged ? nextProgress : task.progress,
|
||||||
|
settledSessionIds: hasSettledListChanged ? nextSettledSessionIds : settledSessionIds,
|
||||||
|
performance: hasPerformanceChanged ? performance : task.performance
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const queueProgressUpdate = (payload: ExportProgress) => {
|
const queueProgressUpdate = (payload: ExportProgress) => {
|
||||||
|
const signature = buildProgressPayloadSignature(payload)
|
||||||
|
if (queuedProgressPayload && signature === queuedProgressSignature) {
|
||||||
|
return
|
||||||
|
}
|
||||||
queuedProgressPayload = payload
|
queuedProgressPayload = payload
|
||||||
|
queuedProgressSignature = signature
|
||||||
if (payload.phase === 'complete') {
|
if (payload.phase === 'complete') {
|
||||||
clearQueuedProgress()
|
clearQueuedProgress()
|
||||||
flushQueuedProgress()
|
flushQueuedProgress()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (queuedProgressRaf !== null || queuedProgressTimer !== null) return
|
if (queuedProgressTimer !== null) return
|
||||||
queuedProgressRaf = window.requestAnimationFrame(() => {
|
|
||||||
queuedProgressRaf = null
|
|
||||||
queuedProgressTimer = window.setTimeout(() => {
|
queuedProgressTimer = window.setTimeout(() => {
|
||||||
queuedProgressTimer = null
|
queuedProgressTimer = null
|
||||||
flushQueuedProgress()
|
flushQueuedProgress()
|
||||||
}, 180)
|
}, EXPORT_PROGRESS_UI_FLUSH_INTERVAL_MS)
|
||||||
})
|
|
||||||
}
|
}
|
||||||
if (next.payload.scope === 'sns') {
|
if (next.payload.scope === 'sns') {
|
||||||
progressUnsubscribeRef.current = window.electronAPI.sns.onExportProgress((payload) => {
|
progressUnsubscribeRef.current = window.electronAPI.sns.onExportProgress((payload) => {
|
||||||
updateTask(next.id, task => {
|
queueProgressUpdate({
|
||||||
if (task.status !== 'running') return task
|
current: Number(payload.current || 0),
|
||||||
return {
|
total: Number(payload.total || 0),
|
||||||
...task,
|
currentSession: '',
|
||||||
progress: {
|
currentSessionId: '',
|
||||||
current: payload.current || 0,
|
|
||||||
total: payload.total || 0,
|
|
||||||
currentName: '',
|
|
||||||
phase: 'exporting',
|
phase: 'exporting',
|
||||||
phaseLabel: payload.status || '',
|
phaseLabel: String(payload.status || ''),
|
||||||
phaseProgress: payload.total > 0 ? payload.current : 0,
|
phaseProgress: payload.total > 0 ? Number(payload.current || 0) : 0,
|
||||||
phaseTotal: payload.total || 0,
|
phaseTotal: Number(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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -5656,6 +5927,8 @@ function ExportPage() {
|
|||||||
dateRangeConfig: serializeExportDateRangeConfig(normalizedRangeSelection),
|
dateRangeConfig: serializeExportDateRangeConfig(normalizedRangeSelection),
|
||||||
intervalDays: 1,
|
intervalDays: 1,
|
||||||
intervalHours: 0,
|
intervalHours: 0,
|
||||||
|
firstTriggerAtEnabled: false,
|
||||||
|
firstTriggerAtValue: '',
|
||||||
stopAtEnabled: false,
|
stopAtEnabled: false,
|
||||||
stopAtValue: '',
|
stopAtValue: '',
|
||||||
maxRunsEnabled: false,
|
maxRunsEnabled: false,
|
||||||
@@ -5691,8 +5964,6 @@ function ExportPage() {
|
|||||||
await configService.setExportDefaultExcelCompactColumns(options.excelCompactColumns)
|
await configService.setExportDefaultExcelCompactColumns(options.excelCompactColumns)
|
||||||
await configService.setExportDefaultTxtColumns(options.txtColumns)
|
await configService.setExportDefaultTxtColumns(options.txtColumns)
|
||||||
await configService.setExportDefaultConcurrency(options.exportConcurrency)
|
await configService.setExportDefaultConcurrency(options.exportConcurrency)
|
||||||
await configService.setExportDefaultImageDeepSearchOnMiss(options.imageDeepSearchOnMiss)
|
|
||||||
setExportDefaultImageDeepSearchOnMiss(options.imageDeepSearchOnMiss)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const openSingleExport = useCallback((session: SessionRow) => {
|
const openSingleExport = useCallback((session: SessionRow) => {
|
||||||
@@ -7336,11 +7607,23 @@ function ExportPage() {
|
|||||||
const handleCancelBackgroundTask = useCallback((taskId: string) => {
|
const handleCancelBackgroundTask = useCallback((taskId: string) => {
|
||||||
requestCancelBackgroundTask(taskId)
|
requestCancelBackgroundTask(taskId)
|
||||||
}, [])
|
}, [])
|
||||||
|
const handlePauseBackgroundTask = useCallback((taskId: string) => {
|
||||||
|
requestPauseBackgroundTask(taskId)
|
||||||
|
}, [])
|
||||||
|
const handleResumeBackgroundTask = useCallback((taskId: string) => {
|
||||||
|
requestResumeBackgroundTask(taskId)
|
||||||
|
}, [])
|
||||||
const handleCancelAllNonExportTasks = useCallback(() => {
|
const handleCancelAllNonExportTasks = useCallback(() => {
|
||||||
requestCancelBackgroundTasks(task => (
|
requestCancelBackgroundTasks(task => (
|
||||||
task.sourcePage !== 'export' &&
|
task.sourcePage !== 'export' &&
|
||||||
|
task.sourcePage !== 'chat' &&
|
||||||
task.cancelable &&
|
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 useCollapsedSessionFormatSelector = isSessionScopeDialog || isContentTextDialog
|
||||||
const shouldShowFormatSection = !isContentScopeDialog || isContentTextDialog
|
const shouldShowFormatSection = !isContentScopeDialog || isContentTextDialog
|
||||||
const shouldShowMediaSection = !isContentScopeDialog
|
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 avatarExportStatusLabel = options.exportAvatars ? '已开启聊天消息导出带头像' : '已关闭聊天消息导出带头像'
|
||||||
const contentTextDialogSummary = '此模式只导出聊天文本,不包含图片语音视频表情包等多媒体文件。'
|
const contentTextDialogSummary = '此模式只导出聊天文本,不包含图片语音视频表情包等多媒体文件。'
|
||||||
const activeDialogFormatLabel = exportDialog.scope === 'sns'
|
const activeDialogFormatLabel = exportDialog.scope === 'sns'
|
||||||
@@ -7496,7 +7771,18 @@ function ExportPage() {
|
|||||||
const isSnsCardStatsLoading = !hasSeededSnsStats
|
const isSnsCardStatsLoading = !hasSeededSnsStats
|
||||||
const taskRunningCount = tasks.filter(task => task.status === 'running').length
|
const taskRunningCount = tasks.filter(task => task.status === 'running').length
|
||||||
const taskQueuedCount = tasks.filter(task => task.status === 'queued').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 hasFilteredContacts = filteredContacts.length > 0
|
||||||
const optionalMetricColumnCount = (shouldShowSnsColumn ? 1 : 0) + (shouldShowMutualFriendsColumn ? 1 : 0)
|
const optionalMetricColumnCount = (shouldShowSnsColumn ? 1 : 0) + (shouldShowMutualFriendsColumn ? 1 : 0)
|
||||||
const contactsMetricColumnCount = 4 + optionalMetricColumnCount
|
const contactsMetricColumnCount = 4 + optionalMetricColumnCount
|
||||||
@@ -7511,15 +7797,25 @@ function ExportPage() {
|
|||||||
width: `${Math.max(contactsHorizontalScrollMetrics.contentWidth, contactsHorizontalScrollMetrics.viewportWidth)}px`
|
width: `${Math.max(contactsHorizontalScrollMetrics.contentWidth, contactsHorizontalScrollMetrics.viewportWidth)}px`
|
||||||
}), [contactsHorizontalScrollMetrics.contentWidth, contactsHorizontalScrollMetrics.viewportWidth])
|
}), [contactsHorizontalScrollMetrics.contentWidth, contactsHorizontalScrollMetrics.viewportWidth])
|
||||||
const nonExportBackgroundTasks = useMemo(() => (
|
const nonExportBackgroundTasks = useMemo(() => (
|
||||||
backgroundTasks.filter(task => task.sourcePage !== 'export')
|
backgroundTasks.filter(task => task.sourcePage !== 'export' && task.sourcePage !== 'chat')
|
||||||
), [backgroundTasks])
|
), [backgroundTasks])
|
||||||
const runningNonExportTaskCount = useMemo(() => (
|
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])
|
), [nonExportBackgroundTasks])
|
||||||
const cancelableNonExportTaskCount = useMemo(() => (
|
const cancelableNonExportTaskCount = useMemo(() => (
|
||||||
nonExportBackgroundTasks.filter(task => (
|
nonExportBackgroundTasks.filter(task => (
|
||||||
task.cancelable &&
|
task.cancelable &&
|
||||||
(task.status === 'running' || task.status === 'cancel_requested')
|
(
|
||||||
|
task.status === 'running' ||
|
||||||
|
task.status === 'pause_requested' ||
|
||||||
|
task.status === 'paused' ||
|
||||||
|
task.status === 'cancel_requested'
|
||||||
|
)
|
||||||
)).length
|
)).length
|
||||||
), [nonExportBackgroundTasks])
|
), [nonExportBackgroundTasks])
|
||||||
const nonExportBackgroundTasksUpdatedAt = useMemo(() => (
|
const nonExportBackgroundTasksUpdatedAt = useMemo(() => (
|
||||||
@@ -8139,12 +8435,16 @@ function ExportPage() {
|
|||||||
<TaskCenterModal
|
<TaskCenterModal
|
||||||
isOpen={isTaskCenterOpen}
|
isOpen={isTaskCenterOpen}
|
||||||
tasks={tasks}
|
tasks={tasks}
|
||||||
|
chatBackgroundTasks={chatBackgroundTasks}
|
||||||
taskRunningCount={taskRunningCount}
|
taskRunningCount={taskRunningCount}
|
||||||
taskQueuedCount={taskQueuedCount}
|
taskQueuedCount={taskQueuedCount}
|
||||||
expandedPerfTaskId={expandedPerfTaskId}
|
expandedPerfTaskId={expandedPerfTaskId}
|
||||||
nowTick={nowTick}
|
nowTick={nowTick}
|
||||||
onClose={closeTaskCenter}
|
onClose={closeTaskCenter}
|
||||||
onTogglePerfTask={toggleTaskPerfDetail}
|
onTogglePerfTask={toggleTaskPerfDetail}
|
||||||
|
onPauseBackgroundTask={handlePauseBackgroundTask}
|
||||||
|
onResumeBackgroundTask={handleResumeBackgroundTask}
|
||||||
|
onCancelBackgroundTask={handleCancelBackgroundTask}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isAutomationModalOpen && createPortal(
|
{isAutomationModalOpen && createPortal(
|
||||||
@@ -8220,6 +8520,7 @@ function ExportPage() {
|
|||||||
{queueState === 'queued' && <span className="automation-task-status queued">排队中</span>}
|
{queueState === 'queued' && <span className="automation-task-status queued">排队中</span>}
|
||||||
</div>
|
</div>
|
||||||
<p>{formatAutomationScheduleLabel(task.schedule)}</p>
|
<p>{formatAutomationScheduleLabel(task.schedule)}</p>
|
||||||
|
<p>首次触发:{resolveAutomationFirstTriggerSummary(task)}</p>
|
||||||
<p>时间范围:{formatAutomationRangeLabel(task.template.dateRangeConfig as any)}</p>
|
<p>时间范围:{formatAutomationRangeLabel(task.template.dateRangeConfig as any)}</p>
|
||||||
<p>会话范围:{task.sessionIds.length} 个</p>
|
<p>会话范围:{task.sessionIds.length} 个</p>
|
||||||
<p>导出目录:{task.outputDir || `${exportFolder || '未设置'}(全局)`}</p>
|
<p>导出目录:{task.outputDir || `${exportFolder || '未设置'}(全局)`}</p>
|
||||||
@@ -8333,6 +8634,52 @@ function ExportPage() {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</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">
|
<div className="automation-form-field">
|
||||||
<span>导出时间范围(按触发时间动态计算)</span>
|
<span>导出时间范围(按触发时间动态计算)</span>
|
||||||
<div className="automation-segment-row">
|
<div className="automation-segment-row">
|
||||||
@@ -8473,7 +8820,11 @@ function ExportPage() {
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="automation-draft-summary">
|
<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>
|
</div>
|
||||||
<div className="automation-editor-actions">
|
<div className="automation-editor-actions">
|
||||||
@@ -8946,7 +9297,12 @@ function ExportPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
className="session-load-detail-task-stop-btn"
|
className="session-load-detail-task-stop-btn"
|
||||||
onClick={() => handleCancelBackgroundTask(task.id)}
|
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>
|
</button>
|
||||||
@@ -9710,30 +10066,6 @@ function ExportPage() {
|
|||||||
</div>
|
</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 && (
|
{isSessionScopeDialog && (
|
||||||
<div className="dialog-section">
|
<div className="dialog-section">
|
||||||
<div className="dialog-switch-row">
|
<div className="dialog-switch-row">
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ const INITIAL_IMAGE_PRELOAD_END = 48
|
|||||||
const INITIAL_IMAGE_RESOLVE_END = 12
|
const INITIAL_IMAGE_RESOLVE_END = 12
|
||||||
const TASK_PROGRESS_UPDATE_MIN_INTERVAL_MS = 250
|
const TASK_PROGRESS_UPDATE_MIN_INTERVAL_MS = 250
|
||||||
const TASK_PROGRESS_UPDATE_MAX_STEPS = 100
|
const TASK_PROGRESS_UPDATE_MAX_STEPS = 100
|
||||||
|
const BATCH_IMAGE_DECRYPT_CONCURRENCY = 8
|
||||||
|
|
||||||
const GridList = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(function GridList(props, ref) {
|
const GridList = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(function GridList(props, ref) {
|
||||||
const { className = '', ...rest } = props
|
const { className = '', ...rest } = props
|
||||||
@@ -71,6 +72,20 @@ function getRangeTimestampEnd(date: string): number | undefined {
|
|||||||
return Number.isFinite(n) ? n : 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 {
|
function getItemKey(item: MediaStreamItem): string {
|
||||||
const sessionId = String(item.sessionId || '').trim().toLowerCase()
|
const sessionId = String(item.sessionId || '').trim().toLowerCase()
|
||||||
const localId = Number(item.localId || 0)
|
const localId = Number(item.localId || 0)
|
||||||
@@ -84,7 +99,7 @@ function getItemKey(item: MediaStreamItem): string {
|
|||||||
const mediaId = String(
|
const mediaId = String(
|
||||||
item.mediaType === 'video'
|
item.mediaType === 'video'
|
||||||
? (item.videoMd5 || '')
|
? (item.videoMd5 || '')
|
||||||
: (item.imageMd5 || item.imageDatName || '')
|
: (item.imageMd5 || getSafeImageDatName(item) || '')
|
||||||
).trim().toLowerCase()
|
).trim().toLowerCase()
|
||||||
return `${sessionId}|${createTime}|${localType}|${serverId}|${mediaId}`
|
return `${sessionId}|${createTime}|${localType}|${serverId}|${mediaId}`
|
||||||
}
|
}
|
||||||
@@ -658,19 +673,20 @@ function ResourcesPage() {
|
|||||||
const to = Math.min(displayItems.length - 1, end)
|
const to = Math.min(displayItems.length - 1, end)
|
||||||
if (to < from) return
|
if (to < from) return
|
||||||
const now = Date.now()
|
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[] = []
|
const itemKeys: string[] = []
|
||||||
for (let i = from; i <= to; i += 1) {
|
for (let i = from; i <= to; i += 1) {
|
||||||
const item = displayItems[i]
|
const item = displayItems[i]
|
||||||
if (!item || item.mediaType !== 'image') continue
|
if (!item || item.mediaType !== 'image') continue
|
||||||
const itemKey = getItemKey(item)
|
const itemKey = getItemKey(item)
|
||||||
if (previewPathMapRef.current[itemKey] || previewPatchRef.current[itemKey]) continue
|
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
|
if ((imageCacheMissUntilRef.current[itemKey] || 0) > now) continue
|
||||||
payloads.push({
|
payloads.push({
|
||||||
sessionId: item.sessionId,
|
sessionId: item.sessionId,
|
||||||
imageMd5: item.imageMd5 || undefined,
|
imageMd5: normalizeMediaToken(item.imageMd5) || undefined,
|
||||||
imageDatName: item.imageDatName || undefined
|
imageDatName: getSafeImageDatName(item) || undefined,
|
||||||
|
createTime: Number(item.createTime || 0) || undefined
|
||||||
})
|
})
|
||||||
itemKeys.push(itemKey)
|
itemKeys.push(itemKey)
|
||||||
if (payloads.length >= MAX_IMAGE_CACHE_RESOLVE_PER_TICK) break
|
if (payloads.length >= MAX_IMAGE_CACHE_RESOLVE_PER_TICK) break
|
||||||
@@ -686,7 +702,10 @@ function ResourcesPage() {
|
|||||||
try {
|
try {
|
||||||
const result = await window.electronAPI.image.resolveCacheBatch(payloads, {
|
const result = await window.electronAPI.image.resolveCacheBatch(payloads, {
|
||||||
disableUpdateCheck: true,
|
disableUpdateCheck: true,
|
||||||
allowCacheIndex: false
|
allowCacheIndex: true,
|
||||||
|
preferFilePath: true,
|
||||||
|
hardlinkOnly: true,
|
||||||
|
suppressEvents: true
|
||||||
})
|
})
|
||||||
const rows = Array.isArray(result?.rows) ? result.rows : []
|
const rows = Array.isArray(result?.rows) ? result.rows : []
|
||||||
const pathPatch: Record<string, string> = {}
|
const pathPatch: Record<string, string> = {}
|
||||||
@@ -733,30 +752,31 @@ function ResourcesPage() {
|
|||||||
if (to < from) return
|
if (to < from) return
|
||||||
|
|
||||||
const now = Date.now()
|
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>()
|
const dedup = new Set<string>()
|
||||||
for (let i = from; i <= to; i += 1) {
|
for (let i = from; i <= to; i += 1) {
|
||||||
const item = displayItems[i]
|
const item = displayItems[i]
|
||||||
if (!item || item.mediaType !== 'image') continue
|
if (!item || item.mediaType !== 'image') continue
|
||||||
const itemKey = getItemKey(item)
|
const itemKey = getItemKey(item)
|
||||||
if (previewPathMapRef.current[itemKey] || previewPatchRef.current[itemKey]) continue
|
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
|
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
|
if (dedup.has(dedupKey)) continue
|
||||||
dedup.add(dedupKey)
|
dedup.add(dedupKey)
|
||||||
imagePreloadUntilRef.current[itemKey] = now + 12000
|
imagePreloadUntilRef.current[itemKey] = now + 12000
|
||||||
payloads.push({
|
payloads.push({
|
||||||
sessionId: item.sessionId,
|
sessionId: item.sessionId,
|
||||||
imageMd5: item.imageMd5 || undefined,
|
imageMd5: normalizeMediaToken(item.imageMd5) || undefined,
|
||||||
imageDatName: item.imageDatName || undefined
|
imageDatName: getSafeImageDatName(item) || undefined,
|
||||||
|
createTime: Number(item.createTime || 0) || undefined
|
||||||
})
|
})
|
||||||
if (payloads.length >= MAX_IMAGE_CACHE_PRELOAD_PER_TICK) break
|
if (payloads.length >= MAX_IMAGE_CACHE_PRELOAD_PER_TICK) break
|
||||||
}
|
}
|
||||||
if (payloads.length === 0) return
|
if (payloads.length === 0) return
|
||||||
void window.electronAPI.image.preload(payloads, {
|
void window.electronAPI.image.preload(payloads, {
|
||||||
allowDecrypt: false,
|
allowDecrypt: false,
|
||||||
allowCacheIndex: false
|
allowCacheIndex: true
|
||||||
})
|
})
|
||||||
}, [displayItems])
|
}, [displayItems])
|
||||||
|
|
||||||
@@ -954,11 +974,14 @@ function ResourcesPage() {
|
|||||||
}, '批量删除确认')
|
}, '批量删除确认')
|
||||||
}, [batchBusy, selectedItems, showAlert, showConfirm])
|
}, [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
|
if (item.mediaType !== 'image') return
|
||||||
|
|
||||||
const key = getItemKey(item)
|
const key = getItemKey(item)
|
||||||
if (!item.imageMd5 && !item.imageDatName) {
|
if (!hasImageLocator(item)) {
|
||||||
showAlert('当前图片缺少解密所需字段(imageMd5/imageDatName)', '无法解密')
|
showAlert('当前图片缺少解密所需字段(imageMd5/imageDatName)', '无法解密')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -972,12 +995,21 @@ function ResourcesPage() {
|
|||||||
try {
|
try {
|
||||||
const result = await window.electronAPI.image.decrypt({
|
const result = await window.electronAPI.image.decrypt({
|
||||||
sessionId: item.sessionId,
|
sessionId: item.sessionId,
|
||||||
imageMd5: item.imageMd5 || undefined,
|
imageMd5: normalizeMediaToken(item.imageMd5) || undefined,
|
||||||
imageDatName: item.imageDatName || undefined,
|
imageDatName: getSafeImageDatName(item) || undefined,
|
||||||
force: true
|
createTime: Number(item.createTime || 0) || undefined,
|
||||||
|
force: true,
|
||||||
|
preferFilePath: true,
|
||||||
|
hardlinkOnly: true,
|
||||||
|
allowCacheIndex: options?.allowCacheIndex ?? true,
|
||||||
|
suppressEvents: true
|
||||||
})
|
})
|
||||||
if (!result?.success) {
|
if (!result?.success) {
|
||||||
showAlert(`解密失败:${result?.error || '未知错误'}`, '解密失败')
|
if (result?.failureKind === 'decrypt_failed') {
|
||||||
|
showAlert(`解密失败:${result?.error || '解密后不是有效图片'}`, '解密失败')
|
||||||
|
} else {
|
||||||
|
showAlert(`本地无数据:${result?.error || '未找到原始 DAT 文件'}`, '未找到本地数据')
|
||||||
|
}
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -991,8 +1023,13 @@ function ResourcesPage() {
|
|||||||
try {
|
try {
|
||||||
const resolved = await window.electronAPI.image.resolveCache({
|
const resolved = await window.electronAPI.image.resolveCache({
|
||||||
sessionId: item.sessionId,
|
sessionId: item.sessionId,
|
||||||
imageMd5: item.imageMd5 || undefined,
|
imageMd5: normalizeMediaToken(item.imageMd5) || undefined,
|
||||||
imageDatName: item.imageDatName || undefined
|
imageDatName: getSafeImageDatName(item) || undefined,
|
||||||
|
createTime: Number(item.createTime || 0) || undefined,
|
||||||
|
preferFilePath: true,
|
||||||
|
hardlinkOnly: true,
|
||||||
|
allowCacheIndex: true,
|
||||||
|
suppressEvents: true
|
||||||
})
|
})
|
||||||
if (resolved?.success && resolved.localPath) {
|
if (resolved?.success && resolved.localPath) {
|
||||||
const localPath = resolved.localPath
|
const localPath = resolved.localPath
|
||||||
@@ -1007,7 +1044,7 @@ function ResourcesPage() {
|
|||||||
setActionMessage('图片解密完成')
|
setActionMessage('图片解密完成')
|
||||||
return undefined
|
return undefined
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showAlert(`解密失败:${String(e)}`, '解密失败')
|
showAlert(`本地无数据:${String(e)}`, '未找到本地数据')
|
||||||
return undefined
|
return undefined
|
||||||
} finally {
|
} finally {
|
||||||
setDecryptingKeys((prev) => {
|
setDecryptingKeys((prev) => {
|
||||||
@@ -1027,8 +1064,13 @@ function ResourcesPage() {
|
|||||||
try {
|
try {
|
||||||
const resolved = await window.electronAPI.image.resolveCache({
|
const resolved = await window.electronAPI.image.resolveCache({
|
||||||
sessionId: item.sessionId,
|
sessionId: item.sessionId,
|
||||||
imageMd5: item.imageMd5 || undefined,
|
imageMd5: normalizeMediaToken(item.imageMd5) || undefined,
|
||||||
imageDatName: item.imageDatName || undefined
|
imageDatName: getSafeImageDatName(item) || undefined,
|
||||||
|
createTime: Number(item.createTime || 0) || undefined,
|
||||||
|
preferFilePath: true,
|
||||||
|
hardlinkOnly: true,
|
||||||
|
allowCacheIndex: true,
|
||||||
|
suppressEvents: true
|
||||||
})
|
})
|
||||||
if (resolved?.success && resolved.localPath) {
|
if (resolved?.success && resolved.localPath) {
|
||||||
localPath = resolved.localPath
|
localPath = resolved.localPath
|
||||||
@@ -1046,8 +1088,13 @@ function ResourcesPage() {
|
|||||||
try {
|
try {
|
||||||
const resolved = await window.electronAPI.image.resolveCache({
|
const resolved = await window.electronAPI.image.resolveCache({
|
||||||
sessionId: item.sessionId,
|
sessionId: item.sessionId,
|
||||||
imageMd5: item.imageMd5 || undefined,
|
imageMd5: normalizeMediaToken(item.imageMd5) || undefined,
|
||||||
imageDatName: item.imageDatName || undefined
|
imageDatName: getSafeImageDatName(item) || undefined,
|
||||||
|
createTime: Number(item.createTime || 0) || undefined,
|
||||||
|
preferFilePath: true,
|
||||||
|
hardlinkOnly: true,
|
||||||
|
allowCacheIndex: true,
|
||||||
|
suppressEvents: true
|
||||||
})
|
})
|
||||||
if (resolved?.success && resolved.localPath) {
|
if (resolved?.success && resolved.localPath) {
|
||||||
localPath = resolved.localPath
|
localPath = resolved.localPath
|
||||||
@@ -1077,7 +1124,8 @@ function ResourcesPage() {
|
|||||||
|
|
||||||
setBatchBusy(true)
|
setBatchBusy(true)
|
||||||
let success = 0
|
let success = 0
|
||||||
let failed = 0
|
let notFound = 0
|
||||||
|
let decryptFailed = 0
|
||||||
const previewPatch: Record<string, string> = {}
|
const previewPatch: Record<string, string> = {}
|
||||||
const updatePatch: Record<string, boolean> = {}
|
const updatePatch: Record<string, boolean> = {}
|
||||||
const taskId = registerBackgroundTask({
|
const taskId = registerBackgroundTask({
|
||||||
@@ -1105,21 +1153,54 @@ function ResourcesPage() {
|
|||||||
lastProgressBucket = bucket
|
lastProgressBucket = bucket
|
||||||
lastProgressUpdateAt = now
|
lastProgressUpdateAt = now
|
||||||
}
|
}
|
||||||
|
const hardlinkMd5Set = new Set<string>()
|
||||||
for (const item of imageItems) {
|
for (const item of imageItems) {
|
||||||
if (!item.imageMd5 && !item.imageDatName) {
|
if (!hasImageLocator(item)) continue
|
||||||
failed += 1
|
const imageMd5 = normalizeMediaToken(item.imageMd5)
|
||||||
completed += 1
|
if (imageMd5) {
|
||||||
updateTaskProgress()
|
hardlinkMd5Set.add(imageMd5)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
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
|
continue
|
||||||
}
|
}
|
||||||
const result = await window.electronAPI.image.decrypt({
|
const result = await window.electronAPI.image.decrypt({
|
||||||
sessionId: item.sessionId,
|
sessionId: item.sessionId,
|
||||||
imageMd5: item.imageMd5 || undefined,
|
imageMd5: normalizeMediaToken(item.imageMd5) || undefined,
|
||||||
imageDatName: item.imageDatName || undefined,
|
imageDatName: getSafeImageDatName(item) || undefined,
|
||||||
force: true
|
createTime: Number(item.createTime || 0) || undefined,
|
||||||
|
force: true,
|
||||||
|
preferFilePath: true,
|
||||||
|
hardlinkOnly: true,
|
||||||
|
allowCacheIndex: true,
|
||||||
|
suppressEvents: true
|
||||||
})
|
})
|
||||||
if (!result?.success) {
|
if (!result?.success) {
|
||||||
failed += 1
|
if (result?.failureKind === 'decrypt_failed') decryptFailed += 1
|
||||||
|
else notFound += 1
|
||||||
} else {
|
} else {
|
||||||
success += 1
|
success += 1
|
||||||
if (result.localPath) {
|
if (result.localPath) {
|
||||||
@@ -1128,9 +1209,15 @@ function ResourcesPage() {
|
|||||||
updatePatch[key] = isLikelyThumbnailPreview(result.localPath)
|
updatePatch[key] = isLikelyThumbnailPreview(result.localPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
notFound += 1
|
||||||
|
} finally {
|
||||||
completed += 1
|
completed += 1
|
||||||
updateTaskProgress()
|
updateTaskProgress()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(Array.from({ length: concurrency }, () => worker()))
|
||||||
updateTaskProgress(true)
|
updateTaskProgress(true)
|
||||||
|
|
||||||
if (Object.keys(previewPatch).length > 0) {
|
if (Object.keys(previewPatch).length > 0) {
|
||||||
@@ -1139,11 +1226,11 @@ function ResourcesPage() {
|
|||||||
if (Object.keys(updatePatch).length > 0) {
|
if (Object.keys(updatePatch).length > 0) {
|
||||||
setPreviewUpdateMap((prev) => ({ ...prev, ...updatePatch }))
|
setPreviewUpdateMap((prev) => ({ ...prev, ...updatePatch }))
|
||||||
}
|
}
|
||||||
setActionMessage(`批量解密完成:成功 ${success},失败 ${failed}`)
|
setActionMessage(`批量解密完成:成功 ${success},未找到 ${notFound},解密失败 ${decryptFailed}`)
|
||||||
showAlert(`批量解密完成:成功 ${success},失败 ${failed}`, '批量解密完成')
|
showAlert(`批量解密完成:成功 ${success},未找到 ${notFound},解密失败 ${decryptFailed}`, '批量解密完成')
|
||||||
finishBackgroundTask(taskId, success > 0 || failed === 0 ? 'completed' : 'failed', {
|
finishBackgroundTask(taskId, decryptFailed > 0 ? 'failed' : 'completed', {
|
||||||
detail: `资源页图片批量解密完成:成功 ${success},失败 ${failed}`,
|
detail: `资源页图片批量解密完成:成功 ${success},未找到 ${notFound},解密失败 ${decryptFailed}`,
|
||||||
progressText: `成功 ${success} / 失败 ${failed}`
|
progressText: `成功 ${success} / 未找到 ${notFound} / 解密失败 ${decryptFailed}`
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
finishBackgroundTask(taskId, 'failed', {
|
finishBackgroundTask(taskId, 'failed', {
|
||||||
|
|||||||
@@ -56,43 +56,28 @@ const normalizeDbKeyStatusMessage = (message: string): string => {
|
|||||||
return message
|
return message
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDbKeyReadyMessage = (message: string): boolean => (
|
const isDbKeyReadyMessage = (message: string): boolean => {
|
||||||
message.includes('现在可以登录')
|
if (isWindows) {
|
||||||
|
return message.includes('现在可以登录')
|
||||||
|| message.includes('Hook安装成功')
|
|| message.includes('Hook安装成功')
|
||||||
|| message.includes('已准备就绪,现在登录微信或退出登录后重新登录微信')
|
|| message.includes('已准备就绪,现在登录微信或退出登录后重新登录微信')
|
||||||
)
|
}
|
||||||
|
return message.includes('现在可以登录')
|
||||||
|
}
|
||||||
|
|
||||||
const pickWxidByAnchorTime = (
|
const pickLatestWxid = (
|
||||||
wxids: Array<{ wxid: string; modifiedTime: number }>,
|
wxids: Array<{ wxid: string; modifiedTime: number }>
|
||||||
anchorTime?: number
|
|
||||||
): string => {
|
): string => {
|
||||||
if (!Array.isArray(wxids) || wxids.length === 0) return ''
|
if (!Array.isArray(wxids) || wxids.length === 0) return ''
|
||||||
const fallbackWxid = wxids[0]?.wxid || ''
|
const fallbackWxid = wxids[0]?.wxid || ''
|
||||||
if (!anchorTime || !Number.isFinite(anchorTime)) return fallbackWxid
|
|
||||||
|
|
||||||
const valid = wxids.filter(item => Number.isFinite(item.modifiedTime) && item.modifiedTime > 0)
|
const valid = wxids.filter(item => Number.isFinite(item.modifiedTime) && item.modifiedTime > 0)
|
||||||
if (valid.length === 0) return fallbackWxid
|
if (valid.length === 0) return fallbackWxid
|
||||||
|
|
||||||
const anchor = Number(anchorTime)
|
const latest = [...valid].sort((a, b) => {
|
||||||
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
|
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
|
||||||
return a.wxid.localeCompare(b.wxid)
|
return a.wxid.localeCompare(b.wxid)
|
||||||
})
|
})
|
||||||
if (near.length > 0) return near[0].wxid
|
return latest[0]?.wxid || fallbackWxid
|
||||||
|
|
||||||
const closest = valid.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)
|
|
||||||
})
|
|
||||||
return closest[0]?.wxid || fallbackWxid
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function WelcomePage({ standalone = false }: WelcomePageProps) {
|
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 (!dbPath) {
|
||||||
if (!silent) setError('请先选择数据库目录')
|
if (!silent) setError('请先选择数据库目录')
|
||||||
return
|
return
|
||||||
@@ -446,9 +431,8 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
const wxids = await window.electronAPI.dbPath.scanWxids(dbPath)
|
const wxids = await window.electronAPI.dbPath.scanWxids(dbPath)
|
||||||
setWxidOptions(wxids)
|
setWxidOptions(wxids)
|
||||||
if (wxids.length > 0) {
|
if (wxids.length > 0) {
|
||||||
// 密钥成功后使用成功时刻作为锚点,自动选择最接近该时刻的活跃账号;
|
// 自动获取密钥后,始终优先选择最近活跃(modifiedTime 最新)的账号。
|
||||||
// 其余场景保持“时间最新”优先。
|
const selectedWxid = pickLatestWxid(wxids)
|
||||||
const selectedWxid = pickWxidByAnchorTime(wxids, anchorTime)
|
|
||||||
setWxid(selectedWxid || wxids[0].wxid)
|
setWxid(selectedWxid || wxids[0].wxid)
|
||||||
if (!silent) setError('')
|
if (!silent) setError('')
|
||||||
} else {
|
} else {
|
||||||
@@ -501,8 +485,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
setHasReacquiredDbKey(true)
|
setHasReacquiredDbKey(true)
|
||||||
setDbKeyStatus('密钥获取成功')
|
setDbKeyStatus('密钥获取成功')
|
||||||
setError('')
|
setError('')
|
||||||
const keySuccessAt = Date.now()
|
await handleScanWxid(true)
|
||||||
await handleScanWxid(true, keySuccessAt)
|
|
||||||
} else {
|
} else {
|
||||||
if (isAddAccountMode) {
|
if (isAddAccountMode) {
|
||||||
setHasReacquiredDbKey(false)
|
setHasReacquiredDbKey(false)
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ type BackgroundTaskListener = (tasks: BackgroundTaskRecord[]) => void
|
|||||||
|
|
||||||
const tasks = new Map<string, BackgroundTaskRecord>()
|
const tasks = new Map<string, BackgroundTaskRecord>()
|
||||||
const cancelHandlers = new Map<string, () => void | Promise<void>>()
|
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>()
|
const listeners = new Set<BackgroundTaskListener>()
|
||||||
let taskSequence = 0
|
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 MAX_SETTLED_TASKS = 24
|
||||||
|
|
||||||
const buildTaskId = (): string => {
|
const buildTaskId = (): string => {
|
||||||
@@ -34,6 +36,9 @@ const pruneSettledTasks = () => {
|
|||||||
|
|
||||||
for (const staleTask of settledTasks.slice(MAX_SETTLED_TASKS)) {
|
for (const staleTask of settledTasks.slice(MAX_SETTLED_TASKS)) {
|
||||||
tasks.delete(staleTask.id)
|
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,
|
detail: input.detail,
|
||||||
progressText: input.progressText,
|
progressText: input.progressText,
|
||||||
cancelable: input.cancelable !== false,
|
cancelable: input.cancelable !== false,
|
||||||
|
resumable: input.resumable === true,
|
||||||
cancelRequested: false,
|
cancelRequested: false,
|
||||||
|
pauseRequested: false,
|
||||||
status: 'running',
|
status: 'running',
|
||||||
startedAt: now,
|
startedAt: now,
|
||||||
updatedAt: now
|
updatedAt: now
|
||||||
@@ -72,6 +79,12 @@ export const registerBackgroundTask = (input: BackgroundTaskInput): string => {
|
|||||||
if (input.onCancel) {
|
if (input.onCancel) {
|
||||||
cancelHandlers.set(taskId, input.onCancel)
|
cancelHandlers.set(taskId, input.onCancel)
|
||||||
}
|
}
|
||||||
|
if (input.onPause) {
|
||||||
|
pauseHandlers.set(taskId, input.onPause)
|
||||||
|
}
|
||||||
|
if (input.onResume) {
|
||||||
|
resumeHandlers.set(taskId, input.onResume)
|
||||||
|
}
|
||||||
pruneSettledTasks()
|
pruneSettledTasks()
|
||||||
notifyListeners()
|
notifyListeners()
|
||||||
return taskId
|
return taskId
|
||||||
@@ -87,6 +100,9 @@ export const updateBackgroundTask = (taskId: string, patch: BackgroundTaskUpdate
|
|||||||
...patch,
|
...patch,
|
||||||
status: nextStatus,
|
status: nextStatus,
|
||||||
updatedAt: nextUpdatedAt,
|
updatedAt: nextUpdatedAt,
|
||||||
|
pauseRequested: nextStatus === 'paused' || nextStatus === 'pause_requested'
|
||||||
|
? true
|
||||||
|
: (nextStatus === 'running' ? false : existing.pauseRequested),
|
||||||
finishedAt: ACTIVE_STATUSES.has(nextStatus) ? undefined : (existing.finishedAt || nextUpdatedAt)
|
finishedAt: ACTIVE_STATUSES.has(nextStatus) ? undefined : (existing.finishedAt || nextUpdatedAt)
|
||||||
})
|
})
|
||||||
pruneSettledTasks()
|
pruneSettledTasks()
|
||||||
@@ -107,9 +123,12 @@ export const finishBackgroundTask = (
|
|||||||
status,
|
status,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
finishedAt: now,
|
finishedAt: now,
|
||||||
cancelRequested: status === 'canceled' ? true : existing.cancelRequested
|
cancelRequested: status === 'canceled' ? true : existing.cancelRequested,
|
||||||
|
pauseRequested: false
|
||||||
})
|
})
|
||||||
cancelHandlers.delete(taskId)
|
cancelHandlers.delete(taskId)
|
||||||
|
pauseHandlers.delete(taskId)
|
||||||
|
resumeHandlers.delete(taskId)
|
||||||
pruneSettledTasks()
|
pruneSettledTasks()
|
||||||
notifyListeners()
|
notifyListeners()
|
||||||
}
|
}
|
||||||
@@ -121,6 +140,7 @@ export const requestCancelBackgroundTask = (taskId: string): boolean => {
|
|||||||
...existing,
|
...existing,
|
||||||
status: 'cancel_requested',
|
status: 'cancel_requested',
|
||||||
cancelRequested: true,
|
cancelRequested: true,
|
||||||
|
pauseRequested: false,
|
||||||
detail: existing.detail || '停止请求已发出,当前查询完成后会结束后续加载',
|
detail: existing.detail || '停止请求已发出,当前查询完成后会结束后续加载',
|
||||||
updatedAt: Date.now()
|
updatedAt: Date.now()
|
||||||
})
|
})
|
||||||
@@ -132,6 +152,46 @@ export const requestCancelBackgroundTask = (taskId: string): boolean => {
|
|||||||
return true
|
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 => {
|
export const requestCancelBackgroundTasks = (predicate: (task: BackgroundTaskRecord) => boolean): number => {
|
||||||
let canceledCount = 0
|
let canceledCount = 0
|
||||||
for (const task of tasks.values()) {
|
for (const task of tasks.values()) {
|
||||||
@@ -147,3 +207,8 @@ export const isBackgroundTaskCancelRequested = (taskId: string): boolean => {
|
|||||||
const task = tasks.get(taskId)
|
const task = tasks.get(taskId)
|
||||||
return Boolean(task?.cancelRequested)
|
return Boolean(task?.cancelRequested)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const isBackgroundTaskPauseRequested = (taskId: string): boolean => {
|
||||||
|
const task = tasks.get(taskId)
|
||||||
|
return Boolean(task?.pauseRequested)
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ export const CONFIG_KEYS = {
|
|||||||
EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns',
|
EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns',
|
||||||
EXPORT_DEFAULT_TXT_COLUMNS: 'exportDefaultTxtColumns',
|
EXPORT_DEFAULT_TXT_COLUMNS: 'exportDefaultTxtColumns',
|
||||||
EXPORT_DEFAULT_CONCURRENCY: 'exportDefaultConcurrency',
|
EXPORT_DEFAULT_CONCURRENCY: 'exportDefaultConcurrency',
|
||||||
EXPORT_DEFAULT_IMAGE_DEEP_SEARCH_ON_MISS: 'exportDefaultImageDeepSearchOnMiss',
|
|
||||||
EXPORT_WRITE_LAYOUT: 'exportWriteLayout',
|
EXPORT_WRITE_LAYOUT: 'exportWriteLayout',
|
||||||
EXPORT_SESSION_NAME_PREFIX_ENABLED: 'exportSessionNamePrefixEnabled',
|
EXPORT_SESSION_NAME_PREFIX_ENABLED: 'exportSessionNamePrefixEnabled',
|
||||||
EXPORT_LAST_SESSION_RUN_MAP: 'exportLastSessionRunMap',
|
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)
|
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 type ExportWriteLayout = 'A' | 'B' | 'C'
|
||||||
|
|
||||||
export async function getExportWriteLayout(): Promise<ExportWriteLayout> {
|
export async function getExportWriteLayout(): Promise<ExportWriteLayout> {
|
||||||
@@ -713,11 +700,17 @@ const normalizeAutomationTask = (raw: unknown): ExportAutomationTask | null => {
|
|||||||
if (scheduleType === 'interval') {
|
if (scheduleType === 'interval') {
|
||||||
const rawDays = Math.max(0, normalizeAutomationNumeric(scheduleObj.intervalDays, 0))
|
const rawDays = Math.max(0, normalizeAutomationNumeric(scheduleObj.intervalDays, 0))
|
||||||
const rawHours = Math.max(0, normalizeAutomationNumeric(scheduleObj.intervalHours, 0))
|
const rawHours = Math.max(0, normalizeAutomationNumeric(scheduleObj.intervalHours, 0))
|
||||||
|
const rawFirstTriggerAt = Math.max(0, normalizeAutomationNumeric(scheduleObj.firstTriggerAt, 0))
|
||||||
const totalHours = (rawDays * 24) + rawHours
|
const totalHours = (rawDays * 24) + rawHours
|
||||||
if (totalHours <= 0) return null
|
if (totalHours <= 0) return null
|
||||||
const intervalDays = Math.floor(totalHours / 24)
|
const intervalDays = Math.floor(totalHours / 24)
|
||||||
const intervalHours = 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
|
if (!schedule) return null
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,21 @@ import {
|
|||||||
registerBackgroundTask,
|
registerBackgroundTask,
|
||||||
updateBackgroundTask
|
updateBackgroundTask
|
||||||
} from '../services/backgroundTaskMonitor'
|
} 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 {
|
export interface BatchImageDecryptState {
|
||||||
isBatchDecrypting: boolean
|
isBatchDecrypting: boolean
|
||||||
@@ -16,9 +30,15 @@ export interface BatchImageDecryptState {
|
|||||||
sessionName: string
|
sessionName: string
|
||||||
taskId: string | null
|
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
|
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
|
setShowToast: (show: boolean) => void
|
||||||
setShowResultToast: (show: boolean) => void
|
setShowResultToast: (show: boolean) => void
|
||||||
reset: () => void
|
reset: () => void
|
||||||
@@ -53,7 +73,7 @@ export const useBatchImageDecryptStore = create<BatchImageDecryptState>((set, ge
|
|||||||
sessionName: '',
|
sessionName: '',
|
||||||
taskId: null,
|
taskId: null,
|
||||||
|
|
||||||
startDecrypt: (total, sessionName, sourcePage = 'chat') => {
|
startDecrypt: (total, sessionName, sourcePage = 'chat', controls) => {
|
||||||
const previousTaskId = get().taskId
|
const previousTaskId = get().taskId
|
||||||
if (previousTaskId) {
|
if (previousTaskId) {
|
||||||
taskProgressUpdateMeta.delete(previousTaskId)
|
taskProgressUpdateMeta.delete(previousTaskId)
|
||||||
@@ -73,7 +93,11 @@ export const useBatchImageDecryptStore = create<BatchImageDecryptState>((set, ge
|
|||||||
title,
|
title,
|
||||||
detail: `正在解密图片(${normalizedProgress.current}/${normalizedProgress.total})`,
|
detail: `正在解密图片(${normalizedProgress.current}/${normalizedProgress.total})`,
|
||||||
progressText: `${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, {
|
taskProgressUpdateMeta.set(taskId, {
|
||||||
lastAt: Date.now(),
|
lastAt: Date.now(),
|
||||||
@@ -97,6 +121,7 @@ export const useBatchImageDecryptStore = create<BatchImageDecryptState>((set, ge
|
|||||||
const previousProgress = get().progress
|
const previousProgress = get().progress
|
||||||
const normalizedProgress = clampProgress(current, total)
|
const normalizedProgress = clampProgress(current, total)
|
||||||
const taskId = get().taskId
|
const taskId = get().taskId
|
||||||
|
let shouldCommitUi = true
|
||||||
if (taskId) {
|
if (taskId) {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const meta = taskProgressUpdateMeta.get(taskId)
|
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 intervalReached = !meta || (now - meta.lastAt >= TASK_PROGRESS_UPDATE_MIN_INTERVAL_MS)
|
||||||
const crossedBucket = !meta || bucket !== meta.lastBucket
|
const crossedBucket = !meta || bucket !== meta.lastBucket
|
||||||
const isFinal = normalizedProgress.total > 0 && normalizedProgress.current >= normalizedProgress.total
|
const isFinal = normalizedProgress.total > 0 && normalizedProgress.current >= normalizedProgress.total
|
||||||
if (crossedBucket || intervalReached || isFinal) {
|
const shouldPublish = crossedBucket || intervalReached || isFinal
|
||||||
|
shouldCommitUi = shouldPublish
|
||||||
|
if (shouldPublish) {
|
||||||
updateBackgroundTask(taskId, {
|
updateBackgroundTask(taskId, {
|
||||||
detail: `正在解密图片(${normalizedProgress.current}/${normalizedProgress.total})`,
|
detail: `正在解密图片(${normalizedProgress.current}/${normalizedProgress.total})`,
|
||||||
progressText: `${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.current !== normalizedProgress.current ||
|
||||||
previousProgress.total !== normalizedProgress.total
|
previousProgress.total !== normalizedProgress.total
|
||||||
) {
|
)) {
|
||||||
set({
|
set({
|
||||||
progress: normalizedProgress
|
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 taskId = get().taskId
|
||||||
const normalizedSuccess = Number.isFinite(success) ? Math.max(0, Math.floor(success)) : 0
|
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 normalizedFail = Number.isFinite(fail) ? Math.max(0, Math.floor(fail)) : 0
|
||||||
if (taskId) {
|
if (taskId) {
|
||||||
taskProgressUpdateMeta.delete(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, {
|
finishBackgroundTask(taskId, status, {
|
||||||
detail: `图片批量解密完成:成功 ${normalizedSuccess},失败 ${normalizedFail}`,
|
detail: options?.detail || `图片批量解密完成:成功 ${normalizedSuccess},失败 ${normalizedFail}`,
|
||||||
progressText: `成功 ${normalizedSuccess} / 失败 ${normalizedFail}`
|
progressText: options?.progressText || `成功 ${normalizedSuccess} / 失败 ${normalizedFail}`
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,27 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
|
import {
|
||||||
|
finishBackgroundTask,
|
||||||
|
registerBackgroundTask,
|
||||||
|
updateBackgroundTask
|
||||||
|
} from '../services/backgroundTaskMonitor'
|
||||||
|
import type { BackgroundTaskSourcePage, BackgroundTaskStatus } from '../types/backgroundTask'
|
||||||
|
|
||||||
export type BatchVoiceTaskType = 'transcribe' | 'decrypt'
|
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 {
|
export interface BatchTranscribeState {
|
||||||
/** 是否正在批量转写 */
|
/** 是否正在批量转写 */
|
||||||
isBatchTranscribing: boolean
|
isBatchTranscribing: boolean
|
||||||
@@ -18,17 +38,44 @@ export interface BatchTranscribeState {
|
|||||||
/** 当前转写的会话名 */
|
/** 当前转写的会话名 */
|
||||||
startTime: number
|
startTime: number
|
||||||
sessionName: string
|
sessionName: string
|
||||||
|
taskId: string | null
|
||||||
|
|
||||||
// Actions
|
// 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
|
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
|
setShowToast: (show: boolean) => void
|
||||||
setShowResult: (show: boolean) => void
|
setShowResult: (show: boolean) => void
|
||||||
reset: () => 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,
|
isBatchTranscribing: false,
|
||||||
taskType: 'transcribe',
|
taskType: 'transcribe',
|
||||||
progress: { current: 0, total: 0 },
|
progress: { current: 0, total: 0 },
|
||||||
@@ -37,34 +84,142 @@ export const useBatchTranscribeStore = create<BatchTranscribeState>((set) => ({
|
|||||||
result: { success: 0, fail: 0 },
|
result: { success: 0, fail: 0 },
|
||||||
sessionName: '',
|
sessionName: '',
|
||||||
startTime: 0,
|
startTime: 0,
|
||||||
|
taskId: null,
|
||||||
|
|
||||||
startTranscribe: (total, sessionName, taskType = 'transcribe') => set({
|
startTranscribe: (total, sessionName, taskType = 'transcribe', sourcePage = 'chat', controls) => {
|
||||||
|
const previousTaskId = get().taskId
|
||||||
|
if (previousTaskId) {
|
||||||
|
taskProgressUpdateMeta.delete(previousTaskId)
|
||||||
|
finishBackgroundTask(previousTaskId, 'canceled', {
|
||||||
|
detail: '已被新的语音批量任务替换',
|
||||||
|
progressText: '已替换'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
set({
|
||||||
isBatchTranscribing: true,
|
isBatchTranscribing: true,
|
||||||
taskType,
|
taskType,
|
||||||
showToast: true,
|
showToast: false,
|
||||||
progress: { current: 0, total },
|
progress: normalizedProgress,
|
||||||
showResult: false,
|
showResult: false,
|
||||||
result: { success: 0, fail: 0 },
|
result: { success: 0, fail: 0 },
|
||||||
sessionName,
|
sessionName: normalizedSessionName,
|
||||||
startTime: Date.now()
|
startTime: Date.now(),
|
||||||
}),
|
taskId
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
updateProgress: (current, total) => set({
|
updateProgress: (current, total) => {
|
||||||
progress: { 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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
finishTranscribe: (success, fail) => set({
|
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,
|
isBatchTranscribing: false,
|
||||||
showToast: false,
|
showToast: false,
|
||||||
showResult: true,
|
showResult: false,
|
||||||
result: { success, fail },
|
result: { success: normalizedSuccess, fail: normalizedFail },
|
||||||
startTime: 0
|
startTime: 0,
|
||||||
}),
|
taskId: null
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
setShowToast: (show) => set({ showToast: show }),
|
setShowToast: (show) => set({ showToast: show }),
|
||||||
setShowResult: (show) => set({ showResult: show }),
|
setShowResult: (show) => set({ showResult: show }),
|
||||||
|
|
||||||
reset: () => set({
|
reset: () => {
|
||||||
|
const taskId = get().taskId
|
||||||
|
if (taskId) {
|
||||||
|
taskProgressUpdateMeta.delete(taskId)
|
||||||
|
finishBackgroundTask(taskId, 'canceled', {
|
||||||
|
detail: '语音批量任务已重置',
|
||||||
|
progressText: '已停止'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
set({
|
||||||
isBatchTranscribing: false,
|
isBatchTranscribing: false,
|
||||||
taskType: 'transcribe',
|
taskType: 'transcribe',
|
||||||
progress: { current: 0, total: 0 },
|
progress: { current: 0, total: 0 },
|
||||||
@@ -72,6 +227,8 @@ export const useBatchTranscribeStore = create<BatchTranscribeState>((set) => ({
|
|||||||
showResult: false,
|
showResult: false,
|
||||||
result: { success: 0, fail: 0 },
|
result: { success: 0, fail: 0 },
|
||||||
sessionName: '',
|
sessionName: '',
|
||||||
startTime: 0
|
startTime: 0,
|
||||||
|
taskId: null
|
||||||
})
|
})
|
||||||
|
}
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export type BackgroundTaskSourcePage =
|
|||||||
|
|
||||||
export type BackgroundTaskStatus =
|
export type BackgroundTaskStatus =
|
||||||
| 'running'
|
| 'running'
|
||||||
|
| 'pause_requested'
|
||||||
|
| 'paused'
|
||||||
| 'cancel_requested'
|
| 'cancel_requested'
|
||||||
| 'completed'
|
| 'completed'
|
||||||
| 'failed'
|
| 'failed'
|
||||||
@@ -21,7 +23,9 @@ export interface BackgroundTaskRecord {
|
|||||||
detail?: string
|
detail?: string
|
||||||
progressText?: string
|
progressText?: string
|
||||||
cancelable: boolean
|
cancelable: boolean
|
||||||
|
resumable: boolean
|
||||||
cancelRequested: boolean
|
cancelRequested: boolean
|
||||||
|
pauseRequested: boolean
|
||||||
status: BackgroundTaskStatus
|
status: BackgroundTaskStatus
|
||||||
startedAt: number
|
startedAt: number
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
@@ -34,7 +38,10 @@ export interface BackgroundTaskInput {
|
|||||||
detail?: string
|
detail?: string
|
||||||
progressText?: string
|
progressText?: string
|
||||||
cancelable?: boolean
|
cancelable?: boolean
|
||||||
|
resumable?: boolean
|
||||||
onCancel?: () => void | Promise<void>
|
onCancel?: () => void | Promise<void>
|
||||||
|
onPause?: () => void | Promise<void>
|
||||||
|
onResume?: () => void | Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BackgroundTaskUpdate {
|
export interface BackgroundTaskUpdate {
|
||||||
|
|||||||
29
src/types/electron.d.ts
vendored
29
src/types/electron.d.ts
vendored
@@ -505,26 +505,42 @@ export interface ElectronAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
image: {
|
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: {
|
resolveCache: (payload: {
|
||||||
sessionId?: string
|
sessionId?: string
|
||||||
imageMd5?: string
|
imageMd5?: string
|
||||||
imageDatName?: string
|
imageDatName?: string
|
||||||
|
createTime?: number
|
||||||
|
preferFilePath?: boolean
|
||||||
|
hardlinkOnly?: boolean
|
||||||
disableUpdateCheck?: boolean
|
disableUpdateCheck?: boolean
|
||||||
allowCacheIndex?: 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: (
|
resolveCacheBatch: (
|
||||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
|
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number; preferFilePath?: boolean; hardlinkOnly?: boolean }>,
|
||||||
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean }
|
options?: { disableUpdateCheck?: boolean; allowCacheIndex?: boolean; preferFilePath?: boolean; hardlinkOnly?: boolean; suppressEvents?: boolean }
|
||||||
) => Promise<{
|
) => Promise<{
|
||||||
success: boolean
|
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
|
error?: string
|
||||||
}>
|
}>
|
||||||
preload: (
|
preload: (
|
||||||
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>,
|
payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string; createTime?: number }>,
|
||||||
options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean }
|
options?: { allowDecrypt?: boolean; allowCacheIndex?: boolean }
|
||||||
) => Promise<boolean>
|
) => Promise<boolean>
|
||||||
|
preloadHardlinkMd5s: (md5List: string[]) => Promise<boolean>
|
||||||
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => () => void
|
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => () => void
|
||||||
onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => () => void
|
onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => () => void
|
||||||
onDecryptProgress: (callback: (payload: {
|
onDecryptProgress: (callback: (payload: {
|
||||||
@@ -1135,7 +1151,6 @@ export interface ExportOptions {
|
|||||||
sessionNameWithTypePrefix?: boolean
|
sessionNameWithTypePrefix?: boolean
|
||||||
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
|
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
|
||||||
exportConcurrency?: number
|
exportConcurrency?: number
|
||||||
imageDeepSearchOnMiss?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExportProgress {
|
export interface ExportProgress {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type ExportAutomationSchedule =
|
|||||||
type: 'interval'
|
type: 'interval'
|
||||||
intervalDays: number
|
intervalDays: number
|
||||||
intervalHours: number
|
intervalHours: number
|
||||||
|
firstTriggerAt?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExportAutomationCondition {
|
export interface ExportAutomationCondition {
|
||||||
|
|||||||
Reference in New Issue
Block a user