mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-18 03:57:02 +08:00
feat: 优化聊天档案浏览与增量导出
1. 增加档案加载状态、错误提示和延迟数据加载。 2. 优化移动端工具栏、消息布局及横向滚动控制。 3. 支持按年份折叠时间轴,并同步可见月份定位。 4. 使用自定义文件名作为档案标题。 5. 增量导出时保留历史头像,仅在视觉变化后新增头像版本。 6. 修复附件消息被误判为合并转发的问题。 7. 补充单元、集成及端到端测试覆盖。
This commit is contained in:
@@ -20,8 +20,50 @@ body {
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
font: 14px system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
|
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 {
|
.page {
|
||||||
|
width: 100%;
|
||||||
max-width: 1380px;
|
max-width: 1380px;
|
||||||
|
min-width: 0;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
padding: 20px 24px;
|
padding: 20px 24px;
|
||||||
@@ -30,6 +72,8 @@ body {
|
|||||||
}
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
grid-template-columns: minmax(220px, 1fr) auto;
|
grid-template-columns: minmax(220px, 1fr) auto;
|
||||||
gap: 14px 24px;
|
gap: 14px 24px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -71,6 +115,9 @@ body {
|
|||||||
.count { margin-left: auto; color: var(--muted); font-size: 13px; }
|
.count { margin-left: auto; color: var(--muted); font-size: 13px; }
|
||||||
.archive-layout {
|
.archive-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
grid-template-columns: 168px minmax(0, 1fr);
|
grid-template-columns: 168px minmax(0, 1fr);
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -80,6 +127,8 @@ body {
|
|||||||
.archive-layout.single-conversation { grid-template-columns: 168px minmax(0, 1fr); }
|
.archive-layout.single-conversation { grid-template-columns: 168px minmax(0, 1fr); }
|
||||||
.archive-navigation {
|
.archive-navigation {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -138,7 +187,36 @@ body {
|
|||||||
padding: 12px 9px;
|
padding: 12px 9px;
|
||||||
}
|
}
|
||||||
.timeline-empty { padding: 10px; color: var(--muted); font-size: 12px; }
|
.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 {
|
.timeline-month {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -159,7 +237,7 @@ body {
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
.timeline-month small { color: inherit; }
|
.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 {
|
.lazy-hint {
|
||||||
width: min(100%, 820px);
|
width: min(100%, 820px);
|
||||||
margin: 0 auto 12px;
|
margin: 0 auto 12px;
|
||||||
@@ -175,6 +253,7 @@ body {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
width: min(100%, 820px);
|
width: min(100%, 820px);
|
||||||
|
min-width: 0;
|
||||||
margin: 0 auto 22px;
|
margin: 0 auto 22px;
|
||||||
}
|
}
|
||||||
.locate-all {
|
.locate-all {
|
||||||
@@ -234,6 +313,7 @@ body {
|
|||||||
}
|
}
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.message.located .bubble { animation: none; outline: 3px solid #36a477; outline-offset: 3px; }
|
.message.located .bubble { animation: none; outline: 3px solid #36a477; outline-offset: 3px; }
|
||||||
|
.archive-loading-indicator { animation: none; }
|
||||||
}
|
}
|
||||||
.message.sent { align-items: flex-end; }
|
.message.sent { align-items: flex-end; }
|
||||||
.message.system { align-items: center; }
|
.message.system { align-items: center; }
|
||||||
@@ -271,7 +351,7 @@ body {
|
|||||||
background: var(--accent-soft);
|
background: var(--accent-soft);
|
||||||
color: var(--accent);
|
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; }
|
.sent .row { flex-direction: row-reverse; }
|
||||||
.avatar {
|
.avatar {
|
||||||
width: 38px;
|
width: 38px;
|
||||||
@@ -470,12 +550,75 @@ body {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
|
html, body { width: 100%; max-width: 100%; overflow-x: hidden; overscroll-behavior-x: none; }
|
||||||
.page { padding: 10px; }
|
.page { padding: 10px; }
|
||||||
.toolbar { grid-template-columns: 1fr; padding: 13px; }
|
.toolbar {
|
||||||
.controls { justify-content: flex-start; }
|
grid-template-columns: minmax(112px, .7fr) minmax(0, 1.3fr);
|
||||||
.controls input[type=search] { width: 100%; }
|
gap: 8px;
|
||||||
.filters { grid-column: 1; }
|
padding: 10px;
|
||||||
.count { width: 100%; margin-left: 0; }
|
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 { grid-template-columns: 1fr; align-content: start; margin-top: 10px; }
|
||||||
.archive-layout.single-conversation { grid-template-columns: 1fr; }
|
.archive-layout.single-conversation { grid-template-columns: 1fr; }
|
||||||
.archive-navigation { align-self: start; gap: 8px; }
|
.archive-navigation { align-self: start; gap: 8px; }
|
||||||
@@ -488,15 +631,22 @@ body {
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
.archive-heading { align-items: flex-start; }
|
.timeline-year-group {
|
||||||
.conversation-filter { flex-basis: 100%; width: 100%; }
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.timeline-year-group + .timeline-year-group { margin-top: 0; }
|
||||||
.timeline-year {
|
.timeline-year {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
align-self: center;
|
width: auto;
|
||||||
margin: 0 2px 0 0;
|
gap: 6px;
|
||||||
padding: 7px 5px;
|
padding: 7px 8px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
.timeline-months { display: flex; gap: 6px; }
|
||||||
|
.timeline-months[hidden] { display: none; }
|
||||||
.timeline-month {
|
.timeline-month {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
width: auto;
|
width: auto;
|
||||||
@@ -507,8 +657,14 @@ body {
|
|||||||
border-left-color: transparent;
|
border-left-color: transparent;
|
||||||
border-bottom-color: var(--accent);
|
border-bottom-color: var(--accent);
|
||||||
}
|
}
|
||||||
.bubble { max-width: calc(100vw - 92px); }
|
.scroll {
|
||||||
.audio-wrap { width: min(260px, calc(100vw - 130px)); }
|
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 => `
|
const renderExportScript = (name: string): string => `
|
||||||
(() => {
|
(() => {
|
||||||
'use strict'
|
'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 PAGE_SIZE = ${EXPORT_PAGE_SIZE}
|
||||||
const WINDOW_STEP = Math.floor(PAGE_SIZE / 2)
|
const WINDOW_STEP = Math.floor(PAGE_SIZE / 2)
|
||||||
const archive = window.__WECHAT_EXPORT__ || { name: ${JSON.stringify(name)}, messages: [] }
|
const archive = window.__WECHAT_EXPORT__ || { name: ${JSON.stringify(name)}, messages: [] }
|
||||||
@@ -561,6 +736,7 @@ const renderExportScript = (name: string): string => `
|
|||||||
let scrollLoadPending = false
|
let scrollLoadPending = false
|
||||||
let scrollLoadSuppressed = false
|
let scrollLoadSuppressed = false
|
||||||
let activeMonthUpdatePending = false
|
let activeMonthUpdatePending = false
|
||||||
|
let expandedTimelineYear = ''
|
||||||
const tabPositions = new Map()
|
const tabPositions = new Map()
|
||||||
let lastScrollTop = 0
|
let lastScrollTop = 0
|
||||||
let zoom = 1
|
let zoom = 1
|
||||||
@@ -856,7 +1032,8 @@ const renderExportScript = (name: string): string => `
|
|||||||
'<span class="locate-label" aria-hidden="true">定位到聊天位置</span></button>'
|
'<span class="locate-label" aria-hidden="true">定位到聊天位置</span></button>'
|
||||||
return '<article class="message' + (message.isSender ? ' sent' : '') + (isSystem ? ' system' : '') +
|
return '<article class="message' + (message.isSender ? ' sent' : '') + (isSystem ? ' system' : '') +
|
||||||
'" data-index="' + archiveIndex + '" data-month="' + esc(monthKey(message)) + '">' +
|
'" 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 ? '' : avatar) + '<div class="bubble"><div class="sender">' +
|
||||||
(isSystem ? '' : esc(sender)) + '</div>' + media + audio + quote + structured +
|
(isSystem ? '' : esc(sender)) + '</div>' + media + audio + quote + structured +
|
||||||
contentMarkup + mediaStatus + '</div>' + locateAction + '</div></article>'
|
contentMarkup + mediaStatus + '</div>' + locateAction + '</div></article>'
|
||||||
@@ -869,26 +1046,26 @@ const renderExportScript = (name: string): string => `
|
|||||||
layout.classList.add('single-conversation')
|
layout.classList.add('single-conversation')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const counts = new Map()
|
const option = (id, label) => '<option value="' + esc(id) + '">' + esc(label) + '</option>'
|
||||||
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>'
|
|
||||||
title.hidden = true
|
title.hidden = true
|
||||||
conversationFilter.hidden = false
|
conversationFilter.hidden = false
|
||||||
conversationSelect.innerHTML = option('all', '全部聊天', allMessages.length) +
|
conversationSelect.innerHTML = option('all', '全部聊天') +
|
||||||
conversations.map((conversation) => option(
|
conversations.map((conversation) => option(conversation.id, conversation.name)).join('')
|
||||||
conversation.id,
|
|
||||||
conversation.name,
|
|
||||||
counts.get(conversation.id) || 0
|
|
||||||
)).join('')
|
|
||||||
conversationSelect.value = activeConversation
|
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 = () => {
|
const renderTimeline = () => {
|
||||||
if (filtered.length === 0) {
|
if (filtered.length === 0) {
|
||||||
|
expandedTimelineYear = ''
|
||||||
timeline.innerHTML = '<div class="timeline-empty">没有可跳转的月份</div>'
|
timeline.innerHTML = '<div class="timeline-empty">没有可跳转的月份</div>'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -898,16 +1075,29 @@ const renderExportScript = (name: string): string => `
|
|||||||
if (key === 'unknown') continue
|
if (key === 'unknown') continue
|
||||||
groups.set(key, (groups.get(key) || 0) + 1)
|
groups.set(key, (groups.get(key) || 0) + 1)
|
||||||
}
|
}
|
||||||
let currentYear = ''
|
const yearGroups = new Map()
|
||||||
let html = ''
|
|
||||||
for (const [key, total] of groups) {
|
for (const [key, total] of groups) {
|
||||||
const parts = key.split('-')
|
const parts = key.split('-')
|
||||||
if (parts[0] !== currentYear) {
|
if (!yearGroups.has(parts[0])) yearGroups.set(parts[0], [])
|
||||||
currentYear = parts[0]
|
yearGroups.get(parts[0]).push({ key, month: Number(parts[1]), total })
|
||||||
html += '<div class="timeline-year">' + esc(currentYear) + ' 年</div>'
|
|
||||||
}
|
}
|
||||||
html += '<button class="timeline-month" type="button" data-month="' + esc(key) + '">' +
|
const years = Array.from(yearGroups.keys())
|
||||||
'<span>' + Number(parts[1]) + ' 月</span><small>' + total + '</small></button>'
|
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>'
|
timeline.innerHTML = html || '<div class="timeline-empty">时间信息不可用</div>'
|
||||||
}
|
}
|
||||||
@@ -923,10 +1113,16 @@ const renderExportScript = (name: string): string => `
|
|||||||
const updateActiveMonth = () => {
|
const updateActiveMonth = () => {
|
||||||
const messages = Array.from(list.querySelectorAll('.message'))
|
const messages = Array.from(list.querySelectorAll('.message'))
|
||||||
const visible = visibleMessages()
|
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 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
|
const activeMessage = atBottom
|
||||||
? visible[visible.length - 1] || messages[messages.length - 1]
|
? visible[visible.length - 1] || messages[messages.length - 1]
|
||||||
: visible[0] || messages[0]
|
: anchoredMessage || visible[0] || messages[0]
|
||||||
const key = activeMessage && activeMessage.dataset.month
|
const key = activeMessage && activeMessage.dataset.month
|
||||||
let activeButton
|
let activeButton
|
||||||
timeline.querySelectorAll('.timeline-month').forEach((button) => {
|
timeline.querySelectorAll('.timeline-month').forEach((button) => {
|
||||||
@@ -935,6 +1131,8 @@ const renderExportScript = (name: string): string => `
|
|||||||
if (active) activeButton = button
|
if (active) activeButton = button
|
||||||
})
|
})
|
||||||
if (!activeButton) return
|
if (!activeButton) return
|
||||||
|
const activeYear = key.split('-')[0]
|
||||||
|
if (activeYear !== expandedTimelineYear) setExpandedTimelineYear(activeYear)
|
||||||
const timelineBounds = timeline.getBoundingClientRect()
|
const timelineBounds = timeline.getBoundingClientRect()
|
||||||
const buttonBounds = activeButton.getBoundingClientRect()
|
const buttonBounds = activeButton.getBoundingClientRect()
|
||||||
if (buttonBounds.top < timelineBounds.top) {
|
if (buttonBounds.top < timelineBounds.top) {
|
||||||
@@ -1092,6 +1290,7 @@ const renderExportScript = (name: string): string => `
|
|||||||
renderWindow()
|
renderWindow()
|
||||||
const target = list.querySelector('.message[data-index="' + index + '"]')
|
const target = list.querySelector('.message[data-index="' + index + '"]')
|
||||||
setScrollTop(target ? Math.max(0, scrollTopForTarget(target, 24)) : 0)
|
setScrollTop(target ? Math.max(0, scrollTopForTarget(target, 24)) : 0)
|
||||||
|
setExpandedTimelineYear(key.split('-')[0])
|
||||||
timeline.querySelectorAll('.timeline-month').forEach((button) => {
|
timeline.querySelectorAll('.timeline-month').forEach((button) => {
|
||||||
button.classList.toggle('active', button.dataset.month === key)
|
button.classList.toggle('active', button.dataset.month === key)
|
||||||
})
|
})
|
||||||
@@ -1154,6 +1353,13 @@ const renderExportScript = (name: string): string => `
|
|||||||
applyFilters(true)
|
applyFilters(true)
|
||||||
})
|
})
|
||||||
timeline.addEventListener('click', (event) => {
|
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]')
|
const button = event.target.closest('[data-month]')
|
||||||
if (button) jumpToMonth(button.dataset.month)
|
if (button) jumpToMonth(button.dataset.month)
|
||||||
})
|
})
|
||||||
@@ -1202,6 +1408,39 @@ const renderExportScript = (name: string): string => `
|
|||||||
'更新于 ' + updatedAt
|
'更新于 ' + updatedAt
|
||||||
renderConversations()
|
renderConversations()
|
||||||
applyFilters()
|
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>
|
<style>${exportStyles}</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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">
|
<main class="page">
|
||||||
<header class="toolbar">
|
<header class="toolbar">
|
||||||
<div class="archive-heading">
|
<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="voice">语音</button>
|
||||||
<button class="filter-button" type="button" data-kind="file">文件</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="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>
|
<span class="count" id="count"></span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -1251,7 +1495,6 @@ export function renderExportPage(name: string): string {
|
|||||||
<button class="lightbox-close" id="lightbox-close" type="button" aria-label="关闭图片预览">×</button>
|
<button class="lightbox-close" id="lightbox-close" type="button" aria-label="关闭图片预览">×</button>
|
||||||
<img id="lightbox-image" alt="预览">
|
<img id="lightbox-image" alt="预览">
|
||||||
</div>
|
</div>
|
||||||
<script src="data/messages.js"></script>
|
|
||||||
<script>${renderExportScript(name)}</script>
|
<script>${renderExportScript(name)}</script>
|
||||||
</body>
|
</body>
|
||||||
</html>`
|
</html>`
|
||||||
|
|||||||
+159
-32
@@ -1,4 +1,4 @@
|
|||||||
import { app, BrowserWindow, shell } from 'electron'
|
import { app, BrowserWindow, nativeImage, shell } from 'electron'
|
||||||
import { createHash } from 'crypto'
|
import { createHash } from 'crypto'
|
||||||
import { createReadStream, createWriteStream, promises as fs } from 'fs'
|
import { createReadStream, createWriteStream, promises as fs } from 'fs'
|
||||||
import { extname, join } from 'path'
|
import { extname, join } from 'path'
|
||||||
@@ -51,6 +51,12 @@ export interface HtmlExportConversation {
|
|||||||
messageCount: number
|
messageCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface HtmlExportAvatarVersion {
|
||||||
|
sourceHash: string
|
||||||
|
visualHash?: string
|
||||||
|
avatarUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
interface HtmlExportArchiveV1 {
|
interface HtmlExportArchiveV1 {
|
||||||
version: 1
|
version: 1
|
||||||
sourceId: string
|
sourceId: string
|
||||||
@@ -65,6 +71,7 @@ export interface HtmlExportArchive {
|
|||||||
exportedAt: string
|
exportedAt: string
|
||||||
conversations: HtmlExportConversation[]
|
conversations: HtmlExportConversation[]
|
||||||
messages: Message[]
|
messages: Message[]
|
||||||
|
avatarVersions?: Record<string, HtmlExportAvatarVersion>
|
||||||
}
|
}
|
||||||
|
|
||||||
const htmlArchiveResourcePrefixes = {
|
const htmlArchiveResourcePrefixes = {
|
||||||
@@ -92,6 +99,76 @@ const fileHashPart = async (filePath: string, length = 16): Promise<string> => {
|
|||||||
}
|
}
|
||||||
const avatarFileName = (buffer: Buffer, extension: string): string =>
|
const avatarFileName = (buffer: Buffer, extension: string): string =>
|
||||||
`avatar_${bufferHashPart(buffer)}.${extension}`
|
`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 =>
|
const htmlArchiveSourceSignature = (message: Message): string =>
|
||||||
JSON.stringify([
|
JSON.stringify([
|
||||||
@@ -307,7 +384,8 @@ export async function readHtmlArchive(
|
|||||||
name: parsed.name || name,
|
name: parsed.name || name,
|
||||||
exportedAt: parsed.exportedAt || new Date(0).toISOString(),
|
exportedAt: parsed.exportedAt || new Date(0).toISOString(),
|
||||||
conversations: parsed.conversations,
|
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') {
|
if (targets.length > 1 && request.format !== 'html') {
|
||||||
throw new Error('多聊天合并仅支持 HTML 格式')
|
throw new Error('多聊天合并仅支持 HTML 格式')
|
||||||
}
|
}
|
||||||
const archiveName =
|
const archiveName = safeFilePart(request.outputName)
|
||||||
targets.length > 1 ? `${targets[0].name} 等 ${targets.length} 个聊天` : targets[0].name
|
|
||||||
const targetById = new Map(targets.map((target) => [target.userMd5, target]))
|
const targetById = new Map(targets.map((target) => [target.userMd5, target]))
|
||||||
send({ jobId: request.jobId, phase: 'reading', processed: 0, total: 100, percent: 0 })
|
send({ jobId: request.jobId, phase: 'reading', processed: 0, total: 100, percent: 0 })
|
||||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||||
@@ -672,6 +749,15 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
|||||||
const previousMessagesByKey = new Map(
|
const previousMessagesByKey = new Map(
|
||||||
previousArchive.messages.map((message) => [exportMessageKey(message), message])
|
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>()
|
const reusablePreviousMessages = new Map<Message, Message>()
|
||||||
for (const message of messages) {
|
for (const message of messages) {
|
||||||
const previous = previousMessagesByKey.get(exportMessageKey(message))
|
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 stickerService = client ? new StickerService(client) : null
|
||||||
const fileService = client ? new FileAssetService(client) : null
|
const fileService = client ? new FileAssetService(client) : null
|
||||||
const exportedAvatars = new Map<string, string>()
|
const exportedAvatars = new Map<string, string>()
|
||||||
const conversationAvatarUrls = new Map<string, string>()
|
const previousAvatarVersions = previousArchive.avatarVersions || {}
|
||||||
if (request.includeAvatars !== false) {
|
const avatarVersions: Record<string, HtmlExportAvatarVersion> = {
|
||||||
for (const target of targets) {
|
...previousAvatarVersions
|
||||||
if (!jobs.has(request.jobId)) throw new Error('已取消')
|
}
|
||||||
if (!target.avatarUrl) continue
|
const resolveExportAvatar = async (
|
||||||
const resolved = await readAvatarAsset(target.avatarUrl)
|
identity: string,
|
||||||
if (!resolved) continue
|
source: string,
|
||||||
const avatarName = avatarFileName(resolved.buffer, resolved.extension)
|
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}`
|
const avatarUrl = `avatars/${avatarName}`
|
||||||
if (!(await resourceExists(avatarUrl))) {
|
if (!(await resourceExists(avatarUrl))) {
|
||||||
await fs.writeFile(join(outputDir, 'avatars', avatarName), resolved.buffer)
|
await fs.writeFile(join(outputDir, 'avatars', avatarName), resolved.buffer)
|
||||||
markResourceExists(avatarUrl)
|
markResourceExists(avatarUrl)
|
||||||
}
|
}
|
||||||
conversationAvatarUrls.set(target.userMd5, 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 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 =
|
const voiceService =
|
||||||
@@ -811,27 +936,28 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
|||||||
const conversationId = message.exportConversationId || targets[0].userMd5
|
const conversationId = message.exportConversationId || targets[0].userMd5
|
||||||
message.exportShowAvatar = request.includeAvatars !== false
|
message.exportShowAvatar = request.includeAvatars !== false
|
||||||
const previous = reusablePreviousMessages.get(message)
|
const previous = reusablePreviousMessages.get(message)
|
||||||
|
const previousMessage = previousMessagesByKey.get(exportMessageKey(message))
|
||||||
const avatar = (message.senderId ? avatarMap[message.senderId] : undefined) || message.img
|
const avatar = (message.senderId ? avatarMap[message.senderId] : undefined) || message.img
|
||||||
const avatarKey = `${conversationId}:${
|
const avatarKey = avatarIdentityKey(conversationId, message)
|
||||||
message.senderId || avatar || `message_${exportMessageKey(message, conversationId)}`
|
let avatarUrl: string | undefined
|
||||||
}`
|
if (
|
||||||
let avatarName = exportedAvatars.get(avatarKey)
|
request.includeAvatars !== false &&
|
||||||
if (!avatarName && avatar) {
|
previousMessage?.exportAvatarUrl &&
|
||||||
const resolvedAvatar = await readAvatarAsset(avatar)
|
(await resourceExists(previousMessage.exportAvatarUrl))
|
||||||
if (resolvedAvatar?.buffer) {
|
) {
|
||||||
const avatarExtension = resolvedAvatar.extension || 'jpg'
|
avatarUrl = previousMessage.exportAvatarUrl
|
||||||
avatarName = avatarFileName(resolvedAvatar.buffer, avatarExtension)
|
} else if (request.includeAvatars !== false && avatar) {
|
||||||
const avatarUrl = `avatars/${avatarName}`
|
avatarUrl = exportedAvatars.get(avatarKey)
|
||||||
if (!(await resourceExists(avatarUrl))) {
|
if (!avatarUrl) {
|
||||||
await fs.writeFile(join(outputDir, 'avatars', avatarName), resolvedAvatar.buffer)
|
avatarUrl = await resolveExportAvatar(
|
||||||
markResourceExists(avatarUrl)
|
avatarKey,
|
||||||
}
|
avatar,
|
||||||
exportedAvatars.set(avatarKey, avatarName)
|
latestPreviousAvatarUrls.get(avatarKey)
|
||||||
|
)
|
||||||
|
if (avatarUrl) exportedAvatars.set(avatarKey, avatarUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (avatarName) {
|
if (avatarUrl) message.exportAvatarUrl = avatarUrl
|
||||||
message.exportAvatarUrl = `avatars/${avatarName}`
|
|
||||||
}
|
|
||||||
if (!request.includeMedia || !message.contentData) {
|
if (!request.includeMedia || !message.contentData) {
|
||||||
send({
|
send({
|
||||||
jobId: request.jobId,
|
jobId: request.jobId,
|
||||||
@@ -1036,7 +1162,8 @@ export async function runExport(request: ExportRequest, win: BrowserWindow): Pro
|
|||||||
(message) => message.exportConversationId === target.userMd5
|
(message) => message.exportConversationId === target.userMd5
|
||||||
).length
|
).length
|
||||||
})),
|
})),
|
||||||
messages: normalizedMessages
|
messages: normalizedMessages,
|
||||||
|
avatarVersions
|
||||||
}
|
}
|
||||||
if (!jobs.has(request.jobId)) throw new Error('已取消')
|
if (!jobs.has(request.jobId)) throw new Error('已取消')
|
||||||
await fs.writeFile(outputPath, renderExportPage(archiveName), 'utf8')
|
await fs.writeFile(outputPath, renderExportPage(archiveName), 'utf8')
|
||||||
|
|||||||
@@ -430,9 +430,14 @@ function parseLocationMessage(content: string): ParsedContent {
|
|||||||
|
|
||||||
function parseShareMessage(content: string): ParsedContent {
|
function parseShareMessage(content: string): ParsedContent {
|
||||||
const appMsgType = extractAppMsgType(content)
|
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)
|
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)) {
|
if (appMsgType === '47' || /<(?:emoji|sticker|emoticon)\b/i.test(content)) {
|
||||||
const sticker = parseStickerMessage(content)
|
const sticker = parseStickerMessage(content)
|
||||||
if (sticker.type === 'sticker') return sticker
|
if (sticker.type === 'sticker') return sticker
|
||||||
|
|||||||
@@ -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 ({
|
test('EXPORT-ARCHIVE-01 merged v2 archive is usable offline on desktop and mobile', async ({
|
||||||
page
|
page
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
@@ -60,13 +109,24 @@ test('EXPORT-ARCHIVE-01 merged v2 archive is usable offline on desktop and mobil
|
|||||||
name: '合并聊天档案',
|
name: '合并聊天档案',
|
||||||
exportedAt: '2026-08-04T00:00:00.000Z',
|
exportedAt: '2026-08-04T00:00:00.000Z',
|
||||||
conversations: [
|
conversations: [
|
||||||
{ id: 'alpha', name: '项目群', type: 'group', messageCount: 2 },
|
{ id: 'alpha', name: '项目群', type: 'group', messageCount: 3 },
|
||||||
{ id: 'beta', name: '文件传输助手', type: 'user', messageCount: 1 }
|
{ id: 'beta', name: '文件传输助手', type: 'user', messageCount: 1 }
|
||||||
],
|
],
|
||||||
messages: [
|
messages: [
|
||||||
archiveMessage('alpha-1', 'alpha', '项目群', '项目群第一条', 1_764_547_200),
|
archiveMessage('alpha-1', 'alpha', '项目群', '项目群第一条', 1_764_547_200),
|
||||||
archiveMessage('beta-1', 'beta', '文件传输助手', '个人聊天消息', 1_769_904_000),
|
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`,
|
})};\n`,
|
||||||
'utf8'
|
'utf8'
|
||||||
@@ -83,12 +143,17 @@ test('EXPORT-ARCHIVE-01 merged v2 archive is usable offline on desktop and mobil
|
|||||||
const conversationSelect = page.getByLabel('筛选聊天')
|
const conversationSelect = page.getByLabel('筛选聊天')
|
||||||
await expect(conversationSelect).toHaveValue('all')
|
await expect(conversationSelect).toHaveValue('all')
|
||||||
await expect(conversationSelect.locator('option')).toHaveCount(3)
|
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-title')).toBeHidden()
|
||||||
await expect(page.locator('.archive-heading #conversation-filter')).toBeVisible()
|
await expect(page.locator('.archive-heading #conversation-filter')).toBeVisible()
|
||||||
await expect(page.locator('#archive-meta')).toHaveText(/^更新于 /)
|
await expect(page.locator('#archive-meta')).toHaveText(/^更新于 /)
|
||||||
await expect(page.locator('#archive-meta')).not.toContainText('条消息')
|
await expect(page.locator('#archive-meta')).not.toContainText('条消息')
|
||||||
await expect(page.locator('.message')).toHaveCount(3)
|
await expect(page.locator('.message')).toHaveCount(4)
|
||||||
await expect(page.locator('.conversation-source')).toHaveCount(3)
|
await expect(page.locator('.conversation-source')).toHaveCount(4)
|
||||||
expect(
|
expect(
|
||||||
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||||
).toBe(true)
|
).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.conversationBottom).toBeLessThanOrEqual(positions.toolbarBottom)
|
||||||
expect(positions.timelineTop).toBeGreaterThanOrEqual(positions.toolbarBottom)
|
expect(positions.timelineTop).toBeGreaterThanOrEqual(positions.toolbarBottom)
|
||||||
expect(positions.documentWidth).toBeLessThanOrEqual(positions.viewportWidth)
|
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 })
|
await page.screenshot({ path: testInfo.outputPath('merged-archive-390.png'), fullPage: true })
|
||||||
} finally {
|
} finally {
|
||||||
rmSync(fixtureRoot, { recursive: true, force: true })
|
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 ({
|
test('EXPORT-ARCHIVE-04 timeline follows the latest visible month after changing tabs', async ({
|
||||||
page
|
page
|
||||||
}) => {
|
}, testInfo) => {
|
||||||
const outputDir = mkdtempSync(join(tmpdir(), 'wxe-timeline-sync-e2e-'))
|
const outputDir = mkdtempSync(join(tmpdir(), 'wxe-timeline-sync-e2e-'))
|
||||||
try {
|
try {
|
||||||
const dataPath = join(outputDir, 'data', 'messages.js')
|
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 messages = page.locator('#messages')
|
||||||
const activeMonth = page.locator('.timeline-month.active')
|
const activeMonth = page.locator('.timeline-month.active')
|
||||||
await expect(activeMonth).toHaveAttribute('data-month', '2026-04')
|
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(
|
expect(
|
||||||
await messages.evaluate(
|
await messages.evaluate(
|
||||||
(element) => element.scrollHeight - element.scrollTop - element.clientHeight
|
(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.buttonTop).toBeGreaterThanOrEqual(timelinePosition.timelineTop)
|
||||||
expect(timelinePosition.buttonBottom).toBeLessThanOrEqual(timelinePosition.timelineBottom + 1)
|
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) => {
|
await messages.evaluate((element) => {
|
||||||
element.scrollTop = 0
|
element.scrollTop = 0
|
||||||
})
|
})
|
||||||
await expect(activeMonth).toHaveAttribute('data-month', '2006-01')
|
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 {
|
} finally {
|
||||||
rmSync(outputDir, { recursive: true, force: true })
|
rmSync(outputDir, { recursive: true, force: true })
|
||||||
}
|
}
|
||||||
@@ -491,7 +702,7 @@ test('EXPORT-ARCHIVE-03 renders shares and locations, and groups payments under
|
|||||||
fullPage: true
|
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.locator('.message')).toHaveCount(6)
|
||||||
await expect(page.getByText('我给你发了一个红包')).toBeVisible()
|
await expect(page.getByText('我给你发了一个红包')).toBeVisible()
|
||||||
await expect(page.getByText('收到转账¥1000.00元')).toBeVisible()
|
await expect(page.getByText('收到转账¥1000.00元')).toBeVisible()
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ const state = vi.hoisted(() => ({
|
|||||||
documents: '',
|
documents: '',
|
||||||
accountRoot: '',
|
accountRoot: '',
|
||||||
videoPath: '',
|
videoPath: '',
|
||||||
|
selfAvatar: undefined as string | undefined,
|
||||||
|
avatarMap: {} as Record<string, string>,
|
||||||
messages: [] as Message[],
|
messages: [] as Message[],
|
||||||
messagesByUser: {} as Record<string, Message[]>,
|
messagesByUser: {} as Record<string, Message[]>,
|
||||||
exportReads: [] as string[],
|
exportReads: [] as string[],
|
||||||
@@ -44,6 +46,26 @@ const state = vi.hoisted(() => ({
|
|||||||
vi.mock('electron', () => ({
|
vi.mock('electron', () => ({
|
||||||
app: { getPath: () => state.documents },
|
app: { getPath: () => state.documents },
|
||||||
shell: { showItemInFolder: vi.fn() },
|
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 {}
|
BrowserWindow: class {}
|
||||||
}))
|
}))
|
||||||
vi.mock('../../src/main/services/chat-service', () => ({
|
vi.mock('../../src/main/services/chat-service', () => ({
|
||||||
@@ -60,10 +82,11 @@ vi.mock('../../src/main/services/chat-service', () => ({
|
|||||||
getUsernameByMd5: (userMd5: string) => `wxid_${userMd5}`
|
getUsernameByMd5: (userMd5: string) => `wxid_${userMd5}`
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
getContactAvatars: () => ({}),
|
getContactAvatars: () => ({ ...state.avatarMap }),
|
||||||
getSelfAccountInfoAsync: async () => ({
|
getSelfAccountInfoAsync: async () => ({
|
||||||
wxid: 'a969409112',
|
wxid: 'a969409112',
|
||||||
nickname: '濑岛田井卫',
|
nickname: '濑岛田井卫',
|
||||||
|
avatar: state.selfAvatar,
|
||||||
accountRoot: state.accountRoot
|
accountRoot: state.accountRoot
|
||||||
})
|
})
|
||||||
}))
|
}))
|
||||||
@@ -192,6 +215,8 @@ describe('media export flow', () => {
|
|||||||
state.documents = mkdtempSync(join(tmpdir(), 'wxe-export-fixture-'))
|
state.documents = mkdtempSync(join(tmpdir(), 'wxe-export-fixture-'))
|
||||||
state.accountRoot = join(state.documents, 'fixture-account')
|
state.accountRoot = join(state.documents, 'fixture-account')
|
||||||
state.videoPath = join(state.documents, 'fixture.mp4')
|
state.videoPath = join(state.documents, 'fixture.mp4')
|
||||||
|
state.selfAvatar = undefined
|
||||||
|
state.avatarMap = {}
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
state.videoPath,
|
state.videoPath,
|
||||||
Buffer.from('000000186674797069736f6d0000020069736f6d69736f32', 'hex')
|
Buffer.from('000000186674797069736f6d0000020069736f6d69736f32', 'hex')
|
||||||
@@ -283,7 +308,8 @@ describe('media export flow', () => {
|
|||||||
'ftyp'
|
'ftyp'
|
||||||
)
|
)
|
||||||
expect(readFileSync(join(outputDir, file.exportMediaUrl!), 'utf8')).toBe('附件内容')
|
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(voice.voiceDataUrl).toMatch(/^voices\/voice_[0-9a-f]{16}\.wav$/)
|
||||||
expect(video.exportMediaUrl).toMatch(/^media\/video_[0-9a-f]{16}\.mp4$/)
|
expect(video.exportMediaUrl).toMatch(/^media\/video_[0-9a-f]{16}\.mp4$/)
|
||||||
expect(file.exportMediaUrl).toMatch(/^files\/file_[0-9a-f]{16}_测试附件\.txt$/)
|
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'])
|
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 () => {
|
it('keeps one-to-one sender sides and fills both display names', async () => {
|
||||||
const { runExport } = await import('../../src/main/export-service')
|
const { runExport } = await import('../../src/main/export-service')
|
||||||
const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
|
const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
|
||||||
@@ -481,6 +534,88 @@ describe('media export flow', () => {
|
|||||||
expect(existsSync(imagePath)).toBe(true)
|
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 () => {
|
it('keeps copied videos writable and can replace a legacy read-only video incrementally', async () => {
|
||||||
const { runExport } = await import('../../src/main/export-service')
|
const { runExport } = await import('../../src/main/export-service')
|
||||||
const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
|
const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
|
||||||
|
|||||||
@@ -25,8 +25,13 @@ describe('export media', () => {
|
|||||||
const html = renderExportPage('脱敏导出')
|
const html = renderExportPage('脱敏导出')
|
||||||
|
|
||||||
expect(EXPORT_PAGE_SIZE).toBe(240)
|
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-label="聊天时间轴"')
|
||||||
|
expect(html).toContain('aria-expanded="')
|
||||||
|
expect(html).toContain('setExpandedTimelineYear')
|
||||||
expect(html).toContain('data-kind="media"')
|
expect(html).toContain('data-kind="media"')
|
||||||
expect(html).toContain('placeholder="搜索发送者或消息内容…"')
|
expect(html).toContain('placeholder="搜索发送者或消息内容…"')
|
||||||
expect(html).toContain('filtered.slice(windowStart, windowEnd)')
|
expect(html).toContain('filtered.slice(windowStart, windowEnd)')
|
||||||
@@ -91,6 +96,10 @@ describe('export media', () => {
|
|||||||
search.dispatchEvent(new dom.window.Event('input'))
|
search.dispatchEvent(new dom.window.Event('input'))
|
||||||
expect(dom.window.document.querySelectorAll('.message')).toHaveLength(5)
|
expect(dom.window.document.querySelectorAll('.message')).toHaveLength(5)
|
||||||
expect(dom.window.document.querySelectorAll('.timeline-month').length).toBeGreaterThan(1)
|
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()
|
dom.window.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -124,7 +133,7 @@ describe('export media', () => {
|
|||||||
expect(dom.window.document.querySelector('#archive-meta')?.textContent).toMatch(/^更新于 /)
|
expect(dom.window.document.querySelector('#archive-meta')?.textContent).toMatch(/^更新于 /)
|
||||||
expect(select.options).toHaveLength(3)
|
expect(select.options).toHaveLength(3)
|
||||||
expect(select.value).toBe('all')
|
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.querySelectorAll('.conversation-source')).toHaveLength(3)
|
||||||
expect(dom.window.document.querySelector('#count')?.textContent).toBe(
|
expect(dom.window.document.querySelector('#count')?.textContent).toBe(
|
||||||
'已显示 3 / 筛选 3 / 全部 3'
|
'已显示 3 / 筛选 3 / 全部 3'
|
||||||
|
|||||||
@@ -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', () => {
|
it('uses the quoted group member id instead of the chatroom id', () => {
|
||||||
const parsed = parseMessageContent(
|
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>',
|
'<appmsg><type>57</type><title>回复内容</title><refermsg><type>1</type><fromusr>123456789@chatroom</fromusr><chatusr>wxid_fixture_member</chatusr><content>被引用内容</content></refermsg></appmsg>',
|
||||||
|
|||||||
Reference in New Issue
Block a user