mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
251 lines
8.6 KiB
TypeScript
251 lines
8.6 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import { useParams, useLocation } from 'react-router-dom'
|
|
import { ChatRecordItem } from '../types/models'
|
|
import TitleBar from '../components/TitleBar'
|
|
import './ChatHistoryPage.scss'
|
|
|
|
export default function ChatHistoryPage() {
|
|
const params = useParams<{ sessionId: string; messageId: string }>()
|
|
const location = useLocation()
|
|
const [recordList, setRecordList] = useState<ChatRecordItem[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [title, setTitle] = useState('聊天记录')
|
|
const [error, setError] = useState('')
|
|
|
|
// 简单的 XML 标签内容提取
|
|
const extractXmlValue = (xml: string, tag: string): string => {
|
|
const match = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`).exec(xml)
|
|
return match ? match[1] : ''
|
|
}
|
|
|
|
// 简单的 HTML 实体解码
|
|
const decodeHtmlEntities = (text?: string): string | undefined => {
|
|
if (!text) return text
|
|
return text
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/&/g, '&')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'")
|
|
}
|
|
|
|
// 前端兜底解析合并转发聊天记录
|
|
const parseChatHistory = (content: string): ChatRecordItem[] | undefined => {
|
|
try {
|
|
const type = extractXmlValue(content, 'type')
|
|
if (type !== '19') 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
|
|
|
|
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
|
|
})
|
|
}
|
|
|
|
return items.length > 0 ? items : undefined
|
|
} catch (e) {
|
|
console.error('前端解析聊天记录失败:', e)
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
// 统一从路由参数或 pathname 中解析 sessionId / messageId
|
|
const getIds = () => {
|
|
const sessionId = params.sessionId || ''
|
|
const messageId = params.messageId || ''
|
|
|
|
if (sessionId && messageId) {
|
|
return { sid: sessionId, mid: messageId }
|
|
}
|
|
|
|
// 独立窗口场景下没有 Route 包裹,用 pathname 手动解析
|
|
const match = /^\/chat-history\/([^/]+)\/([^/]+)/.exec(location.pathname)
|
|
if (match) {
|
|
return { sid: match[1], mid: match[2] }
|
|
}
|
|
|
|
return { sid: '', mid: '' }
|
|
}
|
|
|
|
useEffect(() => {
|
|
const loadData = async () => {
|
|
const { sid, mid } = getIds()
|
|
if (!sid || !mid) {
|
|
setError('无效的聊天记录链接')
|
|
setLoading(false)
|
|
return
|
|
}
|
|
try {
|
|
const result = await window.electronAPI.chat.getMessage(sid, parseInt(mid, 10))
|
|
if (result.success && result.message) {
|
|
const msg = result.message
|
|
// 优先使用后端解析好的列表
|
|
let records: ChatRecordItem[] | undefined = msg.chatRecordList
|
|
|
|
// 如果后端没有解析到,则在前端兜底解析一次
|
|
if ((!records || records.length === 0) && msg.content) {
|
|
records = parseChatHistory(msg.content) || []
|
|
}
|
|
|
|
if (records && records.length > 0) {
|
|
setRecordList(records)
|
|
const match = /<title>(.*?)<\/title>/.exec(msg.content || '')
|
|
if (match) setTitle(match[1])
|
|
} else {
|
|
setError('暂时无法解析这条聊天记录')
|
|
}
|
|
} else {
|
|
setError(result.error || '获取消息失败')
|
|
}
|
|
} catch (e) {
|
|
console.error(e)
|
|
setError('加载详情失败')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
loadData()
|
|
}, [params.sessionId, params.messageId, location.pathname])
|
|
|
|
return (
|
|
<div className="chat-history-page">
|
|
<TitleBar title={title} />
|
|
<div className="history-list">
|
|
{loading ? (
|
|
<div className="status-msg">加载中...</div>
|
|
) : error ? (
|
|
<div className="status-msg error">{error}</div>
|
|
) : recordList.length === 0 ? (
|
|
<div className="status-msg empty">暂无可显示的聊天记录</div>
|
|
) : (
|
|
recordList.map((item, i) => (
|
|
<HistoryItem key={i} item={item} />
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function HistoryItem({ item }: { item: ChatRecordItem }) {
|
|
// sourcetime 在合并转发里有两种格式:
|
|
// 1) 时间戳(秒) 2) 已格式化的字符串 "2026-01-21 09:56:46"
|
|
let time = ''
|
|
if (item.sourcetime) {
|
|
if (/^\d+$/.test(item.sourcetime)) {
|
|
time = new Date(parseInt(item.sourcetime, 10) * 1000).toLocaleString()
|
|
} else {
|
|
time = item.sourcetime
|
|
}
|
|
}
|
|
|
|
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">
|
|
<img
|
|
src={src}
|
|
alt="图片"
|
|
referrerPolicy="no-referrer"
|
|
onError={(e) => {
|
|
const target = e.target as HTMLImageElement
|
|
target.style.display = 'none'
|
|
const placeholder = document.createElement('div')
|
|
placeholder.className = 'media-tip'
|
|
placeholder.textContent = '图片无法加载'
|
|
target.parentElement?.appendChild(placeholder)
|
|
}}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
return <div className="media-placeholder">[图片]</div>
|
|
}
|
|
if (item.datatype === 43) {
|
|
return <div className="media-placeholder">[视频] {item.datatitle}</div>
|
|
}
|
|
if (item.datatype === 34) {
|
|
return <div className="media-placeholder">[语音] {item.duration ? (item.duration / 1000).toFixed(0) + '"' : ''}</div>
|
|
}
|
|
// Fallback
|
|
return <div className="text-content">{item.datadesc || item.datatitle || '[不支持的消息类型]'}</div>
|
|
}
|
|
|
|
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>
|
|
<div className="content-wrapper">
|
|
<div className="header">
|
|
<span className="sender">{item.sourcename || '未知发送者'}</span>
|
|
<span className="time">{time}</span>
|
|
</div>
|
|
<div className={`bubble ${item.datatype === 3 ? 'image-bubble' : ''}`}>
|
|
{renderContent()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|