diff --git a/.github/workflows/dev-daily-fixed.yml b/.github/workflows/dev-daily-fixed.yml index 6c3c813..428aa14 100644 --- a/.github/workflows/dev-daily-fixed.yml +++ b/.github/workflows/dev-daily-fixed.yml @@ -60,7 +60,23 @@ jobs: 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 + RELEASE_ENDPOINT="repos/$GITHUB_REPOSITORY/releases/tags/$FIXED_DEV_TAG" + settled="false" + for i in 1 2 3 4 5; do + gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$RELEASE_REST_ID" -F draft=false -F prerelease=true >/dev/null 2>&1 || true + DRAFT_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.draft' 2>/dev/null || echo true)" + PRERELEASE_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.prerelease' 2>/dev/null || echo false)" + if [ "$DRAFT_STATE" = "false" ] && [ "$PRERELEASE_STATE" = "true" ]; then + settled="true" + break + fi + sleep 2 + done + if [ "$settled" != "true" ]; then + echo "Failed to settle release state after create:" + gh api "$RELEASE_ENDPOINT" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}' + exit 1 + fi dev-mac-arm64: needs: prepare @@ -81,6 +97,22 @@ jobs: - name: Install Dependencies run: npm install + - name: Ensure mac key helpers are executable + shell: bash + run: | + set -euo pipefail + for file in \ + resources/key/macos/universal/xkey_helper \ + resources/key/macos/universal/image_scan_helper \ + resources/key/macos/universal/xkey_helper_macos \ + resources/key/macos/universal/libwx_key.dylib + do + if [ -f "$file" ]; then + chmod +x "$file" + ls -l "$file" + fi + done + - name: Set dev version shell: bash run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version @@ -270,21 +302,25 @@ jobs: - name: Update fixed dev release notes env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - FIXED_DEV_TAG: ${{ env.FIXED_DEV_TAG }} shell: bash run: | set -euo pipefail - TAG="$FIXED_DEV_TAG" + TAG="${FIXED_DEV_TAG:-}" + if [ -z "$TAG" ]; then + echo "FIXED_DEV_TAG is empty, abort." + exit 1 + fi REPO="$GITHUB_REPOSITORY" RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG" + echo "Using release tag: $TAG" - if ! gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then + if ! gh api "repos/$REPO/releases/tags/$TAG" >/dev/null 2>&1; then echo "Release $TAG not found, skip notes update." exit 0 fi - ASSETS_JSON="$(gh release view "$TAG" --repo "$REPO" --json assets)" + ASSETS_JSON="$(gh api "repos/$REPO/releases/tags/$TAG")" pick_asset() { local pattern="$1" @@ -350,4 +386,22 @@ jobs: } update_release_notes - gh release view "$TAG" --repo "$REPO" --json isDraft,isPrerelease,url + RELEASE_REST_ID="$(gh api "repos/$REPO/releases/tags/$TAG" --jq '.id')" + RELEASE_ENDPOINT="repos/$REPO/releases/tags/$TAG" + settled="false" + for i in 1 2 3 4 5; do + gh api --method PATCH "repos/$REPO/releases/$RELEASE_REST_ID" -F draft=false -F prerelease=true >/dev/null 2>&1 || true + DRAFT_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.draft' 2>/dev/null || echo true)" + PRERELEASE_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.prerelease' 2>/dev/null || echo false)" + if [ "$DRAFT_STATE" = "false" ] && [ "$PRERELEASE_STATE" = "true" ]; then + settled="true" + break + fi + sleep 2 + done + if [ "$settled" != "true" ]; then + echo "Failed to settle release state after notes update:" + gh api "$RELEASE_ENDPOINT" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}' + exit 1 + fi + gh api "repos/$REPO/releases/tags/$TAG" --jq '{isDraft: .draft, isPrerelease: .prerelease, url: .html_url}' diff --git a/.github/workflows/preview-nightly-main.yml b/.github/workflows/preview-nightly-main.yml index a6c7b56..52aa2d4 100644 --- a/.github/workflows/preview-nightly-main.yml +++ b/.github/workflows/preview-nightly-main.yml @@ -86,7 +86,23 @@ jobs: 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 + RELEASE_ENDPOINT="repos/$GITHUB_REPOSITORY/releases/tags/$FIXED_PREVIEW_TAG" + settled="false" + for i in 1 2 3 4 5; do + gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$RELEASE_REST_ID" -F draft=false -F prerelease=true >/dev/null 2>&1 || true + DRAFT_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.draft' 2>/dev/null || echo true)" + PRERELEASE_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.prerelease' 2>/dev/null || echo false)" + if [ "$DRAFT_STATE" = "false" ] && [ "$PRERELEASE_STATE" = "true" ]; then + settled="true" + break + fi + sleep 2 + done + if [ "$settled" != "true" ]; then + echo "Failed to settle release state after create:" + gh api "$RELEASE_ENDPOINT" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}' + exit 1 + fi preview-mac-arm64: needs: prepare @@ -108,6 +124,22 @@ jobs: - name: Install Dependencies run: npm install + - name: Ensure mac key helpers are executable + shell: bash + run: | + set -euo pipefail + for file in \ + resources/key/macos/universal/xkey_helper \ + resources/key/macos/universal/image_scan_helper \ + resources/key/macos/universal/xkey_helper_macos \ + resources/key/macos/universal/libwx_key.dylib + do + if [ -f "$file" ]; then + chmod +x "$file" + ls -l "$file" + fi + done + - name: Set preview version shell: bash run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version @@ -315,17 +347,22 @@ jobs: run: | set -euo pipefail - TAG="$FIXED_PREVIEW_TAG" + TAG="${FIXED_PREVIEW_TAG:-}" + if [ -z "$TAG" ]; then + echo "FIXED_PREVIEW_TAG is empty, abort." + exit 1 + fi CURRENT_PREVIEW_VERSION="${{ needs.prepare.outputs.preview_version }}" REPO="$GITHUB_REPOSITORY" RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG" + echo "Using release tag: $TAG" - if ! gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then + if ! gh api "repos/$REPO/releases/tags/$TAG" >/dev/null 2>&1; then echo "Release $TAG not found (possibly all publish jobs failed), skip notes update." exit 0 fi - ASSETS_JSON="$(gh release view "$TAG" --repo "$REPO" --json assets)" + ASSETS_JSON="$(gh api "repos/$REPO/releases/tags/$TAG")" pick_asset() { local pattern="$1" @@ -392,4 +429,22 @@ jobs: } update_release_notes - gh release view "$TAG" --repo "$REPO" --json isDraft,isPrerelease,url + RELEASE_REST_ID="$(gh api "repos/$REPO/releases/tags/$TAG" --jq '.id')" + RELEASE_ENDPOINT="repos/$REPO/releases/tags/$TAG" + settled="false" + for i in 1 2 3 4 5; do + gh api --method PATCH "repos/$REPO/releases/$RELEASE_REST_ID" -F draft=false -F prerelease=true >/dev/null 2>&1 || true + DRAFT_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.draft' 2>/dev/null || echo true)" + PRERELEASE_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.prerelease' 2>/dev/null || echo false)" + if [ "$DRAFT_STATE" = "false" ] && [ "$PRERELEASE_STATE" = "true" ]; then + settled="true" + break + fi + sleep 2 + done + if [ "$settled" != "true" ]; then + echo "Failed to settle release state after notes update:" + gh api "$RELEASE_ENDPOINT" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}' + exit 1 + fi + gh api "repos/$REPO/releases/tags/$TAG" --jq '{isDraft: .draft, isPrerelease: .prerelease, url: .html_url}' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ed89fb5..02f3255 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,7 +36,7 @@ jobs: run: | VERSION=${GITHUB_REF_NAME#v} echo "Syncing package.json version to $VERSION" - npm version $VERSION --no-git-tag-version --allow-same-version + node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json','utf8'));p.version='$VERSION';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')" - name: Build Frontend & Type Check shell: bash @@ -88,12 +88,17 @@ jobs: - name: Install Dependencies run: npm install + - name: Ensure linux key helper is executable + shell: bash + run: | + [ -f "resources/key/linux/x64/xkey_helper" ] && chmod +x "resources/key/linux/x64/xkey_helper" || echo "File not found" + - name: Sync version with tag shell: bash run: | VERSION=${GITHUB_REF_NAME#v} echo "Syncing package.json version to $VERSION" - npm version $VERSION --no-git-tag-version --allow-same-version + node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json','utf8'));p.version='$VERSION';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')" - name: Build Frontend & Type Check shell: bash @@ -115,7 +120,7 @@ jobs: TAG=${GITHUB_REF_NAME} REPO=${{ github.repository }} MINIMUM_VERSION="4.1.7" - gh release download "$TAG" --repo "$REPO" --pattern "latest-linux.yml" --output "/tmp/latest-linux.yml" 2>/dev/null + gh release download "$TAG" --repo "$REPO" --pattern "latest-linux.yml" --output "/tmp/latest-linux.yml" 2>/dev/null || true if [ -f /tmp/latest-linux.yml ] && ! grep -q 'minimumVersion' /tmp/latest-linux.yml; then echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest-linux.yml gh release upload "$TAG" --repo "$REPO" /tmp/latest-linux.yml --clobber @@ -144,7 +149,7 @@ jobs: run: | VERSION=${GITHUB_REF_NAME#v} echo "Syncing package.json version to $VERSION" - npm version $VERSION --no-git-tag-version --allow-same-version + node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json','utf8'));p.version='$VERSION';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')" - name: Build Frontend & Type Check shell: bash @@ -166,7 +171,7 @@ jobs: TAG=${GITHUB_REF_NAME} REPO=${{ github.repository }} MINIMUM_VERSION="4.1.7" - gh release download "$TAG" --repo "$REPO" --pattern "latest.yml" --output "/tmp/latest.yml" 2>/dev/null + gh release download "$TAG" --repo "$REPO" --pattern "latest.yml" --output "/tmp/latest.yml" 2>/dev/null || true if [ -f /tmp/latest.yml ] && ! grep -q 'minimumVersion' /tmp/latest.yml; then echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest.yml gh release upload "$TAG" --repo "$REPO" /tmp/latest.yml --clobber @@ -195,7 +200,7 @@ jobs: run: | VERSION=${GITHUB_REF_NAME#v} echo "Syncing package.json version to $VERSION" - npm version $VERSION --no-git-tag-version --allow-same-version + node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json','utf8'));p.version='$VERSION';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')" - name: Build Frontend & Type Check shell: bash @@ -217,7 +222,7 @@ jobs: TAG=${GITHUB_REF_NAME} REPO=${{ github.repository }} MINIMUM_VERSION="4.1.7" - gh release download "$TAG" --repo "$REPO" --pattern "latest-arm64.yml" --output "/tmp/latest-arm64.yml" 2>/dev/null + gh release download "$TAG" --repo "$REPO" --pattern "latest-arm64.yml" --output "/tmp/latest-arm64.yml" 2>/dev/null || true if [ -f /tmp/latest-arm64.yml ] && ! grep -q 'minimumVersion' /tmp/latest-arm64.yml; then echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest-arm64.yml gh release upload "$TAG" --repo "$REPO" /tmp/latest-arm64.yml --clobber @@ -295,3 +300,22 @@ jobs: EOF gh release edit "$TAG" --repo "$REPO" --notes-file release_notes.md + + deploy-aur: + runs-on: ubuntu-latest + needs: [release-linux] + if: startsWith(github.ref, 'refs/tags/v') + steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Publish AUR package + uses: KSXGitHub/github-actions-deploy-aur@master + with: + pkgname: weflow + ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} + commit_username: H3CoF6 + commit_email: h3cof6@gmail.com + ssh_keyscan_types: ed25519 diff --git a/README.md b/README.md index 01e7beb..bec1328 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,23 @@ # WeFlow -WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析与导出工具。它可以实时获取你的微信聊天记录并将其导出,还可以根据你的聊天记录为你生成独一无二的分析报告 - ---- +WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析与导出工具。它可以实时获取你的微信聊天记录并将其导出,还可以根据你的聊天记录为你生成独一无二的分析报告。

- WeFlow + WeFlow 应用预览

---- -

- -Stargazers - - -Forks - - -Issues - - -Downloads - - -Telegram - + + Stargazers + Forks + Issues + Downloads +

+ + Telegram Channel + Star History Rank

- > [!TIP] > 如果导出聊天记录后,想深入分析聊天内容可以试试 [ChatLab](https://chatlab.fun/) @@ -47,14 +36,12 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析 ## 支持平台与设备 - | 平台 | 设备/架构 | 安装包 | |------|----------|--------| | Windows | Windows10+、x64(amd64) | `.exe` | | macOS | Apple Silicon(M 系列,arm64) | `.dmg` | | Linux | x64 设备(amd64) | `.AppImage`、`.tar.gz` | - ## 快速开始 若你只想使用成品版本,可前往 [Releases](https://github.com/hicccc77/WeFlow/releases) 下载并安装。 @@ -93,7 +80,6 @@ WeFlow 提供本地 HTTP API 服务,支持通过接口查询消息数据,可 完整接口文档:[点击查看](docs/HTTP-API.md) - ## 面向开发者 如果你想从源码构建或为项目贡献代码,请遵循以下步骤: @@ -108,7 +94,6 @@ npm install # 3. 运行应用(开发模式) npm run dev - ``` ## 致谢 @@ -120,18 +105,16 @@ npm run dev 如果 WeFlow 确实帮到了你,可以考虑请我们喝杯咖啡: - -> TRC20 **Address:** `TZCtAw8CaeARWZBfvjidCnTcfnAtf6nvS6` - +> TRC20 **Address:** `TZCtAw8CaeARWZBfvjidCnTcfnAtf6nvS6` ## Star History - - - - Star History Chart - + + + + Star History Chart +
diff --git a/electron/main.ts b/electron/main.ts index a3c0cf0..2794d19 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -950,8 +950,17 @@ function closeSplash() { /** * 创建首次引导窗口 */ -function createOnboardingWindow() { +function createOnboardingWindow(mode: 'default' | 'add-account' = 'default') { + const onboardingHash = mode === 'add-account' + ? '/onboarding-window?mode=add-account' + : '/onboarding-window' + if (onboardingWindow && !onboardingWindow.isDestroyed()) { + if (process.env.VITE_DEV_SERVER_URL) { + onboardingWindow.loadURL(`${process.env.VITE_DEV_SERVER_URL}#${onboardingHash}`) + } else { + onboardingWindow.loadFile(join(__dirname, '../dist/index.html'), { hash: onboardingHash }) + } onboardingWindow.focus() return onboardingWindow } @@ -987,9 +996,9 @@ function createOnboardingWindow() { }) if (process.env.VITE_DEV_SERVER_URL) { - onboardingWindow.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/onboarding-window`) + onboardingWindow.loadURL(`${process.env.VITE_DEV_SERVER_URL}#${onboardingHash}`) } else { - onboardingWindow.loadFile(join(__dirname, '../dist/index.html'), { hash: '/onboarding-window' }) + onboardingWindow.loadFile(join(__dirname, '../dist/index.html'), { hash: onboardingHash }) } onboardingWindow.on('closed', () => { @@ -1635,6 +1644,22 @@ function registerIpcHandlers() { return insightService.triggerTest() }) + ipcMain.handle('insight:generateFootprintInsight', async (_, payload: { + rangeLabel: string + summary: { + private_inbound_people?: number + private_replied_people?: number + private_outbound_people?: number + private_reply_rate?: number + mention_count?: number + mention_group_count?: number + } + privateSegments?: Array<{ displayName?: string; session_id?: string; incoming_count?: number; outgoing_count?: number; message_count?: number; replied?: boolean }> + mentionGroups?: Array<{ displayName?: string; session_id?: string; count?: number }> + }) => { + return insightService.generateFootprintInsight(payload) + }) + ipcMain.handle('config:clear', async () => { if (isLaunchAtStartupSupported() && getSystemLaunchAtStartup()) { const result = setSystemLaunchAtStartup(false) @@ -2244,6 +2269,39 @@ function registerIpcHandlers() { const defaultValue = key === 'lastSession' ? '' : {} cfg.set(key as any, defaultValue as any) } + + try { + const dbPath = String(cfg.get('dbPath') || '').trim() + const automationMapRaw = cfg.get('exportAutomationTaskMap') as Record | undefined + if (automationMapRaw && typeof automationMapRaw === 'object') { + const nextAutomationMap: Record = { ...automationMapRaw } + let changed = false + for (const scopeKey of Object.keys(automationMapRaw)) { + const normalizedScopeKey = String(scopeKey || '').trim() + if (!normalizedScopeKey) continue + const separatorIndex = normalizedScopeKey.lastIndexOf('::') + const scopedDbPath = separatorIndex >= 0 + ? normalizedScopeKey.slice(0, separatorIndex) + : '' + const scopedWxidRaw = separatorIndex >= 0 + ? normalizedScopeKey.slice(separatorIndex + 2) + : normalizedScopeKey + const scopedWxid = normalizeAccountId(scopedWxidRaw) + const wxidMatched = wxidCandidates.includes(scopedWxidRaw) || scopedWxid === normalizedWxid + const dbPathMatched = !dbPath || !scopedDbPath || scopedDbPath === dbPath + if (!wxidMatched || !dbPathMatched) continue + delete nextAutomationMap[scopeKey] + changed = true + } + if (changed) { + cfg.set('exportAutomationTaskMap' as any, nextAutomationMap as any) + } else if (!Object.keys(automationMapRaw).length) { + cfg.set('exportAutomationTaskMap' as any, {} as any) + } + } + } catch (error) { + warnings.push(`清理自动化导出任务失败: ${String(error)}`) + } } if (clearCache) { @@ -2363,6 +2421,21 @@ function registerIpcHandlers() { return chatService.searchMessages(keyword, sessionId, limit, offset, beginTimestamp, endTimestamp) }) + ipcMain.handle('chat:getMyFootprintStats', async (_, beginTimestamp: number, endTimestamp: number, options?: { + myWxid?: string + privateSessionIds?: string[] + groupSessionIds?: string[] + mentionLimit?: number + privateLimit?: number + mentionMode?: 'text_at_me' | string + }) => { + return chatService.getMyFootprintStats(beginTimestamp, endTimestamp, options) + }) + + ipcMain.handle('chat:exportMyFootprint', async (_, beginTimestamp: number, endTimestamp: number, format: 'csv' | 'json', filePath: string) => { + return chatService.exportMyFootprint(beginTimestamp, endTimestamp, format, filePath) + }) + ipcMain.handle('sns:getTimeline', async (_, limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => { return snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime) }) @@ -2988,12 +3061,13 @@ function registerIpcHandlers() { }) // 重新打开首次引导窗口,并隐藏主窗口 - ipcMain.handle('window:openOnboardingWindow', async () => { + ipcMain.handle('window:openOnboardingWindow', async (_, options?: { mode?: 'add-account' }) => { shouldShowMain = false if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.hide() } - createOnboardingWindow() + const mode = options?.mode === 'add-account' ? 'add-account' : 'default' + createOnboardingWindow(mode) return true }) @@ -3455,12 +3529,38 @@ app.whenReady().then(async () => { } const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + const withTimeout = (task: () => Promise, timeoutMs: number): Promise<{ timedOut: boolean; value?: T; error?: string }> => { + return new Promise((resolve) => { + let settled = false + const timer = setTimeout(() => { + if (settled) return + settled = true + resolve({ timedOut: true, error: `timeout(${timeoutMs}ms)` }) + }, timeoutMs) + + task() + .then((value) => { + if (settled) return + settled = true + clearTimeout(timer) + resolve({ timedOut: false, value }) + }) + .catch((error) => { + if (settled) return + settled = true + clearTimeout(timer) + resolve({ timedOut: false, error: String(error) }) + }) + }) + } // 初始化配置服务 updateSplashProgress(5, '正在加载配置...') configService = new ConfigService() applyAutoUpdateChannel('startup') syncLaunchAtStartupPreference() + const onboardingDone = configService.get('onboardingDone') === true + shouldShowMain = onboardingDone // 将用户主题配置推送给 Splash 窗口 if (splashWindow && !splashWindow.isDestroyed()) { @@ -3473,7 +3573,7 @@ app.whenReady().then(async () => { await delay(200) // 设置资源路径 - updateSplashProgress(10, '正在初始化...') + updateSplashProgress(12, '正在初始化...') const candidateResources = app.isPackaged ? join(process.resourcesPath, 'resources') : join(app.getAppPath(), 'resources') @@ -3483,13 +3583,13 @@ app.whenReady().then(async () => { await delay(200) // 初始化数据库服务 - updateSplashProgress(18, '正在初始化...') + updateSplashProgress(20, '正在初始化...') wcdbService.setPaths(resourcesPath, userDataPath) wcdbService.setLogEnabled(configService.get('logEnabled') === true) await delay(200) // 注册 IPC 处理器 - updateSplashProgress(25, '正在初始化...') + updateSplashProgress(28, '正在初始化...') registerIpcHandlers() chatService.addDbMonitorListener((type, json) => { messagePushService.handleDbMonitorChange(type, json) @@ -3499,12 +3599,54 @@ app.whenReady().then(async () => { insightService.start() await delay(200) - // 检查配置状态 - const onboardingDone = configService.get('onboardingDone') - shouldShowMain = onboardingDone === true + // 已完成引导时,在 Splash 阶段预热核心数据(联系人、消息库索引等) + if (onboardingDone) { + updateSplashProgress(34, '正在连接数据库...') + const connectWarmup = await withTimeout(() => chatService.connect(), 12000) + const connected = !connectWarmup.timedOut && connectWarmup.value?.success === true + + if (!connected) { + const reason = connectWarmup.timedOut + ? connectWarmup.error + : (connectWarmup.value?.error || connectWarmup.error || 'unknown') + console.warn('[StartupWarmup] 跳过预热,数据库连接失败:', reason) + updateSplashProgress(68, '数据库预热已跳过') + } else { + const preloadUsernames = new Set() + + updateSplashProgress(44, '正在预加载会话...') + const sessionsWarmup = await withTimeout(() => chatService.getSessions(), 12000) + if (!sessionsWarmup.timedOut && sessionsWarmup.value?.success && Array.isArray(sessionsWarmup.value.sessions)) { + for (const session of sessionsWarmup.value.sessions) { + const username = String((session as any)?.username || '').trim() + if (username) preloadUsernames.add(username) + } + } + + updateSplashProgress(56, '正在预加载联系人...') + const contactsWarmup = await withTimeout(() => chatService.getContacts(), 15000) + if (!contactsWarmup.timedOut && contactsWarmup.value?.success && Array.isArray(contactsWarmup.value.contacts)) { + for (const contact of contactsWarmup.value.contacts) { + const username = String((contact as any)?.username || '').trim() + if (username) preloadUsernames.add(username) + } + } + + updateSplashProgress(63, '正在缓存联系人头像...') + const avatarWarmupUsernames = Array.from(preloadUsernames).slice(0, 2000) + if (avatarWarmupUsernames.length > 0) { + await withTimeout(() => chatService.enrichSessionsContactInfo(avatarWarmupUsernames), 15000) + } + + updateSplashProgress(68, '正在初始化消息库索引...') + await withTimeout(() => chatService.warmupMessageDbSnapshot(), 10000) + } + } else { + updateSplashProgress(68, '首次启动准备中...') + } // 创建主窗口(不显示,由启动流程统一控制) - updateSplashProgress(30, '正在加载界面...') + updateSplashProgress(70, '正在准备主窗口...') mainWindow = createWindow({ autoShow: false }) let iconName = 'icon.ico'; @@ -3576,7 +3718,7 @@ app.whenReady().then(async () => { ) // 等待主窗口加载完成(真正耗时阶段,进度条末端呼吸光点) - updateSplashProgress(30, '正在加载界面...', true) + updateSplashProgress(70, '正在准备主窗口...', true) await new Promise((resolve) => { if (mainWindowReady) { resolve() diff --git a/electron/preload.ts b/electron/preload.ts index 48564f1..9739332 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -110,7 +110,7 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('window:respondCloseConfirm', action), openAgreementWindow: () => ipcRenderer.invoke('window:openAgreementWindow'), completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'), - openOnboardingWindow: () => ipcRenderer.invoke('window:openOnboardingWindow'), + openOnboardingWindow: (options?: { mode?: 'add-account' }) => ipcRenderer.invoke('window:openOnboardingWindow', options), setTitleBarOverlay: (options: { symbolColor: string }) => ipcRenderer.send('window:setTitleBarOverlay', options), openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) => ipcRenderer.invoke('window:openVideoPlayerWindow', videoPath, videoWidth, videoHeight), @@ -258,6 +258,24 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('chat:getMessage', sessionId, localId), searchMessages: (keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number) => ipcRenderer.invoke('chat:searchMessages', keyword, sessionId, limit, offset, beginTimestamp, endTimestamp), + getMyFootprintStats: ( + beginTimestamp: number, + endTimestamp: number, + options?: { + myWxid?: string + privateSessionIds?: string[] + groupSessionIds?: string[] + mentionLimit?: number + privateLimit?: number + mentionMode?: 'text_at_me' | string + } + ) => ipcRenderer.invoke('chat:getMyFootprintStats', beginTimestamp, endTimestamp, options), + exportMyFootprint: ( + beginTimestamp: number, + endTimestamp: number, + format: 'csv' | 'json', + filePath: string + ) => ipcRenderer.invoke('chat:exportMyFootprint', beginTimestamp, endTimestamp, format, filePath), onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => { ipcRenderer.on('wcdb-change', callback) return () => ipcRenderer.removeListener('wcdb-change', callback) @@ -508,6 +526,19 @@ contextBridge.exposeInMainWorld('electronAPI', { insight: { testConnection: () => ipcRenderer.invoke('insight:testConnection'), getTodayStats: () => ipcRenderer.invoke('insight:getTodayStats'), - triggerTest: () => ipcRenderer.invoke('insight:triggerTest') + triggerTest: () => ipcRenderer.invoke('insight:triggerTest'), + generateFootprintInsight: (payload: { + rangeLabel: string + summary: { + private_inbound_people?: number + private_replied_people?: number + private_outbound_people?: number + private_reply_rate?: number + mention_count?: number + mention_group_count?: number + } + privateSegments?: Array<{ displayName?: string; session_id?: string; incoming_count?: number; outgoing_count?: number; message_count?: number; replied?: boolean }> + mentionGroups?: Array<{ displayName?: string; session_id?: string; count?: number }> + }) => ipcRenderer.invoke('insight:generateFootprintInsight', payload) } }) diff --git a/electron/services/bizService.ts b/electron/services/bizService.ts index f7c0eed..a5bb984 100644 --- a/electron/services/bizService.ts +++ b/electron/services/bizService.ts @@ -13,6 +13,7 @@ export interface BizAccount { type: number last_time: number formatted_last_time: string + unread_count?: number } export interface BizMessage { @@ -104,19 +105,24 @@ export class BizService { if (!root || !accountWxid) return [] const bizLatestTime: Record = {} + const bizUnreadCount: Record = {} try { - const sessionsRes = await wcdbService.getSessions() + const sessionsRes = await chatService.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 timeStr = session.lastTimestamp || session.sortTimestamp || 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 } + if (usernames.includes(uname)) { + const unread = Number(session.unreadCount ?? session.unread_count ?? 0) + bizUnreadCount[uname] = Number.isFinite(unread) ? Math.max(0, Math.floor(unread)) : 0 + } } } } catch (e) { @@ -152,7 +158,8 @@ export class BizService { avatar: info?.avatarUrl || '', type: 0, last_time: lastTime, - formatted_last_time: formatBizTime(lastTime) + formatted_last_time: formatBizTime(lastTime), + unread_count: bizUnreadCount[uname] || 0 } }) diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 9cf81b6..e6da68f 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -232,6 +232,109 @@ interface SessionDetailExtra { type SessionDetail = SessionDetailFast & SessionDetailExtra +interface SyntheticUnreadState { + readTimestamp: number + scannedTimestamp: number + latestTimestamp: number + unreadCount: number + summaryTimestamp?: number + summary?: string + lastMsgType?: number +} + +interface MyFootprintSummary { + private_inbound_people: number + private_replied_people: number + private_outbound_people: number + private_reply_rate: number + mention_count: number + mention_group_count: number +} + +interface MyFootprintPrivateSession { + session_id: string + incoming_count: number + outgoing_count: number + replied: boolean + first_incoming_ts: number + first_reply_ts: number + latest_ts: number + anchor_local_id: number + anchor_create_time: number + displayName?: string + avatarUrl?: string +} + +interface MyFootprintPrivateSegment { + session_id: string + segment_index: number + start_ts: number + end_ts: number + duration_sec: number + incoming_count: number + outgoing_count: number + message_count: number + replied: boolean + first_incoming_ts: number + first_reply_ts: number + latest_ts: number + anchor_local_id: number + anchor_create_time: number + displayName?: string + avatarUrl?: string +} + +interface MyFootprintMentionItem { + session_id: string + local_id: number + create_time: number + sender_username: string + message_content: string + source: string + sessionDisplayName?: string + senderDisplayName?: string + senderAvatarUrl?: string +} + +interface MyFootprintMentionGroup { + session_id: string + count: number + latest_ts: number + displayName?: string + avatarUrl?: string +} + +interface MyFootprintDiagnostics { + truncated: boolean + scanned_dbs: number + elapsed_ms: number + mention_truncated?: boolean + private_truncated?: boolean + native_ms?: number + source_filter_ms?: number + fallback_ms?: number + enrich_ms?: number + pipeline_ms?: number + fallback_used?: boolean + private_limit_effective?: number + mention_candidate_limit?: number + native_mention_candidates?: number + source_filtered_mentions?: number + private_session_count?: number + group_session_count?: number + native_passes?: number + native_group_chunks?: number +} + +interface MyFootprintData { + summary: MyFootprintSummary + private_sessions: MyFootprintPrivateSession[] + private_segments: MyFootprintPrivateSegment[] + mentions: MyFootprintMentionItem[] + mention_groups: MyFootprintMentionGroup[] + diagnostics: MyFootprintDiagnostics +} + // 表情包缓存 const emojiCache: Map = new Map() const emojiDownloading: Map> = new Map() @@ -285,6 +388,7 @@ class ChatService { private readonly messageDbCountSnapshotCacheTtlMs = 8000 private sessionMessageCountCache = new Map() private sessionMessageCountHintCache = new Map() + private syntheticUnreadState = new Map() private sessionMessageCountBatchCache: { dbSignature: string sessionIdsKey: string @@ -323,6 +427,8 @@ class ChatService { private contactLabelNameMapCacheAt = 0 private readonly contactLabelNameMapCacheTtlMs = 10 * 60 * 1000 private contactsLoadInFlight: { mode: 'lite' | 'full'; promise: Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> } | null = null + private contactsMemoryCache = new Map<'lite' | 'full', { scope: string; updatedAt: number; contacts: ContactInfo[] }>() + private readonly contactsMemoryCacheTtlMs = 3 * 60 * 1000 private readonly contactDisplayNameCollator = new Intl.Collator('zh-CN') private readonly slowGetContactsLogThresholdMs = 1200 @@ -513,6 +619,43 @@ class ChatService { } } + async warmupMessageDbSnapshot(): Promise<{ success: boolean; messageDbCount?: number; mediaDbCount?: number; error?: string }> { + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) { + return { success: false, error: connectResult.error || '数据库未连接' } + } + + const [messageSnapshot, mediaResult] = await Promise.all([ + this.getMessageDbCountSnapshot(true), + wcdbService.listMediaDbs() + ]) + + let messageDbCount = 0 + if (messageSnapshot.success && Array.isArray(messageSnapshot.dbPaths)) { + messageDbCount = messageSnapshot.dbPaths.length + } + + let mediaDbCount = 0 + if (mediaResult.success && Array.isArray(mediaResult.data)) { + this.mediaDbsCache = [...mediaResult.data] + this.mediaDbsCacheTime = Date.now() + mediaDbCount = mediaResult.data.length + } + + if (!messageSnapshot.success && !mediaResult.success) { + return { + success: false, + error: messageSnapshot.error || mediaResult.error || '初始化消息库索引失败' + } + } + + return { success: true, messageDbCount, mediaDbCount } + } catch (e) { + return { success: false, error: String(e) } + } + } + private async ensureConnected(): Promise<{ success: boolean; error?: string }> { if (this.connected && wcdbService.isReady()) { return { success: true } @@ -733,6 +876,10 @@ class ChatService { } } + await this.addMissingOfficialSessions(sessions, myWxid) + await this.applySyntheticUnreadCounts(sessions) + sessions.sort((a, b) => Number(b.sortTimestamp || b.lastTimestamp || 0) - Number(a.sortTimestamp || a.lastTimestamp || 0)) + // 不等待联系人信息加载,直接返回基础会话列表 // 前端可以异步调用 enrichSessionsWithContacts 来补充信息 return { success: true, sessions } @@ -742,6 +889,242 @@ class ChatService { } } + private async addMissingOfficialSessions(sessions: ChatSession[], myWxid?: string): Promise { + const existing = new Set(sessions.map((session) => String(session.username || '').trim()).filter(Boolean)) + try { + const contactResult = await wcdbService.getContactsCompact() + if (!contactResult.success || !Array.isArray(contactResult.contacts)) return + + for (const row of contactResult.contacts as Record[]) { + const username = String(row.username || '').trim() + if (!username.startsWith('gh_') || existing.has(username)) continue + + sessions.push({ + username, + type: 0, + unreadCount: 0, + summary: '查看公众号历史消息', + sortTimestamp: 0, + lastTimestamp: 0, + lastMsgType: 0, + displayName: row.remark || row.nick_name || row.alias || username, + avatarUrl: undefined, + selfWxid: myWxid + }) + existing.add(username) + } + } catch (error) { + console.warn('[ChatService] 补充公众号会话失败:', error) + } + } + + private shouldUseSyntheticUnread(sessionId: string): boolean { + const normalized = String(sessionId || '').trim() + return normalized.startsWith('gh_') + } + + private async getSessionMessageStatsSnapshot(sessionId: string): Promise<{ total: number; latestTimestamp: number }> { + const tableStatsResult = await wcdbService.getMessageTableStats(sessionId) + if (!tableStatsResult.success || !Array.isArray(tableStatsResult.tables)) { + return { total: 0, latestTimestamp: 0 } + } + + let total = 0 + let latestTimestamp = 0 + for (const row of tableStatsResult.tables as Record[]) { + const count = Number(row.count ?? row.message_count ?? row.messageCount ?? 0) + if (Number.isFinite(count) && count > 0) { + total += Math.floor(count) + } + + const latest = Number( + row.last_timestamp ?? + row.lastTimestamp ?? + row.last_time ?? + row.lastTime ?? + row.max_create_time ?? + row.maxCreateTime ?? + 0 + ) + if (Number.isFinite(latest) && latest > latestTimestamp) { + latestTimestamp = Math.floor(latest) + } + } + + return { total, latestTimestamp } + } + + private async applySyntheticUnreadCounts(sessions: ChatSession[]): Promise { + const candidates = sessions.filter((session) => this.shouldUseSyntheticUnread(session.username)) + if (candidates.length === 0) return + + for (const session of candidates) { + try { + const snapshot = await this.getSessionMessageStatsSnapshot(session.username) + const latestTimestamp = Math.max( + Number(session.lastTimestamp || 0), + Number(session.sortTimestamp || 0), + snapshot.latestTimestamp + ) + if (latestTimestamp > 0) { + session.lastTimestamp = latestTimestamp + session.sortTimestamp = Math.max(Number(session.sortTimestamp || 0), latestTimestamp) + } + if (snapshot.total > 0) { + session.messageCountHint = Math.max(Number(session.messageCountHint || 0), snapshot.total) + this.sessionMessageCountHintCache.set(session.username, session.messageCountHint) + } + + let state = this.syntheticUnreadState.get(session.username) + if (!state) { + const initialUnread = await this.getInitialSyntheticUnreadState(session.username, latestTimestamp) + state = { + readTimestamp: latestTimestamp, + scannedTimestamp: latestTimestamp, + latestTimestamp, + unreadCount: initialUnread.count + } + if (initialUnread.latestMessage) { + state.summary = this.getSessionSummaryFromMessage(initialUnread.latestMessage) + state.summaryTimestamp = Number(initialUnread.latestMessage.createTime || latestTimestamp) + state.lastMsgType = Number(initialUnread.latestMessage.localType || 0) + } + this.syntheticUnreadState.set(session.username, state) + } + + let latestMessageForSummary: Message | undefined + if (latestTimestamp > state.scannedTimestamp) { + const newMessagesResult = await this.getNewMessages( + session.username, + Math.max(0, state.scannedTimestamp), + 1000 + ) + if (newMessagesResult.success && Array.isArray(newMessagesResult.messages)) { + let nextUnread = state.unreadCount + let nextScannedTimestamp = state.scannedTimestamp + for (const message of newMessagesResult.messages) { + const createTime = Number(message.createTime || 0) + if (!Number.isFinite(createTime) || createTime <= state.scannedTimestamp) continue + if (message.isSend === 1) continue + nextUnread += 1 + latestMessageForSummary = message + if (createTime > nextScannedTimestamp) { + nextScannedTimestamp = Math.floor(createTime) + } + } + state.unreadCount = nextUnread + state.scannedTimestamp = Math.max(nextScannedTimestamp, latestTimestamp) + } else { + state.scannedTimestamp = latestTimestamp + } + } + + state.latestTimestamp = Math.max(state.latestTimestamp, latestTimestamp) + if (latestMessageForSummary) { + const summary = this.getSessionSummaryFromMessage(latestMessageForSummary) + if (summary) { + state.summary = summary + state.summaryTimestamp = Number(latestMessageForSummary.createTime || latestTimestamp) + state.lastMsgType = Number(latestMessageForSummary.localType || 0) + } + } + if (state.summary) { + session.summary = state.summary + session.lastMsgType = Number(state.lastMsgType || session.lastMsgType || 0) + } + session.unreadCount = Math.max(Number(session.unreadCount || 0), state.unreadCount) + } catch (error) { + console.warn(`[ChatService] 合成公众号未读失败: ${session.username}`, error) + } + } + } + + private getSessionSummaryFromMessage(message: Message): string { + const cleanOfficialPrefix = (value: string): string => value.replace(/^\s*\[视频号\]\s*/u, '').trim() + let summary = '' + switch (Number(message.localType || 0)) { + case 1: + summary = message.parsedContent || message.rawContent || '' + break + case 3: + summary = '[图片]' + break + case 34: + summary = '[语音]' + break + case 43: + summary = '[视频]' + break + case 47: + summary = '[表情]' + break + case 42: + summary = message.cardNickname || '[名片]' + break + case 48: + summary = '[位置]' + break + case 49: + summary = message.linkTitle || message.fileName || message.parsedContent || '[消息]' + break + default: + summary = message.parsedContent || message.rawContent || this.getMessageTypeLabel(Number(message.localType || 0)) + break + } + return cleanOfficialPrefix(this.cleanString(summary)) + } + + private async getInitialSyntheticUnreadState(sessionId: string, latestTimestamp: number): Promise<{ + count: number + latestMessage?: Message + }> { + const normalizedLatest = Number(latestTimestamp || 0) + if (!Number.isFinite(normalizedLatest) || normalizedLatest <= 0) return { count: 0 } + + const nowSeconds = Math.floor(Date.now() / 1000) + if (Math.abs(nowSeconds - normalizedLatest) > 10 * 60) { + return { count: 0 } + } + + const result = await this.getNewMessages(sessionId, Math.max(0, Math.floor(normalizedLatest) - 1), 20) + if (!result.success || !Array.isArray(result.messages)) return { count: 0 } + const unreadMessages = result.messages.filter((message) => { + const createTime = Number(message.createTime || 0) + return Number.isFinite(createTime) && + createTime >= normalizedLatest && + message.isSend !== 1 + }) + return { + count: unreadMessages.length, + latestMessage: unreadMessages[unreadMessages.length - 1] + } + } + + private markSyntheticUnreadRead(sessionId: string, messages: Message[] = []): void { + const normalized = String(sessionId || '').trim() + if (!this.shouldUseSyntheticUnread(normalized)) return + + let latestTimestamp = 0 + const state = this.syntheticUnreadState.get(normalized) + if (state) latestTimestamp = Math.max(latestTimestamp, state.latestTimestamp, state.scannedTimestamp) + for (const message of messages) { + const createTime = Number(message.createTime || 0) + if (Number.isFinite(createTime) && createTime > latestTimestamp) { + latestTimestamp = Math.floor(createTime) + } + } + + this.syntheticUnreadState.set(normalized, { + readTimestamp: latestTimestamp, + scannedTimestamp: latestTimestamp, + latestTimestamp, + unreadCount: 0, + summary: state?.summary, + summaryTimestamp: state?.summaryTimestamp, + lastMsgType: state?.lastMsgType + }) + } + async getSessionStatuses(usernames: string[]): Promise<{ success: boolean map?: Record @@ -1362,8 +1745,50 @@ class ChatService { } } + private getContactsCacheScope(): string { + const dbPath = String(this.configService.get('dbPath') || '').trim() + const myWxid = String(this.configService.get('myWxid') || '').trim() + return `${dbPath}::${myWxid}` + } + + private cloneContacts(contacts: ContactInfo[]): ContactInfo[] { + return (contacts || []).map((contact) => ({ + ...contact, + labels: Array.isArray(contact.labels) ? [...contact.labels] : contact.labels + })) + } + + private getContactsFromMemoryCache(mode: 'lite' | 'full', scope: string): ContactInfo[] | null { + const cached = this.contactsMemoryCache.get(mode) + if (!cached) return null + if (cached.scope !== scope) return null + if (Date.now() - cached.updatedAt > this.contactsMemoryCacheTtlMs) return null + return this.cloneContacts(cached.contacts) + } + + private setContactsMemoryCache(mode: 'lite' | 'full', scope: string, contacts: ContactInfo[]): void { + this.contactsMemoryCache.set(mode, { + scope, + updatedAt: Date.now(), + contacts: this.cloneContacts(contacts) + }) + } + private async getContactsInternal(options?: GetContactsOptions): Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> { const isLiteMode = options?.lite === true + const mode: 'lite' | 'full' = isLiteMode ? 'lite' : 'full' + const cacheScope = this.getContactsCacheScope() + const cachedContacts = this.getContactsFromMemoryCache(mode, cacheScope) + if (cachedContacts) { + return { success: true, contacts: cachedContacts } + } + if (isLiteMode) { + const fullCachedContacts = this.getContactsFromMemoryCache('full', cacheScope) + if (fullCachedContacts) { + return { success: true, contacts: fullCachedContacts } + } + } + const startedAt = Date.now() const stageDurations: Array<{ stage: string; ms: number }> = [] const captureStage = (stage: string, stageStartedAt: number) => { @@ -1487,6 +1912,10 @@ class ChatService { .join(', ') console.warn(`[ChatService] getContacts(${isLiteMode ? 'lite' : 'full'}) 慢查询 total=${totalMs}ms, ${stageSummary}`) } + this.setContactsMemoryCache(mode, cacheScope, result) + if (!isLiteMode) { + this.setContactsMemoryCache('lite', cacheScope, result) + } return { success: true, contacts: result } } catch (e) { console.error('ChatService: 获取通讯录失败:', e) @@ -1636,6 +2065,9 @@ class ChatService { releaseMessageCursorMutex?.() this.messageCacheService.set(sessionId, filtered) + if (offset === 0 && startTime === 0 && endTime === 0) { + this.markSyntheticUnreadRead(sessionId, filtered) + } console.log( `[ChatService] getMessages session=${sessionId} rawRowsConsumed=${rawRowsConsumed} visibleMessagesReturned=${filtered.length} filteredOut=${collected.filteredOut || 0} nextOffset=${state.fetched} hasMore=${hasMore}` ) @@ -2886,6 +3318,7 @@ class ChatService { this.sessionTablesCache.clear() this.messageTableColumnsCache.clear() this.messageDbCountSnapshotCache = null + this.contactsMemoryCache.clear() this.refreshSessionStatsCacheScope(scope) this.refreshGroupMyMessageCountCacheScope(scope) } @@ -4237,6 +4670,8 @@ class ChatService { case '57': // 引用消息,title 就是回复的内容 return title + case '53': + return `[接龙] ${title.split(/\r?\n/).map(line => line.trim()).find(Boolean) || title}` case '2000': return `[转账] ${title}` case '2001': @@ -4266,6 +4701,8 @@ class ChatService { return '[链接]' case '87': return '[群公告]' + case '53': + return '[接龙]' default: return '[消息]' } @@ -4865,6 +5302,8 @@ class ChatService { const quoteInfo = this.parseQuoteMessage(content) result.quotedContent = quoteInfo.content result.quotedSender = quoteInfo.sender + } else if (xmlType === '53') { + result.appMsgKind = 'solitaire' } else if ((xmlType === '5' || xmlType === '49') && (sourceUsername?.startsWith('gh_') || appName?.includes('公众号') || sourceName)) { result.appMsgKind = 'official-link' } else if (url) { @@ -5633,12 +6072,13 @@ class ChatService { // 如果是字符串 if (typeof raw === 'string') { if (raw.length === 0) return '' + const compactRaw = this.compactEncodedPayload(raw) // 检查是否是 hex 编码 // 只有当字符串足够长(超过16字符)且看起来像 hex 时才尝试解码 // 短字符串(如 "123456" 等纯数字)容易被误判为 hex - if (raw.length > 16 && this.looksLikeHex(raw)) { - const bytes = Buffer.from(raw, 'hex') + if (compactRaw.length > 16 && this.looksLikeHex(compactRaw)) { + const bytes = Buffer.from(compactRaw, 'hex') if (bytes.length > 0) { const result = this.decodeBinaryContent(bytes, raw) // @@ -5649,9 +6089,9 @@ class ChatService { // 检查是否是 base64 编码 // 只有当字符串足够长(超过16字符)且看起来像 base64 时才尝试解码 // 短字符串(如 "test", "home" 等)容易被误判为 base64 - if (raw.length > 16 && this.looksLikeBase64(raw)) { + if (compactRaw.length > 16 && this.looksLikeBase64(compactRaw)) { try { - const bytes = Buffer.from(raw, 'base64') + const bytes = Buffer.from(compactRaw, 'base64') return this.decodeBinaryContent(bytes, raw) } catch { } } @@ -5710,16 +6150,22 @@ class ChatService { * 检查是否像 hex 编码 */ private looksLikeHex(s: string): boolean { - if (s.length % 2 !== 0) return false - return /^[0-9a-fA-F]+$/.test(s) + const compact = this.compactEncodedPayload(s) + if (compact.length % 2 !== 0) return false + return /^[0-9a-fA-F]+$/.test(compact) } /** * 检查是否像 base64 编码 */ private looksLikeBase64(s: string): boolean { - if (s.length % 4 !== 0) return false - return /^[A-Za-z0-9+/=]+$/.test(s) + const compact = this.compactEncodedPayload(s) + if (compact.length % 4 !== 0) return false + return /^[A-Za-z0-9+/=]+$/.test(compact) + } + + private compactEncodedPayload(raw: string): string { + return String(raw || '').replace(/\s+/g, '').trim() } private shouldKeepSession(username: string): boolean { @@ -5983,6 +6429,7 @@ class ChatService { if (includeContacts) { this.avatarCache.clear() this.contactCacheService.clear() + this.contactsMemoryCache.clear() } if (includeMessages) { @@ -7741,6 +8188,552 @@ class ChatService { } } + async getMyFootprintStats( + beginTimestamp: number, + endTimestamp: number, + options?: { + myWxid?: string + privateSessionIds?: string[] + groupSessionIds?: string[] + mentionLimit?: number + privateLimit?: number + mentionMode?: 'text_at_me' | string + } + ): Promise<{ success: boolean; data?: MyFootprintData; error?: string }> { + try { + const connectResult = await this.ensureConnected() + if (!connectResult.success) { + return { success: false, error: connectResult.error || '数据库未连接' } + } + + const begin = this.normalizeTimestampSeconds(beginTimestamp) + const end = this.normalizeTimestampSeconds(endTimestamp) + const normalizedEnd = begin > 0 && end > 0 && end < begin ? begin : end + const mentionLimitRaw = Number(options?.mentionLimit ?? 0) + const privateLimitRaw = Number(options?.privateLimit ?? 0) + const mentionLimit = Number.isFinite(mentionLimitRaw) && mentionLimitRaw >= 0 + ? Math.floor(mentionLimitRaw) + : 0 + const privateLimit = Number.isFinite(privateLimitRaw) && privateLimitRaw >= 0 + ? Math.floor(privateLimitRaw) + : 0 + + let myWxid = String(options?.myWxid || '').trim() + if (!myWxid) { + myWxid = String(this.configService.get('myWxid') || '').trim() + } + if (!myWxid) { + return { success: false, error: '未识别当前账号 wxid' } + } + + let privateSessionIds = Array.isArray(options?.privateSessionIds) + ? options!.privateSessionIds!.map((value) => String(value || '').trim()).filter(Boolean) + : [] + let groupSessionIds = Array.isArray(options?.groupSessionIds) + ? options!.groupSessionIds!.map((value) => String(value || '').trim()).filter(Boolean) + : [] + const hasExplicitGroupScope = Array.isArray(options?.groupSessionIds) + && options!.groupSessionIds!.some((value) => String(value || '').trim().length > 0) + + if (privateSessionIds.length === 0 && groupSessionIds.length === 0) { + const sessionsResult = await wcdbService.getSessions() + if (!sessionsResult.success || !Array.isArray(sessionsResult.sessions)) { + return { success: false, error: sessionsResult.error || '读取会话列表失败' } + } + for (const session of sessionsResult.sessions as Array>) { + const sessionId = String(session.username || session.user_name || '').trim() + if (!sessionId) continue + const sessionLastTs = this.normalizeTimestampSeconds( + Number(session.lastTimestamp || session.sortTimestamp || 0) + ) + if (sessionId.endsWith('@chatroom')) { + groupSessionIds.push(sessionId) + } else { + if (!this.shouldKeepSession(sessionId)) continue + if (begin > 0 && sessionLastTs > 0 && sessionLastTs < begin) continue + privateSessionIds.push(sessionId) + } + } + } + + privateSessionIds = Array.from(new Set( + privateSessionIds + .map((value) => String(value || '').trim()) + .filter((value) => value && !value.endsWith('@chatroom') && this.shouldKeepSession(value)) + )) + groupSessionIds = Array.from(new Set( + groupSessionIds + .map((value) => String(value || '').trim()) + .filter((value) => value && value.endsWith('@chatroom')) + )) + if (!hasExplicitGroupScope) { + groupSessionIds = await this.resolveMyFootprintGroupSessionIds(groupSessionIds, begin, normalizedEnd) + } + + privateSessionIds = await this.filterMyFootprintPrivateSessions(privateSessionIds) + + let data: MyFootprintData | null = null + const effectivePrivateLimit = privateLimit + // native 候选上限:0 表示不截断候选,确保前端 source 二次过滤有完整输入 + const nativeMentionCandidateLimit = 0 + let nativePasses = 0 + const candidateLimitUsed = nativeMentionCandidateLimit + let nativeGroupChunks = 0 + + const runNativePass = async (passOptions: { + label: string + passPrivateSessionIds: string[] + passGroupSessionIds: string[] + candidateLimit: number + passPrivateLimit: number + }): Promise => { + nativePasses += 1 + const nativeResult = await wcdbService.getMyFootprintStats({ + beginTimestamp: begin, + endTimestamp: normalizedEnd, + myWxid, + privateSessionIds: passOptions.passPrivateSessionIds, + groupSessionIds: passOptions.passGroupSessionIds, + mentionLimit: passOptions.candidateLimit, + privateLimit: passOptions.passPrivateLimit, + mentionMode: options?.mentionMode || 'text_at_me' + }) + if (!nativeResult.success || !nativeResult.data) { + throw new Error(nativeResult.error || '获取我的足迹统计失败') + } + const normalized = this.normalizeMyFootprintData(nativeResult.data) + return normalized + } + + const runGroupPasses = async (targetGroupSessionIds: string[]): Promise<{ raw: MyFootprintData | null; chunks: number }> => { + if (!Array.isArray(targetGroupSessionIds) || targetGroupSessionIds.length === 0) { + return { raw: null, chunks: 0 } + } + const singleGroupThresholdRaw = Number(process.env.WEFLOW_MY_FOOTPRINT_SINGLE_GROUP_THRESHOLD || 40) + const singleGroupThreshold = Number.isFinite(singleGroupThresholdRaw) && singleGroupThresholdRaw >= 1 + ? Math.floor(singleGroupThresholdRaw) + : 40 + + let aggregated: MyFootprintData | null = null + let chunks = 0 + if (targetGroupSessionIds.length <= singleGroupThreshold) { + chunks = targetGroupSessionIds.length + for (const sessionId of targetGroupSessionIds) { + const chunkRaw = await runNativePass({ + label: `group-single:${sessionId}`, + passPrivateSessionIds: [], + passGroupSessionIds: [sessionId], + candidateLimit: candidateLimitUsed, + passPrivateLimit: 0 + }) + aggregated = aggregated + ? this.mergeMyFootprintMentionResult(aggregated, chunkRaw) + : chunkRaw + } + } else { + const groupChunks = splitGroupSessionsForNative(targetGroupSessionIds) + chunks = groupChunks.length + for (const chunk of groupChunks) { + const chunkRaw = await runNativePass({ + label: `group-chunk:${chunk[0] || ''}..(${chunk.length})`, + passPrivateSessionIds: [], + passGroupSessionIds: chunk, + candidateLimit: candidateLimitUsed, + passPrivateLimit: 0 + }) + aggregated = aggregated + ? this.mergeMyFootprintMentionResult(aggregated, chunkRaw) + : chunkRaw + } + } + return { raw: aggregated, chunks } + } + + const splitGroupSessionsForNative = (sessionIds: string[]): string[][] => { + const normalized = Array.from(new Set( + (sessionIds || []) + .map((value) => String(value || '').trim()) + .filter((value) => value.endsWith('@chatroom')) + )) + if (normalized.length === 0) return [] + + // 规避 native options_json 可能存在的固定缓冲上限:按 payload 字节安全分块。 + // 这不是降级或裁剪范围,而是完整遍历所有群并做结果合并。 + const maxBytesRaw = Number(process.env.WEFLOW_MY_FOOTPRINT_GROUP_OPTIONS_MAX_BYTES || 900) + const maxBytes = Number.isFinite(maxBytesRaw) && maxBytesRaw >= 512 + ? Math.floor(maxBytesRaw) + : 900 + const estimateBytes = (groups: string[]): number => Buffer.byteLength(JSON.stringify({ + begin, + end: normalizedEnd, + my_wxid: myWxid, + private_session_ids: [], + group_session_ids: groups, + mention_limit: candidateLimitUsed, + private_limit: 0, + mention_mode: options?.mentionMode || 'text_at_me' + }), 'utf8') + + const chunks: string[][] = [] + let current: string[] = [] + for (const sessionId of normalized) { + if (current.length === 0) { + current.push(sessionId) + continue + } + const next = [...current, sessionId] + if (estimateBytes(next) > maxBytes) { + chunks.push(current) + current = [sessionId] + } else { + current = next + } + } + if (current.length > 0) chunks.push(current) + return chunks + } + + let privateNativeRaw: MyFootprintData | null = null + let mentionNativeRaw: MyFootprintData | null = null + + if (privateSessionIds.length > 0) { + privateNativeRaw = await runNativePass({ + label: 'private', + passPrivateSessionIds: privateSessionIds, + passGroupSessionIds: [], + candidateLimit: 0, + passPrivateLimit: effectivePrivateLimit + }) + } + + if (groupSessionIds.length > 0) { + const firstPass = await runGroupPasses(groupSessionIds) + mentionNativeRaw = firstPass.raw + nativeGroupChunks = firstPass.chunks + + if ((mentionNativeRaw?.mentions.length || 0) === 0) { + const probeIndexes = Array.from(new Set([ + 0, + Math.floor(groupSessionIds.length / 2), + groupSessionIds.length - 1 + ])).filter((index) => index >= 0 && index < groupSessionIds.length) + let probeHit = false + for (const index of probeIndexes) { + const sessionId = groupSessionIds[index] + const probeRaw = await runNativePass({ + label: `group-probe:${sessionId}`, + passPrivateSessionIds: [], + passGroupSessionIds: [sessionId], + candidateLimit: candidateLimitUsed, + passPrivateLimit: 0 + }) + if (probeRaw.mentions.length > 0 || probeRaw.summary.mention_count > 0) { + probeHit = true + break + } + } + + if (probeHit) { + await wcdbService.getSessions().catch(() => ({ success: false })) + const retryPass = await runGroupPasses(groupSessionIds) + mentionNativeRaw = retryPass.raw + nativeGroupChunks = retryPass.chunks + } + } + } + + let nativeRaw = privateNativeRaw || mentionNativeRaw || this.normalizeMyFootprintData({}) + if (privateNativeRaw && mentionNativeRaw) { + nativeRaw = this.mergeMyFootprintMentionResult(privateNativeRaw, mentionNativeRaw) + } + + data = this.filterMyFootprintMentionsBySource(nativeRaw, myWxid, mentionLimit) + + if (privateSessionIds.length > 0 && data.private_segments.length === 0) { + const privateSegments = await this.rebuildMyFootprintPrivateSegments({ + begin, + end: normalizedEnd, + myWxid, + privateSessionIds + }) + if (privateSegments.length > 0) { + data = { + ...data, + private_segments: privateSegments + } + } + } + + if (data.mentions.length === 0) { + if (this.shouldRunMyFootprintHeavyDebug()) { + const privatePassRawMentions = privateNativeRaw?.mentions.length || 0 + const mentionPassRawMentions = mentionNativeRaw?.mentions.length || 0 + console.warn( + `[MyFootprint][diag] zero filtered mentions begin=${begin} end=${normalizedEnd} groups=${groupSessionIds.length} raw=${nativeRaw.mentions.length} splitRaw(private=${privatePassRawMentions},group=${mentionPassRawMentions}) passes=${nativePasses} groupChunks=${nativeGroupChunks}` + ) + await this.printMyFootprintNativeLogs('zero_filtered_mentions') + await this.logMyFootprintNativeQuickProbe({ + begin, + end: normalizedEnd, + myWxid, + groupSessionIds, + mentionMode: options?.mentionMode || 'text_at_me' + }) + await this.logMyFootprintZeroMentionDebug({ + begin, + end: normalizedEnd, + myWxid, + groupSessionIds, + nativeData: nativeRaw + }) + } + } + + const enriched = await this.enrichMyFootprintData(data) + return { success: true, data: enriched } + } catch (error) { + console.error('[ChatService] 获取我的足迹统计失败:', error) + return { success: false, error: String(error) } + } + } + + private async logMyFootprintNativeQuickProbe(params: { + begin: number + end: number + myWxid: string + groupSessionIds: string[] + mentionMode: string + }): Promise { + try { + const groups = Array.from(new Set( + (params.groupSessionIds || []) + .map((value) => String(value || '').trim()) + .filter((value) => value.endsWith('@chatroom')) + )) + if (groups.length === 0) { + console.warn('[MyFootprint][native-quick] skipped: no groups') + return + } + const indices = Array.from(new Set([ + 0, + Math.floor(groups.length / 2), + groups.length - 1 + ])).filter((index) => index >= 0 && index < groups.length) + + for (const index of indices) { + const sessionId = groups[index] + const result = await wcdbService.getMyFootprintStats({ + beginTimestamp: params.begin, + endTimestamp: params.end, + myWxid: params.myWxid, + privateSessionIds: [], + groupSessionIds: [sessionId], + mentionLimit: 0, + privateLimit: 0, + mentionMode: params.mentionMode + }) + if (!result.success || !result.data) { + console.warn( + `[MyFootprint][native-quick][${index + 1}/${groups.length}][${sessionId}] fail err=${result.error || 'unknown'}` + ) + continue + } + const raw = this.normalizeMyFootprintData(result.data) + console.warn( + `[MyFootprint][native-quick][${index + 1}/${groups.length}][${sessionId}] mentions=${raw.mentions.length} mentionGroups=${raw.mention_groups.length} summaryMention=${raw.summary.mention_count} diagScanned=${raw.diagnostics.scanned_dbs} diagElapsed=${raw.diagnostics.elapsed_ms}` + ) + } + } catch (error) { + console.warn('[MyFootprint][native-quick] exception:', error) + } + } + + private async rebuildMyFootprintPrivateSegments(params: { + begin: number + end: number + myWxid: string + privateSessionIds: string[] + }): Promise { + const sessionGapSeconds = 10 * 60 + const segments: MyFootprintPrivateSegment[] = [] + + type WorkingSegment = { + segment_index: number + start_ts: number + end_ts: number + incoming_count: number + outgoing_count: number + first_incoming_ts: number + first_reply_ts: number + anchor_local_id: number + anchor_create_time: number + latest_local_id: number + latest_create_time: number + } + + for (const sessionId of params.privateSessionIds) { + const cursorResult = await wcdbService.openMessageCursorLite( + sessionId, + 360, + true, + params.begin, + params.end + ) + if (!cursorResult.success || !cursorResult.cursor) continue + + let segmentCursor = 0 + let active: WorkingSegment | null = null + let lastMessageTs = 0 + const commit = () => { + if (!active) return + const startTs = active.start_ts > 0 ? active.start_ts : active.anchor_create_time + const endTs = active.end_ts > 0 ? active.end_ts : startTs + const incoming = Math.max(0, active.incoming_count) + const outgoing = Math.max(0, active.outgoing_count) + const messageCount = incoming + outgoing + if (startTs > 0 && messageCount > 0) { + segments.push({ + session_id: sessionId, + segment_index: active.segment_index, + start_ts: startTs, + end_ts: endTs, + duration_sec: Math.max(0, endTs - startTs), + incoming_count: incoming, + outgoing_count: outgoing, + message_count: messageCount, + replied: incoming > 0 && outgoing > 0, + first_incoming_ts: active.first_incoming_ts, + first_reply_ts: active.first_reply_ts, + latest_ts: endTs, + anchor_local_id: active.anchor_local_id, + anchor_create_time: startTs + }) + } + active = null + } + + let hasMore = true + try { + while (hasMore) { + const batchResult = await wcdbService.fetchMessageBatch(cursorResult.cursor) + if (!batchResult.success || !Array.isArray(batchResult.rows)) break + hasMore = Boolean(batchResult.hasMore) + + for (const row of batchResult.rows as Array>) { + const createTime = this.toSafeInt(row.create_time, 0) + const localId = this.toSafeInt(row.local_id, 0) + const isSend = this.resolveFootprintRowIsSend(row, params.myWxid) + + if (createTime > 0) { + const needNew = !active || (lastMessageTs > 0 && createTime - lastMessageTs > sessionGapSeconds) + if (needNew) { + commit() + segmentCursor += 1 + active = { + segment_index: segmentCursor, + start_ts: createTime, + end_ts: createTime, + incoming_count: 0, + outgoing_count: 0, + first_incoming_ts: 0, + first_reply_ts: 0, + anchor_local_id: localId, + anchor_create_time: createTime, + latest_local_id: localId, + latest_create_time: createTime + } + } + } else if (!active) { + segmentCursor += 1 + active = { + segment_index: segmentCursor, + start_ts: 0, + end_ts: 0, + incoming_count: 0, + outgoing_count: 0, + first_incoming_ts: 0, + first_reply_ts: 0, + anchor_local_id: localId, + anchor_create_time: 0, + latest_local_id: localId, + latest_create_time: 0 + } + } + + if (isSend) { + if (active) { + active.outgoing_count += 1 + if ( + createTime > 0 + && active.first_incoming_ts > 0 + && createTime >= active.first_incoming_ts + && active.first_reply_ts <= 0 + ) { + active.first_reply_ts = createTime + } + } + } else if (active) { + active.incoming_count += 1 + if (active.first_incoming_ts <= 0 || (createTime > 0 && createTime < active.first_incoming_ts)) { + active.first_incoming_ts = createTime + } + } + + if (active && createTime > 0) { + active.end_ts = createTime + active.latest_create_time = createTime + active.latest_local_id = localId + lastMessageTs = createTime + } + } + } + } finally { + await wcdbService.closeMessageCursor(cursorResult.cursor).catch(() => {}) + } + + commit() + } + + return segments.sort((a, b) => { + if (a.start_ts !== b.start_ts) return a.start_ts - b.start_ts + if (a.session_id !== b.session_id) return a.session_id.localeCompare(b.session_id) + return a.segment_index - b.segment_index + }) + } + + async exportMyFootprint( + beginTimestamp: number, + endTimestamp: number, + format: 'csv' | 'json', + filePath: string + ): Promise<{ success: boolean; filePath?: string; error?: string }> { + try { + const normalizedFormat = String(format || '').toLowerCase() === 'csv' ? 'csv' : 'json' + const targetPath = String(filePath || '').trim() + if (!targetPath) { + return { success: false, error: '导出路径不能为空' } + } + + const statsResult = await this.getMyFootprintStats(beginTimestamp, endTimestamp) + if (!statsResult.success || !statsResult.data) { + return { success: false, error: statsResult.error || '导出前获取统计失败' } + } + + mkdirSync(dirname(targetPath), { recursive: true }) + if (normalizedFormat === 'json') { + writeFileSync(targetPath, JSON.stringify(statsResult.data, null, 2), 'utf-8') + } else { + const csv = this.buildMyFootprintCsv(statsResult.data) + writeFileSync(targetPath, `\uFEFF${csv}`, 'utf-8') + } + + return { success: true, filePath: targetPath } + } catch (error) { + console.error('[ChatService] 导出我的足迹失败:', error) + return { success: false, error: String(error) } + } + } + async getMessageById(sessionId: string, localId: number): Promise<{ success: boolean; message?: Message; error?: string }> { try { const nativeResult = await wcdbService.getMessageById(sessionId, localId) @@ -7798,6 +8791,1637 @@ class ChatService { } } + private normalizeTimestampSeconds(value: number): number { + const numeric = Number(value || 0) + if (!Number.isFinite(numeric) || numeric <= 0) return 0 + return numeric > 1e12 ? Math.floor(numeric / 1000) : Math.floor(numeric) + } + + private toSafeInt(value: unknown, fallback = 0): number { + const parsed = Number.parseInt(String(value ?? '').trim(), 10) + return Number.isFinite(parsed) ? parsed : fallback + } + + private toSafeNumber(value: unknown, fallback = 0): number { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : fallback + } + + private resolveFootprintRowIsSend(row: Record, myWxid: string): boolean { + const raw = row.computed_is_send ?? row.is_send + if (raw === 1 || raw === '1' || raw === true || raw === 'true') return true + if (raw === 0 || raw === '0' || raw === false || raw === 'false') return false + const senderUsername = String(row.sender_username || row.senderUsername || '').trim() + return Boolean(senderUsername && myWxid && senderUsername === myWxid) + } + + private splitAtUserList(raw: string): string[] { + const tokens = String(raw || '') + .split(/[,\s;|]+/g) + .map((token) => token.trim().replace(/^@+/, '').replace(/^["']+|["']+$/g, '')) + .filter(Boolean) + return Array.from(new Set(tokens)) + } + + private containsAtSign(text: string): boolean { + if (!text) return false + return text.includes('@') || text.includes('@') + } + + private footprintMessageLikelyContainsAt(rawContent: unknown): boolean { + if (rawContent === null || rawContent === undefined) return false + const text = typeof rawContent === 'string' ? rawContent : String(rawContent || '') + return this.containsAtSign(text) + } + + private matchesMyFootprintIdentity(rawToken: string, identitySet: Set): boolean { + const token = String(rawToken || '').trim().replace(/^@+/, '') + if (!token) return false + + const normalizedCandidates = new Set() + const addCandidate = (value: string) => { + const normalized = String(value || '').trim().toLowerCase() + if (!normalized) return + normalizedCandidates.add(normalized) + } + + addCandidate(token) + addCandidate(token.replace(/@chatroom$/i, '')) + addCandidate(token.replace(/@openim$/i, '')) + + for (const candidate of normalizedCandidates) { + if (!candidate) continue + for (const selfId of identitySet) { + if (!selfId) continue + if (candidate === selfId) return true + if (candidate.startsWith(`${selfId}_`) || selfId.startsWith(`${candidate}_`)) return true + } + } + return false + } + + private buildMyFootprintIdentitySet(myWxid: string): Set { + const set = new Set() + const add = (value: string) => { + const normalized = String(value || '').trim().toLowerCase() + if (!normalized) return + set.add(normalized) + } + + const raw = String(myWxid || '').trim() + add(raw) + add(this.cleanAccountDirName(raw)) + for (const key of this.buildIdentityKeys(raw)) { + add(key) + } + return set + } + + private buildFootprintSourceCandidates(source: unknown): string[] { + const sourceCandidates: string[] = [] + const seen = new Set() + const pushCandidate = (value: unknown) => { + const normalized = this.cleanUtf16(String(value || '').trim()) + if (!normalized) return + if (seen.has(normalized)) return + seen.add(normalized) + sourceCandidates.push(normalized) + } + + const rawSource = typeof source === 'string' + ? source + : Buffer.isBuffer(source) || source instanceof Uint8Array + ? Buffer.from(source).toString('utf-8') + : typeof source === 'object' && source !== null && Array.isArray((source as { data?: unknown }).data) + ? Buffer.from((source as { data: number[] }).data).toString('utf-8') + : String(source || '') + const normalizedSource = String(rawSource || '').trim() + pushCandidate(normalizedSource) + if (normalizedSource.includes('&')) { + pushCandidate(this.decodeHtmlEntities(normalizedSource)) + } + + const sourceLooksEncoded = normalizedSource.length > 16 + && (this.looksLikeHex(normalizedSource) || this.looksLikeBase64(normalizedSource)) + if (sourceLooksEncoded) { + const decodedFromText = this.decodeMaybeCompressed(normalizedSource, 'footprint_source') + pushCandidate(decodedFromText) + if (decodedFromText.includes('&')) { + pushCandidate(this.decodeHtmlEntities(decodedFromText)) + } + } else if (typeof source !== 'string') { + const decodedFromBinary = this.decodeMaybeCompressed(source, 'footprint_source') + pushCandidate(decodedFromBinary) + if (decodedFromBinary.includes('&')) { + pushCandidate(this.decodeHtmlEntities(decodedFromBinary)) + } + } + + return sourceCandidates + } + + private normalizeFootprintSourceForOutput(source: unknown): string { + if (source === null || source === undefined) return '' + if (typeof source === 'string') return source.trim() + if (Buffer.isBuffer(source) || source instanceof Uint8Array) { + return this.decodeBinaryContent(Buffer.from(source), '').trim() + } + if (typeof source === 'object' && source !== null && Array.isArray((source as { data?: unknown }).data)) { + return this.decodeBinaryContent(Buffer.from((source as { data: number[] }).data), '').trim() + } + return String(source || '').trim() + } + + private extractAtUserListTokensFromSource(source: unknown, prebuiltCandidates?: string[]): string[] { + const tokens = new Set() + const sourceCandidates = Array.isArray(prebuiltCandidates) && prebuiltCandidates.length > 0 + ? prebuiltCandidates + : this.buildFootprintSourceCandidates(source) + const addTokens = (values: string[]) => { + for (const value of values) { + const normalized = String(value || '').trim() + if (!normalized) continue + tokens.add(normalized) + } + } + + const xmlPattern = /]*>([\s\S]*?)<\/atuserlist>/gi + const cdataPattern = //i + for (const candidateSource of sourceCandidates) { + if (!candidateSource.toLowerCase().includes('atuserlist')) continue + + const trimmedCandidateSource = candidateSource.trim() + const maybeJson = trimmedCandidateSource.startsWith('{') + || trimmedCandidateSource.startsWith('[') + || trimmedCandidateSource.includes('"atuserlist"') + if (maybeJson) { + try { + const parsed = JSON.parse(candidateSource) + const atUserList = parsed?.atuserlist + if (Array.isArray(atUserList)) { + const values = atUserList + .map((item: unknown) => this.splitAtUserList(String(item || ''))) + .flat() + addTokens(values) + } + if (typeof atUserList === 'string') { + addTokens(this.splitAtUserList(atUserList)) + } + } catch { + // ignore JSON parse error and continue fallback parsing + } + } + + const jsonMatch = candidateSource.match(/"atuserlist"\s*:\s*(\[[^\]]*\]|"[^"]*"|'[^']*'|[^,}\s]+)/i) + if (jsonMatch) { + const jsonCandidate = String(jsonMatch[1] || '').trim() + if (jsonCandidate.startsWith('[')) { + try { + const arr = JSON.parse(jsonCandidate) + if (Array.isArray(arr)) { + const values = arr + .map((item) => this.splitAtUserList(String(item || ''))) + .flat() + addTokens(values) + } + } catch { + // ignore array parse error + } + } + const unquoted = jsonCandidate.replace(/^["']+|["']+$/g, '') + addTokens(this.splitAtUserList(unquoted)) + } + + xmlPattern.lastIndex = 0 + let xmlMatch: RegExpExecArray | null + while ((xmlMatch = xmlPattern.exec(candidateSource)) !== null) { + let xmlValue = String(xmlMatch[1] || '') + const cdataMatch = xmlValue.match(cdataPattern) + if (cdataMatch?.[1]) { + xmlValue = cdataMatch[1] + } + addTokens(this.splitAtUserList(xmlValue)) + } + } + + return Array.from(tokens) + } + + private sourceAtUserListContains(source: unknown, myWxid: string): boolean { + const selfIdentitySet = this.buildMyFootprintIdentitySet(myWxid) + return this.sourceAtUserListContainsWithIdentitySet(source, selfIdentitySet) + } + + private sourceAtUserListContainsWithIdentitySet(source: unknown, selfIdentitySet: Set): boolean { + if (selfIdentitySet.size === 0) return false + if (typeof source === 'string') { + const raw = source.trim() + if (!raw) return false + const loweredRaw = raw.toLowerCase() + if (loweredRaw.includes('atuserlist')) { + for (const identity of selfIdentitySet) { + if (identity && loweredRaw.includes(identity)) { + return true + } + } + const quickXmlMatch = raw.match(/]*>([\s\S]*?)<\/atuserlist>/i) + if (quickXmlMatch?.[1]) { + const inner = quickXmlMatch[1] + const cdata = inner.match(//i)?.[1] || inner + const quickTokens = this.splitAtUserList(cdata) + if (quickTokens.some((token) => this.matchesMyFootprintIdentity(token, selfIdentitySet))) { + return true + } + } + } else if (raw.length <= 16 || (!this.looksLikeHex(raw) && !this.looksLikeBase64(raw))) { + return false + } + } + const sourceCandidates = this.buildFootprintSourceCandidates(source) + for (const candidate of sourceCandidates) { + const normalized = String(candidate || '').toLowerCase() + if (!normalized || !normalized.includes('atuserlist')) continue + for (const identity of selfIdentitySet) { + if (identity && normalized.includes(identity)) { + return true + } + } + } + const tokens = this.extractAtUserListTokensFromSource(source, sourceCandidates) + if (tokens.length === 0) return false + return tokens.some((token) => this.matchesMyFootprintIdentity(token, selfIdentitySet)) + } + + private async resolveMyFootprintGroupSessionIds( + groupSessionIds: string[], + beginTimestamp = 0, + endTimestamp = 0 + ): Promise { + const normalized = Array.from(new Set( + (groupSessionIds || []) + .map((value) => String(value || '').trim()) + .filter((value) => value.endsWith('@chatroom')) + )) + const begin = this.normalizeTimestampSeconds(beginTimestamp) + const end = this.normalizeTimestampSeconds(endTimestamp) + void begin + void end + + const merged: string[] = [] + const seen = new Set() + const sessionLastTsMap = new Map() + const hasSessionRank = new Set() + const shouldKeepByLastTs = (sessionId: string, preferKeepUnknown: boolean): boolean => { + const normalizedSessionId = String(sessionId || '').trim() + if (!normalizedSessionId) return false + const lastTs = this.normalizeTimestampSeconds(sessionLastTsMap.get(normalizedSessionId) || 0) + const known = hasSessionRank.has(normalizedSessionId) + if (!known) return preferKeepUnknown || begin <= 0 + if (begin > 0 && lastTs > 0 && lastTs < begin) return false + return true + } + const push = (value: string) => { + const normalizedValue = String(value || '').trim() + if (!normalizedValue || !normalizedValue.endsWith('@chatroom')) return + if (seen.has(normalizedValue)) return + seen.add(normalizedValue) + merged.push(normalizedValue) + } + + try { + const sessionsResult = await this.getSessions() + if (sessionsResult.success && Array.isArray(sessionsResult.sessions)) { + const rankedGroups = sessionsResult.sessions + .map((session) => { + const sessionId = String(session?.username || '').trim() + const lastTs = this.normalizeTimestampSeconds( + Number(session?.lastTimestamp || session?.sortTimestamp || 0) + ) + if (sessionId.endsWith('@chatroom')) { + hasSessionRank.add(sessionId) + sessionLastTsMap.set(sessionId, lastTs) + } + return { sessionId, lastTs } + }) + .filter((item) => item.sessionId.endsWith('@chatroom')) + .filter((item) => shouldKeepByLastTs(item.sessionId, false)) + .sort((a, b) => { + if (a.lastTs !== b.lastTs) return b.lastTs - a.lastTs + return a.sessionId.localeCompare(b.sessionId) + }) + for (const item of rankedGroups) { + push(item.sessionId) + } + } + } catch { + // ignore session-based scope resolution failure + } + + try { + const contactGroups = await this.listMyFootprintGroupSessionIdsFromContact() + for (const sessionId of contactGroups) { + if (!shouldKeepByLastTs(sessionId, false)) continue + push(sessionId) + } + } catch { + // ignore contact-based scope resolution failure + } + + for (const sessionId of normalized) { + if (!shouldKeepByLastTs(sessionId, true)) continue + push(sessionId) + } + + return merged.length > 0 ? merged : normalized + } + + private async listMyFootprintGroupSessionIdsFromContact(): Promise { + try { + const result = await wcdbService.execQuery( + 'contact', + null, + "SELECT username FROM contact WHERE username IS NOT NULL AND username != '' AND username LIKE '%@chatroom'" + ) + if (!result.success || !Array.isArray(result.rows)) { + return [] + } + + return Array.from(new Set( + (result.rows as Array>) + .map((row) => String(this.getRowField(row, ['username', 'user_name', 'userName']) || '').trim()) + .filter((value) => value.endsWith('@chatroom')) + )) + } catch { + return [] + } + } + + private async filterMyFootprintPrivateSessions(privateSessionIds: string[]): Promise { + const normalized = Array.from(new Set( + (privateSessionIds || []) + .map((value) => String(value || '').trim()) + .filter((value) => value && !value.endsWith('@chatroom')) + )) + if (normalized.length === 0) return normalized + + try { + const officialSessionIds = await this.getMyFootprintOfficialSessionIdSet(normalized) + if (officialSessionIds.size === 0) return normalized + return normalized.filter((sessionId) => !officialSessionIds.has(sessionId)) + } catch { + return normalized + } + } + + private async getMyFootprintOfficialSessionIdSet(privateSessionIds: string[]): Promise> { + const officialSessionIds = new Set() + const normalized = Array.from(new Set( + (privateSessionIds || []) + .map((value) => String(value || '').trim()) + .filter((value) => value && !value.endsWith('@chatroom')) + )) + if (normalized.length === 0) return officialSessionIds + + for (const sessionId of normalized) { + if (sessionId.startsWith('gh_')) { + officialSessionIds.add(sessionId) + } + } + + const chunkSize = 320 + const buildInListSql = (values: string[]) => values + .map((value) => `'${this.escapeSqlString(value)}'`) + .join(',') + + try { + const bizInfoTableResult = await wcdbService.execQuery( + 'contact', + null, + "SELECT name FROM sqlite_master WHERE type='table' AND lower(name)='biz_info' LIMIT 1" + ) + const bizInfoTableName = bizInfoTableResult.success && Array.isArray(bizInfoTableResult.rows) + ? String((bizInfoTableResult.rows[0] as Record | undefined)?.name || '').trim() + : '' + if (bizInfoTableName) { + const tableSqlName = this.quoteSqlIdentifier(bizInfoTableName) + for (let index = 0; index < normalized.length; index += chunkSize) { + const batch = normalized.slice(index, index + chunkSize) + if (batch.length === 0) continue + const inListSql = buildInListSql(batch) + const sql = `SELECT username FROM ${tableSqlName} WHERE username IN (${inListSql})` + const result = await wcdbService.execQuery('contact', null, sql) + if (!result.success || !Array.isArray(result.rows)) continue + for (const row of result.rows as Array>) { + const username = String(this.getRowField(row, ['username', 'user_name', 'userName']) || '').trim() + if (username) officialSessionIds.add(username) + } + } + } + } catch { + // ignore biz_info lookup failure + } + + try { + const tableInfo = await wcdbService.execQuery('contact', null, 'PRAGMA table_info(contact)') + if (tableInfo.success && Array.isArray(tableInfo.rows)) { + const availableColumns = new Map() + for (const row of tableInfo.rows as Array>) { + const rawName = row.name ?? row.column_name ?? row.columnName + const name = String(rawName || '').trim() + if (!name) continue + availableColumns.set(name.toLowerCase(), name) + } + + const pickColumn = (candidates: string[]): string | null => { + for (const candidate of candidates) { + const actual = availableColumns.get(candidate.toLowerCase()) + if (actual) return actual + } + return null + } + + const usernameColumn = pickColumn(['username', 'user_name', 'userName']) + const officialFlagColumns = [ + pickColumn(['verify_flag', 'verifyFlag', 'verifyflag']), + pickColumn(['verify_status', 'verifyStatus']), + pickColumn(['verify_type', 'verifyType']), + pickColumn(['biz_type', 'bizType']), + pickColumn(['brand_flag', 'brandFlag']), + pickColumn(['service_type', 'serviceType']) + ].filter((column): column is string => Boolean(column)) + + if (usernameColumn && officialFlagColumns.length > 0) { + const selectColumns = Array.from(new Set([usernameColumn, ...officialFlagColumns])) + const selectSql = selectColumns.map((column) => this.quoteSqlIdentifier(column)).join(', ') + for (let index = 0; index < normalized.length; index += chunkSize) { + const batch = normalized.slice(index, index + chunkSize) + if (batch.length === 0) continue + const inListSql = buildInListSql(batch) + const sql = `SELECT ${selectSql} FROM contact WHERE ${this.quoteSqlIdentifier(usernameColumn)} IN (${inListSql})` + const result = await wcdbService.execQuery('contact', null, sql) + if (!result.success || !Array.isArray(result.rows)) continue + for (const row of result.rows as Array>) { + const username = String(this.getRowField(row, [usernameColumn, 'username', 'user_name', 'userName']) || '').trim() + if (!username) continue + const hasOfficialFlag = officialFlagColumns.some((column) => ( + this.isTruthyMyFootprintOfficialFlag(this.getRowField(row, [column])) + )) + if (hasOfficialFlag) { + officialSessionIds.add(username) + } + } + } + } + } + } catch { + // ignore contact-flag lookup failure + } + + return officialSessionIds + } + + private isTruthyMyFootprintOfficialFlag(value: unknown): boolean { + if (value === null || value === undefined) return false + if (typeof value === 'boolean') return value + if (typeof value === 'number') return Number.isFinite(value) && value > 0 + + const normalized = String(value || '').trim().toLowerCase() + if (!normalized) return false + if (normalized === '0' || normalized === 'false' || normalized === 'null' || normalized === 'undefined') { + return false + } + + const numeric = Number(normalized) + if (Number.isFinite(numeric)) { + return numeric > 0 + } + return true + } + + private normalizeMyFootprintData(raw: any): MyFootprintData { + const summaryRaw = raw?.summary || {} + const privateSessionsRaw = Array.isArray(raw?.private_sessions) ? raw.private_sessions : [] + const privateSegmentsRaw = Array.isArray(raw?.private_segments) ? raw.private_segments : [] + const mentionsRaw = Array.isArray(raw?.mentions) ? raw.mentions : [] + const mentionGroupsRaw = Array.isArray(raw?.mention_groups) ? raw.mention_groups : [] + const diagnosticsRaw = raw?.diagnostics || {} + + const summary: MyFootprintSummary = { + private_inbound_people: this.toSafeInt(summaryRaw.private_inbound_people, 0), + private_replied_people: this.toSafeInt(summaryRaw.private_replied_people, 0), + private_outbound_people: this.toSafeInt(summaryRaw.private_outbound_people, 0), + private_reply_rate: this.toSafeNumber(summaryRaw.private_reply_rate, 0), + mention_count: this.toSafeInt(summaryRaw.mention_count, 0), + mention_group_count: this.toSafeInt(summaryRaw.mention_group_count, 0) + } + + const private_sessions: MyFootprintPrivateSession[] = privateSessionsRaw.map((item: any) => ({ + session_id: String(item?.session_id || '').trim(), + incoming_count: this.toSafeInt(item?.incoming_count, 0), + outgoing_count: this.toSafeInt(item?.outgoing_count, 0), + replied: Boolean(item?.replied), + first_incoming_ts: this.toSafeInt(item?.first_incoming_ts, 0), + first_reply_ts: this.toSafeInt(item?.first_reply_ts, 0), + latest_ts: this.toSafeInt(item?.latest_ts, 0), + anchor_local_id: this.toSafeInt(item?.anchor_local_id, 0), + anchor_create_time: this.toSafeInt(item?.anchor_create_time, 0) + })).filter((item) => item.session_id) + + const private_segments: MyFootprintPrivateSegment[] = privateSegmentsRaw.map((item: any) => ({ + session_id: String(item?.session_id || '').trim(), + segment_index: this.toSafeInt(item?.segment_index, 0), + start_ts: this.toSafeInt(item?.start_ts, 0), + end_ts: this.toSafeInt(item?.end_ts, 0), + duration_sec: this.toSafeInt(item?.duration_sec, 0), + incoming_count: this.toSafeInt(item?.incoming_count, 0), + outgoing_count: this.toSafeInt(item?.outgoing_count, 0), + message_count: this.toSafeInt(item?.message_count, 0), + replied: Boolean(item?.replied), + first_incoming_ts: this.toSafeInt(item?.first_incoming_ts, 0), + first_reply_ts: this.toSafeInt(item?.first_reply_ts, 0), + latest_ts: this.toSafeInt(item?.latest_ts, 0), + anchor_local_id: this.toSafeInt(item?.anchor_local_id, 0), + anchor_create_time: this.toSafeInt(item?.anchor_create_time, 0), + displayName: String(item?.displayName || '').trim() || undefined, + avatarUrl: String(item?.avatarUrl || '').trim() || undefined + })).filter((item) => item.session_id && item.start_ts > 0) + + const mentions: MyFootprintMentionItem[] = mentionsRaw.map((item: any) => ({ + session_id: String(item?.session_id || '').trim(), + local_id: this.toSafeInt(item?.local_id, 0), + create_time: this.toSafeInt(item?.create_time, 0), + sender_username: String(item?.sender_username || '').trim(), + message_content: String(item?.message_content || ''), + source: String(item?.source || '') + })).filter((item) => item.session_id) + + const mention_groups: MyFootprintMentionGroup[] = mentionGroupsRaw.map((item: any) => ({ + session_id: String(item?.session_id || '').trim(), + count: this.toSafeInt(item?.count, 0), + latest_ts: this.toSafeInt(item?.latest_ts, 0) + })).filter((item) => item.session_id) + + const diagnostics: MyFootprintDiagnostics = { + truncated: Boolean(diagnosticsRaw.truncated), + scanned_dbs: this.toSafeInt(diagnosticsRaw.scanned_dbs, 0), + elapsed_ms: this.toSafeInt(diagnosticsRaw.elapsed_ms, 0), + mention_truncated: Boolean(diagnosticsRaw.mention_truncated), + private_truncated: Boolean(diagnosticsRaw.private_truncated) + } + + return { + summary, + private_sessions, + private_segments, + mentions, + mention_groups, + diagnostics + } + } + + private filterMyFootprintMentionsBySource(data: MyFootprintData, myWxid: string, mentionLimit: number): MyFootprintData { + const identitySet = this.buildMyFootprintIdentitySet(myWxid) + if (identitySet.size === 0) { + return { + ...data, + summary: { + ...data.summary, + mention_count: 0, + mention_group_count: 0 + }, + mentions: [], + mention_groups: [] + } + } + + const sourceMatchCache = new Map() + const filteredMentions = data.mentions.filter((item) => { + const sourceKey = String(item.source || '') + const cachedMatched = sourceMatchCache.get(sourceKey) + if (cachedMatched !== undefined) return cachedMatched + const matched = this.sourceAtUserListContainsWithIdentitySet(item.source, identitySet) + if (sourceMatchCache.size < 4096) { + sourceMatchCache.set(sourceKey, matched) + } + return matched + }) + .sort((a, b) => { + if (b.create_time !== a.create_time) return b.create_time - a.create_time + return b.local_id - a.local_id + }) + + let truncatedByFrontendLimit = false + if (mentionLimit > 0 && filteredMentions.length > mentionLimit) { + filteredMentions.length = mentionLimit + truncatedByFrontendLimit = true + } + + const mentionGroupMap = new Map() + for (const mention of filteredMentions) { + const group = mentionGroupMap.get(mention.session_id) || { + session_id: mention.session_id, + count: 0, + latest_ts: 0 + } + group.count += 1 + if (mention.create_time > group.latest_ts) group.latest_ts = mention.create_time + mentionGroupMap.set(mention.session_id, group) + } + + const filteredMentionGroups = Array.from(mentionGroupMap.values()) + .sort((a, b) => { + if (b.count !== a.count) return b.count - a.count + if (b.latest_ts !== a.latest_ts) return b.latest_ts - a.latest_ts + return a.session_id.localeCompare(b.session_id) + }) + + const nextSummary: MyFootprintSummary = { + ...data.summary, + mention_count: filteredMentions.length, + mention_group_count: filteredMentionGroups.length + } + + return { + ...data, + summary: nextSummary, + mentions: filteredMentions, + mention_groups: filteredMentionGroups, + diagnostics: { + ...data.diagnostics, + truncated: Boolean(data.diagnostics.truncated || truncatedByFrontendLimit) + } + } + } + + private mergeMyFootprintMentionResult(base: MyFootprintData, mentionResult: MyFootprintData): MyFootprintData { + const mentionMap = new Map() + const pushMention = (item: MyFootprintMentionItem) => { + const key = `${item.session_id}#${item.local_id}#${item.create_time}` + mentionMap.set(key, item) + } + for (const item of base.mentions) pushMention(item) + for (const item of mentionResult.mentions) pushMention(item) + + const mergedMentions = Array.from(mentionMap.values()) + .sort((a, b) => { + if (b.create_time !== a.create_time) return b.create_time - a.create_time + return b.local_id - a.local_id + }) + + const mentionGroupMetaMap = new Map>() + const pushGroupMeta = (group: MyFootprintMentionGroup) => { + const prev = mentionGroupMetaMap.get(group.session_id) || {} + mentionGroupMetaMap.set(group.session_id, { + displayName: group.displayName || prev.displayName, + avatarUrl: group.avatarUrl || prev.avatarUrl + }) + } + for (const group of base.mention_groups) pushGroupMeta(group) + for (const group of mentionResult.mention_groups) pushGroupMeta(group) + + const mentionGroupMap = new Map() + for (const mention of mergedMentions) { + const current = mentionGroupMap.get(mention.session_id) || { + session_id: mention.session_id, + count: 0, + latest_ts: 0 + } + current.count += 1 + if (mention.create_time > current.latest_ts) { + current.latest_ts = mention.create_time + } + mentionGroupMap.set(mention.session_id, current) + } + + const mergedMentionGroups = Array.from(mentionGroupMap.values()) + .map((group) => { + const meta = mentionGroupMetaMap.get(group.session_id) + return { + ...group, + displayName: meta?.displayName, + avatarUrl: meta?.avatarUrl + } + }) + .sort((a, b) => { + if (b.count !== a.count) return b.count - a.count + if (b.latest_ts !== a.latest_ts) return b.latest_ts - a.latest_ts + return a.session_id.localeCompare(b.session_id) + }) + + return { + ...base, + summary: { + ...base.summary, + mention_count: mergedMentions.length, + mention_group_count: mergedMentionGroups.length + }, + private_segments: mentionResult.private_segments.length > 0 + ? mentionResult.private_segments + : base.private_segments, + mentions: mergedMentions, + mention_groups: mergedMentionGroups, + diagnostics: { + ...base.diagnostics, + truncated: Boolean(base.diagnostics.truncated || mentionResult.diagnostics.truncated), + scanned_dbs: Math.max(base.diagnostics.scanned_dbs || 0, mentionResult.diagnostics.scanned_dbs || 0), + elapsed_ms: Math.max(base.diagnostics.elapsed_ms || 0, mentionResult.diagnostics.elapsed_ms || 0) + } + } + } + + private shouldRunMyFootprintHeavyDebug(): boolean { + const flag = String(process.env.WEFLOW_MY_FOOTPRINT_DEBUG || '').trim().toLowerCase() + return flag === '1' || flag === 'true' || flag === 'yes' || flag === 'on' + } + + private async logMyFootprintZeroMentionDebug(params: { + begin: number + end: number + myWxid: string + groupSessionIds: string[] + nativeData: MyFootprintData + }): Promise { + try { + const identityKeySet = this.buildMyFootprintIdentitySet(params.myWxid) + const identitySet = Array.from(identityKeySet) + console.warn( + `[MyFootprint][debug] zero mentions: myWxid=${params.myWxid} identityKeys=${identitySet.join('|')} groups=${params.groupSessionIds.length} nativeMentions=${params.nativeData.mentions.length} nativeMentionGroups=${params.nativeData.mention_groups.length} scannedDbs=${params.nativeData.diagnostics.scanned_dbs}` + ) + + if (params.nativeData.mentions.length > 0) { + const samples = params.nativeData.mentions.slice(0, 5).map((item) => { + const tokens = this.extractAtUserListTokensFromSource(item.source) + const matched = tokens.some((token) => this.matchesMyFootprintIdentity(token, identityKeySet)) + return { + sessionId: item.session_id, + localId: item.local_id, + createTime: item.create_time, + tokens, + matched + } + }) + console.warn(`[MyFootprint][debug] native mention samples=${JSON.stringify(samples)}`) + } + + const allGroups = params.groupSessionIds + console.warn(`[MyFootprint][debug] start group scan: totalGroups=${allGroups.length}`) + let skippedNoTableGroups = 0 + let sqlProbeCount = 0 + let nativeSingleProbeCount = 0 + for (let index = 0; index < allGroups.length; index += 1) { + const sessionId = allGroups[index] + const cursorResult = await wcdbService.openMessageCursorLite( + sessionId, + 120, + false, + params.begin, + params.end + ) + if (!cursorResult.success || !cursorResult.cursor) { + const openCursorError = String(cursorResult.error || 'unknown') + if (openCursorError.includes('-3')) { + skippedNoTableGroups += 1 + console.warn(`[MyFootprint][debug][${index + 1}/${allGroups.length}][${sessionId}] skipped(no message table): ${openCursorError}`) + } else { + console.warn(`[MyFootprint][debug][${index + 1}/${allGroups.length}][${sessionId}] open cursor failed: ${openCursorError}`) + } + continue + } + + let rows = 0 + let atContentRows = 0 + let sourcePresentRows = 0 + let atUserListRows = 0 + let matchedRows = 0 + const unmatchedSamples: Array<{ + localId: number + createTime: number + tokens: string[] + sourcePreview: string + }> = [] + + let hasMore = true + try { + while (hasMore && rows < 200) { + const batchResult = await wcdbService.fetchMessageBatch(cursorResult.cursor) + if (!batchResult.success || !Array.isArray(batchResult.rows)) { + break + } + hasMore = Boolean(batchResult.hasMore) + for (const row of batchResult.rows as Array>) { + rows += 1 + if (rows > 200) break + + const messageContentRaw = row.message_content ?? row.messageContent ?? row.content + const hasAtInContent = this.footprintMessageLikelyContainsAt(messageContentRaw) + if (hasAtInContent) atContentRows += 1 + + const sourceRaw = row.source ?? row.msg_source ?? row.message_source + if (sourceRaw !== null && sourceRaw !== undefined && String(sourceRaw).trim().length > 0) { + sourcePresentRows += 1 + } + if (!hasAtInContent) continue + + const tokens = this.extractAtUserListTokensFromSource(sourceRaw) + if (tokens.length > 0) atUserListRows += 1 + const matched = tokens.some((token) => this.matchesMyFootprintIdentity(token, identityKeySet)) + if (matched) { + matchedRows += 1 + } else if (tokens.length > 0 && unmatchedSamples.length < 3) { + const sourceDecoded = this.decodeMaybeCompressed(sourceRaw, 'footprint_source') || String(sourceRaw || '') + unmatchedSamples.push({ + localId: this.toSafeInt(row.local_id, 0), + createTime: this.toSafeInt(row.create_time, 0), + tokens, + sourcePreview: sourceDecoded.replace(/\s+/g, ' ').slice(0, 260) + }) + } + } + } + } finally { + await wcdbService.closeMessageCursor(cursorResult.cursor).catch(() => {}) + } + + console.warn( + `[MyFootprint][debug][${index + 1}/${allGroups.length}][${sessionId}] rows=${rows} atContentRows=${atContentRows} sourcePresentRows=${sourcePresentRows} atUserListRows=${atUserListRows} matchedRows=${matchedRows}` + ) + if (unmatchedSamples.length > 0) { + console.warn(`[MyFootprint][debug][${sessionId}] unmatchedSamples=${JSON.stringify(unmatchedSamples)}`) + } + + if ((matchedRows > 0 || atContentRows > 0 || atUserListRows > 0) && sqlProbeCount < 6) { + sqlProbeCount += 1 + await this.logMyFootprintNativeSqlProbe(sessionId, params.begin, params.end) + } + if (matchedRows > 0 && nativeSingleProbeCount < 4) { + nativeSingleProbeCount += 1 + await this.logMyFootprintNativeSingleGroupProbe(sessionId, params.begin, params.end, params.myWxid) + } + } + if (skippedNoTableGroups > 0) { + console.warn(`[MyFootprint][debug] skippedNoTableGroups=${skippedNoTableGroups}/${allGroups.length}`) + } + } catch (error) { + console.warn('[MyFootprint][debug] zero mention diagnostics failed:', error) + } + } + + private async printMyFootprintNativeLogs(tag: string): Promise { + try { + const logsResult = await wcdbService.getLogs() + if (!logsResult.success || !Array.isArray(logsResult.logs)) { + console.warn(`[MyFootprint][native-log][${tag}] getLogs failed: ${logsResult.error || 'unknown'}`) + return + } + + const logs = logsResult.logs + .map((line) => String(line || '').trim()) + .filter(Boolean) + const keywords = [ + 'wcdb_get_my_footprint_stats', + 'message_db_cache_refresh', + 'open_message_cursor', + 'open_message_cursor_lite', + 'cursor_init', + 'schema mismatch', + 'no message db', + 'get_sessions' + ] + const related = logs.filter((line) => { + const lowered = line.toLowerCase() + return keywords.some((keyword) => lowered.includes(keyword.toLowerCase())) + }) + + console.warn( + `[MyFootprint][native-log][${tag}] total=${logs.length} related=${related.length}` + ) + const tail = related.slice(-240) + for (const line of tail) { + console.warn(`[MyFootprint][native-log] ${line}`) + } + } catch (error) { + console.warn(`[MyFootprint][native-log][${tag}] exception:`, error) + } + } + + private async logMyFootprintNativeSqlProbe(sessionId: string, begin: number, end: number): Promise { + try { + const tables = await this.getSessionMessageTables(sessionId) + if (!Array.isArray(tables) || tables.length === 0) { + console.warn(`[MyFootprint][sql-probe][${sessionId}] no tables`) + return + } + + const beginTs = this.normalizeTimestampSeconds(begin) + const endTs = this.normalizeTimestampSeconds(end) + const clauseTime = [ + beginTs > 0 ? `"create_time" >= ${beginTs}` : '', + endTs > 0 ? `"create_time" <= ${endTs}` : '' + ].filter(Boolean).join(' AND ') + const whereParts: string[] = [] + if (clauseTime) whereParts.push(clauseTime) + whereParts.push(`"source" IS NOT NULL`) + whereParts.push(`"source" != ''`) + whereParts.push(`(("message_content" IS NOT NULL AND "message_content" != '' AND (instr("message_content", '@') > 0 OR instr("message_content", '@') > 0)) OR instr(lower("source"), 'atuserlist') > 0)`) + const whereSql = whereParts.length > 0 ? ` WHERE ${whereParts.join(' AND ')}` : '' + + let total = 0 + for (const table of tables) { + const tableName = String(table.tableName || '').trim() + const dbPath = String(table.dbPath || '').trim() + if (!tableName || !dbPath) continue + const sql = `SELECT COUNT(1) AS cnt FROM ${this.quoteSqlIdentifier(tableName)}${whereSql}` + const result = await wcdbService.execQuery('message', dbPath, sql) + if (!result.success || !Array.isArray(result.rows) || result.rows.length === 0) { + console.warn(`[MyFootprint][sql-probe][${sessionId}] query failed db=${dbPath} table=${tableName} err=${result.error || 'unknown'}`) + continue + } + const cnt = this.toSafeInt((result.rows[0] as Record).cnt, 0) + total += cnt + if (cnt > 0) { + console.warn(`[MyFootprint][sql-probe][${sessionId}] db=${dbPath} table=${tableName} cnt=${cnt}`) + } + } + console.warn(`[MyFootprint][sql-probe][${sessionId}] total=${total}`) + } catch (error) { + console.warn(`[MyFootprint][sql-probe][${sessionId}] exception:`, error) + } + } + + private async logMyFootprintNativeSingleGroupProbe(sessionId: string, begin: number, end: number, myWxid: string): Promise { + try { + const probeResult = await wcdbService.getMyFootprintStats({ + beginTimestamp: begin, + endTimestamp: end, + myWxid, + privateSessionIds: [], + groupSessionIds: [sessionId], + mentionLimit: 0, + privateLimit: 0, + mentionMode: 'text_at_me' + }) + if (!probeResult.success || !probeResult.data) { + console.warn(`[MyFootprint][single-native][${sessionId}] failed err=${probeResult.error || 'unknown'}`) + return + } + + const raw = this.normalizeMyFootprintData(probeResult.data) + const first = raw.mentions[0] + console.warn( + `[MyFootprint][single-native][${sessionId}] mentions=${raw.mentions.length} groups=${raw.mention_groups.length} truncated=${raw.diagnostics.truncated} firstLocalId=${first?.local_id || 0} firstTs=${first?.create_time || 0}` + ) + } catch (error) { + console.warn(`[MyFootprint][single-native][${sessionId}] exception:`, error) + } + } + + private async getMyFootprintStatsByCursorFallback(params: { + begin: number + end: number + myWxid: string + privateSessionIds: string[] + groupSessionIds: string[] + mentionLimit: number + privateLimit: number + skipPrivateScan?: boolean + mentionScanLimitPerGroup?: number + }): Promise<{ success: boolean; data?: MyFootprintData; error?: string }> { + const startedAt = Date.now() + let truncated = false + + try { + const privateSessionMap = new Map() + type PrivateSegmentWorking = { + segment_index: number + start_ts: number + end_ts: number + incoming_count: number + outgoing_count: number + first_incoming_ts: number + first_reply_ts: number + anchor_local_id: number + anchor_create_time: number + latest_local_id: number + latest_create_time: number + } + const privateSegments: MyFootprintPrivateSegment[] = [] + const mentionGroupsMap = new Map() + const mentions: MyFootprintMentionItem[] = [] + const mentionIdentitySet = this.buildMyFootprintIdentitySet(params.myWxid) + const mentionSourceMatchCache = new Map() + const mentionScanLimit = Number.isFinite(params.mentionScanLimitPerGroup as number) + ? Math.max(60, Math.floor(Number(params.mentionScanLimitPerGroup))) + : Math.max(params.mentionLimit * 12, 4000) + const privateScanLimitPerSession = Math.max( + 120, + Math.min( + 600, + Math.floor((params.privateLimit * 2) / Math.max(params.privateSessionIds.length || 1, 1)) + ) + ) + const privateBatchSize = Math.min(200, privateScanLimitPerSession) + const privateSessionGapSeconds = 10 * 60 + const mentionBatchSize = 360 + const skipPrivateScan = params.skipPrivateScan === true + + if (!skipPrivateScan) for (const sessionId of params.privateSessionIds) { + const cursorResult = await wcdbService.openMessageCursorLite( + sessionId, + privateBatchSize, + true, + params.begin, + params.end + ) + if (!cursorResult.success || !cursorResult.cursor) continue + + const stat: MyFootprintPrivateSession = { + session_id: sessionId, + incoming_count: 0, + outgoing_count: 0, + replied: false, + first_incoming_ts: 0, + first_reply_ts: 0, + latest_ts: 0, + anchor_local_id: 0, + anchor_create_time: 0 + } + let segmentCursor = 0 + let activeSegment: PrivateSegmentWorking | null = null + let lastSegmentMessageTs = 0 + const commitActiveSegment = () => { + if (!activeSegment) return + + const normalizedStart = activeSegment.start_ts > 0 ? activeSegment.start_ts : activeSegment.anchor_create_time + const normalizedEnd = activeSegment.end_ts > 0 ? activeSegment.end_ts : normalizedStart + const incomingCount = Math.max(0, activeSegment.incoming_count) + const outgoingCount = Math.max(0, activeSegment.outgoing_count) + const messageCount = incomingCount + outgoingCount + if (normalizedStart > 0 && messageCount > 0) { + privateSegments.push({ + session_id: sessionId, + segment_index: activeSegment.segment_index, + start_ts: normalizedStart, + end_ts: normalizedEnd, + duration_sec: Math.max(0, normalizedEnd - normalizedStart), + incoming_count: incomingCount, + outgoing_count: outgoingCount, + message_count: messageCount, + replied: incomingCount > 0 && outgoingCount > 0, + first_incoming_ts: activeSegment.first_incoming_ts, + first_reply_ts: activeSegment.first_reply_ts, + latest_ts: normalizedEnd, + anchor_local_id: activeSegment.anchor_local_id, + anchor_create_time: normalizedStart + }) + } + activeSegment = null + } + + let processed = 0 + let hasMore = true + try { + while (hasMore) { + const batchResult = await wcdbService.fetchMessageBatch(cursorResult.cursor) + if (!batchResult.success || !Array.isArray(batchResult.rows)) { + break + } + hasMore = Boolean(batchResult.hasMore) + for (const row of batchResult.rows as Array>) { + if (processed >= privateScanLimitPerSession) { + if (hasMore || batchResult.rows.length > 0) truncated = true + hasMore = false + break + } + processed += 1 + + const createTime = this.toSafeInt(row.create_time, 0) + const localId = this.toSafeInt(row.local_id, 0) + const isSend = this.resolveFootprintRowIsSend(row, params.myWxid) + + if (createTime > 0) { + const startNewSegment = !activeSegment + || (lastSegmentMessageTs > 0 && createTime - lastSegmentMessageTs > privateSessionGapSeconds) + if (startNewSegment) { + commitActiveSegment() + segmentCursor += 1 + activeSegment = { + segment_index: segmentCursor, + start_ts: createTime, + end_ts: createTime, + incoming_count: 0, + outgoing_count: 0, + first_incoming_ts: 0, + first_reply_ts: 0, + anchor_local_id: localId, + anchor_create_time: createTime, + latest_local_id: localId, + latest_create_time: createTime + } + } + } else if (!activeSegment) { + segmentCursor += 1 + activeSegment = { + segment_index: segmentCursor, + start_ts: 0, + end_ts: 0, + incoming_count: 0, + outgoing_count: 0, + first_incoming_ts: 0, + first_reply_ts: 0, + anchor_local_id: localId, + anchor_create_time: 0, + latest_local_id: localId, + latest_create_time: 0 + } + } + + if (isSend) { + stat.outgoing_count += 1 + if ( + createTime > 0 + && stat.first_incoming_ts > 0 + && createTime >= stat.first_incoming_ts + && stat.first_reply_ts <= 0 + ) { + stat.first_reply_ts = createTime + } + if (activeSegment) { + activeSegment.outgoing_count += 1 + if ( + createTime > 0 + && activeSegment.first_incoming_ts > 0 + && createTime >= activeSegment.first_incoming_ts + && activeSegment.first_reply_ts <= 0 + ) { + activeSegment.first_reply_ts = createTime + } + } + } else { + stat.incoming_count += 1 + if (stat.first_incoming_ts <= 0 || (createTime > 0 && createTime < stat.first_incoming_ts)) { + stat.first_incoming_ts = createTime + } + if (activeSegment) { + activeSegment.incoming_count += 1 + if (activeSegment.first_incoming_ts <= 0 || (createTime > 0 && createTime < activeSegment.first_incoming_ts)) { + activeSegment.first_incoming_ts = createTime + } + } + } + + if (stat.latest_ts <= 0 || createTime > stat.latest_ts || (createTime === stat.latest_ts && localId > stat.anchor_local_id)) { + stat.latest_ts = createTime + stat.anchor_local_id = localId + stat.anchor_create_time = createTime + } + + if (activeSegment && createTime > 0) { + activeSegment.end_ts = createTime + activeSegment.latest_create_time = createTime + activeSegment.latest_local_id = localId + lastSegmentMessageTs = createTime + } + } + } + if (hasMore) truncated = true + } finally { + await wcdbService.closeMessageCursor(cursorResult.cursor).catch(() => {}) + } + commitActiveSegment() + stat.replied = stat.incoming_count > 0 && stat.outgoing_count > 0 + + if (stat.incoming_count > 0 || stat.outgoing_count > 0 || stat.latest_ts > 0) { + privateSessionMap.set(sessionId, stat) + } + } + + for (const sessionId of params.groupSessionIds) { + if (mentions.length >= params.mentionLimit) { + truncated = true + break + } + const cursorResult = await wcdbService.openMessageCursorLite( + sessionId, + mentionBatchSize, + false, + params.begin, + params.end + ) + if (!cursorResult.success || !cursorResult.cursor) continue + + let scanned = 0 + let hasMore = true + try { + while (hasMore && scanned < mentionScanLimit) { + const batchResult = await wcdbService.fetchMessageBatch(cursorResult.cursor) + if (!batchResult.success || !Array.isArray(batchResult.rows)) { + break + } + hasMore = Boolean(batchResult.hasMore) + for (const row of batchResult.rows as Array>) { + if (mentions.length >= params.mentionLimit) { + truncated = true + hasMore = false + break + } + scanned += 1 + const messageContentRaw = row.message_content ?? row.messageContent ?? row.content + if (!this.footprintMessageLikelyContainsAt(messageContentRaw)) continue + const sourceRaw = row.source ?? row.msg_source ?? row.message_source + let sourceMatched = false + if (typeof sourceRaw === 'string') { + const sourceKey = sourceRaw + const cachedMatched = mentionSourceMatchCache.get(sourceKey) + if (cachedMatched !== undefined) { + sourceMatched = cachedMatched + } else { + sourceMatched = this.sourceAtUserListContainsWithIdentitySet(sourceRaw, mentionIdentitySet) + if (mentionSourceMatchCache.size < 8192) { + mentionSourceMatchCache.set(sourceKey, sourceMatched) + } + } + } else { + sourceMatched = this.sourceAtUserListContainsWithIdentitySet(sourceRaw, mentionIdentitySet) + } + if (!sourceMatched) continue + const normalizedSource = this.normalizeFootprintSourceForOutput(sourceRaw) + + let senderUsername = String(row.sender_username || row.senderUsername || '').trim() + if (!senderUsername && row._db_path && row.real_sender_id) { + senderUsername = await this.resolveMessageSenderUsernameById( + String(row._db_path), + row.real_sender_id + ) || '' + } + + const mention: MyFootprintMentionItem = { + session_id: sessionId, + local_id: this.toSafeInt(row.local_id, 0), + create_time: this.toSafeInt(row.create_time, 0), + sender_username: senderUsername, + message_content: String(row.message_content || row.messageContent || ''), + source: normalizedSource + } + mentions.push(mention) + + const group = mentionGroupsMap.get(sessionId) || { + session_id: sessionId, + count: 0, + latest_ts: 0 + } + group.count += 1 + if (mention.create_time > group.latest_ts) group.latest_ts = mention.create_time + mentionGroupsMap.set(sessionId, group) + } + } + if (hasMore || scanned >= mentionScanLimit) { + truncated = true + } + } finally { + await wcdbService.closeMessageCursor(cursorResult.cursor).catch(() => {}) + } + } + + mentions.sort((a, b) => { + if (b.create_time !== a.create_time) return b.create_time - a.create_time + return b.local_id - a.local_id + }) + if (mentions.length > params.mentionLimit) { + mentions.length = params.mentionLimit + truncated = true + } + + const private_sessions = Array.from(privateSessionMap.values()) + .sort((a, b) => { + if (b.latest_ts !== a.latest_ts) return b.latest_ts - a.latest_ts + return a.session_id.localeCompare(b.session_id) + }) + const private_segments = [...privateSegments] + .sort((a, b) => { + if (a.start_ts !== b.start_ts) return a.start_ts - b.start_ts + if (a.session_id !== b.session_id) return a.session_id.localeCompare(b.session_id) + return a.segment_index - b.segment_index + }) + const mention_groups = Array.from(mentionGroupsMap.values()) + .sort((a, b) => { + if (b.count !== a.count) return b.count - a.count + if (b.latest_ts !== a.latest_ts) return b.latest_ts - a.latest_ts + return a.session_id.localeCompare(b.session_id) + }) + + const private_inbound_people = private_sessions.filter((item) => item.incoming_count > 0).length + const private_replied_people = private_sessions.filter((item) => item.replied).length + const private_outbound_people = private_sessions.filter((item) => item.outgoing_count > 0).length + const mention_count = mention_groups.reduce((sum, item) => sum + item.count, 0) + const mention_group_count = mention_groups.length + + const summary: MyFootprintSummary = { + private_inbound_people, + private_replied_people, + private_outbound_people, + private_reply_rate: private_inbound_people > 0 ? private_replied_people / private_inbound_people : 0, + mention_count, + mention_group_count + } + + const diagnostics: MyFootprintDiagnostics = { + truncated, + scanned_dbs: 0, + elapsed_ms: Math.max(0, Date.now() - startedAt) + } + + return { + success: true, + data: { + summary, + private_sessions, + private_segments, + mentions, + mention_groups, + diagnostics + } + } + } catch (error) { + return { success: false, error: String(error) } + } + } + + private async enrichMyFootprintData(data: MyFootprintData): Promise { + try { + const sessionIds = Array.from(new Set([ + ...data.private_sessions.map((item) => item.session_id), + ...data.private_segments.map((item) => item.session_id), + ...data.mention_groups.map((item) => item.session_id), + ...data.mentions.map((item) => item.session_id) + ].filter(Boolean))) + const senderUsernames = Array.from(new Set( + data.mentions + .map((item) => item.sender_username) + .filter((value) => String(value || '').trim()) + )) + + const usernames = Array.from(new Set([...sessionIds, ...senderUsernames])) + if (usernames.length === 0) return data + + const enrichResult = await this.enrichSessionsContactInfo(usernames) + if (!enrichResult.success || !enrichResult.contacts) return data + const contacts = enrichResult.contacts + + const nextPrivateSessions = data.private_sessions.map((item) => { + const contact = contacts[item.session_id] + return { + ...item, + displayName: contact?.displayName || item.displayName, + avatarUrl: contact?.avatarUrl || item.avatarUrl + } + }) + const nextPrivateSegments = data.private_segments.map((item) => { + const contact = contacts[item.session_id] + return { + ...item, + displayName: contact?.displayName || item.displayName, + avatarUrl: contact?.avatarUrl || item.avatarUrl + } + }) + + const nextMentionGroups = data.mention_groups.map((item) => { + const contact = contacts[item.session_id] + return { + ...item, + displayName: contact?.displayName || item.displayName, + avatarUrl: contact?.avatarUrl || item.avatarUrl + } + }) + + const nextMentions = await Promise.all(data.mentions.map(async (item) => { + const sessionContact = contacts[item.session_id] + const senderContact = item.sender_username ? contacts[item.sender_username] : undefined + + let normalizedContent = this.normalizeMyFootprintMentionContent(item.message_content) + if (this.isLikelyUnreadableFootprintContent(normalizedContent) && item.session_id && item.local_id > 0) { + const detailResult = await this.getMessageById(item.session_id, item.local_id) + if (detailResult.success && detailResult.message) { + const detailMessage = detailResult.message + const detailRaw = String( + detailMessage.rawContent + || detailMessage.content + || detailMessage.parsedContent + || '' + ) + const resolvedFromDetail = this.normalizeMyFootprintMentionContent(detailRaw) + if (resolvedFromDetail && !this.isLikelyUnreadableFootprintContent(resolvedFromDetail)) { + normalizedContent = resolvedFromDetail + } else { + const parsedFallback = String(detailMessage.parsedContent || '').trim() + if (parsedFallback && !this.isLikelyUnreadableFootprintContent(parsedFallback)) { + normalizedContent = parsedFallback + } + } + } + } + + return { + ...item, + message_content: normalizedContent, + sessionDisplayName: sessionContact?.displayName || item.sessionDisplayName, + senderDisplayName: senderContact?.displayName || item.senderDisplayName || item.sender_username, + senderAvatarUrl: senderContact?.avatarUrl || item.senderAvatarUrl + } + })) + + return { + ...data, + private_sessions: nextPrivateSessions, + private_segments: nextPrivateSegments, + mention_groups: nextMentionGroups, + mentions: nextMentions + } + } catch (error) { + console.error('[ChatService] 补充我的足迹展示信息失败:', error) + return data + } + } + + private normalizeMyFootprintMentionContent(rawContent: unknown): string { + const decodedRaw = this.decodeMaybeCompressed(rawContent, 'footprint_message_content') + let content = String(decodedRaw || rawContent || '') + if (!content) return '' + + content = this.cleanUtf16(this.decodeHtmlEntities(content)).trim() + if (!content) return '' + + const looksLikeXml = content.includes('')) return true + return false + } + + private formatFootprintTime(timestamp: number): string { + if (!Number.isFinite(timestamp) || timestamp <= 0) return '' + const date = new Date(timestamp * 1000) + const y = date.getFullYear() + const m = `${date.getMonth() + 1}`.padStart(2, '0') + const d = `${date.getDate()}`.padStart(2, '0') + const hh = `${date.getHours()}`.padStart(2, '0') + const mm = `${date.getMinutes()}`.padStart(2, '0') + const ss = `${date.getSeconds()}`.padStart(2, '0') + return `${y}-${m}-${d} ${hh}:${mm}:${ss}` + } + + private escapeCsvCell(value: unknown): string { + const text = String(value ?? '') + if (!text) return '' + if (!/[",\n\r]/.test(text)) return text + return `"${text.replace(/"/g, '""')}"` + } + + private buildMyFootprintCsv(data: MyFootprintData): string { + const lines: string[] = [] + const pushRow = (...columns: unknown[]) => { + lines.push(columns.map((value) => this.escapeCsvCell(value)).join(',')) + } + + pushRow('模块', '指标', '数值') + pushRow('summary', '私聊找我人数', data.summary.private_inbound_people) + pushRow('summary', '我回复人数', data.summary.private_replied_people) + pushRow('summary', '我主动联系人数', data.summary.private_outbound_people) + pushRow('summary', '私聊回复率', data.summary.private_reply_rate) + pushRow('summary', '@我次数', data.summary.mention_count) + pushRow('summary', '@我群聊数', data.summary.mention_group_count) + pushRow('summary', '诊断:是否截断', data.diagnostics.truncated ? 'true' : 'false') + pushRow('summary', '诊断:扫描分库数', data.diagnostics.scanned_dbs) + pushRow('summary', '诊断:耗时ms', data.diagnostics.elapsed_ms) + + lines.push('') + pushRow('private_sessions', 'session_id', 'display_name', 'incoming_count', 'outgoing_count', 'replied', 'first_incoming_ts', 'first_reply_ts', 'latest_ts', 'anchor_local_id', 'anchor_create_time') + for (const row of data.private_sessions) { + pushRow( + 'private_sessions', + row.session_id, + row.displayName || '', + row.incoming_count, + row.outgoing_count, + row.replied ? 'true' : 'false', + this.formatFootprintTime(row.first_incoming_ts), + this.formatFootprintTime(row.first_reply_ts), + this.formatFootprintTime(row.latest_ts), + row.anchor_local_id, + row.anchor_create_time + ) + } + + lines.push('') + pushRow( + 'private_segments', + 'session_id', + 'display_name', + 'segment_index', + 'start_ts', + 'end_ts', + 'duration_sec', + 'incoming_count', + 'outgoing_count', + 'message_count', + 'replied', + 'first_incoming_ts', + 'first_reply_ts', + 'latest_ts', + 'anchor_local_id', + 'anchor_create_time' + ) + for (const row of data.private_segments) { + pushRow( + 'private_segments', + row.session_id, + row.displayName || '', + row.segment_index, + this.formatFootprintTime(row.start_ts), + this.formatFootprintTime(row.end_ts), + row.duration_sec, + row.incoming_count, + row.outgoing_count, + row.message_count, + row.replied ? 'true' : 'false', + this.formatFootprintTime(row.first_incoming_ts), + this.formatFootprintTime(row.first_reply_ts), + this.formatFootprintTime(row.latest_ts), + row.anchor_local_id, + row.anchor_create_time + ) + } + + lines.push('') + pushRow('mentions', 'session_id', 'session_display_name', 'local_id', 'create_time', 'sender_username', 'sender_display_name', 'message_content', 'source') + for (const row of data.mentions) { + pushRow( + 'mentions', + row.session_id, + row.sessionDisplayName || '', + row.local_id, + this.formatFootprintTime(row.create_time), + row.sender_username, + row.senderDisplayName || '', + row.message_content, + row.source + ) + } + + lines.push('') + pushRow('mention_groups', 'session_id', 'display_name', 'count', 'latest_ts') + for (const row of data.mention_groups) { + pushRow( + 'mention_groups', + row.session_id, + row.displayName || '', + row.count, + this.formatFootprintTime(row.latest_ts) + ) + } + + return lines.join('\n') + } + private async parseMessage(row: any, options?: { source?: 'search' | 'detail'; sessionId?: string }): Promise { const sourceInfo = this.getMessageSourceInfo(row) const rawContent = this.decodeMessageContent( diff --git a/electron/services/config.ts b/electron/services/config.ts index 87029c9..f7b6f65 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -61,6 +61,8 @@ interface ConfigSchema { notificationFilterMode: 'all' | 'whitelist' | 'blacklist' notificationFilterList: string[] messagePushEnabled: boolean + messagePushFilterMode: 'all' | 'whitelist' | 'blacklist' + messagePushFilterList: string[] httpApiEnabled: boolean httpApiPort: number httpApiHost: string @@ -69,8 +71,12 @@ interface ConfigSchema { quoteLayout: 'quote-top' | 'quote-bottom' wordCloudExcludeWords: string[] exportWriteLayout: 'A' | 'B' | 'C' + exportAutomationTaskMap: Record // AI 见解 + aiModelApiBaseUrl: string + aiModelApiKey: string + aiModelApiModel: string aiInsightEnabled: boolean aiInsightApiBaseUrl: string aiInsightApiKey: string @@ -93,10 +99,23 @@ interface ConfigSchema { aiInsightTelegramToken: string /** Telegram 接收 Chat ID,逗号分隔,支持多个 */ aiInsightTelegramChatIds: string + + // AI 足迹 + aiFootprintEnabled: boolean + aiFootprintSystemPrompt: string + /** 是否将 AI 见解调试日志输出到桌面 */ + aiInsightDebugLogEnabled: boolean } // 需要 safeStorage 加密的字段(普通模式) -const ENCRYPTED_STRING_KEYS: Set = new Set(['decryptKey', 'imageAesKey', 'authPassword', 'httpApiToken', 'aiInsightApiKey']) +const ENCRYPTED_STRING_KEYS: Set = new Set([ + 'decryptKey', + 'imageAesKey', + 'authPassword', + 'httpApiToken', + 'aiModelApiKey', + 'aiInsightApiKey' +]) const ENCRYPTED_BOOL_KEYS: Set = new Set(['authEnabled', 'authUseHello']) const ENCRYPTED_NUMBER_KEYS: Set = new Set(['imageXorKey']) @@ -163,10 +182,16 @@ export class ConfigService { httpApiPort: 5031, httpApiHost: '127.0.0.1', messagePushEnabled: false, + messagePushFilterMode: 'all', + messagePushFilterList: [], windowCloseBehavior: 'ask', quoteLayout: 'quote-top', wordCloudExcludeWords: [], exportWriteLayout: 'A', + exportAutomationTaskMap: {}, + aiModelApiBaseUrl: '', + aiModelApiKey: '', + aiModelApiModel: 'gpt-4o-mini', aiInsightEnabled: false, aiInsightApiBaseUrl: '', aiInsightApiKey: '', @@ -181,7 +206,10 @@ export class ConfigService { aiInsightSystemPrompt: '', aiInsightTelegramEnabled: false, aiInsightTelegramToken: '', - aiInsightTelegramChatIds: '' + aiInsightTelegramChatIds: '', + aiFootprintEnabled: false, + aiFootprintSystemPrompt: '', + aiInsightDebugLogEnabled: false } const storeOptions: any = { @@ -213,6 +241,7 @@ export class ConfigService { } } this.migrateAuthFields() + this.migrateAiConfig() } // === 状态查询 === @@ -270,7 +299,9 @@ export class ConfigService { const inLockMode = this.isLockMode() && this.unlockPassword if (ENCRYPTED_BOOL_KEYS.has(key)) { - toStore = this.safeEncrypt(String(value)) as ConfigSchema[K] + const boolValue = value === true || value === 'true' + // `false` 不需要写入 keychain,避免无意义触发 macOS 钥匙串弹窗 + toStore = (boolValue ? this.safeEncrypt('true') : false) as ConfigSchema[K] } else if (ENCRYPTED_NUMBER_KEYS.has(key)) { if (inLockMode && LOCKABLE_NUMBER_KEYS.has(key)) { toStore = this.lockEncrypt(String(value), this.unlockPassword!) as ConfigSchema[K] @@ -649,7 +680,7 @@ export class ConfigService { clearHelloSecret(): void { this.store.set('authHelloSecret', '' as any) - this.store.set('authUseHello', this.safeEncrypt('false') as any) + this.store.set('authUseHello', false as any) } // === 迁移 === @@ -658,13 +689,18 @@ export class ConfigService { // 将旧版明文 auth 字段迁移为 safeStorage 加密格式 // 如果已经是 safe: 或 lock: 前缀则跳过 const rawEnabled: any = this.store.get('authEnabled') - if (typeof rawEnabled === 'boolean') { - this.store.set('authEnabled', this.safeEncrypt(String(rawEnabled)) as any) + if (rawEnabled === true || rawEnabled === 'true') { + this.store.set('authEnabled', this.safeEncrypt('true') as any) + } else if (rawEnabled === false || rawEnabled === 'false') { + // 保持 false 为明文布尔,避免冷启动访问 keychain + this.store.set('authEnabled', false as any) } const rawUseHello: any = this.store.get('authUseHello') - if (typeof rawUseHello === 'boolean') { - this.store.set('authUseHello', this.safeEncrypt(String(rawUseHello)) as any) + if (rawUseHello === true || rawUseHello === 'true') { + this.store.set('authUseHello', this.safeEncrypt('true') as any) + } else if (rawUseHello === false || rawUseHello === 'false') { + this.store.set('authUseHello', false as any) } const rawPassword: any = this.store.get('authPassword') @@ -710,6 +746,26 @@ export class ConfigService { } } + private migrateAiConfig(): void { + const sharedBaseUrl = String(this.get('aiModelApiBaseUrl') || '').trim() + const sharedApiKey = String(this.get('aiModelApiKey') || '').trim() + const sharedModel = String(this.get('aiModelApiModel') || '').trim() + + const legacyBaseUrl = String(this.get('aiInsightApiBaseUrl') || '').trim() + const legacyApiKey = String(this.get('aiInsightApiKey') || '').trim() + const legacyModel = String(this.get('aiInsightApiModel') || '').trim() + + if (!sharedBaseUrl && legacyBaseUrl) { + this.set('aiModelApiBaseUrl', legacyBaseUrl) + } + if (!sharedApiKey && legacyApiKey) { + this.set('aiModelApiKey', legacyApiKey) + } + if (!sharedModel && legacyModel) { + this.set('aiModelApiModel', legacyModel) + } + } + // === 验证 === verifyAuthEnabled(): boolean { @@ -729,7 +785,7 @@ export class ConfigService { // === 工具方法 === /** - * 获取当前 wxid 对应的图片密钥,优先从 wxidConfigs 中取,找不到则回退到全局��置 + * 获取当前 wxid 对应的图片密钥,优先从 wxidConfigs 中取,找不到则回退到全局配置 */ getImageKeysForCurrentWxid(): { xorKey: unknown; aesKey: string } { const wxid = this.get('myWxid') diff --git a/electron/services/exportRecordService.ts b/electron/services/exportRecordService.ts index 23c82a9..5ff1049 100644 --- a/electron/services/exportRecordService.ts +++ b/electron/services/exportRecordService.ts @@ -19,7 +19,8 @@ class ExportRecordService { private resolveFilePath(): string { if (this.filePath) return this.filePath - const userDataPath = app.getPath('userData') + const workerUserDataPath = String(process.env.WEFLOW_USER_DATA_PATH || process.env.WEFLOW_CONFIG_CWD || '').trim() + const userDataPath = workerUserDataPath || app?.getPath?.('userData') || process.cwd() fs.mkdirSync(userDataPath, { recursive: true }) this.filePath = path.join(userDataPath, 'weflow-export-records.json') return this.filePath diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 2512f72..2717718 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -92,6 +92,7 @@ export interface ExportOptions { dateRange?: { start: number; end: number } | null senderUsername?: string fileNameSuffix?: string + fileNamingMode?: 'classic' | 'date-range' exportMedia?: boolean exportAvatars?: boolean exportImages?: boolean @@ -494,6 +495,80 @@ class ExportService { } } + private sanitizeExportFileNamePart(value: string): string { + return String(value || '') + .replace(/[<>:"\/\\|?*]/g, '_') + .replace(/\.+$/, '') + .trim() + } + + private normalizeFileNamingMode(value: unknown): 'classic' | 'date-range' { + return String(value || '').trim().toLowerCase() === 'date-range' ? 'date-range' : 'classic' + } + + private formatDateTokenBySeconds(seconds?: number): string | null { + if (!Number.isFinite(seconds) || (seconds || 0) <= 0) return null + const date = new Date(Math.floor(Number(seconds)) * 1000) + if (Number.isNaN(date.getTime())) return null + const y = date.getFullYear() + const m = `${date.getMonth() + 1}`.padStart(2, '0') + const d = `${date.getDate()}`.padStart(2, '0') + return `${y}${m}${d}` + } + + private buildDateRangeFileNamePart(dateRange?: { start: number; end: number } | null): string { + const start = this.formatDateTokenBySeconds(dateRange?.start) + const end = this.formatDateTokenBySeconds(dateRange?.end) + if (start && end) { + if (start === end) return start + return start < end ? `${start}-${end}` : `${end}-${start}` + } + if (start) return `${start}-至今` + if (end) return `截至-${end}` + return '全部时间' + } + + private buildSessionExportBaseName( + sessionId: string, + displayName: string, + options: ExportOptions + ): string { + const baseName = this.sanitizeExportFileNamePart(displayName || sessionId) || this.sanitizeExportFileNamePart(sessionId) || 'session' + const suffix = this.sanitizeExportFileNamePart(options.fileNameSuffix || '') + const namingMode = this.normalizeFileNamingMode(options.fileNamingMode) + const parts = [baseName] + if (suffix) parts.push(suffix) + if (namingMode === 'date-range') { + parts.push(this.buildDateRangeFileNamePart(options.dateRange)) + } + return this.sanitizeExportFileNamePart(parts.join('_')) || 'session' + } + + private async reserveUniqueOutputPath(preferredPath: string, reservedPaths: Set): Promise { + const dir = path.dirname(preferredPath) + const ext = path.extname(preferredPath) + const base = path.basename(preferredPath, ext) + + for (let attempt = 0; attempt < 10000; attempt += 1) { + const candidate = attempt === 0 + ? preferredPath + : path.join(dir, `${base}_${attempt + 1}${ext}`) + + if (reservedPaths.has(candidate)) continue + + const exists = await this.pathExists(candidate) + if (reservedPaths.has(candidate)) continue + if (exists) continue + + reservedPaths.add(candidate) + return candidate + } + + const fallback = path.join(dir, `${base}_${Date.now()}${ext}`) + reservedPaths.add(fallback) + return fallback + } + private isCloneUnsupportedError(code: string | undefined): boolean { return code === 'ENOTSUP' || code === 'ENOSYS' || code === 'EINVAL' || code === 'EXDEV' || code === 'ENOTTY' } @@ -2044,6 +2119,7 @@ class ExportService { } return title || '[引用消息]' } + if (xmlType === '53') return title ? `[接龙] ${title.split(/\r?\n/).map(line => line.trim()).find(Boolean) || title}` : '[接龙]' if (xmlType === '5' || xmlType === '49') return title ? `[链接] ${title}` : '[链接]' // 有 title 就返回 title @@ -3145,6 +3221,8 @@ class ExportService { appMsgKind = 'announcement' } else if (xmlType === '57' || hasReferMsg || localType === 244813135921) { appMsgKind = 'quote' + } else if (xmlType === '53') { + appMsgKind = 'solitaire' } else if (xmlType === '5' || xmlType === '49') { appMsgKind = 'link' } else if (looksLikeAppMsg) { @@ -8911,6 +8989,7 @@ class ExportService { ? path.join(outputDir, 'texts') : outputDir const createdTaskDirs = new Set() + const reservedOutputPaths = new Set() const ensureTaskDir = async (dirPath: string) => { if (createdTaskDirs.has(dirPath)) return await fs.promises.mkdir(dirPath, { recursive: true }) @@ -9159,10 +9238,8 @@ class ExportService { phaseLabel: '准备导出' }) - const sanitizeName = (value: string) => value.replace(/[<>:"\/\\|?*]/g, '_').replace(/\.+$/, '').trim() - const baseName = sanitizeName(sessionInfo.displayName || sessionId) || sanitizeName(sessionId) || 'session' - const suffix = sanitizeName(effectiveOptions.fileNameSuffix || '') - const safeName = suffix ? `${baseName}_${suffix}` : baseName + const fileNamingMode = this.normalizeFileNamingMode(effectiveOptions.fileNamingMode) + const safeName = this.buildSessionExportBaseName(sessionId, sessionInfo.displayName, effectiveOptions) const sessionNameWithTypePrefix = effectiveOptions.sessionNameWithTypePrefix !== false const sessionTypePrefix = sessionNameWithTypePrefix ? await this.getSessionFilePrefix(sessionId) : '' const fileNameWithPrefix = `${sessionTypePrefix}${safeName}` @@ -9180,13 +9257,13 @@ class ExportService { else if (effectiveOptions.format === 'txt') ext = '.txt' else if (effectiveOptions.format === 'weclone') ext = '.csv' else if (effectiveOptions.format === 'html') ext = '.html' - const outputPath = path.join(sessionDir, `${fileNameWithPrefix}${ext}`) + const preferredOutputPath = path.join(sessionDir, `${fileNameWithPrefix}${ext}`) const canTrySkipUnchanged = canTrySkipUnchangedTextSessions && typeof messageCountHint === 'number' && messageCountHint >= 0 && typeof latestTimestampHint === 'number' && latestTimestampHint > 0 && - await this.pathExists(outputPath) + await this.pathExists(preferredOutputPath) if (canTrySkipUnchanged) { const latestRecord = exportRecordService.getLatestRecord(sessionId, effectiveOptions.format) const hasNoDataChange = Boolean( @@ -9213,6 +9290,10 @@ class ExportService { } } + const outputPath = fileNamingMode === 'date-range' + ? await this.reserveUniqueOutputPath(preferredOutputPath, reservedOutputPaths) + : preferredOutputPath + let result: { success: boolean; error?: string } if (effectiveOptions.format === 'json' || effectiveOptions.format === 'arkme-json') { result = await this.exportSessionToDetailedJson(sessionId, outputPath, effectiveOptions, sessionProgress, control) diff --git a/electron/services/imageDecryptService.ts b/electron/services/imageDecryptService.ts index 34f755c..84c908c 100644 --- a/electron/services/imageDecryptService.ts +++ b/electron/services/imageDecryptService.ts @@ -949,7 +949,7 @@ export class ImageDecryptService { } catch { } } - // --- 绛栫暐 B: 鏂扮増 Session 鍝堝笇璺緞鐚滄祴 --- + // --- 策略 B: 新版 Session 哈希路径猜测 --- try { const entries = await fs.readdir(root, { withFileTypes: true }) const sessionDirs = entries @@ -1854,7 +1854,7 @@ export class ImageDecryptService { } /** - * 浠?wxgf 鏁版嵁涓彁鍙?HEVC NALU 瑁告祦 + * 从 wxgf 数据中提取 HEVC NALU 裸流 */ private extractHevcNalu(buffer: Buffer): Buffer | null { const nalUnits: Buffer[] = [] diff --git a/electron/services/insightService.ts b/electron/services/insightService.ts index 3d7a092..911af51 100644 --- a/electron/services/insightService.ts +++ b/electron/services/insightService.ts @@ -15,8 +15,10 @@ import https from 'https' import http from 'http' +import fs from 'fs' +import path from 'path' import { URL } from 'url' -import { Notification } from 'electron' +import { app, Notification } from 'electron' import { ConfigService } from './config' import { chatService, ChatSession, Message } from './chatService' @@ -33,12 +35,17 @@ const SILENCE_SCAN_INITIAL_DELAY_MS = 3 * 60 * 1000 /** 单次 API 请求超时(毫秒) */ const API_TIMEOUT_MS = 45_000 +const API_MAX_TOKENS = 200 +const API_TEMPERATURE = 0.7 /** 沉默天数阈值默认值 */ const DEFAULT_SILENCE_DAYS = 3 const INSIGHT_CONFIG_KEYS = new Set([ 'aiInsightEnabled', 'aiInsightScanIntervalHours', + 'aiModelApiBaseUrl', + 'aiModelApiKey', + 'aiModelApiModel', 'dbPath', 'decryptKey', 'myWxid' @@ -51,17 +58,82 @@ interface TodayTriggerRecord { timestamps: number[] } +interface SharedAiModelConfig { + apiBaseUrl: string + apiKey: string + model: string +} + // ─── 日志 ───────────────────────────────────────────────────────────────────── +type InsightLogLevel = 'INFO' | 'WARN' | 'ERROR' + +let debugLogWriteQueue: Promise = Promise.resolve() + +function formatDebugTimestamp(date: Date = new Date()): string { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + const hours = String(date.getHours()).padStart(2, '0') + const minutes = String(date.getMinutes()).padStart(2, '0') + const seconds = String(date.getSeconds()).padStart(2, '0') + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}` +} + +function getInsightDebugLogFilePath(date: Date = new Date()): string { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return path.join(app.getPath('desktop'), `weflow-ai-insight-debug-${year}-${month}-${day}.log`) +} + +function isInsightDebugLogEnabled(): boolean { + try { + return ConfigService.getInstance().get('aiInsightDebugLogEnabled') === true + } catch { + return false + } +} + +function appendInsightDebugText(text: string): void { + if (!isInsightDebugLogEnabled()) return + + let logFilePath = '' + try { + logFilePath = getInsightDebugLogFilePath() + } catch { + return + } + + debugLogWriteQueue = debugLogWriteQueue + .then(() => fs.promises.appendFile(logFilePath, text, 'utf8')) + .catch(() => undefined) +} + +function insightDebugLine(level: InsightLogLevel, message: string): void { + appendInsightDebugText(`[${formatDebugTimestamp()}] [${level}] ${message}\n`) +} + +function insightDebugSection(level: InsightLogLevel, title: string, payload: unknown): void { + const content = typeof payload === 'string' + ? payload + : JSON.stringify(payload, null, 2) + + appendInsightDebugText( + `\n========== [${formatDebugTimestamp()}] [${level}] ${title} ==========\n${content}\n========== END ==========\n` + ) +} + /** * 仅输出到 console,不落盘到文件。 */ -function insightLog(level: 'INFO' | 'WARN' | 'ERROR', message: string): void { +function insightLog(level: InsightLogLevel, message: string): void { if (level === 'ERROR' || level === 'WARN') { console.warn(`[InsightService] ${message}`) } else { console.log(`[InsightService] ${message}`) } + insightDebugLine(level, message) } // ─── 工具函数 ───────────────────────────────────────────────────────────────── @@ -118,8 +190,8 @@ function callApi( const body = JSON.stringify({ model, messages, - max_tokens: 200, - temperature: 0.7, + max_tokens: API_MAX_TOKENS, + temperature: API_TEMPERATURE, stream: false }) @@ -316,28 +388,46 @@ class InsightService { } /** - * 测��� API 连接,返回 { success, message }。 + * 测试 API 连接,返回 { success, message }。 * 供设置页"测试连接"按钮调用。 */ async testConnection(): Promise<{ success: boolean; message: string }> { - const apiBaseUrl = this.config.get('aiInsightApiBaseUrl') as string - const apiKey = this.config.get('aiInsightApiKey') as string - const model = (this.config.get('aiInsightApiModel') as string) || 'gpt-4o-mini' + const { apiBaseUrl, apiKey, model } = this.getSharedAiModelConfig() if (!apiBaseUrl || !apiKey) { return { success: false, message: '请先填写 API 地址和 API Key' } } try { + const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions') + const requestMessages = [{ role: 'user', content: '请回复"连接成功"四个字。' }] + insightDebugSection( + 'INFO', + 'AI 测试连接请求', + [ + `Endpoint: ${endpoint}`, + `Model: ${model}`, + '', + '用户提示词:', + requestMessages[0].content + ].join('\n') + ) + const result = await callApi( apiBaseUrl, apiKey, model, - [{ role: 'user', content: '请回复"连接成功"四个字。' }], + requestMessages, 15_000 ) + insightDebugSection('INFO', 'AI 测试连接输出原文', result) return { success: true, message: `连接成功,模型回复:${result.slice(0, 50)}` } } catch (e) { + insightDebugSection( + 'ERROR', + 'AI 测试连接失败', + `错误信息:${(e as Error).message}\n\n堆栈:\n${(e as Error).stack || '[无堆栈]'}` + ) return { success: false, message: `连接失败:${(e as Error).message}` } } } @@ -348,8 +438,7 @@ class InsightService { */ async triggerTest(): Promise<{ success: boolean; message: string }> { insightLog('INFO', '手动触发测试见解...') - const apiBaseUrl = this.config.get('aiInsightApiBaseUrl') as string - const apiKey = this.config.get('aiInsightApiKey') as string + const { apiBaseUrl, apiKey } = this.getSharedAiModelConfig() if (!apiBaseUrl || !apiKey) { return { success: false, message: '请先填写 API 地址和 Key' } } @@ -398,12 +487,223 @@ class InsightService { return result } + async generateFootprintInsight(params: { + rangeLabel: string + summary: { + private_inbound_people?: number + private_replied_people?: number + private_outbound_people?: number + private_reply_rate?: number + mention_count?: number + mention_group_count?: number + } + privateSegments?: Array<{ displayName?: string; session_id?: string; incoming_count?: number; outgoing_count?: number; message_count?: number; replied?: boolean }> + mentionGroups?: Array<{ displayName?: string; session_id?: string; count?: number }> + }): Promise<{ success: boolean; message: string; insight?: string }> { + const enabled = this.config.get('aiFootprintEnabled') === true + if (!enabled) { + return { success: false, message: '请先在设置中开启「AI 足迹总结」' } + } + + const { apiBaseUrl, apiKey, model } = this.getSharedAiModelConfig() + if (!apiBaseUrl || !apiKey) { + return { success: false, message: '请先填写通用 AI 模型配置(API 地址和 Key)' } + } + + const summary = params?.summary || {} + const rangeLabel = String(params?.rangeLabel || '').trim() || '当前范围' + const privateSegments = Array.isArray(params?.privateSegments) ? params.privateSegments.slice(0, 6) : [] + const mentionGroups = Array.isArray(params?.mentionGroups) ? params.mentionGroups.slice(0, 6) : [] + + const topPrivateText = privateSegments.length > 0 + ? privateSegments + .map((item, idx) => { + const name = String(item.displayName || item.session_id || `联系人${idx + 1}`).trim() + const inbound = Number(item.incoming_count) || 0 + const outbound = Number(item.outgoing_count) || 0 + const total = Math.max(Number(item.message_count) || 0, inbound + outbound) + return `${idx + 1}. ${name}(收${inbound}/发${outbound}/总${total}${item.replied ? '/已回复' : ''})` + }) + .join('\n') + : '无' + + const topMentionText = mentionGroups.length > 0 + ? mentionGroups + .map((item, idx) => { + const name = String(item.displayName || item.session_id || `群聊${idx + 1}`).trim() + const count = Number(item.count) || 0 + return `${idx + 1}. ${name}(@我 ${count} 次)` + }) + .join('\n') + : '无' + + const defaultSystemPrompt = `你是用户的聊天足迹教练,负责基于统计数据给出一段简明复盘。 +要求: +1. 输出 2-3 句,总长度不超过 180 字。 +2. 必须包含:总体观察 + 一个可执行建议。 +3. 语气务实,不夸张,不使用 Markdown。` + const customPrompt = String(this.config.get('aiFootprintSystemPrompt') || '').trim() + const systemPrompt = customPrompt || defaultSystemPrompt + + const userPrompt = `统计范围:${rangeLabel} +有聊天的人数:${Number(summary.private_inbound_people) || 0} +我有回复的人数:${Number(summary.private_outbound_people) || 0} +回复率:${(((Number(summary.private_reply_rate) || 0) * 100)).toFixed(1)}% +@我次数:${Number(summary.mention_count) || 0} +涉及群聊:${Number(summary.mention_group_count) || 0} + +私聊重点: +${topPrivateText} + +群聊@我重点: +${topMentionText} + +请给出足迹复盘(2-3句,含建议):` + + try { + const result = await callApi( + apiBaseUrl, + apiKey, + model, + [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt } + ], + 25_000 + ) + const insight = result.trim().slice(0, 400) + if (!insight) return { success: false, message: '模型返回为空' } + return { success: true, message: '生成成功', insight } + } catch (error) { + return { success: false, message: `生成失败:${(error as Error).message}` } + } + } + // ── 私有方法 ──────────────────────────────────────────────────────────────── private isEnabled(): boolean { return this.config.get('aiInsightEnabled') === true } + private getSharedAiModelConfig(): SharedAiModelConfig { + const apiBaseUrl = String( + this.config.get('aiModelApiBaseUrl') + || this.config.get('aiInsightApiBaseUrl') + || '' + ).trim() + const apiKey = String( + this.config.get('aiModelApiKey') + || this.config.get('aiInsightApiKey') + || '' + ).trim() + const model = String( + this.config.get('aiModelApiModel') + || this.config.get('aiInsightApiModel') + || 'gpt-4o-mini' + ).trim() || 'gpt-4o-mini' + + return { apiBaseUrl, apiKey, model } + } + + private looksLikeWxid(text: string): boolean { + const normalized = String(text || '').trim() + if (!normalized) return false + return /^wxid_[a-z0-9]+$/i.test(normalized) + || /^[a-z0-9_]+@chatroom$/i.test(normalized) + } + + private looksLikeXmlPayload(text: string): boolean { + const normalized = String(text || '').trim() + if (!normalized) return false + return /^(<\?xml| 1_000_000_000_000 ? createTime : createTime * 1000 + const date = new Date(ms) + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + const hours = String(date.getHours()).padStart(2, '0') + const minutes = String(date.getMinutes()).padStart(2, '0') + const seconds = String(date.getSeconds()).padStart(2, '0') + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}` + } + + private async resolveInsightSessionDisplayName(sessionId: string, fallbackDisplayName: string): Promise { + const fallback = String(fallbackDisplayName || '').trim() + if (fallback && !this.looksLikeWxid(fallback)) { + return fallback + } + + try { + const sessions = await this.getSessionsCached() + const matched = sessions.find((session) => String(session.username || '').trim() === sessionId) + const cachedDisplayName = String(matched?.displayName || '').trim() + if (cachedDisplayName && !this.looksLikeWxid(cachedDisplayName)) { + return cachedDisplayName + } + } catch { + // ignore display name lookup failures + } + + try { + const contact = await chatService.getContactAvatar(sessionId) + const contactDisplayName = String(contact?.displayName || '').trim() + if (contactDisplayName && !this.looksLikeWxid(contactDisplayName)) { + return contactDisplayName + } + } catch { + // ignore display name lookup failures + } + + return fallback || sessionId + } + + private formatInsightMessageContent(message: Message): string { + const parsedContent = this.normalizeInsightText(String(message.parsedContent || '')) + const quotedPreview = this.normalizeInsightText(String(message.quotedContent || '')) + const quotedSender = this.normalizeInsightText(String(message.quotedSender || '')) + + if (quotedPreview) { + const cleanQuotedSender = quotedSender && !this.looksLikeWxid(quotedSender) ? quotedSender : '' + const quoteLabel = cleanQuotedSender ? `${cleanQuotedSender}:${quotedPreview}` : quotedPreview + const replyText = parsedContent && parsedContent !== '[引用消息]' ? parsedContent : '' + return replyText ? `${replyText}[引用 ${quoteLabel}]` : `[引用 ${quoteLabel}]` + } + + if (parsedContent) { + return parsedContent + } + + const rawContent = this.normalizeInsightText(String(message.rawContent || '')) + if (rawContent && !this.looksLikeXmlPayload(rawContent)) { + return rawContent + } + + return '[其他消息]' + } + + private buildInsightContextSection(messages: Message[], peerDisplayName: string): string { + if (!messages.length) return '' + + const lines = messages.map((message) => { + const senderName = message.isSend === 1 ? '我' : peerDisplayName + const content = this.formatInsightMessageContent(message) + return `${this.formatInsightMessageTimestamp(message.createTime)} '${senderName}'\n${content}` + }) + + return `近期聊天记录(最近 ${lines.length} 条):\n\n${lines.join('\n\n')}` + } + /** * 判断某个会话是否允许触发见解。 * 若白名单未启用,则所有私聊会话均允许; @@ -475,7 +775,7 @@ class InsightService { } /** - * 获取今日全局已触发次数(所有会话合计),用于 prompt 中告知模���全局上下文。 + * 获取今日全局已触发次数(所有会话合计),用于 prompt 中告知模型全局上下文。 */ private getTodayTotalTriggerCount(): number { this.resetIfNewDay() @@ -696,11 +996,10 @@ class InsightService { if (!sessionId) return if (!this.isEnabled()) return - const apiBaseUrl = this.config.get('aiInsightApiBaseUrl') as string - const apiKey = this.config.get('aiInsightApiKey') as string - const model = (this.config.get('aiInsightApiModel') as string) || 'gpt-4o-mini' + const { apiBaseUrl, apiKey, model } = this.getSharedAiModelConfig() const allowContext = this.config.get('aiInsightAllowContext') as boolean const contextCount = (this.config.get('aiInsightContextCount') as number) || 40 + const resolvedDisplayName = await this.resolveInsightSessionDisplayName(sessionId, displayName) insightLog('INFO', `generateInsightForSession: sessionId=${sessionId}, reason=${triggerReason}, contextCount=${contextCount}, api=${apiBaseUrl ? '已配置' : '未配置'}`) @@ -709,7 +1008,7 @@ class InsightService { return } - // ── 构建 prompt ─────────────���───────────────────────────────���──────────── + // ── 构建 prompt ──────────────────────────────────────────────────────────── // 今日触发统计(让模型具备时间与克制感) const sessionTriggerTimes = this.recordTrigger(sessionId) @@ -721,14 +1020,8 @@ class InsightService { const msgsResult = await chatService.getLatestMessages(sessionId, contextCount) if (msgsResult.success && msgsResult.messages && msgsResult.messages.length > 0) { const messages: Message[] = msgsResult.messages - const msgLines = messages.map((m) => { - const sender = m.isSend === 1 ? '我' : (displayName || sessionId) - const content = m.rawContent || m.parsedContent || '[非文字消息]' - const time = new Date(Number(m.createTime) * 1000).toLocaleString('zh-CN') - return `[${time}] ${sender}:${content}` - }) - contextSection = `\n\n近期对话记录(最近 ${msgLines.length} 条):\n${msgLines.join('\n')}` - insightLog('INFO', `已加载 ${msgLines.length} 条上下文消息`) + contextSection = this.buildInsightContextSection(messages, resolvedDisplayName) + insightLog('INFO', `已加载 ${messages.length} 条上下文消息`) } } catch (e) { insightLog('WARN', `拉取上下文失败: ${(e as Error).message}`) @@ -752,48 +1045,71 @@ class InsightService { // 这样 provider 端(Anthropic/OpenAI)能最大化命中 prompt cache,降低费用 const triggerDesc = triggerReason === 'silence' - ? `你已经 ${silentDays} 天没有和「${displayName}」聊天了。` - : `你最近和「${displayName}」有新的聊天动态。` + ? `你已经 ${silentDays} 天没有和「${resolvedDisplayName}」聊天了。` + : `你最近和「${resolvedDisplayName}」有新的聊天动态。` const todayStatsDesc = sessionTriggerTimes.length > 1 - ? `今天你已经针对「${displayName}」收到过 ${sessionTriggerTimes.length - 1} 条见解(时间:${sessionTriggerTimes.slice(0, -1).join('、')}),请适当克制。` - : `今天你还没有针对「${displayName}」发出过见解。` + ? `今天你已经针对「${resolvedDisplayName}」收到过 ${sessionTriggerTimes.length - 1} 条见解(时间:${sessionTriggerTimes.slice(0, -1).join('、')}),请适当克制。` + : `今天你还没有针对「${resolvedDisplayName}」发出过见解。` const globalStatsDesc = `今天全部联系人合计已触发 ${totalTodayTriggers} 条见解。` - const userPrompt = `触发原因:${triggerDesc} -时间统计:${todayStatsDesc} ${globalStatsDesc}${contextSection} - -请给出你的见解(≤80字):` + const userPrompt = [ + `触发原因:${triggerDesc}`, + `时间统计:${todayStatsDesc}`, + `全局统计:${globalStatsDesc}`, + contextSection, + '请给出你的见解(≤80字):' + ].filter(Boolean).join('\n\n') const endpoint = buildApiUrl(apiBaseUrl, '/chat/completions') + const requestMessages = [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt } + ] + insightLog('INFO', `准备调用 API: ${endpoint},模型: ${model}`) + insightDebugSection( + 'INFO', + `AI 请求 ${resolvedDisplayName} (${sessionId})`, + [ + `接口地址:${endpoint}`, + `模型:${model}`, + `触发原因:${triggerReason}`, + `上下文开关:${allowContext ? '开启' : '关闭'}`, + `上下文条数:${contextCount}`, + '', + '系统提示词:', + systemPrompt, + '', + '用户提示词:', + userPrompt + ].join('\n') + ) try { const result = await callApi( apiBaseUrl, apiKey, model, - [ - { role: 'system', content: systemPrompt }, - { role: 'user', content: userPrompt } - ] + requestMessages ) insightLog('INFO', `API 返回原文: ${result.slice(0, 150)}`) + insightDebugSection('INFO', `AI 输出原文 ${resolvedDisplayName} (${sessionId})`, result) // 模型主动选择跳过 if (result.trim().toUpperCase() === 'SKIP' || result.trim().startsWith('SKIP')) { - insightLog('INFO', `模型选择跳过 ${displayName}`) + insightLog('INFO', `模型选择跳过 ${resolvedDisplayName}`) return } if (!this.isEnabled()) return const insight = result.slice(0, 120) - const notifTitle = `见解 · ${displayName}` + const notifTitle = `见解 · ${resolvedDisplayName}` - insightLog('INFO', `推送通知 → ${displayName}: ${insight}`) + insightLog('INFO', `推送通知 → ${resolvedDisplayName}: ${insight}`) // 渠道一:Electron 原生系统通知 if (Notification.isSupported()) { @@ -821,9 +1137,14 @@ class InsightService { } } - insightLog('INFO', `已为 ${displayName} 推送见解`) + insightLog('INFO', `已为 ${resolvedDisplayName} 推送见解`) } catch (e) { - insightLog('ERROR', `API 调用失败 (${displayName}): ${(e as Error).message}`) + insightDebugSection( + 'ERROR', + `AI 请求失败 ${resolvedDisplayName} (${sessionId})`, + `错误信息:${(e as Error).message}\n\n堆栈:\n${(e as Error).stack || '[无堆栈]'}` + ) + insightLog('ERROR', `API 调用失败 (${resolvedDisplayName}): ${(e as Error).message}`) } } diff --git a/electron/services/keyService.ts b/electron/services/keyService.ts index 72c827c..37242a3 100644 --- a/electron/services/keyService.ts +++ b/electron/services/keyService.ts @@ -9,7 +9,7 @@ import crypto from 'crypto' const execFileAsync = promisify(execFile) type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] } -type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; error?: string } +type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; verified?: boolean; error?: string } export class KeyService { private readonly isMac = process.platform === 'darwin' @@ -814,7 +814,7 @@ export class KeyService { if (!this.verifyDerivedAesKey(aesKey, verifyCiphertext)) continue onProgress?.(`密钥获取成功 (wxid: ${candidateWxid}, code: ${code})`) console.log('[ImageKey] 校验命中: wxid=', candidateWxid, 'code=', code) - return { success: true, xorKey, aesKey } + return { success: true, xorKey, aesKey, verified: true } } } return { success: false, error: '缓存 code 与当前账号 wxid 未匹配,请确认账号目录后重试,或使用内存扫描' } @@ -826,7 +826,7 @@ export class KeyService { const { xorKey, aesKey } = this.deriveImageKeys(fallbackCode, fallbackWxid) onProgress?.(`密钥获取成功 (wxid: ${fallbackWxid}, code: ${fallbackCode})`) console.log('[ImageKey] 回退计算: wxid=', fallbackWxid, 'code=', fallbackCode) - return { success: true, xorKey, aesKey } + return { success: true, xorKey, aesKey, verified: false } } // --- 内存扫描备选方案(融合 Dart+Python 优点)--- diff --git a/electron/services/keyServiceLinux.ts b/electron/services/keyServiceLinux.ts index 85d5a36..e4b5088 100644 --- a/electron/services/keyServiceLinux.ts +++ b/electron/services/keyServiceLinux.ts @@ -3,6 +3,7 @@ import { join } from 'path' import { existsSync, readdirSync, statSync, readFileSync } from 'fs' import { execFile, exec, spawn } from 'child_process' import { promisify } from 'util' +import crypto from 'crypto' import { createRequire } from 'module'; const require = createRequire(import.meta.url); @@ -10,7 +11,7 @@ const execFileAsync = promisify(execFile) const execAsync = promisify(exec) type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] } -type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; error?: string } +type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; verified?: boolean; error?: string } export class KeyServiceLinux { private sudo: any @@ -98,7 +99,12 @@ export class KeyServiceLinux { 'xwechat', '/opt/wechat/wechat', '/usr/bin/wechat', - '/opt/apps/com.tencent.wechat/files/wechat' + '/usr/local/bin/wechat', + '/usr/bin/wechat', + '/opt/apps/com.tencent.wechat/files/wechat', + '/usr/bin/wechat-bin', + '/usr/local/bin/wechat-bin', + 'com.tencent.wechat' ] for (const binName of wechatBins) { @@ -152,7 +158,7 @@ export class KeyServiceLinux { } if (!pid) { - const err = '未能自动启动微信,或获取PID失败,请查看控制台日志或手动启动并登录。' + const err = '未能自动启动微信,或获取PID失败,请查看控制台日志或手动启动微信,看到登录窗口后点击确认。' onStatus?.(err, 2) return { success: false, error: err } } @@ -238,7 +244,14 @@ export class KeyServiceLinux { if (account && account.keys && account.keys.length > 0) { onProgress?.(`已找到匹配的图片密钥 (wxid: ${account.wxid})`); const keyObj = account.keys[0] - return { success: true, xorKey: keyObj.xorKey, aesKey: keyObj.aesKey } + const aesKey = String(keyObj.aesKey || '') + const verified = await this.verifyImageKeyByTemplate(accountPath, aesKey) + if (verified === true) { + onProgress?.('缓存密钥校验成功,已确认可用') + } else if (verified === false) { + onProgress?.('已从缓存计算密钥,但未通过本地模板校验') + } + return { success: true, xorKey: keyObj.xorKey, aesKey, verified: verified === true } } return { success: false, error: '未在缓存中找到匹配的图片密钥' } } catch (err: any) { @@ -246,6 +259,35 @@ export class KeyServiceLinux { } } + private async verifyImageKeyByTemplate(accountPath: string | undefined, aesKey: string): Promise { + const normalizedPath = String(accountPath || '').trim() + if (!normalizedPath || !aesKey || aesKey.length < 16 || !existsSync(normalizedPath)) return null + try { + const template = await this._findTemplateData(normalizedPath, 32) + if (!template.ciphertext) return null + return this.verifyDerivedAesKey(aesKey, template.ciphertext) + } catch { + return null + } + } + + private verifyDerivedAesKey(aesKey: string, ciphertext: Buffer): boolean { + try { + if (!aesKey || aesKey.length < 16 || ciphertext.length !== 16) return false + const decipher = crypto.createDecipheriv('aes-128-ecb', Buffer.from(aesKey, 'ascii').subarray(0, 16), null) + decipher.setAutoPadding(false) + const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()]) + if (dec[0] === 0xFF && dec[1] === 0xD8 && dec[2] === 0xFF) return true + if (dec[0] === 0x89 && dec[1] === 0x50 && dec[2] === 0x4E && dec[3] === 0x47) return true + if (dec[0] === 0x52 && dec[1] === 0x49 && dec[2] === 0x46 && dec[3] === 0x46) return true + if (dec[0] === 0x77 && dec[1] === 0x78 && dec[2] === 0x67 && dec[3] === 0x66) return true + if (dec[0] === 0x47 && dec[1] === 0x49 && dec[2] === 0x46) return true + return false + } catch { + return false + } + } + public async autoGetImageKeyByMemoryScan( accountPath: string, onProgress?: (msg: string) => void diff --git a/electron/services/keyServiceMac.ts b/electron/services/keyServiceMac.ts index c350eb1..9900ec3 100644 --- a/electron/services/keyServiceMac.ts +++ b/electron/services/keyServiceMac.ts @@ -1,13 +1,13 @@ import { app, shell } from 'electron' import { join, basename, dirname } from 'path' -import { existsSync, readdirSync, readFileSync, statSync } from 'fs' +import { existsSync, readdirSync, readFileSync, statSync, chmodSync } from 'fs' import { execFile, spawn } from 'child_process' import { promisify } from 'util' import crypto from 'crypto' import { homedir } from 'os' type DbKeyResult = { success: boolean; key?: string; error?: string; logs?: string[] } -type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; error?: string } +type ImageKeyResult = { success: boolean; xorKey?: number; aesKey?: string; verified?: boolean; error?: string } const execFileAsync = promisify(execFile) export class KeyServiceMac { @@ -403,19 +403,71 @@ export class KeyServiceMac { return `'${String(text).replace(/'/g, `'\\''`)}'` } + private collectMacKeyArtifactPaths(primaryBinaryPath: string): string[] { + const baseDir = dirname(primaryBinaryPath) + const names = ['xkey_helper', 'image_scan_helper', 'xkey_helper_macos', 'libwx_key.dylib'] + const unique: string[] = [] + for (const name of names) { + const full = join(baseDir, name) + if (!existsSync(full)) continue + if (!unique.includes(full)) unique.push(full) + } + if (existsSync(primaryBinaryPath) && !unique.includes(primaryBinaryPath)) { + unique.unshift(primaryBinaryPath) + } + return unique + } + + private ensureExecutableBitsBestEffort(paths: string[]): void { + for (const p of paths) { + try { + const mode = statSync(p).mode + if ((mode & 0o111) !== 0) continue + chmodSync(p, mode | 0o111) + } catch { + // ignore: 可能无权限(例如 /Applications 下 root-owned 的 .app) + } + } + } + + private async ensureExecutableBitsWithElevation(paths: string[], timeoutMs: number): Promise { + const existing = paths.filter(p => existsSync(p)) + if (existing.length === 0) return + + const quotedPaths = existing.map(p => this.shellSingleQuote(p)).join(' ') + const timeoutSec = Math.max(30, Math.ceil(timeoutMs / 1000)) + const scriptLines = [ + `set chmodCmd to "/bin/chmod +x ${quotedPaths}"`, + `set timeoutSec to ${timeoutSec}`, + 'with timeout of timeoutSec seconds', + 'do shell script chmodCmd with administrator privileges', + 'end timeout' + ] + + await execFileAsync('/usr/bin/osascript', scriptLines.flatMap(line => ['-e', line]), { + timeout: timeoutMs + 10_000 + }) + } + private async getDbKeyByHelperElevated( timeoutMs: number, onStatus?: (message: string, level: number) => void ): Promise { const helperPath = this.getHelperPath() + const artifactPaths = this.collectMacKeyArtifactPaths(helperPath) + this.ensureExecutableBitsBestEffort(artifactPaths) const waitMs = Math.max(timeoutMs, 30_000) const timeoutSec = Math.ceil(waitMs / 1000) + 30 const pid = await this.getWeChatPid() + const chmodPart = artifactPaths.length > 0 + ? `/bin/chmod +x ${artifactPaths.map(p => this.shellSingleQuote(p)).join(' ')}` + : '' + const runPart = `${this.shellSingleQuote(helperPath)} ${pid} ${waitMs}` + const privilegedCmd = chmodPart ? `${chmodPart} && ${runPart}` : runPart // 用 AppleScript 的 quoted form 组装命令,避免复杂 shell 拼接导致整条失败 // 通过 try/on error 回传详细错误,避免只看到 "Command failed" const scriptLines = [ - `set helperPath to ${JSON.stringify(helperPath)}`, - `set cmd to quoted form of helperPath & " ${pid} ${waitMs}"`, + `set cmd to ${JSON.stringify(privilegedCmd)}`, `set timeoutSec to ${timeoutSec}`, 'try', 'with timeout of timeoutSec seconds', @@ -503,7 +555,19 @@ export class KeyServiceMac { if (code === 'HOOK_TARGET_ONLY') { return `已定位到目标函数地址(${detail || ''}),但当前原生 C++ 仅完成定位,尚未完成远程 Hook 回调取 key 流程。` } - if (code === 'SCAN_FAILED') return '内存扫描失败' + if (code === 'SCAN_FAILED') { + const normalizedDetail = (detail || '').trim() + if (!normalizedDetail) { + return '内存扫描失败:未匹配到可用特征。可能是当前微信版本更新导致,请升级 WeFlow 后重试。' + } + if (normalizedDetail.includes('Sink pattern not found')) { + return '内存扫描失败:未匹配到目标函数特征,可使用微信 4.1.8.100 版本尝试。' + } + if (normalizedDetail.includes('No suitable module found')) { + return '内存扫描失败:未找到可扫描的微信主模块。请确认微信已完整启动并保持前台,再重试。' + } + return `内存扫描失败:${normalizedDetail}` + } return '未知错误' } @@ -583,7 +647,7 @@ export class KeyServiceMac { const { xorKey, aesKey } = this.deriveImageKeys(code, candidateWxid) if (!this.verifyDerivedAesKey(aesKey, template.ciphertext)) continue onStatus?.(`密钥获取成功 (wxid: ${candidateWxid}, code: ${code})`) - return { success: true, xorKey, aesKey } + return { success: true, xorKey, aesKey, verified: true } } } } @@ -598,7 +662,7 @@ export class KeyServiceMac { const fallbackCode = codes[0] const { xorKey, aesKey } = this.deriveImageKeys(fallbackCode, fallbackWxid) onStatus?.(`密钥获取成功 (wxid: ${fallbackWxid}, code: ${fallbackCode})`) - return { success: true, xorKey, aesKey } + return { success: true, xorKey, aesKey, verified: false } } catch (e: any) { return { success: false, error: `自动获取图片密钥失败: ${e.message}` } } @@ -751,10 +815,12 @@ export class KeyServiceMac { try { const helperPath = this.getImageScanHelperPath() const ciphertextHex = ciphertext.toString('hex') + const artifactPaths = this.collectMacKeyArtifactPaths(helperPath) + this.ensureExecutableBitsBestEffort(artifactPaths) // 1) 直接运行 helper(有正式签名的 debugger entitlement 时可用) if (!this._needsElevation) { - const direct = await this._spawnScanHelper(helperPath, pid, ciphertextHex, false) + const direct = await this._spawnScanHelper(helperPath, pid, ciphertextHex, false, artifactPaths) if (direct.key) return direct.key if (direct.permissionError) { console.warn('[KeyServiceMac] task_for_pid 权限不足,切换到 osascript 提权模式') @@ -765,7 +831,12 @@ export class KeyServiceMac { // 2) 通过 osascript 以管理员权限运行 helper(SIP 下 ad-hoc 签名无法获取 task_for_pid) if (this._needsElevation) { - const elevated = await this._spawnScanHelper(helperPath, pid, ciphertextHex, true) + try { + await this.ensureExecutableBitsWithElevation(artifactPaths, 45_000) + } catch (e: any) { + console.warn('[KeyServiceMac] elevated chmod failed before image scan:', e?.message || e) + } + const elevated = await this._spawnScanHelper(helperPath, pid, ciphertextHex, true, artifactPaths) if (elevated.key) return elevated.key } } catch (e: any) { @@ -868,12 +939,19 @@ export class KeyServiceMac { } private _spawnScanHelper( - helperPath: string, pid: number, ciphertextHex: string, elevated: boolean + helperPath: string, + pid: number, + ciphertextHex: string, + elevated: boolean, + artifactPaths: string[] = [] ): Promise<{ key: string | null; permissionError: boolean }> { return new Promise((resolve, reject) => { let child: ReturnType if (elevated) { - const shellCmd = `'${helperPath}' ${pid} ${ciphertextHex}` + const chmodPart = artifactPaths.length > 0 + ? `/bin/chmod +x ${artifactPaths.map(p => this.shellSingleQuote(p)).join(' ')} && ` + : '' + const shellCmd = `${chmodPart}${this.shellSingleQuote(helperPath)} ${pid} ${ciphertextHex}` child = spawn('/usr/bin/osascript', ['-e', `do shell script ${JSON.stringify(shellCmd)} with administrator privileges`], { stdio: ['ignore', 'pipe', 'pipe'] }) } else { diff --git a/electron/services/messagePushService.ts b/electron/services/messagePushService.ts index 95c180c..ca7e057 100644 --- a/electron/services/messagePushService.ts +++ b/electron/services/messagePushService.ts @@ -11,6 +11,7 @@ interface SessionBaseline { interface MessagePushPayload { event: 'message.new' sessionId: string + sessionType: 'private' | 'group' | 'official' | 'other' messageKey: string avatarUrl?: string sourceName: string @@ -20,6 +21,8 @@ interface MessagePushPayload { const PUSH_CONFIG_KEYS = new Set([ 'messagePushEnabled', + 'messagePushFilterMode', + 'messagePushFilterList', 'dbPath', 'decryptKey', 'myWxid' @@ -38,6 +41,7 @@ class MessagePushService { private rerunRequested = false private started = false private baselineReady = false + private messageTableScanRequested = false constructor() { this.configService = ConfigService.getInstance() @@ -60,12 +64,15 @@ class MessagePushService { payload = null } - const tableName = String(payload?.table || '').trim().toLowerCase() - if (tableName && tableName !== 'session') { + const tableName = String(payload?.table || '').trim() + if (this.isSessionTableChange(tableName)) { + this.scheduleSync() return } - this.scheduleSync() + if (!tableName || this.isMessageTableChange(tableName)) { + this.scheduleSync({ scanMessageBackedSessions: true }) + } } async handleConfigChanged(key: string): Promise { @@ -91,6 +98,7 @@ class MessagePushService { this.recentMessageKeys.clear() this.groupNicknameCache.clear() this.baselineReady = false + this.messageTableScanRequested = false if (this.debounceTimer) { clearTimeout(this.debounceTimer) this.debounceTimer = null @@ -121,7 +129,11 @@ class MessagePushService { this.baselineReady = true } - private scheduleSync(): void { + private scheduleSync(options: { scanMessageBackedSessions?: boolean } = {}): void { + if (options.scanMessageBackedSessions) { + this.messageTableScanRequested = true + } + if (this.debounceTimer) { clearTimeout(this.debounceTimer) } @@ -141,6 +153,8 @@ class MessagePushService { this.processing = true try { if (!this.isPushEnabled()) return + const scanMessageBackedSessions = this.messageTableScanRequested + this.messageTableScanRequested = false const connectResult = await chatService.connect() if (!connectResult.success) { @@ -163,27 +177,47 @@ class MessagePushService { const previousBaseline = new Map(this.sessionBaseline) this.setBaseline(sessions) - const candidates = sessions.filter((session) => this.shouldInspectSession(previousBaseline.get(session.username), session)) + const candidates = sessions.filter((session) => { + const previous = previousBaseline.get(session.username) + if (this.shouldInspectSession(previous, session)) { + return true + } + return scanMessageBackedSessions && this.shouldScanMessageBackedSession(previous, session) + }) for (const session of candidates) { - await this.pushSessionMessages(session, previousBaseline.get(session.username)) + await this.pushSessionMessages( + session, + previousBaseline.get(session.username) || this.sessionBaseline.get(session.username) + ) } } finally { this.processing = false if (this.rerunRequested) { this.rerunRequested = false - this.scheduleSync() + this.scheduleSync({ scanMessageBackedSessions: this.messageTableScanRequested }) } } } private setBaseline(sessions: ChatSession[]): void { + const previousBaseline = new Map(this.sessionBaseline) + const nextBaseline = new Map() + const nowSeconds = Math.floor(Date.now() / 1000) this.sessionBaseline.clear() for (const session of sessions) { - this.sessionBaseline.set(session.username, { - lastTimestamp: Number(session.lastTimestamp || 0), + const username = String(session.username || '').trim() + if (!username) continue + const previous = previousBaseline.get(username) + const sessionTimestamp = Number(session.lastTimestamp || 0) + const initialTimestamp = sessionTimestamp > 0 ? sessionTimestamp : nowSeconds + nextBaseline.set(username, { + lastTimestamp: Math.max(sessionTimestamp, Number(previous?.lastTimestamp || 0), previous ? 0 : initialTimestamp), unreadCount: Number(session.unreadCount || 0) }) } + for (const [username, baseline] of nextBaseline.entries()) { + this.sessionBaseline.set(username, baseline) + } } private shouldInspectSession(previous: SessionBaseline | undefined, session: ChatSession): boolean { @@ -204,16 +238,30 @@ class MessagePushService { return unreadCount > 0 && lastTimestamp > 0 } - if (lastTimestamp <= previous.lastTimestamp) { + return lastTimestamp > previous.lastTimestamp || unreadCount > previous.unreadCount + } + + private shouldScanMessageBackedSession(previous: SessionBaseline | undefined, session: ChatSession): boolean { + const sessionId = String(session.username || '').trim() + if (!sessionId || sessionId.toLowerCase().includes('placeholder_foldgroup')) { return false } - // unread 未增长时,大概率是自己发送、其他设备已读或状态同步,不作为主动推送 - return unreadCount > previous.unreadCount + const summary = String(session.summary || '').trim() + if (Number(session.lastMsgType || 0) === 10002 || summary.includes('撤回了一条消息')) { + return false + } + + const sessionType = this.getSessionType(sessionId, session) + if (sessionType === 'private') { + return false + } + + return Boolean(previous) || Number(session.lastTimestamp || 0) > 0 } private async pushSessionMessages(session: ChatSession, previous: SessionBaseline | undefined): Promise { - const since = Math.max(0, Number(previous?.lastTimestamp || 0) - 1) + const since = Math.max(0, Number(previous?.lastTimestamp || 0)) const newMessagesResult = await chatService.getNewMessages(session.username, since, 1000) if (!newMessagesResult.success || !newMessagesResult.messages || newMessagesResult.messages.length === 0) { return @@ -224,7 +272,7 @@ class MessagePushService { if (!messageKey) continue if (message.isSend === 1) continue - if (previous && Number(message.createTime || 0) < Number(previous.lastTimestamp || 0)) { + if (previous && Number(message.createTime || 0) <= Number(previous.lastTimestamp || 0)) { continue } @@ -234,9 +282,11 @@ class MessagePushService { const payload = await this.buildPayload(session, message) if (!payload) continue + if (!this.shouldPushPayload(payload)) continue httpService.broadcastMessagePush(payload) this.rememberMessageKey(messageKey) + this.bumpSessionBaseline(session.username, message) } } @@ -246,6 +296,7 @@ class MessagePushService { if (!sessionId || !messageKey) return null const isGroup = sessionId.endsWith('@chatroom') + const sessionType = this.getSessionType(sessionId, session) const content = this.getMessageDisplayContent(message) if (isGroup) { @@ -255,6 +306,7 @@ class MessagePushService { return { event: 'message.new', sessionId, + sessionType, messageKey, avatarUrl: session.avatarUrl || groupInfo?.avatarUrl, groupName, @@ -267,6 +319,7 @@ class MessagePushService { return { event: 'message.new', sessionId, + sessionType, messageKey, avatarUrl: session.avatarUrl || contactInfo?.avatarUrl, sourceName: session.displayName || contactInfo?.displayName || sessionId, @@ -274,10 +327,84 @@ class MessagePushService { } } + private getSessionType(sessionId: string, session: ChatSession): MessagePushPayload['sessionType'] { + if (sessionId.endsWith('@chatroom')) { + return 'group' + } + if (sessionId.startsWith('gh_') || session.type === 'official') { + return 'official' + } + if (session.type === 'friend') { + return 'private' + } + return 'other' + } + + private shouldPushPayload(payload: MessagePushPayload): boolean { + const sessionId = String(payload.sessionId || '').trim() + const filterMode = this.getMessagePushFilterMode() + if (filterMode === 'all') { + return true + } + + const filterList = this.getMessagePushFilterList() + const listed = filterList.has(sessionId) + if (filterMode === 'whitelist') { + return listed + } + return !listed + } + + private getMessagePushFilterMode(): 'all' | 'whitelist' | 'blacklist' { + const value = this.configService.get('messagePushFilterMode') + if (value === 'whitelist' || value === 'blacklist') return value + return 'all' + } + + private getMessagePushFilterList(): Set { + const value = this.configService.get('messagePushFilterList') + if (!Array.isArray(value)) return new Set() + return new Set(value.map((item) => String(item || '').trim()).filter(Boolean)) + } + + private isSessionTableChange(tableName: string): boolean { + return String(tableName || '').trim().toLowerCase() === 'session' + } + + private isMessageTableChange(tableName: string): boolean { + const normalized = String(tableName || '').trim().toLowerCase() + if (!normalized) return false + return normalized === 'message' || + normalized === 'msg' || + normalized.startsWith('message_') || + normalized.startsWith('msg_') || + normalized.includes('message') + } + + private bumpSessionBaseline(sessionId: string, message: Message): void { + const key = String(sessionId || '').trim() + if (!key) return + + const createTime = Number(message.createTime || 0) + if (!Number.isFinite(createTime) || createTime <= 0) return + + const current = this.sessionBaseline.get(key) || { lastTimestamp: 0, unreadCount: 0 } + if (createTime > current.lastTimestamp) { + this.sessionBaseline.set(key, { + ...current, + lastTimestamp: createTime + }) + } + } + private getMessageDisplayContent(message: Message): string | null { + const cleanOfficialPrefix = (value: string | null): string | null => { + if (!value) return value + return value.replace(/^\s*\[视频号\]\s*/u, '').trim() || value + } switch (Number(message.localType || 0)) { case 1: - return message.rawContent || null + return cleanOfficialPrefix(message.rawContent || null) case 3: return '[图片]' case 34: @@ -287,13 +414,13 @@ class MessagePushService { case 47: return '[表情]' case 42: - return message.cardNickname || '[名片]' + return cleanOfficialPrefix(message.cardNickname || '[名片]') case 48: return '[位置]' case 49: - return message.linkTitle || message.fileName || '[消息]' + return cleanOfficialPrefix(message.linkTitle || message.fileName || '[消息]') default: - return message.parsedContent || message.rawContent || null + return cleanOfficialPrefix(message.parsedContent || message.rawContent || null) } } diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts index fde2ca7..116ba45 100644 --- a/electron/services/wcdbCore.ts +++ b/electron/services/wcdbCore.ts @@ -58,6 +58,7 @@ export class WcdbCore { private wcdbGetAnnualReportExtras: any = null private wcdbGetDualReportStats: any = null private wcdbGetGroupStats: any = null + private wcdbGetMyFootprintStats: any = null private wcdbGetMessageDates: any = null private wcdbOpenMessageCursor: any = null private wcdbOpenMessageCursorLite: any = null @@ -127,6 +128,8 @@ export class WcdbCore { private logTimer: NodeJS.Timeout | null = null private lastLogTail: string | null = null private lastResolvedLogPath: string | null = null + private lastCursorForceReopenAt = 0 + private readonly cursorForceReopenCooldownMs = 15000 setPaths(resourcesPath: string, userDataPath: string): void { this.resourcesPath = resourcesPath @@ -923,6 +926,13 @@ export class WcdbCore { this.wcdbGetGroupStats = null } + // wcdb_status wcdb_get_my_footprint_stats(wcdb_handle handle, const char* options_json, char** out_json) + try { + this.wcdbGetMyFootprintStats = this.lib.func('int32 wcdb_get_my_footprint_stats(int64 handle, const char* optionsJson, _Out_ void** outJson)') + } catch { + this.wcdbGetMyFootprintStats = null + } + // wcdb_status wcdb_get_message_dates(wcdb_handle handle, const char* session_id, char** out_json) try { this.wcdbGetMessageDates = this.lib.func('int32 wcdb_get_message_dates(int64 handle, const char* sessionId, _Out_ void** outJson)') @@ -3098,6 +3108,65 @@ export class WcdbCore { } } + async getMyFootprintStats(options: { + beginTimestamp?: number + endTimestamp?: number + myWxid?: string + privateSessionIds?: string[] + groupSessionIds?: string[] + mentionLimit?: number + privateLimit?: number + mentionMode?: 'text_at_me' | string + }): Promise<{ success: boolean; data?: any; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + if (!this.wcdbGetMyFootprintStats) { + return { success: false, error: '接口未就绪' } + } + + try { + const normalizedPrivateSessions = Array.from(new Set( + (options?.privateSessionIds || []) + .map((value) => String(value || '').trim()) + .filter(Boolean) + )) + const normalizedGroupSessions = Array.from(new Set( + (options?.groupSessionIds || []) + .map((value) => String(value || '').trim()) + .filter(Boolean) + )) + const mentionLimitRaw = Number(options?.mentionLimit ?? 0) + const privateLimitRaw = Number(options?.privateLimit ?? 0) + const mentionLimit = Number.isFinite(mentionLimitRaw) && mentionLimitRaw >= 0 ? Math.floor(mentionLimitRaw) : 0 + const privateLimit = Number.isFinite(privateLimitRaw) && privateLimitRaw >= 0 ? Math.floor(privateLimitRaw) : 0 + + const payload = JSON.stringify({ + begin: this.normalizeTimestamp(options?.beginTimestamp || 0), + end: this.normalizeTimestamp(options?.endTimestamp || 0), + my_wxid: String(options?.myWxid || '').trim(), + private_session_ids: normalizedPrivateSessions, + group_session_ids: normalizedGroupSessions, + mention_limit: mentionLimit, + private_limit: privateLimit, + mention_mode: options?.mentionMode || 'text_at_me' + }) + + const outPtr = [null as any] + const result = this.wcdbGetMyFootprintStats(this.handle, payload, outPtr) + if (result !== 0 || !outPtr[0]) { + return { success: false, error: `获取我的足迹统计失败: ${result}` } + } + const jsonStr = this.decodeJsonPtr(outPtr[0]) + if (!jsonStr) { + return { success: false, error: '解析我的足迹统计失败' } + } + return { success: true, data: JSON.parse(jsonStr) || {} } + } catch (e) { + return { success: false, error: String(e) } + } + } + /** * 强制重新打开账号连接(绕过路径缓存),用于微信重装后消息数据库刷新失败时的自动恢复。 * 返回重新打开是否成功。 @@ -3119,6 +3188,15 @@ export class WcdbCore { return this.open(path, key, wxid) } + private shouldRetryCursorAfterNoDb(): boolean { + const now = Date.now() + if (now - this.lastCursorForceReopenAt < this.cursorForceReopenCooldownMs) { + return false + } + this.lastCursorForceReopenAt = now + return true + } + async openMessageCursor(sessionId: string, batchSize: number, ascending: boolean, beginTimestamp: number, endTimestamp: number): Promise<{ success: boolean; cursor?: number; error?: string }> { if (!this.ensureReady()) { return { success: false, error: 'WCDB 未连接' } @@ -3136,7 +3214,7 @@ export class WcdbCore { ) // result=-3 表示 WCDB_STATUS_NO_MESSAGE_DB:消息数据库缓存为空(常见于微信重装后) // 自动强制重连并重试一次 - if (result === -3 && outCursor[0] <= 0) { + if (result === -3 && outCursor[0] <= 0 && this.shouldRetryCursorAfterNoDb()) { this.writeLog('openMessageCursor: result=-3 (no message db), attempting forceReopen...', true) const reopened = await this.forceReopen() if (reopened && this.handle !== null) { @@ -3156,11 +3234,13 @@ export class WcdbCore { } } if (result !== 0 || outCursor[0] <= 0) { - await this.printLogs(true) - this.writeLog( - `openMessageCursor failed: sessionId=${sessionId} batchSize=${batchSize} ascending=${ascending ? 1 : 0} begin=${beginTimestamp} end=${endTimestamp} result=${result} cursor=${outCursor[0]}`, - true - ) + if (result !== -3) { + await this.printLogs(true) + this.writeLog( + `openMessageCursor failed: sessionId=${sessionId} batchSize=${batchSize} ascending=${ascending ? 1 : 0} begin=${beginTimestamp} end=${endTimestamp} result=${result} cursor=${outCursor[0]}`, + true + ) + } const hint = result === -3 ? `创建游标失败: ${result}(消息数据库未找到)。如果你最近重装过微信,请尝试重新指定数据目录后重试` : result === -7 @@ -3197,7 +3277,7 @@ export class WcdbCore { // result=-3 表示 WCDB_STATUS_NO_MESSAGE_DB:消息数据库缓存为空 // 自动强制重连并重试一次 - if (result === -3 && outCursor[0] <= 0) { + if (result === -3 && outCursor[0] <= 0 && this.shouldRetryCursorAfterNoDb()) { this.writeLog('openMessageCursorLite: result=-3 (no message db), attempting forceReopen...', true) const reopened = await this.forceReopen() if (reopened && this.handle !== null) { @@ -3218,11 +3298,13 @@ export class WcdbCore { } if (result !== 0 || outCursor[0] <= 0) { - await this.printLogs(true) - this.writeLog( - `openMessageCursorLite failed: sessionId=${sessionId} batchSize=${batchSize} ascending=${ascending ? 1 : 0} begin=${beginTimestamp} end=${endTimestamp} result=${result} cursor=${outCursor[0]}`, - true - ) + if (result !== -3) { + await this.printLogs(true) + this.writeLog( + `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:当前账号消息表结构与程序要求不一致' } } diff --git a/electron/services/wcdbService.ts b/electron/services/wcdbService.ts index 5e7478c..d4c77ef 100644 --- a/electron/services/wcdbService.ts +++ b/electron/services/wcdbService.ts @@ -448,6 +448,19 @@ export class WcdbService { return this.callWorker('getGroupStats', { chatroomId, beginTimestamp, endTimestamp }) } + async getMyFootprintStats(options: { + beginTimestamp?: number + endTimestamp?: number + myWxid?: string + privateSessionIds?: string[] + groupSessionIds?: string[] + mentionLimit?: number + privateLimit?: number + mentionMode?: 'text_at_me' | string + }): Promise<{ success: boolean; data?: any; error?: string }> { + return this.callWorker('getMyFootprintStats', { options }) + } + /** * 打开消息游标 */ diff --git a/electron/wcdbWorker.ts b/electron/wcdbWorker.ts index a666732..2992d01 100644 --- a/electron/wcdbWorker.ts +++ b/electron/wcdbWorker.ts @@ -158,6 +158,9 @@ if (parentPort) { case 'getGroupStats': result = await core.getGroupStats(payload.chatroomId, payload.beginTimestamp, payload.endTimestamp) break + case 'getMyFootprintStats': + result = await core.getMyFootprintStats(payload.options || {}) + break case 'openMessageCursor': result = await core.openMessageCursor(payload.sessionId, payload.batchSize, payload.ascending, payload.beginTimestamp, payload.endTimestamp) break diff --git a/electron/windows/notificationWindow.ts b/electron/windows/notificationWindow.ts index f3c8eca..21bbf01 100644 --- a/electron/windows/notificationWindow.ts +++ b/electron/windows/notificationWindow.ts @@ -115,12 +115,14 @@ export async function showNotification(data: any) { // 检查会话过滤 const filterMode = config.get("notificationFilterMode") || "all"; const filterList = config.get("notificationFilterList") || []; - const sessionId = data.sessionId; + const sessionId = typeof data.sessionId === "string" ? data.sessionId : ""; + // 系统通知(如 "WeFlow 准备就绪")不是聊天消息,不应受会话白/黑名单影响 + const isSystemNotification = sessionId.startsWith("weflow-"); - if (sessionId && filterMode !== "all" && filterList.length > 0) { - const isInList = filterList.includes(sessionId); + if (!isSystemNotification && filterMode !== "all") { + const isInList = sessionId !== "" && filterList.includes(sessionId); if (filterMode === "whitelist" && !isInList) { - // 白名单模式:不在列表中则不显示 + // 白名单模式:不在列表中则不显示(空列表视为全部拦截) return; } if (filterMode === "blacklist" && isInList) { diff --git a/package.json b/package.json index 0f05abe..2aac96c 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ }, "repository": { "type": "git", - "url": "https://github.com/hicccc77/WeFlow" + "url": "https://github.com/Jasonzhu1207/WeFlow" }, "//": "二改不应改变此处的作者与应用信息", "scripts": { @@ -23,7 +23,6 @@ "electron:build": "npm run build" }, "dependencies": { - "@vscode/sudo-prompt": "^9.3.2", "echarts": "^6.0.0", "echarts-for-react": "^3.0.2", "electron-store": "^11.0.2", @@ -42,8 +41,9 @@ "react-router-dom": "^7.14.0", "react-virtuoso": "^4.18.1", "remark-gfm": "^4.0.1", - "sherpa-onnx-node": "^1.12.35", + "sherpa-onnx-node": "^1.10.38", "silk-wasm": "^3.7.1", + "sudo-prompt": "^9.2.1", "wechat-emojis": "^1.0.2", "zustand": "^5.0.2" }, @@ -54,11 +54,11 @@ "@vitejs/plugin-react": "^4.3.4", "electron": "^41.1.1", "electron-builder": "^26.8.1", - "sass": "^1.99.0", + "sass": "^1.98.0", "sharp": "^0.34.5", "typescript": "^6.0.2", - "vite": "^7.3.2", - "vite-plugin-electron": "^0.29.1", + "vite": "^7.0.0", + "vite-plugin-electron": "^0.28.8", "vite-plugin-electron-renderer": "^0.14.6" }, "pnpm": { @@ -70,16 +70,14 @@ "lodash": ">=4.17.21", "brace-expansion": ">=1.1.11", "picomatch": ">=2.3.1", - "ajv": ">=8.18.0", - "ajv-keywords@3>ajv": "^6.12.6", - "@develar/schema-utils>ajv": "^6.12.6" + "ajv": ">=8.18.0" } }, "build": { "appId": "com.WeFlow.app", "publish": { "provider": "github", - "owner": "hicccc77", + "owner": "Jasonzhu1207", "repo": "WeFlow", "releaseType": "release" }, @@ -98,7 +96,7 @@ "gatekeeperAssess": false, "entitlements": "electron/entitlements.mac.plist", "entitlementsInherit": "electron/entitlements.mac.plist", - "icon": "resources/icons/macos/icon.icns" + "icon": "resources/icon.icns" }, "win": { "target": [ @@ -107,19 +105,19 @@ "icon": "public/icon.ico", "extraFiles": [ { - "from": "resources/runtime/win32/msvcp140.dll", + "from": "resources/msvcp140.dll", "to": "." }, { - "from": "resources/runtime/win32/msvcp140_1.dll", + "from": "resources/msvcp140_1.dll", "to": "." }, { - "from": "resources/runtime/win32/vcruntime140.dll", + "from": "resources/vcruntime140.dll", "to": "." }, { - "from": "resources/runtime/win32/vcruntime140_1.dll", + "from": "resources/vcruntime140_1.dll", "to": "." } ] @@ -135,7 +133,7 @@ "synopsis": "WeFlow for Linux", "extraFiles": [ { - "from": "resources/installer/linux/install.sh", + "from": "resources/linux/install.sh", "to": "install.sh" } ] @@ -190,7 +188,7 @@ "node_modules/sherpa-onnx-*/**/*", "node_modules/ffmpeg-static/**/*" ], - "icon": "resources/icons/macos/icon.icns" + "icon": "resources/icon.icns" }, "overrides": { "picomatch": "^4.0.4", diff --git a/resources/key/linux/x64/xkey_helper_linux b/resources/key/linux/x64/xkey_helper_linux old mode 100644 new mode 100755 diff --git a/resources/wcdb/linux/x64/libwcdb_api.so b/resources/wcdb/linux/x64/libwcdb_api.so index 8f698f3..63149bc 100644 Binary files a/resources/wcdb/linux/x64/libwcdb_api.so and b/resources/wcdb/linux/x64/libwcdb_api.so differ diff --git a/resources/wcdb/macos/universal/libwcdb_api.dylib b/resources/wcdb/macos/universal/libwcdb_api.dylib index 5a81c68..5ac39da 100644 Binary files a/resources/wcdb/macos/universal/libwcdb_api.dylib and b/resources/wcdb/macos/universal/libwcdb_api.dylib differ diff --git a/resources/wcdb/win32/arm64/wcdb_api.dll b/resources/wcdb/win32/arm64/wcdb_api.dll index 5f144d8..ef07c33 100644 Binary files a/resources/wcdb/win32/arm64/wcdb_api.dll and b/resources/wcdb/win32/arm64/wcdb_api.dll differ diff --git a/resources/wcdb/win32/x64/wcdb_api.dll b/resources/wcdb/win32/x64/wcdb_api.dll index 4dc46d7..05b6d96 100644 Binary files a/resources/wcdb/win32/x64/wcdb_api.dll and b/resources/wcdb/win32/x64/wcdb_api.dll differ diff --git a/src/App.tsx b/src/App.tsx index c9c574b..a0f11d4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,6 +17,7 @@ import AgreementPage from './pages/AgreementPage' import GroupAnalyticsPage from './pages/GroupAnalyticsPage' import SettingsPage from './pages/SettingsPage' import ExportPage from './pages/ExportPage' +import MyFootprintPage from './pages/MyFootprintPage' import VideoWindow from './pages/VideoWindow' import ImageWindow from './pages/ImageWindow' import SnsPage from './pages/SnsPage' @@ -25,6 +26,7 @@ import ContactsPage from './pages/ContactsPage' import ResourcesPage from './pages/ResourcesPage' import ChatHistoryPage from './pages/ChatHistoryPage' import NotificationWindow from './pages/NotificationWindow' +import AccountManagementPage from './pages/AccountManagementPage' import { useAppStore } from './stores/appStore' import { themes, useThemeStore, type ThemeId, type ThemeMode } from './stores/themeStore' @@ -677,6 +679,7 @@ function App() { } /> } /> + } /> } /> } /> @@ -689,6 +692,7 @@ function App() { } /> } /> } /> + } />
+
event.stopPropagation()} + > + + {openTimeDropdown === 'end' && renderTimeDropdown('end')} +
@@ -453,7 +736,14 @@ export function ExportDateRangeDialog({ diff --git a/src/components/Export/ExportDefaultsSettingsForm.tsx b/src/components/Export/ExportDefaultsSettingsForm.tsx index 6824e5b..1e3d8b1 100644 --- a/src/components/Export/ExportDefaultsSettingsForm.tsx +++ b/src/components/Export/ExportDefaultsSettingsForm.tsx @@ -15,6 +15,7 @@ export interface ExportDefaultsSettingsPatch { format?: string avatars?: boolean dateRange?: ExportDateRangeSelection + fileNamingMode?: configService.ExportFileNamingMode media?: configService.ExportDefaultMediaConfig voiceAsText?: boolean excelCompactColumns?: boolean @@ -44,6 +45,11 @@ const exportExcelColumnOptions = [ { value: 'full', label: '完整列', desc: '含发送者昵称/微信ID/备注' } ] as const +const exportFileNamingModeOptions: Array<{ value: configService.ExportFileNamingMode; label: string; desc: string }> = [ + { value: 'classic', label: '简洁模式', desc: '示例:私聊_张三(兼容旧版)' }, + { value: 'date-range', label: '时间范围模式', desc: '示例:私聊_张三_20250101-20250331(推荐)' } +] + const exportConcurrencyOptions = [1, 2, 3, 4, 5, 6] as const const getOptionLabel = (options: ReadonlyArray<{ value: string; label: string }>, value: string) => { @@ -56,12 +62,15 @@ export function ExportDefaultsSettingsForm({ layout = 'stacked' }: ExportDefaultsSettingsFormProps) { const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false) + const [showExportFileNamingModeSelect, setShowExportFileNamingModeSelect] = useState(false) const [isExportDateRangeDialogOpen, setIsExportDateRangeDialogOpen] = useState(false) const exportExcelColumnsDropdownRef = useRef(null) + const exportFileNamingModeDropdownRef = useRef(null) const [exportDefaultFormat, setExportDefaultFormat] = useState('excel') const [exportDefaultAvatars, setExportDefaultAvatars] = useState(true) const [exportDefaultDateRange, setExportDefaultDateRange] = useState(() => createDefaultExportDateRangeSelection()) + const [exportDefaultFileNamingMode, setExportDefaultFileNamingMode] = useState('classic') const [exportDefaultMedia, setExportDefaultMedia] = useState({ images: true, videos: true, @@ -76,10 +85,11 @@ export function ExportDefaultsSettingsForm({ useEffect(() => { let cancelled = false void (async () => { - const [savedFormat, savedAvatars, savedDateRange, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedConcurrency] = await Promise.all([ + const [savedFormat, savedAvatars, savedDateRange, savedFileNamingMode, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedConcurrency] = await Promise.all([ configService.getExportDefaultFormat(), configService.getExportDefaultAvatars(), configService.getExportDefaultDateRange(), + configService.getExportDefaultFileNamingMode(), configService.getExportDefaultMedia(), configService.getExportDefaultVoiceAsText(), configService.getExportDefaultExcelCompactColumns(), @@ -91,6 +101,7 @@ export function ExportDefaultsSettingsForm({ setExportDefaultFormat(savedFormat || 'excel') setExportDefaultAvatars(savedAvatars ?? true) setExportDefaultDateRange(resolveExportDateRangeConfig(savedDateRange)) + setExportDefaultFileNamingMode(savedFileNamingMode ?? 'classic') setExportDefaultMedia(savedMedia ?? { images: true, videos: true, @@ -114,15 +125,19 @@ export function ExportDefaultsSettingsForm({ if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) { setShowExportExcelColumnsSelect(false) } + if (showExportFileNamingModeSelect && exportFileNamingModeDropdownRef.current && !exportFileNamingModeDropdownRef.current.contains(target)) { + setShowExportFileNamingModeSelect(false) + } } document.addEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside) - }, [showExportExcelColumnsSelect]) + }, [showExportExcelColumnsSelect, showExportFileNamingModeSelect]) const exportExcelColumnsValue = exportDefaultExcelCompactColumns ? 'compact' : 'full' const exportDateRangeLabel = useMemo(() => getExportDateRangeLabel(exportDefaultDateRange), [exportDefaultDateRange]) const exportExcelColumnsLabel = useMemo(() => getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue), [exportExcelColumnsValue]) + const exportFileNamingModeLabel = useMemo(() => getOptionLabel(exportFileNamingModeOptions, exportDefaultFileNamingMode), [exportDefaultFileNamingMode]) const notify = (text: string, success = true) => { onNotify?.(text, success) @@ -224,6 +239,7 @@ export function ExportDefaultsSettingsForm({ className={`settings-time-range-trigger ${isExportDateRangeDialogOpen ? 'open' : ''}`} onClick={() => { setShowExportExcelColumnsSelect(false) + setShowExportFileNamingModeSelect(false) setIsExportDateRangeDialogOpen(true) }} > @@ -247,6 +263,50 @@ export function ExportDefaultsSettingsForm({ }} /> +
+
+ + 控制导出文件名是否包含时间范围 +
+
+
+ + {showExportFileNamingModeSelect && ( +
+ {exportFileNamingModeOptions.map((option) => ( + + ))} +
+ )} +
+
+
+
@@ -259,6 +319,7 @@ export function ExportDefaultsSettingsForm({ className={`select-trigger ${showExportExcelColumnsSelect ? 'open' : ''}`} onClick={() => { setShowExportExcelColumnsSelect(!showExportExcelColumnsSelect) + setShowExportFileNamingModeSelect(false) setIsExportDateRangeDialogOpen(false) }} > diff --git a/src/components/Sidebar.scss b/src/components/Sidebar.scss index 31d4725..5f153ee 100644 --- a/src/components/Sidebar.scss +++ b/src/components/Sidebar.scss @@ -275,263 +275,6 @@ gap: 4px; } -.sidebar-dialog-overlay { - position: fixed; - inset: 0; - background: rgba(15, 23, 42, 0.3); - display: flex; - align-items: center; - justify-content: center; - z-index: 1100; - padding: 20px; - animation: fadeIn 0.2s ease; -} - -@keyframes fadeIn { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -.sidebar-dialog { - width: min(420px, 100%); - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 16px; - box-shadow: 0 18px 40px rgba(15, 23, 42, 0.24); - padding: 18px 18px 16px; - animation: slideUp 0.25s ease; - - h3 { - margin: 0; - font-size: 16px; - color: var(--text-primary); - } - - p { - margin: 10px 0 0; - font-size: 13px; - line-height: 1.6; - color: var(--text-secondary); - } -} - -@keyframes slideUp { - from { - opacity: 0; - transform: translateY(20px) scale(0.95); - } - to { - opacity: 1; - transform: translateY(0) scale(1); - } -} - -.sidebar-wxid-list { - margin-top: 14px; - display: flex; - flex-direction: column; - gap: 8px; - max-height: 300px; - overflow-y: auto; -} - -.sidebar-wxid-item { - width: 100%; - padding: 12px 14px; - border: 1px solid var(--border-color); - border-radius: 10px; - background: var(--bg-secondary); - color: var(--text-primary); - font-size: 13px; - cursor: pointer; - display: flex; - align-items: center; - gap: 12px; - transition: all 0.2s ease; - - &:hover:not(:disabled) { - border-color: rgba(99, 102, 241, 0.32); - background: var(--bg-tertiary); - } - - &.current { - border-color: rgba(99, 102, 241, 0.5); - background: var(--bg-tertiary); - } - - &:disabled { - cursor: not-allowed; - opacity: 0.6; - } - - .wxid-avatar { - width: 40px; - height: 40px; - border-radius: 10px; - overflow: hidden; - background: linear-gradient(135deg, var(--primary), var(--primary-hover)); - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - - img { - width: 100%; - height: 100%; - object-fit: cover; - } - - span { - color: var(--on-primary); - font-size: 16px; - font-weight: 600; - } - } - - .wxid-info { - flex: 1; - min-width: 0; - text-align: left; - } - - .wxid-name { - font-size: 14px; - font-weight: 600; - color: var(--text-primary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .wxid-id { - margin-top: 2px; - font-size: 12px; - color: var(--text-tertiary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .current-badge { - padding: 4px 10px; - border-radius: 6px; - background: var(--primary); - color: var(--on-primary); - font-size: 11px; - font-weight: 600; - flex-shrink: 0; - } -} - -.sidebar-dialog-actions { - margin-top: 18px; - display: flex; - justify-content: flex-end; - gap: 10px; - - button { - border: 1px solid var(--border-color); - border-radius: 10px; - padding: 8px 14px; - font-size: 13px; - cursor: pointer; - background: var(--bg-secondary); - color: var(--text-primary); - transition: all 0.2s ease; - - &:hover:not(:disabled) { - background: var(--bg-tertiary); - } - - &:disabled { - cursor: not-allowed; - opacity: 0.6; - } - } -} - -.sidebar-clear-dialog-overlay { - position: fixed; - inset: 0; - background: rgba(15, 23, 42, 0.3); - display: flex; - align-items: center; - justify-content: center; - z-index: 1100; - padding: 20px; - animation: fadeIn 0.2s ease; -} - -.sidebar-clear-dialog { - width: min(460px, 100%); - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 16px; - box-shadow: 0 18px 40px rgba(15, 23, 42, 0.24); - padding: 18px 18px 16px; - animation: slideUp 0.25s ease; - - h3 { - margin: 0; - font-size: 16px; - color: var(--text-primary); - } - - p { - margin: 10px 0 0; - font-size: 13px; - line-height: 1.6; - color: var(--text-secondary); - } -} - -.sidebar-clear-options { - margin-top: 14px; - display: flex; - gap: 14px; - flex-wrap: wrap; - - label { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 13px; - color: var(--text-primary); - } -} - -.sidebar-clear-actions { - margin-top: 18px; - display: flex; - justify-content: flex-end; - gap: 10px; - - button { - border: 1px solid var(--border-color); - border-radius: 10px; - padding: 8px 14px; - font-size: 13px; - cursor: pointer; - background: var(--bg-secondary); - color: var(--text-primary); - } - - button:disabled { - cursor: not-allowed; - opacity: 0.6; - } - - .danger { - border-color: #ef4444; - background: #ef4444; - color: #fff; - } -} - // 繁花如梦主题:侧边栏毛玻璃 + 激活项用主品牌色 [data-theme="blossom-dream"] .sidebar { background: rgba(255, 255, 255, 0.6); diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 5fa3e9b..4b9a0e7 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,12 +1,9 @@ import { useState, useEffect, useRef } from 'react' import { NavLink, useLocation, useNavigate } from 'react-router-dom' -import { Home, MessageSquare, BarChart3, FileText, Settings, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, RefreshCw, FolderClosed } from 'lucide-react' +import { Home, MessageSquare, BarChart3, FileText, Settings, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, FolderClosed, Footprints, Users } from 'lucide-react' import { useAppStore } from '../stores/appStore' -import { useChatStore } from '../stores/chatStore' -import { useAnalyticsStore } from '../stores/analyticsStore' import * as configService from '../services/config' import { onExportSessionStatus, requestExportSessionStatus } from '../services/exportBridge' -import { UserRound } from 'lucide-react' import './Sidebar.scss' @@ -19,6 +16,8 @@ interface SidebarUserProfile { const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1' const ACCOUNT_PROFILES_CACHE_KEY = 'account_profiles_cache_v1' +const DEFAULT_DISPLAY_NAME = '微信用户' +const DEFAULT_SUBTITLE = '微信账号' interface SidebarUserProfileCache extends SidebarUserProfile { updatedAt: number @@ -33,24 +32,16 @@ interface AccountProfilesCache { } } -interface WxidOption { - wxid: string - modifiedTime: number - nickname?: string - displayName?: string - avatarUrl?: string -} - const readSidebarUserProfileCache = (): SidebarUserProfile | null => { try { const raw = window.localStorage.getItem(SIDEBAR_USER_PROFILE_CACHE_KEY) if (!raw) return null const parsed = JSON.parse(raw) as SidebarUserProfileCache if (!parsed || typeof parsed !== 'object') return null - if (!parsed.wxid || !parsed.displayName) return null + if (!parsed.wxid) return null return { wxid: parsed.wxid, - displayName: parsed.displayName, + displayName: typeof parsed.displayName === 'string' ? parsed.displayName : '', alias: parsed.alias, avatarUrl: parsed.avatarUrl } @@ -60,7 +51,7 @@ const readSidebarUserProfileCache = (): SidebarUserProfile | null => { } const writeSidebarUserProfileCache = (profile: SidebarUserProfile): void => { - if (!profile.wxid || !profile.displayName) return + if (!profile.wxid) return try { const payload: SidebarUserProfileCache = { ...profile, @@ -115,17 +106,11 @@ function Sidebar({ collapsed }: SidebarProps) { const [activeExportTaskCount, setActiveExportTaskCount] = useState(0) const [userProfile, setUserProfile] = useState({ wxid: '', - displayName: '未识别用户' + displayName: DEFAULT_DISPLAY_NAME }) const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false) - const [showSwitchAccountDialog, setShowSwitchAccountDialog] = useState(false) - const [wxidOptions, setWxidOptions] = useState([]) - const [isSwitchingAccount, setIsSwitchingAccount] = useState(false) const accountCardWrapRef = useRef(null) const setLocked = useAppStore(state => state.setLocked) - const isDbConnected = useAppStore(state => state.isDbConnected) - const resetChatStore = useChatStore(state => state.reset) - const clearAnalyticsStoreCache = useAnalyticsStore(state => state.clearCache) useEffect(() => { window.electronAPI.auth.verifyEnabled().then(setAuthEnabled) @@ -164,18 +149,20 @@ function Sidebar({ collapsed }: SidebarProps) { }, []) useEffect(() => { + let disposed = false + let loadSeq = 0 + const loadCurrentUser = async () => { - const patchUserProfile = (patch: Partial, expectedWxid?: string) => { + const seq = ++loadSeq + const patchUserProfile = (patch: Partial) => { + if (disposed || seq !== loadSeq) return setUserProfile(prev => { - if (expectedWxid && prev.wxid && prev.wxid !== expectedWxid) { - return prev - } const next: SidebarUserProfile = { ...prev, ...patch } - if (!next.displayName) { - next.displayName = next.wxid || '未识别用户' + if (typeof next.displayName !== 'string' || next.displayName.length === 0) { + next.displayName = DEFAULT_DISPLAY_NAME } writeSidebarUserProfileCache(next) return next @@ -184,11 +171,33 @@ function Sidebar({ collapsed }: SidebarProps) { try { const wxid = await configService.getMyWxid() + if (disposed || seq !== loadSeq) return const resolvedWxidRaw = String(wxid || '').trim() const cleanedWxid = normalizeAccountId(resolvedWxidRaw) const resolvedWxid = cleanedWxid || resolvedWxidRaw - if (!resolvedWxidRaw && !resolvedWxid) return + if (!resolvedWxidRaw && !resolvedWxid) { + window.localStorage.removeItem(SIDEBAR_USER_PROFILE_CACHE_KEY) + patchUserProfile({ + wxid: '', + displayName: DEFAULT_DISPLAY_NAME, + alias: undefined, + avatarUrl: undefined + }) + return + } + + setUserProfile((prev) => { + if (prev.wxid === resolvedWxid) return prev + const seeded: SidebarUserProfile = { + wxid: resolvedWxid, + displayName: DEFAULT_DISPLAY_NAME, + alias: undefined, + avatarUrl: undefined + } + writeSidebarUserProfileCache(seeded) + return seeded + }) const wxidCandidates = new Set([ resolvedWxidRaw.toLowerCase(), @@ -197,14 +206,13 @@ function Sidebar({ collapsed }: SidebarProps) { ].filter(Boolean)) const normalizeName = (value?: string | null): string | undefined => { - if (!value) return undefined - const trimmed = value.trim() - if (!trimmed) return undefined - const lowered = trimmed.toLowerCase() + if (typeof value !== 'string') return undefined + if (value.length === 0) return undefined + const lowered = value.trim().toLowerCase() if (lowered === 'self') return undefined if (lowered.startsWith('wxid_')) return undefined if (wxidCandidates.has(lowered)) return undefined - return trimmed + return value } const pickFirstValidName = (...candidates: Array): string | undefined => { @@ -229,18 +237,20 @@ function Sidebar({ collapsed }: SidebarProps) { })(), window.electronAPI.chat.getMyAvatarUrl() ]) + if (disposed || seq !== loadSeq) return const myContact = contactResult.status === 'fulfilled' ? contactResult.value : null const displayName = pickFirstValidName( myContact?.remark, myContact?.nickName, myContact?.alias - ) || resolvedWxid || '未识别用户' + ) || DEFAULT_DISPLAY_NAME + const alias = normalizeName(myContact?.alias) patchUserProfile({ wxid: resolvedWxid, displayName, - alias: myContact?.alias, + alias, avatarUrl: avatarResult.status === 'fulfilled' && avatarResult.value.success ? avatarResult.value.avatarUrl : undefined @@ -257,118 +267,28 @@ function Sidebar({ collapsed }: SidebarProps) { void loadCurrentUser() const onWxidChanged = () => { void loadCurrentUser() } + const onWindowFocus = () => { void loadCurrentUser() } + const onVisibilityChange = () => { + if (document.visibilityState === 'visible') { + void loadCurrentUser() + } + } window.addEventListener('wxid-changed', onWxidChanged as EventListener) - return () => window.removeEventListener('wxid-changed', onWxidChanged as EventListener) + window.addEventListener('focus', onWindowFocus) + document.addEventListener('visibilitychange', onVisibilityChange) + return () => { + disposed = true + loadSeq += 1 + window.removeEventListener('wxid-changed', onWxidChanged as EventListener) + window.removeEventListener('focus', onWindowFocus) + document.removeEventListener('visibilitychange', onVisibilityChange) + } }, []) const getAvatarLetter = (name: string): string => { - if (!name) return '?' - return [...name][0] || '?' - } - - const openSwitchAccountDialog = async () => { - setIsAccountMenuOpen(false) - if (!isDbConnected) { - window.alert('数据库未连接,无法切换账号') - return - } - const dbPath = await configService.getDbPath() - if (!dbPath) { - window.alert('请先在设置中配置数据库路径') - return - } - try { - const wxids = await window.electronAPI.dbPath.scanWxids(dbPath) - const accountsCache = readAccountProfilesCache() - console.log('[切换账号] 账号缓存:', accountsCache) - - const enrichedWxids = wxids.map((option: WxidOption) => { - const normalizedWxid = normalizeAccountId(option.wxid) - const cached = accountsCache[option.wxid] || accountsCache[normalizedWxid] - - let displayName = option.nickname || option.wxid - let avatarUrl = option.avatarUrl - - if (option.wxid === userProfile.wxid || normalizedWxid === userProfile.wxid) { - displayName = userProfile.displayName || displayName - avatarUrl = userProfile.avatarUrl || avatarUrl - } - - else if (cached) { - displayName = cached.displayName || displayName - avatarUrl = cached.avatarUrl || avatarUrl - } - - return { - ...option, - displayName, - avatarUrl - } - }) - - setWxidOptions(enrichedWxids) - setShowSwitchAccountDialog(true) - } catch (error) { - console.error('扫描账号失败:', error) - window.alert('扫描账号失败,请稍后重试') - } - } - - const handleSwitchAccount = async (selectedWxid: string) => { - if (!selectedWxid || isSwitchingAccount) return - setIsSwitchingAccount(true) - try { - console.log('[切换账号] 开始切换到:', selectedWxid) - const currentWxid = userProfile.wxid - if (currentWxid === selectedWxid) { - console.log('[切换账号] 已经是当前账号,跳过') - setShowSwitchAccountDialog(false) - setIsSwitchingAccount(false) - return - } - - console.log('[切换账号] 设置新 wxid') - await configService.setMyWxid(selectedWxid) - - console.log('[切换账号] 获取账号配置') - const wxidConfig = await configService.getWxidConfig(selectedWxid) - console.log('[切换账号] 配置内容:', wxidConfig) - if (wxidConfig?.decryptKey) { - console.log('[切换账号] 设置 decryptKey') - await configService.setDecryptKey(wxidConfig.decryptKey) - } - if (typeof wxidConfig?.imageXorKey === 'number') { - console.log('[切换账号] 设置 imageXorKey:', wxidConfig.imageXorKey) - await configService.setImageXorKey(wxidConfig.imageXorKey) - } - if (wxidConfig?.imageAesKey) { - console.log('[切换账号] 设置 imageAesKey') - await configService.setImageAesKey(wxidConfig.imageAesKey) - } - - console.log('[切换账号] 检查数据库连接状态') - console.log('[切换账号] 数据库连接状态:', isDbConnected) - if (isDbConnected) { - console.log('[切换账号] 关闭数据库连接') - await window.electronAPI.chat.close() - } - - console.log('[切换账号] 清除缓存') - window.localStorage.removeItem(SIDEBAR_USER_PROFILE_CACHE_KEY) - clearAnalyticsStoreCache() - resetChatStore() - - console.log('[切换账号] 触发 wxid-changed 事件') - window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: selectedWxid } })) - - console.log('[切换账号] 切换成功') - setShowSwitchAccountDialog(false) - } catch (error) { - console.error('[切换账号] 失败:', error) - window.alert('切换账号失败,请稍后重试') - } finally { - setIsSwitchingAccount(false) - } + if (!name) return '微' + const visible = name.trim() + return (visible && [...visible][0]) || '微' } const openSettingsFromAccountMenu = () => { @@ -380,6 +300,11 @@ function Sidebar({ collapsed }: SidebarProps) { }) } + const openAccountManagement = () => { + setIsAccountMenuOpen(false) + navigate('/account-management') + } + const isActive = (path: string) => { return location.pathname === path || location.pathname.startsWith(`${path}/`) } @@ -459,6 +384,16 @@ function Sidebar({ collapsed }: SidebarProps) { 年度报告 + {/* 我的足迹 */} + + + 我的足迹 + + {/* 导出 */}
- - {showSwitchAccountDialog && ( -
!isSwitchingAccount && setShowSwitchAccountDialog(false)}> -
event.stopPropagation()}> -

切换账号

-

选择要切换的微信账号

-
- {wxidOptions.map((option) => ( - - ))} -
-
- -
-
-
- )} ) } diff --git a/src/pages/AccountManagementPage.scss b/src/pages/AccountManagementPage.scss new file mode 100644 index 0000000..1c0215e --- /dev/null +++ b/src/pages/AccountManagementPage.scss @@ -0,0 +1,274 @@ +.account-management-page { + padding: 22px 24px; + min-height: 100%; + display: flex; + flex-direction: column; + gap: 14px; + color: var(--text-primary); +} + +.account-management-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + + h2 { + margin: 0; + font-size: 22px; + font-weight: 700; + letter-spacing: -0.01em; + } + + p { + margin: 6px 0 0; + color: var(--text-secondary); + font-size: 13px; + } +} + +.account-management-actions { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.account-management-summary { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.summary-item { + border: 1px solid var(--border-color); + border-radius: 12px; + background: var(--bg-secondary); + padding: 10px 12px; +} + +.summary-label { + display: block; + font-size: 11px; + color: var(--text-tertiary); +} + +.summary-value { + display: block; + margin-top: 4px; + font-size: 13px; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.account-notice { + border-radius: 10px; + padding: 10px 12px; + font-size: 13px; + border: 1px solid transparent; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.notice-action { + border: 1px solid currentColor; + border-radius: 999px; + background: transparent; + color: inherit; + font-size: 12px; + font-weight: 600; + padding: 4px 10px; + cursor: pointer; + white-space: nowrap; + transition: opacity 0.2s ease, background 0.2s ease; + + &:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.35); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } +} + +.account-notice.success { + background: rgba(34, 197, 94, 0.12); + color: #15803d; + border-color: rgba(34, 197, 94, 0.25); +} + +.account-notice.error { + background: rgba(239, 68, 68, 0.12); + color: #b91c1c; + border-color: rgba(239, 68, 68, 0.25); +} + +.account-notice.info { + background: rgba(59, 130, 246, 0.12); + color: #1d4ed8; + border-color: rgba(59, 130, 246, 0.25); +} + +.account-empty { + border: 1px dashed var(--border-color); + border-radius: 12px; + background: var(--bg-secondary); + padding: 18px 14px; + color: var(--text-secondary); + font-size: 13px; + display: flex; + align-items: center; + gap: 8px; +} + +.account-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.account-card { + border: 1px solid var(--border-color); + border-radius: 14px; + background: var(--bg-secondary); + padding: 12px; + display: flex; + gap: 12px; + + &.is-current { + border-color: color-mix(in srgb, var(--primary) 60%, var(--border-color)); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--primary) 25%, transparent); + } +} + +.account-avatar { + width: 42px; + height: 42px; + border-radius: 10px; + overflow: hidden; + background: linear-gradient(135deg, var(--primary), var(--primary-hover)); + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + span { + color: var(--on-primary); + font-weight: 600; + font-size: 14px; + } +} + +.account-main { + min-width: 0; + flex: 1; +} + +.account-title-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + + h3 { + margin: 0; + font-size: 15px; + color: var(--text-primary); + } +} + +.account-badge { + display: inline-flex; + align-items: center; + gap: 4px; + border-radius: 999px; + padding: 1px 8px; + font-size: 11px; + font-weight: 600; + + &.current { + color: #0f766e; + background: rgba(20, 184, 166, 0.14); + } + + &.ok { + color: #166534; + background: rgba(34, 197, 94, 0.12); + } + + &.warn { + color: #b45309; + background: rgba(245, 158, 11, 0.15); + } +} + +.account-meta { + margin-top: 3px; + font-size: 12px; + color: var(--text-tertiary); + word-break: break-all; +} + +.meta-tip { + margin-left: 6px; + color: var(--text-secondary); +} + +.account-card-actions { + display: inline-flex; + flex-direction: column; + gap: 8px; + align-items: stretch; + + .btn { + min-width: 104px; + justify-content: center; + } +} + +.account-management-footer { + margin-top: 2px; + color: var(--text-tertiary); + font-size: 12px; +} + +.account-management-page { + .btn-danger { + background: rgba(239, 68, 68, 0.12); + color: #b91c1c; + + &:hover:not(:disabled) { + background: rgba(239, 68, 68, 0.2); + } + } + + .btn:disabled { + opacity: 0.55; + cursor: not-allowed; + } +} + +@media (max-width: 920px) { + .account-management-summary { + grid-template-columns: 1fr; + } + + .account-card { + flex-direction: column; + } + + .account-card-actions { + flex-direction: row; + flex-wrap: wrap; + } +} diff --git a/src/pages/AccountManagementPage.tsx b/src/pages/AccountManagementPage.tsx new file mode 100644 index 0000000..99e022e --- /dev/null +++ b/src/pages/AccountManagementPage.tsx @@ -0,0 +1,574 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { RefreshCw, UserPlus, Trash2, ArrowRightLeft, CheckCircle2, Database } from 'lucide-react' +import { useAppStore } from '../stores/appStore' +import { useChatStore } from '../stores/chatStore' +import { useAnalyticsStore } from '../stores/analyticsStore' +import * as configService from '../services/config' +import './AccountManagementPage.scss' + +interface ScannedWxidOption { + wxid: string + modifiedTime: number + nickname?: string + avatarUrl?: string +} + +interface ManagedAccountItem { + wxid: string + normalizedWxid: string + displayName: string + avatarUrl?: string + modifiedTime?: number + configUpdatedAt?: number + hasConfig: boolean + isCurrent: boolean + fromScan: boolean +} + +type AccountProfileCacheEntry = { + displayName?: string + avatarUrl?: string + updatedAt?: number +} + +interface DeleteUndoState { + targetWxid: string + deletedConfigEntries: Array<[string, configService.WxidConfig]> + deletedProfileEntries: Array<[string, AccountProfileCacheEntry]> + previousCurrentWxid: string + shouldRestoreAsCurrent: boolean + previousDbConnected: boolean +} + +type NoticeState = + | { type: 'success' | 'error' | 'info'; text: string } + | null + +const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1' +const ACCOUNT_PROFILES_CACHE_KEY = 'account_profiles_cache_v1' +const hiddenDeletedAccountIds = new Set() +const DEFAULT_ACCOUNT_DISPLAY_NAME = '微信用户' + +const normalizeAccountId = (value?: string | null): string => { + const trimmed = String(value || '').trim() + if (!trimmed) return '' + if (trimmed.toLowerCase().startsWith('wxid_')) { + const match = trimmed.match(/^(wxid_[^_]+)/i) + return match?.[1] || trimmed + } + const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) + return suffixMatch ? suffixMatch[1] : trimmed +} + +const resolveAccountDisplayName = ( + candidates: Array, + wxidCandidates: Set +): string => { + for (const candidate of candidates) { + if (typeof candidate !== 'string') continue + if (candidate.length === 0) continue + const normalized = candidate.trim().toLowerCase() + if (normalized.startsWith('wxid_')) continue + if (normalized && wxidCandidates.has(normalized)) continue + return candidate + } + return DEFAULT_ACCOUNT_DISPLAY_NAME +} + +const resolveAccountAvatarText = (displayName?: string): string => { + if (typeof displayName !== 'string' || displayName.length === 0) return '微' + const visible = displayName.trim() + return (visible && [...visible][0]) || '微' +} + +const readAccountProfilesCache = (): Record => { + try { + const raw = window.localStorage.getItem(ACCOUNT_PROFILES_CACHE_KEY) + if (!raw) return {} + const parsed = JSON.parse(raw) + return parsed && typeof parsed === 'object' ? parsed as Record : {} + } catch { + return {} + } +} + +function AccountManagementPage() { + const isDbConnected = useAppStore(state => state.isDbConnected) + const setDbConnected = useAppStore(state => state.setDbConnected) + const resetChatStore = useChatStore(state => state.reset) + const clearAnalyticsStoreCache = useAnalyticsStore(state => state.clearCache) + + const [dbPath, setDbPath] = useState('') + const [currentWxid, setCurrentWxid] = useState('') + const [accounts, setAccounts] = useState([]) + const [isLoading, setIsLoading] = useState(false) + const [workingWxid, setWorkingWxid] = useState('') + const [notice, setNotice] = useState(null) + const [deleteUndoState, setDeleteUndoState] = useState(null) + + const loadAccounts = useCallback(async () => { + setIsLoading(true) + try { + const [path, rawCurrentWxid, wxidConfigs] = await Promise.all([ + configService.getDbPath(), + configService.getMyWxid(), + configService.getWxidConfigs() + ]) + const nextDbPath = String(path || '').trim() + const nextCurrentWxid = String(rawCurrentWxid || '').trim() + const normalizedCurrent = normalizeAccountId(nextCurrentWxid) || nextCurrentWxid + setDbPath(nextDbPath) + setCurrentWxid(nextCurrentWxid) + + let scannedWxids: ScannedWxidOption[] = [] + if (nextDbPath) { + try { + const scanned = await window.electronAPI.dbPath.scanWxids(nextDbPath) + scannedWxids = Array.isArray(scanned) ? scanned as ScannedWxidOption[] : [] + } catch { + scannedWxids = [] + } + } + + const accountProfileCache = readAccountProfilesCache() + const configEntries = Object.entries(wxidConfigs || {}) + const configByNormalized = new Map() + for (const [wxid, cfg] of configEntries) { + const normalized = normalizeAccountId(wxid) || wxid + if (!normalized) continue + const previous = configByNormalized.get(normalized) + if (!previous || Number(cfg?.updatedAt || 0) > Number(previous.value?.updatedAt || 0)) { + configByNormalized.set(normalized, { key: wxid, value: cfg || {} }) + } + } + + const merged = new Map() + for (const scanned of scannedWxids) { + const normalized = normalizeAccountId(scanned.wxid) || scanned.wxid + if (!normalized) continue + const cached = accountProfileCache[scanned.wxid] || accountProfileCache[normalized] + const matchedConfig = configByNormalized.get(normalized) + const wxidCandidates = new Set([ + String(scanned.wxid || '').trim().toLowerCase(), + String(normalized || '').trim().toLowerCase() + ].filter(Boolean)) + const displayName = resolveAccountDisplayName( + [scanned.nickname, cached?.displayName], + wxidCandidates + ) + merged.set(normalized, { + wxid: scanned.wxid, + normalizedWxid: normalized, + displayName, + avatarUrl: scanned.avatarUrl || cached?.avatarUrl, + modifiedTime: Number(scanned.modifiedTime || 0), + configUpdatedAt: Number(matchedConfig?.value?.updatedAt || 0), + hasConfig: Boolean(matchedConfig), + isCurrent: normalizedCurrent && normalized === normalizedCurrent, + fromScan: true + }) + } + + for (const [normalized, matchedConfig] of configByNormalized.entries()) { + if (merged.has(normalized)) continue + const wxid = matchedConfig.key + const cached = accountProfileCache[wxid] || accountProfileCache[normalized] + const wxidCandidates = new Set([ + String(wxid || '').trim().toLowerCase(), + String(normalized || '').trim().toLowerCase() + ].filter(Boolean)) + const displayName = resolveAccountDisplayName( + [cached?.displayName], + wxidCandidates + ) + merged.set(normalized, { + wxid, + normalizedWxid: normalized, + displayName, + avatarUrl: cached?.avatarUrl, + modifiedTime: 0, + configUpdatedAt: Number(matchedConfig.value?.updatedAt || 0), + hasConfig: true, + isCurrent: normalizedCurrent && normalized === normalizedCurrent, + fromScan: false + }) + } + + // 被“删除配置”操作移除的账号,在当前会话中从列表隐藏; + // 若后续再次生成配置,则自动恢复展示。 + for (const [normalized, item] of Array.from(merged.entries())) { + if (!hiddenDeletedAccountIds.has(normalized)) continue + if (item.hasConfig) { + hiddenDeletedAccountIds.delete(normalized) + continue + } + merged.delete(normalized) + } + + const nextAccounts = Array.from(merged.values()).sort((a, b) => { + if (a.isCurrent && !b.isCurrent) return -1 + if (!a.isCurrent && b.isCurrent) return 1 + const scanDiff = Number(b.modifiedTime || 0) - Number(a.modifiedTime || 0) + if (scanDiff !== 0) return scanDiff + return Number(b.configUpdatedAt || 0) - Number(a.configUpdatedAt || 0) + }) + setAccounts(nextAccounts) + } catch (error) { + console.error('加载账号列表失败:', error) + setNotice({ type: 'error', text: '加载账号列表失败,请稍后重试' }) + setAccounts([]) + } finally { + setIsLoading(false) + } + }, []) + + useEffect(() => { + void loadAccounts() + const onWxidChanged = () => { void loadAccounts() } + const onWindowFocus = () => { void loadAccounts() } + const onVisibilityChange = () => { + if (document.visibilityState === 'visible') { + void loadAccounts() + } + } + window.addEventListener('wxid-changed', onWxidChanged as EventListener) + window.addEventListener('focus', onWindowFocus) + document.addEventListener('visibilitychange', onVisibilityChange) + return () => { + window.removeEventListener('wxid-changed', onWxidChanged as EventListener) + window.removeEventListener('focus', onWindowFocus) + document.removeEventListener('visibilitychange', onVisibilityChange) + } + }, [loadAccounts]) + + const clearRuntimeCacheState = useCallback(async () => { + if (isDbConnected) { + await window.electronAPI.chat.close() + } + window.localStorage.removeItem(SIDEBAR_USER_PROFILE_CACHE_KEY) + clearAnalyticsStoreCache() + resetChatStore() + }, [clearAnalyticsStoreCache, isDbConnected, resetChatStore]) + + const applyWxidConfig = useCallback(async (wxid: string, wxidConfig: configService.WxidConfig | null) => { + await configService.setMyWxid(wxid) + await configService.setDecryptKey(wxidConfig?.decryptKey || '') + await configService.setImageXorKey(typeof wxidConfig?.imageXorKey === 'number' ? wxidConfig.imageXorKey : 0) + await configService.setImageAesKey(wxidConfig?.imageAesKey || '') + }, []) + + const handleSwitchAccount = useCallback(async (wxid: string) => { + if (!wxid || workingWxid) return + const targetNormalized = normalizeAccountId(wxid) || wxid + const currentNormalized = normalizeAccountId(currentWxid) || currentWxid + if (targetNormalized && currentNormalized && targetNormalized === currentNormalized) return + + setWorkingWxid(wxid) + setNotice(null) + setDeleteUndoState(null) + try { + const allConfigs = await configService.getWxidConfigs() + const configEntries = Object.entries(allConfigs || {}) + const matched = configEntries.find(([key]) => { + const normalized = normalizeAccountId(key) || key + return key === wxid || normalized === targetNormalized + }) + const targetConfig = matched?.[1] || null + await applyWxidConfig(wxid, targetConfig) + await clearRuntimeCacheState() + window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid } })) + setNotice({ type: 'success', text: `已切换到账号「${wxid}」` }) + await loadAccounts() + } catch (error) { + console.error('切换账号失败:', error) + setNotice({ type: 'error', text: '切换账号失败,请稍后重试' }) + } finally { + setWorkingWxid('') + } + }, [applyWxidConfig, clearRuntimeCacheState, currentWxid, loadAccounts, workingWxid]) + + const handleAddAccount = useCallback(async () => { + if (workingWxid) return + setNotice(null) + setDeleteUndoState(null) + try { + await window.electronAPI.window.openOnboardingWindow({ mode: 'add-account' }) + await loadAccounts() + const latestWxid = String(await configService.getMyWxid() || '').trim() + window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: latestWxid } })) + } catch (error) { + console.error('打开添加账号引导失败:', error) + setNotice({ type: 'error', text: '打开添加账号引导失败,请稍后重试' }) + } + }, [loadAccounts, workingWxid]) + + const handleDeleteAccountConfig = useCallback(async (targetWxid: string) => { + if (!targetWxid || workingWxid) return + + const normalizedTarget = normalizeAccountId(targetWxid) || targetWxid + + setWorkingWxid(targetWxid) + setNotice(null) + setDeleteUndoState(null) + try { + const allConfigs = await configService.getWxidConfigs() + const nextConfigs: Record = { ...allConfigs } + const matchedKeys = Object.keys(nextConfigs).filter((key) => { + const normalized = normalizeAccountId(key) || key + return key === targetWxid || normalized === normalizedTarget + }) + + if (matchedKeys.length === 0) { + setNotice({ type: 'info', text: `账号「${targetWxid}」暂无可删除配置` }) + return + } + + const deletedConfigEntries: Array<[string, configService.WxidConfig]> = matchedKeys.map((key) => [key, nextConfigs[key] || {}]) + for (const key of matchedKeys) { + delete nextConfigs[key] + } + await configService.setWxidConfigs(nextConfigs) + + const accountProfileCache = readAccountProfilesCache() + const deletedProfileEntries: Array<[string, AccountProfileCacheEntry]> = [] + for (const key of Object.keys(accountProfileCache)) { + const normalized = normalizeAccountId(key) || key + if (key === targetWxid || normalized === normalizedTarget) { + deletedProfileEntries.push([key, accountProfileCache[key]]) + delete accountProfileCache[key] + } + } + window.localStorage.setItem(ACCOUNT_PROFILES_CACHE_KEY, JSON.stringify(accountProfileCache)) + + const currentNormalized = normalizeAccountId(currentWxid) || currentWxid + const isDeletingCurrent = Boolean(currentNormalized && currentNormalized === normalizedTarget) + const undoPayload: DeleteUndoState = { + targetWxid, + deletedConfigEntries, + deletedProfileEntries, + previousCurrentWxid: currentWxid, + shouldRestoreAsCurrent: isDeletingCurrent, + previousDbConnected: isDbConnected + } + + if (isDeletingCurrent) { + await clearRuntimeCacheState() + + const remainingEntries = Object.entries(nextConfigs) + .filter(([wxid]) => Boolean(String(wxid || '').trim())) + .sort((a, b) => Number(b[1]?.updatedAt || 0) - Number(a[1]?.updatedAt || 0)) + + if (remainingEntries.length > 0) { + const [nextWxid, nextConfig] = remainingEntries[0] + await applyWxidConfig(nextWxid, nextConfig || null) + window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: nextWxid } })) + hiddenDeletedAccountIds.add(normalizedTarget) + setDeleteUndoState(undoPayload) + setNotice({ type: 'success', text: `已删除「${targetWxid}」配置,并切换到「${nextWxid}」` }) + await loadAccounts() + return + } + + await configService.setMyWxid('') + await configService.setDecryptKey('') + await configService.setImageXorKey(0) + await configService.setImageAesKey('') + setDbConnected(false) + window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: '' } })) + hiddenDeletedAccountIds.add(normalizedTarget) + setDeleteUndoState(undoPayload) + setNotice({ type: 'info', text: `已删除「${targetWxid}」配置,当前无可用账号配置,可撤回或添加账号` }) + await loadAccounts() + return + } + + hiddenDeletedAccountIds.add(normalizedTarget) + setDeleteUndoState(undoPayload) + setNotice({ type: 'success', text: `已删除账号「${targetWxid}」配置` }) + await loadAccounts() + } catch (error) { + console.error('删除账号配置失败:', error) + setNotice({ type: 'error', text: '删除账号配置失败,请稍后重试' }) + } finally { + setWorkingWxid('') + } + }, [applyWxidConfig, clearRuntimeCacheState, currentWxid, isDbConnected, loadAccounts, setDbConnected, workingWxid]) + + const handleUndoDelete = useCallback(async () => { + if (!deleteUndoState || workingWxid) return + + setWorkingWxid(`undo:${deleteUndoState.targetWxid}`) + setNotice(null) + try { + const currentConfigs = await configService.getWxidConfigs() + const restoredConfigs: Record = { ...currentConfigs } + for (const [key, configValue] of deleteUndoState.deletedConfigEntries) { + restoredConfigs[key] = configValue || {} + } + await configService.setWxidConfigs(restoredConfigs) + hiddenDeletedAccountIds.delete(normalizeAccountId(deleteUndoState.targetWxid) || deleteUndoState.targetWxid) + + const accountProfileCache = readAccountProfilesCache() + for (const [key, profile] of deleteUndoState.deletedProfileEntries) { + accountProfileCache[key] = profile + } + window.localStorage.setItem(ACCOUNT_PROFILES_CACHE_KEY, JSON.stringify(accountProfileCache)) + + if (deleteUndoState.shouldRestoreAsCurrent && deleteUndoState.previousCurrentWxid) { + const previousNormalized = normalizeAccountId(deleteUndoState.previousCurrentWxid) || deleteUndoState.previousCurrentWxid + const restoreConfigEntry = Object.entries(restoredConfigs) + .filter(([key]) => { + const normalized = normalizeAccountId(key) || key + return key === deleteUndoState.previousCurrentWxid || normalized === previousNormalized + }) + .sort((a, b) => Number(b[1]?.updatedAt || 0) - Number(a[1]?.updatedAt || 0))[0] + const restoreConfig = restoreConfigEntry?.[1] || null + + await clearRuntimeCacheState() + await applyWxidConfig(deleteUndoState.previousCurrentWxid, restoreConfig) + if (deleteUndoState.previousDbConnected) { + setDbConnected(true, dbPath || undefined) + } + window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: deleteUndoState.previousCurrentWxid } })) + } + + setNotice({ type: 'success', text: `已撤回删除,账号「${deleteUndoState.targetWxid}」配置已恢复` }) + setDeleteUndoState(null) + await loadAccounts() + } catch (error) { + console.error('撤回删除失败:', error) + setNotice({ type: 'error', text: '撤回删除失败,请稍后重试' }) + } finally { + setWorkingWxid('') + } + }, [applyWxidConfig, clearRuntimeCacheState, dbPath, deleteUndoState, loadAccounts, setDbConnected, workingWxid]) + + const currentAccountLabel = useMemo(() => { + if (!currentWxid) return '未设置' + return currentWxid + }, [currentWxid]) + + const formatTime = (value?: number): string => { + const ts = Number(value || 0) + if (!ts) return '未知' + const date = new Date(ts) + if (Number.isNaN(date.getTime())) return '未知' + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + const hour = String(date.getHours()).padStart(2, '0') + const minute = String(date.getMinutes()).padStart(2, '0') + return `${year}-${month}-${day} ${hour}:${minute}` + } + + return ( +
+
+
+

账号管理

+

统一管理切换账号、添加账号、删除账号配置。

+
+
+ + +
+
+ +
+
+ 数据库目录 + {dbPath || '未配置'} +
+
+ 当前账号 + {currentAccountLabel} +
+
+ 账号数量 + {accounts.length} +
+
+ + {notice && ( +
+ {notice.text} + {deleteUndoState && (notice.type === 'success' || notice.type === 'info') && ( + + )} +
+ )} + + {accounts.length === 0 ? ( +
+ + 未发现可管理账号,请先添加账号或检查数据库目录。 +
+ ) : ( +
+ {accounts.map((account) => ( +
+
+ {account.avatarUrl ? : {resolveAccountAvatarText(account.displayName)}} +
+
+
+

{account.displayName}

+ {account.isCurrent && ( + + 当前 + + )} + {account.hasConfig ? ( + 已保存配置 + ) : ( + 未保存配置 + )} +
+
wxid: {account.wxid}
+
+ 最近数据更新时间: {formatTime(account.modifiedTime)} · 配置更新时间: {formatTime(account.configUpdatedAt)} + {!account.fromScan && (仅配置记录)} +
+
+
+ + +
+
+ ))} +
+ )} + +
+ 删除仅影响 WeFlow 本地配置,不会删除微信原始数据文件。 +
+
+ ) +} + +export default AccountManagementPage diff --git a/src/pages/BizPage.scss b/src/pages/BizPage.scss index a2faddb..5ff28c6 100644 --- a/src/pages/BizPage.scss +++ b/src/pages/BizPage.scss @@ -11,6 +11,7 @@ } .biz-account-item { + position: relative; display: flex; align-items: center; gap: 12px; @@ -46,6 +47,24 @@ background-color: var(--bg-tertiary); } + .biz-unread-badge { + position: absolute; + top: 8px; + left: 52px; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 9px; + background: #ff4d4f; + color: #fff; + font-size: 11px; + font-weight: 600; + line-height: 18px; + text-align: center; + border: 2px solid var(--bg-secondary); + box-sizing: border-box; + } + .biz-info { flex: 1; min-width: 0; diff --git a/src/pages/BizPage.tsx b/src/pages/BizPage.tsx index 6831d54..be7b547 100644 --- a/src/pages/BizPage.tsx +++ b/src/pages/BizPage.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useMemo, useRef } from 'react'; +import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react'; import { useThemeStore } from '../stores/themeStore'; import { Newspaper, MessageSquareOff } from 'lucide-react'; import './BizPage.scss'; @@ -10,6 +10,7 @@ export interface BizAccount { type: string; last_time: number; formatted_last_time: string; + unread_count?: number; } export const BizAccountList: React.FC<{ @@ -36,25 +37,42 @@ export const BizAccountList: React.FC<{ initWxid().then(_r => { }); }, []); - useEffect(() => { - const fetch = async () => { - if (!myWxid) { - return; - } + const fetchAccounts = useCallback(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 => { } ); + setLoading(true); + try { + const res = await window.electronAPI.biz.listAccounts(myWxid) + setAccounts(res || []); + } catch (err) { + console.error('获取服务号列表失败:', err); + } finally { + setLoading(false); + } }, [myWxid]); + useEffect(() => { + fetchAccounts().then(_r => { }); + }, [fetchAccounts]); + + useEffect(() => { + if (!window.electronAPI.chat.onWcdbChange) return; + const removeListener = window.electronAPI.chat.onWcdbChange((_event: any, data: { json?: string }) => { + try { + const payload = JSON.parse(data.json || '{}'); + const tableName = String(payload.table || '').toLowerCase(); + if (!tableName || tableName === 'session' || tableName.includes('message') || tableName.startsWith('msg_')) { + fetchAccounts().then(_r => { }); + } + } catch { + fetchAccounts().then(_r => { }); + } + }); + return () => removeListener(); + }, [fetchAccounts]); + const filtered = useMemo(() => { let result = accounts; @@ -80,7 +98,12 @@ export const BizAccountList: React.FC<{ {filtered.map(item => (
onSelect(item)} + onClick={() => { + setAccounts(prev => prev.map(account => + account.username === item.username ? { ...account, unread_count: 0 } : account + )); + onSelect({ ...item, unread_count: 0 }); + }} className={`biz-account-item ${selectedUsername === item.username ? 'active' : ''} ${item.username === 'gh_3dfda90e39d6' ? 'pay-account' : ''}`} > + {(item.unread_count || 0) > 0 && ( + {(item.unread_count || 0) > 99 ? '99+' : item.unread_count} + )}
{item.name || item.username} diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index 22d2e56..60b99ee 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -2064,6 +2064,7 @@ .message-bubble .bubble-content:has(> .link-message), .message-bubble .bubble-content:has(> .card-message), .message-bubble .bubble-content:has(> .chat-record-message), +.message-bubble .bubble-content:has(> .solitaire-message), .message-bubble .bubble-content:has(> .official-message), .message-bubble .bubble-content:has(> .channel-video-card), .message-bubble .bubble-content:has(> .location-message) { @@ -3604,6 +3605,140 @@ } } +// 接龙消息 +.solitaire-message { + width: min(360px, 72vw); + max-width: 360px; + background: var(--card-inner-bg); + border: 1px solid var(--border-color); + border-radius: 8px; + overflow: hidden; + cursor: pointer; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08); + transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; + + &:hover { + background: var(--bg-hover); + border-color: var(--primary); + } + + .solitaire-header { + display: flex; + gap: 10px; + padding: 12px 14px 10px; + border-bottom: 1px solid var(--border-color); + } + + .solitaire-icon { + width: 30px; + height: 30px; + border-radius: 8px; + background: color-mix(in srgb, var(--primary) 12%, transparent); + color: var(--primary); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + } + + .solitaire-heading { + min-width: 0; + flex: 1; + } + + .solitaire-title { + color: var(--text-primary); + font-size: 14px; + font-weight: 600; + line-height: 1.45; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .solitaire-meta { + margin-top: 2px; + color: var(--text-tertiary); + font-size: 12px; + line-height: 1.4; + } + + .solitaire-intro, + .solitaire-entry-list { + padding: 10px 14px; + border-bottom: 1px solid var(--border-color); + } + + .solitaire-intro { + color: var(--text-secondary); + font-size: 12px; + line-height: 1.55; + } + + .solitaire-intro-line { + white-space: pre-wrap; + word-break: break-word; + } + + .solitaire-entry-list { + display: flex; + flex-direction: column; + gap: 7px; + } + + .solitaire-entry { + display: flex; + gap: 8px; + align-items: flex-start; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.45; + } + + .solitaire-entry-index { + width: 22px; + height: 22px; + border-radius: 8px; + background: var(--bg-tertiary); + color: var(--text-tertiary); + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + font-size: 11px; + } + + .solitaire-entry-text { + min-width: 0; + flex: 1; + word-break: break-word; + } + + .solitaire-muted-line { + color: var(--text-tertiary); + font-size: 12px; + line-height: 1.45; + } + + .solitaire-footer { + padding: 8px 14px 10px; + color: var(--text-tertiary); + font-size: 12px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + } + + .solitaire-chevron { + transition: transform 0.2s ease; + } + + &.expanded .solitaire-chevron { + transform: rotate(180deg); + } +} + // 通话消息 .call-message { display: flex; diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 4da71be..7af1bc4 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -46,6 +46,12 @@ interface PendingInSessionSearchPayload { results: Message[] } +interface PendingFootprintJumpPayload { + sessionId: string + localId: number + createTime: number +} + type GlobalMsgSearchPhase = 'idle' | 'seed' | 'backfill' | 'done' type GlobalMsgSearchResult = Message & { sessionId: string } @@ -175,6 +181,51 @@ function buildChatRecordPreviewItems(recordList: ChatRecordItem[], maxVisible = ] } +interface SolitaireEntry { + index: string + text: string +} + +interface SolitaireContent { + title: string + introLines: string[] + entries: SolitaireEntry[] +} + +function parseSolitaireContent(rawTitle: string): SolitaireContent { + const lines = String(rawTitle || '') + .replace(/\r\n/g, '\n') + .split('\n') + .map(line => line.trim()) + .filter(Boolean) + + const title = lines[0] || '接龙' + const introLines: string[] = [] + const entries: SolitaireEntry[] = [] + let hasStartedEntries = false + + for (const line of lines.slice(1)) { + const entryMatch = /^(\d+)[..、]\s*(.+)$/.exec(line) + if (entryMatch) { + hasStartedEntries = true + entries.push({ + index: entryMatch[1], + text: entryMatch[2].trim() + }) + continue + } + + if (hasStartedEntries && entries.length > 0) { + const previous = entries[entries.length - 1] + previous.text = `${previous.text} ${line}`.trim() + } else { + introLines.push(line) + } + } + + return { title, introLines, entries } +} + function composeGlobalMsgSearchResults( seedMap: Map, authoritativeMap: Map @@ -1052,6 +1103,13 @@ const SessionItem = React.memo(function SessionItem({
{session.summary || '查看公众号历史消息'} +
+ {session.unreadCount > 0 && ( + + {session.unreadCount > 99 ? '99+' : session.unreadCount} + + )} +
@@ -1363,6 +1421,7 @@ function ChatPage(props: ChatPageProps) { const [globalMsgAuthoritativeSessionCount, setGlobalMsgAuthoritativeSessionCount] = useState(0) const [globalMsgSearchError, setGlobalMsgSearchError] = useState(null) const pendingInSessionSearchRef = useRef(null) + const pendingFootprintJumpRef = useRef(null) const pendingGlobalMsgSearchReplayRef = useRef(null) const globalMsgPrefixCacheRef = useRef(null) @@ -5042,24 +5101,37 @@ function ChatPage(props: ChatPageProps) { return [] } + const officialSessions = sessions.filter(s => s.username.startsWith('gh_')) + // 检查是否有折叠的群聊 const foldedGroups = sessions.filter(s => s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup')) const hasFoldedGroups = foldedGroups.length > 0 let visible = sessions.filter(s => { if (s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup')) return false + if (s.username.startsWith('gh_')) return false return true }) + const latestOfficial = officialSessions.reduce((latest, current) => { + if (!latest) return current + const latestTime = latest.sortTimestamp || latest.lastTimestamp + const currentTime = current.sortTimestamp || current.lastTimestamp + return currentTime > latestTime ? current : latest + }, null) + const officialUnreadCount = officialSessions.reduce((sum, s) => sum + (s.unreadCount || 0), 0) + const bizEntry: ChatSession = { username: OFFICIAL_ACCOUNTS_VIRTUAL_ID, displayName: '公众号', - summary: '查看公众号历史消息', + summary: latestOfficial + ? `${latestOfficial.displayName || latestOfficial.username}: ${latestOfficial.summary || '查看公众号历史消息'}` + : '查看公众号历史消息', type: 0, sortTimestamp: 9999999999, // 放到最前面? 目前还没有严格的对时间进行排序, 后面可以改一下 - lastTimestamp: 0, - lastMsgType: 0, - unreadCount: 0, + lastTimestamp: latestOfficial?.lastTimestamp || latestOfficial?.sortTimestamp || 0, + lastMsgType: latestOfficial?.lastMsgType || 0, + unreadCount: officialUnreadCount, isMuted: false, isFolded: false } @@ -5351,18 +5423,89 @@ function ChatPage(props: ChatPageProps) { selectSessionById ]) - // 监听URL参数中的sessionId,用于通知点击导航 + // 监听 URL 参数中的会话/锚点(通知跳转 + 足迹锚点定位) useEffect(() => { if (standaloneSessionWindow) return // standalone模式由上面的useEffect处理 const params = new URLSearchParams(location.search) - const urlSessionId = params.get('sessionId') + const urlSessionId = String(params.get('sessionId') || '').trim() if (!urlSessionId) return if (!isConnected || isConnecting) return - if (currentSessionId === urlSessionId) return - selectSessionById(urlSessionId) + const jumpSource = String(params.get('jumpSource') || '').trim() + const jumpLocalId = Number.parseInt(String(params.get('jumpLocalId') || ''), 10) + const jumpCreateTime = Number.parseInt(String(params.get('jumpCreateTime') || ''), 10) + const hasFootprintAnchor = jumpSource === 'footprint' + && Number.isFinite(jumpLocalId) + && jumpLocalId > 0 + && Number.isFinite(jumpCreateTime) + && jumpCreateTime > 0 + + if (hasFootprintAnchor) { + pendingFootprintJumpRef.current = { + sessionId: urlSessionId, + localId: jumpLocalId, + createTime: jumpCreateTime + } + if (currentSessionId !== urlSessionId) { + selectSessionById(urlSessionId) + return + } + const messageStub: Message = { + messageKey: `footprint:${urlSessionId}:${jumpCreateTime}:${jumpLocalId}`, + localId: jumpLocalId, + serverId: 0, + localType: 0, + createTime: jumpCreateTime, + sortSeq: jumpCreateTime, + isSend: null, + senderUsername: null, + parsedContent: '', + rawContent: '' + } + handleInSessionResultJump(messageStub) + pendingFootprintJumpRef.current = null + navigate('/chat', { replace: true }) + return + } + + pendingFootprintJumpRef.current = null + if (currentSessionId !== urlSessionId) { + selectSessionById(urlSessionId) + } // 选中后清除URL参数,避免影响后续用户手动切换会话 navigate('/chat', { replace: true }) - }, [standaloneSessionWindow, location.search, isConnected, isConnecting, currentSessionId, selectSessionById, navigate]) + }, [ + standaloneSessionWindow, + location.search, + isConnected, + isConnecting, + currentSessionId, + selectSessionById, + handleInSessionResultJump, + navigate + ]) + + useEffect(() => { + const pending = pendingFootprintJumpRef.current + if (!pending) return + if (!isConnected || isConnecting) return + if (currentSessionId !== pending.sessionId) return + + const messageStub: Message = { + messageKey: `footprint:${pending.sessionId}:${pending.createTime}:${pending.localId}`, + localId: pending.localId, + serverId: 0, + localType: 0, + createTime: pending.createTime, + sortSeq: pending.createTime, + isSend: null, + senderUsername: null, + parsedContent: '', + rawContent: '' + } + handleInSessionResultJump(messageStub) + pendingFootprintJumpRef.current = null + navigate('/chat', { replace: true }) + }, [isConnected, isConnecting, currentSessionId, handleInSessionResultJump, navigate]) useEffect(() => { if (!standaloneSessionWindow || !normalizedInitialSessionId) return @@ -7727,6 +7870,7 @@ function MessageBubble({ const [senderName, setSenderName] = useState(undefined) const [quotedSenderName, setQuotedSenderName] = useState(undefined) const [quoteLayout, setQuoteLayout] = useState('quote-top') + const [solitaireExpanded, setSolitaireExpanded] = useState(false) const senderProfileRequestSeqRef = useRef(0) const [emojiError, setEmojiError] = useState(false) const [emojiLoading, setEmojiLoading] = useState(false) @@ -9335,6 +9479,71 @@ function MessageBubble({ ) } + if (xmlType === '53' || message.appMsgKind === 'solitaire') { + const solitaireText = message.linkTitle || q('appmsg > title') || q('title') || cleanedParsedContent || '接龙' + const solitaire = parseSolitaireContent(solitaireText) + const previewEntries = solitaireExpanded ? solitaire.entries : solitaire.entries.slice(0, 3) + const hiddenEntryCount = Math.max(0, solitaire.entries.length - previewEntries.length) + const introLines = solitaireExpanded ? solitaire.introLines : solitaire.introLines.slice(0, 4) + const hasMoreIntro = !solitaireExpanded && solitaire.introLines.length > introLines.length + const countText = solitaire.entries.length > 0 ? `${solitaire.entries.length} 人参与` : '接龙消息' + + return ( +
{ + e.stopPropagation() + setSolitaireExpanded(value => !value) + }} + onKeyDown={isSelectionMode ? undefined : (e) => { + if (e.key !== 'Enter' && e.key !== ' ') return + e.preventDefault() + e.stopPropagation() + setSolitaireExpanded(value => !value) + }} + title={solitaireExpanded ? '点击收起接龙' : '点击展开接龙'} + > +
+ +
+
{solitaire.title}
+
{countText}
+
+
+ {introLines.length > 0 && ( +
+ {introLines.map((line, index) => ( +
{line}
+ ))} + {hasMoreIntro &&
...
} +
+ )} + {previewEntries.length > 0 ? ( +
+ {previewEntries.map(entry => ( +
+ {entry.index} + {entry.text} +
+ ))} + {hiddenEntryCount > 0 && ( +
还有 {hiddenEntryCount} 条...
+ )} +
+ ) : null} +
+ {solitaireExpanded ? '收起接龙' : '展开接龙'} + +
+
+ ) + } + const title = message.linkTitle || q('title') || cleanedParsedContent || 'Card' const desc = message.appMsgDesc || q('des') const url = message.linkUrl || q('url') diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 0944735..fd4c63f 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -3,10 +3,8 @@ 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); + background: var(--bg-primary); + /* Minimal background matching Footprint */ display: flex; flex-direction: column; gap: 16px; @@ -38,7 +36,9 @@ display: flex; align-items: center; justify-content: flex-start; - gap: 6px; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 8px; animation: exportSectionReveal 0.38s ease both; } @@ -119,7 +119,9 @@ } @keyframes sessionLoadDetailBars { - 0%, 100% { + + 0%, + 100% { transform: scaleY(0.72); opacity: 0.5; } @@ -460,7 +462,7 @@ border-bottom: none; } - > span { + >span { min-width: 0; overflow: hidden; text-overflow: ellipsis; @@ -1308,12 +1310,29 @@ } } -.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; } +.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); @@ -1716,19 +1735,20 @@ flex: 0 0 auto; width: auto; max-width: max-content; - border: 1px solid var(--border-color); - background: var(--bg-secondary); + border: none; + background: transparent; color: var(--text-secondary); min-height: 32px; - padding: 7px 10px; + padding: 7px 12px; border-radius: 999px; cursor: pointer; font-size: 13px; + font-weight: 500; white-space: nowrap; 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; + transition: all 0.2s ease; .tab-btn-content { display: inline-flex; @@ -1753,21 +1773,18 @@ } &:hover { - border-color: color-mix(in srgb, var(--primary) 36%, var(--border-color)); + background: color-mix(in srgb, var(--text-tertiary) 8%, transparent); 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); + background: color-mix(in srgb, var(--primary) 12%, transparent); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02); .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)); + background: color-mix(in srgb, var(--primary) 16%, transparent); + color: var(--primary); } } @@ -1812,15 +1829,17 @@ gap: 6px; padding: 8px 11px; border-radius: 10px; - border: 1px solid var(--border-color); - 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; + border: none; + background: color-mix(in srgb, var(--text-tertiary) 4%, transparent); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03) inset; + flex: 1; + min-width: 180px; + max-width: 320px; + transition: all 0.2s 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); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 20%, transparent), 0 1px 3px rgba(0, 0, 0, 0.02) inset; + background: color-mix(in srgb, var(--text-tertiary) 6%, transparent); } input { @@ -1829,13 +1848,14 @@ color: var(--text-primary); font-size: 13px; outline: none; - width: 220px; + flex: 1; + min-width: 0; } .clear-search { - border: 1px solid transparent; - background: color-mix(in srgb, var(--bg-primary) 84%, var(--bg-secondary)); - color: var(--text-tertiary); + border: none; + background: color-mix(in srgb, var(--text-tertiary) 10%, transparent); + color: var(--text-secondary); cursor: pointer; display: inline-flex; align-items: center; @@ -1843,12 +1863,11 @@ width: 18px; height: 18px; border-radius: 999px; - transition: border-color 0.12s ease, background 0.12s ease, color 0.12s ease; + transition: all 0.2s 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)); + background: color-mix(in srgb, var(--primary) 14%, transparent); } } } @@ -1892,25 +1911,21 @@ --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; + --contacts-actions-sticky-width: 240px; + --contacts-table-min-width: 1240px; overflow: hidden; - border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent); + border: none; border-radius: 12px; min-height: 320px; height: auto; flex: 1; display: flex; flex-direction: column; - 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; + background: var(--bg-secondary); + transition: all 0.2s 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); + background: color-mix(in srgb, var(--text-tertiary) 2%, var(--bg-secondary)); } } @@ -2031,11 +2046,12 @@ } .issue-btn { - border: 1px solid var(--border-color); - background: var(--bg-secondary); + border: none; + background: color-mix(in srgb, var(--text-tertiary) 10%, transparent); border-radius: 8px; - padding: 7px 10px; + padding: 7px 12px; font-size: 12px; + font-weight: 500; color: var(--text-secondary); display: inline-flex; align-items: center; @@ -2045,14 +2061,17 @@ &:hover { color: var(--text-primary); - border-color: var(--text-tertiary); - background: var(--bg-hover); + background: color-mix(in srgb, var(--text-tertiary) 16%, transparent); + transform: translateY(-1px); } &.primary { - background: color-mix(in srgb, var(--primary) 14%, var(--bg-secondary)); - border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color)); + background: color-mix(in srgb, var(--primary) 14%, transparent); color: var(--primary); + + &:hover { + background: color-mix(in srgb, var(--primary) 20%, transparent); + } } } @@ -2070,20 +2089,20 @@ } .contacts-list-header { - --contacts-header-bg: color-mix(in srgb, var(--bg-secondary) 90%, var(--bg-primary)); + --contacts-header-bg: color-mix(in srgb, var(--bg-secondary) 80%, transparent); display: flex; align-items: center; gap: var(--contacts-column-gap); padding: 10px var(--contacts-inline-padding) 8px; min-width: max(100%, var(--contacts-table-min-width)); - border-bottom: 1px solid color-mix(in srgb, var(--border-color) 85%, transparent); + border-bottom: 1px solid color-mix(in srgb, var(--text-tertiary) 6%, transparent); background: var(--contacts-header-bg); font-size: 12px; color: var(--text-tertiary); font-weight: 600; letter-spacing: 0.01em; flex-shrink: 0; - backdrop-filter: saturate(115%) blur(3px); + backdrop-filter: blur(10px); &.is-draggable { cursor: grab; @@ -2164,7 +2183,7 @@ display: flex; align-items: center; justify-content: flex-end; - gap: 8px; + gap: 10px; flex-wrap: nowrap; flex-shrink: 0; position: sticky; @@ -2248,25 +2267,25 @@ } .selection-clear-btn { - border: 1px solid var(--border-color); + border: none; border-radius: 8px; - background: var(--bg-secondary); + background: color-mix(in srgb, var(--text-tertiary) 10%, transparent); color: var(--text-secondary); font-size: 12px; + font-weight: 500; 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; + transition: all 0.2s 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)); + background: color-mix(in srgb, var(--text-tertiary) 16%, transparent); transform: translateY(-1px); } &:disabled { - opacity: 0.65; + opacity: 0.5; cursor: not-allowed; } } @@ -2275,19 +2294,21 @@ border: none; border-radius: 8px; padding: 6px 10px; - background: linear-gradient(180deg, color-mix(in srgb, var(--primary) 94%, #ffffff) 0%, var(--primary) 100%); + background: var(--primary); color: #fff; font-size: 12px; + font-weight: 500; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; flex-shrink: 0; - transition: transform 0.14s ease, box-shadow 0.14s ease, background 0.14s ease; + transition: all 0.2s ease; + box-shadow: 0 2px 6px color-mix(in srgb, var(--primary) 20%, transparent); &:hover:not(:disabled) { - background: var(--primary-hover); + background: color-mix(in srgb, var(--primary) 85%, #fff); transform: translateY(-1px); box-shadow: 0 8px 14px color-mix(in srgb, var(--primary) 30%, transparent); } @@ -2360,7 +2381,7 @@ } .contact-item { - --contacts-row-bg: var(--bg-secondary); + --contacts-row-bg: transparent; display: flex; align-items: center; gap: var(--contacts-column-gap); @@ -2369,15 +2390,14 @@ height: 72px; box-sizing: border-box; border-radius: 10px; - transition: box-shadow 0.18s ease, background 0.18s ease, transform 0.18s ease; + transition: all 0.2s ease; cursor: default; background: var(--contacts-row-bg); - box-shadow: inset 0 0 0 1px transparent; + box-shadow: none; &: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); + background: color-mix(in srgb, var(--text-tertiary) 6%, transparent); + transform: translateX(2px); } } @@ -2634,32 +2654,34 @@ min-width: 1300px; border-collapse: separate; border-spacing: 0; - background: var(--bg-secondary); + background: transparent; thead th { position: sticky; top: 0; - background: color-mix(in srgb, var(--bg-secondary) 90%, var(--bg-primary)); + background: color-mix(in srgb, var(--bg-secondary) 80%, transparent); + backdrop-filter: blur(8px); z-index: 4; font-size: 12px; text-align: left; color: var(--text-secondary); - border-bottom: 1px solid var(--border-color); + border-bottom: 1px solid color-mix(in srgb, var(--text-tertiary) 6%, transparent); padding: 10px 10px; white-space: nowrap; } tbody td { padding: 10px; - border-bottom: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent); + border-bottom: 1px solid color-mix(in srgb, var(--text-tertiary) 4%, transparent); font-size: 13px; color: var(--text-primary); vertical-align: middle; white-space: nowrap; + transition: background 0.15s ease; } - tbody tr:hover { - background: rgba(var(--primary-rgb), 0.03); + tbody tr:hover td { + background: color-mix(in srgb, var(--text-tertiary) 6%, transparent); } .selected-row, @@ -2797,27 +2819,26 @@ } .row-detail-btn { - border: 1px solid var(--border-color); + border: none; border-radius: 8px; - padding: 7px 10px; - background: var(--bg-secondary); + padding: 7px 12px; + background: color-mix(in srgb, var(--text-tertiary) 8%, transparent); color: var(--text-secondary); font-size: 12px; + font-weight: 500; cursor: pointer; white-space: nowrap; - transition: border-color 0.14s ease, color 0.14s ease, background 0.14s ease, transform 0.14s ease; + transition: all 0.2s ease; &:hover { - border-color: var(--text-tertiary); color: var(--text-primary); - background: var(--bg-hover); + background: color-mix(in srgb, var(--text-tertiary) 14%, transparent); transform: translateY(-1px); } &.active { - border-color: var(--primary); color: var(--primary); - background: rgba(var(--primary-rgb), 0.12); + background: color-mix(in srgb, var(--primary) 12%, transparent); } } @@ -2883,7 +2904,7 @@ text-align: center; } - .row-export-link.state-running + .row-export-time { + .row-export-link.state-running+.row-export-time { color: var(--primary); font-weight: 600; } @@ -2918,23 +2939,22 @@ 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%); + border: 1px solid color-mix(in srgb, var(--border-color) 40%, transparent); + border-radius: 20px; + background: var(--bg-primary); display: flex; flex-direction: column; overflow: hidden; - box-shadow: -18px 0 40px rgba(0, 0, 0, 0.24); + box-shadow: -18px 24px 60px rgba(0, 0, 0, 0.16); 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: 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)); + padding: 20px 20px 16px; + border-bottom: 1px solid color-mix(in srgb, var(--text-tertiary) 6%, transparent); + background: transparent; .detail-header-main { display: flex; @@ -3318,22 +3338,22 @@ .export-session-sns-dialog { width: min(760px, 100%); max-height: min(86vh, 860px); - border-radius: 14px; - border: 1px solid var(--border-color); - background: var(--bg-secondary-solid, #ffffff); - box-shadow: 0 22px 46px rgba(0, 0, 0, 0.24); + border-radius: 20px; + border: 1px solid color-mix(in srgb, var(--text-tertiary) 8%, transparent); + background: var(--bg-primary); + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.16); display: flex; flex-direction: column; overflow: hidden; - animation: exportModalPopIn 0.24s cubic-bezier(0.2, 0.78, 0.26, 1) both; + animation: footprintFadeSlideUp 0.3s cubic-bezier(0.2, 0.78, 0.26, 1) both; .sns-dialog-header { display: flex; align-items: center; justify-content: space-between; gap: 10px; - padding: 14px 16px; - border-bottom: 1px solid var(--border-color); + padding: 16px 20px; + border-bottom: 1px solid color-mix(in srgb, var(--text-tertiary) 6%, transparent); } .sns-dialog-header-main { @@ -3673,12 +3693,10 @@ position: relative; overflow: hidden; border-radius: 8px; - background: linear-gradient( - 90deg, - rgba(255, 255, 255, 0.08) 0%, - rgba(255, 255, 255, 0.35) 50%, - rgba(255, 255, 255, 0.08) 100% - ); + background: linear-gradient(90deg, + rgba(255, 255, 255, 0.08) 0%, + rgba(255, 255, 255, 0.35) 50%, + rgba(255, 255, 255, 0.08) 100%); background-size: 220% 100%; animation: exportSkeletonShimmer 1.2s linear infinite; } @@ -3700,13 +3718,39 @@ height: 12px; } -.skeleton-line.w-12 { width: 48%; min-width: 42px; } -.skeleton-line.w-20 { width: 22%; min-width: 36px; } -.skeleton-line.w-30 { width: 32%; min-width: 120px; } -.skeleton-line.w-40 { width: 45%; min-width: 80px; } -.skeleton-line.w-60 { width: 62%; min-width: 110px; } -.skeleton-line.w-100 { width: 100%; } -.skeleton-line.h-32 { height: 32px; border-radius: 10px; } +.skeleton-line.w-12 { + width: 48%; + min-width: 42px; +} + +.skeleton-line.w-20 { + width: 22%; + min-width: 36px; +} + +.skeleton-line.w-30 { + width: 32%; + min-width: 120px; +} + +.skeleton-line.w-40 { + width: 45%; + min-width: 80px; +} + +.skeleton-line.w-60 { + width: 62%; + min-width: 110px; +} + +.skeleton-line.w-100 { + width: 100%; +} + +.skeleton-line.h-32 { + height: 32px; + border-radius: 10px; +} .export-dialog-overlay { position: fixed; @@ -4440,11 +4484,9 @@ justify-content: flex-end; gap: 10px; flex-shrink: 0; - background: linear-gradient( - 180deg, - transparent, - var(--card-bg) 38% - ); + background: linear-gradient(180deg, + transparent, + var(--card-bg) 38%); } .primary-btn, @@ -4824,6 +4866,7 @@ 0% { background-position: 220% 0; } + 100% { background-position: -20% 0; } @@ -4833,6 +4876,7 @@ 0% { width: 0; } + 100% { width: 1.8em; } @@ -4843,10 +4887,12 @@ transform: scale(1); box-shadow: 0 0 0 0 rgba(255, 77, 79, 0.35); } + 70% { transform: scale(1.02); box-shadow: 0 0 0 6px rgba(255, 77, 79, 0); } + 100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(255, 77, 79, 0); @@ -4854,6 +4900,7 @@ } @media (prefers-reduced-motion: reduce) { + .export-board-page, .export-top-panel, .export-section-title-row, @@ -5207,3 +5254,634 @@ } } } + +.automation-hint-pill { + display: inline-flex; + align-items: center; + gap: 6px; + margin-top: 6px; + padding: 5px 12px; + border-radius: 999px; + background: color-mix(in srgb, var(--primary) 10%, transparent); + color: var(--primary); + font-size: 12px; + font-weight: 500; +} + +.automation-modal-overlay { + position: fixed; + inset: 0; + z-index: 7750; + background: rgba(0, 0, 0, 0.38); + display: flex; + align-items: center; + justify-content: center; + padding: 24px 20px; + animation: exportOverlayFadeIn 0.2s ease both; +} + +.automation-modal { + width: min(680px, 100%); + max-height: min(80vh, 820px); + border-radius: 20px; + border: 1px solid color-mix(in srgb, var(--text-tertiary) 8%, transparent); + background: var(--bg-primary); + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.16); + display: flex; + flex-direction: column; + overflow: hidden; + animation: footprintFadeSlideUp 0.28s cubic-bezier(0.2, 0.78, 0.26, 1) both; +} + +.automation-modal-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 20px 20px 16px; + border-bottom: 1px solid color-mix(in srgb, var(--text-tertiary) 6%, transparent); + flex-shrink: 0; + + h3 { + margin: 0; + font-size: 16px; + font-weight: 700; + color: var(--text-primary); + } + + p { + margin: 4px 0 0; + font-size: 12px; + color: var(--text-tertiary); + } +} + +.automation-modal-body { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 16px 20px 20px; +} + +.automation-empty { + padding: 40px 0; + text-align: center; + font-size: 13px; + color: var(--text-tertiary); +} + +.automation-task-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.automation-task-card { + border-radius: 14px; + background: color-mix(in srgb, var(--text-tertiary) 5%, transparent); + padding: 14px 16px; + display: flex; + align-items: flex-start; + gap: 12px; + transition: background 0.2s ease; + + &:hover { + background: color-mix(in srgb, var(--text-tertiary) 9%, transparent); + } + + &.disabled { + opacity: 0.6; + } +} + +.automation-task-main { + flex: 1; + min-width: 0; + + p { + margin: 3px 0 0; + font-size: 12px; + color: var(--text-secondary); + line-height: 1.5; + } +} + +.automation-task-title-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + + strong { + font-size: 14px; + color: var(--text-primary); + font-weight: 600; + } +} + +.automation-task-status { + font-size: 11px; + font-weight: 600; + padding: 2px 8px; + border-radius: 999px; + + &.enabled { + background: color-mix(in srgb, var(--primary) 12%, transparent); + color: var(--primary); + } + + &.disabled { + background: color-mix(in srgb, var(--text-tertiary) 12%, transparent); + color: var(--text-tertiary); + } + + &.running { + background: rgba(82, 196, 26, 0.14); + color: #52c41a; + } + + &.queued { + background: color-mix(in srgb, var(--primary) 12%, transparent); + color: var(--primary); + } +} + +.automation-task-actions { + display: flex; + flex-direction: column; + gap: 6px; + flex-shrink: 0; +} + +.automation-editor-overlay { + position: fixed; + inset: 0; + z-index: 7800; + background: rgba(0, 0, 0, 0.42); + display: flex; + align-items: center; + justify-content: center; + padding: 24px 20px; + animation: exportOverlayFadeIn 0.2s ease both; +} + +.automation-editor-modal { + width: min(560px, 100%); + max-height: min(88vh, 900px); + border-radius: 20px; + border: 1px solid color-mix(in srgb, var(--text-tertiary) 8%, transparent); + background: var(--bg-primary); + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.18); + display: flex; + flex-direction: column; + overflow: hidden; + animation: footprintFadeSlideUp 0.28s cubic-bezier(0.2, 0.78, 0.26, 1) both; +} + +.automation-editor-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 20px 20px 16px; + border-bottom: 1px solid color-mix(in srgb, var(--text-tertiary) 6%, transparent); + flex-shrink: 0; + + h3 { + margin: 0; + font-size: 15px; + font-weight: 700; + color: var(--text-primary); + } +} + +.automation-editor-body { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 16px 20px; + display: flex; + flex-direction: column; + gap: 14px; + + /* 裸 input 统一样式(未套 .automation-form-field 的情况) */ + >input[type='datetime-local'], + >input[type='number'], + >input[type='text'] { + height: 36px; + border: none; + border-radius: 10px; + background: color-mix(in srgb, var(--text-tertiary) 6%, transparent); + color: var(--text-primary); + padding: 0 12px; + font-size: 13px; + width: 100%; + box-sizing: border-box; + outline: none; + transition: background 0.2s ease, box-shadow 0.2s ease; + + &:focus { + background: color-mix(in srgb, var(--text-tertiary) 8%, transparent); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 20%, transparent); + } + + &::-webkit-inner-spin-button, + &::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; + } + + /* datetime-local 的日历图标调整外观 */ + &::-webkit-calendar-picker-indicator { + opacity: 0.5; + cursor: pointer; + filter: var(--datetime-picker-icon-filter, none); + transition: opacity 0.18s ease; + border-radius: 4px; + padding: 2px; + + &:hover { + opacity: 1; + background: color-mix(in srgb, var(--text-tertiary) 10%, transparent); + } + } + + &::-webkit-datetime-edit { + padding: 0; + } + + &::-webkit-datetime-edit-fields-wrapper { + background: transparent; + } + + &::-webkit-datetime-edit-text { + color: var(--text-tertiary); + padding: 0 1px; + } + + &::-webkit-datetime-edit-year-field, + &::-webkit-datetime-edit-month-field, + &::-webkit-datetime-edit-day-field, + &::-webkit-datetime-edit-hour-field, + &::-webkit-datetime-edit-minute-field, + &::-webkit-datetime-edit-ampm-field { + color: var(--text-primary); + border-radius: 3px; + padding: 0 2px; + + &:focus { + background: color-mix(in srgb, var(--primary) 16%, transparent); + color: var(--primary); + outline: none; + } + } + } + + /* 嵌套在 div 内的裸 input(如 stopAt 在 .automation-form-field > div 里) */ + input[type='datetime-local']:not(.automation-form-field input) { + height: 36px; + border: none; + border-radius: 10px; + background: color-mix(in srgb, var(--text-tertiary) 6%, transparent); + color: var(--text-primary); + padding: 0 12px; + font-size: 13px; + width: 100%; + box-sizing: border-box; + outline: none; + transition: background 0.2s ease, box-shadow 0.2s ease; + + &:focus { + background: color-mix(in srgb, var(--text-tertiary) 8%, transparent); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 20%, transparent); + } + + &::-webkit-calendar-picker-indicator { + opacity: 0.5; + cursor: pointer; + transition: opacity 0.18s ease; + border-radius: 4px; + padding: 2px; + + &:hover { + opacity: 1; + } + } + + &::-webkit-datetime-edit-year-field, + &::-webkit-datetime-edit-month-field, + &::-webkit-datetime-edit-day-field, + &::-webkit-datetime-edit-hour-field, + &::-webkit-datetime-edit-minute-field { + color: var(--text-primary); + border-radius: 3px; + padding: 0 2px; + + &:focus { + background: color-mix(in srgb, var(--primary) 16%, transparent); + color: var(--primary); + outline: none; + } + } + } + + input[type='number']:not(.automation-form-field input) { + height: 36px; + border: none; + border-radius: 10px; + background: color-mix(in srgb, var(--text-tertiary) 6%, transparent); + color: var(--text-primary); + padding: 0 12px; + font-size: 13px; + width: 100%; + box-sizing: border-box; + outline: none; + transition: background 0.2s ease, box-shadow 0.2s ease; + + &:focus { + background: color-mix(in srgb, var(--text-tertiary) 8%, transparent); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 20%, transparent); + } + + &::-webkit-inner-spin-button, + &::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; + } + } +} + +.automation-editor-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + padding: 12px 20px 16px; + border-top: 1px solid color-mix(in srgb, var(--text-tertiary) 6%, transparent); + flex-shrink: 0; +} + +.automation-form-field { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 13px; + display: flex; + flex-direction: column; + gap: 6px; + font-size: 13px; + + >span { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + } + + input[type='text'], + input[type='number'], + input[type='datetime-local'] { + height: 36px; + border: none; + border-radius: 10px; + background: color-mix(in srgb, var(--text-tertiary) 6%, transparent); + color: var(--text-primary); + padding: 0 12px; + font-size: 13px; + width: 100%; + box-sizing: border-box; + outline: none; + transition: background 0.2s ease, box-shadow 0.2s ease; + + &:focus { + background: color-mix(in srgb, var(--text-tertiary) 8%, transparent); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 20%, transparent); + } + + &::-webkit-inner-spin-button, + &::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; + } + + &::-webkit-calendar-picker-indicator { + cursor: pointer; + opacity: 0.6; + transition: opacity 0.2s ease; + + &:hover { + opacity: 1; + } + } + } +} + +.automation-inline-time { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.automation-inline-check { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--text-primary); + cursor: pointer; + user-select: none; + + input[type='checkbox'] { + appearance: none; + -webkit-appearance: none; + width: 16px; + height: 16px; + cursor: pointer; + flex-shrink: 0; + border-radius: 4px; + border: 1px solid color-mix(in srgb, var(--text-tertiary) 30%, transparent); + background: transparent; + transition: all 0.2s ease; + position: relative; + + &:hover { + background: color-mix(in srgb, var(--text-tertiary) 6%, transparent); + } + + &:checked { + background: var(--primary); + border-color: var(--primary); + + &::after { + content: ''; + position: absolute; + left: 5px; + top: 2px; + width: 4px; + height: 7px; + border: solid #fff; + border-width: 0 2px 2px 0; + transform: rotate(45deg); + } + } + } +} + +.automation-segment-row { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.automation-segment-btn { + border: none; + border-radius: 8px; + padding: 6px 14px; + background: color-mix(in srgb, var(--text-tertiary) 8%, transparent); + color: var(--text-secondary); + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: all 0.18s ease; + + &:hover { + background: color-mix(in srgb, var(--text-tertiary) 14%, transparent); + color: var(--text-primary); + } + + &.active { + background: color-mix(in srgb, var(--primary) 14%, transparent); + color: var(--primary); + } +} + +.automation-path-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + font-size: 12px; + color: var(--text-tertiary); + padding: 4px 2px; +} + +.automation-draft-summary { + padding: 10px 12px; + border-radius: 10px; + background: color-mix(in srgb, var(--text-tertiary) 5%, transparent); + font-size: 12px; + color: var(--text-secondary); + line-height: 1.6; +} + +.close-icon-btn { + border: none; + background: color-mix(in srgb, var(--text-tertiary) 8%, transparent); + color: var(--text-secondary); + width: 30px; + height: 30px; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + flex-shrink: 0; + transition: all 0.18s ease; + + &:hover { + background: color-mix(in srgb, var(--text-tertiary) 16%, transparent); + color: var(--text-primary); + } +} + +.primary-btn { + border: none; + border-radius: 9px; + padding: 8px 18px; + background: var(--primary); + color: #fff; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + box-shadow: 0 2px 6px color-mix(in srgb, var(--primary) 20%, transparent); + + &:hover:not(:disabled) { + background: color-mix(in srgb, var(--primary) 85%, #fff); + transform: translateY(-1px); + box-shadow: 0 8px 14px color-mix(in srgb, var(--primary) 30%, transparent); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } +} + +/* 终止时间选择器 */ +.automation-stopat-picker { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + + input { + flex: 1; + min-width: 0; + height: 36px; + border: none; + border-radius: 10px; + background: color-mix(in srgb, var(--text-tertiary) 8%, transparent); + color: var(--text-primary); + padding: 0 10px; + font-size: 13px; + outline: none; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + font-variant-numeric: tabular-nums; + + &:hover { + background: color-mix(in srgb, var(--text-tertiary) 12%, transparent); + } + + &:focus { + background: color-mix(in srgb, var(--text-tertiary) 10%, transparent); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 25%, transparent); + } + } + + .automation-stopat-date { + flex: 1.4; + } + + .automation-stopat-time { + flex: 1; + text-align: center; + } +} + +/* 自动化创建模式提示 */ +.automation-create-mode-pill { + display: flex; + align-items: center; + gap: 12px; + padding: 6px 16px; + border-radius: 999px; + background: color-mix(in srgb, var(--primary) 8%, var(--bg-primary)); + border: 1px solid color-mix(in srgb, var(--primary) 15%, transparent); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); + animation: footprintFadeSlideUp 0.3s ease both; + white-space: nowrap; + margin-left: 8px; + + span { + font-size: 13px; + font-weight: 500; + color: var(--primary); + } + + .secondary-btn { + height: 24px; + padding: 0 10px; + font-size: 11px; + border-radius: 6px; + } +} \ No newline at end of file diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 750d496..b7d6f1c 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -32,6 +32,12 @@ import { import type { ChatSession as AppChatSession, ContactInfo } from '../types/models' import type { ExportOptions as ElectronExportOptions, ExportProgress } from '../types/electron' import type { BackgroundTaskRecord } from '../types/backgroundTask' +import type { + ExportAutomationCondition, + ExportAutomationDateRangeConfig, + ExportAutomationSchedule, + ExportAutomationTask +} from '../types/exportAutomation' import * as configService from '../services/config' import { emitExportSessionStatus, @@ -55,12 +61,15 @@ import type { SnsPost } from '../types/sns' import { cloneExportDateRange, cloneExportDateRangeSelection, + createDateRangeByLastNDays, createDefaultDateRange, createDefaultExportDateRangeSelection, getExportDateRangeLabel, resolveExportDateRangeConfig, + serializeExportDateRangeConfig, startOfDay, endOfDay, + type ExportDateRangePreset, type ExportDateRangeSelection } from '../utils/exportDateRange' import './ExportPage.scss' @@ -147,6 +156,8 @@ interface ExportTaskPayload { outputDir: string options?: ElectronExportOptions scope: TaskScope + source: 'manual' | 'automation' + automationTaskId?: string contentType?: ContentType sessionNames: string[] snsOptions?: { @@ -175,6 +186,7 @@ interface ExportTask { interface ExportDialogState { open: boolean + intent: 'manual' | 'automation-create' scope: TaskScope contentType?: ContentType sessionIds: string[] @@ -182,6 +194,27 @@ interface ExportDialogState { title: string } +interface AutomationTaskDraft { + mode: 'create' | 'edit' + id?: string + name: string + enabled: boolean + sessionIds: string[] + sessionNames: string[] + outputDir: string + useGlobalOutputDir: boolean + scope: Exclude + contentType?: ContentType + optionTemplate: Omit + dateRangeConfig: ExportAutomationDateRangeConfig | string | null + intervalDays: number + intervalHours: number + stopAtEnabled: boolean + stopAtValue: string + maxRunsEnabled: boolean + maxRuns: number +} + const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content'] const DETAIL_PRECISE_REFRESH_COOLDOWN_MS = 10 * 60 * 1000 const TASK_PERFORMANCE_UPDATE_MIN_INTERVAL_MS = 900 @@ -589,6 +622,7 @@ const matchesContactTab = (contact: ContactInfo, tab: ConversationTab): boolean } const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` +const createAutomationTaskId = (): string => `auto-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` const CONTACT_ENRICH_TIMEOUT_MS = 7000 const EXPORT_SNS_STATS_CACHE_STALE_MS = 12 * 60 * 60 * 1000 const EXPORT_AVATAR_ENRICH_BATCH_SIZE = 80 @@ -599,6 +633,220 @@ const EXPORT_REENTER_SNS_SOFT_REFRESH_MS = 3 * 60 * 1000 type SessionDataSource = 'cache' | 'network' | null type ContactsDataSource = 'cache' | 'network' | null +const normalizeAutomationIntervalDays = (value: unknown): number => Math.max(0, Math.floor(Number(value) || 0)) +const normalizeAutomationIntervalHours = (value: unknown): number => Math.max(0, Math.min(23, Math.floor(Number(value) || 0))) + +const resolveAutomationIntervalMs = (schedule: ExportAutomationSchedule): number => { + const days = normalizeAutomationIntervalDays(schedule.intervalDays) + const hours = normalizeAutomationIntervalHours(schedule.intervalHours) + const totalHours = (days * 24) + hours + if (totalHours <= 0) return 0 + return totalHours * 60 * 60 * 1000 +} + +const formatAutomationScheduleLabel = (schedule: ExportAutomationSchedule): string => { + const days = normalizeAutomationIntervalDays(schedule.intervalDays) + const hours = normalizeAutomationIntervalHours(schedule.intervalHours) + const parts: string[] = [] + if (days > 0) parts.push(`${days} 天`) + if (hours > 0) parts.push(`${hours} 小时`) + return `每间隔 ${parts.length > 0 ? parts.join(' ') : '0 小时'} 执行一次` +} + +const resolveAutomationDueScheduleKey = (task: ExportAutomationTask, now: Date): string | null => { + const intervalMs = resolveAutomationIntervalMs(task.schedule) + if (intervalMs <= 0) return null + const nowMs = now.getTime() + const anchorAt = Math.max( + 0, + Number(task.runState?.lastTriggeredAt || 0) || Number(task.createdAt || 0) + ) + if (nowMs < anchorAt + intervalMs) return null + return `interval:${anchorAt}:${Math.floor((nowMs - anchorAt) / intervalMs)}` +} + +const toDateTimeLocalValue = (timestamp: number): string => { + const date = new Date(timestamp) + if (Number.isNaN(date.getTime())) return '' + const year = date.getFullYear() + const month = `${date.getMonth() + 1}`.padStart(2, '0') + const day = `${date.getDate()}`.padStart(2, '0') + const hours = `${date.getHours()}`.padStart(2, '0') + const minutes = `${date.getMinutes()}`.padStart(2, '0') + return `${year}-${month}-${day}T${hours}:${minutes}` +} + +const parseDateTimeLocalValue = (value: string): number | null => { + const text = String(value || '').trim() + if (!text) return null + const parsed = new Date(text) + const timestamp = parsed.getTime() + if (!Number.isFinite(timestamp)) return null + return Math.floor(timestamp) +} + +type AutomationRangeMode = 'all' | 'today' | 'yesterday' | 'last7days' | 'last30days' | 'last1year' | 'lastNDays' | 'custom' + +const AUTOMATION_RANGE_OPTIONS: Array<{ mode: AutomationRangeMode; label: string }> = [ + { mode: 'all', label: '全部时间' }, + { mode: 'yesterday', label: '往前1天' }, + { mode: 'last7days', label: '往前7天' }, + { mode: 'last30days', label: '往前30天' }, + { mode: 'last1year', label: '往前1年' }, + { mode: 'lastNDays', label: '往前N天' }, + { mode: 'custom', label: '完整时间' } +] + +const AUTOMATION_LAST_N_DAYS_MIN = 1 +const AUTOMATION_LAST_N_DAYS_MAX = 3650 +const AUTOMATION_LAST_N_DAYS_DEFAULT = 3 + +const normalizeAutomationLastNDays = (value: unknown): number => { + const parsed = Math.floor(Number(value) || 0) + if (!Number.isFinite(parsed) || parsed <= 0) return AUTOMATION_LAST_N_DAYS_DEFAULT + return Math.min(AUTOMATION_LAST_N_DAYS_MAX, Math.max(AUTOMATION_LAST_N_DAYS_MIN, parsed)) +} + +const readAutomationLastNDays = ( + config: ExportAutomationDateRangeConfig | string | null | undefined +): number | null => { + if (!config || typeof config !== 'object') return null + const raw = config as Record + const mode = String(raw.relativeMode || '').trim() + if (mode !== 'last-n-days') return null + const days = Math.floor(Number(raw.relativeDays) || 0) + if (!Number.isFinite(days) || days <= 0) return null + return Math.min(AUTOMATION_LAST_N_DAYS_MAX, Math.max(AUTOMATION_LAST_N_DAYS_MIN, days)) +} + +const buildAutomationLastNDaysConfig = (days: number): ExportAutomationDateRangeConfig => ({ + version: 1, + preset: 'custom', + useAllTime: false, + relativeMode: 'last-n-days', + relativeDays: normalizeAutomationLastNDays(days) +}) + +const resolveAutomationDateRangeSelection = ( + config: ExportAutomationDateRangeConfig | string | null | undefined, + now = new Date() +): ExportDateRangeSelection => { + const relativeDays = readAutomationLastNDays(config) + if (relativeDays) { + return { + preset: 'custom', + useAllTime: false, + dateRange: createDateRangeByLastNDays(relativeDays, now) + } + } + return resolveExportDateRangeConfig(config as any, now) +} + +const resolveAutomationRangeMode = ( + config: ExportAutomationDateRangeConfig | string | null | undefined, + selection: ExportDateRangeSelection +): AutomationRangeMode => { + if (readAutomationLastNDays(config)) return 'lastNDays' + if (selection.useAllTime) return 'all' + if (selection.preset === 'today') return 'today' + if (selection.preset === 'yesterday') return 'yesterday' + if (selection.preset === 'last7days') return 'last7days' + if (selection.preset === 'last30days') return 'last30days' + if (selection.preset === 'last1year') return 'last1year' + return 'custom' +} + +const createAutomationSelectionByMode = ( + mode: Exclude, + now = new Date() +): ExportDateRangeSelection => { + const preset: ExportDateRangePreset = mode + return resolveExportDateRangeConfig({ + version: 1, + preset, + useAllTime: mode === 'all' + }, now) +} + +const formatAutomationRangeLabel = ( + config: ExportAutomationDateRangeConfig | string | null | undefined, + selection?: ExportDateRangeSelection +): string => { + const resolved = selection || resolveAutomationDateRangeSelection(config, new Date()) + const mode = resolveAutomationRangeMode(config, resolved) + if (mode === 'all') return '每次触发导出全部历史消息' + if (mode === 'today') return '每次触发导出当天' + if (mode === 'yesterday') return '每次触发导出前1天(昨日)' + if (mode === 'last7days') return '每次触发导出前7天' + if (mode === 'last30days') return '每次触发导出前30天' + if (mode === 'last1year') return '每次触发导出前1年' + if (mode === 'lastNDays') { + return `每次触发导出前 ${readAutomationLastNDays(config) || AUTOMATION_LAST_N_DAYS_DEFAULT} 天` + } + return `完整时间:${getExportDateRangeLabel(resolved)}` +} + +const formatAutomationStopCondition = (task: ExportAutomationTask): string => { + const endAt = Number(task.stopCondition?.endAt || 0) + const maxRuns = Number(task.stopCondition?.maxRuns || 0) + const labels: string[] = [] + if (endAt > 0) { + labels.push(`截止到 ${new Date(endAt).toLocaleString('zh-CN')}`) + } + if (maxRuns > 0) { + const successCount = Math.max(0, Math.floor(Number(task.runState?.successCount || 0))) + labels.push(`成功 ${successCount}/${maxRuns} 次后停止`) + } + return labels.length > 0 ? labels.join(' · ') : '无' +} + +const resolveAutomationNextTriggerAt = (task: ExportAutomationTask): number | null => { + const intervalMs = resolveAutomationIntervalMs(task.schedule) + if (intervalMs <= 0) return null + const anchorAt = Math.max(0, Number(task.runState?.lastTriggeredAt || 0) || Number(task.createdAt || 0)) + if (!anchorAt) return null + return anchorAt + intervalMs +} + +const formatAutomationCurrentState = ( + task: ExportAutomationTask, + queueState: 'queued' | 'running' | null, + nowMs: number +): string => { + if (!task.enabled) return '已停用' + if (queueState === 'running') return '执行中' + if (queueState === 'queued') return '排队中' + const nextTriggerAt = resolveAutomationNextTriggerAt(task) + if (!nextTriggerAt) return '等待触发' + const diff = nextTriggerAt - nowMs + if (diff <= 0) return '即将触发' + return `等待触发 · 下次 ${new Date(nextTriggerAt).toLocaleString('zh-CN')}(约 ${formatDurationMs(diff)} 后)` +} + +const formatAutomationLastRunSummary = (task: ExportAutomationTask): string => { + const status = task.runState?.lastRunStatus || 'idle' + const label = ( + status === 'idle' ? '尚未执行' : + status === 'queued' ? '已入队' : + status === 'running' ? '执行中' : + status === 'success' ? '执行成功' : + status === 'error' ? '执行失败' : + status === 'skipped' ? '已跳过' : + status + ) + const parts: string[] = [label] + if (task.runState?.lastSuccessAt) { + parts.push(`最近成功于 ${new Date(task.runState.lastSuccessAt).toLocaleString('zh-CN')}`) + } + if (task.runState?.lastSkipReason) { + parts.push(task.runState.lastSkipReason) + } + if (task.runState?.lastError) { + parts.push(task.runState.lastError) + } + return parts.join(' · ') +} + interface ContactsLoadSession { requestId: string startedAt: number @@ -1105,21 +1353,42 @@ const clampExportSelectionToBounds = ( ): ExportDateRangeSelection => { if (!bounds) return cloneExportDateRangeSelection(selection) - const boundedStart = startOfDay(bounds.minDate) - const boundedEnd = endOfDay(bounds.maxDate) - const originalStart = selection.useAllTime ? boundedStart : startOfDay(selection.dateRange.start) - const originalEnd = selection.useAllTime ? boundedEnd : endOfDay(selection.dateRange.end) - const nextStart = new Date(Math.min(Math.max(originalStart.getTime(), boundedStart.getTime()), boundedEnd.getTime())) - const nextEndCandidate = new Date(Math.min(Math.max(originalEnd.getTime(), boundedStart.getTime()), boundedEnd.getTime())) - const nextEnd = nextEndCandidate.getTime() < nextStart.getTime() ? endOfDay(nextStart) : nextEndCandidate - const rangeChanged = nextStart.getTime() !== originalStart.getTime() || nextEnd.getTime() !== originalEnd.getTime() + // For custom selections, only ensure end >= start, preserve time precision + if (selection.preset === 'custom' && !selection.useAllTime) { + const { start, end } = selection.dateRange + if (end.getTime() < start.getTime()) { + return { + ...selection, + dateRange: { start, end: start } + } + } + return cloneExportDateRangeSelection(selection) + } + // For useAllTime, use bounds directly + if (selection.useAllTime) { + return { + preset: selection.preset, + useAllTime: true, + dateRange: { + start: bounds.minDate, + end: bounds.maxDate + } + } + } + + // For preset selections (not custom), clamp dates to bounds and use default times + const boundedStart = new Date(Math.min(Math.max(selection.dateRange.start.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime())) + const boundedEnd = new Date(Math.min(Math.max(selection.dateRange.end.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime())) + // Use default times: start at 00:00, end at 23:59:59 + boundedStart.setHours(0, 0, 0, 0) + boundedEnd.setHours(23, 59, 59, 999) return { - preset: selection.useAllTime ? selection.preset : (rangeChanged ? 'custom' : selection.preset), - useAllTime: selection.useAllTime, + preset: selection.preset, + useAllTime: false, dateRange: { - start: nextStart, - end: nextEnd + start: boundedStart, + end: boundedEnd } } } @@ -1553,6 +1822,8 @@ function ExportPage() { const [isSnsStatsLoading, setIsSnsStatsLoading] = useState(true) const [isBaseConfigLoading, setIsBaseConfigLoading] = useState(true) const [isTaskCenterOpen, setIsTaskCenterOpen] = useState(false) + const [isAutomationModalOpen, setIsAutomationModalOpen] = useState(false) + const [automationHint, setAutomationHint] = useState(null) const [expandedPerfTaskId, setExpandedPerfTaskId] = useState(null) const [sessions, setSessions] = useState([]) const [sessionDataSource, setSessionDataSource] = useState(null) @@ -1621,6 +1892,7 @@ function ExportPage() { const [exportDefaultFormat, setExportDefaultFormat] = useState('excel') const [exportDefaultAvatars, setExportDefaultAvatars] = useState(true) const [exportDefaultDateRangeSelection, setExportDefaultDateRangeSelection] = useState(() => createDefaultExportDateRangeSelection()) + const [exportDefaultFileNamingMode, setExportDefaultFileNamingMode] = useState('classic') const [exportDefaultMedia, setExportDefaultMedia] = useState({ images: true, videos: true, @@ -1658,14 +1930,22 @@ function ExportPage() { const [exportDialog, setExportDialog] = useState({ open: false, + intent: 'manual', scope: 'single', sessionIds: [], sessionNames: [], title: '' }) + const [isAutomationCreateMode, setIsAutomationCreateMode] = useState(false) const [showSessionFormatSelect, setShowSessionFormatSelect] = useState(false) const [tasks, setTasks] = useState([]) + const [automationTasks, setAutomationTasks] = useState([]) + const [automationTaskDraft, setAutomationTaskDraft] = useState(null) + const [isAutomationRangeDialogOpen, setIsAutomationRangeDialogOpen] = useState(false) + const [isResolvingAutomationRangeBounds, setIsResolvingAutomationRangeBounds] = useState(false) + const [automationRangeBounds, setAutomationRangeBounds] = useState(null) + const [automationRangeSelection, setAutomationRangeSelection] = useState(() => createDefaultExportDateRangeSelection()) const [lastExportBySession, setLastExportBySession] = useState>({}) const [lastExportByContent, setLastExportByContent] = useState>({}) const [exportRecordsBySession, setExportRecordsBySession] = useState>({}) @@ -1692,6 +1972,10 @@ function ExportPage() { const progressUnsubscribeRef = useRef<(() => void) | null>(null) const runningTaskIdRef = useRef(null) const tasksRef = useRef([]) + const automationTasksRef = useRef([]) + const automationTasksReadyRef = useRef(false) + const automationSchedulerRunningRef = useRef(false) + const automationQueueStatusByTaskIdRef = useRef>(new Map()) const hasSeededSnsStatsRef = useRef(false) const sessionLoadTokenRef = useRef(0) const preselectAppliedRef = useRef(false) @@ -1788,6 +2072,46 @@ function ExportPage() { return scopeKey }, []) + const persistAutomationTasks = useCallback(async (nextTasks: ExportAutomationTask[]) => { + if (!automationTasksReadyRef.current) return + const scopeKey = await ensureExportCacheScope() + await configService.setExportAutomationTasks(scopeKey, nextTasks) + }, [ensureExportCacheScope]) + + const updateAutomationTasks = useCallback(( + updater: (prev: ExportAutomationTask[]) => ExportAutomationTask[] + ) => { + setAutomationTasks((prev) => { + const next = updater(prev) + void persistAutomationTasks(next) + return next + }) + }, [persistAutomationTasks]) + + const patchAutomationTask = useCallback(( + taskId: string, + updater: (task: ExportAutomationTask) => ExportAutomationTask + ) => { + updateAutomationTasks((prev) => prev.map((task) => (task.id === taskId ? updater(task) : task))) + }, [updateAutomationTasks]) + + const markAutomationTaskSkipped = useCallback((taskId: string, reason: string, scheduleKey?: string) => { + const now = Date.now() + patchAutomationTask(taskId, (task) => ({ + ...task, + updatedAt: now, + runState: { + ...(task.runState || {}), + lastRunStatus: 'skipped', + lastTriggeredAt: now, + lastSkipAt: now, + lastSkipReason: reason, + lastError: undefined, + lastScheduleKey: scheduleKey || task.runState?.lastScheduleKey + } + })) + }, [patchAutomationTask]) + const loadContactsCaches = useCallback(async (scopeKey: string) => { const [contactsItem, avatarItem] = await Promise.all([ configService.getContactsListCache(scopeKey), @@ -1799,6 +2123,22 @@ function ExportPage() { } }, []) + const ensureAutomationTasksLoaded = useCallback(async () => { + if (automationTasksReadyRef.current) return + try { + const scopeKey = await ensureExportCacheScope() + const automationTaskItem = await configService.getExportAutomationTasks(scopeKey) + setAutomationTasks(automationTaskItem?.tasks || []) + automationTasksReadyRef.current = true + } catch (error) { + console.error('加载自动化导出任务失败:', error) + } + }, [ensureExportCacheScope]) + + useEffect(() => { + void ensureAutomationTasksLoaded() + }, [ensureAutomationTasksLoaded]) + useEffect(() => { let cancelled = false void (async () => { @@ -2212,6 +2552,10 @@ function ExportPage() { tasksRef.current = tasks }, [tasks]) + useEffect(() => { + automationTasksRef.current = automationTasks + }, [automationTasks]) + useEffect(() => { sessionsRef.current = sessions }, [sessions]) @@ -2266,11 +2610,19 @@ function ExportPage() { return () => window.clearInterval(timer) }, [isTaskCenterOpen, expandedPerfTaskId, tasks]) + useEffect(() => { + if (!isAutomationModalOpen) return + setNowTick(Date.now()) + const timer = window.setInterval(() => setNowTick(Date.now()), 1000) + return () => window.clearInterval(timer) + }, [isAutomationModalOpen]) + const loadBaseConfig = useCallback(async (): Promise => { setIsBaseConfigLoading(true) + automationTasksReadyRef.current = false let isReady = true try { - const [savedPath, savedFormat, savedAvatars, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedImageDeepSearchOnMiss, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, savedSessionNameWithTypePrefix, savedDefaultDateRange, exportCacheScope] = await Promise.all([ + const [savedPath, savedFormat, savedAvatars, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedImageDeepSearchOnMiss, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, savedSessionNameWithTypePrefix, savedDefaultDateRange, savedFileNamingMode, exportCacheScope] = await Promise.all([ configService.getExportPath(), configService.getExportDefaultFormat(), configService.getExportDefaultAvatars(), @@ -2287,10 +2639,12 @@ function ExportPage() { configService.getExportWriteLayout(), configService.getExportSessionNamePrefixEnabled(), configService.getExportDefaultDateRange(), + configService.getExportDefaultFileNamingMode(), ensureExportCacheScope() ]) const cachedSnsStats = await configService.getExportSnsStatsCache(exportCacheScope) + const automationTaskItem = await configService.getExportAutomationTasks(exportCacheScope) if (savedPath) { setExportFolder(savedPath) @@ -2318,6 +2672,9 @@ function ExportPage() { setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true) setExportDefaultConcurrency(savedConcurrency ?? 2) setExportDefaultImageDeepSearchOnMiss(savedImageDeepSearchOnMiss ?? true) + setExportDefaultFileNamingMode(savedFileNamingMode ?? 'classic') + setAutomationTasks(automationTaskItem?.tasks || []) + automationTasksReadyRef.current = true const resolvedDefaultDateRange = resolveExportDateRangeConfig(savedDefaultDateRange) setExportDefaultDateRangeSelection(resolvedDefaultDateRange) setTimeRangeSelection(resolvedDefaultDateRange) @@ -2357,6 +2714,7 @@ function ExportPage() { })) } catch (error) { isReady = false + automationTasksReadyRef.current = false console.error('加载导出配置失败:', error) } finally { setIsBaseConfigLoading(false) @@ -4022,6 +4380,7 @@ function ExportPage() { useEffect(() => { if (isExportRoute) return // 导出页隐藏时停止后台联系人补齐请求,避免与通讯录页面查询抢占。 + setIsAutomationCreateMode(false) sessionLoadTokenRef.current = Date.now() sessionCountRequestIdRef.current += 1 snsUserPostCountsHydrationTokenRef.current += 1 @@ -4102,8 +4461,8 @@ function ExportPage() { const clearSelection = () => setSelectedSessions(new Set()) - const openExportDialog = useCallback((payload: Omit) => { - setExportDialog({ open: true, ...payload }) + const openExportDialog = useCallback((payload: Omit & { intent?: ExportDialogState['intent'] }) => { + setExportDialog({ open: true, intent: payload.intent || 'manual', ...payload }) setIsTimeRangeDialogOpen(false) setTimeRangeBounds(null) setTimeRangeSelection(exportDefaultDateRangeSelection) @@ -4173,7 +4532,7 @@ function ExportPage() { ]) const closeExportDialog = useCallback(() => { - setExportDialog(prev => ({ ...prev, open: false })) + setExportDialog(prev => ({ ...prev, open: false, intent: 'manual' })) setIsTimeRangeDialogOpen(false) setTimeRangeBounds(null) }, []) @@ -4397,6 +4756,7 @@ function ExportPage() { displayNamePreference: options.displayNamePreference, exportConcurrency: options.exportConcurrency, imageDeepSearchOnMiss: options.imageDeepSearchOnMiss, + fileNamingMode: exportDefaultFileNamingMode, sessionLayout, sessionNameWithTypePrefix, dateRange: options.useAllTime @@ -4463,6 +4823,202 @@ function ExportPage() { } } + const openCreateAutomationDraft = useCallback(() => { + setIsAutomationModalOpen(false) + setAutomationTaskDraft(null) + setIsAutomationRangeDialogOpen(false) + setIsAutomationCreateMode(true) + setSelectedSessions(new Set()) + setAutomationHint('已进入自动化任务创建:请勾选联系人,然后点击「加入任务」') + }, []) + + const openEditAutomationTaskDraft = useCallback((task: ExportAutomationTask) => { + const schedule = task.schedule + const stopAt = Number(task.stopCondition?.endAt || 0) + const maxRuns = Number(task.stopCondition?.maxRuns || 0) + const resolvedRange = resolveAutomationDateRangeSelection(task.template.dateRangeConfig as any, new Date()) + setAutomationRangeSelection(resolvedRange) + setAutomationRangeBounds(null) + setAutomationTaskDraft({ + mode: 'edit', + id: task.id, + name: task.name, + enabled: task.enabled, + sessionIds: task.sessionIds, + sessionNames: task.sessionNames, + outputDir: task.outputDir || exportFolder, + useGlobalOutputDir: !task.outputDir, + scope: task.template.scope, + contentType: task.template.contentType, + optionTemplate: task.template.optionTemplate, + dateRangeConfig: task.template.dateRangeConfig, + intervalDays: normalizeAutomationIntervalDays(schedule.intervalDays), + intervalHours: normalizeAutomationIntervalHours(schedule.intervalHours), + stopAtEnabled: stopAt > 0, + stopAtValue: stopAt > 0 ? toDateTimeLocalValue(stopAt) : '', + maxRunsEnabled: maxRuns > 0, + maxRuns: maxRuns > 0 ? maxRuns : 0 + }) + setIsAutomationModalOpen(true) + }, [exportFolder]) + + const openAutomationDateRangeDialog = useCallback(() => { + if (!automationTaskDraft) return + void (async () => { + if (isResolvingAutomationRangeBounds) return + setIsResolvingAutomationRangeBounds(true) + try { + const nextBounds = await resolveChatExportTimeRangeBounds(automationTaskDraft.sessionIds) + setAutomationRangeBounds(nextBounds) + if (nextBounds) { + const nextSelection = clampExportSelectionToBounds(automationRangeSelection, nextBounds) + if (!areExportSelectionsEqual(nextSelection, automationRangeSelection)) { + setAutomationRangeSelection(nextSelection) + setAutomationTaskDraft((prev) => prev ? { + ...prev, + dateRangeConfig: serializeExportDateRangeConfig(nextSelection) + } : prev) + } + } + setIsAutomationRangeDialogOpen(true) + } catch (error) { + console.error('自动化导出解析时间范围边界失败', error) + setAutomationRangeBounds(null) + setIsAutomationRangeDialogOpen(true) + } finally { + setIsResolvingAutomationRangeBounds(false) + } + })() + }, [ + automationRangeSelection, + automationTaskDraft, + isResolvingAutomationRangeBounds, + resolveChatExportTimeRangeBounds + ]) + + const applyAutomationRangeMode = useCallback((mode: AutomationRangeMode) => { + if (!automationTaskDraft) return + if (mode === 'custom') { + openAutomationDateRangeDialog() + return + } + if (mode === 'lastNDays') { + const relativeDays = readAutomationLastNDays(automationTaskDraft.dateRangeConfig) || AUTOMATION_LAST_N_DAYS_DEFAULT + const nextSelection: ExportDateRangeSelection = { + preset: 'custom', + useAllTime: false, + dateRange: createDateRangeByLastNDays(relativeDays, new Date()) + } + setAutomationRangeSelection(nextSelection) + setAutomationTaskDraft((prev) => prev ? { + ...prev, + dateRangeConfig: buildAutomationLastNDaysConfig(relativeDays) + } : prev) + return + } + const nextSelection = createAutomationSelectionByMode(mode, new Date()) + setAutomationRangeSelection(nextSelection) + setAutomationTaskDraft((prev) => prev ? { + ...prev, + dateRangeConfig: serializeExportDateRangeConfig(nextSelection) + } : prev) + }, [automationTaskDraft, openAutomationDateRangeDialog]) + + const updateAutomationLastNDays = useCallback((value: unknown) => { + const days = normalizeAutomationLastNDays(value) + const nextSelection: ExportDateRangeSelection = { + preset: 'custom', + useAllTime: false, + dateRange: createDateRangeByLastNDays(days, new Date()) + } + setAutomationRangeSelection(nextSelection) + setAutomationTaskDraft((prev) => prev ? { + ...prev, + dateRangeConfig: buildAutomationLastNDaysConfig(days) + } : prev) + }, []) + + const saveAutomationTaskDraft = useCallback(() => { + if (!automationTaskDraft) return + if (!automationTasksReadyRef.current) { + automationTasksReadyRef.current = true + } + const normalizedName = automationTaskDraft.name.trim() + if (!normalizedName) { + window.alert('请输入任务名称') + return + } + if (automationTaskDraft.sessionIds.length === 0) { + window.alert('自动化任务至少需要一个会话') + return + } + + const intervalDays = normalizeAutomationIntervalDays(automationTaskDraft.intervalDays) + const intervalHours = normalizeAutomationIntervalHours(automationTaskDraft.intervalHours) + if (intervalDays <= 0 && intervalHours <= 0) { + window.alert('执行间隔不能为 0,请至少设置天数或小时') + return + } + const schedule: ExportAutomationSchedule = { type: 'interval', intervalDays, intervalHours } + const stopAtTimestamp = automationTaskDraft.stopAtEnabled + ? parseDateTimeLocalValue(automationTaskDraft.stopAtValue) + : null + if (automationTaskDraft.stopAtEnabled && !stopAtTimestamp) { + window.alert('请填写有效的终止时间') + return + } + const maxRuns = automationTaskDraft.maxRunsEnabled + ? Math.max(0, Math.floor(Number(automationTaskDraft.maxRuns || 0))) + : 0 + if (automationTaskDraft.maxRunsEnabled && maxRuns <= 0) { + window.alert('请填写大于 0 的最大执行次数') + return + } + const stopCondition = { + endAt: stopAtTimestamp && stopAtTimestamp > 0 ? stopAtTimestamp : undefined, + maxRuns: maxRuns > 0 ? maxRuns : undefined + } + + const now = Date.now() + const condition: ExportAutomationCondition = { type: 'new-message-since-last-success' } + const nextTask: ExportAutomationTask = { + id: automationTaskDraft.mode === 'edit' && automationTaskDraft.id + ? automationTaskDraft.id + : createAutomationTaskId(), + name: normalizedName, + enabled: automationTaskDraft.enabled, + sessionIds: [...automationTaskDraft.sessionIds], + sessionNames: [...automationTaskDraft.sessionNames], + outputDir: automationTaskDraft.useGlobalOutputDir ? undefined : String(automationTaskDraft.outputDir || '').trim(), + schedule, + condition, + stopCondition: (stopCondition.endAt || stopCondition.maxRuns) ? stopCondition : undefined, + template: { + scope: automationTaskDraft.scope, + contentType: automationTaskDraft.contentType, + optionTemplate: { ...automationTaskDraft.optionTemplate }, + dateRangeConfig: automationTaskDraft.dateRangeConfig + }, + runState: automationTaskDraft.mode === 'edit' + ? automationTasksRef.current.find((item) => item.id === automationTaskDraft.id)?.runState + : { lastRunStatus: 'idle', successCount: 0 }, + createdAt: automationTaskDraft.mode === 'edit' + ? (automationTasksRef.current.find((item) => item.id === automationTaskDraft.id)?.createdAt || now) + : now, + updatedAt: now + } + + updateAutomationTasks((prev) => { + if (automationTaskDraft.mode === 'edit' && automationTaskDraft.id) { + return prev.map((task) => (task.id === automationTaskDraft.id ? nextTask : task)) + } + return [nextTask, ...prev] + }) + setAutomationTaskDraft(null) + setIsAutomationRangeDialogOpen(false) + setAutomationHint(automationTaskDraft.mode === 'edit' ? '自动化任务已更新' : '自动化任务已创建') + }, [automationTaskDraft, updateAutomationTasks]) + const markSessionExported = useCallback((sessionIds: string[], timestamp: number) => { setLastExportBySession(prev => { const next = { ...prev } @@ -4938,10 +5494,123 @@ function ExportPage() { } }, []) + const enqueueExportTask = useCallback((title: string, payload: ExportTaskPayload): string => { + const task: ExportTask = { + id: createTaskId(), + title, + status: 'queued', + settledSessionIds: [], + createdAt: Date.now(), + payload, + progress: createEmptyProgress(), + performance: payload.scope === 'content' && payload.contentType === 'text' + ? createEmptyTaskPerformance() + : undefined + } + setTasks(prev => [task, ...prev]) + return task.id + }, []) + + const buildAutomationExportOptions = useCallback((task: ExportAutomationTask): ElectronExportOptions => { + const selection = resolveAutomationDateRangeSelection(task.template.dateRangeConfig as any, new Date()) + const dateRange = selection.useAllTime + ? null + : { + start: Math.floor(selection.dateRange.start.getTime() / 1000), + end: Math.floor(selection.dateRange.end.getTime() / 1000) + } + return { + ...task.template.optionTemplate, + dateRange + } + }, []) + + const enqueueAutomationTask = useCallback(( + task: ExportAutomationTask, + options?: { scheduleKey?: string; force?: boolean; reason?: string } + ): { queued: boolean; reason?: string } => { + const outputDir = String(task.outputDir || exportFolder || '').trim() + if (!outputDir) { + return { queued: false, reason: '导出目录未设置' } + } + + const hasConflict = tasksRef.current.some((item) => { + if (item.status !== 'running' && item.status !== 'queued') return false + return item.payload.automationTaskId === task.id + }) + if (hasConflict) { + return { queued: false, reason: '任务已有执行队列,本次触发已跳过' } + } + + const exportOptions = buildAutomationExportOptions(task) + const contentType = task.template.contentType + const title = `自动化导出:${task.name}` + enqueueExportTask(title, { + sessionIds: task.sessionIds, + sessionNames: task.sessionNames, + outputDir, + options: exportOptions, + scope: task.template.scope, + source: 'automation', + automationTaskId: task.id, + contentType + }) + const now = Date.now() + patchAutomationTask(task.id, (prev) => ({ + ...prev, + updatedAt: now, + runState: { + ...(prev.runState || {}), + lastRunStatus: 'queued', + lastTriggeredAt: now, + lastSkipReason: undefined, + lastError: undefined, + lastScheduleKey: options?.scheduleKey || prev.runState?.lastScheduleKey + } + })) + if (options?.reason) { + setAutomationHint(options.reason) + } + return { queued: true } + }, [ + buildAutomationExportOptions, + enqueueExportTask, + exportFolder, + patchAutomationTask + ]) + + const resolveAutomationHasNewMessages = useCallback(async (task: ExportAutomationTask): Promise<{ shouldRun: boolean; reason?: string }> => { + const lastSuccessAt = Number(task.runState?.lastSuccessAt || 0) + if (!lastSuccessAt) return { shouldRun: true } + const stats = await window.electronAPI.chat.getExportSessionStats(task.sessionIds, { + includeRelations: false, + allowStaleCache: true + }) + if (!stats.success || !stats.data) { + return { shouldRun: false, reason: stats.error || '会话统计失败,已跳过' } + } + let latestTimestamp = 0 + for (const sessionId of task.sessionIds) { + const raw = Number(stats.data?.[sessionId]?.lastTimestamp || 0) + if (Number.isFinite(raw) && raw > latestTimestamp) { + latestTimestamp = Math.max(0, Math.floor(raw)) + } + } + if (latestTimestamp <= 0) { + return { shouldRun: false, reason: '未检测到可用会话时间戳,已跳过' } + } + const lastSuccessSeconds = Math.floor(lastSuccessAt / 1000) + if (latestTimestamp <= lastSuccessSeconds) { + return { shouldRun: false, reason: '目标会话无新消息,本次已跳过' } + } + return { shouldRun: true } + }, []) + const createTask = async () => { if (!exportDialog.open || !exportFolder) return if (exportDialog.scope !== 'sns' && exportDialog.sessionIds.length === 0) return + const isAutomationCreateIntent = exportDialog.intent === 'automation-create' const exportOptions = exportDialog.scope === 'sns' ? undefined : buildExportOptions(exportDialog.scope, exportDialog.contentType) @@ -4957,30 +5626,58 @@ function ExportPage() { ? '朋友圈批量导出' : `${contentTypeLabels[exportDialog.contentType || 'text']}批量导出` - const task: ExportTask = { - id: createTaskId(), - title, - status: 'queued', - settledSessionIds: [], - createdAt: Date.now(), - payload: { - sessionIds: exportDialog.sessionIds, - sessionNames: exportDialog.sessionNames, + if (isAutomationCreateIntent) { + if (!exportOptions || exportDialog.scope === 'sns') { + window.alert('自动化任务仅支持会话导出') + return + } + const { dateRange: _discard, ...optionTemplate } = exportOptions + const normalizedRangeSelection = cloneExportDateRangeSelection(timeRangeSelection) + const scope = exportDialog.scope === 'single' + ? 'single' + : exportDialog.scope === 'content' + ? 'content' + : 'multi' + setAutomationRangeSelection(normalizedRangeSelection) + setAutomationRangeBounds(null) + setAutomationTaskDraft({ + mode: 'create', + name: exportDialog.sessionIds.length === 1 + ? `${exportDialog.sessionNames[0] || '单会话'} 自动化导出` + : `自动化导出(${exportDialog.sessionIds.length} 个会话)`, + enabled: true, + sessionIds: [...exportDialog.sessionIds], + sessionNames: [...exportDialog.sessionNames], outputDir: exportFolder, - options: exportOptions, - scope: exportDialog.scope, - contentType: exportDialog.contentType, - snsOptions - }, - progress: createEmptyProgress(), - performance: exportDialog.scope === 'content' && exportDialog.contentType === 'text' - ? createEmptyTaskPerformance() - : undefined + useGlobalOutputDir: true, + scope, + contentType: scope === 'content' ? exportDialog.contentType : undefined, + optionTemplate, + dateRangeConfig: serializeExportDateRangeConfig(normalizedRangeSelection), + intervalDays: 1, + intervalHours: 0, + stopAtEnabled: false, + stopAtValue: '', + maxRunsEnabled: false, + maxRuns: 0 + }) + setIsAutomationCreateMode(false) + setAutomationHint('导出配置已完成,请继续设置自动化规则并保存任务') + closeExportDialog() + } else { + enqueueExportTask(title, { + sessionIds: exportDialog.sessionIds, + sessionNames: exportDialog.sessionNames, + outputDir: exportFolder, + options: exportOptions, + scope: exportDialog.scope, + source: 'manual', + contentType: exportDialog.contentType, + snsOptions + }) + closeExportDialog() } - setTasks(prev => [task, ...prev]) - closeExportDialog() - await configService.setExportDefaultFormat(options.format) await configService.setExportDefaultAvatars(options.exportAvatars) await configService.setExportDefaultMedia({ @@ -5037,6 +5734,30 @@ function ExportPage() { .map((item) => item.session) }, [resolveSessionExistingMessageCount]) + const exitAutomationCreateMode = useCallback(() => { + setIsAutomationCreateMode(false) + setAutomationHint('已退出自动化任务创建') + }, []) + + const openAutomationExportConfigDialog = useCallback(() => { + const selectedSet = new Set(selectedSessions) + const selectedRows = sessions.filter((session) => selectedSet.has(session.username)) + const orderedRows = orderSessionsForExport(selectedRows) + if (orderedRows.length === 0) { + window.alert('请先勾选至少一个可导出的会话') + return + } + const ids = orderedRows.map((session) => session.username) + const names = orderedRows.map((session) => session.displayName || session.username) + openExportDialog({ + scope: 'multi', + sessionIds: ids, + sessionNames: names, + title: `自动化任务导出配置(${ids.length} 个会话)`, + intent: 'automation-create' + }) + }, [openExportDialog, orderSessionsForExport, selectedSessions, sessions]) + const openBatchExport = () => { const selectedSet = new Set(selectedSessions) const selectedRows = sessions.filter((session) => selectedSet.has(session.username)) @@ -5122,6 +5843,181 @@ function ExportPage() { [tasks] ) + useEffect(() => { + const previous = automationQueueStatusByTaskIdRef.current + const next = new Map() + for (const task of tasks) { + if (task.payload.source !== 'automation' || !task.payload.automationTaskId) continue + const automationTaskId = task.payload.automationTaskId + next.set(task.id, task.status) + const previousStatus = previous.get(task.id) + if (previousStatus === task.status) continue + + const now = Date.now() + if (task.status === 'running') { + patchAutomationTask(automationTaskId, (current) => ({ + ...current, + updatedAt: now, + runState: { + ...(current.runState || {}), + lastRunStatus: 'running', + lastStartedAt: now, + lastSkipReason: undefined, + lastError: undefined + } + })) + } else if (task.status === 'success') { + patchAutomationTask(automationTaskId, (current) => ({ + ...current, + updatedAt: now, + runState: { + ...(current.runState || {}), + lastRunStatus: 'success', + lastFinishedAt: now, + lastSuccessAt: now, + lastSkipReason: undefined, + lastError: undefined, + successCount: Math.max(0, Math.floor(Number(current.runState?.successCount || 0))) + 1 + } + })) + } else if (task.status === 'error') { + patchAutomationTask(automationTaskId, (current) => ({ + ...current, + updatedAt: now, + runState: { + ...(current.runState || {}), + lastRunStatus: 'error', + lastFinishedAt: now, + lastError: task.error || '导出失败' + } + })) + } + } + automationQueueStatusByTaskIdRef.current = next + }, [patchAutomationTask, tasks]) + + const evaluateAutomationSchedules = useCallback(async () => { + if (!automationTasksReadyRef.current) return + if (automationSchedulerRunningRef.current) return + automationSchedulerRunningRef.current = true + try { + const now = new Date() + const enabledTasks = automationTasksRef.current.filter((task) => task.enabled) + for (const task of enabledTasks) { + const successCount = Math.max(0, Math.floor(Number(task.runState?.successCount || 0))) + const maxRuns = Math.max(0, Math.floor(Number(task.stopCondition?.maxRuns || 0))) + if (maxRuns > 0 && successCount >= maxRuns) { + const stopAt = Date.now() + patchAutomationTask(task.id, (current) => ({ + ...current, + enabled: false, + updatedAt: stopAt, + runState: { + ...(current.runState || {}), + lastRunStatus: 'skipped', + lastSkipAt: stopAt, + lastSkipReason: `已达到最大执行次数(${maxRuns} 次),任务已自动停用`, + successCount: Math.max(0, Math.floor(Number(current.runState?.successCount || 0))) + } + })) + continue + } + + const endAt = Number(task.stopCondition?.endAt || 0) + if (endAt > 0 && now.getTime() > endAt) { + const stopAt = Date.now() + patchAutomationTask(task.id, (current) => ({ + ...current, + enabled: false, + updatedAt: stopAt, + runState: { + ...(current.runState || {}), + lastRunStatus: 'skipped', + lastSkipAt: stopAt, + lastSkipReason: '已超过终止时间,任务已自动停用' + } + })) + continue + } + + const scheduleKey = resolveAutomationDueScheduleKey(task, now) + if (!scheduleKey) continue + if (task.runState?.lastScheduleKey === scheduleKey) continue + + const hasConflict = tasksRef.current.some((item) => { + if (item.status !== 'running' && item.status !== 'queued') return false + return item.payload.automationTaskId === task.id + }) + if (hasConflict) { + markAutomationTaskSkipped(task.id, '任务仍在执行中,本次触发已跳过', scheduleKey) + continue + } + + if (task.condition.type === 'new-message-since-last-success') { + const checkResult = await resolveAutomationHasNewMessages(task) + if (!checkResult.shouldRun) { + markAutomationTaskSkipped(task.id, checkResult.reason || '无新消息,本次触发已跳过', scheduleKey) + continue + } + } + + const queued = enqueueAutomationTask(task, { scheduleKey }) + if (!queued.queued) { + markAutomationTaskSkipped(task.id, queued.reason || '触发失败,本次已跳过', scheduleKey) + } + } + } finally { + automationSchedulerRunningRef.current = false + } + }, [ + enqueueAutomationTask, + markAutomationTaskSkipped, + patchAutomationTask, + resolveAutomationHasNewMessages + ]) + + useEffect(() => { + let cancelled = false + const run = async () => { + if (cancelled) return + if (!automationTasksReadyRef.current) return + try { + await evaluateAutomationSchedules() + } catch (error) { + console.error('自动化导出调度失败:', error) + } + } + void run() + const timer = window.setInterval(() => { + void run() + }, 30_000) + return () => { + cancelled = true + window.clearInterval(timer) + } + }, [evaluateAutomationSchedules]) + + const runAutomationTaskNow = useCallback((taskId: string) => { + const target = automationTasksRef.current.find((task) => task.id === taskId) + if (!target) return + const queued = enqueueAutomationTask(target, { + reason: `已手动触发「${target.name}」`, + scheduleKey: target.runState?.lastScheduleKey + }) + if (!queued.queued) { + markAutomationTaskSkipped(taskId, queued.reason || '手动触发失败') + setAutomationHint(queued.reason || '手动触发失败') + return + } + setAutomationHint(`已加入队列:${target.name}`) + }, [enqueueAutomationTask, markAutomationTaskSkipped]) + + useEffect(() => { + if (!automationHint) return + const timer = window.setTimeout(() => setAutomationHint(null), 2600) + return () => window.clearTimeout(timer) + }, [automationHint]) + const inProgressSessionIdsKey = useMemo( () => inProgressSessionIds.join('||'), [inProgressSessionIds] @@ -6472,6 +7368,7 @@ function ExportPage() { const canCreateTask = exportDialog.scope === 'sns' ? Boolean(exportFolder) : Boolean(exportFolder) && exportDialog.sessionIds.length > 0 + const isAutomationCreateDialog = exportDialog.intent === 'automation-create' const scopeLabel = exportDialog.scope === 'single' ? '单会话' : exportDialog.scope === 'multi' @@ -6790,6 +7687,43 @@ function ExportPage() { const toggleTaskPerfDetail = useCallback((taskId: string) => { setExpandedPerfTaskId(prev => (prev === taskId ? null : taskId)) }, []) + + const toggleAutomationTaskEnabled = useCallback((taskId: string, enabled: boolean) => { + const now = Date.now() + patchAutomationTask(taskId, (task) => ({ + ...task, + enabled, + updatedAt: now + })) + setAutomationHint(enabled ? '自动化任务已启用' : '自动化任务已停用') + }, [patchAutomationTask]) + + const deleteAutomationTask = useCallback((taskId: string) => { + const target = automationTasksRef.current.find((task) => task.id === taskId) + if (!target) return + const confirmed = window.confirm(`确认删除自动化任务「${target.name}」吗?`) + if (!confirmed) return + updateAutomationTasks((prev) => prev.filter((task) => task.id !== taskId)) + setAutomationHint('自动化任务已删除') + }, [updateAutomationTasks]) + + const chooseAutomationDraftOutputDir = useCallback(async () => { + if (!automationTaskDraft) return + const result = await window.electronAPI.dialog.openFile({ + title: '选择任务导出目录', + properties: ['openDirectory'] + }) + if (result.canceled || result.filePaths.length === 0) return + const outputDir = result.filePaths[0] + setAutomationTaskDraft((prev) => { + if (!prev) return prev + return { + ...prev, + outputDir, + useGlobalOutputDir: false + } + }) + }, [automationTaskDraft]) const renderContactRow = useCallback((index: number, contact: ContactInfo) => { const matchedSession = sessionRowByUsername.get(contact.username) const canExport = Boolean(matchedSession?.hasSession) @@ -6799,6 +7733,7 @@ function ExportPage() { const isQueued = canExport && queuedSessionIds.has(contact.username) const recentExportTimestamp = lastExportBySession[contact.username] const hasRecentExport = canExport && Boolean(recentExportTimestamp) + const showRecentExport = !isAutomationCreateMode && hasRecentExport const recentExportTime = hasRecentExport ? formatRecentExportTime(recentExportTimestamp, nowTick) : '' const countedMessages = normalizeMessageCount(sessionMessageCounts[contact.username]) const hintedMessages = normalizeMessageCount(matchedSession?.messageCountHint) @@ -6862,6 +7797,7 @@ function ExportPage() { 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 resolvedAvatarUrl = normalizeExportAvatarUrl(matchedSession?.avatarUrl || contact.avatarUrl) const rowClassName = [ 'contact-row', checked ? 'selected' : '', @@ -6885,7 +7821,7 @@ function ExportPage() {
)}
-
-
- - {hasRecentExport && {recentExportTime}} -
+
+ {!isAutomationCreateMode && ( +
+ + {showRecentExport && {recentExportTime}} +
+ )}
+ {automationHint && ( +
{automationHint}
+ )}
+ {isAutomationModalOpen && createPortal( +
{ + setIsAutomationModalOpen(false) + setAutomationTaskDraft(null) + setIsAutomationRangeDialogOpen(false) + }} + > +
event.stopPropagation()} + > +
+
+

自动化导出

+

仅在应用运行期间生效;错过触发不会补跑。

+
+
+ + +
+
+ +
+ {sortedAutomationTasks.length === 0 ? ( +
+ 暂无自动化任务。点击右上角「新建任务」开始配置。 +
+ ) : ( +
+ {sortedAutomationTasks.map((task) => { + const linkedQueueTask = tasks.find((item) => ( + (item.status === 'running' || item.status === 'queued') && + item.payload.automationTaskId === task.id + )) + const queueState: 'queued' | 'running' | null = linkedQueueTask?.status === 'running' + ? 'running' + : linkedQueueTask?.status === 'queued' + ? 'queued' + : null + return ( +
+
+
+ {task.name} + + {task.enabled ? '已启用' : '已停用'} + + {queueState === 'running' && 执行中} + {queueState === 'queued' && 排队中} +
+

{formatAutomationScheduleLabel(task.schedule)}

+

时间范围:{formatAutomationRangeLabel(task.template.dateRangeConfig as any)}

+

会话范围:{task.sessionIds.length} 个

+

导出目录:{task.outputDir || `${exportFolder || '未设置'}(全局)`}

+

当前状态:{formatAutomationCurrentState(task, queueState, nowTick)}

+

终止条件:{formatAutomationStopCondition(task)}

+

最近结果:{formatAutomationLastRunSummary(task)}

+
+
+ + + + +
+
+ ) + })} +
+ )} +
+
+
, + document.body + )} + + {automationTaskDraft && createPortal( +
{ + setAutomationTaskDraft(null) + setIsAutomationRangeDialogOpen(false) + }}> +
event.stopPropagation()} + > +
+

{automationTaskDraft.mode === 'edit' ? '编辑自动化任务' : '创建自动化任务'}

+ +
+
+ + +
+ + +
+ +
+ 导出时间范围(按触发时间动态计算) +
+ {AUTOMATION_RANGE_OPTIONS.map((option) => { + const active = resolveAutomationRangeMode(automationTaskDraft.dateRangeConfig as any, automationRangeSelection) === option.mode + return ( + + ) + })} +
+ {resolveAutomationRangeMode(automationTaskDraft.dateRangeConfig as any, automationRangeSelection) === 'lastNDays' && ( + + )} +
+ {formatAutomationRangeLabel(automationTaskDraft.dateRangeConfig as any, automationRangeSelection)} + {resolveAutomationRangeMode(automationTaskDraft.dateRangeConfig as any, automationRangeSelection) === 'custom' && ( + + )} +
+
+ +
+ 终止条件(可选) + + {automationTaskDraft.stopAtEnabled && ( +
+ { + const datePart = e.target.value + const timePart = automationTaskDraft.stopAtValue?.slice(11) || '23:59' + setAutomationTaskDraft((prev) => prev ? { ...prev, stopAtValue: datePart ? `${datePart}T${timePart}` : '' } : prev) + }} + /> + { + const timePart = e.target.value + const datePart = automationTaskDraft.stopAtValue?.slice(0, 10) || new Date().toISOString().slice(0, 10) + setAutomationTaskDraft((prev) => prev ? { ...prev, stopAtValue: `${datePart}T${timePart}` } : prev) + }} + /> +
+ )} + + + + {automationTaskDraft.maxRunsEnabled && ( + setAutomationTaskDraft((prev) => prev ? { + ...prev, + maxRuns: Math.max(0, Math.floor(Number(event.target.value) || 0)) + } : prev)} + /> + )} +
+ +
+ 导出目录 + + {!automationTaskDraft.useGlobalOutputDir && ( +
+ + {automationTaskDraft.outputDir || '未设置'} +
+ )} +
+ + + +
+ 会话:{automationTaskDraft.sessionIds.length} 个 · 间隔:{automationTaskDraft.intervalDays} 天 {automationTaskDraft.intervalHours} 小时 · 时间:{formatAutomationRangeLabel(automationTaskDraft.dateRangeConfig as any, automationRangeSelection)} · 条件:有新消息才导出 +
+
+
+ + +
+ setIsAutomationRangeDialogOpen(false)} + onConfirm={(nextSelection) => { + setAutomationRangeSelection(nextSelection) + setAutomationTaskDraft((prev) => prev ? { + ...prev, + dateRangeConfig: serializeExportDateRangeConfig(nextSelection) + } : prev) + setIsAutomationRangeDialogOpen(false) + }} + /> +
+
, + document.body + )} + {isExportDefaultsModalOpen && createPortal(
))}
- + {!isAutomationCreateMode && ( + + )}
) })} @@ -7311,6 +8634,18 @@ function ExportPage() { '你可以先在列表中筛选目标会话,再批量导出,结果会保留每个会话的结构与时间线。' ]} /> + {isAutomationCreateMode && ( +
+ 自动化创建中:先勾选联系人,再点击「加入任务」 + +
+ )} + )} {selectedCount > 0 && ( <> @@ -8270,20 +9614,22 @@ function ExportPage() { -
-
-

时间范围

- + {!isAutomationCreateDialog && ( +
+
+

时间范围

+ +
-
+ )} {shouldShowMediaSection && (
@@ -8461,7 +9807,7 @@ function ExportPage() {
diff --git a/src/pages/MyFootprintPage.scss b/src/pages/MyFootprintPage.scss new file mode 100644 index 0000000..af1dd69 --- /dev/null +++ b/src/pages/MyFootprintPage.scss @@ -0,0 +1,825 @@ +.my-footprint-page { + --timeline-mention: #f59e0b; /* muted orange */ + --timeline-private: #3b82f6; /* muted blue */ + + min-height: 100%; + margin: -24px -24px 0; + padding: 32px 40px; + background: var(--bg-primary); /* Pure minimal background */ + display: flex; + flex-direction: column; + gap: 24px; + overflow-y: auto; + overflow-x: hidden; + animation: footprintPageEnter 0.4s ease-out; + + .card-surface { + /* Removing border and strong shadows, just subtle background if any */ + background: transparent; + } + + .spin { + animation: footprintSpin 1s linear infinite; + } +} + +.footprint-header { + position: relative; + z-index: 30; + display: flex; + flex-direction: column; + gap: 20px; + padding: 10px 0 20px 0; + border-bottom: 1px solid color-mix(in srgb, var(--border-color) 40%, transparent); + animation: footprintFadeSlideUp 0.3s ease both; +} + +.footprint-title-wrap { + display: flex; + flex-direction: column; + gap: 8px; + + h1 { + margin: 0; + font-size: 26px; + font-weight: 600; + line-height: 1.3; + letter-spacing: -0.3px; + color: var(--text-primary); + } + + p { + margin: 0; + color: var(--text-tertiary); + font-size: 14px; + } +} + +.footprint-title-badge { + display: none; /* Removed for minimal design */ +} + +.footprint-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.range-preset-group { + display: flex; + gap: 4px; + background: color-mix(in srgb, var(--text-tertiary) 8%, transparent); + padding: 4px; + border-radius: 8px; +} + +.preset-chip { + background: transparent; + border: none; + color: var(--text-secondary); + border-radius: 6px; + padding: 6px 14px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + color: var(--text-primary); + } + + &.active { + color: var(--text-primary); + background: var(--bg-primary); + box-shadow: 0 1px 3px rgba(0,0,0,0.05); + } +} + +.custom-range-row { + display: inline-flex; + align-items: center; + gap: 10px; + + span { + color: var(--text-tertiary); + font-size: 13px; + font-weight: 500; + } + + input[type="date"] { + font-family: inherit; + border: 1px solid transparent; + background: color-mix(in srgb, var(--text-tertiary) 6%, transparent); + color: var(--text-primary); + border-radius: 8px; + padding: 6px 10px; + font-size: 13px; + font-weight: 500; + transition: all 0.2s ease; + cursor: pointer; + box-shadow: 0 1px 2px rgba(0,0,0,0.02) inset; + + &:hover { + background: color-mix(in srgb, var(--text-tertiary) 12%, transparent); + } + + &:focus { + outline: none; + background: color-mix(in srgb, var(--primary) 4%, transparent); + border-color: color-mix(in srgb, var(--primary) 30%, transparent); + color: var(--primary); + } + + &::-webkit-calendar-picker-indicator { + cursor: pointer; + opacity: 0.5; + transition: all 0.2s ease; + padding: 4px; + margin-left: 4px; + margin-right: -4px; + border-radius: 4px; + } + + &::-webkit-calendar-picker-indicator:hover { + opacity: 0.9; + background: color-mix(in srgb, var(--text-tertiary) 12%, transparent); + } + } +} + +.toolbar-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.search-input { + display: flex; + align-items: center; + gap: 8px; + border: none; + background: color-mix(in srgb, var(--text-tertiary) 8%, transparent); + border-radius: 8px; + padding: 8px 12px; + color: var(--text-tertiary); + transition: all 0.2s ease; + + &:focus-within { + background: color-mix(in srgb, var(--primary) 8%, transparent); + color: var(--primary); + } + + input { + min-width: 180px; + border: none; + background: transparent; + color: var(--text-primary); + font-size: 13px; + + &::placeholder { + color: var(--text-tertiary); + } + &:focus { + outline: none; + } + } +} + +.action-btn, +.jump-btn { + border: none; + background: color-mix(in srgb, var(--text-tertiary) 8%, transparent); + color: var(--text-secondary); + border-radius: 8px; + padding: 8px 12px; + font-size: 13px; + font-weight: 500; + display: inline-flex; + align-items: center; + gap: 6px; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background: color-mix(in srgb, var(--text-tertiary) 15%, transparent); + color: var(--text-primary); + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } +} + +.kpi-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 20px; + padding: 20px 0; + border-bottom: 1px solid color-mix(in srgb, var(--border-color) 40%, transparent); +} + +.kpi-card { + border: none; + background: transparent; + padding: 0; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + text-align: left; + color: var(--text-primary); + animation: footprintKpiIn 0.3s ease both; + transition: opacity 0.2s ease; + cursor: pointer; + + &:hover { + opacity: 0.7; + } + + strong { + font-size: 32px; + font-weight: 300; + line-height: 1; + color: var(--text-primary); + letter-spacing: -0.5px; + } + + small { + color: var(--text-tertiary); + font-size: 12px; + } +} + +.kpi-icon { + display: none; /* Minimalistic, hide icon in KPI */ +} + +.footprint-ai-result { + border-radius: 10px; + padding: 14px 16px; + background: color-mix(in srgb, var(--text-tertiary) 8%, transparent); + border: 1px solid color-mix(in srgb, var(--border-color) 40%, transparent); + + .footprint-ai-head { + display: flex; + justify-content: space-between; + gap: 8px; + margin-bottom: 8px; + + strong { + font-size: 14px; + color: var(--text-primary); + } + + span { + font-size: 12px; + color: var(--text-tertiary); + } + } + + p { + margin: 0; + white-space: pre-wrap; + line-height: 1.6; + color: var(--text-secondary); + font-size: 13px; + } + + &.footprint-ai-result-error { + border-color: color-mix(in srgb, #ef4444 50%, transparent); + } +} + +.kpi-label { + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); +} + +.kpi-diagnostics { + cursor: default; + &:hover { opacity: 1; } + border-left: 1px solid color-mix(in srgb, var(--border-color) 40%, transparent); + padding-left: 20px; +} + +.footprint-timeline { + animation: timelineSwitchFade 0.4s cubic-bezier(0.16, 1, 0.3, 1) both; + --timeline-time-col-width: 64px; + --timeline-dot-col-width: 20px; + --timeline-gap: 24px; + + flex: 1; + display: flex; + flex-direction: column; + gap: 24px; + padding: 10px 0; + + &.timeline-time-month_day_clock { + --timeline-time-col-width: 86px; + } + + &.timeline-time-full_date_clock { + --timeline-time-col-width: 124px; + } +} + +.timeline-head { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} + +.timeline-head-left h2 { + display: none; /* the minimalist approach relies on content, we skip this redundant title */ +} + +.timeline-head-left p { + display: none; +} + +.timeline-mode-row { + display: flex; + gap: 4px; + background: color-mix(in srgb, var(--text-tertiary) 6%, transparent); + padding: 3px; + border-radius: 8px; +} + +.timeline-mode-chip { + border: none; + background: transparent; + color: var(--text-secondary); + font-size: 13px; + font-weight: 500; + padding: 6px 12px; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + color: var(--text-primary); + } + + &.active { + color: var(--text-primary); + background: var(--bg-primary); + box-shadow: 0 1px 3px rgba(0,0,0,0.04); + } +} + +.timeline-stream { + position: relative; + padding-bottom: 40px; + + &::before { + content: ''; + position: absolute; + left: calc(var(--timeline-time-col-width) + var(--timeline-gap) + 9px); + top: 0; + bottom: 0; + width: 2px; + background: color-mix(in srgb, var(--text-tertiary) 20%, transparent); + } +} + +.timeline-item { + display: grid; + grid-template-columns: var(--timeline-time-col-width) var(--timeline-dot-col-width) minmax(0, 1fr); + column-gap: var(--timeline-gap); + align-items: stretch; + margin-bottom: 38px; +} + +.timeline-time { + font-size: 13px; + color: var(--text-tertiary); + text-align: right; + padding-top: 5px; + height: 100%; +} + +.timeline-time-private { + padding-top: 5px; +} + +.timeline-time-range { + display: flex; + flex-direction: column; + align-items: flex-end; + justify-content: space-between; + height: 100%; +} + +.timeline-time-main, +.timeline-time-end { + color: var(--text-secondary); + font-weight: 500; + line-height: 1; +} + +.timeline-time-sep { + display: none; +} + +.timeline-dot-col { + display: flex; + flex-direction: column; + align-items: center; + padding-top: 9px; + height: 100%; +} + +.timeline-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--text-tertiary); + position: relative; + z-index: 1; + flex-shrink: 0; +} + +.timeline-dot-mention { + background: var(--timeline-mention); +} + +.timeline-dot-private { + background: var(--timeline-private); +} + +.timeline-dot-private-inbound_only { + background: var(--timeline-mention); + border: none; +} + +.timeline-dot-private-outbound_only { + background: #22c55e; +} + +.timeline-dot-private-both { + background: var(--timeline-private); +} + +.timeline-dot-start, +.timeline-dot-end { + background: transparent; + border: 1.5px solid var(--text-tertiary); + width: 10px; + height: 10px; + margin-top: -1px; +} + +.timeline-dot-range { + display: flex; + flex-direction: column; + align-items: center; + height: 100%; + width: 100%; +} + +.timeline-dot-range-line { + width: 2px; + flex: 1; + margin: 4px 0; + background: color-mix(in srgb, var(--timeline-private) 30%, transparent); +} + +.timeline-dot-range-line-inbound_only { + background: color-mix(in srgb, var(--timeline-mention) 55%, transparent); +} + +.timeline-dot-range-line-outbound_only { + background: #22c55e; +} + +.timeline-dot-range-line-both { + background: color-mix(in srgb, var(--timeline-private) 55%, transparent); +} + +.timeline-content-wrap { + padding-top: 2px; + padding-bottom: 6px; + display: flex; + flex-direction: column; + justify-content: space-between; + min-height: 100%; +} + +.timeline-boundary { + font-size: 13px; + color: var(--text-tertiary); + padding: 4px 0; +} + +.timeline-card { + display: flex; + flex-direction: column; + gap: 8px; + + /* completely clean out the old card style */ + border: none; + background: transparent; + padding: 0; +} + +.timeline-card-head { + display: flex; + justify-content: space-between; + align-items: center; +} + +.timeline-identity { + display: flex; + align-items: center; + gap: 12px; +} + +.timeline-avatar { + width: 28px; + height: 28px; + border-radius: 50%; /* Modern circle avatars */ + overflow: hidden; + background: color-mix(in srgb, var(--text-tertiary) 10%, transparent); + display: flex; + align-items: center; + justify-content: center; + color: var(--text-tertiary); + + img { + width: 100%; + height: 100%; + object-fit: cover; + } +} + +.timeline-avatar-private { + color: var(--timeline-private); +} + +.timeline-title-group { + display: flex; + align-items: baseline; + gap: 8px; +} + +.timeline-title { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); +} + +.timeline-subtitle { + font-size: 13px; + color: var(--text-tertiary); +} + +.timeline-right-tools { + display: flex; + align-items: center; + gap: 12px; +} + +.timeline-count-badge { + font-size: 12px; + color: var(--text-tertiary); +} + +.timeline-jump-btn { + padding: 4px 10px; + font-size: 12px; + background: transparent; + color: var(--text-tertiary); + + &:hover { + background: color-mix(in srgb, var(--text-tertiary) 10%, transparent); + color: var(--text-primary); + } +} + +.timeline-message { + font-size: 13px; + line-height: 1.5; + color: var(--text-secondary); + padding: 0; + margin-top: 4px; + background: transparent; + border-radius: 0; + word-break: break-word; + white-space: pre-wrap; +} + +.mention-message { + color: var(--text-primary); +} + +.private-message { + color: var(--text-tertiary); +} + +@keyframes timelineSwitchFade { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + + +.mention-token { + color: var(--timeline-mention); + font-weight: 600; +} + +.panel-empty-state { + text-align: center; + padding: 60px 0; + color: var(--text-tertiary); + font-size: 14px; +} + +.footprint-loading { + padding: 40px 0; +} + +.kpi-skeleton-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 20px; + margin-bottom: 20px; +} + +.kpi-skeleton-card { + height: 60px; + border-radius: 8px; + background: color-mix(in srgb, var(--text-tertiary) 6%, transparent); +} + +.timeline-skeleton-list { + display: flex; + flex-direction: column; + gap: 20px; +} + +.timeline-skeleton-item { + height: 80px; + border-radius: 8px; + background: color-mix(in srgb, var(--text-tertiary) 6%, transparent); +} + +.footprint-export-modal-mask { + position: fixed; + inset: 0; + z-index: 1200; + background: color-mix(in srgb, #000 36%, transparent); + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} + +.footprint-export-modal { + width: min(520px, 100%); + border-radius: 16px; + background: var(--bg-primary); + border: 1px solid color-mix(in srgb, var(--border-color) 60%, transparent); + box-shadow: 0 18px 60px rgba(0, 0, 0, 0.2); + padding: 22px 22px 18px; + display: flex; + flex-direction: column; + gap: 10px; + animation: footprintFadeSlideUp 0.2s ease both; + + h3 { + margin: 0; + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + } + + p { + margin: 0; + color: var(--text-secondary); + font-size: 14px; + line-height: 1.5; + } +} + +.export-modal-icon { + width: 34px; + height: 34px; + border-radius: 10px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.export-modal-icon-progress { + color: var(--primary); + background: color-mix(in srgb, var(--primary) 16%, transparent); +} + +.export-modal-icon-success { + color: #16a34a; + background: color-mix(in srgb, #16a34a 18%, transparent); +} + +.export-modal-icon-error { + color: #ef4444; + background: color-mix(in srgb, #ef4444 18%, transparent); +} + +.export-modal-path { + display: block; + margin-top: 2px; + padding: 10px 12px; + border-radius: 10px; + font-family: inherit; + font-weight: 500; + font-size: 12px; + line-height: 1.4; + color: var(--text-tertiary); + background: color-mix(in srgb, var(--text-tertiary) 8%, transparent); + border: 1px solid color-mix(in srgb, var(--border-color) 40%, transparent); + white-space: pre-wrap; + word-break: break-all; +} + +.export-modal-actions { + display: flex; + justify-content: flex-end; + margin-top: 6px; +} + +.skeleton-shimmer { + position: relative; + overflow: hidden; + + &::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient( + 90deg, + transparent, + color-mix(in srgb, var(--bg-primary) 50%, transparent), + transparent + ); + transform: translateX(-100%); + animation: footprintShimmer 1.5s infinite; + } +} + +/* Animations */ +@keyframes footprintPageEnter { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes footprintFadeSlideUp { + from { opacity: 0; transform: translateY(5px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes footprintKpiIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes footprintTimelineItemIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes footprintSpin { + 100% { transform: rotate(360deg); } +} + +@keyframes footprintShimmer { + 100% { transform: translateX(100%); } +} + +@media (max-width: 1100px) { + .kpi-grid, + .kpi-skeleton-grid { + grid-template-columns: repeat(3, 1fr); + } +} + +@media (max-width: 800px) { + .my-footprint-page { + padding: 20px; + } + .kpi-grid, + .kpi-skeleton-grid { + grid-template-columns: repeat(2, 1fr); + } +} diff --git a/src/pages/MyFootprintPage.tsx b/src/pages/MyFootprintPage.tsx new file mode 100644 index 0000000..ff7918d --- /dev/null +++ b/src/pages/MyFootprintPage.tsx @@ -0,0 +1,983 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' +import { useNavigate } from 'react-router-dom' +import { AlertCircle, AtSign, CheckCircle2, Download, Loader2, MessageCircle, RefreshCw, Search, Sparkles, Users } from 'lucide-react' +import DateRangePicker from '../components/DateRangePicker' +import './MyFootprintPage.scss' + +type RangePreset = 'today' | 'yesterday' | 'this_week' | 'last_week' | 'custom' +type TimelineMode = 'all' | 'mention' | 'private' +type TimelineTimeMode = 'clock' | 'month_day_clock' | 'full_date_clock' +type PrivateDotVariant = 'both' | 'inbound_only' | 'outbound_only' +type ExportModalStatus = 'idle' | 'progress' | 'success' | 'error' +type FootprintAiStatus = 'idle' | 'loading' | 'success' | 'error' + +interface MyFootprintSummary { + private_inbound_people: number + private_replied_people: number + private_outbound_people: number + private_reply_rate: number + mention_count: number + mention_group_count: number +} + +interface MyFootprintPrivateSession { + session_id: string + incoming_count: number + outgoing_count: number + replied: boolean + first_incoming_ts: number + first_reply_ts: number + latest_ts: number + anchor_local_id: number + anchor_create_time: number + displayName?: string + avatarUrl?: string +} + +interface MyFootprintPrivateSegment { + session_id: string + segment_index: number + start_ts: number + end_ts: number + duration_sec: number + incoming_count: number + outgoing_count: number + message_count: number + replied: boolean + first_incoming_ts: number + first_reply_ts: number + latest_ts: number + anchor_local_id: number + anchor_create_time: number + displayName?: string + avatarUrl?: string +} + +interface MyFootprintMention { + session_id: string + local_id: number + create_time: number + sender_username: string + message_content: string + source: string + sessionDisplayName?: string + senderDisplayName?: string + senderAvatarUrl?: string +} + +interface MyFootprintMentionGroup { + session_id: string + count: number + latest_ts: number + displayName?: string + avatarUrl?: string +} + +interface MyFootprintDiagnostics { + truncated: boolean + scanned_dbs: number + elapsed_ms: number + mention_truncated?: boolean + private_truncated?: boolean +} + +interface MyFootprintData { + summary: MyFootprintSummary + private_sessions: MyFootprintPrivateSession[] + private_segments: MyFootprintPrivateSegment[] + mentions: MyFootprintMention[] + mention_groups: MyFootprintMentionGroup[] + diagnostics: MyFootprintDiagnostics +} + +interface TimelineBoundaryItem { + kind: 'boundary' + edge: 'start' | 'end' + key: string + time: number + label: string +} + +interface TimelineMentionItem { + kind: 'mention' + key: string + time: number + sessionId: string + localId: number + createTime: number + groupName: string + groupAvatarUrl?: string + senderName: string + messageContent: string +} + +interface TimelinePrivateItem { + kind: 'private' + key: string + time: number + endTime: number + sessionId: string + anchorLocalId: number + anchorCreateTime: number + displayName: string + avatarUrl?: string + subtitle: string + totalInteractions: number + summaryText: string + dotVariant: PrivateDotVariant + isRange: boolean +} + +type TimelineItem = TimelineBoundaryItem | TimelineMentionItem | TimelinePrivateItem + +const EMPTY_DATA: MyFootprintData = { + summary: { + private_inbound_people: 0, + private_replied_people: 0, + private_outbound_people: 0, + private_reply_rate: 0, + mention_count: 0, + mention_group_count: 0 + }, + private_sessions: [], + private_segments: [], + mentions: [], + mention_groups: [], + diagnostics: { + truncated: false, + scanned_dbs: 0, + elapsed_ms: 0, + mention_truncated: false, + private_truncated: false + } +} + +function toDayStart(date: Date): Date { + const next = new Date(date) + next.setHours(0, 0, 0, 0) + return next +} + +function toDayEnd(date: Date): Date { + const next = new Date(date) + next.setHours(23, 59, 59, 999) + return next +} + +function toSeconds(date: Date): number { + return Math.floor(date.getTime() / 1000) +} + +function toDateInputValue(date: Date): string { + const y = date.getFullYear() + const m = `${date.getMonth() + 1}`.padStart(2, '0') + const d = `${date.getDate()}`.padStart(2, '0') + return `${y}-${m}-${d}` +} + +function getWeekStart(date: Date): Date { + const base = toDayStart(date) + const day = base.getDay() + const diff = day === 0 ? -6 : 1 - day + base.setDate(base.getDate() + diff) + return base +} + +function formatTimelineMoment(seconds: number, mode: TimelineTimeMode): string { + if (!seconds || !Number.isFinite(seconds)) return '--' + const date = new Date(seconds * 1000) + const yyyy = `${date.getFullYear()}` + const mm = `${date.getMonth() + 1}`.padStart(2, '0') + const dd = `${date.getDate()}`.padStart(2, '0') + const hh = `${date.getHours()}`.padStart(2, '0') + const min = `${date.getMinutes()}`.padStart(2, '0') + if (mode === 'full_date_clock') { + return `${yyyy}-${mm}-${dd} ${hh}:${min}` + } + if (mode === 'month_day_clock') { + return `${mm}-${dd} ${hh}:${min}` + } + return `${hh}:${min}` +} + +function formatPercent(value: number): string { + const safe = Number.isFinite(value) ? value : 0 + return `${(safe * 100).toFixed(1)}%` +} + +function decodeHtmlEntities(content: string): string { + return String(content || '') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") +} + +function stripGroupSenderPrefix(content: string): string { + return String(content || '') + .replace(/^[\s]*([a-zA-Z0-9_@-]+):(?!\/\/)(?:\s*(?:\r?\n|)\s*|\s*)/i, '') + .replace(/^[a-zA-Z0-9]+@openim:\n?/i, '') +} + +function normalizeFootprintMessageContent(content: string): string { + const decoded = decodeHtmlEntities(content || '') + const stripped = stripGroupSenderPrefix(decoded) + return stripped.trim() +} + +function renderMentionContent(content: string): ReactNode { + const normalized = String(content || '').trim() || '[空消息]' + const parts = normalized.split(/(@我|@我)/g) + if (parts.length <= 1) return normalized + return parts.map((part, index) => { + if (part === '@我' || part === '@我') { + return ( + + {part} + + ) + } + return {part} + }) +} + +function formatDurationLabel(beginTimestamp: number, endTimestamp: number): string { + if (!beginTimestamp || !endTimestamp || endTimestamp <= beginTimestamp) { + return '持续不足 1 分钟' + } + const minutes = Math.max(1, Math.round((endTimestamp - beginTimestamp) / 60)) + return `持续 ${minutes} 分钟` +} + +function resolveRangePresetLabel(preset: RangePreset): string { + switch (preset) { + case 'today': + return '今天' + case 'yesterday': + return '昨天' + case 'this_week': + return '本周' + case 'last_week': + return '上周' + default: + return '自定义' + } +} + +function buildRange(preset: RangePreset, customStart: string, customEnd: string): { begin: number; end: number; label: string } { + const now = new Date() + + if (preset === 'today') { + return { + begin: toSeconds(toDayStart(now)), + end: toSeconds(now), + label: '今天' + } + } + + if (preset === 'yesterday') { + const yesterday = new Date(now) + yesterday.setDate(yesterday.getDate() - 1) + return { + begin: toSeconds(toDayStart(yesterday)), + end: toSeconds(toDayEnd(yesterday)), + label: '昨天' + } + } + + if (preset === 'this_week') { + const weekStart = getWeekStart(now) + const weekEnd = new Date(weekStart) + weekEnd.setDate(weekStart.getDate() + 6) + return { + begin: toSeconds(toDayStart(weekStart)), + end: toSeconds(toDayEnd(weekEnd)), + label: '本周' + } + } + + if (preset === 'last_week') { + const thisWeekStart = getWeekStart(now) + const lastWeekStart = new Date(thisWeekStart) + lastWeekStart.setDate(lastWeekStart.getDate() - 7) + const lastWeekEnd = new Date(thisWeekStart) + lastWeekEnd.setDate(lastWeekEnd.getDate() - 1) + return { + begin: toSeconds(toDayStart(lastWeekStart)), + end: toSeconds(toDayEnd(lastWeekEnd)), + label: '上周' + } + } + + const customStartDate = customStart ? new Date(`${customStart}T00:00:00`) : toDayStart(now) + const customEndDate = customEnd ? new Date(`${customEnd}T23:59:59`) : toDayEnd(now) + const begin = toSeconds(customStartDate) + const end = Math.max(begin, toSeconds(customEndDate)) + + return { + begin, + end, + label: `${toDateInputValue(customStartDate)} 至 ${toDateInputValue(customEndDate)}` + } +} + +function MyFootprintPage() { + const navigate = useNavigate() + const [preset, setPreset] = useState('today') + const [customStartDate, setCustomStartDate] = useState(() => toDateInputValue(toDayStart(new Date()))) + const [customEndDate, setCustomEndDate] = useState(() => toDateInputValue(toDayStart(new Date()))) + const [searchKeyword, setSearchKeyword] = useState('') + const [timelineMode, setTimelineMode] = useState('all') + const [data, setData] = useState(EMPTY_DATA) + const [loading, setLoading] = useState(false) + const [exporting, setExporting] = useState(false) + const [exportModalStatus, setExportModalStatus] = useState('idle') + const [exportModalTitle, setExportModalTitle] = useState('') + const [exportModalDescription, setExportModalDescription] = useState('') + const [exportModalPath, setExportModalPath] = useState('') + const [error, setError] = useState(null) + const [footprintAiStatus, setFootprintAiStatus] = useState('idle') + const [footprintAiText, setFootprintAiText] = useState('') + const inflightRangeKeyRef = useRef(null) + + const currentRange = useMemo(() => buildRange(preset, customStartDate, customEndDate), [preset, customStartDate, customEndDate]) + const timelineTimeMode = useMemo(() => { + const span = Math.max(0, currentRange.end - currentRange.begin) + if (span > 365 * 24 * 60 * 60) return 'full_date_clock' + if (span > 24 * 60 * 60) return 'month_day_clock' + return 'clock' + }, [currentRange.begin, currentRange.end]) + + const handleJump = useCallback((sessionId: string, localId: number, createTime: number) => { + if (!sessionId || !localId || !createTime) return + const query = new URLSearchParams({ + sessionId, + jumpLocalId: String(localId), + jumpCreateTime: String(createTime), + jumpSource: 'footprint' + }) + navigate(`/chat?${query.toString()}`) + }, [navigate]) + + const loadData = useCallback(async () => { + const rangeKey = `${currentRange.begin}-${currentRange.end}` + if (inflightRangeKeyRef.current === rangeKey) { + return + } + inflightRangeKeyRef.current = rangeKey + setLoading(true) + setError(null) + try { + const result = await window.electronAPI.chat.getMyFootprintStats(currentRange.begin, currentRange.end) + if (!result.success || !result.data) { + setError(result.error || '读取统计失败') + setData(EMPTY_DATA) + return + } + setData({ + ...result.data, + private_segments: Array.isArray(result.data.private_segments) ? result.data.private_segments : [] + }) + } catch (loadError) { + setError(String(loadError)) + setData(EMPTY_DATA) + } finally { + setLoading(false) + if (inflightRangeKeyRef.current === rangeKey) { + inflightRangeKeyRef.current = null + } + } + }, [currentRange.begin, currentRange.end]) + + useEffect(() => { + void loadData() + }, [loadData]) + + const keyword = searchKeyword.trim().toLowerCase() + + const privateSessionMetaMap = useMemo(() => { + const map = new Map() + for (const item of data.private_sessions) { + map.set(item.session_id, { + displayName: item.displayName, + avatarUrl: item.avatarUrl + }) + } + for (const item of data.private_segments) { + if (!map.has(item.session_id)) { + map.set(item.session_id, { + displayName: item.displayName, + avatarUrl: item.avatarUrl + }) + } + } + return map + }, [data.private_sessions, data.private_segments]) + + const filteredMentions = useMemo(() => { + if (!keyword) return data.mentions + return data.mentions.filter((item) => { + const sessionName = (item.sessionDisplayName || '').toLowerCase() + const senderName = (item.senderDisplayName || '').toLowerCase() + const sender = item.sender_username.toLowerCase() + const content = normalizeFootprintMessageContent(item.message_content).toLowerCase() + return sessionName.includes(keyword) || senderName.includes(keyword) || sender.includes(keyword) || content.includes(keyword) + }) + }, [data.mentions, keyword]) + + const filteredPrivateSegments = useMemo(() => { + const rawSegments = data.private_segments.length > 0 + ? data.private_segments + : data.private_sessions.map((item, index) => ({ + session_id: item.session_id, + segment_index: index + 1, + start_ts: item.first_incoming_ts > 0 + ? item.first_incoming_ts + : item.first_reply_ts > 0 + ? item.first_reply_ts + : item.latest_ts, + end_ts: item.latest_ts, + duration_sec: Math.max(0, item.latest_ts - (item.first_incoming_ts || item.first_reply_ts || item.latest_ts)), + incoming_count: item.incoming_count, + outgoing_count: item.outgoing_count, + message_count: Math.max(0, item.incoming_count + item.outgoing_count), + replied: item.replied, + first_incoming_ts: item.first_incoming_ts, + first_reply_ts: item.first_reply_ts, + latest_ts: item.latest_ts, + anchor_local_id: item.anchor_local_id, + anchor_create_time: item.anchor_create_time, + displayName: item.displayName, + avatarUrl: item.avatarUrl + })) + + if (!keyword) return rawSegments + return rawSegments.filter((item) => { + const meta = privateSessionMetaMap.get(item.session_id) + const name = String(item.displayName || meta?.displayName || '').toLowerCase() + const id = item.session_id.toLowerCase() + return name.includes(keyword) || id.includes(keyword) + }) + }, [data.private_segments, data.private_sessions, keyword, privateSessionMetaMap]) + + const mentionGroupMetaMap = useMemo(() => { + const map = new Map() + for (const item of data.mention_groups) { + map.set(item.session_id, { displayName: item.displayName, avatarUrl: item.avatarUrl }) + } + for (const item of data.private_sessions) { + if (!map.has(item.session_id)) { + map.set(item.session_id, { displayName: item.displayName, avatarUrl: item.avatarUrl }) + } + } + return map + }, [data.mention_groups, data.private_sessions]) + + const mentionTimelineItems = useMemo(() => { + return filteredMentions + .filter((item) => item.create_time > 0) + .map((item) => { + const groupMeta = mentionGroupMetaMap.get(item.session_id) + return { + kind: 'mention' as const, + key: `mention:${item.session_id}:${item.local_id}`, + time: item.create_time, + sessionId: item.session_id, + localId: item.local_id, + createTime: item.create_time, + groupName: item.sessionDisplayName || groupMeta?.displayName || item.session_id, + groupAvatarUrl: groupMeta?.avatarUrl, + senderName: item.senderDisplayName || item.sender_username || '未知', + messageContent: normalizeFootprintMessageContent(item.message_content) + } + }) + }, [filteredMentions, mentionGroupMetaMap]) + + const privateTimelineItems = useMemo(() => { + return filteredPrivateSegments + .map((item) => { + const startTime = item.start_ts > 0 + ? item.start_ts + : item.first_incoming_ts > 0 + ? item.first_incoming_ts + : item.first_reply_ts > 0 + ? item.first_reply_ts + : item.latest_ts + + const endTime = item.end_ts > 0 ? item.end_ts : item.latest_ts + const isRange = endTime > startTime + 60 + const totalInteractions = Math.max(0, item.message_count || (item.incoming_count + item.outgoing_count)) + const durationLabel = item.duration_sec > 0 + ? `持续 ${Math.max(1, Math.round(item.duration_sec / 60))} 分钟` + : formatDurationLabel(startTime, endTime) + const subtitle = isRange + ? `${formatTimelineMoment(startTime, timelineTimeMode)} 至 ${formatTimelineMoment(endTime || startTime, timelineTimeMode)} · ${durationLabel}` + : '' + const summaryText = `收到 ${item.incoming_count} 条 / 发送 ${item.outgoing_count} 条${item.replied ? ' · 已回复' : ''}` + const sessionMeta = privateSessionMetaMap.get(item.session_id) + let dotVariant: PrivateDotVariant = 'both' + if (item.incoming_count > 0 && item.outgoing_count === 0) { + dotVariant = 'inbound_only' + } else if (item.incoming_count === 0 && item.outgoing_count > 0) { + dotVariant = 'outbound_only' + } + + return { + kind: 'private' as const, + key: `private:${item.session_id}:${item.segment_index}:${item.start_ts}`, + time: startTime, + endTime, + sessionId: item.session_id, + anchorLocalId: item.anchor_local_id, + anchorCreateTime: item.anchor_create_time, + displayName: item.displayName || sessionMeta?.displayName || item.session_id, + avatarUrl: item.avatarUrl || sessionMeta?.avatarUrl, + subtitle, + totalInteractions, + summaryText, + dotVariant, + isRange + } + }) + .filter((item) => item.time > 0) + }, [filteredPrivateSegments, privateSessionMetaMap, timelineTimeMode]) + + const timelineItems = useMemo(() => { + const events: TimelineItem[] = [] + if (timelineMode !== 'private') { + events.push(...mentionTimelineItems) + } + if (timelineMode !== 'mention') { + events.push(...privateTimelineItems) + } + + events.sort((a, b) => { + if (a.time !== b.time) return a.time - b.time + const rankA = a.kind === 'mention' ? 0 : a.kind === 'private' ? 1 : 2 + const rankB = b.kind === 'mention' ? 0 : b.kind === 'private' ? 1 : 2 + return rankA - rankB + }) + + const presetLabel = resolveRangePresetLabel(preset) + const startNode: TimelineBoundaryItem = { + kind: 'boundary', + edge: 'start', + key: 'boundary:start', + time: currentRange.begin, + label: `区域时间开始(${presetLabel})` + } + + const endNode: TimelineBoundaryItem = { + kind: 'boundary', + edge: 'end', + key: 'boundary:end', + time: currentRange.end, + label: `区域时间结束(${preset === 'today' ? '现在' : presetLabel})` + } + + return [startNode, ...events, endNode] + }, [timelineMode, mentionTimelineItems, privateTimelineItems, currentRange.begin, currentRange.end, preset]) + + const timelineEventCount = useMemo( + () => timelineItems.filter((item) => item.kind !== 'boundary').length, + [timelineItems] + ) + + const handleExport = useCallback(async (format: 'csv' | 'json') => { + try { + setExporting(true) + setExportModalStatus('progress') + setExportModalTitle(`正在准备导出 ${format.toUpperCase()}`) + setExportModalDescription('正在准备文件保存信息...') + setExportModalPath('') + const downloadsPath = await window.electronAPI.app.getDownloadsPath() + const separator = downloadsPath && downloadsPath.includes('\\') ? '\\' : '/' + const rangeName = currentRange.label.replace(/[\\/:*?"<>|\s]+/g, '_') + const suggestedName = `my_footprint_${rangeName}_${Date.now()}.${format}` + const defaultPath = downloadsPath ? `${downloadsPath}${separator}${suggestedName}` : suggestedName + + setExportModalDescription('请在弹窗中选择导出路径...') + const saveResult = await window.electronAPI.dialog.saveFile({ + title: format === 'csv' ? '导出我的足迹 CSV' : '导出我的足迹 JSON', + defaultPath, + filters: format === 'csv' + ? [{ name: 'CSV', extensions: ['csv'] }] + : [{ name: 'JSON', extensions: ['json'] }] + }) + if (saveResult.canceled || !saveResult.filePath) { + setExportModalStatus('idle') + setExportModalTitle('') + setExportModalDescription('') + setExportModalPath('') + return + } + + setExportModalDescription('正在导出数据,请稍候...') + setExportModalPath(saveResult.filePath) + const exportResult = await window.electronAPI.chat.exportMyFootprint( + currentRange.begin, + currentRange.end, + format, + saveResult.filePath + ) + if (!exportResult.success) { + setExportModalStatus('error') + setExportModalTitle('导出失败') + setExportModalDescription(exportResult.error || '未知错误') + setExportModalPath(saveResult.filePath) + return + } + setExportModalStatus('success') + setExportModalTitle('导出完成') + setExportModalDescription(`文件已成功导出为 ${format.toUpperCase()}。`) + setExportModalPath(exportResult.filePath || saveResult.filePath) + } catch (exportError) { + setExportModalStatus('error') + setExportModalTitle('导出失败') + setExportModalDescription(String(exportError)) + } finally { + setExporting(false) + } + }, [currentRange.begin, currentRange.end, currentRange.label]) + + const handleGenerateAiSummary = useCallback(async () => { + setFootprintAiStatus('loading') + setFootprintAiText('') + try { + const privateSegments = (data.private_segments.length > 0 ? data.private_segments : data.private_sessions).slice(0, 12) + const result = await window.electronAPI.insight.generateFootprintInsight({ + rangeLabel: currentRange.label, + summary: data.summary, + privateSegments: privateSegments.map((item: MyFootprintPrivateSegment | MyFootprintPrivateSession) => ({ + session_id: item.session_id, + displayName: item.displayName, + incoming_count: item.incoming_count, + outgoing_count: item.outgoing_count, + message_count: 'message_count' in item ? item.message_count : item.incoming_count + item.outgoing_count, + replied: item.replied + })), + mentionGroups: data.mention_groups.slice(0, 12).map((item) => ({ + session_id: item.session_id, + displayName: item.displayName, + count: item.count + })) + }) + if (!result.success || !result.insight) { + setFootprintAiStatus('error') + setFootprintAiText(result.message || '生成失败') + return + } + setFootprintAiStatus('success') + setFootprintAiText(result.insight) + } catch (generateError) { + setFootprintAiStatus('error') + setFootprintAiText(String(generateError)) + } + }, [currentRange.label, data]) + + return ( +
+
+
+

我的微信足迹

+

范围:{currentRange.label}

+
+ +
+
+ {[ + { value: 'today', label: '今天' }, + { value: 'yesterday', label: '昨天' }, + { value: 'this_week', label: '本周' }, + { value: 'last_week', label: '上周' }, + { value: 'custom', label: '自定义' } + ].map((item) => ( + + ))} +
+ + {preset === 'custom' && ( +
+ +
+ )} + +
+
+ + setSearchKeyword(event.target.value)} + placeholder="搜索联系人/群聊/内容" + /> +
+ + + + +
+
+
+ + {loading ? ( +
+
+ {Array.from({ length: 4 }).map((_, index) => ( +
+ ))} +
+
+ {Array.from({ length: 7 }).map((_, index) => ( +
+ ))} +
+
+ ) : error ? ( +
+

读取我的足迹失败

+

{error}

+ +
+ ) : ( + <> +
+ + + + +
+ + {footprintAiStatus !== 'idle' && ( +
+
+ AI 足迹总结 + {currentRange.label} +
+

{footprintAiText}

+
+ )} + +
+
+
+

联络时间线

+

最上方是时间区间开始,最下方是时间区间终点,中间按时间展示群聊 @我 与私聊分段会话节点。

+
+
+ + + +
+
+ + {timelineEventCount === 0 ? ( +
当前区间暂无联络事件,试试切换日期范围或清空关键词筛选。
+ ) : ( +
+ {timelineItems.map((item, index) => ( +
+
+ {item.kind === 'private' ? ( +
+ {formatTimelineMoment(item.time, timelineTimeMode)} + {item.isRange && ( + + {formatTimelineMoment(item.endTime, timelineTimeMode)} + + )} +
+ ) : ( + formatTimelineMoment(item.time, timelineTimeMode) + )} +
+
+ {item.kind === 'private' ? ( +
+
+ {item.isRange && ( + <> +
+
+ + )} +
+ ) : ( +
+ )} +
+
+ {item.kind === 'boundary' && ( +
{item.label}
+ )} + + {item.kind === 'mention' && ( +
+
+
+
+ {item.groupAvatarUrl ? ( + {item.groupName} + ) : ( + + )} +
+
+
{item.groupName}
+
发送人:{item.senderName}
+
+
+ +
+
{renderMentionContent(item.messageContent)}
+
+ )} + + {item.kind === 'private' && ( +
+
+
+
+ {item.avatarUrl ? ( + {item.displayName} + ) : ( + + )} +
+
+
{item.displayName}
+
{item.subtitle}
+
+
+
+ 共 {item.totalInteractions} 条 + +
+
+
{item.summaryText}
+
+ )} +
+
+ ))} +
+ )} +
+ + )} + {exportModalStatus !== 'idle' && ( +
+
+
+ {exportModalStatus === 'progress' && } + {exportModalStatus === 'success' && } + {exportModalStatus === 'error' && } +
+

{exportModalTitle}

+

{exportModalDescription}

+ {exportModalPath && {exportModalPath}} + {exportModalStatus !== 'progress' && ( +
+ +
+ )} +
+
+ )} +
+ ) +} + +export default MyFootprintPage diff --git a/src/pages/SettingsPage.scss b/src/pages/SettingsPage.scss index 37eb6b1..ac35a22 100644 --- a/src/pages/SettingsPage.scss +++ b/src/pages/SettingsPage.scss @@ -177,6 +177,66 @@ box-shadow: var(--shadow-sm); } } + + .tab-group { + display: flex; + flex-direction: column; + gap: 4px; + } + + .tab-group-trigger { + position: relative; + } + + .tab-group-arrow { + margin-left: auto; + color: var(--text-tertiary); + transition: transform 0.2s ease; + + &.expanded { + transform: rotate(180deg); + } + } + + .tab-sublist { + display: flex; + flex-direction: column; + gap: 4px; + padding-left: 8px; + } + + .tab-sublist-wrap { + display: grid; + grid-template-rows: 0fr; + opacity: 0; + transition: grid-template-rows 0.22s ease, opacity 0.18s ease; + + &.expanded { + grid-template-rows: 1fr; + opacity: 1; + } + + &.collapsed { + pointer-events: none; + } + } + + .tab-sublist { + min-height: 0; + overflow: hidden; + } + + .tab-sub-btn { + padding-left: 24px; + font-size: 13px; + } + + .tab-sub-dot { + width: 5px; + height: 5px; + border-radius: 999px; + background: color-mix(in srgb, var(--text-tertiary) 70%, transparent); + } } .settings-body { @@ -199,6 +259,12 @@ } } +.ai-prompt-textarea { + font-family: inherit !important; + font-size: 14px !important; + line-height: 1.6; +} + .tab-content { background: var(--bg-secondary); border: 1px solid var(--border-color); @@ -2283,6 +2349,24 @@ border-radius: 10px; } +.filter-panel-action { + flex-shrink: 0; + border: 1px solid var(--border-color); + border-radius: 6px; + background: var(--bg-tertiary); + color: var(--text-secondary); + padding: 4px 8px; + font-size: 12px; + cursor: pointer; + transition: all 0.16s ease; + + &:hover { + color: var(--primary); + border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color)); + background: color-mix(in srgb, var(--primary) 8%, var(--bg-tertiary)); + } +} + .filter-panel-list { flex: 1; min-height: 200px; @@ -2346,6 +2430,16 @@ white-space: nowrap; } + .filter-item-type { + flex-shrink: 0; + padding: 2px 6px; + border-radius: 6px; + font-size: 11px; + color: var(--text-secondary); + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + } + .filter-item-action { font-size: 18px; font-weight: 500; @@ -2355,6 +2449,36 @@ } } +.push-filter-type-tabs { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; + margin-bottom: 10px; +} + +.push-filter-type-tab { + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-primary); + color: var(--text-secondary); + padding: 6px 10px; + font-size: 13px; + cursor: pointer; + transition: all 0.16s ease; + + &:hover { + color: var(--text-primary); + border-color: color-mix(in srgb, var(--primary) 38%, var(--border-color)); + } + + &.active { + color: var(--primary); + border-color: color-mix(in srgb, var(--primary) 54%, var(--border-color)); + background: color-mix(in srgb, var(--primary) 8%, var(--bg-primary)); + } +} + .filter-panel-empty { display: flex; align-items: center; diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 2d6c3f2..b62f101 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -6,6 +6,7 @@ import { useThemeStore, themes } from '../stores/themeStore' import { useAnalyticsStore } from '../stores/analyticsStore' import { dialog } from '../services/ipc' import * as configService from '../services/config' +import type { ContactInfo } from '../types/models' import { Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy, RotateCcw, Trash2, Plug, Check, Sun, Moon, Monitor, @@ -16,9 +17,23 @@ import { import { Avatar } from '../components/Avatar' import './SettingsPage.scss' -type SettingsTab = 'appearance' | 'notification' | 'antiRevoke' | 'database' | 'models' | 'cache' | 'api' | 'updates' | 'security' | 'about' | 'analytics' | 'insight' +type SettingsTab = + | 'appearance' + | 'notification' + | 'antiRevoke' + | 'database' + | 'models' + | 'cache' + | 'api' + | 'updates' + | 'security' + | 'about' + | 'analytics' + | 'aiCommon' + | 'insight' + | 'aiFootprint' -const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [ +const tabs: { id: Exclude; label: string; icon: React.ElementType }[] = [ { id: 'appearance', label: '外观', icon: Palette }, { id: 'notification', label: '通知', icon: Bell }, { id: 'antiRevoke', label: '防撤回', icon: RotateCcw }, @@ -27,12 +42,17 @@ const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [ { id: 'cache', label: '缓存', icon: HardDrive }, { id: 'api', label: 'API 服务', icon: Globe }, { id: 'analytics', label: '分析', icon: BarChart2 }, - { id: 'insight', label: 'AI 见解', icon: Sparkles }, { id: 'security', label: '安全', icon: ShieldCheck }, { id: 'updates', label: '版本更新', icon: RefreshCw }, { id: 'about', label: '关于', icon: Info } ] +const aiTabs: Array<{ id: Extract; label: string }> = [ + { id: 'aiCommon', label: 'AI 通用' }, + { id: 'insight', label: 'AI 见解' }, + { id: 'aiFootprint', label: 'AI 足迹' } +] + const isMac = navigator.userAgent.toLowerCase().includes('mac') const isLinux = navigator.userAgent.toLowerCase().includes('linux') const isWindows = !isMac && !isLinux @@ -52,6 +72,25 @@ interface WxidOption { avatarUrl?: string } +type SessionFilterType = configService.MessagePushSessionType +type SessionFilterTypeValue = 'all' | SessionFilterType +type SessionFilterMode = 'all' | 'whitelist' | 'blacklist' + +interface SessionFilterOption { + username: string + displayName: string + avatarUrl?: string + type: SessionFilterType +} + +const sessionFilterTypeOptions: Array<{ value: SessionFilterTypeValue; label: string }> = [ + { value: 'all', label: '全部' }, + { value: 'private', label: '私聊' }, + { value: 'group', label: '群聊' }, + { value: 'official', label: '订阅号/服务号' }, + { value: 'other', label: '其他/非好友' } +] + interface SettingsPageProps { onClose?: () => void } @@ -88,6 +127,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const clearAnalyticsStoreCache = useAnalyticsStore((state) => state.clearCache) const [activeTab, setActiveTab] = useState('appearance') + const [aiGroupExpanded, setAiGroupExpanded] = useState(false) const [decryptKey, setDecryptKey] = useState('') const [imageXorKey, setImageXorKey] = useState('') const [imageAesKey, setImageAesKey] = useState('') @@ -125,7 +165,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { setHttpApiToken(token) await configService.setHttpApiToken(token) - showMessage('已生成��保存新的 Access Token', true) + showMessage('已生成并保存新的 Access Token', true) } const clearApiToken = async () => { @@ -150,6 +190,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const [quoteLayout, setQuoteLayout] = useState('quote-top') const [updateChannel, setUpdateChannel] = useState('stable') const [filterSearchKeyword, setFilterSearchKeyword] = useState('') + const [notificationTypeFilter, setNotificationTypeFilter] = useState('all') const [filterModeDropdownOpen, setFilterModeDropdownOpen] = useState(false) const [positionDropdownOpen, setPositionDropdownOpen] = useState(false) const [closeBehaviorDropdownOpen, setCloseBehaviorDropdownOpen] = useState(false) @@ -205,6 +246,12 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const [isTogglingApi, setIsTogglingApi] = useState(false) const [showApiWarning, setShowApiWarning] = useState(false) const [messagePushEnabled, setMessagePushEnabled] = useState(false) + const [messagePushFilterMode, setMessagePushFilterMode] = useState('all') + const [messagePushFilterList, setMessagePushFilterList] = useState([]) + const [messagePushFilterDropdownOpen, setMessagePushFilterDropdownOpen] = useState(false) + const [messagePushFilterSearchKeyword, setMessagePushFilterSearchKeyword] = useState('') + const [messagePushTypeFilter, setMessagePushTypeFilter] = useState('all') + const [messagePushContactOptions, setMessagePushContactOptions] = useState([]) const [antiRevokeSearchKeyword, setAntiRevokeSearchKeyword] = useState('') const [antiRevokeSelectedIds, setAntiRevokeSelectedIds] = useState>(new Set()) const [antiRevokeStatusMap, setAntiRevokeStatusMap] = useState>({}) @@ -217,9 +264,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { // AI 见解 state const [aiInsightEnabled, setAiInsightEnabled] = useState(false) - const [aiInsightApiBaseUrl, setAiInsightApiBaseUrl] = useState('') - const [aiInsightApiKey, setAiInsightApiKey] = useState('') - const [aiInsightApiModel, setAiInsightApiModel] = useState('gpt-4o-mini') + const [aiModelApiBaseUrl, setAiModelApiBaseUrl] = useState('') + const [aiModelApiKey, setAiModelApiKey] = useState('') + const [aiModelApiModel, setAiModelApiModel] = useState('gpt-4o-mini') const [aiInsightSilenceDays, setAiInsightSilenceDays] = useState(3) const [aiInsightAllowContext, setAiInsightAllowContext] = useState(false) const [isTestingInsight, setIsTestingInsight] = useState(false) @@ -237,6 +284,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const [aiInsightTelegramEnabled, setAiInsightTelegramEnabled] = useState(false) const [aiInsightTelegramToken, setAiInsightTelegramToken] = useState('') const [aiInsightTelegramChatIds, setAiInsightTelegramChatIds] = useState('') + const [aiFootprintEnabled, setAiFootprintEnabled] = useState(false) + const [aiFootprintSystemPrompt, setAiFootprintSystemPrompt] = useState('') + const [aiInsightDebugLogEnabled, setAiInsightDebugLogEnabled] = useState(false) // 检查 Hello 可用性 useEffect(() => { @@ -276,6 +326,12 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { setActiveTab(initialTab) }, [location.state]) + useEffect(() => { + if (activeTab === 'aiCommon' || activeTab === 'insight' || activeTab === 'aiFootprint') { + setAiGroupExpanded(true) + } + }, [activeTab]) + useEffect(() => { if (!onClose) return const handleKeyDown = (event: KeyboardEvent) => { @@ -328,15 +384,16 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { setFilterModeDropdownOpen(false) setPositionDropdownOpen(false) setCloseBehaviorDropdownOpen(false) + setMessagePushFilterDropdownOpen(false) } } - if (filterModeDropdownOpen || positionDropdownOpen || closeBehaviorDropdownOpen) { + if (filterModeDropdownOpen || positionDropdownOpen || closeBehaviorDropdownOpen || messagePushFilterDropdownOpen) { document.addEventListener('click', handleClickOutside) } return () => { document.removeEventListener('click', handleClickOutside) } - }, [closeBehaviorDropdownOpen, filterModeDropdownOpen, positionDropdownOpen]) + }, [closeBehaviorDropdownOpen, filterModeDropdownOpen, messagePushFilterDropdownOpen, positionDropdownOpen]) const loadConfig = async () => { @@ -359,6 +416,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const savedNotificationFilterMode = await configService.getNotificationFilterMode() const savedNotificationFilterList = await configService.getNotificationFilterList() const savedMessagePushEnabled = await configService.getMessagePushEnabled() + const savedMessagePushFilterMode = await configService.getMessagePushFilterMode() + const savedMessagePushFilterList = await configService.getMessagePushFilterList() + const contactsResult = await window.electronAPI.chat.getContacts({ lite: true }) const savedLaunchAtStartupStatus = await window.electronAPI.app.getLaunchAtStartupStatus() const savedWindowCloseBehavior = await configService.getWindowCloseBehavior() const savedQuoteLayout = await configService.getQuoteLayout() @@ -409,6 +469,11 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { setNotificationFilterMode(savedNotificationFilterMode) setNotificationFilterList(savedNotificationFilterList) setMessagePushEnabled(savedMessagePushEnabled) + setMessagePushFilterMode(savedMessagePushFilterMode) + setMessagePushFilterList(savedMessagePushFilterList) + if (contactsResult.success && Array.isArray(contactsResult.contacts)) { + setMessagePushContactOptions(contactsResult.contacts as ContactInfo[]) + } setLaunchAtStartup(savedLaunchAtStartupStatus.enabled) setLaunchAtStartupSupported(savedLaunchAtStartupStatus.supported) setLaunchAtStartupReason(savedLaunchAtStartupStatus.reason || '') @@ -448,35 +513,42 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { // 加载 AI 见解配置 const savedAiInsightEnabled = await configService.getAiInsightEnabled() - const savedAiInsightApiBaseUrl = await configService.getAiInsightApiBaseUrl() - const savedAiInsightApiKey = await configService.getAiInsightApiKey() - const savedAiInsightApiModel = await configService.getAiInsightApiModel() + const savedAiModelApiBaseUrl = await configService.getAiModelApiBaseUrl() + const savedAiModelApiKey = await configService.getAiModelApiKey() + const savedAiModelApiModel = await configService.getAiModelApiModel() const savedAiInsightSilenceDays = await configService.getAiInsightSilenceDays() - const savedAiInsightAllowContext = await configService.getAiInsightAllowContext() - const savedAiInsightWhitelistEnabled = await configService.getAiInsightWhitelistEnabled() - const savedAiInsightWhitelist = await configService.getAiInsightWhitelist() - const savedAiInsightCooldownMinutes = await configService.getAiInsightCooldownMinutes() - const savedAiInsightScanIntervalHours = await configService.getAiInsightScanIntervalHours() - const savedAiInsightContextCount = await configService.getAiInsightContextCount() - const savedAiInsightSystemPrompt = await configService.getAiInsightSystemPrompt() - const savedAiInsightTelegramEnabled = await configService.getAiInsightTelegramEnabled() - const savedAiInsightTelegramToken = await configService.getAiInsightTelegramToken() - const savedAiInsightTelegramChatIds = await configService.getAiInsightTelegramChatIds() - setAiInsightEnabled(savedAiInsightEnabled) - setAiInsightApiBaseUrl(savedAiInsightApiBaseUrl) - setAiInsightApiKey(savedAiInsightApiKey) - setAiInsightApiModel(savedAiInsightApiModel) - setAiInsightSilenceDays(savedAiInsightSilenceDays) - setAiInsightAllowContext(savedAiInsightAllowContext) - setAiInsightWhitelistEnabled(savedAiInsightWhitelistEnabled) - setAiInsightWhitelist(new Set(savedAiInsightWhitelist)) - setAiInsightCooldownMinutes(savedAiInsightCooldownMinutes) - setAiInsightScanIntervalHours(savedAiInsightScanIntervalHours) - setAiInsightContextCount(savedAiInsightContextCount) - setAiInsightSystemPrompt(savedAiInsightSystemPrompt) - setAiInsightTelegramEnabled(savedAiInsightTelegramEnabled) - setAiInsightTelegramToken(savedAiInsightTelegramToken) - setAiInsightTelegramChatIds(savedAiInsightTelegramChatIds) + const savedAiInsightAllowContext = await configService.getAiInsightAllowContext() + const savedAiInsightWhitelistEnabled = await configService.getAiInsightWhitelistEnabled() + const savedAiInsightWhitelist = await configService.getAiInsightWhitelist() + const savedAiInsightCooldownMinutes = await configService.getAiInsightCooldownMinutes() + const savedAiInsightScanIntervalHours = await configService.getAiInsightScanIntervalHours() + const savedAiInsightContextCount = await configService.getAiInsightContextCount() + const savedAiInsightSystemPrompt = await configService.getAiInsightSystemPrompt() + const savedAiInsightTelegramEnabled = await configService.getAiInsightTelegramEnabled() + const savedAiInsightTelegramToken = await configService.getAiInsightTelegramToken() + const savedAiInsightTelegramChatIds = await configService.getAiInsightTelegramChatIds() + const savedAiFootprintEnabled = await configService.getAiFootprintEnabled() + const savedAiFootprintSystemPrompt = await configService.getAiFootprintSystemPrompt() + const savedAiInsightDebugLogEnabled = await configService.getAiInsightDebugLogEnabled() + + setAiInsightEnabled(savedAiInsightEnabled) + setAiModelApiBaseUrl(savedAiModelApiBaseUrl) + setAiModelApiKey(savedAiModelApiKey) + setAiModelApiModel(savedAiModelApiModel) + setAiInsightSilenceDays(savedAiInsightSilenceDays) + setAiInsightAllowContext(savedAiInsightAllowContext) + setAiInsightWhitelistEnabled(savedAiInsightWhitelistEnabled) + setAiInsightWhitelist(new Set(savedAiInsightWhitelist)) + setAiInsightCooldownMinutes(savedAiInsightCooldownMinutes) + setAiInsightScanIntervalHours(savedAiInsightScanIntervalHours) + setAiInsightContextCount(savedAiInsightContextCount) + setAiInsightSystemPrompt(savedAiInsightSystemPrompt) + setAiInsightTelegramEnabled(savedAiInsightTelegramEnabled) + setAiInsightTelegramToken(savedAiInsightTelegramToken) + setAiInsightTelegramChatIds(savedAiInsightTelegramChatIds) + setAiFootprintEnabled(savedAiFootprintEnabled) + setAiFootprintSystemPrompt(savedAiFootprintSystemPrompt) + setAiInsightDebugLogEnabled(savedAiInsightDebugLogEnabled) } catch (e: any) { console.error('加载配置失败:', e) @@ -618,7 +690,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { showMessage(`已切换到${channelLabel}更新渠道,正在检查更新`, true) await handleCheckUpdate() } catch (e: any) { - showMessage(`切换更新渠道��败: ${e}`, false) + showMessage(`切换更新渠道失败: ${e}`, false) } } @@ -1154,7 +1226,13 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const keysOverride = buildKeysFromInputs({ decryptKey: result.key }) await handleScanWxid(true, { preferCurrentKeys: true, showDialog: false, keysOverride }) } else { - if (result.error?.includes('未找到微信安装路径') || result.error?.includes('启动微信失败')) { + if ( + result.error?.includes('未找到微信安装路径') || + result.error?.includes('启动微信失败') || + result.error?.includes('未能自动启动微信') || + result.error?.includes('未找到微信进程') || + result.error?.includes('微信进程未运行') + ) { setIsManualStartPrompt(true) setDbKeyStatus('需要手动启动微信') } else { @@ -1213,7 +1291,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { if (result.success && result.aesKey) { if (typeof result.xorKey === 'number') setImageXorKey(`0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`) setImageAesKey(result.aesKey) - setImageKeyStatus('已获取图片��钥') + setImageKeyStatus('已获取图片密钥') showMessage('已自动获取图片密钥', true) const newXorKey = typeof result.xorKey === 'number' ? result.xorKey : 0 const newAesKey = result.aesKey @@ -1457,13 +1535,11 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { { value: 'quote-top' as const, label: '引用在上', - description: '更接近当前 WeFlow 风格', successMessage: '已切换为引用在上样式' }, { value: 'quote-bottom' as const, label: '正文在上', - description: '更接近微信 / 密语风格', successMessage: '已切换为正文在上样式' } ].map(option => { @@ -1513,7 +1589,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
{option.label} - {option.description}
@@ -1613,15 +1688,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { ) const renderNotificationTab = () => { - // 获取已过滤会话的信息 - const getSessionInfo = (username: string) => { - const session = chatSessions.find(s => s.username === username) - return { - displayName: session?.displayName || username, - avatarUrl: session?.avatarUrl || '' - } - } - // 添加会话到过滤列表 const handleAddToFilterList = async (username: string) => { if (notificationFilterList.includes(username)) return @@ -1639,18 +1705,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { showMessage('已从过滤列表移除', true) } - // 过滤掉已在列表中的会话,并根据搜索关键字过滤 - const availableSessions = chatSessions.filter(s => { - if (notificationFilterList.includes(s.username)) return false - if (filterSearchKeyword) { - const keyword = filterSearchKeyword.toLowerCase() - const displayName = (s.displayName || '').toLowerCase() - const username = s.username.toLowerCase() - return displayName.includes(keyword) || username.includes(keyword) - } - return true - }) - return (
@@ -1742,17 +1796,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
{ - const val = option.value as 'all' | 'whitelist' | 'blacklist' - setNotificationFilterMode(val) - setFilterModeDropdownOpen(false) - await configService.setNotificationFilterMode(val) - showMessage( - val === 'all' ? '已设为接收所有通知' : - val === 'whitelist' ? '已设为仅接收白名单通知' : '已设为屏蔽黑名单通知', - true - ) - }} + onClick={() => { void handleSetNotificationFilterMode(option.value as SessionFilterMode) }} > {option.label} {notificationFilterMode === option.value && } @@ -1771,11 +1815,33 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { : '点击左侧会话添加到黑名单,点击右侧会话从黑名单移除'} +
+ {sessionFilterTypeOptions.map(option => ( + + ))} +
+
{/* 可选会话列表 */}
可选会话 + {notificationAvailableSessions.length > 0 && ( + + )}
- {availableSessions.length > 0 ? ( - availableSessions.map(session => ( + {notificationAvailableSessions.length > 0 ? ( + notificationAvailableSessions.map(session => (
{session.displayName || session.username} + {getSessionFilterTypeLabel(session.type)} +
)) ) : (
- {filterSearchKeyword ? '没有匹配的会话' : '暂无可添加的会话'} + {filterSearchKeyword || notificationTypeFilter !== 'all' ? '没有匹配的会话' : '暂无可添加的会话'}
)}
@@ -1818,11 +1885,20 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { {notificationFilterList.length > 0 && ( {notificationFilterList.length} )} + {notificationFilterList.length > 0 && ( + + )}
{notificationFilterList.length > 0 ? ( notificationFilterList.map(username => { - const info = getSessionInfo(username) + const info = getSessionFilterOptionInfo(username) return (
{info.displayName} + {getSessionFilterTypeLabel(info.type)} ×
) @@ -2079,9 +2156,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
{isManualStartPrompt ? (
-

未能自动启动微信,请手动启动并登录后点击下方确认

+

未能自动启动微信,请手动启动微信,看到登录窗口后点击下方确认

) : ( @@ -2488,11 +2565,168 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { showMessage(enabled ? '已开启主动推送' : '已关闭主动推送', true) } + const getSessionFilterType = (session: { username: string; type?: ContactInfo['type'] | number }): SessionFilterType => { + const username = String(session.username || '').trim() + if (username.endsWith('@chatroom')) return 'group' + if (username.startsWith('gh_') || session.type === 'official') return 'official' + if (username.toLowerCase().includes('placeholder_foldgroup')) return 'other' + if (session.type === 'former_friend' || session.type === 'other') return 'other' + return 'private' + } + + const getSessionFilterTypeLabel = (type: SessionFilterType) => { + switch (type) { + case 'private': return '私聊' + case 'group': return '群聊' + case 'official': return '订阅号/服务号' + default: return '其他/非好友' + } + } + + const handleSetMessagePushFilterMode = async (mode: configService.MessagePushFilterMode) => { + setMessagePushFilterMode(mode) + setMessagePushFilterDropdownOpen(false) + await configService.setMessagePushFilterMode(mode) + showMessage( + mode === 'all' ? '主动推送已设为接收所有会话' : + mode === 'whitelist' ? '主动推送已设为仅推送白名单' : '主动推送已设为屏蔽黑名单', + true + ) + } + + const handleAddMessagePushFilterSession = async (username: string) => { + if (messagePushFilterList.includes(username)) return + const next = [...messagePushFilterList, username] + setMessagePushFilterList(next) + await configService.setMessagePushFilterList(next) + showMessage('已添加到主动推送过滤列表', true) + } + + const handleRemoveMessagePushFilterSession = async (username: string) => { + const next = messagePushFilterList.filter(item => item !== username) + setMessagePushFilterList(next) + await configService.setMessagePushFilterList(next) + showMessage('已从主动推送过滤列表移除', true) + } + + const handleAddAllMessagePushFilterSessions = async () => { + const usernames = messagePushAvailableSessions.map(session => session.username) + if (usernames.length === 0) return + const next = Array.from(new Set([...messagePushFilterList, ...usernames])) + setMessagePushFilterList(next) + await configService.setMessagePushFilterList(next) + showMessage(`已添加 ${usernames.length} 个会话`, true) + } + + const handleRemoveAllMessagePushFilterSessions = async () => { + if (messagePushFilterList.length === 0) return + setMessagePushFilterList([]) + await configService.setMessagePushFilterList([]) + showMessage('已清空主动推送过滤列表', true) + } + + const sessionFilterOptionMap = new Map() + + for (const session of chatSessions) { + if (session.username.toLowerCase().includes('placeholder_foldgroup')) continue + sessionFilterOptionMap.set(session.username, { + username: session.username, + displayName: session.displayName || session.username, + avatarUrl: session.avatarUrl, + type: getSessionFilterType(session) + }) + } + + for (const contact of messagePushContactOptions) { + if (!contact.username) continue + if (contact.type !== 'friend' && contact.type !== 'group' && contact.type !== 'official' && contact.type !== 'former_friend') continue + const existing = sessionFilterOptionMap.get(contact.username) + sessionFilterOptionMap.set(contact.username, { + username: contact.username, + displayName: existing?.displayName || contact.displayName || contact.remark || contact.nickname || contact.username, + avatarUrl: existing?.avatarUrl || contact.avatarUrl, + type: getSessionFilterType(contact) + }) + } + + const sessionFilterOptions = Array.from(sessionFilterOptionMap.values()) + .sort((a, b) => { + const aSession = chatSessions.find(session => session.username === a.username) + const bSession = chatSessions.find(session => session.username === b.username) + return Number(bSession?.sortTimestamp || bSession?.lastTimestamp || 0) - + Number(aSession?.sortTimestamp || aSession?.lastTimestamp || 0) + }) + + const getSessionFilterOptionInfo = (username: string) => { + return sessionFilterOptionMap.get(username) || { + username, + displayName: username, + avatarUrl: undefined, + type: 'other' as SessionFilterType + } + } + + const getAvailableSessionFilterOptions = ( + selectedList: string[], + typeFilter: SessionFilterTypeValue, + searchKeyword: string + ) => { + const keyword = searchKeyword.trim().toLowerCase() + return sessionFilterOptions.filter(session => { + if (selectedList.includes(session.username)) return false + if (typeFilter !== 'all' && session.type !== typeFilter) return false + if (keyword) { + return String(session.displayName || '').toLowerCase().includes(keyword) || + session.username.toLowerCase().includes(keyword) + } + return true + }) + } + + const notificationAvailableSessions = getAvailableSessionFilterOptions( + notificationFilterList, + notificationTypeFilter, + filterSearchKeyword + ) + + const messagePushAvailableSessions = getAvailableSessionFilterOptions( + messagePushFilterList, + messagePushTypeFilter, + messagePushFilterSearchKeyword + ) + + const handleAddAllNotificationFilterSessions = async () => { + const usernames = notificationAvailableSessions.map(session => session.username) + if (usernames.length === 0) return + const next = Array.from(new Set([...notificationFilterList, ...usernames])) + setNotificationFilterList(next) + await configService.setNotificationFilterList(next) + showMessage(`已添加 ${usernames.length} 个会话`, true) + } + + const handleRemoveAllNotificationFilterSessions = async () => { + if (notificationFilterList.length === 0) return + setNotificationFilterList([]) + await configService.setNotificationFilterList([]) + showMessage('已清空通知过滤列表', true) + } + + const handleSetNotificationFilterMode = async (mode: SessionFilterMode) => { + setNotificationFilterMode(mode) + setFilterModeDropdownOpen(false) + await configService.setNotificationFilterMode(mode) + showMessage( + mode === 'all' ? '已设为接收所有通知' : + mode === 'whitelist' ? '已设为仅接收白名单通知' : '已设为屏蔽黑名单通知', + true + ) + } + const handleTestInsightConnection = async () => { setIsTestingInsight(true) setInsightTestResult(null) try { - const result = await (window.electronAPI as any).insight.testConnection() + const result = await window.electronAPI.insight.testConnection() setInsightTestResult(result) } catch (e: any) { setInsightTestResult({ success: false, message: `调用失败:${e?.message || String(e)}` }) @@ -2501,6 +2735,118 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { } } + const renderAiCommonTab = () => ( +
+
+ + + 这是「AI 见解」与「AI 足迹总结」共享的模型接入配置。填写 OpenAI 兼容接口的 Base URL,末尾不要加斜杠。 + 程序会自动拼接 /chat/completions。 +
+ 示例:https://api.ohmygpt.com/v1https://api.openai.com/v1 +
+ { + const val = e.target.value + setAiModelApiBaseUrl(val) + scheduleConfigSave('aiModelApiBaseUrl', () => configService.setAiModelApiBaseUrl(val)) + }} + /> +
+ +
+ + + 你的 API Key,保存后经过系统加密存储,不会明文写入磁盘。 + +
+ { + const val = e.target.value + setAiModelApiKey(val) + scheduleConfigSave('aiModelApiKey', () => configService.setAiModelApiKey(val)) + }} + style={{ flex: 1 }} + /> + + {aiModelApiKey && ( + + )} +
+
+ +
+ + + 填写你的 API 提供商支持的模型名,将同时用于见解和足迹模块。 +
+ 常用示例:gpt-4o-minigpt-4odeepseek-chatclaude-3-5-haiku-20241022 +
+ { + const val = e.target.value.trim() || 'gpt-4o-mini' + setAiModelApiModel(val) + scheduleConfigSave('aiModelApiModel', () => configService.setAiModelApiModel(val)) + }} + style={{ width: 260 }} + /> +
+ +
+ + + 测试通用模型连接,见解与足迹都会使用这套配置。 + +
+ + {insightTestResult && ( + + {insightTestResult.success ? : } + {insightTestResult.message} + + )} +
+
+
+ ) + const renderInsightTab = () => (
{/* 总开关 */} @@ -2529,149 +2875,41 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
- {/* API 配置 */} -
- - - 填写 OpenAI 兼容接口的 Base URL,末尾不要加斜杠。 - 程序会自动拼接 /chat/completions。 -
- 示例:https://api.ohmygpt.com/v1https://api.openai.com/v1 -
- { - const val = e.target.value - setAiInsightApiBaseUrl(val) - scheduleConfigSave('aiInsightApiBaseUrl', () => configService.setAiInsightApiBaseUrl(val)) - }} - style={{ fontFamily: 'monospace' }} - /> -
- -
- - - 你的 API Key,保存后经过系统加密存储,不会明文写入磁盘。 - -
- { - const val = e.target.value - setAiInsightApiKey(val) - scheduleConfigSave('aiInsightApiKey', () => configService.setAiInsightApiKey(val)) - }} - style={{ flex: 1, fontFamily: 'monospace' }} - /> - - {aiInsightApiKey && ( - - )} -
-
- -
- - - 填写你的 API 提供商支持的模型名,建议使用综合能力较强的模型以获得有洞察力的见解。 -
- 常用示例:gpt-4o-minigpt-4odeepseek-chatclaude-3-5-haiku-20241022 -
- { - const val = e.target.value.trim() || 'gpt-4o-mini' - setAiInsightApiModel(val) - scheduleConfigSave('aiInsightApiModel', () => configService.setAiInsightApiModel(val)) - }} - style={{ width: 260, fontFamily: 'monospace' }} - /> -
- - {/* 测试连接 + 触发测试 */}
- 先用"测试 API 连接"确认 Key 和 URL 填写正确,再用"立即触发测试见解"验证完整链路(数据库→API→弹窗)。触发后请留意右下角通知弹窗。 + 该功能依赖「AI 通用」里的模型配置。用于验证完整链路(数据库→API→弹窗)。 -
- {/* 测试 API 连接 */} -
- - {insightTestResult && ( - - {insightTestResult.success ? : } - {insightTestResult.message} - +
+
- {/* 触发测试见解 */} -
- - {insightTriggerResult && ( - - {insightTriggerResult.success ? : } - {insightTriggerResult.message} - - )} -
+ + {insightTriggerResult && ( + + {insightTriggerResult.success ? : } + {insightTriggerResult.message} + + )}
@@ -2827,9 +3065,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { 当前显示内置默认提示词,可直接编辑修改。修改后立即生效,无需重启。可变的统计信息(触发次数、对话内容)会自动附加在用户消息里,无需在此填写。