diff --git a/src/main/export-html-template.ts b/src/main/export-html-template.ts
index 2d91937..1bdfc83 100644
--- a/src/main/export-html-template.ts
+++ b/src/main/export-html-template.ts
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] || c
+ (character) =>
+ ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character] ||
+ character
)
-export function renderExportPage(name: string, messages: Message[]): string {
- const body = messages
- .map((m) => {
- const avatar = m.img
- ? `
`
- : safe((m.name || (m.isSender ? '我' : '友')).slice(0, 1))
- const audio = m.voiceDataUrl
- ? `
`
- : ''
- const mediaStatus = m.exportMediaError
- ? `${safe(m.exportMediaError)}
`
- : ''
- const quote =
- m.contentData?.type === 'quote'
- ? `${safe(m.contentData.quotedSender || '引用消息')}${safe(m.contentData.quotedContent || '[引用消息]')}
`
- : ''
- const media =
- m.exportMediaUrl && m.exportMediaType === 'image'
- ? `
`
- : m.exportMediaUrl && m.exportMediaType === 'video'
- ? ``
- : m.exportMediaUrl && m.exportMediaType === 'sticker'
- ? `
`
- : m.exportMediaUrl && m.exportMediaType === 'file'
- ? `${safe(m.exportMediaName || (m.contentData?.type === 'share' ? m.contentData.title : '') || '下载文件')}`
- : ''
- const avatarMarkup =
- m.exportShowAvatar === false
- ? ''
- : `${m.exportAvatarUrl ? `
})
` : avatar}
`
- const isPat = m.contentData?.type === 'system' && m.contentData.pat
- const text = m.content || (m.contentData?.type === 'quote' ? m.contentData.title : '')
- return `${safe(m.datetime)}
${isPat ? '' : avatarMarkup}
${isPat ? '' : safe(m.name || (m.isSender ? '我' : '联系人'))}
${media}${audio}${quote}
${safe(text || (!media && !audio && !quote ? `[${m.type}]` : ''))}
${mediaStatus}
`
+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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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 = '
'
+ } else if (mediaUrl && mediaType === 'video') {
+ media = ''
+ } else if (mediaUrl && mediaType === 'sticker') {
+ media = '
'
+ } else if (mediaUrl && mediaType === 'file') {
+ const fileName = esc(message.exportMediaName || data.title || '下载文件')
+ media = '📎' + fileName + ''
+ }
+ const audio = message.voiceDataUrl
+ ? ''
+ : ''
+ const mediaStatus = message.exportMediaError
+ ? '' + esc(message.exportMediaError) + '
'
+ : ''
+ const quote = data.type === 'quote'
+ ? '' + esc(data.quotedSender || '引用消息') + '' + esc(data.quotedContent || '[引用消息]') + '
'
+ : ''
+ 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
+ ? ''
+ : '' + (message.exportAvatarUrl
+ ? '
 + ')
'
+ : avatarFallback) + '
'
+ const text = message.content || (data.type === 'quote' ? data.title : '')
+ const content = esc(text || (!media && !audio && !quote ? '[' + (message.type || '消息') + ']' : ''))
+ return '' +
+ '' + esc(fullTime(message)) + '
' +
+ (isSystem ? '' : avatar) + '
' +
+ (isSystem ? '' : esc(sender)) + '
' + media + audio + quote +
+ '
' + content + '
' + mediaStatus + '
'
+ }
+
+ const renderTimeline = () => {
+ if (filtered.length === 0) {
+ timeline.innerHTML = '没有可跳转的月份
'
+ 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 += '' + esc(currentYear) + ' 年
'
+ }
+ html += ''
+ }
+ timeline.innerHTML = html || '时间信息不可用
'
+ }
+
+ 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 `${safe(name)} - 聊天记录![预览]()
`
+ }
+ 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 ? '向上滚动加载更早消息
' : ''
+ const after = windowEnd < filtered.length ? '向下滚动加载更多消息
' : ''
+ list.innerHTML = visible.length
+ ? before + visible.map((message, index) => renderMessage(message, windowStart + index)).join('') + after
+ : '没有符合条件的消息
可以更换筛选条件或关键词
'
+ 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 `
+
+
+
+
+ ${safe(name)} - 聊天记录
+
+
+
+
+
+
+
+
+
+
![预览]()
+
+
+
+
+`
}
diff --git a/src/main/export-service.ts b/src/main/export-service.ts
index dbe67eb..cd9fa9f 100644
--- a/src/main/export-service.ts
+++ b/src/main/export-service.ts
@@ -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()
+ 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 {
+ 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 {
+ 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((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,
diff --git a/src/renderer/src/components/export/ExportWorkspace.tsx b/src/renderer/src/components/export/ExportWorkspace.tsx
index 6e4a64d..9486647 100644
--- a/src/renderer/src/components/export/ExportWorkspace.tsx
+++ b/src/renderer/src/components/export/ExportWorkspace.tsx
@@ -325,26 +325,31 @@ export function ExportWorkspace({
CSV 默认最快;HTML 会包含图片、引用和其他媒体,导出时间可能较长。
{format === 'html' && (
-
-
-
-
+ <>
+
+
+
+
+
+ 使用相同名称再次导出时,会把新消息合并进已有档案,不会删除之前导出的消息。
+
+ >
)}
@@ -555,6 +560,11 @@ export function ExportWorkspace({
{targetPath}
+ {format === 'html' && (
+
+ 可以分多次选择不同时间范围,逐步补齐同一个聊天档案。
+
+ )}