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 是一个**完全本地**的微信**实时**聊天记录查看、分析与导出工具。它可以实时获取你的微信聊天记录并将其导出,还可以根据你的聊天记录为你生成独一无二的分析报告。
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
----
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
> [!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
-
```
+## 构建状态
+
+用于开发者排查发布链路,普通用户可忽略:
+
+
+
+
+
+
+
+
+
+
+
+
+
## 致谢
- [密语 CipherTalk](https://github.com/ILoveBingLu/miyu) 为本项目提供了基础框架
@@ -120,18 +130,16 @@ npm run dev
如果 WeFlow 确实帮到了你,可以考虑请我们喝杯咖啡:
-
-> TRC20 **Address:** `TZCtAw8CaeARWZBfvjidCnTcfnAtf6nvS6`
-
+> TRC20 **Address:** `TZCtAw8CaeARWZBfvjidCnTcfnAtf6nvS6`
## Star History
-
-
-
-
-
+
+
+
+
+
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'