fix(export): add horizontal scrollbar for narrow session table

This commit is contained in:
aits2026
2026-03-06 18:05:31 +08:00
parent a0dda0b866
commit 1d1b38210a
2 changed files with 314 additions and 144 deletions

View File

@@ -1555,6 +1555,7 @@
--contacts-message-col-width: 120px; --contacts-message-col-width: 120px;
--contacts-media-col-width: 72px; --contacts-media-col-width: 72px;
--contacts-action-col-width: 140px; --contacts-action-col-width: 140px;
--contacts-table-min-width: 1200px;
overflow: hidden; overflow: hidden;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 10px; border-radius: 10px;
@@ -1566,6 +1567,26 @@
} }
.table-wrap { .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 { .session-table-sticky {
position: sticky; position: sticky;
top: 0; top: 0;
@@ -1575,6 +1596,7 @@
.loading-state, .loading-state,
.empty-state { .empty-state {
width: 100%;
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1590,6 +1612,7 @@
} }
.load-issue-state { .load-issue-state {
width: 100%;
flex: 1; flex: 1;
padding: 14px; padding: 14px;
overflow-y: auto; overflow-y: auto;
@@ -1766,6 +1789,7 @@
} }
.contacts-list { .contacts-list {
width: 100%;
flex: 1; flex: 1;
min-height: var(--contacts-default-list-height); min-height: var(--contacts-default-list-height);
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 { .selection-clear-btn {
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 8px; border-radius: 8px;

View File

@@ -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 { useLocation } from 'react-router-dom'
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso' import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
@@ -1487,6 +1487,10 @@ function ExportPage() {
const [hasSeededSnsStats, setHasSeededSnsStats] = useState(false) const [hasSeededSnsStats, setHasSeededSnsStats] = useState(false)
const [nowTick, setNowTick] = useState(Date.now()) const [nowTick, setNowTick] = useState(Date.now())
const [isContactsListAtTop, setIsContactsListAtTop] = useState(true) const [isContactsListAtTop, setIsContactsListAtTop] = useState(true)
const [contactsHorizontalScrollMetrics, setContactsHorizontalScrollMetrics] = useState({
viewportWidth: 0,
contentWidth: 0
})
const tabCounts = useContactTypeCountsStore(state => state.tabCounts) const tabCounts = useContactTypeCountsStore(state => state.tabCounts)
const isSharedTabCountsLoading = useContactTypeCountsStore(state => state.isLoading) const isSharedTabCountsLoading = useContactTypeCountsStore(state => state.isLoading)
const isSharedTabCountsReady = useContactTypeCountsStore(state => state.isReady) const isSharedTabCountsReady = useContactTypeCountsStore(state => state.isReady)
@@ -1508,6 +1512,10 @@ function ExportPage() {
const contactsAvatarCacheRef = useRef<Record<string, configService.ContactsAvatarCacheEntry>>({}) const contactsAvatarCacheRef = useRef<Record<string, configService.ContactsAvatarCacheEntry>>({})
const contactsVirtuosoRef = useRef<VirtuosoHandle | null>(null) const contactsVirtuosoRef = useRef<VirtuosoHandle | null>(null)
const sessionTableSectionRef = useRef<HTMLDivElement | null>(null) const sessionTableSectionRef = useRef<HTMLDivElement | null>(null)
const contactsHorizontalViewportRef = useRef<HTMLDivElement | null>(null)
const contactsHorizontalContentRef = useRef<HTMLDivElement | null>(null)
const contactsBottomScrollbarRef = useRef<HTMLDivElement | null>(null)
const contactsScrollSyncSourceRef = useRef<'viewport' | 'bottom' | null>(null)
const sessionFormatDropdownRef = useRef<HTMLDivElement | null>(null) const sessionFormatDropdownRef = useRef<HTMLDivElement | null>(null)
const detailRequestSeqRef = useRef(0) const detailRequestSeqRef = useRef(0)
const sessionsRef = useRef<SessionRow[]>([]) const sessionsRef = useRef<SessionRow[]>([])
@@ -5563,6 +5571,21 @@ function ExportPage() {
const taskQueuedCount = tasks.filter(task => task.status === 'queued').length const taskQueuedCount = tasks.filter(task => task.status === 'queued').length
const taskCenterAlertCount = taskRunningCount + taskQueuedCount const taskCenterAlertCount = taskRunningCount + taskQueuedCount
const hasFilteredContacts = filteredContacts.length > 0 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<CSSProperties>(() => ({
width: `${Math.max(contactsHorizontalScrollMetrics.contentWidth, contactsHorizontalScrollMetrics.viewportWidth)}px`
}), [contactsHorizontalScrollMetrics.contentWidth, contactsHorizontalScrollMetrics.viewportWidth])
const sessionLoadDetailUpdatedAt = useMemo(() => { const sessionLoadDetailUpdatedAt = useMemo(() => {
let latest = 0 let latest = 0
for (const row of sessionLoadDetailRows) { for (const row of sessionLoadDetailRows) {
@@ -5588,6 +5611,82 @@ function ExportPage() {
row.mutualFriends.statusLabel.startsWith('加载中') row.mutualFriends.statusLabel.startsWith('加载中')
)) ))
), [sessionLoadDetailRows]) ), [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<HTMLDivElement>) => {
syncContactsHorizontalScroll('viewport', event.currentTarget.scrollLeft)
}, [syncContactsHorizontalScroll])
const handleContactsBottomScrollbarScroll = useCallback((event: UIEvent<HTMLDivElement>) => {
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(() => { const closeTaskCenter = useCallback(() => {
setIsTaskCenterOpen(false) setIsTaskCenterOpen(false)
setExpandedPerfTaskId(null) setExpandedPerfTaskId(null)
@@ -6115,157 +6214,176 @@ function ExportPage() {
</div> </div>
<div className="session-table-section" ref={sessionTableSectionRef}> <div className="session-table-section" ref={sessionTableSectionRef}>
<div className="session-table-layout"> <div className="session-table-layout">
<div className="table-wrap"> <div className="table-wrap" style={contactsTableStyle}>
<div className="session-table-sticky"> <div
<div className="table-toolbar"> ref={contactsHorizontalViewportRef}
<div className="table-tabs" role="tablist" aria-label="会话类型"> className="table-scroll-viewport"
<button className={`tab-btn ${activeTab === 'private' ? 'active' : ''}`} onClick={() => setActiveTab('private')}> onScroll={handleContactsHorizontalViewportScroll}
{isTabCountComputing ? <span className="count-loading"><span className="animated-ellipsis" aria-hidden="true">...</span></span> : tabCounts.private} >
</button> <div ref={contactsHorizontalContentRef} className="table-scroll-content">
<button className={`tab-btn ${activeTab === 'group' ? 'active' : ''}`} onClick={() => setActiveTab('group')}> <div className="session-table-sticky">
{isTabCountComputing ? <span className="count-loading"><span className="animated-ellipsis" aria-hidden="true">...</span></span> : tabCounts.group} <div className="table-toolbar">
</button> <div className="table-tabs" role="tablist" aria-label="会话类型">
<button className={`tab-btn ${activeTab === 'former_friend' ? 'active' : ''}`} onClick={() => setActiveTab('former_friend')}> <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.former_friend} {isTabCountComputing ? <span className="count-loading"><span className="animated-ellipsis" aria-hidden="true">...</span></span> : tabCounts.private}
</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> </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 === '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> </div>
<button className="secondary-btn" onClick={() => void loadContactsList()} disabled={isContactsListLoading}>
<RefreshCw size={14} className={isContactsListLoading ? 'spin' : ''} /> {contactsList.length > 0 && isContactsListLoading && (
<div className="table-stage-hint">
</button> <Loader2 size={14} className="spin" />
</div>
)}
{hasFilteredContacts && (
<div className="contacts-list-header">
<span className="contacts-list-header-select">
<button
className={`select-icon-btn ${isAllVisibleSelected ? 'checked' : ''}`}
type="button"
onClick={toggleSelectAllVisible}
disabled={visibleSelectableCount === 0}
title={isAllVisibleSelected ? '取消全选当前筛选联系人' : '全选当前筛选联系人'}
>
{isAllVisibleSelected ? <CheckSquare size={16} /> : <Square size={16} />}
</button>
</span>
<span className="contacts-list-header-main">
<span className="contacts-list-header-main-label">{contactsHeaderMainLabel}</span>
</span>
<span className="contacts-list-header-count"></span>
<span className="contacts-list-header-media"></span>
<span className="contacts-list-header-media"></span>
<span className="contacts-list-header-media"></span>
<span className="contacts-list-header-media"></span>
{shouldShowSnsColumn && (
<span className="contacts-list-header-media"></span>
)}
{shouldShowMutualFriendsColumn && (
<span className="contacts-list-header-media"></span>
)}
<span className="contacts-list-header-actions">
{selectedCount > 0 && (
<>
<button
className="selection-clear-btn"
type="button"
onClick={clearSelection}
>
</button>
<button
className="selection-export-btn"
type="button"
onClick={openBatchExport}
>
<span></span>
<span className="selection-export-count">{selectedCount}</span>
</button>
</>
)}
</span>
</div>
)}
</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"
onWheelCapture={handleContactsListWheelCapture}
>
<Virtuoso
ref={contactsVirtuosoRef}
className="contacts-virtuoso"
data={filteredContacts}
computeItemKey={(_, contact) => contact.username}
itemContent={renderContactRow}
rangeChanged={handleContactsRangeChanged}
atTopStateChange={setIsContactsListAtTop}
overscan={420}
/>
</div>
)}
</div> </div>
{contactsList.length > 0 && isContactsListLoading && (
<div className="table-stage-hint">
<Loader2 size={14} className="spin" />
</div>
)}
{hasFilteredContacts && (
<div className="contacts-list-header">
<span className="contacts-list-header-select">
<button
className={`select-icon-btn ${isAllVisibleSelected ? 'checked' : ''}`}
type="button"
onClick={toggleSelectAllVisible}
disabled={visibleSelectableCount === 0}
title={isAllVisibleSelected ? '取消全选当前筛选联系人' : '全选当前筛选联系人'}
>
{isAllVisibleSelected ? <CheckSquare size={16} /> : <Square size={16} />}
</button>
</span>
<span className="contacts-list-header-main">
<span className="contacts-list-header-main-label">{contactsHeaderMainLabel}</span>
</span>
<span className="contacts-list-header-count"></span>
<span className="contacts-list-header-media"></span>
<span className="contacts-list-header-media"></span>
<span className="contacts-list-header-media"></span>
<span className="contacts-list-header-media"></span>
{shouldShowSnsColumn && (
<span className="contacts-list-header-media"></span>
)}
{shouldShowMutualFriendsColumn && (
<span className="contacts-list-header-media"></span>
)}
<span className="contacts-list-header-actions">
{selectedCount > 0 && (
<>
<button
className="selection-clear-btn"
type="button"
onClick={clearSelection}
>
</button>
<button
className="selection-export-btn"
type="button"
onClick={openBatchExport}
>
<span></span>
<span className="selection-export-count">{selectedCount}</span>
</button>
</>
)}
</span>
</div>
)}
</div> </div>
{contactsList.length === 0 && contactsLoadIssue ? ( {hasFilteredContacts && hasContactsHorizontalOverflow && (
<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 <div
className="contacts-list" ref={contactsBottomScrollbarRef}
onWheelCapture={handleContactsListWheelCapture} className="table-bottom-scrollbar"
onScroll={handleContactsBottomScrollbarScroll}
aria-label="会话列表横向滚动条"
> >
<Virtuoso <div className="table-bottom-scrollbar-inner" style={contactsBottomScrollbarInnerStyle} />
ref={contactsVirtuosoRef}
className="contacts-virtuoso"
data={filteredContacts}
computeItemKey={(_, contact) => contact.username}
itemContent={renderContactRow}
rangeChanged={handleContactsRangeChanged}
atTopStateChange={setIsContactsListAtTop}
overscan={420}
/>
</div> </div>
)} )}
</div> </div>