mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-07 15:08:41 +00:00
Merge branch 'main' into fix-export-excel-columns
This commit is contained in:
61
.github/workflows/dev-daily-fixed.yml
vendored
61
.github/workflows/dev-daily-fixed.yml
vendored
@@ -12,6 +12,7 @@ permissions:
|
|||||||
env:
|
env:
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||||
FIXED_DEV_TAG: nightly-dev
|
FIXED_DEV_TAG: nightly-dev
|
||||||
|
TARGET_BRANCH: dev
|
||||||
ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/
|
ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -23,6 +24,7 @@ jobs:
|
|||||||
- name: Check out git repository
|
- name: Check out git repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
|
ref: ${{ env.TARGET_BRANCH }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
@@ -36,25 +38,25 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
BASE_VERSION="$(node -p "require('./package.json').version.split('-')[0]")"
|
|
||||||
YEAR_2="$(TZ=Asia/Shanghai date +%y)"
|
YEAR_2="$(TZ=Asia/Shanghai date +%y)"
|
||||||
MONTH="$(TZ=Asia/Shanghai date +%-m)"
|
MONTH="$(TZ=Asia/Shanghai date +%-m)"
|
||||||
DAY="$(TZ=Asia/Shanghai date +%-d)"
|
DAY="$(TZ=Asia/Shanghai date +%-d)"
|
||||||
DEV_VERSION="${BASE_VERSION}-dev.${YEAR_2}.${MONTH}.${DAY}"
|
DEV_VERSION="${YEAR_2}.${MONTH}.${DAY}"
|
||||||
echo "dev_version=$DEV_VERSION" >> "$GITHUB_OUTPUT"
|
echo "dev_version=$DEV_VERSION" >> "$GITHUB_OUTPUT"
|
||||||
echo "Dev version: $DEV_VERSION"
|
echo "Dev version: $DEV_VERSION"
|
||||||
|
|
||||||
- name: Ensure fixed prerelease exists
|
- name: Recreate fixed prerelease
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
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
|
if gh release view "$FIXED_DEV_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||||
gh release edit "$FIXED_DEV_TAG" --repo "$GITHUB_REPOSITORY" --title "Daily Dev Build" --prerelease
|
gh release delete "$FIXED_DEV_TAG" --repo "$GITHUB_REPOSITORY" --yes --cleanup-tag
|
||||||
else
|
|
||||||
gh release create "$FIXED_DEV_TAG" --repo "$GITHUB_REPOSITORY" --title "Daily Dev Build" --notes "开发版发布页" --prerelease
|
|
||||||
fi
|
fi
|
||||||
|
gh release create "$FIXED_DEV_TAG" --repo "$GITHUB_REPOSITORY" --title "Daily Dev Build" --notes "开发版发布页" --prerelease --target "$TARGET_BRANCH"
|
||||||
|
RELEASE_REST_ID="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$FIXED_DEV_TAG" --jq '.id')"
|
||||||
|
gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$RELEASE_REST_ID" -f draft=false -f prerelease=true >/dev/null
|
||||||
|
|
||||||
dev-mac-arm64:
|
dev-mac-arm64:
|
||||||
needs: prepare
|
needs: prepare
|
||||||
@@ -63,6 +65,7 @@ jobs:
|
|||||||
- name: Check out git repository
|
- name: Check out git repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
|
ref: ${{ env.TARGET_BRANCH }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
@@ -79,6 +82,7 @@ jobs:
|
|||||||
run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version
|
run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
- name: Build Frontend & Type Check
|
- name: Build Frontend & Type Check
|
||||||
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
npx tsc
|
npx tsc
|
||||||
npx vite build
|
npx vite build
|
||||||
@@ -95,7 +99,10 @@ jobs:
|
|||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
mapfile -t assets < <(find release -maxdepth 1 -type f | sort)
|
assets=()
|
||||||
|
while IFS= read -r file; do
|
||||||
|
assets+=("$file")
|
||||||
|
done < <(find release -maxdepth 1 -type f | sort)
|
||||||
if [ "${#assets[@]}" -eq 0 ]; then
|
if [ "${#assets[@]}" -eq 0 ]; then
|
||||||
echo "No release files found in ./release"
|
echo "No release files found in ./release"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -109,6 +116,7 @@ jobs:
|
|||||||
- name: Check out git repository
|
- name: Check out git repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
|
ref: ${{ env.TARGET_BRANCH }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
@@ -125,6 +133,7 @@ jobs:
|
|||||||
run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version
|
run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
- name: Build Frontend & Type Check
|
- name: Build Frontend & Type Check
|
||||||
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
npx tsc
|
npx tsc
|
||||||
npx vite build
|
npx vite build
|
||||||
@@ -138,7 +147,10 @@ jobs:
|
|||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
mapfile -t assets < <(find release -maxdepth 1 -type f | sort)
|
assets=()
|
||||||
|
while IFS= read -r file; do
|
||||||
|
assets+=("$file")
|
||||||
|
done < <(find release -maxdepth 1 -type f | sort)
|
||||||
if [ "${#assets[@]}" -eq 0 ]; then
|
if [ "${#assets[@]}" -eq 0 ]; then
|
||||||
echo "No release files found in ./release"
|
echo "No release files found in ./release"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -152,6 +164,7 @@ jobs:
|
|||||||
- name: Check out git repository
|
- name: Check out git repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
|
ref: ${{ env.TARGET_BRANCH }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
@@ -168,6 +181,7 @@ jobs:
|
|||||||
run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version
|
run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
- name: Build Frontend & Type Check
|
- name: Build Frontend & Type Check
|
||||||
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
npx tsc
|
npx tsc
|
||||||
npx vite build
|
npx vite build
|
||||||
@@ -181,7 +195,10 @@ jobs:
|
|||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
mapfile -t assets < <(find release -maxdepth 1 -type f | sort)
|
assets=()
|
||||||
|
while IFS= read -r file; do
|
||||||
|
assets+=("$file")
|
||||||
|
done < <(find release -maxdepth 1 -type f | sort)
|
||||||
if [ "${#assets[@]}" -eq 0 ]; then
|
if [ "${#assets[@]}" -eq 0 ]; then
|
||||||
echo "No release files found in ./release"
|
echo "No release files found in ./release"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -195,6 +212,7 @@ jobs:
|
|||||||
- name: Check out git repository
|
- name: Check out git repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
|
ref: ${{ env.TARGET_BRANCH }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
@@ -211,6 +229,7 @@ jobs:
|
|||||||
run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version
|
run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
- name: Build Frontend & Type Check
|
- name: Build Frontend & Type Check
|
||||||
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
npx tsc
|
npx tsc
|
||||||
npx vite build
|
npx vite build
|
||||||
@@ -224,7 +243,10 @@ jobs:
|
|||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
mapfile -t assets < <(find release -maxdepth 1 -type f | sort)
|
assets=()
|
||||||
|
while IFS= read -r file; do
|
||||||
|
assets+=("$file")
|
||||||
|
done < <(find release -maxdepth 1 -type f | sort)
|
||||||
if [ "${#assets[@]}" -eq 0 ]; then
|
if [ "${#assets[@]}" -eq 0 ]; then
|
||||||
echo "No release files found in ./release"
|
echo "No release files found in ./release"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -291,11 +313,11 @@ jobs:
|
|||||||
- 此版本为每日构建的开发版,包含最新的功能和修复,但可能不够稳定,仅推荐用于测试和验证。
|
- 此版本为每日构建的开发版,包含最新的功能和修复,但可能不够稳定,仅推荐用于测试和验证。
|
||||||
|
|
||||||
## 下载
|
## 下载
|
||||||
- Windows x64: ${WINDOWS_URL:-$RELEASE_PAGE}
|
- Windows x64: [点击下载](${WINDOWS_URL:-$RELEASE_PAGE})
|
||||||
- Windows arm64: ${WINDOWS_ARM64_URL:-$RELEASE_PAGE}
|
- Windows arm64: [点击下载](${WINDOWS_ARM64_URL:-$RELEASE_PAGE})
|
||||||
- macOS(Apple Silicon): ${MAC_URL:-$RELEASE_PAGE}
|
- macOS(Apple Silicon): [点击下载](${MAC_URL:-$RELEASE_PAGE})
|
||||||
- Linux (.tar.gz): ${LINUX_TAR_URL:-$RELEASE_PAGE}
|
- Linux (.tar.gz): [点击下载](${LINUX_TAR_URL:-$RELEASE_PAGE})
|
||||||
- Linux (.AppImage): ${LINUX_APPIMAGE_URL:-$RELEASE_PAGE}
|
- Linux (.AppImage): [点击下载](${LINUX_APPIMAGE_URL:-$RELEASE_PAGE})
|
||||||
|
|
||||||
## macOS 安装提示
|
## macOS 安装提示
|
||||||
- 如果被系统提示已损坏,你需要在终端执行以下命令去除隔离标记:
|
- 如果被系统提示已损坏,你需要在终端执行以下命令去除隔离标记:
|
||||||
@@ -304,7 +326,12 @@ jobs:
|
|||||||
|
|
||||||
## 说明
|
## 说明
|
||||||
- 该发布页的同名资源会被后续构建覆盖,请勿将其视作长期归档版本。
|
- 该发布页的同名资源会被后续构建覆盖,请勿将其视作长期归档版本。
|
||||||
- 如某个平台资源暂未生成,请进入发布页查看最新状态:$RELEASE_PAGE
|
- 如某个平台资源暂未生成,请进入[发布页]($RELEASE_PAGE)查看最新状态
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
gh release edit "$TAG" --repo "$REPO" --title "Daily Dev Build" --notes-file dev_release_notes.md
|
RELEASE_REST_ID="$(gh api "repos/$REPO/releases/tags/$TAG" --jq '.id')"
|
||||||
|
jq -n --rawfile body dev_release_notes.md \
|
||||||
|
'{name:"Daily Dev Build", body:$body, draft:false, prerelease:true}' \
|
||||||
|
> release_update_payload.json
|
||||||
|
gh api --method PATCH "repos/$REPO/releases/$RELEASE_REST_ID" --input release_update_payload.json >/dev/null
|
||||||
|
gh release view "$TAG" --repo "$REPO" --json isDraft,isPrerelease,url
|
||||||
|
|||||||
145
.github/workflows/preview-nightly-main.yml
vendored
145
.github/workflows/preview-nightly-main.yml
vendored
@@ -11,6 +11,8 @@ permissions:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||||
|
FIXED_PREVIEW_TAG: nightly-preview
|
||||||
|
TARGET_BRANCH: main
|
||||||
ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/
|
ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -23,6 +25,7 @@ jobs:
|
|||||||
- name: Check out git repository
|
- name: Check out git repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
|
ref: ${{ env.TARGET_BRANCH }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
@@ -50,15 +53,36 @@ jobs:
|
|||||||
SHOULD_BUILD=false
|
SHOULD_BUILD=false
|
||||||
fi
|
fi
|
||||||
|
|
||||||
BASE_VERSION="$(node -p "require('./package.json').version.split('-')[0]")"
|
|
||||||
YEAR_2="$(TZ=Asia/Shanghai date +%y)"
|
YEAR_2="$(TZ=Asia/Shanghai date +%y)"
|
||||||
EXISTING_COUNT="$(gh api --paginate "repos/${GITHUB_REPOSITORY}/releases" --jq "[.[].tag_name | select(test(\"^v${BASE_VERSION}-preview[.]${YEAR_2}[.][0-9]+$\"))] | length")"
|
YEARLY_RUN_COUNT=1
|
||||||
NEXT_COUNT=$((EXISTING_COUNT + 1))
|
LAST_VERSION="$(gh release view "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" --json body --jq '.body' 2>/dev/null | grep -Eo '0\.[0-9]{2}\.[0-9]+' | head -n 1 || true)"
|
||||||
PREVIEW_VERSION="${BASE_VERSION}-preview.${YEAR_2}.${NEXT_COUNT}"
|
if [[ "$LAST_VERSION" =~ ^0\.([0-9]{2})\.([0-9]+)$ ]]; then
|
||||||
|
LAST_YEAR="${BASH_REMATCH[1]}"
|
||||||
|
LAST_COUNT="${BASH_REMATCH[2]}"
|
||||||
|
if [ "$LAST_YEAR" = "$YEAR_2" ]; then
|
||||||
|
YEARLY_RUN_COUNT=$((LAST_COUNT + 1))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
PREVIEW_VERSION="0.${YEAR_2}.${YEARLY_RUN_COUNT}"
|
||||||
|
|
||||||
echo "should_build=$SHOULD_BUILD" >> "$GITHUB_OUTPUT"
|
echo "should_build=$SHOULD_BUILD" >> "$GITHUB_OUTPUT"
|
||||||
echo "preview_version=$PREVIEW_VERSION" >> "$GITHUB_OUTPUT"
|
echo "preview_version=$PREVIEW_VERSION" >> "$GITHUB_OUTPUT"
|
||||||
echo "Preview version: $PREVIEW_VERSION (commits in last 24h on main: $COMMITS_24H)"
|
echo "Preview version: $PREVIEW_VERSION (commits in last 24h on main: $COMMITS_24H, yearly count: $YEARLY_RUN_COUNT)"
|
||||||
|
|
||||||
|
- name: Recreate fixed preview prerelease
|
||||||
|
if: steps.meta.outputs.should_build == 'true'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if gh release view "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||||
|
gh release delete "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" --yes --cleanup-tag
|
||||||
|
fi
|
||||||
|
gh release create "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" --title "Preview Nightly Build" --notes "预览版发布页" --prerelease --target "$TARGET_BRANCH"
|
||||||
|
RELEASE_REST_ID="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$FIXED_PREVIEW_TAG" --jq '.id')"
|
||||||
|
gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$RELEASE_REST_ID" -f draft=false -f prerelease=true >/dev/null
|
||||||
|
|
||||||
preview-mac-arm64:
|
preview-mac-arm64:
|
||||||
needs: prepare
|
needs: prepare
|
||||||
@@ -68,6 +92,7 @@ jobs:
|
|||||||
- name: Check out git repository
|
- name: Check out git repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
|
ref: ${{ env.TARGET_BRANCH }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
@@ -84,19 +109,34 @@ jobs:
|
|||||||
run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version
|
run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
- name: Build Frontend & Type Check
|
- name: Build Frontend & Type Check
|
||||||
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
npx tsc
|
npx tsc
|
||||||
npx vite build
|
npx vite build
|
||||||
|
|
||||||
- name: Package and Publish macOS arm64 preview
|
- name: Package macOS arm64 preview artifacts
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
export ELECTRON_BUILDER_BINARIES_MIRROR="https://github.com/electron-userland/electron-builder-binaries/releases/download/"
|
export ELECTRON_BUILDER_BINARIES_MIRROR="https://github.com/electron-userland/electron-builder-binaries/releases/download/"
|
||||||
echo "Using ELECTRON_BUILDER_BINARIES_MIRROR=$ELECTRON_BUILDER_BINARIES_MIRROR"
|
echo "Using ELECTRON_BUILDER_BINARIES_MIRROR=$ELECTRON_BUILDER_BINARIES_MIRROR"
|
||||||
npx electron-builder --mac dmg --arm64 --publish always '--config.publish.releaseType=prerelease' '--config.publish.channel=preview'
|
npx electron-builder --mac dmg --arm64 --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-arm64.${ext}'
|
||||||
|
|
||||||
|
- name: Upload macOS arm64 assets to fixed preview release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
assets=()
|
||||||
|
while IFS= read -r file; do
|
||||||
|
assets+=("$file")
|
||||||
|
done < <(find release -maxdepth 1 -type f | sort)
|
||||||
|
if [ "${#assets[@]}" -eq 0 ]; then
|
||||||
|
echo "No release files found in ./release"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
gh release upload "$FIXED_PREVIEW_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
|
||||||
|
|
||||||
preview-linux:
|
preview-linux:
|
||||||
needs: prepare
|
needs: prepare
|
||||||
@@ -106,6 +146,7 @@ jobs:
|
|||||||
- name: Check out git repository
|
- name: Check out git repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
|
ref: ${{ env.TARGET_BRANCH }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
@@ -122,15 +163,32 @@ jobs:
|
|||||||
run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version
|
run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
- name: Build Frontend & Type Check
|
- name: Build Frontend & Type Check
|
||||||
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
npx tsc
|
npx tsc
|
||||||
npx vite build
|
npx vite build
|
||||||
|
|
||||||
- name: Package and Publish Linux preview
|
- name: Package Linux preview artifacts
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
npx electron-builder --linux --publish always '--config.publish.releaseType=prerelease' '--config.publish.channel=preview'
|
npx electron-builder --linux --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-linux.${ext}'
|
||||||
|
|
||||||
|
- name: Upload Linux assets to fixed preview release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
assets=()
|
||||||
|
while IFS= read -r file; do
|
||||||
|
assets+=("$file")
|
||||||
|
done < <(find release -maxdepth 1 -type f | sort)
|
||||||
|
if [ "${#assets[@]}" -eq 0 ]; then
|
||||||
|
echo "No release files found in ./release"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
gh release upload "$FIXED_PREVIEW_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
|
||||||
|
|
||||||
preview-win-x64:
|
preview-win-x64:
|
||||||
needs: prepare
|
needs: prepare
|
||||||
@@ -140,6 +198,7 @@ jobs:
|
|||||||
- name: Check out git repository
|
- name: Check out git repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
|
ref: ${{ env.TARGET_BRANCH }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
@@ -156,15 +215,32 @@ jobs:
|
|||||||
run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version
|
run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
- name: Build Frontend & Type Check
|
- name: Build Frontend & Type Check
|
||||||
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
npx tsc
|
npx tsc
|
||||||
npx vite build
|
npx vite build
|
||||||
|
|
||||||
- name: Package and Publish Windows x64 preview
|
- name: Package Windows x64 preview artifacts
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
npx electron-builder --win nsis --x64 --publish always '--config.publish.releaseType=prerelease' '--config.publish.channel=preview' '--config.artifactName=${productName}-${version}-x64-Setup.${ext}'
|
npx electron-builder --win nsis --x64 --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-x64-Setup.${ext}'
|
||||||
|
|
||||||
|
- name: Upload Windows x64 assets to fixed preview release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
assets=()
|
||||||
|
while IFS= read -r file; do
|
||||||
|
assets+=("$file")
|
||||||
|
done < <(find release -maxdepth 1 -type f | sort)
|
||||||
|
if [ "${#assets[@]}" -eq 0 ]; then
|
||||||
|
echo "No release files found in ./release"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
gh release upload "$FIXED_PREVIEW_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
|
||||||
|
|
||||||
preview-win-arm64:
|
preview-win-arm64:
|
||||||
needs: prepare
|
needs: prepare
|
||||||
@@ -174,6 +250,7 @@ jobs:
|
|||||||
- name: Check out git repository
|
- name: Check out git repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
|
ref: ${{ env.TARGET_BRANCH }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
@@ -190,15 +267,32 @@ jobs:
|
|||||||
run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version
|
run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
- name: Build Frontend & Type Check
|
- name: Build Frontend & Type Check
|
||||||
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
npx tsc
|
npx tsc
|
||||||
npx vite build
|
npx vite build
|
||||||
|
|
||||||
- name: Package and Publish Windows arm64 preview
|
- name: Package Windows arm64 preview artifacts
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
npx electron-builder --win nsis --arm64 --publish always '--config.publish.releaseType=prerelease' '--config.publish.channel=preview-arm64' '--config.artifactName=${productName}-${version}-arm64-Setup.${ext}'
|
npx electron-builder --win nsis --arm64 --publish never '--config.publish.channel=preview-arm64' '--config.artifactName=${productName}-preview-arm64-Setup.${ext}'
|
||||||
|
|
||||||
|
- name: Upload Windows arm64 assets to fixed preview release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
assets=()
|
||||||
|
while IFS= read -r file; do
|
||||||
|
assets+=("$file")
|
||||||
|
done < <(find release -maxdepth 1 -type f | sort)
|
||||||
|
if [ "${#assets[@]}" -eq 0 ]; then
|
||||||
|
echo "No release files found in ./release"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
gh release upload "$FIXED_PREVIEW_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
|
||||||
|
|
||||||
update-preview-release-notes:
|
update-preview-release-notes:
|
||||||
needs:
|
needs:
|
||||||
@@ -217,7 +311,8 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
TAG="v${{ needs.prepare.outputs.preview_version }}"
|
TAG="$FIXED_PREVIEW_TAG"
|
||||||
|
CURRENT_PREVIEW_VERSION="${{ needs.prepare.outputs.preview_version }}"
|
||||||
REPO="$GITHUB_REPOSITORY"
|
REPO="$GITHUB_REPOSITORY"
|
||||||
RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG"
|
RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG"
|
||||||
|
|
||||||
@@ -259,20 +354,26 @@ jobs:
|
|||||||
## Preview Nightly 说明
|
## Preview Nightly 说明
|
||||||
- 该版本为 **预览版**,用于提前体验即将发布的功能与修复。
|
- 该版本为 **预览版**,用于提前体验即将发布的功能与修复。
|
||||||
- 可能包含尚未完全稳定的改动,不建议长期使用
|
- 可能包含尚未完全稳定的改动,不建议长期使用
|
||||||
|
- 当前版本号:\`$CURRENT_PREVIEW_VERSION\`
|
||||||
|
|
||||||
## 下载
|
## 下载
|
||||||
- Windows x64: ${WINDOWS_URL:-$RELEASE_PAGE}
|
- Windows x64: [点击下载](${WINDOWS_URL:-$RELEASE_PAGE})
|
||||||
- Windows arm64: ${WINDOWS_ARM64_URL:-$RELEASE_PAGE}
|
- Windows arm64: [点击下载](${WINDOWS_ARM64_URL:-$RELEASE_PAGE})
|
||||||
- macOS(Apple Silicon): ${MAC_URL:-$RELEASE_PAGE}
|
- macOS(Apple Silicon): [点击下载](${MAC_URL:-$RELEASE_PAGE})
|
||||||
- Linux (.tar.gz): ${LINUX_TAR_URL:-$RELEASE_PAGE}
|
- Linux (.tar.gz): [点击下载](${LINUX_TAR_URL:-$RELEASE_PAGE})
|
||||||
- Linux (.AppImage): ${LINUX_APPIMAGE_URL:-$RELEASE_PAGE}
|
- Linux (.AppImage): [点击下载](${LINUX_APPIMAGE_URL:-$RELEASE_PAGE})
|
||||||
|
|
||||||
## macOS 安装提示
|
## macOS 安装提示
|
||||||
- 如果被系统提示已损坏,你需要在终端执行以下命令去除隔离标记:
|
- 如果被系统提示已损坏,你需要在终端执行以下命令去除隔离标记:
|
||||||
- \`xattr -dr com.apple.quarantine "/Applications/WeFlow.app"\`
|
- \`xattr -dr com.apple.quarantine "/Applications/WeFlow.app"\`
|
||||||
- 执行后重新打开 WeFlow。
|
- 执行后重新打开 WeFlow。
|
||||||
|
|
||||||
> 如某个平台链接暂未生成,请前往发布页查看最新资源:$RELEASE_PAGE
|
> 如某个平台链接暂未生成,请前往[发布页]($RELEASE_PAGE)查看最新资源
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
gh release edit "$TAG" --repo "$REPO" --notes-file preview_release_notes.md
|
RELEASE_REST_ID="$(gh api "repos/$REPO/releases/tags/$TAG" --jq '.id')"
|
||||||
|
jq -n --rawfile body preview_release_notes.md \
|
||||||
|
'{name:"Preview Nightly Build", body:$body, draft:false, prerelease:true}' \
|
||||||
|
> release_update_payload.json
|
||||||
|
gh api --method PATCH "repos/$REPO/releases/$RELEASE_REST_ID" --input release_update_payload.json >/dev/null
|
||||||
|
gh release view "$TAG" --repo "$REPO" --json isDraft,isPrerelease,url
|
||||||
|
|||||||
16
.github/workflows/release.yml
vendored
16
.github/workflows/release.yml
vendored
@@ -39,6 +39,7 @@ jobs:
|
|||||||
npm version $VERSION --no-git-tag-version --allow-same-version
|
npm version $VERSION --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
- name: Build Frontend & Type Check
|
- name: Build Frontend & Type Check
|
||||||
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
npx tsc
|
npx tsc
|
||||||
npx vite build
|
npx vite build
|
||||||
@@ -95,6 +96,7 @@ jobs:
|
|||||||
npm version $VERSION --no-git-tag-version --allow-same-version
|
npm version $VERSION --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
- name: Build Frontend & Type Check
|
- name: Build Frontend & Type Check
|
||||||
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
npx tsc
|
npx tsc
|
||||||
npx vite build
|
npx vite build
|
||||||
@@ -145,6 +147,7 @@ jobs:
|
|||||||
npm version $VERSION --no-git-tag-version --allow-same-version
|
npm version $VERSION --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
- name: Build Frontend & Type Check
|
- name: Build Frontend & Type Check
|
||||||
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
npx tsc
|
npx tsc
|
||||||
npx vite build
|
npx vite build
|
||||||
@@ -195,6 +198,7 @@ jobs:
|
|||||||
npm version $VERSION --no-git-tag-version --allow-same-version
|
npm version $VERSION --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
- name: Build Frontend & Type Check
|
- name: Build Frontend & Type Check
|
||||||
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
npx tsc
|
npx tsc
|
||||||
npx vite build
|
npx vite build
|
||||||
@@ -276,18 +280,18 @@ jobs:
|
|||||||
[点击加入 Telegram 频道](https://t.me/weflow_cc)
|
[点击加入 Telegram 频道](https://t.me/weflow_cc)
|
||||||
|
|
||||||
## 下载
|
## 下载
|
||||||
- Windows x64(Win10+): ${WINDOWS_URL:-$RELEASE_PAGE}
|
- Windows x64(Win10+): [点击下载](${WINDOWS_URL:-$RELEASE_PAGE})
|
||||||
- Windows arm64: ${WINDOWS_ARM64_URL:-$RELEASE_PAGE}
|
- Windows arm64: [点击下载](${WINDOWS_ARM64_URL:-$RELEASE_PAGE})
|
||||||
- macOS(M系列芯片): ${MAC_URL:-$RELEASE_PAGE}
|
- macOS(M系列芯片): [点击下载](${MAC_URL:-$RELEASE_PAGE})
|
||||||
- Linux (.tar.gz): ${LINUX_TAR_URL:-$RELEASE_PAGE}
|
- Linux (.tar.gz): [点击下载](${LINUX_TAR_URL:-$RELEASE_PAGE})
|
||||||
- Linux (.AppImage): ${LINUX_APPIMAGE_URL:-$RELEASE_PAGE}
|
- Linux (.AppImage): [点击下载](${LINUX_APPIMAGE_URL:-$RELEASE_PAGE})
|
||||||
|
|
||||||
## macOS 安装提示
|
## macOS 安装提示
|
||||||
- 如果被系统提示已损坏,你需要在终端执行以下命令去除隔离标记:
|
- 如果被系统提示已损坏,你需要在终端执行以下命令去除隔离标记:
|
||||||
- \`xattr -dr com.apple.quarantine "/Applications/WeFlow.app"\`
|
- \`xattr -dr com.apple.quarantine "/Applications/WeFlow.app"\`
|
||||||
- 执行后重新打开 WeFlow。
|
- 执行后重新打开 WeFlow。
|
||||||
|
|
||||||
> 如果某个平台链接暂时未生成,可进入完整发布页查看全部资源:$RELEASE_PAGE
|
> 如果某个平台链接暂时未生成,可进入[完整发布页]($RELEASE_PAGE)查看全部资源
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
gh release edit "$TAG" --repo "$REPO" --notes-file release_notes.md
|
gh release edit "$TAG" --repo "$REPO" --notes-file release_notes.md
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -71,4 +71,6 @@ resources/wx_send
|
|||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
/pnpm-workspace.yaml
|
/pnpm-workspace.yaml
|
||||||
wechat-research-site
|
wechat-research-site
|
||||||
.codex
|
.codex
|
||||||
|
weflow-web-offical
|
||||||
|
Insight
|
||||||
@@ -68,6 +68,7 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
|
|||||||
| 功能模块 | 说明 |
|
| 功能模块 | 说明 |
|
||||||
|---------|------|
|
|---------|------|
|
||||||
| **聊天** | 解密聊天中的图片、视频、实况(仅支持谷歌协议拍摄的实况);支持**修改**、删除**本地**消息;实时刷新最新消息,无需生成解密中间数据库 |
|
| **聊天** | 解密聊天中的图片、视频、实况(仅支持谷歌协议拍摄的实况);支持**修改**、删除**本地**消息;实时刷新最新消息,无需生成解密中间数据库 |
|
||||||
|
| **消息防撤回** | 防止其他人发送的消息被撤回 |
|
||||||
| **实时弹窗通知** | 新消息到达时提供桌面弹窗提醒,便于及时查看重要会话,提供黑白名单功能 |
|
| **实时弹窗通知** | 新消息到达时提供桌面弹窗提醒,便于及时查看重要会话,提供黑白名单功能 |
|
||||||
| **私聊分析** | 统计好友间消息数量;分析消息类型与发送比例;查看消息时段分布等 |
|
| **私聊分析** | 统计好友间消息数量;分析消息类型与发送比例;查看消息时段分布等 |
|
||||||
| **群聊分析** | 查看群成员详细信息;分析群内发言排行、活跃时段和媒体内容 |
|
| **群聊分析** | 查看群成员详细信息;分析群内发言排行、活跃时段和媒体内容 |
|
||||||
|
|||||||
122
docs/HTTP-API.md
122
docs/HTTP-API.md
@@ -433,7 +433,123 @@ curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom&include
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. 访问导出媒体
|
## 7. 朋友圈接口
|
||||||
|
|
||||||
|
### 7.1 获取朋友圈时间线
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/sns/timeline
|
||||||
|
```
|
||||||
|
|
||||||
|
参数:
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `limit` | number | 否 | 返回数量,默认 20,范围 `1~200` |
|
||||||
|
| `offset` | number | 否 | 偏移量,默认 0 |
|
||||||
|
| `usernames` | string | 否 | 发布者过滤,逗号分隔,如 `wxid_a,wxid_b` |
|
||||||
|
| `keyword` | string | 否 | 关键词过滤(正文) |
|
||||||
|
| `start` | string | 否 | 开始时间,支持 `YYYYMMDD` 或秒/毫秒时间戳 |
|
||||||
|
| `end` | string | 否 | 结束时间,支持 `YYYYMMDD` 或秒/毫秒时间戳 |
|
||||||
|
| `media` | number | 否 | 是否返回可直接访问的媒体地址,默认 `1` |
|
||||||
|
| `replace` | number | 否 | `media=1` 时,是否用解析地址覆盖 `media.url/thumb`,默认 `1` |
|
||||||
|
| `inline` | number | 否 | `media=1` 时,是否内联返回 `data:` URL,默认 `0` |
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "http://127.0.0.1:5031/api/v1/sns/timeline?limit=5"
|
||||||
|
curl "http://127.0.0.1:5031/api/v1/sns/timeline?usernames=wxid_a,wxid_b&keyword=旅行"
|
||||||
|
curl "http://127.0.0.1:5031/api/v1/sns/timeline?limit=3&media=1&replace=1"
|
||||||
|
curl "http://127.0.0.1:5031/api/v1/sns/timeline?limit=3&media=1&inline=1"
|
||||||
|
```
|
||||||
|
|
||||||
|
媒体字段说明(`media=1`):
|
||||||
|
|
||||||
|
- `media[].url/thumb`:你应该优先直接使用的字段。
|
||||||
|
- `replace=1`(默认)时,`media[].url/thumb` 会直接被替换成可访问地址,等价于 `resolvedUrl/resolvedThumbUrl`。
|
||||||
|
- `replace=0` 时,`media[].url/thumb` 仍保留微信原始地址;这时再结合下面的 `raw/proxy/resolved` 字段自己决定用哪一个。
|
||||||
|
- `media[].rawUrl/rawThumb`:原始朋友圈地址
|
||||||
|
- `media[].proxyUrl/proxyThumbUrl`:可直接访问的代理地址
|
||||||
|
- `media[].resolvedUrl/resolvedThumbUrl`:最终可用地址(`inline=1` 时可能是 `data:` URL)
|
||||||
|
- `media[].token/key/encIdx`:微信源数据里的访问/解密参数。通常不需要你自己处理;如果你手动调用 `/api/v1/sns/media/proxy`,把当前条目的 `url` 和 `key` 原样传回即可。
|
||||||
|
- `media[].livePhoto`:实况图的视频部分。外层 `media[].url/thumb` 仍是封面图,`livePhoto` 内部会再提供一组自己的 `url/thumb/raw*/proxy*/resolved*` 字段。
|
||||||
|
- `media=0` 时,不会补充 `raw*/proxy*/resolved*`,接口只返回原始 `url/thumb` 以及源字段(如 `key/token/encIdx`)。
|
||||||
|
|
||||||
|
### 7.2 获取朋友圈发布者
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/sns/usernames
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 获取朋友圈导出统计
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/sns/export/stats
|
||||||
|
```
|
||||||
|
|
||||||
|
参数:
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `fast` | number | 否 | `1` 使用快速统计(优先缓存) |
|
||||||
|
|
||||||
|
### 7.4 朋友圈媒体代理
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/sns/media/proxy
|
||||||
|
```
|
||||||
|
|
||||||
|
参数:
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `url` | string | 是 | 媒体原始 URL |
|
||||||
|
| `key` | string/number | 否 | 解密 key(部分资源需要) |
|
||||||
|
|
||||||
|
### 7.5 导出朋友圈
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/v1/sns/export
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
Body 示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"outputDir": "C:\\Users\\Alice\\Desktop\\sns-export",
|
||||||
|
"format": "json",
|
||||||
|
"usernames": "wxid_a,wxid_b",
|
||||||
|
"keyword": "旅行",
|
||||||
|
"exportMedia": true,
|
||||||
|
"exportImages": true,
|
||||||
|
"exportLivePhotos": true,
|
||||||
|
"exportVideos": true,
|
||||||
|
"start": "20250101",
|
||||||
|
"end": "20251231"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`format` 支持:`json`、`html`、`arkmejson`(兼容写法:`arkme-json`)。
|
||||||
|
|
||||||
|
### 7.6 朋友圈防删开关
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/sns/block-delete/status
|
||||||
|
POST /api/v1/sns/block-delete/install
|
||||||
|
POST /api/v1/sns/block-delete/uninstall
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.7 删除单条朋友圈
|
||||||
|
|
||||||
|
```http
|
||||||
|
DELETE /api/v1/sns/post/{postId}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 访问导出媒体
|
||||||
|
|
||||||
> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json)
|
> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json)
|
||||||
|
|
||||||
@@ -476,7 +592,7 @@ curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/emojis/emoji_300.gif"
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. 使用示例
|
## 9. 使用示例
|
||||||
|
|
||||||
### PowerShell
|
### PowerShell
|
||||||
|
|
||||||
@@ -525,7 +641,7 @@ members = requests.get(
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. 注意事项
|
## 10. 注意事项
|
||||||
|
|
||||||
1. API 仅监听本机 `127.0.0.1`,不对外网开放。
|
1. API 仅监听本机 `127.0.0.1`,不对外网开放。
|
||||||
2. 使用前需要先在 WeFlow 中完成数据库连接。
|
2. 使用前需要先在 WeFlow 中完成数据库连接。
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ interface ExportWorkerConfig {
|
|||||||
sessionIds: string[]
|
sessionIds: string[]
|
||||||
outputDir: string
|
outputDir: string
|
||||||
options: ExportOptions
|
options: ExportOptions
|
||||||
|
dbPath?: string
|
||||||
|
decryptKey?: string
|
||||||
|
myWxid?: string
|
||||||
resourcesPath?: string
|
resourcesPath?: string
|
||||||
userDataPath?: string
|
userDataPath?: string
|
||||||
logEnabled?: boolean
|
logEnabled?: boolean
|
||||||
@@ -29,6 +32,11 @@ async function run() {
|
|||||||
|
|
||||||
wcdbService.setPaths(config.resourcesPath || '', config.userDataPath || '')
|
wcdbService.setPaths(config.resourcesPath || '', config.userDataPath || '')
|
||||||
wcdbService.setLogEnabled(config.logEnabled === true)
|
wcdbService.setLogEnabled(config.logEnabled === true)
|
||||||
|
exportService.setRuntimeConfig({
|
||||||
|
dbPath: config.dbPath,
|
||||||
|
decryptKey: config.decryptKey,
|
||||||
|
myWxid: config.myWxid
|
||||||
|
})
|
||||||
|
|
||||||
const result = await exportService.exportSessions(
|
const result = await exportService.exportSessions(
|
||||||
Array.isArray(config.sessionIds) ? config.sessionIds : [],
|
Array.isArray(config.sessionIds) ? config.sessionIds : [],
|
||||||
|
|||||||
340
electron/main.ts
340
electron/main.ts
@@ -30,7 +30,7 @@ import { cloudControlService } from './services/cloudControlService'
|
|||||||
import { destroyNotificationWindow, registerNotificationHandlers, showNotification } from './windows/notificationWindow'
|
import { destroyNotificationWindow, registerNotificationHandlers, showNotification } from './windows/notificationWindow'
|
||||||
import { httpService } from './services/httpService'
|
import { httpService } from './services/httpService'
|
||||||
import { messagePushService } from './services/messagePushService'
|
import { messagePushService } from './services/messagePushService'
|
||||||
|
import { bizService } from './services/bizService'
|
||||||
|
|
||||||
// 配置自动更新
|
// 配置自动更新
|
||||||
autoUpdater.autoDownload = false
|
autoUpdater.autoDownload = false
|
||||||
@@ -38,18 +38,28 @@ autoUpdater.autoInstallOnAppQuit = true
|
|||||||
autoUpdater.disableDifferentialDownload = true // 禁用差分更新,强制全量下载
|
autoUpdater.disableDifferentialDownload = true // 禁用差分更新,强制全量下载
|
||||||
// 更新通道策略:
|
// 更新通道策略:
|
||||||
// - 稳定版(如 4.3.0)默认走 latest
|
// - 稳定版(如 4.3.0)默认走 latest
|
||||||
// - 预览版(如 4.3.0-preview.26.1)默认走 preview
|
// - 预览版(如 0.26.2)默认走 preview(0.年.当年发布序号)
|
||||||
// - 开发版(如 4.3.0-dev.26.3.4)默认走 dev
|
// - 开发版(如 26.4.5)默认走 dev(年.月.日)
|
||||||
// - 用户可在设置页切换稳定/预览/开发,切换后即时生效
|
// - 用户可在设置页切换稳定/预览/开发,切换后即时生效
|
||||||
// 同时区分 Windows x64 / arm64,避免更新清单互相覆盖。
|
// 同时区分 Windows x64 / arm64,避免更新清单互相覆盖。
|
||||||
const appVersion = app.getVersion()
|
const appVersion = app.getVersion()
|
||||||
|
const inferUpdateTrackFromVersion = (version: string): 'stable' | 'preview' | 'dev' => {
|
||||||
|
const normalized = String(version || '').trim().replace(/^v/i, '')
|
||||||
|
if (/^0\.\d{2}\.\d+$/i.test(normalized)) return 'preview'
|
||||||
|
if (/^\d{2}\.\d{1,2}\.\d{1,2}$/i.test(normalized)) return 'dev'
|
||||||
|
// 兼容旧版命名(如 4.3.0-preview.26.1 / 4.3.0-dev.26.3.4)
|
||||||
|
if (/-preview\.\d+\.\d+$/i.test(normalized)) return 'preview'
|
||||||
|
if (/-dev\.\d+\.\d+\.\d+$/i.test(normalized)) return 'dev'
|
||||||
|
// 兼容 alpha/beta/rc 预发布
|
||||||
|
if (/(alpha|beta|rc)/i.test(normalized)) return 'dev'
|
||||||
|
return 'stable'
|
||||||
|
}
|
||||||
|
|
||||||
const defaultUpdateTrack: 'stable' | 'preview' | 'dev' = (() => {
|
const defaultUpdateTrack: 'stable' | 'preview' | 'dev' = (() => {
|
||||||
if (/-preview\.\d+\.\d+$/i.test(appVersion)) return 'preview'
|
const inferred = inferUpdateTrackFromVersion(appVersion)
|
||||||
if (/-dev\.\d+\.\d+\.\d+$/i.test(appVersion)) return 'dev'
|
if (inferred === 'preview' || inferred === 'dev') return inferred
|
||||||
if (/(alpha|beta|rc)/i.test(appVersion)) return 'dev'
|
|
||||||
return 'stable'
|
return 'stable'
|
||||||
})()
|
})()
|
||||||
const isPrereleaseBuild = defaultUpdateTrack !== 'stable'
|
|
||||||
let configService: ConfigService | null = null
|
let configService: ConfigService | null = null
|
||||||
|
|
||||||
const normalizeUpdateTrack = (raw: unknown): 'stable' | 'preview' | 'dev' | null => {
|
const normalizeUpdateTrack = (raw: unknown): 'stable' | 'preview' | 'dev' | null => {
|
||||||
@@ -62,16 +72,116 @@ const getEffectiveUpdateTrack = (): 'stable' | 'preview' | 'dev' => {
|
|||||||
return configuredTrack || defaultUpdateTrack
|
return configuredTrack || defaultUpdateTrack
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isRemoteVersionNewer = (latestVersion: string, currentVersion: string): boolean => {
|
||||||
|
const latest = String(latestVersion || '').trim()
|
||||||
|
const current = String(currentVersion || '').trim()
|
||||||
|
if (!latest || !current) return false
|
||||||
|
|
||||||
|
const parseVersion = (version: string) => {
|
||||||
|
const normalized = version.replace(/^v/i, '')
|
||||||
|
const [main, pre = ''] = normalized.split('-', 2)
|
||||||
|
const core = main.split('.').map((segment) => Number.parseInt(segment, 10) || 0)
|
||||||
|
const prerelease = pre ? pre.split('.').map((segment) => /^\d+$/.test(segment) ? Number.parseInt(segment, 10) : segment) : []
|
||||||
|
return { core, prerelease }
|
||||||
|
}
|
||||||
|
|
||||||
|
const compareParsedVersion = (a: ReturnType<typeof parseVersion>, b: ReturnType<typeof parseVersion>): number => {
|
||||||
|
const maxLen = Math.max(a.core.length, b.core.length)
|
||||||
|
for (let i = 0; i < maxLen; i += 1) {
|
||||||
|
const left = a.core[i] || 0
|
||||||
|
const right = b.core[i] || 0
|
||||||
|
if (left > right) return 1
|
||||||
|
if (left < right) return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
const aPre = a.prerelease
|
||||||
|
const bPre = b.prerelease
|
||||||
|
if (aPre.length === 0 && bPre.length === 0) return 0
|
||||||
|
if (aPre.length === 0) return 1
|
||||||
|
if (bPre.length === 0) return -1
|
||||||
|
|
||||||
|
const preMaxLen = Math.max(aPre.length, bPre.length)
|
||||||
|
for (let i = 0; i < preMaxLen; i += 1) {
|
||||||
|
const left = aPre[i]
|
||||||
|
const right = bPre[i]
|
||||||
|
if (left === undefined) return -1
|
||||||
|
if (right === undefined) return 1
|
||||||
|
if (left === right) continue
|
||||||
|
|
||||||
|
const leftNum = typeof left === 'number'
|
||||||
|
const rightNum = typeof right === 'number'
|
||||||
|
if (leftNum && rightNum) return left > right ? 1 : -1
|
||||||
|
if (leftNum) return -1
|
||||||
|
if (rightNum) return 1
|
||||||
|
return String(left) > String(right) ? 1 : -1
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return autoUpdater.currentVersion.compare(latest) < 0
|
||||||
|
} catch {
|
||||||
|
return compareParsedVersion(parseVersion(latest), parseVersion(current)) > 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldOfferUpdateForTrack = (latestVersion: string, currentVersion: string): boolean => {
|
||||||
|
if (isRemoteVersionNewer(latestVersion, currentVersion)) return true
|
||||||
|
const effectiveTrack = getEffectiveUpdateTrack()
|
||||||
|
const currentTrack = inferUpdateTrackFromVersion(currentVersion)
|
||||||
|
// 切换通道后,目标通道最新版本与当前版本不同即提示更新(即使是降级)
|
||||||
|
if (effectiveTrack !== currentTrack && latestVersion !== currentVersion) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastAppliedUpdaterChannel: string | null = null
|
||||||
|
let lastAppliedUpdaterFeedUrl: string | null = null
|
||||||
|
const resetUpdaterProviderCache = () => {
|
||||||
|
const updater = autoUpdater as any
|
||||||
|
// electron-updater 会缓存 provider;切换 channel 后需清理缓存,避免仍请求旧通道
|
||||||
|
for (const key of ['clientPromise', '_clientPromise', 'updateInfoAndProvider']) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(updater, key)) {
|
||||||
|
updater[key] = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getUpdaterFeedUrlByTrack = (track: 'stable' | 'preview' | 'dev'): string => {
|
||||||
|
const repoBase = 'https://github.com/hicccc77/WeFlow/releases'
|
||||||
|
if (track === 'stable') return `${repoBase}/latest/download`
|
||||||
|
if (track === 'preview') return `${repoBase}/download/nightly-preview`
|
||||||
|
return `${repoBase}/download/nightly-dev`
|
||||||
|
}
|
||||||
|
|
||||||
const applyAutoUpdateChannel = (reason: 'startup' | 'settings' = 'startup') => {
|
const applyAutoUpdateChannel = (reason: 'startup' | 'settings' = 'startup') => {
|
||||||
const track = getEffectiveUpdateTrack()
|
const track = getEffectiveUpdateTrack()
|
||||||
|
const currentTrack = inferUpdateTrackFromVersion(appVersion)
|
||||||
const baseUpdateChannel = track === 'stable' ? 'latest' : track
|
const baseUpdateChannel = track === 'stable' ? 'latest' : track
|
||||||
autoUpdater.allowPrerelease = track !== 'stable'
|
const nextFeedUrl = getUpdaterFeedUrlByTrack(track)
|
||||||
autoUpdater.allowDowngrade = isPrereleaseBuild && track === 'stable'
|
const nextUpdaterChannel =
|
||||||
autoUpdater.channel =
|
|
||||||
process.platform === 'win32' && process.arch === 'arm64'
|
process.platform === 'win32' && process.arch === 'arm64'
|
||||||
? `${baseUpdateChannel}-arm64`
|
? `${baseUpdateChannel}-arm64`
|
||||||
: baseUpdateChannel
|
: baseUpdateChannel
|
||||||
console.log(`[Update](${reason}) 当前版本 ${appVersion},渠道偏好: ${track},更新通道: ${autoUpdater.channel}`)
|
if (
|
||||||
|
(lastAppliedUpdaterChannel && lastAppliedUpdaterChannel !== nextUpdaterChannel) ||
|
||||||
|
(lastAppliedUpdaterFeedUrl && lastAppliedUpdaterFeedUrl !== nextFeedUrl)
|
||||||
|
) {
|
||||||
|
resetUpdaterProviderCache()
|
||||||
|
}
|
||||||
|
autoUpdater.allowPrerelease = track !== 'stable'
|
||||||
|
// 只要用户当前选择的目标通道与当前安装版本所属通道不同,就允许跨通道更新(含降级)
|
||||||
|
autoUpdater.allowDowngrade = track !== currentTrack
|
||||||
|
// 统一走 generic feed,确保 preview/dev 命中各自固定发布页,不受 GitHub provider 的 prerelease 选择影响。
|
||||||
|
autoUpdater.setFeedURL({
|
||||||
|
provider: 'generic',
|
||||||
|
url: nextFeedUrl,
|
||||||
|
channel: nextUpdaterChannel
|
||||||
|
})
|
||||||
|
autoUpdater.channel = nextUpdaterChannel
|
||||||
|
lastAppliedUpdaterChannel = nextUpdaterChannel
|
||||||
|
lastAppliedUpdaterFeedUrl = nextFeedUrl
|
||||||
|
console.log(`[Update](${reason}) 当前版本 ${appVersion},当前轨道: ${currentTrack},渠道偏好: ${track},更新通道: ${autoUpdater.channel},feed=${nextFeedUrl},allowDowngrade=${autoUpdater.allowDowngrade}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
applyAutoUpdateChannel('startup')
|
applyAutoUpdateChannel('startup')
|
||||||
@@ -80,6 +190,118 @@ const AUTO_UPDATE_ENABLED =
|
|||||||
process.env.AUTO_UPDATE_ENABLED === '1' ||
|
process.env.AUTO_UPDATE_ENABLED === '1' ||
|
||||||
(process.env.AUTO_UPDATE_ENABLED == null && !process.env.VITE_DEV_SERVER_URL)
|
(process.env.AUTO_UPDATE_ENABLED == null && !process.env.VITE_DEV_SERVER_URL)
|
||||||
|
|
||||||
|
const getLaunchAtStartupUnsupportedReason = (): string | null => {
|
||||||
|
if (process.platform !== 'win32' && process.platform !== 'darwin') {
|
||||||
|
return '当前平台暂不支持开机自启动'
|
||||||
|
}
|
||||||
|
if (!app.isPackaged) {
|
||||||
|
return '仅安装后的 Windows / macOS 版本支持开机自启动'
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLaunchAtStartupSupported = (): boolean => getLaunchAtStartupUnsupportedReason() == null
|
||||||
|
|
||||||
|
const getStoredLaunchAtStartupPreference = (): boolean | undefined => {
|
||||||
|
const value = configService?.get('launchAtStartup')
|
||||||
|
return typeof value === 'boolean' ? value : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSystemLaunchAtStartup = (): boolean => {
|
||||||
|
if (!isLaunchAtStartupSupported()) return false
|
||||||
|
try {
|
||||||
|
return app.getLoginItemSettings().openAtLogin === true
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[WeFlow] 读取开机自启动状态失败:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildLaunchAtStartupSettings = (enabled: boolean): Parameters<typeof app.setLoginItemSettings>[0] =>
|
||||||
|
process.platform === 'win32'
|
||||||
|
? { openAtLogin: enabled, path: process.execPath }
|
||||||
|
: { openAtLogin: enabled }
|
||||||
|
|
||||||
|
const setSystemLaunchAtStartup = (enabled: boolean): { success: boolean; enabled: boolean; error?: string } => {
|
||||||
|
try {
|
||||||
|
app.setLoginItemSettings(buildLaunchAtStartupSettings(enabled))
|
||||||
|
const effectiveEnabled = app.getLoginItemSettings().openAtLogin === true
|
||||||
|
if (effectiveEnabled !== enabled) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
enabled: effectiveEnabled,
|
||||||
|
error: '系统未接受该开机自启动设置'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { success: true, enabled: effectiveEnabled }
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
enabled: getSystemLaunchAtStartup(),
|
||||||
|
error: `设置开机自启动失败: ${String((error as Error)?.message || error)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getLaunchAtStartupStatus = (): { enabled: boolean; supported: boolean; reason?: string } => {
|
||||||
|
const unsupportedReason = getLaunchAtStartupUnsupportedReason()
|
||||||
|
if (unsupportedReason) {
|
||||||
|
return {
|
||||||
|
enabled: getStoredLaunchAtStartupPreference() === true,
|
||||||
|
supported: false,
|
||||||
|
reason: unsupportedReason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
enabled: getSystemLaunchAtStartup(),
|
||||||
|
supported: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyLaunchAtStartupPreference = (
|
||||||
|
enabled: boolean
|
||||||
|
): { success: boolean; enabled: boolean; supported: boolean; reason?: string; error?: string } => {
|
||||||
|
const unsupportedReason = getLaunchAtStartupUnsupportedReason()
|
||||||
|
if (unsupportedReason) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
enabled: getStoredLaunchAtStartupPreference() === true,
|
||||||
|
supported: false,
|
||||||
|
reason: unsupportedReason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = setSystemLaunchAtStartup(enabled)
|
||||||
|
configService?.set('launchAtStartup', result.enabled)
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
supported: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncLaunchAtStartupPreference = () => {
|
||||||
|
if (!configService) return
|
||||||
|
|
||||||
|
const unsupportedReason = getLaunchAtStartupUnsupportedReason()
|
||||||
|
if (unsupportedReason) return
|
||||||
|
|
||||||
|
const storedPreference = getStoredLaunchAtStartupPreference()
|
||||||
|
const systemEnabled = getSystemLaunchAtStartup()
|
||||||
|
|
||||||
|
if (typeof storedPreference !== 'boolean') {
|
||||||
|
configService.set('launchAtStartup', systemEnabled)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (storedPreference === systemEnabled) return
|
||||||
|
|
||||||
|
const result = setSystemLaunchAtStartup(storedPreference)
|
||||||
|
configService.set('launchAtStartup', result.enabled)
|
||||||
|
if (!result.success && result.error) {
|
||||||
|
console.error('[WeFlow] 同步开机自启动设置失败:', result.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 使用白名单过滤 PATH,避免被第三方目录中的旧版 VC++ 运行库劫持。
|
// 使用白名单过滤 PATH,避免被第三方目录中的旧版 VC++ 运行库劫持。
|
||||||
// 仅保留系统目录(Windows/System32/SysWOW64)和应用自身目录(可执行目录、resources)。
|
// 仅保留系统目录(Windows/System32/SysWOW64)和应用自身目录(可执行目录、resources)。
|
||||||
function sanitizePathEnv() {
|
function sanitizePathEnv() {
|
||||||
@@ -1152,13 +1374,19 @@ const removeMatchedEntriesInDir = async (
|
|||||||
// 注册 IPC 处理器
|
// 注册 IPC 处理器
|
||||||
function registerIpcHandlers() {
|
function registerIpcHandlers() {
|
||||||
registerNotificationHandlers()
|
registerNotificationHandlers()
|
||||||
|
bizService.registerHandlers()
|
||||||
// 配置相关
|
// 配置相关
|
||||||
ipcMain.handle('config:get', async (_, key: string) => {
|
ipcMain.handle('config:get', async (_, key: string) => {
|
||||||
return configService?.get(key as any)
|
return configService?.get(key as any)
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('config:set', async (_, key: string, value: any) => {
|
ipcMain.handle('config:set', async (_, key: string, value: any) => {
|
||||||
const result = configService?.set(key as any, value)
|
let result: unknown
|
||||||
|
if (key === 'launchAtStartup') {
|
||||||
|
result = applyLaunchAtStartupPreference(value === true)
|
||||||
|
} else {
|
||||||
|
result = configService?.set(key as any, value)
|
||||||
|
}
|
||||||
if (key === 'updateChannel') {
|
if (key === 'updateChannel') {
|
||||||
applyAutoUpdateChannel('settings')
|
applyAutoUpdateChannel('settings')
|
||||||
}
|
}
|
||||||
@@ -1167,6 +1395,12 @@ function registerIpcHandlers() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('config:clear', async () => {
|
ipcMain.handle('config:clear', async () => {
|
||||||
|
if (isLaunchAtStartupSupported() && getSystemLaunchAtStartup()) {
|
||||||
|
const result = setSystemLaunchAtStartup(false)
|
||||||
|
if (!result.success && result.error) {
|
||||||
|
console.error('[WeFlow] 清空配置时关闭开机自启动失败:', result.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
configService?.clear()
|
configService?.clear()
|
||||||
messagePushService.handleConfigCleared()
|
messagePushService.handleConfigCleared()
|
||||||
return true
|
return true
|
||||||
@@ -1209,6 +1443,14 @@ function registerIpcHandlers() {
|
|||||||
return app.getVersion()
|
return app.getVersion()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('app:getLaunchAtStartupStatus', async () => {
|
||||||
|
return getLaunchAtStartupStatus()
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('app:setLaunchAtStartup', async (_, enabled: boolean) => {
|
||||||
|
return applyLaunchAtStartupPreference(enabled === true)
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle('app:checkWayland', async () => {
|
ipcMain.handle('app:checkWayland', async () => {
|
||||||
if (process.platform !== 'linux') return false;
|
if (process.platform !== 'linux') return false;
|
||||||
|
|
||||||
@@ -1278,12 +1520,14 @@ function registerIpcHandlers() {
|
|||||||
if (!AUTO_UPDATE_ENABLED) {
|
if (!AUTO_UPDATE_ENABLED) {
|
||||||
return { hasUpdate: false }
|
return { hasUpdate: false }
|
||||||
}
|
}
|
||||||
|
// 每次主动检查前重新应用一次通道配置,确保使用最新选择的更新通道。
|
||||||
|
applyAutoUpdateChannel('settings')
|
||||||
try {
|
try {
|
||||||
const result = await autoUpdater.checkForUpdates()
|
const result = await autoUpdater.checkForUpdates()
|
||||||
if (result && result.updateInfo) {
|
if (result && result.updateInfo) {
|
||||||
const currentVersion = app.getVersion()
|
const currentVersion = app.getVersion()
|
||||||
const latestVersion = result.updateInfo.version
|
const latestVersion = result.updateInfo.version
|
||||||
if (latestVersion !== currentVersion) {
|
if (shouldOfferUpdateForTrack(latestVersion, currentVersion)) {
|
||||||
return {
|
return {
|
||||||
hasUpdate: true,
|
hasUpdate: true,
|
||||||
version: latestVersion,
|
version: latestVersion,
|
||||||
@@ -1623,6 +1867,18 @@ function registerIpcHandlers() {
|
|||||||
return chatService.deleteMessage(sessionId, localId, createTime, dbPathHint)
|
return chatService.deleteMessage(sessionId, localId, createTime, dbPathHint)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('chat:checkAntiRevokeTriggers', async (_, sessionIds: string[]) => {
|
||||||
|
return chatService.checkAntiRevokeTriggers(sessionIds)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('chat:installAntiRevokeTriggers', async (_, sessionIds: string[]) => {
|
||||||
|
return chatService.installAntiRevokeTriggers(sessionIds)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('chat:uninstallAntiRevokeTriggers', async (_, sessionIds: string[]) => {
|
||||||
|
return chatService.uninstallAntiRevokeTriggers(sessionIds)
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle('chat:getContact', async (_, username: string) => {
|
ipcMain.handle('chat:getContact', async (_, username: string) => {
|
||||||
return await chatService.getContact(username)
|
return await chatService.getContact(username)
|
||||||
})
|
})
|
||||||
@@ -2055,10 +2311,47 @@ function registerIpcHandlers() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions) => {
|
ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions) => {
|
||||||
const onProgress = (progress: ExportProgress) => {
|
const PROGRESS_FORWARD_INTERVAL_MS = 180
|
||||||
if (!event.sender.isDestroyed()) {
|
let pendingProgress: ExportProgress | null = null
|
||||||
event.sender.send('export:progress', progress)
|
let progressTimer: NodeJS.Timeout | null = null
|
||||||
|
let lastProgressSentAt = 0
|
||||||
|
|
||||||
|
const flushProgress = () => {
|
||||||
|
if (!pendingProgress) return
|
||||||
|
if (progressTimer) {
|
||||||
|
clearTimeout(progressTimer)
|
||||||
|
progressTimer = null
|
||||||
}
|
}
|
||||||
|
if (!event.sender.isDestroyed()) {
|
||||||
|
event.sender.send('export:progress', pendingProgress)
|
||||||
|
}
|
||||||
|
pendingProgress = null
|
||||||
|
lastProgressSentAt = Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
const queueProgress = (progress: ExportProgress) => {
|
||||||
|
pendingProgress = progress
|
||||||
|
const force = progress.phase === 'complete'
|
||||||
|
if (force) {
|
||||||
|
flushProgress()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
const elapsed = now - lastProgressSentAt
|
||||||
|
if (elapsed >= PROGRESS_FORWARD_INTERVAL_MS) {
|
||||||
|
flushProgress()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progressTimer) return
|
||||||
|
progressTimer = setTimeout(() => {
|
||||||
|
flushProgress()
|
||||||
|
}, PROGRESS_FORWARD_INTERVAL_MS - elapsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onProgress = (progress: ExportProgress) => {
|
||||||
|
queueProgress(progress)
|
||||||
}
|
}
|
||||||
|
|
||||||
const runMainFallback = async (reason: string) => {
|
const runMainFallback = async (reason: string) => {
|
||||||
@@ -2069,6 +2362,9 @@ function registerIpcHandlers() {
|
|||||||
const cfg = configService || new ConfigService()
|
const cfg = configService || new ConfigService()
|
||||||
configService = cfg
|
configService = cfg
|
||||||
const logEnabled = cfg.get('logEnabled')
|
const logEnabled = cfg.get('logEnabled')
|
||||||
|
const dbPath = String(cfg.get('dbPath') || '').trim()
|
||||||
|
const decryptKey = String(cfg.get('decryptKey') || '').trim()
|
||||||
|
const myWxid = String(cfg.get('myWxid') || '').trim()
|
||||||
const resourcesPath = app.isPackaged
|
const resourcesPath = app.isPackaged
|
||||||
? join(process.resourcesPath, 'resources')
|
? join(process.resourcesPath, 'resources')
|
||||||
: join(app.getAppPath(), 'resources')
|
: join(app.getAppPath(), 'resources')
|
||||||
@@ -2082,6 +2378,9 @@ function registerIpcHandlers() {
|
|||||||
sessionIds,
|
sessionIds,
|
||||||
outputDir,
|
outputDir,
|
||||||
options,
|
options,
|
||||||
|
dbPath,
|
||||||
|
decryptKey,
|
||||||
|
myWxid,
|
||||||
resourcesPath,
|
resourcesPath,
|
||||||
userDataPath,
|
userDataPath,
|
||||||
logEnabled
|
logEnabled
|
||||||
@@ -2137,6 +2436,12 @@ function registerIpcHandlers() {
|
|||||||
return await runWorker()
|
return await runWorker()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return runMainFallback(error instanceof Error ? error.message : String(error))
|
return runMainFallback(error instanceof Error ? error.message : String(error))
|
||||||
|
} finally {
|
||||||
|
flushProgress()
|
||||||
|
if (progressTimer) {
|
||||||
|
clearTimeout(progressTimer)
|
||||||
|
progressTimer = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -2741,7 +3046,7 @@ function checkForUpdatesOnStartup() {
|
|||||||
const latestVersion = result.updateInfo.version
|
const latestVersion = result.updateInfo.version
|
||||||
|
|
||||||
// 检查是否有新版本
|
// 检查是否有新版本
|
||||||
if (latestVersion !== currentVersion && mainWindow) {
|
if (shouldOfferUpdateForTrack(latestVersion, currentVersion) && mainWindow) {
|
||||||
// 检查该版本是否被用户忽略
|
// 检查该版本是否被用户忽略
|
||||||
const ignoredVersion = configService?.get('ignoredUpdateVersion')
|
const ignoredVersion = configService?.get('ignoredUpdateVersion')
|
||||||
if (ignoredVersion === latestVersion) {
|
if (ignoredVersion === latestVersion) {
|
||||||
@@ -2787,6 +3092,7 @@ app.whenReady().then(async () => {
|
|||||||
updateSplashProgress(5, '正在加载配置...')
|
updateSplashProgress(5, '正在加载配置...')
|
||||||
configService = new ConfigService()
|
configService = new ConfigService()
|
||||||
applyAutoUpdateChannel('startup')
|
applyAutoUpdateChannel('startup')
|
||||||
|
syncLaunchAtStartupPreference()
|
||||||
|
|
||||||
// 将用户主题配置推送给 Splash 窗口
|
// 将用户主题配置推送给 Splash 窗口
|
||||||
if (splashWindow && !splashWindow.isDestroyed()) {
|
if (splashWindow && !splashWindow.isDestroyed()) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { join, dirname } from 'path'
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 强制将本地资源目录添加到 PATH 最前端,确保优先加载本地 DLL
|
* 强制将本地资源目录添加到 PATH 最前端,确保优先加载本地 DLL
|
||||||
* 解决系统中存在冲突版本的 DLL 导致的应用崩溃问题
|
* 解决系统中存在冲突版本的数据服务导致的应用崩溃问题
|
||||||
*/
|
*/
|
||||||
function enforceLocalDllPriority() {
|
function enforceLocalDllPriority() {
|
||||||
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||||
@@ -35,5 +35,5 @@ function enforceLocalDllPriority() {
|
|||||||
try {
|
try {
|
||||||
enforceLocalDllPriority()
|
enforceLocalDllPriority()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[WeFlow] Failed to enforce local DLL priority:', e)
|
console.error('[WeFlow] Failed to enforce local service priority:', e)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
app: {
|
app: {
|
||||||
getDownloadsPath: () => ipcRenderer.invoke('app:getDownloadsPath'),
|
getDownloadsPath: () => ipcRenderer.invoke('app:getDownloadsPath'),
|
||||||
getVersion: () => ipcRenderer.invoke('app:getVersion'),
|
getVersion: () => ipcRenderer.invoke('app:getVersion'),
|
||||||
|
getLaunchAtStartupStatus: () => ipcRenderer.invoke('app:getLaunchAtStartupStatus'),
|
||||||
|
setLaunchAtStartup: (enabled: boolean) => ipcRenderer.invoke('app:setLaunchAtStartup', enabled),
|
||||||
checkForUpdates: () => ipcRenderer.invoke('app:checkForUpdates'),
|
checkForUpdates: () => ipcRenderer.invoke('app:checkForUpdates'),
|
||||||
downloadAndInstall: () => ipcRenderer.invoke('app:downloadAndInstall'),
|
downloadAndInstall: () => ipcRenderer.invoke('app:downloadAndInstall'),
|
||||||
ignoreUpdate: (version: string) => ipcRenderer.invoke('app:ignoreUpdate', version),
|
ignoreUpdate: (version: string) => ipcRenderer.invoke('app:ignoreUpdate', version),
|
||||||
@@ -188,6 +190,12 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
ipcRenderer.invoke('chat:updateMessage', sessionId, localId, createTime, newContent),
|
ipcRenderer.invoke('chat:updateMessage', sessionId, localId, createTime, newContent),
|
||||||
deleteMessage: (sessionId: string, localId: number, createTime: number, dbPathHint?: string) =>
|
deleteMessage: (sessionId: string, localId: number, createTime: number, dbPathHint?: string) =>
|
||||||
ipcRenderer.invoke('chat:deleteMessage', sessionId, localId, createTime, dbPathHint),
|
ipcRenderer.invoke('chat:deleteMessage', sessionId, localId, createTime, dbPathHint),
|
||||||
|
checkAntiRevokeTriggers: (sessionIds: string[]) =>
|
||||||
|
ipcRenderer.invoke('chat:checkAntiRevokeTriggers', sessionIds),
|
||||||
|
installAntiRevokeTriggers: (sessionIds: string[]) =>
|
||||||
|
ipcRenderer.invoke('chat:installAntiRevokeTriggers', sessionIds),
|
||||||
|
uninstallAntiRevokeTriggers: (sessionIds: string[]) =>
|
||||||
|
ipcRenderer.invoke('chat:uninstallAntiRevokeTriggers', sessionIds),
|
||||||
resolveTransferDisplayNames: (chatroomId: string, payerUsername: string, receiverUsername: string) =>
|
resolveTransferDisplayNames: (chatroomId: string, payerUsername: string, receiverUsername: string) =>
|
||||||
ipcRenderer.invoke('chat:resolveTransferDisplayNames', chatroomId, payerUsername, receiverUsername),
|
ipcRenderer.invoke('chat:resolveTransferDisplayNames', chatroomId, payerUsername, receiverUsername),
|
||||||
getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'),
|
getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'),
|
||||||
@@ -413,6 +421,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
downloadEmoji: (params: { url: string; encryptUrl?: string; aesKey?: string }) => ipcRenderer.invoke('sns:downloadEmoji', params)
|
downloadEmoji: (params: { url: string; encryptUrl?: string; aesKey?: string }) => ipcRenderer.invoke('sns:downloadEmoji', params)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
biz: {
|
||||||
|
listAccounts: (account?: string) => ipcRenderer.invoke('biz:listAccounts', account),
|
||||||
|
listMessages: (username: string, account?: string, limit?: number, offset?: number) =>
|
||||||
|
ipcRenderer.invoke('biz:listMessages', username, account, limit, offset),
|
||||||
|
listPayRecords: (account?: string, limit?: number, offset?: number) =>
|
||||||
|
ipcRenderer.invoke('biz:listPayRecords', account, limit, offset)
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
// 数据收集
|
// 数据收集
|
||||||
cloud: {
|
cloud: {
|
||||||
|
|||||||
243
electron/services/bizService.ts
Normal file
243
electron/services/bizService.ts
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
import { join } from 'path'
|
||||||
|
import { readdirSync, existsSync } from 'fs'
|
||||||
|
import { wcdbService } from './wcdbService'
|
||||||
|
import { ConfigService } from './config'
|
||||||
|
import { chatService, Message } from './chatService'
|
||||||
|
import { ipcMain } from 'electron'
|
||||||
|
import { createHash } from 'crypto'
|
||||||
|
|
||||||
|
export interface BizAccount {
|
||||||
|
username: string
|
||||||
|
name: string
|
||||||
|
avatar: string
|
||||||
|
type: number
|
||||||
|
last_time: number
|
||||||
|
formatted_last_time: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BizMessage {
|
||||||
|
local_id: number
|
||||||
|
create_time: number
|
||||||
|
title: string
|
||||||
|
des: string
|
||||||
|
url: string
|
||||||
|
cover: string
|
||||||
|
content_list: any[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BizPayRecord {
|
||||||
|
local_id: number
|
||||||
|
create_time: number
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
merchant_name: string
|
||||||
|
merchant_icon: string
|
||||||
|
timestamp: number
|
||||||
|
formatted_time: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BizService {
|
||||||
|
private configService: ConfigService
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.configService = new ConfigService()
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractXmlValue(xml: string, tagName: string): string {
|
||||||
|
const regex = new RegExp(`<${tagName}>([\\s\\S]*?)</${tagName}>`, 'i')
|
||||||
|
const match = regex.exec(xml)
|
||||||
|
if (match) {
|
||||||
|
return match[1].replace(/<!\[CDATA\[/g, '').replace(/\]\]>/g, '').trim()
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseBizContentList(xmlStr: string): any[] {
|
||||||
|
if (!xmlStr) return []
|
||||||
|
const contentList: any[] = []
|
||||||
|
try {
|
||||||
|
const itemRegex = /<item>([\s\S]*?)<\/item>/gi
|
||||||
|
let match: RegExpExecArray | null
|
||||||
|
while ((match = itemRegex.exec(xmlStr)) !== null) {
|
||||||
|
const itemXml = match[1]
|
||||||
|
const itemStruct = {
|
||||||
|
title: this.extractXmlValue(itemXml, 'title'),
|
||||||
|
url: this.extractXmlValue(itemXml, 'url'),
|
||||||
|
cover: this.extractXmlValue(itemXml, 'cover') || this.extractXmlValue(itemXml, 'thumburl'),
|
||||||
|
summary: this.extractXmlValue(itemXml, 'summary') || this.extractXmlValue(itemXml, 'digest')
|
||||||
|
}
|
||||||
|
if (itemStruct.title) contentList.push(itemStruct)
|
||||||
|
}
|
||||||
|
} catch (e) { }
|
||||||
|
return contentList
|
||||||
|
}
|
||||||
|
|
||||||
|
private parsePayXml(xmlStr: string): any {
|
||||||
|
if (!xmlStr) return null
|
||||||
|
try {
|
||||||
|
const title = this.extractXmlValue(xmlStr, 'title')
|
||||||
|
const description = this.extractXmlValue(xmlStr, 'des')
|
||||||
|
const merchantName = this.extractXmlValue(xmlStr, 'display_name') || '微信支付'
|
||||||
|
const merchantIcon = this.extractXmlValue(xmlStr, 'icon_url')
|
||||||
|
const pubTime = parseInt(this.extractXmlValue(xmlStr, 'pub_time') || '0')
|
||||||
|
if (!title && !description) return null
|
||||||
|
return { title, description, merchant_name: merchantName, merchant_icon: merchantIcon, timestamp: pubTime }
|
||||||
|
} catch (e) { return null }
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAccounts(account?: string): Promise<BizAccount[]> {
|
||||||
|
try {
|
||||||
|
// 1. 获取公众号联系人列表
|
||||||
|
const contactsResult = await chatService.getContacts({ lite: true })
|
||||||
|
if (!contactsResult.success || !contactsResult.contacts) return []
|
||||||
|
|
||||||
|
const officialContacts = contactsResult.contacts.filter(c => c.type === 'official')
|
||||||
|
const usernames = officialContacts.map(c => c.username)
|
||||||
|
|
||||||
|
// 获取头像和昵称等补充信息
|
||||||
|
const enrichment = await chatService.enrichSessionsContactInfo(usernames)
|
||||||
|
const contactInfoMap = enrichment.success && enrichment.contacts ? enrichment.contacts : {}
|
||||||
|
|
||||||
|
const root = this.configService.get('dbPath')
|
||||||
|
const myWxid = this.configService.get('myWxid')
|
||||||
|
const accountWxid = account || myWxid
|
||||||
|
if (!root || !accountWxid) return []
|
||||||
|
|
||||||
|
const bizLatestTime: Record<string, number> = {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sessionsRes = await wcdbService.getSessions()
|
||||||
|
if (sessionsRes.success && sessionsRes.sessions) {
|
||||||
|
for (const session of sessionsRes.sessions) {
|
||||||
|
const uname = session.username || session.strUsrName || session.userName || session.id
|
||||||
|
// 适配日志中发现的字段,注意转为整型数字
|
||||||
|
const timeStr = session.last_timestamp || session.sort_timestamp || session.nTime || session.timestamp || '0'
|
||||||
|
const time = parseInt(timeStr.toString(), 10)
|
||||||
|
|
||||||
|
if (usernames.includes(uname) && time > 0) {
|
||||||
|
bizLatestTime[uname] = time
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('获取 Sessions 失败:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 格式化时间显示
|
||||||
|
const formatBizTime = (ts: number) => {
|
||||||
|
if (!ts) return ''
|
||||||
|
const date = new Date(ts * 1000)
|
||||||
|
const now = new Date()
|
||||||
|
const isToday = date.toDateString() === now.toDateString()
|
||||||
|
if (isToday) return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||||
|
|
||||||
|
const yesterday = new Date(now)
|
||||||
|
yesterday.setDate(now.getDate() - 1)
|
||||||
|
if (date.toDateString() === yesterday.toDateString()) return '昨天'
|
||||||
|
|
||||||
|
const isThisYear = date.getFullYear() === now.getFullYear()
|
||||||
|
if (isThisYear) return `${date.getMonth() + 1}/${date.getDate()}`
|
||||||
|
|
||||||
|
return `${date.getFullYear().toString().slice(-2)}/${date.getMonth() + 1}/${date.getDate()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 组装数据
|
||||||
|
const result: BizAccount[] = officialContacts.map(contact => {
|
||||||
|
const uname = contact.username
|
||||||
|
const info = contactInfoMap[uname]
|
||||||
|
const lastTime = bizLatestTime[uname] || 0
|
||||||
|
return {
|
||||||
|
username: uname,
|
||||||
|
name: info?.displayName || contact.displayName || uname,
|
||||||
|
avatar: info?.avatarUrl || '',
|
||||||
|
type: 0,
|
||||||
|
last_time: lastTime,
|
||||||
|
formatted_last_time: formatBizTime(lastTime)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 5. 补充公众号类型 (订阅号/服务号)
|
||||||
|
const contactDbPath = join(root, accountWxid, 'db_storage', 'contact', 'contact.db')
|
||||||
|
if (existsSync(contactDbPath)) {
|
||||||
|
const bizInfoRes = await wcdbService.execQuery('contact', contactDbPath, 'SELECT username, type FROM biz_info')
|
||||||
|
if (bizInfoRes.success && bizInfoRes.rows) {
|
||||||
|
const typeMap: Record<string, number> = {}
|
||||||
|
for (const r of bizInfoRes.rows) typeMap[r.username] = r.type
|
||||||
|
for (const acc of result) if (typeMap[acc.username] !== undefined) acc.type = typeMap[acc.username]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 排序输出
|
||||||
|
return result
|
||||||
|
.filter(acc => !acc.name.includes('广告'))
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.username === 'gh_3dfda90e39d6') return -1 // 微信支付置顶
|
||||||
|
if (b.username === 'gh_3dfda90e39d6') return 1
|
||||||
|
return b.last_time - a.last_time // 按最新时间降序排列
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error('获取账号列表发生错误:', e)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listMessages(username: string, account?: string, limit: number = 20, offset: number = 0): Promise<BizMessage[]> {
|
||||||
|
try {
|
||||||
|
// 仅保留核心路径:利用 chatService 的自动路由能力
|
||||||
|
const res = await chatService.getMessages(username, offset, limit)
|
||||||
|
if (!res.success || !res.messages) return []
|
||||||
|
|
||||||
|
return res.messages.map(msg => {
|
||||||
|
const bizMsg: BizMessage = {
|
||||||
|
local_id: msg.localId,
|
||||||
|
create_time: msg.createTime,
|
||||||
|
title: msg.linkTitle || msg.parsedContent || '',
|
||||||
|
des: msg.appMsgDesc || '',
|
||||||
|
url: msg.linkUrl || '',
|
||||||
|
cover: msg.linkThumb || msg.appMsgThumbUrl || '',
|
||||||
|
content_list: []
|
||||||
|
}
|
||||||
|
if (msg.rawContent) {
|
||||||
|
bizMsg.content_list = this.parseBizContentList(msg.rawContent)
|
||||||
|
if (bizMsg.content_list.length > 0 && !bizMsg.title) {
|
||||||
|
bizMsg.title = bizMsg.content_list[0].title
|
||||||
|
bizMsg.cover = bizMsg.cover || bizMsg.content_list[0].cover
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bizMsg
|
||||||
|
})
|
||||||
|
} catch (e) { return [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
async listPayRecords(account?: string, limit: number = 20, offset: number = 0): Promise<BizPayRecord[]> {
|
||||||
|
const username = 'gh_3dfda90e39d6'
|
||||||
|
try {
|
||||||
|
const res = await chatService.getMessages(username, offset, limit)
|
||||||
|
if (!res.success || !res.messages) return []
|
||||||
|
|
||||||
|
const records: BizPayRecord[] = []
|
||||||
|
for (const msg of res.messages) {
|
||||||
|
if (!msg.rawContent) continue
|
||||||
|
const parsedData = this.parsePayXml(msg.rawContent)
|
||||||
|
if (parsedData) {
|
||||||
|
records.push({
|
||||||
|
local_id: msg.localId,
|
||||||
|
create_time: msg.createTime,
|
||||||
|
...parsedData,
|
||||||
|
timestamp: parsedData.timestamp || msg.createTime,
|
||||||
|
formatted_time: new Date((parsedData.timestamp || msg.createTime) * 1000).toLocaleString()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return records
|
||||||
|
} catch (e) { return [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
registerHandlers() {
|
||||||
|
ipcMain.handle('biz:listAccounts', (_, account) => this.listAccounts(account))
|
||||||
|
ipcMain.handle('biz:listMessages', (_, username, account, limit, offset) => this.listMessages(username, account, limit, offset))
|
||||||
|
ipcMain.handle('biz:listPayRecords', (_, account, limit, offset) => this.listPayRecords(account, limit, offset))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const bizService = new BizService()
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { join, dirname, basename, extname } from 'path'
|
import { join, dirname, basename, extname } from 'path'
|
||||||
import { existsSync, mkdirSync, readdirSync, statSync, readFileSync, writeFileSync, copyFileSync, unlinkSync, watch, promises as fsPromises } from 'fs'
|
import { existsSync, mkdirSync, readdirSync, statSync, readFileSync, writeFileSync, copyFileSync, unlinkSync, watch, promises as fsPromises } from 'fs'
|
||||||
import * as path from 'path'
|
import * as path from 'path'
|
||||||
import * as fs from 'fs'
|
import * as fs from 'fs'
|
||||||
@@ -75,6 +75,7 @@ export interface Message {
|
|||||||
fileName?: string // 文件名
|
fileName?: string // 文件名
|
||||||
fileSize?: number // 文件大小
|
fileSize?: number // 文件大小
|
||||||
fileExt?: string // 文件扩展名
|
fileExt?: string // 文件扩展名
|
||||||
|
fileMd5?: string // 文件 MD5
|
||||||
xmlType?: string // XML 中的 type 字段
|
xmlType?: string // XML 中的 type 字段
|
||||||
appMsgKind?: string // 归一化 appmsg 类型
|
appMsgKind?: string // 归一化 appmsg 类型
|
||||||
appMsgDesc?: string
|
appMsgDesc?: string
|
||||||
@@ -468,7 +469,7 @@ class ChatService {
|
|||||||
if (this.monitorSetup) return
|
if (this.monitorSetup) return
|
||||||
this.monitorSetup = true
|
this.monitorSetup = true
|
||||||
|
|
||||||
// 使用 C++ DLL 内部的文件监控 (ReadDirectoryChangesW)
|
// 使用 C++数据服务内部的文件监控 (ReadDirectoryChangesW)
|
||||||
// 这种方式更高效,且不占用 JS 线程,并能直接监听 session/message 目录变更
|
// 这种方式更高效,且不占用 JS 线程,并能直接监听 session/message 目录变更
|
||||||
wcdbService.setMonitor((type, json) => {
|
wcdbService.setMonitor((type, json) => {
|
||||||
this.handleSessionStatsMonitorChange(type, json)
|
this.handleSessionStatsMonitorChange(type, json)
|
||||||
@@ -558,6 +559,51 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async checkAntiRevokeTriggers(sessionIds: string[]): Promise<{
|
||||||
|
success: boolean
|
||||||
|
rows?: Array<{ sessionId: string; success: boolean; installed?: boolean; error?: string }>
|
||||||
|
error?: string
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const connectResult = await this.ensureConnected()
|
||||||
|
if (!connectResult.success) return { success: false, error: connectResult.error }
|
||||||
|
const normalizedIds = Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean)))
|
||||||
|
return await wcdbService.checkMessageAntiRevokeTriggers(normalizedIds)
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async installAntiRevokeTriggers(sessionIds: string[]): Promise<{
|
||||||
|
success: boolean
|
||||||
|
rows?: Array<{ sessionId: string; success: boolean; alreadyInstalled?: boolean; error?: string }>
|
||||||
|
error?: string
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const connectResult = await this.ensureConnected()
|
||||||
|
if (!connectResult.success) return { success: false, error: connectResult.error }
|
||||||
|
const normalizedIds = Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean)))
|
||||||
|
return await wcdbService.installMessageAntiRevokeTriggers(normalizedIds)
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async uninstallAntiRevokeTriggers(sessionIds: string[]): Promise<{
|
||||||
|
success: boolean
|
||||||
|
rows?: Array<{ sessionId: string; success: boolean; error?: string }>
|
||||||
|
error?: string
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const connectResult = await this.ensureConnected()
|
||||||
|
if (!connectResult.success) return { success: false, error: connectResult.error }
|
||||||
|
const normalizedIds = Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean)))
|
||||||
|
return await wcdbService.uninstallMessageAntiRevokeTriggers(normalizedIds)
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取会话列表(优化:先返回基础数据,不等待联系人信息加载)
|
* 获取会话列表(优化:先返回基础数据,不等待联系人信息加载)
|
||||||
*/
|
*/
|
||||||
@@ -1773,18 +1819,9 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getMessageSourceInfo(row: Record<string, any>): { dbName?: string; tableName?: string; dbPath?: string } {
|
private getMessageSourceInfo(row: Record<string, any>): { dbName?: string; tableName?: string; dbPath?: string } {
|
||||||
const dbPath = String(
|
const dbPath = String(row._db_path || row.db_path || '').trim()
|
||||||
this.getRowField(row, ['_db_path', 'db_path', 'dbPath', 'database_path', 'databasePath', 'source_db_path'])
|
const explicitDbName = String(row.db_name || '').trim()
|
||||||
|| ''
|
const tableName = String(row.table_name || '').trim()
|
||||||
).trim()
|
|
||||||
const explicitDbName = String(
|
|
||||||
this.getRowField(row, ['db_name', 'dbName', 'database_name', 'databaseName', 'db', 'database', 'source_db'])
|
|
||||||
|| ''
|
|
||||||
).trim()
|
|
||||||
const tableName = String(
|
|
||||||
this.getRowField(row, ['table_name', 'tableName', 'table', 'source_table', 'sourceTable'])
|
|
||||||
|| ''
|
|
||||||
).trim()
|
|
||||||
const dbName = explicitDbName || (dbPath ? basename(dbPath, extname(dbPath)) : '')
|
const dbName = explicitDbName || (dbPath ? basename(dbPath, extname(dbPath)) : '')
|
||||||
return {
|
return {
|
||||||
dbName: dbName || undefined,
|
dbName: dbName || undefined,
|
||||||
@@ -3201,7 +3238,7 @@ class ChatService {
|
|||||||
if (!batch.success) break
|
if (!batch.success) break
|
||||||
const rows = Array.isArray(batch.rows) ? batch.rows as Record<string, any>[] : []
|
const rows = Array.isArray(batch.rows) ? batch.rows as Record<string, any>[] : []
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 1)
|
const localType = this.getRowInt(row, ['local_type'], 1)
|
||||||
if (localType === 50) {
|
if (localType === 50) {
|
||||||
counters.callMessages += 1
|
counters.callMessages += 1
|
||||||
continue
|
continue
|
||||||
@@ -3216,8 +3253,8 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
if (localType !== 49) continue
|
if (localType !== 49) continue
|
||||||
|
|
||||||
const rawMessageContent = this.getRowField(row, ['message_content', 'messageContent', 'msg_content', 'msgContent', 'content', 'WCDB_CT_message_content'])
|
const rawMessageContent = row.message_content
|
||||||
const rawCompressContent = this.getRowField(row, ['compress_content', 'compressContent', 'compressed_content', 'compressedContent', 'WCDB_CT_compress_content'])
|
const rawCompressContent = row.compress_content
|
||||||
const content = this.decodeMessageContent(rawMessageContent, rawCompressContent)
|
const content = this.decodeMessageContent(rawMessageContent, rawCompressContent)
|
||||||
const xmlType = this.extractType49XmlTypeForStats(content)
|
const xmlType = this.extractType49XmlTypeForStats(content)
|
||||||
if (xmlType === '2000') counters.transferMessages += 1
|
if (xmlType === '2000') counters.transferMessages += 1
|
||||||
@@ -3270,7 +3307,7 @@ class ChatService {
|
|||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
stats.totalMessages += 1
|
stats.totalMessages += 1
|
||||||
|
|
||||||
const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 1)
|
const localType = this.getRowInt(row, ['local_type'], 1)
|
||||||
if (localType === 34) stats.voiceMessages += 1
|
if (localType === 34) stats.voiceMessages += 1
|
||||||
if (localType === 3) stats.imageMessages += 1
|
if (localType === 3) stats.imageMessages += 1
|
||||||
if (localType === 43) stats.videoMessages += 1
|
if (localType === 43) stats.videoMessages += 1
|
||||||
@@ -3279,8 +3316,8 @@ class ChatService {
|
|||||||
if (localType === 8589934592049) stats.transferMessages += 1
|
if (localType === 8589934592049) stats.transferMessages += 1
|
||||||
if (localType === 8594229559345) stats.redPacketMessages += 1
|
if (localType === 8594229559345) stats.redPacketMessages += 1
|
||||||
if (localType === 49) {
|
if (localType === 49) {
|
||||||
const rawMessageContent = this.getRowField(row, ['message_content', 'messageContent', 'msg_content', 'msgContent', 'content', 'WCDB_CT_message_content'])
|
const rawMessageContent = row.message_content
|
||||||
const rawCompressContent = this.getRowField(row, ['compress_content', 'compressContent', 'compressed_content', 'compressedContent', 'WCDB_CT_compress_content'])
|
const rawCompressContent = row.compress_content
|
||||||
const content = this.decodeMessageContent(rawMessageContent, rawCompressContent)
|
const content = this.decodeMessageContent(rawMessageContent, rawCompressContent)
|
||||||
const xmlType = this.extractType49XmlTypeForStats(content)
|
const xmlType = this.extractType49XmlTypeForStats(content)
|
||||||
if (xmlType === '2000') stats.transferMessages += 1
|
if (xmlType === '2000') stats.transferMessages += 1
|
||||||
@@ -3289,7 +3326,7 @@ class ChatService {
|
|||||||
|
|
||||||
const createTime = this.getRowInt(
|
const createTime = this.getRowInt(
|
||||||
row,
|
row,
|
||||||
['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'],
|
['create_time'],
|
||||||
0
|
0
|
||||||
)
|
)
|
||||||
if (createTime > 0) {
|
if (createTime > 0) {
|
||||||
@@ -3302,7 +3339,7 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sessionId.endsWith('@chatroom')) {
|
if (sessionId.endsWith('@chatroom')) {
|
||||||
const sender = String(this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username']) || '').trim()
|
const sender = String(row.sender_username || '').trim()
|
||||||
const senderKeys = this.buildIdentityKeys(sender)
|
const senderKeys = this.buildIdentityKeys(sender)
|
||||||
if (senderKeys.length > 0) {
|
if (senderKeys.length > 0) {
|
||||||
senderIdentities.add(senderKeys[0])
|
senderIdentities.add(senderKeys[0])
|
||||||
@@ -3310,7 +3347,7 @@ class ChatService {
|
|||||||
stats.groupMyMessages = (stats.groupMyMessages || 0) + 1
|
stats.groupMyMessages = (stats.groupMyMessages || 0) + 1
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const isSend = this.coerceRowNumber(this.getRowField(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send']))
|
const isSend = this.coerceRowNumber(row.computed_is_send ?? row.is_send)
|
||||||
if (Number.isFinite(isSend) && isSend === 1) {
|
if (Number.isFinite(isSend) && isSend === 1) {
|
||||||
stats.groupMyMessages = (stats.groupMyMessages || 0) + 1
|
stats.groupMyMessages = (stats.groupMyMessages || 0) + 1
|
||||||
}
|
}
|
||||||
@@ -3744,32 +3781,18 @@ class ChatService {
|
|||||||
const messages: Message[] = []
|
const messages: Message[] = []
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const sourceInfo = this.getMessageSourceInfo(row)
|
const sourceInfo = this.getMessageSourceInfo(row)
|
||||||
const rawMessageContent = this.getRowField(row, [
|
const rawMessageContent = row.message_content
|
||||||
'message_content',
|
const rawCompressContent = row.compress_content
|
||||||
'messageContent',
|
|
||||||
'content',
|
|
||||||
'msg_content',
|
|
||||||
'msgContent',
|
|
||||||
'WCDB_CT_message_content',
|
|
||||||
'WCDB_CT_messageContent'
|
|
||||||
]);
|
|
||||||
const rawCompressContent = this.getRowField(row, [
|
|
||||||
'compress_content',
|
|
||||||
'compressContent',
|
|
||||||
'compressed_content',
|
|
||||||
'WCDB_CT_compress_content',
|
|
||||||
'WCDB_CT_compressContent'
|
|
||||||
]);
|
|
||||||
|
|
||||||
const content = this.decodeMessageContent(rawMessageContent, rawCompressContent);
|
const content = this.decodeMessageContent(rawMessageContent, rawCompressContent);
|
||||||
const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 1)
|
const localType = this.getRowInt(row, ['local_type'], 1)
|
||||||
const isSendRaw = this.getRowField(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send'])
|
const isSendRaw = row.computed_is_send ?? row.is_send
|
||||||
const parsedRawIsSend = isSendRaw === null ? null : parseInt(isSendRaw, 10)
|
const parsedRawIsSend = isSendRaw === null ? null : parseInt(isSendRaw, 10)
|
||||||
const senderUsername = this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username'])
|
const senderUsername = row.sender_username
|
||||||
|| this.extractSenderUsernameFromContent(content)
|
|| this.extractSenderUsernameFromContent(content)
|
||||||
|| null
|
|| null
|
||||||
const { isSend } = this.resolveMessageIsSend(parsedRawIsSend, senderUsername)
|
const { isSend } = this.resolveMessageIsSend(parsedRawIsSend, senderUsername)
|
||||||
const createTime = this.getRowInt(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0)
|
const createTime = this.getRowInt(row, ['create_time'], 0)
|
||||||
|
|
||||||
if (senderUsername && !myWxid) {
|
if (senderUsername && !myWxid) {
|
||||||
// [DEBUG] Issue #34: 未配置 myWxid,无法判断是否发送
|
// [DEBUG] Issue #34: 未配置 myWxid,无法判断是否发送
|
||||||
@@ -3796,6 +3819,7 @@ class ChatService {
|
|||||||
let fileName: string | undefined
|
let fileName: string | undefined
|
||||||
let fileSize: number | undefined
|
let fileSize: number | undefined
|
||||||
let fileExt: string | undefined
|
let fileExt: string | undefined
|
||||||
|
let fileMd5: string | undefined
|
||||||
let xmlType: string | undefined
|
let xmlType: string | undefined
|
||||||
let appMsgKind: string | undefined
|
let appMsgKind: string | undefined
|
||||||
let appMsgDesc: string | undefined
|
let appMsgDesc: string | undefined
|
||||||
@@ -3900,6 +3924,7 @@ class ChatService {
|
|||||||
fileName = type49Info.fileName
|
fileName = type49Info.fileName
|
||||||
fileSize = type49Info.fileSize
|
fileSize = type49Info.fileSize
|
||||||
fileExt = type49Info.fileExt
|
fileExt = type49Info.fileExt
|
||||||
|
fileMd5 = type49Info.fileMd5
|
||||||
chatRecordTitle = type49Info.chatRecordTitle
|
chatRecordTitle = type49Info.chatRecordTitle
|
||||||
chatRecordList = type49Info.chatRecordList
|
chatRecordList = type49Info.chatRecordList
|
||||||
transferPayerUsername = type49Info.transferPayerUsername
|
transferPayerUsername = type49Info.transferPayerUsername
|
||||||
@@ -3923,6 +3948,7 @@ class ChatService {
|
|||||||
fileName = fileName || type49Info.fileName
|
fileName = fileName || type49Info.fileName
|
||||||
fileSize = fileSize ?? type49Info.fileSize
|
fileSize = fileSize ?? type49Info.fileSize
|
||||||
fileExt = fileExt || type49Info.fileExt
|
fileExt = fileExt || type49Info.fileExt
|
||||||
|
fileMd5 = fileMd5 || type49Info.fileMd5
|
||||||
appMsgKind = appMsgKind || type49Info.appMsgKind
|
appMsgKind = appMsgKind || type49Info.appMsgKind
|
||||||
appMsgDesc = appMsgDesc || type49Info.appMsgDesc
|
appMsgDesc = appMsgDesc || type49Info.appMsgDesc
|
||||||
appMsgAppName = appMsgAppName || type49Info.appMsgAppName
|
appMsgAppName = appMsgAppName || type49Info.appMsgAppName
|
||||||
@@ -3954,10 +3980,10 @@ class ChatService {
|
|||||||
if (!quotedSender && type49Info.quotedSender !== undefined) quotedSender = type49Info.quotedSender
|
if (!quotedSender && type49Info.quotedSender !== undefined) quotedSender = type49Info.quotedSender
|
||||||
}
|
}
|
||||||
|
|
||||||
const localId = this.getRowInt(row, ['local_id', 'localId', 'LocalId', 'msg_local_id', 'msgLocalId', 'MsgLocalId', 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id'], 0)
|
const localId = this.getRowInt(row, ['local_id'], 0)
|
||||||
const serverIdRaw = this.normalizeUnsignedIntegerToken(this.getRowField(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id']))
|
const serverIdRaw = this.normalizeUnsignedIntegerToken(row.server_id)
|
||||||
const serverId = this.getRowInt(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id'], 0)
|
const serverId = this.getRowInt(row, ['server_id'], 0)
|
||||||
const sortSeq = this.getRowInt(row, ['sort_seq', 'sortSeq', 'seq', 'sequence', 'WCDB_CT_sort_seq'], createTime)
|
const sortSeq = this.getRowInt(row, ['sort_seq'], createTime)
|
||||||
|
|
||||||
messages.push({
|
messages.push({
|
||||||
messageKey: this.buildMessageKey({
|
messageKey: this.buildMessageKey({
|
||||||
@@ -3996,6 +4022,7 @@ class ChatService {
|
|||||||
fileName,
|
fileName,
|
||||||
fileSize,
|
fileSize,
|
||||||
fileExt,
|
fileExt,
|
||||||
|
fileMd5,
|
||||||
xmlType,
|
xmlType,
|
||||||
appMsgKind,
|
appMsgKind,
|
||||||
appMsgDesc,
|
appMsgDesc,
|
||||||
@@ -4404,18 +4431,7 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private parseImageDatNameFromRow(row: Record<string, any>): string | undefined {
|
private parseImageDatNameFromRow(row: Record<string, any>): string | undefined {
|
||||||
const packed = this.getRowField(row, [
|
const packed = row.packed_info_data
|
||||||
'packed_info_data',
|
|
||||||
'packed_info',
|
|
||||||
'packedInfoData',
|
|
||||||
'packedInfo',
|
|
||||||
'PackedInfoData',
|
|
||||||
'PackedInfo',
|
|
||||||
'WCDB_CT_packed_info_data',
|
|
||||||
'WCDB_CT_packed_info',
|
|
||||||
'WCDB_CT_PackedInfoData',
|
|
||||||
'WCDB_CT_PackedInfo'
|
|
||||||
])
|
|
||||||
const buffer = this.decodePackedInfo(packed)
|
const buffer = this.decodePackedInfo(packed)
|
||||||
if (!buffer || buffer.length === 0) return undefined
|
if (!buffer || buffer.length === 0) return undefined
|
||||||
const printable: number[] = []
|
const printable: number[] = []
|
||||||
@@ -4470,15 +4486,16 @@ class ChatService {
|
|||||||
*/
|
*/
|
||||||
private parseQuoteMessage(content: string): { content?: string; sender?: string } {
|
private parseQuoteMessage(content: string): { content?: string; sender?: string } {
|
||||||
try {
|
try {
|
||||||
|
const normalizedContent = this.decodeHtmlEntities(content || '')
|
||||||
// 提取 refermsg 部分
|
// 提取 refermsg 部分
|
||||||
const referMsgStart = content.indexOf('<refermsg>')
|
const referMsgStart = normalizedContent.indexOf('<refermsg>')
|
||||||
const referMsgEnd = content.indexOf('</refermsg>')
|
const referMsgEnd = normalizedContent.indexOf('</refermsg>')
|
||||||
|
|
||||||
if (referMsgStart === -1 || referMsgEnd === -1) {
|
if (referMsgStart === -1 || referMsgEnd === -1) {
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
|
|
||||||
const referMsgXml = content.substring(referMsgStart, referMsgEnd + 11)
|
const referMsgXml = normalizedContent.substring(referMsgStart, referMsgEnd + 11)
|
||||||
|
|
||||||
// 提取发送者名称
|
// 提取发送者名称
|
||||||
let displayName = this.extractXmlValue(referMsgXml, 'displayname')
|
let displayName = this.extractXmlValue(referMsgXml, 'displayname')
|
||||||
@@ -4495,8 +4512,8 @@ class ChatService {
|
|||||||
let displayContent = referContent
|
let displayContent = referContent
|
||||||
switch (referType) {
|
switch (referType) {
|
||||||
case '1':
|
case '1':
|
||||||
// 文本消息,清理可能的 wxid
|
// 文本消息优先取“部分引用”字段,缺失时再回退到完整 content
|
||||||
displayContent = this.sanitizeQuotedContent(referContent)
|
displayContent = this.extractPreferredQuotedText(referMsgXml)
|
||||||
break
|
break
|
||||||
case '3':
|
case '3':
|
||||||
displayContent = '[图片]'
|
displayContent = '[图片]'
|
||||||
@@ -4536,6 +4553,76 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extractPreferredQuotedText(referMsgXml: string): string {
|
||||||
|
if (!referMsgXml) return ''
|
||||||
|
|
||||||
|
const sources = [this.decodeHtmlEntities(referMsgXml)]
|
||||||
|
const rawMsgSource = this.extractXmlValue(referMsgXml, 'msgsource')
|
||||||
|
if (rawMsgSource) {
|
||||||
|
const decodedMsgSource = this.decodeHtmlEntities(rawMsgSource)
|
||||||
|
if (decodedMsgSource) {
|
||||||
|
sources.push(decodedMsgSource)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullContent = this.sanitizeQuotedContent(this.extractXmlValue(sources[0] || referMsgXml, 'content'))
|
||||||
|
const partialText = this.extractPartialQuotedText(sources[0] || referMsgXml, fullContent)
|
||||||
|
if (partialText) return partialText
|
||||||
|
|
||||||
|
const candidateTags = [
|
||||||
|
'selectedcontent',
|
||||||
|
'selectedtext',
|
||||||
|
'selectcontent',
|
||||||
|
'selecttext',
|
||||||
|
'quotecontent',
|
||||||
|
'quotetext',
|
||||||
|
'partcontent',
|
||||||
|
'parttext',
|
||||||
|
'excerpt',
|
||||||
|
'summary',
|
||||||
|
'preview'
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const source of sources) {
|
||||||
|
for (const tag of candidateTags) {
|
||||||
|
const value = this.sanitizeQuotedContent(this.extractXmlValue(source, tag))
|
||||||
|
if (value) return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fullContent
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractPartialQuotedText(xml: string, fullContent: string): string {
|
||||||
|
if (!xml || !fullContent) return ''
|
||||||
|
|
||||||
|
const startChar = this.extractXmlValue(xml, 'start')
|
||||||
|
const endChar = this.extractXmlValue(xml, 'end')
|
||||||
|
const startIndexRaw = this.extractXmlValue(xml, 'startindex')
|
||||||
|
const endIndexRaw = this.extractXmlValue(xml, 'endindex')
|
||||||
|
const startIndex = Number.parseInt(startIndexRaw, 10)
|
||||||
|
const endIndex = Number.parseInt(endIndexRaw, 10)
|
||||||
|
|
||||||
|
if (startChar && endChar) {
|
||||||
|
const startPos = fullContent.indexOf(startChar)
|
||||||
|
if (startPos !== -1) {
|
||||||
|
const endPos = fullContent.indexOf(endChar, startPos + startChar.length - 1)
|
||||||
|
if (endPos !== -1 && endPos >= startPos) {
|
||||||
|
const sliced = fullContent.slice(startPos, endPos + endChar.length).trim()
|
||||||
|
if (sliced) return sliced
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number.isFinite(startIndex) && Number.isFinite(endIndex) && endIndex >= startIndex) {
|
||||||
|
const chars = Array.from(fullContent)
|
||||||
|
const sliced = chars.slice(startIndex, endIndex + 1).join('').trim()
|
||||||
|
if (sliced) return sliced
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析名片消息
|
* 解析名片消息
|
||||||
* 格式: <msg username="wxid_xxx" nickname="昵称" ... />
|
* 格式: <msg username="wxid_xxx" nickname="昵称" ... />
|
||||||
@@ -4599,6 +4686,7 @@ class ChatService {
|
|||||||
fileName?: string
|
fileName?: string
|
||||||
fileSize?: number
|
fileSize?: number
|
||||||
fileExt?: string
|
fileExt?: string
|
||||||
|
fileMd5?: string
|
||||||
transferPayerUsername?: string
|
transferPayerUsername?: string
|
||||||
transferReceiverUsername?: string
|
transferReceiverUsername?: string
|
||||||
chatRecordTitle?: string
|
chatRecordTitle?: string
|
||||||
@@ -4795,6 +4883,7 @@ class ChatService {
|
|||||||
|
|
||||||
// 提取文件扩展名
|
// 提取文件扩展名
|
||||||
const fileExt = this.extractXmlValue(content, 'fileext')
|
const fileExt = this.extractXmlValue(content, 'fileext')
|
||||||
|
const fileMd5 = this.extractXmlValue(content, 'md5') || this.extractXmlValue(content, 'filemd5')
|
||||||
if (fileExt) {
|
if (fileExt) {
|
||||||
result.fileExt = fileExt
|
result.fileExt = fileExt
|
||||||
} else if (result.fileName) {
|
} else if (result.fileName) {
|
||||||
@@ -4804,6 +4893,9 @@ class ChatService {
|
|||||||
result.fileExt = match[1]
|
result.fileExt = match[1]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (fileMd5) {
|
||||||
|
result.fileMd5 = fileMd5.toLowerCase()
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5096,7 +5188,7 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//手动查找 media_*.db 文件(当 WCDB DLL 不支持 listMediaDbs 时的 fallback)
|
//手动查找 media_*.db 文件(当 WCDB数据服务不支持 listMediaDbs 时的 fallback)
|
||||||
private async findMediaDbsManually(): Promise<string[]> {
|
private async findMediaDbsManually(): Promise<string[]> {
|
||||||
try {
|
try {
|
||||||
const dbPath = this.configService.get('dbPath')
|
const dbPath = this.configService.get('dbPath')
|
||||||
@@ -5303,14 +5395,14 @@ class ChatService {
|
|||||||
row: Record<string, any>,
|
row: Record<string, any>,
|
||||||
rawContent: string
|
rawContent: string
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const directSender = this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username'])
|
const directSender = row.sender_username
|
||||||
|| this.extractSenderUsernameFromContent(rawContent)
|
|| this.extractSenderUsernameFromContent(rawContent)
|
||||||
if (directSender) {
|
if (directSender) {
|
||||||
return directSender
|
return directSender
|
||||||
}
|
}
|
||||||
|
|
||||||
const dbPath = this.getRowField(row, ['db_path', 'dbPath', '_db_path'])
|
const dbPath = row._db_path
|
||||||
const realSenderId = this.getRowField(row, ['real_sender_id', 'realSenderId'])
|
const realSenderId = row.real_sender_id
|
||||||
if (!dbPath || realSenderId === null || realSenderId === undefined || String(realSenderId).trim() === '') {
|
if (!dbPath || realSenderId === null || realSenderId === undefined || String(realSenderId).trim() === '') {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -5359,7 +5451,7 @@ class ChatService {
|
|||||||
50: '[通话]',
|
50: '[通话]',
|
||||||
10000: '[系统消息]',
|
10000: '[系统消息]',
|
||||||
244813135921: '[引用消息]',
|
244813135921: '[引用消息]',
|
||||||
266287972401: '[拍一拍]',
|
266287972401: '拍一拍',
|
||||||
81604378673: '[聊天记录]',
|
81604378673: '[聊天记录]',
|
||||||
154618822705: '[小程序]',
|
154618822705: '[小程序]',
|
||||||
8594229559345: '[红包]',
|
8594229559345: '[红包]',
|
||||||
@@ -5468,7 +5560,7 @@ class ChatService {
|
|||||||
* XML: <msg><appmsg...><title>"XX"拍了拍"XX"相信未来!</title>...</msg>
|
* XML: <msg><appmsg...><title>"XX"拍了拍"XX"相信未来!</title>...</msg>
|
||||||
*/
|
*/
|
||||||
private cleanPatMessage(content: string): string {
|
private cleanPatMessage(content: string): string {
|
||||||
if (!content) return '[拍一拍]'
|
if (!content) return '拍一拍'
|
||||||
|
|
||||||
// 1. 优先从 XML <title> 标签提取内容
|
// 1. 优先从 XML <title> 标签提取内容
|
||||||
const titleMatch = /<title>([\s\S]*?)<\/title>/i.exec(content)
|
const titleMatch = /<title>([\s\S]*?)<\/title>/i.exec(content)
|
||||||
@@ -5478,14 +5570,14 @@ class ChatService {
|
|||||||
.replace(/\]\]>/g, '')
|
.replace(/\]\]>/g, '')
|
||||||
.trim()
|
.trim()
|
||||||
if (title) {
|
if (title) {
|
||||||
return `[拍一拍] ${title}`
|
return title
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 尝试匹配标准的 "A拍了拍B" 格式
|
// 2. 尝试匹配标准的 "A拍了拍B" 格式
|
||||||
const match = /^(.+?拍了拍.+?)(?:[\r\n]|$|ງ|wxid_)/.exec(content)
|
const match = /^(.+?拍了拍.+?)(?:[\r\n]|$|ງ|wxid_)/.exec(content)
|
||||||
if (match) {
|
if (match) {
|
||||||
return `[拍一拍] ${match[1].trim()}`
|
return match[1].trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 如果匹配失败,尝试清理掉疑似的 garbage (wxid, 乱码)
|
// 3. 如果匹配失败,尝试清理掉疑似的 garbage (wxid, 乱码)
|
||||||
@@ -5499,10 +5591,10 @@ class ChatService {
|
|||||||
|
|
||||||
// 如果清理后还有内容,返回
|
// 如果清理后还有内容,返回
|
||||||
if (cleaned && cleaned.length > 1 && !cleaned.includes('xml')) {
|
if (cleaned && cleaned.length > 1 && !cleaned.includes('xml')) {
|
||||||
return `[拍一拍] ${cleaned}`
|
return cleaned
|
||||||
}
|
}
|
||||||
|
|
||||||
return '[拍一拍]'
|
return '拍一拍'
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -5655,7 +5747,7 @@ class ChatService {
|
|||||||
if (!result.success || !result.contact) return null
|
if (!result.success || !result.contact) return null
|
||||||
const contact = result.contact as Record<string, any>
|
const contact = result.contact as Record<string, any>
|
||||||
let alias = String(contact.alias || contact.Alias || '')
|
let alias = String(contact.alias || contact.Alias || '')
|
||||||
// DLL 有时不返回 alias 字段,补一条直接 SQL 查询兜底
|
//数据服务有时不返回 alias 字段,补一条直接 SQL 查询兜底
|
||||||
if (!alias) {
|
if (!alias) {
|
||||||
try {
|
try {
|
||||||
const aliasResult = await wcdbService.getContactAliasMap([username])
|
const aliasResult = await wcdbService.getContactAliasMap([username])
|
||||||
@@ -7520,11 +7612,7 @@ class ChatService {
|
|||||||
|
|
||||||
for (const row of result.messages) {
|
for (const row of result.messages) {
|
||||||
let message = await this.parseMessage(row, { source: 'search', sessionId })
|
let message = await this.parseMessage(row, { source: 'search', sessionId })
|
||||||
const resolvedSessionId = String(
|
const resolvedSessionId = String(sessionId || row._session_id || '').trim()
|
||||||
sessionId ||
|
|
||||||
this.getRowField(row, ['_session_id', 'session_id', 'sessionId', 'talker', 'username'])
|
|
||||||
|| ''
|
|
||||||
).trim()
|
|
||||||
const needsDetailHydration = isGroupSearch &&
|
const needsDetailHydration = isGroupSearch &&
|
||||||
Boolean(sessionId) &&
|
Boolean(sessionId) &&
|
||||||
message.localId > 0 &&
|
message.localId > 0 &&
|
||||||
@@ -7559,32 +7647,18 @@ class ChatService {
|
|||||||
private async parseMessage(row: any, options?: { source?: 'search' | 'detail'; sessionId?: string }): Promise<Message> {
|
private async parseMessage(row: any, options?: { source?: 'search' | 'detail'; sessionId?: string }): Promise<Message> {
|
||||||
const sourceInfo = this.getMessageSourceInfo(row)
|
const sourceInfo = this.getMessageSourceInfo(row)
|
||||||
const rawContent = this.decodeMessageContent(
|
const rawContent = this.decodeMessageContent(
|
||||||
this.getRowField(row, [
|
row.message_content,
|
||||||
'message_content',
|
row.compress_content
|
||||||
'messageContent',
|
|
||||||
'content',
|
|
||||||
'msg_content',
|
|
||||||
'msgContent',
|
|
||||||
'WCDB_CT_message_content',
|
|
||||||
'WCDB_CT_messageContent'
|
|
||||||
]),
|
|
||||||
this.getRowField(row, [
|
|
||||||
'compress_content',
|
|
||||||
'compressContent',
|
|
||||||
'compressed_content',
|
|
||||||
'WCDB_CT_compress_content',
|
|
||||||
'WCDB_CT_compressContent'
|
|
||||||
])
|
|
||||||
)
|
)
|
||||||
// 这里复用 parseMessagesBatch 里面的解析逻辑,为了简单我这里先写个基础的
|
// 这里复用 parseMessagesBatch 里面的解析逻辑,为了简单我这里先写个基础的
|
||||||
// 实际项目中建议抽取 parseRawMessage(row) 供多处使用
|
// 实际项目中建议抽取 parseRawMessage(row) 供多处使用
|
||||||
const localId = this.getRowInt(row, ['local_id', 'localId', 'LocalId', 'msg_local_id', 'msgLocalId', 'MsgLocalId', 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id'], 0)
|
const localId = this.getRowInt(row, ['local_id'], 0)
|
||||||
const serverIdRaw = this.normalizeUnsignedIntegerToken(this.getRowField(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id']))
|
const serverIdRaw = this.normalizeUnsignedIntegerToken(row.server_id)
|
||||||
const serverId = this.getRowInt(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id'], 0)
|
const serverId = this.getRowInt(row, ['server_id'], 0)
|
||||||
const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 0)
|
const localType = this.getRowInt(row, ['local_type'], 0)
|
||||||
const createTime = this.getRowInt(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0)
|
const createTime = this.getRowInt(row, ['create_time'], 0)
|
||||||
const sortSeq = this.getRowInt(row, ['sort_seq', 'sortSeq', 'seq', 'sequence', 'WCDB_CT_sort_seq'], createTime)
|
const sortSeq = this.getRowInt(row, ['sort_seq'], createTime)
|
||||||
const rawIsSend = this.getRowField(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send'])
|
const rawIsSend = row.computed_is_send ?? row.is_send
|
||||||
const senderUsername = await this.resolveSenderUsernameForMessageRow(row, rawContent)
|
const senderUsername = await this.resolveSenderUsernameForMessageRow(row, rawContent)
|
||||||
const sendState = this.resolveMessageIsSend(rawIsSend === null ? null : parseInt(rawIsSend, 10), senderUsername)
|
const sendState = this.resolveMessageIsSend(rawIsSend === null ? null : parseInt(rawIsSend, 10), senderUsername)
|
||||||
const msg: Message = {
|
const msg: Message = {
|
||||||
@@ -7612,8 +7686,8 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (msg.localId === 0 || msg.createTime === 0) {
|
if (msg.localId === 0 || msg.createTime === 0) {
|
||||||
const rawLocalId = this.getRowField(row, ['local_id', 'localId', 'LocalId', 'msg_local_id', 'msgLocalId', 'MsgLocalId', 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id'])
|
const rawLocalId = row.local_id
|
||||||
const rawCreateTime = this.getRowField(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'])
|
const rawCreateTime = row.create_time
|
||||||
console.warn('[ChatService] parseMessage raw keys', {
|
console.warn('[ChatService] parseMessage raw keys', {
|
||||||
rawLocalId,
|
rawLocalId,
|
||||||
rawLocalIdType: rawLocalId ? typeof rawLocalId : 'null',
|
rawLocalIdType: rawLocalId ? typeof rawLocalId : 'null',
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ import Store from 'electron-store'
|
|||||||
|
|
||||||
// 加密前缀标记
|
// 加密前缀标记
|
||||||
const SAFE_PREFIX = 'safe:' // safeStorage 加密(普通模式)
|
const SAFE_PREFIX = 'safe:' // safeStorage 加密(普通模式)
|
||||||
|
const isSafeStorageAvailable = (): boolean => {
|
||||||
|
try {
|
||||||
|
return typeof safeStorage?.isEncryptionAvailable === 'function' && safeStorage.isEncryptionAvailable()
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
const LOCK_PREFIX = 'lock:' // 密码派生密钥加密(锁定模式)
|
const LOCK_PREFIX = 'lock:' // 密码派生密钥加密(锁定模式)
|
||||||
|
|
||||||
interface ConfigSchema {
|
interface ConfigSchema {
|
||||||
@@ -27,6 +34,7 @@ interface ConfigSchema {
|
|||||||
themeId: string
|
themeId: string
|
||||||
language: string
|
language: string
|
||||||
logEnabled: boolean
|
logEnabled: boolean
|
||||||
|
launchAtStartup?: boolean
|
||||||
llmModelPath: string
|
llmModelPath: string
|
||||||
whisperModelName: string
|
whisperModelName: string
|
||||||
whisperModelDir: string
|
whisperModelDir: string
|
||||||
@@ -60,6 +68,7 @@ interface ConfigSchema {
|
|||||||
windowCloseBehavior: 'ask' | 'tray' | 'quit'
|
windowCloseBehavior: 'ask' | 'tray' | 'quit'
|
||||||
quoteLayout: 'quote-top' | 'quote-bottom'
|
quoteLayout: 'quote-top' | 'quote-bottom'
|
||||||
wordCloudExcludeWords: string[]
|
wordCloudExcludeWords: string[]
|
||||||
|
exportWriteLayout: 'A' | 'B' | 'C'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 需要 safeStorage 加密的字段(普通模式)
|
// 需要 safeStorage 加密的字段(普通模式)
|
||||||
@@ -128,11 +137,12 @@ export class ConfigService {
|
|||||||
httpApiToken: '',
|
httpApiToken: '',
|
||||||
httpApiEnabled: false,
|
httpApiEnabled: false,
|
||||||
httpApiPort: 5031,
|
httpApiPort: 5031,
|
||||||
httpApiHost: '127.0.0.1',
|
httpApiHost: '0.0.0.0',
|
||||||
messagePushEnabled: false,
|
messagePushEnabled: false,
|
||||||
windowCloseBehavior: 'ask',
|
windowCloseBehavior: 'ask',
|
||||||
quoteLayout: 'quote-top',
|
quoteLayout: 'quote-top',
|
||||||
wordCloudExcludeWords: []
|
wordCloudExcludeWords: [],
|
||||||
|
exportWriteLayout: 'A'
|
||||||
}
|
}
|
||||||
|
|
||||||
const storeOptions: any = {
|
const storeOptions: any = {
|
||||||
@@ -254,7 +264,7 @@ export class ConfigService {
|
|||||||
private safeEncrypt(plaintext: string): string {
|
private safeEncrypt(plaintext: string): string {
|
||||||
if (!plaintext) return ''
|
if (!plaintext) return ''
|
||||||
if (plaintext.startsWith(SAFE_PREFIX)) return plaintext
|
if (plaintext.startsWith(SAFE_PREFIX)) return plaintext
|
||||||
if (!safeStorage.isEncryptionAvailable()) return plaintext
|
if (!isSafeStorageAvailable()) return plaintext
|
||||||
const encrypted = safeStorage.encryptString(plaintext)
|
const encrypted = safeStorage.encryptString(plaintext)
|
||||||
return SAFE_PREFIX + encrypted.toString('base64')
|
return SAFE_PREFIX + encrypted.toString('base64')
|
||||||
}
|
}
|
||||||
@@ -262,7 +272,7 @@ export class ConfigService {
|
|||||||
private safeDecrypt(stored: string): string {
|
private safeDecrypt(stored: string): string {
|
||||||
if (!stored) return ''
|
if (!stored) return ''
|
||||||
if (!stored.startsWith(SAFE_PREFIX)) return stored
|
if (!stored.startsWith(SAFE_PREFIX)) return stored
|
||||||
if (!safeStorage.isEncryptionAvailable()) return ''
|
if (!isSafeStorageAvailable()) return ''
|
||||||
try {
|
try {
|
||||||
const buf = Buffer.from(stored.slice(SAFE_PREFIX.length), 'base64')
|
const buf = Buffer.from(stored.slice(SAFE_PREFIX.length), 'base64')
|
||||||
return safeStorage.decryptString(buf)
|
return safeStorage.decryptString(buf)
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ export interface ExportOptions {
|
|||||||
exportVoices?: boolean
|
exportVoices?: boolean
|
||||||
exportVideos?: boolean
|
exportVideos?: boolean
|
||||||
exportEmojis?: boolean
|
exportEmojis?: boolean
|
||||||
|
exportFiles?: boolean
|
||||||
|
maxFileSizeMb?: number
|
||||||
exportVoiceAsText?: boolean
|
exportVoiceAsText?: boolean
|
||||||
excelCompactColumns?: boolean
|
excelCompactColumns?: boolean
|
||||||
txtColumns?: string[]
|
txtColumns?: string[]
|
||||||
@@ -121,7 +123,7 @@ const TXT_COLUMN_DEFINITIONS: Array<{ id: string; label: string }> = [
|
|||||||
|
|
||||||
interface MediaExportItem {
|
interface MediaExportItem {
|
||||||
relativePath: string
|
relativePath: string
|
||||||
kind: 'image' | 'voice' | 'emoji' | 'video'
|
kind: 'image' | 'voice' | 'emoji' | 'video' | 'file'
|
||||||
posterDataUrl?: string
|
posterDataUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +138,11 @@ interface ExportDisplayProfile {
|
|||||||
|
|
||||||
type MessageCollectMode = 'full' | 'text-fast' | 'media-fast'
|
type MessageCollectMode = 'full' | 'text-fast' | 'media-fast'
|
||||||
type MediaContentType = 'voice' | 'image' | 'video' | 'emoji'
|
type MediaContentType = 'voice' | 'image' | 'video' | 'emoji'
|
||||||
|
interface FileExportCandidate {
|
||||||
|
sourcePath: string
|
||||||
|
matchedBy: 'md5' | 'name'
|
||||||
|
yearMonth?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface ExportProgress {
|
export interface ExportProgress {
|
||||||
current: number
|
current: number
|
||||||
@@ -247,6 +254,7 @@ async function parallelLimit<T, R>(
|
|||||||
|
|
||||||
class ExportService {
|
class ExportService {
|
||||||
private configService: ConfigService
|
private configService: ConfigService
|
||||||
|
private runtimeConfig: { dbPath?: string; decryptKey?: string; myWxid?: string } | null = null
|
||||||
private contactCache: LRUCache<string, { displayName: string; avatarUrl?: string }>
|
private contactCache: LRUCache<string, { displayName: string; avatarUrl?: string }>
|
||||||
private inlineEmojiCache: LRUCache<string, string>
|
private inlineEmojiCache: LRUCache<string, string>
|
||||||
private htmlStyleCache: string | null = null
|
private htmlStyleCache: string | null = null
|
||||||
@@ -288,6 +296,10 @@ class ExportService {
|
|||||||
return error
|
return error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setRuntimeConfig(config: { dbPath?: string; decryptKey?: string; myWxid?: string } | null): void {
|
||||||
|
this.runtimeConfig = config
|
||||||
|
}
|
||||||
|
|
||||||
private normalizeSessionIds(sessionIds: string[]): string[] {
|
private normalizeSessionIds(sessionIds: string[]): string[] {
|
||||||
return Array.from(
|
return Array.from(
|
||||||
new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean))
|
new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean))
|
||||||
@@ -430,6 +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 MESSAGE_PROGRESS_DELTA_THRESHOLD = 500
|
||||||
|
|
||||||
const commit = (progress: ExportProgress) => {
|
const commit = (progress: ExportProgress) => {
|
||||||
onProgress(progress)
|
onProgress(progress)
|
||||||
@@ -454,9 +468,9 @@ class ExportService {
|
|||||||
const shouldEmit = force ||
|
const shouldEmit = force ||
|
||||||
phase !== lastPhase ||
|
phase !== lastPhase ||
|
||||||
sessionId !== lastSessionId ||
|
sessionId !== lastSessionId ||
|
||||||
collectedDelta >= 200 ||
|
collectedDelta >= MESSAGE_PROGRESS_DELTA_THRESHOLD ||
|
||||||
exportedDelta >= 200 ||
|
exportedDelta >= MESSAGE_PROGRESS_DELTA_THRESHOLD ||
|
||||||
(now - lastSentAt >= 120)
|
(now - lastSentAt >= MIN_PROGRESS_EMIT_INTERVAL_MS)
|
||||||
|
|
||||||
if (shouldEmit && pending) {
|
if (shouldEmit && pending) {
|
||||||
commit(pending)
|
commit(pending)
|
||||||
@@ -842,7 +856,7 @@ class ExportService {
|
|||||||
|
|
||||||
private isMediaExportEnabled(options: ExportOptions): boolean {
|
private isMediaExportEnabled(options: ExportOptions): boolean {
|
||||||
return options.exportMedia === true &&
|
return options.exportMedia === true &&
|
||||||
Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis)
|
Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis || options.exportFiles)
|
||||||
}
|
}
|
||||||
|
|
||||||
private isUnboundedDateRange(dateRange?: { start: number; end: number } | null): boolean {
|
private isUnboundedDateRange(dateRange?: { start: number; end: number } | null): boolean {
|
||||||
@@ -880,7 +894,7 @@ class ExportService {
|
|||||||
if (options.exportImages) selected.add(3)
|
if (options.exportImages) selected.add(3)
|
||||||
if (options.exportVoices) selected.add(34)
|
if (options.exportVoices) selected.add(34)
|
||||||
if (options.exportVideos) selected.add(43)
|
if (options.exportVideos) selected.add(43)
|
||||||
if (options.exportEmojis) selected.add(47)
|
if (options.exportFiles) selected.add(49)
|
||||||
return selected
|
return selected
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1307,9 +1321,9 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async ensureConnected(): Promise<{ success: boolean; cleanedWxid?: string; error?: string }> {
|
private async ensureConnected(): Promise<{ success: boolean; cleanedWxid?: string; error?: string }> {
|
||||||
const wxid = this.configService.get('myWxid')
|
const wxid = String(this.runtimeConfig?.myWxid || this.configService.get('myWxid') || '').trim()
|
||||||
const dbPath = this.configService.get('dbPath')
|
const dbPath = String(this.runtimeConfig?.dbPath || this.configService.get('dbPath') || '').trim()
|
||||||
const decryptKey = this.configService.get('decryptKey')
|
const decryptKey = String(this.runtimeConfig?.decryptKey || this.configService.get('decryptKey') || '').trim()
|
||||||
if (!wxid) return { success: false, error: '请先在设置页面配置微信ID' }
|
if (!wxid) return { success: false, error: '请先在设置页面配置微信ID' }
|
||||||
if (!dbPath) return { success: false, error: '请先在设置页面配置数据库路径' }
|
if (!dbPath) return { success: false, error: '请先在设置页面配置数据库路径' }
|
||||||
if (!decryptKey) return { success: false, error: '请先在设置页面配置解密密钥' }
|
if (!decryptKey) return { success: false, error: '请先在设置页面配置解密密钥' }
|
||||||
@@ -1414,7 +1428,7 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
return this.buildTrustedGroupNicknameMap(Object.entries(dllResult.nicknames), candidates)
|
return this.buildTrustedGroupNicknameMap(Object.entries(dllResult.nicknames), candidates)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('getGroupNicknamesForRoom dll error:', e)
|
console.error('getGroupNicknamesForRoom service error:', e)
|
||||||
return new Map<string, string>()
|
return new Map<string, string>()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2245,7 +2259,7 @@ class ExportService {
|
|||||||
const referMsgXml = normalized.substring(referMsgStart, referMsgEnd + 11)
|
const referMsgXml = normalized.substring(referMsgStart, referMsgEnd + 11)
|
||||||
const quoteInfo = this.parseQuoteMessage(normalized)
|
const quoteInfo = this.parseQuoteMessage(normalized)
|
||||||
const replyText = this.stripSenderPrefix(this.extractXmlValue(normalized, 'title') || '')
|
const replyText = this.stripSenderPrefix(this.extractXmlValue(normalized, 'title') || '')
|
||||||
const quotedPreview = this.formatQuotedReferencePreview(
|
const quotedPreview = quoteInfo.content || this.formatQuotedReferencePreview(
|
||||||
this.extractXmlValue(referMsgXml, 'content'),
|
this.extractXmlValue(referMsgXml, 'content'),
|
||||||
this.extractXmlValue(referMsgXml, 'type')
|
this.extractXmlValue(referMsgXml, 'type')
|
||||||
)
|
)
|
||||||
@@ -2951,7 +2965,7 @@ class ExportService {
|
|||||||
|
|
||||||
switch (referType) {
|
switch (referType) {
|
||||||
case '1':
|
case '1':
|
||||||
displayContent = this.sanitizeQuotedContent(referContent)
|
displayContent = this.extractPreferredQuotedText(referMsgXml)
|
||||||
break
|
break
|
||||||
case '3':
|
case '3':
|
||||||
displayContent = '[图片]'
|
displayContent = '[图片]'
|
||||||
@@ -2992,6 +3006,76 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extractPreferredQuotedText(referMsgXml: string): string {
|
||||||
|
if (!referMsgXml) return ''
|
||||||
|
|
||||||
|
const sources = [this.decodeHtmlEntities(referMsgXml)]
|
||||||
|
const rawMsgSource = this.extractXmlValue(referMsgXml, 'msgsource')
|
||||||
|
if (rawMsgSource) {
|
||||||
|
const decodedMsgSource = this.decodeHtmlEntities(rawMsgSource)
|
||||||
|
if (decodedMsgSource) {
|
||||||
|
sources.push(decodedMsgSource)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullContent = this.sanitizeQuotedContent(this.extractXmlValue(sources[0] || referMsgXml, 'content'))
|
||||||
|
const partialText = this.extractPartialQuotedText(sources[0] || referMsgXml, fullContent)
|
||||||
|
if (partialText) return partialText
|
||||||
|
|
||||||
|
const candidateTags = [
|
||||||
|
'selectedcontent',
|
||||||
|
'selectedtext',
|
||||||
|
'selectcontent',
|
||||||
|
'selecttext',
|
||||||
|
'quotecontent',
|
||||||
|
'quotetext',
|
||||||
|
'partcontent',
|
||||||
|
'parttext',
|
||||||
|
'excerpt',
|
||||||
|
'summary',
|
||||||
|
'preview'
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const source of sources) {
|
||||||
|
for (const tag of candidateTags) {
|
||||||
|
const value = this.sanitizeQuotedContent(this.extractXmlValue(source, tag))
|
||||||
|
if (value) return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fullContent
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractPartialQuotedText(xml: string, fullContent: string): string {
|
||||||
|
if (!xml || !fullContent) return ''
|
||||||
|
|
||||||
|
const startChar = this.extractXmlValue(xml, 'start')
|
||||||
|
const endChar = this.extractXmlValue(xml, 'end')
|
||||||
|
const startIndexRaw = this.extractXmlValue(xml, 'startindex')
|
||||||
|
const endIndexRaw = this.extractXmlValue(xml, 'endindex')
|
||||||
|
const startIndex = Number.parseInt(startIndexRaw, 10)
|
||||||
|
const endIndex = Number.parseInt(endIndexRaw, 10)
|
||||||
|
|
||||||
|
if (startChar && endChar) {
|
||||||
|
const startPos = fullContent.indexOf(startChar)
|
||||||
|
if (startPos !== -1) {
|
||||||
|
const endPos = fullContent.indexOf(endChar, startPos + startChar.length - 1)
|
||||||
|
if (endPos !== -1 && endPos >= startPos) {
|
||||||
|
const sliced = fullContent.slice(startPos, endPos + endChar.length).trim()
|
||||||
|
if (sliced) return sliced
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number.isFinite(startIndex) && Number.isFinite(endIndex) && endIndex >= startIndex) {
|
||||||
|
const chars = Array.from(fullContent)
|
||||||
|
const sliced = chars.slice(startIndex, endIndex + 1).join('').trim()
|
||||||
|
if (sliced) return sliced
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
private extractChatLabReplyToMessageId(content: string): string | undefined {
|
private extractChatLabReplyToMessageId(content: string): string | undefined {
|
||||||
try {
|
try {
|
||||||
const normalized = this.normalizeAppMessageContent(content || '')
|
const normalized = this.normalizeAppMessageContent(content || '')
|
||||||
@@ -3310,15 +3394,29 @@ class ExportService {
|
|||||||
const subType = this.extractAppMessageType(normalized)
|
const subType = this.extractAppMessageType(normalized)
|
||||||
if (subType && subType !== '5' && subType !== '49') return null
|
if (subType && subType !== '5' && subType !== '49') return null
|
||||||
|
|
||||||
const url = this.normalizeHtmlLinkUrl(this.extractXmlValue(normalized, 'url'))
|
const url = [
|
||||||
|
this.extractXmlValue(normalized, 'url'),
|
||||||
|
this.extractXmlValue(normalized, 'shareurlopen'),
|
||||||
|
this.extractXmlValue(normalized, 'shareurloriginal'),
|
||||||
|
this.extractXmlValue(normalized, 'shareurl'),
|
||||||
|
this.extractXmlValue(normalized, 'shorturl'),
|
||||||
|
this.extractXmlValue(normalized, 'dataurl'),
|
||||||
|
this.extractXmlValue(normalized, 'lowurl'),
|
||||||
|
this.extractXmlValue(normalized, 'streamvideoweburl'),
|
||||||
|
this.extractXmlValue(normalized, 'weburl')
|
||||||
|
]
|
||||||
|
.map(candidate => this.normalizeHtmlLinkUrl(candidate))
|
||||||
|
.find(Boolean) || ''
|
||||||
if (!url) return null
|
if (!url) return null
|
||||||
|
|
||||||
const title = this.extractXmlValue(normalized, 'title') || this.extractXmlValue(normalized, 'des') || url
|
const title = this.stripSenderPrefix(
|
||||||
|
this.extractXmlValue(normalized, 'title') || this.extractXmlValue(normalized, 'des') || url
|
||||||
|
) || url
|
||||||
return { title, url }
|
return { title, url }
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeHtmlLinkUrl(rawUrl: string): string {
|
private normalizeHtmlLinkUrl(rawUrl: string): string {
|
||||||
const value = (rawUrl || '').trim()
|
const value = (rawUrl || '').trim().replace(/&/gi, '&')
|
||||||
if (!value) return ''
|
if (!value) return ''
|
||||||
|
|
||||||
const parseHttpUrl = (candidate: string): string => {
|
const parseHttpUrl = (candidate: string): string => {
|
||||||
@@ -3349,6 +3447,46 @@ class ExportService {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getLinkCardDisplayTitle(linkCard: { title: string; url: string }): string {
|
||||||
|
const normalizedTitle = this.stripSenderPrefix(String(linkCard.title || '').trim())
|
||||||
|
return normalizedTitle || linkCard.url || '链接'
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatLinkCardExportText(
|
||||||
|
content: string,
|
||||||
|
localType: number,
|
||||||
|
style: 'markdown' | 'append-url'
|
||||||
|
): string | null {
|
||||||
|
const linkCard = this.extractHtmlLinkCard(content, localType)
|
||||||
|
if (!linkCard?.url) return null
|
||||||
|
|
||||||
|
const title = this.getLinkCardDisplayTitle(linkCard)
|
||||||
|
if (style === 'markdown') {
|
||||||
|
return `[${title}](${linkCard.url})`
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = title && title !== linkCard.url ? `[链接] ${title}` : '[链接]'
|
||||||
|
return `${prefix}\n${linkCard.url}`
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyExcelLinkCardCell(cell: ExcelJS.Cell, content: string, localType: number): boolean {
|
||||||
|
const linkCard = this.extractHtmlLinkCard(content, localType)
|
||||||
|
if (!linkCard?.url) return false
|
||||||
|
|
||||||
|
const title = this.getLinkCardDisplayTitle(linkCard)
|
||||||
|
cell.value = {
|
||||||
|
text: title,
|
||||||
|
hyperlink: linkCard.url,
|
||||||
|
tooltip: linkCard.url
|
||||||
|
} as any
|
||||||
|
cell.font = {
|
||||||
|
...(cell.font || {}),
|
||||||
|
color: { argb: 'FF0563C1' },
|
||||||
|
underline: true
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 导出媒体文件到指定目录
|
* 导出媒体文件到指定目录
|
||||||
*/
|
*/
|
||||||
@@ -3362,6 +3500,8 @@ class ExportService {
|
|||||||
exportVoices?: boolean
|
exportVoices?: boolean
|
||||||
exportVideos?: boolean
|
exportVideos?: boolean
|
||||||
exportEmojis?: boolean
|
exportEmojis?: boolean
|
||||||
|
exportFiles?: boolean
|
||||||
|
maxFileSizeMb?: number
|
||||||
exportVoiceAsText?: boolean
|
exportVoiceAsText?: boolean
|
||||||
includeVideoPoster?: boolean
|
includeVideoPoster?: boolean
|
||||||
includeVoiceWithTranscript?: boolean
|
includeVoiceWithTranscript?: boolean
|
||||||
@@ -3415,6 +3555,16 @@ class ExportService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((localType === 49 || localType === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6') {
|
||||||
|
return this.exportFileAttachment(
|
||||||
|
msg,
|
||||||
|
mediaRootDir,
|
||||||
|
mediaRelativePrefix,
|
||||||
|
options.maxFileSizeMb,
|
||||||
|
options.dirCache
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3483,20 +3633,11 @@ class ExportService {
|
|||||||
console.log(`[Export] 使用缩略图替代 (localId=${msg.localId}): ${thumbResult.localPath}`)
|
console.log(`[Export] 使用缩略图替代 (localId=${msg.localId}): ${thumbResult.localPath}`)
|
||||||
result.localPath = thumbResult.localPath
|
result.localPath = thumbResult.localPath
|
||||||
} else {
|
} else {
|
||||||
console.log(`[Export] 缩略图也获取失败 (localId=${msg.localId}): error=${thumbResult.error || '未知'}`)
|
console.log(`[Export] 缩略图也获取失败,所有方式均失败 → 将显示 [图片] 占位符`)
|
||||||
// 最后尝试:直接从 imageStore 获取缓存的缩略图 data URL
|
if (missingRunCacheKey) {
|
||||||
const { imageStore } = await import('../main')
|
this.mediaRunMissingImageKeys.add(missingRunCacheKey)
|
||||||
const cachedThumb = imageStore?.getCachedImage(sessionId, imageMd5, imageDatName)
|
|
||||||
if (cachedThumb) {
|
|
||||||
console.log(`[Export] 从 imageStore 获取到缓存缩略图 (localId=${msg.localId})`)
|
|
||||||
result.localPath = cachedThumb
|
|
||||||
} else {
|
|
||||||
console.log(`[Export] 所有方式均失败 → 将显示 [图片] 占位符`)
|
|
||||||
if (missingRunCacheKey) {
|
|
||||||
this.mediaRunMissingImageKeys.add(missingRunCacheKey)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3505,7 +3646,7 @@ class ExportService {
|
|||||||
const imageKey = (imageMd5 || imageDatName || 'image').replace(/[^a-zA-Z0-9_-]/g, '')
|
const imageKey = (imageMd5 || imageDatName || 'image').replace(/[^a-zA-Z0-9_-]/g, '')
|
||||||
|
|
||||||
// 从 data URL 或 file URL 获取实际路径
|
// 从 data URL 或 file URL 获取实际路径
|
||||||
let sourcePath = result.localPath
|
let sourcePath: string = result.localPath!
|
||||||
if (sourcePath.startsWith('data:')) {
|
if (sourcePath.startsWith('data:')) {
|
||||||
// 是 data URL,需要保存为文件
|
// 是 data URL,需要保存为文件
|
||||||
const base64Data = sourcePath.split(',')[1]
|
const base64Data = sourcePath.split(',')[1]
|
||||||
@@ -3885,6 +4026,165 @@ class ExportService {
|
|||||||
return tagMatch?.[1]?.toLowerCase()
|
return tagMatch?.[1]?.toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolveFileAttachmentRoots(): string[] {
|
||||||
|
const dbPath = String(this.configService.get('dbPath') || '').trim()
|
||||||
|
const rawWxid = String(this.configService.get('myWxid') || '').trim()
|
||||||
|
const cleanedWxid = this.cleanAccountDirName(rawWxid)
|
||||||
|
if (!dbPath) return []
|
||||||
|
|
||||||
|
const normalized = dbPath.replace(/[\\/]+$/, '')
|
||||||
|
const roots = new Set<string>()
|
||||||
|
const tryAddRoot = (candidate: string) => {
|
||||||
|
const fileRoot = path.join(candidate, 'msg', 'file')
|
||||||
|
if (fs.existsSync(fileRoot)) {
|
||||||
|
roots.add(fileRoot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tryAddRoot(normalized)
|
||||||
|
if (rawWxid) tryAddRoot(path.join(normalized, rawWxid))
|
||||||
|
if (cleanedWxid && cleanedWxid !== rawWxid) tryAddRoot(path.join(normalized, cleanedWxid))
|
||||||
|
|
||||||
|
const dbStoragePath =
|
||||||
|
this.resolveDbStoragePathForExport(normalized, cleanedWxid) ||
|
||||||
|
this.resolveDbStoragePathForExport(normalized, rawWxid)
|
||||||
|
if (dbStoragePath) {
|
||||||
|
tryAddRoot(path.dirname(dbStoragePath))
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(roots)
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildPreferredFileYearMonths(createTime?: unknown): string[] {
|
||||||
|
const raw = Number(createTime)
|
||||||
|
if (!Number.isFinite(raw) || raw <= 0) return []
|
||||||
|
const ts = raw > 1e12 ? raw : raw * 1000
|
||||||
|
const date = new Date(ts)
|
||||||
|
if (Number.isNaN(date.getTime())) return []
|
||||||
|
const y = date.getFullYear()
|
||||||
|
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
return [`${y}-${m}`]
|
||||||
|
}
|
||||||
|
|
||||||
|
private async verifyFileHash(sourcePath: string, expectedMd5?: string): Promise<boolean> {
|
||||||
|
const normalizedExpected = String(expectedMd5 || '').trim().toLowerCase()
|
||||||
|
if (!normalizedExpected) return true
|
||||||
|
if (!/^[a-f0-9]{32}$/i.test(normalizedExpected)) return true
|
||||||
|
try {
|
||||||
|
const hash = crypto.createHash('md5')
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const stream = fs.createReadStream(sourcePath)
|
||||||
|
stream.on('data', chunk => hash.update(chunk))
|
||||||
|
stream.on('end', () => resolve())
|
||||||
|
stream.on('error', reject)
|
||||||
|
})
|
||||||
|
return hash.digest('hex').toLowerCase() === normalizedExpected
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveFileAttachmentCandidates(msg: any): Promise<FileExportCandidate[]> {
|
||||||
|
const fileName = String(msg?.fileName || '').trim()
|
||||||
|
if (!fileName) return []
|
||||||
|
|
||||||
|
const roots = this.resolveFileAttachmentRoots()
|
||||||
|
if (roots.length === 0) return []
|
||||||
|
|
||||||
|
const normalizedMd5 = String(msg?.fileMd5 || '').trim().toLowerCase()
|
||||||
|
const preferredMonths = this.buildPreferredFileYearMonths(msg?.createTime)
|
||||||
|
const candidates: FileExportCandidate[] = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
|
||||||
|
for (const root of roots) {
|
||||||
|
let monthDirs: string[] = []
|
||||||
|
try {
|
||||||
|
monthDirs = fs.readdirSync(root)
|
||||||
|
.filter(entry => /^\d{4}-\d{2}$/.test(entry) && fs.existsSync(path.join(root, entry)))
|
||||||
|
.sort()
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderedMonths = Array.from(new Set([
|
||||||
|
...preferredMonths,
|
||||||
|
...monthDirs.slice().reverse()
|
||||||
|
]))
|
||||||
|
|
||||||
|
for (const month of orderedMonths) {
|
||||||
|
const sourcePath = path.join(root, month, fileName)
|
||||||
|
if (!fs.existsSync(sourcePath)) continue
|
||||||
|
const resolvedPath = path.resolve(sourcePath)
|
||||||
|
if (seen.has(resolvedPath)) continue
|
||||||
|
seen.add(resolvedPath)
|
||||||
|
|
||||||
|
if (normalizedMd5) {
|
||||||
|
const ok = await this.verifyFileHash(resolvedPath, normalizedMd5)
|
||||||
|
if (ok) {
|
||||||
|
candidates.unshift({ sourcePath: resolvedPath, matchedBy: 'md5', yearMonth: month })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.push({ sourcePath: resolvedPath, matchedBy: 'name', yearMonth: month })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
private async exportFileAttachment(
|
||||||
|
msg: any,
|
||||||
|
mediaRootDir: string,
|
||||||
|
mediaRelativePrefix: string,
|
||||||
|
maxFileSizeMb?: number,
|
||||||
|
dirCache?: Set<string>
|
||||||
|
): Promise<MediaExportItem | null> {
|
||||||
|
try {
|
||||||
|
const fileNameRaw = String(msg?.fileName || '').trim()
|
||||||
|
if (!fileNameRaw) return null
|
||||||
|
|
||||||
|
const filesDir = path.join(mediaRootDir, mediaRelativePrefix, 'files')
|
||||||
|
if (!dirCache?.has(filesDir)) {
|
||||||
|
await fs.promises.mkdir(filesDir, { recursive: true })
|
||||||
|
dirCache?.add(filesDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = await this.resolveFileAttachmentCandidates(msg)
|
||||||
|
if (candidates.length === 0) return null
|
||||||
|
|
||||||
|
const maxBytes = Number.isFinite(maxFileSizeMb)
|
||||||
|
? Math.max(0, Math.floor(Number(maxFileSizeMb) * 1024 * 1024))
|
||||||
|
: 0
|
||||||
|
|
||||||
|
const selected = candidates[0]
|
||||||
|
const stat = await fs.promises.stat(selected.sourcePath)
|
||||||
|
if (!stat.isFile()) return null
|
||||||
|
if (maxBytes > 0 && stat.size > maxBytes) return null
|
||||||
|
|
||||||
|
const normalizedMd5 = String(msg?.fileMd5 || '').trim().toLowerCase()
|
||||||
|
if (normalizedMd5 && selected.matchedBy !== 'md5') {
|
||||||
|
const verified = await this.verifyFileHash(selected.sourcePath, normalizedMd5)
|
||||||
|
if (!verified) return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeBaseName = path.basename(fileNameRaw).replace(/[\\/:*?"<>|]/g, '_') || 'file'
|
||||||
|
const messageId = String(msg?.localId || Date.now())
|
||||||
|
const destFileName = `${messageId}_${safeBaseName}`
|
||||||
|
const destPath = path.join(filesDir, destFileName)
|
||||||
|
const copied = await this.copyFileOptimized(selected.sourcePath, destPath)
|
||||||
|
if (!copied.success) return null
|
||||||
|
|
||||||
|
this.noteMediaTelemetry({ doneFiles: 1, bytesWritten: stat.size })
|
||||||
|
return {
|
||||||
|
relativePath: path.posix.join(mediaRelativePrefix, 'files', destFileName),
|
||||||
|
kind: 'file'
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private extractLocationMeta(content: string, localType: number): {
|
private extractLocationMeta(content: string, localType: number): {
|
||||||
locationLat?: number
|
locationLat?: number
|
||||||
locationLng?: number
|
locationLng?: number
|
||||||
@@ -3941,7 +4241,7 @@ class ExportService {
|
|||||||
mediaRelativePrefix: string
|
mediaRelativePrefix: string
|
||||||
} {
|
} {
|
||||||
const exportMediaEnabled = options.exportMedia === true &&
|
const exportMediaEnabled = options.exportMedia === true &&
|
||||||
Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis)
|
Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis || options.exportFiles)
|
||||||
const outputDir = path.dirname(outputPath)
|
const outputDir = path.dirname(outputPath)
|
||||||
const rawWriteLayout = this.configService.get('exportWriteLayout')
|
const rawWriteLayout = this.configService.get('exportWriteLayout')
|
||||||
const writeLayout = rawWriteLayout === 'A' || rawWriteLayout === 'B' || rawWriteLayout === 'C'
|
const writeLayout = rawWriteLayout === 'A' || rawWriteLayout === 'B' || rawWriteLayout === 'C'
|
||||||
@@ -4878,7 +5178,8 @@ class ExportService {
|
|||||||
return (t === 3 && options.exportImages) || // 图片
|
return (t === 3 && options.exportImages) || // 图片
|
||||||
(t === 47 && options.exportEmojis) || // 表情
|
(t === 47 && options.exportEmojis) || // 表情
|
||||||
(t === 43 && options.exportVideos) || // 视频
|
(t === 43 && options.exportVideos) || // 视频
|
||||||
(t === 34 && options.exportVoices) // 语音文件
|
(t === 34 && options.exportVoices) || // 语音文件
|
||||||
|
((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6')
|
||||||
})
|
})
|
||||||
: []
|
: []
|
||||||
|
|
||||||
@@ -4919,6 +5220,8 @@ class ExportService {
|
|||||||
exportVoices: options.exportVoices,
|
exportVoices: options.exportVoices,
|
||||||
exportVideos: options.exportVideos,
|
exportVideos: options.exportVideos,
|
||||||
exportEmojis: options.exportEmojis,
|
exportEmojis: options.exportEmojis,
|
||||||
|
exportFiles: options.exportFiles,
|
||||||
|
maxFileSizeMb: options.maxFileSizeMb,
|
||||||
exportVoiceAsText: options.exportVoiceAsText,
|
exportVoiceAsText: options.exportVoiceAsText,
|
||||||
includeVideoPoster: options.format === 'html',
|
includeVideoPoster: options.format === 'html',
|
||||||
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
|
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
|
||||||
@@ -5066,6 +5369,11 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const markdownLinkContent = this.formatLinkCardExportText(msg.content, msg.localType, 'markdown')
|
||||||
|
if (markdownLinkContent) {
|
||||||
|
content = markdownLinkContent
|
||||||
|
}
|
||||||
|
|
||||||
const message: ChatLabMessage = {
|
const message: ChatLabMessage = {
|
||||||
sender: msg.senderUsername,
|
sender: msg.senderUsername,
|
||||||
accountName: senderProfile.displayName || memberInfo.accountName,
|
accountName: senderProfile.displayName || memberInfo.accountName,
|
||||||
@@ -5382,7 +5690,8 @@ class ExportService {
|
|||||||
return (t === 3 && options.exportImages) ||
|
return (t === 3 && options.exportImages) ||
|
||||||
(t === 47 && options.exportEmojis) ||
|
(t === 47 && options.exportEmojis) ||
|
||||||
(t === 43 && options.exportVideos) ||
|
(t === 43 && options.exportVideos) ||
|
||||||
(t === 34 && options.exportVoices)
|
(t === 34 && options.exportVoices) ||
|
||||||
|
((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6')
|
||||||
})
|
})
|
||||||
: []
|
: []
|
||||||
|
|
||||||
@@ -5422,6 +5731,8 @@ class ExportService {
|
|||||||
exportVoices: options.exportVoices,
|
exportVoices: options.exportVoices,
|
||||||
exportVideos: options.exportVideos,
|
exportVideos: options.exportVideos,
|
||||||
exportEmojis: options.exportEmojis,
|
exportEmojis: options.exportEmojis,
|
||||||
|
exportFiles: options.exportFiles,
|
||||||
|
maxFileSizeMb: options.maxFileSizeMb,
|
||||||
exportVoiceAsText: options.exportVoiceAsText,
|
exportVoiceAsText: options.exportVoiceAsText,
|
||||||
includeVideoPoster: options.format === 'html',
|
includeVideoPoster: options.format === 'html',
|
||||||
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
|
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
|
||||||
@@ -5558,6 +5869,13 @@ class ExportService {
|
|||||||
content = this.buildQuotedReplyText(quotedReplyDisplay)
|
content = this.buildQuotedReplyText(quotedReplyDisplay)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const appendedLinkContent = quotedReplyDisplay
|
||||||
|
? null
|
||||||
|
: this.formatLinkCardExportText(msg.content, msg.localType, 'append-url')
|
||||||
|
if (appendedLinkContent) {
|
||||||
|
content = appendedLinkContent
|
||||||
|
}
|
||||||
|
|
||||||
// 获取发送者信息用于名称显示
|
// 获取发送者信息用于名称显示
|
||||||
const senderWxid = msg.senderUsername
|
const senderWxid = msg.senderUsername
|
||||||
const contact = senderWxid
|
const contact = senderWxid
|
||||||
@@ -6235,7 +6553,8 @@ class ExportService {
|
|||||||
return (t === 3 && options.exportImages) ||
|
return (t === 3 && options.exportImages) ||
|
||||||
(t === 47 && options.exportEmojis) ||
|
(t === 47 && options.exportEmojis) ||
|
||||||
(t === 43 && options.exportVideos) ||
|
(t === 43 && options.exportVideos) ||
|
||||||
(t === 34 && options.exportVoices)
|
(t === 34 && options.exportVoices) ||
|
||||||
|
((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6')
|
||||||
})
|
})
|
||||||
: []
|
: []
|
||||||
|
|
||||||
@@ -6275,6 +6594,8 @@ class ExportService {
|
|||||||
exportVoices: options.exportVoices,
|
exportVoices: options.exportVoices,
|
||||||
exportVideos: options.exportVideos,
|
exportVideos: options.exportVideos,
|
||||||
exportEmojis: options.exportEmojis,
|
exportEmojis: options.exportEmojis,
|
||||||
|
exportFiles: options.exportFiles,
|
||||||
|
maxFileSizeMb: options.maxFileSizeMb,
|
||||||
exportVoiceAsText: options.exportVoiceAsText,
|
exportVoiceAsText: options.exportVoiceAsText,
|
||||||
includeVideoPoster: options.format === 'html',
|
includeVideoPoster: options.format === 'html',
|
||||||
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
|
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
|
||||||
@@ -6484,16 +6805,14 @@ class ExportService {
|
|||||||
enrichedContentValue = this.buildQuotedReplyText(quotedReplyDisplay)
|
enrichedContentValue = this.buildQuotedReplyText(quotedReplyDisplay)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 调试日志
|
const contentCellIndex = useCompactColumns ? 5 : 9
|
||||||
if (msg.localType === 3 || msg.localType === 47) {
|
const contentCell = worksheet.getCell(currentRow, contentCellIndex)
|
||||||
}
|
|
||||||
|
|
||||||
worksheet.getCell(currentRow, 1).value = i + 1
|
worksheet.getCell(currentRow, 1).value = i + 1
|
||||||
worksheet.getCell(currentRow, 2).value = this.formatTimestamp(msg.createTime)
|
worksheet.getCell(currentRow, 2).value = this.formatTimestamp(msg.createTime)
|
||||||
if (useCompactColumns) {
|
if (useCompactColumns) {
|
||||||
worksheet.getCell(currentRow, 3).value = senderRole
|
worksheet.getCell(currentRow, 3).value = senderRole
|
||||||
worksheet.getCell(currentRow, 4).value = this.getMessageTypeName(msg.localType)
|
worksheet.getCell(currentRow, 4).value = this.getMessageTypeName(msg.localType)
|
||||||
worksheet.getCell(currentRow, 5).value = enrichedContentValue
|
|
||||||
} else {
|
} else {
|
||||||
worksheet.getCell(currentRow, 3).value = senderNickname
|
worksheet.getCell(currentRow, 3).value = senderNickname
|
||||||
worksheet.getCell(currentRow, 4).value = senderWxid
|
worksheet.getCell(currentRow, 4).value = senderWxid
|
||||||
@@ -6501,7 +6820,10 @@ class ExportService {
|
|||||||
worksheet.getCell(currentRow, 6).value = senderGroupNickname
|
worksheet.getCell(currentRow, 6).value = senderGroupNickname
|
||||||
worksheet.getCell(currentRow, 7).value = senderRole
|
worksheet.getCell(currentRow, 7).value = senderRole
|
||||||
worksheet.getCell(currentRow, 8).value = this.getMessageTypeName(msg.localType)
|
worksheet.getCell(currentRow, 8).value = this.getMessageTypeName(msg.localType)
|
||||||
worksheet.getCell(currentRow, 9).value = enrichedContentValue
|
}
|
||||||
|
contentCell.value = enrichedContentValue
|
||||||
|
if (!quotedReplyDisplay) {
|
||||||
|
this.applyExcelLinkCardCell(contentCell, msg.content, msg.localType)
|
||||||
}
|
}
|
||||||
|
|
||||||
currentRow++
|
currentRow++
|
||||||
@@ -6747,7 +7069,7 @@ class ExportService {
|
|||||||
enrichedContentValue = this.buildQuotedReplyText(quotedReplyDisplay)
|
enrichedContentValue = this.buildQuotedReplyText(quotedReplyDisplay)
|
||||||
}
|
}
|
||||||
|
|
||||||
appendRow(useCompactColumns
|
const row = worksheet.addRow(useCompactColumns
|
||||||
? [
|
? [
|
||||||
i + 1,
|
i + 1,
|
||||||
this.formatTimestamp(msg.createTime),
|
this.formatTimestamp(msg.createTime),
|
||||||
@@ -6766,6 +7088,10 @@ class ExportService {
|
|||||||
this.getMessageTypeName(msg.localType),
|
this.getMessageTypeName(msg.localType),
|
||||||
enrichedContentValue
|
enrichedContentValue
|
||||||
])
|
])
|
||||||
|
if (!quotedReplyDisplay) {
|
||||||
|
this.applyExcelLinkCardCell(row.getCell(useCompactColumns ? 5 : 9), msg.content, msg.localType)
|
||||||
|
}
|
||||||
|
row.commit()
|
||||||
|
|
||||||
if ((i + 1) % 200 === 0) {
|
if ((i + 1) % 200 === 0) {
|
||||||
onProgress?.({
|
onProgress?.({
|
||||||
@@ -6943,7 +7269,8 @@ class ExportService {
|
|||||||
return (t === 3 && options.exportImages) ||
|
return (t === 3 && options.exportImages) ||
|
||||||
(t === 47 && options.exportEmojis) ||
|
(t === 47 && options.exportEmojis) ||
|
||||||
(t === 43 && options.exportVideos) ||
|
(t === 43 && options.exportVideos) ||
|
||||||
(t === 34 && options.exportVoices)
|
(t === 34 && options.exportVoices) ||
|
||||||
|
((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6')
|
||||||
})
|
})
|
||||||
: []
|
: []
|
||||||
|
|
||||||
@@ -6983,6 +7310,8 @@ class ExportService {
|
|||||||
exportVoices: options.exportVoices,
|
exportVoices: options.exportVoices,
|
||||||
exportVideos: options.exportVideos,
|
exportVideos: options.exportVideos,
|
||||||
exportEmojis: options.exportEmojis,
|
exportEmojis: options.exportEmojis,
|
||||||
|
exportFiles: options.exportFiles,
|
||||||
|
maxFileSizeMb: options.maxFileSizeMb,
|
||||||
exportVoiceAsText: options.exportVoiceAsText,
|
exportVoiceAsText: options.exportVoiceAsText,
|
||||||
includeVideoPoster: options.format === 'html',
|
includeVideoPoster: options.format === 'html',
|
||||||
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
|
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
|
||||||
@@ -7119,6 +7448,13 @@ class ExportService {
|
|||||||
enrichedContentValue = this.buildQuotedReplyText(quotedReplyDisplay)
|
enrichedContentValue = this.buildQuotedReplyText(quotedReplyDisplay)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const appendedLinkContent = quotedReplyDisplay
|
||||||
|
? null
|
||||||
|
: this.formatLinkCardExportText(msg.content, msg.localType, 'append-url')
|
||||||
|
if (appendedLinkContent) {
|
||||||
|
enrichedContentValue = appendedLinkContent
|
||||||
|
}
|
||||||
|
|
||||||
let senderRole: string
|
let senderRole: string
|
||||||
let senderWxid: string
|
let senderWxid: string
|
||||||
let senderNickname: string
|
let senderNickname: string
|
||||||
@@ -7313,7 +7649,8 @@ class ExportService {
|
|||||||
return (t === 3 && options.exportImages) ||
|
return (t === 3 && options.exportImages) ||
|
||||||
(t === 47 && options.exportEmojis) ||
|
(t === 47 && options.exportEmojis) ||
|
||||||
(t === 43 && options.exportVideos) ||
|
(t === 43 && options.exportVideos) ||
|
||||||
(t === 34 && options.exportVoices)
|
(t === 34 && options.exportVoices) ||
|
||||||
|
((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6')
|
||||||
})
|
})
|
||||||
: []
|
: []
|
||||||
|
|
||||||
@@ -7353,6 +7690,8 @@ class ExportService {
|
|||||||
exportVoices: options.exportVoices,
|
exportVoices: options.exportVoices,
|
||||||
exportVideos: options.exportVideos,
|
exportVideos: options.exportVideos,
|
||||||
exportEmojis: options.exportEmojis,
|
exportEmojis: options.exportEmojis,
|
||||||
|
exportFiles: options.exportFiles,
|
||||||
|
maxFileSizeMb: options.maxFileSizeMb,
|
||||||
exportVoiceAsText: options.exportVoiceAsText,
|
exportVoiceAsText: options.exportVoiceAsText,
|
||||||
includeVideoPoster: options.format === 'html',
|
includeVideoPoster: options.format === 'html',
|
||||||
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
|
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
|
||||||
@@ -7773,6 +8112,8 @@ class ExportService {
|
|||||||
exportImages: options.exportImages,
|
exportImages: options.exportImages,
|
||||||
exportVoices: options.exportVoices,
|
exportVoices: options.exportVoices,
|
||||||
exportEmojis: options.exportEmojis,
|
exportEmojis: options.exportEmojis,
|
||||||
|
exportFiles: options.exportFiles,
|
||||||
|
maxFileSizeMb: options.maxFileSizeMb,
|
||||||
exportVoiceAsText: options.exportVoiceAsText,
|
exportVoiceAsText: options.exportVoiceAsText,
|
||||||
includeVideoPoster: options.format === 'html',
|
includeVideoPoster: options.format === 'html',
|
||||||
includeVoiceWithTranscript: true,
|
includeVoiceWithTranscript: true,
|
||||||
@@ -8311,22 +8652,22 @@ class ExportService {
|
|||||||
|
|
||||||
const metric = aggregatedData?.[sessionId]
|
const metric = aggregatedData?.[sessionId]
|
||||||
const totalCount = Number.isFinite(metric?.totalMessages)
|
const totalCount = Number.isFinite(metric?.totalMessages)
|
||||||
? Math.max(0, Math.floor(metric!.totalMessages))
|
? Math.max(0, Math.floor(metric?.totalMessages ?? 0))
|
||||||
: 0
|
: 0
|
||||||
const voiceCount = Number.isFinite(metric?.voiceMessages)
|
const voiceCount = Number.isFinite(metric?.voiceMessages)
|
||||||
? Math.max(0, Math.floor(metric!.voiceMessages))
|
? Math.max(0, Math.floor(metric?.voiceMessages ?? 0))
|
||||||
: 0
|
: 0
|
||||||
const imageCount = Number.isFinite(metric?.imageMessages)
|
const imageCount = Number.isFinite(metric?.imageMessages)
|
||||||
? Math.max(0, Math.floor(metric!.imageMessages))
|
? Math.max(0, Math.floor(metric?.imageMessages ?? 0))
|
||||||
: 0
|
: 0
|
||||||
const videoCount = Number.isFinite(metric?.videoMessages)
|
const videoCount = Number.isFinite(metric?.videoMessages)
|
||||||
? Math.max(0, Math.floor(metric!.videoMessages))
|
? Math.max(0, Math.floor(metric?.videoMessages ?? 0))
|
||||||
: 0
|
: 0
|
||||||
const emojiCount = Number.isFinite(metric?.emojiMessages)
|
const emojiCount = Number.isFinite(metric?.emojiMessages)
|
||||||
? Math.max(0, Math.floor(metric!.emojiMessages))
|
? Math.max(0, Math.floor(metric?.emojiMessages ?? 0))
|
||||||
: 0
|
: 0
|
||||||
const lastTimestamp = Number.isFinite(metric?.lastTimestamp)
|
const lastTimestamp = Number.isFinite(metric?.lastTimestamp)
|
||||||
? Math.max(0, Math.floor(metric!.lastTimestamp))
|
? Math.max(0, Math.floor(metric?.lastTimestamp ?? 0))
|
||||||
: undefined
|
: undefined
|
||||||
const cachedCountRaw = Number(cachedVoiceCountMap[sessionId] || 0)
|
const cachedCountRaw = Number(cachedVoiceCountMap[sessionId] || 0)
|
||||||
const sessionCachedVoiceCount = Math.min(
|
const sessionCachedVoiceCount = Math.min(
|
||||||
|
|||||||
@@ -275,7 +275,7 @@ class GroupAnalyticsService {
|
|||||||
}
|
}
|
||||||
return this.buildTrustedGroupNicknameMap(Object.entries(dllResult.nicknames), candidates)
|
return this.buildTrustedGroupNicknameMap(Object.entries(dllResult.nicknames), candidates)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('getGroupNicknamesForRoom dll error:', e)
|
console.error('getGroupNicknamesForRoom service error:', e)
|
||||||
return new Map<string, string>()
|
return new Map<string, string>()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { ConfigService } from './config'
|
|||||||
import { videoService } from './videoService'
|
import { videoService } from './videoService'
|
||||||
import { imageDecryptService } from './imageDecryptService'
|
import { imageDecryptService } from './imageDecryptService'
|
||||||
import { groupAnalyticsService } from './groupAnalyticsService'
|
import { groupAnalyticsService } from './groupAnalyticsService'
|
||||||
|
import { snsService } from './snsService'
|
||||||
|
|
||||||
// ChatLab 格式定义
|
// ChatLab 格式定义
|
||||||
interface ChatLabHeader {
|
interface ChatLabHeader {
|
||||||
@@ -308,7 +309,7 @@ class HttpService {
|
|||||||
*/
|
*/
|
||||||
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||||
res.setHeader('Access-Control-Allow-Origin', '*')
|
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS')
|
||||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
|
||||||
|
|
||||||
if (req.method === 'OPTIONS') {
|
if (req.method === 'OPTIONS') {
|
||||||
@@ -348,6 +349,33 @@ class HttpService {
|
|||||||
await this.handleContacts(url, res)
|
await this.handleContacts(url, res)
|
||||||
} else if (pathname === '/api/v1/group-members') {
|
} else if (pathname === '/api/v1/group-members') {
|
||||||
await this.handleGroupMembers(url, res)
|
await this.handleGroupMembers(url, res)
|
||||||
|
} else if (pathname === '/api/v1/sns/timeline') {
|
||||||
|
if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET')
|
||||||
|
await this.handleSnsTimeline(url, res)
|
||||||
|
} else if (pathname === '/api/v1/sns/usernames') {
|
||||||
|
if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET')
|
||||||
|
await this.handleSnsUsernames(res)
|
||||||
|
} else if (pathname === '/api/v1/sns/export/stats') {
|
||||||
|
if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET')
|
||||||
|
await this.handleSnsExportStats(url, res)
|
||||||
|
} else if (pathname === '/api/v1/sns/media/proxy') {
|
||||||
|
if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET')
|
||||||
|
await this.handleSnsMediaProxy(url, res)
|
||||||
|
} else if (pathname === '/api/v1/sns/export') {
|
||||||
|
if (req.method !== 'POST') return this.sendMethodNotAllowed(res, 'POST')
|
||||||
|
await this.handleSnsExport(url, res)
|
||||||
|
} else if (pathname === '/api/v1/sns/block-delete/status') {
|
||||||
|
if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET')
|
||||||
|
await this.handleSnsBlockDeleteStatus(res)
|
||||||
|
} else if (pathname === '/api/v1/sns/block-delete/install') {
|
||||||
|
if (req.method !== 'POST') return this.sendMethodNotAllowed(res, 'POST')
|
||||||
|
await this.handleSnsBlockDeleteInstall(res)
|
||||||
|
} else if (pathname === '/api/v1/sns/block-delete/uninstall') {
|
||||||
|
if (req.method !== 'POST') return this.sendMethodNotAllowed(res, 'POST')
|
||||||
|
await this.handleSnsBlockDeleteUninstall(res)
|
||||||
|
} else if (pathname.startsWith('/api/v1/sns/post/')) {
|
||||||
|
if (req.method !== 'DELETE') return this.sendMethodNotAllowed(res, 'DELETE')
|
||||||
|
await this.handleSnsDeletePost(pathname, res)
|
||||||
} else if (pathname.startsWith('/api/v1/media/')) {
|
} else if (pathname.startsWith('/api/v1/media/')) {
|
||||||
this.handleMediaRequest(pathname, res)
|
this.handleMediaRequest(pathname, res)
|
||||||
} else {
|
} else {
|
||||||
@@ -559,6 +587,15 @@ class HttpService {
|
|||||||
return defaultValue
|
return defaultValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private parseStringListParam(value: string | null): string[] | undefined {
|
||||||
|
if (!value) return undefined
|
||||||
|
const values = value
|
||||||
|
.split(',')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
return values.length > 0 ? Array.from(new Set(values)) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
private parseMediaOptions(url: URL): ApiMediaOptions {
|
private parseMediaOptions(url: URL): ApiMediaOptions {
|
||||||
const mediaEnabled = this.parseBooleanParam(url, ['media', 'meiti'], false)
|
const mediaEnabled = this.parseBooleanParam(url, ['media', 'meiti'], false)
|
||||||
if (!mediaEnabled) {
|
if (!mediaEnabled) {
|
||||||
@@ -790,6 +827,313 @@ class HttpService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handleSnsTimeline(url: URL, res: http.ServerResponse): Promise<void> {
|
||||||
|
const limit = this.parseIntParam(url.searchParams.get('limit'), 20, 1, 200)
|
||||||
|
const offset = this.parseIntParam(url.searchParams.get('offset'), 0, 0, Number.MAX_SAFE_INTEGER)
|
||||||
|
const usernames = this.parseStringListParam(url.searchParams.get('usernames'))
|
||||||
|
const keyword = (url.searchParams.get('keyword') || '').trim() || undefined
|
||||||
|
const resolveMedia = this.parseBooleanParam(url, ['media', 'resolveMedia', 'meiti'], true)
|
||||||
|
const inlineMedia = resolveMedia && this.parseBooleanParam(url, ['inline'], false)
|
||||||
|
const replaceMedia = resolveMedia && this.parseBooleanParam(url, ['replace'], true)
|
||||||
|
const startTimeRaw = this.parseTimeParam(url.searchParams.get('start'))
|
||||||
|
const endTimeRaw = this.parseTimeParam(url.searchParams.get('end'), true)
|
||||||
|
const startTime = startTimeRaw > 0 ? startTimeRaw : undefined
|
||||||
|
const endTime = endTimeRaw > 0 ? endTimeRaw : undefined
|
||||||
|
|
||||||
|
const result = await snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime)
|
||||||
|
if (!result.success) {
|
||||||
|
this.sendError(res, 500, result.error || 'Failed to get sns timeline')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let timeline = result.timeline || []
|
||||||
|
if (resolveMedia && timeline.length > 0) {
|
||||||
|
timeline = await this.enrichSnsTimelineMedia(timeline, inlineMedia, replaceMedia)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sendJson(res, {
|
||||||
|
success: true,
|
||||||
|
count: timeline.length,
|
||||||
|
timeline
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleSnsUsernames(res: http.ServerResponse): Promise<void> {
|
||||||
|
const result = await snsService.getSnsUsernames()
|
||||||
|
if (!result.success) {
|
||||||
|
this.sendError(res, 500, result.error || 'Failed to get sns usernames')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.sendJson(res, {
|
||||||
|
success: true,
|
||||||
|
usernames: result.usernames || []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleSnsExportStats(url: URL, res: http.ServerResponse): Promise<void> {
|
||||||
|
const fast = this.parseBooleanParam(url, ['fast'], false)
|
||||||
|
const result = fast
|
||||||
|
? await snsService.getExportStatsFast()
|
||||||
|
: await snsService.getExportStats()
|
||||||
|
if (!result.success) {
|
||||||
|
this.sendError(res, 500, result.error || 'Failed to get sns export stats')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.sendJson(res, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleSnsMediaProxy(url: URL, res: http.ServerResponse): Promise<void> {
|
||||||
|
const mediaUrl = (url.searchParams.get('url') || '').trim()
|
||||||
|
if (!mediaUrl) {
|
||||||
|
this.sendError(res, 400, 'Missing required parameter: url')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = this.toSnsMediaKey(url.searchParams.get('key'))
|
||||||
|
const result = await snsService.downloadImage(mediaUrl, key)
|
||||||
|
if (!result.success) {
|
||||||
|
this.sendError(res, 502, result.error || 'Failed to proxy sns media')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.data) {
|
||||||
|
res.setHeader('Content-Type', result.contentType || 'application/octet-stream')
|
||||||
|
res.setHeader('Content-Length', result.data.length)
|
||||||
|
res.writeHead(200)
|
||||||
|
res.end(result.data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.cachePath && fs.existsSync(result.cachePath)) {
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(result.cachePath)
|
||||||
|
res.setHeader('Content-Type', result.contentType || 'application/octet-stream')
|
||||||
|
res.setHeader('Content-Length', stat.size)
|
||||||
|
res.writeHead(200)
|
||||||
|
|
||||||
|
const stream = fs.createReadStream(result.cachePath)
|
||||||
|
stream.on('error', () => {
|
||||||
|
if (!res.headersSent) {
|
||||||
|
this.sendError(res, 500, 'Failed to read proxied sns media')
|
||||||
|
} else {
|
||||||
|
try { res.destroy() } catch {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
stream.pipe(res)
|
||||||
|
return
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[HttpService] Failed to stream sns media cache:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sendError(res, 502, result.error || 'Failed to proxy sns media')
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleSnsExport(url: URL, res: http.ServerResponse): Promise<void> {
|
||||||
|
const outputDir = String(url.searchParams.get('outputDir') || '').trim()
|
||||||
|
if (!outputDir) {
|
||||||
|
this.sendError(res, 400, 'Missing required field: outputDir')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawFormat = String(url.searchParams.get('format') || 'json').trim().toLowerCase()
|
||||||
|
const format = rawFormat === 'arkme-json' ? 'arkmejson' : rawFormat
|
||||||
|
if (!['json', 'html', 'arkmejson'].includes(format)) {
|
||||||
|
this.sendError(res, 400, 'Invalid format, supported: json/html/arkmejson')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const usernames = this.parseStringListParam(url.searchParams.get('usernames'))
|
||||||
|
const keyword = String(url.searchParams.get('keyword') || '').trim() || undefined
|
||||||
|
const startTimeRaw = this.parseTimeParam(url.searchParams.get('start'))
|
||||||
|
const endTimeRaw = this.parseTimeParam(url.searchParams.get('end'), true)
|
||||||
|
|
||||||
|
const options: {
|
||||||
|
outputDir: string
|
||||||
|
format: 'json' | 'html' | 'arkmejson'
|
||||||
|
usernames?: string[]
|
||||||
|
keyword?: string
|
||||||
|
exportMedia?: boolean
|
||||||
|
exportImages?: boolean
|
||||||
|
exportLivePhotos?: boolean
|
||||||
|
exportVideos?: boolean
|
||||||
|
startTime?: number
|
||||||
|
endTime?: number
|
||||||
|
} = {
|
||||||
|
outputDir,
|
||||||
|
format: format as 'json' | 'html' | 'arkmejson',
|
||||||
|
usernames,
|
||||||
|
keyword,
|
||||||
|
exportMedia: this.parseBooleanParam(url, ['exportMedia'], false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.searchParams.has('exportImages')) options.exportImages = this.parseBooleanParam(url, ['exportImages'], false)
|
||||||
|
if (url.searchParams.has('exportLivePhotos')) options.exportLivePhotos = this.parseBooleanParam(url, ['exportLivePhotos'], false)
|
||||||
|
if (url.searchParams.has('exportVideos')) options.exportVideos = this.parseBooleanParam(url, ['exportVideos'], false)
|
||||||
|
if (startTimeRaw > 0) options.startTime = startTimeRaw
|
||||||
|
if (endTimeRaw > 0) options.endTime = endTimeRaw
|
||||||
|
|
||||||
|
const result = await snsService.exportTimeline(options)
|
||||||
|
if (!result.success) {
|
||||||
|
this.sendError(res, 500, result.error || 'Failed to export sns timeline')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.sendJson(res, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleSnsBlockDeleteStatus(res: http.ServerResponse): Promise<void> {
|
||||||
|
const result = await snsService.checkSnsBlockDeleteTrigger()
|
||||||
|
if (!result.success) {
|
||||||
|
this.sendError(res, 500, result.error || 'Failed to check sns block-delete status')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.sendJson(res, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleSnsBlockDeleteInstall(res: http.ServerResponse): Promise<void> {
|
||||||
|
const result = await snsService.installSnsBlockDeleteTrigger()
|
||||||
|
if (!result.success) {
|
||||||
|
this.sendError(res, 500, result.error || 'Failed to install sns block-delete trigger')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.sendJson(res, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleSnsBlockDeleteUninstall(res: http.ServerResponse): Promise<void> {
|
||||||
|
const result = await snsService.uninstallSnsBlockDeleteTrigger()
|
||||||
|
if (!result.success) {
|
||||||
|
this.sendError(res, 500, result.error || 'Failed to uninstall sns block-delete trigger')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.sendJson(res, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleSnsDeletePost(pathname: string, res: http.ServerResponse): Promise<void> {
|
||||||
|
const postId = decodeURIComponent(pathname.replace('/api/v1/sns/post/', '')).trim()
|
||||||
|
if (!postId) {
|
||||||
|
this.sendError(res, 400, 'Missing required path parameter: postId')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await snsService.deleteSnsPost(postId)
|
||||||
|
if (!result.success) {
|
||||||
|
this.sendError(res, 500, result.error || 'Failed to delete sns post')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.sendJson(res, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
private toSnsMediaKey(value: unknown): string | number | undefined {
|
||||||
|
if (value == null) return undefined
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) return value
|
||||||
|
const text = String(value).trim()
|
||||||
|
if (!text) return undefined
|
||||||
|
if (/^-?\d+$/.test(text)) return Number(text)
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSnsMediaProxyUrl(rawUrl: string, key?: string | number): string | undefined {
|
||||||
|
const target = String(rawUrl || '').trim()
|
||||||
|
if (!target) return undefined
|
||||||
|
const params = new URLSearchParams({ url: target })
|
||||||
|
if (key !== undefined) params.set('key', String(key))
|
||||||
|
return `http://${this.host}:${this.port}/api/v1/sns/media/proxy?${params.toString()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveSnsMediaUrl(
|
||||||
|
rawUrl: string,
|
||||||
|
key: string | number | undefined,
|
||||||
|
inline: boolean
|
||||||
|
): Promise<{ resolvedUrl?: string; proxyUrl?: string }> {
|
||||||
|
const proxyUrl = this.buildSnsMediaProxyUrl(rawUrl, key)
|
||||||
|
if (!proxyUrl) return {}
|
||||||
|
if (!inline) return { resolvedUrl: proxyUrl, proxyUrl }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resolved = await snsService.proxyImage(rawUrl, key)
|
||||||
|
if (resolved.success && resolved.dataUrl) {
|
||||||
|
return { resolvedUrl: resolved.dataUrl, proxyUrl }
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[HttpService] resolveSnsMediaUrl inline failed:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { resolvedUrl: proxyUrl, proxyUrl }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async enrichSnsTimelineMedia(posts: any[], inline: boolean, replace: boolean): Promise<any[]> {
|
||||||
|
return Promise.all(
|
||||||
|
(posts || []).map(async (post) => {
|
||||||
|
const mediaList = Array.isArray(post?.media) ? post.media : []
|
||||||
|
if (mediaList.length === 0) return post
|
||||||
|
|
||||||
|
const nextMedia = await Promise.all(
|
||||||
|
mediaList.map(async (media: any) => {
|
||||||
|
const rawUrl = typeof media?.url === 'string' ? media.url : ''
|
||||||
|
const rawThumb = typeof media?.thumb === 'string' ? media.thumb : ''
|
||||||
|
const mediaKey = this.toSnsMediaKey(media?.key)
|
||||||
|
|
||||||
|
const [urlResolved, thumbResolved] = await Promise.all([
|
||||||
|
this.resolveSnsMediaUrl(rawUrl, mediaKey, inline),
|
||||||
|
this.resolveSnsMediaUrl(rawThumb, mediaKey, inline)
|
||||||
|
])
|
||||||
|
|
||||||
|
const nextItem: any = {
|
||||||
|
...media,
|
||||||
|
rawUrl,
|
||||||
|
rawThumb,
|
||||||
|
resolvedUrl: urlResolved.resolvedUrl,
|
||||||
|
resolvedThumbUrl: thumbResolved.resolvedUrl,
|
||||||
|
proxyUrl: urlResolved.proxyUrl,
|
||||||
|
proxyThumbUrl: thumbResolved.proxyUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
if (replace) {
|
||||||
|
nextItem.url = urlResolved.resolvedUrl || rawUrl
|
||||||
|
nextItem.thumb = thumbResolved.resolvedUrl || rawThumb
|
||||||
|
}
|
||||||
|
|
||||||
|
if (media?.livePhoto && typeof media.livePhoto === 'object') {
|
||||||
|
const livePhoto = media.livePhoto
|
||||||
|
const rawLiveUrl = typeof livePhoto.url === 'string' ? livePhoto.url : ''
|
||||||
|
const rawLiveThumb = typeof livePhoto.thumb === 'string' ? livePhoto.thumb : ''
|
||||||
|
const liveKey = this.toSnsMediaKey(livePhoto.key ?? mediaKey)
|
||||||
|
|
||||||
|
const [liveUrlResolved, liveThumbResolved] = await Promise.all([
|
||||||
|
this.resolveSnsMediaUrl(rawLiveUrl, liveKey, inline),
|
||||||
|
this.resolveSnsMediaUrl(rawLiveThumb, liveKey, inline)
|
||||||
|
])
|
||||||
|
|
||||||
|
const nextLive: any = {
|
||||||
|
...livePhoto,
|
||||||
|
rawUrl: rawLiveUrl,
|
||||||
|
rawThumb: rawLiveThumb,
|
||||||
|
resolvedUrl: liveUrlResolved.resolvedUrl,
|
||||||
|
resolvedThumbUrl: liveThumbResolved.resolvedUrl,
|
||||||
|
proxyUrl: liveUrlResolved.proxyUrl,
|
||||||
|
proxyThumbUrl: liveThumbResolved.proxyUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
if (replace) {
|
||||||
|
nextLive.url = liveUrlResolved.resolvedUrl || rawLiveUrl
|
||||||
|
nextLive.thumb = liveThumbResolved.resolvedUrl || rawLiveThumb
|
||||||
|
}
|
||||||
|
|
||||||
|
nextItem.livePhoto = nextLive
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextItem
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...post,
|
||||||
|
media: nextMedia
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private getApiMediaExportPath(): string {
|
private getApiMediaExportPath(): string {
|
||||||
return path.join(this.configService.getCacheBasePath(), 'api-media')
|
return path.join(this.configService.getCacheBasePath(), 'api-media')
|
||||||
}
|
}
|
||||||
@@ -1451,6 +1795,11 @@ class HttpService {
|
|||||||
res.end(JSON.stringify(data, null, 2))
|
res.end(JSON.stringify(data, null, 2))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sendMethodNotAllowed(res: http.ServerResponse, allow: string): void {
|
||||||
|
res.setHeader('Allow', allow)
|
||||||
|
this.sendError(res, 405, `Method Not Allowed. Allowed: ${allow}`)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送错误响应
|
* 发送错误响应
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -684,10 +684,7 @@ export class KeyService {
|
|||||||
return { success: false, error: '获取密钥超时', logs }
|
return { success: false, error: '获取密钥超时', logs }
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Image Key (通过 DLL 从缓存目录获取 code,用前端 wxid 计算密钥) ---
|
|
||||||
|
|
||||||
private cleanWxid(wxid: string): string {
|
private cleanWxid(wxid: string): string {
|
||||||
// 截断到第二个下划线: wxid_g4pshorcc0r529_da6c → wxid_g4pshorcc0r529
|
|
||||||
const first = wxid.indexOf('_')
|
const first = wxid.indexOf('_')
|
||||||
if (first === -1) return wxid
|
if (first === -1) return wxid
|
||||||
const second = wxid.indexOf('_', first + 1)
|
const second = wxid.indexOf('_', first + 1)
|
||||||
|
|||||||
@@ -537,6 +537,32 @@ class SnsService {
|
|||||||
return raw.trim()
|
return raw.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async collectSnsUsernamesFromTimeline(maxRounds: number = 2000): Promise<string[]> {
|
||||||
|
const pageSize = 500
|
||||||
|
const uniqueUsers = new Set<string>()
|
||||||
|
let offset = 0
|
||||||
|
|
||||||
|
for (let round = 0; round < maxRounds; round++) {
|
||||||
|
const result = await wcdbService.getSnsTimeline(pageSize, offset, undefined, undefined, 0, 0)
|
||||||
|
if (!result.success || !Array.isArray(result.timeline)) {
|
||||||
|
throw new Error(result.error || '获取朋友圈发布者失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = result.timeline
|
||||||
|
if (rows.length === 0) break
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const username = this.pickTimelineUsername(row)
|
||||||
|
if (username) uniqueUsers.add(username)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows.length < pageSize) break
|
||||||
|
offset += rows.length
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(uniqueUsers)
|
||||||
|
}
|
||||||
|
|
||||||
private async getExportStatsFromTimeline(myWxid?: string): Promise<{ totalPosts: number; totalFriends: number; myPosts: number | null }> {
|
private async getExportStatsFromTimeline(myWxid?: string): Promise<{ totalPosts: number; totalFriends: number; myPosts: number | null }> {
|
||||||
const pageSize = 500
|
const pageSize = 500
|
||||||
const uniqueUsers = new Set<string>()
|
const uniqueUsers = new Set<string>()
|
||||||
@@ -794,7 +820,22 @@ class SnsService {
|
|||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return { success: false, error: result.error || '获取朋友圈联系人失败' }
|
return { success: false, error: result.error || '获取朋友圈联系人失败' }
|
||||||
}
|
}
|
||||||
return { success: true, usernames: result.usernames || [] }
|
const directUsernames = Array.isArray(result.usernames) ? result.usernames : []
|
||||||
|
if (directUsernames.length > 0) {
|
||||||
|
return { success: true, usernames: directUsernames }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退:通过 timeline 分页拉取收集用户名,兼容底层接口暂时返回空数组的场景。
|
||||||
|
try {
|
||||||
|
const timelineUsers = await this.collectSnsUsernamesFromTimeline()
|
||||||
|
if (timelineUsers.length > 0) {
|
||||||
|
return { success: true, usernames: timelineUsers }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 忽略回退错误,保持与原行为一致返回空数组
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, usernames: directUsernames }
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getExportStatsFromTableCount(myWxid?: string): Promise<{ totalPosts: number; totalFriends: number; myPosts: number | null }> {
|
private async getExportStatsFromTableCount(myWxid?: string): Promise<{ totalPosts: number; totalFriends: number; myPosts: number | null }> {
|
||||||
@@ -1021,14 +1062,14 @@ class SnsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 补全 DLL 返回的评论中缺失的 refNickname
|
* 补全数据服务返回的评论中缺失的 refNickname
|
||||||
* DLL 返回的 refCommentId 是被回复评论的 cmtid
|
*数据服务返回的 refCommentId 是被回复评论的 cmtid
|
||||||
* 评论按 cmtid 从小到大排列,cmtid 从 1 开始递增
|
* 评论按 cmtid 从小到大排列,cmtid 从 1 开始递增
|
||||||
*/
|
*/
|
||||||
private fixCommentRefs(comments: any[]): any[] {
|
private fixCommentRefs(comments: any[]): any[] {
|
||||||
if (!comments || comments.length === 0) return []
|
if (!comments || comments.length === 0) return []
|
||||||
|
|
||||||
// DLL 现在返回完整的评论数据(含 emojis、refNickname)
|
//数据服务现在返回完整的评论数据(含 emojis、refNickname)
|
||||||
// 此处做最终的格式化和兜底补全
|
// 此处做最终的格式化和兜底补全
|
||||||
const idToNickname = new Map<string, string>()
|
const idToNickname = new Map<string, string>()
|
||||||
comments.forEach((c, idx) => {
|
comments.forEach((c, idx) => {
|
||||||
@@ -1099,14 +1140,14 @@ class SnsService {
|
|||||||
} : undefined
|
} : undefined
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// DLL 已返回完整评论数据(含 emojis、refNickname)
|
//数据服务已返回完整评论数据(含 emojis、refNickname)
|
||||||
// 如果 DLL 评论缺少表情包信息,回退到从 rawXml 重新解析
|
// 如果数据服务评论缺少表情包信息,回退到从 rawXml 重新解析
|
||||||
const dllComments: any[] = post.comments || []
|
const dllComments: any[] = post.comments || []
|
||||||
const hasEmojisInDll = dllComments.some((c: any) => c.emojis && c.emojis.length > 0)
|
const hasEmojisInDll = dllComments.some((c: any) => c.emojis && c.emojis.length > 0)
|
||||||
|
|
||||||
let finalComments: any[]
|
let finalComments: any[]
|
||||||
if (dllComments.length > 0 && (hasEmojisInDll || !rawXml)) {
|
if (dllComments.length > 0 && (hasEmojisInDll || !rawXml)) {
|
||||||
// DLL 数据完整,直接使用
|
//数据服务数据完整,直接使用
|
||||||
finalComments = this.fixCommentRefs(dllComments)
|
finalComments = this.fixCommentRefs(dllComments)
|
||||||
} else if (rawXml) {
|
} else if (rawXml) {
|
||||||
// 回退:从 rawXml 重新解析(兼容旧版 DLL)
|
// 回退:从 rawXml 重新解析(兼容旧版 DLL)
|
||||||
@@ -1199,7 +1240,7 @@ class SnsService {
|
|||||||
return { success: false, error: result.error }
|
return { success: false, error: result.error }
|
||||||
}
|
}
|
||||||
|
|
||||||
async downloadImage(url: string, key?: string | number): Promise<{ success: boolean; data?: Buffer; contentType?: string; error?: string }> {
|
async downloadImage(url: string, key?: string | number): Promise<{ success: boolean; data?: Buffer; contentType?: string; cachePath?: string; error?: string }> {
|
||||||
return this.fetchAndDecryptImage(url, key)
|
return this.fetchAndDecryptImage(url, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export class VoiceTranscribeService {
|
|||||||
console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`)
|
console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`)
|
||||||
}
|
}
|
||||||
} else if (process.platform === 'win32') {
|
} else if (process.platform === 'win32') {
|
||||||
// Windows: 把 sherpa-onnx DLL 所在目录加到 PATH,否则 native module 找不到依赖
|
// Windows: 把 sherpa-onnx 所在目录加到 PATH,否则 native module 找不到依赖
|
||||||
const existing = env['PATH'] || ''
|
const existing = env['PATH'] || ''
|
||||||
const merged = [...candidates, ...existing.split(';').filter(Boolean)]
|
const merged = [...candidates, ...existing.split(';').filter(Boolean)]
|
||||||
env['PATH'] = Array.from(new Set(merged)).join(';')
|
env['PATH'] = Array.from(new Set(merged)).join(';')
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { join, dirname, basename } from 'path'
|
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'
|
||||||
|
|
||||||
// DLL 初始化错误信息,用于帮助用户诊断问题
|
//数据服务初始化错误信息,用于帮助用户诊断问题
|
||||||
let lastDllInitError: string | null = null
|
let lastDllInitError: string | null = null
|
||||||
|
|
||||||
export function getLastDllInitError(): string | null {
|
export function getLastDllInitError(): string | null {
|
||||||
@@ -92,6 +92,9 @@ export class WcdbCore {
|
|||||||
private wcdbResolveImageHardlinkBatch: any = null
|
private wcdbResolveImageHardlinkBatch: any = null
|
||||||
private wcdbResolveVideoHardlinkMd5: any = null
|
private wcdbResolveVideoHardlinkMd5: any = null
|
||||||
private wcdbResolveVideoHardlinkMd5Batch: any = null
|
private wcdbResolveVideoHardlinkMd5Batch: any = null
|
||||||
|
private wcdbInstallMessageAntiRevokeTrigger: any = null
|
||||||
|
private wcdbUninstallMessageAntiRevokeTrigger: any = null
|
||||||
|
private wcdbCheckMessageAntiRevokeTrigger: any = null
|
||||||
private wcdbInstallSnsBlockDeleteTrigger: any = null
|
private wcdbInstallSnsBlockDeleteTrigger: any = null
|
||||||
private wcdbUninstallSnsBlockDeleteTrigger: any = null
|
private wcdbUninstallSnsBlockDeleteTrigger: any = null
|
||||||
private wcdbCheckSnsBlockDeleteTrigger: any = null
|
private wcdbCheckSnsBlockDeleteTrigger: any = null
|
||||||
@@ -154,7 +157,7 @@ export class WcdbCore {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从 DLL 获取动态管道名(含 PID)
|
// 从数据服务获取动态管道名(含 PID)
|
||||||
let pipePath = '\\\\.\\pipe\\weflow_monitor'
|
let pipePath = '\\\\.\\pipe\\weflow_monitor'
|
||||||
if (this.wcdbGetMonitorPipeName) {
|
if (this.wcdbGetMonitorPipeName) {
|
||||||
try {
|
try {
|
||||||
@@ -163,7 +166,7 @@ export class WcdbCore {
|
|||||||
pipePath = this.koffi.decode(namePtr[0], 'char', -1)
|
pipePath = this.koffi.decode(namePtr[0], 'char', -1)
|
||||||
this.wcdbFreeString(namePtr[0])
|
this.wcdbFreeString(namePtr[0])
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch { }
|
||||||
}
|
}
|
||||||
this.connectMonitorPipe(pipePath)
|
this.connectMonitorPipe(pipePath)
|
||||||
return true
|
return true
|
||||||
@@ -181,7 +184,7 @@ export class WcdbCore {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!this.monitorCallback) return
|
if (!this.monitorCallback) return
|
||||||
|
|
||||||
this.monitorPipeClient = net.createConnection(this.monitorPipePath, () => {})
|
this.monitorPipeClient = net.createConnection(this.monitorPipePath, () => { })
|
||||||
|
|
||||||
let buffer = ''
|
let buffer = ''
|
||||||
this.monitorPipeClient.on('data', (data: Buffer) => {
|
this.monitorPipeClient.on('data', (data: Buffer) => {
|
||||||
@@ -273,7 +276,7 @@ export class WcdbCore {
|
|||||||
const isArm64 = process.arch === 'arm64'
|
const isArm64 = process.arch === 'arm64'
|
||||||
const libName = isMac ? 'libwcdb_api.dylib' : isLinux ? 'libwcdb_api.so' : 'wcdb_api.dll'
|
const libName = isMac ? 'libwcdb_api.dylib' : isLinux ? 'libwcdb_api.so' : 'wcdb_api.dll'
|
||||||
const subDir = isMac ? 'macos' : isLinux ? 'linux' : (isArm64 ? 'arm64' : '')
|
const subDir = isMac ? 'macos' : isLinux ? 'linux' : (isArm64 ? 'arm64' : '')
|
||||||
|
|
||||||
const envDllPath = process.env.WCDB_DLL_PATH
|
const envDllPath = process.env.WCDB_DLL_PATH
|
||||||
if (envDllPath && envDllPath.length > 0) {
|
if (envDllPath && envDllPath.length > 0) {
|
||||||
return envDllPath
|
return envDllPath
|
||||||
@@ -313,7 +316,7 @@ export class WcdbCore {
|
|||||||
'-2302': 'WCDB 初始化异常,请重试',
|
'-2302': 'WCDB 初始化异常,请重试',
|
||||||
'-2303': 'WCDB 未能成功初始化',
|
'-2303': 'WCDB 未能成功初始化',
|
||||||
}
|
}
|
||||||
const msg = messages[String(code) as keyof typeof messages]
|
const msg = messages[String(code) as unknown as keyof typeof messages]
|
||||||
return msg ? `${msg} (错误码: ${code})` : `操作失败,错误码: ${code}`
|
return msg ? `${msg} (错误码: ${code})` : `操作失败,错误码: ${code}`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -635,15 +638,15 @@ export class WcdbCore {
|
|||||||
this.writeLog(`[bootstrap] initialize platform=${process.platform} dllPath=${dllPath} resourcesPath=${this.resourcesPath || ''} userDataPath=${this.userDataPath || ''}`, true)
|
this.writeLog(`[bootstrap] initialize platform=${process.platform} dllPath=${dllPath} resourcesPath=${this.resourcesPath || ''} userDataPath=${this.userDataPath || ''}`, true)
|
||||||
|
|
||||||
if (!existsSync(dllPath)) {
|
if (!existsSync(dllPath)) {
|
||||||
console.error('WCDB DLL 不存在:', dllPath)
|
console.error('WCDB数据服务不存在:', dllPath)
|
||||||
this.writeLog(`[bootstrap] initialize failed: dll not found path=${dllPath}`, true)
|
this.writeLog(`[bootstrap] initialize failed:数据服务not found path=${dllPath}`, true)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const dllDir = dirname(dllPath)
|
const dllDir = dirname(dllPath)
|
||||||
const isMac = process.platform === 'darwin'
|
const isMac = process.platform === 'darwin'
|
||||||
const isLinux = process.platform === 'linux'
|
const isLinux = process.platform === 'linux'
|
||||||
|
|
||||||
// 预加载依赖库
|
// 预加载依赖库
|
||||||
if (isMac) {
|
if (isMac) {
|
||||||
const wcdbCorePath = join(dllDir, 'libWCDB.dylib')
|
const wcdbCorePath = join(dllDir, 'libWCDB.dylib')
|
||||||
@@ -691,7 +694,7 @@ export class WcdbCore {
|
|||||||
|
|
||||||
// 尝试多个可能的资源路径
|
// 尝试多个可能的资源路径
|
||||||
const resourcePaths = [
|
const resourcePaths = [
|
||||||
dllDir, // DLL 所在目录
|
dllDir, //数据服务所在目录
|
||||||
dirname(dllDir), // 上级目录
|
dirname(dllDir), // 上级目录
|
||||||
process.resourcesPath, // 打包后 Contents/Resources
|
process.resourcesPath, // 打包后 Contents/Resources
|
||||||
process.resourcesPath ? join(process.resourcesPath as string, 'resources') : null, // Contents/Resources/resources
|
process.resourcesPath ? join(process.resourcesPath as string, 'resources') : null, // Contents/Resources/resources
|
||||||
@@ -1077,6 +1080,27 @@ export class WcdbCore {
|
|||||||
this.wcdbResolveVideoHardlinkMd5Batch = null
|
this.wcdbResolveVideoHardlinkMd5Batch = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wcdb_status wcdb_install_message_anti_revoke_trigger(wcdb_handle handle, const char* session_id, char** out_error)
|
||||||
|
try {
|
||||||
|
this.wcdbInstallMessageAntiRevokeTrigger = this.lib.func('int32 wcdb_install_message_anti_revoke_trigger(int64 handle, const char* sessionId, _Out_ void** outError)')
|
||||||
|
} catch {
|
||||||
|
this.wcdbInstallMessageAntiRevokeTrigger = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// wcdb_status wcdb_uninstall_message_anti_revoke_trigger(wcdb_handle handle, const char* session_id, char** out_error)
|
||||||
|
try {
|
||||||
|
this.wcdbUninstallMessageAntiRevokeTrigger = this.lib.func('int32 wcdb_uninstall_message_anti_revoke_trigger(int64 handle, const char* sessionId, _Out_ void** outError)')
|
||||||
|
} catch {
|
||||||
|
this.wcdbUninstallMessageAntiRevokeTrigger = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// wcdb_status wcdb_check_message_anti_revoke_trigger(wcdb_handle handle, const char* session_id, int32_t* out_installed)
|
||||||
|
try {
|
||||||
|
this.wcdbCheckMessageAntiRevokeTrigger = this.lib.func('int32 wcdb_check_message_anti_revoke_trigger(int64 handle, const char* sessionId, _Out_ int32* outInstalled)')
|
||||||
|
} catch {
|
||||||
|
this.wcdbCheckMessageAntiRevokeTrigger = null
|
||||||
|
}
|
||||||
|
|
||||||
// wcdb_status wcdb_install_sns_block_delete_trigger(wcdb_handle handle, char** out_error)
|
// wcdb_status wcdb_install_sns_block_delete_trigger(wcdb_handle handle, char** out_error)
|
||||||
try {
|
try {
|
||||||
this.wcdbInstallSnsBlockDeleteTrigger = this.lib.func('int32 wcdb_install_sns_block_delete_trigger(int64 handle, _Out_ void** outError)')
|
this.wcdbInstallSnsBlockDeleteTrigger = this.lib.func('int32 wcdb_install_sns_block_delete_trigger(int64 handle, _Out_ void** outError)')
|
||||||
@@ -1256,7 +1280,7 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 打印 DLL 内部日志(仅在出错时调用)
|
* 打印数据服务内部日志(仅在出错时调用)
|
||||||
*/
|
*/
|
||||||
private async printLogs(force = false): Promise<void> {
|
private async printLogs(force = false): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -1337,12 +1361,12 @@ export class WcdbCore {
|
|||||||
const raw = String(jsonStr || '')
|
const raw = String(jsonStr || '')
|
||||||
if (!raw) return []
|
if (!raw) return []
|
||||||
// 热路径优化:仅在检测到 16+ 位整数字段时才进行字符串包裹,避免每批次多轮全量 replace。
|
// 热路径优化:仅在检测到 16+ 位整数字段时才进行字符串包裹,避免每批次多轮全量 replace。
|
||||||
const needsInt64Normalize = /"(?:server_id|serverId|ServerId|msg_server_id|msgServerId|MsgServerId)"\s*:\s*-?\d{16,}/.test(raw)
|
const needsInt64Normalize = /"server_id"\s*:\s*-?\d{16,}/.test(raw)
|
||||||
if (!needsInt64Normalize) {
|
if (!needsInt64Normalize) {
|
||||||
return JSON.parse(raw)
|
return JSON.parse(raw)
|
||||||
}
|
}
|
||||||
const normalized = raw.replace(
|
const normalized = raw.replace(
|
||||||
/("(?:server_id|serverId|ServerId|msg_server_id|msgServerId|MsgServerId)"\s*:\s*)(-?\d{16,})/g,
|
/("server_id"\s*:\s*)(-?\d{16,})/g,
|
||||||
'$1"$2"'
|
'$1"$2"'
|
||||||
)
|
)
|
||||||
return JSON.parse(normalized)
|
return JSON.parse(normalized)
|
||||||
@@ -1579,7 +1603,7 @@ export class WcdbCore {
|
|||||||
const outPtr = [null as any]
|
const outPtr = [null as any]
|
||||||
const result = this.wcdbGetSessions(this.handle, outPtr)
|
const result = this.wcdbGetSessions(this.handle, outPtr)
|
||||||
|
|
||||||
// DLL 调用后再次让出控制权
|
//数据服务调用后再次让出控制权
|
||||||
await new Promise(resolve => setImmediate(resolve))
|
await new Promise(resolve => setImmediate(resolve))
|
||||||
|
|
||||||
if (result !== 0 || !outPtr[0]) {
|
if (result !== 0 || !outPtr[0]) {
|
||||||
@@ -1655,6 +1679,9 @@ export class WcdbCore {
|
|||||||
const outCount = [0]
|
const outCount = [0]
|
||||||
const result = this.wcdbGetMessageCount(this.handle, sessionId, outCount)
|
const result = this.wcdbGetMessageCount(this.handle, sessionId, outCount)
|
||||||
if (result !== 0) {
|
if (result !== 0) {
|
||||||
|
if (result === -7) {
|
||||||
|
return { success: false, error: 'message schema mismatch:当前账号消息表结构与程序要求不一致' }
|
||||||
|
}
|
||||||
return { success: false, error: `获取消息总数失败: ${result}` }
|
return { success: false, error: `获取消息总数失败: ${result}` }
|
||||||
}
|
}
|
||||||
return { success: true, count: outCount[0] }
|
return { success: true, count: outCount[0] }
|
||||||
@@ -1685,6 +1712,9 @@ export class WcdbCore {
|
|||||||
const sessionId = normalizedSessionIds[i]
|
const sessionId = normalizedSessionIds[i]
|
||||||
const outCount = [0]
|
const outCount = [0]
|
||||||
const result = this.wcdbGetMessageCount(this.handle, sessionId, outCount)
|
const result = this.wcdbGetMessageCount(this.handle, sessionId, outCount)
|
||||||
|
if (result === -7) {
|
||||||
|
return { success: false, error: `message schema mismatch:会话 ${sessionId} 的消息表结构不匹配` }
|
||||||
|
}
|
||||||
counts[sessionId] = result === 0 && Number.isFinite(outCount[0]) ? Math.max(0, Math.floor(outCount[0])) : 0
|
counts[sessionId] = result === 0 && Number.isFinite(outCount[0]) ? Math.max(0, Math.floor(outCount[0])) : 0
|
||||||
|
|
||||||
if (i > 0 && i % 160 === 0) {
|
if (i > 0 && i % 160 === 0) {
|
||||||
@@ -1704,6 +1734,9 @@ export class WcdbCore {
|
|||||||
const outPtr = [null as any]
|
const outPtr = [null as any]
|
||||||
const result = this.wcdbGetSessionMessageCounts(this.handle, JSON.stringify(sessionIds || []), outPtr)
|
const result = this.wcdbGetSessionMessageCounts(this.handle, JSON.stringify(sessionIds || []), outPtr)
|
||||||
if (result !== 0 || !outPtr[0]) {
|
if (result !== 0 || !outPtr[0]) {
|
||||||
|
if (result === -7) {
|
||||||
|
return { success: false, error: 'message schema mismatch:当前账号消息表结构与程序要求不一致' }
|
||||||
|
}
|
||||||
return { success: false, error: `获取会话消息总数失败: ${result}` }
|
return { success: false, error: `获取会话消息总数失败: ${result}` }
|
||||||
}
|
}
|
||||||
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
const jsonStr = this.decodeJsonPtr(outPtr[0])
|
||||||
@@ -1925,7 +1958,7 @@ export class WcdbCore {
|
|||||||
const outPtr = [null as any]
|
const outPtr = [null as any]
|
||||||
const result = this.wcdbGetDisplayNames(this.handle, JSON.stringify(usernames), outPtr)
|
const result = this.wcdbGetDisplayNames(this.handle, JSON.stringify(usernames), outPtr)
|
||||||
|
|
||||||
// DLL 调用后再次让出控制权
|
//数据服务调用后再次让出控制权
|
||||||
await new Promise(resolve => setImmediate(resolve))
|
await new Promise(resolve => setImmediate(resolve))
|
||||||
|
|
||||||
if (result !== 0 || !outPtr[0]) {
|
if (result !== 0 || !outPtr[0]) {
|
||||||
@@ -2010,7 +2043,7 @@ export class WcdbCore {
|
|||||||
const outPtr = [null as any]
|
const outPtr = [null as any]
|
||||||
const result = this.wcdbGetAvatarUrls(handle, JSON.stringify(toFetch), outPtr)
|
const result = this.wcdbGetAvatarUrls(handle, JSON.stringify(toFetch), outPtr)
|
||||||
|
|
||||||
// DLL 调用后再次让出控制权
|
//数据服务调用后再次让出控制权
|
||||||
await new Promise(resolve => setImmediate(resolve))
|
await new Promise(resolve => setImmediate(resolve))
|
||||||
|
|
||||||
if (result !== 0 || !outPtr[0]) {
|
if (result !== 0 || !outPtr[0]) {
|
||||||
@@ -2110,7 +2143,7 @@ export class WcdbCore {
|
|||||||
return { success: false, error: 'WCDB 未连接' }
|
return { success: false, error: 'WCDB 未连接' }
|
||||||
}
|
}
|
||||||
if (!this.wcdbGetGroupNicknames) {
|
if (!this.wcdbGetGroupNicknames) {
|
||||||
return { success: false, error: '当前 DLL 版本不支持获取群昵称接口' }
|
return { success: false, error: '当前数据服务版本不支持获取群昵称接口' }
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const outPtr = [null as any]
|
const outPtr = [null as any]
|
||||||
@@ -2661,7 +2694,9 @@ export class WcdbCore {
|
|||||||
)
|
)
|
||||||
const hint = result === -3
|
const hint = result === -3
|
||||||
? `创建游标失败: ${result}(消息数据库未找到)。如果你最近重装过微信,请尝试重新指定数据目录后重试`
|
? `创建游标失败: ${result}(消息数据库未找到)。如果你最近重装过微信,请尝试重新指定数据目录后重试`
|
||||||
: `创建游标失败: ${result},请查看日志`
|
: result === -7
|
||||||
|
? 'message schema mismatch:当前账号消息表结构与程序要求不一致'
|
||||||
|
: `创建游标失败: ${result},请查看日志`
|
||||||
return { success: false, error: hint }
|
return { success: false, error: hint }
|
||||||
}
|
}
|
||||||
return { success: true, cursor: outCursor[0] }
|
return { success: true, cursor: outCursor[0] }
|
||||||
@@ -2719,6 +2754,9 @@ export class WcdbCore {
|
|||||||
`openMessageCursorLite failed: sessionId=${sessionId} batchSize=${batchSize} ascending=${ascending ? 1 : 0} begin=${beginTimestamp} end=${endTimestamp} result=${result} cursor=${outCursor[0]}`,
|
`openMessageCursorLite failed: sessionId=${sessionId} batchSize=${batchSize} ascending=${ascending ? 1 : 0} begin=${beginTimestamp} end=${endTimestamp} result=${result} cursor=${outCursor[0]}`,
|
||||||
true
|
true
|
||||||
)
|
)
|
||||||
|
if (result === -7) {
|
||||||
|
return { success: false, error: 'message schema mismatch:当前账号消息表结构与程序要求不一致' }
|
||||||
|
}
|
||||||
return { success: false, error: `创建游标失败: ${result},请查看日志` }
|
return { success: false, error: `创建游标失败: ${result},请查看日志` }
|
||||||
}
|
}
|
||||||
return { success: true, cursor: outCursor[0] }
|
return { success: true, cursor: outCursor[0] }
|
||||||
@@ -2790,14 +2828,14 @@ export class WcdbCore {
|
|||||||
if (!this.wcdbExecQuery) return { success: false, error: '接口未就绪' }
|
if (!this.wcdbExecQuery) return { success: false, error: '接口未就绪' }
|
||||||
const fallbackFlag = /fallback|diag|diagnostic/i.test(String(sql || ''))
|
const fallbackFlag = /fallback|diag|diagnostic/i.test(String(sql || ''))
|
||||||
this.writeLog(`[audit:execQuery] kind=${kind} path=${path || ''} sql_len=${String(sql || '').length} fallback=${fallbackFlag ? 1 : 0}`)
|
this.writeLog(`[audit:execQuery] kind=${kind} path=${path || ''} sql_len=${String(sql || '').length} fallback=${fallbackFlag ? 1 : 0}`)
|
||||||
|
|
||||||
// 如果提供了参数,使用参数化查询(需要 C++ 层支持)
|
// 如果提供了参数,使用参数化查询(需要 C++ 层支持)
|
||||||
// 注意:当前 wcdbExecQuery 可能不支持参数化,这是一个占位符实现
|
// 注意:当前 wcdbExecQuery 可能不支持参数化,这是一个占位符实现
|
||||||
// TODO: 需要更新 C++ 层的 wcdb_exec_query 以支持参数绑定
|
// TODO: 需要更新 C++ 层的 wcdb_exec_query 以支持参数绑定
|
||||||
if (params && params.length > 0) {
|
if (params && params.length > 0) {
|
||||||
console.warn('[wcdbCore] execQuery: 参数化查询暂未在 C++ 层实现,将使用原始 SQL(可能存在注入风险)')
|
console.warn('[wcdbCore] execQuery: 参数化查询暂未在 C++ 层实现,将使用原始 SQL(可能存在注入风险)')
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedKind = String(kind || '').toLowerCase()
|
const normalizedKind = String(kind || '').toLowerCase()
|
||||||
const isContactQuery = normalizedKind === 'contact' || /\bfrom\s+contact\b/i.test(String(sql))
|
const isContactQuery = normalizedKind === 'contact' || /\bfrom\s+contact\b/i.test(String(sql))
|
||||||
let effectivePath = path || ''
|
let effectivePath = path || ''
|
||||||
@@ -2948,7 +2986,7 @@ export class WcdbCore {
|
|||||||
|
|
||||||
async getVoiceData(sessionId: string, createTime: number, candidates: string[], localId: number = 0, svrId: string | number = 0): Promise<{ success: boolean; hex?: string; error?: string }> {
|
async getVoiceData(sessionId: string, createTime: number, candidates: string[], localId: number = 0, svrId: string | number = 0): Promise<{ success: boolean; hex?: string; error?: string }> {
|
||||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||||
if (!this.wcdbGetVoiceData) return { success: false, error: '当前 DLL 版本不支持获取语音数据' }
|
if (!this.wcdbGetVoiceData) return { success: false, error: '当前数据服务版本不支持获取语音数据' }
|
||||||
try {
|
try {
|
||||||
const outPtr = [null as any]
|
const outPtr = [null as any]
|
||||||
const result = this.wcdbGetVoiceData(this.handle, sessionId, createTime, localId, BigInt(svrId || 0), JSON.stringify(candidates), outPtr)
|
const result = this.wcdbGetVoiceData(this.handle, sessionId, createTime, localId, BigInt(svrId || 0), JSON.stringify(candidates), outPtr)
|
||||||
@@ -3362,7 +3400,7 @@ export class WcdbCore {
|
|||||||
|
|
||||||
async searchMessages(keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number): Promise<{ success: boolean; messages?: any[]; error?: string }> {
|
async searchMessages(keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number): Promise<{ success: boolean; messages?: any[]; error?: string }> {
|
||||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||||
if (!this.wcdbSearchMessages) return { success: false, error: '当前 DLL 版本不支持搜索消息' }
|
if (!this.wcdbSearchMessages) return { success: false, error: '当前数据服务版本不支持搜索消息' }
|
||||||
try {
|
try {
|
||||||
const handle = this.handle
|
const handle = this.handle
|
||||||
await new Promise(resolve => setImmediate(resolve))
|
await new Promise(resolve => setImmediate(resolve))
|
||||||
@@ -3392,7 +3430,7 @@ export class WcdbCore {
|
|||||||
|
|
||||||
async getSnsTimeline(limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: any[]; error?: string }> {
|
async getSnsTimeline(limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: any[]; error?: string }> {
|
||||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||||
if (!this.wcdbGetSnsTimeline) return { success: false, error: '当前 DLL 版本不支持获取朋友圈' }
|
if (!this.wcdbGetSnsTimeline) return { success: false, error: '当前数据服务版本不支持获取朋友圈' }
|
||||||
try {
|
try {
|
||||||
const outPtr = [null as any]
|
const outPtr = [null as any]
|
||||||
const usernamesJson = usernames && usernames.length > 0 ? JSON.stringify(usernames) : ''
|
const usernamesJson = usernames && usernames.length > 0 ? JSON.stringify(usernames) : ''
|
||||||
@@ -3481,12 +3519,128 @@ export class WcdbCore {
|
|||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async installMessageAntiRevokeTrigger(sessionId: string): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> {
|
||||||
|
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||||
|
if (!this.wcdbInstallMessageAntiRevokeTrigger) return { success: false, error: '当前数据服务版本不支持此功能' }
|
||||||
|
const normalizedSessionId = String(sessionId || '').trim()
|
||||||
|
if (!normalizedSessionId) return { success: false, error: 'sessionId 不能为空' }
|
||||||
|
try {
|
||||||
|
const outPtr = [null]
|
||||||
|
const status = this.wcdbInstallMessageAntiRevokeTrigger(this.handle, normalizedSessionId, outPtr)
|
||||||
|
let msg = ''
|
||||||
|
if (outPtr[0]) {
|
||||||
|
try { msg = this.koffi.decode(outPtr[0], 'char', -1) } catch { }
|
||||||
|
try { this.wcdbFreeString(outPtr[0]) } catch { }
|
||||||
|
}
|
||||||
|
if (status === 1) {
|
||||||
|
return { success: true, alreadyInstalled: true }
|
||||||
|
}
|
||||||
|
if (status !== 0) {
|
||||||
|
return { success: false, error: msg || `DLL error ${status}` }
|
||||||
|
}
|
||||||
|
return { success: true, alreadyInstalled: false }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async uninstallMessageAntiRevokeTrigger(sessionId: string): Promise<{ success: boolean; error?: string }> {
|
||||||
|
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||||
|
if (!this.wcdbUninstallMessageAntiRevokeTrigger) return { success: false, error: '当前数据服务版本不支持此功能' }
|
||||||
|
const normalizedSessionId = String(sessionId || '').trim()
|
||||||
|
if (!normalizedSessionId) return { success: false, error: 'sessionId 不能为空' }
|
||||||
|
try {
|
||||||
|
const outPtr = [null]
|
||||||
|
const status = this.wcdbUninstallMessageAntiRevokeTrigger(this.handle, normalizedSessionId, outPtr)
|
||||||
|
let msg = ''
|
||||||
|
if (outPtr[0]) {
|
||||||
|
try { msg = this.koffi.decode(outPtr[0], 'char', -1) } catch { }
|
||||||
|
try { this.wcdbFreeString(outPtr[0]) } catch { }
|
||||||
|
}
|
||||||
|
if (status !== 0) {
|
||||||
|
return { success: false, error: msg || `DLL error ${status}` }
|
||||||
|
}
|
||||||
|
return { success: true }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkMessageAntiRevokeTrigger(sessionId: string): Promise<{ success: boolean; installed?: boolean; error?: string }> {
|
||||||
|
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||||
|
if (!this.wcdbCheckMessageAntiRevokeTrigger) return { success: false, error: '当前数据服务版本不支持此功能' }
|
||||||
|
const normalizedSessionId = String(sessionId || '').trim()
|
||||||
|
if (!normalizedSessionId) return { success: false, error: 'sessionId 不能为空' }
|
||||||
|
try {
|
||||||
|
const outInstalled = [0]
|
||||||
|
const status = this.wcdbCheckMessageAntiRevokeTrigger(this.handle, normalizedSessionId, outInstalled)
|
||||||
|
if (status !== 0) {
|
||||||
|
return { success: false, error: `DLL error ${status}` }
|
||||||
|
}
|
||||||
|
return { success: true, installed: outInstalled[0] === 1 }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkMessageAntiRevokeTriggers(sessionIds: string[]): Promise<{
|
||||||
|
success: boolean
|
||||||
|
rows?: Array<{ sessionId: string; success: boolean; installed?: boolean; error?: string }>
|
||||||
|
error?: string
|
||||||
|
}> {
|
||||||
|
if (!Array.isArray(sessionIds) || sessionIds.length === 0) {
|
||||||
|
return { success: true, rows: [] }
|
||||||
|
}
|
||||||
|
const uniqueIds = Array.from(new Set(sessionIds.map((id) => String(id || '').trim()).filter(Boolean)))
|
||||||
|
const rows: Array<{ sessionId: string; success: boolean; installed?: boolean; error?: string }> = []
|
||||||
|
for (const sessionId of uniqueIds) {
|
||||||
|
const result = await this.checkMessageAntiRevokeTrigger(sessionId)
|
||||||
|
rows.push({ sessionId, success: result.success, installed: result.installed, error: result.error })
|
||||||
|
}
|
||||||
|
return { success: true, rows }
|
||||||
|
}
|
||||||
|
|
||||||
|
async installMessageAntiRevokeTriggers(sessionIds: string[]): Promise<{
|
||||||
|
success: boolean
|
||||||
|
rows?: Array<{ sessionId: string; success: boolean; alreadyInstalled?: boolean; error?: string }>
|
||||||
|
error?: string
|
||||||
|
}> {
|
||||||
|
if (!Array.isArray(sessionIds) || sessionIds.length === 0) {
|
||||||
|
return { success: true, rows: [] }
|
||||||
|
}
|
||||||
|
const uniqueIds = Array.from(new Set(sessionIds.map((id) => String(id || '').trim()).filter(Boolean)))
|
||||||
|
const rows: Array<{ sessionId: string; success: boolean; alreadyInstalled?: boolean; error?: string }> = []
|
||||||
|
for (const sessionId of uniqueIds) {
|
||||||
|
const result = await this.installMessageAntiRevokeTrigger(sessionId)
|
||||||
|
rows.push({ sessionId, success: result.success, alreadyInstalled: result.alreadyInstalled, error: result.error })
|
||||||
|
}
|
||||||
|
return { success: true, rows }
|
||||||
|
}
|
||||||
|
|
||||||
|
async uninstallMessageAntiRevokeTriggers(sessionIds: string[]): Promise<{
|
||||||
|
success: boolean
|
||||||
|
rows?: Array<{ sessionId: string; success: boolean; error?: string }>
|
||||||
|
error?: string
|
||||||
|
}> {
|
||||||
|
if (!Array.isArray(sessionIds) || sessionIds.length === 0) {
|
||||||
|
return { success: true, rows: [] }
|
||||||
|
}
|
||||||
|
const uniqueIds = Array.from(new Set(sessionIds.map((id) => String(id || '').trim()).filter(Boolean)))
|
||||||
|
const rows: Array<{ sessionId: string; success: boolean; error?: string }> = []
|
||||||
|
for (const sessionId of uniqueIds) {
|
||||||
|
const result = await this.uninstallMessageAntiRevokeTrigger(sessionId)
|
||||||
|
rows.push({ sessionId, success: result.success, error: result.error })
|
||||||
|
}
|
||||||
|
return { success: true, rows }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 为朋友圈安装删除
|
* 为朋友圈安装删除
|
||||||
*/
|
*/
|
||||||
async installSnsBlockDeleteTrigger(): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> {
|
async installSnsBlockDeleteTrigger(): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> {
|
||||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||||
if (!this.wcdbInstallSnsBlockDeleteTrigger) return { success: false, error: '当前 DLL 版本不支持此功能' }
|
if (!this.wcdbInstallSnsBlockDeleteTrigger) return { success: false, error: '当前数据服务版本不支持此功能' }
|
||||||
try {
|
try {
|
||||||
const outPtr = [null]
|
const outPtr = [null]
|
||||||
const status = this.wcdbInstallSnsBlockDeleteTrigger(this.handle, outPtr)
|
const status = this.wcdbInstallSnsBlockDeleteTrigger(this.handle, outPtr)
|
||||||
@@ -3496,7 +3650,7 @@ export class WcdbCore {
|
|||||||
try { this.wcdbFreeString(outPtr[0]) } catch { }
|
try { this.wcdbFreeString(outPtr[0]) } catch { }
|
||||||
}
|
}
|
||||||
if (status === 1) {
|
if (status === 1) {
|
||||||
// DLL 返回 1 表示已安装
|
//数据服务返回 1 表示已安装
|
||||||
return { success: true, alreadyInstalled: true }
|
return { success: true, alreadyInstalled: true }
|
||||||
}
|
}
|
||||||
if (status !== 0) {
|
if (status !== 0) {
|
||||||
@@ -3513,7 +3667,7 @@ export class WcdbCore {
|
|||||||
*/
|
*/
|
||||||
async uninstallSnsBlockDeleteTrigger(): Promise<{ success: boolean; error?: string }> {
|
async uninstallSnsBlockDeleteTrigger(): Promise<{ success: boolean; error?: string }> {
|
||||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||||
if (!this.wcdbUninstallSnsBlockDeleteTrigger) return { success: false, error: '当前 DLL 版本不支持此功能' }
|
if (!this.wcdbUninstallSnsBlockDeleteTrigger) return { success: false, error: '当前数据服务版本不支持此功能' }
|
||||||
try {
|
try {
|
||||||
const outPtr = [null]
|
const outPtr = [null]
|
||||||
const status = this.wcdbUninstallSnsBlockDeleteTrigger(this.handle, outPtr)
|
const status = this.wcdbUninstallSnsBlockDeleteTrigger(this.handle, outPtr)
|
||||||
@@ -3536,7 +3690,7 @@ export class WcdbCore {
|
|||||||
*/
|
*/
|
||||||
async checkSnsBlockDeleteTrigger(): Promise<{ success: boolean; installed?: boolean; error?: string }> {
|
async checkSnsBlockDeleteTrigger(): Promise<{ success: boolean; installed?: boolean; error?: string }> {
|
||||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||||
if (!this.wcdbCheckSnsBlockDeleteTrigger) return { success: false, error: '当前 DLL 版本不支持此功能' }
|
if (!this.wcdbCheckSnsBlockDeleteTrigger) return { success: false, error: '当前数据服务版本不支持此功能' }
|
||||||
try {
|
try {
|
||||||
const outInstalled = [0]
|
const outInstalled = [0]
|
||||||
const status = this.wcdbCheckSnsBlockDeleteTrigger(this.handle, outInstalled)
|
const status = this.wcdbCheckSnsBlockDeleteTrigger(this.handle, outInstalled)
|
||||||
@@ -3551,7 +3705,7 @@ export class WcdbCore {
|
|||||||
|
|
||||||
async deleteSnsPost(postId: string): Promise<{ success: boolean; error?: string }> {
|
async deleteSnsPost(postId: string): Promise<{ success: boolean; error?: string }> {
|
||||||
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
|
||||||
if (!this.wcdbDeleteSnsPost) return { success: false, error: '当前 DLL 版本不支持此功能' }
|
if (!this.wcdbDeleteSnsPost) return { success: false, error: '当前数据服务版本不支持此功能' }
|
||||||
try {
|
try {
|
||||||
const outPtr = [null]
|
const outPtr = [null]
|
||||||
const status = this.wcdbDeleteSnsPost(this.handle, postId, outPtr)
|
const status = this.wcdbDeleteSnsPost(this.handle, postId, outPtr)
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export class WcdbService {
|
|||||||
// Worker 退出,需要 reject 所有 pending promises
|
// Worker 退出,需要 reject 所有 pending promises
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
console.error('WCDB Worker 异常退出,退出码:', code)
|
console.error('WCDB Worker 异常退出,退出码:', code)
|
||||||
const errorMsg = `Worker 异常退出 (退出码: ${code})。可能是 DLL 加载失败,请检查是否安装了 Visual C++ Redistributable。`
|
const errorMsg = `Worker 异常退出 (退出码: ${code})。可能是数据服务加载失败,请检查是否安装了 Visual C++ Redistributable。`
|
||||||
for (const [id, p] of this.pending) {
|
for (const [id, p] of this.pending) {
|
||||||
p.reject(new Error(errorMsg))
|
p.reject(new Error(errorMsg))
|
||||||
}
|
}
|
||||||
@@ -467,7 +467,7 @@ export class WcdbService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取表情包释义(严格 DLL 接口)
|
* 获取表情包释义(严格数据服务接口)
|
||||||
*/
|
*/
|
||||||
async getEmoticonCaptionStrict(md5: string): Promise<{ success: boolean; caption?: string; error?: string }> {
|
async getEmoticonCaptionStrict(md5: string): Promise<{ success: boolean; caption?: string; error?: string }> {
|
||||||
return this.callWorker('getEmoticonCaptionStrict', { md5 })
|
return this.callWorker('getEmoticonCaptionStrict', { md5 })
|
||||||
@@ -561,6 +561,24 @@ export class WcdbService {
|
|||||||
return this.callWorker('getSnsExportStats', { myWxid })
|
return this.callWorker('getSnsExportStats', { myWxid })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async checkMessageAntiRevokeTriggers(
|
||||||
|
sessionIds: string[]
|
||||||
|
): Promise<{ success: boolean; rows?: Array<{ sessionId: string; success: boolean; installed?: boolean; error?: string }>; error?: string }> {
|
||||||
|
return this.callWorker('checkMessageAntiRevokeTriggers', { sessionIds })
|
||||||
|
}
|
||||||
|
|
||||||
|
async installMessageAntiRevokeTriggers(
|
||||||
|
sessionIds: string[]
|
||||||
|
): Promise<{ success: boolean; rows?: Array<{ sessionId: string; success: boolean; alreadyInstalled?: boolean; error?: string }>; error?: string }> {
|
||||||
|
return this.callWorker('installMessageAntiRevokeTriggers', { sessionIds })
|
||||||
|
}
|
||||||
|
|
||||||
|
async uninstallMessageAntiRevokeTriggers(
|
||||||
|
sessionIds: string[]
|
||||||
|
): Promise<{ success: boolean; rows?: Array<{ sessionId: string; success: boolean; error?: string }>; error?: string }> {
|
||||||
|
return this.callWorker('uninstallMessageAntiRevokeTriggers', { sessionIds })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 安装朋友圈删除拦截
|
* 安装朋友圈删除拦截
|
||||||
*/
|
*/
|
||||||
@@ -590,7 +608,7 @@ export class WcdbService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取 DLL 内部日志
|
* 获取数据服务内部日志
|
||||||
*/
|
*/
|
||||||
async getLogs(): Promise<{ success: boolean; logs?: string[]; error?: string }> {
|
async getLogs(): Promise<{ success: boolean; logs?: string[]; error?: string }> {
|
||||||
return this.callWorker('getLogs')
|
return this.callWorker('getLogs')
|
||||||
|
|||||||
@@ -230,6 +230,15 @@ if (parentPort) {
|
|||||||
case 'getSnsExportStats':
|
case 'getSnsExportStats':
|
||||||
result = await core.getSnsExportStats(payload.myWxid)
|
result = await core.getSnsExportStats(payload.myWxid)
|
||||||
break
|
break
|
||||||
|
case 'checkMessageAntiRevokeTriggers':
|
||||||
|
result = await core.checkMessageAntiRevokeTriggers(payload.sessionIds)
|
||||||
|
break
|
||||||
|
case 'installMessageAntiRevokeTriggers':
|
||||||
|
result = await core.installMessageAntiRevokeTriggers(payload.sessionIds)
|
||||||
|
break
|
||||||
|
case 'uninstallMessageAntiRevokeTriggers':
|
||||||
|
result = await core.uninstallMessageAntiRevokeTriggers(payload.sessionIds)
|
||||||
|
break
|
||||||
case 'installSnsBlockDeleteTrigger':
|
case 'installSnsBlockDeleteTrigger':
|
||||||
result = await core.installSnsBlockDeleteTrigger()
|
result = await core.installSnsBlockDeleteTrigger()
|
||||||
break
|
break
|
||||||
|
|||||||
95
package-lock.json
generated
95
package-lock.json
generated
@@ -11,7 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"echarts": "^6.0.0",
|
"echarts": "^6.0.0",
|
||||||
"echarts-for-react": "^3.0.2",
|
"echarts-for-react": "^3.0.2",
|
||||||
"electron-store": "^10.0.0",
|
"electron-store": "^11.0.2",
|
||||||
"electron-updater": "^6.3.9",
|
"electron-updater": "^6.3.9",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"ffmpeg-static": "^5.3.0",
|
"ffmpeg-static": "^5.3.0",
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.13.2",
|
"react-router-dom": "^7.14.0",
|
||||||
"react-virtuoso": "^4.18.1",
|
"react-virtuoso": "^4.18.1",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"sherpa-onnx-node": "^1.10.38",
|
"sherpa-onnx-node": "^1.10.38",
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
"@types/react": "^19.1.0",
|
"@types/react": "^19.1.0",
|
||||||
"@types/react-dom": "^19.1.0",
|
"@types/react-dom": "^19.1.0",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"electron": "^39.2.7",
|
"electron": "^41.1.1",
|
||||||
"electron-builder": "^26.8.1",
|
"electron-builder": "^26.8.1",
|
||||||
"sass": "^1.98.0",
|
"sass": "^1.98.0",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
@@ -2948,13 +2948,13 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.19.15",
|
"version": "24.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz",
|
||||||
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
|
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/plist": {
|
"node_modules/@types/plist": {
|
||||||
@@ -4260,20 +4260,20 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/conf": {
|
"node_modules/conf": {
|
||||||
"version": "14.0.0",
|
"version": "15.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/conf/-/conf-14.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/conf/-/conf-15.1.0.tgz",
|
||||||
"integrity": "sha512-L6BuueHTRuJHQvQVc6YXYZRtN5vJUtOdCTLn0tRYYV5azfbAFcPghB5zEE40mVrV6w7slMTqUfkDomutIK14fw==",
|
"integrity": "sha512-Uy5YN9KEu0WWDaZAVJ5FAmZoaJt9rdK6kH+utItPyGsCqCgaTKkrmZx3zoE0/3q6S3bcp3Ihkk+ZqPxWxFK5og==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ajv": "^8.17.1",
|
"ajv": "^8.17.1",
|
||||||
"ajv-formats": "^3.0.1",
|
"ajv-formats": "^3.0.1",
|
||||||
"atomically": "^2.0.3",
|
"atomically": "^2.0.3",
|
||||||
"debounce-fn": "^6.0.0",
|
"debounce-fn": "^6.0.0",
|
||||||
"dot-prop": "^9.0.0",
|
"dot-prop": "^10.0.0",
|
||||||
"env-paths": "^3.0.0",
|
"env-paths": "^3.0.0",
|
||||||
"json-schema-typed": "^8.0.1",
|
"json-schema-typed": "^8.0.1",
|
||||||
"semver": "^7.7.2",
|
"semver": "^7.7.2",
|
||||||
"uint8array-extras": "^1.4.0"
|
"uint8array-extras": "^1.5.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
@@ -4733,15 +4733,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dot-prop": {
|
"node_modules/dot-prop": {
|
||||||
"version": "9.0.0",
|
"version": "10.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-9.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-10.1.0.tgz",
|
||||||
"integrity": "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==",
|
"integrity": "sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"type-fest": "^4.18.2"
|
"type-fest": "^5.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
@@ -4878,15 +4878,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/electron": {
|
"node_modules/electron": {
|
||||||
"version": "39.8.6",
|
"version": "41.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/electron/-/electron-39.8.6.tgz",
|
"resolved": "https://registry.npmjs.org/electron/-/electron-41.1.1.tgz",
|
||||||
"integrity": "sha512-uWX6Jh5LmwL13VwOSKBjebI+ck+03GOwc8V2Sgbmr9pJVJ/cHfli/PkjXuRDr+hq+SLHQuT9mGHSIfScebApRA==",
|
"integrity": "sha512-8bgvDhBjli+3Z2YCKgzzoBPh6391pr7Xv2h/tTJG4ETgvPvUxZomObbZLs31mUzYb6VrlcDDd9cyWyNKtPm3tA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@electron/get": "^2.0.0",
|
"@electron/get": "^2.0.0",
|
||||||
"@types/node": "^22.7.7",
|
"@types/node": "^24.9.0",
|
||||||
"extract-zip": "^2.0.1"
|
"extract-zip": "^2.0.1"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -5029,13 +5029,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/electron-store": {
|
"node_modules/electron-store": {
|
||||||
"version": "10.1.0",
|
"version": "11.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/electron-store/-/electron-store-10.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/electron-store/-/electron-store-11.0.2.tgz",
|
||||||
"integrity": "sha512-oL8bRy7pVCLpwhmXy05Rh/L6O93+k9t6dqSw0+MckIc3OmCTZm6Mp04Q4f/J0rtu84Ky6ywkR8ivtGOmrq+16w==",
|
"integrity": "sha512-4VkNRdN+BImL2KcCi41WvAYbh6zLX5AUTi4so68yPqiItjbgTjqpEnGAqasgnG+lB6GuAyUltKwVopp6Uv+gwQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"conf": "^14.0.0",
|
"conf": "^15.0.2",
|
||||||
"type-fest": "^4.41.0"
|
"type-fest": "^5.0.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
@@ -8522,9 +8522,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router": {
|
"node_modules/react-router": {
|
||||||
"version": "7.13.2",
|
"version": "7.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.0.tgz",
|
||||||
"integrity": "sha512-tX1Aee+ArlKQP+NIUd7SE6Li+CiGKwQtbS+FfRxPX6Pe4vHOo6nr9d++u5cwg+Z8K/x8tP+7qLmujDtfrAoUJA==",
|
"integrity": "sha512-m/xR9N4LQLmAS0ZhkY2nkPA1N7gQ5TUVa5n8TgANuDTARbn1gt+zLPXEm7W0XDTbrQ2AJSJKhoa6yx1D8BcpxQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cookie": "^1.0.1",
|
"cookie": "^1.0.1",
|
||||||
@@ -8544,12 +8544,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router-dom": {
|
"node_modules/react-router-dom": {
|
||||||
"version": "7.13.2",
|
"version": "7.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.0.tgz",
|
||||||
"integrity": "sha512-aR7SUORwTqAW0JDeiWF07e9SBE9qGpByR9I8kJT5h/FrBKxPMS6TiC7rmVO+gC0q52Bx7JnjWe8Z1sR9faN4YA==",
|
"integrity": "sha512-2G3ajSVSZMEtmTjIklRWlNvo8wICEpLihfD/0YMDxbWK2UyP5EGfnoIn9AIQGnF3G/FX0MRbHXdFcD+rL1ZreQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react-router": "7.13.2"
|
"react-router": "7.14.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
@@ -9489,6 +9489,18 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tagged-tag": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tar": {
|
"node_modules/tar": {
|
||||||
"version": "7.5.13",
|
"version": "7.5.13",
|
||||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz",
|
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz",
|
||||||
@@ -9713,12 +9725,15 @@
|
|||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
"node_modules/type-fest": {
|
"node_modules/type-fest": {
|
||||||
"version": "4.41.0",
|
"version": "5.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz",
|
||||||
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
|
"integrity": "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==",
|
||||||
"license": "(MIT OR CC0-1.0)",
|
"license": "(MIT OR CC0-1.0)",
|
||||||
|
"dependencies": {
|
||||||
|
"tagged-tag": "^1.0.0"
|
||||||
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
@@ -9757,9 +9772,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "6.21.0",
|
"version": "7.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -25,7 +25,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"echarts": "^6.0.0",
|
"echarts": "^6.0.0",
|
||||||
"echarts-for-react": "^3.0.2",
|
"echarts-for-react": "^3.0.2",
|
||||||
"electron-store": "^10.0.0",
|
"electron-store": "^11.0.2",
|
||||||
"electron-updater": "^6.3.9",
|
"electron-updater": "^6.3.9",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"ffmpeg-static": "^5.3.0",
|
"ffmpeg-static": "^5.3.0",
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.13.2",
|
"react-router-dom": "^7.14.0",
|
||||||
"react-virtuoso": "^4.18.1",
|
"react-virtuoso": "^4.18.1",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"sherpa-onnx-node": "^1.10.38",
|
"sherpa-onnx-node": "^1.10.38",
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
"@types/react": "^19.1.0",
|
"@types/react": "^19.1.0",
|
||||||
"@types/react-dom": "^19.1.0",
|
"@types/react-dom": "^19.1.0",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"electron": "^39.2.7",
|
"electron": "^41.1.1",
|
||||||
"electron-builder": "^26.8.1",
|
"electron-builder": "^26.8.1",
|
||||||
"sass": "^1.98.0",
|
"sass": "^1.98.0",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
@@ -70,7 +70,9 @@
|
|||||||
"lodash": ">=4.17.21",
|
"lodash": ">=4.17.21",
|
||||||
"brace-expansion": ">=1.1.11",
|
"brace-expansion": ">=1.1.11",
|
||||||
"picomatch": ">=2.3.1",
|
"picomatch": ">=2.3.1",
|
||||||
"ajv": ">=8.18.0"
|
"ajv": ">=8.18.0",
|
||||||
|
"ajv-keywords@3>ajv": "^6.12.6",
|
||||||
|
"@develar/schema-utils>ajv": "^6.12.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
54
src/App.tsx
54
src/App.tsx
@@ -20,6 +20,7 @@ import ExportPage from './pages/ExportPage'
|
|||||||
import VideoWindow from './pages/VideoWindow'
|
import VideoWindow from './pages/VideoWindow'
|
||||||
import ImageWindow from './pages/ImageWindow'
|
import ImageWindow from './pages/ImageWindow'
|
||||||
import SnsPage from './pages/SnsPage'
|
import SnsPage from './pages/SnsPage'
|
||||||
|
import BizPage from './pages/BizPage'
|
||||||
import ContactsPage from './pages/ContactsPage'
|
import ContactsPage from './pages/ContactsPage'
|
||||||
import ChatHistoryPage from './pages/ChatHistoryPage'
|
import ChatHistoryPage from './pages/ChatHistoryPage'
|
||||||
import NotificationWindow from './pages/NotificationWindow'
|
import NotificationWindow from './pages/NotificationWindow'
|
||||||
@@ -429,7 +430,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
// 如果错误信息包含 VC++ 或 DLL 相关内容,不清除配置,只提示用户
|
// 如果错误信息包含 VC++ 或数据服务相关内容,不清除配置,只提示用户
|
||||||
// 其他错误可能需要重新配置
|
// 其他错误可能需要重新配置
|
||||||
const errorMsg = result.error || ''
|
const errorMsg = result.error || ''
|
||||||
if (errorMsg.includes('Visual C++') ||
|
if (errorMsg.includes('Visual C++') ||
|
||||||
@@ -590,9 +591,13 @@ function App() {
|
|||||||
<div className="agreement-notice">
|
<div className="agreement-notice">
|
||||||
<strong>这是免费软件,如果你是付费购买的话请骂死那个骗子。</strong>
|
<strong>这是免费软件,如果你是付费购买的话请骂死那个骗子。</strong>
|
||||||
<span className="agreement-notice-link">
|
<span className="agreement-notice-link">
|
||||||
我们唯一的官方网站:
|
官方网站:
|
||||||
|
<a href="https://weflow.top" target="_blank" rel="noreferrer">
|
||||||
|
https://weflow.top
|
||||||
|
</a>
|
||||||
|
·
|
||||||
<a href="https://github.com/hicccc77/WeFlow" target="_blank" rel="noreferrer">
|
<a href="https://github.com/hicccc77/WeFlow" target="_blank" rel="noreferrer">
|
||||||
https://github.com/hicccc77/WeFlow
|
GitHub 仓库
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -607,7 +612,7 @@ function App() {
|
|||||||
<p>因使用本软件产生的任何直接或间接损失,开发者不承担任何责任。请确保你的使用行为符合当地法律法规。</p>
|
<p>因使用本软件产生的任何直接或间接损失,开发者不承担任何责任。请确保你的使用行为符合当地法律法规。</p>
|
||||||
|
|
||||||
<h4>4. 隐私保护</h4>
|
<h4>4. 隐私保护</h4>
|
||||||
<p>本软件不收集任何用户数据。软件更新检测仅获取版本信息,不涉及任何个人隐私。</p>
|
<p>本软件不收集任何用户隐私数据。软件更新检测仅获取版本信息,不涉及任何个人隐私。</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="agreement-footer">
|
<div className="agreement-footer">
|
||||||
@@ -665,30 +670,30 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{showWaylandWarning && (
|
{showWaylandWarning && (
|
||||||
<div className="agreement-overlay">
|
<div className="agreement-overlay">
|
||||||
<div className="agreement-modal">
|
<div className="agreement-modal">
|
||||||
<div className="agreement-header">
|
<div className="agreement-header">
|
||||||
<Shield size={32} />
|
<Shield size={32} />
|
||||||
<h2>环境兼容性提示 (Wayland)</h2>
|
<h2>环境兼容性提示 (Wayland)</h2>
|
||||||
|
</div>
|
||||||
|
<div className="agreement-content">
|
||||||
|
<div className="agreement-text">
|
||||||
|
<p>检测到您当前正在使用 <strong>Wayland</strong> 显示服务器。</p>
|
||||||
|
<p>在 Wayland 环境下,出于系统级的安全与设计机制,<strong>应用程序无法直接控制新弹出窗口的位置</strong>。</p>
|
||||||
|
<p>这可能导致某些独立窗口(如消息通知、图片查看器等)出现位置随机、或不受控制的情况。这是底层机制导致的,对此我们无能为力。</p>
|
||||||
|
<br />
|
||||||
|
<p>如果您觉得窗口位置异常严重影响了使用体验,建议尝试:</p>
|
||||||
|
<p>1. 在系统登录界面,将会话切换回 <strong>X11 (Xorg)</strong> 模式。</p>
|
||||||
|
<p>2. 修改您的桌面管理器 (WM/DE) 配置,强制指定该应用程序的窗口规则。</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="agreement-content">
|
</div>
|
||||||
<div className="agreement-text">
|
<div className="agreement-footer">
|
||||||
<p>检测到您当前正在使用 <strong>Wayland</strong> 显示服务器。</p>
|
<div className="agreement-actions">
|
||||||
<p>在 Wayland 环境下,出于系统级的安全与设计机制,<strong>应用程序无法直接控制新弹出窗口的位置</strong>。</p>
|
<button className="btn btn-primary" onClick={handleDismissWaylandWarning}>我知道了,不再提示</button>
|
||||||
<p>这可能导致某些独立窗口(如消息通知、图片查看器等)出现位置随机、或不受控制的情况。这是底层机制导致的,对此我们无能为力。</p>
|
|
||||||
<br />
|
|
||||||
<p>如果您觉得窗口位置异常严重影响了使用体验,建议尝试:</p>
|
|
||||||
<p>1. 在系统登录界面,将会话切换回 <strong>X11 (Xorg)</strong> 模式。</p>
|
|
||||||
<p>2. 修改您的桌面管理器 (WM/DE) 配置,强制指定该应用程序的窗口规则。</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="agreement-footer">
|
|
||||||
<div className="agreement-actions">
|
|
||||||
<button className="btn btn-primary" onClick={handleDismissWaylandWarning}>我知道了,不再提示</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 更新提示对话框 */}
|
{/* 更新提示对话框 */}
|
||||||
@@ -736,6 +741,7 @@ function App() {
|
|||||||
|
|
||||||
<Route path="/export" element={<div className="export-route-anchor" aria-hidden="true" />} />
|
<Route path="/export" element={<div className="export-route-anchor" aria-hidden="true" />} />
|
||||||
<Route path="/sns" element={<SnsPage />} />
|
<Route path="/sns" element={<SnsPage />} />
|
||||||
|
<Route path="/biz" element={<BizPage />} />
|
||||||
<Route path="/contacts" element={<ContactsPage />} />
|
<Route path="/contacts" element={<ContactsPage />} />
|
||||||
<Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} />
|
<Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} />
|
||||||
<Route path="/chat-history-inline/:payloadId" element={<ChatHistoryPage />} />
|
<Route path="/chat-history-inline/:payloadId" element={<ChatHistoryPage />} />
|
||||||
|
|||||||
@@ -66,7 +66,8 @@ export function ExportDefaultsSettingsForm({
|
|||||||
images: true,
|
images: true,
|
||||||
videos: true,
|
videos: true,
|
||||||
voices: true,
|
voices: true,
|
||||||
emojis: true
|
emojis: true,
|
||||||
|
files: true
|
||||||
})
|
})
|
||||||
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false)
|
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false)
|
||||||
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
|
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
|
||||||
@@ -94,7 +95,8 @@ export function ExportDefaultsSettingsForm({
|
|||||||
images: true,
|
images: true,
|
||||||
videos: true,
|
videos: true,
|
||||||
voices: true,
|
voices: true,
|
||||||
emojis: true
|
emojis: true,
|
||||||
|
files: true
|
||||||
})
|
})
|
||||||
setExportDefaultVoiceAsText(savedVoiceAsText ?? false)
|
setExportDefaultVoiceAsText(savedVoiceAsText ?? false)
|
||||||
setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true)
|
setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true)
|
||||||
@@ -292,7 +294,7 @@ export function ExportDefaultsSettingsForm({
|
|||||||
<div className="form-group media-setting-group">
|
<div className="form-group media-setting-group">
|
||||||
<div className="form-copy">
|
<div className="form-copy">
|
||||||
<label>默认导出媒体内容</label>
|
<label>默认导出媒体内容</label>
|
||||||
<span className="form-hint">控制图片、视频、语音、表情包的默认导出开关</span>
|
<span className="form-hint">控制图片、视频、语音、表情包、文件的默认导出开关</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-control">
|
<div className="form-control">
|
||||||
<div className="media-default-grid">
|
<div className="media-default-grid">
|
||||||
@@ -352,6 +354,20 @@ export function ExportDefaultsSettingsForm({
|
|||||||
/>
|
/>
|
||||||
表情包
|
表情包
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={exportDefaultMedia.files}
|
||||||
|
onChange={async (e) => {
|
||||||
|
const next = { ...exportDefaultMedia, files: e.target.checked }
|
||||||
|
setExportDefaultMedia(next)
|
||||||
|
await configService.setExportDefaultMedia(next)
|
||||||
|
onDefaultsChanged?.({ media: next })
|
||||||
|
notify(`已${e.target.checked ? '开启' : '关闭'}默认导出文件`, true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
文件
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,44 +8,9 @@ import {
|
|||||||
registerBackgroundTask,
|
registerBackgroundTask,
|
||||||
updateBackgroundTask
|
updateBackgroundTask
|
||||||
} from '../services/backgroundTaskMonitor'
|
} from '../services/backgroundTaskMonitor'
|
||||||
|
import { drawPatternBackground } from '../utils/reportExport'
|
||||||
import './AnnualReportWindow.scss'
|
import './AnnualReportWindow.scss'
|
||||||
|
|
||||||
// SVG 背景图案 (用于导出)
|
|
||||||
const PATTERN_LIGHT_SVG = `<svg xmlns='http://www.w3.org/2000/svg' width='400' height='400' viewBox='0 0 400 400'><defs><style>.a{fill:none;stroke:#000;stroke-width:1.2;opacity:0.045}.b{fill:none;stroke:#000;stroke-width:1;opacity:0.035}.c{fill:none;stroke:#000;stroke-width:0.8;opacity:0.04}</style></defs><g transform='translate(45,35) rotate(-8)'><circle class='a' cx='0' cy='0' r='16'/><circle class='a' cx='-5' cy='-4' r='2.5'/><circle class='a' cx='5' cy='-4' r='2.5'/><path class='a' d='M-8 4 Q0 12 8 4'/></g><g transform='translate(320,28) rotate(15) scale(0.7)'><path class='b' d='M0 -12 l3 9 9 0 -7 5 3 9 -8 -6 -8 6 3 -9 -7 -5 9 0z'/></g><g transform='translate(180,55) rotate(12)'><path class='a' d='M0 -8 C0 -14 8 -17 12 -10 C16 -17 24 -14 24 -8 C24 4 12 14 12 14 C12 14 0 4 0 -8'/></g><g transform='translate(95,120) rotate(-5) scale(1.1)'><path class='b' d='M0 10 Q-8 10 -8 3 Q-8 -4 0 -4 Q0 -12 10 -12 Q22 -12 22 -2 Q30 -2 30 5 Q30 12 22 12 Z'/></g><g transform='translate(355,95) rotate(8)'><path class='c' d='M0 0 L0 18 M0 0 L18 -4 L18 14'/><ellipse class='c' cx='-4' cy='20' rx='6' ry='4'/><ellipse class='c' cx='14' cy='16' rx='6' ry='4'/></g><g transform='translate(250,110) rotate(-12) scale(0.9)'><rect class='b' x='0' y='0' width='26' height='18' rx='2'/><path class='b' d='M0 2 L13 11 L26 2'/></g><g transform='translate(28,195) rotate(6)'><circle class='a' cx='0' cy='0' r='11'/><path class='a' d='M-5 11 L5 11 M-4 14 L4 14'/><path class='c' d='M-3 -2 L0 -6 L3 -2'/></g><g transform='translate(155,175) rotate(-3) scale(0.85)'><path class='b' d='M0 0 L0 28 Q14 22 28 28 L28 0 Q14 6 0 0'/><path class='b' d='M28 0 L28 28 Q42 22 56 28 L56 0 Q42 6 28 0'/></g><g transform='translate(340,185) rotate(-20) scale(1.2)'><path class='a' d='M0 8 L20 0 L5 6 L8 14 L5 6 L-12 12 Z'/></g><g transform='translate(70,280) rotate(5)'><rect class='b' x='0' y='5' width='30' height='22' rx='4'/><circle class='b' cx='15' cy='16' r='7'/><rect class='b' x='8' y='0' width='14' height='6' rx='2'/></g><g transform='translate(230,250) rotate(-8) scale(1.1)'><rect class='a' x='0' y='6' width='22' height='18' rx='2'/><rect class='a' x='-3' y='0' width='28' height='7' rx='2'/><path class='a' d='M11 0 L11 24 M-3 13 L25 13'/></g><g transform='translate(365,280) rotate(10)'><ellipse class='b' cx='0' cy='0' rx='10' ry='14'/><path class='b' d='M0 14 Q-3 20 0 28 Q2 24 -1 20'/></g><g transform='translate(145,310) rotate(-6)'><path class='c' d='M0 0 L4 28 L24 28 L28 0 Z'/><path class='c' d='M28 6 Q40 6 40 16 Q40 24 28 24'/><path class='c' d='M8 8 Q10 4 12 8'/></g><g transform='translate(310,340) rotate(5) scale(0.9)'><path class='a' d='M0 8 L8 0 L24 0 L32 8 L16 28 Z'/><path class='a' d='M8 0 L12 8 L0 8 M24 0 L20 8 L32 8 M12 8 L16 28 L20 8'/></g><g transform='translate(55,365) rotate(25) scale(1.15)'><path class='a' d='M8 0 Q12 -14 16 0 L14 6 L18 12 L12 9 L6 12 L10 6 Z'/><circle class='c' cx='12' cy='-2' r='2'/></g><g transform='translate(200,375) rotate(-4)'><path class='b' d='M0 12 Q0 -8 24 -8 Q48 -8 48 12'/><path class='c' d='M6 12 Q6 -2 24 -2 Q42 -2 42 12'/><path class='c' d='M12 12 Q12 4 24 4 Q36 4 36 12'/></g><g transform='translate(380,375) rotate(-10)'><circle class='a' cx='0' cy='0' r='8'/><path class='c' d='M0 -14 L0 -10 M0 10 L0 14 M-14 0 L-10 0 M10 0 L14 0 M-10 -10 L-7 -7 M7 7 L10 10 M-10 10 L-7 7 M7 -7 L10 -10'/></g></svg>`
|
|
||||||
|
|
||||||
const PATTERN_DARK_SVG = `<svg xmlns='http://www.w3.org/2000/svg' width='400' height='400' viewBox='0 0 400 400'><defs><style>.a{fill:none;stroke:#fff;stroke-width:1.2;opacity:0.055}.b{fill:none;stroke:#fff;stroke-width:1;opacity:0.045}.c{fill:none;stroke:#fff;stroke-width:0.8;opacity:0.05}</style></defs><g transform='translate(45,35) rotate(-8)'><circle class='a' cx='0' cy='0' r='16'/><circle class='a' cx='-5' cy='-4' r='2.5'/><circle class='a' cx='5' cy='-4' r='2.5'/><path class='a' d='M-8 4 Q0 12 8 4'/></g><g transform='translate(320,28) rotate(15) scale(0.7)'><path class='b' d='M0 -12 l3 9 9 0 -7 5 3 9 -8 -6 -8 6 3 -9 -7 -5 9 0z'/></g><g transform='translate(180,55) rotate(12)'><path class='a' d='M0 -8 C0 -14 8 -17 12 -10 C16 -17 24 -14 24 -8 C24 4 12 14 12 14 C12 14 0 4 0 -8'/></g><g transform='translate(95,120) rotate(-5) scale(1.1)'><path class='b' d='M0 10 Q-8 10 -8 3 Q-8 -4 0 -4 Q0 -12 10 -12 Q22 -12 22 -2 Q30 -2 30 5 Q30 12 22 12 Z'/></g><g transform='translate(355,95) rotate(8)'><path class='c' d='M0 0 L0 18 M0 0 L18 -4 L18 14'/><ellipse class='c' cx='-4' cy='20' rx='6' ry='4'/><ellipse class='c' cx='14' cy='16' rx='6' ry='4'/></g><g transform='translate(250,110) rotate(-12) scale(0.9)'><rect class='b' x='0' y='0' width='26' height='18' rx='2'/><path class='b' d='M0 2 L13 11 L26 2'/></g><g transform='translate(28,195) rotate(6)'><circle class='a' cx='0' cy='0' r='11'/><path class='a' d='M-5 11 L5 11 M-4 14 L4 14'/><path class='c' d='M-3 -2 L0 -6 L3 -2'/></g><g transform='translate(155,175) rotate(-3) scale(0.85)'><path class='b' d='M0 0 L0 28 Q14 22 28 28 L28 0 Q14 6 0 0'/><path class='b' d='M28 0 L28 28 Q42 22 56 28 L56 0 Q42 6 28 0'/></g><g transform='translate(340,185) rotate(-20) scale(1.2)'><path class='a' d='M0 8 L20 0 L5 6 L8 14 L5 6 L-12 12 Z'/></g><g transform='translate(70,280) rotate(5)'><rect class='b' x='0' y='5' width='30' height='22' rx='4'/><circle class='b' cx='15' cy='16' r='7'/><rect class='b' x='8' y='0' width='14' height='6' rx='2'/></g><g transform='translate(230,250) rotate(-8) scale(1.1)'><rect class='a' x='0' y='6' width='22' height='18' rx='2'/><rect class='a' x='-3' y='0' width='28' height='7' rx='2'/><path class='a' d='M11 0 L11 24 M-3 13 L25 13'/></g><g transform='translate(365,280) rotate(10)'><ellipse class='b' cx='0' cy='0' rx='10' ry='14'/><path class='b' d='M0 14 Q-3 20 0 28 Q2 24 -1 20'/></g><g transform='translate(145,310) rotate(-6)'><path class='c' d='M0 0 L4 28 L24 28 L28 0 Z'/><path class='c' d='M28 6 Q40 6 40 16 Q40 24 28 24'/><path class='c' d='M8 8 Q10 4 12 8'/></g><g transform='translate(310,340) rotate(5) scale(0.9)'><path class='a' d='M0 8 L8 0 L24 0 L32 8 L16 28 Z'/><path class='a' d='M8 0 L12 8 L0 8 M24 0 L20 8 L32 8 M12 8 L16 28 L20 8'/></g><g transform='translate(55,365) rotate(25) scale(1.15)'><path class='a' d='M8 0 Q12 -14 16 0 L14 6 L18 12 L12 9 L6 12 L10 6 Z'/><circle class='c' cx='12' cy='-2' r='2'/></g><g transform='translate(200,375) rotate(-4)'><path class='b' d='M0 12 Q0 -8 24 -8 Q48 -8 48 12'/><path class='c' d='M6 12 Q6 -2 24 -2 Q42 -2 42 12'/><path class='c' d='M12 12 Q12 4 24 4 Q36 4 36 12'/></g><g transform='translate(380,375) rotate(-10)'><circle class='a' cx='0' cy='0' r='8'/><path class='c' d='M0 -14 L0 -10 M0 10 L0 14 M-14 0 L-10 0 M10 0 L14 0 M-10 -10 L-7 -7 M7 7 L10 10 M-10 10 L-7 7 M7 -7 L10 -10'/></g></svg>`
|
|
||||||
|
|
||||||
// 绘制 SVG 图案背景到 canvas
|
|
||||||
const drawPatternBackground = async (ctx: CanvasRenderingContext2D, width: number, height: number, bgColor: string, isDark: boolean) => {
|
|
||||||
// 先填充背景色
|
|
||||||
ctx.fillStyle = bgColor
|
|
||||||
ctx.fillRect(0, 0, width, height)
|
|
||||||
|
|
||||||
// 加载 SVG 图案
|
|
||||||
const svgString = isDark ? PATTERN_DARK_SVG : PATTERN_LIGHT_SVG
|
|
||||||
const blob = new Blob([svgString], { type: 'image/svg+xml' })
|
|
||||||
const url = URL.createObjectURL(blob)
|
|
||||||
|
|
||||||
return new Promise<void>((resolve) => {
|
|
||||||
const img = new window.Image()
|
|
||||||
img.onload = () => {
|
|
||||||
// 平铺绘制图案
|
|
||||||
const pattern = ctx.createPattern(img, 'repeat')
|
|
||||||
if (pattern) {
|
|
||||||
ctx.fillStyle = pattern
|
|
||||||
ctx.fillRect(0, 0, width, height)
|
|
||||||
}
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
resolve()
|
|
||||||
}
|
|
||||||
img.onerror = () => {
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
resolve()
|
|
||||||
}
|
|
||||||
img.src = url
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TopContact {
|
interface TopContact {
|
||||||
username: string
|
username: string
|
||||||
displayName: string
|
displayName: string
|
||||||
|
|||||||
360
src/pages/BizPage.scss
Normal file
360
src/pages/BizPage.scss
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
.biz-account-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
background-color: var(--bg-secondary); // 对齐会话列表背景
|
||||||
|
|
||||||
|
.biz-loading {
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.biz-account-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background-color: var(--primary-light) !important;
|
||||||
|
border-left: 3px solid var(--primary);
|
||||||
|
padding-left: 13px; // 补偿 border-left
|
||||||
|
}
|
||||||
|
|
||||||
|
&.pay-account {
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
&.active {
|
||||||
|
background-color: var(--primary-light) !important;
|
||||||
|
border-left: 3px solid var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.biz-avatar {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 8px; // 对齐会话列表头像圆角
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.biz-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
|
||||||
|
.biz-info-top {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.biz-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.biz-time {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.biz-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
width: fit-content;
|
||||||
|
margin-top: 2px;
|
||||||
|
|
||||||
|
&.type-service { color: #07c160; background: rgba(7, 193, 96, 0.1); }
|
||||||
|
&.type-sub { color: var(--primary); background: var(--primary-light); }
|
||||||
|
&.type-enterprise { color: #f5222d; background: rgba(245, 34, 45, 0.1); }
|
||||||
|
&.type-unknown { color: var(--text-tertiary); background: var(--bg-tertiary); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.biz-main {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: var(--bg-secondary); // 对齐聊天页背景
|
||||||
|
|
||||||
|
.main-header {
|
||||||
|
height: 56px;
|
||||||
|
padding: 0 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 24px 16px;
|
||||||
|
background: var(--chat-pattern);
|
||||||
|
background-color: var(--bg-tertiary); // 对齐聊天背景色
|
||||||
|
|
||||||
|
.messages-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px; // 减小间距,因为有了 time-divider
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-divider {
|
||||||
|
text-align: center;
|
||||||
|
margin: 16px 0 8px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 11px;
|
||||||
|
border-radius: 4px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 占位状态:对齐 Chat 页面风格
|
||||||
|
.biz-no-record-container {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
text-align: center;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
|
||||||
|
.no-record-icon {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
opacity: 0.5;
|
||||||
|
|
||||||
|
svg { width: 32px; height: 32px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
max-width: 280px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.biz-loading-more {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pay-card {
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
||||||
|
|
||||||
|
.pay-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
.pay-icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.pay-icon-placeholder {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #07c160;
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pay-title {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pay-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pay-footer {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-card {
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
||||||
|
|
||||||
|
.main-article {
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
.article-cover {
|
||||||
|
width: 100%;
|
||||||
|
height: 220px;
|
||||||
|
object-fit: cover;
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-overlay {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: 16px;
|
||||||
|
background: linear-gradient(transparent, rgba(0,0,0,0.8));
|
||||||
|
|
||||||
|
.article-title {
|
||||||
|
color: white;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.4;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-digest {
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-articles {
|
||||||
|
.sub-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover { background-color: var(--bg-hover); }
|
||||||
|
|
||||||
|
.sub-title {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding-right: 12px;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-cover {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 4px;
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.biz-empty-state {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--bg-tertiary); // 对齐 Chat 页面空白背景
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-radius: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
|
||||||
|
svg { width: 40px; height: 40px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
p { color: var(--text-tertiary); font-size: 14px; }
|
||||||
|
}
|
||||||
336
src/pages/BizPage.tsx
Normal file
336
src/pages/BizPage.tsx
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||||
|
import { useThemeStore } from '../stores/themeStore';
|
||||||
|
import { Newspaper, MessageSquareOff } from 'lucide-react';
|
||||||
|
import './BizPage.scss';
|
||||||
|
|
||||||
|
export interface BizAccount {
|
||||||
|
username: string;
|
||||||
|
name: string;
|
||||||
|
avatar: string;
|
||||||
|
type: string;
|
||||||
|
last_time: number;
|
||||||
|
formatted_last_time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BizAccountList: React.FC<{
|
||||||
|
onSelect: (account: BizAccount) => void;
|
||||||
|
selectedUsername?: string;
|
||||||
|
searchKeyword?: string;
|
||||||
|
}> = ({ onSelect, selectedUsername, searchKeyword }) => {
|
||||||
|
const [accounts, setAccounts] = useState<BizAccount[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const [myWxid, setMyWxid] = useState<string>('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const initWxid = async () => {
|
||||||
|
try {
|
||||||
|
const wxid = await window.electronAPI.config.get('myWxid');
|
||||||
|
if (wxid) {
|
||||||
|
setMyWxid(wxid as string);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("获取 myWxid 失败:", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
initWxid().then(_r => { });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetch = async () => {
|
||||||
|
if (!myWxid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await window.electronAPI.biz.listAccounts(myWxid)
|
||||||
|
setAccounts(res || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取服务号列表失败:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetch().then(_r => { } );
|
||||||
|
}, [myWxid]);
|
||||||
|
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
let result = accounts;
|
||||||
|
if (searchKeyword) {
|
||||||
|
const q = searchKeyword.toLowerCase();
|
||||||
|
result = accounts.filter(a =>
|
||||||
|
(a.name && a.name.toLowerCase().includes(q)) ||
|
||||||
|
(a.username && a.username.toLowerCase().includes(q))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result.sort((a, b) => {
|
||||||
|
if (a.username === 'gh_3dfda90e39d6') return -1; // 微信支付置顶
|
||||||
|
if (b.username === 'gh_3dfda90e39d6') return 1;
|
||||||
|
return b.last_time - a.last_time;
|
||||||
|
});
|
||||||
|
}, [accounts, searchKeyword]);
|
||||||
|
|
||||||
|
|
||||||
|
if (loading) return <div className="biz-loading">加载中...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="biz-account-list">
|
||||||
|
{filtered.map(item => (
|
||||||
|
<div
|
||||||
|
key={item.username}
|
||||||
|
onClick={() => onSelect(item)}
|
||||||
|
className={`biz-account-item ${selectedUsername === item.username ? 'active' : ''} ${item.username === 'gh_3dfda90e39d6' ? 'pay-account' : ''}`}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={item.avatar}
|
||||||
|
className="biz-avatar"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
<div className="biz-info">
|
||||||
|
<div className="biz-info-top">
|
||||||
|
<span className="biz-name">{item.name || item.username}</span>
|
||||||
|
<span className="biz-time">{item.formatted_last_time}</span>
|
||||||
|
</div>
|
||||||
|
{/*{item.username === 'gh_3dfda90e39d6' && (*/}
|
||||||
|
{/* <div className="biz-badge type-service">微信支付</div>*/}
|
||||||
|
{/*)}*/}
|
||||||
|
|
||||||
|
<div className={`biz-badge ${
|
||||||
|
item.type === '1' ? 'type-service' :
|
||||||
|
item.type === '0' ? 'type-sub' :
|
||||||
|
item.type === '2' ? 'type-enterprise' :
|
||||||
|
item.type === '3' ? 'type-enterprise' : 'type-unknown'
|
||||||
|
}`}>
|
||||||
|
{item.type === '0' ? '公众号' : item.type === '1' ? '服务号' : item.type === '2' ? '企业号' : item.type === '3' ? '企业附属' : '未知'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BizMessageArea: React.FC<{
|
||||||
|
account: BizAccount | null;
|
||||||
|
}> = ({ account }) => {
|
||||||
|
const themeMode = useThemeStore((state) => state.themeMode);
|
||||||
|
const [messages, setMessages] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
const [hasMore, setHasMore] = useState(true);
|
||||||
|
const limit = 20;
|
||||||
|
const messageListRef = useRef<HTMLDivElement>(null);
|
||||||
|
const lastScrollHeightRef = useRef<number>(0);
|
||||||
|
const isInitialLoadRef = useRef<boolean>(true);
|
||||||
|
|
||||||
|
const [myWxid, setMyWxid] = useState<string>('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const initWxid = async () => {
|
||||||
|
try {
|
||||||
|
const wxid = await window.electronAPI.config.get('myWxid');
|
||||||
|
if (wxid) {
|
||||||
|
setMyWxid(wxid as string);
|
||||||
|
}
|
||||||
|
} catch (e) { }
|
||||||
|
};
|
||||||
|
initWxid();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isDark = useMemo(() => {
|
||||||
|
if (themeMode === 'dark') return true;
|
||||||
|
if (themeMode === 'system') {
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}, [themeMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (account && myWxid) {
|
||||||
|
setMessages([]);
|
||||||
|
setOffset(0);
|
||||||
|
setHasMore(true);
|
||||||
|
isInitialLoadRef.current = true;
|
||||||
|
loadMessages(account.username, 0);
|
||||||
|
}
|
||||||
|
}, [account, myWxid]);
|
||||||
|
|
||||||
|
const loadMessages = async (username: string, currentOffset: number) => {
|
||||||
|
if (loading || !myWxid) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
if (messageListRef.current) {
|
||||||
|
lastScrollHeightRef.current = messageListRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let res;
|
||||||
|
if (username === 'gh_3dfda90e39d6') {
|
||||||
|
res = await window.electronAPI.biz.listPayRecords(myWxid, limit, currentOffset);
|
||||||
|
} else {
|
||||||
|
res = await window.electronAPI.biz.listMessages(username, myWxid, limit, currentOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res) {
|
||||||
|
if (res.length < limit) setHasMore(false);
|
||||||
|
|
||||||
|
setMessages(prev => {
|
||||||
|
const combined = currentOffset === 0 ? res : [...res, ...prev];
|
||||||
|
const uniqueMessages = Array.from(new Map(combined.map(item => [item.local_id || item.create_time, item])).values());
|
||||||
|
return uniqueMessages.sort((a, b) => a.create_time - b.create_time);
|
||||||
|
});
|
||||||
|
setOffset(currentOffset + limit);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('加载消息失败:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!messageListRef.current) return;
|
||||||
|
|
||||||
|
if (isInitialLoadRef.current && messages.length > 0) {
|
||||||
|
messageListRef.current.scrollTop = messageListRef.current.scrollHeight;
|
||||||
|
isInitialLoadRef.current = false;
|
||||||
|
} else if (messages.length > 0 && !isInitialLoadRef.current && !loading) {
|
||||||
|
|
||||||
|
const newScrollHeight = messageListRef.current.scrollHeight;
|
||||||
|
const heightDiff = newScrollHeight - lastScrollHeightRef.current;
|
||||||
|
if (heightDiff > 0 && messageListRef.current.scrollTop < 100) {
|
||||||
|
messageListRef.current.scrollTop += heightDiff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [messages, loading]);
|
||||||
|
|
||||||
|
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||||
|
const target = e.currentTarget;
|
||||||
|
// 向上滚动到顶部附近触发加载更多(更旧的消息)
|
||||||
|
if (target.scrollTop < 50) {
|
||||||
|
if (!loading && hasMore && account) {
|
||||||
|
loadMessages(account.username, offset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
return (
|
||||||
|
<div className="biz-empty-state">
|
||||||
|
<div className="empty-icon"><Newspaper size={40} /></div>
|
||||||
|
<p>请选择一个服务号查看消息</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatMessageTime = (timestamp: number) => {
|
||||||
|
if (!timestamp) return '';
|
||||||
|
const date = new Date(timestamp * 1000);
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const isToday = date.toDateString() === now.toDateString();
|
||||||
|
if (isToday) {
|
||||||
|
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const yesterday = new Date(now);
|
||||||
|
yesterday.setDate(now.getDate() - 1);
|
||||||
|
if (date.toDateString() === yesterday.toDateString()) {
|
||||||
|
return `昨天 ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isThisYear = date.getFullYear() === now.getFullYear();
|
||||||
|
if (isThisYear) {
|
||||||
|
return `${date.getMonth() + 1}月${date.getDate()}日 ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultImage = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MDAiIGhlaWdodD0iMTgwIj48cmVjdCB3aWR0aD0iNDAwIiBoZWlnaHQ9IjE4MCIgZmlsbD0iI2Y1ZjVmNSIvPjwvc3ZnPg==';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`biz-main ${isDark ? 'dark' : ''}`}>
|
||||||
|
<div className="main-header">
|
||||||
|
<h2>{account.name}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="message-container" onScroll={handleScroll} ref={messageListRef}>
|
||||||
|
<div className="messages-wrapper">
|
||||||
|
{hasMore && messages.length > 0 && (
|
||||||
|
<div className="biz-loading-more">{loading ? '加载中...' : '向上滚动加载更多历史消息'}</div>
|
||||||
|
)}
|
||||||
|
{!loading && messages.length === 0 && (
|
||||||
|
<div className="biz-no-record-container">
|
||||||
|
<div className="no-record-icon">
|
||||||
|
<MessageSquareOff size={48} />
|
||||||
|
</div>
|
||||||
|
<h3>暂无本地记录</h3>
|
||||||
|
<p>该公众号在当前数据库中没有可显示的聊天历史</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{messages.map((msg, index) => {
|
||||||
|
const showTime = true;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={msg.local_id || index}>
|
||||||
|
{showTime && (
|
||||||
|
<div className="time-divider">
|
||||||
|
<span>{formatMessageTime(msg.create_time)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{account.username === 'gh_3dfda90e39d6' ? (
|
||||||
|
<div className="pay-card">
|
||||||
|
<div className="pay-header">
|
||||||
|
{msg.merchant_icon ? <img src={msg.merchant_icon} className="pay-icon" alt=""/> : <div className="pay-icon-placeholder">¥</div>}
|
||||||
|
<span>{msg.merchant_name || '微信支付'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="pay-title">{msg.title}</div>
|
||||||
|
<div className="pay-desc">{msg.description}</div>
|
||||||
|
{/* <div className="pay-footer">{msg.formatted_time}</div> */}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="article-card">
|
||||||
|
<div onClick={() => window.electronAPI.shell.openExternal(msg.url)} className="main-article">
|
||||||
|
<img src={msg.cover || defaultImage} className="article-cover" alt=""/>
|
||||||
|
<div className="article-overlay"><h3 className="article-title">{msg.title}</h3></div>
|
||||||
|
</div>
|
||||||
|
{msg.des && <div className="article-digest">{msg.des}</div>}
|
||||||
|
{msg.content_list && msg.content_list.length > 1 && (
|
||||||
|
<div className="sub-articles">
|
||||||
|
{msg.content_list.slice(1).map((item: any, idx: number) => (
|
||||||
|
<div key={idx} onClick={() => window.electronAPI.shell.openExternal(item.url)} className="sub-item">
|
||||||
|
<span className="sub-title">{item.title}</span>
|
||||||
|
{item.cover && <img src={item.cover} className="sub-cover" alt=""/>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{loading && offset === 0 && <div className="biz-loading-more">加载中...</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const BizPage: React.FC = () => {
|
||||||
|
const [selectedAccount, setSelectedAccount] = useState<BizAccount | null>(null);
|
||||||
|
return (
|
||||||
|
<div className="biz-page">
|
||||||
|
<div className="biz-sidebar">
|
||||||
|
<BizAccountList onSelect={setSelectedAccount} selectedUsername={selectedAccount?.username} />
|
||||||
|
</div>
|
||||||
|
<BizMessageArea account={selectedAccount} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BizPage;
|
||||||
@@ -2127,6 +2127,24 @@
|
|||||||
display: block;
|
display: block;
|
||||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08);
|
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08);
|
||||||
-webkit-app-region: no-drag;
|
-webkit-app-region: no-drag;
|
||||||
|
transition: opacity 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-message.pending {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-message.ready {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-stage {
|
||||||
|
display: inline-block;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-stage.locked {
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-message-wrapper {
|
.image-message-wrapper {
|
||||||
@@ -2694,43 +2712,76 @@
|
|||||||
|
|
||||||
// 会话详情面板
|
// 会话详情面板
|
||||||
.detail-panel {
|
.detail-panel {
|
||||||
width: 280px;
|
width: clamp(280px, 25vw, 360px);
|
||||||
min-width: 280px;
|
min-width: 280px;
|
||||||
background: var(--card-bg);
|
max-width: 360px;
|
||||||
border-left: 1px solid var(--border-color);
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
color-mix(in srgb, var(--card-bg) 94%, #fff 6%) 0%,
|
||||||
|
var(--card-bg) 100%
|
||||||
|
);
|
||||||
|
border-left: 1px solid color-mix(in srgb, var(--border-color) 82%, transparent);
|
||||||
|
box-shadow: -14px 0 28px rgba(0, 0, 0, 0.07);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
animation: slideInRight 0.2s ease;
|
animation: slideInRight 0.28s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
will-change: transform, opacity;
|
||||||
|
|
||||||
.detail-header {
|
.detail-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 16px;
|
gap: 8px;
|
||||||
|
padding: 14px 14px 12px;
|
||||||
|
background: color-mix(in srgb, var(--card-bg) 92%, #fff 8%);
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
|
||||||
|
.detail-title-wrap {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
h4 {
|
h4 {
|
||||||
font-size: 15px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-title-sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
.close-btn {
|
.close-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 4px;
|
padding: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: all 0.18s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
transform: rotate(90deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2762,69 +2813,135 @@
|
|||||||
.detail-content {
|
.detail-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 16px;
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
&::-webkit-scrollbar {
|
||||||
width: 4px;
|
width: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::-webkit-scrollbar-thumb {
|
&::-webkit-scrollbar-thumb {
|
||||||
background: var(--text-tertiary);
|
background: color-mix(in srgb, var(--text-tertiary) 68%, transparent);
|
||||||
border-radius: 2px;
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-overview-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--bg-secondary) 84%, transparent);
|
||||||
|
animation: detailCardEnter 0.24s ease both;
|
||||||
|
|
||||||
|
.detail-overview-avatar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent);
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-overview-meta {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-overview-name {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-overview-sub {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.25;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-section {
|
.detail-section {
|
||||||
margin-bottom: 20px;
|
margin: 0;
|
||||||
|
padding: 12px;
|
||||||
&:last-child {
|
border-radius: 12px;
|
||||||
margin-bottom: 0;
|
border: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent);
|
||||||
}
|
background: color-mix(in srgb, var(--bg-secondary) 86%, transparent);
|
||||||
|
animation: detailCardEnter 0.24s ease both;
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
margin-bottom: 12px;
|
margin-bottom: 10px;
|
||||||
text-transform: uppercase;
|
letter-spacing: 0.3px;
|
||||||
letter-spacing: 0.5px;
|
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
opacity: 0.7;
|
color: var(--primary);
|
||||||
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-stats-meta {
|
.detail-stats-meta {
|
||||||
margin-top: -6px;
|
margin-top: -2px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
|
background: color-mix(in srgb, var(--card-bg) 84%, transparent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-section:nth-child(2) {
|
||||||
|
animation-delay: 0.03s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-section:nth-child(3) {
|
||||||
|
animation-delay: 0.06s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-section:nth-child(4) {
|
||||||
|
animation-delay: 0.09s;
|
||||||
|
}
|
||||||
|
|
||||||
.detail-item {
|
.detail-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px dashed color-mix(in srgb, var(--border-color) 76%, transparent);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|
||||||
&:last-child {
|
&:last-child {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
svg {
|
> svg {
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
width: 88px;
|
||||||
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.value {
|
.value {
|
||||||
@@ -2833,22 +2950,27 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
user-select: text;
|
user-select: text;
|
||||||
|
line-height: 1.35;
|
||||||
|
|
||||||
&.highlight {
|
&.highlight {
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
font-size: 21px;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-inline-btn {
|
.detail-inline-btn {
|
||||||
border: none;
|
border: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent);
|
||||||
background: var(--bg-secondary);
|
background: color-mix(in srgb, var(--card-bg) 90%, transparent);
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
border-radius: 6px;
|
border-radius: 999px;
|
||||||
padding: 4px 8px;
|
padding: 5px 10px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: all 0.16s ease;
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
@@ -2856,6 +2978,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
&:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2868,12 +2991,12 @@
|
|||||||
height: 22px;
|
height: 22px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: 6px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
opacity: 0;
|
opacity: 0.2;
|
||||||
transition: opacity 0.15s, color 0.15s, background 0.15s;
|
transition: opacity 0.15s, color 0.15s, background 0.15s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
@@ -2889,18 +3012,27 @@
|
|||||||
&:hover .copy-btn {
|
&:hover .copy-btn {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:focus-within .copy-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-basic-section .label {
|
||||||
|
width: 70px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-list {
|
.table-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-table-placeholder {
|
.detail-table-placeholder {
|
||||||
padding: 10px 12px;
|
padding: 11px 12px;
|
||||||
background: var(--bg-secondary);
|
background: color-mix(in srgb, var(--card-bg) 84%, transparent);
|
||||||
border-radius: 8px;
|
border: 1px dashed color-mix(in srgb, var(--border-color) 76%, transparent);
|
||||||
|
border-radius: 10px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
@@ -2910,18 +3042,64 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
background: var(--bg-secondary);
|
background: color-mix(in srgb, var(--card-bg) 90%, transparent);
|
||||||
border-radius: 8px;
|
border: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent);
|
||||||
|
border-radius: 10px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
transition: transform 0.16s ease, border-color 0.16s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 26%, var(--border-color));
|
||||||
|
}
|
||||||
|
|
||||||
.db-name {
|
.db-name {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
max-width: 62%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-count {
|
.table-count {
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-detail-panel {
|
||||||
|
.detail-content {
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-overview-card {
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
.detail-overview-meta {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-overview-close-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: color-mix(in srgb, var(--card-bg) 88%, transparent);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: all 0.16s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
transform: rotate(90deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3122,6 +3300,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes detailCardEnter {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* 语音转文字按钮样式 */
|
/* 语音转文字按钮样式 */
|
||||||
.voice-transcribe-btn {
|
.voice-transcribe-btn {
|
||||||
width: 28px;
|
width: 28px;
|
||||||
@@ -4487,6 +4677,32 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 公众号入口样式
|
||||||
|
.session-item.biz-entry {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--hover-bg, rgba(0,0,0,0.05));
|
||||||
|
}
|
||||||
|
|
||||||
|
.biz-entry-avatar {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #07c160;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
// 消息信息弹窗
|
// 消息信息弹窗
|
||||||
.message-info-overlay {
|
.message-info-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -238,7 +238,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.scene-message.sent .scene-avatar {
|
.scene-message.sent .scene-avatar {
|
||||||
border-color: color-mix(in srgb, var(--primary) 30%, var(--bg-tertiary, rgba(0, 0, 0, 0.08)));
|
border-color: rgba(var(--ar-primary-rgb), 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dual-stat-grid {
|
.dual-stat-grid {
|
||||||
@@ -981,4 +981,4 @@
|
|||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { Check, Download, Image, SlidersHorizontal, X } from 'lucide-react'
|
||||||
|
import html2canvas from 'html2canvas'
|
||||||
import ReportHeatmap from '../components/ReportHeatmap'
|
import ReportHeatmap from '../components/ReportHeatmap'
|
||||||
import ReportWordCloud from '../components/ReportWordCloud'
|
import ReportWordCloud from '../components/ReportWordCloud'
|
||||||
|
import { useThemeStore } from '../stores/themeStore'
|
||||||
|
import { drawPatternBackground } from '../utils/reportExport'
|
||||||
import './AnnualReportWindow.scss'
|
import './AnnualReportWindow.scss'
|
||||||
import './DualReportWindow.scss'
|
import './DualReportWindow.scss'
|
||||||
|
|
||||||
@@ -66,6 +70,12 @@ interface DualReportData {
|
|||||||
streak?: { days: number; startDate: string; endDate: string }
|
streak?: { days: number; startDate: string; endDate: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SectionInfo {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
ref: React.RefObject<HTMLElement | null>
|
||||||
|
}
|
||||||
|
|
||||||
function DualReportWindow() {
|
function DualReportWindow() {
|
||||||
const [reportData, setReportData] = useState<DualReportData | null>(null)
|
const [reportData, setReportData] = useState<DualReportData | null>(null)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
@@ -75,6 +85,29 @@ function DualReportWindow() {
|
|||||||
const [myEmojiUrl, setMyEmojiUrl] = useState<string | null>(null)
|
const [myEmojiUrl, setMyEmojiUrl] = useState<string | null>(null)
|
||||||
const [friendEmojiUrl, setFriendEmojiUrl] = useState<string | null>(null)
|
const [friendEmojiUrl, setFriendEmojiUrl] = useState<string | null>(null)
|
||||||
const [activeWordCloudTab, setActiveWordCloudTab] = useState<'shared' | 'my' | 'friend'>('shared')
|
const [activeWordCloudTab, setActiveWordCloudTab] = useState<'shared' | 'my' | 'friend'>('shared')
|
||||||
|
const [isExporting, setIsExporting] = useState(false)
|
||||||
|
const [exportProgress, setExportProgress] = useState('')
|
||||||
|
const [showExportModal, setShowExportModal] = useState(false)
|
||||||
|
const [selectedSections, setSelectedSections] = useState<Set<string>>(new Set())
|
||||||
|
const [fabOpen, setFabOpen] = useState(false)
|
||||||
|
const [exportMode, setExportMode] = useState<'separate' | 'long'>('separate')
|
||||||
|
|
||||||
|
const { themeMode } = useThemeStore()
|
||||||
|
|
||||||
|
const sectionRefs = {
|
||||||
|
cover: useRef<HTMLElement>(null),
|
||||||
|
firstChat: useRef<HTMLElement>(null),
|
||||||
|
yearFirstChat: useRef<HTMLElement>(null),
|
||||||
|
heatmap: useRef<HTMLElement>(null),
|
||||||
|
initiative: useRef<HTMLElement>(null),
|
||||||
|
response: useRef<HTMLElement>(null),
|
||||||
|
streak: useRef<HTMLElement>(null),
|
||||||
|
wordCloud: useRef<HTMLElement>(null),
|
||||||
|
stats: useRef<HTMLElement>(null),
|
||||||
|
ending: useRef<HTMLElement>(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
||||||
@@ -151,6 +184,351 @@ function DualReportWindow() {
|
|||||||
void loadEmojis()
|
void loadEmojis()
|
||||||
}, [reportData])
|
}, [reportData])
|
||||||
|
|
||||||
|
const formatFileYearLabel = (year: number) => (year === 0 ? '历史以来' : String(year))
|
||||||
|
|
||||||
|
const sanitizeFileNameSegment = (value: string) => {
|
||||||
|
const sanitized = value.replace(/[\\/:*?"<>|]/g, '_').trim()
|
||||||
|
return sanitized || '好友'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAvailableSections = (): SectionInfo[] => {
|
||||||
|
if (!reportData) return []
|
||||||
|
|
||||||
|
const sections: SectionInfo[] = [
|
||||||
|
{ id: 'cover', name: '封面', ref: sectionRefs.cover },
|
||||||
|
{ id: 'firstChat', name: '首次聊天', ref: sectionRefs.firstChat }
|
||||||
|
]
|
||||||
|
|
||||||
|
if (reportData.yearFirstChat && (!reportData.firstChat || reportData.yearFirstChat.createTime !== reportData.firstChat.createTime)) {
|
||||||
|
sections.push({ id: 'yearFirstChat', name: '第一段对话', ref: sectionRefs.yearFirstChat })
|
||||||
|
}
|
||||||
|
if (reportData.heatmap) {
|
||||||
|
sections.push({ id: 'heatmap', name: '作息规律', ref: sectionRefs.heatmap })
|
||||||
|
}
|
||||||
|
if (reportData.initiative) {
|
||||||
|
sections.push({ id: 'initiative', name: '主动性', ref: sectionRefs.initiative })
|
||||||
|
}
|
||||||
|
if (reportData.response) {
|
||||||
|
sections.push({ id: 'response', name: '回应速度', ref: sectionRefs.response })
|
||||||
|
}
|
||||||
|
if (reportData.streak) {
|
||||||
|
sections.push({ id: 'streak', name: '最长连续聊天', ref: sectionRefs.streak })
|
||||||
|
}
|
||||||
|
|
||||||
|
sections.push({ id: 'wordCloud', name: '常用语', ref: sectionRefs.wordCloud })
|
||||||
|
sections.push({ id: 'stats', name: '年度统计', ref: sectionRefs.stats })
|
||||||
|
sections.push({ id: 'ending', name: '尾声', ref: sectionRefs.ending })
|
||||||
|
|
||||||
|
return sections
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportSection = async (section: SectionInfo): Promise<{ name: string; data: string } | null> => {
|
||||||
|
const element = section.ref.current
|
||||||
|
if (!element) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const OUTPUT_WIDTH = 1920
|
||||||
|
const OUTPUT_HEIGHT = 1080
|
||||||
|
let wordCloudInner: HTMLElement | null = null
|
||||||
|
let wordTags: NodeListOf<HTMLElement> | null = null
|
||||||
|
let wordCloudOriginalStyle = ''
|
||||||
|
const wordTagOriginalStyles: string[] = []
|
||||||
|
const originalStyle = element.style.cssText
|
||||||
|
|
||||||
|
try {
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (selection && selection.rangeCount > 0) selection.removeAllRanges()
|
||||||
|
const activeEl = document.activeElement as HTMLElement | null
|
||||||
|
activeEl?.blur?.()
|
||||||
|
document.body.classList.add('exporting-snapshot')
|
||||||
|
document.documentElement.classList.add('exporting-snapshot')
|
||||||
|
|
||||||
|
element.style.minHeight = 'auto'
|
||||||
|
element.style.padding = '40px 20px'
|
||||||
|
element.style.background = 'transparent'
|
||||||
|
element.style.backgroundColor = 'transparent'
|
||||||
|
element.style.boxShadow = 'none'
|
||||||
|
|
||||||
|
wordCloudInner = element.querySelector('.word-cloud-inner') as HTMLElement | null
|
||||||
|
wordTags = element.querySelectorAll('.word-tag') as NodeListOf<HTMLElement>
|
||||||
|
|
||||||
|
if (wordCloudInner) {
|
||||||
|
wordCloudOriginalStyle = wordCloudInner.style.cssText
|
||||||
|
wordCloudInner.style.transform = 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
wordTags.forEach((tag, index) => {
|
||||||
|
wordTagOriginalStyles[index] = tag.style.cssText
|
||||||
|
tag.style.opacity = String(tag.style.getPropertyValue('--final-opacity') || '1')
|
||||||
|
tag.style.animation = 'none'
|
||||||
|
})
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
|
||||||
|
const computedStyle = getComputedStyle(document.documentElement)
|
||||||
|
const bgColor = computedStyle.getPropertyValue('--bg-primary').trim() || '#F9F8F6'
|
||||||
|
|
||||||
|
const canvas = await html2canvas(element, {
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
scale: 2,
|
||||||
|
useCORS: true,
|
||||||
|
allowTaint: true,
|
||||||
|
logging: false,
|
||||||
|
onclone: (clonedDoc) => {
|
||||||
|
clonedDoc.body.classList.add('exporting-snapshot')
|
||||||
|
clonedDoc.documentElement.classList.add('exporting-snapshot')
|
||||||
|
clonedDoc.getSelection?.()?.removeAllRanges()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const outputCanvas = document.createElement('canvas')
|
||||||
|
outputCanvas.width = OUTPUT_WIDTH
|
||||||
|
outputCanvas.height = OUTPUT_HEIGHT
|
||||||
|
const ctx = outputCanvas.getContext('2d')
|
||||||
|
if (!ctx) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDark = themeMode === 'dark'
|
||||||
|
await drawPatternBackground(ctx, OUTPUT_WIDTH, OUTPUT_HEIGHT, bgColor, isDark)
|
||||||
|
|
||||||
|
const PADDING = 80
|
||||||
|
const contentWidth = OUTPUT_WIDTH - PADDING * 2
|
||||||
|
const contentHeight = OUTPUT_HEIGHT - PADDING * 2
|
||||||
|
const srcRatio = canvas.width / canvas.height
|
||||||
|
const dstRatio = contentWidth / contentHeight
|
||||||
|
|
||||||
|
let drawWidth: number
|
||||||
|
let drawHeight: number
|
||||||
|
let drawX: number
|
||||||
|
let drawY: number
|
||||||
|
|
||||||
|
if (srcRatio > dstRatio) {
|
||||||
|
drawWidth = contentWidth
|
||||||
|
drawHeight = contentWidth / srcRatio
|
||||||
|
drawX = PADDING
|
||||||
|
drawY = PADDING + (contentHeight - drawHeight) / 2
|
||||||
|
} else {
|
||||||
|
drawHeight = contentHeight
|
||||||
|
drawWidth = contentHeight * srcRatio
|
||||||
|
drawX = PADDING + (contentWidth - drawWidth) / 2
|
||||||
|
drawY = PADDING
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.drawImage(canvas, drawX, drawY, drawWidth, drawHeight)
|
||||||
|
return { name: section.name, data: outputCanvas.toDataURL('image/png') }
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
element.style.cssText = originalStyle
|
||||||
|
if (wordCloudInner) {
|
||||||
|
wordCloudInner.style.cssText = wordCloudOriginalStyle
|
||||||
|
}
|
||||||
|
wordTags?.forEach((tag, index) => {
|
||||||
|
tag.style.cssText = wordTagOriginalStyles[index]
|
||||||
|
})
|
||||||
|
document.body.classList.remove('exporting-snapshot')
|
||||||
|
document.documentElement.classList.remove('exporting-snapshot')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportFullReport = async (filterIds?: Set<string>) => {
|
||||||
|
if (!containerRef.current || !reportData) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsExporting(true)
|
||||||
|
setExportProgress('正在生成长图...')
|
||||||
|
|
||||||
|
let wordCloudInner: HTMLElement | null = null
|
||||||
|
let wordTags: NodeListOf<HTMLElement> | null = null
|
||||||
|
let wordCloudOriginalStyle = ''
|
||||||
|
const wordTagOriginalStyles: string[] = []
|
||||||
|
const container = containerRef.current
|
||||||
|
const sections = Array.from(container.querySelectorAll('.section')) as HTMLElement[]
|
||||||
|
const originalStyles = sections.map((section) => section.style.cssText)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (selection && selection.rangeCount > 0) selection.removeAllRanges()
|
||||||
|
const activeEl = document.activeElement as HTMLElement | null
|
||||||
|
activeEl?.blur?.()
|
||||||
|
document.body.classList.add('exporting-snapshot')
|
||||||
|
document.documentElement.classList.add('exporting-snapshot')
|
||||||
|
|
||||||
|
sections.forEach((section) => {
|
||||||
|
section.style.minHeight = 'auto'
|
||||||
|
section.style.padding = '40px 0'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (filterIds) {
|
||||||
|
getAvailableSections().forEach((section) => {
|
||||||
|
if (!filterIds.has(section.id) && section.ref.current) {
|
||||||
|
section.ref.current.style.display = 'none'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
wordCloudInner = container.querySelector('.word-cloud-inner') as HTMLElement | null
|
||||||
|
wordTags = container.querySelectorAll('.word-tag') as NodeListOf<HTMLElement>
|
||||||
|
|
||||||
|
if (wordCloudInner) {
|
||||||
|
wordCloudOriginalStyle = wordCloudInner.style.cssText
|
||||||
|
wordCloudInner.style.transform = 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
wordTags.forEach((tag, index) => {
|
||||||
|
wordTagOriginalStyles[index] = tag.style.cssText
|
||||||
|
tag.style.opacity = String(tag.style.getPropertyValue('--final-opacity') || '1')
|
||||||
|
tag.style.animation = 'none'
|
||||||
|
})
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||||
|
|
||||||
|
const computedStyle = getComputedStyle(document.documentElement)
|
||||||
|
const bgColor = computedStyle.getPropertyValue('--bg-primary').trim() || '#F9F8F6'
|
||||||
|
|
||||||
|
const canvas = await html2canvas(container, {
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
scale: 2,
|
||||||
|
useCORS: true,
|
||||||
|
allowTaint: true,
|
||||||
|
logging: false,
|
||||||
|
onclone: (clonedDoc) => {
|
||||||
|
clonedDoc.body.classList.add('exporting-snapshot')
|
||||||
|
clonedDoc.documentElement.classList.add('exporting-snapshot')
|
||||||
|
clonedDoc.getSelection?.()?.removeAllRanges()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const outputCanvas = document.createElement('canvas')
|
||||||
|
outputCanvas.width = canvas.width
|
||||||
|
outputCanvas.height = canvas.height
|
||||||
|
const ctx = outputCanvas.getContext('2d')
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('无法创建导出画布')
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDark = themeMode === 'dark'
|
||||||
|
await drawPatternBackground(ctx, canvas.width, canvas.height, bgColor, isDark)
|
||||||
|
ctx.drawImage(canvas, 0, 0)
|
||||||
|
|
||||||
|
const yearFilePrefix = formatFileYearLabel(reportData.year)
|
||||||
|
const friendFileSegment = sanitizeFileNameSegment(reportData.friendName || reportData.friendUsername)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.download = `${yearFilePrefix}双人年度报告_${friendFileSegment}${filterIds ? '_自定义' : ''}.png`
|
||||||
|
link.href = outputCanvas.toDataURL('image/png')
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
} catch (e) {
|
||||||
|
alert('导出失败: ' + String(e))
|
||||||
|
} finally {
|
||||||
|
sections.forEach((section, index) => {
|
||||||
|
section.style.cssText = originalStyles[index]
|
||||||
|
})
|
||||||
|
if (wordCloudInner) {
|
||||||
|
wordCloudInner.style.cssText = wordCloudOriginalStyle
|
||||||
|
}
|
||||||
|
wordTags?.forEach((tag, index) => {
|
||||||
|
tag.style.cssText = wordTagOriginalStyles[index]
|
||||||
|
})
|
||||||
|
document.body.classList.remove('exporting-snapshot')
|
||||||
|
document.documentElement.classList.remove('exporting-snapshot')
|
||||||
|
setIsExporting(false)
|
||||||
|
setExportProgress('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportSelectedSections = async () => {
|
||||||
|
if (!reportData) return
|
||||||
|
|
||||||
|
const sections = getAvailableSections().filter((section) => selectedSections.has(section.id))
|
||||||
|
if (sections.length === 0) {
|
||||||
|
alert('请至少选择一个板块')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exportMode === 'long') {
|
||||||
|
setShowExportModal(false)
|
||||||
|
await exportFullReport(selectedSections)
|
||||||
|
setSelectedSections(new Set())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsExporting(true)
|
||||||
|
setShowExportModal(false)
|
||||||
|
|
||||||
|
const exportedImages: Array<{ name: string; data: string }> = []
|
||||||
|
|
||||||
|
for (let index = 0; index < sections.length; index++) {
|
||||||
|
const section = sections[index]
|
||||||
|
setExportProgress(`正在导出: ${section.name} (${index + 1}/${sections.length})`)
|
||||||
|
|
||||||
|
const result = await exportSection(section)
|
||||||
|
if (result) {
|
||||||
|
exportedImages.push(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exportedImages.length === 0) {
|
||||||
|
alert('导出失败')
|
||||||
|
setIsExporting(false)
|
||||||
|
setExportProgress('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const dirResult = await window.electronAPI.dialog.openDirectory({
|
||||||
|
title: '选择导出文件夹',
|
||||||
|
properties: ['openDirectory', 'createDirectory']
|
||||||
|
})
|
||||||
|
if (dirResult.canceled || !dirResult.filePaths?.[0]) {
|
||||||
|
setIsExporting(false)
|
||||||
|
setExportProgress('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setExportProgress('正在写入文件...')
|
||||||
|
const yearFilePrefix = formatFileYearLabel(reportData.year)
|
||||||
|
const friendFileSegment = sanitizeFileNameSegment(reportData.friendName || reportData.friendUsername)
|
||||||
|
const exportResult = await window.electronAPI.annualReport.exportImages({
|
||||||
|
baseDir: dirResult.filePaths[0],
|
||||||
|
folderName: `${yearFilePrefix}双人年度报告_${friendFileSegment}_分模块`,
|
||||||
|
images: exportedImages.map((image) => ({
|
||||||
|
name: `${yearFilePrefix}双人年度报告_${friendFileSegment}_${image.name}.png`,
|
||||||
|
dataUrl: image.data
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!exportResult.success) {
|
||||||
|
alert('导出失败: ' + (exportResult.error || '未知错误'))
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsExporting(false)
|
||||||
|
setExportProgress('')
|
||||||
|
setSelectedSections(new Set())
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleSection = (id: string) => {
|
||||||
|
const next = new Set(selectedSections)
|
||||||
|
if (next.has(id)) {
|
||||||
|
next.delete(id)
|
||||||
|
} else {
|
||||||
|
next.add(id)
|
||||||
|
}
|
||||||
|
setSelectedSections(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleAll = () => {
|
||||||
|
const sections = getAvailableSections()
|
||||||
|
if (selectedSections.size === sections.length) {
|
||||||
|
setSelectedSections(new Set())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSelectedSections(new Set(sections.map((section) => section.id)))
|
||||||
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="annual-report-window loading">
|
<div className="annual-report-window loading">
|
||||||
@@ -305,7 +683,7 @@ function DualReportWindow() {
|
|||||||
if (emojiUrl) {
|
if (emojiUrl) {
|
||||||
return (
|
return (
|
||||||
<div className="report-emoji-container">
|
<div className="report-emoji-container">
|
||||||
<img src={emojiUrl} alt="表情" className="report-emoji-img" onError={(e) => {
|
<img src={emojiUrl} alt="表情" className="report-emoji-img" crossOrigin="anonymous" onError={(e) => {
|
||||||
(e.target as HTMLImageElement).style.display = 'none';
|
(e.target as HTMLImageElement).style.display = 'none';
|
||||||
(e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style');
|
(e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style');
|
||||||
}} />
|
}} />
|
||||||
@@ -356,7 +734,7 @@ function DualReportWindow() {
|
|||||||
if (avatarUrl) {
|
if (avatarUrl) {
|
||||||
return (
|
return (
|
||||||
<div className="scene-avatar with-image">
|
<div className="scene-avatar with-image">
|
||||||
<img src={avatarUrl} alt={isSentByMe ? 'me-avatar' : 'friend-avatar'} />
|
<img src={avatarUrl} alt={isSentByMe ? 'me-avatar' : 'friend-avatar'} crossOrigin="anonymous" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -419,9 +797,99 @@ function DualReportWindow() {
|
|||||||
<div className="deco-circle c5" />
|
<div className="deco-circle c5" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className={`fab-container ${fabOpen ? 'open' : ''}`}>
|
||||||
|
<button
|
||||||
|
className="fab-item"
|
||||||
|
onClick={() => {
|
||||||
|
setFabOpen(false)
|
||||||
|
setExportMode('separate')
|
||||||
|
setShowExportModal(true)
|
||||||
|
}}
|
||||||
|
title="分模块导出"
|
||||||
|
>
|
||||||
|
<Image size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="fab-item"
|
||||||
|
onClick={() => {
|
||||||
|
setFabOpen(false)
|
||||||
|
setExportMode('long')
|
||||||
|
setShowExportModal(true)
|
||||||
|
}}
|
||||||
|
title="自定义导出长图"
|
||||||
|
>
|
||||||
|
<SlidersHorizontal size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="fab-item"
|
||||||
|
onClick={() => {
|
||||||
|
setFabOpen(false)
|
||||||
|
void exportFullReport()
|
||||||
|
}}
|
||||||
|
title="导出长图"
|
||||||
|
>
|
||||||
|
<Download size={18} />
|
||||||
|
</button>
|
||||||
|
<button className="fab-main" onClick={() => setFabOpen(!fabOpen)}>
|
||||||
|
{fabOpen ? <X size={22} /> : <Download size={22} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isExporting && (
|
||||||
|
<div className="export-overlay">
|
||||||
|
<div className="export-progress-modal">
|
||||||
|
<div className="export-spinner">
|
||||||
|
<div className="spinner-ring"></div>
|
||||||
|
<Download size={24} className="spinner-icon" />
|
||||||
|
</div>
|
||||||
|
<p className="export-title">正在导出</p>
|
||||||
|
<p className="export-status">{exportProgress}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showExportModal && (
|
||||||
|
<div className="export-overlay" onClick={() => setShowExportModal(false)}>
|
||||||
|
<div className="export-modal section-selector" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h3>{exportMode === 'long' ? '自定义导出长图' : '选择要导出的板块'}</h3>
|
||||||
|
<button className="close-btn" onClick={() => setShowExportModal(false)}>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="section-grid">
|
||||||
|
{getAvailableSections().map((section) => (
|
||||||
|
<div
|
||||||
|
key={section.id}
|
||||||
|
className={`section-card ${selectedSections.has(section.id) ? 'selected' : ''}`}
|
||||||
|
onClick={() => toggleSection(section.id)}
|
||||||
|
>
|
||||||
|
<div className="card-check">
|
||||||
|
{selectedSections.has(section.id) && <Check size={14} />}
|
||||||
|
</div>
|
||||||
|
<span>{section.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button className="select-all-btn" onClick={toggleAll}>
|
||||||
|
{selectedSections.size === getAvailableSections().length ? '取消全选' : '全选'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="confirm-btn"
|
||||||
|
onClick={() => void exportSelectedSections()}
|
||||||
|
disabled={selectedSections.size === 0}
|
||||||
|
>
|
||||||
|
{exportMode === 'long' ? '生成长图' : '导出'} {selectedSections.size > 0 ? `(${selectedSections.size})` : ''}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="report-scroll-view">
|
<div className="report-scroll-view">
|
||||||
<div className="report-container">
|
<div className="report-container" ref={containerRef}>
|
||||||
<section className="section">
|
<section className="section" ref={sectionRefs.cover}>
|
||||||
<div className="label-text">WEFLOW · DUAL REPORT</div>
|
<div className="label-text">WEFLOW · DUAL REPORT</div>
|
||||||
<h1 className="hero-title dual-cover-title">{yearTitle}<br />双人聊天报告</h1>
|
<h1 className="hero-title dual-cover-title">{yearTitle}<br />双人聊天报告</h1>
|
||||||
<hr className="divider" />
|
<hr className="divider" />
|
||||||
@@ -433,7 +901,7 @@ function DualReportWindow() {
|
|||||||
<p className="hero-desc">每一次对话都值得被珍藏</p>
|
<p className="hero-desc">每一次对话都值得被珍藏</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="section">
|
<section className="section" ref={sectionRefs.firstChat}>
|
||||||
<div className="label-text">首次聊天</div>
|
<div className="label-text">首次聊天</div>
|
||||||
<h2 className="hero-title">故事的开始</h2>
|
<h2 className="hero-title">故事的开始</h2>
|
||||||
{firstChat ? (
|
{firstChat ? (
|
||||||
@@ -457,7 +925,7 @@ function DualReportWindow() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{yearFirstChat && (!firstChat || yearFirstChat.createTime !== firstChat.createTime) ? (
|
{yearFirstChat && (!firstChat || yearFirstChat.createTime !== firstChat.createTime) ? (
|
||||||
<section className="section">
|
<section className="section" ref={sectionRefs.yearFirstChat}>
|
||||||
<div className="label-text">第一段对话</div>
|
<div className="label-text">第一段对话</div>
|
||||||
<h2 className="hero-title">
|
<h2 className="hero-title">
|
||||||
{reportData.year === 0 ? '你们的第一段对话' : `${reportData.year}年的第一段对话`}
|
{reportData.year === 0 ? '你们的第一段对话' : `${reportData.year}年的第一段对话`}
|
||||||
@@ -473,7 +941,7 @@ function DualReportWindow() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{reportData.heatmap && (
|
{reportData.heatmap && (
|
||||||
<section className="section">
|
<section className="section" ref={sectionRefs.heatmap}>
|
||||||
<div className="label-text">聊天习惯</div>
|
<div className="label-text">聊天习惯</div>
|
||||||
<h2 className="hero-title">作息规律</h2>
|
<h2 className="hero-title">作息规律</h2>
|
||||||
{mostActive && (
|
{mostActive && (
|
||||||
@@ -486,14 +954,14 @@ function DualReportWindow() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{reportData.initiative && (
|
{reportData.initiative && (
|
||||||
<section className="section">
|
<section className="section" ref={sectionRefs.initiative}>
|
||||||
<div className="label-text">主动性</div>
|
<div className="label-text">主动性</div>
|
||||||
<h2 className="hero-title">情感的天平</h2>
|
<h2 className="hero-title">情感的天平</h2>
|
||||||
<div className="initiative-container">
|
<div className="initiative-container">
|
||||||
<div className="initiative-bar-wrapper">
|
<div className="initiative-bar-wrapper">
|
||||||
<div className="initiative-side">
|
<div className="initiative-side">
|
||||||
<div className="avatar-placeholder">
|
<div className="avatar-placeholder">
|
||||||
{reportData.selfAvatarUrl ? <img src={reportData.selfAvatarUrl} alt="me-avatar" /> : '我'}
|
{reportData.selfAvatarUrl ? <img src={reportData.selfAvatarUrl} alt="me-avatar" crossOrigin="anonymous" /> : '我'}
|
||||||
</div>
|
</div>
|
||||||
<div className="count">{reportData.initiative.initiated}次</div>
|
<div className="count">{reportData.initiative.initiated}次</div>
|
||||||
<div className="percent">{initiatedPercent.toFixed(1)}%</div>
|
<div className="percent">{initiatedPercent.toFixed(1)}%</div>
|
||||||
@@ -507,7 +975,7 @@ function DualReportWindow() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="initiative-side">
|
<div className="initiative-side">
|
||||||
<div className="avatar-placeholder">
|
<div className="avatar-placeholder">
|
||||||
{reportData.friendAvatarUrl ? <img src={reportData.friendAvatarUrl} alt="friend-avatar" /> : reportData.friendName.substring(0, 1)}
|
{reportData.friendAvatarUrl ? <img src={reportData.friendAvatarUrl} alt="friend-avatar" crossOrigin="anonymous" /> : reportData.friendName.substring(0, 1)}
|
||||||
</div>
|
</div>
|
||||||
<div className="count">{reportData.initiative.received}次</div>
|
<div className="count">{reportData.initiative.received}次</div>
|
||||||
<div className="percent">{receivedPercent.toFixed(1)}%</div>
|
<div className="percent">{receivedPercent.toFixed(1)}%</div>
|
||||||
@@ -521,7 +989,7 @@ function DualReportWindow() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{reportData.response && (
|
{reportData.response && (
|
||||||
<section className="section">
|
<section className="section" ref={sectionRefs.response}>
|
||||||
<div className="label-text">回应速度</div>
|
<div className="label-text">回应速度</div>
|
||||||
<h2 className="hero-title">你说,我在</h2>
|
<h2 className="hero-title">你说,我在</h2>
|
||||||
<div className="response-pulse-container">
|
<div className="response-pulse-container">
|
||||||
@@ -558,7 +1026,7 @@ function DualReportWindow() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{reportData.streak && (
|
{reportData.streak && (
|
||||||
<section className="section">
|
<section className="section" ref={sectionRefs.streak}>
|
||||||
<div className="label-text">聊天火花</div>
|
<div className="label-text">聊天火花</div>
|
||||||
<h2 className="hero-title">最长连续聊天</h2>
|
<h2 className="hero-title">最长连续聊天</h2>
|
||||||
<div className="streak-spark-visual premium">
|
<div className="streak-spark-visual premium">
|
||||||
@@ -596,7 +1064,7 @@ function DualReportWindow() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<section className="section word-cloud-section">
|
<section className="section word-cloud-section" ref={sectionRefs.wordCloud}>
|
||||||
<div className="label-text">常用语</div>
|
<div className="label-text">常用语</div>
|
||||||
<h2 className="hero-title">{yearTitle}常用语</h2>
|
<h2 className="hero-title">{yearTitle}常用语</h2>
|
||||||
|
|
||||||
@@ -640,7 +1108,7 @@ function DualReportWindow() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="section">
|
<section className="section" ref={sectionRefs.stats}>
|
||||||
<div className="label-text">年度统计</div>
|
<div className="label-text">年度统计</div>
|
||||||
<h2 className="hero-title">{yearTitle}数据概览</h2>
|
<h2 className="hero-title">{yearTitle}数据概览</h2>
|
||||||
<div className="dual-stat-grid">
|
<div className="dual-stat-grid">
|
||||||
@@ -664,7 +1132,7 @@ function DualReportWindow() {
|
|||||||
<div className="emoji-card">
|
<div className="emoji-card">
|
||||||
<div className="emoji-title">我常用的表情</div>
|
<div className="emoji-title">我常用的表情</div>
|
||||||
{myEmojiUrl ? (
|
{myEmojiUrl ? (
|
||||||
<img src={myEmojiUrl} alt="my-emoji" onError={(e) => {
|
<img src={myEmojiUrl} alt="my-emoji" crossOrigin="anonymous" onError={(e) => {
|
||||||
(e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style');
|
(e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style');
|
||||||
(e.target as HTMLImageElement).style.display = 'none';
|
(e.target as HTMLImageElement).style.display = 'none';
|
||||||
}} />
|
}} />
|
||||||
@@ -677,7 +1145,7 @@ function DualReportWindow() {
|
|||||||
<div className="emoji-card">
|
<div className="emoji-card">
|
||||||
<div className="emoji-title">{reportData.friendName}常用的表情</div>
|
<div className="emoji-title">{reportData.friendName}常用的表情</div>
|
||||||
{friendEmojiUrl ? (
|
{friendEmojiUrl ? (
|
||||||
<img src={friendEmojiUrl} alt="friend-emoji" onError={(e) => {
|
<img src={friendEmojiUrl} alt="friend-emoji" crossOrigin="anonymous" onError={(e) => {
|
||||||
(e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style');
|
(e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style');
|
||||||
(e.target as HTMLImageElement).style.display = 'none';
|
(e.target as HTMLImageElement).style.display = 'none';
|
||||||
}} />
|
}} />
|
||||||
@@ -690,7 +1158,7 @@ function DualReportWindow() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="section">
|
<section className="section" ref={sectionRefs.ending}>
|
||||||
<div className="label-text">尾声</div>
|
<div className="label-text">尾声</div>
|
||||||
<h2 className="hero-title">谢谢你一直在</h2>
|
<h2 className="hero-title">谢谢你一直在</h2>
|
||||||
<p className="hero-desc">愿我们继续把故事写下去</p>
|
<p className="hero-desc">愿我们继续把故事写下去</p>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ import {
|
|||||||
Database,
|
Database,
|
||||||
Download,
|
Download,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
|
File as FileIcon,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Hash,
|
Hash,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
@@ -67,7 +68,7 @@ import './ExportPage.scss'
|
|||||||
type ConversationTab = 'private' | 'group' | 'official' | 'former_friend'
|
type ConversationTab = 'private' | 'group' | 'official' | 'former_friend'
|
||||||
type TaskStatus = 'queued' | 'running' | 'success' | 'error'
|
type TaskStatus = 'queued' | 'running' | 'success' | 'error'
|
||||||
type TaskScope = 'single' | 'multi' | 'content' | 'sns'
|
type TaskScope = 'single' | 'multi' | 'content' | 'sns'
|
||||||
type ContentType = 'text' | 'voice' | 'image' | 'video' | 'emoji'
|
type ContentType = 'text' | 'voice' | 'image' | 'video' | 'emoji' | 'file'
|
||||||
type ContentCardType = ContentType | 'sns'
|
type ContentCardType = ContentType | 'sns'
|
||||||
type SnsRankMode = 'likes' | 'comments'
|
type SnsRankMode = 'likes' | 'comments'
|
||||||
|
|
||||||
@@ -88,6 +89,8 @@ interface ExportOptions {
|
|||||||
exportVoices: boolean
|
exportVoices: boolean
|
||||||
exportVideos: boolean
|
exportVideos: boolean
|
||||||
exportEmojis: boolean
|
exportEmojis: boolean
|
||||||
|
exportFiles: boolean
|
||||||
|
maxFileSizeMb: number
|
||||||
exportVoiceAsText: boolean
|
exportVoiceAsText: boolean
|
||||||
excelCompactColumns: boolean
|
excelCompactColumns: boolean
|
||||||
txtColumns: string[]
|
txtColumns: string[]
|
||||||
@@ -181,6 +184,7 @@ interface ExportDialogState {
|
|||||||
|
|
||||||
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 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
|
||||||
@@ -195,8 +199,10 @@ const contentTypeLabels: Record<ContentType, string> = {
|
|||||||
voice: '语音',
|
voice: '语音',
|
||||||
image: '图片',
|
image: '图片',
|
||||||
video: '视频',
|
video: '视频',
|
||||||
emoji: '表情包'
|
emoji: '表情包',
|
||||||
|
file: '文件'
|
||||||
}
|
}
|
||||||
|
const FILE_SIZE_PRESETS_MB = [0, 100, 200, 500, 1024] as const
|
||||||
|
|
||||||
const backgroundTaskSourceLabels: Record<string, string> = {
|
const backgroundTaskSourceLabels: Record<string, string> = {
|
||||||
export: '导出页',
|
export: '导出页',
|
||||||
@@ -311,9 +317,7 @@ const cloneTaskPerformance = (performance?: TaskPerformance): TaskPerformance =>
|
|||||||
write: performance?.stages.write || 0,
|
write: performance?.stages.write || 0,
|
||||||
other: performance?.stages.other || 0
|
other: performance?.stages.other || 0
|
||||||
},
|
},
|
||||||
sessions: Object.fromEntries(
|
sessions: { ...(performance?.sessions || {}) }
|
||||||
Object.entries(performance?.sessions || {}).map(([sessionId, session]) => [sessionId, { ...session }])
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const resolveTaskSessionName = (task: ExportTask, sessionId: string, fallback?: string): string => {
|
const resolveTaskSessionName = (task: ExportTask, sessionId: string, fallback?: string): string => {
|
||||||
@@ -333,6 +337,18 @@ const applyProgressToTaskPerformance = (
|
|||||||
const sessionId = String(payload.currentSessionId || '').trim()
|
const sessionId = String(payload.currentSessionId || '').trim()
|
||||||
if (!sessionId) return task.performance || createEmptyTaskPerformance()
|
if (!sessionId) return task.performance || createEmptyTaskPerformance()
|
||||||
|
|
||||||
|
const currentPerformance = task.performance
|
||||||
|
const currentSession = currentPerformance?.sessions?.[sessionId]
|
||||||
|
if (
|
||||||
|
payload.phase !== 'complete' &&
|
||||||
|
currentSession &&
|
||||||
|
currentSession.lastPhase === payload.phase &&
|
||||||
|
typeof currentSession.lastPhaseStartedAt === 'number' &&
|
||||||
|
now - currentSession.lastPhaseStartedAt < TASK_PERFORMANCE_UPDATE_MIN_INTERVAL_MS
|
||||||
|
) {
|
||||||
|
return currentPerformance
|
||||||
|
}
|
||||||
|
|
||||||
const performance = cloneTaskPerformance(task.performance)
|
const performance = cloneTaskPerformance(task.performance)
|
||||||
const sessionName = resolveTaskSessionName(task, sessionId, payload.currentSession || sessionId)
|
const sessionName = resolveTaskSessionName(task, sessionId, payload.currentSession || sessionId)
|
||||||
const existing = performance.sessions[sessionId]
|
const existing = performance.sessions[sessionId]
|
||||||
@@ -368,7 +384,9 @@ const applyProgressToTaskPerformance = (
|
|||||||
const finalizeTaskPerformance = (task: ExportTask, now: number): TaskPerformance | undefined => {
|
const finalizeTaskPerformance = (task: ExportTask, now: number): TaskPerformance | undefined => {
|
||||||
if (!isTextBatchTask(task) || !task.performance) return task.performance
|
if (!isTextBatchTask(task) || !task.performance) return task.performance
|
||||||
const performance = cloneTaskPerformance(task.performance)
|
const performance = cloneTaskPerformance(task.performance)
|
||||||
for (const session of Object.values(performance.sessions)) {
|
const nextSessions: Record<string, TaskSessionPerformance> = {}
|
||||||
|
for (const [sessionId, sourceSession] of Object.entries(performance.sessions)) {
|
||||||
|
const session: TaskSessionPerformance = { ...sourceSession }
|
||||||
if (session.finishedAt) continue
|
if (session.finishedAt) continue
|
||||||
if (session.lastPhase && typeof session.lastPhaseStartedAt === 'number') {
|
if (session.lastPhase && typeof session.lastPhaseStartedAt === 'number') {
|
||||||
const delta = Math.max(0, now - session.lastPhaseStartedAt)
|
const delta = Math.max(0, now - session.lastPhaseStartedAt)
|
||||||
@@ -378,7 +396,13 @@ const finalizeTaskPerformance = (task: ExportTask, now: number): TaskPerformance
|
|||||||
session.finishedAt = now
|
session.finishedAt = now
|
||||||
session.lastPhase = undefined
|
session.lastPhase = undefined
|
||||||
session.lastPhaseStartedAt = undefined
|
session.lastPhaseStartedAt = undefined
|
||||||
|
nextSessions[sessionId] = session
|
||||||
}
|
}
|
||||||
|
for (const [sessionId, sourceSession] of Object.entries(performance.sessions)) {
|
||||||
|
if (nextSessions[sessionId]) continue
|
||||||
|
nextSessions[sessionId] = { ...sourceSession }
|
||||||
|
}
|
||||||
|
performance.sessions = nextSessions
|
||||||
return performance
|
return performance
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1188,16 +1212,18 @@ const WriteLayoutSelector = memo(function WriteLayoutSelector({
|
|||||||
const writeLayoutLabel = writeLayoutOptions.find(option => option.value === writeLayout)?.label || 'A(类型分目录)'
|
const writeLayoutLabel = writeLayoutOptions.find(option => option.value === writeLayout)?.label || 'A(类型分目录)'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="write-layout-control" ref={containerRef}>
|
<div className={`write-layout-control ${isOpen ? 'open' : ''}`} ref={containerRef}>
|
||||||
<span className="control-label">写入目录方式</span>
|
<span className="control-label">写入目录方式</span>
|
||||||
<button
|
<button
|
||||||
className={`layout-trigger ${isOpen ? 'active' : ''}`}
|
className={`layout-trigger ${isOpen ? 'active' : ''}`}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsOpen(prev => !prev)}
|
onClick={() => setIsOpen(prev => !prev)}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-haspopup="listbox"
|
||||||
>
|
>
|
||||||
{writeLayoutLabel}
|
{writeLayoutLabel}
|
||||||
</button>
|
</button>
|
||||||
<div className={`layout-dropdown ${isOpen ? 'open' : ''}`}>
|
<div className={`layout-dropdown ${isOpen ? 'open' : ''}`} role="listbox" aria-label="写入目录方式">
|
||||||
{writeLayoutOptions.map(option => (
|
{writeLayoutOptions.map(option => (
|
||||||
<button
|
<button
|
||||||
key={option.value}
|
key={option.value}
|
||||||
@@ -1314,7 +1340,7 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
|||||||
}: TaskCenterModalProps) {
|
}: TaskCenterModalProps) {
|
||||||
if (!isOpen) return null
|
if (!isOpen) return null
|
||||||
|
|
||||||
return (
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
className="task-center-modal-overlay"
|
className="task-center-modal-overlay"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -1511,7 +1537,8 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1598,7 +1625,8 @@ function ExportPage() {
|
|||||||
images: true,
|
images: true,
|
||||||
videos: true,
|
videos: true,
|
||||||
voices: true,
|
voices: true,
|
||||||
emojis: true
|
emojis: true,
|
||||||
|
files: true
|
||||||
})
|
})
|
||||||
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false)
|
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false)
|
||||||
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
|
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
|
||||||
@@ -1617,7 +1645,9 @@ function ExportPage() {
|
|||||||
exportImages: true,
|
exportImages: true,
|
||||||
exportVoices: true,
|
exportVoices: true,
|
||||||
exportVideos: true,
|
exportVideos: true,
|
||||||
exportEmojis: true,
|
exportEmojis: true,
|
||||||
|
exportFiles: true,
|
||||||
|
maxFileSizeMb: 200,
|
||||||
exportVoiceAsText: false,
|
exportVoiceAsText: false,
|
||||||
excelCompactColumns: true,
|
excelCompactColumns: true,
|
||||||
txtColumns: defaultTxtColumns,
|
txtColumns: defaultTxtColumns,
|
||||||
@@ -2281,7 +2311,8 @@ function ExportPage() {
|
|||||||
images: true,
|
images: true,
|
||||||
videos: true,
|
videos: true,
|
||||||
voices: true,
|
voices: true,
|
||||||
emojis: true
|
emojis: true,
|
||||||
|
files: true
|
||||||
})
|
})
|
||||||
setExportDefaultVoiceAsText(savedVoiceAsText ?? false)
|
setExportDefaultVoiceAsText(savedVoiceAsText ?? false)
|
||||||
setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true)
|
setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true)
|
||||||
@@ -2310,12 +2341,14 @@ function ExportPage() {
|
|||||||
(savedMedia?.images ?? prev.exportImages) ||
|
(savedMedia?.images ?? prev.exportImages) ||
|
||||||
(savedMedia?.voices ?? prev.exportVoices) ||
|
(savedMedia?.voices ?? prev.exportVoices) ||
|
||||||
(savedMedia?.videos ?? prev.exportVideos) ||
|
(savedMedia?.videos ?? prev.exportVideos) ||
|
||||||
(savedMedia?.emojis ?? prev.exportEmojis)
|
(savedMedia?.emojis ?? prev.exportEmojis) ||
|
||||||
|
(savedMedia?.files ?? prev.exportFiles)
|
||||||
),
|
),
|
||||||
exportImages: savedMedia?.images ?? prev.exportImages,
|
exportImages: savedMedia?.images ?? prev.exportImages,
|
||||||
exportVoices: savedMedia?.voices ?? prev.exportVoices,
|
exportVoices: savedMedia?.voices ?? prev.exportVoices,
|
||||||
exportVideos: savedMedia?.videos ?? prev.exportVideos,
|
exportVideos: savedMedia?.videos ?? prev.exportVideos,
|
||||||
exportEmojis: savedMedia?.emojis ?? prev.exportEmojis,
|
exportEmojis: savedMedia?.emojis ?? prev.exportEmojis,
|
||||||
|
exportFiles: savedMedia?.files ?? prev.exportFiles,
|
||||||
exportVoiceAsText: savedVoiceAsText ?? prev.exportVoiceAsText,
|
exportVoiceAsText: savedVoiceAsText ?? prev.exportVoiceAsText,
|
||||||
excelCompactColumns: savedExcelCompactColumns ?? prev.excelCompactColumns,
|
excelCompactColumns: savedExcelCompactColumns ?? prev.excelCompactColumns,
|
||||||
txtColumns,
|
txtColumns,
|
||||||
@@ -4088,12 +4121,15 @@ function ExportPage() {
|
|||||||
exportDefaultMedia.images ||
|
exportDefaultMedia.images ||
|
||||||
exportDefaultMedia.voices ||
|
exportDefaultMedia.voices ||
|
||||||
exportDefaultMedia.videos ||
|
exportDefaultMedia.videos ||
|
||||||
exportDefaultMedia.emojis
|
exportDefaultMedia.emojis ||
|
||||||
|
exportDefaultMedia.files
|
||||||
),
|
),
|
||||||
exportImages: exportDefaultMedia.images,
|
exportImages: exportDefaultMedia.images,
|
||||||
exportVoices: exportDefaultMedia.voices,
|
exportVoices: exportDefaultMedia.voices,
|
||||||
exportVideos: exportDefaultMedia.videos,
|
exportVideos: exportDefaultMedia.videos,
|
||||||
exportEmojis: exportDefaultMedia.emojis,
|
exportEmojis: exportDefaultMedia.emojis,
|
||||||
|
exportFiles: exportDefaultMedia.files,
|
||||||
|
maxFileSizeMb: prev.maxFileSizeMb,
|
||||||
exportVoiceAsText: exportDefaultVoiceAsText,
|
exportVoiceAsText: exportDefaultVoiceAsText,
|
||||||
excelCompactColumns: exportDefaultExcelCompactColumns,
|
excelCompactColumns: exportDefaultExcelCompactColumns,
|
||||||
exportConcurrency: exportDefaultConcurrency,
|
exportConcurrency: exportDefaultConcurrency,
|
||||||
@@ -4111,12 +4147,14 @@ function ExportPage() {
|
|||||||
next.exportVoices = false
|
next.exportVoices = false
|
||||||
next.exportVideos = false
|
next.exportVideos = false
|
||||||
next.exportEmojis = false
|
next.exportEmojis = false
|
||||||
|
next.exportFiles = false
|
||||||
} else {
|
} else {
|
||||||
next.exportMedia = true
|
next.exportMedia = true
|
||||||
next.exportImages = payload.contentType === 'image'
|
next.exportImages = payload.contentType === 'image'
|
||||||
next.exportVoices = payload.contentType === 'voice'
|
next.exportVoices = payload.contentType === 'voice'
|
||||||
next.exportVideos = payload.contentType === 'video'
|
next.exportVideos = payload.contentType === 'video'
|
||||||
next.exportEmojis = payload.contentType === 'emoji'
|
next.exportEmojis = payload.contentType === 'emoji'
|
||||||
|
next.exportFiles = payload.contentType === 'file'
|
||||||
next.exportVoiceAsText = false
|
next.exportVoiceAsText = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4335,7 +4373,13 @@ function ExportPage() {
|
|||||||
|
|
||||||
const buildExportOptions = (scope: TaskScope, contentType?: ContentType): ElectronExportOptions => {
|
const buildExportOptions = (scope: TaskScope, contentType?: ContentType): ElectronExportOptions => {
|
||||||
const sessionLayout: SessionLayout = writeLayout === 'C' ? 'per-session' : 'shared'
|
const sessionLayout: SessionLayout = writeLayout === 'C' ? 'per-session' : 'shared'
|
||||||
const exportMediaEnabled = Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis)
|
const exportMediaEnabled = Boolean(
|
||||||
|
options.exportImages ||
|
||||||
|
options.exportVoices ||
|
||||||
|
options.exportVideos ||
|
||||||
|
options.exportEmojis ||
|
||||||
|
options.exportFiles
|
||||||
|
)
|
||||||
|
|
||||||
const base: ElectronExportOptions = {
|
const base: ElectronExportOptions = {
|
||||||
format: options.format,
|
format: options.format,
|
||||||
@@ -4345,6 +4389,8 @@ function ExportPage() {
|
|||||||
exportVoices: options.exportVoices,
|
exportVoices: options.exportVoices,
|
||||||
exportVideos: options.exportVideos,
|
exportVideos: options.exportVideos,
|
||||||
exportEmojis: options.exportEmojis,
|
exportEmojis: options.exportEmojis,
|
||||||
|
exportFiles: options.exportFiles,
|
||||||
|
maxFileSizeMb: options.maxFileSizeMb,
|
||||||
exportVoiceAsText: options.exportVoiceAsText,
|
exportVoiceAsText: options.exportVoiceAsText,
|
||||||
excelCompactColumns: options.excelCompactColumns,
|
excelCompactColumns: options.excelCompactColumns,
|
||||||
txtColumns: options.txtColumns,
|
txtColumns: options.txtColumns,
|
||||||
@@ -4375,7 +4421,8 @@ function ExportPage() {
|
|||||||
exportImages: false,
|
exportImages: false,
|
||||||
exportVoices: false,
|
exportVoices: false,
|
||||||
exportVideos: false,
|
exportVideos: false,
|
||||||
exportEmojis: false
|
exportEmojis: false,
|
||||||
|
exportFiles: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4387,6 +4434,7 @@ function ExportPage() {
|
|||||||
exportVoices: contentType === 'voice',
|
exportVoices: contentType === 'voice',
|
||||||
exportVideos: contentType === 'video',
|
exportVideos: contentType === 'video',
|
||||||
exportEmojis: contentType === 'emoji',
|
exportEmojis: contentType === 'emoji',
|
||||||
|
exportFiles: contentType === 'file',
|
||||||
exportVoiceAsText: false
|
exportVoiceAsText: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4452,6 +4500,7 @@ function ExportPage() {
|
|||||||
if (opts.exportVoices) labels.push('语音')
|
if (opts.exportVoices) labels.push('语音')
|
||||||
if (opts.exportVideos) labels.push('视频')
|
if (opts.exportVideos) labels.push('视频')
|
||||||
if (opts.exportEmojis) labels.push('表情包')
|
if (opts.exportEmojis) labels.push('表情包')
|
||||||
|
if (opts.exportFiles) labels.push('文件')
|
||||||
}
|
}
|
||||||
return Array.from(new Set(labels)).join('、')
|
return Array.from(new Set(labels)).join('、')
|
||||||
}, [])
|
}, [])
|
||||||
@@ -4507,6 +4556,7 @@ function ExportPage() {
|
|||||||
if (opts.exportImages) types.push('image')
|
if (opts.exportImages) types.push('image')
|
||||||
if (opts.exportVideos) types.push('video')
|
if (opts.exportVideos) types.push('video')
|
||||||
if (opts.exportEmojis) types.push('emoji')
|
if (opts.exportEmojis) types.push('emoji')
|
||||||
|
if (opts.exportFiles) types.push('file')
|
||||||
}
|
}
|
||||||
return types
|
return types
|
||||||
}
|
}
|
||||||
@@ -4697,7 +4747,7 @@ function ExportPage() {
|
|||||||
queuedProgressTimer = window.setTimeout(() => {
|
queuedProgressTimer = window.setTimeout(() => {
|
||||||
queuedProgressTimer = null
|
queuedProgressTimer = null
|
||||||
flushQueuedProgress()
|
flushQueuedProgress()
|
||||||
}, 100)
|
}, 180)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (next.payload.scope === 'sns') {
|
if (next.payload.scope === 'sns') {
|
||||||
@@ -4937,7 +4987,8 @@ function ExportPage() {
|
|||||||
images: options.exportImages,
|
images: options.exportImages,
|
||||||
voices: options.exportVoices,
|
voices: options.exportVoices,
|
||||||
videos: options.exportVideos,
|
videos: options.exportVideos,
|
||||||
emojis: options.exportEmojis
|
emojis: options.exportEmojis,
|
||||||
|
files: options.exportFiles
|
||||||
})
|
})
|
||||||
await configService.setExportDefaultVoiceAsText(options.exportVoiceAsText)
|
await configService.setExportDefaultVoiceAsText(options.exportVoiceAsText)
|
||||||
await configService.setExportDefaultExcelCompactColumns(options.excelCompactColumns)
|
await configService.setExportDefaultExcelCompactColumns(options.excelCompactColumns)
|
||||||
@@ -6445,6 +6496,10 @@ 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' && (
|
const shouldShowImageDeepSearchToggle = exportDialog.scope !== 'sns' && (
|
||||||
(isSessionScopeDialog && options.exportImages) ||
|
(isSessionScopeDialog && options.exportImages) ||
|
||||||
(isContentScopeDialog && exportDialog.contentType === 'image')
|
(isContentScopeDialog && exportDialog.contentType === 'image')
|
||||||
@@ -6454,6 +6509,80 @@ function ExportPage() {
|
|||||||
const activeDialogFormatLabel = exportDialog.scope === 'sns'
|
const activeDialogFormatLabel = exportDialog.scope === 'sns'
|
||||||
? (snsFormatOptions.find(option => option.value === snsExportFormat)?.label ?? snsExportFormat)
|
? (snsFormatOptions.find(option => option.value === snsExportFormat)?.label ?? snsExportFormat)
|
||||||
: (formatOptions.find(option => option.value === options.format)?.label ?? options.format)
|
: (formatOptions.find(option => option.value === options.format)?.label ?? options.format)
|
||||||
|
const sessionMediaOptions = [
|
||||||
|
{
|
||||||
|
key: 'images',
|
||||||
|
label: '图片',
|
||||||
|
desc: '聊天图片与缩略图',
|
||||||
|
icon: ImageIcon,
|
||||||
|
checked: options.exportImages,
|
||||||
|
onToggle: (checked: boolean) => setOptions(prev => ({ ...prev, exportImages: checked }))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'voices',
|
||||||
|
label: '语音',
|
||||||
|
desc: '语音消息文件',
|
||||||
|
icon: Mic,
|
||||||
|
checked: options.exportVoices,
|
||||||
|
onToggle: (checked: boolean) => setOptions(prev => ({ ...prev, exportVoices: checked }))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'videos',
|
||||||
|
label: '视频',
|
||||||
|
desc: '聊天视频与封面',
|
||||||
|
icon: Video,
|
||||||
|
checked: options.exportVideos,
|
||||||
|
onToggle: (checked: boolean) => setOptions(prev => ({ ...prev, exportVideos: checked }))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'emojis',
|
||||||
|
label: '表情包',
|
||||||
|
desc: '静态与动态表情',
|
||||||
|
icon: MessageSquare,
|
||||||
|
checked: options.exportEmojis,
|
||||||
|
onToggle: (checked: boolean) => setOptions(prev => ({ ...prev, exportEmojis: checked }))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'files',
|
||||||
|
label: '文件',
|
||||||
|
desc: '文档与附件',
|
||||||
|
icon: FileIcon,
|
||||||
|
checked: options.exportFiles,
|
||||||
|
onToggle: (checked: boolean) => setOptions(prev => ({ ...prev, exportFiles: checked }))
|
||||||
|
}
|
||||||
|
]
|
||||||
|
const snsMediaOptions = [
|
||||||
|
{
|
||||||
|
key: 'images',
|
||||||
|
label: '图片',
|
||||||
|
desc: '朋友圈图片',
|
||||||
|
icon: ImageIcon,
|
||||||
|
checked: snsExportImages,
|
||||||
|
onToggle: (checked: boolean) => setSnsExportImages(checked)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'live-photos',
|
||||||
|
label: '实况图',
|
||||||
|
desc: 'Live Photo',
|
||||||
|
icon: Aperture,
|
||||||
|
checked: snsExportLivePhotos,
|
||||||
|
onToggle: (checked: boolean) => setSnsExportLivePhotos(checked)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'videos',
|
||||||
|
label: '视频',
|
||||||
|
desc: '朋友圈视频',
|
||||||
|
icon: Video,
|
||||||
|
checked: snsExportVideos,
|
||||||
|
onToggle: (checked: boolean) => setSnsExportVideos(checked)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
const dialogMediaOptions = exportDialog.scope === 'sns' ? snsMediaOptions : sessionMediaOptions
|
||||||
|
const mediaSelectionSummaryLabel = `已选择 ${dialogMediaOptions.filter(option => option.checked).length}/${dialogMediaOptions.length}`
|
||||||
|
const voiceAsTextStatusLabel = options.exportVoices
|
||||||
|
? '已勾选导出语音:会同时导出语音文件,并在文本中追加语音转写结果。'
|
||||||
|
: '未勾选导出语音时,仅在文本里追加语音转写结果,不导出语音文件。'
|
||||||
|
const fileSizeLimitLabel = options.maxFileSizeMb <= 0 ? '不限' : `${options.maxFileSizeMb} MB`
|
||||||
const shouldShowDisplayNameSection = !(
|
const shouldShowDisplayNameSection = !(
|
||||||
exportDialog.scope === 'sns' ||
|
exportDialog.scope === 'sns' ||
|
||||||
(
|
(
|
||||||
@@ -6472,8 +6601,9 @@ function ExportPage() {
|
|||||||
const taskQueuedCount = tasks.filter(task => task.status === 'queued').length
|
const taskQueuedCount = tasks.filter(task => task.status === 'queued').length
|
||||||
const taskCenterAlertCount = taskRunningCount + taskQueuedCount
|
const taskCenterAlertCount = taskRunningCount + taskQueuedCount
|
||||||
const hasFilteredContacts = filteredContacts.length > 0
|
const hasFilteredContacts = filteredContacts.length > 0
|
||||||
|
const CONTACTS_ACTION_STICKY_WIDTH = 184
|
||||||
const contactsTableMinWidth = useMemo(() => {
|
const contactsTableMinWidth = useMemo(() => {
|
||||||
const baseWidth = 24 + 34 + 44 + 280 + 120 + (4 * 72) + 140 + (8 * 12)
|
const baseWidth = 24 + 34 + 44 + 280 + 120 + (4 * 72) + CONTACTS_ACTION_STICKY_WIDTH + (8 * 12)
|
||||||
const snsWidth = shouldShowSnsColumn ? 72 + 12 : 0
|
const snsWidth = shouldShowSnsColumn ? 72 + 12 : 0
|
||||||
const mutualFriendsWidth = shouldShowMutualFriendsColumn ? 72 + 12 : 0
|
const mutualFriendsWidth = shouldShowMutualFriendsColumn ? 72 + 12 : 0
|
||||||
return baseWidth + snsWidth + mutualFriendsWidth
|
return baseWidth + snsWidth + mutualFriendsWidth
|
||||||
@@ -6664,7 +6794,7 @@ function ExportPage() {
|
|||||||
const toggleTaskPerfDetail = useCallback((taskId: string) => {
|
const toggleTaskPerfDetail = useCallback((taskId: string) => {
|
||||||
setExpandedPerfTaskId(prev => (prev === taskId ? null : taskId))
|
setExpandedPerfTaskId(prev => (prev === taskId ? null : taskId))
|
||||||
}, [])
|
}, [])
|
||||||
const renderContactRow = useCallback((_: number, contact: ContactInfo) => {
|
const renderContactRow = useCallback((index: number, contact: ContactInfo) => {
|
||||||
const matchedSession = sessionRowByUsername.get(contact.username)
|
const matchedSession = sessionRowByUsername.get(contact.username)
|
||||||
const canExport = Boolean(matchedSession?.hasSession)
|
const canExport = Boolean(matchedSession?.hasSession)
|
||||||
const isSessionBindingPending = !matchedSession && (isLoading || isSessionEnriching)
|
const isSessionBindingPending = !matchedSession && (isLoading || isSessionEnriching)
|
||||||
@@ -6730,8 +6860,20 @@ function ExportPage() {
|
|||||||
: contact.type === 'group'
|
: contact.type === 'group'
|
||||||
? '打开群聊'
|
? '打开群聊'
|
||||||
: '打开对话'
|
: '打开对话'
|
||||||
|
const previousContact = index > 0 ? filteredContacts[index - 1] : null
|
||||||
|
const nextContact = index < filteredContacts.length - 1 ? filteredContacts[index + 1] : null
|
||||||
|
const previousCanExport = Boolean(previousContact && sessionRowByUsername.get(previousContact.username)?.hasSession)
|
||||||
|
const nextCanExport = Boolean(nextContact && sessionRowByUsername.get(nextContact.username)?.hasSession)
|
||||||
|
const previousSelected = Boolean(previousContact && previousCanExport && selectedSessions.has(previousContact.username))
|
||||||
|
const nextSelected = Boolean(nextContact && nextCanExport && selectedSessions.has(nextContact.username))
|
||||||
|
const rowClassName = [
|
||||||
|
'contact-row',
|
||||||
|
checked ? 'selected' : '',
|
||||||
|
checked && previousSelected ? 'selected-contiguous-top' : '',
|
||||||
|
checked && nextSelected ? 'selected-contiguous-bottom' : ''
|
||||||
|
].filter(Boolean).join(' ')
|
||||||
return (
|
return (
|
||||||
<div className={`contact-row ${checked ? 'selected' : ''}`}>
|
<div className={rowClassName}>
|
||||||
<div className="contact-item">
|
<div className="contact-item">
|
||||||
<div className="row-left-sticky">
|
<div className="row-left-sticky">
|
||||||
<div className="row-select-cell">
|
<div className="row-select-cell">
|
||||||
@@ -6880,6 +7022,7 @@ function ExportPage() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}, [
|
}, [
|
||||||
|
filteredContacts,
|
||||||
lastExportBySession,
|
lastExportBySession,
|
||||||
navigate,
|
navigate,
|
||||||
nowTick,
|
nowTick,
|
||||||
@@ -6955,11 +7098,12 @@ function ExportPage() {
|
|||||||
setExportDefaultMedia(mediaPatch)
|
setExportDefaultMedia(mediaPatch)
|
||||||
setOptions(prev => ({
|
setOptions(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
exportMedia: Boolean(mediaPatch.images || mediaPatch.voices || mediaPatch.videos || mediaPatch.emojis),
|
exportMedia: Boolean(mediaPatch.images || mediaPatch.voices || mediaPatch.videos || mediaPatch.emojis || mediaPatch.files),
|
||||||
exportImages: mediaPatch.images,
|
exportImages: mediaPatch.images,
|
||||||
exportVoices: mediaPatch.voices,
|
exportVoices: mediaPatch.voices,
|
||||||
exportVideos: mediaPatch.videos,
|
exportVideos: mediaPatch.videos,
|
||||||
exportEmojis: mediaPatch.emojis
|
exportEmojis: mediaPatch.emojis,
|
||||||
|
exportFiles: mediaPatch.files
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
if (typeof patch.voiceAsText === 'boolean') {
|
if (typeof patch.voiceAsText === 'boolean') {
|
||||||
@@ -7048,7 +7192,7 @@ function ExportPage() {
|
|||||||
onTogglePerfTask={toggleTaskPerfDetail}
|
onTogglePerfTask={toggleTaskPerfDetail}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isExportDefaultsModalOpen && (
|
{isExportDefaultsModalOpen && createPortal(
|
||||||
<div
|
<div
|
||||||
className="export-defaults-modal-overlay"
|
className="export-defaults-modal-overlay"
|
||||||
onClick={() => setIsExportDefaultsModalOpen(false)}
|
onClick={() => setIsExportDefaultsModalOpen(false)}
|
||||||
@@ -7086,7 +7230,8 @@ function ExportPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="export-section-title-row">
|
<div className="export-section-title-row">
|
||||||
@@ -7171,7 +7316,7 @@ function ExportPage() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
className={`session-load-detail-entry ${isSessionLoadDetailActive ? 'active' : ''}`}
|
className={`session-load-detail-entry ${showSessionLoadDetailModal ? 'open' : ''} ${isSessionLoadDetailActive && !showSessionLoadDetailModal ? 'active' : ''}`.trim()}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowSessionLoadDetailModal(true)}
|
onClick={() => setShowSessionLoadDetailModal(true)}
|
||||||
>
|
>
|
||||||
@@ -7381,7 +7526,7 @@ function ExportPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showSessionLoadDetailModal && (
|
{showSessionLoadDetailModal && createPortal(
|
||||||
<div
|
<div
|
||||||
className="session-load-detail-overlay"
|
className="session-load-detail-overlay"
|
||||||
onClick={() => setShowSessionLoadDetailModal(false)}
|
onClick={() => setShowSessionLoadDetailModal(false)}
|
||||||
@@ -7616,10 +7761,11 @@ function ExportPage() {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{sessionMutualFriendsDialogTarget && sessionMutualFriendsDialogMetric && (
|
{sessionMutualFriendsDialogTarget && sessionMutualFriendsDialogMetric && createPortal(
|
||||||
<div
|
<div
|
||||||
className="session-mutual-friends-overlay"
|
className="session-mutual-friends-overlay"
|
||||||
onClick={closeSessionMutualFriendsDialog}
|
onClick={closeSessionMutualFriendsDialog}
|
||||||
@@ -7702,10 +7848,11 @@ function ExportPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showSessionDetailPanel && (
|
{showSessionDetailPanel && createPortal(
|
||||||
<div
|
<div
|
||||||
className="export-session-detail-overlay"
|
className="export-session-detail-overlay"
|
||||||
onClick={closeSessionDetailPanel}
|
onClick={closeSessionDetailPanel}
|
||||||
@@ -7807,19 +7954,15 @@ function ExportPage() {
|
|||||||
<div className="detail-record-list">
|
<div className="detail-record-list">
|
||||||
{currentSessionExportRecords.map((record, index) => (
|
{currentSessionExportRecords.map((record, index) => (
|
||||||
<div className="detail-record-item" key={`${record.exportTime}-${record.content}-${index}`}>
|
<div className="detail-record-item" key={`${record.exportTime}-${record.content}-${index}`}>
|
||||||
<div className="record-row">
|
<div className="detail-record-head">
|
||||||
<span className="label">导出时间</span>
|
<span className="record-export-time">{formatYmdHmDateTime(record.exportTime)}</span>
|
||||||
<span className="value">{formatYmdHmDateTime(record.exportTime)}</span>
|
<span className="record-content-pill" title={record.content}>{record.content}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="record-row">
|
<div className="detail-record-path-row">
|
||||||
<span className="label">导出内容</span>
|
<span className="path-label">导出目录</span>
|
||||||
<span className="value">{record.content}</span>
|
<span className="path-value" title={record.outputDir}>{formatPathBrief(record.outputDir)}</span>
|
||||||
</div>
|
|
||||||
<div className="record-row">
|
|
||||||
<span className="label">导出目录</span>
|
|
||||||
<span className="value path" title={record.outputDir}>{formatPathBrief(record.outputDir)}</span>
|
|
||||||
<button
|
<button
|
||||||
className="detail-inline-btn"
|
className="detail-inline-btn detail-record-open-btn"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void window.electronAPI.shell.openPath(record.outputDir)}
|
onClick={() => void window.electronAPI.shell.openPath(record.outputDir)}
|
||||||
>
|
>
|
||||||
@@ -7835,7 +7978,7 @@ function ExportPage() {
|
|||||||
<div className="detail-section">
|
<div className="detail-section">
|
||||||
<div className="section-title">
|
<div className="section-title">
|
||||||
<MessageSquare size={14} />
|
<MessageSquare size={14} />
|
||||||
<span>消息统计(导出口径)</span>
|
<span>消息统计</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="detail-stats-meta">
|
<div className="detail-stats-meta">
|
||||||
{isRefreshingSessionDetailStats
|
{isRefreshingSessionDetailStats
|
||||||
@@ -8018,7 +8161,8 @@ function ExportPage() {
|
|||||||
<div className="detail-empty">暂无详情</div>
|
<div className="detail-empty">暂无详情</div>
|
||||||
)}
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ContactSnsTimelineDialog
|
<ContactSnsTimelineDialog
|
||||||
@@ -8147,45 +8291,103 @@ function ExportPage() {
|
|||||||
|
|
||||||
{shouldShowMediaSection && (
|
{shouldShowMediaSection && (
|
||||||
<div className="dialog-section">
|
<div className="dialog-section">
|
||||||
<h4>{exportDialog.scope === 'sns' ? '媒体文件(可多选)' : '媒体内容'}</h4>
|
<div className="section-header-action media-section-header">
|
||||||
<div className="media-check-grid">
|
<h4>{exportDialog.scope === 'sns' ? '媒体文件(可多选)' : '媒体内容'}</h4>
|
||||||
{exportDialog.scope === 'sns' ? (
|
<span className="media-selection-pill">{mediaSelectionSummaryLabel}</span>
|
||||||
<>
|
|
||||||
<label><input type="checkbox" checked={snsExportImages} onChange={event => setSnsExportImages(event.target.checked)} /> 图片</label>
|
|
||||||
<label><input type="checkbox" checked={snsExportLivePhotos} onChange={event => setSnsExportLivePhotos(event.target.checked)} /> 实况图</label>
|
|
||||||
<label><input type="checkbox" checked={snsExportVideos} onChange={event => setSnsExportVideos(event.target.checked)} /> 视频</label>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<label><input type="checkbox" checked={options.exportImages} onChange={event => setOptions(prev => ({ ...prev, exportImages: event.target.checked }))} /> 图片</label>
|
|
||||||
<label><input type="checkbox" checked={options.exportVoices} onChange={event => setOptions(prev => ({ ...prev, exportVoices: event.target.checked }))} /> 语音</label>
|
|
||||||
<label><input type="checkbox" checked={options.exportVideos} onChange={event => setOptions(prev => ({ ...prev, exportVideos: event.target.checked }))} /> 视频</label>
|
|
||||||
<label><input type="checkbox" checked={options.exportEmojis} onChange={event => setOptions(prev => ({ ...prev, exportEmojis: event.target.checked }))} /> 表情包</label>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{exportDialog.scope === 'sns' && (
|
<div className="media-option-grid">
|
||||||
<div className="format-note">全不勾选时仅导出文本信息,不导出媒体文件。</div>
|
{dialogMediaOptions.map(option => {
|
||||||
|
const Icon = option.icon
|
||||||
|
return (
|
||||||
|
<label key={option.key} className={`media-option-card ${option.checked ? 'active' : ''}`}>
|
||||||
|
<input
|
||||||
|
className="media-option-input"
|
||||||
|
type="checkbox"
|
||||||
|
checked={option.checked}
|
||||||
|
onChange={event => option.onToggle(event.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="media-option-main">
|
||||||
|
<span className="media-option-icon">
|
||||||
|
<Icon size={16} />
|
||||||
|
</span>
|
||||||
|
<span className="media-option-text">
|
||||||
|
<span className="media-option-label">{option.label}</span>
|
||||||
|
<span className="media-option-desc">{option.desc}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className={`media-option-check ${option.checked ? 'active' : ''}`}>
|
||||||
|
<Check size={12} />
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{exportDialog.scope !== 'sns' && (
|
||||||
|
<div
|
||||||
|
className={`dialog-collapse-slot ${options.exportFiles ? 'open' : ''}`}
|
||||||
|
aria-hidden={!options.exportFiles}
|
||||||
|
>
|
||||||
|
<div className="dialog-collapse-inner">
|
||||||
|
<div className="file-size-subsection">
|
||||||
|
<div className="file-size-subsection-header">
|
||||||
|
<div className="file-size-heading">文件大小上限</div>
|
||||||
|
<div className="file-size-current">{fileSizeLimitLabel}</div>
|
||||||
|
</div>
|
||||||
|
<div className="file-size-note">
|
||||||
|
文件导出优先使用消息中的 MD5 做校验;设置上限后,只导出不超过该值的文件。
|
||||||
|
</div>
|
||||||
|
<div className="file-size-preset-row">
|
||||||
|
{FILE_SIZE_PRESETS_MB.map(preset => (
|
||||||
|
<button
|
||||||
|
key={preset}
|
||||||
|
type="button"
|
||||||
|
className={`file-size-preset-btn ${options.maxFileSizeMb === preset ? 'active' : ''}`}
|
||||||
|
onClick={() => setOptions(prev => ({ ...prev, maxFileSizeMb: preset }))}
|
||||||
|
>
|
||||||
|
{preset === 0 ? '不限' : `${preset}MB`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="dialog-input-row">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={10}
|
||||||
|
value={options.maxFileSizeMb}
|
||||||
|
onChange={event => {
|
||||||
|
const raw = Number(event.target.value)
|
||||||
|
setOptions(prev => ({ ...prev, maxFileSizeMb: Number.isFinite(raw) ? Math.max(0, Math.floor(raw)) : 0 }))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>MB</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{shouldShowImageDeepSearchToggle && (
|
{shouldRenderImageDeepSearchToggle && (
|
||||||
<div className="dialog-section">
|
<div className={`dialog-collapse-slot ${shouldShowImageDeepSearchToggle ? 'open' : ''}`} aria-hidden={!shouldShowImageDeepSearchToggle}>
|
||||||
<div className="dialog-switch-row">
|
<div className="dialog-collapse-inner">
|
||||||
<div className="dialog-switch-copy">
|
<div className="dialog-section">
|
||||||
<h4>缺图时深度搜索</h4>
|
<div className="dialog-switch-row">
|
||||||
<div className="format-note">关闭后仅尝试 hardlink 命中,未命中将直接显示占位符,导出速度更快。</div>
|
<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>
|
||||||
<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>
|
||||||
)}
|
)}
|
||||||
@@ -8196,6 +8398,7 @@ function ExportPage() {
|
|||||||
<div className="dialog-switch-copy">
|
<div className="dialog-switch-copy">
|
||||||
<h4>语音转文字</h4>
|
<h4>语音转文字</h4>
|
||||||
<div className="format-note">默认状态跟随更多导出设置中的语音转文字开关。</div>
|
<div className="format-note">默认状态跟随更多导出设置中的语音转文字开关。</div>
|
||||||
|
<div className="format-note">{voiceAsTextStatusLabel}</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -2934,3 +2934,488 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.anti-revoke-tab {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
|
||||||
|
.anti-revoke-hero {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--border-color) 88%, transparent);
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
color-mix(in srgb, var(--bg-secondary) 94%, var(--primary) 6%) 0%,
|
||||||
|
color-mix(in srgb, var(--bg-secondary) 96%, var(--bg-primary) 4%) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.anti-revoke-hero-main {
|
||||||
|
min-width: 240px;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 19px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: var(--text-primary);
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.anti-revoke-metrics {
|
||||||
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(112px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 460px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anti-revoke-metric {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--border-color) 90%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--bg-primary) 93%, var(--bg-secondary) 7%);
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.2;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-total {
|
||||||
|
border-color: color-mix(in srgb, var(--border-color) 78%, var(--primary) 22%);
|
||||||
|
background: color-mix(in srgb, var(--bg-primary) 88%, var(--primary) 12%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-installed {
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 36%, var(--border-color));
|
||||||
|
background: color-mix(in srgb, var(--bg-primary) 90%, var(--primary) 10%);
|
||||||
|
|
||||||
|
.value {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-pending {
|
||||||
|
background: color-mix(in srgb, var(--bg-primary) 95%, var(--bg-secondary) 5%);
|
||||||
|
|
||||||
|
.value {
|
||||||
|
color: color-mix(in srgb, var(--text-primary) 82%, var(--text-secondary));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-error {
|
||||||
|
border-color: color-mix(in srgb, var(--danger) 24%, var(--border-color));
|
||||||
|
background: color-mix(in srgb, var(--danger) 6%, var(--bg-primary));
|
||||||
|
|
||||||
|
.value {
|
||||||
|
color: color-mix(in srgb, var(--danger) 65%, var(--text-primary) 35%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.anti-revoke-control-card {
|
||||||
|
border: 1px solid color-mix(in srgb, var(--border-color) 90%, transparent);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 14px;
|
||||||
|
background: color-mix(in srgb, var(--bg-secondary) 95%, var(--bg-primary) 5%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.anti-revoke-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anti-revoke-search {
|
||||||
|
min-width: 280px;
|
||||||
|
flex: 1;
|
||||||
|
max-width: 420px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: color-mix(in srgb, var(--bg-primary) 85%, var(--bg-secondary) 15%);
|
||||||
|
|
||||||
|
input {
|
||||||
|
height: 36px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.anti-revoke-toolbar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anti-revoke-btn-group {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anti-revoke-batch-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-top: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent);
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anti-revoke-selected-count {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--bg-primary) 92%, var(--bg-secondary) 8%);
|
||||||
|
|
||||||
|
span {
|
||||||
|
position: relative;
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(:last-child)::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
right: -8px;
|
||||||
|
top: 50%;
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: color-mix(in srgb, var(--text-tertiary) 70%, transparent);
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.anti-revoke-toolbar-actions .btn,
|
||||||
|
.anti-revoke-batch-actions .btn {
|
||||||
|
border-radius: 10px;
|
||||||
|
padding-inline: 14px;
|
||||||
|
border-width: 1px;
|
||||||
|
min-height: 36px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anti-revoke-summary {
|
||||||
|
padding: 11px 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--border-color) 86%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--bg-primary) 95%, var(--bg-secondary) 5%);
|
||||||
|
line-height: 1.5;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
&.success {
|
||||||
|
color: color-mix(in srgb, var(--primary) 72%, var(--text-primary) 28%);
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 30%, var(--border-color));
|
||||||
|
background: color-mix(in srgb, var(--primary) 9%, var(--bg-primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
&.error {
|
||||||
|
color: color-mix(in srgb, var(--danger) 70%, var(--text-primary) 30%);
|
||||||
|
border-color: color-mix(in srgb, var(--danger) 24%, var(--border-color));
|
||||||
|
background: color-mix(in srgb, var(--danger) 7%, var(--bg-primary));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.anti-revoke-list {
|
||||||
|
border: 1px solid color-mix(in srgb, var(--border-color) 90%, transparent);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
max-height: 460px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anti-revoke-list-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: color-mix(in srgb, var(--bg-secondary) 93%, var(--bg-primary) 7%);
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 86%, transparent);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anti-revoke-empty {
|
||||||
|
padding: 44px 18px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anti-revoke-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 13px 16px;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
|
||||||
|
transition: background-color 0.18s ease, box-shadow 0.18s ease;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: color-mix(in srgb, var(--bg-secondary) 32%, var(--bg-primary) 68%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
background: color-mix(in srgb, var(--primary) 8%, var(--bg-primary));
|
||||||
|
box-shadow: inset 2px 0 0 color-mix(in srgb, var(--primary) 70%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.anti-revoke-row-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
.anti-revoke-check {
|
||||||
|
position: relative;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
input[type='checkbox'] {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
margin: 0;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-indicator {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--border-color) 78%, var(--primary) 22%);
|
||||||
|
background: color-mix(in srgb, var(--bg-primary) 86%, var(--bg-secondary) 14%);
|
||||||
|
color: var(--on-primary, #fff);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.16s ease;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.75);
|
||||||
|
transition: opacity 0.16s ease, transform 0.16s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='checkbox']:checked + .check-indicator {
|
||||||
|
background: var(--primary);
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='checkbox']:focus-visible + .check-indicator {
|
||||||
|
outline: 2px solid color-mix(in srgb, var(--primary) 42%, transparent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='checkbox']:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='checkbox']:disabled + .check-indicator {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.anti-revoke-row-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.anti-revoke-row-status {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
max-width: 45%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.3;
|
||||||
|
font-weight: 500;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: color-mix(in srgb, var(--bg-secondary) 90%, var(--bg-primary) 10%);
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-tertiary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.installed {
|
||||||
|
color: var(--primary);
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 35%, var(--border-color));
|
||||||
|
background: color-mix(in srgb, var(--primary) 10%, var(--bg-secondary));
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
background: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.not-installed {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-color: color-mix(in srgb, var(--border-color) 84%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--bg-secondary) 90%, var(--bg-primary) 10%);
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
background: color-mix(in srgb, var(--text-tertiary) 86%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.checking {
|
||||||
|
color: color-mix(in srgb, var(--primary) 70%, var(--text-primary) 30%);
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 30%, var(--border-color));
|
||||||
|
background: color-mix(in srgb, var(--primary) 9%, var(--bg-secondary));
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
background: var(--primary);
|
||||||
|
animation: pulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.error {
|
||||||
|
color: color-mix(in srgb, var(--danger) 72%, var(--text-primary) 28%);
|
||||||
|
border-color: color-mix(in srgb, var(--danger) 24%, var(--border-color));
|
||||||
|
background: color-mix(in srgb, var(--danger) 8%, var(--bg-secondary));
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
background: var(--danger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-error {
|
||||||
|
font-size: 12px;
|
||||||
|
color: color-mix(in srgb, var(--danger) 66%, var(--text-primary) 34%);
|
||||||
|
max-width: 100%;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.anti-revoke-hero {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anti-revoke-metrics {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
grid-template-columns: repeat(2, minmax(130px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.anti-revoke-batch-actions {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anti-revoke-selected-count {
|
||||||
|
margin-left: 0;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anti-revoke-row {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anti-revoke-row-status {
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,11 +15,12 @@ import {
|
|||||||
import { Avatar } from '../components/Avatar'
|
import { Avatar } from '../components/Avatar'
|
||||||
import './SettingsPage.scss'
|
import './SettingsPage.scss'
|
||||||
|
|
||||||
type SettingsTab = 'appearance' | 'notification' | 'database' | 'models' | 'cache' | 'api' | 'updates' | 'security' | 'about' | 'analytics'
|
type SettingsTab = 'appearance' | 'notification' | 'antiRevoke' | 'database' | 'models' | 'cache' | 'api' | 'updates' | 'security' | 'about' | 'analytics'
|
||||||
|
|
||||||
const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
|
const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
|
||||||
{ id: 'appearance', label: '外观', icon: Palette },
|
{ id: 'appearance', label: '外观', icon: Palette },
|
||||||
{ id: 'notification', label: '通知', icon: Bell },
|
{ id: 'notification', label: '通知', icon: Bell },
|
||||||
|
{ id: 'antiRevoke', label: '防撤回', icon: RotateCcw },
|
||||||
{ id: 'database', label: '数据库连接', icon: Database },
|
{ id: 'database', label: '数据库连接', icon: Database },
|
||||||
{ id: 'models', label: '模型管理', icon: Mic },
|
{ id: 'models', label: '模型管理', icon: Mic },
|
||||||
{ id: 'cache', label: '缓存', icon: HardDrive },
|
{ id: 'cache', label: '缓存', icon: HardDrive },
|
||||||
@@ -70,6 +71,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
setShowUpdateDialog,
|
setShowUpdateDialog,
|
||||||
} = useAppStore()
|
} = useAppStore()
|
||||||
|
|
||||||
|
const chatSessions = useChatStore((state) => state.sessions)
|
||||||
|
const setChatSessions = useChatStore((state) => state.setSessions)
|
||||||
const resetChatStore = useChatStore((state) => state.reset)
|
const resetChatStore = useChatStore((state) => state.reset)
|
||||||
const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore()
|
const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore()
|
||||||
const [systemDark, setSystemDark] = useState(() => window.matchMedia('(prefers-color-scheme: dark)').matches)
|
const [systemDark, setSystemDark] = useState(() => window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||||
@@ -138,6 +141,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
const [notificationPosition, setNotificationPosition] = useState<'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'>('top-right')
|
const [notificationPosition, setNotificationPosition] = useState<'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'>('top-right')
|
||||||
const [notificationFilterMode, setNotificationFilterMode] = useState<'all' | 'whitelist' | 'blacklist'>('all')
|
const [notificationFilterMode, setNotificationFilterMode] = useState<'all' | 'whitelist' | 'blacklist'>('all')
|
||||||
const [notificationFilterList, setNotificationFilterList] = useState<string[]>([])
|
const [notificationFilterList, setNotificationFilterList] = useState<string[]>([])
|
||||||
|
const [launchAtStartup, setLaunchAtStartup] = useState(false)
|
||||||
|
const [launchAtStartupSupported, setLaunchAtStartupSupported] = useState(isWindows || isMac)
|
||||||
|
const [launchAtStartupReason, setLaunchAtStartupReason] = useState('')
|
||||||
const [windowCloseBehavior, setWindowCloseBehavior] = useState<configService.WindowCloseBehavior>('ask')
|
const [windowCloseBehavior, setWindowCloseBehavior] = useState<configService.WindowCloseBehavior>('ask')
|
||||||
const [quoteLayout, setQuoteLayout] = useState<configService.QuoteLayout>('quote-top')
|
const [quoteLayout, setQuoteLayout] = useState<configService.QuoteLayout>('quote-top')
|
||||||
const [updateChannel, setUpdateChannel] = useState<configService.UpdateChannel>('stable')
|
const [updateChannel, setUpdateChannel] = useState<configService.UpdateChannel>('stable')
|
||||||
@@ -162,6 +168,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
const [isFetchingDbKey, setIsFetchingDbKey] = useState(false)
|
const [isFetchingDbKey, setIsFetchingDbKey] = useState(false)
|
||||||
const [isFetchingImageKey, setIsFetchingImageKey] = useState(false)
|
const [isFetchingImageKey, setIsFetchingImageKey] = useState(false)
|
||||||
const [isCheckingUpdate, setIsCheckingUpdate] = useState(false)
|
const [isCheckingUpdate, setIsCheckingUpdate] = useState(false)
|
||||||
|
const [isUpdatingLaunchAtStartup, setIsUpdatingLaunchAtStartup] = useState(false)
|
||||||
const [appVersion, setAppVersion] = useState('')
|
const [appVersion, setAppVersion] = useState('')
|
||||||
const [message, setMessage] = useState<{ text: string; success: boolean } | null>(null)
|
const [message, setMessage] = useState<{ text: string; success: boolean } | null>(null)
|
||||||
const [showDecryptKey, setShowDecryptKey] = useState(false)
|
const [showDecryptKey, setShowDecryptKey] = useState(false)
|
||||||
@@ -196,6 +203,13 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
const [isTogglingApi, setIsTogglingApi] = useState(false)
|
const [isTogglingApi, setIsTogglingApi] = useState(false)
|
||||||
const [showApiWarning, setShowApiWarning] = useState(false)
|
const [showApiWarning, setShowApiWarning] = useState(false)
|
||||||
const [messagePushEnabled, setMessagePushEnabled] = useState(false)
|
const [messagePushEnabled, setMessagePushEnabled] = useState(false)
|
||||||
|
const [antiRevokeSearchKeyword, setAntiRevokeSearchKeyword] = useState('')
|
||||||
|
const [antiRevokeSelectedIds, setAntiRevokeSelectedIds] = useState<Set<string>>(new Set())
|
||||||
|
const [antiRevokeStatusMap, setAntiRevokeStatusMap] = useState<Record<string, { installed?: boolean; loading?: boolean; error?: string }>>({})
|
||||||
|
const [isAntiRevokeRefreshing, setIsAntiRevokeRefreshing] = useState(false)
|
||||||
|
const [isAntiRevokeInstalling, setIsAntiRevokeInstalling] = useState(false)
|
||||||
|
const [isAntiRevokeUninstalling, setIsAntiRevokeUninstalling] = useState(false)
|
||||||
|
const [antiRevokeSummary, setAntiRevokeSummary] = useState<{ action: 'refresh' | 'install' | 'uninstall'; success: number; failed: number } | null>(null)
|
||||||
|
|
||||||
const isClearingCache = isClearingAnalyticsCache || isClearingImageCache || isClearingAllCache
|
const isClearingCache = isClearingAnalyticsCache || isClearingImageCache || isClearingAllCache
|
||||||
|
|
||||||
@@ -337,6 +351,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
const savedNotificationFilterMode = await configService.getNotificationFilterMode()
|
const savedNotificationFilterMode = await configService.getNotificationFilterMode()
|
||||||
const savedNotificationFilterList = await configService.getNotificationFilterList()
|
const savedNotificationFilterList = await configService.getNotificationFilterList()
|
||||||
const savedMessagePushEnabled = await configService.getMessagePushEnabled()
|
const savedMessagePushEnabled = await configService.getMessagePushEnabled()
|
||||||
|
const savedLaunchAtStartupStatus = await window.electronAPI.app.getLaunchAtStartupStatus()
|
||||||
const savedWindowCloseBehavior = await configService.getWindowCloseBehavior()
|
const savedWindowCloseBehavior = await configService.getWindowCloseBehavior()
|
||||||
const savedQuoteLayout = await configService.getQuoteLayout()
|
const savedQuoteLayout = await configService.getQuoteLayout()
|
||||||
const savedUpdateChannel = await configService.getUpdateChannel()
|
const savedUpdateChannel = await configService.getUpdateChannel()
|
||||||
@@ -386,15 +401,18 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
setNotificationFilterMode(savedNotificationFilterMode)
|
setNotificationFilterMode(savedNotificationFilterMode)
|
||||||
setNotificationFilterList(savedNotificationFilterList)
|
setNotificationFilterList(savedNotificationFilterList)
|
||||||
setMessagePushEnabled(savedMessagePushEnabled)
|
setMessagePushEnabled(savedMessagePushEnabled)
|
||||||
|
setLaunchAtStartup(savedLaunchAtStartupStatus.enabled)
|
||||||
|
setLaunchAtStartupSupported(savedLaunchAtStartupStatus.supported)
|
||||||
|
setLaunchAtStartupReason(savedLaunchAtStartupStatus.reason || '')
|
||||||
setWindowCloseBehavior(savedWindowCloseBehavior)
|
setWindowCloseBehavior(savedWindowCloseBehavior)
|
||||||
setQuoteLayout(savedQuoteLayout)
|
setQuoteLayout(savedQuoteLayout)
|
||||||
if (savedUpdateChannel) {
|
if (savedUpdateChannel) {
|
||||||
setUpdateChannel(savedUpdateChannel)
|
setUpdateChannel(savedUpdateChannel)
|
||||||
} else {
|
} else {
|
||||||
const currentVersion = await window.electronAPI.app.getVersion()
|
const currentVersion = await window.electronAPI.app.getVersion()
|
||||||
if (/-preview\.\d+\.\d+$/i.test(currentVersion)) {
|
if (/^0\.\d{2}\.\d+$/i.test(currentVersion) || /-preview\.\d+\.\d+$/i.test(currentVersion)) {
|
||||||
setUpdateChannel('preview')
|
setUpdateChannel('preview')
|
||||||
} else if (/-dev\.\d+\.\d+\.\d+$/i.test(currentVersion) || /(alpha|beta|rc)/i.test(currentVersion)) {
|
} else if (/^\d{2}\.\d{1,2}\.\d{1,2}$/i.test(currentVersion) || /-dev\.\d+\.\d+\.\d+$/i.test(currentVersion) || /(alpha|beta|rc)/i.test(currentVersion)) {
|
||||||
setUpdateChannel('dev')
|
setUpdateChannel('dev')
|
||||||
} else {
|
} else {
|
||||||
setUpdateChannel('stable')
|
setUpdateChannel('stable')
|
||||||
@@ -428,6 +446,29 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const handleLaunchAtStartupChange = async (enabled: boolean) => {
|
||||||
|
if (isUpdatingLaunchAtStartup) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsUpdatingLaunchAtStartup(true)
|
||||||
|
const result = await window.electronAPI.app.setLaunchAtStartup(enabled)
|
||||||
|
setLaunchAtStartup(result.enabled)
|
||||||
|
setLaunchAtStartupSupported(result.supported)
|
||||||
|
setLaunchAtStartupReason(result.reason || '')
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
showMessage(enabled ? '已开启开机自启动' : '已关闭开机自启动', true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
showMessage(result.error || result.reason || '设置开机自启动失败', false)
|
||||||
|
} catch (e: any) {
|
||||||
|
showMessage(`设置开机自启动失败: ${e?.message || String(e)}`, false)
|
||||||
|
} finally {
|
||||||
|
setIsUpdatingLaunchAtStartup(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const refreshWhisperStatus = async (modelDirValue = whisperModelDir) => {
|
const refreshWhisperStatus = async (modelDirValue = whisperModelDir) => {
|
||||||
try {
|
try {
|
||||||
const result = await window.electronAPI.whisper?.getModelStatus()
|
const result = await window.electronAPI.whisper?.getModelStatus()
|
||||||
@@ -555,6 +596,248 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
}, 200)
|
}, 200)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizeSessionIds = (sessionIds: string[]): string[] =>
|
||||||
|
Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean)))
|
||||||
|
|
||||||
|
const getCurrentAntiRevokeSessionIds = (): string[] =>
|
||||||
|
normalizeSessionIds(chatSessions.map((session) => session.username))
|
||||||
|
|
||||||
|
const ensureAntiRevokeSessionsLoaded = async (): Promise<string[]> => {
|
||||||
|
const current = getCurrentAntiRevokeSessionIds()
|
||||||
|
if (current.length > 0) return current
|
||||||
|
const sessionsResult = await window.electronAPI.chat.getSessions()
|
||||||
|
if (!sessionsResult.success || !sessionsResult.sessions) {
|
||||||
|
throw new Error(sessionsResult.error || '加载会话失败')
|
||||||
|
}
|
||||||
|
setChatSessions(sessionsResult.sessions)
|
||||||
|
return normalizeSessionIds(sessionsResult.sessions.map((session) => session.username))
|
||||||
|
}
|
||||||
|
|
||||||
|
const markAntiRevokeRowsLoading = (sessionIds: string[]) => {
|
||||||
|
setAntiRevokeStatusMap((prev) => {
|
||||||
|
const next = { ...prev }
|
||||||
|
for (const sessionId of sessionIds) {
|
||||||
|
next[sessionId] = {
|
||||||
|
...(next[sessionId] || {}),
|
||||||
|
loading: true,
|
||||||
|
error: undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRefreshAntiRevokeStatus = async (sessionIds?: string[]) => {
|
||||||
|
if (isAntiRevokeRefreshing || isAntiRevokeInstalling || isAntiRevokeUninstalling) return
|
||||||
|
setAntiRevokeSummary(null)
|
||||||
|
setIsAntiRevokeRefreshing(true)
|
||||||
|
try {
|
||||||
|
const targetIds = normalizeSessionIds(
|
||||||
|
sessionIds && sessionIds.length > 0
|
||||||
|
? sessionIds
|
||||||
|
: await ensureAntiRevokeSessionsLoaded()
|
||||||
|
)
|
||||||
|
if (targetIds.length === 0) {
|
||||||
|
setAntiRevokeStatusMap({})
|
||||||
|
showMessage('暂无可检查的会话', true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
markAntiRevokeRowsLoading(targetIds)
|
||||||
|
|
||||||
|
const result = await window.electronAPI.chat.checkAntiRevokeTriggers(targetIds)
|
||||||
|
if (!result.success || !result.rows) {
|
||||||
|
const errorText = result.error || '防撤回状态检查失败'
|
||||||
|
setAntiRevokeStatusMap((prev) => {
|
||||||
|
const next = { ...prev }
|
||||||
|
for (const sessionId of targetIds) {
|
||||||
|
next[sessionId] = {
|
||||||
|
...(next[sessionId] || {}),
|
||||||
|
loading: false,
|
||||||
|
error: errorText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
showMessage(errorText, false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowMap = new Map<string, { sessionId: string; success: boolean; installed?: boolean; error?: string }>()
|
||||||
|
for (const row of result.rows || []) {
|
||||||
|
const sessionId = String(row.sessionId || '').trim()
|
||||||
|
if (!sessionId) continue
|
||||||
|
rowMap.set(sessionId, row)
|
||||||
|
}
|
||||||
|
const mergedRows = targetIds.map((sessionId) => (
|
||||||
|
rowMap.get(sessionId) || { sessionId, success: false, error: '状态查询未返回结果' }
|
||||||
|
))
|
||||||
|
const successCount = mergedRows.filter((row) => row.success).length
|
||||||
|
const failedCount = mergedRows.length - successCount
|
||||||
|
setAntiRevokeStatusMap((prev) => {
|
||||||
|
const next = { ...prev }
|
||||||
|
for (const row of mergedRows) {
|
||||||
|
const sessionId = String(row.sessionId || '').trim()
|
||||||
|
if (!sessionId) continue
|
||||||
|
next[sessionId] = {
|
||||||
|
installed: row.installed === true,
|
||||||
|
loading: false,
|
||||||
|
error: row.success ? undefined : (row.error || '状态查询失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
setAntiRevokeSummary({ action: 'refresh', success: successCount, failed: failedCount })
|
||||||
|
showMessage(`状态刷新完成:成功 ${successCount},失败 ${failedCount}`, failedCount === 0)
|
||||||
|
} catch (e: any) {
|
||||||
|
showMessage(`防撤回状态刷新失败: ${e?.message || String(e)}`, false)
|
||||||
|
} finally {
|
||||||
|
setIsAntiRevokeRefreshing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInstallAntiRevokeTriggers = async () => {
|
||||||
|
if (isAntiRevokeRefreshing || isAntiRevokeInstalling || isAntiRevokeUninstalling) return
|
||||||
|
const sessionIds = normalizeSessionIds(Array.from(antiRevokeSelectedIds))
|
||||||
|
if (sessionIds.length === 0) {
|
||||||
|
showMessage('请先选择至少一个会话', false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setAntiRevokeSummary(null)
|
||||||
|
setIsAntiRevokeInstalling(true)
|
||||||
|
try {
|
||||||
|
markAntiRevokeRowsLoading(sessionIds)
|
||||||
|
const result = await window.electronAPI.chat.installAntiRevokeTriggers(sessionIds)
|
||||||
|
if (!result.success || !result.rows) {
|
||||||
|
const errorText = result.error || '批量安装失败'
|
||||||
|
setAntiRevokeStatusMap((prev) => {
|
||||||
|
const next = { ...prev }
|
||||||
|
for (const sessionId of sessionIds) {
|
||||||
|
next[sessionId] = {
|
||||||
|
...(next[sessionId] || {}),
|
||||||
|
loading: false,
|
||||||
|
error: errorText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
showMessage(errorText, false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowMap = new Map<string, { sessionId: string; success: boolean; alreadyInstalled?: boolean; error?: string }>()
|
||||||
|
for (const row of result.rows || []) {
|
||||||
|
const sessionId = String(row.sessionId || '').trim()
|
||||||
|
if (!sessionId) continue
|
||||||
|
rowMap.set(sessionId, row)
|
||||||
|
}
|
||||||
|
const mergedRows = sessionIds.map((sessionId) => (
|
||||||
|
rowMap.get(sessionId) || { sessionId, success: false, error: '安装未返回结果' }
|
||||||
|
))
|
||||||
|
const successCount = mergedRows.filter((row) => row.success).length
|
||||||
|
const failedCount = mergedRows.length - successCount
|
||||||
|
setAntiRevokeStatusMap((prev) => {
|
||||||
|
const next = { ...prev }
|
||||||
|
for (const row of mergedRows) {
|
||||||
|
const sessionId = String(row.sessionId || '').trim()
|
||||||
|
if (!sessionId) continue
|
||||||
|
next[sessionId] = {
|
||||||
|
installed: row.success ? true : next[sessionId]?.installed,
|
||||||
|
loading: false,
|
||||||
|
error: row.success ? undefined : (row.error || '安装失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
setAntiRevokeSummary({ action: 'install', success: successCount, failed: failedCount })
|
||||||
|
showMessage(`批量安装完成:成功 ${successCount},失败 ${failedCount}`, failedCount === 0)
|
||||||
|
} catch (e: any) {
|
||||||
|
showMessage(`批量安装失败: ${e?.message || String(e)}`, false)
|
||||||
|
} finally {
|
||||||
|
setIsAntiRevokeInstalling(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUninstallAntiRevokeTriggers = async () => {
|
||||||
|
if (isAntiRevokeRefreshing || isAntiRevokeInstalling || isAntiRevokeUninstalling) return
|
||||||
|
const sessionIds = normalizeSessionIds(Array.from(antiRevokeSelectedIds))
|
||||||
|
if (sessionIds.length === 0) {
|
||||||
|
showMessage('请先选择至少一个会话', false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setAntiRevokeSummary(null)
|
||||||
|
setIsAntiRevokeUninstalling(true)
|
||||||
|
try {
|
||||||
|
markAntiRevokeRowsLoading(sessionIds)
|
||||||
|
const result = await window.electronAPI.chat.uninstallAntiRevokeTriggers(sessionIds)
|
||||||
|
if (!result.success || !result.rows) {
|
||||||
|
const errorText = result.error || '批量卸载失败'
|
||||||
|
setAntiRevokeStatusMap((prev) => {
|
||||||
|
const next = { ...prev }
|
||||||
|
for (const sessionId of sessionIds) {
|
||||||
|
next[sessionId] = {
|
||||||
|
...(next[sessionId] || {}),
|
||||||
|
loading: false,
|
||||||
|
error: errorText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
showMessage(errorText, false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowMap = new Map<string, { sessionId: string; success: boolean; error?: string }>()
|
||||||
|
for (const row of result.rows || []) {
|
||||||
|
const sessionId = String(row.sessionId || '').trim()
|
||||||
|
if (!sessionId) continue
|
||||||
|
rowMap.set(sessionId, row)
|
||||||
|
}
|
||||||
|
const mergedRows = sessionIds.map((sessionId) => (
|
||||||
|
rowMap.get(sessionId) || { sessionId, success: false, error: '卸载未返回结果' }
|
||||||
|
))
|
||||||
|
const successCount = mergedRows.filter((row) => row.success).length
|
||||||
|
const failedCount = mergedRows.length - successCount
|
||||||
|
setAntiRevokeStatusMap((prev) => {
|
||||||
|
const next = { ...prev }
|
||||||
|
for (const row of mergedRows) {
|
||||||
|
const sessionId = String(row.sessionId || '').trim()
|
||||||
|
if (!sessionId) continue
|
||||||
|
next[sessionId] = {
|
||||||
|
installed: row.success ? false : next[sessionId]?.installed,
|
||||||
|
loading: false,
|
||||||
|
error: row.success ? undefined : (row.error || '卸载失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
setAntiRevokeSummary({ action: 'uninstall', success: successCount, failed: failedCount })
|
||||||
|
showMessage(`批量卸载完成:成功 ${successCount},失败 ${failedCount}`, failedCount === 0)
|
||||||
|
} catch (e: any) {
|
||||||
|
showMessage(`批量卸载失败: ${e?.message || String(e)}`, false)
|
||||||
|
} finally {
|
||||||
|
setIsAntiRevokeUninstalling(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab !== 'antiRevoke') return
|
||||||
|
let canceled = false
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
const sessionIds = await ensureAntiRevokeSessionsLoaded()
|
||||||
|
if (canceled) return
|
||||||
|
await handleRefreshAntiRevokeStatus(sessionIds)
|
||||||
|
} catch (e: any) {
|
||||||
|
if (!canceled) {
|
||||||
|
showMessage(`加载防撤回会话失败: ${e?.message || String(e)}`, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
canceled = true
|
||||||
|
}
|
||||||
|
}, [activeTab])
|
||||||
|
|
||||||
type WxidKeys = {
|
type WxidKeys = {
|
||||||
decryptKey: string
|
decryptKey: string
|
||||||
imageXorKey: number | null
|
imageXorKey: number | null
|
||||||
@@ -1199,6 +1482,39 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
|
|
||||||
<div className="divider" />
|
<div className="divider" />
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>开机自启动</label>
|
||||||
|
<span className="form-hint">
|
||||||
|
{launchAtStartupSupported
|
||||||
|
? '开启后,登录系统时会自动启动 WeFlow。'
|
||||||
|
: launchAtStartupReason || '当前环境暂不支持开机自启动。'}
|
||||||
|
</span>
|
||||||
|
<div className="log-toggle-line">
|
||||||
|
<span className="log-status">
|
||||||
|
{isUpdatingLaunchAtStartup
|
||||||
|
? '保存中...'
|
||||||
|
: launchAtStartupSupported
|
||||||
|
? (launchAtStartup ? '已开启' : '已关闭')
|
||||||
|
: '当前不可用'}
|
||||||
|
</span>
|
||||||
|
<label className="switch" htmlFor="launch-at-startup-toggle">
|
||||||
|
<input
|
||||||
|
id="launch-at-startup-toggle"
|
||||||
|
className="switch-input"
|
||||||
|
type="checkbox"
|
||||||
|
checked={launchAtStartup}
|
||||||
|
disabled={!launchAtStartupSupported || isUpdatingLaunchAtStartup}
|
||||||
|
onChange={(e) => {
|
||||||
|
void handleLaunchAtStartupChange(e.target.checked)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="switch-slider" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divider" />
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>关闭主窗口时</label>
|
<label>关闭主窗口时</label>
|
||||||
<span className="form-hint">设置点击关闭按钮后的默认行为;选择“每次询问”时会弹出关闭确认。</span>
|
<span className="form-hint">设置点击关闭按钮后的默认行为;选择“每次询问”时会弹出关闭确认。</span>
|
||||||
@@ -1255,11 +1571,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const renderNotificationTab = () => {
|
const renderNotificationTab = () => {
|
||||||
const { sessions } = useChatStore.getState()
|
|
||||||
|
|
||||||
// 获取已过滤会话的信息
|
// 获取已过滤会话的信息
|
||||||
const getSessionInfo = (username: string) => {
|
const getSessionInfo = (username: string) => {
|
||||||
const session = sessions.find(s => s.username === username)
|
const session = chatSessions.find(s => s.username === username)
|
||||||
return {
|
return {
|
||||||
displayName: session?.displayName || username,
|
displayName: session?.displayName || username,
|
||||||
avatarUrl: session?.avatarUrl || ''
|
avatarUrl: session?.avatarUrl || ''
|
||||||
@@ -1284,7 +1598,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 过滤掉已在列表中的会话,并根据搜索关键字过滤
|
// 过滤掉已在列表中的会话,并根据搜索关键字过滤
|
||||||
const availableSessions = sessions.filter(s => {
|
const availableSessions = chatSessions.filter(s => {
|
||||||
if (notificationFilterList.includes(s.username)) return false
|
if (notificationFilterList.includes(s.username)) return false
|
||||||
if (filterSearchKeyword) {
|
if (filterSearchKeyword) {
|
||||||
const keyword = filterSearchKeyword.toLowerCase()
|
const keyword = filterSearchKeyword.toLowerCase()
|
||||||
@@ -1500,6 +1814,199 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const renderAntiRevokeTab = () => {
|
||||||
|
const sortedSessions = [...chatSessions].sort((a, b) => (b.sortTimestamp || 0) - (a.sortTimestamp || 0))
|
||||||
|
const keyword = antiRevokeSearchKeyword.trim().toLowerCase()
|
||||||
|
const filteredSessions = sortedSessions.filter((session) => {
|
||||||
|
if (!keyword) return true
|
||||||
|
const displayName = String(session.displayName || '').toLowerCase()
|
||||||
|
const username = String(session.username || '').toLowerCase()
|
||||||
|
return displayName.includes(keyword) || username.includes(keyword)
|
||||||
|
})
|
||||||
|
const filteredSessionIds = filteredSessions.map((session) => session.username)
|
||||||
|
const selectedCount = antiRevokeSelectedIds.size
|
||||||
|
const selectedInFilteredCount = filteredSessionIds.filter((sessionId) => antiRevokeSelectedIds.has(sessionId)).length
|
||||||
|
const allFilteredSelected = filteredSessionIds.length > 0 && selectedInFilteredCount === filteredSessionIds.length
|
||||||
|
const busy = isAntiRevokeRefreshing || isAntiRevokeInstalling || isAntiRevokeUninstalling
|
||||||
|
const statusStats = filteredSessions.reduce(
|
||||||
|
(acc, session) => {
|
||||||
|
const rowState = antiRevokeStatusMap[session.username]
|
||||||
|
if (rowState?.error) acc.failed += 1
|
||||||
|
else if (rowState?.installed === true) acc.installed += 1
|
||||||
|
else if (rowState?.installed === false) acc.notInstalled += 1
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{ installed: 0, notInstalled: 0, failed: 0 }
|
||||||
|
)
|
||||||
|
|
||||||
|
const toggleSelected = (sessionId: string) => {
|
||||||
|
setAntiRevokeSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(sessionId)) next.delete(sessionId)
|
||||||
|
else next.add(sessionId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectAllFiltered = () => {
|
||||||
|
if (filteredSessionIds.length === 0) return
|
||||||
|
setAntiRevokeSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
for (const sessionId of filteredSessionIds) {
|
||||||
|
next.add(sessionId)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearSelection = () => {
|
||||||
|
setAntiRevokeSelectedIds(new Set())
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tab-content anti-revoke-tab">
|
||||||
|
<div className="anti-revoke-hero">
|
||||||
|
<div className="anti-revoke-hero-main">
|
||||||
|
<h3>防撤回</h3>
|
||||||
|
<p>你可以根据会话进行防撤回部署,安装后无需保持 WeFlow 运行即可实现防撤回</p>
|
||||||
|
</div>
|
||||||
|
<div className="anti-revoke-metrics">
|
||||||
|
<div className="anti-revoke-metric is-total">
|
||||||
|
<span className="label">筛选会话</span>
|
||||||
|
<span className="value">{filteredSessionIds.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="anti-revoke-metric is-installed">
|
||||||
|
<span className="label">已安装</span>
|
||||||
|
<span className="value">{statusStats.installed}</span>
|
||||||
|
</div>
|
||||||
|
<div className="anti-revoke-metric is-pending">
|
||||||
|
<span className="label">未安装</span>
|
||||||
|
<span className="value">{statusStats.notInstalled}</span>
|
||||||
|
</div>
|
||||||
|
<div className="anti-revoke-metric is-error">
|
||||||
|
<span className="label">异常</span>
|
||||||
|
<span className="value">{statusStats.failed}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="anti-revoke-control-card">
|
||||||
|
<div className="anti-revoke-toolbar">
|
||||||
|
<div className="filter-search-box anti-revoke-search">
|
||||||
|
<Search size={14} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索会话..."
|
||||||
|
value={antiRevokeSearchKeyword}
|
||||||
|
onChange={(e) => setAntiRevokeSearchKeyword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="anti-revoke-toolbar-actions">
|
||||||
|
<div className="anti-revoke-btn-group">
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={() => void handleRefreshAntiRevokeStatus()} disabled={busy}>
|
||||||
|
<RefreshCw size={14} /> {isAntiRevokeRefreshing ? '刷新中...' : '刷新状态'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="anti-revoke-btn-group">
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={selectAllFiltered} disabled={busy || filteredSessionIds.length === 0 || allFilteredSelected}>
|
||||||
|
全选
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={clearSelection} disabled={busy || selectedCount === 0}>
|
||||||
|
清空选择
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="anti-revoke-batch-actions">
|
||||||
|
<div className="anti-revoke-btn-group anti-revoke-batch-btns">
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={() => void handleInstallAntiRevokeTriggers()} disabled={busy || selectedCount === 0}>
|
||||||
|
{isAntiRevokeInstalling ? '安装中...' : '批量安装'}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={() => void handleUninstallAntiRevokeTriggers()} disabled={busy || selectedCount === 0}>
|
||||||
|
{isAntiRevokeUninstalling ? '卸载中...' : '批量卸载'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="anti-revoke-selected-count">
|
||||||
|
<span>已选 <strong>{selectedCount}</strong> 个会话</span>
|
||||||
|
<span>筛选命中 <strong>{selectedInFilteredCount}</strong> / {filteredSessionIds.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{antiRevokeSummary && (
|
||||||
|
<div className={`anti-revoke-summary ${antiRevokeSummary.failed > 0 ? 'error' : 'success'}`}>
|
||||||
|
{antiRevokeSummary.action === 'refresh' ? '刷新' : antiRevokeSummary.action === 'install' ? '安装' : '卸载'}
|
||||||
|
完成:成功 {antiRevokeSummary.success},失败 {antiRevokeSummary.failed}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="anti-revoke-list">
|
||||||
|
{filteredSessions.length === 0 ? (
|
||||||
|
<div className="anti-revoke-empty">{antiRevokeSearchKeyword ? '没有匹配的会话' : '暂无会话可配置'}</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="anti-revoke-list-header">
|
||||||
|
<span>会话({filteredSessions.length})</span>
|
||||||
|
<span>状态</span>
|
||||||
|
</div>
|
||||||
|
{filteredSessions.map((session) => {
|
||||||
|
const rowState = antiRevokeStatusMap[session.username]
|
||||||
|
let statusClass = 'unknown'
|
||||||
|
let statusLabel = '未检查'
|
||||||
|
if (rowState?.loading) {
|
||||||
|
statusClass = 'checking'
|
||||||
|
statusLabel = '检查中'
|
||||||
|
} else if (rowState?.error) {
|
||||||
|
statusClass = 'error'
|
||||||
|
statusLabel = '失败'
|
||||||
|
} else if (rowState?.installed === true) {
|
||||||
|
statusClass = 'installed'
|
||||||
|
statusLabel = '已安装'
|
||||||
|
} else if (rowState?.installed === false) {
|
||||||
|
statusClass = 'not-installed'
|
||||||
|
statusLabel = '未安装'
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div key={session.username} className={`anti-revoke-row ${antiRevokeSelectedIds.has(session.username) ? 'selected' : ''}`}>
|
||||||
|
<label className="anti-revoke-row-main">
|
||||||
|
<span className="anti-revoke-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={antiRevokeSelectedIds.has(session.username)}
|
||||||
|
onChange={() => toggleSelected(session.username)}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
<span className="check-indicator" aria-hidden="true">
|
||||||
|
<Check size={12} />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<Avatar
|
||||||
|
src={session.avatarUrl}
|
||||||
|
name={session.displayName || session.username}
|
||||||
|
size={30}
|
||||||
|
/>
|
||||||
|
<div className="anti-revoke-row-text">
|
||||||
|
<span className="name">{session.displayName || session.username}</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<div className="anti-revoke-row-status">
|
||||||
|
<span className={`status-badge ${statusClass}`}>
|
||||||
|
<i className="status-dot" aria-hidden="true" />
|
||||||
|
{statusLabel}
|
||||||
|
</span>
|
||||||
|
{rowState?.error && <span className="status-error">{rowState.error}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const renderDatabaseTab = () => (
|
const renderDatabaseTab = () => (
|
||||||
<div className="tab-content">
|
<div className="tab-content">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
@@ -2444,7 +2951,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
<div className="about-footer">
|
<div className="about-footer">
|
||||||
<p className="about-desc">微信聊天记录分析工具</p>
|
<p className="about-desc">微信聊天记录分析工具</p>
|
||||||
<div className="about-links">
|
<div className="about-links">
|
||||||
<a href="#" onClick={(e) => { e.preventDefault(); window.electronAPI.shell.openExternal('https://github.com/hicccc77/WeFlow') }}>官网</a>
|
<a href="#" onClick={(e) => { e.preventDefault(); window.electronAPI.shell.openExternal('https://weflow.top') }}>官网</a>
|
||||||
|
<span>·</span>
|
||||||
|
<a href="#" onClick={(e) => { e.preventDefault(); window.electronAPI.shell.openExternal('https://github.com/hicccc77/WeFlow') }}>GitHub 仓库</a>
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
<a href="#" onClick={(e) => { e.preventDefault(); window.electronAPI.shell.openExternal('https://chatlab.fun') }}>ChatLab</a>
|
<a href="#" onClick={(e) => { e.preventDefault(); window.electronAPI.shell.openExternal('https://chatlab.fun') }}>ChatLab</a>
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
@@ -2621,6 +3130,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
<div className="settings-body">
|
<div className="settings-body">
|
||||||
{activeTab === 'appearance' && renderAppearanceTab()}
|
{activeTab === 'appearance' && renderAppearanceTab()}
|
||||||
{activeTab === 'notification' && renderNotificationTab()}
|
{activeTab === 'notification' && renderNotificationTab()}
|
||||||
|
{activeTab === 'antiRevoke' && renderAntiRevokeTab()}
|
||||||
{activeTab === 'database' && renderDatabaseTab()}
|
{activeTab === 'database' && renderDatabaseTab()}
|
||||||
{activeTab === 'models' && renderModelsTab()}
|
{activeTab === 'models' && renderModelsTab()}
|
||||||
{activeTab === 'cache' && renderCacheTab()}
|
{activeTab === 'cache' && renderCacheTab()}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const CONFIG_KEYS = {
|
|||||||
LAST_SESSION: 'lastSession',
|
LAST_SESSION: 'lastSession',
|
||||||
WINDOW_BOUNDS: 'windowBounds',
|
WINDOW_BOUNDS: 'windowBounds',
|
||||||
CACHE_PATH: 'cachePath',
|
CACHE_PATH: 'cachePath',
|
||||||
|
LAUNCH_AT_STARTUP: 'launchAtStartup',
|
||||||
|
|
||||||
EXPORT_PATH: 'exportPath',
|
EXPORT_PATH: 'exportPath',
|
||||||
AGREEMENT_ACCEPTED: 'agreementAccepted',
|
AGREEMENT_ACCEPTED: 'agreementAccepted',
|
||||||
@@ -93,6 +94,7 @@ export interface ExportDefaultMediaConfig {
|
|||||||
videos: boolean
|
videos: boolean
|
||||||
voices: boolean
|
voices: boolean
|
||||||
emojis: boolean
|
emojis: boolean
|
||||||
|
files: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WindowCloseBehavior = 'ask' | 'tray' | 'quit'
|
export type WindowCloseBehavior = 'ask' | 'tray' | 'quit'
|
||||||
@@ -103,7 +105,8 @@ const DEFAULT_EXPORT_MEDIA_CONFIG: ExportDefaultMediaConfig = {
|
|||||||
images: true,
|
images: true,
|
||||||
videos: true,
|
videos: true,
|
||||||
voices: true,
|
voices: true,
|
||||||
emojis: true
|
emojis: true,
|
||||||
|
files: true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取解密密钥
|
// 获取解密密钥
|
||||||
@@ -258,6 +261,18 @@ export async function setLogEnabled(enabled: boolean): Promise<void> {
|
|||||||
await config.set(CONFIG_KEYS.LOG_ENABLED, enabled)
|
await config.set(CONFIG_KEYS.LOG_ENABLED, enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取开机自启动偏好
|
||||||
|
export async function getLaunchAtStartup(): Promise<boolean | null> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.LAUNCH_AT_STARTUP)
|
||||||
|
if (typeof value === 'boolean') return value
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置开机自启动偏好
|
||||||
|
export async function setLaunchAtStartup(enabled: boolean): Promise<void> {
|
||||||
|
await config.set(CONFIG_KEYS.LAUNCH_AT_STARTUP, enabled)
|
||||||
|
}
|
||||||
|
|
||||||
// 获取 LLM 模型路径
|
// 获取 LLM 模型路径
|
||||||
export async function getLlmModelPath(): Promise<string | null> {
|
export async function getLlmModelPath(): Promise<string | null> {
|
||||||
const value = await config.get(CONFIG_KEYS.LLM_MODEL_PATH)
|
const value = await config.get(CONFIG_KEYS.LLM_MODEL_PATH)
|
||||||
@@ -410,7 +425,8 @@ export async function getExportDefaultMedia(): Promise<ExportDefaultMediaConfig
|
|||||||
images: value,
|
images: value,
|
||||||
videos: value,
|
videos: value,
|
||||||
voices: value,
|
voices: value,
|
||||||
emojis: value
|
emojis: value,
|
||||||
|
files: value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (value && typeof value === 'object') {
|
if (value && typeof value === 'object') {
|
||||||
@@ -419,7 +435,8 @@ export async function getExportDefaultMedia(): Promise<ExportDefaultMediaConfig
|
|||||||
images: typeof raw.images === 'boolean' ? raw.images : DEFAULT_EXPORT_MEDIA_CONFIG.images,
|
images: typeof raw.images === 'boolean' ? raw.images : DEFAULT_EXPORT_MEDIA_CONFIG.images,
|
||||||
videos: typeof raw.videos === 'boolean' ? raw.videos : DEFAULT_EXPORT_MEDIA_CONFIG.videos,
|
videos: typeof raw.videos === 'boolean' ? raw.videos : DEFAULT_EXPORT_MEDIA_CONFIG.videos,
|
||||||
voices: typeof raw.voices === 'boolean' ? raw.voices : DEFAULT_EXPORT_MEDIA_CONFIG.voices,
|
voices: typeof raw.voices === 'boolean' ? raw.voices : DEFAULT_EXPORT_MEDIA_CONFIG.voices,
|
||||||
emojis: typeof raw.emojis === 'boolean' ? raw.emojis : DEFAULT_EXPORT_MEDIA_CONFIG.emojis
|
emojis: typeof raw.emojis === 'boolean' ? raw.emojis : DEFAULT_EXPORT_MEDIA_CONFIG.emojis,
|
||||||
|
files: typeof raw.files === 'boolean' ? raw.files : DEFAULT_EXPORT_MEDIA_CONFIG.files
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
@@ -431,7 +448,8 @@ export async function setExportDefaultMedia(media: ExportDefaultMediaConfig): Pr
|
|||||||
images: media.images,
|
images: media.images,
|
||||||
videos: media.videos,
|
videos: media.videos,
|
||||||
voices: media.voices,
|
voices: media.voices,
|
||||||
emojis: media.emojis
|
emojis: media.emojis,
|
||||||
|
files: media.files
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,46 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import type { ChatSession, Message, Contact } from '../types/models'
|
import type { ChatSession, Message, Contact } from '../types/models'
|
||||||
|
|
||||||
|
const messageAliasIndex = new Set<string>()
|
||||||
|
|
||||||
|
function buildPrimaryMessageKey(message: Message): string {
|
||||||
|
if (message.messageKey) return String(message.messageKey)
|
||||||
|
return `fallback:${message.serverId || 0}:${message.createTime}:${message.sortSeq || 0}:${message.localId || 0}:${message.senderUsername || ''}:${message.localType || 0}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMessageAliasKeys(message: Message): string[] {
|
||||||
|
const keys = [buildPrimaryMessageKey(message)]
|
||||||
|
const localId = Math.max(0, Number(message.localId || 0))
|
||||||
|
const serverId = Math.max(0, Number(message.serverId || 0))
|
||||||
|
const createTime = Math.max(0, Number(message.createTime || 0))
|
||||||
|
const localType = Math.floor(Number(message.localType || 0))
|
||||||
|
const sender = String(message.senderUsername || '')
|
||||||
|
const isSend = Number(message.isSend ?? -1)
|
||||||
|
|
||||||
|
if (localId > 0) {
|
||||||
|
keys.push(`lid:${localId}`)
|
||||||
|
}
|
||||||
|
if (serverId > 0) {
|
||||||
|
keys.push(`sid:${serverId}`)
|
||||||
|
}
|
||||||
|
if (localType === 3) {
|
||||||
|
const imageIdentity = String(message.imageMd5 || message.imageDatName || '').trim()
|
||||||
|
if (imageIdentity) {
|
||||||
|
keys.push(`img:${createTime}:${sender}:${isSend}:${imageIdentity}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuildMessageAliasIndex(messages: Message[]): void {
|
||||||
|
messageAliasIndex.clear()
|
||||||
|
for (const message of messages) {
|
||||||
|
const aliasKeys = buildMessageAliasKeys(message)
|
||||||
|
aliasKeys.forEach((key) => messageAliasIndex.add(key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChatState {
|
export interface ChatState {
|
||||||
// 连接状态
|
// 连接状态
|
||||||
isConnected: boolean
|
isConnected: boolean
|
||||||
@@ -69,59 +109,37 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
setSessions: (sessions) => set({ sessions, filteredSessions: sessions }),
|
setSessions: (sessions) => set({ sessions, filteredSessions: sessions }),
|
||||||
setFilteredSessions: (sessions) => set({ filteredSessions: sessions }),
|
setFilteredSessions: (sessions) => set({ filteredSessions: sessions }),
|
||||||
|
|
||||||
setCurrentSession: (sessionId, options) => set((state) => ({
|
setCurrentSession: (sessionId, options) => set((state) => {
|
||||||
currentSessionId: sessionId,
|
const nextMessages = options?.preserveMessages ? state.messages : []
|
||||||
messages: options?.preserveMessages ? state.messages : [],
|
rebuildMessageAliasIndex(nextMessages)
|
||||||
hasMoreMessages: true,
|
return {
|
||||||
hasMoreLater: false
|
currentSessionId: sessionId,
|
||||||
})),
|
messages: nextMessages,
|
||||||
|
hasMoreMessages: true,
|
||||||
|
hasMoreLater: false
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
setLoadingSessions: (loading) => set({ isLoadingSessions: loading }),
|
setLoadingSessions: (loading) => set({ isLoadingSessions: loading }),
|
||||||
|
|
||||||
setMessages: (messages) => set({ messages }),
|
setMessages: (messages) => set(() => {
|
||||||
|
rebuildMessageAliasIndex(messages || [])
|
||||||
|
return { messages }
|
||||||
|
}),
|
||||||
|
|
||||||
appendMessages: (newMessages, prepend = false) => set((state) => {
|
appendMessages: (newMessages, prepend = false) => set((state) => {
|
||||||
const buildPrimaryKey = (m: Message): string => {
|
|
||||||
if (m.messageKey) return String(m.messageKey)
|
|
||||||
return `fallback:${m.serverId || 0}:${m.createTime}:${m.sortSeq || 0}:${m.localId || 0}:${m.senderUsername || ''}:${m.localType || 0}`
|
|
||||||
}
|
|
||||||
const buildAliasKeys = (m: Message): string[] => {
|
|
||||||
const keys = [buildPrimaryKey(m)]
|
|
||||||
const localId = Math.max(0, Number(m.localId || 0))
|
|
||||||
const serverId = Math.max(0, Number(m.serverId || 0))
|
|
||||||
const createTime = Math.max(0, Number(m.createTime || 0))
|
|
||||||
const localType = Math.floor(Number(m.localType || 0))
|
|
||||||
const sender = String(m.senderUsername || '')
|
|
||||||
const isSend = Number(m.isSend ?? -1)
|
|
||||||
|
|
||||||
if (localId > 0) {
|
|
||||||
keys.push(`lid:${localId}`)
|
|
||||||
}
|
|
||||||
if (serverId > 0) {
|
|
||||||
keys.push(`sid:${serverId}`)
|
|
||||||
}
|
|
||||||
if (localType === 3) {
|
|
||||||
const imageIdentity = String(m.imageMd5 || m.imageDatName || '').trim()
|
|
||||||
if (imageIdentity) {
|
|
||||||
keys.push(`img:${createTime}:${sender}:${isSend}:${imageIdentity}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return keys
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentMessages = state.messages || []
|
const currentMessages = state.messages || []
|
||||||
const existingAliases = new Set<string>()
|
if (messageAliasIndex.size === 0 && currentMessages.length > 0) {
|
||||||
currentMessages.forEach((msg) => {
|
rebuildMessageAliasIndex(currentMessages)
|
||||||
buildAliasKeys(msg).forEach((key) => existingAliases.add(key))
|
}
|
||||||
})
|
|
||||||
|
|
||||||
const filtered: Message[] = []
|
const filtered: Message[] = []
|
||||||
newMessages.forEach((msg) => {
|
newMessages.forEach((msg) => {
|
||||||
const aliasKeys = buildAliasKeys(msg)
|
const aliasKeys = buildMessageAliasKeys(msg)
|
||||||
const exists = aliasKeys.some((key) => existingAliases.has(key))
|
const exists = aliasKeys.some((key) => messageAliasIndex.has(key))
|
||||||
if (exists) return
|
if (exists) return
|
||||||
filtered.push(msg)
|
filtered.push(msg)
|
||||||
aliasKeys.forEach((key) => existingAliases.add(key))
|
aliasKeys.forEach((key) => messageAliasIndex.add(key))
|
||||||
})
|
})
|
||||||
|
|
||||||
if (filtered.length === 0) return state
|
if (filtered.length === 0) return state
|
||||||
@@ -150,20 +168,23 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
|
|
||||||
setSearchKeyword: (keyword) => set({ searchKeyword: keyword }),
|
setSearchKeyword: (keyword) => set({ searchKeyword: keyword }),
|
||||||
|
|
||||||
reset: () => set({
|
reset: () => set(() => {
|
||||||
isConnected: false,
|
messageAliasIndex.clear()
|
||||||
isConnecting: false,
|
return {
|
||||||
connectionError: null,
|
isConnected: false,
|
||||||
sessions: [],
|
isConnecting: false,
|
||||||
filteredSessions: [],
|
connectionError: null,
|
||||||
currentSessionId: null,
|
sessions: [],
|
||||||
isLoadingSessions: false,
|
filteredSessions: [],
|
||||||
messages: [],
|
currentSessionId: null,
|
||||||
isLoadingMessages: false,
|
isLoadingSessions: false,
|
||||||
isLoadingMore: false,
|
messages: [],
|
||||||
hasMoreMessages: true,
|
isLoadingMessages: false,
|
||||||
hasMoreLater: false,
|
isLoadingMore: false,
|
||||||
contacts: new Map(),
|
hasMoreMessages: true,
|
||||||
searchKeyword: ''
|
hasMoreLater: false,
|
||||||
|
contacts: new Map(),
|
||||||
|
searchKeyword: ''
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}))
|
}))
|
||||||
|
|||||||
32
src/types/electron.d.ts
vendored
32
src/types/electron.d.ts
vendored
@@ -56,6 +56,14 @@ export interface ElectronAPI {
|
|||||||
app: {
|
app: {
|
||||||
getDownloadsPath: () => Promise<string>
|
getDownloadsPath: () => Promise<string>
|
||||||
getVersion: () => Promise<string>
|
getVersion: () => Promise<string>
|
||||||
|
getLaunchAtStartupStatus: () => Promise<{ enabled: boolean; supported: boolean; reason?: string }>
|
||||||
|
setLaunchAtStartup: (enabled: boolean) => Promise<{
|
||||||
|
success: boolean
|
||||||
|
enabled: boolean
|
||||||
|
supported: boolean
|
||||||
|
reason?: string
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
checkForUpdates: () => Promise<{ hasUpdate: boolean; version?: string; releaseNotes?: string }>
|
checkForUpdates: () => Promise<{ hasUpdate: boolean; version?: string; releaseNotes?: string }>
|
||||||
downloadAndInstall: () => Promise<void>
|
downloadAndInstall: () => Promise<void>
|
||||||
ignoreUpdate: (version: string) => Promise<{ success: boolean }>
|
ignoreUpdate: (version: string) => Promise<{ success: boolean }>
|
||||||
@@ -218,6 +226,21 @@ export interface ElectronAPI {
|
|||||||
getContactAvatar: (username: string) => Promise<{ avatarUrl?: string; displayName?: string } | null>
|
getContactAvatar: (username: string) => Promise<{ avatarUrl?: string; displayName?: string } | null>
|
||||||
updateMessage: (sessionId: string, localId: number, createTime: number, newContent: string) => Promise<{ success: boolean; error?: string }>
|
updateMessage: (sessionId: string, localId: number, createTime: number, newContent: string) => Promise<{ success: boolean; error?: string }>
|
||||||
deleteMessage: (sessionId: string, localId: number, createTime: number, dbPathHint?: string) => Promise<{ success: boolean; error?: string }>
|
deleteMessage: (sessionId: string, localId: number, createTime: number, dbPathHint?: string) => Promise<{ success: boolean; error?: string }>
|
||||||
|
checkAntiRevokeTriggers: (sessionIds: string[]) => Promise<{
|
||||||
|
success: boolean
|
||||||
|
rows?: Array<{ sessionId: string; success: boolean; installed?: boolean; error?: string }>
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
|
installAntiRevokeTriggers: (sessionIds: string[]) => Promise<{
|
||||||
|
success: boolean
|
||||||
|
rows?: Array<{ sessionId: string; success: boolean; alreadyInstalled?: boolean; error?: string }>
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
|
uninstallAntiRevokeTriggers: (sessionIds: string[]) => Promise<{
|
||||||
|
success: boolean
|
||||||
|
rows?: Array<{ sessionId: string; success: boolean; error?: string }>
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
resolveTransferDisplayNames: (chatroomId: string, payerUsername: string, receiverUsername: string) => Promise<{ payerName: string; receiverName: string }>
|
resolveTransferDisplayNames: (chatroomId: string, payerUsername: string, receiverUsername: string) => Promise<{ payerName: string; receiverName: string }>
|
||||||
getContacts: (options?: { lite?: boolean }) => Promise<{
|
getContacts: (options?: { lite?: boolean }) => Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
@@ -326,6 +349,11 @@ export interface ElectronAPI {
|
|||||||
getMessage: (sessionId: string, localId: number) => Promise<{ success: boolean; message?: Message; error?: string }>
|
getMessage: (sessionId: string, localId: number) => Promise<{ success: boolean; message?: Message; error?: string }>
|
||||||
onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => () => void
|
onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => () => void
|
||||||
}
|
}
|
||||||
|
biz: {
|
||||||
|
listAccounts: (account?: string) => Promise<any[]>
|
||||||
|
listMessages: (username: string, account?: string, limit?: number, offset?: number) => Promise<any[]>
|
||||||
|
listPayRecords: (account?: string, limit?: number, offset?: number) => Promise<any[]>
|
||||||
|
}
|
||||||
|
|
||||||
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; force?: boolean }) => Promise<{ success: boolean; localPath?: string; liveVideoPath?: string; error?: string }>
|
||||||
@@ -868,7 +896,7 @@ export interface ElectronAPI {
|
|||||||
|
|
||||||
export interface ExportOptions {
|
export interface ExportOptions {
|
||||||
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
|
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
|
||||||
contentType?: 'text' | 'voice' | 'image' | 'video' | 'emoji'
|
contentType?: 'text' | 'voice' | 'image' | 'video' | 'emoji' | 'file'
|
||||||
dateRange?: { start: number; end: number } | null
|
dateRange?: { start: number; end: number } | null
|
||||||
senderUsername?: string
|
senderUsername?: string
|
||||||
fileNameSuffix?: string
|
fileNameSuffix?: string
|
||||||
@@ -878,6 +906,8 @@ export interface ExportOptions {
|
|||||||
exportVoices?: boolean
|
exportVoices?: boolean
|
||||||
exportVideos?: boolean
|
exportVideos?: boolean
|
||||||
exportEmojis?: boolean
|
exportEmojis?: boolean
|
||||||
|
exportFiles?: boolean
|
||||||
|
maxFileSizeMb?: number
|
||||||
exportVoiceAsText?: boolean
|
exportVoiceAsText?: boolean
|
||||||
excelCompactColumns?: boolean
|
excelCompactColumns?: boolean
|
||||||
txtColumns?: string[]
|
txtColumns?: string[]
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export interface Message {
|
|||||||
fileName?: string // 文件名
|
fileName?: string // 文件名
|
||||||
fileSize?: number // 文件大小
|
fileSize?: number // 文件大小
|
||||||
fileExt?: string // 文件扩展名
|
fileExt?: string // 文件扩展名
|
||||||
|
fileMd5?: string // 文件 MD5
|
||||||
xmlType?: string // XML 中的 type 字段
|
xmlType?: string // XML 中的 type 字段
|
||||||
appMsgKind?: string // 归一化 appmsg 类型
|
appMsgKind?: string // 归一化 appmsg 类型
|
||||||
appMsgDesc?: string
|
appMsgDesc?: string
|
||||||
|
|||||||
36
src/utils/reportExport.ts
Normal file
36
src/utils/reportExport.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
const PATTERN_LIGHT_SVG = `<svg xmlns='http://www.w3.org/2000/svg' width='400' height='400' viewBox='0 0 400 400'><defs><style>.a{fill:none;stroke:#000;stroke-width:1.2;opacity:0.045}.b{fill:none;stroke:#000;stroke-width:1;opacity:0.035}.c{fill:none;stroke:#000;stroke-width:0.8;opacity:0.04}</style></defs><g transform='translate(45,35) rotate(-8)'><circle class='a' cx='0' cy='0' r='16'/><circle class='a' cx='-5' cy='-4' r='2.5'/><circle class='a' cx='5' cy='-4' r='2.5'/><path class='a' d='M-8 4 Q0 12 8 4'/></g><g transform='translate(320,28) rotate(15) scale(0.7)'><path class='b' d='M0 -12 l3 9 9 0 -7 5 3 9 -8 -6 -8 6 3 -9 -7 -5 9 0z'/></g><g transform='translate(180,55) rotate(12)'><path class='a' d='M0 -8 C0 -14 8 -17 12 -10 C16 -17 24 -14 24 -8 C24 4 12 14 12 14 C12 14 0 4 0 -8'/></g><g transform='translate(95,120) rotate(-5) scale(1.1)'><path class='b' d='M0 10 Q-8 10 -8 3 Q-8 -4 0 -4 Q0 -12 10 -12 Q22 -12 22 -2 Q30 -2 30 5 Q30 12 22 12 Z'/></g><g transform='translate(355,95) rotate(8)'><path class='c' d='M0 0 L0 18 M0 0 L18 -4 L18 14'/><ellipse class='c' cx='-4' cy='20' rx='6' ry='4'/><ellipse class='c' cx='14' cy='16' rx='6' ry='4'/></g><g transform='translate(250,110) rotate(-12) scale(0.9)'><rect class='b' x='0' y='0' width='26' height='18' rx='2'/><path class='b' d='M0 2 L13 11 L26 2'/></g><g transform='translate(28,195) rotate(6)'><circle class='a' cx='0' cy='0' r='11'/><path class='a' d='M-5 11 L5 11 M-4 14 L4 14'/><path class='c' d='M-3 -2 L0 -6 L3 -2'/></g><g transform='translate(155,175) rotate(-3) scale(0.85)'><path class='b' d='M0 0 L0 28 Q14 22 28 28 L28 0 Q14 6 0 0'/><path class='b' d='M28 0 L28 28 Q42 22 56 28 L56 0 Q42 6 28 0'/></g><g transform='translate(340,185) rotate(-20) scale(1.2)'><path class='a' d='M0 8 L20 0 L5 6 L8 14 L5 6 L-12 12 Z'/></g><g transform='translate(70,280) rotate(5)'><rect class='b' x='0' y='5' width='30' height='22' rx='4'/><circle class='b' cx='15' cy='16' r='7'/><rect class='b' x='8' y='0' width='14' height='6' rx='2'/></g><g transform='translate(230,250) rotate(-8) scale(1.1)'><rect class='a' x='0' y='6' width='22' height='18' rx='2'/><rect class='a' x='-3' y='0' width='28' height='7' rx='2'/><path class='a' d='M11 0 L11 24 M-3 13 L25 13'/></g><g transform='translate(365,280) rotate(10)'><ellipse class='b' cx='0' cy='0' rx='10' ry='14'/><path class='b' d='M0 14 Q-3 20 0 28 Q2 24 -1 20'/></g><g transform='translate(145,310) rotate(-6)'><path class='c' d='M0 0 L4 28 L24 28 L28 0 Z'/><path class='c' d='M28 6 Q40 6 40 16 Q40 24 28 24'/><path class='c' d='M8 8 Q10 4 12 8'/></g><g transform='translate(310,340) rotate(5) scale(0.9)'><path class='a' d='M0 8 L8 0 L24 0 L32 8 L16 28 Z'/><path class='a' d='M8 0 L12 8 L0 8 M24 0 L20 8 L32 8 M12 8 L16 28 L20 8'/></g><g transform='translate(55,365) rotate(25) scale(1.15)'><path class='a' d='M8 0 Q12 -14 16 0 L14 6 L18 12 L12 9 L6 12 L10 6 Z'/><circle class='c' cx='12' cy='-2' r='2'/></g><g transform='translate(200,375) rotate(-4)'><path class='b' d='M0 12 Q0 -8 24 -8 Q48 -8 48 12'/><path class='c' d='M6 12 Q6 -2 24 -2 Q42 -2 42 12'/><path class='c' d='M12 12 Q12 4 24 4 Q36 4 36 12'/></g><g transform='translate(380,375) rotate(-10)'><circle class='a' cx='0' cy='0' r='8'/><path class='c' d='M0 -14 L0 -10 M0 10 L0 14 M-14 0 L-10 0 M10 0 L14 0 M-10 -10 L-7 -7 M7 7 L10 10 M-10 10 L-7 7 M7 -7 L10 -10'/></g></svg>`
|
||||||
|
|
||||||
|
const PATTERN_DARK_SVG = `<svg xmlns='http://www.w3.org/2000/svg' width='400' height='400' viewBox='0 0 400 400'><defs><style>.a{fill:none;stroke:#fff;stroke-width:1.2;opacity:0.055}.b{fill:none;stroke:#fff;stroke-width:1;opacity:0.045}.c{fill:none;stroke:#fff;stroke-width:0.8;opacity:0.05}</style></defs><g transform='translate(45,35) rotate(-8)'><circle class='a' cx='0' cy='0' r='16'/><circle class='a' cx='-5' cy='-4' r='2.5'/><circle class='a' cx='5' cy='-4' r='2.5'/><path class='a' d='M-8 4 Q0 12 8 4'/></g><g transform='translate(320,28) rotate(15) scale(0.7)'><path class='b' d='M0 -12 l3 9 9 0 -7 5 3 9 -8 -6 -8 6 3 -9 -7 -5 9 0z'/></g><g transform='translate(180,55) rotate(12)'><path class='a' d='M0 -8 C0 -14 8 -17 12 -10 C16 -17 24 -14 24 -8 C24 4 12 14 12 14 C12 14 0 4 0 -8'/></g><g transform='translate(95,120) rotate(-5) scale(1.1)'><path class='b' d='M0 10 Q-8 10 -8 3 Q-8 -4 0 -4 Q0 -12 10 -12 Q22 -12 22 -2 Q30 -2 30 5 Q30 12 22 12 Z'/></g><g transform='translate(355,95) rotate(8)'><path class='c' d='M0 0 L0 18 M0 0 L18 -4 L18 14'/><ellipse class='c' cx='-4' cy='20' rx='6' ry='4'/><ellipse class='c' cx='14' cy='16' rx='6' ry='4'/></g><g transform='translate(250,110) rotate(-12) scale(0.9)'><rect class='b' x='0' y='0' width='26' height='18' rx='2'/><path class='b' d='M0 2 L13 11 L26 2'/></g><g transform='translate(28,195) rotate(6)'><circle class='a' cx='0' cy='0' r='11'/><path class='a' d='M-5 11 L5 11 M-4 14 L4 14'/><path class='c' d='M-3 -2 L0 -6 L3 -2'/></g><g transform='translate(155,175) rotate(-3) scale(0.85)'><path class='b' d='M0 0 L0 28 Q14 22 28 28 L28 0 Q14 6 0 0'/><path class='b' d='M28 0 L28 28 Q42 22 56 28 L56 0 Q42 6 28 0'/></g><g transform='translate(340,185) rotate(-20) scale(1.2)'><path class='a' d='M0 8 L20 0 L5 6 L8 14 L5 6 L-12 12 Z'/></g><g transform='translate(70,280) rotate(5)'><rect class='b' x='0' y='5' width='30' height='22' rx='4'/><circle class='b' cx='15' cy='16' r='7'/><rect class='b' x='8' y='0' width='14' height='6' rx='2'/></g><g transform='translate(230,250) rotate(-8) scale(1.1)'><rect class='a' x='0' y='6' width='22' height='18' rx='2'/><rect class='a' x='-3' y='0' width='28' height='7' rx='2'/><path class='a' d='M11 0 L11 24 M-3 13 L25 13'/></g><g transform='translate(365,280) rotate(10)'><ellipse class='b' cx='0' cy='0' rx='10' ry='14'/><path class='b' d='M0 14 Q-3 20 0 28 Q2 24 -1 20'/></g><g transform='translate(145,310) rotate(-6)'><path class='c' d='M0 0 L4 28 L24 28 L28 0 Z'/><path class='c' d='M28 6 Q40 6 40 16 Q40 24 28 24'/><path class='c' d='M8 8 Q10 4 12 8'/></g><g transform='translate(310,340) rotate(5) scale(0.9)'><path class='a' d='M0 8 L8 0 L24 0 L32 8 L16 28 Z'/><path class='a' d='M8 0 L12 8 L0 8 M24 0 L20 8 L32 8 M12 8 L16 28 L20 8'/></g><g transform='translate(55,365) rotate(25) scale(1.15)'><path class='a' d='M8 0 Q12 -14 16 0 L14 6 L18 12 L12 9 L6 12 L10 6 Z'/><circle class='c' cx='12' cy='-2' r='2'/></g><g transform='translate(200,375) rotate(-4)'><path class='b' d='M0 12 Q0 -8 24 -8 Q48 -8 48 12'/><path class='c' d='M6 12 Q6 -2 24 -2 Q42 -2 42 12'/><path class='c' d='M12 12 Q12 4 24 4 Q36 4 36 12'/></g><g transform='translate(380,375) rotate(-10)'><circle class='a' cx='0' cy='0' r='8'/><path class='c' d='M0 -14 L0 -10 M0 10 L0 14 M-14 0 L-10 0 M10 0 L14 0 M-10 -10 L-7 -7 M7 7 L10 10 M-10 10 L-7 7 M7 -7 L10 -10'/></g></svg>`
|
||||||
|
|
||||||
|
export const drawPatternBackground = async (
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
bgColor: string,
|
||||||
|
isDark: boolean
|
||||||
|
) => {
|
||||||
|
ctx.fillStyle = bgColor
|
||||||
|
ctx.fillRect(0, 0, width, height)
|
||||||
|
|
||||||
|
const svgString = isDark ? PATTERN_DARK_SVG : PATTERN_LIGHT_SVG
|
||||||
|
const blob = new Blob([svgString], { type: 'image/svg+xml' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
const img = new window.Image()
|
||||||
|
img.onload = () => {
|
||||||
|
const pattern = ctx.createPattern(img, 'repeat')
|
||||||
|
if (pattern) {
|
||||||
|
ctx.fillStyle = pattern
|
||||||
|
ctx.fillRect(0, 0, width, height)
|
||||||
|
}
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
img.onerror = () => {
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
img.src = url
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -18,38 +18,6 @@ export default defineConfig({
|
|||||||
chunkSizeWarningLimit: 900,
|
chunkSizeWarningLimit: 900,
|
||||||
commonjsOptions: {
|
commonjsOptions: {
|
||||||
ignoreDynamicRequires: true
|
ignoreDynamicRequires: true
|
||||||
},
|
|
||||||
rollupOptions: {
|
|
||||||
output: {
|
|
||||||
manualChunks(id) {
|
|
||||||
if (!id.includes('node_modules')) return
|
|
||||||
|
|
||||||
if (id.includes('/react/') || id.includes('/react-dom/') || id.includes('/react-router')) {
|
|
||||||
return 'vendor-react'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (id.includes('/echarts') || id.includes('/echarts-for-react')) {
|
|
||||||
return 'vendor-echarts'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
id.includes('/react-markdown') ||
|
|
||||||
id.includes('/remark-gfm') ||
|
|
||||||
id.includes('/mdast-') ||
|
|
||||||
id.includes('/micromark-') ||
|
|
||||||
id.includes('/unified') ||
|
|
||||||
id.includes('/vfile')
|
|
||||||
) {
|
|
||||||
return 'vendor-markdown'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (id.includes('/jszip') || id.includes('/exceljs')) {
|
|
||||||
return 'vendor-export'
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'vendor-misc'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
@@ -204,6 +172,7 @@ export default defineConfig({
|
|||||||
renderer()
|
renderer()
|
||||||
],
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
|
dedupe: ['react', 'react-dom'],
|
||||||
alias: {
|
alias: {
|
||||||
'@': resolve(__dirname, 'src')
|
'@': resolve(__dirname, 'src')
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user