Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
71942fd3
Unverified
Commit
71942fd3
authored
Mar 19, 2026
by
Wesley Liddick
Committed by
GitHub
Mar 19, 2026
Browse files
Merge pull request #1132 from touwaeriol/pr/virtual-scroll
perf(frontend): add virtual scrolling to DataTable
parents
550b979a
a20c2111
Changes
4
Hide whitespace changes
Inline
Side-by-side
frontend/package.json
View file @
71942fd3
...
...
@@ -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"
,
...
...
frontend/pnpm-lock.yaml
View file @
71942fd3
...
...
@@ -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'
:
...
...
frontend/src/components/common/DataTable.vue
View file @
71942fd3
...
...
@@ -147,28 +147,46 @@
</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 (virtual scroll) -->
<
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"
>
<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="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 +194,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
'
...
...
@@ -299,6 +318,10 @@ interface Props {
* will emit 'sort' events instead of performing client-side sorting.
*/
serverSideSort
?:
boolean
/** Estimated row height in px for the virtualizer (default 56) */
estimateRowHeight
?:
number
/** Number of rows to render beyond the visible area (default 5) */
overscan
?:
number
}
const
props
=
withDefaults
(
defineProps
<
Props
>
(),
{
...
...
@@ -499,6 +522,33 @@ const sortedData = computed(() => {
.
map
(
item
=>
item
.
row
)
})
// --- Virtual scrolling ---
const
rowVirtualizer
=
useVirtualizer
(
computed
(()
=>
({
count
:
sortedData
.
value
?.
length
??
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
)
{
rowVirtualizer
.
value
.
measureElement
(
el
as
Element
)
}
}
const
hasActionsColumn
=
computed
(()
=>
{
return
props
.
columns
.
some
(
column
=>
column
.
key
===
'
actions
'
)
})
...
...
@@ -595,6 +645,13 @@ watch(
},
{
flush
:
'
post
'
}
)
defineExpose
({
virtualizer
:
rowVirtualizer
,
sortedData
,
resolveRowKey
,
tableWrapperEl
:
tableWrapperRef
,
})
</
script
>
<
style
scoped
>
...
...
@@ -603,6 +660,9 @@ watch(
--select-col-width
:
52px
;
/* 勾选列宽度:px-6 (24px*2) + checkbox (16px) */
position
:
relative
;
overflow-x
:
auto
;
overflow-y
:
auto
;
flex
:
1
;
min-height
:
0
;
isolation
:
isolate
;
}
...
...
frontend/src/composables/useSwipeSelect.ts
View file @
71942fd3
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,
...
...
@@ -25,11 +26,22 @@ export interface SwipeSelectAdapter {
isSelected
:
(
id
:
number
)
=>
boolean
select
:
(
id
:
number
)
=>
void
deselect
:
(
id
:
number
)
=>
void
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
)
...
...
@@ -95,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
()
}
...
...
@@ -140,16 +178,68 @@ export function useSwipeSelect(
const
lo
=
Math
.
min
(
rangeMin
,
prevMin
)
const
hi
=
Math
.
max
(
rangeMax
,
prevMax
)
for
(
let
i
=
lo
;
i
<=
hi
&&
i
<
cachedRows
.
length
;
i
++
)
{
const
id
=
getRowId
(
cachedRows
[
i
])
if
(
id
===
null
)
continue
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
)
if
(
adapter
.
batchUpdate
)
{
adapter
.
batchUpdate
((
draft
)
=>
{
for
(
let
i
=
lo
;
i
<=
hi
&&
i
<
cachedRows
.
length
;
i
++
)
{
const
id
=
getRowId
(
cachedRows
[
i
])
if
(
id
===
null
)
continue
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
<
cachedRows
.
length
;
i
++
)
{
const
id
=
getRowId
(
cachedRows
[
i
])
if
(
id
===
null
)
continue
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
}
/** 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
...
...
@@ -234,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,
...
...
@@ -253,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
)
...
...
@@ -306,22 +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
updateMarquee
(
e
.
clientY
)
const
rowIdx
=
findRowIndexAtY
(
e
.
clientY
)
if
(
rowIdx
>=
0
&&
rowIdx
!==
lastEndIndex
)
applyRange
(
rowIdx
)
const
findIdx
=
virtualContext
?
findRowIndexAtYVirtual
:
findRowIndexAtY
const
apply
=
virtualContext
?
applyRangeVirtual
:
applyRange
cancelAnimationFrame
(
moveRAF
)
moveRAF
=
requestAnimationFrame
(()
=>
{
updateMarquee
(
lastMouseY
)
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
=
find
RowIndexAtY
(
lastMouseY
)
if
(
rowIdx
>=
0
)
apply
Range
(
rowIdx
)
const
rowIdx
=
find
Idx
(
lastMouseY
)
if
(
rowIdx
>=
0
)
apply
(
rowIdx
)
})
}
...
...
@@ -332,6 +474,7 @@ export function useSwipeSelect(
cachedRows
=
[]
initialSelectedSnapshot
.
clear
()
cachedScrollParent
=
null
cancelAnimationFrame
(
moveRAF
)
stopAutoScroll
()
removeMarquee
()
document
.
removeEventListener
(
'
selectstart
'
,
onSelectStart
)
...
...
@@ -372,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
=
find
RowIndexAtY
(
lastMouseY
)
if
(
rowIdx
>=
0
&&
rowIdx
!==
lastEndIndex
)
apply
Range
(
rowIdx
)
const
rowIdx
=
find
Idx
(
lastMouseY
)
if
(
rowIdx
>=
0
&&
rowIdx
!==
lastEndIndex
)
apply
(
rowIdx
)
}
scrollRAF
=
requestAnimationFrame
(
step
)
}
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment