mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-07 00:36:41 +08:00
perf: virtualize management lists and make drag sorting opt-in
This commit is contained in:
@@ -25,6 +25,10 @@ const props = defineProps({
|
|||||||
action: Boolean, // 动作标识
|
action: Boolean, // 动作标识
|
||||||
width: String,
|
width: String,
|
||||||
height: String,
|
height: String,
|
||||||
|
sortable: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// 定义触发的自定义事件
|
// 定义触发的自定义事件
|
||||||
@@ -420,6 +424,7 @@ watch(
|
|||||||
(newOpenState, _) => {
|
(newOpenState, _) => {
|
||||||
if (newOpenState) openPluginDetail()
|
if (newOpenState) openPluginDetail()
|
||||||
},
|
},
|
||||||
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -458,7 +463,7 @@ watch(
|
|||||||
{{ props.plugin?.plugin_desc }}
|
{{ props.plugin?.plugin_desc }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative flex-shrink-0 self-center pb-3" :class="{ 'cursor-move': display.mdAndUp.value }">
|
<div class="relative flex-shrink-0 self-center pb-3" :class="{ 'cursor-move': props.sortable && display.mdAndUp.value }">
|
||||||
<VAvatar size="48">
|
<VAvatar size="48">
|
||||||
<VImg
|
<VImg
|
||||||
ref="imageRef"
|
ref="imageRef"
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
width: String,
|
width: String,
|
||||||
height: String,
|
height: String,
|
||||||
|
sortable: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// 定义触发的自定义事件
|
// 定义触发的自定义事件
|
||||||
@@ -302,14 +306,14 @@ const dropdownItems = ref([
|
|||||||
:icon="folderIcon"
|
:icon="folderIcon"
|
||||||
:size="display.mobile ? 56 : 72"
|
:size="display.mobile ? 56 : 72"
|
||||||
:color="iconColor"
|
:color="iconColor"
|
||||||
:class="{ 'cursor-move': display.mdAndUp.value }"
|
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value }"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 文件夹信息 -->
|
<!-- 文件夹信息 -->
|
||||||
<div
|
<div
|
||||||
class="plugin-folder-card__info"
|
class="plugin-folder-card__info"
|
||||||
:class="{ 'cursor-move': display.mdAndUp.value, 'plugin-folder-card__info--no-icon': !shouldShowIcon }"
|
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value, 'plugin-folder-card__info--no-icon': !shouldShowIcon }"
|
||||||
>
|
>
|
||||||
<!-- 文件夹名称 -->
|
<!-- 文件夹名称 -->
|
||||||
<h3 class="plugin-folder-card__name">
|
<h3 class="plugin-folder-card__name">
|
||||||
|
|||||||
@@ -14,12 +14,14 @@ interface Props {
|
|||||||
pluginStatistics?: { [key: string]: number }
|
pluginStatistics?: { [key: string]: number }
|
||||||
pluginActions?: { [key: string]: boolean }
|
pluginActions?: { [key: string]: boolean }
|
||||||
showRemoveButton?: boolean
|
showRemoveButton?: boolean
|
||||||
|
sortable?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
pluginStatistics: () => ({}),
|
pluginStatistics: () => ({}),
|
||||||
pluginActions: () => ({}),
|
pluginActions: () => ({}),
|
||||||
showRemoveButton: false,
|
showRemoveButton: false,
|
||||||
|
sortable: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -36,7 +38,7 @@ const emit = defineEmits<{
|
|||||||
// 拖拽事件处理
|
// 拖拽事件处理
|
||||||
function handleDragOver(event: DragEvent) {
|
function handleDragOver(event: DragEvent) {
|
||||||
// 只有当拖拽的是插件时才允许放入文件夹
|
// 只有当拖拽的是插件时才允许放入文件夹
|
||||||
if (props.item.type === 'folder') {
|
if (props.sortable && props.item.type === 'folder') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
event.dataTransfer!.dropEffect = 'move'
|
event.dataTransfer!.dropEffect = 'move'
|
||||||
@@ -46,14 +48,14 @@ function handleDragOver(event: DragEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleDragEnter(event: DragEvent) {
|
function handleDragEnter(event: DragEvent) {
|
||||||
if (props.item.type === 'folder') {
|
if (props.sortable && props.item.type === 'folder') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDragLeave(event: DragEvent) {
|
function handleDragLeave(event: DragEvent) {
|
||||||
if (props.item.type === 'folder') {
|
if (props.sortable && props.item.type === 'folder') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
const target = event.currentTarget as HTMLElement
|
const target = event.currentTarget as HTMLElement
|
||||||
@@ -62,7 +64,7 @@ function handleDragLeave(event: DragEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleDropToFolder(event: DragEvent) {
|
function handleDropToFolder(event: DragEvent) {
|
||||||
if (props.item.type === 'folder') {
|
if (props.sortable && props.item.type === 'folder') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
const target = event.currentTarget as HTMLElement
|
const target = event.currentTarget as HTMLElement
|
||||||
@@ -89,6 +91,7 @@ function handleDropToFolder(event: DragEvent) {
|
|||||||
:folder-name="item.data.name"
|
:folder-name="item.data.name"
|
||||||
:plugin-count="item.data.pluginCount"
|
:plugin-count="item.data.pluginCount"
|
||||||
:folder-config="item.data.config"
|
:folder-config="item.data.config"
|
||||||
|
:sortable="sortable"
|
||||||
@open="$emit('openFolder', item.id)"
|
@open="$emit('openFolder', item.id)"
|
||||||
@delete="$emit('deleteFolder', item.id)"
|
@delete="$emit('deleteFolder', item.id)"
|
||||||
@rename="(oldName, newName) => $emit('renameFolder', oldName, newName)"
|
@rename="(oldName, newName) => $emit('renameFolder', oldName, newName)"
|
||||||
@@ -102,6 +105,7 @@ function handleDropToFolder(event: DragEvent) {
|
|||||||
:count="pluginStatistics[item.id] || 0"
|
:count="pluginStatistics[item.id] || 0"
|
||||||
:plugin="item.data"
|
:plugin="item.data"
|
||||||
:action="pluginActions[item.id] || false"
|
:action="pluginActions[item.id] || false"
|
||||||
|
:sortable="sortable"
|
||||||
@remove="$emit('refreshData')"
|
@remove="$emit('refreshData')"
|
||||||
@save="$emit('refreshData')"
|
@save="$emit('refreshData')"
|
||||||
@action-done="$emit('actionDone', item.id)"
|
@action-done="$emit('actionDone', item.id)"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type { Site, SiteStatistic, SiteUserData } from '@/api/types'
|
|||||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||||
import { formatFileSize } from '@/@core/utils/formatters'
|
import { formatFileSize } from '@/@core/utils/formatters'
|
||||||
import { useConfirm } from '@/composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
|
import { getCachedSiteIcon } from '@/utils/siteIconCache'
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
|
|
||||||
// 显示器宽度
|
// 显示器宽度
|
||||||
@@ -25,6 +26,10 @@ const cardProps = defineProps({
|
|||||||
site: Object as PropType<Site>,
|
site: Object as PropType<Site>,
|
||||||
data: Object as PropType<SiteUserData>,
|
data: Object as PropType<SiteUserData>,
|
||||||
stats: Object as PropType<SiteStatistic>,
|
stats: Object as PropType<SiteStatistic>,
|
||||||
|
sortable: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// 定义触发的自定义事件
|
// 定义触发的自定义事件
|
||||||
@@ -34,7 +39,8 @@ const emit = defineEmits(['update', 'remove', 'refresh-stats'])
|
|||||||
const createConfirm = useConfirm()
|
const createConfirm = useConfirm()
|
||||||
|
|
||||||
// 图标
|
// 图标
|
||||||
const siteIcon = ref<string>('')
|
const defaultSiteIcon = getLogoUrl('site')
|
||||||
|
const siteIcon = ref<string>(defaultSiteIcon)
|
||||||
|
|
||||||
// 提示框
|
// 提示框
|
||||||
const $toast = useToast()
|
const $toast = useToast()
|
||||||
@@ -59,12 +65,20 @@ const siteUserDataDialog = ref(false)
|
|||||||
|
|
||||||
// 查询站点图标
|
// 查询站点图标
|
||||||
async function getSiteIcon() {
|
async function getSiteIcon() {
|
||||||
|
const siteId = cardProps.site?.id
|
||||||
|
if (!siteId) {
|
||||||
|
siteIcon.value = defaultSiteIcon
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
siteIcon.value = (await api.get(`site/icon/${cardProps.site?.id}`)).data.icon
|
siteIcon.value = await getCachedSiteIcon(siteId, async () => {
|
||||||
if (!siteIcon.value) {
|
const response = await api.get(`site/icon/${siteId}`)
|
||||||
siteIcon.value = getLogoUrl('site')
|
|
||||||
}
|
return response?.data?.icon || defaultSiteIcon
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
siteIcon.value = defaultSiteIcon
|
||||||
console.error(error)
|
console.error(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,7 +239,7 @@ onMounted(() => {
|
|||||||
rounded="lg"
|
rounded="lg"
|
||||||
size="32"
|
size="32"
|
||||||
class="shrink-0"
|
class="shrink-0"
|
||||||
:class="{ 'cursor-move': display.mdAndUp.value }"
|
:class="{ 'cursor-move': cardProps.sortable && display.mdAndUp.value }"
|
||||||
>
|
>
|
||||||
<VImg :src="siteIcon" class="w-full h-full" :alt="cardProps.site?.name" cover>
|
<VImg :src="siteIcon" class="w-full h-full" :alt="cardProps.site?.name" cover>
|
||||||
<template #placeholder>
|
<template #placeholder>
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
sortable: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// 从 provide 中获取全局设置
|
// 从 provide 中获取全局设置
|
||||||
@@ -266,6 +270,7 @@ watch(
|
|||||||
(newOpenState, _) => {
|
(newOpenState, _) => {
|
||||||
if (newOpenState) editSubscribeDialog()
|
if (newOpenState) editSubscribeDialog()
|
||||||
},
|
},
|
||||||
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
// 监听订阅状态
|
// 监听订阅状态
|
||||||
@@ -380,7 +385,7 @@ function handleCardClick() {
|
|||||||
<div
|
<div
|
||||||
class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md"
|
class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md"
|
||||||
v-if="imageLoaded"
|
v-if="imageLoaded"
|
||||||
:class="{ 'cursor-move': display.mdAndUp.value }"
|
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value }"
|
||||||
>
|
>
|
||||||
<VImg :src="posterUrl" aspect-ratio="2/3" cover>
|
<VImg :src="posterUrl" aspect-ratio="2/3" cover>
|
||||||
<template #placeholder>
|
<template #placeholder>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ const props = withDefaults(
|
|||||||
items: any[]
|
items: any[]
|
||||||
minItemWidth?: number
|
minItemWidth?: number
|
||||||
itemAspectRatio?: number
|
itemAspectRatio?: number
|
||||||
|
estimatedItemHeight?: number
|
||||||
|
scrollToIndex?: number
|
||||||
gap?: number
|
gap?: number
|
||||||
overscanRows?: number
|
overscanRows?: number
|
||||||
getItemKey?: (item: any, index: number) => string | number
|
getItemKey?: (item: any, index: number) => string | number
|
||||||
@@ -11,6 +13,8 @@ const props = withDefaults(
|
|||||||
{
|
{
|
||||||
minItemWidth: 144,
|
minItemWidth: 144,
|
||||||
itemAspectRatio: 1.5,
|
itemAspectRatio: 1.5,
|
||||||
|
estimatedItemHeight: undefined,
|
||||||
|
scrollToIndex: undefined,
|
||||||
gap: 16,
|
gap: 16,
|
||||||
overscanRows: 4,
|
overscanRows: 4,
|
||||||
getItemKey: undefined,
|
getItemKey: undefined,
|
||||||
@@ -27,7 +31,8 @@ const endIndex = ref(0)
|
|||||||
let resizeObserver: ResizeObserver | null = null
|
let resizeObserver: ResizeObserver | null = null
|
||||||
let animationFrameId: number | null = null
|
let animationFrameId: number | null = null
|
||||||
|
|
||||||
const rowStep = computed(() => itemHeight.value + props.gap)
|
const effectiveItemHeight = computed(() => props.estimatedItemHeight ?? itemHeight.value)
|
||||||
|
const rowStep = computed(() => effectiveItemHeight.value + props.gap)
|
||||||
const totalRows = computed(() => Math.ceil(props.items.length / columnCount.value))
|
const totalRows = computed(() => Math.ceil(props.items.length / columnCount.value))
|
||||||
|
|
||||||
const visibleItems = computed(() => props.items.slice(startIndex.value, endIndex.value))
|
const visibleItems = computed(() => props.items.slice(startIndex.value, endIndex.value))
|
||||||
@@ -104,7 +109,7 @@ function syncVisibleRange() {
|
|||||||
const columns = Math.max(1, Math.floor((containerWidth + props.gap) / (props.minItemWidth + props.gap)))
|
const columns = Math.max(1, Math.floor((containerWidth + props.gap) / (props.minItemWidth + props.gap)))
|
||||||
columnCount.value = columns
|
columnCount.value = columns
|
||||||
itemWidth.value = (containerWidth - props.gap * (columns - 1)) / columns
|
itemWidth.value = (containerWidth - props.gap * (columns - 1)) / columns
|
||||||
itemHeight.value = itemWidth.value * props.itemAspectRatio
|
itemHeight.value = props.estimatedItemHeight ?? itemWidth.value * props.itemAspectRatio
|
||||||
|
|
||||||
const rowHeight = rowStep.value || 1
|
const rowHeight = rowStep.value || 1
|
||||||
const containerTop = window.scrollY + container.getBoundingClientRect().top
|
const containerTop = window.scrollY + container.getBoundingClientRect().top
|
||||||
@@ -129,6 +134,30 @@ function queueSyncVisibleRange() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scrollToItemIndex(index: number) {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = containerRef.value
|
||||||
|
if (!container || props.items.length === 0 || index < 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
syncVisibleRange()
|
||||||
|
|
||||||
|
const safeIndex = Math.min(index, props.items.length - 1)
|
||||||
|
const targetRow = Math.floor(safeIndex / columnCount.value)
|
||||||
|
const targetTop = window.scrollY + container.getBoundingClientRect().top + targetRow * rowStep.value
|
||||||
|
|
||||||
|
window.scrollTo({
|
||||||
|
top: Math.max(targetTop - props.gap, 0),
|
||||||
|
behavior: 'auto',
|
||||||
|
})
|
||||||
|
|
||||||
|
queueSyncVisibleRange()
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
queueSyncVisibleRange()
|
queueSyncVisibleRange()
|
||||||
window.addEventListener('scroll', queueSyncVisibleRange, { passive: true })
|
window.addEventListener('scroll', queueSyncVisibleRange, { passive: true })
|
||||||
@@ -157,7 +186,13 @@ onUnmounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.items.length,
|
[
|
||||||
|
() => props.items.length,
|
||||||
|
() => props.minItemWidth,
|
||||||
|
() => props.itemAspectRatio,
|
||||||
|
() => props.estimatedItemHeight,
|
||||||
|
() => props.gap,
|
||||||
|
],
|
||||||
() => {
|
() => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
queueSyncVisibleRange()
|
queueSyncVisibleRange()
|
||||||
@@ -165,6 +200,20 @@ watch(
|
|||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[() => props.scrollToIndex, () => props.items.length],
|
||||||
|
([scrollToIndex]) => {
|
||||||
|
if (scrollToIndex === undefined || scrollToIndex < 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
scrollToItemIndex(scrollToIndex)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ export default {
|
|||||||
descending: 'Descending',
|
descending: 'Descending',
|
||||||
versionMismatch: 'The browser cache version is inconsistent with the server version, please try to clear the cache',
|
versionMismatch: 'The browser cache version is inconsistent with the server version, please try to clear the cache',
|
||||||
clearCache: 'Clear Cache',
|
clearCache: 'Clear Cache',
|
||||||
|
sortMode: 'Sort Mode',
|
||||||
|
sortModeHint: 'Virtualization is disabled for large lists to allow drag sorting, which may reduce smoothness.',
|
||||||
},
|
},
|
||||||
mediaType: {
|
mediaType: {
|
||||||
movie: 'Movie',
|
movie: 'Movie',
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ export default {
|
|||||||
descending: '降序',
|
descending: '降序',
|
||||||
versionMismatch: '浏览器缓存版本与服务端版本不一致,请尝试清除缓存',
|
versionMismatch: '浏览器缓存版本与服务端版本不一致,请尝试清除缓存',
|
||||||
clearCache: '清除缓存',
|
clearCache: '清除缓存',
|
||||||
|
sortMode: '排序模式',
|
||||||
|
sortModeHint: '已关闭大列表虚拟渲染,方便拖拽排序,但页面流畅度可能下降。',
|
||||||
},
|
},
|
||||||
mediaType: {
|
mediaType: {
|
||||||
movie: '电影',
|
movie: '电影',
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ export default {
|
|||||||
descending: '降序',
|
descending: '降序',
|
||||||
versionMismatch: '瀏覽器快取版本與服務端版本不一致,請嘗試清除快取',
|
versionMismatch: '瀏覽器快取版本與服務端版本不一致,請嘗試清除快取',
|
||||||
clearCache: '清除快取',
|
clearCache: '清除快取',
|
||||||
|
sortMode: '排序模式',
|
||||||
|
sortModeHint: '已關閉大列表虛擬渲染,方便拖拽排序,但頁面流暢度可能下降。',
|
||||||
},
|
},
|
||||||
mediaType: {
|
mediaType: {
|
||||||
movie: '電影',
|
movie: '電影',
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ const searchShareDialog = ref(false)
|
|||||||
// 订阅分享统计弹窗
|
// 订阅分享统计弹窗
|
||||||
const shareStatisticsDialog = ref(false)
|
const shareStatisticsDialog = ref(false)
|
||||||
|
|
||||||
|
// 排序模式
|
||||||
|
const subscribeSortMode = ref(false)
|
||||||
|
|
||||||
// 订阅过滤词
|
// 订阅过滤词
|
||||||
const subscribeFilter = ref('')
|
const subscribeFilter = ref('')
|
||||||
|
|
||||||
@@ -122,6 +125,10 @@ function openShareStatisticsDialog() {
|
|||||||
shareStatisticsDialog.value = true
|
shareStatisticsDialog.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleSubscribeSortMode() {
|
||||||
|
subscribeSortMode.value = !subscribeSortMode.value
|
||||||
|
}
|
||||||
|
|
||||||
const shareKeywordUpdater = debounce((keyword: string) => {
|
const shareKeywordUpdater = debounce((keyword: string) => {
|
||||||
shareKeyword.value = keyword.trim()
|
shareKeyword.value = keyword.trim()
|
||||||
}, 300)
|
}, 300)
|
||||||
@@ -220,6 +227,14 @@ registerHeaderTab({
|
|||||||
},
|
},
|
||||||
show: computed(() => activeTab.value === 'mysub'),
|
show: computed(() => activeTab.value === 'mysub'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: 'mdi-sort-variant',
|
||||||
|
variant: 'text',
|
||||||
|
color: computed(() => (subscribeSortMode.value ? 'warning' : 'gray')),
|
||||||
|
class: 'settings-icon-button',
|
||||||
|
action: toggleSubscribeSortMode,
|
||||||
|
show: computed(() => activeTab.value === 'mysub'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: 'mdi-checkbox-multiple-marked-outline',
|
icon: 'mdi-checkbox-multiple-marked-outline',
|
||||||
variant: 'text',
|
variant: 'text',
|
||||||
@@ -267,6 +282,8 @@ onMounted(() => {
|
|||||||
:subid="subId"
|
:subid="subId"
|
||||||
:keyword="subscribeFilter"
|
:keyword="subscribeFilter"
|
||||||
:status-filter="subscribeStatusFilter ?? ''"
|
:status-filter="subscribeStatusFilter ?? ''"
|
||||||
|
:sort-mode="subscribeSortMode"
|
||||||
|
@update:sort-mode="subscribeSortMode = $event"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
type SiteIconCacheEntry = {
|
||||||
|
expiresAt: number
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const SITE_ICON_CACHE_TTL = 10 * 60 * 1000
|
||||||
|
const siteIconCache = new Map<string, SiteIconCacheEntry>()
|
||||||
|
const siteIconRequests = new Map<string, Promise<string>>()
|
||||||
|
|
||||||
|
function readCachedSiteIcon(key: string): string | undefined {
|
||||||
|
const entry = siteIconCache.get(key)
|
||||||
|
if (!entry) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.expiresAt <= Date.now()) {
|
||||||
|
siteIconCache.delete(key)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.value
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCachedSiteIcon(siteId: string | number, loader: () => Promise<string>): Promise<string> {
|
||||||
|
const cacheKey = String(siteId)
|
||||||
|
const cachedIcon = readCachedSiteIcon(cacheKey)
|
||||||
|
if (cachedIcon !== undefined) {
|
||||||
|
return cachedIcon
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentRequest = siteIconRequests.get(cacheKey)
|
||||||
|
if (currentRequest) {
|
||||||
|
return currentRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = loader()
|
||||||
|
.then(icon => {
|
||||||
|
siteIconCache.set(cacheKey, {
|
||||||
|
expiresAt: Date.now() + SITE_ICON_CACHE_TTL,
|
||||||
|
value: icon,
|
||||||
|
})
|
||||||
|
|
||||||
|
return icon
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
siteIconRequests.delete(cacheKey)
|
||||||
|
})
|
||||||
|
|
||||||
|
siteIconRequests.set(cacheKey, request)
|
||||||
|
|
||||||
|
return request
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import PluginMarketSettingDialog from '@/components/dialog/PluginMarketSettingDi
|
|||||||
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 VirtualCardGrid from '@/components/misc/VirtualCardGrid.vue'
|
||||||
import { usePWA } from '@/composables/usePWA'
|
import { usePWA } from '@/composables/usePWA'
|
||||||
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
|
import { useDynamicHeaderTab } from '@/composables/useDynamicHeaderTab'
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ const { appMode } = usePWA()
|
|||||||
|
|
||||||
// 当前标签
|
// 当前标签
|
||||||
const activeTab = ref('installed')
|
const activeTab = ref('installed')
|
||||||
|
const sortMode = ref(false)
|
||||||
|
|
||||||
// 获取插件标签页
|
// 获取插件标签页
|
||||||
const pluginTabs = computed(() => getPluginTabs(t))
|
const pluginTabs = computed(() => getPluginTabs(t))
|
||||||
@@ -58,6 +60,16 @@ registerHeaderTab({
|
|||||||
},
|
},
|
||||||
show: computed(() => activeTab.value === 'installed'),
|
show: computed(() => activeTab.value === 'installed'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: 'mdi-sort-variant',
|
||||||
|
variant: 'text',
|
||||||
|
color: computed(() => (sortMode.value ? 'warning' : 'gray')),
|
||||||
|
class: 'settings-icon-button',
|
||||||
|
action: () => {
|
||||||
|
sortMode.value = !sortMode.value
|
||||||
|
},
|
||||||
|
show: computed(() => activeTab.value === 'installed'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: 'mdi-filter-multiple-outline',
|
icon: 'mdi-filter-multiple-outline',
|
||||||
variant: 'text',
|
variant: 'text',
|
||||||
@@ -249,6 +261,41 @@ const newFolderDialog = ref(false)
|
|||||||
// 新文件夹名称
|
// 新文件夹名称
|
||||||
const newFolderName = ref('')
|
const newFolderName = ref('')
|
||||||
|
|
||||||
|
const pluginByIdMap = computed(() => new Map(dataList.value.map(plugin => [plugin.id, plugin])))
|
||||||
|
const orderValueMap = computed(() => {
|
||||||
|
const map = new Map<string, number>()
|
||||||
|
|
||||||
|
orderConfig.value.forEach((item, index) => {
|
||||||
|
map.set(`${item.type || 'plugin'}:${item.id}`, item.order ?? index)
|
||||||
|
})
|
||||||
|
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
const folderedPluginIds = computed(() => {
|
||||||
|
const pluginIds = new Set<string>()
|
||||||
|
|
||||||
|
Object.values(pluginFolders.value).forEach(folderData => {
|
||||||
|
const plugins = Array.isArray(folderData) ? folderData : folderData.plugins || []
|
||||||
|
plugins.forEach((pluginId: string) => pluginIds.add(pluginId))
|
||||||
|
})
|
||||||
|
|
||||||
|
return pluginIds
|
||||||
|
})
|
||||||
|
|
||||||
|
const canDragSort = computed(() => sortMode.value && activeTab.value === 'installed')
|
||||||
|
const shouldVirtualizeInstalledMainList = computed(() => !sortMode.value && !currentFolder.value)
|
||||||
|
const shouldVirtualizeInstalledFolderList = computed(() => !sortMode.value && !!currentFolder.value)
|
||||||
|
const installedScrollToIndex = computed(() => {
|
||||||
|
if (sortMode.value || currentFolder.value || !pluginId.value) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetIndex = mixedSortList.value.findIndex(item => item.type === 'plugin' && item.id === pluginId.value)
|
||||||
|
|
||||||
|
return targetIndex >= 0 ? targetIndex : undefined
|
||||||
|
})
|
||||||
|
|
||||||
// 获取文件夹内筛选后的插件
|
// 获取文件夹内筛选后的插件
|
||||||
const getFilteredFolderPlugins = (folderName: string) => {
|
const getFilteredFolderPlugins = (folderName: string) => {
|
||||||
const folderData = pluginFolders.value[folderName]
|
const folderData = pluginFolders.value[folderName]
|
||||||
@@ -257,7 +304,7 @@ const getFilteredFolderPlugins = (folderName: string) => {
|
|||||||
// 获取文件夹内的插件并应用筛选条件
|
// 获取文件夹内的插件并应用筛选条件
|
||||||
const folderPlugins: Plugin[] = []
|
const folderPlugins: Plugin[] = []
|
||||||
folderPluginIds.forEach((pluginId: string) => {
|
folderPluginIds.forEach((pluginId: string) => {
|
||||||
const plugin = dataList.value.find(p => p.id === pluginId)
|
const plugin = pluginByIdMap.value.get(pluginId)
|
||||||
if (plugin) {
|
if (plugin) {
|
||||||
folderPlugins.push(plugin)
|
folderPlugins.push(plugin)
|
||||||
}
|
}
|
||||||
@@ -288,12 +335,7 @@ const getFilteredFolderPlugins = (folderName: string) => {
|
|||||||
const displayedPlugins = computed(() => {
|
const displayedPlugins = computed(() => {
|
||||||
if (!currentFolder.value) {
|
if (!currentFolder.value) {
|
||||||
// 主列表:显示未归类的插件
|
// 主列表:显示未归类的插件
|
||||||
const folderedPluginIds = new Set()
|
return filteredDataList.value.filter(plugin => !folderedPluginIds.value.has(plugin.id))
|
||||||
Object.values(pluginFolders.value).forEach(folderData => {
|
|
||||||
const plugins = Array.isArray(folderData) ? folderData : folderData.plugins || []
|
|
||||||
plugins.forEach((pid: string) => folderedPluginIds.add(pid))
|
|
||||||
})
|
|
||||||
return filteredDataList.value.filter(plugin => !folderedPluginIds.has(plugin.id))
|
|
||||||
} else {
|
} else {
|
||||||
// 文件夹内:返回筛选后的插件
|
// 文件夹内:返回筛选后的插件
|
||||||
return getFilteredFolderPlugins(currentFolder.value)
|
return getFilteredFolderPlugins(currentFolder.value)
|
||||||
@@ -365,23 +407,21 @@ function updateMixedSortList() {
|
|||||||
|
|
||||||
// 添加文件夹项目
|
// 添加文件夹项目
|
||||||
displayedFolders.value.forEach(folder => {
|
displayedFolders.value.forEach(folder => {
|
||||||
const orderItem = orderConfig.value.find((item: any) => item.type === 'folder' && item.id === folder.name)
|
|
||||||
allItems.push({
|
allItems.push({
|
||||||
type: 'folder',
|
type: 'folder',
|
||||||
id: folder.name,
|
id: folder.name,
|
||||||
data: folder,
|
data: folder,
|
||||||
order: orderItem?.order ?? 999,
|
order: orderValueMap.value.get(`folder:${folder.name}`) ?? 999,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// 添加插件项目
|
// 添加插件项目
|
||||||
displayedPlugins.value.forEach(plugin => {
|
displayedPlugins.value.forEach(plugin => {
|
||||||
const orderItem = orderConfig.value.find((item: any) => item.type === 'plugin' && item.id === plugin.id)
|
|
||||||
allItems.push({
|
allItems.push({
|
||||||
type: 'plugin',
|
type: 'plugin',
|
||||||
id: plugin.id || '',
|
id: plugin.id || '',
|
||||||
data: plugin,
|
data: plugin,
|
||||||
order: orderItem?.order ?? 999,
|
order: orderValueMap.value.get(`plugin:${plugin.id}`) ?? 999,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -463,9 +503,10 @@ function sortPluginOrder() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
dataList.value.sort((a, b) => {
|
dataList.value.sort((a, b) => {
|
||||||
const aIndex = orderConfig.value.findIndex((item: { id: string }) => item.id === a.id)
|
const aIndex = orderValueMap.value.get(`plugin:${a.id}`) ?? Number.MAX_SAFE_INTEGER
|
||||||
const bIndex = orderConfig.value.findIndex((item: { id: string }) => item.id === b.id)
|
const bIndex = orderValueMap.value.get(`plugin:${b.id}`) ?? Number.MAX_SAFE_INTEGER
|
||||||
return (aIndex === -1 ? 999 : aIndex) - (bIndex === -1 ? 999 : bIndex)
|
|
||||||
|
return aIndex - bIndex
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -505,7 +546,7 @@ async function saveMixedSortOrder() {
|
|||||||
Object.values(pluginFolders.value).forEach(folderData => {
|
Object.values(pluginFolders.value).forEach(folderData => {
|
||||||
const plugins = Array.isArray(folderData) ? folderData : folderData.plugins || []
|
const plugins = Array.isArray(folderData) ? folderData : folderData.plugins || []
|
||||||
plugins.forEach((id: string) => {
|
plugins.forEach((id: string) => {
|
||||||
const folderPlugin = dataList.value.find(p => p.id === id)
|
const folderPlugin = pluginByIdMap.value.get(id)
|
||||||
if (folderPlugin && !newPluginOrder.find(p => p.id === id)) {
|
if (folderPlugin && !newPluginOrder.find(p => p.id === id)) {
|
||||||
newPluginOrder.push(folderPlugin)
|
newPluginOrder.push(folderPlugin)
|
||||||
}
|
}
|
||||||
@@ -994,12 +1035,8 @@ async function loadPluginFolders() {
|
|||||||
// 设置文件夹排序 - 使用全局排序配置
|
// 设置文件夹排序 - 使用全局排序配置
|
||||||
const folderNames = Object.keys(processedFolders)
|
const folderNames = Object.keys(processedFolders)
|
||||||
folderOrder.value = folderNames.sort((a, b) => {
|
folderOrder.value = folderNames.sort((a, b) => {
|
||||||
// 从全局排序配置中查找文件夹的order
|
const aOrder = orderValueMap.value.get(`folder:${a}`) ?? processedFolders[a].order ?? 999
|
||||||
const aOrderItem = orderConfig.value.find((item: any) => item.type === 'folder' && item.id === a)
|
const bOrder = orderValueMap.value.get(`folder:${b}`) ?? processedFolders[b].order ?? 999
|
||||||
const bOrderItem = orderConfig.value.find((item: any) => item.type === 'folder' && item.id === b)
|
|
||||||
|
|
||||||
const aOrder = aOrderItem?.order ?? processedFolders[a].order ?? 999
|
|
||||||
const bOrder = bOrderItem?.order ?? processedFolders[b].order ?? 999
|
|
||||||
|
|
||||||
return aOrder - bOrder
|
return aOrder - bOrder
|
||||||
})
|
})
|
||||||
@@ -1228,7 +1265,7 @@ async function handleDropToFolder(event: DragEvent, folderName: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 验证插件ID
|
// 验证插件ID
|
||||||
const plugin = filteredDataList.value.find(p => p.id === pluginId)
|
const plugin = pluginByIdMap.value.get(pluginId)
|
||||||
|
|
||||||
if (!plugin) {
|
if (!plugin) {
|
||||||
return
|
return
|
||||||
@@ -1485,6 +1522,9 @@ function onDragStartPlugin(evt: any) {
|
|||||||
<div>
|
<div>
|
||||||
<VPageContentTitle v-if="installedFilter" :title="t('plugin.filter', { name: installedFilter })" />
|
<VPageContentTitle v-if="installedFilter" :title="t('plugin.filter', { name: installedFilter })" />
|
||||||
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
||||||
|
<VAlert v-if="sortMode" color="warning" variant="tonal" class="mb-4">
|
||||||
|
{{ t('common.sortModeHint') }}
|
||||||
|
</VAlert>
|
||||||
|
|
||||||
<!-- 文件夹和插件网格 -->
|
<!-- 文件夹和插件网格 -->
|
||||||
<div v-if="(mixedSortList.length > 0 || displayedPlugins.length > 0) && isRefreshed">
|
<div v-if="(mixedSortList.length > 0 || displayedPlugins.length > 0) && isRefreshed">
|
||||||
@@ -1492,6 +1532,7 @@ function onDragStartPlugin(evt: any) {
|
|||||||
<template v-if="!currentFolder">
|
<template v-if="!currentFolder">
|
||||||
<!-- 主列表:使用draggable进行混合排序 -->
|
<!-- 主列表:使用draggable进行混合排序 -->
|
||||||
<draggable
|
<draggable
|
||||||
|
v-if="canDragSort"
|
||||||
v-model="mixedSortList"
|
v-model="mixedSortList"
|
||||||
@end="saveMixedSortOrder"
|
@end="saveMixedSortOrder"
|
||||||
@start="onDragStartPlugin"
|
@start="onDragStartPlugin"
|
||||||
@@ -1506,6 +1547,7 @@ function onDragStartPlugin(evt: any) {
|
|||||||
:item="element"
|
:item="element"
|
||||||
:plugin-statistics="PluginStatistics"
|
:plugin-statistics="PluginStatistics"
|
||||||
:plugin-actions="pluginActions"
|
:plugin-actions="pluginActions"
|
||||||
|
:sortable="true"
|
||||||
@open-folder="openFolder"
|
@open-folder="openFolder"
|
||||||
@delete-folder="deleteFolder"
|
@delete-folder="deleteFolder"
|
||||||
@rename-folder="(oldName, newName) => renameFolder(oldName, newName)"
|
@rename-folder="(oldName, newName) => renameFolder(oldName, newName)"
|
||||||
@@ -1520,11 +1562,40 @@ function onDragStartPlugin(evt: any) {
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</draggable>
|
</draggable>
|
||||||
|
<VirtualCardGrid
|
||||||
|
v-else-if="shouldVirtualizeInstalledMainList"
|
||||||
|
:items="mixedSortList"
|
||||||
|
:get-item-key="item => `${item.type}:${item.id}`"
|
||||||
|
:min-item-width="256"
|
||||||
|
:estimated-item-height="260"
|
||||||
|
:scroll-to-index="installedScrollToIndex"
|
||||||
|
>
|
||||||
|
<template #default="{ item }">
|
||||||
|
<PluginMixedSortCard
|
||||||
|
:item="item"
|
||||||
|
:plugin-statistics="PluginStatistics"
|
||||||
|
:plugin-actions="pluginActions"
|
||||||
|
:sortable="false"
|
||||||
|
@open-folder="openFolder"
|
||||||
|
@delete-folder="deleteFolder"
|
||||||
|
@rename-folder="(oldName, newName) => renameFolder(oldName, newName)"
|
||||||
|
@update-folder-config="(folderName, config) => updateFolderConfig(folderName, config)"
|
||||||
|
@refresh-data="refreshData"
|
||||||
|
@action-done="
|
||||||
|
pluginId => {
|
||||||
|
pluginActions[pluginId] = false
|
||||||
|
}
|
||||||
|
"
|
||||||
|
@drop-to-folder="(event, folderName) => handleDropToFolder(event, folderName)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</VirtualCardGrid>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- 文件夹内:使用draggable排序 + 移出按钮 -->
|
<!-- 文件夹内:使用draggable排序 + 移出按钮 -->
|
||||||
<draggable
|
<draggable
|
||||||
|
v-if="canDragSort"
|
||||||
v-model="draggableFolderPlugins"
|
v-model="draggableFolderPlugins"
|
||||||
@end="saveFolderPluginOrder"
|
@end="saveFolderPluginOrder"
|
||||||
@start="onDragStartPlugin"
|
@start="onDragStartPlugin"
|
||||||
@@ -1539,6 +1610,7 @@ function onDragStartPlugin(evt: any) {
|
|||||||
:item="{ type: 'plugin', id: element.id, data: element, order: 0 }"
|
:item="{ type: 'plugin', id: element.id, data: element, order: 0 }"
|
||||||
:plugin-statistics="PluginStatistics"
|
:plugin-statistics="PluginStatistics"
|
||||||
:plugin-actions="pluginActions"
|
:plugin-actions="pluginActions"
|
||||||
|
:sortable="true"
|
||||||
:show-remove-button="true"
|
:show-remove-button="true"
|
||||||
@refresh-data="refreshData"
|
@refresh-data="refreshData"
|
||||||
@action-done="
|
@action-done="
|
||||||
@@ -1550,6 +1622,30 @@ function onDragStartPlugin(evt: any) {
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</draggable>
|
</draggable>
|
||||||
|
<VirtualCardGrid
|
||||||
|
v-else-if="shouldVirtualizeInstalledFolderList"
|
||||||
|
:items="draggableFolderPlugins"
|
||||||
|
:get-item-key="item => item.id"
|
||||||
|
:min-item-width="256"
|
||||||
|
:estimated-item-height="260"
|
||||||
|
>
|
||||||
|
<template #default="{ item }">
|
||||||
|
<PluginMixedSortCard
|
||||||
|
:item="{ type: 'plugin', id: item.id, data: item, order: 0 }"
|
||||||
|
:plugin-statistics="PluginStatistics"
|
||||||
|
:plugin-actions="pluginActions"
|
||||||
|
:sortable="false"
|
||||||
|
:show-remove-button="true"
|
||||||
|
@refresh-data="refreshData"
|
||||||
|
@action-done="
|
||||||
|
pluginId => {
|
||||||
|
pluginActions[pluginId] = false
|
||||||
|
}
|
||||||
|
"
|
||||||
|
@remove-from-folder="removeFromFolder"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</VirtualCardGrid>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1580,14 +1676,17 @@ function onDragStartPlugin(evt: any) {
|
|||||||
>
|
>
|
||||||
<template #loading />
|
<template #loading />
|
||||||
<template #empty />
|
<template #empty />
|
||||||
<div class="grid gap-4 grid-plugin-card">
|
<VirtualCardGrid
|
||||||
<template
|
v-if="displayUninstalledList.length > 0"
|
||||||
v-for="(data, index) in displayUninstalledList"
|
:items="displayUninstalledList"
|
||||||
:key="`${data.id}_v${data.plugin_version}_${index}`"
|
:get-item-key="item => `${item.id}_v${item.plugin_version}`"
|
||||||
>
|
:min-item-width="256"
|
||||||
<PluginAppCard :plugin="data" :count="PluginStatistics[data.id || '0']" @install="pluginInstalled" />
|
:estimated-item-height="260"
|
||||||
|
>
|
||||||
|
<template #default="{ item }">
|
||||||
|
<PluginAppCard :plugin="item" :count="PluginStatistics[item.id || '0']" @install="pluginInstalled" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</VirtualCardGrid>
|
||||||
</VInfiniteScroll>
|
</VInfiniteScroll>
|
||||||
<NoDataFound
|
<NoDataFound
|
||||||
v-if="displayUninstalledList.length === 0 && isAppMarketLoaded"
|
v-if="displayUninstalledList.length === 0 && isAppMarketLoaded"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import NoDataFound from '@/components/NoDataFound.vue'
|
|||||||
import SiteAddEditDialog from '@/components/dialog/SiteAddEditDialog.vue'
|
import SiteAddEditDialog from '@/components/dialog/SiteAddEditDialog.vue'
|
||||||
import SiteStatisticsDialog from '@/components/dialog/SiteStatisticsDialog.vue'
|
import SiteStatisticsDialog from '@/components/dialog/SiteStatisticsDialog.vue'
|
||||||
import SiteImportDialog from '@/components/dialog/SiteImportDialog.vue'
|
import SiteImportDialog from '@/components/dialog/SiteImportDialog.vue'
|
||||||
|
import VirtualCardGrid from '@/components/misc/VirtualCardGrid.vue'
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
import { useDynamicButton } from '@/composables/useDynamicButton'
|
import { useDynamicButton } from '@/composables/useDynamicButton'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
@@ -50,6 +51,7 @@ const siteStatsDialog = ref(false)
|
|||||||
|
|
||||||
// 导入站点对话框
|
// 导入站点对话框
|
||||||
const siteImportDialog = ref(false)
|
const siteImportDialog = ref(false)
|
||||||
|
const sortMode = ref(false)
|
||||||
|
|
||||||
// 筛选相关
|
// 筛选相关
|
||||||
const filterMenu = ref(false)
|
const filterMenu = ref(false)
|
||||||
@@ -96,6 +98,21 @@ const draggableSiteList = computed({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const siteUserDataMap = computed<Record<string, SiteUserData | undefined>>(() => {
|
||||||
|
const map: Record<string, SiteUserData | undefined> = {}
|
||||||
|
|
||||||
|
userDataList.value.forEach(userData => {
|
||||||
|
if (userData.domain) {
|
||||||
|
map[userData.domain] = userData
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
const canDragSort = computed(() => sortMode.value && filterOption.value === 'all')
|
||||||
|
const shouldVirtualizeList = computed(() => !sortMode.value)
|
||||||
|
|
||||||
// 当前筛选选项的显示信息
|
// 当前筛选选项的显示信息
|
||||||
const currentFilter = computed(() => {
|
const currentFilter = computed(() => {
|
||||||
return filterOptions.value.find(option => option.value === filterOption.value)
|
return filterOptions.value.find(option => option.value === filterOption.value)
|
||||||
@@ -106,12 +123,13 @@ async function fetchData() {
|
|||||||
try {
|
try {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
siteList.value = await api.get('site/')
|
siteList.value = await api.get('site/')
|
||||||
loading.value = false
|
|
||||||
isRefreshed.value = true
|
isRefreshed.value = true
|
||||||
// 获取站点列表后,获取统计数据
|
// 获取站点列表后,获取统计数据
|
||||||
await fetchSiteStats()
|
await fetchSiteStats()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,16 +200,6 @@ async function savaSitesPriority() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据站点ID获取站点数据
|
|
||||||
function getUserData(domain: string) {
|
|
||||||
return userDataList.value.find(userData => userData.domain === domain)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 根据站点域名获取统计数据
|
|
||||||
function getSiteStats(domain: string) {
|
|
||||||
return siteStatsList.value[domain] || {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理站点统计数据刷新请求
|
// 处理站点统计数据刷新请求
|
||||||
async function handleRefreshStats(domain?: string) {
|
async function handleRefreshStats(domain?: string) {
|
||||||
if (domain) {
|
if (domain) {
|
||||||
@@ -220,6 +228,10 @@ function selectFilter(value: string) {
|
|||||||
filterMenu.value = false
|
filterMenu.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleSortMode() {
|
||||||
|
sortMode.value = !sortMode.value
|
||||||
|
}
|
||||||
|
|
||||||
// 导出站点数据
|
// 导出站点数据
|
||||||
async function exportSites() {
|
async function exportSites() {
|
||||||
try {
|
try {
|
||||||
@@ -284,6 +296,16 @@ onActivated(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => filterOption.value,
|
||||||
|
value => {
|
||||||
|
if (value !== 'all' && sortMode.value) {
|
||||||
|
sortMode.value = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
// 使用动态按钮钩子
|
// 使用动态按钮钩子
|
||||||
useDynamicButton({
|
useDynamicButton({
|
||||||
icon: 'mdi-web-plus',
|
icon: 'mdi-web-plus',
|
||||||
@@ -321,6 +343,17 @@ useDynamicButton({
|
|||||||
{{ t('site.statistics') }}
|
{{ t('site.statistics') }}
|
||||||
</span>
|
</span>
|
||||||
</VBtn>
|
</VBtn>
|
||||||
|
<VBtn
|
||||||
|
:icon="display.smAndDown.value"
|
||||||
|
variant="text"
|
||||||
|
:color="sortMode ? 'warning' : 'gray'"
|
||||||
|
@click="toggleSortMode"
|
||||||
|
>
|
||||||
|
<VIcon icon="mdi-sort-variant" />
|
||||||
|
<span v-if="!display.smAndDown.value" class="ml-2">
|
||||||
|
{{ t('common.sortMode') }}
|
||||||
|
</span>
|
||||||
|
</VBtn>
|
||||||
<!-- 筛选按钮 -->
|
<!-- 筛选按钮 -->
|
||||||
<VMenu v-model="filterMenu" offset-y :close-on-content-click="false" location="bottom end">
|
<VMenu v-model="filterMenu" offset-y :close-on-content-click="false" location="bottom end">
|
||||||
<template #activator="{ props }">
|
<template #activator="{ props }">
|
||||||
@@ -362,28 +395,52 @@ useDynamicButton({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<VAlert v-if="sortMode" color="warning" variant="tonal" class="mb-4">
|
||||||
|
{{ t('common.sortModeHint') }}
|
||||||
|
</VAlert>
|
||||||
|
|
||||||
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
||||||
<draggable
|
<draggable
|
||||||
v-if="draggableSiteList.length > 0"
|
v-if="draggableSiteList.length > 0 && canDragSort"
|
||||||
v-model="draggableSiteList"
|
v-model="draggableSiteList"
|
||||||
@end="savaSitesPriority"
|
@end="savaSitesPriority"
|
||||||
handle=".cursor-move"
|
handle=".cursor-move"
|
||||||
item-key="id"
|
item-key="id"
|
||||||
tag="div"
|
tag="div"
|
||||||
:component-data="{ 'class': 'grid gap-4 grid-site-card px-2' }"
|
:component-data="{ 'class': 'grid gap-4 grid-site-card px-2' }"
|
||||||
:disabled="filterOption !== 'all'"
|
|
||||||
>
|
>
|
||||||
<template #item="{ element }">
|
<template #item="{ element }">
|
||||||
<SiteCard
|
<SiteCard
|
||||||
:site="element"
|
:site="element"
|
||||||
:data="getUserData(element.domain)"
|
:data="siteUserDataMap[element.domain]"
|
||||||
:stats="getSiteStats(element.domain)"
|
:stats="siteStatsList[element.domain] || {}"
|
||||||
|
:sortable="true"
|
||||||
@remove="fetchData"
|
@remove="fetchData"
|
||||||
@update="fetchData"
|
@update="fetchData"
|
||||||
@refresh-stats="handleRefreshStats"
|
@refresh-stats="handleRefreshStats"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</draggable>
|
</draggable>
|
||||||
|
<VirtualCardGrid
|
||||||
|
v-else-if="draggableSiteList.length > 0 && shouldVirtualizeList"
|
||||||
|
:items="draggableSiteList"
|
||||||
|
:get-item-key="item => item.id"
|
||||||
|
:min-item-width="256"
|
||||||
|
:estimated-item-height="240"
|
||||||
|
class="px-2"
|
||||||
|
>
|
||||||
|
<template #default="{ item }">
|
||||||
|
<SiteCard
|
||||||
|
:site="item"
|
||||||
|
:data="siteUserDataMap[item.domain]"
|
||||||
|
:stats="siteStatsList[item.domain] || {}"
|
||||||
|
:sortable="false"
|
||||||
|
@remove="fetchData"
|
||||||
|
@update="fetchData"
|
||||||
|
@refresh-stats="handleRefreshStats"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</VirtualCardGrid>
|
||||||
</div>
|
</div>
|
||||||
<NoDataFound
|
<NoDataFound
|
||||||
v-if="draggableSiteList.length === 0 && isRefreshed"
|
v-if="draggableSiteList.length === 0 && isRefreshed"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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 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 { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
@@ -32,8 +33,16 @@ const props = defineProps({
|
|||||||
subid: String,
|
subid: String,
|
||||||
keyword: String,
|
keyword: String,
|
||||||
statusFilter: String,
|
statusFilter: String,
|
||||||
|
sortMode: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:sortMode': [value: boolean]
|
||||||
|
}>()
|
||||||
|
|
||||||
// 是否刷新过
|
// 是否刷新过
|
||||||
let isRefreshed = ref(false)
|
let isRefreshed = ref(false)
|
||||||
|
|
||||||
@@ -56,6 +65,27 @@ const displayList = ref<Subscribe[]>([])
|
|||||||
const isBatchMode = ref(false)
|
const isBatchMode = ref(false)
|
||||||
const selectedSubscribes = ref<number[]>([])
|
const selectedSubscribes = ref<number[]>([])
|
||||||
|
|
||||||
|
const normalizedKeyword = computed(() => props.keyword?.trim().toLowerCase() || '')
|
||||||
|
const selectedSubscribesSet = computed(() => new Set(selectedSubscribes.value))
|
||||||
|
const canSortContext = computed(
|
||||||
|
() => !normalizedKeyword.value && (!props.statusFilter || props.statusFilter === 'all') && !isBatchMode.value,
|
||||||
|
)
|
||||||
|
const sortMode = computed({
|
||||||
|
get: () => props.sortMode,
|
||||||
|
set: value => emit('update:sortMode', value),
|
||||||
|
})
|
||||||
|
const canDragSort = computed(() => sortMode.value && canSortContext.value)
|
||||||
|
const shouldVirtualizeList = computed(() => !sortMode.value)
|
||||||
|
const scrollToIndex = computed(() => {
|
||||||
|
if (!props.subid || sortMode.value) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetIndex = displayList.value.findIndex(item => item.id.toString() === props.subid?.toString())
|
||||||
|
|
||||||
|
return targetIndex >= 0 ? targetIndex : undefined
|
||||||
|
})
|
||||||
|
|
||||||
// 根据订阅数据判断订阅状态
|
// 根据订阅数据判断订阅状态
|
||||||
function getSubscribeStatus(subscribe: Subscribe) {
|
function getSubscribeStatus(subscribe: Subscribe) {
|
||||||
// 洗版中
|
// 洗版中
|
||||||
@@ -95,26 +125,52 @@ function getSubscribeStatus(subscribe: Subscribe) {
|
|||||||
// API请求键值(计算属性)
|
// API请求键值(计算属性)
|
||||||
const orderRequestKey = computed(() => (props.type === '电影' ? 'SubscribeMovieOrder' : 'SubscribeTvOrder'))
|
const orderRequestKey = computed(() => (props.type === '电影' ? 'SubscribeMovieOrder' : 'SubscribeTvOrder'))
|
||||||
|
|
||||||
// 监听dataList变化,同步更新displayList
|
// 监听数据和筛选变化,同步更新显示列表
|
||||||
watch([dataList, () => props.keyword, () => props.statusFilter], () => {
|
watch(
|
||||||
if (superUser)
|
[dataList, normalizedKeyword, () => props.statusFilter, orderConfig],
|
||||||
displayList.value = dataList.value.filter(
|
() => {
|
||||||
data =>
|
const orderIndexMap = new Map(orderConfig.value.map((item, index) => [item.id, index]))
|
||||||
data.type === props.type &&
|
const nextDisplayList = dataList.value.filter(data => {
|
||||||
(!props.keyword || data.name.toLowerCase().includes(props.keyword.toLowerCase())) &&
|
if (data.type !== props.type) {
|
||||||
(!props.statusFilter || props.statusFilter === 'all' || getSubscribeStatus(data) === props.statusFilter),
|
return false
|
||||||
)
|
}
|
||||||
else
|
|
||||||
displayList.value = dataList.value.filter(
|
if (!superUser && data.username !== userName) {
|
||||||
data =>
|
return false
|
||||||
data.type === props.type &&
|
}
|
||||||
data.username === userName &&
|
|
||||||
(!props.keyword || data.name.toLowerCase().includes(props.keyword.toLowerCase())) &&
|
if (normalizedKeyword.value && !data.name?.toLowerCase().includes(normalizedKeyword.value)) {
|
||||||
(!props.statusFilter || props.statusFilter === 'all' || getSubscribeStatus(data) === props.statusFilter),
|
return false
|
||||||
)
|
}
|
||||||
// 排序
|
|
||||||
sortSubscribeOrder()
|
if (props.statusFilter && props.statusFilter !== 'all' && getSubscribeStatus(data) !== props.statusFilter) {
|
||||||
})
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
nextDisplayList.sort((a, b) => {
|
||||||
|
const aIndex = orderIndexMap.get(a.id) ?? Number.MAX_SAFE_INTEGER
|
||||||
|
const bIndex = orderIndexMap.get(b.id) ?? Number.MAX_SAFE_INTEGER
|
||||||
|
|
||||||
|
return aIndex - bIndex
|
||||||
|
})
|
||||||
|
|
||||||
|
displayList.value = nextDisplayList
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
canSortContext,
|
||||||
|
canSort => {
|
||||||
|
if (!canSort && sortMode.value) {
|
||||||
|
sortMode.value = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
// 加载顺序
|
// 加载顺序
|
||||||
async function loadSubscribeOrderConfig() {
|
async function loadSubscribeOrderConfig() {
|
||||||
@@ -129,22 +185,6 @@ async function loadSubscribeOrderConfig() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按order的顺序排序
|
|
||||||
async function sortSubscribeOrder() {
|
|
||||||
if (!orderConfig.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (displayList.value.length === 0) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await loadSubscribeOrderConfig()
|
|
||||||
displayList.value.sort((a, b) => {
|
|
||||||
const aIndex = orderConfig.value.findIndex((item: { id: number }) => item.id === a.id)
|
|
||||||
const bIndex = orderConfig.value.findIndex((item: { id: number }) => item.id === b.id)
|
|
||||||
return (aIndex === -1 ? 999 : aIndex) - (bIndex === -1 ? 999 : bIndex)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存顺序设置
|
// 保存顺序设置
|
||||||
async function saveSubscribeOrder() {
|
async function saveSubscribeOrder() {
|
||||||
// 顺序配置
|
// 顺序配置
|
||||||
@@ -164,10 +204,11 @@ async function fetchData() {
|
|||||||
try {
|
try {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
dataList.value = await api.get('subscribe/')
|
dataList.value = await api.get('subscribe/')
|
||||||
loading.value = false
|
|
||||||
isRefreshed.value = true
|
isRefreshed.value = true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,6 +393,7 @@ const errorTitle = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
await loadSubscribeOrderConfig()
|
||||||
await fetchData()
|
await fetchData()
|
||||||
if (props.subid) {
|
if (props.subid) {
|
||||||
// 找到这个订阅
|
// 找到这个订阅
|
||||||
@@ -441,28 +483,54 @@ defineExpose({
|
|||||||
</VCard>
|
</VCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<VAlert v-if="sortMode" color="warning" variant="tonal" class="mb-4 mx-2">
|
||||||
|
{{ t('common.sortModeHint') }}
|
||||||
|
</VAlert>
|
||||||
|
|
||||||
<draggable
|
<draggable
|
||||||
v-if="displayList.length > 0"
|
v-if="displayList.length > 0 && canDragSort"
|
||||||
v-model="displayList"
|
v-model="displayList"
|
||||||
@end="saveSubscribeOrder"
|
@end="saveSubscribeOrder"
|
||||||
handle=".cursor-move"
|
handle=".cursor-move"
|
||||||
item-key="id"
|
item-key="id"
|
||||||
tag="div"
|
tag="div"
|
||||||
:component-data="{ class: 'grid gap-4 grid-subscribe-card px-2' }"
|
:component-data="{ class: 'grid gap-4 grid-subscribe-card px-2' }"
|
||||||
:disabled="props.keyword || (props.statusFilter && props.statusFilter !== 'all') || isBatchMode"
|
|
||||||
>
|
>
|
||||||
<template #item="{ element }">
|
<template #item="{ element }">
|
||||||
<SubscribeCard
|
<SubscribeCard
|
||||||
:key="element.id"
|
:key="element.id"
|
||||||
:media="element"
|
:media="element"
|
||||||
:batch-mode="isBatchMode"
|
:batch-mode="isBatchMode"
|
||||||
:selected="selectedSubscribes.includes(element.id)"
|
:selected="selectedSubscribesSet.has(element.id)"
|
||||||
|
:sortable="true"
|
||||||
@remove="fetchData"
|
@remove="fetchData"
|
||||||
@save="fetchData"
|
@save="fetchData"
|
||||||
@select="toggleSelectSubscribe(element.id)"
|
@select="toggleSelectSubscribe(element.id)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</draggable>
|
</draggable>
|
||||||
|
<VirtualCardGrid
|
||||||
|
v-else-if="displayList.length > 0 && shouldVirtualizeList"
|
||||||
|
:items="displayList"
|
||||||
|
:get-item-key="item => item.id"
|
||||||
|
:min-item-width="240"
|
||||||
|
:estimated-item-height="300"
|
||||||
|
:scroll-to-index="scrollToIndex"
|
||||||
|
class="px-2"
|
||||||
|
>
|
||||||
|
<template #default="{ item }">
|
||||||
|
<SubscribeCard
|
||||||
|
:key="item.id"
|
||||||
|
:media="item"
|
||||||
|
:batch-mode="isBatchMode"
|
||||||
|
:selected="selectedSubscribesSet.has(item.id)"
|
||||||
|
:sortable="false"
|
||||||
|
@remove="fetchData"
|
||||||
|
@save="fetchData"
|
||||||
|
@select="toggleSelectSubscribe(item.id)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</VirtualCardGrid>
|
||||||
<NoDataFound
|
<NoDataFound
|
||||||
v-if="displayList.length === 0 && isRefreshed"
|
v-if="displayList.length === 0 && isRefreshed"
|
||||||
error-code="404"
|
error-code="404"
|
||||||
|
|||||||
Reference in New Issue
Block a user