import { useState, useEffect, useCallback } from 'react' import { Search, Download, FolderOpen, RefreshCw, Check, Calendar, FileJson, FileText, Table, Loader2, X, ChevronDown, ChevronLeft, ChevronRight, FileSpreadsheet, Database, FileCode, CheckCircle, XCircle, ExternalLink } from 'lucide-react' import * as configService from '../services/config' import './ExportPage.scss' interface ChatSession { username: string displayName?: string avatarUrl?: string summary: string lastTimestamp: number } interface ExportOptions { format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'sql' dateRange: { start: Date; end: Date } | null useAllTime: boolean exportAvatars: boolean exportMedia: boolean exportImages: boolean exportVoices: boolean exportEmojis: boolean exportVoiceAsText: boolean } interface ExportResult { success: boolean successCount?: number failCount?: number error?: string } function ExportPage() { const [sessions, setSessions] = useState([]) const [filteredSessions, setFilteredSessions] = useState([]) const [selectedSessions, setSelectedSessions] = useState>(new Set()) const [isLoading, setIsLoading] = useState(true) const [searchKeyword, setSearchKeyword] = useState('') const [exportFolder, setExportFolder] = useState('') const [isExporting, setIsExporting] = useState(false) const [exportProgress, setExportProgress] = useState({ current: 0, total: 0, currentName: '' }) const [exportResult, setExportResult] = useState(null) const [showDatePicker, setShowDatePicker] = useState(false) const [calendarDate, setCalendarDate] = useState(new Date()) const [selectingStart, setSelectingStart] = useState(true) const [options, setOptions] = useState({ format: 'chatlab', dateRange: { start: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), end: new Date() }, useAllTime: true, exportAvatars: true, exportMedia: false, exportImages: true, exportVoices: true, exportEmojis: true, exportVoiceAsText: false }) const loadSessions = useCallback(async () => { setIsLoading(true) try { const result = await window.electronAPI.chat.connect() if (!result.success) { console.error('连接失败:', result.error) setIsLoading(false) return } const sessionsResult = await window.electronAPI.chat.getSessions() if (sessionsResult.success && sessionsResult.sessions) { setSessions(sessionsResult.sessions) setFilteredSessions(sessionsResult.sessions) } } catch (e) { console.error('加载会话失败:', e) } finally { setIsLoading(false) } }, []) const loadExportPath = useCallback(async () => { try { const savedPath = await configService.getExportPath() if (savedPath) { setExportFolder(savedPath) } else { const downloadsPath = await window.electronAPI.app.getDownloadsPath() setExportFolder(downloadsPath) } } catch (e) { console.error('加载导出路径失败:', e) } }, []) useEffect(() => { loadSessions() loadExportPath() }, [loadSessions, loadExportPath]) useEffect(() => { if (!searchKeyword.trim()) { setFilteredSessions(sessions) return } const lower = searchKeyword.toLowerCase() setFilteredSessions(sessions.filter(s => s.displayName?.toLowerCase().includes(lower) || s.username.toLowerCase().includes(lower) )) }, [searchKeyword, sessions]) const toggleSession = (username: string) => { const newSet = new Set(selectedSessions) if (newSet.has(username)) { newSet.delete(username) } else { newSet.add(username) } setSelectedSessions(newSet) } const toggleSelectAll = () => { if (selectedSessions.size === filteredSessions.length) { setSelectedSessions(new Set()) } else { setSelectedSessions(new Set(filteredSessions.map(s => s.username))) } } const getAvatarLetter = (name: string) => { if (!name) return '?' return [...name][0] || '?' } const formatDate = (date: Date) => { return date.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }) } const openExportFolder = async () => { if (exportFolder) { await window.electronAPI.shell.openPath(exportFolder) } } const startExport = async () => { if (selectedSessions.size === 0 || !exportFolder) return setIsExporting(true) setExportProgress({ current: 0, total: selectedSessions.size, currentName: '' }) setExportResult(null) try { const sessionList = Array.from(selectedSessions) const exportOptions = { format: options.format, exportAvatars: options.exportAvatars, exportMedia: options.exportMedia, exportImages: options.exportMedia && options.exportImages, exportVoices: options.exportMedia && options.exportVoices, exportEmojis: options.exportMedia && options.exportEmojis, exportVoiceAsText: options.exportVoiceAsText, // 独立于 exportMedia dateRange: options.useAllTime ? null : options.dateRange ? { start: Math.floor(options.dateRange.start.getTime() / 1000), // 将结束日期设置为当天的 23:59:59,以包含当天的所有消息 end: Math.floor(new Date(options.dateRange.end.getFullYear(), options.dateRange.end.getMonth(), options.dateRange.end.getDate(), 23, 59, 59).getTime() / 1000) } : null } if (options.format === 'chatlab' || options.format === 'chatlab-jsonl' || options.format === 'json' || options.format === 'excel') { const result = await window.electronAPI.export.exportSessions( sessionList, exportFolder, exportOptions ) setExportResult(result) } else { setExportResult({ success: false, error: `${options.format.toUpperCase()} 格式导出功能开发中...` }) } } catch (e) { console.error('导出失败:', e) setExportResult({ success: false, error: String(e) }) } finally { setIsExporting(false) } } const getDaysInMonth = (date: Date) => { const year = date.getFullYear() const month = date.getMonth() return new Date(year, month + 1, 0).getDate() } const getFirstDayOfMonth = (date: Date) => { const year = date.getFullYear() const month = date.getMonth() return new Date(year, month, 1).getDay() } const generateCalendar = () => { const daysInMonth = getDaysInMonth(calendarDate) const firstDay = getFirstDayOfMonth(calendarDate) const days: (number | null)[] = [] for (let i = 0; i < firstDay; i++) { days.push(null) } for (let i = 1; i <= daysInMonth; i++) { days.push(i) } return days } const handleDateSelect = (day: number) => { const year = calendarDate.getFullYear() const month = calendarDate.getMonth() const selectedDate = new Date(year, month, day) // 设置时间为当天的开始或结束 selectedDate.setHours(selectingStart ? 0 : 23, selectingStart ? 0 : 59, selectingStart ? 0 : 59, selectingStart ? 0 : 999) const now = new Date() // 如果选择的日期晚于当前时间,限制为当前时间 if (selectedDate > now) { selectedDate.setTime(now.getTime()) } if (selectingStart) { // 选择开始日期 const currentEnd = options.dateRange?.end || new Date() // 如果选择的开始日期晚于结束日期,则同时更新结束日期 if (selectedDate > currentEnd) { const newEnd = new Date(selectedDate) newEnd.setHours(23, 59, 59, 999) // 确保结束日期也不晚于当前时间 if (newEnd > now) { newEnd.setTime(now.getTime()) } setOptions({ ...options, dateRange: { start: selectedDate, end: newEnd } }) } else { setOptions({ ...options, dateRange: options.dateRange ? { ...options.dateRange, start: selectedDate } : { start: selectedDate, end: new Date() } }) } setSelectingStart(false) } else { // 选择结束日期 const currentStart = options.dateRange?.start || new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) // 如果选择的结束日期早于开始日期,则同时更新开始日期 if (selectedDate < currentStart) { const newStart = new Date(selectedDate) newStart.setHours(0, 0, 0, 0) setOptions({ ...options, dateRange: { start: newStart, end: selectedDate } }) } else { setOptions({ ...options, dateRange: options.dateRange ? { ...options.dateRange, end: selectedDate } : { start: new Date(), end: selectedDate } }) } setSelectingStart(true) } } const formatOptions = [ { value: 'chatlab', label: 'ChatLab', icon: FileCode, desc: '标准格式,支持其他软件导入' }, { value: 'chatlab-jsonl', label: 'ChatLab JSONL', icon: FileCode, desc: '流式格式,适合大量消息' }, { value: 'json', label: 'JSON', icon: FileJson, desc: '详细格式,包含完整消息信息' }, { value: 'html', label: 'HTML', icon: FileText, desc: '网页格式,可直接浏览' }, { value: 'txt', label: 'TXT', icon: Table, desc: '纯文本,通用格式' }, { value: 'excel', label: 'Excel', icon: FileSpreadsheet, desc: '电子表格,适合统计分析' }, { value: 'sql', label: 'PostgreSQL', icon: Database, desc: '数据库脚本,便于导入到数据库' } ] return (

选择会话

setSearchKeyword(e.target.value)} /> {searchKeyword && ( )}
已选 {selectedSessions.size} 个
{isLoading ? (
加载中...
) : filteredSessions.length === 0 ? (
暂无会话
) : (
{filteredSessions.map(session => (
toggleSession(session.username)} >
{selectedSessions.has(session.username) && }
{session.avatarUrl ? ( ) : ( {getAvatarLetter(session.displayName || session.username)} )}
{session.displayName || session.username}
{session.summary || '暂无消息'}
))}
)}

导出设置

导出格式

{formatOptions.map(fmt => (
setOptions({ ...options, format: fmt.value as any })} > {fmt.label} {fmt.desc}
))}

时间范围

{!options.useAllTime && options.dateRange && (
setShowDatePicker(true)}> {formatDate(options.dateRange.start)} - {formatDate(options.dateRange.end)}
)}

媒体文件

导出图片/语音/表情并在记录内写入相对路径

导出媒体文件 会创建子文件夹并保存媒体资源

头像

可选导出头像索引,关闭则不下载头像

导出头像 用于展示发送者头像,可能会读取或下载头像文件

导出位置

{exportFolder || '未设置'}
{/* 导出进度弹窗 */} {isExporting && (

正在导出

{exportProgress.currentName}

{exportProgress.current} / {exportProgress.total}

)} {/* 导出结果弹窗 */} {exportResult && (
{exportResult.success ? : }

{exportResult.success ? '导出完成' : '导出失败'}

{exportResult.success ? (

成功导出 {exportResult.successCount} 个会话 {exportResult.failCount ? `,${exportResult.failCount} 个失败` : ''}

) : (

{exportResult.error}

)}
{exportResult.success && ( )}
)} {/* 日期选择弹窗 */} {showDatePicker && (
setShowDatePicker(false)}>
e.stopPropagation()}>

选择时间范围

点击选择开始和结束日期,系统会自动调整确保时间顺序正确

setSelectingStart(true)} > 开始日期 {options.dateRange?.start.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' })}
setSelectingStart(false)} > 结束日期 {options.dateRange?.end.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' })}
{calendarDate.getFullYear()}年{calendarDate.getMonth() + 1}月
{['日', '一', '二', '三', '四', '五', '六'].map(day => (
{day}
))}
{generateCalendar().map((day, index) => { if (day === null) { return
} const currentDate = new Date(calendarDate.getFullYear(), calendarDate.getMonth(), day) const isStart = options.dateRange?.start.toDateString() === currentDate.toDateString() const isEnd = options.dateRange?.end.toDateString() === currentDate.toDateString() const isInRange = options.dateRange && currentDate >= options.dateRange.start && currentDate <= options.dateRange.end const today = new Date() today.setHours(0, 0, 0, 0) const isFuture = currentDate > today return (
!isFuture && handleDateSelect(day)} style={{ cursor: isFuture ? 'not-allowed' : 'pointer', opacity: isFuture ? 0.3 : 1 }} > {day}
) })}
)}
) } export default ExportPage