mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
feat(export): modal task center with pause/stop controls
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user