feat: 优化聊天档案浏览与增量导出

1. 增加档案加载状态、错误提示和延迟数据加载。
2. 优化移动端工具栏、消息布局及横向滚动控制。
3. 支持按年份折叠时间轴,并同步可见月份定位。
4. 使用自定义文件名作为档案标题。
5. 增量导出时保留历史头像,仅在视觉变化后新增头像版本。
6. 修复附件消息被误判为合并转发的问题。
7. 补充单元、集成及端到端测试覆盖。
This commit is contained in:
majun.jason
2026-08-05 15:00:31 +08:00
parent 4e84b52cc4
commit 933a87ebbb
7 changed files with 842 additions and 84 deletions
+283 -40
View File
@@ -20,8 +20,50 @@ body {
color: var(--text);
font: 14px system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
}
.archive-loading {
position: fixed;
inset: 0;
z-index: 20;
display: grid;
place-content: center;
justify-items: center;
gap: 10px;
padding: 24px;
background: var(--page);
color: var(--text);
text-align: center;
transition: opacity .2s ease, visibility .2s ease;
}
.archive-loading.complete {
visibility: hidden;
opacity: 0;
pointer-events: none;
}
.archive-loading-indicator {
width: 34px;
height: 34px;
margin-bottom: 4px;
border: 3px solid #c9d9d1;
border-top-color: var(--accent);
border-radius: 50%;
animation: archive-loading-spin .8s linear infinite;
}
.archive-loading strong { font-size: 16px; }
.archive-loading span { color: var(--muted); font-size: 13px; }
.archive-loading.error .archive-loading-indicator {
display: grid;
border: 0;
animation: none;
place-items: center;
color: #a33b32;
font-size: 28px;
}
.archive-loading.error .archive-loading-indicator::before { content: '!'; }
@keyframes archive-loading-spin { to { transform: rotate(360deg); } }
.page {
width: 100%;
max-width: 1380px;
min-width: 0;
height: 100vh;
margin: auto;
padding: 20px 24px;
@@ -30,6 +72,8 @@ body {
}
.toolbar {
display: grid;
width: 100%;
min-width: 0;
grid-template-columns: minmax(220px, 1fr) auto;
gap: 14px 24px;
align-items: center;
@@ -71,6 +115,9 @@ body {
.count { margin-left: auto; color: var(--muted); font-size: 13px; }
.archive-layout {
display: grid;
width: 100%;
max-width: 100%;
min-width: 0;
grid-template-columns: 168px minmax(0, 1fr);
gap: 18px;
min-height: 0;
@@ -80,6 +127,8 @@ body {
.archive-layout.single-conversation { grid-template-columns: 168px minmax(0, 1fr); }
.archive-navigation {
display: flex;
width: 100%;
max-width: 100%;
flex-direction: column;
gap: 10px;
min-width: 0;
@@ -138,7 +187,36 @@ body {
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-year-group + .timeline-year-group { margin-top: 4px; }
.timeline-year {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
margin: 0;
padding: 7px 8px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--text);
font: inherit;
font-size: 13px;
font-weight: 700;
cursor: pointer;
text-align: left;
}
.timeline-year::after {
content: '⌄';
color: var(--muted);
font-size: 16px;
font-weight: 400;
line-height: 1;
transform: rotate(-90deg);
transition: transform .15s ease;
}
.timeline-year:hover { background: #eaf2ee; }
.timeline-year[aria-expanded=true]::after { transform: rotate(0); }
.timeline-months[hidden] { display: none; }
.timeline-month {
width: 100%;
display: flex;
@@ -159,7 +237,7 @@ body {
color: var(--accent);
}
.timeline-month small { color: inherit; }
.scroll { overflow: auto; min-width: 0; padding: 10px 8px 36px; }
.scroll { width: 100%; max-width: 100%; overflow: auto; min-width: 0; padding: 10px 8px 36px; }
.lazy-hint {
width: min(100%, 820px);
margin: 0 auto 12px;
@@ -175,6 +253,7 @@ body {
flex-direction: column;
gap: 6px;
width: min(100%, 820px);
min-width: 0;
margin: 0 auto 22px;
}
.locate-all {
@@ -234,6 +313,7 @@ body {
}
@media (prefers-reduced-motion: reduce) {
.message.located .bubble { animation: none; outline: 3px solid #36a477; outline-offset: 3px; }
.archive-loading-indicator { animation: none; }
}
.message.sent { align-items: flex-end; }
.message.system { align-items: center; }
@@ -271,7 +351,7 @@ body {
background: var(--accent-soft);
color: var(--accent);
}
.row { display: flex; gap: 12px; align-items: flex-end; max-width: 100%; }
.row { display: flex; width: 100%; min-width: 0; gap: 12px; align-items: flex-end; max-width: 100%; }
.sent .row { flex-direction: row-reverse; }
.avatar {
width: 38px;
@@ -470,12 +550,75 @@ body {
cursor: pointer;
}
@media (max-width: 760px) {
html, body { width: 100%; max-width: 100%; overflow-x: hidden; overscroll-behavior-x: none; }
.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; }
.toolbar {
grid-template-columns: minmax(112px, .7fr) minmax(0, 1.3fr);
gap: 8px;
padding: 10px;
border-radius: 14px;
}
.archive-heading {
grid-column: 1;
min-height: 36px;
gap: 4px;
align-items: center;
flex-wrap: nowrap;
}
.archive-heading .title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.archive-heading .meta { display: none; }
.conversation-filter:not([hidden]) {
display: inline-flex;
width: 100%;
min-width: 0;
max-width: 100%;
flex: 1 1 auto;
height: 36px;
}
.conversation-filter::after {
right: 10px;
width: 6px;
height: 6px;
}
.conversation-select {
width: 100%;
min-width: 0;
height: 36px;
padding: 0 24px 0 8px;
border-radius: 9px;
font-size: 12px;
font-weight: 650;
box-shadow: none;
}
.controls { grid-column: 2; min-width: 0; justify-content: flex-start; }
.controls input[type=search] { width: 100%; min-width: 0; height: 36px; padding: 0 10px; }
.filters {
grid-column: 1 / -1;
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.filter-button {
min-height: 30px;
border-radius: 8px;
padding: 4px 6px;
font-size: 12px;
line-height: 1.2;
}
.count {
display: block;
width: 100%;
flex: 0 0 100%;
margin-left: 0;
color: var(--muted);
font-size: 11px;
line-height: 1.4;
}
.archive-layout { grid-template-columns: 1fr; align-content: start; margin-top: 10px; }
.archive-layout.single-conversation { grid-template-columns: 1fr; }
.archive-navigation { align-self: start; gap: 8px; }
@@ -488,15 +631,22 @@ body {
overflow: auto;
padding: 8px;
}
.archive-heading { align-items: flex-start; }
.conversation-filter { flex-basis: 100%; width: 100%; }
.timeline-year-group {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 6px;
}
.timeline-year-group + .timeline-year-group { margin-top: 0; }
.timeline-year {
flex: 0 0 auto;
align-self: center;
margin: 0 2px 0 0;
padding: 7px 5px;
width: auto;
gap: 6px;
padding: 7px 8px;
white-space: nowrap;
}
.timeline-months { display: flex; gap: 6px; }
.timeline-months[hidden] { display: none; }
.timeline-month {
flex: 0 0 auto;
width: auto;
@@ -507,8 +657,14 @@ body {
border-left-color: transparent;
border-bottom-color: var(--accent);
}
.bubble { max-width: calc(100vw - 92px); }
.audio-wrap { width: min(260px, calc(100vw - 130px)); }
.scroll {
overflow-x: hidden;
overscroll-behavior-x: none;
touch-action: pan-y;
}
.message:not(.system) .bubble { max-width: calc(100% - 50px); }
.message:not(.system) .row.has-locate .bubble { max-width: calc(100% - 90px); }
.audio-wrap { width: min(260px, 100%); }
}
`
@@ -523,6 +679,25 @@ const safe = (value: unknown): string =>
const renderExportScript = (name: string): string => `
(() => {
'use strict'
const loadingOverlay = document.querySelector('#archive-loading')
const loadingTitle = document.querySelector('#archive-loading-title')
const loadingDetail = document.querySelector('#archive-loading-detail')
const finishLoading = () => {
if (!loadingOverlay) return
loadingOverlay.setAttribute('aria-busy', 'false')
loadingOverlay.classList.add('complete')
window.setTimeout(() => loadingOverlay.remove(), 250)
}
const failLoading = (message) => {
if (!loadingOverlay) return
loadingOverlay.setAttribute('aria-busy', 'false')
loadingOverlay.setAttribute('role', 'alert')
loadingOverlay.classList.add('error')
if (loadingTitle) loadingTitle.textContent = '无法加载聊天档案'
if (loadingDetail) loadingDetail.textContent = message
}
const initializeArchive = () => {
try {
const PAGE_SIZE = ${EXPORT_PAGE_SIZE}
const WINDOW_STEP = Math.floor(PAGE_SIZE / 2)
const archive = window.__WECHAT_EXPORT__ || { name: ${JSON.stringify(name)}, messages: [] }
@@ -561,6 +736,7 @@ const renderExportScript = (name: string): string => `
let scrollLoadPending = false
let scrollLoadSuppressed = false
let activeMonthUpdatePending = false
let expandedTimelineYear = ''
const tabPositions = new Map()
let lastScrollTop = 0
let zoom = 1
@@ -856,7 +1032,8 @@ const renderExportScript = (name: string): string => `
'<span class="locate-label" aria-hidden="true">定位到聊天位置</span></button>'
return '<article class="message' + (message.isSender ? ' sent' : '') + (isSystem ? ' system' : '') +
'" data-index="' + archiveIndex + '" data-month="' + esc(monthKey(message)) + '">' +
'<div class="time">' + esc(fullTime(message)) + source + '</div><div class="row">' +
'<div class="time">' + esc(fullTime(message)) + source + '</div><div class="row' +
(locateAction ? ' has-locate' : '') + '">' +
(isSystem ? '' : avatar) + '<div class="bubble"><div class="sender">' +
(isSystem ? '' : esc(sender)) + '</div>' + media + audio + quote + structured +
contentMarkup + mediaStatus + '</div>' + locateAction + '</div></article>'
@@ -869,26 +1046,26 @@ const renderExportScript = (name: string): string => `
layout.classList.add('single-conversation')
return
}
const counts = new Map()
for (const message of allMessages) {
const id = message.exportConversationId || ''
counts.set(id, (counts.get(id) || 0) + 1)
}
const option = (id, label, total) =>
'<option value="' + esc(id) + '">' + esc(label) + '' + total + '</option>'
const option = (id, label) => '<option value="' + esc(id) + '">' + esc(label) + '</option>'
title.hidden = true
conversationFilter.hidden = false
conversationSelect.innerHTML = option('all', '全部聊天', allMessages.length) +
conversations.map((conversation) => option(
conversation.id,
conversation.name,
counts.get(conversation.id) || 0
)).join('')
conversationSelect.innerHTML = option('all', '全部聊天') +
conversations.map((conversation) => option(conversation.id, conversation.name)).join('')
conversationSelect.value = activeConversation
}
const setExpandedTimelineYear = (year) => {
expandedTimelineYear = year || ''
timeline.querySelectorAll('.timeline-year').forEach((button) => {
const expanded = button.dataset.year === expandedTimelineYear
button.setAttribute('aria-expanded', String(expanded))
const months = button.nextElementSibling
if (months) months.hidden = !expanded
})
}
const renderTimeline = () => {
if (filtered.length === 0) {
expandedTimelineYear = ''
timeline.innerHTML = '<div class="timeline-empty">没有可跳转的月份</div>'
return
}
@@ -898,16 +1075,29 @@ const renderExportScript = (name: string): string => `
if (key === 'unknown') continue
groups.set(key, (groups.get(key) || 0) + 1)
}
let currentYear = ''
let html = ''
const yearGroups = new Map()
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>'
if (!yearGroups.has(parts[0])) yearGroups.set(parts[0], [])
yearGroups.get(parts[0]).push({ key, month: Number(parts[1]), total })
}
const years = Array.from(yearGroups.keys())
if (!years.includes(expandedTimelineYear)) {
expandedTimelineYear = years[years.length - 1] || ''
}
let html = ''
for (const year of years) {
const expanded = year === expandedTimelineYear
const monthsId = 'timeline-months-' + year
html += '<section class="timeline-year-group" data-year-group="' + esc(year) + '">' +
'<button class="timeline-year" type="button" data-year="' + esc(year) +
'" aria-expanded="' + String(expanded) + '" aria-controls="' + esc(monthsId) + '">' +
esc(year) + ' 年</button>' +
'<div class="timeline-months" id="' + esc(monthsId) + '"' + (expanded ? '' : ' hidden') + '>' +
yearGroups.get(year).map((entry) =>
'<button class="timeline-month" type="button" data-month="' + esc(entry.key) + '">' +
'<span>' + entry.month + ' 月</span><small>' + entry.total + '</small></button>'
).join('') + '</div></section>'
}
timeline.innerHTML = html || '<div class="timeline-empty">时间信息不可用</div>'
}
@@ -923,10 +1113,16 @@ const renderExportScript = (name: string): string => `
const updateActiveMonth = () => {
const messages = Array.from(list.querySelectorAll('.message'))
const visible = visibleMessages()
const listBounds = list.getBoundingClientRect()
const topAnchor = listBounds.top + Math.min(24, listBounds.height / 4)
const atBottom = list.scrollHeight - list.scrollTop - list.clientHeight <= 2
const anchoredMessage = messages.find((message) => {
const bounds = message.getBoundingClientRect()
return bounds.bottom > topAnchor && bounds.top < listBounds.bottom
})
const activeMessage = atBottom
? visible[visible.length - 1] || messages[messages.length - 1]
: visible[0] || messages[0]
: anchoredMessage || visible[0] || messages[0]
const key = activeMessage && activeMessage.dataset.month
let activeButton
timeline.querySelectorAll('.timeline-month').forEach((button) => {
@@ -935,6 +1131,8 @@ const renderExportScript = (name: string): string => `
if (active) activeButton = button
})
if (!activeButton) return
const activeYear = key.split('-')[0]
if (activeYear !== expandedTimelineYear) setExpandedTimelineYear(activeYear)
const timelineBounds = timeline.getBoundingClientRect()
const buttonBounds = activeButton.getBoundingClientRect()
if (buttonBounds.top < timelineBounds.top) {
@@ -1092,6 +1290,7 @@ const renderExportScript = (name: string): string => `
renderWindow()
const target = list.querySelector('.message[data-index="' + index + '"]')
setScrollTop(target ? Math.max(0, scrollTopForTarget(target, 24)) : 0)
setExpandedTimelineYear(key.split('-')[0])
timeline.querySelectorAll('.timeline-month').forEach((button) => {
button.classList.toggle('active', button.dataset.month === key)
})
@@ -1154,6 +1353,13 @@ const renderExportScript = (name: string): string => `
applyFilters(true)
})
timeline.addEventListener('click', (event) => {
const yearButton = event.target.closest('[data-year]')
if (yearButton) {
setExpandedTimelineYear(
yearButton.dataset.year === expandedTimelineYear ? '' : yearButton.dataset.year
)
return
}
const button = event.target.closest('[data-month]')
if (button) jumpToMonth(button.dataset.month)
})
@@ -1202,6 +1408,39 @@ const renderExportScript = (name: string): string => `
'更新于 ' + updatedAt
renderConversations()
applyFilters()
finishLoading()
} catch (error) {
console.error('初始化聊天档案失败', error)
failLoading('消息数据解析失败,请确认档案文件完整后重试')
}
}
if (window.__WECHAT_EXPORT__) {
initializeArchive()
return
}
const loadArchiveData = () => {
const dataScript = document.createElement('script')
dataScript.src = 'data/messages.js'
dataScript.onload = () => {
if (!window.__WECHAT_EXPORT__) {
failLoading('消息数据为空或格式不正确,请重新导出聊天档案')
return
}
initializeArchive()
}
dataScript.onerror = () => {
failLoading('未找到消息数据,请确认 data/messages.js 与当前页面在同一档案中')
}
document.body.appendChild(dataScript)
}
if (typeof window.requestAnimationFrame === 'function') {
window.requestAnimationFrame(() => window.setTimeout(loadArchiveData, 0))
} else {
window.setTimeout(loadArchiveData, 0)
}
})()
`
@@ -1215,6 +1454,11 @@ export function renderExportPage(name: string): string {
<style>${exportStyles}</style>
</head>
<body>
<div class="archive-loading" id="archive-loading" role="status" aria-live="polite" aria-busy="true">
<div class="archive-loading-indicator" aria-hidden="true"></div>
<strong id="archive-loading-title">正在加载聊天档案</strong>
<span id="archive-loading-detail">消息较多时可能需要一些时间</span>
</div>
<main class="page">
<header class="toolbar">
<div class="archive-heading">
@@ -1234,7 +1478,7 @@ export function renderExportPage(name: string): string {
<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>
<button class="filter-button" type="button" data-kind="system">系统</button>
<span class="count" id="count"></span>
</div>
</header>
@@ -1251,7 +1495,6 @@ export function renderExportPage(name: string): string {
<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>`
+159 -32
View File
@@ -1,4 +1,4 @@
import { app, BrowserWindow, shell } from 'electron'
import { app, BrowserWindow, nativeImage, shell } from 'electron'
import { createHash } from 'crypto'
import { createReadStream, createWriteStream, promises as fs } from 'fs'
import { extname, join } from 'path'
@@ -51,6 +51,12 @@ export interface HtmlExportConversation {
messageCount: number
}
interface HtmlExportAvatarVersion {
sourceHash: string
visualHash?: string
avatarUrl: string
}
interface HtmlExportArchiveV1 {
version: 1
sourceId: string
@@ -65,6 +71,7 @@ export interface HtmlExportArchive {
exportedAt: string
conversations: HtmlExportConversation[]
messages: Message[]
avatarVersions?: Record<string, HtmlExportAvatarVersion>
}
const htmlArchiveResourcePrefixes = {
@@ -92,6 +99,76 @@ const fileHashPart = async (filePath: string, length = 16): Promise<string> => {
}
const avatarFileName = (buffer: Buffer, extension: string): string =>
`avatar_${bufferHashPart(buffer)}.${extension}`
const avatarSourceHash = (source: string): string => hashPart(source, 24)
const avatarIdentityKey = (conversationId: string, message: Message): string =>
`${conversationId}:${
message.senderId ||
(message.isSender ? '__self__' : message.from || message.name || '__unknown__')
}`
const avatarVisualHash = (buffer: Buffer): string | null => {
try {
const image = nativeImage.createFromBuffer(buffer)
if (image.isEmpty()) return null
const bitmap = image.resize({ width: 9, height: 8, quality: 'good' }).toBitmap()
if (bitmap.length < 9 * 8 * 4) return null
let hash = 0n
let red = 0
let green = 0
let blue = 0
for (let y = 0; y < 8; y += 1) {
for (let x = 0; x < 8; x += 1) {
const left = (y * 9 + x) * 4
const right = left + 4
blue += bitmap[left]
green += bitmap[left + 1]
red += bitmap[left + 2]
const leftLuma = bitmap[left + 2] * 3 + bitmap[left + 1] * 6 + bitmap[left]
const rightLuma = bitmap[right + 2] * 3 + bitmap[right + 1] * 6 + bitmap[right]
hash = (hash << 1n) | (leftLuma > rightLuma ? 1n : 0n)
}
}
const averageHex = [red, green, blue]
.map((total) =>
Math.round(total / 64)
.toString(16)
.padStart(2, '0')
)
.join('')
return `${hash.toString(16).padStart(16, '0')}:${averageHex}`
} catch {
return null
}
}
const parseAvatarVisualHash = (value: string): { shape: bigint; color: number[] } | null => {
const match = /^([0-9a-f]{16}):([0-9a-f]{6})$/i.exec(value)
if (!match) return null
return {
shape: BigInt(`0x${match[1]}`),
color: [0, 2, 4].map((offset) => Number.parseInt(match[2].slice(offset, offset + 2), 16))
}
}
const avatarHashDistance = (left: bigint, right: bigint): number => {
let difference = left ^ right
let distance = 0
while (difference) {
difference &= difference - 1n
distance += 1
}
return distance
}
const avatarsLookTheSame = (left?: string | null, right?: string | null): boolean => {
if (!left || !right) return false
const leftHash = parseAvatarVisualHash(left)
const rightHash = parseAvatarVisualHash(right)
if (!leftHash || !rightHash || avatarHashDistance(leftHash.shape, rightHash.shape) > 5) {
return false
}
return leftHash.color.every((channel, index) => Math.abs(channel - rightHash.color[index]) <= 12)
}
const htmlArchiveSourceSignature = (message: Message): string =>
JSON.stringify([
@@ -307,7 +384,8 @@ export async function readHtmlArchive(
name: parsed.name || name,
exportedAt: parsed.exportedAt || new Date(0).toISOString(),
conversations: parsed.conversations,
messages: Array.isArray(parsed.messages) ? parsed.messages : []
messages: Array.isArray(parsed.messages) ? parsed.messages : [],
avatarVersions: parsed.avatarVersions
}
}
@@ -555,8 +633,7 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
if (targets.length > 1 && request.format !== 'html') {
throw new Error('多聊天合并仅支持 HTML 格式')
}
const archiveName =
targets.length > 1 ? `${targets[0].name}${targets.length} 个聊天` : targets[0].name
const archiveName = safeFilePart(request.outputName)
const targetById = new Map(targets.map((target) => [target.userMd5, target]))
send({ jobId: request.jobId, phase: 'reading', processed: 0, total: 100, percent: 0 })
await new Promise<void>((resolve) => setImmediate(resolve))
@@ -672,6 +749,15 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
const previousMessagesByKey = new Map(
previousArchive.messages.map((message) => [exportMessageKey(message), message])
)
const latestPreviousAvatarUrls = new Map<string, string>()
for (const message of previousArchive.messages) {
if (!message.exportAvatarUrl) continue
const conversationId = message.exportConversationId || targets[0].userMd5
latestPreviousAvatarUrls.set(
avatarIdentityKey(conversationId, message),
message.exportAvatarUrl
)
}
const reusablePreviousMessages = new Map<Message, Message>()
for (const message of messages) {
const previous = previousMessagesByKey.get(exportMessageKey(message))
@@ -722,20 +808,59 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
const stickerService = client ? new StickerService(client) : null
const fileService = client ? new FileAssetService(client) : null
const exportedAvatars = new Map<string, string>()
const previousAvatarVersions = previousArchive.avatarVersions || {}
const avatarVersions: Record<string, HtmlExportAvatarVersion> = {
...previousAvatarVersions
}
const resolveExportAvatar = async (
identity: string,
source: string,
legacyAvatarUrl?: string
): Promise<string | undefined> => {
const sourceHash = avatarSourceHash(source)
const previousVersion = previousAvatarVersions[identity]
const resolved = await readAvatarAsset(source)
if (!resolved?.buffer) return undefined
const visualHash = avatarVisualHash(resolved.buffer)
const previousAvatarUrl = previousVersion?.avatarUrl || legacyAvatarUrl
if (previousAvatarUrl && (await resourceExists(previousAvatarUrl))) {
const previousVisualHash =
previousVersion?.visualHash ||
avatarVisualHash(await fs.readFile(join(outputDir, previousAvatarUrl)))
if (avatarsLookTheSame(previousVisualHash, visualHash)) {
avatarVersions[identity] = {
sourceHash,
visualHash: visualHash || previousVisualHash || undefined,
avatarUrl: previousAvatarUrl
}
return previousAvatarUrl
}
}
const avatarName = avatarFileName(resolved.buffer, resolved.extension || 'jpg')
const avatarUrl = `avatars/${avatarName}`
if (!(await resourceExists(avatarUrl))) {
await fs.writeFile(join(outputDir, 'avatars', avatarName), resolved.buffer)
markResourceExists(avatarUrl)
}
avatarVersions[identity] = {
sourceHash,
visualHash: visualHash || undefined,
avatarUrl
}
return avatarUrl
}
const conversationAvatarUrls = new Map<string, string>()
if (request.includeAvatars !== false) {
for (const target of targets) {
if (!jobs.has(request.jobId)) throw new Error('已取消')
if (!target.avatarUrl) continue
const resolved = await readAvatarAsset(target.avatarUrl)
if (!resolved) continue
const avatarName = avatarFileName(resolved.buffer, resolved.extension)
const avatarUrl = `avatars/${avatarName}`
if (!(await resourceExists(avatarUrl))) {
await fs.writeFile(join(outputDir, 'avatars', avatarName), resolved.buffer)
markResourceExists(avatarUrl)
}
conversationAvatarUrls.set(target.userMd5, avatarUrl)
const avatarUrl = await resolveExportAvatar(
`conversation:${target.userMd5}`,
target.avatarUrl,
previousArchive.conversations.find((item) => item.id === target.userMd5)?.avatarUrl
)
if (avatarUrl) conversationAvatarUrls.set(target.userMd5, avatarUrl)
}
}
const voiceService =
@@ -811,27 +936,28 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
const conversationId = message.exportConversationId || targets[0].userMd5
message.exportShowAvatar = request.includeAvatars !== false
const previous = reusablePreviousMessages.get(message)
const previousMessage = previousMessagesByKey.get(exportMessageKey(message))
const avatar = (message.senderId ? avatarMap[message.senderId] : undefined) || message.img
const avatarKey = `${conversationId}:${
message.senderId || avatar || `message_${exportMessageKey(message, conversationId)}`
}`
let avatarName = exportedAvatars.get(avatarKey)
if (!avatarName && avatar) {
const resolvedAvatar = await readAvatarAsset(avatar)
if (resolvedAvatar?.buffer) {
const avatarExtension = resolvedAvatar.extension || 'jpg'
avatarName = avatarFileName(resolvedAvatar.buffer, avatarExtension)
const avatarUrl = `avatars/${avatarName}`
if (!(await resourceExists(avatarUrl))) {
await fs.writeFile(join(outputDir, 'avatars', avatarName), resolvedAvatar.buffer)
markResourceExists(avatarUrl)
}
exportedAvatars.set(avatarKey, avatarName)
const avatarKey = avatarIdentityKey(conversationId, message)
let avatarUrl: string | undefined
if (
request.includeAvatars !== false &&
previousMessage?.exportAvatarUrl &&
(await resourceExists(previousMessage.exportAvatarUrl))
) {
avatarUrl = previousMessage.exportAvatarUrl
} else if (request.includeAvatars !== false && avatar) {
avatarUrl = exportedAvatars.get(avatarKey)
if (!avatarUrl) {
avatarUrl = await resolveExportAvatar(
avatarKey,
avatar,
latestPreviousAvatarUrls.get(avatarKey)
)
if (avatarUrl) exportedAvatars.set(avatarKey, avatarUrl)
}
}
if (avatarName) {
message.exportAvatarUrl = `avatars/${avatarName}`
}
if (avatarUrl) message.exportAvatarUrl = avatarUrl
if (!request.includeMedia || !message.contentData) {
send({
jobId: request.jobId,
@@ -1036,7 +1162,8 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
(message) => message.exportConversationId === target.userMd5
).length
})),
messages: normalizedMessages
messages: normalizedMessages,
avatarVersions
}
if (!jobs.has(request.jobId)) throw new Error('已取消')
await fs.writeFile(outputPath, renderExportPage(archiveName), 'utf8')
+6 -1
View File
@@ -430,9 +430,14 @@ function parseLocationMessage(content: string): ParsedContent {
function parseShareMessage(content: string): ParsedContent {
const appMsgType = extractAppMsgType(content)
if (appMsgType === '19' || /<recorditem\b|<dataitem\b/i.test(content)) {
const isFileMessage = appMsgType === '6' || appMsgType === '74'
if (appMsgType === '19') {
return parseForwardBundle(content)
}
if (!isFileMessage && /<recorditem\b|<dataitem\b/i.test(content)) {
const forwardBundle = parseForwardBundle(content)
if (forwardBundle.items.length > 0) return forwardBundle
}
if (appMsgType === '47' || /<(?:emoji|sticker|emoticon)\b/i.test(content)) {
const sticker = parseStickerMessage(content)
if (sticker.type === 'sticker') return sticker
+218 -7
View File
@@ -44,6 +44,55 @@ const zipDirectory = async (
})
}
test('EXPORT-ARCHIVE-00 shows a loading state while archive data is still loading', async ({
page
}, testInfo) => {
let releaseData!: () => void
const dataReady = new Promise<void>((resolve) => {
releaseData = resolve
})
await page.route('http://archive.test/**', async (route) => {
if (route.request().url().endsWith('/data/messages.js')) {
await dataReady
await route.fulfill({
contentType: 'application/javascript',
body: `window.__WECHAT_EXPORT__ = ${JSON.stringify({
version: 1,
sourceId: 'loading-fixture',
name: '大量消息',
exportedAt: '2026-08-05T00:00:00.000Z',
messages: [
archiveMessage('loading-1', 'loading-fixture', '大量消息', '加载完成', 1_767_225_600)
]
})};`
})
return
}
await route.fulfill({
contentType: 'text/html',
body: renderExportPage('大量消息')
})
})
await page.setViewportSize({ width: 1440, height: 900 })
const navigation = page.goto('http://archive.test/index.html')
const loading = page.locator('#archive-loading')
await expect(loading).toBeVisible()
await expect(loading).toContainText('正在加载聊天档案')
await expect(loading).toHaveAttribute('aria-busy', 'true')
await page.screenshot({ path: testInfo.outputPath('archive-loading-1440.png') })
await page.setViewportSize({ width: 390, height: 844 })
await expect(loading).toBeVisible()
await page.screenshot({ path: testInfo.outputPath('archive-loading-390.png') })
releaseData()
await navigation
await expect(loading).toBeHidden()
await expect(page.getByText('加载完成')).toBeVisible()
})
test('EXPORT-ARCHIVE-01 merged v2 archive is usable offline on desktop and mobile', async ({
page
}, testInfo) => {
@@ -60,13 +109,24 @@ test('EXPORT-ARCHIVE-01 merged v2 archive is usable offline on desktop and mobil
name: '合并聊天档案',
exportedAt: '2026-08-04T00:00:00.000Z',
conversations: [
{ id: 'alpha', name: '项目群', type: 'group', messageCount: 2 },
{ id: 'alpha', name: '项目群', type: 'group', messageCount: 3 },
{ id: 'beta', name: '文件传输助手', type: 'user', messageCount: 1 }
],
messages: [
archiveMessage('alpha-1', 'alpha', '项目群', '项目群第一条', 1_764_547_200),
archiveMessage('beta-1', 'beta', '文件传输助手', '个人聊天消息', 1_769_904_000),
archiveMessage('alpha-2', 'alpha', '项目群', '项目群第二条', 1_769_990_400)
archiveMessage('alpha-2', 'alpha', '项目群', '项目群第二条', 1_769_990_400),
{
...archiveMessage(
'alpha-sent',
'alpha',
'Jamie',
'那边多少度呀 热不,这是用于验证移动端右侧头像不会被裁切的消息',
1_775_315_283
),
isSender: true,
name: 'Nanin'
}
]
})};\n`,
'utf8'
@@ -83,12 +143,17 @@ test('EXPORT-ARCHIVE-01 merged v2 archive is usable offline on desktop and mobil
const conversationSelect = page.getByLabel('筛选聊天')
await expect(conversationSelect).toHaveValue('all')
await expect(conversationSelect.locator('option')).toHaveCount(3)
await expect(conversationSelect.locator('option')).toHaveText([
'全部聊天',
'项目群',
'文件传输助手'
])
await expect(page.locator('#archive-title')).toBeHidden()
await expect(page.locator('.archive-heading #conversation-filter')).toBeVisible()
await expect(page.locator('#archive-meta')).toHaveText(/^更新于 /)
await expect(page.locator('#archive-meta')).not.toContainText('条消息')
await expect(page.locator('.message')).toHaveCount(3)
await expect(page.locator('.conversation-source')).toHaveCount(3)
await expect(page.locator('.message')).toHaveCount(4)
await expect(page.locator('.conversation-source')).toHaveCount(4)
expect(
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
@@ -121,7 +186,91 @@ test('EXPORT-ARCHIVE-01 merged v2 archive is usable offline on desktop and mobil
expect(positions.conversationBottom).toBeLessThanOrEqual(positions.toolbarBottom)
expect(positions.timelineTop).toBeGreaterThanOrEqual(positions.toolbarBottom)
expect(positions.documentWidth).toBeLessThanOrEqual(positions.viewportWidth)
await expect(page.locator('.message')).toHaveCount(3)
await expect(page.locator('.message')).toHaveCount(4)
const searchInput = page.getByLabel('搜索消息')
await expect(conversationSelect).toBeVisible()
const compactControlBounds = await page.evaluate(() => {
const conversations = document.querySelector('#conversation-filter')!.getBoundingClientRect()
const search = document.querySelector('#query')!.getBoundingClientRect()
return {
conversationTop: conversations.top,
conversationBottom: conversations.bottom,
conversationWidth: conversations.width,
searchTop: search.top,
searchBottom: search.bottom,
searchWidth: search.width
}
})
expect(
Math.abs(compactControlBounds.conversationTop - compactControlBounds.searchTop)
).toBeLessThanOrEqual(1)
expect(
Math.abs(compactControlBounds.conversationBottom - compactControlBounds.searchBottom)
).toBeLessThanOrEqual(1)
expect(compactControlBounds.searchWidth).toBeGreaterThan(compactControlBounds.conversationWidth)
await conversationSelect.selectOption('beta')
await expect(page.locator('.message')).toHaveCount(1)
await conversationSelect.selectOption('all')
await expect(page.locator('.message')).toHaveCount(4)
await expect(searchInput).toBeVisible()
const mobileFilterButtons = page.locator('.filter-button:visible')
await expect(mobileFilterButtons).toHaveCount(7)
const filterButtonTops = await mobileFilterButtons.evaluateAll((buttons) =>
buttons.map((button) => button.getBoundingClientRect().top)
)
expect(Math.max(...filterButtonTops) - Math.min(...filterButtonTops)).toBeLessThanOrEqual(1)
const countTop = await page
.locator('#count')
.evaluate((element) => element.getBoundingClientRect().top)
const filterBottom = await mobileFilterButtons
.first()
.evaluate((element) => element.getBoundingClientRect().bottom)
expect(countTop).toBeGreaterThanOrEqual(filterBottom)
expect(positions.toolbarBottom - positions.toolbarTop).toBeLessThanOrEqual(150)
await page.getByRole('button', { name: '文字', exact: true }).click()
const messageList = page.locator('#messages')
const sentMessageBounds = await page.locator('.message.sent').evaluate((element) => {
const list = element.parentElement!.getBoundingClientRect()
const message = element.getBoundingClientRect()
const row = element.querySelector('.row')!.getBoundingClientRect()
const avatar = element.querySelector('.avatar')!.getBoundingClientRect()
return {
listLeft: list.left,
listRight: list.right,
messageLeft: message.left,
messageRight: message.right,
rowLeft: row.left,
rowRight: row.right,
avatarLeft: avatar.left,
avatarRight: avatar.right
}
})
expect(Math.abs(sentMessageBounds.rowLeft - sentMessageBounds.messageLeft)).toBeLessThanOrEqual(
1
)
expect(
Math.abs(sentMessageBounds.rowRight - sentMessageBounds.messageRight)
).toBeLessThanOrEqual(1)
expect(sentMessageBounds.rowLeft).toBeGreaterThanOrEqual(sentMessageBounds.listLeft)
expect(sentMessageBounds.rowRight).toBeLessThanOrEqual(sentMessageBounds.listRight)
expect(sentMessageBounds.avatarLeft).toBeGreaterThanOrEqual(sentMessageBounds.listLeft)
expect(sentMessageBounds.avatarRight).toBeLessThanOrEqual(sentMessageBounds.listRight)
const mobileScrollBehavior = await messageList.evaluate((element) => {
const styles = getComputedStyle(element)
return {
overflowX: styles.overflowX,
overscrollBehaviorX: styles.overscrollBehaviorX,
touchAction: styles.touchAction
}
})
expect(mobileScrollBehavior).toEqual({
overflowX: 'hidden',
overscrollBehaviorX: 'none',
touchAction: 'pan-y'
})
await messageList.hover()
await page.mouse.wheel(80, 120)
await expect.poll(() => messageList.evaluate((element) => element.scrollLeft)).toBe(0)
await page.screenshot({ path: testInfo.outputPath('merged-archive-390.png'), fullPage: true })
} finally {
rmSync(fixtureRoot, { recursive: true, force: true })
@@ -171,7 +320,7 @@ test('EXPORT-ARCHIVE-02 legacy single-chat archive keeps its original layout', a
test('EXPORT-ARCHIVE-04 timeline follows the latest visible month after changing tabs', async ({
page
}) => {
}, testInfo) => {
const outputDir = mkdtempSync(join(tmpdir(), 'wxe-timeline-sync-e2e-'))
try {
const dataPath = join(outputDir, 'data', 'messages.js')
@@ -218,6 +367,19 @@ test('EXPORT-ARCHIVE-04 timeline follows the latest visible month after changing
const messages = page.locator('#messages')
const activeMonth = page.locator('.timeline-month.active')
await expect(activeMonth).toHaveAttribute('data-month', '2026-04')
const expandedYear = page.locator('.timeline-year[aria-expanded="true"]')
const latestYear = page.locator('.timeline-year[data-year="2026"]')
await expect(expandedYear).toHaveCount(1)
await expect(expandedYear).toHaveText('2026 年')
await expect(page.locator('.timeline-month:visible')).toHaveCount(1)
await latestYear.click()
await expect(latestYear).toHaveAttribute('aria-expanded', 'false')
await expect(page.locator('.timeline-month:visible')).toHaveCount(0)
await latestYear.click()
await expect(latestYear).toHaveAttribute('aria-expanded', 'true')
await expect(page.locator('.timeline-month:visible')).toHaveCount(1)
await expect(page.locator('#archive-loading')).toBeHidden()
await page.screenshot({ path: testInfo.outputPath('timeline-collapsed-1440.png') })
expect(
await messages.evaluate(
(element) => element.scrollHeight - element.scrollTop - element.clientHeight
@@ -236,10 +398,59 @@ test('EXPORT-ARCHIVE-04 timeline follows the latest visible month after changing
expect(timelinePosition.buttonTop).toBeGreaterThanOrEqual(timelinePosition.timelineTop)
expect(timelinePosition.buttonBottom).toBeLessThanOrEqual(timelinePosition.timelineBottom + 1)
const selectedYear = page.locator('.timeline-year[data-year="2020"]')
await selectedYear.click()
await expect(expandedYear).toHaveText('2020 年')
await expect(selectedYear).toHaveAttribute('aria-expanded', 'true')
await expect(page.locator('.timeline-year[data-year="2026"]')).toHaveAttribute(
'aria-expanded',
'false'
)
await expect(page.locator('.timeline-month:visible')).toHaveCount(12)
const selectedMonth = page.locator('.timeline-month[data-month="2020-07"]')
await selectedMonth.click()
await expect(selectedMonth).toHaveClass(/active/)
await expect(activeMonth).toHaveAttribute('data-month', '2020-07')
const visibleMonths = await messages.evaluate((element) => {
const bounds = element.getBoundingClientRect()
const anchor = bounds.top + Math.min(24, bounds.height / 4)
const items = Array.from(element.querySelectorAll<HTMLElement>('.message'))
return {
firstVisible: items.find((item) => item.getBoundingClientRect().bottom > bounds.top)
?.dataset.month,
firstAnchored: items.find((item) => item.getBoundingClientRect().bottom > anchor)?.dataset
.month
}
})
expect(visibleMonths).toEqual({ firstVisible: '2020-06', firstAnchored: '2020-07' })
await messages.evaluate((element) => {
element.scrollTop = 0
})
await expect(activeMonth).toHaveAttribute('data-month', '2006-01')
await expect(expandedYear).toHaveText('2006 年')
await expect(page.locator('.timeline-month:visible')).toHaveCount(12)
await page.setViewportSize({ width: 390, height: 844 })
expect(
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
const mobileLayoutBounds = await page.evaluate(() => {
const layout = document.querySelector('.archive-layout')!.getBoundingClientRect()
const messages = document.querySelector('#messages')!.getBoundingClientRect()
return {
viewportWidth: window.innerWidth,
layoutLeft: layout.left,
layoutRight: layout.right,
messagesLeft: messages.left,
messagesRight: messages.right
}
})
expect(mobileLayoutBounds.layoutLeft).toBeGreaterThanOrEqual(0)
expect(mobileLayoutBounds.layoutRight).toBeLessThanOrEqual(mobileLayoutBounds.viewportWidth)
expect(mobileLayoutBounds.messagesLeft).toBeGreaterThanOrEqual(0)
expect(mobileLayoutBounds.messagesRight).toBeLessThanOrEqual(mobileLayoutBounds.viewportWidth)
await expect(expandedYear).toHaveText('2006 年')
await page.screenshot({ path: testInfo.outputPath('timeline-collapsed-390.png') })
} finally {
rmSync(outputDir, { recursive: true, force: true })
}
@@ -491,7 +702,7 @@ test('EXPORT-ARCHIVE-03 renders shares and locations, and groups payments under
fullPage: true
})
await page.getByRole('button', { name: '系统 / 其他', exact: true }).click()
await page.getByRole('button', { name: '系统', exact: true }).click()
await expect(page.locator('.message')).toHaveCount(6)
await expect(page.getByText('我给你发了一个红包')).toBeVisible()
await expect(page.getByText('收到转账¥1000.00元')).toBeVisible()
+137 -2
View File
@@ -22,6 +22,8 @@ const state = vi.hoisted(() => ({
documents: '',
accountRoot: '',
videoPath: '',
selfAvatar: undefined as string | undefined,
avatarMap: {} as Record<string, string>,
messages: [] as Message[],
messagesByUser: {} as Record<string, Message[]>,
exportReads: [] as string[],
@@ -44,6 +46,26 @@ const state = vi.hoisted(() => ({
vi.mock('electron', () => ({
app: { getPath: () => state.documents },
shell: { showItemInFolder: vi.fn() },
nativeImage: {
createFromBuffer: (buffer: Buffer) => {
const reversed = buffer.toString().includes('different-avatar')
const bitmap = Buffer.alloc(9 * 8 * 4)
for (let y = 0; y < 8; y += 1) {
for (let x = 0; x < 9; x += 1) {
const offset = (y * 9 + x) * 4
const value = reversed ? 240 - x * 20 : 40 + x * 20
bitmap[offset] = value
bitmap[offset + 1] = value
bitmap[offset + 2] = value
bitmap[offset + 3] = 255
}
}
return {
isEmpty: () => false,
resize: () => ({ toBitmap: () => bitmap })
}
}
},
BrowserWindow: class {}
}))
vi.mock('../../src/main/services/chat-service', () => ({
@@ -60,10 +82,11 @@ vi.mock('../../src/main/services/chat-service', () => ({
getUsernameByMd5: (userMd5: string) => `wxid_${userMd5}`
})
}),
getContactAvatars: () => ({}),
getContactAvatars: () => ({ ...state.avatarMap }),
getSelfAccountInfoAsync: async () => ({
wxid: 'a969409112',
nickname: '濑岛田井卫',
avatar: state.selfAvatar,
accountRoot: state.accountRoot
})
}))
@@ -192,6 +215,8 @@ describe('media export flow', () => {
state.documents = mkdtempSync(join(tmpdir(), 'wxe-export-fixture-'))
state.accountRoot = join(state.documents, 'fixture-account')
state.videoPath = join(state.documents, 'fixture.mp4')
state.selfAvatar = undefined
state.avatarMap = {}
writeFileSync(
state.videoPath,
Buffer.from('000000186674797069736f6d0000020069736f6d69736f32', 'hex')
@@ -283,7 +308,8 @@ describe('media export flow', () => {
'ftyp'
)
expect(readFileSync(join(outputDir, file.exportMediaUrl!), 'utf8')).toBe('附件内容')
expect(html).toContain('<script src="data/messages.js"></script>')
expect(html).toContain("dataScript.src = 'data/messages.js'")
expect(html).toContain('id="archive-loading"')
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(/^files\/file_[0-9a-f]{16}_测试附件\.txt$/)
@@ -305,6 +331,33 @@ describe('media export flow', () => {
expect(state.exportReads).toEqual(['fixture-user'])
})
it('uses the customized file name as the HTML archive title', async () => {
const { runExport } = await import('../../src/main/export-service')
const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
state.messages = [message({ id: 'custom-title', content: '标题测试' })]
const result = await runExport(
{
jobId: 'custom-title',
targets: [target('fixture-user', '联系人原名')],
format: 'html',
outputName: '我修改后的文件名',
kinds: ['text'],
includeMedia: false
},
win as never
)
expect(result.success).toBe(true)
const html = readFileSync(result.outputPath!, 'utf8')
const archive = readArchive(result.outputPath!)
expect(html).toContain('<title>我修改后的文件名 - 聊天记录</title>')
expect(html).toContain('<span class="title" id="archive-title">我修改后的文件名</span>')
expect(html).not.toContain('<title>联系人原名 - 聊天记录</title>')
expect(archive.name).toBe('我修改后的文件名')
expect(archive.conversations[0].name).toBe('联系人原名')
})
it('keeps one-to-one sender sides and fills both display names', async () => {
const { runExport } = await import('../../src/main/export-service')
const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
@@ -481,6 +534,88 @@ describe('media export flow', () => {
expect(existsSync(imagePath)).toBe(true)
})
it('keeps historical avatars and creates a new version only after a real visual change', async () => {
const { runExport } = await import('../../src/main/export-service')
const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
const encodedAvatar = (value: string): string =>
`data:image/jpeg;base64,${Buffer.from(value).toString('base64')}`
const request = {
targets: [target('fixture-user', '头像版本会话')],
format: 'html' as const,
outputName: 'avatar-version-fixture',
kinds: ['text'] as const,
includeMedia: false,
includeAvatars: true
}
const oldMessage = message({
id: 'avatar-old',
isSender: true,
senderId: 'a969409112',
content: '历史消息',
createTime: 1_785_549_600
})
const sameAvatarFirstEncoding = encodedAvatar('same-visual-encoding-one')
state.selfAvatar = sameAvatarFirstEncoding
state.avatarMap = { a969409112: sameAvatarFirstEncoding }
state.messages = [oldMessage]
const first = await runExport(
{ ...request, jobId: 'avatar-version-first', kinds: [...request.kinds] },
win as never
)
expect(first.success, first.error).toBe(true)
const firstAvatarUrl = readArchive(first.outputPath!).messages[0].exportAvatarUrl
const newMessageBeforeChange = message({
id: 'avatar-new-same',
isSender: true,
senderId: 'a969409112',
content: '头像未变时的新消息',
createTime: 1_785_549_700
})
const sameAvatarSecondEncoding = encodedAvatar('same-visual-encoding-two')
state.selfAvatar = sameAvatarSecondEncoding
state.avatarMap = { a969409112: sameAvatarSecondEncoding }
state.messages = [oldMessage, newMessageBeforeChange]
const second = await runExport(
{ ...request, jobId: 'avatar-version-second', kinds: [...request.kinds] },
win as never
)
expect(second.success, second.error).toBe(true)
expect(readArchive(second.outputPath!).messages.map((item) => item.exportAvatarUrl)).toEqual([
firstAvatarUrl,
firstAvatarUrl
])
const newMessageAfterChange = message({
id: 'avatar-new-changed',
isSender: true,
senderId: 'a969409112',
content: '真正换头像后的新消息',
createTime: 1_785_549_800
})
const changedAvatar = encodedAvatar('different-avatar')
state.selfAvatar = changedAvatar
state.avatarMap = { a969409112: changedAvatar }
state.messages = [oldMessage, newMessageBeforeChange, newMessageAfterChange]
const third = await runExport(
{ ...request, jobId: 'avatar-version-third', kinds: [...request.kinds] },
win as never
)
expect(third.success, third.error).toBe(true)
const thirdArchive = readArchive(third.outputPath!)
expect(thirdArchive.messages.slice(0, 2).map((item) => item.exportAvatarUrl)).toEqual([
firstAvatarUrl,
firstAvatarUrl
])
expect(thirdArchive.messages[2].exportAvatarUrl).not.toBe(firstAvatarUrl)
expect(
readdirSync(join(dirname(third.outputPath!), 'avatars')).filter((name) =>
name.startsWith('avatar_')
)
).toHaveLength(2)
})
it('keeps copied videos writable and can replace a legacy read-only video incrementally', async () => {
const { runExport } = await import('../../src/main/export-service')
const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
+11 -2
View File
@@ -25,8 +25,13 @@ describe('export media', () => {
const html = renderExportPage('脱敏导出')
expect(EXPORT_PAGE_SIZE).toBe(240)
expect(html).toContain('<script src="data/messages.js"></script>')
expect(html).toContain("dataScript.src = 'data/messages.js'")
expect(html).toContain('id="archive-loading"')
expect(html).toContain('正在加载聊天档案')
expect(html).toContain('requestAnimationFrame(() => window.setTimeout(loadArchiveData, 0))')
expect(html).toContain('aria-label="聊天时间轴"')
expect(html).toContain('aria-expanded="')
expect(html).toContain('setExpandedTimelineYear')
expect(html).toContain('data-kind="media"')
expect(html).toContain('placeholder="搜索发送者或消息内容…"')
expect(html).toContain('filtered.slice(windowStart, windowEnd)')
@@ -91,6 +96,10 @@ describe('export media', () => {
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)
expect(
dom.window.document.querySelectorAll('.timeline-year[aria-expanded="true"]')
).toHaveLength(1)
expect(dom.window.document.querySelectorAll('.timeline-months:not([hidden])')).toHaveLength(1)
dom.window.close()
})
@@ -124,7 +133,7 @@ describe('export media', () => {
expect(dom.window.document.querySelector('#archive-meta')?.textContent).toMatch(/^更新于 /)
expect(select.options).toHaveLength(3)
expect(select.value).toBe('all')
expect(select.options[0].textContent).toBe('全部聊天3')
expect(select.options[0].textContent).toBe('全部聊天')
expect(dom.window.document.querySelectorAll('.conversation-source')).toHaveLength(3)
expect(dom.window.document.querySelector('#count')?.textContent).toBe(
'已显示 3 / 筛选 3 / 全部 3'
+28
View File
@@ -28,6 +28,34 @@ describe('message parser', () => {
}
})
it.each([
['6', '测试附件.pdf'],
['74', '发送中的附件.zip']
])(
'keeps file app message type %s when attachment metadata contains record tags',
(typeVal, title) => {
const parsed = parseMessageContent(
`<appmsg><type>${typeVal}</type><title>${title}</title><des>1 MB</des><appattach><recorditem>legacy metadata</recorditem><dataitem datatype="8"><datatitle>${title}</datatitle></dataitem></appattach></appmsg>`,
49
)
expect(parsed).toMatchObject({
type: 'share',
title,
typeVal
})
}
)
it('does not classify empty incidental record metadata as a merged forward', () => {
const parsed = parseMessageContent(
'<appmsg><type>5</type><title>普通分享</title><recorditem>legacy metadata</recorditem></appmsg>',
49
)
expect(parsed).toMatchObject({ type: 'share', title: '普通分享', typeVal: '5' })
})
it('uses the quoted group member id instead of the chatroom id', () => {
const parsed = parseMessageContent(
'<appmsg><type>57</type><title>回复内容</title><refermsg><type>1</type><fromusr>123456789@chatroom</fromusr><chatusr>wxid_fixture_member</chatusr><content>被引用内容</content></refermsg></appmsg>',