mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
支持聊天记录转发解析与嵌套聊天记录解析;优化聊天记录转发窗口样式
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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?: {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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('<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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(/'/g, "'")
|
.replace(/'/g, "'")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const extractTopLevelXmlElements = (source: string, tagName: string): Array<{ attrs: string; inner: string }> => {
|
||||||
|
const xml = source || ''
|
||||||
|
if (!xml) return []
|
||||||
|
|
||||||
|
const pattern = new RegExp(`<(/?)${tagName}\\b([^>]*)>`, 'gi')
|
||||||
|
const result: Array<{ attrs: string; inner: string }> = []
|
||||||
|
let match: RegExpExecArray | null
|
||||||
|
let depth = 0
|
||||||
|
let openEnd = -1
|
||||||
|
let openStart = -1
|
||||||
|
let openAttrs = ''
|
||||||
|
|
||||||
|
while ((match = pattern.exec(xml)) !== null) {
|
||||||
|
const isClosing = match[1] === '/'
|
||||||
|
const attrs = match[2] || ''
|
||||||
|
const rawTag = match[0] || ''
|
||||||
|
const selfClosing = !isClosing && /\/\s*>$/.test(rawTag)
|
||||||
|
|
||||||
|
if (!isClosing) {
|
||||||
|
if (depth === 0) {
|
||||||
|
openStart = match.index
|
||||||
|
openEnd = pattern.lastIndex
|
||||||
|
openAttrs = attrs
|
||||||
|
}
|
||||||
|
if (!selfClosing) {
|
||||||
|
depth += 1
|
||||||
|
} else if (depth === 0 && openEnd >= 0) {
|
||||||
|
result.push({ attrs: openAttrs, inner: '' })
|
||||||
|
openStart = -1
|
||||||
|
openEnd = -1
|
||||||
|
openAttrs = ''
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (depth <= 0) continue
|
||||||
|
depth -= 1
|
||||||
|
if (depth === 0 && openEnd >= 0 && openStart >= 0) {
|
||||||
|
result.push({
|
||||||
|
attrs: openAttrs,
|
||||||
|
inner: xml.slice(openEnd, match.index)
|
||||||
|
})
|
||||||
|
openStart = -1
|
||||||
|
openEnd = -1
|
||||||
|
openAttrs = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseChatRecordDataItem = (body: string, attrs = ''): ChatRecordItem | null => {
|
||||||
|
const datatypeMatch = /datatype\s*=\s*["']?(\d+)["']?/i.exec(attrs || '')
|
||||||
|
const datatype = datatypeMatch ? parseInt(datatypeMatch[1], 10) : parseInt(extractXmlValue(body, 'datatype') || '0', 10)
|
||||||
|
|
||||||
|
const sourcename = decodeHtmlEntities(extractXmlValue(body, 'sourcename')) || ''
|
||||||
|
const sourcetime = extractXmlValue(body, 'sourcetime') || ''
|
||||||
|
const sourceheadurl = extractXmlValue(body, 'sourceheadurl') || undefined
|
||||||
|
const datadesc = decodeHtmlEntities(extractXmlValue(body, 'datadesc') || extractXmlValue(body, 'content')) || undefined
|
||||||
|
const datatitle = decodeHtmlEntities(extractXmlValue(body, 'datatitle')) || undefined
|
||||||
|
const fileext = extractXmlValue(body, 'fileext') || undefined
|
||||||
|
const datasize = parseInt(extractXmlValue(body, 'datasize') || '0', 10) || undefined
|
||||||
|
const messageuuid = extractXmlValue(body, 'messageuuid') || undefined
|
||||||
|
|
||||||
|
const dataurl = decodeHtmlEntities(extractXmlValue(body, 'dataurl')) || undefined
|
||||||
|
const datathumburl = decodeHtmlEntities(
|
||||||
|
extractXmlValue(body, 'datathumburl') ||
|
||||||
|
extractXmlValue(body, 'thumburl') ||
|
||||||
|
extractXmlValue(body, 'cdnthumburl')
|
||||||
|
) || undefined
|
||||||
|
const datacdnurl = decodeHtmlEntities(
|
||||||
|
extractXmlValue(body, 'datacdnurl') ||
|
||||||
|
extractXmlValue(body, 'cdnurl') ||
|
||||||
|
extractXmlValue(body, 'cdndataurl')
|
||||||
|
) || undefined
|
||||||
|
const cdndatakey = decodeHtmlEntities(extractXmlValue(body, 'cdndatakey')) || undefined
|
||||||
|
const cdnthumbkey = decodeHtmlEntities(extractXmlValue(body, 'cdnthumbkey')) || undefined
|
||||||
|
const aeskey = decodeHtmlEntities(extractXmlValue(body, 'aeskey') || extractXmlValue(body, 'qaeskey')) || undefined
|
||||||
|
const md5 = extractXmlValue(body, 'md5') || extractXmlValue(body, 'datamd5') || undefined
|
||||||
|
const fullmd5 = extractXmlValue(body, 'fullmd5') || undefined
|
||||||
|
const thumbfullmd5 = extractXmlValue(body, 'thumbfullmd5') || undefined
|
||||||
|
const srcMsgLocalid = parseInt(extractXmlValue(body, 'srcMsgLocalid') || '0', 10) || undefined
|
||||||
|
const imgheight = parseInt(extractXmlValue(body, 'imgheight') || '0', 10) || undefined
|
||||||
|
const imgwidth = parseInt(extractXmlValue(body, 'imgwidth') || '0', 10) || undefined
|
||||||
|
const duration = parseInt(extractXmlValue(body, 'duration') || '0', 10) || undefined
|
||||||
|
const nestedRecordXml = extractXmlValue(body, 'recordxml') || undefined
|
||||||
|
const chatRecordTitle = decodeHtmlEntities(
|
||||||
|
(nestedRecordXml && extractXmlValue(nestedRecordXml, 'title')) ||
|
||||||
|
datatitle ||
|
||||||
|
''
|
||||||
|
) || undefined
|
||||||
|
const chatRecordDesc = decodeHtmlEntities(
|
||||||
|
(nestedRecordXml && extractXmlValue(nestedRecordXml, 'desc')) ||
|
||||||
|
datadesc ||
|
||||||
|
''
|
||||||
|
) || undefined
|
||||||
|
const chatRecordList =
|
||||||
|
datatype === 17 && nestedRecordXml
|
||||||
|
? parseChatRecordContainer(nestedRecordXml)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
if (!(datatype || sourcename || datadesc || datatitle || messageuuid || srcMsgLocalid)) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
datatype: Number.isFinite(datatype) ? datatype : 0,
|
||||||
|
sourcename,
|
||||||
|
sourcetime,
|
||||||
|
sourceheadurl,
|
||||||
|
datadesc,
|
||||||
|
datatitle,
|
||||||
|
fileext,
|
||||||
|
datasize,
|
||||||
|
messageuuid,
|
||||||
|
dataurl,
|
||||||
|
datathumburl,
|
||||||
|
datacdnurl,
|
||||||
|
cdndatakey,
|
||||||
|
cdnthumbkey,
|
||||||
|
aeskey,
|
||||||
|
md5,
|
||||||
|
fullmd5,
|
||||||
|
thumbfullmd5,
|
||||||
|
srcMsgLocalid,
|
||||||
|
imgheight,
|
||||||
|
imgwidth,
|
||||||
|
duration,
|
||||||
|
chatRecordTitle,
|
||||||
|
chatRecordDesc,
|
||||||
|
chatRecordList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseChatRecordContainer = (containerXml: string): ChatRecordItem[] => {
|
||||||
|
const source = containerXml || ''
|
||||||
|
if (!source) return []
|
||||||
|
|
||||||
|
const segments: string[] = [source]
|
||||||
|
const decodedContainer = decodeHtmlEntities(source)
|
||||||
|
if (decodedContainer && decodedContainer !== source) {
|
||||||
|
segments.push(decodedContainer)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cdataRegex = /<!\[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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
4
src/types/electron.d.ts
vendored
4
src/types/electron.d.ts
vendored
@@ -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: {
|
||||||
|
|||||||
@@ -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[] // 嵌套聊天记录列表
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user