fix: 修复媒体导出与 HTML 聊天档案体验

- 修复打包版图片、语音解析不可用,内置 FFmpeg 与必要运行时依赖
- 修复 HTML 导出原图查找、缩略图回退及增量档案媒体解析问题
- 修复本人昵称在软件和 HTML 导出中显示为微信号的问题,并兼容旧档案昵称迁移
- 修复 HTML 语音播放器超出消息气泡的问题
- 优化 HTML 双向滚动加载,大量消息时最多渲染 240 条,避免页面卡顿
- 移除图片解密页面中手动配置 FFmpeg 的相关提示
- 补充图片解密、语音运行时、打包资源和 HTML 导出相关测试
This commit is contained in:
Wxw-Gu
2026-08-04 17:03:07 +08:00
parent c6587c517a
commit 0a3d930298
24 changed files with 949 additions and 515 deletions
+53 -18
View File
@@ -147,6 +147,7 @@ body {
}
.avatar img { width: 100%; height: 100%; object-fit: cover; }
.bubble {
min-width: 0;
max-width: min(78%, 760px);
padding: 13px 15px;
border: 1px solid var(--border);
@@ -157,9 +158,8 @@ body {
.sent .bubble { background: var(--mine); border-color: #c7e6d4; border-radius: 18px 10px 18px 18px; }
.sender { color: var(--muted); font-size: 12px; margin-bottom: 5px; }
.content { line-height: 1.7; word-break: break-word; white-space: pre-wrap; }
.audio-wrap, .audio { width: 260px; }
.audio-wrap { min-width: 260px; }
.audio { display: block; height: 38px; }
.audio-wrap { width: 260px; max-width: 100%; min-width: 0; }
.audio { display: block; width: 100%; max-width: 100%; height: 38px; }
.media-status {
margin-top: 8px;
padding: 6px 8px;
@@ -263,7 +263,7 @@ body {
border-bottom-color: var(--accent);
}
.bubble { max-width: calc(100vw - 92px); }
.audio-wrap, .audio { width: min(260px, calc(100vw - 130px)); min-width: 0; }
.audio-wrap { width: min(260px, calc(100vw - 130px)); }
}
`
@@ -296,9 +296,19 @@ const renderExportScript = (name: string): string => `
let filtered = []
let windowStart = 0
let windowEnd = 0
let loading = false
let scrollLoadPending = false
let scrollLoadSuppressed = false
let lastScrollTop = 0
let zoom = 1
const nextFrame = (callback) => {
if (typeof window.requestAnimationFrame === 'function') {
window.requestAnimationFrame(callback)
} else {
window.setTimeout(callback, 0)
}
}
const esc = (value) => String(value ?? '').replace(
/[&<>"']/g,
(character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[character] || character
@@ -423,6 +433,18 @@ const renderExportScript = (name: string): string => `
const shown = Math.max(0, windowEnd - windowStart)
count.textContent = '已显示 ' + shown + ' / 筛选 ' + filtered.length + ' / 全部 ' + allMessages.length
}
const setScrollTop = (value) => {
scrollLoadSuppressed = true
const previousBehavior = list.style.scrollBehavior
list.style.scrollBehavior = 'auto'
list.scrollTop = value
lastScrollTop = list.scrollTop
nextFrame(() => {
list.style.scrollBehavior = previousBehavior
lastScrollTop = list.scrollTop
scrollLoadSuppressed = false
})
}
const renderWindow = (anchorIndex, anchorOffset) => {
const visible = filtered.slice(windowStart, windowEnd)
const before = windowStart > 0 ? '<div class="lazy-hint">向上滚动加载更早消息</div>' : ''
@@ -432,7 +454,7 @@ const renderExportScript = (name: string): string => `
: '<div class="empty">没有符合条件的消息<br><small>可以更换筛选条件或关键词</small></div>'
if (Number.isInteger(anchorIndex)) {
const anchor = list.querySelector('.message[data-index="' + anchorIndex + '"]')
if (anchor) list.scrollTop = anchor.offsetTop - anchorOffset
if (anchor) setScrollTop(anchor.offsetTop - anchorOffset)
}
updateCount()
updateActiveMonth()
@@ -445,7 +467,7 @@ const renderExportScript = (name: string): string => `
windowEnd = Math.min(filtered.length, PAGE_SIZE)
}
renderWindow()
list.scrollTop = preferLatest ? list.scrollHeight : 0
setScrollTop(preferLatest ? list.scrollHeight : 0)
}
const applyFilters = () => {
const term = query.value.trim().toLowerCase()
@@ -464,7 +486,7 @@ const renderExportScript = (name: string): string => `
windowStart = Math.max(0, windowEnd - PAGE_SIZE)
renderWindow()
const target = list.querySelector('.message[data-index="' + index + '"]')
list.scrollTop = target ? Math.max(0, target.offsetTop - 24) : 0
setScrollTop(target ? Math.max(0, target.offsetTop - 24) : 0)
timeline.querySelectorAll('.timeline-month').forEach((button) => {
button.classList.toggle('active', button.dataset.month === key)
})
@@ -485,18 +507,31 @@ const renderExportScript = (name: string): string => `
renderWindow(anchorIndex, anchorOffset)
}
const scheduleWindowSlide = (direction) => {
if (scrollLoadPending) return
scrollLoadPending = true
nextFrame(() => {
if (direction < 0 ? windowStart > 0 : windowEnd < filtered.length) {
slideWindow(direction)
}
scrollLoadPending = false
})
}
list.addEventListener('scroll', () => {
if (loading) return
if (list.scrollTop < 180 && windowStart > 0) {
loading = true
slideWindow(-1)
loading = false
} else if (list.scrollHeight - list.scrollTop - list.clientHeight < 240 && windowEnd < filtered.length) {
loading = true
slideWindow(1)
loading = false
}
const currentTop = list.scrollTop
const movingUp = currentTop < lastScrollTop
const nearTop = currentTop < 180
const nearBottom = list.scrollHeight - currentTop - list.clientHeight < 240
lastScrollTop = currentTop
if (scrollLoadSuppressed) return
if (movingUp && nearTop) scheduleWindowSlide(-1)
else if (!movingUp && nearBottom) scheduleWindowSlide(1)
})
list.addEventListener('wheel', (event) => {
if (!scrollLoadSuppressed && event.deltaY < 0 && list.scrollTop <= 1) {
scheduleWindowSlide(-1)
}
}, { passive: true })
query.addEventListener('input', applyFilters)
filters.addEventListener('click', (event) => {
const button = event.target.closest('[data-kind]')