mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
fix(chat-export): 统一导出弹窗数量与实际目标
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user