diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 0fefaea..7be9dfe 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -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; + } } diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index f01f256..d44acec 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -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(null) const contactsLoadTimeoutMsRef = useRef(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS) const contactsAvatarCacheRef = useRef>({}) + const contactsVirtuosoRef = useRef(null) const detailRequestSeqRef = useRef(0) const sessionsRef = useRef([]) 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() 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 ( -
-
- {session.avatarUrl ? : {getAvatarLetter(session.displayName || session.username)}} -
-
-
{session.displayName || session.username}
-
- {session.wechatId || session.username} - {!session.hasSession ? ' · 暂无会话记录' : ''} -
-
-
- ) - } - - const renderActionCell = (session: SessionRow) => { - const isDetailActive = showSessionDetailPanel && sessionDetail?.wxid === session.username - if (!session.hasSession) { - return ( -
-
- - -
-
- ) - } - - const isRunning = runningSessionIds.has(session.username) - const isQueued = queuedSessionIds.has(session.username) - const recent = formatRecentExportTime(lastExportBySession[session.username], nowTick) - - return ( -
-
- - -
- {recent && {recent}} -
- ) - } - - const renderTableHeader = () => { - return ( - - 选择 - 联系人(头像/名称/微信号) - 操作 - - ) - } - - const renderRowCells = (session: SessionRow) => { - const selectable = session.hasSession - const checked = selectable && selectedSessions.has(session.username) - - return ( - <> - - - - - {renderSessionName(session)} - {renderActionCell(session)} - - ) - } - 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 ( -
-
-
- -
-
- {contact.avatarUrl ? ( - - ) : ( - {getAvatarLetter(contact.displayName)} - )} -
-
-
{contact.displayName}
-
{contact.username}
-
-
-
- - {messageCountLabel} - -
-
-
-
- - - -
- {recent && {recent}} + 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 ( +
+
+
+ +
+
+ {contact.avatarUrl ? ( + + ) : ( + {getAvatarLetter(contact.displayName)} + )} +
+
+
{contact.displayName}
+
{contact.username}
+
+
+
+ + {messageCountLabel} +
+
+
+ + + +
+ {recent && {recent}} +
- ) - }) - ), [ - 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,52 +4108,105 @@ function ExportPage() { />
-
-
- - - - -
- -
-
- - setSearchKeyword(event.target.value)} - placeholder={`搜索${activeTabLabel}联系人...`} - /> - {searchKeyword && ( - - )} -
- -
-
- - {contactsList.length > 0 && isContactsListLoading && ( -
- - 联系人列表同步中… -
- )} -
+
+
+
+ + + + +
+ +
+
+ + setSearchKeyword(event.target.value)} + placeholder={`搜索${activeTabLabel}联系人...`} + /> + {searchKeyword && ( + + )} +
+ +
+
+ + {contactsList.length > 0 && isContactsListLoading && ( +
+ + 联系人列表同步中… +
+ )} + + {hasFilteredContacts && ( + <> +
+ + + + 当前筛选 {visibleSelectedCount}/{visibleSelectableCount} + + + {selectedCount > 0 && ( + + )} +
+
+ 选择 + 联系人(头像/名称/微信号) + 总消息数 + 操作 +
+ + )} +
+ {contactsList.length === 0 && contactsLoadIssue ? (
@@ -4268,62 +4244,22 @@ function ExportPage() { 联系人加载中...
- ) : filteredContacts.length === 0 ? ( + ) : !hasFilteredContacts ? (
暂无联系人
) : ( - <> -
- - - - 当前筛选 {visibleSelectedCount}/{visibleSelectableCount} - - - {selectedCount > 0 && ( - - )} -
-
- 选择 - 联系人(头像/名称/微信号) - 总消息数 - 操作 -
-
- {contactsListRows} -
- +
+ contact.username} + itemContent={renderContactRow} + atTopStateChange={setIsContactsListAtTop} + overscan={420} + /> +
)}
@@ -4629,6 +4565,16 @@ function ExportPage() {
+ {showBackToTop && ( + + )} + {exportDialog.open && createPortal(
event.stopPropagation()}>