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
+130 -4
View File
@@ -9,7 +9,13 @@ const state = vi.hoisted(() => ({
accountRoot: '',
videoPath: '',
messages: [] as Message[],
imageLookups: [] as { allowThumbnail?: boolean; preferThumbnail?: boolean }[]
imageLookups: [] as {
allowThumbnail?: boolean
preferThumbnail?: boolean
sessionId?: string
sessionMd5?: string
createTime?: number
}[]
}))
vi.mock('electron', () => ({
@@ -23,7 +29,12 @@ vi.mock('../../src/main/services/chat-service', () => ({
getChatDb: () => ({
getWcdb4Client: () => ({ getAccountRoot: () => state.accountRoot })
}),
getContactAvatars: () => ({})
getContactAvatars: () => ({}),
getSelfAccountInfoAsync: async () => ({
wxid: 'a969409112',
nickname: '濑岛田井卫',
accountRoot: state.accountRoot
})
}))
vi.mock('../../src/main/services/image-key-config-service', () => ({
ImageKeyConfigService: class {
@@ -52,17 +63,42 @@ vi.mock('../../src/main/image-decrypt-service', () => ({
findImageFile(
_md5: string,
_datName: string,
options: { allowThumbnail?: boolean; preferThumbnail?: boolean }
options: {
allowThumbnail?: boolean
preferThumbnail?: boolean
sessionId?: string
sessionMd5?: string
createTime?: number
}
): string {
state.imageLookups.push(options)
return 'fixture-original.dat'
}
async findImageFileAsync(
md5: string,
datName: string,
options: {
allowThumbnail?: boolean
preferThumbnail?: boolean
sessionId?: string
sessionMd5?: string
createTime?: number
}
): Promise<string> {
return this.findImageFile(md5, datName, options)
}
decryptImageToBase64WithFallback(): { data: string; filePath: string } {
return {
data: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=',
filePath: 'fixture-original.dat'
}
}
async decryptImageToBase64WithFallbackAsync(): Promise<{
data: string
filePath: string
}> {
return this.decryptImageToBase64WithFallback()
}
isThumbnailFile(): boolean {
return false
}
@@ -200,7 +236,10 @@ describe('media export flow', () => {
expect(missingVoice.exportMediaError).toBe('语音文件缺失:本地未找到语音数据')
expect(state.imageLookups[0]).toMatchObject({
allowThumbnail: false,
preferThumbnail: false
preferThumbnail: false,
sessionId: 'fixture-session',
sessionMd5: 'fixture-user',
createTime: 1_785_549_600
})
expect(progress.length).toBeGreaterThan(0)
})
@@ -301,4 +340,91 @@ describe('media export flow', () => {
expect(exportMessageKey(first, 'fixture-user')).toBe(exportMessageKey(second, 'fixture-user'))
})
it('replaces a raw sender account with the hydrated self nickname', async () => {
const { runExport } = await import('../../src/main/export-service')
const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
state.messages = [
message({
id: 'self-message',
isSender: true,
senderId: 'a969409112',
name: 'a969409112',
content: '本人消息'
})
]
const result = await runExport(
{
jobId: 'self-name',
userMd5: 'fixture-user',
name: '本人昵称',
format: 'html',
outputName: 'self-name',
kinds: ['text'],
includeMedia: false,
nameMap: { a969409112: 'a969409112' }
},
win as never
)
expect(result.success).toBe(true)
expect(readArchive(result.outputPath!).messages[0]?.name).toBe('濑岛田井卫')
})
it('migrates raw self names already stored in an incremental HTML archive', async () => {
const { runExport } = await import('../../src/main/export-service')
const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
const request = {
userMd5: 'fixture-user',
name: '增量昵称',
format: 'html' as const,
outputName: 'incremental-self-name',
kinds: ['text'] as const,
includeMedia: false
}
state.messages = [
message({
id: 'old-self',
isSender: true,
senderId: 'a969409112',
name: 'a969409112',
content: '旧消息',
createTime: 1_785_549_600
})
]
const first = await runExport(
{ ...request, jobId: 'incremental-self-first', kinds: [...request.kinds] },
win as never
)
expect(first.success).toBe(true)
const firstData = readArchive(first.outputPath!)
firstData.messages[0].name = 'a969409112'
writeFileSync(
join(dirname(first.outputPath!), 'data', 'messages.js'),
`window.__WECHAT_EXPORT__ = ${JSON.stringify(firstData)};\n`,
'utf8'
)
state.messages = [
message({
id: 'new-self',
isSender: true,
senderId: 'a969409112',
name: '濑岛田井卫',
content: '新消息',
createTime: 1_785_549_700
})
]
const second = await runExport(
{ ...request, jobId: 'incremental-self-second', kinds: [...request.kinds] },
win as never
)
expect(second.success).toBe(true)
expect(readArchive(second.outputPath!).messages.map((item) => item.name)).toEqual([
'濑岛田井卫',
'濑岛田井卫'
])
})
})