mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-12 17:14:37 +08:00
perf(dashboard): restore additional cards from snapshots (#574)
This commit is contained in:
@@ -2,18 +2,25 @@
|
||||
import api from '@/api'
|
||||
import type { MediaStatistic } from '@/api/types'
|
||||
import { formatDashboardCount, useAnimatedDashboardNumber } from '@/composables/useDashboardMotion'
|
||||
import { useDashboardSnapshot } from '@/composables/useDashboardSnapshot'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
const movieCount = ref(0)
|
||||
const tvCount = ref(0)
|
||||
const episodeCount = ref<number | null>(null)
|
||||
const userCount = ref(0)
|
||||
const movieCountMonth = ref(0)
|
||||
const tvCountMonth = ref(0)
|
||||
const episodeCountMonth = ref(0)
|
||||
const { readSnapshot, writeSnapshot } = useDashboardSnapshot<MediaStatistic>('media-statistic-v1')
|
||||
const currentSnapshot = readSnapshot()
|
||||
|
||||
const movieCount = ref(Number(currentSnapshot?.value.movie_count) || 0)
|
||||
const tvCount = ref(Number(currentSnapshot?.value.tv_count) || 0)
|
||||
const episodeCount = ref<number | null>(
|
||||
currentSnapshot?.value.episode_count == null ? null : Number(currentSnapshot.value.episode_count) || 0,
|
||||
)
|
||||
const userCount = ref(Number(currentSnapshot?.value.user_count) || 0)
|
||||
const movieCountMonth = ref(Number(currentSnapshot?.value.movie_count_month) || 0)
|
||||
const tvCountMonth = ref(Number(currentSnapshot?.value.tv_count_month) || 0)
|
||||
const episodeCountMonth = ref(Number(currentSnapshot?.value.episode_count_month) || 0)
|
||||
let statisticLoadId = 0
|
||||
|
||||
const animatedMovieCount = useAnimatedDashboardNumber(movieCount, {
|
||||
duration: 720,
|
||||
@@ -24,10 +31,13 @@ const animatedTvCount = useAnimatedDashboardNumber(tvCount, {
|
||||
duration: 720,
|
||||
})
|
||||
|
||||
const animatedEpisodeCount = useAnimatedDashboardNumber(computed(() => episodeCount.value ?? 0), {
|
||||
delay: 120,
|
||||
duration: 720,
|
||||
})
|
||||
const animatedEpisodeCount = useAnimatedDashboardNumber(
|
||||
computed(() => episodeCount.value ?? 0),
|
||||
{
|
||||
delay: 120,
|
||||
duration: 720,
|
||||
},
|
||||
)
|
||||
|
||||
const animatedUserCount = useAnimatedDashboardNumber(userCount, {
|
||||
delay: 180,
|
||||
@@ -67,16 +77,29 @@ const statistics = computed(() => [
|
||||
|
||||
// 调用API加载媒体统计数据
|
||||
async function loadMediaStatistic() {
|
||||
const loadId = ++statisticLoadId
|
||||
try {
|
||||
const res: MediaStatistic = await api.get('dashboard/statistic')
|
||||
if (loadId !== statisticLoadId) return
|
||||
|
||||
movieCount.value = Number(res.movie_count) || 0
|
||||
tvCount.value = Number(res.tv_count) || 0
|
||||
episodeCount.value = res.episode_count == null ? null : Number(res.episode_count) || 0
|
||||
userCount.value = Number(res.user_count) || 0
|
||||
movieCountMonth.value = Number(res.movie_count_month) || 0
|
||||
tvCountMonth.value = Number(res.tv_count_month) || 0
|
||||
episodeCountMonth.value = Number(res.episode_count_month) || 0
|
||||
const statistic: MediaStatistic = {
|
||||
movie_count: Number(res.movie_count) || 0,
|
||||
tv_count: Number(res.tv_count) || 0,
|
||||
episode_count: res.episode_count == null ? null : Number(res.episode_count) || 0,
|
||||
user_count: Number(res.user_count) || 0,
|
||||
movie_count_month: Number(res.movie_count_month) || 0,
|
||||
tv_count_month: Number(res.tv_count_month) || 0,
|
||||
episode_count_month: Number(res.episode_count_month) || 0,
|
||||
}
|
||||
|
||||
movieCount.value = statistic.movie_count
|
||||
tvCount.value = statistic.tv_count
|
||||
episodeCount.value = statistic.episode_count
|
||||
userCount.value = statistic.user_count
|
||||
movieCountMonth.value = statistic.movie_count_month
|
||||
tvCountMonth.value = statistic.tv_count_month
|
||||
episodeCountMonth.value = statistic.episode_count_month
|
||||
writeSnapshot(statistic)
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api'
|
||||
import type { MediaInfo } from '@/api/types'
|
||||
import { useDashboardSnapshot } from '@/composables/useDashboardSnapshot'
|
||||
import { getMediaSubscribeId } from '@/composables/useMediaSubscribe'
|
||||
import { useGlobalSettingsStore } from '@/stores'
|
||||
import { getDisplayImageUrl } from '@/utils/imageUtils'
|
||||
@@ -25,10 +26,16 @@ const selectedSourcePath = ref(
|
||||
? storedSourcePath
|
||||
: sources.value[0].apipath,
|
||||
)
|
||||
const mediaItems = shallowRef<MediaInfo[]>([])
|
||||
const mediaCache = new Map<string, MediaInfo[]>()
|
||||
const mediaSnapshots = new Map(
|
||||
sources.value.map(source => [
|
||||
source.apipath,
|
||||
useDashboardSnapshot<MediaInfo[]>(`media-recommend-v1:${source.apipath}`),
|
||||
]),
|
||||
)
|
||||
const initialSnapshot = mediaSnapshots.get(selectedSourcePath.value)?.readSnapshot()
|
||||
const mediaItems = shallowRef<MediaInfo[]>(initialSnapshot?.value ?? [])
|
||||
const activeIndex = ref(0)
|
||||
const loading = ref(true)
|
||||
const loading = ref(!initialSnapshot)
|
||||
const loadFailed = ref(false)
|
||||
const isHovered = ref(false)
|
||||
const isFocusWithin = ref(false)
|
||||
@@ -76,34 +83,36 @@ function getMediaKey(item: MediaInfo) {
|
||||
return getMediaSubscribeId(item)
|
||||
}
|
||||
|
||||
/** 加载指定推荐来源,并缓存当前会话已获取的数据。 */
|
||||
/** 加载指定推荐来源,持久快照只负责立即恢复,随后仍以成功响应更新内容。 */
|
||||
async function loadMedia(sourcePath = selectedSourcePath.value) {
|
||||
const currentRequestId = ++requestId
|
||||
const cachedItems = mediaCache.get(sourcePath)
|
||||
const cachedItems = mediaSnapshots.get(sourcePath)?.readSnapshot()?.value
|
||||
|
||||
if (cachedItems) {
|
||||
mediaItems.value = cachedItems
|
||||
activeIndex.value = 0
|
||||
loading.value = false
|
||||
loadFailed.value = false
|
||||
resumeAutoplayIfReady()
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
loading.value = !cachedItems
|
||||
loadFailed.value = false
|
||||
try {
|
||||
const response = await api.get(sourcePath)
|
||||
if (currentRequestId !== requestId) return
|
||||
|
||||
const items = normalizeMediaResponse(response).filter(isUsableMedia).slice(0, RECOMMEND_SLIDE_COUNT)
|
||||
mediaCache.set(sourcePath, items)
|
||||
mediaSnapshots.get(sourcePath)?.writeSnapshot(items)
|
||||
mediaItems.value = items
|
||||
activeIndex.value = 0
|
||||
} catch (error) {
|
||||
if (currentRequestId !== requestId) return
|
||||
console.error(error)
|
||||
mediaItems.value = []
|
||||
loadFailed.value = true
|
||||
if (!cachedItems) {
|
||||
mediaItems.value = []
|
||||
loadFailed.value = true
|
||||
}
|
||||
} finally {
|
||||
if (currentRequestId === requestId) {
|
||||
loading.value = false
|
||||
@@ -477,7 +486,9 @@ onBeforeUnmount(() => {
|
||||
font-weight: 750;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.15;
|
||||
text-shadow: 0 3px 20px rgba(0, 0, 0, 0.82), 0 1px 2px rgba(0, 0, 0, 0.72);
|
||||
text-shadow:
|
||||
0 3px 20px rgba(0, 0, 0, 0.82),
|
||||
0 1px 2px rgba(0, 0, 0, 0.72);
|
||||
}
|
||||
|
||||
.dashboard-recommend-meta {
|
||||
@@ -545,7 +556,9 @@ onBeforeUnmount(() => {
|
||||
cursor: pointer;
|
||||
inline-size: 54px;
|
||||
padding: 0;
|
||||
transition: background-color 0.2s ease, inline-size 0.2s ease;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
inline-size 0.2s ease;
|
||||
}
|
||||
|
||||
.dashboard-recommend-page.is-active {
|
||||
@@ -569,7 +582,9 @@ onBeforeUnmount(() => {
|
||||
.dashboard-recommend-arrow {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
|
||||
.dashboard-recommend-topbar {
|
||||
|
||||
146
src/views/dashboard/__tests__/AnalyticsMediaStatistic.spec.ts
Normal file
146
src/views/dashboard/__tests__/AnalyticsMediaStatistic.spec.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import api from '@/api'
|
||||
import AnalyticsMediaStatistic from '@/views/dashboard/AnalyticsMediaStatistic.vue'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { screen, waitFor } from '@testing-library/vue'
|
||||
import { defineComponent } from 'vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/api', () => ({ default: { get: vi.fn() } }))
|
||||
vi.mock('@/composables/useDashboardMotion', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useDashboardMotion')>()
|
||||
return {
|
||||
...actual,
|
||||
useAnimatedDashboardNumber: (source: { value: number }) => source,
|
||||
}
|
||||
})
|
||||
|
||||
const apiGet = vi.mocked(api.get)
|
||||
const snapshotKey = 'MP_DASHBOARD_SNAPSHOT_V1:7:media-statistic-v1'
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>(resolver => {
|
||||
resolve = resolver
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('AnalyticsMediaStatistic', () => {
|
||||
it('restores the last successful statistic before F5 revalidation completes', async () => {
|
||||
localStorage.setItem(
|
||||
snapshotKey,
|
||||
JSON.stringify({
|
||||
savedAt: Date.now(),
|
||||
value: {
|
||||
movie_count: 12,
|
||||
tv_count: 34,
|
||||
episode_count: 56,
|
||||
user_count: 7,
|
||||
movie_count_month: 1,
|
||||
tv_count_month: 2,
|
||||
episode_count_month: 3,
|
||||
},
|
||||
}),
|
||||
)
|
||||
const request = deferred<Record<string, number>>()
|
||||
apiGet.mockReturnValue(request.promise)
|
||||
|
||||
await renderWithProviders(AnalyticsMediaStatistic, { initialState: { user: { userID: 7 } } })
|
||||
|
||||
expect(screen.getByText('12')).toBeInTheDocument()
|
||||
expect(screen.getByText('34')).toBeInTheDocument()
|
||||
expect(screen.getByText('56')).toBeInTheDocument()
|
||||
expect(screen.getByText('7')).toBeInTheDocument()
|
||||
expect(apiGet).toHaveBeenCalledWith('dashboard/statistic')
|
||||
|
||||
request.resolve({
|
||||
movie_count: 21,
|
||||
tv_count: 43,
|
||||
episode_count: 65,
|
||||
user_count: 8,
|
||||
movie_count_month: 4,
|
||||
tv_count_month: 5,
|
||||
episode_count_month: 6,
|
||||
})
|
||||
|
||||
await waitFor(() => expect(screen.getByText('21')).toBeInTheDocument())
|
||||
expect(JSON.parse(localStorage.getItem(snapshotKey) ?? '{}').value).toMatchObject({
|
||||
movie_count: 21,
|
||||
tv_count: 43,
|
||||
episode_count: 65,
|
||||
user_count: 8,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the restored statistic when revalidation fails', async () => {
|
||||
localStorage.setItem(
|
||||
snapshotKey,
|
||||
JSON.stringify({
|
||||
savedAt: Date.now(),
|
||||
value: {
|
||||
movie_count: 12,
|
||||
tv_count: 34,
|
||||
episode_count: null,
|
||||
user_count: 7,
|
||||
movie_count_month: 1,
|
||||
tv_count_month: 2,
|
||||
episode_count_month: 3,
|
||||
},
|
||||
}),
|
||||
)
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
apiGet.mockRejectedValue(new Error('remote unavailable'))
|
||||
|
||||
await renderWithProviders(AnalyticsMediaStatistic, { initialState: { user: { userID: 7 } } })
|
||||
|
||||
await waitFor(() => expect(apiGet).toHaveBeenCalledWith('dashboard/statistic'))
|
||||
expect(screen.getByText('12')).toBeInTheDocument()
|
||||
expect(screen.getByText('34')).toBeInTheDocument()
|
||||
expect(screen.getByText('未获取')).toBeInTheDocument()
|
||||
expect(screen.getByText('7')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the newest KeepAlive response when initial refreshes finish out of order', async () => {
|
||||
const firstRequest = deferred<Record<string, number>>()
|
||||
const secondRequest = deferred<Record<string, number>>()
|
||||
apiGet.mockReturnValueOnce(firstRequest.promise).mockReturnValueOnce(secondRequest.promise)
|
||||
const KeepAliveHarness = defineComponent({
|
||||
components: { AnalyticsMediaStatistic },
|
||||
template: '<KeepAlive><AnalyticsMediaStatistic /></KeepAlive>',
|
||||
})
|
||||
|
||||
await renderWithProviders(KeepAliveHarness, { initialState: { user: { userID: 7 } } })
|
||||
await waitFor(() => expect(apiGet).toHaveBeenCalledTimes(2))
|
||||
|
||||
secondRequest.resolve({
|
||||
movie_count: 21,
|
||||
tv_count: 43,
|
||||
episode_count: 65,
|
||||
user_count: 8,
|
||||
movie_count_month: 4,
|
||||
tv_count_month: 5,
|
||||
episode_count_month: 6,
|
||||
})
|
||||
await waitFor(() => expect(screen.getByText('21')).toBeInTheDocument())
|
||||
|
||||
firstRequest.resolve({
|
||||
movie_count: 12,
|
||||
tv_count: 34,
|
||||
episode_count: 56,
|
||||
user_count: 7,
|
||||
movie_count_month: 1,
|
||||
tv_count_month: 2,
|
||||
episode_count_month: 3,
|
||||
})
|
||||
await Promise.resolve()
|
||||
|
||||
expect(screen.getByText('21')).toBeInTheDocument()
|
||||
expect(screen.queryByText('12')).not.toBeInTheDocument()
|
||||
expect(JSON.parse(localStorage.getItem(snapshotKey) ?? '{}').value).toMatchObject({
|
||||
movie_count: 21,
|
||||
tv_count: 43,
|
||||
episode_count: 65,
|
||||
user_count: 8,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -20,20 +20,16 @@ function getSourceMenuButton() {
|
||||
|
||||
async function renderMediaRecommend(
|
||||
response: unknown,
|
||||
options: { sourcePath?: string; status?: number; onRequest?: () => void } = {},
|
||||
options: { sourcePath?: string; status?: number; onRequest?: () => void; userID?: number } = {},
|
||||
) {
|
||||
const sourcePath = options.sourcePath ?? DEFAULT_SOURCE
|
||||
server.use(
|
||||
recommendMediaHandler(
|
||||
sourcePath,
|
||||
response as Record<string, unknown>,
|
||||
options.status ?? 200,
|
||||
options.onRequest,
|
||||
),
|
||||
recommendMediaHandler(sourcePath, response as Record<string, unknown>, options.status ?? 200, options.onRequest),
|
||||
)
|
||||
return renderWithProviders(MediaRecommend, {
|
||||
initialRoute: '/dashboard',
|
||||
initialState: {
|
||||
user: { userID: options.userID ?? -1 },
|
||||
globalSettings: {
|
||||
data: { GLOBAL_IMAGE_CACHE: false },
|
||||
initialized: true,
|
||||
@@ -107,14 +103,15 @@ describe('MediaRecommend', () => {
|
||||
expect(localStorage.getItem('MP_DASHBOARD_RECOMMEND_SOURCE')).toBe(DEFAULT_SOURCE)
|
||||
})
|
||||
|
||||
it('switches sources, persists the choice, and reuses the session cache', async () => {
|
||||
it('switches sources, persists the choice, and revalidates restored snapshots', async () => {
|
||||
const user = userEvent.setup()
|
||||
const trendingRequested = vi.fn()
|
||||
const moviesRequested = vi.fn()
|
||||
server.use(
|
||||
recommendMediaHandler(MOVIE_SOURCE, [createMediaInfo({ title: '热门电影内容' })], 200, moviesRequested),
|
||||
)
|
||||
await renderMediaRecommend([createMediaInfo({ title: '趋势内容' })], { onRequest: trendingRequested })
|
||||
server.use(recommendMediaHandler(MOVIE_SOURCE, [createMediaInfo({ title: '热门电影内容' })], 200, moviesRequested))
|
||||
await renderMediaRecommend([createMediaInfo({ title: '趋势内容' })], {
|
||||
onRequest: trendingRequested,
|
||||
userID: 7,
|
||||
})
|
||||
await waitFor(() => expect(trendingRequested).toHaveBeenCalledOnce())
|
||||
|
||||
await user.click(getSourceMenuButton())
|
||||
@@ -126,7 +123,42 @@ describe('MediaRecommend', () => {
|
||||
await user.click(getSourceMenuButton())
|
||||
await user.click(await screen.findByText('流行趋势'))
|
||||
expect(await screen.findByText('趋势内容')).toBeInTheDocument()
|
||||
expect(trendingRequested).toHaveBeenCalledOnce()
|
||||
await waitFor(() => expect(trendingRequested).toHaveBeenCalledTimes(2))
|
||||
})
|
||||
|
||||
it('restores the selected source snapshot before F5 revalidation completes', async () => {
|
||||
const renderOptions = {
|
||||
initialRoute: '/dashboard',
|
||||
initialState: {
|
||||
user: { userID: 7 },
|
||||
globalSettings: {
|
||||
data: { GLOBAL_IMAGE_CACHE: false },
|
||||
initialized: true,
|
||||
loading: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
const first = await renderMediaRecommend([createMediaInfo({ title: '快照推荐' })], { userID: 7 })
|
||||
await screen.findByText('快照推荐')
|
||||
first.unmount()
|
||||
|
||||
let resolveRequest: ((response: Response) => void) | undefined
|
||||
const requested = vi.fn()
|
||||
server.use(
|
||||
http.get(recommendApiUrls.media(DEFAULT_SOURCE), () => {
|
||||
requested()
|
||||
return new Promise<Response>(resolve => {
|
||||
resolveRequest = resolve
|
||||
})
|
||||
}),
|
||||
)
|
||||
const second = await renderWithProviders(MediaRecommend, renderOptions)
|
||||
|
||||
expect(await screen.findByText('快照推荐')).toBeInTheDocument()
|
||||
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
|
||||
resolveRequest?.(HttpResponse.json([createMediaInfo({ title: '刷新推荐' })]))
|
||||
expect(await screen.findByText('刷新推荐')).toBeInTheDocument()
|
||||
second.unmount()
|
||||
})
|
||||
|
||||
it('supports arrows, pagination, touch gestures, and detail routes', async () => {
|
||||
@@ -254,9 +286,13 @@ describe('MediaRecommend', () => {
|
||||
it('does not restart autoplay when deactivated before the initial request settles', async () => {
|
||||
let resolveRequest: ((response: Response) => void) | undefined
|
||||
server.use(
|
||||
http.get(recommendApiUrls.media(DEFAULT_SOURCE), () => new Promise<Response>(resolve => {
|
||||
resolveRequest = resolve
|
||||
})),
|
||||
http.get(
|
||||
recommendApiUrls.media(DEFAULT_SOURCE),
|
||||
() =>
|
||||
new Promise<Response>(resolve => {
|
||||
resolveRequest = resolve
|
||||
}),
|
||||
),
|
||||
)
|
||||
const KeepAliveHarness = defineComponent({
|
||||
components: { MediaRecommend },
|
||||
@@ -300,9 +336,13 @@ describe('MediaRecommend', () => {
|
||||
let resolveMovies: ((response: Response) => void) | undefined
|
||||
server.use(
|
||||
recommendMediaHandler(DEFAULT_SOURCE, [createMediaInfo({ title: '初始推荐' })]),
|
||||
http.get(recommendApiUrls.media(MOVIE_SOURCE), () => new Promise<Response>(resolve => {
|
||||
resolveMovies = resolve
|
||||
})),
|
||||
http.get(
|
||||
recommendApiUrls.media(MOVIE_SOURCE),
|
||||
() =>
|
||||
new Promise<Response>(resolve => {
|
||||
resolveMovies = resolve
|
||||
}),
|
||||
),
|
||||
)
|
||||
const KeepAliveHarness = defineComponent({
|
||||
components: { MediaRecommend },
|
||||
@@ -363,14 +403,18 @@ describe('MediaRecommend', () => {
|
||||
})
|
||||
|
||||
it('invalidates a pending request when switching back to a cached source', async () => {
|
||||
await renderMediaRecommend([createMediaInfo({ title: '初始结果' })])
|
||||
await renderMediaRecommend([createMediaInfo({ title: '初始结果' })], { userID: 7 })
|
||||
expect(await screen.findByText('初始结果')).toBeInTheDocument()
|
||||
|
||||
let resolveMovies: ((response: Response) => void) | undefined
|
||||
server.use(
|
||||
http.get(recommendApiUrls.media(MOVIE_SOURCE), () => new Promise<Response>(resolve => {
|
||||
resolveMovies = resolve
|
||||
})),
|
||||
http.get(
|
||||
recommendApiUrls.media(MOVIE_SOURCE),
|
||||
() =>
|
||||
new Promise<Response>(resolve => {
|
||||
resolveMovies = resolve
|
||||
}),
|
||||
),
|
||||
)
|
||||
const user = userEvent.setup()
|
||||
await user.click(getSourceMenuButton())
|
||||
|
||||
Reference in New Issue
Block a user