mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-07 16:56:40 +08:00
feat: virtualize card grids
This commit is contained in:
@@ -4,6 +4,7 @@ import type { Site, TorrentInfo, SiteCategory } from '@/api/types'
|
|||||||
import { formatFileSize } from '@core/utils/formatters'
|
import { formatFileSize } from '@core/utils/formatters'
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
|
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
|
||||||
|
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
@@ -94,6 +95,10 @@ const isMobileLayout = computed(() => display.smAndDown.value)
|
|||||||
// 移动端分页数据
|
// 移动端分页数据
|
||||||
const mobileResourceList = computed(() => resourceDataList.value)
|
const mobileResourceList = computed(() => resourceDataList.value)
|
||||||
|
|
||||||
|
function getResourceItemKey(item: TorrentInfo, index: number) {
|
||||||
|
return item.page_url || item.enclosure || `${item.title}-${item.pubdate || ''}-${index}`
|
||||||
|
}
|
||||||
|
|
||||||
// 打开种子详情页面
|
// 打开种子详情页面
|
||||||
function openTorrentDetail(page_url: string) {
|
function openTorrentDetail(page_url: string) {
|
||||||
if (!page_url) return
|
if (!page_url) return
|
||||||
@@ -465,12 +470,17 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="mobileResourceList.length > 0" class="px-3 pb-4">
|
<div v-else-if="mobileResourceList.length > 0" class="site-resource-mobile__list px-3 pb-4">
|
||||||
<VCard
|
<ProgressiveCardGrid
|
||||||
v-for="(item, index) in mobileResourceList"
|
:items="mobileResourceList"
|
||||||
:key="item.page_url || item.enclosure || `${item.title}-${index}`"
|
:columns="1"
|
||||||
class="mb-3"
|
:gap="12"
|
||||||
|
:estimated-item-height="320"
|
||||||
|
:overscan-rows="5"
|
||||||
|
:get-item-key="getResourceItemKey"
|
||||||
>
|
>
|
||||||
|
<template #default="{ item }">
|
||||||
|
<VCard>
|
||||||
<VCardText class="pa-4">
|
<VCardText class="pa-4">
|
||||||
<button type="button" class="site-resource-title-btn text-start" @click="addDownload(item)">
|
<button type="button" class="site-resource-title-btn text-start" @click="addDownload(item)">
|
||||||
<div class="text-body-1 font-weight-medium text-high-emphasis">
|
<div class="text-body-1 font-weight-medium text-high-emphasis">
|
||||||
@@ -485,10 +495,21 @@ onMounted(() => {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<VChip v-if="item.hit_and_run" variant="elevated" size="small" class="me-1 mb-1 text-white bg-black">
|
<VChip
|
||||||
|
v-if="item.hit_and_run"
|
||||||
|
variant="elevated"
|
||||||
|
size="small"
|
||||||
|
class="me-1 mb-1 text-white bg-black"
|
||||||
|
>
|
||||||
H&R
|
H&R
|
||||||
</VChip>
|
</VChip>
|
||||||
<VChip v-if="item.freedate_diff" variant="elevated" color="secondary" size="small" class="me-1 mb-1">
|
<VChip
|
||||||
|
v-if="item.freedate_diff"
|
||||||
|
variant="elevated"
|
||||||
|
color="secondary"
|
||||||
|
size="small"
|
||||||
|
class="me-1 mb-1"
|
||||||
|
>
|
||||||
{{ item.freedate_diff }}
|
{{ item.freedate_diff }}
|
||||||
</VChip>
|
</VChip>
|
||||||
<VChip
|
<VChip
|
||||||
@@ -556,7 +577,8 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
</VCard>
|
</VCard>
|
||||||
|
</template>
|
||||||
|
</ProgressiveCardGrid>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="px-4 py-10 text-center text-medium-emphasis">
|
<div v-else class="px-4 py-10 text-center text-medium-emphasis">
|
||||||
@@ -669,6 +691,15 @@ onMounted(() => {
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.site-resource-mobile {
|
||||||
|
overflow-y: auto;
|
||||||
|
block-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-resource-mobile__list {
|
||||||
|
min-block-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.v-table th {
|
.v-table th {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useIntersectionObserver } from '@vueuse/core'
|
import type { ComponentPublicInstance } from 'vue'
|
||||||
|
|
||||||
|
type ItemKey = string | number
|
||||||
|
type ScrollTarget = Window | HTMLElement
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -9,6 +12,7 @@ const props = withDefaults(
|
|||||||
estimatedItemHeight?: number
|
estimatedItemHeight?: number
|
||||||
scrollToIndex?: number
|
scrollToIndex?: number
|
||||||
gap?: number
|
gap?: number
|
||||||
|
columns?: number
|
||||||
initialCount?: number
|
initialCount?: number
|
||||||
batchSize?: number
|
batchSize?: number
|
||||||
overscanRows?: number
|
overscanRows?: number
|
||||||
@@ -20,6 +24,7 @@ const props = withDefaults(
|
|||||||
estimatedItemHeight: undefined,
|
estimatedItemHeight: undefined,
|
||||||
scrollToIndex: undefined,
|
scrollToIndex: undefined,
|
||||||
gap: 16,
|
gap: 16,
|
||||||
|
columns: undefined,
|
||||||
initialCount: 24,
|
initialCount: 24,
|
||||||
batchSize: 24,
|
batchSize: 24,
|
||||||
overscanRows: 4,
|
overscanRows: 4,
|
||||||
@@ -27,24 +32,194 @@ const props = withDefaults(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
interface VirtualCell {
|
||||||
|
item: any
|
||||||
|
index: number
|
||||||
|
key: ItemKey
|
||||||
|
}
|
||||||
|
|
||||||
const containerRef = ref<HTMLElement | null>(null)
|
const containerRef = ref<HTMLElement | null>(null)
|
||||||
const sentinelRef = ref<HTMLElement | null>(null)
|
const trackRef = ref<HTMLElement | null>(null)
|
||||||
const renderedCount = ref(0)
|
|
||||||
|
|
||||||
let animationFrameId: number | null = null
|
const layoutWidth = ref(0)
|
||||||
|
const viewportTop = ref(0)
|
||||||
|
const viewportBottom = ref(0)
|
||||||
|
const heightVersion = ref(0)
|
||||||
|
|
||||||
const safeInitialCount = computed(() => Math.max(1, props.initialCount))
|
const itemHeights = new Map<ItemKey, number>()
|
||||||
const safeBatchSize = computed(() => Math.max(1, props.batchSize))
|
const observedElements = new Map<HTMLElement, ItemKey>()
|
||||||
const hasMoreItems = computed(() => renderedCount.value < props.items.length)
|
const keyElements = new Map<ItemKey, HTMLElement>()
|
||||||
const visibleItems = computed(() => props.items.slice(0, renderedCount.value))
|
const itemRefCallbacks = new Map<ItemKey, (element: Element | ComponentPublicInstance | null) => void>()
|
||||||
|
|
||||||
|
let resizeObserver: ResizeObserver | null = null
|
||||||
|
let itemResizeObserver: ResizeObserver | null = null
|
||||||
|
let scrollTarget: ScrollTarget | null = null
|
||||||
|
let layoutFrameId: number | null = null
|
||||||
|
let scrollFrameId: number | null = null
|
||||||
|
let mounted = false
|
||||||
|
let pendingRevealIndex: number | null = null
|
||||||
|
let lastMeasuredColumnCount = 0
|
||||||
|
let lastMeasuredColumnWidth = 0
|
||||||
|
|
||||||
|
const safeGap = computed(() => Math.max(0, props.gap))
|
||||||
|
const safeMinItemWidth = computed(() => Math.max(1, props.minItemWidth))
|
||||||
|
const safeOverscanRows = computed(() => Math.max(1, props.overscanRows))
|
||||||
|
|
||||||
|
const columnCount = computed(() => {
|
||||||
|
if (props.columns && props.columns > 0) {
|
||||||
|
return Math.max(1, Math.floor(props.columns))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!layoutWidth.value) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(1, Math.floor((layoutWidth.value + safeGap.value) / (safeMinItemWidth.value + safeGap.value)))
|
||||||
|
})
|
||||||
|
|
||||||
|
const columnWidth = computed(() => {
|
||||||
|
const columns = columnCount.value
|
||||||
|
const width = layoutWidth.value || safeMinItemWidth.value
|
||||||
|
|
||||||
|
return Math.max(1, (width - safeGap.value * (columns - 1)) / columns)
|
||||||
|
})
|
||||||
|
|
||||||
|
const estimatedHeight = computed(() => {
|
||||||
|
if (props.estimatedItemHeight && props.estimatedItemHeight > 0) {
|
||||||
|
return props.estimatedItemHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(1, columnWidth.value * props.itemAspectRatio)
|
||||||
|
})
|
||||||
|
|
||||||
|
const itemKeys = computed(() => props.items.map((item, index) => getComparableKey(item, index)))
|
||||||
|
|
||||||
|
const keyIndexMap = computed(() => {
|
||||||
|
const map = new Map<ItemKey, number>()
|
||||||
|
|
||||||
|
itemKeys.value.forEach((key, index) => {
|
||||||
|
map.set(key, index)
|
||||||
|
})
|
||||||
|
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
const rowMetrics = computed(() => {
|
||||||
|
heightVersion.value
|
||||||
|
|
||||||
|
const rows = Math.ceil(props.items.length / columnCount.value)
|
||||||
|
const heights: number[] = []
|
||||||
|
const measuredRows: boolean[] = []
|
||||||
|
const offsets: number[] = [0]
|
||||||
|
|
||||||
|
for (let row = 0; row < rows; row += 1) {
|
||||||
|
const startIndex = row * columnCount.value
|
||||||
|
const endIndex = Math.min(startIndex + columnCount.value, props.items.length)
|
||||||
|
let rowHeight = 0
|
||||||
|
let hasUnmeasuredItem = false
|
||||||
|
|
||||||
|
for (let index = startIndex; index < endIndex; index += 1) {
|
||||||
|
const height = itemHeights.get(itemKeys.value[index])
|
||||||
|
if (height && height > 0) {
|
||||||
|
rowHeight = Math.max(rowHeight, height)
|
||||||
|
} else {
|
||||||
|
hasUnmeasuredItem = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasUnmeasuredItem) {
|
||||||
|
rowHeight = Math.max(rowHeight, estimatedHeight.value)
|
||||||
|
} else {
|
||||||
|
rowHeight = Math.max(rowHeight, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
heights.push(rowHeight)
|
||||||
|
measuredRows.push(!hasUnmeasuredItem)
|
||||||
|
offsets.push(offsets[row] + rowHeight + (row < rows - 1 ? safeGap.value : 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
heights,
|
||||||
|
measuredRows,
|
||||||
|
offsets,
|
||||||
|
rowCount: rows,
|
||||||
|
totalHeight: offsets[rows] ?? 0,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalHeight = computed(() => rowMetrics.value.totalHeight)
|
||||||
|
|
||||||
|
const visibleRange = computed(() => {
|
||||||
|
const { heights, offsets, rowCount } = rowMetrics.value
|
||||||
|
|
||||||
|
if (!props.items.length || rowCount === 0) {
|
||||||
|
return {
|
||||||
|
endIndex: 0,
|
||||||
|
endRow: 0,
|
||||||
|
startIndex: 0,
|
||||||
|
startRow: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const top = Math.max(0, Math.min(viewportTop.value, totalHeight.value))
|
||||||
|
const bottom = Math.max(top, Math.min(viewportBottom.value, totalHeight.value))
|
||||||
|
const firstVisibleRow = findFirstRowAtOrAfterOffset(offsets, heights, top)
|
||||||
|
const lastVisibleRow = findLastRowAtOrBeforeOffset(offsets, rowCount, bottom)
|
||||||
|
const startRow = clamp(firstVisibleRow - safeOverscanRows.value, 0, rowCount - 1)
|
||||||
|
const endRow = clamp(lastVisibleRow + safeOverscanRows.value, startRow, rowCount - 1)
|
||||||
|
|
||||||
|
return {
|
||||||
|
endIndex: Math.min(props.items.length, (endRow + 1) * columnCount.value),
|
||||||
|
endRow,
|
||||||
|
startIndex: startRow * columnCount.value,
|
||||||
|
startRow,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const visibleCells = computed<VirtualCell[]>(() => {
|
||||||
|
const cells: VirtualCell[] = []
|
||||||
|
|
||||||
|
for (let index = visibleRange.value.startIndex; index < visibleRange.value.endIndex; index += 1) {
|
||||||
|
cells.push({
|
||||||
|
item: props.items[index],
|
||||||
|
index,
|
||||||
|
key: itemKeys.value[index],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return cells
|
||||||
|
})
|
||||||
|
|
||||||
|
const topSpacerHeight = computed(() => rowMetrics.value.offsets[visibleRange.value.startRow] ?? 0)
|
||||||
|
|
||||||
|
const visibleBlockHeight = computed(() => {
|
||||||
|
if (!props.items.length || visibleRange.value.endIndex <= visibleRange.value.startIndex) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(
|
||||||
|
(rowMetrics.value.offsets[visibleRange.value.endRow] ?? 0) +
|
||||||
|
(rowMetrics.value.heights[visibleRange.value.endRow] ?? 0) -
|
||||||
|
(rowMetrics.value.offsets[visibleRange.value.startRow] ?? 0),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const bottomSpacerHeight = computed(() => {
|
||||||
|
return Math.max(totalHeight.value - topSpacerHeight.value - visibleBlockHeight.value, 0)
|
||||||
|
})
|
||||||
|
|
||||||
const gridStyle = computed(() => ({
|
const gridStyle = computed(() => ({
|
||||||
columnGap: `${props.gap}px`,
|
columnGap: `${safeGap.value}px`,
|
||||||
gridTemplateColumns: `repeat(auto-fill, minmax(${props.minItemWidth}px, 1fr))`,
|
gridTemplateColumns: `repeat(${columnCount.value}, minmax(0, 1fr))`,
|
||||||
rowGap: `${props.gap}px`,
|
rowGap: `${safeGap.value}px`,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
function getComparableKey(item: any, index: number) {
|
function clamp(value: number, min: number, max: number) {
|
||||||
|
return Math.min(Math.max(value, min), max)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getComparableKey(item: any, index: number): ItemKey {
|
||||||
if (props.getItemKey) {
|
if (props.getItemKey) {
|
||||||
return props.getItemKey(item, index)
|
return props.getItemKey(item, index)
|
||||||
}
|
}
|
||||||
@@ -52,48 +227,353 @@ function getComparableKey(item: any, index: number) {
|
|||||||
return index
|
return index
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveItemKey(item: any, index: number) {
|
function findFirstRowAtOrAfterOffset(offsets: number[], heights: number[], offset: number) {
|
||||||
return getComparableKey(item, index)
|
let low = 0
|
||||||
|
let high = heights.length - 1
|
||||||
|
let answer = 0
|
||||||
|
|
||||||
|
while (low <= high) {
|
||||||
|
const mid = Math.floor((low + high) / 2)
|
||||||
|
const rowEnd = offsets[mid] + heights[mid]
|
||||||
|
|
||||||
|
if (rowEnd >= offset) {
|
||||||
|
answer = mid
|
||||||
|
high = mid - 1
|
||||||
|
} else {
|
||||||
|
low = mid + 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendNextBatch() {
|
return answer
|
||||||
renderedCount.value = Math.min(props.items.length, renderedCount.value + safeBatchSize.value)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasPageScroll() {
|
function findLastRowAtOrBeforeOffset(offsets: number[], rowCount: number, offset: number) {
|
||||||
if (typeof window === 'undefined') {
|
let low = 0
|
||||||
return true
|
let high = rowCount - 1
|
||||||
|
let answer = 0
|
||||||
|
|
||||||
|
while (low <= high) {
|
||||||
|
const mid = Math.floor((low + high) / 2)
|
||||||
|
|
||||||
|
if (offsets[mid] <= offset) {
|
||||||
|
answer = mid
|
||||||
|
low = mid + 1
|
||||||
|
} else {
|
||||||
|
high = mid - 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const scrollHeight = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight)
|
return answer
|
||||||
|
|
||||||
return scrollHeight - (window.innerHeight || document.documentElement.clientHeight) > 2
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fillViewport() {
|
function getElementFromRef(element: Element | ComponentPublicInstance | null): HTMLElement | null {
|
||||||
if (typeof window === 'undefined') {
|
if (!element || typeof HTMLElement === 'undefined') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element instanceof HTMLElement) {
|
||||||
|
return element
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!('$el' in element)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const componentElement = element.$el
|
||||||
|
|
||||||
|
return componentElement instanceof HTMLElement ? componentElement : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRowHeight(row: number) {
|
||||||
|
const startIndex = row * columnCount.value
|
||||||
|
const endIndex = Math.min(startIndex + columnCount.value, props.items.length)
|
||||||
|
let rowHeight = 0
|
||||||
|
let hasUnmeasuredItem = false
|
||||||
|
|
||||||
|
for (let index = startIndex; index < endIndex; index += 1) {
|
||||||
|
const height = itemHeights.get(itemKeys.value[index])
|
||||||
|
if (height && height > 0) {
|
||||||
|
rowHeight = Math.max(rowHeight, height)
|
||||||
|
} else {
|
||||||
|
hasUnmeasuredItem = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasUnmeasuredItem) {
|
||||||
|
return Math.max(rowHeight, estimatedHeight.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(rowHeight, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureItemResizeObserver() {
|
||||||
|
if (itemResizeObserver || typeof ResizeObserver === 'undefined') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxIterations = Math.ceil(props.items.length / safeBatchSize.value)
|
itemResizeObserver = new ResizeObserver(entries => {
|
||||||
let iterations = 0
|
let shouldUpdate = false
|
||||||
|
let scrollAdjustment = 0
|
||||||
|
const currentViewportTop = viewportTop.value
|
||||||
|
const currentOffsets = rowMetrics.value.offsets
|
||||||
|
|
||||||
while (!hasPageScroll() && hasMoreItems.value && iterations < maxIterations) {
|
entries.forEach(entry => {
|
||||||
appendNextBatch()
|
const element = entry.target
|
||||||
iterations += 1
|
if (!(element instanceof HTMLElement)) {
|
||||||
await nextTick()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function queueFillViewport() {
|
|
||||||
if (typeof window === 'undefined' || animationFrameId !== null) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
animationFrameId = window.requestAnimationFrame(() => {
|
const key = observedElements.get(element)
|
||||||
animationFrameId = null
|
const index = key === undefined ? undefined : keyIndexMap.value.get(key)
|
||||||
void fillViewport()
|
|
||||||
|
if (key === undefined || index === undefined) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextHeight = getResizeEntryHeight(entry)
|
||||||
|
const previousHeight = itemHeights.get(key)
|
||||||
|
|
||||||
|
if (!nextHeight || Math.abs((previousHeight ?? 0) - nextHeight) < 0.5) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = Math.floor(index / columnCount.value)
|
||||||
|
const rowWasFullyMeasured = rowMetrics.value.measuredRows[row]
|
||||||
|
const previousRowHeight = getRowHeight(row)
|
||||||
|
const previousRowBottom = (currentOffsets[row] ?? 0) + previousRowHeight
|
||||||
|
|
||||||
|
if (
|
||||||
|
rowWasFullyMeasured &&
|
||||||
|
previousHeight !== undefined &&
|
||||||
|
previousHeight < previousRowHeight - 0.5 &&
|
||||||
|
nextHeight <= previousRowHeight + 0.5
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
itemHeights.set(key, nextHeight)
|
||||||
|
|
||||||
|
const nextRowHeight = getRowHeight(row)
|
||||||
|
const delta = nextRowHeight - previousRowHeight
|
||||||
|
|
||||||
|
if (Math.abs(delta) >= 0.5 && previousRowBottom < currentViewportTop) {
|
||||||
|
scrollAdjustment += delta
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldUpdate = true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (!shouldUpdate) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
heightVersion.value += 1
|
||||||
|
|
||||||
|
if (Math.abs(scrollAdjustment) >= 0.5) {
|
||||||
|
adjustScrollTop(scrollAdjustment)
|
||||||
|
}
|
||||||
|
|
||||||
|
queueViewportSync()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResizeEntryHeight(entry: ResizeObserverEntry) {
|
||||||
|
const borderSize = Array.isArray(entry.borderBoxSize) ? entry.borderBoxSize[0] : entry.borderBoxSize
|
||||||
|
|
||||||
|
return borderSize?.blockSize || entry.contentRect.height
|
||||||
|
}
|
||||||
|
|
||||||
|
function setItemRef(element: Element | ComponentPublicInstance | null, key: ItemKey) {
|
||||||
|
const htmlElement = getElementFromRef(element)
|
||||||
|
const previousElement = keyElements.get(key)
|
||||||
|
|
||||||
|
if (!htmlElement) {
|
||||||
|
if (previousElement) {
|
||||||
|
itemResizeObserver?.unobserve(previousElement)
|
||||||
|
observedElements.delete(previousElement)
|
||||||
|
keyElements.delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previousElement === htmlElement) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureItemResizeObserver()
|
||||||
|
|
||||||
|
if (previousElement) {
|
||||||
|
itemResizeObserver?.unobserve(previousElement)
|
||||||
|
observedElements.delete(previousElement)
|
||||||
|
}
|
||||||
|
|
||||||
|
observedElements.set(htmlElement, key)
|
||||||
|
keyElements.set(key, htmlElement)
|
||||||
|
itemResizeObserver?.observe(htmlElement)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getItemRef(key: ItemKey) {
|
||||||
|
const existingCallback = itemRefCallbacks.get(key)
|
||||||
|
|
||||||
|
if (existingCallback) {
|
||||||
|
return existingCallback
|
||||||
|
}
|
||||||
|
|
||||||
|
const callback = (element: Element | ComponentPublicInstance | null) => setItemRef(element, key)
|
||||||
|
itemRefCallbacks.set(key, callback)
|
||||||
|
|
||||||
|
return callback
|
||||||
|
}
|
||||||
|
|
||||||
|
function findScrollTarget(): ScrollTarget {
|
||||||
|
let parent = containerRef.value?.parentElement ?? null
|
||||||
|
|
||||||
|
while (parent && parent !== document.body && parent !== document.documentElement) {
|
||||||
|
const overflowY = window.getComputedStyle(parent).overflowY
|
||||||
|
|
||||||
|
if (overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay') {
|
||||||
|
return parent
|
||||||
|
}
|
||||||
|
|
||||||
|
parent = parent.parentElement
|
||||||
|
}
|
||||||
|
|
||||||
|
return window
|
||||||
|
}
|
||||||
|
|
||||||
|
function addScrollListener(target: ScrollTarget) {
|
||||||
|
target.addEventListener('scroll', queueViewportSync, { passive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeScrollListener(target: ScrollTarget | null) {
|
||||||
|
target?.removeEventListener('scroll', queueViewportSync)
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshScrollTarget() {
|
||||||
|
if (!mounted) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextTarget = findScrollTarget()
|
||||||
|
|
||||||
|
if (scrollTarget === nextTarget) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
removeScrollListener(scrollTarget)
|
||||||
|
scrollTarget = nextTarget
|
||||||
|
addScrollListener(scrollTarget)
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncLayoutWidth() {
|
||||||
|
const element = trackRef.value
|
||||||
|
|
||||||
|
if (!element) {
|
||||||
|
layoutWidth.value = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
layoutWidth.value = element.clientWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncViewport() {
|
||||||
|
const element = trackRef.value
|
||||||
|
|
||||||
|
if (!element) {
|
||||||
|
viewportTop.value = 0
|
||||||
|
viewportBottom.value = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackRect = element.getBoundingClientRect()
|
||||||
|
const viewportRect =
|
||||||
|
scrollTarget && scrollTarget !== window
|
||||||
|
? (scrollTarget as HTMLElement).getBoundingClientRect()
|
||||||
|
: {
|
||||||
|
bottom: window.innerHeight,
|
||||||
|
top: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
viewportTop.value = viewportRect.top - trackRect.top
|
||||||
|
viewportBottom.value = viewportRect.bottom - trackRect.top
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueLayoutSync() {
|
||||||
|
if (typeof window === 'undefined' || layoutFrameId !== null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
layoutFrameId = window.requestAnimationFrame(() => {
|
||||||
|
layoutFrameId = null
|
||||||
|
syncLayoutWidth()
|
||||||
|
refreshScrollTarget()
|
||||||
|
syncViewport()
|
||||||
|
flushPendingReveal()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueViewportSync() {
|
||||||
|
if (typeof window === 'undefined' || scrollFrameId !== null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollFrameId = window.requestAnimationFrame(() => {
|
||||||
|
scrollFrameId = null
|
||||||
|
syncViewport()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTrackScrollTop() {
|
||||||
|
const element = trackRef.value
|
||||||
|
|
||||||
|
if (!element || !scrollTarget || scrollTarget === window) {
|
||||||
|
return (element?.getBoundingClientRect().top ?? 0) + window.scrollY
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollElement = scrollTarget as HTMLElement
|
||||||
|
const trackRect = element.getBoundingClientRect()
|
||||||
|
const scrollRect = scrollElement.getBoundingClientRect()
|
||||||
|
|
||||||
|
return trackRect.top - scrollRect.top + scrollElement.scrollTop
|
||||||
|
}
|
||||||
|
|
||||||
|
function adjustScrollTop(delta: number) {
|
||||||
|
if (!scrollTarget || Math.abs(delta) < 0.5) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scrollTarget === window) {
|
||||||
|
window.scrollBy({
|
||||||
|
behavior: 'auto',
|
||||||
|
top: delta,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const scrollElement = scrollTarget as HTMLElement
|
||||||
|
scrollElement.scrollTop += delta
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToRelativeTop(top: number) {
|
||||||
|
if (!scrollTarget) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetTop = getTrackScrollTop() + top
|
||||||
|
|
||||||
|
if (scrollTarget === window) {
|
||||||
|
window.scrollTo({
|
||||||
|
behavior: 'auto',
|
||||||
|
top: targetTop,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
;(scrollTarget as HTMLElement).scrollTo({
|
||||||
|
behavior: 'auto',
|
||||||
|
top: targetTop,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
queueViewportSync()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function revealItem(index: number) {
|
async function revealItem(index: number) {
|
||||||
@@ -101,120 +581,191 @@ async function revealItem(index: number) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const minRenderedCount = Math.ceil((index + 1) / safeBatchSize.value) * safeBatchSize.value
|
|
||||||
renderedCount.value = Math.min(props.items.length, Math.max(renderedCount.value, minRenderedCount))
|
|
||||||
|
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
const target = containerRef.value?.querySelector(`[data-progressive-grid-index="${index}"]`)
|
const row = Math.floor(index / columnCount.value)
|
||||||
if (target instanceof HTMLElement) {
|
const top = rowMetrics.value.offsets[row] ?? 0
|
||||||
target.scrollIntoView({
|
|
||||||
behavior: 'auto',
|
scrollToRelativeTop(top)
|
||||||
block: 'start',
|
|
||||||
inline: 'nearest',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetVisibleItems() {
|
function requestRevealItem(index: number) {
|
||||||
renderedCount.value = Math.min(props.items.length, safeInitialCount.value)
|
pendingRevealIndex = index
|
||||||
|
|
||||||
nextTick(() => {
|
if (!mounted) {
|
||||||
if (props.scrollToIndex !== undefined && props.scrollToIndex >= 0) {
|
|
||||||
void revealItem(props.scrollToIndex)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
queueFillViewport()
|
queueLayoutSync()
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function didItemsAppend(nextItems: any[], previousItems: any[]) {
|
function flushPendingReveal() {
|
||||||
if (!previousItems.length || nextItems.length < previousItems.length) {
|
if (pendingRevealIndex === null || !mounted || !scrollTarget || layoutWidth.value <= 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = pendingRevealIndex
|
||||||
|
pendingRevealIndex = null
|
||||||
|
|
||||||
|
void revealItem(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneMeasurements() {
|
||||||
|
const keys = new Set(itemKeys.value)
|
||||||
|
let changed = false
|
||||||
|
|
||||||
|
Array.from(itemHeights.keys()).forEach(key => {
|
||||||
|
if (!keys.has(key)) {
|
||||||
|
itemHeights.delete(key)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
Array.from(keyElements.entries()).forEach(([key, element]) => {
|
||||||
|
if (!keys.has(key)) {
|
||||||
|
itemResizeObserver?.unobserve(element)
|
||||||
|
observedElements.delete(element)
|
||||||
|
keyElements.delete(key)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
Array.from(itemRefCallbacks.keys()).forEach(key => {
|
||||||
|
if (!keys.has(key)) {
|
||||||
|
itemRefCallbacks.delete(key)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
heightVersion.value += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function didKeysAppend(nextKeys: ItemKey[], previousKeys: ItemKey[] = []) {
|
||||||
|
if (!previousKeys.length || nextKeys.length < previousKeys.length) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return previousItems.every((item, index) => getComparableKey(item, index) === getComparableKey(nextItems[index], index))
|
return previousKeys.every((key, index) => key === nextKeys[index])
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncVisibleItems(nextItems: any[], previousItems: any[] = []) {
|
function syncMeasurementsForItems(nextKeys: ItemKey[], previousKeys: ItemKey[] = []) {
|
||||||
if (didItemsAppend(nextItems, previousItems)) {
|
if (!didKeysAppend(nextKeys, previousKeys) && itemHeights.size) {
|
||||||
renderedCount.value = Math.min(nextItems.length, Math.max(renderedCount.value, previousItems.length))
|
itemHeights.clear()
|
||||||
|
heightVersion.value += 1
|
||||||
|
}
|
||||||
|
|
||||||
nextTick(() => {
|
pruneMeasurements()
|
||||||
if (props.scrollToIndex !== undefined && props.scrollToIndex >= 0) {
|
}
|
||||||
void revealItem(props.scrollToIndex)
|
|
||||||
|
function invalidateMeasurementsForLayoutChange() {
|
||||||
|
const nextColumnCount = columnCount.value
|
||||||
|
const nextColumnWidth = columnWidth.value
|
||||||
|
|
||||||
|
if (
|
||||||
|
lastMeasuredColumnCount === nextColumnCount &&
|
||||||
|
Math.abs(lastMeasuredColumnWidth - nextColumnWidth) < 1
|
||||||
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
queueFillViewport()
|
lastMeasuredColumnCount = nextColumnCount
|
||||||
})
|
lastMeasuredColumnWidth = nextColumnWidth
|
||||||
|
|
||||||
|
if (!itemHeights.size) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
resetVisibleItems()
|
itemHeights.clear()
|
||||||
|
heightVersion.value += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
const { stop } = useIntersectionObserver(
|
|
||||||
sentinelRef,
|
|
||||||
([entry]) => {
|
|
||||||
if (!entry?.isIntersecting || !hasMoreItems.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
appendNextBatch()
|
|
||||||
queueFillViewport()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
rootMargin: '1200px 0px',
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
window.addEventListener('resize', queueFillViewport, { passive: true })
|
mounted = true
|
||||||
|
scrollTarget = findScrollTarget()
|
||||||
|
addScrollListener(scrollTarget)
|
||||||
|
|
||||||
|
resizeObserver = new ResizeObserver(queueLayoutSync)
|
||||||
|
if (trackRef.value) {
|
||||||
|
resizeObserver.observe(trackRef.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', queueLayoutSync, { passive: true })
|
||||||
|
|
||||||
|
queueLayoutSync()
|
||||||
|
})
|
||||||
|
|
||||||
|
onActivated(() => {
|
||||||
|
mounted = true
|
||||||
|
refreshScrollTarget()
|
||||||
|
queueLayoutSync()
|
||||||
|
})
|
||||||
|
|
||||||
|
onDeactivated(() => {
|
||||||
|
mounted = false
|
||||||
|
removeScrollListener(scrollTarget)
|
||||||
|
scrollTarget = null
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
stop()
|
mounted = false
|
||||||
window.removeEventListener('resize', queueFillViewport)
|
removeScrollListener(scrollTarget)
|
||||||
|
scrollTarget = null
|
||||||
|
|
||||||
if (animationFrameId !== null) {
|
window.removeEventListener('resize', queueLayoutSync)
|
||||||
window.cancelAnimationFrame(animationFrameId)
|
resizeObserver?.disconnect()
|
||||||
animationFrameId = null
|
resizeObserver = null
|
||||||
|
itemResizeObserver?.disconnect()
|
||||||
|
itemResizeObserver = null
|
||||||
|
|
||||||
|
if (layoutFrameId !== null) {
|
||||||
|
window.cancelAnimationFrame(layoutFrameId)
|
||||||
|
layoutFrameId = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scrollFrameId !== null) {
|
||||||
|
window.cancelAnimationFrame(scrollFrameId)
|
||||||
|
scrollFrameId = null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
itemKeys,
|
||||||
|
(nextKeys, previousKeys) => {
|
||||||
|
syncMeasurementsForItems(nextKeys, previousKeys)
|
||||||
|
queueLayoutSync()
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
[
|
[
|
||||||
() => props.minItemWidth,
|
() => props.minItemWidth,
|
||||||
() => props.initialCount,
|
() => props.gap,
|
||||||
() => props.batchSize,
|
() => props.estimatedItemHeight,
|
||||||
|
() => props.itemAspectRatio,
|
||||||
|
() => props.columns,
|
||||||
],
|
],
|
||||||
() => {
|
() => {
|
||||||
queueFillViewport()
|
queueLayoutSync()
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.items,
|
[columnCount, columnWidth],
|
||||||
(nextItems, previousItems) => {
|
() => {
|
||||||
syncVisibleItems(nextItems, previousItems)
|
invalidateMeasurementsForLayoutChange()
|
||||||
|
queueViewportSync()
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
[() => props.scrollToIndex, () => props.items.length],
|
[() => props.scrollToIndex, () => props.items.length, columnCount],
|
||||||
([scrollToIndex]) => {
|
([scrollToIndex]) => {
|
||||||
if (scrollToIndex === undefined || scrollToIndex < 0) {
|
if (scrollToIndex === undefined || scrollToIndex < 0 || scrollToIndex >= props.items.length) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
nextTick(() => {
|
requestRevealItem(scrollToIndex)
|
||||||
void revealItem(scrollToIndex)
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
@@ -222,17 +773,31 @@ watch(
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="containerRef" class="progressive-card-grid">
|
<div ref="containerRef" class="progressive-card-grid">
|
||||||
<div class="grid" :style="gridStyle">
|
<div ref="trackRef" class="progressive-card-grid__track">
|
||||||
<div
|
<div
|
||||||
v-for="(item, index) in visibleItems"
|
v-if="topSpacerHeight > 0"
|
||||||
:key="resolveItemKey(item, index)"
|
class="progressive-card-grid__spacer"
|
||||||
|
:style="{ blockSize: `${topSpacerHeight}px` }"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div v-if="visibleCells.length > 0" class="progressive-card-grid__grid" :style="gridStyle">
|
||||||
|
<div
|
||||||
|
v-for="cell in visibleCells"
|
||||||
|
:key="cell.key"
|
||||||
|
:ref="getItemRef(cell.key)"
|
||||||
class="progressive-card-grid__item"
|
class="progressive-card-grid__item"
|
||||||
:data-progressive-grid-index="index"
|
:data-progressive-grid-index="cell.index"
|
||||||
>
|
>
|
||||||
<slot :item="item" :index="index" />
|
<slot :item="cell.item" :index="cell.index" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="hasMoreItems" ref="sentinelRef" class="progressive-card-grid__sentinel" aria-hidden="true" />
|
<div
|
||||||
|
v-if="bottomSpacerHeight > 0"
|
||||||
|
class="progressive-card-grid__spacer"
|
||||||
|
:style="{ blockSize: `${bottomSpacerHeight}px` }"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -241,12 +806,23 @@ watch(
|
|||||||
inline-size: 100%;
|
inline-size: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.progressive-card-grid__track {
|
||||||
|
inline-size: 100%;
|
||||||
|
min-block-size: 1px;
|
||||||
|
overflow-anchor: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progressive-card-grid__grid {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
.progressive-card-grid__item {
|
.progressive-card-grid__item {
|
||||||
|
inline-size: 100%;
|
||||||
min-inline-size: 0;
|
min-inline-size: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progressive-card-grid__sentinel {
|
.progressive-card-grid__item > :deep(*) {
|
||||||
block-size: 1px;
|
block-size: 100%;
|
||||||
inline-size: 100%;
|
inline-size: 100%;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+10
-5
@@ -1149,14 +1149,19 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="filteredRowDataList.length > 0" class="resource-list">
|
<div v-else-if="filteredRowDataList.length > 0" class="resource-list">
|
||||||
<VVirtualScroll renderless :items="filteredRowDataList" :item-height="240">
|
<ProgressiveCardGrid
|
||||||
<template #default="{ item, index, itemRef }">
|
:items="filteredRowDataList"
|
||||||
<div :ref="itemRef" :key="getTorrentItemKey(item, index)">
|
:columns="1"
|
||||||
|
:gap="8"
|
||||||
|
:estimated-item-height="240"
|
||||||
|
:overscan-rows="6"
|
||||||
|
:get-item-key="getTorrentItemKey"
|
||||||
|
>
|
||||||
|
<template #default="{ item, index }">
|
||||||
<TorrentItem :torrent="item" />
|
<TorrentItem :torrent="item" />
|
||||||
<VDivider v-if="index < filteredRowDataList.length - 1" class="my-2" />
|
<VDivider v-if="index < filteredRowDataList.length - 1" class="my-2" />
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</VVirtualScroll>
|
</ProgressiveCardGrid>
|
||||||
</div>
|
</div>
|
||||||
</VCard>
|
</VCard>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ async function fetchData({ done }: { done: any }) {
|
|||||||
<ProgressiveCardGrid
|
<ProgressiveCardGrid
|
||||||
v-if="dataList.length > 0"
|
v-if="dataList.length > 0"
|
||||||
:items="dataList"
|
:items="dataList"
|
||||||
|
:item-aspect-ratio="1.5"
|
||||||
:get-item-key="item => item.tmdb_id || item.douban_id || item.bangumi_id || item.media_id || item.title"
|
:get-item-key="item => item.tmdb_id || item.douban_id || item.bangumi_id || item.media_id || item.title"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -123,7 +123,13 @@ async function fetchData({ done }: { done: any }) {
|
|||||||
<VInfiniteScroll mode="intersect" side="end" :items="dataList" class="overflow-visible px-3" @load="fetchData">
|
<VInfiniteScroll mode="intersect" side="end" :items="dataList" class="overflow-visible px-3" @load="fetchData">
|
||||||
<template #loading />
|
<template #loading />
|
||||||
<template #empty />
|
<template #empty />
|
||||||
<ProgressiveCardGrid v-if="dataList.length > 0" :items="dataList" :get-item-key="item => item.id" tabindex="0">
|
<ProgressiveCardGrid
|
||||||
|
v-if="dataList.length > 0"
|
||||||
|
:items="dataList"
|
||||||
|
:item-aspect-ratio="1.5"
|
||||||
|
:get-item-key="item => item.id"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
<template #default="{ item }">
|
<template #default="{ item }">
|
||||||
<PersonCard :person="item" />
|
<PersonCard :person="item" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1571,6 +1571,7 @@ function onDragStartPlugin(evt: any) {
|
|||||||
:items="mixedSortList"
|
:items="mixedSortList"
|
||||||
:get-item-key="item => `${item.type}:${item.id}`"
|
:get-item-key="item => `${item.type}:${item.id}`"
|
||||||
:min-item-width="256"
|
:min-item-width="256"
|
||||||
|
:estimated-item-height="180"
|
||||||
:scroll-to-index="installedScrollToIndex"
|
:scroll-to-index="installedScrollToIndex"
|
||||||
>
|
>
|
||||||
<template #default="{ item }">
|
<template #default="{ item }">
|
||||||
@@ -1629,6 +1630,7 @@ function onDragStartPlugin(evt: any) {
|
|||||||
:items="draggableFolderPlugins"
|
:items="draggableFolderPlugins"
|
||||||
:get-item-key="item => item.id"
|
:get-item-key="item => item.id"
|
||||||
:min-item-width="256"
|
:min-item-width="256"
|
||||||
|
:estimated-item-height="180"
|
||||||
>
|
>
|
||||||
<template #default="{ item }">
|
<template #default="{ item }">
|
||||||
<PluginMixedSortCard
|
<PluginMixedSortCard
|
||||||
|
|||||||
@@ -427,6 +427,7 @@ useDynamicButton({
|
|||||||
:items="draggableSiteList"
|
:items="draggableSiteList"
|
||||||
:get-item-key="item => item.id"
|
:get-item-key="item => item.id"
|
||||||
:min-item-width="256"
|
:min-item-width="256"
|
||||||
|
:estimated-item-height="168"
|
||||||
class="px-2"
|
class="px-2"
|
||||||
>
|
>
|
||||||
<template #default="{ item }">
|
<template #default="{ item }">
|
||||||
|
|||||||
@@ -518,6 +518,7 @@ defineExpose({
|
|||||||
:items="displayList"
|
:items="displayList"
|
||||||
:get-item-key="item => item.id"
|
:get-item-key="item => item.id"
|
||||||
:min-item-width="240"
|
:min-item-width="240"
|
||||||
|
:estimated-item-height="300"
|
||||||
:scroll-to-index="scrollToIndex"
|
:scroll-to-index="scrollToIndex"
|
||||||
class="px-2"
|
class="px-2"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { User } from '@/api/types'
|
|||||||
import NoDataFound from '@/components/NoDataFound.vue'
|
import NoDataFound from '@/components/NoDataFound.vue'
|
||||||
import UserCard from '@/components/cards/UserCard.vue'
|
import UserCard from '@/components/cards/UserCard.vue'
|
||||||
import UserAddEditDialog from '@/components/dialog/UserAddEditDialog.vue'
|
import UserAddEditDialog from '@/components/dialog/UserAddEditDialog.vue'
|
||||||
|
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||||
import { useDynamicButton } from '@/composables/useDynamicButton'
|
import { useDynamicButton } from '@/composables/useDynamicButton'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { usePWA } from '@/composables/usePWA'
|
import { usePWA } from '@/composables/usePWA'
|
||||||
@@ -80,17 +81,19 @@ useDynamicButton({
|
|||||||
<!-- 加载中提示 -->
|
<!-- 加载中提示 -->
|
||||||
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
||||||
<!-- 用户卡片网格 -->
|
<!-- 用户卡片网格 -->
|
||||||
<div v-if="allUsers.length > 0 && isRefreshed" class="grid gap-4 grid-user-card px-2">
|
<ProgressiveCardGrid
|
||||||
|
v-if="allUsers.length > 0 && isRefreshed"
|
||||||
|
:items="allUsers"
|
||||||
|
:min-item-width="288"
|
||||||
|
:estimated-item-height="260"
|
||||||
|
:get-item-key="user => user.id"
|
||||||
|
class="px-2"
|
||||||
|
>
|
||||||
<!-- 普通用户卡片 -->
|
<!-- 普通用户卡片 -->
|
||||||
<UserCard
|
<template #default="{ item }">
|
||||||
v-for="user in allUsers"
|
<UserCard :user="item" :users="allUsers" @remove="loadAllUsers" @save="loadAllUsers" />
|
||||||
:key="user.id"
|
</template>
|
||||||
:user="user"
|
</ProgressiveCardGrid>
|
||||||
:users="allUsers"
|
|
||||||
@remove="loadAllUsers"
|
|
||||||
@save="loadAllUsers"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 无数据提示 -->
|
<!-- 无数据提示 -->
|
||||||
<div v-if="allUsers.length === 0 && isRefreshed">
|
<div v-if="allUsers.length === 0 && isRefreshed">
|
||||||
|
|||||||
Reference in New Issue
Block a user