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

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

View File

@@ -94,6 +94,32 @@ let isDownloadInProgress = false
let downloadProgressHandler: ((progress: any) => void) | null = null let 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
if (!event.sender.isDestroyed()) { const controlId = createTaskControlState(taskId)
event.sender.send('sns:exportProgress', progress) const exportOptions = { ...(options || {}) }
} delete exportOptions.taskId
})
try {
return snsService.exportTimeline(
exportOptions,
(progress) => {
if (!event.sender.isDestroyed()) {
event.sender.send('sns:exportProgress', progress)
}
},
{
shouldPause: () => Boolean(getTaskControlState(controlId || undefined)?.pauseRequested),
shouldStop: () => Boolean(getTaskControlState(controlId || undefined)?.stopRequested)
}
)
} finally {
clearTaskControlState(controlId || undefined)
}
}) })
ipcMain.handle('sns:selectExportDir', async () => { 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) => {

View File

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

View File

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

View File

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

View File

@@ -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,122 +375,232 @@
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);
padding: 12px; box-shadow: 0 20px 48px rgba(0, 0, 0, 0.24);
flex-shrink: 0; display: flex;
flex-direction: column;
overflow: hidden;
}
.task-empty { .task-center-modal-header {
padding: 12px; padding: 12px 14px;
background: var(--bg-secondary); border-bottom: 1px solid var(--border-color);
border-radius: 8px; display: flex;
font-size: 13px; align-items: center;
color: var(--text-secondary); justify-content: space-between;
} gap: 10px;
}
.task-list { .task-center-modal-title {
display: grid; min-width: 0;
gap: 8px;
max-height: 190px;
overflow-y: auto;
}
.task-card { h3 {
border: 1px solid var(--border-color); margin: 0;
border-radius: 10px; font-size: 16px;
padding: 10px;
display: flex;
gap: 10px;
align-items: flex-start;
background: var(--bg-secondary);
&.running {
border-color: var(--primary);
}
&.error {
border-color: rgba(255, 77, 79, 0.45);
}
&.success {
border-color: rgba(82, 196, 26, 0.4);
}
}
.task-main {
flex: 1;
min-width: 0;
}
.task-title {
font-size: 13px;
color: var(--text-primary); color: var(--text-primary);
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.task-meta { span {
margin-top: 2px; display: block;
display: flex; margin-top: 3px;
flex-wrap: wrap;
gap: 8px;
font-size: 11px;
color: var(--text-secondary);
}
.task-status {
border-radius: 999px;
padding: 2px 8px;
font-weight: 600;
&.queued {
background: rgba(var(--primary-rgb), 0.14);
color: var(--primary);
}
&.running {
background: rgba(var(--primary-rgb), 0.2);
color: var(--primary);
}
&.success {
background: rgba(82, 196, 26, 0.18);
color: #52c41a;
}
&.error {
background: rgba(255, 77, 79, 0.15);
color: #ff4d4f;
}
}
.task-progress-bar {
margin-top: 8px;
height: 6px;
background: rgba(0, 0, 0, 0.08);
border-radius: 3px;
overflow: hidden;
}
.task-progress-fill {
height: 100%;
background: var(--primary);
transition: width 0.2s ease;
}
.task-progress-text {
margin-top: 4px;
font-size: 11px;
color: var(--text-secondary);
}
.task-error {
margin-top: 6px;
font-size: 12px; font-size: 12px;
color: var(--text-secondary);
}
}
.task-center-modal-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 12px;
}
.task-empty {
padding: 12px;
background: var(--bg-secondary);
border-radius: 8px;
font-size: 13px;
color: var(--text-secondary);
}
.task-list {
display: grid;
gap: 8px;
}
.task-card {
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 10px;
display: flex;
gap: 10px;
align-items: flex-start;
background: var(--bg-secondary);
&.running {
border-color: var(--primary);
}
&.paused {
border-color: rgba(250, 173, 20, 0.55);
}
&.stopped {
border-color: rgba(148, 163, 184, 0.46);
}
&.error {
border-color: rgba(255, 77, 79, 0.45);
}
&.success {
border-color: rgba(82, 196, 26, 0.4);
}
}
.task-main {
flex: 1;
min-width: 0;
}
.task-title {
font-size: 13px;
color: var(--text-primary);
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.task-meta {
margin-top: 2px;
display: flex;
flex-wrap: wrap;
gap: 8px;
font-size: 11px;
color: var(--text-secondary);
}
.task-status {
border-radius: 999px;
padding: 2px 8px;
font-weight: 600;
&.queued {
background: rgba(var(--primary-rgb), 0.14);
color: var(--primary);
}
&.running {
background: rgba(var(--primary-rgb), 0.2);
color: var(--primary);
}
&.paused {
background: rgba(250, 173, 20, 0.2);
color: #d48806;
}
&.stopped {
background: rgba(148, 163, 184, 0.2);
color: #64748b;
}
&.success {
background: rgba(82, 196, 26, 0.18);
color: #52c41a;
}
&.error {
background: rgba(255, 77, 79, 0.15);
color: #ff4d4f;
}
}
.task-progress-bar {
margin-top: 8px;
height: 6px;
background: rgba(0, 0, 0, 0.08);
border-radius: 3px;
overflow: hidden;
}
.task-progress-fill {
height: 100%;
background: var(--primary);
transition: width 0.2s ease;
}
.task-progress-text {
margin-top: 4px;
font-size: 11px;
color: var(--text-secondary);
}
.task-error {
margin-top: 6px;
font-size: 12px;
color: #ff4d4f;
}
.task-actions {
display: flex;
flex-direction: column;
gap: 6px;
flex-shrink: 0;
}
.task-action-btn {
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-primary);
color: var(--text-secondary);
min-height: 30px;
padding: 0 10px;
font-size: 12px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
white-space: nowrap;
&:hover:not(:disabled) {
border-color: var(--primary);
color: var(--primary);
}
&:disabled {
opacity: 0.65;
cursor: not-allowed;
}
&.primary {
border-color: rgba(var(--primary-rgb), 0.35);
color: var(--primary);
}
&.danger {
border-color: rgba(255, 77, 79, 0.36);
color: #ff4d4f; color: #ff4d4f;
} }
} }
@@ -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);
} }

View File

@@ -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,36 +1643,49 @@ 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 => {
...task, if (task.status !== 'running') return task
progress: { return {
current: payload.current || 0, ...task,
total: payload.total || 0, progress: {
currentName: '', current: payload.current || 0,
phaseLabel: payload.status || '', total: payload.total || 0,
phaseProgress: payload.total > 0 ? payload.current : 0, currentName: '',
phaseTotal: payload.total || 0 phaseLabel: payload.status || '',
phaseProgress: payload.total > 0 ? payload.current : 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 => {
...task, if (task.status !== 'running') return task
progress: { return {
current: payload.current, ...task,
total: payload.total, progress: {
currentName: payload.currentSession, current: payload.current,
phaseLabel: payload.phaseLabel || '', total: payload.total,
phaseProgress: payload.phaseProgress || 0, currentName: payload.currentSession,
phaseTotal: payload.phaseTotal || 0 phaseLabel: payload.phaseLabel || '',
phaseProgress: payload.phaseProgress || 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,29 +1779,94 @@ 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)
updateTask(next.id, task => ({ if (pendingSessionIds.length === 0) {
...task, updateTask(next.id, task => ({
status: 'success', ...task,
finishedAt: doneAt, status: 'success',
progress: { controlState: undefined,
...task.progress, finishedAt: doneAt,
current: task.progress.total || next.payload.sessionIds.length, progress: {
total: task.progress.total || next.payload.sessionIds.length, ...task.progress,
phaseLabel: '完成', current: task.progress.total || next.payload.sessionIds.length,
phaseProgress: 1, total: task.progress.total || next.payload.sessionIds.length,
phaseTotal: 1 phaseLabel: '完成',
phaseProgress: 1,
phaseTotal: 1
}
}))
} else {
updateTask(next.id, task => ({
...task,
status: 'paused',
controlState: undefined,
finishedAt: doneAt,
payload: {
...task.payload,
sessionIds: pendingSessionIds,
sessionNames: pendingSessionNames
},
progress: {
...task.progress,
current: result.successCount + result.failCount,
total: task.progress.total || next.payload.sessionIds.length,
phaseLabel: '已暂停'
}
}))
} }
})) } else {
updateTask(next.id, task => ({
...task,
status: 'success',
controlState: undefined,
finishedAt: doneAt,
progress: {
...task.progress,
current: task.progress.total || next.payload.sessionIds.length,
total: task.progress.total || next.payload.sessionIds.length,
phaseLabel: '完成',
phaseProgress: 1,
phaseTotal: 1
}
}))
}
} }
} }
} catch (error) { } 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,62 +2661,119 @@ 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
{tasks.length === 0 ? ( className="task-center-modal-overlay"
<div className="task-empty"></div> onClick={() => setIsTaskCenterOpen(false)}
) : ( >
<div className="task-list"> <div
{tasks.map(task => ( className="task-center-modal"
<div key={task.id} className={`task-card ${task.status}`}> role="dialog"
<div className="task-main"> aria-modal="true"
<div className="task-title">{task.title}</div> aria-label="任务中心"
<div className="task-meta"> onClick={(event) => event.stopPropagation()}
<span className={`task-status ${task.status}`}>{task.status === 'queued' ? '排队中' : task.status === 'running' ? '进行中' : task.status === 'success' ? '已完成' : '失败'}</span> >
<span>{new Date(task.createdAt).toLocaleString('zh-CN')}</span> <div className="task-center-modal-header">
</div> <div className="task-center-modal-title">
{task.status === 'running' && ( <h3></h3>
<> <span> {taskRunningCount} · {taskQueuedCount} · {taskPausedCount} · {tasks.length}</span>
<div className="task-progress-bar"> </div>
<div <button
className="task-progress-fill" className="close-icon-btn"
style={{ width: `${task.progress.total > 0 ? (task.progress.current / task.progress.total) * 100 : 0}%` }} type="button"
/> onClick={() => setIsTaskCenterOpen(false)}
</div> aria-label="关闭任务中心"
<div className="task-progress-text"> >
{task.progress.total > 0 <X size={16} />
? `${task.progress.current} / ${task.progress.total}` </button>
: '处理中'}
{task.progress.phaseLabel ? ` · ${task.progress.phaseLabel}` : ''}
</div>
</>
)}
{task.status === 'error' && <div className="task-error">{task.error || '任务失败'}</div>}
</div>
<div className="task-actions">
<button className="secondary-btn" onClick={() => exportFolder && void window.electronAPI.shell.openPath(exportFolder)}>
<FolderOpen size={14} />
</button>
</div>
</div>
))}
</div> </div>
)} <div className="task-center-modal-body">
{tasks.length === 0 ? (
<div className="task-empty"></div>
) : (
<div className="task-list">
{tasks.map(task => (
<div key={task.id} className={`task-card ${task.status} ${task.controlState ? `request-${task.controlState}` : ''}`}>
<div className="task-main">
<div className="task-title">{task.title}</div>
<div className="task-meta">
<span className={`task-status ${task.status}`}>{getTaskStatusLabel(task)}</span>
<span>{new Date(task.createdAt).toLocaleString('zh-CN')}</span>
</div>
{(task.status === 'running' || task.status === 'paused') && (
<>
<div className="task-progress-bar">
<div
className="task-progress-fill"
style={{ width: `${task.progress.total > 0 ? (task.progress.current / task.progress.total) * 100 : 0}%` }}
/>
</div>
<div className="task-progress-text">
{task.progress.total > 0
? `${task.progress.current} / ${task.progress.total}`
: '处理中'}
{task.progress.phaseLabel ? ` · ${task.progress.phaseLabel}` : ''}
</div>
</>
)}
{task.status === 'error' && <div className="task-error">{task.error || '任务失败'}</div>}
</div>
<div className="task-actions">
{(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} />
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div> </div>
)} )}
@@ -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>}

View File

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