diff --git a/frontend/composables/useApi.js b/frontend/composables/useApi.js index d6815ae..71cff36 100644 --- a/frontend/composables/useApi.js +++ b/frontend/composables/useApi.js @@ -138,6 +138,42 @@ export const useApi = () => { } }) } + + // 聊天记录导出(离线zip) + const createChatExport = async (data = {}) => { + return await request('/chat/exports', { + method: 'POST', + body: { + account: data.account || null, + scope: data.scope || 'selected', + usernames: Array.isArray(data.usernames) ? data.usernames : [], + format: data.format || 'json', + start_time: data.start_time != null ? Number(data.start_time) : null, + end_time: data.end_time != null ? Number(data.end_time) : null, + include_hidden: !!data.include_hidden, + include_official: !!data.include_official, + include_media: data.include_media == null ? true : !!data.include_media, + media_kinds: Array.isArray(data.media_kinds) ? data.media_kinds : ['image', 'emoji', 'video', 'video_thumb', 'voice', 'file'], + allow_process_key_extract: !!data.allow_process_key_extract, + privacy_mode: !!data.privacy_mode, + file_name: data.file_name || null + } + }) + } + + const getChatExport = async (exportId) => { + if (!exportId) throw new Error('Missing exportId') + return await request(`/chat/exports/${encodeURIComponent(String(exportId))}`) + } + + const listChatExports = async () => { + return await request('/chat/exports') + } + + const cancelChatExport = async (exportId) => { + if (!exportId) throw new Error('Missing exportId') + return await request(`/chat/exports/${encodeURIComponent(String(exportId))}`, { method: 'DELETE' }) + } return { detectWechat, @@ -151,6 +187,10 @@ export const useApi = () => { downloadChatEmoji, getMediaKeys, saveMediaKeys, - decryptAllMedia + decryptAllMedia, + createChatExport, + getChatExport, + listChatExports, + cancelChatExport } } diff --git a/frontend/pages/chat/[[username]].vue b/frontend/pages/chat/[[username]].vue index 145e29a..a0a5641 100644 --- a/frontend/pages/chat/[[username]].vue +++ b/frontend/pages/chat/[[username]].vue @@ -129,6 +129,13 @@ > 刷新 + @@ -419,6 +426,294 @@ 打开文件夹 + + +
+
+
+
+
导出聊天记录(离线 ZIP)
+ +
+ +
+
{{ exportError }}
+
+ 已开启隐私模式:导出将隐藏会话/用户名/内容,并且不会打包头像与媒体。 +
+ +
+
+
范围
+
+ + + + + +
+ +
+
+ + + +
点击 tab 筛选
+
+
+ +
+
+
+ +
+ +
+ {{ (c.name || c.username || '?').charAt(0) }} +
+
+
+
+ {{ c.name }} + {{ c.isGroup ? '(群)' : '' }} +
+
{{ c.username }}
+
+
+
+ 无匹配会话 +
+
+
+ 已选 {{ exportSelectedUsernames.length }} 个会话 +
+
+
+ +
+
+
格式
+
+ + +
+
+ +
+
时间范围(可选)
+
+ + +
+
+ + + +
+
+ +
+
媒体(离线)
+ +
+ + + + + + +
+
+ +
+
文件名(可选)
+ +
不填则自动生成(输出位置:output/exports/{账号}/)。
+
+ +
+ + +
+
+
+ +
+
+
任务:{{ exportJob.exportId }}
+
状态:{{ exportJob.status }}
+
+
+
+
会话:{{ exportJob.progress?.conversationsDone || 0 }}/{{ exportJob.progress?.conversationsTotal || 0 }}
+
{{ exportOverallPercent }}%
+
+
+
+
+ +
+
+
+ 当前:{{ exportJob.progress?.currentConversationName || exportJob.progress?.currentConversationUsername }} + ({{ exportJob.progress?.currentConversationMessagesExported || 0 }}/{{ exportJob.progress?.currentConversationMessagesTotal || 0 }}) +
+
+ {{ exportCurrentPercent }}% + +
+
+
+
+
+
+
+ +
消息:{{ exportJob.progress?.messagesExported || 0 }};媒体:{{ exportJob.progress?.mediaCopied || 0 }};缺失:{{ exportJob.progress?.mediaMissing || 0 }}
+
+ +
+ + 下载 ZIP + + +
+ +
+ {{ exportJob.error || '导出失败' }} +
+
+
+ +
+ + +
+
+
@@ -500,6 +795,286 @@ const messagesMeta = ref({}) const isLoadingMessages = ref(false) const messagesError = ref('') +// 导出(离线 zip) +const exportModalOpen = ref(false) +const isExportCreating = ref(false) +const exportError = ref('') + +// current: 当前会话(映射为 selected + 单个 username) +const exportScope = ref('current') // current | selected | all | groups | singles +const exportFormat = ref('json') // json | txt +const exportIncludeMedia = ref(true) +const exportMediaKinds = ref(['image', 'emoji', 'video', 'video_thumb', 'voice', 'file']) +const exportIncludeHidden = ref(false) +const exportIncludeOfficial = ref(false) + +const exportStartLocal = ref('') // datetime-local +const exportEndLocal = ref('') // datetime-local +const exportFileName = ref('') + +const exportSearchQuery = ref('') +const exportListTab = ref('all') // all | groups | singles +const exportSelectedUsernames = ref([]) + +const exportJob = ref(null) +let exportPollTimer = null +let exportEventSource = null + +const _clamp01 = (n) => Math.min(1, Math.max(0, n)) +const _asNumber = (v) => { + const n = Number(v) + return Number.isFinite(n) ? n : 0 +} + +const exportOverallPercent = computed(() => { + const job = exportJob.value + const p = job?.progress || {} + const total = _asNumber(p.conversationsTotal) + const done = _asNumber(p.conversationsDone) + if (total <= 0) return 0 + + const currentTotal = _asNumber(p.currentConversationMessagesTotal) + const currentDone = _asNumber(p.currentConversationMessagesExported) + const fracCurrent = currentTotal > 0 ? _clamp01(currentDone / currentTotal) : 0 + const overall = _clamp01((done + (job?.status === 'running' ? fracCurrent : 0)) / total) + return Math.round(overall * 100) +}) + +const exportCurrentPercent = computed(() => { + const p = exportJob.value?.progress || {} + const total = _asNumber(p.currentConversationMessagesTotal) + const done = _asNumber(p.currentConversationMessagesExported) + if (total <= 0) return null + return Math.round(_clamp01(done / total) * 100) +}) + +const exportFilteredContacts = computed(() => { + const q = 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((c) => !!c?.isGroup) + if (tab === 'singles') list = list.filter((c) => !c?.isGroup) + + if (!q) return list + return list.filter((c) => { + const name = String(c?.name || '').toLowerCase() + const username = String(c?.username || '').toLowerCase() + return name.includes(q) || username.includes(q) + }) +}) + +const exportContactCounts = computed(() => { + const list = Array.isArray(contacts.value) ? contacts.value : [] + const total = list.length + const groups = list.filter((c) => !!c?.isGroup).length + return { total, groups, singles: total - groups } +}) + +const toUnixSeconds = (datetimeLocal) => { + const v = String(datetimeLocal || '').trim() + if (!v) return null + const d = new Date(v) + const ms = d.getTime() + if (!ms || Number.isNaN(ms)) return null + return Math.floor(ms / 1000) +} + +const stopExportPolling = () => { + if (exportEventSource) { + try { + exportEventSource.close() + } catch (e) { + // ignore + } + exportEventSource = null + } + if (exportPollTimer) { + clearInterval(exportPollTimer) + exportPollTimer = null + } +} + +const startExportHttpPolling = (exportId) => { + if (!exportId) return + const api = useApi() + exportPollTimer = setInterval(async () => { + try { + const resp = await api.getChatExport(exportId) + exportJob.value = resp?.job || exportJob.value + + const st = String(exportJob.value?.status || '') + if (st === 'done' || st === 'error' || st === 'cancelled') { + stopExportPolling() + } + } catch (e) { + // keep polling; transient errors are possible while exporting + } + }, 1200) +} + +const startExportPolling = (exportId) => { + stopExportPolling() + if (!exportId) return + + if (process.client && typeof window !== 'undefined' && typeof EventSource !== 'undefined') { + const base = 'http://localhost:8000' + const url = `${base}/api/chat/exports/${encodeURIComponent(String(exportId))}/events` + try { + exportEventSource = new EventSource(url) + exportEventSource.onmessage = (ev) => { + try { + const next = JSON.parse(String(ev.data || '{}')) + exportJob.value = next || exportJob.value + const st = String(exportJob.value?.status || '') + if (st === 'done' || st === 'error' || st === 'cancelled') { + stopExportPolling() + } + } catch (e) { + // ignore + } + } + exportEventSource.onerror = () => { + // fallback to HTTP polling + try { + exportEventSource?.close() + } catch (e) { + // ignore + } + exportEventSource = null + if (!exportPollTimer) startExportHttpPolling(exportId) + } + return + } catch (e) { + exportEventSource = null + } + } + + startExportHttpPolling(exportId) +} + +const openExportModal = () => { + exportModalOpen.value = true + exportError.value = '' + exportListTab.value = 'all' + + if (privacyMode.value) { + exportIncludeMedia.value = false + } + + if (selectedContact.value?.username) { + exportScope.value = 'current' + } else { + exportScope.value = '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 st = String(exportJob.value?.status || '') + if (exportId && (st === 'queued' || st === 'running')) { + startExportPolling(exportId) + } +}) + +const getExportDownloadUrl = (exportId) => { + const base = process.client ? 'http://localhost:8000' : '' + return `${base}/api/chat/exports/${encodeURIComponent(String(exportId || ''))}/download` +} + +const startChatExport = async () => { + exportError.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 startTime = toUnixSeconds(exportStartLocal.value) + const endTime = toUnixSeconds(exportEndLocal.value) + if (startTime && endTime && startTime > endTime) { + exportError.value = '时间范围不合法:开始时间不能晚于结束时间' + return + } + + isExportCreating.value = true + try { + const api = useApi() + const resp = await api.createChatExport({ + account: selectedAccount.value, + scope, + usernames, + format: exportFormat.value, + start_time: startTime, + end_time: endTime, + include_hidden: exportIncludeHidden.value, + include_official: exportIncludeOfficial.value, + include_media: exportIncludeMedia.value && !privacyMode.value, + media_kinds: (exportIncludeMedia.value && !privacyMode.value) ? exportMediaKinds.value : [], + privacy_mode: !!privacyMode.value, + file_name: exportFileName.value || null + }) + + exportJob.value = resp?.job || null + const exportId = exportJob.value?.exportId + if (exportId) startExportPolling(exportId) + } catch (e) { + exportError.value = e?.message || '创建导出任务失败' + } finally { + isExportCreating.value = false + } +} + +const cancelCurrentExport = async () => { + const exportId = exportJob.value?.exportId + if (!exportId) return + + try { + const api = useApi() + await api.cancelChatExport(exportId) + const resp = await api.getChatExport(exportId) + exportJob.value = resp?.job || exportJob.value + } catch (e) { + exportError.value = e?.message || '取消导出失败' + } +} + +const applyExportQuickRangeDays = (days) => { + const now = new Date() + const end = new Date(now.getTime()) + const start = new Date(now.getTime() - Number(days) * 24 * 3600 * 1000) + const pad = (n) => String(n).padStart(2, '0') + const fmt = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}` + exportStartLocal.value = fmt(start) + exportEndLocal.value = fmt(end) +} + const messagePageSize = 50 const messageContainerRef = ref(null) @@ -1239,6 +1814,7 @@ onMounted(() => { onUnmounted(() => { if (!process.client) return document.removeEventListener('click', onGlobalClick) + stopExportPolling() }) const loadMessages = async ({ username, reset }) => { @@ -1999,6 +2575,6 @@ const LinkCard = defineComponent({ } .privacy-blur:hover { - filter: blur(4px); + filter: none; }