feat(export): modal task center with pause/stop controls

This commit is contained in:
tisonhuang
2026-03-02 16:01:48 +08:00
parent 51bc60776d
commit 8d68a59799
7 changed files with 836 additions and 219 deletions

View File

@@ -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<string, ExportTaskControlState>()
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) => {

View File

@@ -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')

View File

@@ -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) }
}

View File

@@ -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