fix(chat-export): 修复批量导出默认范围与会话选择异常

- 修复未选中会话时导出弹窗落入无效默认范围的问题

- 支持按全部、群聊、单聊快速切换批量导出范围,并默认选中当前筛选结果

- 优化会话列表整行点击、高亮反馈和选择去重逻辑
This commit is contained in:
2977094657
2026-03-21 14:21:01 +08:00
Unverified
parent fb6601251b
commit 4313c5e34c
2 changed files with 141 additions and 56 deletions
+50 -48
View File
@@ -1290,52 +1290,77 @@
</div>
<div class="space-y-5">
<div class="flex flex-wrap items-end gap-6">
<div class="flex flex-wrap items-end gap-3 xl:flex-nowrap">
<div>
<div class="text-sm font-medium text-gray-800 mb-2">范围</div>
<div class="flex flex-wrap gap-2 text-sm text-gray-700">
<label class="flex items-center gap-1.5 px-3 py-1.5 rounded-md border cursor-pointer transition-colors" :class="exportScope === 'current' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50'">
<input type="radio" value="current" v-model="exportScope" class="hidden" />
<span>当前会话</span>
</label>
<label class="flex items-center gap-1.5 px-3 py-1.5 rounded-md border cursor-pointer transition-colors" :class="exportScope === 'selected' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50'">
<input type="radio" value="selected" v-model="exportScope" class="hidden" />
<span>选择会话批量</span>
</label>
<div class="flex flex-wrap items-center gap-2 text-sm text-gray-700">
<button
type="button"
class="px-2.5 py-1 text-xs rounded-md border transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
:class="exportScope === 'current' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50'"
:disabled="!selectedContact?.username"
@click="exportScope = 'current'"
>
当前会话
</button>
<button
type="button"
class="px-2.5 py-1 text-xs rounded-md border transition-colors"
:class="exportScope === 'selected' && exportListTab === 'all' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50'"
@click="onExportBatchScopeClick('all')"
>
全部 {{ exportContactCounts.total }}
</button>
<button
type="button"
class="px-2.5 py-1 text-xs rounded-md border transition-colors"
:class="exportScope === 'selected' && exportListTab === 'groups' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50'"
@click="onExportBatchScopeClick('groups')"
>
群聊 {{ exportContactCounts.groups }}
</button>
<button
type="button"
class="px-2.5 py-1 text-xs rounded-md border transition-colors"
:class="exportScope === 'selected' && exportListTab === 'singles' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50'"
@click="onExportBatchScopeClick('singles')"
>
单聊 {{ exportContactCounts.singles }}
</button>
</div>
</div>
<div>
<div class="text-sm font-medium text-gray-800 mb-2">格式</div>
<div class="flex items-center gap-2 text-sm text-gray-700">
<label class="flex items-center gap-1.5 px-3 py-1.5 rounded-md border cursor-pointer transition-colors" :class="exportFormat === 'json' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50'">
<label class="flex items-center gap-1 px-2.5 py-1 text-xs rounded-md border cursor-pointer transition-colors" :class="exportFormat === 'json' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50'">
<input type="radio" value="json" v-model="exportFormat" class="hidden" />
<span>JSON</span>
</label>
<label class="flex items-center gap-1.5 px-3 py-1.5 rounded-md border cursor-pointer transition-colors" :class="exportFormat === 'txt' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50'">
<label class="flex items-center gap-1 px-2.5 py-1 text-xs rounded-md border cursor-pointer transition-colors" :class="exportFormat === 'txt' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50'">
<input type="radio" value="txt" v-model="exportFormat" class="hidden" />
<span>TXT</span>
</label>
<label class="flex items-center gap-1.5 px-3 py-1.5 rounded-md border cursor-pointer transition-colors" :class="exportFormat === 'html' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50'">
<label class="flex items-center gap-1 px-2.5 py-1 text-xs rounded-md border cursor-pointer transition-colors" :class="exportFormat === 'html' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50'">
<input type="radio" value="html" v-model="exportFormat" class="hidden" />
<span>HTML</span>
</label>
</div>
</div>
<div class="flex-1 min-w-[320px]">
<div class="flex-1 min-w-[280px]">
<div class="text-sm font-medium text-gray-800 mb-2">时间范围可选</div>
<div class="flex items-center gap-2 flex-wrap">
<div class="flex items-center gap-1.5 flex-wrap">
<input
v-model="exportStartLocal"
type="datetime-local"
class="px-2.5 py-1.5 text-sm rounded-md border border-gray-200 focus:outline-none focus:ring-2 focus:ring-[#03C160]/30"
class="px-2 py-1 text-xs rounded-md border border-gray-200 focus:outline-none focus:ring-2 focus:ring-[#03C160]/30"
/>
<span class="text-gray-400">-</span>
<input
v-model="exportEndLocal"
type="datetime-local"
class="px-2.5 py-1.5 text-sm rounded-md border border-gray-200 focus:outline-none focus:ring-2 focus:ring-[#03C160]/30"
class="px-2 py-1 text-xs rounded-md border border-gray-200 focus:outline-none focus:ring-2 focus:ring-[#03C160]/30"
/>
</div>
</div>
@@ -1367,32 +1392,8 @@
</div>
<div v-if="exportScope === 'selected'" class="mt-3">
<div class="flex items-center gap-2 mb-2">
<button
type="button"
class="text-xs px-2 py-1 rounded border border-gray-200"
:class="exportListTab === 'all' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white hover:bg-gray-50 text-gray-700'"
@click="exportListTab = 'all'"
>
全部 {{ exportContactCounts.total }}
</button>
<button
type="button"
class="text-xs px-2 py-1 rounded border border-gray-200"
:class="exportListTab === 'groups' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white hover:bg-gray-50 text-gray-700'"
@click="exportListTab = 'groups'"
>
群聊 {{ exportContactCounts.groups }}
</button>
<button
type="button"
class="text-xs px-2 py-1 rounded border border-gray-200"
:class="exportListTab === 'singles' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white hover:bg-gray-50 text-gray-700'"
@click="exportListTab = 'singles'"
>
单聊 {{ exportContactCounts.singles }}
</button>
<div class="ml-auto text-xs text-gray-500">点击 tab 筛选</div>
<div class="mb-2 text-xs text-gray-500">
点击上方范围可筛选并默认全选当前结果再次点击可取消全选下方整行可点选会话
</div>
<div class="flex items-center gap-2 mb-2">
<input
@@ -1404,26 +1405,27 @@
/>
</div>
<div class="border border-gray-200 rounded-md max-h-56 overflow-y-auto">
<div
<label
v-for="c in exportFilteredContacts"
:key="c.username"
class="px-3 py-2 border-b border-gray-100 flex items-center gap-2 hover:bg-gray-50"
class="px-3 py-2 border-b border-gray-100 flex items-center gap-2 cursor-pointer transition-colors"
:class="isExportContactSelected(c.username) ? 'bg-[#03C160]/5 hover:bg-[#03C160]/10' : 'hover:bg-gray-50'"
>
<input type="checkbox" :value="c.username" v-model="exportSelectedUsernames" />
<input type="checkbox" :value="c.username" v-model="exportSelectedUsernames" class="cursor-pointer" />
<div class="w-9 h-9 rounded-md overflow-hidden bg-gray-200 flex-shrink-0" :class="{ 'privacy-blur': privacyMode }">
<img v-if="c.avatar" :src="c.avatar" :alt="c.name + '头像'" class="w-full h-full object-cover" referrerpolicy="no-referrer" @error="onAvatarError($event, c)" />
<div v-else class="w-full h-full flex items-center justify-center text-xs font-bold text-gray-600">
{{ (c.name || c.username || '?').charAt(0) }}
</div>
</div>
<div class="min-w-0" :class="{ 'privacy-blur': privacyMode }">
<div class="min-w-0 flex-1" :class="{ 'privacy-blur': privacyMode }">
<div class="text-sm text-gray-800 truncate">
{{ c.name }}
<span class="text-xs text-gray-500">{{ c.isGroup ? '(群)' : '' }}</span>
</div>
<div class="text-xs text-gray-500 truncate">{{ c.username }}</div>
</div>
</div>
</label>
<div v-if="exportFilteredContacts.length === 0" class="px-3 py-3 text-sm text-gray-500">
无匹配会话
</div>
+91 -8
View File
@@ -73,20 +73,35 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
return Math.round(clamp01(done / total) * 100)
})
const exportFilteredContacts = computed(() => {
const query = String(exportSearchQuery.value || '').trim().toLowerCase()
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 getExportFilteredContacts = ({ tab = exportListTab.value, query = exportSearchQuery.value } = {}) => {
const normalizedQuery = String(query || '').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)
const normalizedTab = String(tab || 'all')
if (normalizedTab === 'groups') list = list.filter((contact) => !!contact?.isGroup)
if (normalizedTab === 'singles') list = list.filter((contact) => !contact?.isGroup)
if (!query) return list
if (!normalizedQuery) 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)
return name.includes(normalizedQuery) || username.includes(normalizedQuery)
})
}
const exportFilteredContacts = computed(() => {
return getExportFilteredContacts()
})
const exportContactCounts = computed(() => {
@@ -96,6 +111,60 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
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) => {
exportScope.value = 'selected'
onExportListTabClick(tab)
}
const isDesktopExportRuntime = () => {
return !!(process.client && window?.wechatDesktop?.chooseDirectory)
}
@@ -269,12 +338,17 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
exportModalOpen.value = true
exportError.value = ''
exportSaveMsg.value = ''
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' : 'all'
exportScope.value = selectedContact.value?.username ? 'current' : 'selected'
if (!selectedContact.value?.username) {
selectExportFilteredContacts('all')
}
}
const closeExportModal = () => {
@@ -296,6 +370,12 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
}
})
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 || ''),
@@ -447,6 +527,9 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
exportCurrentPercent,
exportFilteredContacts,
exportContactCounts,
onExportBatchScopeClick,
onExportListTabClick,
isExportContactSelected,
hasWebExportFolder,
chooseExportFolder,
getExportDownloadUrl,