Files
WeFlow/src/pages/ExportPage.tsx
2026-03-04 21:16:39 +08:00

1420 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<ContentType, string> = {
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<SessionRow[]>([])
const [contactMap, setContactMap] = useState<Record<string, ContactInfo>>({})
const [groupMemberCountMap, setGroupMemberCountMap] = useState<Record<string, number>>({})
const [sessionMetrics, setSessionMetrics] = useState<Record<string, SessionMetrics>>({})
const [searchKeyword, setSearchKeyword] = useState('')
const [activeTab, setActiveTab] = useState<ConversationTab>('private')
const [selectedSessions, setSelectedSessions] = useState<Set<string>>(new Set())
const [currentUser, setCurrentUser] = useState<CurrentUserProfile>({ wxid: '', displayName: '未识别用户' })
const [exportFolder, setExportFolder] = useState('')
const [writeLayout, setWriteLayout] = useState<configService.ExportWriteLayout>('A')
const [showWriteLayoutSelect, setShowWriteLayoutSelect] = useState(false)
const [options, setOptions] = useState<ExportOptions>({
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<ExportDialogState>({
open: false,
scope: 'single',
sessionIds: [],
sessionNames: [],
title: ''
})
const [tasks, setTasks] = useState<ExportTask[]>([])
const [lastExportBySession, setLastExportBySession] = useState<Record<string, number>>({})
const [lastExportByContent, setLastExportByContent] = useState<Record<string, number>>({})
const [nowTick, setNowTick] = useState(Date.now())
const progressUnsubscribeRef = useRef<(() => void) | null>(null)
const runningTaskIdRef = useRef<string | null>(null)
const tasksRef = useRef<ExportTask[]>([])
const loadingMetricsRef = useRef<Set<string>>(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<Record<string, ContactInfo>>((map, contact) => {
map[contact.username] = contact
return map
}, {})
setContactMap(nextContactMap)
const nextGroupMemberCountMap: Record<string, number> = {}
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<string, SessionMetrics> = {}
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<ExportDialogState, 'open'>) => {
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<string>()
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<string>()
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 (
<div className="session-cell">
<div className="session-avatar">
{session.avatarUrl ? <img src={session.avatarUrl} alt="" /> : <span>{getAvatarLetter(session.displayName || session.username)}</span>}
</div>
<div className="session-meta">
<div className="session-name">{session.displayName || session.username}</div>
<div className="session-id">{session.wechatId || session.username}</div>
</div>
</div>
)
}
const renderActionCell = (session: SessionRow) => {
const isRunning = runningSessionIds.has(session.username)
const isQueued = queuedSessionIds.has(session.username)
const recent = formatRecentExportTime(lastExportBySession[session.username], nowTick)
return (
<div className="row-action-cell">
<button
className={`row-export-btn ${isRunning ? 'running' : ''}`}
disabled={isRunning}
onClick={() => openSingleExport(session)}
>
{isRunning ? (
<>
<Loader2 size={14} className="spin" />
</>
) : isQueued ? '排队中' : '导出'}
</button>
<span className="row-export-time">{recent}</span>
</div>
)
}
const renderTableHeader = () => {
if (activeTab === 'private') {
return (
<tr>
<th className="sticky-col"></th>
<th>//</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th className="sticky-right"></th>
</tr>
)
}
if (activeTab === 'group') {
return (
<tr>
<th className="sticky-col"></th>
<th>//ID</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th className="sticky-right"></th>
</tr>
)
}
return (
<tr>
<th className="sticky-col"></th>
<th>//</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th className="sticky-right"></th>
</tr>
)
}
const renderRow = (session: SessionRow) => {
const metrics = sessionMetrics[session.username] || {}
const checked = selectedSessions.has(session.username)
return (
<tr key={session.username} className={checked ? 'selected-row' : ''}>
<td className="sticky-col">
<button
className={`select-icon-btn ${checked ? 'checked' : ''}`}
onClick={() => toggleSelectSession(session.username)}
title={checked ? '取消选择' : '选择会话'}
>
{checked ? <CheckSquare size={16} /> : <Square size={16} />}
</button>
</td>
<td>{renderSessionName(session)}</td>
<td>{valueOrDash(metrics.totalMessages)}</td>
<td>{valueOrDash(metrics.voiceMessages)}</td>
<td>{valueOrDash(metrics.imageMessages)}</td>
<td>{valueOrDash(metrics.videoMessages)}</td>
<td>{valueOrDash(metrics.emojiMessages)}</td>
{activeTab === 'private' && (
<>
<td>{valueOrDash(metrics.privateMutualGroups)}</td>
<td>{timestampOrDash(metrics.firstTimestamp)}</td>
<td>{timestampOrDash(metrics.lastTimestamp)}</td>
</>
)}
{activeTab === 'group' && (
<>
<td>{valueOrDash(metrics.groupMyMessages)}</td>
<td>{valueOrDash(metrics.groupMemberCount)}</td>
<td>{valueOrDash(metrics.groupActiveSpeakers)}</td>
<td>{valueOrDash(metrics.groupMutualFriends)}</td>
<td>{timestampOrDash(metrics.firstTimestamp)}</td>
<td>{timestampOrDash(metrics.lastTimestamp)}</td>
</>
)}
{activeTab === 'official' && (
<>
<td>{timestampOrDash(metrics.firstTimestamp)}</td>
<td>{timestampOrDash(metrics.lastTimestamp)}</td>
</>
)}
<td className="sticky-right">{renderActionCell(session)}</td>
</tr>
)
}
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 (
<div className="export-board-page">
<div className="export-top-panel">
<div className="current-user-box">
<div className="avatar-wrap">
{currentUser.avatarUrl ? <img src={currentUser.avatarUrl} alt="" /> : <span>{getAvatarLetter(currentUser.displayName)}</span>}
</div>
<div className="user-meta">
<div className="user-name">{currentUser.displayName}</div>
<div className="user-wxid">{currentUser.wxid || 'wxid 未识别'}</div>
</div>
</div>
<div className="global-export-controls">
<div className="path-control">
<span className="control-label"></span>
<div className="path-value" title={exportFolder}>{exportFolder || '未设置'}</div>
<div className="path-actions">
<button className="secondary-btn" onClick={() => exportFolder && void window.electronAPI.shell.openPath(exportFolder)}>
<ExternalLink size={14} />
</button>
<button
className="secondary-btn"
onClick={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)
}
}}
>
<FolderOpen size={14} />
</button>
</div>
</div>
<div className="write-layout-control">
<span className="control-label"></span>
<button className="layout-trigger" onClick={() => setShowWriteLayoutSelect(prev => !prev)}>
{writeLayoutLabel}
</button>
{showWriteLayoutSelect && (
<div className="layout-dropdown">
{writeLayoutOptions.map(option => (
<button
key={option.value}
className={`layout-option ${writeLayout === option.value ? 'active' : ''}`}
onClick={async () => {
setWriteLayout(option.value)
setShowWriteLayoutSelect(false)
await configService.setExportWriteLayout(option.value)
}}
>
<span className="layout-option-label">{option.label}</span>
<span className="layout-option-desc">{option.desc}</span>
</button>
))}
</div>
)}
</div>
</div>
</div>
<div className="content-card-grid">
{contentCards.map(card => {
const Icon = card.icon
return (
<div key={card.type} className="content-card">
<div className="card-header">
<div className="card-title"><Icon size={16} /> {card.label}</div>
</div>
<div className="card-stats">
<div className="stat-item">
<span></span>
<strong>{card.total}</strong>
</div>
<div className="stat-item">
<span></span>
<strong>{card.exported}</strong>
</div>
</div>
<button className="card-export-btn" onClick={() => openContentExport(card.type)}></button>
</div>
)
})}
</div>
<div className="task-center">
<div className="section-title"></div>
{tasks.length === 0 ? (
<div className="task-empty"></div>
) : (
<div className="task-list">
{tasks.map(task => (
<div key={task.id} className={`task-card ${task.status}`}>
<div className="task-main">
<div className="task-title">{task.title}</div>
<div className="task-meta">
<span className={`task-status ${task.status}`}>{task.status === 'queued' ? '排队中' : task.status === 'running' ? '进行中' : task.status === 'success' ? '已完成' : '失败'}</span>
<span>{new Date(task.createdAt).toLocaleString('zh-CN')}</span>
</div>
{task.status === 'running' && (
<>
<div className="task-progress-bar">
<div
className="task-progress-fill"
style={{ width: `${task.progress.total > 0 ? (task.progress.current / task.progress.total) * 100 : 0}%` }}
/>
</div>
<div className="task-progress-text">
{task.progress.current} / {task.progress.total || task.payload.sessionIds.length}
{task.progress.phaseLabel ? ` · ${task.progress.phaseLabel}` : ''}
</div>
</>
)}
{task.status === 'error' && <div className="task-error">{task.error || '任务失败'}</div>}
</div>
<div className="task-actions">
<button className="secondary-btn" onClick={() => exportFolder && void window.electronAPI.shell.openPath(exportFolder)}>
<FolderOpen size={14} />
</button>
</div>
</div>
))}
</div>
)}
</div>
<div className="session-table-section">
<div className="table-toolbar">
<div className="table-tabs" role="tablist" aria-label="会话类型">
<button className={`tab-btn ${activeTab === 'private' ? 'active' : ''}`} onClick={() => setActiveTab('private')}></button>
<button className={`tab-btn ${activeTab === 'group' ? 'active' : ''}`} onClick={() => setActiveTab('group')}></button>
<button className={`tab-btn ${activeTab === 'official' ? 'active' : ''}`} onClick={() => setActiveTab('official')}></button>
</div>
<div className="toolbar-actions">
<div className="search-input-wrap">
<Search size={14} />
<input
value={searchKeyword}
onChange={(event) => setSearchKeyword(event.target.value)}
placeholder={`搜索${activeTabLabel}会话...`}
/>
{searchKeyword && (
<button className="clear-search" onClick={() => setSearchKeyword('')}>
<X size={12} />
</button>
)}
</div>
<button className="secondary-btn" onClick={toggleSelectAllVisible}>
{visibleSelectedCount > 0 && visibleSelectedCount === visibleSessions.length ? '取消全选' : '全选当前'}
</button>
{selectedCount > 0 && (
<div className="selected-batch-actions">
<span> {selectedCount} </span>
<button className="primary-btn" onClick={openBatchExport}>
<Download size={14} />
</button>
<button className="secondary-btn" onClick={clearSelection}></button>
</div>
)}
</div>
</div>
<div className="table-wrap">
<table className="session-table">
<thead>{renderTableHeader()}</thead>
<tbody>
{isLoading ? (
<tr>
<td colSpan={activeTab === 'group' ? 14 : activeTab === 'private' ? 11 : 10}>
<div className="table-state"><Loader2 size={16} className="spin" />...</div>
</td>
</tr>
) : visibleSessions.length === 0 ? (
<tr>
<td colSpan={activeTab === 'group' ? 14 : activeTab === 'private' ? 11 : 10}>
<div className="table-state"></div>
</td>
</tr>
) : (
visibleSessions.map(renderRow)
)}
</tbody>
</table>
</div>
</div>
{exportDialog.open && (
<div className="export-dialog-overlay" onClick={closeExportDialog}>
<div className="export-dialog" onClick={(event) => event.stopPropagation()}>
<div className="dialog-header">
<h3>{exportDialog.title}</h3>
<button className="close-icon-btn" onClick={closeExportDialog}><X size={16} /></button>
</div>
<div className="dialog-section">
<h4></h4>
<div className="scope-tag-row">
<span className="scope-tag">{exportDialog.scope === 'single' ? '单会话' : exportDialog.scope === 'multi' ? '多会话' : `按内容批量(${contentTypeLabels[exportDialog.contentType || 'text']}`}</span>
<span className="scope-count"> {exportDialog.sessionIds.length} </span>
</div>
<div className="scope-list">
{exportDialog.sessionNames.slice(0, 20).map(name => (
<span key={name} className="scope-item">{name}</span>
))}
{exportDialog.sessionNames.length > 20 && <span className="scope-item">... {exportDialog.sessionNames.length - 20} </span>}
</div>
</div>
<div className="dialog-section">
<h4></h4>
<div className="format-grid">
{formatOptions.map(option => (
<button
key={option.value}
className={`format-card ${options.format === option.value ? 'active' : ''}`}
onClick={() => setOptions(prev => ({ ...prev, format: option.value }))}
>
<div className="format-label">{option.label}</div>
<div className="format-desc">{option.desc}</div>
</button>
))}
</div>
</div>
<div className="dialog-section">
<h4></h4>
<div className="switch-row">
<span></span>
<label className="switch">
<input
type="checkbox"
checked={options.useAllTime}
onChange={(event) => setOptions(prev => ({ ...prev, useAllTime: event.target.checked }))}
/>
<span className="switch-slider"></span>
</label>
</div>
{!options.useAllTime && options.dateRange && (
<div className="date-range-row">
<label>
<input
type="date"
value={formatDateInputValue(options.dateRange.start)}
onChange={(event) => {
const start = parseDateInput(event.target.value, false)
setOptions(prev => ({
...prev,
dateRange: prev.dateRange ? {
start,
end: prev.dateRange.end < start ? parseDateInput(event.target.value, true) : prev.dateRange.end
} : { start, end: new Date() }
}))
}}
/>
</label>
<label>
<input
type="date"
value={formatDateInputValue(options.dateRange.end)}
onChange={(event) => {
const end = parseDateInput(event.target.value, true)
setOptions(prev => ({
...prev,
dateRange: prev.dateRange ? {
start: prev.dateRange.start > end ? parseDateInput(event.target.value, false) : prev.dateRange.start,
end
} : { start: new Date(), end }
}))
}}
/>
</label>
</div>
)}
</div>
<div className="dialog-section">
<h4></h4>
<div className="switch-row">
<span></span>
<label className="switch">
<input
type="checkbox"
checked={options.exportMedia}
onChange={(event) => setOptions(prev => ({ ...prev, exportMedia: event.target.checked }))}
/>
<span className="switch-slider"></span>
</label>
</div>
<div className="media-check-grid">
<label><input type="checkbox" checked={options.exportImages} disabled={!options.exportMedia} onChange={event => setOptions(prev => ({ ...prev, exportImages: event.target.checked }))} /> </label>
<label><input type="checkbox" checked={options.exportVoices} disabled={!options.exportMedia} onChange={event => setOptions(prev => ({ ...prev, exportVoices: event.target.checked }))} /> </label>
<label><input type="checkbox" checked={options.exportVideos} disabled={!options.exportMedia} onChange={event => setOptions(prev => ({ ...prev, exportVideos: event.target.checked }))} /> </label>
<label><input type="checkbox" checked={options.exportEmojis} disabled={!options.exportMedia} onChange={event => setOptions(prev => ({ ...prev, exportEmojis: event.target.checked }))} /> </label>
<label><input type="checkbox" checked={options.exportVoiceAsText} onChange={event => setOptions(prev => ({ ...prev, exportVoiceAsText: event.target.checked }))} /> </label>
<label><input type="checkbox" checked={options.exportAvatars} onChange={event => setOptions(prev => ({ ...prev, exportAvatars: event.target.checked }))} /> </label>
</div>
</div>
<div className="dialog-section">
<h4></h4>
<div className="display-name-options">
{displayNameOptions.map(option => (
<label key={option.value} className={`display-name-item ${options.displayNamePreference === option.value ? 'active' : ''}`}>
<input
type="radio"
checked={options.displayNamePreference === option.value}
onChange={() => setOptions(prev => ({ ...prev, displayNamePreference: option.value }))}
/>
<span>{option.label}</span>
<small>{option.desc}</small>
</label>
))}
</div>
</div>
<div className="dialog-actions">
<button className="secondary-btn" onClick={closeExportDialog}></button>
<button className="primary-btn" onClick={() => void createTask()} disabled={!exportFolder || exportDialog.sessionIds.length === 0}>
<Download size={14} />
</button>
</div>
</div>
</div>
)}
</div>
)
}
export default ExportPage