正在同步导出范围...
+
@@ -1432,6 +1434,7 @@
{{ c.name }}
{{ c.isGroup ? '(群)' : '' }}
+ 补充
{{ c.username }}
diff --git a/frontend/composables/chat/useChatExport.js b/frontend/composables/chat/useChatExport.js
index 9b3452c..98147e2 100644
--- a/frontend/composables/chat/useChatExport.js
+++ b/frontend/composables/chat/useChatExport.js
@@ -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,
diff --git a/frontend/composables/useApi.js b/frontend/composables/useApi.js
index 754f84b..87aa0a0 100644
--- a/frontend/composables/useApi.js
+++ b/frontend/composables/useApi.js
@@ -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,
diff --git a/src/wechat_decrypt_tool/chat_export_service.py b/src/wechat_decrypt_tool/chat_export_service.py
index a0b0444..562b3cc 100644
--- a/src/wechat_decrypt_tool/chat_export_service.py
+++ b/src/wechat_decrypt_tool/chat_export_service.py
@@ -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,
diff --git a/src/wechat_decrypt_tool/routers/chat_export.py b/src/wechat_decrypt_tool/routers/chat_export.py
index feddffa..45d52c9 100644
--- a/src/wechat_decrypt_tool/routers/chat_export.py
+++ b/src/wechat_decrypt_tool/routers/chat_export.py
@@ -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())
diff --git a/tests/test_chat_export_targets.py b/tests/test_chat_export_targets.py
index 4325cc7..7932960 100644
--- a/tests/test_chat_export_targets.py
+++ b/tests/test_chat_export_targets.py
@@ -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()