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

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

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

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

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

5. 补充导出进度、语音、头像、附件和消息解析回归测试。
This commit is contained in:
Nanin
2026-08-05 23:22:47 +08:00
parent c36590839b
commit ce67107128
16 changed files with 495 additions and 103 deletions
+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);