mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 23:56:42 +08:00
Codex/compact downloading poster (#635)
* fix(download): compact poster cards * fix(download): use poster artwork consistently * Improve plugin and media ID search dialogs
This commit is contained in:
+3
-1
@@ -300,8 +300,10 @@ export interface DownloadHistory {
|
|||||||
seasons?: string
|
seasons?: string
|
||||||
// 集 Exx
|
// 集 Exx
|
||||||
episodes?: string
|
episodes?: string
|
||||||
// 海报或背景图
|
// 背景图
|
||||||
image?: string
|
image?: string
|
||||||
|
// 海报
|
||||||
|
poster?: string
|
||||||
// 下载器 Hash
|
// 下载器 Hash
|
||||||
download_hash?: string
|
download_hash?: string
|
||||||
// 种子名称
|
// 种子名称
|
||||||
|
|||||||
@@ -25,13 +25,13 @@ const imageLoadError = ref(false)
|
|||||||
const media = computed(() => props.info?.media ?? {})
|
const media = computed(() => props.info?.media ?? {})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => media.value.image,
|
() => media.value.poster,
|
||||||
() => {
|
() => {
|
||||||
imageLoadError.value = false
|
imageLoadError.value = false
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const hasPosterImage = computed(() => Boolean(media.value.image && !imageLoadError.value))
|
const hasPosterImage = computed(() => Boolean(media.value.poster && !imageLoadError.value))
|
||||||
|
|
||||||
const mediaTitle = computed(() => media.value.title || props.info?.name || props.info?.title || t('common.unknown'))
|
const mediaTitle = computed(() => media.value.title || props.info?.name || props.info?.title || t('common.unknown'))
|
||||||
|
|
||||||
@@ -149,111 +149,124 @@ async function deleteDownload() {
|
|||||||
<template #default="hover">
|
<template #default="hover">
|
||||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||||
<div v-if="cardState" v-bind="hover.props" class="downloading-card-hover-area h-full">
|
<div v-if="cardState" v-bind="hover.props" class="downloading-card-hover-area h-full">
|
||||||
<VCard
|
<div
|
||||||
:key="props.info?.hash"
|
class="downloading-card-shell app-hover-lift-card h-full"
|
||||||
class="downloading-card app-hover-lift-card app-surface h-full overflow-hidden"
|
:class="{ 'app-hover-lift-card--hovering': hover.isHovering }"
|
||||||
:class="{
|
|
||||||
'app-hover-lift-card--hovering': hover.isHovering,
|
|
||||||
'downloading-card--hovering': hover.isHovering,
|
|
||||||
'downloading-card--no-image': !hasPosterImage,
|
|
||||||
}"
|
|
||||||
>
|
>
|
||||||
<div v-if="hasPosterImage" class="downloading-card__poster">
|
<VCard
|
||||||
<VImg :src="media.image" class="downloading-card__image" position="center" @error="imageLoadError = true">
|
:key="props.info?.hash"
|
||||||
<template #placeholder>
|
class="downloading-card h-full overflow-hidden"
|
||||||
<VSkeletonLoader class="downloading-card__image-loader h-full" />
|
:class="{ 'downloading-card--no-image': !hasPosterImage }"
|
||||||
</template>
|
>
|
||||||
</VImg>
|
<div v-if="hasPosterImage" class="downloading-card__poster">
|
||||||
<div class="downloading-card__poster-edge" />
|
<VImg
|
||||||
</div>
|
:src="media.poster"
|
||||||
|
class="downloading-card__image"
|
||||||
<VCardText class="downloading-card__body">
|
cover
|
||||||
<div class="downloading-card__chips">
|
position="center"
|
||||||
<VChip v-if="mediaTypeText" :prepend-icon="mediaTypeIcon" color="primary" size="x-small" variant="tonal">
|
@error="imageLoadError = true"
|
||||||
{{ mediaTypeText }}
|
>
|
||||||
</VChip>
|
<template #placeholder>
|
||||||
<VChip v-if="sourceSiteText" prepend-icon="mdi-web" size="x-small" variant="tonal">
|
<VSkeletonLoader class="downloading-card__image-loader h-full" />
|
||||||
{{ sourceSiteText }}
|
</template>
|
||||||
</VChip>
|
</VImg>
|
||||||
<VChip v-else prepend-icon="mdi-harddisk" size="x-small" variant="tonal">
|
<div class="downloading-card__poster-edge" />
|
||||||
{{ sizeText }}
|
|
||||||
</VChip>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="downloading-card__heading">
|
<VCardText class="downloading-card__body">
|
||||||
<div class="downloading-card__title" :title="mediaTitle">
|
<div class="downloading-card__chips">
|
||||||
<span>{{ mediaTitle }}</span>
|
<VChip
|
||||||
<span v-if="titleMetaText" class="downloading-card__title-meta">{{ titleMetaText }}</span>
|
v-if="mediaTypeText"
|
||||||
</div>
|
:prepend-icon="mediaTypeIcon"
|
||||||
<div class="downloading-card__torrent-title" :title="props.info?.title">
|
|
||||||
{{ props.info?.title || t('common.unknown') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="progressValue > 0" class="downloading-card__progress">
|
|
||||||
<div class="downloading-card__progress-label">
|
|
||||||
<span>
|
|
||||||
{{ isDownloading ? t('common.download') : t('common.pause') }}
|
|
||||||
<span class="downloading-card__progress-separator">·</span>
|
|
||||||
{{ remainingTimeText }}
|
|
||||||
</span>
|
|
||||||
<strong>{{ progressText }}</strong>
|
|
||||||
</div>
|
|
||||||
<VProgressLinear
|
|
||||||
:aria-label="t('common.download')"
|
|
||||||
:model-value="progressValue"
|
|
||||||
:color="isDownloading ? 'success' : 'warning'"
|
|
||||||
bg-color="surface-variant"
|
|
||||||
height="6"
|
|
||||||
rounded
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="downloading-card__footer">
|
|
||||||
<div class="downloading-card__speeds">
|
|
||||||
<div class="downloading-card__speed downloading-card__speed--download">
|
|
||||||
<VIcon icon="mdi-arrow-down" size="16" />
|
|
||||||
<strong :title="downloadSpeedText">{{ downloadSpeedText }}</strong>
|
|
||||||
</div>
|
|
||||||
<div class="downloading-card__speed downloading-card__speed--upload">
|
|
||||||
<VIcon icon="mdi-arrow-up" size="16" />
|
|
||||||
<strong :title="uploadSpeedText">{{ uploadSpeedText }}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<VCardActions class="downloading-card__actions pa-0">
|
|
||||||
<VBtn
|
|
||||||
:aria-label="isDownloading ? t('common.pause') : t('common.download')"
|
|
||||||
:disabled="pendingAction === 'delete'"
|
|
||||||
icon
|
|
||||||
:loading="pendingAction === 'toggle'"
|
|
||||||
color="primary"
|
color="primary"
|
||||||
size="small"
|
size="x-small"
|
||||||
variant="tonal"
|
variant="tonal"
|
||||||
@click="toggleDownload"
|
|
||||||
>
|
>
|
||||||
<VIcon :icon="isDownloading ? 'mdi-pause' : 'mdi-play'" />
|
{{ mediaTypeText }}
|
||||||
<VTooltip activator="parent" location="top">
|
</VChip>
|
||||||
{{ isDownloading ? t('common.pause') : t('common.download') }}
|
<VChip v-if="sourceSiteText" prepend-icon="mdi-web" size="x-small" variant="tonal">
|
||||||
</VTooltip>
|
{{ sourceSiteText }}
|
||||||
</VBtn>
|
</VChip>
|
||||||
<VBtn
|
<VChip v-else prepend-icon="mdi-harddisk" size="x-small" variant="tonal">
|
||||||
:aria-label="t('common.delete')"
|
{{ sizeText }}
|
||||||
:disabled="pendingAction === 'toggle'"
|
</VChip>
|
||||||
:loading="pendingAction === 'delete'"
|
</div>
|
||||||
color="error"
|
|
||||||
icon
|
<div class="downloading-card__heading">
|
||||||
size="small"
|
<div class="downloading-card__title" :title="mediaTitle">
|
||||||
variant="text"
|
<span>{{ mediaTitle }}</span>
|
||||||
@click="deleteDownload"
|
<span v-if="titleMetaText" class="downloading-card__title-meta">{{ titleMetaText }}</span>
|
||||||
>
|
</div>
|
||||||
<VIcon icon="mdi-trash-can-outline" />
|
<div class="downloading-card__torrent-title" :title="props.info?.title">
|
||||||
<VTooltip activator="parent" location="top">{{ t('common.delete') }}</VTooltip>
|
{{ props.info?.title || t('common.unknown') }}
|
||||||
</VBtn>
|
</div>
|
||||||
</VCardActions>
|
</div>
|
||||||
</div>
|
|
||||||
</VCardText>
|
<div v-if="progressValue > 0" class="downloading-card__progress">
|
||||||
</VCard>
|
<div class="downloading-card__progress-label">
|
||||||
|
<span>
|
||||||
|
{{ isDownloading ? t('common.download') : t('common.pause') }}
|
||||||
|
<span class="downloading-card__progress-separator">·</span>
|
||||||
|
{{ remainingTimeText }}
|
||||||
|
</span>
|
||||||
|
<strong>{{ progressText }}</strong>
|
||||||
|
</div>
|
||||||
|
<VProgressLinear
|
||||||
|
:aria-label="t('common.download')"
|
||||||
|
:model-value="progressValue"
|
||||||
|
:color="isDownloading ? 'success' : 'warning'"
|
||||||
|
bg-color="surface-variant"
|
||||||
|
height="6"
|
||||||
|
rounded
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="downloading-card__footer">
|
||||||
|
<div class="downloading-card__speeds">
|
||||||
|
<div class="downloading-card__speed downloading-card__speed--download">
|
||||||
|
<VIcon icon="mdi-arrow-down" size="16" />
|
||||||
|
<strong :title="downloadSpeedText">{{ downloadSpeedText }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="downloading-card__speed downloading-card__speed--upload">
|
||||||
|
<VIcon icon="mdi-arrow-up" size="16" />
|
||||||
|
<strong :title="uploadSpeedText">{{ uploadSpeedText }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VCardActions class="downloading-card__actions pa-0">
|
||||||
|
<VBtn
|
||||||
|
:aria-label="isDownloading ? t('common.pause') : t('common.download')"
|
||||||
|
:disabled="pendingAction === 'delete'"
|
||||||
|
icon
|
||||||
|
:loading="pendingAction === 'toggle'"
|
||||||
|
color="primary"
|
||||||
|
size="small"
|
||||||
|
variant="tonal"
|
||||||
|
@click="toggleDownload"
|
||||||
|
>
|
||||||
|
<VIcon :icon="isDownloading ? 'mdi-pause' : 'mdi-play'" />
|
||||||
|
<VTooltip activator="parent" location="top">
|
||||||
|
{{ isDownloading ? t('common.pause') : t('common.download') }}
|
||||||
|
</VTooltip>
|
||||||
|
</VBtn>
|
||||||
|
<VBtn
|
||||||
|
:aria-label="t('common.delete')"
|
||||||
|
:disabled="pendingAction === 'toggle'"
|
||||||
|
:loading="pendingAction === 'delete'"
|
||||||
|
color="error"
|
||||||
|
icon
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="deleteDownload"
|
||||||
|
>
|
||||||
|
<VIcon icon="mdi-trash-can-outline" />
|
||||||
|
<VTooltip activator="parent" location="top">{{ t('common.delete') }}</VTooltip>
|
||||||
|
</VBtn>
|
||||||
|
</VCardActions>
|
||||||
|
</div>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</VHover>
|
</VHover>
|
||||||
@@ -268,11 +281,15 @@ async function deleteDownload() {
|
|||||||
inline-size: 100%;
|
inline-size: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.downloading-card-shell {
|
||||||
|
border-radius: var(--app-surface-radius);
|
||||||
|
}
|
||||||
|
|
||||||
.downloading-card {
|
.downloading-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-block-size: 12rem;
|
min-block-size: 12rem;
|
||||||
color: rgb(var(--v-theme-on-surface));
|
color: rgb(var(--v-theme-on-surface));
|
||||||
grid-template-columns: 8.25rem minmax(0, 1fr);
|
grid-template-columns: 8rem minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloading-card__poster {
|
.downloading-card__poster {
|
||||||
@@ -339,9 +356,9 @@ async function deleteDownload() {
|
|||||||
|
|
||||||
.downloading-card__title-meta {
|
.downloading-card__title-meta {
|
||||||
margin-inline-start: 0.35rem;
|
margin-inline-start: 0.35rem;
|
||||||
color: rgb(var(--v-theme-primary));
|
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
||||||
font-size: 0.76rem;
|
font-size: 0.76rem;
|
||||||
font-weight: 650;
|
font-weight: 500;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -444,7 +461,7 @@ async function deleteDownload() {
|
|||||||
@container (width <= 25rem) {
|
@container (width <= 25rem) {
|
||||||
.downloading-card {
|
.downloading-card {
|
||||||
min-block-size: 11rem;
|
min-block-size: 11rem;
|
||||||
grid-template-columns: 6.75rem minmax(0, 1fr);
|
grid-template-columns: 7.333rem minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloading-card__poster {
|
.downloading-card__poster {
|
||||||
@@ -478,10 +495,6 @@ async function deleteDownload() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@container (width <= 21rem) {
|
@container (width <= 21rem) {
|
||||||
.downloading-card {
|
|
||||||
grid-template-columns: 6.25rem minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.downloading-card__body {
|
.downloading-card__body {
|
||||||
padding-inline: 0.65rem !important;
|
padding-inline: 0.65rem !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ function downloading(overrides: Partial<DownloadingInfo> = {}): DownloadingInfo
|
|||||||
left_time: '1 小时',
|
left_time: '1 小时',
|
||||||
media: {
|
media: {
|
||||||
episode: 'E02',
|
episode: 'E02',
|
||||||
image: 'https://images.example.com/poster.jpg',
|
poster: 'https://images.example.com/poster.jpg',
|
||||||
season: 'S01',
|
season: 'S01',
|
||||||
title: '测试媒体',
|
title: '测试媒体',
|
||||||
},
|
},
|
||||||
@@ -71,6 +71,32 @@ describe('DownloadingCard display and pause state', () => {
|
|||||||
expect(container.querySelector('.v-card-text .v-progress-linear')).not.toBeInTheDocument()
|
expect(container.querySelector('.v-card-text .v-progress-linear')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('only renders a centered cover image when a poster is available', async () => {
|
||||||
|
const { container, rerender } = await renderCard()
|
||||||
|
|
||||||
|
expect(container.querySelector('.downloading-card__image')).toBeInTheDocument()
|
||||||
|
|
||||||
|
await rerender({
|
||||||
|
downloaderName: 'qb-main',
|
||||||
|
info: downloading({ media: { backdrop: 'https://images.example.com/backdrop.jpg' } }),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(container.querySelector('.downloading-card')).toHaveClass('downloading-card--no-image')
|
||||||
|
expect(container.querySelector('.downloading-card__image')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies the shared lift state to the outer shell on hover', async () => {
|
||||||
|
const { container } = await renderCard()
|
||||||
|
const hoverArea = container.querySelector('.downloading-card-hover-area')!
|
||||||
|
const shell = container.querySelector('.downloading-card-shell')!
|
||||||
|
|
||||||
|
await fireEvent.mouseEnter(hoverArea)
|
||||||
|
await waitFor(() => expect(shell).toHaveClass('app-hover-lift-card--hovering'))
|
||||||
|
|
||||||
|
await fireEvent.mouseLeave(hoverArea)
|
||||||
|
await waitFor(() => expect(shell).not.toHaveClass('app-hover-lift-card--hovering'))
|
||||||
|
})
|
||||||
|
|
||||||
it('uses the current operation and downloader name, changing state only on business success', async () => {
|
it('uses the current operation and downloader name, changing state only on business success', async () => {
|
||||||
const stopRequested = vi.fn()
|
const stopRequested = vi.fn()
|
||||||
const startRequested = vi.fn()
|
const startRequested = vi.fn()
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const pageSize = 30
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const isRefreshed = ref(false)
|
const isRefreshed = ref(false)
|
||||||
|
|
||||||
|
/** 分页加载下载历史,并将新页追加到现有列表。 */
|
||||||
async function loadHistory({ done }: { done: (status: 'empty' | 'error' | 'ok') => void }) {
|
async function loadHistory({ done }: { done: (status: 'empty' | 'error' | 'ok') => void }) {
|
||||||
if (loading.value) {
|
if (loading.value) {
|
||||||
done('ok')
|
done('ok')
|
||||||
@@ -54,6 +55,7 @@ async function loadHistory({ done }: { done: (status: 'empty' | 'error' | 'ok')
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 删除指定下载历史,并在成功后同步移除当前列表项。 */
|
||||||
async function deleteHistory(item: DownloadHistory) {
|
async function deleteHistory(item: DownloadHistory) {
|
||||||
try {
|
try {
|
||||||
const result: { success?: boolean } = await api.delete('history/download', { data: item })
|
const result: { success?: boolean } = await api.delete('history/download', { data: item })
|
||||||
@@ -68,15 +70,19 @@ async function deleteHistory(item: DownloadHistory) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 优先返回海报,缺失时使用背景图,并统一转换为可展示地址。 */
|
||||||
function getHistoryImage(item: DownloadHistory) {
|
function getHistoryImage(item: DownloadHistory) {
|
||||||
if (!item.image) return noImage
|
const image = item.poster || item.image
|
||||||
return getDisplayImageUrl(item.image, globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE)
|
if (!image) return noImage
|
||||||
|
return getDisplayImageUrl(image, globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 返回下载历史的主标题。 */
|
||||||
function getHistoryTitle(item: DownloadHistory) {
|
function getHistoryTitle(item: DownloadHistory) {
|
||||||
return item.title || item.torrent_name || t('dialog.downloadHistory.unknownTitle')
|
return item.title || item.torrent_name || t('dialog.downloadHistory.unknownTitle')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 合并下载历史的季号和集号。 */
|
||||||
function getSeasonEpisode(item: DownloadHistory) {
|
function getSeasonEpisode(item: DownloadHistory) {
|
||||||
return `${item.seasons || ''}${item.episodes || ''}`
|
return `${item.seasons || ''}${item.episodes || ''}`
|
||||||
}
|
}
|
||||||
@@ -118,18 +124,19 @@ function getSeasonEpisode(item: DownloadHistory) {
|
|||||||
<template #empty />
|
<template #empty />
|
||||||
|
|
||||||
<VList lines="three" class="download-history-dialog__content py-0">
|
<VList lines="three" class="download-history-dialog__content py-0">
|
||||||
<VVirtualScroll v-if="historyList.length > 0" renderless :items="historyList" :item-height="136">
|
<VVirtualScroll v-if="historyList.length > 0" renderless :items="historyList" :item-height="120">
|
||||||
<template #default="{ item, itemRef }">
|
<template #default="{ item, itemRef }">
|
||||||
<div :ref="itemRef">
|
<div :ref="itemRef">
|
||||||
<VListItem class="download-history-item">
|
<VListItem class="download-history-item">
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<VImg
|
<VImg
|
||||||
height="64"
|
height="96"
|
||||||
width="96"
|
width="64"
|
||||||
:src="getHistoryImage(item)"
|
:src="getHistoryImage(item)"
|
||||||
aspect-ratio="3/2"
|
aspect-ratio="2/3"
|
||||||
class="download-history-item__image me-3 rounded-md"
|
class="download-history-item__image me-3 rounded-md"
|
||||||
cover
|
cover
|
||||||
|
position="center"
|
||||||
>
|
>
|
||||||
<template #placeholder>
|
<template #placeholder>
|
||||||
<VSkeletonLoader class="h-100 w-100" />
|
<VSkeletonLoader class="h-100 w-100" />
|
||||||
@@ -215,7 +222,8 @@ function getSeasonEpisode(item: DownloadHistory) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.download-history-item {
|
.download-history-item {
|
||||||
min-block-size: 8.5rem;
|
min-block-size: 7rem;
|
||||||
|
padding-block: 0.5rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.download-history-item__image {
|
.download-history-item__image {
|
||||||
@@ -248,6 +256,11 @@ function getSeasonEpisode(item: DownloadHistory) {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.download-history-item__date {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
.download-history-empty {
|
.download-history-empty {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -277,9 +290,13 @@ function getSeasonEpisode(item: DownloadHistory) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (width <= 600px) {
|
@media (width <= 600px) {
|
||||||
|
.download-history-item {
|
||||||
|
min-block-size: 6.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.download-history-item__image {
|
.download-history-item__image {
|
||||||
block-size: 54px !important;
|
block-size: 84px !important;
|
||||||
inline-size: 81px !important;
|
inline-size: 56px !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ function closeDialog() {
|
|||||||
variant="solo"
|
variant="solo"
|
||||||
prepend-inner-icon="mdi-magnify"
|
prepend-inner-icon="mdi-magnify"
|
||||||
flat
|
flat
|
||||||
|
autofocus
|
||||||
class="mx-1"
|
class="mx-1"
|
||||||
/>
|
/>
|
||||||
</VToolbar>
|
</VToolbar>
|
||||||
|
|||||||
@@ -81,6 +81,31 @@ const MenuStub = defineComponent({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const ImageStub = defineComponent({
|
||||||
|
name: 'VImg',
|
||||||
|
inheritAttrs: false,
|
||||||
|
props: {
|
||||||
|
aspectRatio: [String, Number],
|
||||||
|
cover: Boolean,
|
||||||
|
height: [String, Number],
|
||||||
|
position: String,
|
||||||
|
src: String,
|
||||||
|
width: [String, Number],
|
||||||
|
},
|
||||||
|
setup(props, { attrs }) {
|
||||||
|
return () =>
|
||||||
|
h('img', {
|
||||||
|
...attrs,
|
||||||
|
src: props.src,
|
||||||
|
'data-aspect-ratio': props.aspectRatio,
|
||||||
|
'data-cover': String(props.cover),
|
||||||
|
'data-height': props.height,
|
||||||
|
'data-position': props.position,
|
||||||
|
'data-width': props.width,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
let historySeed = 5000
|
let historySeed = 5000
|
||||||
|
|
||||||
function createHistory(overrides: Partial<DownloadHistory> = {}): DownloadHistory {
|
function createHistory(overrides: Partial<DownloadHistory> = {}): DownloadHistory {
|
||||||
@@ -90,7 +115,8 @@ function createHistory(overrides: Partial<DownloadHistory> = {}): DownloadHistor
|
|||||||
download_hash: `hash-${historySeed}`,
|
download_hash: `hash-${historySeed}`,
|
||||||
episodes: 'E01-E02',
|
episodes: 'E01-E02',
|
||||||
id: historySeed,
|
id: historySeed,
|
||||||
image: `https://images.example.com/history-${historySeed}.jpg`,
|
image: `https://images.example.com/backdrop-${historySeed}.jpg`,
|
||||||
|
poster: `https://images.example.com/poster-${historySeed}.jpg`,
|
||||||
path: `/downloads/history-${historySeed}`,
|
path: `/downloads/history-${historySeed}`,
|
||||||
seasons: 'S01',
|
seasons: 'S01',
|
||||||
title: `历史媒体 ${historySeed}`,
|
title: `历史媒体 ${historySeed}`,
|
||||||
@@ -123,6 +149,7 @@ async function renderDialog() {
|
|||||||
},
|
},
|
||||||
stubs: {
|
stubs: {
|
||||||
VInfiniteScroll: InfiniteScrollStub,
|
VInfiniteScroll: InfiniteScrollStub,
|
||||||
|
VImg: ImageStub,
|
||||||
VMenu: MenuStub,
|
VMenu: MenuStub,
|
||||||
VVirtualScroll: VirtualScrollStub,
|
VVirtualScroll: VirtualScrollStub,
|
||||||
},
|
},
|
||||||
@@ -138,9 +165,10 @@ describe('DownloadHistoryDialog', () => {
|
|||||||
|
|
||||||
it('loads and renders download history with page parameters', async () => {
|
it('loads and renders download history with page parameters', async () => {
|
||||||
const item = createHistory({ title: '首载剧集' })
|
const item = createHistory({ title: '首载剧集' })
|
||||||
|
const backdropItem = createHistory({ poster: undefined, title: '背景图历史' })
|
||||||
const requests: URL[] = []
|
const requests: URL[] = []
|
||||||
server.use(
|
server.use(
|
||||||
downloadHistoryHandler([item], 200, url => {
|
downloadHistoryHandler([item, backdropItem], 200, url => {
|
||||||
requests.push(url)
|
requests.push(url)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -148,9 +176,9 @@ describe('DownloadHistoryDialog', () => {
|
|||||||
await renderDialog()
|
await renderDialog()
|
||||||
|
|
||||||
expect(await screen.findByText('首载剧集')).toBeInTheDocument()
|
expect(await screen.findByText('首载剧集')).toBeInTheDocument()
|
||||||
expect(screen.getByText('(2026)')).toBeInTheDocument()
|
expect(screen.getAllByText('(2026)')).toHaveLength(2)
|
||||||
expect(screen.getByText('S01E01-E02').closest('.v-chip')).toBeInTheDocument()
|
expect(within(historyRow(item)).getByText('S01E01-E02').closest('.v-chip')).toBeInTheDocument()
|
||||||
expect(screen.getByText('示例站').closest('.v-chip')).toHaveClass('text-info')
|
expect(within(historyRow(item)).getByText('示例站').closest('.v-chip')).toHaveClass('text-info')
|
||||||
const resourceTitle = screen.getByText(item.torrent_name!)
|
const resourceTitle = screen.getByText(item.torrent_name!)
|
||||||
expect(resourceTitle).toHaveClass('download-history-item__torrent', 'download-history-item__meta')
|
expect(resourceTitle).toHaveClass('download-history-item__torrent', 'download-history-item__meta')
|
||||||
expect(document.querySelector('.download-history-item__date')).toHaveClass('download-history-item__meta')
|
expect(document.querySelector('.download-history-item__date')).toHaveClass('download-history-item__meta')
|
||||||
@@ -159,6 +187,16 @@ describe('DownloadHistoryDialog', () => {
|
|||||||
expect(requests).toHaveLength(1)
|
expect(requests).toHaveLength(1)
|
||||||
expect(requests[0].searchParams.get('page')).toBe('1')
|
expect(requests[0].searchParams.get('page')).toBe('1')
|
||||||
expect(requests[0].searchParams.get('count')).toBe('30')
|
expect(requests[0].searchParams.get('count')).toBe('30')
|
||||||
|
const posterImage = historyRow(item).querySelector<HTMLImageElement>('img.download-history-item__image')
|
||||||
|
const backdropImage = historyRow(backdropItem).querySelector<HTMLImageElement>('img.download-history-item__image')
|
||||||
|
|
||||||
|
expect(posterImage).toHaveAttribute('src', item.poster)
|
||||||
|
expect(backdropImage).toHaveAttribute('src', backdropItem.image)
|
||||||
|
expect(posterImage).toHaveAttribute('data-aspect-ratio', '2/3')
|
||||||
|
expect(posterImage).toHaveAttribute('data-cover', 'true')
|
||||||
|
expect(posterImage).toHaveAttribute('data-height', '96')
|
||||||
|
expect(posterImage).toHaveAttribute('data-position', 'center')
|
||||||
|
expect(posterImage).toHaveAttribute('data-width', '64')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('appends later pages and preserves existing rows at the end', async () => {
|
it('appends later pages and preserves existing rows at the end', async () => {
|
||||||
|
|||||||
@@ -104,11 +104,12 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VCard class="mx-auto" width="100%">
|
<VCard class="media-id-selector mx-auto" width="100%">
|
||||||
<VCardTitle class="d-flex align-center justify-space-between">
|
<VCardTitle class="d-flex align-center justify-space-between">
|
||||||
<span>{{ t('dialog.reorganize.mediaSearchInput') }}</span>
|
<span>{{ t('dialog.reorganize.mediaSearchInput') }}</span>
|
||||||
<VDialogCloseBtn
|
<VDialogCloseBtn
|
||||||
inner-class="static"
|
:aria-label="t('common.close')"
|
||||||
|
inner-class="media-id-selector__close"
|
||||||
@click="
|
@click="
|
||||||
() => {
|
() => {
|
||||||
emit('close')
|
emit('close')
|
||||||
@@ -116,24 +117,25 @@ onMounted(() => {
|
|||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
</VCardTitle>
|
</VCardTitle>
|
||||||
<VCardText class="pt-0">
|
<VCardText class="media-id-selector__search">
|
||||||
<VTextField
|
<VTextField
|
||||||
ref="inputKeyword"
|
ref="inputKeyword"
|
||||||
v-model="keyword"
|
v-model="keyword"
|
||||||
:mobile-layout="false"
|
:mobile-layout="false"
|
||||||
single-line
|
single-line
|
||||||
:placeholder="t('dialog.reorganize.mediaSearchPlaceholder')"
|
:placeholder="t('dialog.reorganize.mediaSearchPlaceholder')"
|
||||||
variant="solo"
|
variant="outlined"
|
||||||
append-inner-icon="mdi-magnify"
|
append-inner-icon="mdi-magnify"
|
||||||
flat
|
flat
|
||||||
|
hide-details
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
@click:append-inner="searchMedias"
|
@click:append-inner="searchMedias"
|
||||||
@keydown.enter="searchMedias"
|
@keydown.enter="searchMedias"
|
||||||
/>
|
/>
|
||||||
</VCardText>
|
</VCardText>
|
||||||
<VDivider />
|
<VDivider />
|
||||||
<VList v-if="items.length > 0" lines="three">
|
<VList v-if="items.length > 0" class="media-id-selector__results" lines="three">
|
||||||
<template v-for="(item, i) in items" :key="i">
|
<template v-for="item in items" :key="`${item.type || 'media'}-${item.id}`">
|
||||||
<VListItem @click="selectMedia(item)">
|
<VListItem @click="selectMedia(item)">
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<VImg
|
<VImg
|
||||||
@@ -160,3 +162,29 @@ onMounted(() => {
|
|||||||
</VList>
|
</VList>
|
||||||
</VCard>
|
</VCard>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.media-id-selector {
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-id-selector__search {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
overflow: visible !important;
|
||||||
|
padding-block: 0.25rem 1rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-id-selector__close {
|
||||||
|
position: relative;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-id-selector__results {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-block-size: 0;
|
||||||
|
overflow-anchor: none;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import MediaIdSelector from '@/components/misc/MediaIdSelector.vue'
|
||||||
|
import { fireEvent, screen } from '@testing-library/vue'
|
||||||
|
import { renderWithProviders } from '@tests/support/render'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
apiGet: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api', () => ({
|
||||||
|
default: { get: mocks.apiGet },
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('MediaIdSelector layout', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.apiGet.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the search field visible while results scroll independently', async () => {
|
||||||
|
mocks.apiGet.mockResolvedValue([
|
||||||
|
{
|
||||||
|
media_id: 'tmdb-1',
|
||||||
|
overview: '测试简介',
|
||||||
|
poster_path: '',
|
||||||
|
title: 'Hello Mini',
|
||||||
|
type: '电视剧',
|
||||||
|
year: '2019',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const { container } = await renderWithProviders(MediaIdSelector, {
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
VDialogCloseBtn: {
|
||||||
|
props: ['innerClass'],
|
||||||
|
template: '<button type="button" :class="innerClass"><slot /></button>',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const searchPanel = container.querySelector('.media-id-selector__search')
|
||||||
|
const closeButton = container.querySelector('.media-id-selector__close')
|
||||||
|
|
||||||
|
expect(container.querySelector('.media-id-selector')).toBeInTheDocument()
|
||||||
|
expect(searchPanel).toBeInstanceOf(HTMLElement)
|
||||||
|
expect(closeButton).toBeInstanceOf(HTMLButtonElement)
|
||||||
|
expect(container.querySelector('.v-input__details')).not.toBeInTheDocument()
|
||||||
|
expect(closeButton).toHaveAttribute('aria-label', '关闭')
|
||||||
|
expect(closeButton).not.toHaveClass('static')
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText('输入媒体名称')
|
||||||
|
await fireEvent.update(input, 'hello')
|
||||||
|
await fireEvent.keyDown(input, { key: 'Enter' })
|
||||||
|
expect(await screen.findByText('Hello Mini(2019)')).toBeInTheDocument()
|
||||||
|
|
||||||
|
const results = container.querySelector('.media-id-selector__results')
|
||||||
|
expect(results).toBeInstanceOf(HTMLElement)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user