mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-18 04:04:28 +08:00
fix(dashboard): show media empty and retry states (#589)
This commit is contained in:
78
src/components/misc/DashboardMediaState.vue
Normal file
78
src/components/misc/DashboardMediaState.vue
Normal 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>
|
||||
58
src/components/misc/DashboardRetryButton.vue
Normal file
58
src/components/misc/DashboardRetryButton.vue
Normal 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>
|
||||
@@ -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',
|
||||
|
||||
@@ -1009,6 +1009,11 @@ export default {
|
||||
library: '我的媒体库',
|
||||
playing: '继续观看',
|
||||
latest: '最新入库',
|
||||
noLatest: '暂无最近入库记录',
|
||||
noPlaying: '暂无继续观看记录',
|
||||
noLibrary: '暂无媒体库数据',
|
||||
mediaServerLoadFailed: '媒体服务器数据加载失败',
|
||||
staleData: '刷新失败,当前显示上次数据',
|
||||
recentImports: '近期整理',
|
||||
viewAll: '查看全部',
|
||||
settings: '设置仪表板',
|
||||
|
||||
@@ -1009,6 +1009,11 @@ export default {
|
||||
library: '我的媒體庫',
|
||||
playing: '繼續觀看',
|
||||
latest: '最新入庫',
|
||||
noLatest: '暫無最近入庫記錄',
|
||||
noPlaying: '暫無繼續觀看記錄',
|
||||
noLibrary: '暫無媒體庫數據',
|
||||
mediaServerLoadFailed: '媒體伺服器數據加載失敗',
|
||||
staleData: '刷新失敗,當前顯示上次數據',
|
||||
recentImports: '近期整理',
|
||||
viewAll: '查看全部',
|
||||
settings: '設置儀表板',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user