feat: 增强 HTML 聊天档案导出

- 增加时间轴、消息筛选、搜索和完整时间显示
- 使用窗口化懒加载优化大消息档案
- 支持同名档案增量合并并安全复用媒体资源
This commit is contained in:
Wxw-Gu
2026-08-04 12:29:38 +08:00
parent c70e49bf16
commit c6587c517a
5 changed files with 1000 additions and 123 deletions
+590 -38
View File
@@ -1,46 +1,598 @@
import type { Message } from '../shared/types'
export const EXPORT_PAGE_SIZE = 240
export const exportStyles = `
:root {
color-scheme: light;
--page: #edf2f0;
--panel: #fff;
--text: #1d2a25;
--muted: #68766f;
--border: #d8e2dc;
--mine: #d9f0e2;
--accent: #176b57;
--accent-soft: #e4f2ec;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
background: var(--page);
color: var(--text);
font: 14px system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
}
.page {
max-width: 1380px;
height: 100vh;
margin: auto;
padding: 20px 24px;
display: flex;
flex-direction: column;
}
.toolbar {
display: grid;
grid-template-columns: minmax(220px, 1fr) auto;
gap: 14px 24px;
align-items: center;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 18px;
padding: 16px 20px;
box-shadow: 0 8px 24px #29483b12;
}
.title { font-size: 18px; font-weight: 750; }
.meta { color: var(--muted); margin-left: 12px; font-size: 13px; }
.controls { display: flex; gap: 8px; align-items: center; justify-content: flex-end; }
.controls input, .filter-button {
border: 1px solid var(--border);
border-radius: 10px;
padding: 8px 11px;
background: #fff;
color: var(--text);
font: inherit;
}
.controls input[type=search] { width: min(320px, 34vw); }
.filters {
grid-column: 1 / -1;
display: flex;
gap: 7px;
align-items: center;
flex-wrap: wrap;
}
.filter-button { cursor: pointer; }
.filter-button.active { background: var(--accent); border-color: var(--accent); color: #fff; }
.count { margin-left: auto; color: var(--muted); font-size: 13px; }
.archive-layout {
display: grid;
grid-template-columns: 150px minmax(0, 1fr);
gap: 18px;
min-height: 0;
flex: 1;
margin-top: 16px;
}
.timeline {
overflow: auto;
background: #f7faf8;
border: 1px solid var(--border);
border-radius: 16px;
padding: 12px 9px;
}
.timeline-empty { padding: 10px; color: var(--muted); font-size: 12px; }
.timeline-year { margin: 6px 7px 5px; color: var(--text); font-size: 13px; font-weight: 700; }
.timeline-month {
width: 100%;
display: flex;
justify-content: space-between;
gap: 8px;
border: 0;
border-left: 3px solid transparent;
border-radius: 8px;
background: transparent;
color: var(--muted);
padding: 7px 8px;
cursor: pointer;
text-align: left;
}
.timeline-month:hover, .timeline-month.active {
border-left-color: var(--accent);
background: var(--accent-soft);
color: var(--accent);
}
.timeline-month small { color: inherit; }
.scroll { overflow: auto; min-width: 0; padding: 10px 8px 36px; }
.lazy-hint {
width: min(100%, 820px);
margin: 0 auto 12px;
padding: 7px 12px;
border-radius: 999px;
background: #e4ece8;
color: var(--muted);
font-size: 12px;
text-align: center;
}
.message {
display: flex;
flex-direction: column;
gap: 6px;
width: min(100%, 820px);
margin: 0 auto 22px;
}
.message.sent { align-items: flex-end; }
.message.system { align-items: center; }
.message.system .row { justify-content: center; }
.message.system .avatar { display: none; }
.message.system .bubble {
max-width: 92%;
padding: 5px 10px;
border: 0;
border-radius: 5px;
background: #e9eeeb;
color: var(--muted);
font-size: 11px;
text-align: center;
box-shadow: none;
}
.message.system .sender { display: none; }
.time { color: var(--muted); font-size: 11px; margin: 0 12px; }
.row { display: flex; gap: 12px; align-items: flex-end; max-width: 100%; }
.sent .row { flex-direction: row-reverse; }
.avatar {
width: 38px;
height: 38px;
flex: 0 0 auto;
border-radius: 50%;
overflow: hidden;
background: #dcebe4;
display: grid;
place-items: center;
}
.avatar img { width: 100%; height: 100%; object-fit: cover; }
.bubble {
max-width: min(78%, 760px);
padding: 13px 15px;
border: 1px solid var(--border);
border-radius: 10px 18px 18px 18px;
background: #fff;
box-shadow: 0 4px 12px #29483b0d;
}
.sent .bubble { background: var(--mine); border-color: #c7e6d4; border-radius: 18px 10px 18px 18px; }
.sender { color: var(--muted); font-size: 12px; margin-bottom: 5px; }
.content { line-height: 1.7; word-break: break-word; white-space: pre-wrap; }
.audio-wrap, .audio { width: 260px; }
.audio-wrap { min-width: 260px; }
.audio { display: block; height: 38px; }
.media-status {
margin-top: 8px;
padding: 6px 8px;
border-left: 3px solid #b27a18;
background: #fff8e8;
color: #79530f;
font-size: 12px;
line-height: 1.5;
}
.file-attachment {
display: flex;
align-items: center;
gap: 9px;
max-width: 320px;
margin: 2px 0 8px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 10px;
background: #f7faf8;
color: var(--accent);
font-weight: 650;
text-decoration: none;
word-break: break-all;
}
.file-attachment:hover { text-decoration: underline; }
.quote-reference {
margin-top: 10px;
padding: 8px 11px;
border-left: 3px solid #8eb4a3;
background: #f1f6f3;
color: var(--muted);
display: grid;
gap: 3px;
}
.quote-reference strong { font-weight: 650; color: var(--text); }
.quote-reference span { white-space: pre-wrap; }
.media-image {
display: block;
max-width: 100%;
max-height: 420px;
margin-bottom: 8px;
border-radius: 12px;
object-fit: contain;
background: #eef2f5;
}
.media-image[data-preview] { cursor: zoom-in; }
.empty { display: grid; place-items: center; min-height: 260px; color: var(--muted); text-align: center; }
.lightbox {
position: fixed;
inset: 0;
display: none;
place-items: center;
background: #14231ddd;
z-index: 10;
padding: 24px;
overflow: auto;
}
.lightbox.open { display: grid; }
.lightbox img {
width: min(86vw, 980px);
max-height: 88vh;
object-fit: contain;
cursor: zoom-in;
transform: scale(var(--zoom, 1));
transform-origin: center;
transition: transform .12s ease;
}
.lightbox-close {
position: fixed;
top: 20px;
right: 20px;
z-index: 11;
width: 42px;
height: 42px;
border: 1px solid #ffffff66;
border-radius: 50%;
background: #14231dcc;
color: #fff;
font-size: 30px;
line-height: 1;
cursor: pointer;
}
@media (max-width: 760px) {
.page { padding: 10px; }
.toolbar { grid-template-columns: 1fr; padding: 13px; }
.controls { justify-content: flex-start; }
.controls input[type=search] { width: 100%; }
.filters { grid-column: 1; }
.count { width: 100%; margin-left: 0; }
.archive-layout { grid-template-columns: 1fr; margin-top: 10px; }
.timeline { display: flex; gap: 6px; overflow: auto; padding: 8px; }
.timeline-year { display: none; }
.timeline-month {
flex: 0 0 auto;
width: auto;
border-left: 0;
border-bottom: 3px solid transparent;
}
.timeline-month:hover, .timeline-month.active {
border-left-color: transparent;
border-bottom-color: var(--accent);
}
.bubble { max-width: calc(100vw - 92px); }
.audio-wrap, .audio { width: min(260px, calc(100vw - 130px)); min-width: 0; }
}
`
export const exportStyles = `:root{color-scheme:light;--page:#edf2f0;--panel:#fff;--text:#1d2a25;--muted:#68766f;--border:#d8e2dc;--mine:#d9f0e2;--accent:#176b57}*{box-sizing:border-box}body{margin:0;background:var(--page);color:var(--text);font:14px system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif}.page{max-width:1240px;height:100vh;margin:auto;padding:22px 28px;display:flex;flex-direction:column}.toolbar{display:flex;align-items:center;justify-content:space-between;gap:20px;background:var(--panel);border:1px solid var(--border);border-radius:18px;padding:18px 24px;box-shadow:0 8px 24px #29483b12}.title{font-size:18px;font-weight:750}.meta{color:var(--muted);margin-left:12px;font-size:13px}.controls{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:0}.controls input,.controls button{border:1px solid var(--border);border-radius:10px;padding:9px 12px;background:#fff;font:inherit}.controls input[type=search]{width:260px}.controls input[type=datetime-local],.controls #jump{display:none}.controls button{background:var(--accent);border-color:var(--accent);color:#fff;cursor:pointer}.count{margin-left:8px;color:var(--muted);font-size:13px}.scroll{margin-top:18px;overflow:auto;flex:1;padding:10px 6px 30px;display:flex;flex-direction:column;align-items:center}.message{display:flex;flex-direction:column;gap:6px;width:min(100%,820px);margin:0 0 22px}.message.hidden{display:none}.message.sent{align-items:flex-end;margin-left:auto}.message.system{align-items:center;width:min(100%,820px)}.message.system .row{justify-content:center}.message.system .avatar{display:none}.message.system .bubble{max-width:92%;padding:5px 10px;border:0;border-radius:5px;background:#e9eeeb;color:var(--muted);font-size:11px;text-align:center;box-shadow:none}.message.system .sender{display:none}.time{color:var(--muted);font-size:11px;margin:0 12px}.row{display:flex;gap:12px;align-items:flex-end}.sent .row{flex-direction:row-reverse}.avatar{width:38px;height:38px;flex:0 0 auto;border-radius:50%;overflow:hidden;background:#dcebe4;display:grid;place-items:center}.avatar img{width:100%;height:100%;object-fit:cover}.bubble{max-width:min(78%,760px);padding:13px 15px;border:1px solid var(--border);border-radius:10px 18px 18px 18px;background:#fff;box-shadow:0 4px 12px #29483b0d}.sent .bubble{background:var(--mine);border-color:#c7e6d4;border-radius:18px 10px 18px 18px}.sender{color:var(--muted);font-size:12px;margin-bottom:5px}.content{line-height:1.7;word-break:break-word;white-space:pre-wrap}.audio-wrap{width:260px;min-width:260px}.audio{display:block;width:260px;height:38px}.media-status{margin-top:8px;padding:6px 8px;border-left:3px solid #b27a18;background:#fff8e8;color:#79530f;font-size:12px;line-height:1.5}.file-attachment{display:flex;align-items:center;gap:10px;min-width:220px;padding:11px 13px;border:1px solid var(--border);border-radius:10px;background:#f6faf8;color:var(--accent);font-weight:650;text-decoration:none;word-break:break-all}.file-attachment:before{content:'文件';display:grid;place-items:center;width:34px;height:34px;flex:0 0 auto;border-radius:8px;background:#dcebe4;color:var(--accent);font-size:11px}.quote-reference{margin-top:10px;padding:8px 11px;border-left:3px solid #8eb4a3;background:#f1f6f3;color:var(--muted);display:grid;gap:3px}.quote-reference strong{font-weight:650;color:var(--text)}.quote-reference span{white-space:pre-wrap}.media-image{display:block;max-width:100%;max-height:360px;border-radius:12px;object-fit:contain;background:#eef2f5;cursor:zoom-in}.lightbox{position:fixed;inset:0;display:none;place-items:center;background:#14231ddd;z-index:10;padding:24px;overflow:auto}.lightbox.open{display:grid}.lightbox img{width:min(86vw,980px);max-height:88vh;object-fit:contain;cursor:zoom-in;transform:scale(var(--zoom,1));transform-origin:center;transition:transform .12s ease}.lightbox-close{position:fixed;top:20px;right:20px;z-index:11;width:42px;height:42px;border:1px solid #ffffff66;border-radius:50%;background:#14231dcc;color:#fff;font-size:30px;line-height:1;cursor:pointer}`
const safe = (value: unknown): string =>
String(value ?? '').replace(
/[&<>"']/g,
(c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c] || c
(character) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[character] ||
character
)
export function renderExportPage(name: string, messages: Message[]): string {
const body = messages
.map((m) => {
const avatar = m.img
? `<img src="${safe(m.img)}" alt="">`
: safe((m.name || (m.isSender ? '我' : '友')).slice(0, 1))
const audio = m.voiceDataUrl
? `<div class="audio-wrap"><audio class="audio" controls preload="metadata" src="${safe(m.voiceDataUrl)}"></audio></div>`
: ''
const mediaStatus = m.exportMediaError
? `<div class="media-status">${safe(m.exportMediaError)}</div>`
: ''
const quote =
m.contentData?.type === 'quote'
? `<div class="quote-reference"><strong>${safe(m.contentData.quotedSender || '引用消息')}</strong><span>${safe(m.contentData.quotedContent || '[引用消息]')}</span></div>`
: ''
const media =
m.exportMediaUrl && m.exportMediaType === 'image'
? `<img class="media-image" src="${safe(m.exportMediaUrl)}" alt="图片">`
: m.exportMediaUrl && m.exportMediaType === 'video'
? `<video class="media-image" controls src="${safe(m.exportMediaUrl)}"></video>`
: m.exportMediaUrl && m.exportMediaType === 'sticker'
? `<img class="media-image" src="${safe(m.exportMediaUrl)}" alt="表情包">`
: m.exportMediaUrl && m.exportMediaType === 'file'
? `<a class="file-attachment" href="${safe(m.exportMediaUrl)}" download>${safe(m.exportMediaName || (m.contentData?.type === 'share' ? m.contentData.title : '') || '下载文件')}</a>`
: ''
const avatarMarkup =
m.exportShowAvatar === false
? ''
: `<div class="avatar">${m.exportAvatarUrl ? `<img src="${safe(m.exportAvatarUrl)}" alt="">` : avatar}</div>`
const isPat = m.contentData?.type === 'system' && m.contentData.pat
const text = m.content || (m.contentData?.type === 'quote' ? m.contentData.title : '')
return `<article class="message${m.isSender ? ' sent' : ''}${isPat ? ' system' : ''}" data-time="${m.createTime || 0}" data-search="${safe(`${m.name || ''} ${m.content || ''} ${m.type}`.toLowerCase())}"><div class="time">${safe(m.datetime)}</div><div class="row">${isPat ? '' : avatarMarkup}<div class="bubble"><div class="sender">${isPat ? '' : safe(m.name || (m.isSender ? '我' : '联系人'))}</div>${media}${audio}${quote}<div class="content">${safe(text || (!media && !audio && !quote ? `[${m.type}]` : ''))}</div>${mediaStatus}</div></div></article>`
const renderExportScript = (name: string): string => `
(() => {
'use strict'
const PAGE_SIZE = ${EXPORT_PAGE_SIZE}
const WINDOW_STEP = Math.floor(PAGE_SIZE / 2)
const archive = window.__WECHAT_EXPORT__ || { name: ${JSON.stringify(name)}, messages: [] }
const allMessages = Array.isArray(archive.messages) ? archive.messages : []
const list = document.querySelector('#messages')
const timeline = document.querySelector('#timeline')
const query = document.querySelector('#query')
const count = document.querySelector('#count')
const meta = document.querySelector('#archive-meta')
const title = document.querySelector('#archive-title')
const filters = document.querySelector('#filters')
const box = document.querySelector('#lightbox')
const preview = document.querySelector('#lightbox-image')
const closeButton = document.querySelector('#lightbox-close')
let activeKind = 'all'
let filtered = []
let windowStart = 0
let windowEnd = 0
let loading = false
let zoom = 1
const esc = (value) => String(value ?? '').replace(
/[&<>"']/g,
(character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[character] || character
)
const pad = (value) => String(value).padStart(2, '0')
const fullTime = (message) => {
const timestamp = Number(message.createTime || 0)
if (!timestamp) return String(message.datetime || '')
const date = new Date(timestamp * 1000)
if (Number.isNaN(date.getTime())) return String(message.datetime || '')
return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate()) +
' ' + pad(date.getHours()) + ':' + pad(date.getMinutes()) + ':' + pad(date.getSeconds())
}
const monthKey = (message) => {
const timestamp = Number(message.createTime || 0)
if (!timestamp) return 'unknown'
const date = new Date(timestamp * 1000)
return date.getFullYear() + '-' + pad(date.getMonth() + 1)
}
const kindOf = (message) => {
const data = message.contentData || {}
if (message.exportMediaType === 'file' || (data.type === 'share' && String(data.typeVal) === '6')) return 'file'
if (
message.exportMediaType === 'image' || message.exportMediaType === 'video' ||
message.exportMediaType === 'sticker' || data.type === 'image' ||
data.type === 'video' || data.type === 'sticker'
) return 'media'
if (message.voiceDataUrl || data.type === 'voice' || message.type === '语音') return 'voice'
if (
data.type === 'share' || data.type === 'location' ||
data.type === 'miniProgram' || data.type === 'forwardBundle'
) return 'share'
if (data.type === 'system' || data.type === 'unknown' || message.from === 'system') return 'system'
return 'text'
}
const searchText = (message) => [
message.name,
message.senderId,
message.content,
message.type,
message.contentData && message.contentData.title,
message.contentData && message.contentData.quotedSender,
message.contentData && message.contentData.quotedContent,
message.exportMediaName
].filter(Boolean).join(' ').toLowerCase()
const renderMessage = (message, archiveIndex) => {
const data = message.contentData || {}
const mediaUrl = message.exportMediaUrl ? esc(message.exportMediaUrl) : ''
const mediaType = message.exportMediaType || data.type
let media = ''
if (mediaUrl && mediaType === 'image') {
media = '<img class="media-image" data-preview src="' + mediaUrl + '" alt="图片">'
} else if (mediaUrl && mediaType === 'video') {
media = '<video class="media-image" controls preload="metadata" src="' + mediaUrl + '"></video>'
} else if (mediaUrl && mediaType === 'sticker') {
media = '<img class="media-image" data-preview src="' + mediaUrl + '" alt="表情包">'
} else if (mediaUrl && mediaType === 'file') {
const fileName = esc(message.exportMediaName || data.title || '下载文件')
media = '<a class="file-attachment" href="' + mediaUrl + '" download><span>📎</span><span>' + fileName + '</span></a>'
}
const audio = message.voiceDataUrl
? '<div class="audio-wrap"><audio class="audio" controls preload="metadata" src="' + esc(message.voiceDataUrl) + '"></audio></div>'
: ''
const mediaStatus = message.exportMediaError
? '<div class="media-status">' + esc(message.exportMediaError) + '</div>'
: ''
const quote = data.type === 'quote'
? '<div class="quote-reference"><strong>' + esc(data.quotedSender || '引用消息') + '</strong><span>' + esc(data.quotedContent || '[引用消息]') + '</span></div>'
: ''
const isSystem = data.type === 'system' && data.pat
const sender = message.name || (message.isSender ? '我' : '联系人')
const avatarFallback = esc(String(sender || '友').slice(0, 1))
const avatar = message.exportShowAvatar === false
? ''
: '<div class="avatar">' + (message.exportAvatarUrl
? '<img src="' + esc(message.exportAvatarUrl) + '" alt="">'
: avatarFallback) + '</div>'
const text = message.content || (data.type === 'quote' ? data.title : '')
const content = esc(text || (!media && !audio && !quote ? '[' + (message.type || '消息') + ']' : ''))
return '<article class="message' + (message.isSender ? ' sent' : '') + (isSystem ? ' system' : '') +
'" data-index="' + archiveIndex + '" data-month="' + esc(monthKey(message)) + '">' +
'<div class="time">' + esc(fullTime(message)) + '</div><div class="row">' +
(isSystem ? '' : avatar) + '<div class="bubble"><div class="sender">' +
(isSystem ? '' : esc(sender)) + '</div>' + media + audio + quote +
'<div class="content">' + content + '</div>' + mediaStatus + '</div></div></article>'
}
const renderTimeline = () => {
if (filtered.length === 0) {
timeline.innerHTML = '<div class="timeline-empty">没有可跳转的月份</div>'
return
}
const groups = new Map()
for (const message of filtered) {
const key = monthKey(message)
if (key === 'unknown') continue
groups.set(key, (groups.get(key) || 0) + 1)
}
let currentYear = ''
let html = ''
for (const [key, total] of groups) {
const parts = key.split('-')
if (parts[0] !== currentYear) {
currentYear = parts[0]
html += '<div class="timeline-year">' + esc(currentYear) + ' 年</div>'
}
html += '<button class="timeline-month" type="button" data-month="' + esc(key) + '">' +
'<span>' + Number(parts[1]) + ' 月</span><small>' + total + '</small></button>'
}
timeline.innerHTML = html || '<div class="timeline-empty">时间信息不可用</div>'
}
const updateActiveMonth = () => {
const first = list.querySelector('.message')
const key = first && first.dataset.month
timeline.querySelectorAll('.timeline-month').forEach((button) => {
button.classList.toggle('active', button.dataset.month === key)
})
.join('')
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${safe(name)} - 聊天记录</title><style>${exportStyles}</style></head><body><main class="page"><header class="toolbar"><div><span class="title">${safe(name)}</span><span class="meta">${messages.length.toLocaleString()} 条消息</span></div><div class="controls"><input id="query" type="search" placeholder="搜索消息..."><input id="point" type="datetime-local"><button id="jump">跳转</button><span class="count" id="count"></span></div></header><section class="scroll" id="messages">${body}</section></main><div class="lightbox" id="lightbox"><button class="lightbox-close" id="lightbox-close" type="button" aria-label="关闭图片预览">×</button><img id="lightbox-image" alt="预览"></div><script>(()=>{const all=[...document.querySelectorAll('.message')],q=document.querySelector('#query'),d=document.querySelector('#point'),c=document.querySelector('#count'),box=document.querySelector('#lightbox'),preview=document.querySelector('#lightbox-image'),closeButton=document.querySelector('#lightbox-close');let zoom=1;const updateZoom=()=>preview.style.setProperty('--zoom',zoom);const closeLightbox=()=>{box.classList.remove('open');zoom=1;updateZoom()};const update=()=>{const term=q.value.trim().toLowerCase(),at=d.value?new Date(d.value).getTime()/1000:0;let n=0;all.forEach(x=>{const ok=(!term||x.dataset.search.includes(term))&&(!at||Number(x.dataset.time)>=at);x.classList.toggle('hidden',!ok);if(ok)n++});c.textContent='共 '+n+' 条'};q.addEventListener('input',update);d.addEventListener('change',update);document.querySelector('#jump').onclick=()=>{const at=d.value?new Date(d.value).getTime()/1000:0;all.find(x=>Number(x.dataset.time)>=at)?.scrollIntoView({behavior:'smooth',block:'center'})};document.querySelectorAll('.media-image').forEach(image=>image.addEventListener('click',()=>{if(image.tagName==='IMG'){preview.src=image.src;zoom=1;updateZoom();box.classList.add('open')}}));preview.addEventListener('wheel',event=>{event.preventDefault();zoom=Math.min(5,Math.max(.5,zoom+(event.deltaY<0?.2:-.2)));updateZoom()},{passive:false});preview.addEventListener('dblclick',()=>{zoom=1;updateZoom()});box.addEventListener('click',event=>{if(event.target===box)closeLightbox()});closeButton.addEventListener('click',closeLightbox);document.addEventListener('keydown',event=>{if(event.key==='Escape')closeLightbox()});update()})()</script></body></html>`
}
const updateCount = () => {
const shown = Math.max(0, windowEnd - windowStart)
count.textContent = '已显示 ' + shown + ' / 筛选 ' + filtered.length + ' / 全部 ' + allMessages.length
}
const renderWindow = (anchorIndex, anchorOffset) => {
const visible = filtered.slice(windowStart, windowEnd)
const before = windowStart > 0 ? '<div class="lazy-hint">向上滚动加载更早消息</div>' : ''
const after = windowEnd < filtered.length ? '<div class="lazy-hint">向下滚动加载更多消息</div>' : ''
list.innerHTML = visible.length
? before + visible.map((message, index) => renderMessage(message, windowStart + index)).join('') + after
: '<div class="empty">没有符合条件的消息<br><small>可以更换筛选条件或关键词</small></div>'
if (Number.isInteger(anchorIndex)) {
const anchor = list.querySelector('.message[data-index="' + anchorIndex + '"]')
if (anchor) list.scrollTop = anchor.offsetTop - anchorOffset
}
updateCount()
updateActiveMonth()
}
const resetWindow = (preferLatest) => {
windowEnd = filtered.length
windowStart = Math.max(0, windowEnd - PAGE_SIZE)
if (!preferLatest) {
windowStart = 0
windowEnd = Math.min(filtered.length, PAGE_SIZE)
}
renderWindow()
list.scrollTop = preferLatest ? list.scrollHeight : 0
}
const applyFilters = () => {
const term = query.value.trim().toLowerCase()
filtered = allMessages.filter((message) =>
(activeKind === 'all' || kindOf(message) === activeKind) &&
(!term || searchText(message).includes(term))
)
renderTimeline()
resetWindow(true)
}
const jumpToMonth = (key) => {
const index = filtered.findIndex((message) => monthKey(message) === key)
if (index < 0) return
windowStart = Math.max(0, index - Math.floor(PAGE_SIZE / 4))
windowEnd = Math.min(filtered.length, windowStart + PAGE_SIZE)
windowStart = Math.max(0, windowEnd - PAGE_SIZE)
renderWindow()
const target = list.querySelector('.message[data-index="' + index + '"]')
list.scrollTop = target ? Math.max(0, target.offsetTop - 24) : 0
timeline.querySelectorAll('.timeline-month').forEach((button) => {
button.classList.toggle('active', button.dataset.month === key)
})
}
const slideWindow = (direction) => {
const renderedMessages = list.querySelectorAll('.message')
const anchor =
direction < 0 ? renderedMessages[0] : renderedMessages[renderedMessages.length - 1]
const anchorIndex = anchor ? Number(anchor.dataset.index) : undefined
const anchorOffset = anchor ? anchor.offsetTop - list.scrollTop : 0
if (direction < 0) {
windowStart = Math.max(0, windowStart - WINDOW_STEP)
windowEnd = Math.min(filtered.length, windowStart + PAGE_SIZE)
} else {
windowEnd = Math.min(filtered.length, windowEnd + WINDOW_STEP)
windowStart = Math.max(0, windowEnd - PAGE_SIZE)
}
renderWindow(anchorIndex, anchorOffset)
}
list.addEventListener('scroll', () => {
if (loading) return
if (list.scrollTop < 180 && windowStart > 0) {
loading = true
slideWindow(-1)
loading = false
} else if (list.scrollHeight - list.scrollTop - list.clientHeight < 240 && windowEnd < filtered.length) {
loading = true
slideWindow(1)
loading = false
}
})
query.addEventListener('input', applyFilters)
filters.addEventListener('click', (event) => {
const button = event.target.closest('[data-kind]')
if (!button) return
activeKind = button.dataset.kind
filters.querySelectorAll('[data-kind]').forEach((item) => item.classList.toggle('active', item === button))
applyFilters()
})
timeline.addEventListener('click', (event) => {
const button = event.target.closest('[data-month]')
if (button) jumpToMonth(button.dataset.month)
})
const updateZoom = () => preview.style.setProperty('--zoom', zoom)
const closeLightbox = () => {
box.classList.remove('open')
zoom = 1
updateZoom()
}
list.addEventListener('click', (event) => {
const image = event.target.closest('img[data-preview]')
if (!image) return
preview.src = image.src
zoom = 1
updateZoom()
box.classList.add('open')
})
preview.addEventListener('wheel', (event) => {
event.preventDefault()
zoom = Math.min(5, Math.max(.5, zoom + (event.deltaY < 0 ? .2 : -.2)))
updateZoom()
}, { passive: false })
preview.addEventListener('dblclick', () => {
zoom = 1
updateZoom()
})
box.addEventListener('click', (event) => {
if (event.target === box) closeLightbox()
})
closeButton.addEventListener('click', closeLightbox)
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') closeLightbox()
})
title.textContent = archive.name || ${JSON.stringify(name)}
meta.textContent = allMessages.length.toLocaleString() + ' 条消息 · 更新于 ' +
(archive.exportedAt
? new Date(archive.exportedAt).toLocaleString('zh-CN', { hour12: false })
: '未知时间')
applyFilters()
})()
`
export function renderExportPage(name: string): string {
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${safe(name)} - 聊天记录</title>
<style>${exportStyles}</style>
</head>
<body>
<main class="page">
<header class="toolbar">
<div>
<span class="title" id="archive-title">${safe(name)}</span>
<span class="meta" id="archive-meta">正在读取消息…</span>
</div>
<div class="controls">
<input id="query" type="search" placeholder="搜索发送者或消息内容…" aria-label="搜索消息">
</div>
<div class="filters" id="filters">
<button class="filter-button active" type="button" data-kind="all">全部</button>
<button class="filter-button" type="button" data-kind="text">文字</button>
<button class="filter-button" type="button" data-kind="media">图片 / 视频</button>
<button class="filter-button" type="button" data-kind="voice">语音</button>
<button class="filter-button" type="button" data-kind="file">文件</button>
<button class="filter-button" type="button" data-kind="share">分享</button>
<button class="filter-button" type="button" data-kind="system">系统 / 其他</button>
<span class="count" id="count"></span>
</div>
</header>
<section class="archive-layout">
<nav class="timeline" id="timeline" aria-label="聊天时间轴"></nav>
<section class="scroll" id="messages">
<div class="empty">正在加载聊天档案…</div>
</section>
</section>
</main>
<div class="lightbox" id="lightbox">
<button class="lightbox-close" id="lightbox-close" type="button" aria-label="关闭图片预览">×</button>
<img id="lightbox-image" alt="预览">
</div>
<script src="data/messages.js"></script>
<script>${renderExportScript(name)}</script>
</body>
</html>`
}
+177 -13
View File
@@ -1,4 +1,5 @@
import { app, BrowserWindow, shell } from 'electron'
import { createHash } from 'crypto'
import { promises as fs } from 'fs'
import { extname, join } from 'path'
import { fileURLToPath } from 'url'
@@ -29,6 +30,146 @@ const exportStamp = (): string => {
}
const imageKeys = new ImageKeyConfigService()
export interface HtmlExportArchive {
version: 1
sourceId: string
name: string
exportedAt: string
messages: Message[]
}
const archiveDataPrefix = 'window.__WECHAT_EXPORT__ = '
const hashPart = (value: string, length = 16): string =>
createHash('sha1').update(value).digest('hex').slice(0, length)
export const exportMessageKey = (message: Message, sourceId = ''): string => {
const sessionId = message.sessionId || sourceId
if (message.localId && message.createTime) {
return `${sessionId}:local:${message.localId}:${message.createTime}`
}
if (message.serverId) return `${sessionId}:server:${message.serverId}`
if (message.id && message.createTime && !/^0\.\d+$/.test(message.id)) {
return `${sessionId}:id:${message.id}:${message.createTime}`
}
return `${sessionId}:fallback:${hashPart(
JSON.stringify([
message.createTime || 0,
message.senderId || '',
message.isSender,
message.type,
message.content,
message.contentData || null
]),
24
)}`
}
const mergeArchiveMessage = (previous: Message, current: Message): Message => {
const merged = { ...previous, ...current }
const preserveWhenMissing: (keyof Message)[] = [
'voiceDataUrl',
'voiceDuration',
'exportMediaUrl',
'exportMediaType',
'exportMediaName',
'exportAvatarUrl'
]
for (const key of preserveWhenMissing) {
if (current[key] == null && previous[key] != null) {
Object.assign(merged, { [key]: previous[key] })
}
}
if (!current.exportMediaUrl && !current.voiceDataUrl && previous.exportMediaError) {
merged.exportMediaError = previous.exportMediaError
}
return merged
}
export function mergeHtmlArchiveMessages(
previous: Message[],
current: Message[],
sourceId = ''
): Message[] {
const merged = new Map<string, Message>()
for (const message of previous) merged.set(exportMessageKey(message, sourceId), message)
for (const message of current) {
const key = exportMessageKey(message, sourceId)
const existing = merged.get(key)
merged.set(key, existing ? mergeArchiveMessage(existing, message) : message)
}
return Array.from(merged.values()).sort((left, right) => {
const byTime = Number(left.createTime || 0) - Number(right.createTime || 0)
if (byTime !== 0) return byTime
return Number(left.localId || 0) - Number(right.localId || 0)
})
}
export async function readHtmlArchive(
outputDir: string,
sourceId: string,
name: string
): Promise<HtmlExportArchive> {
const dataPath = join(outputDir, 'data', 'messages.js')
let source = ''
try {
source = await fs.readFile(dataPath, 'utf8')
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return { version: 1, sourceId, name, exportedAt: new Date(0).toISOString(), messages: [] }
}
throw error
}
const assignment = source.indexOf('=')
if (assignment < 0) throw new Error('现有 HTML 档案数据格式无法识别,请更换导出名称')
const json = source
.slice(assignment + 1)
.trim()
.replace(/;\s*$/, '')
let archive: HtmlExportArchive
try {
archive = JSON.parse(json) as HtmlExportArchive
} catch {
throw new Error('现有 HTML 档案数据已损坏,请从 messages.js.bak 恢复或更换导出名称')
}
if (archive.sourceId && archive.sourceId !== sourceId) {
throw new Error('同名导出目录已属于另一个会话,请修改文件名称后重试')
}
return {
version: 1,
sourceId,
name: archive.name || name,
exportedAt: archive.exportedAt || new Date(0).toISOString(),
messages: Array.isArray(archive.messages) ? archive.messages : []
}
}
export async function writeHtmlArchive(
outputDir: string,
archive: HtmlExportArchive
): Promise<void> {
const dataDir = join(outputDir, 'data')
const dataPath = join(dataDir, 'messages.js')
const backupPath = `${dataPath}.bak`
const temporaryPath = `${dataPath}.tmp-${process.pid}-${Date.now()}`
await fs.mkdir(dataDir, { recursive: true })
try {
await fs.copyFile(dataPath, backupPath)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}
const source = `${archiveDataPrefix}${JSON.stringify(archive)};\n`
await fs.writeFile(temporaryPath, source, 'utf8')
try {
await fs.rename(temporaryPath, dataPath)
} catch (error) {
if (!['EEXIST', 'EPERM'].includes((error as NodeJS.ErrnoException).code || '')) throw error
await fs.rm(dataPath, { force: true })
await fs.rename(temporaryPath, dataPath)
} finally {
await fs.rm(temporaryPath, { force: true })
}
}
const keepMediaError = (request: ExportRequest, message: Message, error: string): void => {
if (request.keepMissing !== false) message.exportMediaError = error
}
@@ -108,7 +249,7 @@ const kindOf = (message: Message): ExportMessageKind => {
const csv = (value: unknown): string => `"${String(value ?? '').replace(/"/g, '""')}"`
function render(format: ExportRequest['format'], messages: Message[], name: string): string {
if (format === 'html') return renderExportPage(name, messages)
if (format === 'html') return renderExportPage(name)
if (format === 'json')
return JSON.stringify({ name, exportedAt: new Date().toISOString(), messages }, null, 2)
if (format === 'markdown')
@@ -138,9 +279,9 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
try {
send({ jobId: request.jobId, phase: 'reading', processed: 0, total: 100, percent: 0 })
await new Promise<void>((resolve) => setImmediate(resolve))
const messages = chat
.listMessages(request.userMd5, request.startTime, request.endTime)
.filter((m) => request.kinds.includes(kindOf(m)))
const messages = (
await chat.listMessagesAsync(request.userMd5, request.startTime, request.endTime)
).filter((message) => request.kinds.includes(kindOf(message)))
for (const message of messages) {
message.exportMediaUrl = undefined
message.exportMediaType = undefined
@@ -172,13 +313,17 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
const root = join(app.getPath('documents'), 'WechatExplorer', '导出')
await fs.mkdir(root, { recursive: true })
const ext = request.format === 'markdown' ? 'md' : request.format
const outputFolder = `${safeFilePart(request.outputName)}_${exportStamp()}`
const outputFolder =
request.format === 'html'
? safeFilePart(request.outputName)
: `${safeFilePart(request.outputName)}_${exportStamp()}`
const outputDir = join(root, outputFolder)
const outputPath =
request.format === 'html'
? join(outputDir, 'index.html')
: join(root, `${outputFolder}.${ext}`)
if (request.format === 'html') {
const previousArchive = await readHtmlArchive(outputDir, request.userMd5, request.name)
await fs.mkdir(join(outputDir, 'voices'), { recursive: true })
await fs.mkdir(join(outputDir, 'media'), { recursive: true })
await fs.mkdir(join(outputDir, 'avatars'), { recursive: true })
@@ -208,7 +353,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
? new VoiceService(chat.getChatDb()!.getWcdb4Client())
: null
if (voiceService) {
for (const [index, message] of messages.entries()) {
for (const message of messages) {
if (kindOf(message) !== 'voice') continue
if (!message.sessionId || message.localId == null || !message.createTime) {
keepMediaError(request, message, '语音标识不完整,无法定位本地语音')
@@ -231,7 +376,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
keepMediaError(request, message, reason)
continue
}
const voiceName = `voice_${index + 1}_${message.localId}.wav`
const voiceName = `voice_${hashPart(exportMessageKey(message, request.userMd5))}.wav`
const audioBuffer = Buffer.from(voice.data, 'base64')
await fs.writeFile(join(outputDir, 'voices', voiceName), audioBuffer)
message.voiceDataUrl = `voices/${voiceName}`
@@ -258,10 +403,11 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
const avatarBuffer = resolvedAvatar?.buffer || null
const avatarExtension = resolvedAvatar?.extension || 'jpg'
if (avatarBuffer) {
const avatarKey = message.senderId || `message_${index + 1}`
const avatarKey =
message.senderId || avatar || `message_${exportMessageKey(message, request.userMd5)}`
let avatarName = exportedAvatars.get(avatarKey)
if (!avatarName) {
avatarName = `avatar_${exportedAvatars.size + 1}.${avatarExtension}`
avatarName = `avatar_${hashPart(avatarKey)}.${avatarExtension}`
await fs.writeFile(join(outputDir, 'avatars', avatarName), avatarBuffer)
exportedAvatars.set(avatarKey, avatarName)
}
@@ -307,7 +453,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
}
const decoded = decryptedImage ? decodeDataUrl(decryptedImage.data) : null
if (decoded) {
const name = `image_${index + 1}.${decoded.extension}`
const name = `image_${hashPart(exportMessageKey(message, request.userMd5))}.${decoded.extension}`
await fs.writeFile(join(outputDir, 'media', name), decoded.buffer)
message.exportMediaUrl = `media/${name}`
message.exportMediaType = 'image'
@@ -344,7 +490,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
} else if (extname(source).toLowerCase() !== '.mp4') {
keepMediaError(request, message, '视频格式不支持,仅支持本地 MP4 文件')
} else {
const name = `video_${index + 1}.mp4`
const name = `video_${hashPart(exportMessageKey(message, request.userMd5))}.mp4`
await fs.copyFile(source, join(outputDir, 'media', name))
message.exportMediaUrl = `media/${name}`
message.exportMediaType = 'video'
@@ -359,7 +505,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
? await readAvatarAsset(stickerSource)
: null
if (decoded) {
const name = `sticker_${index + 1}.${decoded.extension}`
const name = `sticker_${hashPart(exportMessageKey(message, request.userMd5))}.${decoded.extension}`
await fs.writeFile(join(outputDir, 'media', name), decoded.buffer)
message.exportMediaUrl = `media/${name}`
message.exportMediaType = 'sticker'
@@ -374,7 +520,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
if (!resolved.success || !resolved.filePath || !resolved.fileName) {
keepMediaError(request, message, resolved.error || '本地文件附件缺失')
} else {
const name = `file_${index + 1}_${safeFilePart(resolved.fileName)}`
const name = `file_${hashPart(exportMessageKey(message, request.userMd5))}_${safeFilePart(resolved.fileName)}`
await fs.copyFile(resolved.filePath, join(outputDir, 'media', name))
message.exportMediaUrl = `media/${name}`
message.exportMediaType = 'file'
@@ -390,6 +536,24 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
percent: 15 + Math.round(((index + 1) / Math.max(messages.length, 1)) * 75)
})
}
const archive: HtmlExportArchive = {
version: 1,
sourceId: request.userMd5,
name: request.name,
exportedAt: new Date().toISOString(),
messages: mergeHtmlArchiveMessages(previousArchive.messages, messages, request.userMd5)
}
await fs.writeFile(outputPath, renderExportPage(request.name), 'utf8')
await writeHtmlArchive(outputDir, archive)
send({
jobId: request.jobId,
phase: 'completed',
processed: archive.messages.length,
total: archive.messages.length,
percent: 100,
outputPath
})
return { success: true, outputPath, messageCount: archive.messages.length }
} else {
send({
jobId: request.jobId,
@@ -325,26 +325,31 @@ export function ExportWorkspace({
CSV HTML
</p>
{format === 'html' && (
<div className="export-html-options">
<label>
<input
type="radio"
name="html-package-top"
checked={!zip}
onChange={() => setZip(false)}
/>{' '}
HTML
</label>
<label>
<input
type="radio"
name="html-package-top"
checked={zip}
onChange={() => setZip(true)}
/>{' '}
HTML ZIP
</label>
</div>
<>
<div className="export-html-options">
<label>
<input
type="radio"
name="html-package-top"
checked={!zip}
onChange={() => setZip(false)}
/>{' '}
HTML
</label>
<label>
<input
type="radio"
name="html-package-top"
checked={zip}
onChange={() => setZip(true)}
/>{' '}
HTML ZIP
</label>
</div>
<p className="export-helper-text">
使
</p>
</>
)}
</section>
@@ -555,6 +560,11 @@ export function ExportWorkspace({
<strong>{targetPath}</strong>
<button type="button"></button>
</div>
{format === 'html' && (
<p className="export-helper-text">
</p>
)}
</section>
</div>
<footer className="export-action-bar">
+125 -8
View File
@@ -1,5 +1,5 @@
import { dirname, join } from 'path'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Message } from '../../src/shared/types'
@@ -19,6 +19,7 @@ vi.mock('electron', () => ({
}))
vi.mock('../../src/main/services/chat-service', () => ({
listMessages: () => structuredClone(state.messages),
listMessagesAsync: async () => structuredClone(state.messages),
getChatDb: () => ({
getWcdb4Client: () => ({ getAccountRoot: () => state.accountRoot })
}),
@@ -92,6 +93,19 @@ const message = (overrides: Partial<Message>): Message => ({
...overrides
})
const readArchive = (outputPath: string): { sourceId: string; messages: Message[] } => {
const source = readFileSync(join(dirname(outputPath), 'data', 'messages.js'), 'utf8')
return JSON.parse(
source
.slice(source.indexOf('=') + 1)
.trim()
.replace(/;\s*$/, '')
) as {
sourceId: string
messages: Message[]
}
}
describe('media export flow', () => {
beforeEach(() => {
state.documents = mkdtempSync(join(tmpdir(), 'wxe-export-fixture-'))
@@ -167,21 +181,124 @@ describe('media export flow', () => {
expect(result.success).toBe(true)
const html = readFileSync(result.outputPath!, 'utf8')
const outputDir = dirname(result.outputPath!)
expect(readFileSync(join(outputDir, 'voices/voice_1_1.wav')).subarray(0, 4).toString()).toBe(
const archive = readArchive(result.outputPath!)
const voice = archive.messages.find((item) => item.id === 'voice-ok')!
const video = archive.messages.find((item) => item.id === 'video')!
const file = archive.messages.find((item) => item.id === 'file')!
const missingVoice = archive.messages.find((item) => item.id === 'voice-missing')!
expect(readFileSync(join(outputDir, voice.voiceDataUrl!)).subarray(0, 4).toString()).toBe(
'RIFF'
)
expect(readFileSync(join(outputDir, 'media/video_4.mp4')).subarray(4, 8).toString()).toBe(
expect(readFileSync(join(outputDir, video.exportMediaUrl!)).subarray(4, 8).toString()).toBe(
'ftyp'
)
expect(readFileSync(join(outputDir, 'media/file_5_测试附件.txt'), 'utf8')).toBe('附件内容')
expect(html).toContain('src="voices/voice_1_1.wav"')
expect(html).toContain('src="media/video_4.mp4"')
expect(html).toContain('href="media/file_5_测试附件.txt" download')
expect(html).toContain('语音文件缺失:本地未找到语音数据')
expect(readFileSync(join(outputDir, file.exportMediaUrl!), 'utf8')).toBe('附件内容')
expect(html).toContain('<script src="data/messages.js"></script>')
expect(voice.voiceDataUrl).toMatch(/^voices\/voice_[0-9a-f]{16}\.wav$/)
expect(video.exportMediaUrl).toMatch(/^media\/video_[0-9a-f]{16}\.mp4$/)
expect(file.exportMediaUrl).toMatch(/^media\/file_[0-9a-f]{16}_测试附件\.txt$/)
expect(missingVoice.exportMediaError).toBe('语音文件缺失:本地未找到语音数据')
expect(state.imageLookups[0]).toMatchObject({
allowThumbnail: false,
preferThumbnail: false
})
expect(progress.length).toBeGreaterThan(0)
})
it('incrementally merges the same HTML archive, deduplicates messages, and keeps old media', async () => {
const { runExport } = await import('../../src/main/export-service')
const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
const request = {
jobId: 'incremental-first',
userMd5: 'fixture-user',
name: '增量会话',
format: 'html' as const,
outputName: 'incremental-fixture',
kinds: ['voice', 'text'] as const,
includeMedia: true,
keepMissing: true
}
state.messages = [
message({
id: 'voice-old',
type: '语音',
sessionId: 'fixture-session',
localId: 1,
contentData: { type: 'voice', duration: 1 }
}),
message({ id: 'text-old', content: '第一次导出', createTime: 1_785_549_660 })
]
const first = await runExport({ ...request, kinds: [...request.kinds] }, win as never)
expect(first.success).toBe(true)
const firstArchive = readArchive(first.outputPath!)
const oldVoiceUrl = firstArchive.messages.find((item) => item.id === 'voice-old')!.voiceDataUrl
state.messages = [
message({ id: 'text-old', content: '同一条消息已更新', createTime: 1_785_549_660 }),
message({ id: 'text-new', content: '第二次新增', createTime: 1_785_549_720 })
]
const second = await runExport(
{
...request,
jobId: 'incremental-second',
kinds: [...request.kinds],
includeMedia: false
},
win as never
)
expect(second.success).toBe(true)
expect(second.outputPath).toBe(first.outputPath)
const secondArchive = readArchive(second.outputPath!)
expect(secondArchive.messages.map((item) => item.id)).toEqual([
'voice-old',
'text-old',
'text-new'
])
expect(secondArchive.messages.find((item) => item.id === 'text-old')?.content).toBe(
'同一条消息已更新'
)
expect(secondArchive.messages.find((item) => item.id === 'voice-old')?.voiceDataUrl).toBe(
oldVoiceUrl
)
expect(existsSync(join(dirname(second.outputPath!), 'data', 'messages.js.bak'))).toBe(true)
})
it('refuses to merge a different conversation into an existing named archive', async () => {
const { runExport } = await import('../../src/main/export-service')
const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
state.messages = [message({ id: 'text', content: 'fixture' })]
const baseRequest = {
jobId: 'source-first',
userMd5: 'first-user',
name: '第一个会话',
format: 'html' as const,
outputName: 'same-name',
kinds: ['text'] as const,
includeMedia: false
}
const first = await runExport({ ...baseRequest, kinds: [...baseRequest.kinds] }, win as never)
const second = await runExport(
{
...baseRequest,
jobId: 'source-second',
userMd5: 'second-user',
name: '第二个会话',
kinds: [...baseRequest.kinds]
},
win as never
)
expect(first.success).toBe(true)
expect(second.success).toBe(false)
expect(second.error).toContain('另一个会话')
expect(readArchive(first.outputPath!).sourceId).toBe('first-user')
})
it('uses message content as the stable fallback when the database supplies a random id', async () => {
const { exportMessageKey } = await import('../../src/main/export-service')
const first = message({ id: '0.123456', content: '同一条无本地 ID 消息' })
const second = message({ id: '0.987654', content: '同一条无本地 ID 消息' })
expect(exportMessageKey(first, 'fixture-user')).toBe(exportMessageKey(second, 'fixture-user'))
})
})
+78 -44
View File
@@ -1,17 +1,13 @@
import { describe, expect, it } from 'vitest'
import { renderExportPage } from '../../src/main/export-html-template'
import { JSDOM } from 'jsdom'
import { EXPORT_PAGE_SIZE, renderExportPage } from '../../src/main/export-html-template'
import { getImageExportAttempts } from '../../src/shared/export-media'
import type { Message } from '../../src/shared/types'
const baseMessage = (overrides: Partial<Message>): Message => ({
id: 'fixture-message',
from: 'fixture',
type: '文本',
datetime: '2026-08-01 10:00:00',
content: '',
isSender: false,
...overrides
})
const inlineScriptOf = (html: string): string =>
Array.from(html.matchAll(/<script(?:\s[^>]*)?>([\s\S]*?)<\/script>/g))
.map((match) => match[1].trim())
.find(Boolean) || ''
describe('export media', () => {
it('always attempts the original before an explicitly enabled thumbnail fallback', () => {
@@ -25,47 +21,85 @@ describe('export media', () => {
expect(repeated).toEqual(first)
})
it('renders movable relative audio and video assets plus accurate missing-media details', () => {
const html = renderExportPage('脱敏导出', [
baseMessage({ id: 'voice', type: '语音', voiceDataUrl: 'voices/voice_1.wav' }),
baseMessage({
id: 'video',
type: '视频',
exportMediaType: 'video',
exportMediaUrl: 'media/video_2.mp4'
}),
baseMessage({
id: 'missing',
type: '语音',
exportMediaError: '语音文件缺失:本地未找到语音数据'
}),
baseMessage({
id: 'file',
type: '文件',
contentData: { type: 'share', typeVal: '6', title: '示例附件.zip', url: '' },
exportMediaType: 'file',
exportMediaName: '示例附件.zip',
exportMediaUrl: 'media/file_4_示例附件.zip'
})
])
it('loads archive data and provides timeline, filters, search, and bounded lazy rendering', () => {
const html = renderExportPage('脱敏导出')
expect(html).toContain(
'audio class="audio" controls preload="metadata" src="voices/voice_1.wav"'
expect(EXPORT_PAGE_SIZE).toBe(240)
expect(html).toContain('<script src="data/messages.js"></script>')
expect(html).toContain('aria-label="聊天时间轴"')
expect(html).toContain('data-kind="media"')
expect(html).toContain('placeholder="搜索发送者或消息内容…"')
expect(html).toContain('filtered.slice(windowStart, windowEnd)')
expect(html).toContain('windowStart = Math.max(0, windowEnd - PAGE_SIZE)')
expect(html).toContain('date.getSeconds()')
const inlineScript = inlineScriptOf(html)
expect(inlineScript).toBeTruthy()
expect(() => new Function(inlineScript)).not.toThrow()
})
it('initially renders only one page and searches the full archive dataset', () => {
const html = renderExportPage('大量消息')
const dom = new JSDOM(html, { runScripts: 'outside-only' })
const messages = Array.from(
{ length: 500 },
(_, index): Message => ({
id: `message-${index}`,
from: 'user',
type: '普通文本',
datetime: '',
content: index % 100 === 0 ? `needle-${index}` : `普通消息-${index}`,
isSender: false,
createTime: 1_767_225_600 + index * 86_400
})
)
expect(html).toContain('video class="media-image" controls src="media/video_2.mp4"')
expect(html).toContain('语音文件缺失:本地未找到语音数据')
expect(html).toContain('class="file-attachment" href="media/file_4_示例附件.zip" download')
Object.assign(dom.window, {
__WECHAT_EXPORT__: {
version: 1,
sourceId: 'fixture',
name: '大量消息',
exportedAt: '2026-08-04T00:00:00.000Z',
messages
}
})
dom.window.eval(inlineScriptOf(html))
expect(dom.window.document.querySelectorAll('.message')).toHaveLength(EXPORT_PAGE_SIZE)
expect(dom.window.document.querySelector('#count')?.textContent).toBe(
'已显示 240 / 筛选 500 / 全部 500'
)
const list = dom.window.document.querySelector('#messages')!
for (let index = 0; index < 5; index += 1) {
list.dispatchEvent(new dom.window.Event('scroll'))
expect(dom.window.document.querySelectorAll('.message').length).toBeLessThanOrEqual(
EXPORT_PAGE_SIZE
)
}
const search = dom.window.document.querySelector('#query') as HTMLInputElement
search.value = 'needle'
search.dispatchEvent(new dom.window.Event('input'))
expect(dom.window.document.querySelectorAll('.message')).toHaveLength(5)
expect(dom.window.document.querySelectorAll('.timeline-month').length).toBeGreaterThan(1)
dom.window.close()
})
it('keeps relative media, file download, quote, and missing-media renderers', () => {
const html = renderExportPage('媒体档案')
expect(html).toContain('audio class="audio" controls preload="metadata"')
expect(html).toContain('video class="media-image" controls preload="metadata"')
expect(html).toContain('class="file-attachment" href="')
expect(html).toContain('class="quote-reference"')
expect(html).toContain('message.exportMediaError')
expect(html).not.toMatch(/(?:src|href)="[A-Za-z]:\\/)
})
it('renders explicit and keyboard-accessible lightbox closing controls', () => {
const html = renderExportPage('图片预览', [
baseMessage({ id: 'image', type: '图片', exportMediaUrl: 'media/image.jpg' })
])
const html = renderExportPage('图片预览')
expect(html).toContain('aria-label="关闭图片预览"')
expect(html).toContain("closeButton.addEventListener('click',closeLightbox)")
expect(html).toContain('if(event.target===box)closeLightbox()')
expect(html).toContain("if(event.key==='Escape')closeLightbox()")
expect(html).toContain("closeButton.addEventListener('click', closeLightbox)")
expect(html).toContain('if (event.target === box) closeLightbox()')
expect(html).toContain("if (event.key === 'Escape') closeLightbox()")
})
})