mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-03 15:08:25 +00:00
Merge branch 'dev' into dev
This commit is contained in:
7
.github/dependabot.yml
vendored
Normal file
7
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "npm"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "daily"
|
||||||
|
target-branch: "dev"
|
||||||
322
.github/workflows/dev-daily-fixed.yml
vendored
Normal file
322
.github/workflows/dev-daily-fixed.yml
vendored
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
name: Dev Daily
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# GitHub Actions schedule uses UTC. 16:00 UTC = 北京时间次日 00:00
|
||||||
|
- cron: "0 16 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||||
|
FIXED_DEV_TAG: nightly-dev
|
||||||
|
ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
prepare:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
dev_version: ${{ steps.meta.outputs.dev_version }}
|
||||||
|
steps:
|
||||||
|
- name: Check out git repository
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Generate daily dev version
|
||||||
|
id: meta
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
BASE_VERSION="$(node -p "require('./package.json').version.split('-')[0]")"
|
||||||
|
YEAR_2="$(TZ=Asia/Shanghai date +%y)"
|
||||||
|
MONTH="$(TZ=Asia/Shanghai date +%-m)"
|
||||||
|
DAY="$(TZ=Asia/Shanghai date +%-d)"
|
||||||
|
DEV_VERSION="${BASE_VERSION}-dev.${YEAR_2}.${MONTH}.${DAY}"
|
||||||
|
echo "dev_version=$DEV_VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Dev version: $DEV_VERSION"
|
||||||
|
|
||||||
|
- name: Ensure fixed prerelease exists
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if gh release view "$FIXED_DEV_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||||
|
gh release edit "$FIXED_DEV_TAG" --repo "$GITHUB_REPOSITORY" --title "Daily Dev Build" --prerelease
|
||||||
|
else
|
||||||
|
gh release create "$FIXED_DEV_TAG" --repo "$GITHUB_REPOSITORY" --title "Daily Dev Build" --notes "开发版发布页" --prerelease
|
||||||
|
fi
|
||||||
|
|
||||||
|
dev-mac-arm64:
|
||||||
|
needs: prepare
|
||||||
|
runs-on: macos-14
|
||||||
|
steps:
|
||||||
|
- name: Check out git repository
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Set dev version
|
||||||
|
shell: bash
|
||||||
|
run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
|
- name: Build Frontend & Type Check
|
||||||
|
run: |
|
||||||
|
npx tsc
|
||||||
|
npx vite build
|
||||||
|
|
||||||
|
- name: Package macOS arm64 dev artifacts
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
export ELECTRON_BUILDER_BINARIES_MIRROR="https://github.com/electron-userland/electron-builder-binaries/releases/download/"
|
||||||
|
echo "Using ELECTRON_BUILDER_BINARIES_MIRROR=$ELECTRON_BUILDER_BINARIES_MIRROR"
|
||||||
|
npx electron-builder --mac dmg --arm64 --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-arm64.${ext}'
|
||||||
|
|
||||||
|
- name: Upload macOS arm64 assets to fixed 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_DEV_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
|
||||||
|
|
||||||
|
dev-linux:
|
||||||
|
needs: prepare
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out git repository
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Set dev version
|
||||||
|
shell: bash
|
||||||
|
run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
|
- name: Build Frontend & Type Check
|
||||||
|
run: |
|
||||||
|
npx tsc
|
||||||
|
npx vite build
|
||||||
|
|
||||||
|
- name: Package Linux dev artifacts
|
||||||
|
run: |
|
||||||
|
npx electron-builder --linux --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-linux.${ext}'
|
||||||
|
|
||||||
|
- name: Upload Linux assets to fixed 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_DEV_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
|
||||||
|
|
||||||
|
dev-win-x64:
|
||||||
|
needs: prepare
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out git repository
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Set dev version
|
||||||
|
shell: bash
|
||||||
|
run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
|
- name: Build Frontend & Type Check
|
||||||
|
run: |
|
||||||
|
npx tsc
|
||||||
|
npx vite build
|
||||||
|
|
||||||
|
- name: Package Windows x64 dev artifacts
|
||||||
|
run: |
|
||||||
|
npx electron-builder --win nsis --x64 --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-x64-Setup.${ext}'
|
||||||
|
|
||||||
|
- name: Upload Windows x64 assets to fixed 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_DEV_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
|
||||||
|
|
||||||
|
dev-win-arm64:
|
||||||
|
needs: prepare
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out git repository
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Set dev version
|
||||||
|
shell: bash
|
||||||
|
run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
|
- name: Build Frontend & Type Check
|
||||||
|
run: |
|
||||||
|
npx tsc
|
||||||
|
npx vite build
|
||||||
|
|
||||||
|
- name: Package Windows arm64 dev artifacts
|
||||||
|
run: |
|
||||||
|
npx electron-builder --win nsis --arm64 --publish never '--config.publish.channel=dev-arm64' '--config.artifactName=${productName}-dev-arm64-Setup.${ext}'
|
||||||
|
|
||||||
|
- name: Upload Windows arm64 assets to fixed 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_DEV_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
|
||||||
|
|
||||||
|
update-dev-release-notes:
|
||||||
|
needs:
|
||||||
|
- prepare
|
||||||
|
- dev-mac-arm64
|
||||||
|
- dev-linux
|
||||||
|
- dev-win-x64
|
||||||
|
- dev-win-arm64
|
||||||
|
if: always() && needs.prepare.result == 'success'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Update fixed dev release notes
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
FIXED_DEV_TAG: ${{ env.FIXED_DEV_TAG }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
TAG="$FIXED_DEV_TAG"
|
||||||
|
REPO="$GITHUB_REPOSITORY"
|
||||||
|
RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG"
|
||||||
|
|
||||||
|
if ! gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then
|
||||||
|
echo "Release $TAG not found, skip notes update."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
ASSETS_JSON="$(gh release view "$TAG" --repo "$REPO" --json assets)"
|
||||||
|
|
||||||
|
pick_asset() {
|
||||||
|
local pattern="$1"
|
||||||
|
echo "$ASSETS_JSON" | jq -r --arg p "$pattern" '[.assets[].name | select(test($p))][0] // ""'
|
||||||
|
}
|
||||||
|
|
||||||
|
WINDOWS_ASSET="$(pick_asset "dev-x64-Setup[.]exe$")"
|
||||||
|
WINDOWS_ARM64_ASSET="$(pick_asset "dev-arm64-Setup[.]exe$")"
|
||||||
|
MAC_ASSET="$(pick_asset "dev-arm64[.]dmg$")"
|
||||||
|
LINUX_TAR_ASSET="$(pick_asset "dev-linux[.]tar[.]gz$")"
|
||||||
|
LINUX_APPIMAGE_ASSET="$(pick_asset "dev-linux[.]AppImage$")"
|
||||||
|
|
||||||
|
build_link() {
|
||||||
|
local name="$1"
|
||||||
|
if [ -n "$name" ]; then
|
||||||
|
echo "https://github.com/$REPO/releases/download/$TAG/$name"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
WINDOWS_URL="$(build_link "$WINDOWS_ASSET")"
|
||||||
|
WINDOWS_ARM64_URL="$(build_link "$WINDOWS_ARM64_ASSET")"
|
||||||
|
MAC_URL="$(build_link "$MAC_ASSET")"
|
||||||
|
LINUX_TAR_URL="$(build_link "$LINUX_TAR_ASSET")"
|
||||||
|
LINUX_APPIMAGE_URL="$(build_link "$LINUX_APPIMAGE_ASSET")"
|
||||||
|
|
||||||
|
cat > dev_release_notes.md <<EOF
|
||||||
|
## Daily Dev Build
|
||||||
|
- 该发布页为 **开发版**。
|
||||||
|
- 当前构建版本:\`${{ needs.prepare.outputs.dev_version }}\`
|
||||||
|
- 此版本为每日构建的开发版,包含最新的功能和修复,但可能不够稳定,仅推荐用于测试和验证。
|
||||||
|
|
||||||
|
## 下载
|
||||||
|
- Windows x64: ${WINDOWS_URL:-$RELEASE_PAGE}
|
||||||
|
- Windows arm64: ${WINDOWS_ARM64_URL:-$RELEASE_PAGE}
|
||||||
|
- macOS(Apple Silicon): ${MAC_URL:-$RELEASE_PAGE}
|
||||||
|
- Linux (.tar.gz): ${LINUX_TAR_URL:-$RELEASE_PAGE}
|
||||||
|
- Linux (.AppImage): ${LINUX_APPIMAGE_URL:-$RELEASE_PAGE}
|
||||||
|
|
||||||
|
## macOS 安装提示
|
||||||
|
- 如果被系统提示已损坏,你需要在终端执行以下命令去除隔离标记:
|
||||||
|
- \`xattr -dr com.apple.quarantine "/Applications/WeFlow.app"\`
|
||||||
|
- 执行后重新打开 WeFlow。
|
||||||
|
|
||||||
|
## 说明
|
||||||
|
- 该发布页的同名资源会被后续构建覆盖,请勿将其视作长期归档版本。
|
||||||
|
- 如某个平台资源暂未生成,请进入发布页查看最新状态:$RELEASE_PAGE
|
||||||
|
EOF
|
||||||
|
|
||||||
|
gh release edit "$TAG" --repo "$REPO" --title "Daily Dev Build" --notes-file dev_release_notes.md
|
||||||
278
.github/workflows/preview-nightly-main.yml
vendored
Normal file
278
.github/workflows/preview-nightly-main.yml
vendored
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
name: Preview Nightly
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# GitHub Actions schedule uses UTC. 16:00 UTC = 北京时间次日 00:00
|
||||||
|
- cron: "0 16 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||||
|
ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
prepare:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
should_build: ${{ steps.meta.outputs.should_build }}
|
||||||
|
preview_version: ${{ steps.meta.outputs.preview_version }}
|
||||||
|
steps:
|
||||||
|
- name: Check out git repository
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Decide whether to build and generate preview version
|
||||||
|
id: meta
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
git fetch origin main --depth=1
|
||||||
|
COMMITS_24H="$(git rev-list --count --since='24 hours ago' origin/main)"
|
||||||
|
|
||||||
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
|
SHOULD_BUILD=true
|
||||||
|
elif [ "$COMMITS_24H" -gt 0 ]; then
|
||||||
|
SHOULD_BUILD=true
|
||||||
|
else
|
||||||
|
SHOULD_BUILD=false
|
||||||
|
fi
|
||||||
|
|
||||||
|
BASE_VERSION="$(node -p "require('./package.json').version.split('-')[0]")"
|
||||||
|
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")"
|
||||||
|
NEXT_COUNT=$((EXISTING_COUNT + 1))
|
||||||
|
PREVIEW_VERSION="${BASE_VERSION}-preview.${YEAR_2}.${NEXT_COUNT}"
|
||||||
|
|
||||||
|
echo "should_build=$SHOULD_BUILD" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "preview_version=$PREVIEW_VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Preview version: $PREVIEW_VERSION (commits in last 24h on main: $COMMITS_24H)"
|
||||||
|
|
||||||
|
preview-mac-arm64:
|
||||||
|
needs: prepare
|
||||||
|
if: needs.prepare.outputs.should_build == 'true'
|
||||||
|
runs-on: macos-14
|
||||||
|
steps:
|
||||||
|
- name: Check out git repository
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Set preview version
|
||||||
|
shell: bash
|
||||||
|
run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
|
- name: Build Frontend & Type Check
|
||||||
|
run: |
|
||||||
|
npx tsc
|
||||||
|
npx vite build
|
||||||
|
|
||||||
|
- name: Package and Publish macOS arm64 preview
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
export ELECTRON_BUILDER_BINARIES_MIRROR="https://github.com/electron-userland/electron-builder-binaries/releases/download/"
|
||||||
|
echo "Using ELECTRON_BUILDER_BINARIES_MIRROR=$ELECTRON_BUILDER_BINARIES_MIRROR"
|
||||||
|
npx electron-builder --mac dmg --arm64 --publish always '--config.publish.releaseType=prerelease' '--config.publish.channel=preview'
|
||||||
|
|
||||||
|
preview-linux:
|
||||||
|
needs: prepare
|
||||||
|
if: needs.prepare.outputs.should_build == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out git repository
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Set preview version
|
||||||
|
shell: bash
|
||||||
|
run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
|
- name: Build Frontend & Type Check
|
||||||
|
run: |
|
||||||
|
npx tsc
|
||||||
|
npx vite build
|
||||||
|
|
||||||
|
- name: Package and Publish Linux preview
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
npx electron-builder --linux --publish always '--config.publish.releaseType=prerelease' '--config.publish.channel=preview'
|
||||||
|
|
||||||
|
preview-win-x64:
|
||||||
|
needs: prepare
|
||||||
|
if: needs.prepare.outputs.should_build == 'true'
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out git repository
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Set preview version
|
||||||
|
shell: bash
|
||||||
|
run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
|
- name: Build Frontend & Type Check
|
||||||
|
run: |
|
||||||
|
npx tsc
|
||||||
|
npx vite build
|
||||||
|
|
||||||
|
- name: Package and Publish Windows x64 preview
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
npx electron-builder --win nsis --x64 --publish always '--config.publish.releaseType=prerelease' '--config.publish.channel=preview' '--config.artifactName=${productName}-${version}-x64-Setup.${ext}'
|
||||||
|
|
||||||
|
preview-win-arm64:
|
||||||
|
needs: prepare
|
||||||
|
if: needs.prepare.outputs.should_build == 'true'
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out git repository
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Set preview version
|
||||||
|
shell: bash
|
||||||
|
run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
|
- name: Build Frontend & Type Check
|
||||||
|
run: |
|
||||||
|
npx tsc
|
||||||
|
npx vite build
|
||||||
|
|
||||||
|
- name: Package and Publish Windows arm64 preview
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
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}'
|
||||||
|
|
||||||
|
update-preview-release-notes:
|
||||||
|
needs:
|
||||||
|
- prepare
|
||||||
|
- preview-mac-arm64
|
||||||
|
- preview-linux
|
||||||
|
- preview-win-x64
|
||||||
|
- preview-win-arm64
|
||||||
|
if: needs.prepare.outputs.should_build == 'true' && always()
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Update preview release notes
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
TAG="v${{ needs.prepare.outputs.preview_version }}"
|
||||||
|
REPO="$GITHUB_REPOSITORY"
|
||||||
|
RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG"
|
||||||
|
|
||||||
|
if ! gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then
|
||||||
|
echo "Release $TAG not found (possibly all publish jobs failed), skip notes update."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
ASSETS_JSON="$(gh release view "$TAG" --repo "$REPO" --json assets)"
|
||||||
|
|
||||||
|
pick_asset() {
|
||||||
|
local pattern="$1"
|
||||||
|
echo "$ASSETS_JSON" | jq -r --arg p "$pattern" '[.assets[].name | select(test($p))][0] // ""'
|
||||||
|
}
|
||||||
|
|
||||||
|
WINDOWS_ASSET="$(pick_asset "x64.*[.]exe$")"
|
||||||
|
if [ -z "$WINDOWS_ASSET" ]; then
|
||||||
|
WINDOWS_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("[.]exe$")) | select(test("arm64") | not)][0] // ""')"
|
||||||
|
fi
|
||||||
|
WINDOWS_ARM64_ASSET="$(pick_asset "arm64.*[.]exe$")"
|
||||||
|
MAC_ASSET="$(pick_asset "[.]dmg$")"
|
||||||
|
LINUX_TAR_ASSET="$(pick_asset "[.]tar[.]gz$")"
|
||||||
|
LINUX_APPIMAGE_ASSET="$(pick_asset "[.]AppImage$")"
|
||||||
|
|
||||||
|
build_link() {
|
||||||
|
local name="$1"
|
||||||
|
if [ -n "$name" ]; then
|
||||||
|
echo "https://github.com/$REPO/releases/download/$TAG/$name"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
WINDOWS_URL="$(build_link "$WINDOWS_ASSET")"
|
||||||
|
WINDOWS_ARM64_URL="$(build_link "$WINDOWS_ARM64_ASSET")"
|
||||||
|
MAC_URL="$(build_link "$MAC_ASSET")"
|
||||||
|
LINUX_TAR_URL="$(build_link "$LINUX_TAR_ASSET")"
|
||||||
|
LINUX_APPIMAGE_URL="$(build_link "$LINUX_APPIMAGE_ASSET")"
|
||||||
|
|
||||||
|
cat > preview_release_notes.md <<EOF
|
||||||
|
## Preview Nightly 说明
|
||||||
|
- 该版本为 **预览版**,用于提前体验即将发布的功能与修复。
|
||||||
|
- 可能包含尚未完全稳定的改动,不建议长期使用
|
||||||
|
|
||||||
|
## 下载
|
||||||
|
- Windows x64: ${WINDOWS_URL:-$RELEASE_PAGE}
|
||||||
|
- Windows arm64: ${WINDOWS_ARM64_URL:-$RELEASE_PAGE}
|
||||||
|
- macOS(Apple Silicon): ${MAC_URL:-$RELEASE_PAGE}
|
||||||
|
- Linux (.tar.gz): ${LINUX_TAR_URL:-$RELEASE_PAGE}
|
||||||
|
- Linux (.AppImage): ${LINUX_APPIMAGE_URL:-$RELEASE_PAGE}
|
||||||
|
|
||||||
|
## macOS 安装提示
|
||||||
|
- 如果被系统提示已损坏,你需要在终端执行以下命令去除隔离标记:
|
||||||
|
- \`xattr -dr com.apple.quarantine "/Applications/WeFlow.app"\`
|
||||||
|
- 执行后重新打开 WeFlow。
|
||||||
|
|
||||||
|
> 如某个平台链接暂未生成,请前往发布页查看最新资源:$RELEASE_PAGE
|
||||||
|
EOF
|
||||||
|
|
||||||
|
gh release edit "$TAG" --repo "$REPO" --notes-file preview_release_notes.md
|
||||||
11
.github/workflows/release.yml
vendored
11
.github/workflows/release.yml
vendored
@@ -10,6 +10,7 @@ permissions:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||||
|
ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release-mac-arm64:
|
release-mac-arm64:
|
||||||
@@ -46,7 +47,10 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||||
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
|
export ELECTRON_BUILDER_BINARIES_MIRROR="https://github.com/electron-userland/electron-builder-binaries/releases/download/"
|
||||||
|
echo "Using ELECTRON_BUILDER_BINARIES_MIRROR=$ELECTRON_BUILDER_BINARIES_MIRROR"
|
||||||
npx electron-builder --mac dmg --arm64 --publish always
|
npx electron-builder --mac dmg --arm64 --publish always
|
||||||
|
|
||||||
- name: Inject minimumVersion into latest yml
|
- name: Inject minimumVersion into latest yml
|
||||||
@@ -276,11 +280,10 @@ jobs:
|
|||||||
- Windows arm64: ${WINDOWS_ARM64_URL:-$RELEASE_PAGE}
|
- Windows arm64: ${WINDOWS_ARM64_URL:-$RELEASE_PAGE}
|
||||||
- macOS(M系列芯片): ${MAC_URL:-$RELEASE_PAGE}
|
- macOS(M系列芯片): ${MAC_URL:-$RELEASE_PAGE}
|
||||||
- Linux (.tar.gz): ${LINUX_TAR_URL:-$RELEASE_PAGE}
|
- Linux (.tar.gz): ${LINUX_TAR_URL:-$RELEASE_PAGE}
|
||||||
- linux (.AppImage): ${LINUX_APPIMAGE_URL:-$RELEASE_PAGE}
|
- Linux (.AppImage): ${LINUX_APPIMAGE_URL:-$RELEASE_PAGE}
|
||||||
|
|
||||||
## macOS 安装提示(未知来源)
|
## macOS 安装提示
|
||||||
- 若打开时提示“来自未知开发者”或“无法验证开发者”,请到「系统设置 -> 隐私与安全性」中允许打开该应用。
|
- 如果被系统提示已损坏,你需要在终端执行以下命令去除隔离标记:
|
||||||
- 如果仍被系统拦截,请在终端执行以下命令去除隔离标记:
|
|
||||||
- \`xattr -dr com.apple.quarantine "/Applications/WeFlow.app"\`
|
- \`xattr -dr com.apple.quarantine "/Applications/WeFlow.app"\`
|
||||||
- 执行后重新打开 WeFlow。
|
- 执行后重新打开 WeFlow。
|
||||||
|
|
||||||
|
|||||||
34
.github/workflows/security-scan.yml
vendored
34
.github/workflows/security-scan.yml
vendored
@@ -2,8 +2,10 @@ name: Security Scan
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '0 2 * * *' # 每天 UTC 02:00(北京时间 10:00)
|
- cron: '0 2 * * *' # 每天 UTC 02:00
|
||||||
workflow_dispatch: # 支持手动触发
|
workflow_dispatch: # 手动触发
|
||||||
|
pull_request: # PR 时触发
|
||||||
|
branches: [ main, dev ]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -31,18 +33,14 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: '20'
|
||||||
|
cache: 'npm' # 使用 npm 缓存加速
|
||||||
- name: Install pnpm
|
|
||||||
uses: pnpm/action-setup@v3
|
|
||||||
with:
|
|
||||||
version: 9
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --no-frozen-lockfile
|
run: npm ci --ignore-scripts
|
||||||
|
|
||||||
# 1. npm audit - 检查依赖漏洞
|
# 1. npm audit - 检查依赖漏洞
|
||||||
- name: Dependency vulnerability audit
|
- name: Dependency vulnerability audit
|
||||||
run: pnpm audit --audit-level=moderate
|
run: npm audit --audit-level=moderate
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|
||||||
# 2. CodeQL 静态分析
|
# 2. CodeQL 静态分析
|
||||||
@@ -67,7 +65,7 @@ jobs:
|
|||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|
||||||
# 动态获取所有分支并扫描(排除已在 matrix 中的)
|
# 动态获取所有分支并扫描
|
||||||
scan-all-branches:
|
scan-all-branches:
|
||||||
name: Scan additional branches
|
name: Scan additional branches
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -77,19 +75,13 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: List all branches
|
- name: Run npm audit on all branches
|
||||||
id: branches
|
|
||||||
run: |
|
|
||||||
git branch -r | grep -v HEAD | sed 's|origin/||' | tr -d ' ' | while read branch; do
|
|
||||||
echo "Branch: $branch"
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Run pnpm audit on all branches
|
|
||||||
run: |
|
run: |
|
||||||
git branch -r | grep -v HEAD | sed 's|origin/||' | tr -d ' ' | while read branch; do
|
git branch -r | grep -v HEAD | sed 's|origin/||' | tr -d ' ' | while read branch; do
|
||||||
echo "===== Auditing branch: $branch ====="
|
echo "===== Auditing branch: $branch ====="
|
||||||
git checkout "$branch" 2>/dev/null || continue
|
git checkout "$branch" 2>/dev/null || continue
|
||||||
pnpm install --frozen-lockfile --silent 2>/dev/null || npm install --silent 2>/dev/null || true
|
# 尝试安装并审计
|
||||||
pnpm audit --audit-level=moderate 2>/dev/null || true
|
npm ci --ignore-scripts --silent 2>/dev/null || npm install --ignore-scripts --silent 2>/dev/null || true
|
||||||
|
npm audit --audit-level=moderate 2>/dev/null || true
|
||||||
done
|
done
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
4
.npmrc
4
.npmrc
@@ -1,3 +1 @@
|
|||||||
registry=https://registry.npmmirror.com
|
registry=https://registry.npmjs.org
|
||||||
electron-mirror=https://npmmirror.com/mirrors/electron/
|
|
||||||
electron-builder-binaries-mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
|
|
||||||
|
|||||||
119
electron/main.ts
119
electron/main.ts
@@ -30,16 +30,105 @@ 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
|
||||||
autoUpdater.autoInstallOnAppQuit = true
|
autoUpdater.autoInstallOnAppQuit = true
|
||||||
autoUpdater.disableDifferentialDownload = true // 禁用差分更新,强制全量下载
|
autoUpdater.disableDifferentialDownload = true // 禁用差分更新,强制全量下载
|
||||||
// Windows x64 与 arm64 使用不同更新通道,避免 latest.yml 互相覆盖导致下错架构安装包。
|
// 更新通道策略:
|
||||||
if (process.platform === 'win32' && process.arch === 'arm64') {
|
// - 稳定版(如 4.3.0)默认走 latest
|
||||||
autoUpdater.channel = 'latest-arm64'
|
// - 预览版(如 4.3.0-preview.26.1)默认走 preview
|
||||||
|
// - 开发版(如 4.3.0-dev.26.3.4)默认走 dev
|
||||||
|
// - 用户可在设置页切换稳定/预览/开发,切换后即时生效
|
||||||
|
// 同时区分 Windows x64 / arm64,避免更新清单互相覆盖。
|
||||||
|
const appVersion = app.getVersion()
|
||||||
|
const defaultUpdateTrack: 'stable' | 'preview' | 'dev' = (() => {
|
||||||
|
if (/-preview\.\d+\.\d+$/i.test(appVersion)) return 'preview'
|
||||||
|
if (/-dev\.\d+\.\d+\.\d+$/i.test(appVersion)) return 'dev'
|
||||||
|
if (/(alpha|beta|rc)/i.test(appVersion)) return 'dev'
|
||||||
|
return 'stable'
|
||||||
|
})()
|
||||||
|
const isPrereleaseBuild = defaultUpdateTrack !== 'stable'
|
||||||
|
let configService: ConfigService | null = null
|
||||||
|
|
||||||
|
const normalizeUpdateTrack = (raw: unknown): 'stable' | 'preview' | 'dev' | null => {
|
||||||
|
if (raw === 'stable' || raw === 'preview' || raw === 'dev') return raw
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getEffectiveUpdateTrack = (): 'stable' | 'preview' | 'dev' => {
|
||||||
|
const configuredTrack = normalizeUpdateTrack(configService?.get('updateChannel'))
|
||||||
|
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 applyAutoUpdateChannel = (reason: 'startup' | 'settings' = 'startup') => {
|
||||||
|
const track = getEffectiveUpdateTrack()
|
||||||
|
const baseUpdateChannel = track === 'stable' ? 'latest' : track
|
||||||
|
autoUpdater.allowPrerelease = track !== 'stable'
|
||||||
|
autoUpdater.allowDowngrade = isPrereleaseBuild && track === 'stable'
|
||||||
|
autoUpdater.channel =
|
||||||
|
process.platform === 'win32' && process.arch === 'arm64'
|
||||||
|
? `${baseUpdateChannel}-arm64`
|
||||||
|
: baseUpdateChannel
|
||||||
|
console.log(`[Update](${reason}) 当前版本 ${appVersion},渠道偏好: ${track},更新通道: ${autoUpdater.channel}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
applyAutoUpdateChannel('startup')
|
||||||
const AUTO_UPDATE_ENABLED =
|
const AUTO_UPDATE_ENABLED =
|
||||||
process.env.AUTO_UPDATE_ENABLED === 'true' ||
|
process.env.AUTO_UPDATE_ENABLED === 'true' ||
|
||||||
process.env.AUTO_UPDATE_ENABLED === '1' ||
|
process.env.AUTO_UPDATE_ENABLED === '1' ||
|
||||||
@@ -87,7 +176,6 @@ function sanitizePathEnv() {
|
|||||||
sanitizePathEnv()
|
sanitizePathEnv()
|
||||||
|
|
||||||
// 单例服务
|
// 单例服务
|
||||||
let configService: ConfigService | null = null
|
|
||||||
|
|
||||||
// 协议窗口实例
|
// 协议窗口实例
|
||||||
let agreementWindow: BrowserWindow | null = null
|
let agreementWindow: BrowserWindow | null = null
|
||||||
@@ -243,6 +331,14 @@ const normalizeReleaseNotes = (rawReleaseNotes: unknown): string => {
|
|||||||
return cleaned
|
return cleaned
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getDialogReleaseNotes = (rawReleaseNotes: unknown): string => {
|
||||||
|
const track = getEffectiveUpdateTrack()
|
||||||
|
if (track !== 'stable') {
|
||||||
|
return '修复了一些已知问题'
|
||||||
|
}
|
||||||
|
return normalizeReleaseNotes(rawReleaseNotes)
|
||||||
|
}
|
||||||
|
|
||||||
type AnnualReportYearsLoadStrategy = 'cache' | 'native' | 'hybrid'
|
type AnnualReportYearsLoadStrategy = 'cache' | 'native' | 'hybrid'
|
||||||
type AnnualReportYearsLoadPhase = 'cache' | 'native' | 'scan' | 'done'
|
type AnnualReportYearsLoadPhase = 'cache' | 'native' | 'scan' | 'done'
|
||||||
|
|
||||||
@@ -1110,6 +1206,7 @@ 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)
|
||||||
@@ -1117,6 +1214,9 @@ function registerIpcHandlers() {
|
|||||||
|
|
||||||
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)
|
const result = configService?.set(key as any, value)
|
||||||
|
if (key === 'updateChannel') {
|
||||||
|
applyAutoUpdateChannel('settings')
|
||||||
|
}
|
||||||
void messagePushService.handleConfigChanged(key)
|
void messagePushService.handleConfigChanged(key)
|
||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
@@ -1238,11 +1338,11 @@ function registerIpcHandlers() {
|
|||||||
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 (isRemoteVersionNewer(latestVersion, currentVersion)) {
|
||||||
return {
|
return {
|
||||||
hasUpdate: true,
|
hasUpdate: true,
|
||||||
version: latestVersion,
|
version: latestVersion,
|
||||||
releaseNotes: normalizeReleaseNotes(result.updateInfo.releaseNotes),
|
releaseNotes: getDialogReleaseNotes(result.updateInfo.releaseNotes),
|
||||||
minimumVersion: (result.updateInfo as any).minimumVersion
|
minimumVersion: (result.updateInfo as any).minimumVersion
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2696,7 +2796,7 @@ function checkForUpdatesOnStartup() {
|
|||||||
const latestVersion = result.updateInfo.version
|
const latestVersion = result.updateInfo.version
|
||||||
|
|
||||||
// 检查是否有新版本
|
// 检查是否有新版本
|
||||||
if (latestVersion !== currentVersion && mainWindow) {
|
if (isRemoteVersionNewer(latestVersion, currentVersion) && mainWindow) {
|
||||||
// 检查该版本是否被用户忽略
|
// 检查该版本是否被用户忽略
|
||||||
const ignoredVersion = configService?.get('ignoredUpdateVersion')
|
const ignoredVersion = configService?.get('ignoredUpdateVersion')
|
||||||
if (ignoredVersion === latestVersion) {
|
if (ignoredVersion === latestVersion) {
|
||||||
@@ -2707,7 +2807,7 @@ function checkForUpdatesOnStartup() {
|
|||||||
// 通知渲染进程有新版本
|
// 通知渲染进程有新版本
|
||||||
mainWindow.webContents.send('app:updateAvailable', {
|
mainWindow.webContents.send('app:updateAvailable', {
|
||||||
version: latestVersion,
|
version: latestVersion,
|
||||||
releaseNotes: normalizeReleaseNotes(result.updateInfo.releaseNotes),
|
releaseNotes: getDialogReleaseNotes(result.updateInfo.releaseNotes),
|
||||||
minimumVersion: (result.updateInfo as any).minimumVersion
|
minimumVersion: (result.updateInfo as any).minimumVersion
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -2741,6 +2841,7 @@ app.whenReady().then(async () => {
|
|||||||
// 初始化配置服务
|
// 初始化配置服务
|
||||||
updateSplashProgress(5, '正在加载配置...')
|
updateSplashProgress(5, '正在加载配置...')
|
||||||
configService = new ConfigService()
|
configService = new ConfigService()
|
||||||
|
applyAutoUpdateChannel('startup')
|
||||||
|
|
||||||
// 将用户主题配置推送给 Splash 窗口
|
// 将用户主题配置推送给 Splash 窗口
|
||||||
if (splashWindow && !splashWindow.isDestroyed()) {
|
if (splashWindow && !splashWindow.isDestroyed()) {
|
||||||
|
|||||||
@@ -413,6 +413,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()
|
||||||
@@ -45,6 +45,7 @@ interface ConfigSchema {
|
|||||||
|
|
||||||
// 更新相关
|
// 更新相关
|
||||||
ignoredUpdateVersion: string
|
ignoredUpdateVersion: string
|
||||||
|
updateChannel: 'auto' | 'stable' | 'preview' | 'dev'
|
||||||
|
|
||||||
// 通知
|
// 通知
|
||||||
notificationEnabled: boolean
|
notificationEnabled: boolean
|
||||||
@@ -119,6 +120,7 @@ export class ConfigService {
|
|||||||
authUseHello: false,
|
authUseHello: false,
|
||||||
authHelloSecret: '',
|
authHelloSecret: '',
|
||||||
ignoredUpdateVersion: '',
|
ignoredUpdateVersion: '',
|
||||||
|
updateChannel: 'auto',
|
||||||
notificationEnabled: true,
|
notificationEnabled: true,
|
||||||
notificationPosition: 'top-right',
|
notificationPosition: 'top-right',
|
||||||
notificationFilterMode: 'all',
|
notificationFilterMode: 'all',
|
||||||
|
|||||||
2771
package-lock.json
generated
2771
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
57
package.json
57
package.json
@@ -23,9 +23,9 @@
|
|||||||
"electron:build": "npm run build"
|
"electron:build": "npm run build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"echarts": "^5.5.1",
|
"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",
|
||||||
@@ -34,11 +34,11 @@
|
|||||||
"jieba-wasm": "^2.2.0",
|
"jieba-wasm": "^2.2.0",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"koffi": "^2.9.0",
|
"koffi": "^2.9.0",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^1.7.0",
|
||||||
"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,12 +52,12 @@
|
|||||||
"@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.83.0",
|
"sass": "^1.98.0",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^6.0.2",
|
||||||
"vite": "^6.0.5",
|
"vite": "^7.0.0",
|
||||||
"vite-plugin-electron": "^0.28.8",
|
"vite-plugin-electron": "^0.28.8",
|
||||||
"vite-plugin-electron-renderer": "^0.14.6"
|
"vite-plugin-electron-renderer": "^0.14.6"
|
||||||
},
|
},
|
||||||
@@ -102,7 +102,25 @@
|
|||||||
"target": [
|
"target": [
|
||||||
"nsis"
|
"nsis"
|
||||||
],
|
],
|
||||||
"icon": "public/icon.ico"
|
"icon": "public/icon.ico",
|
||||||
|
"extraFiles": [
|
||||||
|
{
|
||||||
|
"from": "resources/msvcp140.dll",
|
||||||
|
"to": "."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "resources/msvcp140_1.dll",
|
||||||
|
"to": "."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "resources/vcruntime140.dll",
|
||||||
|
"to": "."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "resources/vcruntime140_1.dll",
|
||||||
|
"to": "."
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"linux": {
|
"linux": {
|
||||||
"icon": "public/icon.png",
|
"icon": "public/icon.png",
|
||||||
@@ -170,30 +188,11 @@
|
|||||||
"node_modules/sherpa-onnx-*/**/*",
|
"node_modules/sherpa-onnx-*/**/*",
|
||||||
"node_modules/ffmpeg-static/**/*"
|
"node_modules/ffmpeg-static/**/*"
|
||||||
],
|
],
|
||||||
"extraFiles": [
|
|
||||||
{
|
|
||||||
"from": "resources/msvcp140.dll",
|
|
||||||
"to": "."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"from": "resources/msvcp140_1.dll",
|
|
||||||
"to": "."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"from": "resources/vcruntime140.dll",
|
|
||||||
"to": "."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"from": "resources/vcruntime140_1.dll",
|
|
||||||
"to": "."
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"icon": "resources/icon.icns"
|
"icon": "resources/icon.icns"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"picomatch": "^4.0.4",
|
"picomatch": "^4.0.4",
|
||||||
"tar": "^7.5.13",
|
"tar": "^7.5.13",
|
||||||
"immutable": "^5.1.5",
|
"immutable": "^5.1.5"
|
||||||
"ajv": "^6.14.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 />} />
|
||||||
|
|||||||
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,24 +6278,36 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
|
|
||||||
{/* 折叠群列表 */}
|
{/* 折叠群列表 */}
|
||||||
<div className="session-list-panel folded-panel">
|
<div className="session-list-panel folded-panel">
|
||||||
{foldedSessions.length > 0 ? (
|
{foldedView && (
|
||||||
<div className="session-list">
|
foldedSessions.length > 0 ? (
|
||||||
{foldedSessions.map(session => (
|
<div className="session-list">
|
||||||
<SessionItem
|
{foldedSessions.map(session => (
|
||||||
key={session.username}
|
<SessionItem
|
||||||
session={session}
|
key={session.username}
|
||||||
isActive={currentSessionId === session.username}
|
session={session}
|
||||||
onSelect={handleSelectSession}
|
isActive={currentSessionId === session.username || (bizView && session.username === OFFICIAL_ACCOUNTS_VIRTUAL_ID)}
|
||||||
formatTime={formatSessionTime}
|
onSelect={handleSelectSession}
|
||||||
searchKeyword={searchKeyword}
|
formatTime={formatSessionTime}
|
||||||
|
searchKeyword={searchKeyword}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="empty-sessions">
|
||||||
|
<Users size={32} />
|
||||||
|
<p>没有折叠的群聊</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{bizView && (
|
||||||
|
<div style={{ height: '100%', overflowY: 'auto' }}>
|
||||||
|
<BizAccountList
|
||||||
|
onSelect={setSelectedBizAccount}
|
||||||
|
selectedUsername={selectedBizAccount?.username}
|
||||||
|
searchKeyword={searchKeyword}
|
||||||
/>
|
/>
|
||||||
))}
|
</div>
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="empty-sessions">
|
|
||||||
<Users size={32} />
|
|
||||||
<p>没有折叠的群聊</p>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -6247,9 +6319,11 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
|
|
||||||
{/* 右侧消息区域 */}
|
{/* 右侧消息区域 */}
|
||||||
<div className="message-area">
|
<div className="message-area">
|
||||||
{currentSession ? (
|
{bizView ? (
|
||||||
<>
|
<BizMessageArea account={selectedBizAccount} />
|
||||||
<div className="message-header">
|
) : currentSession ? (
|
||||||
|
<>
|
||||||
|
<div className="message-header">
|
||||||
<Avatar
|
<Avatar
|
||||||
src={currentSession.avatarUrl}
|
src={currentSession.avatarUrl}
|
||||||
name={currentSession.displayName || currentSession.username}
|
name={currentSession.displayName || currentSession.username}
|
||||||
|
|||||||
@@ -1401,6 +1401,220 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 版本更新页面
|
||||||
|
.updates-tab {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.updates-hero {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 20px 22px;
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--primary) 18%, var(--border-color));
|
||||||
|
background:
|
||||||
|
linear-gradient(110deg, color-mix(in srgb, var(--bg-primary) 98%, #ffffff 2%), color-mix(in srgb, var(--bg-primary) 97%, var(--primary) 3%));
|
||||||
|
box-shadow: 0 8px 22px color-mix(in srgb, var(--primary) 8%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.updates-hero-main {
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 10px 0 6px;
|
||||||
|
font-size: 30px;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.updates-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary);
|
||||||
|
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.updates-hero-action {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.updates-card {
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: color-mix(in srgb, var(--bg-primary) 96%, var(--bg-secondary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.updates-progress-card {
|
||||||
|
padding: 16px 18px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--primary) 34%, var(--border-color));
|
||||||
|
background: color-mix(in srgb, var(--bg-primary) 95%, var(--primary) 4%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.updates-progress-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 14px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.updates-card-header {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-channel-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-channel-card {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: border-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color));
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 8px 24px color-mix(in srgb, var(--primary) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 60%, var(--border-color));
|
||||||
|
background: color-mix(in srgb, var(--primary) 10%, var(--bg-primary));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-channel-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-channel-card .desc {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.updates-progress-track {
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.updates-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: linear-gradient(90deg, var(--primary), color-mix(in srgb, var(--primary) 72%, #ffffff));
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.26), transparent);
|
||||||
|
animation: updatesShimmer 1.7s linear infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.updates-ignore-btn {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 920px) {
|
||||||
|
.updates-hero {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.updates-hero-action {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes updatesShimmer {
|
||||||
|
from {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// 关于页面
|
// 关于页面
|
||||||
.about-tab {
|
.about-tab {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
import { Avatar } from '../components/Avatar'
|
import { Avatar } from '../components/Avatar'
|
||||||
import './SettingsPage.scss'
|
import './SettingsPage.scss'
|
||||||
|
|
||||||
type SettingsTab = 'appearance' | 'notification' | 'database' | 'models' | 'cache' | 'api' | 'security' | 'about' | 'analytics'
|
type SettingsTab = 'appearance' | 'notification' | 'database' | 'models' | 'cache' | 'api' | 'updates' | 'security' | 'about' | 'analytics'
|
||||||
|
|
||||||
const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
|
const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
|
||||||
{ id: 'appearance', label: '外观', icon: Palette },
|
{ id: 'appearance', label: '外观', icon: Palette },
|
||||||
@@ -24,9 +24,9 @@ const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
|
|||||||
{ id: 'models', label: '模型管理', icon: Mic },
|
{ id: 'models', label: '模型管理', icon: Mic },
|
||||||
{ id: 'cache', label: '缓存', icon: HardDrive },
|
{ id: 'cache', label: '缓存', icon: HardDrive },
|
||||||
{ id: 'api', label: 'API 服务', icon: Globe },
|
{ id: 'api', label: 'API 服务', icon: Globe },
|
||||||
|
|
||||||
{ id: 'analytics', label: '分析', icon: BarChart2 },
|
{ id: 'analytics', label: '分析', icon: BarChart2 },
|
||||||
{ id: 'security', label: '安全', icon: ShieldCheck },
|
{ id: 'security', label: '安全', icon: ShieldCheck },
|
||||||
|
{ id: 'updates', label: '版本更新', icon: RefreshCw },
|
||||||
{ id: 'about', label: '关于', icon: Info }
|
{ id: 'about', label: '关于', icon: Info }
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -68,7 +68,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
setDownloadProgress,
|
setDownloadProgress,
|
||||||
showUpdateDialog,
|
showUpdateDialog,
|
||||||
setShowUpdateDialog,
|
setShowUpdateDialog,
|
||||||
setUpdateError
|
|
||||||
} = useAppStore()
|
} = useAppStore()
|
||||||
|
|
||||||
const resetChatStore = useChatStore((state) => state.reset)
|
const resetChatStore = useChatStore((state) => state.reset)
|
||||||
@@ -141,6 +140,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
const [notificationFilterList, setNotificationFilterList] = useState<string[]>([])
|
const [notificationFilterList, setNotificationFilterList] = useState<string[]>([])
|
||||||
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 [filterSearchKeyword, setFilterSearchKeyword] = useState('')
|
const [filterSearchKeyword, setFilterSearchKeyword] = useState('')
|
||||||
const [filterModeDropdownOpen, setFilterModeDropdownOpen] = useState(false)
|
const [filterModeDropdownOpen, setFilterModeDropdownOpen] = useState(false)
|
||||||
const [positionDropdownOpen, setPositionDropdownOpen] = useState(false)
|
const [positionDropdownOpen, setPositionDropdownOpen] = useState(false)
|
||||||
@@ -339,6 +339,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
const savedMessagePushEnabled = await configService.getMessagePushEnabled()
|
const savedMessagePushEnabled = await configService.getMessagePushEnabled()
|
||||||
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 savedAuthEnabled = await window.electronAPI.auth.verifyEnabled()
|
const savedAuthEnabled = await window.electronAPI.auth.verifyEnabled()
|
||||||
const savedAuthUseHello = await configService.getAuthUseHello()
|
const savedAuthUseHello = await configService.getAuthUseHello()
|
||||||
@@ -387,6 +388,18 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
setMessagePushEnabled(savedMessagePushEnabled)
|
setMessagePushEnabled(savedMessagePushEnabled)
|
||||||
setWindowCloseBehavior(savedWindowCloseBehavior)
|
setWindowCloseBehavior(savedWindowCloseBehavior)
|
||||||
setQuoteLayout(savedQuoteLayout)
|
setQuoteLayout(savedQuoteLayout)
|
||||||
|
if (savedUpdateChannel) {
|
||||||
|
setUpdateChannel(savedUpdateChannel)
|
||||||
|
} else {
|
||||||
|
const currentVersion = await window.electronAPI.app.getVersion()
|
||||||
|
if (/-preview\.\d+\.\d+$/i.test(currentVersion)) {
|
||||||
|
setUpdateChannel('preview')
|
||||||
|
} else if (/-dev\.\d+\.\d+\.\d+$/i.test(currentVersion) || /(alpha|beta|rc)/i.test(currentVersion)) {
|
||||||
|
setUpdateChannel('dev')
|
||||||
|
} else {
|
||||||
|
setUpdateChannel('stable')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const savedExcludeWords = await configService.getWordCloudExcludeWords()
|
const savedExcludeWords = await configService.getWordCloudExcludeWords()
|
||||||
setWordCloudExcludeWords(savedExcludeWords)
|
setWordCloudExcludeWords(savedExcludeWords)
|
||||||
@@ -512,7 +525,22 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleUpdateChannelChange = async (channel: configService.UpdateChannel) => {
|
||||||
|
if (channel === updateChannel) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
setUpdateChannel(channel)
|
||||||
|
await configService.setUpdateChannel(channel)
|
||||||
|
await configService.setIgnoredUpdateVersion('')
|
||||||
|
setUpdateInfo(null)
|
||||||
|
setShowUpdateDialog(false)
|
||||||
|
const channelLabel = channel === 'stable' ? '稳定版' : channel === 'preview' ? '预览版' : '开发版'
|
||||||
|
showMessage(`已切换到${channelLabel}更新渠道,正在检查更新`, true)
|
||||||
|
await handleCheckUpdate()
|
||||||
|
} catch (e: any) {
|
||||||
|
showMessage(`切换更新渠道失败: ${e}`, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const showMessage = (text: string, success: boolean) => {
|
const showMessage = (text: string, success: boolean) => {
|
||||||
setMessage({ text, success })
|
setMessage({ text, success })
|
||||||
@@ -1474,6 +1502,16 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
|
|
||||||
const renderDatabaseTab = () => (
|
const renderDatabaseTab = () => (
|
||||||
<div className="tab-content">
|
<div className="tab-content">
|
||||||
|
<div className="form-group">
|
||||||
|
<label>连接测试</label>
|
||||||
|
<span className="form-hint">检测当前数据库配置是否可用</span>
|
||||||
|
<button className="btn btn-secondary" onClick={handleTestConnection} disabled={isLoading || isTesting}>
|
||||||
|
<Plug size={16} /> {isTesting ? '测试中...' : '测试连接'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divider" />
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>解密密钥</label>
|
<label>解密密钥</label>
|
||||||
<span className="form-hint">64位十六进制密钥</span>
|
<span className="form-hint">64位十六进制密钥</span>
|
||||||
@@ -2400,35 +2438,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
<img src="./logo.png" alt="WeFlow" />
|
<img src="./logo.png" alt="WeFlow" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="about-name">WeFlow</h2>
|
<h2 className="about-name">WeFlow</h2>
|
||||||
<p className="about-slogan">WeFlow</p>
|
|
||||||
<p className="about-version">v{appVersion || '...'}</p>
|
<p className="about-version">v{appVersion || '...'}</p>
|
||||||
|
|
||||||
<div className="about-update">
|
|
||||||
{updateInfo?.hasUpdate ? (
|
|
||||||
<>
|
|
||||||
<p className="update-hint">新版 v{updateInfo.version} 可用</p>
|
|
||||||
{isDownloading ? (
|
|
||||||
<div className="update-progress">
|
|
||||||
<div className="progress-bar">
|
|
||||||
<div className="progress-inner" style={{ width: `${(downloadProgress?.percent || 0)}%` }} />
|
|
||||||
</div>
|
|
||||||
<span>{(downloadProgress?.percent || 0).toFixed(0)}%</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button className="btn btn-primary" onClick={() => setShowUpdateDialog(true)}>
|
|
||||||
<Download size={16} /> 立即更新
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
|
|
||||||
<button className="btn btn-secondary" onClick={handleCheckUpdate} disabled={isCheckingUpdate}>
|
|
||||||
<RefreshCw size={16} className={isCheckingUpdate ? 'spin' : ''} />
|
|
||||||
{isCheckingUpdate ? '检查中...' : '检查更新'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="about-footer">
|
<div className="about-footer">
|
||||||
@@ -2440,7 +2450,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
<span>·</span>
|
<span>·</span>
|
||||||
<a href="#" onClick={(e) => { e.preventDefault(); window.electronAPI.window.openAgreementWindow() }}>用户协议</a>
|
<a href="#" onClick={(e) => { e.preventDefault(); window.electronAPI.window.openAgreementWindow() }}>用户协议</a>
|
||||||
</div>
|
</div>
|
||||||
<p className="copyright">© 2025 WeFlow. All rights reserved.</p>
|
<p className="copyright">© 2026 WeFlow. All rights reserved.</p>
|
||||||
|
|
||||||
<div className="log-toggle-line" style={{ marginTop: '16px', justifyContent: 'center' }}>
|
<div className="log-toggle-line" style={{ marginTop: '16px', justifyContent: 'center' }}>
|
||||||
<span style={{ fontSize: '13px', opacity: 0.7 }}>匿名数据收集</span>
|
<span style={{ fontSize: '13px', opacity: 0.7 }}>匿名数据收集</span>
|
||||||
@@ -2463,6 +2473,82 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const renderUpdatesTab = () => {
|
||||||
|
const downloadPercent = Math.max(0, Math.min(100, Number(downloadProgress?.percent || 0)))
|
||||||
|
const channelCards: { id: configService.UpdateChannel; title: string; desc: string }[] = [
|
||||||
|
{ id: 'stable', title: '稳定版', desc: '正式发布的版本,适合日常使用' },
|
||||||
|
{ id: 'preview', title: '预览版', desc: '正式发布前的预览体验版本' },
|
||||||
|
{ id: 'dev', title: '开发版', desc: '即刻体验我们的屎山代码' }
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tab-content updates-tab">
|
||||||
|
<div className="updates-hero">
|
||||||
|
<div className="updates-hero-main">
|
||||||
|
<span className="updates-chip">当前版本</span>
|
||||||
|
<h2>{appVersion || '...'}</h2>
|
||||||
|
<p>{updateInfo?.hasUpdate ? `发现新版本 v${updateInfo.version}` : '当前已是最新版本,可手动检查更新'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="updates-hero-action">
|
||||||
|
{updateInfo?.hasUpdate ? (
|
||||||
|
<button className="btn btn-primary" onClick={() => setShowUpdateDialog(true)}>
|
||||||
|
<Download size={16} /> 立即更新
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button className="btn btn-secondary" onClick={handleCheckUpdate} disabled={isCheckingUpdate}>
|
||||||
|
<RefreshCw size={16} className={isCheckingUpdate ? 'spin' : ''} />
|
||||||
|
{isCheckingUpdate ? '检查中...' : '检查更新'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(isDownloading || updateInfo?.hasUpdate) && (
|
||||||
|
<div className="updates-progress-card">
|
||||||
|
<div className="updates-progress-header">
|
||||||
|
<h3>{isDownloading ? `正在下载 v${updateInfo?.version || ''}` : `新版本 v${updateInfo?.version} 已就绪`}</h3>
|
||||||
|
{isDownloading ? <strong>{downloadPercent.toFixed(0)}%</strong> : <span>可立即安装</span>}
|
||||||
|
</div>
|
||||||
|
<div className="updates-progress-track">
|
||||||
|
<div className="updates-progress-fill" style={{ width: `${isDownloading ? downloadPercent : 100}%` }} />
|
||||||
|
</div>
|
||||||
|
{updateInfo?.hasUpdate && !isDownloading && (
|
||||||
|
<button className="btn btn-secondary updates-ignore-btn" onClick={handleIgnoreUpdate}>
|
||||||
|
暂不提醒此版本
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="updates-card">
|
||||||
|
<div className="updates-card-header">
|
||||||
|
<h3>更新渠道</h3>
|
||||||
|
<span>切换渠道后会自动重新检查</span>
|
||||||
|
</div>
|
||||||
|
<div className="update-channel-grid">
|
||||||
|
{channelCards.map((channel) => {
|
||||||
|
const active = updateChannel === channel.id
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={channel.id}
|
||||||
|
className={`update-channel-card ${active ? 'active' : ''}`}
|
||||||
|
onClick={() => void handleUpdateChannelChange(channel.id)}
|
||||||
|
disabled={active}
|
||||||
|
>
|
||||||
|
<div className="update-channel-title-row">
|
||||||
|
<span className="title">{channel.title}</span>
|
||||||
|
{active && <Check size={16} />}
|
||||||
|
</div>
|
||||||
|
<span className="desc">{channel.desc}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`settings-modal-overlay ${isClosing ? 'closing' : ''}`} onClick={handleClose}>
|
<div className={`settings-modal-overlay ${isClosing ? 'closing' : ''}`} onClick={handleClose}>
|
||||||
<div className={`settings-page ${isClosing ? 'closing' : ''}`} onClick={(event) => event.stopPropagation()}>
|
<div className={`settings-page ${isClosing ? 'closing' : ''}`} onClick={(event) => event.stopPropagation()}>
|
||||||
@@ -2510,9 +2596,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
<h1>设置</h1>
|
<h1>设置</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="settings-actions">
|
<div className="settings-actions">
|
||||||
<button className="btn btn-secondary" onClick={handleTestConnection} disabled={isLoading || isTesting}>
|
|
||||||
<Plug size={16} /> {isTesting ? '测试中...' : '测试连接'}
|
|
||||||
</button>
|
|
||||||
{onClose && (
|
{onClose && (
|
||||||
<button type="button" className="settings-close-btn" onClick={handleClose} aria-label="关闭设置">
|
<button type="button" className="settings-close-btn" onClick={handleClose} aria-label="关闭设置">
|
||||||
<X size={18} />
|
<X size={18} />
|
||||||
@@ -2542,6 +2625,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
{activeTab === 'models' && renderModelsTab()}
|
{activeTab === 'models' && renderModelsTab()}
|
||||||
{activeTab === 'cache' && renderCacheTab()}
|
{activeTab === 'cache' && renderCacheTab()}
|
||||||
{activeTab === 'api' && renderApiTab()}
|
{activeTab === 'api' && renderApiTab()}
|
||||||
|
{activeTab === 'updates' && renderUpdatesTab()}
|
||||||
{activeTab === 'analytics' && renderAnalyticsTab()}
|
{activeTab === 'analytics' && renderAnalyticsTab()}
|
||||||
{activeTab === 'security' && renderSecurityTab()}
|
{activeTab === 'security' && renderSecurityTab()}
|
||||||
{activeTab === 'about' && renderAboutTab()}
|
{activeTab === 'about' && renderAboutTab()}
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export const CONFIG_KEYS = {
|
|||||||
|
|
||||||
// 更新
|
// 更新
|
||||||
IGNORED_UPDATE_VERSION: 'ignoredUpdateVersion',
|
IGNORED_UPDATE_VERSION: 'ignoredUpdateVersion',
|
||||||
|
UPDATE_CHANNEL: 'updateChannel',
|
||||||
|
|
||||||
// 通知
|
// 通知
|
||||||
NOTIFICATION_ENABLED: 'notificationEnabled',
|
NOTIFICATION_ENABLED: 'notificationEnabled',
|
||||||
@@ -96,6 +97,7 @@ export interface ExportDefaultMediaConfig {
|
|||||||
|
|
||||||
export type WindowCloseBehavior = 'ask' | 'tray' | 'quit'
|
export type WindowCloseBehavior = 'ask' | 'tray' | 'quit'
|
||||||
export type QuoteLayout = 'quote-top' | 'quote-bottom'
|
export type QuoteLayout = 'quote-top' | 'quote-bottom'
|
||||||
|
export type UpdateChannel = 'stable' | 'preview' | 'dev'
|
||||||
|
|
||||||
const DEFAULT_EXPORT_MEDIA_CONFIG: ExportDefaultMediaConfig = {
|
const DEFAULT_EXPORT_MEDIA_CONFIG: ExportDefaultMediaConfig = {
|
||||||
images: true,
|
images: true,
|
||||||
@@ -1381,6 +1383,18 @@ export async function setIgnoredUpdateVersion(version: string): Promise<void> {
|
|||||||
await config.set(CONFIG_KEYS.IGNORED_UPDATE_VERSION, version)
|
await config.set(CONFIG_KEYS.IGNORED_UPDATE_VERSION, version)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取更新渠道(空值/auto 视为未显式设置,交由安装包类型决定默认渠道)
|
||||||
|
export async function getUpdateChannel(): Promise<UpdateChannel | null> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.UPDATE_CHANNEL)
|
||||||
|
if (value === 'stable' || value === 'preview' || value === 'dev') return value
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置更新渠道
|
||||||
|
export async function setUpdateChannel(channel: UpdateChannel): Promise<void> {
|
||||||
|
await config.set(CONFIG_KEYS.UPDATE_CHANNEL, channel)
|
||||||
|
}
|
||||||
|
|
||||||
// 获取通知开关
|
// 获取通知开关
|
||||||
export async function getNotificationEnabled(): Promise<boolean> {
|
export async function getNotificationEnabled(): Promise<boolean> {
|
||||||
const value = await config.get(CONFIG_KEYS.NOTIFICATION_ENABLED)
|
const value = await config.get(CONFIG_KEYS.NOTIFICATION_ENABLED)
|
||||||
|
|||||||
5
src/types/electron.d.ts
vendored
5
src/types/electron.d.ts
vendored
@@ -326,6 +326,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 }>
|
||||||
|
|||||||
@@ -17,8 +17,7 @@
|
|||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
},
|
}
|
||||||
"baseUrl": "."
|
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts"],
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts"],
|
||||||
"references": [{ "path": "./tsconfig.node.json" }]
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export default defineConfig({
|
|||||||
strictPort: false // 如果3000被占用,自动尝试下一个
|
strictPort: false // 如果3000被占用,自动尝试下一个
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
|
chunkSizeWarningLimit: 900,
|
||||||
commonjsOptions: {
|
commonjsOptions: {
|
||||||
ignoreDynamicRequires: true
|
ignoreDynamicRequires: true
|
||||||
}
|
}
|
||||||
@@ -171,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