diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index f0ca4c9..d8f7528 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -1555,6 +1555,7 @@ --contacts-message-col-width: 120px; --contacts-media-col-width: 72px; --contacts-action-col-width: 140px; + --contacts-table-min-width: 1200px; overflow: hidden; border: 1px solid var(--border-color); border-radius: 10px; @@ -1566,6 +1567,26 @@ } .table-wrap { + .table-scroll-viewport { + flex: 1; + min-height: 0; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + } + + .table-scroll-content { + min-width: max(100%, var(--contacts-table-min-width)); + width: max-content; + min-height: 100%; + display: flex; + flex-direction: column; + } + .session-table-sticky { position: sticky; top: 0; @@ -1575,6 +1596,7 @@ .loading-state, .empty-state { + width: 100%; flex: 1; display: flex; flex-direction: column; @@ -1590,6 +1612,7 @@ } .load-issue-state { + width: 100%; flex: 1; padding: 14px; overflow-y: auto; @@ -1766,6 +1789,7 @@ } .contacts-list { + width: 100%; flex: 1; min-height: var(--contacts-default-list-height); height: var(--contacts-default-list-height); @@ -1787,6 +1811,34 @@ } } + .table-bottom-scrollbar { + flex: 0 0 auto; + height: 16px; + overflow-x: auto; + overflow-y: hidden; + border-top: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent); + background: color-mix(in srgb, var(--bg-primary) 86%, var(--bg-secondary)); + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--text-tertiary) 70%, transparent) transparent; + + &::-webkit-scrollbar { + height: 10px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + border-radius: 999px; + background: color-mix(in srgb, var(--text-tertiary) 70%, transparent); + } + } + + .table-bottom-scrollbar-inner { + height: 1px; + } + .selection-clear-btn { border: 1px solid var(--border-color); border-radius: 8px; diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 085af11..37c7674 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useEffect, useMemo, useRef, useState, type WheelEvent } from 'react' +import { memo, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type UIEvent, type WheelEvent } from 'react' import { useLocation } from 'react-router-dom' import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso' import { createPortal } from 'react-dom' @@ -1487,6 +1487,10 @@ function ExportPage() { const [hasSeededSnsStats, setHasSeededSnsStats] = useState(false) const [nowTick, setNowTick] = useState(Date.now()) const [isContactsListAtTop, setIsContactsListAtTop] = useState(true) + const [contactsHorizontalScrollMetrics, setContactsHorizontalScrollMetrics] = useState({ + viewportWidth: 0, + contentWidth: 0 + }) const tabCounts = useContactTypeCountsStore(state => state.tabCounts) const isSharedTabCountsLoading = useContactTypeCountsStore(state => state.isLoading) const isSharedTabCountsReady = useContactTypeCountsStore(state => state.isReady) @@ -1508,6 +1512,10 @@ function ExportPage() { const contactsAvatarCacheRef = useRef>({}) const contactsVirtuosoRef = useRef(null) const sessionTableSectionRef = useRef(null) + const contactsHorizontalViewportRef = useRef(null) + const contactsHorizontalContentRef = useRef(null) + const contactsBottomScrollbarRef = useRef(null) + const contactsScrollSyncSourceRef = useRef<'viewport' | 'bottom' | null>(null) const sessionFormatDropdownRef = useRef(null) const detailRequestSeqRef = useRef(0) const sessionsRef = useRef([]) @@ -5563,6 +5571,21 @@ function ExportPage() { const taskQueuedCount = tasks.filter(task => task.status === 'queued').length const taskCenterAlertCount = taskRunningCount + taskQueuedCount const hasFilteredContacts = filteredContacts.length > 0 + const contactsTableMinWidth = useMemo(() => { + const baseWidth = 24 + 34 + 44 + 280 + 120 + (4 * 72) + 140 + (8 * 12) + const snsWidth = shouldShowSnsColumn ? 72 + 12 : 0 + const mutualFriendsWidth = shouldShowMutualFriendsColumn ? 72 + 12 : 0 + return baseWidth + snsWidth + mutualFriendsWidth + }, [shouldShowMutualFriendsColumn, shouldShowSnsColumn]) + const contactsTableStyle = useMemo(() => ( + { + ['--contacts-table-min-width' as const]: `${contactsTableMinWidth}px` + } as CSSProperties + ), [contactsTableMinWidth]) + const hasContactsHorizontalOverflow = contactsHorizontalScrollMetrics.contentWidth - contactsHorizontalScrollMetrics.viewportWidth > 1 + const contactsBottomScrollbarInnerStyle = useMemo(() => ({ + width: `${Math.max(contactsHorizontalScrollMetrics.contentWidth, contactsHorizontalScrollMetrics.viewportWidth)}px` + }), [contactsHorizontalScrollMetrics.contentWidth, contactsHorizontalScrollMetrics.viewportWidth]) const sessionLoadDetailUpdatedAt = useMemo(() => { let latest = 0 for (const row of sessionLoadDetailRows) { @@ -5588,6 +5611,82 @@ function ExportPage() { row.mutualFriends.statusLabel.startsWith('加载中') )) ), [sessionLoadDetailRows]) + const syncContactsHorizontalScroll = useCallback((source: 'viewport' | 'bottom', scrollLeft: number) => { + if (contactsScrollSyncSourceRef.current && contactsScrollSyncSourceRef.current !== source) return + + contactsScrollSyncSourceRef.current = source + const viewport = contactsHorizontalViewportRef.current + const bottomScrollbar = contactsBottomScrollbarRef.current + + if (source !== 'viewport' && viewport && Math.abs(viewport.scrollLeft - scrollLeft) > 1) { + viewport.scrollLeft = scrollLeft + } + + if (source !== 'bottom' && bottomScrollbar && Math.abs(bottomScrollbar.scrollLeft - scrollLeft) > 1) { + bottomScrollbar.scrollLeft = scrollLeft + } + + window.requestAnimationFrame(() => { + if (contactsScrollSyncSourceRef.current === source) { + contactsScrollSyncSourceRef.current = null + } + }) + }, []) + const handleContactsHorizontalViewportScroll = useCallback((event: UIEvent) => { + syncContactsHorizontalScroll('viewport', event.currentTarget.scrollLeft) + }, [syncContactsHorizontalScroll]) + const handleContactsBottomScrollbarScroll = useCallback((event: UIEvent) => { + syncContactsHorizontalScroll('bottom', event.currentTarget.scrollLeft) + }, [syncContactsHorizontalScroll]) + useEffect(() => { + const viewport = contactsHorizontalViewportRef.current + const content = contactsHorizontalContentRef.current + if (!viewport || !content) return + + const syncMetrics = () => { + const viewportWidth = Math.round(viewport.clientWidth) + const contentWidth = Math.round(content.scrollWidth) + + setContactsHorizontalScrollMetrics((prev) => ( + prev.viewportWidth === viewportWidth && prev.contentWidth === contentWidth + ? prev + : { viewportWidth, contentWidth } + )) + + const maxScrollLeft = Math.max(0, contentWidth - viewportWidth) + const clampedScrollLeft = Math.min(viewport.scrollLeft, maxScrollLeft) + + if (Math.abs(viewport.scrollLeft - clampedScrollLeft) > 1) { + viewport.scrollLeft = clampedScrollLeft + } + + const bottomScrollbar = contactsBottomScrollbarRef.current + if (bottomScrollbar) { + const nextScrollLeft = Math.min(bottomScrollbar.scrollLeft, maxScrollLeft) + if (Math.abs(bottomScrollbar.scrollLeft - nextScrollLeft) > 1) { + bottomScrollbar.scrollLeft = nextScrollLeft + } + if (Math.abs(nextScrollLeft - clampedScrollLeft) > 1) { + bottomScrollbar.scrollLeft = clampedScrollLeft + } + } + } + + syncMetrics() + + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', syncMetrics) + return () => window.removeEventListener('resize', syncMetrics) + } + + const resizeObserver = new ResizeObserver(syncMetrics) + resizeObserver.observe(viewport) + resizeObserver.observe(content) + + return () => { + resizeObserver.disconnect() + } + }, []) const closeTaskCenter = useCallback(() => { setIsTaskCenterOpen(false) setExpandedPerfTaskId(null) @@ -6115,157 +6214,176 @@ function ExportPage() {
-
-
-
-
- - - -
- -
-
- - setSearchKeyword(event.target.value)} - placeholder={`搜索${activeTabLabel}联系人...`} - /> - {searchKeyword && ( - - )} + + +
+ +
+
+ + setSearchKeyword(event.target.value)} + placeholder={`搜索${activeTabLabel}联系人...`} + /> + {searchKeyword && ( + + )} +
+ +
- + + {contactsList.length > 0 && isContactsListLoading && ( +
+ + 联系人列表同步中… +
+ )} + + {hasFilteredContacts && ( +
+ + + + + {contactsHeaderMainLabel} + + 总消息数 + 表情包 + 语音 + 图片 + 视频 + {shouldShowSnsColumn && ( + 朋友圈 + )} + {shouldShowMutualFriendsColumn && ( + 共同好友 + )} + + {selectedCount > 0 && ( + <> + + + + )} + +
+ )}
+ + {contactsList.length === 0 && contactsLoadIssue ? ( +
+
+
+ + {contactsLoadIssue.title} +
+

{contactsLoadIssue.message}

+

{contactsLoadIssue.reason}

+
    +
  • 可能原因1:数据库当前仍在执行高开销查询(例如导出页后台统计)。
  • +
  • 可能原因2:contact.db 数据量较大,首次查询时间过长。
  • +
  • 可能原因3:数据库连接状态异常或 IPC 调用卡住。
  • +
+
+ + + +
+ {showContactsDiagnostics && ( +
{contactsDiagnosticsText}
+ )} +
+
+ ) : isContactsListLoading && contactsList.length === 0 ? ( +
+ + 联系人加载中... +
+ ) : !hasFilteredContacts ? ( +
+ 暂无联系人 +
+ ) : ( +
+ contact.username} + itemContent={renderContactRow} + rangeChanged={handleContactsRangeChanged} + atTopStateChange={setIsContactsListAtTop} + overscan={420} + /> +
+ )}
- - {contactsList.length > 0 && isContactsListLoading && ( -
- - 联系人列表同步中… -
- )} - - {hasFilteredContacts && ( -
- - - - - {contactsHeaderMainLabel} - - 总消息数 - 表情包 - 语音 - 图片 - 视频 - {shouldShowSnsColumn && ( - 朋友圈 - )} - {shouldShowMutualFriendsColumn && ( - 共同好友 - )} - - {selectedCount > 0 && ( - <> - - - - )} - -
- )}
- {contactsList.length === 0 && contactsLoadIssue ? ( -
-
-
- - {contactsLoadIssue.title} -
-

{contactsLoadIssue.message}

-

{contactsLoadIssue.reason}

-
    -
  • 可能原因1:数据库当前仍在执行高开销查询(例如导出页后台统计)。
  • -
  • 可能原因2:contact.db 数据量较大,首次查询时间过长。
  • -
  • 可能原因3:数据库连接状态异常或 IPC 调用卡住。
  • -
-
- - - -
- {showContactsDiagnostics && ( -
{contactsDiagnosticsText}
- )} -
-
- ) : isContactsListLoading && contactsList.length === 0 ? ( -
- - 联系人加载中... -
- ) : !hasFilteredContacts ? ( -
- 暂无联系人 -
- ) : ( + {hasFilteredContacts && hasContactsHorizontalOverflow && (
- contact.username} - itemContent={renderContactRow} - rangeChanged={handleContactsRangeChanged} - atTopStateChange={setIsContactsListAtTop} - overscan={420} - /> +
)}