perf: virtualize management lists and make drag sorting opt-in

This commit is contained in:
jxxghp
2026-05-09 16:07:28 +08:00
parent a475085d7b
commit 96d655155a
14 changed files with 481 additions and 101 deletions

View File

@@ -25,6 +25,10 @@ const props = defineProps({
action: Boolean, // 动作标识
width: String,
height: String,
sortable: {
type: Boolean,
default: false,
},
})
// 定义触发的自定义事件
@@ -420,6 +424,7 @@ watch(
(newOpenState, _) => {
if (newOpenState) openPluginDetail()
},
{ immediate: true },
)
</script>
@@ -458,7 +463,7 @@ watch(
{{ props.plugin?.plugin_desc }}
</div>
</div>
<div class="relative flex-shrink-0 self-center pb-3" :class="{ 'cursor-move': display.mdAndUp.value }">
<div class="relative flex-shrink-0 self-center pb-3" :class="{ 'cursor-move': props.sortable && display.mdAndUp.value }">
<VAvatar size="48">
<VImg
ref="imageRef"

View File

@@ -25,6 +25,10 @@ const props = defineProps({
},
width: String,
height: String,
sortable: {
type: Boolean,
default: false,
},
})
// 定义触发的自定义事件
@@ -302,14 +306,14 @@ const dropdownItems = ref([
:icon="folderIcon"
:size="display.mobile ? 56 : 72"
:color="iconColor"
:class="{ 'cursor-move': display.mdAndUp.value }"
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value }"
/>
</div>
<!-- 文件夹信息 -->
<div
class="plugin-folder-card__info"
:class="{ 'cursor-move': display.mdAndUp.value, 'plugin-folder-card__info--no-icon': !shouldShowIcon }"
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value, 'plugin-folder-card__info--no-icon': !shouldShowIcon }"
>
<!-- 文件夹名称 -->
<h3 class="plugin-folder-card__name">

View File

@@ -14,12 +14,14 @@ interface Props {
pluginStatistics?: { [key: string]: number }
pluginActions?: { [key: string]: boolean }
showRemoveButton?: boolean
sortable?: boolean
}
const props = withDefaults(defineProps<Props>(), {
pluginStatistics: () => ({}),
pluginActions: () => ({}),
showRemoveButton: false,
sortable: false,
})
const emit = defineEmits<{
@@ -36,7 +38,7 @@ const emit = defineEmits<{
// 拖拽事件处理
function handleDragOver(event: DragEvent) {
// 只有当拖拽的是插件时才允许放入文件夹
if (props.item.type === 'folder') {
if (props.sortable && props.item.type === 'folder') {
event.preventDefault()
event.stopPropagation()
event.dataTransfer!.dropEffect = 'move'
@@ -46,14 +48,14 @@ function handleDragOver(event: DragEvent) {
}
function handleDragEnter(event: DragEvent) {
if (props.item.type === 'folder') {
if (props.sortable && props.item.type === 'folder') {
event.preventDefault()
event.stopPropagation()
}
}
function handleDragLeave(event: DragEvent) {
if (props.item.type === 'folder') {
if (props.sortable && props.item.type === 'folder') {
event.preventDefault()
event.stopPropagation()
const target = event.currentTarget as HTMLElement
@@ -62,7 +64,7 @@ function handleDragLeave(event: DragEvent) {
}
function handleDropToFolder(event: DragEvent) {
if (props.item.type === 'folder') {
if (props.sortable && props.item.type === 'folder') {
event.preventDefault()
event.stopPropagation()
const target = event.currentTarget as HTMLElement
@@ -89,6 +91,7 @@ function handleDropToFolder(event: DragEvent) {
:folder-name="item.data.name"
:plugin-count="item.data.pluginCount"
:folder-config="item.data.config"
:sortable="sortable"
@open="$emit('openFolder', item.id)"
@delete="$emit('deleteFolder', item.id)"
@rename="(oldName, newName) => $emit('renameFolder', oldName, newName)"
@@ -102,6 +105,7 @@ function handleDropToFolder(event: DragEvent) {
:count="pluginStatistics[item.id] || 0"
:plugin="item.data"
:action="pluginActions[item.id] || false"
:sortable="sortable"
@remove="$emit('refreshData')"
@save="$emit('refreshData')"
@action-done="$emit('actionDone', item.id)"

View File

@@ -12,6 +12,7 @@ import type { Site, SiteStatistic, SiteUserData } from '@/api/types'
import { isNullOrEmptyObject } from '@/@core/utils'
import { formatFileSize } from '@/@core/utils/formatters'
import { useConfirm } from '@/composables/useConfirm'
import { getCachedSiteIcon } from '@/utils/siteIconCache'
import { useDisplay } from 'vuetify'
// 显示器宽度
@@ -25,6 +26,10 @@ const cardProps = defineProps({
site: Object as PropType<Site>,
data: Object as PropType<SiteUserData>,
stats: Object as PropType<SiteStatistic>,
sortable: {
type: Boolean,
default: false,
},
})
// 定义触发的自定义事件
@@ -34,7 +39,8 @@ const emit = defineEmits(['update', 'remove', 'refresh-stats'])
const createConfirm = useConfirm()
// 图标
const siteIcon = ref<string>('')
const defaultSiteIcon = getLogoUrl('site')
const siteIcon = ref<string>(defaultSiteIcon)
// 提示框
const $toast = useToast()
@@ -59,12 +65,20 @@ const siteUserDataDialog = ref(false)
// 查询站点图标
async function getSiteIcon() {
const siteId = cardProps.site?.id
if (!siteId) {
siteIcon.value = defaultSiteIcon
return
}
try {
siteIcon.value = (await api.get(`site/icon/${cardProps.site?.id}`)).data.icon
if (!siteIcon.value) {
siteIcon.value = getLogoUrl('site')
}
siteIcon.value = await getCachedSiteIcon(siteId, async () => {
const response = await api.get(`site/icon/${siteId}`)
return response?.data?.icon || defaultSiteIcon
})
} catch (error) {
siteIcon.value = defaultSiteIcon
console.error(error)
}
}
@@ -225,7 +239,7 @@ onMounted(() => {
rounded="lg"
size="32"
class="shrink-0"
:class="{ 'cursor-move': display.mdAndUp.value }"
:class="{ 'cursor-move': cardProps.sortable && display.mdAndUp.value }"
>
<VImg :src="siteIcon" class="w-full h-full" :alt="cardProps.site?.name" cover>
<template #placeholder>

View File

@@ -29,6 +29,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
sortable: {
type: Boolean,
default: false,
},
})
// 从 provide 中获取全局设置
@@ -266,6 +270,7 @@ watch(
(newOpenState, _) => {
if (newOpenState) editSubscribeDialog()
},
{ immediate: true },
)
// 监听订阅状态
@@ -380,7 +385,7 @@ function handleCardClick() {
<div
class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md"
v-if="imageLoaded"
:class="{ 'cursor-move': display.mdAndUp.value }"
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value }"
>
<VImg :src="posterUrl" aspect-ratio="2/3" cover>
<template #placeholder>

View File

@@ -4,6 +4,8 @@ const props = withDefaults(
items: any[]
minItemWidth?: number
itemAspectRatio?: number
estimatedItemHeight?: number
scrollToIndex?: number
gap?: number
overscanRows?: number
getItemKey?: (item: any, index: number) => string | number
@@ -11,6 +13,8 @@ const props = withDefaults(
{
minItemWidth: 144,
itemAspectRatio: 1.5,
estimatedItemHeight: undefined,
scrollToIndex: undefined,
gap: 16,
overscanRows: 4,
getItemKey: undefined,
@@ -27,7 +31,8 @@ const endIndex = ref(0)
let resizeObserver: ResizeObserver | null = null
let animationFrameId: number | null = null
const rowStep = computed(() => itemHeight.value + props.gap)
const effectiveItemHeight = computed(() => props.estimatedItemHeight ?? itemHeight.value)
const rowStep = computed(() => effectiveItemHeight.value + props.gap)
const totalRows = computed(() => Math.ceil(props.items.length / columnCount.value))
const visibleItems = computed(() => props.items.slice(startIndex.value, endIndex.value))
@@ -104,7 +109,7 @@ function syncVisibleRange() {
const columns = Math.max(1, Math.floor((containerWidth + props.gap) / (props.minItemWidth + props.gap)))
columnCount.value = columns
itemWidth.value = (containerWidth - props.gap * (columns - 1)) / columns
itemHeight.value = itemWidth.value * props.itemAspectRatio
itemHeight.value = props.estimatedItemHeight ?? itemWidth.value * props.itemAspectRatio
const rowHeight = rowStep.value || 1
const containerTop = window.scrollY + container.getBoundingClientRect().top
@@ -129,6 +134,30 @@ function queueSyncVisibleRange() {
})
}
function scrollToItemIndex(index: number) {
if (typeof window === 'undefined') {
return
}
const container = containerRef.value
if (!container || props.items.length === 0 || index < 0) {
return
}
syncVisibleRange()
const safeIndex = Math.min(index, props.items.length - 1)
const targetRow = Math.floor(safeIndex / columnCount.value)
const targetTop = window.scrollY + container.getBoundingClientRect().top + targetRow * rowStep.value
window.scrollTo({
top: Math.max(targetTop - props.gap, 0),
behavior: 'auto',
})
queueSyncVisibleRange()
}
onMounted(() => {
queueSyncVisibleRange()
window.addEventListener('scroll', queueSyncVisibleRange, { passive: true })
@@ -157,7 +186,13 @@ onUnmounted(() => {
})
watch(
() => props.items.length,
[
() => props.items.length,
() => props.minItemWidth,
() => props.itemAspectRatio,
() => props.estimatedItemHeight,
() => props.gap,
],
() => {
nextTick(() => {
queueSyncVisibleRange()
@@ -165,6 +200,20 @@ watch(
},
{ immediate: true },
)
watch(
[() => props.scrollToIndex, () => props.items.length],
([scrollToIndex]) => {
if (scrollToIndex === undefined || scrollToIndex < 0) {
return
}
nextTick(() => {
scrollToItemIndex(scrollToIndex)
})
},
{ immediate: true },
)
</script>
<template>