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/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/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'