feat: 优化聊天导出进度与媒体处理

1. 拆分读取、解析、转写、媒体处理、写入和压缩阶段,完善任务进度展示。

2. 增量导出复用语音资源与转写结果,并为缺失转写补充识别。

3. 稳定远程头像文件名并保留同源头像版本更新。

4. 支持音频附件直接播放、新窗口打开附件,并解码分享标题 XML 实体。

5. 补充导出进度、语音、头像、附件和消息解析回归测试。
This commit is contained in:
Nanin
2026-08-05 23:22:47 +08:00
parent 3c59fb64e9
commit e3615c0153
16 changed files with 495 additions and 103 deletions
+13 -4
View File
@@ -1113,9 +1113,16 @@ const renderExportScript = (name: string): string => `
return '' return ''
} }
const playableAudioPattern = /\\.(?:mp3|wav|m4a|aac|ogg|oga|opus|flac|webm)(?:$|[?#])/i
const playableAudioFile = (name, url) =>
playableAudioPattern.test(String(name || '')) || playableAudioPattern.test(String(url || ''))
const renderAudioPlayer = (source) =>
'<div class="audio-wrap"><audio class="audio" controls preload="metadata" src="' + source + '"></audio></div>'
const renderMessage = (message, archiveIndex) => { const renderMessage = (message, archiveIndex) => {
const data = message.contentData || {} const data = message.contentData || {}
const mediaUrl = message.exportMediaUrl ? esc(message.exportMediaUrl) : '' const rawMediaUrl = message.exportMediaUrl ? String(message.exportMediaUrl) : ''
const mediaUrl = rawMediaUrl ? esc(rawMediaUrl) : ''
const mediaType = message.exportMediaType || data.type const mediaType = message.exportMediaType || data.type
let media = '' let media = ''
if (mediaUrl && mediaType === 'image') { if (mediaUrl && mediaType === 'image') {
@@ -1125,11 +1132,13 @@ const renderExportScript = (name: string): string => `
} else if (mediaUrl && mediaType === 'sticker') { } else if (mediaUrl && mediaType === 'sticker') {
media = '<img class="media-image" data-preview src="' + mediaUrl + '" alt="表情包">' media = '<img class="media-image" data-preview src="' + mediaUrl + '" alt="表情包">'
} else if (mediaUrl && mediaType === 'file') { } else if (mediaUrl && mediaType === 'file') {
const fileName = esc(message.exportMediaName || data.title || '下载文件') const rawFileName = message.exportMediaName || data.title || '打开文件'
media = '<a class="file-attachment" href="' + mediaUrl + '" download><span>📎</span><span>' + fileName + '</span></a>' const fileName = esc(rawFileName)
media = '<a class="file-attachment" href="' + mediaUrl + '" target="_blank" rel="noreferrer noopener"><span>📎</span><span>' + fileName + '</span></a>' +
(playableAudioFile(rawFileName, rawMediaUrl) ? renderAudioPlayer(mediaUrl) : '')
} }
const audio = message.voiceDataUrl const audio = message.voiceDataUrl
? '<div class="audio-wrap"><audio class="audio" controls preload="metadata" src="' + esc(message.voiceDataUrl) + '"></audio></div>' ? renderAudioPlayer(esc(message.voiceDataUrl))
: '' : ''
const voiceTranscript = message.voiceTranscript const voiceTranscript = message.voiceTranscript
? '<div class="voice-transcript">' + esc(message.voiceTranscript) + '</div>' ? '<div class="voice-transcript">' + esc(message.voiceTranscript) + '</div>'
+128 -60
View File
@@ -98,8 +98,10 @@ const fileHashPart = async (filePath: string, length = 16): Promise<string> => {
}) })
return hash.digest('hex').slice(0, length) return hash.digest('hex').slice(0, length)
} }
const avatarFileName = (buffer: Buffer, extension: string): string => const avatarFileName = (source: string, buffer: Buffer, extension: string): string =>
`avatar_${bufferHashPart(buffer)}.${extension}` `avatar_${/^https?:\/\//i.test(source) ? hashPart(source) : bufferHashPart(buffer)}.${extension}`
const avatarVersionFileName = (source: string, buffer: Buffer, extension: string): string =>
`avatar_${hashPart(source)}_${bufferHashPart(buffer)}.${extension}`
const avatarSourceHash = (source: string): string => hashPart(source, 24) const avatarSourceHash = (source: string): string => hashPart(source, 24)
const avatarIdentityKey = (conversationId: string, message: Message): string => const avatarIdentityKey = (conversationId: string, message: Message): string =>
`${conversationId}:${ `${conversationId}:${
@@ -690,6 +692,13 @@ export async function runExport(
if (/^wxid_/i.test(name)) return false if (/^wxid_/i.test(name)) return false
return true return true
} }
send({
jobId: request.jobId,
phase: 'parsing',
processed: 0,
total: messages.length,
percent: 12
})
for (const message of messages) { for (const message of messages) {
const target = targetById.get(message.exportConversationId || '') const target = targetById.get(message.exportConversationId || '')
const peerUsername = target ? client?.getUsernameByMd5(target.userMd5) || '' : '' const peerUsername = target ? client?.getUsernameByMd5(target.userMd5) || '' : ''
@@ -729,17 +738,23 @@ export async function runExport(
message.exportMediaError = '当前导出格式记录媒体状态,但不复制媒体文件' message.exportMediaError = '当前导出格式记录媒体状态,但不复制媒体文件'
} }
} }
send({ jobId: request.jobId, phase: 'reading', processed: 10, total: 100, percent: 10 }) send({
jobId: request.jobId,
phase: 'parsing',
processed: messages.length,
total: messages.length,
percent: 15
})
if (!jobs.has(request.jobId)) { if (!jobs.has(request.jobId)) {
send({ jobId: request.jobId, phase: 'cancelled', processed: 0, percent: 10 }) send({ jobId: request.jobId, phase: 'cancelled', processed: 0, percent: 10 })
return { success: false, error: '已取消' } return { success: false, error: '已取消' }
} }
send({ send({
jobId: request.jobId, jobId: request.jobId,
phase: 'writing', phase: request.format === 'html' ? 'parsing' : 'writing',
processed: 0, processed: 0,
total: messages.length, total: messages.length,
percent: 15 percent: request.format === 'html' ? 18 : 20
}) })
const root = join(app.getPath('documents'), 'WechatExplorer', '导出') const root = join(app.getPath('documents'), 'WechatExplorer', '导出')
await fs.mkdir(root, { recursive: true }) await fs.mkdir(root, { recursive: true })
@@ -846,8 +861,13 @@ export async function runExport(
} }
} }
const avatarName = avatarFileName(resolved.buffer, resolved.extension || 'jpg') const extension = resolved.extension || 'jpg'
const avatarUrl = `avatars/${avatarName}` let avatarName = avatarFileName(source, resolved.buffer, extension)
let avatarUrl = `avatars/${avatarName}`
if (avatarUrl === previousAvatarUrl) {
avatarName = avatarVersionFileName(source, resolved.buffer, extension)
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)
@@ -876,54 +896,76 @@ export async function runExport(
request.includeMedia && chat.getChatDb() request.includeMedia && chat.getChatDb()
? new VoiceService(chat.getChatDb()!.getWcdb4Client()) ? new VoiceService(chat.getChatDb()!.getWcdb4Client())
: null : null
const voiceMessages = messages.filter((message) => kindOf(message) === 'voice')
const voicePhase = request.includeVoiceTranscripts ? 'transcribing' : 'media'
const voiceProgressEnd = request.includeVoiceTranscripts ? 50 : 35
if (voiceService) { if (voiceService) {
for (const message of messages) { send({
jobId: request.jobId,
phase: voicePhase,
processed: 0,
total: voiceMessages.length,
percent: 20
})
for (const [voiceIndex, message] of voiceMessages.entries()) {
if (!jobs.has(request.jobId)) throw new Error('已取消') if (!jobs.has(request.jobId)) throw new Error('已取消')
if (kindOf(message) !== 'voice') continue
const previous = reusablePreviousMessages.get(message) const previous = reusablePreviousMessages.get(message)
let canTranscribe = true
if (previous?.voiceDataUrl && (await resourceExists(previous.voiceDataUrl))) { if (previous?.voiceDataUrl && (await resourceExists(previous.voiceDataUrl))) {
message.voiceDataUrl = previous.voiceDataUrl message.voiceDataUrl = previous.voiceDataUrl
message.voiceDuration = previous.voiceDuration message.voiceDuration = previous.voiceDuration
continue if (request.includeVoiceTranscripts && previous.voiceTranscript) {
} message.voiceTranscript = previous.voiceTranscript
if (!message.sessionId || message.localId == null || !message.createTime) { }
} else if (!message.sessionId || message.localId == null || !message.createTime) {
keepMediaError(request, message, '语音标识不完整,无法定位本地语音') keepMediaError(request, message, '语音标识不完整,无法定位本地语音')
continue canTranscribe = false
} } else {
try { try {
const voice = await voiceService.resolveVoice( const voice = await voiceService.resolveVoice(
message.sessionId, message.sessionId,
message.localId, message.localId,
message.createTime, message.createTime,
message.serverId message.serverId
) )
if (!voice.success || !voice.data) { if (!voice.success || !voice.data) {
const detail = voice.error || '未知原因' const detail = voice.error || '未知原因'
const reason = /未找到|不存在|获取语音数据失败/.test(detail) const reason = /未找到|不存在|获取语音数据失败/.test(detail)
? `语音文件缺失:${detail}` ? `${detail}`
: /Silk|解码|数据为空/.test(detail) : /Silk|解码|数据为空/.test(detail)
? `语音解析失败:${detail}` ? `${detail}`
: `语音格式不支持或读取失败:${detail}` : `${detail}`
keepMediaError(request, message, reason) keepMediaError(request, message, reason)
continue canTranscribe = false
}
const audioBuffer = Buffer.from(voice.data, 'base64')
const voiceName = `voice_${bufferHashPart(audioBuffer)}.wav`
const voiceUrl = `voices/${voiceName}`
if (!(await resourceExists(voiceUrl))) {
await fs.writeFile(join(outputDir, 'voices', voiceName), audioBuffer)
markResourceExists(voiceUrl)
}
message.voiceDataUrl = voiceUrl
message.voiceDuration = Math.max(1, Math.round(audioBuffer.length / (24000 * 2)))
if (request.includeVoiceTranscripts) {
if (!voiceRecognition) {
message.voiceTranscriptError = '语音转文字服务不可用'
} else { } else {
const audioBuffer = Buffer.from(voice.data, 'base64')
const voiceName = `voice_${bufferHashPart(audioBuffer)}.wav`
const voiceUrl = `voices/${voiceName}`
if (!(await resourceExists(voiceUrl))) {
await fs.writeFile(join(outputDir, 'voices', voiceName), audioBuffer)
markResourceExists(voiceUrl)
}
message.voiceDataUrl = voiceUrl
message.voiceDuration = Math.max(1, Math.round(audioBuffer.length / (24000 * 2)))
}
} catch (error) {
keepMediaError(
request,
message,
`${error instanceof Error ? error.message : String(error)}`
)
canTranscribe = false
}
}
if (request.includeVoiceTranscripts && canTranscribe && !message.voiceTranscript) {
if (!voiceRecognition) {
message.voiceTranscriptError = '语音转文字服务不可用'
} else {
try {
const recognition = await voiceRecognition.recognize({ const recognition = await voiceRecognition.recognize({
sessionId: message.sessionId, sessionId: message.sessionId!,
localId: message.localId, localId: message.localId!,
createTime: message.createTime, createTime: message.createTime!,
svrId: message.serverId svrId: message.serverId
}) })
if (recognition.success) { if (recognition.success) {
@@ -931,15 +973,23 @@ export async function runExport(
} else { } else {
message.voiceTranscriptError = recognition.error || '语音识别失败' message.voiceTranscriptError = recognition.error || '语音识别失败'
} }
} catch (error) {
message.voiceTranscriptError =
error instanceof Error ? error.message : '语音识别失败'
} }
} }
} catch (error) {
keepMediaError(
request,
message,
`语音文件写入失败:${error instanceof Error ? error.message : String(error)}`
)
} }
send({
jobId: request.jobId,
phase: voicePhase,
processed: voiceIndex + 1,
total: voiceMessages.length,
percent:
20 +
Math.round(
((voiceIndex + 1) / Math.max(voiceMessages.length, 1)) * (voiceProgressEnd - 20)
)
})
} }
} else if (request.includeMedia) { } else if (request.includeMedia) {
for (const message of messages) { for (const message of messages) {
@@ -948,6 +998,17 @@ export async function runExport(
} }
} }
} }
const mediaStartPercent = voiceService ? voiceProgressEnd : 20
const mediaPercent = (processed: number): number =>
mediaStartPercent +
Math.round((processed / Math.max(messages.length, 1)) * (90 - mediaStartPercent))
send({
jobId: request.jobId,
phase: 'media',
processed: 0,
total: messages.length,
percent: mediaStartPercent
})
for (const [index, message] of messages.entries()) { for (const [index, message] of messages.entries()) {
if (!jobs.has(request.jobId)) { if (!jobs.has(request.jobId)) {
send({ send({
@@ -955,7 +1016,7 @@ export async function runExport(
phase: 'cancelled', phase: 'cancelled',
processed: index, processed: index,
total: messages.length, total: messages.length,
percent: 15 + Math.round((index / Math.max(messages.length, 1)) * 75) percent: mediaPercent(index)
}) })
return { success: false, error: '已取消' } return { success: false, error: '已取消' }
} }
@@ -987,10 +1048,10 @@ export async function runExport(
if (!request.includeMedia || !message.contentData) { if (!request.includeMedia || !message.contentData) {
send({ send({
jobId: request.jobId, jobId: request.jobId,
phase: 'writing', phase: 'media',
processed: index + 1, processed: index + 1,
total: messages.length, total: messages.length,
percent: 15 + Math.round(((index + 1) / Math.max(messages.length, 1)) * 75) percent: mediaPercent(index + 1)
}) })
continue continue
} }
@@ -1013,10 +1074,10 @@ export async function runExport(
message.exportMediaName = previous.exportMediaName message.exportMediaName = previous.exportMediaName
send({ send({
jobId: request.jobId, jobId: request.jobId,
phase: 'writing', phase: 'media',
processed: index + 1, processed: index + 1,
total: messages.length, total: messages.length,
percent: 15 + Math.round(((index + 1) / Math.max(messages.length, 1)) * 75) percent: mediaPercent(index + 1)
}) })
continue continue
} }
@@ -1158,10 +1219,10 @@ export async function runExport(
} }
send({ send({
jobId: request.jobId, jobId: request.jobId,
phase: 'writing', phase: 'media',
processed: index + 1, processed: index + 1,
total: messages.length, total: messages.length,
percent: 15 + Math.round(((index + 1) / Math.max(messages.length, 1)) * 75) percent: mediaPercent(index + 1)
}) })
} }
const mergedMessages = mergeHtmlArchiveMessages( const mergedMessages = mergeHtmlArchiveMessages(
@@ -1192,6 +1253,13 @@ export async function runExport(
avatarVersions avatarVersions
} }
if (!jobs.has(request.jobId)) throw new Error('已取消') if (!jobs.has(request.jobId)) throw new Error('已取消')
send({
jobId: request.jobId,
phase: 'writing',
processed: archive.messages.length,
total: archive.messages.length,
percent: 92
})
await fs.writeFile(outputPath, renderExportPage(archiveName), 'utf8') await fs.writeFile(outputPath, renderExportPage(archiveName), 'utf8')
await writeHtmlArchive(outputDir, archive) await writeHtmlArchive(outputDir, archive)
await pruneHtmlArchiveResources(outputDir, archive) await pruneHtmlArchiveResources(outputDir, archive)
@@ -1204,7 +1272,7 @@ export async function runExport(
phase: 'compressing', phase: 'compressing',
processed: archive.messages.length, processed: archive.messages.length,
total: archive.messages.length, total: archive.messages.length,
percent: 95 percent: 96
}) })
await writeZipArchive(outputDir, zipPath, outputFolder, request.jobId) await writeZipArchive(outputDir, zipPath, outputFolder, request.jobId)
completedPath = zipPath completedPath = zipPath
+1 -1
View File
@@ -482,7 +482,7 @@ function parseShareMessage(content: string): ParsedContent {
} }
} }
const title = extractXmlValue(content, 'title') || '' const title = decodeXmlEntities(extractXmlValue(content, 'title')) || ''
const des = extractXmlValue(content, 'des') || extractXmlValue(content, 'desc') || '' const des = extractXmlValue(content, 'des') || extractXmlValue(content, 'desc') || ''
const url = extractXmlValue(content, 'url') || '' const url = extractXmlValue(content, 'url') || ''
const appname = extractXmlValue(content, 'appname') || extractXmlValue(content, 'appInfo') || '' const appname = extractXmlValue(content, 'appname') || extractXmlValue(content, 'appInfo') || ''
+2
View File
@@ -387,6 +387,8 @@ function App(): React.ReactElement {
targetNames, targetNames,
targetLabel, targetLabel,
format: request.format, format: request.format,
includeVoiceTranscripts: request.includeVoiceTranscripts,
zip: request.zip,
status: 'running', status: 'running',
progress: { jobId: request.jobId, phase: 'reading', processed: 0, percent: 0 }, progress: { jobId: request.jobId, phase: 'reading', processed: 0, percent: 0 },
createdAt: Date.now() createdAt: Date.now()
@@ -10,6 +10,8 @@ interface ExportPreviewPanelProps {
previewBytes: number previewBytes: number
selfInfo: SelfInfo | null selfInfo: SelfInfo | null
progress: ExportJobProgress | null progress: ExportJobProgress | null
includeVoiceTranscripts: boolean
zip: boolean
selectedCount: number selectedCount: number
jobId: string jobId: string
onCancel: (jobId: string) => void onCancel: (jobId: string) => void
@@ -23,11 +25,43 @@ export function ExportPreviewPanel({
previewBytes, previewBytes,
selfInfo, selfInfo,
progress, progress,
includeVoiceTranscripts,
zip,
selectedCount, selectedCount,
jobId, jobId,
onCancel, onCancel,
onReveal onReveal
}: ExportPreviewPanelProps): React.ReactElement { }: ExportPreviewPanelProps): React.ReactElement {
const percent = Math.max(0, Math.min(100, progress?.percent ?? 0))
const phase = progress?.phase || 'reading'
const showTranscriptStep = includeVoiceTranscripts || phase === 'transcribing'
const showZipStep = zip || phase === 'compressing'
const steps = [
{ phase: 'reading', label: '分批读取聊天记录' },
{ phase: 'parsing', label: '解析消息内容' },
...(showTranscriptStep ? [{ phase: 'transcribing', label: '语音转文字' }] : []),
{ phase: 'media', label: '处理媒体资源' },
{ phase: 'writing', label: '生成档案' },
...(showZipStep ? [{ phase: 'compressing', label: '压缩 ZIP' }] : [])
]
const currentStepIndex = Math.max(
0,
steps.findIndex((step) => step.phase === phase)
)
const indeterminate = phase === 'reading' && percent === 0
const progressText =
phase === 'compressing'
? `正在压缩资源包... ${percent}%`
: phase === 'writing'
? `正在生成档案... ${percent}%`
: phase === 'transcribing'
? `正在转写语音 ${progress?.processed ?? 0}/${progress?.total ?? 0}... ${percent}%`
: phase === 'media'
? `正在处理媒体资源 ${progress?.processed ?? 0}/${progress?.total ?? 0}... ${percent}%`
: phase === 'parsing'
? `正在解析消息内容... ${percent}%`
: `正在读取消息... ${percent}%`
return ( return (
<aside className={`export-preview-panel ${status !== 'idle' ? `status-${status}` : ''}`}> <aside className={`export-preview-panel ${status !== 'idle' ? `status-${status}` : ''}`}>
{status === 'idle' && ( {status === 'idle' && (
@@ -114,27 +148,29 @@ export function ExportPreviewPanel({
<p></p> <p></p>
<ol> <ol>
<li className="done"></li> <li className="done"></li>
<li className="current"> {steps.map((step, index) => (
{progress?.phase === 'compressing' <li
? '压缩 ZIP' key={step.phase}
: progress?.phase === 'writing' className={
? '生成档案' index < currentStepIndex ? 'done' : index === currentStepIndex ? 'current' : ''
: '分批读取聊天记录'} }
</li> >
<li></li> {step.label}
<li></li> </li>
<li></li> ))}
</ol> </ol>
<div className="export-progress-bar" aria-label="导出进度"> <div
<span style={{ width: `${progress?.percent ?? 0}%` }} /> className={`export-progress-bar ${indeterminate ? 'indeterminate' : ''}`}
role="progressbar"
aria-label="导出进度"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={indeterminate ? undefined : percent}
aria-valuetext={indeterminate ? '正在读取消息' : `${percent}%`}
>
<span style={indeterminate ? undefined : { width: `${percent}%` }} />
</div> </div>
<strong> <strong>{progressText}</strong>
{progress?.phase === 'compressing'
? `正在压缩资源包... ${progress.percent ?? 0}%`
: progress?.phase === 'writing'
? `正在写入 ${progress.processed.toLocaleString()} 条消息... ${progress.percent ?? 0}%`
: `正在读取消息... ${progress?.percent ?? 0}%`}
</strong>
<button type="button" className="export-cancel-button" onClick={() => onCancel(jobId)}> <button type="button" className="export-cancel-button" onClick={() => onCancel(jobId)}>
</button> </button>
@@ -11,7 +11,10 @@ interface ExportTaskCenterProps {
const phaseLabels: Record<ExportTaskRecord['progress']['phase'], string> = { const phaseLabels: Record<ExportTaskRecord['progress']['phase'], string> = {
reading: '读取消息', reading: '读取消息',
writing: '导出资源', parsing: '解析消息',
media: '处理媒体',
transcribing: '语音转文字',
writing: '生成档案',
compressing: '压缩归档', compressing: '压缩归档',
completed: '已完成', completed: '已完成',
cancelled: '已取消', cancelled: '已取消',
@@ -63,6 +63,10 @@ export function ExportWorkspace({
const [status, setStatus] = useState<ExportStatus>('idle') const [status, setStatus] = useState<ExportStatus>('idle')
const [jobId, setJobId] = useState('') const [jobId, setJobId] = useState('')
const [progress, setProgress] = useState<ExportJobProgress | null>(null) const [progress, setProgress] = useState<ExportJobProgress | null>(null)
const [activeJobOptions, setActiveJobOptions] = useState({
includeVoiceTranscripts: false,
zip: false
})
const [taskCenterOpen, setTaskCenterOpen] = useState(false) const [taskCenterOpen, setTaskCenterOpen] = useState(false)
const selectionLimit = 5 const selectionLimit = 5
@@ -213,8 +217,16 @@ export function ExportWorkspace({
if (!activeContact || selectedContacts.length === 0 || status === 'running') return if (!activeContact || selectedContacts.length === 0 || status === 'running') return
// Runs only from the export button event; a fresh id is required for each job. // Runs only from the export button event; a fresh id is required for each job.
const nextJobId = `export-${Date.now()}` const nextJobId = `export-${Date.now()}`
const exportFormat = selectedContacts.length > 1 ? 'html' : format
const shouldIncludeVoiceTranscripts =
includeVoiceTranscripts &&
includeMedia &&
exportFormat === 'html' &&
selectedKinds.has('voice') &&
voiceModelStatus?.state === 'ready'
setJobId(nextJobId) setJobId(nextJobId)
setProgress(null) setProgress(null)
setActiveJobOptions({ includeVoiceTranscripts: shouldIncludeVoiceTranscripts, zip })
setStatus('running') setStatus('running')
const targets: ExportTarget[] = await Promise.all( const targets: ExportTarget[] = await Promise.all(
selectedContacts.map(async (contact) => { selectedContacts.map(async (contact) => {
@@ -262,7 +274,7 @@ export function ExportWorkspace({
const request: ExportRequest = { const request: ExportRequest = {
jobId: nextJobId, jobId: nextJobId,
targets, targets,
format: selectedContacts.length > 1 ? 'html' : format, format: exportFormat,
outputName, outputName,
startTime: startOfRange startTime: startOfRange
? Math.floor(startOfRange.getTime() / 1000) ? Math.floor(startOfRange.getTime() / 1000)
@@ -276,12 +288,7 @@ export function ExportWorkspace({
: undefined, : undefined,
kinds: Array.from(selectedKinds) as ExportMessageKind[], kinds: Array.from(selectedKinds) as ExportMessageKind[],
includeMedia, includeMedia,
includeVoiceTranscripts: includeVoiceTranscripts: shouldIncludeVoiceTranscripts,
includeVoiceTranscripts &&
includeMedia &&
format === 'html' &&
selectedKinds.has('voice') &&
voiceModelStatus?.state === 'ready',
preferOriginal, preferOriginal,
fallbackThumbnail, fallbackThumbnail,
keepMissing, keepMissing,
@@ -319,6 +326,10 @@ export function ExportWorkspace({
if (!currentTask) return if (!currentTask) return
setJobId(currentTask.jobId) setJobId(currentTask.jobId)
setProgress(currentTask.progress) setProgress(currentTask.progress)
setActiveJobOptions({
includeVoiceTranscripts: currentTask.includeVoiceTranscripts === true,
zip: currentTask.zip === true
})
setStatus( setStatus(
currentTask.status === 'running' currentTask.status === 'running'
? 'running' ? 'running'
@@ -349,6 +360,7 @@ export function ExportWorkspace({
setStatus('idle') setStatus('idle')
setJobId('') setJobId('')
setProgress(null) setProgress(null)
setActiveJobOptions({ includeVoiceTranscripts: false, zip: false })
} }
const targetPath = const targetPath =
@@ -686,6 +698,8 @@ export function ExportWorkspace({
previewBytes={previewBytes} previewBytes={previewBytes}
selfInfo={selfInfo} selfInfo={selfInfo}
progress={progress} progress={progress}
includeVoiceTranscripts={activeJobOptions.includeVoiceTranscripts}
zip={activeJobOptions.zip}
selectedCount={selectedContacts.length} selectedCount={selectedContacts.length}
jobId={jobId} jobId={jobId}
onCancel={(exportJobId) => { onCancel={(exportJobId) => {
+20
View File
@@ -1009,6 +1009,26 @@
background: var(--wxex-brand); background: var(--wxex-brand);
transition: width 0.2s ease; transition: width 0.2s ease;
} }
&.indeterminate span {
width: 24%;
animation: export-progress-indeterminate 1.2s ease-in-out infinite;
}
}
@keyframes export-progress-indeterminate {
from {
transform: translateX(-110%);
}
to {
transform: translateX(430%);
}
}
@media (prefers-reduced-motion: reduce) {
.export-progress-bar.indeterminate span {
width: 8%;
animation: none;
}
} }
.export-job-state > strong { .export-job-state > strong {
color: var(--wxex-text-secondary); color: var(--wxex-text-secondary);
+12 -1
View File
@@ -43,7 +43,16 @@ export interface ExportRequest {
export interface ExportJobProgress { export interface ExportJobProgress {
jobId: string jobId: string
phase: 'reading' | 'writing' | 'compressing' | 'completed' | 'cancelled' | 'failed' phase:
| 'reading'
| 'parsing'
| 'media'
| 'transcribing'
| 'writing'
| 'compressing'
| 'completed'
| 'cancelled'
| 'failed'
processed: number processed: number
total?: number total?: number
percent?: number percent?: number
@@ -57,6 +66,8 @@ export interface ExportTaskRecord {
targetNames: string[] targetNames: string[]
targetLabel: string targetLabel: string
format: ExportFormat format: ExportFormat
includeVoiceTranscripts?: boolean
zip?: boolean
status: 'running' | 'completed' | 'cancelled' | 'failed' status: 'running' | 'completed' | 'cancelled' | 'failed'
progress: ExportJobProgress progress: ExportJobProgress
createdAt: number createdAt: number
@@ -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%' })
})
})
+6 -3
View File
@@ -22,8 +22,9 @@ describe('export task center', () => {
tasks={[ tasks={[
{ {
jobId: 'failed-export', jobId: 'failed-export',
contactId: 'fixture', targetIds: ['fixture'],
contactName: '脱敏会话', targetNames: ['脱敏会话'],
targetLabel: '脱敏会话',
format: 'html', format: 'html',
status: 'failed', status: 'failed',
progress: { 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: '复制日志' })) await userEvent.click(screen.getByRole('button', { name: '复制日志' }))
expect(writeText).toHaveBeenCalledOnce() expect(writeText).toHaveBeenCalledOnce()
@@ -40,11 +40,15 @@ describe('export voice transcripts', () => {
type: 'user' type: 'user'
} }
]} ]}
selectedContact={null} initialContact={{
previewMessages={[]} m_nsUsrName: 'filehelper',
m_nsNickName: '文件传输助手',
md5: 'fixture-contact',
type: 'user'
}}
selfInfo={null} selfInfo={null}
dbReady dbReady
onSelectContact={vi.fn()} loadPreviewMessages={vi.fn().mockResolvedValue([])}
onOpenSettings={vi.fn()} onOpenSettings={vi.fn()}
exportTasks={[]} exportTasks={[]}
onStartExport={onStartExport} onStartExport={onStartExport}
@@ -29,6 +29,7 @@ describe('ExportWorkspace multi-chat selection', () => {
configurable: true, configurable: true,
value: { value: {
onExportProgress: vi.fn(() => vi.fn()), onExportProgress: vi.fn(() => vi.fn()),
getVoiceModelStatus: vi.fn().mockRejectedValue(new Error('fixture model unavailable')),
getGroupSnapshot: vi.fn(async () => ({ members: [] })), getGroupSnapshot: vi.fn(async () => ({ members: [] })),
cancelExport: vi.fn(async () => ({ success: true })), cancelExport: vi.fn(async () => ({ success: true })),
revealExport: vi.fn(async () => ({ success: true })) revealExport: vi.fn(async () => ({ success: true }))
+110 -1
View File
@@ -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 () => { it('writes playable relative assets, keeps failures, and requests the original image first', async () => {
const { runExport } = await import('../../src/main/export-service') const { runExport } = await import('../../src/main/export-service')
@@ -331,6 +334,56 @@ describe('media export flow', () => {
expect(state.exportReads).toEqual(['fixture-user']) 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 () => { it('uses the customized file name as the HTML archive title', 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() } }
@@ -616,6 +669,62 @@ describe('media export flow', () => {
).toHaveLength(2) ).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 () => { 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() } }
+29 -3
View File
@@ -236,12 +236,25 @@ describe('export media', () => {
...messageForArchive('target-file', 'fixture', '定位消息', '', 4), ...messageForArchive('target-file', 'fixture', '定位消息', '', 4),
type: '文件', type: '文件',
exportMediaType: 'file', 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), ...messageForArchive('target-share', 'fixture', '定位消息', '', 5),
type: '分享', type: '分享',
contentData: { type: 'share', typeVal: '5', title: '目标分享' } contentData: {
type: 'share',
typeVal: '5',
title: '目标分享',
url: 'https://example.com/shared'
}
}, },
{ {
...messageForArchive('target-system', 'fixture', '定位消息', '目标系统消息', 6), ...messageForArchive('target-system', 'fixture', '定位消息', '目标系统消息', 6),
@@ -275,6 +288,19 @@ describe('export media', () => {
for (const kind of ['media', 'voice', 'file', 'share', 'system']) { for (const kind of ['media', 'voice', 'file', 'share', 'system']) {
const filterButton = dom.window.document.querySelector(`[data-kind="${kind}"]`) as HTMLElement const filterButton = dom.window.document.querySelector(`[data-kind="${kind}"]`) as HTMLElement
filterButton.click() 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 const locateButton = dom.window.document.querySelector('.locate-all') as HTMLElement
expect(locateButton?.getAttribute('aria-label')).toBe('定位到聊天位置') expect(locateButton?.getAttribute('aria-label')).toBe('定位到聊天位置')
expect(locateButton?.querySelector('.locate-icon')?.textContent).toBe('⌖') expect(locateButton?.querySelector('.locate-icon')?.textContent).toBe('⌖')
@@ -320,7 +346,7 @@ describe('export media', () => {
dom.window.close() 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('媒体档案') const html = renderExportPage('媒体档案')
expect(html).toContain('audio class="audio" controls preload="metadata"') expect(html).toContain('audio class="audio" controls preload="metadata"')
+13
View File
@@ -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 &amp; 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', () => { it('does not classify empty incidental record metadata as a merged forward', () => {
const parsed = parseMessageContent( const parsed = parseMessageContent(
'<appmsg><type>5</type><title>普通分享</title><recorditem>legacy metadata</recorditem></appmsg>', '<appmsg><type>5</type><title>普通分享</title><recorditem>legacy metadata</recorditem></appmsg>',