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,8 +3829,7 @@ function ExportPage() {
const toggleTaskPerfDetail = useCallback((taskId: string) => {
setExpandedPerfTaskId(prev => (prev === taskId ? null : taskId))
}, [])
const contactsListRows = useMemo(() => (
filteredContacts.map((contact) => {
const renderContactRow = useCallback((_: number, contact: ContactInfo) => {
const matchedSession = sessionRowByUsername.get(contact.username)
const canExport = Boolean(matchedSession?.hasSession)
const checked = canExport && selectedSessions.has(contact.username)
@@ -3925,10 +3845,7 @@ function ExportPage() {
? displayedMessageCount.toLocaleString('zh-CN')
: (isLoadingSessionCounts ? '统计中…' : '--')
return (
<div
key={contact.username}
className={`contact-row ${checked ? 'selected' : ''}`}
>
<div className={`contact-row ${checked ? 'selected' : ''}`}>
<div className="contact-item">
<div className="row-select-cell">
<button
@@ -4003,9 +3920,7 @@ function ExportPage() {
</div>
</div>
)
})
), [
filteredContacts,
}, [
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,6 +4108,9 @@ function ExportPage() {
/>
</div>
<div className="session-table-section">
<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')}>
@@ -4229,50 +4155,7 @@ function ExportPage() {
</div>
)}
<div className="session-table-layout">
<div className="table-wrap">
{contactsList.length === 0 && contactsLoadIssue ? (
<div className="load-issue-state">
<div className="issue-card">
<div className="issue-title">
<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>
) : isContactsListLoading && contactsList.length === 0 ? (
<div className="loading-state">
<Loader2 size={32} className="spin" />
<span>...</span>
</div>
) : filteredContacts.length === 0 ? (
<div className="empty-state">
<span></span>
</div>
) : (
{hasFilteredContacts && (
<>
<div className="contacts-selection-toolbar">
<button
@@ -4320,13 +4203,66 @@ function ExportPage() {
<span className="contacts-list-header-count"></span>
<span className="contacts-list-header-actions"></span>
</div>
<div className="contacts-list">
{contactsListRows}
</div>
</>
)}
</div>
{contactsList.length === 0 && contactsLoadIssue ? (
<div className="load-issue-state">
<div className="issue-card">
<div className="issue-title">
<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>
) : isContactsListLoading && contactsList.length === 0 ? (
<div className="loading-state">
<Loader2 size={32} className="spin" />
<span>...</span>
</div>
) : !hasFilteredContacts ? (
<div className="empty-state">
<span></span>
</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>
{showSessionDetailPanel && (
<div
className="export-session-detail-overlay"
@@ -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()}>