mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 03:27:00 +08:00
feat: 优化聊天导出进度与媒体处理
1. 拆分读取、解析、转写、媒体处理、写入和压缩阶段,完善任务进度展示。 2. 增量导出复用语音资源与转写结果,并为缺失转写补充识别。 3. 稳定远程头像文件名并保留同源头像版本更新。 4. 支持音频附件直接播放、新窗口打开附件,并解码分享标题 XML 实体。 5. 补充导出进度、语音、头像、附件和消息解析回归测试。
This commit is contained in:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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') || ''
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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
@@ -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%' })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 }))
|
||||||
|
|||||||
@@ -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() } }
|
||||||
|
|||||||
@@ -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"')
|
||||||
|
|||||||
@@ -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', () => {
|
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>',
|
||||||
|
|||||||
Reference in New Issue
Block a user