Files
WeChatDataAnalysis/frontend/composables/chat/useChatExport.js
T
2977094657 965cbd1aa5 refactor(chat-ui): 拆分聊天页组件并抽离组合式逻辑
- 将聊天页从超大单文件拆分为会话列表、会话面板、消息列表、消息项和覆盖层组件\n- 抽离会话、消息、搜索、导出、编辑、历史窗口等组合式逻辑\n- 下沉消息格式化、消息归一化、聊天记录解析与文件图标等工具模块\n- 新增 chat.css 并在 Nuxt 中注册,统一聊天页样式
2026-03-15 23:38:31 +08:00

461 lines
15 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 exportFilteredContacts = computed(() => {
const query = String(exportSearchQuery.value || '').trim().toLowerCase()
let list = Array.isArray(contacts.value) ? contacts.value : []
const tab = String(exportListTab.value || 'all')
if (tab === 'groups') list = list.filter((contact) => !!contact?.isGroup)
if (tab === 'singles') list = list.filter((contact) => !contact?.isGroup)
if (!query) return list
return list.filter((contact) => {
const name = String(contact?.name || '').toLowerCase()
const username = String(contact?.username || '').toLowerCase()
return name.includes(query) || username.includes(query)
})
})
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 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 = ''
exportListTab.value = 'all'
exportStartLocal.value = ''
exportEndLocal.value = ''
exportMessageTypes.value = exportMessageTypeOptions.map((item) => item.value)
exportAutoSavedFor.value = ''
exportScope.value = selectedContact.value?.username ? 'current' : '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(
() => ({
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,
hasWebExportFolder,
chooseExportFolder,
getExportDownloadUrl,
saveExportToSelectedFolder,
openExportModal,
closeExportModal,
startChatExport,
cancelCurrentExport,
stopExportPolling
}
}