mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-05-31 21:30:33 +08:00
perf: virtualize management lists and make drag sorting opt-in
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user