import { useState, useEffect, useCallback, useRef } 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 exportVideos: boolean exportEmojis: boolean exportVoiceAsText: boolean excelCompactColumns: boolean txtColumns: string[] displayNamePreference: 'group-nickname' | 'remark' | 'nickname' exportConcurrency: number } interface ExportResult { success: boolean successCount?: number failCount?: number error?: string } type SessionLayout = 'shared' | 'per-session' function ExportPage() { const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content'] 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 [showMediaLayoutPrompt, setShowMediaLayoutPrompt] = useState(false) const [showDisplayNameSelect, setShowDisplayNameSelect] = useState(false) const displayNameDropdownRef = useRef(null) const [options, setOptions] = useState({ format: 'excel', dateRange: { start: new Date(new Date().setHours(0, 0, 0, 0)), end: new Date() }, useAllTime: false, exportAvatars: true, exportMedia: false, exportImages: true, exportVoices: true, exportVideos: true, exportEmojis: true, exportVoiceAsText: true, excelCompactColumns: true, txtColumns: defaultTxtColumns, displayNamePreference: 'remark', exportConcurrency: 2 }) const buildDateRangeFromPreset = (preset: string) => { const now = new Date() if (preset === 'all') { return { useAllTime: true, dateRange: { start: now, end: now } } } let rangeMs = 0 if (preset === '7d') rangeMs = 7 * 24 * 60 * 60 * 1000 if (preset === '30d') rangeMs = 30 * 24 * 60 * 60 * 1000 if (preset === '90d') rangeMs = 90 * 24 * 60 * 60 * 1000 if (preset === 'today' || rangeMs === 0) { const start = new Date(now) start.setHours(0, 0, 0, 0) return { useAllTime: false, dateRange: { start, end: now } } } const start = new Date(now.getTime() - rangeMs) start.setHours(0, 0, 0, 0) return { useAllTime: false, dateRange: { start, end: now } } } 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) } }, []) const loadExportDefaults = useCallback(async () => { try { const [ savedFormat, savedRange, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency ] = await Promise.all([ configService.getExportDefaultFormat(), configService.getExportDefaultDateRange(), configService.getExportDefaultMedia(), configService.getExportDefaultVoiceAsText(), configService.getExportDefaultExcelCompactColumns(), configService.getExportDefaultTxtColumns(), configService.getExportDefaultConcurrency() ]) const preset = savedRange || 'today' const rangeDefaults = buildDateRangeFromPreset(preset) const txtColumns = savedTxtColumns && savedTxtColumns.length > 0 ? savedTxtColumns : defaultTxtColumns setOptions((prev) => ({ ...prev, format: (savedFormat as ExportOptions['format']) || 'excel', useAllTime: rangeDefaults.useAllTime, dateRange: rangeDefaults.dateRange, exportMedia: savedMedia ?? false, exportVoiceAsText: savedVoiceAsText ?? true, excelCompactColumns: savedExcelCompactColumns ?? true, txtColumns, exportConcurrency: savedConcurrency ?? 2 })) } catch (e) { console.error('加载导出默认设置失败:', e) } }, []) useEffect(() => { loadSessions() loadExportPath() loadExportDefaults() }, [loadSessions, loadExportPath, loadExportDefaults]) useEffect(() => { const handleChange = () => { setSelectedSessions(new Set()) setSearchKeyword('') setExportResult(null) setSessions([]) setFilteredSessions([]) loadSessions() } window.addEventListener('wxid-changed', handleChange as EventListener) return () => window.removeEventListener('wxid-changed', handleChange as EventListener) }, [loadSessions]) useEffect(() => { const removeListener = window.electronAPI.export.onProgress?.((payload: { current: number; total: number; currentSession: string; phase: string }) => { setExportProgress({ current: payload.current, total: payload.total, currentName: payload.currentSession }) }) return () => { removeListener?.() } }, []) useEffect(() => { const handleClickOutside = (event: MouseEvent) => { const target = event.target as Node if (showDisplayNameSelect && displayNameDropdownRef.current && !displayNameDropdownRef.current.contains(target)) { setShowDisplayNameSelect(false) } } document.addEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside) }, [showDisplayNameSelect]) 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 handleFormatChange = (format: ExportOptions['format']) => { setOptions((prev) => { const next = { ...prev, format } if (format === 'html') { return { ...next, exportMedia: true, exportImages: true, exportVoices: true, exportVideos: true, exportEmojis: true, exportVoiceAsText: true } } return next }) } const openExportFolder = async () => { if (exportFolder) { await window.electronAPI.shell.openPath(exportFolder) } } const runExport = async (sessionLayout: SessionLayout) => { 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, exportVideos: options.exportMedia && options.exportVideos, exportEmojis: options.exportMedia && options.exportEmojis, exportVoiceAsText: options.exportVoiceAsText, // 即使不导出媒体,也可以导出语音转文字内容 excelCompactColumns: options.excelCompactColumns, txtColumns: options.txtColumns, displayNamePreference: options.displayNamePreference, exportConcurrency: options.exportConcurrency, sessionLayout, 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' || options.format === 'txt' || options.format === 'html') { 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 startExport = () => { if (selectedSessions.size === 0 || !exportFolder) return if (options.exportMedia && selectedSessions.size > 1) { setShowMediaLayoutPrompt(true) return } const layout: SessionLayout = options.exportMedia ? 'per-session' : 'shared' runExport(layout) } 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: '数据库脚本,便于导入到数据库' } ] const displayNameOptions = [ { value: 'group-nickname', label: '群昵称优先', desc: '仅群聊有效,私聊显示备注/昵称' }, { value: 'remark', label: '备注优先', desc: '有备注显示备注,否则显示昵称' }, { value: 'nickname', label: '微信昵称', desc: '始终显示微信昵称' } ] const displayNameOption = displayNameOptions.find(option => option.value === options.displayNamePreference) const displayNameLabel = displayNameOption?.label || '备注优先' 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 => (
handleFormatChange(fmt.value as ExportOptions['format'])} > {fmt.label} {fmt.desc}
))}

时间范围

选择要导出的消息时间区间

导出全部时间 关闭此项以选择特定的起止日期
{!options.useAllTime && options.dateRange && ( <>
setShowDatePicker(true)}>
{formatDate(options.dateRange.start)} - {formatDate(options.dateRange.end)}
)}
{/* 发送者名称显示偏好 */} {(options.format === 'html' || options.format === 'json' || options.format === 'txt') && (

发送者名称显示

选择导出时优先显示的名称

{showDisplayNameSelect && (
{displayNameOptions.map(option => ( ))}
)}
)}

媒体文件

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

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

头像

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

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

导出位置

{exportFolder || '未设置'}
{/* 媒体导出布局选择弹窗 */} {showMediaLayoutPrompt && (
setShowMediaLayoutPrompt(false)}>
e.stopPropagation()}>

导出文件夹布局

检测到同时导出多个会话并包含媒体文件,请选择存放方式:

)} {/* 导出进度弹窗 */} {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