perf(export): virtualize session table and prioritize metrics loading

This commit is contained in:
tisonhuang
2026-03-01 17:07:32 +08:00
parent dffd3c9138
commit d12c111684
2 changed files with 81 additions and 45 deletions

View File

@@ -549,14 +549,19 @@
}
.table-wrap {
overflow: auto;
overflow: hidden;
border: 1px solid var(--border-color);
border-radius: 10px;
min-height: 0;
flex: 1;
}
.session-table {
.table-virtuoso {
height: 100%;
}
.session-table,
.table-wrap table {
width: 100%;
min-width: 1300px;
border-collapse: separate;
@@ -588,7 +593,8 @@
background: rgba(var(--primary-rgb), 0.03);
}
.selected-row {
.selected-row,
tbody tr:has(.select-icon-btn.checked) {
background: rgba(var(--primary-rgb), 0.08);
}

View File

@@ -1,5 +1,6 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useLocation } from 'react-router-dom'
import { TableVirtuoso } from 'react-virtuoso'
import {
Aperture,
ChevronDown,
@@ -236,6 +237,9 @@ const timestampOrDash = (timestamp?: number): string => {
}
const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const METRICS_VIEWPORT_PREFETCH = 140
const METRICS_BACKGROUND_BATCH = 60
const METRICS_BACKGROUND_INTERVAL_MS = 180
const WriteLayoutSelector = memo(function WriteLayoutSelector({
writeLayout,
@@ -355,6 +359,7 @@ function ExportPage() {
const sessionLoadTokenRef = useRef(0)
const loadingMetricsRef = useRef<Set<string>>(new Set())
const preselectAppliedRef = useRef(false)
const visibleSessionsRef = useRef<SessionRow[]>([])
useEffect(() => {
tasksRef.current = tasks
@@ -615,6 +620,10 @@ function ExportPage() {
})
}, [sessions, activeTab, searchKeyword, sessionMetrics])
useEffect(() => {
visibleSessionsRef.current = visibleSessions
}, [visibleSessions])
const ensureSessionMetrics = useCallback(async (targetSessions: SessionRow[]) => {
const currentMetrics = sessionMetricsRef.current
const pending = targetSessions.filter(session => !currentMetrics[session.username] && !loadingMetricsRef.current.has(session.username))
@@ -674,13 +683,44 @@ function ExportPage() {
}, [])
useEffect(() => {
const targets = visibleSessions.slice(0, 40)
const keyword = searchKeyword.trim().toLowerCase()
const targets = sessions
.filter((session) => {
if (session.kind !== activeTab) return false
if (!keyword) return true
return (
(session.displayName || '').toLowerCase().includes(keyword) ||
session.username.toLowerCase().includes(keyword)
)
})
.sort((a, b) => (b.sortTimestamp || b.lastTimestamp || 0) - (a.sortTimestamp || a.lastTimestamp || 0))
.slice(0, METRICS_VIEWPORT_PREFETCH)
void ensureSessionMetrics(targets)
}, [visibleSessions, ensureSessionMetrics])
}, [sessions, activeTab, searchKeyword, ensureSessionMetrics])
const handleTableRangeChanged = useCallback((range: { startIndex: number; endIndex: number }) => {
const current = visibleSessionsRef.current
if (current.length === 0) return
const start = Math.max(0, range.startIndex - METRICS_VIEWPORT_PREFETCH)
const end = Math.min(current.length - 1, range.endIndex + METRICS_VIEWPORT_PREFETCH)
if (end < start) return
void ensureSessionMetrics(current.slice(start, end + 1))
}, [ensureSessionMetrics])
useEffect(() => {
if (sessions.length === 0) return
void ensureSessionMetrics(sessions)
let cursor = 0
const timer = window.setInterval(() => {
if (cursor >= sessions.length) {
window.clearInterval(timer)
return
}
const chunk = sessions.slice(cursor, cursor + METRICS_BACKGROUND_BATCH)
cursor += METRICS_BACKGROUND_BATCH
void ensureSessionMetrics(chunk)
}, METRICS_BACKGROUND_INTERVAL_MS)
return () => window.clearInterval(timer)
}, [sessions, ensureSessionMetrics])
const selectedCount = selectedSessions.size
@@ -1294,12 +1334,12 @@ function ExportPage() {
)
}
const renderRow = (session: SessionRow) => {
const renderRowCells = (session: SessionRow) => {
const metrics = sessionMetrics[session.username] || {}
const checked = selectedSessions.has(session.username)
return (
<tr key={session.username} className={checked ? 'selected-row' : ''}>
<>
<td className="sticky-col">
<button
className={`select-icon-btn ${checked ? 'checked' : ''}`}
@@ -1344,7 +1384,7 @@ function ExportPage() {
)}
<td className="sticky-right">{renderActionCell(session)}</td>
</tr>
</>
)
}
@@ -1357,7 +1397,6 @@ function ExportPage() {
return count
}, [visibleSessions, selectedSessions])
const tableColSpan = activeTab === 'group' ? 14 : (activeTab === 'private' || activeTab === 'former_friend' ? 11 : 10)
const canCreateTask = exportDialog.scope === 'sns'
? Boolean(exportFolder)
: Boolean(exportFolder) && exportDialog.sessionIds.length > 0
@@ -1380,10 +1419,6 @@ function ExportPage() {
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 tableBodyRows = useMemo(
() => visibleSessions.map(renderRow),
[visibleSessions, selectedSessions, sessionMetrics, activeTab, runningSessionIds, queuedSessionIds, nowTick, lastExportBySession]
)
const chooseExportFolder = useCallback(async () => {
const result = await window.electronAPI.dialog.openFile({
title: '选择导出目录',
@@ -1589,12 +1624,7 @@ function ExportPage() {
)}
<div className="table-wrap">
<table className="session-table">
<thead>{renderTableHeader()}</thead>
<tbody>
{showInitialSkeleton ? (
<tr>
<td colSpan={tableColSpan}>
<div className="table-skeleton-list">
{Array.from({ length: 8 }).map((_, rowIndex) => (
<div key={`skeleton-row-${rowIndex}`} className="table-skeleton-item">
@@ -1607,19 +1637,19 @@ function ExportPage() {
</div>
))}
</div>
</td>
</tr>
) : visibleSessions.length === 0 ? (
<tr>
<td colSpan={tableColSpan}>
<div className="table-state"></div>
</td>
</tr>
) : (
tableBodyRows
<TableVirtuoso
className="table-virtuoso"
data={visibleSessions}
fixedHeaderContent={renderTableHeader}
computeItemKey={(_, session) => session.username}
rangeChanged={handleTableRangeChanged}
itemContent={(_, session) => renderRowCells(session)}
overscan={420}
/>
)}
</tbody>
</table>
</div>
</div>