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