diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss index 6dea0d6..f29beda 100644 --- a/src/pages/SnsPage.scss +++ b/src/pages/SnsPage.scss @@ -2729,6 +2729,54 @@ color: var(--text-tertiary); text-align: center; } + + .export-progress-actions { + display: flex; + justify-content: center; + gap: 8px; + flex-wrap: wrap; + margin-top: 4px; + } + + .export-progress-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-width: 82px; + height: 32px; + padding: 0 12px; + border-radius: 8px; + border: 1px solid var(--border-color); + background: var(--bg-secondary); + color: var(--text-secondary); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease; + + &:hover:not(:disabled) { + background: var(--hover-bg); + color: var(--text-primary); + } + + &.primary { + border-color: color-mix(in srgb, var(--primary) 36%, var(--border-color)); + background: rgba(var(--primary-rgb), 0.1); + color: var(--primary); + } + + &.danger { + border-color: color-mix(in srgb, #ff4d4f 36%, var(--border-color)); + background: color-mix(in srgb, #ff4d4f 10%, var(--bg-secondary)); + color: #d9363e; + } + + &:disabled { + opacity: 0.55; + cursor: not-allowed; + } + } } .export-result { diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index 88909a3..019f73b 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useLayoutEffect, useState, useRef, useCallback, useMemo } from 'react' -import { RefreshCw, Search, X, Download, FolderOpen, FileJson, FileText, Image, CheckCircle, AlertCircle, Calendar, Info, Shield, ShieldOff, Loader2 } from 'lucide-react' +import { RefreshCw, Search, X, Download, FolderOpen, FileJson, FileText, Image, CheckCircle, AlertCircle, Calendar, Info, Shield, ShieldOff, Loader2, Pause, Play, Square } from 'lucide-react' import './SnsPage.scss' import { SnsPost } from '../types/sns' import { SnsPostItem } from '../components/Sns/SnsPostItem' @@ -64,10 +64,42 @@ interface SnsOverviewStats { type OverviewStatsStatus = 'loading' | 'ready' | 'error' type SnsExportScope = { kind: 'all' } | { kind: 'selected'; usernames: string[] } +type SnsExportTaskStatus = 'idle' | 'running' | 'pause_requested' | 'paused' | 'cancel_requested' + +interface SnsExportProgress { + current: number + total: number + status: string +} + +interface SnsExportResult { + success: boolean + filePath?: string + postCount?: number + mediaCount?: number + paused?: boolean + stopped?: boolean + error?: string +} + +interface SnsExportRequest { + taskId: string + outputDir: string + format: 'json' | 'html' | 'arkmejson' + usernames?: string[] + keyword?: string + exportImages: boolean + exportLivePhotos: boolean + exportVideos: boolean + startTime?: number + endTime?: number +} const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1' const SNS_CACHE_MIGRATION_PROMPT_SESSION_KEY = 'sns_cache_migration_prompted_v1' +const createSnsExportTaskId = (): string => `sns-export-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + interface SnsCacheMigrationItem { label: string sourceDir: string @@ -179,8 +211,9 @@ export default function SnsPage() { () => createExportDateRangeSelectionFromPreset('all') ) const [isExporting, setIsExporting] = useState(false) - const [exportProgress, setExportProgress] = useState<{ current: number; total: number; status: string } | null>(null) - const [exportResult, setExportResult] = useState<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; error?: string } | null>(null) + const [exportTaskStatus, setExportTaskStatus] = useState('idle') + const [exportProgress, setExportProgress] = useState(null) + const [exportResult, setExportResult] = useState(null) const [refreshSpin, setRefreshSpin] = useState(false) const [isExportDateRangeDialogOpen, setIsExportDateRangeDialogOpen] = useState(false) @@ -211,6 +244,8 @@ export default function SnsPage() { const snsUserPostCountsCacheScopeKeyRef = useRef('') const activeContactsLoadTaskIdRef = useRef(null) const activeContactsCountTaskIdRef = useRef(null) + const activeExportTaskIdRef = useRef(null) + const activeExportRequestRef = useRef(null) const scrollAdjustmentRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null) const pendingResetFeedRef = useRef(false) const contactsLoadTokenRef = useRef(0) @@ -465,7 +500,11 @@ export default function SnsPage() { : overviewStatsStatus === 'loading' || contactsLoading ) - const canStartExport = Boolean(exportFolder) && !isExporting && ( + const isExportLocked = isExporting || exportTaskStatus !== 'idle' + const canPauseExport = exportTaskStatus === 'running' + const canResumeExport = exportTaskStatus === 'paused' || exportTaskStatus === 'pause_requested' + const canCancelExport = exportTaskStatus !== 'idle' + const canStartExport = Boolean(exportFolder) && !isExportLocked && ( exportScope.kind === 'all' || exportScope.usernames.length > 0 ) @@ -772,14 +811,205 @@ export default function SnsPage() { const exportDateRangeLabel = useMemo(() => getExportDateRangeLabel(exportDateRangeSelection), [exportDateRangeSelection]) + const clearActiveExportTask = useCallback(() => { + activeExportTaskIdRef.current = null + activeExportRequestRef.current = null + setExportTaskStatus('idle') + setIsExporting(false) + }, []) + + const buildSnsExportRequest = useCallback((taskId: string): SnsExportRequest => ({ + taskId, + outputDir: exportFolder, + format: exportFormat, + usernames: exportScope.kind === 'selected' ? [...exportScope.usernames] : undefined, + keyword: searchKeyword || undefined, + exportImages, + exportLivePhotos, + exportVideos, + startTime: exportDateRangeSelection.useAllTime + ? undefined + : Math.floor(exportDateRangeSelection.dateRange.start.getTime() / 1000), + endTime: exportDateRangeSelection.useAllTime + ? undefined + : Math.floor(exportDateRangeSelection.dateRange.end.getTime() / 1000) + }), [ + exportDateRangeSelection, + exportFolder, + exportFormat, + exportImages, + exportLivePhotos, + exportScope, + exportVideos, + searchKeyword + ]) + + const runSnsExport = useCallback(async (request: SnsExportRequest, statusText = '准备导出...') => { + activeExportTaskIdRef.current = request.taskId + activeExportRequestRef.current = request + setIsExporting(true) + setExportTaskStatus('running') + setExportResult(null) + setExportProgress(prev => prev || { current: 0, total: 0, status: statusText }) + + let keepTaskActive = false + const removeProgress = window.electronAPI.sns.onExportProgress((progress: SnsExportProgress) => { + setExportProgress(progress) + }) + + try { + const result = await window.electronAPI.sns.exportTimeline(request) + if (!result.success) { + setExportResult(result) + return + } + + if (result.paused) { + keepTaskActive = true + setExportTaskStatus('paused') + setExportProgress(prev => ({ + current: Math.max(prev?.current || 0, result.postCount || 0), + total: Math.max(prev?.total || 0, result.postCount || 0), + status: '已暂停,可继续或取消' + })) + return + } + + if (result.stopped) { + setExportResult(null) + setExportProgress(null) + setShowExportDialog(false) + return + } + + setExportResult(result) + } catch (e: any) { + setExportResult({ success: false, error: e.message || String(e) }) + } finally { + removeProgress() + setIsExporting(false) + if (!keepTaskActive) { + activeExportTaskIdRef.current = null + activeExportRequestRef.current = null + setExportTaskStatus('idle') + } + } + }, []) + + const handleStartSnsExport = useCallback(() => { + if (!canStartExport) return + const request = buildSnsExportRequest(createSnsExportTaskId()) + setExportProgress({ current: 0, total: 0, status: '准备导出...' }) + void runSnsExport(request) + }, [buildSnsExportRequest, canStartExport, runSnsExport]) + + const handlePauseSnsExport = useCallback(() => { + const taskId = activeExportTaskIdRef.current + if (!taskId || exportTaskStatus !== 'running') return + setExportTaskStatus('pause_requested') + setExportProgress(prev => ({ + current: prev?.current || 0, + total: prev?.total || 0, + status: '暂停请求已发送,正在等待安全检查点' + })) + window.electronAPI.export.pauseTask(taskId).then(result => { + if (result.success) return + setExportTaskStatus(current => current === 'pause_requested' ? 'running' : current) + setExportProgress(prev => ({ + current: prev?.current || 0, + total: prev?.total || 0, + status: result.error || '暂停请求失败' + })) + }).catch(error => { + setExportTaskStatus(current => current === 'pause_requested' ? 'running' : current) + setExportProgress(prev => ({ + current: prev?.current || 0, + total: prev?.total || 0, + status: String(error) + })) + }) + }, [exportTaskStatus]) + + const handleResumeSnsExport = useCallback(() => { + const taskId = activeExportTaskIdRef.current + const request = activeExportRequestRef.current + if (!taskId || !request || (exportTaskStatus !== 'paused' && exportTaskStatus !== 'pause_requested')) return + setExportTaskStatus('running') + setExportProgress(prev => ({ + current: prev?.current || 0, + total: prev?.total || 0, + status: '正在继续导出...' + })) + window.electronAPI.export.resumeTask(taskId).then(result => { + if (!result.success) { + setExportTaskStatus('paused') + setExportProgress(prev => ({ + current: prev?.current || 0, + total: prev?.total || 0, + status: result.error || '继续任务失败' + })) + return + } + void runSnsExport(request, '正在继续导出...') + }).catch(error => { + setExportTaskStatus('paused') + setExportProgress(prev => ({ + current: prev?.current || 0, + total: prev?.total || 0, + status: String(error) + })) + }) + }, [exportTaskStatus, runSnsExport]) + + const handleCancelSnsExport = useCallback(() => { + const taskId = activeExportTaskIdRef.current + if (!taskId || exportTaskStatus === 'idle' || exportTaskStatus === 'cancel_requested') return + const shouldCloseAfterAck = exportTaskStatus === 'paused' || !isExporting + setExportTaskStatus('cancel_requested') + setExportProgress(prev => ({ + current: prev?.current || 0, + total: prev?.total || 0, + status: '取消请求已发送,正在安全停止并清理' + })) + window.electronAPI.export.cancelTask(taskId).then(result => { + if (!result.success) { + setExportTaskStatus(shouldCloseAfterAck ? 'paused' : 'running') + setExportProgress(prev => ({ + current: prev?.current || 0, + total: prev?.total || 0, + status: result.error || '取消任务失败' + })) + return + } + if (shouldCloseAfterAck) { + clearActiveExportTask() + setExportResult(null) + setExportProgress(null) + setShowExportDialog(false) + } + }).catch(error => { + setExportTaskStatus(shouldCloseAfterAck ? 'paused' : 'running') + setExportProgress(prev => ({ + current: prev?.current || 0, + total: prev?.total || 0, + status: String(error) + })) + }) + }, [clearActiveExportTask, exportTaskStatus, isExporting]) + const openExportDialog = useCallback((scope: SnsExportScope) => { + if (isExportLocked) { + setShowExportDialog(true) + return + } setExportScope(scope) setExportResult(null) setExportProgress(null) + clearActiveExportTask() setExportDateRangeSelection(createExportDateRangeSelectionFromPreset('all')) setIsExportDateRangeDialogOpen(false) setShowExportDialog(true) - }, []) + }, [clearActiveExportTask, isExportLocked]) const loadPosts = useCallback(async (options: { reset?: boolean, direction?: 'older' | 'newer' } = {}) => { const { reset = false, direction = 'older' } = options @@ -2048,11 +2278,11 @@ export default function SnsPage() { {/* 导出对话框 */} {showExportDialog && ( -
!isExporting && setShowExportDialog(false)}> +
!isExportLocked && setShowExportDialog(false)}>
e.stopPropagation()}>

导出朋友圈

-
@@ -2078,7 +2308,7 @@ export default function SnsPage() { @@ -2139,9 +2369,9 @@ export default function SnsPage() { type="button" className="time-range-trigger sns-export-time-range-trigger" onClick={() => { - if (!isExporting) setIsExportDateRangeDialogOpen(true) + if (!isExportLocked) setIsExportDateRangeDialogOpen(true) }} - disabled={isExporting} + disabled={isExportLocked} > {exportDateRangeLabel} > @@ -2161,7 +2391,7 @@ export default function SnsPage() { type="checkbox" checked={exportImages} onChange={(e) => setExportImages(e.target.checked)} - disabled={isExporting} + disabled={isExportLocked} /> 图片 @@ -2170,7 +2400,7 @@ export default function SnsPage() { type="checkbox" checked={exportLivePhotos} onChange={(e) => setExportLivePhotos(e.target.checked)} - disabled={isExporting} + disabled={isExportLocked} /> 实况图 @@ -2179,7 +2409,7 @@ export default function SnsPage() { type="checkbox" checked={exportVideos} onChange={(e) => setExportVideos(e.target.checked)} - disabled={isExporting} + disabled={isExportLocked} /> 视频 @@ -2194,7 +2424,7 @@ export default function SnsPage() {
{/* 进度条 */} - {isExporting && exportProgress && ( + {isExportLocked && exportProgress && (
{exportProgress.status} +
+ {canPauseExport && ( + + )} + {canResumeExport && ( + + )} + {canCancelExport && ( + + )} +
)} @@ -2211,47 +2474,14 @@ export default function SnsPage() {