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