Files
WeChatDataAnalysis/frontend/composables/chat/useChatExport.js
T
2977094657 4313c5e34c fix(chat-export): 修复批量导出默认范围与会话选择异常
- 修复未选中会话时导出弹窗落入无效默认范围的问题

- 支持按全部、群聊、单聊快速切换批量导出范围,并默认选中当前筛选结果

- 优化会话列表整行点击、高亮反馈和选择去重逻辑
2026-03-21 14:21:01 +08:00

544 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { computed, ref, watch } from 'vue'
import { reportServerErrorFromResponse } from '~/lib/server-error-logging'
import { toUnixSeconds } from '~/lib/chat/formatters'
export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selectedContact, privacyMode }) => {
const exportModalOpen = ref(false)
const isExportCreating = ref(false)
const exportError = ref('')
const exportScope = ref('current')
const exportFormat = ref('json')
const exportDownloadRemoteMedia = ref(true)
const exportHtmlPageSize = ref(1000)
const exportMessageTypeOptions = [
{ value: 'text', label: '文本' },
{ value: 'image', label: '图片' },
{ value: 'emoji', label: '表情' },
{ value: 'video', label: '视频' },
{ value: 'voice', label: '语音' },
{ value: 'chatHistory', label: '聊天记录' },
{ value: 'transfer', label: '转账' },
{ value: 'redPacket', label: '红包' },
{ value: 'file', label: '文件' },
{ value: 'link', label: '链接' },
{ value: 'quote', label: '引用' },
{ value: 'system', label: '系统' },
{ value: 'voip', label: '通话' }
]
const exportMessageTypes = ref(exportMessageTypeOptions.map((item) => item.value))
const exportStartLocal = ref('')
const exportEndLocal = ref('')
const exportFileName = ref('')
const exportFolder = ref('')
const exportFolderHandle = ref(null)
const exportSaveBusy = ref(false)
const exportSaveMsg = ref('')
const exportAutoSavedFor = ref('')
const exportSearchQuery = ref('')
const exportListTab = ref('all')
const exportSelectedUsernames = ref([])
const exportJob = ref(null)
let exportPollTimer = null
let exportEventSource = null
const clamp01 = (value) => Math.min(1, Math.max(0, value))
const asNumber = (value) => {
const next = Number(value)
return Number.isFinite(next) ? next : 0
}
const exportOverallPercent = computed(() => {
const job = exportJob.value
const progress = job?.progress || {}
const total = asNumber(progress.conversationsTotal)
const done = asNumber(progress.conversationsDone)
if (total <= 0) return 0
const currentTotal = asNumber(progress.currentConversationMessagesTotal)
const currentDone = asNumber(progress.currentConversationMessagesExported)
const currentFraction = currentTotal > 0 ? clamp01(currentDone / currentTotal) : 0
const overall = clamp01((done + (job?.status === 'running' ? currentFraction : 0)) / total)
return Math.round(overall * 100)
})
const exportCurrentPercent = computed(() => {
const progress = exportJob.value?.progress || {}
const total = asNumber(progress.currentConversationMessagesTotal)
const done = asNumber(progress.currentConversationMessagesExported)
if (total <= 0) return null
return Math.round(clamp01(done / total) * 100)
})
const normalizeExportSelectedUsernames = (list) => {
const seen = new Set()
return (Array.isArray(list) ? list : []).reduce((acc, item) => {
const username = String(item || '').trim()
if (!username || seen.has(username)) return acc
seen.add(username)
acc.push(username)
return acc
}, [])
}
const getExportFilteredContacts = ({ tab = exportListTab.value, query = exportSearchQuery.value } = {}) => {
const normalizedQuery = String(query || '').trim().toLowerCase()
let list = Array.isArray(contacts.value) ? contacts.value : []
const normalizedTab = String(tab || 'all')
if (normalizedTab === 'groups') list = list.filter((contact) => !!contact?.isGroup)
if (normalizedTab === 'singles') list = list.filter((contact) => !contact?.isGroup)
if (!normalizedQuery) return list
return list.filter((contact) => {
const name = String(contact?.name || '').toLowerCase()
const username = String(contact?.username || '').toLowerCase()
return name.includes(normalizedQuery) || username.includes(normalizedQuery)
})
}
const exportFilteredContacts = computed(() => {
return getExportFilteredContacts()
})
const exportContactCounts = computed(() => {
const list = Array.isArray(contacts.value) ? contacts.value : []
const total = list.length
const groups = list.filter((contact) => !!contact?.isGroup).length
return { total, groups, singles: total - groups }
})
const exportSelectedUsernameSet = computed(() => {
return new Set(normalizeExportSelectedUsernames(exportSelectedUsernames.value))
})
const setExportSelectedUsernames = (list) => {
exportSelectedUsernames.value = normalizeExportSelectedUsernames(list)
}
const getExportFilteredUsernames = (tab = exportListTab.value) => {
return getExportFilteredContacts({ tab })
.map((contact) => String(contact?.username || '').trim())
.filter(Boolean)
}
const selectExportFilteredContacts = (tab = exportListTab.value) => {
setExportSelectedUsernames(getExportFilteredUsernames(tab))
}
const clearExportFilteredContacts = () => {
setExportSelectedUsernames([])
}
const areExportFilteredContactsAllSelected = (tab = exportListTab.value) => {
const usernames = getExportFilteredUsernames(tab)
if (usernames.length !== exportSelectedUsernameSet.value.size) return false
return usernames.every((username) => exportSelectedUsernameSet.value.has(username))
}
const onExportListTabClick = (tab) => {
const nextTab = String(tab || 'all')
const isSameTab = String(exportListTab.value || 'all') === nextTab
exportListTab.value = nextTab
if (isSameTab) {
if (areExportFilteredContactsAllSelected(nextTab)) {
clearExportFilteredContacts(nextTab)
} else {
selectExportFilteredContacts(nextTab)
}
return
}
selectExportFilteredContacts(nextTab)
}
const isExportContactSelected = (username) => {
return exportSelectedUsernameSet.value.has(String(username || '').trim())
}
const onExportBatchScopeClick = (tab) => {
exportScope.value = 'selected'
onExportListTabClick(tab)
}
const isDesktopExportRuntime = () => {
return !!(process.client && window?.wechatDesktop?.chooseDirectory)
}
const isWebDirectoryPickerSupported = () => {
return !!(process.client && typeof window.showDirectoryPicker === 'function')
}
const hasWebExportFolder = computed(() => {
return !!(isWebDirectoryPickerSupported() && exportFolderHandle.value)
})
const chooseExportFolder = async () => {
exportError.value = ''
exportSaveMsg.value = ''
try {
if (!process.client) {
exportError.value = '当前环境不支持选择导出目录'
return
}
if (isDesktopExportRuntime()) {
const result = await window.wechatDesktop.chooseDirectory({ title: '选择导出目录' })
if (result && !result.canceled && Array.isArray(result.filePaths) && result.filePaths.length > 0) {
exportFolder.value = String(result.filePaths[0] || '').trim()
exportFolderHandle.value = null
}
return
}
if (isWebDirectoryPickerSupported()) {
const handle = await window.showDirectoryPicker()
if (handle) {
exportFolderHandle.value = handle
exportFolder.value = `浏览器目录:${String(handle.name || '已选择')}`
}
return
}
exportError.value = '当前浏览器不支持目录选择,请使用桌面端或 Chromium 新版浏览器'
} catch (error) {
exportError.value = error?.message || '选择导出目录失败'
}
}
const guessExportZipName = (job) => {
const raw = String(job?.zipPath || '').trim()
if (raw) {
const name = raw.replace(/\\/g, '/').split('/').pop()
if (name && name.toLowerCase().endsWith('.zip')) return name
}
const exportId = String(job?.exportId || '').trim() || 'export'
return `wechat_chat_export_${exportId}.zip`
}
const getExportDownloadUrl = (exportId) => {
return `${apiBase}/chat/exports/${encodeURIComponent(String(exportId || ''))}/download`
}
const saveExportToSelectedFolder = async (options = {}) => {
const autoSave = !!options?.auto
exportError.value = ''
exportSaveMsg.value = ''
if (!process.client || !isWebDirectoryPickerSupported()) {
exportError.value = '当前环境不支持保存到浏览器目录'
return
}
const handle = exportFolderHandle.value
if (!handle || typeof handle.getFileHandle !== 'function') {
exportError.value = '请先选择浏览器导出目录'
return
}
const exportId = exportJob.value?.exportId
if (!exportId || String(exportJob.value?.status || '') !== 'done') {
exportError.value = '导出任务尚未完成'
return
}
exportSaveBusy.value = true
try {
const response = await fetch(getExportDownloadUrl(exportId))
if (!response.ok) {
await reportServerErrorFromResponse(response, {
method: 'GET',
requestUrl: getExportDownloadUrl(exportId),
message: `下载导出文件失败(${response.status}`,
source: 'chat.exportDownload'
})
throw new Error(`下载导出文件失败(${response.status}`)
}
const blob = await response.blob()
const fileName = guessExportZipName(exportJob.value)
const fileHandle = await handle.getFileHandle(fileName, { create: true })
const writable = await fileHandle.createWritable()
await writable.write(blob)
await writable.close()
exportAutoSavedFor.value = String(exportId)
exportSaveMsg.value = autoSave
? `已自动保存到已选目录:${fileName}`
: `已保存到已选目录:${fileName}`
} catch (error) {
exportError.value = error?.message || '保存到浏览器目录失败'
} finally {
exportSaveBusy.value = false
}
}
const stopExportPolling = () => {
if (exportEventSource) {
try {
exportEventSource.close()
} catch {}
exportEventSource = null
}
if (exportPollTimer) {
clearInterval(exportPollTimer)
exportPollTimer = null
}
}
const startExportHttpPolling = (exportId) => {
if (!exportId) return
exportPollTimer = setInterval(async () => {
try {
const response = await api.getChatExport(exportId)
exportJob.value = response?.job || exportJob.value
const status = String(exportJob.value?.status || '')
if (status === 'done' || status === 'error' || status === 'cancelled') {
stopExportPolling()
}
} catch {}
}, 1200)
}
const startExportPolling = (exportId) => {
stopExportPolling()
if (!exportId) return
if (process.client && typeof window !== 'undefined' && typeof EventSource !== 'undefined') {
const url = `${apiBase}/chat/exports/${encodeURIComponent(String(exportId))}/events`
try {
exportEventSource = new EventSource(url)
exportEventSource.onmessage = (event) => {
try {
const next = JSON.parse(String(event.data || '{}'))
exportJob.value = next || exportJob.value
const status = String(exportJob.value?.status || '')
if (status === 'done' || status === 'error' || status === 'cancelled') {
stopExportPolling()
}
} catch {}
}
exportEventSource.onerror = () => {
try {
exportEventSource?.close()
} catch {}
exportEventSource = null
if (!exportPollTimer) startExportHttpPolling(exportId)
}
return
} catch {
exportEventSource = null
}
}
startExportHttpPolling(exportId)
}
const openExportModal = () => {
exportModalOpen.value = true
exportError.value = ''
exportSaveMsg.value = ''
exportSearchQuery.value = ''
exportListTab.value = 'all'
exportSelectedUsernames.value = []
exportStartLocal.value = ''
exportEndLocal.value = ''
exportMessageTypes.value = exportMessageTypeOptions.map((item) => item.value)
exportAutoSavedFor.value = ''
exportScope.value = selectedContact.value?.username ? 'current' : 'selected'
if (!selectedContact.value?.username) {
selectExportFilteredContacts('all')
}
}
const closeExportModal = () => {
exportModalOpen.value = false
exportError.value = ''
}
watch(exportModalOpen, (open) => {
if (!process.client) return
if (!open) {
stopExportPolling()
return
}
const exportId = exportJob.value?.exportId
const status = String(exportJob.value?.status || '')
if (exportId && (status === 'queued' || status === 'running')) {
startExportPolling(exportId)
}
})
watch(exportScope, (scope, previousScope) => {
if (scope !== 'selected' || previousScope === 'selected') return
if (exportSelectedUsernames.value.length > 0) return
selectExportFilteredContacts(exportListTab.value)
})
watch(
() => ({
exportId: String(exportJob.value?.exportId || ''),
status: String(exportJob.value?.status || '')
}),
async ({ exportId, status }) => {
if (!process.client || status !== 'done' || !exportId) return
if (!hasWebExportFolder.value) return
if (exportAutoSavedFor.value === exportId) return
if (exportSaveBusy.value) return
await saveExportToSelectedFolder({ auto: true })
}
)
const startChatExport = async () => {
exportError.value = ''
exportSaveMsg.value = ''
if (!selectedAccount.value) {
exportError.value = '未选择账号'
return
}
let scope = exportScope.value
let usernames = []
if (scope === 'current') {
scope = 'selected'
if (selectedContact.value?.username) {
usernames = [selectedContact.value.username]
}
} else if (scope === 'selected') {
usernames = Array.isArray(exportSelectedUsernames.value) ? exportSelectedUsernames.value.filter(Boolean) : []
}
if (scope === 'selected' && (!usernames || usernames.length === 0)) {
exportError.value = '请选择至少一个会话'
return
}
const hasDesktopFolder = isDesktopExportRuntime() && !!String(exportFolder.value || '').trim()
const hasWebFolder = !isDesktopExportRuntime() && !!exportFolderHandle.value
if (!hasDesktopFolder && !hasWebFolder) {
exportError.value = '请先选择导出目录'
return
}
const startTime = toUnixSeconds(exportStartLocal.value)
const endTime = toUnixSeconds(exportEndLocal.value)
if (startTime && endTime && startTime > endTime) {
exportError.value = '时间范围不合法:开始时间不能晚于结束时间'
return
}
const messageTypes = Array.isArray(exportMessageTypes.value) ? exportMessageTypes.value.filter(Boolean) : []
if (messageTypes.length === 0) {
exportError.value = '请至少勾选一个消息类型'
return
}
const selectedTypeSet = new Set(messageTypes.map((item) => String(item || '').trim()))
const mediaKindSet = new Set()
if (selectedTypeSet.has('chatHistory')) {
mediaKindSet.add('image')
mediaKindSet.add('emoji')
mediaKindSet.add('video')
mediaKindSet.add('video_thumb')
mediaKindSet.add('voice')
mediaKindSet.add('file')
}
if (selectedTypeSet.has('image')) mediaKindSet.add('image')
if (selectedTypeSet.has('emoji')) mediaKindSet.add('emoji')
if (selectedTypeSet.has('video')) {
mediaKindSet.add('video')
mediaKindSet.add('video_thumb')
}
if (selectedTypeSet.has('voice')) mediaKindSet.add('voice')
if (selectedTypeSet.has('file')) mediaKindSet.add('file')
const mediaKinds = Array.from(mediaKindSet)
const includeMedia = !privacyMode.value && mediaKinds.length > 0
isExportCreating.value = true
exportAutoSavedFor.value = ''
try {
const response = await api.createChatExport({
account: selectedAccount.value,
scope,
usernames,
format: exportFormat.value,
start_time: startTime,
end_time: endTime,
include_hidden: false,
include_official: false,
message_types: messageTypes,
include_media: includeMedia,
media_kinds: mediaKinds,
download_remote_media: exportFormat.value === 'html' && !!exportDownloadRemoteMedia.value,
html_page_size: Math.max(0, Math.floor(Number(exportHtmlPageSize.value || 1000))),
output_dir: isDesktopExportRuntime() ? String(exportFolder.value || '').trim() : null,
privacy_mode: !!privacyMode.value,
file_name: exportFileName.value || null
})
exportJob.value = response?.job || null
const exportId = exportJob.value?.exportId
if (exportId) startExportPolling(exportId)
} catch (error) {
exportError.value = error?.message || '创建导出任务失败'
} finally {
isExportCreating.value = false
}
}
const cancelCurrentExport = async () => {
const exportId = exportJob.value?.exportId
if (!exportId) return
try {
await api.cancelChatExport(exportId)
const response = await api.getChatExport(exportId)
exportJob.value = response?.job || exportJob.value
} catch (error) {
exportError.value = error?.message || '取消导出失败'
}
}
return {
exportModalOpen,
isExportCreating,
exportError,
exportScope,
exportFormat,
exportDownloadRemoteMedia,
exportHtmlPageSize,
exportMessageTypeOptions,
exportMessageTypes,
exportStartLocal,
exportEndLocal,
exportFileName,
exportFolder,
exportFolderHandle,
exportSaveBusy,
exportSaveMsg,
exportAutoSavedFor,
exportSearchQuery,
exportListTab,
exportSelectedUsernames,
exportJob,
exportOverallPercent,
exportCurrentPercent,
exportFilteredContacts,
exportContactCounts,
onExportBatchScopeClick,
onExportListTabClick,
isExportContactSelected,
hasWebExportFolder,
chooseExportFolder,
getExportDownloadUrl,
saveExportToSelectedFolder,
openExportModal,
closeExportModal,
startChatExport,
cancelCurrentExport,
stopExportPolling
}
}