mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
fix(export): add horizontal scrollbar for narrow session table
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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>可能原因2:contact.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>可能原因2:contact.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>
|
||||||
|
|||||||
Reference in New Issue
Block a user