当前:{{ exportJob.progress?.currentConversationName || exportJob.progress?.currentConversationUsername }}
@@ -1621,8 +1650,17 @@
{{ exportJob.error || '导出失败' }}
@@ -1808,6 +1847,7 @@ useHead({
const route = useRoute()
const isSnsRoute = computed(() => route.path?.startsWith('/sns'))
+const isContactsRoute = computed(() => route.path?.startsWith('/contacts'))
const isWrappedRoute = computed(() => route.path?.startsWith('/wrapped'))
const routeUsername = computed(() => {
@@ -1821,6 +1861,12 @@ const buildChatPath = (username) => {
// 响应式数据
const selectedContact = ref(null)
+const contactProfileCardOpen = ref(false)
+const contactProfileCardMessageId = ref('')
+const contactProfileLoading = ref(false)
+const contactProfileError = ref('')
+const contactProfileData = ref(null)
+let contactProfileHoverHideTimer = null
// 隐私模式
const privacyMode = ref(false)
@@ -2128,6 +2174,10 @@ const goSns = async () => {
await navigateTo('/sns')
}
+const goContacts = async () => {
+ await navigateTo('/contacts')
+}
+
const goWrapped = async () => {
await navigateTo('/wrapped')
}
@@ -2642,7 +2692,6 @@ const exportError = ref('')
// current: 当前会话(映射为 selected + 单个 username)
const exportScope = ref('current') // current | selected | all | groups | singles
const exportFormat = ref('json') // json | txt
-const exportMessageTypeMode = ref('all') // all | filter
const exportMessageTypeOptions = [
{ value: 'text', label: '文本' },
{ value: 'image', label: '图片' },
@@ -2658,14 +2707,15 @@ const exportMessageTypeOptions = [
{ value: 'voip', label: '通话' }
]
const exportMessageTypes = ref(exportMessageTypeOptions.map((x) => x.value))
-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 exportFolder = ref('')
+const exportFolderHandle = ref(null)
+const exportSaveBusy = ref(false)
+const exportSaveMsg = ref('')
+const exportAutoSavedFor = ref('')
const exportSearchQuery = ref('')
const exportListTab = ref('all') // all | groups | singles
@@ -2719,6 +2769,153 @@ const exportFilteredContacts = computed(() => {
})
})
+const contactProfileResolvedName = computed(() => {
+ const profile = contactProfileData.value || {}
+ const displayName = String(profile?.displayName || '').trim()
+ if (displayName) return displayName
+ const contactName = String(selectedContact.value?.name || '').trim()
+ if (contactName) return contactName
+ return String(profile?.username || selectedContact.value?.username || '').trim()
+})
+
+const contactProfileResolvedUsername = computed(() => {
+ const profile = contactProfileData.value || {}
+ return String(profile?.username || selectedContact.value?.username || '').trim()
+})
+
+const contactProfileResolvedNickname = computed(() => {
+ return String(contactProfileData.value?.nickname || '').trim()
+})
+
+const contactProfileResolvedAlias = computed(() => {
+ return String(contactProfileData.value?.alias || '').trim()
+})
+
+const contactProfileResolvedRegion = computed(() => {
+ return String(contactProfileData.value?.region || '').trim()
+})
+
+const contactProfileResolvedRemark = computed(() => {
+ return String(contactProfileData.value?.remark || '').trim()
+})
+
+const contactProfileResolvedSource = computed(() => {
+ return String(contactProfileData.value?.source || '').trim()
+})
+
+const contactProfileResolvedSourceScene = computed(() => {
+ const value = contactProfileData.value?.sourceScene
+ if (value == null || value === '') return null
+ const n = Number(value)
+ return Number.isFinite(n) ? n : null
+})
+
+const contactProfileResolvedAvatar = computed(() => {
+ const profileAvatar = String(contactProfileData.value?.avatar || '').trim()
+ if (profileAvatar) return profileAvatar
+ return String(selectedContact.value?.avatar || '').trim()
+})
+
+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 (e) {
+ exportError.value = e?.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 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 resp = await fetch(getExportDownloadUrl(exportId))
+ if (!resp.ok) {
+ throw new Error(`下载导出文件失败(${resp.status})`)
+ }
+ const blob = await resp.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 (e) {
+ exportError.value = e?.message || '保存到浏览器目录失败'
+ } finally {
+ exportSaveBusy.value = false
+ }
+}
+
const exportContactCounts = computed(() => {
const list = Array.isArray(contacts.value) ? contacts.value : []
const total = list.length
@@ -2826,11 +3023,12 @@ const startExportPolling = (exportId) => {
const openExportModal = () => {
exportModalOpen.value = true
exportError.value = ''
+ exportSaveMsg.value = ''
exportListTab.value = 'all'
-
- if (privacyMode.value) {
- exportIncludeMedia.value = false
- }
+ exportStartLocal.value = ''
+ exportEndLocal.value = ''
+ exportMessageTypes.value = exportMessageTypeOptions.map((x) => x.value)
+ exportAutoSavedFor.value = ''
if (selectedContact.value?.username) {
exportScope.value = 'current'
@@ -2844,6 +3042,136 @@ const closeExportModal = () => {
exportError.value = ''
}
+const fetchContactProfile = async (options = {}) => {
+ const username = String(options?.username || contactProfileData.value?.username || selectedContact.value?.username || '').trim()
+ const displayNameFallback = String(options?.displayName || '').trim()
+ const avatarFallback = String(options?.avatar || '').trim()
+ const account = String(selectedAccount.value || '').trim()
+ if (!username || !account) {
+ contactProfileData.value = null
+ return
+ }
+
+ contactProfileLoading.value = true
+ contactProfileError.value = ''
+ try {
+ const api = useApi()
+ const resp = await api.listChatContacts({
+ account,
+ include_friends: true,
+ include_groups: true,
+ include_officials: true,
+ })
+ const list = Array.isArray(resp?.contacts) ? resp.contacts : []
+ const matched = list.find((item) => String(item?.username || '').trim() === username)
+ if (matched) {
+ const normalized = {
+ ...matched,
+ username,
+ }
+ if (!String(normalized.displayName || '').trim() && displayNameFallback) {
+ normalized.displayName = displayNameFallback
+ }
+ if (!String(normalized.avatar || '').trim() && avatarFallback) {
+ normalized.avatar = avatarFallback
+ }
+ contactProfileData.value = normalized
+ } else {
+ contactProfileData.value = {
+ username,
+ displayName: displayNameFallback || selectedContact.value?.name || username,
+ avatar: avatarFallback || selectedContact.value?.avatar || '',
+ nickname: '',
+ alias: '',
+ region: '',
+ remark: '',
+ source: '',
+ sourceScene: null,
+ }
+ }
+ } catch (e) {
+ contactProfileData.value = {
+ username,
+ displayName: displayNameFallback || selectedContact.value?.name || username,
+ avatar: avatarFallback || selectedContact.value?.avatar || '',
+ nickname: '',
+ alias: '',
+ region: '',
+ remark: '',
+ source: '',
+ sourceScene: null,
+ }
+ contactProfileError.value = e?.message || '加载联系人资料失败'
+ } finally {
+ contactProfileLoading.value = false
+ }
+}
+
+const clearContactProfileHoverHideTimer = () => {
+ if (contactProfileHoverHideTimer) {
+ clearTimeout(contactProfileHoverHideTimer)
+ contactProfileHoverHideTimer = null
+ }
+}
+
+const closeContactProfileCard = () => {
+ contactProfileCardOpen.value = false
+ contactProfileCardMessageId.value = ''
+}
+
+const onMessageAvatarMouseEnter = async (message) => {
+ const isSent = !!message?.isSent
+ if (isSent) return
+ const messageId = String(message?.id ?? '').trim()
+ if (!messageId) return
+ const username = String(message?.senderUsername || '').trim()
+ if (!username || username === 'self') return
+
+ const senderName = String(message?.senderDisplayName || message?.sender || '').trim()
+ const senderAvatar = String(message?.avatar || '').trim()
+ if (!contactProfileData.value || String(contactProfileData.value?.username || '').trim() !== username) {
+ contactProfileData.value = {
+ username,
+ displayName: senderName || username,
+ avatar: senderAvatar,
+ nickname: '',
+ alias: '',
+ region: '',
+ remark: '',
+ source: '',
+ sourceScene: null,
+ }
+ } else {
+ if (!String(contactProfileData.value?.displayName || '').trim() && senderName) {
+ contactProfileData.value.displayName = senderName
+ }
+ if (!String(contactProfileData.value?.avatar || '').trim() && senderAvatar) {
+ contactProfileData.value.avatar = senderAvatar
+ }
+ }
+
+ clearContactProfileHoverHideTimer()
+ contactProfileCardMessageId.value = messageId
+ contactProfileCardOpen.value = true
+ await fetchContactProfile({ username, displayName: senderName, avatar: senderAvatar })
+}
+
+const onMessageAvatarMouseLeave = () => {
+ clearContactProfileHoverHideTimer()
+ contactProfileHoverHideTimer = setTimeout(() => {
+ closeContactProfileCard()
+ }, 120)
+}
+
+const onContactCardMouseEnter = () => {
+ clearContactProfileHoverHideTimer()
+}
+
+const toggleRealtimeFromSidebar = async () => {
+ if (realtimeChecking.value) return
+ await toggleRealtime()
+}
+
watch(exportModalOpen, (open) => {
if (!process.client) return
if (!open) {
@@ -2858,6 +3186,40 @@ watch(exportModalOpen, (open) => {
}
})
+watch(
+ () => selectedContact.value?.username,
+ () => {
+ clearContactProfileHoverHideTimer()
+ closeContactProfileCard()
+ contactProfileError.value = ''
+ contactProfileData.value = null
+ }
+)
+
+watch(
+ () => selectedAccount.value,
+ () => {
+ clearContactProfileHoverHideTimer()
+ closeContactProfileCard()
+ contactProfileError.value = ''
+ contactProfileData.value = null
+ }
+)
+
+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 getExportDownloadUrl = (exportId) => {
const base = process.client ? 'http://localhost:8000' : ''
return `${base}/api/chat/exports/${encodeURIComponent(String(exportId || ''))}/download`
@@ -2865,6 +3227,7 @@ const getExportDownloadUrl = (exportId) => {
const startChatExport = async () => {
exportError.value = ''
+ exportSaveMsg.value = ''
if (!selectedAccount.value) {
exportError.value = '未选择账号'
return
@@ -2886,6 +3249,13 @@ const startChatExport = async () => {
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) {
@@ -2893,15 +3263,28 @@ const startChatExport = async () => {
return
}
- const messageTypes = exportMessageTypeMode.value === 'filter'
- ? (Array.isArray(exportMessageTypes.value) ? exportMessageTypes.value.filter(Boolean) : [])
- : []
- if (exportMessageTypeMode.value === 'filter' && messageTypes.length === 0) {
- exportError.value = '请选择至少一个消息类型'
+ const messageTypes = Array.isArray(exportMessageTypes.value) ? exportMessageTypes.value.filter(Boolean) : []
+ if (messageTypes.length === 0) {
+ exportError.value = '请至少勾选一个消息类型'
return
}
+ const selectedTypeSet = new Set(messageTypes.map((t) => String(t || '').trim()))
+ const mediaKindSet = new Set()
+ 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 api = useApi()
const resp = await api.createChatExport({
@@ -2911,11 +3294,12 @@ const startChatExport = async () => {
format: exportFormat.value,
start_time: startTime,
end_time: endTime,
- include_hidden: exportIncludeHidden.value,
- include_official: exportIncludeOfficial.value,
+ include_hidden: false,
+ include_official: false,
message_types: messageTypes,
- include_media: exportIncludeMedia.value && !privacyMode.value,
- media_kinds: (exportIncludeMedia.value && !privacyMode.value) ? exportMediaKinds.value : [],
+ include_media: includeMedia,
+ media_kinds: mediaKinds,
+ output_dir: isDesktopExportRuntime() ? String(exportFolder.value || '').trim() : null,
privacy_mode: !!privacyMode.value,
file_name: exportFileName.value || null
})
@@ -2944,16 +3328,6 @@ const cancelCurrentExport = async () => {
}
}
-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)
@@ -4829,6 +5203,10 @@ const onGlobalKeyDown = (e) => {
if (contextMenu.value.visible) closeContextMenu()
if (previewImageUrl.value) closeImagePreview()
if (chatHistoryModalVisible.value) closeChatHistoryModal()
+ if (contactProfileCardOpen.value) {
+ clearContactProfileHoverHideTimer()
+ closeContactProfileCard()
+ }
if (messageSearchSenderDropdownOpen.value) closeMessageSearchSenderDropdown()
if (messageSearchOpen.value) closeMessageSearch()
if (searchContext.value?.active) exitSearchContext()
@@ -4845,6 +5223,7 @@ onUnmounted(() => {
if (!process.client) return
document.removeEventListener('click', onGlobalClick)
document.removeEventListener('keydown', onGlobalKeyDown)
+ clearContactProfileHoverHideTimer()
stopSessionListResize()
if (messageSearchDebounceTimer) clearTimeout(messageSearchDebounceTimer)
messageSearchDebounceTimer = null
diff --git a/frontend/pages/contacts.vue b/frontend/pages/contacts.vue
new file mode 100644
index 0000000..b72d6ea
--- /dev/null
+++ b/frontend/pages/contacts.vue
@@ -0,0 +1,572 @@
+
+
+
+
+
+
+
![avatar]()
+
我
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 总计 {{ counts.total }}
+
+
+
+
加载中…
+
{{ error }}
+
暂无联系人
+
+
+
+
![]()
+
{{ contact.displayName?.charAt(0) || '?' }}
+
+
+
{{ contact.displayName }}
+
{{ contact.username }}
+
+ 地区:{{ contact.region }}
+ ·
+ 来源:{{ contact.source }}
+
+
+
+ {{ typeLabel(contact.type) }}
+
+
+
+
+
+
+
+
+
导出联系人
+
支持 JSON / CSV,默认包含头像链接
+
+
+
+
+
+
+
+
+
+
导出目录
+
{{ exportFolder || '未选择' }}
+
+
+
+
+
+
{{ exportMsg }}
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/pages/sns.vue b/frontend/pages/sns.vue
index 5582806..e8ad502 100644
--- a/frontend/pages/sns.vue
+++ b/frontend/pages/sns.vue
@@ -68,6 +68,26 @@