diff --git a/electron/main.ts b/electron/main.ts index 732a638..a60c898 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,6 +1,7 @@ import './preload-env' import { app, BrowserWindow, ipcMain, nativeTheme, session, Tray, Menu, nativeImage } from 'electron' import { Worker } from 'worker_threads' +import { randomUUID } from 'crypto' import { join, dirname } from 'path' import { autoUpdater } from 'electron-updater' import { readFile, writeFile, mkdir, rm, readdir } from 'fs/promises' @@ -112,6 +113,7 @@ let shouldShowMain = true let isAppQuitting = false let tray: Tray | null = null let isClosePromptVisible = false +const chatHistoryPayloadStore = new Map() type WindowCloseBehavior = 'ask' | 'tray' | 'quit' @@ -769,6 +771,14 @@ function createImageViewerWindow(imagePath: string, liveVideoPath?: string) { * 创建独立的聊天记录窗口 */ function createChatHistoryWindow(sessionId: string, messageId: number) { + return createChatHistoryRouteWindow(`/chat-history/${sessionId}/${messageId}`) +} + +function createChatHistoryPayloadWindow(payloadId: string) { + return createChatHistoryRouteWindow(`/chat-history-inline/${payloadId}`) +} + +function createChatHistoryRouteWindow(route: string) { const isDev = !!process.env.VITE_DEV_SERVER_URL const iconPath = isDev ? join(__dirname, '../public/icon.ico') @@ -803,7 +813,7 @@ function createChatHistoryWindow(sessionId: string, messageId: number) { }) if (process.env.VITE_DEV_SERVER_URL) { - win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/chat-history/${sessionId}/${messageId}`) + win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#${route}`) win.webContents.on('before-input-event', (event, input) => { if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) { @@ -817,7 +827,7 @@ function createChatHistoryWindow(sessionId: string, messageId: number) { }) } else { win.loadFile(join(__dirname, '../dist/index.html'), { - hash: `/chat-history/${sessionId}/${messageId}` + hash: route }) } @@ -1260,6 +1270,23 @@ function registerIpcHandlers() { return true }) + ipcMain.handle('window:openChatHistoryPayloadWindow', (_, payload: { sessionId: string; title?: string; recordList: any[] }) => { + const payloadId = randomUUID() + chatHistoryPayloadStore.set(payloadId, { + sessionId: String(payload?.sessionId || '').trim(), + title: String(payload?.title || '').trim() || '聊天记录', + recordList: Array.isArray(payload?.recordList) ? payload.recordList : [] + }) + createChatHistoryPayloadWindow(payloadId) + return true + }) + + ipcMain.handle('window:getChatHistoryPayload', (_, payloadId: string) => { + const payload = chatHistoryPayloadStore.get(String(payloadId || '').trim()) + if (!payload) return { success: false, error: '聊天记录载荷不存在或已失效' } + return { success: true, payload } + }) + // 打开会话聊天窗口(同会话仅保留一个窗口并聚焦) ipcMain.handle('window:openSessionChatWindow', (_, sessionId: string, options?: OpenSessionChatWindowOptions) => { const win = createSessionChatWindow(sessionId, options) diff --git a/electron/preload.ts b/electron/preload.ts index b225964..f12a272 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -113,6 +113,10 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('window:openImageViewerWindow', imagePath, liveVideoPath), openChatHistoryWindow: (sessionId: string, messageId: number) => ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId), + openChatHistoryPayloadWindow: (payload: { sessionId: string; title?: string; recordList: any[] }) => + ipcRenderer.invoke('window:openChatHistoryPayloadWindow', payload), + getChatHistoryPayload: (payloadId: string) => + ipcRenderer.invoke('window:getChatHistoryPayload', payloadId), openSessionChatWindow: ( sessionId: string, options?: { diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 7971c43..e8d1c1f 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -114,8 +114,28 @@ export interface Message { datatype: number sourcename: string sourcetime: string - datadesc: string + sourceheadurl?: string + datadesc?: string datatitle?: string + fileext?: string + datasize?: number + messageuuid?: string + dataurl?: string + datathumburl?: string + datacdnurl?: string + cdndatakey?: string + cdnthumbkey?: string + aeskey?: string + md5?: string + fullmd5?: string + thumbfullmd5?: string + srcMsgLocalid?: number + imgheight?: number + imgwidth?: number + duration?: number + chatRecordTitle?: string + chatRecordDesc?: string + chatRecordList?: any[] }> _db_path?: string // 内部字段:记录消息所属数据库路径 } @@ -3120,8 +3140,28 @@ class ChatService { datatype: number sourcename: string sourcetime: string - datadesc: string + sourceheadurl?: string + datadesc?: string datatitle?: string + fileext?: string + datasize?: number + messageuuid?: string + dataurl?: string + datathumburl?: string + datacdnurl?: string + cdndatakey?: string + cdnthumbkey?: string + aeskey?: string + md5?: string + fullmd5?: string + thumbfullmd5?: string + srcMsgLocalid?: number + imgheight?: number + imgwidth?: number + duration?: number + chatRecordTitle?: string + chatRecordDesc?: string + chatRecordList?: any[] }> | undefined if (localType === 47 && content) { @@ -3873,8 +3913,28 @@ class ChatService { datatype: number sourcename: string sourcetime: string - datadesc: string + sourceheadurl?: string + datadesc?: string datatitle?: string + fileext?: string + datasize?: number + messageuuid?: string + dataurl?: string + datathumburl?: string + datacdnurl?: string + cdndatakey?: string + cdnthumbkey?: string + aeskey?: string + md5?: string + fullmd5?: string + thumbfullmd5?: string + srcMsgLocalid?: number + imgheight?: number + imgwidth?: number + duration?: number + chatRecordTitle?: string + chatRecordDesc?: string + chatRecordList?: any[] }> } { try { @@ -4057,41 +4117,8 @@ class ChatService { 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) { + const recordList = this.parseForwardChatRecordList(content) + if (recordList && recordList.length > 0) { result.chatRecordList = recordList } break @@ -4158,6 +4185,224 @@ class ChatService { } } + private parseForwardChatRecordList(content: string): any[] | undefined { + const normalized = this.decodeHtmlEntities(content || '') + if (!normalized.includes('() + const recordItemRegex = /([\s\S]*?)<\/recorditem>/gi + let recordItemMatch: RegExpExecArray | null + while ((recordItemMatch = recordItemRegex.exec(normalized)) !== null) { + const parsed = this.parseForwardChatRecordContainer(recordItemMatch[1] || '') + for (const item of parsed) { + const key = `${item.datatype}|${item.sourcename}|${item.sourcetime}|${item.datadesc || ''}|${item.datatitle || ''}|${item.messageuuid || ''}` + if (!dedupe.has(key)) { + dedupe.add(key) + items.push(item) + } + } + } + + if (items.length === 0 && normalized.includes(' 0 ? items : undefined + } + + private extractTopLevelXmlElements(source: string, tagName: string): Array<{ attrs: string; inner: string }> { + const xml = source || '' + if (!xml) return [] + + const pattern = new RegExp(`<(/?)${tagName}\\b([^>]*)>`, 'gi') + const result: Array<{ attrs: string; inner: string }> = [] + let match: RegExpExecArray | null + let depth = 0 + let openEnd = -1 + let openStart = -1 + let openAttrs = '' + + while ((match = pattern.exec(xml)) !== null) { + const isClosing = match[1] === '/' + const attrs = match[2] || '' + const rawTag = match[0] || '' + const selfClosing = !isClosing && /\/\s*>$/.test(rawTag) + + if (!isClosing) { + if (depth === 0) { + openStart = match.index + openEnd = pattern.lastIndex + openAttrs = attrs + } + if (!selfClosing) { + depth += 1 + } else if (depth === 0 && openEnd >= 0) { + result.push({ attrs: openAttrs, inner: '' }) + openStart = -1 + openEnd = -1 + openAttrs = '' + } + continue + } + + if (depth <= 0) continue + depth -= 1 + if (depth === 0 && openEnd >= 0 && openStart >= 0) { + result.push({ + attrs: openAttrs, + inner: xml.slice(openEnd, match.index) + }) + openStart = -1 + openEnd = -1 + openAttrs = '' + } + } + + return result + } + + private parseForwardChatRecordContainer(containerXml: string): any[] { + const source = containerXml || '' + if (!source) return [] + + const segments: string[] = [source] + const decodedContainer = this.decodeHtmlEntities(source) + if (decodedContainer !== source) { + segments.push(decodedContainer) + } + + const cdataRegex = //g + let cdataMatch: RegExpExecArray | null + while ((cdataMatch = cdataRegex.exec(source)) !== null) { + const cdataInner = cdataMatch[1] || '' + if (!cdataInner) continue + segments.push(cdataInner) + const decodedInner = this.decodeHtmlEntities(cdataInner) + if (decodedInner !== cdataInner) { + segments.push(decodedInner) + } + } + + const items: any[] = [] + const seen = new Set() + for (const segment of segments) { + if (!segment) continue + const dataItems = this.extractTopLevelXmlElements(segment, 'dataitem') + for (const dataItem of dataItems) { + const parsed = this.parseForwardChatRecordDataItem(dataItem.inner || '', dataItem.attrs || '') + if (!parsed) continue + const key = `${parsed.datatype}|${parsed.sourcename}|${parsed.sourcetime}|${parsed.datadesc || ''}|${parsed.datatitle || ''}|${parsed.messageuuid || ''}` + if (!seen.has(key)) { + seen.add(key) + items.push(parsed) + } + } + } + + if (items.length > 0) return items + const fallback = this.parseForwardChatRecordDataItem(source, '') + return fallback ? [fallback] : [] + } + + private parseForwardChatRecordDataItem(itemXml: string, attrs: string): any | null { + const datatypeMatch = /datatype\s*=\s*["']?(\d+)["']?/i.exec(attrs || '') + const datatype = datatypeMatch ? parseInt(datatypeMatch[1], 10) : parseInt(this.extractXmlValue(itemXml, 'datatype') || '0', 10) + const sourcename = this.decodeHtmlEntities(this.extractXmlValue(itemXml, 'sourcename') || '') + const sourcetime = this.extractXmlValue(itemXml, 'sourcetime') || '' + const sourceheadurl = this.extractXmlValue(itemXml, 'sourceheadurl') || undefined + const datadesc = this.decodeHtmlEntities( + this.extractXmlValue(itemXml, 'datadesc') || + this.extractXmlValue(itemXml, 'content') || + '' + ) || undefined + const datatitle = this.decodeHtmlEntities(this.extractXmlValue(itemXml, 'datatitle') || '') || undefined + const fileext = this.extractXmlValue(itemXml, 'fileext') || undefined + const datasize = parseInt(this.extractXmlValue(itemXml, 'datasize') || '0', 10) || undefined + const messageuuid = this.extractXmlValue(itemXml, 'messageuuid') || undefined + const dataurl = this.decodeHtmlEntities(this.extractXmlValue(itemXml, 'dataurl') || '') || undefined + const datathumburl = this.decodeHtmlEntities( + this.extractXmlValue(itemXml, 'datathumburl') || + this.extractXmlValue(itemXml, 'thumburl') || + this.extractXmlValue(itemXml, 'cdnthumburl') || + '' + ) || undefined + const datacdnurl = this.decodeHtmlEntities( + this.extractXmlValue(itemXml, 'datacdnurl') || + this.extractXmlValue(itemXml, 'cdnurl') || + this.extractXmlValue(itemXml, 'cdndataurl') || + '' + ) || undefined + const cdndatakey = this.extractXmlValue(itemXml, 'cdndatakey') || undefined + const cdnthumbkey = this.extractXmlValue(itemXml, 'cdnthumbkey') || undefined + const aeskey = this.decodeHtmlEntities( + this.extractXmlValue(itemXml, 'aeskey') || + this.extractXmlValue(itemXml, 'qaeskey') || + '' + ) || undefined + const md5 = this.extractXmlValue(itemXml, 'md5') || this.extractXmlValue(itemXml, 'datamd5') || undefined + const fullmd5 = this.extractXmlValue(itemXml, 'fullmd5') || undefined + const thumbfullmd5 = this.extractXmlValue(itemXml, 'thumbfullmd5') || undefined + const srcMsgLocalid = parseInt(this.extractXmlValue(itemXml, 'srcMsgLocalid') || '0', 10) || undefined + const imgheight = parseInt(this.extractXmlValue(itemXml, 'imgheight') || '0', 10) || undefined + const imgwidth = parseInt(this.extractXmlValue(itemXml, 'imgwidth') || '0', 10) || undefined + const duration = parseInt(this.extractXmlValue(itemXml, 'duration') || '0', 10) || undefined + const nestedRecordXml = this.extractXmlValue(itemXml, 'recordxml') || undefined + const chatRecordTitle = this.decodeHtmlEntities( + (nestedRecordXml && this.extractXmlValue(nestedRecordXml, 'title')) || + datatitle || + '' + ) || undefined + const chatRecordDesc = this.decodeHtmlEntities( + (nestedRecordXml && this.extractXmlValue(nestedRecordXml, 'desc')) || + datadesc || + '' + ) || undefined + const chatRecordList = + datatype === 17 && nestedRecordXml + ? this.parseForwardChatRecordContainer(nestedRecordXml) + : undefined + + if (!(datatype || sourcename || datadesc || datatitle || messageuuid || srcMsgLocalid)) return null + + return { + datatype: Number.isFinite(datatype) ? datatype : 0, + sourcename, + sourcetime, + sourceheadurl, + datadesc, + datatitle, + fileext, + datasize, + messageuuid, + dataurl, + datathumburl, + datacdnurl, + cdndatakey, + cdnthumbkey, + aeskey, + md5, + fullmd5, + thumbfullmd5, + srcMsgLocalid, + imgheight, + imgwidth, + duration, + chatRecordTitle, + chatRecordDesc, + chatRecordList + } + } + //手动查找 media_*.db 文件(当 WCDB DLL 不支持 listMediaDbs 时的 fallback) private async findMediaDbsManually(): Promise { try { diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index cd13b16..6929f59 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -49,6 +49,20 @@ interface ChatLabMessage { chatRecords?: any[] // 嵌套的聊天记录 } +interface ForwardChatRecordItem { + datatype: number + sourcename: string + sourcetime: string + sourceheadurl?: string + datadesc?: string + datatitle?: string + fileext?: string + datasize?: number + chatRecordTitle?: string + chatRecordDesc?: string + chatRecordList?: ForwardChatRecordItem[] +} + interface ChatLabExport { chatlab: ChatLabHeader meta: ChatLabMeta @@ -1231,12 +1245,13 @@ class ExportService { * 转换微信消息类型到 ChatLab 类型 */ private convertMessageType(localType: number, content: string): number { - // 检查 XML 中的 type 标签(支持大 localType 的情况) - const xmlTypeMatch = /(\d+)<\/type>/i.exec(content) - const xmlType = xmlTypeMatch ? parseInt(xmlTypeMatch[1]) : null + const normalized = this.normalizeAppMessageContent(content || '') + const xmlTypeRaw = this.extractAppMessageType(normalized) + const xmlType = xmlTypeRaw ? Number.parseInt(xmlTypeRaw, 10) : null + const looksLikeAppMessage = localType === 49 || normalized.includes('') // 特殊处理 type 49 或 XML type - if (localType === 49 || xmlType) { + if (looksLikeAppMessage || xmlType) { const subType = xmlType || 0 switch (subType) { case 6: return 4 // 文件 -> FILE @@ -1248,7 +1263,7 @@ class ExportService { case 5: case 49: return 7 // 链接 -> LINK default: - if (xmlType) return 7 // 有 XML type 但未知,默认为链接 + if (xmlType || looksLikeAppMessage) return 7 // 有 appmsg 但未知,默认为链接 } } return MESSAGE_TYPE_MAP[localType] ?? 99 // 未知类型 -> OTHER @@ -1549,9 +1564,8 @@ class ExportService { ): string | null { if (!content) return null - // 检查 XML 中的 type 标签(支持大 localType 的情况) - const xmlTypeMatch = /(\d+)<\/type>/i.exec(content) - const xmlType = xmlTypeMatch ? xmlTypeMatch[1] : null + const normalizedContent = this.normalizeAppMessageContent(content) + const xmlType = this.extractAppMessageType(normalizedContent) switch (localType) { case 1: // 文本 @@ -1587,15 +1601,15 @@ class ExportService { return locParts.length > 0 ? `[位置] ${locParts.join(' ')}` : '[位置]' } case 49: { - const title = this.extractXmlValue(content, 'title') - const type = this.extractXmlValue(content, 'type') - const songName = this.extractXmlValue(content, 'songname') + const title = this.extractXmlValue(normalizedContent, 'title') + const type = this.extractAppMessageType(normalizedContent) + const songName = this.extractXmlValue(normalizedContent, 'songname') // 转账消息特殊处理 if (type === '2000') { - const feedesc = this.extractXmlValue(content, 'feedesc') - const payMemo = this.extractXmlValue(content, 'pay_memo') - const transferPrefix = this.getTransferPrefix(content, myWxid, senderWxid, isSend) + const feedesc = this.extractXmlValue(normalizedContent, 'feedesc') + const payMemo = this.extractXmlValue(normalizedContent, 'pay_memo') + const transferPrefix = this.getTransferPrefix(normalizedContent, myWxid, senderWxid, isSend) if (feedesc) { return payMemo ? `${transferPrefix} ${feedesc} ${payMemo}` : `${transferPrefix} ${feedesc}` } @@ -1604,7 +1618,7 @@ class ExportService { if (type === '3') return songName ? `[音乐] ${songName}` : (title ? `[音乐] ${title}` : '[音乐]') if (type === '6') return title ? `[文件] ${title}` : '[文件]' - if (type === '19') return title ? `[聊天记录] ${title}` : '[聊天记录]' + if (type === '19') return this.formatForwardChatRecordContent(normalizedContent) if (type === '33' || type === '36') return title ? `[小程序] ${title}` : '[小程序]' if (type === '57') return title || '[引用消息]' if (type === '5' || type === '49') return title ? `[链接] ${title}` : '[链接]' @@ -1646,7 +1660,7 @@ class ExportService { // 其他类型 if (xmlType === '3') return title ? `[音乐] ${title}` : '[音乐]' if (xmlType === '6') return title ? `[文件] ${title}` : '[文件]' - if (xmlType === '19') return title ? `[聊天记录] ${title}` : '[聊天记录]' + if (xmlType === '19') return this.formatForwardChatRecordContent(normalizedContent) if (xmlType === '33' || xmlType === '36') return title ? `[小程序] ${title}` : '[小程序]' if (xmlType === '57') return title || '[引用消息]' if (xmlType === '5' || xmlType === '49') return title ? `[链接] ${title}` : '[链接]' @@ -1656,7 +1670,7 @@ class ExportService { } // 最后尝试提取文本内容 - return this.stripSenderPrefix(content) || null + return this.stripSenderPrefix(normalizedContent) || null } } @@ -1719,8 +1733,8 @@ class ExportService { const normalized = this.normalizeAppMessageContent(safeContent) const isAppMessage = normalized.includes('') if (localType === 49 || isAppMessage) { - const typeMatch = /(\d+)<\/type>/i.exec(normalized) - const subType = typeMatch ? parseInt(typeMatch[1], 10) : 0 + const subTypeRaw = this.extractAppMessageType(normalized) + const subType = subTypeRaw ? parseInt(subTypeRaw, 10) : 0 const title = this.extractXmlValue(normalized, 'title') || this.extractXmlValue(normalized, 'appname') // 群公告消息(type 87) @@ -1766,12 +1780,7 @@ class ExportService { return `[红包]${title || '微信红包'}` } if (subType === 19 || normalized.includes('')) { if (xmlType === '6') return 'file' return 'text' } @@ -2023,8 +2033,8 @@ class ExportService { private getMessageTypeName(localType: number, content?: string): string { // 检查 XML 中的 type 标签(支持大 localType 的情况) if (content) { - const xmlTypeMatch = /(\d+)<\/type>/i.exec(content) - const xmlType = xmlTypeMatch ? xmlTypeMatch[1] : null + const normalized = this.normalizeAppMessageContent(content) + const xmlType = this.extractAppMessageType(normalized) if (xmlType) { switch (xmlType) { @@ -2146,45 +2156,38 @@ class ExportService { /** * 解析合并转发的聊天记录 (Type 19) */ - private parseChatHistory(content: string): any[] | undefined { + private parseChatHistory(content: string): ForwardChatRecordItem[] | undefined { try { - const type = this.extractXmlValue(content, 'type') - if (type !== '19') return undefined + const normalized = this.normalizeAppMessageContent(content || '') + const appMsgType = this.extractAppMessageType(normalized) + if (appMsgType !== '19' && !normalized.includes('[\s\S]*?[\s\S]*?<\/recorditem>/.exec(content) - if (!match) return undefined + const items: ForwardChatRecordItem[] = [] + const dedupe = new Set() + const recordItemRegex = /([\s\S]*?)<\/recorditem>/gi + let recordItemMatch: RegExpExecArray | null + while ((recordItemMatch = recordItemRegex.exec(normalized)) !== null) { + const parsedItems = this.parseForwardChatRecordContainer(recordItemMatch[1] || '') + for (const item of parsedItems) { + const dedupeKey = `${item.datatype}|${item.sourcename}|${item.sourcetime}|${item.datadesc || ''}|${item.datatitle || ''}` + if (!dedupe.has(dedupeKey)) { + dedupe.add(dedupeKey) + items.push(item) + } + } + } - const innerXml = match[1] - const items: any[] = [] - const itemRegex = /([\s\S]*?)<\/dataitem>/g - let itemMatch - - 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 = this.extractXmlValue(body, 'sourcename') - const sourcetime = this.extractXmlValue(body, 'sourcetime') - const sourceheadurl = this.extractXmlValue(body, 'sourceheadurl') - const datadesc = this.extractXmlValue(body, 'datadesc') - const datatitle = this.extractXmlValue(body, 'datatitle') - const fileext = this.extractXmlValue(body, 'fileext') - const datasize = parseInt(this.extractXmlValue(body, 'datasize') || '0') - - items.push({ - datatype, - sourcename, - sourcetime, - sourceheadurl, - datadesc: this.decodeHtmlEntities(datadesc), - datatitle: this.decodeHtmlEntities(datatitle), - fileext, - datasize - }) + if (items.length === 0 && normalized.includes(' 0 ? items : undefined @@ -2194,6 +2197,139 @@ class ExportService { } } + private parseForwardChatRecordContainer(containerXml: string): ForwardChatRecordItem[] { + const source = containerXml || '' + if (!source) return [] + + const segments: string[] = [source] + const decodedContainer = this.decodeHtmlEntities(source) + if (decodedContainer !== source) { + segments.push(decodedContainer) + } + + const cdataRegex = //g + let cdataMatch: RegExpExecArray | null + while ((cdataMatch = cdataRegex.exec(source)) !== null) { + const cdataInner = cdataMatch[1] || '' + if (cdataInner) { + segments.push(cdataInner) + const decodedInner = this.decodeHtmlEntities(cdataInner) + if (decodedInner !== cdataInner) { + segments.push(decodedInner) + } + } + } + + const items: ForwardChatRecordItem[] = [] + const seen = new Set() + for (const segment of segments) { + if (!segment) continue + const dataItemRegex = /]*)>([\s\S]*?)<\/dataitem>/gi + let dataItemMatch: RegExpExecArray | null + while ((dataItemMatch = dataItemRegex.exec(segment)) !== null) { + const parsed = this.parseForwardChatRecordDataItem(dataItemMatch[2] || '', dataItemMatch[1] || '') + if (!parsed) continue + const key = `${parsed.datatype}|${parsed.sourcename}|${parsed.sourcetime}|${parsed.datadesc || ''}|${parsed.datatitle || ''}` + if (!seen.has(key)) { + seen.add(key) + items.push(parsed) + } + } + } + + if (items.length > 0) return items + const fallback = this.parseForwardChatRecordDataItem(source, '') + return fallback ? [fallback] : [] + } + + private parseForwardChatRecordDataItem(body: string, attrs: string): ForwardChatRecordItem | null { + const datatypeByAttr = /datatype\s*=\s*["']?(\d+)["']?/i.exec(attrs || '') + const datatypeRaw = datatypeByAttr?.[1] || this.extractXmlValue(body, 'datatype') || '0' + const datatype = Number.parseInt(datatypeRaw, 10) + const sourcename = this.decodeHtmlEntities(this.extractXmlValue(body, 'sourcename')) + const sourcetime = this.extractXmlValue(body, 'sourcetime') + const sourceheadurl = this.extractXmlValue(body, 'sourceheadurl') + const datadesc = this.decodeHtmlEntities(this.extractXmlValue(body, 'datadesc') || this.extractXmlValue(body, 'content')) + const datatitle = this.decodeHtmlEntities(this.extractXmlValue(body, 'datatitle')) + const fileext = this.extractXmlValue(body, 'fileext') + const datasizeRaw = this.extractXmlValue(body, 'datasize') + const datasize = datasizeRaw ? Number.parseInt(datasizeRaw, 10) : 0 + const nestedRecordXml = this.extractXmlValue(body, 'recordxml') || '' + const nestedRecordList = + datatype === 17 && nestedRecordXml + ? this.parseForwardChatRecordContainer(nestedRecordXml) + : undefined + const chatRecordTitle = this.decodeHtmlEntities( + (nestedRecordXml && this.extractXmlValue(nestedRecordXml, 'title')) || datatitle || '' + ) + const chatRecordDesc = this.decodeHtmlEntities( + (nestedRecordXml && this.extractXmlValue(nestedRecordXml, 'desc')) || datadesc || '' + ) + + if (!sourcename && !datadesc && !datatitle) return null + + return { + datatype: Number.isFinite(datatype) ? datatype : 0, + sourcename: sourcename || '', + sourcetime: sourcetime || '', + sourceheadurl: sourceheadurl || undefined, + datadesc: datadesc || undefined, + datatitle: datatitle || undefined, + fileext: fileext || undefined, + datasize: Number.isFinite(datasize) && datasize > 0 ? datasize : undefined, + chatRecordTitle: chatRecordTitle || undefined, + chatRecordDesc: chatRecordDesc || undefined, + chatRecordList: nestedRecordList && nestedRecordList.length > 0 ? nestedRecordList : undefined + } + } + + private formatForwardChatRecordItemText(item: ForwardChatRecordItem): string { + const desc = (item.datadesc || '').trim() + const title = (item.datatitle || '').trim() + if (desc) return desc + if (title) return title + switch (item.datatype) { + case 3: return '[图片]' + case 34: return '[语音消息]' + case 43: return '[视频]' + case 47: return '[动画表情]' + case 49: + case 8: return title ? `[文件] ${title}` : '[文件]' + case 17: return item.chatRecordDesc || title || '[聊天记录]' + default: return '[消息]' + } + } + + private buildForwardChatRecordLines(record: ForwardChatRecordItem, depth = 0): string[] { + const indent = depth > 0 ? `${' '.repeat(Math.min(depth, 8))}` : '' + const senderPrefix = record.sourcename ? `${record.sourcename}: ` : '' + if (record.chatRecordList && record.chatRecordList.length > 0) { + const nestedTitle = record.chatRecordTitle || record.datatitle || record.chatRecordDesc || '聊天记录' + const header = `${indent}${senderPrefix}[转发的聊天记录]${nestedTitle}` + const nestedLines = record.chatRecordList.flatMap((item) => this.buildForwardChatRecordLines(item, depth + 1)) + return [header, ...nestedLines] + } + const text = this.formatForwardChatRecordItemText(record) + return [`${indent}${senderPrefix}${text}`] + } + + private formatForwardChatRecordContent(content: string): string { + const normalized = this.normalizeAppMessageContent(content || '') + const forwardName = + this.extractXmlValue(normalized, 'nickname') || + this.extractXmlValue(normalized, 'title') || + this.extractXmlValue(normalized, 'des') || + this.extractXmlValue(normalized, 'displayname') || + '聊天记录' + const records = this.parseChatHistory(normalized) + if (!records || records.length === 0) { + return forwardName ? `[转发的聊天记录]${forwardName}` : '[转发的聊天记录]' + } + + const lines = records.flatMap((record) => this.buildForwardChatRecordLines(record)) + return `${forwardName ? `[转发的聊天记录]${forwardName}` : '[转发的聊天记录]'}\n${lines.join('\n')}` + } + /** * 解码 HTML 实体 */ @@ -2230,7 +2366,8 @@ class ExportService { private extractAppMessageType(content: string): string { if (!content) return '' - const appmsgMatch = /([\s\S]*?)<\/appmsg>/i.exec(content) + const normalized = this.normalizeAppMessageContent(content) + const appmsgMatch = /([\s\S]*?)<\/appmsg>/i.exec(normalized) if (appmsgMatch) { const appmsgInner = appmsgMatch[1] .replace(//gi, '') @@ -2238,7 +2375,11 @@ class ExportService { const typeMatch = /([\s\S]*?)<\/type>/i.exec(appmsgInner) if (typeMatch) return typeMatch[1].trim() } - return this.extractXmlValue(content, 'type') + if (!normalized.includes('')) { + return '' + } + const fallbackTypeMatch = /(\d+)<\/type>/i.exec(normalized) + return fallbackTypeMatch ? fallbackTypeMatch[1] : '' } private looksLikeWxid(text: string): boolean { @@ -2600,7 +2741,7 @@ class ExportService { const isAppMessage = localType === 49 || normalized.includes('') if (!isAppMessage) return null - const subType = this.extractXmlValue(normalized, 'type') + const subType = this.extractAppMessageType(normalized) if (subType && subType !== '5' && subType !== '49') return null const url = this.normalizeHtmlLinkUrl(this.extractXmlValue(normalized, 'url')) @@ -3444,11 +3585,12 @@ class ExportService { } else if (localType === 43 && content) { // 视频消息 videoMd5 = videoMd5 || this.extractVideoMd5(content) - } else if (collectMode === 'full' && localType === 49 && content) { - // 检查是否是聊天记录消息(type=19) - const xmlType = this.extractXmlValue(content, 'type') + } else if (collectMode === 'full' && content && (localType === 49 || content.includes('} /> } /> } /> + } /> diff --git a/src/pages/ChatHistoryPage.scss b/src/pages/ChatHistoryPage.scss index c108394..7465fae 100644 --- a/src/pages/ChatHistoryPage.scss +++ b/src/pages/ChatHistoryPage.scss @@ -2,15 +2,16 @@ display: flex; flex-direction: column; height: 100vh; - background: var(--bg-primary); + background: + linear-gradient(180deg, color-mix(in srgb, var(--bg-primary) 96%, white) 0%, var(--bg-primary) 100%); .history-list { flex: 1; overflow-y: auto; - padding: 16px; + padding: 18px 18px 28px; display: flex; flex-direction: column; - gap: 12px; + gap: 0; .status-msg { text-align: center; @@ -30,8 +31,9 @@ .history-item { display: flex; - gap: 12px; + gap: 14px; align-items: flex-start; + padding: 14px 0 0; &.error-item { padding: 12px; @@ -43,65 +45,70 @@ justify-content: center; } - .avatar { - width: 40px; - height: 40px; - border-radius: 4px; + .history-avatar { + width: 36px; + height: 36px; + border-radius: 8px; overflow: hidden; flex-shrink: 0; - background: var(--bg-tertiary); + border: none; + box-shadow: none; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + display: flex; + align-items: center; + justify-content: center; - img { + .avatar-component.avatar-inner { width: 100%; height: 100%; - object-fit: cover; - } + border-radius: inherit; + background: transparent; - .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; + img.avatar-image { + // Forwarded record head images may include a light matte edge. + // Slightly zoom in to crop that edge and align with normal chat avatars. + transform: scale(1.12); + transform-origin: center; + } } } .content-wrapper { flex: 1; min-width: 0; + padding-bottom: 18px; + border-bottom: 1px solid color-mix(in srgb, var(--border-color) 82%, transparent); .header { display: flex; justify-content: space-between; - align-items: center; - margin-bottom: 6px; + align-items: flex-start; + gap: 12px; + margin-bottom: 4px; .sender { - font-size: 14px; - font-weight: 500; - color: var(--text-primary); + font-size: 13px; + font-weight: 400; + color: color-mix(in srgb, var(--text-secondary) 82%, transparent); + line-height: 1.3; } .time { font-size: 12px; - color: var(--text-tertiary); + color: color-mix(in srgb, var(--text-tertiary) 92%, transparent); flex-shrink: 0; margin-left: 8px; + line-height: 1.3; } } .bubble { - background: var(--bg-secondary); - padding: 10px 14px; - border-radius: 18px 18px 18px 4px; + background: transparent; + padding: 0; + border-radius: 0; word-wrap: break-word; max-width: 100%; - display: inline-block; + display: block; &.image-bubble { padding: 0; @@ -109,8 +116,8 @@ } .text-content { - font-size: 14px; - line-height: 1.6; + font-size: 15px; + line-height: 1.7; color: var(--text-primary); white-space: pre-wrap; word-break: break-word; @@ -118,23 +125,84 @@ .media-content { img { - max-width: 100%; - max-height: 300px; - border-radius: 8px; + max-width: min(100%, 420px); + max-height: 320px; + border-radius: 12px; display: block; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08); + background: color-mix(in srgb, var(--bg-secondary) 88%, transparent); } .media-tip { - padding: 8px 12px; + padding: 6px 0; color: var(--text-tertiary); font-size: 13px; } } .media-placeholder { - font-size: 14px; + font-size: 13px; color: var(--text-secondary); - padding: 4px 0; + padding: 4px 0 0; + } + + .nested-chat-record-card { + min-width: 220px; + max-width: 320px; + background: color-mix(in srgb, var(--bg-secondary) 97%, #f5f7fb); + border: 1px solid var(--border-color); + border-radius: 14px; + overflow: hidden; + padding: 0; + text-align: left; + cursor: default; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.04); + + &.clickable { + cursor: pointer; + transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease; + + &:hover { + transform: translateY(-1px); + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08); + border-color: color-mix(in srgb, var(--primary) 28%, var(--border-color)); + } + } + + &:disabled { + border: 1px solid var(--border-color); + opacity: 1; + } + } + + .nested-chat-record-title { + padding: 13px 15px 9px; + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + } + + .nested-chat-record-list { + padding: 0 15px 11px; + display: flex; + flex-direction: column; + gap: 4px; + border-bottom: 1px solid var(--border-color); + } + + .nested-chat-record-line { + font-size: 13px; + line-height: 1.45; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .nested-chat-record-footer { + padding: 8px 15px 11px; + font-size: 12px; + color: var(--text-tertiary); } } } diff --git a/src/pages/ChatHistoryPage.tsx b/src/pages/ChatHistoryPage.tsx index 18c4f56..830b389 100644 --- a/src/pages/ChatHistoryPage.tsx +++ b/src/pages/ChatHistoryPage.tsx @@ -3,10 +3,13 @@ import { useParams, useLocation } from 'react-router-dom' import { ChatRecordItem } from '../types/models' import TitleBar from '../components/TitleBar' import { ErrorBoundary } from '../components/ErrorBoundary' +import { Avatar } from '../components/Avatar' import './ChatHistoryPage.scss' +const forwardedImageCache = new Map() + export default function ChatHistoryPage() { - const params = useParams<{ sessionId: string; messageId: string }>() + const params = useParams<{ sessionId: string; messageId: string; payloadId: string }>() const location = useLocation() const [recordList, setRecordList] = useState([]) const [loading, setLoading] = useState(true) @@ -30,64 +33,212 @@ export default function ChatHistoryPage() { .replace(/'/g, "'") } + const extractTopLevelXmlElements = (source: string, tagName: string): Array<{ attrs: string; inner: string }> => { + const xml = source || '' + if (!xml) return [] + + const pattern = new RegExp(`<(/?)${tagName}\\b([^>]*)>`, 'gi') + const result: Array<{ attrs: string; inner: string }> = [] + let match: RegExpExecArray | null + let depth = 0 + let openEnd = -1 + let openStart = -1 + let openAttrs = '' + + while ((match = pattern.exec(xml)) !== null) { + const isClosing = match[1] === '/' + const attrs = match[2] || '' + const rawTag = match[0] || '' + const selfClosing = !isClosing && /\/\s*>$/.test(rawTag) + + if (!isClosing) { + if (depth === 0) { + openStart = match.index + openEnd = pattern.lastIndex + openAttrs = attrs + } + if (!selfClosing) { + depth += 1 + } else if (depth === 0 && openEnd >= 0) { + result.push({ attrs: openAttrs, inner: '' }) + openStart = -1 + openEnd = -1 + openAttrs = '' + } + continue + } + + if (depth <= 0) continue + depth -= 1 + if (depth === 0 && openEnd >= 0 && openStart >= 0) { + result.push({ + attrs: openAttrs, + inner: xml.slice(openEnd, match.index) + }) + openStart = -1 + openEnd = -1 + openAttrs = '' + } + } + + return result + } + + const parseChatRecordDataItem = (body: string, attrs = ''): ChatRecordItem | null => { + const datatypeMatch = /datatype\s*=\s*["']?(\d+)["']?/i.exec(attrs || '') + const datatype = datatypeMatch ? parseInt(datatypeMatch[1], 10) : parseInt(extractXmlValue(body, 'datatype') || '0', 10) + + const sourcename = decodeHtmlEntities(extractXmlValue(body, 'sourcename')) || '' + const sourcetime = extractXmlValue(body, 'sourcetime') || '' + const sourceheadurl = extractXmlValue(body, 'sourceheadurl') || undefined + const datadesc = decodeHtmlEntities(extractXmlValue(body, 'datadesc') || extractXmlValue(body, 'content')) || undefined + const datatitle = decodeHtmlEntities(extractXmlValue(body, 'datatitle')) || undefined + const fileext = extractXmlValue(body, 'fileext') || undefined + const datasize = parseInt(extractXmlValue(body, 'datasize') || '0', 10) || undefined + const messageuuid = extractXmlValue(body, 'messageuuid') || undefined + + const dataurl = decodeHtmlEntities(extractXmlValue(body, 'dataurl')) || undefined + const datathumburl = decodeHtmlEntities( + extractXmlValue(body, 'datathumburl') || + extractXmlValue(body, 'thumburl') || + extractXmlValue(body, 'cdnthumburl') + ) || undefined + const datacdnurl = decodeHtmlEntities( + extractXmlValue(body, 'datacdnurl') || + extractXmlValue(body, 'cdnurl') || + extractXmlValue(body, 'cdndataurl') + ) || undefined + const cdndatakey = decodeHtmlEntities(extractXmlValue(body, 'cdndatakey')) || undefined + const cdnthumbkey = decodeHtmlEntities(extractXmlValue(body, 'cdnthumbkey')) || undefined + const aeskey = decodeHtmlEntities(extractXmlValue(body, 'aeskey') || extractXmlValue(body, 'qaeskey')) || undefined + const md5 = extractXmlValue(body, 'md5') || extractXmlValue(body, 'datamd5') || undefined + const fullmd5 = extractXmlValue(body, 'fullmd5') || undefined + const thumbfullmd5 = extractXmlValue(body, 'thumbfullmd5') || undefined + const srcMsgLocalid = parseInt(extractXmlValue(body, 'srcMsgLocalid') || '0', 10) || undefined + const imgheight = parseInt(extractXmlValue(body, 'imgheight') || '0', 10) || undefined + const imgwidth = parseInt(extractXmlValue(body, 'imgwidth') || '0', 10) || undefined + const duration = parseInt(extractXmlValue(body, 'duration') || '0', 10) || undefined + const nestedRecordXml = extractXmlValue(body, 'recordxml') || undefined + const chatRecordTitle = decodeHtmlEntities( + (nestedRecordXml && extractXmlValue(nestedRecordXml, 'title')) || + datatitle || + '' + ) || undefined + const chatRecordDesc = decodeHtmlEntities( + (nestedRecordXml && extractXmlValue(nestedRecordXml, 'desc')) || + datadesc || + '' + ) || undefined + const chatRecordList = + datatype === 17 && nestedRecordXml + ? parseChatRecordContainer(nestedRecordXml) + : undefined + + if (!(datatype || sourcename || datadesc || datatitle || messageuuid || srcMsgLocalid)) return null + + return { + datatype: Number.isFinite(datatype) ? datatype : 0, + sourcename, + sourcetime, + sourceheadurl, + datadesc, + datatitle, + fileext, + datasize, + messageuuid, + dataurl, + datathumburl, + datacdnurl, + cdndatakey, + cdnthumbkey, + aeskey, + md5, + fullmd5, + thumbfullmd5, + srcMsgLocalid, + imgheight, + imgwidth, + duration, + chatRecordTitle, + chatRecordDesc, + chatRecordList + } + } + + const parseChatRecordContainer = (containerXml: string): ChatRecordItem[] => { + const source = containerXml || '' + if (!source) return [] + + const segments: string[] = [source] + const decodedContainer = decodeHtmlEntities(source) + if (decodedContainer && decodedContainer !== source) { + segments.push(decodedContainer) + } + + const cdataRegex = //g + let cdataMatch: RegExpExecArray | null + while ((cdataMatch = cdataRegex.exec(source)) !== null) { + const cdataInner = cdataMatch[1] || '' + if (!cdataInner) continue + segments.push(cdataInner) + const decodedInner = decodeHtmlEntities(cdataInner) + if (decodedInner && decodedInner !== cdataInner) { + segments.push(decodedInner) + } + } + + const items: ChatRecordItem[] = [] + const dedupe = new Set() + for (const segment of segments) { + if (!segment) continue + const dataItems = extractTopLevelXmlElements(segment, 'dataitem') + for (const dataItem of dataItems) { + const item = parseChatRecordDataItem(dataItem.inner || '', dataItem.attrs || '') + if (!item) continue + const key = `${item.datatype}|${item.sourcename}|${item.sourcetime}|${item.datadesc || ''}|${item.datatitle || ''}|${item.messageuuid || ''}` + if (!dedupe.has(key)) { + dedupe.add(key) + items.push(item) + } + } + } + + if (items.length > 0) return items + const fallback = parseChatRecordDataItem(source, '') + return fallback ? [fallback] : [] + } + // 前端兜底解析合并转发聊天记录 const parseChatHistory = (content: string): ChatRecordItem[] | undefined => { try { - const type = extractXmlValue(content, 'type') - if (type !== '19') return undefined + const decodedContent = decodeHtmlEntities(content) || content + const type = extractXmlValue(decodedContent, 'type') + if (type !== '19' && !decodedContent.includes('[\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 + const dedupe = new Set() + const recordItemRegex = /([\s\S]*?)<\/recorditem>/gi + let recordItemMatch: RegExpExecArray | null + while ((recordItemMatch = recordItemRegex.exec(decodedContent)) !== null) { + const parsedItems = parseChatRecordContainer(recordItemMatch[1] || '') + for (const item of parsedItems) { + const key = `${item.datatype}|${item.sourcename}|${item.sourcetime}|${item.datadesc || ''}|${item.datatitle || ''}|${item.messageuuid || ''}` + if (!dedupe.has(key)) { + dedupe.add(key) + items.push(item) + } + } + } - 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 - }) + if (items.length === 0 && decodedContent.includes(' 0 ? items : undefined @@ -115,9 +266,34 @@ export default function ChatHistoryPage() { return { sid: '', mid: '' } } + const ids = getIds() + const payloadId = params.payloadId || (() => { + const match = /^\/chat-history-inline\/([^/]+)/.exec(location.pathname) + return match ? match[1] : '' + })() + useEffect(() => { const loadData = async () => { - const { sid, mid } = getIds() + if (payloadId) { + try { + const result = await window.electronAPI.window.getChatHistoryPayload(payloadId) + if (result.success && result.payload) { + setRecordList(Array.isArray(result.payload.recordList) ? result.payload.recordList : []) + setTitle(result.payload.title || '聊天记录') + setError('') + } else { + setError(result.error || '聊天记录载荷不存在') + } + } catch (e) { + console.error(e) + setError('加载详情失败') + } finally { + setLoading(false) + } + return + } + + const { sid, mid } = ids if (!sid || !mid) { setError('无效的聊天记录链接') setLoading(false) @@ -153,7 +329,7 @@ export default function ChatHistoryPage() { } } loadData() - }, [params.sessionId, params.messageId, location.pathname]) + }, [ids.mid, ids.sid, location.pathname, payloadId]) return (
@@ -168,7 +344,7 @@ export default function ChatHistoryPage() { ) : ( recordList.map((item, i) => ( 消息解析失败
}> - + )) )} @@ -177,9 +353,198 @@ export default function ChatHistoryPage() { ) } -function HistoryItem({ item }: { item: ChatRecordItem }) { - const [imageError, setImageError] = useState(false) - +function detectImageMimeFromBase64(base64: string): string { + try { + const head = window.atob(base64.slice(0, 48)) + const bytes = new Uint8Array(head.length) + for (let i = 0; i < head.length; i++) { + bytes[i] = head.charCodeAt(i) + } + if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) return 'image/gif' + if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47) return 'image/png' + if (bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF) return 'image/jpeg' + if ( + bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 && + bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50 + ) { + return 'image/webp' + } + } catch { } + return 'image/jpeg' +} + +function normalizeChatRecordText(value?: string): string { + return String(value || '') + .replace(/\u00a0/g, ' ') + .replace(/\s+/g, ' ') + .trim() +} + +function getChatRecordPreviewText(item: ChatRecordItem): string { + const text = normalizeChatRecordText(item.datadesc) || normalizeChatRecordText(item.datatitle) + if (item.datatype === 17) { + return normalizeChatRecordText(item.chatRecordTitle) || normalizeChatRecordText(item.datatitle) || '聊天记录' + } + if (item.datatype === 2 || item.datatype === 3) return '[图片]' + if (item.datatype === 43) return '[视频]' + if (item.datatype === 34) return '[语音]' + if (item.datatype === 47) return '[表情]' + return text || '[媒体消息]' +} + +function ForwardedImage({ item, sessionId }: { item: ChatRecordItem; sessionId: string }) { + const cacheKey = + item.thumbfullmd5 || + item.fullmd5 || + item.md5 || + item.messageuuid || + item.datathumburl || + item.datacdnurl || + item.dataurl || + `local:${item.srcMsgLocalid || 0}` + const [localPath, setLocalPath] = useState(() => forwardedImageCache.get(cacheKey)) + const [loading, setLoading] = useState(!forwardedImageCache.has(cacheKey)) + const [error, setError] = useState(false) + + useEffect(() => { + if (localPath || error) return + + let cancelled = false + const candidateMd5s = Array.from(new Set([ + item.thumbfullmd5, + item.fullmd5, + item.md5 + ].filter(Boolean) as string[])) + + const load = async () => { + setLoading(true) + + for (const imageMd5 of candidateMd5s) { + const cached = await window.electronAPI.image.resolveCache({ imageMd5 }) + if (cached.success && cached.localPath) { + if (!cancelled) { + forwardedImageCache.set(cacheKey, cached.localPath) + setLocalPath(cached.localPath) + setLoading(false) + } + return + } + } + + for (const imageMd5 of candidateMd5s) { + const decrypted = await window.electronAPI.image.decrypt({ imageMd5 }) + if (decrypted.success && decrypted.localPath) { + if (!cancelled) { + forwardedImageCache.set(cacheKey, decrypted.localPath) + setLocalPath(decrypted.localPath) + setLoading(false) + } + return + } + } + + if (sessionId && item.srcMsgLocalid) { + const fallback = await window.electronAPI.chat.getImageData(sessionId, String(item.srcMsgLocalid)) + if (fallback.success && fallback.data) { + const dataUrl = `data:${detectImageMimeFromBase64(fallback.data)};base64,${fallback.data}` + if (!cancelled) { + forwardedImageCache.set(cacheKey, dataUrl) + setLocalPath(dataUrl) + setLoading(false) + } + return + } + } + + const remoteSrc = item.dataurl || item.datathumburl || item.datacdnurl + if (remoteSrc && /^https?:\/\//i.test(remoteSrc)) { + if (!cancelled) { + setLocalPath(remoteSrc) + setLoading(false) + } + return + } + + if (!cancelled) { + setError(true) + setLoading(false) + } + } + + load().catch(() => { + if (!cancelled) { + setError(true) + setLoading(false) + } + }) + + return () => { + cancelled = true + } + }, [cacheKey, error, item.dataurl, item.datacdnurl, item.datathumburl, item.fullmd5, item.md5, item.messageuuid, item.srcMsgLocalid, item.thumbfullmd5, localPath, sessionId]) + + if (localPath) { + return ( +
+ 图片 +
+ ) + } + + if (loading) { + return
图片加载中...
+ } + + if (error) { + return
图片未索引到本地缓存
+ } + + return
[图片]
+} + +function NestedChatRecordCard({ item, sessionId }: { item: ChatRecordItem; sessionId: string }) { + const previewItems = (item.chatRecordList || []).slice(0, 3) + const title = normalizeChatRecordText(item.chatRecordTitle) || normalizeChatRecordText(item.datatitle) || '聊天记录' + const description = normalizeChatRecordText(item.chatRecordDesc) || normalizeChatRecordText(item.datadesc) + const canOpen = Boolean(sessionId && item.chatRecordList && item.chatRecordList.length > 0) + + const handleOpen = () => { + if (!canOpen) return + window.electronAPI.window.openChatHistoryPayloadWindow({ + sessionId, + title, + recordList: item.chatRecordList || [] + }).catch(() => { }) + } + + return ( + + ) +} + +function HistoryItem({ item, sessionId }: { item: ChatRecordItem; sessionId: string }) { // sourcetime 在合并转发里有两种格式: // 1) 时间戳(秒) 2) 已格式化的字符串 "2026-01-21 09:56:46" let time = '' @@ -191,31 +556,18 @@ function HistoryItem({ item }: { item: ChatRecordItem }) { } } + const senderDisplayName = item.sourcename ?? '未知发送者' + const renderContent = () => { if (item.datatype === 1) { // 文本消息 return
{item.datadesc || ''}
} - if (item.datatype === 3) { - // 图片 - const src = item.datathumburl || item.datacdnurl - if (src) { - return ( -
- {imageError ? ( -
图片无法加载
- ) : ( - 图片 setImageError(true)} - /> - )} -
- ) - } - return
[图片]
+ if (item.datatype === 2 || item.datatype === 3) { + return + } + if (item.datatype === 17) { + return } if (item.datatype === 43) { return
[视频] {item.datatitle}
@@ -229,21 +581,20 @@ function HistoryItem({ item }: { item: ChatRecordItem }) { return (
-
- {item.sourceheadurl ? ( - - ) : ( -
- {item.sourcename?.slice(0, 1)} -
- )} +
+
- {item.sourcename || '未知发送者'} + {senderDisplayName} {time}
-
+
{renderContent()}
diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index 299849d..8776e5f 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -3307,13 +3307,89 @@ // 聊天记录消息 (合并转发) .chat-record-message { - background: var(--card-inner-bg) !important; - border: 1px solid var(--border-color) !important; - transition: opacity 0.2s ease; + width: 300px; + min-width: 240px; + max-width: 336px; + background: color-mix(in srgb, var(--bg-secondary) 97%, #f5f7fb); + border: 1px solid var(--border-color); + border-radius: 16px; + overflow: hidden; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.04); + transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease; cursor: pointer; + padding: 0; &:hover { - opacity: 0.85; + transform: translateY(-1px); + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08); + border-color: color-mix(in srgb, var(--primary) 28%, var(--border-color)); + } + + .chat-record-title { + padding: 13px 16px 6px; + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + line-height: 1.45; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + + .chat-record-meta-line { + padding: 0 16px 10px; + font-size: 11px; + color: var(--text-tertiary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .chat-record-list { + padding: 0 16px 11px; + display: flex; + flex-direction: column; + gap: 4px; + max-height: 92px; + overflow: hidden; + border-bottom: 1px solid var(--border-color); + } + + .chat-record-item { + font-size: 12px; + line-height: 1.45; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .source-name { + color: currentColor; + opacity: 0.92; + font-weight: 500; + margin-right: 4px; + } + + .chat-record-more { + font-size: 11px; + color: var(--text-tertiary); + } + + .chat-record-desc { + padding: 0 16px 11px; + font-size: 12px; + line-height: 1.45; + color: var(--text-secondary); + border-bottom: 1px solid var(--border-color); + } + + .chat-record-footer { + padding: 8px 16px 10px; + font-size: 11px; + color: var(--text-tertiary); } } @@ -3387,75 +3463,6 @@ } } -// 聊天记录消息 - 复用 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; @@ -3552,23 +3559,18 @@ .message-bubble.sent { .card-message, - .chat-record-message, .miniapp-message, .appmsg-rich-card { background: var(--sent-card-bg); .card-name, .miniapp-title, - .source-name, .link-title { color: white; } .card-label, .miniapp-label, - .chat-record-item, - .chat-record-meta-line, - .chat-record-desc, .link-desc, .appmsg-url-line { color: rgba(255, 255, 255, 0.8); @@ -3576,14 +3578,10 @@ .card-icon, .miniapp-icon, - .chat-record-icon { + .link-thumb-placeholder { color: white; } - .chat-record-more { - color: rgba(255, 255, 255, 0.9); - } - .appmsg-meta-badge { color: rgba(255, 255, 255, 0.92); background: rgba(255, 255, 255, 0.12); @@ -4225,43 +4223,6 @@ } } -// 聊天记录消息外观 -.chat-record-message { - background: var(--card-inner-bg) !important; - border: 1px solid var(--border-color); - border-radius: 8px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); - - &:hover { - background: var(--bg-hover) !important; - } - - .chat-record-list { - font-size: 13px; - color: var(--text-tertiary); - line-height: 1.6; - margin-top: 8px; - padding-top: 8px; - border-top: 1px solid var(--border-color); - - .chat-record-item { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - - .source-name { - color: var(--text-secondary); - } - } - } - - .chat-record-more { - font-size: 12px; - color: var(--text-tertiary); - margin-top: 4px; - } -} - // 公众号文章图文消息外观 (大图模式) .official-message { display: flex; diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index f741813..c2502f0 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -7,7 +7,7 @@ import { useShallow } from 'zustand/react/shallow' import { useChatStore } from '../stores/chatStore' import { useBatchTranscribeStore, type BatchVoiceTaskType } from '../stores/batchTranscribeStore' import { useBatchImageDecryptStore } from '../stores/batchImageDecryptStore' -import type { ChatSession, Message } from '../types/models' +import type { ChatRecordItem, ChatSession, Message } from '../types/models' import { getEmojiPath } from 'wechat-emojis' import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog' import { LivePhotoIcon } from '../components/LivePhotoIcon' @@ -114,6 +114,44 @@ function flattenGlobalMsgSearchSessionMap(map: Map 0 +} + +function getChatRecordPreviewText(item: ChatRecordItem): string { + const text = normalizeChatRecordText(item.datadesc) || normalizeChatRecordText(item.datatitle) + if (item.datatype === 17) { + return normalizeChatRecordText(item.chatRecordTitle) || normalizeChatRecordText(item.datatitle) || '聊天记录' + } + if (item.datatype === 2 || item.datatype === 3) return '[媒体消息]' + if (item.datatype === 43) return '[视频]' + if (item.datatype === 34) return '[语音]' + if (item.datatype === 47) return '[表情]' + return text || '[媒体消息]' +} + +function buildChatRecordPreviewItems(recordList: ChatRecordItem[], maxVisible = 3): ChatRecordItem[] { + if (recordList.length <= maxVisible) return recordList.slice(0, maxVisible) + const firstNestedIndex = recordList.findIndex(item => item.datatype === 17) + if (firstNestedIndex < 0 || firstNestedIndex < maxVisible) { + return recordList.slice(0, maxVisible) + } + if (maxVisible <= 1) { + return [recordList[firstNestedIndex]] + } + return [ + ...recordList.slice(0, maxVisible - 1), + recordList[firstNestedIndex] + ] +} + function composeGlobalMsgSearchResults( seedMap: Map, authoritativeMap: Map @@ -9000,11 +9038,12 @@ function MessageBubble({ ? `共 ${recordList.length} 条聊天记录` : desc || '聊天记录' - const previewItems = recordList.slice(0, 4) + const previewItems = buildChatRecordPreviewItems(recordList, 3) + const remainingCount = Math.max(0, recordList.length - previewItems.length) return (
{ e.stopPropagation() // 打开聊天记录窗口 @@ -9012,42 +9051,32 @@ function MessageBubble({ }} title="点击查看详细聊天记录" > -
-
- {displayTitle} -
+
+ {displayTitle}
-
-
- {previewItems.length > 0 ? ( - <> -
- {metaText} -
-
- {previewItems.map((item, i) => ( -
- - {item.sourcename ? `${item.sourcename}: ` : ''} - - {item.datadesc || item.datatitle || '[媒体消息]'} -
- ))} - {recordList.length > previewItems.length && ( -
还有 {recordList.length - previewItems.length} 条…
- )} -
- - ) : ( -
- {desc || '点击打开查看完整聊天记录'} +
+ {metaText} +
+ {previewItems.length > 0 ? ( +
+ {previewItems.map((item, i) => ( +
+ + {hasRenderableChatRecordName(item.sourcename) ? `${item.sourcename}: ` : ''} + + {getChatRecordPreviewText(item)}
+ ))} + {remainingCount > 0 && ( +
还有 {remainingCount} 条…
)}
-
- + ) : ( +
+ {desc || '点击打开查看完整聊天记录'}
-
+ )} +
聊天记录
) } diff --git a/src/styles/main.scss b/src/styles/main.scss index 3006e86..0de9c35 100644 --- a/src/styles/main.scss +++ b/src/styles/main.scss @@ -530,4 +530,4 @@ body { opacity: 0.5; cursor: not-allowed; } -} \ No newline at end of file +} diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index b8e9f52..3824a16 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -1,4 +1,4 @@ -import type { ChatSession, Message, Contact, ContactInfo } from './models' +import type { ChatSession, Message, Contact, ContactInfo, ChatRecordItem } from './models' export interface SessionChatWindowOpenOptions { source?: 'chat' | 'export' @@ -24,6 +24,8 @@ export interface ElectronAPI { resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise openImageViewerWindow: (imagePath: string, liveVideoPath?: string) => Promise openChatHistoryWindow: (sessionId: string, messageId: number) => Promise + openChatHistoryPayloadWindow: (payload: { sessionId: string; title?: string; recordList: ChatRecordItem[] }) => Promise + getChatHistoryPayload: (payloadId: string) => Promise<{ success: boolean; payload?: { sessionId: string; title?: string; recordList: ChatRecordItem[] }; error?: string }> openSessionChatWindow: (sessionId: string, options?: SessionChatWindowOpenOptions) => Promise } config: { diff --git a/src/types/models.ts b/src/types/models.ts index de287c0..74e81dd 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -129,11 +129,19 @@ export interface ChatRecordItem { dataurl?: string // 数据URL datathumburl?: string // 缩略图URL datacdnurl?: string // CDN URL + cdndatakey?: string // CDN 数据 key + cdnthumbkey?: string // CDN 缩略图 key aeskey?: string // AES密钥 md5?: string // MD5 + fullmd5?: string // 原图 MD5 + thumbfullmd5?: string // 缩略图 MD5 + srcMsgLocalid?: number // 源消息 LocalId imgheight?: number // 图片高度 imgwidth?: number // 图片宽度 duration?: number // 时长(毫秒) + chatRecordTitle?: string // 嵌套聊天记录标题 + chatRecordDesc?: string // 嵌套聊天记录描述 + chatRecordList?: ChatRecordItem[] // 嵌套聊天记录列表 }