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

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 './preload-env'
import { app, BrowserWindow, ipcMain, nativeTheme, session, Tray, Menu, nativeImage } from 'electron' import { app, BrowserWindow, ipcMain, nativeTheme, session, Tray, Menu, nativeImage } from 'electron'
import { Worker } from 'worker_threads' import { Worker } from 'worker_threads'
import { randomUUID } from 'crypto'
import { join, dirname } from 'path' import { join, dirname } from 'path'
import { autoUpdater } from 'electron-updater' import { autoUpdater } from 'electron-updater'
import { readFile, writeFile, mkdir, rm, readdir } from 'fs/promises' import { readFile, writeFile, mkdir, rm, readdir } from 'fs/promises'
@@ -112,6 +113,7 @@ let shouldShowMain = true
let isAppQuitting = false let isAppQuitting = false
let tray: Tray | null = null let tray: Tray | null = null
let isClosePromptVisible = false let isClosePromptVisible = false
const chatHistoryPayloadStore = new Map<string, { sessionId: string; title?: string; recordList: any[] }>()
type WindowCloseBehavior = 'ask' | 'tray' | 'quit' type WindowCloseBehavior = 'ask' | 'tray' | 'quit'
@@ -769,6 +771,14 @@ function createImageViewerWindow(imagePath: string, liveVideoPath?: string) {
* 创建独立的聊天记录窗口 * 创建独立的聊天记录窗口
*/ */
function createChatHistoryWindow(sessionId: string, messageId: number) { 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 isDev = !!process.env.VITE_DEV_SERVER_URL
const iconPath = isDev const iconPath = isDev
? join(__dirname, '../public/icon.ico') ? join(__dirname, '../public/icon.ico')
@@ -803,7 +813,7 @@ function createChatHistoryWindow(sessionId: string, messageId: number) {
}) })
if (process.env.VITE_DEV_SERVER_URL) { 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) => { win.webContents.on('before-input-event', (event, input) => {
if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) { if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) {
@@ -817,7 +827,7 @@ function createChatHistoryWindow(sessionId: string, messageId: number) {
}) })
} else { } else {
win.loadFile(join(__dirname, '../dist/index.html'), { win.loadFile(join(__dirname, '../dist/index.html'), {
hash: `/chat-history/${sessionId}/${messageId}` hash: route
}) })
} }
@@ -1260,6 +1270,23 @@ function registerIpcHandlers() {
return true 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) => { ipcMain.handle('window:openSessionChatWindow', (_, sessionId: string, options?: OpenSessionChatWindowOptions) => {
const win = createSessionChatWindow(sessionId, options) const win = createSessionChatWindow(sessionId, options)

View File

@@ -113,6 +113,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke('window:openImageViewerWindow', imagePath, liveVideoPath), ipcRenderer.invoke('window:openImageViewerWindow', imagePath, liveVideoPath),
openChatHistoryWindow: (sessionId: string, messageId: number) => openChatHistoryWindow: (sessionId: string, messageId: number) =>
ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId), 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: ( openSessionChatWindow: (
sessionId: string, sessionId: string,
options?: { options?: {

View File

@@ -114,8 +114,28 @@ export interface Message {
datatype: number datatype: number
sourcename: string sourcename: string
sourcetime: string sourcetime: string
datadesc: string sourceheadurl?: string
datadesc?: string
datatitle?: 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 // 内部字段:记录消息所属数据库路径 _db_path?: string // 内部字段:记录消息所属数据库路径
} }
@@ -3120,8 +3140,28 @@ class ChatService {
datatype: number datatype: number
sourcename: string sourcename: string
sourcetime: string sourcetime: string
datadesc: string sourceheadurl?: string
datadesc?: string
datatitle?: 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 }> | undefined
if (localType === 47 && content) { if (localType === 47 && content) {
@@ -3873,8 +3913,28 @@ class ChatService {
datatype: number datatype: number
sourcename: string sourcename: string
sourcetime: string sourcetime: string
datadesc: string sourceheadurl?: string
datadesc?: string
datatitle?: 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 { try {
@@ -4057,41 +4117,8 @@ class ChatService {
case '19': { case '19': {
// 聊天记录 // 聊天记录
result.chatRecordTitle = title || '聊天记录' result.chatRecordTitle = title || '聊天记录'
const recordList = this.parseForwardChatRecordList(content)
// 解析聊天记录列表 if (recordList && recordList.length > 0) {
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) {
result.chatRecordList = recordList result.chatRecordList = recordList
} }
break 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 //手动查找 media_*.db 文件(当 WCDB DLL 不支持 listMediaDbs 时的 fallback
private async findMediaDbsManually(): Promise<string[]> { private async findMediaDbsManually(): Promise<string[]> {
try { try {

View File

@@ -49,6 +49,20 @@ interface ChatLabMessage {
chatRecords?: any[] // 嵌套的聊天记录 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 { interface ChatLabExport {
chatlab: ChatLabHeader chatlab: ChatLabHeader
meta: ChatLabMeta meta: ChatLabMeta
@@ -1231,12 +1245,13 @@ class ExportService {
* 转换微信消息类型到 ChatLab 类型 * 转换微信消息类型到 ChatLab 类型
*/ */
private convertMessageType(localType: number, content: string): number { private convertMessageType(localType: number, content: string): number {
// 检查 XML 中的 type 标签(支持大 localType 的情况) const normalized = this.normalizeAppMessageContent(content || '')
const xmlTypeMatch = /<type>(\d+)<\/type>/i.exec(content) const xmlTypeRaw = this.extractAppMessageType(normalized)
const xmlType = xmlTypeMatch ? parseInt(xmlTypeMatch[1]) : null const xmlType = xmlTypeRaw ? Number.parseInt(xmlTypeRaw, 10) : null
const looksLikeAppMessage = localType === 49 || normalized.includes('<appmsg') || normalized.includes('<msg>')
// 特殊处理 type 49 或 XML type // 特殊处理 type 49 或 XML type
if (localType === 49 || xmlType) { if (looksLikeAppMessage || xmlType) {
const subType = xmlType || 0 const subType = xmlType || 0
switch (subType) { switch (subType) {
case 6: return 4 // 文件 -> FILE case 6: return 4 // 文件 -> FILE
@@ -1248,7 +1263,7 @@ class ExportService {
case 5: case 5:
case 49: return 7 // 链接 -> LINK case 49: return 7 // 链接 -> LINK
default: default:
if (xmlType) return 7 // 有 XML type 但未知,默认为链接 if (xmlType || looksLikeAppMessage) return 7 // 有 appmsg 但未知,默认为链接
} }
} }
return MESSAGE_TYPE_MAP[localType] ?? 99 // 未知类型 -> OTHER return MESSAGE_TYPE_MAP[localType] ?? 99 // 未知类型 -> OTHER
@@ -1549,9 +1564,8 @@ class ExportService {
): string | null { ): string | null {
if (!content) return null if (!content) return null
// 检查 XML 中的 type 标签(支持大 localType 的情况) const normalizedContent = this.normalizeAppMessageContent(content)
const xmlTypeMatch = /<type>(\d+)<\/type>/i.exec(content) const xmlType = this.extractAppMessageType(normalizedContent)
const xmlType = xmlTypeMatch ? xmlTypeMatch[1] : null
switch (localType) { switch (localType) {
case 1: // 文本 case 1: // 文本
@@ -1587,15 +1601,15 @@ class ExportService {
return locParts.length > 0 ? `[位置] ${locParts.join(' ')}` : '[位置]' return locParts.length > 0 ? `[位置] ${locParts.join(' ')}` : '[位置]'
} }
case 49: { case 49: {
const title = this.extractXmlValue(content, 'title') const title = this.extractXmlValue(normalizedContent, 'title')
const type = this.extractXmlValue(content, 'type') const type = this.extractAppMessageType(normalizedContent)
const songName = this.extractXmlValue(content, 'songname') const songName = this.extractXmlValue(normalizedContent, 'songname')
// 转账消息特殊处理 // 转账消息特殊处理
if (type === '2000') { if (type === '2000') {
const feedesc = this.extractXmlValue(content, 'feedesc') const feedesc = this.extractXmlValue(normalizedContent, 'feedesc')
const payMemo = this.extractXmlValue(content, 'pay_memo') const payMemo = this.extractXmlValue(normalizedContent, 'pay_memo')
const transferPrefix = this.getTransferPrefix(content, myWxid, senderWxid, isSend) const transferPrefix = this.getTransferPrefix(normalizedContent, myWxid, senderWxid, isSend)
if (feedesc) { if (feedesc) {
return payMemo ? `${transferPrefix} ${feedesc} ${payMemo}` : `${transferPrefix} ${feedesc}` return payMemo ? `${transferPrefix} ${feedesc} ${payMemo}` : `${transferPrefix} ${feedesc}`
} }
@@ -1604,7 +1618,7 @@ class ExportService {
if (type === '3') return songName ? `[音乐] ${songName}` : (title ? `[音乐] ${title}` : '[音乐]') if (type === '3') return songName ? `[音乐] ${songName}` : (title ? `[音乐] ${title}` : '[音乐]')
if (type === '6') return 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 === '33' || type === '36') return title ? `[小程序] ${title}` : '[小程序]'
if (type === '57') return title || '[引用消息]' if (type === '57') return title || '[引用消息]'
if (type === '5' || type === '49') return title ? `[链接] ${title}` : '[链接]' if (type === '5' || type === '49') return title ? `[链接] ${title}` : '[链接]'
@@ -1646,7 +1660,7 @@ class ExportService {
// 其他类型 // 其他类型
if (xmlType === '3') return title ? `[音乐] ${title}` : '[音乐]' if (xmlType === '3') return title ? `[音乐] ${title}` : '[音乐]'
if (xmlType === '6') 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 === '33' || xmlType === '36') return title ? `[小程序] ${title}` : '[小程序]'
if (xmlType === '57') return title || '[引用消息]' if (xmlType === '57') return title || '[引用消息]'
if (xmlType === '5' || xmlType === '49') return title ? `[链接] ${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 normalized = this.normalizeAppMessageContent(safeContent)
const isAppMessage = normalized.includes('<appmsg') || normalized.includes('<msg>') const isAppMessage = normalized.includes('<appmsg') || normalized.includes('<msg>')
if (localType === 49 || isAppMessage) { if (localType === 49 || isAppMessage) {
const typeMatch = /<type>(\d+)<\/type>/i.exec(normalized) const subTypeRaw = this.extractAppMessageType(normalized)
const subType = typeMatch ? parseInt(typeMatch[1], 10) : 0 const subType = subTypeRaw ? parseInt(subTypeRaw, 10) : 0
const title = this.extractXmlValue(normalized, 'title') || this.extractXmlValue(normalized, 'appname') const title = this.extractXmlValue(normalized, 'title') || this.extractXmlValue(normalized, 'appname')
// 群公告消息type 87 // 群公告消息type 87
@@ -1766,12 +1780,7 @@ class ExportService {
return `[红包]${title || '微信红包'}` return `[红包]${title || '微信红包'}`
} }
if (subType === 19 || normalized.includes('<recorditem')) { if (subType === 19 || normalized.includes('<recorditem')) {
const forwardName = return this.formatForwardChatRecordContent(normalized)
this.extractXmlValue(normalized, 'nickname') ||
this.extractXmlValue(normalized, 'title') ||
this.extractXmlValue(normalized, 'des') ||
this.extractXmlValue(normalized, 'displayname')
return forwardName ? `[转发的聊天记录]${forwardName}` : '[转发的聊天记录]'
} }
if (subType === 33 || subType === 36) { if (subType === 33 || subType === 36) {
const appName = this.extractXmlValue(normalized, 'appname') || title || '小程序' const appName = this.extractXmlValue(normalized, 'appname') || title || '小程序'
@@ -1813,8 +1822,9 @@ class ExportService {
if (localType === 43) return 'video' if (localType === 43) return 'video'
if (localType === 34) return 'voice' if (localType === 34) return 'voice'
if (localType === 48) return 'location' if (localType === 48) return 'location'
if (localType === 49) { const normalized = this.normalizeAppMessageContent(content || '')
const xmlType = this.extractXmlValue(content || '', 'type') const xmlType = this.extractAppMessageType(normalized)
if (localType === 49 || normalized.includes('<appmsg') || normalized.includes('<msg>')) {
if (xmlType === '6') return 'file' if (xmlType === '6') return 'file'
return 'text' return 'text'
} }
@@ -2023,8 +2033,8 @@ class ExportService {
private getMessageTypeName(localType: number, content?: string): string { private getMessageTypeName(localType: number, content?: string): string {
// 检查 XML 中的 type 标签(支持大 localType 的情况) // 检查 XML 中的 type 标签(支持大 localType 的情况)
if (content) { if (content) {
const xmlTypeMatch = /<type>(\d+)<\/type>/i.exec(content) const normalized = this.normalizeAppMessageContent(content)
const xmlType = xmlTypeMatch ? xmlTypeMatch[1] : null const xmlType = this.extractAppMessageType(normalized)
if (xmlType) { if (xmlType) {
switch (xmlType) { switch (xmlType) {
@@ -2146,45 +2156,38 @@ class ExportService {
/** /**
* 解析合并转发的聊天记录 (Type 19) * 解析合并转发的聊天记录 (Type 19)
*/ */
private parseChatHistory(content: string): any[] | undefined { private parseChatHistory(content: string): ForwardChatRecordItem[] | undefined {
try { try {
const type = this.extractXmlValue(content, 'type') const normalized = this.normalizeAppMessageContent(content || '')
if (type !== '19') return undefined const appMsgType = this.extractAppMessageType(normalized)
if (appMsgType !== '19' && !normalized.includes('<recorditem')) {
return undefined
}
// 提取 recorditem 中的 CDATA const items: ForwardChatRecordItem[] = []
const match = /<recorditem>[\s\S]*?<!\[CDATA\[([\s\S]*?)\]\]>[\s\S]*?<\/recorditem>/.exec(content) const dedupe = new Set<string>()
if (!match) return undefined 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] if (items.length === 0 && normalized.includes('<dataitem')) {
const items: any[] = [] const fallbackItems = this.parseForwardChatRecordContainer(normalized)
const itemRegex = /<dataitem\s+(.*?)>([\s\S]*?)<\/dataitem>/g for (const item of fallbackItems) {
let itemMatch const dedupeKey = `${item.datatype}|${item.sourcename}|${item.sourcetime}|${item.datadesc || ''}|${item.datatitle || ''}`
if (!dedupe.has(dedupeKey)) {
while ((itemMatch = itemRegex.exec(innerXml)) !== null) { dedupe.add(dedupeKey)
const attrs = itemMatch[1] items.push(item)
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
})
} }
return items.length > 0 ? items : undefined 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 实体 * 解码 HTML 实体
*/ */
@@ -2230,7 +2366,8 @@ class ExportService {
private extractAppMessageType(content: string): string { private extractAppMessageType(content: string): string {
if (!content) return '' 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) { if (appmsgMatch) {
const appmsgInner = appmsgMatch[1] const appmsgInner = appmsgMatch[1]
.replace(/<refermsg[\s\S]*?<\/refermsg>/gi, '') .replace(/<refermsg[\s\S]*?<\/refermsg>/gi, '')
@@ -2238,7 +2375,11 @@ class ExportService {
const typeMatch = /<type>([\s\S]*?)<\/type>/i.exec(appmsgInner) const typeMatch = /<type>([\s\S]*?)<\/type>/i.exec(appmsgInner)
if (typeMatch) return typeMatch[1].trim() 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 { private looksLikeWxid(text: string): boolean {
@@ -2600,7 +2741,7 @@ class ExportService {
const isAppMessage = localType === 49 || normalized.includes('<appmsg') || normalized.includes('<msg>') const isAppMessage = localType === 49 || normalized.includes('<appmsg') || normalized.includes('<msg>')
if (!isAppMessage) return null if (!isAppMessage) return null
const subType = this.extractXmlValue(normalized, 'type') const subType = this.extractAppMessageType(normalized)
if (subType && subType !== '5' && subType !== '49') return null if (subType && subType !== '5' && subType !== '49') return null
const url = this.normalizeHtmlLinkUrl(this.extractXmlValue(normalized, 'url')) const url = this.normalizeHtmlLinkUrl(this.extractXmlValue(normalized, 'url'))
@@ -3444,11 +3585,12 @@ class ExportService {
} else if (localType === 43 && content) { } else if (localType === 43 && content) {
// 视频消息 // 视频消息
videoMd5 = videoMd5 || this.extractVideoMd5(content) videoMd5 = videoMd5 || this.extractVideoMd5(content)
} else if (collectMode === 'full' && localType === 49 && content) { } else if (collectMode === 'full' && content && (localType === 49 || content.includes('<appmsg') || content.includes('&lt;appmsg'))) {
// 检查是否是聊天记录消息type=19 // 检查是否是聊天记录消息type=19,兼容大 localType 的 appmsg
const xmlType = this.extractXmlValue(content, 'type') const normalizedContent = this.normalizeAppMessageContent(content)
const xmlType = this.extractAppMessageType(normalizedContent)
if (xmlType === '19') { 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 isAgreementWindow = location.pathname === '/agreement-window'
const isOnboardingWindow = location.pathname === '/onboarding-window' const isOnboardingWindow = location.pathname === '/onboarding-window'
const isVideoPlayerWindow = location.pathname === '/video-player-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 isStandaloneChatWindow = location.pathname === '/chat-window'
const isNotificationWindow = location.pathname === '/notification-window' const isNotificationWindow = location.pathname === '/notification-window'
const isSettingsRoute = location.pathname === '/settings' const isSettingsRoute = location.pathname === '/settings'
@@ -660,6 +660,7 @@ function App() {
<Route path="/sns" element={<SnsPage />} /> <Route path="/sns" element={<SnsPage />} />
<Route path="/contacts" element={<ContactsPage />} /> <Route path="/contacts" element={<ContactsPage />} />
<Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} /> <Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} />
<Route path="/chat-history-inline/:payloadId" element={<ChatHistoryPage />} />
</Routes> </Routes>
</RouteGuard> </RouteGuard>
</main> </main>

View File

@@ -2,15 +2,16 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100vh; 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 { .history-list {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 16px; padding: 18px 18px 28px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 0;
.status-msg { .status-msg {
text-align: center; text-align: center;
@@ -30,8 +31,9 @@
.history-item { .history-item {
display: flex; display: flex;
gap: 12px; gap: 14px;
align-items: flex-start; align-items: flex-start;
padding: 14px 0 0;
&.error-item { &.error-item {
padding: 12px; padding: 12px;
@@ -43,65 +45,70 @@
justify-content: center; justify-content: center;
} }
.avatar { .history-avatar {
width: 40px; width: 36px;
height: 40px; height: 36px;
border-radius: 4px; border-radius: 8px;
overflow: hidden; overflow: hidden;
flex-shrink: 0; 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%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; border-radius: inherit;
} background: transparent;
.avatar-placeholder { img.avatar-image {
width: 100%; // Forwarded record head images may include a light matte edge.
height: 100%; // Slightly zoom in to crop that edge and align with normal chat avatars.
display: flex; transform: scale(1.12);
align-items: center; transform-origin: center;
justify-content: center; }
color: var(--text-tertiary);
font-size: 16px;
font-weight: 500;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
} }
} }
.content-wrapper { .content-wrapper {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
padding-bottom: 18px;
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 82%, transparent);
.header { .header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: flex-start;
margin-bottom: 6px; gap: 12px;
margin-bottom: 4px;
.sender { .sender {
font-size: 14px; font-size: 13px;
font-weight: 500; font-weight: 400;
color: var(--text-primary); color: color-mix(in srgb, var(--text-secondary) 82%, transparent);
line-height: 1.3;
} }
.time { .time {
font-size: 12px; font-size: 12px;
color: var(--text-tertiary); color: color-mix(in srgb, var(--text-tertiary) 92%, transparent);
flex-shrink: 0; flex-shrink: 0;
margin-left: 8px; margin-left: 8px;
line-height: 1.3;
} }
} }
.bubble { .bubble {
background: var(--bg-secondary); background: transparent;
padding: 10px 14px; padding: 0;
border-radius: 18px 18px 18px 4px; border-radius: 0;
word-wrap: break-word; word-wrap: break-word;
max-width: 100%; max-width: 100%;
display: inline-block; display: block;
&.image-bubble { &.image-bubble {
padding: 0; padding: 0;
@@ -109,8 +116,8 @@
} }
.text-content { .text-content {
font-size: 14px; font-size: 15px;
line-height: 1.6; line-height: 1.7;
color: var(--text-primary); color: var(--text-primary);
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
@@ -118,23 +125,84 @@
.media-content { .media-content {
img { img {
max-width: 100%; max-width: min(100%, 420px);
max-height: 300px; max-height: 320px;
border-radius: 8px; border-radius: 12px;
display: block; 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 { .media-tip {
padding: 8px 12px; padding: 6px 0;
color: var(--text-tertiary); color: var(--text-tertiary);
font-size: 13px; font-size: 13px;
} }
} }
.media-placeholder { .media-placeholder {
font-size: 14px; font-size: 13px;
color: var(--text-secondary); 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 { ChatRecordItem } from '../types/models'
import TitleBar from '../components/TitleBar' import TitleBar from '../components/TitleBar'
import { ErrorBoundary } from '../components/ErrorBoundary' import { ErrorBoundary } from '../components/ErrorBoundary'
import { Avatar } from '../components/Avatar'
import './ChatHistoryPage.scss' import './ChatHistoryPage.scss'
const forwardedImageCache = new Map<string, string>()
export default function ChatHistoryPage() { export default function ChatHistoryPage() {
const params = useParams<{ sessionId: string; messageId: string }>() const params = useParams<{ sessionId: string; messageId: string; payloadId: string }>()
const location = useLocation() const location = useLocation()
const [recordList, setRecordList] = useState<ChatRecordItem[]>([]) const [recordList, setRecordList] = useState<ChatRecordItem[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@@ -30,64 +33,212 @@ export default function ChatHistoryPage() {
.replace(/&#39;/g, "'") .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 => { const parseChatHistory = (content: string): ChatRecordItem[] | undefined => {
try { try {
const type = extractXmlValue(content, 'type') const decodedContent = decodeHtmlEntities(content) || content
if (type !== '19') return undefined 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 items: ChatRecordItem[] = []
const itemRegex = /<dataitem\s+(.*?)>([\s\S]*?)<\/dataitem>/g const dedupe = new Set<string>()
let itemMatch: RegExpExecArray | null 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) { if (items.length === 0 && decodedContent.includes('<dataitem')) {
const attrs = itemMatch[1] const parsedItems = parseChatRecordContainer(decodedContent)
const body = itemMatch[2] for (const item of parsedItems) {
const key = `${item.datatype}|${item.sourcename}|${item.sourcetime}|${item.datadesc || ''}|${item.datatitle || ''}|${item.messageuuid || ''}`
const datatypeMatch = /datatype="(\d+)"/.exec(attrs) if (!dedupe.has(key)) {
const datatype = datatypeMatch ? parseInt(datatypeMatch[1]) : 0 dedupe.add(key)
items.push(item)
const sourcename = extractXmlValue(body, 'sourcename') }
const sourcetime = extractXmlValue(body, 'sourcetime') }
const sourceheadurl = extractXmlValue(body, 'sourceheadurl')
const datadesc = extractXmlValue(body, 'datadesc')
const datatitle = extractXmlValue(body, 'datatitle')
const fileext = extractXmlValue(body, 'fileext')
const datasize = parseInt(extractXmlValue(body, 'datasize') || '0')
const messageuuid = extractXmlValue(body, 'messageuuid')
const dataurl = extractXmlValue(body, 'dataurl')
const datathumburl = extractXmlValue(body, 'datathumburl') || extractXmlValue(body, 'thumburl')
const datacdnurl = extractXmlValue(body, 'datacdnurl') || extractXmlValue(body, 'cdnurl')
const aeskey = extractXmlValue(body, 'aeskey') || extractXmlValue(body, 'qaeskey')
const md5 = extractXmlValue(body, 'md5') || extractXmlValue(body, 'datamd5')
const imgheight = parseInt(extractXmlValue(body, 'imgheight') || '0')
const imgwidth = parseInt(extractXmlValue(body, 'imgwidth') || '0')
const duration = parseInt(extractXmlValue(body, 'duration') || '0')
items.push({
datatype,
sourcename,
sourcetime,
sourceheadurl,
datadesc: decodeHtmlEntities(datadesc),
datatitle: decodeHtmlEntities(datatitle),
fileext,
datasize,
messageuuid,
dataurl: decodeHtmlEntities(dataurl),
datathumburl: decodeHtmlEntities(datathumburl),
datacdnurl: decodeHtmlEntities(datacdnurl),
aeskey: decodeHtmlEntities(aeskey),
md5,
imgheight,
imgwidth,
duration
})
} }
return items.length > 0 ? items : undefined return items.length > 0 ? items : undefined
@@ -115,9 +266,34 @@ export default function ChatHistoryPage() {
return { sid: '', mid: '' } return { sid: '', mid: '' }
} }
const ids = getIds()
const payloadId = params.payloadId || (() => {
const match = /^\/chat-history-inline\/([^/]+)/.exec(location.pathname)
return match ? match[1] : ''
})()
useEffect(() => { useEffect(() => {
const loadData = async () => { 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) { if (!sid || !mid) {
setError('无效的聊天记录链接') setError('无效的聊天记录链接')
setLoading(false) setLoading(false)
@@ -153,7 +329,7 @@ export default function ChatHistoryPage() {
} }
} }
loadData() loadData()
}, [params.sessionId, params.messageId, location.pathname]) }, [ids.mid, ids.sid, location.pathname, payloadId])
return ( return (
<div className="chat-history-page"> <div className="chat-history-page">
@@ -168,7 +344,7 @@ export default function ChatHistoryPage() {
) : ( ) : (
recordList.map((item, i) => ( recordList.map((item, i) => (
<ErrorBoundary key={i} fallback={<div className="history-item error-item"></div>}> <ErrorBoundary key={i} fallback={<div className="history-item error-item"></div>}>
<HistoryItem item={item} /> <HistoryItem item={item} sessionId={ids.sid} />
</ErrorBoundary> </ErrorBoundary>
)) ))
)} )}
@@ -177,9 +353,198 @@ export default function ChatHistoryPage() {
) )
} }
function HistoryItem({ item }: { item: ChatRecordItem }) { function detectImageMimeFromBase64(base64: string): string {
const [imageError, setImageError] = useState(false) 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 在合并转发里有两种格式: // sourcetime 在合并转发里有两种格式:
// 1) 时间戳(秒) 2) 已格式化的字符串 "2026-01-21 09:56:46" // 1) 时间戳(秒) 2) 已格式化的字符串 "2026-01-21 09:56:46"
let time = '' let time = ''
@@ -191,31 +556,18 @@ function HistoryItem({ item }: { item: ChatRecordItem }) {
} }
} }
const senderDisplayName = item.sourcename ?? '未知发送者'
const renderContent = () => { const renderContent = () => {
if (item.datatype === 1) { if (item.datatype === 1) {
// 文本消息 // 文本消息
return <div className="text-content">{item.datadesc || ''}</div> return <div className="text-content">{item.datadesc || ''}</div>
} }
if (item.datatype === 3) { if (item.datatype === 2 || item.datatype === 3) {
// 图片 return <ForwardedImage item={item} sessionId={sessionId} />
const src = item.datathumburl || item.datacdnurl }
if (src) { if (item.datatype === 17) {
return ( return <NestedChatRecordCard item={item} sessionId={sessionId} />
<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 === 43) { if (item.datatype === 43) {
return <div className="media-placeholder">[] {item.datatitle}</div> return <div className="media-placeholder">[] {item.datatitle}</div>
@@ -229,21 +581,20 @@ function HistoryItem({ item }: { item: ChatRecordItem }) {
return ( return (
<div className="history-item"> <div className="history-item">
<div className="avatar"> <div className="history-avatar">
{item.sourceheadurl ? ( <Avatar
<img src={item.sourceheadurl} alt="" referrerPolicy="no-referrer" /> src={item.sourceheadurl}
) : ( name={senderDisplayName}
<div className="avatar-placeholder"> size={36}
{item.sourcename?.slice(0, 1)} className="avatar-inner"
</div> />
)}
</div> </div>
<div className="content-wrapper"> <div className="content-wrapper">
<div className="header"> <div className="header">
<span className="sender">{item.sourcename || '未知发送者'}</span> <span className="sender">{senderDisplayName}</span>
<span className="time">{time}</span> <span className="time">{time}</span>
</div> </div>
<div className={`bubble ${item.datatype === 3 ? 'image-bubble' : ''}`}> <div className={`bubble ${(item.datatype === 2 || item.datatype === 3) ? 'image-bubble' : ''}`}>
{renderContent()} {renderContent()}
</div> </div>
</div> </div>

View File

@@ -3307,13 +3307,89 @@
// 聊天记录消息 (合并转发) // 聊天记录消息 (合并转发)
.chat-record-message { .chat-record-message {
background: var(--card-inner-bg) !important; width: 300px;
border: 1px solid var(--border-color) !important; min-width: 240px;
transition: opacity 0.2s ease; 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; cursor: pointer;
padding: 0;
&:hover { &: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 { .miniapp-message {
display: flex; display: flex;
@@ -3552,23 +3559,18 @@
.message-bubble.sent { .message-bubble.sent {
.card-message, .card-message,
.chat-record-message,
.miniapp-message, .miniapp-message,
.appmsg-rich-card { .appmsg-rich-card {
background: var(--sent-card-bg); background: var(--sent-card-bg);
.card-name, .card-name,
.miniapp-title, .miniapp-title,
.source-name,
.link-title { .link-title {
color: white; color: white;
} }
.card-label, .card-label,
.miniapp-label, .miniapp-label,
.chat-record-item,
.chat-record-meta-line,
.chat-record-desc,
.link-desc, .link-desc,
.appmsg-url-line { .appmsg-url-line {
color: rgba(255, 255, 255, 0.8); color: rgba(255, 255, 255, 0.8);
@@ -3576,14 +3578,10 @@
.card-icon, .card-icon,
.miniapp-icon, .miniapp-icon,
.chat-record-icon { .link-thumb-placeholder {
color: white; color: white;
} }
.chat-record-more {
color: rgba(255, 255, 255, 0.9);
}
.appmsg-meta-badge { .appmsg-meta-badge {
color: rgba(255, 255, 255, 0.92); color: rgba(255, 255, 255, 0.92);
background: rgba(255, 255, 255, 0.12); 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 { .official-message {
display: flex; display: flex;

View File

@@ -7,7 +7,7 @@ import { useShallow } from 'zustand/react/shallow'
import { useChatStore } from '../stores/chatStore' import { useChatStore } from '../stores/chatStore'
import { useBatchTranscribeStore, type BatchVoiceTaskType } from '../stores/batchTranscribeStore' import { useBatchTranscribeStore, type BatchVoiceTaskType } from '../stores/batchTranscribeStore'
import { useBatchImageDecryptStore } from '../stores/batchImageDecryptStore' 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 { getEmojiPath } from 'wechat-emojis'
import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog' import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog'
import { LivePhotoIcon } from '../components/LivePhotoIcon' import { LivePhotoIcon } from '../components/LivePhotoIcon'
@@ -114,6 +114,44 @@ function flattenGlobalMsgSearchSessionMap(map: Map<string, GlobalMsgSearchResult
return sortMessagesByCreateTimeDesc(all) 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( function composeGlobalMsgSearchResults(
seedMap: Map<string, GlobalMsgSearchResult[]>, seedMap: Map<string, GlobalMsgSearchResult[]>,
authoritativeMap: Map<string, GlobalMsgSearchResult[]> authoritativeMap: Map<string, GlobalMsgSearchResult[]>
@@ -9000,11 +9038,12 @@ function MessageBubble({
? `${recordList.length} 条聊天记录` ? `${recordList.length} 条聊天记录`
: desc || '聊天记录' : desc || '聊天记录'
const previewItems = recordList.slice(0, 4) const previewItems = buildChatRecordPreviewItems(recordList, 3)
const remainingCount = Math.max(0, recordList.length - previewItems.length)
return ( return (
<div <div
className="link-message chat-record-message" className="chat-record-message"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
// 打开聊天记录窗口 // 打开聊天记录窗口
@@ -9012,42 +9051,32 @@ function MessageBubble({
}} }}
title="点击查看详细聊天记录" title="点击查看详细聊天记录"
> >
<div className="link-header"> <div className="chat-record-title" title={displayTitle}>
<div className="link-title" title={displayTitle}> {displayTitle}
{displayTitle}
</div>
</div> </div>
<div className="link-body"> <div className="chat-record-meta-line" title={metaText}>
<div className="chat-record-preview"> {metaText}
{previewItems.length > 0 ? ( </div>
<> {previewItems.length > 0 ? (
<div className="chat-record-meta-line" title={metaText}> <div className="chat-record-list">
{metaText} {previewItems.map((item, i) => (
</div> <div key={i} className="chat-record-item">
<div className="chat-record-list"> <span className="source-name">
{previewItems.map((item, i) => ( {hasRenderableChatRecordName(item.sourcename) ? `${item.sourcename}: ` : ''}
<div key={i} className="chat-record-item"> </span>
<span className="source-name"> {getChatRecordPreviewText(item)}
{item.sourcename ? `${item.sourcename}: ` : ''}
</span>
{item.datadesc || item.datatitle || '[媒体消息]'}
</div>
))}
{recordList.length > previewItems.length && (
<div className="chat-record-more"> {recordList.length - previewItems.length} </div>
)}
</div>
</>
) : (
<div className="chat-record-desc">
{desc || '点击打开查看完整聊天记录'}
</div> </div>
))}
{remainingCount > 0 && (
<div className="chat-record-more"> {remainingCount} </div>
)} )}
</div> </div>
<div className="chat-record-icon"> ) : (
<MessageSquare size={18} /> <div className="chat-record-desc">
{desc || '点击打开查看完整聊天记录'}
</div> </div>
</div> )}
<div className="chat-record-footer"></div>
</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 { export interface SessionChatWindowOpenOptions {
source?: 'chat' | 'export' source?: 'chat' | 'export'
@@ -24,6 +24,8 @@ export interface ElectronAPI {
resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise<void> resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise<void>
openImageViewerWindow: (imagePath: string, liveVideoPath?: string) => Promise<void> openImageViewerWindow: (imagePath: string, liveVideoPath?: string) => Promise<void>
openChatHistoryWindow: (sessionId: string, messageId: number) => Promise<boolean> 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> openSessionChatWindow: (sessionId: string, options?: SessionChatWindowOpenOptions) => Promise<boolean>
} }
config: { config: {

View File

@@ -129,11 +129,19 @@ export interface ChatRecordItem {
dataurl?: string // 数据URL dataurl?: string // 数据URL
datathumburl?: string // 缩略图URL datathumburl?: string // 缩略图URL
datacdnurl?: string // CDN URL datacdnurl?: string // CDN URL
cdndatakey?: string // CDN 数据 key
cdnthumbkey?: string // CDN 缩略图 key
aeskey?: string // AES密钥 aeskey?: string // AES密钥
md5?: string // MD5 md5?: string // MD5
fullmd5?: string // 原图 MD5
thumbfullmd5?: string // 缩略图 MD5
srcMsgLocalid?: number // 源消息 LocalId
imgheight?: number // 图片高度 imgheight?: number // 图片高度
imgwidth?: number // 图片宽度 imgwidth?: number // 图片宽度
duration?: number // 时长(毫秒) duration?: number // 时长(毫秒)
chatRecordTitle?: string // 嵌套聊天记录标题
chatRecordDesc?: string // 嵌套聊天记录描述
chatRecordList?: ChatRecordItem[] // 嵌套聊天记录列表
} }