Skip to content

Commit cf6cefe

Browse files
committed
improvement(chat-export): 优化导出取消响应并补充保存进度反馈
- 导出弹层展示实际生成位置、浏览器目录保存进度和取消中状态\n- 导出服务补充取消检查、链路追踪与实时同步暂停恢复\n- 预建媒体索引并减少 emoji 空查找开销,补充相关测试
1 parent 6af745b commit cf6cefe

5 files changed

Lines changed: 1315 additions & 84 deletions

File tree

frontend/components/chat/ChatOverlays.vue

Lines changed: 34 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1440,39 +1440,12 @@
14401440
<div>
14411441
<div class="text-sm font-medium text-gray-800 mb-2">消息类型(导出内容)</div>
14421442
<div class="mt-2 p-3 bg-gray-50 rounded-md border border-gray-200">
1443-
<div class="flex items-center gap-2 mb-2">
1444-
<button
1445-
type="button"
1446-
class="text-xs px-2 py-1 rounded border border-gray-200 bg-white hover:bg-gray-50"
1447-
@click="exportMessageTypes = exportMessageTypeOptions.map((x) => x.value)"
1443+
<div class="grid grid-cols-2 gap-x-2 gap-y-2 text-[13px] text-gray-700 md:grid-cols-[repeat(13,max-content)] md:justify-between md:gap-x-3 md:gap-y-0">
1444+
<label
1445+
v-for="opt in exportMessageTypeOptions"
1446+
:key="opt.value"
1447+
class="flex items-center gap-1.5 whitespace-nowrap md:flex-shrink-0"
14481448
>
1449-
全选
1450-
</button>
1451-
<button
1452-
type="button"
1453-
class="text-xs px-2 py-1 rounded border border-gray-200 bg-white hover:bg-gray-50"
1454-
@click="exportMessageTypes = ['voice']"
1455-
>
1456-
只语音
1457-
</button>
1458-
<button
1459-
type="button"
1460-
class="text-xs px-2 py-1 rounded border border-gray-200 bg-white hover:bg-gray-50"
1461-
@click="exportMessageTypes = ['transfer']"
1462-
>
1463-
只转账
1464-
</button>
1465-
<button
1466-
type="button"
1467-
class="text-xs px-2 py-1 rounded border border-gray-200 bg-white hover:bg-gray-50"
1468-
@click="exportMessageTypes = ['redPacket']"
1469-
>
1470-
只红包
1471-
</button>
1472-
<div class="ml-auto text-xs text-gray-500">已选 {{ exportMessageTypes.length }} 项</div>
1473-
</div>
1474-
<div class="grid grid-cols-3 md:grid-cols-4 gap-2 text-sm text-gray-700">
1475-
<label v-for="opt in exportMessageTypeOptions" :key="opt.value" class="flex items-center gap-2">
14761449
<input type="checkbox" :value="opt.value" v-model="exportMessageTypes" />
14771450
<span>{{ opt.label }}</span>
14781451
</label>
@@ -1512,7 +1485,7 @@
15121485
v-if="exportFolder"
15131486
type="button"
15141487
class="text-sm px-3 py-2 rounded-md bg-white border border-gray-200 hover:bg-gray-50"
1515-
@click="exportFolder = ''; exportFolderHandle = null; exportSaveMsg = ''"
1488+
@click="clearExportFolderSelection"
15161489
>
15171490
清空
15181491
</button>
@@ -1563,34 +1536,32 @@
15631536
<div>消息:{{ exportJob.progress?.messagesExported || 0 }};媒体:{{ exportJob.progress?.mediaCopied || 0 }};缺失:{{ exportJob.progress?.mediaMissing || 0 }}</div>
15641537
</div>
15651538

1566-
<div class="mt-3 flex items-center gap-2">
1567-
<button
1568-
v-if="exportJob.status === 'done' && hasWebExportFolder"
1569-
class="text-sm px-3 py-2 rounded-md bg-[#03C160] text-white hover:bg-[#02a650] disabled:opacity-60"
1570-
type="button"
1571-
:disabled="exportSaveBusy"
1572-
@click="saveExportToSelectedFolder"
1573-
>
1574-
{{ exportSaveBusy ? '保存中...' : '保存到已选目录' }}
1575-
</button>
1539+
<div v-if="exportJob.status === 'done'" class="mt-3 rounded-md border border-gray-200 bg-white/80 px-3 py-2 text-xs text-gray-700 space-y-2">
1540+
<div>
1541+
<span class="font-medium text-gray-900">实际生成位置:</span>
1542+
<div class="mt-1 break-all">{{ exportBackendZipPath || '未生成' }}</div>
1543+
</div>
1544+
<div v-if="hasWebExportFolder">
1545+
<span class="font-medium text-gray-900">浏览器目录:</span>
1546+
<div class="mt-1 break-all">{{ exportFolder || '未选择' }}</div>
1547+
</div>
1548+
<div v-if="exportSaveState === 'saving'" class="text-sky-600 whitespace-pre-wrap">{{ exportSaveProgressText }}</div>
1549+
<div v-else-if="exportSaveMsg" class="text-green-600 whitespace-pre-wrap">{{ exportSaveMsg }}</div>
1550+
<div v-else-if="exportSaveError" class="text-red-600 whitespace-pre-wrap">{{ exportSaveError }}</div>
1551+
<div v-if="hasWebExportFolder" class="text-gray-500">
1552+
浏览器模式通常会在写入完成后才显示文件,且出于安全限制,这里只能显示目录名,不能显示完整磁盘路径。
1553+
</div>
1554+
</div>
1555+
1556+
<div v-if="exportJob.status === 'done' && !hasWebExportFolder" class="mt-3 flex items-center gap-2">
15761557
<a
1577-
v-if="exportJob.status === 'done' && !hasWebExportFolder"
15781558
class="text-sm px-3 py-2 rounded-md bg-[#03C160] text-white hover:bg-[#02a650]"
15791559
:href="getExportDownloadUrl(exportJob.exportId)"
15801560
target="_blank"
15811561
>
15821562
下载 ZIP
15831563
</a>
1584-
<button
1585-
v-if="exportJob.status === 'running'"
1586-
class="text-sm px-3 py-2 rounded-md bg-white border border-gray-200 hover:bg-gray-50"
1587-
type="button"
1588-
@click="cancelCurrentExport"
1589-
>
1590-
取消任务
1591-
</button>
15921564
</div>
1593-
<div v-if="exportSaveMsg" class="mt-2 text-xs text-green-600 whitespace-pre-wrap">{{ exportSaveMsg }}</div>
15941565

15951566
<div v-if="exportJob.status === 'error'" class="mt-2 text-sm text-red-600 whitespace-pre-wrap">
15961567
{{ exportJob.error || '导出失败' }}
@@ -1603,13 +1574,23 @@
16031574
关闭
16041575
</button>
16051576
<button
1577+
v-if="!(exportJob && (exportJob.status === 'queued' || exportJob.status === 'running'))"
16061578
class="text-sm px-3 py-2 rounded-md bg-[#03C160] text-white hover:bg-[#02a650] disabled:opacity-60"
16071579
type="button"
16081580
@click="startChatExport"
16091581
:disabled="isExportCreating"
16101582
>
16111583
{{ isExportCreating ? '创建中...' : '开始导出' }}
16121584
</button>
1585+
<button
1586+
v-else
1587+
class="text-sm px-3 py-2 rounded-md bg-white border border-red-200 text-red-600 hover:bg-red-50 disabled:opacity-60"
1588+
type="button"
1589+
@click="cancelCurrentExport"
1590+
:disabled="exportCancelRequested"
1591+
>
1592+
{{ exportCancelRequested ? '取消中...' : '取消任务' }}
1593+
</button>
16131594
</div>
16141595
</div>
16151596
</div>

frontend/composables/chat/useChatExport.js

Lines changed: 102 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,12 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
3535
const exportFolderHandle = ref(null)
3636
const exportSaveBusy = ref(false)
3737
const exportSaveMsg = ref('')
38+
const exportSaveError = ref('')
39+
const exportSaveState = ref('idle')
40+
const exportSaveBytesWritten = ref(0)
41+
const exportSaveBytesTotal = ref(0)
3842
const exportAutoSavedFor = ref('')
43+
const exportCancelRequested = ref(false)
3944

4045
const exportSearchQuery = ref('')
4146
const exportListTab = ref('all')
@@ -50,6 +55,27 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
5055
const next = Number(value)
5156
return Number.isFinite(next) ? next : 0
5257
}
58+
const formatBytes = (value) => {
59+
const bytes = Number(value)
60+
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
61+
const units = ['B', 'KB', 'MB', 'GB', 'TB']
62+
let size = bytes
63+
let index = 0
64+
while (size >= 1024 && index < units.length - 1) {
65+
size /= 1024
66+
index += 1
67+
}
68+
const digits = size >= 100 || index === 0 ? 0 : size >= 10 ? 1 : 2
69+
return `${size.toFixed(digits)} ${units[index]}`
70+
}
71+
const resetExportSaveFeedback = ({ resetAutoSavedFor = false } = {}) => {
72+
exportSaveMsg.value = ''
73+
exportSaveError.value = ''
74+
exportSaveState.value = 'idle'
75+
exportSaveBytesWritten.value = 0
76+
exportSaveBytesTotal.value = 0
77+
if (resetAutoSavedFor) exportAutoSavedFor.value = ''
78+
}
5379

5480
const exportOverallPercent = computed(() => {
5581
const job = exportJob.value
@@ -72,6 +98,17 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
7298
if (total <= 0) return null
7399
return Math.round(clamp01(done / total) * 100)
74100
})
101+
const exportBackendZipPath = computed(() => {
102+
return String(exportJob.value?.zipPath || '').trim()
103+
})
104+
const exportSaveProgressText = computed(() => {
105+
if (exportSaveState.value !== 'saving') return ''
106+
const fileName = guessExportZipName(exportJob.value)
107+
if (exportSaveBytesTotal.value > 0) {
108+
return `正在保存到浏览器目录:${fileName}${formatBytes(exportSaveBytesWritten.value)} / ${formatBytes(exportSaveBytesTotal.value)})`
109+
}
110+
return `正在保存到浏览器目录:${fileName}${formatBytes(exportSaveBytesWritten.value)})`
111+
})
75112

76113
const normalizeExportSelectedUsernames = (list) => {
77114
const seen = new Set()
@@ -179,7 +216,7 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
179216

180217
const chooseExportFolder = async () => {
181218
exportError.value = ''
182-
exportSaveMsg.value = ''
219+
resetExportSaveFeedback()
183220
try {
184221
if (!process.client) {
185222
exportError.value = '当前环境不支持选择导出目录'
@@ -206,6 +243,10 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
206243

207244
exportError.value = '当前浏览器不支持目录选择,请使用桌面端或 Chromium 新版浏览器'
208245
} catch (error) {
246+
const message = String(error?.message || '').trim()
247+
if (error?.name === 'AbortError' || message.includes('The user aborted a request')) {
248+
return
249+
}
209250
exportError.value = error?.message || '选择导出目录失败'
210251
}
211252
}
@@ -227,7 +268,7 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
227268
const saveExportToSelectedFolder = async (options = {}) => {
228269
const autoSave = !!options?.auto
229270
exportError.value = ''
230-
exportSaveMsg.value = ''
271+
resetExportSaveFeedback()
231272
if (!process.client || !isWebDirectoryPickerSupported()) {
232273
exportError.value = '当前环境不支持保存到浏览器目录'
233274
return
@@ -245,6 +286,7 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
245286
}
246287

247288
exportSaveBusy.value = true
289+
exportSaveState.value = 'saving'
248290
try {
249291
const response = await fetch(getExportDownloadUrl(exportId))
250292
if (!response.ok) {
@@ -256,18 +298,46 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
256298
})
257299
throw new Error(`下载导出文件失败(${response.status})`)
258300
}
259-
const blob = await response.blob()
301+
exportSaveBytesTotal.value = asNumber(response.headers.get('Content-Length'))
260302
const fileName = guessExportZipName(exportJob.value)
261303
const fileHandle = await handle.getFileHandle(fileName, { create: true })
262304
const writable = await fileHandle.createWritable()
263-
await writable.write(blob)
264-
await writable.close()
305+
if (response.body && typeof response.body.getReader === 'function') {
306+
const reader = response.body.getReader()
307+
try {
308+
while (true) {
309+
const { done, value } = await reader.read()
310+
if (done) break
311+
if (!value || !value.byteLength) continue
312+
await writable.write(value)
313+
exportSaveBytesWritten.value += value.byteLength
314+
}
315+
await writable.close()
316+
} catch (error) {
317+
try {
318+
await reader.cancel()
319+
} catch {}
320+
try {
321+
await writable.abort()
322+
} catch {}
323+
throw error
324+
}
325+
} else {
326+
const blob = await response.blob()
327+
exportSaveBytesWritten.value = asNumber(blob.size)
328+
if (exportSaveBytesTotal.value <= 0) exportSaveBytesTotal.value = exportSaveBytesWritten.value
329+
await writable.write(blob)
330+
await writable.close()
331+
}
265332
exportAutoSavedFor.value = String(exportId)
333+
exportSaveState.value = 'success'
334+
const folderLabel = String(exportFolder.value || '').trim() || '已选目录'
266335
exportSaveMsg.value = autoSave
267-
? `已自动保存到已选目录${fileName}`
268-
: `已保存到已选目录${fileName}`
336+
? `浏览器目录自动保存成功${fileName}\n位置:${folderLabel}`
337+
: `浏览器目录保存成功${fileName}\n位置:${folderLabel}`
269338
} catch (error) {
270-
exportError.value = error?.message || '保存到浏览器目录失败'
339+
exportSaveState.value = 'error'
340+
exportSaveError.value = `浏览器目录保存失败:${error?.message || '未知错误'}`
271341
} finally {
272342
exportSaveBusy.value = false
273343
}
@@ -337,7 +407,8 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
337407
const openExportModal = () => {
338408
exportModalOpen.value = true
339409
exportError.value = ''
340-
exportSaveMsg.value = ''
410+
resetExportSaveFeedback({ resetAutoSavedFor: true })
411+
exportCancelRequested.value = false
341412
exportSearchQuery.value = ''
342413
exportListTab.value = 'all'
343414
exportSelectedUsernames.value = []
@@ -356,6 +427,12 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
356427
exportError.value = ''
357428
}
358429

430+
const clearExportFolderSelection = () => {
431+
exportFolder.value = ''
432+
exportFolderHandle.value = null
433+
resetExportSaveFeedback({ resetAutoSavedFor: true })
434+
}
435+
359436
watch(exportModalOpen, (open) => {
360437
if (!process.client) return
361438
if (!open) {
@@ -382,6 +459,9 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
382459
status: String(exportJob.value?.status || '')
383460
}),
384461
async ({ exportId, status }) => {
462+
if (status !== 'queued' && status !== 'running') {
463+
exportCancelRequested.value = false
464+
}
385465
if (!process.client || status !== 'done' || !exportId) return
386466
if (!hasWebExportFolder.value) return
387467
if (exportAutoSavedFor.value === exportId) return
@@ -392,7 +472,8 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
392472

393473
const startChatExport = async () => {
394474
exportError.value = ''
395-
exportSaveMsg.value = ''
475+
resetExportSaveFeedback({ resetAutoSavedFor: true })
476+
exportCancelRequested.value = false
396477
if (!selectedAccount.value) {
397478
exportError.value = '未选择账号'
398479
return
@@ -490,13 +571,17 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
490571

491572
const cancelCurrentExport = async () => {
492573
const exportId = exportJob.value?.exportId
493-
if (!exportId) return
574+
const status = String(exportJob.value?.status || '')
575+
if (!exportId || (status !== 'queued' && status !== 'running') || exportCancelRequested.value) return
494576

577+
exportError.value = ''
578+
exportCancelRequested.value = true
495579
try {
496580
await api.cancelChatExport(exportId)
497581
const response = await api.getChatExport(exportId)
498582
exportJob.value = response?.job || exportJob.value
499583
} catch (error) {
584+
exportCancelRequested.value = false
500585
exportError.value = error?.message || '取消导出失败'
501586
}
502587
}
@@ -518,7 +603,12 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
518603
exportFolderHandle,
519604
exportSaveBusy,
520605
exportSaveMsg,
606+
exportSaveError,
607+
exportSaveState,
608+
exportSaveProgressText,
609+
exportBackendZipPath,
521610
exportAutoSavedFor,
611+
exportCancelRequested,
522612
exportSearchQuery,
523613
exportListTab,
524614
exportSelectedUsernames,
@@ -532,6 +622,7 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
532622
isExportContactSelected,
533623
hasWebExportFolder,
534624
chooseExportFolder,
625+
clearExportFolderSelection,
535626
getExportDownloadUrl,
536627
saveExportToSelectedFolder,
537628
openExportModal,

0 commit comments

Comments
 (0)