diff --git a/electron/main/ipcMain.ts b/electron/main/ipcMain.ts index c7a67e8..6903e96 100644 --- a/electron/main/ipcMain.ts +++ b/electron/main/ipcMain.ts @@ -8,13 +8,33 @@ import * as databaseCore from './database/core' import * as worker from './worker' // 导入解析器模块 import * as parser from './parser' -import { detectFormat, type ParseProgress } from './parser' +import { detectFormat, type ParseProgress, type ParseResult } from './parser' // 导入合并模块 import * as merger from './merger' import type { MergeParams } from '../../src/types/chat' console.log('[IpcMain] Database, Worker and Parser modules imported') +// ==================== 解析结果缓存 ==================== +// 用于合并功能:缓存文件的完整解析结果,避免重复解析 +// 这样用户删除本地文件后仍然可以进行合并 +const parseResultCache = new Map() + +/** + * 清理指定文件的缓存 + */ +function clearParseCache(filePath: string): void { + parseResultCache.delete(filePath) +} + +/** + * 清理所有缓存 + */ +function clearAllParseCache(): void { + parseResultCache.clear() + console.log('[IpcMain] 已清理所有解析缓存') +} + const mainIpcMain = (win: BrowserWindow) => { console.log('[IpcMain] Registering IPC handlers...') @@ -597,18 +617,35 @@ const mainIpcMain = (win: BrowserWindow) => { /** * 解析文件获取基本信息(用于合并预览) - * 使用流式解析,支持大文件 + * 使用流式解析获取进度,同时缓存完整解析结果 */ ipcMain.handle('merge:parseFileInfo', async (_, filePath: string) => { try { - // 使用流式解析,避免大文件 OOM - return await worker.streamParseFileInfo(filePath, (progress: ParseProgress) => { + // 使用流式解析,避免大文件 OOM,同时获取完整解析结果 + const result = await worker.streamParseFileInfo(filePath, (progress: ParseProgress) => { // 可选:发送进度到渲染进程 win.webContents.send('merge:parseProgress', { filePath, progress, }) }) + + // 缓存完整解析结果(用于后续合并) + // 这样即使用户删除本地文件,也能继续合并 + if (result.parseResult) { + parseResultCache.set(filePath, result.parseResult) + console.log(`[IpcMain] 已缓存解析结果: ${filePath}, 消息数: ${result.parseResult.messages.length}`) + } + + // 返回基本信息(不包含完整解析结果,减少 IPC 传输) + return { + name: result.name, + format: result.format, + platform: result.platform, + messageCount: result.messageCount, + memberCount: result.memberCount, + fileSize: result.fileSize, + } } catch (error) { console.error('解析文件信息失败:', error) throw error @@ -616,11 +653,11 @@ const mainIpcMain = (win: BrowserWindow) => { }) /** - * 检测合并冲突 + * 检测合并冲突(使用缓存的解析结果) */ ipcMain.handle('merge:checkConflicts', async (_, filePaths: string[]) => { try { - return merger.checkConflicts(filePaths) + return merger.checkConflictsWithCache(filePaths, parseResultCache) } catch (error) { console.error('检测冲突失败:', error) throw error @@ -628,17 +665,36 @@ const mainIpcMain = (win: BrowserWindow) => { }) /** - * 执行合并 + * 执行合并(使用缓存的解析结果) */ ipcMain.handle('merge:mergeFiles', async (_, params: MergeParams) => { try { - return merger.mergeFiles(params) + const result = await merger.mergeFilesWithCache(params, parseResultCache) + // 合并完成后清理缓存 + if (result.success) { + for (const filePath of params.filePaths) { + clearParseCache(filePath) + } + } + return result } catch (error) { console.error('合并失败:', error) return { success: false, error: String(error) } } }) + /** + * 清理合并缓存(用于用户移除文件时) + */ + ipcMain.handle('merge:clearCache', async (_, filePath?: string) => { + if (filePath) { + clearParseCache(filePath) + } else { + clearAllParseCache() + } + return true + }) + /** * 显示打开对话框(通用) */ diff --git a/electron/main/merger/index.ts b/electron/main/merger/index.ts index d7499dc..b6faf57 100644 --- a/electron/main/merger/index.ts +++ b/electron/main/merger/index.ts @@ -84,9 +84,67 @@ function getMessageKey(msg: ParsedMessage): string { } /** - * 检测合并冲突 + * 检测合并冲突(使用缓存的解析结果) * 规则:时间戳 + 用户名 + 字符长度,当两项相同但另一项不同时报告冲突 */ +export async function checkConflictsWithCache( + filePaths: string[], + cache: Map +): Promise { + const allMessages: Array<{ msg: ParsedMessage; source: string }> = [] + const conflicts: MergeConflict[] = [] + + console.log('[Merger] checkConflictsWithCache: 开始检测冲突') + console.log( + '[Merger] 文件列表:', + filePaths.map((p) => path.basename(p)) + ) + console.log( + '[Merger] 缓存状态:', + filePaths.map((p) => `${path.basename(p)}: ${cache.has(p) ? '已缓存' : '未缓存'}`) + ) + + // 解析所有文件(优先使用缓存) + const parseResults: Array<{ result: ParseResult; source: string }> = [] + for (const filePath of filePaths) { + let result: ParseResult + if (cache.has(filePath)) { + result = cache.get(filePath)! + console.log(`[Merger] 使用缓存: ${path.basename(filePath)}`) + } else { + // 回退到文件解析(兼容性) + console.log(`[Merger] 缓存未命中,重新解析: ${path.basename(filePath)}`) + result = await parseFileSync(filePath) + } + parseResults.push({ result, source: path.basename(filePath) }) + } + + // 检查格式一致性 + const formats = parseResults.map((p) => p.result.meta.platform) + const uniqueFormats = [...new Set(formats)] + if (uniqueFormats.length > 1) { + throw new Error( + `不支持合并不同格式的聊天记录。\n检测到的格式:${uniqueFormats.join('、')}\n请确保所有文件使用相同的导出工具和格式。` + ) + } + console.log('[Merger] 格式检查通过:', uniqueFormats[0]) + + // 收集所有消息 + for (const { result, source } of parseResults) { + console.log(`[Merger] 处理 ${source}: ${result.messages.length} 条消息`) + for (const msg of result.messages) { + allMessages.push({ msg, source }) + } + } + console.log(`[Merger] 总消息数: ${allMessages.length}`) + + return detectConflictsInMessages(allMessages, conflicts) +} + +/** + * 检测合并冲突(原版,直接读取文件) + * @deprecated 使用 checkConflictsWithCache 替代 + */ export async function checkConflicts(filePaths: string[]): Promise { const allMessages: Array<{ msg: ParsedMessage; source: string }> = [] const conflicts: MergeConflict[] = [] @@ -128,6 +186,16 @@ export async function checkConflicts(filePaths: string[]): Promise, + conflicts: MergeConflict[] +): ConflictCheckResult { // 按时间戳分组检测冲突 const timeGroups = new Map>() for (const item of allMessages) { @@ -240,7 +308,45 @@ export async function checkConflicts(filePaths: string[]): Promise): Promise { + try { + const { filePaths, outputName, outputDir, conflictResolutions, andAnalyze } = params + + console.log('[Merger] mergeFilesWithCache: 开始合并') + console.log( + '[Merger] 缓存状态:', + filePaths.map((p) => `${path.basename(p)}: ${cache.has(p) ? '已缓存' : '未缓存'}`) + ) + + // 解析所有文件(优先使用缓存) + const parseResults: Array<{ result: ParseResult; source: string }> = [] + for (const filePath of filePaths) { + let result: ParseResult + if (cache.has(filePath)) { + result = cache.get(filePath)! + console.log(`[Merger] 使用缓存: ${path.basename(filePath)}`) + } else { + // 回退到文件解析(兼容性) + console.log(`[Merger] 缓存未命中,重新解析: ${path.basename(filePath)}`) + result = await parseFileSync(filePath) + } + parseResults.push({ result, source: path.basename(filePath) }) + } + + return executeMerge(parseResults, outputName, outputDir, conflictResolutions, andAnalyze) + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : '合并失败', + } + } +} + +/** + * 合并多个聊天记录文件(原版,直接读取文件) + * @deprecated 使用 mergeFilesWithCache 替代 */ export async function mergeFiles(params: MergeParams): Promise { try { @@ -253,6 +359,26 @@ export async function mergeFiles(params: MergeParams): Promise { parseResults.push({ result, source: path.basename(filePath) }) } + return executeMerge(parseResults, outputName, outputDir, conflictResolutions, andAnalyze) + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : '合并失败', + } + } +} + +/** + * 内部函数:执行合并逻辑 + */ +function executeMerge( + parseResults: Array<{ result: ParseResult; source: string }>, + outputName: string, + outputDir: string | undefined, + conflictResolutions: ConflictResolution[], + andAnalyze: boolean +): MergeResult { + try { // 合并成员 const memberMap = new Map() for (const { result } of parseResults) { diff --git a/electron/main/parser/formats/qq-native-txt.ts b/electron/main/parser/formats/qq-native-txt.ts index 69be114..9d0d76b 100644 --- a/electron/main/parser/formats/qq-native-txt.ts +++ b/electron/main/parser/formats/qq-native-txt.ts @@ -275,4 +275,3 @@ const module_: FormatModule = { } export default module_ - diff --git a/electron/main/worker/streamImport.ts b/electron/main/worker/streamImport.ts index 3e14136..1f21601 100644 --- a/electron/main/worker/streamImport.ts +++ b/electron/main/worker/streamImport.ts @@ -307,21 +307,28 @@ export async function streamImport(filePath: string, requestId: string): Promise } } -/** - * 流式解析文件获取基本信息(只统计,不写入数据库) - * 用于合并功能的预览 - */ -export async function streamParseFileInfo( - filePath: string, - requestId: string -): Promise<{ +/** 流式解析文件信息的返回结果 */ +export interface StreamParseFileInfoResult { + // 基本信息(用于预览) name: string format: string platform: string messageCount: number memberCount: number fileSize: number -}> { + // 完整解析结果(用于后续合并,避免重复解析) + parseResult: { + meta: ParsedMeta + members: ParsedMember[] + messages: ParsedMessage[] + } +} + +/** + * 流式解析文件获取基本信息和完整解析结果 + * 用于合并功能的预览,同时缓存完整结果供后续合并使用 + */ +export async function streamParseFileInfo(filePath: string, requestId: string): Promise { const formatFeature = detectFormat(filePath) if (!formatFeature) { throw new Error('无法识别文件格式') @@ -340,9 +347,9 @@ export async function streamParseFileInfo( message: '正在读取文件...', }) - let name = '未知群聊' - let platform = formatFeature.platform - let messageCount = 0 + let meta: ParsedMeta = { name: '未知群聊', platform: formatFeature.platform, type: 'group' } + const members: ParsedMember[] = [] + const messages: ParsedMessage[] = [] const memberSet = new Set() await streamParseFile(filePath, { @@ -353,31 +360,38 @@ export async function streamParseFileInfo( sendProgress(requestId, progress) }, - onMeta: (meta) => { - name = meta.name - platform = meta.platform + onMeta: (parsedMeta) => { + meta = parsedMeta }, - onMembers: (members) => { - for (const m of members) { - memberSet.add(m.platformId) + onMembers: (parsedMembers) => { + for (const m of parsedMembers) { + if (!memberSet.has(m.platformId)) { + memberSet.add(m.platformId) + members.push(m) + } } }, - onMessageBatch: (messages) => { - messageCount += messages.length - for (const msg of messages) { + onMessageBatch: (batch) => { + messages.push(...batch) + for (const msg of batch) { memberSet.add(msg.senderPlatformId) } }, }) return { - name, + name: meta.name, format: formatFeature.name, - platform, - messageCount, + platform: meta.platform, + messageCount: messages.length, memberCount: memberSet.size, fileSize, + parseResult: { + meta, + members, + messages, + }, } } diff --git a/electron/main/worker/workerManager.ts b/electron/main/worker/workerManager.ts index 0ca64b4..633a681 100644 --- a/electron/main/worker/workerManager.ts +++ b/electron/main/worker/workerManager.ts @@ -314,7 +314,7 @@ export async function parseFileInfo(filePath: string): Promise { } /** - * 流式解析文件获取基本信息(用于合并预览) + * 流式解析文件获取基本信息和完整解析结果(用于合并预览) */ export async function streamParseFileInfo( filePath: string, @@ -325,6 +325,18 @@ export async function streamParseFileInfo( platform: string messageCount: number memberCount: number + fileSize: number + parseResult: { + meta: { name: string; platform: string; type: string } + members: Array<{ platformId: string; name: string; nickname?: string }> + messages: Array<{ + senderPlatformId: string + senderName: string + timestamp: number + type: number + content?: string + }> + } }> { return sendToWorkerWithProgress('streamParseFileInfo', { filePath }, onProgress) } diff --git a/electron/preload/index.d.ts b/electron/preload/index.d.ts index ce48e2c..9d298da 100644 --- a/electron/preload/index.d.ts +++ b/electron/preload/index.d.ts @@ -77,6 +77,7 @@ interface MergeApi { parseFileInfo: (filePath: string) => Promise checkConflicts: (filePaths: string[]) => Promise mergeFiles: (params: MergeParams) => Promise + clearCache: (filePath?: string) => Promise onParseProgress: (callback: (data: { filePath: string; progress: ImportProgress }) => void) => () => void } diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 83d4867..0bca0d0 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -290,6 +290,7 @@ const chatApi = { const mergeApi = { /** * 解析文件获取基本信息(用于合并预览) + * 解析后结果会被缓存,后续合并时无需再次读取原始文件 */ parseFileInfo: (filePath: string): Promise => { return ipcRenderer.invoke('merge:parseFileInfo', filePath) @@ -309,6 +310,14 @@ const mergeApi = { return ipcRenderer.invoke('merge:mergeFiles', params) }, + /** + * 清理解析缓存 + * @param filePath 可选,指定文件路径则清理该文件的缓存,否则清理所有缓存 + */ + clearCache: (filePath?: string): Promise => { + return ipcRenderer.invoke('merge:clearCache', filePath) + }, + /** * 监听解析进度(用于大文件) */ diff --git a/src/components/tools/MergeTab.vue b/src/components/tools/MergeTab.vue index 0a5aa70..6f5f2ca 100644 --- a/src/components/tools/MergeTab.vue +++ b/src/components/tools/MergeTab.vue @@ -159,6 +159,11 @@ async function handleClickSelect() { // 移除文件 function removeFile(id: string) { + const file = files.value.find((f) => f.id === id) + if (file) { + // 清理该文件的解析缓存 + window.mergeApi.clearCache(file.path) + } files.value = files.value.filter((f) => f.id !== id) } @@ -265,6 +270,10 @@ async function openOutputFolder() { // 重置状态 function reset() { + // 清理所有文件的解析缓存 + for (const file of files.value) { + window.mergeApi.clearCache(file.path) + } files.value = [] conflicts.value = [] outputName.value = ''