mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
feat(export): add text batch task performance breakdown
This commit is contained in:
@@ -276,7 +276,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
ipcRenderer.invoke('export:pauseTask', taskId),
|
ipcRenderer.invoke('export:pauseTask', taskId),
|
||||||
stopTask: (taskId: string) =>
|
stopTask: (taskId: string) =>
|
||||||
ipcRenderer.invoke('export:stopTask', taskId),
|
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; 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')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ export interface ExportProgress {
|
|||||||
current: number
|
current: number
|
||||||
total: number
|
total: number
|
||||||
currentSession: string
|
currentSession: string
|
||||||
|
currentSessionId?: string
|
||||||
phase: 'preparing' | 'exporting' | 'exporting-media' | 'exporting-voice' | 'writing' | 'complete'
|
phase: 'preparing' | 'exporting' | 'exporting-media' | 'exporting-voice' | 'writing' | 'complete'
|
||||||
phaseProgress?: number
|
phaseProgress?: number
|
||||||
phaseTotal?: number
|
phaseTotal?: number
|
||||||
@@ -5299,7 +5300,8 @@ class ExportService {
|
|||||||
...progress,
|
...progress,
|
||||||
current: completedCount,
|
current: completedCount,
|
||||||
total: sessionIds.length,
|
total: sessionIds.length,
|
||||||
currentSession: sessionInfo.displayName
|
currentSession: sessionInfo.displayName,
|
||||||
|
currentSessionId: sessionId
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5411,6 +5413,7 @@ class ExportService {
|
|||||||
current: sessionIds.length,
|
current: sessionIds.length,
|
||||||
total: sessionIds.length,
|
total: sessionIds.length,
|
||||||
currentSession: '',
|
currentSession: '',
|
||||||
|
currentSessionId: '',
|
||||||
phase: 'complete'
|
phase: 'complete'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -557,6 +557,88 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.task-perf-summary {
|
||||||
|
margin-top: 6px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-perf-panel {
|
||||||
|
margin-top: 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-perf-title {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-perf-row {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-perf-row-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-perf-row-track {
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgba(0, 0, 0, 0.08);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-perf-row-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-perf-empty {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-perf-session-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-perf-session-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-perf-session-rank {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-perf-session-time {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
.task-error {
|
.task-error {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
@@ -79,11 +79,29 @@ interface TaskProgress {
|
|||||||
current: number
|
current: number
|
||||||
total: number
|
total: number
|
||||||
currentName: string
|
currentName: string
|
||||||
|
phase: ExportProgress['phase'] | ''
|
||||||
phaseLabel: string
|
phaseLabel: string
|
||||||
phaseProgress: number
|
phaseProgress: number
|
||||||
phaseTotal: number
|
phaseTotal: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TaskPerfStage = 'collect' | 'build' | 'write' | 'other'
|
||||||
|
|
||||||
|
interface TaskSessionPerformance {
|
||||||
|
sessionId: string
|
||||||
|
sessionName: string
|
||||||
|
startedAt: number
|
||||||
|
finishedAt?: number
|
||||||
|
elapsedMs: number
|
||||||
|
lastPhase?: ExportProgress['phase']
|
||||||
|
lastPhaseStartedAt?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TaskPerformance {
|
||||||
|
stages: Record<TaskPerfStage, number>
|
||||||
|
sessions: Record<string, TaskSessionPerformance>
|
||||||
|
}
|
||||||
|
|
||||||
interface ExportTaskPayload {
|
interface ExportTaskPayload {
|
||||||
sessionIds: string[]
|
sessionIds: string[]
|
||||||
outputDir: string
|
outputDir: string
|
||||||
@@ -110,6 +128,7 @@ interface ExportTask {
|
|||||||
error?: string
|
error?: string
|
||||||
payload: ExportTaskPayload
|
payload: ExportTaskPayload
|
||||||
progress: TaskProgress
|
progress: TaskProgress
|
||||||
|
performance?: TaskPerformance
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ExportDialogState {
|
interface ExportDialogState {
|
||||||
@@ -170,11 +189,165 @@ const createEmptyProgress = (): TaskProgress => ({
|
|||||||
current: 0,
|
current: 0,
|
||||||
total: 0,
|
total: 0,
|
||||||
currentName: '',
|
currentName: '',
|
||||||
|
phase: '',
|
||||||
phaseLabel: '',
|
phaseLabel: '',
|
||||||
phaseProgress: 0,
|
phaseProgress: 0,
|
||||||
phaseTotal: 0
|
phaseTotal: 0
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const createEmptyTaskPerformance = (): TaskPerformance => ({
|
||||||
|
stages: {
|
||||||
|
collect: 0,
|
||||||
|
build: 0,
|
||||||
|
write: 0,
|
||||||
|
other: 0
|
||||||
|
},
|
||||||
|
sessions: {}
|
||||||
|
})
|
||||||
|
|
||||||
|
const isTextBatchTask = (task: ExportTask): boolean => (
|
||||||
|
task.payload.scope === 'content' && task.payload.contentType === 'text'
|
||||||
|
)
|
||||||
|
|
||||||
|
const resolvePerfStageByPhase = (phase?: ExportProgress['phase']): TaskPerfStage => {
|
||||||
|
if (phase === 'preparing') return 'collect'
|
||||||
|
if (phase === 'writing') return 'write'
|
||||||
|
if (phase === 'exporting' || phase === 'exporting-media' || phase === 'exporting-voice') return 'build'
|
||||||
|
return 'other'
|
||||||
|
}
|
||||||
|
|
||||||
|
const cloneTaskPerformance = (performance?: TaskPerformance): TaskPerformance => ({
|
||||||
|
stages: {
|
||||||
|
collect: performance?.stages.collect || 0,
|
||||||
|
build: performance?.stages.build || 0,
|
||||||
|
write: performance?.stages.write || 0,
|
||||||
|
other: performance?.stages.other || 0
|
||||||
|
},
|
||||||
|
sessions: Object.fromEntries(
|
||||||
|
Object.entries(performance?.sessions || {}).map(([sessionId, session]) => [sessionId, { ...session }])
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const resolveTaskSessionName = (task: ExportTask, sessionId: string, fallback?: string): string => {
|
||||||
|
const idx = task.payload.sessionIds.indexOf(sessionId)
|
||||||
|
if (idx >= 0) {
|
||||||
|
return task.payload.sessionNames[idx] || fallback || sessionId
|
||||||
|
}
|
||||||
|
return fallback || sessionId
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyProgressToTaskPerformance = (
|
||||||
|
task: ExportTask,
|
||||||
|
payload: ExportProgress,
|
||||||
|
now: number
|
||||||
|
): TaskPerformance | undefined => {
|
||||||
|
if (!isTextBatchTask(task)) return task.performance
|
||||||
|
const sessionId = String(payload.currentSessionId || '').trim()
|
||||||
|
if (!sessionId) return task.performance || createEmptyTaskPerformance()
|
||||||
|
|
||||||
|
const performance = cloneTaskPerformance(task.performance)
|
||||||
|
const sessionName = resolveTaskSessionName(task, sessionId, payload.currentSession || sessionId)
|
||||||
|
const existing = performance.sessions[sessionId]
|
||||||
|
const session: TaskSessionPerformance = existing
|
||||||
|
? { ...existing, sessionName: existing.sessionName || sessionName }
|
||||||
|
: {
|
||||||
|
sessionId,
|
||||||
|
sessionName,
|
||||||
|
startedAt: now,
|
||||||
|
elapsedMs: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session.finishedAt && session.lastPhase && typeof session.lastPhaseStartedAt === 'number') {
|
||||||
|
const delta = Math.max(0, now - session.lastPhaseStartedAt)
|
||||||
|
performance.stages[resolvePerfStageByPhase(session.lastPhase)] += delta
|
||||||
|
}
|
||||||
|
|
||||||
|
session.elapsedMs = Math.max(session.elapsedMs, now - session.startedAt)
|
||||||
|
|
||||||
|
if (payload.phase === 'complete') {
|
||||||
|
session.finishedAt = now
|
||||||
|
session.lastPhase = undefined
|
||||||
|
session.lastPhaseStartedAt = undefined
|
||||||
|
} else {
|
||||||
|
session.lastPhase = payload.phase
|
||||||
|
session.lastPhaseStartedAt = now
|
||||||
|
}
|
||||||
|
|
||||||
|
performance.sessions[sessionId] = session
|
||||||
|
return performance
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalizeTaskPerformance = (task: ExportTask, now: number): TaskPerformance | undefined => {
|
||||||
|
if (!isTextBatchTask(task) || !task.performance) return task.performance
|
||||||
|
const performance = cloneTaskPerformance(task.performance)
|
||||||
|
for (const session of Object.values(performance.sessions)) {
|
||||||
|
if (session.finishedAt) continue
|
||||||
|
if (session.lastPhase && typeof session.lastPhaseStartedAt === 'number') {
|
||||||
|
const delta = Math.max(0, now - session.lastPhaseStartedAt)
|
||||||
|
performance.stages[resolvePerfStageByPhase(session.lastPhase)] += delta
|
||||||
|
}
|
||||||
|
session.elapsedMs = Math.max(session.elapsedMs, now - session.startedAt)
|
||||||
|
session.finishedAt = now
|
||||||
|
session.lastPhase = undefined
|
||||||
|
session.lastPhaseStartedAt = undefined
|
||||||
|
}
|
||||||
|
return performance
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTaskPerformanceStageTotals = (
|
||||||
|
performance: TaskPerformance | undefined,
|
||||||
|
now: number
|
||||||
|
): Record<TaskPerfStage, number> => {
|
||||||
|
const totals: Record<TaskPerfStage, number> = {
|
||||||
|
collect: performance?.stages.collect || 0,
|
||||||
|
build: performance?.stages.build || 0,
|
||||||
|
write: performance?.stages.write || 0,
|
||||||
|
other: performance?.stages.other || 0
|
||||||
|
}
|
||||||
|
if (!performance) return totals
|
||||||
|
for (const session of Object.values(performance.sessions)) {
|
||||||
|
if (session.finishedAt) continue
|
||||||
|
if (!session.lastPhase || typeof session.lastPhaseStartedAt !== 'number') continue
|
||||||
|
const delta = Math.max(0, now - session.lastPhaseStartedAt)
|
||||||
|
totals[resolvePerfStageByPhase(session.lastPhase)] += delta
|
||||||
|
}
|
||||||
|
return totals
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTaskPerformanceTopSessions = (
|
||||||
|
performance: TaskPerformance | undefined,
|
||||||
|
now: number,
|
||||||
|
limit = 5
|
||||||
|
): Array<TaskSessionPerformance & { liveElapsedMs: number }> => {
|
||||||
|
if (!performance) return []
|
||||||
|
return Object.values(performance.sessions)
|
||||||
|
.map((session) => {
|
||||||
|
const liveElapsedMs = session.finishedAt
|
||||||
|
? session.elapsedMs
|
||||||
|
: Math.max(session.elapsedMs, now - session.startedAt)
|
||||||
|
return {
|
||||||
|
...session,
|
||||||
|
liveElapsedMs
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.liveElapsedMs - a.liveElapsedMs)
|
||||||
|
.slice(0, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDurationMs = (ms: number): string => {
|
||||||
|
const totalSeconds = Math.max(0, Math.floor(ms / 1000))
|
||||||
|
const hours = Math.floor(totalSeconds / 3600)
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||||
|
const seconds = totalSeconds % 60
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}小时${minutes}分${seconds}秒`
|
||||||
|
}
|
||||||
|
if (minutes > 0) {
|
||||||
|
return `${minutes}分${seconds}秒`
|
||||||
|
}
|
||||||
|
return `${seconds}秒`
|
||||||
|
}
|
||||||
|
|
||||||
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') {
|
||||||
@@ -570,6 +743,7 @@ function ExportPage() {
|
|||||||
const [isSnsStatsLoading, setIsSnsStatsLoading] = useState(true)
|
const [isSnsStatsLoading, setIsSnsStatsLoading] = useState(true)
|
||||||
const [isBaseConfigLoading, setIsBaseConfigLoading] = useState(true)
|
const [isBaseConfigLoading, setIsBaseConfigLoading] = useState(true)
|
||||||
const [isTaskCenterOpen, setIsTaskCenterOpen] = useState(false)
|
const [isTaskCenterOpen, setIsTaskCenterOpen] = useState(false)
|
||||||
|
const [expandedPerfTaskId, setExpandedPerfTaskId] = useState<string | null>(null)
|
||||||
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)
|
||||||
@@ -1022,6 +1196,14 @@ function ExportPage() {
|
|||||||
tasksRef.current = tasks
|
tasksRef.current = tasks
|
||||||
}, [tasks])
|
}, [tasks])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!expandedPerfTaskId) return
|
||||||
|
const target = tasks.find(task => task.id === expandedPerfTaskId)
|
||||||
|
if (!target || !isTextBatchTask(target)) {
|
||||||
|
setExpandedPerfTaskId(null)
|
||||||
|
}
|
||||||
|
}, [tasks, expandedPerfTaskId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
hasSeededSnsStatsRef.current = hasSeededSnsStats
|
hasSeededSnsStatsRef.current = hasSeededSnsStats
|
||||||
}, [hasSeededSnsStats])
|
}, [hasSeededSnsStats])
|
||||||
@@ -1044,6 +1226,14 @@ function ExportPage() {
|
|||||||
return () => clearInterval(timer)
|
return () => clearInterval(timer)
|
||||||
}, [isExportRoute])
|
}, [isExportRoute])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isTaskCenterOpen || !expandedPerfTaskId) return
|
||||||
|
const target = tasks.find(task => task.id === expandedPerfTaskId)
|
||||||
|
if (!target || target.status !== 'running' || !isTextBatchTask(target)) return
|
||||||
|
const timer = window.setInterval(() => setNowTick(Date.now()), 1000)
|
||||||
|
return () => window.clearInterval(timer)
|
||||||
|
}, [isTaskCenterOpen, expandedPerfTaskId, tasks])
|
||||||
|
|
||||||
const loadBaseConfig = useCallback(async (): Promise<boolean> => {
|
const loadBaseConfig = useCallback(async (): Promise<boolean> => {
|
||||||
setIsBaseConfigLoading(true)
|
setIsBaseConfigLoading(true)
|
||||||
let isReady = true
|
let isReady = true
|
||||||
@@ -1718,7 +1908,10 @@ function ExportPage() {
|
|||||||
controlState: undefined,
|
controlState: undefined,
|
||||||
startedAt: Date.now(),
|
startedAt: Date.now(),
|
||||||
finishedAt: undefined,
|
finishedAt: undefined,
|
||||||
error: undefined
|
error: undefined,
|
||||||
|
performance: isTextBatchTask(task)
|
||||||
|
? (task.performance || createEmptyTaskPerformance())
|
||||||
|
: task.performance
|
||||||
}))
|
}))
|
||||||
|
|
||||||
progressUnsubscribeRef.current?.()
|
progressUnsubscribeRef.current?.()
|
||||||
@@ -1732,6 +1925,7 @@ function ExportPage() {
|
|||||||
current: payload.current || 0,
|
current: payload.current || 0,
|
||||||
total: payload.total || 0,
|
total: payload.total || 0,
|
||||||
currentName: '',
|
currentName: '',
|
||||||
|
phase: 'exporting',
|
||||||
phaseLabel: payload.status || '',
|
phaseLabel: payload.status || '',
|
||||||
phaseProgress: payload.total > 0 ? payload.current : 0,
|
phaseProgress: payload.total > 0 ? payload.current : 0,
|
||||||
phaseTotal: payload.total || 0
|
phaseTotal: payload.total || 0
|
||||||
@@ -1743,16 +1937,20 @@ function ExportPage() {
|
|||||||
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
|
if (task.status !== 'running') return task
|
||||||
|
const now = Date.now()
|
||||||
|
const performance = applyProgressToTaskPerformance(task, payload, now)
|
||||||
return {
|
return {
|
||||||
...task,
|
...task,
|
||||||
progress: {
|
progress: {
|
||||||
current: payload.current,
|
current: payload.current,
|
||||||
total: payload.total,
|
total: payload.total,
|
||||||
currentName: payload.currentSession,
|
currentName: payload.currentSession,
|
||||||
|
phase: payload.phase,
|
||||||
phaseLabel: payload.phaseLabel || '',
|
phaseLabel: payload.phaseLabel || '',
|
||||||
phaseProgress: payload.phaseProgress || 0,
|
phaseProgress: payload.phaseProgress || 0,
|
||||||
phaseTotal: payload.phaseTotal || 0
|
phaseTotal: payload.phaseTotal || 0
|
||||||
}
|
},
|
||||||
|
performance
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -1776,7 +1974,8 @@ function ExportPage() {
|
|||||||
status: 'error',
|
status: 'error',
|
||||||
controlState: undefined,
|
controlState: undefined,
|
||||||
finishedAt: Date.now(),
|
finishedAt: Date.now(),
|
||||||
error: result.error || '朋友圈导出失败'
|
error: result.error || '朋友圈导出失败',
|
||||||
|
performance: finalizeTaskPerformance(task, Date.now())
|
||||||
}))
|
}))
|
||||||
} else if (result.stopped) {
|
} else if (result.stopped) {
|
||||||
updateTask(next.id, task => ({
|
updateTask(next.id, task => ({
|
||||||
@@ -1787,7 +1986,8 @@ function ExportPage() {
|
|||||||
progress: {
|
progress: {
|
||||||
...task.progress,
|
...task.progress,
|
||||||
phaseLabel: '已停止'
|
phaseLabel: '已停止'
|
||||||
}
|
},
|
||||||
|
performance: finalizeTaskPerformance(task, Date.now())
|
||||||
}))
|
}))
|
||||||
} else if (result.paused) {
|
} else if (result.paused) {
|
||||||
updateTask(next.id, task => ({
|
updateTask(next.id, task => ({
|
||||||
@@ -1798,7 +1998,8 @@ function ExportPage() {
|
|||||||
progress: {
|
progress: {
|
||||||
...task.progress,
|
...task.progress,
|
||||||
phaseLabel: '已暂停'
|
phaseLabel: '已暂停'
|
||||||
}
|
},
|
||||||
|
performance: finalizeTaskPerformance(task, Date.now())
|
||||||
}))
|
}))
|
||||||
} else {
|
} else {
|
||||||
const doneAt = Date.now()
|
const doneAt = Date.now()
|
||||||
@@ -1820,7 +2021,8 @@ function ExportPage() {
|
|||||||
phaseLabel: '完成',
|
phaseLabel: '完成',
|
||||||
phaseProgress: 1,
|
phaseProgress: 1,
|
||||||
phaseTotal: 1
|
phaseTotal: 1
|
||||||
}
|
},
|
||||||
|
performance: finalizeTaskPerformance(task, doneAt)
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -1841,7 +2043,8 @@ function ExportPage() {
|
|||||||
status: 'error',
|
status: 'error',
|
||||||
controlState: undefined,
|
controlState: undefined,
|
||||||
finishedAt: Date.now(),
|
finishedAt: Date.now(),
|
||||||
error: result.error || '导出失败'
|
error: result.error || '导出失败',
|
||||||
|
performance: finalizeTaskPerformance(task, Date.now())
|
||||||
}))
|
}))
|
||||||
} else {
|
} else {
|
||||||
const doneAt = Date.now()
|
const doneAt = Date.now()
|
||||||
@@ -1867,7 +2070,8 @@ function ExportPage() {
|
|||||||
current: result.successCount + result.failCount,
|
current: result.successCount + result.failCount,
|
||||||
total: task.progress.total || next.payload.sessionIds.length,
|
total: task.progress.total || next.payload.sessionIds.length,
|
||||||
phaseLabel: '已停止'
|
phaseLabel: '已停止'
|
||||||
}
|
},
|
||||||
|
performance: finalizeTaskPerformance(task, doneAt)
|
||||||
}))
|
}))
|
||||||
} else if (result.paused) {
|
} else if (result.paused) {
|
||||||
const pendingSessionIds = Array.isArray(result.pendingSessionIds)
|
const pendingSessionIds = Array.isArray(result.pendingSessionIds)
|
||||||
@@ -1892,7 +2096,8 @@ function ExportPage() {
|
|||||||
phaseLabel: '完成',
|
phaseLabel: '完成',
|
||||||
phaseProgress: 1,
|
phaseProgress: 1,
|
||||||
phaseTotal: 1
|
phaseTotal: 1
|
||||||
}
|
},
|
||||||
|
performance: finalizeTaskPerformance(task, doneAt)
|
||||||
}))
|
}))
|
||||||
} else {
|
} else {
|
||||||
updateTask(next.id, task => ({
|
updateTask(next.id, task => ({
|
||||||
@@ -1910,7 +2115,8 @@ function ExportPage() {
|
|||||||
current: result.successCount + result.failCount,
|
current: result.successCount + result.failCount,
|
||||||
total: task.progress.total || next.payload.sessionIds.length,
|
total: task.progress.total || next.payload.sessionIds.length,
|
||||||
phaseLabel: '已暂停'
|
phaseLabel: '已暂停'
|
||||||
}
|
},
|
||||||
|
performance: finalizeTaskPerformance(task, doneAt)
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -1926,18 +2132,21 @@ function ExportPage() {
|
|||||||
phaseLabel: '完成',
|
phaseLabel: '完成',
|
||||||
phaseProgress: 1,
|
phaseProgress: 1,
|
||||||
phaseTotal: 1
|
phaseTotal: 1
|
||||||
}
|
},
|
||||||
|
performance: finalizeTaskPerformance(task, doneAt)
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const doneAt = Date.now()
|
||||||
updateTask(next.id, task => ({
|
updateTask(next.id, task => ({
|
||||||
...task,
|
...task,
|
||||||
status: 'error',
|
status: 'error',
|
||||||
controlState: undefined,
|
controlState: undefined,
|
||||||
finishedAt: Date.now(),
|
finishedAt: doneAt,
|
||||||
error: String(error)
|
error: String(error),
|
||||||
|
performance: finalizeTaskPerformance(task, doneAt)
|
||||||
}))
|
}))
|
||||||
} finally {
|
} finally {
|
||||||
progressUnsubscribeRef.current?.()
|
progressUnsubscribeRef.current?.()
|
||||||
@@ -1966,7 +2175,8 @@ function ExportPage() {
|
|||||||
updateTask(taskId, task => ({
|
updateTask(taskId, task => ({
|
||||||
...task,
|
...task,
|
||||||
status: 'paused',
|
status: 'paused',
|
||||||
controlState: undefined
|
controlState: undefined,
|
||||||
|
performance: finalizeTaskPerformance(task, Date.now())
|
||||||
}))
|
}))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -2008,15 +2218,17 @@ function ExportPage() {
|
|||||||
if (!shouldStop) return
|
if (!shouldStop) return
|
||||||
|
|
||||||
if (target.status === 'queued' || target.status === 'paused') {
|
if (target.status === 'queued' || target.status === 'paused') {
|
||||||
|
const doneAt = Date.now()
|
||||||
updateTask(taskId, task => ({
|
updateTask(taskId, task => ({
|
||||||
...task,
|
...task,
|
||||||
status: 'stopped',
|
status: 'stopped',
|
||||||
controlState: undefined,
|
controlState: undefined,
|
||||||
finishedAt: Date.now(),
|
finishedAt: doneAt,
|
||||||
progress: {
|
progress: {
|
||||||
...task.progress,
|
...task.progress,
|
||||||
phaseLabel: '已停止'
|
phaseLabel: '已停止'
|
||||||
}
|
},
|
||||||
|
performance: finalizeTaskPerformance(task, doneAt)
|
||||||
}))
|
}))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -2073,7 +2285,10 @@ function ExportPage() {
|
|||||||
contentType: exportDialog.contentType,
|
contentType: exportDialog.contentType,
|
||||||
snsOptions
|
snsOptions
|
||||||
},
|
},
|
||||||
progress: createEmptyProgress()
|
progress: createEmptyProgress(),
|
||||||
|
performance: exportDialog.scope === 'content' && exportDialog.contentType === 'text'
|
||||||
|
? createEmptyTaskPerformance()
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
setTasks(prev => [task, ...prev])
|
setTasks(prev => [task, ...prev])
|
||||||
@@ -2798,7 +3013,10 @@ function ExportPage() {
|
|||||||
{isTaskCenterOpen && (
|
{isTaskCenterOpen && (
|
||||||
<div
|
<div
|
||||||
className="task-center-modal-overlay"
|
className="task-center-modal-overlay"
|
||||||
onClick={() => setIsTaskCenterOpen(false)}
|
onClick={() => {
|
||||||
|
setIsTaskCenterOpen(false)
|
||||||
|
setExpandedPerfTaskId(null)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="task-center-modal"
|
className="task-center-modal"
|
||||||
@@ -2815,7 +3033,10 @@ function ExportPage() {
|
|||||||
<button
|
<button
|
||||||
className="close-icon-btn"
|
className="close-icon-btn"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsTaskCenterOpen(false)}
|
onClick={() => {
|
||||||
|
setIsTaskCenterOpen(false)
|
||||||
|
setExpandedPerfTaskId(null)
|
||||||
|
}}
|
||||||
aria-label="关闭任务中心"
|
aria-label="关闭任务中心"
|
||||||
>
|
>
|
||||||
<X size={16} />
|
<X size={16} />
|
||||||
@@ -2826,68 +3047,139 @@ function ExportPage() {
|
|||||||
<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} ${task.controlState ? `request-${task.controlState}` : ''}`}>
|
const canShowPerfDetail = isTextBatchTask(task) && Boolean(task.performance)
|
||||||
<div className="task-main">
|
const isPerfExpanded = expandedPerfTaskId === task.id
|
||||||
<div className="task-title">{task.title}</div>
|
const stageTotals = canShowPerfDetail
|
||||||
<div className="task-meta">
|
? getTaskPerformanceStageTotals(task.performance, nowTick)
|
||||||
<span className={`task-status ${task.status}`}>{getTaskStatusLabel(task)}</span>
|
: null
|
||||||
<span>{new Date(task.createdAt).toLocaleString('zh-CN')}</span>
|
const stageTotalMs = stageTotals
|
||||||
|
? stageTotals.collect + stageTotals.build + stageTotals.write + stageTotals.other
|
||||||
|
: 0
|
||||||
|
const topSessions = isPerfExpanded
|
||||||
|
? getTaskPerformanceTopSessions(task.performance, nowTick, 5)
|
||||||
|
: []
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{canShowPerfDetail && stageTotals && (
|
||||||
|
<div className="task-perf-summary">
|
||||||
|
<span>累计耗时 {formatDurationMs(stageTotalMs)}</span>
|
||||||
|
{task.progress.total > 0 && (
|
||||||
|
<span>平均/会话 {formatDurationMs(Math.floor(stageTotalMs / Math.max(1, task.progress.total)))}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{canShowPerfDetail && isPerfExpanded && stageTotals && (
|
||||||
|
<div className="task-perf-panel">
|
||||||
|
<div className="task-perf-title">阶段耗时分布</div>
|
||||||
|
{[
|
||||||
|
{ key: 'collect' as const, label: '收集消息' },
|
||||||
|
{ key: 'build' as const, label: '构建消息' },
|
||||||
|
{ key: 'write' as const, label: '写入文件' },
|
||||||
|
{ key: 'other' as const, label: '其他' }
|
||||||
|
].map(item => {
|
||||||
|
const value = stageTotals[item.key]
|
||||||
|
const ratio = stageTotalMs > 0 ? Math.min(100, (value / stageTotalMs) * 100) : 0
|
||||||
|
return (
|
||||||
|
<div className="task-perf-row" key={item.key}>
|
||||||
|
<div className="task-perf-row-head">
|
||||||
|
<span>{item.label}</span>
|
||||||
|
<span>{formatDurationMs(value)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="task-perf-row-track">
|
||||||
|
<div className="task-perf-row-fill" style={{ width: `${ratio}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<div className="task-perf-title">最慢会话 Top5</div>
|
||||||
|
{topSessions.length === 0 ? (
|
||||||
|
<div className="task-perf-empty">暂无会话耗时数据</div>
|
||||||
|
) : (
|
||||||
|
<div className="task-perf-session-list">
|
||||||
|
{topSessions.map((session, index) => (
|
||||||
|
<div className="task-perf-session-item" key={session.sessionId}>
|
||||||
|
<span className="task-perf-session-rank">
|
||||||
|
{index + 1}. {session.sessionName || session.sessionId}
|
||||||
|
{!session.finishedAt ? '(进行中)' : ''}
|
||||||
|
</span>
|
||||||
|
<span className="task-perf-session-time">{formatDurationMs(session.liveElapsedMs)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{task.status === 'error' && <div className="task-error">{task.error || '任务失败'}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="task-actions">
|
||||||
|
{canShowPerfDetail && (
|
||||||
|
<button
|
||||||
|
className={`task-action-btn ${isPerfExpanded ? 'primary' : ''}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpandedPerfTaskId(prev => (prev === task.id ? null : task.id))}
|
||||||
|
>
|
||||||
|
{isPerfExpanded ? '收起详情' : '性能详情'}
|
||||||
|
</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)}>
|
||||||
|
<FolderOpen size={14} /> 目录
|
||||||
|
</button>
|
||||||
</div>
|
</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>
|
||||||
<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>
|
||||||
|
|||||||
Reference in New Issue
Block a user