mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-03 15:08:25 +00:00
Compare commits
43 Commits
v4.3.0-pre
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2dbe6ee8f | ||
|
|
0175a6998b | ||
|
|
758de9949b | ||
|
|
81b8960d41 | ||
|
|
5b25619b24 | ||
|
|
62e23aaf23 | ||
|
|
aac8eed898 | ||
|
|
108980befb | ||
|
|
a6c899c098 | ||
|
|
28170d31df | ||
|
|
0047685f54 | ||
|
|
2cc0fc64a4 | ||
|
|
67642cebfd | ||
|
|
327dc85d14 | ||
|
|
8c4f42bab1 | ||
|
|
40c29e494c | ||
|
|
0235ec7edc | ||
|
|
fa2a000624 | ||
|
|
861b24cef1 | ||
|
|
ee1977384e | ||
|
|
5d08505f62 | ||
|
|
ab21124327 | ||
|
|
1df792ec9c | ||
|
|
a8fa6e5987 | ||
|
|
1d69c5a78d | ||
|
|
0ae7ba3e11 | ||
|
|
c421ca7f2f | ||
|
|
ea4fff5b10 | ||
|
|
e0b0e38271 | ||
|
|
510b956649 | ||
|
|
17b8af4bc4 | ||
|
|
617b400884 | ||
|
|
5b56b2e0be | ||
|
|
7b8bd747ad | ||
|
|
e4872a78f5 | ||
|
|
4692216325 | ||
|
|
f81ba3028d | ||
|
|
73a948c528 | ||
|
|
71238d4a01 | ||
|
|
4ea4020faa | ||
|
|
c88a1c5848 | ||
|
|
0162769d22 | ||
|
|
fa55755921 |
29
.github/workflows/dev-daily-fixed.yml
vendored
29
.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,11 +38,10 @@ 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"
|
||||||
|
|
||||||
@@ -63,6 +64,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
|
||||||
@@ -95,7 +97,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 +114,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
|
||||||
@@ -138,7 +144,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 +161,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
|
||||||
@@ -181,7 +191,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 +208,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
|
||||||
@@ -224,7 +238,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
|
||||||
|
|||||||
120
.github/workflows/preview-nightly-main.yml
vendored
120
.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,34 @@ 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: Ensure fixed preview prerelease exists
|
||||||
|
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 edit "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" --title "Preview Nightly Build" --prerelease
|
||||||
|
else
|
||||||
|
gh release create "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" --title "Preview Nightly Build" --notes "预览版发布页" --prerelease
|
||||||
|
fi
|
||||||
|
|
||||||
preview-mac-arm64:
|
preview-mac-arm64:
|
||||||
needs: prepare
|
needs: prepare
|
||||||
@@ -68,6 +90,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
|
||||||
@@ -88,15 +111,29 @@ jobs:
|
|||||||
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 +143,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
|
||||||
@@ -126,11 +164,27 @@ jobs:
|
|||||||
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 +194,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
|
||||||
@@ -160,11 +215,27 @@ jobs:
|
|||||||
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 +245,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
|
||||||
@@ -194,11 +266,27 @@ jobs:
|
|||||||
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 +305,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,6 +348,7 @@ jobs:
|
|||||||
## Preview Nightly 说明
|
## Preview Nightly 说明
|
||||||
- 该版本为 **预览版**,用于提前体验即将发布的功能与修复。
|
- 该版本为 **预览版**,用于提前体验即将发布的功能与修复。
|
||||||
- 可能包含尚未完全稳定的改动,不建议长期使用
|
- 可能包含尚未完全稳定的改动,不建议长期使用
|
||||||
|
- 当前版本号:\`$CURRENT_PREVIEW_VERSION\`
|
||||||
|
|
||||||
## 下载
|
## 下载
|
||||||
- Windows x64: ${WINDOWS_URL:-$RELEASE_PAGE}
|
- Windows x64: ${WINDOWS_URL:-$RELEASE_PAGE}
|
||||||
|
|||||||
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 中完成数据库连接。
|
||||||
|
|||||||
254
electron/main.ts
254
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,97 @@ 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
|
||||||
|
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 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 nextUpdaterChannel =
|
||||||
autoUpdater.allowDowngrade = isPrereleaseBuild && track === 'stable'
|
|
||||||
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) {
|
||||||
|
resetUpdaterProviderCache()
|
||||||
|
}
|
||||||
|
autoUpdater.allowPrerelease = track !== 'stable'
|
||||||
|
// 只要用户当前选择的目标通道与当前安装版本所属通道不同,就允许跨通道更新(含降级)
|
||||||
|
autoUpdater.allowDowngrade = track !== currentTrack
|
||||||
|
autoUpdater.channel = nextUpdaterChannel
|
||||||
|
lastAppliedUpdaterChannel = nextUpdaterChannel
|
||||||
|
console.log(`[Update](${reason}) 当前版本 ${appVersion},当前轨道: ${currentTrack},渠道偏好: ${track},更新通道: ${autoUpdater.channel},allowDowngrade=${autoUpdater.allowDowngrade}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
applyAutoUpdateChannel('startup')
|
applyAutoUpdateChannel('startup')
|
||||||
@@ -80,6 +171,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 +1355,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 +1376,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 +1424,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 +1501,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,
|
||||||
@@ -2741,7 +2966,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 +3012,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()) {
|
||||||
|
|||||||
@@ -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),
|
||||||
@@ -413,6 +415,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: {
|
||||||
|
|||||||
221
electron/services/bizService.ts
Normal file
221
electron/services/bizService.ts
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
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 {
|
||||||
|
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 dbDir = join(root, accountWxid, 'db_storage', 'message')
|
||||||
|
const bizLatestTime: Record<string, number> = {}
|
||||||
|
|
||||||
|
if (existsSync(dbDir)) {
|
||||||
|
const bizDbFiles = readdirSync(dbDir).filter(f => f.startsWith('biz_message') && f.endsWith('.db'))
|
||||||
|
for (const file of bizDbFiles) {
|
||||||
|
const dbPath = join(dbDir, file)
|
||||||
|
const name2idRes = await wcdbService.execQuery('message', dbPath, 'SELECT username FROM Name2Id')
|
||||||
|
if (name2idRes.success && name2idRes.rows) {
|
||||||
|
for (const row of name2idRes.rows) {
|
||||||
|
const uname = row.username || row.user_name
|
||||||
|
if (uname) {
|
||||||
|
const md5 = createHash('md5').update(uname).digest('hex').toLowerCase()
|
||||||
|
const tName = `Msg_${md5}`
|
||||||
|
const timeRes = await wcdbService.execQuery('message', dbPath, `SELECT MAX(create_time) as max_time FROM ${tName}`)
|
||||||
|
if (timeRes.success && timeRes.rows && timeRes.rows[0]?.max_time) {
|
||||||
|
const t = parseInt(timeRes.rows[0].max_time)
|
||||||
|
if (!isNaN(t)) bizLatestTime[uname] = Math.max(bizLatestTime[uname] || 0, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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: lastTime ? new Date(lastTime * 1000).toISOString().split('T')[0] : ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const contactDbPath = join(root, accountWxid, '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]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) { 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()
|
||||||
@@ -27,6 +27,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
|
||||||
@@ -128,7 +129,7 @@ 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',
|
||||||
|
|||||||
@@ -3310,15 +3310,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 +3363,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
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 导出媒体文件到指定目录
|
* 导出媒体文件到指定目录
|
||||||
*/
|
*/
|
||||||
@@ -5066,6 +5120,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,
|
||||||
@@ -5558,6 +5617,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
|
||||||
@@ -6484,16 +6550,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 +6565,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 +6814,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 +6833,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?.({
|
||||||
@@ -7119,6 +7190,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
|
||||||
|
|||||||
@@ -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}`)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送错误响应
|
* 发送错误响应
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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 }> {
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -736,6 +737,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 />} />
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
345
src/pages/BizPage.scss
Normal file
345
src/pages/BizPage.scss
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
.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: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 占位状态:对齐 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; }
|
||||||
|
}
|
||||||
261
src/pages/BizPage.tsx
Normal file
261
src/pages/BizPage.tsx
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
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: number;
|
||||||
|
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(() => {
|
||||||
|
if (!searchKeyword) return accounts;
|
||||||
|
const q = searchKeyword.toLowerCase();
|
||||||
|
return accounts.filter(a =>
|
||||||
|
(a.name && a.name.toLowerCase().includes(q)) ||
|
||||||
|
(a.username && a.username.toLowerCase().includes(q))
|
||||||
|
);
|
||||||
|
}, [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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 我看了下没有接口获取相关type,如果exec没法用的话确实无能为力,后面再适配吧 */}
|
||||||
|
{/*<div className={`biz-badge ${*/}
|
||||||
|
{/* item.type === 1 ? 'type-service' :*/}
|
||||||
|
{/* item.type === 0 ? 'type-sub' :*/}
|
||||||
|
{/* item.type === 2 ? 'type-enterprise' : 'type-unknown'*/}
|
||||||
|
{/*}`}>*/}
|
||||||
|
{/* {item.type === 1 ? '服务号' : item.type === 0 ? '订阅号' : item.type === 2 ? '企业号' : '未知'}*/}
|
||||||
|
{/*</div>*/}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. 公众号消息区域组件
|
||||||
|
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 [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);
|
||||||
|
loadMessages(account.username, 0);
|
||||||
|
}
|
||||||
|
}, [account, myWxid]);
|
||||||
|
|
||||||
|
const loadMessages = async (username: string, currentOffset: number) => {
|
||||||
|
if (loading || !myWxid) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
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 => currentOffset === 0 ? res : [...prev, ...res]);
|
||||||
|
setOffset(currentOffset + limit);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('加载消息失败:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||||
|
const target = e.currentTarget;
|
||||||
|
if (target.scrollHeight - Math.abs(target.scrollTop) - target.clientHeight < 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 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">
|
||||||
|
{!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) => (
|
||||||
|
<div key={msg.local_id}>
|
||||||
|
{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 && <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;
|
||||||
@@ -4487,6 +4487,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;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||||
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture } from 'lucide-react'
|
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture, Newspaper } from 'lucide-react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
|
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
|
||||||
@@ -16,6 +16,7 @@ import JumpToDatePopover from '../components/JumpToDatePopover'
|
|||||||
import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog'
|
import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog'
|
||||||
import { type ContactSnsTimelineTarget, isSingleContactSession } from '../components/Sns/contactSnsTimeline'
|
import { type ContactSnsTimelineTarget, isSingleContactSession } from '../components/Sns/contactSnsTimeline'
|
||||||
import * as configService from '../services/config'
|
import * as configService from '../services/config'
|
||||||
|
import BizPage, { BizAccountList, BizMessageArea, BizAccount } from './BizPage'
|
||||||
import {
|
import {
|
||||||
finishBackgroundTask,
|
finishBackgroundTask,
|
||||||
isBackgroundTaskCancelRequested,
|
isBackgroundTaskCancelRequested,
|
||||||
@@ -36,6 +37,8 @@ const SYSTEM_MESSAGE_TYPES = [
|
|||||||
266287972401, // 拍一拍
|
266287972401, // 拍一拍
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const OFFICIAL_ACCOUNTS_VIRTUAL_ID = 'official_accounts_virtual'
|
||||||
|
|
||||||
interface PendingInSessionSearchPayload {
|
interface PendingInSessionSearchPayload {
|
||||||
sessionId: string
|
sessionId: string
|
||||||
keyword: string
|
keyword: string
|
||||||
@@ -983,6 +986,7 @@ const SessionItem = React.memo(function SessionItem({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const isFoldEntry = session.username.toLowerCase().includes('placeholder_foldgroup')
|
const isFoldEntry = session.username.toLowerCase().includes('placeholder_foldgroup')
|
||||||
|
const isBizEntry = session.username === OFFICIAL_ACCOUNTS_VIRTUAL_ID
|
||||||
|
|
||||||
// 折叠入口:专属名称和图标
|
// 折叠入口:专属名称和图标
|
||||||
if (isFoldEntry) {
|
if (isFoldEntry) {
|
||||||
@@ -1007,6 +1011,29 @@ const SessionItem = React.memo(function SessionItem({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 公众号入口:专属名称和图标
|
||||||
|
if (isBizEntry) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`session-item biz-entry ${isActive ? 'active' : ''}`}
|
||||||
|
onClick={() => onSelect(session)}
|
||||||
|
>
|
||||||
|
<div className="biz-entry-avatar">
|
||||||
|
<Newspaper size={22} />
|
||||||
|
</div>
|
||||||
|
<div className="session-info">
|
||||||
|
<div className="session-top">
|
||||||
|
<span className="session-name">订阅号/服务号</span>
|
||||||
|
<span className="session-time">{timeText}</span>
|
||||||
|
</div>
|
||||||
|
<div className="session-bottom">
|
||||||
|
<span className="session-summary">{session.summary || '查看公众号历史消息'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 根据匹配字段显示不同的 summary
|
// 根据匹配字段显示不同的 summary
|
||||||
const summaryContent = useMemo(() => {
|
const summaryContent = useMemo(() => {
|
||||||
if (session.matchedField === 'wxid') {
|
if (session.matchedField === 'wxid') {
|
||||||
@@ -1204,6 +1231,8 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
const [highlightedMessageKeys, setHighlightedMessageKeys] = useState<string[]>([])
|
const [highlightedMessageKeys, setHighlightedMessageKeys] = useState<string[]>([])
|
||||||
const [isRefreshingSessions, setIsRefreshingSessions] = useState(false)
|
const [isRefreshingSessions, setIsRefreshingSessions] = useState(false)
|
||||||
const [foldedView, setFoldedView] = useState(false) // 是否在"折叠的群聊"视图
|
const [foldedView, setFoldedView] = useState(false) // 是否在"折叠的群聊"视图
|
||||||
|
const [bizView, setBizView] = useState(false) // 是否在"公众号"视图
|
||||||
|
const [selectedBizAccount, setSelectedBizAccount] = useState<BizAccount | null>(null)
|
||||||
const [hasInitialMessages, setHasInitialMessages] = useState(false)
|
const [hasInitialMessages, setHasInitialMessages] = useState(false)
|
||||||
const [isSessionSwitching, setIsSessionSwitching] = useState(false)
|
const [isSessionSwitching, setIsSessionSwitching] = useState(false)
|
||||||
const [noMessageTable, setNoMessageTable] = useState(false)
|
const [noMessageTable, setNoMessageTable] = useState(false)
|
||||||
@@ -2691,6 +2720,9 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
setConnected(false)
|
setConnected(false)
|
||||||
setConnecting(false)
|
setConnecting(false)
|
||||||
setHasMoreMessages(true)
|
setHasMoreMessages(true)
|
||||||
|
setFoldedView(false)
|
||||||
|
setBizView(false)
|
||||||
|
setSelectedBizAccount(null)
|
||||||
setHasMoreLater(false)
|
setHasMoreLater(false)
|
||||||
const scope = await resolveChatCacheScope()
|
const scope = await resolveChatCacheScope()
|
||||||
hydrateSessionListCache(scope)
|
hydrateSessionListCache(scope)
|
||||||
@@ -3964,6 +3996,12 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
setFoldedView(true)
|
setFoldedView(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 点击公众号入口,切换到公众号视图
|
||||||
|
if (session.username === OFFICIAL_ACCOUNTS_VIRTUAL_ID) {
|
||||||
|
setBizView(true)
|
||||||
|
setSelectedBizAccount(null) // 切入时默认不选中任何公众号
|
||||||
|
return
|
||||||
|
}
|
||||||
selectSessionById(session.username)
|
selectSessionById(session.username)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4946,14 +4984,30 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
const foldedGroups = sessions.filter(s => s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup'))
|
const foldedGroups = sessions.filter(s => s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup'))
|
||||||
const hasFoldedGroups = foldedGroups.length > 0
|
const hasFoldedGroups = foldedGroups.length > 0
|
||||||
|
|
||||||
const visible = sessions.filter(s => {
|
let visible = sessions.filter(s => {
|
||||||
if (s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup')) return false
|
if (s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup')) return false
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
// 如果有折叠的群聊,但列表中没有入口,则插入入口
|
const bizEntry: ChatSession = {
|
||||||
|
username: OFFICIAL_ACCOUNTS_VIRTUAL_ID,
|
||||||
|
displayName: '公众号',
|
||||||
|
summary: '查看公众号历史消息',
|
||||||
|
type: 0,
|
||||||
|
sortTimestamp: 9999999999, // 放到最前面? 目前还没有严格的对时间进行排序, 后面可以改一下
|
||||||
|
lastTimestamp: 0,
|
||||||
|
lastMsgType: 0,
|
||||||
|
unreadCount: 0,
|
||||||
|
isMuted: false,
|
||||||
|
isFolded: false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!visible.some(s => s.username === OFFICIAL_ACCOUNTS_VIRTUAL_ID)) {
|
||||||
|
visible.unshift(bizEntry)
|
||||||
|
}
|
||||||
|
|
||||||
if (hasFoldedGroups && !visible.some(s => s.username.toLowerCase().includes('placeholder_foldgroup'))) {
|
if (hasFoldedGroups && !visible.some(s => s.username.toLowerCase().includes('placeholder_foldgroup'))) {
|
||||||
// 找到最新的折叠消息
|
|
||||||
const latestFolded = foldedGroups.reduce((latest, current) => {
|
const latestFolded = foldedGroups.reduce((latest, current) => {
|
||||||
const latestTime = latest.sortTimestamp || latest.lastTimestamp
|
const latestTime = latest.sortTimestamp || latest.lastTimestamp
|
||||||
const currentTime = current.sortTimestamp || current.lastTimestamp
|
const currentTime = current.sortTimestamp || current.lastTimestamp
|
||||||
@@ -6031,7 +6085,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
ref={sidebarRef}
|
ref={sidebarRef}
|
||||||
style={{ width: sidebarWidth, minWidth: sidebarWidth, maxWidth: sidebarWidth }}
|
style={{ width: sidebarWidth, minWidth: sidebarWidth, maxWidth: sidebarWidth }}
|
||||||
>
|
>
|
||||||
<div className={`session-header session-header-viewport ${foldedView ? 'folded' : ''}`}>
|
<div className={`session-header session-header-viewport ${foldedView || bizView ? 'folded' : ''}`}>
|
||||||
{/* 普通 header */}
|
{/* 普通 header */}
|
||||||
<div className="session-header-panel main-header">
|
<div className="session-header-panel main-header">
|
||||||
<div className="search-row">
|
<div className="search-row">
|
||||||
@@ -6061,12 +6115,18 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
{/* 折叠群 header */}
|
{/* 折叠群 header */}
|
||||||
<div className="session-header-panel folded-header">
|
<div className="session-header-panel folded-header">
|
||||||
<div className="folded-view-header">
|
<div className="folded-view-header">
|
||||||
<button className="icon-btn back-btn" onClick={() => setFoldedView(false)}>
|
<button className="icon-btn back-btn" onClick={() => {
|
||||||
|
setFoldedView(false)
|
||||||
|
setBizView(false)
|
||||||
|
}}>
|
||||||
<ChevronLeft size={18} />
|
<ChevronLeft size={18} />
|
||||||
</button>
|
</button>
|
||||||
<span className="folded-view-title">
|
<span className="folded-view-title">
|
||||||
<Users size={14} />
|
{foldedView ? (
|
||||||
折叠的群聊
|
<><Users size={14} /> 折叠的群聊</>
|
||||||
|
) : bizView ? (
|
||||||
|
<><Newspaper size={14} /> 订阅号/服务号</>
|
||||||
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -6173,7 +6233,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={`session-list-viewport ${foldedView ? 'folded' : ''}`}>
|
<div className={`session-list-viewport ${foldedView || bizView ? 'folded' : ''}`}>
|
||||||
{/* 普通会话列表 */}
|
{/* 普通会话列表 */}
|
||||||
<div className="session-list-panel main-panel">
|
<div className="session-list-panel main-panel">
|
||||||
{Array.isArray(filteredSessions) && filteredSessions.length > 0 ? (
|
{Array.isArray(filteredSessions) && filteredSessions.length > 0 ? (
|
||||||
@@ -6199,7 +6259,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
<SessionItem
|
<SessionItem
|
||||||
key={session.username}
|
key={session.username}
|
||||||
session={session}
|
session={session}
|
||||||
isActive={currentSessionId === session.username}
|
isActive={currentSessionId === session.username || (bizView && session.username === OFFICIAL_ACCOUNTS_VIRTUAL_ID)}
|
||||||
onSelect={handleSelectSession}
|
onSelect={handleSelectSession}
|
||||||
formatTime={formatSessionTime}
|
formatTime={formatSessionTime}
|
||||||
searchKeyword={searchKeyword}
|
searchKeyword={searchKeyword}
|
||||||
@@ -6218,13 +6278,14 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
|
|
||||||
{/* 折叠群列表 */}
|
{/* 折叠群列表 */}
|
||||||
<div className="session-list-panel folded-panel">
|
<div className="session-list-panel folded-panel">
|
||||||
{foldedSessions.length > 0 ? (
|
{foldedView && (
|
||||||
|
foldedSessions.length > 0 ? (
|
||||||
<div className="session-list">
|
<div className="session-list">
|
||||||
{foldedSessions.map(session => (
|
{foldedSessions.map(session => (
|
||||||
<SessionItem
|
<SessionItem
|
||||||
key={session.username}
|
key={session.username}
|
||||||
session={session}
|
session={session}
|
||||||
isActive={currentSessionId === session.username}
|
isActive={currentSessionId === session.username || (bizView && session.username === OFFICIAL_ACCOUNTS_VIRTUAL_ID)}
|
||||||
onSelect={handleSelectSession}
|
onSelect={handleSelectSession}
|
||||||
formatTime={formatSessionTime}
|
formatTime={formatSessionTime}
|
||||||
searchKeyword={searchKeyword}
|
searchKeyword={searchKeyword}
|
||||||
@@ -6236,6 +6297,17 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
<Users size={32} />
|
<Users size={32} />
|
||||||
<p>没有折叠的群聊</p>
|
<p>没有折叠的群聊</p>
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{bizView && (
|
||||||
|
<div style={{ height: '100%', overflowY: 'auto' }}>
|
||||||
|
<BizAccountList
|
||||||
|
onSelect={setSelectedBizAccount}
|
||||||
|
selectedUsername={selectedBizAccount?.username}
|
||||||
|
searchKeyword={searchKeyword}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -6247,7 +6319,9 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
|
|
||||||
{/* 右侧消息区域 */}
|
{/* 右侧消息区域 */}
|
||||||
<div className="message-area">
|
<div className="message-area">
|
||||||
{currentSession ? (
|
{bizView ? (
|
||||||
|
<BizMessageArea account={selectedBizAccount} />
|
||||||
|
) : currentSession ? (
|
||||||
<>
|
<>
|
||||||
<div className="message-header">
|
<div className="message-header">
|
||||||
<Avatar
|
<Avatar
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -138,6 +138,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 +165,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)
|
||||||
@@ -337,6 +341,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 +391,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 +436,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()
|
||||||
@@ -1199,6 +1230,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>
|
||||||
|
|||||||
@@ -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',
|
||||||
@@ -258,6 +259,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)
|
||||||
|
|||||||
13
src/types/electron.d.ts
vendored
13
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 }>
|
||||||
@@ -326,6 +334,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 }>
|
||||||
|
|||||||
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