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:
jxxghp
2026-08-03 21:23:20 +08:00
committed by GitHub
parent 37e645ed9c
commit 4097fd74dd
8 changed files with 315 additions and 131 deletions
+3 -1
View File
@@ -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
// 种子名称 // 种子名称
+122 -109
View File
@@ -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 () => {
+34 -6
View File
@@ -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 Mini2019')).toBeInTheDocument()
const results = container.querySelector('.media-id-selector__results')
expect(results).toBeInstanceOf(HTMLElement)
})
})