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 { .table-wrap {
overflow: auto; overflow: hidden;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 10px; border-radius: 10px;
min-height: 0; min-height: 0;
flex: 1; flex: 1;
} }
.session-table { .table-virtuoso {
height: 100%;
}
.session-table,
.table-wrap table {
width: 100%; width: 100%;
min-width: 1300px; min-width: 1300px;
border-collapse: separate; border-collapse: separate;
@@ -588,7 +593,8 @@
background: rgba(var(--primary-rgb), 0.03); 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); background: rgba(var(--primary-rgb), 0.08);
} }

View File

@@ -1,5 +1,6 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import { TableVirtuoso } from 'react-virtuoso'
import { import {
Aperture, Aperture,
ChevronDown, ChevronDown,
@@ -236,6 +237,9 @@ const timestampOrDash = (timestamp?: number): string => {
} }
const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` 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({ const WriteLayoutSelector = memo(function WriteLayoutSelector({
writeLayout, writeLayout,
@@ -355,6 +359,7 @@ function ExportPage() {
const sessionLoadTokenRef = useRef(0) const sessionLoadTokenRef = useRef(0)
const loadingMetricsRef = useRef<Set<string>>(new Set()) const loadingMetricsRef = useRef<Set<string>>(new Set())
const preselectAppliedRef = useRef(false) const preselectAppliedRef = useRef(false)
const visibleSessionsRef = useRef<SessionRow[]>([])
useEffect(() => { useEffect(() => {
tasksRef.current = tasks tasksRef.current = tasks
@@ -615,6 +620,10 @@ function ExportPage() {
}) })
}, [sessions, activeTab, searchKeyword, sessionMetrics]) }, [sessions, activeTab, searchKeyword, sessionMetrics])
useEffect(() => {
visibleSessionsRef.current = visibleSessions
}, [visibleSessions])
const ensureSessionMetrics = useCallback(async (targetSessions: SessionRow[]) => { const ensureSessionMetrics = useCallback(async (targetSessions: SessionRow[]) => {
const currentMetrics = sessionMetricsRef.current const currentMetrics = sessionMetricsRef.current
const pending = targetSessions.filter(session => !currentMetrics[session.username] && !loadingMetricsRef.current.has(session.username)) const pending = targetSessions.filter(session => !currentMetrics[session.username] && !loadingMetricsRef.current.has(session.username))
@@ -674,13 +683,44 @@ function ExportPage() {
}, []) }, [])
useEffect(() => { 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) 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(() => { useEffect(() => {
if (sessions.length === 0) return 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]) }, [sessions, ensureSessionMetrics])
const selectedCount = selectedSessions.size const selectedCount = selectedSessions.size
@@ -1294,12 +1334,12 @@ function ExportPage() {
) )
} }
const renderRow = (session: SessionRow) => { const renderRowCells = (session: SessionRow) => {
const metrics = sessionMetrics[session.username] || {} const metrics = sessionMetrics[session.username] || {}
const checked = selectedSessions.has(session.username) const checked = selectedSessions.has(session.username)
return ( return (
<tr key={session.username} className={checked ? 'selected-row' : ''}> <>
<td className="sticky-col"> <td className="sticky-col">
<button <button
className={`select-icon-btn ${checked ? 'checked' : ''}`} className={`select-icon-btn ${checked ? 'checked' : ''}`}
@@ -1344,7 +1384,7 @@ function ExportPage() {
)} )}
<td className="sticky-right">{renderActionCell(session)}</td> <td className="sticky-right">{renderActionCell(session)}</td>
</tr> </>
) )
} }
@@ -1357,7 +1397,6 @@ function ExportPage() {
return count return count
}, [visibleSessions, selectedSessions]) }, [visibleSessions, selectedSessions])
const tableColSpan = activeTab === 'group' ? 14 : (activeTab === 'private' || activeTab === 'former_friend' ? 11 : 10)
const canCreateTask = exportDialog.scope === 'sns' const canCreateTask = exportDialog.scope === 'sns'
? Boolean(exportFolder) ? Boolean(exportFolder)
: Boolean(exportFolder) && exportDialog.sessionIds.length > 0 : Boolean(exportFolder) && exportDialog.sessionIds.length > 0
@@ -1380,10 +1419,6 @@ function ExportPage() {
const taskRunningCount = tasks.filter(task => task.status === 'running').length const taskRunningCount = tasks.filter(task => task.status === 'running').length
const taskQueuedCount = tasks.filter(task => task.status === 'queued').length const taskQueuedCount = tasks.filter(task => task.status === 'queued').length
const showInitialSkeleton = isLoading && sessions.length === 0 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 chooseExportFolder = useCallback(async () => {
const result = await window.electronAPI.dialog.openFile({ const result = await window.electronAPI.dialog.openFile({
title: '选择导出目录', title: '选择导出目录',
@@ -1589,37 +1624,32 @@ function ExportPage() {
)} )}
<div className="table-wrap"> <div className="table-wrap">
<table className="session-table"> {showInitialSkeleton ? (
<thead>{renderTableHeader()}</thead> <div className="table-skeleton-list">
<tbody> {Array.from({ length: 8 }).map((_, rowIndex) => (
{showInitialSkeleton ? ( <div key={`skeleton-row-${rowIndex}`} className="table-skeleton-item">
<tr> <span className="skeleton-shimmer skeleton-dot"></span>
<td colSpan={tableColSpan}> <span className="skeleton-shimmer skeleton-avatar"></span>
<div className="table-skeleton-list"> <span className="skeleton-shimmer skeleton-line w-30"></span>
{Array.from({ length: 8 }).map((_, rowIndex) => ( <span className="skeleton-shimmer skeleton-line w-12"></span>
<div key={`skeleton-row-${rowIndex}`} className="table-skeleton-item"> <span className="skeleton-shimmer skeleton-line w-12"></span>
<span className="skeleton-shimmer skeleton-dot"></span> <span className="skeleton-shimmer skeleton-line w-12"></span>
<span className="skeleton-shimmer skeleton-avatar"></span> </div>
<span className="skeleton-shimmer skeleton-line w-30"></span> ))}
<span className="skeleton-shimmer skeleton-line w-12"></span> </div>
<span className="skeleton-shimmer skeleton-line w-12"></span> ) : visibleSessions.length === 0 ? (
<span className="skeleton-shimmer skeleton-line w-12"></span> <div className="table-state"></div>
</div> ) : (
))} <TableVirtuoso
</div> className="table-virtuoso"
</td> data={visibleSessions}
</tr> fixedHeaderContent={renderTableHeader}
) : visibleSessions.length === 0 ? ( computeItemKey={(_, session) => session.username}
<tr> rangeChanged={handleTableRangeChanged}
<td colSpan={tableColSpan}> itemContent={(_, session) => renderRowCells(session)}
<div className="table-state"></div> overscan={420}
</td> />
</tr> )}
) : (
tableBodyRows
)}
</tbody>
</table>
</div> </div>
</div> </div>