mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-26 07:26:46 +00:00
Merge pull request #845 from BeiChen-CN/codex/export-pause-cancel
feat(export): 添加导出暂停取消控制
This commit is contained in:
@@ -5,6 +5,7 @@ interface ExportWorkerConfig {
|
|||||||
sessionIds: string[]
|
sessionIds: string[]
|
||||||
outputDir: string
|
outputDir: string
|
||||||
options: ExportOptions
|
options: ExportOptions
|
||||||
|
taskId?: string
|
||||||
dbPath?: string
|
dbPath?: string
|
||||||
decryptKey?: string
|
decryptKey?: string
|
||||||
myWxid?: string
|
myWxid?: string
|
||||||
@@ -14,6 +15,27 @@ interface ExportWorkerConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const config = workerData as ExportWorkerConfig
|
const config = workerData as ExportWorkerConfig
|
||||||
|
const controlState = {
|
||||||
|
pauseRequested: false,
|
||||||
|
stopRequested: false
|
||||||
|
}
|
||||||
|
|
||||||
|
parentPort?.on('message', (message: any) => {
|
||||||
|
if (!message || typeof message.type !== 'string') return
|
||||||
|
if (message.type === 'export:pause') {
|
||||||
|
controlState.pauseRequested = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (message.type === 'export:resume') {
|
||||||
|
controlState.pauseRequested = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (message.type === 'export:cancel') {
|
||||||
|
controlState.stopRequested = true
|
||||||
|
controlState.pauseRequested = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
process.env.WEFLOW_WORKER = '1'
|
process.env.WEFLOW_WORKER = '1'
|
||||||
if (config.resourcesPath) {
|
if (config.resourcesPath) {
|
||||||
process.env.WCDB_RESOURCES_PATH = config.resourcesPath
|
process.env.WCDB_RESOURCES_PATH = config.resourcesPath
|
||||||
@@ -47,7 +69,19 @@ async function run() {
|
|||||||
type: 'export:progress',
|
type: 'export:progress',
|
||||||
data: progress
|
data: progress
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
config.taskId
|
||||||
|
? {
|
||||||
|
shouldPause: () => controlState.pauseRequested,
|
||||||
|
shouldStop: () => controlState.stopRequested,
|
||||||
|
recordCreatedFile: (filePath: string) => {
|
||||||
|
parentPort?.postMessage({ type: 'export:createdFile', filePath })
|
||||||
|
},
|
||||||
|
recordCreatedDir: (dirPath: string) => {
|
||||||
|
parentPort?.postMessage({ type: 'export:createdDir', dirPath })
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
)
|
)
|
||||||
|
|
||||||
parentPort?.postMessage({
|
parentPort?.postMessage({
|
||||||
|
|||||||
112
electron/main.ts
112
electron/main.ts
@@ -16,6 +16,7 @@ import { analyticsService } from './services/analyticsService'
|
|||||||
import { groupAnalyticsService } from './services/groupAnalyticsService'
|
import { groupAnalyticsService } from './services/groupAnalyticsService'
|
||||||
import { annualReportService } from './services/annualReportService'
|
import { annualReportService } from './services/annualReportService'
|
||||||
import { exportService, ExportOptions, ExportProgress } from './services/exportService'
|
import { exportService, ExportOptions, ExportProgress } from './services/exportService'
|
||||||
|
import { exportTaskControlService } from './services/exportTaskControlService'
|
||||||
import { KeyService } from './services/keyService'
|
import { KeyService } from './services/keyService'
|
||||||
import { KeyServiceLinux } from './services/keyServiceLinux'
|
import { KeyServiceLinux } from './services/keyServiceLinux'
|
||||||
import { KeyServiceMac } from './services/keyServiceMac'
|
import { KeyServiceMac } from './services/keyServiceMac'
|
||||||
@@ -64,6 +65,42 @@ const defaultUpdateTrack: 'stable' | 'preview' | 'dev' = (() => {
|
|||||||
return 'stable'
|
return 'stable'
|
||||||
})()
|
})()
|
||||||
let configService: ConfigService | null = null
|
let configService: ConfigService | null = null
|
||||||
|
const activeExportWorkers = new Map<string, Worker>()
|
||||||
|
const activeExportTasks = new Set<string>()
|
||||||
|
|
||||||
|
const normalizeExportTaskId = (taskId: unknown): string => String(taskId || '').trim()
|
||||||
|
|
||||||
|
const postExportWorkerControl = (taskId: string, action: 'pause' | 'resume' | 'cancel') => {
|
||||||
|
const worker = activeExportWorkers.get(taskId)
|
||||||
|
if (!worker) return
|
||||||
|
try {
|
||||||
|
worker.postMessage({ type: `export:${action}` })
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[export-task-control] failed to post ${action} to worker:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalizeExportTaskControlResult = async (taskId: string, result: any) => {
|
||||||
|
if (!taskId) return result
|
||||||
|
if (result?.stopped) {
|
||||||
|
const cleanup = await exportTaskControlService.cleanupTask(taskId)
|
||||||
|
if (!cleanup.success) {
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
success: false,
|
||||||
|
error: `导出已停止,但清理已导出文件失败:${cleanup.error || '未知错误'}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
cleanup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!result?.paused) {
|
||||||
|
exportTaskControlService.releaseTask(taskId)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
const normalizeUpdateTrack = (raw: unknown): 'stable' | 'preview' | 'dev' | null => {
|
const normalizeUpdateTrack = (raw: unknown): 'stable' | 'preview' | 'dev' | null => {
|
||||||
if (raw === 'stable' || raw === 'preview' || raw === 'dev') return raw
|
if (raw === 'stable' || raw === 'preview' || raw === 'dev') return raw
|
||||||
@@ -2636,16 +2673,25 @@ function registerIpcHandlers() {
|
|||||||
|
|
||||||
ipcMain.handle('sns:exportTimeline', async (event, options: any) => {
|
ipcMain.handle('sns:exportTimeline', async (event, options: any) => {
|
||||||
const exportOptions = { ...(options || {}) }
|
const exportOptions = { ...(options || {}) }
|
||||||
|
const taskId = normalizeExportTaskId(exportOptions.taskId)
|
||||||
delete exportOptions.taskId
|
delete exportOptions.taskId
|
||||||
|
const taskControl = taskId ? exportTaskControlService.createControl(taskId, String(exportOptions.outputDir || '')) : undefined
|
||||||
|
if (taskId) activeExportTasks.add(taskId)
|
||||||
|
|
||||||
return snsService.exportTimeline(
|
try {
|
||||||
|
const result = await snsService.exportTimeline(
|
||||||
exportOptions,
|
exportOptions,
|
||||||
(progress) => {
|
(progress) => {
|
||||||
if (!event.sender.isDestroyed()) {
|
if (!event.sender.isDestroyed()) {
|
||||||
event.sender.send('sns:exportProgress', progress)
|
event.sender.send('sns:exportProgress', progress)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
taskControl
|
||||||
)
|
)
|
||||||
|
return finalizeExportTaskControlResult(taskId, result)
|
||||||
|
} finally {
|
||||||
|
if (taskId) activeExportTasks.delete(taskId)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('sns:selectExportDir', async () => {
|
ipcMain.handle('sns:selectExportDir', async () => {
|
||||||
@@ -2968,7 +3014,40 @@ function registerIpcHandlers() {
|
|||||||
return exportService.getExportStats(sessionIds, options)
|
return exportService.getExportStats(sessionIds, options)
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions) => {
|
ipcMain.handle('export:pauseTask', async (_, taskId: string) => {
|
||||||
|
const normalizedTaskId = normalizeExportTaskId(taskId)
|
||||||
|
if (!normalizedTaskId) return { success: false, error: '缺少导出任务 ID' }
|
||||||
|
const success = exportTaskControlService.pauseTask(normalizedTaskId)
|
||||||
|
if (success) postExportWorkerControl(normalizedTaskId, 'pause')
|
||||||
|
return { success }
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('export:resumeTask', async (_, taskId: string) => {
|
||||||
|
const normalizedTaskId = normalizeExportTaskId(taskId)
|
||||||
|
if (!normalizedTaskId) return { success: false, error: '缺少导出任务 ID' }
|
||||||
|
const success = exportTaskControlService.resumeTask(normalizedTaskId)
|
||||||
|
if (success) postExportWorkerControl(normalizedTaskId, 'resume')
|
||||||
|
return { success }
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('export:cancelTask', async (_, taskId: string) => {
|
||||||
|
const normalizedTaskId = normalizeExportTaskId(taskId)
|
||||||
|
if (!normalizedTaskId) return { success: false, error: '缺少导出任务 ID' }
|
||||||
|
const success = exportTaskControlService.cancelTask(normalizedTaskId)
|
||||||
|
if (success) postExportWorkerControl(normalizedTaskId, 'cancel')
|
||||||
|
if (success && !activeExportTasks.has(normalizedTaskId)) {
|
||||||
|
const cleanup = await exportTaskControlService.cleanupTask(normalizedTaskId)
|
||||||
|
return cleanup.success
|
||||||
|
? { success: true, cleanup }
|
||||||
|
: { success: false, error: cleanup.error || '清理已导出文件失败' }
|
||||||
|
}
|
||||||
|
return { success }
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions, controlOptions?: { taskId?: string }) => {
|
||||||
|
const taskId = normalizeExportTaskId(controlOptions?.taskId)
|
||||||
|
const taskControl = taskId ? exportTaskControlService.createControl(taskId, outputDir) : undefined
|
||||||
|
if (taskId) activeExportTasks.add(taskId)
|
||||||
const PROGRESS_FORWARD_INTERVAL_MS = 180
|
const PROGRESS_FORWARD_INTERVAL_MS = 180
|
||||||
let pendingProgress: ExportProgress | null = null
|
let pendingProgress: ExportProgress | null = null
|
||||||
let progressTimer: NodeJS.Timeout | null = null
|
let progressTimer: NodeJS.Timeout | null = null
|
||||||
@@ -3014,7 +3093,7 @@ function registerIpcHandlers() {
|
|||||||
|
|
||||||
const runMainFallback = async (reason: string) => {
|
const runMainFallback = async (reason: string) => {
|
||||||
console.warn(`[fallback-export-main] ${reason}`)
|
console.warn(`[fallback-export-main] ${reason}`)
|
||||||
return exportService.exportSessions(sessionIds, outputDir, options, onProgress)
|
return exportService.exportSessions(sessionIds, outputDir, options, onProgress, taskControl)
|
||||||
}
|
}
|
||||||
|
|
||||||
const cfg = configService || new ConfigService()
|
const cfg = configService || new ConfigService()
|
||||||
@@ -3036,6 +3115,7 @@ function registerIpcHandlers() {
|
|||||||
sessionIds,
|
sessionIds,
|
||||||
outputDir,
|
outputDir,
|
||||||
options,
|
options,
|
||||||
|
taskId,
|
||||||
dbPath,
|
dbPath,
|
||||||
decryptKey,
|
decryptKey,
|
||||||
myWxid,
|
myWxid,
|
||||||
@@ -3046,9 +3126,15 @@ function registerIpcHandlers() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
let settled = false
|
let settled = false
|
||||||
|
if (taskId) {
|
||||||
|
activeExportWorkers.set(taskId, worker)
|
||||||
|
}
|
||||||
const finalizeResolve = (value: any) => {
|
const finalizeResolve = (value: any) => {
|
||||||
if (settled) return
|
if (settled) return
|
||||||
settled = true
|
settled = true
|
||||||
|
if (taskId && activeExportWorkers.get(taskId) === worker) {
|
||||||
|
activeExportWorkers.delete(taskId)
|
||||||
|
}
|
||||||
worker.removeAllListeners()
|
worker.removeAllListeners()
|
||||||
void worker.terminate()
|
void worker.terminate()
|
||||||
resolve(value)
|
resolve(value)
|
||||||
@@ -3056,6 +3142,9 @@ function registerIpcHandlers() {
|
|||||||
const finalizeReject = (error: Error) => {
|
const finalizeReject = (error: Error) => {
|
||||||
if (settled) return
|
if (settled) return
|
||||||
settled = true
|
settled = true
|
||||||
|
if (taskId && activeExportWorkers.get(taskId) === worker) {
|
||||||
|
activeExportWorkers.delete(taskId)
|
||||||
|
}
|
||||||
worker.removeAllListeners()
|
worker.removeAllListeners()
|
||||||
void worker.terminate()
|
void worker.terminate()
|
||||||
reject(error)
|
reject(error)
|
||||||
@@ -3066,6 +3155,14 @@ function registerIpcHandlers() {
|
|||||||
onProgress(msg.data as ExportProgress)
|
onProgress(msg.data as ExportProgress)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (msg && msg.type === 'export:createdFile' && taskId) {
|
||||||
|
exportTaskControlService.recordCreatedFile(taskId, String(msg.filePath || ''))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (msg && msg.type === 'export:createdDir' && taskId) {
|
||||||
|
exportTaskControlService.recordCreatedDir(taskId, String(msg.dirPath || ''))
|
||||||
|
return
|
||||||
|
}
|
||||||
if (msg && msg.type === 'export:result') {
|
if (msg && msg.type === 'export:result') {
|
||||||
finalizeResolve(msg.data)
|
finalizeResolve(msg.data)
|
||||||
return
|
return
|
||||||
@@ -3091,10 +3188,13 @@ function registerIpcHandlers() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await runWorker()
|
const result = await runWorker()
|
||||||
|
return await finalizeExportTaskControlResult(taskId, result)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return runMainFallback(error instanceof Error ? error.message : String(error))
|
const result = await runMainFallback(error instanceof Error ? error.message : String(error))
|
||||||
|
return await finalizeExportTaskControlResult(taskId, result)
|
||||||
} finally {
|
} finally {
|
||||||
|
if (taskId) activeExportTasks.delete(taskId)
|
||||||
flushProgress()
|
flushProgress()
|
||||||
if (progressTimer) {
|
if (progressTimer) {
|
||||||
clearTimeout(progressTimer)
|
clearTimeout(progressTimer)
|
||||||
|
|||||||
@@ -463,8 +463,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
export: {
|
export: {
|
||||||
getExportStats: (sessionIds: string[], options: any) =>
|
getExportStats: (sessionIds: string[], options: any) =>
|
||||||
ipcRenderer.invoke('export:getExportStats', sessionIds, options),
|
ipcRenderer.invoke('export:getExportStats', sessionIds, options),
|
||||||
exportSessions: (sessionIds: string[], outputDir: string, options: any) =>
|
exportSessions: (sessionIds: string[], outputDir: string, options: any, controlOptions?: { taskId?: string }) =>
|
||||||
ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options),
|
ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options, controlOptions),
|
||||||
|
pauseTask: (taskId: string) =>
|
||||||
|
ipcRenderer.invoke('export:pauseTask', taskId),
|
||||||
|
resumeTask: (taskId: string) =>
|
||||||
|
ipcRenderer.invoke('export:resumeTask', taskId),
|
||||||
|
cancelTask: (taskId: string) =>
|
||||||
|
ipcRenderer.invoke('export:cancelTask', taskId),
|
||||||
exportSession: (sessionId: string, outputPath: string, options: any) =>
|
exportSession: (sessionId: string, outputPath: string, options: any) =>
|
||||||
ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options),
|
ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options),
|
||||||
exportContacts: (outputDir: string, options: any) =>
|
exportContacts: (outputDir: string, options: any) =>
|
||||||
|
|||||||
@@ -200,6 +200,8 @@ interface MediaSourceResolution {
|
|||||||
interface ExportTaskControl {
|
interface ExportTaskControl {
|
||||||
shouldPause?: () => boolean
|
shouldPause?: () => boolean
|
||||||
shouldStop?: () => boolean
|
shouldStop?: () => boolean
|
||||||
|
recordCreatedFile?: (filePath: string) => void
|
||||||
|
recordCreatedDir?: (dirPath: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ExportStatsResult {
|
interface ExportStatsResult {
|
||||||
@@ -279,6 +281,7 @@ class ExportService {
|
|||||||
private readonly exportAggregatedSessionStatsCacheTtlMs = 60 * 1000
|
private readonly exportAggregatedSessionStatsCacheTtlMs = 60 * 1000
|
||||||
private readonly exportStatsCacheMaxEntries = 16
|
private readonly exportStatsCacheMaxEntries = 16
|
||||||
private readonly STOP_ERROR_CODE = 'WEFLOW_EXPORT_STOP_REQUESTED'
|
private readonly STOP_ERROR_CODE = 'WEFLOW_EXPORT_STOP_REQUESTED'
|
||||||
|
private readonly PAUSE_ERROR_CODE = 'WEFLOW_EXPORT_PAUSE_REQUESTED'
|
||||||
private mediaFileCachePopulatePending = new Map<string, Promise<string | null>>()
|
private mediaFileCachePopulatePending = new Map<string, Promise<string | null>>()
|
||||||
private mediaFileCacheReadyDirs = new Set<string>()
|
private mediaFileCacheReadyDirs = new Set<string>()
|
||||||
private mediaExportTelemetry: MediaExportTelemetry | null = null
|
private mediaExportTelemetry: MediaExportTelemetry | null = null
|
||||||
@@ -311,6 +314,12 @@ class ExportService {
|
|||||||
return error
|
return error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private createPauseError(): Error {
|
||||||
|
const error = new Error('导出任务已暂停')
|
||||||
|
;(error as Error & { code?: string }).code = this.PAUSE_ERROR_CODE
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
|
||||||
setRuntimeConfig(config: { dbPath?: string; decryptKey?: string; myWxid?: string } | null): void {
|
setRuntimeConfig(config: { dbPath?: string; decryptKey?: string; myWxid?: string } | null): void {
|
||||||
this.runtimeConfig = config
|
this.runtimeConfig = config
|
||||||
}
|
}
|
||||||
@@ -453,10 +462,42 @@ class ExportService {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isPauseError(error: unknown): boolean {
|
||||||
|
if (!error) return false
|
||||||
|
if (typeof error === 'string') {
|
||||||
|
return error.includes(this.PAUSE_ERROR_CODE) || error.includes('导出任务已暂停')
|
||||||
|
}
|
||||||
|
if (error instanceof Error) {
|
||||||
|
const code = (error as Error & { code?: string }).code
|
||||||
|
return code === this.PAUSE_ERROR_CODE || error.message.includes(this.PAUSE_ERROR_CODE) || error.message.includes('导出任务已暂停')
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
private throwIfStopRequested(control?: ExportTaskControl): void {
|
private throwIfStopRequested(control?: ExportTaskControl): void {
|
||||||
if (control?.shouldStop?.()) {
|
if (control?.shouldStop?.()) {
|
||||||
throw this.createStopError()
|
throw this.createStopError()
|
||||||
}
|
}
|
||||||
|
if (control?.shouldPause?.()) {
|
||||||
|
throw this.createPauseError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureExportDir(dirPath: string, control?: ExportTaskControl, dirCache?: Set<string>): Promise<void> {
|
||||||
|
if (dirCache?.has(dirPath)) return
|
||||||
|
const existed = await this.pathExists(dirPath)
|
||||||
|
await fs.promises.mkdir(dirPath, { recursive: true })
|
||||||
|
dirCache?.add(dirPath)
|
||||||
|
if (!existed) {
|
||||||
|
control?.recordCreatedDir?.(dirPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async recordCreatedFileBeforeWrite(filePath: string, control?: ExportTaskControl): Promise<void> {
|
||||||
|
if (!control?.recordCreatedFile) return
|
||||||
|
if (!await this.pathExists(filePath)) {
|
||||||
|
control.recordCreatedFile(filePath)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private getClampedConcurrency(value: number | undefined, fallback = 2, max = 6): number {
|
private getClampedConcurrency(value: number | undefined, fallback = 2, max = 6): number {
|
||||||
@@ -850,8 +891,10 @@ class ExportService {
|
|||||||
private async copyMediaWithCacheAndDedup(
|
private async copyMediaWithCacheAndDedup(
|
||||||
kind: 'image' | 'video' | 'emoji',
|
kind: 'image' | 'video' | 'emoji',
|
||||||
sourcePath: string,
|
sourcePath: string,
|
||||||
destPath: string
|
destPath: string,
|
||||||
|
control?: ExportTaskControl
|
||||||
): Promise<{ success: boolean; code?: string }> {
|
): Promise<{ success: boolean; code?: string }> {
|
||||||
|
const existedBeforeCopy = await this.pathExists(destPath)
|
||||||
const resolved = await this.resolvePreferredMediaSource(kind, sourcePath)
|
const resolved = await this.resolvePreferredMediaSource(kind, sourcePath)
|
||||||
if (resolved.cacheHit) {
|
if (resolved.cacheHit) {
|
||||||
this.noteMediaTelemetry({ cacheHitFiles: 1 })
|
this.noteMediaTelemetry({ cacheHitFiles: 1 })
|
||||||
@@ -870,6 +913,9 @@ class ExportService {
|
|||||||
dedupReuseFiles: 1,
|
dedupReuseFiles: 1,
|
||||||
bytesWritten: resolved.fileStat?.size || 0
|
bytesWritten: resolved.fileStat?.size || 0
|
||||||
})
|
})
|
||||||
|
if (!existedBeforeCopy) {
|
||||||
|
control?.recordCreatedFile?.(destPath)
|
||||||
|
}
|
||||||
return { success: true }
|
return { success: true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -886,6 +932,9 @@ class ExportService {
|
|||||||
doneFiles: 1,
|
doneFiles: 1,
|
||||||
bytesWritten: resolved.fileStat?.size || 0
|
bytesWritten: resolved.fileStat?.size || 0
|
||||||
})
|
})
|
||||||
|
if (!existedBeforeCopy) {
|
||||||
|
control?.recordCreatedFile?.(destPath)
|
||||||
|
}
|
||||||
return { success: true }
|
return { success: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3962,6 +4011,7 @@ class ExportService {
|
|||||||
includeVideoPoster?: boolean
|
includeVideoPoster?: boolean
|
||||||
includeVoiceWithTranscript?: boolean
|
includeVoiceWithTranscript?: boolean
|
||||||
dirCache?: Set<string>
|
dirCache?: Set<string>
|
||||||
|
control?: ExportTaskControl
|
||||||
}
|
}
|
||||||
): Promise<MediaExportItem | null> {
|
): Promise<MediaExportItem | null> {
|
||||||
const localType = msg.localType
|
const localType = msg.localType
|
||||||
@@ -3973,7 +4023,8 @@ class ExportService {
|
|||||||
sessionId,
|
sessionId,
|
||||||
mediaRootDir,
|
mediaRootDir,
|
||||||
mediaRelativePrefix,
|
mediaRelativePrefix,
|
||||||
options.dirCache
|
options.dirCache,
|
||||||
|
options.control
|
||||||
)
|
)
|
||||||
if (result) {
|
if (result) {
|
||||||
}
|
}
|
||||||
@@ -3983,7 +4034,7 @@ class ExportService {
|
|||||||
// 语音消息
|
// 语音消息
|
||||||
if (localType === 34) {
|
if (localType === 34) {
|
||||||
if (options.exportVoices) {
|
if (options.exportVoices) {
|
||||||
return this.exportVoice(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache)
|
return this.exportVoice(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache, options.control)
|
||||||
}
|
}
|
||||||
if (options.exportVoiceAsText) {
|
if (options.exportVoiceAsText) {
|
||||||
return null
|
return null
|
||||||
@@ -3992,7 +4043,7 @@ class ExportService {
|
|||||||
|
|
||||||
// 动画表情
|
// 动画表情
|
||||||
if (localType === 47 && options.exportEmojis) {
|
if (localType === 47 && options.exportEmojis) {
|
||||||
const result = await this.exportEmoji(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache)
|
const result = await this.exportEmoji(msg, sessionId, mediaRootDir, mediaRelativePrefix, options.dirCache, options.control)
|
||||||
if (result) {
|
if (result) {
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
@@ -4005,7 +4056,8 @@ class ExportService {
|
|||||||
mediaRootDir,
|
mediaRootDir,
|
||||||
mediaRelativePrefix,
|
mediaRelativePrefix,
|
||||||
options.dirCache,
|
options.dirCache,
|
||||||
options.includeVideoPoster === true
|
options.includeVideoPoster === true,
|
||||||
|
options.control
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4015,7 +4067,8 @@ class ExportService {
|
|||||||
mediaRootDir,
|
mediaRootDir,
|
||||||
mediaRelativePrefix,
|
mediaRelativePrefix,
|
||||||
options.maxFileSizeMb,
|
options.maxFileSizeMb,
|
||||||
options.dirCache
|
options.dirCache,
|
||||||
|
options.control
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4030,14 +4083,12 @@ class ExportService {
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
mediaRootDir: string,
|
mediaRootDir: string,
|
||||||
mediaRelativePrefix: string,
|
mediaRelativePrefix: string,
|
||||||
dirCache?: Set<string>
|
dirCache?: Set<string>,
|
||||||
|
control?: ExportTaskControl
|
||||||
): Promise<MediaExportItem | null> {
|
): Promise<MediaExportItem | null> {
|
||||||
try {
|
try {
|
||||||
const imagesDir = path.join(mediaRootDir, mediaRelativePrefix, 'images')
|
const imagesDir = path.join(mediaRootDir, mediaRelativePrefix, 'images')
|
||||||
if (!dirCache?.has(imagesDir)) {
|
await this.ensureExportDir(imagesDir, control, dirCache)
|
||||||
await fs.promises.mkdir(imagesDir, { recursive: true })
|
|
||||||
dirCache?.add(imagesDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
const tryResolveImagePath = async (imageMd5?: string, imageDatName?: string): Promise<string | null> => {
|
const tryResolveImagePath = async (imageMd5?: string, imageDatName?: string): Promise<string | null> => {
|
||||||
if (!imageMd5 && !imageDatName) return null
|
if (!imageMd5 && !imageDatName) return null
|
||||||
@@ -4123,6 +4174,7 @@ class ExportService {
|
|||||||
const destPath = path.join(imagesDir, fileName)
|
const destPath = path.join(imagesDir, fileName)
|
||||||
|
|
||||||
const buffer = Buffer.from(base64Data, 'base64')
|
const buffer = Buffer.from(base64Data, 'base64')
|
||||||
|
await this.recordCreatedFileBeforeWrite(destPath, control)
|
||||||
await fs.promises.writeFile(destPath, buffer)
|
await fs.promises.writeFile(destPath, buffer)
|
||||||
this.noteMediaTelemetry({
|
this.noteMediaTelemetry({
|
||||||
doneFiles: 1,
|
doneFiles: 1,
|
||||||
@@ -4142,7 +4194,7 @@ class ExportService {
|
|||||||
const ext = path.extname(sourcePath) || '.jpg'
|
const ext = path.extname(sourcePath) || '.jpg'
|
||||||
const fileName = `${messageId}_${imageKey}${ext}`
|
const fileName = `${messageId}_${imageKey}${ext}`
|
||||||
const destPath = path.join(imagesDir, fileName)
|
const destPath = path.join(imagesDir, fileName)
|
||||||
const copied = await this.copyMediaWithCacheAndDedup('image', sourcePath, destPath)
|
const copied = await this.copyMediaWithCacheAndDedup('image', sourcePath, destPath, control)
|
||||||
if (!copied.success) {
|
if (!copied.success) {
|
||||||
if (copied.code === 'ENOENT') {
|
if (copied.code === 'ENOENT') {
|
||||||
console.log(`[Export] 源图片文件不存在 (localId=${msg.localId}): ${sourcePath} → 将显示 [图片] 占位符`)
|
console.log(`[Export] 源图片文件不存在 (localId=${msg.localId}): ${sourcePath} → 将显示 [图片] 占位符`)
|
||||||
@@ -4261,14 +4313,12 @@ class ExportService {
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
mediaRootDir: string,
|
mediaRootDir: string,
|
||||||
mediaRelativePrefix: string,
|
mediaRelativePrefix: string,
|
||||||
dirCache?: Set<string>
|
dirCache?: Set<string>,
|
||||||
|
control?: ExportTaskControl
|
||||||
): Promise<MediaExportItem | null> {
|
): Promise<MediaExportItem | null> {
|
||||||
try {
|
try {
|
||||||
const voicesDir = path.join(mediaRootDir, mediaRelativePrefix, 'voices')
|
const voicesDir = path.join(mediaRootDir, mediaRelativePrefix, 'voices')
|
||||||
if (!dirCache?.has(voicesDir)) {
|
await this.ensureExportDir(voicesDir, control, dirCache)
|
||||||
await fs.promises.mkdir(voicesDir, { recursive: true })
|
|
||||||
dirCache?.add(voicesDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
const msgId = String(msg.localId)
|
const msgId = String(msg.localId)
|
||||||
const safeSession = this.cleanAccountDirName(sessionId)
|
const safeSession = this.cleanAccountDirName(sessionId)
|
||||||
@@ -4300,6 +4350,7 @@ class ExportService {
|
|||||||
|
|
||||||
// voiceResult.data 是 base64 编码的 wav 数据
|
// voiceResult.data 是 base64 编码的 wav 数据
|
||||||
const wavBuffer = Buffer.from(voiceResult.data, 'base64')
|
const wavBuffer = Buffer.from(voiceResult.data, 'base64')
|
||||||
|
await this.recordCreatedFileBeforeWrite(destPath, control)
|
||||||
await fs.promises.writeFile(destPath, wavBuffer)
|
await fs.promises.writeFile(destPath, wavBuffer)
|
||||||
this.noteMediaTelemetry({
|
this.noteMediaTelemetry({
|
||||||
doneFiles: 1,
|
doneFiles: 1,
|
||||||
@@ -4338,14 +4389,12 @@ class ExportService {
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
mediaRootDir: string,
|
mediaRootDir: string,
|
||||||
mediaRelativePrefix: string,
|
mediaRelativePrefix: string,
|
||||||
dirCache?: Set<string>
|
dirCache?: Set<string>,
|
||||||
|
control?: ExportTaskControl
|
||||||
): Promise<MediaExportItem | null> {
|
): Promise<MediaExportItem | null> {
|
||||||
try {
|
try {
|
||||||
const emojisDir = path.join(mediaRootDir, mediaRelativePrefix, 'emojis')
|
const emojisDir = path.join(mediaRootDir, mediaRelativePrefix, 'emojis')
|
||||||
if (!dirCache?.has(emojisDir)) {
|
await this.ensureExportDir(emojisDir, control, dirCache)
|
||||||
await fs.promises.mkdir(emojisDir, { recursive: true })
|
|
||||||
dirCache?.add(emojisDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用 chatService 下载表情包 (利用其重试和 fallback 逻辑)
|
// 使用 chatService 下载表情包 (利用其重试和 fallback 逻辑)
|
||||||
const localPath = await chatService.downloadEmojiFile(msg)
|
const localPath = await chatService.downloadEmojiFile(msg)
|
||||||
@@ -4359,7 +4408,7 @@ class ExportService {
|
|||||||
const key = msg.emojiMd5 || String(msg.localId)
|
const key = msg.emojiMd5 || String(msg.localId)
|
||||||
const fileName = `${key}${ext}`
|
const fileName = `${key}${ext}`
|
||||||
const destPath = path.join(emojisDir, fileName)
|
const destPath = path.join(emojisDir, fileName)
|
||||||
const copied = await this.copyMediaWithCacheAndDedup('emoji', localPath, destPath)
|
const copied = await this.copyMediaWithCacheAndDedup('emoji', localPath, destPath, control)
|
||||||
if (!copied.success) return null
|
if (!copied.success) return null
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -4381,7 +4430,8 @@ class ExportService {
|
|||||||
mediaRootDir: string,
|
mediaRootDir: string,
|
||||||
mediaRelativePrefix: string,
|
mediaRelativePrefix: string,
|
||||||
dirCache?: Set<string>,
|
dirCache?: Set<string>,
|
||||||
includePoster = false
|
includePoster = false,
|
||||||
|
control?: ExportTaskControl
|
||||||
): Promise<MediaExportItem | null> {
|
): Promise<MediaExportItem | null> {
|
||||||
try {
|
try {
|
||||||
let videoMd5 = String(msg.videoMd5 || '').trim().toLowerCase()
|
let videoMd5 = String(msg.videoMd5 || '').trim().toLowerCase()
|
||||||
@@ -4404,16 +4454,13 @@ class ExportService {
|
|||||||
if (!videoInfo) return null
|
if (!videoInfo) return null
|
||||||
|
|
||||||
const videosDir = path.join(mediaRootDir, mediaRelativePrefix, 'videos')
|
const videosDir = path.join(mediaRootDir, mediaRelativePrefix, 'videos')
|
||||||
if (!dirCache?.has(videosDir)) {
|
await this.ensureExportDir(videosDir, control, dirCache)
|
||||||
await fs.promises.mkdir(videosDir, { recursive: true })
|
|
||||||
dirCache?.add(videosDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourcePath = videoInfo.videoUrl
|
const sourcePath = videoInfo.videoUrl
|
||||||
const fileName = path.basename(sourcePath)
|
const fileName = path.basename(sourcePath)
|
||||||
const destPath = path.join(videosDir, fileName)
|
const destPath = path.join(videosDir, fileName)
|
||||||
|
|
||||||
const copied = await this.copyMediaWithCacheAndDedup('video', sourcePath, destPath)
|
const copied = await this.copyMediaWithCacheAndDedup('video', sourcePath, destPath, control)
|
||||||
if (!copied.success) return null
|
if (!copied.success) return null
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -4864,7 +4911,8 @@ class ExportService {
|
|||||||
mediaRootDir: string,
|
mediaRootDir: string,
|
||||||
mediaRelativePrefix: string,
|
mediaRelativePrefix: string,
|
||||||
maxFileSizeMb?: number,
|
maxFileSizeMb?: number,
|
||||||
dirCache?: Set<string>
|
dirCache?: Set<string>,
|
||||||
|
control?: ExportTaskControl
|
||||||
): Promise<MediaExportItem | null> {
|
): Promise<MediaExportItem | null> {
|
||||||
try {
|
try {
|
||||||
const fileNameRaw = String(msg?.fileName || '').trim()
|
const fileNameRaw = String(msg?.fileName || '').trim()
|
||||||
@@ -4872,10 +4920,7 @@ class ExportService {
|
|||||||
|
|
||||||
const fileExtDir = this.resolveFileAttachmentExtensionDir(msg, fileNameRaw)
|
const fileExtDir = this.resolveFileAttachmentExtensionDir(msg, fileNameRaw)
|
||||||
const fileDir = path.join(mediaRootDir, mediaRelativePrefix, 'file', fileExtDir)
|
const fileDir = path.join(mediaRootDir, mediaRelativePrefix, 'file', fileExtDir)
|
||||||
if (!dirCache?.has(fileDir)) {
|
await this.ensureExportDir(fileDir, control, dirCache)
|
||||||
await fs.promises.mkdir(fileDir, { recursive: true })
|
|
||||||
dirCache?.add(fileDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
const candidates = await this.resolveFileAttachmentCandidates(msg)
|
const candidates = await this.resolveFileAttachmentCandidates(msg)
|
||||||
if (candidates.length === 0) {
|
if (candidates.length === 0) {
|
||||||
@@ -4919,6 +4964,7 @@ class ExportService {
|
|||||||
const messageId = String(msg?.localId || Date.now())
|
const messageId = String(msg?.localId || Date.now())
|
||||||
const destFileName = `${messageId}_${safeBaseName}`
|
const destFileName = `${messageId}_${safeBaseName}`
|
||||||
const destPath = path.join(fileDir, destFileName)
|
const destPath = path.join(fileDir, destFileName)
|
||||||
|
const existedBeforeCopy = await this.pathExists(destPath)
|
||||||
const copied = await this.copyFileOptimized(selected.sourcePath, destPath)
|
const copied = await this.copyFileOptimized(selected.sourcePath, destPath)
|
||||||
if (!copied.success) {
|
if (!copied.success) {
|
||||||
this.recordFileAttachmentMiss(msg, '附件复制失败', {
|
this.recordFileAttachmentMiss(msg, '附件复制失败', {
|
||||||
@@ -4929,6 +4975,9 @@ class ExportService {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!existedBeforeCopy) {
|
||||||
|
control?.recordCreatedFile?.(destPath)
|
||||||
|
}
|
||||||
this.noteMediaTelemetry({ doneFiles: 1, bytesWritten: stat.size })
|
this.noteMediaTelemetry({ doneFiles: 1, bytesWritten: stat.size })
|
||||||
return {
|
return {
|
||||||
relativePath: path.posix.join(mediaRelativePrefix, 'file', fileExtDir, destFileName),
|
relativePath: path.posix.join(mediaRelativePrefix, 'file', fileExtDir, destFileName),
|
||||||
@@ -5884,16 +5933,15 @@ class ExportService {
|
|||||||
*/
|
*/
|
||||||
private async exportAvatarsToFiles(
|
private async exportAvatarsToFiles(
|
||||||
members: Array<{ username: string; avatarUrl?: string }>,
|
members: Array<{ username: string; avatarUrl?: string }>,
|
||||||
outputDir: string
|
outputDir: string,
|
||||||
|
control?: ExportTaskControl
|
||||||
): Promise<Map<string, string>> {
|
): Promise<Map<string, string>> {
|
||||||
const result = new Map<string, string>()
|
const result = new Map<string, string>()
|
||||||
if (members.length === 0) return result
|
if (members.length === 0) return result
|
||||||
|
|
||||||
// 创建 avatars 子目录
|
// 创建 avatars 子目录
|
||||||
const avatarsDir = path.join(outputDir, 'avatars')
|
const avatarsDir = path.join(outputDir, 'avatars')
|
||||||
if (!fs.existsSync(avatarsDir)) {
|
await this.ensureExportDir(avatarsDir, control)
|
||||||
fs.mkdirSync(avatarsDir, { recursive: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
const AVATAR_CONCURRENCY = 8
|
const AVATAR_CONCURRENCY = 8
|
||||||
await parallelLimit(members, AVATAR_CONCURRENCY, async (member) => {
|
await parallelLimit(members, AVATAR_CONCURRENCY, async (member) => {
|
||||||
@@ -5934,6 +5982,7 @@ class ExportService {
|
|||||||
try {
|
try {
|
||||||
await fs.promises.access(avatarPath)
|
await fs.promises.access(avatarPath)
|
||||||
} catch {
|
} catch {
|
||||||
|
await this.recordCreatedFileBeforeWrite(avatarPath, control)
|
||||||
await fs.promises.writeFile(avatarPath, data)
|
await fs.promises.writeFile(avatarPath, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6202,7 +6251,8 @@ class ExportService {
|
|||||||
maxFileSizeMb: options.maxFileSizeMb,
|
maxFileSizeMb: options.maxFileSizeMb,
|
||||||
exportVoiceAsText: options.exportVoiceAsText,
|
exportVoiceAsText: options.exportVoiceAsText,
|
||||||
includeVideoPoster: options.format === 'html',
|
includeVideoPoster: options.format === 'html',
|
||||||
dirCache: mediaDirCache
|
dirCache: mediaDirCache,
|
||||||
|
control
|
||||||
})
|
})
|
||||||
mediaCache.set(mediaKey, mediaItem)
|
mediaCache.set(mediaKey, mediaItem)
|
||||||
}
|
}
|
||||||
@@ -6551,9 +6601,11 @@ class ExportService {
|
|||||||
lines.push(JSON.stringify({ _type: 'message', ...message }))
|
lines.push(JSON.stringify({ _type: 'message', ...message }))
|
||||||
}
|
}
|
||||||
this.throwIfStopRequested(control)
|
this.throwIfStopRequested(control)
|
||||||
|
await this.recordCreatedFileBeforeWrite(outputPath, control)
|
||||||
await fs.promises.writeFile(outputPath, lines.join('\n'), 'utf-8')
|
await fs.promises.writeFile(outputPath, lines.join('\n'), 'utf-8')
|
||||||
} else {
|
} else {
|
||||||
this.throwIfStopRequested(control)
|
this.throwIfStopRequested(control)
|
||||||
|
await this.recordCreatedFileBeforeWrite(outputPath, control)
|
||||||
await fs.promises.writeFile(outputPath, JSON.stringify(chatLabExport, null, 2), 'utf-8')
|
await fs.promises.writeFile(outputPath, JSON.stringify(chatLabExport, null, 2), 'utf-8')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6573,6 +6625,9 @@ class ExportService {
|
|||||||
if (this.isStopError(e)) {
|
if (this.isStopError(e)) {
|
||||||
return { success: false, error: '导出任务已停止' }
|
return { success: false, error: '导出任务已停止' }
|
||||||
}
|
}
|
||||||
|
if (this.isPauseError(e)) {
|
||||||
|
return { success: false, error: '导出任务已暂停' }
|
||||||
|
}
|
||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6706,7 +6761,8 @@ class ExportService {
|
|||||||
maxFileSizeMb: options.maxFileSizeMb,
|
maxFileSizeMb: options.maxFileSizeMb,
|
||||||
exportVoiceAsText: options.exportVoiceAsText,
|
exportVoiceAsText: options.exportVoiceAsText,
|
||||||
includeVideoPoster: options.format === 'html',
|
includeVideoPoster: options.format === 'html',
|
||||||
dirCache: mediaDirCache
|
dirCache: mediaDirCache,
|
||||||
|
control
|
||||||
})
|
})
|
||||||
mediaCache.set(mediaKey, mediaItem)
|
mediaCache.set(mediaKey, mediaItem)
|
||||||
}
|
}
|
||||||
@@ -7256,6 +7312,7 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.throwIfStopRequested(control)
|
this.throwIfStopRequested(control)
|
||||||
|
await this.recordCreatedFileBeforeWrite(outputPath, control)
|
||||||
await fs.promises.writeFile(outputPath, JSON.stringify(arkmeExport, null, 2), 'utf-8')
|
await fs.promises.writeFile(outputPath, JSON.stringify(arkmeExport, null, 2), 'utf-8')
|
||||||
} else {
|
} else {
|
||||||
const detailedExport: any = {
|
const detailedExport: any = {
|
||||||
@@ -7279,6 +7336,7 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.throwIfStopRequested(control)
|
this.throwIfStopRequested(control)
|
||||||
|
await this.recordCreatedFileBeforeWrite(outputPath, control)
|
||||||
await fs.promises.writeFile(outputPath, JSON.stringify(detailedExport, null, 2), 'utf-8')
|
await fs.promises.writeFile(outputPath, JSON.stringify(detailedExport, null, 2), 'utf-8')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7298,6 +7356,9 @@ class ExportService {
|
|||||||
if (this.isStopError(e)) {
|
if (this.isStopError(e)) {
|
||||||
return { success: false, error: '导出任务已停止' }
|
return { success: false, error: '导出任务已停止' }
|
||||||
}
|
}
|
||||||
|
if (this.isPauseError(e)) {
|
||||||
|
return { success: false, error: '导出任务已暂停' }
|
||||||
|
}
|
||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7571,7 +7632,8 @@ class ExportService {
|
|||||||
maxFileSizeMb: options.maxFileSizeMb,
|
maxFileSizeMb: options.maxFileSizeMb,
|
||||||
exportVoiceAsText: options.exportVoiceAsText,
|
exportVoiceAsText: options.exportVoiceAsText,
|
||||||
includeVideoPoster: options.format === 'html',
|
includeVideoPoster: options.format === 'html',
|
||||||
dirCache: mediaDirCache
|
dirCache: mediaDirCache,
|
||||||
|
control
|
||||||
})
|
})
|
||||||
mediaCache.set(mediaKey, mediaItem)
|
mediaCache.set(mediaKey, mediaItem)
|
||||||
}
|
}
|
||||||
@@ -7835,6 +7897,7 @@ class ExportService {
|
|||||||
|
|
||||||
// 写入文件
|
// 写入文件
|
||||||
this.throwIfStopRequested(control)
|
this.throwIfStopRequested(control)
|
||||||
|
await this.recordCreatedFileBeforeWrite(outputPath, control)
|
||||||
await workbook.xlsx.writeFile(outputPath)
|
await workbook.xlsx.writeFile(outputPath)
|
||||||
|
|
||||||
onProgress?.({
|
onProgress?.({
|
||||||
@@ -7853,6 +7916,9 @@ class ExportService {
|
|||||||
if (this.isStopError(e)) {
|
if (this.isStopError(e)) {
|
||||||
return { success: false, error: '导出任务已停止' }
|
return { success: false, error: '导出任务已停止' }
|
||||||
}
|
}
|
||||||
|
if (this.isPauseError(e)) {
|
||||||
|
return { success: false, error: '导出任务已暂停' }
|
||||||
|
}
|
||||||
// 处理文件被占用的错误
|
// 处理文件被占用的错误
|
||||||
if (e instanceof Error) {
|
if (e instanceof Error) {
|
||||||
if (e.message.includes('EBUSY') || e.message.includes('resource busy') || e.message.includes('locked')) {
|
if (e.message.includes('EBUSY') || e.message.includes('resource busy') || e.message.includes('locked')) {
|
||||||
@@ -8134,6 +8200,9 @@ class ExportService {
|
|||||||
if (this.isStopError(e)) {
|
if (this.isStopError(e)) {
|
||||||
return { success: false, error: '导出任务已停止' }
|
return { success: false, error: '导出任务已停止' }
|
||||||
}
|
}
|
||||||
|
if (this.isPauseError(e)) {
|
||||||
|
return { success: false, error: '导出任务已暂停' }
|
||||||
|
}
|
||||||
if (e instanceof Error) {
|
if (e instanceof Error) {
|
||||||
if (e.message.includes('EBUSY') || e.message.includes('resource busy') || e.message.includes('locked')) {
|
if (e.message.includes('EBUSY') || e.message.includes('resource busy') || e.message.includes('locked')) {
|
||||||
return { success: false, error: '文件已经打开,请关闭后再导出' }
|
return { success: false, error: '文件已经打开,请关闭后再导出' }
|
||||||
@@ -8315,7 +8384,8 @@ class ExportService {
|
|||||||
maxFileSizeMb: options.maxFileSizeMb,
|
maxFileSizeMb: options.maxFileSizeMb,
|
||||||
exportVoiceAsText: options.exportVoiceAsText,
|
exportVoiceAsText: options.exportVoiceAsText,
|
||||||
includeVideoPoster: options.format === 'html',
|
includeVideoPoster: options.format === 'html',
|
||||||
dirCache: mediaDirCache
|
dirCache: mediaDirCache,
|
||||||
|
control
|
||||||
})
|
})
|
||||||
mediaCache.set(mediaKey, mediaItem)
|
mediaCache.set(mediaKey, mediaItem)
|
||||||
}
|
}
|
||||||
@@ -8382,6 +8452,7 @@ class ExportService {
|
|||||||
exportedMessages: 0
|
exportedMessages: 0
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await this.recordCreatedFileBeforeWrite(outputPath, control)
|
||||||
const stream = fs.createWriteStream(outputPath, { encoding: 'utf-8' })
|
const stream = fs.createWriteStream(outputPath, { encoding: 'utf-8' })
|
||||||
const writeChunk = async (chunk: string): Promise<void> => {
|
const writeChunk = async (chunk: string): Promise<void> => {
|
||||||
await new Promise<void>((resolve, _reject) => {
|
await new Promise<void>((resolve, _reject) => {
|
||||||
@@ -8567,6 +8638,9 @@ class ExportService {
|
|||||||
if (this.isStopError(e)) {
|
if (this.isStopError(e)) {
|
||||||
return { success: false, error: '导出任务已停止' }
|
return { success: false, error: '导出任务已停止' }
|
||||||
}
|
}
|
||||||
|
if (this.isPauseError(e)) {
|
||||||
|
return { success: false, error: '导出任务已暂停' }
|
||||||
|
}
|
||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8710,7 +8784,8 @@ class ExportService {
|
|||||||
maxFileSizeMb: options.maxFileSizeMb,
|
maxFileSizeMb: options.maxFileSizeMb,
|
||||||
exportVoiceAsText: options.exportVoiceAsText,
|
exportVoiceAsText: options.exportVoiceAsText,
|
||||||
includeVideoPoster: options.format === 'html',
|
includeVideoPoster: options.format === 'html',
|
||||||
dirCache: mediaDirCache
|
dirCache: mediaDirCache,
|
||||||
|
control
|
||||||
})
|
})
|
||||||
mediaCache.set(mediaKey, mediaItem)
|
mediaCache.set(mediaKey, mediaItem)
|
||||||
}
|
}
|
||||||
@@ -8777,6 +8852,7 @@ class ExportService {
|
|||||||
exportedMessages: 0
|
exportedMessages: 0
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await this.recordCreatedFileBeforeWrite(outputPath, control)
|
||||||
const stream = fs.createWriteStream(outputPath, { encoding: 'utf-8' })
|
const stream = fs.createWriteStream(outputPath, { encoding: 'utf-8' })
|
||||||
const writeChunk = async (chunk: string): Promise<void> => {
|
const writeChunk = async (chunk: string): Promise<void> => {
|
||||||
await new Promise<void>((resolve, _reject) => {
|
await new Promise<void>((resolve, _reject) => {
|
||||||
@@ -8929,6 +9005,9 @@ class ExportService {
|
|||||||
if (this.isStopError(e)) {
|
if (this.isStopError(e)) {
|
||||||
return { success: false, error: '导出任务已停止' }
|
return { success: false, error: '导出任务已停止' }
|
||||||
}
|
}
|
||||||
|
if (this.isPauseError(e)) {
|
||||||
|
return { success: false, error: '导出任务已暂停' }
|
||||||
|
}
|
||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9153,7 +9232,8 @@ class ExportService {
|
|||||||
includeVideoPoster: options.format === 'html',
|
includeVideoPoster: options.format === 'html',
|
||||||
includeVoiceWithTranscript: true,
|
includeVoiceWithTranscript: true,
|
||||||
exportVideos: options.exportVideos,
|
exportVideos: options.exportVideos,
|
||||||
dirCache: mediaDirCache
|
dirCache: mediaDirCache,
|
||||||
|
control
|
||||||
})
|
})
|
||||||
mediaCache.set(mediaKey, mediaItem)
|
mediaCache.set(mediaKey, mediaItem)
|
||||||
}
|
}
|
||||||
@@ -9224,7 +9304,8 @@ class ExportService {
|
|||||||
{ username: sessionId, avatarUrl: sessionInfo.avatarUrl },
|
{ username: sessionId, avatarUrl: sessionInfo.avatarUrl },
|
||||||
{ username: cleanedMyWxid, avatarUrl: myInfo.avatarUrl }
|
{ username: cleanedMyWxid, avatarUrl: myInfo.avatarUrl }
|
||||||
],
|
],
|
||||||
path.dirname(outputPath)
|
path.dirname(outputPath),
|
||||||
|
control
|
||||||
)
|
)
|
||||||
: new Map<string, string>()
|
: new Map<string, string>()
|
||||||
|
|
||||||
@@ -9241,6 +9322,7 @@ class ExportService {
|
|||||||
// ================= BEGIN STREAM WRITING =================
|
// ================= BEGIN STREAM WRITING =================
|
||||||
const exportMeta = this.getExportMeta(sessionId, sessionInfo, isGroup)
|
const exportMeta = this.getExportMeta(sessionId, sessionInfo, isGroup)
|
||||||
const htmlStyles = this.loadExportHtmlStyles()
|
const htmlStyles = this.loadExportHtmlStyles()
|
||||||
|
await this.recordCreatedFileBeforeWrite(outputPath, control)
|
||||||
const stream = fs.createWriteStream(outputPath, { encoding: 'utf-8' })
|
const stream = fs.createWriteStream(outputPath, { encoding: 'utf-8' })
|
||||||
|
|
||||||
const writePromise = (str: string) => {
|
const writePromise = (str: string) => {
|
||||||
@@ -9605,6 +9687,9 @@ class ExportService {
|
|||||||
if (this.isStopError(e)) {
|
if (this.isStopError(e)) {
|
||||||
return { success: false, error: '导出任务已停止' }
|
return { success: false, error: '导出任务已停止' }
|
||||||
}
|
}
|
||||||
|
if (this.isPauseError(e)) {
|
||||||
|
return { success: false, error: '导出任务已暂停' }
|
||||||
|
}
|
||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9908,7 +9993,7 @@ class ExportService {
|
|||||||
const reservedOutputPaths = new Set<string>()
|
const reservedOutputPaths = new Set<string>()
|
||||||
const ensureTaskDir = async (dirPath: string) => {
|
const ensureTaskDir = async (dirPath: string) => {
|
||||||
if (createdTaskDirs.has(dirPath)) return
|
if (createdTaskDirs.has(dirPath)) return
|
||||||
await fs.promises.mkdir(dirPath, { recursive: true })
|
await this.ensureExportDir(dirPath, control)
|
||||||
createdTaskDirs.add(dirPath)
|
createdTaskDirs.add(dirPath)
|
||||||
}
|
}
|
||||||
await ensureTaskDir(exportBaseDir)
|
await ensureTaskDir(exportBaseDir)
|
||||||
@@ -10085,7 +10170,7 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const runOne = async (sessionId: string): Promise<'done' | 'stopped'> => {
|
const runOne = async (sessionId: string): Promise<'done' | 'stopped' | 'paused'> => {
|
||||||
try {
|
try {
|
||||||
this.throwIfStopRequested(control)
|
this.throwIfStopRequested(control)
|
||||||
const sessionInfo = await this.getContactInfo(sessionId)
|
const sessionInfo = await this.getContactInfo(sessionId)
|
||||||
@@ -10234,6 +10319,10 @@ class ExportService {
|
|||||||
activeSessionRatios.delete(sessionId)
|
activeSessionRatios.delete(sessionId)
|
||||||
return 'stopped'
|
return 'stopped'
|
||||||
}
|
}
|
||||||
|
if (!result.success && this.isPauseError(result.error)) {
|
||||||
|
activeSessionRatios.delete(sessionId)
|
||||||
|
return 'paused'
|
||||||
|
}
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
successCount++
|
successCount++
|
||||||
@@ -10269,6 +10358,10 @@ class ExportService {
|
|||||||
activeSessionRatios.delete(sessionId)
|
activeSessionRatios.delete(sessionId)
|
||||||
return 'stopped'
|
return 'stopped'
|
||||||
}
|
}
|
||||||
|
if (this.isPauseError(error)) {
|
||||||
|
activeSessionRatios.delete(sessionId)
|
||||||
|
return 'paused'
|
||||||
|
}
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10294,6 +10387,11 @@ class ExportService {
|
|||||||
queue.unshift(sessionId)
|
queue.unshift(sessionId)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
if (runState === 'paused') {
|
||||||
|
pauseRequested = true
|
||||||
|
queue.unshift(sessionId)
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const workers = Array.from({ length: Math.min(sessionConcurrency, queue.length) }, async () => {
|
const workers = Array.from({ length: Math.min(sessionConcurrency, queue.length) }, async () => {
|
||||||
@@ -10315,6 +10413,11 @@ class ExportService {
|
|||||||
queue.unshift(sessionId)
|
queue.unshift(sessionId)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
if (runState === 'paused') {
|
||||||
|
pauseRequested = true
|
||||||
|
queue.unshift(sessionId)
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
await Promise.all(workers)
|
await Promise.all(workers)
|
||||||
@@ -10333,7 +10436,7 @@ class ExportService {
|
|||||||
sessionOutputPaths
|
sessionOutputPaths
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (pauseRequested && pendingSessionIds.length > 0) {
|
if (pauseRequested) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
successCount,
|
successCount,
|
||||||
|
|||||||
210
electron/services/exportTaskControlService.ts
Normal file
210
electron/services/exportTaskControlService.ts
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
import * as path from 'path'
|
||||||
|
import { rm, rmdir } from 'fs/promises'
|
||||||
|
|
||||||
|
export type ExportTaskControlState = 'running' | 'pause_requested' | 'cancel_requested'
|
||||||
|
|
||||||
|
export interface ExportTaskControlHooks {
|
||||||
|
shouldPause: () => boolean
|
||||||
|
shouldStop: () => boolean
|
||||||
|
recordCreatedFile: (filePath: string) => void
|
||||||
|
recordCreatedDir: (dirPath: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExportTaskManifest {
|
||||||
|
outputDir: string
|
||||||
|
files: Set<string>
|
||||||
|
dirs: Set<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExportTaskControlRecord {
|
||||||
|
state: ExportTaskControlState
|
||||||
|
manifest: ExportTaskManifest
|
||||||
|
createdAt: number
|
||||||
|
updatedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportTaskCleanupResult {
|
||||||
|
success: boolean
|
||||||
|
filesDeleted: number
|
||||||
|
dirsDeleted: number
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
class ExportTaskControlService {
|
||||||
|
private tasks = new Map<string, ExportTaskControlRecord>()
|
||||||
|
|
||||||
|
createControl(taskId: string, outputDir: string): ExportTaskControlHooks {
|
||||||
|
this.registerTask(taskId, outputDir)
|
||||||
|
return {
|
||||||
|
shouldPause: () => this.getState(taskId) === 'pause_requested',
|
||||||
|
shouldStop: () => this.getState(taskId) === 'cancel_requested',
|
||||||
|
recordCreatedFile: (filePath: string) => this.recordCreatedFile(taskId, filePath),
|
||||||
|
recordCreatedDir: (dirPath: string) => this.recordCreatedDir(taskId, dirPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerTask(taskId: string, outputDir: string): void {
|
||||||
|
const normalizedTaskId = this.normalizeTaskId(taskId)
|
||||||
|
if (!normalizedTaskId) return
|
||||||
|
|
||||||
|
const normalizedOutputDir = path.resolve(String(outputDir || '').trim() || '.')
|
||||||
|
const existing = this.tasks.get(normalizedTaskId)
|
||||||
|
if (existing) {
|
||||||
|
existing.state = 'running'
|
||||||
|
existing.updatedAt = Date.now()
|
||||||
|
if (!existing.manifest.outputDir) {
|
||||||
|
existing.manifest.outputDir = normalizedOutputDir
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.tasks.set(normalizedTaskId, {
|
||||||
|
state: 'running',
|
||||||
|
manifest: {
|
||||||
|
outputDir: normalizedOutputDir,
|
||||||
|
files: new Set<string>(),
|
||||||
|
dirs: new Set<string>()
|
||||||
|
},
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pauseTask(taskId: string): boolean {
|
||||||
|
return this.setState(taskId, 'pause_requested')
|
||||||
|
}
|
||||||
|
|
||||||
|
resumeTask(taskId: string): boolean {
|
||||||
|
return this.setState(taskId, 'running')
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelTask(taskId: string): boolean {
|
||||||
|
return this.setState(taskId, 'cancel_requested')
|
||||||
|
}
|
||||||
|
|
||||||
|
getState(taskId: string): ExportTaskControlState | null {
|
||||||
|
const normalizedTaskId = this.normalizeTaskId(taskId)
|
||||||
|
if (!normalizedTaskId) return null
|
||||||
|
return this.tasks.get(normalizedTaskId)?.state || null
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseTask(taskId: string): void {
|
||||||
|
const normalizedTaskId = this.normalizeTaskId(taskId)
|
||||||
|
if (!normalizedTaskId) return
|
||||||
|
this.tasks.delete(normalizedTaskId)
|
||||||
|
}
|
||||||
|
|
||||||
|
recordCreatedFile(taskId: string, filePath: string): void {
|
||||||
|
const task = this.getTaskForManifestWrite(taskId, filePath)
|
||||||
|
if (!task) return
|
||||||
|
task.manifest.files.add(path.resolve(filePath))
|
||||||
|
task.updatedAt = Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
recordCreatedDir(taskId: string, dirPath: string): void {
|
||||||
|
const task = this.getTaskForManifestWrite(taskId, dirPath)
|
||||||
|
if (!task) return
|
||||||
|
task.manifest.dirs.add(path.resolve(dirPath))
|
||||||
|
task.updatedAt = Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
async cleanupTask(taskId: string): Promise<ExportTaskCleanupResult> {
|
||||||
|
const normalizedTaskId = this.normalizeTaskId(taskId)
|
||||||
|
const task = normalizedTaskId ? this.tasks.get(normalizedTaskId) : undefined
|
||||||
|
if (!task) {
|
||||||
|
return { success: true, filesDeleted: 0, dirsDeleted: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputDir = task.manifest.outputDir
|
||||||
|
let filesDeleted = 0
|
||||||
|
let dirsDeleted = 0
|
||||||
|
const errors: string[] = []
|
||||||
|
|
||||||
|
const files = Array.from(task.manifest.files)
|
||||||
|
.filter(filePath => this.isInsideOutputDir(filePath, outputDir))
|
||||||
|
.sort((a, b) => b.length - a.length)
|
||||||
|
|
||||||
|
for (const filePath of files) {
|
||||||
|
try {
|
||||||
|
await rm(filePath, { force: true, recursive: false })
|
||||||
|
filesDeleted++
|
||||||
|
} catch (error) {
|
||||||
|
const code = (error as NodeJS.ErrnoException | undefined)?.code
|
||||||
|
if (code !== 'ENOENT') {
|
||||||
|
errors.push(`${filePath}: ${error instanceof Error ? error.message : String(error)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dirs = Array.from(task.manifest.dirs)
|
||||||
|
.filter(dirPath => this.isInsideOutputDir(dirPath, outputDir) || this.isSamePath(dirPath, outputDir))
|
||||||
|
.sort((a, b) => b.length - a.length)
|
||||||
|
|
||||||
|
for (const dirPath of dirs) {
|
||||||
|
try {
|
||||||
|
await rmdir(dirPath)
|
||||||
|
dirsDeleted++
|
||||||
|
} catch (error) {
|
||||||
|
const code = (error as NodeJS.ErrnoException | undefined)?.code
|
||||||
|
if (code !== 'ENOENT' && code !== 'ENOTEMPTY' && code !== 'EEXIST') {
|
||||||
|
errors.push(`${dirPath}: ${error instanceof Error ? error.message : String(error)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length === 0) {
|
||||||
|
this.releaseTask(normalizedTaskId)
|
||||||
|
return { success: true, filesDeleted, dirsDeleted }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
filesDeleted,
|
||||||
|
dirsDeleted,
|
||||||
|
error: errors.slice(0, 3).join('; ')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setState(taskId: string, state: ExportTaskControlState): boolean {
|
||||||
|
const normalizedTaskId = this.normalizeTaskId(taskId)
|
||||||
|
if (!normalizedTaskId) return false
|
||||||
|
const task = this.tasks.get(normalizedTaskId)
|
||||||
|
if (!task) return false
|
||||||
|
task.state = state
|
||||||
|
task.updatedAt = Date.now()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTaskForManifestWrite(taskId: string, targetPath: string): ExportTaskControlRecord | null {
|
||||||
|
const normalizedTaskId = this.normalizeTaskId(taskId)
|
||||||
|
if (!normalizedTaskId) return null
|
||||||
|
const task = this.tasks.get(normalizedTaskId)
|
||||||
|
if (!task) return null
|
||||||
|
if (!this.isInsideOutputDir(targetPath, task.manifest.outputDir) && !this.isSamePath(targetPath, task.manifest.outputDir)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return task
|
||||||
|
}
|
||||||
|
|
||||||
|
private isInsideOutputDir(targetPath: string, outputDir: string): boolean {
|
||||||
|
const resolvedTarget = path.resolve(targetPath)
|
||||||
|
const resolvedOutputDir = path.resolve(outputDir)
|
||||||
|
const relativePath = path.relative(resolvedOutputDir, resolvedTarget)
|
||||||
|
return Boolean(relativePath) && !relativePath.startsWith('..') && !path.isAbsolute(relativePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
private isSamePath(left: string, right: string): boolean {
|
||||||
|
const resolvedLeft = path.resolve(left)
|
||||||
|
const resolvedRight = path.resolve(right)
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
return resolvedLeft.toLowerCase() === resolvedRight.toLowerCase()
|
||||||
|
}
|
||||||
|
return resolvedLeft === resolvedRight
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeTaskId(taskId: string): string {
|
||||||
|
return String(taskId || '').trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const exportTaskControlService = new ExportTaskControlService()
|
||||||
@@ -1340,6 +1340,8 @@ class SnsService {
|
|||||||
}, progressCallback?: (progress: { current: number; total: number; status: string }) => void, control?: {
|
}, progressCallback?: (progress: { current: number; total: number; status: string }) => void, control?: {
|
||||||
shouldPause?: () => boolean
|
shouldPause?: () => boolean
|
||||||
shouldStop?: () => boolean
|
shouldStop?: () => boolean
|
||||||
|
recordCreatedFile?: (filePath: string) => void
|
||||||
|
recordCreatedDir?: (dirPath: string) => void
|
||||||
}): Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; paused?: boolean; stopped?: boolean; error?: string }> {
|
}): Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; paused?: boolean; stopped?: boolean; error?: string }> {
|
||||||
const { outputDir, format, usernames, keyword, startTime, endTime } = options
|
const { outputDir, format, usernames, keyword, startTime, endTime } = options
|
||||||
const hasExplicitMediaSelection =
|
const hasExplicitMediaSelection =
|
||||||
@@ -1361,6 +1363,18 @@ class SnsService {
|
|||||||
if (control?.shouldPause?.()) return 'paused'
|
if (control?.shouldPause?.()) return 'paused'
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
const ensureExportDir = (dirPath: string) => {
|
||||||
|
const existed = existsSync(dirPath)
|
||||||
|
if (!existed) {
|
||||||
|
mkdirSync(dirPath, { recursive: true })
|
||||||
|
control?.recordCreatedDir?.(dirPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const recordCreatedFileBeforeWrite = (filePath: string) => {
|
||||||
|
if (!existsSync(filePath)) {
|
||||||
|
control?.recordCreatedFile?.(filePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
const buildInterruptedResult = (state: 'paused' | 'stopped', postCount: number, mediaCount: number) => (
|
const buildInterruptedResult = (state: 'paused' | 'stopped', postCount: number, mediaCount: number) => (
|
||||||
state === 'stopped'
|
state === 'stopped'
|
||||||
? { success: true, stopped: true, filePath: '', postCount, mediaCount }
|
? { success: true, stopped: true, filePath: '', postCount, mediaCount }
|
||||||
@@ -1369,9 +1383,7 @@ class SnsService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// 确保输出目录存在
|
// 确保输出目录存在
|
||||||
if (!existsSync(outputDir)) {
|
ensureExportDir(outputDir)
|
||||||
mkdirSync(outputDir, { recursive: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. 分页加载全部帖子
|
// 1. 分页加载全部帖子
|
||||||
const allPosts: SnsPost[] = []
|
const allPosts: SnsPost[] = []
|
||||||
@@ -1414,9 +1426,7 @@ class SnsService {
|
|||||||
const mediaDir = join(outputDir, 'media')
|
const mediaDir = join(outputDir, 'media')
|
||||||
|
|
||||||
if (shouldExportMedia) {
|
if (shouldExportMedia) {
|
||||||
if (!existsSync(mediaDir)) {
|
ensureExportDir(mediaDir)
|
||||||
mkdirSync(mediaDir, { recursive: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 收集所有媒体下载任务
|
// 收集所有媒体下载任务
|
||||||
const mediaTasks: Array<{
|
const mediaTasks: Array<{
|
||||||
@@ -1485,6 +1495,7 @@ class SnsService {
|
|||||||
} else {
|
} else {
|
||||||
const result = await this.fetchAndDecryptImage(task.url, task.key)
|
const result = await this.fetchAndDecryptImage(task.url, task.key)
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
|
recordCreatedFileBeforeWrite(filePath)
|
||||||
await writeFile(filePath, result.data)
|
await writeFile(filePath, result.data)
|
||||||
if (task.kind === 'livephoto') {
|
if (task.kind === 'livephoto') {
|
||||||
if (media.livePhoto) (media.livePhoto as any).localPath = `media/${fileName}`
|
if (media.livePhoto) (media.livePhoto as any).localPath = `media/${fileName}`
|
||||||
@@ -1494,6 +1505,7 @@ class SnsService {
|
|||||||
mediaCount++
|
mediaCount++
|
||||||
} else if (result.success && result.cachePath) {
|
} else if (result.success && result.cachePath) {
|
||||||
const cachedData = await readFile(result.cachePath)
|
const cachedData = await readFile(result.cachePath)
|
||||||
|
recordCreatedFileBeforeWrite(filePath)
|
||||||
await writeFile(filePath, cachedData)
|
await writeFile(filePath, cachedData)
|
||||||
if (task.kind === 'livephoto') {
|
if (task.kind === 'livephoto') {
|
||||||
if (media.livePhoto) (media.livePhoto as any).localPath = `media/${fileName}`
|
if (media.livePhoto) (media.livePhoto as any).localPath = `media/${fileName}`
|
||||||
@@ -1531,7 +1543,7 @@ class SnsService {
|
|||||||
// 2.5 下载头像
|
// 2.5 下载头像
|
||||||
const avatarMap = new Map<string, string>()
|
const avatarMap = new Map<string, string>()
|
||||||
if (format === 'html') {
|
if (format === 'html') {
|
||||||
if (!existsSync(mediaDir)) mkdirSync(mediaDir, { recursive: true })
|
ensureExportDir(mediaDir)
|
||||||
const uniqueUsers = [...new Map(allPosts.filter(p => p.avatarUrl).map(p => [p.username, p])).values()]
|
const uniqueUsers = [...new Map(allPosts.filter(p => p.avatarUrl).map(p => [p.username, p])).values()]
|
||||||
let avatarDone = 0
|
let avatarDone = 0
|
||||||
const avatarQueue = [...uniqueUsers]
|
const avatarQueue = [...uniqueUsers]
|
||||||
@@ -1548,6 +1560,7 @@ class SnsService {
|
|||||||
} else {
|
} else {
|
||||||
const result = await this.fetchAndDecryptImage(post.avatarUrl!)
|
const result = await this.fetchAndDecryptImage(post.avatarUrl!)
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
|
recordCreatedFileBeforeWrite(filePath)
|
||||||
await writeFile(filePath, result.data)
|
await writeFile(filePath, result.data)
|
||||||
avatarMap.set(post.username, `media/${fileName}`)
|
avatarMap.set(post.username, `media/${fileName}`)
|
||||||
}
|
}
|
||||||
@@ -1602,6 +1615,7 @@ class SnsService {
|
|||||||
linkUrl: (p as any).linkUrl
|
linkUrl: (p as any).linkUrl
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
recordCreatedFileBeforeWrite(outputFilePath)
|
||||||
await writeFile(outputFilePath, JSON.stringify(exportData, null, 2), 'utf-8')
|
await writeFile(outputFilePath, JSON.stringify(exportData, null, 2), 'utf-8')
|
||||||
} else if (format === 'arkmejson') {
|
} else if (format === 'arkmejson') {
|
||||||
outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.json`)
|
outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.json`)
|
||||||
@@ -1689,11 +1703,13 @@ class SnsService {
|
|||||||
},
|
},
|
||||||
posts
|
posts
|
||||||
}
|
}
|
||||||
|
recordCreatedFileBeforeWrite(outputFilePath)
|
||||||
await writeFile(outputFilePath, JSON.stringify(exportData, null, 2), 'utf-8')
|
await writeFile(outputFilePath, JSON.stringify(exportData, null, 2), 'utf-8')
|
||||||
} else {
|
} else {
|
||||||
// HTML 格式
|
// HTML 格式
|
||||||
outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.html`)
|
outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.html`)
|
||||||
const html = this.generateHtml(allPosts, { usernames, keyword }, avatarMap)
|
const html = this.generateHtml(allPosts, { usernames, keyword }, avatarMap)
|
||||||
|
recordCreatedFileBeforeWrite(outputFilePath)
|
||||||
await writeFile(outputFilePath, html, 'utf-8')
|
await writeFile(outputFilePath, html, 'utf-8')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ import {
|
|||||||
import './ExportPage.scss'
|
import './ExportPage.scss'
|
||||||
|
|
||||||
type ConversationTab = 'private' | 'group' | 'official' | 'former_friend'
|
type ConversationTab = 'private' | 'group' | 'official' | 'former_friend'
|
||||||
type TaskStatus = 'queued' | 'running' | 'success' | 'error'
|
type TaskStatus = 'queued' | 'running' | 'pause_requested' | 'paused' | 'cancel_requested' | 'success' | 'error'
|
||||||
type TaskScope = 'single' | 'multi' | 'content' | 'sns'
|
type TaskScope = 'single' | 'multi' | 'content' | 'sns'
|
||||||
type ContentType = 'text' | 'voice' | 'image' | 'video' | 'emoji' | 'file'
|
type ContentType = 'text' | 'voice' | 'image' | 'video' | 'emoji' | 'file'
|
||||||
type ContentCardType = ContentType | 'sns'
|
type ContentCardType = ContentType | 'sns'
|
||||||
@@ -578,10 +578,27 @@ const formatDurationMs = (ms: number): string => {
|
|||||||
const getTaskStatusLabel = (task: ExportTask): string => {
|
const getTaskStatusLabel = (task: ExportTask): string => {
|
||||||
if (task.status === 'queued') return '排队中'
|
if (task.status === 'queued') return '排队中'
|
||||||
if (task.status === 'running') return '进行中'
|
if (task.status === 'running') return '进行中'
|
||||||
|
if (task.status === 'pause_requested') return '暂停中'
|
||||||
|
if (task.status === 'paused') return '已暂停'
|
||||||
|
if (task.status === 'cancel_requested') return '取消中'
|
||||||
if (task.status === 'success') return '已完成'
|
if (task.status === 'success') return '已完成'
|
||||||
return '失败'
|
return '失败'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolveExportTaskCardClass = (status: TaskStatus): 'queued' | 'running' | 'paused' | 'stopped' | 'success' | 'error' => {
|
||||||
|
if (status === 'pause_requested' || status === 'paused') return 'paused'
|
||||||
|
if (status === 'cancel_requested') return 'stopped'
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
const isExportTaskActiveStatus = (status: TaskStatus): boolean => (
|
||||||
|
status === 'queued' ||
|
||||||
|
status === 'running' ||
|
||||||
|
status === 'pause_requested' ||
|
||||||
|
status === 'paused' ||
|
||||||
|
status === 'cancel_requested'
|
||||||
|
)
|
||||||
|
|
||||||
const resolveBackgroundTaskCardClass = (status: BackgroundTaskRecord['status']): 'running' | 'paused' | 'stopped' | 'success' | 'error' => {
|
const resolveBackgroundTaskCardClass = (status: BackgroundTaskRecord['status']): 'running' | 'paused' | 'stopped' | 'success' | 'error' => {
|
||||||
if (status === 'running') return 'running'
|
if (status === 'running') return 'running'
|
||||||
if (status === 'pause_requested' || status === 'paused') return 'paused'
|
if (status === 'pause_requested' || status === 'paused') return 'paused'
|
||||||
@@ -1809,6 +1826,9 @@ interface TaskCenterModalProps {
|
|||||||
nowTick: number
|
nowTick: number
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onTogglePerfTask: (taskId: string) => void
|
onTogglePerfTask: (taskId: string) => void
|
||||||
|
onPauseExportTask: (taskId: string) => void
|
||||||
|
onResumeExportTask: (taskId: string) => void
|
||||||
|
onCancelExportTask: (taskId: string) => void
|
||||||
onPauseBackgroundTask: (taskId: string) => void
|
onPauseBackgroundTask: (taskId: string) => void
|
||||||
onResumeBackgroundTask: (taskId: string) => void
|
onResumeBackgroundTask: (taskId: string) => void
|
||||||
onCancelBackgroundTask: (taskId: string) => void
|
onCancelBackgroundTask: (taskId: string) => void
|
||||||
@@ -1824,6 +1844,9 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
|||||||
nowTick,
|
nowTick,
|
||||||
onClose,
|
onClose,
|
||||||
onTogglePerfTask,
|
onTogglePerfTask,
|
||||||
|
onPauseExportTask,
|
||||||
|
onResumeExportTask,
|
||||||
|
onCancelExportTask,
|
||||||
onPauseBackgroundTask,
|
onPauseBackgroundTask,
|
||||||
onResumeBackgroundTask,
|
onResumeBackgroundTask,
|
||||||
onCancelBackgroundTask
|
onCancelBackgroundTask
|
||||||
@@ -1954,15 +1977,31 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
|||||||
: `图片耗时 ${formatDurationMs(imageTimingElapsedMs)}`
|
: `图片耗时 ${formatDurationMs(imageTimingElapsedMs)}`
|
||||||
)
|
)
|
||||||
: ''
|
: ''
|
||||||
|
const taskCardClass = resolveExportTaskCardClass(task.status)
|
||||||
|
const canShowProgress = (
|
||||||
|
task.status === 'running' ||
|
||||||
|
task.status === 'pause_requested' ||
|
||||||
|
task.status === 'paused' ||
|
||||||
|
task.status === 'cancel_requested'
|
||||||
|
)
|
||||||
|
const canPause = task.status === 'running'
|
||||||
|
const canResume = task.status === 'paused' || task.status === 'pause_requested'
|
||||||
|
const canCancel = (
|
||||||
|
task.status === 'queued' ||
|
||||||
|
task.status === 'running' ||
|
||||||
|
task.status === 'pause_requested' ||
|
||||||
|
task.status === 'paused' ||
|
||||||
|
task.status === 'cancel_requested'
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
<div key={task.id} className={`task-card ${task.status}`}>
|
<div key={task.id} className={`task-card ${taskCardClass}`}>
|
||||||
<div className="task-main">
|
<div className="task-main">
|
||||||
<div className="task-title">{task.title}</div>
|
<div className="task-title">{task.title}</div>
|
||||||
<div className="task-meta">
|
<div className="task-meta">
|
||||||
<span className={`task-status ${task.status}`}>{getTaskStatusLabel(task)}</span>
|
<span className={`task-status ${taskCardClass}`}>{getTaskStatusLabel(task)}</span>
|
||||||
<span>{new Date(task.createdAt).toLocaleString('zh-CN')}</span>
|
<span>{new Date(task.createdAt).toLocaleString('zh-CN')}</span>
|
||||||
</div>
|
</div>
|
||||||
{task.status === 'running' && (
|
{canShowProgress && (
|
||||||
<>
|
<>
|
||||||
<div className="task-progress-bar">
|
<div className="task-progress-bar">
|
||||||
<div
|
<div
|
||||||
@@ -2050,6 +2089,34 @@ const TaskCenterModal = memo(function TaskCenterModal({
|
|||||||
{isPerfExpanded ? '收起详情' : '性能详情'}
|
{isPerfExpanded ? '收起详情' : '性能详情'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{canPause && (
|
||||||
|
<button
|
||||||
|
className="task-action-btn"
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPauseExportTask(task.id)}
|
||||||
|
>
|
||||||
|
<Pause size={14} /> 暂停
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canResume && (
|
||||||
|
<button
|
||||||
|
className="task-action-btn primary"
|
||||||
|
type="button"
|
||||||
|
onClick={() => onResumeExportTask(task.id)}
|
||||||
|
>
|
||||||
|
<Play size={14} /> 继续
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canCancel && (
|
||||||
|
<button
|
||||||
|
className="task-action-btn danger"
|
||||||
|
type="button"
|
||||||
|
onClick={() => onCancelExportTask(task.id)}
|
||||||
|
disabled={task.status === 'cancel_requested'}
|
||||||
|
>
|
||||||
|
<Square size={14} /> {task.status === 'cancel_requested' ? '取消中' : '取消'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className="task-action-btn"
|
className="task-action-btn"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -5586,7 +5653,7 @@ function ExportPage() {
|
|||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const currentSessionId = String(payload.currentSessionId || '').trim()
|
const currentSessionId = String(payload.currentSessionId || '').trim()
|
||||||
updateTask(next.id, task => {
|
updateTask(next.id, task => {
|
||||||
if (task.status !== 'running') return task
|
if (task.status !== 'running' && task.status !== 'pause_requested' && task.status !== 'cancel_requested') return task
|
||||||
const performance = applyProgressToTaskPerformance(task, payload, now)
|
const performance = applyProgressToTaskPerformance(task, payload, now)
|
||||||
const settledSessionIds = task.settledSessionIds || []
|
const settledSessionIds = task.settledSessionIds || []
|
||||||
const nextSettledSessionIds = (
|
const nextSettledSessionIds = (
|
||||||
@@ -5740,7 +5807,8 @@ function ExportPage() {
|
|||||||
exportLivePhotos: snsOptions.exportLivePhotos,
|
exportLivePhotos: snsOptions.exportLivePhotos,
|
||||||
exportVideos: snsOptions.exportVideos,
|
exportVideos: snsOptions.exportVideos,
|
||||||
startTime: snsOptions.startTime,
|
startTime: snsOptions.startTime,
|
||||||
endTime: snsOptions.endTime
|
endTime: snsOptions.endTime,
|
||||||
|
taskId: next.id
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@@ -5751,6 +5819,19 @@ function ExportPage() {
|
|||||||
error: result.error || '朋友圈导出失败',
|
error: result.error || '朋友圈导出失败',
|
||||||
performance: finalizeTaskPerformance(task, Date.now())
|
performance: finalizeTaskPerformance(task, Date.now())
|
||||||
}))
|
}))
|
||||||
|
} else if (result.stopped) {
|
||||||
|
setTasks(prev => prev.filter(task => task.id !== next.id))
|
||||||
|
} else if (result.paused) {
|
||||||
|
updateTask(next.id, task => ({
|
||||||
|
...task,
|
||||||
|
status: 'paused',
|
||||||
|
progress: {
|
||||||
|
...task.progress,
|
||||||
|
phaseLabel: '已暂停,可继续或取消',
|
||||||
|
current: Math.max(task.progress.current, result.postCount || 0),
|
||||||
|
total: Math.max(task.progress.total, result.postCount || 0)
|
||||||
|
}
|
||||||
|
}))
|
||||||
} else {
|
} else {
|
||||||
const doneAt = Date.now()
|
const doneAt = Date.now()
|
||||||
const exportedPosts = Math.max(0, result.postCount || 0)
|
const exportedPosts = Math.max(0, result.postCount || 0)
|
||||||
@@ -5782,7 +5863,8 @@ function ExportPage() {
|
|||||||
const result = await window.electronAPI.export.exportSessions(
|
const result = await window.electronAPI.export.exportSessions(
|
||||||
next.payload.sessionIds,
|
next.payload.sessionIds,
|
||||||
next.payload.outputDir,
|
next.payload.outputDir,
|
||||||
next.payload.options
|
next.payload.options,
|
||||||
|
{ taskId: next.id }
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@@ -5793,6 +5875,33 @@ function ExportPage() {
|
|||||||
error: result.error || '导出失败',
|
error: result.error || '导出失败',
|
||||||
performance: finalizeTaskPerformance(task, Date.now())
|
performance: finalizeTaskPerformance(task, Date.now())
|
||||||
}))
|
}))
|
||||||
|
} else if (result.stopped) {
|
||||||
|
setTasks(prev => prev.filter(task => task.id !== next.id))
|
||||||
|
} else if (result.paused) {
|
||||||
|
const pendingSessionIds = Array.isArray(result.pendingSessionIds)
|
||||||
|
? result.pendingSessionIds
|
||||||
|
: []
|
||||||
|
updateTask(next.id, task => ({
|
||||||
|
...task,
|
||||||
|
status: 'paused',
|
||||||
|
payload: {
|
||||||
|
...task.payload,
|
||||||
|
sessionIds: pendingSessionIds.length > 0 ? pendingSessionIds : task.payload.sessionIds
|
||||||
|
},
|
||||||
|
settledSessionIds: Array.isArray(result.successSessionIds)
|
||||||
|
? Array.from(new Set([...(task.settledSessionIds || []), ...result.successSessionIds]))
|
||||||
|
: task.settledSessionIds,
|
||||||
|
sessionOutputPaths: {
|
||||||
|
...(task.sessionOutputPaths || {}),
|
||||||
|
...((result.sessionOutputPaths && typeof result.sessionOutputPaths === 'object')
|
||||||
|
? result.sessionOutputPaths
|
||||||
|
: {})
|
||||||
|
},
|
||||||
|
progress: {
|
||||||
|
...task.progress,
|
||||||
|
phaseLabel: '已暂停,可继续或取消'
|
||||||
|
}
|
||||||
|
}))
|
||||||
} else {
|
} else {
|
||||||
const doneAt = Date.now()
|
const doneAt = Date.now()
|
||||||
const contentTypes = next.payload.contentType
|
const contentTypes = next.payload.contentType
|
||||||
@@ -5913,7 +6022,13 @@ function ExportPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasConflict = tasksRef.current.some((item) => {
|
const hasConflict = tasksRef.current.some((item) => {
|
||||||
if (item.status !== 'running' && item.status !== 'queued') return false
|
if (
|
||||||
|
item.status !== 'running' &&
|
||||||
|
item.status !== 'queued' &&
|
||||||
|
item.status !== 'pause_requested' &&
|
||||||
|
item.status !== 'paused' &&
|
||||||
|
item.status !== 'cancel_requested'
|
||||||
|
) return false
|
||||||
return item.payload.automationTaskId === task.id
|
return item.payload.automationTaskId === task.id
|
||||||
})
|
})
|
||||||
if (hasConflict) {
|
if (hasConflict) {
|
||||||
@@ -6200,7 +6315,7 @@ function ExportPage() {
|
|||||||
const runningSessionIds = useMemo(() => {
|
const runningSessionIds = useMemo(() => {
|
||||||
const set = new Set<string>()
|
const set = new Set<string>()
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
if (task.status !== 'running') continue
|
if (task.status !== 'running' && task.status !== 'pause_requested' && task.status !== 'cancel_requested') continue
|
||||||
const settled = new Set(task.settledSessionIds || [])
|
const settled = new Set(task.settledSessionIds || [])
|
||||||
for (const id of task.payload.sessionIds) {
|
for (const id of task.payload.sessionIds) {
|
||||||
if (settled.has(id)) continue
|
if (settled.has(id)) continue
|
||||||
@@ -6213,7 +6328,7 @@ function ExportPage() {
|
|||||||
const queuedSessionIds = useMemo(() => {
|
const queuedSessionIds = useMemo(() => {
|
||||||
const set = new Set<string>()
|
const set = new Set<string>()
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
if (task.status !== 'queued') continue
|
if (task.status !== 'queued' && task.status !== 'paused') continue
|
||||||
for (const id of task.payload.sessionIds) {
|
for (const id of task.payload.sessionIds) {
|
||||||
set.add(id)
|
set.add(id)
|
||||||
}
|
}
|
||||||
@@ -6224,7 +6339,7 @@ function ExportPage() {
|
|||||||
const inProgressSessionIds = useMemo(() => {
|
const inProgressSessionIds = useMemo(() => {
|
||||||
const set = new Set<string>()
|
const set = new Set<string>()
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
if (task.status !== 'running' && task.status !== 'queued') continue
|
if (!isExportTaskActiveStatus(task.status)) continue
|
||||||
for (const id of task.payload.sessionIds) {
|
for (const id of task.payload.sessionIds) {
|
||||||
set.add(id)
|
set.add(id)
|
||||||
}
|
}
|
||||||
@@ -6232,7 +6347,7 @@ function ExportPage() {
|
|||||||
return Array.from(set).sort()
|
return Array.from(set).sort()
|
||||||
}, [tasks])
|
}, [tasks])
|
||||||
const activeTaskCount = useMemo(
|
const activeTaskCount = useMemo(
|
||||||
() => tasks.filter(task => task.status === 'running' || task.status === 'queued').length,
|
() => tasks.filter(task => isExportTaskActiveStatus(task.status)).length,
|
||||||
[tasks]
|
[tasks]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -6247,7 +6362,7 @@ function ExportPage() {
|
|||||||
if (previousStatus === task.status) continue
|
if (previousStatus === task.status) continue
|
||||||
|
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
if (task.status === 'running') {
|
if (task.status === 'running' || task.status === 'pause_requested' || task.status === 'paused' || task.status === 'cancel_requested') {
|
||||||
patchAutomationTask(automationTaskId, (current) => ({
|
patchAutomationTask(automationTaskId, (current) => ({
|
||||||
...current,
|
...current,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
@@ -6338,7 +6453,13 @@ function ExportPage() {
|
|||||||
if (task.runState?.lastScheduleKey === scheduleKey) continue
|
if (task.runState?.lastScheduleKey === scheduleKey) continue
|
||||||
|
|
||||||
const hasConflict = tasksRef.current.some((item) => {
|
const hasConflict = tasksRef.current.some((item) => {
|
||||||
if (item.status !== 'running' && item.status !== 'queued') return false
|
if (
|
||||||
|
item.status !== 'running' &&
|
||||||
|
item.status !== 'queued' &&
|
||||||
|
item.status !== 'pause_requested' &&
|
||||||
|
item.status !== 'paused' &&
|
||||||
|
item.status !== 'cancel_requested'
|
||||||
|
) return false
|
||||||
return item.payload.automationTaskId === task.id
|
return item.payload.automationTaskId === task.id
|
||||||
})
|
})
|
||||||
if (hasConflict) {
|
if (hasConflict) {
|
||||||
@@ -6448,7 +6569,7 @@ function ExportPage() {
|
|||||||
const runningCardTypes = useMemo(() => {
|
const runningCardTypes = useMemo(() => {
|
||||||
const set = new Set<ContentCardType>()
|
const set = new Set<ContentCardType>()
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
if (task.status !== 'running') continue
|
if (!isExportTaskActiveStatus(task.status)) continue
|
||||||
if (task.payload.scope === 'sns') {
|
if (task.payload.scope === 'sns') {
|
||||||
set.add('sns')
|
set.add('sns')
|
||||||
continue
|
continue
|
||||||
@@ -7891,7 +8012,12 @@ function ExportPage() {
|
|||||||
)
|
)
|
||||||
const isTabCountComputing = isSharedTabCountsLoading && !isSharedTabCountsReady
|
const isTabCountComputing = isSharedTabCountsLoading && !isSharedTabCountsReady
|
||||||
const isSnsCardStatsLoading = !hasSeededSnsStats
|
const isSnsCardStatsLoading = !hasSeededSnsStats
|
||||||
const taskRunningCount = tasks.filter(task => task.status === 'running').length
|
const taskRunningCount = tasks.filter(task => (
|
||||||
|
task.status === 'running' ||
|
||||||
|
task.status === 'pause_requested' ||
|
||||||
|
task.status === 'paused' ||
|
||||||
|
task.status === 'cancel_requested'
|
||||||
|
)).length
|
||||||
const taskQueuedCount = tasks.filter(task => task.status === 'queued').length
|
const taskQueuedCount = tasks.filter(task => task.status === 'queued').length
|
||||||
const chatBackgroundTasks = useMemo(() => (
|
const chatBackgroundTasks = useMemo(() => (
|
||||||
backgroundTasks.filter(task => task.sourcePage === 'chat')
|
backgroundTasks.filter(task => task.sourcePage === 'chat')
|
||||||
@@ -8105,6 +8231,112 @@ function ExportPage() {
|
|||||||
const toggleTaskPerfDetail = useCallback((taskId: string) => {
|
const toggleTaskPerfDetail = useCallback((taskId: string) => {
|
||||||
setExpandedPerfTaskId(prev => (prev === taskId ? null : taskId))
|
setExpandedPerfTaskId(prev => (prev === taskId ? null : taskId))
|
||||||
}, [])
|
}, [])
|
||||||
|
const handlePauseExportTask = useCallback((taskId: string) => {
|
||||||
|
const task = tasksRef.current.find(item => item.id === taskId)
|
||||||
|
if (!task || task.status !== 'running') return
|
||||||
|
updateTask(taskId, current => ({
|
||||||
|
...current,
|
||||||
|
status: 'pause_requested',
|
||||||
|
progress: {
|
||||||
|
...current.progress,
|
||||||
|
phaseLabel: current.progress.phaseLabel || '暂停请求已发送'
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
window.electronAPI.export.pauseTask(taskId).then(result => {
|
||||||
|
if (result.success) return
|
||||||
|
updateTask(taskId, current => ({
|
||||||
|
...current,
|
||||||
|
status: current.status === 'pause_requested' ? 'running' : current.status,
|
||||||
|
error: result.error || '暂停请求失败'
|
||||||
|
}))
|
||||||
|
}).catch(error => {
|
||||||
|
updateTask(taskId, current => ({
|
||||||
|
...current,
|
||||||
|
status: current.status === 'pause_requested' ? 'running' : current.status,
|
||||||
|
error: String(error)
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}, [updateTask])
|
||||||
|
const handleResumeExportTask = useCallback((taskId: string) => {
|
||||||
|
const task = tasksRef.current.find(item => item.id === taskId)
|
||||||
|
if (!task || (task.status !== 'paused' && task.status !== 'pause_requested')) return
|
||||||
|
window.electronAPI.export.resumeTask(taskId).then(result => {
|
||||||
|
const doneAt = Date.now()
|
||||||
|
if (!result.success) {
|
||||||
|
updateTask(taskId, current => ({
|
||||||
|
...current,
|
||||||
|
status: 'error',
|
||||||
|
finishedAt: doneAt,
|
||||||
|
error: result.error || '继续任务失败',
|
||||||
|
performance: finalizeTaskPerformance(current, doneAt)
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updateTask(taskId, current => ({
|
||||||
|
...current,
|
||||||
|
status: current.status === 'pause_requested' ? 'running' : 'queued',
|
||||||
|
finishedAt: undefined,
|
||||||
|
error: undefined,
|
||||||
|
progress: {
|
||||||
|
...current.progress,
|
||||||
|
phaseLabel: current.status === 'pause_requested' ? '继续中' : '等待继续'
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}).catch(error => {
|
||||||
|
const doneAt = Date.now()
|
||||||
|
updateTask(taskId, current => ({
|
||||||
|
...current,
|
||||||
|
status: 'error',
|
||||||
|
finishedAt: doneAt,
|
||||||
|
error: String(error),
|
||||||
|
performance: finalizeTaskPerformance(current, doneAt)
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}, [updateTask])
|
||||||
|
const handleCancelExportTask = useCallback((taskId: string) => {
|
||||||
|
const task = tasksRef.current.find(item => item.id === taskId)
|
||||||
|
if (!task) return
|
||||||
|
if (task.status === 'queued') {
|
||||||
|
setTasks(prev => prev.filter(item => item.id !== taskId))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (task.status !== 'running' && task.status !== 'pause_requested' && task.status !== 'paused' && task.status !== 'cancel_requested') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updateTask(taskId, current => ({
|
||||||
|
...current,
|
||||||
|
status: 'cancel_requested',
|
||||||
|
progress: {
|
||||||
|
...current.progress,
|
||||||
|
phaseLabel: '取消请求已发送,正在安全停止'
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
window.electronAPI.export.cancelTask(taskId).then(result => {
|
||||||
|
if (result.success && task.status === 'paused') {
|
||||||
|
setTasks(prev => prev.filter(item => item.id !== taskId))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!result.success) {
|
||||||
|
const doneAt = Date.now()
|
||||||
|
updateTask(taskId, current => ({
|
||||||
|
...current,
|
||||||
|
status: 'error',
|
||||||
|
finishedAt: doneAt,
|
||||||
|
error: result.error || '取消任务失败',
|
||||||
|
performance: finalizeTaskPerformance(current, doneAt)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}).catch(error => {
|
||||||
|
const doneAt = Date.now()
|
||||||
|
updateTask(taskId, current => ({
|
||||||
|
...current,
|
||||||
|
status: 'error',
|
||||||
|
finishedAt: doneAt,
|
||||||
|
error: String(error),
|
||||||
|
performance: finalizeTaskPerformance(current, doneAt)
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}, [updateTask])
|
||||||
|
|
||||||
const toggleAutomationTaskEnabled = useCallback((taskId: string, enabled: boolean) => {
|
const toggleAutomationTaskEnabled = useCallback((taskId: string, enabled: boolean) => {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
@@ -8564,6 +8796,9 @@ function ExportPage() {
|
|||||||
nowTick={nowTick}
|
nowTick={nowTick}
|
||||||
onClose={closeTaskCenter}
|
onClose={closeTaskCenter}
|
||||||
onTogglePerfTask={toggleTaskPerfDetail}
|
onTogglePerfTask={toggleTaskPerfDetail}
|
||||||
|
onPauseExportTask={handlePauseExportTask}
|
||||||
|
onResumeExportTask={handleResumeExportTask}
|
||||||
|
onCancelExportTask={handleCancelExportTask}
|
||||||
onPauseBackgroundTask={handlePauseBackgroundTask}
|
onPauseBackgroundTask={handlePauseBackgroundTask}
|
||||||
onResumeBackgroundTask={handleResumeBackgroundTask}
|
onResumeBackgroundTask={handleResumeBackgroundTask}
|
||||||
onCancelBackgroundTask={handleCancelBackgroundTask}
|
onCancelBackgroundTask={handleCancelBackgroundTask}
|
||||||
@@ -8622,12 +8857,12 @@ function ExportPage() {
|
|||||||
<div className="automation-task-list">
|
<div className="automation-task-list">
|
||||||
{sortedAutomationTasks.map((task) => {
|
{sortedAutomationTasks.map((task) => {
|
||||||
const linkedQueueTask = tasks.find((item) => (
|
const linkedQueueTask = tasks.find((item) => (
|
||||||
(item.status === 'running' || item.status === 'queued') &&
|
isExportTaskActiveStatus(item.status) &&
|
||||||
item.payload.automationTaskId === task.id
|
item.payload.automationTaskId === task.id
|
||||||
))
|
))
|
||||||
const queueState: 'queued' | 'running' | null = linkedQueueTask?.status === 'running'
|
const queueState: 'queued' | 'running' | null = linkedQueueTask?.status === 'running'
|
||||||
? 'running'
|
? 'running'
|
||||||
: linkedQueueTask?.status === 'queued'
|
: linkedQueueTask && isExportTaskActiveStatus(linkedQueueTask.status)
|
||||||
? 'queued'
|
? 'queued'
|
||||||
: null
|
: null
|
||||||
return (
|
return (
|
||||||
|
|||||||
10
src/types/electron.d.ts
vendored
10
src/types/electron.d.ts
vendored
@@ -1092,16 +1092,21 @@ export interface ElectronAPI {
|
|||||||
estimatedSeconds: number
|
estimatedSeconds: number
|
||||||
sessions: Array<{ sessionId: string; displayName: string; totalCount: number; voiceCount: 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, controlOptions?: { taskId?: string }) => Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
successCount?: number
|
successCount?: number
|
||||||
failCount?: number
|
failCount?: number
|
||||||
|
paused?: boolean
|
||||||
|
stopped?: boolean
|
||||||
pendingSessionIds?: string[]
|
pendingSessionIds?: string[]
|
||||||
successSessionIds?: string[]
|
successSessionIds?: string[]
|
||||||
failedSessionIds?: string[]
|
failedSessionIds?: string[]
|
||||||
sessionOutputPaths?: Record<string, string>
|
sessionOutputPaths?: Record<string, string>
|
||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
|
pauseTask: (taskId: string) => Promise<{ success: boolean; error?: string }>
|
||||||
|
resumeTask: (taskId: string) => Promise<{ success: boolean; error?: string }>
|
||||||
|
cancelTask: (taskId: string) => Promise<{ success: boolean; error?: string }>
|
||||||
exportSession: (sessionId: string, outputPath: string, options: ExportOptions) => Promise<{
|
exportSession: (sessionId: string, outputPath: string, options: ExportOptions) => Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
error?: string
|
error?: string
|
||||||
@@ -1174,7 +1179,8 @@ export interface ElectronAPI {
|
|||||||
exportVideos?: boolean
|
exportVideos?: boolean
|
||||||
startTime?: number
|
startTime?: number
|
||||||
endTime?: 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
|
onExportProgress: (callback: (payload: { current: number; total: number; status: string }) => void) => () => void
|
||||||
selectExportDir: () => Promise<{ canceled: boolean; filePath?: string }>
|
selectExportDir: () => Promise<{ canceled: boolean; filePath?: string }>
|
||||||
getSnsUsernames: () => Promise<{ success: boolean; usernames?: string[]; error?: string }>
|
getSnsUsernames: () => Promise<{ success: boolean; usernames?: string[]; error?: string }>
|
||||||
|
|||||||
Reference in New Issue
Block a user