diff --git a/electron/main.ts b/electron/main.ts index 85a8ffb..0631d85 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -94,6 +94,32 @@ let isDownloadInProgress = false let downloadProgressHandler: ((progress: any) => void) | null = null let downloadedHandler: (() => void) | null = null +interface ExportTaskControlState { + pauseRequested: boolean + stopRequested: boolean +} + +const exportTaskControlMap = new Map() + +const getTaskControlState = (taskId?: string): ExportTaskControlState | null => { + const normalized = typeof taskId === 'string' ? taskId.trim() : '' + if (!normalized) return null + return exportTaskControlMap.get(normalized) || null +} + +const createTaskControlState = (taskId?: string): string | null => { + const normalized = typeof taskId === 'string' ? taskId.trim() : '' + if (!normalized) return null + exportTaskControlMap.set(normalized, { pauseRequested: false, stopRequested: false }) + return normalized +} + +const clearTaskControlState = (taskId?: string): void => { + const normalized = typeof taskId === 'string' ? taskId.trim() : '' + if (!normalized) return + exportTaskControlMap.delete(normalized) +} + function createWindow(options: { autoShow?: boolean } = {}) { // 获取图标路径 - 打包后在 resources 目录 const { autoShow = true } = options @@ -1103,11 +1129,27 @@ function registerIpcHandlers() { }) ipcMain.handle('sns:exportTimeline', async (event, options: any) => { - return snsService.exportTimeline(options, (progress) => { - if (!event.sender.isDestroyed()) { - event.sender.send('sns:exportProgress', progress) - } - }) + const taskId = typeof options?.taskId === 'string' ? options.taskId : undefined + const controlId = createTaskControlState(taskId) + const exportOptions = { ...(options || {}) } + delete exportOptions.taskId + + try { + return snsService.exportTimeline( + exportOptions, + (progress) => { + if (!event.sender.isDestroyed()) { + event.sender.send('sns:exportProgress', progress) + } + }, + { + shouldPause: () => Boolean(getTaskControlState(controlId || undefined)?.pauseRequested), + shouldStop: () => Boolean(getTaskControlState(controlId || undefined)?.stopRequested) + } + ) + } finally { + clearTaskControlState(controlId || undefined) + } }) ipcMain.handle('sns:selectExportDir', async () => { @@ -1230,13 +1272,40 @@ function registerIpcHandlers() { return exportService.getExportStats(sessionIds, options) }) - ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions) => { + ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions, taskId?: string) => { + const controlId = createTaskControlState(taskId) const onProgress = (progress: ExportProgress) => { if (!event.sender.isDestroyed()) { event.sender.send('export:progress', progress) } } - return exportService.exportSessions(sessionIds, outputDir, options, onProgress) + + try { + return exportService.exportSessions(sessionIds, outputDir, options, onProgress, { + shouldPause: () => Boolean(getTaskControlState(controlId || undefined)?.pauseRequested), + shouldStop: () => Boolean(getTaskControlState(controlId || undefined)?.stopRequested) + }) + } finally { + clearTaskControlState(controlId || undefined) + } + }) + + ipcMain.handle('export:pauseTask', async (_, taskId: string) => { + const state = getTaskControlState(taskId) + if (!state) { + return { success: false, error: '任务未在执行中或已结束' } + } + state.pauseRequested = true + return { success: true } + }) + + ipcMain.handle('export:stopTask', async (_, taskId: string) => { + const state = getTaskControlState(taskId) + if (!state) { + return { success: false, error: '任务未在执行中或已结束' } + } + state.stopRequested = true + return { success: true } }) ipcMain.handle('export:exportSession', async (_, sessionId: string, outputPath: string, options: ExportOptions) => { diff --git a/electron/preload.ts b/electron/preload.ts index 1ac66df..b8d6aa1 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -266,12 +266,16 @@ contextBridge.exposeInMainWorld('electronAPI', { export: { getExportStats: (sessionIds: string[], options: any) => ipcRenderer.invoke('export:getExportStats', sessionIds, options), - exportSessions: (sessionIds: string[], outputDir: string, options: any) => - ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options), + exportSessions: (sessionIds: string[], outputDir: string, options: any, taskId?: string) => + ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options, taskId), exportSession: (sessionId: string, outputPath: string, options: any) => ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options), exportContacts: (outputDir: string, options: any) => ipcRenderer.invoke('export:exportContacts', outputDir, options), + pauseTask: (taskId: string) => + ipcRenderer.invoke('export:pauseTask', taskId), + stopTask: (taskId: string) => + ipcRenderer.invoke('export:stopTask', taskId), onProgress: (callback: (payload: { current: number; total: number; currentSession: string; phase: string }) => void) => { ipcRenderer.on('export:progress', (_, payload) => callback(payload)) return () => ipcRenderer.removeAllListeners('export:progress') diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 8afc695..40877b8 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -4776,10 +4776,26 @@ class ExportService { sessionIds: string[], outputDir: string, options: ExportOptions, - onProgress?: (progress: ExportProgress) => void - ): Promise<{ success: boolean; successCount: number; failCount: number; error?: string }> { + onProgress?: (progress: ExportProgress) => void, + control?: { + shouldPause?: () => boolean + shouldStop?: () => boolean + } + ): Promise<{ + success: boolean + successCount: number + failCount: number + paused?: boolean + stopped?: boolean + pendingSessionIds?: string[] + successSessionIds?: string[] + failedSessionIds?: string[] + error?: string + }> { let successCount = 0 let failCount = 0 + const successSessionIds: string[] = [] + const failedSessionIds: string[] = [] try { const conn = await this.ensureConnected() @@ -4804,11 +4820,13 @@ class ExportService { const sessionConcurrency = (exportMediaEnabled && sessionLayout === 'shared') ? 1 : clampedConcurrency + const queue = [...sessionIds] + let pauseRequested = false + let stopRequested = false - await parallelLimit(sessionIds, sessionConcurrency, async (sessionId) => { + const runOne = async (sessionId: string) => { const sessionInfo = await this.getContactInfo(sessionId) - // 创建包装后的进度回调,自动附加会话级信息 const sessionProgress = (progress: ExportProgress) => { onProgress?.({ ...progress, @@ -4864,8 +4882,10 @@ class ExportService { if (result.success) { successCount++ + successSessionIds.push(sessionId) } else { failCount++ + failedSessionIds.push(sessionId) console.error(`导出 ${sessionId} 失败:`, result.error) } @@ -4876,7 +4896,49 @@ class ExportService { currentSession: sessionInfo.displayName, phase: 'exporting' }) + } + + const workers = Array.from({ length: Math.min(sessionConcurrency, queue.length) }, async () => { + while (queue.length > 0) { + if (control?.shouldStop?.()) { + stopRequested = true + break + } + if (control?.shouldPause?.()) { + pauseRequested = true + break + } + + const sessionId = queue.shift() + if (!sessionId) break + await runOne(sessionId) + } }) + await Promise.all(workers) + + const pendingSessionIds = [...queue] + if (stopRequested && pendingSessionIds.length > 0) { + return { + success: true, + successCount, + failCount, + stopped: true, + pendingSessionIds, + successSessionIds, + failedSessionIds + } + } + if (pauseRequested && pendingSessionIds.length > 0) { + return { + success: true, + successCount, + failCount, + paused: true, + pendingSessionIds, + successSessionIds, + failedSessionIds + } + } onProgress?.({ current: sessionIds.length, @@ -4885,7 +4947,7 @@ class ExportService { phase: 'complete' }) - return { success: true, successCount, failCount } + return { success: true, successCount, failCount, successSessionIds, failedSessionIds } } catch (e) { return { success: false, successCount, failCount, error: String(e) } } diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index 007d919..10c65f8 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -827,8 +827,21 @@ class SnsService { exportMedia?: boolean startTime?: number endTime?: number - }, progressCallback?: (progress: { current: number; total: number; status: string }) => void): Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; error?: string }> { + }, 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 getControlState = (): 'paused' | 'stopped' | null => { + if (control?.shouldStop?.()) return 'stopped' + if (control?.shouldPause?.()) return 'paused' + return null + } + const buildInterruptedResult = (state: 'paused' | 'stopped', postCount: number, mediaCount: number) => ( + state === 'stopped' + ? { success: true, stopped: true, filePath: '', postCount, mediaCount } + : { success: true, paused: true, filePath: '', postCount, mediaCount } + ) try { // 确保输出目录存在 @@ -845,6 +858,10 @@ class SnsService { progressCallback?.({ current: 0, total: 0, status: '正在加载朋友圈数据...' }) while (hasMore) { + const controlState = getControlState() + if (controlState) { + return buildInterruptedResult(controlState, allPosts.length, 0) + } const result = await this.getTimeline(pageSize, 0, usernames, keyword, startTime, endTs) if (result.success && result.timeline && result.timeline.length > 0) { allPosts.push(...result.timeline) @@ -921,11 +938,18 @@ class SnsService { const queue = [...mediaTasks] const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => { while (queue.length > 0) { + const controlState = getControlState() + if (controlState) return controlState const task = queue.shift()! await runTask(task) } + return null }) - await Promise.all(workers) + const workerResults = await Promise.all(workers) + const interruptedState = workerResults.find(state => state === 'paused' || state === 'stopped') + if (interruptedState) { + return buildInterruptedResult(interruptedState, allPosts.length, mediaCount) + } } // 2.5 下载头像 @@ -937,6 +961,8 @@ class SnsService { const avatarQueue = [...uniqueUsers] const avatarWorkers = Array.from({ length: Math.min(5, avatarQueue.length) }, async () => { while (avatarQueue.length > 0) { + const controlState = getControlState() + if (controlState) return controlState const post = avatarQueue.shift()! try { const fileName = `avatar_${crypto.createHash('md5').update(post.username).digest('hex').slice(0, 8)}.jpg` @@ -954,11 +980,20 @@ class SnsService { avatarDone++ progressCallback?.({ current: avatarDone, total: uniqueUsers.length, status: `正在下载头像 (${avatarDone}/${uniqueUsers.length})...` }) } + return null }) - await Promise.all(avatarWorkers) + const avatarWorkerResults = await Promise.all(avatarWorkers) + const interruptedState = avatarWorkerResults.find(state => state === 'paused' || state === 'stopped') + if (interruptedState) { + return buildInterruptedResult(interruptedState, allPosts.length, mediaCount) + } } // 3. 生成输出文件 + const finalControlState = getControlState() + if (finalControlState) { + return buildInterruptedResult(finalControlState, allPosts.length, mediaCount) + } const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19) let outputFilePath: string diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index cb61e85..de74b8d 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -236,7 +236,7 @@ min-width: 0; } - .task-collapse-btn { + .task-open-btn { border: 1px solid var(--border-color); background: var(--bg-primary); border-radius: 7px; @@ -245,14 +245,38 @@ color: var(--text-secondary); display: inline-flex; align-items: center; - gap: 3px; + gap: 6px; cursor: pointer; flex-shrink: 0; + transition: border-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease; &:hover { border-color: var(--primary); color: var(--primary); } + + &.active-running { + border-color: rgba(255, 77, 79, 0.45); + color: #ff4d4f; + box-shadow: 0 0 0 2px rgba(255, 77, 79, 0.16); + } + } + + .task-running-badge { + min-width: 16px; + height: 16px; + border-radius: 999px; + background: #ff4d4f; + color: #fff; + font-size: 10px; + font-weight: 700; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 4px; + line-height: 1; + box-shadow: 0 0 0 2px rgba(255, 77, 79, 0.2); + animation: exportTaskBadgePulse 1.2s ease-in-out infinite; } .secondary-btn { @@ -351,122 +375,232 @@ gap: 1px; } -.task-center { +.task-center-modal-overlay { + position: fixed; + top: 40px; + right: 0; + bottom: 0; + left: 0; + z-index: 1180; + background: rgba(15, 23, 42, 0.28); + display: flex; + align-items: flex-start; + justify-content: center; + padding: 24px 20px; +} + +.task-center-modal { + width: min(980px, calc(100vw - 40px)); + max-height: calc(100vh - 72px); + border-radius: 14px; border: 1px solid var(--border-color); - border-radius: 12px; background: var(--card-bg); - padding: 12px; - flex-shrink: 0; + box-shadow: 0 20px 48px rgba(0, 0, 0, 0.24); + display: flex; + flex-direction: column; + overflow: hidden; +} - .task-empty { - padding: 12px; - background: var(--bg-secondary); - border-radius: 8px; - font-size: 13px; - color: var(--text-secondary); - } +.task-center-modal-header { + padding: 12px 14px; + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} - .task-list { - display: grid; - gap: 8px; - max-height: 190px; - overflow-y: auto; - } +.task-center-modal-title { + min-width: 0; - .task-card { - border: 1px solid var(--border-color); - border-radius: 10px; - padding: 10px; - display: flex; - gap: 10px; - align-items: flex-start; - background: var(--bg-secondary); - - &.running { - border-color: var(--primary); - } - - &.error { - border-color: rgba(255, 77, 79, 0.45); - } - - &.success { - border-color: rgba(82, 196, 26, 0.4); - } - } - - .task-main { - flex: 1; - min-width: 0; - } - - .task-title { - font-size: 13px; + h3 { + margin: 0; + font-size: 16px; color: var(--text-primary); - font-weight: 600; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; } - .task-meta { - margin-top: 2px; - display: flex; - flex-wrap: wrap; - gap: 8px; - font-size: 11px; - color: var(--text-secondary); - } - - .task-status { - border-radius: 999px; - padding: 2px 8px; - font-weight: 600; - - &.queued { - background: rgba(var(--primary-rgb), 0.14); - color: var(--primary); - } - - &.running { - background: rgba(var(--primary-rgb), 0.2); - color: var(--primary); - } - - &.success { - background: rgba(82, 196, 26, 0.18); - color: #52c41a; - } - - &.error { - background: rgba(255, 77, 79, 0.15); - color: #ff4d4f; - } - } - - .task-progress-bar { - margin-top: 8px; - height: 6px; - background: rgba(0, 0, 0, 0.08); - border-radius: 3px; - overflow: hidden; - } - - .task-progress-fill { - height: 100%; - background: var(--primary); - transition: width 0.2s ease; - } - - .task-progress-text { - margin-top: 4px; - font-size: 11px; - color: var(--text-secondary); - } - - .task-error { - margin-top: 6px; + span { + display: block; + margin-top: 3px; font-size: 12px; + color: var(--text-secondary); + } +} + +.task-center-modal-body { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 12px; +} + +.task-empty { + padding: 12px; + background: var(--bg-secondary); + border-radius: 8px; + font-size: 13px; + color: var(--text-secondary); +} + +.task-list { + display: grid; + gap: 8px; +} + +.task-card { + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 10px; + display: flex; + gap: 10px; + align-items: flex-start; + background: var(--bg-secondary); + + &.running { + border-color: var(--primary); + } + + &.paused { + border-color: rgba(250, 173, 20, 0.55); + } + + &.stopped { + border-color: rgba(148, 163, 184, 0.46); + } + + &.error { + border-color: rgba(255, 77, 79, 0.45); + } + + &.success { + border-color: rgba(82, 196, 26, 0.4); + } +} + +.task-main { + flex: 1; + min-width: 0; +} + +.task-title { + font-size: 13px; + color: var(--text-primary); + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.task-meta { + margin-top: 2px; + display: flex; + flex-wrap: wrap; + gap: 8px; + font-size: 11px; + color: var(--text-secondary); +} + +.task-status { + border-radius: 999px; + padding: 2px 8px; + font-weight: 600; + + &.queued { + background: rgba(var(--primary-rgb), 0.14); + color: var(--primary); + } + + &.running { + background: rgba(var(--primary-rgb), 0.2); + color: var(--primary); + } + + &.paused { + background: rgba(250, 173, 20, 0.2); + color: #d48806; + } + + &.stopped { + background: rgba(148, 163, 184, 0.2); + color: #64748b; + } + + &.success { + background: rgba(82, 196, 26, 0.18); + color: #52c41a; + } + + &.error { + background: rgba(255, 77, 79, 0.15); + color: #ff4d4f; + } +} + +.task-progress-bar { + margin-top: 8px; + height: 6px; + background: rgba(0, 0, 0, 0.08); + border-radius: 3px; + overflow: hidden; +} + +.task-progress-fill { + height: 100%; + background: var(--primary); + transition: width 0.2s ease; +} + +.task-progress-text { + margin-top: 4px; + font-size: 11px; + color: var(--text-secondary); +} + +.task-error { + margin-top: 6px; + font-size: 12px; + color: #ff4d4f; +} + +.task-actions { + display: flex; + flex-direction: column; + gap: 6px; + flex-shrink: 0; +} + +.task-action-btn { + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-primary); + color: var(--text-secondary); + min-height: 30px; + padding: 0 10px; + font-size: 12px; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + white-space: nowrap; + + &:hover:not(:disabled) { + border-color: var(--primary); + color: var(--primary); + } + + &:disabled { + opacity: 0.65; + cursor: not-allowed; + } + + &.primary { + border-color: rgba(var(--primary-rgb), 0.35); + color: var(--primary); + } + + &.danger { + border-color: rgba(255, 77, 79, 0.36); color: #ff4d4f; } } @@ -1034,6 +1168,12 @@ background: color-mix(in srgb, var(--primary) 80%, #000); } + &.paused { + background: rgba(250, 173, 20, 0.16); + color: #d48806; + border: 1px solid rgba(250, 173, 20, 0.38); + } + &.no-session { background: var(--bg-secondary); color: var(--text-tertiary); @@ -1677,6 +1817,21 @@ } } +@keyframes exportTaskBadgePulse { + 0% { + transform: scale(1); + box-shadow: 0 0 0 0 rgba(255, 77, 79, 0.35); + } + 70% { + transform: scale(1.02); + box-shadow: 0 0 0 6px rgba(255, 77, 79, 0); + } + 100% { + transform: scale(1); + box-shadow: 0 0 0 0 rgba(255, 77, 79, 0); + } +} + @media (max-width: 1360px) { .global-export-controls { padding: 10px; @@ -1725,6 +1880,19 @@ grid-template-columns: 1fr; } + .task-center-modal-overlay { + padding: 12px 10px; + } + + .task-center-modal { + width: calc(100vw - 20px); + max-height: calc(100vh - 56px); + } + + .task-actions { + width: 84px; + } + .export-session-detail-panel { width: calc(100vw - 12px); } diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 1d681e3..cf968b8 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -5,8 +5,6 @@ import { Aperture, Calendar, Check, - ChevronDown, - ChevronRight, CheckSquare, Copy, Database, @@ -35,7 +33,8 @@ import { useContactTypeCountsStore } from '../stores/contactTypeCountsStore' import './ExportPage.scss' type ConversationTab = 'private' | 'group' | 'official' | 'former_friend' -type TaskStatus = 'queued' | 'running' | 'success' | 'error' +type TaskStatus = 'queued' | 'running' | 'paused' | 'stopped' | 'success' | 'error' +type TaskControlState = 'pausing' | 'stopping' type TaskScope = 'single' | 'multi' | 'content' | 'sns' type ContentType = 'text' | 'voice' | 'image' | 'video' | 'emoji' type ContentCardType = ContentType | 'sns' @@ -97,6 +96,7 @@ interface ExportTask { id: string title: string status: TaskStatus + controlState?: TaskControlState createdAt: number startedAt?: number finishedAt?: number @@ -167,6 +167,19 @@ const createEmptyProgress = (): TaskProgress => ({ phaseTotal: 0 }) +const getTaskStatusLabel = (task: ExportTask): string => { + if (task.status === 'queued') return '排队中' + if (task.status === 'running') { + if (task.controlState === 'pausing') return '暂停中' + if (task.controlState === 'stopping') return '停止中' + return '进行中' + } + if (task.status === 'paused') return '已暂停' + if (task.status === 'stopped') return '已停止' + if (task.status === 'success') return '已完成' + return '失败' +} + const formatAbsoluteDate = (timestamp: number): string => { const d = new Date(timestamp) const y = d.getFullYear() @@ -548,7 +561,7 @@ function ExportPage() { const [isSessionEnriching, setIsSessionEnriching] = useState(false) const [isSnsStatsLoading, setIsSnsStatsLoading] = useState(true) const [isBaseConfigLoading, setIsBaseConfigLoading] = useState(true) - const [isTaskCenterExpanded, setIsTaskCenterExpanded] = useState(false) + const [isTaskCenterOpen, setIsTaskCenterOpen] = useState(false) const [sessions, setSessions] = useState([]) const [sessionDataSource, setSessionDataSource] = useState(null) const [sessionContactsUpdatedAt, setSessionContactsUpdatedAt] = useState(null) @@ -1630,36 +1643,49 @@ function ExportPage() { if (!next) return runningTaskIdRef.current = next.id - updateTask(next.id, task => ({ ...task, status: 'running', startedAt: Date.now() })) + updateTask(next.id, task => ({ + ...task, + status: 'running', + controlState: undefined, + startedAt: Date.now(), + finishedAt: undefined, + error: undefined + })) progressUnsubscribeRef.current?.() if (next.payload.scope === 'sns') { progressUnsubscribeRef.current = window.electronAPI.sns.onExportProgress((payload) => { - updateTask(next.id, task => ({ - ...task, - progress: { - current: payload.current || 0, - total: payload.total || 0, - currentName: '', - phaseLabel: payload.status || '', - phaseProgress: payload.total > 0 ? payload.current : 0, - phaseTotal: payload.total || 0 + updateTask(next.id, task => { + if (task.status !== 'running') return task + return { + ...task, + progress: { + current: payload.current || 0, + total: payload.total || 0, + currentName: '', + phaseLabel: payload.status || '', + phaseProgress: payload.total > 0 ? payload.current : 0, + phaseTotal: payload.total || 0 + } } - })) + }) }) } else { progressUnsubscribeRef.current = window.electronAPI.export.onProgress((payload: ExportProgress) => { - updateTask(next.id, task => ({ - ...task, - progress: { - current: payload.current, - total: payload.total, - currentName: payload.currentSession, - phaseLabel: payload.phaseLabel || '', - phaseProgress: payload.phaseProgress || 0, - phaseTotal: payload.phaseTotal || 0 + updateTask(next.id, task => { + if (task.status !== 'running') return task + return { + ...task, + progress: { + current: payload.current, + total: payload.total, + currentName: payload.currentSession, + phaseLabel: payload.phaseLabel || '', + phaseProgress: payload.phaseProgress || 0, + phaseTotal: payload.phaseTotal || 0 + } } - })) + }) }) } @@ -1671,16 +1697,40 @@ function ExportPage() { format: snsOptions.format, exportMedia: snsOptions.exportMedia, startTime: snsOptions.startTime, - endTime: snsOptions.endTime + endTime: snsOptions.endTime, + taskId: next.id }) if (!result.success) { updateTask(next.id, task => ({ ...task, status: 'error', + controlState: undefined, finishedAt: Date.now(), error: result.error || '朋友圈导出失败' })) + } else if (result.stopped) { + updateTask(next.id, task => ({ + ...task, + status: 'stopped', + controlState: undefined, + finishedAt: Date.now(), + progress: { + ...task.progress, + phaseLabel: '已停止' + } + })) + } else if (result.paused) { + updateTask(next.id, task => ({ + ...task, + status: 'paused', + controlState: undefined, + finishedAt: Date.now(), + progress: { + ...task.progress, + phaseLabel: '已暂停' + } + })) } else { const doneAt = Date.now() const exportedPosts = Math.max(0, result.postCount || 0) @@ -1692,6 +1742,7 @@ function ExportPage() { updateTask(next.id, task => ({ ...task, status: 'success', + controlState: undefined, finishedAt: doneAt, progress: { ...task.progress, @@ -1711,13 +1762,15 @@ function ExportPage() { const result = await window.electronAPI.export.exportSessions( next.payload.sessionIds, next.payload.outputDir, - next.payload.options + next.payload.options, + next.id ) if (!result.success) { updateTask(next.id, task => ({ ...task, status: 'error', + controlState: undefined, finishedAt: Date.now(), error: result.error || '导出失败' })) @@ -1726,29 +1779,94 @@ function ExportPage() { const contentTypes = next.payload.contentType ? [next.payload.contentType] : inferContentTypesFromOptions(next.payload.options) + const successSessionIds = Array.isArray(result.successSessionIds) + ? result.successSessionIds + : [] + if (successSessionIds.length > 0) { + markSessionExported(successSessionIds, doneAt) + markContentExported(successSessionIds, contentTypes, doneAt) + } - markSessionExported(next.payload.sessionIds, doneAt) - markContentExported(next.payload.sessionIds, contentTypes, doneAt) + if (result.stopped) { + updateTask(next.id, task => ({ + ...task, + status: 'stopped', + controlState: undefined, + finishedAt: doneAt, + progress: { + ...task.progress, + current: result.successCount + result.failCount, + total: task.progress.total || next.payload.sessionIds.length, + phaseLabel: '已停止' + } + })) + } else if (result.paused) { + const pendingSessionIds = Array.isArray(result.pendingSessionIds) + ? result.pendingSessionIds + : [] + const sessionNameMap = new Map() + next.payload.sessionIds.forEach((sessionId, index) => { + sessionNameMap.set(sessionId, next.payload.sessionNames[index] || sessionId) + }) + const pendingSessionNames = pendingSessionIds.map(sessionId => sessionNameMap.get(sessionId) || sessionId) - updateTask(next.id, task => ({ - ...task, - status: 'success', - finishedAt: doneAt, - progress: { - ...task.progress, - current: task.progress.total || next.payload.sessionIds.length, - total: task.progress.total || next.payload.sessionIds.length, - phaseLabel: '完成', - phaseProgress: 1, - phaseTotal: 1 + if (pendingSessionIds.length === 0) { + updateTask(next.id, task => ({ + ...task, + status: 'success', + controlState: undefined, + finishedAt: doneAt, + progress: { + ...task.progress, + current: task.progress.total || next.payload.sessionIds.length, + total: task.progress.total || next.payload.sessionIds.length, + phaseLabel: '完成', + phaseProgress: 1, + phaseTotal: 1 + } + })) + } else { + updateTask(next.id, task => ({ + ...task, + status: 'paused', + controlState: undefined, + finishedAt: doneAt, + payload: { + ...task.payload, + sessionIds: pendingSessionIds, + sessionNames: pendingSessionNames + }, + progress: { + ...task.progress, + current: result.successCount + result.failCount, + total: task.progress.total || next.payload.sessionIds.length, + phaseLabel: '已暂停' + } + })) } - })) + } else { + updateTask(next.id, task => ({ + ...task, + status: 'success', + controlState: undefined, + finishedAt: doneAt, + progress: { + ...task.progress, + current: task.progress.total || next.payload.sessionIds.length, + total: task.progress.total || next.payload.sessionIds.length, + phaseLabel: '完成', + phaseProgress: 1, + phaseTotal: 1 + } + })) + } } } } catch (error) { updateTask(next.id, task => ({ ...task, status: 'error', + controlState: undefined, finishedAt: Date.now(), error: String(error) })) @@ -1771,6 +1889,88 @@ function ExportPage() { } }, []) + const pauseTask = useCallback(async (taskId: string) => { + const target = tasksRef.current.find(task => task.id === taskId) + if (!target) return + + if (target.status === 'queued') { + updateTask(taskId, task => ({ + ...task, + status: 'paused', + controlState: undefined + })) + return + } + + if (target.status !== 'running') return + + updateTask(taskId, task => ( + task.status === 'running' + ? { ...task, controlState: 'pausing' } + : task + )) + + const result = await window.electronAPI.export.pauseTask(taskId) + if (!result.success) { + updateTask(taskId, task => ( + task.status === 'running' + ? { ...task, controlState: undefined } + : task + )) + window.alert(result.error || '暂停任务失败,请重试') + } + }, [updateTask]) + + const resumeTask = useCallback((taskId: string) => { + updateTask(taskId, task => { + if (task.status !== 'paused') return task + return { + ...task, + status: 'queued', + controlState: undefined + } + }) + }, [updateTask]) + + const stopTask = useCallback(async (taskId: string) => { + const target = tasksRef.current.find(task => task.id === taskId) + if (!target) return + const shouldStop = window.confirm('确认停止该导出任务吗?') + if (!shouldStop) return + + if (target.status === 'queued' || target.status === 'paused') { + updateTask(taskId, task => ({ + ...task, + status: 'stopped', + controlState: undefined, + finishedAt: Date.now(), + progress: { + ...task.progress, + phaseLabel: '已停止' + } + })) + return + } + + if (target.status !== 'running') return + + updateTask(taskId, task => ( + task.status === 'running' + ? { ...task, controlState: 'stopping' } + : task + )) + + const result = await window.electronAPI.export.stopTask(taskId) + if (!result.success) { + updateTask(taskId, task => ( + task.status === 'running' + ? { ...task, controlState: undefined } + : task + )) + window.alert(result.error || '停止任务失败,请重试') + } + }, [updateTask]) + const createTask = async () => { if (!exportDialog.open || !exportFolder) return if (exportDialog.scope !== 'sns' && exportDialog.sessionIds.length === 0) return @@ -1892,6 +2092,17 @@ function ExportPage() { return set }, [tasks]) + const pausedSessionIds = useMemo(() => { + const set = new Set() + for (const task of tasks) { + if (task.status !== 'paused') continue + for (const id of task.payload.sessionIds) { + set.add(id) + } + } + return set + }, [tasks]) + const runningCardTypes = useMemo(() => { const set = new Set() for (const task of tasks) { @@ -2299,6 +2510,7 @@ function ExportPage() { const isRunning = runningSessionIds.has(session.username) const isQueued = queuedSessionIds.has(session.username) + const isPaused = pausedSessionIds.has(session.username) const recent = formatRecentExportTime(lastExportBySession[session.username], nowTick) return ( @@ -2311,8 +2523,8 @@ function ExportPage() { 详情 {recent && {recent}} @@ -2395,6 +2607,7 @@ function ExportPage() { const isSnsCardStatsLoading = !hasSeededSnsStats const taskRunningCount = tasks.filter(task => task.status === 'running').length const taskQueuedCount = tasks.filter(task => task.status === 'queued').length + const taskPausedCount = tasks.filter(task => task.status === 'paused').length const showInitialSkeleton = isLoading && sessions.length === 0 const chooseExportFolder = useCallback(async () => { const result = await window.electronAPI.dialog.openFile({ @@ -2448,62 +2661,119 @@ function ExportPage() {
进行中 {taskRunningCount} 排队 {taskQueuedCount} + 暂停 {taskPausedCount} 总计 {tasks.length}
- {isTaskCenterExpanded && ( -
- {tasks.length === 0 ? ( -
暂无任务。点击会话导出或卡片导出后会在这里创建任务。
- ) : ( -
- {tasks.map(task => ( -
-
-
{task.title}
-
- {task.status === 'queued' ? '排队中' : task.status === 'running' ? '进行中' : task.status === 'success' ? '已完成' : '失败'} - {new Date(task.createdAt).toLocaleString('zh-CN')} -
- {task.status === 'running' && ( - <> -
-
0 ? (task.progress.current / task.progress.total) * 100 : 0}%` }} - /> -
-
- {task.progress.total > 0 - ? `${task.progress.current} / ${task.progress.total}` - : '处理中'} - {task.progress.phaseLabel ? ` · ${task.progress.phaseLabel}` : ''} -
- - )} - {task.status === 'error' &&
{task.error || '任务失败'}
} -
-
- -
-
- ))} + {isTaskCenterOpen && ( +
setIsTaskCenterOpen(false)} + > +
event.stopPropagation()} + > +
+
+

任务中心

+ 进行中 {taskRunningCount} · 排队 {taskQueuedCount} · 暂停 {taskPausedCount} · 总计 {tasks.length} +
+
- )} +
+ {tasks.length === 0 ? ( +
暂无任务。点击会话导出或卡片导出后会在这里创建任务。
+ ) : ( +
+ {tasks.map(task => ( +
+
+
{task.title}
+
+ {getTaskStatusLabel(task)} + {new Date(task.createdAt).toLocaleString('zh-CN')} +
+ {(task.status === 'running' || task.status === 'paused') && ( + <> +
+
0 ? (task.progress.current / task.progress.total) * 100 : 0}%` }} + /> +
+
+ {task.progress.total > 0 + ? `${task.progress.current} / ${task.progress.total}` + : '处理中'} + {task.progress.phaseLabel ? ` · ${task.progress.phaseLabel}` : ''} +
+ + )} + {task.status === 'error' &&
{task.error || '任务失败'}
} +
+
+ {(task.status === 'running' || task.status === 'queued') && ( + + )} + {task.status === 'paused' && ( + + )} + {(task.status === 'running' || task.status === 'queued' || task.status === 'paused') && ( + + )} + +
+
+ ))} +
+ )} +
+
)} @@ -2677,6 +2947,7 @@ function ExportPage() { const canExport = Boolean(matchedSession?.hasSession) const isRunning = canExport && runningSessionIds.has(contact.username) const isQueued = canExport && queuedSessionIds.has(contact.username) + const isPaused = canExport && pausedSessionIds.has(contact.username) const recent = canExport ? formatRecentExportTime(lastExportBySession[contact.username], nowTick) : '' return (
{recent && {recent}} diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index e24c3d5..4efdeb8 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -521,10 +521,15 @@ export interface ElectronAPI { estimatedSeconds: number sessions: Array<{ sessionId: string; displayName: string; totalCount: number; voiceCount: number }> }> - exportSessions: (sessionIds: string[], outputDir: string, options: ExportOptions) => Promise<{ + exportSessions: (sessionIds: string[], outputDir: string, options: ExportOptions, taskId?: string) => Promise<{ success: boolean successCount?: number failCount?: number + paused?: boolean + stopped?: boolean + pendingSessionIds?: string[] + successSessionIds?: string[] + failedSessionIds?: string[] error?: string }> exportSession: (sessionId: string, outputPath: string, options: ExportOptions) => Promise<{ @@ -536,6 +541,8 @@ export interface ElectronAPI { successCount?: number error?: string }> + pauseTask: (taskId: string) => Promise<{ success: boolean; error?: string }> + stopTask: (taskId: string) => Promise<{ success: boolean; error?: string }> onProgress: (callback: (payload: ExportProgress) => void) => () => void } whisper: { @@ -587,7 +594,8 @@ export interface ElectronAPI { exportMedia?: boolean startTime?: number endTime?: number - }) => Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; error?: string }> + taskId?: string + }) => Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; paused?: boolean; stopped?: boolean; error?: string }> onExportProgress: (callback: (payload: { current: number; total: number; status: string }) => void) => () => void selectExportDir: () => Promise<{ canceled: boolean; filePath?: string }> getSnsUsernames: () => Promise<{ success: boolean; usernames?: string[]; error?: string }>