fix(export): restore virtualized contacts list and sticky controls

This commit is contained in:
tisonhuang
2026-03-05 12:18:28 +08:00
parent b436bb63da
commit 6a85b82643
2 changed files with 301 additions and 300 deletions

View File

@@ -859,6 +859,7 @@
display: inline-flex;
align-items: center;
gap: 6px;
margin: 8px 12px 0;
padding: 6px 10px;
border-radius: 999px;
background: rgba(var(--primary-rgb), 0.1);
@@ -874,6 +875,9 @@
align-items: flex-start;
gap: 12px;
flex-wrap: wrap;
padding: 10px 12px;
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 85%, transparent);
background: color-mix(in srgb, var(--bg-primary) 82%, var(--bg-secondary));
}
.table-cache-meta {
@@ -990,17 +994,24 @@
--contacts-select-col-width: 34px;
--contacts-message-col-width: 120px;
--contacts-action-col-width: 280px;
overflow: visible;
overflow: hidden;
border: 1px solid var(--border-color);
border-radius: 10px;
min-height: 320px;
height: auto;
flex: 0 0 auto;
flex: 1;
display: flex;
flex-direction: column;
}
.table-wrap {
.session-table-sticky {
position: sticky;
top: 0;
z-index: 20;
background: var(--card-bg);
}
.loading-state,
.empty-state {
flex: 1;
@@ -1151,12 +1162,27 @@
}
.contacts-list {
flex: 0 0 auto;
min-height: auto;
overflow: visible;
flex: 1;
min-height: 360px;
height: clamp(360px, 62vh, 760px);
overflow: hidden;
padding: 0 12px 12px;
}
.contacts-virtuoso {
height: 100%;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: var(--text-tertiary);
border-radius: 3px;
opacity: 0.3;
}
}
.contacts-selection-toolbar {
display: flex;
align-items: center;
@@ -1381,6 +1407,28 @@
}
}
.back-to-top-btn {
position: fixed;
top: 14px;
right: 18px;
z-index: 120;
border: 1px solid rgba(var(--primary-rgb), 0.4);
border-radius: 999px;
background: color-mix(in srgb, var(--bg-primary) 90%, var(--bg-secondary));
color: var(--primary);
font-size: 12px;
font-weight: 600;
padding: 6px 12px;
cursor: pointer;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.18);
transition: transform 0.12s ease, box-shadow 0.12s ease;
&:hover {
transform: translateY(-1px);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.2);
}
}
.table-virtuoso {
height: 100%;
}
@@ -2354,7 +2402,7 @@
overflow-y: auto;
border-radius: 12px;
border: 1px solid var(--border-color);
background: var(--card-bg);
background: var(--bg-secondary-solid, var(--bg-primary));
padding: 12px;
display: flex;
flex-direction: column;
@@ -2662,6 +2710,8 @@
.table-wrap .contacts-list {
padding: 0 10px 10px;
min-height: 300px;
height: min(56vh, 560px);
}
.table-wrap .row-message-count {
@@ -2726,4 +2776,9 @@
.export-session-detail-panel {
width: calc(100vw - 12px);
}
.back-to-top-btn {
top: 10px;
right: 10px;
}
}

View File

@@ -1,6 +1,6 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useLocation } from 'react-router-dom'
import { TableVirtuoso } from 'react-virtuoso'
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
import { createPortal } from 'react-dom'
import {
Aperture,
@@ -1274,6 +1274,8 @@ function ExportPage() {
})
const [hasSeededSnsStats, setHasSeededSnsStats] = useState(false)
const [nowTick, setNowTick] = useState(Date.now())
const [isContactsListAtTop, setIsContactsListAtTop] = useState(true)
const [isPageScrolled, setIsPageScrolled] = useState(false)
const tabCounts = useContactTypeCountsStore(state => state.tabCounts)
const isSharedTabCountsLoading = useContactTypeCountsStore(state => state.isLoading)
const isSharedTabCountsReady = useContactTypeCountsStore(state => state.isReady)
@@ -1293,6 +1295,7 @@ function ExportPage() {
const contactsLoadTimeoutTimerRef = useRef<number | null>(null)
const contactsLoadTimeoutMsRef = useRef(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS)
const contactsAvatarCacheRef = useRef<Record<string, configService.ContactsAvatarCacheEntry>>({})
const contactsVirtuosoRef = useRef<VirtuosoHandle | null>(null)
const detailRequestSeqRef = useRef(0)
const sessionsRef = useRef<SessionRow[]>([])
const contactsListSizeRef = useRef(0)
@@ -1592,6 +1595,19 @@ function ExportPage() {
return () => clearInterval(timer)
}, [isExportRoute])
useEffect(() => {
if (!isExportRoute) {
setIsPageScrolled(false)
return
}
const onWindowScroll = () => {
setIsPageScrolled(window.scrollY > 160)
}
onWindowScroll()
window.addEventListener('scroll', onWindowScroll, { passive: true })
return () => window.removeEventListener('scroll', onWindowScroll)
}, [isExportRoute])
useEffect(() => {
if (!isTaskCenterOpen || !expandedPerfTaskId) return
const target = tasks.find(task => task.id === expandedPerfTaskId)
@@ -3331,6 +3347,11 @@ function ExportPage() {
return indexedContacts.map(item => item.contact)
}, [contactsList, activeTab, searchKeyword, sessionMessageCounts, sessionRowByUsername])
useEffect(() => {
contactsVirtuosoRef.current?.scrollToIndex({ index: 0, align: 'start' })
setIsContactsListAtTop(true)
}, [activeTab, searchKeyword])
const contactByUsername = useMemo(() => {
const map = new Map<string, ContactInfo>()
for (const contact of contactsList) {
@@ -3739,107 +3760,6 @@ function ExportPage() {
return sessions.reduce((count, session) => (session.avatarUrl ? count + 1 : count), 0)
}, [sessions])
const renderSessionName = (session: SessionRow) => {
return (
<div className="session-cell">
<div className="session-avatar">
{session.avatarUrl ? <img src={session.avatarUrl} alt="" /> : <span>{getAvatarLetter(session.displayName || session.username)}</span>}
</div>
<div className="session-meta">
<div className="session-name">{session.displayName || session.username}</div>
<div className="session-id">
{session.wechatId || session.username}
{!session.hasSession ? ' · 暂无会话记录' : ''}
</div>
</div>
</div>
)
}
const renderActionCell = (session: SessionRow) => {
const isDetailActive = showSessionDetailPanel && sessionDetail?.wxid === session.username
if (!session.hasSession) {
return (
<div className="row-action-cell">
<div className="row-action-main">
<button
className={`row-detail-btn ${isDetailActive ? 'active' : ''}`}
onClick={() => openSessionDetail(session.username)}
>
</button>
<button className="row-export-btn no-session" disabled>
</button>
</div>
</div>
)
}
const isRunning = runningSessionIds.has(session.username)
const isQueued = queuedSessionIds.has(session.username)
const recent = formatRecentExportTime(lastExportBySession[session.username], nowTick)
return (
<div className="row-action-cell">
<div className="row-action-main">
<button
className={`row-detail-btn ${isDetailActive ? 'active' : ''}`}
onClick={() => openSessionDetail(session.username)}
>
</button>
<button
className={`row-export-btn ${isRunning ? 'running' : ''}`}
disabled={isRunning}
onClick={() => openSingleExport(session)}
>
{isRunning ? (
<>
<Loader2 size={14} className="spin" />
</>
) : isQueued ? '排队中' : '导出'}
</button>
</div>
{recent && <span className="row-export-time">{recent}</span>}
</div>
)
}
const renderTableHeader = () => {
return (
<tr>
<th className="sticky-col"></th>
<th>//</th>
<th className="sticky-right"></th>
</tr>
)
}
const renderRowCells = (session: SessionRow) => {
const selectable = session.hasSession
const checked = selectable && selectedSessions.has(session.username)
return (
<>
<td className="sticky-col">
<button
className={`select-icon-btn ${checked ? 'checked' : ''}`}
disabled={!selectable}
onClick={() => toggleSelectSession(session.username)}
title={selectable ? (checked ? '取消选择' : '选择会话') : '该联系人暂无会话记录'}
>
{checked ? <CheckSquare size={16} /> : <Square size={16} />}
</button>
</td>
<td>{renderSessionName(session)}</td>
<td className="sticky-right">{renderActionCell(session)}</td>
</>
)
}
const visibleSelectedCount = useMemo(() => {
const visibleSet = new Set(
filteredContacts
@@ -3900,7 +3820,8 @@ function ExportPage() {
const isSnsCardStatsLoading = !hasSeededSnsStats
const taskRunningCount = tasks.filter(task => task.status === 'running').length
const taskQueuedCount = tasks.filter(task => task.status === 'queued').length
const showInitialSkeleton = isLoading && sessions.length === 0
const hasFilteredContacts = filteredContacts.length > 0
const showBackToTop = isPageScrolled || !isContactsListAtTop
const closeTaskCenter = useCallback(() => {
setIsTaskCenterOpen(false)
setExpandedPerfTaskId(null)
@@ -3908,104 +3829,98 @@ function ExportPage() {
const toggleTaskPerfDetail = useCallback((taskId: string) => {
setExpandedPerfTaskId(prev => (prev === taskId ? null : taskId))
}, [])
const contactsListRows = useMemo(() => (
filteredContacts.map((contact) => {
const matchedSession = sessionRowByUsername.get(contact.username)
const canExport = Boolean(matchedSession?.hasSession)
const checked = canExport && selectedSessions.has(contact.username)
const isRunning = canExport && runningSessionIds.has(contact.username)
const isQueued = canExport && queuedSessionIds.has(contact.username)
const recent = canExport ? formatRecentExportTime(lastExportBySession[contact.username], nowTick) : ''
const countedMessages = normalizeMessageCount(sessionMessageCounts[contact.username])
const hintedMessages = normalizeMessageCount(matchedSession?.messageCountHint)
const displayedMessageCount = countedMessages ?? hintedMessages
const messageCountLabel = !canExport
? '--'
: typeof displayedMessageCount === 'number'
? displayedMessageCount.toLocaleString('zh-CN')
: (isLoadingSessionCounts ? '统计中…' : '--')
return (
<div
key={contact.username}
className={`contact-row ${checked ? 'selected' : ''}`}
>
<div className="contact-item">
<div className="row-select-cell">
<button
className={`select-icon-btn ${checked ? 'checked' : ''}`}
type="button"
disabled={!canExport}
onClick={() => toggleSelectSession(contact.username)}
title={canExport ? (checked ? '取消选择' : '选择会话') : '该联系人暂无会话记录'}
>
{checked ? <CheckSquare size={16} /> : <Square size={16} />}
</button>
</div>
<div className="contact-avatar">
{contact.avatarUrl ? (
<img src={contact.avatarUrl} alt="" loading="lazy" />
) : (
<span>{getAvatarLetter(contact.displayName)}</span>
)}
</div>
<div className="contact-info">
<div className="contact-name">{contact.displayName}</div>
<div className="contact-remark">{contact.username}</div>
</div>
<div className="row-message-count">
<div className="row-message-stats">
<strong className={`row-message-count-value ${typeof displayedMessageCount === 'number' ? '' : 'muted'}`}>
{messageCountLabel}
</strong>
</div>
</div>
<div className="row-action-cell">
<div className="row-action-main">
<button
className="row-open-chat-btn"
disabled={!canExport}
title={canExport ? '在新窗口打开该会话' : '该联系人暂无会话记录'}
onClick={() => {
if (!canExport) return
void window.electronAPI.window.openSessionChatWindow(contact.username)
}}
>
<ExternalLink size={13} />
</button>
<button
className={`row-detail-btn ${showSessionDetailPanel && sessionDetail?.wxid === contact.username ? 'active' : ''}`}
onClick={() => openSessionDetail(contact.username)}
>
</button>
<button
className={`row-export-btn ${isRunning ? 'running' : ''} ${!canExport ? 'no-session' : ''}`}
disabled={!canExport || isRunning}
onClick={() => {
if (!matchedSession || !matchedSession.hasSession) return
openSingleExport({
...matchedSession,
displayName: contact.displayName || matchedSession.displayName || matchedSession.username
})
}}
>
{isRunning ? (
<>
<Loader2 size={14} className="spin" />
</>
) : !canExport ? '暂无会话' : isQueued ? '排队中' : '单会话导出'}
</button>
</div>
{recent && <span className="row-export-time">{recent}</span>}
const renderContactRow = useCallback((_: number, contact: ContactInfo) => {
const matchedSession = sessionRowByUsername.get(contact.username)
const canExport = Boolean(matchedSession?.hasSession)
const checked = canExport && selectedSessions.has(contact.username)
const isRunning = canExport && runningSessionIds.has(contact.username)
const isQueued = canExport && queuedSessionIds.has(contact.username)
const recent = canExport ? formatRecentExportTime(lastExportBySession[contact.username], nowTick) : ''
const countedMessages = normalizeMessageCount(sessionMessageCounts[contact.username])
const hintedMessages = normalizeMessageCount(matchedSession?.messageCountHint)
const displayedMessageCount = countedMessages ?? hintedMessages
const messageCountLabel = !canExport
? '--'
: typeof displayedMessageCount === 'number'
? displayedMessageCount.toLocaleString('zh-CN')
: (isLoadingSessionCounts ? '统计中…' : '--')
return (
<div className={`contact-row ${checked ? 'selected' : ''}`}>
<div className="contact-item">
<div className="row-select-cell">
<button
className={`select-icon-btn ${checked ? 'checked' : ''}`}
type="button"
disabled={!canExport}
onClick={() => toggleSelectSession(contact.username)}
title={canExport ? (checked ? '取消选择' : '选择会话') : '该联系人暂无会话记录'}
>
{checked ? <CheckSquare size={16} /> : <Square size={16} />}
</button>
</div>
<div className="contact-avatar">
{contact.avatarUrl ? (
<img src={contact.avatarUrl} alt="" loading="lazy" />
) : (
<span>{getAvatarLetter(contact.displayName)}</span>
)}
</div>
<div className="contact-info">
<div className="contact-name">{contact.displayName}</div>
<div className="contact-remark">{contact.username}</div>
</div>
<div className="row-message-count">
<div className="row-message-stats">
<strong className={`row-message-count-value ${typeof displayedMessageCount === 'number' ? '' : 'muted'}`}>
{messageCountLabel}
</strong>
</div>
</div>
<div className="row-action-cell">
<div className="row-action-main">
<button
className="row-open-chat-btn"
disabled={!canExport}
title={canExport ? '在新窗口打开该会话' : '该联系人暂无会话记录'}
onClick={() => {
if (!canExport) return
void window.electronAPI.window.openSessionChatWindow(contact.username)
}}
>
<ExternalLink size={13} />
</button>
<button
className={`row-detail-btn ${showSessionDetailPanel && sessionDetail?.wxid === contact.username ? 'active' : ''}`}
onClick={() => openSessionDetail(contact.username)}
>
</button>
<button
className={`row-export-btn ${isRunning ? 'running' : ''} ${!canExport ? 'no-session' : ''}`}
disabled={!canExport || isRunning}
onClick={() => {
if (!matchedSession || !matchedSession.hasSession) return
openSingleExport({
...matchedSession,
displayName: contact.displayName || matchedSession.displayName || matchedSession.username
})
}}
>
{isRunning ? (
<>
<Loader2 size={14} className="spin" />
</>
) : !canExport ? '暂无会话' : isQueued ? '排队中' : '单会话导出'}
</button>
</div>
{recent && <span className="row-export-time">{recent}</span>}
</div>
</div>
)
})
), [
filteredContacts,
</div>
)
}, [
isLoadingSessionCounts,
lastExportBySession,
nowTick,
@@ -4020,6 +3935,14 @@ function ExportPage() {
showSessionDetailPanel,
toggleSelectSession
])
const handleBackToTop = useCallback(() => {
contactsVirtuosoRef.current?.scrollToIndex({ index: 0, align: 'start', behavior: 'smooth' })
window.scrollTo({ top: 0, behavior: 'smooth' })
}, [])
useEffect(() => {
if (hasFilteredContacts) return
setIsContactsListAtTop(true)
}, [hasFilteredContacts])
const chooseExportFolder = useCallback(async () => {
const result = await window.electronAPI.dialog.openFile({
title: '选择导出目录',
@@ -4185,52 +4108,105 @@ function ExportPage() {
/>
</div>
<div className="session-table-section">
<div className="table-toolbar">
<div className="table-tabs" role="tablist" aria-label="会话类型">
<button className={`tab-btn ${activeTab === 'private' ? 'active' : ''}`} onClick={() => setActiveTab('private')}>
{isTabCountComputing ? <span className="count-loading"><span className="animated-ellipsis" aria-hidden="true">...</span></span> : tabCounts.private}
</button>
<button className={`tab-btn ${activeTab === 'group' ? 'active' : ''}`} onClick={() => setActiveTab('group')}>
{isTabCountComputing ? <span className="count-loading"><span className="animated-ellipsis" aria-hidden="true">...</span></span> : tabCounts.group}
</button>
<button className={`tab-btn ${activeTab === 'official' ? 'active' : ''}`} onClick={() => setActiveTab('official')}>
{isTabCountComputing ? <span className="count-loading"><span className="animated-ellipsis" aria-hidden="true">...</span></span> : tabCounts.official}
</button>
<button className={`tab-btn ${activeTab === 'former_friend' ? 'active' : ''}`} onClick={() => setActiveTab('former_friend')}>
{isTabCountComputing ? <span className="count-loading"><span className="animated-ellipsis" aria-hidden="true">...</span></span> : tabCounts.former_friend}
</button>
</div>
<div className="toolbar-actions">
<div className="search-input-wrap">
<Search size={14} />
<input
value={searchKeyword}
onChange={(event) => setSearchKeyword(event.target.value)}
placeholder={`搜索${activeTabLabel}联系人...`}
/>
{searchKeyword && (
<button className="clear-search" onClick={() => setSearchKeyword('')}>
<X size={12} />
</button>
)}
</div>
<button className="secondary-btn" onClick={() => void loadContactsList()} disabled={isContactsListLoading}>
<RefreshCw size={14} className={isContactsListLoading ? 'spin' : ''} />
</button>
</div>
</div>
{contactsList.length > 0 && isContactsListLoading && (
<div className="table-stage-hint">
<Loader2 size={14} className="spin" />
</div>
)}
<div className="session-table-layout">
<div className="table-wrap">
<div className="session-table-sticky">
<div className="table-toolbar">
<div className="table-tabs" role="tablist" aria-label="会话类型">
<button className={`tab-btn ${activeTab === 'private' ? 'active' : ''}`} onClick={() => setActiveTab('private')}>
{isTabCountComputing ? <span className="count-loading"><span className="animated-ellipsis" aria-hidden="true">...</span></span> : tabCounts.private}
</button>
<button className={`tab-btn ${activeTab === 'group' ? 'active' : ''}`} onClick={() => setActiveTab('group')}>
{isTabCountComputing ? <span className="count-loading"><span className="animated-ellipsis" aria-hidden="true">...</span></span> : tabCounts.group}
</button>
<button className={`tab-btn ${activeTab === 'official' ? 'active' : ''}`} onClick={() => setActiveTab('official')}>
{isTabCountComputing ? <span className="count-loading"><span className="animated-ellipsis" aria-hidden="true">...</span></span> : tabCounts.official}
</button>
<button className={`tab-btn ${activeTab === 'former_friend' ? 'active' : ''}`} onClick={() => setActiveTab('former_friend')}>
{isTabCountComputing ? <span className="count-loading"><span className="animated-ellipsis" aria-hidden="true">...</span></span> : tabCounts.former_friend}
</button>
</div>
<div className="toolbar-actions">
<div className="search-input-wrap">
<Search size={14} />
<input
value={searchKeyword}
onChange={(event) => setSearchKeyword(event.target.value)}
placeholder={`搜索${activeTabLabel}联系人...`}
/>
{searchKeyword && (
<button className="clear-search" onClick={() => setSearchKeyword('')}>
<X size={12} />
</button>
)}
</div>
<button className="secondary-btn" onClick={() => void loadContactsList()} disabled={isContactsListLoading}>
<RefreshCw size={14} className={isContactsListLoading ? 'spin' : ''} />
</button>
</div>
</div>
{contactsList.length > 0 && isContactsListLoading && (
<div className="table-stage-hint">
<Loader2 size={14} className="spin" />
</div>
)}
{hasFilteredContacts && (
<>
<div className="contacts-selection-toolbar">
<button
className={`select-icon-btn ${isAllVisibleSelected ? 'checked' : ''}`}
type="button"
onClick={toggleSelectAllVisible}
disabled={visibleSelectableCount === 0}
title={isAllVisibleSelected ? '取消全选当前筛选联系人' : '全选当前筛选联系人'}
>
{isAllVisibleSelected ? <CheckSquare size={16} /> : <Square size={16} />}
</button>
<button
className="selection-toggle-btn"
type="button"
onClick={toggleSelectAllVisible}
disabled={visibleSelectableCount === 0}
>
{isAllVisibleSelected ? '取消全选当前筛选' : '全选当前筛选'}
</button>
<span className="selection-summary muted">
{visibleSelectedCount}/{visibleSelectableCount}
</span>
<button
className="selection-clear-btn"
type="button"
onClick={clearSelection}
disabled={selectedCount === 0}
>
</button>
{selectedCount > 0 && (
<button
className="selection-export-btn"
type="button"
onClick={openBatchExport}
>
<span></span>
<span className="selection-export-count">{selectedCount}</span>
</button>
)}
</div>
<div className="contacts-list-header">
<span className="contacts-list-header-select"></span>
<span className="contacts-list-header-main">//</span>
<span className="contacts-list-header-count"></span>
<span className="contacts-list-header-actions"></span>
</div>
</>
)}
</div>
{contactsList.length === 0 && contactsLoadIssue ? (
<div className="load-issue-state">
<div className="issue-card">
@@ -4268,62 +4244,22 @@ function ExportPage() {
<Loader2 size={32} className="spin" />
<span>...</span>
</div>
) : filteredContacts.length === 0 ? (
) : !hasFilteredContacts ? (
<div className="empty-state">
<span></span>
</div>
) : (
<>
<div className="contacts-selection-toolbar">
<button
className={`select-icon-btn ${isAllVisibleSelected ? 'checked' : ''}`}
type="button"
onClick={toggleSelectAllVisible}
disabled={visibleSelectableCount === 0}
title={isAllVisibleSelected ? '取消全选当前筛选联系人' : '全选当前筛选联系人'}
>
{isAllVisibleSelected ? <CheckSquare size={16} /> : <Square size={16} />}
</button>
<button
className="selection-toggle-btn"
type="button"
onClick={toggleSelectAllVisible}
disabled={visibleSelectableCount === 0}
>
{isAllVisibleSelected ? '取消全选当前筛选' : '全选当前筛选'}
</button>
<span className="selection-summary muted">
{visibleSelectedCount}/{visibleSelectableCount}
</span>
<button
className="selection-clear-btn"
type="button"
onClick={clearSelection}
disabled={selectedCount === 0}
>
</button>
{selectedCount > 0 && (
<button
className="selection-export-btn"
type="button"
onClick={openBatchExport}
>
<span></span>
<span className="selection-export-count">{selectedCount}</span>
</button>
)}
</div>
<div className="contacts-list-header">
<span className="contacts-list-header-select"></span>
<span className="contacts-list-header-main">//</span>
<span className="contacts-list-header-count"></span>
<span className="contacts-list-header-actions"></span>
</div>
<div className="contacts-list">
{contactsListRows}
</div>
</>
<div className="contacts-list">
<Virtuoso
ref={contactsVirtuosoRef}
className="contacts-virtuoso"
data={filteredContacts}
computeItemKey={(_, contact) => contact.username}
itemContent={renderContactRow}
atTopStateChange={setIsContactsListAtTop}
overscan={420}
/>
</div>
)}
</div>
@@ -4629,6 +4565,16 @@ function ExportPage() {
</div>
</div>
{showBackToTop && (
<button
type="button"
className="back-to-top-btn"
onClick={handleBackToTop}
>
</button>
)}
{exportDialog.open && createPortal(
<div className="export-dialog-overlay" onClick={closeExportDialog}>
<div className="export-dialog" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>