mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-28 15:07:55 +00:00
支持聊天记录转发解析与嵌套聊天记录解析;优化聊天记录转发窗口样式
This commit is contained in:
@@ -3,10 +3,13 @@ import { useParams, useLocation } from 'react-router-dom'
|
||||
import { ChatRecordItem } from '../types/models'
|
||||
import TitleBar from '../components/TitleBar'
|
||||
import { ErrorBoundary } from '../components/ErrorBoundary'
|
||||
import { Avatar } from '../components/Avatar'
|
||||
import './ChatHistoryPage.scss'
|
||||
|
||||
const forwardedImageCache = new Map<string, string>()
|
||||
|
||||
export default function ChatHistoryPage() {
|
||||
const params = useParams<{ sessionId: string; messageId: string }>()
|
||||
const params = useParams<{ sessionId: string; messageId: string; payloadId: string }>()
|
||||
const location = useLocation()
|
||||
const [recordList, setRecordList] = useState<ChatRecordItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -30,64 +33,212 @@ export default function ChatHistoryPage() {
|
||||
.replace(/'/g, "'")
|
||||
}
|
||||
|
||||
const extractTopLevelXmlElements = (source: string, tagName: string): Array<{ attrs: string; inner: string }> => {
|
||||
const xml = source || ''
|
||||
if (!xml) return []
|
||||
|
||||
const pattern = new RegExp(`<(/?)${tagName}\\b([^>]*)>`, 'gi')
|
||||
const result: Array<{ attrs: string; inner: string }> = []
|
||||
let match: RegExpExecArray | null
|
||||
let depth = 0
|
||||
let openEnd = -1
|
||||
let openStart = -1
|
||||
let openAttrs = ''
|
||||
|
||||
while ((match = pattern.exec(xml)) !== null) {
|
||||
const isClosing = match[1] === '/'
|
||||
const attrs = match[2] || ''
|
||||
const rawTag = match[0] || ''
|
||||
const selfClosing = !isClosing && /\/\s*>$/.test(rawTag)
|
||||
|
||||
if (!isClosing) {
|
||||
if (depth === 0) {
|
||||
openStart = match.index
|
||||
openEnd = pattern.lastIndex
|
||||
openAttrs = attrs
|
||||
}
|
||||
if (!selfClosing) {
|
||||
depth += 1
|
||||
} else if (depth === 0 && openEnd >= 0) {
|
||||
result.push({ attrs: openAttrs, inner: '' })
|
||||
openStart = -1
|
||||
openEnd = -1
|
||||
openAttrs = ''
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (depth <= 0) continue
|
||||
depth -= 1
|
||||
if (depth === 0 && openEnd >= 0 && openStart >= 0) {
|
||||
result.push({
|
||||
attrs: openAttrs,
|
||||
inner: xml.slice(openEnd, match.index)
|
||||
})
|
||||
openStart = -1
|
||||
openEnd = -1
|
||||
openAttrs = ''
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const parseChatRecordDataItem = (body: string, attrs = ''): ChatRecordItem | null => {
|
||||
const datatypeMatch = /datatype\s*=\s*["']?(\d+)["']?/i.exec(attrs || '')
|
||||
const datatype = datatypeMatch ? parseInt(datatypeMatch[1], 10) : parseInt(extractXmlValue(body, 'datatype') || '0', 10)
|
||||
|
||||
const sourcename = decodeHtmlEntities(extractXmlValue(body, 'sourcename')) || ''
|
||||
const sourcetime = extractXmlValue(body, 'sourcetime') || ''
|
||||
const sourceheadurl = extractXmlValue(body, 'sourceheadurl') || undefined
|
||||
const datadesc = decodeHtmlEntities(extractXmlValue(body, 'datadesc') || extractXmlValue(body, 'content')) || undefined
|
||||
const datatitle = decodeHtmlEntities(extractXmlValue(body, 'datatitle')) || undefined
|
||||
const fileext = extractXmlValue(body, 'fileext') || undefined
|
||||
const datasize = parseInt(extractXmlValue(body, 'datasize') || '0', 10) || undefined
|
||||
const messageuuid = extractXmlValue(body, 'messageuuid') || undefined
|
||||
|
||||
const dataurl = decodeHtmlEntities(extractXmlValue(body, 'dataurl')) || undefined
|
||||
const datathumburl = decodeHtmlEntities(
|
||||
extractXmlValue(body, 'datathumburl') ||
|
||||
extractXmlValue(body, 'thumburl') ||
|
||||
extractXmlValue(body, 'cdnthumburl')
|
||||
) || undefined
|
||||
const datacdnurl = decodeHtmlEntities(
|
||||
extractXmlValue(body, 'datacdnurl') ||
|
||||
extractXmlValue(body, 'cdnurl') ||
|
||||
extractXmlValue(body, 'cdndataurl')
|
||||
) || undefined
|
||||
const cdndatakey = decodeHtmlEntities(extractXmlValue(body, 'cdndatakey')) || undefined
|
||||
const cdnthumbkey = decodeHtmlEntities(extractXmlValue(body, 'cdnthumbkey')) || undefined
|
||||
const aeskey = decodeHtmlEntities(extractXmlValue(body, 'aeskey') || extractXmlValue(body, 'qaeskey')) || undefined
|
||||
const md5 = extractXmlValue(body, 'md5') || extractXmlValue(body, 'datamd5') || undefined
|
||||
const fullmd5 = extractXmlValue(body, 'fullmd5') || undefined
|
||||
const thumbfullmd5 = extractXmlValue(body, 'thumbfullmd5') || undefined
|
||||
const srcMsgLocalid = parseInt(extractXmlValue(body, 'srcMsgLocalid') || '0', 10) || undefined
|
||||
const imgheight = parseInt(extractXmlValue(body, 'imgheight') || '0', 10) || undefined
|
||||
const imgwidth = parseInt(extractXmlValue(body, 'imgwidth') || '0', 10) || undefined
|
||||
const duration = parseInt(extractXmlValue(body, 'duration') || '0', 10) || undefined
|
||||
const nestedRecordXml = extractXmlValue(body, 'recordxml') || undefined
|
||||
const chatRecordTitle = decodeHtmlEntities(
|
||||
(nestedRecordXml && extractXmlValue(nestedRecordXml, 'title')) ||
|
||||
datatitle ||
|
||||
''
|
||||
) || undefined
|
||||
const chatRecordDesc = decodeHtmlEntities(
|
||||
(nestedRecordXml && extractXmlValue(nestedRecordXml, 'desc')) ||
|
||||
datadesc ||
|
||||
''
|
||||
) || undefined
|
||||
const chatRecordList =
|
||||
datatype === 17 && nestedRecordXml
|
||||
? parseChatRecordContainer(nestedRecordXml)
|
||||
: undefined
|
||||
|
||||
if (!(datatype || sourcename || datadesc || datatitle || messageuuid || srcMsgLocalid)) return null
|
||||
|
||||
return {
|
||||
datatype: Number.isFinite(datatype) ? datatype : 0,
|
||||
sourcename,
|
||||
sourcetime,
|
||||
sourceheadurl,
|
||||
datadesc,
|
||||
datatitle,
|
||||
fileext,
|
||||
datasize,
|
||||
messageuuid,
|
||||
dataurl,
|
||||
datathumburl,
|
||||
datacdnurl,
|
||||
cdndatakey,
|
||||
cdnthumbkey,
|
||||
aeskey,
|
||||
md5,
|
||||
fullmd5,
|
||||
thumbfullmd5,
|
||||
srcMsgLocalid,
|
||||
imgheight,
|
||||
imgwidth,
|
||||
duration,
|
||||
chatRecordTitle,
|
||||
chatRecordDesc,
|
||||
chatRecordList
|
||||
}
|
||||
}
|
||||
|
||||
const parseChatRecordContainer = (containerXml: string): ChatRecordItem[] => {
|
||||
const source = containerXml || ''
|
||||
if (!source) return []
|
||||
|
||||
const segments: string[] = [source]
|
||||
const decodedContainer = decodeHtmlEntities(source)
|
||||
if (decodedContainer && decodedContainer !== source) {
|
||||
segments.push(decodedContainer)
|
||||
}
|
||||
|
||||
const cdataRegex = /<!\[CDATA\[([\s\S]*?)\]\]>/g
|
||||
let cdataMatch: RegExpExecArray | null
|
||||
while ((cdataMatch = cdataRegex.exec(source)) !== null) {
|
||||
const cdataInner = cdataMatch[1] || ''
|
||||
if (!cdataInner) continue
|
||||
segments.push(cdataInner)
|
||||
const decodedInner = decodeHtmlEntities(cdataInner)
|
||||
if (decodedInner && decodedInner !== cdataInner) {
|
||||
segments.push(decodedInner)
|
||||
}
|
||||
}
|
||||
|
||||
const items: ChatRecordItem[] = []
|
||||
const dedupe = new Set<string>()
|
||||
for (const segment of segments) {
|
||||
if (!segment) continue
|
||||
const dataItems = extractTopLevelXmlElements(segment, 'dataitem')
|
||||
for (const dataItem of dataItems) {
|
||||
const item = parseChatRecordDataItem(dataItem.inner || '', dataItem.attrs || '')
|
||||
if (!item) continue
|
||||
const key = `${item.datatype}|${item.sourcename}|${item.sourcetime}|${item.datadesc || ''}|${item.datatitle || ''}|${item.messageuuid || ''}`
|
||||
if (!dedupe.has(key)) {
|
||||
dedupe.add(key)
|
||||
items.push(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (items.length > 0) return items
|
||||
const fallback = parseChatRecordDataItem(source, '')
|
||||
return fallback ? [fallback] : []
|
||||
}
|
||||
|
||||
// 前端兜底解析合并转发聊天记录
|
||||
const parseChatHistory = (content: string): ChatRecordItem[] | undefined => {
|
||||
try {
|
||||
const type = extractXmlValue(content, 'type')
|
||||
if (type !== '19') return undefined
|
||||
const decodedContent = decodeHtmlEntities(content) || content
|
||||
const type = extractXmlValue(decodedContent, 'type')
|
||||
if (type !== '19' && !decodedContent.includes('<recorditem')) return undefined
|
||||
|
||||
const match = /<recorditem>[\s\S]*?<!\[CDATA\[([\s\S]*?)\]\]>[\s\S]*?<\/recorditem>/.exec(content)
|
||||
if (!match) return undefined
|
||||
|
||||
const innerXml = match[1]
|
||||
const items: ChatRecordItem[] = []
|
||||
const itemRegex = /<dataitem\s+(.*?)>([\s\S]*?)<\/dataitem>/g
|
||||
let itemMatch: RegExpExecArray | null
|
||||
const dedupe = new Set<string>()
|
||||
const recordItemRegex = /<recorditem>([\s\S]*?)<\/recorditem>/gi
|
||||
let recordItemMatch: RegExpExecArray | null
|
||||
while ((recordItemMatch = recordItemRegex.exec(decodedContent)) !== null) {
|
||||
const parsedItems = parseChatRecordContainer(recordItemMatch[1] || '')
|
||||
for (const item of parsedItems) {
|
||||
const key = `${item.datatype}|${item.sourcename}|${item.sourcetime}|${item.datadesc || ''}|${item.datatitle || ''}|${item.messageuuid || ''}`
|
||||
if (!dedupe.has(key)) {
|
||||
dedupe.add(key)
|
||||
items.push(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while ((itemMatch = itemRegex.exec(innerXml)) !== null) {
|
||||
const attrs = itemMatch[1]
|
||||
const body = itemMatch[2]
|
||||
|
||||
const datatypeMatch = /datatype="(\d+)"/.exec(attrs)
|
||||
const datatype = datatypeMatch ? parseInt(datatypeMatch[1]) : 0
|
||||
|
||||
const sourcename = extractXmlValue(body, 'sourcename')
|
||||
const sourcetime = extractXmlValue(body, 'sourcetime')
|
||||
const sourceheadurl = extractXmlValue(body, 'sourceheadurl')
|
||||
const datadesc = extractXmlValue(body, 'datadesc')
|
||||
const datatitle = extractXmlValue(body, 'datatitle')
|
||||
const fileext = extractXmlValue(body, 'fileext')
|
||||
const datasize = parseInt(extractXmlValue(body, 'datasize') || '0')
|
||||
const messageuuid = extractXmlValue(body, 'messageuuid')
|
||||
|
||||
const dataurl = extractXmlValue(body, 'dataurl')
|
||||
const datathumburl = extractXmlValue(body, 'datathumburl') || extractXmlValue(body, 'thumburl')
|
||||
const datacdnurl = extractXmlValue(body, 'datacdnurl') || extractXmlValue(body, 'cdnurl')
|
||||
const aeskey = extractXmlValue(body, 'aeskey') || extractXmlValue(body, 'qaeskey')
|
||||
const md5 = extractXmlValue(body, 'md5') || extractXmlValue(body, 'datamd5')
|
||||
const imgheight = parseInt(extractXmlValue(body, 'imgheight') || '0')
|
||||
const imgwidth = parseInt(extractXmlValue(body, 'imgwidth') || '0')
|
||||
const duration = parseInt(extractXmlValue(body, 'duration') || '0')
|
||||
|
||||
items.push({
|
||||
datatype,
|
||||
sourcename,
|
||||
sourcetime,
|
||||
sourceheadurl,
|
||||
datadesc: decodeHtmlEntities(datadesc),
|
||||
datatitle: decodeHtmlEntities(datatitle),
|
||||
fileext,
|
||||
datasize,
|
||||
messageuuid,
|
||||
dataurl: decodeHtmlEntities(dataurl),
|
||||
datathumburl: decodeHtmlEntities(datathumburl),
|
||||
datacdnurl: decodeHtmlEntities(datacdnurl),
|
||||
aeskey: decodeHtmlEntities(aeskey),
|
||||
md5,
|
||||
imgheight,
|
||||
imgwidth,
|
||||
duration
|
||||
})
|
||||
if (items.length === 0 && decodedContent.includes('<dataitem')) {
|
||||
const parsedItems = parseChatRecordContainer(decodedContent)
|
||||
for (const item of parsedItems) {
|
||||
const key = `${item.datatype}|${item.sourcename}|${item.sourcetime}|${item.datadesc || ''}|${item.datatitle || ''}|${item.messageuuid || ''}`
|
||||
if (!dedupe.has(key)) {
|
||||
dedupe.add(key)
|
||||
items.push(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return items.length > 0 ? items : undefined
|
||||
@@ -115,9 +266,34 @@ export default function ChatHistoryPage() {
|
||||
return { sid: '', mid: '' }
|
||||
}
|
||||
|
||||
const ids = getIds()
|
||||
const payloadId = params.payloadId || (() => {
|
||||
const match = /^\/chat-history-inline\/([^/]+)/.exec(location.pathname)
|
||||
return match ? match[1] : ''
|
||||
})()
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
const { sid, mid } = getIds()
|
||||
if (payloadId) {
|
||||
try {
|
||||
const result = await window.electronAPI.window.getChatHistoryPayload(payloadId)
|
||||
if (result.success && result.payload) {
|
||||
setRecordList(Array.isArray(result.payload.recordList) ? result.payload.recordList : [])
|
||||
setTitle(result.payload.title || '聊天记录')
|
||||
setError('')
|
||||
} else {
|
||||
setError(result.error || '聊天记录载荷不存在')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
setError('加载详情失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const { sid, mid } = ids
|
||||
if (!sid || !mid) {
|
||||
setError('无效的聊天记录链接')
|
||||
setLoading(false)
|
||||
@@ -153,7 +329,7 @@ export default function ChatHistoryPage() {
|
||||
}
|
||||
}
|
||||
loadData()
|
||||
}, [params.sessionId, params.messageId, location.pathname])
|
||||
}, [ids.mid, ids.sid, location.pathname, payloadId])
|
||||
|
||||
return (
|
||||
<div className="chat-history-page">
|
||||
@@ -168,7 +344,7 @@ export default function ChatHistoryPage() {
|
||||
) : (
|
||||
recordList.map((item, i) => (
|
||||
<ErrorBoundary key={i} fallback={<div className="history-item error-item">消息解析失败</div>}>
|
||||
<HistoryItem item={item} />
|
||||
<HistoryItem item={item} sessionId={ids.sid} />
|
||||
</ErrorBoundary>
|
||||
))
|
||||
)}
|
||||
@@ -177,9 +353,198 @@ export default function ChatHistoryPage() {
|
||||
)
|
||||
}
|
||||
|
||||
function HistoryItem({ item }: { item: ChatRecordItem }) {
|
||||
const [imageError, setImageError] = useState(false)
|
||||
|
||||
function detectImageMimeFromBase64(base64: string): string {
|
||||
try {
|
||||
const head = window.atob(base64.slice(0, 48))
|
||||
const bytes = new Uint8Array(head.length)
|
||||
for (let i = 0; i < head.length; i++) {
|
||||
bytes[i] = head.charCodeAt(i)
|
||||
}
|
||||
if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) return 'image/gif'
|
||||
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47) return 'image/png'
|
||||
if (bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF) return 'image/jpeg'
|
||||
if (
|
||||
bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 &&
|
||||
bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50
|
||||
) {
|
||||
return 'image/webp'
|
||||
}
|
||||
} catch { }
|
||||
return 'image/jpeg'
|
||||
}
|
||||
|
||||
function normalizeChatRecordText(value?: string): string {
|
||||
return String(value || '')
|
||||
.replace(/\u00a0/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function getChatRecordPreviewText(item: ChatRecordItem): string {
|
||||
const text = normalizeChatRecordText(item.datadesc) || normalizeChatRecordText(item.datatitle)
|
||||
if (item.datatype === 17) {
|
||||
return normalizeChatRecordText(item.chatRecordTitle) || normalizeChatRecordText(item.datatitle) || '聊天记录'
|
||||
}
|
||||
if (item.datatype === 2 || item.datatype === 3) return '[图片]'
|
||||
if (item.datatype === 43) return '[视频]'
|
||||
if (item.datatype === 34) return '[语音]'
|
||||
if (item.datatype === 47) return '[表情]'
|
||||
return text || '[媒体消息]'
|
||||
}
|
||||
|
||||
function ForwardedImage({ item, sessionId }: { item: ChatRecordItem; sessionId: string }) {
|
||||
const cacheKey =
|
||||
item.thumbfullmd5 ||
|
||||
item.fullmd5 ||
|
||||
item.md5 ||
|
||||
item.messageuuid ||
|
||||
item.datathumburl ||
|
||||
item.datacdnurl ||
|
||||
item.dataurl ||
|
||||
`local:${item.srcMsgLocalid || 0}`
|
||||
const [localPath, setLocalPath] = useState<string | undefined>(() => forwardedImageCache.get(cacheKey))
|
||||
const [loading, setLoading] = useState(!forwardedImageCache.has(cacheKey))
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (localPath || error) return
|
||||
|
||||
let cancelled = false
|
||||
const candidateMd5s = Array.from(new Set([
|
||||
item.thumbfullmd5,
|
||||
item.fullmd5,
|
||||
item.md5
|
||||
].filter(Boolean) as string[]))
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
|
||||
for (const imageMd5 of candidateMd5s) {
|
||||
const cached = await window.electronAPI.image.resolveCache({ imageMd5 })
|
||||
if (cached.success && cached.localPath) {
|
||||
if (!cancelled) {
|
||||
forwardedImageCache.set(cacheKey, cached.localPath)
|
||||
setLocalPath(cached.localPath)
|
||||
setLoading(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
for (const imageMd5 of candidateMd5s) {
|
||||
const decrypted = await window.electronAPI.image.decrypt({ imageMd5 })
|
||||
if (decrypted.success && decrypted.localPath) {
|
||||
if (!cancelled) {
|
||||
forwardedImageCache.set(cacheKey, decrypted.localPath)
|
||||
setLocalPath(decrypted.localPath)
|
||||
setLoading(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionId && item.srcMsgLocalid) {
|
||||
const fallback = await window.electronAPI.chat.getImageData(sessionId, String(item.srcMsgLocalid))
|
||||
if (fallback.success && fallback.data) {
|
||||
const dataUrl = `data:${detectImageMimeFromBase64(fallback.data)};base64,${fallback.data}`
|
||||
if (!cancelled) {
|
||||
forwardedImageCache.set(cacheKey, dataUrl)
|
||||
setLocalPath(dataUrl)
|
||||
setLoading(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const remoteSrc = item.dataurl || item.datathumburl || item.datacdnurl
|
||||
if (remoteSrc && /^https?:\/\//i.test(remoteSrc)) {
|
||||
if (!cancelled) {
|
||||
setLocalPath(remoteSrc)
|
||||
setLoading(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
setError(true)
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
load().catch(() => {
|
||||
if (!cancelled) {
|
||||
setError(true)
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [cacheKey, error, item.dataurl, item.datacdnurl, item.datathumburl, item.fullmd5, item.md5, item.messageuuid, item.srcMsgLocalid, item.thumbfullmd5, localPath, sessionId])
|
||||
|
||||
if (localPath) {
|
||||
return (
|
||||
<div className="media-content">
|
||||
<img src={localPath} alt="图片" referrerPolicy="no-referrer" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="media-tip">图片加载中...</div>
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="media-tip">图片未索引到本地缓存</div>
|
||||
}
|
||||
|
||||
return <div className="media-placeholder">[图片]</div>
|
||||
}
|
||||
|
||||
function NestedChatRecordCard({ item, sessionId }: { item: ChatRecordItem; sessionId: string }) {
|
||||
const previewItems = (item.chatRecordList || []).slice(0, 3)
|
||||
const title = normalizeChatRecordText(item.chatRecordTitle) || normalizeChatRecordText(item.datatitle) || '聊天记录'
|
||||
const description = normalizeChatRecordText(item.chatRecordDesc) || normalizeChatRecordText(item.datadesc)
|
||||
const canOpen = Boolean(sessionId && item.chatRecordList && item.chatRecordList.length > 0)
|
||||
|
||||
const handleOpen = () => {
|
||||
if (!canOpen) return
|
||||
window.electronAPI.window.openChatHistoryPayloadWindow({
|
||||
sessionId,
|
||||
title,
|
||||
recordList: item.chatRecordList || []
|
||||
}).catch(() => { })
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`nested-chat-record-card${canOpen ? ' clickable' : ''}`}
|
||||
onClick={handleOpen}
|
||||
disabled={!canOpen}
|
||||
title={canOpen ? '点击打开聊天记录' : undefined}
|
||||
>
|
||||
<div className="nested-chat-record-title">{title}</div>
|
||||
{previewItems.length > 0 ? (
|
||||
<div className="nested-chat-record-list">
|
||||
{previewItems.map((previewItem, index) => (
|
||||
<div key={`${previewItem.messageuuid || previewItem.srcMsgLocalid || index}`} className="nested-chat-record-line">
|
||||
{getChatRecordPreviewText(previewItem)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : description ? (
|
||||
<div className="nested-chat-record-list">
|
||||
<div className="nested-chat-record-line">{description}</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="nested-chat-record-footer">聊天记录</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function HistoryItem({ item, sessionId }: { item: ChatRecordItem; sessionId: string }) {
|
||||
// sourcetime 在合并转发里有两种格式:
|
||||
// 1) 时间戳(秒) 2) 已格式化的字符串 "2026-01-21 09:56:46"
|
||||
let time = ''
|
||||
@@ -191,31 +556,18 @@ function HistoryItem({ item }: { item: ChatRecordItem }) {
|
||||
}
|
||||
}
|
||||
|
||||
const senderDisplayName = item.sourcename ?? '未知发送者'
|
||||
|
||||
const renderContent = () => {
|
||||
if (item.datatype === 1) {
|
||||
// 文本消息
|
||||
return <div className="text-content">{item.datadesc || ''}</div>
|
||||
}
|
||||
if (item.datatype === 3) {
|
||||
// 图片
|
||||
const src = item.datathumburl || item.datacdnurl
|
||||
if (src) {
|
||||
return (
|
||||
<div className="media-content">
|
||||
{imageError ? (
|
||||
<div className="media-tip">图片无法加载</div>
|
||||
) : (
|
||||
<img
|
||||
src={src}
|
||||
alt="图片"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <div className="media-placeholder">[图片]</div>
|
||||
if (item.datatype === 2 || item.datatype === 3) {
|
||||
return <ForwardedImage item={item} sessionId={sessionId} />
|
||||
}
|
||||
if (item.datatype === 17) {
|
||||
return <NestedChatRecordCard item={item} sessionId={sessionId} />
|
||||
}
|
||||
if (item.datatype === 43) {
|
||||
return <div className="media-placeholder">[视频] {item.datatitle}</div>
|
||||
@@ -229,21 +581,20 @@ function HistoryItem({ item }: { item: ChatRecordItem }) {
|
||||
|
||||
return (
|
||||
<div className="history-item">
|
||||
<div className="avatar">
|
||||
{item.sourceheadurl ? (
|
||||
<img src={item.sourceheadurl} alt="" referrerPolicy="no-referrer" />
|
||||
) : (
|
||||
<div className="avatar-placeholder">
|
||||
{item.sourcename?.slice(0, 1)}
|
||||
</div>
|
||||
)}
|
||||
<div className="history-avatar">
|
||||
<Avatar
|
||||
src={item.sourceheadurl}
|
||||
name={senderDisplayName}
|
||||
size={36}
|
||||
className="avatar-inner"
|
||||
/>
|
||||
</div>
|
||||
<div className="content-wrapper">
|
||||
<div className="header">
|
||||
<span className="sender">{item.sourcename || '未知发送者'}</span>
|
||||
<span className="sender">{senderDisplayName}</span>
|
||||
<span className="time">{time}</span>
|
||||
</div>
|
||||
<div className={`bubble ${item.datatype === 3 ? 'image-bubble' : ''}`}>
|
||||
<div className={`bubble ${(item.datatype === 2 || item.datatype === 3) ? 'image-bubble' : ''}`}>
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user