diff --git a/.github/workflows/dev-daily-fixed.yml b/.github/workflows/dev-daily-fixed.yml index e867f46..fb63aff 100644 --- a/.github/workflows/dev-daily-fixed.yml +++ b/.github/workflows/dev-daily-fixed.yml @@ -12,6 +12,7 @@ permissions: env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" FIXED_DEV_TAG: nightly-dev + TARGET_BRANCH: dev ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/ jobs: @@ -23,6 +24,7 @@ jobs: - name: Check out git repository uses: actions/checkout@v5 with: + ref: ${{ env.TARGET_BRANCH }} fetch-depth: 0 - name: Install Node.js @@ -36,25 +38,25 @@ jobs: 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}" + DEV_VERSION="${YEAR_2}.${MONTH}.${DAY}" echo "dev_version=$DEV_VERSION" >> "$GITHUB_OUTPUT" echo "Dev version: $DEV_VERSION" - - name: Ensure fixed prerelease exists + - name: Recreate fixed prerelease 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 + gh release delete "$FIXED_DEV_TAG" --repo "$GITHUB_REPOSITORY" --yes --cleanup-tag fi + gh release create "$FIXED_DEV_TAG" --repo "$GITHUB_REPOSITORY" --title "Daily Dev Build" --notes "开发版发布页" --prerelease --target "$TARGET_BRANCH" + RELEASE_REST_ID="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$FIXED_DEV_TAG" --jq '.id')" + gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$RELEASE_REST_ID" -f draft=false -f prerelease=true >/dev/null dev-mac-arm64: needs: prepare @@ -63,6 +65,7 @@ jobs: - name: Check out git repository uses: actions/checkout@v5 with: + ref: ${{ env.TARGET_BRANCH }} fetch-depth: 0 - name: Install Node.js @@ -79,6 +82,7 @@ jobs: run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version - name: Build Frontend & Type Check + shell: bash run: | npx tsc npx vite build @@ -95,7 +99,10 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} shell: bash run: | - mapfile -t assets < <(find release -maxdepth 1 -type f | sort) + assets=() + while IFS= read -r file; do + assets+=("$file") + done < <(find release -maxdepth 1 -type f | sort) if [ "${#assets[@]}" -eq 0 ]; then echo "No release files found in ./release" exit 1 @@ -109,6 +116,7 @@ jobs: - name: Check out git repository uses: actions/checkout@v5 with: + ref: ${{ env.TARGET_BRANCH }} fetch-depth: 0 - name: Install Node.js @@ -125,6 +133,7 @@ jobs: run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version - name: Build Frontend & Type Check + shell: bash run: | npx tsc npx vite build @@ -138,7 +147,10 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} shell: bash run: | - mapfile -t assets < <(find release -maxdepth 1 -type f | sort) + assets=() + while IFS= read -r file; do + assets+=("$file") + done < <(find release -maxdepth 1 -type f | sort) if [ "${#assets[@]}" -eq 0 ]; then echo "No release files found in ./release" exit 1 @@ -152,6 +164,7 @@ jobs: - name: Check out git repository uses: actions/checkout@v5 with: + ref: ${{ env.TARGET_BRANCH }} fetch-depth: 0 - name: Install Node.js @@ -168,6 +181,7 @@ jobs: run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version - name: Build Frontend & Type Check + shell: bash run: | npx tsc npx vite build @@ -181,7 +195,10 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} shell: bash run: | - mapfile -t assets < <(find release -maxdepth 1 -type f | sort) + assets=() + while IFS= read -r file; do + assets+=("$file") + done < <(find release -maxdepth 1 -type f | sort) if [ "${#assets[@]}" -eq 0 ]; then echo "No release files found in ./release" exit 1 @@ -195,6 +212,7 @@ jobs: - name: Check out git repository uses: actions/checkout@v5 with: + ref: ${{ env.TARGET_BRANCH }} fetch-depth: 0 - name: Install Node.js @@ -211,6 +229,7 @@ jobs: run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version - name: Build Frontend & Type Check + shell: bash run: | npx tsc npx vite build @@ -224,7 +243,10 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} shell: bash run: | - mapfile -t assets < <(find release -maxdepth 1 -type f | sort) + assets=() + while IFS= read -r file; do + assets+=("$file") + done < <(find release -maxdepth 1 -type f | sort) if [ "${#assets[@]}" -eq 0 ]; then echo "No release files found in ./release" exit 1 @@ -291,11 +313,11 @@ jobs: - 此版本为每日构建的开发版,包含最新的功能和修复,但可能不够稳定,仅推荐用于测试和验证。 ## 下载 - - 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} + - 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 安装提示 - 如果被系统提示已损坏,你需要在终端执行以下命令去除隔离标记: @@ -304,7 +326,12 @@ jobs: ## 说明 - 该发布页的同名资源会被后续构建覆盖,请勿将其视作长期归档版本。 - - 如某个平台资源暂未生成,请进入发布页查看最新状态:$RELEASE_PAGE + - 如某个平台资源暂未生成,请进入[发布页]($RELEASE_PAGE)查看最新状态 EOF - gh release edit "$TAG" --repo "$REPO" --title "Daily Dev Build" --notes-file dev_release_notes.md + RELEASE_REST_ID="$(gh api "repos/$REPO/releases/tags/$TAG" --jq '.id')" + jq -n --rawfile body dev_release_notes.md \ + '{name:"Daily Dev Build", body:$body, draft:false, prerelease:true}' \ + > release_update_payload.json + gh api --method PATCH "repos/$REPO/releases/$RELEASE_REST_ID" --input release_update_payload.json >/dev/null + gh release view "$TAG" --repo "$REPO" --json isDraft,isPrerelease,url diff --git a/.github/workflows/preview-nightly-main.yml b/.github/workflows/preview-nightly-main.yml index 9ff6a19..751d227 100644 --- a/.github/workflows/preview-nightly-main.yml +++ b/.github/workflows/preview-nightly-main.yml @@ -11,6 +11,8 @@ permissions: env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + FIXED_PREVIEW_TAG: nightly-preview + TARGET_BRANCH: main ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/ jobs: @@ -23,6 +25,7 @@ jobs: - name: Check out git repository uses: actions/checkout@v5 with: + ref: ${{ env.TARGET_BRANCH }} fetch-depth: 0 - name: Install Node.js @@ -50,15 +53,36 @@ jobs: 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}" + YEARLY_RUN_COUNT=1 + LAST_VERSION="$(gh release view "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" --json body --jq '.body' 2>/dev/null | grep -Eo '0\.[0-9]{2}\.[0-9]+' | head -n 1 || true)" + if [[ "$LAST_VERSION" =~ ^0\.([0-9]{2})\.([0-9]+)$ ]]; then + LAST_YEAR="${BASH_REMATCH[1]}" + LAST_COUNT="${BASH_REMATCH[2]}" + if [ "$LAST_YEAR" = "$YEAR_2" ]; then + YEARLY_RUN_COUNT=$((LAST_COUNT + 1)) + fi + fi + + PREVIEW_VERSION="0.${YEAR_2}.${YEARLY_RUN_COUNT}" echo "should_build=$SHOULD_BUILD" >> "$GITHUB_OUTPUT" echo "preview_version=$PREVIEW_VERSION" >> "$GITHUB_OUTPUT" - echo "Preview version: $PREVIEW_VERSION (commits in last 24h on main: $COMMITS_24H)" + echo "Preview version: $PREVIEW_VERSION (commits in last 24h on main: $COMMITS_24H, yearly count: $YEARLY_RUN_COUNT)" + + - name: Recreate fixed preview prerelease + if: steps.meta.outputs.should_build == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + if gh release view "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + gh release delete "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" --yes --cleanup-tag + fi + gh release create "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" --title "Preview Nightly Build" --notes "预览版发布页" --prerelease --target "$TARGET_BRANCH" + RELEASE_REST_ID="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$FIXED_PREVIEW_TAG" --jq '.id')" + gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$RELEASE_REST_ID" -f draft=false -f prerelease=true >/dev/null preview-mac-arm64: needs: prepare @@ -68,6 +92,7 @@ jobs: - name: Check out git repository uses: actions/checkout@v5 with: + ref: ${{ env.TARGET_BRANCH }} fetch-depth: 0 - name: Install Node.js @@ -84,19 +109,34 @@ jobs: run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version - name: Build Frontend & Type Check + shell: bash run: | npx tsc npx vite build - - name: Package and Publish macOS arm64 preview + - name: Package macOS arm64 preview artifacts 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' + npx electron-builder --mac dmg --arm64 --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-arm64.${ext}' + + - name: Upload macOS arm64 assets to fixed preview release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + assets=() + while IFS= read -r file; do + assets+=("$file") + done < <(find release -maxdepth 1 -type f | sort) + if [ "${#assets[@]}" -eq 0 ]; then + echo "No release files found in ./release" + exit 1 + fi + gh release upload "$FIXED_PREVIEW_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber preview-linux: needs: prepare @@ -106,6 +146,7 @@ jobs: - name: Check out git repository uses: actions/checkout@v5 with: + ref: ${{ env.TARGET_BRANCH }} fetch-depth: 0 - name: Install Node.js @@ -122,15 +163,32 @@ jobs: run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version - name: Build Frontend & Type Check + shell: bash run: | npx tsc npx vite build - - name: Package and Publish Linux preview + - name: Package Linux preview artifacts env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash run: | - npx electron-builder --linux --publish always '--config.publish.releaseType=prerelease' '--config.publish.channel=preview' + npx electron-builder --linux --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-linux.${ext}' + + - name: Upload Linux assets to fixed preview release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + assets=() + while IFS= read -r file; do + assets+=("$file") + done < <(find release -maxdepth 1 -type f | sort) + if [ "${#assets[@]}" -eq 0 ]; then + echo "No release files found in ./release" + exit 1 + fi + gh release upload "$FIXED_PREVIEW_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber preview-win-x64: needs: prepare @@ -140,6 +198,7 @@ jobs: - name: Check out git repository uses: actions/checkout@v5 with: + ref: ${{ env.TARGET_BRANCH }} fetch-depth: 0 - name: Install Node.js @@ -156,15 +215,32 @@ jobs: run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version - name: Build Frontend & Type Check + shell: bash run: | npx tsc npx vite build - - name: Package and Publish Windows x64 preview + - name: Package Windows x64 preview artifacts env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash run: | - npx electron-builder --win nsis --x64 --publish always '--config.publish.releaseType=prerelease' '--config.publish.channel=preview' '--config.artifactName=${productName}-${version}-x64-Setup.${ext}' + npx electron-builder --win nsis --x64 --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-x64-Setup.${ext}' + + - name: Upload Windows x64 assets to fixed preview release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + assets=() + while IFS= read -r file; do + assets+=("$file") + done < <(find release -maxdepth 1 -type f | sort) + if [ "${#assets[@]}" -eq 0 ]; then + echo "No release files found in ./release" + exit 1 + fi + gh release upload "$FIXED_PREVIEW_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber preview-win-arm64: needs: prepare @@ -174,6 +250,7 @@ jobs: - name: Check out git repository uses: actions/checkout@v5 with: + ref: ${{ env.TARGET_BRANCH }} fetch-depth: 0 - name: Install Node.js @@ -190,15 +267,32 @@ jobs: run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version - name: Build Frontend & Type Check + shell: bash run: | npx tsc npx vite build - - name: Package and Publish Windows arm64 preview + - name: Package Windows arm64 preview artifacts env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash run: | - npx electron-builder --win nsis --arm64 --publish always '--config.publish.releaseType=prerelease' '--config.publish.channel=preview-arm64' '--config.artifactName=${productName}-${version}-arm64-Setup.${ext}' + npx electron-builder --win nsis --arm64 --publish never '--config.publish.channel=preview-arm64' '--config.artifactName=${productName}-preview-arm64-Setup.${ext}' + + - name: Upload Windows arm64 assets to fixed preview release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + assets=() + while IFS= read -r file; do + assets+=("$file") + done < <(find release -maxdepth 1 -type f | sort) + if [ "${#assets[@]}" -eq 0 ]; then + echo "No release files found in ./release" + exit 1 + fi + gh release upload "$FIXED_PREVIEW_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber update-preview-release-notes: needs: @@ -217,7 +311,8 @@ jobs: run: | set -euo pipefail - TAG="v${{ needs.prepare.outputs.preview_version }}" + TAG="$FIXED_PREVIEW_TAG" + CURRENT_PREVIEW_VERSION="${{ needs.prepare.outputs.preview_version }}" REPO="$GITHUB_REPOSITORY" RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG" @@ -259,20 +354,26 @@ jobs: ## Preview Nightly 说明 - 该版本为 **预览版**,用于提前体验即将发布的功能与修复。 - 可能包含尚未完全稳定的改动,不建议长期使用 + - 当前版本号:\`$CURRENT_PREVIEW_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} + - 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 + > 如某个平台链接暂未生成,请前往[发布页]($RELEASE_PAGE)查看最新资源 EOF - gh release edit "$TAG" --repo "$REPO" --notes-file preview_release_notes.md + RELEASE_REST_ID="$(gh api "repos/$REPO/releases/tags/$TAG" --jq '.id')" + jq -n --rawfile body preview_release_notes.md \ + '{name:"Preview Nightly Build", body:$body, draft:false, prerelease:true}' \ + > release_update_payload.json + gh api --method PATCH "repos/$REPO/releases/$RELEASE_REST_ID" --input release_update_payload.json >/dev/null + gh release view "$TAG" --repo "$REPO" --json isDraft,isPrerelease,url diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index daf1d58..ed89fb5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,6 +39,7 @@ jobs: npm version $VERSION --no-git-tag-version --allow-same-version - name: Build Frontend & Type Check + shell: bash run: | npx tsc npx vite build @@ -95,6 +96,7 @@ jobs: npm version $VERSION --no-git-tag-version --allow-same-version - name: Build Frontend & Type Check + shell: bash run: | npx tsc npx vite build @@ -145,6 +147,7 @@ jobs: npm version $VERSION --no-git-tag-version --allow-same-version - name: Build Frontend & Type Check + shell: bash run: | npx tsc npx vite build @@ -195,6 +198,7 @@ jobs: npm version $VERSION --no-git-tag-version --allow-same-version - name: Build Frontend & Type Check + shell: bash run: | npx tsc npx vite build @@ -276,18 +280,18 @@ jobs: [点击加入 Telegram 频道](https://t.me/weflow_cc) ## 下载 - - Windows x64(Win10+): ${WINDOWS_URL:-$RELEASE_PAGE} - - Windows arm64: ${WINDOWS_ARM64_URL:-$RELEASE_PAGE} - - macOS(M系列芯片): ${MAC_URL:-$RELEASE_PAGE} - - Linux (.tar.gz): ${LINUX_TAR_URL:-$RELEASE_PAGE} - - Linux (.AppImage): ${LINUX_APPIMAGE_URL:-$RELEASE_PAGE} + - Windows x64(Win10+): [点击下载](${WINDOWS_URL:-$RELEASE_PAGE}) + - Windows arm64: [点击下载](${WINDOWS_ARM64_URL:-$RELEASE_PAGE}) + - macOS(M系列芯片): [点击下载](${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 + > 如果某个平台链接暂时未生成,可进入[完整发布页]($RELEASE_PAGE)查看全部资源 EOF gh release edit "$TAG" --repo "$REPO" --notes-file release_notes.md diff --git a/.gitignore b/.gitignore index 95745fb..ae6f9bf 100644 --- a/.gitignore +++ b/.gitignore @@ -71,4 +71,6 @@ resources/wx_send pnpm-lock.yaml /pnpm-workspace.yaml wechat-research-site -.codex \ No newline at end of file +.codex +weflow-web-offical +Insight \ No newline at end of file diff --git a/README.md b/README.md index 8c6c48a..01e7beb 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析 | 功能模块 | 说明 | |---------|------| | **聊天** | 解密聊天中的图片、视频、实况(仅支持谷歌协议拍摄的实况);支持**修改**、删除**本地**消息;实时刷新最新消息,无需生成解密中间数据库 | +| **消息防撤回** | 防止其他人发送的消息被撤回 | | **实时弹窗通知** | 新消息到达时提供桌面弹窗提醒,便于及时查看重要会话,提供黑白名单功能 | | **私聊分析** | 统计好友间消息数量;分析消息类型与发送比例;查看消息时段分布等 | | **群聊分析** | 查看群成员详细信息;分析群内发言排行、活跃时段和媒体内容 | diff --git a/docs/HTTP-API.md b/docs/HTTP-API.md index 052dd8a..fb2c636 100644 --- a/docs/HTTP-API.md +++ b/docs/HTTP-API.md @@ -433,7 +433,123 @@ curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom&include --- -## 7. 访问导出媒体 +## 7. 朋友圈接口 + +### 7.1 获取朋友圈时间线 + +```http +GET /api/v1/sns/timeline +``` + +参数: + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `limit` | number | 否 | 返回数量,默认 20,范围 `1~200` | +| `offset` | number | 否 | 偏移量,默认 0 | +| `usernames` | string | 否 | 发布者过滤,逗号分隔,如 `wxid_a,wxid_b` | +| `keyword` | string | 否 | 关键词过滤(正文) | +| `start` | string | 否 | 开始时间,支持 `YYYYMMDD` 或秒/毫秒时间戳 | +| `end` | string | 否 | 结束时间,支持 `YYYYMMDD` 或秒/毫秒时间戳 | +| `media` | number | 否 | 是否返回可直接访问的媒体地址,默认 `1` | +| `replace` | number | 否 | `media=1` 时,是否用解析地址覆盖 `media.url/thumb`,默认 `1` | +| `inline` | number | 否 | `media=1` 时,是否内联返回 `data:` URL,默认 `0` | + +示例: + +```bash +curl "http://127.0.0.1:5031/api/v1/sns/timeline?limit=5" +curl "http://127.0.0.1:5031/api/v1/sns/timeline?usernames=wxid_a,wxid_b&keyword=旅行" +curl "http://127.0.0.1:5031/api/v1/sns/timeline?limit=3&media=1&replace=1" +curl "http://127.0.0.1:5031/api/v1/sns/timeline?limit=3&media=1&inline=1" +``` + +媒体字段说明(`media=1`): + +- `media[].url/thumb`:你应该优先直接使用的字段。 +- `replace=1`(默认)时,`media[].url/thumb` 会直接被替换成可访问地址,等价于 `resolvedUrl/resolvedThumbUrl`。 +- `replace=0` 时,`media[].url/thumb` 仍保留微信原始地址;这时再结合下面的 `raw/proxy/resolved` 字段自己决定用哪一个。 +- `media[].rawUrl/rawThumb`:原始朋友圈地址 +- `media[].proxyUrl/proxyThumbUrl`:可直接访问的代理地址 +- `media[].resolvedUrl/resolvedThumbUrl`:最终可用地址(`inline=1` 时可能是 `data:` URL) +- `media[].token/key/encIdx`:微信源数据里的访问/解密参数。通常不需要你自己处理;如果你手动调用 `/api/v1/sns/media/proxy`,把当前条目的 `url` 和 `key` 原样传回即可。 +- `media[].livePhoto`:实况图的视频部分。外层 `media[].url/thumb` 仍是封面图,`livePhoto` 内部会再提供一组自己的 `url/thumb/raw*/proxy*/resolved*` 字段。 +- `media=0` 时,不会补充 `raw*/proxy*/resolved*`,接口只返回原始 `url/thumb` 以及源字段(如 `key/token/encIdx`)。 + +### 7.2 获取朋友圈发布者 + +```http +GET /api/v1/sns/usernames +``` + +### 7.3 获取朋友圈导出统计 + +```http +GET /api/v1/sns/export/stats +``` + +参数: + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `fast` | number | 否 | `1` 使用快速统计(优先缓存) | + +### 7.4 朋友圈媒体代理 + +```http +GET /api/v1/sns/media/proxy +``` + +参数: + +| 参数 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `url` | string | 是 | 媒体原始 URL | +| `key` | string/number | 否 | 解密 key(部分资源需要) | + +### 7.5 导出朋友圈 + +```http +POST /api/v1/sns/export +Content-Type: application/json +``` + +Body 示例: + +```json +{ + "outputDir": "C:\\Users\\Alice\\Desktop\\sns-export", + "format": "json", + "usernames": "wxid_a,wxid_b", + "keyword": "旅行", + "exportMedia": true, + "exportImages": true, + "exportLivePhotos": true, + "exportVideos": true, + "start": "20250101", + "end": "20251231" +} +``` + +`format` 支持:`json`、`html`、`arkmejson`(兼容写法:`arkme-json`)。 + +### 7.6 朋友圈防删开关 + +```http +GET /api/v1/sns/block-delete/status +POST /api/v1/sns/block-delete/install +POST /api/v1/sns/block-delete/uninstall +``` + +### 7.7 删除单条朋友圈 + +```http +DELETE /api/v1/sns/post/{postId} +``` + +--- + +## 8. 访问导出媒体 > 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json) @@ -476,7 +592,7 @@ curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/emojis/emoji_300.gif" --- -## 8. 使用示例 +## 9. 使用示例 ### PowerShell @@ -525,7 +641,7 @@ members = requests.get( --- -## 9. 注意事项 +## 10. 注意事项 1. API 仅监听本机 `127.0.0.1`,不对外网开放。 2. 使用前需要先在 WeFlow 中完成数据库连接。 diff --git a/electron/exportWorker.ts b/electron/exportWorker.ts index 1f98439..dfa4ba3 100644 --- a/electron/exportWorker.ts +++ b/electron/exportWorker.ts @@ -5,6 +5,9 @@ interface ExportWorkerConfig { sessionIds: string[] outputDir: string options: ExportOptions + dbPath?: string + decryptKey?: string + myWxid?: string resourcesPath?: string userDataPath?: string logEnabled?: boolean @@ -29,6 +32,11 @@ async function run() { wcdbService.setPaths(config.resourcesPath || '', config.userDataPath || '') wcdbService.setLogEnabled(config.logEnabled === true) + exportService.setRuntimeConfig({ + dbPath: config.dbPath, + decryptKey: config.decryptKey, + myWxid: config.myWxid + }) const result = await exportService.exportSessions( Array.isArray(config.sessionIds) ? config.sessionIds : [], diff --git a/electron/main.ts b/electron/main.ts index 355693a..6b692b4 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -30,7 +30,7 @@ import { cloudControlService } from './services/cloudControlService' import { destroyNotificationWindow, registerNotificationHandlers, showNotification } from './windows/notificationWindow' import { httpService } from './services/httpService' import { messagePushService } from './services/messagePushService' - +import { bizService } from './services/bizService' // 配置自动更新 autoUpdater.autoDownload = false @@ -38,18 +38,28 @@ autoUpdater.autoInstallOnAppQuit = true autoUpdater.disableDifferentialDownload = true // 禁用差分更新,强制全量下载 // 更新通道策略: // - 稳定版(如 4.3.0)默认走 latest -// - 预览版(如 4.3.0-preview.26.1)默认走 preview -// - 开发版(如 4.3.0-dev.26.3.4)默认走 dev +// - 预览版(如 0.26.2)默认走 preview(0.年.当年发布序号) +// - 开发版(如 26.4.5)默认走 dev(年.月.日) // - 用户可在设置页切换稳定/预览/开发,切换后即时生效 // 同时区分 Windows x64 / arm64,避免更新清单互相覆盖。 const appVersion = app.getVersion() +const inferUpdateTrackFromVersion = (version: string): 'stable' | 'preview' | 'dev' => { + const normalized = String(version || '').trim().replace(/^v/i, '') + if (/^0\.\d{2}\.\d+$/i.test(normalized)) return 'preview' + if (/^\d{2}\.\d{1,2}\.\d{1,2}$/i.test(normalized)) return 'dev' + // 兼容旧版命名(如 4.3.0-preview.26.1 / 4.3.0-dev.26.3.4) + if (/-preview\.\d+\.\d+$/i.test(normalized)) return 'preview' + if (/-dev\.\d+\.\d+\.\d+$/i.test(normalized)) return 'dev' + // 兼容 alpha/beta/rc 预发布 + if (/(alpha|beta|rc)/i.test(normalized)) return 'dev' + return 'stable' +} + const defaultUpdateTrack: 'stable' | 'preview' | 'dev' = (() => { - 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' + const inferred = inferUpdateTrackFromVersion(appVersion) + if (inferred === 'preview' || inferred === 'dev') return inferred return 'stable' })() -const isPrereleaseBuild = defaultUpdateTrack !== 'stable' let configService: ConfigService | null = null const normalizeUpdateTrack = (raw: unknown): 'stable' | 'preview' | 'dev' | null => { @@ -62,16 +72,116 @@ const getEffectiveUpdateTrack = (): 'stable' | 'preview' | 'dev' => { 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, b: ReturnType): number => { + const maxLen = Math.max(a.core.length, b.core.length) + for (let i = 0; i < maxLen; i += 1) { + const left = a.core[i] || 0 + const right = b.core[i] || 0 + if (left > right) return 1 + if (left < right) return -1 + } + + const aPre = a.prerelease + const bPre = b.prerelease + if (aPre.length === 0 && bPre.length === 0) return 0 + if (aPre.length === 0) return 1 + if (bPre.length === 0) return -1 + + const preMaxLen = Math.max(aPre.length, bPre.length) + for (let i = 0; i < preMaxLen; i += 1) { + const left = aPre[i] + const right = bPre[i] + if (left === undefined) return -1 + if (right === undefined) return 1 + if (left === right) continue + + const leftNum = typeof left === 'number' + const rightNum = typeof right === 'number' + if (leftNum && rightNum) return left > right ? 1 : -1 + if (leftNum) return -1 + if (rightNum) return 1 + return String(left) > String(right) ? 1 : -1 + } + + return 0 + } + + try { + return autoUpdater.currentVersion.compare(latest) < 0 + } catch { + return compareParsedVersion(parseVersion(latest), parseVersion(current)) > 0 + } +} + +const shouldOfferUpdateForTrack = (latestVersion: string, currentVersion: string): boolean => { + if (isRemoteVersionNewer(latestVersion, currentVersion)) return true + const effectiveTrack = getEffectiveUpdateTrack() + const currentTrack = inferUpdateTrackFromVersion(currentVersion) + // 切换通道后,目标通道最新版本与当前版本不同即提示更新(即使是降级) + if (effectiveTrack !== currentTrack && latestVersion !== currentVersion) return true + return false +} + +let lastAppliedUpdaterChannel: string | null = null +let lastAppliedUpdaterFeedUrl: string | null = null +const resetUpdaterProviderCache = () => { + const updater = autoUpdater as any + // electron-updater 会缓存 provider;切换 channel 后需清理缓存,避免仍请求旧通道 + for (const key of ['clientPromise', '_clientPromise', 'updateInfoAndProvider']) { + if (Object.prototype.hasOwnProperty.call(updater, key)) { + updater[key] = null + } + } +} + +const getUpdaterFeedUrlByTrack = (track: 'stable' | 'preview' | 'dev'): string => { + const repoBase = 'https://github.com/hicccc77/WeFlow/releases' + if (track === 'stable') return `${repoBase}/latest/download` + if (track === 'preview') return `${repoBase}/download/nightly-preview` + return `${repoBase}/download/nightly-dev` +} + const applyAutoUpdateChannel = (reason: 'startup' | 'settings' = 'startup') => { const track = getEffectiveUpdateTrack() + const currentTrack = inferUpdateTrackFromVersion(appVersion) const baseUpdateChannel = track === 'stable' ? 'latest' : track - autoUpdater.allowPrerelease = track !== 'stable' - autoUpdater.allowDowngrade = isPrereleaseBuild && track === 'stable' - autoUpdater.channel = + const nextFeedUrl = getUpdaterFeedUrlByTrack(track) + const nextUpdaterChannel = process.platform === 'win32' && process.arch === 'arm64' ? `${baseUpdateChannel}-arm64` : baseUpdateChannel - console.log(`[Update](${reason}) 当前版本 ${appVersion},渠道偏好: ${track},更新通道: ${autoUpdater.channel}`) + if ( + (lastAppliedUpdaterChannel && lastAppliedUpdaterChannel !== nextUpdaterChannel) || + (lastAppliedUpdaterFeedUrl && lastAppliedUpdaterFeedUrl !== nextFeedUrl) + ) { + resetUpdaterProviderCache() + } + autoUpdater.allowPrerelease = track !== 'stable' + // 只要用户当前选择的目标通道与当前安装版本所属通道不同,就允许跨通道更新(含降级) + autoUpdater.allowDowngrade = track !== currentTrack + // 统一走 generic feed,确保 preview/dev 命中各自固定发布页,不受 GitHub provider 的 prerelease 选择影响。 + autoUpdater.setFeedURL({ + provider: 'generic', + url: nextFeedUrl, + channel: nextUpdaterChannel + }) + autoUpdater.channel = nextUpdaterChannel + lastAppliedUpdaterChannel = nextUpdaterChannel + lastAppliedUpdaterFeedUrl = nextFeedUrl + console.log(`[Update](${reason}) 当前版本 ${appVersion},当前轨道: ${currentTrack},渠道偏好: ${track},更新通道: ${autoUpdater.channel},feed=${nextFeedUrl},allowDowngrade=${autoUpdater.allowDowngrade}`) } applyAutoUpdateChannel('startup') @@ -80,6 +190,118 @@ const AUTO_UPDATE_ENABLED = process.env.AUTO_UPDATE_ENABLED === '1' || (process.env.AUTO_UPDATE_ENABLED == null && !process.env.VITE_DEV_SERVER_URL) +const getLaunchAtStartupUnsupportedReason = (): string | null => { + if (process.platform !== 'win32' && process.platform !== 'darwin') { + return '当前平台暂不支持开机自启动' + } + if (!app.isPackaged) { + return '仅安装后的 Windows / macOS 版本支持开机自启动' + } + return null +} + +const isLaunchAtStartupSupported = (): boolean => getLaunchAtStartupUnsupportedReason() == null + +const getStoredLaunchAtStartupPreference = (): boolean | undefined => { + const value = configService?.get('launchAtStartup') + return typeof value === 'boolean' ? value : undefined +} + +const getSystemLaunchAtStartup = (): boolean => { + if (!isLaunchAtStartupSupported()) return false + try { + return app.getLoginItemSettings().openAtLogin === true + } catch (error) { + console.error('[WeFlow] 读取开机自启动状态失败:', error) + return false + } +} + +const buildLaunchAtStartupSettings = (enabled: boolean): Parameters[0] => + process.platform === 'win32' + ? { openAtLogin: enabled, path: process.execPath } + : { openAtLogin: enabled } + +const setSystemLaunchAtStartup = (enabled: boolean): { success: boolean; enabled: boolean; error?: string } => { + try { + app.setLoginItemSettings(buildLaunchAtStartupSettings(enabled)) + const effectiveEnabled = app.getLoginItemSettings().openAtLogin === true + if (effectiveEnabled !== enabled) { + return { + success: false, + enabled: effectiveEnabled, + error: '系统未接受该开机自启动设置' + } + } + return { success: true, enabled: effectiveEnabled } + } catch (error) { + return { + success: false, + enabled: getSystemLaunchAtStartup(), + error: `设置开机自启动失败: ${String((error as Error)?.message || error)}` + } + } +} + +const getLaunchAtStartupStatus = (): { enabled: boolean; supported: boolean; reason?: string } => { + const unsupportedReason = getLaunchAtStartupUnsupportedReason() + if (unsupportedReason) { + return { + enabled: getStoredLaunchAtStartupPreference() === true, + supported: false, + reason: unsupportedReason + } + } + return { + enabled: getSystemLaunchAtStartup(), + supported: true + } +} + +const applyLaunchAtStartupPreference = ( + enabled: boolean +): { success: boolean; enabled: boolean; supported: boolean; reason?: string; error?: string } => { + const unsupportedReason = getLaunchAtStartupUnsupportedReason() + if (unsupportedReason) { + return { + success: false, + enabled: getStoredLaunchAtStartupPreference() === true, + supported: false, + reason: unsupportedReason + } + } + + const result = setSystemLaunchAtStartup(enabled) + configService?.set('launchAtStartup', result.enabled) + return { + ...result, + supported: true + } +} + +const syncLaunchAtStartupPreference = () => { + if (!configService) return + + const unsupportedReason = getLaunchAtStartupUnsupportedReason() + if (unsupportedReason) return + + const storedPreference = getStoredLaunchAtStartupPreference() + const systemEnabled = getSystemLaunchAtStartup() + + if (typeof storedPreference !== 'boolean') { + configService.set('launchAtStartup', systemEnabled) + return + } + + if (storedPreference === systemEnabled) return + + const result = setSystemLaunchAtStartup(storedPreference) + configService.set('launchAtStartup', result.enabled) + if (!result.success && result.error) { + console.error('[WeFlow] 同步开机自启动设置失败:', result.error) + } +} + // 使用白名单过滤 PATH,避免被第三方目录中的旧版 VC++ 运行库劫持。 // 仅保留系统目录(Windows/System32/SysWOW64)和应用自身目录(可执行目录、resources)。 function sanitizePathEnv() { @@ -1152,13 +1374,19 @@ const removeMatchedEntriesInDir = async ( // 注册 IPC 处理器 function registerIpcHandlers() { registerNotificationHandlers() + bizService.registerHandlers() // 配置相关 ipcMain.handle('config:get', async (_, key: string) => { return configService?.get(key as any) }) ipcMain.handle('config:set', async (_, key: string, value: any) => { - const result = configService?.set(key as any, value) + let result: unknown + if (key === 'launchAtStartup') { + result = applyLaunchAtStartupPreference(value === true) + } else { + result = configService?.set(key as any, value) + } if (key === 'updateChannel') { applyAutoUpdateChannel('settings') } @@ -1167,6 +1395,12 @@ function registerIpcHandlers() { }) ipcMain.handle('config:clear', async () => { + if (isLaunchAtStartupSupported() && getSystemLaunchAtStartup()) { + const result = setSystemLaunchAtStartup(false) + if (!result.success && result.error) { + console.error('[WeFlow] 清空配置时关闭开机自启动失败:', result.error) + } + } configService?.clear() messagePushService.handleConfigCleared() return true @@ -1209,6 +1443,14 @@ function registerIpcHandlers() { return app.getVersion() }) + ipcMain.handle('app:getLaunchAtStartupStatus', async () => { + return getLaunchAtStartupStatus() + }) + + ipcMain.handle('app:setLaunchAtStartup', async (_, enabled: boolean) => { + return applyLaunchAtStartupPreference(enabled === true) + }) + ipcMain.handle('app:checkWayland', async () => { if (process.platform !== 'linux') return false; @@ -1278,12 +1520,14 @@ function registerIpcHandlers() { if (!AUTO_UPDATE_ENABLED) { return { hasUpdate: false } } + // 每次主动检查前重新应用一次通道配置,确保使用最新选择的更新通道。 + applyAutoUpdateChannel('settings') try { const result = await autoUpdater.checkForUpdates() if (result && result.updateInfo) { const currentVersion = app.getVersion() const latestVersion = result.updateInfo.version - if (latestVersion !== currentVersion) { + if (shouldOfferUpdateForTrack(latestVersion, currentVersion)) { return { hasUpdate: true, version: latestVersion, @@ -1623,6 +1867,18 @@ function registerIpcHandlers() { return chatService.deleteMessage(sessionId, localId, createTime, dbPathHint) }) + ipcMain.handle('chat:checkAntiRevokeTriggers', async (_, sessionIds: string[]) => { + return chatService.checkAntiRevokeTriggers(sessionIds) + }) + + ipcMain.handle('chat:installAntiRevokeTriggers', async (_, sessionIds: string[]) => { + return chatService.installAntiRevokeTriggers(sessionIds) + }) + + ipcMain.handle('chat:uninstallAntiRevokeTriggers', async (_, sessionIds: string[]) => { + return chatService.uninstallAntiRevokeTriggers(sessionIds) + }) + ipcMain.handle('chat:getContact', async (_, username: string) => { return await chatService.getContact(username) }) @@ -2055,10 +2311,47 @@ function registerIpcHandlers() { }) ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions) => { - const onProgress = (progress: ExportProgress) => { - if (!event.sender.isDestroyed()) { - event.sender.send('export:progress', progress) + const PROGRESS_FORWARD_INTERVAL_MS = 180 + let pendingProgress: ExportProgress | null = null + let progressTimer: NodeJS.Timeout | null = null + let lastProgressSentAt = 0 + + const flushProgress = () => { + if (!pendingProgress) return + if (progressTimer) { + clearTimeout(progressTimer) + progressTimer = null } + if (!event.sender.isDestroyed()) { + event.sender.send('export:progress', pendingProgress) + } + pendingProgress = null + lastProgressSentAt = Date.now() + } + + const queueProgress = (progress: ExportProgress) => { + pendingProgress = progress + const force = progress.phase === 'complete' + if (force) { + flushProgress() + return + } + + const now = Date.now() + const elapsed = now - lastProgressSentAt + if (elapsed >= PROGRESS_FORWARD_INTERVAL_MS) { + flushProgress() + return + } + + if (progressTimer) return + progressTimer = setTimeout(() => { + flushProgress() + }, PROGRESS_FORWARD_INTERVAL_MS - elapsed) + } + + const onProgress = (progress: ExportProgress) => { + queueProgress(progress) } const runMainFallback = async (reason: string) => { @@ -2069,6 +2362,9 @@ function registerIpcHandlers() { const cfg = configService || new ConfigService() configService = cfg const logEnabled = cfg.get('logEnabled') + const dbPath = String(cfg.get('dbPath') || '').trim() + const decryptKey = String(cfg.get('decryptKey') || '').trim() + const myWxid = String(cfg.get('myWxid') || '').trim() const resourcesPath = app.isPackaged ? join(process.resourcesPath, 'resources') : join(app.getAppPath(), 'resources') @@ -2082,6 +2378,9 @@ function registerIpcHandlers() { sessionIds, outputDir, options, + dbPath, + decryptKey, + myWxid, resourcesPath, userDataPath, logEnabled @@ -2137,6 +2436,12 @@ function registerIpcHandlers() { return await runWorker() } catch (error) { return runMainFallback(error instanceof Error ? error.message : String(error)) + } finally { + flushProgress() + if (progressTimer) { + clearTimeout(progressTimer) + progressTimer = null + } } }) @@ -2741,7 +3046,7 @@ function checkForUpdatesOnStartup() { const latestVersion = result.updateInfo.version // 检查是否有新版本 - if (latestVersion !== currentVersion && mainWindow) { + if (shouldOfferUpdateForTrack(latestVersion, currentVersion) && mainWindow) { // 检查该版本是否被用户忽略 const ignoredVersion = configService?.get('ignoredUpdateVersion') if (ignoredVersion === latestVersion) { @@ -2787,6 +3092,7 @@ app.whenReady().then(async () => { updateSplashProgress(5, '正在加载配置...') configService = new ConfigService() applyAutoUpdateChannel('startup') + syncLaunchAtStartupPreference() // 将用户主题配置推送给 Splash 窗口 if (splashWindow && !splashWindow.isDestroyed()) { diff --git a/electron/preload-env.ts b/electron/preload-env.ts index 3476a0b..514b5e6 100644 --- a/electron/preload-env.ts +++ b/electron/preload-env.ts @@ -2,7 +2,7 @@ import { join, dirname } from 'path' /** * 强制将本地资源目录添加到 PATH 最前端,确保优先加载本地 DLL - * 解决系统中存在冲突版本的 DLL 导致的应用崩溃问题 + * 解决系统中存在冲突版本的数据服务导致的应用崩溃问题 */ function enforceLocalDllPriority() { const isDev = !!process.env.VITE_DEV_SERVER_URL @@ -35,5 +35,5 @@ function enforceLocalDllPriority() { try { enforceLocalDllPriority() } catch (e) { - console.error('[WeFlow] Failed to enforce local DLL priority:', e) + console.error('[WeFlow] Failed to enforce local service priority:', e) } diff --git a/electron/preload.ts b/electron/preload.ts index bfa151d..88385ce 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -53,6 +53,8 @@ contextBridge.exposeInMainWorld('electronAPI', { app: { getDownloadsPath: () => ipcRenderer.invoke('app:getDownloadsPath'), getVersion: () => ipcRenderer.invoke('app:getVersion'), + getLaunchAtStartupStatus: () => ipcRenderer.invoke('app:getLaunchAtStartupStatus'), + setLaunchAtStartup: (enabled: boolean) => ipcRenderer.invoke('app:setLaunchAtStartup', enabled), checkForUpdates: () => ipcRenderer.invoke('app:checkForUpdates'), downloadAndInstall: () => ipcRenderer.invoke('app:downloadAndInstall'), ignoreUpdate: (version: string) => ipcRenderer.invoke('app:ignoreUpdate', version), @@ -188,6 +190,12 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('chat:updateMessage', sessionId, localId, createTime, newContent), deleteMessage: (sessionId: string, localId: number, createTime: number, dbPathHint?: string) => ipcRenderer.invoke('chat:deleteMessage', sessionId, localId, createTime, dbPathHint), + checkAntiRevokeTriggers: (sessionIds: string[]) => + ipcRenderer.invoke('chat:checkAntiRevokeTriggers', sessionIds), + installAntiRevokeTriggers: (sessionIds: string[]) => + ipcRenderer.invoke('chat:installAntiRevokeTriggers', sessionIds), + uninstallAntiRevokeTriggers: (sessionIds: string[]) => + ipcRenderer.invoke('chat:uninstallAntiRevokeTriggers', sessionIds), resolveTransferDisplayNames: (chatroomId: string, payerUsername: string, receiverUsername: string) => ipcRenderer.invoke('chat:resolveTransferDisplayNames', chatroomId, payerUsername, receiverUsername), getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'), @@ -413,6 +421,14 @@ contextBridge.exposeInMainWorld('electronAPI', { downloadEmoji: (params: { url: string; encryptUrl?: string; aesKey?: string }) => ipcRenderer.invoke('sns:downloadEmoji', params) }, + 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: { diff --git a/electron/services/bizService.ts b/electron/services/bizService.ts new file mode 100644 index 0000000..f7c0eed --- /dev/null +++ b/electron/services/bizService.ts @@ -0,0 +1,243 @@ +import { join } from 'path' +import { readdirSync, existsSync } from 'fs' +import { wcdbService } from './wcdbService' +import { ConfigService } from './config' +import { chatService, Message } from './chatService' +import { ipcMain } from 'electron' +import { createHash } from 'crypto' + +export interface BizAccount { + username: string + name: string + avatar: string + type: number + last_time: number + formatted_last_time: string +} + +export interface BizMessage { + local_id: number + create_time: number + title: string + des: string + url: string + cover: string + content_list: any[] +} + +export interface BizPayRecord { + local_id: number + create_time: number + title: string + description: string + merchant_name: string + merchant_icon: string + timestamp: number + formatted_time: string +} + +export class BizService { + private configService: ConfigService + + constructor() { + this.configService = new ConfigService() + } + + private extractXmlValue(xml: string, tagName: string): string { + const regex = new RegExp(`<${tagName}>([\\s\\S]*?)`, 'i') + const match = regex.exec(xml) + if (match) { + return match[1].replace(//g, '').trim() + } + return '' + } + + private parseBizContentList(xmlStr: string): any[] { + if (!xmlStr) return [] + const contentList: any[] = [] + try { + const itemRegex = /([\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 { + try { + // 1. 获取公众号联系人列表 + const contactsResult = await chatService.getContacts({ lite: true }) + if (!contactsResult.success || !contactsResult.contacts) return [] + + const officialContacts = contactsResult.contacts.filter(c => c.type === 'official') + const usernames = officialContacts.map(c => c.username) + + // 获取头像和昵称等补充信息 + const enrichment = await chatService.enrichSessionsContactInfo(usernames) + const contactInfoMap = enrichment.success && enrichment.contacts ? enrichment.contacts : {} + + const root = this.configService.get('dbPath') + const myWxid = this.configService.get('myWxid') + const accountWxid = account || myWxid + if (!root || !accountWxid) return [] + + const bizLatestTime: Record = {} + + try { + const sessionsRes = await wcdbService.getSessions() + if (sessionsRes.success && sessionsRes.sessions) { + for (const session of sessionsRes.sessions) { + const uname = session.username || session.strUsrName || session.userName || session.id + // 适配日志中发现的字段,注意转为整型数字 + const timeStr = session.last_timestamp || session.sort_timestamp || session.nTime || session.timestamp || '0' + const time = parseInt(timeStr.toString(), 10) + + if (usernames.includes(uname) && time > 0) { + bizLatestTime[uname] = time + } + } + } + } catch (e) { + console.error('获取 Sessions 失败:', e) + } + + // 3. 格式化时间显示 + const formatBizTime = (ts: number) => { + if (!ts) return '' + const date = new Date(ts * 1000) + const now = new Date() + const isToday = date.toDateString() === now.toDateString() + if (isToday) return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false }) + + const yesterday = new Date(now) + yesterday.setDate(now.getDate() - 1) + if (date.toDateString() === yesterday.toDateString()) return '昨天' + + const isThisYear = date.getFullYear() === now.getFullYear() + if (isThisYear) return `${date.getMonth() + 1}/${date.getDate()}` + + return `${date.getFullYear().toString().slice(-2)}/${date.getMonth() + 1}/${date.getDate()}` + } + + // 4. 组装数据 + const result: BizAccount[] = officialContacts.map(contact => { + const uname = contact.username + const info = contactInfoMap[uname] + const lastTime = bizLatestTime[uname] || 0 + return { + username: uname, + name: info?.displayName || contact.displayName || uname, + avatar: info?.avatarUrl || '', + type: 0, + last_time: lastTime, + formatted_last_time: formatBizTime(lastTime) + } + }) + + // 5. 补充公众号类型 (订阅号/服务号) + const contactDbPath = join(root, accountWxid, 'db_storage', 'contact', 'contact.db') + if (existsSync(contactDbPath)) { + const bizInfoRes = await wcdbService.execQuery('contact', contactDbPath, 'SELECT username, type FROM biz_info') + if (bizInfoRes.success && bizInfoRes.rows) { + const typeMap: Record = {} + for (const r of bizInfoRes.rows) typeMap[r.username] = r.type + for (const acc of result) if (typeMap[acc.username] !== undefined) acc.type = typeMap[acc.username] + } + } + + // 6. 排序输出 + return result + .filter(acc => !acc.name.includes('广告')) + .sort((a, b) => { + if (a.username === 'gh_3dfda90e39d6') return -1 // 微信支付置顶 + if (b.username === 'gh_3dfda90e39d6') return 1 + return b.last_time - a.last_time // 按最新时间降序排列 + }) + } catch (e) { + console.error('获取账号列表发生错误:', e) + return [] + } + } + + async listMessages(username: string, account?: string, limit: number = 20, offset: number = 0): Promise { + 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 { + 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() diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 6069c38..270b4dc 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -1,4 +1,4 @@ -import { join, dirname, basename, extname } from 'path' +import { join, dirname, basename, extname } from 'path' import { existsSync, mkdirSync, readdirSync, statSync, readFileSync, writeFileSync, copyFileSync, unlinkSync, watch, promises as fsPromises } from 'fs' import * as path from 'path' import * as fs from 'fs' @@ -75,6 +75,7 @@ export interface Message { fileName?: string // 文件名 fileSize?: number // 文件大小 fileExt?: string // 文件扩展名 + fileMd5?: string // 文件 MD5 xmlType?: string // XML 中的 type 字段 appMsgKind?: string // 归一化 appmsg 类型 appMsgDesc?: string @@ -468,7 +469,7 @@ class ChatService { if (this.monitorSetup) return this.monitorSetup = true - // 使用 C++ DLL 内部的文件监控 (ReadDirectoryChangesW) + // 使用 C++数据服务内部的文件监控 (ReadDirectoryChangesW) // 这种方式更高效,且不占用 JS 线程,并能直接监听 session/message 目录变更 wcdbService.setMonitor((type, json) => { this.handleSessionStatsMonitorChange(type, json) @@ -558,6 +559,51 @@ class ChatService { } } + async checkAntiRevokeTriggers(sessionIds: string[]): Promise<{ + success: boolean + rows?: Array<{ sessionId: string; success: boolean; installed?: boolean; error?: string }> + error?: string + }> { + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) return { success: false, error: connectResult.error } + const normalizedIds = Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean))) + return await wcdbService.checkMessageAntiRevokeTriggers(normalizedIds) + } catch (e) { + return { success: false, error: String(e) } + } + } + + async installAntiRevokeTriggers(sessionIds: string[]): Promise<{ + success: boolean + rows?: Array<{ sessionId: string; success: boolean; alreadyInstalled?: boolean; error?: string }> + error?: string + }> { + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) return { success: false, error: connectResult.error } + const normalizedIds = Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean))) + return await wcdbService.installMessageAntiRevokeTriggers(normalizedIds) + } catch (e) { + return { success: false, error: String(e) } + } + } + + async uninstallAntiRevokeTriggers(sessionIds: string[]): Promise<{ + success: boolean + rows?: Array<{ sessionId: string; success: boolean; error?: string }> + error?: string + }> { + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) return { success: false, error: connectResult.error } + const normalizedIds = Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean))) + return await wcdbService.uninstallMessageAntiRevokeTriggers(normalizedIds) + } catch (e) { + return { success: false, error: String(e) } + } + } + /** * 获取会话列表(优化:先返回基础数据,不等待联系人信息加载) */ @@ -1773,18 +1819,9 @@ class ChatService { } private getMessageSourceInfo(row: Record): { dbName?: string; tableName?: string; dbPath?: string } { - const dbPath = String( - this.getRowField(row, ['_db_path', 'db_path', 'dbPath', 'database_path', 'databasePath', 'source_db_path']) - || '' - ).trim() - const explicitDbName = String( - this.getRowField(row, ['db_name', 'dbName', 'database_name', 'databaseName', 'db', 'database', 'source_db']) - || '' - ).trim() - const tableName = String( - this.getRowField(row, ['table_name', 'tableName', 'table', 'source_table', 'sourceTable']) - || '' - ).trim() + const dbPath = String(row._db_path || row.db_path || '').trim() + const explicitDbName = String(row.db_name || '').trim() + const tableName = String(row.table_name || '').trim() const dbName = explicitDbName || (dbPath ? basename(dbPath, extname(dbPath)) : '') return { dbName: dbName || undefined, @@ -3201,7 +3238,7 @@ class ChatService { if (!batch.success) break const rows = Array.isArray(batch.rows) ? batch.rows as Record[] : [] for (const row of rows) { - const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 1) + const localType = this.getRowInt(row, ['local_type'], 1) if (localType === 50) { counters.callMessages += 1 continue @@ -3216,8 +3253,8 @@ class ChatService { } if (localType !== 49) continue - const rawMessageContent = this.getRowField(row, ['message_content', 'messageContent', 'msg_content', 'msgContent', 'content', 'WCDB_CT_message_content']) - const rawCompressContent = this.getRowField(row, ['compress_content', 'compressContent', 'compressed_content', 'compressedContent', 'WCDB_CT_compress_content']) + const rawMessageContent = row.message_content + const rawCompressContent = row.compress_content const content = this.decodeMessageContent(rawMessageContent, rawCompressContent) const xmlType = this.extractType49XmlTypeForStats(content) if (xmlType === '2000') counters.transferMessages += 1 @@ -3270,7 +3307,7 @@ class ChatService { for (const row of rows) { stats.totalMessages += 1 - const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 1) + const localType = this.getRowInt(row, ['local_type'], 1) if (localType === 34) stats.voiceMessages += 1 if (localType === 3) stats.imageMessages += 1 if (localType === 43) stats.videoMessages += 1 @@ -3279,8 +3316,8 @@ class ChatService { if (localType === 8589934592049) stats.transferMessages += 1 if (localType === 8594229559345) stats.redPacketMessages += 1 if (localType === 49) { - const rawMessageContent = this.getRowField(row, ['message_content', 'messageContent', 'msg_content', 'msgContent', 'content', 'WCDB_CT_message_content']) - const rawCompressContent = this.getRowField(row, ['compress_content', 'compressContent', 'compressed_content', 'compressedContent', 'WCDB_CT_compress_content']) + const rawMessageContent = row.message_content + const rawCompressContent = row.compress_content const content = this.decodeMessageContent(rawMessageContent, rawCompressContent) const xmlType = this.extractType49XmlTypeForStats(content) if (xmlType === '2000') stats.transferMessages += 1 @@ -3289,7 +3326,7 @@ class ChatService { const createTime = this.getRowInt( row, - ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], + ['create_time'], 0 ) if (createTime > 0) { @@ -3302,7 +3339,7 @@ class ChatService { } if (sessionId.endsWith('@chatroom')) { - const sender = String(this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username']) || '').trim() + const sender = String(row.sender_username || '').trim() const senderKeys = this.buildIdentityKeys(sender) if (senderKeys.length > 0) { senderIdentities.add(senderKeys[0]) @@ -3310,7 +3347,7 @@ class ChatService { stats.groupMyMessages = (stats.groupMyMessages || 0) + 1 } } else { - const isSend = this.coerceRowNumber(this.getRowField(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send'])) + const isSend = this.coerceRowNumber(row.computed_is_send ?? row.is_send) if (Number.isFinite(isSend) && isSend === 1) { stats.groupMyMessages = (stats.groupMyMessages || 0) + 1 } @@ -3744,32 +3781,18 @@ class ChatService { const messages: Message[] = [] for (const row of rows) { const sourceInfo = this.getMessageSourceInfo(row) - const rawMessageContent = this.getRowField(row, [ - 'message_content', - 'messageContent', - 'content', - 'msg_content', - 'msgContent', - 'WCDB_CT_message_content', - 'WCDB_CT_messageContent' - ]); - const rawCompressContent = this.getRowField(row, [ - 'compress_content', - 'compressContent', - 'compressed_content', - 'WCDB_CT_compress_content', - 'WCDB_CT_compressContent' - ]); + const rawMessageContent = row.message_content + const rawCompressContent = row.compress_content const content = this.decodeMessageContent(rawMessageContent, rawCompressContent); - const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 1) - const isSendRaw = this.getRowField(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send']) + const localType = this.getRowInt(row, ['local_type'], 1) + const isSendRaw = row.computed_is_send ?? row.is_send const parsedRawIsSend = isSendRaw === null ? null : parseInt(isSendRaw, 10) - const senderUsername = this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username']) + const senderUsername = row.sender_username || this.extractSenderUsernameFromContent(content) || null const { isSend } = this.resolveMessageIsSend(parsedRawIsSend, senderUsername) - const createTime = this.getRowInt(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0) + const createTime = this.getRowInt(row, ['create_time'], 0) if (senderUsername && !myWxid) { // [DEBUG] Issue #34: 未配置 myWxid,无法判断是否发送 @@ -3796,6 +3819,7 @@ class ChatService { let fileName: string | undefined let fileSize: number | undefined let fileExt: string | undefined + let fileMd5: string | undefined let xmlType: string | undefined let appMsgKind: string | undefined let appMsgDesc: string | undefined @@ -3900,6 +3924,7 @@ class ChatService { fileName = type49Info.fileName fileSize = type49Info.fileSize fileExt = type49Info.fileExt + fileMd5 = type49Info.fileMd5 chatRecordTitle = type49Info.chatRecordTitle chatRecordList = type49Info.chatRecordList transferPayerUsername = type49Info.transferPayerUsername @@ -3923,6 +3948,7 @@ class ChatService { fileName = fileName || type49Info.fileName fileSize = fileSize ?? type49Info.fileSize fileExt = fileExt || type49Info.fileExt + fileMd5 = fileMd5 || type49Info.fileMd5 appMsgKind = appMsgKind || type49Info.appMsgKind appMsgDesc = appMsgDesc || type49Info.appMsgDesc appMsgAppName = appMsgAppName || type49Info.appMsgAppName @@ -3954,10 +3980,10 @@ class ChatService { if (!quotedSender && type49Info.quotedSender !== undefined) quotedSender = type49Info.quotedSender } - const localId = this.getRowInt(row, ['local_id', 'localId', 'LocalId', 'msg_local_id', 'msgLocalId', 'MsgLocalId', 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id'], 0) - const serverIdRaw = this.normalizeUnsignedIntegerToken(this.getRowField(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id'])) - const serverId = this.getRowInt(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id'], 0) - const sortSeq = this.getRowInt(row, ['sort_seq', 'sortSeq', 'seq', 'sequence', 'WCDB_CT_sort_seq'], createTime) + const localId = this.getRowInt(row, ['local_id'], 0) + const serverIdRaw = this.normalizeUnsignedIntegerToken(row.server_id) + const serverId = this.getRowInt(row, ['server_id'], 0) + const sortSeq = this.getRowInt(row, ['sort_seq'], createTime) messages.push({ messageKey: this.buildMessageKey({ @@ -3996,6 +4022,7 @@ class ChatService { fileName, fileSize, fileExt, + fileMd5, xmlType, appMsgKind, appMsgDesc, @@ -4404,18 +4431,7 @@ class ChatService { } private parseImageDatNameFromRow(row: Record): string | undefined { - const packed = this.getRowField(row, [ - 'packed_info_data', - 'packed_info', - 'packedInfoData', - 'packedInfo', - 'PackedInfoData', - 'PackedInfo', - 'WCDB_CT_packed_info_data', - 'WCDB_CT_packed_info', - 'WCDB_CT_PackedInfoData', - 'WCDB_CT_PackedInfo' - ]) + const packed = row.packed_info_data const buffer = this.decodePackedInfo(packed) if (!buffer || buffer.length === 0) return undefined const printable: number[] = [] @@ -4470,15 +4486,16 @@ class ChatService { */ private parseQuoteMessage(content: string): { content?: string; sender?: string } { try { + const normalizedContent = this.decodeHtmlEntities(content || '') // 提取 refermsg 部分 - const referMsgStart = content.indexOf('') - const referMsgEnd = content.indexOf('') + const referMsgStart = normalizedContent.indexOf('') + const referMsgEnd = normalizedContent.indexOf('') if (referMsgStart === -1 || referMsgEnd === -1) { return {} } - const referMsgXml = content.substring(referMsgStart, referMsgEnd + 11) + const referMsgXml = normalizedContent.substring(referMsgStart, referMsgEnd + 11) // 提取发送者名称 let displayName = this.extractXmlValue(referMsgXml, 'displayname') @@ -4495,8 +4512,8 @@ class ChatService { let displayContent = referContent switch (referType) { case '1': - // 文本消息,清理可能的 wxid - displayContent = this.sanitizeQuotedContent(referContent) + // 文本消息优先取“部分引用”字段,缺失时再回退到完整 content + displayContent = this.extractPreferredQuotedText(referMsgXml) break case '3': displayContent = '[图片]' @@ -4536,6 +4553,76 @@ class ChatService { } } + private extractPreferredQuotedText(referMsgXml: string): string { + if (!referMsgXml) return '' + + const sources = [this.decodeHtmlEntities(referMsgXml)] + const rawMsgSource = this.extractXmlValue(referMsgXml, 'msgsource') + if (rawMsgSource) { + const decodedMsgSource = this.decodeHtmlEntities(rawMsgSource) + if (decodedMsgSource) { + sources.push(decodedMsgSource) + } + } + + const fullContent = this.sanitizeQuotedContent(this.extractXmlValue(sources[0] || referMsgXml, 'content')) + const partialText = this.extractPartialQuotedText(sources[0] || referMsgXml, fullContent) + if (partialText) return partialText + + const candidateTags = [ + 'selectedcontent', + 'selectedtext', + 'selectcontent', + 'selecttext', + 'quotecontent', + 'quotetext', + 'partcontent', + 'parttext', + 'excerpt', + 'summary', + 'preview' + ] + + for (const source of sources) { + for (const tag of candidateTags) { + const value = this.sanitizeQuotedContent(this.extractXmlValue(source, tag)) + if (value) return value + } + } + + return fullContent + } + + private extractPartialQuotedText(xml: string, fullContent: string): string { + if (!xml || !fullContent) return '' + + const startChar = this.extractXmlValue(xml, 'start') + const endChar = this.extractXmlValue(xml, 'end') + const startIndexRaw = this.extractXmlValue(xml, 'startindex') + const endIndexRaw = this.extractXmlValue(xml, 'endindex') + const startIndex = Number.parseInt(startIndexRaw, 10) + const endIndex = Number.parseInt(endIndexRaw, 10) + + if (startChar && endChar) { + const startPos = fullContent.indexOf(startChar) + if (startPos !== -1) { + const endPos = fullContent.indexOf(endChar, startPos + startChar.length - 1) + if (endPos !== -1 && endPos >= startPos) { + const sliced = fullContent.slice(startPos, endPos + endChar.length).trim() + if (sliced) return sliced + } + } + } + + if (Number.isFinite(startIndex) && Number.isFinite(endIndex) && endIndex >= startIndex) { + const chars = Array.from(fullContent) + const sliced = chars.slice(startIndex, endIndex + 1).join('').trim() + if (sliced) return sliced + } + + return '' + } + /** * 解析名片消息 * 格式: @@ -4599,6 +4686,7 @@ class ChatService { fileName?: string fileSize?: number fileExt?: string + fileMd5?: string transferPayerUsername?: string transferReceiverUsername?: string chatRecordTitle?: string @@ -4795,6 +4883,7 @@ class ChatService { // 提取文件扩展名 const fileExt = this.extractXmlValue(content, 'fileext') + const fileMd5 = this.extractXmlValue(content, 'md5') || this.extractXmlValue(content, 'filemd5') if (fileExt) { result.fileExt = fileExt } else if (result.fileName) { @@ -4804,6 +4893,9 @@ class ChatService { result.fileExt = match[1] } } + if (fileMd5) { + result.fileMd5 = fileMd5.toLowerCase() + } break } @@ -5096,7 +5188,7 @@ class ChatService { } } - //手动查找 media_*.db 文件(当 WCDB DLL 不支持 listMediaDbs 时的 fallback) + //手动查找 media_*.db 文件(当 WCDB数据服务不支持 listMediaDbs 时的 fallback) private async findMediaDbsManually(): Promise { try { const dbPath = this.configService.get('dbPath') @@ -5303,14 +5395,14 @@ class ChatService { row: Record, rawContent: string ): Promise { - const directSender = this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username']) + const directSender = row.sender_username || this.extractSenderUsernameFromContent(rawContent) if (directSender) { return directSender } - const dbPath = this.getRowField(row, ['db_path', 'dbPath', '_db_path']) - const realSenderId = this.getRowField(row, ['real_sender_id', 'realSenderId']) + const dbPath = row._db_path + const realSenderId = row.real_sender_id if (!dbPath || realSenderId === null || realSenderId === undefined || String(realSenderId).trim() === '') { return null } @@ -5359,7 +5451,7 @@ class ChatService { 50: '[通话]', 10000: '[系统消息]', 244813135921: '[引用消息]', - 266287972401: '[拍一拍]', + 266287972401: '拍一拍', 81604378673: '[聊天记录]', 154618822705: '[小程序]', 8594229559345: '[红包]', @@ -5468,7 +5560,7 @@ class ChatService { * XML: "XX"拍了拍"XX"相信未来!... */ private cleanPatMessage(content: string): string { - if (!content) return '[拍一拍]' + if (!content) return '拍一拍' // 1. 优先从 XML 标签提取内容 const titleMatch = /<title>([\s\S]*?)<\/title>/i.exec(content) @@ -5478,14 +5570,14 @@ class ChatService { .replace(/\]\]>/g, '') .trim() if (title) { - return `[拍一拍] ${title}` + return title } } // 2. 尝试匹配标准的 "A拍了拍B" 格式 const match = /^(.+?拍了拍.+?)(?:[\r\n]|$|ງ|wxid_)/.exec(content) if (match) { - return `[拍一拍] ${match[1].trim()}` + return match[1].trim() } // 3. 如果匹配失败,尝试清理掉疑似的 garbage (wxid, 乱码) @@ -5499,10 +5591,10 @@ class ChatService { // 如果清理后还有内容,返回 if (cleaned && cleaned.length > 1 && !cleaned.includes('xml')) { - return `[拍一拍] ${cleaned}` + return cleaned } - return '[拍一拍]' + return '拍一拍' } /** @@ -5655,7 +5747,7 @@ class ChatService { if (!result.success || !result.contact) return null const contact = result.contact as Record<string, any> let alias = String(contact.alias || contact.Alias || '') - // DLL 有时不返回 alias 字段,补一条直接 SQL 查询兜底 + //数据服务有时不返回 alias 字段,补一条直接 SQL 查询兜底 if (!alias) { try { const aliasResult = await wcdbService.getContactAliasMap([username]) @@ -7520,11 +7612,7 @@ class ChatService { for (const row of result.messages) { let message = await this.parseMessage(row, { source: 'search', sessionId }) - const resolvedSessionId = String( - sessionId || - this.getRowField(row, ['_session_id', 'session_id', 'sessionId', 'talker', 'username']) - || '' - ).trim() + const resolvedSessionId = String(sessionId || row._session_id || '').trim() const needsDetailHydration = isGroupSearch && Boolean(sessionId) && message.localId > 0 && @@ -7559,32 +7647,18 @@ class ChatService { private async parseMessage(row: any, options?: { source?: 'search' | 'detail'; sessionId?: string }): Promise<Message> { const sourceInfo = this.getMessageSourceInfo(row) const rawContent = this.decodeMessageContent( - this.getRowField(row, [ - 'message_content', - 'messageContent', - 'content', - 'msg_content', - 'msgContent', - 'WCDB_CT_message_content', - 'WCDB_CT_messageContent' - ]), - this.getRowField(row, [ - 'compress_content', - 'compressContent', - 'compressed_content', - 'WCDB_CT_compress_content', - 'WCDB_CT_compressContent' - ]) + row.message_content, + row.compress_content ) // 这里复用 parseMessagesBatch 里面的解析逻辑,为了简单我这里先写个基础的 // 实际项目中建议抽取 parseRawMessage(row) 供多处使用 - const localId = this.getRowInt(row, ['local_id', 'localId', 'LocalId', 'msg_local_id', 'msgLocalId', 'MsgLocalId', 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id'], 0) - const serverIdRaw = this.normalizeUnsignedIntegerToken(this.getRowField(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id'])) - const serverId = this.getRowInt(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id'], 0) - const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 0) - const createTime = this.getRowInt(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time'], 0) - const sortSeq = this.getRowInt(row, ['sort_seq', 'sortSeq', 'seq', 'sequence', 'WCDB_CT_sort_seq'], createTime) - const rawIsSend = this.getRowField(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send']) + const localId = this.getRowInt(row, ['local_id'], 0) + const serverIdRaw = this.normalizeUnsignedIntegerToken(row.server_id) + const serverId = this.getRowInt(row, ['server_id'], 0) + const localType = this.getRowInt(row, ['local_type'], 0) + const createTime = this.getRowInt(row, ['create_time'], 0) + const sortSeq = this.getRowInt(row, ['sort_seq'], createTime) + const rawIsSend = row.computed_is_send ?? row.is_send const senderUsername = await this.resolveSenderUsernameForMessageRow(row, rawContent) const sendState = this.resolveMessageIsSend(rawIsSend === null ? null : parseInt(rawIsSend, 10), senderUsername) const msg: Message = { @@ -7612,8 +7686,8 @@ class ChatService { } if (msg.localId === 0 || msg.createTime === 0) { - const rawLocalId = this.getRowField(row, ['local_id', 'localId', 'LocalId', 'msg_local_id', 'msgLocalId', 'MsgLocalId', 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id']) - const rawCreateTime = this.getRowField(row, ['create_time', 'createTime', 'createtime', 'msg_create_time', 'msgCreateTime', 'msg_time', 'msgTime', 'time', 'WCDB_CT_create_time']) + const rawLocalId = row.local_id + const rawCreateTime = row.create_time console.warn('[ChatService] parseMessage raw keys', { rawLocalId, rawLocalIdType: rawLocalId ? typeof rawLocalId : 'null', diff --git a/electron/services/config.ts b/electron/services/config.ts index 3269b0b..6fa0af8 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -5,6 +5,13 @@ import Store from 'electron-store' // 加密前缀标记 const SAFE_PREFIX = 'safe:' // safeStorage 加密(普通模式) +const isSafeStorageAvailable = (): boolean => { + try { + return typeof safeStorage?.isEncryptionAvailable === 'function' && safeStorage.isEncryptionAvailable() + } catch { + return false + } +} const LOCK_PREFIX = 'lock:' // 密码派生密钥加密(锁定模式) interface ConfigSchema { @@ -27,6 +34,7 @@ interface ConfigSchema { themeId: string language: string logEnabled: boolean + launchAtStartup?: boolean llmModelPath: string whisperModelName: string whisperModelDir: string @@ -60,6 +68,7 @@ interface ConfigSchema { windowCloseBehavior: 'ask' | 'tray' | 'quit' quoteLayout: 'quote-top' | 'quote-bottom' wordCloudExcludeWords: string[] + exportWriteLayout: 'A' | 'B' | 'C' } // 需要 safeStorage 加密的字段(普通模式) @@ -128,11 +137,12 @@ export class ConfigService { httpApiToken: '', httpApiEnabled: false, httpApiPort: 5031, - httpApiHost: '127.0.0.1', + httpApiHost: '0.0.0.0', messagePushEnabled: false, windowCloseBehavior: 'ask', quoteLayout: 'quote-top', - wordCloudExcludeWords: [] + wordCloudExcludeWords: [], + exportWriteLayout: 'A' } const storeOptions: any = { @@ -254,7 +264,7 @@ export class ConfigService { private safeEncrypt(plaintext: string): string { if (!plaintext) return '' if (plaintext.startsWith(SAFE_PREFIX)) return plaintext - if (!safeStorage.isEncryptionAvailable()) return plaintext + if (!isSafeStorageAvailable()) return plaintext const encrypted = safeStorage.encryptString(plaintext) return SAFE_PREFIX + encrypted.toString('base64') } @@ -262,7 +272,7 @@ export class ConfigService { private safeDecrypt(stored: string): string { if (!stored) return '' if (!stored.startsWith(SAFE_PREFIX)) return stored - if (!safeStorage.isEncryptionAvailable()) return '' + if (!isSafeStorageAvailable()) return '' try { const buf = Buffer.from(stored.slice(SAFE_PREFIX.length), 'base64') return safeStorage.decryptString(buf) diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 34815cb..6363e98 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -98,6 +98,8 @@ export interface ExportOptions { exportVoices?: boolean exportVideos?: boolean exportEmojis?: boolean + exportFiles?: boolean + maxFileSizeMb?: number exportVoiceAsText?: boolean excelCompactColumns?: boolean txtColumns?: string[] @@ -121,7 +123,7 @@ const TXT_COLUMN_DEFINITIONS: Array<{ id: string; label: string }> = [ interface MediaExportItem { relativePath: string - kind: 'image' | 'voice' | 'emoji' | 'video' + kind: 'image' | 'voice' | 'emoji' | 'video' | 'file' posterDataUrl?: string } @@ -136,6 +138,11 @@ interface ExportDisplayProfile { type MessageCollectMode = 'full' | 'text-fast' | 'media-fast' type MediaContentType = 'voice' | 'image' | 'video' | 'emoji' +interface FileExportCandidate { + sourcePath: string + matchedBy: 'md5' | 'name' + yearMonth?: string +} export interface ExportProgress { current: number @@ -247,6 +254,7 @@ async function parallelLimit<T, R>( class ExportService { private configService: ConfigService + private runtimeConfig: { dbPath?: string; decryptKey?: string; myWxid?: string } | null = null private contactCache: LRUCache<string, { displayName: string; avatarUrl?: string }> private inlineEmojiCache: LRUCache<string, string> private htmlStyleCache: string | null = null @@ -288,6 +296,10 @@ class ExportService { return error } + setRuntimeConfig(config: { dbPath?: string; decryptKey?: string; myWxid?: string } | null): void { + this.runtimeConfig = config + } + private normalizeSessionIds(sessionIds: string[]): string[] { return Array.from( new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean)) @@ -430,6 +442,8 @@ class ExportService { let lastSessionId = '' let lastCollected = 0 let lastExported = 0 + const MIN_PROGRESS_EMIT_INTERVAL_MS = 250 + const MESSAGE_PROGRESS_DELTA_THRESHOLD = 500 const commit = (progress: ExportProgress) => { onProgress(progress) @@ -454,9 +468,9 @@ class ExportService { const shouldEmit = force || phase !== lastPhase || sessionId !== lastSessionId || - collectedDelta >= 200 || - exportedDelta >= 200 || - (now - lastSentAt >= 120) + collectedDelta >= MESSAGE_PROGRESS_DELTA_THRESHOLD || + exportedDelta >= MESSAGE_PROGRESS_DELTA_THRESHOLD || + (now - lastSentAt >= MIN_PROGRESS_EMIT_INTERVAL_MS) if (shouldEmit && pending) { commit(pending) @@ -842,7 +856,7 @@ class ExportService { private isMediaExportEnabled(options: ExportOptions): boolean { return options.exportMedia === true && - Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis) + Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis || options.exportFiles) } private isUnboundedDateRange(dateRange?: { start: number; end: number } | null): boolean { @@ -880,7 +894,7 @@ class ExportService { if (options.exportImages) selected.add(3) if (options.exportVoices) selected.add(34) if (options.exportVideos) selected.add(43) - if (options.exportEmojis) selected.add(47) + if (options.exportFiles) selected.add(49) return selected } @@ -1307,9 +1321,9 @@ class ExportService { } private async ensureConnected(): Promise<{ success: boolean; cleanedWxid?: string; error?: string }> { - const wxid = this.configService.get('myWxid') - const dbPath = this.configService.get('dbPath') - const decryptKey = this.configService.get('decryptKey') + const wxid = String(this.runtimeConfig?.myWxid || this.configService.get('myWxid') || '').trim() + const dbPath = String(this.runtimeConfig?.dbPath || this.configService.get('dbPath') || '').trim() + const decryptKey = String(this.runtimeConfig?.decryptKey || this.configService.get('decryptKey') || '').trim() if (!wxid) return { success: false, error: '请先在设置页面配置微信ID' } if (!dbPath) return { success: false, error: '请先在设置页面配置数据库路径' } if (!decryptKey) return { success: false, error: '请先在设置页面配置解密密钥' } @@ -1414,7 +1428,7 @@ class ExportService { } return this.buildTrustedGroupNicknameMap(Object.entries(dllResult.nicknames), candidates) } catch (e) { - console.error('getGroupNicknamesForRoom dll error:', e) + console.error('getGroupNicknamesForRoom service error:', e) return new Map<string, string>() } } @@ -2245,7 +2259,7 @@ class ExportService { const referMsgXml = normalized.substring(referMsgStart, referMsgEnd + 11) const quoteInfo = this.parseQuoteMessage(normalized) const replyText = this.stripSenderPrefix(this.extractXmlValue(normalized, 'title') || '') - const quotedPreview = this.formatQuotedReferencePreview( + const quotedPreview = quoteInfo.content || this.formatQuotedReferencePreview( this.extractXmlValue(referMsgXml, 'content'), this.extractXmlValue(referMsgXml, 'type') ) @@ -2951,7 +2965,7 @@ class ExportService { switch (referType) { case '1': - displayContent = this.sanitizeQuotedContent(referContent) + displayContent = this.extractPreferredQuotedText(referMsgXml) break case '3': displayContent = '[图片]' @@ -2992,6 +3006,76 @@ class ExportService { } } + private extractPreferredQuotedText(referMsgXml: string): string { + if (!referMsgXml) return '' + + const sources = [this.decodeHtmlEntities(referMsgXml)] + const rawMsgSource = this.extractXmlValue(referMsgXml, 'msgsource') + if (rawMsgSource) { + const decodedMsgSource = this.decodeHtmlEntities(rawMsgSource) + if (decodedMsgSource) { + sources.push(decodedMsgSource) + } + } + + const fullContent = this.sanitizeQuotedContent(this.extractXmlValue(sources[0] || referMsgXml, 'content')) + const partialText = this.extractPartialQuotedText(sources[0] || referMsgXml, fullContent) + if (partialText) return partialText + + const candidateTags = [ + 'selectedcontent', + 'selectedtext', + 'selectcontent', + 'selecttext', + 'quotecontent', + 'quotetext', + 'partcontent', + 'parttext', + 'excerpt', + 'summary', + 'preview' + ] + + for (const source of sources) { + for (const tag of candidateTags) { + const value = this.sanitizeQuotedContent(this.extractXmlValue(source, tag)) + if (value) return value + } + } + + return fullContent + } + + private extractPartialQuotedText(xml: string, fullContent: string): string { + if (!xml || !fullContent) return '' + + const startChar = this.extractXmlValue(xml, 'start') + const endChar = this.extractXmlValue(xml, 'end') + const startIndexRaw = this.extractXmlValue(xml, 'startindex') + const endIndexRaw = this.extractXmlValue(xml, 'endindex') + const startIndex = Number.parseInt(startIndexRaw, 10) + const endIndex = Number.parseInt(endIndexRaw, 10) + + if (startChar && endChar) { + const startPos = fullContent.indexOf(startChar) + if (startPos !== -1) { + const endPos = fullContent.indexOf(endChar, startPos + startChar.length - 1) + if (endPos !== -1 && endPos >= startPos) { + const sliced = fullContent.slice(startPos, endPos + endChar.length).trim() + if (sliced) return sliced + } + } + } + + if (Number.isFinite(startIndex) && Number.isFinite(endIndex) && endIndex >= startIndex) { + const chars = Array.from(fullContent) + const sliced = chars.slice(startIndex, endIndex + 1).join('').trim() + if (sliced) return sliced + } + + return '' + } + private extractChatLabReplyToMessageId(content: string): string | undefined { try { const normalized = this.normalizeAppMessageContent(content || '') @@ -3310,15 +3394,29 @@ class ExportService { const subType = this.extractAppMessageType(normalized) if (subType && subType !== '5' && subType !== '49') return null - const url = this.normalizeHtmlLinkUrl(this.extractXmlValue(normalized, 'url')) + const url = [ + this.extractXmlValue(normalized, 'url'), + this.extractXmlValue(normalized, 'shareurlopen'), + this.extractXmlValue(normalized, 'shareurloriginal'), + this.extractXmlValue(normalized, 'shareurl'), + this.extractXmlValue(normalized, 'shorturl'), + this.extractXmlValue(normalized, 'dataurl'), + this.extractXmlValue(normalized, 'lowurl'), + this.extractXmlValue(normalized, 'streamvideoweburl'), + this.extractXmlValue(normalized, 'weburl') + ] + .map(candidate => this.normalizeHtmlLinkUrl(candidate)) + .find(Boolean) || '' if (!url) return null - const title = this.extractXmlValue(normalized, 'title') || this.extractXmlValue(normalized, 'des') || url + const title = this.stripSenderPrefix( + this.extractXmlValue(normalized, 'title') || this.extractXmlValue(normalized, 'des') || url + ) || url return { title, url } } private normalizeHtmlLinkUrl(rawUrl: string): string { - const value = (rawUrl || '').trim() + const value = (rawUrl || '').trim().replace(/&/gi, '&') if (!value) return '' const parseHttpUrl = (candidate: string): string => { @@ -3349,6 +3447,46 @@ class ExportService { return '' } + private getLinkCardDisplayTitle(linkCard: { title: string; url: string }): string { + const normalizedTitle = this.stripSenderPrefix(String(linkCard.title || '').trim()) + return normalizedTitle || linkCard.url || '链接' + } + + private formatLinkCardExportText( + content: string, + localType: number, + style: 'markdown' | 'append-url' + ): string | null { + const linkCard = this.extractHtmlLinkCard(content, localType) + if (!linkCard?.url) return null + + const title = this.getLinkCardDisplayTitle(linkCard) + if (style === 'markdown') { + return `[${title}](${linkCard.url})` + } + + const prefix = title && title !== linkCard.url ? `[链接] ${title}` : '[链接]' + return `${prefix}\n${linkCard.url}` + } + + private applyExcelLinkCardCell(cell: ExcelJS.Cell, content: string, localType: number): boolean { + const linkCard = this.extractHtmlLinkCard(content, localType) + if (!linkCard?.url) return false + + const title = this.getLinkCardDisplayTitle(linkCard) + cell.value = { + text: title, + hyperlink: linkCard.url, + tooltip: linkCard.url + } as any + cell.font = { + ...(cell.font || {}), + color: { argb: 'FF0563C1' }, + underline: true + } + return true + } + /** * 导出媒体文件到指定目录 */ @@ -3362,6 +3500,8 @@ class ExportService { exportVoices?: boolean exportVideos?: boolean exportEmojis?: boolean + exportFiles?: boolean + maxFileSizeMb?: number exportVoiceAsText?: boolean includeVideoPoster?: boolean includeVoiceWithTranscript?: boolean @@ -3415,6 +3555,16 @@ class ExportService { ) } + if ((localType === 49 || localType === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6') { + return this.exportFileAttachment( + msg, + mediaRootDir, + mediaRelativePrefix, + options.maxFileSizeMb, + options.dirCache + ) + } + return null } @@ -3483,20 +3633,11 @@ class ExportService { console.log(`[Export] 使用缩略图替代 (localId=${msg.localId}): ${thumbResult.localPath}`) result.localPath = thumbResult.localPath } else { - console.log(`[Export] 缩略图也获取失败 (localId=${msg.localId}): error=${thumbResult.error || '未知'}`) - // 最后尝试:直接从 imageStore 获取缓存的缩略图 data URL - const { imageStore } = await import('../main') - const cachedThumb = imageStore?.getCachedImage(sessionId, imageMd5, imageDatName) - if (cachedThumb) { - console.log(`[Export] 从 imageStore 获取到缓存缩略图 (localId=${msg.localId})`) - result.localPath = cachedThumb - } else { - console.log(`[Export] 所有方式均失败 → 将显示 [图片] 占位符`) - if (missingRunCacheKey) { - this.mediaRunMissingImageKeys.add(missingRunCacheKey) - } - return null + console.log(`[Export] 缩略图也获取失败,所有方式均失败 → 将显示 [图片] 占位符`) + if (missingRunCacheKey) { + this.mediaRunMissingImageKeys.add(missingRunCacheKey) } + return null } } @@ -3505,7 +3646,7 @@ class ExportService { const imageKey = (imageMd5 || imageDatName || 'image').replace(/[^a-zA-Z0-9_-]/g, '') // 从 data URL 或 file URL 获取实际路径 - let sourcePath = result.localPath + let sourcePath: string = result.localPath! if (sourcePath.startsWith('data:')) { // 是 data URL,需要保存为文件 const base64Data = sourcePath.split(',')[1] @@ -3885,6 +4026,165 @@ class ExportService { return tagMatch?.[1]?.toLowerCase() } + private resolveFileAttachmentRoots(): string[] { + const dbPath = String(this.configService.get('dbPath') || '').trim() + const rawWxid = String(this.configService.get('myWxid') || '').trim() + const cleanedWxid = this.cleanAccountDirName(rawWxid) + if (!dbPath) return [] + + const normalized = dbPath.replace(/[\\/]+$/, '') + const roots = new Set<string>() + const tryAddRoot = (candidate: string) => { + const fileRoot = path.join(candidate, 'msg', 'file') + if (fs.existsSync(fileRoot)) { + roots.add(fileRoot) + } + } + + tryAddRoot(normalized) + if (rawWxid) tryAddRoot(path.join(normalized, rawWxid)) + if (cleanedWxid && cleanedWxid !== rawWxid) tryAddRoot(path.join(normalized, cleanedWxid)) + + const dbStoragePath = + this.resolveDbStoragePathForExport(normalized, cleanedWxid) || + this.resolveDbStoragePathForExport(normalized, rawWxid) + if (dbStoragePath) { + tryAddRoot(path.dirname(dbStoragePath)) + } + + return Array.from(roots) + } + + private buildPreferredFileYearMonths(createTime?: unknown): string[] { + const raw = Number(createTime) + if (!Number.isFinite(raw) || raw <= 0) return [] + const ts = raw > 1e12 ? raw : raw * 1000 + const date = new Date(ts) + if (Number.isNaN(date.getTime())) return [] + const y = date.getFullYear() + const m = String(date.getMonth() + 1).padStart(2, '0') + return [`${y}-${m}`] + } + + private async verifyFileHash(sourcePath: string, expectedMd5?: string): Promise<boolean> { + const normalizedExpected = String(expectedMd5 || '').trim().toLowerCase() + if (!normalizedExpected) return true + if (!/^[a-f0-9]{32}$/i.test(normalizedExpected)) return true + try { + const hash = crypto.createHash('md5') + await new Promise<void>((resolve, reject) => { + const stream = fs.createReadStream(sourcePath) + stream.on('data', chunk => hash.update(chunk)) + stream.on('end', () => resolve()) + stream.on('error', reject) + }) + return hash.digest('hex').toLowerCase() === normalizedExpected + } catch { + return false + } + } + + private async resolveFileAttachmentCandidates(msg: any): Promise<FileExportCandidate[]> { + const fileName = String(msg?.fileName || '').trim() + if (!fileName) return [] + + const roots = this.resolveFileAttachmentRoots() + if (roots.length === 0) return [] + + const normalizedMd5 = String(msg?.fileMd5 || '').trim().toLowerCase() + const preferredMonths = this.buildPreferredFileYearMonths(msg?.createTime) + const candidates: FileExportCandidate[] = [] + const seen = new Set<string>() + + for (const root of roots) { + let monthDirs: string[] = [] + try { + monthDirs = fs.readdirSync(root) + .filter(entry => /^\d{4}-\d{2}$/.test(entry) && fs.existsSync(path.join(root, entry))) + .sort() + } catch { + continue + } + + const orderedMonths = Array.from(new Set([ + ...preferredMonths, + ...monthDirs.slice().reverse() + ])) + + for (const month of orderedMonths) { + const sourcePath = path.join(root, month, fileName) + if (!fs.existsSync(sourcePath)) continue + const resolvedPath = path.resolve(sourcePath) + if (seen.has(resolvedPath)) continue + seen.add(resolvedPath) + + if (normalizedMd5) { + const ok = await this.verifyFileHash(resolvedPath, normalizedMd5) + if (ok) { + candidates.unshift({ sourcePath: resolvedPath, matchedBy: 'md5', yearMonth: month }) + continue + } + } + + candidates.push({ sourcePath: resolvedPath, matchedBy: 'name', yearMonth: month }) + } + } + + return candidates + } + + private async exportFileAttachment( + msg: any, + mediaRootDir: string, + mediaRelativePrefix: string, + maxFileSizeMb?: number, + dirCache?: Set<string> + ): Promise<MediaExportItem | null> { + try { + const fileNameRaw = String(msg?.fileName || '').trim() + if (!fileNameRaw) return null + + const filesDir = path.join(mediaRootDir, mediaRelativePrefix, 'files') + if (!dirCache?.has(filesDir)) { + await fs.promises.mkdir(filesDir, { recursive: true }) + dirCache?.add(filesDir) + } + + const candidates = await this.resolveFileAttachmentCandidates(msg) + if (candidates.length === 0) return null + + const maxBytes = Number.isFinite(maxFileSizeMb) + ? Math.max(0, Math.floor(Number(maxFileSizeMb) * 1024 * 1024)) + : 0 + + const selected = candidates[0] + const stat = await fs.promises.stat(selected.sourcePath) + if (!stat.isFile()) return null + if (maxBytes > 0 && stat.size > maxBytes) return null + + const normalizedMd5 = String(msg?.fileMd5 || '').trim().toLowerCase() + if (normalizedMd5 && selected.matchedBy !== 'md5') { + const verified = await this.verifyFileHash(selected.sourcePath, normalizedMd5) + if (!verified) return null + } + + const safeBaseName = path.basename(fileNameRaw).replace(/[\\/:*?"<>|]/g, '_') || 'file' + const messageId = String(msg?.localId || Date.now()) + const destFileName = `${messageId}_${safeBaseName}` + const destPath = path.join(filesDir, destFileName) + const copied = await this.copyFileOptimized(selected.sourcePath, destPath) + if (!copied.success) return null + + this.noteMediaTelemetry({ doneFiles: 1, bytesWritten: stat.size }) + return { + relativePath: path.posix.join(mediaRelativePrefix, 'files', destFileName), + kind: 'file' + } + } catch { + return null + } + } + private extractLocationMeta(content: string, localType: number): { locationLat?: number locationLng?: number @@ -3941,7 +4241,7 @@ class ExportService { mediaRelativePrefix: string } { const exportMediaEnabled = options.exportMedia === true && - Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis) + Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis || options.exportFiles) const outputDir = path.dirname(outputPath) const rawWriteLayout = this.configService.get('exportWriteLayout') const writeLayout = rawWriteLayout === 'A' || rawWriteLayout === 'B' || rawWriteLayout === 'C' @@ -4878,7 +5178,8 @@ class ExportService { return (t === 3 && options.exportImages) || // 图片 (t === 47 && options.exportEmojis) || // 表情 (t === 43 && options.exportVideos) || // 视频 - (t === 34 && options.exportVoices) // 语音文件 + (t === 34 && options.exportVoices) || // 语音文件 + ((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6') }) : [] @@ -4919,6 +5220,8 @@ class ExportService { exportVoices: options.exportVoices, exportVideos: options.exportVideos, exportEmojis: options.exportEmojis, + exportFiles: options.exportFiles, + maxFileSizeMb: options.maxFileSizeMb, exportVoiceAsText: options.exportVoiceAsText, includeVideoPoster: options.format === 'html', imageDeepSearchOnMiss: options.imageDeepSearchOnMiss, @@ -5066,6 +5369,11 @@ class ExportService { } } + const markdownLinkContent = this.formatLinkCardExportText(msg.content, msg.localType, 'markdown') + if (markdownLinkContent) { + content = markdownLinkContent + } + const message: ChatLabMessage = { sender: msg.senderUsername, accountName: senderProfile.displayName || memberInfo.accountName, @@ -5382,7 +5690,8 @@ class ExportService { return (t === 3 && options.exportImages) || (t === 47 && options.exportEmojis) || (t === 43 && options.exportVideos) || - (t === 34 && options.exportVoices) + (t === 34 && options.exportVoices) || + ((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6') }) : [] @@ -5422,6 +5731,8 @@ class ExportService { exportVoices: options.exportVoices, exportVideos: options.exportVideos, exportEmojis: options.exportEmojis, + exportFiles: options.exportFiles, + maxFileSizeMb: options.maxFileSizeMb, exportVoiceAsText: options.exportVoiceAsText, includeVideoPoster: options.format === 'html', imageDeepSearchOnMiss: options.imageDeepSearchOnMiss, @@ -5558,6 +5869,13 @@ class ExportService { content = this.buildQuotedReplyText(quotedReplyDisplay) } + const appendedLinkContent = quotedReplyDisplay + ? null + : this.formatLinkCardExportText(msg.content, msg.localType, 'append-url') + if (appendedLinkContent) { + content = appendedLinkContent + } + // 获取发送者信息用于名称显示 const senderWxid = msg.senderUsername const contact = senderWxid @@ -6235,7 +6553,8 @@ class ExportService { return (t === 3 && options.exportImages) || (t === 47 && options.exportEmojis) || (t === 43 && options.exportVideos) || - (t === 34 && options.exportVoices) + (t === 34 && options.exportVoices) || + ((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6') }) : [] @@ -6275,6 +6594,8 @@ class ExportService { exportVoices: options.exportVoices, exportVideos: options.exportVideos, exportEmojis: options.exportEmojis, + exportFiles: options.exportFiles, + maxFileSizeMb: options.maxFileSizeMb, exportVoiceAsText: options.exportVoiceAsText, includeVideoPoster: options.format === 'html', imageDeepSearchOnMiss: options.imageDeepSearchOnMiss, @@ -6484,16 +6805,14 @@ class ExportService { enrichedContentValue = this.buildQuotedReplyText(quotedReplyDisplay) } - // 调试日志 - if (msg.localType === 3 || msg.localType === 47) { - } + const contentCellIndex = useCompactColumns ? 5 : 9 + const contentCell = worksheet.getCell(currentRow, contentCellIndex) worksheet.getCell(currentRow, 1).value = i + 1 worksheet.getCell(currentRow, 2).value = this.formatTimestamp(msg.createTime) if (useCompactColumns) { worksheet.getCell(currentRow, 3).value = senderRole worksheet.getCell(currentRow, 4).value = this.getMessageTypeName(msg.localType) - worksheet.getCell(currentRow, 5).value = enrichedContentValue } else { worksheet.getCell(currentRow, 3).value = senderNickname worksheet.getCell(currentRow, 4).value = senderWxid @@ -6501,7 +6820,10 @@ class ExportService { worksheet.getCell(currentRow, 6).value = senderGroupNickname worksheet.getCell(currentRow, 7).value = senderRole worksheet.getCell(currentRow, 8).value = this.getMessageTypeName(msg.localType) - worksheet.getCell(currentRow, 9).value = enrichedContentValue + } + contentCell.value = enrichedContentValue + if (!quotedReplyDisplay) { + this.applyExcelLinkCardCell(contentCell, msg.content, msg.localType) } currentRow++ @@ -6747,7 +7069,7 @@ class ExportService { enrichedContentValue = this.buildQuotedReplyText(quotedReplyDisplay) } - appendRow(useCompactColumns + const row = worksheet.addRow(useCompactColumns ? [ i + 1, this.formatTimestamp(msg.createTime), @@ -6766,6 +7088,10 @@ class ExportService { this.getMessageTypeName(msg.localType), enrichedContentValue ]) + if (!quotedReplyDisplay) { + this.applyExcelLinkCardCell(row.getCell(useCompactColumns ? 5 : 9), msg.content, msg.localType) + } + row.commit() if ((i + 1) % 200 === 0) { onProgress?.({ @@ -6943,7 +7269,8 @@ class ExportService { return (t === 3 && options.exportImages) || (t === 47 && options.exportEmojis) || (t === 43 && options.exportVideos) || - (t === 34 && options.exportVoices) + (t === 34 && options.exportVoices) || + ((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6') }) : [] @@ -6983,6 +7310,8 @@ class ExportService { exportVoices: options.exportVoices, exportVideos: options.exportVideos, exportEmojis: options.exportEmojis, + exportFiles: options.exportFiles, + maxFileSizeMb: options.maxFileSizeMb, exportVoiceAsText: options.exportVoiceAsText, includeVideoPoster: options.format === 'html', imageDeepSearchOnMiss: options.imageDeepSearchOnMiss, @@ -7119,6 +7448,13 @@ class ExportService { enrichedContentValue = this.buildQuotedReplyText(quotedReplyDisplay) } + const appendedLinkContent = quotedReplyDisplay + ? null + : this.formatLinkCardExportText(msg.content, msg.localType, 'append-url') + if (appendedLinkContent) { + enrichedContentValue = appendedLinkContent + } + let senderRole: string let senderWxid: string let senderNickname: string @@ -7313,7 +7649,8 @@ class ExportService { return (t === 3 && options.exportImages) || (t === 47 && options.exportEmojis) || (t === 43 && options.exportVideos) || - (t === 34 && options.exportVoices) + (t === 34 && options.exportVoices) || + ((t === 49 || t === 8589934592049) && options.exportFiles && String(msg?.xmlType || '') === '6') }) : [] @@ -7353,6 +7690,8 @@ class ExportService { exportVoices: options.exportVoices, exportVideos: options.exportVideos, exportEmojis: options.exportEmojis, + exportFiles: options.exportFiles, + maxFileSizeMb: options.maxFileSizeMb, exportVoiceAsText: options.exportVoiceAsText, includeVideoPoster: options.format === 'html', imageDeepSearchOnMiss: options.imageDeepSearchOnMiss, @@ -7773,6 +8112,8 @@ class ExportService { exportImages: options.exportImages, exportVoices: options.exportVoices, exportEmojis: options.exportEmojis, + exportFiles: options.exportFiles, + maxFileSizeMb: options.maxFileSizeMb, exportVoiceAsText: options.exportVoiceAsText, includeVideoPoster: options.format === 'html', includeVoiceWithTranscript: true, @@ -8311,22 +8652,22 @@ class ExportService { const metric = aggregatedData?.[sessionId] const totalCount = Number.isFinite(metric?.totalMessages) - ? Math.max(0, Math.floor(metric!.totalMessages)) + ? Math.max(0, Math.floor(metric?.totalMessages ?? 0)) : 0 const voiceCount = Number.isFinite(metric?.voiceMessages) - ? Math.max(0, Math.floor(metric!.voiceMessages)) + ? Math.max(0, Math.floor(metric?.voiceMessages ?? 0)) : 0 const imageCount = Number.isFinite(metric?.imageMessages) - ? Math.max(0, Math.floor(metric!.imageMessages)) + ? Math.max(0, Math.floor(metric?.imageMessages ?? 0)) : 0 const videoCount = Number.isFinite(metric?.videoMessages) - ? Math.max(0, Math.floor(metric!.videoMessages)) + ? Math.max(0, Math.floor(metric?.videoMessages ?? 0)) : 0 const emojiCount = Number.isFinite(metric?.emojiMessages) - ? Math.max(0, Math.floor(metric!.emojiMessages)) + ? Math.max(0, Math.floor(metric?.emojiMessages ?? 0)) : 0 const lastTimestamp = Number.isFinite(metric?.lastTimestamp) - ? Math.max(0, Math.floor(metric!.lastTimestamp)) + ? Math.max(0, Math.floor(metric?.lastTimestamp ?? 0)) : undefined const cachedCountRaw = Number(cachedVoiceCountMap[sessionId] || 0) const sessionCachedVoiceCount = Math.min( diff --git a/electron/services/groupAnalyticsService.ts b/electron/services/groupAnalyticsService.ts index a244d16..b07745a 100644 --- a/electron/services/groupAnalyticsService.ts +++ b/electron/services/groupAnalyticsService.ts @@ -275,7 +275,7 @@ class GroupAnalyticsService { } return this.buildTrustedGroupNicknameMap(Object.entries(dllResult.nicknames), candidates) } catch (e) { - console.error('getGroupNicknamesForRoom dll error:', e) + console.error('getGroupNicknamesForRoom service error:', e) return new Map<string, string>() } } diff --git a/electron/services/httpService.ts b/electron/services/httpService.ts index 02fa030..29d8952 100644 --- a/electron/services/httpService.ts +++ b/electron/services/httpService.ts @@ -12,6 +12,7 @@ import { ConfigService } from './config' import { videoService } from './videoService' import { imageDecryptService } from './imageDecryptService' import { groupAnalyticsService } from './groupAnalyticsService' +import { snsService } from './snsService' // ChatLab 格式定义 interface ChatLabHeader { @@ -308,7 +309,7 @@ class HttpService { */ private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> { res.setHeader('Access-Control-Allow-Origin', '*') - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS') res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization') if (req.method === 'OPTIONS') { @@ -348,6 +349,33 @@ class HttpService { await this.handleContacts(url, res) } else if (pathname === '/api/v1/group-members') { await this.handleGroupMembers(url, res) + } else if (pathname === '/api/v1/sns/timeline') { + if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET') + await this.handleSnsTimeline(url, res) + } else if (pathname === '/api/v1/sns/usernames') { + if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET') + await this.handleSnsUsernames(res) + } else if (pathname === '/api/v1/sns/export/stats') { + if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET') + await this.handleSnsExportStats(url, res) + } else if (pathname === '/api/v1/sns/media/proxy') { + if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET') + await this.handleSnsMediaProxy(url, res) + } else if (pathname === '/api/v1/sns/export') { + if (req.method !== 'POST') return this.sendMethodNotAllowed(res, 'POST') + await this.handleSnsExport(url, res) + } else if (pathname === '/api/v1/sns/block-delete/status') { + if (req.method !== 'GET') return this.sendMethodNotAllowed(res, 'GET') + await this.handleSnsBlockDeleteStatus(res) + } else if (pathname === '/api/v1/sns/block-delete/install') { + if (req.method !== 'POST') return this.sendMethodNotAllowed(res, 'POST') + await this.handleSnsBlockDeleteInstall(res) + } else if (pathname === '/api/v1/sns/block-delete/uninstall') { + if (req.method !== 'POST') return this.sendMethodNotAllowed(res, 'POST') + await this.handleSnsBlockDeleteUninstall(res) + } else if (pathname.startsWith('/api/v1/sns/post/')) { + if (req.method !== 'DELETE') return this.sendMethodNotAllowed(res, 'DELETE') + await this.handleSnsDeletePost(pathname, res) } else if (pathname.startsWith('/api/v1/media/')) { this.handleMediaRequest(pathname, res) } else { @@ -559,6 +587,15 @@ class HttpService { return defaultValue } + private parseStringListParam(value: string | null): string[] | undefined { + if (!value) return undefined + const values = value + .split(',') + .map((item) => item.trim()) + .filter(Boolean) + return values.length > 0 ? Array.from(new Set(values)) : undefined + } + private parseMediaOptions(url: URL): ApiMediaOptions { const mediaEnabled = this.parseBooleanParam(url, ['media', 'meiti'], false) if (!mediaEnabled) { @@ -790,6 +827,313 @@ class HttpService { } } + private async handleSnsTimeline(url: URL, res: http.ServerResponse): Promise<void> { + const limit = this.parseIntParam(url.searchParams.get('limit'), 20, 1, 200) + const offset = this.parseIntParam(url.searchParams.get('offset'), 0, 0, Number.MAX_SAFE_INTEGER) + const usernames = this.parseStringListParam(url.searchParams.get('usernames')) + const keyword = (url.searchParams.get('keyword') || '').trim() || undefined + const resolveMedia = this.parseBooleanParam(url, ['media', 'resolveMedia', 'meiti'], true) + const inlineMedia = resolveMedia && this.parseBooleanParam(url, ['inline'], false) + const replaceMedia = resolveMedia && this.parseBooleanParam(url, ['replace'], true) + const startTimeRaw = this.parseTimeParam(url.searchParams.get('start')) + const endTimeRaw = this.parseTimeParam(url.searchParams.get('end'), true) + const startTime = startTimeRaw > 0 ? startTimeRaw : undefined + const endTime = endTimeRaw > 0 ? endTimeRaw : undefined + + const result = await snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime) + if (!result.success) { + this.sendError(res, 500, result.error || 'Failed to get sns timeline') + return + } + + let timeline = result.timeline || [] + if (resolveMedia && timeline.length > 0) { + timeline = await this.enrichSnsTimelineMedia(timeline, inlineMedia, replaceMedia) + } + + this.sendJson(res, { + success: true, + count: timeline.length, + timeline + }) + } + + private async handleSnsUsernames(res: http.ServerResponse): Promise<void> { + const result = await snsService.getSnsUsernames() + if (!result.success) { + this.sendError(res, 500, result.error || 'Failed to get sns usernames') + return + } + this.sendJson(res, { + success: true, + usernames: result.usernames || [] + }) + } + + private async handleSnsExportStats(url: URL, res: http.ServerResponse): Promise<void> { + const fast = this.parseBooleanParam(url, ['fast'], false) + const result = fast + ? await snsService.getExportStatsFast() + : await snsService.getExportStats() + if (!result.success) { + this.sendError(res, 500, result.error || 'Failed to get sns export stats') + return + } + this.sendJson(res, result) + } + + private async handleSnsMediaProxy(url: URL, res: http.ServerResponse): Promise<void> { + const mediaUrl = (url.searchParams.get('url') || '').trim() + if (!mediaUrl) { + this.sendError(res, 400, 'Missing required parameter: url') + return + } + + const key = this.toSnsMediaKey(url.searchParams.get('key')) + const result = await snsService.downloadImage(mediaUrl, key) + if (!result.success) { + this.sendError(res, 502, result.error || 'Failed to proxy sns media') + return + } + + if (result.data) { + res.setHeader('Content-Type', result.contentType || 'application/octet-stream') + res.setHeader('Content-Length', result.data.length) + res.writeHead(200) + res.end(result.data) + return + } + + if (result.cachePath && fs.existsSync(result.cachePath)) { + try { + const stat = fs.statSync(result.cachePath) + res.setHeader('Content-Type', result.contentType || 'application/octet-stream') + res.setHeader('Content-Length', stat.size) + res.writeHead(200) + + const stream = fs.createReadStream(result.cachePath) + stream.on('error', () => { + if (!res.headersSent) { + this.sendError(res, 500, 'Failed to read proxied sns media') + } else { + try { res.destroy() } catch {} + } + }) + stream.pipe(res) + return + } catch (error) { + console.error('[HttpService] Failed to stream sns media cache:', error) + } + } + + this.sendError(res, 502, result.error || 'Failed to proxy sns media') + } + + private async handleSnsExport(url: URL, res: http.ServerResponse): Promise<void> { + const outputDir = String(url.searchParams.get('outputDir') || '').trim() + if (!outputDir) { + this.sendError(res, 400, 'Missing required field: outputDir') + return + } + + const rawFormat = String(url.searchParams.get('format') || 'json').trim().toLowerCase() + const format = rawFormat === 'arkme-json' ? 'arkmejson' : rawFormat + if (!['json', 'html', 'arkmejson'].includes(format)) { + this.sendError(res, 400, 'Invalid format, supported: json/html/arkmejson') + return + } + + const usernames = this.parseStringListParam(url.searchParams.get('usernames')) + const keyword = String(url.searchParams.get('keyword') || '').trim() || undefined + const startTimeRaw = this.parseTimeParam(url.searchParams.get('start')) + const endTimeRaw = this.parseTimeParam(url.searchParams.get('end'), true) + + const options: { + outputDir: string + format: 'json' | 'html' | 'arkmejson' + usernames?: string[] + keyword?: string + exportMedia?: boolean + exportImages?: boolean + exportLivePhotos?: boolean + exportVideos?: boolean + startTime?: number + endTime?: number + } = { + outputDir, + format: format as 'json' | 'html' | 'arkmejson', + usernames, + keyword, + exportMedia: this.parseBooleanParam(url, ['exportMedia'], false) + } + + if (url.searchParams.has('exportImages')) options.exportImages = this.parseBooleanParam(url, ['exportImages'], false) + if (url.searchParams.has('exportLivePhotos')) options.exportLivePhotos = this.parseBooleanParam(url, ['exportLivePhotos'], false) + if (url.searchParams.has('exportVideos')) options.exportVideos = this.parseBooleanParam(url, ['exportVideos'], false) + if (startTimeRaw > 0) options.startTime = startTimeRaw + if (endTimeRaw > 0) options.endTime = endTimeRaw + + const result = await snsService.exportTimeline(options) + if (!result.success) { + this.sendError(res, 500, result.error || 'Failed to export sns timeline') + return + } + this.sendJson(res, result) + } + + private async handleSnsBlockDeleteStatus(res: http.ServerResponse): Promise<void> { + const result = await snsService.checkSnsBlockDeleteTrigger() + if (!result.success) { + this.sendError(res, 500, result.error || 'Failed to check sns block-delete status') + return + } + this.sendJson(res, result) + } + + private async handleSnsBlockDeleteInstall(res: http.ServerResponse): Promise<void> { + const result = await snsService.installSnsBlockDeleteTrigger() + if (!result.success) { + this.sendError(res, 500, result.error || 'Failed to install sns block-delete trigger') + return + } + this.sendJson(res, result) + } + + private async handleSnsBlockDeleteUninstall(res: http.ServerResponse): Promise<void> { + const result = await snsService.uninstallSnsBlockDeleteTrigger() + if (!result.success) { + this.sendError(res, 500, result.error || 'Failed to uninstall sns block-delete trigger') + return + } + this.sendJson(res, result) + } + + private async handleSnsDeletePost(pathname: string, res: http.ServerResponse): Promise<void> { + const postId = decodeURIComponent(pathname.replace('/api/v1/sns/post/', '')).trim() + if (!postId) { + this.sendError(res, 400, 'Missing required path parameter: postId') + return + } + + const result = await snsService.deleteSnsPost(postId) + if (!result.success) { + this.sendError(res, 500, result.error || 'Failed to delete sns post') + return + } + this.sendJson(res, result) + } + + private toSnsMediaKey(value: unknown): string | number | undefined { + if (value == null) return undefined + if (typeof value === 'number' && Number.isFinite(value)) return value + const text = String(value).trim() + if (!text) return undefined + if (/^-?\d+$/.test(text)) return Number(text) + return text + } + + private buildSnsMediaProxyUrl(rawUrl: string, key?: string | number): string | undefined { + const target = String(rawUrl || '').trim() + if (!target) return undefined + const params = new URLSearchParams({ url: target }) + if (key !== undefined) params.set('key', String(key)) + return `http://${this.host}:${this.port}/api/v1/sns/media/proxy?${params.toString()}` + } + + private async resolveSnsMediaUrl( + rawUrl: string, + key: string | number | undefined, + inline: boolean + ): Promise<{ resolvedUrl?: string; proxyUrl?: string }> { + const proxyUrl = this.buildSnsMediaProxyUrl(rawUrl, key) + if (!proxyUrl) return {} + if (!inline) return { resolvedUrl: proxyUrl, proxyUrl } + + try { + const resolved = await snsService.proxyImage(rawUrl, key) + if (resolved.success && resolved.dataUrl) { + return { resolvedUrl: resolved.dataUrl, proxyUrl } + } + } catch (error) { + console.warn('[HttpService] resolveSnsMediaUrl inline failed:', error) + } + + return { resolvedUrl: proxyUrl, proxyUrl } + } + + private async enrichSnsTimelineMedia(posts: any[], inline: boolean, replace: boolean): Promise<any[]> { + return Promise.all( + (posts || []).map(async (post) => { + const mediaList = Array.isArray(post?.media) ? post.media : [] + if (mediaList.length === 0) return post + + const nextMedia = await Promise.all( + mediaList.map(async (media: any) => { + const rawUrl = typeof media?.url === 'string' ? media.url : '' + const rawThumb = typeof media?.thumb === 'string' ? media.thumb : '' + const mediaKey = this.toSnsMediaKey(media?.key) + + const [urlResolved, thumbResolved] = await Promise.all([ + this.resolveSnsMediaUrl(rawUrl, mediaKey, inline), + this.resolveSnsMediaUrl(rawThumb, mediaKey, inline) + ]) + + const nextItem: any = { + ...media, + rawUrl, + rawThumb, + resolvedUrl: urlResolved.resolvedUrl, + resolvedThumbUrl: thumbResolved.resolvedUrl, + proxyUrl: urlResolved.proxyUrl, + proxyThumbUrl: thumbResolved.proxyUrl + } + + if (replace) { + nextItem.url = urlResolved.resolvedUrl || rawUrl + nextItem.thumb = thumbResolved.resolvedUrl || rawThumb + } + + if (media?.livePhoto && typeof media.livePhoto === 'object') { + const livePhoto = media.livePhoto + const rawLiveUrl = typeof livePhoto.url === 'string' ? livePhoto.url : '' + const rawLiveThumb = typeof livePhoto.thumb === 'string' ? livePhoto.thumb : '' + const liveKey = this.toSnsMediaKey(livePhoto.key ?? mediaKey) + + const [liveUrlResolved, liveThumbResolved] = await Promise.all([ + this.resolveSnsMediaUrl(rawLiveUrl, liveKey, inline), + this.resolveSnsMediaUrl(rawLiveThumb, liveKey, inline) + ]) + + const nextLive: any = { + ...livePhoto, + rawUrl: rawLiveUrl, + rawThumb: rawLiveThumb, + resolvedUrl: liveUrlResolved.resolvedUrl, + resolvedThumbUrl: liveThumbResolved.resolvedUrl, + proxyUrl: liveUrlResolved.proxyUrl, + proxyThumbUrl: liveThumbResolved.proxyUrl + } + + if (replace) { + nextLive.url = liveUrlResolved.resolvedUrl || rawLiveUrl + nextLive.thumb = liveThumbResolved.resolvedUrl || rawLiveThumb + } + + nextItem.livePhoto = nextLive + } + + return nextItem + }) + ) + + return { + ...post, + media: nextMedia + } + }) + ) + } + private getApiMediaExportPath(): string { return path.join(this.configService.getCacheBasePath(), 'api-media') } @@ -1451,6 +1795,11 @@ class HttpService { res.end(JSON.stringify(data, null, 2)) } + private sendMethodNotAllowed(res: http.ServerResponse, allow: string): void { + res.setHeader('Allow', allow) + this.sendError(res, 405, `Method Not Allowed. Allowed: ${allow}`) + } + /** * 发送错误响应 */ diff --git a/electron/services/keyService.ts b/electron/services/keyService.ts index 25b0aa1..4b25c88 100644 --- a/electron/services/keyService.ts +++ b/electron/services/keyService.ts @@ -684,10 +684,7 @@ export class KeyService { return { success: false, error: '获取密钥超时', logs } } - // --- Image Key (通过 DLL 从缓存目录获取 code,用前端 wxid 计算密钥) --- - private cleanWxid(wxid: string): string { - // 截断到第二个下划线: wxid_g4pshorcc0r529_da6c → wxid_g4pshorcc0r529 const first = wxid.indexOf('_') if (first === -1) return wxid const second = wxid.indexOf('_', first + 1) diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index e0d31f9..3a23acb 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -537,6 +537,32 @@ class SnsService { return raw.trim() } + private async collectSnsUsernamesFromTimeline(maxRounds: number = 2000): Promise<string[]> { + const pageSize = 500 + const uniqueUsers = new Set<string>() + let offset = 0 + + for (let round = 0; round < maxRounds; round++) { + const result = await wcdbService.getSnsTimeline(pageSize, offset, undefined, undefined, 0, 0) + if (!result.success || !Array.isArray(result.timeline)) { + throw new Error(result.error || '获取朋友圈发布者失败') + } + + const rows = result.timeline + if (rows.length === 0) break + + for (const row of rows) { + const username = this.pickTimelineUsername(row) + if (username) uniqueUsers.add(username) + } + + if (rows.length < pageSize) break + offset += rows.length + } + + return Array.from(uniqueUsers) + } + private async getExportStatsFromTimeline(myWxid?: string): Promise<{ totalPosts: number; totalFriends: number; myPosts: number | null }> { const pageSize = 500 const uniqueUsers = new Set<string>() @@ -794,7 +820,22 @@ class SnsService { if (!result.success) { return { success: false, error: result.error || '获取朋友圈联系人失败' } } - return { success: true, usernames: result.usernames || [] } + const directUsernames = Array.isArray(result.usernames) ? result.usernames : [] + if (directUsernames.length > 0) { + return { success: true, usernames: directUsernames } + } + + // 回退:通过 timeline 分页拉取收集用户名,兼容底层接口暂时返回空数组的场景。 + try { + const timelineUsers = await this.collectSnsUsernamesFromTimeline() + if (timelineUsers.length > 0) { + return { success: true, usernames: timelineUsers } + } + } catch { + // 忽略回退错误,保持与原行为一致返回空数组 + } + + return { success: true, usernames: directUsernames } } private async getExportStatsFromTableCount(myWxid?: string): Promise<{ totalPosts: number; totalFriends: number; myPosts: number | null }> { @@ -1021,14 +1062,14 @@ class SnsService { } /** - * 补全 DLL 返回的评论中缺失的 refNickname - * DLL 返回的 refCommentId 是被回复评论的 cmtid + * 补全数据服务返回的评论中缺失的 refNickname + *数据服务返回的 refCommentId 是被回复评论的 cmtid * 评论按 cmtid 从小到大排列,cmtid 从 1 开始递增 */ private fixCommentRefs(comments: any[]): any[] { if (!comments || comments.length === 0) return [] - // DLL 现在返回完整的评论数据(含 emojis、refNickname) + //数据服务现在返回完整的评论数据(含 emojis、refNickname) // 此处做最终的格式化和兜底补全 const idToNickname = new Map<string, string>() comments.forEach((c, idx) => { @@ -1099,14 +1140,14 @@ class SnsService { } : undefined })) - // DLL 已返回完整评论数据(含 emojis、refNickname) - // 如果 DLL 评论缺少表情包信息,回退到从 rawXml 重新解析 + //数据服务已返回完整评论数据(含 emojis、refNickname) + // 如果数据服务评论缺少表情包信息,回退到从 rawXml 重新解析 const dllComments: any[] = post.comments || [] const hasEmojisInDll = dllComments.some((c: any) => c.emojis && c.emojis.length > 0) let finalComments: any[] if (dllComments.length > 0 && (hasEmojisInDll || !rawXml)) { - // DLL 数据完整,直接使用 + //数据服务数据完整,直接使用 finalComments = this.fixCommentRefs(dllComments) } else if (rawXml) { // 回退:从 rawXml 重新解析(兼容旧版 DLL) @@ -1199,7 +1240,7 @@ class SnsService { return { success: false, error: result.error } } - async downloadImage(url: string, key?: string | number): Promise<{ success: boolean; data?: Buffer; contentType?: string; error?: string }> { + async downloadImage(url: string, key?: string | number): Promise<{ success: boolean; data?: Buffer; contentType?: string; cachePath?: string; error?: string }> { return this.fetchAndDecryptImage(url, key) } diff --git a/electron/services/voiceTranscribeService.ts b/electron/services/voiceTranscribeService.ts index 952bac9..5cc7804 100644 --- a/electron/services/voiceTranscribeService.ts +++ b/electron/services/voiceTranscribeService.ts @@ -76,7 +76,7 @@ export class VoiceTranscribeService { console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`) } } else if (process.platform === 'win32') { - // Windows: 把 sherpa-onnx DLL 所在目录加到 PATH,否则 native module 找不到依赖 + // Windows: 把 sherpa-onnx 所在目录加到 PATH,否则 native module 找不到依赖 const existing = env['PATH'] || '' const merged = [...candidates, ...existing.split(';').filter(Boolean)] env['PATH'] = Array.from(new Set(merged)).join(';') diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts index b5d039b..8c389c2 100644 --- a/electron/services/wcdbCore.ts +++ b/electron/services/wcdbCore.ts @@ -1,8 +1,8 @@ -import { join, dirname, basename } from 'path' +import { join, dirname, basename } from 'path' import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs' import { tmpdir } from 'os' -// DLL 初始化错误信息,用于帮助用户诊断问题 +//数据服务初始化错误信息,用于帮助用户诊断问题 let lastDllInitError: string | null = null export function getLastDllInitError(): string | null { @@ -92,6 +92,9 @@ export class WcdbCore { private wcdbResolveImageHardlinkBatch: any = null private wcdbResolveVideoHardlinkMd5: any = null private wcdbResolveVideoHardlinkMd5Batch: any = null + private wcdbInstallMessageAntiRevokeTrigger: any = null + private wcdbUninstallMessageAntiRevokeTrigger: any = null + private wcdbCheckMessageAntiRevokeTrigger: any = null private wcdbInstallSnsBlockDeleteTrigger: any = null private wcdbUninstallSnsBlockDeleteTrigger: any = null private wcdbCheckSnsBlockDeleteTrigger: any = null @@ -154,7 +157,7 @@ export class WcdbCore { return false } - // 从 DLL 获取动态管道名(含 PID) + // 从数据服务获取动态管道名(含 PID) let pipePath = '\\\\.\\pipe\\weflow_monitor' if (this.wcdbGetMonitorPipeName) { try { @@ -163,7 +166,7 @@ export class WcdbCore { pipePath = this.koffi.decode(namePtr[0], 'char', -1) this.wcdbFreeString(namePtr[0]) } - } catch {} + } catch { } } this.connectMonitorPipe(pipePath) return true @@ -181,7 +184,7 @@ export class WcdbCore { setTimeout(() => { if (!this.monitorCallback) return - this.monitorPipeClient = net.createConnection(this.monitorPipePath, () => {}) + this.monitorPipeClient = net.createConnection(this.monitorPipePath, () => { }) let buffer = '' this.monitorPipeClient.on('data', (data: Buffer) => { @@ -273,7 +276,7 @@ export class WcdbCore { const isArm64 = process.arch === 'arm64' const libName = isMac ? 'libwcdb_api.dylib' : isLinux ? 'libwcdb_api.so' : 'wcdb_api.dll' const subDir = isMac ? 'macos' : isLinux ? 'linux' : (isArm64 ? 'arm64' : '') - + const envDllPath = process.env.WCDB_DLL_PATH if (envDllPath && envDllPath.length > 0) { return envDllPath @@ -313,7 +316,7 @@ export class WcdbCore { '-2302': 'WCDB 初始化异常,请重试', '-2303': 'WCDB 未能成功初始化', } - const msg = messages[String(code) as keyof typeof messages] + const msg = messages[String(code) as unknown as keyof typeof messages] return msg ? `${msg} (错误码: ${code})` : `操作失败,错误码: ${code}` } @@ -635,15 +638,15 @@ export class WcdbCore { this.writeLog(`[bootstrap] initialize platform=${process.platform} dllPath=${dllPath} resourcesPath=${this.resourcesPath || ''} userDataPath=${this.userDataPath || ''}`, true) if (!existsSync(dllPath)) { - console.error('WCDB DLL 不存在:', dllPath) - this.writeLog(`[bootstrap] initialize failed: dll not found path=${dllPath}`, true) + console.error('WCDB数据服务不存在:', dllPath) + this.writeLog(`[bootstrap] initialize failed:数据服务not found path=${dllPath}`, true) return false } const dllDir = dirname(dllPath) const isMac = process.platform === 'darwin' const isLinux = process.platform === 'linux' - + // 预加载依赖库 if (isMac) { const wcdbCorePath = join(dllDir, 'libWCDB.dylib') @@ -691,7 +694,7 @@ export class WcdbCore { // 尝试多个可能的资源路径 const resourcePaths = [ - dllDir, // DLL 所在目录 + dllDir, //数据服务所在目录 dirname(dllDir), // 上级目录 process.resourcesPath, // 打包后 Contents/Resources process.resourcesPath ? join(process.resourcesPath as string, 'resources') : null, // Contents/Resources/resources @@ -1077,6 +1080,27 @@ export class WcdbCore { this.wcdbResolveVideoHardlinkMd5Batch = null } + // wcdb_status wcdb_install_message_anti_revoke_trigger(wcdb_handle handle, const char* session_id, char** out_error) + try { + this.wcdbInstallMessageAntiRevokeTrigger = this.lib.func('int32 wcdb_install_message_anti_revoke_trigger(int64 handle, const char* sessionId, _Out_ void** outError)') + } catch { + this.wcdbInstallMessageAntiRevokeTrigger = null + } + + // wcdb_status wcdb_uninstall_message_anti_revoke_trigger(wcdb_handle handle, const char* session_id, char** out_error) + try { + this.wcdbUninstallMessageAntiRevokeTrigger = this.lib.func('int32 wcdb_uninstall_message_anti_revoke_trigger(int64 handle, const char* sessionId, _Out_ void** outError)') + } catch { + this.wcdbUninstallMessageAntiRevokeTrigger = null + } + + // wcdb_status wcdb_check_message_anti_revoke_trigger(wcdb_handle handle, const char* session_id, int32_t* out_installed) + try { + this.wcdbCheckMessageAntiRevokeTrigger = this.lib.func('int32 wcdb_check_message_anti_revoke_trigger(int64 handle, const char* sessionId, _Out_ int32* outInstalled)') + } catch { + this.wcdbCheckMessageAntiRevokeTrigger = null + } + // wcdb_status wcdb_install_sns_block_delete_trigger(wcdb_handle handle, char** out_error) try { this.wcdbInstallSnsBlockDeleteTrigger = this.lib.func('int32 wcdb_install_sns_block_delete_trigger(int64 handle, _Out_ void** outError)') @@ -1256,7 +1280,7 @@ export class WcdbCore { } /** - * 打印 DLL 内部日志(仅在出错时调用) + * 打印数据服务内部日志(仅在出错时调用) */ private async printLogs(force = false): Promise<void> { try { @@ -1337,12 +1361,12 @@ export class WcdbCore { const raw = String(jsonStr || '') if (!raw) return [] // 热路径优化:仅在检测到 16+ 位整数字段时才进行字符串包裹,避免每批次多轮全量 replace。 - const needsInt64Normalize = /"(?:server_id|serverId|ServerId|msg_server_id|msgServerId|MsgServerId)"\s*:\s*-?\d{16,}/.test(raw) + const needsInt64Normalize = /"server_id"\s*:\s*-?\d{16,}/.test(raw) if (!needsInt64Normalize) { return JSON.parse(raw) } const normalized = raw.replace( - /("(?:server_id|serverId|ServerId|msg_server_id|msgServerId|MsgServerId)"\s*:\s*)(-?\d{16,})/g, + /("server_id"\s*:\s*)(-?\d{16,})/g, '$1"$2"' ) return JSON.parse(normalized) @@ -1579,7 +1603,7 @@ export class WcdbCore { const outPtr = [null as any] const result = this.wcdbGetSessions(this.handle, outPtr) - // DLL 调用后再次让出控制权 + //数据服务调用后再次让出控制权 await new Promise(resolve => setImmediate(resolve)) if (result !== 0 || !outPtr[0]) { @@ -1655,6 +1679,9 @@ export class WcdbCore { const outCount = [0] const result = this.wcdbGetMessageCount(this.handle, sessionId, outCount) if (result !== 0) { + if (result === -7) { + return { success: false, error: 'message schema mismatch:当前账号消息表结构与程序要求不一致' } + } return { success: false, error: `获取消息总数失败: ${result}` } } return { success: true, count: outCount[0] } @@ -1685,6 +1712,9 @@ export class WcdbCore { const sessionId = normalizedSessionIds[i] const outCount = [0] const result = this.wcdbGetMessageCount(this.handle, sessionId, outCount) + if (result === -7) { + return { success: false, error: `message schema mismatch:会话 ${sessionId} 的消息表结构不匹配` } + } counts[sessionId] = result === 0 && Number.isFinite(outCount[0]) ? Math.max(0, Math.floor(outCount[0])) : 0 if (i > 0 && i % 160 === 0) { @@ -1704,6 +1734,9 @@ export class WcdbCore { const outPtr = [null as any] const result = this.wcdbGetSessionMessageCounts(this.handle, JSON.stringify(sessionIds || []), outPtr) if (result !== 0 || !outPtr[0]) { + if (result === -7) { + return { success: false, error: 'message schema mismatch:当前账号消息表结构与程序要求不一致' } + } return { success: false, error: `获取会话消息总数失败: ${result}` } } const jsonStr = this.decodeJsonPtr(outPtr[0]) @@ -1925,7 +1958,7 @@ export class WcdbCore { const outPtr = [null as any] const result = this.wcdbGetDisplayNames(this.handle, JSON.stringify(usernames), outPtr) - // DLL 调用后再次让出控制权 + //数据服务调用后再次让出控制权 await new Promise(resolve => setImmediate(resolve)) if (result !== 0 || !outPtr[0]) { @@ -2010,7 +2043,7 @@ export class WcdbCore { const outPtr = [null as any] const result = this.wcdbGetAvatarUrls(handle, JSON.stringify(toFetch), outPtr) - // DLL 调用后再次让出控制权 + //数据服务调用后再次让出控制权 await new Promise(resolve => setImmediate(resolve)) if (result !== 0 || !outPtr[0]) { @@ -2110,7 +2143,7 @@ export class WcdbCore { return { success: false, error: 'WCDB 未连接' } } if (!this.wcdbGetGroupNicknames) { - return { success: false, error: '当前 DLL 版本不支持获取群昵称接口' } + return { success: false, error: '当前数据服务版本不支持获取群昵称接口' } } try { const outPtr = [null as any] @@ -2661,7 +2694,9 @@ export class WcdbCore { ) const hint = result === -3 ? `创建游标失败: ${result}(消息数据库未找到)。如果你最近重装过微信,请尝试重新指定数据目录后重试` - : `创建游标失败: ${result},请查看日志` + : result === -7 + ? 'message schema mismatch:当前账号消息表结构与程序要求不一致' + : `创建游标失败: ${result},请查看日志` return { success: false, error: hint } } return { success: true, cursor: outCursor[0] } @@ -2719,6 +2754,9 @@ export class WcdbCore { `openMessageCursorLite failed: sessionId=${sessionId} batchSize=${batchSize} ascending=${ascending ? 1 : 0} begin=${beginTimestamp} end=${endTimestamp} result=${result} cursor=${outCursor[0]}`, true ) + if (result === -7) { + return { success: false, error: 'message schema mismatch:当前账号消息表结构与程序要求不一致' } + } return { success: false, error: `创建游标失败: ${result},请查看日志` } } return { success: true, cursor: outCursor[0] } @@ -2790,14 +2828,14 @@ export class WcdbCore { if (!this.wcdbExecQuery) return { success: false, error: '接口未就绪' } const fallbackFlag = /fallback|diag|diagnostic/i.test(String(sql || '')) this.writeLog(`[audit:execQuery] kind=${kind} path=${path || ''} sql_len=${String(sql || '').length} fallback=${fallbackFlag ? 1 : 0}`) - + // 如果提供了参数,使用参数化查询(需要 C++ 层支持) // 注意:当前 wcdbExecQuery 可能不支持参数化,这是一个占位符实现 // TODO: 需要更新 C++ 层的 wcdb_exec_query 以支持参数绑定 if (params && params.length > 0) { console.warn('[wcdbCore] execQuery: 参数化查询暂未在 C++ 层实现,将使用原始 SQL(可能存在注入风险)') } - + const normalizedKind = String(kind || '').toLowerCase() const isContactQuery = normalizedKind === 'contact' || /\bfrom\s+contact\b/i.test(String(sql)) let effectivePath = path || '' @@ -2948,7 +2986,7 @@ export class WcdbCore { async getVoiceData(sessionId: string, createTime: number, candidates: string[], localId: number = 0, svrId: string | number = 0): Promise<{ success: boolean; hex?: string; error?: string }> { if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } - if (!this.wcdbGetVoiceData) return { success: false, error: '当前 DLL 版本不支持获取语音数据' } + if (!this.wcdbGetVoiceData) return { success: false, error: '当前数据服务版本不支持获取语音数据' } try { const outPtr = [null as any] const result = this.wcdbGetVoiceData(this.handle, sessionId, createTime, localId, BigInt(svrId || 0), JSON.stringify(candidates), outPtr) @@ -3362,7 +3400,7 @@ export class WcdbCore { async searchMessages(keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number): Promise<{ success: boolean; messages?: any[]; error?: string }> { if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } - if (!this.wcdbSearchMessages) return { success: false, error: '当前 DLL 版本不支持搜索消息' } + if (!this.wcdbSearchMessages) return { success: false, error: '当前数据服务版本不支持搜索消息' } try { const handle = this.handle await new Promise(resolve => setImmediate(resolve)) @@ -3392,7 +3430,7 @@ export class WcdbCore { async getSnsTimeline(limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: any[]; error?: string }> { if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } - if (!this.wcdbGetSnsTimeline) return { success: false, error: '当前 DLL 版本不支持获取朋友圈' } + if (!this.wcdbGetSnsTimeline) return { success: false, error: '当前数据服务版本不支持获取朋友圈' } try { const outPtr = [null as any] const usernamesJson = usernames && usernames.length > 0 ? JSON.stringify(usernames) : '' @@ -3481,12 +3519,128 @@ export class WcdbCore { return { success: false, error: String(e) } } } + + async installMessageAntiRevokeTrigger(sessionId: string): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbInstallMessageAntiRevokeTrigger) return { success: false, error: '当前数据服务版本不支持此功能' } + const normalizedSessionId = String(sessionId || '').trim() + if (!normalizedSessionId) return { success: false, error: 'sessionId 不能为空' } + try { + const outPtr = [null] + const status = this.wcdbInstallMessageAntiRevokeTrigger(this.handle, normalizedSessionId, outPtr) + let msg = '' + if (outPtr[0]) { + try { msg = this.koffi.decode(outPtr[0], 'char', -1) } catch { } + try { this.wcdbFreeString(outPtr[0]) } catch { } + } + if (status === 1) { + return { success: true, alreadyInstalled: true } + } + if (status !== 0) { + return { success: false, error: msg || `DLL error ${status}` } + } + return { success: true, alreadyInstalled: false } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async uninstallMessageAntiRevokeTrigger(sessionId: string): Promise<{ success: boolean; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbUninstallMessageAntiRevokeTrigger) return { success: false, error: '当前数据服务版本不支持此功能' } + const normalizedSessionId = String(sessionId || '').trim() + if (!normalizedSessionId) return { success: false, error: 'sessionId 不能为空' } + try { + const outPtr = [null] + const status = this.wcdbUninstallMessageAntiRevokeTrigger(this.handle, normalizedSessionId, outPtr) + let msg = '' + if (outPtr[0]) { + try { msg = this.koffi.decode(outPtr[0], 'char', -1) } catch { } + try { this.wcdbFreeString(outPtr[0]) } catch { } + } + if (status !== 0) { + return { success: false, error: msg || `DLL error ${status}` } + } + return { success: true } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async checkMessageAntiRevokeTrigger(sessionId: string): Promise<{ success: boolean; installed?: boolean; error?: string }> { + if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } + if (!this.wcdbCheckMessageAntiRevokeTrigger) return { success: false, error: '当前数据服务版本不支持此功能' } + const normalizedSessionId = String(sessionId || '').trim() + if (!normalizedSessionId) return { success: false, error: 'sessionId 不能为空' } + try { + const outInstalled = [0] + const status = this.wcdbCheckMessageAntiRevokeTrigger(this.handle, normalizedSessionId, outInstalled) + if (status !== 0) { + return { success: false, error: `DLL error ${status}` } + } + return { success: true, installed: outInstalled[0] === 1 } + } catch (e) { + return { success: false, error: String(e) } + } + } + + async checkMessageAntiRevokeTriggers(sessionIds: string[]): Promise<{ + success: boolean + rows?: Array<{ sessionId: string; success: boolean; installed?: boolean; error?: string }> + error?: string + }> { + if (!Array.isArray(sessionIds) || sessionIds.length === 0) { + return { success: true, rows: [] } + } + const uniqueIds = Array.from(new Set(sessionIds.map((id) => String(id || '').trim()).filter(Boolean))) + const rows: Array<{ sessionId: string; success: boolean; installed?: boolean; error?: string }> = [] + for (const sessionId of uniqueIds) { + const result = await this.checkMessageAntiRevokeTrigger(sessionId) + rows.push({ sessionId, success: result.success, installed: result.installed, error: result.error }) + } + return { success: true, rows } + } + + async installMessageAntiRevokeTriggers(sessionIds: string[]): Promise<{ + success: boolean + rows?: Array<{ sessionId: string; success: boolean; alreadyInstalled?: boolean; error?: string }> + error?: string + }> { + if (!Array.isArray(sessionIds) || sessionIds.length === 0) { + return { success: true, rows: [] } + } + const uniqueIds = Array.from(new Set(sessionIds.map((id) => String(id || '').trim()).filter(Boolean))) + const rows: Array<{ sessionId: string; success: boolean; alreadyInstalled?: boolean; error?: string }> = [] + for (const sessionId of uniqueIds) { + const result = await this.installMessageAntiRevokeTrigger(sessionId) + rows.push({ sessionId, success: result.success, alreadyInstalled: result.alreadyInstalled, error: result.error }) + } + return { success: true, rows } + } + + async uninstallMessageAntiRevokeTriggers(sessionIds: string[]): Promise<{ + success: boolean + rows?: Array<{ sessionId: string; success: boolean; error?: string }> + error?: string + }> { + if (!Array.isArray(sessionIds) || sessionIds.length === 0) { + return { success: true, rows: [] } + } + const uniqueIds = Array.from(new Set(sessionIds.map((id) => String(id || '').trim()).filter(Boolean))) + const rows: Array<{ sessionId: string; success: boolean; error?: string }> = [] + for (const sessionId of uniqueIds) { + const result = await this.uninstallMessageAntiRevokeTrigger(sessionId) + rows.push({ sessionId, success: result.success, error: result.error }) + } + return { success: true, rows } + } + /** * 为朋友圈安装删除 */ async installSnsBlockDeleteTrigger(): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> { if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } - if (!this.wcdbInstallSnsBlockDeleteTrigger) return { success: false, error: '当前 DLL 版本不支持此功能' } + if (!this.wcdbInstallSnsBlockDeleteTrigger) return { success: false, error: '当前数据服务版本不支持此功能' } try { const outPtr = [null] const status = this.wcdbInstallSnsBlockDeleteTrigger(this.handle, outPtr) @@ -3496,7 +3650,7 @@ export class WcdbCore { try { this.wcdbFreeString(outPtr[0]) } catch { } } if (status === 1) { - // DLL 返回 1 表示已安装 + //数据服务返回 1 表示已安装 return { success: true, alreadyInstalled: true } } if (status !== 0) { @@ -3513,7 +3667,7 @@ export class WcdbCore { */ async uninstallSnsBlockDeleteTrigger(): Promise<{ success: boolean; error?: string }> { if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } - if (!this.wcdbUninstallSnsBlockDeleteTrigger) return { success: false, error: '当前 DLL 版本不支持此功能' } + if (!this.wcdbUninstallSnsBlockDeleteTrigger) return { success: false, error: '当前数据服务版本不支持此功能' } try { const outPtr = [null] const status = this.wcdbUninstallSnsBlockDeleteTrigger(this.handle, outPtr) @@ -3536,7 +3690,7 @@ export class WcdbCore { */ async checkSnsBlockDeleteTrigger(): Promise<{ success: boolean; installed?: boolean; error?: string }> { if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } - if (!this.wcdbCheckSnsBlockDeleteTrigger) return { success: false, error: '当前 DLL 版本不支持此功能' } + if (!this.wcdbCheckSnsBlockDeleteTrigger) return { success: false, error: '当前数据服务版本不支持此功能' } try { const outInstalled = [0] const status = this.wcdbCheckSnsBlockDeleteTrigger(this.handle, outInstalled) @@ -3551,7 +3705,7 @@ export class WcdbCore { async deleteSnsPost(postId: string): Promise<{ success: boolean; error?: string }> { if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } - if (!this.wcdbDeleteSnsPost) return { success: false, error: '当前 DLL 版本不支持此功能' } + if (!this.wcdbDeleteSnsPost) return { success: false, error: '当前数据服务版本不支持此功能' } try { const outPtr = [null] const status = this.wcdbDeleteSnsPost(this.handle, postId, outPtr) diff --git a/electron/services/wcdbService.ts b/electron/services/wcdbService.ts index f52de6c..9516922 100644 --- a/electron/services/wcdbService.ts +++ b/electron/services/wcdbService.ts @@ -80,7 +80,7 @@ export class WcdbService { // Worker 退出,需要 reject 所有 pending promises if (code !== 0) { console.error('WCDB Worker 异常退出,退出码:', code) - const errorMsg = `Worker 异常退出 (退出码: ${code})。可能是 DLL 加载失败,请检查是否安装了 Visual C++ Redistributable。` + const errorMsg = `Worker 异常退出 (退出码: ${code})。可能是数据服务加载失败,请检查是否安装了 Visual C++ Redistributable。` for (const [id, p] of this.pending) { p.reject(new Error(errorMsg)) } @@ -467,7 +467,7 @@ export class WcdbService { } /** - * 获取表情包释义(严格 DLL 接口) + * 获取表情包释义(严格数据服务接口) */ async getEmoticonCaptionStrict(md5: string): Promise<{ success: boolean; caption?: string; error?: string }> { return this.callWorker('getEmoticonCaptionStrict', { md5 }) @@ -561,6 +561,24 @@ export class WcdbService { return this.callWorker('getSnsExportStats', { myWxid }) } + async checkMessageAntiRevokeTriggers( + sessionIds: string[] + ): Promise<{ success: boolean; rows?: Array<{ sessionId: string; success: boolean; installed?: boolean; error?: string }>; error?: string }> { + return this.callWorker('checkMessageAntiRevokeTriggers', { sessionIds }) + } + + async installMessageAntiRevokeTriggers( + sessionIds: string[] + ): Promise<{ success: boolean; rows?: Array<{ sessionId: string; success: boolean; alreadyInstalled?: boolean; error?: string }>; error?: string }> { + return this.callWorker('installMessageAntiRevokeTriggers', { sessionIds }) + } + + async uninstallMessageAntiRevokeTriggers( + sessionIds: string[] + ): Promise<{ success: boolean; rows?: Array<{ sessionId: string; success: boolean; error?: string }>; error?: string }> { + return this.callWorker('uninstallMessageAntiRevokeTriggers', { sessionIds }) + } + /** * 安装朋友圈删除拦截 */ @@ -590,7 +608,7 @@ export class WcdbService { } /** - * 获取 DLL 内部日志 + * 获取数据服务内部日志 */ async getLogs(): Promise<{ success: boolean; logs?: string[]; error?: string }> { return this.callWorker('getLogs') diff --git a/electron/wcdbWorker.ts b/electron/wcdbWorker.ts index 898084d..57b1045 100644 --- a/electron/wcdbWorker.ts +++ b/electron/wcdbWorker.ts @@ -230,6 +230,15 @@ if (parentPort) { case 'getSnsExportStats': result = await core.getSnsExportStats(payload.myWxid) break + case 'checkMessageAntiRevokeTriggers': + result = await core.checkMessageAntiRevokeTriggers(payload.sessionIds) + break + case 'installMessageAntiRevokeTriggers': + result = await core.installMessageAntiRevokeTriggers(payload.sessionIds) + break + case 'uninstallMessageAntiRevokeTriggers': + result = await core.uninstallMessageAntiRevokeTriggers(payload.sessionIds) + break case 'installSnsBlockDeleteTrigger': result = await core.installSnsBlockDeleteTrigger() break diff --git a/package-lock.json b/package-lock.json index 0ae8f81..8fa56e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "echarts": "^6.0.0", "echarts-for-react": "^3.0.2", - "electron-store": "^10.0.0", + "electron-store": "^11.0.2", "electron-updater": "^6.3.9", "exceljs": "^4.4.0", "ffmpeg-static": "^5.3.0", @@ -24,7 +24,7 @@ "react": "^19.2.3", "react-dom": "^19.2.3", "react-markdown": "^10.1.0", - "react-router-dom": "^7.13.2", + "react-router-dom": "^7.14.0", "react-virtuoso": "^4.18.1", "remark-gfm": "^4.0.1", "sherpa-onnx-node": "^1.10.38", @@ -38,7 +38,7 @@ "@types/react": "^19.1.0", "@types/react-dom": "^19.1.0", "@vitejs/plugin-react": "^4.3.4", - "electron": "^39.2.7", + "electron": "^41.1.1", "electron-builder": "^26.8.1", "sass": "^1.98.0", "sharp": "^0.34.5", @@ -2948,13 +2948,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.19.15", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", - "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "version": "24.12.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", + "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": "~7.16.0" } }, "node_modules/@types/plist": { @@ -4260,20 +4260,20 @@ } }, "node_modules/conf": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/conf/-/conf-14.0.0.tgz", - "integrity": "sha512-L6BuueHTRuJHQvQVc6YXYZRtN5vJUtOdCTLn0tRYYV5azfbAFcPghB5zEE40mVrV6w7slMTqUfkDomutIK14fw==", + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/conf/-/conf-15.1.0.tgz", + "integrity": "sha512-Uy5YN9KEu0WWDaZAVJ5FAmZoaJt9rdK6kH+utItPyGsCqCgaTKkrmZx3zoE0/3q6S3bcp3Ihkk+ZqPxWxFK5og==", "license": "MIT", "dependencies": { "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "atomically": "^2.0.3", "debounce-fn": "^6.0.0", - "dot-prop": "^9.0.0", + "dot-prop": "^10.0.0", "env-paths": "^3.0.0", "json-schema-typed": "^8.0.1", "semver": "^7.7.2", - "uint8array-extras": "^1.4.0" + "uint8array-extras": "^1.5.0" }, "engines": { "node": ">=20" @@ -4733,15 +4733,15 @@ } }, "node_modules/dot-prop": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-9.0.0.tgz", - "integrity": "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-10.1.0.tgz", + "integrity": "sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q==", "license": "MIT", "dependencies": { - "type-fest": "^4.18.2" + "type-fest": "^5.0.0" }, "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -4878,15 +4878,15 @@ } }, "node_modules/electron": { - "version": "39.8.6", - "resolved": "https://registry.npmjs.org/electron/-/electron-39.8.6.tgz", - "integrity": "sha512-uWX6Jh5LmwL13VwOSKBjebI+ck+03GOwc8V2Sgbmr9pJVJ/cHfli/PkjXuRDr+hq+SLHQuT9mGHSIfScebApRA==", + "version": "41.1.1", + "resolved": "https://registry.npmjs.org/electron/-/electron-41.1.1.tgz", + "integrity": "sha512-8bgvDhBjli+3Z2YCKgzzoBPh6391pr7Xv2h/tTJG4ETgvPvUxZomObbZLs31mUzYb6VrlcDDd9cyWyNKtPm3tA==", "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { "@electron/get": "^2.0.0", - "@types/node": "^22.7.7", + "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { @@ -5029,13 +5029,13 @@ } }, "node_modules/electron-store": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/electron-store/-/electron-store-10.1.0.tgz", - "integrity": "sha512-oL8bRy7pVCLpwhmXy05Rh/L6O93+k9t6dqSw0+MckIc3OmCTZm6Mp04Q4f/J0rtu84Ky6ywkR8ivtGOmrq+16w==", + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/electron-store/-/electron-store-11.0.2.tgz", + "integrity": "sha512-4VkNRdN+BImL2KcCi41WvAYbh6zLX5AUTi4so68yPqiItjbgTjqpEnGAqasgnG+lB6GuAyUltKwVopp6Uv+gwQ==", "license": "MIT", "dependencies": { - "conf": "^14.0.0", - "type-fest": "^4.41.0" + "conf": "^15.0.2", + "type-fest": "^5.0.1" }, "engines": { "node": ">=20" @@ -8522,9 +8522,9 @@ } }, "node_modules/react-router": { - "version": "7.13.2", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.2.tgz", - "integrity": "sha512-tX1Aee+ArlKQP+NIUd7SE6Li+CiGKwQtbS+FfRxPX6Pe4vHOo6nr9d++u5cwg+Z8K/x8tP+7qLmujDtfrAoUJA==", + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.0.tgz", + "integrity": "sha512-m/xR9N4LQLmAS0ZhkY2nkPA1N7gQ5TUVa5n8TgANuDTARbn1gt+zLPXEm7W0XDTbrQ2AJSJKhoa6yx1D8BcpxQ==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -8544,12 +8544,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.13.2", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.2.tgz", - "integrity": "sha512-aR7SUORwTqAW0JDeiWF07e9SBE9qGpByR9I8kJT5h/FrBKxPMS6TiC7rmVO+gC0q52Bx7JnjWe8Z1sR9faN4YA==", + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.0.tgz", + "integrity": "sha512-2G3ajSVSZMEtmTjIklRWlNvo8wICEpLihfD/0YMDxbWK2UyP5EGfnoIn9AIQGnF3G/FX0MRbHXdFcD+rL1ZreQ==", "license": "MIT", "dependencies": { - "react-router": "7.13.2" + "react-router": "7.14.0" }, "engines": { "node": ">=20.0.0" @@ -9489,6 +9489,18 @@ "node": ">=8" } }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tar": { "version": "7.5.13", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", @@ -9713,12 +9725,15 @@ "license": "0BSD" }, "node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz", + "integrity": "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==", "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, "engines": { - "node": ">=16" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -9757,9 +9772,9 @@ } }, "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index 0f5d54e..5361a0e 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "dependencies": { "echarts": "^6.0.0", "echarts-for-react": "^3.0.2", - "electron-store": "^10.0.0", + "electron-store": "^11.0.2", "electron-updater": "^6.3.9", "exceljs": "^4.4.0", "ffmpeg-static": "^5.3.0", @@ -38,7 +38,7 @@ "react": "^19.2.3", "react-dom": "^19.2.3", "react-markdown": "^10.1.0", - "react-router-dom": "^7.13.2", + "react-router-dom": "^7.14.0", "react-virtuoso": "^4.18.1", "remark-gfm": "^4.0.1", "sherpa-onnx-node": "^1.10.38", @@ -52,7 +52,7 @@ "@types/react": "^19.1.0", "@types/react-dom": "^19.1.0", "@vitejs/plugin-react": "^4.3.4", - "electron": "^39.2.7", + "electron": "^41.1.1", "electron-builder": "^26.8.1", "sass": "^1.98.0", "sharp": "^0.34.5", @@ -70,7 +70,9 @@ "lodash": ">=4.17.21", "brace-expansion": ">=1.1.11", "picomatch": ">=2.3.1", - "ajv": ">=8.18.0" + "ajv": ">=8.18.0", + "ajv-keywords@3>ajv": "^6.12.6", + "@develar/schema-utils>ajv": "^6.12.6" } }, "build": { diff --git a/resources/arm64/wcdb_api.dll b/resources/arm64/wcdb_api.dll index de6bce8..78747ac 100644 Binary files a/resources/arm64/wcdb_api.dll and b/resources/arm64/wcdb_api.dll differ diff --git a/resources/linux/libwcdb_api.so b/resources/linux/libwcdb_api.so index d3c686a..0fa218c 100755 Binary files a/resources/linux/libwcdb_api.so and b/resources/linux/libwcdb_api.so differ diff --git a/resources/macos/libwcdb_api.dylib b/resources/macos/libwcdb_api.dylib index d185cfc..26b44d2 100755 Binary files a/resources/macos/libwcdb_api.dylib and b/resources/macos/libwcdb_api.dylib differ diff --git a/resources/wcdb_api.dll b/resources/wcdb_api.dll index c9609ad..2ba487b 100644 Binary files a/resources/wcdb_api.dll and b/resources/wcdb_api.dll differ diff --git a/resources/wcdb_api_arm64.dll b/resources/wcdb_api_arm64.dll deleted file mode 100644 index de6bce8..0000000 Binary files a/resources/wcdb_api_arm64.dll and /dev/null differ diff --git a/src/App.tsx b/src/App.tsx index d5f2512..92d0a93 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,7 @@ import ExportPage from './pages/ExportPage' import VideoWindow from './pages/VideoWindow' import ImageWindow from './pages/ImageWindow' import SnsPage from './pages/SnsPage' +import BizPage from './pages/BizPage' import ContactsPage from './pages/ContactsPage' import ChatHistoryPage from './pages/ChatHistoryPage' import NotificationWindow from './pages/NotificationWindow' @@ -429,7 +430,7 @@ function App() { } } else { - // 如果错误信息包含 VC++ 或 DLL 相关内容,不清除配置,只提示用户 + // 如果错误信息包含 VC++ 或数据服务相关内容,不清除配置,只提示用户 // 其他错误可能需要重新配置 const errorMsg = result.error || '' if (errorMsg.includes('Visual C++') || @@ -590,9 +591,13 @@ function App() { <div className="agreement-notice"> <strong>这是免费软件,如果你是付费购买的话请骂死那个骗子。</strong> <span className="agreement-notice-link"> - 我们唯一的官方网站: + 官方网站: + <a href="https://weflow.top" target="_blank" rel="noreferrer"> + https://weflow.top + </a> +  ·  <a href="https://github.com/hicccc77/WeFlow" target="_blank" rel="noreferrer"> - https://github.com/hicccc77/WeFlow + GitHub 仓库 </a> </span> </div> @@ -607,7 +612,7 @@ function App() { <p>因使用本软件产生的任何直接或间接损失,开发者不承担任何责任。请确保你的使用行为符合当地法律法规。</p> <h4>4. 隐私保护</h4> - <p>本软件不收集任何用户数据。软件更新检测仅获取版本信息,不涉及任何个人隐私。</p> + <p>本软件不收集任何用户隐私数据。软件更新检测仅获取版本信息,不涉及任何个人隐私。</p> </div> </div> <div className="agreement-footer"> @@ -665,30 +670,30 @@ function App() { )} {showWaylandWarning && ( - <div className="agreement-overlay"> - <div className="agreement-modal"> - <div className="agreement-header"> - <Shield size={32} /> - <h2>环境兼容性提示 (Wayland)</h2> + <div className="agreement-overlay"> + <div className="agreement-modal"> + <div className="agreement-header"> + <Shield size={32} /> + <h2>环境兼容性提示 (Wayland)</h2> + </div> + <div className="agreement-content"> + <div className="agreement-text"> + <p>检测到您当前正在使用 <strong>Wayland</strong> 显示服务器。</p> + <p>在 Wayland 环境下,出于系统级的安全与设计机制,<strong>应用程序无法直接控制新弹出窗口的位置</strong>。</p> + <p>这可能导致某些独立窗口(如消息通知、图片查看器等)出现位置随机、或不受控制的情况。这是底层机制导致的,对此我们无能为力。</p> + <br /> + <p>如果您觉得窗口位置异常严重影响了使用体验,建议尝试:</p> + <p>1. 在系统登录界面,将会话切换回 <strong>X11 (Xorg)</strong> 模式。</p> + <p>2. 修改您的桌面管理器 (WM/DE) 配置,强制指定该应用程序的窗口规则。</p> </div> - <div className="agreement-content"> - <div className="agreement-text"> - <p>检测到您当前正在使用 <strong>Wayland</strong> 显示服务器。</p> - <p>在 Wayland 环境下,出于系统级的安全与设计机制,<strong>应用程序无法直接控制新弹出窗口的位置</strong>。</p> - <p>这可能导致某些独立窗口(如消息通知、图片查看器等)出现位置随机、或不受控制的情况。这是底层机制导致的,对此我们无能为力。</p> - <br /> - <p>如果您觉得窗口位置异常严重影响了使用体验,建议尝试:</p> - <p>1. 在系统登录界面,将会话切换回 <strong>X11 (Xorg)</strong> 模式。</p> - <p>2. 修改您的桌面管理器 (WM/DE) 配置,强制指定该应用程序的窗口规则。</p> - </div> - </div> - <div className="agreement-footer"> - <div className="agreement-actions"> - <button className="btn btn-primary" onClick={handleDismissWaylandWarning}>我知道了,不再提示</button> - </div> + </div> + <div className="agreement-footer"> + <div className="agreement-actions"> + <button className="btn btn-primary" onClick={handleDismissWaylandWarning}>我知道了,不再提示</button> </div> </div> </div> + </div> )} {/* 更新提示对话框 */} @@ -736,6 +741,7 @@ function App() { <Route path="/export" element={<div className="export-route-anchor" aria-hidden="true" />} /> <Route path="/sns" element={<SnsPage />} /> + <Route path="/biz" element={<BizPage />} /> <Route path="/contacts" element={<ContactsPage />} /> <Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} /> <Route path="/chat-history-inline/:payloadId" element={<ChatHistoryPage />} /> diff --git a/src/components/Export/ExportDefaultsSettingsForm.tsx b/src/components/Export/ExportDefaultsSettingsForm.tsx index 17090e2..6824e5b 100644 --- a/src/components/Export/ExportDefaultsSettingsForm.tsx +++ b/src/components/Export/ExportDefaultsSettingsForm.tsx @@ -66,7 +66,8 @@ export function ExportDefaultsSettingsForm({ images: true, videos: true, voices: true, - emojis: true + emojis: true, + files: true }) const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false) const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true) @@ -94,7 +95,8 @@ export function ExportDefaultsSettingsForm({ images: true, videos: true, voices: true, - emojis: true + emojis: true, + files: true }) setExportDefaultVoiceAsText(savedVoiceAsText ?? false) setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true) @@ -292,7 +294,7 @@ export function ExportDefaultsSettingsForm({ <div className="form-group media-setting-group"> <div className="form-copy"> <label>默认导出媒体内容</label> - <span className="form-hint">控制图片、视频、语音、表情包的默认导出开关</span> + <span className="form-hint">控制图片、视频、语音、表情包、文件的默认导出开关</span> </div> <div className="form-control"> <div className="media-default-grid"> @@ -352,6 +354,20 @@ export function ExportDefaultsSettingsForm({ /> 表情包 </label> + <label> + <input + type="checkbox" + checked={exportDefaultMedia.files} + onChange={async (e) => { + const next = { ...exportDefaultMedia, files: e.target.checked } + setExportDefaultMedia(next) + await configService.setExportDefaultMedia(next) + onDefaultsChanged?.({ media: next }) + notify(`已${e.target.checked ? '开启' : '关闭'}默认导出文件`, true) + }} + /> + 文件 + </label> </div> </div> </div> diff --git a/src/pages/AnnualReportWindow.tsx b/src/pages/AnnualReportWindow.tsx index ab5fd0d..5b2d510 100644 --- a/src/pages/AnnualReportWindow.tsx +++ b/src/pages/AnnualReportWindow.tsx @@ -8,44 +8,9 @@ import { registerBackgroundTask, updateBackgroundTask } from '../services/backgroundTaskMonitor' +import { drawPatternBackground } from '../utils/reportExport' import './AnnualReportWindow.scss' -// SVG 背景图案 (用于导出) -const PATTERN_LIGHT_SVG = `<svg xmlns='http://www.w3.org/2000/svg' width='400' height='400' viewBox='0 0 400 400'><defs><style>.a{fill:none;stroke:#000;stroke-width:1.2;opacity:0.045}.b{fill:none;stroke:#000;stroke-width:1;opacity:0.035}.c{fill:none;stroke:#000;stroke-width:0.8;opacity:0.04}</style></defs><g transform='translate(45,35) rotate(-8)'><circle class='a' cx='0' cy='0' r='16'/><circle class='a' cx='-5' cy='-4' r='2.5'/><circle class='a' cx='5' cy='-4' r='2.5'/><path class='a' d='M-8 4 Q0 12 8 4'/></g><g transform='translate(320,28) rotate(15) scale(0.7)'><path class='b' d='M0 -12 l3 9 9 0 -7 5 3 9 -8 -6 -8 6 3 -9 -7 -5 9 0z'/></g><g transform='translate(180,55) rotate(12)'><path class='a' d='M0 -8 C0 -14 8 -17 12 -10 C16 -17 24 -14 24 -8 C24 4 12 14 12 14 C12 14 0 4 0 -8'/></g><g transform='translate(95,120) rotate(-5) scale(1.1)'><path class='b' d='M0 10 Q-8 10 -8 3 Q-8 -4 0 -4 Q0 -12 10 -12 Q22 -12 22 -2 Q30 -2 30 5 Q30 12 22 12 Z'/></g><g transform='translate(355,95) rotate(8)'><path class='c' d='M0 0 L0 18 M0 0 L18 -4 L18 14'/><ellipse class='c' cx='-4' cy='20' rx='6' ry='4'/><ellipse class='c' cx='14' cy='16' rx='6' ry='4'/></g><g transform='translate(250,110) rotate(-12) scale(0.9)'><rect class='b' x='0' y='0' width='26' height='18' rx='2'/><path class='b' d='M0 2 L13 11 L26 2'/></g><g transform='translate(28,195) rotate(6)'><circle class='a' cx='0' cy='0' r='11'/><path class='a' d='M-5 11 L5 11 M-4 14 L4 14'/><path class='c' d='M-3 -2 L0 -6 L3 -2'/></g><g transform='translate(155,175) rotate(-3) scale(0.85)'><path class='b' d='M0 0 L0 28 Q14 22 28 28 L28 0 Q14 6 0 0'/><path class='b' d='M28 0 L28 28 Q42 22 56 28 L56 0 Q42 6 28 0'/></g><g transform='translate(340,185) rotate(-20) scale(1.2)'><path class='a' d='M0 8 L20 0 L5 6 L8 14 L5 6 L-12 12 Z'/></g><g transform='translate(70,280) rotate(5)'><rect class='b' x='0' y='5' width='30' height='22' rx='4'/><circle class='b' cx='15' cy='16' r='7'/><rect class='b' x='8' y='0' width='14' height='6' rx='2'/></g><g transform='translate(230,250) rotate(-8) scale(1.1)'><rect class='a' x='0' y='6' width='22' height='18' rx='2'/><rect class='a' x='-3' y='0' width='28' height='7' rx='2'/><path class='a' d='M11 0 L11 24 M-3 13 L25 13'/></g><g transform='translate(365,280) rotate(10)'><ellipse class='b' cx='0' cy='0' rx='10' ry='14'/><path class='b' d='M0 14 Q-3 20 0 28 Q2 24 -1 20'/></g><g transform='translate(145,310) rotate(-6)'><path class='c' d='M0 0 L4 28 L24 28 L28 0 Z'/><path class='c' d='M28 6 Q40 6 40 16 Q40 24 28 24'/><path class='c' d='M8 8 Q10 4 12 8'/></g><g transform='translate(310,340) rotate(5) scale(0.9)'><path class='a' d='M0 8 L8 0 L24 0 L32 8 L16 28 Z'/><path class='a' d='M8 0 L12 8 L0 8 M24 0 L20 8 L32 8 M12 8 L16 28 L20 8'/></g><g transform='translate(55,365) rotate(25) scale(1.15)'><path class='a' d='M8 0 Q12 -14 16 0 L14 6 L18 12 L12 9 L6 12 L10 6 Z'/><circle class='c' cx='12' cy='-2' r='2'/></g><g transform='translate(200,375) rotate(-4)'><path class='b' d='M0 12 Q0 -8 24 -8 Q48 -8 48 12'/><path class='c' d='M6 12 Q6 -2 24 -2 Q42 -2 42 12'/><path class='c' d='M12 12 Q12 4 24 4 Q36 4 36 12'/></g><g transform='translate(380,375) rotate(-10)'><circle class='a' cx='0' cy='0' r='8'/><path class='c' d='M0 -14 L0 -10 M0 10 L0 14 M-14 0 L-10 0 M10 0 L14 0 M-10 -10 L-7 -7 M7 7 L10 10 M-10 10 L-7 7 M7 -7 L10 -10'/></g></svg>` - -const PATTERN_DARK_SVG = `<svg xmlns='http://www.w3.org/2000/svg' width='400' height='400' viewBox='0 0 400 400'><defs><style>.a{fill:none;stroke:#fff;stroke-width:1.2;opacity:0.055}.b{fill:none;stroke:#fff;stroke-width:1;opacity:0.045}.c{fill:none;stroke:#fff;stroke-width:0.8;opacity:0.05}</style></defs><g transform='translate(45,35) rotate(-8)'><circle class='a' cx='0' cy='0' r='16'/><circle class='a' cx='-5' cy='-4' r='2.5'/><circle class='a' cx='5' cy='-4' r='2.5'/><path class='a' d='M-8 4 Q0 12 8 4'/></g><g transform='translate(320,28) rotate(15) scale(0.7)'><path class='b' d='M0 -12 l3 9 9 0 -7 5 3 9 -8 -6 -8 6 3 -9 -7 -5 9 0z'/></g><g transform='translate(180,55) rotate(12)'><path class='a' d='M0 -8 C0 -14 8 -17 12 -10 C16 -17 24 -14 24 -8 C24 4 12 14 12 14 C12 14 0 4 0 -8'/></g><g transform='translate(95,120) rotate(-5) scale(1.1)'><path class='b' d='M0 10 Q-8 10 -8 3 Q-8 -4 0 -4 Q0 -12 10 -12 Q22 -12 22 -2 Q30 -2 30 5 Q30 12 22 12 Z'/></g><g transform='translate(355,95) rotate(8)'><path class='c' d='M0 0 L0 18 M0 0 L18 -4 L18 14'/><ellipse class='c' cx='-4' cy='20' rx='6' ry='4'/><ellipse class='c' cx='14' cy='16' rx='6' ry='4'/></g><g transform='translate(250,110) rotate(-12) scale(0.9)'><rect class='b' x='0' y='0' width='26' height='18' rx='2'/><path class='b' d='M0 2 L13 11 L26 2'/></g><g transform='translate(28,195) rotate(6)'><circle class='a' cx='0' cy='0' r='11'/><path class='a' d='M-5 11 L5 11 M-4 14 L4 14'/><path class='c' d='M-3 -2 L0 -6 L3 -2'/></g><g transform='translate(155,175) rotate(-3) scale(0.85)'><path class='b' d='M0 0 L0 28 Q14 22 28 28 L28 0 Q14 6 0 0'/><path class='b' d='M28 0 L28 28 Q42 22 56 28 L56 0 Q42 6 28 0'/></g><g transform='translate(340,185) rotate(-20) scale(1.2)'><path class='a' d='M0 8 L20 0 L5 6 L8 14 L5 6 L-12 12 Z'/></g><g transform='translate(70,280) rotate(5)'><rect class='b' x='0' y='5' width='30' height='22' rx='4'/><circle class='b' cx='15' cy='16' r='7'/><rect class='b' x='8' y='0' width='14' height='6' rx='2'/></g><g transform='translate(230,250) rotate(-8) scale(1.1)'><rect class='a' x='0' y='6' width='22' height='18' rx='2'/><rect class='a' x='-3' y='0' width='28' height='7' rx='2'/><path class='a' d='M11 0 L11 24 M-3 13 L25 13'/></g><g transform='translate(365,280) rotate(10)'><ellipse class='b' cx='0' cy='0' rx='10' ry='14'/><path class='b' d='M0 14 Q-3 20 0 28 Q2 24 -1 20'/></g><g transform='translate(145,310) rotate(-6)'><path class='c' d='M0 0 L4 28 L24 28 L28 0 Z'/><path class='c' d='M28 6 Q40 6 40 16 Q40 24 28 24'/><path class='c' d='M8 8 Q10 4 12 8'/></g><g transform='translate(310,340) rotate(5) scale(0.9)'><path class='a' d='M0 8 L8 0 L24 0 L32 8 L16 28 Z'/><path class='a' d='M8 0 L12 8 L0 8 M24 0 L20 8 L32 8 M12 8 L16 28 L20 8'/></g><g transform='translate(55,365) rotate(25) scale(1.15)'><path class='a' d='M8 0 Q12 -14 16 0 L14 6 L18 12 L12 9 L6 12 L10 6 Z'/><circle class='c' cx='12' cy='-2' r='2'/></g><g transform='translate(200,375) rotate(-4)'><path class='b' d='M0 12 Q0 -8 24 -8 Q48 -8 48 12'/><path class='c' d='M6 12 Q6 -2 24 -2 Q42 -2 42 12'/><path class='c' d='M12 12 Q12 4 24 4 Q36 4 36 12'/></g><g transform='translate(380,375) rotate(-10)'><circle class='a' cx='0' cy='0' r='8'/><path class='c' d='M0 -14 L0 -10 M0 10 L0 14 M-14 0 L-10 0 M10 0 L14 0 M-10 -10 L-7 -7 M7 7 L10 10 M-10 10 L-7 7 M7 -7 L10 -10'/></g></svg>` - -// 绘制 SVG 图案背景到 canvas -const drawPatternBackground = async (ctx: CanvasRenderingContext2D, width: number, height: number, bgColor: string, isDark: boolean) => { - // 先填充背景色 - ctx.fillStyle = bgColor - ctx.fillRect(0, 0, width, height) - - // 加载 SVG 图案 - const svgString = isDark ? PATTERN_DARK_SVG : PATTERN_LIGHT_SVG - const blob = new Blob([svgString], { type: 'image/svg+xml' }) - const url = URL.createObjectURL(blob) - - return new Promise<void>((resolve) => { - const img = new window.Image() - img.onload = () => { - // 平铺绘制图案 - const pattern = ctx.createPattern(img, 'repeat') - if (pattern) { - ctx.fillStyle = pattern - ctx.fillRect(0, 0, width, height) - } - URL.revokeObjectURL(url) - resolve() - } - img.onerror = () => { - URL.revokeObjectURL(url) - resolve() - } - img.src = url - }) -} - interface TopContact { username: string displayName: string diff --git a/src/pages/BizPage.scss b/src/pages/BizPage.scss new file mode 100644 index 0000000..a2faddb --- /dev/null +++ b/src/pages/BizPage.scss @@ -0,0 +1,360 @@ +.biz-account-list { + flex: 1; + overflow-y: auto; + background-color: var(--bg-secondary); // 对齐会话列表背景 + + .biz-loading { + padding: 20px; + text-align: center; + font-size: 12px; + color: var(--text-tertiary); + } + + .biz-account-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + cursor: pointer; + transition: all 0.2s; + border-bottom: 1px solid var(--border-color); + + &:hover { + background-color: var(--bg-hover); + } + + &.active { + background-color: var(--primary-light) !important; + border-left: 3px solid var(--primary); + padding-left: 13px; // 补偿 border-left + } + + &.pay-account { + background-color: var(--bg-primary); + &.active { + background-color: var(--primary-light) !important; + border-left: 3px solid var(--primary); + } + } + + .biz-avatar { + width: 48px; + height: 48px; + border-radius: 8px; // 对齐会话列表头像圆角 + object-fit: cover; + flex-shrink: 0; + background-color: var(--bg-tertiary); + } + + .biz-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; + + .biz-info-top { + display: flex; + justify-content: space-between; + align-items: center; + + .biz-name { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .biz-time { + font-size: 11px; + color: var(--text-tertiary); + flex-shrink: 0; + } + } + + .biz-badge { + font-size: 10px; + padding: 1px 6px; + border-radius: 4px; + width: fit-content; + margin-top: 2px; + + &.type-service { color: #07c160; background: rgba(7, 193, 96, 0.1); } + &.type-sub { color: var(--primary); background: var(--primary-light); } + &.type-enterprise { color: #f5222d; background: rgba(245, 34, 45, 0.1); } + &.type-unknown { color: var(--text-tertiary); background: var(--bg-tertiary); } + } + } + } +} + +.biz-main { + height: 100%; + display: flex; + flex-direction: column; + background-color: var(--bg-secondary); // 对齐聊天页背景 + + .main-header { + height: 56px; + padding: 0 20px; + display: flex; + align-items: center; + border-bottom: 1px solid var(--border-color); + background-color: var(--card-bg); + flex-shrink: 0; + + h2 { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + } + } + + .message-container { + flex: 1; + overflow-y: auto; + padding: 24px 16px; + background: var(--chat-pattern); + background-color: var(--bg-tertiary); // 对齐聊天背景色 + + .messages-wrapper { + width: 100%; + max-width: 600px; + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 16px; // 减小间距,因为有了 time-divider + } + } + + .time-divider { + text-align: center; + margin: 16px 0 8px; + + span { + display: inline-block; + padding: 2px 8px; + background-color: var(--bg-primary); + color: var(--text-tertiary); + font-size: 11px; + border-radius: 4px; + opacity: 0.8; + } + } + + // 占位状态:对齐 Chat 页面风格 + .biz-no-record-container { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 20px; + text-align: center; + background: var(--bg-tertiary); + + .no-record-icon { + width: 64px; + height: 64px; + border-radius: 50%; + background-color: var(--bg-secondary); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 20px; + color: var(--text-tertiary); + opacity: 0.5; + + svg { width: 32px; height: 32px; } + } + + h3 { + font-size: 16px; + font-weight: 500; + color: var(--text-primary); + margin-bottom: 8px; + } + + p { + font-size: 13px; + color: var(--text-secondary); + max-width: 280px; + line-height: 1.5; + } + } + + .biz-loading-more { + text-align: center; + padding: 20px; + font-size: 12px; + color: var(--text-tertiary); + } + + .pay-card { + background-color: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 20px; + box-shadow: 0 2px 8px rgba(0,0,0,0.05); + + .pay-header { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--text-tertiary); + margin-bottom: 20px; + + .pay-icon { + width: 24px; + height: 24px; + border-radius: 50%; + object-fit: cover; + } + .pay-icon-placeholder { + width: 24px; + height: 24px; + border-radius: 50%; + background-color: #07c160; + color: white; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + } + } + + .pay-title { + text-align: center; + font-size: 22px; + font-weight: 500; + color: var(--text-primary); + margin-bottom: 24px; + } + + .pay-desc { + font-size: 13px; + line-height: 1.6; + color: var(--text-secondary); + white-space: pre-wrap; + } + + .pay-footer { + margin-top: 16px; + padding-top: 12px; + border-top: 1px solid var(--border-color); + font-size: 12px; + color: var(--text-tertiary); + text-align: right; + } + } + + .article-card { + background-color: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 12px; + overflow: hidden; + box-shadow: 0 2px 8px rgba(0,0,0,0.05); + + .main-article { + position: relative; + cursor: pointer; + + .article-cover { + width: 100%; + height: 220px; + object-fit: cover; + background-color: var(--bg-tertiary); + } + + .article-overlay { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 16px; + background: linear-gradient(transparent, rgba(0,0,0,0.8)); + + .article-title { + color: white; + font-size: 17px; + font-weight: 500; + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + } + } + + .article-digest { + padding: 12px 16px; + font-size: 14px; + color: var(--text-secondary); + border-bottom: 1px solid var(--border-color); + } + + .sub-articles { + .sub-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px; + border-top: 1px solid var(--border-color); + cursor: pointer; + + &:hover { background-color: var(--bg-hover); } + + .sub-title { + flex: 1; + font-size: 15px; + color: var(--text-primary); + padding-right: 12px; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + + .sub-cover { + width: 48px; + height: 48px; + border-radius: 4px; + object-fit: cover; + flex-shrink: 0; + border: 1px solid var(--border-color); + } + } + } + } +} + +.biz-empty-state { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + height: 100%; + background: var(--bg-tertiary); // 对齐 Chat 页面空白背景 + + .empty-icon { + width: 80px; + height: 80px; + margin-bottom: 20px; + border-radius: 16px; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--bg-secondary); + color: var(--text-tertiary); + + svg { width: 40px; height: 40px; } + } + + p { color: var(--text-tertiary); font-size: 14px; } +} diff --git a/src/pages/BizPage.tsx b/src/pages/BizPage.tsx new file mode 100644 index 0000000..6831d54 --- /dev/null +++ b/src/pages/BizPage.tsx @@ -0,0 +1,336 @@ +import React, { useState, useEffect, useMemo, useRef } from 'react'; +import { useThemeStore } from '../stores/themeStore'; +import { Newspaper, MessageSquareOff } from 'lucide-react'; +import './BizPage.scss'; + +export interface BizAccount { + username: string; + name: string; + avatar: string; + type: string; + last_time: number; + formatted_last_time: string; +} + +export const BizAccountList: React.FC<{ + onSelect: (account: BizAccount) => void; + selectedUsername?: string; + searchKeyword?: string; +}> = ({ onSelect, selectedUsername, searchKeyword }) => { + const [accounts, setAccounts] = useState<BizAccount[]>([]); + const [loading, setLoading] = useState(false); + + const [myWxid, setMyWxid] = useState<string>(''); + + useEffect(() => { + const initWxid = async () => { + try { + const wxid = await window.electronAPI.config.get('myWxid'); + if (wxid) { + setMyWxid(wxid as string); + } + } catch (e) { + console.error("获取 myWxid 失败:", e); + } + }; + initWxid().then(_r => { }); + }, []); + + useEffect(() => { + const fetch = async () => { + if (!myWxid) { + return; + } + + setLoading(true); + try { + const res = await window.electronAPI.biz.listAccounts(myWxid) + setAccounts(res || []); + } catch (err) { + console.error('获取服务号列表失败:', err); + } finally { + setLoading(false); + } + }; + fetch().then(_r => { } ); + }, [myWxid]); + + + const filtered = useMemo(() => { + let result = accounts; + if (searchKeyword) { + const q = searchKeyword.toLowerCase(); + result = accounts.filter(a => + (a.name && a.name.toLowerCase().includes(q)) || + (a.username && a.username.toLowerCase().includes(q)) + ); + } + return result.sort((a, b) => { + if (a.username === 'gh_3dfda90e39d6') return -1; // 微信支付置顶 + if (b.username === 'gh_3dfda90e39d6') return 1; + return b.last_time - a.last_time; + }); + }, [accounts, searchKeyword]); + + + if (loading) return <div className="biz-loading">加载中...</div>; + + return ( + <div className="biz-account-list"> + {filtered.map(item => ( + <div + key={item.username} + onClick={() => onSelect(item)} + className={`biz-account-item ${selectedUsername === item.username ? 'active' : ''} ${item.username === 'gh_3dfda90e39d6' ? 'pay-account' : ''}`} + > + <img + src={item.avatar} + className="biz-avatar" + alt="" + /> + <div className="biz-info"> + <div className="biz-info-top"> + <span className="biz-name">{item.name || item.username}</span> + <span className="biz-time">{item.formatted_last_time}</span> + </div> + {/*{item.username === 'gh_3dfda90e39d6' && (*/} + {/* <div className="biz-badge type-service">微信支付</div>*/} + {/*)}*/} + + <div className={`biz-badge ${ + item.type === '1' ? 'type-service' : + item.type === '0' ? 'type-sub' : + item.type === '2' ? 'type-enterprise' : + item.type === '3' ? 'type-enterprise' : 'type-unknown' + }`}> + {item.type === '0' ? '公众号' : item.type === '1' ? '服务号' : item.type === '2' ? '企业号' : item.type === '3' ? '企业附属' : '未知'} + </div> + + </div> + </div> + ))} + </div> + ); +}; + +export const BizMessageArea: React.FC<{ + account: BizAccount | null; +}> = ({ account }) => { + const themeMode = useThemeStore((state) => state.themeMode); + const [messages, setMessages] = useState<any[]>([]); + const [loading, setLoading] = useState(false); + const [offset, setOffset] = useState(0); + const [hasMore, setHasMore] = useState(true); + const limit = 20; + const messageListRef = useRef<HTMLDivElement>(null); + const lastScrollHeightRef = useRef<number>(0); + const isInitialLoadRef = useRef<boolean>(true); + + const [myWxid, setMyWxid] = useState<string>(''); + + useEffect(() => { + const initWxid = async () => { + try { + const wxid = await window.electronAPI.config.get('myWxid'); + if (wxid) { + setMyWxid(wxid as string); + } + } catch (e) { } + }; + initWxid(); + }, []); + + const isDark = useMemo(() => { + if (themeMode === 'dark') return true; + if (themeMode === 'system') { + return window.matchMedia('(prefers-color-scheme: dark)').matches; + } + return false; + }, [themeMode]); + + useEffect(() => { + if (account && myWxid) { + setMessages([]); + setOffset(0); + setHasMore(true); + isInitialLoadRef.current = true; + loadMessages(account.username, 0); + } + }, [account, myWxid]); + + const loadMessages = async (username: string, currentOffset: number) => { + if (loading || !myWxid) return; + + setLoading(true); + if (messageListRef.current) { + lastScrollHeightRef.current = messageListRef.current.scrollHeight; + } + + try { + let res; + if (username === 'gh_3dfda90e39d6') { + res = await window.electronAPI.biz.listPayRecords(myWxid, limit, currentOffset); + } else { + res = await window.electronAPI.biz.listMessages(username, myWxid, limit, currentOffset); + } + + if (res) { + if (res.length < limit) setHasMore(false); + + setMessages(prev => { + const combined = currentOffset === 0 ? res : [...res, ...prev]; + const uniqueMessages = Array.from(new Map(combined.map(item => [item.local_id || item.create_time, item])).values()); + return uniqueMessages.sort((a, b) => a.create_time - b.create_time); + }); + setOffset(currentOffset + limit); + } + } catch (err) { + console.error('加载消息失败:', err); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (!messageListRef.current) return; + + if (isInitialLoadRef.current && messages.length > 0) { + messageListRef.current.scrollTop = messageListRef.current.scrollHeight; + isInitialLoadRef.current = false; + } else if (messages.length > 0 && !isInitialLoadRef.current && !loading) { + + const newScrollHeight = messageListRef.current.scrollHeight; + const heightDiff = newScrollHeight - lastScrollHeightRef.current; + if (heightDiff > 0 && messageListRef.current.scrollTop < 100) { + messageListRef.current.scrollTop += heightDiff; + } + } + }, [messages, loading]); + + const handleScroll = (e: React.UIEvent<HTMLDivElement>) => { + const target = e.currentTarget; + // 向上滚动到顶部附近触发加载更多(更旧的消息) + if (target.scrollTop < 50) { + if (!loading && hasMore && account) { + loadMessages(account.username, offset); + } + } + }; + + if (!account) { + return ( + <div className="biz-empty-state"> + <div className="empty-icon"><Newspaper size={40} /></div> + <p>请选择一个服务号查看消息</p> + </div> + ); + } + + const formatMessageTime = (timestamp: number) => { + if (!timestamp) return ''; + const date = new Date(timestamp * 1000); + const now = new Date(); + + const isToday = date.toDateString() === now.toDateString(); + if (isToday) { + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false }); + } + + const yesterday = new Date(now); + yesterday.setDate(now.getDate() - 1); + if (date.toDateString() === yesterday.toDateString()) { + return `昨天 ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`; + } + + const isThisYear = date.getFullYear() === now.getFullYear(); + if (isThisYear) { + return `${date.getMonth() + 1}月${date.getDate()}日 ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`; + } + + return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`; + }; + + const defaultImage = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MDAiIGhlaWdodD0iMTgwIj48cmVjdCB3aWR0aD0iNDAwIiBoZWlnaHQ9IjE4MCIgZmlsbD0iI2Y1ZjVmNSIvPjwvc3ZnPg=='; + + return ( + <div className={`biz-main ${isDark ? 'dark' : ''}`}> + <div className="main-header"> + <h2>{account.name}</h2> + </div> + <div className="message-container" onScroll={handleScroll} ref={messageListRef}> + <div className="messages-wrapper"> + {hasMore && messages.length > 0 && ( + <div className="biz-loading-more">{loading ? '加载中...' : '向上滚动加载更多历史消息'}</div> + )} + {!loading && messages.length === 0 && ( + <div className="biz-no-record-container"> + <div className="no-record-icon"> + <MessageSquareOff size={48} /> + </div> + <h3>暂无本地记录</h3> + <p>该公众号在当前数据库中没有可显示的聊天历史</p> + </div> + )} + {messages.map((msg, index) => { + const showTime = true; + + return ( + <div key={msg.local_id || index}> + {showTime && ( + <div className="time-divider"> + <span>{formatMessageTime(msg.create_time)}</span> + </div> + )} + + {account.username === 'gh_3dfda90e39d6' ? ( + <div className="pay-card"> + <div className="pay-header"> + {msg.merchant_icon ? <img src={msg.merchant_icon} className="pay-icon" alt=""/> : <div className="pay-icon-placeholder">¥</div>} + <span>{msg.merchant_name || '微信支付'}</span> + </div> + <div className="pay-title">{msg.title}</div> + <div className="pay-desc">{msg.description}</div> + {/* <div className="pay-footer">{msg.formatted_time}</div> */} + </div> + ) : ( + <div className="article-card"> + <div onClick={() => window.electronAPI.shell.openExternal(msg.url)} className="main-article"> + <img src={msg.cover || defaultImage} className="article-cover" alt=""/> + <div className="article-overlay"><h3 className="article-title">{msg.title}</h3></div> + </div> + {msg.des && <div className="article-digest">{msg.des}</div>} + {msg.content_list && msg.content_list.length > 1 && ( + <div className="sub-articles"> + {msg.content_list.slice(1).map((item: any, idx: number) => ( + <div key={idx} onClick={() => window.electronAPI.shell.openExternal(item.url)} className="sub-item"> + <span className="sub-title">{item.title}</span> + {item.cover && <img src={item.cover} className="sub-cover" alt=""/>} + </div> + ))} + </div> + )} + </div> + )} + </div> + ); + })} + {loading && offset === 0 && <div className="biz-loading-more">加载中...</div>} + </div> + </div> + </div> + ); +}; + +const BizPage: React.FC = () => { + const [selectedAccount, setSelectedAccount] = useState<BizAccount | null>(null); + return ( + <div className="biz-page"> + <div className="biz-sidebar"> + <BizAccountList onSelect={setSelectedAccount} selectedUsername={selectedAccount?.username} /> + </div> + <BizMessageArea account={selectedAccount} /> + </div> + ); +} + +export default BizPage; diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index 69781d3..8190a19 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -2127,6 +2127,24 @@ display: block; box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08); -webkit-app-region: no-drag; + transition: opacity 0.18s ease; +} + +.image-message.pending { + opacity: 0; +} + +.image-message.ready { + opacity: 1; +} + +.image-stage { + display: inline-block; + -webkit-app-region: no-drag; +} + +.image-stage.locked { + overflow: hidden; } .image-message-wrapper { @@ -2694,43 +2712,76 @@ // 会话详情面板 .detail-panel { - width: 280px; + width: clamp(280px, 25vw, 360px); min-width: 280px; - background: var(--card-bg); - border-left: 1px solid var(--border-color); + max-width: 360px; + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--card-bg) 94%, #fff 6%) 0%, + var(--card-bg) 100% + ); + border-left: 1px solid color-mix(in srgb, var(--border-color) 82%, transparent); + box-shadow: -14px 0 28px rgba(0, 0, 0, 0.07); display: flex; flex-direction: column; overflow: hidden; - animation: slideInRight 0.2s ease; + animation: slideInRight 0.28s cubic-bezier(0.22, 1, 0.36, 1); + will-change: transform, opacity; .detail-header { display: flex; align-items: center; justify-content: space-between; - padding: 16px; + gap: 8px; + padding: 14px 14px 12px; + background: color-mix(in srgb, var(--card-bg) 92%, #fff 8%); border-bottom: 1px solid var(--border-color); + position: sticky; + top: 0; + z-index: 2; + backdrop-filter: blur(6px); + + .detail-title-wrap { + min-width: 0; + display: flex; + flex-direction: column; + gap: 3px; + } h4 { - font-size: 15px; + font-size: 16px; font-weight: 600; color: var(--text-primary); margin: 0; } + .detail-title-sub { + font-size: 12px; + color: var(--text-tertiary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .close-btn { + width: 28px; + height: 28px; background: none; border: none; - padding: 4px; + padding: 0; cursor: pointer; color: var(--text-secondary); - border-radius: 6px; + border-radius: 8px; display: flex; align-items: center; justify-content: center; + flex-shrink: 0; + transition: all 0.18s ease; &:hover { background: var(--bg-hover); color: var(--text-primary); + transform: rotate(90deg); } } } @@ -2762,69 +2813,135 @@ .detail-content { flex: 1; overflow-y: auto; - padding: 16px; + padding: 12px; + display: flex; + flex-direction: column; + gap: 12px; &::-webkit-scrollbar { - width: 4px; + width: 6px; } &::-webkit-scrollbar-thumb { - background: var(--text-tertiary); - border-radius: 2px; + background: color-mix(in srgb, var(--text-tertiary) 68%, transparent); + border-radius: 999px; + } + } + + .detail-overview-card { + display: flex; + align-items: center; + gap: 10px; + padding: 12px; + border-radius: 12px; + border: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent); + background: color-mix(in srgb, var(--bg-secondary) 84%, transparent); + animation: detailCardEnter 0.24s ease both; + + .detail-overview-avatar { + flex-shrink: 0; + border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent); + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08); + } + + .detail-overview-meta { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; + } + + .detail-overview-name { + color: var(--text-primary); + font-size: 14px; + font-weight: 600; + line-height: 1.3; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .detail-overview-sub { + color: var(--text-tertiary); + font-size: 12px; + line-height: 1.25; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } } .detail-section { - margin-bottom: 20px; - - &:last-child { - margin-bottom: 0; - } + margin: 0; + padding: 12px; + border-radius: 12px; + border: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent); + background: color-mix(in srgb, var(--bg-secondary) 86%, transparent); + animation: detailCardEnter 0.24s ease both; .section-title { display: flex; align-items: center; - gap: 6px; - font-size: 14px; + gap: 8px; + font-size: 13px; font-weight: 600; color: var(--text-secondary); - margin-bottom: 12px; - text-transform: uppercase; - letter-spacing: 0.5px; + margin-bottom: 10px; + letter-spacing: 0.3px; svg { - opacity: 0.7; + color: var(--primary); + opacity: 0.9; } } .detail-stats-meta { - margin-top: -6px; + margin-top: -2px; margin-bottom: 10px; + padding: 6px 8px; + border-radius: 8px; font-size: 12px; color: var(--text-tertiary); + background: color-mix(in srgb, var(--card-bg) 84%, transparent); } } + .detail-section:nth-child(2) { + animation-delay: 0.03s; + } + + .detail-section:nth-child(3) { + animation-delay: 0.06s; + } + + .detail-section:nth-child(4) { + animation-delay: 0.09s; + } + .detail-item { display: flex; align-items: center; - gap: 8px; + gap: 10px; padding: 8px 0; - border-bottom: 1px solid var(--border-color); + border-bottom: 1px dashed color-mix(in srgb, var(--border-color) 76%, transparent); font-size: 13px; &:last-child { border-bottom: none; } - svg { + > svg { color: var(--text-tertiary); flex-shrink: 0; + width: 14px; + height: 14px; } .label { color: var(--text-secondary); flex-shrink: 0; + width: 88px; + line-height: 1.3; } .value { @@ -2833,22 +2950,27 @@ color: var(--text-primary); word-break: break-all; user-select: text; + line-height: 1.35; &.highlight { color: var(--primary); font-weight: 600; + font-size: 21px; + letter-spacing: 0.2px; } } .detail-inline-btn { - border: none; - background: var(--bg-secondary); + border: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent); + background: color-mix(in srgb, var(--card-bg) 90%, transparent); color: var(--primary); - border-radius: 6px; - padding: 4px 8px; + border-radius: 999px; + padding: 5px 10px; font-size: 12px; line-height: 1; + font-weight: 500; cursor: pointer; + transition: all 0.16s ease; &:disabled { cursor: not-allowed; @@ -2856,6 +2978,7 @@ } &:hover:not(:disabled) { + transform: translateY(-1px); background: var(--bg-hover); } } @@ -2868,12 +2991,12 @@ height: 22px; padding: 0; border: none; - border-radius: 4px; + border-radius: 6px; background: transparent; color: var(--text-tertiary); cursor: pointer; flex-shrink: 0; - opacity: 0; + opacity: 0.2; transition: opacity 0.15s, color 0.15s, background 0.15s; &:hover { @@ -2889,18 +3012,27 @@ &:hover .copy-btn { opacity: 1; } + + &:focus-within .copy-btn { + opacity: 1; + } + } + + .detail-basic-section .label { + width: 70px; } .table-list { display: flex; flex-direction: column; - gap: 8px; + gap: 10px; } .detail-table-placeholder { - padding: 10px 12px; - background: var(--bg-secondary); - border-radius: 8px; + padding: 11px 12px; + background: color-mix(in srgb, var(--card-bg) 84%, transparent); + border: 1px dashed color-mix(in srgb, var(--border-color) 76%, transparent); + border-radius: 10px; font-size: 12px; color: var(--text-secondary); } @@ -2910,18 +3042,64 @@ align-items: center; justify-content: space-between; padding: 10px 12px; - background: var(--bg-secondary); - border-radius: 8px; + background: color-mix(in srgb, var(--card-bg) 90%, transparent); + border: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent); + border-radius: 10px; font-size: 12px; + transition: transform 0.16s ease, border-color 0.16s ease; + + &:hover { + transform: translateY(-1px); + border-color: color-mix(in srgb, var(--primary) 26%, var(--border-color)); + } .db-name { color: var(--text-primary); font-weight: 500; + max-width: 62%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .table-count { color: var(--primary); - font-weight: 500; + font-weight: 600; + } + } +} + +.session-detail-panel { + .detail-content { + padding-top: 10px; + } + + .detail-overview-card { + gap: 10px; + + .detail-overview-meta { + flex: 1; + } + } + + .detail-overview-close-btn { + width: 28px; + height: 28px; + border: none; + border-radius: 8px; + background: color-mix(in srgb, var(--card-bg) 88%, transparent); + color: var(--text-secondary); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + flex-shrink: 0; + transition: all 0.16s ease; + + &:hover { + color: var(--text-primary); + background: var(--bg-hover); + transform: rotate(90deg); } } } @@ -3122,6 +3300,18 @@ } } +@keyframes detailCardEnter { + from { + opacity: 0; + transform: translateY(6px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + /* 语音转文字按钮样式 */ .voice-transcribe-btn { width: 28px; @@ -4487,6 +4677,32 @@ 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 { position: fixed; diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 2abc958..5e86cc5 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -1,5 +1,5 @@ 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, 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 { createPortal } from 'react-dom' import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso' @@ -16,6 +16,7 @@ import JumpToDatePopover from '../components/JumpToDatePopover' import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog' import { type ContactSnsTimelineTarget, isSingleContactSession } from '../components/Sns/contactSnsTimeline' import * as configService from '../services/config' +import BizPage, { BizAccountList, BizMessageArea, BizAccount } from './BizPage' import { finishBackgroundTask, isBackgroundTaskCancelRequested, @@ -36,6 +37,8 @@ const SYSTEM_MESSAGE_TYPES = [ 266287972401, // 拍一拍 ] +const OFFICIAL_ACCOUNTS_VIRTUAL_ID = 'official_accounts_virtual' + interface PendingInSessionSearchPayload { sessionId: string keyword: string @@ -61,6 +64,24 @@ const GLOBAL_MSG_LEGACY_CONCURRENCY = 6 const GLOBAL_MSG_SEARCH_CANCELED_ERROR = '__WEFLOW_GLOBAL_MSG_SEARCH_CANCELED__' const GLOBAL_MSG_SHADOW_COMPARE_SAMPLE_RATE = 0.2 const GLOBAL_MSG_SHADOW_COMPARE_STORAGE_KEY = 'weflow.debug.searchShadowCompare' +const MESSAGE_LIST_SCROLL_IDLE_MS = 160 +const MESSAGE_TOP_WHEEL_LOAD_COOLDOWN_MS = 160 +const MESSAGE_EDGE_TRIGGER_DISTANCE_PX = 96 + +type RequestIdleCallbackCompat = (callback: () => void, options?: { timeout?: number }) => number + +function scheduleWhenIdle(task: () => void, options?: { timeout?: number; fallbackDelay?: number }): void { + const requestIdleCallbackFn = ( + globalThis as typeof globalThis & { requestIdleCallback?: RequestIdleCallbackCompat } + ).requestIdleCallback + + if (typeof requestIdleCallbackFn === 'function') { + requestIdleCallbackFn(task, options?.timeout !== undefined ? { timeout: options.timeout } : undefined) + return + } + + window.setTimeout(task, options?.fallbackDelay ?? 0) +} function isGlobalMsgSearchCanceled(error: unknown): boolean { return String(error || '') === GLOBAL_MSG_SEARCH_CANCELED_ERROR @@ -207,6 +228,12 @@ function sortMessagesByCreateTimeDesc<T extends Pick<Message, 'createTime' | 'lo }) } +function isRenderableImageSrc(value?: string | null): boolean { + const src = String(value || '').trim() + if (!src) return false + return /^(https?:\/\/|data:image\/|blob:|file:\/\/|\/)/i.test(src) +} + function normalizeSearchIdentityText(value?: string | null): string | undefined { const normalized = String(value || '').trim() if (!normalized) return undefined @@ -983,6 +1010,7 @@ const SessionItem = React.memo(function SessionItem({ ) const isFoldEntry = session.username.toLowerCase().includes('placeholder_foldgroup') + const isBizEntry = session.username === OFFICIAL_ACCOUNTS_VIRTUAL_ID // 折叠入口:专属名称和图标 if (isFoldEntry) { @@ -1007,6 +1035,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 const summaryContent = useMemo(() => { if (session.matchedField === 'wxid') { @@ -1152,7 +1203,12 @@ function ChatPage(props: ChatPageProps) { const visibleMessageRangeRef = useRef<{ startIndex: number; endIndex: number }>({ startIndex: 0, endIndex: 0 }) const topRangeLoadLockRef = useRef(false) const bottomRangeLoadLockRef = useRef(false) + const topRangeLoadLastTriggerAtRef = useRef(0) const suppressAutoLoadLaterRef = useRef(false) + const suppressAutoScrollOnNextMessageGrowthRef = useRef(false) + const prependingHistoryRef = useRef(false) + const isMessageListScrollingRef = useRef(false) + const messageListScrollTimeoutRef = useRef<number | null>(null) const searchInputRef = useRef<HTMLInputElement>(null) const sidebarRef = useRef<HTMLDivElement>(null) const handleMessageListScrollParentRef = useCallback((node: HTMLDivElement | null) => { @@ -1204,6 +1260,8 @@ function ChatPage(props: ChatPageProps) { const [highlightedMessageKeys, setHighlightedMessageKeys] = useState<string[]>([]) const [isRefreshingSessions, setIsRefreshingSessions] = 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 [isSessionSwitching, setIsSessionSwitching] = useState(false) const [noMessageTable, setNoMessageTable] = useState(false) @@ -1371,6 +1429,18 @@ function ChatPage(props: ChatPageProps) { }, delayMs) }, []) + const markMessageListScrolling = useCallback(() => { + isMessageListScrollingRef.current = true + if (messageListScrollTimeoutRef.current !== null) { + window.clearTimeout(messageListScrollTimeoutRef.current) + messageListScrollTimeoutRef.current = null + } + messageListScrollTimeoutRef.current = window.setTimeout(() => { + isMessageListScrollingRef.current = false + messageListScrollTimeoutRef.current = null + }, MESSAGE_LIST_SCROLL_IDLE_MS) + }, []) + const isGroupChatSession = useCallback((username: string) => { return username.includes('@chatroom') }, []) @@ -2691,6 +2761,9 @@ function ChatPage(props: ChatPageProps) { setConnected(false) setConnecting(false) setHasMoreMessages(true) + setFoldedView(false) + setBizView(false) + setSelectedBizAccount(null) setHasMoreLater(false) const scope = await resolveChatCacheScope() hydrateSessionListCache(scope) @@ -2901,15 +2974,9 @@ function ChatPage(props: ChatPageProps) { await loadContactInfoBatch(usernames) } else { await new Promise<void>((resolve) => { - if ('requestIdleCallback' in window) { - window.requestIdleCallback(() => { - void loadContactInfoBatch(usernames).finally(resolve) - }, { timeout: 700 }) - } else { - setTimeout(() => { - void loadContactInfoBatch(usernames).finally(resolve) - }, 80) - } + scheduleWhenIdle(() => { + void loadContactInfoBatch(usernames).finally(resolve) + }, { timeout: 700, fallbackDelay: 80 }) }) } processedBatchCount += 1 @@ -3008,7 +3075,7 @@ function ChatPage(props: ChatPageProps) { const loadContactInfoBatch = async (usernames: string[]) => { const startTime = performance.now() try { - // 在 DLL 调用前让出控制权(使用 setTimeout 0 代替 setImmediate) + // 在数据服务调用前让出控制权(使用 setTimeout 0 代替 setImmediate) await new Promise(resolve => setTimeout(resolve, 0)) const dllStart = performance.now() @@ -3019,7 +3086,7 @@ function ChatPage(props: ChatPageProps) { } const dllTime = performance.now() - dllStart - // DLL 调用后再次让出控制权 + //数据服务调用后再次让出控制权 await new Promise(resolve => setTimeout(resolve, 0)) const totalTime = performance.now() - startTime @@ -3201,19 +3268,32 @@ function ChatPage(props: ChatPageProps) { } if (defer) { - if ('requestIdleCallback' in window) { - window.requestIdleCallback(() => { - runWarmup() - }, { timeout: 1200 }) - } else { - globalThis.setTimeout(runWarmup, 120) - } + scheduleWhenIdle(runWarmup, { timeout: 1200, fallbackDelay: 120 }) return } runWarmup() }, [loadContactInfoBatch]) + const scheduleGroupSenderWarmup = useCallback((usernames: string[], defer = false) => { + if (!Array.isArray(usernames) || usernames.length === 0) return + const run = () => warmupGroupSenderProfiles(usernames, false) + if (!defer && !isMessageListScrollingRef.current) { + run() + return + } + + const runWhenIdle = () => { + if (isMessageListScrollingRef.current) { + window.setTimeout(runWhenIdle, MESSAGE_LIST_SCROLL_IDLE_MS) + return + } + run() + } + + scheduleWhenIdle(runWhenIdle, { timeout: 1200, fallbackDelay: MESSAGE_LIST_SCROLL_IDLE_MS }) + }, [warmupGroupSenderProfiles]) + // 加载消息 const loadMessages = async ( sessionId: string, @@ -3223,6 +3303,10 @@ function ChatPage(props: ChatPageProps) { ascending = false, options: LoadMessagesOptions = {} ) => { + const isPrependHistoryLoad = offset > 0 && !ascending + if (isPrependHistoryLoad) { + prependingHistoryRef.current = true + } const listEl = messageListRef.current const session = sessionMapRef.current.get(sessionId) const unreadCount = session?.unreadCount ?? 0 @@ -3256,10 +3340,6 @@ function ChatPage(props: ChatPageProps) { Math.max(visibleRange.startIndex, 0), Math.max(messages.length - 1, 0) ) - const anchorMessageKeyBeforePrepend = offset > 0 && messages.length > 0 - ? getMessageKey(messages[visibleStartIndex]) - : null - // 记录加载前的第一条消息元素(非虚拟列表回退路径) const firstMsgEl = listEl?.querySelector('.message-wrapper') as HTMLElement | null @@ -3308,12 +3388,11 @@ function ChatPage(props: ChatPageProps) { .map(m => m.senderUsername as string) )] if (unknownSenders.length > 0) { - warmupGroupSenderProfiles(unknownSenders, options.deferGroupSenderWarmup === true) + scheduleGroupSenderWarmup(unknownSenders, options.deferGroupSenderWarmup === true) } } // 日期跳转时滚动到顶部,否则滚动到底部 - const loadedMessages = result.messages requestAnimationFrame(() => { if (isDateJumpRef.current) { if (messageVirtuosoRef.current && resultMessages.length > 0) { @@ -3333,6 +3412,19 @@ function ChatPage(props: ChatPageProps) { } }) } else { + const existingMessageKeys = messageKeySetRef.current + const incomingSeen = new Set<string>() + let prependedInsertedCount = 0 + for (const row of resultMessages) { + const key = getMessageKey(row) + if (incomingSeen.has(key)) continue + incomingSeen.add(key) + if (!existingMessageKeys.has(key)) { + prependedInsertedCount += 1 + } + } + + suppressAutoScrollOnNextMessageGrowthRef.current = true appendMessages(resultMessages, true) // 加载更多也同样处理发送者信息预取 @@ -3343,24 +3435,20 @@ function ChatPage(props: ChatPageProps) { .map(m => m.senderUsername as string) )] if (unknownSenders.length > 0) { - warmupGroupSenderProfiles(unknownSenders, false) + scheduleGroupSenderWarmup(unknownSenders, false) } } // 加载更早消息后保持视口锚点,避免跳屏 - const appendedMessages = result.messages requestAnimationFrame(() => { if (messageVirtuosoRef.current) { - if (anchorMessageKeyBeforePrepend) { - const latestMessages = useChatStore.getState().messages || [] - const anchorIndex = latestMessages.findIndex((msg) => getMessageKey(msg) === anchorMessageKeyBeforePrepend) - if (anchorIndex >= 0) { - messageVirtuosoRef.current.scrollToIndex({ index: anchorIndex, align: 'start', behavior: 'auto' }) - return - } - } - if (resultMessages.length > 0) { - messageVirtuosoRef.current.scrollToIndex({ index: resultMessages.length, align: 'start', behavior: 'auto' }) + const latestMessages = useChatStore.getState().messages || [] + const anchorIndex = Math.min( + Math.max(visibleStartIndex + prependedInsertedCount, 0), + Math.max(latestMessages.length - 1, 0) + ) + if (latestMessages.length > 0) { + messageVirtuosoRef.current.scrollToIndex({ index: anchorIndex, align: 'start', behavior: 'auto' }) } return } @@ -3400,6 +3488,11 @@ function ChatPage(props: ChatPageProps) { setMessages([]) } } finally { + if (isPrependHistoryLoad) { + requestAnimationFrame(() => { + prependingHistoryRef.current = false + }) + } setLoadingMessages(false) setLoadingMore(false) if (offset === 0 && pendingSessionLoadRef.current === sessionId) { @@ -3430,9 +3523,11 @@ function ChatPage(props: ChatPageProps) { setCurrentOffset(0) setJumpStartTime(0) setJumpEndTime(end) + suppressAutoLoadLaterRef.current = true setShowJumpPopover(false) void loadMessages(targetSessionId, 0, 0, end, false, { - switchRequestSeq: options.switchRequestSeq + switchRequestSeq: options.switchRequestSeq, + forceInitialLimit: 120 }) }, [currentSessionId, loadMessages]) @@ -3964,6 +4059,12 @@ function ChatPage(props: ChatPageProps) { setFoldedView(true) return } + // 点击公众号入口,切换到公众号视图 + if (session.username === OFFICIAL_ACCOUNTS_VIRTUAL_ID) { + setBizView(true) + setSelectedBizAccount(null) // 切入时默认不选中任何公众号 + return + } selectSessionById(session.username) } @@ -4342,36 +4443,6 @@ function ChatPage(props: ChatPageProps) { return } - if (range.endIndex >= Math.max(total - 2, 0)) { - isMessageListAtBottomRef.current = true - setShowScrollToBottom(prev => (prev ? false : prev)) - } - - if ( - range.startIndex <= 2 && - !topRangeLoadLockRef.current && - !isLoadingMore && - !isLoadingMessages && - hasMoreMessages && - currentSessionId - ) { - topRangeLoadLockRef.current = true - void loadMessages(currentSessionId, currentOffset, jumpStartTime, jumpEndTime) - } - - if ( - range.endIndex >= total - 3 && - !bottomRangeLoadLockRef.current && - !suppressAutoLoadLaterRef.current && - !isLoadingMore && - !isLoadingMessages && - hasMoreLater && - currentSessionId - ) { - bottomRangeLoadLockRef.current = true - void loadLaterMessages() - } - if (shouldWarmupVisibleGroupSenders) { const now = Date.now() if (now - lastVisibleSenderWarmupAtRef.current >= 180) { @@ -4390,27 +4461,18 @@ function ChatPage(props: ChatPageProps) { if (pendingUsernames.size >= 24) break } if (pendingUsernames.size > 0) { - warmupGroupSenderProfiles([...pendingUsernames], false) + scheduleGroupSenderWarmup([...pendingUsernames], false) } } } }, [ messages.length, - isLoadingMore, - isLoadingMessages, - hasMoreMessages, - hasMoreLater, currentSessionId, - currentOffset, - jumpStartTime, - jumpEndTime, isGroupChatSession, standaloneSessionWindow, normalizedInitialSessionId, normalizedStandaloneInitialContactType, - warmupGroupSenderProfiles, - loadMessages, - loadLaterMessages + scheduleGroupSenderWarmup ]) const handleMessageAtBottomStateChange = useCallback((atBottom: boolean) => { @@ -4424,9 +4486,8 @@ function ChatPage(props: ChatPageProps) { const distanceFromBottom = listEl ? (listEl.scrollHeight - (listEl.scrollTop + listEl.clientHeight)) : Number.POSITIVE_INFINITY - const nearBottomByRange = visibleMessageRangeRef.current.endIndex >= Math.max(messages.length - 2, 0) const nearBottomByDistance = distanceFromBottom <= 140 - const effectiveAtBottom = atBottom || nearBottomByRange || nearBottomByDistance + const effectiveAtBottom = atBottom || nearBottomByDistance isMessageListAtBottomRef.current = effectiveAtBottom if (!effectiveAtBottom) { @@ -4454,19 +4515,48 @@ function ChatPage(props: ChatPageProps) { }, [messages.length, isLoadingMessages, isLoadingMore, isSessionSwitching]) const handleMessageListWheel = useCallback((event: React.WheelEvent<HTMLDivElement>) => { - if (event.deltaY <= 18) return - if (!currentSessionId || isLoadingMore || isLoadingMessages || !hasMoreLater) return + markMessageListScrolling() + if (!currentSessionId || isLoadingMore || isLoadingMessages) return const listEl = messageListRef.current if (!listEl) return + const distanceFromTop = listEl.scrollTop const distanceFromBottom = listEl.scrollHeight - (listEl.scrollTop + listEl.clientHeight) - if (distanceFromBottom > 96) return + + if (event.deltaY <= -18) { + if (!hasMoreMessages) return + if (distanceFromTop > MESSAGE_EDGE_TRIGGER_DISTANCE_PX) return + if (topRangeLoadLockRef.current) return + const now = Date.now() + if (now - topRangeLoadLastTriggerAtRef.current < MESSAGE_TOP_WHEEL_LOAD_COOLDOWN_MS) return + topRangeLoadLastTriggerAtRef.current = now + topRangeLoadLockRef.current = true + isMessageListAtBottomRef.current = false + void loadMessages(currentSessionId, currentOffset, jumpStartTime, jumpEndTime) + return + } + + if (event.deltaY <= 18) return + if (!hasMoreLater) return + if (distanceFromBottom > MESSAGE_EDGE_TRIGGER_DISTANCE_PX) return if (bottomRangeLoadLockRef.current) return // 用户明确向下滚动时允许加载后续消息 suppressAutoLoadLaterRef.current = false bottomRangeLoadLockRef.current = true void loadLaterMessages() - }, [currentSessionId, hasMoreLater, isLoadingMessages, isLoadingMore, loadLaterMessages]) + }, [ + currentSessionId, + hasMoreLater, + hasMoreMessages, + isLoadingMessages, + isLoadingMore, + currentOffset, + jumpStartTime, + jumpEndTime, + markMessageListScrolling, + loadMessages, + loadLaterMessages + ]) const handleMessageAtTopStateChange = useCallback((atTop: boolean) => { if (!atTop) { @@ -4621,6 +4711,11 @@ function ChatPage(props: ChatPageProps) { if (sessionScrollTimeoutRef.current) { clearTimeout(sessionScrollTimeoutRef.current) } + if (messageListScrollTimeoutRef.current !== null) { + window.clearTimeout(messageListScrollTimeoutRef.current) + messageListScrollTimeoutRef.current = null + } + isMessageListScrollingRef.current = false contactUpdateQueueRef.current.clear() pendingSessionContactEnrichRef.current.clear() sessionContactEnrichAttemptAtRef.current.clear() @@ -4661,8 +4756,12 @@ function ChatPage(props: ChatPageProps) { lastObservedMessageCountRef.current = currentCount if (currentCount <= previousCount) return if (!currentSessionId || isLoadingMessages || isSessionSwitching) return - const wasNearBottomByRange = visibleMessageRangeRef.current.endIndex >= Math.max(previousCount - 2, 0) - if (!isMessageListAtBottomRef.current && !wasNearBottomByRange) return + if (suppressAutoScrollOnNextMessageGrowthRef.current || prependingHistoryRef.current) { + suppressAutoScrollOnNextMessageGrowthRef.current = false + return + } + if (!isMessageListAtBottomRef.current) return + if (suppressAutoLoadLaterRef.current) return suppressScrollToBottomButton(220) isMessageListAtBottomRef.current = true requestAnimationFrame(() => { @@ -4946,14 +5045,30 @@ function ChatPage(props: ChatPageProps) { const foldedGroups = sessions.filter(s => s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup')) 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 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'))) { - // 找到最新的折叠消息 + const latestFolded = foldedGroups.reduce((latest, current) => { const latestTime = latest.sortTimestamp || latest.lastTimestamp const currentTime = current.sortTimestamp || current.lastTimestamp @@ -6031,7 +6146,7 @@ function ChatPage(props: ChatPageProps) { ref={sidebarRef} 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 */} <div className="session-header-panel main-header"> <div className="search-row"> @@ -6061,12 +6176,18 @@ function ChatPage(props: ChatPageProps) { {/* 折叠群 header */} <div className="session-header-panel folded-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} /> </button> <span className="folded-view-title"> - <Users size={14} /> - 折叠的群聊 + {foldedView ? ( + <><Users size={14} /> 折叠的群聊</> + ) : bizView ? ( + <><Newspaper size={14} /> 订阅号/服务号</> + ) : null} </span> </div> </div> @@ -6173,7 +6294,7 @@ function ChatPage(props: ChatPageProps) { ))} </div> ) : ( - <div className={`session-list-viewport ${foldedView ? 'folded' : ''}`}> + <div className={`session-list-viewport ${foldedView || bizView ? 'folded' : ''}`}> {/* 普通会话列表 */} <div className="session-list-panel main-panel"> {Array.isArray(filteredSessions) && filteredSessions.length > 0 ? ( @@ -6199,7 +6320,7 @@ function ChatPage(props: ChatPageProps) { <SessionItem key={session.username} session={session} - isActive={currentSessionId === session.username} + isActive={currentSessionId === session.username || (bizView && session.username === OFFICIAL_ACCOUNTS_VIRTUAL_ID)} onSelect={handleSelectSession} formatTime={formatSessionTime} searchKeyword={searchKeyword} @@ -6218,24 +6339,36 @@ function ChatPage(props: ChatPageProps) { {/* 折叠群列表 */} <div className="session-list-panel folded-panel"> - {foldedSessions.length > 0 ? ( - <div className="session-list"> - {foldedSessions.map(session => ( - <SessionItem - key={session.username} - session={session} - isActive={currentSessionId === session.username} - onSelect={handleSelectSession} - formatTime={formatSessionTime} - searchKeyword={searchKeyword} + {foldedView && ( + foldedSessions.length > 0 ? ( + <div className="session-list"> + {foldedSessions.map(session => ( + <SessionItem + key={session.username} + session={session} + isActive={currentSessionId === session.username || (bizView && session.username === OFFICIAL_ACCOUNTS_VIRTUAL_ID)} + onSelect={handleSelectSession} + 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 className="empty-sessions"> - <Users size={32} /> - <p>没有折叠的群聊</p> - </div> + </div> )} </div> </div> @@ -6247,9 +6380,11 @@ function ChatPage(props: ChatPageProps) { {/* 右侧消息区域 */} <div className="message-area"> - {currentSession ? ( - <> - <div className="message-header"> + {bizView ? ( + <BizMessageArea account={selectedBizAccount} /> + ) : currentSession ? ( + <> + <div className="message-header"> <Avatar src={currentSession.avatarUrl} name={currentSession.displayName || currentSession.username} @@ -6529,6 +6664,7 @@ function ChatPage(props: ChatPageProps) { <div className={`message-list ${hasInitialMessages ? 'loaded' : 'loading'}`} ref={handleMessageListScrollParentRef} + onScroll={markMessageListScrolling} onWheel={handleMessageListWheel} > {!isLoadingMessages && messages.length === 0 && !hasMoreMessages ? ( @@ -6542,8 +6678,12 @@ function ChatPage(props: ChatPageProps) { className="message-virtuoso" customScrollParent={messageListScrollParent ?? undefined} data={messages} - overscan={360} - followOutput={(atBottom) => (atBottom || isMessageListAtBottomRef.current ? 'auto' : false)} + overscan={220} + followOutput={(atBottom) => ( + prependingHistoryRef.current + ? false + : (atBottom && isMessageListAtBottomRef.current ? 'auto' : false) + )} atBottomThreshold={80} atBottomStateChange={handleMessageAtBottomStateChange} atTopStateChange={handleMessageAtTopStateChange} @@ -6655,13 +6795,7 @@ function ChatPage(props: ChatPageProps) { {/* 会话详情面板 */} {showDetailPanel && ( - <div className="detail-panel"> - <div className="detail-header"> - <h4>会话详情</h4> - <button className="close-btn" onClick={() => setShowDetailPanel(false)}> - <X size={16} /> - </button> - </div> + <div className="detail-panel session-detail-panel"> {isLoadingDetail && !sessionDetail ? ( <div className="detail-loading"> <Loader2 size={20} className="spin" /> @@ -6669,7 +6803,27 @@ function ChatPage(props: ChatPageProps) { </div> ) : sessionDetail ? ( <div className="detail-content"> - <div className="detail-section"> + <div className="detail-overview-card"> + <Avatar + src={currentSession?.avatarUrl} + name={sessionDetail.remark || sessionDetail.nickName || currentSession?.displayName || sessionDetail.wxid} + size={42} + className="detail-overview-avatar" + /> + <div className="detail-overview-meta"> + <span className="detail-overview-name"> + {sessionDetail.remark || sessionDetail.nickName || currentSession?.displayName || sessionDetail.alias || sessionDetail.wxid} + </span> + <span className="detail-overview-sub"> + {sessionDetail.alias || sessionDetail.wxid} + </span> + </div> + <button className="detail-overview-close-btn" onClick={() => setShowDetailPanel(false)} title="关闭详情"> + <X size={16} /> + </button> + </div> + + <div className="detail-section detail-basic-section"> <div className="detail-item"> <Hash size={14} /> <span className="label">微信ID</span> @@ -6707,10 +6861,10 @@ function ChatPage(props: ChatPageProps) { )} </div> - <div className="detail-section"> + <div className="detail-section detail-stats-section"> <div className="section-title"> <MessageSquare size={14} /> - <span>消息统计(导出口径)</span> + <span>消息统计</span> </div> <div className="detail-stats-meta"> {isRefreshingDetailStats @@ -6868,7 +7022,7 @@ function ChatPage(props: ChatPageProps) { </div> </div> - <div className="detail-section"> + <div className="detail-section detail-db-section"> <div className="section-title"> <Database size={14} /> <span>数据库分布</span> @@ -7585,6 +7739,8 @@ function MessageBubble({ // State variables... const [imageError, setImageError] = useState(false) const [imageLoading, setImageLoading] = useState(false) + const [imageLoaded, setImageLoaded] = useState(false) + const [imageStageLockHeight, setImageStageLockHeight] = useState<number | null>(null) const [imageHasUpdate, setImageHasUpdate] = useState(false) const [imageClicked, setImageClicked] = useState(false) const imageUpdateCheckedRef = useRef<string | null>(null) @@ -7630,6 +7786,11 @@ function MessageBubble({ const videoContainerRef = useRef<HTMLElement>(null) const [isVideoVisible, setIsVideoVisible] = useState(false) const [videoMd5, setVideoMd5] = useState<string | null>(null) + const imageStageLockStyle = useMemo<React.CSSProperties | undefined>(() => ( + imageStageLockHeight && imageStageLockHeight > 0 + ? { height: `${Math.round(imageStageLockHeight)}px` } + : undefined + ), [imageStageLockHeight]) // 解析视频 MD5 useEffect(() => { @@ -7773,6 +7934,14 @@ function MessageBubble({ captureResizeBaseline(imageContainerRef.current, imageResizeBaselineRef) }, [captureResizeBaseline]) + const lockImageStageHeight = useCallback(() => { + const host = imageContainerRef.current + if (!host) return + const height = host.getBoundingClientRect().height + if (!Number.isFinite(height) || height <= 0) return + setImageStageLockHeight(Math.round(height)) + }, []) + const captureEmojiResizeBaseline = useCallback(() => { captureResizeBaseline(emojiContainerRef.current, emojiResizeBaselineRef) }, [captureResizeBaseline]) @@ -7781,6 +7950,12 @@ function MessageBubble({ stabilizeScrollAfterResize(imageContainerRef.current, imageResizeBaselineRef) }, [stabilizeScrollAfterResize]) + const releaseImageStageLock = useCallback(() => { + window.requestAnimationFrame(() => { + setImageStageLockHeight(null) + }) + }, []) + const stabilizeEmojiScrollAfterResize = useCallback(() => { stabilizeScrollAfterResize(emojiContainerRef.current, emojiResizeBaselineRef) }, [stabilizeScrollAfterResize]) @@ -7934,6 +8109,7 @@ function MessageBubble({ imageDataUrlCache.set(imageCacheKey, result.localPath) if (imageLocalPath !== result.localPath) { captureImageResizeBaseline() + lockImageStageHeight() } setImageLocalPath(result.localPath) setImageHasUpdate(false) @@ -7949,6 +8125,7 @@ function MessageBubble({ imageDataUrlCache.set(imageCacheKey, dataUrl) if (imageLocalPath !== dataUrl) { captureImageResizeBaseline() + lockImageStageHeight() } setImageLocalPath(dataUrl) setImageHasUpdate(false) @@ -7962,7 +8139,7 @@ function MessageBubble({ imageDecryptPendingRef.current = false } return { success: false } - }, [isImage, message.imageMd5, message.imageDatName, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64, imageLocalPath, captureImageResizeBaseline]) + }, [isImage, message.imageMd5, message.imageDatName, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64, imageLocalPath, captureImageResizeBaseline, lockImageStageHeight]) const triggerForceHd = useCallback(() => { if (!message.imageMd5 && !message.imageDatName) return @@ -8025,6 +8202,7 @@ function MessageBubble({ imageDataUrlCache.set(imageCacheKey, resolved.localPath) if (imageLocalPath !== resolved.localPath) { captureImageResizeBaseline() + lockImageStageHeight() } setImageLocalPath(resolved.localPath) if (resolved.liveVideoPath) setImageLiveVideoPath(resolved.liveVideoPath) @@ -8039,6 +8217,7 @@ function MessageBubble({ imageLocalPath, imageCacheKey, captureImageResizeBaseline, + lockImageStageHeight, message.imageDatName, message.imageMd5, requestImageDecrypt, @@ -8053,6 +8232,16 @@ function MessageBubble({ } }, []) + useEffect(() => { + setImageLoaded(false) + }, [imageLocalPath]) + + useEffect(() => { + if (imageLoading) return + if (!imageError && imageLocalPath) return + setImageStageLockHeight(null) + }, [imageError, imageLoading, imageLocalPath]) + useEffect(() => { if (!isImage || imageLoading) return if (!message.imageMd5 && !message.imageDatName) return @@ -8069,6 +8258,7 @@ function MessageBubble({ imageDataUrlCache.set(imageCacheKey, result.localPath) if (!imageLocalPath || imageLocalPath !== result.localPath) { captureImageResizeBaseline() + lockImageStageHeight() setImageLocalPath(result.localPath) setImageError(false) } @@ -8079,7 +8269,7 @@ function MessageBubble({ return () => { cancelled = true } - }, [isImage, imageLocalPath, imageLoading, message.imageMd5, message.imageDatName, imageCacheKey, session.username, captureImageResizeBaseline]) + }, [isImage, imageLocalPath, imageLoading, message.imageMd5, message.imageDatName, imageCacheKey, session.username, captureImageResizeBaseline, lockImageStageHeight]) useEffect(() => { if (!isImage) return @@ -8113,6 +8303,7 @@ function MessageBubble({ } if (imageLocalPath !== payload.localPath) { captureImageResizeBaseline() + lockImageStageHeight() } setImageLocalPath((prev) => (prev === payload.localPath ? prev : payload.localPath)) setImageError(false) @@ -8121,7 +8312,7 @@ function MessageBubble({ return () => { unsubscribe?.() } - }, [isImage, imageCacheKey, imageLocalPath, message.imageDatName, message.imageMd5, captureImageResizeBaseline]) + }, [isImage, imageCacheKey, imageLocalPath, message.imageDatName, message.imageMd5, captureImageResizeBaseline, lockImageStageHeight]) // 图片进入视野前自动解密(懒加载) useEffect(() => { @@ -8504,11 +8695,46 @@ function MessageBubble({ appMsgTextCache.set(selector, value) return value }, [appMsgDoc, appMsgTextCache]) + const queryPreferredQuotedContent = useCallback((): string => { + if (message.quotedContent) return message.quotedContent + const candidates = [ + 'refermsg > selectedcontent', + 'refermsg > selectedtext', + 'refermsg > selectcontent', + 'refermsg > selecttext', + 'refermsg > quotecontent', + 'refermsg > quotetext', + 'refermsg > partcontent', + 'refermsg > parttext', + 'refermsg > excerpt', + 'refermsg > summary', + 'refermsg > preview', + 'refermsg > content' + ] + for (const selector of candidates) { + const value = queryAppMsgText(selector) + if (value) return value + } + return '' + }, [message.quotedContent, queryAppMsgText]) + const appMsgThumbRawCandidate = useMemo(() => ( + message.linkThumb || + message.appMsgThumbUrl || + queryAppMsgText('appmsg > thumburl') || + queryAppMsgText('appmsg > cdnthumburl') || + queryAppMsgText('appmsg > cover') || + queryAppMsgText('appmsg > coverurl') || + queryAppMsgText('thumburl') || + queryAppMsgText('cdnthumburl') || + queryAppMsgText('cover') || + queryAppMsgText('coverurl') || + '' + ).trim(), [message.linkThumb, message.appMsgThumbUrl, queryAppMsgText]) const quotedSenderUsername = resolveQuotedSenderUsername( queryAppMsgText('refermsg > fromusr'), queryAppMsgText('refermsg > chatusr') ) - const quotedContent = message.quotedContent || queryAppMsgText('refermsg > content') || '' + const quotedContent = queryPreferredQuotedContent() const quotedSenderFallbackName = useMemo( () => resolveQuotedSenderFallbackDisplayName( session.username, @@ -8637,6 +8863,17 @@ function MessageBubble({ // Selection mode handling removed from here to allow normal rendering // We will wrap the output instead if (isSystem) { + const isPatSystemMessage = message.localType === 266287972401 + const patTitleRaw = isPatSystemMessage + ? (queryAppMsgText('appmsg > title') || queryAppMsgText('title') || message.parsedContent || '') + : '' + const patDisplayText = isPatSystemMessage + ? cleanMessageContent(String(patTitleRaw).replace(/^\s*\[拍一拍\]\s*/i, '')) + : '' + const systemContentNode = isPatSystemMessage + ? renderTextWithEmoji(patDisplayText || '拍一拍') + : message.parsedContent + return ( <div className={`message-bubble system ${isSelectionMode ? 'selectable' : ''}`} @@ -8665,7 +8902,7 @@ function MessageBubble({ {isSelected && <Check size={14} strokeWidth={3} />} </div> )} - <div className="bubble-content">{message.parsedContent}</div> + <div className="bubble-content">{systemContentNode}</div> </div> ) } @@ -8674,7 +8911,11 @@ function MessageBubble({ const renderContent = () => { if (isImage) { return ( - <div ref={imageContainerRef}> + <div + ref={imageContainerRef} + className={`image-stage ${imageStageLockHeight ? 'locked' : ''}`} + style={imageStageLockStyle} + > {imageLoading ? ( <div className="image-loading"> <Loader2 size={20} className="spin" /> @@ -8696,15 +8937,19 @@ function MessageBubble({ <img src={imageLocalPath} alt="图片" - className="image-message" + className={`image-message ${imageLoaded ? 'ready' : 'pending'}`} onClick={() => { void handleOpenImageViewer() }} onLoad={() => { + setImageLoaded(true) setImageError(false) stabilizeImageScrollAfterResize() + releaseImageStageLock() }} onError={() => { imageResizeBaselineRef.current = null + setImageLoaded(false) setImageError(true) + releaseImageStageLock() }} /> {imageLiveVideoPath && ( @@ -9030,10 +9275,16 @@ function MessageBubble({ const xmlType = message.xmlType || q('appmsg > type') || q('type') + // type 62: 拍一拍(按普通文本渲染,支持 [烟花] 这类 emoji 占位符) + if (xmlType === '62') { + const patText = cleanMessageContent((q('title') || cleanedParsedContent || '').replace(/^\s*\[拍一拍\]\s*/i, '')) + return <div className="bubble-content">{renderTextWithEmoji(patText || '拍一拍')}</div> + } + // type 57: 引用回复消息,解析 refermsg 渲染为引用样式 if (xmlType === '57') { const replyText = q('title') || cleanedParsedContent || '' - const referContent = q('refermsg > content') || '' + const referContent = queryPreferredQuotedContent() const referType = q('refermsg > type') || '' // 根据被引用消息类型渲染对应内容 @@ -9073,7 +9324,8 @@ function MessageBubble({ const title = message.linkTitle || q('title') || cleanedParsedContent || 'Card' const desc = message.appMsgDesc || q('des') const url = message.linkUrl || q('url') - const thumbUrl = message.linkThumb || message.appMsgThumbUrl || q('thumburl') || q('cdnthumburl') || q('cover') || q('coverurl') + const fallbackThumbUrl = appMsgThumbRawCandidate + const thumbUrl = isRenderableImageSrc(fallbackThumbUrl) ? fallbackThumbUrl : '' const musicUrl = message.appMsgMusicUrl || message.appMsgDataUrl || q('musicurl') || q('playurl') || q('dataurl') || q('lowurl') const sourceName = message.appMsgSourceName || q('sourcename') const sourceDisplayName = q('sourcedisplayname') || '' @@ -9147,9 +9399,7 @@ function MessageBubble({ loading="lazy" referrerPolicy="no-referrer" /> - ) : ( - <div className={`link-thumb-placeholder ${cardKind}`}>{cardKind.slice(0, 2).toUpperCase()}</div> - )} + ) : null} </div> </div> ) @@ -9157,7 +9407,7 @@ function MessageBubble({ if (kind === 'quote') { // 引用回复消息(appMsgKind='quote',xmlType=57) const replyText = message.linkTitle || q('title') || cleanedParsedContent || '' - const referContent = message.quotedContent || q('refermsg > content') || '' + const referContent = queryPreferredQuotedContent() return ( renderBubbleWithQuote( renderQuotedMessageBlock(renderTextWithEmoji(cleanMessageContent(referContent))), @@ -9348,7 +9598,7 @@ function MessageBubble({ // 引用回复消息 (type=57),防止被误判为链接 if (appMsgType === '57') { const replyText = parsedDoc?.querySelector('title')?.textContent?.trim() || cleanedParsedContent || '' - const referContent = parsedDoc?.querySelector('refermsg > content')?.textContent?.trim() || '' + const referContent = queryPreferredQuotedContent() const referType = parsedDoc?.querySelector('refermsg > type')?.textContent?.trim() || '' const renderReferContent2 = () => { @@ -9589,9 +9839,6 @@ function MessageBubble({ </div> <div className="link-body"> <div className="link-desc" title={desc}>{desc}</div> - <div className="link-thumb-placeholder"> - <Link size={24} /> - </div> </div> </div> ) diff --git a/src/pages/DualReportWindow.scss b/src/pages/DualReportWindow.scss index 44bee66..7eb5ff9 100644 --- a/src/pages/DualReportWindow.scss +++ b/src/pages/DualReportWindow.scss @@ -238,7 +238,7 @@ } .scene-message.sent .scene-avatar { - border-color: color-mix(in srgb, var(--primary) 30%, var(--bg-tertiary, rgba(0, 0, 0, 0.08))); + border-color: rgba(var(--ar-primary-rgb), 0.3); } .dual-stat-grid { @@ -981,4 +981,4 @@ transform: translateY(0); } } -} \ No newline at end of file +} diff --git a/src/pages/DualReportWindow.tsx b/src/pages/DualReportWindow.tsx index 585fb1e..e9f9627 100644 --- a/src/pages/DualReportWindow.tsx +++ b/src/pages/DualReportWindow.tsx @@ -1,6 +1,10 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' +import { Check, Download, Image, SlidersHorizontal, X } from 'lucide-react' +import html2canvas from 'html2canvas' import ReportHeatmap from '../components/ReportHeatmap' import ReportWordCloud from '../components/ReportWordCloud' +import { useThemeStore } from '../stores/themeStore' +import { drawPatternBackground } from '../utils/reportExport' import './AnnualReportWindow.scss' import './DualReportWindow.scss' @@ -66,6 +70,12 @@ interface DualReportData { streak?: { days: number; startDate: string; endDate: string } } +interface SectionInfo { + id: string + name: string + ref: React.RefObject<HTMLElement | null> +} + function DualReportWindow() { const [reportData, setReportData] = useState<DualReportData | null>(null) const [isLoading, setIsLoading] = useState(true) @@ -75,6 +85,29 @@ function DualReportWindow() { const [myEmojiUrl, setMyEmojiUrl] = useState<string | null>(null) const [friendEmojiUrl, setFriendEmojiUrl] = useState<string | null>(null) const [activeWordCloudTab, setActiveWordCloudTab] = useState<'shared' | 'my' | 'friend'>('shared') + const [isExporting, setIsExporting] = useState(false) + const [exportProgress, setExportProgress] = useState('') + const [showExportModal, setShowExportModal] = useState(false) + const [selectedSections, setSelectedSections] = useState<Set<string>>(new Set()) + const [fabOpen, setFabOpen] = useState(false) + const [exportMode, setExportMode] = useState<'separate' | 'long'>('separate') + + const { themeMode } = useThemeStore() + + const sectionRefs = { + cover: useRef<HTMLElement>(null), + firstChat: useRef<HTMLElement>(null), + yearFirstChat: useRef<HTMLElement>(null), + heatmap: useRef<HTMLElement>(null), + initiative: useRef<HTMLElement>(null), + response: useRef<HTMLElement>(null), + streak: useRef<HTMLElement>(null), + wordCloud: useRef<HTMLElement>(null), + stats: useRef<HTMLElement>(null), + ending: useRef<HTMLElement>(null) + } + + const containerRef = useRef<HTMLDivElement>(null) useEffect(() => { const params = new URLSearchParams(window.location.hash.split('?')[1] || '') @@ -151,6 +184,351 @@ function DualReportWindow() { void loadEmojis() }, [reportData]) + const formatFileYearLabel = (year: number) => (year === 0 ? '历史以来' : String(year)) + + const sanitizeFileNameSegment = (value: string) => { + const sanitized = value.replace(/[\\/:*?"<>|]/g, '_').trim() + return sanitized || '好友' + } + + const getAvailableSections = (): SectionInfo[] => { + if (!reportData) return [] + + const sections: SectionInfo[] = [ + { id: 'cover', name: '封面', ref: sectionRefs.cover }, + { id: 'firstChat', name: '首次聊天', ref: sectionRefs.firstChat } + ] + + if (reportData.yearFirstChat && (!reportData.firstChat || reportData.yearFirstChat.createTime !== reportData.firstChat.createTime)) { + sections.push({ id: 'yearFirstChat', name: '第一段对话', ref: sectionRefs.yearFirstChat }) + } + if (reportData.heatmap) { + sections.push({ id: 'heatmap', name: '作息规律', ref: sectionRefs.heatmap }) + } + if (reportData.initiative) { + sections.push({ id: 'initiative', name: '主动性', ref: sectionRefs.initiative }) + } + if (reportData.response) { + sections.push({ id: 'response', name: '回应速度', ref: sectionRefs.response }) + } + if (reportData.streak) { + sections.push({ id: 'streak', name: '最长连续聊天', ref: sectionRefs.streak }) + } + + sections.push({ id: 'wordCloud', name: '常用语', ref: sectionRefs.wordCloud }) + sections.push({ id: 'stats', name: '年度统计', ref: sectionRefs.stats }) + sections.push({ id: 'ending', name: '尾声', ref: sectionRefs.ending }) + + return sections + } + + const exportSection = async (section: SectionInfo): Promise<{ name: string; data: string } | null> => { + const element = section.ref.current + if (!element) { + return null + } + + const OUTPUT_WIDTH = 1920 + const OUTPUT_HEIGHT = 1080 + let wordCloudInner: HTMLElement | null = null + let wordTags: NodeListOf<HTMLElement> | null = null + let wordCloudOriginalStyle = '' + const wordTagOriginalStyles: string[] = [] + const originalStyle = element.style.cssText + + try { + const selection = window.getSelection() + if (selection && selection.rangeCount > 0) selection.removeAllRanges() + const activeEl = document.activeElement as HTMLElement | null + activeEl?.blur?.() + document.body.classList.add('exporting-snapshot') + document.documentElement.classList.add('exporting-snapshot') + + element.style.minHeight = 'auto' + element.style.padding = '40px 20px' + element.style.background = 'transparent' + element.style.backgroundColor = 'transparent' + element.style.boxShadow = 'none' + + wordCloudInner = element.querySelector('.word-cloud-inner') as HTMLElement | null + wordTags = element.querySelectorAll('.word-tag') as NodeListOf<HTMLElement> + + if (wordCloudInner) { + wordCloudOriginalStyle = wordCloudInner.style.cssText + wordCloudInner.style.transform = 'none' + } + + wordTags.forEach((tag, index) => { + wordTagOriginalStyles[index] = tag.style.cssText + tag.style.opacity = String(tag.style.getPropertyValue('--final-opacity') || '1') + tag.style.animation = 'none' + }) + + await new Promise((resolve) => setTimeout(resolve, 50)) + + const computedStyle = getComputedStyle(document.documentElement) + const bgColor = computedStyle.getPropertyValue('--bg-primary').trim() || '#F9F8F6' + + const canvas = await html2canvas(element, { + backgroundColor: 'transparent', + scale: 2, + useCORS: true, + allowTaint: true, + logging: false, + onclone: (clonedDoc) => { + clonedDoc.body.classList.add('exporting-snapshot') + clonedDoc.documentElement.classList.add('exporting-snapshot') + clonedDoc.getSelection?.()?.removeAllRanges() + } + }) + + const outputCanvas = document.createElement('canvas') + outputCanvas.width = OUTPUT_WIDTH + outputCanvas.height = OUTPUT_HEIGHT + const ctx = outputCanvas.getContext('2d') + if (!ctx) { + return null + } + + const isDark = themeMode === 'dark' + await drawPatternBackground(ctx, OUTPUT_WIDTH, OUTPUT_HEIGHT, bgColor, isDark) + + const PADDING = 80 + const contentWidth = OUTPUT_WIDTH - PADDING * 2 + const contentHeight = OUTPUT_HEIGHT - PADDING * 2 + const srcRatio = canvas.width / canvas.height + const dstRatio = contentWidth / contentHeight + + let drawWidth: number + let drawHeight: number + let drawX: number + let drawY: number + + if (srcRatio > dstRatio) { + drawWidth = contentWidth + drawHeight = contentWidth / srcRatio + drawX = PADDING + drawY = PADDING + (contentHeight - drawHeight) / 2 + } else { + drawHeight = contentHeight + drawWidth = contentHeight * srcRatio + drawX = PADDING + (contentWidth - drawWidth) / 2 + drawY = PADDING + } + + ctx.drawImage(canvas, drawX, drawY, drawWidth, drawHeight) + return { name: section.name, data: outputCanvas.toDataURL('image/png') } + } catch { + return null + } finally { + element.style.cssText = originalStyle + if (wordCloudInner) { + wordCloudInner.style.cssText = wordCloudOriginalStyle + } + wordTags?.forEach((tag, index) => { + tag.style.cssText = wordTagOriginalStyles[index] + }) + document.body.classList.remove('exporting-snapshot') + document.documentElement.classList.remove('exporting-snapshot') + } + } + + const exportFullReport = async (filterIds?: Set<string>) => { + if (!containerRef.current || !reportData) { + return + } + + setIsExporting(true) + setExportProgress('正在生成长图...') + + let wordCloudInner: HTMLElement | null = null + let wordTags: NodeListOf<HTMLElement> | null = null + let wordCloudOriginalStyle = '' + const wordTagOriginalStyles: string[] = [] + const container = containerRef.current + const sections = Array.from(container.querySelectorAll('.section')) as HTMLElement[] + const originalStyles = sections.map((section) => section.style.cssText) + + try { + const selection = window.getSelection() + if (selection && selection.rangeCount > 0) selection.removeAllRanges() + const activeEl = document.activeElement as HTMLElement | null + activeEl?.blur?.() + document.body.classList.add('exporting-snapshot') + document.documentElement.classList.add('exporting-snapshot') + + sections.forEach((section) => { + section.style.minHeight = 'auto' + section.style.padding = '40px 0' + }) + + if (filterIds) { + getAvailableSections().forEach((section) => { + if (!filterIds.has(section.id) && section.ref.current) { + section.ref.current.style.display = 'none' + } + }) + } + + wordCloudInner = container.querySelector('.word-cloud-inner') as HTMLElement | null + wordTags = container.querySelectorAll('.word-tag') as NodeListOf<HTMLElement> + + if (wordCloudInner) { + wordCloudOriginalStyle = wordCloudInner.style.cssText + wordCloudInner.style.transform = 'none' + } + + wordTags.forEach((tag, index) => { + wordTagOriginalStyles[index] = tag.style.cssText + tag.style.opacity = String(tag.style.getPropertyValue('--final-opacity') || '1') + tag.style.animation = 'none' + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + const computedStyle = getComputedStyle(document.documentElement) + const bgColor = computedStyle.getPropertyValue('--bg-primary').trim() || '#F9F8F6' + + const canvas = await html2canvas(container, { + backgroundColor: 'transparent', + scale: 2, + useCORS: true, + allowTaint: true, + logging: false, + onclone: (clonedDoc) => { + clonedDoc.body.classList.add('exporting-snapshot') + clonedDoc.documentElement.classList.add('exporting-snapshot') + clonedDoc.getSelection?.()?.removeAllRanges() + } + }) + + const outputCanvas = document.createElement('canvas') + outputCanvas.width = canvas.width + outputCanvas.height = canvas.height + const ctx = outputCanvas.getContext('2d') + if (!ctx) { + throw new Error('无法创建导出画布') + } + + const isDark = themeMode === 'dark' + await drawPatternBackground(ctx, canvas.width, canvas.height, bgColor, isDark) + ctx.drawImage(canvas, 0, 0) + + const yearFilePrefix = formatFileYearLabel(reportData.year) + const friendFileSegment = sanitizeFileNameSegment(reportData.friendName || reportData.friendUsername) + const link = document.createElement('a') + link.download = `${yearFilePrefix}双人年度报告_${friendFileSegment}${filterIds ? '_自定义' : ''}.png` + link.href = outputCanvas.toDataURL('image/png') + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + } catch (e) { + alert('导出失败: ' + String(e)) + } finally { + sections.forEach((section, index) => { + section.style.cssText = originalStyles[index] + }) + if (wordCloudInner) { + wordCloudInner.style.cssText = wordCloudOriginalStyle + } + wordTags?.forEach((tag, index) => { + tag.style.cssText = wordTagOriginalStyles[index] + }) + document.body.classList.remove('exporting-snapshot') + document.documentElement.classList.remove('exporting-snapshot') + setIsExporting(false) + setExportProgress('') + } + } + + const exportSelectedSections = async () => { + if (!reportData) return + + const sections = getAvailableSections().filter((section) => selectedSections.has(section.id)) + if (sections.length === 0) { + alert('请至少选择一个板块') + return + } + + if (exportMode === 'long') { + setShowExportModal(false) + await exportFullReport(selectedSections) + setSelectedSections(new Set()) + return + } + + setIsExporting(true) + setShowExportModal(false) + + const exportedImages: Array<{ name: string; data: string }> = [] + + for (let index = 0; index < sections.length; index++) { + const section = sections[index] + setExportProgress(`正在导出: ${section.name} (${index + 1}/${sections.length})`) + + const result = await exportSection(section) + if (result) { + exportedImages.push(result) + } + } + + if (exportedImages.length === 0) { + alert('导出失败') + setIsExporting(false) + setExportProgress('') + return + } + + const dirResult = await window.electronAPI.dialog.openDirectory({ + title: '选择导出文件夹', + properties: ['openDirectory', 'createDirectory'] + }) + if (dirResult.canceled || !dirResult.filePaths?.[0]) { + setIsExporting(false) + setExportProgress('') + return + } + + setExportProgress('正在写入文件...') + const yearFilePrefix = formatFileYearLabel(reportData.year) + const friendFileSegment = sanitizeFileNameSegment(reportData.friendName || reportData.friendUsername) + const exportResult = await window.electronAPI.annualReport.exportImages({ + baseDir: dirResult.filePaths[0], + folderName: `${yearFilePrefix}双人年度报告_${friendFileSegment}_分模块`, + images: exportedImages.map((image) => ({ + name: `${yearFilePrefix}双人年度报告_${friendFileSegment}_${image.name}.png`, + dataUrl: image.data + })) + }) + + if (!exportResult.success) { + alert('导出失败: ' + (exportResult.error || '未知错误')) + } + + setIsExporting(false) + setExportProgress('') + setSelectedSections(new Set()) + } + + const toggleSection = (id: string) => { + const next = new Set(selectedSections) + if (next.has(id)) { + next.delete(id) + } else { + next.add(id) + } + setSelectedSections(next) + } + + const toggleAll = () => { + const sections = getAvailableSections() + if (selectedSections.size === sections.length) { + setSelectedSections(new Set()) + return + } + setSelectedSections(new Set(sections.map((section) => section.id))) + } + if (isLoading) { return ( <div className="annual-report-window loading"> @@ -305,7 +683,7 @@ function DualReportWindow() { if (emojiUrl) { return ( <div className="report-emoji-container"> - <img src={emojiUrl} alt="表情" className="report-emoji-img" onError={(e) => { + <img src={emojiUrl} alt="表情" className="report-emoji-img" crossOrigin="anonymous" onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; (e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style'); }} /> @@ -356,7 +734,7 @@ function DualReportWindow() { if (avatarUrl) { return ( <div className="scene-avatar with-image"> - <img src={avatarUrl} alt={isSentByMe ? 'me-avatar' : 'friend-avatar'} /> + <img src={avatarUrl} alt={isSentByMe ? 'me-avatar' : 'friend-avatar'} crossOrigin="anonymous" /> </div> ) } @@ -419,9 +797,99 @@ function DualReportWindow() { <div className="deco-circle c5" /> </div> + <div className={`fab-container ${fabOpen ? 'open' : ''}`}> + <button + className="fab-item" + onClick={() => { + setFabOpen(false) + setExportMode('separate') + setShowExportModal(true) + }} + title="分模块导出" + > + <Image size={18} /> + </button> + <button + className="fab-item" + onClick={() => { + setFabOpen(false) + setExportMode('long') + setShowExportModal(true) + }} + title="自定义导出长图" + > + <SlidersHorizontal size={18} /> + </button> + <button + className="fab-item" + onClick={() => { + setFabOpen(false) + void exportFullReport() + }} + title="导出长图" + > + <Download size={18} /> + </button> + <button className="fab-main" onClick={() => setFabOpen(!fabOpen)}> + {fabOpen ? <X size={22} /> : <Download size={22} />} + </button> + </div> + + {isExporting && ( + <div className="export-overlay"> + <div className="export-progress-modal"> + <div className="export-spinner"> + <div className="spinner-ring"></div> + <Download size={24} className="spinner-icon" /> + </div> + <p className="export-title">正在导出</p> + <p className="export-status">{exportProgress}</p> + </div> + </div> + )} + + {showExportModal && ( + <div className="export-overlay" onClick={() => setShowExportModal(false)}> + <div className="export-modal section-selector" onClick={(e) => e.stopPropagation()}> + <div className="modal-header"> + <h3>{exportMode === 'long' ? '自定义导出长图' : '选择要导出的板块'}</h3> + <button className="close-btn" onClick={() => setShowExportModal(false)}> + <X size={20} /> + </button> + </div> + <div className="section-grid"> + {getAvailableSections().map((section) => ( + <div + key={section.id} + className={`section-card ${selectedSections.has(section.id) ? 'selected' : ''}`} + onClick={() => toggleSection(section.id)} + > + <div className="card-check"> + {selectedSections.has(section.id) && <Check size={14} />} + </div> + <span>{section.name}</span> + </div> + ))} + </div> + <div className="modal-footer"> + <button className="select-all-btn" onClick={toggleAll}> + {selectedSections.size === getAvailableSections().length ? '取消全选' : '全选'} + </button> + <button + className="confirm-btn" + onClick={() => void exportSelectedSections()} + disabled={selectedSections.size === 0} + > + {exportMode === 'long' ? '生成长图' : '导出'} {selectedSections.size > 0 ? `(${selectedSections.size})` : ''} + </button> + </div> + </div> + </div> + )} + <div className="report-scroll-view"> - <div className="report-container"> - <section className="section"> + <div className="report-container" ref={containerRef}> + <section className="section" ref={sectionRefs.cover}> <div className="label-text">WEFLOW · DUAL REPORT</div> <h1 className="hero-title dual-cover-title">{yearTitle}<br />双人聊天报告</h1> <hr className="divider" /> @@ -433,7 +901,7 @@ function DualReportWindow() { <p className="hero-desc">每一次对话都值得被珍藏</p> </section> - <section className="section"> + <section className="section" ref={sectionRefs.firstChat}> <div className="label-text">首次聊天</div> <h2 className="hero-title">故事的开始</h2> {firstChat ? ( @@ -457,7 +925,7 @@ function DualReportWindow() { </section> {yearFirstChat && (!firstChat || yearFirstChat.createTime !== firstChat.createTime) ? ( - <section className="section"> + <section className="section" ref={sectionRefs.yearFirstChat}> <div className="label-text">第一段对话</div> <h2 className="hero-title"> {reportData.year === 0 ? '你们的第一段对话' : `${reportData.year}年的第一段对话`} @@ -473,7 +941,7 @@ function DualReportWindow() { ) : null} {reportData.heatmap && ( - <section className="section"> + <section className="section" ref={sectionRefs.heatmap}> <div className="label-text">聊天习惯</div> <h2 className="hero-title">作息规律</h2> {mostActive && ( @@ -486,14 +954,14 @@ function DualReportWindow() { )} {reportData.initiative && ( - <section className="section"> + <section className="section" ref={sectionRefs.initiative}> <div className="label-text">主动性</div> <h2 className="hero-title">情感的天平</h2> <div className="initiative-container"> <div className="initiative-bar-wrapper"> <div className="initiative-side"> <div className="avatar-placeholder"> - {reportData.selfAvatarUrl ? <img src={reportData.selfAvatarUrl} alt="me-avatar" /> : '我'} + {reportData.selfAvatarUrl ? <img src={reportData.selfAvatarUrl} alt="me-avatar" crossOrigin="anonymous" /> : '我'} </div> <div className="count">{reportData.initiative.initiated}次</div> <div className="percent">{initiatedPercent.toFixed(1)}%</div> @@ -507,7 +975,7 @@ function DualReportWindow() { </div> <div className="initiative-side"> <div className="avatar-placeholder"> - {reportData.friendAvatarUrl ? <img src={reportData.friendAvatarUrl} alt="friend-avatar" /> : reportData.friendName.substring(0, 1)} + {reportData.friendAvatarUrl ? <img src={reportData.friendAvatarUrl} alt="friend-avatar" crossOrigin="anonymous" /> : reportData.friendName.substring(0, 1)} </div> <div className="count">{reportData.initiative.received}次</div> <div className="percent">{receivedPercent.toFixed(1)}%</div> @@ -521,7 +989,7 @@ function DualReportWindow() { )} {reportData.response && ( - <section className="section"> + <section className="section" ref={sectionRefs.response}> <div className="label-text">回应速度</div> <h2 className="hero-title">你说,我在</h2> <div className="response-pulse-container"> @@ -558,7 +1026,7 @@ function DualReportWindow() { )} {reportData.streak && ( - <section className="section"> + <section className="section" ref={sectionRefs.streak}> <div className="label-text">聊天火花</div> <h2 className="hero-title">最长连续聊天</h2> <div className="streak-spark-visual premium"> @@ -596,7 +1064,7 @@ function DualReportWindow() { </section> )} - <section className="section word-cloud-section"> + <section className="section word-cloud-section" ref={sectionRefs.wordCloud}> <div className="label-text">常用语</div> <h2 className="hero-title">{yearTitle}常用语</h2> @@ -640,7 +1108,7 @@ function DualReportWindow() { </div> </section> - <section className="section"> + <section className="section" ref={sectionRefs.stats}> <div className="label-text">年度统计</div> <h2 className="hero-title">{yearTitle}数据概览</h2> <div className="dual-stat-grid"> @@ -664,7 +1132,7 @@ function DualReportWindow() { <div className="emoji-card"> <div className="emoji-title">我常用的表情</div> {myEmojiUrl ? ( - <img src={myEmojiUrl} alt="my-emoji" onError={(e) => { + <img src={myEmojiUrl} alt="my-emoji" crossOrigin="anonymous" onError={(e) => { (e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style'); (e.target as HTMLImageElement).style.display = 'none'; }} /> @@ -677,7 +1145,7 @@ function DualReportWindow() { <div className="emoji-card"> <div className="emoji-title">{reportData.friendName}常用的表情</div> {friendEmojiUrl ? ( - <img src={friendEmojiUrl} alt="friend-emoji" onError={(e) => { + <img src={friendEmojiUrl} alt="friend-emoji" crossOrigin="anonymous" onError={(e) => { (e.target as HTMLImageElement).nextElementSibling?.removeAttribute('style'); (e.target as HTMLImageElement).style.display = 'none'; }} /> @@ -690,7 +1158,7 @@ function DualReportWindow() { </div> </section> - <section className="section"> + <section className="section" ref={sectionRefs.ending}> <div className="label-text">尾声</div> <h2 className="hero-title">谢谢你一直在</h2> <p className="hero-desc">愿我们继续把故事写下去</p> diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index ab340f4..0944735 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -1,14 +1,18 @@ .export-board-page { - min-height: calc(100% + 48px); - height: auto; - margin: -24px; - padding: 20px; - background: var(--bg-primary); + min-height: 100%; + height: 100%; + margin: -24px -24px 0; + padding: 18px 22px 12px; + background: + radial-gradient(1200px 520px at 6% -8%, color-mix(in srgb, var(--primary) 11%, transparent), transparent 65%), + radial-gradient(860px 420px at 90% 0%, color-mix(in srgb, var(--primary) 7%, transparent), transparent 66%), + var(--bg-primary); display: flex; flex-direction: column; gap: 16px; overflow-x: hidden; - overflow-y: visible; + overflow-y: hidden; + animation: exportPageEnter 0.34s ease-out; .spin { animation: exportSpin 1s linear infinite; @@ -18,12 +22,16 @@ .export-top-panel { display: block; flex-shrink: 0; + position: relative; + z-index: 55; + animation: exportSectionReveal 0.34s ease both; } .export-top-bar { display: flex; align-items: stretch; gap: 12px; + position: relative; } .export-section-title-row { @@ -31,6 +39,7 @@ align-items: center; justify-content: flex-start; gap: 6px; + animation: exportSectionReveal 0.38s ease both; } .session-load-detail-entry { @@ -58,6 +67,12 @@ background: color-mix(in srgb, var(--primary) 8%, var(--bg-secondary)); color: var(--text-primary); } + + &.open { + border-color: color-mix(in srgb, var(--primary) 30%, var(--border-color)); + background: color-mix(in srgb, var(--primary) 5%, var(--bg-secondary)); + color: var(--text-primary); + } } .session-load-detail-entry-icon { @@ -98,6 +113,11 @@ opacity: 1; } +.session-load-detail-entry.open .session-load-detail-entry-bar { + animation: none; + opacity: 0.86; +} + @keyframes sessionLoadDetailBars { 0%, 100% { transform: scaleY(0.72); @@ -112,9 +132,10 @@ .export-section-title { margin: 0; - font-size: 15px; - font-weight: 600; + font-size: 18px; + font-weight: 700; color: var(--text-primary); + letter-spacing: 0.2px; } .section-info-tooltip { @@ -177,8 +198,9 @@ display: flex; align-items: center; justify-content: center; - z-index: 2200; + z-index: 7800; padding: 20px; + animation: exportOverlayFadeIn 0.2s ease both; } .session-load-detail-modal { @@ -191,6 +213,7 @@ box-shadow: 0 22px 46px rgba(0, 0, 0, 0.28); display: flex; flex-direction: column; + animation: exportModalPopIn 0.24s cubic-bezier(0.2, 0.78, 0.26, 1) both; } .session-load-detail-header { @@ -485,8 +508,9 @@ display: flex; align-items: center; justify-content: center; - z-index: 2250; + z-index: 7850; padding: 20px; + animation: exportOverlayFadeIn 0.2s ease both; } .session-mutual-friends-modal { @@ -499,6 +523,7 @@ box-shadow: 0 22px 46px rgba(0, 0, 0, 0.28); display: flex; flex-direction: column; + animation: exportModalPopIn 0.24s cubic-bezier(0.2, 0.78, 0.26, 1) both; } .session-mutual-friends-header { @@ -716,14 +741,23 @@ --top-inline-control-height: 34px; flex: 0 1 980px; width: min(980px, 100%); - background: var(--card-bg); - border: 1px solid var(--border-color); - border-radius: 12px; - padding: 12px; + background: + linear-gradient(180deg, color-mix(in srgb, var(--card-bg) 75%, var(--bg-primary)) 0%, var(--card-bg) 100%); + border: 1px solid color-mix(in srgb, var(--border-color) 86%, transparent); + border-radius: 14px; + box-shadow: 0 14px 28px rgba(15, 23, 42, 0.08); + padding: 13px; display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(240px, 1fr) auto; gap: 10px; align-items: stretch; + animation: exportSectionReveal 0.4s ease both; + transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease; + + &:hover { + border-color: color-mix(in srgb, var(--primary) 30%, var(--border-color)); + box-shadow: 0 16px 30px rgba(15, 23, 42, 0.1); + } .control-label { font-size: 11px; @@ -759,12 +793,13 @@ .path-value { border: 1px dashed var(--border-color); border-radius: 8px; - background: var(--bg-secondary); + background: color-mix(in srgb, var(--bg-secondary) 86%, var(--bg-primary)); display: flex; align-items: stretch; min-width: 0; flex: 1; overflow: hidden; + transition: border-color 0.15s ease, background 0.15s ease; } .path-link { @@ -816,6 +851,11 @@ width: 100%; max-width: 100%; z-index: 40; + isolation: isolate; + + &.open { + z-index: 3200; + } } .more-export-settings-control { @@ -836,12 +876,13 @@ line-height: 1; white-space: nowrap; cursor: pointer; - transition: border-color 0.12s ease, color 0.12s ease, background 0.12s ease; + transition: border-color 0.12s ease, color 0.12s ease, background 0.12s ease, transform 0.12s ease; &:hover { border-color: var(--primary); color: var(--primary); background: color-mix(in srgb, var(--primary) 6%, var(--bg-secondary)); + transform: translateY(-1px); } } @@ -859,10 +900,11 @@ text-overflow: ellipsis; text-align: left; cursor: pointer; - transition: border-color 0.12s ease; + transition: border-color 0.12s ease, background 0.12s ease; &:hover { border-color: var(--primary); + background: color-mix(in srgb, var(--primary) 6%, var(--bg-secondary)); } &.active { @@ -877,16 +919,18 @@ right: auto; width: clamp(300px, 36vw, 420px); max-width: calc(100vw - 40px); - background: var(--bg-primary); - border: 1px solid var(--border-color); - border-radius: 12px; - box-shadow: 0 16px 36px rgba(0, 0, 0, 0.28); + background: var(--bg-secondary-solid, #ffffff); + border: 1px solid color-mix(in srgb, var(--border-color) 88%, transparent); + border-radius: 14px; + box-shadow: 0 22px 38px rgba(15, 23, 42, 0.2); padding: 6px; z-index: 3000; max-height: 260px; overflow-y: auto; + overflow-x: hidden; opacity: 0; transform: translateY(-4px); + transform-origin: top left; pointer-events: none; visibility: hidden; transition: opacity 0.12s ease, transform 0.12s ease, visibility 0.12s step-end; @@ -899,6 +943,7 @@ pointer-events: auto; visibility: visible; transition: opacity 0.12s ease, transform 0.12s ease, visibility 0.12s step-start; + animation: exportPopoverEnter 0.18s ease both; } } @@ -908,12 +953,14 @@ background: transparent; color: var(--text-primary); text-align: left; - padding: 8px 10px; + padding: 10px 10px; border-radius: 8px; cursor: pointer; display: flex; flex-direction: column; - gap: 2px; + gap: 3px; + min-height: 58px; + transition: background 0.12s ease, color 0.12s ease; &:hover { background: var(--bg-hover); @@ -939,7 +986,7 @@ .layout-prefix-toggle { margin-top: 4px; - padding: 10px; + padding: 11px 10px 10px; border-top: 1px solid color-mix(in srgb, var(--border-color) 85%, transparent); display: flex; align-items: center; @@ -1012,9 +1059,10 @@ min-width: 92px; min-height: 42px; margin-left: auto; - border: 1px solid var(--border-color); - border-radius: 12px; - background: var(--card-bg); + border: 1px solid color-mix(in srgb, var(--border-color) 86%, transparent); + border-radius: 14px; + background: + linear-gradient(180deg, color-mix(in srgb, var(--card-bg) 80%, var(--bg-primary)) 0%, var(--card-bg) 100%); color: var(--text-primary); padding: 10px 12px; display: inline-flex; @@ -1026,12 +1074,15 @@ cursor: pointer; flex-shrink: 0; align-self: stretch; - transition: border-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease, transform 0.12s ease; + transition: border-color 0.14s ease, color 0.14s ease, box-shadow 0.14s ease, transform 0.14s ease, background 0.14s ease; + animation: exportSectionReveal 0.46s ease both; &:hover { - border-color: var(--primary); + border-color: color-mix(in srgb, var(--primary) 48%, var(--border-color)); color: var(--primary); - transform: translateY(-1px); + transform: translateY(-2px); + background: color-mix(in srgb, var(--primary) 7%, var(--card-bg)); + box-shadow: 0 10px 18px rgba(15, 23, 42, 0.12); } &.has-alert { @@ -1047,8 +1098,9 @@ display: flex; align-items: center; justify-content: center; - z-index: 2300; + z-index: 7700; padding: 20px; + animation: exportOverlayFadeIn 0.2s ease both; } .export-defaults-modal { @@ -1061,6 +1113,7 @@ box-shadow: 0 22px 46px rgba(0, 0, 0, 0.28); display: flex; flex-direction: column; + animation: exportModalPopIn 0.24s cubic-bezier(0.2, 0.78, 0.26, 1) both; } .export-defaults-modal-header { @@ -1120,19 +1173,31 @@ .content-card-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); - gap: 8px; + grid-template-columns: repeat(auto-fit, minmax(142px, 1fr)); + gap: 10px; flex-shrink: 0; + animation: exportSectionReveal 0.52s ease both; } .content-card { - border: 1px solid var(--border-color); - border-radius: 12px; - background: var(--card-bg); - padding: 10px; + border: 1px solid color-mix(in srgb, var(--border-color) 86%, transparent); + border-radius: 13px; + background: + linear-gradient(180deg, color-mix(in srgb, var(--card-bg) 82%, var(--bg-primary)) 0%, var(--card-bg) 100%); + box-shadow: 0 10px 20px rgba(15, 23, 42, 0.06); + padding: 11px; display: flex; flex-direction: column; gap: 8px; + transition: border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease, background 0.16s ease; + animation: exportCardReveal 0.42s ease both; + + &:hover { + border-color: color-mix(in srgb, var(--primary) 38%, var(--border-color)); + box-shadow: 0 16px 24px rgba(15, 23, 42, 0.11); + transform: translateY(-2px); + background: color-mix(in srgb, var(--primary) 4%, var(--card-bg)); + } .card-header { display: flex; @@ -1145,9 +1210,9 @@ display: flex; align-items: center; gap: 5px; - font-size: 13px; + font-size: 14px; color: var(--text-primary); - font-weight: 600; + font-weight: 700; } .card-title-meta { @@ -1172,12 +1237,12 @@ display: flex; align-items: center; justify-content: space-between; - font-size: 11px; + font-size: 12px; color: var(--text-secondary); strong { color: var(--text-primary); - font-size: 13px; + font-size: 14px; } } } @@ -1185,10 +1250,10 @@ .card-export-btn { margin-top: auto; border: 1px solid transparent; - border-radius: 7px; - padding: 7px 9px; + border-radius: 9px; + padding: 8px 10px; cursor: pointer; - font-size: 12px; + font-size: 13px; font-weight: 600; display: inline-flex; align-items: center; @@ -1203,6 +1268,8 @@ &.primary:hover { background: var(--primary-hover); + transform: translateY(-1px); + box-shadow: 0 8px 14px color-mix(in srgb, var(--primary) 24%, transparent); } &.secondary { @@ -1215,6 +1282,7 @@ border-color: color-mix(in srgb, var(--primary) 28%, transparent); color: var(--text-primary); background: color-mix(in srgb, var(--bg-primary) 94%, var(--primary) 6%); + transform: translateY(-1px); } &:disabled { @@ -1240,6 +1308,13 @@ } } +.content-card-grid .content-card:nth-child(1) { animation-delay: 0.03s; } +.content-card-grid .content-card:nth-child(2) { animation-delay: 0.07s; } +.content-card-grid .content-card:nth-child(3) { animation-delay: 0.11s; } +.content-card-grid .content-card:nth-child(4) { animation-delay: 0.15s; } +.content-card-grid .content-card:nth-child(5) { animation-delay: 0.19s; } +.content-card-grid .content-card:nth-child(6) { animation-delay: 0.23s; } + .count-loading { color: var(--text-tertiary); font-size: 12px; @@ -1255,12 +1330,13 @@ right: 0; bottom: 0; left: 0; - z-index: 1180; + z-index: 7600; background: rgba(15, 23, 42, 0.28); display: flex; align-items: flex-start; justify-content: center; padding: 24px 20px; + animation: exportOverlayFadeIn 0.2s ease both; } .task-center-modal { @@ -1273,6 +1349,7 @@ display: flex; flex-direction: column; overflow: hidden; + animation: exportModalPopIn 0.24s cubic-bezier(0.2, 0.78, 0.26, 1) both; } .task-center-modal-header { @@ -1330,6 +1407,7 @@ gap: 10px; align-items: flex-start; background: var(--bg-secondary-solid, #ffffff); + animation: exportItemRise 0.2s ease both; &.running { border-color: var(--primary); @@ -1563,11 +1641,23 @@ } .session-table-section { - flex: 0 0 auto; - min-height: 420px; + flex: 1 1 420px; + min-height: 0; display: flex; flex-direction: column; overflow: visible; + border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent); + border-radius: 14px; + background: + linear-gradient(180deg, color-mix(in srgb, var(--card-bg) 72%, var(--bg-primary)) 0%, color-mix(in srgb, var(--card-bg) 90%, var(--bg-primary)) 100%); + box-shadow: 0 14px 30px rgba(15, 23, 42, 0.07); + animation: exportSectionReveal 0.58s ease both; + transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease; + + &:hover { + border-color: color-mix(in srgb, var(--primary) 20%, var(--border-color)); + box-shadow: 0 18px 36px rgba(15, 23, 42, 0.09); + } } .table-stage-hint { @@ -1582,6 +1672,7 @@ color: var(--primary); font-size: 12px; width: fit-content; + animation: exportSectionReveal 0.35s ease both; } .table-toolbar { @@ -1590,9 +1681,10 @@ align-items: flex-start; gap: 12px; flex-wrap: wrap; - padding: 10px 12px; - border-bottom: 1px solid color-mix(in srgb, var(--border-color) 85%, transparent); - background: color-mix(in srgb, var(--bg-primary) 82%, var(--bg-secondary)); + padding: 12px 14px; + border-bottom: 1px solid color-mix(in srgb, var(--border-color) 82%, transparent); + background: linear-gradient(180deg, color-mix(in srgb, var(--bg-primary) 88%, var(--card-bg)) 0%, color-mix(in srgb, var(--bg-primary) 92%, var(--bg-secondary)) 100%); + transition: border-color 0.16s ease, background 0.16s ease; } .table-cache-meta { @@ -1627,7 +1719,8 @@ border: 1px solid var(--border-color); background: var(--bg-secondary); color: var(--text-secondary); - padding: 7px 6px; + min-height: 32px; + padding: 7px 10px; border-radius: 999px; cursor: pointer; font-size: 13px; @@ -1635,18 +1728,52 @@ display: inline-flex; align-items: center; justify-content: center; + transition: border-color 0.14s ease, background 0.14s ease, color 0.14s ease, transform 0.14s ease, box-shadow 0.14s ease; .tab-btn-content { display: inline-flex; align-items: center; - gap: 4px; + gap: 5px; line-height: 1; + + span:last-child { + min-width: 24px; + min-height: 18px; + padding: 0 6px; + border-radius: 999px; + background: color-mix(in srgb, var(--bg-primary) 82%, var(--bg-secondary)); + color: var(--text-secondary); + font-size: 11px; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + font-variant-numeric: tabular-nums; + } + } + + &:hover { + border-color: color-mix(in srgb, var(--primary) 36%, var(--border-color)); + color: var(--text-primary); + transform: translateY(-1px); + box-shadow: 0 7px 14px rgba(15, 23, 42, 0.08); } &.active { border-color: var(--primary); color: var(--primary); background: rgba(var(--primary-rgb), 0.12); + box-shadow: 0 6px 14px color-mix(in srgb, var(--primary) 24%, transparent); + + .tab-btn-content span:last-child { + background: color-mix(in srgb, var(--primary) 16%, var(--bg-secondary)); + color: color-mix(in srgb, var(--primary) 84%, var(--text-primary)); + } + } + + &:focus-visible { + outline: 2px solid color-mix(in srgb, var(--primary) 44%, transparent); + outline-offset: 2px; } } } @@ -1664,17 +1791,37 @@ align-items: center; gap: 8px; flex-wrap: wrap; + + .secondary-btn { + min-height: 34px; + border-radius: 10px; + transition: border-color 0.14s ease, background 0.14s ease, color 0.14s ease, transform 0.14s ease; + + &:hover:not(:disabled) { + transform: translateY(-1px); + border-color: color-mix(in srgb, var(--primary) 36%, var(--border-color)); + color: var(--primary); + background: color-mix(in srgb, var(--primary) 8%, var(--bg-secondary)); + } + } } .search-input-wrap { display: flex; align-items: center; gap: 6px; - padding: 8px 10px; - border-radius: 8px; + padding: 8px 11px; + border-radius: 10px; border: 1px solid var(--border-color); - background: var(--bg-secondary); - min-width: 220px; + background: color-mix(in srgb, var(--bg-secondary) 92%, var(--bg-primary)); + min-width: 240px; + transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease; + + &:focus-within { + border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color)); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 16%, transparent); + background: var(--bg-secondary); + } input { border: none; @@ -1682,15 +1829,27 @@ color: var(--text-primary); font-size: 13px; outline: none; - width: 180px; + width: 220px; } .clear-search { - border: none; - background: transparent; + border: 1px solid transparent; + background: color-mix(in srgb, var(--bg-primary) 84%, var(--bg-secondary)); color: var(--text-tertiary); cursor: pointer; - display: flex; + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 999px; + transition: border-color 0.12s ease, background 0.12s ease, color 0.12s ease; + + &:hover { + border-color: color-mix(in srgb, var(--primary) 36%, var(--border-color)); + color: var(--primary); + background: color-mix(in srgb, var(--primary) 8%, var(--bg-secondary)); + } } } @@ -1708,7 +1867,9 @@ .session-table-layout { display: flex; + flex: 1; min-height: 0; + padding: 10px; .table-wrap { flex: 1; @@ -1719,7 +1880,7 @@ .table-wrap { --contacts-native-scrollbar-compensation: 18px; --contacts-row-height: 76px; - --contacts-default-visible-rows: 10; + --contacts-default-visible-rows: 8; --contacts-default-list-height: calc(var(--contacts-row-height) * var(--contacts-default-visible-rows)); --contacts-select-col-width: 34px; --contacts-avatar-col-width: 44px; @@ -1731,32 +1892,49 @@ --contacts-message-col-width: 120px; --contacts-media-col-width: 72px; --contacts-action-col-width: 140px; + --contacts-actions-sticky-width: max(var(--contacts-action-col-width), 184px); --contacts-table-min-width: 1200px; overflow: hidden; - border: 1px solid var(--border-color); - border-radius: 10px; + border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent); + border-radius: 12px; min-height: 320px; height: auto; flex: 1; display: flex; flex-direction: column; - background: var(--bg-secondary); + background: linear-gradient(180deg, color-mix(in srgb, var(--bg-secondary) 84%, var(--bg-primary)) 0%, var(--bg-secondary) 100%); + box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 18%, transparent); + transition: border-color 0.16s ease, box-shadow 0.16s ease; + + &:hover { + border-color: color-mix(in srgb, var(--primary) 22%, var(--border-color)); + box-shadow: + inset 0 1px 0 color-mix(in srgb, #fff 24%, transparent), + 0 8px 18px rgba(15, 23, 42, 0.06); + } } .table-wrap { .table-scroll-shell { overflow: hidden; + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; } .table-scroll-viewport { + flex: 1; min-height: 0; overflow-x: auto; - overflow-y: visible; + overflow-y: hidden; scrollbar-width: none; -ms-overflow-style: none; background: var(--bg-secondary); padding-bottom: var(--contacts-native-scrollbar-compensation); margin-bottom: calc(-1 * var(--contacts-native-scrollbar-compensation)); + display: flex; + flex-direction: column; &::-webkit-scrollbar { display: none; @@ -1765,6 +1943,9 @@ .table-scroll-content { min-width: max(100%, var(--contacts-table-min-width)); + min-height: 0; + display: flex; + flex-direction: column; } .session-table-sticky { @@ -1902,6 +2083,7 @@ font-weight: 600; letter-spacing: 0.01em; flex-shrink: 0; + backdrop-filter: saturate(115%) blur(3px); &.is-draggable { cursor: grab; @@ -1938,7 +2120,7 @@ max-width: var(--contacts-main-col-width); display: flex; align-items: center; - gap: 8px; + gap: var(--contacts-column-gap); } .contacts-list-header-main-label { @@ -1977,8 +2159,8 @@ } .contacts-list-header-actions { - width: max(var(--contacts-action-col-width), 184px); - min-width: max(var(--contacts-action-col-width), 184px); + width: var(--contacts-actions-sticky-width); + min-width: var(--contacts-actions-sticky-width); display: flex; align-items: center; justify-content: flex-end; @@ -2007,8 +2189,8 @@ width: 100%; min-width: max(100%, var(--contacts-table-min-width)); flex: 1; - min-height: var(--contacts-default-list-height); - height: var(--contacts-default-list-height); + min-height: 0; + height: auto; position: relative; overflow-x: clip; overflow-y: auto; @@ -2074,10 +2256,13 @@ padding: 6px 10px; cursor: pointer; white-space: nowrap; + transition: border-color 0.14s ease, color 0.14s ease, background 0.14s ease, transform 0.14s ease; &:hover:not(:disabled) { border-color: var(--text-tertiary); color: var(--text-primary); + background: color-mix(in srgb, var(--bg-primary) 88%, var(--bg-secondary)); + transform: translateY(-1px); } &:disabled { @@ -2090,7 +2275,7 @@ border: none; border-radius: 8px; padding: 6px 10px; - background: var(--primary); + background: linear-gradient(180deg, color-mix(in srgb, var(--primary) 94%, #ffffff) 0%, var(--primary) 100%); color: #fff; font-size: 12px; cursor: pointer; @@ -2099,9 +2284,12 @@ gap: 6px; white-space: nowrap; flex-shrink: 0; + transition: transform 0.14s ease, box-shadow 0.14s ease, background 0.14s ease; &:hover:not(:disabled) { background: var(--primary-hover); + transform: translateY(-1px); + box-shadow: 0 8px 14px color-mix(in srgb, var(--primary) 30%, transparent); } .selection-export-count { @@ -2132,6 +2320,42 @@ &.selected .contact-item:hover { box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--primary) 60%, transparent); + transform: none; + } + + &.selected-contiguous-bottom { + padding-bottom: 0; + } + + &.selected-contiguous-bottom .contact-item { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + box-shadow: + inset 1px 0 0 color-mix(in srgb, var(--primary) 52%, transparent), + inset -1px 0 0 color-mix(in srgb, var(--primary) 52%, transparent), + inset 0 1px 0 color-mix(in srgb, var(--primary) 52%, transparent); + } + + &.selected-contiguous-top .contact-item { + margin-top: -1px; + border-top-left-radius: 0; + border-top-right-radius: 0; + box-shadow: + inset 1px 0 0 color-mix(in srgb, var(--primary) 52%, transparent), + inset -1px 0 0 color-mix(in srgb, var(--primary) 52%, transparent), + inset 0 -1px 0 color-mix(in srgb, var(--primary) 52%, transparent); + } + + &.selected-contiguous-top.selected-contiguous-bottom .contact-item { + box-shadow: + inset 1px 0 0 color-mix(in srgb, var(--primary) 52%, transparent), + inset -1px 0 0 color-mix(in srgb, var(--primary) 52%, transparent); + } + + &.selected-contiguous-bottom .contact-item:hover, + &.selected-contiguous-top .contact-item:hover, + &.selected-contiguous-top.selected-contiguous-bottom .contact-item:hover { + box-shadow: inherit; } } @@ -2145,7 +2369,7 @@ height: 72px; box-sizing: border-box; border-radius: 10px; - transition: all 0.2s; + transition: box-shadow 0.18s ease, background 0.18s ease, transform 0.18s ease; cursor: default; background: var(--contacts-row-bg); box-shadow: inset 0 0 0 1px transparent; @@ -2153,6 +2377,7 @@ &:hover { background: var(--contacts-row-bg); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text-tertiary) 24%, transparent); + transform: translateX(1px); } } @@ -2294,11 +2519,13 @@ color: var(--primary); font-variant-numeric: tabular-nums; cursor: pointer; + border-radius: 6px; + transition: color 0.12s ease, background 0.12s ease; &:hover { color: var(--primary-hover); - text-decoration: underline; - text-underline-offset: 2px; + background: color-mix(in srgb, var(--primary) 12%, transparent); + text-decoration: none; } &:focus-visible { @@ -2538,14 +2765,25 @@ justify-content: center; align-self: stretch; gap: 4px; - width: var(--contacts-action-col-width); - min-width: var(--contacts-action-col-width); + width: var(--contacts-actions-sticky-width); + min-width: var(--contacts-actions-sticky-width); flex-shrink: 0; position: sticky; right: 0; z-index: 10; background: var(--contacts-row-bg); + &::before { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: -9px; + width: 9px; + pointer-events: none; + background: linear-gradient(to right, transparent, var(--contacts-row-bg)); + } + .row-action-main { display: inline-flex; align-items: flex-start; @@ -2567,11 +2805,13 @@ font-size: 12px; cursor: pointer; white-space: nowrap; + transition: border-color 0.14s ease, color 0.14s ease, background 0.14s ease, transform 0.14s ease; &:hover { border-color: var(--text-tertiary); color: var(--text-primary); background: var(--bg-hover); + transform: translateY(-1px); } &.active { @@ -2596,7 +2836,7 @@ .row-export-link { border: none; - padding: 0; + padding: 2px 6px; margin: 0; background: transparent; color: var(--primary); @@ -2605,11 +2845,13 @@ line-height: 1.2; font-weight: 600; white-space: nowrap; + border-radius: 6px; + transition: color 0.12s ease, background 0.12s ease; &:hover:not(:disabled) { color: var(--primary-hover); - text-decoration: underline; - text-underline-offset: 2px; + background: color-mix(in srgb, var(--primary) 10%, transparent); + text-decoration: none; } &:disabled { @@ -2661,45 +2903,50 @@ .export-session-detail-overlay { position: fixed; - top: 40px; - right: 0; - bottom: 0; - left: 0; - z-index: 1100; + inset: 0; + z-index: 7900; display: flex; + align-items: stretch; justify-content: flex-end; - background: rgba(15, 23, 42, 0.24); + padding: 12px; + background: rgba(15, 23, 42, 0.42); + backdrop-filter: blur(1px); + animation: exportOverlayFadeIn 0.2s ease both; } .export-session-detail-panel { - width: min(360px, calc(100vw - 16px)); - height: calc(100vh - 40px); - border-left: 1px solid var(--border-color); - border-radius: 0; - background: var(--bg-secondary-solid, #ffffff); + width: min(448px, calc(100vw - 24px)); + height: 100%; + max-height: calc(100vh - 24px); + border: 1px solid color-mix(in srgb, var(--border-color) 86%, transparent); + border-radius: 16px; + background: + linear-gradient(180deg, color-mix(in srgb, var(--card-bg) 82%, var(--bg-primary)) 0%, var(--bg-secondary-solid, #ffffff) 100%); display: flex; flex-direction: column; overflow: hidden; - box-shadow: -12px 0 30px rgba(0, 0, 0, 0.18); + box-shadow: -18px 0 40px rgba(0, 0, 0, 0.24); + animation: exportDetailPanelIn 0.26s cubic-bezier(0.22, 0.8, 0.24, 1) both; .detail-header { display: flex; align-items: center; justify-content: space-between; - padding: 14px; - border-bottom: 1px solid var(--border-color); + padding: 16px 16px 12px; + border-bottom: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent); + background: color-mix(in srgb, var(--bg-primary) 92%, var(--card-bg)); .detail-header-main { display: flex; align-items: center; - gap: 10px; + gap: 12px; min-width: 0; } .detail-header-avatar { - width: 32px; - height: 32px; - border-radius: 8px; + width: 36px; + height: 36px; + border-radius: 10px; background: linear-gradient(135deg, var(--primary), var(--primary-hover)); display: flex; align-items: center; @@ -2725,8 +2972,8 @@ h4 { margin: 0; - font-size: 14px; - font-weight: 600; + font-size: 15px; + font-weight: 700; color: var(--text-primary); line-height: 1.2; overflow: hidden; @@ -2737,7 +2984,7 @@ .detail-header-id { margin-top: 3px; - font-size: 11px; + font-size: 12px; color: var(--text-tertiary); overflow: hidden; text-overflow: ellipsis; @@ -2745,19 +2992,21 @@ } .close-btn { - border: none; - background: transparent; + border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent); + background: color-mix(in srgb, var(--bg-primary) 86%, var(--bg-secondary)); color: var(--text-secondary); - width: 26px; - height: 26px; - border-radius: 6px; + width: 30px; + height: 30px; + border-radius: 8px; display: flex; align-items: center; justify-content: center; cursor: pointer; + transition: border-color 0.14s ease, background 0.14s ease, color 0.14s ease; &:hover { - background: var(--bg-hover); + border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color)); + background: color-mix(in srgb, var(--primary) 8%, var(--bg-secondary)); color: var(--text-primary); } } @@ -2779,11 +3028,16 @@ flex: 1; min-height: 0; overflow-y: auto; - padding: 14px; + padding: 12px; + background: color-mix(in srgb, var(--bg-secondary-solid, #ffffff) 94%, var(--bg-primary)); } .detail-section { - margin-bottom: 18px; + margin-bottom: 12px; + padding: 10px 12px; + border-radius: 12px; + border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent); + background: color-mix(in srgb, var(--bg-primary) 90%, var(--bg-secondary)); &:last-child { margin-bottom: 0; @@ -2793,17 +3047,16 @@ display: flex; align-items: center; gap: 6px; - font-size: 12px; + font-size: 13px; font-weight: 600; color: var(--text-secondary); - margin-bottom: 10px; - text-transform: uppercase; - letter-spacing: 0.4px; + margin-bottom: 8px; + letter-spacing: 0.1px; } .detail-stats-meta { - margin-top: -4px; - margin-bottom: 10px; + margin-top: 0; + margin-bottom: 8px; font-size: 12px; color: var(--text-tertiary); } @@ -2813,8 +3066,9 @@ display: flex; align-items: center; gap: 8px; + min-height: 34px; padding: 8px 0; - border-bottom: 1px solid var(--border-color); + border-bottom: 1px solid color-mix(in srgb, var(--border-color) 82%, transparent); font-size: 13px; &:last-child { @@ -2824,6 +3078,7 @@ .label { color: var(--text-secondary); flex-shrink: 0; + min-width: 70px; } .value { @@ -2840,14 +3095,15 @@ } .detail-inline-btn { - border: none; - background: var(--bg-secondary); + border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent); + background: color-mix(in srgb, var(--bg-primary) 86%, var(--bg-secondary)); color: var(--primary); - border-radius: 6px; + border-radius: 7px; padding: 4px 8px; font-size: 12px; line-height: 1; cursor: pointer; + transition: border-color 0.14s ease, background 0.14s ease, color 0.14s ease; &:disabled { cursor: not-allowed; @@ -2855,7 +3111,8 @@ } &:hover:not(:disabled) { - background: var(--bg-hover); + border-color: color-mix(in srgb, var(--primary) 40%, var(--border-color)); + background: color-mix(in srgb, var(--primary) 8%, var(--bg-secondary)); } } @@ -2880,7 +3137,7 @@ transition: opacity 0.15s, color 0.15s, background 0.15s; &:hover { - background: var(--bg-secondary); + background: color-mix(in srgb, var(--bg-primary) 84%, var(--bg-secondary)); color: var(--text-primary); } } @@ -2905,38 +3162,109 @@ } .detail-record-item { - border: 1px solid var(--border-color); - border-radius: 8px; - padding: 8px 10px; - background: var(--bg-primary); + border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent); + border-radius: 10px; + padding: 10px; + background: color-mix(in srgb, var(--bg-primary) 92%, var(--bg-secondary)); + transition: border-color 0.14s ease, box-shadow 0.14s ease; - .record-row { + &:hover { + border-color: color-mix(in srgb, var(--primary) 28%, var(--border-color)); + box-shadow: 0 6px 12px rgba(15, 23, 42, 0.08); + } + + .detail-record-head { display: flex; align-items: center; - gap: 8px; - padding: 4px 0; + justify-content: space-between; + gap: 10px; + padding-bottom: 8px; + border-bottom: 1px dashed color-mix(in srgb, var(--border-color) 78%, transparent); + } + + .record-export-time { + font-size: 13px; + font-weight: 700; + color: var(--text-primary); + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + + .record-content-pill { + min-width: 0; + max-width: 62%; + border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent); + border-radius: 999px; + background: color-mix(in srgb, var(--bg-primary) 86%, var(--bg-secondary)); + color: var(--text-secondary); font-size: 12px; + padding: 3px 8px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: right; + } - .label { - color: var(--text-secondary); - width: 56px; - flex-shrink: 0; + .detail-record-path-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + padding-top: 8px; + font-size: 12px; + } + + .path-label { + color: var(--text-secondary); + flex-shrink: 0; + white-space: nowrap; + } + + .path-value { + color: var(--text-primary); + min-width: 0; + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + word-break: normal; + } + + .detail-inline-btn { + flex-shrink: 0; + min-height: 26px; + padding: 0 10px; + border-radius: 7px; + border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent); + background: color-mix(in srgb, var(--bg-primary) 88%, var(--bg-secondary)); + color: var(--text-primary); + font-size: 12px; + font-weight: 600; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: border-color 0.14s ease, background 0.14s ease, color 0.14s ease, transform 0.14s ease; + + &:hover:not(:disabled) { + border-color: color-mix(in srgb, var(--primary) 38%, var(--border-color)); + color: var(--primary); + background: color-mix(in srgb, var(--primary) 9%, var(--bg-secondary)); + transform: translateY(-1px); } - .value { - color: var(--text-primary); - flex: 1; - text-align: right; - word-break: break-all; - - &.path { - text-align: left; - } + &:focus-visible { + outline: 2px solid color-mix(in srgb, var(--primary) 38%, transparent); + outline-offset: 2px; } + } - .detail-inline-btn { - flex-shrink: 0; - } + .detail-record-open-btn { + appearance: none; + -webkit-appearance: none; + border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent); + box-shadow: none; } } @@ -2959,8 +3287,9 @@ align-items: center; justify-content: space-between; padding: 10px 12px; - border-radius: 8px; - background: var(--bg-secondary); + border-radius: 10px; + border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent); + background: color-mix(in srgb, var(--bg-primary) 90%, var(--bg-secondary)); font-size: 12px; .db-name { @@ -2977,12 +3306,13 @@ .export-session-sns-overlay { position: fixed; inset: 0; - z-index: 1200; + z-index: 7880; display: flex; align-items: center; justify-content: center; padding: 24px 16px; background: rgba(15, 23, 42, 0.38); + animation: exportOverlayFadeIn 0.2s ease both; } .export-session-sns-dialog { @@ -2995,6 +3325,7 @@ display: flex; flex-direction: column; overflow: hidden; + animation: exportModalPopIn 0.24s cubic-bezier(0.2, 0.78, 0.26, 1) both; .sns-dialog-header { display: flex; @@ -3380,22 +3711,23 @@ .export-dialog-overlay { position: fixed; inset: 0; - background: rgba(0, 0, 0, 0.45); - backdrop-filter: blur(4px); + background: rgba(15, 18, 28, 0.48); + backdrop-filter: blur(7px); display: flex; align-items: center; justify-content: center; - padding: 16px; + padding: 20px; z-index: 1000; } .export-dialog { - width: min(1080px, calc(100vw - 32px)); - max-height: calc(100vh - 32px); + width: min(860px, calc(100vw - 40px)); + max-height: calc(100vh - 40px); background: var(--card-bg); - border: 1px solid var(--border-color); - border-radius: 14px; - padding: 14px 14px 12px; + border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent); + border-radius: 16px; + box-shadow: 0 26px 52px rgba(0, 0, 0, 0.3); + padding: 16px 16px 14px; display: flex; flex-direction: column; overflow: hidden; @@ -3406,20 +3738,33 @@ overflow-y: auto; display: flex; flex-direction: column; - gap: 10px; - padding-right: 14px; + gap: 12px; + padding-right: 6px; + + &::-webkit-scrollbar { + width: 8px; + } + + &::-webkit-scrollbar-thumb { + border-radius: 999px; + background: color-mix(in srgb, var(--text-tertiary) 48%, transparent); + } } .dialog-header { display: flex; align-items: center; justify-content: space-between; - margin-bottom: 10px; + margin-bottom: 12px; + padding: 2px 2px 0; + gap: 12px; h3 { margin: 0; color: var(--text-primary); - font-size: 18px; + font-size: 22px; + line-height: 1.2; + letter-spacing: 0.2px; } } @@ -3432,35 +3777,44 @@ .dialog-header-note { font-size: 12px; - line-height: 1.45; - color: var(--text-secondary); + line-height: 1.5; + color: var(--text-tertiary); } .close-icon-btn { - border: 1px solid var(--border-color); - background: var(--bg-secondary); - border-radius: 8px; - width: 30px; - height: 30px; + border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent); + background: color-mix(in srgb, var(--bg-secondary) 86%, var(--bg-primary)); + border-radius: 10px; + width: 34px; + height: 34px; display: flex; align-items: center; justify-content: center; cursor: pointer; color: var(--text-secondary); + transition: border-color 0.16s ease, color 0.16s ease, transform 0.16s ease, background 0.16s ease; + + &:hover { + border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color)); + color: var(--primary); + background: color-mix(in srgb, var(--primary) 7%, var(--bg-primary)); + transform: translateY(-1px); + } } .dialog-section { - border: 1px solid var(--border-color); - border-radius: 10px; - padding: 12px; - background: var(--bg-secondary); + border: 1px solid color-mix(in srgb, var(--border-color) 82%, transparent); + border-radius: 12px; + padding: 13px 14px; + background: color-mix(in srgb, var(--bg-secondary) 78%, var(--bg-primary)); h4 { - margin: 0 0 8px; - font-size: 13px; - color: var(--text-secondary); - text-transform: uppercase; - letter-spacing: 0.4px; + margin: 0 0 10px; + font-size: 15px; + color: var(--text-primary); + font-weight: 700; + letter-spacing: 0.2px; + line-height: 1.3; } } @@ -3476,21 +3830,23 @@ } .time-range-trigger { - border: 1px solid var(--border-color); - background: var(--bg-primary); + border: 1px solid color-mix(in srgb, var(--border-color) 88%, transparent); + background: color-mix(in srgb, var(--bg-primary) 68%, var(--bg-secondary)); border-radius: 999px; color: var(--text-primary); - font-size: 12px; - min-height: 32px; - padding: 0 10px; + font-size: 13px; + min-height: 36px; + padding: 0 12px; display: inline-flex; align-items: center; gap: 8px; cursor: pointer; + transition: border-color 0.15s ease, color 0.15s ease, background 0.15s ease; &:hover { - border-color: rgba(var(--primary-rgb), 0.45); + border-color: rgba(var(--primary-rgb), 0.52); color: var(--primary); + background: color-mix(in srgb, var(--primary) 8%, var(--bg-secondary)); } .time-range-arrow { @@ -3577,11 +3933,11 @@ .scope-tag { border-radius: 999px; - background: rgba(var(--primary-rgb), 0.15); + background: rgba(var(--primary-rgb), 0.14); color: var(--primary); - padding: 4px 10px; + padding: 6px 11px; font-size: 12px; - font-weight: 600; + font-weight: 700; } .scope-count { @@ -3593,64 +3949,70 @@ margin-top: 8px; display: flex; flex-wrap: wrap; - gap: 6px; + gap: 7px; max-height: 120px; overflow: auto; } .scope-item { - background: var(--bg-primary); - border: 1px solid var(--border-color); + background: color-mix(in srgb, var(--bg-primary) 68%, var(--bg-secondary)); + border: 1px solid color-mix(in srgb, var(--border-color) 86%, transparent); border-radius: 999px; - padding: 4px 9px; + padding: 6px 11px; font-size: 12px; - color: var(--text-primary); + color: var(--text-secondary); } .format-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 6px; + gap: 8px; } .format-note { - margin: 0 0 8px; + margin: 0 0 10px; font-size: 12px; - line-height: 1.45; + line-height: 1.55; color: var(--text-secondary); } .format-card { width: 100%; min-height: 0; - border: 1px solid var(--border-color); - border-radius: 10px; - padding: 8px 10px; + border: 1px solid color-mix(in srgb, var(--border-color) 88%, transparent); + border-radius: 11px; + padding: 10px 11px; text-align: left; - background: var(--bg-primary); + background: color-mix(in srgb, var(--bg-primary) 70%, var(--bg-secondary)); cursor: pointer; display: flex; flex-direction: column; align-items: flex-start; justify-content: flex-start; + transition: border-color 0.15s ease, background 0.15s ease, transform 0.15s ease; .format-label { - font-size: 13px; + font-size: 14px; font-weight: 600; color: var(--text-primary); line-height: 1.35; } .format-desc { - margin-top: 1px; - font-size: 11px; + margin-top: 2px; + font-size: 12px; color: var(--text-tertiary); - line-height: 1.35; + line-height: 1.45; + } + + &:hover { + border-color: color-mix(in srgb, var(--primary) 36%, var(--border-color)); + transform: translateY(-1px); } &.active { - border-color: var(--primary); - background: rgba(var(--primary-rgb), 0.08); + border-color: color-mix(in srgb, var(--primary) 75%, var(--border-color)); + background: color-mix(in srgb, var(--primary) 11%, var(--bg-secondary)); } } @@ -3685,24 +4047,281 @@ } } -.media-check-grid { - margin-top: 10px; +.media-section-header { + margin-bottom: 10px; +} + +.media-selection-pill { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--primary) 42%, var(--border-color)); + background: color-mix(in srgb, var(--primary) 12%, var(--bg-secondary)); + color: color-mix(in srgb, var(--primary) 80%, var(--text-primary)); + min-height: 28px; + padding: 0 10px; + font-size: 12px; + font-weight: 700; + line-height: 1; +} + +.media-option-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(168px, 1fr)); + gap: 9px; +} + +.media-option-card { + position: relative; + border: 1px solid color-mix(in srgb, var(--border-color) 86%, transparent); + border-radius: 12px; + background: color-mix(in srgb, var(--bg-primary) 72%, var(--bg-secondary)); + min-height: 74px; + padding: 10px 11px; display: flex; align-items: center; - flex-wrap: wrap; - gap: 8px 16px; + justify-content: space-between; + gap: 9px; + cursor: pointer; + transition: border-color 0.15s ease, background 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease; - label { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 12px; - color: var(--text-primary); - white-space: nowrap; + &:hover { + border-color: color-mix(in srgb, var(--primary) 46%, var(--border-color)); + transform: translateY(-1px); } - input[type='checkbox'] { - accent-color: var(--primary); + &:has(.media-option-input:focus-visible) { + outline: 2px solid color-mix(in srgb, var(--primary) 38%, transparent); + outline-offset: 1px; + } + + &.active { + border-color: color-mix(in srgb, var(--primary) 76%, var(--border-color)); + background: color-mix(in srgb, var(--primary) 12%, var(--bg-secondary)); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--primary) 28%, transparent); + } +} + +.media-option-input { + position: absolute; + inset: 0; + opacity: 0; + pointer-events: none; +} + +.media-option-main { + min-width: 0; + display: inline-flex; + align-items: center; + gap: 9px; +} + +.media-option-icon { + width: 30px; + height: 30px; + border-radius: 9px; + border: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent); + background: color-mix(in srgb, var(--bg-primary) 86%, var(--bg-secondary)); + color: var(--text-secondary); + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.media-option-card.active .media-option-icon { + border-color: color-mix(in srgb, var(--primary) 58%, var(--border-color)); + background: color-mix(in srgb, var(--primary) 18%, var(--bg-secondary)); + color: var(--primary); +} + +.media-option-text { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.media-option-label { + font-size: 13px; + line-height: 1.3; + font-weight: 700; + color: var(--text-primary); +} + +.media-option-desc { + font-size: 11px; + line-height: 1.4; + color: var(--text-secondary); +} + +.media-option-check { + width: 18px; + height: 18px; + border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--border-color) 82%, transparent); + background: color-mix(in srgb, var(--bg-primary) 76%, var(--bg-secondary)); + color: transparent; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease; +} + +.media-option-check.active { + border-color: color-mix(in srgb, var(--primary) 85%, var(--border-color)); + background: color-mix(in srgb, var(--primary) 90%, var(--bg-secondary)); + color: #fff; +} + +.dialog-collapse-slot { + display: grid; + grid-template-rows: 0fr; + margin-top: 0; + opacity: 0; + transform: translateY(-6px); + pointer-events: none; + transition: grid-template-rows 0.24s ease, opacity 0.18s ease, transform 0.24s ease, margin-top 0.24s ease; +} + +.dialog-collapse-slot.open { + grid-template-rows: 1fr; + margin-top: 12px; + opacity: 1; + transform: translateY(0); + pointer-events: auto; +} + +.dialog-collapse-inner { + min-height: 0; + overflow: hidden; +} + +.file-size-subsection { + border-radius: 12px; + border: 1px solid color-mix(in srgb, var(--border-color) 82%, transparent); + background: color-mix(in srgb, var(--bg-primary) 64%, var(--bg-secondary)); + padding: 11px 12px; + display: flex; + flex-direction: column; + gap: 10px; + transition: opacity 0.16s ease; +} + +.file-size-subsection.disabled { + opacity: 0.66; +} + +.file-size-subsection-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.file-size-heading { + font-size: 13px; + font-weight: 700; + color: var(--text-primary); +} + +.file-size-current { + font-size: 12px; + color: var(--text-secondary); + font-variant-numeric: tabular-nums; +} + +.file-size-note { + font-size: 12px; + line-height: 1.5; + color: var(--text-secondary); +} + +.file-size-preset-row { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.file-size-preset-btn { + border: 1px solid color-mix(in srgb, var(--border-color) 86%, transparent); + border-radius: 999px; + background: color-mix(in srgb, var(--bg-primary) 78%, var(--bg-secondary)); + color: var(--text-secondary); + min-height: 29px; + padding: 0 10px; + font-size: 12px; + font-weight: 600; + line-height: 1; + cursor: pointer; + transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease; + + &:hover:not(:disabled) { + border-color: color-mix(in srgb, var(--primary) 45%, var(--border-color)); + color: var(--text-primary); + } + + &.active { + border-color: color-mix(in srgb, var(--primary) 72%, var(--border-color)); + background: color-mix(in srgb, var(--primary) 12%, var(--bg-secondary)); + color: color-mix(in srgb, var(--primary) 82%, var(--text-primary)); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.58; + } +} + +.dialog-input-row { + display: inline-flex; + align-items: center; + gap: 6px; + margin-top: 2px; + + input { + width: 128px; + height: 36px; + border-radius: 9px; + border: 1px solid color-mix(in srgb, var(--border-color) 88%, transparent); + background: color-mix(in srgb, var(--bg-primary) 72%, var(--bg-secondary)); + color: var(--text-primary); + font-size: 15px; + font-variant-numeric: tabular-nums; + padding: 0 10px; + appearance: textfield; + -moz-appearance: textfield; + transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease; + + &:focus { + outline: none; + border-color: color-mix(in srgb, var(--primary) 70%, var(--border-color)); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 16%, transparent); + background: color-mix(in srgb, var(--bg-primary) 86%, var(--bg-secondary)); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.68; + border-color: color-mix(in srgb, var(--border-color) 92%, transparent); + background: color-mix(in srgb, var(--bg-secondary) 90%, var(--bg-primary)); + } + + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } + } + + span { + font-size: 14px; + font-weight: 600; + letter-spacing: 0.15px; + line-height: 1; + color: var(--text-secondary); } } @@ -3732,14 +4351,15 @@ flex-shrink: 0; width: 46px; height: 26px; - border: none; + border: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent); border-radius: 999px; background: color-mix(in srgb, var(--text-tertiary) 45%, transparent); cursor: pointer; - transition: background 0.2s ease; + transition: background 0.2s ease, border-color 0.2s ease; &.on { background: var(--primary); + border-color: color-mix(in srgb, var(--primary) 90%, var(--border-color)); } } @@ -3761,80 +4381,96 @@ .display-name-options { display: grid; - grid-template-columns: 1fr 1fr 1fr; + grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; } .display-name-item { - border: 1px solid var(--border-color); - border-radius: 8px; - padding: 8px; + border: 1px solid color-mix(in srgb, var(--border-color) 85%, transparent); + border-radius: 10px; + padding: 10px; width: 100%; + min-height: 86px; display: flex; flex-direction: column; - gap: 2px; - background: var(--bg-primary); + gap: 4px; + background: color-mix(in srgb, var(--bg-primary) 72%, var(--bg-secondary)); text-align: left; cursor: pointer; color: inherit; font: inherit; appearance: none; -webkit-appearance: none; + transition: border-color 0.15s ease, background 0.15s ease, transform 0.15s ease; &:focus-visible { outline: 2px solid rgba(var(--primary-rgb), 0.35); outline-offset: 1px; } + &:hover { + border-color: color-mix(in srgb, var(--primary) 44%, var(--border-color)); + transform: translateY(-1px); + } + span { - font-size: 12px; + font-size: 13px; color: var(--text-primary); - font-weight: 600; + font-weight: 700; } small { color: var(--text-secondary); - font-size: 11px; - line-height: 1.4; + font-size: 12px; + line-height: 1.45; } &.active { - border-color: var(--primary); - background: rgba(var(--primary-rgb), 0.08); + border-color: color-mix(in srgb, var(--primary) 76%, var(--border-color)); + background: color-mix(in srgb, var(--primary) 11%, var(--bg-secondary)); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--primary) 30%, transparent); } } .dialog-actions { - margin-top: 10px; - padding-top: 10px; + margin-top: 12px; + padding-top: 12px; border-top: 1px solid var(--border-color); display: flex; justify-content: flex-end; - gap: 8px; + gap: 10px; flex-shrink: 0; - background: var(--card-bg); + background: linear-gradient( + 180deg, + transparent, + var(--card-bg) 38% + ); } .primary-btn, .secondary-btn { - border-radius: 8px; - padding: 7px 12px; - font-size: 12px; + border-radius: 10px; + min-height: 38px; + padding: 8px 14px; + font-size: 13px; font-weight: 600; border: 1px solid var(--border-color); display: inline-flex; align-items: center; gap: 6px; cursor: pointer; + transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease, transform 0.15s ease; } .primary-btn { border-color: var(--primary); background: var(--primary); color: #fff; + min-width: 168px; &:hover { background: var(--primary-hover); + transform: translateY(-1px); } &:disabled { @@ -3844,12 +4480,15 @@ } .secondary-btn { - background: var(--bg-secondary); + background: color-mix(in srgb, var(--bg-secondary) 85%, var(--bg-primary)); color: var(--text-primary); + min-width: 112px; &:hover { border-color: var(--primary); color: var(--primary); + background: color-mix(in srgb, var(--primary) 7%, var(--bg-secondary)); + transform: translateY(-1px); } } @@ -4081,6 +4720,96 @@ justify-content: flex-end; } +@keyframes exportPageEnter { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +@keyframes exportSectionReveal { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +@keyframes exportCardReveal { + 0% { + opacity: 0; + transform: translateY(14px) scale(0.987); + } + + 100% { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes exportOverlayFadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes exportModalPopIn { + 0% { + opacity: 0; + transform: translateY(10px) scale(0.985); + } + + 100% { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes exportDetailPanelIn { + 0% { + opacity: 0; + transform: translateX(16px) scale(0.995); + } + + 100% { + opacity: 1; + transform: translateX(0) scale(1); + } +} + +@keyframes exportPopoverEnter { + 0% { + opacity: 0; + transform: translateY(-8px) scale(0.985); + } + + 100% { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes exportItemRise { + from { + opacity: 0; + transform: translateY(6px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + @keyframes exportSpin { from { transform: rotate(0deg); @@ -4124,6 +4853,49 @@ } } +@media (prefers-reduced-motion: reduce) { + .export-board-page, + .export-top-panel, + .export-section-title-row, + .global-export-controls, + .task-center-card, + .content-card-grid, + .content-card, + .session-table-section, + .table-stage-hint, + .dialog-collapse-slot { + animation: none !important; + transition: none !important; + transform: none !important; + } + + .animated-ellipsis, + .session-load-detail-entry-bar, + .task-center-card-badge, + .spin { + animation: none !important; + } + + .session-load-detail-overlay, + .task-center-modal-overlay, + .export-defaults-modal-overlay, + .session-mutual-friends-overlay, + .export-session-detail-overlay, + .session-load-detail-modal, + .task-center-modal, + .export-defaults-modal, + .session-mutual-friends-modal, + .export-session-detail-panel, + .export-session-sns-overlay, + .export-session-sns-dialog, + .task-card, + .layout-dropdown.open { + animation: none !important; + transition: none !important; + transform: none !important; + } +} + @media (max-width: 1360px) { .export-top-bar { gap: 10px; @@ -4142,11 +4914,11 @@ } .display-name-options { - grid-template-columns: 1fr; + grid-template-columns: repeat(2, minmax(0, 1fr)); } - .media-check-grid { - gap: 8px 12px; + .media-option-grid { + gap: 8px; } } @@ -4292,6 +5064,77 @@ width: calc(100vw - 20px); max-height: calc(100vh - 20px); padding: 12px 10px 10px; + border-radius: 14px; + } + + .dialog-header { + margin-bottom: 10px; + + h3 { + font-size: 19px; + } + } + + .dialog-section { + padding: 11px 11px; + + h4 { + font-size: 14px; + } + } + + .section-header-action { + align-items: flex-start; + flex-direction: column; + gap: 8px; + } + + .time-range-trigger { + width: 100%; + justify-content: space-between; + } + + .display-name-options { + grid-template-columns: 1fr; + } + + .dialog-input-row { + width: 100%; + display: flex; + align-items: center; + + input { + width: 100%; + min-width: 0; + flex: 1; + } + } + + .dialog-actions { + display: grid; + grid-template-columns: 1fr 1.35fr; + gap: 8px; + } + + .secondary-btn, + .primary-btn { + min-width: 0; + width: 100%; + justify-content: center; + } + + .media-option-grid { + grid-template-columns: 1fr; + } + + .media-option-card { + min-height: 68px; + } + + .file-size-subsection-header { + align-items: flex-start; + flex-direction: column; + gap: 4px; } .format-grid { diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 133f496..a65d513 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -12,6 +12,7 @@ import { Database, Download, ExternalLink, + File as FileIcon, FolderOpen, Hash, Image as ImageIcon, @@ -67,7 +68,7 @@ import './ExportPage.scss' type ConversationTab = 'private' | 'group' | 'official' | 'former_friend' type TaskStatus = 'queued' | 'running' | 'success' | 'error' type TaskScope = 'single' | 'multi' | 'content' | 'sns' -type ContentType = 'text' | 'voice' | 'image' | 'video' | 'emoji' +type ContentType = 'text' | 'voice' | 'image' | 'video' | 'emoji' | 'file' type ContentCardType = ContentType | 'sns' type SnsRankMode = 'likes' | 'comments' @@ -88,6 +89,8 @@ interface ExportOptions { exportVoices: boolean exportVideos: boolean exportEmojis: boolean + exportFiles: boolean + maxFileSizeMb: number exportVoiceAsText: boolean excelCompactColumns: boolean txtColumns: string[] @@ -181,6 +184,7 @@ interface ExportDialogState { const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content'] const DETAIL_PRECISE_REFRESH_COOLDOWN_MS = 10 * 60 * 1000 +const TASK_PERFORMANCE_UPDATE_MIN_INTERVAL_MS = 900 const SESSION_MEDIA_METRIC_PREFETCH_ROWS = 10 const SESSION_MEDIA_METRIC_BATCH_SIZE = 8 const SESSION_MEDIA_METRIC_BACKGROUND_FEED_SIZE = 48 @@ -195,8 +199,10 @@ const contentTypeLabels: Record<ContentType, string> = { voice: '语音', image: '图片', video: '视频', - emoji: '表情包' + emoji: '表情包', + file: '文件' } +const FILE_SIZE_PRESETS_MB = [0, 100, 200, 500, 1024] as const const backgroundTaskSourceLabels: Record<string, string> = { export: '导出页', @@ -311,9 +317,7 @@ const cloneTaskPerformance = (performance?: TaskPerformance): TaskPerformance => write: performance?.stages.write || 0, other: performance?.stages.other || 0 }, - sessions: Object.fromEntries( - Object.entries(performance?.sessions || {}).map(([sessionId, session]) => [sessionId, { ...session }]) - ) + sessions: { ...(performance?.sessions || {}) } }) const resolveTaskSessionName = (task: ExportTask, sessionId: string, fallback?: string): string => { @@ -333,6 +337,18 @@ const applyProgressToTaskPerformance = ( const sessionId = String(payload.currentSessionId || '').trim() if (!sessionId) return task.performance || createEmptyTaskPerformance() + const currentPerformance = task.performance + const currentSession = currentPerformance?.sessions?.[sessionId] + if ( + payload.phase !== 'complete' && + currentSession && + currentSession.lastPhase === payload.phase && + typeof currentSession.lastPhaseStartedAt === 'number' && + now - currentSession.lastPhaseStartedAt < TASK_PERFORMANCE_UPDATE_MIN_INTERVAL_MS + ) { + return currentPerformance + } + const performance = cloneTaskPerformance(task.performance) const sessionName = resolveTaskSessionName(task, sessionId, payload.currentSession || sessionId) const existing = performance.sessions[sessionId] @@ -368,7 +384,9 @@ const applyProgressToTaskPerformance = ( const finalizeTaskPerformance = (task: ExportTask, now: number): TaskPerformance | undefined => { if (!isTextBatchTask(task) || !task.performance) return task.performance const performance = cloneTaskPerformance(task.performance) - for (const session of Object.values(performance.sessions)) { + const nextSessions: Record<string, TaskSessionPerformance> = {} + for (const [sessionId, sourceSession] of Object.entries(performance.sessions)) { + const session: TaskSessionPerformance = { ...sourceSession } if (session.finishedAt) continue if (session.lastPhase && typeof session.lastPhaseStartedAt === 'number') { const delta = Math.max(0, now - session.lastPhaseStartedAt) @@ -378,7 +396,13 @@ const finalizeTaskPerformance = (task: ExportTask, now: number): TaskPerformance session.finishedAt = now session.lastPhase = undefined session.lastPhaseStartedAt = undefined + nextSessions[sessionId] = session } + for (const [sessionId, sourceSession] of Object.entries(performance.sessions)) { + if (nextSessions[sessionId]) continue + nextSessions[sessionId] = { ...sourceSession } + } + performance.sessions = nextSessions return performance } @@ -1188,16 +1212,18 @@ const WriteLayoutSelector = memo(function WriteLayoutSelector({ const writeLayoutLabel = writeLayoutOptions.find(option => option.value === writeLayout)?.label || 'A(类型分目录)' return ( - <div className="write-layout-control" ref={containerRef}> + <div className={`write-layout-control ${isOpen ? 'open' : ''}`} ref={containerRef}> <span className="control-label">写入目录方式</span> <button className={`layout-trigger ${isOpen ? 'active' : ''}`} type="button" onClick={() => setIsOpen(prev => !prev)} + aria-expanded={isOpen} + aria-haspopup="listbox" > {writeLayoutLabel} </button> - <div className={`layout-dropdown ${isOpen ? 'open' : ''}`}> + <div className={`layout-dropdown ${isOpen ? 'open' : ''}`} role="listbox" aria-label="写入目录方式"> {writeLayoutOptions.map(option => ( <button key={option.value} @@ -1314,7 +1340,7 @@ const TaskCenterModal = memo(function TaskCenterModal({ }: TaskCenterModalProps) { if (!isOpen) return null - return ( + return createPortal( <div className="task-center-modal-overlay" onClick={onClose} @@ -1511,7 +1537,8 @@ const TaskCenterModal = memo(function TaskCenterModal({ )} </div> </div> - </div> + </div>, + document.body ) }) @@ -1598,7 +1625,8 @@ function ExportPage() { images: true, videos: true, voices: true, - emojis: true + emojis: true, + files: true }) const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false) const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true) @@ -1617,7 +1645,9 @@ function ExportPage() { exportImages: true, exportVoices: true, exportVideos: true, - exportEmojis: true, + exportEmojis: true, + exportFiles: true, + maxFileSizeMb: 200, exportVoiceAsText: false, excelCompactColumns: true, txtColumns: defaultTxtColumns, @@ -2281,7 +2311,8 @@ function ExportPage() { images: true, videos: true, voices: true, - emojis: true + emojis: true, + files: true }) setExportDefaultVoiceAsText(savedVoiceAsText ?? false) setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true) @@ -2310,12 +2341,14 @@ function ExportPage() { (savedMedia?.images ?? prev.exportImages) || (savedMedia?.voices ?? prev.exportVoices) || (savedMedia?.videos ?? prev.exportVideos) || - (savedMedia?.emojis ?? prev.exportEmojis) + (savedMedia?.emojis ?? prev.exportEmojis) || + (savedMedia?.files ?? prev.exportFiles) ), exportImages: savedMedia?.images ?? prev.exportImages, exportVoices: savedMedia?.voices ?? prev.exportVoices, exportVideos: savedMedia?.videos ?? prev.exportVideos, exportEmojis: savedMedia?.emojis ?? prev.exportEmojis, + exportFiles: savedMedia?.files ?? prev.exportFiles, exportVoiceAsText: savedVoiceAsText ?? prev.exportVoiceAsText, excelCompactColumns: savedExcelCompactColumns ?? prev.excelCompactColumns, txtColumns, @@ -4088,12 +4121,15 @@ function ExportPage() { exportDefaultMedia.images || exportDefaultMedia.voices || exportDefaultMedia.videos || - exportDefaultMedia.emojis + exportDefaultMedia.emojis || + exportDefaultMedia.files ), exportImages: exportDefaultMedia.images, exportVoices: exportDefaultMedia.voices, exportVideos: exportDefaultMedia.videos, exportEmojis: exportDefaultMedia.emojis, + exportFiles: exportDefaultMedia.files, + maxFileSizeMb: prev.maxFileSizeMb, exportVoiceAsText: exportDefaultVoiceAsText, excelCompactColumns: exportDefaultExcelCompactColumns, exportConcurrency: exportDefaultConcurrency, @@ -4111,12 +4147,14 @@ function ExportPage() { next.exportVoices = false next.exportVideos = false next.exportEmojis = false + next.exportFiles = false } else { next.exportMedia = true next.exportImages = payload.contentType === 'image' next.exportVoices = payload.contentType === 'voice' next.exportVideos = payload.contentType === 'video' next.exportEmojis = payload.contentType === 'emoji' + next.exportFiles = payload.contentType === 'file' next.exportVoiceAsText = false } } @@ -4335,7 +4373,13 @@ function ExportPage() { const buildExportOptions = (scope: TaskScope, contentType?: ContentType): ElectronExportOptions => { const sessionLayout: SessionLayout = writeLayout === 'C' ? 'per-session' : 'shared' - const exportMediaEnabled = Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis) + const exportMediaEnabled = Boolean( + options.exportImages || + options.exportVoices || + options.exportVideos || + options.exportEmojis || + options.exportFiles + ) const base: ElectronExportOptions = { format: options.format, @@ -4345,6 +4389,8 @@ function ExportPage() { exportVoices: options.exportVoices, exportVideos: options.exportVideos, exportEmojis: options.exportEmojis, + exportFiles: options.exportFiles, + maxFileSizeMb: options.maxFileSizeMb, exportVoiceAsText: options.exportVoiceAsText, excelCompactColumns: options.excelCompactColumns, txtColumns: options.txtColumns, @@ -4375,7 +4421,8 @@ function ExportPage() { exportImages: false, exportVoices: false, exportVideos: false, - exportEmojis: false + exportEmojis: false, + exportFiles: false } } @@ -4387,6 +4434,7 @@ function ExportPage() { exportVoices: contentType === 'voice', exportVideos: contentType === 'video', exportEmojis: contentType === 'emoji', + exportFiles: contentType === 'file', exportVoiceAsText: false } } @@ -4452,6 +4500,7 @@ function ExportPage() { if (opts.exportVoices) labels.push('语音') if (opts.exportVideos) labels.push('视频') if (opts.exportEmojis) labels.push('表情包') + if (opts.exportFiles) labels.push('文件') } return Array.from(new Set(labels)).join('、') }, []) @@ -4507,6 +4556,7 @@ function ExportPage() { if (opts.exportImages) types.push('image') if (opts.exportVideos) types.push('video') if (opts.exportEmojis) types.push('emoji') + if (opts.exportFiles) types.push('file') } return types } @@ -4697,7 +4747,7 @@ function ExportPage() { queuedProgressTimer = window.setTimeout(() => { queuedProgressTimer = null flushQueuedProgress() - }, 100) + }, 180) }) } if (next.payload.scope === 'sns') { @@ -4937,7 +4987,8 @@ function ExportPage() { images: options.exportImages, voices: options.exportVoices, videos: options.exportVideos, - emojis: options.exportEmojis + emojis: options.exportEmojis, + files: options.exportFiles }) await configService.setExportDefaultVoiceAsText(options.exportVoiceAsText) await configService.setExportDefaultExcelCompactColumns(options.excelCompactColumns) @@ -6445,6 +6496,10 @@ function ExportPage() { const useCollapsedSessionFormatSelector = isSessionScopeDialog || isContentTextDialog const shouldShowFormatSection = !isContentScopeDialog || isContentTextDialog const shouldShowMediaSection = !isContentScopeDialog + const shouldRenderImageDeepSearchToggle = exportDialog.scope !== 'sns' && ( + isSessionScopeDialog || + (isContentScopeDialog && exportDialog.contentType === 'image') + ) const shouldShowImageDeepSearchToggle = exportDialog.scope !== 'sns' && ( (isSessionScopeDialog && options.exportImages) || (isContentScopeDialog && exportDialog.contentType === 'image') @@ -6454,6 +6509,80 @@ function ExportPage() { const activeDialogFormatLabel = exportDialog.scope === 'sns' ? (snsFormatOptions.find(option => option.value === snsExportFormat)?.label ?? snsExportFormat) : (formatOptions.find(option => option.value === options.format)?.label ?? options.format) + const sessionMediaOptions = [ + { + key: 'images', + label: '图片', + desc: '聊天图片与缩略图', + icon: ImageIcon, + checked: options.exportImages, + onToggle: (checked: boolean) => setOptions(prev => ({ ...prev, exportImages: checked })) + }, + { + key: 'voices', + label: '语音', + desc: '语音消息文件', + icon: Mic, + checked: options.exportVoices, + onToggle: (checked: boolean) => setOptions(prev => ({ ...prev, exportVoices: checked })) + }, + { + key: 'videos', + label: '视频', + desc: '聊天视频与封面', + icon: Video, + checked: options.exportVideos, + onToggle: (checked: boolean) => setOptions(prev => ({ ...prev, exportVideos: checked })) + }, + { + key: 'emojis', + label: '表情包', + desc: '静态与动态表情', + icon: MessageSquare, + checked: options.exportEmojis, + onToggle: (checked: boolean) => setOptions(prev => ({ ...prev, exportEmojis: checked })) + }, + { + key: 'files', + label: '文件', + desc: '文档与附件', + icon: FileIcon, + checked: options.exportFiles, + onToggle: (checked: boolean) => setOptions(prev => ({ ...prev, exportFiles: checked })) + } + ] + const snsMediaOptions = [ + { + key: 'images', + label: '图片', + desc: '朋友圈图片', + icon: ImageIcon, + checked: snsExportImages, + onToggle: (checked: boolean) => setSnsExportImages(checked) + }, + { + key: 'live-photos', + label: '实况图', + desc: 'Live Photo', + icon: Aperture, + checked: snsExportLivePhotos, + onToggle: (checked: boolean) => setSnsExportLivePhotos(checked) + }, + { + key: 'videos', + label: '视频', + desc: '朋友圈视频', + icon: Video, + checked: snsExportVideos, + onToggle: (checked: boolean) => setSnsExportVideos(checked) + } + ] + const dialogMediaOptions = exportDialog.scope === 'sns' ? snsMediaOptions : sessionMediaOptions + const mediaSelectionSummaryLabel = `已选择 ${dialogMediaOptions.filter(option => option.checked).length}/${dialogMediaOptions.length}` + const voiceAsTextStatusLabel = options.exportVoices + ? '已勾选导出语音:会同时导出语音文件,并在文本中追加语音转写结果。' + : '未勾选导出语音时,仅在文本里追加语音转写结果,不导出语音文件。' + const fileSizeLimitLabel = options.maxFileSizeMb <= 0 ? '不限' : `${options.maxFileSizeMb} MB` const shouldShowDisplayNameSection = !( exportDialog.scope === 'sns' || ( @@ -6472,8 +6601,9 @@ function ExportPage() { const taskQueuedCount = tasks.filter(task => task.status === 'queued').length const taskCenterAlertCount = taskRunningCount + taskQueuedCount const hasFilteredContacts = filteredContacts.length > 0 + const CONTACTS_ACTION_STICKY_WIDTH = 184 const contactsTableMinWidth = useMemo(() => { - const baseWidth = 24 + 34 + 44 + 280 + 120 + (4 * 72) + 140 + (8 * 12) + const baseWidth = 24 + 34 + 44 + 280 + 120 + (4 * 72) + CONTACTS_ACTION_STICKY_WIDTH + (8 * 12) const snsWidth = shouldShowSnsColumn ? 72 + 12 : 0 const mutualFriendsWidth = shouldShowMutualFriendsColumn ? 72 + 12 : 0 return baseWidth + snsWidth + mutualFriendsWidth @@ -6664,7 +6794,7 @@ function ExportPage() { const toggleTaskPerfDetail = useCallback((taskId: string) => { setExpandedPerfTaskId(prev => (prev === taskId ? null : taskId)) }, []) - const renderContactRow = useCallback((_: number, contact: ContactInfo) => { + const renderContactRow = useCallback((index: number, contact: ContactInfo) => { const matchedSession = sessionRowByUsername.get(contact.username) const canExport = Boolean(matchedSession?.hasSession) const isSessionBindingPending = !matchedSession && (isLoading || isSessionEnriching) @@ -6730,8 +6860,20 @@ function ExportPage() { : contact.type === 'group' ? '打开群聊' : '打开对话' + const previousContact = index > 0 ? filteredContacts[index - 1] : null + const nextContact = index < filteredContacts.length - 1 ? filteredContacts[index + 1] : null + const previousCanExport = Boolean(previousContact && sessionRowByUsername.get(previousContact.username)?.hasSession) + const nextCanExport = Boolean(nextContact && sessionRowByUsername.get(nextContact.username)?.hasSession) + const previousSelected = Boolean(previousContact && previousCanExport && selectedSessions.has(previousContact.username)) + const nextSelected = Boolean(nextContact && nextCanExport && selectedSessions.has(nextContact.username)) + const rowClassName = [ + 'contact-row', + checked ? 'selected' : '', + checked && previousSelected ? 'selected-contiguous-top' : '', + checked && nextSelected ? 'selected-contiguous-bottom' : '' + ].filter(Boolean).join(' ') return ( - <div className={`contact-row ${checked ? 'selected' : ''}`}> + <div className={rowClassName}> <div className="contact-item"> <div className="row-left-sticky"> <div className="row-select-cell"> @@ -6880,6 +7022,7 @@ function ExportPage() { </div> ) }, [ + filteredContacts, lastExportBySession, navigate, nowTick, @@ -6955,11 +7098,12 @@ function ExportPage() { setExportDefaultMedia(mediaPatch) setOptions(prev => ({ ...prev, - exportMedia: Boolean(mediaPatch.images || mediaPatch.voices || mediaPatch.videos || mediaPatch.emojis), + exportMedia: Boolean(mediaPatch.images || mediaPatch.voices || mediaPatch.videos || mediaPatch.emojis || mediaPatch.files), exportImages: mediaPatch.images, exportVoices: mediaPatch.voices, exportVideos: mediaPatch.videos, - exportEmojis: mediaPatch.emojis + exportEmojis: mediaPatch.emojis, + exportFiles: mediaPatch.files })) } if (typeof patch.voiceAsText === 'boolean') { @@ -7048,7 +7192,7 @@ function ExportPage() { onTogglePerfTask={toggleTaskPerfDetail} /> - {isExportDefaultsModalOpen && ( + {isExportDefaultsModalOpen && createPortal( <div className="export-defaults-modal-overlay" onClick={() => setIsExportDefaultsModalOpen(false)} @@ -7086,7 +7230,8 @@ function ExportPage() { </button> </div> </div> - </div> + </div>, + document.body )} <div className="export-section-title-row"> @@ -7171,7 +7316,7 @@ function ExportPage() { ]} /> <button - className={`session-load-detail-entry ${isSessionLoadDetailActive ? 'active' : ''}`} + className={`session-load-detail-entry ${showSessionLoadDetailModal ? 'open' : ''} ${isSessionLoadDetailActive && !showSessionLoadDetailModal ? 'active' : ''}`.trim()} type="button" onClick={() => setShowSessionLoadDetailModal(true)} > @@ -7381,7 +7526,7 @@ function ExportPage() { )} </div> - {showSessionLoadDetailModal && ( + {showSessionLoadDetailModal && createPortal( <div className="session-load-detail-overlay" onClick={() => setShowSessionLoadDetailModal(false)} @@ -7616,10 +7761,11 @@ function ExportPage() { </section> </div> </div> - </div> + </div>, + document.body )} - {sessionMutualFriendsDialogTarget && sessionMutualFriendsDialogMetric && ( + {sessionMutualFriendsDialogTarget && sessionMutualFriendsDialogMetric && createPortal( <div className="session-mutual-friends-overlay" onClick={closeSessionMutualFriendsDialog} @@ -7702,10 +7848,11 @@ function ExportPage() { )} </div> </div> - </div> + </div>, + document.body )} - {showSessionDetailPanel && ( + {showSessionDetailPanel && createPortal( <div className="export-session-detail-overlay" onClick={closeSessionDetailPanel} @@ -7807,19 +7954,15 @@ function ExportPage() { <div className="detail-record-list"> {currentSessionExportRecords.map((record, index) => ( <div className="detail-record-item" key={`${record.exportTime}-${record.content}-${index}`}> - <div className="record-row"> - <span className="label">导出时间</span> - <span className="value">{formatYmdHmDateTime(record.exportTime)}</span> + <div className="detail-record-head"> + <span className="record-export-time">{formatYmdHmDateTime(record.exportTime)}</span> + <span className="record-content-pill" title={record.content}>{record.content}</span> </div> - <div className="record-row"> - <span className="label">导出内容</span> - <span className="value">{record.content}</span> - </div> - <div className="record-row"> - <span className="label">导出目录</span> - <span className="value path" title={record.outputDir}>{formatPathBrief(record.outputDir)}</span> + <div className="detail-record-path-row"> + <span className="path-label">导出目录</span> + <span className="path-value" title={record.outputDir}>{formatPathBrief(record.outputDir)}</span> <button - className="detail-inline-btn" + className="detail-inline-btn detail-record-open-btn" type="button" onClick={() => void window.electronAPI.shell.openPath(record.outputDir)} > @@ -7835,7 +7978,7 @@ function ExportPage() { <div className="detail-section"> <div className="section-title"> <MessageSquare size={14} /> - <span>消息统计(导出口径)</span> + <span>消息统计</span> </div> <div className="detail-stats-meta"> {isRefreshingSessionDetailStats @@ -8018,7 +8161,8 @@ function ExportPage() { <div className="detail-empty">暂无详情</div> )} </aside> - </div> + </div>, + document.body )} <ContactSnsTimelineDialog @@ -8147,45 +8291,103 @@ function ExportPage() { {shouldShowMediaSection && ( <div className="dialog-section"> - <h4>{exportDialog.scope === 'sns' ? '媒体文件(可多选)' : '媒体内容'}</h4> - <div className="media-check-grid"> - {exportDialog.scope === 'sns' ? ( - <> - <label><input type="checkbox" checked={snsExportImages} onChange={event => setSnsExportImages(event.target.checked)} /> 图片</label> - <label><input type="checkbox" checked={snsExportLivePhotos} onChange={event => setSnsExportLivePhotos(event.target.checked)} /> 实况图</label> - <label><input type="checkbox" checked={snsExportVideos} onChange={event => setSnsExportVideos(event.target.checked)} /> 视频</label> - </> - ) : ( - <> - <label><input type="checkbox" checked={options.exportImages} onChange={event => setOptions(prev => ({ ...prev, exportImages: event.target.checked }))} /> 图片</label> - <label><input type="checkbox" checked={options.exportVoices} onChange={event => setOptions(prev => ({ ...prev, exportVoices: event.target.checked }))} /> 语音</label> - <label><input type="checkbox" checked={options.exportVideos} onChange={event => setOptions(prev => ({ ...prev, exportVideos: event.target.checked }))} /> 视频</label> - <label><input type="checkbox" checked={options.exportEmojis} onChange={event => setOptions(prev => ({ ...prev, exportEmojis: event.target.checked }))} /> 表情包</label> - </> - )} + <div className="section-header-action media-section-header"> + <h4>{exportDialog.scope === 'sns' ? '媒体文件(可多选)' : '媒体内容'}</h4> + <span className="media-selection-pill">{mediaSelectionSummaryLabel}</span> </div> - {exportDialog.scope === 'sns' && ( - <div className="format-note">全不勾选时仅导出文本信息,不导出媒体文件。</div> + <div className="media-option-grid"> + {dialogMediaOptions.map(option => { + const Icon = option.icon + return ( + <label key={option.key} className={`media-option-card ${option.checked ? 'active' : ''}`}> + <input + className="media-option-input" + type="checkbox" + checked={option.checked} + onChange={event => option.onToggle(event.target.checked)} + /> + <span className="media-option-main"> + <span className="media-option-icon"> + <Icon size={16} /> + </span> + <span className="media-option-text"> + <span className="media-option-label">{option.label}</span> + <span className="media-option-desc">{option.desc}</span> + </span> + </span> + <span className={`media-option-check ${option.checked ? 'active' : ''}`}> + <Check size={12} /> + </span> + </label> + ) + })} + </div> + {exportDialog.scope !== 'sns' && ( + <div + className={`dialog-collapse-slot ${options.exportFiles ? 'open' : ''}`} + aria-hidden={!options.exportFiles} + > + <div className="dialog-collapse-inner"> + <div className="file-size-subsection"> + <div className="file-size-subsection-header"> + <div className="file-size-heading">文件大小上限</div> + <div className="file-size-current">{fileSizeLimitLabel}</div> + </div> + <div className="file-size-note"> + 文件导出优先使用消息中的 MD5 做校验;设置上限后,只导出不超过该值的文件。 + </div> + <div className="file-size-preset-row"> + {FILE_SIZE_PRESETS_MB.map(preset => ( + <button + key={preset} + type="button" + className={`file-size-preset-btn ${options.maxFileSizeMb === preset ? 'active' : ''}`} + onClick={() => setOptions(prev => ({ ...prev, maxFileSizeMb: preset }))} + > + {preset === 0 ? '不限' : `${preset}MB`} + </button> + ))} + </div> + <div className="dialog-input-row"> + <input + type="number" + min={0} + step={10} + value={options.maxFileSizeMb} + onChange={event => { + const raw = Number(event.target.value) + setOptions(prev => ({ ...prev, maxFileSizeMb: Number.isFinite(raw) ? Math.max(0, Math.floor(raw)) : 0 })) + }} + /> + <span>MB</span> + </div> + </div> + </div> + </div> )} </div> )} - {shouldShowImageDeepSearchToggle && ( - <div className="dialog-section"> - <div className="dialog-switch-row"> - <div className="dialog-switch-copy"> - <h4>缺图时深度搜索</h4> - <div className="format-note">关闭后仅尝试 hardlink 命中,未命中将直接显示占位符,导出速度更快。</div> + {shouldRenderImageDeepSearchToggle && ( + <div className={`dialog-collapse-slot ${shouldShowImageDeepSearchToggle ? 'open' : ''}`} aria-hidden={!shouldShowImageDeepSearchToggle}> + <div className="dialog-collapse-inner"> + <div className="dialog-section"> + <div className="dialog-switch-row"> + <div className="dialog-switch-copy"> + <h4>缺图时深度搜索</h4> + <div className="format-note">关闭后仅尝试 hardlink 命中,未命中将直接显示占位符,导出速度更快。</div> + </div> + <button + type="button" + className={`dialog-switch ${options.imageDeepSearchOnMiss ? 'on' : ''}`} + aria-pressed={options.imageDeepSearchOnMiss} + aria-label="切换缺图时深度搜索" + onClick={() => setOptions(prev => ({ ...prev, imageDeepSearchOnMiss: !prev.imageDeepSearchOnMiss }))} + > + <span className="dialog-switch-thumb" /> + </button> + </div> </div> - <button - type="button" - className={`dialog-switch ${options.imageDeepSearchOnMiss ? 'on' : ''}`} - aria-pressed={options.imageDeepSearchOnMiss} - aria-label="切换缺图时深度搜索" - onClick={() => setOptions(prev => ({ ...prev, imageDeepSearchOnMiss: !prev.imageDeepSearchOnMiss }))} - > - <span className="dialog-switch-thumb" /> - </button> </div> </div> )} @@ -8196,6 +8398,7 @@ function ExportPage() { <div className="dialog-switch-copy"> <h4>语音转文字</h4> <div className="format-note">默认状态跟随更多导出设置中的语音转文字开关。</div> + <div className="format-note">{voiceAsTextStatusLabel}</div> </div> <button type="button" diff --git a/src/pages/SettingsPage.scss b/src/pages/SettingsPage.scss index 8ebad97..37eb6b1 100644 --- a/src/pages/SettingsPage.scss +++ b/src/pages/SettingsPage.scss @@ -2934,3 +2934,488 @@ } } } + +.anti-revoke-tab { + display: flex; + flex-direction: column; + gap: 14px; + + .anti-revoke-hero { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; + padding: 18px; + border-radius: 18px; + border: 1px solid color-mix(in srgb, var(--border-color) 88%, transparent); + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--bg-secondary) 94%, var(--primary) 6%) 0%, + color-mix(in srgb, var(--bg-secondary) 96%, var(--bg-primary) 4%) 100% + ); + } + + .anti-revoke-hero-main { + min-width: 240px; + + h3 { + margin: 0; + font-size: 19px; + font-weight: 600; + line-height: 1.3; + color: var(--text-primary); + letter-spacing: 0.3px; + } + + p { + margin: 8px 0 0; + font-size: 13px; + color: var(--text-secondary); + line-height: 1.6; + } + } + + .anti-revoke-metrics { + flex: 1; + display: grid; + grid-template-columns: repeat(4, minmax(112px, 1fr)); + gap: 10px; + min-width: 460px; + } + + .anti-revoke-metric { + display: flex; + flex-direction: column; + justify-content: center; + gap: 6px; + padding: 12px 14px; + border-radius: 12px; + border: 1px solid color-mix(in srgb, var(--border-color) 90%, transparent); + background: color-mix(in srgb, var(--bg-primary) 93%, var(--bg-secondary) 7%); + + .label { + font-size: 12px; + color: var(--text-secondary); + line-height: 1.2; + letter-spacing: 0.2px; + } + + .value { + font-size: 30px; + font-weight: 700; + color: var(--text-primary); + line-height: 1; + font-variant-numeric: tabular-nums; + } + + &.is-total { + border-color: color-mix(in srgb, var(--border-color) 78%, var(--primary) 22%); + background: color-mix(in srgb, var(--bg-primary) 88%, var(--primary) 12%); + } + + &.is-installed { + border-color: color-mix(in srgb, var(--primary) 36%, var(--border-color)); + background: color-mix(in srgb, var(--bg-primary) 90%, var(--primary) 10%); + + .value { + color: var(--primary); + } + } + + &.is-pending { + background: color-mix(in srgb, var(--bg-primary) 95%, var(--bg-secondary) 5%); + + .value { + color: color-mix(in srgb, var(--text-primary) 82%, var(--text-secondary)); + } + } + + &.is-error { + border-color: color-mix(in srgb, var(--danger) 24%, var(--border-color)); + background: color-mix(in srgb, var(--danger) 6%, var(--bg-primary)); + + .value { + color: color-mix(in srgb, var(--danger) 65%, var(--text-primary) 35%); + } + } + } + + .anti-revoke-control-card { + border: 1px solid color-mix(in srgb, var(--border-color) 90%, transparent); + border-radius: 16px; + padding: 14px; + background: color-mix(in srgb, var(--bg-secondary) 95%, var(--bg-primary) 5%); + } + + .anti-revoke-toolbar { + display: flex; + gap: 14px; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; + flex-wrap: wrap; + } + + .anti-revoke-search { + min-width: 280px; + flex: 1; + max-width: 420px; + border-radius: 10px; + background: color-mix(in srgb, var(--bg-primary) 85%, var(--bg-secondary) 15%); + + input { + height: 36px; + font-size: 13px; + } + } + + .anti-revoke-toolbar-actions { + display: flex; + align-items: stretch; + justify-content: flex-end; + gap: 10px; + flex-wrap: wrap; + margin-left: auto; + } + + .anti-revoke-btn-group { + display: inline-flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + } + + .anti-revoke-batch-actions { + display: flex; + align-items: flex-start; + gap: 12px; + flex-wrap: wrap; + justify-content: space-between; + border-top: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent); + padding-top: 12px; + } + + .anti-revoke-selected-count { + display: inline-flex; + align-items: center; + gap: 14px; + font-size: 12px; + color: var(--text-secondary); + margin-left: auto; + padding: 8px 12px; + border-radius: 10px; + border: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent); + background: color-mix(in srgb, var(--bg-primary) 92%, var(--bg-secondary) 8%); + + span { + position: relative; + line-height: 1.2; + white-space: nowrap; + + strong { + color: var(--text-primary); + font-weight: 700; + font-variant-numeric: tabular-nums; + } + + &:not(:last-child)::after { + content: ''; + position: absolute; + right: -8px; + top: 50%; + width: 4px; + height: 4px; + border-radius: 50%; + background: color-mix(in srgb, var(--text-tertiary) 70%, transparent); + transform: translateY(-50%); + } + } + } + + .anti-revoke-toolbar-actions .btn, + .anti-revoke-batch-actions .btn { + border-radius: 10px; + padding-inline: 14px; + border-width: 1px; + min-height: 36px; + justify-content: center; + } + + .anti-revoke-summary { + padding: 11px 14px; + border-radius: 12px; + font-size: 13px; + border: 1px solid color-mix(in srgb, var(--border-color) 86%, transparent); + background: color-mix(in srgb, var(--bg-primary) 95%, var(--bg-secondary) 5%); + line-height: 1.5; + font-weight: 500; + + &.success { + color: color-mix(in srgb, var(--primary) 72%, var(--text-primary) 28%); + border-color: color-mix(in srgb, var(--primary) 30%, var(--border-color)); + background: color-mix(in srgb, var(--primary) 9%, var(--bg-primary)); + } + + &.error { + color: color-mix(in srgb, var(--danger) 70%, var(--text-primary) 30%); + border-color: color-mix(in srgb, var(--danger) 24%, var(--border-color)); + background: color-mix(in srgb, var(--danger) 7%, var(--bg-primary)); + } + } + + .anti-revoke-list { + border: 1px solid color-mix(in srgb, var(--border-color) 90%, transparent); + border-radius: 16px; + background: var(--bg-primary); + max-height: 460px; + overflow-y: auto; + overflow-x: hidden; + } + + .anti-revoke-list-header { + position: sticky; + top: 0; + z-index: 2; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + padding: 12px 16px; + background: color-mix(in srgb, var(--bg-secondary) 93%, var(--bg-primary) 7%); + border-bottom: 1px solid color-mix(in srgb, var(--border-color) 86%, transparent); + color: var(--text-tertiary); + font-size: 12px; + letter-spacing: 0.24px; + } + + .anti-revoke-empty { + padding: 44px 18px; + font-size: 13px; + color: var(--text-secondary); + text-align: center; + } + + .anti-revoke-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding: 13px 16px; + border-bottom: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent); + transition: background-color 0.18s ease, box-shadow 0.18s ease; + + &:last-child { + border-bottom: none; + } + + &:hover { + background: color-mix(in srgb, var(--bg-secondary) 32%, var(--bg-primary) 68%); + } + + &.selected { + background: color-mix(in srgb, var(--primary) 8%, var(--bg-primary)); + box-shadow: inset 2px 0 0 color-mix(in srgb, var(--primary) 70%, transparent); + } + } + + .anti-revoke-row-main { + display: flex; + align-items: center; + gap: 12px; + flex: 1; + min-width: 0; + cursor: pointer; + + .anti-revoke-check { + position: relative; + width: 18px; + height: 18px; + flex-shrink: 0; + + input[type='checkbox'] { + position: absolute; + inset: 0; + margin: 0; + opacity: 0; + cursor: pointer; + } + + .check-indicator { + width: 100%; + height: 100%; + border-radius: 6px; + border: 1px solid color-mix(in srgb, var(--border-color) 78%, var(--primary) 22%); + background: color-mix(in srgb, var(--bg-primary) 86%, var(--bg-secondary) 14%); + color: var(--on-primary, #fff); + display: inline-flex; + align-items: center; + justify-content: center; + transition: all 0.16s ease; + + svg { + opacity: 0; + transform: scale(0.75); + transition: opacity 0.16s ease, transform 0.16s ease; + } + } + + input[type='checkbox']:checked + .check-indicator { + background: var(--primary); + border-color: var(--primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent); + + svg { + opacity: 1; + transform: scale(1); + } + } + + input[type='checkbox']:focus-visible + .check-indicator { + outline: 2px solid color-mix(in srgb, var(--primary) 42%, transparent); + outline-offset: 1px; + } + + input[type='checkbox']:disabled { + cursor: not-allowed; + } + + input[type='checkbox']:disabled + .check-indicator { + opacity: 0.55; + } + } + } + + .anti-revoke-row-text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + + .name { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + line-height: 1.2; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + } + } + + .anti-revoke-row-status { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; + max-width: 45%; + } + + .status-badge { + display: inline-flex; + align-items: center; + gap: 6px; + border-radius: 999px; + padding: 4px 10px; + font-size: 12px; + line-height: 1.3; + font-weight: 500; + border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent); + color: var(--text-secondary); + background: color-mix(in srgb, var(--bg-secondary) 90%, var(--bg-primary) 10%); + + .status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--text-tertiary); + flex-shrink: 0; + } + + &.installed { + color: var(--primary); + border-color: color-mix(in srgb, var(--primary) 35%, var(--border-color)); + background: color-mix(in srgb, var(--primary) 10%, var(--bg-secondary)); + + .status-dot { + background: var(--primary); + } + } + + &.not-installed { + color: var(--text-secondary); + border-color: color-mix(in srgb, var(--border-color) 84%, transparent); + background: color-mix(in srgb, var(--bg-secondary) 90%, var(--bg-primary) 10%); + + .status-dot { + background: color-mix(in srgb, var(--text-tertiary) 86%, transparent); + } + } + + &.checking { + color: color-mix(in srgb, var(--primary) 70%, var(--text-primary) 30%); + border-color: color-mix(in srgb, var(--primary) 30%, var(--border-color)); + background: color-mix(in srgb, var(--primary) 9%, var(--bg-secondary)); + + .status-dot { + background: var(--primary); + animation: pulse 1.2s ease-in-out infinite; + } + } + + &.error { + color: color-mix(in srgb, var(--danger) 72%, var(--text-primary) 28%); + border-color: color-mix(in srgb, var(--danger) 24%, var(--border-color)); + background: color-mix(in srgb, var(--danger) 8%, var(--bg-secondary)); + + .status-dot { + background: var(--danger); + } + } + } + + .status-error { + font-size: 12px; + color: color-mix(in srgb, var(--danger) 66%, var(--text-primary) 34%); + max-width: 100%; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + } + + @media (max-width: 980px) { + .anti-revoke-hero { + flex-direction: column; + } + + .anti-revoke-metrics { + width: 100%; + min-width: 0; + grid-template-columns: repeat(2, minmax(130px, 1fr)); + } + + .anti-revoke-batch-actions { + align-items: flex-start; + flex-direction: column; + } + + .anti-revoke-selected-count { + margin-left: 0; + width: 100%; + justify-content: flex-start; + overflow-x: auto; + } + + .anti-revoke-row { + align-items: flex-start; + flex-direction: column; + } + + .anti-revoke-row-status { + width: 100%; + flex-direction: row; + align-items: center; + justify-content: space-between; + max-width: none; + } + } +} diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 55c5779..316228c 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -15,11 +15,12 @@ import { import { Avatar } from '../components/Avatar' import './SettingsPage.scss' -type SettingsTab = 'appearance' | 'notification' | 'database' | 'models' | 'cache' | 'api' | 'updates' | 'security' | 'about' | 'analytics' +type SettingsTab = 'appearance' | 'notification' | 'antiRevoke' | 'database' | 'models' | 'cache' | 'api' | 'updates' | 'security' | 'about' | 'analytics' const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [ { id: 'appearance', label: '外观', icon: Palette }, { id: 'notification', label: '通知', icon: Bell }, + { id: 'antiRevoke', label: '防撤回', icon: RotateCcw }, { id: 'database', label: '数据库连接', icon: Database }, { id: 'models', label: '模型管理', icon: Mic }, { id: 'cache', label: '缓存', icon: HardDrive }, @@ -70,6 +71,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { setShowUpdateDialog, } = useAppStore() + const chatSessions = useChatStore((state) => state.sessions) + const setChatSessions = useChatStore((state) => state.setSessions) const resetChatStore = useChatStore((state) => state.reset) const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore() const [systemDark, setSystemDark] = useState(() => window.matchMedia('(prefers-color-scheme: dark)').matches) @@ -138,6 +141,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const [notificationPosition, setNotificationPosition] = useState<'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'>('top-right') const [notificationFilterMode, setNotificationFilterMode] = useState<'all' | 'whitelist' | 'blacklist'>('all') const [notificationFilterList, setNotificationFilterList] = useState<string[]>([]) + const [launchAtStartup, setLaunchAtStartup] = useState(false) + const [launchAtStartupSupported, setLaunchAtStartupSupported] = useState(isWindows || isMac) + const [launchAtStartupReason, setLaunchAtStartupReason] = useState('') const [windowCloseBehavior, setWindowCloseBehavior] = useState<configService.WindowCloseBehavior>('ask') const [quoteLayout, setQuoteLayout] = useState<configService.QuoteLayout>('quote-top') const [updateChannel, setUpdateChannel] = useState<configService.UpdateChannel>('stable') @@ -162,6 +168,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const [isFetchingDbKey, setIsFetchingDbKey] = useState(false) const [isFetchingImageKey, setIsFetchingImageKey] = useState(false) const [isCheckingUpdate, setIsCheckingUpdate] = useState(false) + const [isUpdatingLaunchAtStartup, setIsUpdatingLaunchAtStartup] = useState(false) const [appVersion, setAppVersion] = useState('') const [message, setMessage] = useState<{ text: string; success: boolean } | null>(null) const [showDecryptKey, setShowDecryptKey] = useState(false) @@ -196,6 +203,13 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const [isTogglingApi, setIsTogglingApi] = useState(false) const [showApiWarning, setShowApiWarning] = useState(false) const [messagePushEnabled, setMessagePushEnabled] = useState(false) + const [antiRevokeSearchKeyword, setAntiRevokeSearchKeyword] = useState('') + const [antiRevokeSelectedIds, setAntiRevokeSelectedIds] = useState<Set<string>>(new Set()) + const [antiRevokeStatusMap, setAntiRevokeStatusMap] = useState<Record<string, { installed?: boolean; loading?: boolean; error?: string }>>({}) + const [isAntiRevokeRefreshing, setIsAntiRevokeRefreshing] = useState(false) + const [isAntiRevokeInstalling, setIsAntiRevokeInstalling] = useState(false) + const [isAntiRevokeUninstalling, setIsAntiRevokeUninstalling] = useState(false) + const [antiRevokeSummary, setAntiRevokeSummary] = useState<{ action: 'refresh' | 'install' | 'uninstall'; success: number; failed: number } | null>(null) const isClearingCache = isClearingAnalyticsCache || isClearingImageCache || isClearingAllCache @@ -337,6 +351,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const savedNotificationFilterMode = await configService.getNotificationFilterMode() const savedNotificationFilterList = await configService.getNotificationFilterList() const savedMessagePushEnabled = await configService.getMessagePushEnabled() + const savedLaunchAtStartupStatus = await window.electronAPI.app.getLaunchAtStartupStatus() const savedWindowCloseBehavior = await configService.getWindowCloseBehavior() const savedQuoteLayout = await configService.getQuoteLayout() const savedUpdateChannel = await configService.getUpdateChannel() @@ -386,15 +401,18 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { setNotificationFilterMode(savedNotificationFilterMode) setNotificationFilterList(savedNotificationFilterList) setMessagePushEnabled(savedMessagePushEnabled) + setLaunchAtStartup(savedLaunchAtStartupStatus.enabled) + setLaunchAtStartupSupported(savedLaunchAtStartupStatus.supported) + setLaunchAtStartupReason(savedLaunchAtStartupStatus.reason || '') setWindowCloseBehavior(savedWindowCloseBehavior) setQuoteLayout(savedQuoteLayout) if (savedUpdateChannel) { setUpdateChannel(savedUpdateChannel) } else { const currentVersion = await window.electronAPI.app.getVersion() - if (/-preview\.\d+\.\d+$/i.test(currentVersion)) { + if (/^0\.\d{2}\.\d+$/i.test(currentVersion) || /-preview\.\d+\.\d+$/i.test(currentVersion)) { setUpdateChannel('preview') - } else if (/-dev\.\d+\.\d+\.\d+$/i.test(currentVersion) || /(alpha|beta|rc)/i.test(currentVersion)) { + } else if (/^\d{2}\.\d{1,2}\.\d{1,2}$/i.test(currentVersion) || /-dev\.\d+\.\d+\.\d+$/i.test(currentVersion) || /(alpha|beta|rc)/i.test(currentVersion)) { setUpdateChannel('dev') } else { setUpdateChannel('stable') @@ -428,6 +446,29 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { + const handleLaunchAtStartupChange = async (enabled: boolean) => { + if (isUpdatingLaunchAtStartup) return + + try { + setIsUpdatingLaunchAtStartup(true) + const result = await window.electronAPI.app.setLaunchAtStartup(enabled) + setLaunchAtStartup(result.enabled) + setLaunchAtStartupSupported(result.supported) + setLaunchAtStartupReason(result.reason || '') + + if (result.success) { + showMessage(enabled ? '已开启开机自启动' : '已关闭开机自启动', true) + return + } + + showMessage(result.error || result.reason || '设置开机自启动失败', false) + } catch (e: any) { + showMessage(`设置开机自启动失败: ${e?.message || String(e)}`, false) + } finally { + setIsUpdatingLaunchAtStartup(false) + } + } + const refreshWhisperStatus = async (modelDirValue = whisperModelDir) => { try { const result = await window.electronAPI.whisper?.getModelStatus() @@ -555,6 +596,248 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { }, 200) } + const normalizeSessionIds = (sessionIds: string[]): string[] => + Array.from(new Set((sessionIds || []).map((id) => String(id || '').trim()).filter(Boolean))) + + const getCurrentAntiRevokeSessionIds = (): string[] => + normalizeSessionIds(chatSessions.map((session) => session.username)) + + const ensureAntiRevokeSessionsLoaded = async (): Promise<string[]> => { + const current = getCurrentAntiRevokeSessionIds() + if (current.length > 0) return current + const sessionsResult = await window.electronAPI.chat.getSessions() + if (!sessionsResult.success || !sessionsResult.sessions) { + throw new Error(sessionsResult.error || '加载会话失败') + } + setChatSessions(sessionsResult.sessions) + return normalizeSessionIds(sessionsResult.sessions.map((session) => session.username)) + } + + const markAntiRevokeRowsLoading = (sessionIds: string[]) => { + setAntiRevokeStatusMap((prev) => { + const next = { ...prev } + for (const sessionId of sessionIds) { + next[sessionId] = { + ...(next[sessionId] || {}), + loading: true, + error: undefined + } + } + return next + }) + } + + const handleRefreshAntiRevokeStatus = async (sessionIds?: string[]) => { + if (isAntiRevokeRefreshing || isAntiRevokeInstalling || isAntiRevokeUninstalling) return + setAntiRevokeSummary(null) + setIsAntiRevokeRefreshing(true) + try { + const targetIds = normalizeSessionIds( + sessionIds && sessionIds.length > 0 + ? sessionIds + : await ensureAntiRevokeSessionsLoaded() + ) + if (targetIds.length === 0) { + setAntiRevokeStatusMap({}) + showMessage('暂无可检查的会话', true) + return + } + markAntiRevokeRowsLoading(targetIds) + + const result = await window.electronAPI.chat.checkAntiRevokeTriggers(targetIds) + if (!result.success || !result.rows) { + const errorText = result.error || '防撤回状态检查失败' + setAntiRevokeStatusMap((prev) => { + const next = { ...prev } + for (const sessionId of targetIds) { + next[sessionId] = { + ...(next[sessionId] || {}), + loading: false, + error: errorText + } + } + return next + }) + showMessage(errorText, false) + return + } + + const rowMap = new Map<string, { sessionId: string; success: boolean; installed?: boolean; error?: string }>() + for (const row of result.rows || []) { + const sessionId = String(row.sessionId || '').trim() + if (!sessionId) continue + rowMap.set(sessionId, row) + } + const mergedRows = targetIds.map((sessionId) => ( + rowMap.get(sessionId) || { sessionId, success: false, error: '状态查询未返回结果' } + )) + const successCount = mergedRows.filter((row) => row.success).length + const failedCount = mergedRows.length - successCount + setAntiRevokeStatusMap((prev) => { + const next = { ...prev } + for (const row of mergedRows) { + const sessionId = String(row.sessionId || '').trim() + if (!sessionId) continue + next[sessionId] = { + installed: row.installed === true, + loading: false, + error: row.success ? undefined : (row.error || '状态查询失败') + } + } + return next + }) + setAntiRevokeSummary({ action: 'refresh', success: successCount, failed: failedCount }) + showMessage(`状态刷新完成:成功 ${successCount},失败 ${failedCount}`, failedCount === 0) + } catch (e: any) { + showMessage(`防撤回状态刷新失败: ${e?.message || String(e)}`, false) + } finally { + setIsAntiRevokeRefreshing(false) + } + } + + const handleInstallAntiRevokeTriggers = async () => { + if (isAntiRevokeRefreshing || isAntiRevokeInstalling || isAntiRevokeUninstalling) return + const sessionIds = normalizeSessionIds(Array.from(antiRevokeSelectedIds)) + if (sessionIds.length === 0) { + showMessage('请先选择至少一个会话', false) + return + } + setAntiRevokeSummary(null) + setIsAntiRevokeInstalling(true) + try { + markAntiRevokeRowsLoading(sessionIds) + const result = await window.electronAPI.chat.installAntiRevokeTriggers(sessionIds) + if (!result.success || !result.rows) { + const errorText = result.error || '批量安装失败' + setAntiRevokeStatusMap((prev) => { + const next = { ...prev } + for (const sessionId of sessionIds) { + next[sessionId] = { + ...(next[sessionId] || {}), + loading: false, + error: errorText + } + } + return next + }) + showMessage(errorText, false) + return + } + + const rowMap = new Map<string, { sessionId: string; success: boolean; alreadyInstalled?: boolean; error?: string }>() + for (const row of result.rows || []) { + const sessionId = String(row.sessionId || '').trim() + if (!sessionId) continue + rowMap.set(sessionId, row) + } + const mergedRows = sessionIds.map((sessionId) => ( + rowMap.get(sessionId) || { sessionId, success: false, error: '安装未返回结果' } + )) + const successCount = mergedRows.filter((row) => row.success).length + const failedCount = mergedRows.length - successCount + setAntiRevokeStatusMap((prev) => { + const next = { ...prev } + for (const row of mergedRows) { + const sessionId = String(row.sessionId || '').trim() + if (!sessionId) continue + next[sessionId] = { + installed: row.success ? true : next[sessionId]?.installed, + loading: false, + error: row.success ? undefined : (row.error || '安装失败') + } + } + return next + }) + setAntiRevokeSummary({ action: 'install', success: successCount, failed: failedCount }) + showMessage(`批量安装完成:成功 ${successCount},失败 ${failedCount}`, failedCount === 0) + } catch (e: any) { + showMessage(`批量安装失败: ${e?.message || String(e)}`, false) + } finally { + setIsAntiRevokeInstalling(false) + } + } + + const handleUninstallAntiRevokeTriggers = async () => { + if (isAntiRevokeRefreshing || isAntiRevokeInstalling || isAntiRevokeUninstalling) return + const sessionIds = normalizeSessionIds(Array.from(antiRevokeSelectedIds)) + if (sessionIds.length === 0) { + showMessage('请先选择至少一个会话', false) + return + } + setAntiRevokeSummary(null) + setIsAntiRevokeUninstalling(true) + try { + markAntiRevokeRowsLoading(sessionIds) + const result = await window.electronAPI.chat.uninstallAntiRevokeTriggers(sessionIds) + if (!result.success || !result.rows) { + const errorText = result.error || '批量卸载失败' + setAntiRevokeStatusMap((prev) => { + const next = { ...prev } + for (const sessionId of sessionIds) { + next[sessionId] = { + ...(next[sessionId] || {}), + loading: false, + error: errorText + } + } + return next + }) + showMessage(errorText, false) + return + } + + const rowMap = new Map<string, { sessionId: string; success: boolean; error?: string }>() + for (const row of result.rows || []) { + const sessionId = String(row.sessionId || '').trim() + if (!sessionId) continue + rowMap.set(sessionId, row) + } + const mergedRows = sessionIds.map((sessionId) => ( + rowMap.get(sessionId) || { sessionId, success: false, error: '卸载未返回结果' } + )) + const successCount = mergedRows.filter((row) => row.success).length + const failedCount = mergedRows.length - successCount + setAntiRevokeStatusMap((prev) => { + const next = { ...prev } + for (const row of mergedRows) { + const sessionId = String(row.sessionId || '').trim() + if (!sessionId) continue + next[sessionId] = { + installed: row.success ? false : next[sessionId]?.installed, + loading: false, + error: row.success ? undefined : (row.error || '卸载失败') + } + } + return next + }) + setAntiRevokeSummary({ action: 'uninstall', success: successCount, failed: failedCount }) + showMessage(`批量卸载完成:成功 ${successCount},失败 ${failedCount}`, failedCount === 0) + } catch (e: any) { + showMessage(`批量卸载失败: ${e?.message || String(e)}`, false) + } finally { + setIsAntiRevokeUninstalling(false) + } + } + + useEffect(() => { + if (activeTab !== 'antiRevoke') return + let canceled = false + ;(async () => { + try { + const sessionIds = await ensureAntiRevokeSessionsLoaded() + if (canceled) return + await handleRefreshAntiRevokeStatus(sessionIds) + } catch (e: any) { + if (!canceled) { + showMessage(`加载防撤回会话失败: ${e?.message || String(e)}`, false) + } + } + })() + return () => { + canceled = true + } + }, [activeTab]) + type WxidKeys = { decryptKey: string imageXorKey: number | null @@ -1199,6 +1482,39 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { <div className="divider" /> + <div className="form-group"> + <label>开机自启动</label> + <span className="form-hint"> + {launchAtStartupSupported + ? '开启后,登录系统时会自动启动 WeFlow。' + : launchAtStartupReason || '当前环境暂不支持开机自启动。'} + </span> + <div className="log-toggle-line"> + <span className="log-status"> + {isUpdatingLaunchAtStartup + ? '保存中...' + : launchAtStartupSupported + ? (launchAtStartup ? '已开启' : '已关闭') + : '当前不可用'} + </span> + <label className="switch" htmlFor="launch-at-startup-toggle"> + <input + id="launch-at-startup-toggle" + className="switch-input" + type="checkbox" + checked={launchAtStartup} + disabled={!launchAtStartupSupported || isUpdatingLaunchAtStartup} + onChange={(e) => { + void handleLaunchAtStartupChange(e.target.checked) + }} + /> + <span className="switch-slider" /> + </label> + </div> + </div> + + <div className="divider" /> + <div className="form-group"> <label>关闭主窗口时</label> <span className="form-hint">设置点击关闭按钮后的默认行为;选择“每次询问”时会弹出关闭确认。</span> @@ -1255,11 +1571,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { ) const renderNotificationTab = () => { - const { sessions } = useChatStore.getState() - // 获取已过滤会话的信息 const getSessionInfo = (username: string) => { - const session = sessions.find(s => s.username === username) + const session = chatSessions.find(s => s.username === username) return { displayName: session?.displayName || username, avatarUrl: session?.avatarUrl || '' @@ -1284,7 +1598,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { } // 过滤掉已在列表中的会话,并根据搜索关键字过滤 - const availableSessions = sessions.filter(s => { + const availableSessions = chatSessions.filter(s => { if (notificationFilterList.includes(s.username)) return false if (filterSearchKeyword) { const keyword = filterSearchKeyword.toLowerCase() @@ -1500,6 +1814,199 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { ) } + const renderAntiRevokeTab = () => { + const sortedSessions = [...chatSessions].sort((a, b) => (b.sortTimestamp || 0) - (a.sortTimestamp || 0)) + const keyword = antiRevokeSearchKeyword.trim().toLowerCase() + const filteredSessions = sortedSessions.filter((session) => { + if (!keyword) return true + const displayName = String(session.displayName || '').toLowerCase() + const username = String(session.username || '').toLowerCase() + return displayName.includes(keyword) || username.includes(keyword) + }) + const filteredSessionIds = filteredSessions.map((session) => session.username) + const selectedCount = antiRevokeSelectedIds.size + const selectedInFilteredCount = filteredSessionIds.filter((sessionId) => antiRevokeSelectedIds.has(sessionId)).length + const allFilteredSelected = filteredSessionIds.length > 0 && selectedInFilteredCount === filteredSessionIds.length + const busy = isAntiRevokeRefreshing || isAntiRevokeInstalling || isAntiRevokeUninstalling + const statusStats = filteredSessions.reduce( + (acc, session) => { + const rowState = antiRevokeStatusMap[session.username] + if (rowState?.error) acc.failed += 1 + else if (rowState?.installed === true) acc.installed += 1 + else if (rowState?.installed === false) acc.notInstalled += 1 + return acc + }, + { installed: 0, notInstalled: 0, failed: 0 } + ) + + const toggleSelected = (sessionId: string) => { + setAntiRevokeSelectedIds((prev) => { + const next = new Set(prev) + if (next.has(sessionId)) next.delete(sessionId) + else next.add(sessionId) + return next + }) + } + + const selectAllFiltered = () => { + if (filteredSessionIds.length === 0) return + setAntiRevokeSelectedIds((prev) => { + const next = new Set(prev) + for (const sessionId of filteredSessionIds) { + next.add(sessionId) + } + return next + }) + } + + const clearSelection = () => { + setAntiRevokeSelectedIds(new Set()) + } + + return ( + <div className="tab-content anti-revoke-tab"> + <div className="anti-revoke-hero"> + <div className="anti-revoke-hero-main"> + <h3>防撤回</h3> + <p>你可以根据会话进行防撤回部署,安装后无需保持 WeFlow 运行即可实现防撤回</p> + </div> + <div className="anti-revoke-metrics"> + <div className="anti-revoke-metric is-total"> + <span className="label">筛选会话</span> + <span className="value">{filteredSessionIds.length}</span> + </div> + <div className="anti-revoke-metric is-installed"> + <span className="label">已安装</span> + <span className="value">{statusStats.installed}</span> + </div> + <div className="anti-revoke-metric is-pending"> + <span className="label">未安装</span> + <span className="value">{statusStats.notInstalled}</span> + </div> + <div className="anti-revoke-metric is-error"> + <span className="label">异常</span> + <span className="value">{statusStats.failed}</span> + </div> + </div> + </div> + + <div className="anti-revoke-control-card"> + <div className="anti-revoke-toolbar"> + <div className="filter-search-box anti-revoke-search"> + <Search size={14} /> + <input + type="text" + placeholder="搜索会话..." + value={antiRevokeSearchKeyword} + onChange={(e) => setAntiRevokeSearchKeyword(e.target.value)} + /> + </div> + <div className="anti-revoke-toolbar-actions"> + <div className="anti-revoke-btn-group"> + <button className="btn btn-secondary btn-sm" onClick={() => void handleRefreshAntiRevokeStatus()} disabled={busy}> + <RefreshCw size={14} /> {isAntiRevokeRefreshing ? '刷新中...' : '刷新状态'} + </button> + </div> + <div className="anti-revoke-btn-group"> + <button className="btn btn-secondary btn-sm" onClick={selectAllFiltered} disabled={busy || filteredSessionIds.length === 0 || allFilteredSelected}> + 全选 + </button> + <button className="btn btn-secondary btn-sm" onClick={clearSelection} disabled={busy || selectedCount === 0}> + 清空选择 + </button> + </div> + </div> + </div> + + <div className="anti-revoke-batch-actions"> + <div className="anti-revoke-btn-group anti-revoke-batch-btns"> + <button className="btn btn-primary btn-sm" onClick={() => void handleInstallAntiRevokeTriggers()} disabled={busy || selectedCount === 0}> + {isAntiRevokeInstalling ? '安装中...' : '批量安装'} + </button> + <button className="btn btn-secondary btn-sm" onClick={() => void handleUninstallAntiRevokeTriggers()} disabled={busy || selectedCount === 0}> + {isAntiRevokeUninstalling ? '卸载中...' : '批量卸载'} + </button> + </div> + <div className="anti-revoke-selected-count"> + <span>已选 <strong>{selectedCount}</strong> 个会话</span> + <span>筛选命中 <strong>{selectedInFilteredCount}</strong> / {filteredSessionIds.length}</span> + </div> + </div> + </div> + + {antiRevokeSummary && ( + <div className={`anti-revoke-summary ${antiRevokeSummary.failed > 0 ? 'error' : 'success'}`}> + {antiRevokeSummary.action === 'refresh' ? '刷新' : antiRevokeSummary.action === 'install' ? '安装' : '卸载'} + 完成:成功 {antiRevokeSummary.success},失败 {antiRevokeSummary.failed} + </div> + )} + + <div className="anti-revoke-list"> + {filteredSessions.length === 0 ? ( + <div className="anti-revoke-empty">{antiRevokeSearchKeyword ? '没有匹配的会话' : '暂无会话可配置'}</div> + ) : ( + <> + <div className="anti-revoke-list-header"> + <span>会话({filteredSessions.length})</span> + <span>状态</span> + </div> + {filteredSessions.map((session) => { + const rowState = antiRevokeStatusMap[session.username] + let statusClass = 'unknown' + let statusLabel = '未检查' + if (rowState?.loading) { + statusClass = 'checking' + statusLabel = '检查中' + } else if (rowState?.error) { + statusClass = 'error' + statusLabel = '失败' + } else if (rowState?.installed === true) { + statusClass = 'installed' + statusLabel = '已安装' + } else if (rowState?.installed === false) { + statusClass = 'not-installed' + statusLabel = '未安装' + } + return ( + <div key={session.username} className={`anti-revoke-row ${antiRevokeSelectedIds.has(session.username) ? 'selected' : ''}`}> + <label className="anti-revoke-row-main"> + <span className="anti-revoke-check"> + <input + type="checkbox" + checked={antiRevokeSelectedIds.has(session.username)} + onChange={() => toggleSelected(session.username)} + disabled={busy} + /> + <span className="check-indicator" aria-hidden="true"> + <Check size={12} /> + </span> + </span> + <Avatar + src={session.avatarUrl} + name={session.displayName || session.username} + size={30} + /> + <div className="anti-revoke-row-text"> + <span className="name">{session.displayName || session.username}</span> + </div> + </label> + <div className="anti-revoke-row-status"> + <span className={`status-badge ${statusClass}`}> + <i className="status-dot" aria-hidden="true" /> + {statusLabel} + </span> + {rowState?.error && <span className="status-error">{rowState.error}</span>} + </div> + </div> + ) + })} + </> + )} + </div> + </div> + ) + } + const renderDatabaseTab = () => ( <div className="tab-content"> <div className="form-group"> @@ -2444,7 +2951,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { <div className="about-footer"> <p className="about-desc">微信聊天记录分析工具</p> <div className="about-links"> - <a href="#" onClick={(e) => { e.preventDefault(); window.electronAPI.shell.openExternal('https://github.com/hicccc77/WeFlow') }}>官网</a> + <a href="#" onClick={(e) => { e.preventDefault(); window.electronAPI.shell.openExternal('https://weflow.top') }}>官网</a> + <span>·</span> + <a href="#" onClick={(e) => { e.preventDefault(); window.electronAPI.shell.openExternal('https://github.com/hicccc77/WeFlow') }}>GitHub 仓库</a> <span>·</span> <a href="#" onClick={(e) => { e.preventDefault(); window.electronAPI.shell.openExternal('https://chatlab.fun') }}>ChatLab</a> <span>·</span> @@ -2621,6 +3130,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { <div className="settings-body"> {activeTab === 'appearance' && renderAppearanceTab()} {activeTab === 'notification' && renderNotificationTab()} + {activeTab === 'antiRevoke' && renderAntiRevokeTab()} {activeTab === 'database' && renderDatabaseTab()} {activeTab === 'models' && renderModelsTab()} {activeTab === 'cache' && renderCacheTab()} diff --git a/src/services/config.ts b/src/services/config.ts index 59e8afa..a0b7c54 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -13,6 +13,7 @@ export const CONFIG_KEYS = { LAST_SESSION: 'lastSession', WINDOW_BOUNDS: 'windowBounds', CACHE_PATH: 'cachePath', + LAUNCH_AT_STARTUP: 'launchAtStartup', EXPORT_PATH: 'exportPath', AGREEMENT_ACCEPTED: 'agreementAccepted', @@ -93,6 +94,7 @@ export interface ExportDefaultMediaConfig { videos: boolean voices: boolean emojis: boolean + files: boolean } export type WindowCloseBehavior = 'ask' | 'tray' | 'quit' @@ -103,7 +105,8 @@ const DEFAULT_EXPORT_MEDIA_CONFIG: ExportDefaultMediaConfig = { images: true, videos: true, voices: true, - emojis: true + emojis: true, + files: true } // 获取解密密钥 @@ -258,6 +261,18 @@ export async function setLogEnabled(enabled: boolean): Promise<void> { await config.set(CONFIG_KEYS.LOG_ENABLED, enabled) } +// 获取开机自启动偏好 +export async function getLaunchAtStartup(): Promise<boolean | null> { + const value = await config.get(CONFIG_KEYS.LAUNCH_AT_STARTUP) + if (typeof value === 'boolean') return value + return null +} + +// 设置开机自启动偏好 +export async function setLaunchAtStartup(enabled: boolean): Promise<void> { + await config.set(CONFIG_KEYS.LAUNCH_AT_STARTUP, enabled) +} + // 获取 LLM 模型路径 export async function getLlmModelPath(): Promise<string | null> { const value = await config.get(CONFIG_KEYS.LLM_MODEL_PATH) @@ -410,7 +425,8 @@ export async function getExportDefaultMedia(): Promise<ExportDefaultMediaConfig images: value, videos: value, voices: value, - emojis: value + emojis: value, + files: value } } if (value && typeof value === 'object') { @@ -419,7 +435,8 @@ export async function getExportDefaultMedia(): Promise<ExportDefaultMediaConfig images: typeof raw.images === 'boolean' ? raw.images : DEFAULT_EXPORT_MEDIA_CONFIG.images, videos: typeof raw.videos === 'boolean' ? raw.videos : DEFAULT_EXPORT_MEDIA_CONFIG.videos, voices: typeof raw.voices === 'boolean' ? raw.voices : DEFAULT_EXPORT_MEDIA_CONFIG.voices, - emojis: typeof raw.emojis === 'boolean' ? raw.emojis : DEFAULT_EXPORT_MEDIA_CONFIG.emojis + emojis: typeof raw.emojis === 'boolean' ? raw.emojis : DEFAULT_EXPORT_MEDIA_CONFIG.emojis, + files: typeof raw.files === 'boolean' ? raw.files : DEFAULT_EXPORT_MEDIA_CONFIG.files } } return null @@ -431,7 +448,8 @@ export async function setExportDefaultMedia(media: ExportDefaultMediaConfig): Pr images: media.images, videos: media.videos, voices: media.voices, - emojis: media.emojis + emojis: media.emojis, + files: media.files }) } diff --git a/src/stores/chatStore.ts b/src/stores/chatStore.ts index b4c04f7..3ca03f1 100644 --- a/src/stores/chatStore.ts +++ b/src/stores/chatStore.ts @@ -1,6 +1,46 @@ import { create } from 'zustand' import type { ChatSession, Message, Contact } from '../types/models' +const messageAliasIndex = new Set<string>() + +function buildPrimaryMessageKey(message: Message): string { + if (message.messageKey) return String(message.messageKey) + return `fallback:${message.serverId || 0}:${message.createTime}:${message.sortSeq || 0}:${message.localId || 0}:${message.senderUsername || ''}:${message.localType || 0}` +} + +function buildMessageAliasKeys(message: Message): string[] { + const keys = [buildPrimaryMessageKey(message)] + const localId = Math.max(0, Number(message.localId || 0)) + const serverId = Math.max(0, Number(message.serverId || 0)) + const createTime = Math.max(0, Number(message.createTime || 0)) + const localType = Math.floor(Number(message.localType || 0)) + const sender = String(message.senderUsername || '') + const isSend = Number(message.isSend ?? -1) + + if (localId > 0) { + keys.push(`lid:${localId}`) + } + if (serverId > 0) { + keys.push(`sid:${serverId}`) + } + if (localType === 3) { + const imageIdentity = String(message.imageMd5 || message.imageDatName || '').trim() + if (imageIdentity) { + keys.push(`img:${createTime}:${sender}:${isSend}:${imageIdentity}`) + } + } + + return keys +} + +function rebuildMessageAliasIndex(messages: Message[]): void { + messageAliasIndex.clear() + for (const message of messages) { + const aliasKeys = buildMessageAliasKeys(message) + aliasKeys.forEach((key) => messageAliasIndex.add(key)) + } +} + export interface ChatState { // 连接状态 isConnected: boolean @@ -69,59 +109,37 @@ export const useChatStore = create<ChatState>((set, get) => ({ setSessions: (sessions) => set({ sessions, filteredSessions: sessions }), setFilteredSessions: (sessions) => set({ filteredSessions: sessions }), - setCurrentSession: (sessionId, options) => set((state) => ({ - currentSessionId: sessionId, - messages: options?.preserveMessages ? state.messages : [], - hasMoreMessages: true, - hasMoreLater: false - })), + setCurrentSession: (sessionId, options) => set((state) => { + const nextMessages = options?.preserveMessages ? state.messages : [] + rebuildMessageAliasIndex(nextMessages) + return { + currentSessionId: sessionId, + messages: nextMessages, + hasMoreMessages: true, + hasMoreLater: false + } + }), setLoadingSessions: (loading) => set({ isLoadingSessions: loading }), - setMessages: (messages) => set({ messages }), + setMessages: (messages) => set(() => { + rebuildMessageAliasIndex(messages || []) + return { messages } + }), appendMessages: (newMessages, prepend = false) => set((state) => { - const buildPrimaryKey = (m: Message): string => { - if (m.messageKey) return String(m.messageKey) - return `fallback:${m.serverId || 0}:${m.createTime}:${m.sortSeq || 0}:${m.localId || 0}:${m.senderUsername || ''}:${m.localType || 0}` - } - const buildAliasKeys = (m: Message): string[] => { - const keys = [buildPrimaryKey(m)] - const localId = Math.max(0, Number(m.localId || 0)) - const serverId = Math.max(0, Number(m.serverId || 0)) - const createTime = Math.max(0, Number(m.createTime || 0)) - const localType = Math.floor(Number(m.localType || 0)) - const sender = String(m.senderUsername || '') - const isSend = Number(m.isSend ?? -1) - - if (localId > 0) { - keys.push(`lid:${localId}`) - } - if (serverId > 0) { - keys.push(`sid:${serverId}`) - } - if (localType === 3) { - const imageIdentity = String(m.imageMd5 || m.imageDatName || '').trim() - if (imageIdentity) { - keys.push(`img:${createTime}:${sender}:${isSend}:${imageIdentity}`) - } - } - return keys - } - const currentMessages = state.messages || [] - const existingAliases = new Set<string>() - currentMessages.forEach((msg) => { - buildAliasKeys(msg).forEach((key) => existingAliases.add(key)) - }) + if (messageAliasIndex.size === 0 && currentMessages.length > 0) { + rebuildMessageAliasIndex(currentMessages) + } const filtered: Message[] = [] newMessages.forEach((msg) => { - const aliasKeys = buildAliasKeys(msg) - const exists = aliasKeys.some((key) => existingAliases.has(key)) + const aliasKeys = buildMessageAliasKeys(msg) + const exists = aliasKeys.some((key) => messageAliasIndex.has(key)) if (exists) return filtered.push(msg) - aliasKeys.forEach((key) => existingAliases.add(key)) + aliasKeys.forEach((key) => messageAliasIndex.add(key)) }) if (filtered.length === 0) return state @@ -150,20 +168,23 @@ export const useChatStore = create<ChatState>((set, get) => ({ setSearchKeyword: (keyword) => set({ searchKeyword: keyword }), - reset: () => set({ - isConnected: false, - isConnecting: false, - connectionError: null, - sessions: [], - filteredSessions: [], - currentSessionId: null, - isLoadingSessions: false, - messages: [], - isLoadingMessages: false, - isLoadingMore: false, - hasMoreMessages: true, - hasMoreLater: false, - contacts: new Map(), - searchKeyword: '' + reset: () => set(() => { + messageAliasIndex.clear() + return { + isConnected: false, + isConnecting: false, + connectionError: null, + sessions: [], + filteredSessions: [], + currentSessionId: null, + isLoadingSessions: false, + messages: [], + isLoadingMessages: false, + isLoadingMore: false, + hasMoreMessages: true, + hasMoreLater: false, + contacts: new Map(), + searchKeyword: '' + } }) })) diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 31e64a5..8ef9277 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -56,6 +56,14 @@ export interface ElectronAPI { app: { getDownloadsPath: () => Promise<string> getVersion: () => Promise<string> + getLaunchAtStartupStatus: () => Promise<{ enabled: boolean; supported: boolean; reason?: string }> + setLaunchAtStartup: (enabled: boolean) => Promise<{ + success: boolean + enabled: boolean + supported: boolean + reason?: string + error?: string + }> checkForUpdates: () => Promise<{ hasUpdate: boolean; version?: string; releaseNotes?: string }> downloadAndInstall: () => Promise<void> ignoreUpdate: (version: string) => Promise<{ success: boolean }> @@ -218,6 +226,21 @@ export interface ElectronAPI { getContactAvatar: (username: string) => Promise<{ avatarUrl?: string; displayName?: string } | null> updateMessage: (sessionId: string, localId: number, createTime: number, newContent: string) => Promise<{ success: boolean; error?: string }> deleteMessage: (sessionId: string, localId: number, createTime: number, dbPathHint?: string) => Promise<{ success: boolean; error?: string }> + checkAntiRevokeTriggers: (sessionIds: string[]) => Promise<{ + success: boolean + rows?: Array<{ sessionId: string; success: boolean; installed?: boolean; error?: string }> + error?: string + }> + installAntiRevokeTriggers: (sessionIds: string[]) => Promise<{ + success: boolean + rows?: Array<{ sessionId: string; success: boolean; alreadyInstalled?: boolean; error?: string }> + error?: string + }> + uninstallAntiRevokeTriggers: (sessionIds: string[]) => Promise<{ + success: boolean + rows?: Array<{ sessionId: string; success: boolean; error?: string }> + error?: string + }> resolveTransferDisplayNames: (chatroomId: string, payerUsername: string, receiverUsername: string) => Promise<{ payerName: string; receiverName: string }> getContacts: (options?: { lite?: boolean }) => Promise<{ success: boolean @@ -326,6 +349,11 @@ export interface ElectronAPI { getMessage: (sessionId: string, localId: number) => Promise<{ success: boolean; message?: Message; error?: string }> 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: { decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => Promise<{ success: boolean; localPath?: string; liveVideoPath?: string; error?: string }> @@ -868,7 +896,7 @@ export interface ElectronAPI { export interface ExportOptions { format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql' - contentType?: 'text' | 'voice' | 'image' | 'video' | 'emoji' + contentType?: 'text' | 'voice' | 'image' | 'video' | 'emoji' | 'file' dateRange?: { start: number; end: number } | null senderUsername?: string fileNameSuffix?: string @@ -878,6 +906,8 @@ export interface ExportOptions { exportVoices?: boolean exportVideos?: boolean exportEmojis?: boolean + exportFiles?: boolean + maxFileSizeMb?: number exportVoiceAsText?: boolean excelCompactColumns?: boolean txtColumns?: string[] diff --git a/src/types/models.ts b/src/types/models.ts index fccdba4..9d9ea0d 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -75,6 +75,7 @@ export interface Message { fileName?: string // 文件名 fileSize?: number // 文件大小 fileExt?: string // 文件扩展名 + fileMd5?: string // 文件 MD5 xmlType?: string // XML 中的 type 字段 appMsgKind?: string // 归一化 appmsg 类型 appMsgDesc?: string diff --git a/src/utils/reportExport.ts b/src/utils/reportExport.ts new file mode 100644 index 0000000..224b99e --- /dev/null +++ b/src/utils/reportExport.ts @@ -0,0 +1,36 @@ +const PATTERN_LIGHT_SVG = `<svg xmlns='http://www.w3.org/2000/svg' width='400' height='400' viewBox='0 0 400 400'><defs><style>.a{fill:none;stroke:#000;stroke-width:1.2;opacity:0.045}.b{fill:none;stroke:#000;stroke-width:1;opacity:0.035}.c{fill:none;stroke:#000;stroke-width:0.8;opacity:0.04}</style></defs><g transform='translate(45,35) rotate(-8)'><circle class='a' cx='0' cy='0' r='16'/><circle class='a' cx='-5' cy='-4' r='2.5'/><circle class='a' cx='5' cy='-4' r='2.5'/><path class='a' d='M-8 4 Q0 12 8 4'/></g><g transform='translate(320,28) rotate(15) scale(0.7)'><path class='b' d='M0 -12 l3 9 9 0 -7 5 3 9 -8 -6 -8 6 3 -9 -7 -5 9 0z'/></g><g transform='translate(180,55) rotate(12)'><path class='a' d='M0 -8 C0 -14 8 -17 12 -10 C16 -17 24 -14 24 -8 C24 4 12 14 12 14 C12 14 0 4 0 -8'/></g><g transform='translate(95,120) rotate(-5) scale(1.1)'><path class='b' d='M0 10 Q-8 10 -8 3 Q-8 -4 0 -4 Q0 -12 10 -12 Q22 -12 22 -2 Q30 -2 30 5 Q30 12 22 12 Z'/></g><g transform='translate(355,95) rotate(8)'><path class='c' d='M0 0 L0 18 M0 0 L18 -4 L18 14'/><ellipse class='c' cx='-4' cy='20' rx='6' ry='4'/><ellipse class='c' cx='14' cy='16' rx='6' ry='4'/></g><g transform='translate(250,110) rotate(-12) scale(0.9)'><rect class='b' x='0' y='0' width='26' height='18' rx='2'/><path class='b' d='M0 2 L13 11 L26 2'/></g><g transform='translate(28,195) rotate(6)'><circle class='a' cx='0' cy='0' r='11'/><path class='a' d='M-5 11 L5 11 M-4 14 L4 14'/><path class='c' d='M-3 -2 L0 -6 L3 -2'/></g><g transform='translate(155,175) rotate(-3) scale(0.85)'><path class='b' d='M0 0 L0 28 Q14 22 28 28 L28 0 Q14 6 0 0'/><path class='b' d='M28 0 L28 28 Q42 22 56 28 L56 0 Q42 6 28 0'/></g><g transform='translate(340,185) rotate(-20) scale(1.2)'><path class='a' d='M0 8 L20 0 L5 6 L8 14 L5 6 L-12 12 Z'/></g><g transform='translate(70,280) rotate(5)'><rect class='b' x='0' y='5' width='30' height='22' rx='4'/><circle class='b' cx='15' cy='16' r='7'/><rect class='b' x='8' y='0' width='14' height='6' rx='2'/></g><g transform='translate(230,250) rotate(-8) scale(1.1)'><rect class='a' x='0' y='6' width='22' height='18' rx='2'/><rect class='a' x='-3' y='0' width='28' height='7' rx='2'/><path class='a' d='M11 0 L11 24 M-3 13 L25 13'/></g><g transform='translate(365,280) rotate(10)'><ellipse class='b' cx='0' cy='0' rx='10' ry='14'/><path class='b' d='M0 14 Q-3 20 0 28 Q2 24 -1 20'/></g><g transform='translate(145,310) rotate(-6)'><path class='c' d='M0 0 L4 28 L24 28 L28 0 Z'/><path class='c' d='M28 6 Q40 6 40 16 Q40 24 28 24'/><path class='c' d='M8 8 Q10 4 12 8'/></g><g transform='translate(310,340) rotate(5) scale(0.9)'><path class='a' d='M0 8 L8 0 L24 0 L32 8 L16 28 Z'/><path class='a' d='M8 0 L12 8 L0 8 M24 0 L20 8 L32 8 M12 8 L16 28 L20 8'/></g><g transform='translate(55,365) rotate(25) scale(1.15)'><path class='a' d='M8 0 Q12 -14 16 0 L14 6 L18 12 L12 9 L6 12 L10 6 Z'/><circle class='c' cx='12' cy='-2' r='2'/></g><g transform='translate(200,375) rotate(-4)'><path class='b' d='M0 12 Q0 -8 24 -8 Q48 -8 48 12'/><path class='c' d='M6 12 Q6 -2 24 -2 Q42 -2 42 12'/><path class='c' d='M12 12 Q12 4 24 4 Q36 4 36 12'/></g><g transform='translate(380,375) rotate(-10)'><circle class='a' cx='0' cy='0' r='8'/><path class='c' d='M0 -14 L0 -10 M0 10 L0 14 M-14 0 L-10 0 M10 0 L14 0 M-10 -10 L-7 -7 M7 7 L10 10 M-10 10 L-7 7 M7 -7 L10 -10'/></g></svg>` + +const PATTERN_DARK_SVG = `<svg xmlns='http://www.w3.org/2000/svg' width='400' height='400' viewBox='0 0 400 400'><defs><style>.a{fill:none;stroke:#fff;stroke-width:1.2;opacity:0.055}.b{fill:none;stroke:#fff;stroke-width:1;opacity:0.045}.c{fill:none;stroke:#fff;stroke-width:0.8;opacity:0.05}</style></defs><g transform='translate(45,35) rotate(-8)'><circle class='a' cx='0' cy='0' r='16'/><circle class='a' cx='-5' cy='-4' r='2.5'/><circle class='a' cx='5' cy='-4' r='2.5'/><path class='a' d='M-8 4 Q0 12 8 4'/></g><g transform='translate(320,28) rotate(15) scale(0.7)'><path class='b' d='M0 -12 l3 9 9 0 -7 5 3 9 -8 -6 -8 6 3 -9 -7 -5 9 0z'/></g><g transform='translate(180,55) rotate(12)'><path class='a' d='M0 -8 C0 -14 8 -17 12 -10 C16 -17 24 -14 24 -8 C24 4 12 14 12 14 C12 14 0 4 0 -8'/></g><g transform='translate(95,120) rotate(-5) scale(1.1)'><path class='b' d='M0 10 Q-8 10 -8 3 Q-8 -4 0 -4 Q0 -12 10 -12 Q22 -12 22 -2 Q30 -2 30 5 Q30 12 22 12 Z'/></g><g transform='translate(355,95) rotate(8)'><path class='c' d='M0 0 L0 18 M0 0 L18 -4 L18 14'/><ellipse class='c' cx='-4' cy='20' rx='6' ry='4'/><ellipse class='c' cx='14' cy='16' rx='6' ry='4'/></g><g transform='translate(250,110) rotate(-12) scale(0.9)'><rect class='b' x='0' y='0' width='26' height='18' rx='2'/><path class='b' d='M0 2 L13 11 L26 2'/></g><g transform='translate(28,195) rotate(6)'><circle class='a' cx='0' cy='0' r='11'/><path class='a' d='M-5 11 L5 11 M-4 14 L4 14'/><path class='c' d='M-3 -2 L0 -6 L3 -2'/></g><g transform='translate(155,175) rotate(-3) scale(0.85)'><path class='b' d='M0 0 L0 28 Q14 22 28 28 L28 0 Q14 6 0 0'/><path class='b' d='M28 0 L28 28 Q42 22 56 28 L56 0 Q42 6 28 0'/></g><g transform='translate(340,185) rotate(-20) scale(1.2)'><path class='a' d='M0 8 L20 0 L5 6 L8 14 L5 6 L-12 12 Z'/></g><g transform='translate(70,280) rotate(5)'><rect class='b' x='0' y='5' width='30' height='22' rx='4'/><circle class='b' cx='15' cy='16' r='7'/><rect class='b' x='8' y='0' width='14' height='6' rx='2'/></g><g transform='translate(230,250) rotate(-8) scale(1.1)'><rect class='a' x='0' y='6' width='22' height='18' rx='2'/><rect class='a' x='-3' y='0' width='28' height='7' rx='2'/><path class='a' d='M11 0 L11 24 M-3 13 L25 13'/></g><g transform='translate(365,280) rotate(10)'><ellipse class='b' cx='0' cy='0' rx='10' ry='14'/><path class='b' d='M0 14 Q-3 20 0 28 Q2 24 -1 20'/></g><g transform='translate(145,310) rotate(-6)'><path class='c' d='M0 0 L4 28 L24 28 L28 0 Z'/><path class='c' d='M28 6 Q40 6 40 16 Q40 24 28 24'/><path class='c' d='M8 8 Q10 4 12 8'/></g><g transform='translate(310,340) rotate(5) scale(0.9)'><path class='a' d='M0 8 L8 0 L24 0 L32 8 L16 28 Z'/><path class='a' d='M8 0 L12 8 L0 8 M24 0 L20 8 L32 8 M12 8 L16 28 L20 8'/></g><g transform='translate(55,365) rotate(25) scale(1.15)'><path class='a' d='M8 0 Q12 -14 16 0 L14 6 L18 12 L12 9 L6 12 L10 6 Z'/><circle class='c' cx='12' cy='-2' r='2'/></g><g transform='translate(200,375) rotate(-4)'><path class='b' d='M0 12 Q0 -8 24 -8 Q48 -8 48 12'/><path class='c' d='M6 12 Q6 -2 24 -2 Q42 -2 42 12'/><path class='c' d='M12 12 Q12 4 24 4 Q36 4 36 12'/></g><g transform='translate(380,375) rotate(-10)'><circle class='a' cx='0' cy='0' r='8'/><path class='c' d='M0 -14 L0 -10 M0 10 L0 14 M-14 0 L-10 0 M10 0 L14 0 M-10 -10 L-7 -7 M7 7 L10 10 M-10 10 L-7 7 M7 -7 L10 -10'/></g></svg>` + +export const drawPatternBackground = async ( + ctx: CanvasRenderingContext2D, + width: number, + height: number, + bgColor: string, + isDark: boolean +) => { + ctx.fillStyle = bgColor + ctx.fillRect(0, 0, width, height) + + const svgString = isDark ? PATTERN_DARK_SVG : PATTERN_LIGHT_SVG + const blob = new Blob([svgString], { type: 'image/svg+xml' }) + const url = URL.createObjectURL(blob) + + return new Promise<void>((resolve) => { + const img = new window.Image() + img.onload = () => { + const pattern = ctx.createPattern(img, 'repeat') + if (pattern) { + ctx.fillStyle = pattern + ctx.fillRect(0, 0, width, height) + } + URL.revokeObjectURL(url) + resolve() + } + img.onerror = () => { + URL.revokeObjectURL(url) + resolve() + } + img.src = url + }) +} diff --git a/vite.config.ts b/vite.config.ts index d121f1b..04493f7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -18,38 +18,6 @@ export default defineConfig({ chunkSizeWarningLimit: 900, commonjsOptions: { ignoreDynamicRequires: true - }, - rollupOptions: { - output: { - manualChunks(id) { - if (!id.includes('node_modules')) return - - if (id.includes('/react/') || id.includes('/react-dom/') || id.includes('/react-router')) { - return 'vendor-react' - } - - if (id.includes('/echarts') || id.includes('/echarts-for-react')) { - return 'vendor-echarts' - } - - if ( - id.includes('/react-markdown') || - id.includes('/remark-gfm') || - id.includes('/mdast-') || - id.includes('/micromark-') || - id.includes('/unified') || - id.includes('/vfile') - ) { - return 'vendor-markdown' - } - - if (id.includes('/jszip') || id.includes('/exceljs')) { - return 'vendor-export' - } - - return 'vendor-misc' - } - } } }, optimizeDeps: { @@ -204,6 +172,7 @@ export default defineConfig({ renderer() ], resolve: { + dedupe: ['react', 'react-dom'], alias: { '@': resolve(__dirname, 'src') }