mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 15:25:50 +00:00
fix(export): restore virtualized contacts list and sticky controls
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>可能原因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>
|
||||
) : 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>可能原因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">
|
||||
<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()}>
|
||||
|
||||
Reference in New Issue
Block a user