mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-08 17:26:41 +08:00
fix(dashboard): show media empty and retry states (#589)
This commit is contained in:
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -1017,6 +1017,11 @@ export default {
|
|||||||
library: 'My Media Library',
|
library: 'My Media Library',
|
||||||
playing: 'Continue Watching',
|
playing: 'Continue Watching',
|
||||||
latest: 'Latest Imports',
|
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',
|
recentImports: 'Recent Transfers',
|
||||||
viewAll: 'View All',
|
viewAll: 'View All',
|
||||||
settings: 'Dashboard Settings',
|
settings: 'Dashboard Settings',
|
||||||
|
|||||||
@@ -1009,6 +1009,11 @@ export default {
|
|||||||
library: '我的媒体库',
|
library: '我的媒体库',
|
||||||
playing: '继续观看',
|
playing: '继续观看',
|
||||||
latest: '最新入库',
|
latest: '最新入库',
|
||||||
|
noLatest: '暂无最近入库记录',
|
||||||
|
noPlaying: '暂无继续观看记录',
|
||||||
|
noLibrary: '暂无媒体库数据',
|
||||||
|
mediaServerLoadFailed: '媒体服务器数据加载失败',
|
||||||
|
staleData: '刷新失败,当前显示上次数据',
|
||||||
recentImports: '近期整理',
|
recentImports: '近期整理',
|
||||||
viewAll: '查看全部',
|
viewAll: '查看全部',
|
||||||
settings: '设置仪表板',
|
settings: '设置仪表板',
|
||||||
|
|||||||
@@ -1009,6 +1009,11 @@ export default {
|
|||||||
library: '我的媒體庫',
|
library: '我的媒體庫',
|
||||||
playing: '繼續觀看',
|
playing: '繼續觀看',
|
||||||
latest: '最新入庫',
|
latest: '最新入庫',
|
||||||
|
noLatest: '暫無最近入庫記錄',
|
||||||
|
noPlaying: '暫無繼續觀看記錄',
|
||||||
|
noLibrary: '暫無媒體庫數據',
|
||||||
|
mediaServerLoadFailed: '媒體伺服器數據加載失敗',
|
||||||
|
staleData: '刷新失敗,當前顯示上次數據',
|
||||||
recentImports: '近期整理',
|
recentImports: '近期整理',
|
||||||
viewAll: '查看全部',
|
viewAll: '查看全部',
|
||||||
settings: '設置儀表板',
|
settings: '設置儀表板',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import type { MediaInfo } from '@/api/types'
|
import type { MediaInfo } from '@/api/types'
|
||||||
|
import DashboardRetryButton from '@/components/misc/DashboardRetryButton.vue'
|
||||||
import { useDashboardSnapshot } from '@/composables/useDashboardSnapshot'
|
import { useDashboardSnapshot } from '@/composables/useDashboardSnapshot'
|
||||||
import { getMediaSubscribeId } from '@/composables/useMediaSubscribe'
|
import { getMediaSubscribeId } from '@/composables/useMediaSubscribe'
|
||||||
import { useGlobalSettingsStore } from '@/stores'
|
import { useGlobalSettingsStore } from '@/stores'
|
||||||
@@ -34,6 +35,7 @@ const mediaSnapshots = new Map(
|
|||||||
)
|
)
|
||||||
const initialSnapshot = mediaSnapshots.get(selectedSourcePath.value)?.readSnapshot()
|
const initialSnapshot = mediaSnapshots.get(selectedSourcePath.value)?.readSnapshot()
|
||||||
const mediaItems = shallowRef<MediaInfo[]>(initialSnapshot?.value ?? [])
|
const mediaItems = shallowRef<MediaInfo[]>(initialSnapshot?.value ?? [])
|
||||||
|
const hasSnapshot = ref(Boolean(initialSnapshot))
|
||||||
const activeIndex = ref(0)
|
const activeIndex = ref(0)
|
||||||
const loading = ref(!initialSnapshot)
|
const loading = ref(!initialSnapshot)
|
||||||
const loadFailed = ref(false)
|
const loadFailed = ref(false)
|
||||||
@@ -90,12 +92,14 @@ async function loadMedia(sourcePath = selectedSourcePath.value) {
|
|||||||
|
|
||||||
if (cachedItems) {
|
if (cachedItems) {
|
||||||
mediaItems.value = cachedItems
|
mediaItems.value = cachedItems
|
||||||
|
hasSnapshot.value = true
|
||||||
activeIndex.value = 0
|
activeIndex.value = 0
|
||||||
loading.value = false
|
loading.value = false
|
||||||
loadFailed.value = false
|
loadFailed.value = false
|
||||||
resumeAutoplayIfReady()
|
resumeAutoplayIfReady()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!cachedItems) hasSnapshot.value = false
|
||||||
loading.value = !cachedItems
|
loading.value = !cachedItems
|
||||||
loadFailed.value = false
|
loadFailed.value = false
|
||||||
try {
|
try {
|
||||||
@@ -105,13 +109,14 @@ async function loadMedia(sourcePath = selectedSourcePath.value) {
|
|||||||
const items = normalizeMediaResponse(response).filter(isUsableMedia).slice(0, RECOMMEND_SLIDE_COUNT)
|
const items = normalizeMediaResponse(response).filter(isUsableMedia).slice(0, RECOMMEND_SLIDE_COUNT)
|
||||||
mediaSnapshots.get(sourcePath)?.writeSnapshot(items)
|
mediaSnapshots.get(sourcePath)?.writeSnapshot(items)
|
||||||
mediaItems.value = items
|
mediaItems.value = items
|
||||||
|
hasSnapshot.value = true
|
||||||
activeIndex.value = 0
|
activeIndex.value = 0
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (currentRequestId !== requestId) return
|
if (currentRequestId !== requestId) return
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
loadFailed.value = true
|
||||||
if (!cachedItems) {
|
if (!cachedItems) {
|
||||||
mediaItems.value = []
|
mediaItems.value = []
|
||||||
loadFailed.value = true
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (currentRequestId === requestId) {
|
if (currentRequestId === requestId) {
|
||||||
@@ -289,6 +294,8 @@ onBeforeUnmount(() => {
|
|||||||
<span>{{ t('dashboard.recommendedMedia') }}</span>
|
<span>{{ t('dashboard.recommendedMedia') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="dashboard-recommend-actions">
|
||||||
|
<DashboardRetryButton v-if="loadFailed" deferred :label="t('dashboard.staleData')" @retry="loadMedia()" />
|
||||||
<VMenu location="bottom end">
|
<VMenu location="bottom end">
|
||||||
<template #activator="{ props: menuProps }">
|
<template #activator="{ props: menuProps }">
|
||||||
<VBtn
|
<VBtn
|
||||||
@@ -316,6 +323,7 @@ onBeforeUnmount(() => {
|
|||||||
</VList>
|
</VList>
|
||||||
</VMenu>
|
</VMenu>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="dashboard-recommend-content"
|
class="dashboard-recommend-content"
|
||||||
@@ -374,10 +382,23 @@ onBeforeUnmount(() => {
|
|||||||
/>
|
/>
|
||||||
</template>
|
</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" />
|
<VIcon icon="mdi-image-off-outline" size="38" />
|
||||||
<span>{{ loadFailed ? t('dashboard.recommendLoadFailed') : t('dashboard.noRecommendations') }}</span>
|
<span>{{
|
||||||
<VBtn v-if="loadFailed" variant="tonal" size="small" @click="loadMedia()">{{ t('common.retry') }}</VBtn>
|
loadFailed && !hasSnapshot ? t('dashboard.recommendLoadFailed') : t('dashboard.noRecommendations')
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
</VCard>
|
</VCard>
|
||||||
</template>
|
</template>
|
||||||
@@ -451,6 +472,13 @@ onBeforeUnmount(() => {
|
|||||||
padding: 0.55rem 0.85rem;
|
padding: 0.55rem 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-recommend-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-inline-size: 0;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
.dashboard-recommend-source {
|
.dashboard-recommend-source {
|
||||||
max-inline-size: min(320px, 45vw);
|
max-inline-size: min(320px, 45vw);
|
||||||
background: rgba(8, 18, 28, 0.55) !important;
|
background: rgba(8, 18, 28, 0.55) !important;
|
||||||
@@ -567,6 +595,7 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-recommend-empty {
|
.dashboard-recommend-empty {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
block-size: 100%;
|
block-size: 100%;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -576,6 +605,12 @@ onBeforeUnmount(() => {
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-recommend-retry {
|
||||||
|
position: absolute;
|
||||||
|
inset-block-start: 1rem;
|
||||||
|
inset-inline-end: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
@media (min-width: 741px) and (hover: hover) {
|
@media (min-width: 741px) and (hover: hover) {
|
||||||
.dashboard-recommend-topbar,
|
.dashboard-recommend-topbar,
|
||||||
.dashboard-recommend-detail,
|
.dashboard-recommend-detail,
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import type { MediaServerConf, MediaServerPlayItem } from '@/api/types'
|
import type { MediaServerConf, MediaServerPlayItem } from '@/api/types'
|
||||||
import PosterCard from '@/components/cards/PosterCard.vue'
|
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 ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||||
import { useDashboardMediaGridCapacity } from '@/composables/useDashboardMediaGridCapacity'
|
import { useDashboardMediaGridCapacity } from '@/composables/useDashboardMediaGridCapacity'
|
||||||
import { useDashboardSnapshot } from '@/composables/useDashboardSnapshot'
|
import { useDashboardSnapshot } from '@/composables/useDashboardSnapshot'
|
||||||
@@ -22,6 +24,10 @@ const currentSnapshot = readSnapshot()
|
|||||||
|
|
||||||
// 最近入库列表
|
// 最近入库列表
|
||||||
const latestList = ref<{ [key: string]: MediaServerPlayItem[] }>(currentSnapshot?.value ?? {})
|
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[]>([])
|
const mediaServers = ref<MediaServerConf[]>([])
|
||||||
@@ -84,8 +90,15 @@ async function loadData() {
|
|||||||
if (count <= 0) return
|
if (count <= 0) return
|
||||||
|
|
||||||
const loadId = ++latestLoadId
|
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
|
if (loadId !== latestLoadId) return
|
||||||
|
|
||||||
const enabledServers = mediaServers.value.filter(server => server.enabled)
|
const enabledServers = mediaServers.value.filter(server => server.enabled)
|
||||||
@@ -97,7 +110,11 @@ async function loadData() {
|
|||||||
|
|
||||||
const nextLatestList: { [key: string]: MediaServerPlayItem[] } = {}
|
const nextLatestList: { [key: string]: MediaServerPlayItem[] } = {}
|
||||||
for (const [name, data] of entries) {
|
for (const [name, data] of entries) {
|
||||||
if (data === undefined) return
|
if (data === undefined) {
|
||||||
|
loadFailed.value = true
|
||||||
|
isLoading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
if (data.length > 0) {
|
if (data.length > 0) {
|
||||||
nextLatestList[name] = data.slice(0, count)
|
nextLatestList[name] = data.slice(0, count)
|
||||||
}
|
}
|
||||||
@@ -105,6 +122,9 @@ async function loadData() {
|
|||||||
|
|
||||||
latestList.value = nextLatestList
|
latestList.value = nextLatestList
|
||||||
writeSnapshot(nextLatestList)
|
writeSnapshot(nextLatestList)
|
||||||
|
hasSnapshot.value = true
|
||||||
|
loadFailed.value = false
|
||||||
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(latestItemCount, count => {
|
watch(latestItemCount, count => {
|
||||||
@@ -128,9 +148,29 @@ onActivated(() => {
|
|||||||
class="dashboard-media-stack"
|
class="dashboard-media-stack"
|
||||||
:class="{ 'dashboard-grid-fill': Object.keys(latestList).length > 0 }"
|
: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">
|
<VCard v-for="(data, name) in latestList" :key="name" class="dashboard-work-card dashboard-media-card">
|
||||||
<VCardItem class="dashboard-media-header">
|
<VCardItem class="dashboard-media-header">
|
||||||
<VCardTitle>{{ t('dashboard.latest') }} - {{ name }}</VCardTitle>
|
<VCardTitle>{{ t('dashboard.latest') }} - {{ name }}</VCardTitle>
|
||||||
|
<template v-if="loadFailed" #append>
|
||||||
|
<DashboardRetryButton deferred :label="t('dashboard.staleData')" @retry="loadData" />
|
||||||
|
</template>
|
||||||
</VCardItem>
|
</VCardItem>
|
||||||
|
|
||||||
<div class="dashboard-media-content px-5 pb-3">
|
<div class="dashboard-media-content px-5 pb-3">
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import type { MediaServerConf, MediaServerLibrary } from '@/api/types'
|
import type { MediaServerConf, MediaServerLibrary } from '@/api/types'
|
||||||
import LibraryCard from '@/components/cards/LibraryCard.vue'
|
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 ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||||
import { useDashboardSnapshot } from '@/composables/useDashboardSnapshot'
|
import { useDashboardSnapshot } from '@/composables/useDashboardSnapshot'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
@@ -19,6 +21,10 @@ const currentSnapshot = readSnapshot()
|
|||||||
|
|
||||||
// 媒体库列表
|
// 媒体库列表
|
||||||
const libraryList = ref<DashboardMediaServerLibrary[]>(currentSnapshot?.value ?? [])
|
const libraryList = ref<DashboardMediaServerLibrary[]>(currentSnapshot?.value ?? [])
|
||||||
|
// 空结果同样是成功快照;刷新失败不能把已确认的空状态改写成首次加载失败。
|
||||||
|
const hasSnapshot = ref(currentSnapshot !== undefined)
|
||||||
|
const isLoading = ref(!currentSnapshot)
|
||||||
|
const loadFailed = ref(false)
|
||||||
|
|
||||||
// 所有媒体服务器设置
|
// 所有媒体服务器设置
|
||||||
const mediaServers = ref<MediaServerConf[]>([])
|
const mediaServers = ref<MediaServerConf[]>([])
|
||||||
@@ -60,13 +66,25 @@ async function loadLibrary(server: string) {
|
|||||||
*/
|
*/
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
const loadId = ++libraryLoadId
|
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
|
if (loadId !== libraryLoadId) return
|
||||||
|
|
||||||
const enabledServers = mediaServers.value.filter(server => server.enabled)
|
const enabledServers = mediaServers.value.filter(server => server.enabled)
|
||||||
const serverLibraries = await Promise.all(enabledServers.map(server => loadLibrary(server.name)))
|
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>()
|
const libraryMap = new Map<string, DashboardMediaServerLibrary>()
|
||||||
serverLibraries
|
serverLibraries
|
||||||
@@ -79,6 +97,9 @@ async function loadData() {
|
|||||||
const nextLibraryList = Array.from(libraryMap.values())
|
const nextLibraryList = Array.from(libraryMap.values())
|
||||||
libraryList.value = nextLibraryList
|
libraryList.value = nextLibraryList
|
||||||
writeSnapshot(nextLibraryList)
|
writeSnapshot(nextLibraryList)
|
||||||
|
hasSnapshot.value = true
|
||||||
|
loadFailed.value = false
|
||||||
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -98,9 +119,29 @@ onActivated(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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">
|
<VCardItem class="dashboard-media-header">
|
||||||
<VCardTitle>{{ t('dashboard.library') }}</VCardTitle>
|
<VCardTitle>{{ t('dashboard.library') }}</VCardTitle>
|
||||||
|
<template v-if="loadFailed" #append>
|
||||||
|
<DashboardRetryButton deferred :label="t('dashboard.staleData')" @retry="loadData" />
|
||||||
|
</template>
|
||||||
</VCardItem>
|
</VCardItem>
|
||||||
<div class="dashboard-media-content px-5 pb-3">
|
<div class="dashboard-media-content px-5 pb-3">
|
||||||
<ProgressiveCardGrid
|
<ProgressiveCardGrid
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import type { MediaServerConf, MediaServerPlayItem } from '@/api/types'
|
import type { MediaServerConf, MediaServerPlayItem } from '@/api/types'
|
||||||
import PlayingBackdropCard from '@/components/cards/PlayingBackdropCard.vue'
|
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 ProgressiveCardGrid from '@/components/misc/ProgressiveCardGrid.vue'
|
||||||
import { useDashboardMediaGridCapacity } from '@/composables/useDashboardMediaGridCapacity'
|
import { useDashboardMediaGridCapacity } from '@/composables/useDashboardMediaGridCapacity'
|
||||||
import { useDashboardSnapshot } from '@/composables/useDashboardSnapshot'
|
import { useDashboardSnapshot } from '@/composables/useDashboardSnapshot'
|
||||||
@@ -25,6 +27,10 @@ const currentSnapshot = readSnapshot()
|
|||||||
|
|
||||||
// 继续播放列表
|
// 继续播放列表
|
||||||
const playingList = ref<DashboardPlayingItem[]>(currentSnapshot?.value ?? [])
|
const playingList = ref<DashboardPlayingItem[]>(currentSnapshot?.value ?? [])
|
||||||
|
// 空结果同样是成功快照;刷新失败不能把已确认的空状态改写成首次加载失败。
|
||||||
|
const hasSnapshot = ref(currentSnapshot !== undefined)
|
||||||
|
const isLoading = ref(!currentSnapshot)
|
||||||
|
const loadFailed = ref(false)
|
||||||
|
|
||||||
// 所有媒体服务器设置
|
// 所有媒体服务器设置
|
||||||
const mediaServers = ref<MediaServerConf[]>([])
|
const mediaServers = ref<MediaServerConf[]>([])
|
||||||
@@ -89,15 +95,26 @@ async function loadData() {
|
|||||||
if (count <= 0) return
|
if (count <= 0) return
|
||||||
|
|
||||||
const loadId = ++playingLoadId
|
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
|
if (loadId !== playingLoadId) return
|
||||||
|
|
||||||
const enabledServers = mediaServers.value.filter(server => server.enabled)
|
const enabledServers = mediaServers.value.filter(server => server.enabled)
|
||||||
const serverItems = await Promise.all(enabledServers.map(server => loadPlayingList(server.name, count)))
|
const serverItems = await Promise.all(enabledServers.map(server => loadPlayingList(server.name, count)))
|
||||||
|
|
||||||
if (loadId !== playingLoadId) return
|
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>()
|
const itemMap = new Map<string, DashboardPlayingItem>()
|
||||||
|
|
||||||
@@ -113,6 +130,9 @@ async function loadData() {
|
|||||||
const nextPlayingList = Array.from(itemMap.values()).slice(0, count)
|
const nextPlayingList = Array.from(itemMap.values()).slice(0, count)
|
||||||
playingList.value = nextPlayingList
|
playingList.value = nextPlayingList
|
||||||
writeSnapshot(nextPlayingList)
|
writeSnapshot(nextPlayingList)
|
||||||
|
hasSnapshot.value = true
|
||||||
|
loadFailed.value = false
|
||||||
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(playingItemCount, count => {
|
watch(playingItemCount, count => {
|
||||||
@@ -136,9 +156,29 @@ onActivated(() => {
|
|||||||
class="dashboard-media-shell"
|
class="dashboard-media-shell"
|
||||||
:class="{ 'dashboard-grid-fill': displayedPlayingList.length > 0 }"
|
: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">
|
<VCard v-if="displayedPlayingList.length > 0" class="dashboard-media-card">
|
||||||
<VCardItem class="dashboard-media-header">
|
<VCardItem class="dashboard-media-header">
|
||||||
<VCardTitle>{{ t('dashboard.playing') }}</VCardTitle>
|
<VCardTitle>{{ t('dashboard.playing') }}</VCardTitle>
|
||||||
|
<template v-if="loadFailed" #append>
|
||||||
|
<DashboardRetryButton deferred :label="t('dashboard.staleData')" @retry="loadData" />
|
||||||
|
</template>
|
||||||
</VCardItem>
|
</VCardItem>
|
||||||
|
|
||||||
<div class="dashboard-media-content px-5 pb-3">
|
<div class="dashboard-media-content px-5 pb-3">
|
||||||
|
|||||||
@@ -161,6 +161,19 @@ describe('MediaRecommend', () => {
|
|||||||
second.unmount()
|
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 () => {
|
it('supports arrows, pagination, touch gestures, and detail routes', async () => {
|
||||||
const first = createMediaInfo({ title: '普通媒体', tmdb_id: 101, type: '电影', year: '2025' })
|
const first = createMediaInfo({ title: '普通媒体', tmdb_id: 101, type: '电影', year: '2025' })
|
||||||
const second = createMediaInfo({ collection_id: 202, title: '媒体合集', tmdb_id: undefined, type: '合集' })
|
const second = createMediaInfo({ collection_id: 202, title: '媒体合集', tmdb_id: undefined, type: '合集' })
|
||||||
@@ -386,10 +399,13 @@ describe('MediaRecommend', () => {
|
|||||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
await renderMediaRecommend({}, { status: 500 })
|
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()
|
expect(consoleError).toHaveBeenCalled()
|
||||||
server.use(recommendMediaHandler(DEFAULT_SOURCE, [createMediaInfo({ title: '重试成功' })]))
|
server.use(recommendMediaHandler(DEFAULT_SOURCE, [createMediaInfo({ title: '重试成功' })]))
|
||||||
await user.click(screen.getByRole('button', { name: '重试' }))
|
await user.click(retryButton)
|
||||||
|
|
||||||
expect(await screen.findByText('重试成功')).toBeInTheDocument()
|
expect(await screen.findByText('重试成功')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import MediaServerLatest from '@/views/dashboard/MediaServerLatest.vue'
|
|||||||
import MediaServerLibrary from '@/views/dashboard/MediaServerLibrary.vue'
|
import MediaServerLibrary from '@/views/dashboard/MediaServerLibrary.vue'
|
||||||
import MediaServerPlaying from '@/views/dashboard/MediaServerPlaying.vue'
|
import MediaServerPlaying from '@/views/dashboard/MediaServerPlaying.vue'
|
||||||
import { renderWithProviders } from '@tests/support/render'
|
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 { defineComponent, ref, type Component } from 'vue'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
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 () => {
|
it('restores the last successful library snapshot before F5 revalidation completes', async () => {
|
||||||
const pendingSettings = deferred<{ data: { value: Array<{ enabled: boolean; name: string }> } }>()
|
const pendingSettings = deferred<{ data: { value: Array<{ enabled: boolean; name: string }> } }>()
|
||||||
let reload = false
|
let reload = false
|
||||||
@@ -207,6 +280,7 @@ describe('dashboard media server cards', () => {
|
|||||||
await waitFor(() => expect(playingReads).toBe(2))
|
await waitFor(() => expect(playingReads).toBe(2))
|
||||||
await new Promise(resolve => setTimeout(resolve, 0))
|
await new Promise(resolve => setTimeout(resolve, 0))
|
||||||
expect(screen.getByText('旧继续观看')).toBeInTheDocument()
|
expect(screen.getByText('旧继续观看')).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('button', { name: '刷新失败,当前显示上次数据' })).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps recent-library content when a warm refresh fails', async () => {
|
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 waitFor(() => expect(latestReads).toBe(2))
|
||||||
await new Promise(resolve => setTimeout(resolve, 0))
|
await new Promise(resolve => setTimeout(resolve, 0))
|
||||||
expect(screen.getByText('旧最近入库')).toBeInTheDocument()
|
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 () => {
|
it('replaces the media-library snapshot atomically after a warm refresh', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user