From 6314c0f1d6aed50b565ca00de799d1321e9c0eee Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Wed, 4 Mar 2026 13:22:46 +0800 Subject: [PATCH] feat(sns-export): split media export selection into image/live/video --- electron/services/snsService.ts | 103 ++++++++++++++++++++++++++++---- src/pages/ExportPage.tsx | 45 ++++++++++---- src/pages/SnsPage.scss | 23 ++++++- src/pages/SnsPage.tsx | 56 +++++++++++------ src/types/electron.d.ts | 4 +- 5 files changed, 188 insertions(+), 43 deletions(-) diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index 33bf11e..3c12077 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -1085,13 +1085,30 @@ class SnsService { usernames?: string[] keyword?: string exportMedia?: boolean + exportImages?: boolean + exportLivePhotos?: boolean + exportVideos?: boolean startTime?: number endTime?: number }, progressCallback?: (progress: { current: number; total: number; status: string }) => void, control?: { shouldPause?: () => boolean shouldStop?: () => boolean }): Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; paused?: boolean; stopped?: boolean; error?: string }> { - const { outputDir, format, usernames, keyword, exportMedia = false, startTime, endTime } = options + const { outputDir, format, usernames, keyword, startTime, endTime } = options + const hasExplicitMediaSelection = + typeof options.exportImages === 'boolean' || + typeof options.exportLivePhotos === 'boolean' || + typeof options.exportVideos === 'boolean' + const shouldExportImages = hasExplicitMediaSelection + ? options.exportImages === true + : options.exportMedia === true + const shouldExportLivePhotos = hasExplicitMediaSelection + ? options.exportLivePhotos === true + : options.exportMedia === true + const shouldExportVideos = hasExplicitMediaSelection + ? options.exportVideos === true + : options.exportMedia === true + const shouldExportMedia = shouldExportImages || shouldExportLivePhotos || shouldExportVideos const getControlState = (): 'paused' | 'stopped' | null => { if (control?.shouldStop?.()) return 'stopped' if (control?.shouldPause?.()) return 'paused' @@ -1149,15 +1166,54 @@ class SnsService { let mediaCount = 0 const mediaDir = join(outputDir, 'media') - if (exportMedia) { + if (shouldExportMedia) { if (!existsSync(mediaDir)) { mkdirSync(mediaDir, { recursive: true }) } // 收集所有媒体下载任务 - const mediaTasks: { media: SnsMedia; postId: string; mi: number }[] = [] + const mediaTasks: Array<{ + kind: 'image' | 'video' | 'livephoto' + media: SnsMedia + url: string + key?: string + postId: string + mi: number + }> = [] for (const post of allPosts) { - post.media.forEach((media, mi) => mediaTasks.push({ media, postId: post.id, mi })) + post.media.forEach((media, mi) => { + const isVideo = isVideoUrl(media.url) + if (shouldExportImages && !isVideo && media.url) { + mediaTasks.push({ + kind: 'image', + media, + url: media.url, + key: media.key, + postId: post.id, + mi + }) + } + if (shouldExportVideos && isVideo && media.url) { + mediaTasks.push({ + kind: 'video', + media, + url: media.url, + key: media.key, + postId: post.id, + mi + }) + } + if (shouldExportLivePhotos && media.livePhoto?.url) { + mediaTasks.push({ + kind: 'livephoto', + media, + url: media.livePhoto.url, + key: media.livePhoto.key || media.key, + postId: post.id, + mi + }) + } + }) } // 并发下载(5路) @@ -1166,29 +1222,42 @@ class SnsService { const runTask = async (task: typeof mediaTasks[0]) => { const { media, postId, mi } = task try { - const isVideo = isVideoUrl(media.url) + const isVideo = task.kind === 'video' || task.kind === 'livephoto' || isVideoUrl(task.url) const ext = isVideo ? 'mp4' : 'jpg' - const fileName = `${postId}_${mi}.${ext}` + const suffix = task.kind === 'livephoto' ? '_live' : '' + const fileName = `${postId}_${mi}${suffix}.${ext}` const filePath = join(mediaDir, fileName) if (existsSync(filePath)) { - ;(media as any).localPath = `media/${fileName}` + if (task.kind === 'livephoto') { + if (media.livePhoto) (media.livePhoto as any).localPath = `media/${fileName}` + } else { + ;(media as any).localPath = `media/${fileName}` + } mediaCount++ } else { - const result = await this.fetchAndDecryptImage(media.url, media.key) + const result = await this.fetchAndDecryptImage(task.url, task.key) if (result.success && result.data) { await writeFile(filePath, result.data) - ;(media as any).localPath = `media/${fileName}` + if (task.kind === 'livephoto') { + if (media.livePhoto) (media.livePhoto as any).localPath = `media/${fileName}` + } else { + ;(media as any).localPath = `media/${fileName}` + } mediaCount++ } else if (result.success && result.cachePath) { const cachedData = await readFile(result.cachePath) await writeFile(filePath, cachedData) - ;(media as any).localPath = `media/${fileName}` + if (task.kind === 'livephoto') { + if (media.livePhoto) (media.livePhoto as any).localPath = `media/${fileName}` + } else { + ;(media as any).localPath = `media/${fileName}` + } mediaCount++ } } } catch (e) { - console.warn(`[SnsExport] 媒体下载失败: ${task.media.url}`, e) + console.warn(`[SnsExport] 媒体下载失败: ${task.url}`, e) } done++ progressCallback?.({ current: done, total: mediaTasks.length, status: `正在下载媒体 (${done}/${mediaTasks.length})...` }) @@ -1323,7 +1392,12 @@ class SnsService { media: post.media.map(m => ({ url: m.url, thumb: m.thumb, - localPath: (m as any).localPath || undefined + localPath: (m as any).localPath || undefined, + livePhoto: m.livePhoto ? { + url: m.livePhoto.url, + thumb: m.livePhoto.thumb, + localPath: (m.livePhoto as any).localPath || undefined + } : undefined })), likes: post.likes, comments: post.comments, @@ -1343,6 +1417,11 @@ class SnsService { exportTime: new Date().toISOString(), format: 'arkmejson', schemaVersion: '1.0.0', + mediaSelection: { + images: shouldExportImages, + livePhotos: shouldExportLivePhotos, + videos: shouldExportVideos + }, totalPosts: allPosts.length, filters: { usernames: usernames || [], diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 3e1e291..7d9ff07 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -112,7 +112,9 @@ interface ExportTaskPayload { sessionNames: string[] snsOptions?: { format: SnsTimelineExportFormat - exportMedia?: boolean + exportImages?: boolean + exportLivePhotos?: boolean + exportVideos?: boolean startTime?: number endTime?: number } @@ -878,6 +880,9 @@ function ExportPage() { const [exportFolder, setExportFolder] = useState('') const [writeLayout, setWriteLayout] = useState('A') const [snsExportFormat, setSnsExportFormat] = useState('html') + const [snsExportImages, setSnsExportImages] = useState(false) + const [snsExportLivePhotos, setSnsExportLivePhotos] = useState(false) + const [snsExportVideos, setSnsExportVideos] = useState(false) const [options, setOptions] = useState({ format: 'arkme-json', @@ -2038,7 +2043,6 @@ function ExportPage() { const buildSnsExportOptions = () => { const format: SnsTimelineExportFormat = snsExportFormat - const exportMediaEnabled = Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis) const dateRange = options.useAllTime ? null : options.dateRange @@ -2050,7 +2054,9 @@ function ExportPage() { return { format, - exportMedia: exportMediaEnabled, + exportImages: snsExportImages, + exportLivePhotos: snsExportLivePhotos, + exportVideos: snsExportVideos, startTime: dateRange?.startTime, endTime: dateRange?.endTime } @@ -2159,11 +2165,13 @@ function ExportPage() { try { if (next.payload.scope === 'sns') { - const snsOptions = next.payload.snsOptions || { format: 'html' as SnsTimelineExportFormat, exportMedia: false } + const snsOptions = next.payload.snsOptions || { format: 'html' as SnsTimelineExportFormat, exportImages: false, exportLivePhotos: false, exportVideos: false } const result = await window.electronAPI.sns.exportTimeline({ outputDir: next.payload.outputDir, format: snsOptions.format, - exportMedia: snsOptions.exportMedia, + exportImages: snsOptions.exportImages, + exportLivePhotos: snsOptions.exportLivePhotos, + exportVideos: snsOptions.exportVideos, startTime: snsOptions.startTime, endTime: snsOptions.endTime, taskId: next.id @@ -4414,15 +4422,28 @@ function ExportPage() { {shouldShowMediaSection && (
-

媒体与头像

+

{exportDialog.scope === 'sns' ? '媒体文件(可多选)' : '媒体与头像'}

- - - - - - + {exportDialog.scope === 'sns' ? ( + <> + + + + + ) : ( + <> + + + + + + + + )}
+ {exportDialog.scope === 'sns' && ( +
全不勾选时仅导出文本信息,不导出媒体文件。
+ )}
)} diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss index 4b4253f..0e5a73f 100644 --- a/src/pages/SnsPage.scss +++ b/src/pages/SnsPage.scss @@ -1984,10 +1984,31 @@ font-size: 12px; color: var(--text-tertiary); margin: 0; - padding-left: 24px; line-height: 1.4; } +.export-media-check-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); + gap: 8px; + + label { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: var(--text-primary); + padding: 8px 10px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-secondary); + } + + input[type='checkbox'] { + margin: 0; + } +} + .export-progress { display: flex; flex-direction: column; diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index f94e5b1..4da076f 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -62,7 +62,9 @@ export default function SnsPage() { const [showExportDialog, setShowExportDialog] = useState(false) const [exportFormat, setExportFormat] = useState<'json' | 'html' | 'arkmejson'>('html') const [exportFolder, setExportFolder] = useState('') - const [exportMedia, setExportMedia] = useState(false) + const [exportImages, setExportImages] = useState(false) + const [exportLivePhotos, setExportLivePhotos] = useState(false) + const [exportVideos, setExportVideos] = useState(false) const [exportDateRange, setExportDateRange] = useState<{ start: string; end: string }>({ start: '', end: '' }) const [isExporting, setIsExporting] = useState(false) const [exportProgress, setExportProgress] = useState<{ current: number; total: number; status: string } | null>(null) @@ -950,22 +952,40 @@ export default function SnsPage() { {/* 媒体导出 */}
-
-
- - 导出媒体文件(图片/视频) -
- + +
+ + +
- {exportMedia && ( -

媒体文件将保存到输出目录的 media 子目录中,可能需要较长时间

- )} +

全不勾选时仅导出文本信息,不导出媒体文件

{/* 同步提示 */} @@ -1015,7 +1035,9 @@ export default function SnsPage() { format: exportFormat, usernames: selectedUsernames.length > 0 ? selectedUsernames : undefined, keyword: searchKeyword || undefined, - exportMedia, + exportImages, + exportLivePhotos, + exportVideos, startTime: exportDateRange.start ? Math.floor(new Date(exportDateRange.start).getTime() / 1000) : undefined, endTime: exportDateRange.end ? Math.floor(new Date(exportDateRange.end + 'T23:59:59').getTime() / 1000) : undefined }) diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 939eee0..eca2038 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -719,7 +719,9 @@ export interface ElectronAPI { format: 'json' | 'html' | 'arkmejson' usernames?: string[] keyword?: string - exportMedia?: boolean + exportImages?: boolean + exportLivePhotos?: boolean + exportVideos?: boolean startTime?: number endTime?: number taskId?: string