import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useLocation } from 'react-router-dom' import { 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' type TaskStatus = 'queued' | 'running' | 'success' | 'error' type TaskScope = 'single' | 'multi' | 'content' type ContentType = 'text' | 'voice' | 'image' | 'video' | 'emoji' 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[] } 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 } interface CurrentUserProfile { wxid: string displayName: string avatarUrl?: 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' 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)}` function ExportPage() { const location = useLocation() const [isLoading, setIsLoading] = useState(true) const [sessions, setSessions] = useState([]) const [contactMap, setContactMap] = useState>({}) const [groupMemberCountMap, setGroupMemberCountMap] = useState>({}) const [sessionMetrics, setSessionMetrics] = useState>({}) const [searchKeyword, setSearchKeyword] = useState('') const [activeTab, setActiveTab] = useState('private') const [selectedSessions, setSelectedSessions] = useState>(new Set()) const [currentUser, setCurrentUser] = useState({ wxid: '', displayName: '未识别用户' }) const [exportFolder, setExportFolder] = useState('') const [writeLayout, setWriteLayout] = useState('A') const [showWriteLayoutSelect, setShowWriteLayoutSelect] = useState(false) 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 [nowTick, setNowTick] = useState(Date.now()) const progressUnsubscribeRef = useRef<(() => void) | null>(null) const runningTaskIdRef = useRef(null) const tasksRef = useRef([]) const loadingMetricsRef = useRef>(new Set()) const preselectAppliedRef = useRef(false) useEffect(() => { tasksRef.current = tasks }, [tasks]) 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 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([ configService.getExportPath(), configService.getExportDefaultFormat(), configService.getExportDefaultMedia(), configService.getExportDefaultVoiceAsText(), configService.getExportDefaultExcelCompactColumns(), configService.getExportDefaultTxtColumns(), configService.getExportDefaultConcurrency(), configService.getExportWriteLayout(), configService.getExportLastSessionRunMap(), configService.getExportLastContentRunMap() ]) if (savedPath) { setExportFolder(savedPath) } else { const downloadsPath = await window.electronAPI.app.getDownloadsPath() setExportFolder(downloadsPath) } setWriteLayout(savedWriteLayout) setLastExportBySession(savedSessionMap) setLastExportByContent(savedContentMap) 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) } }, []) const loadSessions = useCallback(async () => { setIsLoading(true) try { const connectResult = await window.electronAPI.chat.connect() if (!connectResult.success) { console.error('连接失败:', connectResult.error) setIsLoading(false) return } const [sessionsResult, contactsResult, groupChatsResult] = await Promise.all([ window.electronAPI.chat.getSessions(), window.electronAPI.chat.getContacts(), window.electronAPI.groupAnalytics.getGroupChats() ]) const contacts: ContactInfo[] = contactsResult.success && contactsResult.contacts ? contactsResult.contacts : [] const nextContactMap = contacts.reduce>((map, contact) => { map[contact.username] = contact return map }, {}) setContactMap(nextContactMap) const nextGroupMemberCountMap: Record = {} if (groupChatsResult.success && groupChatsResult.data) { for (const group of groupChatsResult.data) { nextGroupMemberCountMap[group.username] = group.memberCount } } setGroupMemberCountMap(nextGroupMemberCountMap) if (sessionsResult.success && sessionsResult.sessions) { const nextSessions = sessionsResult.sessions .map((session) => { const contact = nextContactMap[session.username] const kind = toKindByContactType(session, contact) return { ...session, kind, wechatId: contact?.username || session.username, displayName: session.displayName || contact?.displayName || session.username, avatarUrl: session.avatarUrl || contact?.avatarUrl } as SessionRow }) .sort((a, b) => (b.sortTimestamp || b.lastTimestamp || 0) - (a.sortTimestamp || a.lastTimestamp || 0)) setSessions(nextSessions) } } catch (error) { console.error('加载会话失败:', error) } finally { setIsLoading(false) } }, []) useEffect(() => { loadCurrentUser() loadBaseConfig() loadSessions() }, [loadCurrentUser, loadBaseConfig, loadSessions]) 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) ) }) }, [sessions, activeTab, searchKeyword]) const ensureSessionMetrics = useCallback(async (targetSessions: SessionRow[]) => { const pending = targetSessions.filter(session => !sessionMetrics[session.username] && !loadingMetricsRef.current.has(session.username)) if (pending.length === 0) return for (const session of pending) { loadingMetricsRef.current.add(session.username) } const updates: Record = {} for (const session of pending) { const metrics: SessionMetrics = {} try { const detailResult = await window.electronAPI.chat.getSessionDetail(session.username) if (detailResult.success && detailResult.detail) { metrics.totalMessages = detailResult.detail.messageCount metrics.firstTimestamp = detailResult.detail.firstMessageTime metrics.lastTimestamp = detailResult.detail.latestMessageTime } const exportStats = await window.electronAPI.export.getExportStats([session.username], { exportVoiceAsText: false, exportMedia: true, exportImages: true, exportVoices: true, exportVideos: true, exportEmojis: true, dateRange: null }) metrics.voiceMessages = exportStats.voiceMessages if (metrics.totalMessages === undefined) { metrics.totalMessages = exportStats.totalMessages } if (session.kind === 'group') { metrics.groupMemberCount = groupMemberCountMap[session.username] const [mediaStatsResult, rankingResult] = await Promise.all([ window.electronAPI.groupAnalytics.getGroupMediaStats(session.username), window.electronAPI.groupAnalytics.getGroupMessageRanking(session.username) ]) if (mediaStatsResult.success && mediaStatsResult.data?.typeCounts) { for (const item of mediaStatsResult.data.typeCounts) { const n = item.name.toLowerCase() if (n.includes('图片')) metrics.imageMessages = item.count if (n.includes('视频')) metrics.videoMessages = item.count if (n.includes('语音')) metrics.voiceMessages = item.count if (n.includes('表情')) metrics.emojiMessages = item.count } } if (rankingResult.success && rankingResult.data) { metrics.groupActiveSpeakers = rankingResult.data.length const selfWxid = session.selfWxid || currentUser.wxid const me = rankingResult.data.find(item => item.member.username === selfWxid) if (me) { metrics.groupMyMessages = me.messageCount } } } } catch (error) { console.error('加载会话统计失败:', session.username, error) } finally { loadingMetricsRef.current.delete(session.username) } updates[session.username] = metrics } if (Object.keys(updates).length > 0) { setSessionMetrics(prev => ({ ...prev, ...updates })) } }, [sessionMetrics, groupMemberCountMap, currentUser.wxid]) useEffect(() => { const targets = visibleSessions.slice(0, 40) void ensureSessionMetrics(targets) }, [visibleSessions, 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 === '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 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?.() 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) { 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]) useEffect(() => { void runNextTask() }, [tasks, runNextTask]) useEffect(() => { return () => { progressUnsubscribeRef.current?.() progressUnsubscribeRef.current = null } }, []) const createTask = async () => { if (!exportDialog.open || exportDialog.sessionIds.length === 0 || !exportFolder) return const exportOptions = buildExportOptions(exportDialog.scope, exportDialog.contentType) const title = exportDialog.scope === 'single' ? `${exportDialog.sessionNames[0] || '会话'} 导出` : exportDialog.scope === 'multi' ? `批量导出(${exportDialog.sessionIds.length} 个会话)` : `${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 }, 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 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 contentCards = useMemo(() => { const scopeSessions = sessions.filter(session => session.kind === 'private' || session.kind === 'group') const total = scopeSessions.length return [ { 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], total, exported } }) }, [sessions, lastExportByContent]) const activeTabLabel = useMemo(() => { if (activeTab === 'private') return '私聊' if (activeTab === 'group') 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}
) } const renderTableHeader = () => { if (activeTab === 'private') { return ( 选择 会话名(头像/昵称/微信号) 总消息 语音 图片 视频 表情包 共同群聊数 最早时间 最新时间 操作 ) } if (activeTab === 'group') { return ( 选择 会话名(群头像/群名称/群ID) 总消息 语音 图片 视频 表情包 我发的消息数 群人数 群发言人数 群共同好友数 最早时间 最新时间 操作 ) } return ( 选择 会话名(头像/名称/微信号) 总消息 语音 图片 视频 表情包 最早时间 最新时间 操作 ) } const renderRow = (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' && ( <> {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 writeLayoutLabel = writeLayoutOptions.find(option => option.value === writeLayout)?.label || 'A(类型分目录)' return (
{currentUser.avatarUrl ? : {getAvatarLetter(currentUser.displayName)}}
{currentUser.displayName}
{currentUser.wxid || 'wxid 未识别'}
导出位置
{exportFolder || '未设置'}
写入格式 {showWriteLayoutSelect && (
{writeLayoutOptions.map(option => ( ))}
)}
{contentCards.map(card => { const Icon = card.icon return (
{card.label}
总会话数 {card.total}
已导出会话数 {card.exported}
) })}
任务中心
{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.current} / {task.progress.total || task.payload.sessionIds.length} {task.progress.phaseLabel ? ` · ${task.progress.phaseLabel}` : ''}
)} {task.status === 'error' &&
{task.error || '任务失败'}
}
))}
)}
setSearchKeyword(event.target.value)} placeholder={`搜索${activeTabLabel}会话...`} /> {searchKeyword && ( )}
{selectedCount > 0 && (
已选中 {selectedCount} 个会话
)}
{renderTableHeader()} {isLoading ? ( ) : visibleSessions.length === 0 ? ( ) : ( visibleSessions.map(renderRow) )}
加载中...
暂无会话
{exportDialog.open && (
event.stopPropagation()}>

{exportDialog.title}

导出范围

{exportDialog.scope === 'single' ? '单会话' : exportDialog.scope === 'multi' ? '多会话' : `按内容批量(${contentTypeLabels[exportDialog.contentType || 'text']})`} 共 {exportDialog.sessionIds.length} 个会话
{exportDialog.sessionNames.slice(0, 20).map(name => ( {name} ))} {exportDialog.sessionNames.length > 20 && ... 还有 {exportDialog.sessionNames.length - 20} 个}

对话文本导出格式选择

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

时间范围

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

媒体与头像

导出媒体文件

发送者名称显示

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