From 933a87ebbb40b460c8b6ae5fbd0cb6accb927f2a Mon Sep 17 00:00:00 2001 From: "majun.jason" Date: Wed, 5 Aug 2026 15:00:31 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E6=A1=A3=E6=A1=88=E6=B5=8F=E8=A7=88=E4=B8=8E=E5=A2=9E=E9=87=8F?= =?UTF-8?q?=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 增加档案加载状态、错误提示和延迟数据加载。 2. 优化移动端工具栏、消息布局及横向滚动控制。 3. 支持按年份折叠时间轴,并同步可见月份定位。 4. 使用自定义文件名作为档案标题。 5. 增量导出时保留历史头像,仅在视觉变化后新增头像版本。 6. 修复附件消息被误判为合并转发的问题。 7. 补充单元、集成及端到端测试覆盖。 --- src/main/export-html-template.ts | 323 +++++++++++++++++--- src/main/export-service.ts | 191 ++++++++++-- src/main/message-parser.ts | 7 +- tests/e2e/export-archive.spec.ts | 225 +++++++++++++- tests/integration/export-media-flow.test.ts | 139 ++++++++- tests/unit/export-media.test.ts | 13 +- tests/unit/message-parser.test.ts | 28 ++ 7 files changed, 842 insertions(+), 84 deletions(-) diff --git a/src/main/export-html-template.ts b/src/main/export-html-template.ts index d38ead5..34157ba 100644 --- a/src/main/export-html-template.ts +++ b/src/main/export-html-template.ts @@ -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 => ` '' return '
' + - '
' + esc(fullTime(message)) + source + '
' + + '
' + esc(fullTime(message)) + source + '
' + (isSystem ? '' : avatar) + '
' + (isSystem ? '' : esc(sender)) + '
' + media + audio + quote + structured + contentMarkup + mediaStatus + '
' + locateAction + '
' @@ -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) => - '' + const option = (id, label) => '' 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 = '
没有可跳转的月份
' 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 += '
' + esc(currentYear) + ' 年
' - } - html += '' + 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 += '
' + + '' + + '
' + + yearGroups.get(year).map((entry) => + '' + ).join('') + '
' } timeline.innerHTML = html || '
时间信息不可用
' } @@ -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 { +
+ + 正在加载聊天档案 + 消息较多时可能需要一些时间 +
@@ -1234,7 +1478,7 @@ export function renderExportPage(name: string): string { - +
@@ -1251,7 +1495,6 @@ export function renderExportPage(name: string): string { 预览 - ` diff --git a/src/main/export-service.ts b/src/main/export-service.ts index 1b0fbaf..0de9400 100644 --- a/src/main/export-service.ts +++ b/src/main/export-service.ts @@ -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 } const htmlArchiveResourcePrefixes = { @@ -92,6 +99,76 @@ const fileHashPart = async (filePath: string, length = 16): Promise => { } 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((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() + 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() 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() + const previousAvatarVersions = previousArchive.avatarVersions || {} + const avatarVersions: Record = { + ...previousAvatarVersions + } + const resolveExportAvatar = async ( + identity: string, + source: string, + legacyAvatarUrl?: string + ): Promise => { + 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() 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') diff --git a/src/main/message-parser.ts b/src/main/message-parser.ts index 5b095ca..4309492 100644 --- a/src/main/message-parser.ts +++ b/src/main/message-parser.ts @@ -430,9 +430,14 @@ function parseLocationMessage(content: string): ParsedContent { function parseShareMessage(content: string): ParsedContent { const appMsgType = extractAppMsgType(content) - if (appMsgType === '19' || / 0) return forwardBundle + } if (appMsgType === '47' || /<(?:emoji|sticker|emoticon)\b/i.test(content)) { const sticker = parseStickerMessage(content) if (sticker.type === 'sticker') return sticker diff --git a/tests/e2e/export-archive.spec.ts b/tests/e2e/export-archive.spec.ts index af116d5..be78a60 100644 --- a/tests/e2e/export-archive.spec.ts +++ b/tests/e2e/export-archive.spec.ts @@ -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((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('.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() diff --git a/tests/integration/export-media-flow.test.ts b/tests/integration/export-media-flow.test.ts index ba594d7..845905e 100644 --- a/tests/integration/export-media-flow.test.ts +++ b/tests/integration/export-media-flow.test.ts @@ -22,6 +22,8 @@ const state = vi.hoisted(() => ({ documents: '', accountRoot: '', videoPath: '', + selfAvatar: undefined as string | undefined, + avatarMap: {} as Record, messages: [] as Message[], messagesByUser: {} as Record, 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('') + 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('我修改后的文件名 - 聊天记录') + expect(html).toContain('我修改后的文件名') + expect(html).not.toContain('联系人原名 - 聊天记录') + 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() } } diff --git a/tests/unit/export-media.test.ts b/tests/unit/export-media.test.ts index e15f208..897a392 100644 --- a/tests/unit/export-media.test.ts +++ b/tests/unit/export-media.test.ts @@ -25,8 +25,13 @@ describe('export media', () => { const html = renderExportPage('脱敏导出') expect(EXPORT_PAGE_SIZE).toBe(240) - expect(html).toContain('') + 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' diff --git a/tests/unit/message-parser.test.ts b/tests/unit/message-parser.test.ts index 372abe0..138c797 100644 --- a/tests/unit/message-parser.test.ts +++ b/tests/unit/message-parser.test.ts @@ -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( + `${typeVal}${title}1 MBlegacy metadata${title}`, + 49 + ) + + expect(parsed).toMatchObject({ + type: 'share', + title, + typeVal + }) + } + ) + + it('does not classify empty incidental record metadata as a merged forward', () => { + const parsed = parseMessageContent( + '5普通分享legacy metadata', + 49 + ) + + expect(parsed).toMatchObject({ type: 'share', title: '普通分享', typeVal: '5' }) + }) + it('uses the quoted group member id instead of the chatroom id', () => { const parsed = parseMessageContent( '57回复内容1123456789@chatroomwxid_fixture_member被引用内容',