mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-08 09:16:58 +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
|
||||
// 集 Exx
|
||||
episodes?: string
|
||||
// 海报或背景图
|
||||
// 背景图
|
||||
image?: string
|
||||
// 海报
|
||||
poster?: string
|
||||
// 下载器 Hash
|
||||
download_hash?: string
|
||||
// 种子名称
|
||||
|
||||
@@ -25,13 +25,13 @@ const imageLoadError = ref(false)
|
||||
const media = computed(() => props.info?.media ?? {})
|
||||
|
||||
watch(
|
||||
() => media.value.image,
|
||||
() => media.value.poster,
|
||||
() => {
|
||||
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'))
|
||||
|
||||
@@ -149,111 +149,124 @@ async function deleteDownload() {
|
||||
<template #default="hover">
|
||||
<!-- Hover 命中区域保持静止,避免卡片上浮后底边反复触发 mouseleave。 -->
|
||||
<div v-if="cardState" v-bind="hover.props" class="downloading-card-hover-area h-full">
|
||||
<VCard
|
||||
:key="props.info?.hash"
|
||||
class="downloading-card app-hover-lift-card app-surface h-full overflow-hidden"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': hover.isHovering,
|
||||
'downloading-card--hovering': hover.isHovering,
|
||||
'downloading-card--no-image': !hasPosterImage,
|
||||
}"
|
||||
<div
|
||||
class="downloading-card-shell app-hover-lift-card h-full"
|
||||
:class="{ 'app-hover-lift-card--hovering': hover.isHovering }"
|
||||
>
|
||||
<div v-if="hasPosterImage" class="downloading-card__poster">
|
||||
<VImg :src="media.image" class="downloading-card__image" position="center" @error="imageLoadError = true">
|
||||
<template #placeholder>
|
||||
<VSkeletonLoader class="downloading-card__image-loader h-full" />
|
||||
</template>
|
||||
</VImg>
|
||||
<div class="downloading-card__poster-edge" />
|
||||
</div>
|
||||
|
||||
<VCardText class="downloading-card__body">
|
||||
<div class="downloading-card__chips">
|
||||
<VChip v-if="mediaTypeText" :prepend-icon="mediaTypeIcon" color="primary" size="x-small" variant="tonal">
|
||||
{{ mediaTypeText }}
|
||||
</VChip>
|
||||
<VChip v-if="sourceSiteText" prepend-icon="mdi-web" size="x-small" variant="tonal">
|
||||
{{ sourceSiteText }}
|
||||
</VChip>
|
||||
<VChip v-else prepend-icon="mdi-harddisk" size="x-small" variant="tonal">
|
||||
{{ sizeText }}
|
||||
</VChip>
|
||||
<VCard
|
||||
:key="props.info?.hash"
|
||||
class="downloading-card h-full overflow-hidden"
|
||||
:class="{ 'downloading-card--no-image': !hasPosterImage }"
|
||||
>
|
||||
<div v-if="hasPosterImage" class="downloading-card__poster">
|
||||
<VImg
|
||||
:src="media.poster"
|
||||
class="downloading-card__image"
|
||||
cover
|
||||
position="center"
|
||||
@error="imageLoadError = true"
|
||||
>
|
||||
<template #placeholder>
|
||||
<VSkeletonLoader class="downloading-card__image-loader h-full" />
|
||||
</template>
|
||||
</VImg>
|
||||
<div class="downloading-card__poster-edge" />
|
||||
</div>
|
||||
|
||||
<div class="downloading-card__heading">
|
||||
<div class="downloading-card__title" :title="mediaTitle">
|
||||
<span>{{ mediaTitle }}</span>
|
||||
<span v-if="titleMetaText" class="downloading-card__title-meta">{{ titleMetaText }}</span>
|
||||
</div>
|
||||
<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'"
|
||||
<VCardText class="downloading-card__body">
|
||||
<div class="downloading-card__chips">
|
||||
<VChip
|
||||
v-if="mediaTypeText"
|
||||
:prepend-icon="mediaTypeIcon"
|
||||
color="primary"
|
||||
size="small"
|
||||
size="x-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>
|
||||
{{ mediaTypeText }}
|
||||
</VChip>
|
||||
<VChip v-if="sourceSiteText" prepend-icon="mdi-web" size="x-small" variant="tonal">
|
||||
{{ sourceSiteText }}
|
||||
</VChip>
|
||||
<VChip v-else prepend-icon="mdi-harddisk" size="x-small" variant="tonal">
|
||||
{{ sizeText }}
|
||||
</VChip>
|
||||
</div>
|
||||
|
||||
<div class="downloading-card__heading">
|
||||
<div class="downloading-card__title" :title="mediaTitle">
|
||||
<span>{{ mediaTitle }}</span>
|
||||
<span v-if="titleMetaText" class="downloading-card__title-meta">{{ titleMetaText }}</span>
|
||||
</div>
|
||||
<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"
|
||||
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>
|
||||
</template>
|
||||
</VHover>
|
||||
@@ -268,11 +281,15 @@ async function deleteDownload() {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.downloading-card-shell {
|
||||
border-radius: var(--app-surface-radius);
|
||||
}
|
||||
|
||||
.downloading-card {
|
||||
display: grid;
|
||||
min-block-size: 12rem;
|
||||
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 {
|
||||
@@ -339,9 +356,9 @@ async function deleteDownload() {
|
||||
|
||||
.downloading-card__title-meta {
|
||||
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-weight: 650;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -444,7 +461,7 @@ async function deleteDownload() {
|
||||
@container (width <= 25rem) {
|
||||
.downloading-card {
|
||||
min-block-size: 11rem;
|
||||
grid-template-columns: 6.75rem minmax(0, 1fr);
|
||||
grid-template-columns: 7.333rem minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.downloading-card__poster {
|
||||
@@ -478,10 +495,6 @@ async function deleteDownload() {
|
||||
}
|
||||
|
||||
@container (width <= 21rem) {
|
||||
.downloading-card {
|
||||
grid-template-columns: 6.25rem minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.downloading-card__body {
|
||||
padding-inline: 0.65rem !important;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ function downloading(overrides: Partial<DownloadingInfo> = {}): DownloadingInfo
|
||||
left_time: '1 小时',
|
||||
media: {
|
||||
episode: 'E02',
|
||||
image: 'https://images.example.com/poster.jpg',
|
||||
poster: 'https://images.example.com/poster.jpg',
|
||||
season: 'S01',
|
||||
title: '测试媒体',
|
||||
},
|
||||
@@ -71,6 +71,32 @@ describe('DownloadingCard display and pause state', () => {
|
||||
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 () => {
|
||||
const stopRequested = vi.fn()
|
||||
const startRequested = vi.fn()
|
||||
|
||||
@@ -22,6 +22,7 @@ const pageSize = 30
|
||||
const loading = ref(false)
|
||||
const isRefreshed = ref(false)
|
||||
|
||||
/** 分页加载下载历史,并将新页追加到现有列表。 */
|
||||
async function loadHistory({ done }: { done: (status: 'empty' | 'error' | 'ok') => void }) {
|
||||
if (loading.value) {
|
||||
done('ok')
|
||||
@@ -54,6 +55,7 @@ async function loadHistory({ done }: { done: (status: 'empty' | 'error' | 'ok')
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除指定下载历史,并在成功后同步移除当前列表项。 */
|
||||
async function deleteHistory(item: DownloadHistory) {
|
||||
try {
|
||||
const result: { success?: boolean } = await api.delete('history/download', { data: item })
|
||||
@@ -68,15 +70,19 @@ async function deleteHistory(item: DownloadHistory) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 优先返回海报,缺失时使用背景图,并统一转换为可展示地址。 */
|
||||
function getHistoryImage(item: DownloadHistory) {
|
||||
if (!item.image) return noImage
|
||||
return getDisplayImageUrl(item.image, globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE)
|
||||
const image = item.poster || item.image
|
||||
if (!image) return noImage
|
||||
return getDisplayImageUrl(image, globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE)
|
||||
}
|
||||
|
||||
/** 返回下载历史的主标题。 */
|
||||
function getHistoryTitle(item: DownloadHistory) {
|
||||
return item.title || item.torrent_name || t('dialog.downloadHistory.unknownTitle')
|
||||
}
|
||||
|
||||
/** 合并下载历史的季号和集号。 */
|
||||
function getSeasonEpisode(item: DownloadHistory) {
|
||||
return `${item.seasons || ''}${item.episodes || ''}`
|
||||
}
|
||||
@@ -118,18 +124,19 @@ function getSeasonEpisode(item: DownloadHistory) {
|
||||
<template #empty />
|
||||
|
||||
<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 }">
|
||||
<div :ref="itemRef">
|
||||
<VListItem class="download-history-item">
|
||||
<template #prepend>
|
||||
<VImg
|
||||
height="64"
|
||||
width="96"
|
||||
height="96"
|
||||
width="64"
|
||||
:src="getHistoryImage(item)"
|
||||
aspect-ratio="3/2"
|
||||
aspect-ratio="2/3"
|
||||
class="download-history-item__image me-3 rounded-md"
|
||||
cover
|
||||
position="center"
|
||||
>
|
||||
<template #placeholder>
|
||||
<VSkeletonLoader class="h-100 w-100" />
|
||||
@@ -215,7 +222,8 @@ function getSeasonEpisode(item: DownloadHistory) {
|
||||
}
|
||||
|
||||
.download-history-item {
|
||||
min-block-size: 8.5rem;
|
||||
min-block-size: 7rem;
|
||||
padding-block: 0.5rem !important;
|
||||
}
|
||||
|
||||
.download-history-item__image {
|
||||
@@ -248,6 +256,11 @@ function getSeasonEpisode(item: DownloadHistory) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.download-history-item__date {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.download-history-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -277,9 +290,13 @@ function getSeasonEpisode(item: DownloadHistory) {
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
.download-history-item {
|
||||
min-block-size: 6.5rem;
|
||||
}
|
||||
|
||||
.download-history-item__image {
|
||||
block-size: 54px !important;
|
||||
inline-size: 81px !important;
|
||||
block-size: 84px !important;
|
||||
inline-size: 56px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -86,6 +86,7 @@ function closeDialog() {
|
||||
variant="solo"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
flat
|
||||
autofocus
|
||||
class="mx-1"
|
||||
/>
|
||||
</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
|
||||
|
||||
function createHistory(overrides: Partial<DownloadHistory> = {}): DownloadHistory {
|
||||
@@ -90,7 +115,8 @@ function createHistory(overrides: Partial<DownloadHistory> = {}): DownloadHistor
|
||||
download_hash: `hash-${historySeed}`,
|
||||
episodes: 'E01-E02',
|
||||
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}`,
|
||||
seasons: 'S01',
|
||||
title: `历史媒体 ${historySeed}`,
|
||||
@@ -123,6 +149,7 @@ async function renderDialog() {
|
||||
},
|
||||
stubs: {
|
||||
VInfiniteScroll: InfiniteScrollStub,
|
||||
VImg: ImageStub,
|
||||
VMenu: MenuStub,
|
||||
VVirtualScroll: VirtualScrollStub,
|
||||
},
|
||||
@@ -138,9 +165,10 @@ describe('DownloadHistoryDialog', () => {
|
||||
|
||||
it('loads and renders download history with page parameters', async () => {
|
||||
const item = createHistory({ title: '首载剧集' })
|
||||
const backdropItem = createHistory({ poster: undefined, title: '背景图历史' })
|
||||
const requests: URL[] = []
|
||||
server.use(
|
||||
downloadHistoryHandler([item], 200, url => {
|
||||
downloadHistoryHandler([item, backdropItem], 200, url => {
|
||||
requests.push(url)
|
||||
}),
|
||||
)
|
||||
@@ -148,9 +176,9 @@ describe('DownloadHistoryDialog', () => {
|
||||
await renderDialog()
|
||||
|
||||
expect(await screen.findByText('首载剧集')).toBeInTheDocument()
|
||||
expect(screen.getByText('(2026)')).toBeInTheDocument()
|
||||
expect(screen.getByText('S01E01-E02').closest('.v-chip')).toBeInTheDocument()
|
||||
expect(screen.getByText('示例站').closest('.v-chip')).toHaveClass('text-info')
|
||||
expect(screen.getAllByText('(2026)')).toHaveLength(2)
|
||||
expect(within(historyRow(item)).getByText('S01E01-E02').closest('.v-chip')).toBeInTheDocument()
|
||||
expect(within(historyRow(item)).getByText('示例站').closest('.v-chip')).toHaveClass('text-info')
|
||||
const resourceTitle = screen.getByText(item.torrent_name!)
|
||||
expect(resourceTitle).toHaveClass('download-history-item__torrent', '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[0].searchParams.get('page')).toBe('1')
|
||||
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 () => {
|
||||
|
||||
@@ -104,11 +104,12 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<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">
|
||||
<span>{{ t('dialog.reorganize.mediaSearchInput') }}</span>
|
||||
<VDialogCloseBtn
|
||||
inner-class="static"
|
||||
:aria-label="t('common.close')"
|
||||
inner-class="media-id-selector__close"
|
||||
@click="
|
||||
() => {
|
||||
emit('close')
|
||||
@@ -116,24 +117,25 @@ onMounted(() => {
|
||||
"
|
||||
/>
|
||||
</VCardTitle>
|
||||
<VCardText class="pt-0">
|
||||
<VCardText class="media-id-selector__search">
|
||||
<VTextField
|
||||
ref="inputKeyword"
|
||||
v-model="keyword"
|
||||
:mobile-layout="false"
|
||||
single-line
|
||||
:placeholder="t('dialog.reorganize.mediaSearchPlaceholder')"
|
||||
variant="solo"
|
||||
variant="outlined"
|
||||
append-inner-icon="mdi-magnify"
|
||||
flat
|
||||
hide-details
|
||||
:loading="loading"
|
||||
@click:append-inner="searchMedias"
|
||||
@keydown.enter="searchMedias"
|
||||
/>
|
||||
</VCardText>
|
||||
<VDivider />
|
||||
<VList v-if="items.length > 0" lines="three">
|
||||
<template v-for="(item, i) in items" :key="i">
|
||||
<VList v-if="items.length > 0" class="media-id-selector__results" lines="three">
|
||||
<template v-for="item in items" :key="`${item.type || 'media'}-${item.id}`">
|
||||
<VListItem @click="selectMedia(item)">
|
||||
<template #prepend>
|
||||
<VImg
|
||||
@@ -160,3 +162,29 @@ onMounted(() => {
|
||||
</VList>
|
||||
</VCard>
|
||||
</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