perf(frontend): add virtual scrolling for account table

Introduce @tanstack/vue-virtual to DataTable with opt-in virtualScroll
prop. Only AccountsView enables it — other consumers are unaffected.
DOM renders ~20 visible rows instead of 100, eliminating the main
rendering bottleneck when switching to 100 items/page.

- DataTable: add virtualScroll/estimateRowHeight/overscan props,
  virtualizer setup, padding-tr placeholders, defineExpose
- useSwipeSelect: dual-mode (DOM vs data-driven) for virtual compat
- AccountUsageCell: module-level Map cache with 5min TTL to avoid
  re-fetching usage on scroll-induced mount/unmount cycles
- AccountsView: enable virtualScroll, wire up virtual context
This commit is contained in:
erio
2026-03-18 22:28:52 +08:00
parent 5eb6f190bd
commit 525592b3f6
6 changed files with 342 additions and 40 deletions
+1
View File
@@ -16,6 +16,7 @@
},
"dependencies": {
"@lobehub/icons": "^4.0.2",
"@tanstack/vue-virtual": "^3.13.23",
"@vueuse/core": "^10.7.0",
"axios": "^1.13.5",
"chart.js": "^4.4.1",
+18
View File
@@ -11,6 +11,9 @@ importers:
'@lobehub/icons':
specifier: ^4.0.2
version: 4.0.2(@lobehub/ui@4.9.2)(@types/react@19.2.7)(antd@6.1.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@tanstack/vue-virtual':
specifier: ^3.13.23
version: 3.13.23(vue@3.5.26(typescript@5.6.3))
'@vueuse/core':
specifier: ^10.7.0
version: 10.11.1(vue@3.5.26(typescript@5.6.3))
@@ -1376,6 +1379,14 @@ packages:
peerDependencies:
react: '>= 16.3.0'
'@tanstack/virtual-core@3.13.23':
resolution: {integrity: sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==}
'@tanstack/vue-virtual@3.13.23':
resolution: {integrity: sha512-b5jPluAR6U3eOq6GWAYSpj3ugnAIZgGR0e6aGAgyRse0Yu6MVQQ0ZWm9SArSXWtageogn6bkVD8D//c4IjW3xQ==}
peerDependencies:
vue: ^2.7.0 || ^3.0.0
'@types/d3-array@3.2.2':
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
@@ -5808,6 +5819,13 @@ snapshots:
dependencies:
react: 19.2.3
'@tanstack/virtual-core@3.13.23': {}
'@tanstack/vue-virtual@3.13.23(vue@3.5.26(typescript@5.6.3))':
dependencies:
'@tanstack/virtual-core': 3.13.23
vue: 3.5.26(typescript@5.6.3)
'@types/d3-array@3.2.2': {}
'@types/d3-axis@3.0.6':
@@ -451,6 +451,10 @@ import { formatCompactNumber } from '@/utils/format'
import UsageProgressBar from './UsageProgressBar.vue'
import AccountQuotaInfo from './AccountQuotaInfo.vue'
// Module-level cache shared across all AccountUsageCell instances
const _usageCache = new Map<number, { data: AccountUsageInfo; ts: number }>()
const USAGE_CACHE_TTL = 5 * 60 * 1000 // 5 minutes
const props = withDefaults(
defineProps<{
account: Account
@@ -946,16 +950,29 @@ const copyValidationURL = async () => {
}
}
const loadUsage = async () => {
const loadUsage = async (bypassCache = false) => {
if (!shouldFetchUsage.value) return
// Check cache
if (!bypassCache) {
const cached = _usageCache.get(props.account.id)
if (cached && Date.now() - cached.ts < USAGE_CACHE_TTL) {
usageInfo.value = cached.data
loading.value = false
return
}
}
loading.value = true
error.value = null
try {
const fetchFn = () => adminAPI.accounts.getUsage(props.account.id)
const result = await enqueueUsageRequest(props.account, fetchFn)
if (!unmounted.value) usageInfo.value = result
if (!unmounted.value) {
usageInfo.value = result
_usageCache.set(props.account.id, { data: result, ts: Date.now() })
}
} catch (e: any) {
if (!unmounted.value) {
error.value = t('common.error')
@@ -1077,7 +1094,8 @@ watch(
if (nextToken === prevToken) return
if (!shouldFetchUsage.value) return
loadUsage().catch((e) => {
_usageCache.delete(props.account.id)
loadUsage(true).catch((e) => {
console.error('Failed to refresh usage after manual refresh:', e)
})
}
+153 -23
View File
@@ -33,7 +33,7 @@
<template v-else>
<div
v-for="(row, index) in sortedData"
v-for="(row, index) in displayedData"
:key="resolveRowKey(row, index)"
class="rounded-lg border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-900"
>
@@ -65,7 +65,8 @@
class="table-wrapper hidden md:block"
:class="{
'actions-expanded': actionsExpanded,
'is-scrollable': isScrollable
'is-scrollable': isScrollable,
'virtual-scroll-container': virtualScroll
}"
>
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
@@ -147,28 +148,70 @@
</td>
</tr>
<!-- Data rows -->
<tr
v-else
v-for="(row, index) in sortedData"
:key="resolveRowKey(row, index)"
:data-row-id="resolveRowKey(row, index)"
class="hover:bg-gray-50 dark:hover:bg-dark-800"
>
<td
v-for="(column, colIndex) in columns"
:key="column.key"
:class="[
'whitespace-nowrap py-4 text-sm text-gray-900 dark:text-gray-100',
getAdaptivePaddingClass(),
getStickyColumnClass(column, colIndex)
]"
<!-- Data rows: non-virtual mode (original behavior) -->
<template v-else-if="!virtualScroll">
<tr
v-for="(row, index) in displayedData"
:key="resolveRowKey(row, index)"
:data-row-id="resolveRowKey(row, index)"
class="hover:bg-gray-50 dark:hover:bg-dark-800"
>
<slot :name="`cell-${column.key}`" :row="row" :value="row[column.key]" :expanded="actionsExpanded">
{{ column.formatter ? column.formatter(row[column.key], row) : row[column.key] }}
</slot>
</td>
</tr>
<td
v-for="(column, colIndex) in columns"
:key="column.key"
:class="[
'whitespace-nowrap py-4 text-sm text-gray-900 dark:text-gray-100',
getAdaptivePaddingClass(),
getStickyColumnClass(column, colIndex)
]"
>
<slot :name="`cell-${column.key}`" :row="row" :value="row[column.key]" :expanded="actionsExpanded">
{{ column.formatter ? column.formatter(row[column.key], row) : row[column.key] }}
</slot>
</td>
</tr>
</template>
<!-- Data rows: virtual scroll mode -->
<template v-else>
<tr v-if="virtualPaddingTop > 0" aria-hidden="true">
<td :colspan="columns.length"
:style="{ height: virtualPaddingTop + 'px', padding: 0, border: 'none' }">
</td>
</tr>
<tr
v-for="virtualRow in virtualItems"
:key="resolveRowKey(sortedData[virtualRow.index], virtualRow.index)"
:data-row-id="resolveRowKey(sortedData[virtualRow.index], virtualRow.index)"
:data-index="virtualRow.index"
:ref="measureElement"
class="hover:bg-gray-50 dark:hover:bg-dark-800"
>
<td
v-for="(column, colIndex) in columns"
:key="column.key"
:class="[
'whitespace-nowrap py-4 text-sm text-gray-900 dark:text-gray-100',
getAdaptivePaddingClass(),
getStickyColumnClass(column, colIndex)
]"
>
<slot :name="`cell-${column.key}`"
:row="sortedData[virtualRow.index]"
:value="sortedData[virtualRow.index][column.key]"
:expanded="actionsExpanded">
{{ column.formatter
? column.formatter(sortedData[virtualRow.index][column.key], sortedData[virtualRow.index])
: sortedData[virtualRow.index][column.key] }}
</slot>
</td>
</tr>
<tr v-if="virtualPaddingBottom > 0" aria-hidden="true">
<td :colspan="columns.length"
:style="{ height: virtualPaddingBottom + 'px', padding: 0, border: 'none' }">
</td>
</tr>
</template>
</tbody>
</table>
</div>
@@ -176,6 +219,7 @@
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useVirtualizer } from '@tanstack/vue-virtual'
import { useI18n } from 'vue-i18n'
import type { Column } from './types'
import Icon from '@/components/icons/Icon.vue'
@@ -251,6 +295,9 @@ let resizeHandler: (() => void) | null = null
onMounted(() => {
checkScrollable()
checkActionsColumnWidth()
if (!props.virtualScroll) {
startProgressiveReveal(sortedData.value?.length ?? 0)
}
if (tableWrapperRef.value && typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(() => {
checkScrollable()
@@ -269,6 +316,7 @@ onMounted(() => {
onUnmounted(() => {
resizeObserver?.disconnect()
cancelAnimationFrame(progressiveRAF)
if (resizeHandler) {
window.removeEventListener('resize', resizeHandler)
resizeHandler = null
@@ -299,6 +347,12 @@ interface Props {
* will emit 'sort' events instead of performing client-side sorting.
*/
serverSideSort?: boolean
/** Enable virtual scrolling (opt-in, default false) */
virtualScroll?: boolean
/** Estimated row height in px for the virtualizer (default 56) */
estimateRowHeight?: number
/** Number of rows to render beyond visible area (default 5) */
overscan?: number
}
const props = withDefaults(defineProps<Props>(), {
@@ -499,6 +553,68 @@ const sortedData = computed(() => {
.map(item => item.row)
})
// --- Virtual scrolling ---
const rowVirtualizer = useVirtualizer(computed(() => ({
count: props.virtualScroll ? (sortedData.value?.length ?? 0) : 0,
getScrollElement: () => tableWrapperRef.value,
estimateSize: () => props.estimateRowHeight ?? 56,
overscan: props.overscan ?? 5,
})))
const virtualItems = computed(() => rowVirtualizer.value.getVirtualItems())
const virtualPaddingTop = computed(() => {
const items = virtualItems.value
return items.length > 0 ? items[0].start : 0
})
const virtualPaddingBottom = computed(() => {
const items = virtualItems.value
if (items.length === 0) return 0
return rowVirtualizer.value.getTotalSize() - items[items.length - 1].end
})
const measureElement = (el: any) => {
if (el && props.virtualScroll) {
rowVirtualizer.value.measureElement(el as Element)
}
}
// --- Progressive rendering: render rows in chunks to avoid blocking the main thread ---
const PROGRESSIVE_CHUNK = 30
const visibleCount = ref(0)
let progressiveRAF = 0
const displayedData = computed(() => {
const all = sortedData.value
if (!all || visibleCount.value >= all.length) return all
return all.slice(0, visibleCount.value)
})
function startProgressiveReveal(total: number) {
cancelAnimationFrame(progressiveRAF)
visibleCount.value = Math.min(PROGRESSIVE_CHUNK, total)
if (total <= PROGRESSIVE_CHUNK) return
const step = () => {
if (visibleCount.value < total) {
visibleCount.value = Math.min(visibleCount.value + PROGRESSIVE_CHUNK, total)
if (visibleCount.value < total) {
progressiveRAF = requestAnimationFrame(step)
}
}
}
progressiveRAF = requestAnimationFrame(step)
}
// Restart progressive rendering when the underlying data array changes (page switch, filter, reload).
// Use shallowRef identity check so sort-only changes (same array reference) don't re-trigger.
const dataIdentity = computed(() => props.data)
watch(dataIdentity, () => {
if (!props.virtualScroll) {
startProgressiveReveal(sortedData.value?.length ?? 0)
}
})
const hasActionsColumn = computed(() => {
return props.columns.some(column => column.key === 'actions')
})
@@ -595,6 +711,13 @@ watch(
},
{ flush: 'post' }
)
defineExpose({
virtualizer: rowVirtualizer,
sortedData,
resolveRowKey,
tableWrapperEl: tableWrapperRef,
})
</script>
<style scoped>
@@ -606,6 +729,13 @@ watch(
isolation: isolate;
}
/* Virtual scroll mode: enable vertical scrolling, fill available height */
.table-wrapper.virtual-scroll-container {
overflow-y: auto;
flex: 1;
min-height: 0;
}
/* 表头容器,确保在滚动时覆盖表体内容 */
.table-wrapper .table-header {
position: sticky;
+136 -12
View File
@@ -1,4 +1,5 @@
import { ref, onMounted, onUnmounted, type Ref } from 'vue'
import type { Virtualizer } from '@tanstack/vue-virtual'
/**
* WeChat-style swipe/drag to select rows in a DataTable,
@@ -28,9 +29,19 @@ export interface SwipeSelectAdapter {
batchUpdate?: (updater: (draft: Set<number>) => void) => void
}
export interface SwipeSelectVirtualContext {
/** Get the virtualizer instance */
getVirtualizer: () => Virtualizer<HTMLElement, Element> | null
/** Get all sorted data */
getSortedData: () => any[]
/** Get row ID from data row */
getRowId: (row: any, index: number) => number
}
export function useSwipeSelect(
containerRef: Ref<HTMLElement | null>,
adapter: SwipeSelectAdapter
adapter: SwipeSelectAdapter,
virtualContext?: SwipeSelectVirtualContext
) {
const isDragging = ref(false)
@@ -96,6 +107,32 @@ export function useSwipeSelect(
return (clientY - rHi.bottom < rLo.top - clientY) ? hi : lo
}
/** Virtual mode: find row index from Y coordinate using virtualizer data */
function findRowIndexAtYVirtual(clientY: number): number {
const virt = virtualContext!.getVirtualizer()
if (!virt) return -1
const scrollEl = virt.scrollElement
if (!scrollEl) return -1
const scrollRect = scrollEl.getBoundingClientRect()
const thead = scrollEl.querySelector('thead')
const theadHeight = thead ? thead.getBoundingClientRect().height : 0
const contentY = clientY - scrollRect.top - theadHeight + scrollEl.scrollTop
// Search in rendered virtualItems first
const items = virt.getVirtualItems()
for (const item of items) {
if (contentY >= item.start && contentY < item.end) return item.index
}
// Outside visible range: estimate
const totalCount = virtualContext!.getSortedData().length
if (totalCount === 0) return -1
const est = virt.options.estimateSize(0)
const guess = Math.floor(contentY / est)
return Math.max(0, Math.min(totalCount - 1, guess))
}
// --- Prevent text selection via selectstart (no body style mutation) ---
function onSelectStart(e: Event) { e.preventDefault() }
@@ -170,6 +207,44 @@ export function useSwipeSelect(
lastEndIndex = endIndex
}
/** Virtual mode: apply selection range using data array instead of DOM */
function applyRangeVirtual(endIndex: number) {
if (startRowIndex < 0 || endIndex < 0) return
const rangeMin = Math.min(startRowIndex, endIndex)
const rangeMax = Math.max(startRowIndex, endIndex)
const prevMin = lastEndIndex >= 0 ? Math.min(startRowIndex, lastEndIndex) : rangeMin
const prevMax = lastEndIndex >= 0 ? Math.max(startRowIndex, lastEndIndex) : rangeMax
const lo = Math.min(rangeMin, prevMin)
const hi = Math.max(rangeMax, prevMax)
const data = virtualContext!.getSortedData()
if (adapter.batchUpdate) {
adapter.batchUpdate((draft) => {
for (let i = lo; i <= hi && i < data.length; i++) {
const id = virtualContext!.getRowId(data[i], i)
const shouldBeSelected = (i >= rangeMin && i <= rangeMax)
? (dragMode === 'select')
: (initialSelectedSnapshot.get(id) ?? false)
if (shouldBeSelected) draft.add(id)
else draft.delete(id)
}
})
} else {
for (let i = lo; i <= hi && i < data.length; i++) {
const id = virtualContext!.getRowId(data[i], i)
if (i >= rangeMin && i <= rangeMax) {
if (dragMode === 'select') adapter.select(id)
else adapter.deselect(id)
} else {
const wasSelected = initialSelectedSnapshot.get(id) ?? false
if (wasSelected) adapter.select(id)
else adapter.deselect(id)
}
}
}
lastEndIndex = endIndex
}
// --- Scrollable parent ---
function getScrollParent(el: HTMLElement): HTMLElement {
let parent = el.parentElement
@@ -249,8 +324,14 @@ export function useSwipeSelect(
if (shouldPreferNativeTextSelection(target)) return
if (shouldPreferNativeSelectionOutsideRows(target)) return
cachedRows = getDataRows()
if (cachedRows.length === 0) return
if (virtualContext) {
// Virtual mode: check data availability instead of DOM rows
const data = virtualContext.getSortedData()
if (data.length === 0) return
} else {
cachedRows = getDataRows()
if (cachedRows.length === 0) return
}
pendingStartY = e.clientY
// Prevent text selection as soon as the mouse is down,
@@ -268,13 +349,19 @@ export function useSwipeSelect(
document.removeEventListener('mousemove', onThresholdMove)
document.removeEventListener('mouseup', onThresholdUp)
beginDrag(pendingStartY)
if (virtualContext) {
beginDragVirtual(pendingStartY)
} else {
beginDrag(pendingStartY)
}
// Process the move that crossed the threshold
lastMouseY = e.clientY
updateMarquee(e.clientY)
const rowIdx = findRowIndexAtY(e.clientY)
if (rowIdx >= 0) applyRange(rowIdx)
const findIdx = virtualContext ? findRowIndexAtYVirtual : findRowIndexAtY
const apply = virtualContext ? applyRangeVirtual : applyRange
const rowIdx = findIdx(e.clientY)
if (rowIdx >= 0) apply(rowIdx)
autoScroll(e)
document.addEventListener('mousemove', onMouseMove)
@@ -321,27 +408,62 @@ export function useSwipeSelect(
window.getSelection()?.removeAllRanges()
}
/** Virtual mode: begin drag using data array */
function beginDragVirtual(clientY: number) {
startRowIndex = findRowIndexAtYVirtual(clientY)
const data = virtualContext!.getSortedData()
const startRowId = startRowIndex >= 0 && startRowIndex < data.length
? virtualContext!.getRowId(data[startRowIndex], startRowIndex)
: null
dragMode = (startRowId !== null && adapter.isSelected(startRowId)) ? 'deselect' : 'select'
// Build full snapshot from all data rows
initialSelectedSnapshot = new Map()
for (let i = 0; i < data.length; i++) {
const id = virtualContext!.getRowId(data[i], i)
initialSelectedSnapshot.set(id, adapter.isSelected(id))
}
isDragging.value = true
startY = clientY
lastMouseY = clientY
lastEndIndex = -1
// In virtual mode, scroll parent is the virtualizer's scroll element
const virt = virtualContext!.getVirtualizer()
cachedScrollParent = virt?.scrollElement ?? (containerRef.value ? getScrollParent(containerRef.value) : null)
createMarquee()
updateMarquee(clientY)
applyRangeVirtual(startRowIndex)
window.getSelection()?.removeAllRanges()
}
let moveRAF = 0
function onMouseMove(e: MouseEvent) {
if (!isDragging.value) return
lastMouseY = e.clientY
const findIdx = virtualContext ? findRowIndexAtYVirtual : findRowIndexAtY
const apply = virtualContext ? applyRangeVirtual : applyRange
cancelAnimationFrame(moveRAF)
moveRAF = requestAnimationFrame(() => {
updateMarquee(lastMouseY)
const rowIdx = findRowIndexAtY(lastMouseY)
if (rowIdx >= 0 && rowIdx !== lastEndIndex) applyRange(rowIdx)
const rowIdx = findIdx(lastMouseY)
if (rowIdx >= 0 && rowIdx !== lastEndIndex) apply(rowIdx)
})
autoScroll(e)
}
function onWheel() {
if (!isDragging.value) return
const findIdx = virtualContext ? findRowIndexAtYVirtual : findRowIndexAtY
const apply = virtualContext ? applyRangeVirtual : applyRange
// After wheel scroll, rows shift in viewport — re-check selection
requestAnimationFrame(() => {
if (!isDragging.value) return // guard: drag may have ended before this frame
const rowIdx = findRowIndexAtY(lastMouseY)
if (rowIdx >= 0) applyRange(rowIdx)
const rowIdx = findIdx(lastMouseY)
if (rowIdx >= 0) apply(rowIdx)
})
}
@@ -393,13 +515,15 @@ export function useSwipeSelect(
}
if (dy !== 0) {
const findIdx = virtualContext ? findRowIndexAtYVirtual : findRowIndexAtY
const apply = virtualContext ? applyRangeVirtual : applyRange
const step = () => {
const prevScrollTop = scrollEl.scrollTop
scrollEl.scrollTop += dy
// Only re-check selection if scroll actually moved
if (scrollEl.scrollTop !== prevScrollTop) {
const rowIdx = findRowIndexAtY(lastMouseY)
if (rowIdx >= 0 && rowIdx !== lastEndIndex) applyRange(rowIdx)
const rowIdx = findIdx(lastMouseY)
if (rowIdx >= 0 && rowIdx !== lastEndIndex) apply(rowIdx)
}
scrollRAF = requestAnimationFrame(step)
}
+13 -2
View File
@@ -134,6 +134,7 @@
<AccountBulkActionsBar :selected-ids="selIds" @delete="handleBulkDelete" @reset-status="handleBulkResetStatus" @refresh-token="handleBulkRefreshToken" @edit="showBulkEdit = true" @clear="clearSelection" @select-page="selectPage" @toggle-schedulable="handleBulkToggleSchedulable" />
<div ref="accountTableRef" class="flex min-h-0 flex-1 flex-col overflow-hidden">
<DataTable
ref="dataTableRef"
:columns="cols"
:data="accounts"
:loading="loading"
@@ -141,6 +142,9 @@
default-sort-key="name"
default-sort-order="asc"
:sort-storage-key="ACCOUNT_SORT_STORAGE_KEY"
:virtual-scroll="true"
:estimate-row-height="72"
:overscan="5"
>
<template #header-select>
<input
@@ -302,7 +306,7 @@ import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth'
import { adminAPI } from '@/api/admin'
import { useTableLoader } from '@/composables/useTableLoader'
import { useSwipeSelect } from '@/composables/useSwipeSelect'
import { useSwipeSelect, type SwipeSelectVirtualContext } from '@/composables/useSwipeSelect'
import { useTableSelection } from '@/composables/useTableSelection'
import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
@@ -339,6 +343,7 @@ const authStore = useAuthStore()
const proxies = ref<AccountProxy[]>([])
const groups = ref<AdminGroup[]>([])
const accountTableRef = ref<HTMLElement | null>(null)
const dataTableRef = ref<InstanceType<typeof DataTable> | null>(null)
const selPlatforms = computed<AccountPlatform[]>(() => {
const platforms = new Set(
accounts.value
@@ -604,12 +609,18 @@ const {
getId: (account) => account.id
})
const swipeVirtualContext: SwipeSelectVirtualContext = {
getVirtualizer: () => dataTableRef.value?.virtualizer ?? null,
getSortedData: () => dataTableRef.value?.sortedData ?? accounts.value,
getRowId: (row: any) => row.id,
}
useSwipeSelect(accountTableRef, {
isSelected,
select,
deselect,
batchUpdate
})
}, swipeVirtualContext)
const resetAutoRefreshCache = () => {
autoRefreshETag.value = null