mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-06-21 15:43:51 +08:00
Feat/virtualizarefactor: virtualization rework — unify Virtual components, fix memory leaks, migrate 15+ consumerstion rework (#472)
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import noImage from '@images/no-image.jpeg'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import axios from 'axios'
|
||||
import api from '@/api'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { formatSeason, formatRating } from '@/@core/utils/formatters'
|
||||
@@ -256,6 +257,8 @@ async function handleCheckSubscribe() {
|
||||
)
|
||||
isSubscribed.value = subscribed
|
||||
} catch (error) {
|
||||
// 路由切换会主动 abort 在途请求,cancel 不是错误,静默忽略
|
||||
if (axios.isCancel(error)) return
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
@@ -280,6 +283,8 @@ async function handleCheckExists() {
|
||||
isExists.value = exists
|
||||
setCachedMediaExistsStatus(getExistsStatusKey(), exists)
|
||||
} catch (error) {
|
||||
// 路由切换会主动 abort 在途请求,cancel 不是错误,静默忽略
|
||||
if (axios.isCancel(error)) return
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
@@ -434,17 +439,36 @@ function setupIntersectionObserver() {
|
||||
}
|
||||
}
|
||||
|
||||
// 计算图片地址
|
||||
const getImgUrl: Ref<string> = computed(() => {
|
||||
if (imageLoadError.value) return noImage
|
||||
const url = props.media?.poster_path?.replace('original', 'w500') ?? noImage
|
||||
// 使用图片缓存
|
||||
// 包装 URL:根据全局开关走系统图片缓存或 douban 代理
|
||||
function wrapPosterUrl(url: string): string {
|
||||
if (globalSettings.GLOBAL_IMAGE_CACHE)
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
|
||||
// 如果地址中包含douban则使用中转代理
|
||||
if (url.includes('doubanio.com'))
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
|
||||
return url
|
||||
}
|
||||
|
||||
// 计算图片地址
|
||||
const getImgUrl: Ref<string> = computed(() => {
|
||||
if (imageLoadError.value) return noImage
|
||||
// 卡片在网格中显示宽度 ~150-220px,w342 在 2x DPR 下足够清晰,
|
||||
// 比 w500 减少约 53% 的解码位图内存
|
||||
const url = props.media?.poster_path?.replace('original', 'w342')
|
||||
if (!url) return noImage
|
||||
return wrapPosterUrl(url)
|
||||
})
|
||||
|
||||
// 计算低画质占位图(LQIP)— TMDB 自带 w92 缩略图 ~5KB,
|
||||
// 公网/慢源场景先秒铺 w92 模糊预览,再淡入 w342 清晰图。
|
||||
// 当 poster_path 不包含 'original' 串(如 Douban)时 replace 退化为 no-op,
|
||||
// 此时返回空串:VImg 不要设 lazy-src(避免重复请求同一 URL),
|
||||
// 占位回退到 #placeholder 槽里的 skeleton。
|
||||
const getLqipUrl: Ref<string> = computed(() => {
|
||||
const path = props.media?.poster_path
|
||||
if (!path) return ''
|
||||
const lqip = path.replace('original', 'w92')
|
||||
if (lqip === path) return ''
|
||||
return wrapPosterUrl(lqip)
|
||||
})
|
||||
|
||||
// 移除订阅
|
||||
@@ -466,6 +490,13 @@ onBeforeUnmount(() => {
|
||||
observer.value?.disconnect()
|
||||
observer.value = null
|
||||
})
|
||||
|
||||
// keep-alive 缓存场景下 onBeforeUnmount 不会触发,需要在 onDeactivated 主动 disconnect
|
||||
// 防止冻结的卡片继续持有 observer 引用
|
||||
onDeactivated(() => {
|
||||
observer.value?.disconnect()
|
||||
observer.value = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -483,15 +514,22 @@ onBeforeUnmount(() => {
|
||||
}"
|
||||
@click.stop="goMediaDetail(hover.isHovering ?? false)"
|
||||
>
|
||||
<!--
|
||||
lazy-src 和 #placeholder 不能同时给 VImg:
|
||||
placeholder 槽里的 skeleton 会被 Vuetify 叠在 lazy-src 上层渲染,
|
||||
主图加载期间 skeleton 把 LQIP 模糊预览盖成灰屏。
|
||||
有 lazy-src 就让它自己当占位;无 lazy-src(Douban)才走 skeleton。
|
||||
-->
|
||||
<VImg
|
||||
aspect-ratio="2/3"
|
||||
:src="getImgUrl"
|
||||
:lazy-src="getLqipUrl || undefined"
|
||||
class="object-cover aspect-w-2 aspect-h-3"
|
||||
cover
|
||||
@load="isImageLoaded = true"
|
||||
@error="imageLoadError = true"
|
||||
>
|
||||
<template #placeholder>
|
||||
<template v-if="!getLqipUrl" #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
</div>
|
||||
@@ -593,6 +631,17 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
</template>
|
||||
<style scoped>
|
||||
.media-card {
|
||||
/*
|
||||
告诉浏览器:当本卡片远离视口时跳过 paint 和图片解码。
|
||||
被跳过期间浏览器可以丢弃解码后的 bitmap(图片缓存大头);
|
||||
重新进入视口时按 contain-intrinsic-size 撑出位置后再 paint。
|
||||
auto 关键字让首次 paint 后记忆实际尺寸,避免滚动条跳动。
|
||||
*/
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 280px;
|
||||
}
|
||||
|
||||
.media-card-title {
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.25rem;
|
||||
|
||||
@@ -26,7 +26,9 @@ function getPersonImage() {
|
||||
let url = ''
|
||||
if (personProps.person?.source === 'themoviedb') {
|
||||
if (!personInfo.value?.profile_path) return personIcon
|
||||
url = `https://${globalSettings.TMDB_IMAGE_DOMAIN}/t/p/w600_and_h900_bestv2${personInfo.value?.profile_path}`
|
||||
// 头像在 VAvatar size=100 内裁剪显示,w185 已覆盖到 3x DPR,
|
||||
// 比原来的 w600_and_h900_bestv2 减少约 90% 的解码位图内存
|
||||
url = `https://${globalSettings.TMDB_IMAGE_DOMAIN}/t/p/w185${personInfo.value?.profile_path}`
|
||||
} else if (personProps.person?.source === 'douban') {
|
||||
if (!personInfo.value?.avatar) return personIcon
|
||||
if (typeof personInfo.value?.avatar === 'object') {
|
||||
|
||||
@@ -62,11 +62,21 @@ const releaseDialog = ref(false)
|
||||
const detailDialog = ref(false)
|
||||
|
||||
// 图片加载完成
|
||||
async function imageLoaded() {
|
||||
function imageLoaded() {
|
||||
isImageLoaded.value = true
|
||||
const imageElement = imageRef.value?.$el.querySelector('img') as HTMLImageElement
|
||||
// 从图片中提取背景色
|
||||
backgroundColor.value = await getDominantColor(imageElement)
|
||||
const imageElement = imageRef.value?.$el?.querySelector('img') as HTMLImageElement | null
|
||||
if (!imageElement) return
|
||||
// 主色调提取(ColorThief 走 canvas 解码)会阻塞主线程,
|
||||
// 滚动时挤掉浏览器自身的图片解码窗口,放到 idle 中执行更安全。
|
||||
const extract = () => {
|
||||
getDominantColor(imageElement)
|
||||
.then(c => {
|
||||
backgroundColor.value = c
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
if (typeof requestIdleCallback === 'function') requestIdleCallback(extract, { timeout: 1500 })
|
||||
else setTimeout(extract, 50)
|
||||
}
|
||||
|
||||
// 安装插件
|
||||
|
||||
@@ -108,11 +108,20 @@ watch(
|
||||
)
|
||||
|
||||
// 图片加载完成
|
||||
async function imageLoaded() {
|
||||
function imageLoaded() {
|
||||
isImageLoaded.value = true
|
||||
const imageElement = imageRef.value?.$el.querySelector('img') as HTMLImageElement
|
||||
// 从图片中提取背景色
|
||||
backgroundColor.value = await getDominantColor(imageElement)
|
||||
const imageElement = imageRef.value?.$el?.querySelector('img') as HTMLImageElement | null
|
||||
if (!imageElement) return
|
||||
// ColorThief 走 canvas 解码会阻塞主线程,放到 idle 中执行
|
||||
const extract = () => {
|
||||
getDominantColor(imageElement)
|
||||
.then(c => {
|
||||
backgroundColor.value = c
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
if (typeof requestIdleCallback === 'function') requestIdleCallback(extract, { timeout: 1500 })
|
||||
else setTimeout(extract, 50)
|
||||
}
|
||||
|
||||
// 显示更新日志
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { formatDateDifference } from '@/@core/utils/formatters'
|
||||
import { tmdbResize } from '@/@core/utils/image'
|
||||
import type { SubscribeShare } from '@/api/types'
|
||||
import router from '@/router'
|
||||
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
|
||||
@@ -39,19 +40,19 @@ function imageLoadHandler() {
|
||||
// 分享时间
|
||||
const dateText = ref(props.media && props.media?.date ? formatDateDifference(props.media.date) : '')
|
||||
|
||||
// 计算backdrop图片地址
|
||||
// 计算backdrop图片地址(卡片 backdrop 宽度 ~400-600px,w780 在 TMDB 上足够覆盖 2x DPR)
|
||||
const backdropUrl = computed(() => {
|
||||
const url = props.media?.backdrop || props.media?.poster
|
||||
// 使用图片缓存
|
||||
const raw = props.media?.backdrop || props.media?.poster
|
||||
const url = tmdbResize(raw, 'w780') || raw
|
||||
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
|
||||
return url
|
||||
})
|
||||
|
||||
// 计算海报图片地址
|
||||
// 计算海报图片地址(缩略 64×96 显示,w185 已覆盖 3x DPR)
|
||||
const posterUrl = computed(() => {
|
||||
const url = props.media?.poster
|
||||
// 使用图片缓存
|
||||
const raw = props.media?.poster
|
||||
const url = tmdbResize(raw, 'w185') || raw
|
||||
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
|
||||
return url
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Site, TorrentInfo, SiteCategory } from '@/api/types'
|
||||
import { formatFileSize } from '@core/utils/formatters'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
|
||||
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||
import VirtualList from '@/components/virtual/VirtualList.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// 国际化
|
||||
@@ -471,15 +471,14 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<div v-else-if="mobileResourceList.length > 0" class="site-resource-mobile__list px-3 pb-4">
|
||||
<ProgressiveCardGrid
|
||||
<VirtualList
|
||||
:items="mobileResourceList"
|
||||
:columns="1"
|
||||
:gap="12"
|
||||
:estimated-item-height="320"
|
||||
:overscan-rows="5"
|
||||
:estimate-size="320"
|
||||
:overscan="5"
|
||||
:get-item-key="getResourceItemKey"
|
||||
container-height="100%"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<template #item="{ item }">
|
||||
<VCard>
|
||||
<VCardText class="pa-4">
|
||||
<button type="button" class="site-resource-title-btn text-start" @click="addDownload(item)">
|
||||
@@ -578,7 +577,7 @@ onMounted(() => {
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</template>
|
||||
</ProgressiveCardGrid>
|
||||
</VirtualList>
|
||||
</div>
|
||||
|
||||
<div v-else class="px-4 py-10 text-center text-medium-emphasis">
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useDisplay } from 'vuetify'
|
||||
import ProgressDialog from './ProgressDialog.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { mediaTypeDict } from '@/api/constants'
|
||||
import VirtualList from '@/components/virtual/VirtualList.vue'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -24,9 +25,6 @@ const emit = defineEmits(['close', 'save'])
|
||||
// 订阅历史列表
|
||||
const historyList = ref<Subscribe[]>([])
|
||||
|
||||
// 当前加载数据
|
||||
const currData = ref<Subscribe[]>([])
|
||||
|
||||
// 当前页
|
||||
const currentPage = ref(1)
|
||||
|
||||
@@ -36,6 +34,9 @@ const pageSize = ref(30)
|
||||
// 是否加载中
|
||||
const loading = ref(false)
|
||||
|
||||
// 是否还有更多数据
|
||||
const hasMore = ref(true)
|
||||
|
||||
// 是否加载完成
|
||||
const isRefreshed = ref(false)
|
||||
|
||||
@@ -45,41 +46,31 @@ const progressDialog = ref(false)
|
||||
// 进度文字
|
||||
const progressText = ref('')
|
||||
|
||||
// 调用API查询列表
|
||||
async function loadHistory({ done }: { done: any }) {
|
||||
// 如果正在加载中,直接返回
|
||||
if (loading.value) {
|
||||
done('ok')
|
||||
return
|
||||
}
|
||||
// VirtualList ref(泛型组件无法用 InstanceType 表达,用 any)
|
||||
const listRef = ref<any>(null)
|
||||
|
||||
// 调用API查询列表
|
||||
// 调用API查询列表(VirtualList @load-more 触发)
|
||||
async function loadHistory() {
|
||||
if (loading.value || !hasMore.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
// 设置加载中
|
||||
loading.value = true
|
||||
currData.value = await api.get(`subscribe/history/${props.type}`, {
|
||||
const data: Subscribe[] = await api.get(`subscribe/history/${props.type}`, {
|
||||
params: {
|
||||
page: currentPage.value,
|
||||
count: pageSize.value,
|
||||
},
|
||||
})
|
||||
// 标计为已请求完成
|
||||
isRefreshed.value = true
|
||||
if (currData.value.length === 0) {
|
||||
// 如果没有数据,跳出
|
||||
done('empty')
|
||||
if (!data || data.length === 0) {
|
||||
hasMore.value = false
|
||||
} else {
|
||||
// 合并数据
|
||||
historyList.value = [...historyList.value, ...currData.value]
|
||||
// 页码+1
|
||||
historyList.value.push(...data)
|
||||
currentPage.value++
|
||||
// 返回加载成功
|
||||
done('ok')
|
||||
// 如果服务端返回不足一页,认为没有更多
|
||||
if (data.length < pageSize.value) hasMore.value = false
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
// 返回加载失败
|
||||
done('error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -143,6 +134,11 @@ function getMediaTypeText(type: string | undefined) {
|
||||
if (!type) return ''
|
||||
return mediaTypeDict[type]
|
||||
}
|
||||
|
||||
// 初始加载
|
||||
onMounted(() => {
|
||||
void loadHistory()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -154,67 +150,71 @@ function getMediaTypeText(type: string | undefined) {
|
||||
<VDivider />
|
||||
<VDialogCloseBtn @click="emit('close')" />
|
||||
<VList lines="two" class="flex-grow-1 min-h-0 py-0">
|
||||
<VInfiniteScroll mode="intersect" side="end" :items="historyList" class="h-100" @load="loadHistory">
|
||||
<template #loading>
|
||||
<LoadingBanner />
|
||||
</template>
|
||||
<template #empty />
|
||||
<VVirtualScroll v-if="historyList.length > 0" renderless :items="historyList" :item-height="104">
|
||||
<template #default="{ item, itemRef }">
|
||||
<div :ref="itemRef">
|
||||
<VListItem>
|
||||
<template #prepend>
|
||||
<VImg
|
||||
height="75"
|
||||
width="50"
|
||||
:src="item.poster"
|
||||
aspect-ratio="2/3"
|
||||
class="object-cover rounded ring-gray-500 me-3"
|
||||
cover
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</template>
|
||||
<VListItemTitle v-if="item.type == '电视剧'">
|
||||
{{ item.name }}
|
||||
<span class="text-sm">{{ t('dialog.subscribeHistory.season', { season: item.season }) }}</span>
|
||||
</VListItemTitle>
|
||||
<VListItemTitle v-else>
|
||||
{{ item.name }}
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle class="mt-2">{{ formatDateDifference(item.date) }}</VListItemSubtitle>
|
||||
<VListItemSubtitle class="mt-2">{{ item.description }}</VListItemSubtitle>
|
||||
<template #append>
|
||||
<div class="me-n3">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="(menu, i) in dropdownItems"
|
||||
:key="i"
|
||||
:base-color="menu.color"
|
||||
@click="menu.props.click(item)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="menu.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="menu.title" />
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
<VirtualList
|
||||
ref="listRef"
|
||||
:items="historyList"
|
||||
:estimate-size="104"
|
||||
:overscan="6"
|
||||
key-field="id"
|
||||
container-height="60vh"
|
||||
:load-more-threshold="5"
|
||||
@load-more="loadHistory"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<VListItem>
|
||||
<template #prepend>
|
||||
<VImg
|
||||
height="75"
|
||||
width="50"
|
||||
:src="item.poster"
|
||||
aspect-ratio="2/3"
|
||||
class="object-cover rounded ring-gray-500 me-3"
|
||||
cover
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||
</div>
|
||||
</template>
|
||||
</VListItem>
|
||||
</div>
|
||||
</template>
|
||||
</VVirtualScroll>
|
||||
</VInfiniteScroll>
|
||||
</VImg>
|
||||
</template>
|
||||
<VListItemTitle v-if="item.type == '电视剧'">
|
||||
{{ item.name }}
|
||||
<span class="text-sm">{{ t('dialog.subscribeHistory.season', { season: item.season }) }}</span>
|
||||
</VListItemTitle>
|
||||
<VListItemTitle v-else>
|
||||
{{ item.name }}
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle class="mt-2">{{ formatDateDifference(item.date) }}</VListItemSubtitle>
|
||||
<VListItemSubtitle class="mt-2">{{ item.description }}</VListItemSubtitle>
|
||||
<template #append>
|
||||
<div class="me-n3">
|
||||
<IconBtn>
|
||||
<VIcon icon="mdi-dots-vertical" />
|
||||
<VMenu activator="parent" close-on-content-click>
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="(menu, i) in dropdownItems"
|
||||
:key="i"
|
||||
:base-color="menu.color"
|
||||
@click="menu.props.click(item)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon :icon="menu.props.prependIcon" />
|
||||
</template>
|
||||
<VListItemTitle v-text="menu.title" />
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
</template>
|
||||
</VListItem>
|
||||
</template>
|
||||
<template #loading>
|
||||
<LoadingBanner v-if="loading" />
|
||||
</template>
|
||||
</VirtualList>
|
||||
</VList>
|
||||
<VCardText v-if="historyList.length === 0 && isRefreshed" class="text-center">{{
|
||||
t('dialog.subscribeHistory.noData')
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import { useAvailableHeight } from '@/composables/useAvailableHeight'
|
||||
import VirtualList from '@/components/virtual/VirtualList.vue'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -756,8 +757,13 @@ onUnmounted(() => {
|
||||
class="text-high-emphasis file-list-container"
|
||||
:style="{ height: `${listAvailableHeight}px`, maxHeight: `${listAvailableHeight}px` }"
|
||||
>
|
||||
<VVirtualScroll :items="displayItems" style="block-size: 100%">
|
||||
<template #default="{ item }">
|
||||
<VirtualList
|
||||
:items="displayItems"
|
||||
:estimate-size="56"
|
||||
:overscan="8"
|
||||
:container-height="`${listAvailableHeight}px`"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<VListItem v-bind="hover.props" class="px-3 pe-1" @click="listItemClick(item)">
|
||||
@@ -824,7 +830,7 @@ onUnmounted(() => {
|
||||
</template>
|
||||
</VHover>
|
||||
</template>
|
||||
</VVirtualScroll>
|
||||
</VirtualList>
|
||||
</VList>
|
||||
</VCardText>
|
||||
<VCardText v-else-if="filter" class="grow d-flex justify-center align-center grey--text py-5">
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { AxiosRequestConfig, AxiosInstance } from 'axios'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import { useAvailableHeight } from '@/composables/useAvailableHeight'
|
||||
import VirtualList from '@/components/virtual/VirtualList.vue'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
@@ -254,11 +255,16 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<VCard class="file-navigator rounded-e-0 rounded-t-0" v-if="!isMobile" :height="`${availableHeight}px`">
|
||||
<VVirtualScroll :items="visibleTreeRows" :item-height="32" class="tree-container">
|
||||
<template #default="{ item }">
|
||||
<VirtualList
|
||||
:items="visibleTreeRows"
|
||||
:estimate-size="32"
|
||||
key-field="key"
|
||||
container-height="100%"
|
||||
class="tree-container"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<div
|
||||
v-if="item.type === 'root'"
|
||||
:key="item.key"
|
||||
class="tree-item root-item"
|
||||
:class="{ 'active': currentPath === '/' }"
|
||||
@click="
|
||||
@@ -278,7 +284,6 @@ onMounted(async () => {
|
||||
|
||||
<div
|
||||
v-else-if="item.type === 'loading'"
|
||||
:key="item.key"
|
||||
class="tree-loading"
|
||||
:style="getTreeRowStyle(item.level)"
|
||||
>
|
||||
@@ -290,7 +295,6 @@ onMounted(async () => {
|
||||
|
||||
<div
|
||||
v-else
|
||||
:key="item.key"
|
||||
class="tree-item"
|
||||
:class="{ 'active': currentPath === item.dir.path }"
|
||||
:style="getTreeRowStyle(item.level)"
|
||||
@@ -322,7 +326,7 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</VVirtualScroll>
|
||||
</VirtualList>
|
||||
</VCard>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,932 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { ComponentPublicInstance } from 'vue'
|
||||
|
||||
type ItemKey = string | number
|
||||
type ScrollTarget = Window | HTMLElement
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
items: any[]
|
||||
minItemWidth?: number
|
||||
itemAspectRatio?: number
|
||||
estimatedItemHeight?: number
|
||||
scrollToIndex?: number
|
||||
gap?: number
|
||||
columns?: number
|
||||
initialCount?: number
|
||||
batchSize?: number
|
||||
overscanRows?: number
|
||||
getItemKey?: (item: any, index: number) => string | number
|
||||
}>(),
|
||||
{
|
||||
minItemWidth: 144,
|
||||
itemAspectRatio: 1.5,
|
||||
estimatedItemHeight: undefined,
|
||||
scrollToIndex: undefined,
|
||||
gap: 16,
|
||||
columns: undefined,
|
||||
initialCount: 24,
|
||||
batchSize: 24,
|
||||
overscanRows: 4,
|
||||
getItemKey: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
interface VirtualCell {
|
||||
item: any
|
||||
index: number
|
||||
key: ItemKey
|
||||
}
|
||||
|
||||
interface VirtualRange {
|
||||
endIndex: number
|
||||
endRow: number
|
||||
startIndex: number
|
||||
startRow: number
|
||||
}
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const trackRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const layoutWidth = ref(0)
|
||||
const viewportTop = ref(0)
|
||||
const viewportBottom = ref(0)
|
||||
const heightVersion = ref(0)
|
||||
const frozenVisibleRange = ref<VirtualRange | null>(null)
|
||||
const isOverlayGrid = ref(false)
|
||||
|
||||
const itemHeights = new Map<ItemKey, number>()
|
||||
const observedElements = new Map<HTMLElement, ItemKey>()
|
||||
const keyElements = new Map<ItemKey, HTMLElement>()
|
||||
const itemRefCallbacks = new Map<ItemKey, (element: Element | ComponentPublicInstance | null) => void>()
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let itemResizeObserver: ResizeObserver | null = null
|
||||
let overlayLockObserver: MutationObserver | null = null
|
||||
let scrollTarget: ScrollTarget | null = null
|
||||
let layoutFrameId: number | null = null
|
||||
let scrollFrameId: number | null = null
|
||||
let mounted = false
|
||||
let pendingRevealIndex: number | null = null
|
||||
let lastMeasuredColumnCount = 0
|
||||
let lastMeasuredColumnWidth = 0
|
||||
|
||||
const safeGap = computed(() => Math.max(0, props.gap))
|
||||
const safeMinItemWidth = computed(() => Math.max(1, props.minItemWidth))
|
||||
const safeOverscanRows = computed(() => Math.max(1, props.overscanRows))
|
||||
|
||||
const columnCount = computed(() => {
|
||||
if (props.columns && props.columns > 0) {
|
||||
return Math.max(1, Math.floor(props.columns))
|
||||
}
|
||||
|
||||
if (!layoutWidth.value) {
|
||||
return 1
|
||||
}
|
||||
|
||||
return Math.max(1, Math.floor((layoutWidth.value + safeGap.value) / (safeMinItemWidth.value + safeGap.value)))
|
||||
})
|
||||
|
||||
const columnWidth = computed(() => {
|
||||
const columns = columnCount.value
|
||||
const width = layoutWidth.value || safeMinItemWidth.value
|
||||
|
||||
return Math.max(1, (width - safeGap.value * (columns - 1)) / columns)
|
||||
})
|
||||
|
||||
const estimatedHeight = computed(() => {
|
||||
if (props.estimatedItemHeight && props.estimatedItemHeight > 0) {
|
||||
return props.estimatedItemHeight
|
||||
}
|
||||
|
||||
return Math.max(1, columnWidth.value * props.itemAspectRatio)
|
||||
})
|
||||
|
||||
const itemKeys = computed(() => props.items.map((item, index) => getComparableKey(item, index)))
|
||||
|
||||
const keyIndexMap = computed(() => {
|
||||
const map = new Map<ItemKey, number>()
|
||||
|
||||
itemKeys.value.forEach((key, index) => {
|
||||
map.set(key, index)
|
||||
})
|
||||
|
||||
return map
|
||||
})
|
||||
|
||||
const rowMetrics = computed(() => {
|
||||
heightVersion.value
|
||||
|
||||
const rows = Math.ceil(props.items.length / columnCount.value)
|
||||
const heights: number[] = []
|
||||
const measuredRows: boolean[] = []
|
||||
const offsets: number[] = [0]
|
||||
|
||||
for (let row = 0; row < rows; row += 1) {
|
||||
const startIndex = row * columnCount.value
|
||||
const endIndex = Math.min(startIndex + columnCount.value, props.items.length)
|
||||
let rowHeight = 0
|
||||
let hasUnmeasuredItem = false
|
||||
|
||||
for (let index = startIndex; index < endIndex; index += 1) {
|
||||
const height = itemHeights.get(itemKeys.value[index])
|
||||
if (height && height > 0) {
|
||||
rowHeight = Math.max(rowHeight, height)
|
||||
} else {
|
||||
hasUnmeasuredItem = true
|
||||
}
|
||||
}
|
||||
|
||||
if (hasUnmeasuredItem) {
|
||||
rowHeight = Math.max(rowHeight, estimatedHeight.value)
|
||||
} else {
|
||||
rowHeight = Math.max(rowHeight, 1)
|
||||
}
|
||||
|
||||
heights.push(rowHeight)
|
||||
measuredRows.push(!hasUnmeasuredItem)
|
||||
offsets.push(offsets[row] + rowHeight + (row < rows - 1 ? safeGap.value : 0))
|
||||
}
|
||||
|
||||
return {
|
||||
heights,
|
||||
measuredRows,
|
||||
offsets,
|
||||
rowCount: rows,
|
||||
totalHeight: offsets[rows] ?? 0,
|
||||
}
|
||||
})
|
||||
|
||||
const totalHeight = computed(() => rowMetrics.value.totalHeight)
|
||||
|
||||
const calculatedVisibleRange = computed<VirtualRange>(() => {
|
||||
if (isOverlayGrid.value) {
|
||||
const rowCount = Math.max(1, Math.ceil(props.items.length / columnCount.value))
|
||||
|
||||
return {
|
||||
endIndex: props.items.length,
|
||||
endRow: rowCount - 1,
|
||||
startIndex: 0,
|
||||
startRow: 0,
|
||||
}
|
||||
}
|
||||
|
||||
const { heights, offsets, rowCount } = rowMetrics.value
|
||||
|
||||
if (!props.items.length || rowCount === 0) {
|
||||
return {
|
||||
endIndex: 0,
|
||||
endRow: 0,
|
||||
startIndex: 0,
|
||||
startRow: 0,
|
||||
}
|
||||
}
|
||||
|
||||
const top = Math.max(0, Math.min(viewportTop.value, totalHeight.value))
|
||||
const bottom = Math.max(top, Math.min(viewportBottom.value, totalHeight.value))
|
||||
const firstVisibleRow = findFirstRowAtOrAfterOffset(offsets, heights, top)
|
||||
const lastVisibleRow = findLastRowAtOrBeforeOffset(offsets, rowCount, bottom)
|
||||
const startRow = clamp(firstVisibleRow - safeOverscanRows.value, 0, rowCount - 1)
|
||||
const endRow = clamp(lastVisibleRow + safeOverscanRows.value, startRow, rowCount - 1)
|
||||
|
||||
return {
|
||||
endIndex: Math.min(props.items.length, (endRow + 1) * columnCount.value),
|
||||
endRow,
|
||||
startIndex: startRow * columnCount.value,
|
||||
startRow,
|
||||
}
|
||||
})
|
||||
|
||||
const visibleRange = computed(() => frozenVisibleRange.value ?? calculatedVisibleRange.value)
|
||||
|
||||
const visibleCells = computed<VirtualCell[]>(() => {
|
||||
const cells: VirtualCell[] = []
|
||||
|
||||
for (let index = visibleRange.value.startIndex; index < visibleRange.value.endIndex; index += 1) {
|
||||
cells.push({
|
||||
item: props.items[index],
|
||||
index,
|
||||
key: itemKeys.value[index],
|
||||
})
|
||||
}
|
||||
|
||||
return cells
|
||||
})
|
||||
|
||||
const topSpacerHeight = computed(() => {
|
||||
if (isOverlayGrid.value) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return rowMetrics.value.offsets[visibleRange.value.startRow] ?? 0
|
||||
})
|
||||
|
||||
const visibleBlockHeight = computed(() => {
|
||||
if (!props.items.length || visibleRange.value.endIndex <= visibleRange.value.startIndex) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return Math.max(
|
||||
(rowMetrics.value.offsets[visibleRange.value.endRow] ?? 0) +
|
||||
(rowMetrics.value.heights[visibleRange.value.endRow] ?? 0) -
|
||||
(rowMetrics.value.offsets[visibleRange.value.startRow] ?? 0),
|
||||
0,
|
||||
)
|
||||
})
|
||||
|
||||
const bottomSpacerHeight = computed(() => {
|
||||
if (isOverlayGrid.value) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return Math.max(totalHeight.value - topSpacerHeight.value - visibleBlockHeight.value, 0)
|
||||
})
|
||||
|
||||
const gridStyle = computed(() => ({
|
||||
columnGap: `${safeGap.value}px`,
|
||||
gridTemplateColumns: `repeat(${columnCount.value}, minmax(0, 1fr))`,
|
||||
rowGap: `${safeGap.value}px`,
|
||||
}))
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
|
||||
function getComparableKey(item: any, index: number): ItemKey {
|
||||
if (props.getItemKey) {
|
||||
return props.getItemKey(item, index)
|
||||
}
|
||||
|
||||
return index
|
||||
}
|
||||
|
||||
function findFirstRowAtOrAfterOffset(offsets: number[], heights: number[], offset: number) {
|
||||
let low = 0
|
||||
let high = heights.length - 1
|
||||
let answer = 0
|
||||
|
||||
while (low <= high) {
|
||||
const mid = Math.floor((low + high) / 2)
|
||||
const rowEnd = offsets[mid] + heights[mid]
|
||||
|
||||
if (rowEnd >= offset) {
|
||||
answer = mid
|
||||
high = mid - 1
|
||||
} else {
|
||||
low = mid + 1
|
||||
}
|
||||
}
|
||||
|
||||
return answer
|
||||
}
|
||||
|
||||
function findLastRowAtOrBeforeOffset(offsets: number[], rowCount: number, offset: number) {
|
||||
let low = 0
|
||||
let high = rowCount - 1
|
||||
let answer = 0
|
||||
|
||||
while (low <= high) {
|
||||
const mid = Math.floor((low + high) / 2)
|
||||
|
||||
if (offsets[mid] <= offset) {
|
||||
answer = mid
|
||||
low = mid + 1
|
||||
} else {
|
||||
high = mid - 1
|
||||
}
|
||||
}
|
||||
|
||||
return answer
|
||||
}
|
||||
|
||||
function isDocumentOverlayLocked() {
|
||||
return typeof document !== 'undefined' && document.documentElement.classList.contains('v-overlay-scroll-blocked')
|
||||
}
|
||||
|
||||
function isGridInsideOverlay() {
|
||||
return Boolean(containerRef.value?.closest('.v-overlay, .v-overlay__content'))
|
||||
}
|
||||
|
||||
function syncOverlayGridState() {
|
||||
isOverlayGrid.value = isGridInsideOverlay()
|
||||
}
|
||||
|
||||
function shouldPauseVirtualSync() {
|
||||
return isDocumentOverlayLocked() && !isOverlayGrid.value
|
||||
}
|
||||
|
||||
function freezeVisibleRange() {
|
||||
if (frozenVisibleRange.value) {
|
||||
return
|
||||
}
|
||||
|
||||
// 弹窗打开期间固定当前渲染窗口,防止 body 锁滚动造成坐标跳变并卸载触发弹窗的卡片。
|
||||
frozenVisibleRange.value = { ...calculatedVisibleRange.value }
|
||||
}
|
||||
|
||||
function releaseVisibleRange() {
|
||||
frozenVisibleRange.value = null
|
||||
}
|
||||
|
||||
function handleOverlayLockChange() {
|
||||
if (shouldPauseVirtualSync()) {
|
||||
freezeVisibleRange()
|
||||
return
|
||||
}
|
||||
|
||||
releaseVisibleRange()
|
||||
queueLayoutSync()
|
||||
}
|
||||
|
||||
function getElementFromRef(element: Element | ComponentPublicInstance | null): HTMLElement | null {
|
||||
if (!element || typeof HTMLElement === 'undefined') {
|
||||
return null
|
||||
}
|
||||
|
||||
if (element instanceof HTMLElement) {
|
||||
return element
|
||||
}
|
||||
|
||||
if (!('$el' in element)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const componentElement = element.$el
|
||||
|
||||
return componentElement instanceof HTMLElement ? componentElement : null
|
||||
}
|
||||
|
||||
function getRowHeight(row: number) {
|
||||
const startIndex = row * columnCount.value
|
||||
const endIndex = Math.min(startIndex + columnCount.value, props.items.length)
|
||||
let rowHeight = 0
|
||||
let hasUnmeasuredItem = false
|
||||
|
||||
for (let index = startIndex; index < endIndex; index += 1) {
|
||||
const height = itemHeights.get(itemKeys.value[index])
|
||||
if (height && height > 0) {
|
||||
rowHeight = Math.max(rowHeight, height)
|
||||
} else {
|
||||
hasUnmeasuredItem = true
|
||||
}
|
||||
}
|
||||
|
||||
if (hasUnmeasuredItem) {
|
||||
return Math.max(rowHeight, estimatedHeight.value)
|
||||
}
|
||||
|
||||
return Math.max(rowHeight, 1)
|
||||
}
|
||||
|
||||
function ensureItemResizeObserver() {
|
||||
if (itemResizeObserver || typeof ResizeObserver === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
itemResizeObserver = new ResizeObserver(entries => {
|
||||
if (shouldPauseVirtualSync()) {
|
||||
freezeVisibleRange()
|
||||
return
|
||||
}
|
||||
|
||||
let shouldUpdate = false
|
||||
let scrollAdjustment = 0
|
||||
const currentViewportTop = viewportTop.value
|
||||
const currentOffsets = rowMetrics.value.offsets
|
||||
|
||||
entries.forEach(entry => {
|
||||
const element = entry.target
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
return
|
||||
}
|
||||
|
||||
const key = observedElements.get(element)
|
||||
const index = key === undefined ? undefined : keyIndexMap.value.get(key)
|
||||
|
||||
if (key === undefined || index === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextHeight = getResizeEntryHeight(entry)
|
||||
const previousHeight = itemHeights.get(key)
|
||||
|
||||
if (!nextHeight || Math.abs((previousHeight ?? 0) - nextHeight) < 0.5) {
|
||||
return
|
||||
}
|
||||
|
||||
const row = Math.floor(index / columnCount.value)
|
||||
const rowWasFullyMeasured = rowMetrics.value.measuredRows[row]
|
||||
const previousRowHeight = getRowHeight(row)
|
||||
const previousRowBottom = (currentOffsets[row] ?? 0) + previousRowHeight
|
||||
|
||||
if (
|
||||
rowWasFullyMeasured &&
|
||||
previousHeight !== undefined &&
|
||||
previousHeight < previousRowHeight - 0.5 &&
|
||||
nextHeight <= previousRowHeight + 0.5
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
itemHeights.set(key, nextHeight)
|
||||
|
||||
const nextRowHeight = getRowHeight(row)
|
||||
const delta = nextRowHeight - previousRowHeight
|
||||
|
||||
if (Math.abs(delta) >= 0.5 && previousRowBottom < currentViewportTop) {
|
||||
scrollAdjustment += delta
|
||||
}
|
||||
|
||||
shouldUpdate = true
|
||||
})
|
||||
|
||||
if (!shouldUpdate) {
|
||||
return
|
||||
}
|
||||
|
||||
heightVersion.value += 1
|
||||
|
||||
if (Math.abs(scrollAdjustment) >= 0.5) {
|
||||
adjustScrollTop(scrollAdjustment)
|
||||
}
|
||||
|
||||
queueViewportSync()
|
||||
})
|
||||
}
|
||||
|
||||
function getResizeEntryHeight(entry: ResizeObserverEntry) {
|
||||
const borderSize = Array.isArray(entry.borderBoxSize) ? entry.borderBoxSize[0] : entry.borderBoxSize
|
||||
|
||||
return borderSize?.blockSize || entry.contentRect.height
|
||||
}
|
||||
|
||||
function setItemRef(element: Element | ComponentPublicInstance | null, key: ItemKey) {
|
||||
const htmlElement = getElementFromRef(element)
|
||||
const previousElement = keyElements.get(key)
|
||||
|
||||
if (!htmlElement) {
|
||||
if (previousElement) {
|
||||
itemResizeObserver?.unobserve(previousElement)
|
||||
observedElements.delete(previousElement)
|
||||
keyElements.delete(key)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (previousElement === htmlElement) {
|
||||
return
|
||||
}
|
||||
|
||||
ensureItemResizeObserver()
|
||||
|
||||
if (previousElement) {
|
||||
itemResizeObserver?.unobserve(previousElement)
|
||||
observedElements.delete(previousElement)
|
||||
}
|
||||
|
||||
observedElements.set(htmlElement, key)
|
||||
keyElements.set(key, htmlElement)
|
||||
itemResizeObserver?.observe(htmlElement)
|
||||
}
|
||||
|
||||
function getItemRef(key: ItemKey) {
|
||||
const existingCallback = itemRefCallbacks.get(key)
|
||||
|
||||
if (existingCallback) {
|
||||
return existingCallback
|
||||
}
|
||||
|
||||
const callback = (element: Element | ComponentPublicInstance | null) => setItemRef(element, key)
|
||||
itemRefCallbacks.set(key, callback)
|
||||
|
||||
return callback
|
||||
}
|
||||
|
||||
function findScrollTarget(): ScrollTarget {
|
||||
let parent = containerRef.value?.parentElement ?? null
|
||||
|
||||
while (parent && parent !== document.body && parent !== document.documentElement) {
|
||||
const overflowY = window.getComputedStyle(parent).overflowY
|
||||
|
||||
if (overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay') {
|
||||
return parent
|
||||
}
|
||||
|
||||
parent = parent.parentElement
|
||||
}
|
||||
|
||||
return window
|
||||
}
|
||||
|
||||
function addScrollListener(target: ScrollTarget) {
|
||||
target.addEventListener('scroll', queueViewportSync, { passive: true })
|
||||
}
|
||||
|
||||
function removeScrollListener(target: ScrollTarget | null) {
|
||||
target?.removeEventListener('scroll', queueViewportSync)
|
||||
}
|
||||
|
||||
function refreshScrollTarget() {
|
||||
if (!mounted) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextTarget = findScrollTarget()
|
||||
|
||||
if (scrollTarget === nextTarget) {
|
||||
return
|
||||
}
|
||||
|
||||
removeScrollListener(scrollTarget)
|
||||
scrollTarget = nextTarget
|
||||
addScrollListener(scrollTarget)
|
||||
}
|
||||
|
||||
function syncLayoutWidth() {
|
||||
const element = trackRef.value
|
||||
|
||||
if (!element) {
|
||||
layoutWidth.value = 0
|
||||
return
|
||||
}
|
||||
|
||||
layoutWidth.value = element.clientWidth
|
||||
}
|
||||
|
||||
function syncViewport() {
|
||||
const element = trackRef.value
|
||||
|
||||
if (!element) {
|
||||
viewportTop.value = 0
|
||||
viewportBottom.value = 0
|
||||
return
|
||||
}
|
||||
|
||||
const trackRect = element.getBoundingClientRect()
|
||||
const viewportRect =
|
||||
scrollTarget && scrollTarget !== window
|
||||
? (scrollTarget as HTMLElement).getBoundingClientRect()
|
||||
: {
|
||||
bottom: window.innerHeight,
|
||||
top: 0,
|
||||
}
|
||||
|
||||
viewportTop.value = viewportRect.top - trackRect.top
|
||||
viewportBottom.value = viewportRect.bottom - trackRect.top
|
||||
}
|
||||
|
||||
function queueLayoutSync() {
|
||||
if (typeof window === 'undefined' || layoutFrameId !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
layoutFrameId = window.requestAnimationFrame(() => {
|
||||
layoutFrameId = null
|
||||
|
||||
if (shouldPauseVirtualSync()) {
|
||||
freezeVisibleRange()
|
||||
return
|
||||
}
|
||||
|
||||
// 弹窗内容已经由 overlay 限定生命周期,直接完整渲染可避免弹窗内交互被虚拟回收打断。
|
||||
syncOverlayGridState()
|
||||
releaseVisibleRange()
|
||||
syncLayoutWidth()
|
||||
refreshScrollTarget()
|
||||
syncViewport()
|
||||
flushPendingReveal()
|
||||
})
|
||||
}
|
||||
|
||||
function queueViewportSync() {
|
||||
if (typeof window === 'undefined' || scrollFrameId !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
scrollFrameId = window.requestAnimationFrame(() => {
|
||||
scrollFrameId = null
|
||||
|
||||
if (shouldPauseVirtualSync()) {
|
||||
freezeVisibleRange()
|
||||
return
|
||||
}
|
||||
|
||||
releaseVisibleRange()
|
||||
syncViewport()
|
||||
})
|
||||
}
|
||||
|
||||
function getTrackScrollTop() {
|
||||
const element = trackRef.value
|
||||
|
||||
if (!element || !scrollTarget || scrollTarget === window) {
|
||||
return (element?.getBoundingClientRect().top ?? 0) + window.scrollY
|
||||
}
|
||||
|
||||
const scrollElement = scrollTarget as HTMLElement
|
||||
const trackRect = element.getBoundingClientRect()
|
||||
const scrollRect = scrollElement.getBoundingClientRect()
|
||||
|
||||
return trackRect.top - scrollRect.top + scrollElement.scrollTop
|
||||
}
|
||||
|
||||
function adjustScrollTop(delta: number) {
|
||||
if (!scrollTarget || Math.abs(delta) < 0.5) {
|
||||
return
|
||||
}
|
||||
|
||||
if (scrollTarget === window) {
|
||||
window.scrollBy({
|
||||
behavior: 'auto',
|
||||
top: delta,
|
||||
})
|
||||
} else {
|
||||
const scrollElement = scrollTarget as HTMLElement
|
||||
scrollElement.scrollTop += delta
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToRelativeTop(top: number) {
|
||||
if (!scrollTarget) {
|
||||
return
|
||||
}
|
||||
|
||||
const targetTop = getTrackScrollTop() + top
|
||||
|
||||
if (scrollTarget === window) {
|
||||
window.scrollTo({
|
||||
behavior: 'auto',
|
||||
top: targetTop,
|
||||
})
|
||||
} else {
|
||||
;(scrollTarget as HTMLElement).scrollTo({
|
||||
behavior: 'auto',
|
||||
top: targetTop,
|
||||
})
|
||||
}
|
||||
|
||||
queueViewportSync()
|
||||
}
|
||||
|
||||
async function revealItem(index: number) {
|
||||
if (typeof window === 'undefined' || index < 0 || index >= props.items.length) {
|
||||
return
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
|
||||
const row = Math.floor(index / columnCount.value)
|
||||
const top = rowMetrics.value.offsets[row] ?? 0
|
||||
|
||||
scrollToRelativeTop(top)
|
||||
}
|
||||
|
||||
function requestRevealItem(index: number) {
|
||||
pendingRevealIndex = index
|
||||
|
||||
if (!mounted) {
|
||||
return
|
||||
}
|
||||
|
||||
queueLayoutSync()
|
||||
}
|
||||
|
||||
function flushPendingReveal() {
|
||||
if (pendingRevealIndex === null || !mounted || !scrollTarget || layoutWidth.value <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const index = pendingRevealIndex
|
||||
pendingRevealIndex = null
|
||||
|
||||
void revealItem(index)
|
||||
}
|
||||
|
||||
function pruneMeasurements() {
|
||||
const keys = new Set(itemKeys.value)
|
||||
let changed = false
|
||||
|
||||
Array.from(itemHeights.keys()).forEach(key => {
|
||||
if (!keys.has(key)) {
|
||||
itemHeights.delete(key)
|
||||
changed = true
|
||||
}
|
||||
})
|
||||
|
||||
Array.from(keyElements.entries()).forEach(([key, element]) => {
|
||||
if (!keys.has(key)) {
|
||||
itemResizeObserver?.unobserve(element)
|
||||
observedElements.delete(element)
|
||||
keyElements.delete(key)
|
||||
}
|
||||
})
|
||||
|
||||
Array.from(itemRefCallbacks.keys()).forEach(key => {
|
||||
if (!keys.has(key)) {
|
||||
itemRefCallbacks.delete(key)
|
||||
}
|
||||
})
|
||||
|
||||
if (changed) {
|
||||
heightVersion.value += 1
|
||||
}
|
||||
}
|
||||
|
||||
function didKeysAppend(nextKeys: ItemKey[], previousKeys: ItemKey[] = []) {
|
||||
if (!previousKeys.length || nextKeys.length < previousKeys.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
return previousKeys.every((key, index) => key === nextKeys[index])
|
||||
}
|
||||
|
||||
function syncMeasurementsForItems(nextKeys: ItemKey[], previousKeys: ItemKey[] = []) {
|
||||
if (!didKeysAppend(nextKeys, previousKeys) && itemHeights.size) {
|
||||
itemHeights.clear()
|
||||
heightVersion.value += 1
|
||||
}
|
||||
|
||||
pruneMeasurements()
|
||||
}
|
||||
|
||||
function invalidateMeasurementsForLayoutChange() {
|
||||
const nextColumnCount = columnCount.value
|
||||
const nextColumnWidth = columnWidth.value
|
||||
|
||||
if (
|
||||
lastMeasuredColumnCount === nextColumnCount &&
|
||||
Math.abs(lastMeasuredColumnWidth - nextColumnWidth) < 1
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
lastMeasuredColumnCount = nextColumnCount
|
||||
lastMeasuredColumnWidth = nextColumnWidth
|
||||
|
||||
if (!itemHeights.size) {
|
||||
return
|
||||
}
|
||||
|
||||
itemHeights.clear()
|
||||
heightVersion.value += 1
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
mounted = true
|
||||
syncOverlayGridState()
|
||||
scrollTarget = findScrollTarget()
|
||||
addScrollListener(scrollTarget)
|
||||
|
||||
resizeObserver = new ResizeObserver(queueLayoutSync)
|
||||
if (trackRef.value) {
|
||||
resizeObserver.observe(trackRef.value)
|
||||
}
|
||||
|
||||
if (typeof MutationObserver !== 'undefined') {
|
||||
overlayLockObserver = new MutationObserver(handleOverlayLockChange)
|
||||
overlayLockObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class'],
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener('resize', queueLayoutSync, { passive: true })
|
||||
|
||||
queueLayoutSync()
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
mounted = true
|
||||
refreshScrollTarget()
|
||||
queueLayoutSync()
|
||||
})
|
||||
|
||||
onDeactivated(() => {
|
||||
mounted = false
|
||||
removeScrollListener(scrollTarget)
|
||||
scrollTarget = null
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
mounted = false
|
||||
removeScrollListener(scrollTarget)
|
||||
scrollTarget = null
|
||||
|
||||
window.removeEventListener('resize', queueLayoutSync)
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
itemResizeObserver?.disconnect()
|
||||
itemResizeObserver = null
|
||||
overlayLockObserver?.disconnect()
|
||||
overlayLockObserver = null
|
||||
|
||||
if (layoutFrameId !== null) {
|
||||
window.cancelAnimationFrame(layoutFrameId)
|
||||
layoutFrameId = null
|
||||
}
|
||||
|
||||
if (scrollFrameId !== null) {
|
||||
window.cancelAnimationFrame(scrollFrameId)
|
||||
scrollFrameId = null
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
itemKeys,
|
||||
(nextKeys, previousKeys) => {
|
||||
syncMeasurementsForItems(nextKeys, previousKeys)
|
||||
queueLayoutSync()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
[
|
||||
() => props.minItemWidth,
|
||||
() => props.gap,
|
||||
() => props.estimatedItemHeight,
|
||||
() => props.itemAspectRatio,
|
||||
() => props.columns,
|
||||
],
|
||||
() => {
|
||||
queueLayoutSync()
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
[columnCount, columnWidth],
|
||||
() => {
|
||||
invalidateMeasurementsForLayoutChange()
|
||||
queueViewportSync()
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
[() => props.scrollToIndex, () => props.items.length, columnCount],
|
||||
([scrollToIndex]) => {
|
||||
if (scrollToIndex === undefined || scrollToIndex < 0 || scrollToIndex >= props.items.length) {
|
||||
return
|
||||
}
|
||||
|
||||
requestRevealItem(scrollToIndex)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" class="progressive-card-grid">
|
||||
<div ref="trackRef" class="progressive-card-grid__track">
|
||||
<div
|
||||
v-if="topSpacerHeight > 0"
|
||||
class="progressive-card-grid__spacer"
|
||||
:style="{ blockSize: `${topSpacerHeight}px` }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div v-if="visibleCells.length > 0" class="progressive-card-grid__grid" :style="gridStyle">
|
||||
<div
|
||||
v-for="cell in visibleCells"
|
||||
:key="cell.key"
|
||||
:ref="getItemRef(cell.key)"
|
||||
class="progressive-card-grid__item"
|
||||
:data-progressive-grid-index="cell.index"
|
||||
>
|
||||
<slot :item="cell.item" :index="cell.index" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="bottomSpacerHeight > 0"
|
||||
class="progressive-card-grid__spacer"
|
||||
:style="{ blockSize: `${bottomSpacerHeight}px` }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.progressive-card-grid {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.progressive-card-grid__track {
|
||||
inline-size: 100%;
|
||||
min-block-size: 1px;
|
||||
overflow-anchor: none;
|
||||
}
|
||||
|
||||
.progressive-card-grid__grid {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.progressive-card-grid__item {
|
||||
inline-size: 100%;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.progressive-card-grid__item > :deep(*) {
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
}
|
||||
</style>
|
||||
60
src/components/virtual/AutoSizer.vue
Normal file
60
src/components/virtual/AutoSizer.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<!--
|
||||
============================================================
|
||||
AutoSizer - 测量自身容器尺寸并通过 slot 传给子虚拟组件
|
||||
============================================================
|
||||
|
||||
设计目标:
|
||||
- 把「容器宽高测量」职责从虚拟化组件里拿出来(react-virtualized 经典分层)
|
||||
- 虚拟化组件只接受显式 width/height/columns,不再内部 ResizeObserver
|
||||
- 容器自适应需求由本组件 + 业务计算(如 useResponsiveCols)解决
|
||||
|
||||
原理:
|
||||
- 用 `@vueuse/core` 的 `useElementSize` 观察自身根 div
|
||||
- 默认 100% 宽高填满父容器;父容器必须给出有限尺寸(弹性盒/网格/显式 height)
|
||||
- 把 { width, height } 通过默认 slot 暴露给子内容
|
||||
|
||||
典型用法(dashboard 卡片里的可变宽网格):
|
||||
<VCard>
|
||||
<VCardItem>...</VCardItem>
|
||||
<AutoSizer #default="{ width }">
|
||||
<VirtualGrid :columns="Math.max(1, Math.floor(width / 240))"
|
||||
:container-height="'10rem'" ... />
|
||||
</AutoSizer>
|
||||
</VCard>
|
||||
|
||||
注意:
|
||||
- 父容器必须有有限宽(CSS 默认 100% 即可)和高度(dashboard 卡片有明确 block-size)
|
||||
- 首次渲染时 width/height 为 0 直到 useElementSize 第一帧回写。
|
||||
子组件应能容忍 cols=最小值(useResponsiveCols 默认 min=1)。
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useElementSize } from '@vueuse/core'
|
||||
|
||||
defineProps<{
|
||||
/** 透传到根 div 的额外 class */
|
||||
class?: string | string[] | Record<string, boolean>
|
||||
}>()
|
||||
|
||||
const rootRef = ref<HTMLElement | null>(null)
|
||||
const { width, height } = useElementSize(rootRef)
|
||||
|
||||
defineExpose({
|
||||
getRootElement: () => rootRef.value,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="rootRef" :class="['virtual-auto-sizer', $props.class]">
|
||||
<slot :width="width" :height="height" :root-ref="rootRef" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.virtual-auto-sizer {
|
||||
position: relative;
|
||||
inline-size: 100%;
|
||||
block-size: 100%;
|
||||
}
|
||||
</style>
|
||||
199
src/components/virtual/VirtualGrid.vue
Normal file
199
src/components/virtual/VirtualGrid.vue
Normal file
@@ -0,0 +1,199 @@
|
||||
<!--
|
||||
============================================================
|
||||
VirtualGrid - @tanstack/vue-virtual 二维虚拟网格兼容层
|
||||
============================================================
|
||||
|
||||
设计目标:
|
||||
- 内部把扁平 items[] 按外部传入的 `columns` 数值打包成 rows[][]
|
||||
- 对业务屏蔽 row chunking 细节,业务只写「一项卡片」
|
||||
- 网格只在垂直方向虚拟化(行),水平方向用 CSS Grid 等分
|
||||
- 支持两种滚动模式:容器内 scroll(默认)+ 页面 window scroll(useWindowScroll=true)
|
||||
- 不感知容器:组件本身不读视口、不测自身宽度。
|
||||
cols 由调用方算好喂进来(useBreakpointCols / useResponsiveCols / 显式数值)。
|
||||
容器自适应需求请用 <AutoSizer> 或 useResponsiveCols。
|
||||
|
||||
典型用法(路由主页,window scroll,视口断点决定列数):
|
||||
<script setup>
|
||||
const cols = useBreakpointCols({ xs: 2, sm: 3, md: 4, lg: 5, xl: 6 })
|
||||
</script>
|
||||
<template>
|
||||
<VirtualGrid :items="dataList" :columns="cols"
|
||||
:row-estimate-size="320" key-field="id"
|
||||
use-window-scroll @load-more="fetchData">
|
||||
<template #item="{ item }"> <MediaCard :media="item" /> </template>
|
||||
</VirtualGrid>
|
||||
</template>
|
||||
|
||||
典型用法(dashboard 卡片,容器宽度决定列数):
|
||||
<AutoSizer #default="{ width }">
|
||||
<VirtualGrid :columns="Math.max(1, Math.floor(width / 240))"
|
||||
:items="data" :container-height="'10rem'" ... />
|
||||
</AutoSizer>
|
||||
-->
|
||||
|
||||
<script setup lang="ts" generic="T extends Record<string, any>">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useWindowScrollMargin } from '@/composables/virtual/useWindowScrollMargin'
|
||||
import { useLoadMoreSentinel } from '@/composables/virtual/useLoadMoreSentinel'
|
||||
import { useVirtualizerBridge } from '@/composables/virtual/useVirtualizerBridge'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
items: T[]
|
||||
/** 列数(必填)。调用方用 useBreakpointCols / useResponsiveCols / 显式数值喂进来 */
|
||||
columns: number
|
||||
/** 行高估算(px) */
|
||||
rowEstimateSize?: number
|
||||
/** 卡片间距 + 容器内边距 */
|
||||
gap?: number | string
|
||||
/** 视口外预渲染的行数 */
|
||||
overscan?: number
|
||||
/** key 字段名。若同时给了 getItemKey 则后者优先 */
|
||||
keyField?: keyof T
|
||||
/**
|
||||
* 取 key 的函数式入口,优先级高于 keyField。
|
||||
* 用于 fallback 链场景,如 `(item) => item.id || item.link || item.title`。
|
||||
* `index` 是 item 在扁平 items[] 中的 0 起始下标。
|
||||
*/
|
||||
getItemKey?: (item: T, index: number) => string | number
|
||||
/** 容器内 scroll 模式下的容器高度(useWindowScroll=false 时生效) */
|
||||
containerHeight?: string | number
|
||||
/** 使用页面 window scroll(路由主页推荐) */
|
||||
useWindowScroll?: boolean
|
||||
}>(),
|
||||
{
|
||||
rowEstimateSize: 320,
|
||||
gap: 12,
|
||||
overscan: 3,
|
||||
containerHeight: '100%',
|
||||
useWindowScroll: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ loadMore: []; scroll: [event: Event] }>()
|
||||
|
||||
// 列数兜底为 1,避免外部传 0/负数时切片死循环
|
||||
const cols = computed(() => Math.max(1, Math.floor(props.columns) || 1))
|
||||
|
||||
const rows = computed<T[][]>(() => {
|
||||
const n = cols.value
|
||||
const list = props.items
|
||||
const out: T[][] = []
|
||||
for (let i = 0; i < list.length; i += n) {
|
||||
out.push(list.slice(i, i + n))
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
const scrollEl = ref<HTMLElement | null>(null)
|
||||
|
||||
// Base Layer:scrollMargin 追踪(window resize + body ResizeObserver 自管理 + 卸载清理)
|
||||
const { scrollMargin } = useWindowScrollMargin(scrollEl, () => props.useWindowScroll)
|
||||
|
||||
// Base Layer:tanstack 桥接(useVirtualizer/useWindowVirtualizer 二选一 + measureRef null 转发)
|
||||
// 网格只在垂直方向虚拟化「行」,不传 getItemKey(key 由 rowItemKey 打在内层卡片上)。
|
||||
const { virtualizer, totalSize, virtualItems: virtualRows, measureRef } = useVirtualizerBridge({
|
||||
count: () => rows.value.length,
|
||||
estimateSize: () => props.rowEstimateSize,
|
||||
overscan: () => props.overscan,
|
||||
scrollMargin: () => scrollMargin.value,
|
||||
getScrollElement: () => scrollEl.value,
|
||||
useWindowScroll: props.useWindowScroll,
|
||||
})
|
||||
|
||||
// Base Layer:触底加载哨兵(sentinel + IntersectionObserver + items.length 兜底)
|
||||
const { sentinel: loadMoreSentinel } = useLoadMoreSentinel({
|
||||
itemsLength: () => props.items.length,
|
||||
onFire: () => emit('loadMore'),
|
||||
})
|
||||
|
||||
function rowItemKey(item: T, rowIdx: number, colIdx: number): string | number {
|
||||
// 优先级:getItemKey 函数 > keyField 字段 > 位置 fallback
|
||||
if (props.getItemKey) {
|
||||
const k = props.getItemKey(item, rowIdx * cols.value + colIdx)
|
||||
if (typeof k === 'string' || typeof k === 'number') return k
|
||||
}
|
||||
if (props.keyField) {
|
||||
const k = (item as Record<string, any>)[props.keyField as string]
|
||||
if (typeof k === 'string' || typeof k === 'number') return k
|
||||
}
|
||||
return `${rowIdx}-${colIdx}`
|
||||
}
|
||||
|
||||
const gapStr = computed(() => (typeof props.gap === 'number' ? `${props.gap}px` : props.gap))
|
||||
|
||||
defineExpose({
|
||||
scrollToRow: (idx: number) => virtualizer.value.scrollToIndex(idx),
|
||||
scrollToIndex: (idx: number) => virtualizer.value.scrollToIndex(idx),
|
||||
scrollToOffset: (px: number) => virtualizer.value.scrollToOffset(px),
|
||||
getScrollElement: () => scrollEl.value,
|
||||
cols,
|
||||
})
|
||||
|
||||
const containerStyle = computed(() => {
|
||||
if (props.useWindowScroll) {
|
||||
return {
|
||||
position: 'relative' as const,
|
||||
width: '100%' as const,
|
||||
}
|
||||
}
|
||||
return {
|
||||
height:
|
||||
typeof props.containerHeight === 'number'
|
||||
? `${props.containerHeight}px`
|
||||
: props.containerHeight,
|
||||
overflow: 'auto' as const,
|
||||
overscrollBehavior: 'contain' as const,
|
||||
willChange: 'transform' as const,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="scrollEl" :style="containerStyle" @scroll="emit('scroll', $event)">
|
||||
<slot v-if="!items.length" name="empty" />
|
||||
|
||||
<div :style="{ height: `${totalSize}px`, position: 'relative', width: '100%' }">
|
||||
<div
|
||||
v-for="v in virtualRows"
|
||||
:key="String(v.key)"
|
||||
:data-index="v.index"
|
||||
:ref="measureRef"
|
||||
:style="{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${v.start - scrollMargin}px)`,
|
||||
contain: 'layout style',
|
||||
}"
|
||||
>
|
||||
<div
|
||||
:style="{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
|
||||
gap: gapStr,
|
||||
paddingBottom: gapStr,
|
||||
}"
|
||||
>
|
||||
<template
|
||||
v-for="(item, i) in rows[v.index]"
|
||||
:key="rowItemKey(item, v.index, i)"
|
||||
>
|
||||
<slot name="item" :item="item" :row-index="v.index" :col-index="i" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部 sentinel:进入视口触发 loadMore,比 watch(virtualRows) 更可靠 -->
|
||||
<div
|
||||
v-if="items.length"
|
||||
ref="loadMoreSentinel"
|
||||
aria-hidden="true"
|
||||
style="height: 1px; width: 100%"
|
||||
/>
|
||||
|
||||
<slot name="loading" />
|
||||
</div>
|
||||
</template>
|
||||
189
src/components/virtual/VirtualList.vue
Normal file
189
src/components/virtual/VirtualList.vue
Normal file
@@ -0,0 +1,189 @@
|
||||
<!--
|
||||
============================================================
|
||||
VirtualList - @tanstack/vue-virtual 一维虚拟列表兼容层
|
||||
============================================================
|
||||
|
||||
设计目标:
|
||||
- 把 headless 的 useVirtualizer 封装成 slot 式 SFC
|
||||
- 业务侧只关心「一项 item 长什么样」
|
||||
- 支持两种滚动模式:容器内 scroll(默认)+ 页面 window scroll(useWindowScroll=true)
|
||||
- 内置触底加载(@load-more)
|
||||
- 内置 Layout Jump 防护(measureElement + scroll anchoring)
|
||||
- 内置 Scroll-back Blank 防护(overscroll-behavior + 暴露 scrollTo* 命令式 API)
|
||||
|
||||
典型用法(容器内 scroll,对话框/弹窗):
|
||||
<VirtualList :items="list" :estimate-size="104" key-field="id"
|
||||
container-height="60vh" @load-more="loadMore">
|
||||
<template #item="{ item }"> ... </template>
|
||||
</VirtualList>
|
||||
|
||||
典型用法(页面级 window scroll,路由主页):
|
||||
<VirtualList :items="list" :estimate-size="120" key-field="id"
|
||||
use-window-scroll @load-more="loadMore">
|
||||
<template #item="{ item }"> ... </template>
|
||||
</VirtualList>
|
||||
-->
|
||||
|
||||
<script setup lang="ts" generic="T extends Record<string, any>">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useWindowScrollMargin } from '@/composables/virtual/useWindowScrollMargin'
|
||||
import { useLoadMoreSentinel } from '@/composables/virtual/useLoadMoreSentinel'
|
||||
import { useVirtualizerBridge } from '@/composables/virtual/useVirtualizerBridge'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
items: T[]
|
||||
/** 估算每项高度(px)。不定高时仍需给估算值,库自动用 measureElement 校正 */
|
||||
estimateSize?: number
|
||||
/** 视口外预渲染项数。调高减少回滚白屏,调低减少内存 */
|
||||
overscan?: number
|
||||
/** key 字段名,强烈建议传。若同时给了 getItemKey 则后者优先 */
|
||||
keyField?: keyof T
|
||||
/**
|
||||
* 取 key 的函数式入口,优先级高于 keyField。
|
||||
* 用途:单字段 keyField 表达不了的场景,如 fallback 链
|
||||
* `(m) => getMessageKey(m) || index`、组合 id 等。
|
||||
*/
|
||||
getItemKey?: (item: T, index: number) => string | number
|
||||
/** 容器内 scroll 模式下的容器高度(useWindowScroll=false 时生效) */
|
||||
containerHeight?: string | number
|
||||
/** 末尾还剩多少项时触发 load-more */
|
||||
loadMoreThreshold?: number
|
||||
/**
|
||||
* 反向加载阈值:当首项 index 小于等于该值时触发 load-more-reverse。
|
||||
* 用途:聊天/消息流场景,滚动到顶端加载更早的内容(如 MessageView)。
|
||||
* 0 = 禁用反向加载(默认)。
|
||||
*/
|
||||
loadMoreReverseThreshold?: number
|
||||
/**
|
||||
* 使用页面 window scroll(路由主页推荐)。
|
||||
* - true: 不在内部生成滚动条,跟随页面滚动,自动用 scrollMargin 校正
|
||||
* - false(默认): 在内部生成固定高度滚动容器(适合对话框/抽屉)
|
||||
*/
|
||||
useWindowScroll?: boolean
|
||||
}>(),
|
||||
{
|
||||
estimateSize: 100,
|
||||
overscan: 5,
|
||||
containerHeight: '100%',
|
||||
loadMoreThreshold: 5,
|
||||
loadMoreReverseThreshold: 0,
|
||||
useWindowScroll: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
loadMore: []
|
||||
loadMoreReverse: []
|
||||
scroll: [event: Event]
|
||||
}>()
|
||||
|
||||
const scrollEl = ref<HTMLElement | null>(null)
|
||||
|
||||
// Base Layer:scrollMargin 追踪(window resize + body ResizeObserver 自管理 + 卸载清理)
|
||||
const { scrollMargin } = useWindowScrollMargin(scrollEl, () => props.useWindowScroll)
|
||||
|
||||
// Base Layer:tanstack 桥接(useVirtualizer/useWindowVirtualizer 二选一 + measureRef null 转发)
|
||||
const { virtualizer, totalSize, virtualItems, measureRef } = useVirtualizerBridge({
|
||||
count: () => props.items.length,
|
||||
estimateSize: () => props.estimateSize,
|
||||
overscan: () => props.overscan,
|
||||
scrollMargin: () => scrollMargin.value,
|
||||
getScrollElement: () => scrollEl.value,
|
||||
useWindowScroll: props.useWindowScroll,
|
||||
// 优先级:getItemKey 函数 > keyField 字段 > index 兜底
|
||||
getItemKey: (i: number) => {
|
||||
const item = props.items[i]
|
||||
if (props.getItemKey && item !== undefined) return props.getItemKey(item, i)
|
||||
if (props.keyField && item !== undefined) return item[props.keyField] as string | number
|
||||
return i
|
||||
},
|
||||
})
|
||||
|
||||
// Base Layer:触底加载哨兵。容器内 scroll 模式需把 IntersectionObserver root
|
||||
// 指向滚动容器(window scroll 模式传 null = 视口)。
|
||||
const sentinelRoot = computed(() => (props.useWindowScroll ? null : scrollEl.value))
|
||||
|
||||
const { sentinel: loadMoreSentinel } = useLoadMoreSentinel({
|
||||
itemsLength: () => props.items.length,
|
||||
onFire: () => emit('loadMore'),
|
||||
root: sentinelRoot,
|
||||
})
|
||||
|
||||
// 反向加载(聊天/消息流往上加载更早内容):再调一次哨兵,按阈值启用。
|
||||
const { sentinel: loadMoreReverseSentinel } = useLoadMoreSentinel({
|
||||
itemsLength: () => props.items.length,
|
||||
onFire: () => emit('loadMoreReverse'),
|
||||
root: sentinelRoot,
|
||||
enabled: () => props.loadMoreReverseThreshold > 0,
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
scrollToIndex: (idx: number, opts?: { align?: 'start' | 'center' | 'end' | 'auto' }) =>
|
||||
virtualizer.value.scrollToIndex(idx, opts),
|
||||
scrollToOffset: (px: number) => virtualizer.value.scrollToOffset(px),
|
||||
getScrollElement: () => scrollEl.value,
|
||||
getVirtualizer: () => virtualizer.value,
|
||||
})
|
||||
|
||||
const containerStyle = computed(() => {
|
||||
if (props.useWindowScroll) {
|
||||
return {
|
||||
position: 'relative' as const,
|
||||
width: '100%' as const,
|
||||
}
|
||||
}
|
||||
return {
|
||||
height:
|
||||
typeof props.containerHeight === 'number'
|
||||
? `${props.containerHeight}px`
|
||||
: props.containerHeight,
|
||||
overflow: 'auto' as const,
|
||||
overscrollBehavior: 'contain' as const,
|
||||
willChange: 'transform' as const,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="scrollEl" :style="containerStyle" @scroll="emit('scroll', $event)">
|
||||
<slot v-if="!items.length" name="empty" />
|
||||
|
||||
<!-- 顶部 sentinel:反向加载(聊天往上加载)-->
|
||||
<div
|
||||
v-if="items.length && loadMoreReverseThreshold > 0"
|
||||
ref="loadMoreReverseSentinel"
|
||||
aria-hidden="true"
|
||||
style="height: 1px; width: 100%"
|
||||
/>
|
||||
|
||||
<div :style="{ height: `${totalSize}px`, position: 'relative', width: '100%' }">
|
||||
<div
|
||||
v-for="v in virtualItems"
|
||||
:key="String(v.key)"
|
||||
:data-index="v.index"
|
||||
:ref="measureRef"
|
||||
:style="{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${v.start - scrollMargin}px)`,
|
||||
contain: 'layout style',
|
||||
}"
|
||||
>
|
||||
<slot name="item" :item="items[v.index]" :index="v.index" :virtual="v" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部 sentinel:触底加载 -->
|
||||
<div
|
||||
v-if="items.length"
|
||||
ref="loadMoreSentinel"
|
||||
aria-hidden="true"
|
||||
style="height: 1px; width: 100%"
|
||||
/>
|
||||
|
||||
<slot name="loading" />
|
||||
</div>
|
||||
</template>
|
||||
227
src/components/virtual/VirtualMasonry.vue
Normal file
227
src/components/virtual/VirtualMasonry.vue
Normal file
@@ -0,0 +1,227 @@
|
||||
<!--
|
||||
============================================================
|
||||
VirtualMasonry - 不等高瀑布流 + 窗口虚拟化
|
||||
============================================================
|
||||
|
||||
与 VirtualGrid 的区别:
|
||||
- VirtualGrid 假定每张卡等高(适合海报这种 2:3 等比卡)
|
||||
- VirtualMasonry 支持每项独立高度(Pinterest 风、混排)
|
||||
|
||||
布局算法:
|
||||
- 把 items[] 按到 N 列;每来一项放进当前最矮的那一列
|
||||
- 总高度 = max(列高);可视范围按 scrollY+overscan 过滤位置
|
||||
|
||||
虚拟化策略:
|
||||
- 不依赖 @tanstack/vue-virtual:masonry 没有"行"的概念
|
||||
- 监听 window.scroll 计算可视 Y 区间,只渲染 top..top+vh+overscan 范围的项
|
||||
- 用 requestAnimationFrame 节流,滚动帧率不丢
|
||||
|
||||
高度来源(优先级从高到低):
|
||||
1. 用户传入 getItemHeight(item) 回调(如根据 aspect ratio 计算)
|
||||
2. estimateItemHeight 兜底
|
||||
(v1 不做 mount 后实测回填——masonry 实测会引发已布局项位移)
|
||||
|
||||
列数由调用方决定(与 VirtualGrid 一致,组件本身不感知容器):
|
||||
用 useBreakpointCols / useResponsiveCols / 显式数值喂给 :columns。
|
||||
|
||||
典型用法:
|
||||
<script setup>
|
||||
const cols = useBreakpointCols({ xs: 2, sm: 3, md: 4, lg: 5 })
|
||||
</script>
|
||||
<template>
|
||||
<VirtualMasonry
|
||||
:items="people"
|
||||
:columns="cols"
|
||||
:estimate-item-height="280"
|
||||
:get-item-height="p => p.height ?? 280"
|
||||
key-field="id"
|
||||
@load-more="fetchMore">
|
||||
<template #item="{ item }"> <PersonCard :person="item" /> </template>
|
||||
</VirtualMasonry>
|
||||
</template>
|
||||
-->
|
||||
|
||||
<script setup lang="ts" generic="T extends Record<string, any>">
|
||||
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useWindowScrollMargin } from '@/composables/virtual/useWindowScrollMargin'
|
||||
import { useLoadMoreSentinel } from '@/composables/virtual/useLoadMoreSentinel'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
items: T[]
|
||||
/** 列数(必填)。调用方用 useBreakpointCols / useResponsiveCols / 显式数值喂进来 */
|
||||
columns: number
|
||||
/** 估算项高度(px),未提供 getItemHeight 时用这个 */
|
||||
estimateItemHeight?: number
|
||||
/** 从 item 计算实际高度(如已知图片宽高比) */
|
||||
getItemHeight?: (item: T) => number | undefined
|
||||
/** key 字段名(去重 + 复用 DOM) */
|
||||
keyField?: keyof T
|
||||
/** 列间距(px) */
|
||||
gap?: number
|
||||
/** 视口外预渲染像素(上下各 overscan px)*/
|
||||
overscan?: number
|
||||
}>(),
|
||||
{
|
||||
estimateItemHeight: 300,
|
||||
gap: 12,
|
||||
overscan: 600,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ loadMore: [] }>()
|
||||
|
||||
// 列数兜底为 1,避免外部传 0/负数时除零
|
||||
const cols = computed(() => Math.max(1, Math.floor(props.columns) || 1))
|
||||
|
||||
interface LayoutItem {
|
||||
item: T
|
||||
key: string | number
|
||||
top: number
|
||||
leftPct: number
|
||||
widthPct: number
|
||||
height: number
|
||||
col: number
|
||||
index: number
|
||||
}
|
||||
|
||||
const layout = computed<{ positions: LayoutItem[]; totalHeight: number }>(() => {
|
||||
const n = cols.value
|
||||
const colHeights = new Array<number>(n).fill(0)
|
||||
const positions: LayoutItem[] = []
|
||||
const widthPct = 100 / n
|
||||
const gap = props.gap
|
||||
|
||||
for (let i = 0; i < props.items.length; i++) {
|
||||
const item = props.items[i]
|
||||
// 找最矮列
|
||||
let c = 0
|
||||
let minH = colHeights[0]
|
||||
for (let j = 1; j < n; j++) {
|
||||
if (colHeights[j] < minH) {
|
||||
minH = colHeights[j]
|
||||
c = j
|
||||
}
|
||||
}
|
||||
const top = colHeights[c]
|
||||
const h = props.getItemHeight?.(item) ?? props.estimateItemHeight
|
||||
const key = props.keyField
|
||||
? ((item as Record<string, any>)[props.keyField as string] ?? i)
|
||||
: i
|
||||
positions.push({
|
||||
item,
|
||||
key,
|
||||
top,
|
||||
leftPct: c * widthPct,
|
||||
widthPct,
|
||||
height: h,
|
||||
col: c,
|
||||
index: i,
|
||||
})
|
||||
colHeights[c] = top + h + gap
|
||||
}
|
||||
|
||||
const totalHeight = colHeights.reduce((m, v) => (v > m ? v : m), 0)
|
||||
return { positions, totalHeight }
|
||||
})
|
||||
|
||||
const scrollEl = ref<HTMLElement | null>(null)
|
||||
const scrollY = ref(0)
|
||||
const viewportH = ref(typeof window !== 'undefined' ? window.innerHeight : 800)
|
||||
let rafId: number | null = null
|
||||
|
||||
// Base Layer:scrollMargin 追踪。Masonry 永远是 window scroll,enabled 恒为 true。
|
||||
// (window resize + body ResizeObserver 由 composable 自管理 + 卸载清理)
|
||||
const { scrollMargin } = useWindowScrollMargin(scrollEl, () => true)
|
||||
|
||||
function onScroll() {
|
||||
if (rafId !== null) return
|
||||
rafId = requestAnimationFrame(() => {
|
||||
scrollY.value = window.scrollY
|
||||
rafId = null
|
||||
})
|
||||
}
|
||||
|
||||
function onResize() {
|
||||
viewportH.value = window.innerHeight
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
scrollY.value = window.scrollY
|
||||
window.addEventListener('scroll', onScroll, { passive: true })
|
||||
window.addEventListener('resize', onResize, { passive: true })
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('scroll', onScroll)
|
||||
window.removeEventListener('resize', onResize)
|
||||
}
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = null
|
||||
}
|
||||
})
|
||||
|
||||
// 可视范围内的项(含 overscan):
|
||||
// 本组件 Y 坐标系从 0(容器顶)开始;窗口里看到的容器顶 Y =
|
||||
// scrollY - scrollMargin(scrollMargin 是容器在文档里的偏移)。
|
||||
// 所以可视区间 [vTop, vBottom] 是容器内坐标。
|
||||
const visibleItems = computed(() => {
|
||||
const vTop = scrollY.value - scrollMargin.value - props.overscan
|
||||
const vBottom = scrollY.value - scrollMargin.value + viewportH.value + props.overscan
|
||||
return layout.value.positions.filter(p => p.top + p.height >= vTop && p.top <= vBottom)
|
||||
})
|
||||
|
||||
// Base Layer:触底加载哨兵(sentinel + IntersectionObserver + items.length 兜底)
|
||||
const { sentinel: loadMoreSentinel } = useLoadMoreSentinel({
|
||||
itemsLength: () => props.items.length,
|
||||
onFire: () => emit('loadMore'),
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
getScrollElement: () => scrollEl.value,
|
||||
getLayout: () => layout.value,
|
||||
cols,
|
||||
})
|
||||
|
||||
const gapStr = computed(() => `${props.gap}px`)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="scrollEl" style="position: relative; width: 100%">
|
||||
<slot v-if="!items.length" name="empty" />
|
||||
|
||||
<!-- 撑出总高度,让滚动条反映真实长度 -->
|
||||
<div :style="{ position: 'relative', height: `${layout.totalHeight}px`, width: '100%' }">
|
||||
<div
|
||||
v-for="p in visibleItems"
|
||||
:key="String(p.key)"
|
||||
:data-index="p.index"
|
||||
:style="{
|
||||
position: 'absolute',
|
||||
top: `${p.top}px`,
|
||||
left: `${p.leftPct}%`,
|
||||
width: `${p.widthPct}%`,
|
||||
height: `${p.height}px`,
|
||||
paddingRight: p.col < cols - 1 ? gapStr : '0',
|
||||
paddingBottom: gapStr,
|
||||
boxSizing: 'border-box',
|
||||
contain: 'layout style',
|
||||
}"
|
||||
>
|
||||
<slot name="item" :item="p.item" :index="p.index" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="items.length"
|
||||
ref="loadMoreSentinel"
|
||||
aria-hidden="true"
|
||||
style="height: 1px; width: 100%"
|
||||
/>
|
||||
|
||||
<slot name="loading" />
|
||||
</div>
|
||||
</template>
|
||||
149
src/components/virtual/VirtualTree.vue
Normal file
149
src/components/virtual/VirtualTree.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<!--
|
||||
============================================================
|
||||
VirtualTree - 虚拟滚动树
|
||||
============================================================
|
||||
|
||||
设计目标:
|
||||
- 不引入新的虚拟化引擎:VirtualTree = useTreeFlatten + VirtualList
|
||||
- useTreeFlatten 按展开状态把树深度优先扁平成一维数组
|
||||
- VirtualList 负责虚拟滚动 / 触底加载 / measureElement 防泄漏
|
||||
- 业务侧只写「一个节点长什么样」,缩进/展开按钮由业务在 #node slot 里画
|
||||
|
||||
展开状态双模式:
|
||||
- 非受控(默认):组件内部维护,可用 defaultExpandedKeys 设初值
|
||||
- 受控:传入 expandedKeys 即进入受控模式,组件只 emit update:expandedKeys
|
||||
|
||||
典型用法(文件树,容器内 scroll):
|
||||
<VirtualTree :nodes="roots" :get-node-id="n => n.path"
|
||||
:estimate-size="32" container-height="60vh">
|
||||
<template #node="{ node, depth, expanded, hasChildren, toggle }">
|
||||
<div :style="{ paddingInlineStart: `${depth * 16}px` }" @click="hasChildren && toggle()">
|
||||
<VIcon v-if="hasChildren">{{ expanded ? 'mdi-chevron-down' : 'mdi-chevron-right' }}</VIcon>
|
||||
{{ node.name }}
|
||||
</div>
|
||||
</template>
|
||||
</VirtualTree>
|
||||
-->
|
||||
|
||||
<script setup lang="ts" generic="T">
|
||||
import { computed, ref } from 'vue'
|
||||
import VirtualList from '@/components/virtual/VirtualList.vue'
|
||||
import { useTreeFlatten } from '@/composables/virtual/useTreeFlatten'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 根节点数组 */
|
||||
nodes: T[]
|
||||
/** 取节点唯一 id(必填,用作虚拟列表 key + 展开状态键) */
|
||||
getNodeId: (node: T) => string | number
|
||||
/** 取子节点数组,默认读 node.children */
|
||||
getChildren?: (node: T) => T[] | undefined
|
||||
/** 估算行高(px) */
|
||||
estimateSize?: number
|
||||
/** 视口外预渲染项数 */
|
||||
overscan?: number
|
||||
/** 使用页面 window scroll */
|
||||
useWindowScroll?: boolean
|
||||
/** 容器内 scroll 模式下的容器高度 */
|
||||
containerHeight?: string | number
|
||||
/** 受控模式:传入即进入受控,组件只 emit update:expandedKeys */
|
||||
expandedKeys?: (string | number)[]
|
||||
/** 非受控模式的初始展开 id 列表 */
|
||||
defaultExpandedKeys?: (string | number)[]
|
||||
}>(),
|
||||
{
|
||||
getChildren: (node: any) => node?.children,
|
||||
estimateSize: 32,
|
||||
overscan: 8,
|
||||
useWindowScroll: false,
|
||||
containerHeight: '100%',
|
||||
expandedKeys: undefined,
|
||||
defaultExpandedKeys: () => [],
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
loadMore: []
|
||||
'update:expandedKeys': [keys: (string | number)[]]
|
||||
toggle: [id: string | number, expanded: boolean]
|
||||
}>()
|
||||
|
||||
// 展开状态:受控(props.expandedKeys 已传)走 emit,非受控走内部 ref。
|
||||
const internalExpanded = ref<Set<string | number>>(new Set(props.defaultExpandedKeys))
|
||||
const isControlled = computed(() => props.expandedKeys !== undefined)
|
||||
const expandedSet = computed<Set<string | number>>(() =>
|
||||
isControlled.value ? new Set(props.expandedKeys) : internalExpanded.value,
|
||||
)
|
||||
|
||||
function isExpanded(id: string | number) {
|
||||
return expandedSet.value.has(id)
|
||||
}
|
||||
|
||||
function setExpanded(id: string | number, expanded: boolean) {
|
||||
const next = new Set(expandedSet.value)
|
||||
if (expanded) next.add(id)
|
||||
else next.delete(id)
|
||||
if (isControlled.value) emit('update:expandedKeys', [...next])
|
||||
else internalExpanded.value = next
|
||||
emit('toggle', id, expanded)
|
||||
}
|
||||
|
||||
function toggle(id: string | number) {
|
||||
setExpanded(id, !expandedSet.value.has(id))
|
||||
}
|
||||
|
||||
// Base Layer:按展开状态扁平化(纯 computed),结果喂给 VirtualList
|
||||
const flatNodes = useTreeFlatten<T>({
|
||||
nodes: () => props.nodes,
|
||||
getId: props.getNodeId,
|
||||
getChildren: node => props.getChildren(node),
|
||||
isExpanded,
|
||||
})
|
||||
|
||||
const listRef = ref<any>(null)
|
||||
|
||||
defineExpose({
|
||||
toggle,
|
||||
expand: (id: string | number) => setExpanded(id, true),
|
||||
collapse: (id: string | number) => setExpanded(id, false),
|
||||
isExpanded,
|
||||
getFlatNodes: () => flatNodes.value,
|
||||
scrollToIndex: (idx: number, opts?: { align?: 'start' | 'center' | 'end' | 'auto' }) =>
|
||||
listRef.value?.scrollToIndex(idx, opts),
|
||||
getScrollElement: () => listRef.value?.getScrollElement?.() ?? null,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VirtualList
|
||||
ref="listRef"
|
||||
:items="flatNodes"
|
||||
key-field="id"
|
||||
:estimate-size="estimateSize"
|
||||
:overscan="overscan"
|
||||
:use-window-scroll="useWindowScroll"
|
||||
:container-height="containerHeight"
|
||||
@load-more="emit('loadMore')"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<slot
|
||||
name="node"
|
||||
:node="item.node"
|
||||
:id="item.id"
|
||||
:depth="item.depth"
|
||||
:expanded="item.expanded"
|
||||
:has-children="item.hasChildren"
|
||||
:parent-id="item.parentId"
|
||||
:toggle="() => toggle(item.id)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #empty>
|
||||
<slot name="empty" />
|
||||
</template>
|
||||
|
||||
<template #loading>
|
||||
<slot name="loading" />
|
||||
</template>
|
||||
</VirtualList>
|
||||
</template>
|
||||
Reference in New Issue
Block a user