import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useLocation } from 'react-router-dom' import { TableVirtuoso } from 'react-virtuoso' import { Aperture, ChevronDown, ChevronRight, CheckSquare, Download, ExternalLink, FolderOpen, Image as ImageIcon, Loader2, MessageSquareText, Mic, Search, Square, Video, WandSparkles, X } from 'lucide-react' import type { ChatSession as AppChatSession, ContactInfo } from '../types/models' import type { ExportOptions as ElectronExportOptions, ExportProgress } from '../types/electron' import * as configService from '../services/config' import './ExportPage.scss' type ConversationTab = 'private' | 'group' | 'official' | 'former_friend' type TaskStatus = 'queued' | 'running' | 'success' | 'error' type TaskScope = 'single' | 'multi' | 'content' | 'sns' type ContentType = 'text' | 'voice' | 'image' | 'video' | 'emoji' type ContentCardType = ContentType | 'sns' type SessionLayout = 'shared' | 'per-session' type DisplayNamePreference = 'group-nickname' | 'remark' | 'nickname' type TextExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql' interface ExportOptions { format: TextExportFormat 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: DisplayNamePreference exportConcurrency: number } interface SessionRow extends AppChatSession { kind: ConversationTab wechatId?: string } interface SessionMetrics { totalMessages?: number voiceMessages?: number imageMessages?: number videoMessages?: number emojiMessages?: number privateMutualGroups?: number groupMemberCount?: number groupMyMessages?: number groupActiveSpeakers?: number groupMutualFriends?: number firstTimestamp?: number lastTimestamp?: number } interface TaskProgress { current: number total: number currentName: string phaseLabel: string phaseProgress: number phaseTotal: number } interface ExportTaskPayload { sessionIds: string[] outputDir: string options?: ElectronExportOptions scope: TaskScope contentType?: ContentType sessionNames: string[] snsOptions?: { format: 'json' | 'html' exportMedia?: boolean startTime?: number endTime?: number } } interface ExportTask { id: string title: string status: TaskStatus createdAt: number startedAt?: number finishedAt?: number error?: string payload: ExportTaskPayload progress: TaskProgress } interface ExportDialogState { open: boolean scope: TaskScope contentType?: ContentType sessionIds: string[] sessionNames: string[] title: string } const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content'] const contentTypeLabels: Record = { text: '聊天文本', voice: '语音', image: '图片', video: '视频', emoji: '表情包' } const formatOptions: Array<{ value: TextExportFormat; label: string; desc: string }> = [ { value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' }, { value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' }, { value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' }, { value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' }, { value: 'txt', label: 'TXT', desc: '纯文本,通用格式' }, { value: 'excel', label: 'Excel', desc: '电子表格,适合统计分析' }, { value: 'weclone', label: 'WeClone CSV', desc: 'WeClone 兼容字段格式(CSV)' }, { value: 'sql', label: 'PostgreSQL', desc: '数据库脚本,便于导入到数据库' } ] const displayNameOptions: Array<{ value: DisplayNamePreference; label: string; desc: string }> = [ { value: 'group-nickname', label: '群昵称优先', desc: '仅群聊有效,私聊显示备注/昵称' }, { value: 'remark', label: '备注优先', desc: '有备注显示备注,否则显示昵称' }, { value: 'nickname', label: '微信昵称', desc: '始终显示微信昵称' } ] const writeLayoutOptions: Array<{ value: configService.ExportWriteLayout; label: string; desc: string }> = [ { value: 'A', label: 'A(类型分目录)', desc: '聊天文本、语音、视频、表情包、图片分别创建文件夹' }, { value: 'B', label: 'B(文本根目录+媒体按会话)', desc: '聊天文本在根目录;媒体按类型目录后再按会话分目录' }, { value: 'C', label: 'C(按会话分目录)', desc: '每个会话一个目录,目录内包含文本与媒体文件' } ] const createEmptyProgress = (): TaskProgress => ({ current: 0, total: 0, currentName: '', phaseLabel: '', phaseProgress: 0, phaseTotal: 0 }) const formatAbsoluteDate = (timestamp: number): string => { const d = new Date(timestamp) const y = d.getFullYear() const m = `${d.getMonth() + 1}`.padStart(2, '0') const day = `${d.getDate()}`.padStart(2, '0') return `${y}-${m}-${day}` } const formatRecentExportTime = (timestamp?: number, now = Date.now()): string => { if (!timestamp) return '' const diff = Math.max(0, now - timestamp) const minute = 60 * 1000 const hour = 60 * minute const day = 24 * hour if (diff < hour) { const minutes = Math.max(1, Math.floor(diff / minute)) return `${minutes} 分钟前` } if (diff < day) { const hours = Math.max(1, Math.floor(diff / hour)) return `${hours} 小时前` } return formatAbsoluteDate(timestamp) } const formatDateInputValue = (date: Date): string => { const y = date.getFullYear() const m = `${date.getMonth() + 1}`.padStart(2, '0') const d = `${date.getDate()}`.padStart(2, '0') return `${y}-${m}-${d}` } const parseDateInput = (value: string, endOfDay: boolean): Date => { const [year, month, day] = value.split('-').map(v => Number(v)) const date = new Date(year, month - 1, day) if (endOfDay) { date.setHours(23, 59, 59, 999) } else { date.setHours(0, 0, 0, 0) } return 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' } const getAvatarLetter = (name: string): string => { if (!name) return '?' return [...name][0] || '?' } const valueOrDash = (value?: number): string => { if (value === undefined || value === null) return '--' return value.toLocaleString() } const timestampOrDash = (timestamp?: number): string => { if (!timestamp) return '--' return formatAbsoluteDate(timestamp * 1000) } const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` const METRICS_VIEWPORT_PREFETCH = 140 const METRICS_BACKGROUND_BATCH = 60 const METRICS_BACKGROUND_INTERVAL_MS = 180 const WriteLayoutSelector = memo(function WriteLayoutSelector({ writeLayout, onChange }: { writeLayout: configService.ExportWriteLayout onChange: (value: configService.ExportWriteLayout) => Promise }) { const [isOpen, setIsOpen] = useState(false) const containerRef = useRef(null) useEffect(() => { if (!isOpen) return const handleOutsideClick = (event: MouseEvent) => { if (containerRef.current?.contains(event.target as Node)) return setIsOpen(false) } document.addEventListener('mousedown', handleOutsideClick) return () => document.removeEventListener('mousedown', handleOutsideClick) }, [isOpen]) const writeLayoutLabel = writeLayoutOptions.find(option => option.value === writeLayout)?.label || 'A(类型分目录)' return (
写入目录方式
{writeLayoutOptions.map(option => ( ))}
) }) function ExportPage() { const location = useLocation() const [isLoading, setIsLoading] = useState(true) const [isSessionEnriching, setIsSessionEnriching] = useState(false) const [isTabCountsLoading, setIsTabCountsLoading] = useState(true) const [isSnsStatsLoading, setIsSnsStatsLoading] = useState(true) const [isBaseConfigLoading, setIsBaseConfigLoading] = useState(true) const [isTaskCenterExpanded, setIsTaskCenterExpanded] = useState(false) const [sessions, setSessions] = useState([]) const [prefetchedTabCounts, setPrefetchedTabCounts] = useState | null>(null) const [sessionMetrics, setSessionMetrics] = useState>({}) const [searchKeyword, setSearchKeyword] = useState('') const [activeTab, setActiveTab] = useState('private') const [selectedSessions, setSelectedSessions] = useState>(new Set()) const [exportFolder, setExportFolder] = useState('') const [writeLayout, setWriteLayout] = useState('A') 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: false, excelCompactColumns: true, txtColumns: defaultTxtColumns, displayNamePreference: 'remark', exportConcurrency: 2 }) const [exportDialog, setExportDialog] = useState({ open: false, scope: 'single', sessionIds: [], sessionNames: [], title: '' }) const [tasks, setTasks] = useState([]) const [lastExportBySession, setLastExportBySession] = useState>({}) const [lastExportByContent, setLastExportByContent] = useState>({}) 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) const runningTaskIdRef = useRef(null) const tasksRef = useRef([]) const sessionMetricsRef = useRef>({}) const sessionLoadTokenRef = useRef(0) const loadingMetricsRef = useRef>(new Set()) const preselectAppliedRef = useRef(false) const visibleSessionsRef = useRef([]) useEffect(() => { tasksRef.current = tasks }, [tasks]) useEffect(() => { sessionMetricsRef.current = sessionMetrics }, [sessionMetrics]) const preselectSessionIds = useMemo(() => { const state = location.state as { preselectSessionIds?: unknown; preselectSessionId?: unknown } | null const rawList = Array.isArray(state?.preselectSessionIds) ? state?.preselectSessionIds : (typeof state?.preselectSessionId === 'string' ? [state.preselectSessionId] : []) return rawList .filter((item): item is string => typeof item === 'string') .map(item => item.trim()) .filter(Boolean) }, [location.state]) useEffect(() => { const timer = setInterval(() => setNowTick(Date.now()), 60 * 1000) return () => clearInterval(timer) }, []) const loadBaseConfig = useCallback(async () => { setIsBaseConfigLoading(true) try { const [savedPath, savedFormat, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedWriteLayout, savedSessionMap, savedContentMap, savedSnsPostCount] = await Promise.all([ configService.getExportPath(), configService.getExportDefaultFormat(), configService.getExportDefaultMedia(), configService.getExportDefaultVoiceAsText(), configService.getExportDefaultExcelCompactColumns(), configService.getExportDefaultTxtColumns(), configService.getExportDefaultConcurrency(), configService.getExportWriteLayout(), configService.getExportLastSessionRunMap(), configService.getExportLastContentRunMap(), configService.getExportLastSnsPostCount() ]) if (savedPath) { setExportFolder(savedPath) } else { const downloadsPath = await window.electronAPI.app.getDownloadsPath() setExportFolder(downloadsPath) } setWriteLayout(savedWriteLayout) setLastExportBySession(savedSessionMap) setLastExportByContent(savedContentMap) setLastSnsExportPostCount(savedSnsPostCount) const txtColumns = savedTxtColumns && savedTxtColumns.length > 0 ? savedTxtColumns : defaultTxtColumns setOptions(prev => ({ ...prev, format: (savedFormat as TextExportFormat) || prev.format, exportMedia: savedMedia ?? prev.exportMedia, exportVoiceAsText: savedVoiceAsText ?? prev.exportVoiceAsText, excelCompactColumns: savedExcelCompactColumns ?? prev.excelCompactColumns, txtColumns, exportConcurrency: savedConcurrency ?? prev.exportConcurrency })) } catch (error) { console.error('加载导出配置失败:', error) } finally { setIsBaseConfigLoading(false) } }, []) const loadTabCounts = useCallback(async () => { setIsTabCountsLoading(true) try { const result = await window.electronAPI.chat.getExportTabCounts() if (result.success && result.counts) { setPrefetchedTabCounts(result.counts) } } catch (error) { console.error('加载导出页会话分类数量失败:', error) } finally { setIsTabCountsLoading(false) } }, []) const loadSnsStats = useCallback(async () => { setIsSnsStatsLoading(true) 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) } finally { setIsSnsStatsLoading(false) } }, []) const loadSessions = useCallback(async () => { const loadToken = Date.now() sessionLoadTokenRef.current = loadToken setIsLoading(true) setIsSessionEnriching(false) const isStale = () => sessionLoadTokenRef.current !== loadToken try { const connectResult = await window.electronAPI.chat.connect() if (!connectResult.success) { console.error('连接失败:', connectResult.error) if (!isStale()) setIsLoading(false) return } const sessionsResult = await window.electronAPI.chat.getSessions() if (isStale()) return if (sessionsResult.success && sessionsResult.sessions) { const baseSessions = sessionsResult.sessions .map((session) => { return { ...session, kind: toKindByContactType(session), wechatId: session.username, displayName: session.displayName || session.username, avatarUrl: session.avatarUrl } as SessionRow }) .sort((a, b) => (b.sortTimestamp || b.lastTimestamp || 0) - (a.sortTimestamp || a.lastTimestamp || 0)) if (isStale()) return setSessions(baseSessions) setIsLoading(false) // 后台补齐联系人字段(昵称、头像、类型),不阻塞首屏会话列表渲染。 setIsSessionEnriching(true) void (async () => { try { const contactsResult = await window.electronAPI.chat.getContacts() if (isStale()) return const contacts: ContactInfo[] = contactsResult.success && contactsResult.contacts ? contactsResult.contacts : [] const nextContactMap = contacts.reduce>((map, contact) => { map[contact.username] = contact return map }, {}) const needsEnrichment = baseSessions .filter(session => !session.avatarUrl || !session.displayName || session.displayName === session.username) .map(session => session.username) let extraContactMap: Record = {} if (needsEnrichment.length > 0) { const enrichResult = await window.electronAPI.chat.enrichSessionsContactInfo(needsEnrichment) if (enrichResult.success && enrichResult.contacts) { extraContactMap = enrichResult.contacts } } if (isStale()) return const nextSessions = baseSessions .map((session) => { const contact = nextContactMap[session.username] const extra = extraContactMap[session.username] const displayName = extra?.displayName || contact?.displayName || session.displayName || session.username const avatarUrl = extra?.avatarUrl || session.avatarUrl || contact?.avatarUrl return { ...session, kind: toKindByContactType(session, contact), wechatId: contact?.username || session.wechatId || session.username, displayName, avatarUrl } }) .sort((a, b) => { const aMetric = sessionMetricsRef.current[a.username]?.totalMessages ?? 0 const bMetric = sessionMetricsRef.current[b.username]?.totalMessages ?? 0 if (bMetric !== aMetric) return bMetric - aMetric return (b.sortTimestamp || b.lastTimestamp || 0) - (a.sortTimestamp || a.lastTimestamp || 0) }) setSessions(nextSessions) } catch (enrichError) { console.error('导出页补充会话联系人信息失败:', enrichError) } finally { if (!isStale()) setIsSessionEnriching(false) } })() } else { setIsLoading(false) } } catch (error) { console.error('加载会话失败:', error) if (!isStale()) setIsLoading(false) } finally { if (!isStale()) setIsLoading(false) } }, []) useEffect(() => { void loadBaseConfig() void (async () => { await loadTabCounts() await loadSessions() })() // 朋友圈统计延后一点加载,避免与首屏会话初始化抢占。 const timer = window.setTimeout(() => { void loadSnsStats() }, 180) return () => window.clearTimeout(timer) }, [loadTabCounts, loadBaseConfig, loadSessions, loadSnsStats]) useEffect(() => { preselectAppliedRef.current = false }, [location.key, preselectSessionIds]) useEffect(() => { if (preselectAppliedRef.current) return if (sessions.length === 0 || preselectSessionIds.length === 0) return const exists = new Set(sessions.map(session => session.username)) const matched = preselectSessionIds.filter(id => exists.has(id)) preselectAppliedRef.current = true if (matched.length > 0) { setSelectedSessions(new Set(matched)) } }, [sessions, preselectSessionIds]) const visibleSessions = useMemo(() => { const keyword = searchKeyword.trim().toLowerCase() return sessions .filter((session) => { if (session.kind !== activeTab) return false if (!keyword) return true return ( (session.displayName || '').toLowerCase().includes(keyword) || session.username.toLowerCase().includes(keyword) ) }) .sort((a, b) => { const totalA = sessionMetrics[a.username]?.totalMessages ?? 0 const totalB = sessionMetrics[b.username]?.totalMessages ?? 0 if (totalB !== totalA) { return totalB - totalA } const latestA = sessionMetrics[a.username]?.lastTimestamp ?? a.lastTimestamp ?? 0 const latestB = sessionMetrics[b.username]?.lastTimestamp ?? b.lastTimestamp ?? 0 return latestB - latestA }) }, [sessions, activeTab, searchKeyword, sessionMetrics]) useEffect(() => { visibleSessionsRef.current = visibleSessions }, [visibleSessions]) const ensureSessionMetrics = useCallback(async (targetSessions: SessionRow[]) => { const currentMetrics = sessionMetricsRef.current const pending = targetSessions.filter(session => !currentMetrics[session.username] && !loadingMetricsRef.current.has(session.username)) if (pending.length === 0) return const updates: Record = {} for (const session of pending) { loadingMetricsRef.current.add(session.username) } try { const batchSize = 80 for (let i = 0; i < pending.length; i += batchSize) { const chunk = pending.slice(i, i + batchSize) const ids = chunk.map(session => session.username) try { const statsResult = await window.electronAPI.chat.getExportSessionStats(ids) if (!statsResult.success || !statsResult.data) { console.error('加载会话统计失败:', statsResult.error || '未知错误') continue } for (const session of chunk) { const raw = statsResult.data[session.username] // 成功响应但无明细时按 0 回填,避免该行反复重试导致滚动抖动。 updates[session.username] = { totalMessages: raw?.totalMessages ?? 0, voiceMessages: raw?.voiceMessages ?? 0, imageMessages: raw?.imageMessages ?? 0, videoMessages: raw?.videoMessages ?? 0, emojiMessages: raw?.emojiMessages ?? 0, privateMutualGroups: raw?.privateMutualGroups, groupMemberCount: raw?.groupMemberCount, groupMyMessages: raw?.groupMyMessages, groupActiveSpeakers: raw?.groupActiveSpeakers, groupMutualFriends: raw?.groupMutualFriends, firstTimestamp: raw?.firstTimestamp, lastTimestamp: raw?.lastTimestamp } } } catch (error) { console.error('加载会话统计分批失败:', error) } } } catch (error) { console.error('加载会话统计失败:', error) } finally { for (const session of pending) { loadingMetricsRef.current.delete(session.username) } } if (Object.keys(updates).length > 0) { setSessionMetrics(prev => ({ ...prev, ...updates })) } }, []) useEffect(() => { const keyword = searchKeyword.trim().toLowerCase() const targets = sessions .filter((session) => { if (session.kind !== activeTab) return false if (!keyword) return true return ( (session.displayName || '').toLowerCase().includes(keyword) || session.username.toLowerCase().includes(keyword) ) }) .sort((a, b) => (b.sortTimestamp || b.lastTimestamp || 0) - (a.sortTimestamp || a.lastTimestamp || 0)) .slice(0, METRICS_VIEWPORT_PREFETCH) void ensureSessionMetrics(targets) }, [sessions, activeTab, searchKeyword, ensureSessionMetrics]) const handleTableRangeChanged = useCallback((range: { startIndex: number; endIndex: number }) => { const current = visibleSessionsRef.current if (current.length === 0) return const start = Math.max(0, range.startIndex - METRICS_VIEWPORT_PREFETCH) const end = Math.min(current.length - 1, range.endIndex + METRICS_VIEWPORT_PREFETCH) if (end < start) return void ensureSessionMetrics(current.slice(start, end + 1)) }, [ensureSessionMetrics]) useEffect(() => { if (sessions.length === 0) return let cursor = 0 const timer = window.setInterval(() => { if (cursor >= sessions.length) { window.clearInterval(timer) return } const chunk = sessions.slice(cursor, cursor + METRICS_BACKGROUND_BATCH) cursor += METRICS_BACKGROUND_BATCH void ensureSessionMetrics(chunk) }, METRICS_BACKGROUND_INTERVAL_MS) return () => window.clearInterval(timer) }, [sessions, ensureSessionMetrics]) const selectedCount = selectedSessions.size const toggleSelectSession = (sessionId: string) => { setSelectedSessions(prev => { const next = new Set(prev) if (next.has(sessionId)) { next.delete(sessionId) } else { next.add(sessionId) } return next }) } const toggleSelectAllVisible = () => { const visibleIds = visibleSessions.map(session => session.username) if (visibleIds.length === 0) return setSelectedSessions(prev => { const next = new Set(prev) const allSelected = visibleIds.every(id => next.has(id)) if (allSelected) { for (const id of visibleIds) { next.delete(id) } } else { for (const id of visibleIds) { next.add(id) } } return next }) } const clearSelection = () => setSelectedSessions(new Set()) const openExportDialog = (payload: Omit) => { 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 })) } else { setOptions(prev => ({ ...prev, exportMedia: true, exportImages: payload.contentType === 'image', exportVoices: payload.contentType === 'voice', exportVideos: payload.contentType === 'video', exportEmojis: payload.contentType === 'emoji' })) } } } const closeExportDialog = () => { setExportDialog(prev => ({ ...prev, open: false })) } const buildExportOptions = (scope: TaskScope, contentType?: ContentType): ElectronExportOptions => { const sessionLayout: SessionLayout = writeLayout === 'C' ? 'per-session' : 'shared' const base: ElectronExportOptions = { 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), end: Math.floor(options.dateRange.end.getTime() / 1000) } : null } if (scope === 'content' && contentType) { if (contentType === 'text') { return { ...base, exportMedia: false, exportImages: false, exportVoices: false, exportVideos: false, exportEmojis: false } } return { ...base, exportMedia: true, exportImages: contentType === 'image', exportVoices: contentType === 'voice', exportVideos: contentType === 'video', exportEmojis: contentType === 'emoji' } } 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 } for (const id of sessionIds) { next[id] = timestamp } void configService.setExportLastSessionRunMap(next) return next }) }, []) const markContentExported = useCallback((sessionIds: string[], contentTypes: ContentType[], timestamp: number) => { setLastExportByContent(prev => { const next = { ...prev } for (const id of sessionIds) { for (const type of contentTypes) { next[`${id}::${type}`] = timestamp } } void configService.setExportLastContentRunMap(next) return next }) }, []) const inferContentTypesFromOptions = (opts: ElectronExportOptions): ContentType[] => { const types: ContentType[] = ['text'] if (opts.exportMedia) { if (opts.exportVoices) types.push('voice') if (opts.exportImages) types.push('image') if (opts.exportVideos) types.push('video') if (opts.exportEmojis) types.push('emoji') } return types } const updateTask = useCallback((taskId: string, updater: (task: ExportTask) => ExportTask) => { setTasks(prev => prev.map(task => (task.id === taskId ? updater(task) : task))) }, []) const runNextTask = useCallback(async () => { if (runningTaskIdRef.current) return const queue = [...tasksRef.current].reverse() const next = queue.find(task => task.status === 'queued') if (!next) return runningTaskIdRef.current = next.id updateTask(next.id, task => ({ ...task, status: 'running', startedAt: Date.now() })) progressUnsubscribeRef.current?.() if (next.payload.scope === 'sns') { progressUnsubscribeRef.current = window.electronAPI.sns.onExportProgress((payload) => { updateTask(next.id, task => ({ ...task, progress: { 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 => ({ ...task, status: 'error', finishedAt: Date.now(), error: String(error) })) } finally { progressUnsubscribeRef.current?.() progressUnsubscribeRef.current = null runningTaskIdRef.current = null void runNextTask() } }, [updateTask, markSessionExported, markContentExported, loadSnsStats, lastSnsExportPostCount]) useEffect(() => { void runNextTask() }, [tasks, runNextTask]) useEffect(() => { return () => { progressUnsubscribeRef.current?.() progressUnsubscribeRef.current = null } }, []) const createTask = async () => { if (!exportDialog.open || !exportFolder) return if (exportDialog.scope !== 'sns' && exportDialog.sessionIds.length === 0) return 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} 个会话)` : exportDialog.scope === 'sns' ? '朋友圈批量导出' : `${contentTypeLabels[exportDialog.contentType || 'text']}批量导出` const task: ExportTask = { id: createTaskId(), title, status: 'queued', createdAt: Date.now(), payload: { sessionIds: exportDialog.sessionIds, sessionNames: exportDialog.sessionNames, outputDir: exportFolder, options: exportOptions, scope: exportDialog.scope, contentType: exportDialog.contentType, snsOptions }, progress: createEmptyProgress() } setTasks(prev => [task, ...prev]) closeExportDialog() await configService.setExportDefaultFormat(options.format) await configService.setExportDefaultMedia(options.exportMedia) await configService.setExportDefaultVoiceAsText(options.exportVoiceAsText) await configService.setExportDefaultExcelCompactColumns(options.excelCompactColumns) await configService.setExportDefaultTxtColumns(options.txtColumns) await configService.setExportDefaultConcurrency(options.exportConcurrency) } const openSingleExport = (session: SessionRow) => { openExportDialog({ scope: 'single', sessionIds: [session.username], sessionNames: [session.displayName || session.username], title: `导出会话:${session.displayName || session.username}` }) } const openBatchExport = () => { const ids = Array.from(selectedSessions) if (ids.length === 0) return const nameMap = new Map(sessions.map(session => [session.username, session.displayName || session.username])) const names = ids.map(id => nameMap.get(id) || id) openExportDialog({ scope: 'multi', sessionIds: ids, sessionNames: names, title: `批量导出(${ids.length} 个会话)` }) } const openContentExport = (contentType: ContentType) => { const ids = sessions .filter(session => session.kind === 'private' || session.kind === 'group') .map(session => session.username) const names = sessions .filter(session => session.kind === 'private' || session.kind === 'group') .map(session => session.displayName || session.username) openExportDialog({ scope: 'content', contentType, sessionIds: ids, sessionNames: names, title: `${contentTypeLabels[contentType]}批量导出` }) } const openSnsExport = () => { openExportDialog({ scope: 'sns', sessionIds: [], sessionNames: ['全部朋友圈动态'], title: '朋友圈批量导出' }) } const runningSessionIds = useMemo(() => { const set = new Set() for (const task of tasks) { if (task.status !== 'running') continue for (const id of task.payload.sessionIds) { set.add(id) } } return set }, [tasks]) const queuedSessionIds = useMemo(() => { const set = new Set() for (const task of tasks) { if (task.status !== 'queued') continue for (const id of task.payload.sessionIds) { set.add(id) } } return set }, [tasks]) const sessionTabCounts = useMemo(() => { const counts: Record = { private: 0, group: 0, official: 0, former_friend: 0 } for (const session of sessions) { counts[session.kind] += 1 } return counts }, [sessions]) const tabCounts = useMemo(() => { if (sessions.length > 0) { return sessionTabCounts } if (prefetchedTabCounts) { return prefetchedTabCounts } return sessionTabCounts }, [sessions.length, sessionTabCounts, prefetchedTabCounts]) const contentCards = useMemo(() => { const scopeSessions = sessions.filter(session => session.kind === 'private' || session.kind === 'group') const totalSessions = scopeSessions.length const snsExportedCount = Math.min(lastSnsExportPostCount, snsStats.totalPosts) const sessionCards = [ { type: 'text' as ContentType, icon: MessageSquareText }, { type: 'voice' as ContentType, icon: Mic }, { type: 'image' as ContentType, icon: ImageIcon }, { type: 'video' as ContentType, icon: Video }, { type: 'emoji' as ContentType, icon: WandSparkles } ].map(item => { let exported = 0 for (const session of scopeSessions) { if (lastExportByContent[`${session.username}::${item.type}`]) { exported += 1 } } return { ...item, label: contentTypeLabels[item.type], stats: [ { label: '总会话数', value: totalSessions }, { label: '已导出', value: exported } ] } }) const snsCard = { type: 'sns' as ContentCardType, icon: Aperture, label: '朋友圈', stats: [ { label: '朋友圈条数', value: snsStats.totalPosts }, { 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]) const renderSessionName = (session: SessionRow) => { return (
{session.avatarUrl ? : {getAvatarLetter(session.displayName || session.username)}}
{session.displayName || session.username}
{session.wechatId || session.username}
) } const renderActionCell = (session: SessionRow) => { const isRunning = runningSessionIds.has(session.username) const isQueued = queuedSessionIds.has(session.username) const recent = formatRecentExportTime(lastExportBySession[session.username], nowTick) return (
{recent && {recent}}
) } const renderTableHeader = () => { if (activeTab === 'private' || activeTab === 'former_friend') { return ( 选择 会话名(头像/昵称/微信号) 总消息 语音 图片 视频 表情包 共同群聊数 最早时间 最新时间 操作 ) } if (activeTab === 'group') { return ( 选择 会话名(群头像/群名称/群ID) 总消息 语音 图片 视频 表情包 我发的消息数 群人数 群发言人数 群共同好友数 最早时间 最新时间 操作 ) } return ( 选择 会话名(头像/名称/微信号) 总消息 语音 图片 视频 表情包 最早时间 最新时间 操作 ) } const renderRowCells = (session: SessionRow) => { const metrics = sessionMetrics[session.username] || {} const checked = selectedSessions.has(session.username) return ( <> {renderSessionName(session)} {valueOrDash(metrics.totalMessages)} {valueOrDash(metrics.voiceMessages)} {valueOrDash(metrics.imageMessages)} {valueOrDash(metrics.videoMessages)} {valueOrDash(metrics.emojiMessages)} {(activeTab === 'private' || activeTab === 'former_friend') && ( <> {valueOrDash(metrics.privateMutualGroups)} {timestampOrDash(metrics.firstTimestamp)} {timestampOrDash(metrics.lastTimestamp)} )} {activeTab === 'group' && ( <> {valueOrDash(metrics.groupMyMessages)} {valueOrDash(metrics.groupMemberCount)} {valueOrDash(metrics.groupActiveSpeakers)} {valueOrDash(metrics.groupMutualFriends)} {timestampOrDash(metrics.firstTimestamp)} {timestampOrDash(metrics.lastTimestamp)} )} {activeTab === 'official' && ( <> {timestampOrDash(metrics.firstTimestamp)} {timestampOrDash(metrics.lastTimestamp)} )} {renderActionCell(session)} ) } const visibleSelectedCount = useMemo(() => { const visibleSet = new Set(visibleSessions.map(session => session.username)) let count = 0 for (const id of selectedSessions) { if (visibleSet.has(id)) count += 1 } return count }, [visibleSessions, selectedSessions]) 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 const hasTabCountsSource = prefetchedTabCounts !== null || sessions.length > 0 const isTabCountComputing = isTabCountsLoading && !hasTabCountsSource const isSessionCardStatsLoading = isLoading || isBaseConfigLoading const taskRunningCount = tasks.filter(task => task.status === 'running').length const taskQueuedCount = tasks.filter(task => task.status === 'queued').length const showInitialSkeleton = isLoading && sessions.length === 0 const chooseExportFolder = useCallback(async () => { const result = await window.electronAPI.dialog.openFile({ title: '选择导出目录', properties: ['openDirectory'] }) if (!result.canceled && result.filePaths.length > 0) { const nextPath = result.filePaths[0] setExportFolder(nextPath) await configService.setExportPath(nextPath) } }, []) return (
导出位置
{ setWriteLayout(value) await configService.setExportWriteLayout(value) }} />
{contentCards.map(card => { const Icon = card.icon const isCardStatsLoading = card.type === 'sns' ? (isSnsStatsLoading || isBaseConfigLoading) : isSessionCardStatsLoading return (
{card.label}
{card.stats.map((stat) => (
{stat.label} {isCardStatsLoading ? ( 统计中 ) : stat.value.toLocaleString()}
))}
) })}
任务中心
进行中 {taskRunningCount} 排队 {taskQueuedCount} 总计 {tasks.length}
{isTaskCenterExpanded && (tasks.length === 0 ? (
暂无任务。点击会话导出或卡片导出后会在这里创建任务。
) : (
{tasks.map(task => (
{task.title}
{task.status === 'queued' ? '排队中' : task.status === 'running' ? '进行中' : task.status === 'success' ? '已完成' : '失败'} {new Date(task.createdAt).toLocaleString('zh-CN')}
{task.status === 'running' && ( <>
0 ? (task.progress.current / task.progress.total) * 100 : 0}%` }} />
{task.progress.total > 0 ? `${task.progress.current} / ${task.progress.total}` : '处理中'} {task.progress.phaseLabel ? ` · ${task.progress.phaseLabel}` : ''}
)} {task.status === 'error' &&
{task.error || '任务失败'}
}
))}
))}
setSearchKeyword(event.target.value)} placeholder={`搜索${activeTabLabel}会话...`} /> {searchKeyword && ( )}
{selectedCount > 0 && (
已选中 {selectedCount} 个会话
)}
{(isLoading || isSessionEnriching) && (
{isLoading ? '正在加载会话列表…' : '正在补充头像和统计…'}
)}
{showInitialSkeleton ? (
{Array.from({ length: 8 }).map((_, rowIndex) => (
))}
) : visibleSessions.length === 0 ? (
暂无会话
) : ( session.username} rangeChanged={handleTableRangeChanged} itemContent={(_, session) => renderRowCells(session)} overscan={420} /> )}
{exportDialog.open && (
event.stopPropagation()}>

{exportDialog.title}

导出范围

{scopeLabel} {scopeCountLabel}
{exportDialog.sessionNames.slice(0, 20).map(name => ( {name} ))} {exportDialog.sessionNames.length > 20 && ... 还有 {exportDialog.sessionNames.length - 20} 个}

对话文本导出格式选择

{formatCandidateOptions.map(option => ( ))}

时间范围

导出全部时间
{!options.useAllTime && options.dateRange && (
)}

媒体与头像

导出媒体文件

发送者名称显示

{displayNameOptions.map(option => ( ))}
)}
) } export default ExportPage