From b5507b9f5d98bfd68eac7c649502738071369eab Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Mon, 2 Mar 2026 13:46:04 +0800 Subject: [PATCH] feat(export): add session detail sidebar entry --- src/pages/ExportPage.scss | 228 ++++++++++++++ src/pages/ExportPage.tsx | 645 +++++++++++++++++++++++++++++++------- 2 files changed, 759 insertions(+), 114 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index e13ecfc..eaad4bc 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -569,6 +569,18 @@ 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 { overflow: hidden; border: 1px solid var(--border-color); @@ -936,6 +948,35 @@ align-items: flex-end; 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 { border: none; 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 { display: flex; align-items: center; @@ -1401,6 +1615,16 @@ .media-check-grid { 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) { @@ -1421,4 +1645,8 @@ .date-range-row { grid-template-columns: 1fr; } + + .export-session-detail-panel { + max-height: 320px; + } } diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 10a11c0..5051bea 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -3,16 +3,22 @@ import { useLocation } from 'react-router-dom' import { TableVirtuoso } from 'react-virtuoso' import { Aperture, + Calendar, + Check, ChevronDown, ChevronRight, CheckSquare, + Copy, + Database, Download, ExternalLink, FolderOpen, + Hash, Image as ImageIcon, Loader2, AlertTriangle, ClipboardList, + MessageSquare, MessageSquareText, Mic, RefreshCw, @@ -169,6 +175,15 @@ const formatAbsoluteDate = (timestamp: number): string => { return `${y}-${m}-${day}` } +const formatYmdDateFromSeconds = (timestamp?: number): string => { + if (!timestamp || !Number.isFinite(timestamp)) return '—' + const d = new Date(timestamp * 1000) + const y = d.getFullYear() + const m = `${d.getMonth() + 1}`.padStart(2, '0') + const day = `${d.getDate()}`.padStart(2, '0') + return `${y}-${m}-${day}` +} + const formatRecentExportTime = (timestamp?: number, now = Date.now()): string => { if (!timestamp) return '' const diff = Math.max(0, now - timestamp) @@ -270,6 +285,28 @@ interface ContactsLoadIssue { 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 (promise: Promise, timeoutMs: number): Promise => { let timer: ReturnType | null = null try { @@ -536,6 +573,11 @@ function ExportPage() { total: 0, running: false }) + const [showSessionDetailPanel, setShowSessionDetailPanel] = useState(false) + const [sessionDetail, setSessionDetail] = useState(null) + const [isLoadingSessionDetail, setIsLoadingSessionDetail] = useState(false) + const [isLoadingSessionDetailExtra, setIsLoadingSessionDetailExtra] = useState(false) + const [copiedDetailField, setCopiedDetailField] = useState(null) const [exportFolder, setExportFolder] = useState('') const [writeLayout, setWriteLayout] = useState('A') @@ -598,6 +640,7 @@ function ExportPage() { const contactsLoadTimeoutMsRef = useRef(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS) const contactsAvatarCacheRef = useRef>({}) const contactsListRef = useRef(null) + const detailRequestSeqRef = useRef(0) const ensureExportCacheScope = useCallback(async (): Promise => { if (exportCacheScopeReadyRef.current) { @@ -1913,6 +1956,163 @@ function ExportPage() { return map }, [sessions]) + const contactByUsername = useMemo(() => { + const map = new Map() + 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(() => { if (!contactsUpdatedAt) return '' return new Date(contactsUpdatedAt).toLocaleString() @@ -2044,12 +2244,21 @@ function ExportPage() { } const renderActionCell = (session: SessionRow) => { + const isDetailActive = showSessionDetailPanel && sessionDetail?.wxid === session.username if (!session.hasSession) { return (
- +
+ + +
) } @@ -2060,18 +2269,26 @@ function ExportPage() { return (
- +
+ + +
{recent && {recent}}
) @@ -2364,110 +2581,310 @@ function ExportPage() { )} -
- {contactsList.length === 0 && contactsLoadIssue ? ( -
-
-
- - {contactsLoadIssue.title} +
+
+ {contactsList.length === 0 && contactsLoadIssue ? ( +
+
+
+ + {contactsLoadIssue.title} +
+

{contactsLoadIssue.message}

+

{contactsLoadIssue.reason}

+
    +
  • 可能原因1:数据库当前仍在执行高开销查询(例如导出页后台统计)。
  • +
  • 可能原因2:contact.db 数据量较大,首次查询时间过长。
  • +
  • 可能原因3:数据库连接状态异常或 IPC 调用卡住。
  • +
+
+ + + +
+ {showContactsDiagnostics && ( +
{contactsDiagnosticsText}
+ )}
-

{contactsLoadIssue.message}

-

{contactsLoadIssue.reason}

-
    -
  • 可能原因1:数据库当前仍在执行高开销查询(例如导出页后台统计)。
  • -
  • 可能原因2:contact.db 数据量较大,首次查询时间过长。
  • -
  • 可能原因3:数据库连接状态异常或 IPC 调用卡住。
  • -
-
- - - -
- {showContactsDiagnostics && ( -
{contactsDiagnosticsText}
- )}
-
- ) : isContactsListLoading && contactsList.length === 0 ? ( -
- - 联系人加载中... -
- ) : filteredContacts.length === 0 ? ( -
- 暂无联系人 -
- ) : ( -
-
- {visibleContacts.map((contact, idx) => { - const absoluteIndex = contactStartIndex + idx - const top = absoluteIndex * CONTACTS_LIST_VIRTUAL_ROW_HEIGHT - const matchedSession = sessionRowByUsername.get(contact.username) - const canExport = Boolean(matchedSession?.hasSession) - const isRunning = canExport && runningSessionIds.has(contact.username) - const isQueued = canExport && queuedSessionIds.has(contact.username) - const recent = canExport ? formatRecentExportTime(lastExportBySession[contact.username], nowTick) : '' - return ( -
-
-
- {contact.avatarUrl ? ( - - ) : ( - {getAvatarLetter(contact.displayName)} - )} -
-
-
{contact.displayName}
-
{contact.username}
-
-
- {getContactTypeName(contact.type)} -
-
- - {recent && {recent}} + ) : isContactsListLoading && contactsList.length === 0 ? ( +
+ + 联系人加载中... +
+ ) : filteredContacts.length === 0 ? ( +
+ 暂无联系人 +
+ ) : ( +
+
+ {visibleContacts.map((contact, idx) => { + const absoluteIndex = contactStartIndex + idx + const top = absoluteIndex * CONTACTS_LIST_VIRTUAL_ROW_HEIGHT + const matchedSession = sessionRowByUsername.get(contact.username) + const canExport = Boolean(matchedSession?.hasSession) + const isRunning = canExport && runningSessionIds.has(contact.username) + const isQueued = canExport && queuedSessionIds.has(contact.username) + const recent = canExport ? formatRecentExportTime(lastExportBySession[contact.username], nowTick) : '' + return ( +
+
+
+ {contact.avatarUrl ? ( + + ) : ( + {getAvatarLetter(contact.displayName)} + )} +
+
+
{contact.displayName}
+
{contact.username}
+
+
+ {getContactTypeName(contact.type)} +
+
+
+ + +
+ {recent && {recent}} +
-
- ) - })} + ) + })} +
-
+ )} +
+ + {showSessionDetailPanel && ( + )}