mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 15:25:50 +00:00
perf(export): optimize task center modal responsiveness
This commit is contained in:
@@ -362,6 +362,10 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--primary-hover);
|
background: var(--primary-hover);
|
||||||
@@ -374,6 +378,7 @@
|
|||||||
|
|
||||||
&.running {
|
&.running {
|
||||||
background: var(--primary-hover);
|
background: var(--primary-hover);
|
||||||
|
opacity: 0.65;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -801,6 +801,182 @@ const WriteLayoutSelector = memo(function WriteLayoutSelector({
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
interface TaskCenterModalProps {
|
||||||
|
isOpen: boolean
|
||||||
|
tasks: ExportTask[]
|
||||||
|
taskRunningCount: number
|
||||||
|
taskQueuedCount: number
|
||||||
|
expandedPerfTaskId: string | null
|
||||||
|
nowTick: number
|
||||||
|
onClose: () => void
|
||||||
|
onTogglePerfTask: (taskId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const TaskCenterModal = memo(function TaskCenterModal({
|
||||||
|
isOpen,
|
||||||
|
tasks,
|
||||||
|
taskRunningCount,
|
||||||
|
taskQueuedCount,
|
||||||
|
expandedPerfTaskId,
|
||||||
|
nowTick,
|
||||||
|
onClose,
|
||||||
|
onTogglePerfTask
|
||||||
|
}: TaskCenterModalProps) {
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="task-center-modal-overlay"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="task-center-modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="任务中心"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="task-center-modal-header">
|
||||||
|
<div className="task-center-modal-title">
|
||||||
|
<h3>任务中心</h3>
|
||||||
|
<span>进行中 {taskRunningCount} · 排队 {taskQueuedCount} · 总计 {tasks.length}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="close-icon-btn"
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="关闭任务中心"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="task-center-modal-body">
|
||||||
|
{tasks.length === 0 ? (
|
||||||
|
<div className="task-empty">暂无任务。点击会话导出或卡片导出后会在这里创建任务。</div>
|
||||||
|
) : (
|
||||||
|
<div className="task-list">
|
||||||
|
{tasks.map(task => {
|
||||||
|
const canShowPerfDetail = isTextBatchTask(task) && Boolean(task.performance)
|
||||||
|
const isPerfExpanded = expandedPerfTaskId === task.id
|
||||||
|
const stageTotals = canShowPerfDetail
|
||||||
|
? getTaskPerformanceStageTotals(task.performance, nowTick)
|
||||||
|
: null
|
||||||
|
const stageTotalMs = stageTotals
|
||||||
|
? stageTotals.collect + stageTotals.build + stageTotals.write + stageTotals.other
|
||||||
|
: 0
|
||||||
|
const topSessions = isPerfExpanded
|
||||||
|
? getTaskPerformanceTopSessions(task.performance, nowTick, 5)
|
||||||
|
: []
|
||||||
|
const normalizedProgressTotal = task.progress.total > 0 ? task.progress.total : 0
|
||||||
|
const normalizedProgressCurrent = normalizedProgressTotal > 0
|
||||||
|
? Math.max(0, Math.min(normalizedProgressTotal, task.progress.current))
|
||||||
|
: 0
|
||||||
|
const currentSessionRatio = task.progress.phaseTotal > 0
|
||||||
|
? Math.max(0, Math.min(1, task.progress.phaseProgress / task.progress.phaseTotal))
|
||||||
|
: null
|
||||||
|
return (
|
||||||
|
<div key={task.id} className={`task-card ${task.status}`}>
|
||||||
|
<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' && (
|
||||||
|
<>
|
||||||
|
<div className="task-progress-bar">
|
||||||
|
<div
|
||||||
|
className="task-progress-fill"
|
||||||
|
style={{ width: `${normalizedProgressTotal > 0 ? (normalizedProgressCurrent / normalizedProgressTotal) * 100 : 0}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="task-progress-text">
|
||||||
|
{normalizedProgressTotal > 0
|
||||||
|
? `${Math.floor(normalizedProgressCurrent)} / ${normalizedProgressTotal}`
|
||||||
|
: '处理中'}
|
||||||
|
{task.status === 'running' && currentSessionRatio !== null
|
||||||
|
? `(当前会话 ${Math.round(currentSessionRatio * 100)}%)`
|
||||||
|
: ''}
|
||||||
|
{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={() => onTogglePerfTask(task.id)}
|
||||||
|
>
|
||||||
|
{isPerfExpanded ? '收起详情' : '性能详情'}
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
function ExportPage() {
|
function ExportPage() {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const isExportRoute = location.pathname === '/export'
|
const isExportRoute = location.pathname === '/export'
|
||||||
@@ -2480,7 +2656,7 @@ function ExportPage() {
|
|||||||
await configService.setExportDefaultConcurrency(options.exportConcurrency)
|
await configService.setExportDefaultConcurrency(options.exportConcurrency)
|
||||||
}
|
}
|
||||||
|
|
||||||
const openSingleExport = (session: SessionRow) => {
|
const openSingleExport = useCallback((session: SessionRow) => {
|
||||||
if (!session.hasSession) return
|
if (!session.hasSession) return
|
||||||
openExportDialog({
|
openExportDialog({
|
||||||
scope: 'single',
|
scope: 'single',
|
||||||
@@ -2488,7 +2664,7 @@ function ExportPage() {
|
|||||||
sessionNames: [session.displayName || session.username],
|
sessionNames: [session.displayName || session.username],
|
||||||
title: `导出会话:${session.displayName || session.username}`
|
title: `导出会话:${session.displayName || session.username}`
|
||||||
})
|
})
|
||||||
}
|
}, [openExportDialog])
|
||||||
|
|
||||||
const resolveSessionExistingMessageCount = useCallback((session: SessionRow): number => {
|
const resolveSessionExistingMessageCount = useCallback((session: SessionRow): number => {
|
||||||
const counted = normalizeMessageCount(sessionMessageCounts[session.username])
|
const counted = normalizeMessageCount(sessionMessageCounts[session.username])
|
||||||
@@ -3314,6 +3490,111 @@ function ExportPage() {
|
|||||||
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 showInitialSkeleton = isLoading && sessions.length === 0
|
const showInitialSkeleton = isLoading && sessions.length === 0
|
||||||
|
const closeTaskCenter = useCallback(() => {
|
||||||
|
setIsTaskCenterOpen(false)
|
||||||
|
setExpandedPerfTaskId(null)
|
||||||
|
}, [])
|
||||||
|
const toggleTaskPerfDetail = useCallback((taskId: string) => {
|
||||||
|
setExpandedPerfTaskId(prev => (prev === taskId ? null : taskId))
|
||||||
|
}, [])
|
||||||
|
const contactsListRows = useMemo(() => (
|
||||||
|
filteredContacts.map((contact) => {
|
||||||
|
const matchedSession = sessionRowByUsername.get(contact.username)
|
||||||
|
const canExport = Boolean(matchedSession?.hasSession)
|
||||||
|
const isRunning = canExport && runningSessionIds.has(contact.username)
|
||||||
|
const isQueued = canExport && queuedSessionIds.has(contact.username)
|
||||||
|
const recent = canExport ? formatRecentExportTime(lastExportBySession[contact.username], nowTick) : ''
|
||||||
|
const countedMessages = normalizeMessageCount(sessionMessageCounts[contact.username])
|
||||||
|
const hintedMessages = normalizeMessageCount(matchedSession?.messageCountHint)
|
||||||
|
const displayedMessageCount = countedMessages ?? hintedMessages
|
||||||
|
const messageCountLabel = !canExport
|
||||||
|
? '--'
|
||||||
|
: typeof displayedMessageCount === 'number'
|
||||||
|
? displayedMessageCount.toLocaleString('zh-CN')
|
||||||
|
: (isLoadingSessionCounts ? '统计中…' : '--')
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={contact.username}
|
||||||
|
className="contact-row"
|
||||||
|
>
|
||||||
|
<div className="contact-item">
|
||||||
|
<div className="contact-avatar">
|
||||||
|
{contact.avatarUrl ? (
|
||||||
|
<img src={contact.avatarUrl} alt="" loading="lazy" />
|
||||||
|
) : (
|
||||||
|
<span>{getAvatarLetter(contact.displayName)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="contact-info">
|
||||||
|
<div className="contact-name">{contact.displayName}</div>
|
||||||
|
<div className="contact-remark">{contact.username}</div>
|
||||||
|
</div>
|
||||||
|
<div className="row-message-count">
|
||||||
|
<div className="row-message-stats">
|
||||||
|
<strong className={`row-message-count-value ${typeof displayedMessageCount === 'number' ? '' : 'muted'}`}>
|
||||||
|
{messageCountLabel}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="row-action-cell">
|
||||||
|
<div className="row-action-main">
|
||||||
|
<button
|
||||||
|
className="row-open-chat-btn"
|
||||||
|
disabled={!canExport}
|
||||||
|
title={canExport ? '在新窗口打开该会话' : '该联系人暂无会话记录'}
|
||||||
|
onClick={() => {
|
||||||
|
if (!canExport) return
|
||||||
|
void window.electronAPI.window.openSessionChatWindow(contact.username)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ExternalLink size={13} />
|
||||||
|
打开对话
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`row-detail-btn ${showSessionDetailPanel && sessionDetail?.wxid === contact.username ? 'active' : ''}`}
|
||||||
|
onClick={() => openSessionDetail(contact.username)}
|
||||||
|
>
|
||||||
|
详情
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`row-export-btn ${isRunning ? 'running' : ''} ${!canExport ? 'no-session' : ''}`}
|
||||||
|
disabled={!canExport || isRunning}
|
||||||
|
onClick={() => {
|
||||||
|
if (!matchedSession || !matchedSession.hasSession) return
|
||||||
|
openSingleExport({
|
||||||
|
...matchedSession,
|
||||||
|
displayName: contact.displayName || matchedSession.displayName || matchedSession.username
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isRunning ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={14} className="spin" />
|
||||||
|
导出中
|
||||||
|
</>
|
||||||
|
) : !canExport ? '暂无会话' : isQueued ? '排队中' : '单会话导出'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{recent && <span className="row-export-time">{recent}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
), [
|
||||||
|
filteredContacts,
|
||||||
|
isLoadingSessionCounts,
|
||||||
|
lastExportBySession,
|
||||||
|
nowTick,
|
||||||
|
openSessionDetail,
|
||||||
|
openSingleExport,
|
||||||
|
queuedSessionIds,
|
||||||
|
runningSessionIds,
|
||||||
|
sessionDetail?.wxid,
|
||||||
|
sessionMessageCounts,
|
||||||
|
sessionRowByUsername,
|
||||||
|
showSessionDetailPanel
|
||||||
|
])
|
||||||
const chooseExportFolder = useCallback(async () => {
|
const chooseExportFolder = useCallback(async () => {
|
||||||
const result = await window.electronAPI.dialog.openFile({
|
const result = await window.electronAPI.dialog.openFile({
|
||||||
title: '选择导出目录',
|
title: '选择导出目录',
|
||||||
@@ -3381,163 +3662,16 @@ function ExportPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isTaskCenterOpen && (
|
<TaskCenterModal
|
||||||
<div
|
isOpen={isTaskCenterOpen}
|
||||||
className="task-center-modal-overlay"
|
tasks={tasks}
|
||||||
onClick={() => {
|
taskRunningCount={taskRunningCount}
|
||||||
setIsTaskCenterOpen(false)
|
taskQueuedCount={taskQueuedCount}
|
||||||
setExpandedPerfTaskId(null)
|
expandedPerfTaskId={expandedPerfTaskId}
|
||||||
}}
|
nowTick={nowTick}
|
||||||
>
|
onClose={closeTaskCenter}
|
||||||
<div
|
onTogglePerfTask={toggleTaskPerfDetail}
|
||||||
className="task-center-modal"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label="任务中心"
|
|
||||||
onClick={(event) => event.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="task-center-modal-header">
|
|
||||||
<div className="task-center-modal-title">
|
|
||||||
<h3>任务中心</h3>
|
|
||||||
<span>进行中 {taskRunningCount} · 排队 {taskQueuedCount} · 总计 {tasks.length}</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className="close-icon-btn"
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setIsTaskCenterOpen(false)
|
|
||||||
setExpandedPerfTaskId(null)
|
|
||||||
}}
|
|
||||||
aria-label="关闭任务中心"
|
|
||||||
>
|
|
||||||
<X size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="task-center-modal-body">
|
|
||||||
{tasks.length === 0 ? (
|
|
||||||
<div className="task-empty">暂无任务。点击会话导出或卡片导出后会在这里创建任务。</div>
|
|
||||||
) : (
|
|
||||||
<div className="task-list">
|
|
||||||
{tasks.map(task => {
|
|
||||||
const canShowPerfDetail = isTextBatchTask(task) && Boolean(task.performance)
|
|
||||||
const isPerfExpanded = expandedPerfTaskId === task.id
|
|
||||||
const stageTotals = canShowPerfDetail
|
|
||||||
? getTaskPerformanceStageTotals(task.performance, nowTick)
|
|
||||||
: null
|
|
||||||
const stageTotalMs = stageTotals
|
|
||||||
? stageTotals.collect + stageTotals.build + stageTotals.write + stageTotals.other
|
|
||||||
: 0
|
|
||||||
const topSessions = isPerfExpanded
|
|
||||||
? getTaskPerformanceTopSessions(task.performance, nowTick, 5)
|
|
||||||
: []
|
|
||||||
const normalizedProgressTotal = task.progress.total > 0 ? task.progress.total : 0
|
|
||||||
const normalizedProgressCurrent = normalizedProgressTotal > 0
|
|
||||||
? Math.max(0, Math.min(normalizedProgressTotal, task.progress.current))
|
|
||||||
: 0
|
|
||||||
const currentSessionRatio = task.progress.phaseTotal > 0
|
|
||||||
? Math.max(0, Math.min(1, task.progress.phaseProgress / task.progress.phaseTotal))
|
|
||||||
: null
|
|
||||||
return (
|
|
||||||
<div key={task.id} className={`task-card ${task.status}`}>
|
|
||||||
<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' && (
|
|
||||||
<>
|
|
||||||
<div className="task-progress-bar">
|
|
||||||
<div
|
|
||||||
className="task-progress-fill"
|
|
||||||
style={{ width: `${normalizedProgressTotal > 0 ? (normalizedProgressCurrent / normalizedProgressTotal) * 100 : 0}%` }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div className="task-progress-text">
|
|
||||||
{normalizedProgressTotal > 0
|
|
||||||
? `${Math.floor(normalizedProgressCurrent)} / ${normalizedProgressTotal}`
|
|
||||||
: '处理中'}
|
|
||||||
{task.status === 'running' && currentSessionRatio !== null
|
|
||||||
? `(当前会话 ${Math.round(currentSessionRatio * 100)}%)`
|
|
||||||
: ''}
|
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
<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 className="content-card-grid">
|
<div className="content-card-grid">
|
||||||
{contentCards.map(card => {
|
{contentCards.map(card => {
|
||||||
@@ -3585,7 +3719,12 @@ function ExportPage() {
|
|||||||
openContentExport(card.type)
|
openContentExport(card.type)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isCardRunning ? '批量导出中' : '批量导出'}
|
{isCardRunning ? (
|
||||||
|
<>
|
||||||
|
<span>批量导出中</span>
|
||||||
|
<Loader2 size={14} className="spin" />
|
||||||
|
</>
|
||||||
|
) : '批量导出'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -3714,89 +3853,7 @@ function ExportPage() {
|
|||||||
<span className="contacts-list-header-actions">操作</span>
|
<span className="contacts-list-header-actions">操作</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="contacts-list">
|
<div className="contacts-list">
|
||||||
{filteredContacts.map((contact) => {
|
{contactsListRows}
|
||||||
const matchedSession = sessionRowByUsername.get(contact.username)
|
|
||||||
const canExport = Boolean(matchedSession?.hasSession)
|
|
||||||
const isRunning = canExport && runningSessionIds.has(contact.username)
|
|
||||||
const isQueued = canExport && queuedSessionIds.has(contact.username)
|
|
||||||
const recent = canExport ? formatRecentExportTime(lastExportBySession[contact.username], nowTick) : ''
|
|
||||||
const countedMessages = normalizeMessageCount(sessionMessageCounts[contact.username])
|
|
||||||
const hintedMessages = normalizeMessageCount(matchedSession?.messageCountHint)
|
|
||||||
const displayedMessageCount = countedMessages ?? hintedMessages
|
|
||||||
const messageCountLabel = !canExport
|
|
||||||
? '--'
|
|
||||||
: typeof displayedMessageCount === 'number'
|
|
||||||
? displayedMessageCount.toLocaleString('zh-CN')
|
|
||||||
: (isLoadingSessionCounts ? '统计中…' : '--')
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={contact.username}
|
|
||||||
className="contact-row"
|
|
||||||
>
|
|
||||||
<div className="contact-item">
|
|
||||||
<div className="contact-avatar">
|
|
||||||
{contact.avatarUrl ? (
|
|
||||||
<img src={contact.avatarUrl} alt="" loading="lazy" />
|
|
||||||
) : (
|
|
||||||
<span>{getAvatarLetter(contact.displayName)}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="contact-info">
|
|
||||||
<div className="contact-name">{contact.displayName}</div>
|
|
||||||
<div className="contact-remark">{contact.username}</div>
|
|
||||||
</div>
|
|
||||||
<div className="row-message-count">
|
|
||||||
<div className="row-message-stats">
|
|
||||||
<strong className={`row-message-count-value ${typeof displayedMessageCount === 'number' ? '' : 'muted'}`}>
|
|
||||||
{messageCountLabel}
|
|
||||||
</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="row-action-cell">
|
|
||||||
<div className="row-action-main">
|
|
||||||
<button
|
|
||||||
className="row-open-chat-btn"
|
|
||||||
disabled={!canExport}
|
|
||||||
title={canExport ? '在新窗口打开该会话' : '该联系人暂无会话记录'}
|
|
||||||
onClick={() => {
|
|
||||||
if (!canExport) return
|
|
||||||
void window.electronAPI.window.openSessionChatWindow(contact.username)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ExternalLink size={13} />
|
|
||||||
打开对话
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`row-detail-btn ${showSessionDetailPanel && sessionDetail?.wxid === contact.username ? 'active' : ''}`}
|
|
||||||
onClick={() => openSessionDetail(contact.username)}
|
|
||||||
>
|
|
||||||
详情
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`row-export-btn ${isRunning ? 'running' : ''} ${!canExport ? 'no-session' : ''}`}
|
|
||||||
disabled={!canExport || isRunning}
|
|
||||||
onClick={() => {
|
|
||||||
if (!matchedSession || !matchedSession.hasSession) return
|
|
||||||
openSingleExport({
|
|
||||||
...matchedSession,
|
|
||||||
displayName: contact.displayName || matchedSession.displayName || matchedSession.username
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isRunning ? (
|
|
||||||
<>
|
|
||||||
<Loader2 size={14} className="spin" />
|
|
||||||
导出中
|
|
||||||
</>
|
|
||||||
) : !canExport ? '暂无会话' : isQueued ? '排队中' : '单会话导出'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{recent && <span className="row-export-time">{recent}</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user