From d37131bf96732468a887ab1d6d08f8079a5cafa9 Mon Sep 17 00:00:00 2001 From: 2977094657 <2977094657@qq.com> Date: Thu, 1 Jan 2026 16:30:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(chat):=20=E5=90=88=E5=B9=B6=E8=BD=AC?= =?UTF-8?q?=E5=8F=91=E8=81=8A=E5=A4=A9=E8=AE=B0=E5=BD=95=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E9=A2=84=E8=A7=88=E4=B8=8E=E5=BC=B9=E7=AA=97=E6=9F=A5=E7=9C=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - appmsg(type=19) 解析为 renderType=chatHistory,并透传 recordItem(recorditem 原文) - 修复 recorditem CDATA 内包含 时误判为引用消息的问题 - 列表/导出路径统一带上 recordItem,并避免已解析的 appmsg 被二次 XML 解析覆盖 - 前端聊天页新增聊天记录卡片 + 弹窗,支持按条展示及图片/视频/引用内容预览 - 会话列表与摘要统一显示为 [聊天记录] --- frontend/pages/chat/[[username]].vue | 638 +++++++++++++++++- .../chat_export_service.py | 14 +- src/wechat_decrypt_tool/chat_helpers.py | 38 +- src/wechat_decrypt_tool/routers/chat.py | 55 +- 4 files changed, 729 insertions(+), 16 deletions(-) diff --git a/frontend/pages/chat/[[username]].vue b/frontend/pages/chat/[[username]].vue index fd934d7..595f2eb 100644 --- a/frontend/pages/chat/[[username]].vue +++ b/frontend/pages/chat/[[username]].vue @@ -318,7 +318,7 @@
- 视频 + 视频
{{ message.content }} @@ -442,6 +442,29 @@
+ +
+
+
{{ message.title || '聊天记录' }}
+
+
+ {{ line }} +
+
+
+
+ 聊天记录 +
+
@@ -927,6 +950,193 @@
+ +
+
+
+
{{ chatHistoryModalTitle || '聊天记录' }}
+ +
+ +
+
+ 没有可显示的聊天记录 +
+ +
+
+
+
{ 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 "