feat: 导出功能

This commit is contained in:
Wxw-Gu
2026-07-27 17:43:36 +08:00
parent 9ae0c6cc47
commit e93e4554d7
18 changed files with 2007 additions and 92 deletions
+13 -1
View File
@@ -20,6 +20,7 @@ import { AiModelConfig, useGroupReportGeneration } from './hooks/useGroupReportG
import { SummaryDateRange, SummaryMessageType } from './utils/group-report'
import { Contact, Message } from '../../shared/types'
import { DatabaseConnectionMode, DatabaseConnectionPage } from './components/DatabaseConnectionPage'
import { ExportWorkspace } from './components/export/ExportWorkspace'
const SIDEBAR_MIN_WIDTH = 260
const SIDEBAR_MAX_WIDTH = 380
@@ -1201,8 +1202,19 @@ function App(): React.ReactElement {
/>
)
case 'search':
case 'export':
return renderPlaceholderPage(activePage)
case 'export':
return (
<ExportWorkspace
contacts={contacts}
selectedContact={selectedContact}
previewMessages={messages}
selfInfo={selfInfo}
dbReady={isDatabaseConnected}
onSelectContact={handleSelectContact}
onOpenSettings={openSettings}
/>
)
}
}
+740
View File
@@ -7808,3 +7808,743 @@ body {
color: var(--wxex-text-muted);
font: 11px/17px var(--wxex-font);
}
/* Export workspace */
.export-workspace {
display: grid;
grid-template-columns: 292px minmax(520px, 1fr) 360px;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
background: var(--wxex-bg-main);
}
.export-contact-panel,
.export-config-panel,
.export-preview-panel {
min-width: 0;
min-height: 0;
background: var(--wxex-bg-elevated);
}
.export-contact-panel {
display: flex;
flex-direction: column;
border-right: 1px solid var(--wxex-border);
background: var(--wxex-bg-sidebar);
}
.export-panel-header {
padding: 20px 16px 12px;
border-bottom: 1px solid var(--wxex-border);
}
.export-panel-title-row,
.export-section-heading,
.export-preview-heading,
.export-action-bar,
.export-target-path,
.export-media-master,
.export-account-summary {
display: flex;
align-items: center;
}
.export-panel-title-row {
justify-content: space-between;
gap: 8px;
margin-bottom: 14px;
}
.export-panel-title-row h2,
.export-config-header h1,
.export-job-state h2 {
margin: 0;
color: var(--wxex-text-primary);
font-weight: 700;
}
.export-panel-title-row h2 {
font-size: 17px;
}
.export-count-badge {
padding: 3px 8px;
border-radius: 5px;
background: var(--wxex-bg-elevated);
color: var(--wxex-text-secondary);
font-size: 11px;
white-space: nowrap;
}
.export-search-field {
display: flex;
align-items: center;
gap: 8px;
height: 38px;
padding: 0 10px;
border: 1px solid var(--wxex-border);
border-radius: 7px;
background: var(--wxex-bg-elevated);
color: var(--wxex-text-muted);
}
.export-search-field input,
.export-save-section input,
.export-date-fields input {
min-width: 0;
border: 0;
outline: 0;
background: transparent;
color: var(--wxex-text-primary);
font: 13px/20px var(--wxex-font);
}
.export-search-field input {
width: 100%;
}
.export-filter-tabs {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 3px;
margin-top: 12px;
padding: 3px;
border-radius: 7px;
background: #e3e9e5;
}
.export-filter-tabs button,
.export-range-toggle button {
border: 0;
border-radius: 5px;
background: transparent;
color: var(--wxex-text-secondary);
cursor: pointer;
font: 600 12px/30px var(--wxex-font);
}
.export-filter-tabs button.active,
.export-range-toggle button.active {
background: var(--wxex-bg-elevated);
color: var(--wxex-brand);
box-shadow: 0 1px 2px rgba(32, 39, 36, 0.06);
}
.export-contact-list {
flex: 1;
min-height: 0;
overflow: auto;
padding: 8px 0;
}
.export-contact-item {
display: flex;
align-items: center;
width: 100%;
gap: 10px;
padding: 11px 16px;
border: 0;
border-left: 3px solid transparent;
background: transparent;
color: var(--wxex-text-primary);
cursor: pointer;
text-align: left;
}
.export-contact-item:hover {
background: rgba(255, 255, 255, 0.58);
}
.export-contact-item.active {
border-left-color: var(--wxex-brand);
background: var(--wxex-brand-soft);
}
.export-contact-avatar,
.export-account-avatar,
.export-chat-avatar,
.export-preview-avatar {
display: grid;
place-items: center;
flex: 0 0 auto;
overflow: hidden;
background: var(--wxex-brand-soft);
color: var(--wxex-brand);
font-weight: 700;
}
.export-contact-avatar {
width: 38px;
height: 38px;
border-radius: 8px;
}
.export-contact-avatar img,
.export-account-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.export-contact-copy,
.export-account-summary > span:last-child {
display: grid;
min-width: 0;
gap: 2px;
}
.export-contact-copy strong,
.export-account-summary strong {
overflow: hidden;
color: var(--wxex-text-primary);
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.export-contact-copy small,
.export-account-summary small {
color: var(--wxex-text-muted);
font-size: 11px;
}
.export-account-summary small.ready {
color: var(--wxex-success);
}
.export-account-summary {
gap: 9px;
padding: 14px 16px;
border: 0;
border-top: 1px solid var(--wxex-border);
background: transparent;
text-align: left;
cursor: pointer;
}
.export-account-avatar {
width: 34px;
height: 34px;
border-radius: 50%;
}
.export-config-panel {
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--wxex-bg-main);
}
.export-config-scroll {
flex: 1;
min-height: 0;
overflow: auto;
padding: 24px 28px 28px;
}
.export-config-header {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 26px;
}
.export-chat-avatar {
width: 52px;
height: 52px;
border-radius: 10px;
font-size: 22px;
}
.export-chat-avatar img,
.export-preview-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.export-config-header h1 {
font-size: 20px;
line-height: 28px;
}
.export-config-header p {
margin: 2px 0 0;
color: var(--wxex-text-secondary);
font-size: 13px;
}
.export-section {
margin-bottom: 25px;
}
.export-section h3 {
margin: 0 0 12px;
color: var(--wxex-text-primary);
font-size: 12px;
font-weight: 700;
}
.export-section-heading {
justify-content: space-between;
gap: 12px;
}
.export-section-heading span {
color: var(--wxex-brand);
font-size: 12px;
}
.export-range-toggle {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.export-range-toggle button {
border: 1px solid var(--wxex-border);
background: var(--wxex-bg-elevated);
line-height: 36px;
}
.export-range-toggle button.active {
border-color: var(--wxex-brand);
background: var(--wxex-brand);
color: #fff;
box-shadow: none;
}
.export-date-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 10px;
padding: 12px;
border-radius: 8px;
background: #f0f3f0;
}
.export-date-fields label,
.export-save-section > label {
display: grid;
gap: 5px;
color: var(--wxex-text-secondary);
font-size: 11px;
}
.export-date-fields input,
.export-save-section input {
width: 100%;
padding: 8px 9px;
border: 1px solid var(--wxex-border);
border-radius: 6px;
background: #fff;
}
.export-kind-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4px 20px;
padding: 14px;
border-radius: 8px;
background: #f0f3f0;
}
.export-check-row {
display: flex;
align-items: center;
gap: 8px;
min-height: 28px;
color: var(--wxex-text-primary);
font-size: 12px;
cursor: pointer;
}
.export-check-row input {
width: 16px;
height: 16px;
accent-color: var(--wxex-brand);
}
.export-name-mode-grid {
display: flex;
gap: 8px;
padding: 10px;
border-radius: 8px;
background: #f0f3f0;
}
.export-name-mode-option {
display: flex;
align-items: center;
gap: 7px;
flex: 1;
min-height: 32px;
padding: 0 8px;
border: 1px solid var(--wxex-border);
border-radius: 6px;
background: #fff;
color: var(--wxex-text-primary);
font-size: 12px;
cursor: pointer;
}
.export-name-mode-option input {
accent-color: var(--wxex-brand);
}
.export-media-master {
justify-content: space-between;
gap: 12px;
padding: 13px 14px;
border: 1px solid var(--wxex-border);
border-radius: 7px;
background: #fff;
color: var(--wxex-text-primary);
font-size: 12px;
}
.export-media-master input {
width: 17px;
height: 17px;
accent-color: var(--wxex-brand);
}
.export-media-options {
display: grid;
gap: 2px;
margin-top: 8px;
padding: 9px 13px;
border-radius: 8px;
background: #f0f3f0;
}
.export-media-options.disabled {
opacity: 0.52;
}
.export-resource-statuses {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 9px;
}
.export-resource-statuses span {
padding: 3px 7px;
border-radius: 4px;
background: var(--wxex-brand-soft);
color: var(--wxex-success);
font-size: 10px;
}
.export-helper-text {
margin: 9px 0 0;
color: var(--wxex-text-muted);
font-size: 11px;
}
.export-format-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.export-format-grid button {
min-height: 78px;
border: 1px solid var(--wxex-border);
border-radius: 8px;
background: #fff;
color: var(--wxex-text-primary);
cursor: pointer;
}
.export-format-grid button.active {
border: 2px solid var(--wxex-brand);
background: #f2f8f5;
}
.export-format-grid strong {
display: block;
font-size: 13px;
}
.export-format-grid small {
display: block;
margin-top: 6px;
color: var(--wxex-success);
font-size: 10px;
}
.export-html-options {
display: grid;
gap: 8px;
margin-top: 10px;
padding: 11px 13px;
border: 1px solid var(--wxex-border);
border-radius: 8px;
background: #f0f3f0;
color: var(--wxex-text-primary);
font-size: 12px;
}
.export-html-options input {
accent-color: var(--wxex-brand);
}
.export-save-section {
display: grid;
gap: 12px;
}
.export-target-path {
gap: 10px;
padding: 9px 10px;
border: 1px solid var(--wxex-border);
border-radius: 7px;
background: #fff;
color: var(--wxex-text-secondary);
font-size: 11px;
}
.export-target-path strong {
flex: 1;
min-width: 0;
overflow: hidden;
color: var(--wxex-text-primary);
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.export-target-path button {
padding: 5px 8px;
border: 1px solid var(--wxex-border);
border-radius: 5px;
background: #fff;
color: var(--wxex-text-primary);
cursor: pointer;
font: 11px var(--wxex-font);
}
.export-action-bar {
flex: 0 0 58px;
gap: 8px;
padding: 0 22px;
border-top: 1px solid var(--wxex-border);
background: #fff;
color: var(--wxex-text-secondary);
font-size: 12px;
}
.export-ready-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--wxex-success);
}
.export-ready-dot.completed {
background: var(--wxex-brand);
}
.export-target-summary {
flex: 1;
min-width: 0;
margin-left: 14px;
overflow: hidden;
color: var(--wxex-text-muted);
text-overflow: ellipsis;
white-space: nowrap;
}
.export-reset-button {
border: 0;
background: transparent;
color: var(--wxex-text-primary);
cursor: pointer;
font: 600 12px var(--wxex-font);
}
.export-primary-button {
min-width: 132px;
padding: 10px 16px;
border: 0;
border-radius: 7px;
background: var(--wxex-brand);
color: #fff;
cursor: pointer;
font: 700 12px var(--wxex-font);
}
.export-primary-button:disabled {
cursor: default;
opacity: 0.55;
}
.export-preview-panel {
display: flex;
flex-direction: column;
overflow: hidden;
border-left: 1px solid var(--wxex-border);
background: #fbfcfb;
}
.export-preview-heading {
justify-content: space-between;
flex: 0 0 52px;
padding: 0 18px;
border-bottom: 1px solid var(--wxex-border);
color: var(--wxex-text-primary);
font-size: 12px;
}
.export-preview-heading span {
color: var(--wxex-text-muted);
font-size: 11px;
}
.export-message-preview {
flex: 1;
min-height: 0;
overflow: auto;
padding: 16px 14px;
}
.export-preview-date {
width: fit-content;
margin: 0 auto 18px;
padding: 4px 9px;
border-radius: 10px;
background: #e9eeeb;
color: var(--wxex-text-secondary);
font-size: 10px;
}
.export-preview-message {
display: flex;
align-items: flex-start;
gap: 8px;
margin-bottom: 15px;
}
.export-preview-message.mine {
flex-direction: row-reverse;
}
.export-preview-avatar {
width: 30px;
height: 30px;
border-radius: 7px;
font-size: 11px;
}
.export-preview-bubble {
max-width: 78%;
padding: 9px 10px;
border-radius: 8px;
background: #fff;
color: var(--wxex-text-primary);
font-size: 12px;
line-height: 18px;
box-shadow: 0 1px 2px rgba(32, 39, 36, 0.05);
}
.export-preview-message.mine .export-preview-bubble {
background: #95ec69;
}
.export-preview-bubble small {
display: block;
margin-bottom: 3px;
color: var(--wxex-text-muted);
font-size: 10px;
}
.export-preview-stats {
flex: 0 0 auto;
display: grid;
gap: 12px;
padding: 18px;
border-top: 1px solid var(--wxex-border);
}
.export-preview-stats span,
.export-complete-summary span {
display: flex;
justify-content: space-between;
color: var(--wxex-text-secondary);
font-size: 12px;
}
.export-preview-stats strong,
.export-complete-summary strong {
color: var(--wxex-text-primary);
font-weight: 600;
}
.export-job-state {
display: grid;
align-content: center;
gap: 12px;
height: 100%;
padding: 28px;
text-align: center;
}
.export-job-state h2 {
font-size: 18px;
}
.export-job-state p {
margin: 0;
color: var(--wxex-text-secondary);
font-size: 12px;
line-height: 18px;
}
.export-job-state ol {
display: grid;
gap: 10px;
margin: 8px 0;
padding: 0;
list-style: none;
text-align: left;
}
.export-job-state li {
color: var(--wxex-text-muted);
font-size: 12px;
}
.export-job-state li::before {
display: inline-grid;
width: 16px;
height: 16px;
margin-right: 8px;
place-items: center;
border: 1px solid var(--wxex-border);
border-radius: 50%;
content: '';
vertical-align: -3px;
}
.export-job-state li.done {
color: var(--wxex-success);
}
.export-job-state li.done::before {
border-color: var(--wxex-success);
background: var(--wxex-success);
content: '✓';
color: #fff;
font-size: 10px;
}
.export-job-state li.current {
color: var(--wxex-text-primary);
font-weight: 600;
}
.export-job-state li.current::before {
border-color: var(--wxex-brand);
background: var(--wxex-brand);
box-shadow: inset 0 0 0 4px #fff;
}
.export-progress-bar {
height: 6px;
overflow: hidden;
border-radius: 4px;
background: #e2e9e4;
}
.export-progress-bar span {
display: block;
height: 100%;
border-radius: 4px;
background: var(--wxex-brand);
transition: width 0.2s ease;
}
.export-job-state > strong {
color: var(--wxex-text-secondary);
font-size: 12px;
font-weight: 500;
}
.export-cancel-button,
.export-open-folder-button {
padding: 10px 14px;
border: 1px solid var(--wxex-border);
border-radius: 7px;
background: #fff;
color: var(--wxex-text-primary);
cursor: pointer;
font: 600 12px var(--wxex-font);
}
.export-success-icon {
width: 58px;
height: 58px;
margin: 0 auto 4px;
border: 5px solid var(--wxex-brand-soft);
border-radius: 50%;
background: #fff;
color: var(--wxex-brand);
font-size: 30px;
line-height: 48px;
}
.export-complete-summary {
display: grid;
gap: 12px;
margin: 12px 0;
padding: 14px;
border-radius: 8px;
background: #eef3ef;
text-align: left;
}
@media (max-width: 1100px) {
.export-workspace {
grid-template-columns: 248px minmax(460px, 1fr);
}
.export-preview-panel {
display: none;
}
}
@@ -4,7 +4,6 @@ import { ChatHeader } from './chat/ChatHeader'
import { ChatStatusBar } from './chat/ChatStatusBar'
import { DataTrustBar } from './chat/DataTrustBar'
import { EmptyConversationState } from './chat/EmptyConversationState'
import { ExportRange } from './chat/ExportMenu'
import { MessageList } from './chat/MessageList'
interface ChatWindowProps {
@@ -171,65 +170,6 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
}
}, [previewImage])
const handleExport = (days: ExportRange): void => {
if (!messages.length) return
let filtered = messages
if (days !== 'all') {
const now = new Date()
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
filtered = messages.filter((m) => {
const parsed = new Date(m.datetime).getTime()
if (isNaN(parsed)) return true
if (days === 0) {
// 今天
return parsed >= startOfDay
} else if (days === 1) {
// 昨天
const startOfYesterday = startOfDay - 86400000
return parsed >= startOfYesterday && parsed < startOfDay
} else if (days === 7) {
// 过去 7 天
const startOf7DaysAgo = startOfDay - 7 * 86400000
return parsed >= startOf7DaysAgo
} else if (days === 30) {
// 过去 30 天
const startOf30DaysAgo = startOfDay - 30 * 86400000
return parsed >= startOf30DaysAgo
}
return true
})
}
const headers = ['发送者', '类型', '时间', '内容']
const csvContent = [
headers.join(','),
...filtered.map((m) => {
let prefix = ''
if (isGroupChat) {
prefix = m.name ? `${m.name}: ` : ''
} else {
const name = m.from === 'user' ? contact?.m_nsNickName || '未知' : '我'
prefix = `${name}: `
}
const fullContent = `${prefix}${m.content}`
const content = fullContent.replace(/"/g, '""').replace(/\n/g, ' ')
return `"${m.from}","${m.type}","${m.datetime}","${content}"`
})
].join('\n')
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.setAttribute('download', `${contact?.m_nsNickName || 'chat'}_export.csv`)
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
const filteredMessages = React.useMemo(() => {
return messages.filter((msg) => {
const filterTypes = (import.meta.env.VITE_FILTER_MSG_TYPES || '')
@@ -255,11 +195,9 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
filteredCount={filteredMessages.length}
contentFilter={contentFilter || ''}
isAiLoading={isAiLoading}
canExport={messages.length > 0}
onContentFilterChange={onContentFilterChange || (() => undefined)}
onRefresh={onRefresh}
onRefreshData={onRefreshData}
onExport={handleExport}
onOpenAiSettings={onCreateGroupReport || (() => undefined)}
/>
<DataTrustBar messageCount={messages.length} />
@@ -2,14 +2,21 @@ import { ParsedContent } from '../../../shared/types'
import { useEffect, useState } from 'react'
import type { JSX, MouseEvent } from 'react'
import { renderWechatEmojiText } from '../utils/wechatEmojiText'
import { ImageBubble } from './ImageBubble'
const stickerDataUrlCache = new Map<string, string>()
interface RichMessageBubbleProps {
contentData: ParsedContent
sessionId?: string
onImageClick?: (imageUrl: string) => void
}
export function RichMessageBubble({ contentData }: RichMessageBubbleProps): JSX.Element {
export function RichMessageBubble({
contentData,
sessionId,
onImageClick
}: RichMessageBubbleProps): JSX.Element {
switch (contentData.type) {
case 'location':
return <LocationBubble data={contentData} />
@@ -22,7 +29,7 @@ export function RichMessageBubble({ contentData }: RichMessageBubbleProps): JSX.
case 'sticker':
return <StickerBubble data={contentData} />
case 'quote':
return <QuoteBubble data={contentData} />
return <QuoteBubble data={contentData} sessionId={sessionId} onImageClick={onImageClick} />
case 'system':
return <SystemBubble data={contentData} />
case 'unknown':
@@ -201,7 +208,15 @@ function StickerBubble({
)
}
function QuoteBubble({ data }: { data: Extract<ParsedContent, { type: 'quote' }> }): JSX.Element {
function QuoteBubble({
data,
sessionId,
onImageClick
}: {
data: Extract<ParsedContent, { type: 'quote' }>
sessionId?: string
onImageClick?: (imageUrl: string) => void
}): JSX.Element {
const quotedText = data.quotedContent || data.content || '[引用消息]'
const replyText = data.content || data.title || ''
const quotedSender = data.quotedSender || data.sender || ''
@@ -210,7 +225,17 @@ function QuoteBubble({ data }: { data: Extract<ParsedContent, { type: 'quote' }>
<div className="quote-message">
<div className="quoted-message">
{quotedSender && <span className="quoted-sender">{quotedSender}</span>}
<span className="quoted-text">{renderWechatEmojiText(quotedText, 18)}</span>
{data.quotedImageMd5 || data.quotedImageDatName ? (
<ImageBubble
imageMd5={data.quotedImageMd5}
imageDatName={data.quotedImageDatName}
sessionId={sessionId}
isThumb
onImageClick={onImageClick}
/>
) : (
<span className="quoted-text">{renderWechatEmojiText(quotedText, 18)}</span>
)}
</div>
{replyText && <div className="quote-reply">{renderWechatEmojiText(replyText)}</div>}
</div>
@@ -1,7 +1,6 @@
import React, { useEffect, useRef, useState } from 'react'
import { Contact } from '../../../../shared/types'
import { ConversationContentSearch } from './ConversationContentSearch'
import { ExportMenu, ExportRange } from './ExportMenu'
import { AiIcon, MoreIcon, RefreshIcon, SearchIcon } from './icons'
interface ChatHeaderProps {
@@ -12,11 +11,9 @@ interface ChatHeaderProps {
filteredCount: number
contentFilter: string
isAiLoading: boolean
canExport: boolean
onContentFilterChange: (value: string) => void
onRefresh?: () => void
onRefreshData?: () => void
onExport: (range: ExportRange) => void
onOpenAiSettings: () => void
}
@@ -28,11 +25,9 @@ export function ChatHeader({
filteredCount,
contentFilter,
isAiLoading,
canExport,
onContentFilterChange,
onRefresh,
onRefreshData,
onExport,
onOpenAiSettings
}: ChatHeaderProps): React.ReactElement {
const [searchOpen, setSearchOpen] = useState(Boolean(contentFilter))
@@ -96,7 +91,6 @@ export function ChatHeader({
<button type="button" className="chat-icon-button" onClick={onRefresh} title="刷新聊天记录">
<RefreshIcon />
</button>
<ExportMenu disabled={!canExport} onExport={onExport} />
<div className="chat-menu" ref={moreRef}>
<button
type="button"
@@ -16,7 +16,7 @@ interface MessageBubbleProps {
onImageClick: (imageUrl: string) => void
}
const RICH_MESSAGE_TYPES = ['名片', '位置', '分享消息', '通话', '表情包', '系统消息']
const RICH_MESSAGE_TYPES = ['名片', '位置', '分享消息', '引用消息', '通话', '表情包', '系统消息']
export function MessageBubble({
message,
@@ -60,7 +60,11 @@ export function MessageBubble({
duration={message.contentData.duration}
/>
) : isRichMedia && message.contentData ? (
<RichMessageBubble contentData={message.contentData} />
<RichMessageBubble
contentData={message.contentData}
sessionId={message.sessionId}
onImageClick={onImageClick}
/>
) : (
<div className="message-text">{renderWechatEmojiText(message.content)}</div>
)}
@@ -0,0 +1,692 @@
import React, { useMemo, useState } from 'react'
import type { Contact, Message } from '../../../../shared/types'
import type {
ExportJobProgress,
ExportMessageKind,
ExportNameMode
} from '../../../../shared/export'
type ExportRange = 'today' | 'threeDays' | 'sevenDays' | 'custom'
type ExportFormat = 'html' | 'csv' | 'json' | 'markdown'
type ExportStatus = 'idle' | 'running' | 'completed'
interface GroupMemberName {
wxid: string
nickname: string
groupNickname: string
wechatNickname: string
remark: string
avatar: string
}
interface SelfInfo {
wxid: string
nickname: string
avatar?: string
accountRoot: string
}
interface ExportWorkspaceProps {
contacts: Contact[]
selectedContact: Contact | null
previewMessages: Message[]
selfInfo: SelfInfo | null
dbReady: boolean
onSelectContact: (contact: Contact) => void
onOpenSettings: () => void
}
const messageKinds = [
['text', '文字'],
['image', '图片'],
['video', '视频'],
['voice', '语音'],
['sticker', '表情包'],
['share', '链接与分享'],
['location', '位置'],
['system', '系统消息']
] as const
const formatLabels: Record<ExportFormat, { label: string; hint?: string }> = {
html: { label: 'HTML', hint: '推荐' },
csv: { label: 'CSV' },
json: { label: 'JSON' },
markdown: { label: 'Markdown' }
}
function displayName(contact: Contact | null): string {
return contact?.m_nsNickName || contact?.m_nsUsrName || '未选择会话'
}
function formatPreviewTime(message: Message): string {
if (!message.createTime) return message.datetime || ''
return new Date(message.createTime * 1000).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
})
}
export function ExportWorkspace({
contacts,
selectedContact,
previewMessages,
selfInfo,
dbReady,
onSelectContact,
onOpenSettings
}: ExportWorkspaceProps): React.ReactElement {
const [contactFilter, setContactFilter] = useState('')
const [contactType, setContactType] = useState<'all' | 'group' | 'user'>('all')
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 [includeMedia, setIncludeMedia] = useState(true)
const [includeAvatars, setIncludeAvatars] = useState(true)
const [preferOriginal, setPreferOriginal] = useState(true)
const [fallbackThumbnail, setFallbackThumbnail] = useState(true)
const [keepMissing, setKeepMissing] = useState(false)
const [format, setFormat] = useState<ExportFormat>('html')
const [zip, setZip] = useState(false)
const [fileName, setFileName] = useState('')
const [status, setStatus] = useState<ExportStatus>('idle')
const [jobId, setJobId] = useState('')
const [progress, setProgress] = useState<ExportJobProgress | null>(null)
const filteredContacts = useMemo(() => {
const keyword = contactFilter.trim().toLowerCase()
return contacts.filter((contact) => {
if (contactType !== 'all' && contact.type !== contactType) return false
if (!keyword) return true
return [contact.m_nsNickName, contact.m_nsUsrName].some((value) =>
value.toLowerCase().includes(keyword)
)
})
}, [contactFilter, contactType, contacts])
const activeContact = selectedContact || filteredContacts[0] || contacts[0] || null
const activeName = displayName(activeContact)
const preview = previewMessages.slice(-20)
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])
React.useEffect(() => {
setNameMode(activeContact?.type === 'group' ? 'groupNickname' : 'remark')
let cancelled = false
if (!activeContact || activeContact.type !== 'group') {
setGroupMembers([])
return () => {
cancelled = true
}
}
void window.api.getGroupSnapshot(activeContact.md5).then((snapshot) => {
if (!cancelled) setGroupMembers((snapshot?.members || []) as GroupMemberName[])
})
return () => {
cancelled = true
}
}, [activeContact])
const toggleKind = (value: string): void => {
setSelectedKinds((current) => {
const next = new Set(current)
if (next.has(value)) next.delete(value)
else next.add(value)
return next
})
}
const handleStart = async (): Promise<void> => {
if (!activeContact || status === 'running') return
const nextJobId = `export-${Date.now()}`
setJobId(nextJobId)
setProgress(null)
setStatus('running')
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 = {
jobId: nextJobId,
userMd5: activeContact.md5,
name: activeName,
format,
outputName,
startTime: startOfRange
? Math.floor(startOfRange.getTime() / 1000)
: range === 'custom' && startDate
? Math.floor(new Date(startDate).getTime() / 1000)
: undefined,
endTime: startOfRange
? Math.floor(endOfToday.getTime() / 1000)
: range === 'custom' && endDate
? Math.floor(new Date(endDate).getTime() / 1000)
: undefined,
kinds: Array.from(selectedKinds) as ExportMessageKind[],
includeMedia,
includeAvatars,
avatarUrls,
nameMode,
nameMap,
zip
}
const result = await window.api.startExport(request)
if (!result.success && result.error !== '已取消') setStatus('idle')
}
React.useEffect(
() =>
window.api.onExportProgress((next) => {
if (next.jobId !== jobId) return
setProgress(next)
if (next.phase === 'completed') setStatus('completed')
if (next.phase === 'cancelled' || next.phase === 'failed') setStatus('idle')
}),
[jobId]
)
const targetPath =
format === 'html'
? zip
? `文稿/WechatExplorer/导出/${outputName}.zip`
: `文稿/WechatExplorer/导出/${outputName}/`
: `文稿/WechatExplorer/导出/${outputName}.${format === 'markdown' ? 'md' : format}`
return (
<div className="export-workspace">
<aside className="export-contact-panel">
<div className="export-panel-header">
<div className="export-panel-title-row">
<h2></h2>
<span className="export-count-badge"> {contacts.length.toLocaleString()} </span>
</div>
<label className="export-search-field">
<span aria-hidden></span>
<input
value={contactFilter}
onChange={(event) => setContactFilter(event.target.value)}
placeholder="搜索群聊、联系人或 wxid"
aria-label="搜索聊天"
/>
</label>
<div className="export-filter-tabs" role="tablist" aria-label="聊天类型">
{(
[
['all', '全部'],
['group', '群聊'],
['user', '联系人']
] as const
).map(([value, label]) => (
<button
key={value}
type="button"
className={contactType === value ? 'active' : ''}
onClick={() => setContactType(value)}
>
{label}
</button>
))}
</div>
</div>
<div className="export-contact-list">
{filteredContacts.map((contact) => {
const name = displayName(contact)
return (
<button
key={contact.md5}
type="button"
className={`export-contact-item ${activeContact?.md5 === contact.md5 ? 'active' : ''}`}
onClick={() => onSelectContact(contact)}
>
<span className="export-contact-avatar">
{contact.avatar ? <img src={contact.avatar} alt="" /> : name.slice(0, 1)}
</span>
<span className="export-contact-copy">
<strong>{name}</strong>
<small>{contact.type === 'group' ? '群聊' : '联系人'}</small>
</span>
</button>
)
})}
</div>
<button type="button" className="export-account-summary" onClick={onOpenSettings}>
<span className="export-account-avatar">
{selfInfo?.avatar ? (
<img src={selfInfo.avatar} alt="" />
) : (
(selfInfo?.nickname || '我').slice(0, 1)
)}
</span>
<span>
<strong>{selfInfo?.nickname || '当前账号'}</strong>
<small className={dbReady ? 'ready' : ''}>
{dbReady ? '数据库已连接' : '数据库未连接'}
</small>
</span>
</button>
</aside>
<main className="export-config-panel">
<div className="export-config-scroll">
<header className="export-config-header">
<span className="export-chat-avatar">
{activeContact?.avatar ? (
<img src={activeContact.avatar} alt="" />
) : (
activeName.slice(0, 1)
)}
</span>
<span>
<h1></h1>
<p>
{activeName}
{activeContact?.type === 'group' ? ' · 群聊' : ''}
</p>
</span>
</header>
<section className="export-section">
<div className="export-section-heading">
<h3></h3>
<span>{status === 'completed' ? '已完成导出' : '消息数量将在开始导出后统计'}</span>
</div>
<div className="export-range-toggle">
<button
type="button"
className={range === 'today' ? 'active' : ''}
onClick={() => setRange('today')}
>
</button>
<button
type="button"
className={range === 'threeDays' ? 'active' : ''}
onClick={() => setRange('threeDays')}
>
3
</button>
<button
type="button"
className={range === 'sevenDays' ? 'active' : ''}
onClick={() => setRange('sevenDays')}
>
7
</button>
<button
type="button"
className={range === 'custom' ? 'active' : ''}
onClick={() => setRange('custom')}
>
</button>
</div>
{range === 'custom' && (
<div className="export-date-fields">
<label>
<input
type="datetime-local"
value={startDate}
onChange={(event) => setStartDate(event.target.value)}
/>
</label>
<label>
<input
type="datetime-local"
value={endDate}
onChange={(event) => setEndDate(event.target.value)}
/>
</label>
</div>
)}
</section>
<section className="export-section">
<h3></h3>
<div className="export-kind-grid">
{messageKinds.map(([value, label]) => (
<label key={value} className="export-check-row">
<input
type="checkbox"
checked={selectedKinds.has(value)}
onChange={() => toggleKind(value)}
/>
<span>{label}</span>
</label>
))}
</div>
</section>
<section className="export-section">
<h3></h3>
<div className="export-name-mode-grid" role="radiogroup" aria-label="消息显示名称">
{nameOptions.map((option) => (
<label key={option.value} className="export-name-mode-option">
<input
type="radio"
name="export-name-mode"
checked={nameMode === option.value}
onChange={() => setNameMode(option.value)}
/>
<span>{option.label}</span>
</label>
))}
</div>
</section>
<section className="export-section">
<h3></h3>
<label className="export-media-master">
<span></span>
<input
type="checkbox"
checked={includeMedia}
onChange={(event) => setIncludeMedia(event.target.checked)}
/>
</label>
<div className={`export-media-options ${includeMedia ? '' : 'disabled'}`}>
<label className="export-check-row">
<input
type="checkbox"
checked={preferOriginal}
disabled={!includeMedia}
onChange={(event) => setPreferOriginal(event.target.checked)}
/>
<span></span>
</label>
<label className="export-check-row">
<input
type="checkbox"
checked={fallbackThumbnail}
disabled={!includeMedia}
onChange={(event) => setFallbackThumbnail(event.target.checked)}
/>
<span>使</span>
</label>
<label className="export-check-row">
<input
type="checkbox"
checked={keepMissing}
disabled={!includeMedia}
onChange={(event) => setKeepMissing(event.target.checked)}
/>
<span></span>
</label>
</div>
<div className="export-resource-statuses">
<span></span>
<span></span>
<span></span>
<span></span>
</div>
<p className="export-helper-text"></p>
<label className="export-media-master">
<span></span>
<input
type="checkbox"
checked={includeAvatars}
onChange={(event) => setIncludeAvatars(event.target.checked)}
/>
</label>
</section>
<section className="export-section">
<h3></h3>
<div className="export-format-grid">
{(Object.keys(formatLabels) as ExportFormat[]).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>
<input
value={fileName}
onChange={(event) => setFileName(event.target.value)}
placeholder={`${activeName}_聊天档案`}
/>
</label>
<div className="export-target-path">
<span></span>
<strong>{targetPath}</strong>
<button type="button"></button>
</div>
</section>
</div>
<footer className="export-action-bar">
<span className={`export-ready-dot ${status === 'completed' ? 'completed' : ''}`} />
<span>
{status === 'running'
? '正在后台导出'
: status === 'completed'
? '导出完成'
: '准备就绪'}
</span>
<span className="export-target-summary">{targetPath}</span>
<button type="button" className="export-reset-button" onClick={() => setStatus('idle')}>
</button>
<button
type="button"
className="export-primary-button"
disabled={!activeContact || status === 'running'}
onClick={handleStart}
>
{status === 'running' ? '正在导出' : status === 'completed' ? '再次导出' : '开始导出'}
</button>
</footer>
</main>
<aside className={`export-preview-panel ${status !== 'idle' ? `status-${status}` : ''}`}>
{status === 'idle' && (
<>
<div className="export-preview-heading">
<strong></strong>
<span> 20 </span>
</div>
<div className="export-message-preview">
<div className="export-preview-date"></div>
{(preview.length
? preview
: [
{
id: 'empty',
from: 'user',
content: '导出预览将在这里显示',
type: '文字',
datetime: '',
isSender: false
}
]
).map((message) => (
<div
key={message.id}
className={`export-preview-message ${message.isSender ? 'mine' : ''}`}
>
<span className="export-preview-avatar">
{message.img || (message.isSender && selfInfo?.avatar) ? (
<img src={message.isSender ? selfInfo?.avatar : message.img} alt="" />
) : (
(message.isSender ? '我' : message.name || '友').slice(0, 1)
)}
</span>
<span className="export-preview-bubble">
<small>
{message.name || (message.isSender ? '我' : '联系人')} ·{' '}
{formatPreviewTime(message)}
</small>
{message.content || `[${message.type}]`}
</span>
</div>
))}
</div>
<div className="export-preview-stats">
<span>
<strong></strong>
</span>
<span>
<strong></strong>
</span>
<span>
<strong></strong>
</span>
</div>
</>
)}
{status === 'running' && (
<div className="export-job-state">
<h2></h2>
<p></p>
<ol>
<li className="done"></li>
<li className="current">
{progress?.phase === 'writing' ? '生成档案' : '分批读取聊天记录'}
</li>
<li></li>
<li></li>
<li></li>
</ol>
<div className="export-progress-bar" aria-label="导出进度">
<span style={{ width: `${progress?.percent ?? 0}%` }} />
</div>
<strong>
{progress?.phase === 'writing'
? `正在写入 ${progress.processed.toLocaleString()} 条消息... ${progress.percent ?? 0}%`
: `正在读取消息... ${progress?.percent ?? 0}%`}
</strong>
<button
type="button"
className="export-cancel-button"
onClick={() => {
void window.api.cancelExport(jobId)
setStatus('idle')
}}
>
</button>
</div>
)}
{status === 'completed' && (
<div className="export-job-state completed">
<div className="export-success-icon"></div>
<h2></h2>
<p></p>
<div className="export-complete-summary">
<span>
<strong>{progress?.processed.toLocaleString() || '已完成'}</strong>
</span>
<span>
<strong></strong>
</span>
<span>
<strong></strong>
</span>
</div>
<button
type="button"
className="export-primary-button"
onClick={() =>
progress?.outputPath && void window.api.revealExport(progress.outputPath)
}
>
</button>
<button
type="button"
className="export-open-folder-button"
onClick={() =>
progress?.outputPath && void window.api.revealExport(progress.outputPath)
}
>
</button>
</div>
)}
</aside>
</div>
)
}