Files
weflow-new/src/pages/ExportPage.tsx
T

3070 lines
119 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 { memo, useCallback, useEffect, useMemo, useRef, useState, type UIEvent } from 'react'
import { useLocation } from 'react-router-dom'
import { TableVirtuoso } from 'react-virtuoso'
import {
Aperture,
Calendar,
Check,
ChevronDown,
ChevronRight,
CheckSquare,
Copy,
Database,
Download,
ExternalLink,
FolderOpen,
Hash,
Image as ImageIcon,
Loader2,
AlertTriangle,
ClipboardList,
MessageSquare,
MessageSquareText,
Mic,
RefreshCw,
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 { useContactTypeCountsStore } from '../stores/contactTypeCountsStore'
import './ExportPage.scss'
type ConversationTab = 'private' | 'group' | 'official' | 'former_friend'
type TaskStatus = 'queued' | 'running' | 'success' | 'error'
type TaskScope = 'single' | 'multi' | 'content' | 'sns'
type ContentType = 'text' | 'voice' | 'image' | 'video' | 'emoji'
type ContentCardType = ContentType | 'sns'
type SessionLayout = 'shared' | 'per-session'
type DisplayNamePreference = 'group-nickname' | 'remark' | 'nickname'
type TextExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
interface ExportOptions {
format: TextExportFormat
dateRange: { start: Date; end: Date } | null
useAllTime: boolean
exportAvatars: boolean
exportMedia: boolean
exportImages: boolean
exportVoices: boolean
exportVideos: boolean
exportEmojis: boolean
exportVoiceAsText: boolean
excelCompactColumns: boolean
txtColumns: string[]
displayNamePreference: DisplayNamePreference
exportConcurrency: number
}
interface SessionRow extends AppChatSession {
kind: ConversationTab
wechatId?: string
hasSession: boolean
}
interface TaskProgress {
current: number
total: number
currentName: string
phaseLabel: string
phaseProgress: number
phaseTotal: number
}
interface ExportTaskPayload {
sessionIds: string[]
outputDir: string
options?: ElectronExportOptions
scope: TaskScope
contentType?: ContentType
sessionNames: string[]
snsOptions?: {
format: 'json' | 'html'
exportMedia?: boolean
startTime?: number
endTime?: number
}
}
interface ExportTask {
id: string
title: string
status: TaskStatus
createdAt: number
startedAt?: number
finishedAt?: number
error?: string
payload: ExportTaskPayload
progress: TaskProgress
}
interface ExportDialogState {
open: boolean
scope: TaskScope
contentType?: ContentType
sessionIds: string[]
sessionNames: string[]
title: string
}
const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content']
const contentTypeLabels: Record<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 formatYmdDateFromSeconds = (timestamp?: number): string => {
if (!timestamp || !Number.isFinite(timestamp)) return '—'
const d = new Date(timestamp * 1000)
const y = d.getFullYear()
const m = `${d.getMonth() + 1}`.padStart(2, '0')
const day = `${d.getDate()}`.padStart(2, '0')
return `${y}-${m}-${day}`
}
const formatRecentExportTime = (timestamp?: number, now = Date.now()): string => {
if (!timestamp) return ''
const diff = Math.max(0, now - timestamp)
const minute = 60 * 1000
const hour = 60 * minute
const day = 24 * hour
if (diff < hour) {
const minutes = Math.max(1, Math.floor(diff / minute))
return `${minutes} 分钟前`
}
if (diff < day) {
const hours = Math.max(1, Math.floor(diff / hour))
return `${hours} 小时前`
}
return formatAbsoluteDate(timestamp)
}
const formatDateInputValue = (date: Date): string => {
const y = date.getFullYear()
const m = `${date.getMonth() + 1}`.padStart(2, '0')
const d = `${date.getDate()}`.padStart(2, '0')
return `${y}-${m}-${d}`
}
const parseDateInput = (value: string, endOfDay: boolean): Date => {
const [year, month, day] = value.split('-').map(v => Number(v))
const date = new Date(year, month - 1, day)
if (endOfDay) {
date.setHours(23, 59, 59, 999)
} else {
date.setHours(0, 0, 0, 0)
}
return date
}
const toKindByContactType = (session: AppChatSession, contact?: ContactInfo): ConversationTab => {
if (session.username.endsWith('@chatroom')) return 'group'
if (contact?.type === 'official') return 'official'
if (contact?.type === 'former_friend') return 'former_friend'
return 'private'
}
const toKindByContact = (contact: ContactInfo): ConversationTab => {
if (contact.type === 'group') return 'group'
if (contact.type === 'official') return 'official'
if (contact.type === 'former_friend') return 'former_friend'
return 'private'
}
const isContentScopeSession = (session: SessionRow): boolean => (
session.kind === 'private' || session.kind === 'group' || session.kind === 'former_friend'
)
const getAvatarLetter = (name: string): string => {
if (!name) return '?'
return [...name][0] || '?'
}
const matchesContactTab = (contact: ContactInfo, tab: ConversationTab): boolean => {
if (tab === 'private') return contact.type === 'friend'
if (tab === 'group') return contact.type === 'group'
if (tab === 'official') return contact.type === 'official'
return contact.type === 'former_friend'
}
const getContactTypeName = (type: ContactInfo['type']): string => {
if (type === 'friend') return '好友'
if (type === 'group') return '群聊'
if (type === 'official') return '公众号'
if (type === 'former_friend') return '曾经的好友'
return '其他'
}
const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const CONTACT_ENRICH_TIMEOUT_MS = 7000
const EXPORT_SNS_STATS_CACHE_STALE_MS = 12 * 60 * 60 * 1000
const EXPORT_AVATAR_RECHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
const EXPORT_AVATAR_ENRICH_BATCH_SIZE = 80
const CONTACTS_LIST_VIRTUAL_ROW_HEIGHT = 76
const CONTACTS_LIST_VIRTUAL_OVERSCAN = 10
const DEFAULT_CONTACTS_LOAD_TIMEOUT_MS = 3000
type SessionDataSource = 'cache' | 'network' | null
type ContactsDataSource = 'cache' | 'network' | null
interface ContactsLoadSession {
requestId: string
startedAt: number
attempt: number
timeoutMs: number
}
interface ContactsLoadIssue {
kind: 'timeout' | 'error'
title: string
message: string
reason: string
errorDetail?: string
occurredAt: number
elapsedMs: number
}
interface SessionDetail {
wxid: string
displayName: string
remark?: string
nickName?: string
alias?: string
avatarUrl?: string
messageCount: number
voiceMessages?: number
imageMessages?: number
videoMessages?: number
emojiMessages?: number
privateMutualGroups?: number
groupMemberCount?: number
groupMyMessages?: number
groupActiveSpeakers?: number
groupMutualFriends?: number
firstMessageTime?: number
latestMessageTime?: number
messageTables: { dbName: string; tableName: string; count: number }[]
}
const withTimeout = async <T,>(promise: Promise<T>, timeoutMs: number): Promise<T | null> => {
let timer: ReturnType<typeof setTimeout> | null = null
try {
return await Promise.race([
promise,
new Promise<null>((resolve) => {
timer = setTimeout(() => resolve(null), timeoutMs)
})
])
} finally {
if (timer) {
clearTimeout(timer)
}
}
}
const toContactMapFromCaches = (
contacts: configService.ContactsListCacheContact[],
avatarEntries: Record<string, configService.ContactsAvatarCacheEntry>
): Record<string, ContactInfo> => {
const map: Record<string, ContactInfo> = {}
for (const contact of contacts || []) {
if (!contact?.username) continue
map[contact.username] = {
...contact,
avatarUrl: avatarEntries[contact.username]?.avatarUrl
}
}
return map
}
const mergeAvatarCacheIntoContacts = (
sourceContacts: ContactInfo[],
avatarEntries: Record<string, configService.ContactsAvatarCacheEntry>
): ContactInfo[] => {
if (!sourceContacts.length || Object.keys(avatarEntries).length === 0) {
return sourceContacts
}
let changed = false
const merged = sourceContacts.map((contact) => {
const cachedAvatar = avatarEntries[contact.username]?.avatarUrl
if (!cachedAvatar || contact.avatarUrl) {
return contact
}
changed = true
return {
...contact,
avatarUrl: cachedAvatar
}
})
return changed ? merged : sourceContacts
}
const upsertAvatarCacheFromContacts = (
avatarEntries: Record<string, configService.ContactsAvatarCacheEntry>,
sourceContacts: ContactInfo[],
options?: { prune?: boolean; markCheckedUsernames?: string[]; now?: number }
): {
avatarEntries: Record<string, configService.ContactsAvatarCacheEntry>
changed: boolean
updatedAt: number | null
} => {
const nextCache = { ...avatarEntries }
const now = options?.now || Date.now()
const markCheckedSet = new Set((options?.markCheckedUsernames || []).filter(Boolean))
const usernamesInSource = new Set<string>()
let changed = false
for (const contact of sourceContacts) {
const username = String(contact.username || '').trim()
if (!username) continue
usernamesInSource.add(username)
const prev = nextCache[username]
const avatarUrl = String(contact.avatarUrl || '').trim()
if (!avatarUrl) continue
const updatedAt = !prev || prev.avatarUrl !== avatarUrl ? now : prev.updatedAt
const checkedAt = markCheckedSet.has(username) ? now : (prev?.checkedAt || now)
if (!prev || prev.avatarUrl !== avatarUrl || prev.updatedAt !== updatedAt || prev.checkedAt !== checkedAt) {
nextCache[username] = {
avatarUrl,
updatedAt,
checkedAt
}
changed = true
}
}
for (const username of markCheckedSet) {
const prev = nextCache[username]
if (!prev) continue
if (prev.checkedAt !== now) {
nextCache[username] = {
...prev,
checkedAt: now
}
changed = true
}
}
if (options?.prune) {
for (const username of Object.keys(nextCache)) {
if (usernamesInSource.has(username)) continue
delete nextCache[username]
changed = true
}
}
return {
avatarEntries: nextCache,
changed,
updatedAt: changed ? now : null
}
}
const toSessionRowsWithContacts = (
sessions: AppChatSession[],
contactMap: Record<string, ContactInfo>
): SessionRow[] => {
const sessionMap = new Map<string, AppChatSession>()
for (const session of sessions || []) {
sessionMap.set(session.username, session)
}
const contacts = Object.values(contactMap)
.filter((contact) => (
contact.type === 'friend' ||
contact.type === 'group' ||
contact.type === 'official' ||
contact.type === 'former_friend'
))
if (contacts.length > 0) {
return contacts
.map((contact) => {
const session = sessionMap.get(contact.username)
const latestTs = session?.sortTimestamp || session?.lastTimestamp || 0
return {
...(session || {
username: contact.username,
type: 0,
unreadCount: 0,
summary: '',
sortTimestamp: latestTs,
lastTimestamp: latestTs,
lastMsgType: 0
}),
username: contact.username,
kind: toKindByContact(contact),
wechatId: contact.username,
displayName: contact.displayName || session?.displayName || contact.username,
avatarUrl: contact.avatarUrl || session?.avatarUrl,
hasSession: Boolean(session)
} as SessionRow
})
.sort((a, b) => {
const latestA = a.sortTimestamp || a.lastTimestamp || 0
const latestB = b.sortTimestamp || b.lastTimestamp || 0
if (latestA !== latestB) return latestB - latestA
return (a.displayName || a.username).localeCompare(b.displayName || b.username, 'zh-Hans-CN')
})
}
return sessions
.map((session) => {
const contact = contactMap[session.username]
return {
...session,
kind: toKindByContactType(session, contact),
wechatId: contact?.username || session.username,
displayName: contact?.displayName || session.displayName || session.username,
avatarUrl: contact?.avatarUrl || session.avatarUrl,
hasSession: true
} as SessionRow
})
.sort((a, b) => (b.sortTimestamp || b.lastTimestamp || 0) - (a.sortTimestamp || a.lastTimestamp || 0))
}
const WriteLayoutSelector = memo(function WriteLayoutSelector({
writeLayout,
onChange
}: {
writeLayout: configService.ExportWriteLayout
onChange: (value: configService.ExportWriteLayout) => Promise<void>
}) {
const [isOpen, setIsOpen] = useState(false)
const containerRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (!isOpen) return
const handleOutsideClick = (event: MouseEvent) => {
if (containerRef.current?.contains(event.target as Node)) return
setIsOpen(false)
}
document.addEventListener('mousedown', handleOutsideClick)
return () => document.removeEventListener('mousedown', handleOutsideClick)
}, [isOpen])
const writeLayoutLabel = writeLayoutOptions.find(option => option.value === writeLayout)?.label || 'A(类型分目录)'
return (
<div className="write-layout-control" ref={containerRef}>
<span className="control-label">写入目录方式</span>
<button
className={`layout-trigger ${isOpen ? 'active' : ''}`}
type="button"
onClick={() => setIsOpen(prev => !prev)}
>
{writeLayoutLabel}
</button>
<div className={`layout-dropdown ${isOpen ? 'open' : ''}`}>
{writeLayoutOptions.map(option => (
<button
key={option.value}
className={`layout-option ${writeLayout === option.value ? 'active' : ''}`}
type="button"
onClick={async () => {
await onChange(option.value)
setIsOpen(false)
}}
>
<span className="layout-option-label">{option.label}</span>
<span className="layout-option-desc">{option.desc}</span>
</button>
))}
</div>
</div>
)
})
function ExportPage() {
const location = useLocation()
const isExportRoute = location.pathname === '/export'
const [isLoading, setIsLoading] = useState(true)
const [isSessionEnriching, setIsSessionEnriching] = useState(false)
const [isSnsStatsLoading, setIsSnsStatsLoading] = useState(true)
const [isBaseConfigLoading, setIsBaseConfigLoading] = useState(true)
const [isTaskCenterExpanded, setIsTaskCenterExpanded] = useState(false)
const [sessions, setSessions] = useState<SessionRow[]>([])
const [sessionDataSource, setSessionDataSource] = useState<SessionDataSource>(null)
const [sessionContactsUpdatedAt, setSessionContactsUpdatedAt] = useState<number | null>(null)
const [sessionAvatarUpdatedAt, setSessionAvatarUpdatedAt] = useState<number | null>(null)
const [searchKeyword, setSearchKeyword] = useState('')
const [activeTab, setActiveTab] = useState<ConversationTab>('private')
const [selectedSessions, setSelectedSessions] = useState<Set<string>>(new Set())
const [contactsList, setContactsList] = useState<ContactInfo[]>([])
const [isContactsListLoading, setIsContactsListLoading] = useState(true)
const [contactsDataSource, setContactsDataSource] = useState<ContactsDataSource>(null)
const [contactsUpdatedAt, setContactsUpdatedAt] = useState<number | null>(null)
const [avatarCacheUpdatedAt, setAvatarCacheUpdatedAt] = useState<number | null>(null)
const [contactsListScrollTop, setContactsListScrollTop] = useState(0)
const [contactsListViewportHeight, setContactsListViewportHeight] = useState(480)
const [contactsLoadTimeoutMs, setContactsLoadTimeoutMs] = useState(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS)
const [contactsLoadSession, setContactsLoadSession] = useState<ContactsLoadSession | null>(null)
const [contactsLoadIssue, setContactsLoadIssue] = useState<ContactsLoadIssue | null>(null)
const [showContactsDiagnostics, setShowContactsDiagnostics] = useState(false)
const [contactsDiagnosticTick, setContactsDiagnosticTick] = useState(Date.now())
const [contactsAvatarEnrichProgress, setContactsAvatarEnrichProgress] = useState({
loaded: 0,
total: 0,
running: false
})
const [showSessionDetailPanel, setShowSessionDetailPanel] = useState(false)
const [sessionDetail, setSessionDetail] = useState<SessionDetail | null>(null)
const [isLoadingSessionDetail, setIsLoadingSessionDetail] = useState(false)
const [isLoadingSessionDetailExtra, setIsLoadingSessionDetailExtra] = useState(false)
const [copiedDetailField, setCopiedDetailField] = useState<string | null>(null)
const [exportFolder, setExportFolder] = useState('')
const [writeLayout, setWriteLayout] = useState<configService.ExportWriteLayout>('A')
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 [lastSnsExportPostCount, setLastSnsExportPostCount] = useState(0)
const [snsStats, setSnsStats] = useState<{ totalPosts: number; totalFriends: number }>({
totalPosts: 0,
totalFriends: 0
})
const [hasSeededSnsStats, setHasSeededSnsStats] = useState(false)
const [nowTick, setNowTick] = useState(Date.now())
const tabCounts = useContactTypeCountsStore(state => state.tabCounts)
const isSharedTabCountsLoading = useContactTypeCountsStore(state => state.isLoading)
const isSharedTabCountsReady = useContactTypeCountsStore(state => state.isReady)
const ensureSharedTabCountsLoaded = useContactTypeCountsStore(state => state.ensureLoaded)
const syncContactTypeCounts = useContactTypeCountsStore(state => state.syncFromContacts)
const progressUnsubscribeRef = useRef<(() => void) | null>(null)
const runningTaskIdRef = useRef<string | null>(null)
const tasksRef = useRef<ExportTask[]>([])
const hasSeededSnsStatsRef = useRef(false)
const sessionLoadTokenRef = useRef(0)
const preselectAppliedRef = useRef(false)
const exportCacheScopeRef = useRef('default')
const exportCacheScopeReadyRef = useRef(false)
const contactsLoadVersionRef = useRef(0)
const contactsLoadAttemptRef = useRef(0)
const contactsLoadTimeoutTimerRef = useRef<number | null>(null)
const contactsLoadTimeoutMsRef = useRef(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS)
const contactsAvatarCacheRef = useRef<Record<string, configService.ContactsAvatarCacheEntry>>({})
const contactsListRef = useRef<HTMLDivElement>(null)
const detailRequestSeqRef = useRef(0)
const ensureExportCacheScope = useCallback(async (): Promise<string> => {
if (exportCacheScopeReadyRef.current) {
return exportCacheScopeRef.current
}
const [myWxid, dbPath] = await Promise.all([
configService.getMyWxid(),
configService.getDbPath()
])
const scopeKey = dbPath || myWxid
? `${dbPath || ''}::${myWxid || ''}`
: 'default'
exportCacheScopeRef.current = scopeKey
exportCacheScopeReadyRef.current = true
return scopeKey
}, [])
const loadContactsCaches = useCallback(async (scopeKey: string) => {
const [contactsItem, avatarItem] = await Promise.all([
configService.getContactsListCache(scopeKey),
configService.getContactsAvatarCache(scopeKey)
])
return {
contactsItem,
avatarItem
}
}, [])
useEffect(() => {
let cancelled = false
void (async () => {
try {
const value = await configService.getContactsLoadTimeoutMs()
if (!cancelled) {
setContactsLoadTimeoutMs(value)
}
} catch (error) {
console.error('读取通讯录超时配置失败:', error)
}
})()
return () => {
cancelled = true
}
}, [])
useEffect(() => {
contactsLoadTimeoutMsRef.current = contactsLoadTimeoutMs
}, [contactsLoadTimeoutMs])
const applyEnrichedContactsToList = useCallback((enrichedMap: Record<string, { displayName?: string; avatarUrl?: string }>) => {
if (!enrichedMap || Object.keys(enrichedMap).length === 0) return
setContactsList(prev => {
let changed = false
const next = prev.map(contact => {
const enriched = enrichedMap[contact.username]
if (!enriched) return contact
const displayName = enriched.displayName || contact.displayName
const avatarUrl = enriched.avatarUrl || contact.avatarUrl
if (displayName === contact.displayName && avatarUrl === contact.avatarUrl) {
return contact
}
changed = true
return {
...contact,
displayName,
avatarUrl
}
})
return changed ? next : prev
})
}, [])
const enrichContactsListInBackground = useCallback(async (
sourceContacts: ContactInfo[],
loadVersion: number,
scopeKey: string
) => {
const sourceByUsername = new Map<string, ContactInfo>()
for (const contact of sourceContacts) {
if (!contact.username) continue
sourceByUsername.set(contact.username, contact)
}
const now = Date.now()
const usernames = sourceContacts
.map(contact => contact.username)
.filter(Boolean)
.filter((username) => {
const currentContact = sourceByUsername.get(username)
if (!currentContact) return false
const cacheEntry = contactsAvatarCacheRef.current[username]
if (!cacheEntry || !cacheEntry.avatarUrl) {
return !currentContact.avatarUrl
}
if (currentContact.avatarUrl && currentContact.avatarUrl !== cacheEntry.avatarUrl) {
return true
}
const checkedAt = cacheEntry.checkedAt || 0
return now - checkedAt >= EXPORT_AVATAR_RECHECK_INTERVAL_MS
})
const total = usernames.length
setContactsAvatarEnrichProgress({
loaded: 0,
total,
running: total > 0
})
if (total === 0) return
for (let i = 0; i < total; i += EXPORT_AVATAR_ENRICH_BATCH_SIZE) {
if (contactsLoadVersionRef.current !== loadVersion) return
const batch = usernames.slice(i, i + EXPORT_AVATAR_ENRICH_BATCH_SIZE)
if (batch.length === 0) continue
try {
const avatarResult = await window.electronAPI.chat.enrichSessionsContactInfo(batch)
if (contactsLoadVersionRef.current !== loadVersion) return
if (avatarResult.success && avatarResult.contacts) {
applyEnrichedContactsToList(avatarResult.contacts)
for (const [username, enriched] of Object.entries(avatarResult.contacts)) {
const prev = sourceByUsername.get(username)
if (!prev) continue
sourceByUsername.set(username, {
...prev,
displayName: enriched.displayName || prev.displayName,
avatarUrl: enriched.avatarUrl || prev.avatarUrl
})
}
}
const batchContacts = batch
.map(username => sourceByUsername.get(username))
.filter((contact): contact is ContactInfo => Boolean(contact))
const upsertResult = upsertAvatarCacheFromContacts(
contactsAvatarCacheRef.current,
batchContacts,
{ markCheckedUsernames: batch }
)
contactsAvatarCacheRef.current = upsertResult.avatarEntries
if (upsertResult.updatedAt) {
setAvatarCacheUpdatedAt(upsertResult.updatedAt)
}
} catch (error) {
console.error('导出页分批补全头像失败:', error)
}
const loaded = Math.min(i + batch.length, total)
setContactsAvatarEnrichProgress({
loaded,
total,
running: loaded < total
})
await new Promise(resolve => setTimeout(resolve, 0))
}
void configService.setContactsAvatarCache(scopeKey, contactsAvatarCacheRef.current).catch((error) => {
console.error('写入导出页头像缓存失败:', error)
})
}, [applyEnrichedContactsToList])
const loadContactsList = useCallback(async (options?: { scopeKey?: string }) => {
const scopeKey = options?.scopeKey || await ensureExportCacheScope()
const loadVersion = contactsLoadVersionRef.current + 1
contactsLoadVersionRef.current = loadVersion
contactsLoadAttemptRef.current += 1
const startedAt = Date.now()
const timeoutMs = contactsLoadTimeoutMsRef.current
const requestId = `export-contacts-${startedAt}-${contactsLoadAttemptRef.current}`
setContactsLoadSession({
requestId,
startedAt,
attempt: contactsLoadAttemptRef.current,
timeoutMs
})
setContactsLoadIssue(null)
setShowContactsDiagnostics(false)
if (contactsLoadTimeoutTimerRef.current) {
window.clearTimeout(contactsLoadTimeoutTimerRef.current)
contactsLoadTimeoutTimerRef.current = null
}
const timeoutTimerId = window.setTimeout(() => {
if (contactsLoadVersionRef.current !== loadVersion) return
const elapsedMs = Date.now() - startedAt
setContactsLoadIssue({
kind: 'timeout',
title: '联系人列表加载超时',
message: `等待超过 ${timeoutMs}ms,联系人列表仍未返回。`,
reason: 'chat.getContacts 长时间未返回,可能是数据库查询繁忙或连接异常。',
occurredAt: Date.now(),
elapsedMs
})
}, timeoutMs)
contactsLoadTimeoutTimerRef.current = timeoutTimerId
setIsContactsListLoading(true)
setContactsAvatarEnrichProgress({
loaded: 0,
total: 0,
running: false
})
try {
const contactsResult = await window.electronAPI.chat.getContacts()
if (contactsLoadVersionRef.current !== loadVersion) return
if (contactsResult.success && contactsResult.contacts) {
if (contactsLoadTimeoutTimerRef.current === timeoutTimerId) {
window.clearTimeout(contactsLoadTimeoutTimerRef.current)
contactsLoadTimeoutTimerRef.current = null
}
const contactsWithAvatarCache = mergeAvatarCacheIntoContacts(
contactsResult.contacts,
contactsAvatarCacheRef.current
)
setContactsList(contactsWithAvatarCache)
syncContactTypeCounts(contactsWithAvatarCache)
setContactsDataSource('network')
setContactsUpdatedAt(Date.now())
setContactsLoadIssue(null)
setIsContactsListLoading(false)
const upsertResult = upsertAvatarCacheFromContacts(
contactsAvatarCacheRef.current,
contactsWithAvatarCache,
{ prune: true }
)
contactsAvatarCacheRef.current = upsertResult.avatarEntries
if (upsertResult.updatedAt) {
setAvatarCacheUpdatedAt(upsertResult.updatedAt)
}
void configService.setContactsAvatarCache(scopeKey, contactsAvatarCacheRef.current).catch((error) => {
console.error('写入导出页头像缓存失败:', error)
})
void configService.setContactsListCache(
scopeKey,
contactsWithAvatarCache.map(contact => ({
username: contact.username,
displayName: contact.displayName,
remark: contact.remark,
nickname: contact.nickname,
type: contact.type
}))
).catch((error) => {
console.error('写入导出页通讯录缓存失败:', error)
})
void enrichContactsListInBackground(contactsWithAvatarCache, loadVersion, scopeKey)
return
}
const elapsedMs = Date.now() - startedAt
setContactsLoadIssue({
kind: 'error',
title: '联系人列表加载失败',
message: '联系人接口返回失败,未拿到联系人列表。',
reason: 'chat.getContacts 返回 success=false。',
errorDetail: contactsResult.error || '未知错误',
occurredAt: Date.now(),
elapsedMs
})
} catch (error) {
console.error('加载导出页联系人失败:', error)
const elapsedMs = Date.now() - startedAt
setContactsLoadIssue({
kind: 'error',
title: '联系人列表加载失败',
message: '联系人请求执行异常。',
reason: '调用 chat.getContacts 发生异常。',
errorDetail: String(error),
occurredAt: Date.now(),
elapsedMs
})
} finally {
if (contactsLoadTimeoutTimerRef.current === timeoutTimerId) {
window.clearTimeout(contactsLoadTimeoutTimerRef.current)
contactsLoadTimeoutTimerRef.current = null
}
if (contactsLoadVersionRef.current === loadVersion) {
setIsContactsListLoading(false)
}
}
}, [ensureExportCacheScope, enrichContactsListInBackground, syncContactTypeCounts])
useEffect(() => {
if (!isExportRoute) return
let cancelled = false
void (async () => {
const scopeKey = await ensureExportCacheScope()
if (cancelled) return
try {
const [cacheItem, avatarCacheItem] = await Promise.all([
configService.getContactsListCache(scopeKey),
configService.getContactsAvatarCache(scopeKey)
])
const avatarCacheMap = avatarCacheItem?.avatars || {}
contactsAvatarCacheRef.current = avatarCacheMap
setAvatarCacheUpdatedAt(avatarCacheItem?.updatedAt || null)
if (!cancelled && cacheItem && Array.isArray(cacheItem.contacts) && cacheItem.contacts.length > 0) {
const cachedContacts: ContactInfo[] = cacheItem.contacts.map(contact => ({
...contact,
avatarUrl: avatarCacheMap[contact.username]?.avatarUrl
}))
setContactsList(cachedContacts)
syncContactTypeCounts(cachedContacts)
setContactsDataSource('cache')
setContactsUpdatedAt(cacheItem.updatedAt || null)
setIsContactsListLoading(false)
}
} catch (error) {
console.error('读取导出页联系人缓存失败:', error)
}
if (!cancelled) {
void loadContactsList({ scopeKey })
}
})()
return () => {
cancelled = true
}
}, [isExportRoute, ensureExportCacheScope, loadContactsList, syncContactTypeCounts])
useEffect(() => {
if (isExportRoute) return
contactsLoadVersionRef.current += 1
setContactsAvatarEnrichProgress({
loaded: 0,
total: 0,
running: false
})
}, [isExportRoute])
useEffect(() => {
if (contactsLoadTimeoutTimerRef.current) {
window.clearTimeout(contactsLoadTimeoutTimerRef.current)
contactsLoadTimeoutTimerRef.current = null
}
return () => {
if (contactsLoadTimeoutTimerRef.current) {
window.clearTimeout(contactsLoadTimeoutTimerRef.current)
contactsLoadTimeoutTimerRef.current = null
}
}
}, [])
useEffect(() => {
if (!contactsLoadIssue || contactsList.length > 0) return
if (!(isContactsListLoading && contactsLoadIssue.kind === 'timeout')) return
const timer = window.setInterval(() => {
setContactsDiagnosticTick(Date.now())
}, 500)
return () => window.clearInterval(timer)
}, [contactsList.length, isContactsListLoading, contactsLoadIssue])
useEffect(() => {
tasksRef.current = tasks
}, [tasks])
useEffect(() => {
hasSeededSnsStatsRef.current = hasSeededSnsStats
}, [hasSeededSnsStats])
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(() => {
if (!isExportRoute) return
const timer = setInterval(() => setNowTick(Date.now()), 60 * 1000)
return () => clearInterval(timer)
}, [isExportRoute])
const loadBaseConfig = useCallback(async () => {
setIsBaseConfigLoading(true)
try {
const [savedPath, savedFormat, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedWriteLayout, savedSessionMap, savedContentMap, savedSnsPostCount, exportCacheScope] = await Promise.all([
configService.getExportPath(),
configService.getExportDefaultFormat(),
configService.getExportDefaultMedia(),
configService.getExportDefaultVoiceAsText(),
configService.getExportDefaultExcelCompactColumns(),
configService.getExportDefaultTxtColumns(),
configService.getExportDefaultConcurrency(),
configService.getExportWriteLayout(),
configService.getExportLastSessionRunMap(),
configService.getExportLastContentRunMap(),
configService.getExportLastSnsPostCount(),
ensureExportCacheScope()
])
const cachedSnsStats = await configService.getExportSnsStatsCache(exportCacheScope)
if (savedPath) {
setExportFolder(savedPath)
} else {
const downloadsPath = await window.electronAPI.app.getDownloadsPath()
setExportFolder(downloadsPath)
}
setWriteLayout(savedWriteLayout)
setLastExportBySession(savedSessionMap)
setLastExportByContent(savedContentMap)
setLastSnsExportPostCount(savedSnsPostCount)
if (cachedSnsStats && Date.now() - cachedSnsStats.updatedAt <= EXPORT_SNS_STATS_CACHE_STALE_MS) {
setSnsStats({
totalPosts: cachedSnsStats.totalPosts || 0,
totalFriends: cachedSnsStats.totalFriends || 0
})
hasSeededSnsStatsRef.current = true
setHasSeededSnsStats(true)
}
const txtColumns = savedTxtColumns && savedTxtColumns.length > 0 ? savedTxtColumns : defaultTxtColumns
setOptions(prev => ({
...prev,
format: (savedFormat as TextExportFormat) || prev.format,
exportMedia: savedMedia ?? prev.exportMedia,
exportVoiceAsText: savedVoiceAsText ?? prev.exportVoiceAsText,
excelCompactColumns: savedExcelCompactColumns ?? prev.excelCompactColumns,
txtColumns,
exportConcurrency: savedConcurrency ?? prev.exportConcurrency
}))
} catch (error) {
console.error('加载导出配置失败:', error)
} finally {
setIsBaseConfigLoading(false)
}
}, [ensureExportCacheScope])
const loadSnsStats = useCallback(async (options?: { full?: boolean; silent?: boolean }) => {
if (!options?.silent) {
setIsSnsStatsLoading(true)
}
const applyStats = async (next: { totalPosts: number; totalFriends: number } | null) => {
if (!next) return
const normalized = {
totalPosts: Number.isFinite(next.totalPosts) ? Math.max(0, Math.floor(next.totalPosts)) : 0,
totalFriends: Number.isFinite(next.totalFriends) ? Math.max(0, Math.floor(next.totalFriends)) : 0
}
setSnsStats(normalized)
hasSeededSnsStatsRef.current = true
setHasSeededSnsStats(true)
if (exportCacheScopeReadyRef.current) {
await configService.setExportSnsStatsCache(exportCacheScopeRef.current, normalized)
}
}
try {
const fastResult = await withTimeout(window.electronAPI.sns.getExportStatsFast(), 2200)
if (fastResult?.success && fastResult.data) {
const fastStats = {
totalPosts: fastResult.data.totalPosts || 0,
totalFriends: fastResult.data.totalFriends || 0
}
if (fastStats.totalPosts > 0 || hasSeededSnsStatsRef.current) {
await applyStats(fastStats)
}
}
if (options?.full) {
const result = await withTimeout(window.electronAPI.sns.getExportStats(), 9000)
if (result?.success && result.data) {
await applyStats({
totalPosts: result.data.totalPosts || 0,
totalFriends: result.data.totalFriends || 0
})
}
}
} catch (error) {
console.error('加载朋友圈导出统计失败:', error)
} finally {
if (!options?.silent) {
setIsSnsStatsLoading(false)
}
}
}, [])
const loadSessions = useCallback(async () => {
const loadToken = Date.now()
sessionLoadTokenRef.current = loadToken
setIsLoading(true)
setIsSessionEnriching(false)
const isStale = () => sessionLoadTokenRef.current !== loadToken
try {
const scopeKey = await ensureExportCacheScope()
if (isStale()) return
const {
contactsItem: cachedContactsItem,
avatarItem: cachedAvatarItem
} = await loadContactsCaches(scopeKey)
if (isStale()) return
const cachedContacts = cachedContactsItem?.contacts || []
const cachedAvatarEntries = cachedAvatarItem?.avatars || {}
const cachedContactMap = toContactMapFromCaches(cachedContacts, cachedAvatarEntries)
if (cachedContacts.length > 0) {
syncContactTypeCounts(Object.values(cachedContactMap))
setSessions(toSessionRowsWithContacts([], cachedContactMap))
setSessionDataSource('cache')
setIsLoading(false)
}
setSessionContactsUpdatedAt(cachedContactsItem?.updatedAt || null)
setSessionAvatarUpdatedAt(cachedAvatarItem?.updatedAt || null)
const connectResult = await window.electronAPI.chat.connect()
if (!connectResult.success) {
console.error('连接失败:', connectResult.error)
if (!isStale()) setIsLoading(false)
return
}
const sessionsResult = await window.electronAPI.chat.getSessions()
if (isStale()) return
if (sessionsResult.success && sessionsResult.sessions) {
const rawSessions = sessionsResult.sessions
const baseSessions = toSessionRowsWithContacts(rawSessions, cachedContactMap)
if (isStale()) return
setSessions(baseSessions)
setSessionDataSource(cachedContacts.length > 0 ? 'cache' : 'network')
if (cachedContacts.length === 0) {
setSessionContactsUpdatedAt(Date.now())
}
setIsLoading(false)
// 后台补齐联系人字段(昵称、头像、类型),不阻塞首屏会话列表渲染。
setIsSessionEnriching(true)
void (async () => {
try {
let contactMap = { ...cachedContactMap }
let avatarEntries = { ...cachedAvatarEntries }
let hasFreshNetworkData = false
let hasNetworkContactsSnapshot = false
if (isStale()) return
const contactsResult = await withTimeout(window.electronAPI.chat.getContacts(), CONTACT_ENRICH_TIMEOUT_MS)
if (isStale()) return
const contactsFromNetwork: ContactInfo[] = contactsResult?.success && contactsResult.contacts ? contactsResult.contacts : []
if (contactsFromNetwork.length > 0) {
hasFreshNetworkData = true
hasNetworkContactsSnapshot = true
const contactsWithCachedAvatar = mergeAvatarCacheIntoContacts(contactsFromNetwork, avatarEntries)
const nextContactMap = contactsWithCachedAvatar.reduce<Record<string, ContactInfo>>((map, contact) => {
map[contact.username] = contact
return map
}, {})
for (const [username, cachedContact] of Object.entries(cachedContactMap)) {
if (!nextContactMap[username]) {
nextContactMap[username] = cachedContact
}
}
contactMap = nextContactMap
syncContactTypeCounts(Object.values(contactMap))
const refreshAt = Date.now()
setSessionContactsUpdatedAt(refreshAt)
const upsertResult = upsertAvatarCacheFromContacts(avatarEntries, Object.values(contactMap), {
prune: true,
now: refreshAt
})
avatarEntries = upsertResult.avatarEntries
if (upsertResult.updatedAt) {
setSessionAvatarUpdatedAt(upsertResult.updatedAt)
}
}
const sourceContacts = Object.values(contactMap)
const sourceByUsername = new Map<string, ContactInfo>()
for (const contact of sourceContacts) {
if (!contact?.username) continue
sourceByUsername.set(contact.username, contact)
}
const now = Date.now()
const rawSessionMap = rawSessions.reduce<Record<string, AppChatSession>>((map, session) => {
map[session.username] = session
return map
}, {})
const candidateUsernames = sourceContacts.length > 0
? sourceContacts.map(contact => contact.username)
: baseSessions.map(session => session.username)
const needsEnrichment = candidateUsernames
.filter(Boolean)
.filter((username) => {
const currentContact = sourceByUsername.get(username)
const cacheEntry = avatarEntries[username]
const session = rawSessionMap[username]
const currentAvatarUrl = currentContact?.avatarUrl || session?.avatarUrl
if (!cacheEntry || !cacheEntry.avatarUrl) {
return !currentAvatarUrl
}
if (currentAvatarUrl && currentAvatarUrl !== cacheEntry.avatarUrl) {
return true
}
const checkedAt = cacheEntry.checkedAt || 0
return now - checkedAt >= EXPORT_AVATAR_RECHECK_INTERVAL_MS
})
let extraContactMap: Record<string, { displayName?: string; avatarUrl?: string }> = {}
if (needsEnrichment.length > 0) {
for (let i = 0; i < needsEnrichment.length; i += EXPORT_AVATAR_ENRICH_BATCH_SIZE) {
if (isStale()) return
const batch = needsEnrichment.slice(i, i + EXPORT_AVATAR_ENRICH_BATCH_SIZE)
if (batch.length === 0) continue
try {
const enrichResult = await withTimeout(
window.electronAPI.chat.enrichSessionsContactInfo(batch),
CONTACT_ENRICH_TIMEOUT_MS
)
if (isStale()) return
if (enrichResult?.success && enrichResult.contacts) {
extraContactMap = {
...extraContactMap,
...enrichResult.contacts
}
hasFreshNetworkData = true
for (const [username, enriched] of Object.entries(enrichResult.contacts)) {
const current = sourceByUsername.get(username)
if (!current) continue
sourceByUsername.set(username, {
...current,
displayName: enriched.displayName || current.displayName,
avatarUrl: enriched.avatarUrl || current.avatarUrl
})
}
}
} catch (batchError) {
console.error('导出页分批补充会话联系人信息失败:', batchError)
}
const batchContacts = batch
.map(username => sourceByUsername.get(username))
.filter((contact): contact is ContactInfo => Boolean(contact))
const upsertResult = upsertAvatarCacheFromContacts(avatarEntries, batchContacts, {
markCheckedUsernames: batch
})
avatarEntries = upsertResult.avatarEntries
if (upsertResult.updatedAt) {
setSessionAvatarUpdatedAt(upsertResult.updatedAt)
}
await new Promise(resolve => setTimeout(resolve, 0))
}
}
const contactsForPersist = Array.from(sourceByUsername.values())
if (hasNetworkContactsSnapshot && contactsForPersist.length > 0) {
const upsertResult = upsertAvatarCacheFromContacts(avatarEntries, contactsForPersist, {
prune: true
})
avatarEntries = upsertResult.avatarEntries
if (upsertResult.updatedAt) {
setSessionAvatarUpdatedAt(upsertResult.updatedAt)
}
}
contactMap = contactsForPersist.reduce<Record<string, ContactInfo>>((map, contact) => {
map[contact.username] = contact
return map
}, contactMap)
if (isStale()) return
const nextSessions = toSessionRowsWithContacts(rawSessions, contactMap)
.map((session) => {
const extra = extraContactMap[session.username]
const displayName = extra?.displayName || session.displayName || session.username
const avatarUrl = extra?.avatarUrl || session.avatarUrl || avatarEntries[session.username]?.avatarUrl
if (displayName === session.displayName && avatarUrl === session.avatarUrl) {
return session
}
return {
...session,
displayName,
avatarUrl
}
})
.sort((a, b) => (b.sortTimestamp || b.lastTimestamp || 0) - (a.sortTimestamp || a.lastTimestamp || 0))
const contactsCachePayload = Object.values(contactMap).map((contact) => ({
username: contact.username,
displayName: contact.displayName || contact.username,
remark: contact.remark,
nickname: contact.nickname,
type: contact.type
}))
const persistAt = Date.now()
setSessions(nextSessions)
if (hasNetworkContactsSnapshot && contactsCachePayload.length > 0) {
await configService.setContactsListCache(scopeKey, contactsCachePayload)
setSessionContactsUpdatedAt(persistAt)
}
if (Object.keys(avatarEntries).length > 0) {
await configService.setContactsAvatarCache(scopeKey, avatarEntries)
setSessionAvatarUpdatedAt(persistAt)
}
if (hasFreshNetworkData) {
setSessionDataSource('network')
}
} catch (enrichError) {
console.error('导出页补充会话联系人信息失败:', enrichError)
} finally {
if (!isStale()) setIsSessionEnriching(false)
}
})()
} else {
setIsLoading(false)
}
} catch (error) {
console.error('加载会话失败:', error)
if (!isStale()) setIsLoading(false)
} finally {
if (!isStale()) setIsLoading(false)
}
}, [ensureExportCacheScope, loadContactsCaches, syncContactTypeCounts])
useEffect(() => {
if (!isExportRoute) return
void loadBaseConfig()
void ensureSharedTabCountsLoaded()
void loadSessions()
// 朋友圈统计延后一点加载,避免与首屏会话初始化抢占。
const timer = window.setTimeout(() => {
void loadSnsStats({ full: true })
}, 120)
return () => window.clearTimeout(timer)
}, [isExportRoute, ensureSharedTabCountsLoaded, loadBaseConfig, loadSessions, loadSnsStats])
useEffect(() => {
if (isExportRoute) return
// 导出页隐藏时停止后台联系人补齐请求,避免与通讯录页面查询抢占。
sessionLoadTokenRef.current = Date.now()
setIsSessionEnriching(false)
}, [isExportRoute])
useEffect(() => {
preselectAppliedRef.current = false
}, [location.key, preselectSessionIds])
useEffect(() => {
if (preselectAppliedRef.current) return
if (sessions.length === 0 || preselectSessionIds.length === 0) return
const exists = new Set(sessions.map(session => session.username))
const matched = preselectSessionIds.filter(id => exists.has(id))
preselectAppliedRef.current = true
if (matched.length > 0) {
setSelectedSessions(new Set(matched))
}
}, [sessions, preselectSessionIds])
const visibleSessions = useMemo(() => {
const keyword = searchKeyword.trim().toLowerCase()
return sessions
.filter((session) => {
if (session.kind !== activeTab) return false
if (!keyword) return true
return (
(session.displayName || '').toLowerCase().includes(keyword) ||
session.username.toLowerCase().includes(keyword)
)
})
.sort((a, b) => {
const latestA = a.sortTimestamp || a.lastTimestamp || 0
const latestB = b.sortTimestamp || b.lastTimestamp || 0
return latestB - latestA
})
}, [sessions, activeTab, searchKeyword])
const selectedCount = selectedSessions.size
const toggleSelectSession = (sessionId: string) => {
const target = sessions.find(session => session.username === sessionId)
if (!target?.hasSession) return
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.filter(session => session.hasSession).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 })
setOptions(prev => {
const nextDateRange = prev.dateRange ?? (() => {
const now = new Date()
const start = new Date(now)
start.setHours(0, 0, 0, 0)
return { start, end: now }
})()
const next: ExportOptions = {
...prev,
useAllTime: true,
dateRange: nextDateRange
}
if (payload.scope === 'sns') {
next.format = prev.format === 'json' || prev.format === 'html' ? prev.format : 'html'
return next
}
if (payload.scope === 'content' && payload.contentType) {
if (payload.contentType === 'text') {
next.exportMedia = false
} else {
next.exportMedia = true
next.exportImages = payload.contentType === 'image'
next.exportVoices = payload.contentType === 'voice'
next.exportVideos = payload.contentType === 'video'
next.exportEmojis = payload.contentType === 'emoji'
}
}
return next
})
}
const closeExportDialog = () => {
setExportDialog(prev => ({ ...prev, open: false }))
}
const buildExportOptions = (scope: TaskScope, contentType?: ContentType): ElectronExportOptions => {
const sessionLayout: SessionLayout = writeLayout === 'C' ? 'per-session' : 'shared'
const exportMediaEnabled = Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis)
const base: ElectronExportOptions = {
format: options.format,
exportAvatars: options.exportAvatars,
exportMedia: exportMediaEnabled,
exportImages: options.exportImages,
exportVoices: options.exportVoices,
exportVideos: options.exportVideos,
exportEmojis: options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText,
excelCompactColumns: options.excelCompactColumns,
txtColumns: options.txtColumns,
displayNamePreference: options.displayNamePreference,
exportConcurrency: options.exportConcurrency,
sessionLayout,
dateRange: options.useAllTime
? null
: options.dateRange
? {
start: Math.floor(options.dateRange.start.getTime() / 1000),
end: Math.floor(options.dateRange.end.getTime() / 1000)
}
: null
}
if (scope === 'content' && contentType) {
if (contentType === 'text') {
return {
...base,
exportMedia: false,
exportImages: false,
exportVoices: false,
exportVideos: false,
exportEmojis: false
}
}
return {
...base,
exportMedia: true,
exportImages: contentType === 'image',
exportVoices: contentType === 'voice',
exportVideos: contentType === 'video',
exportEmojis: contentType === 'emoji'
}
}
return base
}
const buildSnsExportOptions = () => {
const format: 'json' | 'html' = options.format === 'json' ? 'json' : 'html'
const exportMediaEnabled = Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis)
const dateRange = options.useAllTime
? null
: options.dateRange
? {
startTime: Math.floor(options.dateRange.start.getTime() / 1000),
endTime: Math.floor(options.dateRange.end.getTime() / 1000)
}
: null
return {
format,
exportMedia: exportMediaEnabled,
startTime: dateRange?.startTime,
endTime: dateRange?.endTime
}
}
const markSessionExported = useCallback((sessionIds: string[], timestamp: number) => {
setLastExportBySession(prev => {
const next = { ...prev }
for (const id of sessionIds) {
next[id] = timestamp
}
void configService.setExportLastSessionRunMap(next)
return next
})
}, [])
const markContentExported = useCallback((sessionIds: string[], contentTypes: ContentType[], timestamp: number) => {
setLastExportByContent(prev => {
const next = { ...prev }
for (const id of sessionIds) {
for (const type of contentTypes) {
next[`${id}::${type}`] = timestamp
}
}
void configService.setExportLastContentRunMap(next)
return next
})
}, [])
const inferContentTypesFromOptions = (opts: ElectronExportOptions): ContentType[] => {
const types: ContentType[] = ['text']
if (opts.exportMedia) {
if (opts.exportVoices) types.push('voice')
if (opts.exportImages) types.push('image')
if (opts.exportVideos) types.push('video')
if (opts.exportEmojis) types.push('emoji')
}
return types
}
const updateTask = useCallback((taskId: string, updater: (task: ExportTask) => ExportTask) => {
setTasks(prev => prev.map(task => (task.id === taskId ? updater(task) : task)))
}, [])
const runNextTask = useCallback(async () => {
if (runningTaskIdRef.current) return
const queue = [...tasksRef.current].reverse()
const next = queue.find(task => task.status === 'queued')
if (!next) return
runningTaskIdRef.current = next.id
updateTask(next.id, task => ({ ...task, status: 'running', startedAt: Date.now() }))
progressUnsubscribeRef.current?.()
if (next.payload.scope === 'sns') {
progressUnsubscribeRef.current = window.electronAPI.sns.onExportProgress((payload) => {
updateTask(next.id, task => ({
...task,
progress: {
current: payload.current || 0,
total: payload.total || 0,
currentName: '',
phaseLabel: payload.status || '',
phaseProgress: payload.total > 0 ? payload.current : 0,
phaseTotal: payload.total || 0
}
}))
})
} else {
progressUnsubscribeRef.current = window.electronAPI.export.onProgress((payload: ExportProgress) => {
updateTask(next.id, task => ({
...task,
progress: {
current: payload.current,
total: payload.total,
currentName: payload.currentSession,
phaseLabel: payload.phaseLabel || '',
phaseProgress: payload.phaseProgress || 0,
phaseTotal: payload.phaseTotal || 0
}
}))
})
}
try {
if (next.payload.scope === 'sns') {
const snsOptions = next.payload.snsOptions || { format: 'html' as const, exportMedia: false }
const result = await window.electronAPI.sns.exportTimeline({
outputDir: next.payload.outputDir,
format: snsOptions.format,
exportMedia: snsOptions.exportMedia,
startTime: snsOptions.startTime,
endTime: snsOptions.endTime
})
if (!result.success) {
updateTask(next.id, task => ({
...task,
status: 'error',
finishedAt: Date.now(),
error: result.error || '朋友圈导出失败'
}))
} else {
const doneAt = Date.now()
const exportedPosts = Math.max(0, result.postCount || 0)
const mergedExportedCount = Math.max(lastSnsExportPostCount, exportedPosts)
setLastSnsExportPostCount(mergedExportedCount)
await configService.setExportLastSnsPostCount(mergedExportedCount)
await loadSnsStats({ full: true })
updateTask(next.id, task => ({
...task,
status: 'success',
finishedAt: doneAt,
progress: {
...task.progress,
current: exportedPosts,
total: exportedPosts,
phaseLabel: '完成',
phaseProgress: 1,
phaseTotal: 1
}
}))
}
} else {
if (!next.payload.options) {
throw new Error('导出参数缺失')
}
const result = await window.electronAPI.export.exportSessions(
next.payload.sessionIds,
next.payload.outputDir,
next.payload.options
)
if (!result.success) {
updateTask(next.id, task => ({
...task,
status: 'error',
finishedAt: Date.now(),
error: result.error || '导出失败'
}))
} else {
const doneAt = Date.now()
const contentTypes = next.payload.contentType
? [next.payload.contentType]
: inferContentTypesFromOptions(next.payload.options)
markSessionExported(next.payload.sessionIds, doneAt)
markContentExported(next.payload.sessionIds, contentTypes, doneAt)
updateTask(next.id, task => ({
...task,
status: 'success',
finishedAt: doneAt,
progress: {
...task.progress,
current: task.progress.total || next.payload.sessionIds.length,
total: task.progress.total || next.payload.sessionIds.length,
phaseLabel: '完成',
phaseProgress: 1,
phaseTotal: 1
}
}))
}
}
} catch (error) {
updateTask(next.id, task => ({
...task,
status: 'error',
finishedAt: Date.now(),
error: String(error)
}))
} finally {
progressUnsubscribeRef.current?.()
progressUnsubscribeRef.current = null
runningTaskIdRef.current = null
void runNextTask()
}
}, [updateTask, markSessionExported, markContentExported, loadSnsStats, lastSnsExportPostCount])
useEffect(() => {
void runNextTask()
}, [tasks, runNextTask])
useEffect(() => {
return () => {
progressUnsubscribeRef.current?.()
progressUnsubscribeRef.current = null
}
}, [])
const createTask = async () => {
if (!exportDialog.open || !exportFolder) return
if (exportDialog.scope !== 'sns' && exportDialog.sessionIds.length === 0) return
const exportOptions = exportDialog.scope === 'sns'
? undefined
: buildExportOptions(exportDialog.scope, exportDialog.contentType)
const snsOptions = exportDialog.scope === 'sns'
? buildSnsExportOptions()
: undefined
const title =
exportDialog.scope === 'single'
? `${exportDialog.sessionNames[0] || '会话'} 导出`
: exportDialog.scope === 'multi'
? `批量导出(${exportDialog.sessionIds.length} 个会话)`
: exportDialog.scope === 'sns'
? '朋友圈批量导出'
: `${contentTypeLabels[exportDialog.contentType || 'text']}批量导出`
const task: ExportTask = {
id: createTaskId(),
title,
status: 'queued',
createdAt: Date.now(),
payload: {
sessionIds: exportDialog.sessionIds,
sessionNames: exportDialog.sessionNames,
outputDir: exportFolder,
options: exportOptions,
scope: exportDialog.scope,
contentType: exportDialog.contentType,
snsOptions
},
progress: createEmptyProgress()
}
setTasks(prev => [task, ...prev])
closeExportDialog()
await configService.setExportDefaultFormat(options.format)
await configService.setExportDefaultMedia(Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis))
await configService.setExportDefaultVoiceAsText(options.exportVoiceAsText)
await configService.setExportDefaultExcelCompactColumns(options.excelCompactColumns)
await configService.setExportDefaultTxtColumns(options.txtColumns)
await configService.setExportDefaultConcurrency(options.exportConcurrency)
}
const openSingleExport = (session: SessionRow) => {
if (!session.hasSession) return
openExportDialog({
scope: 'single',
sessionIds: [session.username],
sessionNames: [session.displayName || session.username],
title: `导出会话:${session.displayName || session.username}`
})
}
const openBatchExport = () => {
const selectable = new Set(sessions.filter(session => session.hasSession).map(session => session.username))
const ids = Array.from(selectedSessions).filter(id => selectable.has(id))
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.hasSession && isContentScopeSession(session))
.map(session => session.username)
const names = sessions
.filter(session => session.hasSession && isContentScopeSession(session))
.map(session => session.displayName || session.username)
openExportDialog({
scope: 'content',
contentType,
sessionIds: ids,
sessionNames: names,
title: `${contentTypeLabels[contentType]}批量导出`
})
}
const openSnsExport = () => {
openExportDialog({
scope: 'sns',
sessionIds: [],
sessionNames: ['全部朋友圈动态'],
title: '朋友圈批量导出'
})
}
const runningSessionIds = useMemo(() => {
const set = new Set<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(isContentScopeSession)
const totalSessions = tabCounts.private + tabCounts.group + tabCounts.former_friend
const snsExportedCount = Math.min(lastSnsExportPostCount, snsStats.totalPosts)
const sessionCards = [
{ type: 'text' as ContentType, icon: MessageSquareText },
{ type: 'voice' as ContentType, icon: Mic },
{ type: 'image' as ContentType, icon: ImageIcon },
{ type: 'video' as ContentType, icon: Video },
{ type: 'emoji' as ContentType, icon: WandSparkles }
].map(item => {
let exported = 0
for (const session of scopeSessions) {
if (lastExportByContent[`${session.username}::${item.type}`]) {
exported += 1
}
}
return {
...item,
label: contentTypeLabels[item.type],
stats: [
{ label: '总会话数', value: totalSessions },
{ label: '已导出', value: exported }
]
}
})
const snsCard = {
type: 'sns' as ContentCardType,
icon: Aperture,
label: '朋友圈',
stats: [
{ label: '朋友圈条数', value: snsStats.totalPosts },
{ label: '已导出', value: snsExportedCount }
]
}
return [...sessionCards, snsCard]
}, [sessions, tabCounts, lastExportByContent, snsStats, lastSnsExportPostCount])
const activeTabLabel = useMemo(() => {
if (activeTab === 'private') return '私聊'
if (activeTab === 'group') return '群聊'
if (activeTab === 'former_friend') return '曾经的好友'
return '公众号'
}, [activeTab])
const filteredContacts = useMemo(() => {
const keyword = searchKeyword.trim().toLowerCase()
return contactsList
.filter((contact) => {
if (!matchesContactTab(contact, activeTab)) return false
if (!keyword) return true
return (
(contact.displayName || '').toLowerCase().includes(keyword) ||
(contact.remark || '').toLowerCase().includes(keyword) ||
contact.username.toLowerCase().includes(keyword)
)
})
.sort((a, b) => (a.displayName || a.username).localeCompare(b.displayName || b.username, 'zh-Hans-CN'))
}, [contactsList, activeTab, searchKeyword])
const sessionRowByUsername = useMemo(() => {
const map = new Map<string, SessionRow>()
for (const session of sessions) {
map.set(session.username, session)
}
return map
}, [sessions])
const contactByUsername = useMemo(() => {
const map = new Map<string, ContactInfo>()
for (const contact of contactsList) {
map.set(contact.username, contact)
}
return map
}, [contactsList])
const loadSessionDetail = useCallback(async (sessionId: string) => {
const normalizedSessionId = String(sessionId || '').trim()
if (!normalizedSessionId) return
const requestSeq = ++detailRequestSeqRef.current
const mappedSession = sessionRowByUsername.get(normalizedSessionId)
const mappedContact = contactByUsername.get(normalizedSessionId)
const hintedCount = typeof mappedSession?.messageCountHint === 'number' && Number.isFinite(mappedSession.messageCountHint) && mappedSession.messageCountHint >= 0
? Math.floor(mappedSession.messageCountHint)
: undefined
setCopiedDetailField(null)
setSessionDetail((prev) => {
const sameSession = prev?.wxid === normalizedSessionId
return {
wxid: normalizedSessionId,
displayName: mappedSession?.displayName || mappedContact?.displayName || prev?.displayName || normalizedSessionId,
remark: sameSession ? prev?.remark : mappedContact?.remark,
nickName: sameSession ? prev?.nickName : mappedContact?.nickname,
alias: sameSession ? prev?.alias : undefined,
avatarUrl: mappedSession?.avatarUrl || mappedContact?.avatarUrl || (sameSession ? prev?.avatarUrl : undefined),
messageCount: hintedCount ?? (sameSession ? prev.messageCount : Number.NaN),
voiceMessages: sameSession ? prev?.voiceMessages : undefined,
imageMessages: sameSession ? prev?.imageMessages : undefined,
videoMessages: sameSession ? prev?.videoMessages : undefined,
emojiMessages: sameSession ? prev?.emojiMessages : undefined,
privateMutualGroups: sameSession ? prev?.privateMutualGroups : undefined,
groupMemberCount: sameSession ? prev?.groupMemberCount : undefined,
groupMyMessages: sameSession ? prev?.groupMyMessages : undefined,
groupActiveSpeakers: sameSession ? prev?.groupActiveSpeakers : undefined,
groupMutualFriends: sameSession ? prev?.groupMutualFriends : undefined,
firstMessageTime: sameSession ? prev?.firstMessageTime : undefined,
latestMessageTime: sameSession ? prev?.latestMessageTime : undefined,
messageTables: sameSession && Array.isArray(prev?.messageTables) ? prev.messageTables : []
}
})
setIsLoadingSessionDetail(true)
setIsLoadingSessionDetailExtra(true)
try {
const result = await window.electronAPI.chat.getSessionDetailFast(normalizedSessionId)
if (requestSeq !== detailRequestSeqRef.current) return
if (result.success && result.detail) {
setSessionDetail((prev) => ({
wxid: normalizedSessionId,
displayName: result.detail!.displayName || prev?.displayName || normalizedSessionId,
remark: result.detail!.remark ?? prev?.remark,
nickName: result.detail!.nickName ?? prev?.nickName,
alias: result.detail!.alias ?? prev?.alias,
avatarUrl: result.detail!.avatarUrl || prev?.avatarUrl,
messageCount: Number.isFinite(result.detail!.messageCount) ? result.detail!.messageCount : prev?.messageCount ?? Number.NaN,
voiceMessages: prev?.voiceMessages,
imageMessages: prev?.imageMessages,
videoMessages: prev?.videoMessages,
emojiMessages: prev?.emojiMessages,
privateMutualGroups: prev?.privateMutualGroups,
groupMemberCount: prev?.groupMemberCount,
groupMyMessages: prev?.groupMyMessages,
groupActiveSpeakers: prev?.groupActiveSpeakers,
groupMutualFriends: prev?.groupMutualFriends,
firstMessageTime: prev?.firstMessageTime,
latestMessageTime: prev?.latestMessageTime,
messageTables: Array.isArray(prev?.messageTables) ? (prev?.messageTables || []) : []
}))
}
} catch (error) {
console.error('导出页加载会话详情失败:', error)
} finally {
if (requestSeq === detailRequestSeqRef.current) {
setIsLoadingSessionDetail(false)
}
}
try {
const [extraResultSettled, statsResultSettled] = await Promise.allSettled([
window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId),
window.electronAPI.chat.getExportSessionStats([normalizedSessionId])
])
if (requestSeq !== detailRequestSeqRef.current) return
setSessionDetail((prev) => {
if (!prev || prev.wxid !== normalizedSessionId) return prev
let next = { ...prev }
if (extraResultSettled.status === 'fulfilled' && extraResultSettled.value.success && extraResultSettled.value.detail) {
next = {
...next,
firstMessageTime: extraResultSettled.value.detail.firstMessageTime,
latestMessageTime: extraResultSettled.value.detail.latestMessageTime,
messageTables: Array.isArray(extraResultSettled.value.detail.messageTables) ? extraResultSettled.value.detail.messageTables : []
}
}
if (statsResultSettled.status === 'fulfilled' && statsResultSettled.value.success && statsResultSettled.value.data) {
const metric = statsResultSettled.value.data[normalizedSessionId]
if (metric) {
next = {
...next,
messageCount: Number.isFinite(metric.totalMessages) ? metric.totalMessages : next.messageCount,
voiceMessages: metric.voiceMessages,
imageMessages: metric.imageMessages,
videoMessages: metric.videoMessages,
emojiMessages: metric.emojiMessages,
privateMutualGroups: metric.privateMutualGroups,
groupMemberCount: metric.groupMemberCount,
groupMyMessages: metric.groupMyMessages,
groupActiveSpeakers: metric.groupActiveSpeakers,
groupMutualFriends: metric.groupMutualFriends,
firstMessageTime: Number.isFinite(metric.firstTimestamp) ? metric.firstTimestamp : next.firstMessageTime,
latestMessageTime: Number.isFinite(metric.lastTimestamp) ? metric.lastTimestamp : next.latestMessageTime
}
}
}
return next
})
} catch (error) {
console.error('导出页加载会话详情补充统计失败:', error)
} finally {
if (requestSeq === detailRequestSeqRef.current) {
setIsLoadingSessionDetailExtra(false)
}
}
}, [contactByUsername, sessionRowByUsername])
const openSessionDetail = useCallback((sessionId: string) => {
if (!sessionId) return
setShowSessionDetailPanel(true)
void loadSessionDetail(sessionId)
}, [loadSessionDetail])
useEffect(() => {
if (!showSessionDetailPanel) return
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setShowSessionDetailPanel(false)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [showSessionDetailPanel])
const handleCopyDetailField = useCallback(async (text: string, field: string) => {
try {
await navigator.clipboard.writeText(text)
setCopiedDetailField(field)
setTimeout(() => setCopiedDetailField(null), 1500)
} catch {
const textarea = document.createElement('textarea')
textarea.value = text
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
setCopiedDetailField(field)
setTimeout(() => setCopiedDetailField(null), 1500)
}
}, [])
const contactsUpdatedAtLabel = useMemo(() => {
if (!contactsUpdatedAt) return ''
return new Date(contactsUpdatedAt).toLocaleString()
}, [contactsUpdatedAt])
const avatarCacheUpdatedAtLabel = useMemo(() => {
if (!avatarCacheUpdatedAt) return ''
return new Date(avatarCacheUpdatedAt).toLocaleString()
}, [avatarCacheUpdatedAt])
const contactsAvatarCachedCount = useMemo(() => {
return contactsList.reduce((count, contact) => (
contact.avatarUrl ? count + 1 : count
), 0)
}, [contactsList])
useEffect(() => {
if (!contactsListRef.current) return
contactsListRef.current.scrollTop = 0
setContactsListScrollTop(0)
}, [activeTab, searchKeyword])
useEffect(() => {
const node = contactsListRef.current
if (!node) return
const updateViewportHeight = () => {
setContactsListViewportHeight(Math.max(node.clientHeight, CONTACTS_LIST_VIRTUAL_ROW_HEIGHT))
}
updateViewportHeight()
const observer = new ResizeObserver(() => updateViewportHeight())
observer.observe(node)
return () => observer.disconnect()
}, [filteredContacts.length, isContactsListLoading])
useEffect(() => {
const maxScroll = Math.max(0, filteredContacts.length * CONTACTS_LIST_VIRTUAL_ROW_HEIGHT - contactsListViewportHeight)
if (contactsListScrollTop <= maxScroll) return
setContactsListScrollTop(maxScroll)
if (contactsListRef.current) {
contactsListRef.current.scrollTop = maxScroll
}
}, [filteredContacts.length, contactsListViewportHeight, contactsListScrollTop])
const { startIndex: contactStartIndex, endIndex: contactEndIndex } = useMemo(() => {
if (filteredContacts.length === 0) {
return { startIndex: 0, endIndex: 0 }
}
const baseStart = Math.floor(contactsListScrollTop / CONTACTS_LIST_VIRTUAL_ROW_HEIGHT)
const visibleCount = Math.ceil(contactsListViewportHeight / CONTACTS_LIST_VIRTUAL_ROW_HEIGHT)
const nextStart = Math.max(0, baseStart - CONTACTS_LIST_VIRTUAL_OVERSCAN)
const nextEnd = Math.min(filteredContacts.length, nextStart + visibleCount + CONTACTS_LIST_VIRTUAL_OVERSCAN * 2)
return {
startIndex: nextStart,
endIndex: nextEnd
}
}, [filteredContacts.length, contactsListViewportHeight, contactsListScrollTop])
const visibleContacts = useMemo(() => {
return filteredContacts.slice(contactStartIndex, contactEndIndex)
}, [filteredContacts, contactStartIndex, contactEndIndex])
const onContactsListScroll = useCallback((event: UIEvent<HTMLDivElement>) => {
setContactsListScrollTop(event.currentTarget.scrollTop)
}, [])
const contactsIssueElapsedMs = useMemo(() => {
if (!contactsLoadIssue) return 0
if (isContactsListLoading && contactsLoadSession) {
return Math.max(contactsLoadIssue.elapsedMs, contactsDiagnosticTick - contactsLoadSession.startedAt)
}
return contactsLoadIssue.elapsedMs
}, [contactsDiagnosticTick, isContactsListLoading, contactsLoadIssue, contactsLoadSession])
const contactsDiagnosticsText = useMemo(() => {
if (!contactsLoadIssue || !contactsLoadSession) return ''
return [
`请求ID: ${contactsLoadSession.requestId}`,
`请求序号: 第 ${contactsLoadSession.attempt} 次`,
`阈值配置: ${contactsLoadSession.timeoutMs}ms`,
`当前状态: ${contactsLoadIssue.kind === 'timeout' ? '超时等待中' : '请求失败'}`,
`累计耗时: ${(contactsIssueElapsedMs / 1000).toFixed(1)}s`,
`发生时间: ${new Date(contactsLoadIssue.occurredAt).toLocaleString()}`,
'阶段: chat.getContacts',
`原因: ${contactsLoadIssue.reason}`,
`错误详情: ${contactsLoadIssue.errorDetail || '无'}`
].join('\n')
}, [contactsIssueElapsedMs, contactsLoadIssue, contactsLoadSession])
const copyContactsDiagnostics = useCallback(async () => {
if (!contactsDiagnosticsText) return
try {
await navigator.clipboard.writeText(contactsDiagnosticsText)
alert('诊断信息已复制')
} catch (error) {
console.error('复制诊断信息失败:', error)
alert('复制失败,请手动复制诊断信息')
}
}, [contactsDiagnosticsText])
const sessionContactsUpdatedAtLabel = useMemo(() => {
if (!sessionContactsUpdatedAt) return ''
return new Date(sessionContactsUpdatedAt).toLocaleString()
}, [sessionContactsUpdatedAt])
const sessionAvatarUpdatedAtLabel = useMemo(() => {
if (!sessionAvatarUpdatedAt) return ''
return new Date(sessionAvatarUpdatedAt).toLocaleString()
}, [sessionAvatarUpdatedAt])
const sessionAvatarCachedCount = useMemo(() => {
return sessions.reduce((count, session) => (session.avatarUrl ? count + 1 : count), 0)
}, [sessions])
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}
{!session.hasSession ? ' · 暂无会话记录' : ''}
</div>
</div>
</div>
)
}
const renderActionCell = (session: SessionRow) => {
const isDetailActive = showSessionDetailPanel && sessionDetail?.wxid === session.username
if (!session.hasSession) {
return (
<div className="row-action-cell">
<div className="row-action-main">
<button
className={`row-detail-btn ${isDetailActive ? 'active' : ''}`}
onClick={() => openSessionDetail(session.username)}
>
详情
</button>
<button className="row-export-btn no-session" disabled>
暂无会话
</button>
</div>
</div>
)
}
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">
<div className="row-action-main">
<button
className={`row-detail-btn ${isDetailActive ? 'active' : ''}`}
onClick={() => openSessionDetail(session.username)}
>
详情
</button>
<button
className={`row-export-btn ${isRunning ? 'running' : ''}`}
disabled={isRunning}
onClick={() => openSingleExport(session)}
>
{isRunning ? (
<>
<Loader2 size={14} className="spin" />
导出中
</>
) : isQueued ? '排队中' : '导出'}
</button>
</div>
{recent && <span className="row-export-time">{recent}</span>}
</div>
)
}
const renderTableHeader = () => {
return (
<tr>
<th className="sticky-col">选择</th>
<th>联系人(头像/名称/微信号)</th>
<th className="sticky-right">操作</th>
</tr>
)
}
const renderRowCells = (session: SessionRow) => {
const selectable = session.hasSession
const checked = selectable && selectedSessions.has(session.username)
return (
<>
<td className="sticky-col">
<button
className={`select-icon-btn ${checked ? 'checked' : ''}`}
disabled={!selectable}
onClick={() => toggleSelectSession(session.username)}
title={selectable ? (checked ? '取消选择' : '选择会话') : '该联系人暂无会话记录'}
>
{checked ? <CheckSquare size={16} /> : <Square size={16} />}
</button>
</td>
<td>{renderSessionName(session)}</td>
<td className="sticky-right">{renderActionCell(session)}</td>
</>
)
}
const visibleSelectedCount = useMemo(() => {
const visibleSet = new Set(visibleSessions.map(session => session.username))
let count = 0
for (const id of selectedSessions) {
if (visibleSet.has(id)) count += 1
}
return count
}, [visibleSessions, selectedSessions])
const canCreateTask = exportDialog.scope === 'sns'
? Boolean(exportFolder)
: Boolean(exportFolder) && exportDialog.sessionIds.length > 0
const scopeLabel = exportDialog.scope === 'single'
? '单会话'
: exportDialog.scope === 'multi'
? '多会话'
: exportDialog.scope === 'sns'
? '朋友圈批量'
: `按内容批量(${contentTypeLabels[exportDialog.contentType || 'text']})`
const scopeCountLabel = exportDialog.scope === 'sns'
? `共 ${snsStats.totalPosts} 条朋友圈动态`
: `共 ${exportDialog.sessionIds.length} 个会话`
const formatCandidateOptions = exportDialog.scope === 'sns'
? formatOptions.filter(option => option.value === 'html' || option.value === 'json')
: formatOptions
const isTabCountComputing = isSharedTabCountsLoading && !isSharedTabCountsReady
const isSessionCardStatsLoading = isBaseConfigLoading || (isSharedTabCountsLoading && !isSharedTabCountsReady)
const isSnsCardStatsLoading = !hasSeededSnsStats
const taskRunningCount = tasks.filter(task => task.status === 'running').length
const taskQueuedCount = tasks.filter(task => task.status === 'queued').length
const showInitialSkeleton = isLoading && sessions.length === 0
const chooseExportFolder = useCallback(async () => {
const result = await window.electronAPI.dialog.openFile({
title: '选择导出目录',
properties: ['openDirectory']
})
if (!result.canceled && result.filePaths.length > 0) {
const nextPath = result.filePaths[0]
setExportFolder(nextPath)
await configService.setExportPath(nextPath)
}
}, [])
return (
<div className="export-board-page">
<div className="export-top-panel">
<div className="global-export-controls">
<div className="path-control">
<span className="control-label">导出位置</span>
<div className="path-inline-row">
<div className="path-value">
<button
className="path-link"
type="button"
title={exportFolder}
onClick={() => void chooseExportFolder()}
>
{exportFolder || '未设置'}
</button>
<button className="path-change-btn" type="button" onClick={() => void chooseExportFolder()}>
更换
</button>
</div>
<button className="secondary-btn" onClick={() => exportFolder && void window.electronAPI.shell.openPath(exportFolder)}>
<ExternalLink size={14} /> 打开
</button>
</div>
</div>
<WriteLayoutSelector
writeLayout={writeLayout}
onChange={async (value) => {
setWriteLayout(value)
await configService.setExportWriteLayout(value)
}}
/>
<div className="task-center-control">
<span className="control-label">任务中心</span>
<div className="task-center-inline">
<div className="task-summary">
<span>进行中 {taskRunningCount}</span>
<span>排队 {taskQueuedCount}</span>
<span>总计 {tasks.length}</span>
</div>
<button
className="task-collapse-btn"
type="button"
onClick={() => setIsTaskCenterExpanded(prev => !prev)}
>
{isTaskCenterExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
{isTaskCenterExpanded ? '收起' : '展开'}
</button>
</div>
</div>
</div>
</div>
{isTaskCenterExpanded && (
<div className="task-center expanded">
{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.total > 0
? `${task.progress.current} / ${task.progress.total}`
: '处理中'}
{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="content-card-grid">
{contentCards.map(card => {
const Icon = card.icon
const isCardStatsLoading = card.type === 'sns'
? isSnsCardStatsLoading
: isSessionCardStatsLoading
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">
{card.stats.map((stat) => (
<div key={stat.label} className="stat-item">
<span>{stat.label}</span>
<strong>
{isCardStatsLoading ? (
<span className="count-loading">
统计中<span className="animated-ellipsis" aria-hidden="true">...</span>
</span>
) : stat.value.toLocaleString()}
</strong>
</div>
))}
</div>
<button
className="card-export-btn"
onClick={() => {
if (card.type === 'sns') {
openSnsExport()
return
}
openContentExport(card.type)
}}
>
导出
</button>
</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')}>
私聊 {isTabCountComputing ? <span className="count-loading">计算中<span className="animated-ellipsis" aria-hidden="true">...</span></span> : tabCounts.private}
</button>
<button className={`tab-btn ${activeTab === 'group' ? 'active' : ''}`} onClick={() => setActiveTab('group')}>
群聊 {isTabCountComputing ? <span className="count-loading">计算中<span className="animated-ellipsis" aria-hidden="true">...</span></span> : tabCounts.group}
</button>
<button className={`tab-btn ${activeTab === 'official' ? 'active' : ''}`} onClick={() => setActiveTab('official')}>
公众号 {isTabCountComputing ? <span className="count-loading">计算中<span className="animated-ellipsis" aria-hidden="true">...</span></span> : tabCounts.official}
</button>
<button className={`tab-btn ${activeTab === 'former_friend' ? 'active' : ''}`} onClick={() => setActiveTab('former_friend')}>
曾经的好友 {isTabCountComputing ? <span className="count-loading">计算中<span className="animated-ellipsis" aria-hidden="true">...</span></span> : tabCounts.former_friend}
</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={() => void loadContactsList()} disabled={isContactsListLoading}>
<RefreshCw size={14} className={isContactsListLoading ? 'spin' : ''} />
刷新
</button>
</div>
</div>
<div className="table-cache-meta">
<span className="meta-item">
共 {filteredContacts.length} / {contactsList.length} 个联系人
</span>
{contactsUpdatedAt && (
<span className="meta-item">
{contactsDataSource === 'cache' ? '缓存' : '最新'} · 更新于 {contactsUpdatedAtLabel}
</span>
)}
{contactsList.length > 0 && (
<span className="meta-item">
头像缓存 {contactsAvatarCachedCount}/{contactsList.length}
{avatarCacheUpdatedAtLabel ? ` · 更新于 ${avatarCacheUpdatedAtLabel}` : ''}
</span>
)}
{(isContactsListLoading || contactsAvatarEnrichProgress.running) && contactsList.length > 0 && (
<span className="meta-item syncing">后台同步中...</span>
)}
{contactsAvatarEnrichProgress.running && (
<span className="meta-item syncing">
头像补全中 {contactsAvatarEnrichProgress.loaded}/{contactsAvatarEnrichProgress.total}
</span>
)}
</div>
{contactsList.length > 0 && (isContactsListLoading || contactsAvatarEnrichProgress.running) && (
<div className="table-stage-hint">
<Loader2 size={14} className="spin" />
{isContactsListLoading ? '联系人列表同步中…' : '正在补充头像…'}
</div>
)}
<div className="session-table-layout">
<div className="table-wrap">
{contactsList.length === 0 && contactsLoadIssue ? (
<div className="load-issue-state">
<div className="issue-card">
<div className="issue-title">
<AlertTriangle size={18} />
<span>{contactsLoadIssue.title}</span>
</div>
<p className="issue-message">{contactsLoadIssue.message}</p>
<p className="issue-reason">{contactsLoadIssue.reason}</p>
<ul className="issue-hints">
<li>可能原因1:数据库当前仍在执行高开销查询(例如导出页后台统计)。</li>
<li>可能原因2:contact.db 数据量较大,首次查询时间过长。</li>
<li>可能原因3:数据库连接状态异常或 IPC 调用卡住。</li>
</ul>
<div className="issue-actions">
<button className="issue-btn primary" onClick={() => void loadContactsList()}>
<RefreshCw size={14} />
<span>重试加载</span>
</button>
<button className="issue-btn" onClick={() => setShowContactsDiagnostics(prev => !prev)}>
<ClipboardList size={14} />
<span>{showContactsDiagnostics ? '收起诊断详情' : '查看诊断详情'}</span>
</button>
<button className="issue-btn" onClick={copyContactsDiagnostics}>
<span>复制诊断信息</span>
</button>
</div>
{showContactsDiagnostics && (
<pre className="issue-diagnostics">{contactsDiagnosticsText}</pre>
)}
</div>
</div>
) : isContactsListLoading && contactsList.length === 0 ? (
<div className="loading-state">
<Loader2 size={32} className="spin" />
<span>联系人加载中...</span>
</div>
) : filteredContacts.length === 0 ? (
<div className="empty-state">
<span>暂无联系人</span>
</div>
) : (
<div className="contacts-list" ref={contactsListRef} onScroll={onContactsListScroll}>
<div
className="contacts-list-virtual"
style={{ height: filteredContacts.length * CONTACTS_LIST_VIRTUAL_ROW_HEIGHT }}
>
{visibleContacts.map((contact, idx) => {
const absoluteIndex = contactStartIndex + idx
const top = absoluteIndex * CONTACTS_LIST_VIRTUAL_ROW_HEIGHT
const matchedSession = sessionRowByUsername.get(contact.username)
const canExport = Boolean(matchedSession?.hasSession)
const isRunning = canExport && runningSessionIds.has(contact.username)
const isQueued = canExport && queuedSessionIds.has(contact.username)
const recent = canExport ? formatRecentExportTime(lastExportBySession[contact.username], nowTick) : ''
return (
<div
key={contact.username}
className="contact-row"
style={{ transform: `translateY(${top}px)` }}
>
<div className="contact-item">
<div className="contact-avatar">
{contact.avatarUrl ? (
<img src={contact.avatarUrl} alt="" loading="lazy" />
) : (
<span>{getAvatarLetter(contact.displayName)}</span>
)}
</div>
<div className="contact-info">
<div className="contact-name">{contact.displayName}</div>
<div className="contact-remark">{contact.username}</div>
</div>
<div className={`contact-type ${contact.type}`}>
<span>{getContactTypeName(contact.type)}</span>
</div>
<div className="row-action-cell">
<div className="row-action-main">
<button
className={`row-detail-btn ${showSessionDetailPanel && sessionDetail?.wxid === contact.username ? 'active' : ''}`}
onClick={() => openSessionDetail(contact.username)}
>
详情
</button>
<button
className={`row-export-btn ${isRunning ? 'running' : ''} ${!canExport ? 'no-session' : ''}`}
disabled={!canExport || isRunning}
onClick={() => {
if (!matchedSession || !matchedSession.hasSession) return
openSingleExport({
...matchedSession,
displayName: contact.displayName || matchedSession.displayName || matchedSession.username
})
}}
>
{isRunning ? (
<>
<Loader2 size={14} className="spin" />
导出中
</>
) : !canExport ? '暂无会话' : isQueued ? '排队中' : '导出'}
</button>
</div>
{recent && <span className="row-export-time">{recent}</span>}
</div>
</div>
</div>
)
})}
</div>
</div>
)}
</div>
{showSessionDetailPanel && (
<div
className="export-session-detail-overlay"
onClick={() => setShowSessionDetailPanel(false)}
>
<aside
className="export-session-detail-panel"
role="dialog"
aria-modal="true"
aria-label="会话详情"
onClick={(event) => event.stopPropagation()}
>
<div className="detail-header">
<div className="detail-header-main">
<div className="detail-header-avatar">
{sessionDetail?.avatarUrl ? (
<img src={sessionDetail.avatarUrl} alt="" />
) : (
<span>{getAvatarLetter(sessionDetail?.displayName || sessionDetail?.wxid || '')}</span>
)}
</div>
<div className="detail-header-meta">
<h4>{sessionDetail?.displayName || '会话详情'}</h4>
<div className="detail-header-id">{sessionDetail?.wxid || ''}</div>
</div>
</div>
<button className="close-btn" onClick={() => setShowSessionDetailPanel(false)}>
<X size={16} />
</button>
</div>
{isLoadingSessionDetail && !sessionDetail ? (
<div className="detail-loading">
<Loader2 size={20} className="spin" />
<span>加载中...</span>
</div>
) : sessionDetail ? (
<div className="detail-content">
<div className="detail-section">
<div className="detail-item">
<Hash size={14} />
<span className="label">微信ID</span>
<span className="value">{sessionDetail.wxid}</span>
<button className="copy-btn" title="复制" onClick={() => void handleCopyDetailField(sessionDetail.wxid, 'wxid')}>
{copiedDetailField === 'wxid' ? <Check size={12} /> : <Copy size={12} />}
</button>
</div>
{sessionDetail.remark && (
<div className="detail-item">
<span className="label">备注</span>
<span className="value">{sessionDetail.remark}</span>
<button className="copy-btn" title="复制" onClick={() => void handleCopyDetailField(sessionDetail.remark || '', 'remark')}>
{copiedDetailField === 'remark' ? <Check size={12} /> : <Copy size={12} />}
</button>
</div>
)}
{sessionDetail.nickName && (
<div className="detail-item">
<span className="label">昵称</span>
<span className="value">{sessionDetail.nickName}</span>
<button className="copy-btn" title="复制" onClick={() => void handleCopyDetailField(sessionDetail.nickName || '', 'nickName')}>
{copiedDetailField === 'nickName' ? <Check size={12} /> : <Copy size={12} />}
</button>
</div>
)}
{sessionDetail.alias && (
<div className="detail-item">
<span className="label">微信号</span>
<span className="value">{sessionDetail.alias}</span>
<button className="copy-btn" title="复制" onClick={() => void handleCopyDetailField(sessionDetail.alias || '', 'alias')}>
{copiedDetailField === 'alias' ? <Check size={12} /> : <Copy size={12} />}
</button>
</div>
)}
</div>
<div className="detail-section">
<div className="section-title">
<MessageSquare size={14} />
<span>消息统计(导出口径)</span>
</div>
<div className="detail-item">
<span className="label">消息总数</span>
<span className="value highlight">
{Number.isFinite(sessionDetail.messageCount)
? sessionDetail.messageCount.toLocaleString()
: ((isLoadingSessionDetail || isLoadingSessionDetailExtra) ? '统计中...' : '—')}
</span>
</div>
<div className="detail-item">
<span className="label">语音</span>
<span className="value">
{Number.isFinite(sessionDetail.voiceMessages)
? (sessionDetail.voiceMessages as number).toLocaleString()
: (isLoadingSessionDetailExtra ? '统计中...' : '—')}
</span>
</div>
<div className="detail-item">
<span className="label">图片</span>
<span className="value">
{Number.isFinite(sessionDetail.imageMessages)
? (sessionDetail.imageMessages as number).toLocaleString()
: (isLoadingSessionDetailExtra ? '统计中...' : '—')}
</span>
</div>
<div className="detail-item">
<span className="label">视频</span>
<span className="value">
{Number.isFinite(sessionDetail.videoMessages)
? (sessionDetail.videoMessages as number).toLocaleString()
: (isLoadingSessionDetailExtra ? '统计中...' : '—')}
</span>
</div>
<div className="detail-item">
<span className="label">表情包</span>
<span className="value">
{Number.isFinite(sessionDetail.emojiMessages)
? (sessionDetail.emojiMessages as number).toLocaleString()
: (isLoadingSessionDetailExtra ? '统计中...' : '—')}
</span>
</div>
{sessionDetail.wxid.includes('@chatroom') ? (
<>
<div className="detail-item">
<span className="label">我发的消息数</span>
<span className="value">
{Number.isFinite(sessionDetail.groupMyMessages)
? (sessionDetail.groupMyMessages as number).toLocaleString()
: (isLoadingSessionDetailExtra ? '统计中...' : '—')}
</span>
</div>
<div className="detail-item">
<span className="label">群人数</span>
<span className="value">
{Number.isFinite(sessionDetail.groupMemberCount)
? (sessionDetail.groupMemberCount as number).toLocaleString()
: (isLoadingSessionDetailExtra ? '统计中...' : '—')}
</span>
</div>
<div className="detail-item">
<span className="label">群发言人数</span>
<span className="value">
{Number.isFinite(sessionDetail.groupActiveSpeakers)
? (sessionDetail.groupActiveSpeakers as number).toLocaleString()
: (isLoadingSessionDetailExtra ? '统计中...' : '—')}
</span>
</div>
<div className="detail-item">
<span className="label">群共同好友数</span>
<span className="value">
{Number.isFinite(sessionDetail.groupMutualFriends)
? (sessionDetail.groupMutualFriends as number).toLocaleString()
: (isLoadingSessionDetailExtra ? '统计中...' : '—')}
</span>
</div>
</>
) : (
<div className="detail-item">
<span className="label">共同群聊数</span>
<span className="value">
{Number.isFinite(sessionDetail.privateMutualGroups)
? (sessionDetail.privateMutualGroups as number).toLocaleString()
: (isLoadingSessionDetailExtra ? '统计中...' : '—')}
</span>
</div>
)}
<div className="detail-item">
<Calendar size={14} />
<span className="label">首条消息</span>
<span className="value">
{sessionDetail.firstMessageTime
? formatYmdDateFromSeconds(sessionDetail.firstMessageTime)
: (isLoadingSessionDetailExtra ? '统计中...' : '—')}
</span>
</div>
<div className="detail-item">
<Calendar size={14} />
<span className="label">最新消息</span>
<span className="value">
{sessionDetail.latestMessageTime
? formatYmdDateFromSeconds(sessionDetail.latestMessageTime)
: (isLoadingSessionDetailExtra ? '统计中...' : '—')}
</span>
</div>
</div>
<div className="detail-section">
<div className="section-title">
<Database size={14} />
<span>数据库分布</span>
</div>
{Array.isArray(sessionDetail.messageTables) && sessionDetail.messageTables.length > 0 ? (
<div className="table-list">
{sessionDetail.messageTables.map((table, index) => (
<div key={`${table.dbName}-${table.tableName}-${index}`} className="table-item">
<span className="db-name">{table.dbName}</span>
<span className="table-count">{table.count.toLocaleString()} 条</span>
</div>
))}
</div>
) : (
<div className="detail-table-placeholder">
{isLoadingSessionDetailExtra ? '统计中...' : '暂无统计数据'}
</div>
)}
</div>
</div>
) : (
<div className="detail-empty">暂无详情</div>
)}
</aside>
</div>
)}
</div>
</div>
{exportDialog.open && (
<div className="export-dialog-overlay" onClick={closeExportDialog}>
<div className="export-dialog" role="dialog" aria-modal="true" 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-body">
<div className="dialog-section">
<h4>导出范围</h4>
<div className="scope-tag-row">
<span className="scope-tag">{scopeLabel}</span>
<span className="scope-count">{scopeCountLabel}</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">
{formatCandidateOptions.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="media-check-grid">
<label><input type="checkbox" checked={options.exportImages} onChange={event => setOptions(prev => ({ ...prev, exportImages: event.target.checked }))} /> 图片</label>
<label><input type="checkbox" checked={options.exportVoices} onChange={event => setOptions(prev => ({ ...prev, exportVoices: event.target.checked }))} /> 语音</label>
<label><input type="checkbox" checked={options.exportVideos} onChange={event => setOptions(prev => ({ ...prev, exportVideos: event.target.checked }))} /> 视频</label>
<label><input type="checkbox" checked={options.exportEmojis} 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" role="radiogroup" aria-label="发送者名称显示">
{displayNameOptions.map(option => {
const isActive = options.displayNamePreference === option.value
return (
<button
key={option.value}
type="button"
role="radio"
aria-checked={isActive}
className={`display-name-item ${isActive ? 'active' : ''}`}
onClick={() => setOptions(prev => ({ ...prev, displayNamePreference: option.value }))}
>
<span>{option.label}</span>
<small>{option.desc}</small>
</button>
)
})}
</div>
</div>
</div>
<div className="dialog-actions">
<button className="secondary-btn" onClick={closeExportDialog}>取消</button>
<button className="primary-btn" onClick={() => void createTask()} disabled={!canCreateTask}>
<Download size={14} /> 创建导出任务
</button>
</div>
</div>
</div>
)}
</div>
)
}
export default ExportPage