feat(export): add sns stats card and conversation tab updates

This commit is contained in:
tisonhuang
2026-03-01 15:20:08 +08:00
parent e686bb6247
commit 596baad296
9 changed files with 491 additions and 188 deletions

View File

@@ -14,67 +14,10 @@
}
.export-top-panel {
display: grid;
grid-template-columns: minmax(260px, 380px) 1fr;
gap: 14px;
display: block;
flex-shrink: 0;
}
.current-user-box {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 14px;
padding: 14px;
display: flex;
align-items: center;
gap: 12px;
.avatar-wrap {
width: 48px;
height: 48px;
border-radius: 12px;
overflow: hidden;
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
display: flex;
align-items: center;
justify-content: center;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
span {
color: #fff;
font-size: 18px;
font-weight: 600;
}
}
.user-meta {
min-width: 0;
}
.user-name {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-wxid {
margin-top: 2px;
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.global-export-controls {
background: var(--card-bg);
border: 1px solid var(--border-color);
@@ -120,6 +63,7 @@
display: flex;
flex-direction: column;
gap: 6px;
z-index: 40;
}
.layout-trigger {
@@ -142,14 +86,16 @@
top: calc(100% + 6px);
left: 0;
right: 0;
background: var(--card-bg);
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 12px;
box-shadow: var(--shadow-md);
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.28);
padding: 6px;
z-index: 20;
z-index: 3000;
max-height: 260px;
overflow-y: auto;
opacity: 1;
backdrop-filter: none;
}
.layout-option {
@@ -188,7 +134,7 @@
.content-card-grid {
display: grid;
grid-template-columns: repeat(5, minmax(150px, 1fr));
grid-template-columns: repeat(6, minmax(150px, 1fr));
gap: 10px;
flex-shrink: 0;
}
@@ -397,6 +343,7 @@
.table-tabs {
display: flex;
gap: 8px;
flex-wrap: wrap;
.tab-btn {
border: 1px solid var(--border-color);
@@ -406,6 +353,9 @@
border-radius: 999px;
cursor: pointer;
font-size: 13px;
white-space: nowrap;
display: inline-flex;
align-items: center;
&.active {
border-color: var(--primary);

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useLocation } from 'react-router-dom'
import {
Aperture,
CheckSquare,
Download,
ExternalLink,
@@ -20,10 +21,11 @@ import type { ExportOptions as ElectronExportOptions, ExportProgress } from '../
import * as configService from '../services/config'
import './ExportPage.scss'
type ConversationTab = 'private' | 'group' | 'official'
type ConversationTab = 'private' | 'group' | 'official' | 'former_friend'
type TaskStatus = 'queued' | 'running' | 'success' | 'error'
type TaskScope = 'single' | 'multi' | 'content'
type TaskScope = 'single' | 'multi' | 'content' | 'sns'
type ContentType = 'text' | 'voice' | 'image' | 'video' | 'emoji'
type ContentCardType = ContentType | 'sns'
type SessionLayout = 'shared' | 'per-session'
@@ -80,10 +82,16 @@ interface TaskProgress {
interface ExportTaskPayload {
sessionIds: string[]
outputDir: string
options: ElectronExportOptions
options?: ElectronExportOptions
scope: TaskScope
contentType?: ContentType
sessionNames: string[]
snsOptions?: {
format: 'json' | 'html'
exportMedia?: boolean
startTime?: number
endTime?: number
}
}
interface ExportTask {
@@ -107,12 +115,6 @@ interface ExportDialogState {
title: string
}
interface CurrentUserProfile {
wxid: string
displayName: string
avatarUrl?: string
}
const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content']
const contentTypeLabels: Record<ContentType, string> = {
text: '聊天文本',
@@ -212,6 +214,7 @@ const parseDateInput = (value: string, endOfDay: boolean): Date => {
const toKindByContactType = (session: AppChatSession, contact?: ContactInfo): ConversationTab => {
if (session.username.endsWith('@chatroom')) return 'group'
if (contact?.type === 'official') return 'official'
if (contact?.type === 'former_friend') return 'former_friend'
return 'private'
}
@@ -242,8 +245,6 @@ function ExportPage() {
const [activeTab, setActiveTab] = useState<ConversationTab>('private')
const [selectedSessions, setSelectedSessions] = useState<Set<string>>(new Set())
const [currentUser, setCurrentUser] = useState<CurrentUserProfile>({ wxid: '', displayName: '未识别用户' })
const [exportFolder, setExportFolder] = useState('')
const [writeLayout, setWriteLayout] = useState<configService.ExportWriteLayout>('A')
const [showWriteLayoutSelect, setShowWriteLayoutSelect] = useState(false)
@@ -279,6 +280,11 @@ function ExportPage() {
const [tasks, setTasks] = useState<ExportTask[]>([])
const [lastExportBySession, setLastExportBySession] = useState<Record<string, number>>({})
const [lastExportByContent, setLastExportByContent] = useState<Record<string, number>>({})
const [lastSnsExportPostCount, setLastSnsExportPostCount] = useState(0)
const [snsStats, setSnsStats] = useState<{ totalPosts: number; totalFriends: number }>({
totalPosts: 0,
totalFriends: 0
})
const [nowTick, setNowTick] = useState(Date.now())
const progressUnsubscribeRef = useRef<(() => void) | null>(null)
@@ -308,32 +314,9 @@ function ExportPage() {
return () => clearInterval(timer)
}, [])
const loadCurrentUser = useCallback(async () => {
try {
const wxid = await configService.getMyWxid()
let displayName = wxid || '未识别用户'
let avatarUrl: string | undefined
if (wxid) {
const myContact = await window.electronAPI.chat.getContact(wxid)
const bestName = [myContact?.remark, myContact?.nickName, myContact?.alias, wxid].find(Boolean)
if (bestName) displayName = bestName
}
const avatarResult = await window.electronAPI.chat.getMyAvatarUrl()
if (avatarResult.success && avatarResult.avatarUrl) {
avatarUrl = avatarResult.avatarUrl
}
setCurrentUser({ wxid: wxid || '', displayName, avatarUrl })
} catch (error) {
console.error('加载当前用户信息失败:', error)
}
}, [])
const loadBaseConfig = useCallback(async () => {
try {
const [savedPath, savedFormat, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedWriteLayout, savedSessionMap, savedContentMap] = await Promise.all([
const [savedPath, savedFormat, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedWriteLayout, savedSessionMap, savedContentMap, savedSnsPostCount] = await Promise.all([
configService.getExportPath(),
configService.getExportDefaultFormat(),
configService.getExportDefaultMedia(),
@@ -343,7 +326,8 @@ function ExportPage() {
configService.getExportDefaultConcurrency(),
configService.getExportWriteLayout(),
configService.getExportLastSessionRunMap(),
configService.getExportLastContentRunMap()
configService.getExportLastContentRunMap(),
configService.getExportLastSnsPostCount()
])
if (savedPath) {
@@ -356,6 +340,7 @@ function ExportPage() {
setWriteLayout(savedWriteLayout)
setLastExportBySession(savedSessionMap)
setLastExportByContent(savedContentMap)
setLastSnsExportPostCount(savedSnsPostCount)
const txtColumns = savedTxtColumns && savedTxtColumns.length > 0 ? savedTxtColumns : defaultTxtColumns
setOptions(prev => ({
@@ -372,6 +357,20 @@ function ExportPage() {
}
}, [])
const loadSnsStats = useCallback(async () => {
try {
const result = await window.electronAPI.sns.getExportStats()
if (result.success && result.data) {
setSnsStats({
totalPosts: result.data.totalPosts || 0,
totalFriends: result.data.totalFriends || 0
})
}
} catch (error) {
console.error('加载朋友圈导出统计失败:', error)
}
}, [])
const loadSessions = useCallback(async () => {
setIsLoading(true)
try {
@@ -418,10 +417,10 @@ function ExportPage() {
}, [])
useEffect(() => {
loadCurrentUser()
loadBaseConfig()
loadSessions()
}, [loadCurrentUser, loadBaseConfig, loadSessions])
loadSnsStats()
}, [loadBaseConfig, loadSessions, loadSnsStats])
useEffect(() => {
preselectAppliedRef.current = false
@@ -541,6 +540,14 @@ function ExportPage() {
const openExportDialog = (payload: Omit<ExportDialogState, 'open'>) => {
setExportDialog({ open: true, ...payload })
if (payload.scope === 'sns') {
setOptions(prev => ({
...prev,
format: prev.format === 'json' || prev.format === 'html' ? prev.format : 'html'
}))
return
}
if (payload.scope === 'content' && payload.contentType) {
if (payload.contentType === 'text') {
setOptions(prev => ({ ...prev, exportMedia: false }))
@@ -613,6 +620,25 @@ function ExportPage() {
return base
}
const buildSnsExportOptions = () => {
const format: 'json' | 'html' = options.format === 'json' ? 'json' : 'html'
const dateRange = options.useAllTime
? null
: options.dateRange
? {
startTime: Math.floor(options.dateRange.start.getTime() / 1000),
endTime: Math.floor(options.dateRange.end.getTime() / 1000)
}
: null
return {
format,
exportMedia: options.exportMedia,
startTime: dateRange?.startTime,
endTime: dateRange?.endTime
}
}
const markSessionExported = useCallback((sessionIds: string[], timestamp: number) => {
setLastExportBySession(prev => {
const next = { ...prev }
@@ -663,56 +689,117 @@ function ExportPage() {
updateTask(next.id, task => ({ ...task, status: 'running', startedAt: Date.now() }))
progressUnsubscribeRef.current?.()
progressUnsubscribeRef.current = window.electronAPI.export.onProgress((payload: ExportProgress) => {
updateTask(next.id, task => ({
...task,
progress: {
current: payload.current,
total: payload.total,
currentName: payload.currentSession,
phaseLabel: payload.phaseLabel || '',
phaseProgress: payload.phaseProgress || 0,
phaseTotal: payload.phaseTotal || 0
}
}))
})
try {
const result = await window.electronAPI.export.exportSessions(
next.payload.sessionIds,
next.payload.outputDir,
next.payload.options
)
if (!result.success) {
if (next.payload.scope === 'sns') {
progressUnsubscribeRef.current = window.electronAPI.sns.onExportProgress((payload) => {
updateTask(next.id, task => ({
...task,
status: 'error',
finishedAt: Date.now(),
error: result.error || '导出失败'
}))
} else {
const doneAt = Date.now()
const contentTypes = next.payload.contentType
? [next.payload.contentType]
: inferContentTypesFromOptions(next.payload.options)
markSessionExported(next.payload.sessionIds, doneAt)
markContentExported(next.payload.sessionIds, contentTypes, doneAt)
updateTask(next.id, task => ({
...task,
status: 'success',
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
current: payload.current || 0,
total: payload.total || 0,
currentName: '',
phaseLabel: payload.status || '',
phaseProgress: payload.total > 0 ? payload.current : 0,
phaseTotal: payload.total || 0
}
}))
})
} else {
progressUnsubscribeRef.current = window.electronAPI.export.onProgress((payload: ExportProgress) => {
updateTask(next.id, task => ({
...task,
progress: {
current: payload.current,
total: payload.total,
currentName: payload.currentSession,
phaseLabel: payload.phaseLabel || '',
phaseProgress: payload.phaseProgress || 0,
phaseTotal: payload.phaseTotal || 0
}
}))
})
}
try {
if (next.payload.scope === 'sns') {
const snsOptions = next.payload.snsOptions || { format: 'html' as const, exportMedia: false }
const result = await window.electronAPI.sns.exportTimeline({
outputDir: next.payload.outputDir,
format: snsOptions.format,
exportMedia: snsOptions.exportMedia,
startTime: snsOptions.startTime,
endTime: snsOptions.endTime
})
if (!result.success) {
updateTask(next.id, task => ({
...task,
status: 'error',
finishedAt: Date.now(),
error: result.error || '朋友圈导出失败'
}))
} else {
const doneAt = Date.now()
const exportedPosts = Math.max(0, result.postCount || 0)
const mergedExportedCount = Math.max(lastSnsExportPostCount, exportedPosts)
setLastSnsExportPostCount(mergedExportedCount)
await configService.setExportLastSnsPostCount(mergedExportedCount)
await loadSnsStats()
updateTask(next.id, task => ({
...task,
status: 'success',
finishedAt: doneAt,
progress: {
...task.progress,
current: exportedPosts,
total: exportedPosts,
phaseLabel: '完成',
phaseProgress: 1,
phaseTotal: 1
}
}))
}
} else {
if (!next.payload.options) {
throw new Error('导出参数缺失')
}
const result = await window.electronAPI.export.exportSessions(
next.payload.sessionIds,
next.payload.outputDir,
next.payload.options
)
if (!result.success) {
updateTask(next.id, task => ({
...task,
status: 'error',
finishedAt: Date.now(),
error: result.error || '导出失败'
}))
} else {
const doneAt = Date.now()
const contentTypes = next.payload.contentType
? [next.payload.contentType]
: inferContentTypesFromOptions(next.payload.options)
markSessionExported(next.payload.sessionIds, doneAt)
markContentExported(next.payload.sessionIds, contentTypes, doneAt)
updateTask(next.id, task => ({
...task,
status: 'success',
finishedAt: doneAt,
progress: {
...task.progress,
current: task.progress.total || next.payload.sessionIds.length,
total: task.progress.total || next.payload.sessionIds.length,
phaseLabel: '完成',
phaseProgress: 1,
phaseTotal: 1
}
}))
}
}
} catch (error) {
updateTask(next.id, task => ({
@@ -727,7 +814,7 @@ function ExportPage() {
runningTaskIdRef.current = null
void runNextTask()
}
}, [updateTask, markSessionExported, markContentExported])
}, [updateTask, markSessionExported, markContentExported, loadSnsStats, lastSnsExportPostCount])
useEffect(() => {
void runNextTask()
@@ -741,15 +828,23 @@ function ExportPage() {
}, [])
const createTask = async () => {
if (!exportDialog.open || exportDialog.sessionIds.length === 0 || !exportFolder) return
if (!exportDialog.open || !exportFolder) return
if (exportDialog.scope !== 'sns' && exportDialog.sessionIds.length === 0) return
const exportOptions = buildExportOptions(exportDialog.scope, exportDialog.contentType)
const exportOptions = exportDialog.scope === 'sns'
? undefined
: buildExportOptions(exportDialog.scope, exportDialog.contentType)
const snsOptions = exportDialog.scope === 'sns'
? buildSnsExportOptions()
: undefined
const title =
exportDialog.scope === 'single'
? `${exportDialog.sessionNames[0] || '会话'} 导出`
: exportDialog.scope === 'multi'
? `批量导出(${exportDialog.sessionIds.length} 个会话)`
: `${contentTypeLabels[exportDialog.contentType || 'text']}批量导出`
: exportDialog.scope === 'sns'
? '朋友圈批量导出'
: `${contentTypeLabels[exportDialog.contentType || 'text']}批量导出`
const task: ExportTask = {
id: createTaskId(),
@@ -762,7 +857,8 @@ function ExportPage() {
outputDir: exportFolder,
options: exportOptions,
scope: exportDialog.scope,
contentType: exportDialog.contentType
contentType: exportDialog.contentType,
snsOptions
},
progress: createEmptyProgress()
}
@@ -819,6 +915,15 @@ function ExportPage() {
})
}
const openSnsExport = () => {
openExportDialog({
scope: 'sns',
sessionIds: [],
sessionNames: ['全部朋友圈动态'],
title: '朋友圈批量导出'
})
}
const runningSessionIds = useMemo(() => {
const set = new Set<string>()
for (const task of tasks) {
@@ -841,11 +946,25 @@ function ExportPage() {
return set
}, [tasks])
const tabCounts = useMemo(() => {
const counts: Record<ConversationTab, number> = {
private: 0,
group: 0,
official: 0,
former_friend: 0
}
for (const session of sessions) {
counts[session.kind] += 1
}
return counts
}, [sessions])
const contentCards = useMemo(() => {
const scopeSessions = sessions.filter(session => session.kind === 'private' || session.kind === 'group')
const total = scopeSessions.length
const totalSessions = scopeSessions.length
const snsExportedCount = Math.min(lastSnsExportPostCount, snsStats.totalPosts)
return [
const sessionCards = [
{ type: 'text' as ContentType, icon: MessageSquareText },
{ type: 'voice' as ContentType, icon: Mic },
{ type: 'image' as ContentType, icon: ImageIcon },
@@ -862,15 +981,31 @@ function ExportPage() {
return {
...item,
label: contentTypeLabels[item.type],
total,
exported
stats: [
{ label: '总会话数', value: totalSessions },
{ label: '已导出会话数', value: exported }
]
}
})
}, [sessions, lastExportByContent])
const snsCard = {
type: 'sns' as ContentCardType,
icon: Aperture,
label: '朋友圈',
stats: [
{ label: '朋友圈条数', value: snsStats.totalPosts },
{ label: '好友数', value: snsStats.totalFriends },
{ label: '已导出朋友圈条数', value: snsExportedCount }
]
}
return [...sessionCards, snsCard]
}, [sessions, lastExportByContent, snsStats, lastSnsExportPostCount])
const activeTabLabel = useMemo(() => {
if (activeTab === 'private') return '私聊'
if (activeTab === 'group') return '群聊'
if (activeTab === 'former_friend') return '曾经的好友'
return '公众号'
}, [activeTab])
@@ -913,7 +1048,7 @@ function ExportPage() {
}
const renderTableHeader = () => {
if (activeTab === 'private') {
if (activeTab === 'private' || activeTab === 'former_friend') {
return (
<tr>
<th className="sticky-col"></th>
@@ -991,7 +1126,7 @@ function ExportPage() {
<td>{valueOrDash(metrics.videoMessages)}</td>
<td>{valueOrDash(metrics.emojiMessages)}</td>
{activeTab === 'private' && (
{(activeTab === 'private' || activeTab === 'former_friend') && (
<>
<td>{valueOrDash(metrics.privateMutualGroups)}</td>
<td>{timestampOrDash(metrics.firstTimestamp)}</td>
@@ -1032,20 +1167,27 @@ function ExportPage() {
}, [visibleSessions, selectedSessions])
const writeLayoutLabel = writeLayoutOptions.find(option => option.value === writeLayout)?.label || 'A类型分目录'
const tableColSpan = activeTab === 'group' ? 14 : (activeTab === 'private' || activeTab === 'former_friend' ? 11 : 10)
const canCreateTask = exportDialog.scope === 'sns'
? Boolean(exportFolder)
: Boolean(exportFolder) && exportDialog.sessionIds.length > 0
const scopeLabel = exportDialog.scope === 'single'
? '单会话'
: exportDialog.scope === 'multi'
? '多会话'
: exportDialog.scope === 'sns'
? '朋友圈批量'
: `按内容批量(${contentTypeLabels[exportDialog.contentType || 'text']}`
const scopeCountLabel = exportDialog.scope === 'sns'
? `${snsStats.totalPosts} 条朋友圈动态`
: `${exportDialog.sessionIds.length} 个会话`
const formatCandidateOptions = exportDialog.scope === 'sns'
? formatOptions.filter(option => option.value === 'html' || option.value === 'json')
: formatOptions
return (
<div className="export-board-page">
<div className="export-top-panel">
<div className="current-user-box">
<div className="avatar-wrap">
{currentUser.avatarUrl ? <img src={currentUser.avatarUrl} alt="" /> : <span>{getAvatarLetter(currentUser.displayName)}</span>}
</div>
<div className="user-meta">
<div className="user-name">{currentUser.displayName}</div>
<div className="user-wxid">{currentUser.wxid || 'wxid 未识别'}</div>
</div>
</div>
<div className="global-export-controls">
<div className="path-control">
<span className="control-label"></span>
@@ -1074,7 +1216,7 @@ function ExportPage() {
</div>
<div className="write-layout-control">
<span className="control-label"></span>
<span className="control-label"></span>
<button className="layout-trigger" onClick={() => setShowWriteLayoutSelect(prev => !prev)}>
{writeLayoutLabel}
</button>
@@ -1109,16 +1251,25 @@ function ExportPage() {
<div className="card-title"><Icon size={16} /> {card.label}</div>
</div>
<div className="card-stats">
<div className="stat-item">
<span></span>
<strong>{card.total}</strong>
</div>
<div className="stat-item">
<span></span>
<strong>{card.exported}</strong>
</div>
{card.stats.map((stat) => (
<div key={stat.label} className="stat-item">
<span>{stat.label}</span>
<strong>{stat.value.toLocaleString()}</strong>
</div>
))}
</div>
<button className="card-export-btn" onClick={() => openContentExport(card.type)}></button>
<button
className="card-export-btn"
onClick={() => {
if (card.type === 'sns') {
openSnsExport()
return
}
openContentExport(card.type)
}}
>
</button>
</div>
)
})}
@@ -1147,7 +1298,9 @@ function ExportPage() {
/>
</div>
<div className="task-progress-text">
{task.progress.current} / {task.progress.total || task.payload.sessionIds.length}
{task.progress.total > 0
? `${task.progress.current} / ${task.progress.total}`
: '处理中'}
{task.progress.phaseLabel ? ` · ${task.progress.phaseLabel}` : ''}
</div>
</>
@@ -1168,9 +1321,18 @@ function ExportPage() {
<div className="session-table-section">
<div className="table-toolbar">
<div className="table-tabs" role="tablist" aria-label="会话类型">
<button className={`tab-btn ${activeTab === 'private' ? 'active' : ''}`} onClick={() => setActiveTab('private')}></button>
<button className={`tab-btn ${activeTab === 'group' ? 'active' : ''}`} onClick={() => setActiveTab('group')}></button>
<button className={`tab-btn ${activeTab === 'official' ? 'active' : ''}`} onClick={() => setActiveTab('official')}></button>
<button className={`tab-btn ${activeTab === 'private' ? 'active' : ''}`} onClick={() => setActiveTab('private')}>
{tabCounts.private}
</button>
<button className={`tab-btn ${activeTab === 'group' ? 'active' : ''}`} onClick={() => setActiveTab('group')}>
{tabCounts.group}
</button>
<button className={`tab-btn ${activeTab === 'official' ? 'active' : ''}`} onClick={() => setActiveTab('official')}>
{tabCounts.official}
</button>
<button className={`tab-btn ${activeTab === 'former_friend' ? 'active' : ''}`} onClick={() => setActiveTab('former_friend')}>
{tabCounts.former_friend}
</button>
</div>
<div className="toolbar-actions">
@@ -1210,13 +1372,13 @@ function ExportPage() {
<tbody>
{isLoading ? (
<tr>
<td colSpan={activeTab === 'group' ? 14 : activeTab === 'private' ? 11 : 10}>
<td colSpan={tableColSpan}>
<div className="table-state"><Loader2 size={16} className="spin" />...</div>
</td>
</tr>
) : visibleSessions.length === 0 ? (
<tr>
<td colSpan={activeTab === 'group' ? 14 : activeTab === 'private' ? 11 : 10}>
<td colSpan={tableColSpan}>
<div className="table-state"></div>
</td>
</tr>
@@ -1239,8 +1401,8 @@ function ExportPage() {
<div className="dialog-section">
<h4></h4>
<div className="scope-tag-row">
<span className="scope-tag">{exportDialog.scope === 'single' ? '单会话' : exportDialog.scope === 'multi' ? '多会话' : `按内容批量(${contentTypeLabels[exportDialog.contentType || 'text']}`}</span>
<span className="scope-count"> {exportDialog.sessionIds.length} </span>
<span className="scope-tag">{scopeLabel}</span>
<span className="scope-count">{scopeCountLabel}</span>
</div>
<div className="scope-list">
{exportDialog.sessionNames.slice(0, 20).map(name => (
@@ -1253,7 +1415,7 @@ function ExportPage() {
<div className="dialog-section">
<h4></h4>
<div className="format-grid">
{formatOptions.map(option => (
{formatCandidateOptions.map(option => (
<button
key={option.value}
className={`format-card ${options.format === option.value ? 'active' : ''}`}
@@ -1363,7 +1525,7 @@ function ExportPage() {
<div className="dialog-actions">
<button className="secondary-btn" onClick={closeExportDialog}></button>
<button className="primary-btn" onClick={() => void createTask()} disabled={!exportFolder || exportDialog.sessionIds.length === 0}>
<button className="primary-btn" onClick={() => void createTask()} disabled={!canCreateTask}>
<Download size={14} />
</button>
</div>