perf(dashboard): restore additional cards from snapshots (#574)

This commit is contained in:
InfinityPacer
2026-07-23 06:03:42 +08:00
committed by GitHub
parent b32a4e7c6a
commit 4a196731f1
4 changed files with 282 additions and 54 deletions

View File

@@ -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)
}

View File

@@ -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 {

View 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,
})
})
})

View File

@@ -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())