Merge branch 'nanin/develop' into develop

This commit is contained in:
Wxw-Gu
2026-08-05 18:44:25 +08:00
37 changed files with 5185 additions and 492 deletions
+37 -12
View File
@@ -212,7 +212,32 @@ function App(): React.ReactElement {
const [exportTasks, setExportTasks] = useState<ExportTaskRecord[]>(() => {
try {
const stored = JSON.parse(localStorage.getItem('wxe_export_tasks') || '[]')
return Array.isArray(stored) ? (stored as ExportTaskRecord[]).slice(0, 20) : []
if (!Array.isArray(stored)) return []
return stored.slice(0, 20).map(
(
value: Partial<ExportTaskRecord> & {
contactId?: string
contactName?: string
}
) => {
const targetIds = Array.isArray(value.targetIds)
? value.targetIds
: value.contactId
? [value.contactId]
: []
const targetNames = Array.isArray(value.targetNames)
? value.targetNames
: value.contactName
? [value.contactName]
: []
return {
...value,
targetIds,
targetNames,
targetLabel: value.targetLabel || targetNames.join('、') || '聊天导出'
} as ExportTaskRecord
}
)
} catch {
return []
}
@@ -353,10 +378,14 @@ function App(): React.ReactElement {
const handleStartExport = async (
request: ExportRequest
): Promise<import('../../shared/export').ExportResult> => {
const targetNames = request.targets.map((target) => target.name)
const targetLabel =
targetNames.length > 1 ? `${targetNames[0]}${targetNames.length} 个聊天` : targetNames[0]
const task: ExportTaskRecord = {
jobId: request.jobId,
contactId: request.userMd5,
contactName: request.name,
targetIds: request.targets.map((target) => target.userMd5),
targetNames,
targetLabel,
format: request.format,
status: 'running',
progress: { jobId: request.jobId, phase: 'reading', processed: 0, percent: 0 },
@@ -1232,16 +1261,14 @@ function App(): React.ReactElement {
}
}
const loadExportPreview = async (contact: Contact): Promise<void> => {
setSelectedContact(contact)
selectedContactMd5Ref.current = contact.md5
const loadExportPreviewMessages = async (contact: Contact): Promise<Message[]> => {
try {
const previewMessages = await window.api.getMessages(contact.md5, undefined, undefined, {
return await window.api.getMessages(contact.md5, undefined, undefined, {
limit: EXPORT_PREVIEW_LIMIT
})
if (selectedContactMd5Ref.current === contact.md5) setMessages(previewMessages)
} catch (error) {
console.warn('[Export] preview load failed:', error)
return []
}
}
@@ -1334,7 +1361,6 @@ function App(): React.ReactElement {
const handlePageChange = (page: AppPage): void => {
setActivePage(page)
if (page === 'archive' && selectedContact) void handleSelectContact(selectedContact)
if (page === 'export' && selectedContact) void loadExportPreview(selectedContact)
if (page === 'settings') setSettingsCategory('account-database')
if (page === 'report' && isGroupContact(selectedContact) && !reportSourceContact) {
setReportSourceContact(selectedContact)
@@ -1714,11 +1740,10 @@ function App(): React.ReactElement {
return (
<ExportWorkspace
contacts={contacts}
selectedContact={selectedContact}
previewMessages={messages}
initialContact={selectedContact}
selfInfo={selfInfo}
dbReady={isDatabaseConnected}
onSelectContact={loadExportPreview}
loadPreviewMessages={loadExportPreviewMessages}
onOpenSettings={openSettings}
exportTasks={exportTasks}
onStartExport={handleStartExport}
+9 -3
View File
@@ -4,14 +4,20 @@ interface VideoBubbleProps {
md5?: string
newMd5?: string
rawMd5?: string
createTime?: number
duration?: number
width?: number
height?: number
}
export function VideoBubble({
md5,
newMd5,
rawMd5,
duration
createTime,
duration,
width,
height
}: VideoBubbleProps): React.ReactElement {
const hashes = useMemo(
() => [rawMd5, newMd5, md5].filter((value): value is string => Boolean(value)),
@@ -22,7 +28,7 @@ export function VideoBubble({
useEffect(() => {
let cancelled = false
window.api
.getVideo(hashes)
.getVideo(hashes, { createTime, duration, width, height })
.then((result) => {
if (!cancelled) setMedia(result.success ? result : { error: result.error })
})
@@ -32,7 +38,7 @@ export function VideoBubble({
return () => {
cancelled = true
}
}, [hashes])
}, [createTime, duration, hashes, height, width])
if (!media.url) {
return <div className="video-placeholder">{media.error || '视频加载中…'}</div>
@@ -81,7 +81,10 @@ export function MessageBubble({
md5={message.contentData.md5}
newMd5={message.contentData.newMd5}
rawMd5={message.contentData.rawMd5}
createTime={message.createTime}
duration={message.contentData.duration}
width={message.contentData.width}
height={message.contentData.height}
/>
) : isRichMedia && message.contentData ? (
<RichMessageBubble
@@ -6,6 +6,9 @@ interface ExportContactPanelProps {
contacts: Contact[]
filteredContacts: Contact[]
activeContact: Contact | null
selectedContactIds: string[]
selectionMode: boolean
selectionLimit: number
selfInfo: SelfInfo | null
dbReady: boolean
contactFilter: string
@@ -13,6 +16,7 @@ interface ExportContactPanelProps {
onContactFilterChange: (value: string) => void
onContactTypeChange: (value: 'all' | 'group' | 'user') => void
onSelectContact: (contact: Contact) => void
onCompleteSelection: () => void
onOpenSettings: () => void
}
@@ -20,6 +24,9 @@ export function ExportContactPanel({
contacts,
filteredContacts,
activeContact,
selectedContactIds,
selectionMode,
selectionLimit,
selfInfo,
dbReady,
contactFilter,
@@ -27,6 +34,7 @@ export function ExportContactPanel({
onContactFilterChange,
onContactTypeChange,
onSelectContact,
onCompleteSelection,
onOpenSettings
}: ExportContactPanelProps): React.ReactElement {
return (
@@ -65,15 +73,30 @@ export function ExportContactPanel({
</div>
</div>
{selectionMode && (
<div className="export-multi-select-bar">
<span>
{selectedContactIds.length} / {selectionLimit}
</span>
<button type="button" onClick={onCompleteSelection}>
</button>
</div>
)}
<div className="export-contact-list">
{filteredContacts.map((contact) => {
const name = displayName(contact)
const selected = selectedContactIds.includes(contact.md5)
const atLimit = selectionMode && !selected && selectedContactIds.length >= selectionLimit
return (
<button
key={contact.md5}
type="button"
className={`export-contact-item ${activeContact?.md5 === contact.md5 ? 'active' : ''}`}
className={`export-contact-item ${activeContact?.md5 === contact.md5 ? 'active' : ''} ${selected ? 'selected' : ''}`}
onClick={() => onSelectContact(contact)}
disabled={atLimit}
aria-pressed={selected}
>
<span className="export-contact-avatar">
{contact.avatar ? <img src={contact.avatar} alt="" /> : name.slice(0, 1)}
@@ -82,6 +105,11 @@ export function ExportContactPanel({
<strong>{name}</strong>
<small>{contact.type === 'group' ? '群聊' : '联系人'}</small>
</span>
{selectionMode && (
<span className={`export-contact-check ${selected ? 'checked' : ''}`} aria-hidden>
{selected ? '✓' : ''}
</span>
)}
</button>
)
})}
@@ -10,6 +10,7 @@ interface ExportPreviewPanelProps {
previewBytes: number
selfInfo: SelfInfo | null
progress: ExportJobProgress | null
selectedCount: number
jobId: string
onCancel: (jobId: string) => void
onReveal: (path: string) => void
@@ -22,6 +23,7 @@ export function ExportPreviewPanel({
previewBytes,
selfInfo,
progress,
selectedCount,
jobId,
onCancel,
onReveal
@@ -32,7 +34,9 @@ export function ExportPreviewPanel({
<>
<div className="export-preview-heading">
<strong></strong>
<span> 20 </span>
<span>
{selectedCount > 1 ? `${selectedCount} 个聊天 · 合并预览` : '仅预览最近 20 条'}
</span>
</div>
<div className="export-message-preview">
<div className="export-preview-date"></div>
@@ -50,7 +54,7 @@ export function ExportPreviewPanel({
]
).map((message) => (
<div
key={message.id}
key={`${message.exportConversationId || 'single'}:${message.id}`}
className={`export-preview-message ${message.isSender ? 'mine' : ''} ${
message.contentData?.type === 'system' && message.contentData.pat ? 'system' : ''
}`}
@@ -64,6 +68,9 @@ export function ExportPreviewPanel({
</span>
<span className="export-preview-bubble">
<small>
{selectedCount > 1 && message.exportConversationName
? `${message.exportConversationName} · `
: ''}
{message.name || (message.isSender ? '我' : '联系人')} ·{' '}
{formatPreviewTime(message)}
</small>
@@ -108,7 +115,11 @@ export function ExportPreviewPanel({
<ol>
<li className="done"></li>
<li className="current">
{progress?.phase === 'writing' ? '生成档案' : '分批读取聊天记录'}
{progress?.phase === 'compressing'
? '压缩 ZIP'
: progress?.phase === 'writing'
? '生成档案'
: '分批读取聊天记录'}
</li>
<li></li>
<li></li>
@@ -118,9 +129,11 @@ export function ExportPreviewPanel({
<span style={{ width: `${progress?.percent ?? 0}%` }} />
</div>
<strong>
{progress?.phase === 'writing'
? `正在写入 ${progress.processed.toLocaleString()} 条消息... ${progress.percent ?? 0}%`
: `正在读取消息... ${progress?.percent ?? 0}%`}
{progress?.phase === 'compressing'
? `正在压缩资源包... ${progress.percent ?? 0}%`
: progress?.phase === 'writing'
? `正在写入 ${progress.processed.toLocaleString()} 条消息... ${progress.percent ?? 0}%`
: `正在读取消息... ${progress?.percent ?? 0}%`}
</strong>
<button type="button" className="export-cancel-button" onClick={() => onCancel(jobId)}>
@@ -9,6 +9,25 @@ interface ExportTaskCenterProps {
onCancel: (jobId: string) => void
}
const phaseLabels: Record<ExportTaskRecord['progress']['phase'], string> = {
reading: '读取消息',
writing: '导出资源',
compressing: '压缩归档',
completed: '已完成',
cancelled: '已取消',
failed: '导出失败'
}
const taskDetail = (task: ExportTaskRecord): string | null => {
if (task.status === 'completed') {
return `成功导出 ${task.progress.total ?? task.progress.processed} 条消息`
}
if (task.status === 'failed') {
return `失败原因:${task.progress.error || '未知错误'}`
}
return null
}
export function ExportTaskCenter({
open,
taskCount,
@@ -22,7 +41,7 @@ export function ExportTaskCenter({
const log = [
'WechatExplorer 导出任务日志',
`时间:${new Date(task.createdAt).toLocaleString('zh-CN')}`,
`会话:${task.contactName}`,
`会话:${task.targetLabel}`,
`格式:${task.format.toUpperCase()}`,
`状态:${task.progress.phase}`,
`进度:${task.progress.percent ?? 0}%`,
@@ -46,35 +65,41 @@ export function ExportTaskCenter({
{tasks.length === 0 ? (
<p></p>
) : (
tasks.map((task) => (
<div className="export-task-row" key={task.jobId}>
<span>
<strong>{task.contactName}</strong>
<small>
{task.format.toUpperCase()} · {task.progress.phase}
</small>
{task.progress.error && (
<small className="export-task-error" title={task.progress.error}>
{task.progress.error}
tasks.map((task) => {
const detail = taskDetail(task)
return (
<div className="export-task-row" key={task.jobId}>
<span>
<strong>{task.targetLabel}</strong>
<small>
{task.format.toUpperCase()} · {phaseLabels[task.progress.phase]}
</small>
{detail && (
<small
className={`export-task-detail ${task.status}`}
title={task.status === 'failed' ? detail : undefined}
>
{detail}
</small>
)}
</span>
<span className="export-task-progress">
<i style={{ width: `${task.progress.percent ?? 0}%` }} />
<b>{task.progress.percent ?? 0}%</b>
</span>
{task.status === 'running' && (
<button type="button" onClick={() => onCancel(task.jobId)}>
</button>
)}
</span>
<span className="export-task-progress">
<i style={{ width: `${task.progress.percent ?? 0}%` }} />
<b>{task.progress.percent ?? 0}%</b>
</span>
{task.status === 'running' && (
<button type="button" onClick={() => onCancel(task.jobId)}>
</button>
)}
{task.status === 'failed' && (
<button type="button" onClick={() => void copyTaskLog(task)}>
{copiedJobId === task.jobId ? '已复制' : '复制日志'}
</button>
)}
</div>
))
{task.status === 'failed' && (
<button type="button" onClick={() => void copyTaskLog(task)}>
{copiedJobId === task.jobId ? '已复制' : '复制日志'}
</button>
)}
</div>
)
})
)}
</section>
)}
@@ -3,12 +3,15 @@ import type { Message } from '../../../../shared/types'
import type {
ExportJobProgress,
ExportMessageKind,
ExportNameMode
ExportNameMode,
ExportRequest,
ExportTarget
} from '../../../../shared/export'
import { ExportContactPanel } from './ExportContactPanel'
import { ExportPreviewPanel } from './ExportPreviewPanel'
import { ExportTaskCenter } from './ExportTaskCenter'
import type {
Contact,
ExportFormat,
ExportRange,
ExportStatus,
@@ -20,24 +23,33 @@ import type { VoiceModelStatus } from '../../../../shared/voice-recognition'
export function ExportWorkspace({
contacts,
selectedContact,
previewMessages,
initialContact,
selfInfo,
dbReady,
onSelectContact,
loadPreviewMessages,
onOpenSettings,
exportTasks,
onStartExport,
onCancelExport
}: ExportWorkspaceProps): React.ReactElement {
const initialSelection = initialContact || contacts[0] || null
const initialContactRef = React.useRef<Contact | null>(initialSelection)
const previewLoadingRef = React.useRef(new Set<string>())
const [contactFilter, setContactFilter] = useState('')
const [contactType, setContactType] = useState<'all' | 'group' | 'user'>('all')
const [selectionMode, setSelectionMode] = useState(false)
const [selectedContacts, setSelectedContacts] = useState<Contact[]>(() =>
initialSelection ? [initialSelection] : []
)
const [activeContactId, setActiveContactId] = useState(initialSelection?.md5 || '')
const [previewByContact, setPreviewByContact] = useState<Record<string, Message[]>>({})
const [range, setRange] = useState<ExportRange>('today')
const [startDate, setStartDate] = useState('')
const [endDate, setEndDate] = useState('')
const [selectedKinds, setSelectedKinds] = useState<Set<string>>(() => new Set(['text']))
const [nameMode, setNameMode] = useState<ExportNameMode>('remark')
const [groupMembers, setGroupMembers] = useState<GroupMemberName[]>([])
const [nameMode, setNameMode] = useState<ExportNameMode>(
initialSelection?.type === 'group' ? 'groupNickname' : 'remark'
)
const [includeMedia, setIncludeMedia] = useState(true)
const [includeVoiceTranscripts, setIncludeVoiceTranscripts] = useState(true)
const [voiceModelStatus, setVoiceModelStatus] = useState<VoiceModelStatus | null>(null)
@@ -52,6 +64,27 @@ export function ExportWorkspace({
const [jobId, setJobId] = useState('')
const [progress, setProgress] = useState<ExportJobProgress | null>(null)
const [taskCenterOpen, setTaskCenterOpen] = useState(false)
const selectionLimit = 5
React.useEffect(() => {
if (selectedContacts.length > 0) return
const candidate = initialContact || contacts[0]
if (!candidate) return
initialContactRef.current = candidate
setSelectedContacts([candidate])
setActiveContactId(candidate.md5)
}, [contacts, initialContact, selectedContacts.length])
React.useEffect(() => {
for (const contact of selectedContacts) {
if (previewByContact[contact.md5] || previewLoadingRef.current.has(contact.md5)) continue
previewLoadingRef.current.add(contact.md5)
void loadPreviewMessages(contact).then((items) => {
previewLoadingRef.current.delete(contact.md5)
setPreviewByContact((current) => ({ ...current, [contact.md5]: items }))
})
}
}, [loadPreviewMessages, previewByContact, selectedContacts])
const filteredContacts = useMemo(() => {
const keyword = contactFilter.trim().toLowerCase()
@@ -64,11 +97,35 @@ export function ExportWorkspace({
})
}, [contactFilter, contactType, contacts])
const activeContact = selectedContact || filteredContacts[0] || contacts[0] || null
const currentTask = exportTasks.find((task) => task.contactId === activeContact?.md5)
const activeContact =
selectedContacts.find((contact) => contact.md5 === activeContactId) ||
selectedContacts[0] ||
null
const selectedTargetKey = selectedContacts
.map((contact) => contact.md5)
.sort()
.join('|')
const currentTask = exportTasks.find(
(task) => [...task.targetIds].sort().join('|') === selectedTargetKey
)
const taskCount = exportTasks.filter((task) => task.status === 'running').length
const activeName = displayName(activeContact)
const preview = previewMessages.slice(-20)
const selectedNames = selectedContacts.map(displayName)
const selectedLabel =
selectedNames.length > 1
? `${selectedNames.join('、')} · 共 ${selectedNames.length} 个聊天`
: selectedNames[0] || '未选择聊天'
const preview = selectedContacts
.flatMap((contact) =>
(previewByContact[contact.md5] || []).map((message) => ({
...message,
exportConversationId: contact.md5,
exportConversationName: displayName(contact),
exportConversationAvatarUrl: contact.avatar
}))
)
.sort((left, right) => Number(left.createTime || 0) - Number(right.createTime || 0))
.slice(-20)
const previewMediaCount = preview.filter(
(message) =>
['image', 'video', 'voice', 'sticker'].includes(message.contentData?.type || '') ||
@@ -78,79 +135,59 @@ export function ExportWorkspace({
(total, message) => total + (message.content?.length || 0) * 2 + (message.img ? 1024 : 0),
0
)
const outputName = fileName.trim() || `${activeName}_聊天档案`
const nameOptions: { value: ExportNameMode; label: string }[] =
activeContact?.type === 'group'
? [
{ value: 'groupNickname', label: '群昵称' },
{ value: 'remark', label: '备注' },
{ value: 'wechatNickname', label: '微信名' }
]
: [
{ value: 'remark', label: '备注' },
{ value: 'wechatNickname', label: '微信名' }
]
const nameMap = useMemo(() => {
const map: Record<string, string> = {}
if (activeContact?.type === 'group') {
for (const member of groupMembers) {
const value =
nameMode === 'groupNickname'
? member.groupNickname || member.nickname || member.wxid
: nameMode === 'remark'
? member.remark || member.wechatNickname || member.wxid
: member.wechatNickname || member.wxid
map[member.wxid] = value
}
} else if (activeContact) {
map[activeContact.m_nsUsrName] =
nameMode === 'remark'
? activeContact.remark || activeContact.m_nsNickName || activeContact.m_nsUsrName
: activeContact.wechatNickname || activeContact.m_nsUsrName
}
if (selfInfo?.wxid) map[selfInfo.wxid] = selfInfo.nickname || selfInfo.wxid
return map
}, [activeContact, groupMembers, nameMode, selfInfo])
const avatarUrls = useMemo(() => {
const map: Record<string, string> = {}
if (activeContact?.m_nsUsrName && activeContact.avatar) {
map[activeContact.m_nsUsrName] = activeContact.avatar
}
for (const member of groupMembers) {
if (member.avatar) map[member.wxid] = member.avatar
}
if (selfInfo?.wxid && selfInfo.avatar) map[selfInfo.wxid] = selfInfo.avatar
return map
}, [activeContact, groupMembers, selfInfo])
const defaultOutputName =
selectedContacts.length > 1
? `${selectedNames[0]}${selectedContacts.length}个聊天_合并档案`
: `${activeName}_聊天档案`
const outputName = fileName.trim() || defaultOutputName
const nameOptions: { value: ExportNameMode; label: string }[] = selectedContacts.some(
(contact) => contact.type === 'group'
)
? [
{ value: 'groupNickname', label: '群昵称' },
{ value: 'remark', label: '备注' },
{ value: 'wechatNickname', label: '微信名' }
]
: [
{ value: 'remark', label: '备注' },
{ value: 'wechatNickname', label: '微信名' }
]
const previewName = (message: Message): string =>
(message.senderId && nameMap[message.senderId]) ||
message.name ||
(message.isSender ? selfInfo?.nickname : undefined) ||
(message.isSender ? '我' : '联系人')
const previewAvatar = (message: Message): string | undefined =>
(message.senderId && avatarUrls[message.senderId]) ||
message.img ||
(message.isSender ? selfInfo?.avatar : undefined)
message.img || (message.isSender ? selfInfo?.avatar : undefined)
const previewItems = preview.map((message) => ({
...message,
name: previewName(message),
img: previewAvatar(message)
}))
React.useEffect(() => {
setNameMode(activeContact?.type === 'group' ? 'groupNickname' : 'remark')
setGroupMembers([])
if (!activeContact || activeContact.type !== 'group') return
const timer = window.setTimeout(() => {
void window.api.getGroupSnapshot(activeContact.md5).then((snapshot) => {
setGroupMembers((snapshot?.members || []) as GroupMemberName[])
})
}, 300)
return () => window.clearTimeout(timer)
}, [activeContact])
const handleSelectContact = (contact: Contact): void => {
if (!selectionMode) {
setSelectedContacts([contact])
setActiveContactId(contact.md5)
setStatus('idle')
return
}
const selected = selectedContacts.some((item) => item.md5 === contact.md5)
if (selected) {
if (selectedContacts.length === 1) return
const next = selectedContacts.filter((item) => item.md5 !== contact.md5)
setSelectedContacts(next)
if (activeContactId === contact.md5) setActiveContactId(next[0].md5)
setStatus('idle')
return
}
if (selectedContacts.length >= selectionLimit) return
const next = [...selectedContacts, contact]
setSelectedContacts(next)
setActiveContactId(contact.md5)
setFormat('html')
setStatus('idle')
}
React.useEffect(() => {
let active = true
@@ -173,42 +210,59 @@ export function ExportWorkspace({
}
const handleStart = async (): Promise<void> => {
if (!activeContact || status === 'running') return
if (!activeContact || selectedContacts.length === 0 || status === 'running') return
// Runs only from the export button event; a fresh id is required for each job.
// eslint-disable-next-line react-hooks/purity
const nextJobId = `export-${Date.now()}`
setJobId(nextJobId)
setProgress(null)
setStatus('running')
let exportNameMap = nameMap
let exportAvatarUrls = avatarUrls
if (activeContact.type === 'group') {
const snapshot = await window.api.getGroupSnapshot(activeContact.md5)
const members = (snapshot?.members || []) as GroupMemberName[]
setGroupMembers(members)
exportNameMap = { ...nameMap }
exportAvatarUrls = { ...avatarUrls }
for (const member of members) {
exportNameMap[member.wxid] =
nameMode === 'groupNickname'
? member.groupNickname || member.nickname || member.wxid
: nameMode === 'remark'
? member.remark || member.wechatNickname || member.wxid
: member.wechatNickname || member.wxid
if (member.avatar) exportAvatarUrls[member.wxid] = member.avatar
}
}
const targets: ExportTarget[] = await Promise.all(
selectedContacts.map(async (contact) => {
const nameMap: Record<string, string> = {}
const avatarUrls: Record<string, string> = {}
if (contact.type === 'group') {
const snapshot = await window.api.getGroupSnapshot(contact.md5)
for (const member of (snapshot?.members || []) as GroupMemberName[]) {
nameMap[member.wxid] =
nameMode === 'groupNickname'
? member.groupNickname || member.nickname || member.wxid
: nameMode === 'remark'
? member.remark || member.wechatNickname || member.wxid
: member.wechatNickname || member.wxid
if (member.avatar) avatarUrls[member.wxid] = member.avatar
}
} else {
nameMap[contact.m_nsUsrName] =
nameMode === 'remark'
? contact.remark || contact.m_nsNickName || contact.m_nsUsrName
: contact.wechatNickname || contact.m_nsUsrName
if (contact.avatar) avatarUrls[contact.m_nsUsrName] = contact.avatar
}
if (selfInfo?.wxid) {
nameMap[selfInfo.wxid] = selfInfo.nickname || selfInfo.wxid
if (selfInfo.avatar) avatarUrls[selfInfo.wxid] = selfInfo.avatar
}
return {
userMd5: contact.md5,
name: displayName(contact),
type: contact.type,
avatarUrl: contact.avatar,
nameMode,
nameMap,
avatarUrls
}
})
)
const now = new Date()
const endOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)
const days = range === 'today' ? 1 : range === 'threeDays' ? 3 : range === 'sevenDays' ? 7 : 0
const startOfRange = days
? new Date(now.getFullYear(), now.getMonth(), now.getDate() - days + 1)
: null
const request = {
const request: ExportRequest = {
jobId: nextJobId,
userMd5: activeContact.md5,
name: activeName,
format,
targets,
format: selectedContacts.length > 1 ? 'html' : format,
outputName,
startTime: startOfRange
? Math.floor(startOfRange.getTime() / 1000)
@@ -232,9 +286,6 @@ export function ExportWorkspace({
fallbackThumbnail,
keepMissing,
includeAvatars,
avatarUrls: exportAvatarUrls,
nameMode,
nameMap: exportNameMap,
zip
}
const result = await onStartExport(request)
@@ -277,6 +328,29 @@ export function ExportWorkspace({
)
}, [currentTask])
const resetDefaults = (): void => {
const contact = initialContactRef.current || contacts[0] || null
setSelectedContacts(contact ? [contact] : [])
setActiveContactId(contact?.md5 || '')
setSelectionMode(false)
setRange('today')
setStartDate('')
setEndDate('')
setSelectedKinds(new Set(['text']))
setNameMode(contact?.type === 'group' ? 'groupNickname' : 'remark')
setIncludeMedia(true)
setIncludeAvatars(true)
setPreferOriginal(true)
setFallbackThumbnail(true)
setKeepMissing(true)
setFormat('csv')
setZip(false)
setFileName('')
setStatus('idle')
setJobId('')
setProgress(null)
}
const targetPath =
format === 'html'
? zip
@@ -290,13 +364,17 @@ export function ExportWorkspace({
contacts={contacts}
filteredContacts={filteredContacts}
activeContact={activeContact}
selectedContactIds={selectedContacts.map((contact) => contact.md5)}
selectionMode={selectionMode}
selectionLimit={selectionLimit}
selfInfo={selfInfo}
dbReady={dbReady}
contactFilter={contactFilter}
contactType={contactType}
onContactFilterChange={setContactFilter}
onContactTypeChange={setContactType}
onSelectContact={onSelectContact}
onSelectContact={handleSelectContact}
onCompleteSelection={() => setSelectionMode(false)}
onOpenSettings={onOpenSettings}
/>
@@ -310,20 +388,28 @@ export function ExportWorkspace({
onCancel={(taskJobId) => void onCancelExport(taskJobId)}
/>
<header className="export-config-header">
<span className="export-chat-avatar">
{activeContact?.avatar ? (
<img src={activeContact.avatar} alt="" />
) : (
activeName.slice(0, 1)
)}
<span className="export-chat-avatar-stack" aria-hidden>
{selectedContacts.slice(0, 3).map((contact) => (
<span className="export-chat-avatar" key={contact.md5}>
{contact.avatar ? (
<img src={contact.avatar} alt="" />
) : (
displayName(contact).slice(0, 1)
)}
</span>
))}
</span>
<span>
<span className="export-config-title">
<h1></h1>
<p>
{activeName}
{activeContact?.type === 'group' ? ' · 群聊' : ''}
</p>
<p>{selectedLabel}</p>
</span>
<button
type="button"
className="export-add-chat-button"
onClick={() => setSelectionMode((current) => !current)}
>
{selectionMode ? '完成选择' : '+ 添加聊天'}
</button>
</header>
<section className="export-section export-format-top">
@@ -334,6 +420,7 @@ export function ExportWorkspace({
key={value}
type="button"
className={format === value ? 'active' : ''}
disabled={selectedContacts.length > 1 && value !== 'html'}
onClick={() => setFormat(value)}
>
<strong>{formatLabels[value].label}</strong>
@@ -342,7 +429,9 @@ export function ExportWorkspace({
))}
</div>
<p className="export-helper-text">
CSV HTML
{selectedContacts.length > 1
? '多聊天合并仅支持 HTML,会保留每条消息所属的聊天。'
: 'CSV 默认最快;HTML 会包含图片、引用和其他媒体,导出时间可能较长。'}
</p>
{format === 'html' && (
<>
@@ -544,45 +633,6 @@ export function ExportWorkspace({
</label>
</section>
<section className="export-section">
<h3></h3>
<div className="export-format-grid">
{formatOrder.map((value) => (
<button
key={value}
type="button"
className={format === value ? 'active' : ''}
onClick={() => setFormat(value)}
>
<strong>{formatLabels[value].label}</strong>
{formatLabels[value].hint && <small>{formatLabels[value].hint}</small>}
</button>
))}
</div>
{format === 'html' && (
<div className="export-html-options">
<label>
<input
type="radio"
name="html-package"
checked={!zip}
onChange={() => setZip(false)}
/>{' '}
HTML
</label>
<label>
<input
type="radio"
name="html-package"
checked={zip}
onChange={() => setZip(true)}
/>{' '}
HTML ZIP
</label>
</div>
)}
</section>
<section className="export-section export-save-section">
<h3></h3>
<label>
@@ -590,7 +640,7 @@ export function ExportWorkspace({
<input
value={fileName}
onChange={(event) => setFileName(event.target.value)}
placeholder={`${activeName}_聊天档案`}
placeholder={defaultOutputName}
/>
</label>
<div className="export-target-path">
@@ -615,7 +665,7 @@ export function ExportWorkspace({
: '准备就绪'}
</span>
<span className="export-target-summary">{targetPath}</span>
<button type="button" className="export-reset-button" onClick={() => setStatus('idle')}>
<button type="button" className="export-reset-button" onClick={resetDefaults}>
</button>
<button
@@ -636,6 +686,7 @@ export function ExportWorkspace({
previewBytes={previewBytes}
selfInfo={selfInfo}
progress={progress}
selectedCount={selectedContacts.length}
jobId={jobId}
onCancel={(exportJobId) => {
void window.api.cancelExport(exportJobId)
@@ -30,15 +30,21 @@ export interface SelfInfo {
export interface ExportWorkspaceProps {
contacts: Contact[]
selectedContact: Contact | null
previewMessages: Message[]
initialContact: Contact | null
selfInfo: SelfInfo | null
dbReady: boolean
onSelectContact: (contact: Contact) => void
loadPreviewMessages: (contact: Contact) => Promise<Message[]>
onOpenSettings: () => void
exportTasks: ExportTaskRecord[]
onStartExport: (request: ExportRequest) => Promise<ExportResult>
onCancelExport: (jobId: string) => Promise<void>
}
export type { Contact, ExportJobProgress, ExportMessageKind, Message, ExportNameMode, ExportTaskRecord }
export type {
Contact,
ExportJobProgress,
ExportMessageKind,
Message,
ExportNameMode,
ExportTaskRecord
}
+101
View File
@@ -116,6 +116,25 @@
padding: 8px 0;
}
.export-multi-select-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 9px 16px;
border-bottom: 1px solid var(--wxex-border);
background: var(--wxex-brand-soft);
color: var(--wxex-brand);
font-size: 12px;
button {
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
font: 600 12px/18px var(--wxex-font);
}
}
.export-contact-item {
display: flex;
align-items: center;
@@ -137,6 +156,28 @@
border-left-color: var(--wxex-brand);
background: var(--wxex-brand-soft);
}
&:disabled {
cursor: not-allowed;
opacity: 0.48;
}
}
.export-contact-check {
width: 18px;
height: 18px;
display: grid;
place-items: center;
flex: 0 0 auto;
border: 1px solid var(--wxex-border-strong);
border-radius: 4px;
color: #fff;
font-size: 12px;
&.checked {
border-color: var(--wxex-brand);
background: var(--wxex-brand);
}
}
.export-contact-avatar,
@@ -246,6 +287,46 @@
font-size: 13px;
}
}
.export-config-title {
min-width: 0;
}
.export-config-title p {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.export-chat-avatar-stack {
position: relative;
width: 70px;
height: 58px;
flex: 0 0 70px;
.export-chat-avatar {
position: absolute;
top: 3px;
border: 2px solid var(--wxex-bg-main);
}
.export-chat-avatar:nth-child(2) {
left: 12px;
top: 8px;
}
.export-chat-avatar:nth-child(3) {
left: 24px;
top: 13px;
}
}
.export-add-chat-button {
margin-left: auto;
padding: 7px 10px;
border: 1px solid var(--wxex-brand);
border-radius: 6px;
background: transparent;
color: var(--wxex-brand);
cursor: pointer;
white-space: nowrap;
font: 600 12px/18px var(--wxex-font);
}
.export-chat-avatar {
width: 52px;
height: 52px;
@@ -259,6 +340,11 @@
}
}
.export-format-grid button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.export-section {
margin-bottom: 25px;
@@ -768,6 +854,21 @@
color: var(--wxex-danger, #b42318);
}
.export-task-detail {
overflow: visible;
text-overflow: clip;
white-space: normal;
overflow-wrap: anywhere;
&.completed {
color: var(--wxex-brand);
}
&.failed {
color: var(--wxex-danger, #c43d3d);
}
}
button {
border: 1px solid var(--wxex-border);
border-radius: 5px;