mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-10 16:13:28 +08:00
perf(list): prefetch infinite scroll pages (#622)
This commit is contained in:
@@ -147,7 +147,14 @@ async function fetchData({ done }: { done: (status: 'empty' | 'error' | 'loading
|
||||
|
||||
<template>
|
||||
<LoadingBanner v-if="!isRefreshed && !loadFailed" class="mt-12" />
|
||||
<VInfiniteScroll mode="intersect" side="end" :items="dataList" class="overflow-visible pt-3 px-2" @load="fetchData">
|
||||
<VInfiniteScroll
|
||||
mode="intersect"
|
||||
side="end"
|
||||
:items="dataList"
|
||||
:margin="dataList.length > 0 ? 600 : 0"
|
||||
class="overflow-visible pt-3 px-2"
|
||||
@load="fetchData"
|
||||
>
|
||||
<template #loading />
|
||||
<template #empty />
|
||||
<template #error="{ props: retryProps }">
|
||||
|
||||
@@ -29,6 +29,9 @@ const loading = ref(false)
|
||||
// 是否加载完成
|
||||
const isRefreshed = ref(false)
|
||||
|
||||
// 首次成功响应前,请求失败只展示错误和重试入口。
|
||||
const loadFailed = ref(false)
|
||||
|
||||
// 使用 shallowRef 避免长列表中的深层代理开销
|
||||
const dataList = shallowRef<Person[]>([])
|
||||
|
||||
@@ -56,6 +59,7 @@ function getParams() {
|
||||
async function fetchData({ done }: { done: any }) {
|
||||
try {
|
||||
if (!props.apipath) return
|
||||
loadFailed.value = false
|
||||
|
||||
// 如果正在加载中,直接返回
|
||||
if (loading.value) {
|
||||
@@ -71,8 +75,6 @@ async function fetchData({ done }: { done: any }) {
|
||||
loading.value = true
|
||||
// 请求API
|
||||
const currentData = await loadPageData()
|
||||
// 取消加载中
|
||||
loading.value = false
|
||||
// 标计为已请求完成
|
||||
isRefreshed.value = true
|
||||
if (currentData.length === 0) {
|
||||
@@ -108,22 +110,38 @@ async function fetchData({ done }: { done: any }) {
|
||||
// 返回加载成功
|
||||
done('ok')
|
||||
}
|
||||
// 取消加载中
|
||||
loading.value = false
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
loadFailed.value = true
|
||||
// 返回加载失败
|
||||
done('error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
|
||||
<VInfiniteScroll mode="intersect" side="end" :items="dataList" class="overflow-visible px-3" @load="fetchData">
|
||||
<LoadingBanner v-if="!isRefreshed && !loadFailed" class="mt-12" />
|
||||
<VInfiniteScroll
|
||||
mode="intersect"
|
||||
side="end"
|
||||
:items="dataList"
|
||||
:margin="dataList.length > 0 ? 600 : 0"
|
||||
class="overflow-visible px-3"
|
||||
@load="fetchData"
|
||||
>
|
||||
<template #loading />
|
||||
<template #empty />
|
||||
<template #error="{ props: retryProps }">
|
||||
<div class="d-flex flex-column align-center ga-2 py-4" role="alert">
|
||||
<span class="text-body-2 text-medium-emphasis">{{ t('error.networkError') }}</span>
|
||||
<VBtn v-bind="retryProps" prepend-icon="mdi-refresh" size="small" variant="tonal">
|
||||
{{ t('common.retry') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</template>
|
||||
<ProgressiveCardGrid
|
||||
v-if="dataList.length > 0"
|
||||
:items="dataList"
|
||||
|
||||
@@ -14,6 +14,7 @@ const LIST_PATH = 'test/discover/list'
|
||||
const LIST_URL = new URL(LIST_PATH, API_BASE_URL).href
|
||||
|
||||
type InfiniteScrollStatus = 'empty' | 'error' | 'ok'
|
||||
const initialLoadMargins: number[] = []
|
||||
|
||||
const InfiniteScrollStub = defineComponent({
|
||||
name: 'VInfiniteScroll',
|
||||
@@ -22,12 +23,17 @@ const InfiniteScrollStub = defineComponent({
|
||||
type: Array as PropType<MediaInfo[]>,
|
||||
default: () => [],
|
||||
},
|
||||
margin: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ['load'],
|
||||
setup(_props, { emit, slots }) {
|
||||
setup(props, { emit, slots }) {
|
||||
const status = ref<'empty' | 'error' | 'idle' | 'loading'>('idle')
|
||||
|
||||
function load() {
|
||||
initialLoadMargins.push(props.margin)
|
||||
status.value = 'loading'
|
||||
emit('load', {
|
||||
done(nextStatus: InfiniteScrollStatus) {
|
||||
@@ -39,7 +45,7 @@ const InfiniteScrollStub = defineComponent({
|
||||
onMounted(load)
|
||||
|
||||
return () =>
|
||||
h('section', { 'aria-label': '媒体无限列表' }, [
|
||||
h('section', { 'aria-label': '媒体无限列表', 'data-margin': String(props.margin) }, [
|
||||
h('output', { 'aria-label': '媒体无限列表状态' }, status.value),
|
||||
status.value === 'error'
|
||||
? slots.error?.({
|
||||
@@ -162,6 +168,7 @@ function gridKeys() {
|
||||
|
||||
describe('MediaCardListView', () => {
|
||||
beforeEach(() => {
|
||||
initialLoadMargins.length = 0
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
@@ -178,6 +185,8 @@ describe('MediaCardListView', () => {
|
||||
await renderList({ params: { genre: '科幻', page: '99' } })
|
||||
|
||||
expect(await screen.findByRole('article', { name: '媒体卡片 内部页码结果' })).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('媒体无限列表')).toHaveAttribute('data-margin', '600')
|
||||
expect(initialLoadMargins[0]).toBe(0)
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0].searchParams.get('page')).toBe('1')
|
||||
expect(requests[0].searchParams.get('genre')).toBe('科幻')
|
||||
|
||||
146
src/views/discover/__tests__/PersonCardListView.spec.ts
Normal file
146
src/views/discover/__tests__/PersonCardListView.spec.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import type { Person } from '@/api/types'
|
||||
import PersonCardListView from '@/views/discover/PersonCardListView.vue'
|
||||
import { screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { server } from '@tests/support/msw/server'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { HttpResponse, http } from 'msw'
|
||||
import { defineComponent, h, onMounted, ref, type PropType } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const LIST_PATH = 'test/discover/people'
|
||||
const LIST_URL = new URL(LIST_PATH, 'http://localhost/api/v1/').href
|
||||
|
||||
type InfiniteScrollStatus = 'empty' | 'error' | 'ok'
|
||||
const initialLoadMargins: number[] = []
|
||||
|
||||
const InfiniteScrollStub = defineComponent({
|
||||
name: 'VInfiniteScroll',
|
||||
props: {
|
||||
margin: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ['load'],
|
||||
setup(props, { emit, slots }) {
|
||||
const status = ref<'empty' | 'error' | 'idle' | 'loading'>('idle')
|
||||
|
||||
function load() {
|
||||
initialLoadMargins.push(props.margin)
|
||||
status.value = 'loading'
|
||||
emit('load', {
|
||||
done(nextStatus: InfiniteScrollStatus) {
|
||||
status.value = nextStatus === 'ok' ? 'idle' : nextStatus
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
|
||||
return () =>
|
||||
h('section', { 'aria-label': '人物无限列表', 'data-margin': String(props.margin) }, [
|
||||
h('output', { 'aria-label': '人物无限列表状态' }, status.value),
|
||||
status.value === 'error'
|
||||
? slots.error?.({
|
||||
side: 'end',
|
||||
props: { color: undefined, onClick: load },
|
||||
})
|
||||
: null,
|
||||
slots.default?.(),
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
const ProgressiveCardGridStub = defineComponent({
|
||||
name: 'ProgressiveCardGrid',
|
||||
props: {
|
||||
items: {
|
||||
type: Array as PropType<Person[]>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props, { slots }) {
|
||||
return () =>
|
||||
h(
|
||||
'section',
|
||||
props.items.flatMap(item => slots.default?.({ item }) ?? []),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const PersonCardStub = defineComponent({
|
||||
name: 'PersonCard',
|
||||
props: {
|
||||
person: {
|
||||
type: Object as PropType<Person>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
return () => h('article', props.person.name)
|
||||
},
|
||||
})
|
||||
|
||||
const LoadingBannerStub = defineComponent({
|
||||
name: 'LoadingBanner',
|
||||
template: '<div role="status">正在加载人物列表</div>',
|
||||
})
|
||||
|
||||
async function renderList() {
|
||||
return renderWithProviders(PersonCardListView, {
|
||||
props: { apipath: LIST_PATH },
|
||||
global: {
|
||||
stubs: {
|
||||
LoadingBanner: LoadingBannerStub,
|
||||
NoDataFound: true,
|
||||
PersonCard: PersonCardStub,
|
||||
ProgressiveCardGrid: ProgressiveCardGridStub,
|
||||
VInfiniteScroll: InfiniteScrollStub,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('PersonCardListView', () => {
|
||||
beforeEach(() => {
|
||||
initialLoadMargins.length = 0
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(600)
|
||||
vi.spyOn(document.body, 'scrollHeight', 'get').mockReturnValue(900)
|
||||
})
|
||||
|
||||
it('loads people with the configured prefetch margin', async () => {
|
||||
server.use(
|
||||
http.get(LIST_URL, () =>
|
||||
HttpResponse.json([{ id: 101, name: '探索人物', source: 'themoviedb' } satisfies Person]),
|
||||
),
|
||||
)
|
||||
|
||||
await renderList()
|
||||
|
||||
expect(await screen.findByText('探索人物')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('人物无限列表')).toHaveAttribute('data-margin', '600')
|
||||
expect(initialLoadMargins[0]).toBe(0)
|
||||
})
|
||||
|
||||
it('shows an inline retry and retries the same page after a request failure', async () => {
|
||||
let requests = 0
|
||||
server.use(
|
||||
http.get(LIST_URL, () => {
|
||||
requests++
|
||||
if (requests === 1) return HttpResponse.json({ detail: 'failed' }, { status: 500 })
|
||||
return HttpResponse.json([{ id: 202, name: '人物重试结果', source: 'themoviedb' } satisfies Person])
|
||||
}),
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
|
||||
await renderList()
|
||||
const retry = await screen.findByRole('button', { name: '重试' })
|
||||
expect(screen.queryByText('正在加载人物列表')).not.toBeInTheDocument()
|
||||
await user.click(retry)
|
||||
|
||||
expect(await screen.findByText('人物重试结果')).toBeInTheDocument()
|
||||
expect(requests).toBe(2)
|
||||
})
|
||||
})
|
||||
@@ -1706,6 +1706,7 @@ onUnmounted(() => {
|
||||
mode="intersect"
|
||||
side="end"
|
||||
:items="mobileDataList"
|
||||
:margin="mobileDataList.length > 0 ? 280 : 0"
|
||||
class="transfer-history-mobile-scroll"
|
||||
@load="loadMobileHistory"
|
||||
>
|
||||
@@ -1715,6 +1716,14 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</template>
|
||||
<template #empty />
|
||||
<template #error="{ props: retryProps }">
|
||||
<div class="transfer-history-mobile-state d-flex flex-column ga-2" role="alert">
|
||||
<span class="text-body-2 text-medium-emphasis">{{ t('common.serverConnectionFailed') }}</span>
|
||||
<VBtn v-bind="retryProps" prepend-icon="mdi-refresh" size="small" variant="tonal">
|
||||
{{ t('common.retry') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ProgressiveCardGrid
|
||||
v-if="mobileDataList.length > 0"
|
||||
|
||||
@@ -129,21 +129,36 @@ type InfiniteStatus = 'empty' | 'error' | 'ok'
|
||||
|
||||
const InfiniteScrollStub = defineComponent({
|
||||
name: 'VInfiniteScroll',
|
||||
props: {
|
||||
margin: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ['load'],
|
||||
setup(_props, { emit, slots }) {
|
||||
setup(props, { emit, slots }) {
|
||||
const status = ref('idle')
|
||||
function load() {
|
||||
status.value = 'loading'
|
||||
emit('load', {
|
||||
done(nextStatus: InfiniteStatus) {
|
||||
status.value = nextStatus
|
||||
status.value = nextStatus === 'ok' ? 'idle' : nextStatus
|
||||
},
|
||||
})
|
||||
}
|
||||
return () =>
|
||||
h('section', { 'aria-label': '整理历史无限列表' }, [
|
||||
h('section', { 'aria-label': '整理历史无限列表', 'data-margin': String(props.margin) }, [
|
||||
h('output', { 'aria-label': '整理历史无限列表状态' }, status.value),
|
||||
status.value === 'loading' ? slots.loading?.({}) : null,
|
||||
status.value === 'error'
|
||||
? slots.error?.({
|
||||
side: 'end',
|
||||
props: { color: undefined, onClick: load },
|
||||
})
|
||||
: null,
|
||||
status.value === 'empty' ? slots.empty?.({}) : null,
|
||||
slots.default?.(),
|
||||
h('button', { onClick: load, type: 'button' }, '加载下一页'),
|
||||
status.value === 'idle' ? h('button', { onClick: load, type: 'button' }, '加载下一页') : null,
|
||||
])
|
||||
},
|
||||
})
|
||||
@@ -445,8 +460,10 @@ describe('TransferHistoryView', () => {
|
||||
|
||||
await renderHistory('/history?search=%E7%A7%BB%E5%8A%A8')
|
||||
|
||||
expect(screen.getByLabelText('整理历史无限列表')).toHaveAttribute('data-margin', '0')
|
||||
await fireEvent.click(screen.getByRole('button', { name: '加载下一页' }))
|
||||
expect(await screen.findByText('记录 25')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('整理历史无限列表')).toHaveAttribute('data-margin', '280')
|
||||
await fireEvent.click(screen.getByRole('button', { name: '加载下一页' }))
|
||||
expect(await screen.findByText('追加 4')).toBeInTheDocument()
|
||||
expect(document.querySelectorAll('[data-mobile-key="25"]')).toHaveLength(1)
|
||||
@@ -467,7 +484,7 @@ describe('TransferHistoryView', () => {
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: '加载下一页' }))
|
||||
await waitFor(() => expect(screen.getByRole('status', { name: '整理历史无限列表状态' })).toHaveTextContent('error'))
|
||||
await fireEvent.click(screen.getByRole('button', { name: '加载下一页' }))
|
||||
await fireEvent.click(screen.getByRole('button', { name: '重试' }))
|
||||
expect(await screen.findByText('重试结果')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@@ -309,6 +309,7 @@ async function fetchData({ done }: { done: (status: 'empty' | 'error' | 'ok') =>
|
||||
mode="intersect"
|
||||
side="end"
|
||||
:items="dataList"
|
||||
:margin="dataList.length > 0 ? 480 : 0"
|
||||
class="overflow-visible px-2"
|
||||
@load="fetchData"
|
||||
:key="currentKey"
|
||||
|
||||
@@ -269,6 +269,7 @@ function removeData(id: number) {
|
||||
mode="intersect"
|
||||
side="end"
|
||||
:items="dataList"
|
||||
:margin="dataList.length > 0 ? 480 : 0"
|
||||
class="overflow-visible px-2"
|
||||
@load="fetchData"
|
||||
:key="currentKey"
|
||||
|
||||
@@ -12,14 +12,22 @@ import { defineComponent, h, onMounted, ref, type PropType } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type InfiniteScrollStatus = 'empty' | 'error' | 'ok'
|
||||
const initialLoadMargins: number[] = []
|
||||
|
||||
const InfiniteScrollStub = defineComponent({
|
||||
name: 'VInfiniteScroll',
|
||||
props: {
|
||||
margin: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ['load'],
|
||||
setup(_props, { emit, slots }) {
|
||||
setup(props, { emit, slots }) {
|
||||
const status = ref<'empty' | 'error' | 'idle' | 'loading'>('idle')
|
||||
|
||||
function load() {
|
||||
initialLoadMargins.push(props.margin)
|
||||
status.value = 'loading'
|
||||
emit('load', {
|
||||
done(nextStatus: InfiniteScrollStatus) {
|
||||
@@ -31,7 +39,7 @@ const InfiniteScrollStub = defineComponent({
|
||||
onMounted(load)
|
||||
|
||||
return () =>
|
||||
h('section', { 'aria-label': '热门订阅无限列表' }, [
|
||||
h('section', { 'aria-label': '热门订阅无限列表', 'data-margin': String(props.margin) }, [
|
||||
h('output', { 'aria-label': '热门订阅无限列表状态' }, status.value),
|
||||
status.value === 'loading' ? slots.loading?.({}) : null,
|
||||
status.value === 'error'
|
||||
@@ -142,6 +150,7 @@ async function renderPopular(type: '电影' | '电视剧' = '电影') {
|
||||
|
||||
describe('SubscribePopularView', () => {
|
||||
beforeEach(() => {
|
||||
initialLoadMargins.length = 0
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
setHasScroll(true)
|
||||
})
|
||||
@@ -160,6 +169,8 @@ describe('SubscribePopularView', () => {
|
||||
await renderPopular(type)
|
||||
|
||||
expect(await screen.findByText(type === '电影' ? '默认热门电影' : '默认热门剧集')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('热门订阅无限列表')).toHaveAttribute('data-margin', '480')
|
||||
expect(initialLoadMargins[0]).toBe(0)
|
||||
expect(screen.getByText(type === '电影' ? '18' : '27')).toBeInTheDocument()
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0].searchParams.get('stype')).toBe(type)
|
||||
|
||||
@@ -12,14 +12,22 @@ import { defineComponent, h, onMounted, ref, type PropType } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type InfiniteScrollStatus = 'empty' | 'error' | 'ok'
|
||||
const initialLoadMargins: number[] = []
|
||||
|
||||
const InfiniteScrollStub = defineComponent({
|
||||
name: 'VInfiniteScroll',
|
||||
props: {
|
||||
margin: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ['load'],
|
||||
setup(_props, { emit, slots }) {
|
||||
setup(props, { emit, slots }) {
|
||||
const status = ref<'empty' | 'error' | 'idle' | 'loading'>('idle')
|
||||
|
||||
function load() {
|
||||
initialLoadMargins.push(props.margin)
|
||||
status.value = 'loading'
|
||||
emit('load', {
|
||||
done(nextStatus: InfiniteScrollStatus) {
|
||||
@@ -31,7 +39,7 @@ const InfiniteScrollStub = defineComponent({
|
||||
onMounted(load)
|
||||
|
||||
return () =>
|
||||
h('section', { 'aria-label': '订阅分享无限列表' }, [
|
||||
h('section', { 'aria-label': '订阅分享无限列表', 'data-margin': String(props.margin) }, [
|
||||
h('output', { 'aria-label': '订阅分享无限列表状态' }, status.value),
|
||||
status.value === 'loading' ? slots.loading?.({}) : null,
|
||||
status.value === 'error'
|
||||
@@ -162,6 +170,7 @@ async function renderShare(keyword = '') {
|
||||
|
||||
describe('SubscribeShareView', () => {
|
||||
beforeEach(() => {
|
||||
initialLoadMargins.length = 0
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
setHasScroll(true)
|
||||
})
|
||||
@@ -178,6 +187,8 @@ describe('SubscribeShareView', () => {
|
||||
await renderShare()
|
||||
|
||||
expect(await screen.findByText('默认分享卡片')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('订阅分享无限列表')).toHaveAttribute('data-margin', '480')
|
||||
expect(initialLoadMargins[0]).toBe(0)
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0].searchParams.get('page')).toBe('1')
|
||||
expect(requests[0].searchParams.get('count')).toBe('30')
|
||||
@@ -262,9 +273,9 @@ describe('SubscribeShareView', () => {
|
||||
const first = createSubscribeShare({ share_title: '未满屏分享第一页' })
|
||||
const second = createSubscribeShare({ share_title: '未满屏分享第二页' })
|
||||
const requestedPages: string[] = []
|
||||
const scrollHeight = vi.spyOn(document.body, 'scrollHeight', 'get').mockImplementation(() =>
|
||||
requestedPages.length >= 2 ? 900 : 500,
|
||||
)
|
||||
const scrollHeight = vi
|
||||
.spyOn(document.body, 'scrollHeight', 'get')
|
||||
.mockImplementation(() => (requestedPages.length >= 2 ? 900 : 500))
|
||||
server.use(
|
||||
http.get(subscribeApiUrls.shares, ({ request }) => {
|
||||
const page = new URL(request.url).searchParams.get('page') ?? ''
|
||||
|
||||
Reference in New Issue
Block a user