+
+
{{ message.title || '聊天记录' }}
+
+
+
+ 聊天记录
+
+
{
voipType: msg.voipType || '',
title: msg.title || '',
url: msg.url || '',
+ recordItem: msg.recordItem || '',
imageMd5: msg.imageMd5 || '',
imageFileId: msg.imageFileId || '',
emojiMd5: msg.emojiMd5 || '',
@@ -3468,6 +3679,371 @@ const onEmojiDownloadClick = async (message) => {
}
}
+const getChatHistoryPreviewLines = (message) => {
+ const raw = String(message?.content || '').trim()
+ if (!raw) return []
+ return raw.split(/\r?\n/).map((x) => x.trim()).filter(Boolean).slice(0, 4)
+}
+
+// 合并转发聊天记录弹窗
+const chatHistoryModalVisible = ref(false)
+const chatHistoryModalTitle = ref('')
+const chatHistoryModalRecords = ref([])
+const chatHistoryModalInfo = ref({ isChatRoom: false })
+
+const isMaybeMd5 = (value) => /^[0-9a-f]{32}$/i.test(String(value || '').trim())
+const pickFirstMd5 = (...values) => {
+ for (const v of values) {
+ const s = String(v || '').trim()
+ if (isMaybeMd5(s)) return s.toLowerCase()
+ }
+ return ''
+}
+
+const normalizeChatHistoryUrl = (value) => String(value || '').trim().replace(/\s+/g, '')
+
+const parseChatHistoryRecord = (recordItemXml) => {
+ if (!process.client) return { info: null, items: [] }
+ const xml = String(recordItemXml || '').trim()
+ if (!xml) return { info: null, items: [] }
+
+ const normalized = xml.replace(/ /g, ' ')
+ let doc
+ try {
+ doc = new DOMParser().parseFromString(normalized, 'text/xml')
+ } catch {
+ return { info: null, items: [] }
+ }
+
+ const parserErrors = doc.getElementsByTagName('parsererror')
+ if (parserErrors && parserErrors.length) return { info: null, items: [] }
+
+ const getText = (node, tag) => {
+ try {
+ const el = node.getElementsByTagName(tag)?.[0]
+ return String(el?.textContent || '').trim()
+ } catch {
+ return ''
+ }
+ }
+
+ const root = doc?.documentElement
+ const isChatRoom = String(getText(root, 'isChatRoom') || '').trim() === '1'
+ const title = getText(root, 'title')
+ const desc = getText(root, 'desc') || getText(root, 'info')
+
+ const items = Array.from(doc.getElementsByTagName('dataitem') || [])
+ const parsed = items.map((node, idx) => {
+ const datatype = String(node.getAttribute('datatype') || '').trim()
+ const dataid = String(node.getAttribute('dataid') || '').trim() || String(idx)
+
+ const sourcename = getText(node, 'sourcename')
+ const sourcetime = getText(node, 'sourcetime')
+ const sourceheadurl = normalizeChatHistoryUrl(getText(node, 'sourceheadurl'))
+ const datatitle = getText(node, 'datatitle')
+ const datadesc = getText(node, 'datadesc')
+ const datafmt = getText(node, 'datafmt')
+ const duration = getText(node, 'duration')
+
+ const fullmd5 = getText(node, 'fullmd5')
+ const thumbfullmd5 = getText(node, 'thumbfullmd5')
+ const md5 = getText(node, 'md5') || getText(node, 'emoticonmd5') || getText(node, 'emojiMd5')
+
+ let content = datatitle || datadesc
+ if (!content) {
+ if (datatype === '4') content = '[视频]'
+ else if (datatype === '2' || datatype === '3') content = '[图片]'
+ else if (datatype === '47' || datatype === '37') content = '[表情]'
+ else if (datatype) content = `[消息 ${datatype}]`
+ else content = '[消息]'
+ }
+
+ // Guess renderType using both datatype and available tags.
+ const fmt = String(datafmt || '').trim().toLowerCase().replace(/^\./, '')
+ const imageFormats = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'heic', 'heif'])
+
+ let renderType = 'text'
+ if (datatype === '4' || String(duration || '').trim() || fmt === 'mp4') {
+ renderType = 'video'
+ } else if (datatype === '47' || datatype === '37') {
+ renderType = 'emoji'
+ } else if (
+ datatype === '2'
+ || datatype === '3'
+ || imageFormats.has(fmt)
+ || (datatype !== '1' && isMaybeMd5(fullmd5))
+ ) {
+ renderType = 'image'
+ } else if (isMaybeMd5(md5) && /表情/.test(String(content || ''))) {
+ // Some merged-forward records use non-standard datatype but still provide emoticon md5.
+ renderType = 'emoji'
+ }
+
+ return {
+ id: dataid,
+ datatype,
+ sourcename,
+ sourcetime,
+ sourceheadurl,
+ datafmt,
+ duration,
+ fullmd5,
+ thumbfullmd5,
+ md5,
+ renderType,
+ content
+ }
+ })
+
+ return {
+ info: { isChatRoom, title, desc },
+ items: parsed
+ }
+}
+
+const formatChatHistoryVideoDuration = (value) => {
+ const total = Math.max(0, parseInt(String(value || '').trim(), 10) || 0)
+ const m = Math.floor(total / 60)
+ const s = total % 60
+ if (m <= 0) return `0:${String(s).padStart(2, '0')}`
+ return `${m}:${String(s).padStart(2, '0')}`
+}
+
+const normalizeChatHistoryRecordItem = (rec) => {
+ const mediaBase = process.client ? 'http://localhost:8000' : ''
+ const account = encodeURIComponent(selectedAccount.value || '')
+ const username = encodeURIComponent(selectedContact.value?.username || '')
+
+ const out = { ...(rec || {}) }
+ out.senderDisplayName = String(out.sourcename || '').trim()
+ out.senderAvatar = normalizeChatHistoryUrl(out.sourceheadurl)
+ out.fullTime = String(out.sourcetime || '').trim()
+
+ if (out.renderType === 'video') {
+ out.videoMd5 = pickFirstMd5(out.fullmd5, out.md5)
+ out.videoThumbMd5 = pickFirstMd5(out.thumbfullmd5)
+ out.videoDuration = String(out.duration || '').trim()
+ const thumbCandidates = []
+ if (out.videoMd5) {
+ thumbCandidates.push(`${mediaBase}/api/chat/media/video_thumb?account=${account}&md5=${encodeURIComponent(out.videoMd5)}&username=${username}`)
+ }
+ if (out.videoThumbMd5 && out.videoThumbMd5 !== out.videoMd5) {
+ thumbCandidates.push(`${mediaBase}/api/chat/media/video_thumb?account=${account}&md5=${encodeURIComponent(out.videoThumbMd5)}&username=${username}`)
+ }
+ out._videoThumbCandidates = thumbCandidates
+ out._videoThumbCandidateIndex = 0
+ out._videoThumbError = false
+ out.videoThumbUrl = thumbCandidates[0] || ''
+ out.videoUrl = out.videoMd5
+ ? `${mediaBase}/api/chat/media/video?account=${account}&md5=${encodeURIComponent(out.videoMd5)}&username=${username}`
+ : ''
+ if (!out.content || /^\[.+\]$/.test(String(out.content || '').trim())) out.content = '[视频]'
+ } else if (out.renderType === 'emoji') {
+ out.emojiMd5 = pickFirstMd5(out.md5, out.fullmd5, out.thumbfullmd5)
+ out.emojiUrl = out.emojiMd5
+ ? `${mediaBase}/api/chat/media/emoji?account=${account}&md5=${encodeURIComponent(out.emojiMd5)}&username=${username}`
+ : ''
+ if (!out.content || /^\[.+\]$/.test(String(out.content || '').trim())) out.content = '[表情]'
+ } else if (out.renderType === 'image') {
+ out.imageMd5 = pickFirstMd5(out.fullmd5, out.thumbfullmd5, out.md5)
+ out.imageUrl = out.imageMd5
+ ? `${mediaBase}/api/chat/media/image?account=${account}&md5=${encodeURIComponent(out.imageMd5)}&username=${username}`
+ : ''
+ if (!out.content || /^\[.+\]$/.test(String(out.content || '').trim())) out.content = '[图片]'
+ }
+
+ return out
+}
+
+const enhanceChatHistoryRecords = (records) => {
+ const list = Array.isArray(records) ? records : []
+ const videoByThumbMd5 = new Map()
+ const videoByMd5 = new Map()
+ const imageByMd5 = new Map()
+ const emojiByMd5 = new Map()
+
+ for (const rec of list) {
+ if (!rec) continue
+ if (rec.renderType === 'video' && rec.videoThumbMd5) {
+ videoByThumbMd5.set(String(rec.videoThumbMd5).toLowerCase(), rec)
+ }
+ if (rec.renderType === 'video' && rec.videoMd5) {
+ videoByMd5.set(String(rec.videoMd5).toLowerCase(), rec)
+ }
+ if (rec.renderType === 'image') {
+ const keys = [
+ pickFirstMd5(rec.imageMd5),
+ pickFirstMd5(rec.fullmd5),
+ pickFirstMd5(rec.thumbfullmd5),
+ ].filter(Boolean)
+ for (const k of keys) imageByMd5.set(k, rec)
+ }
+ if (rec.renderType === 'emoji') {
+ const keys = [
+ pickFirstMd5(rec.emojiMd5),
+ pickFirstMd5(rec.md5),
+ pickFirstMd5(rec.fullmd5),
+ pickFirstMd5(rec.thumbfullmd5),
+ ].filter(Boolean)
+ for (const k of keys) emojiByMd5.set(k, rec)
+ }
+ }
+
+ for (const rec of list) {
+ if (!rec) continue
+ if (String(rec.renderType || '') !== 'text') continue
+
+ const refKey = pickFirstMd5(rec.thumbfullmd5) || pickFirstMd5(rec.fullmd5)
+ if (!refKey) continue
+
+ const v = videoByThumbMd5.get(refKey) || videoByMd5.get(refKey)
+ if (v) {
+ const quoteThumbCandidates = Array.isArray(v._videoThumbCandidates) ? v._videoThumbCandidates.slice() : []
+ rec._quoteThumbCandidates = quoteThumbCandidates
+ rec._quoteThumbCandidateIndex = 0
+ rec._quoteThumbError = false
+ const quoteThumbUrl = quoteThumbCandidates[0] || v.videoThumbUrl || ''
+ rec.renderType = 'quote'
+ rec.quote = {
+ kind: 'video',
+ thumbUrl: quoteThumbUrl,
+ url: v.videoUrl || '',
+ duration: v.videoDuration || '',
+ label: v.content || '[视频]',
+ targetId: v.id || ''
+ }
+ rec.quoteMedia = {
+ videoMd5: v.videoMd5,
+ videoThumbMd5: v.videoThumbMd5,
+ videoUrl: v.videoUrl,
+ videoThumbUrl: quoteThumbUrl
+ }
+ continue
+ }
+
+ const img = imageByMd5.get(refKey)
+ if (img) {
+ rec.renderType = 'quote'
+ rec.quote = {
+ kind: 'image',
+ thumbUrl: img.imageUrl || '',
+ url: img.imageUrl || '',
+ label: img.content || '[图片]',
+ targetId: img.id || ''
+ }
+ rec.quoteMedia = {
+ imageMd5: img.imageMd5,
+ imageUrl: img.imageUrl
+ }
+ continue
+ }
+
+ const em = emojiByMd5.get(refKey)
+ if (em) {
+ rec.renderType = 'quote'
+ rec.quote = {
+ kind: 'emoji',
+ thumbUrl: em.emojiUrl || '',
+ url: em.emojiUrl || '',
+ label: em.content || '[表情]',
+ targetId: em.id || ''
+ }
+ rec.quoteMedia = {
+ emojiMd5: em.emojiMd5,
+ emojiUrl: em.emojiUrl
+ }
+ }
+ }
+
+ return list
+}
+
+const onChatHistoryVideoThumbError = (rec) => {
+ if (!rec) return
+ const candidates = rec._videoThumbCandidates
+ if (!Array.isArray(candidates) || candidates.length <= 1) {
+ rec._videoThumbError = true
+ return
+ }
+
+ const cur = Math.max(0, Number(rec._videoThumbCandidateIndex || 0))
+ const next = cur + 1
+ if (next < candidates.length) {
+ rec._videoThumbCandidateIndex = next
+ rec.videoThumbUrl = candidates[next]
+ return
+ }
+ rec._videoThumbError = true
+}
+
+const onChatHistoryQuoteThumbError = (rec) => {
+ if (!rec || !rec.quote) return
+ const candidates = rec._quoteThumbCandidates
+ if (!Array.isArray(candidates) || candidates.length <= 1) {
+ rec._quoteThumbError = true
+ return
+ }
+
+ const cur = Math.max(0, Number(rec._quoteThumbCandidateIndex || 0))
+ const next = cur + 1
+ if (next < candidates.length) {
+ rec._quoteThumbCandidateIndex = next
+ rec.quote.thumbUrl = candidates[next]
+ return
+ }
+ rec._quoteThumbError = true
+}
+
+const openChatHistoryQuote = (rec) => {
+ if (!process.client) return
+ const q = rec?.quote
+ if (!q) return
+
+ const kind = String(q.kind || '')
+ const url = String(q.url || '').trim()
+ if (!url) return
+
+ if (kind === 'video') {
+ try {
+ window.open(url, '_blank', 'noreferrer')
+ } catch {}
+ return
+ }
+
+ if (kind === 'image' || kind === 'emoji') {
+ openImagePreview(url)
+ }
+}
+
+const openChatHistoryModal = (message) => {
+ if (!process.client) return
+ chatHistoryModalTitle.value = String(message?.title || '聊天记录')
+
+ const recordItem = String(message?.recordItem || '').trim()
+ const parsed = parseChatHistoryRecord(recordItem)
+ chatHistoryModalInfo.value = parsed?.info || { isChatRoom: false }
+ const records = parsed?.items
+ chatHistoryModalRecords.value = Array.isArray(records) ? enhanceChatHistoryRecords(records.map(normalizeChatHistoryRecordItem)) : []
+
+ if (!chatHistoryModalRecords.value.length) {
+ // 降级:使用摘要内容按行展示
+ const lines = String(message?.content || '').trim().split(/\r?\n/).map((x) => x.trim()).filter(Boolean)
+ chatHistoryModalInfo.value = { isChatRoom: false }
+ chatHistoryModalRecords.value = lines.map((line, idx) => normalizeChatHistoryRecordItem({ id: String(idx), datatype: '1', sourcename: '', sourcetime: '', content: line, renderType: 'text' }))
+ }
+
+ chatHistoryModalVisible.value = true
+ document.body.style.overflow = 'hidden'
+}
+
+const closeChatHistoryModal = () => {
+ chatHistoryModalVisible.value = false
+ chatHistoryModalTitle.value = ''
+ chatHistoryModalRecords.value = []
+ chatHistoryModalInfo.value = { isChatRoom: false }
+ document.body.style.overflow = previewImageUrl.value ? 'hidden' : ''
+}
+
const onGlobalClick = (e) => {
if (contextMenu.value.visible) closeContextMenu()
if (messageSearchSenderDropdownOpen.value) {
@@ -3504,6 +4080,7 @@ const onGlobalKeyDown = (e) => {
if (key === 'Escape') {
if (contextMenu.value.visible) closeContextMenu()
if (previewImageUrl.value) closeImagePreview()
+ if (chatHistoryModalVisible.value) closeChatHistoryModal()
if (messageSearchSenderDropdownOpen.value) closeMessageSearchSenderDropdown()
if (messageSearchOpen.value) closeMessageSearch()
if (searchContext.value?.active) exitSearchContext()
@@ -4105,6 +4682,65 @@ const LinkCard = defineComponent({
right: -4px;
}
+.wechat-chat-history-card {
+ width: 210px;
+ background: #ffffff;
+ border-radius: var(--message-radius);
+ cursor: pointer;
+ transition: background-color 0.15s ease;
+}
+
+.wechat-chat-history-card:hover {
+ background: #f5f5f5;
+}
+
+.wechat-chat-history-body {
+ padding: 10px 12px;
+}
+
+.wechat-chat-history-title {
+ font-size: 14px;
+ font-weight: 400;
+ color: #161616;
+ margin-bottom: 6px;
+}
+
+.wechat-chat-history-preview {
+ font-size: 12px;
+ color: #6b7280;
+ line-height: 1.4;
+}
+
+.wechat-chat-history-line {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.wechat-chat-history-bottom {
+ height: 27px;
+ display: flex;
+ align-items: center;
+ padding: 0 12px;
+ border-top: none;
+ position: relative;
+}
+
+.wechat-chat-history-bottom::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 13px;
+ right: 13px;
+ height: 1.5px;
+ background: #e8e8e8;
+}
+
+.wechat-chat-history-bottom span {
+ font-size: 12px;
+ color: #b2b2b2;
+}
+
/* 转账消息样式 - 微信风格 */
.wechat-transfer-card {
width: 210px;
diff --git a/src/wechat_decrypt_tool/chat_export_service.py b/src/wechat_decrypt_tool/chat_export_service.py
index 9054726..e3b10c0 100644
--- a/src/wechat_decrypt_tool/chat_export_service.py
+++ b/src/wechat_decrypt_tool/chat_export_service.py
@@ -890,6 +890,7 @@ def _parse_message_for_export(
content_text = raw_text
title = ""
url = ""
+ record_item = ""
image_md5 = ""
image_file_id = ""
emoji_md5 = ""
@@ -929,6 +930,7 @@ def _parse_message_for_export(
content_text = str(parsed.get("content") or "")
title = str(parsed.get("title") or "")
url = str(parsed.get("url") or "")
+ record_item = str(parsed.get("recordItem") or "")
quote_title = str(parsed.get("quoteTitle") or "")
quote_content = str(parsed.get("quoteContent") or "")
amount = str(parsed.get("amount") or "")
@@ -1089,14 +1091,17 @@ def _parse_message_for_export(
content_text = _infer_message_brief_by_local_type(local_type)
else:
if content_text.startswith("<") or content_text.startswith('"<'):
+ parsed_special = False
if "
str:
if t == 8594229559345:
return "[Red Packet]"
if t == 81604378673:
- return "[Chat History]"
+ return "[聊天记录]"
if t == 266287972401:
return "[Pat]"
if t == 8589934592049:
@@ -698,6 +698,22 @@ def _parse_app_message(text: str) -> dict[str, Any]:
lower = text.lower()
+ if app_type == 19:
+ # 合并转发聊天记录(Chat History)
+ # 注意:recorditem 的 CDATA 内部可能包含 等标签,不能据此把整条消息误判为引用消息。
+ record_item = _extract_xml_tag_text(text, "recorditem")
+ preview = (des or "").strip()
+ if not preview:
+ if record_item:
+ preview = str(_extract_xml_tag_text(record_item, "desc") or "").strip()
+
+ return {
+ "renderType": "chatHistory",
+ "content": preview or "[聊天记录]",
+ "title": (title or "").strip() or "聊天记录",
+ "recordItem": record_item or "",
+ }
+
if app_type in (5, 68) and url:
thumb_url = _extract_xml_tag_text(text, "thumburl")
return {
@@ -724,7 +740,21 @@ def _parse_app_message(text: str) -> dict[str, Any]:
"fileMd5": file_md5 or "",
}
- if app_type == 57 or "]*>.*?)",
+ "",
+ text,
+ flags=re.IGNORECASE | re.DOTALL,
+ ).lower()
+ except Exception:
+ refermsg_probe = lower
+
+ if app_type == 57 or "