diff --git a/electron/main.ts b/electron/main.ts index a04dc4c..9747a28 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -13,7 +13,7 @@ import { imagePreloadService } from './services/imagePreloadService' import { analyticsService } from './services/analyticsService' import { groupAnalyticsService } from './services/groupAnalyticsService' import { annualReportService } from './services/annualReportService' -import { exportService, ExportOptions } from './services/exportService' +import { exportService, ExportOptions, ExportProgress } from './services/exportService' import { KeyService } from './services/keyService' import { voiceTranscribeService } from './services/voiceTranscribeService' import { videoService } from './services/videoService' @@ -646,8 +646,13 @@ function registerIpcHandlers() { }) // 导出相关 - ipcMain.handle('export:exportSessions', async (_, sessionIds: string[], outputDir: string, options: ExportOptions) => { - return exportService.exportSessions(sessionIds, outputDir, options) + ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions) => { + const onProgress = (progress: ExportProgress) => { + if (!event.sender.isDestroyed()) { + event.sender.send('export:progress', progress) + } + } + return exportService.exportSessions(sessionIds, outputDir, options, onProgress) }) ipcMain.handle('export:exportSession', async (_, sessionId: string, outputPath: string, options: ExportOptions) => { diff --git a/electron/preload.ts b/electron/preload.ts index f04e6f4..e70b70b 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -191,7 +191,11 @@ contextBridge.exposeInMainWorld('electronAPI', { exportSessions: (sessionIds: string[], outputDir: string, options: any) => ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options), exportSession: (sessionId: string, outputPath: string, options: any) => - ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options) + ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options), + onProgress: (callback: (payload: { current: number; total: number; currentSession: string; phase: string }) => void) => { + ipcRenderer.on('export:progress', (_, payload) => callback(payload)) + return () => ipcRenderer.removeAllListeners('export:progress') + } }, whisper: { diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 0a3f1cb..eb2aa2e 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -72,6 +72,7 @@ export interface ExportOptions { exportEmojis?: boolean exportVoiceAsText?: boolean excelCompactColumns?: boolean + sessionLayout?: 'shared' | 'per-session' } interface MediaExportItem { @@ -408,14 +409,15 @@ class ExportService { private async exportMediaForMessage( msg: any, sessionId: string, - mediaDir: string, + mediaRootDir: string, + mediaRelativePrefix: string, options: { exportImages?: boolean; exportVoices?: boolean; exportEmojis?: boolean; exportVoiceAsText?: boolean } ): Promise { const localType = msg.localType // 图片消息 if (localType === 3 && options.exportImages) { - const result = await this.exportImage(msg, sessionId, mediaDir) + const result = await this.exportImage(msg, sessionId, mediaRootDir, mediaRelativePrefix) if (result) { } return result @@ -429,13 +431,13 @@ class ExportService { } // 否则导出语音文件 if (options.exportVoices) { - return this.exportVoice(msg, sessionId, mediaDir) + return this.exportVoice(msg, sessionId, mediaRootDir, mediaRelativePrefix) } } // 动画表情 if (localType === 47 && options.exportEmojis) { - const result = await this.exportEmoji(msg, sessionId, mediaDir) + const result = await this.exportEmoji(msg, sessionId, mediaRootDir, mediaRelativePrefix) if (result) { } return result @@ -447,9 +449,14 @@ class ExportService { /** * 导出图片文件 */ - private async exportImage(msg: any, sessionId: string, mediaDir: string): Promise { + private async exportImage( + msg: any, + sessionId: string, + mediaRootDir: string, + mediaRelativePrefix: string + ): Promise { try { - const imagesDir = path.join(mediaDir, 'media', 'images') + const imagesDir = path.join(mediaRootDir, mediaRelativePrefix, 'images') if (!fs.existsSync(imagesDir)) { fs.mkdirSync(imagesDir, { recursive: true }) } @@ -494,7 +501,7 @@ class ExportService { fs.writeFileSync(destPath, Buffer.from(base64Data, 'base64')) return { - relativePath: `media/images/${fileName}`, + relativePath: path.posix.join(mediaRelativePrefix, 'images', fileName), kind: 'image' } } else if (sourcePath.startsWith('file://')) { @@ -512,7 +519,7 @@ class ExportService { } return { - relativePath: `media/images/${fileName}`, + relativePath: path.posix.join(mediaRelativePrefix, 'images', fileName), kind: 'image' } } @@ -526,9 +533,14 @@ class ExportService { /** * 导出语音文件 */ - private async exportVoice(msg: any, sessionId: string, mediaDir: string): Promise { + private async exportVoice( + msg: any, + sessionId: string, + mediaRootDir: string, + mediaRelativePrefix: string + ): Promise { try { - const voicesDir = path.join(mediaDir, 'media', 'voices') + const voicesDir = path.join(mediaRootDir, mediaRelativePrefix, 'voices') if (!fs.existsSync(voicesDir)) { fs.mkdirSync(voicesDir, { recursive: true }) } @@ -540,7 +552,7 @@ class ExportService { // 如果已存在则跳过 if (fs.existsSync(destPath)) { return { - relativePath: `media/voices/${fileName}`, + relativePath: path.posix.join(mediaRelativePrefix, 'voices', fileName), kind: 'voice' } } @@ -556,7 +568,7 @@ class ExportService { fs.writeFileSync(destPath, wavBuffer) return { - relativePath: `media/voices/${fileName}`, + relativePath: path.posix.join(mediaRelativePrefix, 'voices', fileName), kind: 'voice' } } catch (e) { @@ -582,9 +594,14 @@ class ExportService { /** * 导出表情文件 */ - private async exportEmoji(msg: any, sessionId: string, mediaDir: string): Promise { + private async exportEmoji( + msg: any, + sessionId: string, + mediaRootDir: string, + mediaRelativePrefix: string + ): Promise { try { - const emojisDir = path.join(mediaDir, 'media', 'emojis') + const emojisDir = path.join(mediaRootDir, mediaRelativePrefix, 'emojis') if (!fs.existsSync(emojisDir)) { fs.mkdirSync(emojisDir, { recursive: true }) } @@ -613,7 +630,7 @@ class ExportService { // 如果已存在则跳过 if (fs.existsSync(destPath)) { return { - relativePath: `media/emojis/${fileName}`, + relativePath: path.posix.join(mediaRelativePrefix, 'emojis', fileName), kind: 'emoji' } } @@ -621,13 +638,13 @@ class ExportService { // 下载表情 if (emojiUrl) { const downloaded = await this.downloadFile(emojiUrl, destPath) - if (downloaded) { - return { - relativePath: `media/emojis/${fileName}`, - kind: 'emoji' - } - } else { - } + if (downloaded) { + return { + relativePath: path.posix.join(mediaRelativePrefix, 'emojis', fileName), + kind: 'emoji' + } + } else { + } } return null @@ -704,6 +721,22 @@ class ExportService { return '.jpg' } + private getMediaLayout(outputPath: string, options: ExportOptions): { + exportMediaEnabled: boolean + mediaRootDir: string + mediaRelativePrefix: string + } { + const exportMediaEnabled = options.exportMedia === true && + Boolean(options.exportImages || options.exportVoices || options.exportEmojis) + const outputDir = path.dirname(outputPath) + const outputBaseName = path.basename(outputPath, path.extname(outputPath)) + const useSharedMediaLayout = options.sessionLayout === 'shared' + const mediaRelativePrefix = useSharedMediaLayout + ? path.posix.join('media', outputBaseName) + : 'media' + return { exportMediaEnabled, mediaRootDir: outputDir, mediaRelativePrefix } + } + /** * 下载文件 */ @@ -1128,29 +1161,43 @@ class ExportService { phase: 'exporting' }) - const chatLabMessages: ChatLabMessage[] = [] - for (const msg of allMessages) { - const memberInfo = collected.memberSet.get(msg.senderUsername)?.member || { - platformId: msg.senderUsername, - accountName: msg.senderUsername, - groupNickname: undefined - } + const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options) + const mediaCache = new Map() + const chatLabMessages: ChatLabMessage[] = [] + for (const msg of allMessages) { + const memberInfo = collected.memberSet.get(msg.senderUsername)?.member || { + platformId: msg.senderUsername, + accountName: msg.senderUsername, + groupNickname: undefined + } - let content = this.parseMessageContent(msg.content, msg.localType) - // 如果是语音消息且开启了转文字 - if (msg.localType === 34 && options.exportVoiceAsText) { - content = await this.transcribeVoice(sessionId, String(msg.localId)) - } + let content = this.parseMessageContent(msg.content, msg.localType) + if (exportMediaEnabled) { + const mediaKey = `${msg.localType}_${msg.localId}` + if (!mediaCache.has(mediaKey)) { + const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { + exportImages: options.exportImages, + exportVoices: options.exportVoices, + exportEmojis: options.exportEmojis, + exportVoiceAsText: options.exportVoiceAsText + }) + mediaCache.set(mediaKey, mediaItem) + } + } + if (msg.localType === 34 && options.exportVoiceAsText) { + // 如果是语音消息且开启了转文字 + content = await this.transcribeVoice(sessionId, String(msg.localId)) + } - chatLabMessages.push({ - sender: msg.senderUsername, - accountName: memberInfo.accountName, - groupNickname: memberInfo.groupNickname, - timestamp: msg.createTime, - type: this.convertMessageType(msg.localType, msg.content), - content: content - }) - } + chatLabMessages.push({ + sender: msg.senderUsername, + accountName: memberInfo.accountName, + groupNickname: memberInfo.groupNickname, + timestamp: msg.createTime, + type: this.convertMessageType(msg.localType, msg.content), + content: content + }) + } const avatarMap = options.exportAvatars ? await this.exportAvatars( @@ -1243,22 +1290,41 @@ class ExportService { phase: 'preparing' }) - const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) - const allMessages: any[] = [] + const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) + const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options) + const mediaCache = new Map() + const allMessages: any[] = [] - for (const msg of collected.rows) { - const senderInfo = await this.getContactInfo(msg.senderUsername) - const sourceMatch = /[\s\S]*?<\/msgsource>/i.exec(msg.content || '') - const source = sourceMatch ? sourceMatch[0] : '' + for (const msg of collected.rows) { + const senderInfo = await this.getContactInfo(msg.senderUsername) + const sourceMatch = /[\s\S]*?<\/msgsource>/i.exec(msg.content || '') + const source = sourceMatch ? sourceMatch[0] : '' - let content = this.parseMessageContent(msg.content, msg.localType) - if (msg.localType === 34 && options.exportVoiceAsText) { - content = await this.transcribeVoice(sessionId, String(msg.localId)) - } + let content = this.parseMessageContent(msg.content, msg.localType) + let mediaItem: MediaExportItem | null = null + if (exportMediaEnabled) { + const mediaKey = `${msg.localType}_${msg.localId}` + if (mediaCache.has(mediaKey)) { + mediaItem = mediaCache.get(mediaKey) || null + } else { + mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { + exportImages: options.exportImages, + exportVoices: options.exportVoices, + exportEmojis: options.exportEmojis, + exportVoiceAsText: options.exportVoiceAsText + }) + mediaCache.set(mediaKey, mediaItem) + } + } + if (mediaItem) { + content = mediaItem.relativePath + } else if (msg.localType === 34 && options.exportVoiceAsText) { + content = await this.transcribeVoice(sessionId, String(msg.localId)) + } - allMessages.push({ - localId: allMessages.length + 1, - createTime: msg.createTime, + allMessages.push({ + localId: allMessages.length + 1, + createTime: msg.createTime, formattedTime: this.formatTimestamp(msg.createTime), type: this.getMessageTypeName(msg.localType), localType: msg.localType, @@ -1482,8 +1548,7 @@ class ExportService { const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime) // 媒体导出设置 - const exportMediaEnabled = options.exportImages || options.exportVoices || options.exportEmojis - const sessionDir = path.dirname(outputPath) // 会话目录,用于媒体导出 + const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options) // 媒体导出缓存 const mediaCache = new Map() @@ -1498,7 +1563,7 @@ class ExportService { if (mediaCache.has(mediaKey)) { mediaItem = mediaCache.get(mediaKey) || null } else { - mediaItem = await this.exportMediaForMessage(msg, sessionId, sessionDir, { + mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, { exportImages: options.exportImages, exportVoices: options.exportVoices, exportEmojis: options.exportEmojis, @@ -1656,9 +1721,15 @@ class ExportService { fs.mkdirSync(outputDir, { recursive: true }) } - for (let i = 0; i < sessionIds.length; i++) { - const sessionId = sessionIds[i] - const sessionInfo = await this.getContactInfo(sessionId) + const exportMediaEnabled = options.exportMedia === true && + Boolean(options.exportImages || options.exportVoices || options.exportEmojis) + const sessionLayout = exportMediaEnabled + ? (options.sessionLayout ?? 'per-session') + : 'shared' + + for (let i = 0; i < sessionIds.length; i++) { + const sessionId = sessionIds[i] + const sessionInfo = await this.getContactInfo(sessionId) onProgress?.({ current: i + 1, @@ -1667,13 +1738,13 @@ class ExportService { phase: 'exporting' }) - const safeName = sessionInfo.displayName.replace(/[<>:"/\\|?*]/g, '_') + const safeName = sessionInfo.displayName.replace(/[<>:"/\\|?*]/g, '_') + const useSessionFolder = sessionLayout === 'per-session' + const sessionDir = useSessionFolder ? path.join(outputDir, safeName) : outputDir - // 为每个会话创建单独的文件夹 - const sessionDir = path.join(outputDir, safeName) - if (!fs.existsSync(sessionDir)) { - fs.mkdirSync(sessionDir, { recursive: true }) - } + if (useSessionFolder && !fs.existsSync(sessionDir)) { + fs.mkdirSync(sessionDir, { recursive: true }) + } let ext = '.json' if (options.format === 'chatlab-jsonl') ext = '.jsonl' diff --git a/electron/services/keyService.ts b/electron/services/keyService.ts index 8b51c8d..08f730a 100644 --- a/electron/services/keyService.ts +++ b/electron/services/keyService.ts @@ -33,6 +33,7 @@ export class KeyService { private ReadProcessMemory: any = null private MEMORY_BASIC_INFORMATION: any = null private TerminateProcess: any = null + private QueryFullProcessImageNameW: any = null // User32 private EnumWindows: any = null @@ -194,6 +195,7 @@ export class KeyService { this.OpenProcess = this.kernel32.func('OpenProcess', 'HANDLE', ['uint32', 'bool', 'uint32']) this.CloseHandle = this.kernel32.func('CloseHandle', 'bool', ['HANDLE']) this.TerminateProcess = this.kernel32.func('TerminateProcess', 'bool', ['HANDLE', 'uint32']) + this.QueryFullProcessImageNameW = this.kernel32.func('QueryFullProcessImageNameW', 'bool', ['HANDLE', 'uint32', this.koffi.out('uint16*'), this.koffi.out('uint32*')]) this.VirtualQueryEx = this.kernel32.func('VirtualQueryEx', 'uint64', ['HANDLE', 'uint64', this.koffi.out(this.koffi.pointer(this.MEMORY_BASIC_INFORMATION)), 'uint64']) this.ReadProcessMemory = this.kernel32.func('ReadProcessMemory', 'bool', ['HANDLE', 'uint64', 'void*', 'uint64', this.koffi.out(this.koffi.pointer('uint64'))]) @@ -310,7 +312,46 @@ export class KeyService { } } + private async getProcessExecutablePath(pid: number): Promise { + if (!this.ensureKernel32()) return null + // 0x1000 = PROCESS_QUERY_LIMITED_INFORMATION + const hProcess = this.OpenProcess(0x1000, false, pid) + if (!hProcess) return null + + try { + const sizeBuf = Buffer.alloc(4) + sizeBuf.writeUInt32LE(1024, 0) + const pathBuf = Buffer.alloc(1024 * 2) + + const ret = this.QueryFullProcessImageNameW(hProcess, 0, pathBuf, sizeBuf) + if (ret) { + const len = sizeBuf.readUInt32LE(0) + return pathBuf.toString('ucs2', 0, len * 2) + } + return null + } catch (e) { + console.error('获取进程路径失败:', e) + return null + } finally { + this.CloseHandle(hProcess) + } + } + private async findWeChatInstallPath(): Promise { + // 0. 优先尝试获取正在运行的微信进程路径 + try { + const pid = await this.findWeChatPid() + if (pid) { + const runPath = await this.getProcessExecutablePath(pid) + if (runPath && existsSync(runPath)) { + console.log('发现正在运行的微信进程,使用路径:', runPath) + return runPath + } + } + } catch (e) { + console.error('尝试获取运行中微信路径失败:', e) + } + // 1. Registry - Uninstall Keys const uninstallKeys = [ 'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall', diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts index 6144726..e3c9baf 100644 --- a/electron/services/wcdbCore.ts +++ b/electron/services/wcdbCore.ts @@ -1,6 +1,12 @@ import { join, dirname, basename } from 'path' import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs' +// DLL 初始化错误信息,用于帮助用户诊断问题 +let lastDllInitError: string | null = null +export function getLastDllInitError(): string | null { + return lastDllInitError +} + export class WcdbCore { private resourcesPath: string | null = null private userDataPath: string | null = null @@ -208,6 +214,31 @@ export class WcdbCore { return false } + // 关键修复:显式预加载依赖库 WCDB.dll 和 SDL2.dll + // Windows 加载器默认不会查找子目录中的依赖,必须先将其加载到内存 + // 这可以解决部分用户因为 VC++ 运行时或 DLL 依赖问题导致的闪退 + const dllDir = dirname(dllPath) + const wcdbCorePath = join(dllDir, 'WCDB.dll') + if (existsSync(wcdbCorePath)) { + try { + this.koffi.load(wcdbCorePath) + this.writeLog('预加载 WCDB.dll 成功') + } catch (e) { + console.warn('预加载 WCDB.dll 失败(可能不是致命的):', e) + this.writeLog(`预加载 WCDB.dll 失败: ${String(e)}`) + } + } + const sdl2Path = join(dllDir, 'SDL2.dll') + if (existsSync(sdl2Path)) { + try { + this.koffi.load(sdl2Path) + this.writeLog('预加载 SDL2.dll 成功') + } catch (e) { + console.warn('预加载 SDL2.dll 失败(可能不是致命的):', e) + this.writeLog(`预加载 SDL2.dll 失败: ${String(e)}`) + } + } + this.lib = this.koffi.load(dllPath) // 定义类型 @@ -362,9 +393,20 @@ export class WcdbCore { } this.initialized = true + lastDllInitError = null return true } catch (e) { - console.error('WCDB 初始化异常:', e) + const errorMsg = e instanceof Error ? e.message : String(e) + console.error('WCDB 初始化异常:', errorMsg) + this.writeLog(`WCDB 初始化异常: ${errorMsg}`, true) + lastDllInitError = errorMsg + // 检查是否是常见的 VC++ 运行时缺失错误 + if (errorMsg.includes('126') || errorMsg.includes('找不到指定的模块') || + errorMsg.includes('The specified module could not be found')) { + lastDllInitError = '可能缺少 Visual C++ 运行时库。请安装 Microsoft Visual C++ Redistributable (x64)。' + } else if (errorMsg.includes('193') || errorMsg.includes('不是有效的 Win32 应用程序')) { + lastDllInitError = 'DLL 架构不匹配。请确保使用 64 位版本的应用程序。' + } return false } } @@ -391,7 +433,9 @@ export class WcdbCore { if (!this.initialized) { const initOk = await this.initialize() if (!initOk) { - return { success: false, error: 'WCDB 初始化失败' } + // 返回更详细的错误信息,帮助用户诊断问题 + const detailedError = lastDllInitError || 'WCDB 初始化失败' + return { success: false, error: detailedError } } } diff --git a/electron/services/wcdbService.ts b/electron/services/wcdbService.ts index 7628c67..bdcd2bc 100644 --- a/electron/services/wcdbService.ts +++ b/electron/services/wcdbService.ts @@ -58,12 +58,24 @@ export class WcdbService { }) this.worker.on('error', (err) => { - // Worker error + // Worker 发生错误,需要 reject 所有 pending promises + console.error('WCDB Worker 错误:', err) + const errorMsg = err instanceof Error ? err.message : String(err) + for (const [id, p] of this.pending) { + p.reject(new Error(`Worker 错误: ${errorMsg}`)) + } + this.pending.clear() }) this.worker.on('exit', (code) => { + // Worker 退出,需要 reject 所有 pending promises if (code !== 0) { - // Worker exited with error + console.error('WCDB Worker 异常退出,退出码:', code) + const errorMsg = `Worker 异常退出 (退出码: ${code})。可能是 DLL 加载失败,请检查是否安装了 Visual C++ Redistributable。` + for (const [id, p] of this.pending) { + p.reject(new Error(errorMsg)) + } + this.pending.clear() } this.worker = null }) diff --git a/package-lock.json b/package-lock.json index a38e504..ba9957a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8537,12 +8537,6 @@ "sherpa-onnx-win-x64": "^1.12.23" } }, - "node_modules/sherpa-onnx-node/node_modules/sherpa-onnx-darwin-x64": { - "optional": true - }, - "node_modules/sherpa-onnx-node/node_modules/sherpa-onnx-linux-arm64": { - "optional": true - }, "node_modules/sherpa-onnx-win-ia32": { "version": "1.12.23", "resolved": "https://registry.npmmirror.com/sherpa-onnx-win-ia32/-/sherpa-onnx-win-ia32-1.12.23.tgz", diff --git a/src/App.tsx b/src/App.tsx index 77aa32f..98fe58b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -202,10 +202,22 @@ function App() { } } else { console.log('自动连接失败:', result.error) + // 如果错误信息包含 VC++ 或 DLL 相关内容,不清除配置,只提示用户 + // 其他错误可能需要重新配置 + const errorMsg = result.error || '' + if (errorMsg.includes('Visual C++') || + errorMsg.includes('DLL') || + errorMsg.includes('Worker') || + errorMsg.includes('126') || + errorMsg.includes('模块')) { + console.warn('检测到可能的运行时依赖问题:', errorMsg) + // 不清除配置,让用户安装 VC++ 后重试 + } } } } catch (e) { console.error('自动连接出错:', e) + // 捕获异常但不清除配置,防止循环重新引导 } } diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 4aa5b35..ddedbd9 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -602,6 +602,87 @@ } } + .export-layout-modal { + background: var(--card-bg); + padding: 28px 32px; + border-radius: 16px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25); + text-align: center; + width: min(520px, 90vw); + + h3 { + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + margin: 0 0 8px; + } + + .layout-subtitle { + font-size: 14px; + color: var(--text-secondary); + margin: 0 0 20px; + } + + .layout-options { + display: grid; + gap: 12px; + } + + .layout-option-btn { + display: flex; + flex-direction: column; + gap: 6px; + padding: 14px 18px; + border-radius: 12px; + border: 1px solid var(--border-color); + background: var(--bg-secondary); + text-align: left; + cursor: pointer; + transition: all 0.2s; + + &:hover { + border-color: var(--primary); + background: rgba(var(--primary-rgb), 0.08); + } + + &.primary { + border-color: var(--primary); + background: rgba(var(--primary-rgb), 0.12); + } + + .layout-title { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); + } + + .layout-desc { + font-size: 12px; + color: var(--text-tertiary); + } + } + + .layout-actions { + margin-top: 18px; + display: flex; + justify-content: center; + } + + .layout-cancel-btn { + padding: 8px 20px; + border-radius: 8px; + border: 1px solid var(--border-color); + background: var(--bg-secondary); + color: var(--text-primary); + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: var(--bg-hover); + } + } + } + .export-result-modal { background: var(--card-bg); padding: 32px 40px; @@ -1056,4 +1137,4 @@ input:checked + .slider::before { transform: translateX(20px); } -} \ No newline at end of file +} diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 19d8605..929c3a3 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -31,6 +31,8 @@ interface ExportResult { error?: string } +type SessionLayout = 'shared' | 'per-session' + function ExportPage() { const [sessions, setSessions] = useState([]) const [filteredSessions, setFilteredSessions] = useState([]) @@ -44,6 +46,7 @@ function ExportPage() { const [showDatePicker, setShowDatePicker] = useState(false) const [calendarDate, setCalendarDate] = useState(new Date()) const [selectingStart, setSelectingStart] = useState(true) + const [showMediaLayoutPrompt, setShowMediaLayoutPrompt] = useState(false) const [options, setOptions] = useState({ format: 'excel', @@ -154,6 +157,19 @@ function ExportPage() { loadExportDefaults() }, [loadSessions, loadExportPath, loadExportDefaults]) + useEffect(() => { + const removeListener = window.electronAPI.export.onProgress?.((payload) => { + setExportProgress({ + current: payload.current, + total: payload.total, + currentName: payload.currentSession + }) + }) + return () => { + removeListener?.() + } + }, []) + useEffect(() => { if (!searchKeyword.trim()) { setFilteredSessions(sessions) @@ -199,7 +215,7 @@ function ExportPage() { } } - const startExport = async () => { + const runExport = async (sessionLayout: SessionLayout) => { if (selectedSessions.size === 0 || !exportFolder) return setIsExporting(true) @@ -215,11 +231,12 @@ function ExportPage() { exportImages: options.exportMedia && options.exportImages, exportVoices: options.exportMedia && options.exportVoices, exportEmojis: options.exportMedia && options.exportEmojis, - exportVoiceAsText: options.exportVoiceAsText, // 独立于 exportMedia + exportVoiceAsText: options.exportVoiceAsText, // ?????????exportMedia excelCompactColumns: options.excelCompactColumns, + sessionLayout, dateRange: options.useAllTime ? null : options.dateRange ? { start: Math.floor(options.dateRange.start.getTime() / 1000), - // 将结束日期设置为当天的 23:59:59,以包含当天的所有消息 + // ?????????????????????????????????23:59:59,?????????????????????????????? end: Math.floor(new Date(options.dateRange.end.getFullYear(), options.dateRange.end.getMonth(), options.dateRange.end.getDate(), 23, 59, 59).getTime() / 1000) } : null } @@ -232,16 +249,28 @@ function ExportPage() { ) setExportResult(result) } else { - setExportResult({ success: false, error: `${options.format.toUpperCase()} 格式导出功能开发中...` }) + setExportResult({ success: false, error: `${options.format.toUpperCase()} ???????????????????????????...` }) } } catch (e) { - console.error('导出失败:', e) + console.error('????????????:', e) setExportResult({ success: false, error: String(e) }) } finally { setIsExporting(false) } } + const startExport = () => { + if (selectedSessions.size === 0 || !exportFolder) return + + if (options.exportMedia && selectedSessions.size > 1) { + setShowMediaLayoutPrompt(true) + return + } + + const layout: SessionLayout = options.exportMedia ? 'per-session' : 'shared' + runExport(layout) + } + const getDaysInMonth = (date: Date) => { const year = date.getFullYear() const month = date.getMonth() @@ -600,6 +629,43 @@ function ExportPage() { + {/* 媒体导出布局选择弹窗 */} + {showMediaLayoutPrompt && ( +
setShowMediaLayoutPrompt(false)}> +
e.stopPropagation()}> +

导出文件夹布局

+

检测到同时导出多个会话并包含媒体文件,请选择存放方式:

+
+ + +
+
+ +
+
+
+ )} + {/* 导出进度弹窗 */} {isExporting && (
diff --git a/src/pages/SettingsPage.scss b/src/pages/SettingsPage.scss index c6bb34b..e5a4bed 100644 --- a/src/pages/SettingsPage.scss +++ b/src/pages/SettingsPage.scss @@ -221,6 +221,100 @@ } } + .select-field { + position: relative; + margin-bottom: 10px; + } + + .select-trigger { + width: 100%; + padding: 10px 16px; + border: 1px solid var(--border-color); + border-radius: 9999px; + font-size: 14px; + background: var(--bg-primary); + color: var(--text-primary); + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + cursor: pointer; + transition: all 0.2s; + + &:hover { + border-color: var(--text-tertiary); + } + + &.open { + border-color: var(--primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent); + } + } + + .select-value { + flex: 1; + min-width: 0; + text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .select-dropdown { + position: absolute; + top: calc(100% + 6px); + left: 0; + right: 0; + background: color-mix(in srgb, var(--bg-primary) 85%, var(--bg-secondary)); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 6px; + box-shadow: var(--shadow-md); + z-index: 20; + max-height: 320px; + overflow-y: auto; + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + } + + .select-option { + width: 100%; + text-align: left; + display: flex; + flex-direction: column; + gap: 4px; + padding: 10px 12px; + border: none; + border-radius: 10px; + background: transparent; + cursor: pointer; + transition: all 0.15s; + color: var(--text-primary); + font-size: 14px; + + &:hover { + background: var(--bg-tertiary); + } + + &.active { + background: color-mix(in srgb, var(--primary) 12%, transparent); + color: var(--primary); + } + } + + .option-label { + font-weight: 500; + } + + .option-desc { + font-size: 12px; + color: var(--text-tertiary); + } + + .select-option.active .option-desc { + color: var(--primary); + } + .input-with-toggle { position: relative; display: flex; @@ -1096,13 +1190,15 @@ left: 0; right: 0; margin-top: 4px; - background: var(--bg-secondary); + background: color-mix(in srgb, var(--bg-primary) 85%, var(--bg-secondary)); border: 1px solid var(--border-primary); border-radius: 8px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); z-index: 100; max-height: 200px; overflow-y: auto; + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); } .wxid-option { @@ -1216,4 +1312,4 @@ border-top: 1px solid var(--border-primary); display: flex; justify-content: flex-end; -} \ No newline at end of file +} diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 6fbe196..999dc5f 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -41,6 +41,12 @@ function SettingsPage() { const [wxidOptions, setWxidOptions] = useState([]) const [showWxidSelect, setShowWxidSelect] = useState(false) const wxidDropdownRef = useRef(null) + const [showExportFormatSelect, setShowExportFormatSelect] = useState(false) + const [showExportDateRangeSelect, setShowExportDateRangeSelect] = useState(false) + const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false) + const exportFormatDropdownRef = useRef(null) + const exportDateRangeDropdownRef = useRef(null) + const exportExcelColumnsDropdownRef = useRef(null) const [cachePath, setCachePath] = useState('') const [logEnabled, setLogEnabled] = useState(false) const [whisperModelName, setWhisperModelName] = useState('base') @@ -85,13 +91,23 @@ function SettingsPage() { // 点击外部关闭下拉框 useEffect(() => { const handleClickOutside = (e: MouseEvent) => { - if (showWxidSelect && wxidDropdownRef.current && !wxidDropdownRef.current.contains(e.target as Node)) { + const target = e.target as Node + if (showWxidSelect && wxidDropdownRef.current && !wxidDropdownRef.current.contains(target)) { setShowWxidSelect(false) } + if (showExportFormatSelect && exportFormatDropdownRef.current && !exportFormatDropdownRef.current.contains(target)) { + setShowExportFormatSelect(false) + } + if (showExportDateRangeSelect && exportDateRangeDropdownRef.current && !exportDateRangeDropdownRef.current.contains(target)) { + setShowExportDateRangeSelect(false) + } + if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) { + setShowExportExcelColumnsSelect(false) + } } document.addEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside) - }, [showWxidSelect]) + }, [showWxidSelect, showExportFormatSelect, showExportDateRangeSelect, showExportExcelColumnsSelect]) useEffect(() => { const removeDb = window.electronAPI.key.onDbKeyStatus((payload) => { @@ -863,48 +879,114 @@ function SettingsPage() {
) - const renderExportTab = () => ( + const exportFormatOptions = [ + { value: 'excel', label: 'Excel', desc: '电子表格,适合统计分析' }, + { value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' }, + { value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' }, + { value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' }, + { value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' }, + { value: 'txt', label: 'TXT', desc: '纯文本,通用格式' }, + { value: 'sql', label: 'PostgreSQL', desc: '数据库脚本,便于导入到数据库' } + ] + const exportDateRangeOptions = [ + { value: 'today', label: '今天' }, + { value: '7d', label: '最近7天' }, + { value: '30d', label: '最近30天' }, + { value: '90d', label: '最近90天' }, + { value: 'all', label: '全部时间' } + ] + const exportExcelColumnOptions = [ + { value: 'compact', label: '精简列', desc: '序号、时间、发送者身份、消息类型、内容' }, + { value: 'full', label: '完整列', desc: '含发送者昵称/微信ID/备注' } + ] + + const getOptionLabel = (options: { value: string; label: string }[], value: string) => { + return options.find((option) => option.value === value)?.label ?? value + } + + const renderExportTab = () => { + const exportExcelColumnsValue = exportDefaultExcelCompactColumns ? 'compact' : 'full' + const exportFormatLabel = getOptionLabel(exportFormatOptions, exportDefaultFormat) + const exportDateRangeLabel = getOptionLabel(exportDateRangeOptions, exportDefaultDateRange) + const exportExcelColumnsLabel = getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue) + + return (
导出页面默认选中的格式 - +
+ + {showExportFormatSelect && ( +
+ {exportFormatOptions.map((option) => ( + + ))} +
+ )} +
控制导出页面的默认时间选择 - +
+ + {showExportDateRangeSelect && ( +
+ {exportDateRangeOptions.map((option) => ( + + ))} +
+ )} +
@@ -956,21 +1038,45 @@ function SettingsPage() {
控制 Excel 导出的列字段 - +
+ + {showExportExcelColumnsSelect && ( +
+ {exportExcelColumnOptions.map((option) => ( + + ))} +
+ )} +
- ) + ) + } const renderCacheTab = () => (

管理应用缓存数据

diff --git a/src/pages/WelcomePage.tsx b/src/pages/WelcomePage.tsx index fb2878c..3f5ac52 100644 --- a/src/pages/WelcomePage.tsx +++ b/src/pages/WelcomePage.tsx @@ -441,7 +441,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { 浏览选择
-
建议选择包含 xwechat_files 的目录
+
请选择微信-设置-存储位置对应的目录
⚠️ 目录路径不可包含中文,如有中文请去微信-设置-存储位置点击更改,迁移至全英文目录
)} @@ -507,7 +507,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { {dbKeyStatus &&
{dbKeyStatus}
}
获取密钥会自动识别最近登录的账号
-
点击自动获取后微信将重新启动,当页面提示可以登录微信了再点击登录
+
点击自动获取后微信将重新启动,当页面提示hook安装成功,现在登录微信后再点击登录
)} @@ -533,7 +533,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { {isFetchingImageKey ? '获取中...' : '自动获取图片密钥'} {imageKeyStatus &&
{imageKeyStatus}
} -
如获取失败,请先打开朋友圈图片再重试
+
请在电脑微信中打开查看几个图片后再点击获取秘钥,如获取失败请重复以上操作
{isFetchingImageKey &&
正在扫描内存,请稍候...
} )} diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index bacefb3..2298766 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -314,6 +314,7 @@ export interface ElectronAPI { success: boolean error?: string }> + onProgress: (callback: (payload: ExportProgress) => void) => () => void } whisper: { downloadModel: () => Promise<{ success: boolean; modelPath?: string; tokensPath?: string; error?: string }> @@ -332,6 +333,14 @@ export interface ExportOptions { exportEmojis?: boolean exportVoiceAsText?: boolean excelCompactColumns?: boolean + sessionLayout?: 'shared' | 'per-session' +} + +export interface ExportProgress { + current: number + total: number + currentSession: string + phase: 'preparing' | 'exporting' | 'writing' | 'complete' } export interface WxidInfo {