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)} - 聊天记录
${safe(name)}${messages.length.toLocaleString()} 条消息
${body}
` + } + 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)} - 聊天记录 + + + +
+
+
+ ${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' && ( +

+ 可以分多次选择不同时间范围,逐步补齐同一个聊天档案。 +

+ )}