mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
1420 lines
50 KiB
TypeScript
1420 lines
50 KiB
TypeScript
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
|