mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
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:
@@ -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",
|
||||
|
||||
Generated
+18
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user