perf: virtualize remaining long result views

Reduce DOM growth across resource, history, workflow, share, downloading, and message views so large datasets stay responsive while scrolling.
This commit is contained in:
jxxghp
2026-05-09 17:28:23 +08:00
parent 96d655155a
commit 62e0d8e9dc
11 changed files with 236 additions and 189 deletions

View File

@@ -5,6 +5,8 @@ import api from '@/api'
import type { Context } from '@/api/types'
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
import { isNullOrEmptyObject } from '@/@core/utils'
import { getCachedSiteIcon } from '@/utils/siteIconCache'
import { downloadedTorrentMap, markTorrentDownloaded } from '@/utils/torrentDownloadCache'
// 输入参数
const props = defineProps({
@@ -32,8 +34,7 @@ const downloadItem = ref(props.torrent)
// 站点图标
const siteIcons = ref<Record<number, string>>({})
// 存储是否已经下载过的记录
const downloaded = ref<string[]>([])
const isDownloaded = computed(() => Boolean(torrent.value?.enclosure && downloadedTorrentMap[torrent.value.enclosure]))
// 添加下载对话框
const addDownloadDialog = ref(false)
@@ -41,8 +42,7 @@ const addDownloadDialog = ref(false)
// 添加下载成功
function addDownloadSuccess(url: string) {
addDownloadDialog.value = false
// 添加下载成功
downloaded.value.push(url)
markTorrentDownloaded(url)
}
// 添加下载失败
@@ -53,10 +53,21 @@ function addDownloadError(error: string) {
// 查询站点图标
async function getSiteIcon(site: number | undefined) {
if (!site) return
try {
siteIcons.value[site] = (await api.get(`site/icon/${site}`)).data.icon
siteIcons.value[site] = await getCachedSiteIcon(site, async () => {
try {
const response = await api.get(`site/icon/${site}`)
return response?.data?.icon || ''
} catch (error) {
console.error(error)
return ''
}
})
} catch (error) {
console.error(error)
siteIcons.value[site] = ''
}
}
@@ -109,20 +120,27 @@ async function openMoreTorrentsDialog() {
showMoreTorrents.value = true
}
// 装载时查询站点图标
onMounted(() => {
getSiteIcon(props.torrent?.torrent_info?.site)
})
watch(
() => props.torrent,
value => {
torrent.value = value?.torrent_info
media.value = value?.media_info
meta.value = value?.meta_info
downloadItem.value = value
getSiteIcon(value?.torrent_info?.site)
},
{ immediate: true },
)
</script>
<template>
<div class="h-full">
<VCard
:width="props.width || '100%'"
:variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'flat'"
:variant="isDownloaded ? 'outlined' : 'flat'"
@click="handleAddDownload(props.torrent)"
class="h-full cursor-pointer transition-transform hover:-translate-y-1 duration-300 d-flex flex-column overflow-hidden torrent-card"
:class="{ 'border-success border-2 opacity-85': downloaded.includes(torrent?.enclosure || '') }"
:class="{ 'border-success border-2 opacity-85': isDownloaded }"
hover
>
<!-- 优惠标签 -->

View File

@@ -4,6 +4,8 @@ import { formatFileSize, formatDateDifference } from '@/@core/utils/formatters'
import api from '@/api'
import type { Context } from '@/api/types'
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
import { getCachedSiteIcon } from '@/utils/siteIconCache'
import { downloadedTorrentMap, markTorrentDownloaded } from '@/utils/torrentDownloadCache'
// 输入参数
const props = defineProps({
@@ -22,37 +24,31 @@ const meta = ref(props.torrent?.meta_info)
// 站点图标
const siteIcon = ref('')
// 站点图标加载状态
const iconLoading = ref(false)
const iconError = ref(false)
// 存储是否已经下载过的记录
const downloaded = ref<string[]>([])
const isDownloaded = computed(() => Boolean(torrent.value?.enclosure && downloadedTorrentMap[torrent.value.enclosure]))
// 添加下载对话框
const addDownloadDialog = ref(false)
// 查询站点图标
async function getSiteIcon() {
if (!torrent?.value?.site || iconLoading.value) {
if (!torrent?.value?.site) {
return
}
iconLoading.value = true
iconError.value = false
try {
const response = await api.get(`site/icon/${torrent.value.site}`)
if (response && response.data && response.data.icon) {
siteIcon.value = response.data.icon
} else {
iconError.value = true
}
siteIcon.value = await getCachedSiteIcon(torrent.value.site, async () => {
try {
const response = await api.get(`site/icon/${torrent.value?.site}`)
return response?.data?.icon || ''
} catch (error) {
console.error('Failed to load site icon:', error)
return ''
}
})
} catch (error) {
console.error('Failed to load site icon:', error)
iconError.value = true
} finally {
iconLoading.value = false
siteIcon.value = ''
}
}
@@ -83,8 +79,7 @@ async function handleAddDownload() {
// 添加下载成功
function addDownloadSuccess(url: string) {
addDownloadDialog.value = false
// 添加下载成功
downloaded.value.push(url)
markTorrentDownloaded(url)
}
// 添加下载失败
@@ -97,10 +92,16 @@ function openTorrentDetail() {
window.open(torrent.value?.page_url, '_blank')
}
// 装载时查询站点图标
onMounted(() => {
getSiteIcon()
})
watch(
() => props.torrent,
value => {
torrent.value = value?.torrent_info
media.value = value?.media_info
meta.value = value?.meta_info
getSiteIcon()
},
{ immediate: true },
)
</script>
<template>
@@ -108,7 +109,7 @@ onMounted(() => {
<VListItem
:value="props.torrent?.torrent_info?.enclosure"
class="pa-3 mb-2 rounded torrent-item transition-all duration-300 hover:-translate-y-1 overflow-hidden"
:class="{ 'border-start border-success border-3 opacity-85': downloaded.includes(torrent?.enclosure || '') }"
:class="{ 'border-start border-success border-3 opacity-85': isDownloaded }"
@click="handleAddDownload"
>
<!-- 优惠标签 -->

View File

@@ -76,12 +76,12 @@ async function loadHistory({ done }: { done: any }) {
// 返回加载成功
done('ok')
}
// 取消加载中
loading.value = false
} catch (e) {
console.error(e)
// 返回加载失败
done('error')
} finally {
loading.value = false
}
}
@@ -153,65 +153,67 @@ function getMediaTypeText(type: string | undefined) {
</VCardItem>
<VDivider />
<VDialogCloseBtn @click="emit('close')" />
<VList lines="two">
<VInfiniteScroll mode="intersect" side="end" :items="historyList" class="overflow-visible" @load="loadHistory">
<VList lines="two" class="flex-grow-1 min-h-0 py-0">
<VInfiniteScroll mode="intersect" side="end" :items="historyList" class="h-100" @load="loadHistory">
<template #loading>
<LoadingBanner />
</template>
<template #empty />
<template v-if="historyList.length > 0">
<template v-for="(item, i) in historyList" :key="i">
<VListItem>
<template #prepend>
<VImg
height="75"
width="50"
:src="item.poster"
aspect-ratio="2/3"
class="object-cover rounded ring-gray-500 me-3"
cover
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
</VImg>
</template>
<VListItemTitle v-if="item.type == '电视剧'">
{{ item.name }}
<span class="text-sm">{{ t('dialog.subscribeHistory.season', { season: item.season }) }}</span>
</VListItemTitle>
<VListItemTitle v-else>
{{ item.name }}
</VListItemTitle>
<VListItemSubtitle class="mt-2">{{ formatDateDifference(item.date) }}</VListItemSubtitle>
<VListItemSubtitle class="mt-2">{{ item.description }}</VListItemSubtitle>
<template #append>
<div class="me-n3">
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem
v-for="(menu, i) in dropdownItems"
:key="i"
:base-color="menu.color"
@click="menu.props.click(item)"
>
<template #prepend>
<VIcon :icon="menu.props.prependIcon" />
</template>
<VListItemTitle v-text="menu.title" />
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
</template>
</VListItem>
<VVirtualScroll v-if="historyList.length > 0" renderless :items="historyList" :item-height="104">
<template #default="{ item, itemRef }">
<div :ref="itemRef">
<VListItem>
<template #prepend>
<VImg
height="75"
width="50"
:src="item.poster"
aspect-ratio="2/3"
class="object-cover rounded ring-gray-500 me-3"
cover
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
</div>
</template>
</VImg>
</template>
<VListItemTitle v-if="item.type == '电视剧'">
{{ item.name }}
<span class="text-sm">{{ t('dialog.subscribeHistory.season', { season: item.season }) }}</span>
</VListItemTitle>
<VListItemTitle v-else>
{{ item.name }}
</VListItemTitle>
<VListItemSubtitle class="mt-2">{{ formatDateDifference(item.date) }}</VListItemSubtitle>
<VListItemSubtitle class="mt-2">{{ item.description }}</VListItemSubtitle>
<template #append>
<div class="me-n3">
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem
v-for="(menu, i) in dropdownItems"
:key="i"
:base-color="menu.color"
@click="menu.props.click(item)"
>
<template #prepend>
<VIcon :icon="menu.props.prependIcon" />
</template>
<VListItemTitle v-text="menu.title" />
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
</template>
</VListItem>
</div>
</template>
</template>
</VVirtualScroll>
</VInfiniteScroll>
</VList>
<VCardText v-if="historyList.length === 0 && isRefreshed" class="text-center">{{

View File

@@ -5,11 +5,11 @@ import api from '@/api'
import type { Context } from '@/api/types'
import TorrentCard from '@/components/cards/TorrentCard.vue'
import TorrentItem from '@/components/cards/TorrentItem.vue'
import VirtualCardGrid from '@/components/misc/VirtualCardGrid.vue'
import TorrentFilterBar from '@/components/filter/TorrentFilterBar.vue'
import { useI18n } from 'vue-i18n'
import { useGlobalSettingsStore } from '@/stores/global'
import { useTorrentFilter, type FilterState } from '@/composables/useTorrentFilter'
import { useInfiniteScroll } from '@/composables/useInfiniteScroll'
import { useToast } from 'vue-toastification'
// 国际化
@@ -86,12 +86,6 @@ interface SearchTorrent extends Context {
}
const filteredCardDataList = ref<Array<SearchTorrent>>([])
// 使用无限滚动 composable行视图
const rowScroll = useInfiniteScroll(filteredRowDataList)
// 使用无限滚动 composable卡片视图
const cardScroll = useInfiniteScroll(filteredCardDataList)
// 是否刷新过
const isRefreshed = ref(false)
@@ -1057,29 +1051,19 @@ onUnmounted(() => {
class="stream-result-item"
/>
</div>
<!-- 资源列表 -->
<VInfiniteScroll
v-else
mode="intersect"
side="end"
:items="cardScroll.displayDataList.value"
class="overflow-visible"
@load="cardScroll.loadMore"
<VirtualCardGrid
v-else-if="filteredCardDataList.length > 0"
:items="filteredCardDataList"
:get-item-key="getTorrentItemKey"
:min-item-width="300"
:estimated-item-height="400"
>
<template #loading />
<template #empty />
<div class="grid gap-4 grid-torrent-card items-start">
<TorrentCard
v-for="(item, index) in cardScroll.displayDataList.value"
:key="getTorrentItemKey(item, index)"
:torrent="item"
:more="item.more"
class="stream-result-item"
/>
</div>
</VInfiniteScroll>
<template #default="{ item }">
<TorrentCard :torrent="item" :more="item.more" />
</template>
</VirtualCardGrid>
<!-- 无结果时显示 -->
<div v-if="!progressActive && cardScroll.displayDataList.value.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" />
<div class="text-h6 text-grey mt-4">{{ t('torrent.noResults') }}</div>
</div>
@@ -1089,7 +1073,7 @@ onUnmounted(() => {
<div v-else-if="viewType === 'row'" key="row">
<VCard class="resource-list-container">
<!-- 无结果时显示 -->
<div v-if="!progressActive && rowScroll.displayDataList.value.length === 0" class="no-results">
<div v-if="!progressActive && filteredRowDataList.length === 0" class="no-results">
<VIcon icon="mdi-file-search-outline" size="64" color="grey-lighten-1" />
<div class="text-h6 text-grey mt-4">{{ t('torrent.noResults') }}</div>
</div>
@@ -1103,26 +1087,16 @@ onUnmounted(() => {
<VDivider v-if="index < streamPreviewDataList.length - 1" class="my-2" />
</div>
</div>
<!-- 资源列表 -->
<VInfiniteScroll
v-else
mode="intersect"
side="end"
:items="rowScroll.displayDataList.value"
class="resource-list overflow-visible"
@load="rowScroll.loadMore"
>
<template #loading />
<template #empty />
<div
v-for="(item, index) in rowScroll.displayDataList.value"
:key="getTorrentItemKey(item, index)"
class="stream-result-item"
>
<TorrentItem :torrent="item" />
<VDivider v-if="index < rowScroll.displayDataList.value.length - 1" class="my-2" />
</div>
</VInfiniteScroll>
<div v-else-if="filteredRowDataList.length > 0" class="resource-list">
<VVirtualScroll renderless :items="filteredRowDataList" :item-height="240">
<template #default="{ item, index, itemRef }">
<div :ref="itemRef" :key="getTorrentItemKey(item, index)">
<TorrentItem :torrent="item" />
<VDivider v-if="index < filteredRowDataList.length - 1" class="my-2" />
</div>
</template>
</VVirtualScroll>
</div>
</VCard>
</div>
</VFadeTransition>
@@ -1422,9 +1396,7 @@ onUnmounted(() => {
}
.resource-list {
display: flex;
flex-direction: column;
gap: 8px;
display: block;
}
/* 无结果提示 */

View File

@@ -0,0 +1,13 @@
import { reactive } from 'vue'
const downloadedTorrentMap = reactive<Record<string, boolean>>({})
export function markTorrentDownloaded(url?: string | null) {
if (!url) {
return
}
downloadedTorrentMap[url] = true
}
export { downloadedTorrentMap }

View File

@@ -4,6 +4,7 @@ import api from '@/api'
import type { DownloadingInfo } from '@/api/types'
import NoDataFound from '@/components/NoDataFound.vue'
import DownloadingCard from '@/components/cards/DownloadingCard.vue'
import VirtualCardGrid from '@/components/misc/VirtualCardGrid.vue'
import { useUserStore } from '@/stores'
import { useI18n } from 'vue-i18n'
import { useBackgroundOptimization } from '@/composables/useBackgroundOptimization'
@@ -67,14 +68,17 @@ const { loading: dataLoading } = useDataRefresh(
<template>
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
<VPullToRefresh v-model="loading" @load="onRefresh" :pull-down-threshold="64">
<div v-if="filteredDataList.length > 0" class="grid gap-4 grid-downloading-card">
<DownloadingCard
v-for="data in filteredDataList"
:key="data.hash"
:info="data"
:downloader-name="props.name"
/>
</div>
<VirtualCardGrid
v-if="filteredDataList.length > 0"
:items="filteredDataList"
:get-item-key="item => item.hash || item.name"
:min-item-width="320"
:estimated-item-height="230"
>
<template #default="{ item }">
<DownloadingCard :info="item" :downloader-name="props.name" />
</template>
</VirtualCardGrid>
<NoDataFound
v-if="filteredDataList.length === 0 && isRefreshed"
error-code="404"

View File

@@ -3,6 +3,7 @@ import api from '@/api'
import type { MediaInfo } from '@/api/types'
import MediaCard from '@/components/cards/MediaCard.vue'
import NoDataFound from '@/components/NoDataFound.vue'
import VirtualCardGrid from '@/components/misc/VirtualCardGrid.vue'
import { useI18n } from 'vue-i18n'
// 国际化
@@ -274,15 +275,24 @@ async function fetchData({ done }: { done: any }) {
>
<template #loading />
<template #empty />
<div v-if="dataList.length > 0" class="grid gap-4 grid-media-card" tabindex="0">
<div v-for="data in dataList" :key="data.tmdb_id || data.douban_id">
<MediaCard :media="data" />
<div v-if="data.popularity" class="mt-2 flex flex-row justify-center align-center text-subtitle-2">
<VIcon icon="mdi-fire" color="error" />
<span> {{ data.popularity.toLocaleString() }}</span>
<VirtualCardGrid
v-if="dataList.length > 0"
:items="dataList"
:get-item-key="item => item.tmdb_id || item.douban_id || item.bangumi_id || item.media_id || item.title"
:min-item-width="144"
:estimated-item-height="320"
tabindex="0"
>
<template #default="{ item }">
<div>
<MediaCard :media="item" />
<div v-if="item.popularity" class="mt-2 flex flex-row justify-center align-center text-subtitle-2">
<VIcon icon="mdi-fire" color="error" />
<span> {{ item.popularity.toLocaleString() }}</span>
</div>
</div>
</div>
</div>
</template>
</VirtualCardGrid>
<NoDataFound
v-if="dataList.length === 0 && isRefreshed"
error-code="404"

View File

@@ -3,6 +3,7 @@ import api from '@/api'
import type { SubscribeShare } from '@/api/types'
import NoDataFound from '@/components/NoDataFound.vue'
import SubscribeShareCard from '@/components/cards/SubscribeShareCard.vue'
import VirtualCardGrid from '@/components/misc/VirtualCardGrid.vue'
import { useI18n } from 'vue-i18n'
// 国际化
@@ -294,11 +295,18 @@ function removeData(id: number) {
>
<template #loading />
<template #empty />
<div v-if="dataList.length > 0" class="grid gap-4 grid-subscribe-card" tabindex="0">
<div v-for="data in dataList" :key="data.id">
<SubscribeShareCard :media="data" @delete="removeData(data.id || 0)" />
</div>
</div>
<VirtualCardGrid
v-if="dataList.length > 0"
:items="dataList"
:get-item-key="item => item.id || `${item.tmdbid || item.doubanid || item.name}-${item.share_user}`"
:min-item-width="240"
:estimated-item-height="260"
tabindex="0"
>
<template #default="{ item }">
<SubscribeShareCard :media="item" @delete="removeData(item.id || 0)" />
</template>
</VirtualCardGrid>
<NoDataFound
v-if="dataList.length === 0 && isRefreshed"
error-code="404"

View File

@@ -149,12 +149,11 @@ async function loadMessages({ done }: { done: any }) {
// 没有新数据
done('empty')
}
// 取消加载中
loading.value = false
} catch (error) {
console.error('加载消息失败:', error)
loading.value = false
done('error')
} finally {
loading.value = false
}
}
@@ -256,17 +255,19 @@ onMounted(() => {
<LoadingBanner />
</template>
<template #empty> {{ t('message.noMoreData') }} </template>
<div>
<div
v-for="(msg, index) in messages"
:key="getMessageKey(msg) || index"
class="chat-group d-flex mt-5 mb-8"
:class="msg.action == 1 ? 'flex-row align-start' : 'flex-row-reverse align-end'"
>
<div class="d-inline-flex flex-column" :class="msg.action == 1 ? 'align-start' : 'align-end'">
<MessageCard :message="msg" @imageload="handleImageLoad" />
<VVirtualScroll renderless :items="messages" :item-height="160">
<template #default="{ item, index, itemRef }">
<div
:ref="itemRef"
:key="getMessageKey(item) || index"
class="chat-group d-flex mt-5 mb-8"
:class="item.action == 1 ? 'flex-row align-start' : 'flex-row-reverse align-end'"
>
<div class="d-inline-flex flex-column" :class="item.action == 1 ? 'align-start' : 'align-end'">
<MessageCard :message="item" @imageload="handleImageLoad" />
</div>
</div>
</div>
</div>
</template>
</VVirtualScroll>
</VInfiniteScroll>
</template>

View File

@@ -4,6 +4,7 @@ import { Workflow } from '@/api/types'
import WorkflowAddEditDialog from '@/components/dialog/WorkflowAddEditDialog.vue'
import WorkflowTaskCard from '@/components/cards/WorkflowTaskCard.vue'
import NoDataFound from '@/components/NoDataFound.vue'
import VirtualCardGrid from '@/components/misc/VirtualCardGrid.vue'
import { useI18n } from 'vue-i18n'
// 国际化
@@ -66,9 +67,18 @@ defineExpose({
<template>
<div>
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
<div v-if="workflowList.length > 0 && isRefreshed" class="grid gap-4 grid-workflow-card px-2">
<WorkflowTaskCard v-for="item in workflowList" :key="item.id" :workflow="item" :event-types="eventTypes" @refresh="fetchData" />
</div>
<VirtualCardGrid
v-if="workflowList.length > 0 && isRefreshed"
:items="workflowList"
:get-item-key="item => item.id"
:min-item-width="288"
:estimated-item-height="420"
class="px-2"
>
<template #default="{ item }">
<WorkflowTaskCard :workflow="item" :event-types="eventTypes" @refresh="fetchData" />
</template>
</VirtualCardGrid>
<NoDataFound
v-if="workflowList.length === 0 && isRefreshed"
error-code="404"

View File

@@ -3,6 +3,7 @@ import api from '@/api'
import type { WorkflowShare } from '@/api/types'
import NoDataFound from '@/components/NoDataFound.vue'
import WorkflowShareCard from '@/components/cards/WorkflowShareCard.vue'
import VirtualCardGrid from '@/components/misc/VirtualCardGrid.vue'
import { useI18n } from 'vue-i18n'
// 国际化
@@ -156,16 +157,23 @@ onActivated(() => {
<VInfiniteScroll mode="intersect" side="end" :items="dataList" class="overflow-visible px-2" @load="fetchData" :key="currentKey">
<template #loading />
<template #empty />
<div v-if="dataList.length > 0" class="grid gap-4 grid-workflow-share-card" tabindex="0">
<div v-for="data in dataList" :key="data.id">
<VirtualCardGrid
v-if="dataList.length > 0"
:items="dataList"
:get-item-key="item => item.id"
:min-item-width="288"
:estimated-item-height="220"
tabindex="0"
>
<template #default="{ item }">
<WorkflowShareCard
:workflow="data"
:workflow="item"
:event-types="eventTypes"
@delete="removeData(data.id || '')"
@delete="removeData(item.id || '')"
@update="emit('update')"
/>
</div>
</div>
</template>
</VirtualCardGrid>
<NoDataFound
v-if="dataList.length === 0 && isRefreshed"
error-code="404"