mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 03:27:00 +08:00
feat: 增强 HTML 聊天档案导出
- 增加时间轴、消息筛选、搜索和完整时间显示 - 使用窗口化懒加载优化大消息档案 - 支持同名档案增量合并并安全复用媒体资源
This commit is contained in:
@@ -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
|
||||
? `<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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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
@@ -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">
|
||||
|
||||
@@ -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'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()")
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user