fix(dashboard): show media empty and retry states (#589)

This commit is contained in:
InfinityPacer
2026-07-25 12:57:28 +08:00
committed by GitHub
parent 48f8e8fc71
commit 76c524eb9b
11 changed files with 461 additions and 40 deletions

View File

@@ -0,0 +1,78 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const props = defineProps<{
/** 空结果使用的图标。 */
emptyIcon: string
/** 成功空结果对应的业务文案。 */
emptyText: string
/** 当前是否正在等待首次可用结果。 */
loading: boolean
/** 当前是否因请求失败而没有可展示的快照。 */
failed: boolean
/** 卡片标题。 */
title: string
}>()
defineSlots<{
/** 在标题栏右侧显示与当前状态相关的轻量操作。 */
append?: () => unknown
}>()
const { t } = useI18n()
</script>
<template>
<VCard class="dashboard-media-state dashboard-grid-fill">
<VCardItem class="dashboard-media-state-header">
<VCardTitle>{{ props.title }}</VCardTitle>
<template #append>
<slot name="append" />
</template>
</VCardItem>
<VCardText
class="dashboard-media-state-content text-medium-emphasis"
:role="props.failed ? 'alert' : 'status'"
aria-live="polite"
>
<template v-if="props.loading">
<VProgressCircular indeterminate color="primary" size="28" width="2" />
<span>{{ t('common.loading') }}</span>
</template>
<template v-else-if="props.failed">
<VIcon icon="mdi-server-network-off" color="warning" size="30" />
<span>{{ t('dashboard.mediaServerLoadFailed') }}</span>
</template>
<template v-else>
<VIcon :icon="props.emptyIcon" size="30" />
<span>{{ props.emptyText }}</span>
</template>
</VCardText>
</VCard>
</template>
<style scoped>
.dashboard-media-state {
display: flex;
flex-direction: column;
min-block-size: 10rem;
}
.dashboard-media-state-header {
padding-block-end: 0.25rem;
}
.dashboard-media-state-content {
display: flex;
flex: 1 1 auto;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 0.65rem;
min-block-size: 0;
text-align: center;
}
</style>

View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
const props = defineProps<{
/** 有可展示快照时,在支持悬停的桌面设备上延后到卡片交互时显示。 */
deferred?: boolean
/** 描述当前卡片重试目标的可访问文案。 */
label: string
}>()
const emit = defineEmits<{
/** 请求用户主动重新加载当前卡片。 */
retry: []
}>()
</script>
<template>
<VBtn
icon
variant="text"
color="warning"
size="small"
:class="{ 'dashboard-retry-button--deferred': props.deferred }"
:aria-label="props.label"
@click="emit('retry')"
>
<VIcon icon="mdi-cloud-alert-outline" size="20" />
<VTooltip activator="parent" location="top">
{{ props.label }}
</VTooltip>
</VBtn>
</template>
<style scoped>
@media (hover: none), (pointer: coarse) {
.dashboard-retry-button--deferred {
display: none;
}
}
@media (hover: hover) and (pointer: fine) {
.dashboard-retry-button--deferred {
opacity: 0;
pointer-events: none;
transition: opacity 180ms ease;
}
:global(.v-card:hover .dashboard-retry-button--deferred),
:global(.v-card:focus-within .dashboard-retry-button--deferred) {
opacity: 1;
pointer-events: auto;
}
}
@media (prefers-reduced-motion: reduce) {
.dashboard-retry-button--deferred {
transition: none;
}
}
</style>

View File

@@ -1017,6 +1017,11 @@ export default {
library: 'My Media Library',
playing: 'Continue Watching',
latest: 'Latest Imports',
noLatest: 'No recent imports',
noPlaying: 'No continue-watching items',
noLibrary: 'No media library data',
mediaServerLoadFailed: 'Failed to load media server data',
staleData: 'Refresh failed. Showing the last successful data.',
recentImports: 'Recent Transfers',
viewAll: 'View All',
settings: 'Dashboard Settings',

View File

@@ -1009,6 +1009,11 @@ export default {
library: '我的媒体库',
playing: '继续观看',
latest: '最新入库',
noLatest: '暂无最近入库记录',
noPlaying: '暂无继续观看记录',
noLibrary: '暂无媒体库数据',
mediaServerLoadFailed: '媒体服务器数据加载失败',
staleData: '刷新失败,当前显示上次数据',
recentImports: '近期整理',
viewAll: '查看全部',
settings: '设置仪表板',

View File

@@ -1009,6 +1009,11 @@ export default {
library: '我的媒體庫',
playing: '繼續觀看',
latest: '最新入庫',
noLatest: '暫無最近入庫記錄',
noPlaying: '暫無繼續觀看記錄',
noLibrary: '暫無媒體庫數據',
mediaServerLoadFailed: '媒體伺服器數據加載失敗',
staleData: '刷新失敗,當前顯示上次數據',
recentImports: '近期整理',
viewAll: '查看全部',
settings: '設置儀表板',

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import api from '@/api'
import type { MediaInfo } from '@/api/types'
import DashboardRetryButton from '@/components/misc/DashboardRetryButton.vue'
import { useDashboardSnapshot } from '@/composables/useDashboardSnapshot'
import { getMediaSubscribeId } from '@/composables/useMediaSubscribe'
import { useGlobalSettingsStore } from '@/stores'
@@ -34,6 +35,7 @@ const mediaSnapshots = new Map(
)
const initialSnapshot = mediaSnapshots.get(selectedSourcePath.value)?.readSnapshot()
const mediaItems = shallowRef<MediaInfo[]>(initialSnapshot?.value ?? [])
const hasSnapshot = ref(Boolean(initialSnapshot))
const activeIndex = ref(0)
const loading = ref(!initialSnapshot)
const loadFailed = ref(false)
@@ -90,12 +92,14 @@ async function loadMedia(sourcePath = selectedSourcePath.value) {
if (cachedItems) {
mediaItems.value = cachedItems
hasSnapshot.value = true
activeIndex.value = 0
loading.value = false
loadFailed.value = false
resumeAutoplayIfReady()
}
if (!cachedItems) hasSnapshot.value = false
loading.value = !cachedItems
loadFailed.value = false
try {
@@ -105,13 +109,14 @@ async function loadMedia(sourcePath = selectedSourcePath.value) {
const items = normalizeMediaResponse(response).filter(isUsableMedia).slice(0, RECOMMEND_SLIDE_COUNT)
mediaSnapshots.get(sourcePath)?.writeSnapshot(items)
mediaItems.value = items
hasSnapshot.value = true
activeIndex.value = 0
} catch (error) {
if (currentRequestId !== requestId) return
console.error(error)
loadFailed.value = true
if (!cachedItems) {
mediaItems.value = []
loadFailed.value = true
}
} finally {
if (currentRequestId === requestId) {
@@ -289,32 +294,35 @@ onBeforeUnmount(() => {
<span>{{ t('dashboard.recommendedMedia') }}</span>
</div>
<VMenu location="bottom end">
<template #activator="{ props: menuProps }">
<VBtn
v-bind="menuProps"
class="dashboard-recommend-source"
variant="tonal"
color="white"
rounded="pill"
append-icon="mdi-chevron-down"
:aria-label="t('dashboard.selectRecommendSource')"
>
<VIcon :icon="getSourceIcon(selectedSource)" color="primary" size="20" start />
<span class="dashboard-recommend-source-title">{{ selectedSource.title }}</span>
</VBtn>
</template>
<VList density="compact" max-height="360" :aria-label="t('dashboard.selectRecommendSource')">
<VListItem
v-for="source in sources"
:key="source.apipath"
:active="source.apipath === selectedSourcePath"
:prepend-icon="getSourceIcon(source)"
:title="source.title"
@click="selectSource(source)"
/>
</VList>
</VMenu>
<div class="dashboard-recommend-actions">
<DashboardRetryButton v-if="loadFailed" deferred :label="t('dashboard.staleData')" @retry="loadMedia()" />
<VMenu location="bottom end">
<template #activator="{ props: menuProps }">
<VBtn
v-bind="menuProps"
class="dashboard-recommend-source"
variant="tonal"
color="white"
rounded="pill"
append-icon="mdi-chevron-down"
:aria-label="t('dashboard.selectRecommendSource')"
>
<VIcon :icon="getSourceIcon(selectedSource)" color="primary" size="20" start />
<span class="dashboard-recommend-source-title">{{ selectedSource.title }}</span>
</VBtn>
</template>
<VList density="compact" max-height="360" :aria-label="t('dashboard.selectRecommendSource')">
<VListItem
v-for="source in sources"
:key="source.apipath"
:active="source.apipath === selectedSourcePath"
:prepend-icon="getSourceIcon(source)"
:title="source.title"
@click="selectSource(source)"
/>
</VList>
</VMenu>
</div>
</div>
<div
@@ -374,10 +382,23 @@ onBeforeUnmount(() => {
/>
</template>
<div v-else class="dashboard-recommend-empty">
<div
v-else
class="dashboard-recommend-empty"
:role="loadFailed && !hasSnapshot ? 'alert' : 'status'"
aria-live="polite"
>
<DashboardRetryButton
v-if="loadFailed"
class="dashboard-recommend-retry"
:deferred="hasSnapshot"
:label="hasSnapshot ? t('dashboard.staleData') : t('dashboard.recommendLoadFailed')"
@retry="loadMedia()"
/>
<VIcon icon="mdi-image-off-outline" size="38" />
<span>{{ loadFailed ? t('dashboard.recommendLoadFailed') : t('dashboard.noRecommendations') }}</span>
<VBtn v-if="loadFailed" variant="tonal" size="small" @click="loadMedia()">{{ t('common.retry') }}</VBtn>
<span>{{
loadFailed && !hasSnapshot ? t('dashboard.recommendLoadFailed') : t('dashboard.noRecommendations')
}}</span>
</div>
</VCard>
</template>
@@ -451,6 +472,13 @@ onBeforeUnmount(() => {
padding: 0.55rem 0.85rem;
}
.dashboard-recommend-actions {
display: flex;
align-items: center;
min-inline-size: 0;
gap: 0.35rem;
}
.dashboard-recommend-source {
max-inline-size: min(320px, 45vw);
background: rgba(8, 18, 28, 0.55) !important;
@@ -567,6 +595,7 @@ onBeforeUnmount(() => {
}
.dashboard-recommend-empty {
position: relative;
display: flex;
block-size: 100%;
align-items: center;
@@ -576,6 +605,12 @@ onBeforeUnmount(() => {
gap: 0.75rem;
}
.dashboard-recommend-retry {
position: absolute;
inset-block-start: 1rem;
inset-inline-end: 1rem;
}
@media (min-width: 741px) and (hover: hover) {
.dashboard-recommend-topbar,
.dashboard-recommend-detail,

View File

@@ -2,6 +2,8 @@
import api from '@/api'
import type { MediaServerConf, MediaServerPlayItem } from '@/api/types'
import PosterCard from '@/components/cards/PosterCard.vue'
import DashboardRetryButton from '@/components/misc/DashboardRetryButton.vue'
import DashboardMediaState from '@/components/misc/DashboardMediaState.vue'
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
import { useDashboardMediaGridCapacity } from '@/composables/useDashboardMediaGridCapacity'
import { useDashboardSnapshot } from '@/composables/useDashboardSnapshot'
@@ -22,6 +24,10 @@ const currentSnapshot = readSnapshot()
// 最近入库列表
const latestList = ref<{ [key: string]: MediaServerPlayItem[] }>(currentSnapshot?.value ?? {})
// 空结果同样是成功快照;刷新失败不能把已确认的空状态改写成首次加载失败。
const hasSnapshot = ref(currentSnapshot !== undefined)
const isLoading = ref(!currentSnapshot)
const loadFailed = ref(false)
// 所有媒体服务器设置
const mediaServers = ref<MediaServerConf[]>([])
@@ -84,8 +90,15 @@ async function loadData() {
if (count <= 0) return
const loadId = ++latestLoadId
if (!hasSnapshot.value) isLoading.value = true
if (!(await loadMediaServerSetting())) return
if (!(await loadMediaServerSetting())) {
if (loadId === latestLoadId) {
loadFailed.value = true
isLoading.value = false
}
return
}
if (loadId !== latestLoadId) return
const enabledServers = mediaServers.value.filter(server => server.enabled)
@@ -97,7 +110,11 @@ async function loadData() {
const nextLatestList: { [key: string]: MediaServerPlayItem[] } = {}
for (const [name, data] of entries) {
if (data === undefined) return
if (data === undefined) {
loadFailed.value = true
isLoading.value = false
return
}
if (data.length > 0) {
nextLatestList[name] = data.slice(0, count)
}
@@ -105,6 +122,9 @@ async function loadData() {
latestList.value = nextLatestList
writeSnapshot(nextLatestList)
hasSnapshot.value = true
loadFailed.value = false
isLoading.value = false
}
watch(latestItemCount, count => {
@@ -128,9 +148,29 @@ onActivated(() => {
class="dashboard-media-stack"
:class="{ 'dashboard-grid-fill': Object.keys(latestList).length > 0 }"
>
<DashboardMediaState
v-if="Object.keys(latestList).length === 0"
:title="t('dashboard.latest')"
:empty-text="t('dashboard.noLatest')"
empty-icon="mdi-movie-off-outline"
:loading="isLoading"
:failed="loadFailed && !hasSnapshot"
>
<template v-if="loadFailed" #append>
<DashboardRetryButton
:deferred="hasSnapshot"
:label="hasSnapshot ? t('dashboard.staleData') : t('dashboard.mediaServerLoadFailed')"
@retry="loadData"
/>
</template>
</DashboardMediaState>
<VCard v-for="(data, name) in latestList" :key="name" class="dashboard-work-card dashboard-media-card">
<VCardItem class="dashboard-media-header">
<VCardTitle>{{ t('dashboard.latest') }} - {{ name }}</VCardTitle>
<template v-if="loadFailed" #append>
<DashboardRetryButton deferred :label="t('dashboard.staleData')" @retry="loadData" />
</template>
</VCardItem>
<div class="dashboard-media-content px-5 pb-3">

View File

@@ -2,6 +2,8 @@
import api from '@/api'
import type { MediaServerConf, MediaServerLibrary } from '@/api/types'
import LibraryCard from '@/components/cards/LibraryCard.vue'
import DashboardRetryButton from '@/components/misc/DashboardRetryButton.vue'
import DashboardMediaState from '@/components/misc/DashboardMediaState.vue'
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
import { useDashboardSnapshot } from '@/composables/useDashboardSnapshot'
import { useI18n } from 'vue-i18n'
@@ -19,6 +21,10 @@ const currentSnapshot = readSnapshot()
// 媒体库列表
const libraryList = ref<DashboardMediaServerLibrary[]>(currentSnapshot?.value ?? [])
// 空结果同样是成功快照;刷新失败不能把已确认的空状态改写成首次加载失败。
const hasSnapshot = ref(currentSnapshot !== undefined)
const isLoading = ref(!currentSnapshot)
const loadFailed = ref(false)
// 所有媒体服务器设置
const mediaServers = ref<MediaServerConf[]>([])
@@ -60,13 +66,25 @@ async function loadLibrary(server: string) {
*/
async function loadData() {
const loadId = ++libraryLoadId
if (!(await loadMediaServerSetting())) return
if (!hasSnapshot.value) isLoading.value = true
if (!(await loadMediaServerSetting())) {
if (loadId === libraryLoadId) {
loadFailed.value = true
isLoading.value = false
}
return
}
if (loadId !== libraryLoadId) return
const enabledServers = mediaServers.value.filter(server => server.enabled)
const serverLibraries = await Promise.all(enabledServers.map(server => loadLibrary(server.name)))
if (loadId !== libraryLoadId || serverLibraries.some(libraries => libraries === undefined)) return
if (loadId !== libraryLoadId) return
if (serverLibraries.some(libraries => libraries === undefined)) {
loadFailed.value = true
isLoading.value = false
return
}
const libraryMap = new Map<string, DashboardMediaServerLibrary>()
serverLibraries
@@ -79,6 +97,9 @@ async function loadData() {
const nextLibraryList = Array.from(libraryMap.values())
libraryList.value = nextLibraryList
writeSnapshot(nextLibraryList)
hasSnapshot.value = true
loadFailed.value = false
isLoading.value = false
}
onMounted(() => {
@@ -98,9 +119,29 @@ onActivated(() => {
</script>
<template>
<VCard v-if="libraryList.length > 0" class="dashboard-media-card dashboard-grid-fill">
<DashboardMediaState
v-if="libraryList.length === 0"
:title="t('dashboard.library')"
:empty-text="t('dashboard.noLibrary')"
empty-icon="mdi-folder-multiple-outline"
:loading="isLoading"
:failed="loadFailed && !hasSnapshot"
>
<template v-if="loadFailed" #append>
<DashboardRetryButton
:deferred="hasSnapshot"
:label="hasSnapshot ? t('dashboard.staleData') : t('dashboard.mediaServerLoadFailed')"
@retry="loadData"
/>
</template>
</DashboardMediaState>
<VCard v-else class="dashboard-media-card dashboard-grid-fill">
<VCardItem class="dashboard-media-header">
<VCardTitle>{{ t('dashboard.library') }}</VCardTitle>
<template v-if="loadFailed" #append>
<DashboardRetryButton deferred :label="t('dashboard.staleData')" @retry="loadData" />
</template>
</VCardItem>
<div class="dashboard-media-content px-5 pb-3">
<ProgressiveCardGrid

View File

@@ -2,6 +2,8 @@
import api from '@/api'
import type { MediaServerConf, MediaServerPlayItem } from '@/api/types'
import PlayingBackdropCard from '@/components/cards/PlayingBackdropCard.vue'
import DashboardRetryButton from '@/components/misc/DashboardRetryButton.vue'
import DashboardMediaState from '@/components/misc/DashboardMediaState.vue'
import ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
import { useDashboardMediaGridCapacity } from '@/composables/useDashboardMediaGridCapacity'
import { useDashboardSnapshot } from '@/composables/useDashboardSnapshot'
@@ -25,6 +27,10 @@ const currentSnapshot = readSnapshot()
// 继续播放列表
const playingList = ref<DashboardPlayingItem[]>(currentSnapshot?.value ?? [])
// 空结果同样是成功快照;刷新失败不能把已确认的空状态改写成首次加载失败。
const hasSnapshot = ref(currentSnapshot !== undefined)
const isLoading = ref(!currentSnapshot)
const loadFailed = ref(false)
// 所有媒体服务器设置
const mediaServers = ref<MediaServerConf[]>([])
@@ -89,15 +95,26 @@ async function loadData() {
if (count <= 0) return
const loadId = ++playingLoadId
if (!hasSnapshot.value) isLoading.value = true
if (!(await loadMediaServerSetting())) return
if (!(await loadMediaServerSetting())) {
if (loadId === playingLoadId) {
loadFailed.value = true
isLoading.value = false
}
return
}
if (loadId !== playingLoadId) return
const enabledServers = mediaServers.value.filter(server => server.enabled)
const serverItems = await Promise.all(enabledServers.map(server => loadPlayingList(server.name, count)))
if (loadId !== playingLoadId) return
if (serverItems.some(items => items === undefined)) return
if (serverItems.some(items => items === undefined)) {
loadFailed.value = true
isLoading.value = false
return
}
const itemMap = new Map<string, DashboardPlayingItem>()
@@ -113,6 +130,9 @@ async function loadData() {
const nextPlayingList = Array.from(itemMap.values()).slice(0, count)
playingList.value = nextPlayingList
writeSnapshot(nextPlayingList)
hasSnapshot.value = true
loadFailed.value = false
isLoading.value = false
}
watch(playingItemCount, count => {
@@ -136,9 +156,29 @@ onActivated(() => {
class="dashboard-media-shell"
:class="{ 'dashboard-grid-fill': displayedPlayingList.length > 0 }"
>
<DashboardMediaState
v-if="playingList.length === 0"
:title="t('dashboard.playing')"
:empty-text="t('dashboard.noPlaying')"
empty-icon="mdi-play-circle-outline"
:loading="isLoading"
:failed="loadFailed && !hasSnapshot"
>
<template v-if="loadFailed" #append>
<DashboardRetryButton
:deferred="hasSnapshot"
:label="hasSnapshot ? t('dashboard.staleData') : t('dashboard.mediaServerLoadFailed')"
@retry="loadData"
/>
</template>
</DashboardMediaState>
<VCard v-if="displayedPlayingList.length > 0" class="dashboard-media-card">
<VCardItem class="dashboard-media-header">
<VCardTitle>{{ t('dashboard.playing') }}</VCardTitle>
<template v-if="loadFailed" #append>
<DashboardRetryButton deferred :label="t('dashboard.staleData')" @retry="loadData" />
</template>
</VCardItem>
<div class="dashboard-media-content px-5 pb-3">

View File

@@ -161,6 +161,19 @@ describe('MediaRecommend', () => {
second.unmount()
})
it('keeps the selected source snapshot and shows the shared warning when revalidation fails', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const first = await renderMediaRecommend([createMediaInfo({ title: '上次推荐' })], { userID: 7 })
await screen.findByText('上次推荐')
first.unmount()
await renderMediaRecommend({}, { status: 502, userID: 7 })
expect(await screen.findByText('上次推荐')).toBeInTheDocument()
expect(await screen.findByRole('button', { name: '刷新失败,当前显示上次数据' })).toBeInTheDocument()
expect(consoleError).toHaveBeenCalled()
})
it('supports arrows, pagination, touch gestures, and detail routes', async () => {
const first = createMediaInfo({ title: '普通媒体', tmdb_id: 101, type: '电影', year: '2025' })
const second = createMediaInfo({ collection_id: 202, title: '媒体合集', tmdb_id: undefined, type: '合集' })
@@ -386,10 +399,13 @@ describe('MediaRecommend', () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
await renderMediaRecommend({}, { status: 500 })
expect(await screen.findByText('推荐媒体加载失败')).toBeInTheDocument()
const retryButton = await screen.findByRole('button', { name: '推荐媒体加载失败' })
const failureAlert = retryButton.closest<HTMLElement>('[role="alert"]')
if (!failureAlert) throw new Error('推荐媒体失败状态缺少 alert 容器')
expect(within(failureAlert).getByText('推荐媒体加载失败')).toBeInTheDocument()
expect(consoleError).toHaveBeenCalled()
server.use(recommendMediaHandler(DEFAULT_SOURCE, [createMediaInfo({ title: '重试成功' })]))
await user.click(screen.getByRole('button', { name: '重试' }))
await user.click(retryButton)
expect(await screen.findByText('重试成功')).toBeInTheDocument()
})

View File

@@ -2,7 +2,7 @@ import MediaServerLatest from '@/views/dashboard/MediaServerLatest.vue'
import MediaServerLibrary from '@/views/dashboard/MediaServerLibrary.vue'
import MediaServerPlaying from '@/views/dashboard/MediaServerPlaying.vue'
import { renderWithProviders } from '@tests/support/render'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import { fireEvent, screen, waitFor, within } from '@testing-library/vue'
import { defineComponent, ref, type Component } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -131,6 +131,79 @@ describe('dashboard media server cards', () => {
})
})
it.each([
[MediaServerLatest, 'mediaserver/latest', '暂无最近入库记录'],
[MediaServerPlaying, 'mediaserver/playing', '暂无继续观看记录'],
[MediaServerLibrary, 'mediaserver/library', '暂无媒体库数据'],
])('shows the explicit empty state for %s', async (component, endpoint, emptyText) => {
mocks.apiGet.mockImplementation((url: string) => {
if (url === 'system/setting/MediaServers') return { data: { value: [{ enabled: true, name: 'home' }] } }
if (url === endpoint) return []
throw new Error(`Unexpected GET ${url}`)
})
await renderWithProviders(keepAliveHarness(component))
expect(await screen.findByText(emptyText)).toBeInTheDocument()
})
it.each([
[MediaServerLatest, 'mediaserver/latest', '暂无最近入库记录'],
[MediaServerPlaying, 'mediaserver/playing', '暂无继续观看记录'],
[MediaServerLibrary, 'mediaserver/library', '暂无媒体库数据'],
])('keeps the successful empty snapshot when %s later fails', async (component, endpoint, emptyText) => {
let endpointReads = 0
mocks.apiGet.mockImplementation((url: string) => {
if (url === 'system/setting/MediaServers') return { data: { value: [{ enabled: true, name: 'home' }] } }
if (url === endpoint) {
endpointReads += 1
if (endpointReads === 1) return []
throw new Error('remote unavailable')
}
throw new Error(`Unexpected GET ${url}`)
})
await renderWithProviders(keepAliveHarness(component))
expect(await screen.findByText(emptyText)).toBeInTheDocument()
await reactivateCard()
await waitFor(() => expect(endpointReads).toBe(2))
expect(screen.getByText(emptyText)).toBeInTheDocument()
expect(screen.queryByText('媒体服务器数据加载失败')).not.toBeInTheDocument()
expect(await screen.findByRole('button', { name: '刷新失败,当前显示上次数据' })).toBeInTheDocument()
})
it.each([
[MediaServerLatest, 'mediaserver/latest', '恢复的最近入库'],
[MediaServerPlaying, 'mediaserver/playing', '恢复的继续观看'],
[MediaServerLibrary, 'mediaserver/library', '恢复的媒体库'],
])('shows a retry state when %s fails without a snapshot', async (component, endpoint, recoveredText) => {
let endpointReads = 0
let shouldFail = true
mocks.apiGet.mockImplementation((url: string) => {
if (url === 'system/setting/MediaServers') return { data: { value: [{ enabled: true, name: 'home' }] } }
if (url === endpoint) {
endpointReads += 1
if (shouldFail) throw new Error('remote unavailable')
if (endpoint === 'mediaserver/library') return [{ id: 'library', name: recoveredText }]
return [{ id: 'media', title: recoveredText }]
}
throw new Error(`Unexpected GET ${url}`)
})
await renderWithProviders(keepAliveHarness(component))
const failureAlert = await screen.findByRole('alert')
expect(within(failureAlert).getByText('媒体服务器数据加载失败')).toBeInTheDocument()
const failedReads = endpointReads
shouldFail = false
await fireEvent.click(screen.getByRole('button', { name: '媒体服务器数据加载失败' }))
expect(await screen.findByText(recoveredText)).toBeInTheDocument()
expect(endpointReads).toBe(failedReads + 1)
})
it('restores the last successful library snapshot before F5 revalidation completes', async () => {
const pendingSettings = deferred<{ data: { value: Array<{ enabled: boolean; name: string }> } }>()
let reload = false
@@ -207,6 +280,7 @@ describe('dashboard media server cards', () => {
await waitFor(() => expect(playingReads).toBe(2))
await new Promise(resolve => setTimeout(resolve, 0))
expect(screen.getByText('旧继续观看')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '刷新失败,当前显示上次数据' })).toBeInTheDocument()
})
it('keeps recent-library content when a warm refresh fails', async () => {
@@ -232,6 +306,30 @@ describe('dashboard media server cards', () => {
await waitFor(() => expect(latestReads).toBe(2))
await new Promise(resolve => setTimeout(resolve, 0))
expect(screen.getByText('旧最近入库')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '刷新失败,当前显示上次数据' })).toBeInTheDocument()
})
it('keeps media-library content when a warm refresh fails', async () => {
const refresh = deferred<Array<{ id: string; name: string }>>()
let libraryReads = 0
mocks.apiGet.mockImplementation((url: string) => {
if (url === 'system/setting/MediaServers') return { data: { value: [{ enabled: true, name: 'home' }] } }
if (url === 'mediaserver/library') {
libraryReads += 1
return libraryReads === 1 ? [{ id: 'old', name: '旧媒体库' }] : refresh.promise
}
throw new Error(`Unexpected GET ${url}`)
})
await renderWithProviders(keepAliveHarness(MediaServerLibrary))
await screen.findByText('旧媒体库')
await reactivateCard()
refresh.reject(new Error('remote unavailable'))
await waitFor(() => expect(libraryReads).toBe(2))
await new Promise(resolve => setTimeout(resolve, 0))
expect(screen.getByText('旧媒体库')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '刷新失败,当前显示上次数据' })).toBeInTheDocument()
})
it('replaces the media-library snapshot atomically after a warm refresh', async () => {