diff --git a/frontend/pages/sns.vue b/frontend/pages/sns.vue index 53f113a..6e37b16 100644 --- a/frontend/pages/sns.vue +++ b/frontend/pages/sns.vue @@ -5,7 +5,7 @@
朋友圈联系人
-
{{ snsUsers.length }}
+
{{ visibleSnsUsers.length }}
-
导出格式
-
- -
-
- -
-
-
导出目录
-
{{ exportFolderModeText }}
-
-
- {{ exportFolder || '未选择' }} -
-
- - -
-
{{ exportFolderHint }}
-
{{ exportSaveProgressText }}
-
{{ exportSaveMsg }}
-
{{ exportSaveError }}
-
- -
- -
-
{{ exportError }}
-
-
-
任务:{{ exportJob.exportId }}
-
状态:{{ exportStatusText }}
-
- -
-
动态:{{ exportJob.progress?.postsExported || 0 }}/{{ exportJob.progress?.postsTotal || 0 }}
-
{{ exportOverallPercent }}%
-
-
-
-
- -
-
联系人:{{ exportJob.progress?.usersDone || 0 }}/{{ exportJob.progress?.usersTotal || 0 }}
-
格式:{{ exportActiveFormatLabel }}
-
- -
-
-
- 当前:{{ exportCurrentTargetLabel }}({{ exportJob.progress?.currentUserPostsDone || 0 }}/{{ exportJob.progress?.currentUserPostsTotal || 0 }}) -
-
- {{ exportCurrentPercent }}% - -
-
-
-
-
-
-
- -
- 媒体:{{ exportJob.progress?.mediaCopied || 0 }};缺失:{{ exportJob.progress?.mediaMissing || 0 }} -
- -
- 已导出到:{{ exportOutputPathText }} -
- -
- -
@@ -163,11 +45,12 @@ >
- + +
+
+
+
+
+
导出朋友圈(离线 ZIP)
+
+ 直接勾选要导出的联系人;支持搜索、批量勾选,以及自定义 ZIP 文件名和导出目录。 +
+
+ +
+ +
+
{{ exportError }}
+ +
+
+
+
当前导出任务
+
ID:{{ exportJob.exportId || '-' }}
+
+
+ + 状态:{{ exportStatusText }} + + + {{ exportOverallPercent }}% + + + +
+
+ +
+
+
动态:{{ exportJob.progress?.postsExported || 0 }}/{{ exportJob.progress?.postsTotal || 0 }}
+
{{ exportOverallPercent }}%
+
+
+
+
+
+ +
+
+ 联系人:{{ exportJob.progress?.usersDone || 0 }}/{{ exportJob.progress?.usersTotal || 0 }} +
+
+ 格式:{{ exportActiveFormatLabel }} +
+
+ 已复制媒体:{{ exportJob.progress?.mediaCopied || 0 }} +
+
+ 缺失媒体:{{ exportJob.progress?.mediaMissing || 0 }} +
+
+ +
+
+
+ 当前联系人:{{ exportCurrentTargetLabel }} + ({{ exportJob.progress?.currentUserPostsDone || 0 }}/{{ exportJob.progress?.currentUserPostsTotal || 0 }}) +
+
+ {{ exportCurrentPercent }}% + +
+
+
+
+
+
+
+ +
+ 已发送取消请求,正在等待当前步骤结束… +
+
+ 导出已取消。 +
+
+ {{ exportJob.error }} +
+ +
+ 已导出到:{{ exportOutputPathText }} +
+
+ +
+
+
导出格式
+
+ +
+
+ +
+ + +
+ +
+
+
导出目录
+
{{ exportFolderModeText }}
+
+
+
+ {{ exportFolder || '未选择' }} +
+ + +
+
+
+
{{ exportFolderHint }}
+ +
+
+
选择联系人
+
+
已选 {{ exportSelectedCount }} 人
+ + +
+
+ +
+ +
+
+
+ 未找到可导出的联系人 +
+ +
+
+ 默认按勾选联系人导出;如需全部导出,直接点“全选当前结果”即可。 +
+
+
+ +
+
已选 {{ exportSelectedCount }} 人
+
+ + + +
+
+
+
+ +
{ + const username = String(item?.username || '').trim() + const displayName = String(item?.displayName || '').trim() + const postCount = Number(item?.postCount || 0) + if (!username) return true + if (!Number.isFinite(postCount) || postCount <= 0) return true + return /^v3_/i.test(username) && /@stranger$/i.test(username) && (!displayName || displayName === username) +} + +const visibleSnsUsers = computed(() => { + const list = Array.isArray(snsUsers.value) ? snsUsers.value : [] + return list.filter((item) => !shouldHideSnsUser(item)) +}) + +const snsAvatarErrorKey = (username) => String(username || '').trim() + +const hasSnsAvatarError = (username) => { + const key = snsAvatarErrorKey(username) + return key ? !!snsAvatarErrors.value[key] : false +} + +const onSnsAvatarError = (username) => { + const key = snsAvatarErrorKey(username) + if (!key || snsAvatarErrors.value[key]) return + snsAvatarErrors.value = { + ...snsAvatarErrors.value, + [key]: true + } +} const selectedSnsUserInfo = computed(() => { const uname = String(selectedSnsUser.value || '').trim() if (!uname) return null - const list = Array.isArray(snsUsers.value) ? snsUsers.value : [] + const list = visibleSnsUsers.value return list.find((u) => String(u?.username || '').trim() === uname) || null }) @@ -845,7 +1037,7 @@ const showSnsCountMismatchHint = computed(() => { const filteredSnsUsers = computed(() => { const q = String(snsUserQuery.value || '').trim().toLowerCase() - const list = Array.isArray(snsUsers.value) ? snsUsers.value : [] + const list = visibleSnsUsers.value if (!q) return list return list.filter((u) => { const uname = String(u?.username || '').toLowerCase() @@ -876,6 +1068,11 @@ const exportSaveBytesTotal = ref(0) const exportAutoSavedFor = ref('') const exportJob = ref(null) const exportError = ref('') +const exportModalOpen = ref(false) +const exportFileName = ref('') +const exportSearchQuery = ref('') +const exportSelectedUsernames = ref([]) +const isExportCancelling = ref(false) let exportEventSource = null let exportPollTimer = null @@ -979,6 +1176,102 @@ const exportCurrentTargetLabel = computed(() => { return String(progress.currentDisplayName || progress.currentUsername || '').trim() }) +const isSnsExportBusy = computed(() => { + const status = String(exportJob.value?.status || '').trim() + return status === 'queued' || status === 'running' +}) + +const canCancelSnsExport = computed(() => { + if (!exportJob.value?.exportId) return false + const status = String(exportJob.value?.status || '').trim() + return status === 'queued' || status === 'running' +}) + +const normalizeExportSelectedUsernames = (list) => { + const validUsernames = new Set( + visibleSnsUsers.value + .map((item) => String(item?.username || '').trim()) + .filter(Boolean) + ) + const seen = new Set() + return (Array.isArray(list) ? list : []).reduce((acc, item) => { + const username = String(item || '').trim() + if (!username || seen.has(username)) return acc + if (validUsernames.size > 0 && !validUsernames.has(username)) return acc + seen.add(username) + acc.push(username) + return acc + }, []) +} + +const exportSelectedUsernameSet = computed(() => { + return new Set(normalizeExportSelectedUsernames(exportSelectedUsernames.value)) +}) + +const exportSelectedCount = computed(() => { + return exportSelectedUsernameSet.value.size +}) + +const exportFilteredSnsUsers = computed(() => { + const q = String(exportSearchQuery.value || '').trim().toLowerCase() + const list = visibleSnsUsers.value + if (!q) return list + return list.filter((item) => { + const username = String(item?.username || '').toLowerCase() + const displayName = String(item?.displayName || '').toLowerCase() + return username.includes(q) || displayName.includes(q) + }) +}) + +const exportFilteredSnsUsernames = computed(() => { + return exportFilteredSnsUsers.value + .map((item) => String(item?.username || '').trim()) + .filter(Boolean) +}) + +const areAllFilteredExportUsersSelected = computed(() => { + const usernames = exportFilteredSnsUsernames.value + if (!usernames.length) return false + return usernames.every((username) => exportSelectedUsernameSet.value.has(username)) +}) + +const clearExportSelectedUsers = () => { + exportSelectedUsernames.value = [] +} + +const toggleSelectAllFilteredExportUsers = () => { + const usernames = exportFilteredSnsUsernames.value + if (!usernames.length) return + + if (areAllFilteredExportUsersSelected.value) { + const removeSet = new Set(usernames) + exportSelectedUsernames.value = normalizeExportSelectedUsernames(exportSelectedUsernames.value) + .filter((username) => !removeSet.has(username)) + return + } + + exportSelectedUsernames.value = normalizeExportSelectedUsernames([ + ...exportSelectedUsernames.value, + ...usernames + ]) +} + +const openExportModal = () => { + exportModalOpen.value = true + exportError.value = '' + exportSearchQuery.value = '' + exportFileName.value = '' + exportSelectedUsernames.value = selectedSnsUser.value + ? normalizeExportSelectedUsernames([selectedSnsUser.value]) + : [] +} + +const closeExportModal = () => { + exportModalOpen.value = false + exportError.value = '' + exportSearchQuery.value = '' +} + const exportBackendZipPath = computed(() => { return String(exportJob.value?.zipPath || '').trim() }) @@ -1225,40 +1518,74 @@ const ensureSnsExportFolderReady = () => { return false } -const startSnsExport = async ({ scope, usernames }) => { - if (!selectedAccount.value) return +const cancelSnsExportJob = async () => { + const exportId = String(exportJob.value?.exportId || '').trim() + if (!exportId || !canCancelSnsExport.value || isExportCancelling.value) return exportError.value = '' + isExportCancelling.value = true + try { + await api.cancelSnsExport(exportId) + try { + const resp = await api.getSnsExport(exportId) + exportJob.value = resp?.job || exportJob.value + } catch { + // ignore refresh errors, polling/SSE will continue updating the job + } + } catch (e) { + exportError.value = e?.message || '取消导出任务失败' + isExportCancelling.value = false + } +} + +const startSnsExport = async ({ scope, usernames, fileName } = {}) => { + if (!selectedAccount.value) return false + exportError.value = '' + isExportCancelling.value = false resetExportSaveFeedback({ resetAutoSavedFor: true }) - if (!ensureSnsExportFolderReady()) return + if (!ensureSnsExportFolderReady()) return false + + const normalizedScope = String(scope || '').trim() === 'all' ? 'all' : 'selected' + const normalizedUsernames = normalizeExportSelectedUsernames(usernames) + if (normalizedScope === 'selected' && normalizedUsernames.length === 0) { + exportError.value = '请选择至少一个联系人' + return false + } + try { const resp = await api.createSnsExport({ account: selectedAccount.value, - scope, - usernames: Array.isArray(usernames) ? usernames : [], + scope: normalizedScope, + usernames: normalizedUsernames, format: exportFormat.value, use_cache: snsUseCache.value ? 1 : 0, - output_dir: hasDesktopExportFolder.value ? String(exportFolder.value || '').trim() : null + output_dir: hasDesktopExportFolder.value ? String(exportFolder.value || '').trim() : null, + file_name: String(fileName || '').trim() || null }) exportJob.value = resp?.job || null const exportId = exportJob.value?.exportId if (exportId) startSnsExportPolling(exportId) + return true } catch (e) { - exportError.value = e?.message || '\u521b\u5efa\u5bfc\u51fa\u4efb\u52a1\u5931\u8d25' + exportError.value = e?.message || '创建导出任务失败' + return false } } -const onExportAllClick = async () => { - await startSnsExport({ scope: 'all', usernames: [] }) -} +const startSnsExportFromModal = async () => { + const usernames = normalizeExportSelectedUsernames(exportSelectedUsernames.value) + if (!usernames.length) { + exportError.value = '请选择至少一个联系人' + return + } -const onExportCurrentClick = async () => { - if (!selectedAccount.value) return - const uname = String(selectedSnsUser.value || '').trim() - if (!uname) return - await startSnsExport({ scope: 'selected', usernames: [uname] }) + const created = await startSnsExport({ + scope: 'selected', + usernames, + fileName: exportFileName.value + }) + if (created) exportError.value = '' } - // Track failed images per-post, per-index to render placeholders instead of broken . const mediaErrors = ref({}) @@ -2194,10 +2521,16 @@ watch( stopSnsExportPolling() exportJob.value = null exportError.value = '' + isExportCancelling.value = false + exportModalOpen.value = false + exportFileName.value = '' + exportSearchQuery.value = '' + exportSelectedUsernames.value = [] resetExportSaveFeedback({ resetAutoSavedFor: true }) snsUserQuery.value = '' selectedSnsUser.value = '' snsUsers.value = [] + snsAvatarErrors.value = {} activeLivePhotoKey.value = '' livePhotoVideoErrors.value = {} if (previewCtx.value) closeImagePreview() @@ -2223,6 +2556,26 @@ watch( } ) +watch( + () => ({ + exportId: String(exportJob.value?.exportId || ''), + status: String(exportJob.value?.status || '') + }), + ({ exportId, status }, prev) => { + if (!exportId) { + isExportCancelling.value = false + return + } + if (exportId !== String(prev?.exportId || '')) { + isExportCancelling.value = false + return + } + if (status !== 'queued' && status !== 'running') { + isExportCancelling.value = false + } + } +) + onMounted(async () => { @@ -2238,6 +2591,10 @@ const onGlobalClick = () => { const onGlobalKeyDown = (e) => { if (!process.client) return if (String(e?.key || '') === 'Escape') { + if (exportModalOpen.value) { + closeExportModal() + return + } if (previewCtx.value) closeImagePreview() if (contextMenu.value.visible) closeContextMenu() } diff --git a/src/wechat_decrypt_tool/routers/sns.py b/src/wechat_decrypt_tool/routers/sns.py index deeba8b..8a4bb85 100644 --- a/src/wechat_decrypt_tool/routers/sns.py +++ b/src/wechat_decrypt_tool/routers/sns.py @@ -2067,6 +2067,8 @@ def list_sns_users( post_count = int(r["postCount"] or 0) except Exception: post_count = 0 + if post_count <= 0: + continue row = contact_rows.get(uname) display = _clean_name(_pick_display_name(row, uname)) or uname