feat(export): add session detail sidebar entry

This commit is contained in:
tisonhuang
2026-03-02 13:46:04 +08:00
parent 204baa52ab
commit b5507b9f5d
2 changed files with 759 additions and 114 deletions

View File

@@ -569,6 +569,18 @@
color: var(--text-secondary); color: var(--text-secondary);
} }
.session-table-layout {
display: flex;
flex: 1;
min-height: 0;
gap: 10px;
.table-wrap {
flex: 1;
min-width: 0;
}
}
.table-wrap { .table-wrap {
overflow: hidden; overflow: hidden;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
@@ -936,6 +948,35 @@
align-items: flex-end; align-items: flex-end;
gap: 4px; gap: 4px;
.row-action-main {
display: inline-flex;
align-items: center;
gap: 6px;
}
.row-detail-btn {
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 7px 10px;
background: var(--bg-secondary);
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
white-space: nowrap;
&:hover {
border-color: var(--text-tertiary);
color: var(--text-primary);
background: var(--bg-hover);
}
&.active {
border-color: var(--primary);
color: var(--primary);
background: rgba(var(--primary-rgb), 0.12);
}
}
.row-export-btn { .row-export-btn {
border: none; border: none;
border-radius: 8px; border-radius: 8px;
@@ -974,6 +1015,179 @@
} }
} }
.export-session-detail-panel {
width: 300px;
min-width: 300px;
border: 1px solid var(--border-color);
border-radius: 10px;
background: var(--card-bg);
display: flex;
flex-direction: column;
overflow: hidden;
.detail-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px;
border-bottom: 1px solid var(--border-color);
h4 {
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.close-btn {
border: none;
background: transparent;
color: var(--text-secondary);
width: 26px;
height: 26px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
}
}
.detail-loading,
.detail-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
color: var(--text-secondary);
font-size: 13px;
padding: 14px;
}
.detail-content {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 14px;
}
.detail-section {
margin-bottom: 18px;
&:last-child {
margin-bottom: 0;
}
.section-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 10px;
text-transform: uppercase;
letter-spacing: 0.4px;
}
}
.detail-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0;
border-bottom: 1px solid var(--border-color);
font-size: 13px;
&:last-child {
border-bottom: none;
}
.label {
color: var(--text-secondary);
flex-shrink: 0;
}
.value {
flex: 1;
text-align: right;
color: var(--text-primary);
word-break: break-all;
user-select: text;
&.highlight {
color: var(--primary);
font-weight: 600;
}
}
.copy-btn {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
border: none;
border-radius: 4px;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.15s, color 0.15s, background 0.15s;
&:hover {
background: var(--bg-secondary);
color: var(--text-primary);
}
}
&:hover .copy-btn {
opacity: 1;
}
}
.table-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.detail-table-placeholder {
padding: 10px 12px;
border-radius: 8px;
background: var(--bg-secondary);
font-size: 12px;
color: var(--text-secondary);
}
.table-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-radius: 8px;
background: var(--bg-secondary);
font-size: 12px;
.db-name {
color: var(--text-primary);
font-weight: 500;
}
.table-count {
color: var(--text-secondary);
}
}
}
.table-state { .table-state {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1401,6 +1615,16 @@
.media-check-grid { .media-check-grid {
grid-template-columns: repeat(2, minmax(120px, 1fr)); grid-template-columns: repeat(2, minmax(120px, 1fr));
} }
.session-table-layout.with-detail {
flex-direction: column;
}
.export-session-detail-panel {
width: 100%;
min-width: 0;
max-height: 360px;
}
} }
@media (max-width: 720px) { @media (max-width: 720px) {
@@ -1421,4 +1645,8 @@
.date-range-row { .date-range-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.export-session-detail-panel {
max-height: 320px;
}
} }

View File

@@ -3,16 +3,22 @@ import { useLocation } from 'react-router-dom'
import { TableVirtuoso } from 'react-virtuoso' import { TableVirtuoso } from 'react-virtuoso'
import { import {
Aperture, Aperture,
Calendar,
Check,
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
CheckSquare, CheckSquare,
Copy,
Database,
Download, Download,
ExternalLink, ExternalLink,
FolderOpen, FolderOpen,
Hash,
Image as ImageIcon, Image as ImageIcon,
Loader2, Loader2,
AlertTriangle, AlertTriangle,
ClipboardList, ClipboardList,
MessageSquare,
MessageSquareText, MessageSquareText,
Mic, Mic,
RefreshCw, RefreshCw,
@@ -169,6 +175,15 @@ const formatAbsoluteDate = (timestamp: number): string => {
return `${y}-${m}-${day}` 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 => { const formatRecentExportTime = (timestamp?: number, now = Date.now()): string => {
if (!timestamp) return '' if (!timestamp) return ''
const diff = Math.max(0, now - timestamp) const diff = Math.max(0, now - timestamp)
@@ -270,6 +285,28 @@ interface ContactsLoadIssue {
elapsedMs: 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> => { const withTimeout = async <T,>(promise: Promise<T>, timeoutMs: number): Promise<T | null> => {
let timer: ReturnType<typeof setTimeout> | null = null let timer: ReturnType<typeof setTimeout> | null = null
try { try {
@@ -536,6 +573,11 @@ function ExportPage() {
total: 0, total: 0,
running: false 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 [exportFolder, setExportFolder] = useState('')
const [writeLayout, setWriteLayout] = useState<configService.ExportWriteLayout>('A') const [writeLayout, setWriteLayout] = useState<configService.ExportWriteLayout>('A')
@@ -598,6 +640,7 @@ function ExportPage() {
const contactsLoadTimeoutMsRef = useRef(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS) const contactsLoadTimeoutMsRef = useRef(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS)
const contactsAvatarCacheRef = useRef<Record<string, configService.ContactsAvatarCacheEntry>>({}) const contactsAvatarCacheRef = useRef<Record<string, configService.ContactsAvatarCacheEntry>>({})
const contactsListRef = useRef<HTMLDivElement>(null) const contactsListRef = useRef<HTMLDivElement>(null)
const detailRequestSeqRef = useRef(0)
const ensureExportCacheScope = useCallback(async (): Promise<string> => { const ensureExportCacheScope = useCallback(async (): Promise<string> => {
if (exportCacheScopeReadyRef.current) { if (exportCacheScopeReadyRef.current) {
@@ -1913,6 +1956,163 @@ function ExportPage() {
return map return map
}, [sessions]) }, [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])
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(() => { const contactsUpdatedAtLabel = useMemo(() => {
if (!contactsUpdatedAt) return '' if (!contactsUpdatedAt) return ''
return new Date(contactsUpdatedAt).toLocaleString() return new Date(contactsUpdatedAt).toLocaleString()
@@ -2044,12 +2244,21 @@ function ExportPage() {
} }
const renderActionCell = (session: SessionRow) => { const renderActionCell = (session: SessionRow) => {
const isDetailActive = showSessionDetailPanel && sessionDetail?.wxid === session.username
if (!session.hasSession) { if (!session.hasSession) {
return ( return (
<div className="row-action-cell"> <div className="row-action-cell">
<button className="row-export-btn no-session" disabled> <div className="row-action-main">
<button
</button> className={`row-detail-btn ${isDetailActive ? 'active' : ''}`}
onClick={() => openSessionDetail(session.username)}
>
</button>
<button className="row-export-btn no-session" disabled>
</button>
</div>
</div> </div>
) )
} }
@@ -2060,18 +2269,26 @@ function ExportPage() {
return ( return (
<div className="row-action-cell"> <div className="row-action-cell">
<button <div className="row-action-main">
className={`row-export-btn ${isRunning ? 'running' : ''}`} <button
disabled={isRunning} className={`row-detail-btn ${isDetailActive ? 'active' : ''}`}
onClick={() => openSingleExport(session)} onClick={() => openSessionDetail(session.username)}
> >
{isRunning ? (
<> </button>
<Loader2 size={14} className="spin" /> <button
className={`row-export-btn ${isRunning ? 'running' : ''}`}
</> disabled={isRunning}
) : isQueued ? '排队中' : '导出'} onClick={() => openSingleExport(session)}
</button> >
{isRunning ? (
<>
<Loader2 size={14} className="spin" />
</>
) : isQueued ? '排队中' : '导出'}
</button>
</div>
{recent && <span className="row-export-time">{recent}</span>} {recent && <span className="row-export-time">{recent}</span>}
</div> </div>
) )
@@ -2364,110 +2581,310 @@ function ExportPage() {
</div> </div>
)} )}
<div className="table-wrap"> <div className={`session-table-layout ${showSessionDetailPanel ? 'with-detail' : ''}`}>
{contactsList.length === 0 && contactsLoadIssue ? ( <div className="table-wrap">
<div className="load-issue-state"> {contactsList.length === 0 && contactsLoadIssue ? (
<div className="issue-card"> <div className="load-issue-state">
<div className="issue-title"> <div className="issue-card">
<AlertTriangle size={18} /> <div className="issue-title">
<span>{contactsLoadIssue.title}</span> <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>2contact.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>
<p className="issue-message">{contactsLoadIssue.message}</p>
<p className="issue-reason">{contactsLoadIssue.reason}</p>
<ul className="issue-hints">
<li>1</li>
<li>2contact.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>
</div> ) : isContactsListLoading && contactsList.length === 0 ? (
) : isContactsListLoading && contactsList.length === 0 ? ( <div className="loading-state">
<div className="loading-state"> <Loader2 size={32} className="spin" />
<Loader2 size={32} className="spin" /> <span>...</span>
<span>...</span> </div>
</div> ) : filteredContacts.length === 0 ? (
) : filteredContacts.length === 0 ? ( <div className="empty-state">
<div className="empty-state"> <span></span>
<span></span> </div>
</div> ) : (
) : ( <div className="contacts-list" ref={contactsListRef} onScroll={onContactsListScroll}>
<div className="contacts-list" ref={contactsListRef} onScroll={onContactsListScroll}> <div
<div className="contacts-list-virtual"
className="contacts-list-virtual" style={{ height: filteredContacts.length * CONTACTS_LIST_VIRTUAL_ROW_HEIGHT }}
style={{ height: filteredContacts.length * CONTACTS_LIST_VIRTUAL_ROW_HEIGHT }} >
> {visibleContacts.map((contact, idx) => {
{visibleContacts.map((contact, idx) => { const absoluteIndex = contactStartIndex + idx
const absoluteIndex = contactStartIndex + idx const top = absoluteIndex * CONTACTS_LIST_VIRTUAL_ROW_HEIGHT
const top = absoluteIndex * CONTACTS_LIST_VIRTUAL_ROW_HEIGHT const matchedSession = sessionRowByUsername.get(contact.username)
const matchedSession = sessionRowByUsername.get(contact.username) const canExport = Boolean(matchedSession?.hasSession)
const canExport = Boolean(matchedSession?.hasSession) const isRunning = canExport && runningSessionIds.has(contact.username)
const isRunning = canExport && runningSessionIds.has(contact.username) const isQueued = canExport && queuedSessionIds.has(contact.username)
const isQueued = canExport && queuedSessionIds.has(contact.username) const recent = canExport ? formatRecentExportTime(lastExportBySession[contact.username], nowTick) : ''
const recent = canExport ? formatRecentExportTime(lastExportBySession[contact.username], nowTick) : '' return (
return ( <div
<div key={contact.username}
key={contact.username} className="contact-row"
className="contact-row" style={{ transform: `translateY(${top}px)` }}
style={{ transform: `translateY(${top}px)` }} >
> <div className="contact-item">
<div className="contact-item"> <div className="contact-avatar">
<div className="contact-avatar"> {contact.avatarUrl ? (
{contact.avatarUrl ? ( <img src={contact.avatarUrl} alt="" loading="lazy" />
<img src={contact.avatarUrl} alt="" loading="lazy" /> ) : (
) : ( <span>{getAvatarLetter(contact.displayName)}</span>
<span>{getAvatarLetter(contact.displayName)}</span> )}
)} </div>
</div> <div className="contact-info">
<div className="contact-info"> <div className="contact-name">{contact.displayName}</div>
<div className="contact-name">{contact.displayName}</div> <div className="contact-remark">{contact.username}</div>
<div className="contact-remark">{contact.username}</div> </div>
</div> <div className={`contact-type ${contact.type}`}>
<div className={`contact-type ${contact.type}`}> <span>{getContactTypeName(contact.type)}</span>
<span>{getContactTypeName(contact.type)}</span> </div>
</div> <div className="row-action-cell">
<div className="row-action-cell"> <div className="row-action-main">
<button <button
className={`row-export-btn ${isRunning ? 'running' : ''} ${!canExport ? 'no-session' : ''}`} className={`row-detail-btn ${showSessionDetailPanel && sessionDetail?.wxid === contact.username ? 'active' : ''}`}
disabled={!canExport || isRunning} onClick={() => openSessionDetail(contact.username)}
onClick={() => { >
if (!matchedSession || !matchedSession.hasSession) return
openSingleExport({ </button>
...matchedSession, <button
displayName: contact.displayName || matchedSession.displayName || matchedSession.username className={`row-export-btn ${isRunning ? 'running' : ''} ${!canExport ? 'no-session' : ''}`}
}) disabled={!canExport || isRunning}
}} onClick={() => {
> if (!matchedSession || !matchedSession.hasSession) return
{isRunning ? ( openSingleExport({
<> ...matchedSession,
<Loader2 size={14} className="spin" /> displayName: contact.displayName || matchedSession.displayName || matchedSession.username
})
</> }}
) : !canExport ? '暂无会话' : isQueued ? '排队中' : '导出'} >
</button> {isRunning ? (
{recent && <span className="row-export-time">{recent}</span>} <>
<Loader2 size={14} className="spin" />
</>
) : !canExport ? '暂无会话' : isQueued ? '排队中' : '导出'}
</button>
</div>
{recent && <span className="row-export-time">{recent}</span>}
</div>
</div> </div>
</div> </div>
</div> )
) })}
})} </div>
</div> </div>
</div> )}
</div>
{showSessionDetailPanel && (
<aside className="export-session-detail-panel">
<div className="detail-header">
<h4></h4>
<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> </div>