Feat/virtualizarefactor: virtualization rework — unify Virtual components, fix memory leaks, migrate 15+ consumerstion rework (#472)

This commit is contained in:
Aqr-K
2026-05-15 21:15:30 +08:00
committed by GitHub
parent 0fda7c70de
commit 5953496d84
51 changed files with 2398 additions and 2130 deletions

View File

@@ -14,33 +14,95 @@ function rgbStringToHex(rgbArray: number[]): string {
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
}
// 主色调缓存:相同 URL 不重复经过 ColorThief 的 canvas 解码
const DOMINANT_COLOR_CACHE_MAX = 200
const dominantColorCache = new Map<string, string>()
function rememberDominantColor(key: string, value: string) {
if (!key) return
if (dominantColorCache.size >= DOMINANT_COLOR_CACHE_MAX) {
const first = dominantColorCache.keys().next().value
if (first !== undefined) dominantColorCache.delete(first)
}
dominantColorCache.set(key, value)
}
// 提取主要颜色
export async function getDominantColor(image: HTMLImageElement): Promise<string> {
const colorThief = new ColorThief()
const dominantColor = colorThief.getColor(image)
return rgbStringToHex(dominantColor)
const cacheKey = image?.currentSrc || image?.src || ''
const cached = cacheKey ? dominantColorCache.get(cacheKey) : undefined
if (cached) return cached
try {
const colorThief = new ColorThief()
const dominantColor = colorThief.getColor(image)
const hex = rgbStringToHex(dominantColor)
rememberDominantColor(cacheKey, hex)
return hex
} catch (e) {
console.warn('getDominantColor failed', e)
return '#28A9E1'
}
}
// 预加载缓存:已成功加载的 URL 不再重复创建 Image 对象
const PRELOAD_CACHE_MAX = 50
const preloadedUrls = new Set<string>()
function rememberPreloaded(url: string) {
if (!url) return
if (preloadedUrls.size >= PRELOAD_CACHE_MAX) {
const first = preloadedUrls.values().next().value
if (first !== undefined) preloadedUrls.delete(first)
}
preloadedUrls.add(url)
}
// 预加载图片
export async function preloadImage(url: string): Promise<boolean> {
if (!url) return false
if (preloadedUrls.has(url)) return true
return new Promise(resolve => {
const img = new Image()
img.decoding = 'async'
let settled = false
img.onload = () => resolve(true)
img.onerror = () => resolve(false)
const finish = (ok: boolean) => {
if (settled) return
settled = true
img.onload = null
img.onerror = null
window.clearTimeout(timeout)
if (ok) rememberPreloaded(url)
else img.src = '' // 释放解码位图
resolve(ok)
}
// 设置超时,防止图片长时间加载
const timeout = setTimeout(() => {
img.src = ''
resolve(false)
}, 5000) // 5秒超时
img.onload = () => finish(true)
img.onerror = () => finish(false)
const timeout = window.setTimeout(() => finish(false), 5000)
img.src = url
// 如果图片已经缓存,onload可能不会触发
if (img.complete) {
clearTimeout(timeout)
resolve(true)
}
// 命中浏览器缓存时 onload 可能不会触发
if (img.complete && img.naturalWidth > 0) finish(true)
})
}
// TMDB 图片域名地址(仅作为兜底,调用方应优先用 globalSettings.TMDB_IMAGE_DOMAIN
const TMDB_PATH_RE = /\/t\/p\/(original|w\d+|h\d+|w\d+_and_h\d+_bestv2)\//
/**
* 把 TMDB 图片 URL 重置到指定渲染尺寸。非 TMDB URL豆瓣 / Bangumi / 自定义代理)原样返回。
* 用于在卡片场景下避免下载 / 解码 1MP+ 的原图。
*
* 常见尺寸w92 / w154 / w185 / w342 / w500 / w780 / originalposter, backdrop
* w45 / w185 / h632 / originalprofile
*/
export function tmdbResize(
url: string | undefined | null,
size: 'w92' | 'w154' | 'w185' | 'w342' | 'w500' | 'w780' | 'original',
): string {
if (!url) return ''
return url.replace(TMDB_PATH_RE, `/t/p/${size}/`)
}

View File

@@ -45,6 +45,13 @@ api.interceptors.response.use(
return response.data
},
error => {
// 请求被主动取消(路由切换 / 组件卸载触发 requestOptimizer abort
// 这不是错误:原样透传 cancel error让调用方用 axios.isCancel() 识别并静默处理。
// 不能落到下面 new Error(error.message) 分支——那会把 cancel 签名抹掉,
// 调用方只能看到一个 message='canceled' 的普通 Error被迫当错误打日志。
if (axios.isCancel(error)) {
return Promise.reject(error)
}
if (!error.response) {
// 网络错误或请求超时 - 通知离线状态管理系统
const isNetworkError =

View File

@@ -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-220pxw342 在 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-srcDouban才走 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;

View File

@@ -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') {

View File

@@ -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)
}
// 安装插件

View File

@@ -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)
}
// 显示更新日志

View File

@@ -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-600pxw780 在 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

View File

@@ -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">

View File

@@ -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')

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -0,0 +1,199 @@
<!--
============================================================
VirtualGrid - @tanstack/vue-virtual 二维虚拟网格兼容层
============================================================
设计目标
- 内部把扁平 items[] 按外部传入的 `columns` 数值打包成 rows[][]
- 对业务屏蔽 row chunking 细节业务只写一项卡片
- 网格只在垂直方向虚拟化水平方向用 CSS Grid 等分
- 支持两种滚动模式容器内 scroll默认+ 页面 window scrolluseWindowScroll=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 LayerscrollMargin 追踪window resize + body ResizeObserver 自管理 + 卸载清理)
const { scrollMargin } = useWindowScrollMargin(scrollEl, () => props.useWindowScroll)
// Base Layertanstack 桥接useVirtualizer/useWindowVirtualizer 二选一 + measureRef null 转发)
// 网格只在垂直方向虚拟化「行」,不传 getItemKeykey 由 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>

View File

@@ -0,0 +1,189 @@
<!--
============================================================
VirtualList - @tanstack/vue-virtual 一维虚拟列表兼容层
============================================================
设计目标
- headless useVirtualizer 封装成 slot SFC
- 业务侧只关心一项 item 长什么样
- 支持两种滚动模式容器内 scroll默认+ 页面 window scrolluseWindowScroll=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 LayerscrollMargin 追踪window resize + body ResizeObserver 自管理 + 卸载清理)
const { scrollMargin } = useWindowScrollMargin(scrollEl, () => props.useWindowScroll)
// Base Layertanstack 桥接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>

View File

@@ -0,0 +1,227 @@
<!--
============================================================
VirtualMasonry - 不等高瀑布流 + 窗口虚拟化
============================================================
VirtualGrid 的区别
- VirtualGrid 假定每张卡等高适合海报这种 2:3 等比卡
- VirtualMasonry 支持每项独立高度Pinterest 混排
布局算法
- items[] 按到 N 每来一项放进当前最矮的那一列
- 总高度 = max(列高)可视范围按 scrollY+overscan 过滤位置
虚拟化策略
- 不依赖 @tanstack/vue-virtualmasonry 没有"行"的概念
- 监听 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 LayerscrollMargin 追踪。Masonry 永远是 window scrollenabled 恒为 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 - scrollMarginscrollMargin 是容器在文档里的偏移)。
// 所以可视区间 [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>

View 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>

View File

@@ -68,61 +68,77 @@ export function useDynamicHeaderTab() {
},
}
// 收集所有 watch 句柄,组件 scope 销毁时主动 stop——
// 即使在 keep-alive 缓存的页面里scope 仍归属当前组件,显式 stop
// 可保证 ReactiveEffect/Dep 链条释放,避免长跑场景累积。
const watchStops: Array<() => void> = []
// 如果启用了PWA状态恢复监听PWA状态变化并同步到modelValue
// 但只在非激活状态下响应,避免干扰页面激活时的状态
if (pwaTabState) {
watch(pwaTabState.activeTab, newTab => {
if (newTab && newTab !== config.modelValue.value) {
config.modelValue.value = newTab
// 更新tabConfig并重新注册
tabConfig.modelValue = newTab
if (registerDynamicHeaderTab) {
registerDynamicHeaderTab(tabConfig)
watchStops.push(
watch(pwaTabState.activeTab, newTab => {
if (newTab && newTab !== config.modelValue.value) {
config.modelValue.value = newTab
// 更新tabConfig并重新注册
tabConfig.modelValue = newTab
if (registerDynamicHeaderTab) {
registerDynamicHeaderTab(tabConfig)
}
}
}
})
}),
)
}
// 监听modelValue变化并更新配置
watch(config.modelValue, newValue => {
tabConfig.modelValue = newValue
// 同步到PWA状态
if (pwaTabState && newValue) {
pwaTabState.activeTab.value = newValue
}
// 重新注册以更新值
if (registerDynamicHeaderTab) {
registerDynamicHeaderTab(tabConfig)
} else if (typeof window !== 'undefined') {
// 使用全局方法作为备用
const globalRegister = (window as any).__VUE_INJECT_DYNAMIC_HEADER_TAB__
if (globalRegister) {
globalRegister(tabConfig)
watchStops.push(
watch(config.modelValue, newValue => {
tabConfig.modelValue = newValue
// 同步到PWA状态
if (pwaTabState && newValue) {
pwaTabState.activeTab.value = newValue
}
}
})
// 重新注册以更新值
if (registerDynamicHeaderTab) {
registerDynamicHeaderTab(tabConfig)
} else if (typeof window !== 'undefined') {
// 使用全局方法作为备用
const globalRegister = (window as any).__VUE_INJECT_DYNAMIC_HEADER_TAB__
if (globalRegister) {
globalRegister(tabConfig)
}
}
}),
)
// 如果items是computed或ref也需要监听其变化
if (!Array.isArray(config.items)) {
watch(
config.items,
newItems => {
tabConfig.items = newItems
// 重新注册以更新items
if (registerDynamicHeaderTab) {
registerDynamicHeaderTab(tabConfig)
} else if (typeof window !== 'undefined') {
// 使用全局方法作为备用
const globalRegister = (window as any).__VUE_INJECT_DYNAMIC_HEADER_TAB__
if (globalRegister) {
globalRegister(tabConfig)
watchStops.push(
watch(
config.items,
newItems => {
tabConfig.items = newItems
// 重新注册以更新items
if (registerDynamicHeaderTab) {
registerDynamicHeaderTab(tabConfig)
} else if (typeof window !== 'undefined') {
// 使用全局方法作为备用
const globalRegister = (window as any).__VUE_INJECT_DYNAMIC_HEADER_TAB__
if (globalRegister) {
globalRegister(tabConfig)
}
}
}
},
{ deep: true },
},
{ deep: true },
),
)
}
onScopeDispose(() => {
watchStops.forEach(stop => stop())
watchStops.length = 0
})
// 注册函数
const doRegister = () => {
// 确保路由路径是最新的

View File

@@ -1,60 +0,0 @@
import type { Ref } from 'vue'
type InfiniteScrollStatus = 'ok' | 'empty' | 'loading' | 'error'
/**
* 无限滚动 composable
* 用于管理分页显示和无限滚动加载
* @param sourceData - 源数据(响应式引用)
* @param pageSize - 每页显示数量默认20
*/
export function useInfiniteScroll<T>(
sourceData: Ref<T[]>,
pageSize: number = 20
) {
// 显示用的数据列表
const displayDataList = ref<T[]>([])
// 剩余数据列表(用于无限滚动)
const remainingDataList = ref<T[]>([]) as Ref<T[]>
// 初始化数据
function initData() {
if (sourceData.value?.length) {
// 显示前 pageSize 个
displayDataList.value = sourceData.value.slice(0, pageSize) as T[]
// 保存剩余数据
remainingDataList.value = sourceData.value.slice(pageSize) as T[]
} else {
displayDataList.value = []
remainingDataList.value = []
}
}
// 加载更多
function loadMore({ done }: { done: (status: InfiniteScrollStatus) => void }) {
// 从 remainingDataList 中获取最前面的 pageSize 个元素
const itemsToMove = remainingDataList.value.splice(0, pageSize) as T[]
;(displayDataList.value as T[]).push(...itemsToMove)
done('ok')
}
// 重置数据
function reset() {
displayDataList.value = []
remainingDataList.value = []
}
// 监听源数据变化,重新初始化
watch(sourceData, () => {
initData()
}, { deep: true, immediate: true })
return {
displayDataList,
remainingDataList,
initData,
loadMore,
reset,
}
}

View File

@@ -0,0 +1,110 @@
/**
* useScrollRestore - 长列表滚动位置 + 数据持久化恢复
* ============================================================
*
* 用途解决「Scroll-back Blank / 回滚白屏」问题
*
* 工作机制:
* 1. 在 onBeforeRouteLeave / onDeactivated 时保存 scrollTop + items + meta
* 2. 在 onMounted 时检查 sessionStorage若有缓存则恢复 items 与滚动位置
* 3. 数据未缓存时调用业务 loader 拉初始页
*
* 配套要求:
* - 业务组件持有 items ref<any[]>
* - 业务组件持有 VirtualList / VirtualGrid 的 ref用于调 scrollToOffset
* - keep-alive 命中时本 composable 不重复触发恢复onActivated 不接管)
*
* 典型用法:
* const listRef = ref<InstanceType<typeof VirtualList> | null>(null)
* const items = ref<Item[]>([])
* const pageNum = ref(1)
*
* useScrollRestore({
* listRef,
* items,
* getMeta: () => ({ pageNum: pageNum.value }),
* applyMeta: (meta) => { pageNum.value = meta.pageNum ?? 1 },
* loader: () => fetchInitial(),
* })
*/
import type { Ref } from 'vue'
import { nextTick, onMounted, onBeforeUnmount } from 'vue'
import { onBeforeRouteLeave, useRoute } from 'vue-router'
import { useScrollPositionStore } from '@/stores/scrollPosition'
interface ListRefLike {
getScrollElement: () => HTMLElement | null
scrollToOffset: (px: number) => void
}
interface ScrollRestoreOptions<T> {
/** VirtualList / VirtualGrid 的 ref暴露了 getScrollElement / scrollToOffset */
listRef: Ref<ListRefLike | null>
/** 业务侧持有的数据数组 ref */
items: Ref<T[]>
/** 自定义缓存 key默认用当前路由 fullPath */
cacheKey?: () => string
/** 抽取需要持久化的业务元数据(如 pageNum、查询参数 */
getMeta?: () => Record<string, any>
/** 恢复时回写业务元数据 */
applyMeta?: (meta: Record<string, any>) => void
/** 无缓存时的初始加载函数 */
loader?: () => void | Promise<void>
}
export function useScrollRestore<T>(opts: ScrollRestoreOptions<T>) {
const route = useRoute()
const store = useScrollPositionStore()
const resolveKey = () => opts.cacheKey?.() ?? `scroll:${route.fullPath}`
// 单次锁onBeforeRouteLeave + onBeforeUnmount 双钩子防漏,去重避免写两次 store
let savedThisCycle = false
function save() {
if (savedThisCycle) return
const el = opts.listRef.value?.getScrollElement()
if (!el) return
store.save<T>(resolveKey(), {
scrollTop: el.scrollTop,
items: opts.items.value,
meta: opts.getMeta?.(),
})
savedThisCycle = true
}
async function restoreOrLoad() {
savedThisCycle = false // 新一轮挂载,允许下次再保存
const snap = store.restore<T>(resolveKey())
if (snap && snap.items.length > 0) {
// 恢复数据 + 元数据 + 滚动位置
opts.items.value = snap.items as T[]
if (snap.meta) opts.applyMeta?.(snap.meta)
await nextTick()
// 双 rAF 等 virtualizer 把 totalSize 算稳定(首帧可能为 0
requestAnimationFrame(() => {
requestAnimationFrame(() => {
opts.listRef.value?.scrollToOffset(snap.scrollTop)
})
})
} else {
await opts.loader?.()
}
}
onMounted(() => {
void restoreOrLoad()
})
onBeforeRouteLeave(() => {
save()
})
onBeforeUnmount(() => {
// 兜底:被 keep-alive 踢出 / 组件销毁时也要保存savedThisCycle 防双写)
save()
})
return { save, restoreOrLoad }
}

View File

@@ -0,0 +1,44 @@
import { computed, type ComputedRef } from 'vue'
import { useDisplay } from 'vuetify'
/**
* ============================================================
* useBreakpointCols - 视口断点驱动的列数
* ============================================================
*
* 把 Vuetify 的视口断点(`useDisplay()`)映射成一个 cols 数值,
* 喂给 VirtualGrid / VirtualMasonry 的 `:columns` prop。
*
* 适用:全宽路由级网格 —— 列数由窗口宽度决定。
*
* 容器自适应(嵌在窄/可变宽容器里)请改用 `useResponsiveCols`
* 或 `<AutoSizer>` 包一层。
*
* 典型用法:
* <script setup>
* const cols = useBreakpointCols({ xs: 1, sm: 2, md: 3, lg: 4, xl: 5, xxl: 5 })
* </script>
* <template>
* <VirtualGrid :columns="cols" ...> ... </VirtualGrid>
* </template>
*/
export interface Breakpoints {
xs?: number
sm?: number
md?: number
lg?: number
xl?: number
xxl?: number
}
export function useBreakpointCols(breakpoints: Breakpoints): ComputedRef<number> {
const display = useDisplay()
return computed(() => {
if (display.xs.value) return breakpoints.xs ?? 2
if (display.sm.value) return breakpoints.sm ?? 3
if (display.md.value) return breakpoints.md ?? 4
if (display.lg.value) return breakpoints.lg ?? 5
if (display.xl.value) return breakpoints.xl ?? 6
return breakpoints.xxl ?? 6
})
}

View File

@@ -0,0 +1,64 @@
import { ref, watch, nextTick, type MaybeRefOrGetter } from 'vue'
import { useIntersectionObserver } from '@vueuse/core'
/**
* ============================================================
* useLoadMoreSentinel - 虚拟滚动 Base Layer触底/触顶加载哨兵
* ============================================================
*
* 规则:
* - sentinel 进入视口 + items 自上次 fire 起已变化 → fire
* - sentinel 离开视口 → 解锁
* - sentinel 持续 intersecting短列表/大列宽IntersectionObserver
* 不会再回调,必须靠 items.length watcher 兜底重新评估 tryFire
* 否则只能 fire 一次后死锁、首屏填不满。
*
* 业务侧的 onFire 仍需自己持有 loading 锁防并发。
*
* VirtualList 的反向加载(聊天往上加载)= 再调一次本 composable
* 传 enabled 即可。
*
* @param itemsLength 返回当前 items 长度的 getter
* @param onFire 触发加载的回调
* @param root 容器内 scroll 模式的 IntersectionObserver rootwindow scroll 传 undefined
* @param enabled 是否启用(反向加载未开启时返回 false
*/
export function useLoadMoreSentinel(opts: {
itemsLength: () => number
onFire: () => void
root?: MaybeRefOrGetter<HTMLElement | null>
enabled?: () => boolean
}) {
const sentinel = ref<HTMLElement | null>(null)
let isIntersecting = false
let lastFireLen = -1
function tryFire() {
if (opts.enabled && !opts.enabled()) return
if (!isIntersecting) return
if (lastFireLen >= 0 && opts.itemsLength() === lastFireLen) return
lastFireLen = opts.itemsLength()
opts.onFire()
}
useIntersectionObserver(
sentinel,
([entry]: IntersectionObserverEntry[]) => {
isIntersecting = entry.isIntersecting
if (isIntersecting) tryFire()
},
{ root: opts.root, rootMargin: '200px', threshold: 0 },
)
watch(opts.itemsLength, (len: number) => {
if (len === 0) {
// 列表清空(如换搜索词)→ 重置锁状态
lastFireLen = -1
return
}
// 等下一帧,让 DOM 完成布局后再判断 sentinel 位置
nextTick(tryFire)
})
return { sentinel, tryFire }
}

View File

@@ -0,0 +1,48 @@
import { computed, type Ref, type ComputedRef } from 'vue'
import { useElementSize } from '@vueuse/core'
/**
* ============================================================
* useResponsiveCols - 容器宽度驱动的列数
* ============================================================
*
* 用 `@vueuse/core` 的 `useElementSize` 观察容器宽度,
* 按 `minItemWidth` 算出能塞下几列。
*
* 适用:嵌在窄/可变宽容器里的网格 —— dashboard 卡片、对话框等
* 「视口断点表达不了」的场景。
*
* 典型用法(配合 AutoSizer)
* <AutoSizer #default="{ width }">
* <VirtualGrid
* :columns="useResponsiveCols(autoSizerRef, { minItemWidth: 240 })"
* :container-height="'10rem'" ...
* />
* </AutoSizer>
*
* 或直接传一个外层容器 ref
* const wrapperRef = ref<HTMLElement | null>(null)
* const cols = useResponsiveCols(wrapperRef, { minItemWidth: 240 })
*/
export function useResponsiveCols(
containerRef: Ref<HTMLElement | null>,
opts: {
/** 单项最小宽度(含 gap 估算更稳,但不强求) */
minItemWidth: number
/** 最少列数(默认 1避免 width=0 时返回 0 */
min?: number
/** 最多列数(可选上限) */
max?: number
},
): ComputedRef<number> {
const { width } = useElementSize(containerRef)
return computed(() => {
const w = width.value
const minCols = opts.min ?? 1
if (!w || opts.minItemWidth <= 0) return minCols
const raw = Math.floor(w / opts.minItemWidth)
let n = Math.max(minCols, raw)
if (opts.max !== undefined) n = Math.min(opts.max, n)
return n
})
}

View File

@@ -0,0 +1,58 @@
import { computed, type ComputedRef } from 'vue'
/**
* ============================================================
* useTreeFlatten - 虚拟滚动 Base Layer树扁平化
* ============================================================
*
* 把树形数据按「当前展开状态」深度优先扁平成一维数组,
* 交给 VirtualList 做虚拟滚动。纯 computed无 DOM 副作用,易测。
*
* VirtualTree 即「useTreeFlatten + VirtualList」的组合
* 不引入新的虚拟化引擎。
*/
export interface FlatTreeNode<T> {
/** 原始节点 */
node: T
/** 节点唯一 id */
id: string | number
/** 层级深度,根节点为 0 */
depth: number
/** 是否已展开(仅 hasChildren 时有意义) */
expanded: boolean
/** 是否有子节点 */
hasChildren: boolean
/** 父节点 id根节点为 null */
parentId: string | number | null
}
/**
* @param nodes 根节点数组的 getter
* @param getId 取节点唯一 id
* @param getChildren 取子节点数组(无子节点返回 undefined/[]
* @param isExpanded 判断某 id 当前是否展开
*/
export function useTreeFlatten<T>(opts: {
nodes: () => T[]
getId: (node: T) => string | number
getChildren: (node: T) => T[] | undefined
isExpanded: (id: string | number) => boolean
}): ComputedRef<FlatTreeNode<T>[]> {
return computed(() => {
const out: FlatTreeNode<T>[] = []
const walk = (list: T[], depth: number, parentId: string | number | null) => {
for (const node of list) {
const id = opts.getId(node)
const children = opts.getChildren(node)
const hasChildren = !!children && children.length > 0
const expanded = hasChildren && opts.isExpanded(id)
out.push({ node, id, depth, expanded, hasChildren, parentId })
if (expanded && children) walk(children, depth + 1, id)
}
}
walk(opts.nodes(), 0, null)
return out
})
}

View File

@@ -0,0 +1,75 @@
import { computed } from 'vue'
import { useVirtualizer, useWindowVirtualizer } from '@tanstack/vue-virtual'
/**
* ============================================================
* useVirtualizerBridge - 虚拟滚动 Base Layertanstack 桥接
* ============================================================
*
* 封装 useVirtualizer / useWindowVirtualizer 的二选一 + measureRef null 转发。
* 供 VirtualGrid / VirtualList 复用VirtualMasonry 不走 tanstack无 row 概念)。
*
* 为什么必须二选一scroll 事件不冒泡到 <html>
* useVirtualizer + document.scrollingElement 会让 virtualizer 永远以为
* scrollOffset=0 → 虚拟化失效。window scroll 必须用 useWindowVirtualizer。
*
* measureRef 的 null 转发是内存泄漏修复的【唯一真源】——
* 行/项卸载时 Vue 用 null 调用 ref 回调,必须把 null 转发给 measureElement
* 否则 @tanstack/virtual-core 不会执行 elementsCache 的清理分支
* (它只在 measureElement(null) 时遍历并 unobserve 掉 !isConnected 的元素)。
* 不转发的话 ResizeObserver 会强引用每个曾渲染过的元素,
* detached DOM 无法 GC → 钉住其下所有 Vue 组件实例(内存泄漏根因)。
*/
export function useVirtualizerBridge(opts: {
count: () => number
estimateSize: () => number
overscan: () => number
scrollMargin: () => number
getScrollElement: () => HTMLElement | null
useWindowScroll: boolean
/** 可选:把 index 映射为稳定 keyVirtualList 用VirtualGrid 自行做 row chunking 不传) */
getItemKey?: (index: number) => string | number
}) {
const virtualizer = (opts.useWindowScroll
? useWindowVirtualizer({
get count() {
return opts.count()
},
estimateSize: () => opts.estimateSize(),
get overscan() {
return opts.overscan()
},
get scrollMargin() {
return opts.scrollMargin()
},
...(opts.getItemKey ? { getItemKey: opts.getItemKey } : {}),
})
: useVirtualizer({
get count() {
return opts.count()
},
getScrollElement: () => opts.getScrollElement(),
estimateSize: () => opts.estimateSize(),
get overscan() {
return opts.overscan()
},
get scrollMargin() {
return opts.scrollMargin()
},
...(opts.getItemKey ? { getItemKey: opts.getItemKey } : {}),
})) as unknown as ReturnType<typeof useVirtualizer<Element, Element>>
const totalSize = computed(() => virtualizer.value.getTotalSize())
const virtualItems = computed(() => virtualizer.value.getVirtualItems())
function measureRef(el: any) {
if (el instanceof HTMLElement) {
virtualizer.value.measureElement(el)
} else {
// 见文件头注释null 必须转发,否则 ResizeObserver 泄漏 detached DOM。
virtualizer.value.measureElement(null)
}
}
return { virtualizer, totalSize, virtualItems, measureRef }
}

View File

@@ -0,0 +1,75 @@
import { ref, onMounted, onBeforeUnmount, type Ref } from 'vue'
/**
* ============================================================
* useWindowScrollMargin - 虚拟滚动 Base LayerscrollMargin 追踪
* ============================================================
*
* window scroll 模式下virtualizer 需要知道滚动容器顶部相对文档的 Y 偏移
* scrollMargin = getBoundingClientRect().top + scrollY才能把"窗口滚动量"
* 换算成"容器内坐标"。
*
* 何时会变陈旧 —— 列表【上方】的内容高度变化(折叠面板展开、异步内容撑高等),
* 会把列表整体往下推scrollMargin 必须随之更新,否则虚拟项渲染位置整体偏移
* (出现空隙或重叠)。三道防线覆盖:
* 1. window resize —— 视口尺寸变化
* 2. body ResizeObserver —— body 盒子自身变化(内容驱动高度的布局下,
* 上方内容撑高会让 body 长高 → 触发)
* 3. window scrollrAF 节流)—— 自愈兜底:当布局是 `html,body{height:100%}`、
* 滚动发生在 <html> 上时,上方内容撑高【不会】改变 body 盒子 → RO 不触发,
* 此时靠下一次 scroll 重算自愈。正常滚动时 rect.top+scrollY 恒定(写回同值
* 不触发响应式),只有真发生上方位移才会写入新值,故几乎零成本、无抖动。
*
* 残留边角:上方面板展开且用户【不滚动】、同时布局又非内容驱动高度 —— 此时
* 需要消费方在已知的 toggle 时机主动调用返回的 updateScrollMargin() 即可消除。
*
* @param scrollEl 绑定到滚动容器根元素的模板 ref
* @param enabled 是否启用(容器内 scroll 模式返回 falsewindow scroll 返回 true
*/
export function useWindowScrollMargin(scrollEl: Ref<HTMLElement | null>, enabled: () => boolean) {
const scrollMargin = ref(0)
let resizeObserver: ResizeObserver | null = null
let rafId: number | null = null
function updateScrollMargin() {
if (!enabled() || !scrollEl.value || typeof window === 'undefined') {
scrollMargin.value = 0
return
}
scrollMargin.value = scrollEl.value.getBoundingClientRect().top + window.scrollY
}
// scroll 自愈rAF 节流,避免占用滚动热路径。
function onScroll() {
if (rafId !== null) return
rafId = requestAnimationFrame(() => {
rafId = null
updateScrollMargin()
})
}
onMounted(() => {
updateScrollMargin()
if (enabled() && typeof window !== 'undefined') {
window.addEventListener('resize', updateScrollMargin, { passive: true })
window.addEventListener('scroll', onScroll, { passive: true })
resizeObserver = new ResizeObserver(updateScrollMargin)
if (document.body) resizeObserver.observe(document.body)
}
})
onBeforeUnmount(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('resize', updateScrollMargin)
window.removeEventListener('scroll', onScroll)
}
if (rafId !== null) {
cancelAnimationFrame(rafId)
rafId = null
}
resizeObserver?.disconnect()
resizeObserver = null
})
return { scrollMargin, updateScrollMargin }
}

View File

@@ -258,6 +258,9 @@ function appendPluginSidebarMenus() {
}
}
// 未读消息订阅句柄,在 onMounted 里赋值、onBeforeUnmount 里回收
let unsubscribeUnread: (() => void) | null = null
onMounted(async () => {
// 获取菜单列表
startMenus.value = getMenuList(t('menu.start'))
@@ -270,20 +273,23 @@ onMounted(async () => {
appendPluginSidebarMenus()
// 监听全局未读消息事件
const unsubscribe = onUnreadMessage(handleUnreadMessage)
unsubscribeUnread = onUnreadMessage(handleUnreadMessage)
// 监听Service Worker消息
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', handleServiceWorkerMessage)
}
})
// 组件卸载清理监听
onBeforeUnmount(() => {
unsubscribe()
if ('serviceWorker' in navigator) {
navigator.serviceWorker.removeEventListener('message', handleServiceWorkerMessage)
}
})
// 卸载清理必须同步注册:放在 onMounted 的 async 回调里、且在 await 之后,
// Vue 的"当前组件实例"上下文已丢失onBeforeUnmount 会报
// "no active component instance" 并且监听器永远不会被回收。
onBeforeUnmount(() => {
unsubscribeUnread?.()
unsubscribeUnread = null
if ('serviceWorker' in navigator) {
navigator.serviceWorker.removeEventListener('message', handleServiceWorkerMessage)
}
})
</script>

View File

@@ -2,6 +2,7 @@
import api from '@/api'
import { RecommendSource } from '@/api/types'
import MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue'
import VirtualList from '@/components/virtual/VirtualList.vue'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
@@ -269,16 +270,25 @@ onActivated(async () => {
<template>
<div class="mp-recommend">
<!-- 滚动内容区域 -->
<!--
VirtualList 纵向虚拟化 13 SlideView
原先 TransitionGroup 把所有 SlideView 一直挂在 DOM 导致
13 × ~15 visible cards = 195 张卡 + 195 decoded bitmap 常驻
虚拟化后只挂载视口附近 2-3 SlideView
estimate-size=320 9rem 卡片高(216px) + 标题/边距(~100px)
-->
<div class="recommend-content">
<TransitionGroup name="fade">
<MediaCardSlideView
v-for="item in filteredViews"
:key="item.title"
v-bind="item"
:ready="isReady"
class="content-group"
/>
</TransitionGroup>
<VirtualList
:items="filteredViews"
:estimate-size="320"
key-field="title"
:overscan="2"
use-window-scroll
>
<template #item="{ item }">
<MediaCardSlideView v-bind="item" :ready="isReady" class="content-group" />
</template>
</VirtualList>
<div v-if="isReady && filteredViews.length === 0" class="empty-category">
<VIcon icon="mdi-alert-circle-outline" size="large" class="empty-icon" />

View File

@@ -6,13 +6,18 @@ import api from '@/api'
import type { Context } from '@/api/types'
import TorrentCard from '@/components/cards/TorrentCard.vue'
import TorrentItem from '@/components/cards/TorrentItem.vue'
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
import VirtualGrid from '@/components/virtual/VirtualGrid.vue'
import VirtualList from '@/components/virtual/VirtualList.vue'
import { useBreakpointCols } from '@/composables/virtual/useBreakpointCols'
import TorrentFilterBar from '@/components/filter/TorrentFilterBar.vue'
import { useI18n } from 'vue-i18n'
import { useGlobalSettingsStore } from '@/stores/global'
import { useTorrentFilter, type FilterState } from '@/composables/useTorrentFilter'
import { useToast } from 'vue-toastification'
// 列数:按视口断点(路由级全宽页)
const cols = useBreakpointCols({ xs: 1, sm: 2, md: 2, lg: 3, xl: 4, xxl: 4 })
// 国际化
const { t } = useI18n()
@@ -1166,17 +1171,19 @@ onUnmounted(() => {
class="stream-result-item"
/>
</div>
<ProgressiveCardGrid
<VirtualGrid
v-else-if="filteredCardDataList.length > 0"
:items="filteredCardDataList"
:get-item-key="getTorrentItemKey"
:min-item-width="300"
:estimated-item-height="400"
:columns="cols"
:row-estimate-size="400"
:gap="16"
:overscan="3"
use-window-scroll
>
<template #default="{ item }">
<template #item="{ item }">
<TorrentCard :torrent="item" :more="item.more" />
</template>
</ProgressiveCardGrid>
</VirtualGrid>
<!-- 无结果时显示 -->
<div v-if="!progressActive && filteredCardDataList.length === 0" class="no-results">
<VIcon icon="mdi-file-search-outline" size="64" color="grey-lighten-1" />
@@ -1203,19 +1210,19 @@ onUnmounted(() => {
</div>
</div>
<div v-else-if="filteredRowDataList.length > 0" class="resource-list">
<ProgressiveCardGrid
<VirtualList
:items="filteredRowDataList"
:columns="1"
:gap="8"
:estimated-item-height="240"
:overscan-rows="6"
:get-item-key="getTorrentItemKey"
:estimate-size="240"
:overscan="5"
use-window-scroll
>
<template #default="{ item, index }">
<TorrentItem :torrent="item" />
<VDivider v-if="index < filteredRowDataList.length - 1" class="my-2" />
<template #item="{ item, index }">
<div :key="getTorrentItemKey(item, index)">
<TorrentItem :torrent="item" />
<VDivider v-if="index < filteredRowDataList.length - 1" class="my-2" />
</div>
</template>
</ProgressiveCardGrid>
</VirtualList>
</div>
</VCard>
</div>

View File

@@ -32,7 +32,6 @@ const router = createRouter({
path: '/recommend',
component: () => import('../pages/recommend.vue'),
meta: {
keepAlive: true,
requiresAuth: true,
},
},
@@ -40,7 +39,6 @@ const router = createRouter({
path: '/discover',
component: () => import('../pages/discover.vue'),
meta: {
keepAlive: true,
requiresAuth: true,
},
},
@@ -55,7 +53,6 @@ const router = createRouter({
path: '/subscribe/movie',
component: () => import('../pages/subscribe.vue'),
meta: {
keepAlive: true,
requiresAuth: true,
subType: '电影',
},
@@ -64,7 +61,6 @@ const router = createRouter({
path: '/subscribe/tv',
component: () => import('../pages/subscribe.vue'),
meta: {
keepAlive: true,
requiresAuth: true,
subType: '电视剧',
},

View File

@@ -0,0 +1,72 @@
/**
* 滚动位置持久化 store
* 用途解决「Scroll-back Blank / 回滚白屏」问题
*
* 工作机制:
* - 在长列表页面 onBeforeRouteLeave 时保存当前 scrollTop + 已加载数据
* - 在 onMounted 时检查是否有缓存,若有则恢复数据 + 滚动位置
* - 即使 keep-alive 把组件踢出(项目配置 :max="12"),数据/滚动位置仍可恢复
*
* 持久化策略:
* - sessionStorage仅当前浏览器会话有效不污染长期存储
* - 单条记录有 TTL默认 30 分钟),过期自动作废
*/
import { defineStore } from 'pinia'
interface ScrollSnapshot<T = unknown> {
scrollTop: number
items: T[]
/** 业务侧用于恢复分页状态的额外数据 */
meta?: Record<string, any>
savedAt: number
}
const TTL_MS = 30 * 60 * 1000 // 30 分钟
export const useScrollPositionStore = defineStore('scrollPosition', {
state: () => ({
snapshots: {} as Record<string, ScrollSnapshot<any>>,
}),
actions: {
save<T>(key: string, snapshot: Omit<ScrollSnapshot<T>, 'savedAt'>) {
this.snapshots[key] = { ...snapshot, savedAt: Date.now() }
},
restore<T>(key: string): ScrollSnapshot<T> | null {
const snap = this.snapshots[key] as ScrollSnapshot<T> | undefined
if (!snap) return null
// 过期作废
if (Date.now() - snap.savedAt > TTL_MS) {
delete this.snapshots[key]
return null
}
return snap
},
clear(key: string) {
delete this.snapshots[key]
},
clearAll() {
this.snapshots = {}
},
/** 清除所有过期项(启动时可调用) */
cleanExpired() {
const now = Date.now()
for (const key of Object.keys(this.snapshots)) {
if (now - this.snapshots[key].savedAt > TTL_MS) {
delete this.snapshots[key]
}
}
},
},
persist: {
// sessionStorage浏览器会话级不污染 localStorage
storage: typeof window !== 'undefined' ? window.sessionStorage : undefined,
pick: ['snapshots'],
} as any,
})

View File

@@ -3,7 +3,8 @@ import { ref, onMounted } from 'vue'
import api from '@/api'
import type { MediaServerConf, MediaServerPlayItem } from '@/api/types'
import PosterCard from '@/components/cards/PosterCard.vue'
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
import VirtualGrid from '@/components/virtual/VirtualGrid.vue'
import { useResponsiveCols } from '@/composables/virtual/useResponsiveCols'
import { useI18n } from 'vue-i18n'
// 国际化
@@ -15,6 +16,11 @@ const latestList = ref<{ [key: string]: MediaServerPlayItem[] }>({})
// 所有媒体服务器设置
const mediaServers = ref<MediaServerConf[]>([])
// 容器宽度驱动列数dashboard 卡片宽度由布局决定,不能用视口断点)。
// 多个服务器段落共用一个 wrapRef —— 它们在 dashboard 中竖向堆叠、宽度相同。
const wrapRef = ref<HTMLElement | null>(null)
const cols = useResponsiveCols(wrapRef, { minItemWidth: 144 })
// 调用API查询媒体服务器设置
async function loadMediaServerSetting() {
try {
@@ -57,7 +63,7 @@ onActivated(() => {
</script>
<template>
<div>
<div ref="wrapRef">
<VHover v-for="(data, name) in latestList" :key="name">
<template #default="hover">
<VCard v-bind="hover.props">
@@ -68,18 +74,20 @@ onActivated(() => {
<VCardTitle>{{ t('dashboard.latest') }} - {{ name }}</VCardTitle>
</VCardItem>
<ProgressiveCardGrid
<VirtualGrid
:items="data"
:get-item-key="item => item.id || item.link || item.title"
:min-item-width="144"
:item-aspect-ratio="1.5"
:columns="cols"
:row-estimate-size="240"
:gap="12"
:get-item-key="(item: MediaServerPlayItem) => item.id || item.link || item.title"
use-window-scroll
class="mx-3 mb-3"
tabindex="0"
>
<template #default="{ item }">
<template #item="{ item }">
<PosterCard :media="item" />
</template>
</ProgressiveCardGrid>
</VirtualGrid>
</VCard>
</template>
</VHover>

View File

@@ -2,7 +2,8 @@
import api from '@/api'
import type { MediaServerConf, MediaServerLibrary } from '@/api/types'
import LibraryCard from '@/components/cards/LibraryCard.vue'
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
import VirtualGrid from '@/components/virtual/VirtualGrid.vue'
import { useResponsiveCols } from '@/composables/virtual/useResponsiveCols'
import { useI18n } from 'vue-i18n'
// 国际化
@@ -14,6 +15,10 @@ const libraryList = ref<MediaServerLibrary[]>([])
// 所有媒体服务器设置
const mediaServers = ref<MediaServerConf[]>([])
// 容器宽度驱动列数dashboard 卡片宽度由布局决定,不能用视口断点)
const wrapRef = ref<HTMLElement | null>(null)
const cols = useResponsiveCols(wrapRef, { minItemWidth: 240 })
// 调用API查询媒体服务器设置
async function loadMediaServerSetting() {
try {
@@ -70,18 +75,21 @@ onActivated(() => {
</template>
<VCardTitle>{{ t('dashboard.library') }}</VCardTitle>
</VCardItem>
<ProgressiveCardGrid
:items="libraryList"
:get-item-key="item => item.id || item.name"
:min-item-width="240"
:estimated-item-height="160"
class="mx-3 mb-3"
tabindex="0"
>
<template #default="{ item }">
<LibraryCard :media="item" height="10rem" />
</template>
</ProgressiveCardGrid>
<div ref="wrapRef" class="mx-3 mb-3">
<VirtualGrid
:items="libraryList"
:columns="cols"
:row-estimate-size="180"
:gap="12"
:get-item-key="(item: MediaServerLibrary) => item.id || item.name"
use-window-scroll
tabindex="0"
>
<template #item="{ item }">
<LibraryCard :media="item" height="10rem" />
</template>
</VirtualGrid>
</div>
</VCard>
</template>
</VHover>

View File

@@ -2,7 +2,8 @@
import api from '@/api'
import type { MediaServerConf, MediaServerPlayItem } from '@/api/types'
import BackdropCard from '@/components/cards/BackdropCard.vue'
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
import VirtualGrid from '@/components/virtual/VirtualGrid.vue'
import { useResponsiveCols } from '@/composables/virtual/useResponsiveCols'
import { useI18n } from 'vue-i18n'
// 国际化
@@ -14,6 +15,10 @@ const playingList = ref<MediaServerPlayItem[]>([])
// 所有媒体服务器设置
const mediaServers = ref<MediaServerConf[]>([])
// 容器宽度驱动列数dashboard 卡片宽度由布局决定,不能用视口断点)
const wrapRef = ref<HTMLElement | null>(null)
const cols = useResponsiveCols(wrapRef, { minItemWidth: 240 })
// 调用API查询媒体服务器设置
async function loadMediaServerSetting() {
try {
@@ -71,18 +76,21 @@ onActivated(() => {
<VCardTitle>{{ t('dashboard.playing') }}</VCardTitle>
</VCardItem>
<ProgressiveCardGrid
:items="playingList"
:get-item-key="item => item.id || item.link || item.title"
:min-item-width="240"
:estimated-item-height="160"
class="mx-3 mb-3"
tabindex="0"
>
<template #default="{ item }">
<BackdropCard :media="item" height="10rem" />
</template>
</ProgressiveCardGrid>
<div ref="wrapRef" class="mx-3 mb-3">
<VirtualGrid
:items="playingList"
:columns="cols"
:row-estimate-size="180"
:gap="12"
:get-item-key="(item: MediaServerPlayItem) => item.id || item.link || item.title"
use-window-scroll
tabindex="0"
>
<template #item="{ item }">
<BackdropCard :media="item" height="10rem" />
</template>
</VirtualGrid>
</div>
</VCard>
</template>
</VHover>

View File

@@ -2,45 +2,44 @@
import api from '@/api'
import type { MediaInfo } from '@/api/types'
import MediaCard from '@/components/cards/MediaCard.vue'
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
import VirtualGrid from '@/components/virtual/VirtualGrid.vue'
import NoDataFound from '@/components/NoDataFound.vue'
import { useBreakpointCols } from '@/composables/virtual/useBreakpointCols'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
// 列数:按视口断点(路由级全宽页)
const cols = useBreakpointCols({ xs: 3, sm: 4, md: 6, lg: 8, xl: 10, xxl: 12 })
// 输入参数
const props = defineProps({
apipath: String,
params: Object as PropType<{ [key: string]: any }>,
})
// 判断是否有滚动条
function hasScroll() {
return document.body.scrollHeight - (window.innerHeight || document.documentElement.clientHeight) > 2
}
// 当前页码
const page = ref(1)
// 是否加载中
// 是否加载中(同时作为触底加载的锁)
const loading = ref(false)
// 是否加载完成
const isRefreshed = ref(false)
// 是否还有更多
const hasMore = ref(true)
// 使用 shallowRef 避免长列表中的深层代理开销
const dataList = shallowRef<MediaInfo[]>([])
// 用于保存已处理过的 key
// 用于保存已处理过的 key(去重)
const seenKeys = new Set<string>()
// 拼装参数
function getParams() {
let params = {
page: page.value,
}
let params = { page: page.value }
if (props.params) params = { ...params, ...props.params }
return params
}
@@ -61,115 +60,63 @@ const dedupFields = [
function deduplicate(items: MediaInfo[]): MediaInfo[] {
return items.filter(item => {
const key = dedupFields.map(field => String(item[field])).join('~')
if (seenKeys.has(key)) {
return false
}
if (seenKeys.has(key)) return false
seenKeys.add(key)
return true
})
}
function appendData(items: MediaInfo[]) {
dataList.value = dataList.value.concat(items)
}
async function loadPageData() {
const rawData: MediaInfo[] = await api.get(props.apipath!, {
params: getParams(),
})
return {
rawCount: rawData.length,
uniqueData: deduplicate(rawData),
}
}
// 获取列表数据
async function fetchData({ done }: { done: any }) {
// 获取列表数据VirtualGrid @load-more 触发)
async function fetchData() {
if (!props.apipath) return
if (loading.value || !hasMore.value) return
loading.value = true
try {
if (!props.apipath) return
// 如果正在加载中,直接返回
if (loading.value) {
done('ok')
const rawData: MediaInfo[] = await api.get(props.apipath, {
params: getParams(),
})
isRefreshed.value = true
if (!rawData || rawData.length === 0) {
hasMore.value = false
return
}
// 加载到满屏或者加载出错
if (!hasScroll()) {
// 加载多次
while (!hasScroll()) {
// 设置加载中
loading.value = true
// 请求API
const { rawCount, uniqueData } = await loadPageData()
// 取消加载中
loading.value = false
// 标计为已请求完成
isRefreshed.value = true
if (rawCount === 0) {
// 如果没有数据,跳出
done('empty')
return
}
// 合并数据
appendData(uniqueData)
// 页码+1
page.value++
// 返回加载成功
done('ok')
}
} else {
// 加载一次
// 设置加载中
loading.value = true
// 请求API
const { rawCount, uniqueData } = await loadPageData()
// 标计为已请求完成
isRefreshed.value = true
if (rawCount === 0) {
// 如果没有数据,跳出
done('empty')
} else {
// 合并数据
appendData(uniqueData)
// 页码+1
page.value++
// 返回加载成功
done('ok')
}
}
// 取消加载中
loading.value = false
const uniqueData = deduplicate(rawData)
dataList.value = dataList.value.concat(uniqueData)
page.value++
} catch (error) {
console.error(error)
// 返回加载失败
done('error')
} finally {
loading.value = false
}
}
// 初始加载
onMounted(() => {
void fetchData()
})
</script>
<template>
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
<VInfiniteScroll mode="intersect" side="end" :items="dataList" class="overflow-visible pt-3 px-2" @load="fetchData">
<template #loading />
<template #empty />
<ProgressiveCardGrid
v-if="dataList.length > 0"
:items="dataList"
:item-aspect-ratio="1.5"
:get-item-key="item => item.tmdb_id || item.douban_id || item.bangumi_id || item.media_id || item.title"
tabindex="0"
>
<template #default="{ item }">
<MediaCard :media="item" />
</template>
</ProgressiveCardGrid>
<NoDataFound
v-if="dataList.length === 0 && isRefreshed"
error-code="404"
:error-title="t('common.noData')"
:error-description="t('error.networkError')"
/>
</VInfiniteScroll>
<VirtualGrid
v-if="isRefreshed && dataList.length > 0"
:items="dataList"
:columns="cols"
:row-estimate-size="280"
:gap="16"
:overscan="3"
use-window-scroll
class="pt-3 px-3"
@load-more="fetchData"
>
<template #item="{ item }">
<MediaCard :media="item" />
</template>
</VirtualGrid>
<NoDataFound
v-if="dataList.length === 0 && isRefreshed"
error-code="404"
:error-title="t('common.noData')"
:error-description="t('error.networkError')"
/>
</template>

View File

@@ -8,6 +8,11 @@ import { useIntersectionObserver, until } from '@vueuse/core'
const { t } = useI18n()
// 模块级数据缓存:当外层用 VirtualList 虚拟化时SlideView 会随滚动
// 反复 unmount/mount没有缓存会每次重新请求后端公网部署还会打到 TMDB
// 缓存按 apipath 维度,跨实例共享,刷新页面即失效。
const dataCache = new Map<string, MediaInfo[]>()
// 输入参数
const props = defineProps({
apipath: String,
@@ -37,7 +42,15 @@ const containerRef = ref<HTMLElement | null>(null)
async function fetchData() {
try {
if (!props.apipath) return
const cached = dataCache.get(props.apipath)
if (cached) {
dataList.value = cached
if (cached.length > 0) await until(() => props.ready).toBe(true)
componentLoaded.value = true
return
}
dataList.value = await api.get(props.apipath)
dataCache.set(props.apipath, dataList.value)
if (dataList.value.length > 0) {
// 数据获取后,等待 ready 信号再渲染,避免阻塞动画
await until(() => props.ready).toBe(true)
@@ -52,6 +65,9 @@ async function fetchData() {
}
// 使用 IntersectionObserver 实现懒加载
// rootMargin 收窄到 100px仅在 SlideView 真正接近视口时才触发,
// 避免页面初次挂载时多个 SlideView 同时落在探测区导致的层叠 fire +
// 后续 layout shift 引发的"自动一直往下刷"假象。
const { stop } = useIntersectionObserver(
containerRef,
([{ isIntersecting }]) => {
@@ -61,7 +77,7 @@ const { stop } = useIntersectionObserver(
}
},
{
rootMargin: '300px', // 提前加载距离
rootMargin: '100px',
},
)

View File

@@ -2,12 +2,16 @@
import api from '@/api'
import type { Person } from '@/api/types'
import PersonCard from '@/components/cards/PersonCard.vue'
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
import VirtualGrid from '@/components/virtual/VirtualGrid.vue'
import NoDataFound from '@/components/NoDataFound.vue'
import { useBreakpointCols } from '@/composables/virtual/useBreakpointCols'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
// 列数:按视口断点(路由级全宽页)
const cols = useBreakpointCols({ xs: 3, sm: 4, md: 6, lg: 8, xl: 10, xxl: 12 })
// 输入参数
const props = defineProps({
apipath: String,
@@ -15,130 +19,67 @@ const props = defineProps({
type: String,
})
// 判断是否有滚动条
function hasScroll() {
return document.body.scrollHeight - (window.innerHeight || document.documentElement.clientHeight) > 2
}
// 当前页码
const page = ref(1)
// 是否加载中
const loading = ref(false)
// 是否加载完成
const isRefreshed = ref(false)
// 使用 shallowRef 避免长列表中的深层代理开销
const hasMore = ref(true)
const dataList = shallowRef<Person[]>([])
function appendData(items: Person[]) {
dataList.value = dataList.value.concat(items)
}
async function loadPageData() {
return api.get(props.apipath!, {
params: getParams(),
}) as Promise<Person[]>
}
// 拼装参数
function getParams() {
let params = {
page: page.value,
}
let params = { page: page.value }
if (props.params) params = { ...params, ...props.params }
return params
}
// 获取列表数据
async function fetchData({ done }: { done: any }) {
async function fetchData() {
if (!props.apipath) return
if (loading.value || !hasMore.value) return
loading.value = true
try {
if (!props.apipath) return
// 如果正在加载中,直接返回
if (loading.value) {
done('ok')
const currentData = (await api.get(props.apipath, {
params: getParams(),
})) as Person[]
isRefreshed.value = true
if (!currentData || currentData.length === 0) {
hasMore.value = false
return
}
// 加载到满屏或者加载出错
if (!hasScroll()) {
// 加载多次
while (!hasScroll()) {
// 设置加载中
loading.value = true
// 请求API
const currentData = await loadPageData()
// 取消加载中
loading.value = false
// 标计为已请求完成
isRefreshed.value = true
if (currentData.length === 0) {
// 如果没有数据,跳出
done('empty')
return
} else {
// 合并数据
appendData(currentData)
// 页码+1
page.value++
// 返回加载成功
done('ok')
}
}
} else {
// 加载一次
// 设置加载中
loading.value = true
// 请求API
const currentData = await loadPageData()
// 标计为已请求完成
isRefreshed.value = true
if (currentData.length === 0) {
// 如果没有数据,跳出
done('empty')
} else {
// 合并数据
appendData(currentData)
// 页码+1
page.value++
// 返回加载成功
done('ok')
}
// 取消加载中
loading.value = false
}
dataList.value = dataList.value.concat(currentData)
page.value++
} catch (error) {
console.error(error)
// 返回加载失败
done('error')
} finally {
loading.value = false
}
}
onMounted(() => {
void fetchData()
})
</script>
<template>
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
<VInfiniteScroll mode="intersect" side="end" :items="dataList" class="overflow-visible px-3" @load="fetchData">
<template #loading />
<template #empty />
<ProgressiveCardGrid
v-if="dataList.length > 0"
:items="dataList"
:item-aspect-ratio="1.5"
:get-item-key="item => item.id"
tabindex="0"
>
<template #default="{ item }">
<PersonCard :person="item" />
</template>
</ProgressiveCardGrid>
<NoDataFound
v-if="dataList.length === 0 && isRefreshed"
error-code="404"
:error-title="t('common.noData')"
:error-description="t('error.networkError')"
/>
</VInfiniteScroll>
<VirtualGrid
v-if="isRefreshed && dataList.length > 0"
:items="dataList"
:columns="cols"
:row-estimate-size="260"
:gap="16"
:overscan="3"
key-field="id"
use-window-scroll
class="pt-3 px-3"
@load-more="fetchData"
>
<template #item="{ item }">
<PersonCard :person="item" />
</template>
</VirtualGrid>
<NoDataFound
v-if="dataList.length === 0 && isRefreshed"
error-code="404"
:error-title="t('common.noData')"
:error-description="t('error.networkError')"
/>
</template>

View File

@@ -41,6 +41,8 @@ async function fetchData() {
}
}
// rootMargin 收窄到 100px避免页面初次挂载时多个 SlideView 同时落在探测区
// 引起层叠 fire与 MediaCardSlideView 保持一致)。
const { stop } = useIntersectionObserver(
containerRef,
([{ isIntersecting }]) => {
@@ -50,7 +52,7 @@ const { stop } = useIntersectionObserver(
}
},
{
rootMargin: '300px',
rootMargin: '100px',
},
)

View File

@@ -10,10 +10,15 @@ import { getPluginTabs } from '@/router/i18n-menu'
import { useDynamicButton } from '@/composables/useDynamicButton'
import { useI18n } from 'vue-i18n'
import PluginMixedSortCard from '@/components/cards/PluginMixedSortCard.vue'
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
import VirtualGrid from '@/components/virtual/VirtualGrid.vue'
import VirtualList from '@/components/virtual/VirtualList.vue'
import { useBreakpointCols } from '@/composables/virtual/useBreakpointCols'
import { usePWA } from '@/composables/usePWA'
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
// 列数:按视口断点(路由级全宽页,三个 VirtualGrid 共用:已安装/插件文件夹/插件市场)
const cols = useBreakpointCols({ xs: 1, sm: 2, md: 3, lg: 4, xl: 5, xxl: 5 })
// 国际化
const { t } = useI18n()
@@ -298,6 +303,19 @@ const installedScrollToIndex = computed(() => {
return targetIndex >= 0 ? targetIndex : undefined
})
// VirtualGrid 实例 ref用于命令式 scrollToRow桥接 installedScrollToIndex 跳卡片需求)
const installedGridRef = ref<any>(null)
const marketGridRef = ref<any>(null)
watch(installedScrollToIndex, idx => {
if (idx === undefined || !installedGridRef.value) return
const cols = installedGridRef.value.cols?.value ?? 4
const rowIdx = Math.floor(idx / cols)
requestAnimationFrame(() => {
installedGridRef.value?.scrollToRow(rowIdx)
})
})
// 获取文件夹内筛选后的插件
const getFilteredFolderPlugins = (folderName: string) => {
const folderData = pluginFolders.value[folderName]
@@ -919,12 +937,11 @@ watch([dataList, installedFilter, hasUpdateFilter, enabledFilter], () => {
})
})
// 插件市场加载更多数据
function loadMarketMore({ done }: { done: any }) {
// 从 dataList 中获取最前面的 20 个元素
// 插件市场加载更多数据VirtualGrid @load-more 触发)
function loadMarketMore() {
if (sortedUninstalledList.value.length === 0) return
const itemsToMove = sortedUninstalledList.value.splice(0, 20)
displayUninstalledList.value.push(...itemsToMove)
done('ok')
}
// 组件挂载后
@@ -1568,15 +1585,17 @@ function onDragStartPlugin(evt: any) {
/>
</template>
</Draggable>
<ProgressiveCardGrid
<VirtualGrid
v-else-if="shouldVirtualizeInstalledMainList"
ref="installedGridRef"
:items="mixedSortList"
:get-item-key="item => `${item.type}:${item.id}`"
:min-item-width="256"
:estimated-item-height="180"
:scroll-to-index="installedScrollToIndex"
:columns="cols"
:row-estimate-size="260"
:gap="16"
:overscan="3"
use-window-scroll
>
<template #default="{ item }">
<template #item="{ item }">
<PluginMixedSortCard
:item="item"
:plugin-statistics="PluginStatistics"
@@ -1595,7 +1614,7 @@ function onDragStartPlugin(evt: any) {
@drop-to-folder="(event, folderName) => handleDropToFolder(event, folderName)"
/>
</template>
</ProgressiveCardGrid>
</VirtualGrid>
</template>
<template v-else>
@@ -1627,14 +1646,17 @@ function onDragStartPlugin(evt: any) {
/>
</template>
</Draggable>
<ProgressiveCardGrid
<VirtualGrid
v-else-if="shouldVirtualizeInstalledFolderList"
:items="draggableFolderPlugins"
:get-item-key="item => item.id"
:min-item-width="256"
:estimated-item-height="180"
:columns="cols"
:row-estimate-size="260"
:gap="16"
:overscan="3"
key-field="id"
use-window-scroll
>
<template #default="{ item }">
<template #item="{ item }">
<PluginMixedSortCard
:item="{ type: 'plugin', id: item.id, data: item, order: 0 }"
:plugin-statistics="PluginStatistics"
@@ -1650,7 +1672,7 @@ function onDragStartPlugin(evt: any) {
@remove-from-folder="removeFromFolder"
/>
</template>
</ProgressiveCardGrid>
</VirtualGrid>
</template>
</div>
@@ -1671,28 +1693,22 @@ function onDragStartPlugin(evt: any) {
<div>
<LoadingBanner v-if="!isAppMarketLoaded || isMarketRefreshing" class="mt-12" />
<!-- 资源列表 -->
<VInfiniteScroll
v-if="isAppMarketLoaded && !isMarketRefreshing"
mode="intersect"
side="end"
<VirtualGrid
v-if="isAppMarketLoaded && !isMarketRefreshing && displayUninstalledList.length > 0"
ref="marketGridRef"
:items="displayUninstalledList"
@load="loadMarketMore"
:columns="cols"
:row-estimate-size="280"
:gap="16"
:overscan="3"
use-window-scroll
class="overflow-visible"
@load-more="loadMarketMore"
>
<template #loading />
<template #empty />
<ProgressiveCardGrid
v-if="displayUninstalledList.length > 0"
:items="displayUninstalledList"
:get-item-key="item => `${item.id}_v${item.plugin_version}`"
:min-item-width="256"
:estimated-item-height="260"
>
<template #default="{ item }">
<PluginAppCard :plugin="item" :count="PluginStatistics[item.id || '0']" @install="pluginInstalled" />
</template>
</ProgressiveCardGrid>
</VInfiniteScroll>
<template #item="{ item }">
<PluginAppCard :plugin="item" :count="PluginStatistics[item.id || '0']" @install="pluginInstalled" />
</template>
</VirtualGrid>
<NoDataFound
v-if="displayUninstalledList.length === 0 && isAppMarketLoaded"
error-code="404"
@@ -1767,8 +1783,8 @@ function onDragStartPlugin(evt: any) {
</VToolbar>
<VDialogCloseBtn @click="closeSearchDialog" />
<VList v-if="filterPlugins.length > 0" lines="two">
<VVirtualScroll :items="filterPlugins">
<template #default="{ item }">
<VirtualList :items="filterPlugins" :estimate-size="80" :overscan="6" container-height="60vh">
<template #item="{ item }">
<VListItem @click="openPlugin(item)">
<template #prepend>
<VAvatar>
@@ -1800,7 +1816,7 @@ function onDragStartPlugin(evt: any) {
</VListItemSubtitle>
</VListItem>
</template>
</VVirtualScroll>
</VirtualList>
</VList>
</VCard>
</VDialog>

View File

@@ -4,30 +4,27 @@ import api from '@/api'
import type { DownloadingInfo } from '@/api/types'
import NoDataFound from '@/components/NoDataFound.vue'
import DownloadingCard from '@/components/cards/DownloadingCard.vue'
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
import VirtualGrid from '@/components/virtual/VirtualGrid.vue'
import { useUserStore } from '@/stores'
import { useI18n } from 'vue-i18n'
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
import { useBreakpointCols } from '@/composables/virtual/useBreakpointCols'
// 国际化
const { t } = useI18n()
// 列数:按视口断点(路由级全宽页)
const cols = useBreakpointCols({ xs: 1, sm: 2, md: 2, lg: 3, xl: 3, xxl: 3 })
const { useDataRefresh } = useBackgroundOptimization()
// 定义输入参数
const props = defineProps<{
name: string
}>()
// 用户 Store
const userStore = useUserStore()
// 数据列表
const dataList = ref<DownloadingInfo[]>([])
// 是否刷新过
const isRefreshed = ref(false)
// 获取订阅列表数据
async function fetchData() {
try {
dataList.value = await api.get('download/', { params: { name: props.name } })
@@ -37,19 +34,15 @@ async function fetchData() {
}
}
// 刷新状态
const loading = ref(false)
// 下拉刷新
function onRefresh() {
loading.value = true
fetchData()
loading.value = false
}
// 过滤数据,管理员用户显示全部,非管理员只显示自己的订阅
const filteredDataList = computed(() => {
// 从 Store 中获取用户信息
const superUser = userStore.superUser
const userName = userStore.userName
if (superUser) return dataList.value
@@ -60,25 +53,27 @@ const filteredDataList = computed(() => {
const { loading: dataLoading } = useDataRefresh(
'downloading-list',
fetchData,
3000, // 3秒间隔
true // 立即执行
3000,
true,
)
</script>
<template>
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
<VPullToRefresh v-model="loading" @load="onRefresh" :pull-down-threshold="64">
<ProgressiveCardGrid
<VirtualGrid
v-if="filteredDataList.length > 0"
:items="filteredDataList"
:get-item-key="item => item.hash || item.name"
:min-item-width="320"
:estimated-item-height="230"
:columns="cols"
:row-estimate-size="230"
:gap="12"
:overscan="3"
use-window-scroll
>
<template #default="{ item }">
<template #item="{ item }">
<DownloadingCard :info="item" :downloader-name="props.name" />
</template>
</ProgressiveCardGrid>
</VirtualGrid>
<NoDataFound
v-if="filteredDataList.length === 0 && isRefreshed"
error-code="404"

View File

@@ -3,12 +3,16 @@ import api from '@/api'
import type { Site, SiteUserData } from '@/api/types'
import SiteCard from '@/components/cards/SiteCard.vue'
import NoDataFound from '@/components/NoDataFound.vue'
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
import VirtualGrid from '@/components/virtual/VirtualGrid.vue'
import { useBreakpointCols } from '@/composables/virtual/useBreakpointCols'
import { useDynamicButton } from '@/composables/useDynamicButton'
import { useI18n } from 'vue-i18n'
import { usePWA } from '@/composables/usePWA'
import { useToast } from 'vue-toastification'
// 列数:按视口断点(路由级全宽页)
const cols = useBreakpointCols({ xs: 1, sm: 2, md: 3, lg: 4, xl: 5, xxl: 5 })
// 国际化
const { t } = useI18n()
@@ -424,15 +428,18 @@ useDynamicButton({
/>
</template>
</Draggable>
<ProgressiveCardGrid
<VirtualGrid
v-else-if="draggableSiteList.length > 0 && shouldVirtualizeList"
:items="draggableSiteList"
:get-item-key="item => item.id"
:min-item-width="256"
:estimated-item-height="168"
:columns="cols"
:row-estimate-size="220"
:gap="16"
:overscan="3"
key-field="id"
use-window-scroll
class="px-2"
>
<template #default="{ item }">
<template #item="{ item }">
<SiteCard
:site="item"
:data="siteUserDataMap[item.domain]"
@@ -443,7 +450,7 @@ useDynamicButton({
@refresh-stats="handleRefreshStats"
/>
</template>
</ProgressiveCardGrid>
</VirtualGrid>
</div>
<NoDataFound
v-if="draggableSiteList.length === 0 && isRefreshed"

View File

@@ -5,12 +5,16 @@ import type { Subscribe } from '@/api/types'
import NoDataFound from '@/components/NoDataFound.vue'
import SubscribeCard from '@/components/cards/SubscribeCard.vue'
import SubscribeHistoryDialog from '@/components/dialog/SubscribeHistoryDialog.vue'
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
import VirtualGrid from '@/components/virtual/VirtualGrid.vue'
import { useBreakpointCols } from '@/composables/virtual/useBreakpointCols'
import { useUserStore } from '@/stores'
import { useI18n } from 'vue-i18n'
import { useToast } from 'vue-toastification'
import { useConfirm } from '@/composables/useConfirm'
// 列数:按视口断点(路由级全宽页)
const cols = useBreakpointCols({ xs: 1, sm: 2, md: 3, lg: 4, xl: 5, xxl: 5 })
// 国际化
const { t } = useI18n()
@@ -86,6 +90,19 @@ const scrollToIndex = computed(() => {
return targetIndex >= 0 ? targetIndex : undefined
})
// VirtualGrid 实例 ref用于命令式 scrollToRow桥接 scrollToIndex 跳卡片需求)
const gridRef = ref<any>(null)
watch(scrollToIndex, idx => {
if (idx === undefined || !gridRef.value) return
const cols = gridRef.value.cols?.value ?? 4
const rowIdx = Math.floor(idx / cols)
// 等下一帧再跳,确保 virtualizer 已经把 totalSize 算稳定
requestAnimationFrame(() => {
gridRef.value?.scrollToRow(rowIdx)
})
})
// 根据订阅数据判断订阅状态
function getSubscribeStatus(subscribe: Subscribe) {
// 洗版中
@@ -513,16 +530,19 @@ defineExpose({
/>
</template>
</draggable>
<ProgressiveCardGrid
<VirtualGrid
v-else-if="displayList.length > 0 && shouldVirtualizeList"
ref="gridRef"
:items="displayList"
:get-item-key="item => item.id"
:min-item-width="240"
:estimated-item-height="300"
:scroll-to-index="scrollToIndex"
:columns="cols"
:row-estimate-size="280"
:gap="16"
:overscan="3"
key-field="id"
use-window-scroll
class="px-2"
>
<template #default="{ item }">
<template #item="{ item }">
<SubscribeCard
:key="item.id"
:media="item"
@@ -534,7 +554,7 @@ defineExpose({
@select="toggleSelectSubscribe(item.id)"
/>
</template>
</ProgressiveCardGrid>
</VirtualGrid>
<NoDataFound
v-if="displayList.length === 0 && isRefreshed"
error-code="404"

View File

@@ -3,48 +3,41 @@ import api from '@/api'
import type { MediaInfo } from '@/api/types'
import MediaCard from '@/components/cards/MediaCard.vue'
import NoDataFound from '@/components/NoDataFound.vue'
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
import VirtualGrid from '@/components/virtual/VirtualGrid.vue'
import { useBreakpointCols } from '@/composables/virtual/useBreakpointCols'
import { useI18n } from 'vue-i18n'
// 国际化
const { t } = useI18n()
// 列数:按视口断点(路由级全宽页)
const cols = useBreakpointCols({ xs: 3, sm: 4, md: 6, lg: 8, xl: 10, xxl: 12 })
// 输入参数
const props = defineProps({
type: String,
})
// 判断是否有滚动条
function hasScroll() {
return document.body.scrollHeight - (window.innerHeight || document.documentElement.clientHeight) > 2
}
// API
const apipath = 'subscribe/popular'
// 当前页码
const page = ref(1)
// 是否加载中
const loading = ref(false)
// 是否加载完成
const isRefreshed = ref(false)
const hasMore = ref(true)
// 数据列表
const dataList = ref<MediaInfo[]>([])
const currData = ref<MediaInfo[]>([])
// 筛选参数
const filterParams = reactive({
genre_id: '', // 空字符串表示选中"全部"
genre_id: '',
min_rating: 0,
max_rating: 10,
min_sub: 1,
sort_type: 'count', // 默认按热度排序
sort_type: 'count',
})
// 当前Key用于重新加载数据
// 当前 Key用于在筛选条件变化时重置 VirtualGrid 内部状态
const currentKey = ref(0)
// TMDB电影风格字典
@@ -90,115 +83,62 @@ const tmdbTvGenreDict: Record<string, string> = {
'37': t('tmdb.genreType.western'),
}
// 获取当前类型对应的风格字典
const currentGenreDict = computed(() => {
return props.type === '电影' ? tmdbMovieGenreDict : tmdbTvGenreDict
})
// 监听筛选参数变化
// 筛选变化 → 重置列表
watch(
filterParams,
() => {
// 重置数据
dataList.value = []
page.value = 1
hasMore.value = true
isRefreshed.value = false
currentKey.value++
void fetchData()
},
{ deep: true },
)
// 拼装参数
function getParams() {
let params: { [key: string]: any } = {
const params: { [key: string]: any } = {
stype: props.type,
page: page.value,
count: 30,
}
// 添加筛选参数
if (filterParams.genre_id) {
params.genre_id = parseInt(filterParams.genre_id)
}
if (filterParams.min_rating > 0) {
params.min_rating = filterParams.min_rating
}
if (filterParams.max_rating < 10) {
params.max_rating = filterParams.max_rating
}
if (filterParams.min_sub > 1) {
params.min_sub = filterParams.min_sub
}
if (filterParams.sort_type) {
params.sort_type = filterParams.sort_type
}
if (filterParams.genre_id) params.genre_id = parseInt(filterParams.genre_id)
if (filterParams.min_rating > 0) params.min_rating = filterParams.min_rating
if (filterParams.max_rating < 10) params.max_rating = filterParams.max_rating
if (filterParams.min_sub > 1) params.min_sub = filterParams.min_sub
if (filterParams.sort_type) params.sort_type = filterParams.sort_type
return params
}
// 获取列表数据
async function fetchData({ done }: { done: any }) {
async function fetchData() {
if (loading.value || !hasMore.value) return
loading.value = true
try {
// 如果正在加载中,直接返回
if (loading.value) {
done('ok')
const data: MediaInfo[] = await api.get(apipath, { params: getParams() })
isRefreshed.value = true
if (!data || data.length === 0) {
hasMore.value = false
return
}
// 加载到满屏或者加载出错
if (!hasScroll()) {
// 加载多次
while (!hasScroll()) {
// 设置加载中
loading.value = true
// 请求API
currData.value = await api.get(apipath, {
params: getParams(),
})
// 取消加载中
loading.value = false
// 标计为已请求完成
isRefreshed.value = true
if (currData.value.length === 0) {
// 如果没有数据,跳出
done('empty')
return
}
// 合并数据
dataList.value = [...dataList.value, ...currData.value]
// 页码+1
page.value++
// 返回加载成功
done('ok')
}
} else {
// 设置加载中
loading.value = true
// 请求API
currData.value = await api.get(apipath, {
params: getParams(),
})
loading.value = false
// 标计为已请求完成
isRefreshed.value = true
if (currData.value.length === 0) {
// 如果没有数据,跳出
done('empty')
} else {
// 合并数据
dataList.value = [...dataList.value, ...currData.value]
// 页码+1
page.value++
// 返回加载成功
done('ok')
}
}
dataList.value = [...dataList.value, ...data]
page.value++
if (data.length < 30) hasMore.value = false
} catch (error) {
console.error(error)
// 返回加载失败
done('error')
} finally {
loading.value = false
}
}
onMounted(() => {
void fetchData()
})
</script>
<template>
@@ -226,21 +166,16 @@ async function fetchData({ done }: { done: any }) {
<VLabel>{{ t('tmdb.genre') }}</VLabel>
</div>
<VChipGroup v-model="filterParams.genre_id">
<VChip
:color="filterParams.genre_id == '' ? 'primary' : ''"
filter
tile
value=""
>
<VChip :color="filterParams.genre_id == '' ? 'primary' : ''" filter tile value="">
{{ t('common.all') }}
</VChip>
<VChip
v-for="(value, key) in currentGenreDict"
:key="key"
:color="filterParams.genre_id == key ? 'primary' : ''"
filter
tile
:value="key"
v-for="(value, key) in currentGenreDict"
:key="key"
>
{{ value }}
</VChip>
@@ -259,45 +194,37 @@ async function fetchData({ done }: { done: any }) {
:step="1"
class="align-center"
hide-details
>
</VSlider>
/>
</div>
</div>
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
<VInfiniteScroll
mode="intersect"
side="end"
:items="dataList"
class="overflow-visible px-2"
@load="fetchData"
<VirtualGrid
v-if="isRefreshed && dataList.length > 0"
:key="currentKey"
:items="dataList"
:columns="cols"
:row-estimate-size="320"
:gap="16"
:overscan="3"
use-window-scroll
class="pt-2 px-3"
@load-more="fetchData"
>
<template #loading />
<template #empty />
<ProgressiveCardGrid
v-if="dataList.length > 0"
:items="dataList"
:get-item-key="item => item.tmdb_id || item.douban_id || item.bangumi_id || item.media_id || item.title"
:min-item-width="144"
:estimated-item-height="320"
tabindex="0"
>
<template #default="{ item }">
<div>
<MediaCard :media="item" />
<div v-if="item.popularity" class="mt-2 flex flex-row justify-center align-center text-subtitle-2">
<VIcon icon="mdi-fire" color="error" />
<span> {{ item.popularity.toLocaleString() }}</span>
</div>
<template #item="{ item }">
<div>
<MediaCard :media="item" />
<div v-if="item.popularity" class="mt-2 flex flex-row justify-center align-center text-subtitle-2">
<VIcon icon="mdi-fire" color="error" />
<span> {{ item.popularity.toLocaleString() }}</span>
</div>
</template>
</ProgressiveCardGrid>
<NoDataFound
v-if="dataList.length === 0 && isRefreshed"
error-code="404"
:error-title="t('common.noData')"
:error-description="t('subscribe.noPopularData')"
/>
</VInfiniteScroll>
</div>
</template>
</VirtualGrid>
<NoDataFound
v-if="dataList.length === 0 && isRefreshed"
error-code="404"
:error-title="t('common.noData')"
:error-description="t('subscribe.noPopularData')"
/>
</template>

View File

@@ -3,44 +3,37 @@ import api from '@/api'
import type { SubscribeShare } from '@/api/types'
import NoDataFound from '@/components/NoDataFound.vue'
import SubscribeShareCard from '@/components/cards/SubscribeShareCard.vue'
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
import VirtualGrid from '@/components/virtual/VirtualGrid.vue'
import { useBreakpointCols } from '@/composables/virtual/useBreakpointCols'
import { useI18n } from 'vue-i18n'
// 国际化
const { t } = useI18n()
// 定义输入参数
// 列数:按视口断点(路由级全宽页)
const cols = useBreakpointCols({ xs: 1, sm: 2, md: 3, lg: 4, xl: 5, xxl: 5 })
const props = defineProps({
// 过滤关键字
keyword: String,
})
// 判断是否有滚动条
function hasScroll() {
return document.body.scrollHeight - (window.innerHeight || document.documentElement.clientHeight) > 2
}
// API
const apipath = 'subscribe/shares'
// 当前页码
const page = ref(1)
// 搜索关键字
const keyword = ref(props.keyword)
// 筛选参数
const filterParams = reactive({
genre_id: '', // 空字符串表示选中"全部"
min_rating: 0,
max_rating: 10,
sort_type: 'time', // 默认按时间排序
})
// 当前Key用于重新加载数据
const loading = ref(false)
const isRefreshed = ref(false)
const hasMore = ref(true)
const currentKey = ref(0)
// TMDB电影风格字典
const dataList = ref<SubscribeShare[]>([])
const filterParams = reactive({
genre_id: '',
min_rating: 0,
max_rating: 10,
sort_type: 'time',
})
const tmdbMovieGenreDict: Record<string, string> = {
'28': t('tmdb.genreType.action'),
'12': t('tmdb.genreType.adventure'),
@@ -63,7 +56,6 @@ const tmdbMovieGenreDict: Record<string, string> = {
'37': t('tmdb.genreType.western'),
}
// TMDB电视剧风格字典
const tmdbTvGenreDict: Record<string, string> = {
'10759': t('tmdb.genreType.actionAdventure'),
'16': t('tmdb.genreType.animation'),
@@ -83,145 +75,77 @@ const tmdbTvGenreDict: Record<string, string> = {
'37': t('tmdb.genreType.western'),
}
// 获取当前类型对应的风格字典(订阅分享包含电影和电视剧,所以显示所有风格)
const currentGenreDict = computed(() => {
// 合并电影和电视剧风格字典
return { ...tmdbMovieGenreDict, ...tmdbTvGenreDict }
})
const currentGenreDict = computed(() => ({ ...tmdbMovieGenreDict, ...tmdbTvGenreDict }))
// 监听 props.keyword 变化
watch(
() => props.keyword,
newKeyword => {
keyword.value = newKeyword || ''
// 重置页码和数据
page.value = 1
dataList.value = []
page.value = 1
hasMore.value = true
isRefreshed.value = false
currentKey.value++
void fetchData()
},
)
// 监听筛选参数变化
watch(
filterParams,
() => {
// 重置数据
dataList.value = []
page.value = 1
hasMore.value = true
isRefreshed.value = false
currentKey.value++
void fetchData()
},
{ deep: true },
)
// 是否加载中
const loading = ref(false)
// 是否加载完成
const isRefreshed = ref(false)
// 数据列表
const dataList = ref<SubscribeShare[]>([])
const currData = ref<SubscribeShare[]>([])
// 拼装参数
function getParams() {
let params: { [key: string]: any } = {
const params: { [key: string]: any } = {
page: page.value,
count: 30,
name: keyword.value,
}
// 添加筛选参数
if (filterParams.genre_id) {
params.genre_id = parseInt(filterParams.genre_id)
}
if (filterParams.min_rating > 0) {
params.min_rating = filterParams.min_rating
}
if (filterParams.max_rating < 10) {
params.max_rating = filterParams.max_rating
}
if (filterParams.sort_type) {
params.sort_type = filterParams.sort_type
}
if (filterParams.genre_id) params.genre_id = parseInt(filterParams.genre_id)
if (filterParams.min_rating > 0) params.min_rating = filterParams.min_rating
if (filterParams.max_rating < 10) params.max_rating = filterParams.max_rating
if (filterParams.sort_type) params.sort_type = filterParams.sort_type
return params
}
// 获取列表数据
async function fetchData({ done }: { done: any }) {
async function fetchData() {
if (loading.value || !hasMore.value) return
loading.value = true
try {
// 如果正在加载中,直接返回
if (loading.value) {
done('ok')
const data: SubscribeShare[] = await api.get(apipath, { params: getParams() })
isRefreshed.value = true
if (!data || data.length === 0) {
hasMore.value = false
return
}
// 加载到满屏或者加载出错
if (!hasScroll()) {
// 加载多次
while (!hasScroll()) {
// 设置加载中
loading.value = true
// 请求API
currData.value = await api.get(apipath, {
params: getParams(),
})
// 取消加载中
loading.value = false
// 标计为已请求完成
isRefreshed.value = true
if (currData.value.length === 0) {
// 如果没有数据,跳出
done('empty')
return
}
// 合并数据
dataList.value = [...dataList.value, ...currData.value]
// 页码+1
page.value++
// 返回加载成功
done('ok')
}
} else {
// 设置加载中
loading.value = true
// 请求API
currData.value = await api.get(apipath, {
params: getParams(),
})
loading.value = false
// 标计为已请求完成
isRefreshed.value = true
if (currData.value.length === 0) {
// 如果没有数据,跳出
done('empty')
} else {
// 合并数据
dataList.value = [...dataList.value, ...currData.value]
// 页码+1
page.value++
// 返回加载成功
done('ok')
}
}
dataList.value = [...dataList.value, ...data]
page.value++
if (data.length < 30) hasMore.value = false
} catch (error) {
console.error(error)
// 返回加载失败
done('error')
} finally {
loading.value = false
}
}
// 将数据从列表中移除
function removeData(id: number) {
dataList.value = dataList.value.filter(item => item.id !== id)
}
onMounted(() => {
void fetchData()
})
</script>
<template>
<!-- 筛选器 -->
<div class="px-3 mb-4">
<div class="flex justify-start align-center mb-3">
<div class="mr-5">
@@ -245,21 +169,16 @@ function removeData(id: number) {
<VLabel>{{ t('tmdb.genre') }}</VLabel>
</div>
<VChipGroup v-model="filterParams.genre_id">
<VChip
:color="filterParams.genre_id == '' ? 'primary' : ''"
filter
tile
value=""
>
<VChip :color="filterParams.genre_id == '' ? 'primary' : ''" filter tile value="">
{{ t('common.all') }}
</VChip>
<VChip
v-for="(value, key) in currentGenreDict"
:key="key"
:color="filterParams.genre_id == key ? 'primary' : ''"
filter
tile
:value="key"
v-for="(value, key) in currentGenreDict"
:key="key"
>
{{ value }}
</VChip>
@@ -278,40 +197,32 @@ function removeData(id: number) {
:step="1"
class="align-center"
hide-details
>
</VSlider>
/>
</div>
</div>
<VPageContentTitle v-if="keyword" :title="`${t('common.search')}${keyword}`" />
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
<VInfiniteScroll
mode="intersect"
side="end"
:items="dataList"
class="overflow-visible px-2"
@load="fetchData"
<VirtualGrid
v-if="isRefreshed && dataList.length > 0"
:key="currentKey"
:items="dataList"
:columns="cols"
:row-estimate-size="260"
:gap="12"
:overscan="3"
use-window-scroll
class="pt-2"
@load-more="fetchData"
>
<template #loading />
<template #empty />
<ProgressiveCardGrid
v-if="dataList.length > 0"
:items="dataList"
:get-item-key="item => item.id || `${item.tmdbid || item.doubanid || item.name}-${item.share_user}`"
:min-item-width="240"
:estimated-item-height="260"
tabindex="0"
>
<template #default="{ item }">
<SubscribeShareCard :media="item" @delete="removeData(item.id || 0)" />
</template>
</ProgressiveCardGrid>
<NoDataFound
v-if="dataList.length === 0 && isRefreshed"
error-code="404"
:error-title="t('common.noData')"
:error-description="keyword ? t('common.noContent') : t('subscribe.noShareData')"
/>
</VInfiniteScroll>
<template #item="{ item }">
<SubscribeShareCard :media="item" @delete="removeData(item.id || 0)" />
</template>
</VirtualGrid>
<NoDataFound
v-if="dataList.length === 0 && isRefreshed"
error-code="404"
:error-title="t('common.noData')"
:error-description="keyword ? t('common.noContent') : t('subscribe.noShareData')"
/>
</template>

View File

@@ -1,6 +1,7 @@
<script lang="ts" setup>
import type { Message } from '@/api/types'
import MessageCard from '@/components/cards/MessageCard.vue'
import VirtualList from '@/components/virtual/VirtualList.vue'
import api from '@/api'
import { useI18n } from 'vue-i18n'
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
@@ -9,28 +10,28 @@ import { useBackgroundOptimization } from '@/composables/useBackgroundOptimizati
const { t } = useI18n()
const { useSSE } = useBackgroundOptimization()
// 消息列表
// 消息列表(按时间升序:旧 -> 新,最新在底部)
const messages = ref<Message[]>([])
// 当前页数据
const currData = ref<Message[]>([])
// 已加载消息的签名集合
// 使用消息内容签名去重,避免仅按秒级时间戳判断时误吞同一秒内的不同消息。
// 已加载消息的签名集合,仅按秒级时间戳会误吞同一秒内的不同消息
const messageKeys = new Set<string>()
// 是否完成加载
// 是否完成首屏加载(影响空态文案显示)
const isLoaded = ref(false)
// 是否加载中
// 是否加载中(避免并发)
const loading = ref(false)
// 当前页码
// 还有更早的消息可加载?拉到空页后置 false
const hasMoreOlder = ref(true)
// 当前页码:拉取「更早」消息时递增
const page = ref(1)
// 存量消息最新时间
const lastTime = ref('')
// 消息列表滚动容器
// 反向加载阈值。首屏完成 + forceScrollToEnd 之后才打开,否则
// reverse sentinel 在 items 首次渲染时会和跳到底部竞速,多拉一页。
const reverseThreshold = ref(0)
// 虚拟列表实例引用:用于读取 scrollEl 做智能滚动
const messageListRef = ref<any>(null)
// 自动滚动状态
@@ -47,7 +48,8 @@ function getMessageTime(message: Message) {
return message.reg_time || message.date || ''
}
// 生成消息签名
// 生成消息签名(多字段 fallback 链,单字段 keyField 表达不了,
// 所以走 VirtualList 的 getItemKey 函数 prop
function getMessageKey(message: Message) {
return [
message.action ?? '',
@@ -62,12 +64,11 @@ function getMessageKey(message: Message) {
].join('::')
}
// 排序消息列表,确保最新消息始终位于底部
// 排序确保最新消息始终位于底部
function sortMessages(items: Message[]) {
return [...items].sort((a, b) => compareTime(getMessageTime(a), getMessageTime(b)))
}
// 记录最新消息时间
function updateLastTime(message: Message) {
const messageTime = getMessageTime(message)
if (messageTime && compareTime(messageTime, lastTime.value) > 0) {
@@ -76,7 +77,8 @@ function updateLastTime(message: Message) {
}
function getScrollContainer() {
const container = messageListRef.value?.$el ?? messageListRef.value
const container =
messageListRef.value?.getScrollElement?.() ?? messageListRef.value?.$el ?? messageListRef.value
return container instanceof HTMLElement ? container : null
}
@@ -100,21 +102,6 @@ function handleScroll() {
updateAutoScrollState()
}
function bindScrollListener() {
const container = getScrollContainer()
if (!container) {
return
}
container.removeEventListener('scroll', handleScroll)
container.addEventListener('scroll', handleScroll, { passive: true })
updateAutoScrollState()
}
function unbindScrollListener() {
getScrollContainer()?.removeEventListener('scroll', handleScroll)
}
function scrollContainerToEnd() {
const container = getScrollContainer()
if (!container) {
@@ -167,101 +154,71 @@ function forceScrollToEnd() {
requestScrollToEnd(true)
}
// 合并消息到当前列表
function mergeMessages(items: Message[]) {
let hasNewMessage = false
for (const item of sortMessages(items)) {
const messageKey = getMessageKey(item)
if (messageKeys.has(messageKey)) {
continue
}
if (messageKeys.has(messageKey)) continue
messageKeys.add(messageKey)
messages.value.push(item)
updateLastTime(item)
hasNewMessage = true
}
if (hasNewMessage) {
messages.value = sortMessages(messages.value)
}
if (hasNewMessage) messages.value = sortMessages(messages.value)
return hasNewMessage
}
// SSE消息处理函数
// SSE 新消息到达 -> 智能跟随到底
function handleSSEMessage(event: MessageEvent) {
const message = event.data
if (message) {
const object = JSON.parse(message)
if (mergeMessages([object])) {
requestScrollToEnd() // 新消息到达时触发智能滚动
}
if (!message) return
const object = JSON.parse(message)
if (mergeMessages([object])) {
requestScrollToEnd()
}
}
// 使用优化的SSE连接
const { manager, isConnected } = useSSE(
// 使用优化的 SSE 连接
const { manager } = useSSE(
`${import.meta.env.VITE_API_BASE_URL}system/message?role=user`,
handleSSEMessage,
'message-view',
{
backgroundCloseDelay: 5000,
reconnectDelay: 3000,
maxReconnectAttempts: 3,
},
{ backgroundCloseDelay: 5000, reconnectDelay: 3000, maxReconnectAttempts: 3 },
)
// 调用API加载存量消息
async function loadMessages({ done }: { done: any }) {
// 如果正在加载中,直接返回
if (loading.value) {
done('ok')
return
}
// 加载更早一页:首屏 + 用户向上滚动时由 @load-more-reverse 触发
async function loadOlderMessages() {
if (loading.value || !hasMoreOlder.value) return
try {
// 设置加载中
loading.value = true
currData.value = await api.get('message/web', {
params: {
page: page.value,
size: 20,
},
})
// 已加载过
const data = (await api.get('message/web', {
params: { page: page.value, size: 20 },
})) as Message[]
isLoaded.value = true
if (currData.value.length > 0) {
const hasNewMessage = mergeMessages(currData.value)
if (data.length > 0) {
const hasNewMessage = mergeMessages(data)
// 首次加载时滚动到底部
if (page.value === 1 && hasNewMessage) {
requestScrollToEnd(true)
}
// 页码+1
page.value++
// 完成
done('ok')
} else {
// 没有新数据
done('empty')
hasMoreOlder.value = false
}
} catch (error) {
console.error('加载消息失败:', error)
done('error')
} finally {
loading.value = false
}
}
// 主动刷新最新一页消息作为SSE偶发丢流时的兜底
// 主动刷新最新一页,作为 SSE 偶发丢流时的兜底
async function refreshLatestMessages() {
try {
const latestMessages = (await api.get('message/web', {
params: {
page: 1,
size: 20,
},
params: { page: 1, size: 20 },
})) as Message[]
if (mergeMessages(latestMessages)) {
@@ -272,57 +229,41 @@ async function refreshLatestMessages() {
}
}
// 比较yyyy-MM-dd HH:mm:ss时间大小
// 比较时间
function compareTime(time1: string, time2: string) {
if (!time1 && !time2) return 0
if (!time1) return -1
if (!time2) return 1
try {
// 统一时间格式处理,支持多种格式
const normalizeTime = (time: string) => {
// 如果是ISO格式直接使用
if (time.includes('T')) {
return new Date(time).getTime()
}
// 如果是yyyy-MM-dd HH:mm:ss格式替换-为/
if (time.includes('T')) return new Date(time).getTime()
return new Date(time.replaceAll(/-/g, '/')).getTime()
}
const timestamp1 = normalizeTime(time1)
const timestamp2 = normalizeTime(time2)
return timestamp1 - timestamp2
return normalizeTime(time1) - normalizeTime(time2)
} catch (error) {
console.error('时间比较错误:', error, 'time1:', time1, 'time2:', time2)
return 0
}
}
// 图片加载完成时触发智能滚动
// 图片加载完成 -> 智能跟随(图片把行撑高,需要重判距底距离)
function handleImageLoad() {
requestScrollToEnd()
}
// 暂停SSE连接
// 暂停/恢复 SSE
function pauseSSE() {
if (manager) {
manager.removeMessageListener('message-view')
}
manager?.removeMessageListener('message-view')
}
// 恢复SSE连接
function resumeSSE() {
if (manager) {
// 先移除再重建监听确保恢复时拿到一条新的SSE连接。
manager.removeMessageListener('message-view')
manager.addMessageListener('message-view', handleSSEMessage)
}
refreshLatestMessages()
}
// 暴露方法给父组件
defineExpose({
pauseSSE,
resumeSSE,
@@ -330,10 +271,12 @@ defineExpose({
forceScrollToEnd,
})
onMounted(() => {
nextTick(() => {
bindScrollListener()
requestScrollToEnd(true)
onMounted(async () => {
await loadOlderMessages()
await nextTick()
scrollContainerToEnd()
requestAnimationFrame(() => {
reverseThreshold.value = 1
})
})
@@ -345,38 +288,43 @@ onBeforeUnmount(() => {
if (scrollReleaseTimer) {
window.clearTimeout(scrollReleaseTimer)
}
unbindScrollListener()
})
</script>
<template>
<VInfiniteScroll
<VirtualList
ref="messageListRef"
:mode="!isLoaded ? 'intersect' : 'manual'"
side="start"
:items="messages"
class="overflow-auto h-full"
@load="loadMessages"
:load-more-text="t('message.loadMore') + ' ...'"
:estimate-size="160"
:get-item-key="getMessageKey"
:load-more-reverse-threshold="reverseThreshold"
container-height="100%"
class="h-full overflow-auto"
@load-more-reverse="loadOlderMessages"
@scroll="handleScroll"
>
<template #loading>
<LoadingBanner />
<template #empty>
<div class="d-flex justify-center align-center h-full text-medium-emphasis">
{{ isLoaded ? t('message.noMoreData') : '' }}
</div>
</template>
<template #empty> {{ t('message.noMoreData') }} </template>
<VVirtualScroll renderless :items="messages" :item-height="160">
<template #default="{ item, index, itemRef }">
<template #loading>
<LoadingBanner v-if="loading" />
</template>
<template #item="{ item }">
<div
class="chat-group d-flex mt-5 mb-8"
:class="item.action == 1 ? 'flex-row align-start' : 'flex-row-reverse align-end'"
>
<div
:ref="itemRef"
:key="getMessageKey(item) || index"
class="chat-group d-flex mt-5 mb-8"
:class="item.action == 1 ? 'flex-row align-start' : 'flex-row-reverse align-end'"
class="d-inline-flex flex-column"
:class="item.action == 1 ? 'align-start' : 'align-end'"
>
<div class="d-inline-flex flex-column" :class="item.action == 1 ? 'align-start' : 'align-end'">
<MessageCard :message="item" @imageload="handleImageLoad" />
</div>
<MessageCard :message="item" @imageload="handleImageLoad" />
</div>
</template>
</VVirtualScroll>
</VInfiniteScroll>
</div>
</template>
</VirtualList>
</template>

View File

@@ -4,11 +4,15 @@ import type { User } from '@/api/types'
import NoDataFound from '@/components/NoDataFound.vue'
import UserCard from '@/components/cards/UserCard.vue'
import UserAddEditDialog from '@/components/dialog/UserAddEditDialog.vue'
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
import VirtualGrid from '@/components/virtual/VirtualGrid.vue'
import { useBreakpointCols } from '@/composables/virtual/useBreakpointCols'
import { useDynamicButton } from '@/composables/useDynamicButton'
import { useI18n } from 'vue-i18n'
import { usePWA } from '@/composables/usePWA'
// 列数按视口断点路由级全宽页min-item-width=288 → 1xs 2sm 3md 4lg 5xl
const cols = useBreakpointCols({ xs: 1, sm: 2, md: 3, lg: 4, xl: 5, xxl: 5 })
// 国际化
const { t } = useI18n()
@@ -81,19 +85,21 @@ useDynamicButton({
<!-- 加载中提示 -->
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
<!-- 用户卡片网格 -->
<ProgressiveCardGrid
<VirtualGrid
v-if="allUsers.length > 0 && isRefreshed"
:items="allUsers"
:min-item-width="288"
:estimated-item-height="260"
:get-item-key="user => user.id"
:columns="cols"
:row-estimate-size="260"
:gap="16"
key-field="id"
use-window-scroll
class="px-2"
>
<!-- 普通用户卡片 -->
<template #default="{ item }">
<template #item="{ item }">
<UserCard :user="item" :users="allUsers" @remove="loadAllUsers" @save="loadAllUsers" />
</template>
</ProgressiveCardGrid>
</VirtualGrid>
<!-- 无数据提示 -->
<div v-if="allUsers.length === 0 && isRefreshed">

View File

@@ -4,25 +4,20 @@ import { Workflow } from '@/api/types'
import WorkflowAddEditDialog from '@/components/dialog/WorkflowAddEditDialog.vue'
import WorkflowTaskCard from '@/components/cards/WorkflowTaskCard.vue'
import NoDataFound from '@/components/NoDataFound.vue'
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
import VirtualGrid from '@/components/virtual/VirtualGrid.vue'
import { useBreakpointCols } from '@/composables/virtual/useBreakpointCols'
import { useI18n } from 'vue-i18n'
// 国际化
const { t } = useI18n()
// 是否刷新
// 列数:按视口断点(路由级全宽页)
const cols = useBreakpointCols({ xs: 1, sm: 2, md: 2, lg: 3, xl: 4, xxl: 4 })
const isRefreshed = ref(false)
// 新增对话框
const addDialog = ref(false)
// 所有任务
const workflowList = ref<Workflow[]>([])
// 事件类型列表
const eventTypes = ref<Array<{ title: string; value: string }>>([])
// 加载事件类型列表
async function loadEventTypes() {
try {
eventTypes.value = await api.get('workflow/event_types')
@@ -31,7 +26,6 @@ async function loadEventTypes() {
}
}
// 加载数据
async function fetchData() {
try {
workflowList.value = await api.get('workflow/')
@@ -41,7 +35,6 @@ async function fetchData() {
}
}
// 新增完成
function addDone() {
addDialog.value = false
fetchData()
@@ -64,21 +57,25 @@ defineExpose({
openAddDialog,
})
</script>
<template>
<div>
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
<ProgressiveCardGrid
<VirtualGrid
v-if="workflowList.length > 0 && isRefreshed"
:items="workflowList"
:get-item-key="item => item.id"
:min-item-width="288"
:estimated-item-height="420"
:columns="cols"
:row-estimate-size="420"
:gap="12"
:overscan="2"
key-field="id"
use-window-scroll
class="px-2"
>
<template #default="{ item }">
<template #item="{ item }">
<WorkflowTaskCard :workflow="item" :event-types="eventTypes" @refresh="fetchData" />
</template>
</ProgressiveCardGrid>
</VirtualGrid>
<NoDataFound
v-if="workflowList.length === 0 && isRefreshed"
error-code="404"

View File

@@ -3,50 +3,33 @@ import api from '@/api'
import type { WorkflowShare } from '@/api/types'
import NoDataFound from '@/components/NoDataFound.vue'
import WorkflowShareCard from '@/components/cards/WorkflowShareCard.vue'
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
import VirtualGrid from '@/components/virtual/VirtualGrid.vue'
import { useBreakpointCols } from '@/composables/virtual/useBreakpointCols'
import { useI18n } from 'vue-i18n'
// 国际化
const { t } = useI18n()
// 定义输入参数
// 列数:按视口断点(路由级全宽页)
const cols = useBreakpointCols({ xs: 1, sm: 2, md: 2, lg: 3, xl: 4, xxl: 4 })
const props = defineProps({
// 过滤关键字
keyword: String,
})
// 定义事件
const emit = defineEmits(['update'])
// 判断是否有滚动条
function hasScroll() {
return document.body.scrollHeight - (window.innerHeight || document.documentElement.clientHeight) > 2
}
// API
const apipath = 'workflow/shares'
// 当前页码
const page = ref(1)
// 搜索关键字
const keyword = ref(props.keyword)
const loading = ref(false)
const isRefreshed = ref(false)
const hasMore = ref(true)
const currentKey = ref(0)
// 是否加载中
const loading = ref(false)
// 是否加载完成
const isRefreshed = ref(false)
// 数据列表
const dataList = ref<WorkflowShare[]>([])
const currData = ref<WorkflowShare[]>([])
// 事件类型列表
const eventTypes = ref<Array<{ title: string; value: string }>>([])
// 加载事件类型列表
async function loadEventTypes() {
try {
eventTypes.value = await api.get('workflow/event_types')
@@ -59,126 +42,91 @@ watch(
() => props.keyword,
newKeyword => {
keyword.value = newKeyword || ''
page.value = 1
dataList.value = []
page.value = 1
hasMore.value = true
isRefreshed.value = false
currentKey.value++
void fetchData()
},
)
// 拼装参数
function getParams() {
let params = {
return {
page: page.value,
count: 30,
name: keyword.value,
}
return params
}
// 获取列表数据
async function fetchData({ done }: { done: any }) {
async function fetchData() {
if (loading.value || !hasMore.value) return
loading.value = true
try {
// 如果正在加载中,直接返回
if (loading.value) {
done('ok')
const data: WorkflowShare[] = await api.get(apipath, { params: getParams() })
isRefreshed.value = true
if (!data || data.length === 0) {
hasMore.value = false
return
}
// 加载到满屏或者加载出错
if (!hasScroll()) {
// 加载多次
while (!hasScroll()) {
// 设置加载中
loading.value = true
// 请求API
currData.value = await api.get(apipath, {
params: getParams(),
})
// 取消加载中
loading.value = false
// 标计为已请求完成
isRefreshed.value = true
if (currData.value.length === 0) {
// 如果没有数据,跳出
done('empty')
return
}
// 合并数据
dataList.value = [...dataList.value, ...currData.value]
// 页码+1
page.value++
// 返回加载成功
done('ok')
}
} else {
// 设置加载中
loading.value = true
// 请求API
currData.value = await api.get(apipath, {
params: getParams(),
})
loading.value = false
// 标计为已请求完成
isRefreshed.value = true
if (currData.value.length === 0) {
// 如果没有数据,跳出
done('empty')
} else {
// 合并数据
dataList.value = [...dataList.value, ...currData.value]
// 页码+1
page.value++
// 返回加载成功
done('ok')
}
}
dataList.value = [...dataList.value, ...data]
page.value++
if (data.length < 30) hasMore.value = false
} catch (error) {
console.error(error)
// 返回加载失败
done('error')
} finally {
loading.value = false
}
}
// 将数据从列表中移除
function removeData(id: string) {
dataList.value = dataList.value.filter(item => item.id !== id)
}
// 路由激活:刷新事件类型 + 数据(保留原行为)
onActivated(() => {
loadEventTypes()
fetchData({ done: () => {} })
// 仅在尚未加载过时拉首屏,避免每次切回都全量重拉造成白屏
if (!isRefreshed.value || dataList.value.length === 0) {
void fetchData()
}
})
onMounted(() => {
loadEventTypes()
void fetchData()
})
</script>
<template>
<VPageContentTitle v-if="keyword" :title="`${t('common.search')}${keyword}`" />
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
<VInfiniteScroll mode="intersect" side="end" :items="dataList" class="overflow-visible px-2" @load="fetchData" :key="currentKey">
<template #loading />
<template #empty />
<ProgressiveCardGrid
v-if="dataList.length > 0"
:items="dataList"
:get-item-key="item => item.id"
:min-item-width="288"
:estimated-item-height="220"
tabindex="0"
>
<template #default="{ item }">
<WorkflowShareCard
:workflow="item"
:event-types="eventTypes"
@delete="removeData(item.id || '')"
@update="emit('update')"
/>
</template>
</ProgressiveCardGrid>
<NoDataFound
v-if="dataList.length === 0 && isRefreshed"
error-code="404"
:error-title="t('common.noData')"
:error-description="keyword ? t('common.noContent') : t('workflow.noShareData')"
/>
</VInfiniteScroll>
<VirtualGrid
v-if="isRefreshed && dataList.length > 0"
:key="currentKey"
:items="dataList"
:columns="cols"
:row-estimate-size="220"
:gap="12"
:overscan="3"
key-field="id"
use-window-scroll
class="pt-2"
@load-more="fetchData"
>
<template #item="{ item }">
<WorkflowShareCard
:workflow="item"
:event-types="eventTypes"
@delete="removeData(item.id || '')"
@update="emit('update')"
/>
</template>
</VirtualGrid>
<NoDataFound
v-if="dataList.length === 0 && isRefreshed"
error-code="404"
:error-title="t('common.noData')"
:error-description="keyword ? t('common.noContent') : t('workflow.noShareData')"
/>
</template>