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 exportSaveError = ref('') const exportSaveState = ref('idle') const exportSaveBytesWritten = ref(0) const exportSaveBytesTotal = ref(0) const exportAutoSavedFor = ref('') const exportCancelRequested = ref(false) const exportSearchQuery = ref('') const exportListTab = ref('all') const exportSelectedUsernames = ref([]) const exportTargetContacts = ref([]) const exportTargetsLoading = ref(false) const exportTargetsLoaded = ref(false) const exportTargetsError = 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 formatBytes = (value) => { const bytes = Number(value) if (!Number.isFinite(bytes) || bytes <= 0) return '0 B' const units = ['B', 'KB', 'MB', 'GB', 'TB'] let size = bytes let index = 0 while (size >= 1024 && index < units.length - 1) { size /= 1024 index += 1 } const digits = size >= 100 || index === 0 ? 0 : size >= 10 ? 1 : 2 return `${size.toFixed(digits)} ${units[index]}` } const resetExportSaveFeedback = ({ resetAutoSavedFor = false } = {}) => { exportSaveMsg.value = '' exportSaveError.value = '' exportSaveState.value = 'idle' exportSaveBytesWritten.value = 0 exportSaveBytesTotal.value = 0 if (resetAutoSavedFor) exportAutoSavedFor.value = '' } 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 exportBackendZipPath = computed(() => { return String(exportJob.value?.zipPath || '').trim() }) const exportSaveProgressText = computed(() => { if (exportSaveState.value !== 'saving') return '' const fileName = guessExportZipName(exportJob.value) if (exportSaveBytesTotal.value > 0) { return `正在保存到浏览器目录:${fileName}(${formatBytes(exportSaveBytesWritten.value)} / ${formatBytes(exportSaveBytesTotal.value)})` } return `正在保存到浏览器目录:${fileName}(${formatBytes(exportSaveBytesWritten.value)})` }) 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 normalizeExportTargetContact = (item) => { const username = String(item?.username || '').trim() const name = String(item?.name || item?.displayName || username).trim() || username return { ...item, username, name, displayName: name, avatar: String(item?.avatar || '').trim(), isGroup: item?.isGroup != null ? !!item.isGroup : username.endsWith('@chatroom'), isHidden: !!item?.isHidden, inSessionList: item?.inSessionList == null ? true : !!item.inSessionList } } const getLocalExportContacts = () => { return (Array.isArray(contacts.value) ? contacts.value : []) .map(normalizeExportTargetContact) .filter((contact) => !!contact.username) } const getExportBaseContacts = () => { if (exportTargetsLoaded.value) return exportTargetContacts.value return getLocalExportContacts() } const getExportFilteredContacts = ({ tab = exportListTab.value, query = exportSearchQuery.value } = {}) => { const normalizedQuery = String(query || '').trim().toLowerCase() let list = getExportBaseContacts() 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 = getExportBaseContacts() 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) => { const nextTab = String(tab || 'all') exportListTab.value = nextTab exportScope.value = nextTab === 'groups' || nextTab === 'singles' ? nextTab : 'all' selectExportFilteredContacts(nextTab) } const onExportCustomScopeClick = () => { exportScope.value = 'selected' if (exportSelectedUsernames.value.length === 0) { selectExportFilteredContacts(exportListTab.value) } } const loadExportTargets = async ({ selectFiltered = false } = {}) => { if (!selectedAccount.value) return const selectedBeforeLoad = normalizeExportSelectedUsernames(exportSelectedUsernames.value) const filteredBeforeLoad = getExportFilteredUsernames(exportListTab.value) const selectedWasCurrentFiltered = filteredBeforeLoad.length > 0 && filteredBeforeLoad.length === selectedBeforeLoad.length && filteredBeforeLoad.every((username) => selectedBeforeLoad.includes(username)) exportTargetsLoading.value = true exportTargetsError.value = '' try { const response = await api.getChatExportTargets({ account: selectedAccount.value, include_hidden: true, include_official: false }) const selectedNow = normalizeExportSelectedUsernames(exportSelectedUsernames.value) const filteredNowBeforeLoad = getExportFilteredUsernames(exportListTab.value) const selectedNowMatchesPreloadFilter = filteredBeforeLoad.length > 0 && filteredBeforeLoad.length === selectedNow.length && filteredBeforeLoad.every((username) => selectedNow.includes(username)) const selectedNowMatchesCurrentFilter = filteredNowBeforeLoad.length > 0 && filteredNowBeforeLoad.length === selectedNow.length && filteredNowBeforeLoad.every((username) => selectedNow.includes(username)) const targets = Array.isArray(response?.targets) ? response.targets : [] exportTargetContacts.value = targets .map(normalizeExportTargetContact) .filter((contact) => !!contact.username) exportTargetsLoaded.value = true if (selectFiltered || selectedWasCurrentFiltered || selectedNowMatchesPreloadFilter || selectedNowMatchesCurrentFilter) { selectExportFilteredContacts(exportListTab.value) } } catch (error) { exportTargetsLoaded.value = false exportTargetContacts.value = [] exportTargetsError.value = error?.message || '加载导出范围失败' if (!exportError.value) { exportError.value = `加载导出范围失败:${exportTargetsError.value}` } } finally { exportTargetsLoading.value = false } } 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 = '' resetExportSaveFeedback() 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) { const message = String(error?.message || '').trim() if (error?.name === 'AbortError' || message.includes('The user aborted a request')) { return } 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 = '' resetExportSaveFeedback() 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 exportSaveState.value = 'saving' 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})`) } exportSaveBytesTotal.value = asNumber(response.headers.get('Content-Length')) const fileName = guessExportZipName(exportJob.value) const fileHandle = await handle.getFileHandle(fileName, { create: true }) const writable = await fileHandle.createWritable() if (response.body && typeof response.body.getReader === 'function') { const reader = response.body.getReader() try { while (true) { const { done, value } = await reader.read() if (done) break if (!value || !value.byteLength) continue await writable.write(value) exportSaveBytesWritten.value += value.byteLength } await writable.close() } catch (error) { try { await reader.cancel() } catch {} try { await writable.abort() } catch {} throw error } } else { const blob = await response.blob() exportSaveBytesWritten.value = asNumber(blob.size) if (exportSaveBytesTotal.value <= 0) exportSaveBytesTotal.value = exportSaveBytesWritten.value await writable.write(blob) await writable.close() } exportAutoSavedFor.value = String(exportId) exportSaveState.value = 'success' const folderLabel = String(exportFolder.value || '').trim() || '已选目录' exportSaveMsg.value = autoSave ? `浏览器目录自动保存成功:${fileName}\n位置:${folderLabel}` : `浏览器目录保存成功:${fileName}\n位置:${folderLabel}` } catch (error) { exportSaveState.value = 'error' exportSaveError.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 = '' exportTargetsError.value = '' exportTargetsLoaded.value = false exportTargetContacts.value = [] resetExportSaveFeedback({ resetAutoSavedFor: true }) exportCancelRequested.value = false 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' loadExportTargets({ selectFiltered: !selectedContact.value?.username }) } const closeExportModal = () => { exportModalOpen.value = false exportError.value = '' } const clearExportFolderSelection = () => { exportFolder.value = '' exportFolderHandle.value = null resetExportSaveFeedback({ resetAutoSavedFor: true }) } 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 (status !== 'queued' && status !== 'running') { exportCancelRequested.value = false } 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 = '' resetExportSaveFeedback({ resetAutoSavedFor: true }) exportCancelRequested.value = false 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) : [] } else if (scope !== 'all' && scope !== 'groups' && scope !== 'singles') { 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: scope === 'all' || scope === 'groups' || scope === 'singles', 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 const status = String(exportJob.value?.status || '') if (!exportId || (status !== 'queued' && status !== 'running') || exportCancelRequested.value) return exportError.value = '' exportCancelRequested.value = true try { await api.cancelChatExport(exportId) const response = await api.getChatExport(exportId) exportJob.value = response?.job || exportJob.value } catch (error) { exportCancelRequested.value = false exportError.value = error?.message || '取消导出失败' } } return { exportModalOpen, isExportCreating, exportError, exportScope, exportFormat, exportDownloadRemoteMedia, exportHtmlPageSize, exportMessageTypeOptions, exportMessageTypes, exportStartLocal, exportEndLocal, exportFileName, exportFolder, exportFolderHandle, exportSaveBusy, exportSaveMsg, exportSaveError, exportSaveState, exportSaveProgressText, exportBackendZipPath, exportAutoSavedFor, exportCancelRequested, exportSearchQuery, exportListTab, exportSelectedUsernames, exportJob, exportOverallPercent, exportCurrentPercent, exportTargetsLoading, exportTargetsLoaded, exportTargetsError, exportFilteredContacts, exportContactCounts, onExportBatchScopeClick, onExportCustomScopeClick, onExportListTabClick, isExportContactSelected, hasWebExportFolder, chooseExportFolder, clearExportFolderSelection, getExportDownloadUrl, saveExportToSelectedFolder, openExportModal, closeExportModal, startChatExport, cancelCurrentExport, stopExportPolling } }