refactor(export): remove task pause/stop and prioritize export by loaded message counts

This commit is contained in:
tisonhuang
2026-03-04 18:20:56 +08:00
parent 54d6cded53
commit cf7190aaec
5 changed files with 106 additions and 378 deletions

View File

@@ -95,11 +95,6 @@ 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
}
type AnnualReportYearsLoadStrategy = 'cache' | 'native' | 'hybrid' type AnnualReportYearsLoadStrategy = 'cache' | 'native' | 'hybrid'
type AnnualReportYearsLoadPhase = 'cache' | 'native' | 'scan' | 'done' type AnnualReportYearsLoadPhase = 'cache' | 'native' | 'scan' | 'done'
@@ -126,31 +121,11 @@ interface AnnualReportYearsTaskState {
updatedAt: number updatedAt: number
} }
const exportTaskControlMap = new Map<string, ExportTaskControlState>()
const pendingExportTaskControlMap = new Map<string, ExportTaskControlState>()
const annualReportYearsLoadTasks = new Map<string, AnnualReportYearsTaskState>() const annualReportYearsLoadTasks = new Map<string, AnnualReportYearsTaskState>()
const annualReportYearsTaskByCacheKey = new Map<string, string>() const annualReportYearsTaskByCacheKey = new Map<string, string>()
const annualReportYearsSnapshotCache = new Map<string, { snapshot: AnnualReportYearsProgressPayload; updatedAt: number; taskId: string }>() const annualReportYearsSnapshotCache = new Map<string, { snapshot: AnnualReportYearsProgressPayload; updatedAt: number; taskId: string }>()
const annualReportYearsSnapshotTtlMs = 10 * 60 * 1000 const annualReportYearsSnapshotTtlMs = 10 * 60 * 1000
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
const pending = pendingExportTaskControlMap.get(normalized)
exportTaskControlMap.set(normalized, {
pauseRequested: Boolean(pending?.pauseRequested),
stopRequested: Boolean(pending?.stopRequested)
})
pendingExportTaskControlMap.delete(normalized)
return normalized
}
const normalizeAnnualReportYearsSnapshot = (snapshot: AnnualReportYearsProgressPayload): AnnualReportYearsProgressPayload => { const normalizeAnnualReportYearsSnapshot = (snapshot: AnnualReportYearsProgressPayload): AnnualReportYearsProgressPayload => {
const years = Array.isArray(snapshot.years) ? [...snapshot.years] : [] const years = Array.isArray(snapshot.years) ? [...snapshot.years] : []
return { ...snapshot, years } return { ...snapshot, years }
@@ -212,22 +187,6 @@ const isYearsLoadCanceled = (taskId: string): boolean => {
return task?.canceled === true return task?.canceled === true
} }
const clearTaskControlState = (taskId?: string): void => {
const normalized = typeof taskId === 'string' ? taskId.trim() : ''
if (!normalized) return
exportTaskControlMap.delete(normalized)
pendingExportTaskControlMap.delete(normalized)
}
const queueTaskControlRequest = (taskId: string, action: 'pause' | 'stop'): void => {
const normalized = taskId.trim()
if (!normalized) return
const existing = pendingExportTaskControlMap.get(normalized) || { pauseRequested: false, stopRequested: false }
if (action === 'pause') existing.pauseRequested = true
if (action === 'stop') existing.stopRequested = true
pendingExportTaskControlMap.set(normalized, existing)
}
function createWindow(options: { autoShow?: boolean } = {}) { function createWindow(options: { autoShow?: boolean } = {}) {
// 获取图标路径 - 打包后在 resources 目录 // 获取图标路径 - 打包后在 resources 目录
const { autoShow = true } = options const { autoShow = true } = options
@@ -1269,27 +1228,17 @@ function registerIpcHandlers() {
}) })
ipcMain.handle('sns:exportTimeline', async (event, options: any) => { ipcMain.handle('sns:exportTimeline', async (event, options: any) => {
const taskId = typeof options?.taskId === 'string' ? options.taskId : undefined
const controlId = createTaskControlState(taskId)
const exportOptions = { ...(options || {}) } const exportOptions = { ...(options || {}) }
delete exportOptions.taskId delete exportOptions.taskId
try {
return snsService.exportTimeline( return snsService.exportTimeline(
exportOptions, exportOptions,
(progress) => { (progress) => {
if (!event.sender.isDestroyed()) { if (!event.sender.isDestroyed()) {
event.sender.send('sns:exportProgress', progress) event.sender.send('sns:exportProgress', progress)
} }
},
{
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 () => {
@@ -1412,42 +1361,14 @@ function registerIpcHandlers() {
return exportService.getExportStats(sessionIds, options) return exportService.getExportStats(sessionIds, options)
}) })
ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions, taskId?: string) => { ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions) => {
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)
} }
} }
try { return exportService.exportSessions(sessionIds, outputDir, options, onProgress)
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) {
queueTaskControlRequest(taskId, 'pause')
return { success: true, queued: true }
}
state.pauseRequested = true
return { success: true }
})
ipcMain.handle('export:stopTask', async (_, taskId: string) => {
const state = getTaskControlState(taskId)
if (!state) {
queueTaskControlRequest(taskId, 'stop')
return { success: true, queued: true }
}
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

@@ -306,16 +306,12 @@ 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, taskId?: string) => exportSessions: (sessionIds: string[], outputDir: string, options: any) =>
ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options, taskId), ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options),
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; currentSessionId?: string; phase: string }) => void) => { onProgress: (callback: (payload: { current: number; total: number; currentSession: string; currentSessionId?: 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

@@ -6331,15 +6331,15 @@ class ExportService {
total: sessionIds.length, total: sessionIds.length,
currentSession: sessionInfo.displayName, currentSession: sessionInfo.displayName,
currentSessionId: sessionId, currentSessionId: sessionId,
phase: 'exporting', phase: 'complete',
phaseLabel: '该会话没有消息,已跳过' phaseLabel: '该会话没有消息,已跳过'
}) })
return 'done' return 'done'
} }
if (emptySessionIds.has(sessionId)) { if (emptySessionIds.has(sessionId)) {
failCount++ successCount++
failedSessionIds.push(sessionId) successSessionIds.push(sessionId)
activeSessionRatios.delete(sessionId) activeSessionRatios.delete(sessionId)
completedCount++ completedCount++
onProgress?.({ onProgress?.({
@@ -6347,7 +6347,7 @@ class ExportService {
total: sessionIds.length, total: sessionIds.length,
currentSession: sessionInfo.displayName, currentSession: sessionInfo.displayName,
currentSessionId: sessionId, currentSessionId: sessionId,
phase: 'exporting', phase: 'complete',
phaseLabel: '该会话没有消息,已跳过' phaseLabel: '该会话没有消息,已跳过'
}) })
return 'done' return 'done'
@@ -6419,7 +6419,7 @@ class ExportService {
total: sessionIds.length, total: sessionIds.length,
currentSession: sessionInfo.displayName, currentSession: sessionInfo.displayName,
currentSessionId: sessionId, currentSessionId: sessionId,
phase: 'exporting', phase: 'complete',
phaseLabel: '无变化,已跳过' phaseLabel: '无变化,已跳过'
}) })
return 'done' return 'done'
@@ -6463,6 +6463,14 @@ class ExportService {
failCount++ failCount++
failedSessionIds.push(sessionId) failedSessionIds.push(sessionId)
console.error(`导出 ${sessionId} 失败:`, result.error) console.error(`导出 ${sessionId} 失败:`, result.error)
onProgress?.({
current: computeAggregateCurrent(),
total: sessionIds.length,
currentSession: sessionInfo.displayName,
currentSessionId: sessionId,
phase: 'complete',
phaseLabel: '导出失败'
})
} }
activeSessionRatios.delete(sessionId) activeSessionRatios.delete(sessionId)

View File

@@ -40,8 +40,7 @@ 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' | 'paused' | 'stopped' | 'success' | 'error' type TaskStatus = 'queued' | 'running' | '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'
@@ -124,7 +123,6 @@ 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
@@ -353,13 +351,7 @@ const formatDurationMs = (ms: number): string => {
const getTaskStatusLabel = (task: ExportTask): string => { const getTaskStatusLabel = (task: ExportTask): string => {
if (task.status === 'queued') return '排队中' if (task.status === 'queued') return '排队中'
if (task.status === 'running') { if (task.status === 'running') return '进行中'
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 '已完成' if (task.status === 'success') return '已完成'
return '失败' return '失败'
} }
@@ -429,6 +421,7 @@ const parseDateInput = (value: string, endOfDay: boolean): Date => {
const toKindByContactType = (session: AppChatSession, contact?: ContactInfo): ConversationTab => { const toKindByContactType = (session: AppChatSession, contact?: ContactInfo): ConversationTab => {
if (session.username.endsWith('@chatroom')) return 'group' if (session.username.endsWith('@chatroom')) return 'group'
if (session.username.startsWith('gh_')) return 'official'
if (contact?.type === 'official') return 'official' if (contact?.type === 'official') return 'official'
if (contact?.type === 'former_friend') return 'former_friend' if (contact?.type === 'former_friend') return 'former_friend'
return 'private' return 'private'
@@ -445,6 +438,13 @@ const isContentScopeSession = (session: SessionRow): boolean => (
session.kind === 'private' || session.kind === 'group' || session.kind === 'former_friend' session.kind === 'private' || session.kind === 'group' || session.kind === 'former_friend'
) )
const exportKindPriority: Record<ConversationTab, number> = {
private: 0,
group: 1,
former_friend: 2,
official: 3
}
const getAvatarLetter = (name: string): string => { const getAvatarLetter = (name: string): string => {
if (!name) return '?' if (!name) return '?'
return [...name][0] || '?' return [...name][0] || '?'
@@ -2277,7 +2277,6 @@ function ExportPage() {
updateTask(next.id, task => ({ updateTask(next.id, task => ({
...task, ...task,
status: 'running', status: 'running',
controlState: undefined,
startedAt: Date.now(), startedAt: Date.now(),
finishedAt: undefined, finishedAt: undefined,
error: undefined, error: undefined,
@@ -2338,43 +2337,17 @@ function ExportPage() {
exportLivePhotos: snsOptions.exportLivePhotos, exportLivePhotos: snsOptions.exportLivePhotos,
exportVideos: snsOptions.exportVideos, exportVideos: snsOptions.exportVideos,
startTime: snsOptions.startTime, startTime: snsOptions.startTime,
endTime: snsOptions.endTime, endTime: snsOptions.endTime
taskId: next.id
}) })
if (!result.success) { if (!result.success) {
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 || '朋友圈导出失败',
performance: finalizeTaskPerformance(task, Date.now()) performance: finalizeTaskPerformance(task, Date.now())
})) }))
} else if (result.stopped) {
updateTask(next.id, task => ({
...task,
status: 'stopped',
controlState: undefined,
finishedAt: Date.now(),
progress: {
...task.progress,
phaseLabel: '已停止'
},
performance: finalizeTaskPerformance(task, Date.now())
}))
} else if (result.paused) {
updateTask(next.id, task => ({
...task,
status: 'paused',
controlState: undefined,
finishedAt: Date.now(),
progress: {
...task.progress,
phaseLabel: '已暂停'
},
performance: finalizeTaskPerformance(task, Date.now())
}))
} 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)
@@ -2386,7 +2359,6 @@ 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,
@@ -2407,23 +2379,19 @@ 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 || '导出失败',
performance: finalizeTaskPerformance(task, Date.now()) performance: finalizeTaskPerformance(task, Date.now())
})) }))
} else { } else {
const doneAt = Date.now() const doneAt = Date.now()
const successCount = result.successCount ?? 0
const failCount = result.failCount ?? 0
const contentTypes = next.payload.contentType const contentTypes = next.payload.contentType
? [next.payload.contentType] ? [next.payload.contentType]
: inferContentTypesFromOptions(next.payload.options) : inferContentTypesFromOptions(next.payload.options)
@@ -2435,35 +2403,9 @@ function ExportPage() {
markContentExported(successSessionIds, contentTypes, doneAt) markContentExported(successSessionIds, contentTypes, doneAt)
} }
if (result.stopped) {
updateTask(next.id, task => ({
...task,
status: 'stopped',
controlState: undefined,
finishedAt: doneAt,
progress: {
...task.progress,
current: successCount + failCount,
total: task.progress.total || next.payload.sessionIds.length,
phaseLabel: '已停止'
},
performance: finalizeTaskPerformance(task, doneAt)
}))
} 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, finishedAt: doneAt,
progress: { progress: {
...task.progress, ...task.progress,
@@ -2475,43 +2417,6 @@ function ExportPage() {
}, },
performance: finalizeTaskPerformance(task, doneAt) performance: finalizeTaskPerformance(task, doneAt)
})) }))
} else {
updateTask(next.id, task => ({
...task,
status: 'paused',
controlState: undefined,
finishedAt: doneAt,
payload: {
...task.payload,
sessionIds: pendingSessionIds,
sessionNames: pendingSessionNames
},
progress: {
...task.progress,
current: successCount + failCount,
total: task.progress.total || next.payload.sessionIds.length,
phaseLabel: '已暂停'
},
performance: finalizeTaskPerformance(task, doneAt)
}))
}
} 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
},
performance: finalizeTaskPerformance(task, doneAt)
}))
}
} }
} }
} catch (error) { } catch (error) {
@@ -2519,7 +2424,6 @@ function ExportPage() {
updateTask(next.id, task => ({ updateTask(next.id, task => ({
...task, ...task,
status: 'error', status: 'error',
controlState: undefined,
finishedAt: doneAt, finishedAt: doneAt,
error: String(error), error: String(error),
performance: finalizeTaskPerformance(task, doneAt) performance: finalizeTaskPerformance(task, doneAt)
@@ -2543,91 +2447,6 @@ 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,
performance: finalizeTaskPerformance(task, Date.now())
}))
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') {
const doneAt = Date.now()
updateTask(taskId, task => ({
...task,
status: 'stopped',
controlState: undefined,
finishedAt: doneAt,
progress: {
...task.progress,
phaseLabel: '已停止'
},
performance: finalizeTaskPerformance(task, doneAt)
}))
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
@@ -2688,12 +2507,45 @@ function ExportPage() {
}) })
} }
const resolveSessionExistingMessageCount = useCallback((session: SessionRow): number => {
const counted = normalizeMessageCount(sessionMessageCounts[session.username])
if (typeof counted === 'number') return counted
const hinted = normalizeMessageCount(session.messageCountHint)
if (typeof hinted === 'number') return hinted
return 0
}, [sessionMessageCounts])
const orderSessionsForExport = useCallback((source: SessionRow[]): SessionRow[] => {
return source
.filter((session) => session.hasSession && isContentScopeSession(session))
.map((session) => ({
session,
count: resolveSessionExistingMessageCount(session)
}))
.filter((item) => item.count > 0)
.sort((a, b) => {
const kindDiff = exportKindPriority[a.session.kind] - exportKindPriority[b.session.kind]
if (kindDiff !== 0) return kindDiff
if (a.count !== b.count) return b.count - a.count
const tsA = a.session.sortTimestamp || a.session.lastTimestamp || 0
const tsB = b.session.sortTimestamp || b.session.lastTimestamp || 0
if (tsA !== tsB) return tsB - tsA
return (a.session.displayName || a.session.username)
.localeCompare(b.session.displayName || b.session.username, 'zh-Hans-CN')
})
.map((item) => item.session)
}, [resolveSessionExistingMessageCount])
const openBatchExport = () => { const openBatchExport = () => {
const selectable = new Set(sessions.filter(session => session.hasSession).map(session => session.username)) const selectedSet = new Set(selectedSessions)
const ids = Array.from(selectedSessions).filter(id => selectable.has(id)) const selectedRows = sessions.filter((session) => selectedSet.has(session.username))
if (ids.length === 0) return const orderedRows = orderSessionsForExport(selectedRows)
const nameMap = new Map(sessions.map(session => [session.username, session.displayName || session.username])) if (orderedRows.length === 0) {
const names = ids.map(id => nameMap.get(id) || id) window.alert('所选会话暂无可导出的消息(总消息数为 0')
return
}
const ids = orderedRows.map((session) => session.username)
const names = orderedRows.map((session) => session.displayName || session.username)
openExportDialog({ openExportDialog({
scope: 'multi', scope: 'multi',
@@ -2704,13 +2556,13 @@ function ExportPage() {
} }
const openContentExport = (contentType: ContentType) => { const openContentExport = (contentType: ContentType) => {
const ids = sessions const orderedRows = orderSessionsForExport(sessions)
.filter(session => session.hasSession && isContentScopeSession(session)) if (orderedRows.length === 0) {
.map(session => session.username) window.alert('当前会话列表暂无可导出的消息(总消息数为 0')
return
const names = sessions }
.filter(session => session.hasSession && isContentScopeSession(session)) const ids = orderedRows.map((session) => session.username)
.map(session => session.displayName || session.username) const names = orderedRows.map((session) => session.displayName || session.username)
openExportDialog({ openExportDialog({
scope: 'content', scope: 'content',
@@ -2752,17 +2604,6 @@ 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 inProgressSessionIds = useMemo(() => { const inProgressSessionIds = useMemo(() => {
const set = new Set<string>() const set = new Set<string>()
for (const task of tasks) { for (const task of tasks) {
@@ -3567,7 +3408,6 @@ 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 (
@@ -3580,8 +3420,8 @@ function ExportPage() {
</button> </button>
<button <button
className={`row-export-btn ${isRunning ? 'running' : ''} ${isPaused ? 'paused' : ''}`} className={`row-export-btn ${isRunning ? 'running' : ''}`}
disabled={isRunning || isPaused} disabled={isRunning}
onClick={() => openSingleExport(session)} onClick={() => openSingleExport(session)}
> >
{isRunning ? ( {isRunning ? (
@@ -3589,7 +3429,7 @@ function ExportPage() {
<Loader2 size={14} className="spin" /> <Loader2 size={14} className="spin" />
</> </>
) : isPaused ? '已暂停' : isQueued ? '排队中' : '导出'} ) : isQueued ? '排队中' : '导出'}
</button> </button>
</div> </div>
{recent && <span className="row-export-time">{recent}</span>} {recent && <span className="row-export-time">{recent}</span>}
@@ -3668,7 +3508,6 @@ 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({
@@ -3722,7 +3561,6 @@ 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
@@ -3756,7 +3594,7 @@ function ExportPage() {
<div className="task-center-modal-header"> <div className="task-center-modal-header">
<div className="task-center-modal-title"> <div className="task-center-modal-title">
<h3></h3> <h3></h3>
<span> {taskRunningCount} · {taskQueuedCount} · {taskPausedCount} · {tasks.length}</span> <span> {taskRunningCount} · {taskQueuedCount} · {tasks.length}</span>
</div> </div>
<button <button
className="close-icon-btn" className="close-icon-btn"
@@ -3795,14 +3633,14 @@ function ExportPage() {
? Math.max(0, Math.min(1, task.progress.phaseProgress / task.progress.phaseTotal)) ? Math.max(0, Math.min(1, task.progress.phaseProgress / task.progress.phaseTotal))
: null : null
return ( return (
<div key={task.id} className={`task-card ${task.status} ${task.controlState ? `request-${task.controlState}` : ''}`}> <div key={task.id} className={`task-card ${task.status}`}>
<div className="task-main"> <div className="task-main">
<div className="task-title">{task.title}</div> <div className="task-title">{task.title}</div>
<div className="task-meta"> <div className="task-meta">
<span className={`task-status ${task.status}`}>{getTaskStatusLabel(task)}</span> <span className={`task-status ${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 === 'paused') && ( {task.status === 'running' && (
<> <>
<div className="task-progress-bar"> <div className="task-progress-bar">
<div <div
@@ -3882,35 +3720,6 @@ function ExportPage() {
{isPerfExpanded ? '收起详情' : '性能详情'} {isPerfExpanded ? '收起详情' : '性能详情'}
</button> </button>
)} )}
{(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)}> <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>
@@ -4253,7 +4062,6 @@ 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) : ''
const countedMessages = normalizeMessageCount(sessionMessageCounts[contact.username]) const countedMessages = normalizeMessageCount(sessionMessageCounts[contact.username])
const hintedMessages = normalizeMessageCount(matchedSession?.messageCountHint) const hintedMessages = normalizeMessageCount(matchedSession?.messageCountHint)
@@ -4300,8 +4108,8 @@ function ExportPage() {
</button> </button>
<button <button
className={`row-export-btn ${isRunning ? 'running' : ''} ${isPaused ? 'paused' : ''} ${!canExport ? 'no-session' : ''}`} className={`row-export-btn ${isRunning ? 'running' : ''} ${!canExport ? 'no-session' : ''}`}
disabled={!canExport || isRunning || isPaused} disabled={!canExport || isRunning}
onClick={() => { onClick={() => {
if (!matchedSession || !matchedSession.hasSession) return if (!matchedSession || !matchedSession.hasSession) return
openSingleExport({ openSingleExport({
@@ -4315,7 +4123,7 @@ function ExportPage() {
<Loader2 size={14} className="spin" /> <Loader2 size={14} className="spin" />
</> </>
) : !canExport ? '暂无会话' : isPaused ? '已暂停' : isQueued ? '排队中' : '导出'} ) : !canExport ? '暂无会话' : isQueued ? '排队中' : '导出'}
</button> </button>
</div> </div>
{recent && <span className="row-export-time">{recent}</span>} {recent && <span className="row-export-time">{recent}</span>}

View File

@@ -692,12 +692,10 @@ 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, taskId?: string) => Promise<{ exportSessions: (sessionIds: string[], outputDir: string, options: ExportOptions) => Promise<{
success: boolean success: boolean
successCount?: number successCount?: number
failCount?: number failCount?: number
paused?: boolean
stopped?: boolean
pendingSessionIds?: string[] pendingSessionIds?: string[]
successSessionIds?: string[] successSessionIds?: string[]
failedSessionIds?: string[] failedSessionIds?: string[]
@@ -712,8 +710,6 @@ 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: {
@@ -767,8 +763,7 @@ export interface ElectronAPI {
exportVideos?: boolean exportVideos?: boolean
startTime?: number startTime?: number
endTime?: number endTime?: number
taskId?: string }) => Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; error?: 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 }>