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 ''
}
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 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
let media = ''
if (mediaUrl && mediaType === 'image') {
@@ -1125,11 +1132,13 @@ const renderExportScript = (name: string): string => `
} else if (mediaUrl && mediaType === 'sticker') {
media = '<img class="media-image" data-preview src="' + mediaUrl + '" alt="表情包">'
} else if (mediaUrl && mediaType === 'file') {
const fileName = esc(message.exportMediaName || data.title || '下载文件')
media = '<a class="file-attachment" href="' + mediaUrl + '" download><span>📎</span><span>' + fileName + '</span></a>'
const rawFileName = message.exportMediaName || data.title || '打开文件'
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
? '<div class="audio-wrap"><audio class="audio" controls preload="metadata" src="' + esc(message.voiceDataUrl) + '"></audio></div>'
? renderAudioPlayer(esc(message.voiceDataUrl))
: ''
const voiceTranscript = message.voiceTranscript
? '<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)
}
const avatarFileName = (buffer: Buffer, extension: string): string =>
`avatar_${bufferHashPart(buffer)}.${extension}`
const avatarFileName = (source: string, buffer: Buffer, extension: string): string =>
`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 avatarIdentityKey = (conversationId: string, message: Message): string =>
`${conversationId}:${
@@ -690,6 +692,13 @@ export async function runExport(
if (/^wxid_/i.test(name)) return false
return true
}
send({
jobId: request.jobId,
phase: 'parsing',
processed: 0,
total: messages.length,
percent: 12
})
for (const message of messages) {
const target = targetById.get(message.exportConversationId || '')
const peerUsername = target ? client?.getUsernameByMd5(target.userMd5) || '' : ''
@@ -729,17 +738,23 @@ export async function runExport(
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)) {
send({ jobId: request.jobId, phase: 'cancelled', processed: 0, percent: 10 })
return { success: false, error: '已取消' }
}
send({
jobId: request.jobId,
phase: 'writing',
phase: request.format === 'html' ? 'parsing' : 'writing',
processed: 0,
total: messages.length,
percent: 15
percent: request.format === 'html' ? 18 : 20
})
const root = join(app.getPath('documents'), 'WechatExplorer', '导出')
await fs.mkdir(root, { recursive: true })
@@ -846,8 +861,13 @@ export async function runExport(
}
}
const avatarName = avatarFileName(resolved.buffer, resolved.extension || 'jpg')
const avatarUrl = `avatars/${avatarName}`
const extension = resolved.extension || 'jpg'
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))) {
await fs.writeFile(join(outputDir, 'avatars', avatarName), resolved.buffer)
markResourceExists(avatarUrl)
@@ -876,54 +896,76 @@ export async function runExport(
request.includeMedia && chat.getChatDb()
? new VoiceService(chat.getChatDb()!.getWcdb4Client())
: null
const voiceMessages = messages.filter((message) => kindOf(message) === 'voice')
const voicePhase = request.includeVoiceTranscripts ? 'transcribing' : 'media'
const voiceProgressEnd = request.includeVoiceTranscripts ? 50 : 35
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 (kindOf(message) !== 'voice') continue
const previous = reusablePreviousMessages.get(message)
let canTranscribe = true
if (previous?.voiceDataUrl && (await resourceExists(previous.voiceDataUrl))) {
message.voiceDataUrl = previous.voiceDataUrl
message.voiceDuration = previous.voiceDuration
continue
}
if (!message.sessionId || message.localId == null || !message.createTime) {
if (request.includeVoiceTranscripts && previous.voiceTranscript) {
message.voiceTranscript = previous.voiceTranscript
}
} else if (!message.sessionId || message.localId == null || !message.createTime) {
keepMediaError(request, message, '语音标识不完整,无法定位本地语音')
continue
}
try {
const voice = await voiceService.resolveVoice(
message.sessionId,
message.localId,
message.createTime,
message.serverId
)
if (!voice.success || !voice.data) {
const detail = voice.error || '未知原因'
const reason = /未找到|不存在|获取语音数据失败/.test(detail)
? `语音文件缺失:${detail}`
: /Silk|解码|数据为空/.test(detail)
? `语音解析失败:${detail}`
: `语音格式不支持或读取失败:${detail}`
keepMediaError(request, message, reason)
continue
}
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 = '语音转文字服务不可用'
canTranscribe = false
} else {
try {
const voice = await voiceService.resolveVoice(
message.sessionId,
message.localId,
message.createTime,
message.serverId
)
if (!voice.success || !voice.data) {
const detail = voice.error || '未知原因'
const reason = /未找到|不存在|获取语音数据失败/.test(detail)
? `${detail}`
: /Silk|解码|数据为空/.test(detail)
? `${detail}`
: `${detail}`
keepMediaError(request, message, reason)
canTranscribe = false
} 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({
sessionId: message.sessionId,
localId: message.localId,
createTime: message.createTime,
sessionId: message.sessionId!,
localId: message.localId!,
createTime: message.createTime!,
svrId: message.serverId
})
if (recognition.success) {
@@ -931,15 +973,23 @@ export async function runExport(
} else {
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) {
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()) {
if (!jobs.has(request.jobId)) {
send({
@@ -955,7 +1016,7 @@ export async function runExport(
phase: 'cancelled',
processed: index,
total: messages.length,
percent: 15 + Math.round((index / Math.max(messages.length, 1)) * 75)
percent: mediaPercent(index)
})
return { success: false, error: '已取消' }
}
@@ -987,10 +1048,10 @@ export async function runExport(
if (!request.includeMedia || !message.contentData) {
send({
jobId: request.jobId,
phase: 'writing',
phase: 'media',
processed: index + 1,
total: messages.length,
percent: 15 + Math.round(((index + 1) / Math.max(messages.length, 1)) * 75)
percent: mediaPercent(index + 1)
})
continue
}
@@ -1013,10 +1074,10 @@ export async function runExport(
message.exportMediaName = previous.exportMediaName
send({
jobId: request.jobId,
phase: 'writing',
phase: 'media',
processed: index + 1,
total: messages.length,
percent: 15 + Math.round(((index + 1) / Math.max(messages.length, 1)) * 75)
percent: mediaPercent(index + 1)
})
continue
}
@@ -1158,10 +1219,10 @@ export async function runExport(
}
send({
jobId: request.jobId,
phase: 'writing',
phase: 'media',
processed: index + 1,
total: messages.length,
percent: 15 + Math.round(((index + 1) / Math.max(messages.length, 1)) * 75)
percent: mediaPercent(index + 1)
})
}
const mergedMessages = mergeHtmlArchiveMessages(
@@ -1192,6 +1253,13 @@ export async function runExport(
avatarVersions
}
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 writeHtmlArchive(outputDir, archive)
await pruneHtmlArchiveResources(outputDir, archive)
@@ -1204,7 +1272,7 @@ export async function runExport(
phase: 'compressing',
processed: archive.messages.length,
total: archive.messages.length,
percent: 95
percent: 96
})
await writeZipArchive(outputDir, zipPath, outputFolder, request.jobId)
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 url = extractXmlValue(content, 'url') || ''
const appname = extractXmlValue(content, 'appname') || extractXmlValue(content, 'appInfo') || ''
+2
View File
@@ -387,6 +387,8 @@ function App(): React.ReactElement {
targetNames,
targetLabel,
format: request.format,
includeVoiceTranscripts: request.includeVoiceTranscripts,
zip: request.zip,
status: 'running',
progress: { jobId: request.jobId, phase: 'reading', processed: 0, percent: 0 },
createdAt: Date.now()
@@ -10,6 +10,8 @@ interface ExportPreviewPanelProps {
previewBytes: number
selfInfo: SelfInfo | null
progress: ExportJobProgress | null
includeVoiceTranscripts: boolean
zip: boolean
selectedCount: number
jobId: string
onCancel: (jobId: string) => void
@@ -23,11 +25,43 @@ export function ExportPreviewPanel({
previewBytes,
selfInfo,
progress,
includeVoiceTranscripts,
zip,
selectedCount,
jobId,
onCancel,
onReveal
}: 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 (
<aside className={`export-preview-panel ${status !== 'idle' ? `status-${status}` : ''}`}>
{status === 'idle' && (
@@ -114,27 +148,29 @@ export function ExportPreviewPanel({
<p></p>
<ol>
<li className="done"></li>
<li className="current">
{progress?.phase === 'compressing'
? '压缩 ZIP'
: progress?.phase === 'writing'
? '生成档案'
: '分批读取聊天记录'}
</li>
<li></li>
<li></li>
<li></li>
{steps.map((step, index) => (
<li
key={step.phase}
className={
index < currentStepIndex ? 'done' : index === currentStepIndex ? 'current' : ''
}
>
{step.label}
</li>
))}
</ol>
<div className="export-progress-bar" aria-label="导出进度">
<span style={{ width: `${progress?.percent ?? 0}%` }} />
<div
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>
<strong>
{progress?.phase === 'compressing'
? `正在压缩资源包... ${progress.percent ?? 0}%`
: progress?.phase === 'writing'
? `正在写入 ${progress.processed.toLocaleString()} 条消息... ${progress.percent ?? 0}%`
: `正在读取消息... ${progress?.percent ?? 0}%`}
</strong>
<strong>{progressText}</strong>
<button type="button" className="export-cancel-button" onClick={() => onCancel(jobId)}>
</button>
@@ -11,7 +11,10 @@ interface ExportTaskCenterProps {
const phaseLabels: Record<ExportTaskRecord['progress']['phase'], string> = {
reading: '读取消息',
writing: '导出资源',
parsing: '解析消息',
media: '处理媒体',
transcribing: '语音转文字',
writing: '生成档案',
compressing: '压缩归档',
completed: '已完成',
cancelled: '已取消',
@@ -63,6 +63,10 @@ export function ExportWorkspace({
const [status, setStatus] = useState<ExportStatus>('idle')
const [jobId, setJobId] = useState('')
const [progress, setProgress] = useState<ExportJobProgress | null>(null)
const [activeJobOptions, setActiveJobOptions] = useState({
includeVoiceTranscripts: false,
zip: false
})
const [taskCenterOpen, setTaskCenterOpen] = useState(false)
const selectionLimit = 5
@@ -213,8 +217,16 @@ export function ExportWorkspace({
if (!activeContact || selectedContacts.length === 0 || status === 'running') return
// Runs only from the export button event; a fresh id is required for each job.
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)
setProgress(null)
setActiveJobOptions({ includeVoiceTranscripts: shouldIncludeVoiceTranscripts, zip })
setStatus('running')
const targets: ExportTarget[] = await Promise.all(
selectedContacts.map(async (contact) => {
@@ -262,7 +274,7 @@ export function ExportWorkspace({
const request: ExportRequest = {
jobId: nextJobId,
targets,
format: selectedContacts.length > 1 ? 'html' : format,
format: exportFormat,
outputName,
startTime: startOfRange
? Math.floor(startOfRange.getTime() / 1000)
@@ -276,12 +288,7 @@ export function ExportWorkspace({
: undefined,
kinds: Array.from(selectedKinds) as ExportMessageKind[],
includeMedia,
includeVoiceTranscripts:
includeVoiceTranscripts &&
includeMedia &&
format === 'html' &&
selectedKinds.has('voice') &&
voiceModelStatus?.state === 'ready',
includeVoiceTranscripts: shouldIncludeVoiceTranscripts,
preferOriginal,
fallbackThumbnail,
keepMissing,
@@ -319,6 +326,10 @@ export function ExportWorkspace({
if (!currentTask) return
setJobId(currentTask.jobId)
setProgress(currentTask.progress)
setActiveJobOptions({
includeVoiceTranscripts: currentTask.includeVoiceTranscripts === true,
zip: currentTask.zip === true
})
setStatus(
currentTask.status === 'running'
? 'running'
@@ -349,6 +360,7 @@ export function ExportWorkspace({
setStatus('idle')
setJobId('')
setProgress(null)
setActiveJobOptions({ includeVoiceTranscripts: false, zip: false })
}
const targetPath =
@@ -686,6 +698,8 @@ export function ExportWorkspace({
previewBytes={previewBytes}
selfInfo={selfInfo}
progress={progress}
includeVoiceTranscripts={activeJobOptions.includeVoiceTranscripts}
zip={activeJobOptions.zip}
selectedCount={selectedContacts.length}
jobId={jobId}
onCancel={(exportJobId) => {
+20
View File
@@ -1009,6 +1009,26 @@
background: var(--wxex-brand);
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 {
color: var(--wxex-text-secondary);
+12 -1
View File
@@ -43,7 +43,16 @@ export interface ExportRequest {
export interface ExportJobProgress {
jobId: string
phase: 'reading' | 'writing' | 'compressing' | 'completed' | 'cancelled' | 'failed'
phase:
| 'reading'
| 'parsing'
| 'media'
| 'transcribing'
| 'writing'
| 'compressing'
| 'completed'
| 'cancelled'
| 'failed'
processed: number
total?: number
percent?: number
@@ -57,6 +66,8 @@ export interface ExportTaskRecord {
targetNames: string[]
targetLabel: string
format: ExportFormat
includeVoiceTranscripts?: boolean
zip?: boolean
status: 'running' | 'completed' | 'cancelled' | 'failed'
progress: ExportJobProgress
createdAt: number