diff --git a/electron/main.ts b/electron/main.ts index 8e45fb8..16d26ba 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -369,6 +369,66 @@ function createVideoPlayerWindow(videoPath: string, videoWidth?: number, videoHe return win } +/** + * 创建独立的聊天记录窗口 + */ +function createChatHistoryWindow(sessionId: string, messageId: number) { + const isDev = !!process.env.VITE_DEV_SERVER_URL + const iconPath = isDev + ? join(__dirname, '../public/icon.ico') + : join(process.resourcesPath, 'icon.ico') + + // 根据系统主题设置窗口背景色 + const isDark = nativeTheme.shouldUseDarkColors + + const win = new BrowserWindow({ + width: 600, + height: 800, + minWidth: 400, + minHeight: 500, + icon: iconPath, + webPreferences: { + preload: join(__dirname, 'preload.js'), + contextIsolation: true, + nodeIntegration: false + }, + titleBarStyle: 'hidden', + titleBarOverlay: { + color: '#00000000', + symbolColor: isDark ? '#ffffff' : '#1a1a1a', + height: 32 + }, + show: false, + backgroundColor: isDark ? '#1A1A1A' : '#F0F0F0', + autoHideMenuBar: true + }) + + win.once('ready-to-show', () => { + win.show() + }) + + if (process.env.VITE_DEV_SERVER_URL) { + win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/chat-history/${sessionId}/${messageId}`) + + win.webContents.on('before-input-event', (event, input) => { + if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) { + if (win.webContents.isDevToolsOpened()) { + win.webContents.closeDevTools() + } else { + win.webContents.openDevTools() + } + event.preventDefault() + } + }) + } else { + win.loadFile(join(__dirname, '../dist/index.html'), { + hash: `/chat-history/${sessionId}/${messageId}` + }) + } + + return win +} + function showMainWindow() { shouldShowMain = true if (mainWindowReady) { @@ -530,6 +590,12 @@ function registerIpcHandlers() { createVideoPlayerWindow(videoPath, videoWidth, videoHeight) }) + // 打开聊天记录窗口 + ipcMain.handle('window:openChatHistoryWindow', (_, sessionId: string, messageId: number) => { + createChatHistoryWindow(sessionId, messageId) + return true + }) + // 根据视频尺寸调整窗口大小 ipcMain.handle('window:resizeToFitVideo', (event, videoWidth: number, videoHeight: number) => { const win = BrowserWindow.fromWebContents(event.sender) @@ -698,7 +764,7 @@ function registerIpcHandlers() { }) }) - ipcMain.handle('chat:getMessageById', async (_, sessionId: string, localId: number) => { + ipcMain.handle('chat:getMessage', async (_, sessionId: string, localId: number) => { return chatService.getMessageById(sessionId, localId) }) diff --git a/electron/preload.ts b/electron/preload.ts index 54846fb..4e37c02 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -57,7 +57,9 @@ contextBridge.exposeInMainWorld('electronAPI', { openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) => ipcRenderer.invoke('window:openVideoPlayerWindow', videoPath, videoWidth, videoHeight), resizeToFitVideo: (videoWidth: number, videoHeight: number) => - ipcRenderer.invoke('window:resizeToFitVideo', videoWidth, videoHeight) + ipcRenderer.invoke('window:resizeToFitVideo', videoWidth, videoHeight), + openChatHistoryWindow: (sessionId: string, messageId: number) => + ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId) }, // 数据库路径 @@ -121,7 +123,9 @@ contextBridge.exposeInMainWorld('electronAPI', { }, execQuery: (kind: string, path: string | null, sql: string) => ipcRenderer.invoke('chat:execQuery', kind, path, sql), - getContacts: () => ipcRenderer.invoke('chat:getContacts') + getContacts: () => ipcRenderer.invoke('chat:getContacts'), + getMessage: (sessionId: string, localId: number) => + ipcRenderer.invoke('chat:getMessage', sessionId, localId) }, diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index e0effd1..688c5bd 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -58,6 +58,26 @@ export interface Message { encrypVer?: number cdnThumbUrl?: string voiceDurationSeconds?: number + // Type 49 细分字段 + linkTitle?: string // 链接/文件标题 + linkUrl?: string // 链接 URL + linkThumb?: string // 链接缩略图 + fileName?: string // 文件名 + fileSize?: number // 文件大小 + fileExt?: string // 文件扩展名 + xmlType?: string // XML 中的 type 字段 + // 名片消息 + cardUsername?: string // 名片的微信ID + cardNickname?: string // 名片的昵称 + // 聊天记录 + chatRecordTitle?: string // 聊天记录标题 + chatRecordList?: Array<{ + datatype: number + sourcename: string + sourcetime: string + datadesc: string + datatitle?: string + }> } export interface Contact { @@ -106,6 +126,9 @@ class ChatService { timeColumn?: string name2IdTable?: string }>() + // 缓存会话表信息,避免每次查询 + private sessionTablesCache = new Map>() + private readonly sessionTablesCacheTtl = 300000 // 5分钟 constructor() { this.configService = new ConfigService() @@ -1023,6 +1046,26 @@ class ChatService { let encrypVer: number | undefined let cdnThumbUrl: string | undefined let voiceDurationSeconds: number | undefined + // Type 49 细分字段 + let linkTitle: string | undefined + let linkUrl: string | undefined + let linkThumb: string | undefined + let fileName: string | undefined + let fileSize: number | undefined + let fileExt: string | undefined + let xmlType: string | undefined + // 名片消息 + let cardUsername: string | undefined + let cardNickname: string | undefined + // 聊天记录 + let chatRecordTitle: string | undefined + let chatRecordList: Array<{ + datatype: number + sourcename: string + sourcetime: string + datadesc: string + datatitle?: string + }> | undefined if (localType === 47 && content) { const emojiInfo = this.parseEmojiInfo(content) @@ -1040,6 +1083,23 @@ class ChatService { videoMd5 = this.parseVideoMd5(content) } else if (localType === 34 && content) { voiceDurationSeconds = this.parseVoiceDurationSeconds(content) + } else if (localType === 42 && content) { + // 名片消息 + const cardInfo = this.parseCardInfo(content) + cardUsername = cardInfo.username + cardNickname = cardInfo.nickname + } else if (localType === 49 && content) { + // Type 49 消息(链接、文件、小程序、转账等) + const type49Info = this.parseType49Message(content) + xmlType = type49Info.xmlType + linkTitle = type49Info.linkTitle + linkUrl = type49Info.linkUrl + linkThumb = type49Info.linkThumb + fileName = type49Info.fileName + fileSize = type49Info.fileSize + fileExt = type49Info.fileExt + chatRecordTitle = type49Info.chatRecordTitle + chatRecordList = type49Info.chatRecordList } else if (localType === 244813135921 || (content && content.includes('57'))) { const quoteInfo = this.parseQuoteMessage(content) quotedContent = quoteInfo.content @@ -1066,7 +1126,18 @@ class ChatService { voiceDurationSeconds, aesKey, encrypVer, - cdnThumbUrl + cdnThumbUrl, + linkTitle, + linkUrl, + linkThumb, + fileName, + fileSize, + fileExt, + xmlType, + cardUsername, + cardNickname, + chatRecordTitle, + chatRecordList }) const last = messages[messages.length - 1] if ((last.localType === 3 || last.localType === 34) && (last.localId === 0 || last.createTime === 0)) { @@ -1164,17 +1235,35 @@ class ChatService { return `[链接] ${title}` case '6': return `[文件] ${title}` + case '19': + return `[聊天记录] ${title}` case '33': case '36': return `[小程序] ${title}` case '57': // 引用消息,title 就是回复的内容 return title + case '2000': + return `[转账] ${title}` default: return title } } - return '[消息]' + + // 如果没有 title,根据 type 返回默认标签 + switch (type) { + case '6': + return '[文件]' + case '19': + return '[聊天记录]' + case '33': + case '36': + return '[小程序]' + case '2000': + return '[转账]' + default: + return '[消息]' + } } /** @@ -1458,6 +1547,185 @@ class ChatService { } } + /** + * 解析名片消息 + * 格式: + */ + private parseCardInfo(content: string): { username?: string; nickname?: string } { + try { + if (!content) return {} + + // 提取 username + const username = this.extractXmlAttribute(content, 'msg', 'username') || undefined + + // 提取 nickname + const nickname = this.extractXmlAttribute(content, 'msg', 'nickname') || undefined + + return { username, nickname } + } catch (e) { + console.error('[ChatService] 名片解析失败:', e) + return {} + } + } + + /** + * 解析 Type 49 消息(链接、文件、小程序、转账等) + * 根据 X 区分不同类型 + */ + private parseType49Message(content: string): { + xmlType?: string + linkTitle?: string + linkUrl?: string + linkThumb?: string + fileName?: string + fileSize?: number + fileExt?: string + chatRecordTitle?: string + chatRecordList?: Array<{ + datatype: number + sourcename: string + sourcetime: string + datadesc: string + datatitle?: string + }> + } { + try { + if (!content) return {} + + // 提取 appmsg 中的 type + const xmlType = this.extractXmlValue(content, 'type') + if (!xmlType) return {} + + const result: any = { xmlType } + + // 提取通用字段 + const title = this.extractXmlValue(content, 'title') + const url = this.extractXmlValue(content, 'url') + + switch (xmlType) { + case '6': { + // 文件消息 + result.fileName = title || this.extractXmlValue(content, 'filename') + result.linkTitle = result.fileName + + // 提取文件大小 + const fileSizeStr = this.extractXmlValue(content, 'totallen') || + this.extractXmlValue(content, 'filesize') + if (fileSizeStr) { + const size = parseInt(fileSizeStr, 10) + if (!isNaN(size)) { + result.fileSize = size + } + } + + // 提取文件扩展名 + const fileExt = this.extractXmlValue(content, 'fileext') + if (fileExt) { + result.fileExt = fileExt + } else if (result.fileName) { + // 从文件名提取扩展名 + const match = /\.([^.]+)$/.exec(result.fileName) + if (match) { + result.fileExt = match[1] + } + } + break + } + + case '19': { + // 聊天记录 + result.chatRecordTitle = title || '聊天记录' + + // 解析聊天记录列表 + const recordList: Array<{ + datatype: number + sourcename: string + sourcetime: string + datadesc: string + datatitle?: string + }> = [] + + // 查找所有 标签 + const recordItemRegex = /([\s\S]*?)<\/recorditem>/gi + let match: RegExpExecArray | null + + while ((match = recordItemRegex.exec(content)) !== null) { + const itemXml = match[1] + + const datatypeStr = this.extractXmlValue(itemXml, 'datatype') + const sourcename = this.extractXmlValue(itemXml, 'sourcename') + const sourcetime = this.extractXmlValue(itemXml, 'sourcetime') + const datadesc = this.extractXmlValue(itemXml, 'datadesc') + const datatitle = this.extractXmlValue(itemXml, 'datatitle') + + if (sourcename && datadesc) { + recordList.push({ + datatype: datatypeStr ? parseInt(datatypeStr, 10) : 0, + sourcename, + sourcetime: sourcetime || '', + datadesc, + datatitle: datatitle || undefined + }) + } + } + + if (recordList.length > 0) { + result.chatRecordList = recordList + } + break + } + + case '33': + case '36': { + // 小程序 + result.linkTitle = title + result.linkUrl = url + + // 提取缩略图 + const thumbUrl = this.extractXmlValue(content, 'thumburl') || + this.extractXmlValue(content, 'cdnthumburl') + if (thumbUrl) { + result.linkThumb = thumbUrl + } + break + } + + case '2000': { + // 转账 + result.linkTitle = title || '[转账]' + + // 可以提取转账金额等信息 + const payMemo = this.extractXmlValue(content, 'pay_memo') + const feedesc = this.extractXmlValue(content, 'feedesc') + + if (payMemo) { + result.linkTitle = payMemo + } else if (feedesc) { + result.linkTitle = feedesc + } + break + } + + default: { + // 其他类型,提取通用字段 + result.linkTitle = title + result.linkUrl = url + + const thumbUrl = this.extractXmlValue(content, 'thumburl') || + this.extractXmlValue(content, 'cdnthumburl') + if (thumbUrl) { + result.linkThumb = thumbUrl + } + } + } + + return result + } catch (e) { + console.error('[ChatService] Type 49 消息解析失败:', e) + return {} + } + } + //手动查找 media_*.db 文件(当 WCDB DLL 不支持 listMediaDbs 时的 fallback) private async findMediaDbsManually(): Promise { try { @@ -3198,19 +3466,35 @@ class ChatService { async getMessageById(sessionId: string, localId: number): Promise<{ success: boolean; message?: Message; error?: string }> { try { - // 1. 获取该会话所在的消息表 - // 注意:这里使用 getMessageTableStats 而不是 getMessageTables,因为前者包含 db_path - const tableStats = await wcdbService.getMessageTableStats(sessionId) - if (!tableStats.success || !tableStats.tables || tableStats.tables.length === 0) { - return { success: false, error: '未找到会话消息表' } + // 1. 尝试从缓存获取会话表信息 + let tables = this.sessionTablesCache.get(sessionId) + + if (!tables) { + // 缓存未命中,查询数据库 + const tableStats = await wcdbService.getMessageTableStats(sessionId) + if (!tableStats.success || !tableStats.tables || tableStats.tables.length === 0) { + return { success: false, error: '未找到会话消息表' } + } + + // 提取表信息并缓存 + tables = tableStats.tables + .map(t => ({ + tableName: t.table_name || t.name, + dbPath: t.db_path + })) + .filter(t => t.tableName && t.dbPath) as Array<{ tableName: string; dbPath: string }> + + if (tables.length > 0) { + this.sessionTablesCache.set(sessionId, tables) + // 设置过期清理 + setTimeout(() => { + this.sessionTablesCache.delete(sessionId) + }, this.sessionTablesCacheTtl) + } } // 2. 遍历表查找消息 (通常只有一个主表,但可能有归档) - for (const tableInfo of tableStats.tables) { - const tableName = tableInfo.table_name || tableInfo.name - const dbPath = tableInfo.db_path - if (!tableName || !dbPath) continue - + for (const { tableName, dbPath } of tables) { // 构造查询 const sql = `SELECT * FROM ${tableName} WHERE local_id = ${localId} LIMIT 1` const result = await wcdbService.execQuery('message', dbPath, sql) diff --git a/electron/services/imageDecryptService.ts b/electron/services/imageDecryptService.ts index fedc3d5..1fc3648 100644 --- a/electron/services/imageDecryptService.ts +++ b/electron/services/imageDecryptService.ts @@ -415,14 +415,8 @@ export class ImageDecryptService { if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hardlinkPath) return hardlinkPath } - // hardlink 找到的是缩略图,但要求高清图:尝试同目录查找高清变体 + // hardlink 找到的是缩略图,但要求高清图,直接返回 null,不再搜索 if (!allowThumbnail && isThumb) { - const hdPath = this.findHdVariantInSameDir(hardlinkPath) - if (hdPath) { - this.cacheDatPath(accountDir, imageMd5, hdPath) - if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdPath) - return hdPath - } return null } } @@ -438,11 +432,6 @@ export class ImageDecryptService { return fallbackPath } if (!allowThumbnail && isThumb) { - const hdPath = this.findHdVariantInSameDir(fallbackPath) - if (hdPath) { - this.cacheDatPath(accountDir, imageDatName, hdPath) - return hdPath - } return null } } @@ -460,20 +449,15 @@ export class ImageDecryptService { this.cacheDatPath(accountDir, imageDatName, hardlinkPath) return hardlinkPath } - // hardlink 找到的是缩略图,但要求高清图:尝试同目录查找高清变体 + // hardlink 找到的是缩略图,但要求高清图,直接返回 null if (!allowThumbnail && isThumb) { - const hdPath = this.findHdVariantInSameDir(hardlinkPath) - if (hdPath) { - this.cacheDatPath(accountDir, imageDatName, hdPath) - return hdPath - } return null } } this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName }) } - // 如果要求高清图但 hardlink 没找到,也不要搜索全盘了(搜索太慢) + // 如果要求高清图但 hardlink 没找到,也不要搜索了(搜索太慢) if (!allowThumbnail) { return null } @@ -483,9 +467,6 @@ export class ImageDecryptService { const cached = this.resolvedCache.get(imageDatName) if (cached && existsSync(cached)) { if (allowThumbnail || !this.isThumbnailPath(cached)) return cached - // 缓存的是缩略图,尝试同目录找高清变体 - const hdPath = this.findHdVariantInSameDir(cached) - if (hdPath) return hdPath } } @@ -530,38 +511,6 @@ export class ImageDecryptService { return this.searchDatFile(accountDir, imageDatName, true, true) } - /** - * 在同目录中尝试查找高清图变体 - * 缩略图: xxx_t.dat / xxx.t.dat -> 高清图: xxx_h.dat / xxx.h.dat / xxx.dat - */ - private findHdVariantInSameDir(thumbPath: string): string | null { - try { - const dir = dirname(thumbPath) - const fileName = basename(thumbPath).toLowerCase() - - let baseName = fileName - if (baseName.endsWith('_t.dat')) { - baseName = baseName.slice(0, -6) - } else if (baseName.endsWith('.t.dat')) { - baseName = baseName.slice(0, -6) - } else { - return null - } - - const variants = [ - `${baseName}_h.dat`, - `${baseName}.h.dat`, - `${baseName}.dat` - ] - - for (const variant of variants) { - const candidate = join(dir, variant) - if (existsSync(candidate)) return candidate - } - } catch { } - return null - } - private async checkHasUpdate( payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }, cacheKey: string, @@ -950,42 +899,71 @@ export class ImageDecryptService { } private findCachedOutput(cacheKey: string, preferHd: boolean = false, sessionId?: string): string | null { - const root = this.getCacheRoot() + const allRoots = this.getAllCacheRoots() const normalizedKey = this.normalizeDatBase(cacheKey.toLowerCase()) const extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp'] - if (sessionId) { - const sessionDir = join(root, this.sanitizeDirName(sessionId)) - if (existsSync(sessionDir)) { - try { - const sessionEntries = readdirSync(sessionDir) - for (const entry of sessionEntries) { - const timeDir = join(sessionDir, entry) - if (!this.isDirectory(timeDir)) continue - const hit = this.findCachedOutputInDir(timeDir, normalizedKey, extensions, preferHd) - if (hit) return hit - } - } catch { - // ignore + // 遍历所有可能的缓存根路径 + for (const root of allRoots) { + // 策略1: 新目录结构 Images/{sessionId}/{YYYY-MM}/{file}_hd.jpg + if (sessionId) { + const sessionDir = join(root, this.sanitizeDirName(sessionId)) + if (existsSync(sessionDir)) { + try { + const dateDirs = readdirSync(sessionDir, { withFileTypes: true }) + .filter(d => d.isDirectory() && /^\d{4}-\d{2}$/.test(d.name)) + .map(d => d.name) + .sort() + .reverse() // 最新的日期优先 + + for (const dateDir of dateDirs) { + const imageDir = join(sessionDir, dateDir) + const hit = this.findCachedOutputInDir(imageDir, normalizedKey, extensions, preferHd) + if (hit) return hit + } + } catch { } } } - } - // 新目录结构: Images/{normalizedKey}/{normalizedKey}_thumb.jpg 或 _hd.jpg - const imageDir = join(root, normalizedKey) - if (existsSync(imageDir)) { - const hit = this.findCachedOutputInDir(imageDir, normalizedKey, extensions, preferHd) - if (hit) return hit - } + // 策略2: 遍历所有 sessionId 目录查找(如果没有指定 sessionId) + try { + const sessionDirs = readdirSync(root, { withFileTypes: true }) + .filter(d => d.isDirectory()) + .map(d => d.name) - // 兼容旧的平铺结构 - for (const ext of extensions) { - const candidate = join(root, `${cacheKey}${ext}`) - if (existsSync(candidate)) return candidate - } - for (const ext of extensions) { - const candidate = join(root, `${cacheKey}_t${ext}`) - if (existsSync(candidate)) return candidate + for (const session of sessionDirs) { + const sessionDir = join(root, session) + // 检查是否是日期目录结构 + try { + const subDirs = readdirSync(sessionDir, { withFileTypes: true }) + .filter(d => d.isDirectory() && /^\d{4}-\d{2}$/.test(d.name)) + .map(d => d.name) + + for (const dateDir of subDirs) { + const imageDir = join(sessionDir, dateDir) + const hit = this.findCachedOutputInDir(imageDir, normalizedKey, extensions, preferHd) + if (hit) return hit + } + } catch { } + } + } catch { } + + // 策略3: 旧目录结构 Images/{normalizedKey}/{normalizedKey}_thumb.jpg + const oldImageDir = join(root, normalizedKey) + if (existsSync(oldImageDir)) { + const hit = this.findCachedOutputInDir(oldImageDir, normalizedKey, extensions, preferHd) + if (hit) return hit + } + + // 策略4: 最旧的平铺结构 Images/{file}.jpg + for (const ext of extensions) { + const candidate = join(root, `${cacheKey}${ext}`) + if (existsSync(candidate)) return candidate + } + for (const ext of extensions) { + const candidate = join(root, `${cacheKey}_t${ext}`) + if (existsSync(candidate)) return candidate + } } return null @@ -1155,15 +1133,19 @@ export class ImageDecryptService { if (this.cacheIndexed) return if (this.cacheIndexing) return this.cacheIndexing this.cacheIndexing = new Promise((resolve) => { - const root = this.getCacheRoot() - try { - this.indexCacheDir(root, 2, 0) - } catch { - this.cacheIndexed = true - this.cacheIndexing = null - resolve() - return + // 扫描所有可能的缓存根目录 + const allRoots = this.getAllCacheRoots() + this.logInfo('开始索引缓存', { roots: allRoots.length }) + + for (const root of allRoots) { + try { + this.indexCacheDir(root, 3, 0) // 增加深度到3,支持 sessionId/YYYY-MM 结构 + } catch (e) { + this.logError('索引目录失败', e, { root }) + } } + + this.logInfo('缓存索引完成', { entries: this.resolvedCache.size }) this.cacheIndexed = true this.cacheIndexing = null resolve() @@ -1171,6 +1153,39 @@ export class ImageDecryptService { return this.cacheIndexing } + /** + * 获取所有可能的缓存根路径(用于查找已缓存的图片) + * 包含当前路径、配置路径、旧版本路径 + */ + private getAllCacheRoots(): string[] { + const roots: string[] = [] + const configured = this.configService.get('cachePath') + const documentsPath = app.getPath('documents') + + // 主要路径(当前使用的) + const mainRoot = this.getCacheRoot() + roots.push(mainRoot) + + // 如果配置了自定义路径,也检查其下的 Images + if (configured) { + roots.push(join(configured, 'Images')) + roots.push(join(configured, 'images')) + } + + // 默认路径 + roots.push(join(documentsPath, 'WeFlow', 'Images')) + roots.push(join(documentsPath, 'WeFlow', 'images')) + + // 兼容旧路径(如果有的话) + roots.push(join(documentsPath, 'WeFlowData', 'Images')) + + // 去重并过滤存在的路径 + const uniqueRoots = Array.from(new Set(roots)) + const existingRoots = uniqueRoots.filter(r => existsSync(r)) + + return existingRoots + } + private indexCacheDir(root: string, maxDepth: number, depth: number): void { let entries: string[] try { diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts index 9825b3e..b7ba5ba 100644 --- a/electron/services/wcdbCore.ts +++ b/electron/services/wcdbCore.ts @@ -246,15 +246,37 @@ export class WcdbCore { // InitProtection (Added for security) try { - this.wcdbInitProtection = this.lib.func('int32 InitProtection(const char* resourcePath)') - const protectionCode = this.wcdbInitProtection(dllDir) - if (protectionCode !== 0) { - console.error('Core security check failed:', protectionCode) - lastDllInitError = `初始化失败,错误码: ${protectionCode}` - return false + this.wcdbInitProtection = this.lib.func('bool InitProtection(const char* resourcePath)') + + // 尝试多个可能的资源路径 + const resourcePaths = [ + dllDir, // DLL 所在目录 + dirname(dllDir), // 上级目录 + this.resourcesPath, // 配置的资源路径 + join(process.cwd(), 'resources') // 开发环境 + ].filter(Boolean) + + let protectionOk = false + for (const resPath of resourcePaths) { + try { + console.log(`[WCDB] 尝试 InitProtection: ${resPath}`) + protectionOk = this.wcdbInitProtection(resPath) + if (protectionOk) { + console.log(`[WCDB] InitProtection 成功: ${resPath}`) + break + } + } catch (e) { + console.warn(`[WCDB] InitProtection 失败 (${resPath}):`, e) + } + } + + if (!protectionOk) { + console.warn('[WCDB] Core security check failed - 继续运行但可能不稳定') + this.writeLog('InitProtection 失败,继续运行') + // 不返回 false,允许继续运行 } } catch (e) { - console.warn('InitProtection symbol not found or failed:', e) + console.warn('InitProtection symbol not found:', e) } // 定义类型 @@ -1439,4 +1461,4 @@ export class WcdbCore { return { success: false, error: String(e) } } } -} +} \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 5096276..90f8562 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,7 @@ import ExportPage from './pages/ExportPage' import VideoWindow from './pages/VideoWindow' import SnsPage from './pages/SnsPage' import ContactsPage from './pages/ContactsPage' +import ChatHistoryPage from './pages/ChatHistoryPage' import { useAppStore } from './stores/appStore' import { themes, useThemeStore, type ThemeId } from './stores/themeStore' @@ -49,6 +50,7 @@ function App() { const isAgreementWindow = location.pathname === '/agreement-window' const isOnboardingWindow = location.pathname === '/onboarding-window' const isVideoPlayerWindow = location.pathname === '/video-player-window' + const isChatHistoryWindow = location.pathname.startsWith('/chat-history/') const [themeHydrated, setThemeHydrated] = useState(false) // 锁定状态 @@ -298,6 +300,11 @@ function App() { return } + // 独立聊天记录窗口 + if (isChatHistoryWindow) { + return + } + // 主窗口 - 完整布局 return (
@@ -392,6 +399,7 @@ function App() { } /> } /> } /> + } /> diff --git a/src/components/TitleBar.tsx b/src/components/TitleBar.tsx index 77fe4e3..570e6e9 100644 --- a/src/components/TitleBar.tsx +++ b/src/components/TitleBar.tsx @@ -1,10 +1,14 @@ import './TitleBar.scss' -function TitleBar() { +interface TitleBarProps { + title?: string +} + +function TitleBar({ title }: TitleBarProps = {}) { return (
WeFlow - WeFlow + {title || 'WeFlow'}
) } diff --git a/src/pages/ChatHistoryPage.scss b/src/pages/ChatHistoryPage.scss new file mode 100644 index 0000000..74c2af6 --- /dev/null +++ b/src/pages/ChatHistoryPage.scss @@ -0,0 +1,132 @@ +.chat-history-page { + display: flex; + flex-direction: column; + height: 100vh; + background: var(--bg-primary); + + .history-list { + flex: 1; + overflow-y: auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; + + .status-msg { + text-align: center; + padding: 40px 20px; + color: var(--text-tertiary); + font-size: 14px; + + &.error { + color: var(--danger); + } + + &.empty { + color: var(--text-tertiary); + } + } + } + + .history-item { + display: flex; + gap: 12px; + align-items: flex-start; + + .avatar { + width: 40px; + height: 40px; + border-radius: 4px; + overflow: hidden; + flex-shrink: 0; + background: var(--bg-tertiary); + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .avatar-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-tertiary); + font-size: 16px; + font-weight: 500; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + } + } + + .content-wrapper { + flex: 1; + min-width: 0; + + .header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 6px; + + .sender { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + } + + .time { + font-size: 12px; + color: var(--text-tertiary); + flex-shrink: 0; + margin-left: 8px; + } + } + + .bubble { + background: var(--bg-secondary); + padding: 10px 14px; + border-radius: 18px 18px 18px 4px; + word-wrap: break-word; + max-width: 100%; + display: inline-block; + + &.image-bubble { + padding: 0; + background: transparent; + } + + .text-content { + font-size: 14px; + line-height: 1.6; + color: var(--text-primary); + white-space: pre-wrap; + word-break: break-word; + } + + .media-content { + img { + max-width: 100%; + max-height: 300px; + border-radius: 8px; + display: block; + } + + .media-tip { + padding: 8px 12px; + color: var(--text-tertiary); + font-size: 13px; + } + } + + .media-placeholder { + font-size: 14px; + color: var(--text-secondary); + padding: 4px 0; + } + } + } + } +} diff --git a/src/pages/ChatHistoryPage.tsx b/src/pages/ChatHistoryPage.tsx new file mode 100644 index 0000000..45404e8 --- /dev/null +++ b/src/pages/ChatHistoryPage.tsx @@ -0,0 +1,250 @@ +import { useEffect, useState } from 'react' +import { useParams, useLocation } from 'react-router-dom' +import { ChatRecordItem } from '../types/models' +import TitleBar from '../components/TitleBar' +import './ChatHistoryPage.scss' + +export default function ChatHistoryPage() { + const params = useParams<{ sessionId: string; messageId: string }>() + const location = useLocation() + const [recordList, setRecordList] = useState([]) + const [loading, setLoading] = useState(true) + const [title, setTitle] = useState('聊天记录') + const [error, setError] = useState('') + + // 简单的 XML 标签内容提取 + const extractXmlValue = (xml: string, tag: string): string => { + const match = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`).exec(xml) + return match ? match[1] : '' + } + + // 简单的 HTML 实体解码 + const decodeHtmlEntities = (text?: string): string | undefined => { + if (!text) return text + return text + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, "'") + } + + // 前端兜底解析合并转发聊天记录 + const parseChatHistory = (content: string): ChatRecordItem[] | undefined => { + try { + const type = extractXmlValue(content, 'type') + if (type !== '19') return undefined + + const match = /[\s\S]*?[\s\S]*?<\/recorditem>/.exec(content) + if (!match) return undefined + + const innerXml = match[1] + const items: ChatRecordItem[] = [] + const itemRegex = /([\s\S]*?)<\/dataitem>/g + let itemMatch: RegExpExecArray | null + + while ((itemMatch = itemRegex.exec(innerXml)) !== null) { + const attrs = itemMatch[1] + const body = itemMatch[2] + + const datatypeMatch = /datatype="(\d+)"/.exec(attrs) + const datatype = datatypeMatch ? parseInt(datatypeMatch[1]) : 0 + + const sourcename = extractXmlValue(body, 'sourcename') + const sourcetime = extractXmlValue(body, 'sourcetime') + const sourceheadurl = extractXmlValue(body, 'sourceheadurl') + const datadesc = extractXmlValue(body, 'datadesc') + const datatitle = extractXmlValue(body, 'datatitle') + const fileext = extractXmlValue(body, 'fileext') + const datasize = parseInt(extractXmlValue(body, 'datasize') || '0') + const messageuuid = extractXmlValue(body, 'messageuuid') + + const dataurl = extractXmlValue(body, 'dataurl') + const datathumburl = extractXmlValue(body, 'datathumburl') || extractXmlValue(body, 'thumburl') + const datacdnurl = extractXmlValue(body, 'datacdnurl') || extractXmlValue(body, 'cdnurl') + const aeskey = extractXmlValue(body, 'aeskey') || extractXmlValue(body, 'qaeskey') + const md5 = extractXmlValue(body, 'md5') || extractXmlValue(body, 'datamd5') + const imgheight = parseInt(extractXmlValue(body, 'imgheight') || '0') + const imgwidth = parseInt(extractXmlValue(body, 'imgwidth') || '0') + const duration = parseInt(extractXmlValue(body, 'duration') || '0') + + items.push({ + datatype, + sourcename, + sourcetime, + sourceheadurl, + datadesc: decodeHtmlEntities(datadesc), + datatitle: decodeHtmlEntities(datatitle), + fileext, + datasize, + messageuuid, + dataurl: decodeHtmlEntities(dataurl), + datathumburl: decodeHtmlEntities(datathumburl), + datacdnurl: decodeHtmlEntities(datacdnurl), + aeskey: decodeHtmlEntities(aeskey), + md5, + imgheight, + imgwidth, + duration + }) + } + + return items.length > 0 ? items : undefined + } catch (e) { + console.error('前端解析聊天记录失败:', e) + return undefined + } + } + + // 统一从路由参数或 pathname 中解析 sessionId / messageId + const getIds = () => { + const sessionId = params.sessionId || '' + const messageId = params.messageId || '' + + if (sessionId && messageId) { + return { sid: sessionId, mid: messageId } + } + + // 独立窗口场景下没有 Route 包裹,用 pathname 手动解析 + const match = /^\/chat-history\/([^/]+)\/([^/]+)/.exec(location.pathname) + if (match) { + return { sid: match[1], mid: match[2] } + } + + return { sid: '', mid: '' } + } + + useEffect(() => { + const loadData = async () => { + const { sid, mid } = getIds() + if (!sid || !mid) { + setError('无效的聊天记录链接') + setLoading(false) + return + } + try { + const result = await window.electronAPI.chat.getMessage(sid, parseInt(mid, 10)) + if (result.success && result.message) { + const msg = result.message + // 优先使用后端解析好的列表 + let records: ChatRecordItem[] | undefined = msg.chatRecordList + + // 如果后端没有解析到,则在前端兜底解析一次 + if ((!records || records.length === 0) && msg.content) { + records = parseChatHistory(msg.content) || [] + } + + if (records && records.length > 0) { + setRecordList(records) + const match = /(.*?)<\/title>/.exec(msg.content || '') + if (match) setTitle(match[1]) + } else { + setError('暂时无法解析这条聊天记录') + } + } else { + setError(result.error || '获取消息失败') + } + } catch (e) { + console.error(e) + setError('加载详情失败') + } finally { + setLoading(false) + } + } + loadData() + }, [params.sessionId, params.messageId, location.pathname]) + + return ( + <div className="chat-history-page"> + <TitleBar title={title} /> + <div className="history-list"> + {loading ? ( + <div className="status-msg">加载中...</div> + ) : error ? ( + <div className="status-msg error">{error}</div> + ) : recordList.length === 0 ? ( + <div className="status-msg empty">暂无可显示的聊天记录</div> + ) : ( + recordList.map((item, i) => ( + <HistoryItem key={i} item={item} /> + )) + )} + </div> + </div> + ) +} + +function HistoryItem({ item }: { item: ChatRecordItem }) { + // sourcetime 在合并转发里有两种格式: + // 1) 时间戳(秒) 2) 已格式化的字符串 "2026-01-21 09:56:46" + let time = '' + if (item.sourcetime) { + if (/^\d+$/.test(item.sourcetime)) { + time = new Date(parseInt(item.sourcetime, 10) * 1000).toLocaleString() + } else { + time = item.sourcetime + } + } + + const renderContent = () => { + if (item.datatype === 1) { + // 文本消息 + return <div className="text-content">{item.datadesc || ''}</div> + } + if (item.datatype === 3) { + // 图片 + const src = item.datathumburl || item.datacdnurl + if (src) { + return ( + <div className="media-content"> + <img + src={src} + alt="图片" + referrerPolicy="no-referrer" + onError={(e) => { + const target = e.target as HTMLImageElement + target.style.display = 'none' + const placeholder = document.createElement('div') + placeholder.className = 'media-tip' + placeholder.textContent = '图片无法加载' + target.parentElement?.appendChild(placeholder) + }} + /> + </div> + ) + } + return <div className="media-placeholder">[图片]</div> + } + if (item.datatype === 43) { + return <div className="media-placeholder">[视频] {item.datatitle}</div> + } + if (item.datatype === 34) { + return <div className="media-placeholder">[语音] {item.duration ? (item.duration / 1000).toFixed(0) + '"' : ''}</div> + } + // Fallback + return <div className="text-content">{item.datadesc || item.datatitle || '[不支持的消息类型]'}</div> + } + + return ( + <div className="history-item"> + <div className="avatar"> + {item.sourceheadurl ? ( + <img src={item.sourceheadurl} alt="" referrerPolicy="no-referrer" /> + ) : ( + <div className="avatar-placeholder"> + {item.sourcename?.slice(0, 1)} + </div> + )} + </div> + <div className="content-wrapper"> + <div className="header"> + <span className="sender">{item.sourcename || '未知发送者'}</span> + <span className="time">{time}</span> + </div> + <div className={`bubble ${item.datatype === 3 ? 'image-bubble' : ''}`}> + {renderContent()} + </div> + </div> + </div> + ) +} diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index ce324a5..ea3329d 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -834,92 +834,93 @@ // 链接卡片消息样式 .link-message { - cursor: pointer; + width: 280px; background: var(--card-bg); border-radius: 8px; overflow: hidden; - border: 1px solid var(--border-color); + cursor: pointer; transition: all 0.2s ease; - max-width: 300px; - margin-top: 4px; + border: 1px solid var(--border-color); &:hover { background: var(--bg-hover); - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + border-color: var(--primary); } .link-header { + padding: 10px 12px 6px; display: flex; - align-items: flex-start; - padding: 12px; - gap: 12px; + gap: 8px; + + .link-title { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + flex: 1; + } } - .link-content { - flex: 1; - min-width: 0; - } - - .link-title { - font-size: 14px; - font-weight: 500; - color: var(--text-primary); - margin-bottom: 4px; - display: -webkit-box; - -webkit-line-clamp: 2; - line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - line-height: 1.4; - } - - .link-desc { - font-size: 12px; - color: var(--text-secondary); - display: -webkit-box; - -webkit-line-clamp: 2; - line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - line-height: 1.4; - opacity: 0.8; - } - - .link-icon { - flex-shrink: 0; - width: 40px; - height: 40px; - background: var(--bg-tertiary); - border-radius: 6px; + .link-body { + padding: 6px 12px 10px; display: flex; - align-items: center; - justify-content: center; - color: var(--text-secondary); + gap: 10px; - svg { - opacity: 0.8; + .link-desc { + font-size: 12px; + color: var(--text-secondary); + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 3; + line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + flex: 1; + } + + .link-thumb { + width: 48px; + height: 48px; + border-radius: 4px; + object-fit: cover; + flex-shrink: 0; + background: var(--bg-tertiary); + } + + .link-thumb-placeholder { + width: 48px; + height: 48px; + border-radius: 4px; + background: var(--bg-tertiary); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--text-tertiary); + + svg { + opacity: 0.5; + } } } } // 适配发送出去的消息中的链接卡片 .message-bubble.sent .link-message { - background: rgba(255, 255, 255, 0.1); - border-color: rgba(255, 255, 255, 0.2); + background: var(--card-bg); + border: 1px solid var(--border-color); + + .link-title { + color: var(--text-primary); + } - .link-title, .link-desc { - color: #fff; - } - - .link-icon { - background: rgba(255, 255, 255, 0.2); - color: #fff; - } - - &:hover { - background: rgba(255, 255, 255, 0.2); + color: var(--text-secondary); } } @@ -2170,4 +2171,304 @@ .spin { animation: spin 1s linear infinite; } -} \ No newline at end of file +} + + +// 名片消息 +.card-message { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + background: var(--bg-tertiary); + border-radius: 8px; + min-width: 200px; + + .card-icon { + flex-shrink: 0; + color: var(--primary); + } + + .card-info { + flex: 1; + min-width: 0; + } + + .card-name { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + margin-bottom: 2px; + } + + .card-label { + font-size: 12px; + color: var(--text-tertiary); + } +} + +// 通话消息 +.call-message { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + color: var(--text-secondary); + font-size: 13px; + + svg { + flex-shrink: 0; + } +} + +// 文件消息 +// 文件消息 +.file-message { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + background: var(--bg-tertiary); + border-radius: 8px; + min-width: 220px; + cursor: pointer; + transition: background 0.2s; + + &:hover { + background: var(--bg-hover); + } + + .file-icon { + flex-shrink: 0; + color: var(--primary); + } + + .file-info { + flex: 1; + min-width: 0; + } + + .file-name { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + margin-bottom: 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .file-meta { + font-size: 12px; + color: var(--text-tertiary); + } +} + +// 发送的文件消息样式 +.message-bubble.sent .file-message { + background: #fff; + border: 1px solid rgba(0, 0, 0, 0.1); + + .file-name { + color: #333; + } + + .file-meta { + color: #999; + } +} + +// 聊天记录消息 - 复用 link-message 基础样式 +.chat-record-message { + cursor: pointer; + + .link-header { + padding-bottom: 4px; + } + + .chat-record-preview { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; + min-width: 0; + } + + .chat-record-meta-line { + font-size: 11px; + color: var(--text-tertiary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .chat-record-list { + display: flex; + flex-direction: column; + gap: 2px; + max-height: 70px; + overflow: hidden; + } + + .chat-record-item { + font-size: 12px; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .source-name { + color: var(--text-primary); + font-weight: 500; + margin-right: 4px; + } + + .chat-record-more { + font-size: 12px; + color: var(--primary); + } + + .chat-record-desc { + font-size: 12px; + color: var(--text-secondary); + } + + .chat-record-icon { + width: 40px; + height: 40px; + border-radius: 10px; + background: var(--primary-gradient); + display: flex; + align-items: center; + justify-content: center; + color: #fff; + flex-shrink: 0; + } +} + +// 小程序消息 +.miniapp-message { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + background: var(--bg-tertiary); + border-radius: 8px; + min-width: 200px; + + .miniapp-icon { + flex-shrink: 0; + color: var(--primary); + } + + .miniapp-info { + flex: 1; + min-width: 0; + } + + .miniapp-title { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + margin-bottom: 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .miniapp-label { + font-size: 12px; + color: var(--text-tertiary); + } +} + +// 转账消息卡片 +.transfer-message { + width: 240px; + background: linear-gradient(135deg, #f59e42 0%, #f5a742 100%); + border-radius: 12px; + padding: 14px 16px; + display: flex; + gap: 12px; + align-items: center; + cursor: default; + + &.received { + background: linear-gradient(135deg, #b8b8b8 0%, #a8a8a8 100%); + } + + .transfer-icon { + flex-shrink: 0; + + svg { + width: 32px; + height: 32px; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.1)); + } + } + + .transfer-info { + flex: 1; + color: white; + + .transfer-amount { + font-size: 18px; + font-weight: 600; + margin-bottom: 2px; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); + } + + .transfer-memo { + font-size: 13px; + margin-bottom: 8px; + opacity: 0.95; + } + + .transfer-label { + font-size: 12px; + opacity: 0.85; + } + } +} + +// 发送消息中的特殊消息类型适配(除了文件和转账) +.message-bubble.sent { + .card-message, + .chat-record-message, + .miniapp-message { + background: rgba(255, 255, 255, 0.15); + + .card-name, + .miniapp-title, + .source-name { + color: white; + } + + .card-label, + .miniapp-label, + .chat-record-item, + .chat-record-meta-line, + .chat-record-desc { + color: rgba(255, 255, 255, 0.8); + } + + .card-icon, + .miniapp-icon, + .chat-record-icon { + color: white; + } + + .chat-record-more { + color: rgba(255, 255, 255, 0.9); + } + } + + .call-message { + color: rgba(255, 255, 255, 0.9); + + svg { + color: white; + } + } +} diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 9142d7b..8709aca 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -22,6 +22,15 @@ function isSystemMessage(localType: number): boolean { return SYSTEM_MESSAGE_TYPES.includes(localType) } +// 格式化文件大小 +function formatFileSize(bytes: number): string { + if (bytes === 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i] +} + interface ChatPageProps { // 保留接口以备将来扩展 } @@ -1476,6 +1485,9 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o const isImage = message.localType === 3 const isVideo = message.localType === 43 const isVoice = message.localType === 34 + const isCard = message.localType === 42 + const isCall = message.localType === 50 + const isType49 = message.localType === 49 const isSent = message.isSend === 1 const [senderAvatarUrl, setSenderAvatarUrl] = useState<string | undefined>(undefined) const [senderName, setSenderName] = useState<string | undefined>(undefined) @@ -2438,6 +2450,268 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o ) } + // 名片消息 + if (isCard) { + const cardName = message.cardNickname || message.cardUsername || '未知联系人' + return ( + <div className="card-message"> + <div className="card-icon"> + <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"> + <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" /> + <circle cx="12" cy="7" r="4" /> + </svg> + </div> + <div className="card-info"> + <div className="card-name">{cardName}</div> + <div className="card-label">个人名片</div> + </div> + </div> + ) + } + + // 通话消息 + if (isCall) { + return ( + <div className="call-message"> + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> + <path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" /> + </svg> + <span>{message.parsedContent || '[通话]'}</span> + </div> + ) + } + + // 链接消息 (AppMessage) + const isAppMsg = message.rawContent?.includes('<appmsg') || (message.parsedContent && message.parsedContent.includes('<appmsg')) + + if (isAppMsg) { + let title = '链接' + let desc = '' + let url = '' + let appMsgType = '' + + try { + const content = message.rawContent || message.parsedContent || '' + // 简单清理 XML 前缀(如 wxid:) + const xmlContent = content.substring(content.indexOf('<msg>')) + + const parser = new DOMParser() + const doc = parser.parseFromString(xmlContent, 'text/xml') + + title = doc.querySelector('title')?.textContent || '链接' + desc = doc.querySelector('des')?.textContent || '' + url = doc.querySelector('url')?.textContent || '' + appMsgType = doc.querySelector('appmsg > type')?.textContent || doc.querySelector('type')?.textContent || '' + } catch (e) { + console.error('解析 AppMsg 失败:', e) + } + + // 聊天记录 (type=19) + if (appMsgType === '19') { + const recordList = message.chatRecordList || [] + const displayTitle = title || '群聊的聊天记录' + const metaText = + recordList.length > 0 + ? `共 ${recordList.length} 条聊天记录` + : desc || '聊天记录' + + const previewItems = recordList.slice(0, 4) + + return ( + <div + className="link-message chat-record-message" + onClick={(e) => { + e.stopPropagation() + // 打开聊天记录窗口 + window.electronAPI.window.openChatHistoryWindow(session.username, message.localId) + }} + title="点击查看详细聊天记录" + > + <div className="link-header"> + <div className="link-title" title={displayTitle}> + {displayTitle} + </div> + </div> + <div className="link-body"> + <div className="chat-record-preview"> + {previewItems.length > 0 ? ( + <> + <div className="chat-record-meta-line" title={metaText}> + {metaText} + </div> + <div className="chat-record-list"> + {previewItems.map((item, i) => ( + <div key={i} className="chat-record-item"> + <span className="source-name"> + {item.sourcename ? `${item.sourcename}: ` : ''} + </span> + {item.datadesc || item.datatitle || '[媒体消息]'} + </div> + ))} + {recordList.length > previewItems.length && ( + <div className="chat-record-more">还有 {recordList.length - previewItems.length} 条…</div> + )} + </div> + </> + ) : ( + <div className="chat-record-desc"> + {desc || '点击打开查看完整聊天记录'} + </div> + )} + </div> + <div className="chat-record-icon"> + <MessageSquare size={18} /> + </div> + </div> + </div> + ) + } + + // 文件消息 (type=6) + if (appMsgType === '6') { + const fileName = message.fileName || title || '文件' + const fileSize = message.fileSize + const fileExt = message.fileExt || fileName.split('.').pop()?.toLowerCase() || '' + + // 根据扩展名选择图标 + const getFileIcon = () => { + const archiveExts = ['zip', 'rar', '7z', 'tar', 'gz', 'bz2'] + if (archiveExts.includes(fileExt)) { + return ( + <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"> + <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /> + <polyline points="7 10 12 15 17 10" /> + <line x1="12" y1="15" x2="12" y2="3" /> + </svg> + ) + } + return ( + <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"> + <path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z" /> + <polyline points="13 2 13 9 20 9" /> + </svg> + ) + } + + return ( + <div className="file-message"> + <div className="file-icon"> + {getFileIcon()} + </div> + <div className="file-info"> + <div className="file-name" title={fileName}>{fileName}</div> + <div className="file-meta"> + {fileSize ? formatFileSize(fileSize) : ''} + </div> + </div> + </div> + ) + } + + // 转账消息 (type=2000) + if (appMsgType === '2000') { + try { + const content = message.rawContent || message.content || message.parsedContent || '' + + // 添加调试日志 + console.log('[Transfer Debug] Raw content:', content.substring(0, 500)) + + const parser = new DOMParser() + const doc = parser.parseFromString(content, 'text/xml') + + const feedesc = doc.querySelector('feedesc')?.textContent || '' + const payMemo = doc.querySelector('pay_memo')?.textContent || '' + const paysubtype = doc.querySelector('paysubtype')?.textContent || '1' + + console.log('[Transfer Debug] Parsed:', { feedesc, payMemo, paysubtype, title }) + + // paysubtype: 1=待收款, 3=已收款 + const isReceived = paysubtype === '3' + + // 如果 feedesc 为空,使用 title 作为降级 + const displayAmount = feedesc || title || '微信转账' + + return ( + <div className={`transfer-message ${isReceived ? 'received' : ''}`}> + <div className="transfer-icon"> + <svg width="32" height="32" viewBox="0 0 40 40" fill="none"> + <circle cx="20" cy="20" r="18" stroke="white" strokeWidth="2" /> + <path d="M12 20h16M20 12l8 8-8 8" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /> + </svg> + </div> + <div className="transfer-info"> + <div className="transfer-amount">{displayAmount}</div> + {payMemo && <div className="transfer-memo">{payMemo}</div>} + <div className="transfer-label">{isReceived ? '已收款' : '微信转账'}</div> + </div> + </div> + ) + } catch (e) { + console.error('[Transfer Debug] Parse error:', e) + // 解析失败时的降级处理 + const feedesc = title || '微信转账' + return ( + <div className="transfer-message"> + <div className="transfer-icon"> + <svg width="32" height="32" viewBox="0 0 40 40" fill="none"> + <circle cx="20" cy="20" r="18" stroke="white" strokeWidth="2" /> + <path d="M12 20h16M20 12l8 8-8 8" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /> + </svg> + </div> + <div className="transfer-info"> + <div className="transfer-amount">{feedesc}</div> + <div className="transfer-label">微信转账</div> + </div> + </div> + ) + } + } + + // 小程序 (type=33/36) + if (appMsgType === '33' || appMsgType === '36') { + return ( + <div className="miniapp-message"> + <div className="miniapp-icon"> + <svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"> + <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/> + </svg> + </div> + <div className="miniapp-info"> + <div className="miniapp-title">{title}</div> + <div className="miniapp-label">小程序</div> + </div> + </div> + ) + } + + // 有 URL 的链接消息 + if (url) { + return ( + <div + className="link-message" + onClick={(e) => { + e.stopPropagation() + if (window.electronAPI?.shell?.openExternal) { + window.electronAPI.shell.openExternal(url) + } else { + window.open(url, '_blank') + } + }} + > + <div className="link-header"> + <div className="link-title" title={title}>{title}</div> + </div> + <div className="link-body"> + <div className="link-desc" title={desc}>{desc}</div> + <div className="link-thumb-placeholder"> + <Link size={24} /> + </div> + </div> + </div> + ) + } + } + // 表情包消息 if (isEmoji) { // ... (keep existing emoji logic) @@ -2492,67 +2766,6 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o ) } - // 解析引用消息(Links / App Messages) - // localType: 21474836529 corresponds to AppMessage which often contains links - if (isLinkMessage) { - try { - // 清理内容:移除可能的 wxid 前缀,找到 XML 起始位置 - let contentToParse = message.rawContent || message.parsedContent || ''; - const xmlStartIndex = contentToParse.indexOf('<'); - if (xmlStartIndex >= 0) { - contentToParse = contentToParse.substring(xmlStartIndex); - } - - // 处理 HTML 转义字符 - if (contentToParse.includes('<')) { - contentToParse = contentToParse - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/&/g, '&') - .replace(/"/g, '"') - .replace(/'/g, "'"); - } - - const parser = new DOMParser(); - const doc = parser.parseFromString(contentToParse, "text/xml"); - const appMsg = doc.querySelector('appmsg'); - - if (appMsg) { - const title = doc.querySelector('title')?.textContent || '未命名链接'; - const des = doc.querySelector('des')?.textContent || '无描述'; - const url = doc.querySelector('url')?.textContent || ''; - - return ( - <div - className="link-message" - onClick={(e) => { - e.stopPropagation(); - if (url) { - // 优先使用 electron 接口打开外部浏览器 - if (window.electronAPI?.shell?.openExternal) { - window.electronAPI.shell.openExternal(url); - } else { - window.open(url, '_blank'); - } - } - }} - > - <div className="link-header"> - <div className="link-content"> - <div className="link-title" title={title}>{title}</div> - <div className="link-desc" title={des}>{des}</div> - </div> - <div className="link-icon"> - <Link size={24} /> - </div> - </div> - </div> - ); - } - } catch (e) { - console.error('Failed to parse app message', e); - } - } // 普通消息 return <div className="bubble-content">{renderTextWithEmoji(cleanMessageContent(message.parsedContent))}</div> } diff --git a/src/types/analytics.ts b/src/types/analytics.ts index fbbd60d..1fd98f4 100644 --- a/src/types/analytics.ts +++ b/src/types/analytics.ts @@ -41,11 +41,12 @@ export const MESSAGE_TYPE_LABELS: Record<number, string> = { 244813135921: '文本', 3: '图片', 34: '语音', + 42: '名片', 43: '视频', 47: '表情', 48: '位置', 49: '链接/文件', - 42: '名片', + 50: '通话', 10000: '系统消息', } diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 8bd781e..8866bd9 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -11,6 +11,7 @@ export interface ElectronAPI { setTitleBarOverlay: (options: { symbolColor: string }) => void openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) => Promise<void> resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise<void> + openChatHistoryWindow: (sessionId: string, messageId: number) => Promise<boolean> } config: { get: (key: string) => Promise<unknown> @@ -106,6 +107,7 @@ export interface ElectronAPI { getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => Promise<{ success: boolean; transcript?: string; error?: string }> onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void execQuery: (kind: string, path: string | null, sql: string) => Promise<{ success: boolean; rows?: any[]; error?: string }> + getMessage: (sessionId: string, localId: number) => Promise<{ success: boolean; message?: Message; error?: string }> } image: { diff --git a/src/types/models.ts b/src/types/models.ts index 45ec73d..2600c69 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -53,8 +53,44 @@ export interface Message { // 引用消息 quotedContent?: string quotedSender?: string + // Type 49 细分字段 + linkTitle?: string // 链接/文件标题 + linkUrl?: string // 链接 URL + linkThumb?: string // 链接缩略图 + fileName?: string // 文件名 + fileSize?: number // 文件大小 + fileExt?: string // 文件扩展名 + xmlType?: string // XML 中的 type 字段 + // 名片消息 + cardUsername?: string // 名片的微信ID + cardNickname?: string // 名片的昵称 + // 聊天记录 + chatRecordTitle?: string // 聊天记录标题 + chatRecordList?: ChatRecordItem[] // 聊天记录列表 } +// 聊天记录项 +export interface ChatRecordItem { + datatype: number // 消息类型 + sourcename: string // 发送者 + sourcetime: string // 时间 + sourceheadurl?: string // 发送者头像 + datadesc?: string // 内容描述 + datatitle?: string // 标题 + fileext?: string // 文件扩展名 + datasize?: number // 文件大小 + messageuuid?: string // 消息UUID + dataurl?: string // 数据URL + datathumburl?: string // 缩略图URL + datacdnurl?: string // CDN URL + aeskey?: string // AES密钥 + md5?: string // MD5 + imgheight?: number // 图片高度 + imgwidth?: number // 图片宽度 + duration?: number // 时长(毫秒) +} + + // 分析数据 export interface AnalyticsData { totalMessages: number