mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-08 17:26:41 +08:00
Revert "Feat/virtualizarefactor: virtualization rework — unify Virtual components, fix memory leaks, migrate 15+ consumerstion rework (#472)"
This reverts commit 5953496d84.
This commit is contained in:
@@ -35,6 +35,3 @@ package-lock.json
|
|||||||
# iconify dist files
|
# iconify dist files
|
||||||
src/@iconify/*.js
|
src/@iconify/*.js
|
||||||
public/plugin_icon/**
|
public/plugin_icon/**
|
||||||
|
|
||||||
# AI
|
|
||||||
.omc/
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "moviepilot",
|
"name": "moviepilot",
|
||||||
"version": "2.11.4",
|
"version": "2.11.4",
|
||||||
"license": "MIT",
|
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": "dist/service.js",
|
"bin": "dist/service.js",
|
||||||
@@ -29,7 +28,6 @@
|
|||||||
"@fullcalendar/timegrid": "^6.1.15",
|
"@fullcalendar/timegrid": "^6.1.15",
|
||||||
"@fullcalendar/vue3": "^6.1.15",
|
"@fullcalendar/vue3": "^6.1.15",
|
||||||
"@iconify/utils": "^2.2.1",
|
"@iconify/utils": "^2.2.1",
|
||||||
"@tanstack/vue-virtual": "^3.13.24",
|
|
||||||
"@types/crypto-js": "^4.2.2",
|
"@types/crypto-js": "^4.2.2",
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
"@vue-flow/background": "^1.3.2",
|
"@vue-flow/background": "^1.3.2",
|
||||||
|
|||||||
+13
-75
@@ -14,95 +14,33 @@ function rgbStringToHex(rgbArray: number[]): string {
|
|||||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
|
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> {
|
export async function getDominantColor(image: HTMLImageElement): Promise<string> {
|
||||||
const cacheKey = image?.currentSrc || image?.src || ''
|
|
||||||
const cached = cacheKey ? dominantColorCache.get(cacheKey) : undefined
|
|
||||||
if (cached) return cached
|
|
||||||
try {
|
|
||||||
const colorThief = new ColorThief()
|
const colorThief = new ColorThief()
|
||||||
const dominantColor = colorThief.getColor(image)
|
const dominantColor = colorThief.getColor(image)
|
||||||
const hex = rgbStringToHex(dominantColor)
|
return 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> {
|
export async function preloadImage(url: string): Promise<boolean> {
|
||||||
if (!url) return false
|
|
||||||
if (preloadedUrls.has(url)) return true
|
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
const img = new Image()
|
const img = new Image()
|
||||||
img.decoding = 'async'
|
|
||||||
let settled = false
|
|
||||||
|
|
||||||
const finish = (ok: boolean) => {
|
img.onload = () => resolve(true)
|
||||||
if (settled) return
|
img.onerror = () => resolve(false)
|
||||||
settled = true
|
|
||||||
img.onload = null
|
|
||||||
img.onerror = null
|
|
||||||
window.clearTimeout(timeout)
|
|
||||||
if (ok) rememberPreloaded(url)
|
|
||||||
else img.src = '' // 释放解码位图
|
|
||||||
resolve(ok)
|
|
||||||
}
|
|
||||||
|
|
||||||
img.onload = () => finish(true)
|
// 设置超时,防止图片长时间加载
|
||||||
img.onerror = () => finish(false)
|
const timeout = setTimeout(() => {
|
||||||
|
img.src = ''
|
||||||
const timeout = window.setTimeout(() => finish(false), 5000)
|
resolve(false)
|
||||||
|
}, 5000) // 5秒超时
|
||||||
|
|
||||||
img.src = url
|
img.src = url
|
||||||
|
|
||||||
// 命中浏览器缓存时 onload 可能不会触发
|
// 如果图片已经缓存,onload可能不会触发
|
||||||
if (img.complete && img.naturalWidth > 0) finish(true)
|
if (img.complete) {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
resolve(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,13 +45,6 @@ api.interceptors.response.use(
|
|||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
error => {
|
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) {
|
if (!error.response) {
|
||||||
// 网络错误或请求超时 - 通知离线状态管理系统
|
// 网络错误或请求超时 - 通知离线状态管理系统
|
||||||
const isNetworkError =
|
const isNetworkError =
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import noImage from '@images/no-image.jpeg'
|
import noImage from '@images/no-image.jpeg'
|
||||||
import { getLogoUrl } from '@/utils/imageUtils'
|
import { getLogoUrl } from '@/utils/imageUtils'
|
||||||
import axios from 'axios'
|
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import { formatSeason, formatRating } from '@/@core/utils/formatters'
|
import { formatSeason, formatRating } from '@/@core/utils/formatters'
|
||||||
@@ -257,8 +256,6 @@ async function handleCheckSubscribe() {
|
|||||||
)
|
)
|
||||||
isSubscribed.value = subscribed
|
isSubscribed.value = subscribed
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 路由切换会主动 abort 在途请求,cancel 不是错误,静默忽略
|
|
||||||
if (axios.isCancel(error)) return
|
|
||||||
console.error(error)
|
console.error(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -283,8 +280,6 @@ async function handleCheckExists() {
|
|||||||
isExists.value = exists
|
isExists.value = exists
|
||||||
setCachedMediaExistsStatus(getExistsStatusKey(), exists)
|
setCachedMediaExistsStatus(getExistsStatusKey(), exists)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 路由切换会主动 abort 在途请求,cancel 不是错误,静默忽略
|
|
||||||
if (axios.isCancel(error)) return
|
|
||||||
console.error(error)
|
console.error(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -439,36 +434,17 @@ function setupIntersectionObserver() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 包装 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)}`
|
|
||||||
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(() => {
|
const getImgUrl: Ref<string> = computed(() => {
|
||||||
if (imageLoadError.value) return noImage
|
if (imageLoadError.value) return noImage
|
||||||
// 卡片在网格中显示宽度 ~150-220px,w342 在 2x DPR 下足够清晰,
|
const url = props.media?.poster_path?.replace('original', 'w500') ?? noImage
|
||||||
// 比 w500 减少约 53% 的解码位图内存
|
// 使用图片缓存
|
||||||
const url = props.media?.poster_path?.replace('original', 'w342')
|
if (globalSettings.GLOBAL_IMAGE_CACHE)
|
||||||
if (!url) return noImage
|
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
|
||||||
return wrapPosterUrl(url)
|
// 如果地址中包含douban则使用中转代理
|
||||||
})
|
if (url.includes('doubanio.com'))
|
||||||
|
return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
|
||||||
// 计算低画质占位图(LQIP)— TMDB 自带 w92 缩略图 ~5KB,
|
return url
|
||||||
// 公网/慢源场景先秒铺 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)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 移除订阅
|
// 移除订阅
|
||||||
@@ -490,13 +466,6 @@ onBeforeUnmount(() => {
|
|||||||
observer.value?.disconnect()
|
observer.value?.disconnect()
|
||||||
observer.value = null
|
observer.value = null
|
||||||
})
|
})
|
||||||
|
|
||||||
// keep-alive 缓存场景下 onBeforeUnmount 不会触发,需要在 onDeactivated 主动 disconnect
|
|
||||||
// 防止冻结的卡片继续持有 observer 引用
|
|
||||||
onDeactivated(() => {
|
|
||||||
observer.value?.disconnect()
|
|
||||||
observer.value = null
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -514,22 +483,15 @@ onDeactivated(() => {
|
|||||||
}"
|
}"
|
||||||
@click.stop="goMediaDetail(hover.isHovering ?? false)"
|
@click.stop="goMediaDetail(hover.isHovering ?? false)"
|
||||||
>
|
>
|
||||||
<!--
|
|
||||||
lazy-src 和 #placeholder 不能同时给 VImg:
|
|
||||||
placeholder 槽里的 skeleton 会被 Vuetify 叠在 lazy-src 上层渲染,
|
|
||||||
主图加载期间 skeleton 把 LQIP 模糊预览盖成灰屏。
|
|
||||||
有 lazy-src 就让它自己当占位;无 lazy-src(Douban)才走 skeleton。
|
|
||||||
-->
|
|
||||||
<VImg
|
<VImg
|
||||||
aspect-ratio="2/3"
|
aspect-ratio="2/3"
|
||||||
:src="getImgUrl"
|
:src="getImgUrl"
|
||||||
:lazy-src="getLqipUrl || undefined"
|
|
||||||
class="object-cover aspect-w-2 aspect-h-3"
|
class="object-cover aspect-w-2 aspect-h-3"
|
||||||
cover
|
cover
|
||||||
@load="isImageLoaded = true"
|
@load="isImageLoaded = true"
|
||||||
@error="imageLoadError = true"
|
@error="imageLoadError = true"
|
||||||
>
|
>
|
||||||
<template v-if="!getLqipUrl" #placeholder>
|
<template #placeholder>
|
||||||
<div class="w-full h-full">
|
<div class="w-full h-full">
|
||||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||||
</div>
|
</div>
|
||||||
@@ -631,17 +593,6 @@ onDeactivated(() => {
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.media-card {
|
|
||||||
/*
|
|
||||||
告诉浏览器:当本卡片远离视口时跳过 paint 和图片解码。
|
|
||||||
被跳过期间浏览器可以丢弃解码后的 bitmap(图片缓存大头);
|
|
||||||
重新进入视口时按 contain-intrinsic-size 撑出位置后再 paint。
|
|
||||||
auto 关键字让首次 paint 后记忆实际尺寸,避免滚动条跳动。
|
|
||||||
*/
|
|
||||||
content-visibility: auto;
|
|
||||||
contain-intrinsic-size: auto 280px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-card-title {
|
.media-card-title {
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
line-height: 1.25rem;
|
line-height: 1.25rem;
|
||||||
|
|||||||
@@ -26,9 +26,7 @@ function getPersonImage() {
|
|||||||
let url = ''
|
let url = ''
|
||||||
if (personProps.person?.source === 'themoviedb') {
|
if (personProps.person?.source === 'themoviedb') {
|
||||||
if (!personInfo.value?.profile_path) return personIcon
|
if (!personInfo.value?.profile_path) return personIcon
|
||||||
// 头像在 VAvatar size=100 内裁剪显示,w185 已覆盖到 3x DPR,
|
url = `https://${globalSettings.TMDB_IMAGE_DOMAIN}/t/p/w600_and_h900_bestv2${personInfo.value?.profile_path}`
|
||||||
// 比原来的 w600_and_h900_bestv2 减少约 90% 的解码位图内存
|
|
||||||
url = `https://${globalSettings.TMDB_IMAGE_DOMAIN}/t/p/w185${personInfo.value?.profile_path}`
|
|
||||||
} else if (personProps.person?.source === 'douban') {
|
} else if (personProps.person?.source === 'douban') {
|
||||||
if (!personInfo.value?.avatar) return personIcon
|
if (!personInfo.value?.avatar) return personIcon
|
||||||
if (typeof personInfo.value?.avatar === 'object') {
|
if (typeof personInfo.value?.avatar === 'object') {
|
||||||
|
|||||||
@@ -62,21 +62,11 @@ const releaseDialog = ref(false)
|
|||||||
const detailDialog = ref(false)
|
const detailDialog = ref(false)
|
||||||
|
|
||||||
// 图片加载完成
|
// 图片加载完成
|
||||||
function imageLoaded() {
|
async function imageLoaded() {
|
||||||
isImageLoaded.value = true
|
isImageLoaded.value = true
|
||||||
const imageElement = imageRef.value?.$el?.querySelector('img') as HTMLImageElement | null
|
const imageElement = imageRef.value?.$el.querySelector('img') as HTMLImageElement
|
||||||
if (!imageElement) return
|
// 从图片中提取背景色
|
||||||
// 主色调提取(ColorThief 走 canvas 解码)会阻塞主线程,
|
backgroundColor.value = await getDominantColor(imageElement)
|
||||||
// 滚动时挤掉浏览器自身的图片解码窗口,放到 idle 中执行更安全。
|
|
||||||
const extract = () => {
|
|
||||||
getDominantColor(imageElement)
|
|
||||||
.then(c => {
|
|
||||||
backgroundColor.value = c
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
}
|
|
||||||
if (typeof requestIdleCallback === 'function') requestIdleCallback(extract, { timeout: 1500 })
|
|
||||||
else setTimeout(extract, 50)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 安装插件
|
// 安装插件
|
||||||
|
|||||||
@@ -108,20 +108,11 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// 图片加载完成
|
// 图片加载完成
|
||||||
function imageLoaded() {
|
async function imageLoaded() {
|
||||||
isImageLoaded.value = true
|
isImageLoaded.value = true
|
||||||
const imageElement = imageRef.value?.$el?.querySelector('img') as HTMLImageElement | null
|
const imageElement = imageRef.value?.$el.querySelector('img') as HTMLImageElement
|
||||||
if (!imageElement) return
|
// 从图片中提取背景色
|
||||||
// ColorThief 走 canvas 解码会阻塞主线程,放到 idle 中执行
|
backgroundColor.value = await getDominantColor(imageElement)
|
||||||
const extract = () => {
|
|
||||||
getDominantColor(imageElement)
|
|
||||||
.then(c => {
|
|
||||||
backgroundColor.value = c
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
}
|
|
||||||
if (typeof requestIdleCallback === 'function') requestIdleCallback(extract, { timeout: 1500 })
|
|
||||||
else setTimeout(extract, 50)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 显示更新日志
|
// 显示更新日志
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { formatDateDifference } from '@/@core/utils/formatters'
|
import { formatDateDifference } from '@/@core/utils/formatters'
|
||||||
import { tmdbResize } from '@/@core/utils/image'
|
|
||||||
import type { SubscribeShare } from '@/api/types'
|
import type { SubscribeShare } from '@/api/types'
|
||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
|
import SubscribeEditDialog from '../dialog/SubscribeEditDialog.vue'
|
||||||
@@ -40,19 +39,19 @@ function imageLoadHandler() {
|
|||||||
// 分享时间
|
// 分享时间
|
||||||
const dateText = ref(props.media && props.media?.date ? formatDateDifference(props.media.date) : '')
|
const dateText = ref(props.media && props.media?.date ? formatDateDifference(props.media.date) : '')
|
||||||
|
|
||||||
// 计算backdrop图片地址(卡片 backdrop 宽度 ~400-600px,w780 在 TMDB 上足够覆盖 2x DPR)
|
// 计算backdrop图片地址
|
||||||
const backdropUrl = computed(() => {
|
const backdropUrl = computed(() => {
|
||||||
const raw = props.media?.backdrop || props.media?.poster
|
const url = props.media?.backdrop || props.media?.poster
|
||||||
const url = tmdbResize(raw, 'w780') || raw
|
// 使用图片缓存
|
||||||
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
|
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
|
||||||
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
|
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
|
||||||
return url
|
return url
|
||||||
})
|
})
|
||||||
|
|
||||||
// 计算海报图片地址(缩略 64×96 显示,w185 已覆盖 3x DPR)
|
// 计算海报图片地址
|
||||||
const posterUrl = computed(() => {
|
const posterUrl = computed(() => {
|
||||||
const raw = props.media?.poster
|
const url = props.media?.poster
|
||||||
const url = tmdbResize(raw, 'w185') || raw
|
// 使用图片缓存
|
||||||
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
|
if (globalSettings.GLOBAL_IMAGE_CACHE && url)
|
||||||
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
|
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
|
||||||
return url
|
return url
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { Site, TorrentInfo, SiteCategory } from '@/api/types'
|
|||||||
import { formatFileSize } from '@core/utils/formatters'
|
import { formatFileSize } from '@core/utils/formatters'
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
|
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
|
||||||
import VirtualList from '@/components/virtual/VirtualList.vue'
|
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
@@ -471,14 +471,15 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="mobileResourceList.length > 0" class="site-resource-mobile__list px-3 pb-4">
|
<div v-else-if="mobileResourceList.length > 0" class="site-resource-mobile__list px-3 pb-4">
|
||||||
<VirtualList
|
<ProgressiveCardGrid
|
||||||
:items="mobileResourceList"
|
:items="mobileResourceList"
|
||||||
:estimate-size="320"
|
:columns="1"
|
||||||
:overscan="5"
|
:gap="12"
|
||||||
|
:estimated-item-height="320"
|
||||||
|
:overscan-rows="5"
|
||||||
:get-item-key="getResourceItemKey"
|
:get-item-key="getResourceItemKey"
|
||||||
container-height="100%"
|
|
||||||
>
|
>
|
||||||
<template #item="{ item }">
|
<template #default="{ item }">
|
||||||
<VCard>
|
<VCard>
|
||||||
<VCardText class="pa-4">
|
<VCardText class="pa-4">
|
||||||
<button type="button" class="site-resource-title-btn text-start" @click="addDownload(item)">
|
<button type="button" class="site-resource-title-btn text-start" @click="addDownload(item)">
|
||||||
@@ -577,7 +578,7 @@ onMounted(() => {
|
|||||||
</VCardText>
|
</VCardText>
|
||||||
</VCard>
|
</VCard>
|
||||||
</template>
|
</template>
|
||||||
</VirtualList>
|
</ProgressiveCardGrid>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="px-4 py-10 text-center text-medium-emphasis">
|
<div v-else class="px-4 py-10 text-center text-medium-emphasis">
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { useDisplay } from 'vuetify'
|
|||||||
import ProgressDialog from './ProgressDialog.vue'
|
import ProgressDialog from './ProgressDialog.vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { mediaTypeDict } from '@/api/constants'
|
import { mediaTypeDict } from '@/api/constants'
|
||||||
import VirtualList from '@/components/virtual/VirtualList.vue'
|
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -25,6 +24,9 @@ const emit = defineEmits(['close', 'save'])
|
|||||||
// 订阅历史列表
|
// 订阅历史列表
|
||||||
const historyList = ref<Subscribe[]>([])
|
const historyList = ref<Subscribe[]>([])
|
||||||
|
|
||||||
|
// 当前加载数据
|
||||||
|
const currData = ref<Subscribe[]>([])
|
||||||
|
|
||||||
// 当前页
|
// 当前页
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
|
|
||||||
@@ -34,9 +36,6 @@ const pageSize = ref(30)
|
|||||||
// 是否加载中
|
// 是否加载中
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
// 是否还有更多数据
|
|
||||||
const hasMore = ref(true)
|
|
||||||
|
|
||||||
// 是否加载完成
|
// 是否加载完成
|
||||||
const isRefreshed = ref(false)
|
const isRefreshed = ref(false)
|
||||||
|
|
||||||
@@ -46,31 +45,41 @@ const progressDialog = ref(false)
|
|||||||
// 进度文字
|
// 进度文字
|
||||||
const progressText = ref('')
|
const progressText = ref('')
|
||||||
|
|
||||||
// VirtualList ref(泛型组件无法用 InstanceType 表达,用 any)
|
// 调用API查询列表
|
||||||
const listRef = ref<any>(null)
|
async function loadHistory({ done }: { done: any }) {
|
||||||
|
// 如果正在加载中,直接返回
|
||||||
|
if (loading.value) {
|
||||||
|
done('ok')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 调用API查询列表(VirtualList @load-more 触发)
|
// 调用API查询列表
|
||||||
async function loadHistory() {
|
|
||||||
if (loading.value || !hasMore.value) return
|
|
||||||
loading.value = true
|
|
||||||
try {
|
try {
|
||||||
const data: Subscribe[] = await api.get(`subscribe/history/${props.type}`, {
|
// 设置加载中
|
||||||
|
loading.value = true
|
||||||
|
currData.value = await api.get(`subscribe/history/${props.type}`, {
|
||||||
params: {
|
params: {
|
||||||
page: currentPage.value,
|
page: currentPage.value,
|
||||||
count: pageSize.value,
|
count: pageSize.value,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
// 标计为已请求完成
|
||||||
isRefreshed.value = true
|
isRefreshed.value = true
|
||||||
if (!data || data.length === 0) {
|
if (currData.value.length === 0) {
|
||||||
hasMore.value = false
|
// 如果没有数据,跳出
|
||||||
|
done('empty')
|
||||||
} else {
|
} else {
|
||||||
historyList.value.push(...data)
|
// 合并数据
|
||||||
|
historyList.value = [...historyList.value, ...currData.value]
|
||||||
|
// 页码+1
|
||||||
currentPage.value++
|
currentPage.value++
|
||||||
// 如果服务端返回不足一页,认为没有更多
|
// 返回加载成功
|
||||||
if (data.length < pageSize.value) hasMore.value = false
|
done('ok')
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
|
// 返回加载失败
|
||||||
|
done('error')
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -134,11 +143,6 @@ function getMediaTypeText(type: string | undefined) {
|
|||||||
if (!type) return ''
|
if (!type) return ''
|
||||||
return mediaTypeDict[type]
|
return mediaTypeDict[type]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始加载
|
|
||||||
onMounted(() => {
|
|
||||||
void loadHistory()
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -150,17 +154,14 @@ onMounted(() => {
|
|||||||
<VDivider />
|
<VDivider />
|
||||||
<VDialogCloseBtn @click="emit('close')" />
|
<VDialogCloseBtn @click="emit('close')" />
|
||||||
<VList lines="two" class="flex-grow-1 min-h-0 py-0">
|
<VList lines="two" class="flex-grow-1 min-h-0 py-0">
|
||||||
<VirtualList
|
<VInfiniteScroll mode="intersect" side="end" :items="historyList" class="h-100" @load="loadHistory">
|
||||||
ref="listRef"
|
<template #loading>
|
||||||
:items="historyList"
|
<LoadingBanner />
|
||||||
:estimate-size="104"
|
</template>
|
||||||
:overscan="6"
|
<template #empty />
|
||||||
key-field="id"
|
<VVirtualScroll v-if="historyList.length > 0" renderless :items="historyList" :item-height="104">
|
||||||
container-height="60vh"
|
<template #default="{ item, itemRef }">
|
||||||
:load-more-threshold="5"
|
<div :ref="itemRef">
|
||||||
@load-more="loadHistory"
|
|
||||||
>
|
|
||||||
<template #item="{ item }">
|
|
||||||
<VListItem>
|
<VListItem>
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<VImg
|
<VImg
|
||||||
@@ -210,11 +211,10 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</VListItem>
|
</VListItem>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #loading>
|
</VVirtualScroll>
|
||||||
<LoadingBanner v-if="loading" />
|
</VInfiniteScroll>
|
||||||
</template>
|
|
||||||
</VirtualList>
|
|
||||||
</VList>
|
</VList>
|
||||||
<VCardText v-if="historyList.length === 0 && isRefreshed" class="text-center">{{
|
<VCardText v-if="historyList.length === 0 && isRefreshed" class="text-center">{{
|
||||||
t('dialog.subscribeHistory.noData')
|
t('dialog.subscribeHistory.noData')
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
|
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
|
||||||
import { usePWA } from '@/composables/usePWA'
|
import { usePWA } from '@/composables/usePWA'
|
||||||
import { useAvailableHeight } from '@/composables/useAvailableHeight'
|
import { useAvailableHeight } from '@/composables/useAvailableHeight'
|
||||||
import VirtualList from '@/components/virtual/VirtualList.vue'
|
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -757,13 +756,8 @@ onUnmounted(() => {
|
|||||||
class="text-high-emphasis file-list-container"
|
class="text-high-emphasis file-list-container"
|
||||||
:style="{ height: `${listAvailableHeight}px`, maxHeight: `${listAvailableHeight}px` }"
|
:style="{ height: `${listAvailableHeight}px`, maxHeight: `${listAvailableHeight}px` }"
|
||||||
>
|
>
|
||||||
<VirtualList
|
<VVirtualScroll :items="displayItems" style="block-size: 100%">
|
||||||
:items="displayItems"
|
<template #default="{ item }">
|
||||||
:estimate-size="56"
|
|
||||||
:overscan="8"
|
|
||||||
:container-height="`${listAvailableHeight}px`"
|
|
||||||
>
|
|
||||||
<template #item="{ item }">
|
|
||||||
<VHover>
|
<VHover>
|
||||||
<template #default="hover">
|
<template #default="hover">
|
||||||
<VListItem v-bind="hover.props" class="px-3 pe-1" @click="listItemClick(item)">
|
<VListItem v-bind="hover.props" class="px-3 pe-1" @click="listItemClick(item)">
|
||||||
@@ -830,7 +824,7 @@ onUnmounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
</VHover>
|
</VHover>
|
||||||
</template>
|
</template>
|
||||||
</VirtualList>
|
</VVirtualScroll>
|
||||||
</VList>
|
</VList>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VCardText v-else-if="filter" class="grow d-flex justify-center align-center grey--text py-5">
|
<VCardText v-else-if="filter" class="grow d-flex justify-center align-center grey--text py-5">
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import type { AxiosRequestConfig, AxiosInstance } from 'axios'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { usePWA } from '@/composables/usePWA'
|
import { usePWA } from '@/composables/usePWA'
|
||||||
import { useAvailableHeight } from '@/composables/useAvailableHeight'
|
import { useAvailableHeight } from '@/composables/useAvailableHeight'
|
||||||
import VirtualList from '@/components/virtual/VirtualList.vue'
|
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -255,16 +254,11 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VCard class="file-navigator rounded-e-0 rounded-t-0" v-if="!isMobile" :height="`${availableHeight}px`">
|
<VCard class="file-navigator rounded-e-0 rounded-t-0" v-if="!isMobile" :height="`${availableHeight}px`">
|
||||||
<VirtualList
|
<VVirtualScroll :items="visibleTreeRows" :item-height="32" class="tree-container">
|
||||||
:items="visibleTreeRows"
|
<template #default="{ item }">
|
||||||
:estimate-size="32"
|
|
||||||
key-field="key"
|
|
||||||
container-height="100%"
|
|
||||||
class="tree-container"
|
|
||||||
>
|
|
||||||
<template #item="{ item }">
|
|
||||||
<div
|
<div
|
||||||
v-if="item.type === 'root'"
|
v-if="item.type === 'root'"
|
||||||
|
:key="item.key"
|
||||||
class="tree-item root-item"
|
class="tree-item root-item"
|
||||||
:class="{ 'active': currentPath === '/' }"
|
:class="{ 'active': currentPath === '/' }"
|
||||||
@click="
|
@click="
|
||||||
@@ -284,6 +278,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
v-else-if="item.type === 'loading'"
|
v-else-if="item.type === 'loading'"
|
||||||
|
:key="item.key"
|
||||||
class="tree-loading"
|
class="tree-loading"
|
||||||
:style="getTreeRowStyle(item.level)"
|
:style="getTreeRowStyle(item.level)"
|
||||||
>
|
>
|
||||||
@@ -295,6 +290,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
|
:key="item.key"
|
||||||
class="tree-item"
|
class="tree-item"
|
||||||
:class="{ 'active': currentPath === item.dir.path }"
|
:class="{ 'active': currentPath === item.dir.path }"
|
||||||
:style="getTreeRowStyle(item.level)"
|
:style="getTreeRowStyle(item.level)"
|
||||||
@@ -326,7 +322,7 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</VirtualList>
|
</VVirtualScroll>
|
||||||
</VCard>
|
</VCard>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,932 @@
|
|||||||
|
<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>
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
<!--
|
|
||||||
============================================================
|
|
||||||
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>
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
<!--
|
|
||||||
============================================================
|
|
||||||
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>
|
|
||||||
@@ -1,189 +0,0 @@
|
|||||||
<!--
|
|
||||||
============================================================
|
|
||||||
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>
|
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
<!--
|
|
||||||
============================================================
|
|
||||||
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>
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
<!--
|
|
||||||
============================================================
|
|
||||||
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,15 +68,9 @@ export function useDynamicHeaderTab() {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// 收集所有 watch 句柄,组件 scope 销毁时主动 stop——
|
|
||||||
// 即使在 keep-alive 缓存的页面里,scope 仍归属当前组件,显式 stop
|
|
||||||
// 可保证 ReactiveEffect/Dep 链条释放,避免长跑场景累积。
|
|
||||||
const watchStops: Array<() => void> = []
|
|
||||||
|
|
||||||
// 如果启用了PWA状态恢复,监听PWA状态变化并同步到modelValue
|
// 如果启用了PWA状态恢复,监听PWA状态变化并同步到modelValue
|
||||||
// 但只在非激活状态下响应,避免干扰页面激活时的状态
|
// 但只在非激活状态下响应,避免干扰页面激活时的状态
|
||||||
if (pwaTabState) {
|
if (pwaTabState) {
|
||||||
watchStops.push(
|
|
||||||
watch(pwaTabState.activeTab, newTab => {
|
watch(pwaTabState.activeTab, newTab => {
|
||||||
if (newTab && newTab !== config.modelValue.value) {
|
if (newTab && newTab !== config.modelValue.value) {
|
||||||
config.modelValue.value = newTab
|
config.modelValue.value = newTab
|
||||||
@@ -86,12 +80,10 @@ export function useDynamicHeaderTab() {
|
|||||||
registerDynamicHeaderTab(tabConfig)
|
registerDynamicHeaderTab(tabConfig)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听modelValue变化并更新配置
|
// 监听modelValue变化并更新配置
|
||||||
watchStops.push(
|
|
||||||
watch(config.modelValue, newValue => {
|
watch(config.modelValue, newValue => {
|
||||||
tabConfig.modelValue = newValue
|
tabConfig.modelValue = newValue
|
||||||
// 同步到PWA状态
|
// 同步到PWA状态
|
||||||
@@ -108,12 +100,10 @@ export function useDynamicHeaderTab() {
|
|||||||
globalRegister(tabConfig)
|
globalRegister(tabConfig)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
})
|
||||||
)
|
|
||||||
|
|
||||||
// 如果items是computed或ref,也需要监听其变化
|
// 如果items是computed或ref,也需要监听其变化
|
||||||
if (!Array.isArray(config.items)) {
|
if (!Array.isArray(config.items)) {
|
||||||
watchStops.push(
|
|
||||||
watch(
|
watch(
|
||||||
config.items,
|
config.items,
|
||||||
newItems => {
|
newItems => {
|
||||||
@@ -130,15 +120,9 @@ export function useDynamicHeaderTab() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ deep: true },
|
{ deep: true },
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
onScopeDispose(() => {
|
|
||||||
watchStops.forEach(stop => stop())
|
|
||||||
watchStops.length = 0
|
|
||||||
})
|
|
||||||
|
|
||||||
// 注册函数
|
// 注册函数
|
||||||
const doRegister = () => {
|
const doRegister = () => {
|
||||||
// 确保路由路径是最新的
|
// 确保路由路径是最新的
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 }
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
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 }
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
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 }
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
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,9 +258,6 @@ function appendPluginSidebarMenus() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 未读消息订阅句柄,在 onMounted 里赋值、onBeforeUnmount 里回收
|
|
||||||
let unsubscribeUnread: (() => void) | null = null
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// 获取菜单列表
|
// 获取菜单列表
|
||||||
startMenus.value = getMenuList(t('menu.start'))
|
startMenus.value = getMenuList(t('menu.start'))
|
||||||
@@ -273,24 +270,21 @@ onMounted(async () => {
|
|||||||
appendPluginSidebarMenus()
|
appendPluginSidebarMenus()
|
||||||
|
|
||||||
// 监听全局未读消息事件
|
// 监听全局未读消息事件
|
||||||
unsubscribeUnread = onUnreadMessage(handleUnreadMessage)
|
const unsubscribe = onUnreadMessage(handleUnreadMessage)
|
||||||
|
|
||||||
// 监听Service Worker消息
|
// 监听Service Worker消息
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
navigator.serviceWorker.addEventListener('message', handleServiceWorkerMessage)
|
navigator.serviceWorker.addEventListener('message', handleServiceWorkerMessage)
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
// 卸载清理必须同步注册:放在 onMounted 的 async 回调里、且在 await 之后,
|
// 组件卸载时清理监听
|
||||||
// Vue 的"当前组件实例"上下文已丢失,onBeforeUnmount 会报
|
|
||||||
// "no active component instance" 并且监听器永远不会被回收。
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
unsubscribeUnread?.()
|
unsubscribe()
|
||||||
unsubscribeUnread = null
|
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
navigator.serviceWorker.removeEventListener('message', handleServiceWorkerMessage)
|
navigator.serviceWorker.removeEventListener('message', handleServiceWorkerMessage)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
+9
-19
@@ -2,7 +2,6 @@
|
|||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { RecommendSource } from '@/api/types'
|
import { RecommendSource } from '@/api/types'
|
||||||
import MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue'
|
import MediaCardSlideView from '@/views/discover/MediaCardSlideView.vue'
|
||||||
import VirtualList from '@/components/virtual/VirtualList.vue'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
|
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
|
||||||
@@ -270,25 +269,16 @@ onActivated(async () => {
|
|||||||
<template>
|
<template>
|
||||||
<div class="mp-recommend">
|
<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">
|
<div class="recommend-content">
|
||||||
<VirtualList
|
<TransitionGroup name="fade">
|
||||||
:items="filteredViews"
|
<MediaCardSlideView
|
||||||
:estimate-size="320"
|
v-for="item in filteredViews"
|
||||||
key-field="title"
|
:key="item.title"
|
||||||
:overscan="2"
|
v-bind="item"
|
||||||
use-window-scroll
|
:ready="isReady"
|
||||||
>
|
class="content-group"
|
||||||
<template #item="{ item }">
|
/>
|
||||||
<MediaCardSlideView v-bind="item" :ready="isReady" class="content-group" />
|
</TransitionGroup>
|
||||||
</template>
|
|
||||||
</VirtualList>
|
|
||||||
|
|
||||||
<div v-if="isReady && filteredViews.length === 0" class="empty-category">
|
<div v-if="isReady && filteredViews.length === 0" class="empty-category">
|
||||||
<VIcon icon="mdi-alert-circle-outline" size="large" class="empty-icon" />
|
<VIcon icon="mdi-alert-circle-outline" size="large" class="empty-icon" />
|
||||||
|
|||||||
+15
-22
@@ -6,18 +6,13 @@ import api from '@/api'
|
|||||||
import type { Context } from '@/api/types'
|
import type { Context } from '@/api/types'
|
||||||
import TorrentCard from '@/components/cards/TorrentCard.vue'
|
import TorrentCard from '@/components/cards/TorrentCard.vue'
|
||||||
import TorrentItem from '@/components/cards/TorrentItem.vue'
|
import TorrentItem from '@/components/cards/TorrentItem.vue'
|
||||||
import VirtualGrid from '@/components/virtual/VirtualGrid.vue'
|
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||||
import VirtualList from '@/components/virtual/VirtualList.vue'
|
|
||||||
import { useBreakpointCols } from '@/composables/virtual/useBreakpointCols'
|
|
||||||
import TorrentFilterBar from '@/components/filter/TorrentFilterBar.vue'
|
import TorrentFilterBar from '@/components/filter/TorrentFilterBar.vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useGlobalSettingsStore } from '@/stores/global'
|
import { useGlobalSettingsStore } from '@/stores/global'
|
||||||
import { useTorrentFilter, type FilterState } from '@/composables/useTorrentFilter'
|
import { useTorrentFilter, type FilterState } from '@/composables/useTorrentFilter'
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
|
|
||||||
// 列数:按视口断点(路由级全宽页)
|
|
||||||
const cols = useBreakpointCols({ xs: 1, sm: 2, md: 2, lg: 3, xl: 4, xxl: 4 })
|
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
@@ -1171,19 +1166,17 @@ onUnmounted(() => {
|
|||||||
class="stream-result-item"
|
class="stream-result-item"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<VirtualGrid
|
<ProgressiveCardGrid
|
||||||
v-else-if="filteredCardDataList.length > 0"
|
v-else-if="filteredCardDataList.length > 0"
|
||||||
:items="filteredCardDataList"
|
:items="filteredCardDataList"
|
||||||
:columns="cols"
|
:get-item-key="getTorrentItemKey"
|
||||||
:row-estimate-size="400"
|
:min-item-width="300"
|
||||||
:gap="16"
|
:estimated-item-height="400"
|
||||||
:overscan="3"
|
|
||||||
use-window-scroll
|
|
||||||
>
|
>
|
||||||
<template #item="{ item }">
|
<template #default="{ item }">
|
||||||
<TorrentCard :torrent="item" :more="item.more" />
|
<TorrentCard :torrent="item" :more="item.more" />
|
||||||
</template>
|
</template>
|
||||||
</VirtualGrid>
|
</ProgressiveCardGrid>
|
||||||
<!-- 无结果时显示 -->
|
<!-- 无结果时显示 -->
|
||||||
<div v-if="!progressActive && filteredCardDataList.length === 0" class="no-results">
|
<div v-if="!progressActive && filteredCardDataList.length === 0" class="no-results">
|
||||||
<VIcon icon="mdi-file-search-outline" size="64" color="grey-lighten-1" />
|
<VIcon icon="mdi-file-search-outline" size="64" color="grey-lighten-1" />
|
||||||
@@ -1210,19 +1203,19 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="filteredRowDataList.length > 0" class="resource-list">
|
<div v-else-if="filteredRowDataList.length > 0" class="resource-list">
|
||||||
<VirtualList
|
<ProgressiveCardGrid
|
||||||
:items="filteredRowDataList"
|
:items="filteredRowDataList"
|
||||||
:estimate-size="240"
|
:columns="1"
|
||||||
:overscan="5"
|
:gap="8"
|
||||||
use-window-scroll
|
:estimated-item-height="240"
|
||||||
|
:overscan-rows="6"
|
||||||
|
:get-item-key="getTorrentItemKey"
|
||||||
>
|
>
|
||||||
<template #item="{ item, index }">
|
<template #default="{ item, index }">
|
||||||
<div :key="getTorrentItemKey(item, index)">
|
|
||||||
<TorrentItem :torrent="item" />
|
<TorrentItem :torrent="item" />
|
||||||
<VDivider v-if="index < filteredRowDataList.length - 1" class="my-2" />
|
<VDivider v-if="index < filteredRowDataList.length - 1" class="my-2" />
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</VirtualList>
|
</ProgressiveCardGrid>
|
||||||
</div>
|
</div>
|
||||||
</VCard>
|
</VCard>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ const router = createRouter({
|
|||||||
path: '/recommend',
|
path: '/recommend',
|
||||||
component: () => import('../pages/recommend.vue'),
|
component: () => import('../pages/recommend.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
|
keepAlive: true,
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -39,6 +40,7 @@ const router = createRouter({
|
|||||||
path: '/discover',
|
path: '/discover',
|
||||||
component: () => import('../pages/discover.vue'),
|
component: () => import('../pages/discover.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
|
keepAlive: true,
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -53,6 +55,7 @@ const router = createRouter({
|
|||||||
path: '/subscribe/movie',
|
path: '/subscribe/movie',
|
||||||
component: () => import('../pages/subscribe.vue'),
|
component: () => import('../pages/subscribe.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
|
keepAlive: true,
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
subType: '电影',
|
subType: '电影',
|
||||||
},
|
},
|
||||||
@@ -61,6 +64,7 @@ const router = createRouter({
|
|||||||
path: '/subscribe/tv',
|
path: '/subscribe/tv',
|
||||||
component: () => import('../pages/subscribe.vue'),
|
component: () => import('../pages/subscribe.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
|
keepAlive: true,
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
subType: '电视剧',
|
subType: '电视剧',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
/**
|
|
||||||
* 滚动位置持久化 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,8 +3,7 @@ import { ref, onMounted } from 'vue'
|
|||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import type { MediaServerConf, MediaServerPlayItem } from '@/api/types'
|
import type { MediaServerConf, MediaServerPlayItem } from '@/api/types'
|
||||||
import PosterCard from '@/components/cards/PosterCard.vue'
|
import PosterCard from '@/components/cards/PosterCard.vue'
|
||||||
import VirtualGrid from '@/components/virtual/VirtualGrid.vue'
|
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||||
import { useResponsiveCols } from '@/composables/virtual/useResponsiveCols'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
@@ -16,11 +15,6 @@ const latestList = ref<{ [key: string]: MediaServerPlayItem[] }>({})
|
|||||||
// 所有媒体服务器设置
|
// 所有媒体服务器设置
|
||||||
const mediaServers = ref<MediaServerConf[]>([])
|
const mediaServers = ref<MediaServerConf[]>([])
|
||||||
|
|
||||||
// 容器宽度驱动列数(dashboard 卡片宽度由布局决定,不能用视口断点)。
|
|
||||||
// 多个服务器段落共用一个 wrapRef —— 它们在 dashboard 中竖向堆叠、宽度相同。
|
|
||||||
const wrapRef = ref<HTMLElement | null>(null)
|
|
||||||
const cols = useResponsiveCols(wrapRef, { minItemWidth: 144 })
|
|
||||||
|
|
||||||
// 调用API查询媒体服务器设置
|
// 调用API查询媒体服务器设置
|
||||||
async function loadMediaServerSetting() {
|
async function loadMediaServerSetting() {
|
||||||
try {
|
try {
|
||||||
@@ -63,7 +57,7 @@ onActivated(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="wrapRef">
|
<div>
|
||||||
<VHover v-for="(data, name) in latestList" :key="name">
|
<VHover v-for="(data, name) in latestList" :key="name">
|
||||||
<template #default="hover">
|
<template #default="hover">
|
||||||
<VCard v-bind="hover.props">
|
<VCard v-bind="hover.props">
|
||||||
@@ -74,20 +68,18 @@ onActivated(() => {
|
|||||||
<VCardTitle>{{ t('dashboard.latest') }} - {{ name }}</VCardTitle>
|
<VCardTitle>{{ t('dashboard.latest') }} - {{ name }}</VCardTitle>
|
||||||
</VCardItem>
|
</VCardItem>
|
||||||
|
|
||||||
<VirtualGrid
|
<ProgressiveCardGrid
|
||||||
:items="data"
|
:items="data"
|
||||||
:columns="cols"
|
:get-item-key="item => item.id || item.link || item.title"
|
||||||
:row-estimate-size="240"
|
:min-item-width="144"
|
||||||
:gap="12"
|
:item-aspect-ratio="1.5"
|
||||||
:get-item-key="(item: MediaServerPlayItem) => item.id || item.link || item.title"
|
|
||||||
use-window-scroll
|
|
||||||
class="mx-3 mb-3"
|
class="mx-3 mb-3"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
<template #item="{ item }">
|
<template #default="{ item }">
|
||||||
<PosterCard :media="item" />
|
<PosterCard :media="item" />
|
||||||
</template>
|
</template>
|
||||||
</VirtualGrid>
|
</ProgressiveCardGrid>
|
||||||
</VCard>
|
</VCard>
|
||||||
</template>
|
</template>
|
||||||
</VHover>
|
</VHover>
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import type { MediaServerConf, MediaServerLibrary } from '@/api/types'
|
import type { MediaServerConf, MediaServerLibrary } from '@/api/types'
|
||||||
import LibraryCard from '@/components/cards/LibraryCard.vue'
|
import LibraryCard from '@/components/cards/LibraryCard.vue'
|
||||||
import VirtualGrid from '@/components/virtual/VirtualGrid.vue'
|
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||||
import { useResponsiveCols } from '@/composables/virtual/useResponsiveCols'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
@@ -15,10 +14,6 @@ const libraryList = ref<MediaServerLibrary[]>([])
|
|||||||
// 所有媒体服务器设置
|
// 所有媒体服务器设置
|
||||||
const mediaServers = ref<MediaServerConf[]>([])
|
const mediaServers = ref<MediaServerConf[]>([])
|
||||||
|
|
||||||
// 容器宽度驱动列数(dashboard 卡片宽度由布局决定,不能用视口断点)
|
|
||||||
const wrapRef = ref<HTMLElement | null>(null)
|
|
||||||
const cols = useResponsiveCols(wrapRef, { minItemWidth: 240 })
|
|
||||||
|
|
||||||
// 调用API查询媒体服务器设置
|
// 调用API查询媒体服务器设置
|
||||||
async function loadMediaServerSetting() {
|
async function loadMediaServerSetting() {
|
||||||
try {
|
try {
|
||||||
@@ -75,21 +70,18 @@ onActivated(() => {
|
|||||||
</template>
|
</template>
|
||||||
<VCardTitle>{{ t('dashboard.library') }}</VCardTitle>
|
<VCardTitle>{{ t('dashboard.library') }}</VCardTitle>
|
||||||
</VCardItem>
|
</VCardItem>
|
||||||
<div ref="wrapRef" class="mx-3 mb-3">
|
<ProgressiveCardGrid
|
||||||
<VirtualGrid
|
|
||||||
:items="libraryList"
|
:items="libraryList"
|
||||||
:columns="cols"
|
:get-item-key="item => item.id || item.name"
|
||||||
:row-estimate-size="180"
|
:min-item-width="240"
|
||||||
:gap="12"
|
:estimated-item-height="160"
|
||||||
:get-item-key="(item: MediaServerLibrary) => item.id || item.name"
|
class="mx-3 mb-3"
|
||||||
use-window-scroll
|
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
<template #item="{ item }">
|
<template #default="{ item }">
|
||||||
<LibraryCard :media="item" height="10rem" />
|
<LibraryCard :media="item" height="10rem" />
|
||||||
</template>
|
</template>
|
||||||
</VirtualGrid>
|
</ProgressiveCardGrid>
|
||||||
</div>
|
|
||||||
</VCard>
|
</VCard>
|
||||||
</template>
|
</template>
|
||||||
</VHover>
|
</VHover>
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import type { MediaServerConf, MediaServerPlayItem } from '@/api/types'
|
import type { MediaServerConf, MediaServerPlayItem } from '@/api/types'
|
||||||
import BackdropCard from '@/components/cards/BackdropCard.vue'
|
import BackdropCard from '@/components/cards/BackdropCard.vue'
|
||||||
import VirtualGrid from '@/components/virtual/VirtualGrid.vue'
|
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||||
import { useResponsiveCols } from '@/composables/virtual/useResponsiveCols'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
@@ -15,10 +14,6 @@ const playingList = ref<MediaServerPlayItem[]>([])
|
|||||||
// 所有媒体服务器设置
|
// 所有媒体服务器设置
|
||||||
const mediaServers = ref<MediaServerConf[]>([])
|
const mediaServers = ref<MediaServerConf[]>([])
|
||||||
|
|
||||||
// 容器宽度驱动列数(dashboard 卡片宽度由布局决定,不能用视口断点)
|
|
||||||
const wrapRef = ref<HTMLElement | null>(null)
|
|
||||||
const cols = useResponsiveCols(wrapRef, { minItemWidth: 240 })
|
|
||||||
|
|
||||||
// 调用API查询媒体服务器设置
|
// 调用API查询媒体服务器设置
|
||||||
async function loadMediaServerSetting() {
|
async function loadMediaServerSetting() {
|
||||||
try {
|
try {
|
||||||
@@ -76,21 +71,18 @@ onActivated(() => {
|
|||||||
<VCardTitle>{{ t('dashboard.playing') }}</VCardTitle>
|
<VCardTitle>{{ t('dashboard.playing') }}</VCardTitle>
|
||||||
</VCardItem>
|
</VCardItem>
|
||||||
|
|
||||||
<div ref="wrapRef" class="mx-3 mb-3">
|
<ProgressiveCardGrid
|
||||||
<VirtualGrid
|
|
||||||
:items="playingList"
|
:items="playingList"
|
||||||
:columns="cols"
|
:get-item-key="item => item.id || item.link || item.title"
|
||||||
:row-estimate-size="180"
|
:min-item-width="240"
|
||||||
:gap="12"
|
:estimated-item-height="160"
|
||||||
:get-item-key="(item: MediaServerPlayItem) => item.id || item.link || item.title"
|
class="mx-3 mb-3"
|
||||||
use-window-scroll
|
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
<template #item="{ item }">
|
<template #default="{ item }">
|
||||||
<BackdropCard :media="item" height="10rem" />
|
<BackdropCard :media="item" height="10rem" />
|
||||||
</template>
|
</template>
|
||||||
</VirtualGrid>
|
</ProgressiveCardGrid>
|
||||||
</div>
|
|
||||||
</VCard>
|
</VCard>
|
||||||
</template>
|
</template>
|
||||||
</VHover>
|
</VHover>
|
||||||
|
|||||||
@@ -2,44 +2,45 @@
|
|||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import type { MediaInfo } from '@/api/types'
|
import type { MediaInfo } from '@/api/types'
|
||||||
import MediaCard from '@/components/cards/MediaCard.vue'
|
import MediaCard from '@/components/cards/MediaCard.vue'
|
||||||
import VirtualGrid from '@/components/virtual/VirtualGrid.vue'
|
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||||
import NoDataFound from '@/components/NoDataFound.vue'
|
import NoDataFound from '@/components/NoDataFound.vue'
|
||||||
import { useBreakpointCols } from '@/composables/virtual/useBreakpointCols'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
// 列数:按视口断点(路由级全宽页)
|
|
||||||
const cols = useBreakpointCols({ xs: 3, sm: 4, md: 6, lg: 8, xl: 10, xxl: 12 })
|
|
||||||
|
|
||||||
// 输入参数
|
// 输入参数
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
apipath: String,
|
apipath: String,
|
||||||
params: Object as PropType<{ [key: string]: any }>,
|
params: Object as PropType<{ [key: string]: any }>,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 判断是否有滚动条
|
||||||
|
function hasScroll() {
|
||||||
|
return document.body.scrollHeight - (window.innerHeight || document.documentElement.clientHeight) > 2
|
||||||
|
}
|
||||||
|
|
||||||
// 当前页码
|
// 当前页码
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
|
|
||||||
// 是否加载中(同时作为触底加载的锁)
|
// 是否加载中
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
// 是否加载完成
|
// 是否加载完成
|
||||||
const isRefreshed = ref(false)
|
const isRefreshed = ref(false)
|
||||||
|
|
||||||
// 是否还有更多
|
|
||||||
const hasMore = ref(true)
|
|
||||||
|
|
||||||
// 使用 shallowRef 避免长列表中的深层代理开销
|
// 使用 shallowRef 避免长列表中的深层代理开销
|
||||||
const dataList = shallowRef<MediaInfo[]>([])
|
const dataList = shallowRef<MediaInfo[]>([])
|
||||||
|
|
||||||
// 用于保存已处理过的 key(去重)
|
// 用于保存已处理过的 key
|
||||||
const seenKeys = new Set<string>()
|
const seenKeys = new Set<string>()
|
||||||
|
|
||||||
// 拼装参数
|
// 拼装参数
|
||||||
function getParams() {
|
function getParams() {
|
||||||
let params = { page: page.value }
|
let params = {
|
||||||
|
page: page.value,
|
||||||
|
}
|
||||||
if (props.params) params = { ...params, ...props.params }
|
if (props.params) params = { ...params, ...props.params }
|
||||||
|
|
||||||
return params
|
return params
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,63 +61,115 @@ const dedupFields = [
|
|||||||
function deduplicate(items: MediaInfo[]): MediaInfo[] {
|
function deduplicate(items: MediaInfo[]): MediaInfo[] {
|
||||||
return items.filter(item => {
|
return items.filter(item => {
|
||||||
const key = dedupFields.map(field => String(item[field])).join('~')
|
const key = dedupFields.map(field => String(item[field])).join('~')
|
||||||
if (seenKeys.has(key)) return false
|
if (seenKeys.has(key)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
seenKeys.add(key)
|
seenKeys.add(key)
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取列表数据(VirtualGrid @load-more 触发)
|
function appendData(items: MediaInfo[]) {
|
||||||
async function fetchData() {
|
dataList.value = dataList.value.concat(items)
|
||||||
if (!props.apipath) return
|
}
|
||||||
if (loading.value || !hasMore.value) return
|
|
||||||
loading.value = true
|
async function loadPageData() {
|
||||||
try {
|
const rawData: MediaInfo[] = await api.get(props.apipath!, {
|
||||||
const rawData: MediaInfo[] = await api.get(props.apipath, {
|
|
||||||
params: getParams(),
|
params: getParams(),
|
||||||
})
|
})
|
||||||
isRefreshed.value = true
|
|
||||||
if (!rawData || rawData.length === 0) {
|
return {
|
||||||
hasMore.value = false
|
rawCount: rawData.length,
|
||||||
return
|
uniqueData: deduplicate(rawData),
|
||||||
}
|
|
||||||
const uniqueData = deduplicate(rawData)
|
|
||||||
dataList.value = dataList.value.concat(uniqueData)
|
|
||||||
page.value++
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始加载
|
// 获取列表数据
|
||||||
onMounted(() => {
|
async function fetchData({ done }: { done: any }) {
|
||||||
void fetchData()
|
try {
|
||||||
})
|
if (!props.apipath) return
|
||||||
|
|
||||||
|
// 如果正在加载中,直接返回
|
||||||
|
if (loading.value) {
|
||||||
|
done('ok')
|
||||||
|
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
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
// 返回加载失败
|
||||||
|
done('error')
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
||||||
<VirtualGrid
|
<VInfiniteScroll mode="intersect" side="end" :items="dataList" class="overflow-visible pt-3 px-2" @load="fetchData">
|
||||||
v-if="isRefreshed && dataList.length > 0"
|
<template #loading />
|
||||||
|
<template #empty />
|
||||||
|
<ProgressiveCardGrid
|
||||||
|
v-if="dataList.length > 0"
|
||||||
:items="dataList"
|
:items="dataList"
|
||||||
:columns="cols"
|
:item-aspect-ratio="1.5"
|
||||||
:row-estimate-size="280"
|
:get-item-key="item => item.tmdb_id || item.douban_id || item.bangumi_id || item.media_id || item.title"
|
||||||
:gap="16"
|
tabindex="0"
|
||||||
:overscan="3"
|
|
||||||
use-window-scroll
|
|
||||||
class="pt-3 px-3"
|
|
||||||
@load-more="fetchData"
|
|
||||||
>
|
>
|
||||||
<template #item="{ item }">
|
<template #default="{ item }">
|
||||||
<MediaCard :media="item" />
|
<MediaCard :media="item" />
|
||||||
</template>
|
</template>
|
||||||
</VirtualGrid>
|
</ProgressiveCardGrid>
|
||||||
<NoDataFound
|
<NoDataFound
|
||||||
v-if="dataList.length === 0 && isRefreshed"
|
v-if="dataList.length === 0 && isRefreshed"
|
||||||
error-code="404"
|
error-code="404"
|
||||||
:error-title="t('common.noData')"
|
:error-title="t('common.noData')"
|
||||||
:error-description="t('error.networkError')"
|
:error-description="t('error.networkError')"
|
||||||
/>
|
/>
|
||||||
|
</VInfiniteScroll>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -8,11 +8,6 @@ import { useIntersectionObserver, until } from '@vueuse/core'
|
|||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
// 模块级数据缓存:当外层用 VirtualList 虚拟化时,SlideView 会随滚动
|
|
||||||
// 反复 unmount/mount,没有缓存会每次重新请求后端(公网部署还会打到 TMDB)。
|
|
||||||
// 缓存按 apipath 维度,跨实例共享,刷新页面即失效。
|
|
||||||
const dataCache = new Map<string, MediaInfo[]>()
|
|
||||||
|
|
||||||
// 输入参数
|
// 输入参数
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
apipath: String,
|
apipath: String,
|
||||||
@@ -42,15 +37,7 @@ const containerRef = ref<HTMLElement | null>(null)
|
|||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
try {
|
try {
|
||||||
if (!props.apipath) return
|
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)
|
dataList.value = await api.get(props.apipath)
|
||||||
dataCache.set(props.apipath, dataList.value)
|
|
||||||
if (dataList.value.length > 0) {
|
if (dataList.value.length > 0) {
|
||||||
// 数据获取后,等待 ready 信号再渲染,避免阻塞动画
|
// 数据获取后,等待 ready 信号再渲染,避免阻塞动画
|
||||||
await until(() => props.ready).toBe(true)
|
await until(() => props.ready).toBe(true)
|
||||||
@@ -65,9 +52,6 @@ async function fetchData() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 使用 IntersectionObserver 实现懒加载
|
// 使用 IntersectionObserver 实现懒加载
|
||||||
// rootMargin 收窄到 100px:仅在 SlideView 真正接近视口时才触发,
|
|
||||||
// 避免页面初次挂载时多个 SlideView 同时落在探测区导致的层叠 fire +
|
|
||||||
// 后续 layout shift 引发的"自动一直往下刷"假象。
|
|
||||||
const { stop } = useIntersectionObserver(
|
const { stop } = useIntersectionObserver(
|
||||||
containerRef,
|
containerRef,
|
||||||
([{ isIntersecting }]) => {
|
([{ isIntersecting }]) => {
|
||||||
@@ -77,7 +61,7 @@ const { stop } = useIntersectionObserver(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
rootMargin: '100px',
|
rootMargin: '300px', // 提前加载距离
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,12 @@
|
|||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import type { Person } from '@/api/types'
|
import type { Person } from '@/api/types'
|
||||||
import PersonCard from '@/components/cards/PersonCard.vue'
|
import PersonCard from '@/components/cards/PersonCard.vue'
|
||||||
import VirtualGrid from '@/components/virtual/VirtualGrid.vue'
|
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||||
import NoDataFound from '@/components/NoDataFound.vue'
|
import NoDataFound from '@/components/NoDataFound.vue'
|
||||||
import { useBreakpointCols } from '@/composables/virtual/useBreakpointCols'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
// 列数:按视口断点(路由级全宽页)
|
|
||||||
const cols = useBreakpointCols({ xs: 3, sm: 4, md: 6, lg: 8, xl: 10, xxl: 12 })
|
|
||||||
|
|
||||||
// 输入参数
|
// 输入参数
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
apipath: String,
|
apipath: String,
|
||||||
@@ -19,67 +15,130 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 判断是否有滚动条
|
||||||
|
function hasScroll() {
|
||||||
|
return document.body.scrollHeight - (window.innerHeight || document.documentElement.clientHeight) > 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当前页码
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
|
|
||||||
|
// 是否加载中
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
|
// 是否加载完成
|
||||||
const isRefreshed = ref(false)
|
const isRefreshed = ref(false)
|
||||||
const hasMore = ref(true)
|
|
||||||
|
// 使用 shallowRef 避免长列表中的深层代理开销
|
||||||
const dataList = shallowRef<Person[]>([])
|
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() {
|
function getParams() {
|
||||||
let params = { page: page.value }
|
let params = {
|
||||||
|
page: page.value,
|
||||||
|
}
|
||||||
if (props.params) params = { ...params, ...props.params }
|
if (props.params) params = { ...params, ...props.params }
|
||||||
|
|
||||||
return params
|
return params
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchData() {
|
// 获取列表数据
|
||||||
if (!props.apipath) return
|
async function fetchData({ done }: { done: any }) {
|
||||||
if (loading.value || !hasMore.value) return
|
|
||||||
loading.value = true
|
|
||||||
try {
|
try {
|
||||||
const currentData = (await api.get(props.apipath, {
|
if (!props.apipath) return
|
||||||
params: getParams(),
|
|
||||||
})) as Person[]
|
// 如果正在加载中,直接返回
|
||||||
isRefreshed.value = true
|
if (loading.value) {
|
||||||
if (!currentData || currentData.length === 0) {
|
done('ok')
|
||||||
hasMore.value = false
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dataList.value = dataList.value.concat(currentData)
|
|
||||||
|
// 加载到满屏或者加载出错
|
||||||
|
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++
|
page.value++
|
||||||
} catch (error) {
|
// 返回加载成功
|
||||||
console.error(error)
|
done('ok')
|
||||||
} finally {
|
}
|
||||||
|
}
|
||||||
|
} 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
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
// 返回加载失败
|
||||||
|
done('error')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
void fetchData()
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
||||||
<VirtualGrid
|
<VInfiniteScroll mode="intersect" side="end" :items="dataList" class="overflow-visible px-3" @load="fetchData">
|
||||||
v-if="isRefreshed && dataList.length > 0"
|
<template #loading />
|
||||||
|
<template #empty />
|
||||||
|
<ProgressiveCardGrid
|
||||||
|
v-if="dataList.length > 0"
|
||||||
:items="dataList"
|
:items="dataList"
|
||||||
:columns="cols"
|
:item-aspect-ratio="1.5"
|
||||||
:row-estimate-size="260"
|
:get-item-key="item => item.id"
|
||||||
:gap="16"
|
tabindex="0"
|
||||||
:overscan="3"
|
|
||||||
key-field="id"
|
|
||||||
use-window-scroll
|
|
||||||
class="pt-3 px-3"
|
|
||||||
@load-more="fetchData"
|
|
||||||
>
|
>
|
||||||
<template #item="{ item }">
|
<template #default="{ item }">
|
||||||
<PersonCard :person="item" />
|
<PersonCard :person="item" />
|
||||||
</template>
|
</template>
|
||||||
</VirtualGrid>
|
</ProgressiveCardGrid>
|
||||||
<NoDataFound
|
<NoDataFound
|
||||||
v-if="dataList.length === 0 && isRefreshed"
|
v-if="dataList.length === 0 && isRefreshed"
|
||||||
error-code="404"
|
error-code="404"
|
||||||
:error-title="t('common.noData')"
|
:error-title="t('common.noData')"
|
||||||
:error-description="t('error.networkError')"
|
:error-description="t('error.networkError')"
|
||||||
/>
|
/>
|
||||||
|
</VInfiniteScroll>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -41,8 +41,6 @@ async function fetchData() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// rootMargin 收窄到 100px,避免页面初次挂载时多个 SlideView 同时落在探测区
|
|
||||||
// 引起层叠 fire(与 MediaCardSlideView 保持一致)。
|
|
||||||
const { stop } = useIntersectionObserver(
|
const { stop } = useIntersectionObserver(
|
||||||
containerRef,
|
containerRef,
|
||||||
([{ isIntersecting }]) => {
|
([{ isIntersecting }]) => {
|
||||||
@@ -52,7 +50,7 @@ const { stop } = useIntersectionObserver(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
rootMargin: '100px',
|
rootMargin: '300px',
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -10,15 +10,10 @@ import { getPluginTabs } from '@/router/i18n-menu'
|
|||||||
import { useDynamicButton } from '@/composables/useDynamicButton'
|
import { useDynamicButton } from '@/composables/useDynamicButton'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import PluginMixedSortCard from '@/components/cards/PluginMixedSortCard.vue'
|
import PluginMixedSortCard from '@/components/cards/PluginMixedSortCard.vue'
|
||||||
import VirtualGrid from '@/components/virtual/VirtualGrid.vue'
|
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||||
import VirtualList from '@/components/virtual/VirtualList.vue'
|
|
||||||
import { useBreakpointCols } from '@/composables/virtual/useBreakpointCols'
|
|
||||||
import { usePWA } from '@/composables/usePWA'
|
import { usePWA } from '@/composables/usePWA'
|
||||||
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
|
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
|
||||||
|
|
||||||
// 列数:按视口断点(路由级全宽页,三个 VirtualGrid 共用:已安装/插件文件夹/插件市场)
|
|
||||||
const cols = useBreakpointCols({ xs: 1, sm: 2, md: 3, lg: 4, xl: 5, xxl: 5 })
|
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
@@ -303,19 +298,6 @@ const installedScrollToIndex = computed(() => {
|
|||||||
return targetIndex >= 0 ? targetIndex : undefined
|
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 getFilteredFolderPlugins = (folderName: string) => {
|
||||||
const folderData = pluginFolders.value[folderName]
|
const folderData = pluginFolders.value[folderName]
|
||||||
@@ -937,11 +919,12 @@ watch([dataList, installedFilter, hasUpdateFilter, enabledFilter], () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// 插件市场加载更多数据(VirtualGrid @load-more 触发)
|
// 插件市场加载更多数据
|
||||||
function loadMarketMore() {
|
function loadMarketMore({ done }: { done: any }) {
|
||||||
if (sortedUninstalledList.value.length === 0) return
|
// 从 dataList 中获取最前面的 20 个元素
|
||||||
const itemsToMove = sortedUninstalledList.value.splice(0, 20)
|
const itemsToMove = sortedUninstalledList.value.splice(0, 20)
|
||||||
displayUninstalledList.value.push(...itemsToMove)
|
displayUninstalledList.value.push(...itemsToMove)
|
||||||
|
done('ok')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 组件挂载后
|
// 组件挂载后
|
||||||
@@ -1585,17 +1568,15 @@ function onDragStartPlugin(evt: any) {
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</Draggable>
|
</Draggable>
|
||||||
<VirtualGrid
|
<ProgressiveCardGrid
|
||||||
v-else-if="shouldVirtualizeInstalledMainList"
|
v-else-if="shouldVirtualizeInstalledMainList"
|
||||||
ref="installedGridRef"
|
|
||||||
:items="mixedSortList"
|
:items="mixedSortList"
|
||||||
:columns="cols"
|
:get-item-key="item => `${item.type}:${item.id}`"
|
||||||
:row-estimate-size="260"
|
:min-item-width="256"
|
||||||
:gap="16"
|
:estimated-item-height="180"
|
||||||
:overscan="3"
|
:scroll-to-index="installedScrollToIndex"
|
||||||
use-window-scroll
|
|
||||||
>
|
>
|
||||||
<template #item="{ item }">
|
<template #default="{ item }">
|
||||||
<PluginMixedSortCard
|
<PluginMixedSortCard
|
||||||
:item="item"
|
:item="item"
|
||||||
:plugin-statistics="PluginStatistics"
|
:plugin-statistics="PluginStatistics"
|
||||||
@@ -1614,7 +1595,7 @@ function onDragStartPlugin(evt: any) {
|
|||||||
@drop-to-folder="(event, folderName) => handleDropToFolder(event, folderName)"
|
@drop-to-folder="(event, folderName) => handleDropToFolder(event, folderName)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</VirtualGrid>
|
</ProgressiveCardGrid>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@@ -1646,17 +1627,14 @@ function onDragStartPlugin(evt: any) {
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</Draggable>
|
</Draggable>
|
||||||
<VirtualGrid
|
<ProgressiveCardGrid
|
||||||
v-else-if="shouldVirtualizeInstalledFolderList"
|
v-else-if="shouldVirtualizeInstalledFolderList"
|
||||||
:items="draggableFolderPlugins"
|
:items="draggableFolderPlugins"
|
||||||
:columns="cols"
|
:get-item-key="item => item.id"
|
||||||
:row-estimate-size="260"
|
:min-item-width="256"
|
||||||
:gap="16"
|
:estimated-item-height="180"
|
||||||
:overscan="3"
|
|
||||||
key-field="id"
|
|
||||||
use-window-scroll
|
|
||||||
>
|
>
|
||||||
<template #item="{ item }">
|
<template #default="{ item }">
|
||||||
<PluginMixedSortCard
|
<PluginMixedSortCard
|
||||||
:item="{ type: 'plugin', id: item.id, data: item, order: 0 }"
|
:item="{ type: 'plugin', id: item.id, data: item, order: 0 }"
|
||||||
:plugin-statistics="PluginStatistics"
|
:plugin-statistics="PluginStatistics"
|
||||||
@@ -1672,7 +1650,7 @@ function onDragStartPlugin(evt: any) {
|
|||||||
@remove-from-folder="removeFromFolder"
|
@remove-from-folder="removeFromFolder"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</VirtualGrid>
|
</ProgressiveCardGrid>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1693,22 +1671,28 @@ function onDragStartPlugin(evt: any) {
|
|||||||
<div>
|
<div>
|
||||||
<LoadingBanner v-if="!isAppMarketLoaded || isMarketRefreshing" class="mt-12" />
|
<LoadingBanner v-if="!isAppMarketLoaded || isMarketRefreshing" class="mt-12" />
|
||||||
<!-- 资源列表 -->
|
<!-- 资源列表 -->
|
||||||
<VirtualGrid
|
<VInfiniteScroll
|
||||||
v-if="isAppMarketLoaded && !isMarketRefreshing && displayUninstalledList.length > 0"
|
v-if="isAppMarketLoaded && !isMarketRefreshing"
|
||||||
ref="marketGridRef"
|
mode="intersect"
|
||||||
|
side="end"
|
||||||
:items="displayUninstalledList"
|
:items="displayUninstalledList"
|
||||||
:columns="cols"
|
@load="loadMarketMore"
|
||||||
:row-estimate-size="280"
|
|
||||||
:gap="16"
|
|
||||||
:overscan="3"
|
|
||||||
use-window-scroll
|
|
||||||
class="overflow-visible"
|
class="overflow-visible"
|
||||||
@load-more="loadMarketMore"
|
|
||||||
>
|
>
|
||||||
<template #item="{ item }">
|
<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" />
|
<PluginAppCard :plugin="item" :count="PluginStatistics[item.id || '0']" @install="pluginInstalled" />
|
||||||
</template>
|
</template>
|
||||||
</VirtualGrid>
|
</ProgressiveCardGrid>
|
||||||
|
</VInfiniteScroll>
|
||||||
<NoDataFound
|
<NoDataFound
|
||||||
v-if="displayUninstalledList.length === 0 && isAppMarketLoaded"
|
v-if="displayUninstalledList.length === 0 && isAppMarketLoaded"
|
||||||
error-code="404"
|
error-code="404"
|
||||||
@@ -1783,8 +1767,8 @@ function onDragStartPlugin(evt: any) {
|
|||||||
</VToolbar>
|
</VToolbar>
|
||||||
<VDialogCloseBtn @click="closeSearchDialog" />
|
<VDialogCloseBtn @click="closeSearchDialog" />
|
||||||
<VList v-if="filterPlugins.length > 0" lines="two">
|
<VList v-if="filterPlugins.length > 0" lines="two">
|
||||||
<VirtualList :items="filterPlugins" :estimate-size="80" :overscan="6" container-height="60vh">
|
<VVirtualScroll :items="filterPlugins">
|
||||||
<template #item="{ item }">
|
<template #default="{ item }">
|
||||||
<VListItem @click="openPlugin(item)">
|
<VListItem @click="openPlugin(item)">
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<VAvatar>
|
<VAvatar>
|
||||||
@@ -1816,7 +1800,7 @@ function onDragStartPlugin(evt: any) {
|
|||||||
</VListItemSubtitle>
|
</VListItemSubtitle>
|
||||||
</VListItem>
|
</VListItem>
|
||||||
</template>
|
</template>
|
||||||
</VirtualList>
|
</VVirtualScroll>
|
||||||
</VList>
|
</VList>
|
||||||
</VCard>
|
</VCard>
|
||||||
</VDialog>
|
</VDialog>
|
||||||
|
|||||||
@@ -4,27 +4,30 @@ import api from '@/api'
|
|||||||
import type { DownloadingInfo } from '@/api/types'
|
import type { DownloadingInfo } from '@/api/types'
|
||||||
import NoDataFound from '@/components/NoDataFound.vue'
|
import NoDataFound from '@/components/NoDataFound.vue'
|
||||||
import DownloadingCard from '@/components/cards/DownloadingCard.vue'
|
import DownloadingCard from '@/components/cards/DownloadingCard.vue'
|
||||||
import VirtualGrid from '@/components/virtual/VirtualGrid.vue'
|
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||||
import { useUserStore } from '@/stores'
|
import { useUserStore } from '@/stores'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
|
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
|
||||||
import { useBreakpointCols } from '@/composables/virtual/useBreakpointCols'
|
|
||||||
|
|
||||||
|
// 国际化
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
// 列数:按视口断点(路由级全宽页)
|
|
||||||
const cols = useBreakpointCols({ xs: 1, sm: 2, md: 2, lg: 3, xl: 3, xxl: 3 })
|
|
||||||
const { useDataRefresh } = useBackgroundOptimization()
|
const { useDataRefresh } = useBackgroundOptimization()
|
||||||
|
|
||||||
|
// 定义输入参数
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
name: string
|
name: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
// 用户 Store
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
// 数据列表
|
||||||
const dataList = ref<DownloadingInfo[]>([])
|
const dataList = ref<DownloadingInfo[]>([])
|
||||||
|
|
||||||
|
// 是否刷新过
|
||||||
const isRefreshed = ref(false)
|
const isRefreshed = ref(false)
|
||||||
|
|
||||||
|
// 获取订阅列表数据
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
try {
|
try {
|
||||||
dataList.value = await api.get('download/', { params: { name: props.name } })
|
dataList.value = await api.get('download/', { params: { name: props.name } })
|
||||||
@@ -34,15 +37,19 @@ async function fetchData() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 刷新状态
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
|
// 下拉刷新
|
||||||
function onRefresh() {
|
function onRefresh() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
fetchData()
|
fetchData()
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 过滤数据,管理员用户显示全部,非管理员只显示自己的订阅
|
||||||
const filteredDataList = computed(() => {
|
const filteredDataList = computed(() => {
|
||||||
|
// 从 Store 中获取用户信息
|
||||||
const superUser = userStore.superUser
|
const superUser = userStore.superUser
|
||||||
const userName = userStore.userName
|
const userName = userStore.userName
|
||||||
if (superUser) return dataList.value
|
if (superUser) return dataList.value
|
||||||
@@ -53,27 +60,25 @@ const filteredDataList = computed(() => {
|
|||||||
const { loading: dataLoading } = useDataRefresh(
|
const { loading: dataLoading } = useDataRefresh(
|
||||||
'downloading-list',
|
'downloading-list',
|
||||||
fetchData,
|
fetchData,
|
||||||
3000,
|
3000, // 3秒间隔
|
||||||
true,
|
true // 立即执行
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
||||||
<VPullToRefresh v-model="loading" @load="onRefresh" :pull-down-threshold="64">
|
<VPullToRefresh v-model="loading" @load="onRefresh" :pull-down-threshold="64">
|
||||||
<VirtualGrid
|
<ProgressiveCardGrid
|
||||||
v-if="filteredDataList.length > 0"
|
v-if="filteredDataList.length > 0"
|
||||||
:items="filteredDataList"
|
:items="filteredDataList"
|
||||||
:columns="cols"
|
:get-item-key="item => item.hash || item.name"
|
||||||
:row-estimate-size="230"
|
:min-item-width="320"
|
||||||
:gap="12"
|
:estimated-item-height="230"
|
||||||
:overscan="3"
|
|
||||||
use-window-scroll
|
|
||||||
>
|
>
|
||||||
<template #item="{ item }">
|
<template #default="{ item }">
|
||||||
<DownloadingCard :info="item" :downloader-name="props.name" />
|
<DownloadingCard :info="item" :downloader-name="props.name" />
|
||||||
</template>
|
</template>
|
||||||
</VirtualGrid>
|
</ProgressiveCardGrid>
|
||||||
<NoDataFound
|
<NoDataFound
|
||||||
v-if="filteredDataList.length === 0 && isRefreshed"
|
v-if="filteredDataList.length === 0 && isRefreshed"
|
||||||
error-code="404"
|
error-code="404"
|
||||||
|
|||||||
@@ -3,16 +3,12 @@ import api from '@/api'
|
|||||||
import type { Site, SiteUserData } from '@/api/types'
|
import type { Site, SiteUserData } from '@/api/types'
|
||||||
import SiteCard from '@/components/cards/SiteCard.vue'
|
import SiteCard from '@/components/cards/SiteCard.vue'
|
||||||
import NoDataFound from '@/components/NoDataFound.vue'
|
import NoDataFound from '@/components/NoDataFound.vue'
|
||||||
import VirtualGrid from '@/components/virtual/VirtualGrid.vue'
|
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||||
import { useBreakpointCols } from '@/composables/virtual/useBreakpointCols'
|
|
||||||
import { useDynamicButton } from '@/composables/useDynamicButton'
|
import { useDynamicButton } from '@/composables/useDynamicButton'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { usePWA } from '@/composables/usePWA'
|
import { usePWA } from '@/composables/usePWA'
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
|
|
||||||
// 列数:按视口断点(路由级全宽页)
|
|
||||||
const cols = useBreakpointCols({ xs: 1, sm: 2, md: 3, lg: 4, xl: 5, xxl: 5 })
|
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
@@ -428,18 +424,15 @@ useDynamicButton({
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</Draggable>
|
</Draggable>
|
||||||
<VirtualGrid
|
<ProgressiveCardGrid
|
||||||
v-else-if="draggableSiteList.length > 0 && shouldVirtualizeList"
|
v-else-if="draggableSiteList.length > 0 && shouldVirtualizeList"
|
||||||
:items="draggableSiteList"
|
:items="draggableSiteList"
|
||||||
:columns="cols"
|
:get-item-key="item => item.id"
|
||||||
:row-estimate-size="220"
|
:min-item-width="256"
|
||||||
:gap="16"
|
:estimated-item-height="168"
|
||||||
:overscan="3"
|
|
||||||
key-field="id"
|
|
||||||
use-window-scroll
|
|
||||||
class="px-2"
|
class="px-2"
|
||||||
>
|
>
|
||||||
<template #item="{ item }">
|
<template #default="{ item }">
|
||||||
<SiteCard
|
<SiteCard
|
||||||
:site="item"
|
:site="item"
|
||||||
:data="siteUserDataMap[item.domain]"
|
:data="siteUserDataMap[item.domain]"
|
||||||
@@ -450,7 +443,7 @@ useDynamicButton({
|
|||||||
@refresh-stats="handleRefreshStats"
|
@refresh-stats="handleRefreshStats"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</VirtualGrid>
|
</ProgressiveCardGrid>
|
||||||
</div>
|
</div>
|
||||||
<NoDataFound
|
<NoDataFound
|
||||||
v-if="draggableSiteList.length === 0 && isRefreshed"
|
v-if="draggableSiteList.length === 0 && isRefreshed"
|
||||||
|
|||||||
@@ -5,16 +5,12 @@ import type { Subscribe } from '@/api/types'
|
|||||||
import NoDataFound from '@/components/NoDataFound.vue'
|
import NoDataFound from '@/components/NoDataFound.vue'
|
||||||
import SubscribeCard from '@/components/cards/SubscribeCard.vue'
|
import SubscribeCard from '@/components/cards/SubscribeCard.vue'
|
||||||
import SubscribeHistoryDialog from '@/components/dialog/SubscribeHistoryDialog.vue'
|
import SubscribeHistoryDialog from '@/components/dialog/SubscribeHistoryDialog.vue'
|
||||||
import VirtualGrid from '@/components/virtual/VirtualGrid.vue'
|
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||||
import { useBreakpointCols } from '@/composables/virtual/useBreakpointCols'
|
|
||||||
import { useUserStore } from '@/stores'
|
import { useUserStore } from '@/stores'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import { useConfirm } from '@/composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
|
|
||||||
// 列数:按视口断点(路由级全宽页)
|
|
||||||
const cols = useBreakpointCols({ xs: 1, sm: 2, md: 3, lg: 4, xl: 5, xxl: 5 })
|
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
@@ -90,19 +86,6 @@ const scrollToIndex = computed(() => {
|
|||||||
return targetIndex >= 0 ? targetIndex : undefined
|
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) {
|
function getSubscribeStatus(subscribe: Subscribe) {
|
||||||
// 洗版中
|
// 洗版中
|
||||||
@@ -530,19 +513,16 @@ defineExpose({
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</draggable>
|
</draggable>
|
||||||
<VirtualGrid
|
<ProgressiveCardGrid
|
||||||
v-else-if="displayList.length > 0 && shouldVirtualizeList"
|
v-else-if="displayList.length > 0 && shouldVirtualizeList"
|
||||||
ref="gridRef"
|
|
||||||
:items="displayList"
|
:items="displayList"
|
||||||
:columns="cols"
|
:get-item-key="item => item.id"
|
||||||
:row-estimate-size="280"
|
:min-item-width="240"
|
||||||
:gap="16"
|
:estimated-item-height="300"
|
||||||
:overscan="3"
|
:scroll-to-index="scrollToIndex"
|
||||||
key-field="id"
|
|
||||||
use-window-scroll
|
|
||||||
class="px-2"
|
class="px-2"
|
||||||
>
|
>
|
||||||
<template #item="{ item }">
|
<template #default="{ item }">
|
||||||
<SubscribeCard
|
<SubscribeCard
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
:media="item"
|
:media="item"
|
||||||
@@ -554,7 +534,7 @@ defineExpose({
|
|||||||
@select="toggleSelectSubscribe(item.id)"
|
@select="toggleSelectSubscribe(item.id)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</VirtualGrid>
|
</ProgressiveCardGrid>
|
||||||
<NoDataFound
|
<NoDataFound
|
||||||
v-if="displayList.length === 0 && isRefreshed"
|
v-if="displayList.length === 0 && isRefreshed"
|
||||||
error-code="404"
|
error-code="404"
|
||||||
|
|||||||
@@ -3,41 +3,48 @@ import api from '@/api'
|
|||||||
import type { MediaInfo } from '@/api/types'
|
import type { MediaInfo } from '@/api/types'
|
||||||
import MediaCard from '@/components/cards/MediaCard.vue'
|
import MediaCard from '@/components/cards/MediaCard.vue'
|
||||||
import NoDataFound from '@/components/NoDataFound.vue'
|
import NoDataFound from '@/components/NoDataFound.vue'
|
||||||
import VirtualGrid from '@/components/virtual/VirtualGrid.vue'
|
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||||
import { useBreakpointCols } from '@/composables/virtual/useBreakpointCols'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
// 列数:按视口断点(路由级全宽页)
|
|
||||||
const cols = useBreakpointCols({ xs: 3, sm: 4, md: 6, lg: 8, xl: 10, xxl: 12 })
|
|
||||||
|
|
||||||
// 输入参数
|
// 输入参数
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
type: String,
|
type: String,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 判断是否有滚动条
|
||||||
|
function hasScroll() {
|
||||||
|
return document.body.scrollHeight - (window.innerHeight || document.documentElement.clientHeight) > 2
|
||||||
|
}
|
||||||
|
|
||||||
// API
|
// API
|
||||||
const apipath = 'subscribe/popular'
|
const apipath = 'subscribe/popular'
|
||||||
|
|
||||||
|
// 当前页码
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const loading = ref(false)
|
|
||||||
const isRefreshed = ref(false)
|
|
||||||
const hasMore = ref(true)
|
|
||||||
|
|
||||||
|
// 是否加载中
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
// 是否加载完成
|
||||||
|
const isRefreshed = ref(false)
|
||||||
|
|
||||||
|
// 数据列表
|
||||||
const dataList = ref<MediaInfo[]>([])
|
const dataList = ref<MediaInfo[]>([])
|
||||||
|
const currData = ref<MediaInfo[]>([])
|
||||||
|
|
||||||
// 筛选参数
|
// 筛选参数
|
||||||
const filterParams = reactive({
|
const filterParams = reactive({
|
||||||
genre_id: '',
|
genre_id: '', // 空字符串表示选中"全部"
|
||||||
min_rating: 0,
|
min_rating: 0,
|
||||||
max_rating: 10,
|
max_rating: 10,
|
||||||
min_sub: 1,
|
min_sub: 1,
|
||||||
sort_type: 'count',
|
sort_type: 'count', // 默认按热度排序
|
||||||
})
|
})
|
||||||
|
|
||||||
// 当前 Key(用于在筛选条件变化时重置 VirtualGrid 内部状态)
|
// 当前Key(用于重新加载数据)
|
||||||
const currentKey = ref(0)
|
const currentKey = ref(0)
|
||||||
|
|
||||||
// TMDB电影风格字典
|
// TMDB电影风格字典
|
||||||
@@ -83,62 +90,115 @@ const tmdbTvGenreDict: Record<string, string> = {
|
|||||||
'37': t('tmdb.genreType.western'),
|
'37': t('tmdb.genreType.western'),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取当前类型对应的风格字典
|
||||||
const currentGenreDict = computed(() => {
|
const currentGenreDict = computed(() => {
|
||||||
return props.type === '电影' ? tmdbMovieGenreDict : tmdbTvGenreDict
|
return props.type === '电影' ? tmdbMovieGenreDict : tmdbTvGenreDict
|
||||||
})
|
})
|
||||||
|
|
||||||
// 筛选变化 → 重置列表
|
// 监听筛选参数变化
|
||||||
watch(
|
watch(
|
||||||
filterParams,
|
filterParams,
|
||||||
() => {
|
() => {
|
||||||
|
// 重置数据
|
||||||
dataList.value = []
|
dataList.value = []
|
||||||
page.value = 1
|
page.value = 1
|
||||||
hasMore.value = true
|
|
||||||
isRefreshed.value = false
|
isRefreshed.value = false
|
||||||
currentKey.value++
|
currentKey.value++
|
||||||
void fetchData()
|
|
||||||
},
|
},
|
||||||
{ deep: true },
|
{ deep: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
// 拼装参数
|
// 拼装参数
|
||||||
function getParams() {
|
function getParams() {
|
||||||
const params: { [key: string]: any } = {
|
let params: { [key: string]: any } = {
|
||||||
stype: props.type,
|
stype: props.type,
|
||||||
page: page.value,
|
page: page.value,
|
||||||
count: 30,
|
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.genre_id) {
|
||||||
if (filterParams.min_sub > 1) params.min_sub = filterParams.min_sub
|
params.genre_id = parseInt(filterParams.genre_id)
|
||||||
if (filterParams.sort_type) params.sort_type = filterParams.sort_type
|
}
|
||||||
|
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
|
return params
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchData() {
|
// 获取列表数据
|
||||||
if (loading.value || !hasMore.value) return
|
async function fetchData({ done }: { done: any }) {
|
||||||
loading.value = true
|
|
||||||
try {
|
try {
|
||||||
const data: MediaInfo[] = await api.get(apipath, { params: getParams() })
|
// 如果正在加载中,直接返回
|
||||||
isRefreshed.value = true
|
if (loading.value) {
|
||||||
if (!data || data.length === 0) {
|
done('ok')
|
||||||
hasMore.value = false
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dataList.value = [...dataList.value, ...data]
|
|
||||||
|
// 加载到满屏或者加载出错
|
||||||
|
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++
|
page.value++
|
||||||
if (data.length < 30) hasMore.value = false
|
// 返回加载成功
|
||||||
|
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')
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
} finally {
|
// 返回加载失败
|
||||||
loading.value = false
|
done('error')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
void fetchData()
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -166,16 +226,21 @@ onMounted(() => {
|
|||||||
<VLabel>{{ t('tmdb.genre') }}</VLabel>
|
<VLabel>{{ t('tmdb.genre') }}</VLabel>
|
||||||
</div>
|
</div>
|
||||||
<VChipGroup v-model="filterParams.genre_id">
|
<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') }}
|
{{ t('common.all') }}
|
||||||
</VChip>
|
</VChip>
|
||||||
<VChip
|
<VChip
|
||||||
v-for="(value, key) in currentGenreDict"
|
|
||||||
:key="key"
|
|
||||||
:color="filterParams.genre_id == key ? 'primary' : ''"
|
:color="filterParams.genre_id == key ? 'primary' : ''"
|
||||||
filter
|
filter
|
||||||
tile
|
tile
|
||||||
:value="key"
|
:value="key"
|
||||||
|
v-for="(value, key) in currentGenreDict"
|
||||||
|
:key="key"
|
||||||
>
|
>
|
||||||
{{ value }}
|
{{ value }}
|
||||||
</VChip>
|
</VChip>
|
||||||
@@ -194,24 +259,31 @@ onMounted(() => {
|
|||||||
:step="1"
|
:step="1"
|
||||||
class="align-center"
|
class="align-center"
|
||||||
hide-details
|
hide-details
|
||||||
/>
|
>
|
||||||
|
</VSlider>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
||||||
<VirtualGrid
|
<VInfiniteScroll
|
||||||
v-if="isRefreshed && dataList.length > 0"
|
mode="intersect"
|
||||||
:key="currentKey"
|
side="end"
|
||||||
:items="dataList"
|
:items="dataList"
|
||||||
:columns="cols"
|
class="overflow-visible px-2"
|
||||||
:row-estimate-size="320"
|
@load="fetchData"
|
||||||
:gap="16"
|
:key="currentKey"
|
||||||
:overscan="3"
|
|
||||||
use-window-scroll
|
|
||||||
class="pt-2 px-3"
|
|
||||||
@load-more="fetchData"
|
|
||||||
>
|
>
|
||||||
<template #item="{ item }">
|
<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>
|
<div>
|
||||||
<MediaCard :media="item" />
|
<MediaCard :media="item" />
|
||||||
<div v-if="item.popularity" class="mt-2 flex flex-row justify-center align-center text-subtitle-2">
|
<div v-if="item.popularity" class="mt-2 flex flex-row justify-center align-center text-subtitle-2">
|
||||||
@@ -220,11 +292,12 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</VirtualGrid>
|
</ProgressiveCardGrid>
|
||||||
<NoDataFound
|
<NoDataFound
|
||||||
v-if="dataList.length === 0 && isRefreshed"
|
v-if="dataList.length === 0 && isRefreshed"
|
||||||
error-code="404"
|
error-code="404"
|
||||||
:error-title="t('common.noData')"
|
:error-title="t('common.noData')"
|
||||||
:error-description="t('subscribe.noPopularData')"
|
:error-description="t('subscribe.noPopularData')"
|
||||||
/>
|
/>
|
||||||
|
</VInfiniteScroll>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -3,37 +3,44 @@ import api from '@/api'
|
|||||||
import type { SubscribeShare } from '@/api/types'
|
import type { SubscribeShare } from '@/api/types'
|
||||||
import NoDataFound from '@/components/NoDataFound.vue'
|
import NoDataFound from '@/components/NoDataFound.vue'
|
||||||
import SubscribeShareCard from '@/components/cards/SubscribeShareCard.vue'
|
import SubscribeShareCard from '@/components/cards/SubscribeShareCard.vue'
|
||||||
import VirtualGrid from '@/components/virtual/VirtualGrid.vue'
|
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||||
import { useBreakpointCols } from '@/composables/virtual/useBreakpointCols'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
// 国际化
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
// 列数:按视口断点(路由级全宽页)
|
// 定义输入参数
|
||||||
const cols = useBreakpointCols({ xs: 1, sm: 2, md: 3, lg: 4, xl: 5, xxl: 5 })
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
|
// 过滤关键字
|
||||||
keyword: String,
|
keyword: String,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 判断是否有滚动条
|
||||||
|
function hasScroll() {
|
||||||
|
return document.body.scrollHeight - (window.innerHeight || document.documentElement.clientHeight) > 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// API
|
||||||
const apipath = 'subscribe/shares'
|
const apipath = 'subscribe/shares'
|
||||||
|
|
||||||
|
// 当前页码
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
|
|
||||||
|
// 搜索关键字
|
||||||
const keyword = ref(props.keyword)
|
const keyword = ref(props.keyword)
|
||||||
const loading = ref(false)
|
|
||||||
const isRefreshed = ref(false)
|
|
||||||
const hasMore = ref(true)
|
|
||||||
const currentKey = ref(0)
|
|
||||||
|
|
||||||
const dataList = ref<SubscribeShare[]>([])
|
|
||||||
|
|
||||||
|
// 筛选参数
|
||||||
const filterParams = reactive({
|
const filterParams = reactive({
|
||||||
genre_id: '',
|
genre_id: '', // 空字符串表示选中"全部"
|
||||||
min_rating: 0,
|
min_rating: 0,
|
||||||
max_rating: 10,
|
max_rating: 10,
|
||||||
sort_type: 'time',
|
sort_type: 'time', // 默认按时间排序
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 当前Key(用于重新加载数据)
|
||||||
|
const currentKey = ref(0)
|
||||||
|
|
||||||
|
// TMDB电影风格字典
|
||||||
const tmdbMovieGenreDict: Record<string, string> = {
|
const tmdbMovieGenreDict: Record<string, string> = {
|
||||||
'28': t('tmdb.genreType.action'),
|
'28': t('tmdb.genreType.action'),
|
||||||
'12': t('tmdb.genreType.adventure'),
|
'12': t('tmdb.genreType.adventure'),
|
||||||
@@ -56,6 +63,7 @@ const tmdbMovieGenreDict: Record<string, string> = {
|
|||||||
'37': t('tmdb.genreType.western'),
|
'37': t('tmdb.genreType.western'),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TMDB电视剧风格字典
|
||||||
const tmdbTvGenreDict: Record<string, string> = {
|
const tmdbTvGenreDict: Record<string, string> = {
|
||||||
'10759': t('tmdb.genreType.actionAdventure'),
|
'10759': t('tmdb.genreType.actionAdventure'),
|
||||||
'16': t('tmdb.genreType.animation'),
|
'16': t('tmdb.genreType.animation'),
|
||||||
@@ -75,77 +83,145 @@ const tmdbTvGenreDict: Record<string, string> = {
|
|||||||
'37': t('tmdb.genreType.western'),
|
'37': t('tmdb.genreType.western'),
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentGenreDict = computed(() => ({ ...tmdbMovieGenreDict, ...tmdbTvGenreDict }))
|
// 获取当前类型对应的风格字典(订阅分享包含电影和电视剧,所以显示所有风格)
|
||||||
|
const currentGenreDict = computed(() => {
|
||||||
|
// 合并电影和电视剧风格字典
|
||||||
|
return { ...tmdbMovieGenreDict, ...tmdbTvGenreDict }
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听 props.keyword 变化
|
||||||
watch(
|
watch(
|
||||||
() => props.keyword,
|
() => props.keyword,
|
||||||
newKeyword => {
|
newKeyword => {
|
||||||
keyword.value = newKeyword || ''
|
keyword.value = newKeyword || ''
|
||||||
dataList.value = []
|
// 重置页码和数据
|
||||||
page.value = 1
|
page.value = 1
|
||||||
hasMore.value = true
|
dataList.value = []
|
||||||
isRefreshed.value = false
|
isRefreshed.value = false
|
||||||
currentKey.value++
|
currentKey.value++
|
||||||
void fetchData()
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 监听筛选参数变化
|
||||||
watch(
|
watch(
|
||||||
filterParams,
|
filterParams,
|
||||||
() => {
|
() => {
|
||||||
|
// 重置数据
|
||||||
dataList.value = []
|
dataList.value = []
|
||||||
page.value = 1
|
page.value = 1
|
||||||
hasMore.value = true
|
|
||||||
isRefreshed.value = false
|
isRefreshed.value = false
|
||||||
currentKey.value++
|
currentKey.value++
|
||||||
void fetchData()
|
|
||||||
},
|
},
|
||||||
{ deep: true },
|
{ deep: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 是否加载中
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
// 是否加载完成
|
||||||
|
const isRefreshed = ref(false)
|
||||||
|
|
||||||
|
// 数据列表
|
||||||
|
const dataList = ref<SubscribeShare[]>([])
|
||||||
|
const currData = ref<SubscribeShare[]>([])
|
||||||
|
|
||||||
|
// 拼装参数
|
||||||
function getParams() {
|
function getParams() {
|
||||||
const params: { [key: string]: any } = {
|
let params: { [key: string]: any } = {
|
||||||
page: page.value,
|
page: page.value,
|
||||||
count: 30,
|
count: 30,
|
||||||
name: keyword.value,
|
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.genre_id) {
|
||||||
if (filterParams.sort_type) params.sort_type = filterParams.sort_type
|
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
|
return params
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchData() {
|
// 获取列表数据
|
||||||
if (loading.value || !hasMore.value) return
|
async function fetchData({ done }: { done: any }) {
|
||||||
loading.value = true
|
|
||||||
try {
|
try {
|
||||||
const data: SubscribeShare[] = await api.get(apipath, { params: getParams() })
|
// 如果正在加载中,直接返回
|
||||||
isRefreshed.value = true
|
if (loading.value) {
|
||||||
if (!data || data.length === 0) {
|
done('ok')
|
||||||
hasMore.value = false
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dataList.value = [...dataList.value, ...data]
|
|
||||||
|
// 加载到满屏或者加载出错
|
||||||
|
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++
|
page.value++
|
||||||
if (data.length < 30) hasMore.value = false
|
// 返回加载成功
|
||||||
|
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')
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
} finally {
|
// 返回加载失败
|
||||||
loading.value = false
|
done('error')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 将数据从列表中移除
|
||||||
function removeData(id: number) {
|
function removeData(id: number) {
|
||||||
dataList.value = dataList.value.filter(item => item.id !== id)
|
dataList.value = dataList.value.filter(item => item.id !== id)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
void fetchData()
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- 筛选器 -->
|
||||||
<div class="px-3 mb-4">
|
<div class="px-3 mb-4">
|
||||||
<div class="flex justify-start align-center mb-3">
|
<div class="flex justify-start align-center mb-3">
|
||||||
<div class="mr-5">
|
<div class="mr-5">
|
||||||
@@ -169,16 +245,21 @@ onMounted(() => {
|
|||||||
<VLabel>{{ t('tmdb.genre') }}</VLabel>
|
<VLabel>{{ t('tmdb.genre') }}</VLabel>
|
||||||
</div>
|
</div>
|
||||||
<VChipGroup v-model="filterParams.genre_id">
|
<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') }}
|
{{ t('common.all') }}
|
||||||
</VChip>
|
</VChip>
|
||||||
<VChip
|
<VChip
|
||||||
v-for="(value, key) in currentGenreDict"
|
|
||||||
:key="key"
|
|
||||||
:color="filterParams.genre_id == key ? 'primary' : ''"
|
:color="filterParams.genre_id == key ? 'primary' : ''"
|
||||||
filter
|
filter
|
||||||
tile
|
tile
|
||||||
:value="key"
|
:value="key"
|
||||||
|
v-for="(value, key) in currentGenreDict"
|
||||||
|
:key="key"
|
||||||
>
|
>
|
||||||
{{ value }}
|
{{ value }}
|
||||||
</VChip>
|
</VChip>
|
||||||
@@ -197,32 +278,40 @@ onMounted(() => {
|
|||||||
:step="1"
|
:step="1"
|
||||||
class="align-center"
|
class="align-center"
|
||||||
hide-details
|
hide-details
|
||||||
/>
|
>
|
||||||
|
</VSlider>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<VPageContentTitle v-if="keyword" :title="`${t('common.search')}:${keyword}`" />
|
<VPageContentTitle v-if="keyword" :title="`${t('common.search')}:${keyword}`" />
|
||||||
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
||||||
<VirtualGrid
|
<VInfiniteScroll
|
||||||
v-if="isRefreshed && dataList.length > 0"
|
mode="intersect"
|
||||||
:key="currentKey"
|
side="end"
|
||||||
:items="dataList"
|
:items="dataList"
|
||||||
:columns="cols"
|
class="overflow-visible px-2"
|
||||||
:row-estimate-size="260"
|
@load="fetchData"
|
||||||
:gap="12"
|
:key="currentKey"
|
||||||
:overscan="3"
|
|
||||||
use-window-scroll
|
|
||||||
class="pt-2"
|
|
||||||
@load-more="fetchData"
|
|
||||||
>
|
>
|
||||||
<template #item="{ item }">
|
<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)" />
|
<SubscribeShareCard :media="item" @delete="removeData(item.id || 0)" />
|
||||||
</template>
|
</template>
|
||||||
</VirtualGrid>
|
</ProgressiveCardGrid>
|
||||||
<NoDataFound
|
<NoDataFound
|
||||||
v-if="dataList.length === 0 && isRefreshed"
|
v-if="dataList.length === 0 && isRefreshed"
|
||||||
error-code="404"
|
error-code="404"
|
||||||
:error-title="t('common.noData')"
|
:error-title="t('common.noData')"
|
||||||
:error-description="keyword ? t('common.noContent') : t('subscribe.noShareData')"
|
:error-description="keyword ? t('common.noContent') : t('subscribe.noShareData')"
|
||||||
/>
|
/>
|
||||||
|
</VInfiniteScroll>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { Message } from '@/api/types'
|
import type { Message } from '@/api/types'
|
||||||
import MessageCard from '@/components/cards/MessageCard.vue'
|
import MessageCard from '@/components/cards/MessageCard.vue'
|
||||||
import VirtualList from '@/components/virtual/VirtualList.vue'
|
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
|
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
|
||||||
@@ -10,28 +9,28 @@ import { useBackgroundOptimization } from '@/composables/useBackgroundOptimizati
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { useSSE } = useBackgroundOptimization()
|
const { useSSE } = useBackgroundOptimization()
|
||||||
|
|
||||||
// 消息列表(按时间升序:旧 -> 新,最新在底部)
|
// 消息列表
|
||||||
const messages = ref<Message[]>([])
|
const messages = ref<Message[]>([])
|
||||||
|
// 当前页数据
|
||||||
|
const currData = ref<Message[]>([])
|
||||||
|
|
||||||
// 已加载消息的签名集合,仅按秒级时间戳会误吞同一秒内的不同消息
|
// 已加载消息的签名集合
|
||||||
|
// 使用消息内容签名去重,避免仅按秒级时间戳判断时误吞同一秒内的不同消息。
|
||||||
const messageKeys = new Set<string>()
|
const messageKeys = new Set<string>()
|
||||||
|
|
||||||
// 是否完成首屏加载(影响空态文案显示)
|
// 是否完成加载
|
||||||
const isLoaded = ref(false)
|
const isLoaded = ref(false)
|
||||||
// 是否加载中(避免并发)
|
|
||||||
|
// 是否加载中
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
// 还有更早的消息可加载?拉到空页后置 false
|
|
||||||
const hasMoreOlder = ref(true)
|
// 当前页码
|
||||||
// 当前页码:拉取「更早」消息时递增
|
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
|
|
||||||
// 存量消息最新时间
|
// 存量消息最新时间
|
||||||
const lastTime = ref('')
|
const lastTime = ref('')
|
||||||
|
|
||||||
// 反向加载阈值。首屏完成 + forceScrollToEnd 之后才打开,否则
|
// 消息列表滚动容器
|
||||||
// reverse sentinel 在 items 首次渲染时会和跳到底部竞速,多拉一页。
|
|
||||||
const reverseThreshold = ref(0)
|
|
||||||
|
|
||||||
// 虚拟列表实例引用:用于读取 scrollEl 做智能滚动
|
|
||||||
const messageListRef = ref<any>(null)
|
const messageListRef = ref<any>(null)
|
||||||
|
|
||||||
// 自动滚动状态
|
// 自动滚动状态
|
||||||
@@ -48,8 +47,7 @@ function getMessageTime(message: Message) {
|
|||||||
return message.reg_time || message.date || ''
|
return message.reg_time || message.date || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成消息签名(多字段 fallback 链,单字段 keyField 表达不了,
|
// 生成消息签名
|
||||||
// 所以走 VirtualList 的 getItemKey 函数 prop)
|
|
||||||
function getMessageKey(message: Message) {
|
function getMessageKey(message: Message) {
|
||||||
return [
|
return [
|
||||||
message.action ?? '',
|
message.action ?? '',
|
||||||
@@ -64,11 +62,12 @@ function getMessageKey(message: Message) {
|
|||||||
].join('::')
|
].join('::')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 排序确保最新消息始终位于底部
|
// 排序消息列表,确保最新消息始终位于底部
|
||||||
function sortMessages(items: Message[]) {
|
function sortMessages(items: Message[]) {
|
||||||
return [...items].sort((a, b) => compareTime(getMessageTime(a), getMessageTime(b)))
|
return [...items].sort((a, b) => compareTime(getMessageTime(a), getMessageTime(b)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 记录最新消息时间
|
||||||
function updateLastTime(message: Message) {
|
function updateLastTime(message: Message) {
|
||||||
const messageTime = getMessageTime(message)
|
const messageTime = getMessageTime(message)
|
||||||
if (messageTime && compareTime(messageTime, lastTime.value) > 0) {
|
if (messageTime && compareTime(messageTime, lastTime.value) > 0) {
|
||||||
@@ -77,8 +76,7 @@ function updateLastTime(message: Message) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getScrollContainer() {
|
function getScrollContainer() {
|
||||||
const container =
|
const container = messageListRef.value?.$el ?? messageListRef.value
|
||||||
messageListRef.value?.getScrollElement?.() ?? messageListRef.value?.$el ?? messageListRef.value
|
|
||||||
|
|
||||||
return container instanceof HTMLElement ? container : null
|
return container instanceof HTMLElement ? container : null
|
||||||
}
|
}
|
||||||
@@ -102,6 +100,21 @@ function handleScroll() {
|
|||||||
updateAutoScrollState()
|
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() {
|
function scrollContainerToEnd() {
|
||||||
const container = getScrollContainer()
|
const container = getScrollContainer()
|
||||||
if (!container) {
|
if (!container) {
|
||||||
@@ -154,71 +167,101 @@ function forceScrollToEnd() {
|
|||||||
requestScrollToEnd(true)
|
requestScrollToEnd(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 合并消息到当前列表
|
||||||
function mergeMessages(items: Message[]) {
|
function mergeMessages(items: Message[]) {
|
||||||
let hasNewMessage = false
|
let hasNewMessage = false
|
||||||
|
|
||||||
for (const item of sortMessages(items)) {
|
for (const item of sortMessages(items)) {
|
||||||
const messageKey = getMessageKey(item)
|
const messageKey = getMessageKey(item)
|
||||||
if (messageKeys.has(messageKey)) continue
|
if (messageKeys.has(messageKey)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
messageKeys.add(messageKey)
|
messageKeys.add(messageKey)
|
||||||
messages.value.push(item)
|
messages.value.push(item)
|
||||||
updateLastTime(item)
|
updateLastTime(item)
|
||||||
hasNewMessage = true
|
hasNewMessage = true
|
||||||
}
|
}
|
||||||
if (hasNewMessage) messages.value = sortMessages(messages.value)
|
|
||||||
|
if (hasNewMessage) {
|
||||||
|
messages.value = sortMessages(messages.value)
|
||||||
|
}
|
||||||
|
|
||||||
return hasNewMessage
|
return hasNewMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSE 新消息到达 -> 智能跟随到底
|
// SSE消息处理函数
|
||||||
function handleSSEMessage(event: MessageEvent) {
|
function handleSSEMessage(event: MessageEvent) {
|
||||||
const message = event.data
|
const message = event.data
|
||||||
if (!message) return
|
if (message) {
|
||||||
|
|
||||||
const object = JSON.parse(message)
|
const object = JSON.parse(message)
|
||||||
if (mergeMessages([object])) {
|
if (mergeMessages([object])) {
|
||||||
requestScrollToEnd()
|
requestScrollToEnd() // 新消息到达时触发智能滚动
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用优化的SSE连接
|
// 使用优化的SSE连接
|
||||||
const { manager } = useSSE(
|
const { manager, isConnected } = useSSE(
|
||||||
`${import.meta.env.VITE_API_BASE_URL}system/message?role=user`,
|
`${import.meta.env.VITE_API_BASE_URL}system/message?role=user`,
|
||||||
handleSSEMessage,
|
handleSSEMessage,
|
||||||
'message-view',
|
'message-view',
|
||||||
{ backgroundCloseDelay: 5000, reconnectDelay: 3000, maxReconnectAttempts: 3 },
|
{
|
||||||
|
backgroundCloseDelay: 5000,
|
||||||
|
reconnectDelay: 3000,
|
||||||
|
maxReconnectAttempts: 3,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// 加载更早一页:首屏 + 用户向上滚动时由 @load-more-reverse 触发
|
// 调用API加载存量消息
|
||||||
async function loadOlderMessages() {
|
async function loadMessages({ done }: { done: any }) {
|
||||||
if (loading.value || !hasMoreOlder.value) return
|
// 如果正在加载中,直接返回
|
||||||
|
if (loading.value) {
|
||||||
|
done('ok')
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
|
// 设置加载中
|
||||||
loading.value = true
|
loading.value = true
|
||||||
const data = (await api.get('message/web', {
|
currData.value = await api.get('message/web', {
|
||||||
params: { page: page.value, size: 20 },
|
params: {
|
||||||
})) as Message[]
|
page: page.value,
|
||||||
|
size: 20,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// 已加载过
|
||||||
isLoaded.value = true
|
isLoaded.value = true
|
||||||
if (data.length > 0) {
|
if (currData.value.length > 0) {
|
||||||
const hasNewMessage = mergeMessages(data)
|
const hasNewMessage = mergeMessages(currData.value)
|
||||||
|
|
||||||
|
// 首次加载时滚动到底部
|
||||||
if (page.value === 1 && hasNewMessage) {
|
if (page.value === 1 && hasNewMessage) {
|
||||||
requestScrollToEnd(true)
|
requestScrollToEnd(true)
|
||||||
}
|
}
|
||||||
|
// 页码+1
|
||||||
page.value++
|
page.value++
|
||||||
|
// 完成
|
||||||
|
done('ok')
|
||||||
} else {
|
} else {
|
||||||
hasMoreOlder.value = false
|
// 没有新数据
|
||||||
|
done('empty')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载消息失败:', error)
|
console.error('加载消息失败:', error)
|
||||||
|
done('error')
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 主动刷新最新一页,作为 SSE 偶发丢流时的兜底
|
// 主动刷新最新一页消息,作为SSE偶发丢流时的兜底
|
||||||
async function refreshLatestMessages() {
|
async function refreshLatestMessages() {
|
||||||
try {
|
try {
|
||||||
const latestMessages = (await api.get('message/web', {
|
const latestMessages = (await api.get('message/web', {
|
||||||
params: { page: 1, size: 20 },
|
params: {
|
||||||
|
page: 1,
|
||||||
|
size: 20,
|
||||||
|
},
|
||||||
})) as Message[]
|
})) as Message[]
|
||||||
|
|
||||||
if (mergeMessages(latestMessages)) {
|
if (mergeMessages(latestMessages)) {
|
||||||
@@ -229,41 +272,57 @@ async function refreshLatestMessages() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 比较时间
|
// 比较yyyy-MM-dd HH:mm:ss时间大小
|
||||||
function compareTime(time1: string, time2: string) {
|
function compareTime(time1: string, time2: string) {
|
||||||
if (!time1 && !time2) return 0
|
if (!time1 && !time2) return 0
|
||||||
if (!time1) return -1
|
if (!time1) return -1
|
||||||
if (!time2) return 1
|
if (!time2) return 1
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 统一时间格式处理,支持多种格式
|
||||||
const normalizeTime = (time: string) => {
|
const normalizeTime = (time: string) => {
|
||||||
if (time.includes('T')) return new Date(time).getTime()
|
// 如果是ISO格式,直接使用
|
||||||
|
if (time.includes('T')) {
|
||||||
|
return new Date(time).getTime()
|
||||||
|
}
|
||||||
|
// 如果是yyyy-MM-dd HH:mm:ss格式,替换-为/
|
||||||
return new Date(time.replaceAll(/-/g, '/')).getTime()
|
return new Date(time.replaceAll(/-/g, '/')).getTime()
|
||||||
}
|
}
|
||||||
return normalizeTime(time1) - normalizeTime(time2)
|
|
||||||
|
const timestamp1 = normalizeTime(time1)
|
||||||
|
const timestamp2 = normalizeTime(time2)
|
||||||
|
|
||||||
|
return timestamp1 - timestamp2
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('时间比较错误:', error, 'time1:', time1, 'time2:', time2)
|
console.error('时间比较错误:', error, 'time1:', time1, 'time2:', time2)
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 图片加载完成 -> 智能跟随(图片把行撑高,需要重判距底距离)
|
// 图片加载完成时触发智能滚动
|
||||||
function handleImageLoad() {
|
function handleImageLoad() {
|
||||||
requestScrollToEnd()
|
requestScrollToEnd()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 暂停/恢复 SSE
|
// 暂停SSE连接
|
||||||
function pauseSSE() {
|
function pauseSSE() {
|
||||||
manager?.removeMessageListener('message-view')
|
if (manager) {
|
||||||
|
manager.removeMessageListener('message-view')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 恢复SSE连接
|
||||||
function resumeSSE() {
|
function resumeSSE() {
|
||||||
if (manager) {
|
if (manager) {
|
||||||
|
// 先移除再重建监听,确保恢复时拿到一条新的SSE连接。
|
||||||
manager.removeMessageListener('message-view')
|
manager.removeMessageListener('message-view')
|
||||||
manager.addMessageListener('message-view', handleSSEMessage)
|
manager.addMessageListener('message-view', handleSSEMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshLatestMessages()
|
refreshLatestMessages()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 暴露方法给父组件
|
||||||
defineExpose({
|
defineExpose({
|
||||||
pauseSSE,
|
pauseSSE,
|
||||||
resumeSSE,
|
resumeSSE,
|
||||||
@@ -271,12 +330,10 @@ defineExpose({
|
|||||||
forceScrollToEnd,
|
forceScrollToEnd,
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(() => {
|
||||||
await loadOlderMessages()
|
nextTick(() => {
|
||||||
await nextTick()
|
bindScrollListener()
|
||||||
scrollContainerToEnd()
|
requestScrollToEnd(true)
|
||||||
requestAnimationFrame(() => {
|
|
||||||
reverseThreshold.value = 1
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -288,43 +345,38 @@ onBeforeUnmount(() => {
|
|||||||
if (scrollReleaseTimer) {
|
if (scrollReleaseTimer) {
|
||||||
window.clearTimeout(scrollReleaseTimer)
|
window.clearTimeout(scrollReleaseTimer)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
unbindScrollListener()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VirtualList
|
<VInfiniteScroll
|
||||||
ref="messageListRef"
|
ref="messageListRef"
|
||||||
|
:mode="!isLoaded ? 'intersect' : 'manual'"
|
||||||
|
side="start"
|
||||||
:items="messages"
|
:items="messages"
|
||||||
:estimate-size="160"
|
class="overflow-auto h-full"
|
||||||
:get-item-key="getMessageKey"
|
@load="loadMessages"
|
||||||
:load-more-reverse-threshold="reverseThreshold"
|
:load-more-text="t('message.loadMore') + ' ...'"
|
||||||
container-height="100%"
|
|
||||||
class="h-full overflow-auto"
|
|
||||||
@load-more-reverse="loadOlderMessages"
|
|
||||||
@scroll="handleScroll"
|
|
||||||
>
|
>
|
||||||
<template #empty>
|
|
||||||
<div class="d-flex justify-center align-center h-full text-medium-emphasis">
|
|
||||||
{{ isLoaded ? t('message.noMoreData') : '' }}
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #loading>
|
<template #loading>
|
||||||
<LoadingBanner v-if="loading" />
|
<LoadingBanner />
|
||||||
</template>
|
</template>
|
||||||
|
<template #empty> {{ t('message.noMoreData') }} </template>
|
||||||
<template #item="{ item }">
|
<VVirtualScroll renderless :items="messages" :item-height="160">
|
||||||
|
<template #default="{ item, index, itemRef }">
|
||||||
<div
|
<div
|
||||||
|
:ref="itemRef"
|
||||||
|
:key="getMessageKey(item) || index"
|
||||||
class="chat-group d-flex mt-5 mb-8"
|
class="chat-group d-flex mt-5 mb-8"
|
||||||
:class="item.action == 1 ? 'flex-row align-start' : 'flex-row-reverse align-end'"
|
:class="item.action == 1 ? 'flex-row align-start' : 'flex-row-reverse align-end'"
|
||||||
>
|
>
|
||||||
<div
|
<div class="d-inline-flex flex-column" :class="item.action == 1 ? 'align-start' : 'align-end'">
|
||||||
class="d-inline-flex flex-column"
|
|
||||||
:class="item.action == 1 ? 'align-start' : 'align-end'"
|
|
||||||
>
|
|
||||||
<MessageCard :message="item" @imageload="handleImageLoad" />
|
<MessageCard :message="item" @imageload="handleImageLoad" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</VirtualList>
|
</VVirtualScroll>
|
||||||
|
</VInfiniteScroll>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -4,15 +4,11 @@ import type { User } from '@/api/types'
|
|||||||
import NoDataFound from '@/components/NoDataFound.vue'
|
import NoDataFound from '@/components/NoDataFound.vue'
|
||||||
import UserCard from '@/components/cards/UserCard.vue'
|
import UserCard from '@/components/cards/UserCard.vue'
|
||||||
import UserAddEditDialog from '@/components/dialog/UserAddEditDialog.vue'
|
import UserAddEditDialog from '@/components/dialog/UserAddEditDialog.vue'
|
||||||
import VirtualGrid from '@/components/virtual/VirtualGrid.vue'
|
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||||
import { useBreakpointCols } from '@/composables/virtual/useBreakpointCols'
|
|
||||||
import { useDynamicButton } from '@/composables/useDynamicButton'
|
import { useDynamicButton } from '@/composables/useDynamicButton'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { usePWA } from '@/composables/usePWA'
|
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()
|
const { t } = useI18n()
|
||||||
|
|
||||||
@@ -85,21 +81,19 @@ useDynamicButton({
|
|||||||
<!-- 加载中提示 -->
|
<!-- 加载中提示 -->
|
||||||
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
||||||
<!-- 用户卡片网格 -->
|
<!-- 用户卡片网格 -->
|
||||||
<VirtualGrid
|
<ProgressiveCardGrid
|
||||||
v-if="allUsers.length > 0 && isRefreshed"
|
v-if="allUsers.length > 0 && isRefreshed"
|
||||||
:items="allUsers"
|
:items="allUsers"
|
||||||
:columns="cols"
|
:min-item-width="288"
|
||||||
:row-estimate-size="260"
|
:estimated-item-height="260"
|
||||||
:gap="16"
|
:get-item-key="user => user.id"
|
||||||
key-field="id"
|
|
||||||
use-window-scroll
|
|
||||||
class="px-2"
|
class="px-2"
|
||||||
>
|
>
|
||||||
<!-- 普通用户卡片 -->
|
<!-- 普通用户卡片 -->
|
||||||
<template #item="{ item }">
|
<template #default="{ item }">
|
||||||
<UserCard :user="item" :users="allUsers" @remove="loadAllUsers" @save="loadAllUsers" />
|
<UserCard :user="item" :users="allUsers" @remove="loadAllUsers" @save="loadAllUsers" />
|
||||||
</template>
|
</template>
|
||||||
</VirtualGrid>
|
</ProgressiveCardGrid>
|
||||||
|
|
||||||
<!-- 无数据提示 -->
|
<!-- 无数据提示 -->
|
||||||
<div v-if="allUsers.length === 0 && isRefreshed">
|
<div v-if="allUsers.length === 0 && isRefreshed">
|
||||||
|
|||||||
@@ -4,20 +4,25 @@ import { Workflow } from '@/api/types'
|
|||||||
import WorkflowAddEditDialog from '@/components/dialog/WorkflowAddEditDialog.vue'
|
import WorkflowAddEditDialog from '@/components/dialog/WorkflowAddEditDialog.vue'
|
||||||
import WorkflowTaskCard from '@/components/cards/WorkflowTaskCard.vue'
|
import WorkflowTaskCard from '@/components/cards/WorkflowTaskCard.vue'
|
||||||
import NoDataFound from '@/components/NoDataFound.vue'
|
import NoDataFound from '@/components/NoDataFound.vue'
|
||||||
import VirtualGrid from '@/components/virtual/VirtualGrid.vue'
|
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||||
import { useBreakpointCols } from '@/composables/virtual/useBreakpointCols'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
// 国际化
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
// 列数:按视口断点(路由级全宽页)
|
// 是否刷新
|
||||||
const cols = useBreakpointCols({ xs: 1, sm: 2, md: 2, lg: 3, xl: 4, xxl: 4 })
|
|
||||||
|
|
||||||
const isRefreshed = ref(false)
|
const isRefreshed = ref(false)
|
||||||
|
|
||||||
|
// 新增对话框
|
||||||
const addDialog = ref(false)
|
const addDialog = ref(false)
|
||||||
|
|
||||||
|
// 所有任务
|
||||||
const workflowList = ref<Workflow[]>([])
|
const workflowList = ref<Workflow[]>([])
|
||||||
|
|
||||||
|
// 事件类型列表
|
||||||
const eventTypes = ref<Array<{ title: string; value: string }>>([])
|
const eventTypes = ref<Array<{ title: string; value: string }>>([])
|
||||||
|
|
||||||
|
// 加载事件类型列表
|
||||||
async function loadEventTypes() {
|
async function loadEventTypes() {
|
||||||
try {
|
try {
|
||||||
eventTypes.value = await api.get('workflow/event_types')
|
eventTypes.value = await api.get('workflow/event_types')
|
||||||
@@ -26,6 +31,7 @@ async function loadEventTypes() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 加载数据
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
try {
|
try {
|
||||||
workflowList.value = await api.get('workflow/')
|
workflowList.value = await api.get('workflow/')
|
||||||
@@ -35,6 +41,7 @@ async function fetchData() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 新增完成
|
||||||
function addDone() {
|
function addDone() {
|
||||||
addDialog.value = false
|
addDialog.value = false
|
||||||
fetchData()
|
fetchData()
|
||||||
@@ -57,25 +64,21 @@ defineExpose({
|
|||||||
openAddDialog,
|
openAddDialog,
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
||||||
<VirtualGrid
|
<ProgressiveCardGrid
|
||||||
v-if="workflowList.length > 0 && isRefreshed"
|
v-if="workflowList.length > 0 && isRefreshed"
|
||||||
:items="workflowList"
|
:items="workflowList"
|
||||||
:columns="cols"
|
:get-item-key="item => item.id"
|
||||||
:row-estimate-size="420"
|
:min-item-width="288"
|
||||||
:gap="12"
|
:estimated-item-height="420"
|
||||||
:overscan="2"
|
|
||||||
key-field="id"
|
|
||||||
use-window-scroll
|
|
||||||
class="px-2"
|
class="px-2"
|
||||||
>
|
>
|
||||||
<template #item="{ item }">
|
<template #default="{ item }">
|
||||||
<WorkflowTaskCard :workflow="item" :event-types="eventTypes" @refresh="fetchData" />
|
<WorkflowTaskCard :workflow="item" :event-types="eventTypes" @refresh="fetchData" />
|
||||||
</template>
|
</template>
|
||||||
</VirtualGrid>
|
</ProgressiveCardGrid>
|
||||||
<NoDataFound
|
<NoDataFound
|
||||||
v-if="workflowList.length === 0 && isRefreshed"
|
v-if="workflowList.length === 0 && isRefreshed"
|
||||||
error-code="404"
|
error-code="404"
|
||||||
|
|||||||
@@ -3,33 +3,50 @@ import api from '@/api'
|
|||||||
import type { WorkflowShare } from '@/api/types'
|
import type { WorkflowShare } from '@/api/types'
|
||||||
import NoDataFound from '@/components/NoDataFound.vue'
|
import NoDataFound from '@/components/NoDataFound.vue'
|
||||||
import WorkflowShareCard from '@/components/cards/WorkflowShareCard.vue'
|
import WorkflowShareCard from '@/components/cards/WorkflowShareCard.vue'
|
||||||
import VirtualGrid from '@/components/virtual/VirtualGrid.vue'
|
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||||
import { useBreakpointCols } from '@/composables/virtual/useBreakpointCols'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
// 国际化
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
// 列数:按视口断点(路由级全宽页)
|
// 定义输入参数
|
||||||
const cols = useBreakpointCols({ xs: 1, sm: 2, md: 2, lg: 3, xl: 4, xxl: 4 })
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
|
// 过滤关键字
|
||||||
keyword: String,
|
keyword: String,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 定义事件
|
||||||
const emit = defineEmits(['update'])
|
const emit = defineEmits(['update'])
|
||||||
|
|
||||||
|
// 判断是否有滚动条
|
||||||
|
function hasScroll() {
|
||||||
|
return document.body.scrollHeight - (window.innerHeight || document.documentElement.clientHeight) > 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// API
|
||||||
const apipath = 'workflow/shares'
|
const apipath = 'workflow/shares'
|
||||||
|
|
||||||
|
// 当前页码
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
|
|
||||||
|
// 搜索关键字
|
||||||
const keyword = ref(props.keyword)
|
const keyword = ref(props.keyword)
|
||||||
const loading = ref(false)
|
|
||||||
const isRefreshed = ref(false)
|
|
||||||
const hasMore = ref(true)
|
|
||||||
const currentKey = ref(0)
|
const currentKey = ref(0)
|
||||||
|
|
||||||
|
// 是否加载中
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
// 是否加载完成
|
||||||
|
const isRefreshed = ref(false)
|
||||||
|
|
||||||
|
// 数据列表
|
||||||
const dataList = ref<WorkflowShare[]>([])
|
const dataList = ref<WorkflowShare[]>([])
|
||||||
|
const currData = ref<WorkflowShare[]>([])
|
||||||
|
|
||||||
|
// 事件类型列表
|
||||||
const eventTypes = ref<Array<{ title: string; value: string }>>([])
|
const eventTypes = ref<Array<{ title: string; value: string }>>([])
|
||||||
|
|
||||||
|
// 加载事件类型列表
|
||||||
async function loadEventTypes() {
|
async function loadEventTypes() {
|
||||||
try {
|
try {
|
||||||
eventTypes.value = await api.get('workflow/event_types')
|
eventTypes.value = await api.get('workflow/event_types')
|
||||||
@@ -42,79 +59,113 @@ watch(
|
|||||||
() => props.keyword,
|
() => props.keyword,
|
||||||
newKeyword => {
|
newKeyword => {
|
||||||
keyword.value = newKeyword || ''
|
keyword.value = newKeyword || ''
|
||||||
dataList.value = []
|
|
||||||
page.value = 1
|
page.value = 1
|
||||||
hasMore.value = true
|
dataList.value = []
|
||||||
isRefreshed.value = false
|
isRefreshed.value = false
|
||||||
currentKey.value++
|
currentKey.value++
|
||||||
void fetchData()
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 拼装参数
|
||||||
function getParams() {
|
function getParams() {
|
||||||
return {
|
let params = {
|
||||||
page: page.value,
|
page: page.value,
|
||||||
count: 30,
|
count: 30,
|
||||||
name: keyword.value,
|
name: keyword.value,
|
||||||
}
|
}
|
||||||
|
return params
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchData() {
|
// 获取列表数据
|
||||||
if (loading.value || !hasMore.value) return
|
async function fetchData({ done }: { done: any }) {
|
||||||
loading.value = true
|
|
||||||
try {
|
try {
|
||||||
const data: WorkflowShare[] = await api.get(apipath, { params: getParams() })
|
// 如果正在加载中,直接返回
|
||||||
isRefreshed.value = true
|
if (loading.value) {
|
||||||
if (!data || data.length === 0) {
|
done('ok')
|
||||||
hasMore.value = false
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dataList.value = [...dataList.value, ...data]
|
|
||||||
|
// 加载到满屏或者加载出错
|
||||||
|
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++
|
page.value++
|
||||||
if (data.length < 30) hasMore.value = false
|
// 返回加载成功
|
||||||
|
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')
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
} finally {
|
// 返回加载失败
|
||||||
loading.value = false
|
done('error')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 将数据从列表中移除
|
||||||
function removeData(id: string) {
|
function removeData(id: string) {
|
||||||
dataList.value = dataList.value.filter(item => item.id !== id)
|
dataList.value = dataList.value.filter(item => item.id !== id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 路由激活:刷新事件类型 + 数据(保留原行为)
|
|
||||||
onActivated(() => {
|
onActivated(() => {
|
||||||
loadEventTypes()
|
loadEventTypes()
|
||||||
// 仅在尚未加载过时拉首屏,避免每次切回都全量重拉造成白屏
|
fetchData({ done: () => {} })
|
||||||
if (!isRefreshed.value || dataList.value.length === 0) {
|
|
||||||
void fetchData()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
loadEventTypes()
|
|
||||||
void fetchData()
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VPageContentTitle v-if="keyword" :title="`${t('common.search')}:${keyword}`" />
|
<VPageContentTitle v-if="keyword" :title="`${t('common.search')}:${keyword}`" />
|
||||||
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
||||||
<VirtualGrid
|
<VInfiniteScroll mode="intersect" side="end" :items="dataList" class="overflow-visible px-2" @load="fetchData" :key="currentKey">
|
||||||
v-if="isRefreshed && dataList.length > 0"
|
<template #loading />
|
||||||
:key="currentKey"
|
<template #empty />
|
||||||
|
<ProgressiveCardGrid
|
||||||
|
v-if="dataList.length > 0"
|
||||||
:items="dataList"
|
:items="dataList"
|
||||||
:columns="cols"
|
:get-item-key="item => item.id"
|
||||||
:row-estimate-size="220"
|
:min-item-width="288"
|
||||||
:gap="12"
|
:estimated-item-height="220"
|
||||||
:overscan="3"
|
tabindex="0"
|
||||||
key-field="id"
|
|
||||||
use-window-scroll
|
|
||||||
class="pt-2"
|
|
||||||
@load-more="fetchData"
|
|
||||||
>
|
>
|
||||||
<template #item="{ item }">
|
<template #default="{ item }">
|
||||||
<WorkflowShareCard
|
<WorkflowShareCard
|
||||||
:workflow="item"
|
:workflow="item"
|
||||||
:event-types="eventTypes"
|
:event-types="eventTypes"
|
||||||
@@ -122,11 +173,12 @@ onMounted(() => {
|
|||||||
@update="emit('update')"
|
@update="emit('update')"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</VirtualGrid>
|
</ProgressiveCardGrid>
|
||||||
<NoDataFound
|
<NoDataFound
|
||||||
v-if="dataList.length === 0 && isRefreshed"
|
v-if="dataList.length === 0 && isRefreshed"
|
||||||
error-code="404"
|
error-code="404"
|
||||||
:error-title="t('common.noData')"
|
:error-title="t('common.noData')"
|
||||||
:error-description="keyword ? t('common.noContent') : t('workflow.noShareData')"
|
:error-description="keyword ? t('common.noContent') : t('workflow.noShareData')"
|
||||||
/>
|
/>
|
||||||
|
</VInfiniteScroll>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -861,24 +861,24 @@
|
|||||||
integrity sha512-+nxncfwHM5SgAtrVzgpzJOI1ol0PkumhVo469KCf9lUi21IGcY90G98VuHm9VRrUypmAzawAHO9bs6hqeADaVg==
|
integrity sha512-+nxncfwHM5SgAtrVzgpzJOI1ol0PkumhVo469KCf9lUi21IGcY90G98VuHm9VRrUypmAzawAHO9bs6hqeADaVg==
|
||||||
|
|
||||||
"@emnapi/core@^1.4.3":
|
"@emnapi/core@^1.4.3":
|
||||||
version "1.10.0"
|
version "1.8.1"
|
||||||
resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.10.0.tgz#380ccc8f2412ea22d1d972df7f8ee23a3b9c7467"
|
resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.8.1.tgz#fd9efe721a616288345ffee17a1f26ac5dd01349"
|
||||||
integrity sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==
|
integrity sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@emnapi/wasi-threads" "1.2.1"
|
"@emnapi/wasi-threads" "1.1.0"
|
||||||
tslib "^2.4.0"
|
tslib "^2.4.0"
|
||||||
|
|
||||||
"@emnapi/runtime@^1.2.0", "@emnapi/runtime@^1.4.3":
|
"@emnapi/runtime@^1.2.0", "@emnapi/runtime@^1.4.3":
|
||||||
version "1.10.0"
|
version "1.8.1"
|
||||||
resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.10.0.tgz#4b260c0d3534204e98c6110b8db1a987d26ec87c"
|
resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.8.1.tgz#550fa7e3c0d49c5fb175a116e8cd70614f9a22a5"
|
||||||
integrity sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==
|
integrity sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib "^2.4.0"
|
tslib "^2.4.0"
|
||||||
|
|
||||||
"@emnapi/wasi-threads@1.2.1":
|
"@emnapi/wasi-threads@1.1.0":
|
||||||
version "1.2.1"
|
version "1.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz#28fed21a1ba1ce797c44a070abc94d42f3ae8548"
|
resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz#60b2102fddc9ccb78607e4a3cf8403ea69be41bf"
|
||||||
integrity sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==
|
integrity sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib "^2.4.0"
|
tslib "^2.4.0"
|
||||||
|
|
||||||
@@ -1175,7 +1175,7 @@
|
|||||||
|
|
||||||
"@iconify-json/tabler@^1.2.23":
|
"@iconify-json/tabler@^1.2.23":
|
||||||
version "1.2.34"
|
version "1.2.34"
|
||||||
resolved "https://registry.npmjs.org/@iconify-json/tabler/-/tabler-1.2.34.tgz"
|
resolved "https://registry.yarnpkg.com/@iconify-json/tabler/-/tabler-1.2.34.tgz#5c61bf336911c289aaaf218e0c6b78a34a27bc88"
|
||||||
integrity sha512-WSlE5QrptidM57sCnXkpxZKcrk+oue6OlSJD5+gw8rIjuovOeNlejL/zABBM5kASsxLjoSy738Q8hmKrVzODuA==
|
integrity sha512-WSlE5QrptidM57sCnXkpxZKcrk+oue6OlSJD5+gw8rIjuovOeNlejL/zABBM5kASsxLjoSy738Q8hmKrVzODuA==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@iconify/types" "*"
|
"@iconify/types" "*"
|
||||||
@@ -1938,18 +1938,6 @@
|
|||||||
resolved "https://registry.npmjs.org/@tailwindcss/aspect-ratio/-/aspect-ratio-0.4.2.tgz"
|
resolved "https://registry.npmjs.org/@tailwindcss/aspect-ratio/-/aspect-ratio-0.4.2.tgz"
|
||||||
integrity sha512-8QPrypskfBa7QIMuKHg2TA7BqES6vhBrDLOv8Unb6FcFyd3TjKbc6lcmb9UPQHxfl24sXoJ41ux/H7qQQvfaSQ==
|
integrity sha512-8QPrypskfBa7QIMuKHg2TA7BqES6vhBrDLOv8Unb6FcFyd3TjKbc6lcmb9UPQHxfl24sXoJ41ux/H7qQQvfaSQ==
|
||||||
|
|
||||||
"@tanstack/virtual-core@3.14.0":
|
|
||||||
version "3.14.0"
|
|
||||||
resolved "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.14.0.tgz"
|
|
||||||
integrity sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==
|
|
||||||
|
|
||||||
"@tanstack/vue-virtual@^3.13.24":
|
|
||||||
version "3.13.24"
|
|
||||||
resolved "https://registry.yarnpkg.com/@tanstack/vue-virtual/-/vue-virtual-3.13.24.tgz#f6c76f452dff6c1add9934f6b8023b9c8dce06c0"
|
|
||||||
integrity sha512-A0k2qF0zFSUStXSZkGXABouXr2Tw2Ztl/cVIYG9qy84uR8W7UNjAcX3DvzBS3YnDcwvLxab8v7dbmYBZ39itDA==
|
|
||||||
dependencies:
|
|
||||||
"@tanstack/virtual-core" "3.14.0"
|
|
||||||
|
|
||||||
"@tokenizer/token@^0.3.0":
|
"@tokenizer/token@^0.3.0":
|
||||||
version "0.3.0"
|
version "0.3.0"
|
||||||
resolved "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz"
|
resolved "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz"
|
||||||
@@ -1961,9 +1949,9 @@
|
|||||||
integrity sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==
|
integrity sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==
|
||||||
|
|
||||||
"@tybys/wasm-util@^0.10.0":
|
"@tybys/wasm-util@^0.10.0":
|
||||||
version "0.10.2"
|
version "0.10.1"
|
||||||
resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.2.tgz#12b3a1b33db1f9cad4ddff1f604ab7dd00bf464e"
|
resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz#ecddd3205cf1e2d5274649ff0eedd2991ed7f414"
|
||||||
integrity sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==
|
integrity sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib "^2.4.0"
|
tslib "^2.4.0"
|
||||||
|
|
||||||
@@ -7014,7 +7002,16 @@ std-env@^3.9.0:
|
|||||||
resolved "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz"
|
resolved "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz"
|
||||||
integrity sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==
|
integrity sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==
|
||||||
|
|
||||||
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
|
"string-width-cjs@npm:string-width@^4.2.0":
|
||||||
|
version "4.2.3"
|
||||||
|
resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz"
|
||||||
|
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||||
|
dependencies:
|
||||||
|
emoji-regex "^8.0.0"
|
||||||
|
is-fullwidth-code-point "^3.0.0"
|
||||||
|
strip-ansi "^6.0.1"
|
||||||
|
|
||||||
|
string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
|
||||||
version "4.2.3"
|
version "4.2.3"
|
||||||
resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz"
|
resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz"
|
||||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||||
@@ -7099,7 +7096,14 @@ stringify-object@^3.3.0:
|
|||||||
is-obj "^1.0.1"
|
is-obj "^1.0.1"
|
||||||
is-regexp "^1.0.0"
|
is-regexp "^1.0.0"
|
||||||
|
|
||||||
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
|
||||||
|
version "6.0.1"
|
||||||
|
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz"
|
||||||
|
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||||
|
dependencies:
|
||||||
|
ansi-regex "^5.0.1"
|
||||||
|
|
||||||
|
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||||
version "6.0.1"
|
version "6.0.1"
|
||||||
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz"
|
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz"
|
||||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||||
|
|||||||
Reference in New Issue
Block a user