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