From e86cfb42e5f0ffce17238ad2f6464dee52f4c0e4 Mon Sep 17 00:00:00 2001 From: 2977094657 <2977094657@qq.com> Date: Mon, 20 Apr 2026 15:24:26 +0800 Subject: [PATCH] =?UTF-8?q?improvement(sns-ui):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E6=9C=8B=E5=8F=8B=E5=9C=88=E5=AF=BC=E5=87=BA=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=E4=B8=8E=E4=BF=9D=E5=AD=98=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 HTML/JSON/TXT 导出格式切换 - 支持选择导出目录并自动保存导出包 - 增加总进度和当前联系人进度展示 --- frontend/composables/useApi.js | 3 +- frontend/pages/sns.vue | 483 ++++++++++++++++++++++++++++++--- 2 files changed, 444 insertions(+), 42 deletions(-) diff --git a/frontend/composables/useApi.js b/frontend/composables/useApi.js index 470ee02..fd0c747 100644 --- a/frontend/composables/useApi.js +++ b/frontend/composables/useApi.js @@ -453,7 +453,7 @@ export const useApi = () => { return await request(`/chat/exports/${encodeURIComponent(String(exportId))}`, { method: 'DELETE' }) } - // 朋友圈导出(离线 HTML zip) + // 朋友圈导出(离线 ZIP,支持 HTML / JSON / TXT) const createSnsExport = async (data = {}) => { return await request('/sns/exports', { method: 'POST', @@ -461,6 +461,7 @@ export const useApi = () => { account: data.account || null, scope: data.scope || 'selected', usernames: Array.isArray(data.usernames) ? data.usernames : [], + format: data.format || 'html', use_cache: data.use_cache == null ? true : !!data.use_cache, output_dir: data.output_dir == null ? null : String(data.output_dir || '').trim(), file_name: data.file_name || null diff --git a/frontend/pages/sns.vue b/frontend/pages/sns.vue index da1136a..53f113a 100644 --- a/frontend/pages/sns.vue +++ b/frontend/pages/sns.vue @@ -14,13 +14,61 @@ class="mt-2 w-full px-3 py-2 rounded-md border border-gray-200 bg-white text-sm outline-none focus:ring-2 focus:ring-[#576b95]/30 focus:border-[#576b95]" /> +
+
导出格式
+
+ +
+
+ +
+
+
导出目录
+
{{ exportFolderModeText }}
+
+
+ {{ exportFolder || '未选择' }} +
+
+ + +
+
{{ exportFolderHint }}
+
{{ exportSaveProgressText }}
+
{{ exportSaveMsg }}
+
{{ exportSaveError }}
+
+
@@ -28,23 +76,71 @@ type="button" class="flex-1 px-3 py-2 rounded-md text-sm border border-gray-200 bg-white hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" @click="onExportCurrentClick" - :disabled="!selectedAccount || !selectedSnsUser || exportJob?.status === 'running'" - title="导出当前选中联系人(HTML 离线 ZIP)" + :disabled="!selectedAccount || !selectedSnsUser || exportJob?.status === 'running' || exportJob?.status === 'queued'" + :title="`导出当前选中联系人(${exportFormatLabel} ZIP)`" > 导出此人
{{ exportError }}
-
- 导出状态:{{ exportJob.status }} - +
+
+
任务:{{ 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 }} +
+ +
+ +
@@ -664,7 +760,7 @@ import { useChatAccountsStore } from '~/stores/chatAccounts' import { usePrivacyStore } from '~/stores/privacy' import { parseTextWithEmoji } from '~/lib/wechat-emojis' import { SNS_SETTING_USE_CACHE_KEY, readLocalBoolSetting } from '~/lib/desktop-settings' -import { reportServerErrorFromError } from '~/lib/server-error-logging' +import { reportServerErrorFromError, reportServerErrorFromResponse } from '~/lib/server-error-logging' useHead({ title: '朋友圈 - 微信数据分析助手' }) @@ -762,12 +858,305 @@ const pageSize = 20 const apiBase = useApiBase() -// 朋友圈导出(HTML 离线 ZIP) +// 朋友圈导出(离线 ZIP) +const exportFormat = ref('html') +const exportFormatOptions = [ + { value: 'html', label: 'HTML' }, + { value: 'json', label: 'JSON' }, + { value: 'txt', label: 'TXT' } +] +const exportFolder = ref('') +const exportFolderHandle = ref(null) +const exportSaveBusy = ref(false) +const exportSaveMsg = ref('') +const exportSaveError = ref('') +const exportSaveState = ref('idle') +const exportSaveBytesWritten = ref(0) +const exportSaveBytesTotal = ref(0) +const exportAutoSavedFor = ref('') const exportJob = ref(null) const exportError = ref('') let exportEventSource = null let exportPollTimer = null +const asNumber = (v) => { + const n = Number(v) + return Number.isFinite(n) ? n : 0 +} + +const clamp01 = (v) => Math.max(0, Math.min(1, Number(v) || 0)) + +const formatBytes = (value) => { + const bytes = Number(value) + if (!Number.isFinite(bytes) || bytes <= 0) return '0 B' + const units = ['B', 'KB', 'MB', 'GB', 'TB'] + let size = bytes + let index = 0 + while (size >= 1024 && index < units.length - 1) { + size /= 1024 + index += 1 + } + const digits = size >= 100 || index === 0 ? 0 : size >= 10 ? 1 : 2 + return `${size.toFixed(digits)} ${units[index]}` +} + +const resetExportSaveFeedback = ({ resetAutoSavedFor = false } = {}) => { + exportSaveMsg.value = '' + exportSaveError.value = '' + exportSaveState.value = 'idle' + exportSaveBytesWritten.value = 0 + exportSaveBytesTotal.value = 0 + if (resetAutoSavedFor) exportAutoSavedFor.value = '' +} + +const isDesktopExportRuntime = () => { + return !!(process.client && window?.wechatDesktop?.chooseDirectory) +} + +const isWebDirectoryPickerSupported = () => { + return !!(process.client && typeof window.showDirectoryPicker === 'function') +} + +const hasDesktopExportFolder = computed(() => { + return !!(isDesktopExportRuntime() && String(exportFolder.value || '').trim()) +}) + +const hasWebExportFolder = computed(() => { + return !!(!isDesktopExportRuntime() && isWebDirectoryPickerSupported() && exportFolderHandle.value) +}) + +const hasSelectedExportFolder = computed(() => { + return !!(hasDesktopExportFolder.value || hasWebExportFolder.value) +}) + +const exportFormatLabel = computed(() => { + return exportFormatOptions.find((item) => item.value === exportFormat.value)?.label || 'HTML' +}) + +const exportActiveFormat = computed(() => { + const raw = String(exportJob.value?.options?.format || exportFormat.value || 'html').trim().toLowerCase() + return exportFormatOptions.some((item) => item.value === raw) ? raw : 'html' +}) + +const exportActiveFormatLabel = computed(() => { + return exportFormatOptions.find((item) => item.value === exportActiveFormat.value)?.label || 'HTML' +}) + +const exportStatusText = computed(() => { + const status = String(exportJob.value?.status || '').trim() + return { + queued: '排队中', + running: '导出中', + done: '已完成', + error: '失败', + cancelled: '已取消' + }[status] || status || '-' +}) + +const exportOverallPercent = computed(() => { + const status = String(exportJob.value?.status || '').trim() + if (status === 'done') return 100 + const progress = exportJob.value?.progress || {} + const postsTotal = asNumber(progress.postsTotal) + const postsDone = asNumber(progress.postsExported) + if (postsTotal > 0) return Math.round(clamp01(postsDone / postsTotal) * 100) + const usersTotal = asNumber(progress.usersTotal) + const usersDone = asNumber(progress.usersDone) + if (usersTotal > 0) return Math.round(clamp01(usersDone / usersTotal) * 100) + return 0 +}) + +const exportCurrentPercent = computed(() => { + const progress = exportJob.value?.progress || {} + const total = asNumber(progress.currentUserPostsTotal) + const done = asNumber(progress.currentUserPostsDone) + if (total <= 0) return null + return Math.round(clamp01(done / total) * 100) +}) + +const exportCurrentTargetLabel = computed(() => { + const progress = exportJob.value?.progress || {} + return String(progress.currentDisplayName || progress.currentUsername || '').trim() +}) + +const exportBackendZipPath = computed(() => { + return String(exportJob.value?.zipPath || '').trim() +}) + +const exportFolderModeText = computed(() => { + if (isDesktopExportRuntime()) return '\u684c\u9762\u7aef\u76ee\u5f55' + if (isWebDirectoryPickerSupported()) return '\u6d4f\u89c8\u5668\u76ee\u5f55' + return '\u9700\u9009\u62e9\u6587\u4ef6\u5939' +}) + +const exportFolderHint = computed(() => { + if (isDesktopExportRuntime()) { + return hasDesktopExportFolder.value + ? '\u4f1a\u50cf\u666e\u901a\u804a\u5929\u8bb0\u5f55\u5bfc\u51fa\u4e00\u6837\uff0c\u5b8c\u6210\u540e\u76f4\u63a5\u5199\u5165\u4e0a\u9762\u7684\u6587\u4ef6\u5939\u3002' + : '\u8bf7\u5148\u9009\u62e9\u6587\u4ef6\u5939\uff0c\u5bfc\u51fa\u5b8c\u6210\u540e\u4f1a\u76f4\u63a5\u5199\u5165\u8be5\u76ee\u5f55\u3002' + } + if (isWebDirectoryPickerSupported()) { + return hasWebExportFolder.value + ? '\u5bfc\u51fa\u5b8c\u6210\u540e\u4f1a\u81ea\u52a8\u4fdd\u5b58\u5230\u6240\u9009\u6d4f\u89c8\u5668\u76ee\u5f55\u3002' + : '\u8bf7\u5148\u9009\u62e9\u6d4f\u89c8\u5668\u76ee\u5f55\uff0c\u5bfc\u51fa\u5b8c\u6210\u540e\u4f1a\u81ea\u52a8\u4fdd\u5b58\u3002' + } + return '\u5f53\u524d\u73af\u5883\u4e0d\u652f\u6301\u76ee\u5f55\u9009\u62e9\uff0c\u8bf7\u4f7f\u7528\u684c\u9762\u7aef\u6216 Chromium \u65b0\u7248\u6d4f\u89c8\u5668\u3002' +}) + +const guessSnsExportZipName = (job) => { + const raw = String(job?.zipPath || '').trim() + if (raw) { + const name = raw.replace(/\\/g, '/').split('/').pop() + if (name && name.toLowerCase().endsWith('.zip')) return name + } + const format = String(job?.options?.format || exportFormat.value || 'html').trim().toLowerCase() || 'html' + const exportId = String(job?.exportId || '').trim() || 'export' + return `wechat_sns_export_${format}_${exportId}.zip` +} + +const exportSaveProgressText = computed(() => { + if (exportSaveState.value !== 'saving') return '' + const fileName = guessSnsExportZipName(exportJob.value) + if (exportSaveBytesTotal.value > 0) { + return `\u6b63\u5728\u4fdd\u5b58\u5230\u6d4f\u89c8\u5668\u76ee\u5f55\uff1a${fileName}\uff08${formatBytes(exportSaveBytesWritten.value)} / ${formatBytes(exportSaveBytesTotal.value)}\uff09` + } + return `\u6b63\u5728\u4fdd\u5b58\u5230\u6d4f\u89c8\u5668\u76ee\u5f55\uff1a${fileName}\uff08${formatBytes(exportSaveBytesWritten.value)}\uff09` +}) + +const exportOutputPathText = computed(() => { + if (String(exportJob.value?.status || '') !== 'done') return '' + if (hasWebExportFolder.value) return '' + const raw = exportBackendZipPath.value + if (!raw) return '' + if (isDesktopExportRuntime()) return raw + const requestedOutputDir = String(exportJob.value?.options?.outputDir || '').trim() + return requestedOutputDir ? raw : '' +}) + +const chooseExportFolder = async () => { + exportError.value = '' + resetExportSaveFeedback() + try { + if (!process.client) { + exportError.value = '\u5f53\u524d\u73af\u5883\u4e0d\u652f\u6301\u9009\u62e9\u5bfc\u51fa\u76ee\u5f55' + return + } + + if (isDesktopExportRuntime()) { + const result = await window.wechatDesktop.chooseDirectory({ title: '\u9009\u62e9\u5bfc\u51fa\u76ee\u5f55' }) + if (result && !result.canceled && Array.isArray(result.filePaths) && result.filePaths.length > 0) { + exportFolder.value = String(result.filePaths[0] || '').trim() + exportFolderHandle.value = null + } + return + } + + if (isWebDirectoryPickerSupported()) { + const handle = await window.showDirectoryPicker() + if (handle) { + exportFolderHandle.value = handle + exportFolder.value = `\u6d4f\u89c8\u5668\u76ee\u5f55\uff1a${String(handle.name || '\u5df2\u9009\u62e9')}` + } + return + } + + exportError.value = '\u5f53\u524d\u6d4f\u89c8\u5668\u4e0d\u652f\u6301\u76ee\u5f55\u9009\u62e9\uff0c\u8bf7\u4f7f\u7528\u684c\u9762\u7aef\u6216 Chromium \u65b0\u7248\u6d4f\u89c8\u5668' + } catch (error) { + const message = String(error?.message || '').trim() + if (error?.name === 'AbortError' || message.includes('The user aborted a request')) { + return + } + exportError.value = error?.message || '\u9009\u62e9\u5bfc\u51fa\u76ee\u5f55\u5931\u8d25' + } +} + +const clearExportFolderSelection = () => { + exportFolder.value = '' + exportFolderHandle.value = null + resetExportSaveFeedback({ resetAutoSavedFor: true }) +} + +const getSnsExportDownloadUrl = (exportId) => { + return `${apiBase}/sns/exports/${encodeURIComponent(String(exportId || ''))}/download` +} + +const saveSnsExportToSelectedFolder = async (options = {}) => { + const autoSave = !!options?.auto + exportError.value = '' + resetExportSaveFeedback() + if (!process.client || !isWebDirectoryPickerSupported()) { + exportError.value = '\u5f53\u524d\u73af\u5883\u4e0d\u652f\u6301\u4fdd\u5b58\u5230\u6d4f\u89c8\u5668\u76ee\u5f55' + return + } + const handle = exportFolderHandle.value + if (!handle || typeof handle.getFileHandle !== 'function') { + exportError.value = '\u8bf7\u5148\u9009\u62e9\u6d4f\u89c8\u5668\u5bfc\u51fa\u76ee\u5f55' + return + } + + const exportId = exportJob.value?.exportId + if (!exportId || String(exportJob.value?.status || '') !== 'done') { + exportError.value = '\u5bfc\u51fa\u4efb\u52a1\u5c1a\u672a\u5b8c\u6210' + return + } + + exportSaveBusy.value = true + exportSaveState.value = 'saving' + try { + const response = await fetch(getSnsExportDownloadUrl(exportId)) + if (!response.ok) { + await reportServerErrorFromResponse(response, { + method: 'GET', + requestUrl: getSnsExportDownloadUrl(exportId), + message: `\u4e0b\u8f7d\u5bfc\u51fa\u6587\u4ef6\u5931\u8d25\uff08${response.status}\uff09`, + source: 'sns.exportDownload' + }) + throw new Error(`\u4e0b\u8f7d\u5bfc\u51fa\u6587\u4ef6\u5931\u8d25\uff08${response.status}\uff09`) + } + exportSaveBytesTotal.value = asNumber(response.headers.get('Content-Length')) + const fileName = guessSnsExportZipName(exportJob.value) + const fileHandle = await handle.getFileHandle(fileName, { create: true }) + const writable = await fileHandle.createWritable() + if (response.body && typeof response.body.getReader === 'function') { + const reader = response.body.getReader() + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + if (!value || !value.byteLength) continue + await writable.write(value) + exportSaveBytesWritten.value += value.byteLength + } + await writable.close() + } catch (error) { + try { + await reader.cancel() + } catch {} + try { + await writable.abort() + } catch {} + throw error + } + } else { + const blob = await response.blob() + exportSaveBytesWritten.value = asNumber(blob.size) + if (exportSaveBytesTotal.value <= 0) exportSaveBytesTotal.value = exportSaveBytesWritten.value + await writable.write(blob) + await writable.close() + } + exportAutoSavedFor.value = String(exportId) + exportSaveState.value = 'success' + const folderLabel = String(exportFolder.value || '').trim() || '\u5df2\u9009\u76ee\u5f55' + exportSaveMsg.value = autoSave + ? `\u6d4f\u89c8\u5668\u76ee\u5f55\u81ea\u52a8\u4fdd\u5b58\u6210\u529f\uff1a${fileName}\n\u4f4d\u7f6e\uff1a${folderLabel}` + : `\u6d4f\u89c8\u5668\u76ee\u5f55\u4fdd\u5b58\u6210\u529f\uff1a${fileName}\n\u4f4d\u7f6e\uff1a${folderLabel}` + } catch (error) { + exportSaveState.value = 'error' + exportSaveError.value = `\u6d4f\u89c8\u5668\u76ee\u5f55\u4fdd\u5b58\u5931\u8d25\uff1a${error?.message || '\u672a\u77e5\u9519\u8bef'}` + } finally { + exportSaveBusy.value = false + } +} const stopSnsExportPolling = () => { if (exportEventSource) { try { @@ -828,52 +1217,48 @@ const startSnsExportPolling = (exportId) => { startSnsExportHttpPolling(exportId) } -const downloadSnsExport = (exportId) => { - if (!process.client) return - const id = String(exportId || '').trim() - if (!id) return - const url = `${apiBase}/sns/exports/${encodeURIComponent(id)}/download` - window.open(url, '_blank', 'noopener,noreferrer') +const ensureSnsExportFolderReady = () => { + if (hasSelectedExportFolder.value) return true + exportError.value = isDesktopExportRuntime() || isWebDirectoryPickerSupported() + ? '\u8bf7\u5148\u9009\u62e9\u5bfc\u51fa\u76ee\u5f55' + : '\u5f53\u524d\u73af\u5883\u4e0d\u652f\u6301\u76ee\u5f55\u9009\u62e9\uff0c\u8bf7\u4f7f\u7528\u684c\u9762\u7aef\u6216 Chromium \u65b0\u7248\u6d4f\u89c8\u5668' + return false } -const onExportAllClick = async () => { +const startSnsExport = async ({ scope, usernames }) => { if (!selectedAccount.value) return exportError.value = '' + resetExportSaveFeedback({ resetAutoSavedFor: true }) + if (!ensureSnsExportFolderReady()) return try { const resp = await api.createSnsExport({ account: selectedAccount.value, - scope: 'all', - usernames: [], - use_cache: snsUseCache.value ? 1 : 0 + scope, + usernames: Array.isArray(usernames) ? usernames : [], + format: exportFormat.value, + use_cache: snsUseCache.value ? 1 : 0, + output_dir: hasDesktopExportFolder.value ? String(exportFolder.value || '').trim() : null }) exportJob.value = resp?.job || null const exportId = exportJob.value?.exportId if (exportId) startSnsExportPolling(exportId) } catch (e) { - exportError.value = e?.message || '创建导出任务失败' + exportError.value = e?.message || '\u521b\u5efa\u5bfc\u51fa\u4efb\u52a1\u5931\u8d25' } } +const onExportAllClick = async () => { + await startSnsExport({ scope: 'all', usernames: [] }) +} + const onExportCurrentClick = async () => { if (!selectedAccount.value) return const uname = String(selectedSnsUser.value || '').trim() if (!uname) return - exportError.value = '' - try { - const resp = await api.createSnsExport({ - account: selectedAccount.value, - scope: 'selected', - usernames: [uname], - use_cache: snsUseCache.value ? 1 : 0 - }) - exportJob.value = resp?.job || null - const exportId = exportJob.value?.exportId - if (exportId) startSnsExportPolling(exportId) - } catch (e) { - exportError.value = e?.message || '创建导出任务失败' - } + await startSnsExport({ scope: 'selected', usernames: [uname] }) } + // Track failed images per-post, per-index to render placeholders instead of broken . const mediaErrors = ref({}) @@ -1809,6 +2194,7 @@ watch( stopSnsExportPolling() exportJob.value = null exportError.value = '' + resetExportSaveFeedback({ resetAutoSavedFor: true }) snsUserQuery.value = '' selectedSnsUser.value = '' snsUsers.value = [] @@ -1823,6 +2209,21 @@ watch( { immediate: true } ) +watch( + () => ({ + exportId: String(exportJob.value?.exportId || ''), + status: String(exportJob.value?.status || '') + }), + async ({ exportId, status }) => { + if (!process.client || status !== 'done' || !exportId) return + if (!hasWebExportFolder.value) return + if (exportAutoSavedFor.value === exportId) return + if (exportSaveBusy.value) return + await saveSnsExportToSelectedFolder({ auto: true }) + } +) + + onMounted(async () => { privacyStore.init()