mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 23:56:42 +08:00
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:
@@ -5,6 +5,8 @@ import api from '@/api'
|
|||||||
import type { Context } from '@/api/types'
|
import type { Context } from '@/api/types'
|
||||||
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
|
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
|
||||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||||
|
import { getCachedSiteIcon } from '@/utils/siteIconCache'
|
||||||
|
import { downloadedTorrentMap, markTorrentDownloaded } from '@/utils/torrentDownloadCache'
|
||||||
|
|
||||||
// 输入参数
|
// 输入参数
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -32,8 +34,7 @@ const downloadItem = ref(props.torrent)
|
|||||||
// 站点图标
|
// 站点图标
|
||||||
const siteIcons = ref<Record<number, string>>({})
|
const siteIcons = ref<Record<number, string>>({})
|
||||||
|
|
||||||
// 存储是否已经下载过的记录
|
const isDownloaded = computed(() => Boolean(torrent.value?.enclosure && downloadedTorrentMap[torrent.value.enclosure]))
|
||||||
const downloaded = ref<string[]>([])
|
|
||||||
|
|
||||||
// 添加下载对话框
|
// 添加下载对话框
|
||||||
const addDownloadDialog = ref(false)
|
const addDownloadDialog = ref(false)
|
||||||
@@ -41,8 +42,7 @@ const addDownloadDialog = ref(false)
|
|||||||
// 添加下载成功
|
// 添加下载成功
|
||||||
function addDownloadSuccess(url: string) {
|
function addDownloadSuccess(url: string) {
|
||||||
addDownloadDialog.value = false
|
addDownloadDialog.value = false
|
||||||
// 添加下载成功
|
markTorrentDownloaded(url)
|
||||||
downloaded.value.push(url)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加下载失败
|
// 添加下载失败
|
||||||
@@ -53,10 +53,21 @@ function addDownloadError(error: string) {
|
|||||||
// 查询站点图标
|
// 查询站点图标
|
||||||
async function getSiteIcon(site: number | undefined) {
|
async function getSiteIcon(site: number | undefined) {
|
||||||
if (!site) return
|
if (!site) return
|
||||||
|
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
siteIcons.value[site] = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,20 +120,27 @@ async function openMoreTorrentsDialog() {
|
|||||||
showMoreTorrents.value = true
|
showMoreTorrents.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 装载时查询站点图标
|
watch(
|
||||||
onMounted(() => {
|
() => props.torrent,
|
||||||
getSiteIcon(props.torrent?.torrent_info?.site)
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="h-full">
|
<div class="h-full">
|
||||||
<VCard
|
<VCard
|
||||||
:width="props.width || '100%'"
|
:width="props.width || '100%'"
|
||||||
:variant="downloaded.includes(torrent?.enclosure || '') ? 'outlined' : 'flat'"
|
:variant="isDownloaded ? 'outlined' : 'flat'"
|
||||||
@click="handleAddDownload(props.torrent)"
|
@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="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
|
hover
|
||||||
>
|
>
|
||||||
<!-- 优惠标签 -->
|
<!-- 优惠标签 -->
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { formatFileSize, formatDateDifference } from '@/@core/utils/formatters'
|
|||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import type { Context } from '@/api/types'
|
import type { Context } from '@/api/types'
|
||||||
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
|
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
|
||||||
|
import { getCachedSiteIcon } from '@/utils/siteIconCache'
|
||||||
|
import { downloadedTorrentMap, markTorrentDownloaded } from '@/utils/torrentDownloadCache'
|
||||||
|
|
||||||
// 输入参数
|
// 输入参数
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -22,37 +24,31 @@ const meta = ref(props.torrent?.meta_info)
|
|||||||
// 站点图标
|
// 站点图标
|
||||||
const siteIcon = ref('')
|
const siteIcon = ref('')
|
||||||
|
|
||||||
// 站点图标加载状态
|
const isDownloaded = computed(() => Boolean(torrent.value?.enclosure && downloadedTorrentMap[torrent.value.enclosure]))
|
||||||
const iconLoading = ref(false)
|
|
||||||
const iconError = ref(false)
|
|
||||||
|
|
||||||
// 存储是否已经下载过的记录
|
|
||||||
const downloaded = ref<string[]>([])
|
|
||||||
|
|
||||||
// 添加下载对话框
|
// 添加下载对话框
|
||||||
const addDownloadDialog = ref(false)
|
const addDownloadDialog = ref(false)
|
||||||
|
|
||||||
// 查询站点图标
|
// 查询站点图标
|
||||||
async function getSiteIcon() {
|
async function getSiteIcon() {
|
||||||
if (!torrent?.value?.site || iconLoading.value) {
|
if (!torrent?.value?.site) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
iconLoading.value = true
|
|
||||||
iconError.value = false
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.get(`site/icon/${torrent.value.site}`)
|
siteIcon.value = await getCachedSiteIcon(torrent.value.site, async () => {
|
||||||
if (response && response.data && response.data.icon) {
|
try {
|
||||||
siteIcon.value = response.data.icon
|
const response = await api.get(`site/icon/${torrent.value?.site}`)
|
||||||
} else {
|
|
||||||
iconError.value = true
|
return response?.data?.icon || ''
|
||||||
}
|
} catch (error) {
|
||||||
|
console.error('Failed to load site icon:', error)
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load site icon:', error)
|
console.error('Failed to load site icon:', error)
|
||||||
iconError.value = true
|
siteIcon.value = ''
|
||||||
} finally {
|
|
||||||
iconLoading.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,8 +79,7 @@ async function handleAddDownload() {
|
|||||||
// 添加下载成功
|
// 添加下载成功
|
||||||
function addDownloadSuccess(url: string) {
|
function addDownloadSuccess(url: string) {
|
||||||
addDownloadDialog.value = false
|
addDownloadDialog.value = false
|
||||||
// 添加下载成功
|
markTorrentDownloaded(url)
|
||||||
downloaded.value.push(url)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加下载失败
|
// 添加下载失败
|
||||||
@@ -97,10 +92,16 @@ function openTorrentDetail() {
|
|||||||
window.open(torrent.value?.page_url, '_blank')
|
window.open(torrent.value?.page_url, '_blank')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 装载时查询站点图标
|
watch(
|
||||||
onMounted(() => {
|
() => props.torrent,
|
||||||
getSiteIcon()
|
value => {
|
||||||
})
|
torrent.value = value?.torrent_info
|
||||||
|
media.value = value?.media_info
|
||||||
|
meta.value = value?.meta_info
|
||||||
|
getSiteIcon()
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -108,7 +109,7 @@ onMounted(() => {
|
|||||||
<VListItem
|
<VListItem
|
||||||
:value="props.torrent?.torrent_info?.enclosure"
|
: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="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"
|
@click="handleAddDownload"
|
||||||
>
|
>
|
||||||
<!-- 优惠标签 -->
|
<!-- 优惠标签 -->
|
||||||
|
|||||||
@@ -76,12 +76,12 @@ async function loadHistory({ done }: { done: any }) {
|
|||||||
// 返回加载成功
|
// 返回加载成功
|
||||||
done('ok')
|
done('ok')
|
||||||
}
|
}
|
||||||
// 取消加载中
|
|
||||||
loading.value = false
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
// 返回加载失败
|
// 返回加载失败
|
||||||
done('error')
|
done('error')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,65 +153,67 @@ function getMediaTypeText(type: string | undefined) {
|
|||||||
</VCardItem>
|
</VCardItem>
|
||||||
<VDivider />
|
<VDivider />
|
||||||
<VDialogCloseBtn @click="emit('close')" />
|
<VDialogCloseBtn @click="emit('close')" />
|
||||||
<VList lines="two">
|
<VList lines="two" class="flex-grow-1 min-h-0 py-0">
|
||||||
<VInfiniteScroll mode="intersect" side="end" :items="historyList" class="overflow-visible" @load="loadHistory">
|
<VInfiniteScroll mode="intersect" side="end" :items="historyList" class="h-100" @load="loadHistory">
|
||||||
<template #loading>
|
<template #loading>
|
||||||
<LoadingBanner />
|
<LoadingBanner />
|
||||||
</template>
|
</template>
|
||||||
<template #empty />
|
<template #empty />
|
||||||
<template v-if="historyList.length > 0">
|
<VVirtualScroll v-if="historyList.length > 0" renderless :items="historyList" :item-height="104">
|
||||||
<template v-for="(item, i) in historyList" :key="i">
|
<template #default="{ item, itemRef }">
|
||||||
<VListItem>
|
<div :ref="itemRef">
|
||||||
<template #prepend>
|
<VListItem>
|
||||||
<VImg
|
<template #prepend>
|
||||||
height="75"
|
<VImg
|
||||||
width="50"
|
height="75"
|
||||||
:src="item.poster"
|
width="50"
|
||||||
aspect-ratio="2/3"
|
:src="item.poster"
|
||||||
class="object-cover rounded ring-gray-500 me-3"
|
aspect-ratio="2/3"
|
||||||
cover
|
class="object-cover rounded ring-gray-500 me-3"
|
||||||
>
|
cover
|
||||||
<template #placeholder>
|
>
|
||||||
<div class="w-full h-full">
|
<template #placeholder>
|
||||||
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
<div class="w-full h-full">
|
||||||
</div>
|
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
|
||||||
</template>
|
</div>
|
||||||
</VImg>
|
</template>
|
||||||
</template>
|
</VImg>
|
||||||
<VListItemTitle v-if="item.type == '电视剧'">
|
</template>
|
||||||
{{ item.name }}
|
<VListItemTitle v-if="item.type == '电视剧'">
|
||||||
<span class="text-sm">{{ t('dialog.subscribeHistory.season', { season: item.season }) }}</span>
|
{{ item.name }}
|
||||||
</VListItemTitle>
|
<span class="text-sm">{{ t('dialog.subscribeHistory.season', { season: item.season }) }}</span>
|
||||||
<VListItemTitle v-else>
|
</VListItemTitle>
|
||||||
{{ item.name }}
|
<VListItemTitle v-else>
|
||||||
</VListItemTitle>
|
{{ item.name }}
|
||||||
<VListItemSubtitle class="mt-2">{{ formatDateDifference(item.date) }}</VListItemSubtitle>
|
</VListItemTitle>
|
||||||
<VListItemSubtitle class="mt-2">{{ item.description }}</VListItemSubtitle>
|
<VListItemSubtitle class="mt-2">{{ formatDateDifference(item.date) }}</VListItemSubtitle>
|
||||||
<template #append>
|
<VListItemSubtitle class="mt-2">{{ item.description }}</VListItemSubtitle>
|
||||||
<div class="me-n3">
|
<template #append>
|
||||||
<IconBtn>
|
<div class="me-n3">
|
||||||
<VIcon icon="mdi-dots-vertical" />
|
<IconBtn>
|
||||||
<VMenu activator="parent" close-on-content-click>
|
<VIcon icon="mdi-dots-vertical" />
|
||||||
<VList>
|
<VMenu activator="parent" close-on-content-click>
|
||||||
<VListItem
|
<VList>
|
||||||
v-for="(menu, i) in dropdownItems"
|
<VListItem
|
||||||
:key="i"
|
v-for="(menu, i) in dropdownItems"
|
||||||
:base-color="menu.color"
|
:key="i"
|
||||||
@click="menu.props.click(item)"
|
:base-color="menu.color"
|
||||||
>
|
@click="menu.props.click(item)"
|
||||||
<template #prepend>
|
>
|
||||||
<VIcon :icon="menu.props.prependIcon" />
|
<template #prepend>
|
||||||
</template>
|
<VIcon :icon="menu.props.prependIcon" />
|
||||||
<VListItemTitle v-text="menu.title" />
|
</template>
|
||||||
</VListItem>
|
<VListItemTitle v-text="menu.title" />
|
||||||
</VList>
|
</VListItem>
|
||||||
</VMenu>
|
</VList>
|
||||||
</IconBtn>
|
</VMenu>
|
||||||
</div>
|
</IconBtn>
|
||||||
</template>
|
</div>
|
||||||
</VListItem>
|
</template>
|
||||||
|
</VListItem>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</VVirtualScroll>
|
||||||
</VInfiniteScroll>
|
</VInfiniteScroll>
|
||||||
</VList>
|
</VList>
|
||||||
<VCardText v-if="historyList.length === 0 && isRefreshed" class="text-center">{{
|
<VCardText v-if="historyList.length === 0 && isRefreshed" class="text-center">{{
|
||||||
|
|||||||
+24
-52
@@ -5,11 +5,11 @@ 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 VirtualCardGrid from '@/components/misc/VirtualCardGrid.vue'
|
||||||
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 { useInfiniteScroll } from '@/composables/useInfiniteScroll'
|
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
@@ -86,12 +86,6 @@ interface SearchTorrent extends Context {
|
|||||||
}
|
}
|
||||||
const filteredCardDataList = ref<Array<SearchTorrent>>([])
|
const filteredCardDataList = ref<Array<SearchTorrent>>([])
|
||||||
|
|
||||||
// 使用无限滚动 composable(行视图)
|
|
||||||
const rowScroll = useInfiniteScroll(filteredRowDataList)
|
|
||||||
|
|
||||||
// 使用无限滚动 composable(卡片视图)
|
|
||||||
const cardScroll = useInfiniteScroll(filteredCardDataList)
|
|
||||||
|
|
||||||
// 是否刷新过
|
// 是否刷新过
|
||||||
const isRefreshed = ref(false)
|
const isRefreshed = ref(false)
|
||||||
|
|
||||||
@@ -1057,29 +1051,19 @@ onUnmounted(() => {
|
|||||||
class="stream-result-item"
|
class="stream-result-item"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<!-- 资源列表 -->
|
<VirtualCardGrid
|
||||||
<VInfiniteScroll
|
v-else-if="filteredCardDataList.length > 0"
|
||||||
v-else
|
:items="filteredCardDataList"
|
||||||
mode="intersect"
|
:get-item-key="getTorrentItemKey"
|
||||||
side="end"
|
:min-item-width="300"
|
||||||
:items="cardScroll.displayDataList.value"
|
:estimated-item-height="400"
|
||||||
class="overflow-visible"
|
|
||||||
@load="cardScroll.loadMore"
|
|
||||||
>
|
>
|
||||||
<template #loading />
|
<template #default="{ item }">
|
||||||
<template #empty />
|
<TorrentCard :torrent="item" :more="item.more" />
|
||||||
<div class="grid gap-4 grid-torrent-card items-start">
|
</template>
|
||||||
<TorrentCard
|
</VirtualCardGrid>
|
||||||
v-for="(item, index) in cardScroll.displayDataList.value"
|
|
||||||
:key="getTorrentItemKey(item, index)"
|
|
||||||
:torrent="item"
|
|
||||||
:more="item.more"
|
|
||||||
class="stream-result-item"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</VInfiniteScroll>
|
|
||||||
<!-- 无结果时显示 -->
|
<!-- 无结果时显示 -->
|
||||||
<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" />
|
<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 class="text-h6 text-grey mt-4">{{ t('torrent.noResults') }}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1089,7 +1073,7 @@ onUnmounted(() => {
|
|||||||
<div v-else-if="viewType === 'row'" key="row">
|
<div v-else-if="viewType === 'row'" key="row">
|
||||||
<VCard class="resource-list-container">
|
<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" />
|
<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 class="text-h6 text-grey mt-4">{{ t('torrent.noResults') }}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1103,26 +1087,16 @@ onUnmounted(() => {
|
|||||||
<VDivider v-if="index < streamPreviewDataList.length - 1" class="my-2" />
|
<VDivider v-if="index < streamPreviewDataList.length - 1" class="my-2" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 资源列表 -->
|
<div v-else-if="filteredRowDataList.length > 0" class="resource-list">
|
||||||
<VInfiniteScroll
|
<VVirtualScroll renderless :items="filteredRowDataList" :item-height="240">
|
||||||
v-else
|
<template #default="{ item, index, itemRef }">
|
||||||
mode="intersect"
|
<div :ref="itemRef" :key="getTorrentItemKey(item, index)">
|
||||||
side="end"
|
<TorrentItem :torrent="item" />
|
||||||
:items="rowScroll.displayDataList.value"
|
<VDivider v-if="index < filteredRowDataList.length - 1" class="my-2" />
|
||||||
class="resource-list overflow-visible"
|
</div>
|
||||||
@load="rowScroll.loadMore"
|
</template>
|
||||||
>
|
</VVirtualScroll>
|
||||||
<template #loading />
|
</div>
|
||||||
<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>
|
|
||||||
</VCard>
|
</VCard>
|
||||||
</div>
|
</div>
|
||||||
</VFadeTransition>
|
</VFadeTransition>
|
||||||
@@ -1422,9 +1396,7 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.resource-list {
|
.resource-list {
|
||||||
display: flex;
|
display: block;
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 无结果提示 */
|
/* 无结果提示 */
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -4,6 +4,7 @@ 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 VirtualCardGrid from '@/components/misc/VirtualCardGrid.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'
|
||||||
@@ -67,14 +68,17 @@ const { loading: dataLoading } = useDataRefresh(
|
|||||||
<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">
|
||||||
<div v-if="filteredDataList.length > 0" class="grid gap-4 grid-downloading-card">
|
<VirtualCardGrid
|
||||||
<DownloadingCard
|
v-if="filteredDataList.length > 0"
|
||||||
v-for="data in filteredDataList"
|
:items="filteredDataList"
|
||||||
:key="data.hash"
|
:get-item-key="item => item.hash || item.name"
|
||||||
:info="data"
|
:min-item-width="320"
|
||||||
:downloader-name="props.name"
|
:estimated-item-height="230"
|
||||||
/>
|
>
|
||||||
</div>
|
<template #default="{ item }">
|
||||||
|
<DownloadingCard :info="item" :downloader-name="props.name" />
|
||||||
|
</template>
|
||||||
|
</VirtualCardGrid>
|
||||||
<NoDataFound
|
<NoDataFound
|
||||||
v-if="filteredDataList.length === 0 && isRefreshed"
|
v-if="filteredDataList.length === 0 && isRefreshed"
|
||||||
error-code="404"
|
error-code="404"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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 VirtualCardGrid from '@/components/misc/VirtualCardGrid.vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
@@ -274,15 +275,24 @@ async function fetchData({ done }: { done: any }) {
|
|||||||
>
|
>
|
||||||
<template #loading />
|
<template #loading />
|
||||||
<template #empty />
|
<template #empty />
|
||||||
<div v-if="dataList.length > 0" class="grid gap-4 grid-media-card" tabindex="0">
|
<VirtualCardGrid
|
||||||
<div v-for="data in dataList" :key="data.tmdb_id || data.douban_id">
|
v-if="dataList.length > 0"
|
||||||
<MediaCard :media="data" />
|
:items="dataList"
|
||||||
<div v-if="data.popularity" class="mt-2 flex flex-row justify-center align-center text-subtitle-2">
|
:get-item-key="item => item.tmdb_id || item.douban_id || item.bangumi_id || item.media_id || item.title"
|
||||||
<VIcon icon="mdi-fire" color="error" />
|
:min-item-width="144"
|
||||||
<span> {{ data.popularity.toLocaleString() }}</span>
|
: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>
|
||||||
</div>
|
</VirtualCardGrid>
|
||||||
<NoDataFound
|
<NoDataFound
|
||||||
v-if="dataList.length === 0 && isRefreshed"
|
v-if="dataList.length === 0 && isRefreshed"
|
||||||
error-code="404"
|
error-code="404"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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 VirtualCardGrid from '@/components/misc/VirtualCardGrid.vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
@@ -294,11 +295,18 @@ function removeData(id: number) {
|
|||||||
>
|
>
|
||||||
<template #loading />
|
<template #loading />
|
||||||
<template #empty />
|
<template #empty />
|
||||||
<div v-if="dataList.length > 0" class="grid gap-4 grid-subscribe-card" tabindex="0">
|
<VirtualCardGrid
|
||||||
<div v-for="data in dataList" :key="data.id">
|
v-if="dataList.length > 0"
|
||||||
<SubscribeShareCard :media="data" @delete="removeData(data.id || 0)" />
|
:items="dataList"
|
||||||
</div>
|
:get-item-key="item => item.id || `${item.tmdbid || item.doubanid || item.name}-${item.share_user}`"
|
||||||
</div>
|
:min-item-width="240"
|
||||||
|
:estimated-item-height="260"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<template #default="{ item }">
|
||||||
|
<SubscribeShareCard :media="item" @delete="removeData(item.id || 0)" />
|
||||||
|
</template>
|
||||||
|
</VirtualCardGrid>
|
||||||
<NoDataFound
|
<NoDataFound
|
||||||
v-if="dataList.length === 0 && isRefreshed"
|
v-if="dataList.length === 0 && isRefreshed"
|
||||||
error-code="404"
|
error-code="404"
|
||||||
|
|||||||
@@ -149,12 +149,11 @@ async function loadMessages({ done }: { done: any }) {
|
|||||||
// 没有新数据
|
// 没有新数据
|
||||||
done('empty')
|
done('empty')
|
||||||
}
|
}
|
||||||
// 取消加载中
|
|
||||||
loading.value = false
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载消息失败:', error)
|
console.error('加载消息失败:', error)
|
||||||
loading.value = false
|
|
||||||
done('error')
|
done('error')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,17 +255,19 @@ onMounted(() => {
|
|||||||
<LoadingBanner />
|
<LoadingBanner />
|
||||||
</template>
|
</template>
|
||||||
<template #empty> {{ t('message.noMoreData') }} </template>
|
<template #empty> {{ t('message.noMoreData') }} </template>
|
||||||
<div>
|
<VVirtualScroll renderless :items="messages" :item-height="160">
|
||||||
<div
|
<template #default="{ item, index, itemRef }">
|
||||||
v-for="(msg, index) in messages"
|
<div
|
||||||
:key="getMessageKey(msg) || index"
|
:ref="itemRef"
|
||||||
class="chat-group d-flex mt-5 mb-8"
|
:key="getMessageKey(item) || index"
|
||||||
:class="msg.action == 1 ? 'flex-row align-start' : 'flex-row-reverse align-end'"
|
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="msg.action == 1 ? 'align-start' : 'align-end'">
|
>
|
||||||
<MessageCard :message="msg" @imageload="handleImageLoad" />
|
<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>
|
||||||
</div>
|
</VVirtualScroll>
|
||||||
</VInfiniteScroll>
|
</VInfiniteScroll>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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 VirtualCardGrid from '@/components/misc/VirtualCardGrid.vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
@@ -66,9 +67,18 @@ defineExpose({
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
||||||
<div v-if="workflowList.length > 0 && isRefreshed" class="grid gap-4 grid-workflow-card px-2">
|
<VirtualCardGrid
|
||||||
<WorkflowTaskCard v-for="item in workflowList" :key="item.id" :workflow="item" :event-types="eventTypes" @refresh="fetchData" />
|
v-if="workflowList.length > 0 && isRefreshed"
|
||||||
</div>
|
: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
|
<NoDataFound
|
||||||
v-if="workflowList.length === 0 && isRefreshed"
|
v-if="workflowList.length === 0 && isRefreshed"
|
||||||
error-code="404"
|
error-code="404"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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 VirtualCardGrid from '@/components/misc/VirtualCardGrid.vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
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">
|
<VInfiniteScroll mode="intersect" side="end" :items="dataList" class="overflow-visible px-2" @load="fetchData" :key="currentKey">
|
||||||
<template #loading />
|
<template #loading />
|
||||||
<template #empty />
|
<template #empty />
|
||||||
<div v-if="dataList.length > 0" class="grid gap-4 grid-workflow-share-card" tabindex="0">
|
<VirtualCardGrid
|
||||||
<div v-for="data in dataList" :key="data.id">
|
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
|
<WorkflowShareCard
|
||||||
:workflow="data"
|
:workflow="item"
|
||||||
:event-types="eventTypes"
|
:event-types="eventTypes"
|
||||||
@delete="removeData(data.id || '')"
|
@delete="removeData(item.id || '')"
|
||||||
@update="emit('update')"
|
@update="emit('update')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</VirtualCardGrid>
|
||||||
<NoDataFound
|
<NoDataFound
|
||||||
v-if="dataList.length === 0 && isRefreshed"
|
v-if="dataList.length === 0 && isRefreshed"
|
||||||
error-code="404"
|
error-code="404"
|
||||||
|
|||||||
Reference in New Issue
Block a user