From 0a3aad6ba37a40a38b6c6993d3542d07e5867e34 Mon Sep 17 00:00:00 2001
From: 2977094657 <2977094657@qq.com>
Date: Tue, 23 Dec 2025 20:26:21 +0800
Subject: [PATCH] =?UTF-8?q?feat(chat):=20=E8=81=8A=E5=A4=A9=E9=A1=B5?=
=?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=AF=BC=E5=87=BA=E5=BC=B9=E7=AA=97=E4=B8=8E?=
=?UTF-8?q?=E8=BF=9B=E5=BA=A6=E5=B1=95=E7=A4=BA?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 导出弹窗支持范围/格式/时间范围/媒体开关/文件名等参数
- 批量会话列表展示头像,提供 全部/群聊/单聊 tab 与搜索
- 导出进度使用 SSE 实时更新(失败回退轮询),提供进度条展示
- 支持任务取消与 ZIP 下载
- 隐私模式下导出同步隐私策略,且 hover 不再保持模糊
---
frontend/composables/useApi.js | 42 +-
frontend/pages/chat/[[username]].vue | 578 ++++++++++++++++++++++++++-
2 files changed, 618 insertions(+), 2 deletions(-)
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 }}
+
+
+
+
+
+ {{ 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;
}