支持聊天记录转发解析与嵌套聊天记录解析;优化聊天记录转发窗口样式

This commit is contained in:
cc
2026-03-20 00:02:49 +08:00
parent 7590623d26
commit 60dc911228
12 changed files with 1237 additions and 399 deletions

View File

@@ -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<string, { sessionId: string; title?: string; recordList: any[] }>()
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)

View File

@@ -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?: {

View File

@@ -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
}> = []
// 查找所有 <recorditem> 标签
const recordItemRegex = /<recorditem>([\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('<recorditem') && !normalized.includes('<dataitem')) {
return undefined
}
const items: any[] = []
const dedupe = new Set<string>()
const recordItemRegex = /<recorditem>([\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('<dataitem')) {
const parsed = this.parseForwardChatRecordContainer(normalized)
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)
}
}
}
return items.length > 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 = /<!\[CDATA\[([\s\S]*?)\]\]>/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<string>()
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<string[]> {
try {

View File

@@ -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 = /<type>(\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('<appmsg') || normalized.includes('<msg>')
// 特殊处理 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 = /<type>(\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('<appmsg') || normalized.includes('<msg>')
if (localType === 49 || isAppMessage) {
const typeMatch = /<type>(\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('<recorditem')) {
const forwardName =
this.extractXmlValue(normalized, 'nickname') ||
this.extractXmlValue(normalized, 'title') ||
this.extractXmlValue(normalized, 'des') ||
this.extractXmlValue(normalized, 'displayname')
return forwardName ? `[转发的聊天记录]${forwardName}` : '[转发的聊天记录]'
return this.formatForwardChatRecordContent(normalized)
}
if (subType === 33 || subType === 36) {
const appName = this.extractXmlValue(normalized, 'appname') || title || '小程序'
@@ -1813,8 +1822,9 @@ class ExportService {
if (localType === 43) return 'video'
if (localType === 34) return 'voice'
if (localType === 48) return 'location'
if (localType === 49) {
const xmlType = this.extractXmlValue(content || '', 'type')
const normalized = this.normalizeAppMessageContent(content || '')
const xmlType = this.extractAppMessageType(normalized)
if (localType === 49 || normalized.includes('<appmsg') || normalized.includes('<msg>')) {
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 = /<type>(\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('<recorditem')) {
return undefined
}
// 提取 recorditem 中的 CDATA
const match = /<recorditem>[\s\S]*?<!\[CDATA\[([\s\S]*?)\]\]>[\s\S]*?<\/recorditem>/.exec(content)
if (!match) return undefined
const items: ForwardChatRecordItem[] = []
const dedupe = new Set<string>()
const recordItemRegex = /<recorditem>([\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 = /<dataitem\s+(.*?)>([\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('<dataitem')) {
const fallbackItems = this.parseForwardChatRecordContainer(normalized)
for (const item of fallbackItems) {
const dedupeKey = `${item.datatype}|${item.sourcename}|${item.sourcetime}|${item.datadesc || ''}|${item.datatitle || ''}`
if (!dedupe.has(dedupeKey)) {
dedupe.add(dedupeKey)
items.push(item)
}
}
}
return items.length > 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 = /<!\[CDATA\[([\s\S]*?)\]\]>/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<string>()
for (const segment of segments) {
if (!segment) continue
const dataItemRegex = /<dataitem\b([^>]*)>([\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 = /<appmsg[\s\S]*?>([\s\S]*?)<\/appmsg>/i.exec(content)
const normalized = this.normalizeAppMessageContent(content)
const appmsgMatch = /<appmsg[\s\S]*?>([\s\S]*?)<\/appmsg>/i.exec(normalized)
if (appmsgMatch) {
const appmsgInner = appmsgMatch[1]
.replace(/<refermsg[\s\S]*?<\/refermsg>/gi, '')
@@ -2238,7 +2375,11 @@ class ExportService {
const typeMatch = /<type>([\s\S]*?)<\/type>/i.exec(appmsgInner)
if (typeMatch) return typeMatch[1].trim()
}
return this.extractXmlValue(content, 'type')
if (!normalized.includes('<appmsg') && !normalized.includes('<msg>')) {
return ''
}
const fallbackTypeMatch = /<type>(\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('<appmsg') || normalized.includes('<msg>')
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('<appmsg') || content.includes('&lt;appmsg'))) {
// 检查是否是聊天记录消息type=19,兼容大 localType 的 appmsg
const normalizedContent = this.normalizeAppMessageContent(content)
const xmlType = this.extractAppMessageType(normalizedContent)
if (xmlType === '19') {
chatRecordList = this.parseChatHistory(content)
chatRecordList = this.parseChatHistory(normalizedContent)
}
}
}

View File

@@ -75,7 +75,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 isChatHistoryWindow = location.pathname.startsWith('/chat-history/') || location.pathname.startsWith('/chat-history-inline/')
const isStandaloneChatWindow = location.pathname === '/chat-window'
const isNotificationWindow = location.pathname === '/notification-window'
const isSettingsRoute = location.pathname === '/settings'
@@ -660,6 +660,7 @@ function App() {
<Route path="/sns" element={<SnsPage />} />
<Route path="/contacts" element={<ContactsPage />} />
<Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} />
<Route path="/chat-history-inline/:payloadId" element={<ChatHistoryPage />} />
</Routes>
</RouteGuard>
</main>

View File

@@ -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);
}
}
}

View File

@@ -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<string, string>()
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<ChatRecordItem[]>([])
const [loading, setLoading] = useState(true)
@@ -30,64 +33,212 @@ export default function ChatHistoryPage() {
.replace(/&#39;/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 = /<!\[CDATA\[([\s\S]*?)\]\]>/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<string>()
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('<recorditem')) return undefined
const match = /<recorditem>[\s\S]*?<!\[CDATA\[([\s\S]*?)\]\]>[\s\S]*?<\/recorditem>/.exec(content)
if (!match) return undefined
const innerXml = match[1]
const items: ChatRecordItem[] = []
const itemRegex = /<dataitem\s+(.*?)>([\s\S]*?)<\/dataitem>/g
let itemMatch: RegExpExecArray | null
const dedupe = new Set<string>()
const recordItemRegex = /<recorditem>([\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('<dataitem')) {
const parsedItems = parseChatRecordContainer(decodedContent)
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)
}
}
}
return items.length > 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 (
<div className="chat-history-page">
@@ -168,7 +344,7 @@ export default function ChatHistoryPage() {
) : (
recordList.map((item, i) => (
<ErrorBoundary key={i} fallback={<div className="history-item error-item"></div>}>
<HistoryItem item={item} />
<HistoryItem item={item} sessionId={ids.sid} />
</ErrorBoundary>
))
)}
@@ -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<string | undefined>(() => 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 (
<div className="media-content">
<img src={localPath} alt="图片" referrerPolicy="no-referrer" />
</div>
)
}
if (loading) {
return <div className="media-tip">...</div>
}
if (error) {
return <div className="media-tip"></div>
}
return <div className="media-placeholder">[]</div>
}
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 (
<button
type="button"
className={`nested-chat-record-card${canOpen ? ' clickable' : ''}`}
onClick={handleOpen}
disabled={!canOpen}
title={canOpen ? '点击打开聊天记录' : undefined}
>
<div className="nested-chat-record-title">{title}</div>
{previewItems.length > 0 ? (
<div className="nested-chat-record-list">
{previewItems.map((previewItem, index) => (
<div key={`${previewItem.messageuuid || previewItem.srcMsgLocalid || index}`} className="nested-chat-record-line">
{getChatRecordPreviewText(previewItem)}
</div>
))}
</div>
) : description ? (
<div className="nested-chat-record-list">
<div className="nested-chat-record-line">{description}</div>
</div>
) : null}
<div className="nested-chat-record-footer"></div>
</button>
)
}
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 <div className="text-content">{item.datadesc || ''}</div>
}
if (item.datatype === 3) {
// 图片
const src = item.datathumburl || item.datacdnurl
if (src) {
return (
<div className="media-content">
{imageError ? (
<div className="media-tip"></div>
) : (
<img
src={src}
alt="图片"
referrerPolicy="no-referrer"
onError={() => setImageError(true)}
/>
)}
</div>
)
}
return <div className="media-placeholder">[]</div>
if (item.datatype === 2 || item.datatype === 3) {
return <ForwardedImage item={item} sessionId={sessionId} />
}
if (item.datatype === 17) {
return <NestedChatRecordCard item={item} sessionId={sessionId} />
}
if (item.datatype === 43) {
return <div className="media-placeholder">[] {item.datatitle}</div>
@@ -229,21 +581,20 @@ function HistoryItem({ item }: { item: ChatRecordItem }) {
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 className="history-avatar">
<Avatar
src={item.sourceheadurl}
name={senderDisplayName}
size={36}
className="avatar-inner"
/>
</div>
<div className="content-wrapper">
<div className="header">
<span className="sender">{item.sourcename || '未知发送者'}</span>
<span className="sender">{senderDisplayName}</span>
<span className="time">{time}</span>
</div>
<div className={`bubble ${item.datatype === 3 ? 'image-bubble' : ''}`}>
<div className={`bubble ${(item.datatype === 2 || item.datatype === 3) ? 'image-bubble' : ''}`}>
{renderContent()}
</div>
</div>

View File

@@ -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;

View File

@@ -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<string, GlobalMsgSearchResult
return sortMessagesByCreateTimeDesc(all)
}
function normalizeChatRecordText(value?: string): string {
return String(value || '')
.replace(/\u00a0/g, ' ')
.replace(/\s+/g, ' ')
.trim()
}
function hasRenderableChatRecordName(value?: string): boolean {
return value !== undefined && value !== null && String(value).length > 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<string, GlobalMsgSearchResult[]>,
authoritativeMap: Map<string, GlobalMsgSearchResult[]>
@@ -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 (
<div
className="link-message chat-record-message"
className="chat-record-message"
onClick={(e) => {
e.stopPropagation()
// 打开聊天记录窗口
@@ -9012,42 +9051,32 @@ function MessageBubble({
}}
title="点击查看详细聊天记录"
>
<div className="link-header">
<div className="link-title" title={displayTitle}>
{displayTitle}
</div>
<div className="chat-record-title" title={displayTitle}>
{displayTitle}
</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 className="chat-record-meta-line" title={metaText}>
{metaText}
</div>
{previewItems.length > 0 ? (
<div className="chat-record-list">
{previewItems.map((item, i) => (
<div key={i} className="chat-record-item">
<span className="source-name">
{hasRenderableChatRecordName(item.sourcename) ? `${item.sourcename}: ` : ''}
</span>
{getChatRecordPreviewText(item)}
</div>
))}
{remainingCount > 0 && (
<div className="chat-record-more"> {remainingCount} </div>
)}
</div>
<div className="chat-record-icon">
<MessageSquare size={18} />
) : (
<div className="chat-record-desc">
{desc || '点击打开查看完整聊天记录'}
</div>
</div>
)}
<div className="chat-record-footer"></div>
</div>
)
}

View File

@@ -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<void>
openImageViewerWindow: (imagePath: string, liveVideoPath?: string) => Promise<void>
openChatHistoryWindow: (sessionId: string, messageId: number) => Promise<boolean>
openChatHistoryPayloadWindow: (payload: { sessionId: string; title?: string; recordList: ChatRecordItem[] }) => Promise<boolean>
getChatHistoryPayload: (payloadId: string) => Promise<{ success: boolean; payload?: { sessionId: string; title?: string; recordList: ChatRecordItem[] }; error?: string }>
openSessionChatWindow: (sessionId: string, options?: SessionChatWindowOpenOptions) => Promise<boolean>
}
config: {

View File

@@ -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[] // 嵌套聊天记录列表
}