diff --git a/README.md b/README.md index c97135f..89f34a4 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,11 @@ **一款现代化的微信聊天记录查看与分析工具** [![License](https://img.shields.io/badge/license-CC--BY--NC--SA--4.0-blue.svg)](LICENSE) -[![Version](https://img.shields.io/badge/version-2.2.0-green.svg)](package.json) +[![Version](https://img.shields.io/badge/version-2.2.2-green.svg)](package.json) [![Platform](https://img.shields.io/badge/platform-Windows-0078D6.svg?logo=windows)]() [![Electron](https://img.shields.io/badge/Electron-39-47848F.svg?logo=electron)]() [![React](https://img.shields.io/badge/React-19-61DAFB.svg?logo=react)]() -[![Telegram](https://img.shields.io/badge/Telegram-Join%20Group-26A5E4.svg?logo=telegram)](https://t.me/weflow_cc) +[![Telegram](https://img.shields.io/badge/Telegram-Join%20Group-26A5E4.svg?logo=telegram)](https://t.me/CipherTalk) [功能特性](#-功能特性) • [快速开始](#-快速开始) • [技术栈](#️-技术栈) • [贡献指南](#-贡献指南) • [许可证](#-许可证) diff --git a/electron/main.ts b/electron/main.ts index 6313aa8..cd205ad 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1026,6 +1026,34 @@ function registerIpcHandlers() { return dialog.showSaveDialog(options) }) + // 文件操作 + ipcMain.handle('file:delete', async (_, filePath: string) => { + try { + const fs = await import('fs') + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath) + return { success: true } + } else { + return { success: false, error: '文件不存在' } + } + } catch (error: any) { + return { success: false, error: error.message } + } + }) + + ipcMain.handle('file:copy', async (_, sourcePath: string, destPath: string) => { + try { + const fs = await import('fs') + if (!fs.existsSync(sourcePath)) { + return { success: false, error: '源文件不存在' } + } + fs.copyFileSync(sourcePath, destPath) + return { success: true } + } catch (error: any) { + return { success: false, error: error.message } + } + }) + ipcMain.handle('shell:openPath', async (_, path: string) => { const { shell } = await import('electron') return shell.openPath(path) diff --git a/electron/preload.ts b/electron/preload.ts index dcfb7b2..a0afd40 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -30,6 +30,12 @@ contextBridge.exposeInMainWorld('electronAPI', { saveFile: (options: any) => ipcRenderer.invoke('dialog:saveFile', options) }, + // 文件操作 + file: { + delete: (filePath: string) => ipcRenderer.invoke('file:delete', filePath), + copy: (sourcePath: string, destPath: string) => ipcRenderer.invoke('file:copy', sourcePath, destPath) + }, + // Shell shell: { openPath: (path: string) => ipcRenderer.invoke('shell:openPath', path), diff --git a/electron/services/cacheService.ts b/electron/services/cacheService.ts index ffcb38b..27f021b 100644 --- a/electron/services/cacheService.ts +++ b/electron/services/cacheService.ts @@ -111,38 +111,81 @@ export class CacheService { */ async clearDatabases(): Promise<{ success: boolean; error?: string }> { try { + const wxid = this.configService.get('myWxid') + if (!wxid) { + console.warn('[CacheService] 未配置 wxid,无法清理数据库缓存') + return { success: false, error: '未配置 wxid' } + } + + // 先断开所有数据库连接 + console.log('[CacheService] 断开数据库连接...') + try { + const { chatService } = await import('./chatService') + chatService.close() + console.log('[CacheService] 已关闭 chatService') + } catch (e) { + console.warn('关闭 chatService 失败:', e) + } + + // 关闭语音转文字缓存数据库 + try { + const { voiceTranscribeService } = await import('./voiceTranscribeService') + if (voiceTranscribeService && (voiceTranscribeService as any).cacheDb) { + try { + ;(voiceTranscribeService as any).cacheDb.close() + ;(voiceTranscribeService as any).cacheDb = null + console.log('[CacheService] 已关闭语音转文字缓存数据库') + } catch (e) { + console.warn('关闭语音转文字缓存数据库失败:', e) + } + } + } catch (e) { + console.warn('导入 voiceTranscribeService 失败:', e) + } + + // 等待文件句柄释放(增加等待时间) + await new Promise(resolve => setTimeout(resolve, 1000)) + const cachePath = this.getEffectiveCachePath() + console.log('[CacheService] 缓存路径:', cachePath) + if (!existsSync(cachePath)) { return { success: true } } - const wxid = this.configService.get('myWxid') - if (wxid) { - const possibleFolderNames = [ - wxid, - (wxid as string).replace('wxid_', ''), - (wxid as string).split('_').slice(0, 2).join('_'), - ] - for (const folderName of possibleFolderNames) { - const wxidFolderPath = join(cachePath, folderName) - if (existsSync(wxidFolderPath)) { - this.clearDbFilesInFolder(wxidFolderPath) + // 查找并删除 wxid 文件夹(包含所有解密后的数据库) + const possibleFolderNames = [ + wxid, + (wxid as string).replace('wxid_', ''), + (wxid as string).split('_').slice(0, 2).join('_'), + ] + + let deleted = false + for (const folderName of possibleFolderNames) { + const wxidFolderPath = join(cachePath, folderName) + if (existsSync(wxidFolderPath)) { + console.log('[CacheService] 找到 wxid 文件夹,准备删除:', wxidFolderPath) + try { + rmSync(wxidFolderPath, { recursive: true, force: true }) + console.log('[CacheService] 成功删除 wxid 文件夹') + deleted = true break + } catch (e: any) { + console.error('[CacheService] 删除 wxid 文件夹失败:', e) + return { success: false, error: `删除失败: ${e.message}` } } } } - const files = readdirSync(cachePath) - for (const file of files) { - const filePath = join(cachePath, file) - const stat = statSync(filePath) - if (stat.isFile() && file.endsWith('.db')) { - rmSync(filePath, { force: true }) - } + if (!deleted) { + console.warn('[CacheService] 未找到 wxid 文件夹') + return { success: false, error: '未找到数据库缓存文件夹' } } + console.log('[CacheService] 数据库缓存清理完成') return { success: true } } catch (e) { + console.error('[CacheService] 清理数据库缓存失败:', e) return { success: false, error: String(e) } } } @@ -164,6 +207,24 @@ export class CacheService { return { success: true } } + // 先关闭可能占用数据库文件的服务 + try { + const { voiceTranscribeService } = await import('./voiceTranscribeService') + if (voiceTranscribeService && (voiceTranscribeService as any).cacheDb) { + try { + ;(voiceTranscribeService as any).cacheDb.close() + ;(voiceTranscribeService as any).cacheDb = null + } catch (e) { + console.warn('关闭语音转文字缓存数据库失败:', e) + } + } + } catch (e) { + console.warn('导入 voiceTranscribeService 失败:', e) + } + + // 等待一下确保文件句柄释放 + await new Promise(resolve => setTimeout(resolve, 100)) + // 清除指定的缓存目录 const dirsToRemove = ['images', 'Images', 'Emojis', 'logs'] @@ -202,11 +263,20 @@ export class CacheService { // 递归清除子目录中的.db文件 this.clearDbFilesInFolder(filePath) } else if (stat.isFile() && file.endsWith('.db')) { - rmSync(filePath, { force: true }) + try { + rmSync(filePath, { force: true }) + } catch (e: any) { + // 如果文件被占用,跳过并记录警告 + if (e.code === 'EBUSY' || e.code === 'EPERM') { + console.warn(`跳过被占用的数据库文件: ${file}`) + } else { + console.error(`删除数据库文件失败: ${file}`, e) + } + } } } } catch (e) { - // 忽略权限错误等 + console.error('清除文件夹中的数据库文件失败:', e) } } diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 25c69bd..6b5a66c 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -62,6 +62,9 @@ export interface Message { fileExt?: string // 文件扩展名 fileMd5?: string // 文件 MD5 chatRecordList?: ChatRecordItem[] // 聊天记录列表 (Type 19) + // 转账消息相关 + transferPayerUsername?: string // 转账付款方 wxid + transferReceiverUsername?: string // 转账收款方 wxid } export interface ChatRecordItem { @@ -354,6 +357,8 @@ class ChatService extends EventEmitter { } this.sessionDb = null this.contactDb = null + this.emoticonDb = null + this.emotionDb = null this.headImageDb = null this.messageDbCache.clear() this.knownMessageDbFiles.clear() diff --git a/electron/services/dataManagementService.ts b/electron/services/dataManagementService.ts index 702fad8..47098c9 100644 --- a/electron/services/dataManagementService.ts +++ b/electron/services/dataManagementService.ts @@ -1031,16 +1031,28 @@ class DataManagementService { return { success: false, error: '缓存目录不存在,请先解密数据库' } } - // 图片统一存放在 images 目录下 + const directories: { wxid: string; path: string }[] = [] + + // 图片目录 const imagesDir = path.join(cachePath, 'images') - if (!fs.existsSync(imagesDir)) { + if (fs.existsSync(imagesDir)) { + directories.push({ wxid, path: imagesDir }) + } + + // 表情包目录 + const emojisDir = path.join(cachePath, 'Emojis') + if (fs.existsSync(emojisDir)) { + directories.push({ wxid, path: emojisDir }) + } + + if (directories.length === 0) { return { success: false, error: '图片目录不存在,请先解密数据库' } } - // 返回图片目录(所有账号共享) + // 返回所有目录 return { success: true, - directories: [{ wxid, path: imagesDir }] + directories } } catch (e) { console.error('获取图片目录失败:', e) diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 9a4248d..055e7ae 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -6,6 +6,8 @@ import { ConfigService } from './config' import { voiceTranscribeService } from './voiceTranscribeService' import * as XLSX from 'xlsx' import { HtmlExportGenerator } from './htmlExportGenerator' +import { imageDecryptService } from './imageDecryptService' +import { videoService } from './videoService' // ChatLab 0.0.2 格式类型定义 interface ChatLabHeader { @@ -84,6 +86,11 @@ export interface ExportOptions { dateRange?: { start: number; end: number } | null exportMedia?: boolean exportAvatars?: boolean + exportImages?: boolean + exportVideos?: boolean + exportEmojis?: boolean + exportVoices?: boolean + mediaPathMap?: Map } export interface ContactExportOptions { @@ -427,7 +434,7 @@ class ExportService { // 检查 XML 中的 type 标签(支持大 localType 的情况) const xmlTypeMatch = /(\d+)<\/type>/i.exec(content) const xmlType = xmlTypeMatch ? parseInt(xmlTypeMatch[1]) : null - + // 特殊处理 type 49 或 XML type if (localType === 49 || xmlType) { const subType = xmlType || 0 @@ -440,7 +447,7 @@ class ExportService { case 2000: return 99 // 转账 -> OTHER (ChatLab 没有转账类型) case 5: case 49: return 7 // 链接 -> LINK - default: + default: if (xmlType) return 7 // 有 XML type 但未知,默认为链接 } } @@ -519,7 +526,7 @@ class ExportService { /** * 解析消息内容为可读文本 */ - private parseMessageContent(content: string, localType: number, sessionId?: string, createTime?: number): string | null { + private parseMessageContent(content: string, localType: number, sessionId?: string, createTime?: number, mediaPathMap?: Map): string | null { if (!content) return null // 检查 XML 中的 type 标签(支持大 localType 的情况) @@ -529,25 +536,42 @@ class ExportService { switch (localType) { case 1: // 文本 return this.stripSenderPrefix(content) - case 3: return '[图片]' + case 3: { + // 图片消息:如果有媒体映射表,返回相对路径 + if (mediaPathMap && createTime && mediaPathMap.has(createTime)) { + return `[图片] ${mediaPathMap.get(createTime)}` + } + return '[图片]' + } case 34: { - // 语音消息 - 尝试获取转写文字 - if (sessionId && createTime) { - const transcript = voiceTranscribeService.getCachedTranscript(sessionId, createTime) - if (transcript) { - return `[语音消息] ${transcript}` - } + // 语音消息 + const transcript = (sessionId && createTime) ? voiceTranscribeService.getCachedTranscript(sessionId, createTime) : null + if (mediaPathMap && createTime && mediaPathMap.has(createTime)) { + return `[语音消息] ${mediaPathMap.get(createTime)}${transcript ? ' ' + transcript : ''}` + } + if (transcript) { + return `[语音消息] ${transcript}` } return '[语音消息]' } case 42: return '[名片]' - case 43: return '[视频]' - case 47: return '[动画表情]' + case 43: { + if (mediaPathMap && createTime && mediaPathMap.has(createTime)) { + return `[视频] ${mediaPathMap.get(createTime)}` + } + return '[视频]' + } + case 47: { + if (mediaPathMap && createTime && mediaPathMap.has(createTime)) { + return `[动画表情] ${mediaPathMap.get(createTime)}` + } + return '[动画表情]' + } case 48: return '[位置]' case 49: { const title = this.extractXmlValue(content, 'title') const type = this.extractXmlValue(content, 'type') - + // 群公告消息(type 87) if (type === '87') { const textAnnouncement = this.extractXmlValue(content, 'textannouncement') @@ -556,7 +580,7 @@ class ExportService { } return '[群公告]' } - + // 转账消息特殊处理 if (type === '2000') { const feedesc = this.extractXmlValue(content, 'feedesc') @@ -566,7 +590,7 @@ class ExportService { } return '[转账]' } - + if (type === '6') return title ? `[文件] ${title}` : '[文件]' if (type === '19') return title ? `[聊天记录] ${title}` : '[聊天记录]' if (type === '33' || type === '36') return title ? `[小程序] ${title}` : '[小程序]' @@ -585,7 +609,7 @@ class ExportService { // 对于未知的 localType,检查 XML type 来判断消息类型 if (xmlType) { const title = this.extractXmlValue(content, 'title') - + // 群公告消息(type 87) if (xmlType === '87') { const textAnnouncement = this.extractXmlValue(content, 'textannouncement') @@ -594,7 +618,7 @@ class ExportService { } return '[群公告]' } - + // 转账消息 if (xmlType === '2000') { const feedesc = this.extractXmlValue(content, 'feedesc') @@ -604,18 +628,18 @@ class ExportService { } return '[转账]' } - + // 其他类型 if (xmlType === '6') return title ? `[文件] ${title}` : '[文件]' if (xmlType === '19') return title ? `[聊天记录] ${title}` : '[聊天记录]' if (xmlType === '33' || xmlType === '36') return title ? `[小程序] ${title}` : '[小程序]' if (xmlType === '57') return title || '[引用消息]' if (xmlType === '5' || xmlType === '49') return title ? `[链接] ${title}` : '[链接]' - + // 有 title 就返回 title if (title) return title } - + // 最后尝试提取文本内容 return this.stripSenderPrefix(content) || null } @@ -633,17 +657,17 @@ class ExportService { */ private extractRevokerInfo(content: string): { isRevoke: boolean; isSelfRevoke?: boolean; revokerWxid?: string } { if (!content) return { isRevoke: false } - + // 检查是否是撤回消息 if (!content.includes('revokemsg') && !content.includes('撤回')) { return { isRevoke: false } } - + // 检查是否是 "你撤回了" - 自己撤回 if (content.includes('你撤回')) { return { isRevoke: true, isSelfRevoke: true } } - + // 尝试从 标签提取(格式: wxid_xxx) const sessionMatch = /([^<]+)<\/session>/i.exec(content) if (sessionMatch) { @@ -653,13 +677,13 @@ class ExportService { return { isRevoke: true, revokerWxid: session } } } - + // 尝试从 提取 const fromUserMatch = /([^<]+)<\/fromusername>/i.exec(content) if (fromUserMatch) { return { isRevoke: true, revokerWxid: fromUserMatch[1].trim() } } - + // 是撤回消息但无法提取撤回者 return { isRevoke: true } } @@ -762,7 +786,7 @@ class ExportService { // 判断是否是自己发送 const isSend = row.is_send === 1 || senderUsername === cleanedMyWxid - + // 确定实际发送者 let actualSender: string if (localType === 10000 || localType === 266287972401) { @@ -868,10 +892,10 @@ class ExportService { // 构建 ChatLab 格式消息 const chatLabMessages: ChatLabMessage[] = [] - + for (const msg of allMessages) { const memberInfo = memberSet.get(msg.senderUsername) || { platformId: msg.senderUsername, accountName: msg.senderUsername } - let parsedContent = this.parseMessageContent(msg.content, msg.localType, sessionId, msg.createTime) + let parsedContent = this.parseMessageContent(msg.content, msg.localType, sessionId, msg.createTime, options.mediaPathMap) // 转账消息:追加 "谁转账给谁" 信息 if (parsedContent && parsedContent.startsWith('[转账]') && msg.content) { @@ -896,16 +920,16 @@ class ExportService { type: this.convertMessageType(msg.localType, msg.content), content: parsedContent } - + // 添加可选字段 if (msg.groupNickname) message.groupNickname = msg.groupNickname if (msg.platformMessageId) message.platformMessageId = msg.platformMessageId if (msg.replyToMessageId) message.replyToMessageId = msg.replyToMessageId - + // 如果有聊天记录,添加为嵌套字段 if (msg.chatRecordList && msg.chatRecordList.length > 0) { const chatRecords: ChatRecordItem[] = [] - + for (const record of msg.chatRecordList) { // 解析时间戳 (格式: "YYYY-MM-DD HH:MM:SS") let recordTimestamp = msg.createTime @@ -931,7 +955,7 @@ class ExportService { // 转换消息类型 let recordType = 0 // TEXT let recordContent = record.datadesc || record.datatitle || '' - + switch (record.datatype) { case 1: recordType = 0 // TEXT @@ -969,14 +993,14 @@ class ExportService { type: recordType, content: recordContent } - + // 添加头像(如果启用导出头像) if (options.exportAvatars && record.sourceheadurl) { chatRecord.avatar = record.sourceheadurl } - + chatRecords.push(chatRecord) - + // 添加成员信息 if (record.sourcename && !memberSet.has(record.sourcename)) { memberSet.set(record.sourcename, { @@ -986,10 +1010,10 @@ class ExportService { }) } } - + message.chatRecords = chatRecords } - + chatLabMessages.push(message) } @@ -1173,7 +1197,7 @@ class ExportService { // 转换消息类型名称 let typeName = '文本消息' let content = record.datadesc || record.datatitle || '' - + switch (record.datatype) { case 1: typeName = '文本消息' @@ -1376,7 +1400,7 @@ class ExportService { if (content) { const xmlTypeMatch = /(\d+)<\/type>/i.exec(content) const xmlType = xmlTypeMatch ? xmlTypeMatch[1] : null - + if (xmlType) { switch (xmlType) { case '87': return '群公告' @@ -1552,7 +1576,7 @@ class ExportService { type: this.getMessageTypeName(localType, content), localType, chatLabType: this.convertMessageType(localType, content), - content: this.parseMessageContent(content, localType, sessionId, createTime), + content: this.parseMessageContent(content, localType, sessionId, createTime, options.mediaPathMap), rawContent: content, // 保留原始内容(用于转账描述解析) isSend: isSend ? 1 : 0, senderUsername: actualSender, @@ -1772,7 +1796,7 @@ class ExportService { const time = new Date(msg.createTime * 1000) // 获取消息内容(使用统一的解析方法) - let messageContent = this.parseMessageContent(msg.content, msg.type, sessionId, msg.createTime) + let messageContent = this.parseMessageContent(msg.content, msg.type, sessionId, msg.createTime, options.mediaPathMap) // 转账消息:追加 "谁转账给谁" 信息 if (messageContent && messageContent.startsWith('[转账]') && msg.content) { @@ -1975,7 +1999,7 @@ class ExportService { sender: actualSender, senderName: senderInfo.displayName, type: localType, - content: this.parseMessageContent(content, localType, sessionId, createTime), + content: this.parseMessageContent(content, localType, sessionId, createTime, options.mediaPathMap), rawContent: content, isSend, chatRecords: chatRecordList ? this.formatChatRecordsForJson(chatRecordList, options) : undefined @@ -2032,28 +2056,15 @@ class ExportService { messages: allMessages } - // 创建导出目录 - const exportDir = path.dirname(outputPath) - const baseName = path.basename(outputPath, '.html') - const exportFolder = path.join(exportDir, baseName) - - // 如果目录不存在则创建 - if (!fs.existsSync(exportFolder)) { - fs.mkdirSync(exportFolder, { recursive: true }) + // 直接写入单文件 HTML(CSS/JS/数据全部内联) + const outputDir = path.dirname(outputPath) + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }) } - // 生成并写入各个文件 - const htmlPath = path.join(exportFolder, 'index.html') - const cssPath = path.join(exportFolder, 'styles.css') - const jsPath = path.join(exportFolder, 'app.js') - const dataPath = path.join(exportFolder, 'data.js') + fs.writeFileSync(outputPath, HtmlExportGenerator.generateHtmlWithData(exportData), 'utf-8') - fs.writeFileSync(htmlPath, HtmlExportGenerator.generateHtmlWithData(exportData), 'utf-8') - fs.writeFileSync(cssPath, HtmlExportGenerator.generateCss(), 'utf-8') - fs.writeFileSync(jsPath, HtmlExportGenerator.generateJs(), 'utf-8') - fs.writeFileSync(dataPath, HtmlExportGenerator.generateDataJs(exportData), 'utf-8') - - return { success: true, outputPath: htmlPath } + return { success: true } } catch (e) { console.error('ExportService: HTML 导出失败:', e) return { success: false, error: String(e) } @@ -2137,24 +2148,53 @@ class ExportService { }) // 生成文件名(清理非法字符) - const safeName = sessionInfo.displayName.replace(/[<>:"/\\|?*]/g, '_') + const safeName = sessionInfo.displayName.replace(/[<>:"\/\\|?*]/g, '_') let ext = '.json' if (options.format === 'chatlab-jsonl') ext = '.jsonl' else if (options.format === 'excel') ext = '.xlsx' else if (options.format === 'html') ext = '.html' - const outputPath = path.join(outputDir, `${safeName}${ext}`) + + // 当导出媒体时,创建会话子文件夹,把文件和媒体都放进去 + const hasMedia = options.exportImages || options.exportVideos || options.exportEmojis || options.exportVoices + const sessionOutputDir = hasMedia ? path.join(outputDir, safeName) : outputDir + if (hasMedia && !fs.existsSync(sessionOutputDir)) { + fs.mkdirSync(sessionOutputDir, { recursive: true }) + } + + const outputPath = path.join(sessionOutputDir, `${safeName}${ext}`) + + // 先导出媒体文件,收集路径映射表 + let mediaPathMap: Map | undefined + if (hasMedia) { + try { + mediaPathMap = await this.exportMediaFiles(sessionId, safeName, sessionOutputDir, options, (detail) => { + onProgress?.({ + current: i + 1, + total: sessionIds.length, + currentSession: sessionInfo.displayName, + phase: 'writing', + detail + }) + }) + } catch (e) { + console.error(`导出 ${sessionId} 媒体文件失败:`, e) + } + } + + // 将媒体路径映射表附加到 options 上 + const exportOpts = mediaPathMap ? { ...options, mediaPathMap } : options let result: { success: boolean; error?: string } // 根据格式选择导出方法 if (options.format === 'json') { - result = await this.exportSessionToDetailedJson(sessionId, outputPath, options) + result = await this.exportSessionToDetailedJson(sessionId, outputPath, exportOpts) } else if (options.format === 'chatlab' || options.format === 'chatlab-jsonl') { - result = await this.exportSessionToChatLab(sessionId, outputPath, options) + result = await this.exportSessionToChatLab(sessionId, outputPath, exportOpts) } else if (options.format === 'excel') { - result = await this.exportSessionToExcel(sessionId, outputPath, options) + result = await this.exportSessionToExcel(sessionId, outputPath, exportOpts) } else if (options.format === 'html') { - result = await this.exportSessionToHtml(sessionId, outputPath, options) + result = await this.exportSessionToHtml(sessionId, outputPath, exportOpts) } else { result = { success: false, error: `不支持的格式: ${options.format}` } } @@ -2185,8 +2225,561 @@ class ExportService { } /** - * 导出通讯录 + * 导出会话的媒体文件(图片和视频) */ + private async exportMediaFiles( + sessionId: string, + safeName: string, + outputDir: string, + options: ExportOptions, + onDetail?: (detail: string) => void + ): Promise> { + // 返回 createTime → 相对路径 的映射表 + const mediaPathMap = new Map() + + const dbTablePairs = this.findSessionTables(sessionId) + if (dbTablePairs.length === 0) return mediaPathMap + + // 创建媒体输出目录(直接在会话文件夹下创建子目录) + const imageOutDir = options.exportImages ? path.join(outputDir, 'images') : '' + const videoOutDir = options.exportVideos ? path.join(outputDir, 'videos') : '' + const emojiOutDir = options.exportEmojis ? path.join(outputDir, 'emojis') : '' + + if (options.exportImages && !fs.existsSync(imageOutDir)) { + fs.mkdirSync(imageOutDir, { recursive: true }) + } + if (options.exportVideos && !fs.existsSync(videoOutDir)) { + fs.mkdirSync(videoOutDir, { recursive: true }) + } + if (options.exportEmojis && !fs.existsSync(emojiOutDir)) { + fs.mkdirSync(emojiOutDir, { recursive: true }) + } + + let imageCount = 0 + let videoCount = 0 + let emojiCount = 0 + + // 构建查询条件:只查需要的消息类型 + const typeConditions: string[] = [] + if (options.exportImages) typeConditions.push('3') + if (options.exportVideos) typeConditions.push('43') + if (options.exportEmojis) typeConditions.push('47') + + // 图片/视频/表情循环(语音在后面独立处理) + if (typeConditions.length > 0) { + for (const { db, tableName } of dbTablePairs) { + try { + const hasName2Id = db.prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name='Name2Id'" + ).get() + + const typeFilter = typeConditions.map(t => `local_type = ${t}`).join(' OR ') + + // 用 SELECT * 获取完整行,包含 packed_info_data + let sql: string + if (hasName2Id) { + sql = `SELECT m.* FROM ${tableName} m WHERE (${typeFilter}) ORDER BY m.create_time ASC` + } else { + sql = `SELECT * FROM ${tableName} WHERE (${typeFilter}) ORDER BY create_time ASC` + } + + const rows = db.prepare(sql).all() as any[] + + for (const row of rows) { + const createTime = row.create_time || 0 + + // 时间范围过滤 + if (options.dateRange) { + if (createTime < options.dateRange.start || createTime > options.dateRange.end) { + continue + } + } + + const localType = row.local_type || row.type || 1 + const content = this.decodeMessageContent(row.message_content, row.compress_content) + + // 导出图片 + if (options.exportImages && localType === 3) { + try { + // 从 XML 提取 md5 + const imageMd5 = this.extractXmlValue(content, 'md5') || + (/\]*\smd5\s*=\s*['"]([^'"]+)['"]/i.exec(content))?.[1] || + undefined + + // 从 packed_info_data 解析 dat 文件名(缓存文件以此命名) + const imageDatName = this.parseImageDatName(row) + + if (imageMd5 || imageDatName) { + const cacheResult = await imageDecryptService.decryptImage({ + sessionId, + imageMd5, + imageDatName + }) + + if (cacheResult.success && cacheResult.localPath) { + // localPath 是 file:///path?v=xxx 格式,转为本地路径 + let filePath = cacheResult.localPath + .replace(/\?v=\d+$/, '') + .replace(/^file:\/\/\//i, '') + filePath = decodeURIComponent(filePath) + + if (fs.existsSync(filePath)) { + const ext = path.extname(filePath) || '.jpg' + const fileName = `${createTime}_${imageMd5 || imageDatName}${ext}` + const destPath = path.join(imageOutDir, fileName) + if (!fs.existsSync(destPath)) { + fs.copyFileSync(filePath, destPath) + imageCount++ + // 记录映射:createTime → 相对路径 + mediaPathMap.set(createTime, `images/${fileName}`) + } + } + } + } + } catch (e) { + // 跳过单张图片的错误 + } + } + + // 导出视频 + if (options.exportVideos && localType === 43) { + try { + const videoMd5 = videoService.parseVideoMd5(content) + if (videoMd5) { + const videoInfo = videoService.getVideoInfo(videoMd5) + if (videoInfo.exists && videoInfo.videoUrl) { + const videoPath = videoInfo.videoUrl.replace(/^file:\/\/\//i, '').replace(/\//g, path.sep) + if (fs.existsSync(videoPath)) { + const fileName = `${createTime}_${videoMd5}.mp4` + const destPath = path.join(videoOutDir, fileName) + if (!fs.existsSync(destPath)) { + fs.copyFileSync(videoPath, destPath) + videoCount++ + // 记录映射:createTime → 相对路径 + mediaPathMap.set(createTime, `videos/${fileName}`) + } + } + } + } + } catch (e) { + // 跳过单个视频的错误 + } + } + + // 导出表情包 + if (options.exportEmojis && localType === 47) { + try { + // 从 XML 提取 cdnUrl 和 md5 + const cdnUrlMatch = /cdnurl\s*=\s*['"]([^'"]+)['"]/i.exec(content) + const thumbUrlMatch = /thumburl\s*=\s*['"]([^'"]+)['"]/i.exec(content) + const md5Match = /md5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) || + /([^<]+)<\/md5>/i.exec(content) + + let cdnUrl = cdnUrlMatch?.[1] || thumbUrlMatch?.[1] || '' + const emojiMd5 = md5Match?.[1] || '' + + if (cdnUrl) { + cdnUrl = cdnUrl.replace(/&/g, '&') + } + + if (emojiMd5 || cdnUrl) { + const cacheKey = emojiMd5 || this.hashString(cdnUrl) + // 确定文件扩展名 + const ext = cdnUrl.includes('.gif') || content.includes('type="2"') ? '.gif' : '.png' + const fileName = `${createTime}_${cacheKey}${ext}` + const destPath = path.join(emojiOutDir, fileName) + + if (!fs.existsSync(destPath)) { + // 1. 先检查本地缓存(cachePath/Emojis/) + let sourceFile = this.findLocalEmoji(cacheKey) + + // 2. 找不到就从 CDN 下载 + if (!sourceFile && cdnUrl) { + sourceFile = await this.downloadEmojiFile(cdnUrl, cacheKey) + } + + if (sourceFile && fs.existsSync(sourceFile)) { + fs.copyFileSync(sourceFile, destPath) + emojiCount++ + mediaPathMap.set(createTime, `emojis/${fileName}`) + } + } else { + // 文件已存在,只记录映射 + mediaPathMap.set(createTime, `emojis/${fileName}`) + } + } + } catch (e) { + // 跳过单个表情的错误 + } + } + } + } catch (e) { + console.error(`[Export] 读取媒体消息失败:`, e) + } + } + } // 结束 typeConditions > 0 + + // === 语音导出(独立流程:需要从 MediaDb 读取) === + let voiceCount = 0 + if (options.exportVoices) { + const voiceOutDir = path.join(outputDir, 'voices') + if (!fs.existsSync(voiceOutDir)) { + fs.mkdirSync(voiceOutDir, { recursive: true }) + } + + onDetail?.('正在导出语音消息...') + + // 1. 收集所有语音消息的 createTime + const voiceCreateTimes: number[] = [] + for (const { db, tableName } of dbTablePairs) { + try { + let sql = `SELECT create_time FROM ${tableName} WHERE local_type = 34` + if (options.dateRange) { + sql += ` AND create_time >= ${options.dateRange.start} AND create_time <= ${options.dateRange.end}` + } + sql += ` ORDER BY create_time` + const rows = db.prepare(sql).all() as any[] + for (const row of rows) { + if (row.create_time) voiceCreateTimes.push(row.create_time) + } + } catch { } + } + + if (voiceCreateTimes.length > 0) { + // 2. 查找 MediaDb + const mediaDbs = this.findMediaDbs() + + if (mediaDbs.length > 0) { + // 3. 只初始化一次 silk-wasm + let silkWasm: any = null + try { + silkWasm = require('silk-wasm') + } catch (e) { + console.error('[Export] silk-wasm 加载失败:', e) + } + + if (silkWasm) { + // 4. 打开所有 MediaDb,预先建立 VoiceInfo 查询 + interface VoiceDbInfo { + db: InstanceType + voiceTable: string + dataColumn: string + timeColumn: string + chatNameIdColumn: string | null + name2IdTable: string | null + } + const voiceDbs: VoiceDbInfo[] = [] + + for (const dbPath of mediaDbs) { + try { + const mediaDb = new Database(dbPath, { readonly: true }) + const tables = mediaDb.prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'VoiceInfo%'" + ).all() as any[] + if (tables.length === 0) { mediaDb.close(); continue } + + const voiceTable = tables[0].name + const columns = mediaDb.prepare(`PRAGMA table_info('${voiceTable}')`).all() as any[] + const colNames = columns.map((c: any) => c.name.toLowerCase()) + + const dataColumn = colNames.find((c: string) => ['voice_data', 'buf', 'voicebuf', 'data'].includes(c)) + const timeColumn = colNames.find((c: string) => ['create_time', 'createtime', 'time'].includes(c)) + if (!dataColumn || !timeColumn) { mediaDb.close(); continue } + + const chatNameIdColumn = colNames.find((c: string) => ['chat_name_id', 'chatnameid', 'chat_nameid'].includes(c)) || null + const n2iTables = mediaDb.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Name2Id%'").all() as any[] + const name2IdTable = n2iTables.length > 0 ? n2iTables[0].name : null + + voiceDbs.push({ db: mediaDb, voiceTable, dataColumn, timeColumn, chatNameIdColumn, name2IdTable }) + } catch { } + } + + // 5. 串行处理语音(避免内存溢出) + const myWxid = this.configService.get('myWxid') + const candidates = [sessionId] + if (myWxid && myWxid !== sessionId) candidates.push(myWxid) + + const total = voiceCreateTimes.length + for (let idx = 0; idx < total; idx++) { + const createTime = voiceCreateTimes[idx] + const fileName = `${createTime}.wav` + const destPath = path.join(voiceOutDir, fileName) + + // 已存在则跳过 + if (fs.existsSync(destPath)) { + mediaPathMap.set(createTime, `voices/${fileName}`) + continue + } + + // 在 MediaDb 中查找 SILK 数据 + let silkData: Buffer | null = null + for (const vdb of voiceDbs) { + try { + // 策略1: chatNameId + createTime + if (vdb.chatNameIdColumn && vdb.name2IdTable) { + for (const cand of candidates) { + const n2i = vdb.db.prepare(`SELECT rowid FROM ${vdb.name2IdTable} WHERE user_name = ?`).get(cand) as any + if (n2i?.rowid) { + const row = vdb.db.prepare(`SELECT ${vdb.dataColumn} AS data FROM ${vdb.voiceTable} WHERE ${vdb.chatNameIdColumn} = ? AND ${vdb.timeColumn} = ? LIMIT 1`).get(n2i.rowid, createTime) as any + if (row?.data) { + silkData = this.decodeVoiceBlob(row.data) + if (silkData) break + } + } + } + } + // 策略2: 仅 createTime + if (!silkData) { + const row = vdb.db.prepare(`SELECT ${vdb.dataColumn} AS data FROM ${vdb.voiceTable} WHERE ${vdb.timeColumn} = ? LIMIT 1`).get(createTime) as any + if (row?.data) { + silkData = this.decodeVoiceBlob(row.data) + } + } + if (silkData) break + } catch { } + } + + if (!silkData) continue + + try { + // SILK → PCM → WAV(串行,立即释放) + const result = await silkWasm.decode(silkData, 24000) + silkData = null // 释放 SILK 数据 + if (!result?.data) continue + const pcmData = Buffer.from(result.data) + const wavData = this.createWavBuffer(pcmData, 24000) + fs.writeFileSync(destPath, wavData) + voiceCount++ + mediaPathMap.set(createTime, `voices/${fileName}`) + } catch { } + + // 进度日志 + if ((idx + 1) % 10 === 0 || idx === total - 1) { + onDetail?.(`语音导出: ${idx + 1}/${total}`) + } + } + + // 6. 关闭所有 MediaDb + for (const vdb of voiceDbs) { + try { vdb.db.close() } catch { } + } + } + } + } + } + + const parts: string[] = [] + if (imageCount > 0) parts.push(`${imageCount} 张图片`) + if (videoCount > 0) parts.push(`${videoCount} 个视频`) + if (emojiCount > 0) parts.push(`${emojiCount} 个表情`) + if (voiceCount > 0) parts.push(`${voiceCount} 条语音`) + const summary = parts.length > 0 ? `媒体导出完成: ${parts.join(', ')}` : '无媒体文件' + onDetail?.(summary) + console.log(`[Export] ${sessionId} ${summary}`) + return mediaPathMap + } + + /** + * 从数据库行的 packed_info_data 中解析图片 dat 文件名 + * 复制自 chatService.parseImageDatNameFromRow 逻辑 + */ + private parseImageDatName(row: Record): string | undefined { + // 尝试多种可能的字段名 + const fieldNames = [ + 'packed_info_data', 'packed_info', 'packedInfoData', 'packedInfo', + 'PackedInfoData', 'PackedInfo', + 'WCDB_CT_packed_info_data', 'WCDB_CT_packed_info', + 'WCDB_CT_PackedInfoData', 'WCDB_CT_PackedInfo' + ] + let packed: any = undefined + for (const name of fieldNames) { + if (row[name] !== undefined && row[name] !== null) { + packed = row[name] + break + } + } + + // 解码为 Buffer + let buffer: Buffer | null = null + if (!packed) return undefined + if (Buffer.isBuffer(packed)) { + buffer = packed + } else if (packed instanceof Uint8Array) { + buffer = Buffer.from(packed) + } else if (Array.isArray(packed)) { + buffer = Buffer.from(packed) + } else if (typeof packed === 'string') { + const trimmed = packed.trim() + if (/^[a-fA-F0-9]+$/.test(trimmed) && trimmed.length % 2 === 0) { + try { buffer = Buffer.from(trimmed, 'hex') } catch { } + } + if (!buffer) { + try { buffer = Buffer.from(trimmed, 'base64') } catch { } + } + } else if (typeof packed === 'object' && Array.isArray(packed.data)) { + buffer = Buffer.from(packed.data) + } + + if (!buffer || buffer.length === 0) return undefined + + // 提取可打印字符 + const printable: number[] = [] + for (let i = 0; i < buffer.length; i++) { + const byte = buffer[i] + if (byte >= 0x20 && byte <= 0x7e) { + printable.push(byte) + } else { + printable.push(0x20) + } + } + const text = Buffer.from(printable).toString('utf-8') + + // 匹配 dat 文件名 + const match = /([0-9a-fA-F]{8,})(?:\.t)?\.dat/.exec(text) + if (match?.[1]) return match[1].toLowerCase() + const hexMatch = /([0-9a-fA-F]{16,})/.exec(text) + return hexMatch?.[1]?.toLowerCase() + } + + /** + * 简单字符串哈希(用于无 md5 时生成缓存 key) + */ + private hashString(str: string): string { + let hash = 0 + for (let i = 0; i < str.length; i++) { + const chr = str.charCodeAt(i) + hash = ((hash << 5) - hash) + chr + hash |= 0 + } + return Math.abs(hash).toString(16) + } + + /** + * 查找本地缓存的表情包文件 + */ + private findLocalEmoji(cacheKey: string): string | null { + try { + const cachePath = this.configService.get('cachePath') + if (!cachePath) return null + + const emojiCacheDir = path.join(cachePath, 'Emojis') + if (!fs.existsSync(emojiCacheDir)) return null + + // 检查各种扩展名 + const extensions = ['.gif', '.png', '.webp', '.jpg', '.jpeg', ''] + for (const ext of extensions) { + const filePath = path.join(emojiCacheDir, `${cacheKey}${ext}`) + if (fs.existsSync(filePath)) { + const stat = fs.statSync(filePath) + if (stat.isFile() && stat.size > 0) return filePath + } + } + return null + } catch { + return null + } + } + + /** + * 从 CDN 下载表情包文件并缓存 + */ + private async downloadEmojiFile(cdnUrl: string, cacheKey: string): Promise { + try { + const cachePath = this.configService.get('cachePath') + if (!cachePath) return null + + const emojiCacheDir = path.join(cachePath, 'Emojis') + if (!fs.existsSync(emojiCacheDir)) { + fs.mkdirSync(emojiCacheDir, { recursive: true }) + } + + // 使用 https/http 模块下载 + const { net } = require('electron') + const response = await net.fetch(cdnUrl, { + method: 'GET', + headers: { 'User-Agent': 'Mozilla/5.0' } + }) + + if (!response.ok) return null + + const buffer = Buffer.from(await response.arrayBuffer()) + if (buffer.length === 0) return null + + // 检测文件类型 + let ext = '.gif' + if (buffer[0] === 0x89 && buffer[1] === 0x50) ext = '.png' + else if (buffer[0] === 0xFF && buffer[1] === 0xD8) ext = '.jpg' + else if (buffer[0] === 0x52 && buffer[1] === 0x49) ext = '.webp' + + const filePath = path.join(emojiCacheDir, `${cacheKey}${ext}`) + fs.writeFileSync(filePath, buffer) + return filePath + } catch { + return null + } + } + + /** + * 查找 media 数据库文件 + */ + private findMediaDbs(): string[] { + if (!this.dbDir) return [] + const result: string[] = [] + try { + const files = fs.readdirSync(this.dbDir) + for (const file of files) { + const lower = file.toLowerCase() + if (lower.startsWith('media') && lower.endsWith('.db')) { + result.push(path.join(this.dbDir, file)) + } + } + } catch { } + return result + } + + /** + * 解码语音 Blob 数据为 Buffer + */ + private decodeVoiceBlob(raw: any): Buffer | null { + if (!raw) return null + if (Buffer.isBuffer(raw)) return raw + if (raw instanceof Uint8Array) return Buffer.from(raw) + if (Array.isArray(raw)) return Buffer.from(raw) + if (typeof raw === 'string') { + const trimmed = raw.trim() + if (/^[a-fA-F0-9]+$/.test(trimmed) && trimmed.length % 2 === 0) { + try { return Buffer.from(trimmed, 'hex') } catch { } + } + try { return Buffer.from(trimmed, 'base64') } catch { } + } + if (typeof raw === 'object' && Array.isArray(raw.data)) { + return Buffer.from(raw.data) + } + return null + } + + /** + * PCM 数据生成 WAV 文件 Buffer + */ + private createWavBuffer(pcmData: Buffer, sampleRate: number = 24000, channels: number = 1): Buffer { + const pcmLength = pcmData.length + const header = Buffer.alloc(44) + header.write('RIFF', 0) + header.writeUInt32LE(36 + pcmLength, 4) + header.write('WAVE', 8) + header.write('fmt ', 12) + header.writeUInt32LE(16, 16) + header.writeUInt16LE(1, 20) + header.writeUInt16LE(channels, 22) + header.writeUInt32LE(sampleRate, 24) + header.writeUInt32LE(sampleRate * channels * 2, 28) + header.writeUInt16LE(channels * 2, 32) + header.writeUInt16LE(16, 34) + header.write('data', 36) + header.writeUInt32LE(pcmLength, 40) + return Buffer.concat([header, pcmData]) + } + /** * 导出通讯录 */ diff --git a/electron/services/htmlExportGenerator.ts b/electron/services/htmlExportGenerator.ts index 124a392..33bf55e 100644 --- a/electron/services/htmlExportGenerator.ts +++ b/electron/services/htmlExportGenerator.ts @@ -1,7 +1,7 @@ -/** +/** * HTML 导出生成器 - * 负责生成聊天记录的 HTML 展示页面 - * 使用外部资源引用,避免文件过大 + * 生成现代风格的聊天记录 HTML 页面 + * 支持图片/视频内联显示、搜索、主题切换 */ export interface HtmlExportMessage { @@ -49,804 +49,820 @@ export interface HtmlExportData { export class HtmlExportGenerator { /** - * 生成 HTML 主文件(引用外部 CSS 和 JS) + * 生成完整的单文件 HTML(内联 CSS + JS + 数据) */ static generateHtmlWithData(exportData: HtmlExportData): string { const escapedSessionName = this.escapeHtml(exportData.meta.sessionName) - const dateRangeText = exportData.meta.dateRange + const dateRangeText = exportData.meta.dateRange ? `${new Date(exportData.meta.dateRange.start * 1000).toLocaleDateString('zh-CN')} - ${new Date(exportData.meta.dateRange.end * 1000).toLocaleDateString('zh-CN')}` : '' - + return ` ${escapedSessionName} - 聊天记录 - - + -
-
-

${escapedSessionName}

-
- 共 ${exportData.messages.length} 条消息 - ${dateRangeText ? ` | ${dateRangeText}` : ''} +
+
+
+
${escapedSessionName.charAt(0)}
+
+

${escapedSessionName}

+ ${exportData.messages.length} 条消息${dateRangeText ? ' · ' + dateRangeText : ''} +
-
- -
- - - -
- 共 ${exportData.messages.length} 条消息 - +
+ +
+ + + - -
-
-
正在加载聊天记录...
-
-
- - - - + + + + + -`; +` } /** - * 生成外部 CSS 文件 + * 生成 CSS 样式 */ static generateCss(): string { - return `/* CipherTalk 聊天记录导出样式 */ - -* { - margin: 0; - padding: 0; - box-sizing: border-box; + return ` +:root { + --bg: #f0f2f5; + --chat-bg: #efeae2; + --header-bg: #075e54; + --header-text: #fff; + --bubble-recv: #ffffff; + --bubble-send: #d9fdd3; + --text: #111b21; + --text-secondary: #667781; + --text-time: #667781; + --border: #e9edef; + --search-bg: #f0f2f5; + --system-bg: rgba(0,0,0,0.04); + --system-text: #667781; + --shadow: rgba(0,0,0,0.08); + --link: #027eb5; + --media-bg: #e4e4e4; } +[data-theme="dark"] { + --bg: #0b141a; + --chat-bg: #0b141a; + --header-bg: #1f2c34; + --header-text: #e9edef; + --bubble-recv: #202c33; + --bubble-send: #005c4b; + --text: #e9edef; + --text-secondary: #8696a0; + --text-time: #8696a0; + --border: #222d34; + --search-bg: #111b21; + --system-bg: rgba(255,255,255,0.05); + --system-text: #8696a0; + --shadow: rgba(0,0,0,0.3); + --link: #53bdeb; + --media-bg: #1a2a33; +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + body { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - min-height: 100vh; - padding: 20px; - line-height: 1.6; - color: #333; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.45; + -webkit-font-smoothing: antialiased; } -.container { - max-width: 1000px; +.app { + max-width: 900px; margin: 0 auto; - background: white; - border-radius: 16px; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); - overflow: hidden; - animation: slideIn 0.5s ease-out; -} - -@keyframes slideIn { - from { - opacity: 0; - transform: translateY(30px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -/* 头部样式 */ -.header { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; - padding: 40px 30px; - text-align: center; - position: relative; - overflow: hidden; -} - -.header::before { - content: ''; - position: absolute; - top: -50%; - left: -50%; - width: 200%; - height: 200%; - background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 70%); - animation: pulse 15s ease-in-out infinite; -} - -@keyframes pulse { - 0%, 100% { transform: scale(1); } - 50% { transform: scale(1.1); } -} - -.header h1 { - font-size: 32px; - margin-bottom: 12px; - font-weight: 700; - position: relative; - z-index: 1; - text-shadow: 0 2px 10px rgba(0,0,0,0.2); -} - -.header .meta { - font-size: 15px; - opacity: 0.95; - position: relative; - z-index: 1; -} - -/* 控制栏样式 */ -.controls { - position: sticky; - top: 0; - background: white; - padding: 20px; - border-bottom: 2px solid #f0f0f0; + height: 100vh; display: flex; + flex-direction: column; + box-shadow: 0 0 40px var(--shadow); +} + +/* 头部 */ +.chat-header { + background: var(--header-bg); + color: var(--header-text); + padding: 10px 16px; + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; + z-index: 10; +} + +.header-left { + display: flex; + align-items: center; gap: 12px; - align-items: center; - flex-wrap: wrap; - z-index: 100; - box-shadow: 0 2px 8px rgba(0,0,0,0.05); + min-width: 0; } -.controls input[type="text"] { - flex: 1; - min-width: 250px; - padding: 12px 16px; - border: 2px solid #e0e0e0; - border-radius: 8px; - font-size: 14px; - transition: all 0.3s; -} - -.controls input[type="text"]:focus { - outline: none; - border-color: #667eea; - box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); -} - -.controls button { - padding: 12px 24px; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; - border: none; - border-radius: 8px; - cursor: pointer; - font-size: 14px; - font-weight: 600; - transition: all 0.3s; - box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3); -} - -.controls button:hover { - transform: translateY(-2px); - box-shadow: 0 6px 16px rgba(102, 126, 234, 0.4); -} - -.controls button:active { - transform: translateY(0); -} - -.controls .stats { - display: flex; - gap: 12px; - align-items: center; - margin-left: auto; - font-size: 14px; - color: #666; -} - -.controls .stats span { - font-weight: 500; -} - -/* 滚动容器 */ -.scroll-container { - height: calc(100vh - 280px); - overflow-y: auto; - overflow-x: hidden; - position: relative; - will-change: scroll-position; - -webkit-overflow-scrolling: touch; -} - -.scroll-container::-webkit-scrollbar { - width: 8px; -} - -.scroll-container::-webkit-scrollbar-track { - background: #f1f1f1; - border-radius: 4px; -} - -.scroll-container::-webkit-scrollbar-thumb { - background: #888; - border-radius: 4px; -} - -.scroll-container::-webkit-scrollbar-thumb:hover { - background: #555; -} - -/* 消息容器 */ -.messages { - padding: 20px; - background: #fafafa; -} - -.message-placeholder { - height: 80px; - display: flex; - align-items: center; - justify-content: center; - color: #999; - font-size: 14px; -} - -.loading, -.error, -.no-messages { - text-align: center; - padding: 60px 20px; - font-size: 16px; -} - -.loading { - color: #999; -} - -.error { - color: #d32f2f; -} - -.no-messages { - color: #999; -} - -/* 消息样式 */ -.message { - display: flex; - margin-bottom: 20px; - opacity: 1; - transition: opacity 0.2s; -} - -.message:last-child { - margin-bottom: 0; -} - -.message.sent { - flex-direction: row-reverse; -} - -.message .avatar { +.header-avatar { width: 40px; height: 40px; border-radius: 50%; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - flex-shrink: 0; - overflow: hidden; + background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; - color: white; - font-weight: 700; - font-size: 16px; - box-shadow: 0 2px 8px rgba(0,0,0,0.1); + font-size: 18px; + font-weight: 600; + flex-shrink: 0; } -.message .avatar img { +.header-info { + min-width: 0; +} + +.header-info h1 { + font-size: 16px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.header-meta { + font-size: 12px; + opacity: 0.8; +} + +.header-actions { + display: flex; + gap: 4px; +} + +.icon-btn { + background: none; + border: none; + color: var(--header-text); + font-size: 18px; + cursor: pointer; + padding: 8px; + border-radius: 50%; + transition: background 0.2s; + line-height: 1; +} + +.icon-btn:hover { + background: rgba(255,255,255,0.15); +} + +/* 搜索栏 */ +.search-bar { + background: var(--header-bg); + padding: 0 16px 10px; + display: none; + align-items: center; + gap: 8px; +} + +.search-bar.active { + display: flex; +} + +.search-bar input { + flex: 1; + padding: 8px 12px; + border: none; + border-radius: 8px; + background: rgba(255,255,255,0.15); + color: var(--header-text); + font-size: 14px; + outline: none; +} + +.search-bar input::placeholder { + color: rgba(255,255,255,0.5); +} + +#searchCount { + color: rgba(255,255,255,0.7); + font-size: 12px; + white-space: nowrap; +} + +#clearSearch { + background: none; + border: none; + color: rgba(255,255,255,0.7); + font-size: 16px; + cursor: pointer; + padding: 4px 8px; +} + +/* 聊天体 */ +.chat-body { + flex: 1; + overflow-y: auto; + background: var(--chat-bg); + padding: 8px 0; +} + +.chat-body::-webkit-scrollbar { width: 6px; } +.chat-body::-webkit-scrollbar-track { background: transparent; } +.chat-body::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.2); border-radius: 3px; } + +/* 日期分割线 */ +.date-divider { + text-align: center; + padding: 12px 0 8px; +} + +.date-divider span { + background: var(--system-bg); + color: var(--system-text); + padding: 4px 12px; + border-radius: 8px; + font-size: 12px; + font-weight: 500; +} + +/* 系统消息 */ +.system-msg { + text-align: center; + padding: 4px 60px; + margin: 2px 0; +} + +.system-msg span { + background: var(--system-bg); + color: var(--system-text); + padding: 4px 12px; + border-radius: 8px; + font-size: 12px; + display: inline-block; + max-width: 100%; + word-break: break-word; +} + +/* 消息行 */ +.msg-row { + display: flex; + padding: 1px 10px; + align-items: flex-end; + gap: 6px; +} + +.msg-row.sent { + flex-direction: row-reverse; +} + +/* 头像 */ +.msg-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + flex-shrink: 0; + overflow: hidden; + background: #dfe5e7; + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + font-weight: 600; + color: #fff; + align-self: flex-start; + margin-top: 2px; +} + +.msg-avatar img { width: 100%; height: 100%; object-fit: cover; } -.message .content-wrapper { +.msg-avatar.c0 { background: #25d366; } +.msg-avatar.c1 { background: #128c7e; } +.msg-avatar.c2 { background: #075e54; } +.msg-avatar.c3 { background: #34b7f1; } +.msg-avatar.c4 { background: #00a884; } +.msg-avatar.c5 { background: #7c5cbf; } +.msg-avatar.c6 { background: #e67e22; } +.msg-avatar.c7 { background: #e74c3c; } + +/* 气泡 */ +.msg-bubble { max-width: 65%; - margin: 0 10px; + min-width: 80px; } -.message.sent .content-wrapper { - display: flex; - flex-direction: column; - align-items: flex-end; -} - -.message .sender-name { +.msg-sender { font-size: 12px; - color: #666; - margin-bottom: 4px; + color: var(--link); font-weight: 500; - line-height: 1.2; + margin-bottom: 1px; + padding: 0 4px; } -.message .bubble { - background: white; - padding: 10px 14px; - border-radius: 12px; - word-wrap: break-word; +.bubble-body { + background: var(--bubble-recv); + padding: 6px 8px 4px; + border-radius: 8px; + position: relative; + box-shadow: 0 1px 1px var(--shadow); word-break: break-word; white-space: pre-wrap; - overflow-wrap: break-word; - position: relative; - box-shadow: 0 1px 4px rgba(0,0,0,0.08); - transition: box-shadow 0.2s; - max-width: 100%; - line-height: 1.5; + font-size: 14px; } -.message .bubble:hover { - box-shadow: 0 2px 8px rgba(0,0,0,0.12); +.msg-row.sent .bubble-body { + background: var(--bubble-send); } -.message.sent .bubble { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; - box-shadow: 0 2px 8px rgba(102, 126, 234, 0.25); +.msg-text { + line-height: 1.4; } -.message .time { +.msg-time { font-size: 11px; - color: #999; - margin-top: 4px; - line-height: 1.2; + color: var(--text-time); + text-align: right; + margin-top: 2px; + white-space: nowrap; } -.message.sent .time { - text-align: right; +/* 媒体样式 */ +.msg-image { + cursor: pointer; + border-radius: 6px; + max-width: 300px; + max-height: 300px; + display: block; + object-fit: contain; + background: var(--media-bg); +} + +.msg-image.broken { + width: 200px; + height: 60px; + display: flex; + align-items: center; + justify-content: center; + background: var(--media-bg); + color: var(--text-secondary); + font-size: 12px; + border-radius: 6px; +} + +.msg-video { + max-width: 320px; + max-height: 240px; + border-radius: 6px; + background: #000; +} + +/* 表情包 */ +.msg-emoji { + max-width: 120px; + max-height: 120px; + display: block; + cursor: pointer; +} + +/* 语音播放器 */ +.msg-voice { + display: flex; + flex-direction: column; + gap: 4px; +} + +.msg-voice audio { + height: 32px; + max-width: 240px; +} + +.msg-voice .voice-text { + font-size: 12px; + color: var(--secondary-text); + opacity: 0.8; } /* 聊天记录引用 */ .chat-records { - margin-top: 8px; - padding: 8px 10px; + margin-top: 4px; + padding: 6px 8px; background: rgba(0,0,0,0.04); - border-radius: 8px; - border-left: 3px solid #667eea; -} - -.message.sent .chat-records { - background: rgba(255,255,255,0.15); - border-left-color: rgba(255,255,255,0.6); -} - -.chat-records .title { - font-size: 12px; - font-weight: 700; - margin-bottom: 6px; - color: #667eea; - line-height: 1.2; -} - -.message.sent .chat-records .title { - color: rgba(255,255,255,0.95); -} - -.chat-record-item { - font-size: 12px; - padding: 6px 0; - border-bottom: 1px solid rgba(0,0,0,0.06); - line-height: 1.4; -} - -.chat-record-item:last-child { - border-bottom: none; - padding-bottom: 0; -} - -.chat-record-item .record-sender { - font-weight: 600; - color: #333; -} - -.message.sent .chat-record-item .record-sender { - color: rgba(255,255,255,0.95); -} - -.chat-record-item .record-time { - font-size: 10px; - color: #999; - margin-left: 8px; -} - -.message.sent .chat-record-item .record-time { - color: rgba(255,255,255,0.75); -} - -.chat-record-item .record-content { - margin-top: 2px; - color: #666; - line-height: 1.4; - word-wrap: break-word; - word-break: break-word; - white-space: pre-wrap; - overflow-wrap: break-word; -} - -.message.sent .chat-record-item .record-content { - color: rgba(255,255,255,0.9); -} - -/* 页脚 */ -.footer { - text-align: center; - padding: 24px; - color: #999; + border-radius: 6px; + border-left: 3px solid var(--link); font-size: 13px; - border-top: 2px solid #f0f0f0; - background: #fafafa; } -/* 响应式设计 */ -@media (max-width: 768px) { - body { - padding: 10px; - } - - .container { - border-radius: 12px; - } - - .header { - padding: 30px 20px; - } - - .header h1 { - font-size: 24px; - } - - .controls { - padding: 15px; - } - - .controls input[type="text"] { - min-width: 100%; - } - - .controls .stats { - width: 100%; - justify-content: center; - margin-left: 0; - margin-top: 10px; - } - - .scroll-container { - height: calc(100vh - 320px); - } - - .messages { - padding: 20px 15px; - } - - .message .content-wrapper { - max-width: 75%; - } +[data-theme="dark"] .chat-records { + background: rgba(255,255,255,0.05); } -/* 打印样式 */ -@media print { - body { - background: white; - padding: 0; - } - - .container { - box-shadow: none; - border-radius: 0; - } - - .controls { - display: none; - } - - .message { - page-break-inside: avoid; - } -}`; +.chat-records .cr-title { + font-size: 12px; + font-weight: 600; + color: var(--link); + margin-bottom: 4px; +} + +.cr-item { + padding: 3px 0; + border-bottom: 1px solid rgba(0,0,0,0.05); +} + +.cr-item:last-child { border-bottom: none; } + +.cr-item .cr-sender { + font-weight: 600; + font-size: 12px; +} + +.cr-item .cr-time { + font-size: 10px; + color: var(--text-secondary); + margin-left: 6px; +} + +.cr-item .cr-content { + color: var(--text-secondary); + font-size: 12px; + margin-top: 1px; +} + +/* 底部 */ +.chat-footer { + background: var(--bg); + text-align: center; + padding: 10px; + font-size: 12px; + color: var(--text-secondary); + border-top: 1px solid var(--border); + flex-shrink: 0; +} + +/* 加载指示器 */ +.loading-indicator { + text-align: center; + padding: 20px; + color: var(--text-secondary); + font-size: 13px; + display: none; +} + +.loading-indicator.active { display: block; } + +/* 图片预览 */ +.lightbox { + display: none; + position: fixed; + inset: 0; + background: rgba(0,0,0,0.9); + z-index: 1000; + align-items: center; + justify-content: center; + cursor: zoom-out; +} + +.lightbox.active { + display: flex; +} + +.lightbox img { + max-width: 95vw; + max-height: 95vh; + object-fit: contain; + border-radius: 4px; +} + +.lightbox-close { + position: absolute; + top: 16px; + right: 20px; + background: none; + border: none; + color: #fff; + font-size: 28px; + cursor: pointer; + z-index: 1001; + opacity: 0.7; +} + +.lightbox-close:hover { opacity: 1; } + +/* 响应式 */ +@media (max-width: 600px) { + .msg-bubble { max-width: 80%; } + .msg-image { max-width: 220px; } + .msg-video { max-width: 260px; } + .msg-emoji { max-width: 100px; } +} +` } /** - * 生成数据 JS 文件(作为全局变量) - */ - static generateDataJs(exportData: HtmlExportData): string { - return `// CipherTalk 聊天记录数据 -window.CHAT_DATA = ${JSON.stringify(exportData, null, 2)};`; - } - - /** - * 生成外部 JavaScript 文件 + * 生成 JavaScript 逻辑 */ static generateJs(): string { - return `// CipherTalk 聊天记录导出应用 + return ` +(function() { + const data = window.CHAT_DATA; + const messages = data.messages; + const members = {}; + data.members.forEach(m => { members[m.id] = m; }); -class ChatApp { - constructor() { - this.allData = window.CHAT_DATA; - this.filteredMessages = this.allData.messages; - - // 无感加载配置 - this.batchSize = 30; // 每次加载30条 - this.loadedCount = 0; // 已加载数量 - this.isLoading = false; // 是否正在加载 - - // DOM 元素 - this.scrollContainer = null; - this.messagesContainer = null; - this.loadMoreObserver = null; - this.sentinel = null; // 哨兵元素 - - this.init(); - } + const chatBody = document.getElementById('chatBody'); + const container = document.getElementById('messagesContainer'); + const loadingEl = document.getElementById('loadingIndicator'); + const lightbox = document.getElementById('lightbox'); + const lightboxImg = document.getElementById('lightboxImg'); - init() { - try { - if (!this.allData) { - throw new Error('数据加载失败'); - } - - // 获取DOM元素 - this.scrollContainer = document.getElementById('scrollContainer'); - this.messagesContainer = document.getElementById('messagesContainer'); - - // 清空容器 - this.messagesContainer.innerHTML = ''; - - // 绑定事件 - this.bindEvents(); - - // 设置 Intersection Observer(必须在 loadMoreMessages 之前) - this.setupIntersectionObserver(); - - // 初始加载 - this.loadMoreMessages(); - - // 更新统计信息 - this.updateStats(); - } catch (error) { - console.error('初始化失败:', error); - document.getElementById('messagesContainer').innerHTML = - \`
加载失败: \${error.message}
\`; - } - } + let filteredMessages = messages; + let loadedCount = 0; + const BATCH = 50; + let isLoading = false; - bindEvents() { - // 搜索框回车 - const searchInput = document.getElementById('searchInput'); - searchInput.addEventListener('keypress', (e) => { - if (e.key === 'Enter') { - this.searchMessages(); - } - }); - } + // 主题切换 + document.getElementById('themeToggle').addEventListener('click', () => { + const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; + document.documentElement.setAttribute('data-theme', isDark ? '' : 'dark'); + }); - setupIntersectionObserver() { - // 创建哨兵元素 - this.sentinel = document.createElement('div'); - this.sentinel.className = 'message-placeholder'; - this.sentinel.textContent = '加载中...'; - this.sentinel.style.display = 'none'; - - // 创建 Intersection Observer - this.loadMoreObserver = new IntersectionObserver((entries) => { - entries.forEach(entry => { - if (entry.isIntersecting && !this.isLoading) { - this.loadMoreMessages(); - } - }); - }, { - root: this.scrollContainer, - rootMargin: '200px', // 提前200px开始加载 - threshold: 0.1 - }); - } + // 搜索 + const searchBar = document.getElementById('searchBar'); + const searchInput = document.getElementById('searchInput'); + const searchCount = document.getElementById('searchCount'); - loadMoreMessages() { - if (this.isLoading) return; - if (this.loadedCount >= this.filteredMessages.length) { - // 所有消息已加载完毕 - if (this.sentinel && this.sentinel.parentNode) { - this.sentinel.remove(); - } - return; - } - - this.isLoading = true; - - // 计算本次加载的范围 - const start = this.loadedCount; - const end = Math.min(start + this.batchSize, this.filteredMessages.length); - const batch = this.filteredMessages.slice(start, end); - - // 创建文档片段 - const fragment = document.createDocumentFragment(); - - // 渲染消息 - batch.forEach(msg => { - const messageElement = this.createMessageElement(msg); - fragment.appendChild(messageElement); - }); - - // 移除旧的哨兵 - if (this.sentinel && this.sentinel.parentNode) { - this.sentinel.remove(); - } - - // 添加消息到容器 - this.messagesContainer.appendChild(fragment); - - // 更新已加载数量 - this.loadedCount = end; - - // 如果还有更多消息,添加哨兵 - if (this.loadedCount < this.filteredMessages.length) { - this.sentinel.style.display = 'flex'; - this.messagesContainer.appendChild(this.sentinel); - - // 观察哨兵 - this.loadMoreObserver.observe(this.sentinel); - } - - this.isLoading = false; - this.updateStats(); - } + document.getElementById('searchToggle').addEventListener('click', () => { + searchBar.classList.toggle('active'); + if (searchBar.classList.contains('active')) searchInput.focus(); + }); - createMessageElement(msg) { - const div = document.createElement('div'); - div.className = msg.isSend ? 'message sent' : 'message'; - div.innerHTML = this.renderMessage(msg); - return div; - } + let searchTimer; + searchInput.addEventListener('input', () => { + clearTimeout(searchTimer); + searchTimer = setTimeout(doSearch, 300); + }); - renderMessage(msg) { - const member = this.allData.members.find(m => m.id === msg.sender); - const senderName = member ? member.name : msg.senderName; - const avatar = member && member.avatar ? member.avatar : null; - const time = new Date(msg.timestamp * 1000).toLocaleString('zh-CN'); - - // 生成头像 - let avatarHtml = ''; - if (avatar) { - avatarHtml = \`\${this.escapeHtml(senderName)}\`; + document.getElementById('clearSearch').addEventListener('click', () => { + searchInput.value = ''; + doSearch(); + }); + + function doSearch() { + const q = searchInput.value.trim().toLowerCase(); + if (!q) { + filteredMessages = messages; + searchCount.textContent = ''; } else { - avatarHtml = senderName.charAt(0).toUpperCase(); - } - - // 生成消息内容 - let contentHtml = msg.content ? this.escapeHtml(msg.content) : '无内容'; - - // 如果有聊天记录,添加聊天记录展示 - let chatRecordsHtml = ''; - if (msg.chatRecords && msg.chatRecords.length > 0) { - chatRecordsHtml = '
'; - chatRecordsHtml += '
📋 聊天记录引用
'; - for (const record of msg.chatRecords) { - chatRecordsHtml += \` -
-
- \${this.escapeHtml(record.senderDisplayName)} - \${this.escapeHtml(record.formattedTime)} -
-
\${this.escapeHtml(record.content)}
-
- \`; - } - chatRecordsHtml += '
'; - } - - return \` -
\${avatarHtml}
-
-
\${this.escapeHtml(senderName)}
-
- \${contentHtml} - \${chatRecordsHtml} -
-
\${time}
-
- \`; - } - - searchMessages() { - const keyword = document.getElementById('searchInput').value.trim().toLowerCase(); - if (!keyword) { - this.filteredMessages = this.allData.messages; - } else { - this.filteredMessages = this.allData.messages.filter(msg => { - // 搜索消息内容 - if (msg.content && msg.content.toLowerCase().includes(keyword)) { - return true; - } - // 搜索发送者名称 - const member = this.allData.members.find(m => m.id === msg.sender); - const senderName = member ? member.name : msg.senderName; - if (senderName.toLowerCase().includes(keyword)) { - return true; - } - // 搜索聊天记录内容 - if (msg.chatRecords) { - for (const record of msg.chatRecords) { - if (record.content.toLowerCase().includes(keyword) || - record.senderDisplayName.toLowerCase().includes(keyword)) { - return true; - } - } - } + filteredMessages = messages.filter(m => { + if (m.content && m.content.toLowerCase().includes(q)) return true; + const mem = members[m.sender]; + if (mem && mem.name.toLowerCase().includes(q)) return true; + if (m.senderName && m.senderName.toLowerCase().includes(q)) return true; return false; }); + searchCount.textContent = filteredMessages.length + ' 条结果'; } - - // 重置并重新加载 - this.reset(); + loadedCount = 0; + container.innerHTML = ''; + loadMore(); } - clearSearch() { - document.getElementById('searchInput').value = ''; - this.filteredMessages = this.allData.messages; - this.reset(); + // 图片灯箱 + lightbox.addEventListener('click', () => lightbox.classList.remove('active')); + document.getElementById('lightboxClose').addEventListener('click', (e) => { + e.stopPropagation(); + lightbox.classList.remove('active'); + }); + + function openLightbox(src) { + lightboxImg.src = src; + lightbox.classList.add('active'); } - reset() { - // 停止观察 - if (this.loadMoreObserver && this.sentinel && this.sentinel.parentNode) { - this.loadMoreObserver.unobserve(this.sentinel); + // 媒体加载失败处理 + function imgError(el, label) { + var div = document.createElement('div'); + div.className = 'msg-image broken'; + div.textContent = label; + el.replaceWith(div); + } + + // 颜色分配 + function avatarColor(id) { + let hash = 0; + for (let i = 0; i < id.length; i++) hash = ((hash << 5) - hash) + id.charCodeAt(i); + return 'c' + (Math.abs(hash) % 8); + } + + // HTML 转义 + function esc(text) { + const d = document.createElement('div'); + d.textContent = text; + return d.innerHTML; + } + + // 格式化时间 + function fmtTime(ts) { + const d = new Date(ts * 1000); + const h = String(d.getHours()).padStart(2, '0'); + const m = String(d.getMinutes()).padStart(2, '0'); + return h + ':' + m; + } + + function fmtDate(ts) { + const d = new Date(ts * 1000); + return d.getFullYear() + '年' + (d.getMonth() + 1) + '月' + d.getDate() + '日' + + ' 星期' + '日一二三四五六'[d.getDay()]; + } + + // 渲染消息内容(处理图片/视频路径) + function renderContent(msg) { + const content = msg.content; + if (!content) return '无内容'; + + // 图片消息:[图片] images/xxx.jpg + const imgMatch = content.match(/^\\[图片\\]\\s+(.+)$/); + if (imgMatch) { + const src = imgMatch[1]; + return ''; } - - // 清空容器 - this.messagesContainer.innerHTML = ''; - - // 重置状态 - this.loadedCount = 0; - this.isLoading = false; - - // 滚动到顶部 - this.scrollContainer.scrollTop = 0; - - // 重新设置观察器(必须在 loadMoreMessages 之前) - this.setupIntersectionObserver(); - - // 重新加载 - this.loadMoreMessages(); + // 仅 [图片] 无路径 + if (content === '[图片]') return '
📷 图片
'; + + // 视频消息:[视频] videos/xxx.mp4 + const vidMatch = content.match(/^\\[视频\\]\\s+(.+)$/); + if (vidMatch) { + const src = vidMatch[1]; + return ''; + } + if (content === '[视频]') return '
🎥 视频
'; + + // 动画表情:[动画表情] emojis/xxx.gif + const emojiMatch = content.match(/^\\[动画表情\\]\\s+(.+)$/); + if (emojiMatch) { + const src = emojiMatch[1]; + return ''; + } + if (content === '[动画表情]') return '
😀 表情
'; + + // 语音消息:[语音消息] voices/xxx.wav [转写文字] + const voiceMatch = content.match(/^\\[语音消息\\]\\s+(voices\\/[^\\s]+)(?:\\s+([\\s\\S]+))?$/); + if (voiceMatch) { + const src = voiceMatch[1]; + const transcript = voiceMatch[2] || ''; + let html = '
'; + html += ''; + if (transcript) html += '
' + esc(transcript) + '
'; + html += '
'; + return html; + } + if (content === '[语音消息]') return '
🎙️ 语音
'; + + return '' + esc(content) + ''; } - updateStats() { - const totalCount = this.filteredMessages.length; - document.getElementById('messageStats').textContent = \`共 \${totalCount} 条消息\`; - document.getElementById('loadedStats').textContent = \`已加载 \${this.loadedCount} 条\`; + // 渲染聊天记录引用 + function renderChatRecords(records) { + if (!records || records.length === 0) return ''; + let html = '
📋 聊天记录
'; + for (const r of records) { + html += '
'; + html += '' + esc(r.senderDisplayName) + ''; + if (r.formattedTime) html += '' + esc(r.formattedTime) + ''; + html += '
' + esc(r.content) + '
'; + } + return html + '
'; } - escapeHtml(text) { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; - } -} + // 渲染单条消息 + function renderMsg(msg, prevMsg) { + let html = ''; -// 初始化应用 -const app = new ChatApp();`; + // 日期分割线 + if (!prevMsg || fmtDate(msg.timestamp) !== fmtDate(prevMsg.timestamp)) { + html += '
' + fmtDate(msg.timestamp) + '
'; + } + + // 系统消息 + if (msg.type === 10000 || msg.type === 266287972401) { + html += '
' + esc(msg.content || '') + '
'; + return html; + } + + const mem = members[msg.sender]; + const name = mem ? mem.name : (msg.senderName || msg.sender); + const avatar = mem && mem.avatar ? mem.avatar : null; + const isGroup = data.meta.isGroup; + const isSend = msg.isSend; + + html += '
'; + + // 头像 + html += '
'; + if (avatar) { + html += ''; + } else { + html += esc(name.charAt(0)); + } + html += '
'; + + // 气泡 + html += '
'; + if (isGroup && !isSend) { + html += '
' + esc(name) + '
'; + } + html += '
'; + html += renderContent(msg); + if (msg.chatRecords) html += renderChatRecords(msg.chatRecords); + html += '
' + fmtTime(msg.timestamp) + '
'; + html += '
'; + + return html; + } + + // 按批次加载 + function loadMore() { + if (isLoading || loadedCount >= filteredMessages.length) { + loadingEl.classList.remove('active'); + return; + } + isLoading = true; + loadingEl.classList.add('active'); + + requestAnimationFrame(() => { + const end = Math.min(loadedCount + BATCH, filteredMessages.length); + let html = ''; + for (let i = loadedCount; i < end; i++) { + const prev = i > 0 ? filteredMessages[i - 1] : null; + html += renderMsg(filteredMessages[i], prev); + } + container.insertAdjacentHTML('beforeend', html); + loadedCount = end; + isLoading = false; + + if (loadedCount >= filteredMessages.length) { + loadingEl.classList.remove('active'); + } + }); + } + + // 滚动加载 + chatBody.addEventListener('scroll', () => { + if (chatBody.scrollTop + chatBody.clientHeight >= chatBody.scrollHeight - 300) { + loadMore(); + } + }); + + // 全局函数 + window.__lightbox = openLightbox; + window.__imgError = imgError; + + // 初始加载 + loadMore(); +})(); +` + } + + /** + * 生成数据 JS 文件(兼容旧接口) + */ + static generateDataJs(exportData: HtmlExportData): string { + return `window.CHAT_DATA = ${JSON.stringify(exportData)};` } /** * 生成数据 JSON 文件 */ static generateDataJson(exportData: HtmlExportData): string { - return JSON.stringify(exportData, null, 2); + return JSON.stringify(exportData, null, 2) } /** @@ -862,4 +878,4 @@ const app = new ChatApp();`; } return text.replace(/[&<>"']/g, m => map[m]) } -} \ No newline at end of file +} diff --git a/electron/services/imageDecryptService.ts b/electron/services/imageDecryptService.ts index c516f35..4cb8862 100644 --- a/electron/services/imageDecryptService.ts +++ b/electron/services/imageDecryptService.ts @@ -51,12 +51,14 @@ export class ImageDecryptService { private updateFlags = new Map() async resolveCachedImage(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }): Promise { - await this.ensureCacheIndexed() + // 不再等待缓存索引,直接查找 const cacheKeys = this.getCacheKeys(payload) const cacheKey = cacheKeys[0] if (!cacheKey) { return { success: false, error: '缺少图片标识' } } + + // 1. 先检查内存缓存(最快) for (const key of cacheKeys) { const cached = this.resolvedCache.get(key) if (cached && existsSync(cached) && this.isImageFile(cached)) { @@ -76,8 +78,9 @@ export class ImageDecryptService { } } + // 2. 快速查找缓存文件(优先查找当前 sessionId 的最新日期目录) for (const key of cacheKeys) { - const existing = this.findCachedOutput(key, payload.sessionId) + const existing = this.findCachedOutputFast(key, payload.sessionId) if (existing) { this.cacheResolvedPaths(key, payload.imageMd5, payload.imageDatName, existing) const localPath = this.filePathToUrl(existing) @@ -92,11 +95,16 @@ export class ImageDecryptService { return { success: true, localPath, hasUpdate } } } + + // 3. 后台启动完整索引(不阻塞当前请求) + if (!this.cacheIndexed && !this.cacheIndexing) { + void this.ensureCacheIndexed() + } + return { success: false, error: '未找到缓存图片' } } async decryptImage(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }): Promise { - await this.ensureCacheIndexed() const cacheKey = payload.imageMd5 || payload.imageDatName if (!cacheKey) { return { success: false, error: '缺少图片标识' } @@ -104,8 +112,9 @@ export class ImageDecryptService { // 即使 force=true,也先检查是否有高清图缓存 if (payload.force) { - // 查找高清图缓存 - const hdCached = this.findCachedOutput(cacheKey, payload.sessionId, true) + // 快速查找高清图缓存 + const hdCached = this.findCachedOutputFast(cacheKey, payload.sessionId, true) || + this.findCachedOutput(cacheKey, payload.sessionId, true) if (hdCached && existsSync(hdCached) && this.isImageFile(hdCached)) { const localPath = this.filePathToUrl(hdCached) return { success: true, localPath, isThumb: false } @@ -379,14 +388,21 @@ export class ImageDecryptService { return hardlinkPath } // hardlink 找到的是缩略图,但要求高清图 - // 尝试在同一目录下查找高清图变体(快速查找,不遍历) + // 尝试在同一目录下查找高清图变体(快速查找) const hdPath = this.findHdVariantInSameDir(hardlinkPath) if (hdPath) { this.cacheDatPath(accountDir, imageMd5, hdPath) if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdPath) return hdPath } - // 没找到高清图,返回 null(不进行全局搜索) + // 同目录没找到高清图,尝试在该目录下搜索 + const hdInDir = await this.searchDatFileInDir(dirname(hardlinkPath), imageDatName || imageMd5 || '', false) + if (hdInDir) { + this.cacheDatPath(accountDir, imageMd5, hdInDir) + if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdInDir) + return hdInDir + } + // 该目录也没找到,返回 null(不进行全局搜索,避免性能问题) return null } } @@ -405,6 +421,12 @@ export class ImageDecryptService { this.cacheDatPath(accountDir, imageDatName, hdPath) return hdPath } + // 同目录没找到高清图,尝试在该目录下搜索 + const hdInDir = await this.searchDatFileInDir(dirname(hardlinkPath), imageDatName, false) + if (hdInDir) { + this.cacheDatPath(accountDir, imageDatName, hdInDir) + return hdInDir + } return null } } @@ -419,6 +441,9 @@ export class ImageDecryptService { // 缓存的是缩略图,尝试找高清图 const hdPath = this.findHdVariantInSameDir(cached) if (hdPath) return hdPath + // 同目录没找到,尝试在该目录下搜索 + const hdInDir = await this.searchDatFileInDir(dirname(cached), imageDatName, false) + if (hdInDir) return hdInDir } } @@ -1027,6 +1052,61 @@ export class ImageDecryptService { return null } + /** + * 快速查找缓存文件(直接构造路径,不遍历目录) + * 用于 resolveCachedImage,避免全局扫描 + */ + private findCachedOutputFast(cacheKey: string, sessionId?: string, preferHd: boolean = false): string | null { + if (!sessionId) return null + + const normalizedKey = this.normalizeDatBase(cacheKey.toLowerCase()) + const extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp'] + const allRoots = this.getAllCacheRoots() + + // 构造最近 3 个月的日期目录 + const now = new Date() + const recentMonths: string[] = [] + for (let i = 0; i < 3; i++) { + const d = new Date(now.getFullYear(), now.getMonth() - i, 1) + recentMonths.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`) + } + + // 直接构造路径并检查文件是否存在 + for (const root of allRoots) { + for (const dateDir of recentMonths) { + const imageDir = join(root, sessionId, dateDir) + + // 批量构造所有可能的路径 + const candidates: string[] = [] + + if (preferHd) { + // 优先高清图 + for (const ext of extensions) { + candidates.push(join(imageDir, `${normalizedKey}_hd${ext}`)) + } + for (const ext of extensions) { + candidates.push(join(imageDir, `${normalizedKey}_thumb${ext}`)) + } + } else { + // 优先缩略图 + for (const ext of extensions) { + candidates.push(join(imageDir, `${normalizedKey}_thumb${ext}`)) + } + for (const ext of extensions) { + candidates.push(join(imageDir, `${normalizedKey}_hd${ext}`)) + } + } + + // 检查文件是否存在 + for (const candidate of candidates) { + if (existsSync(candidate)) return candidate + } + } + } + + return null + } + /** * 清理旧的 .hevc 文件(ffmpeg 转换失败时遗留的) */ diff --git a/src/App.scss b/src/App.scss index 8c3c995..5c3f640 100644 --- a/src/App.scss +++ b/src/App.scss @@ -14,7 +14,11 @@ .content { flex: 1; overflow: auto; - padding: 24px; + padding: 24px 24px 0 24px; // 移除底部 padding + + &.no-overflow { + overflow: hidden; // 数据管理页面禁用外层滚动 + } } // 更新提示悬浮卡片 @@ -264,3 +268,66 @@ } } } + +// 下载进度胶囊 +.download-progress-capsule { + position: fixed; + top: 40px; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 12px; + padding: 8px 16px; + background: rgba(30, 30, 30, 0.9); + backdrop-filter: blur(8px); + border-radius: 20px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); + z-index: 2000; + color: white; + font-size: 13px; + font-weight: 500; + border: 1px solid rgba(255, 255, 255, 0.1); + animation: capsuleSlideDown 0.3s cubic-bezier(0.16, 1, 0.3, 1); + + .spin { + animation: spin 1s linear infinite; + color: var(--primary); + } + + span { + white-space: nowrap; + } + + .progress-bar-bg { + width: 100px; + height: 4px; + background: rgba(255, 255, 255, 0.2); + border-radius: 2px; + overflow: hidden; + } + + .progress-bar-fill { + height: 100%; + background: var(--primary); + border-radius: 2px; + transition: width 0.2s linear; + } +} + +@keyframes capsuleSlideDown { + from { + opacity: 0; + transform: translate(-50%, -20px); + } + to { + opacity: 1; + transform: translate(-50%, 0); + } +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + diff --git a/src/App.tsx b/src/App.tsx index 0aebf77..3500b47 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -31,7 +31,7 @@ import * as configService from './services/config' import { initTldList } from './utils/linkify' import LockScreen from './pages/LockScreen' import { useAuthStore } from './stores/authStore' -import { X, Shield } from 'lucide-react' +import { X, Shield, Loader2 } from 'lucide-react' import './App.scss' function App() { @@ -52,6 +52,7 @@ function App() { // 更新提示状态 const [updateInfo, setUpdateInfo] = useState<{ version: string; releaseNotes: string } | null>(null) + const [downloadProgress, setDownloadProgress] = useState(null) // 加载主题配置 useEffect(() => { @@ -153,6 +154,16 @@ function App() { } }, []) + // 监听下载进度 + useEffect(() => { + const removeDownloadListener = window.electronAPI.app.onDownloadProgress?.((progress) => { + setDownloadProgress(progress) + }) + return () => { + removeDownloadListener?.() + } + }, []) + const dismissUpdate = () => { setUpdateInfo(null) } @@ -436,8 +447,11 @@ function App() {
发现新版本
v{updateInfo.version} 已发布
- diff --git a/src/pages/DataManagementPage.scss b/src/pages/DataManagementPage.scss index a94a9bb..05a8520 100644 --- a/src/pages/DataManagementPage.scss +++ b/src/pages/DataManagementPage.scss @@ -45,6 +45,53 @@ display: flex; flex-direction: column; gap: 24px; + height: calc(100vh - 140px); // 设置固定高度 + overflow-y: auto; // 启用滚动 + overflow-x: hidden; + + // 滚动条样式 + &::-webkit-scrollbar { + width: 8px; + } + + &::-webkit-scrollbar-track { + background: var(--bg-tertiary); + border-radius: 4px; + } + + &::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 4px; + + &:hover { + background: var(--text-tertiary); + } + } +} + +.media-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 16px; + + h2 { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + margin: 0 0 4px; + } + + .section-desc { + font-size: 13px; + color: var(--text-tertiary); + margin: 0; + } + + .section-actions { + display: flex; + gap: 10px; + } } .page-section { @@ -127,6 +174,15 @@ } } +.btn-danger { + background: #ef4444; + color: white; + + &:hover:not(:disabled) { + background: #dc2626; + } +} + .database-list { display: flex; flex-direction: column; @@ -318,15 +374,17 @@ } } -// 媒体网格样式 +// 媒体网格样式 - 直接显示在主区域 .media-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); // 自动填充,充分利用空间 gap: 12px; - max-height: 600px; - overflow-y: auto; padding: 4px; + max-height: calc(100vh - 200px); // 设置最大高度 + overflow-y: auto; // 启用垂直滚动 + overflow-x: hidden; + // 滚动条样式 &::-webkit-scrollbar { width: 8px; } @@ -346,7 +404,7 @@ } &.emoji-grid { - grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); // 表情包也自适应 } } @@ -364,6 +422,10 @@ transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); border-color: var(--primary); + + .media-actions { + opacity: 1; + } } &.pending { @@ -381,6 +443,7 @@ width: 100%; height: 100%; object-fit: cover; + display: block; } .media-placeholder { @@ -402,6 +465,49 @@ } } + .media-actions { + position: absolute; + top: 8px; + right: 8px; + display: flex; + gap: 6px; + opacity: 0; + transition: opacity 0.2s; + + .action-btn { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border: none; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s; + backdrop-filter: blur(8px); + + &.download-btn { + background: rgba(59, 130, 246, 0.9); + color: white; + + &:hover { + background: rgba(37, 99, 235, 1); + transform: scale(1.1); + } + } + + &.delete-btn { + background: rgba(239, 68, 68, 0.9); + color: white; + + &:hover { + background: rgba(220, 38, 38, 1); + transform: scale(1.1); + } + } + } + } + .media-info { position: absolute; bottom: 0; @@ -435,8 +541,6 @@ } .emoji-item { - aspect-ratio: 1; - img { padding: 12px; object-fit: contain; @@ -452,6 +556,26 @@ } } +.loading-more { + grid-column: 1 / -1; + text-align: center; + padding: 16px; + font-size: 13px; + color: var(--text-tertiary); + background: var(--bg-tertiary); + border-radius: 8px; + animation: pulse 1.5s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.6; + } +} + .more-hint { grid-column: 1 / -1; text-align: center; @@ -494,6 +618,7 @@ align-items: center; justify-content: center; z-index: 1000; + backdrop-filter: blur(4px); .progress-card { background: var(--bg-primary); @@ -542,6 +667,95 @@ } } +.delete-confirm-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + backdrop-filter: blur(4px); + animation: fadeIn 0.2s ease; + + .delete-confirm-card { + background: var(--bg-primary); + border-radius: 16px; + padding: 28px 32px; + min-width: 400px; + max-width: 500px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); + animation: slideUp 0.2s ease; + + h3 { + margin: 0 0 16px; + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + text-align: center; + } + + .confirm-message { + margin: 0 0 12px; + font-size: 15px; + color: var(--text-primary); + text-align: center; + } + + .confirm-detail { + margin: 0 0 8px; + font-size: 13px; + color: var(--text-secondary); + text-align: center; + padding: 8px 12px; + background: var(--bg-tertiary); + border-radius: 8px; + word-break: break-all; + } + + .confirm-warning { + margin: 0 0 24px; + font-size: 12px; + color: #ef4444; + text-align: center; + font-weight: 500; + } + + .confirm-actions { + display: flex; + gap: 12px; + justify-content: center; + + .btn { + min-width: 100px; + } + } + } +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + // 图片列表样式 .current-dir { diff --git a/src/pages/DataManagementPage.tsx b/src/pages/DataManagementPage.tsx index 6174463..85b50b9 100644 --- a/src/pages/DataManagementPage.tsx +++ b/src/pages/DataManagementPage.tsx @@ -1,6 +1,6 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' import { useLocation } from 'react-router-dom' -import { Database, Check, Circle, Unlock, RefreshCw, RefreshCcw, Image as ImageIcon, Smile } from 'lucide-react' +import { Database, Check, Circle, Unlock, RefreshCw, RefreshCcw, Image as ImageIcon, Smile, Download, Trash2 } from 'lucide-react' import './DataManagementPage.scss' interface DatabaseFile { @@ -22,6 +22,11 @@ interface ImageFileInfo { version: number } +interface DeleteConfirmData { + image: ImageFileInfo + show: boolean +} + type TabType = 'database' | 'images' | 'emojis' function DataManagementPage() { @@ -29,11 +34,21 @@ function DataManagementPage() { const [databases, setDatabases] = useState([]) const [images, setImages] = useState([]) const [emojis, setEmojis] = useState([]) + const [imageCount, setImageCount] = useState({ total: 0, decrypted: 0 }) + const [emojiCount, setEmojiCount] = useState({ total: 0, decrypted: 0 }) const [isLoading, setIsLoading] = useState(false) const [isDecrypting, setIsDecrypting] = useState(false) const [message, setMessage] = useState<{ text: string; success: boolean } | null>(null) const [progress, setProgress] = useState(null) + const [deleteConfirm, setDeleteConfirm] = useState({ image: null as any, show: false }) const location = useLocation() + + // 懒加载相关状态 + const [displayedImageCount, setDisplayedImageCount] = useState(20) + const [displayedEmojiCount, setDisplayedEmojiCount] = useState(20) + const imageGridRef = useRef(null) + const emojiGridRef = useRef(null) + const loadMoreThreshold = 300 // 距离底部多少像素时加载更多 const loadDatabases = useCallback(async () => { setIsLoading(true) @@ -66,35 +81,36 @@ function DataManagementPage() { return } - // 扫描第一个目录的图片 - const firstDir = dirsResult.directories[0] - console.log('[DataManagement] 扫描目录:', firstDir.path) - - const result = await window.electronAPI.dataManagement.scanImages(firstDir.path) - console.log('[DataManagement] 扫描结果:', result) - - if (result.success && result.images) { - console.log('[DataManagement] 找到图片数量:', result.images.length) - - // 分离图片和表情包 - const imageList: ImageFileInfo[] = [] - const emojiList: ImageFileInfo[] = [] - - result.images.forEach(img => { - console.log('[DataManagement] 处理图片:', img.fileName, '路径:', img.filePath) - // 根据路径判断是否是表情包 - if (img.filePath.includes('CustomEmotions') || img.filePath.includes('emoji')) { - emojiList.push(img) - } else { - imageList.push(img) - } - }) - - console.log('[DataManagement] 图片分类完成 - 普通图片:', imageList.length, '表情包:', emojiList.length) - setImages(imageList) - setEmojis(emojiList) - } else { - showMessage(result.error || '扫描图片失败', false) + // 找到 images 和 Emojis 目录 + const imagesDir = dirsResult.directories.find(d => d.path.includes('images')) + const emojisDir = dirsResult.directories.find(d => d.path.includes('Emojis')) + + // 扫描普通图片 + if (imagesDir) { + console.log('[DataManagement] 扫描图片目录:', imagesDir.path) + const result = await window.electronAPI.dataManagement.scanImages(imagesDir.path) + if (result.success && result.images) { + console.log('[DataManagement] 找到普通图片:', result.images.length, '个') + setImages(result.images) + setImageCount({ + total: result.images.length, + decrypted: result.images.filter(img => img.isDecrypted).length + }) + } + } + + // 扫描表情包 + if (emojisDir) { + console.log('[DataManagement] 扫描表情包目录:', emojisDir.path) + const result = await window.electronAPI.dataManagement.scanImages(emojisDir.path) + if (result.success && result.images) { + console.log('[DataManagement] 找到表情包:', result.images.length, '个') + setEmojis(result.images) + setEmojiCount({ + total: result.images.length, + decrypted: result.images.filter(emoji => emoji.isDecrypted).length + }) + } } } catch (e) { console.error('[DataManagement] 扫描图片异常:', e) @@ -104,6 +120,46 @@ function DataManagementPage() { } }, []) + // 页面加载时预加载图片数量(不加载详细数据) + useEffect(() => { + const loadImageCounts = async () => { + try { + const dirsResult = await window.electronAPI.dataManagement.getImageDirectories() + if (dirsResult.success && dirsResult.directories && dirsResult.directories.length > 0) { + // 找到 images 和 Emojis 目录 + const imagesDir = dirsResult.directories.find(d => d.path.includes('images')) + const emojisDir = dirsResult.directories.find(d => d.path.includes('Emojis')) + + // 扫描普通图片数量 + if (imagesDir) { + const result = await window.electronAPI.dataManagement.scanImages(imagesDir.path) + if (result.success && result.images) { + setImageCount({ + total: result.images.length, + decrypted: result.images.filter(img => img.isDecrypted).length + }) + } + } + + // 扫描表情包数量 + if (emojisDir) { + const result = await window.electronAPI.dataManagement.scanImages(emojisDir.path) + if (result.success && result.images) { + setEmojiCount({ + total: result.images.length, + decrypted: result.images.filter(emoji => emoji.isDecrypted).length + }) + } + } + } + } catch (e) { + console.error('[DataManagement] 预加载图片数量失败:', e) + } + } + + loadImageCounts() + }, []) + useEffect(() => { if (activeTab === 'database') { loadDatabases() @@ -273,6 +329,9 @@ function DataManagementPage() { loadDatabases() } else if (activeTab === 'images' || activeTab === 'emojis') { loadImages() + // 重置懒加载计数 + setDisplayedImageCount(20) + setDisplayedEmojiCount(20) } } @@ -290,12 +349,142 @@ function DataManagementPage() { } } + const handleDownloadImage = async (e: React.MouseEvent, image: ImageFileInfo) => { + e.stopPropagation() // 阻止触发点击打开图片 + + if (!image.isDecrypted || !image.decryptedPath) { + showMessage('图片未解密,无法下载', false) + return + } + + try { + // 直接使用浏览器的下载功能 + const link = document.createElement('a') + link.href = image.decryptedPath.startsWith('file://') + ? image.decryptedPath + : `file:///${image.decryptedPath.replace(/\\/g, '/')}` + link.download = image.fileName + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + showMessage('下载成功', true) + } catch (e) { + showMessage(`下载失败: ${e}`, false) + } + } + + const handleDeleteImage = async (e: React.MouseEvent, image: ImageFileInfo) => { + e.stopPropagation() // 阻止触发点击打开图片 + + if (!image.isDecrypted || !image.decryptedPath) { + showMessage('图片未解密,无法删除', false) + return + } + + // 显示自定义确认对话框 + setDeleteConfirm({ image, show: true }) + } + + const confirmDelete = async () => { + const image = deleteConfirm.image + setDeleteConfirm({ image: null as any, show: false }) + + try { + // 删除解密后的文件 + const result = await window.electronAPI.file.delete(image.decryptedPath!) + + if (result.success) { + showMessage('删除成功', true) + // 刷新列表 + await loadImages() + } else { + showMessage(`删除失败: ${result.error}`, false) + } + } catch (e) { + showMessage(`删除失败: ${e}`, false) + } + } + + const cancelDelete = () => { + setDeleteConfirm({ image: null as any, show: false }) + } + + // 懒加载:监听滚动事件 + useEffect(() => { + const handleScroll = (e: Event) => { + const target = e.target as HTMLDivElement + const scrollTop = target.scrollTop + const scrollHeight = target.scrollHeight + const clientHeight = target.clientHeight + + // 距离底部小于阈值时加载更多 + if (scrollHeight - scrollTop - clientHeight < loadMoreThreshold) { + if (activeTab === 'images' && displayedImageCount < images.length) { + setDisplayedImageCount(prev => Math.min(prev + 20, images.length)) + } else if (activeTab === 'emojis' && displayedEmojiCount < emojis.length) { + setDisplayedEmojiCount(prev => Math.min(prev + 20, emojis.length)) + } + } + } + + const imageGrid = imageGridRef.current + const emojiGrid = emojiGridRef.current + + if (activeTab === 'images' && imageGrid) { + imageGrid.addEventListener('scroll', handleScroll) + return () => imageGrid.removeEventListener('scroll', handleScroll) + } else if (activeTab === 'emojis' && emojiGrid) { + emojiGrid.addEventListener('scroll', handleScroll) + return () => emojiGrid.removeEventListener('scroll', handleScroll) + } + }, [activeTab, displayedImageCount, displayedEmojiCount, images.length, emojis.length]) + + // 检查是否需要加载更多(如果没有滚动条) + useEffect(() => { + const checkAndLoadMore = () => { + const grid = activeTab === 'images' ? imageGridRef.current : emojiGridRef.current + if (!grid) return + + const hasScroll = grid.scrollHeight > grid.clientHeight + const hasMore = activeTab === 'images' + ? displayedImageCount < images.length + : displayedEmojiCount < emojis.length + + // 如果没有滚动条且还有更多内容,继续加载 + if (!hasScroll && hasMore) { + if (activeTab === 'images') { + setDisplayedImageCount(prev => Math.min(prev + 20, images.length)) + } else { + setDisplayedEmojiCount(prev => Math.min(prev + 20, emojis.length)) + } + } + } + + // 延迟检查,等待 DOM 渲染完成 + const timer = setTimeout(checkAndLoadMore, 100) + return () => clearTimeout(timer) + }, [activeTab, displayedImageCount, displayedEmojiCount, images.length, emojis.length]) + + // 切换标签时重置懒加载计数 + useEffect(() => { + setDisplayedImageCount(20) + setDisplayedEmojiCount(20) + }, [activeTab]) + const pendingCount = databases.filter(db => !db.isDecrypted).length const decryptedCount = databases.filter(db => db.isDecrypted).length const needsUpdateCount = databases.filter(db => db.needsUpdate).length - const decryptedImagesCount = images.filter(img => img.isDecrypted).length - const decryptedEmojisCount = emojis.filter(emoji => emoji.isDecrypted).length + // 使用预加载的计数,如果已加载详细数据则使用详细数据的计数 + const displayImageCount = images.length > 0 ? images.length : imageCount.total + const displayDecryptedImagesCount = images.length > 0 + ? images.filter(img => img.isDecrypted).length + : imageCount.decrypted + + const displayEmojiCount = emojis.length > 0 ? emojis.length : emojiCount.total + const displayDecryptedEmojisCount = emojis.length > 0 + ? emojis.filter(emoji => emoji.isDecrypted).length + : emojiCount.decrypted return ( @@ -326,6 +515,25 @@ function DataManagementPage() {
)} + {deleteConfirm.show && ( +
+
e.stopPropagation()}> +

确认删除

+

确定要删除这张图片吗?

+

文件名: {deleteConfirm.image?.fileName}

+

此操作不可恢复!

+
+ + +
+
+
+ )} +

数据管理

@@ -341,20 +549,20 @@ function DataManagementPage() { onClick={() => setActiveTab('images')} > - 图片 ({decryptedImagesCount}/{images.length}) + 图片 ({displayDecryptedImagesCount}/{displayImageCount})
-
- {activeTab === 'database' && ( + {activeTab === 'database' && ( +
@@ -418,133 +626,171 @@ function DataManagementPage() { )}
- )} +
+ )} - {activeTab === 'images' && ( -
-
-
-

图片管理

-

- {isLoading ? '正在扫描...' : `共 ${images.length} 张图片,${decryptedImagesCount} 张已解密`} -

-
-
- -
+ {activeTab === 'images' && ( + <> +
+
+

图片管理

+

+ {isLoading ? '正在扫描...' : `共 ${displayImageCount} 张图片,${displayDecryptedImagesCount} 张已解密`} +

+
+ +
+
-
- {images.slice(0, 100).map((image, index) => ( -
handleImageClick(image)} - > - {image.isDecrypted && image.decryptedPath ? ( +
+ {images.slice(0, displayedImageCount).map((image, index) => ( +
handleImageClick(image)} + > + {image.isDecrypted && image.decryptedPath ? ( + <> {image.fileName} { console.error('[DataManagement] 图片加载失败:', image.decryptedPath) e.currentTarget.style.display = 'none' }} /> - ) : ( -
- - 未解密 +
+ +
- )} -
- {image.fileName} - {formatFileSize(image.fileSize)} + + ) : ( +
+ + 未解密
+ )} +
+ {image.fileName} + {formatFileSize(image.fileSize)}
- ))} - - {!isLoading && images.length === 0 && ( -
- -

未找到图片文件

-

请先解密数据库

-
- )} - - {images.length > 100 && ( -
- 仅显示前 100 张图片,共 {images.length} 张 -
- )} -
-
- )} - - {activeTab === 'emojis' && ( -
-
-
-

表情包管理

-

- {isLoading ? '正在扫描...' : `共 ${emojis.length} 个表情包,${decryptedEmojisCount} 个已解密`} -

-
- -
-
+ ))} -
- {emojis.slice(0, 100).map((emoji, index) => ( -
handleImageClick(emoji)} - > - {emoji.isDecrypted && emoji.decryptedPath ? ( + {!isLoading && images.length === 0 && ( +
+ +

未找到图片文件

+

请先解密数据库

+
+ )} + + {displayedImageCount < images.length && ( +
+ 加载中... ({displayedImageCount}/{images.length}) +
+ )} +
+ + )} + + {activeTab === 'emojis' && ( + <> +
+
+

表情包管理

+

+ {isLoading ? '正在扫描...' : `共 ${displayEmojiCount} 个表情包,${displayDecryptedEmojisCount} 个已解密`} +

+
+
+ +
+
+ +
+ {emojis.slice(0, displayedEmojiCount).map((emoji, index) => ( +
handleImageClick(emoji)} + > + {emoji.isDecrypted && emoji.decryptedPath ? ( + <> {emoji.fileName} { console.error('[DataManagement] 表情包加载失败:', emoji.decryptedPath) e.currentTarget.style.display = 'none' }} /> - ) : ( -
- - 未解密 +
+ +
- )} -
- {emoji.fileName} + + ) : ( +
+ + 未解密
+ )} +
+ {emoji.fileName}
- ))} +
+ ))} - {!isLoading && emojis.length === 0 && ( -
- -

未找到表情包

-

请先解密数据库

-
- )} + {!isLoading && emojis.length === 0 && ( +
+ +

未找到表情包

+

请先解密数据库

+
+ )} - {emojis.length > 100 && ( -
- 仅显示前 100 个表情包,共 {emojis.length} 个 -
- )} -
-
- )} -
+ {displayedEmojiCount < emojis.length && ( +
+ 加载中... ({displayedEmojiCount}/{emojis.length}) +
+ )} +
+ + )} ) } diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index d57ae54..6d79bf5 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -111,12 +111,59 @@ } } + // 会话类型筛选按钮组 + .session-type-filter { + display: flex; + align-items: center; + gap: 4px; + padding: 0 20px; + margin-bottom: 8px; + + .type-filter-btn { + display: flex; + align-items: center; + gap: 4px; + padding: 5px 14px; + font-size: 12px; + font-weight: 500; + color: var(--text-secondary); + background: var(--bg-secondary); + border: 1px solid transparent; + border-radius: 20px; + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + + &.active { + background: rgba(var(--primary-rgb), 0.1); + color: var(--primary); + border-color: rgba(var(--primary-rgb), 0.3); + font-weight: 600; + } + + svg { + flex-shrink: 0; + } + } + } + .select-actions { display: flex; align-items: center; justify-content: space-between; padding: 0 20px 12px; + .select-actions-left { + display: flex; + align-items: center; + gap: 2px; + } + .select-all-btn { background: none; border: none; @@ -131,12 +178,37 @@ } } + .select-type-btn { + display: flex; + align-items: center; + gap: 3px; + background: none; + border: none; + padding: 5px 10px; + font-size: 12px; + color: var(--text-tertiary); + cursor: pointer; + border-radius: 6px; + transition: all 0.2s; + white-space: nowrap; + + &:hover { + background: rgba(var(--primary-rgb), 0.08); + color: var(--primary); + } + + svg { + flex-shrink: 0; + } + } + .selected-count { font-size: 13px; color: var(--text-secondary); padding: 4px 12px; background: var(--bg-secondary); border-radius: 12px; + flex-shrink: 0; } } @@ -519,7 +591,7 @@ .export-options { display: flex; - flex-direction: column; + flex-wrap: wrap; gap: 12px; } @@ -856,15 +928,39 @@ margin: 0 0 8px; } + .progress-phase { + font-size: 13px; + color: var(--primary); + margin: 0 0 4px; + font-weight: 500; + } + .progress-text { font-size: 14px; color: var(--text-secondary); - margin: 0 0 20px; + margin: 0 0 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .progress-detail { + font-size: 12px; + color: var(--text-tertiary); + margin: 0 0 8px; + } + + .progress-export-options { + font-size: 12px; + color: var(--text-tertiary); + margin: 0 0 16px; + padding: 6px 12px; + background: var(--bg-secondary); + border-radius: 6px; + display: inline-flex; + gap: 0; + } + .progress-bar { height: 6px; background: var(--bg-secondary); diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index a3735c7..a8539ab 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback } from 'react' -import { Search, Download, FolderOpen, RefreshCw, Check, FileJson, FileText, Table, Loader2, X, FileSpreadsheet, Database, FileCode, CheckCircle, XCircle, ExternalLink, MessageSquare, Users, User } from 'lucide-react' +import { Search, Download, FolderOpen, RefreshCw, Check, FileJson, FileText, Table, Loader2, X, FileSpreadsheet, Database, FileCode, CheckCircle, XCircle, ExternalLink, MessageSquare, Users, User, Filter, Image, Video, CircleUserRound, Smile, Mic } from 'lucide-react' import DateRangePicker from '../components/DateRangePicker' import { useTitleBarStore } from '../stores/titleBarStore' import * as configService from '../services/config' @@ -29,6 +29,10 @@ interface ExportOptions { startDate: string endDate: string exportAvatars: boolean + exportImages: boolean + exportVideos: boolean + exportEmojis: boolean + exportVoices: boolean } interface ContactExportOptions { @@ -49,6 +53,9 @@ interface ExportResult { error?: string } +// 会话类型筛选 +type SessionTypeFilter = 'all' | 'group' | 'private' + function ExportPage() { const [activeTab, setActiveTab] = useState('chat') const setTitleBarContent = useTitleBarStore(state => state.setRightContent) @@ -59,12 +66,13 @@ function ExportPage() { const [selectedSessions, setSelectedSessions] = useState>(new Set()) const [isLoading, setIsLoading] = useState(true) const [searchKeyword, setSearchKeyword] = useState('') + const [sessionTypeFilter, setSessionTypeFilter] = useState('all') const [exportFolder, setExportFolder] = useState('') const [isExporting, setIsExporting] = useState(false) - const [exportProgress, setExportProgress] = useState({ - current: 0, - total: 0, - currentName: '', + const [exportProgress, setExportProgress] = useState({ + current: 0, + total: 0, + currentName: '', phase: '', detail: '' }) @@ -74,7 +82,11 @@ function ExportPage() { format: 'chatlab', startDate: '', endDate: '', - exportAvatars: true + exportAvatars: true, + exportImages: false, + exportVideos: false, + exportEmojis: false, + exportVoices: false }) // 通讯录导出状态 @@ -104,12 +116,12 @@ function ExportPage() { let endDate = '' if (defaultDateRange > 0) { const today = new Date() - + const year = today.getFullYear() const month = String(today.getMonth() + 1).padStart(2, '0') const day = String(today.getDate()).padStart(2, '0') const todayStr = `${year}-${month}-${day}` - + if (defaultDateRange === 1) { // 最近1天 = 今天 startDate = todayStr @@ -118,11 +130,11 @@ function ExportPage() { // 其他天数:从 N 天前到今天 const start = new Date(today) start.setDate(today.getDate() - defaultDateRange + 1) - + const startYear = start.getFullYear() const startMonth = String(start.getMonth() + 1).padStart(2, '0') const startDay = String(start.getDate()).padStart(2, '0') - + startDate = `${startYear}-${startMonth}-${startDay}` endDate = todayStr } @@ -148,11 +160,18 @@ function ExportPage() { // 监听导出进度 useEffect(() => { const removeListener = window.electronAPI.export.onProgress((data) => { + // 将 phase 英文映射为中文描述 + const phaseMap: Record = { + 'preparing': '正在准备...', + 'exporting': '正在导出消息...', + 'writing': '正在写入文件...', + 'complete': '导出完成' + } setExportProgress({ current: data.current || 0, total: data.total || 0, currentName: data.currentSession || '', - phase: data.phase || '', + phase: (data.phase ? phaseMap[data.phase] : undefined) || data.phase || '', detail: data.detail || '' }) }) @@ -258,18 +277,28 @@ function ExportPage() { return () => setTitleBarContent(null) }, [activeTab, setTitleBarContent]) - // 聊天会话搜索过滤 + // 聊天会话搜索与类型过滤 useEffect(() => { - if (!searchKeyword.trim()) { - setFilteredSessions(sessions) - return + let filtered = sessions + + // 类型过滤 + if (sessionTypeFilter === 'group') { + filtered = filtered.filter(s => s.username.includes('@chatroom')) + } else if (sessionTypeFilter === 'private') { + filtered = filtered.filter(s => !s.username.includes('@chatroom')) } - const lower = searchKeyword.toLowerCase() - setFilteredSessions(sessions.filter(s => - s.displayName?.toLowerCase().includes(lower) || - s.username.toLowerCase().includes(lower) - )) - }, [searchKeyword, sessions]) + + // 关键词过滤 + if (searchKeyword.trim()) { + const lower = searchKeyword.toLowerCase() + filtered = filtered.filter(s => + s.displayName?.toLowerCase().includes(lower) || + s.username.toLowerCase().includes(lower) + ) + } + + setFilteredSessions(filtered) + }, [searchKeyword, sessions, sessionTypeFilter]) // 通讯录搜索过滤 useEffect(() => { @@ -307,13 +336,29 @@ function ExportPage() { } const toggleSelectAll = () => { - if (selectedSessions.size === filteredSessions.length) { + if (selectedSessions.size === filteredSessions.length && filteredSessions.length > 0) { setSelectedSessions(new Set()) } else { setSelectedSessions(new Set(filteredSessions.map(s => s.username))) } } + // 快捷选择:仅选群聊 + const selectOnlyGroups = () => { + const groupUsernames = filteredSessions + .filter(s => s.username.includes('@chatroom')) + .map(s => s.username) + setSelectedSessions(new Set(groupUsernames)) + } + + // 快捷选择:仅选私聊 + const selectOnlyPrivate = () => { + const privateUsernames = filteredSessions + .filter(s => !s.username.includes('@chatroom')) + .map(s => s.username) + setSelectedSessions(new Set(privateUsernames)) + } + const toggleContact = (username: string) => { const newSet = new Set(selectedContacts) if (newSet.has(username)) { @@ -374,10 +419,14 @@ function ExportPage() { const exportOptions = { format: options.format, dateRange: (options.startDate && options.endDate) ? { - start: Math.floor(new Date(options.startDate).getTime() / 1000), + start: Math.floor(new Date(options.startDate + 'T00:00:00').getTime() / 1000), end: Math.floor(new Date(options.endDate + 'T23:59:59').getTime() / 1000) } : null, - exportAvatars: options.exportAvatars + exportAvatars: options.exportAvatars, + exportImages: options.exportImages, + exportVideos: options.exportVideos, + exportEmojis: options.exportEmojis, + exportVoices: options.exportVoices } if (options.format === 'chatlab' || options.format === 'chatlab-jsonl' || options.format === 'json' || options.format === 'excel' || options.format === 'html') { @@ -486,10 +535,43 @@ function ExportPage() { )}
-
- + + +
+ +
+
+ + + +
已选 {selectedSessions.size} 个
@@ -578,8 +660,49 @@ function ExportPage() { onChange={e => setOptions(prev => ({ ...prev, exportAvatars: e.target.checked }))} />
+ 导出头像 + + + +
@@ -824,8 +947,21 @@ function ExportPage() {

正在导出

{exportProgress.phase &&

{exportProgress.phase}

} -

{exportProgress.currentName || '准备中...'}

+ {exportProgress.currentName && ( +

当前会话: {exportProgress.currentName}

+ )} {exportProgress.detail &&

{exportProgress.detail}

} + {!exportProgress.currentName && !exportProgress.detail && ( +

准备中...

+ )} +
+ 格式: {options.format.toUpperCase()} + {options.exportImages && · 含图片} + {options.exportVideos && · 含视频} + {options.exportEmojis && · 含表情} + {options.exportVoices && · 含语音} + {options.exportAvatars && · 含头像} +
{exportProgress.total > 0 && ( <>
@@ -834,7 +970,7 @@ function ExportPage() { style={{ width: `${(exportProgress.current / exportProgress.total) * 100}%` }} />
-

{exportProgress.current} / {exportProgress.total}

+

{exportProgress.current} / {exportProgress.total} 个会话

)} diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 4a518dc..7872a7e 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -44,6 +44,10 @@ export interface ElectronAPI { openFile: (options?: Electron.OpenDialogOptions) => Promise saveFile: (options?: Electron.SaveDialogOptions) => Promise } + file: { + delete: (filePath: string) => Promise<{ success: boolean; error?: string }> + copy: (sourcePath: string, destPath: string) => Promise<{ success: boolean; error?: string }> + } shell: { openPath: (path: string) => Promise openExternal: (url: string) => Promise