fix(chat-export): 统一导出弹窗数量与实际目标

This commit is contained in:
2977094657
2026-06-10 20:46:44 +08:00
Unverified
parent 09a3e7d4ac
commit 9be38589f2
6 changed files with 222 additions and 9 deletions
+6 -3
View File
@@ -1311,7 +1311,7 @@
:class="exportScope === 'all' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50'"
@click="onExportBatchScopeClick('all')"
>
全部 {{ exportContactCounts.total }}
全部 {{ exportTargetsLoading ? '...' : exportContactCounts.total }}
</button>
<button
type="button"
@@ -1319,7 +1319,7 @@
:class="exportScope === 'groups' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50'"
@click="onExportBatchScopeClick('groups')"
>
群聊 {{ exportContactCounts.groups }}
群聊 {{ exportTargetsLoading ? '...' : exportContactCounts.groups }}
</button>
<button
type="button"
@@ -1327,7 +1327,7 @@
:class="exportScope === 'singles' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50'"
@click="onExportBatchScopeClick('singles')"
>
单聊 {{ exportContactCounts.singles }}
单聊 {{ exportTargetsLoading ? '...' : exportContactCounts.singles }}
</button>
<button
type="button"
@@ -1338,6 +1338,8 @@
自定义
</button>
</div>
<div v-if="exportTargetsLoading" class="mt-1 text-[11px] text-gray-400">正在同步导出范围...</div>
<div v-else-if="exportTargetsError" class="mt-1 text-[11px] text-red-500">{{ exportTargetsError }}</div>
</div>
<div>
@@ -1432,6 +1434,7 @@
<div class="text-sm text-gray-800 truncate">
{{ c.name }}
<span class="text-xs text-gray-500">{{ c.isGroup ? '(群)' : '' }}</span>
<span v-if="!c.inSessionList" class="ml-1 text-[10px] text-[#03C160]">补充</span>
</div>
<div class="text-xs text-gray-500 truncate">{{ c.username }}</div>
</div>
+85 -5
View File
@@ -45,6 +45,10 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
const exportSearchQuery = ref('')
const exportListTab = ref('all')
const exportSelectedUsernames = ref([])
const exportTargetContacts = ref([])
const exportTargetsLoading = ref(false)
const exportTargetsLoaded = ref(false)
const exportTargetsError = ref('')
const exportJob = ref(null)
let exportPollTimer = null
@@ -121,9 +125,35 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
}, [])
}
const normalizeExportTargetContact = (item) => {
const username = String(item?.username || '').trim()
const name = String(item?.name || item?.displayName || username).trim() || username
return {
...item,
username,
name,
displayName: name,
avatar: String(item?.avatar || '').trim(),
isGroup: item?.isGroup != null ? !!item.isGroup : username.endsWith('@chatroom'),
isHidden: !!item?.isHidden,
inSessionList: item?.inSessionList == null ? true : !!item.inSessionList
}
}
const getLocalExportContacts = () => {
return (Array.isArray(contacts.value) ? contacts.value : [])
.map(normalizeExportTargetContact)
.filter((contact) => !!contact.username)
}
const getExportBaseContacts = () => {
if (exportTargetsLoaded.value) return exportTargetContacts.value
return getLocalExportContacts()
}
const getExportFilteredContacts = ({ tab = exportListTab.value, query = exportSearchQuery.value } = {}) => {
const normalizedQuery = String(query || '').trim().toLowerCase()
let list = Array.isArray(contacts.value) ? contacts.value : []
let list = getExportBaseContacts()
const normalizedTab = String(tab || 'all')
if (normalizedTab === 'groups') list = list.filter((contact) => !!contact?.isGroup)
@@ -142,7 +172,7 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
})
const exportContactCounts = computed(() => {
const list = Array.isArray(contacts.value) ? contacts.value : []
const list = getExportBaseContacts()
const total = list.length
const groups = list.filter((contact) => !!contact?.isGroup).length
return { total, groups, singles: total - groups }
@@ -211,6 +241,52 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
}
}
const loadExportTargets = async ({ selectFiltered = false } = {}) => {
if (!selectedAccount.value) return
const selectedBeforeLoad = normalizeExportSelectedUsernames(exportSelectedUsernames.value)
const filteredBeforeLoad = getExportFilteredUsernames(exportListTab.value)
const selectedWasCurrentFiltered =
filteredBeforeLoad.length > 0 &&
filteredBeforeLoad.length === selectedBeforeLoad.length &&
filteredBeforeLoad.every((username) => selectedBeforeLoad.includes(username))
exportTargetsLoading.value = true
exportTargetsError.value = ''
try {
const response = await api.getChatExportTargets({
account: selectedAccount.value,
include_hidden: true,
include_official: false
})
const selectedNow = normalizeExportSelectedUsernames(exportSelectedUsernames.value)
const filteredNowBeforeLoad = getExportFilteredUsernames(exportListTab.value)
const selectedNowMatchesPreloadFilter =
filteredBeforeLoad.length > 0 &&
filteredBeforeLoad.length === selectedNow.length &&
filteredBeforeLoad.every((username) => selectedNow.includes(username))
const selectedNowMatchesCurrentFilter =
filteredNowBeforeLoad.length > 0 &&
filteredNowBeforeLoad.length === selectedNow.length &&
filteredNowBeforeLoad.every((username) => selectedNow.includes(username))
const targets = Array.isArray(response?.targets) ? response.targets : []
exportTargetContacts.value = targets
.map(normalizeExportTargetContact)
.filter((contact) => !!contact.username)
exportTargetsLoaded.value = true
if (selectFiltered || selectedWasCurrentFiltered || selectedNowMatchesPreloadFilter || selectedNowMatchesCurrentFilter) {
selectExportFilteredContacts(exportListTab.value)
}
} catch (error) {
exportTargetsLoaded.value = false
exportTargetContacts.value = []
exportTargetsError.value = error?.message || '加载导出范围失败'
if (!exportError.value) {
exportError.value = `加载导出范围失败:${exportTargetsError.value}`
}
} finally {
exportTargetsLoading.value = false
}
}
const isDesktopExportRuntime = () => {
return !!(process.client && window?.wechatDesktop?.chooseDirectory)
}
@@ -416,6 +492,9 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
const openExportModal = () => {
exportModalOpen.value = true
exportError.value = ''
exportTargetsError.value = ''
exportTargetsLoaded.value = false
exportTargetContacts.value = []
resetExportSaveFeedback({ resetAutoSavedFor: true })
exportCancelRequested.value = false
exportSearchQuery.value = ''
@@ -426,9 +505,7 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
exportMessageTypes.value = exportMessageTypeOptions.map((item) => item.value)
exportAutoSavedFor.value = ''
exportScope.value = selectedContact.value?.username ? 'current' : 'selected'
if (!selectedContact.value?.username) {
selectExportFilteredContacts('all')
}
loadExportTargets({ selectFiltered: !selectedContact.value?.username })
}
const closeExportModal = () => {
@@ -627,6 +704,9 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
exportJob,
exportOverallPercent,
exportCurrentPercent,
exportTargetsLoading,
exportTargetsLoaded,
exportTargetsError,
exportFilteredContacts,
exportContactCounts,
onExportBatchScopeClick,
+10
View File
@@ -449,6 +449,15 @@ export const useApi = () => {
return await request('/chat/exports')
}
const getChatExportTargets = async (params = {}) => {
const query = new URLSearchParams()
if (params && params.account) query.set('account', params.account)
if (params && params.include_hidden != null) query.set('include_hidden', String(!!params.include_hidden))
if (params && params.include_official != null) query.set('include_official', String(!!params.include_official))
const url = '/chat/exports/targets' + (query.toString() ? `?${query.toString()}` : '')
return await request(url)
}
const cancelChatExport = async (exportId) => {
if (!exportId) throw new Error('Missing exportId')
return await request(`/chat/exports/${encodeURIComponent(String(exportId))}`, { method: 'DELETE' })
@@ -693,6 +702,7 @@ export const useApi = () => {
createChatExport,
getChatExport,
listChatExports,
getChatExportTargets,
cancelChatExport,
createSnsExport,
getSnsExport,
@@ -43,6 +43,7 @@ from .chat_helpers import (
_parse_location_message,
_parse_system_message_content,
_parse_pat_message,
_build_avatar_url,
_pick_display_name,
_quote_ident,
_resolve_account_dir,
@@ -3745,6 +3746,73 @@ def _load_message_backed_export_targets(*, account_dir: Path, seed_usernames: se
return out
def build_chat_export_targets_preview(
*,
account_dir: Path,
include_hidden: bool = True,
include_official: bool = False,
base_url: str = "",
) -> dict[str, Any]:
targets = _resolve_export_targets(
account_dir=account_dir,
scope="all",
usernames=[],
include_hidden=bool(include_hidden),
include_official=bool(include_official),
)
session_rows, session_hidden_by_username = _load_export_session_targets(account_dir)
session_usernames = {u for u, _sort_ts in session_rows}
contact_rows = _load_contact_rows(account_dir / "contact.db", targets)
base = str(base_url or "").rstrip("/")
conversations: list[dict[str, Any]] = []
for u in targets:
row = contact_rows.get(u)
display_name = _pick_display_name(row, u) if row is not None else u
avatar_path = _build_avatar_url(account_dir.name, u)
conversations.append(
{
"username": u,
"name": display_name,
"displayName": display_name,
"isGroup": bool(u.endswith("@chatroom")),
"isHidden": bool(int(session_hidden_by_username.get(u) or 0) == 1),
"inSessionList": bool(u in session_usernames),
"avatar": f"{base}{avatar_path}" if base else avatar_path,
}
)
group_count = sum(1 for item in conversations if bool(item.get("isGroup")))
return {
"status": "success",
"account": account_dir.name,
"includeHidden": bool(include_hidden),
"includeOfficial": bool(include_official),
"targets": conversations,
"counts": {
"total": len(conversations),
"groups": group_count,
"singles": len(conversations) - group_count,
},
}
def get_chat_export_targets_preview(
*,
account: Optional[str],
include_hidden: bool = True,
include_official: bool = False,
base_url: str = "",
) -> dict[str, Any]:
account_dir = _resolve_account_dir(account)
return build_chat_export_targets_preview(
account_dir=account_dir,
include_hidden=include_hidden,
include_official=include_official,
base_url=base_url,
)
def _conversation_dir_name(
idx: int,
display_name: str,
+17 -1
View File
@@ -7,7 +7,7 @@ from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import FileResponse, StreamingResponse
from pydantic import BaseModel, Field
from ..chat_export_service import CHAT_EXPORT_MANAGER
from ..chat_export_service import CHAT_EXPORT_MANAGER, get_chat_export_targets_preview
from ..path_fix import PathFixRoute
router = APIRouter(route_class=PathFixRoute)
@@ -101,6 +101,22 @@ async def list_chat_exports():
return {"status": "success", "jobs": jobs}
@router.get("/api/chat/exports/targets", summary="获取聊天记录导出目标预览")
async def preview_chat_export_targets(
request: Request,
account: Optional[str] = None,
include_hidden: bool = True,
include_official: bool = False,
):
base_url = str(request.base_url).rstrip("/")
return get_chat_export_targets_preview(
account=account,
include_hidden=bool(include_hidden),
include_official=bool(include_official),
base_url=base_url,
)
@router.get("/api/chat/exports/{export_id}", summary="获取导出任务状态")
async def get_chat_export(export_id: str):
job = CHAT_EXPORT_MANAGER.get_job(str(export_id or "").strip())
+36
View File
@@ -48,6 +48,7 @@ class TestChatExportTargets(unittest.TestCase):
("wxid_no_session", "", "No session friend", "", 1, 0, "", ""),
("wxid_session_hidden", "", "Hidden session friend", "", 1, 0, "", ""),
("room_no_session@chatroom", "", "No session group", "", 1, 0, "", ""),
("room_hidden@chatroom", "", "Hidden session group", "", 1, 0, "", ""),
("gh_official_no_session", "", "Official account", "", 1, 24, "", ""),
("wxid_no_messages", "", "No messages friend", "", 1, 0, "", ""),
]
@@ -70,6 +71,7 @@ class TestChatExportTargets(unittest.TestCase):
)
conn.execute("INSERT INTO SessionTable VALUES (?, ?, ?)", ("wxid_visible", 0, 100))
conn.execute("INSERT INTO SessionTable VALUES (?, ?, ?)", ("wxid_session_hidden", 1, 200))
conn.execute("INSERT INTO SessionTable VALUES (?, ?, ?)", ("room_hidden@chatroom", 1, 250))
conn.commit()
finally:
conn.close()
@@ -84,6 +86,7 @@ class TestChatExportTargets(unittest.TestCase):
"wxid_no_session",
"wxid_session_hidden",
"room_no_session@chatroom",
"room_hidden@chatroom",
"gh_official_no_session",
"wxid_no_messages",
]
@@ -95,6 +98,7 @@ class TestChatExportTargets(unittest.TestCase):
"wxid_no_session": 300,
"wxid_session_hidden": 400,
"room_no_session@chatroom": 350,
"room_hidden@chatroom": 450,
"gh_official_no_session": 360,
}
for username, create_time in message_usernames.items():
@@ -148,6 +152,7 @@ class TestChatExportTargets(unittest.TestCase):
self.assertIn("wxid_no_session", targets)
self.assertIn("room_no_session@chatroom", targets)
self.assertNotIn("wxid_session_hidden", targets)
self.assertNotIn("room_hidden@chatroom", targets)
self.assertNotIn("gh_official_no_session", targets)
self.assertNotIn("wxid_no_messages", targets)
@@ -184,6 +189,37 @@ class TestChatExportTargets(unittest.TestCase):
self.assertNotIn("room_no_session@chatroom", singles)
self.assertIn("gh_official_no_session", with_official)
def test_preview_counts_match_bulk_export_targets_including_hidden_sessions(self):
import wechat_decrypt_tool.chat_export_service as svc
with TemporaryDirectory() as td:
account_dir = self._prepare_account(Path(td))
preview = svc.build_chat_export_targets_preview(
account_dir=account_dir,
include_hidden=True,
include_official=False,
base_url="http://example.test",
)
actual_targets = svc._resolve_export_targets(
account_dir=account_dir,
scope="all",
usernames=[],
include_hidden=True,
include_official=False,
)
preview_targets = preview["targets"]
preview_usernames = [item["username"] for item in preview_targets]
by_username = {item["username"]: item for item in preview_targets}
self.assertEqual(preview_usernames, actual_targets)
self.assertEqual(preview["counts"], {"total": 5, "groups": 2, "singles": 3})
self.assertTrue(by_username["room_hidden@chatroom"]["isHidden"])
self.assertTrue(by_username["room_hidden@chatroom"]["inSessionList"])
self.assertFalse(by_username["room_no_session@chatroom"]["inSessionList"])
self.assertTrue(by_username["room_no_session@chatroom"]["avatar"].startswith("http://example.test/api/chat/avatar?"))
if __name__ == "__main__":
unittest.main()