mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 11:37:06 +08:00
feat: 优化聊天导出进度与媒体处理
1. 拆分读取、解析、转写、媒体处理、写入和压缩阶段,完善任务进度展示。 2. 增量导出复用语音资源与转写结果,并为缺失转写补充识别。 3. 稳定远程头像文件名并保留同源头像版本更新。 4. 支持音频附件直接播放、新窗口打开附件,并解码分享标题 XML 实体。 5. 补充导出进度、语音、头像、附件和消息解析回归测试。
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ExportPreviewPanel } from '../../src/renderer/src/components/export/ExportPreviewPanel'
|
||||
|
||||
const baseProps = {
|
||||
status: 'running' as const,
|
||||
previewItems: [],
|
||||
previewMediaCount: 0,
|
||||
previewBytes: 0,
|
||||
selfInfo: null,
|
||||
selectedCount: 1,
|
||||
jobId: 'fixture-job',
|
||||
onCancel: vi.fn(),
|
||||
onReveal: vi.fn()
|
||||
}
|
||||
|
||||
describe('export progress panel', () => {
|
||||
it('shows an indeterminate bar while the first message scan is still at zero', () => {
|
||||
render(
|
||||
<ExportPreviewPanel
|
||||
{...baseProps}
|
||||
progress={{
|
||||
jobId: 'fixture-job',
|
||||
phase: 'reading',
|
||||
processed: 0,
|
||||
percent: 0
|
||||
}}
|
||||
includeVoiceTranscripts={false}
|
||||
zip={false}
|
||||
/>
|
||||
)
|
||||
|
||||
const progressbar = screen.getByRole('progressbar', { name: '导出进度' })
|
||||
expect(progressbar).toHaveClass('indeterminate')
|
||||
expect(progressbar).not.toHaveAttribute('aria-valuenow')
|
||||
expect(progressbar).toHaveAttribute('aria-valuetext', '正在读取消息')
|
||||
})
|
||||
|
||||
it('adds voice transcription and ZIP stages only for an export that uses them', () => {
|
||||
render(
|
||||
<ExportPreviewPanel
|
||||
{...baseProps}
|
||||
progress={{
|
||||
jobId: 'fixture-job',
|
||||
phase: 'transcribing',
|
||||
processed: 3,
|
||||
total: 8,
|
||||
percent: 31
|
||||
}}
|
||||
includeVoiceTranscripts
|
||||
zip
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getAllByRole('listitem').map((item) => item.textContent)).toEqual([
|
||||
'准备导出',
|
||||
'分批读取聊天记录',
|
||||
'解析消息内容',
|
||||
'语音转文字',
|
||||
'处理媒体资源',
|
||||
'生成档案',
|
||||
'压缩 ZIP'
|
||||
])
|
||||
expect(screen.getByText('语音转文字')).toHaveClass('current')
|
||||
expect(screen.getByText('解析消息内容')).toHaveClass('done')
|
||||
expect(screen.getByText('处理媒体资源')).not.toHaveClass('done', 'current')
|
||||
expect(screen.getByText('正在转写语音 3/8... 31%')).toBeVisible()
|
||||
|
||||
const progressbar = screen.getByRole('progressbar', { name: '导出进度' })
|
||||
expect(progressbar).toHaveAttribute('aria-valuenow', '31')
|
||||
expect(progressbar.querySelector('span')).toHaveStyle({ width: '31%' })
|
||||
})
|
||||
})
|
||||
@@ -22,8 +22,9 @@ describe('export task center', () => {
|
||||
tasks={[
|
||||
{
|
||||
jobId: 'failed-export',
|
||||
contactId: 'fixture',
|
||||
contactName: '脱敏会话',
|
||||
targetIds: ['fixture'],
|
||||
targetNames: ['脱敏会话'],
|
||||
targetLabel: '脱敏会话',
|
||||
format: 'html',
|
||||
status: 'failed',
|
||||
progress: {
|
||||
@@ -41,7 +42,9 @@ describe('export task center', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('EPERM: operation not permitted, copyfile')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('失败原因:EPERM: operation not permitted, copyfile')
|
||||
).toBeInTheDocument()
|
||||
await userEvent.click(screen.getByRole('button', { name: '复制日志' }))
|
||||
|
||||
expect(writeText).toHaveBeenCalledOnce()
|
||||
|
||||
@@ -40,11 +40,15 @@ describe('export voice transcripts', () => {
|
||||
type: 'user'
|
||||
}
|
||||
]}
|
||||
selectedContact={null}
|
||||
previewMessages={[]}
|
||||
initialContact={{
|
||||
m_nsUsrName: 'filehelper',
|
||||
m_nsNickName: '文件传输助手',
|
||||
md5: 'fixture-contact',
|
||||
type: 'user'
|
||||
}}
|
||||
selfInfo={null}
|
||||
dbReady
|
||||
onSelectContact={vi.fn()}
|
||||
loadPreviewMessages={vi.fn().mockResolvedValue([])}
|
||||
onOpenSettings={vi.fn()}
|
||||
exportTasks={[]}
|
||||
onStartExport={onStartExport}
|
||||
|
||||
@@ -29,6 +29,7 @@ describe('ExportWorkspace multi-chat selection', () => {
|
||||
configurable: true,
|
||||
value: {
|
||||
onExportProgress: vi.fn(() => vi.fn()),
|
||||
getVoiceModelStatus: vi.fn().mockRejectedValue(new Error('fixture model unavailable')),
|
||||
getGroupSnapshot: vi.fn(async () => ({ members: [] })),
|
||||
cancelExport: vi.fn(async () => ({ success: true })),
|
||||
revealExport: vi.fn(async () => ({ success: true }))
|
||||
|
||||
@@ -269,7 +269,10 @@ describe('media export flow', () => {
|
||||
]
|
||||
})
|
||||
|
||||
afterEach(() => rmSync(state.documents, { recursive: true, force: true }))
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
rmSync(state.documents, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('writes playable relative assets, keeps failures, and requests the original image first', async () => {
|
||||
const { runExport } = await import('../../src/main/export-service')
|
||||
@@ -331,6 +334,56 @@ describe('media export flow', () => {
|
||||
expect(state.exportReads).toEqual(['fixture-user'])
|
||||
})
|
||||
|
||||
it('reports voice transcription as its own progress stage', async () => {
|
||||
const { runExport } = await import('../../src/main/export-service')
|
||||
state.messages = [
|
||||
message({
|
||||
id: 'voice-transcript-progress',
|
||||
type: '语音',
|
||||
sessionId: 'fixture-session',
|
||||
localId: 1,
|
||||
contentData: { type: 'voice', duration: 1 }
|
||||
})
|
||||
]
|
||||
const progress: { phase: string; processed: number; total?: number; percent?: number }[] = []
|
||||
const win = {
|
||||
isDestroyed: () => false,
|
||||
webContents: {
|
||||
send: (_channel: string, payload: (typeof progress)[number]) => progress.push(payload)
|
||||
}
|
||||
}
|
||||
const recognize = vi.fn(async () => ({ success: true as const, transcript: '固定转写文本' }))
|
||||
|
||||
const result = await runExport(
|
||||
{
|
||||
jobId: 'voice-transcript-progress',
|
||||
targets: [target()],
|
||||
format: 'html',
|
||||
outputName: 'voice-transcript-progress',
|
||||
kinds: ['voice'],
|
||||
includeMedia: true,
|
||||
includeVoiceTranscripts: true
|
||||
},
|
||||
win as never,
|
||||
{ recognize }
|
||||
)
|
||||
|
||||
expect(result.success, result.error).toBe(true)
|
||||
expect(readArchive(result.outputPath!).messages[0].voiceTranscript).toBe('固定转写文本')
|
||||
expect(recognize).toHaveBeenCalledOnce()
|
||||
const phases = progress.map((item) => item.phase)
|
||||
expect(phases).toContain('parsing')
|
||||
expect(phases).toContain('transcribing')
|
||||
expect(phases).toContain('media')
|
||||
expect(phases).toContain('writing')
|
||||
expect(phases.indexOf('transcribing')).toBeLessThan(phases.indexOf('media'))
|
||||
expect(phases.indexOf('media')).toBeLessThan(phases.indexOf('writing'))
|
||||
expect(progress.filter((item) => item.phase === 'transcribing').at(-1)).toMatchObject({
|
||||
processed: 1,
|
||||
total: 1
|
||||
})
|
||||
})
|
||||
|
||||
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() } }
|
||||
@@ -616,6 +669,62 @@ describe('media export flow', () => {
|
||||
).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('keeps remote avatar filenames stable across independent first exports', async () => {
|
||||
const { runExport } = await import('../../src/main/export-service')
|
||||
const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
|
||||
const remoteAvatar = 'https://wx.qlogo.cn/mmhead/stable-avatar/0'
|
||||
const responses = [
|
||||
Buffer.from('same-visual-encoding-one'),
|
||||
Buffer.from('same-visual-encoding-two')
|
||||
]
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(responses.shift(), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'image/jpeg' }
|
||||
})
|
||||
)
|
||||
)
|
||||
state.selfAvatar = remoteAvatar
|
||||
state.avatarMap = { a969409112: remoteAvatar }
|
||||
state.messages = [
|
||||
message({
|
||||
id: 'stable-remote-avatar',
|
||||
isSender: true,
|
||||
senderId: 'a969409112',
|
||||
content: '远程头像文件名应稳定'
|
||||
})
|
||||
]
|
||||
const request = {
|
||||
targets: [target('fixture-user', '远程头像会话')],
|
||||
format: 'html' as const,
|
||||
kinds: ['text'] as const,
|
||||
includeMedia: false,
|
||||
includeAvatars: true
|
||||
}
|
||||
|
||||
const first = await runExport(
|
||||
{ ...request, jobId: 'remote-avatar-first', outputName: 'remote-avatar-first' },
|
||||
win as never
|
||||
)
|
||||
const second = await runExport(
|
||||
{ ...request, jobId: 'remote-avatar-second', outputName: 'remote-avatar-second' },
|
||||
win as never
|
||||
)
|
||||
|
||||
expect(first.success, first.error).toBe(true)
|
||||
expect(second.success, second.error).toBe(true)
|
||||
const firstAvatarUrl = readArchive(first.outputPath!).messages[0].exportAvatarUrl
|
||||
const secondAvatarUrl = readArchive(second.outputPath!).messages[0].exportAvatarUrl
|
||||
expect(firstAvatarUrl).toBe('avatars/avatar_c24b49a201c894fc.jpg')
|
||||
expect(secondAvatarUrl).toBe(firstAvatarUrl)
|
||||
expect(readFileSync(join(dirname(first.outputPath!), firstAvatarUrl!))).not.toEqual(
|
||||
readFileSync(join(dirname(second.outputPath!), secondAvatarUrl!))
|
||||
)
|
||||
})
|
||||
|
||||
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() } }
|
||||
|
||||
@@ -236,12 +236,25 @@ describe('export media', () => {
|
||||
...messageForArchive('target-file', 'fixture', '定位消息', '', 4),
|
||||
type: '文件',
|
||||
exportMediaType: 'file',
|
||||
exportMediaUrl: 'files/target.pdf'
|
||||
exportMediaUrl: 'files/target.mp3',
|
||||
exportMediaName: 'target.mp3'
|
||||
},
|
||||
{
|
||||
...messageForArchive('target-document', 'fixture', '定位消息', '', 4.5),
|
||||
type: '文件',
|
||||
exportMediaType: 'file',
|
||||
exportMediaUrl: 'files/target.pdf',
|
||||
exportMediaName: 'target.pdf'
|
||||
},
|
||||
{
|
||||
...messageForArchive('target-share', 'fixture', '定位消息', '', 5),
|
||||
type: '分享',
|
||||
contentData: { type: 'share', typeVal: '5', title: '目标分享' }
|
||||
contentData: {
|
||||
type: 'share',
|
||||
typeVal: '5',
|
||||
title: '目标分享',
|
||||
url: 'https://example.com/shared'
|
||||
}
|
||||
},
|
||||
{
|
||||
...messageForArchive('target-system', 'fixture', '定位消息', '目标系统消息', 6),
|
||||
@@ -275,6 +288,19 @@ describe('export media', () => {
|
||||
for (const kind of ['media', 'voice', 'file', 'share', 'system']) {
|
||||
const filterButton = dom.window.document.querySelector(`[data-kind="${kind}"]`) as HTMLElement
|
||||
filterButton.click()
|
||||
if (kind === 'file' || kind === 'share') {
|
||||
const link = dom.window.document.querySelector(
|
||||
kind === 'file' ? '.file-attachment' : '.structured-link'
|
||||
)
|
||||
expect(link?.getAttribute('target')).toBe('_blank')
|
||||
expect(link?.getAttribute('rel')).toBe('noreferrer noopener')
|
||||
if (kind === 'file') {
|
||||
expect(link?.hasAttribute('download')).toBe(false)
|
||||
const audioPlayers = dom.window.document.querySelectorAll('.audio')
|
||||
expect(audioPlayers).toHaveLength(1)
|
||||
expect(audioPlayers[0].getAttribute('src')).toBe('files/target.mp3')
|
||||
}
|
||||
}
|
||||
const locateButton = dom.window.document.querySelector('.locate-all') as HTMLElement
|
||||
expect(locateButton?.getAttribute('aria-label')).toBe('定位到聊天位置')
|
||||
expect(locateButton?.querySelector('.locate-icon')?.textContent).toBe('⌖')
|
||||
@@ -320,7 +346,7 @@ describe('export media', () => {
|
||||
dom.window.close()
|
||||
})
|
||||
|
||||
it('keeps relative media, file download, quote, and missing-media renderers', () => {
|
||||
it('keeps relative media, new-window file links, quote, and missing-media renderers', () => {
|
||||
const html = renderExportPage('媒体档案')
|
||||
|
||||
expect(html).toContain('audio class="audio" controls preload="metadata"')
|
||||
|
||||
@@ -47,6 +47,19 @@ describe('message parser', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('decodes XML entities in file titles used for attachment lookup', () => {
|
||||
const parsed = parseMessageContent(
|
||||
'<appmsg><type>6</type><title>Check-in Voucher (Samabe Bali Suites & Villas).pdf</title></appmsg>',
|
||||
49
|
||||
)
|
||||
|
||||
expect(parsed).toMatchObject({
|
||||
type: 'share',
|
||||
title: 'Check-in Voucher (Samabe Bali Suites & Villas).pdf',
|
||||
typeVal: '6'
|
||||
})
|
||||
})
|
||||
|
||||
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>',
|
||||
|
||||
Reference in New Issue
Block a user