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..44cf1bb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,6 +31,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: Sync version with tag shell: bash run: | diff --git a/README.md b/README.md index 01e7beb..0376588 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,32 @@ # WeFlow -WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析与导出工具。它可以实时获取你的微信聊天记录并将其导出,还可以根据你的聊天记录为你生成独一无二的分析报告 - ---- +WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析与导出工具。它可以实时获取你的微信聊天记录并将其导出,还可以根据你的聊天记录为你生成独一无二的分析报告。

- WeFlow + + Telegram Channel + + + Stargazers + + + Forks + + + Issues + + + Downloads + + + Star History Rank +

---- -

- -Stargazers - - -Forks - - -Issues - - -Downloads - - -Telegram - + WeFlow 应用预览

- > [!TIP] > 如果导出聊天记录后,想深入分析聊天内容可以试试 [ChatLab](https://chatlab.fun/) @@ -47,14 +45,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 +89,6 @@ WeFlow 提供本地 HTTP API 服务,支持通过接口查询消息数据,可 完整接口文档:[点击查看](docs/HTTP-API.md) - ## 面向开发者 如果你想从源码构建或为项目贡献代码,请遵循以下步骤: @@ -108,9 +103,24 @@ npm install # 3. 运行应用(开发模式) npm run dev - ``` +## 构建状态 + +用于开发者排查发布链路,普通用户可忽略: + +

+ + Release Workflow + + + Preview Nightly Workflow + + + Dev Daily Workflow + +

+ ## 致谢 - [密语 CipherTalk](https://github.com/ILoveBingLu/miyu) 为本项目提供了基础框架 @@ -120,18 +130,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..48d18de 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -3455,12 +3455,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 +3499,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 +3509,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 +3525,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 +3644,7 @@ app.whenReady().then(async () => { ) // 等待主窗口加载完成(真正耗时阶段,进度条末端呼吸光点) - updateSplashProgress(30, '正在加载界面...', true) + updateSplashProgress(70, '正在准备主窗口...', true) await new Promise((resolve) => { if (mainWindowReady) { resolve() diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 9cf81b6..24da2ca 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -323,6 +323,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 +515,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 } @@ -1362,8 +1401,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 +1568,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) @@ -2886,6 +2971,7 @@ class ChatService { this.sessionTablesCache.clear() this.messageTableColumnsCache.clear() this.messageDbCountSnapshotCache = null + this.contactsMemoryCache.clear() this.refreshSessionStatsCacheScope(scope) this.refreshGroupMyMessageCountCacheScope(scope) } @@ -5983,6 +6069,7 @@ class ChatService { if (includeContacts) { this.avatarCache.clear() this.contactCacheService.clear() + this.contactsMemoryCache.clear() } if (includeMessages) { diff --git a/electron/services/config.ts b/electron/services/config.ts index 87029c9..959d889 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -270,7 +270,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 +651,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 +660,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') diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 2512f72..d13458c 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' } @@ -8911,6 +8986,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 +9235,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 +9254,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 +9287,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/keyServiceMac.ts b/electron/services/keyServiceMac.ts index c350eb1..40cb2f2 100644 --- a/electron/services/keyServiceMac.ts +++ b/electron/services/keyServiceMac.ts @@ -1,6 +1,6 @@ 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' @@ -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', @@ -751,10 +803,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 +819,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 +927,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/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/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 750d496..1f95d36 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -1621,6 +1621,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, @@ -2270,7 +2271,7 @@ function ExportPage() { setIsBaseConfigLoading(true) 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,6 +2288,7 @@ function ExportPage() { configService.getExportWriteLayout(), configService.getExportSessionNamePrefixEnabled(), configService.getExportDefaultDateRange(), + configService.getExportDefaultFileNamingMode(), ensureExportCacheScope() ]) @@ -2318,6 +2320,7 @@ function ExportPage() { setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true) setExportDefaultConcurrency(savedConcurrency ?? 2) setExportDefaultImageDeepSearchOnMiss(savedImageDeepSearchOnMiss ?? true) + setExportDefaultFileNamingMode(savedFileNamingMode ?? 'classic') const resolvedDefaultDateRange = resolveExportDateRangeConfig(savedDefaultDateRange) setExportDefaultDateRangeSelection(resolvedDefaultDateRange) setTimeRangeSelection(resolvedDefaultDateRange) @@ -4397,6 +4400,7 @@ function ExportPage() { displayNamePreference: options.displayNamePreference, exportConcurrency: options.exportConcurrency, imageDeepSearchOnMiss: options.imageDeepSearchOnMiss, + fileNamingMode: exportDefaultFileNamingMode, sessionLayout, sessionNameWithTypePrefix, dateRange: options.useAllTime @@ -7089,6 +7093,9 @@ function ExportPage() { if (patch.dateRange) { setExportDefaultDateRangeSelection(patch.dateRange) } + if (patch.fileNamingMode) { + setExportDefaultFileNamingMode(patch.fileNamingMode) + } if (patch.media) { const mediaPatch = patch.media setExportDefaultMedia(mediaPatch) diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 2d6c3f2..5c13eb1 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -1457,13 +1457,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 +1511,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
{option.label} - {option.description}
diff --git a/src/pages/WelcomePage.tsx b/src/pages/WelcomePage.tsx index 9c4feef..b344d16 100644 --- a/src/pages/WelcomePage.tsx +++ b/src/pages/WelcomePage.tsx @@ -31,6 +31,7 @@ const steps = [ { id: 'image', title: '图片密钥', desc: '获取 XOR 与 AES 密钥' }, { id: 'security', title: '安全防护', desc: '保护你的数据' } ] +type SetupStepId = typeof steps[number]['id'] interface WelcomePageProps { standalone?: boolean @@ -438,6 +439,48 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { } } + const jumpToStep = (stepId: SetupStepId) => { + const targetIndex = steps.findIndex(step => step.id === stepId) + if (targetIndex >= 0) setStepIndex(targetIndex) + } + + const validateDbStepBeforeNext = async (): Promise => { + if (!dbPath) return '数据库目录步骤未完成:请先选择数据库目录' + if (dbPathValidationError) return `数据库目录步骤配置有误:${dbPathValidationError}` + try { + const wxids = await window.electronAPI.dbPath.scanWxids(dbPath) + if (!Array.isArray(wxids) || wxids.length === 0) { + return '数据库目录步骤配置有误:当前目录下未找到可用账号数据(缺少 db_storage),请重新选择微信数据目录' + } + } catch (e) { + return `数据库目录步骤配置有误:目录读取失败,请确认该路径可访问(${String(e)})` + } + return null + } + + const findConfigIssueBeforeConnect = async (): Promise<{ stepId: SetupStepId; message: string } | null> => { + const dbIssue = await validateDbStepBeforeNext() + if (dbIssue) return { stepId: 'db', message: dbIssue } + + let scannedWxids: Array<{ wxid: string }> = [] + try { + scannedWxids = await window.electronAPI.dbPath.scanWxids(dbPath) + } catch { + scannedWxids = [] + } + + if (!wxid) { + return { stepId: 'key', message: '解密密钥步骤未完成:请先选择微信账号 (wxid)' } + } + if (!scannedWxids.some(item => item.wxid === wxid)) { + return { stepId: 'key', message: `解密密钥步骤配置有误:微信账号「${wxid}」不在当前数据库目录中,请重新选择账号` } + } + if (!decryptKey || decryptKey.length !== 64) { + return { stepId: 'key', message: '解密密钥步骤未完成:请填写 64 位解密密钥' } + } + return null + } + const canGoNext = () => { if (currentStep.id === 'intro') return true if (currentStep.id === 'db') return Boolean(dbPath) && !dbPathValidationError @@ -453,7 +496,15 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { return false } - const handleNext = () => { + const handleNext = async () => { + if (currentStep.id === 'db') { + const dbStepIssue = await validateDbStepBeforeNext() + if (dbStepIssue) { + setError(dbStepIssue) + return + } + } + if (!canGoNext()) { if (currentStep.id === 'db' && !dbPath) setError('请先选择数据库目录') else if (currentStep.id === 'db' && dbPathValidationError) setError(dbPathValidationError) @@ -473,9 +524,12 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { } const handleConnect = async () => { - if (!dbPath) { setError('请先选择数据库目录'); return } - if (!wxid) { setError('请填写微信ID'); return } - if (!decryptKey || decryptKey.length !== 64) { setError('请填写 64 位解密密钥'); return } + const configIssue = await findConfigIssueBeforeConnect() + if (configIssue) { + setError(configIssue.message) + jumpToStep(configIssue.stepId) + return + } setIsConnecting(true) setError('') @@ -484,7 +538,19 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { try { const result = await window.electronAPI.wcdb.testConnection(dbPath, decryptKey, wxid) if (!result.success) { - setError(result.error || 'WCDB 连接失败') + const errorMessage = result.error || 'WCDB 连接失败' + if (errorMessage.includes('-3001')) { + const fallbackIssue = await findConfigIssueBeforeConnect() + if (fallbackIssue) { + setError(fallbackIssue.message) + jumpToStep(fallbackIssue.stepId) + } else { + setError(`数据库目录步骤配置有误:${errorMessage}`) + jumpToStep('db') + } + } else { + setError(errorMessage) + } setLoading(false) return } diff --git a/src/services/config.ts b/src/services/config.ts index 16cf4e6..c949613 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -30,6 +30,7 @@ export const CONFIG_KEYS = { EXPORT_DEFAULT_FORMAT: 'exportDefaultFormat', EXPORT_DEFAULT_AVATARS: 'exportDefaultAvatars', EXPORT_DEFAULT_DATE_RANGE: 'exportDefaultDateRange', + EXPORT_DEFAULT_FILE_NAMING_MODE: 'exportDefaultFileNamingMode', EXPORT_DEFAULT_MEDIA: 'exportDefaultMedia', EXPORT_DEFAULT_VOICE_AS_TEXT: 'exportDefaultVoiceAsText', EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns', @@ -114,6 +115,8 @@ export interface ExportDefaultMediaConfig { files: boolean } +export type ExportFileNamingMode = 'classic' | 'date-range' + export type WindowCloseBehavior = 'ask' | 'tray' | 'quit' export type QuoteLayout = 'quote-top' | 'quote-bottom' export type UpdateChannel = 'stable' | 'preview' | 'dev' @@ -434,6 +437,18 @@ export async function setExportDefaultDateRange(range: ExportDefaultDateRangeCon await config.set(CONFIG_KEYS.EXPORT_DEFAULT_DATE_RANGE, range) } +// 获取导出默认文件命名方式 +export async function getExportDefaultFileNamingMode(): Promise { + const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_FILE_NAMING_MODE) + if (value === 'classic' || value === 'date-range') return value + return null +} + +// 设置导出默认文件命名方式 +export async function setExportDefaultFileNamingMode(mode: ExportFileNamingMode): Promise { + await config.set(CONFIG_KEYS.EXPORT_DEFAULT_FILE_NAMING_MODE, mode) +} + // 获取导出默认媒体设置 export async function getExportDefaultMedia(): Promise { const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_MEDIA) diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 83f8fbf..54b4a59 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -1005,6 +1005,7 @@ export interface ExportOptions { exportVoiceAsText?: boolean excelCompactColumns?: boolean txtColumns?: string[] + fileNamingMode?: 'classic' | 'date-range' sessionLayout?: 'shared' | 'per-session' sessionNameWithTypePrefix?: boolean displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'