Improve card badge layouts and expand card tests

This commit is contained in:
jxxghp
2026-08-03 22:16:28 +08:00
parent cd993e3cf4
commit e2902adba8
4 changed files with 179 additions and 27 deletions

View File

@@ -447,7 +447,7 @@ function handleCardClick() {
:ripple="display.smAndUp.value && !props.batchMode && !props.sortable"
>
<div
v-if="bestVersionBadge && imageLoaded"
v-if="bestVersionBadge && imageLoaded && display.smAndUp.value"
class="best-version-badge"
:class="{ 'best-version-badge-full': bestVersionBadge.full }"
>
@@ -498,11 +498,7 @@ function handleCardClick() {
</VImg>
<div class="subscribe-card-mobile-image-scrim subscribe-card-background"></div>
<div
v-if="props.media?.username || lastUpdateText"
class="subscribe-card-mobile-image-meta"
:class="{ 'subscribe-card-mobile-image-meta--with-badge': bestVersionBadge }"
>
<div v-if="props.media?.username || lastUpdateText" class="subscribe-card-mobile-image-meta">
<div
v-if="props.media?.username"
class="subscribe-card-mobile-image-meta__item subscribe-card-mobile-image-meta__user"
@@ -521,13 +517,22 @@ function handleCardClick() {
</div>
<div class="subscribe-card-mobile-title">
<span>{{ props.media?.name }}</span>
<span
v-if="formatSeasonLabel(props.media?.season, t('media.specials'))"
class="subscribe-card-mobile-season"
v-if="bestVersionBadge && imageLoaded"
class="best-version-badge subscribe-card-mobile-best-version-badge"
:class="{ 'best-version-badge-full': bestVersionBadge.full }"
>
{{ formatSeasonLabel(props.media?.season, t('media.specials')) }}
<VIcon :icon="bestVersionBadge.icon" color="white" size="14" />
</span>
<div class="subscribe-card-mobile-title-text">
<span>{{ props.media?.name }}</span>
<span
v-if="formatSeasonLabel(props.media?.season, t('media.specials'))"
class="subscribe-card-mobile-season"
>
{{ formatSeasonLabel(props.media?.season, t('media.specials')) }}
</span>
</div>
</div>
</div>
@@ -761,10 +766,6 @@ function handleCardClick() {
flex: 1 1 auto;
}
.subscribe-card-mobile-image-meta--with-badge {
padding-inline-start: 2rem;
}
.subscribe-card-mobile-image-meta__user span {
min-inline-size: 0;
overflow: hidden;
@@ -789,18 +790,36 @@ function handleCardClick() {
.subscribe-card-mobile-title {
position: absolute;
z-index: 2;
display: -webkit-box;
overflow: hidden;
display: flex;
align-items: flex-start;
gap: 0.375rem;
color: white;
font-size: 1rem;
font-weight: 650;
inset-block-end: 0;
inset-inline: 0;
line-height: 1.3;
padding: 1.75rem 0.75rem 0.625rem;
padding: 1rem 0.75rem 0.625rem;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.95);
}
.subscribe-card-mobile-title-text {
display: -webkit-box;
min-inline-size: 0;
max-block-size: 3.9em;
overflow: hidden;
flex: 1 1 auto;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
-webkit-line-clamp: 3;
}
.best-version-badge.subscribe-card-mobile-best-version-badge {
position: static;
z-index: auto;
block-size: 1.25rem;
flex: 0 0 1.25rem;
inline-size: 1.25rem;
margin-block-start: 0.05rem;
}
.subscribe-card-mobile-season {
@@ -907,7 +926,7 @@ function handleCardClick() {
}
/**
* 洗版标识:卡片左上角 24x24 圆形徽标
* 洗版标识:桌面端左上角使用 24x24 圆形徽标,移动端缩小后放在标题前。
* 分集:深色半透底 + 模糊
* 全集:磨砂玻璃半透白底 + 大模糊
*/
@@ -947,10 +966,6 @@ function handleCardClick() {
opacity: 0.58;
}
.best-version-badge {
inset-inline-start: 0.5rem;
}
.subscribe-card-pending-tint::after {
box-shadow:
inset 0 0 0 1px rgba(var(--v-theme-info), 0.28),

View File

@@ -4,9 +4,16 @@ import { fireEvent, screen, waitFor } from '@testing-library/vue'
import { renderWithProviders } from '@tests/support/render'
import { deleteDownloadHandler, downloadActionHandler } from '@tests/support/msw/handlers/download'
import { server } from '@tests/support/msw/server'
import { defineComponent, h } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
function downloading(overrides: Partial<DownloadingInfo> = {}): DownloadingInfo {
interface DownloadingCardInfo extends DownloadingInfo {
site_name?: string
trackers?: string[]
}
/** 创建可按用例覆盖字段的下载任务数据。 */
function downloading(overrides: Partial<DownloadingCardInfo> = {}): DownloadingCardInfo {
return {
dlspeed: '2 MiB',
hash: 'hash-1',
@@ -28,12 +35,14 @@ function downloading(overrides: Partial<DownloadingInfo> = {}): DownloadingInfo
}
}
/** 使用生产插件和指定下载器渲染下载任务卡片。 */
async function renderCard(info = downloading(), downloaderName = 'qb-main') {
return renderWithProviders(DownloadingCard, {
props: { downloaderName, info },
})
}
/** 获取卡片的继续/暂停和删除操作按钮。 */
function actionButtons(container: Element) {
const buttons = [...container.querySelectorAll<HTMLButtonElement>('.v-card-actions button')]
expect(buttons).toHaveLength(2)
@@ -71,6 +80,89 @@ describe('DownloadingCard display and pause state', () => {
expect(container.querySelector('.v-card-text .v-progress-linear')).not.toBeInTheDocument()
})
it('normalizes media types, progress bounds, speeds and missing metadata', async () => {
const { container, rerender } = await renderCard(
downloading({
dlspeed: ' 3 MiB/s ',
left_time: ' ',
media: { title: '英文电影类型', type: 'movie' },
progress: 101,
size: 0,
upspeed: ' ',
}),
)
expect(container.querySelector('.downloading-card__chips')).toHaveTextContent('电影')
expect(container.querySelector('.downloading-card__progress')).toHaveTextContent('100%')
expect(container.querySelector('.downloading-card__progress')).toHaveTextContent('--')
expect(container.querySelector('.downloading-card__speeds')).toHaveTextContent('3 MiB/s')
expect(container.querySelector('.downloading-card__speeds')).toHaveTextContent('0 B/s')
expect(container.querySelector('.downloading-card__chips')).toHaveTextContent('0.00 B')
for (const [type, label] of [
['电影', '电影'],
['tv', '电视剧'],
['电视剧', '电视剧'],
['纪录片', '纪录片'],
]) {
await rerender({
downloaderName: 'qb-main',
info: downloading({ media: { title: `${type}标题`, type }, progress: -1 }),
})
expect(container.querySelector('.downloading-card__chips')).toHaveTextContent(label)
expect(container.querySelector('.downloading-card__progress')).not.toBeInTheDocument()
}
await rerender({
downloaderName: 'qb-main',
info: downloading({
dlspeed: undefined,
media: { title: '无类型电影' },
progress: Number.NaN,
season_episode: undefined,
upspeed: '1 MiB/s',
}),
})
expect(container.querySelector('.downloading-card__chips')).toHaveTextContent('电影')
expect(container.querySelector('.downloading-card__speeds')).toHaveTextContent('0 B/s')
expect(container.querySelector('.downloading-card__speeds')).toHaveTextContent('1 MiB/s')
await rerender({
downloaderName: 'qb-main',
info: downloading({ media: { season: 'S02' }, name: '', season_episode: undefined, title: '任务标题回退' }),
})
expect(container.querySelector('.downloading-card__chips')).toHaveTextContent('电视剧')
expect(container.querySelector('.downloading-card__title')).toHaveTextContent('任务标题回退')
await rerender({ downloaderName: '', info: undefined })
expect(container.querySelector('.downloading-card__title')).toHaveTextContent('未知')
expect(container.querySelectorAll('.downloading-card__chips .v-chip')).toHaveLength(1)
})
it('prefers explicit site names and safely reduces tracker URLs to hostnames', async () => {
const { container, rerender } = await renderCard(downloading({ site_name: ' M-Team ' }))
expect(container.querySelector('.downloading-card__chips')).toHaveTextContent('M-Team')
await rerender({
downloaderName: 'qb-main',
info: downloading({ media: { ...downloading().media, site_name: '媒体站点' }, site_name: undefined }),
})
expect(container.querySelector('.downloading-card__chips')).toHaveTextContent('媒体站点')
await rerender({
downloaderName: 'qb-main',
info: downloading({
media: downloading().media,
site_name: undefined,
trackers: ['', 'not-a-url', 'https://www.tracker.example.com/announce?passkey=secret'],
}),
})
expect(container.querySelector('.downloading-card__chips')).toHaveTextContent('tracker.example.com')
expect(container).not.toHaveTextContent('passkey')
expect(container).not.toHaveTextContent('secret')
})
it('only renders a centered cover image when a poster is available', async () => {
const { container, rerender } = await renderCard()
@@ -85,6 +177,30 @@ describe('DownloadingCard display and pause state', () => {
expect(container.querySelector('.downloading-card__image')).not.toBeInTheDocument()
})
it('hides a failed poster and retries when the task receives a new poster', async () => {
const VImgStub = defineComponent({
name: 'VImg',
emits: ['error'],
/** 提供可由用例主动触发失败事件的图片替身。 */
setup(_props, { emit }) {
return () => h('button', { 'aria-label': '图片加载失败', onClick: () => emit('error') })
},
})
const { container, rerender } = await renderWithProviders(DownloadingCard, {
props: { downloaderName: 'qb-main', info: downloading() },
global: { stubs: { VImg: VImgStub } },
})
await fireEvent.click(screen.getByRole('button', { name: '图片加载失败' }))
await waitFor(() => expect(container.querySelector('.downloading-card')).toHaveClass('downloading-card--no-image'))
await rerender({
downloaderName: 'qb-main',
info: downloading({ media: { ...downloading().media, poster: 'https://images.example.com/new-poster.jpg' } }),
})
await waitFor(() => expect(screen.getByRole('button', { name: '图片加载失败' })).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')!

View File

@@ -32,17 +32,22 @@ const selectedSitesUrl = new URL('system/setting/public/IndexerSites', API_BASE_
let intersectionObservers: IntersectionObserverMock[] = []
/** 提供可手动触发的视口观察器,供媒体卡片测试验证懒加载行为。 */
class IntersectionObserverMock implements IntersectionObserver {
readonly root: Element | Document | null
readonly rootMargin: string
readonly thresholds: readonly number[]
/** 记录观察器释放调用。 */
readonly disconnect = vi.fn()
/** 记录被观察元素,供后续构造交叉状态。 */
readonly observe = vi.fn((target: Element) => {
this.target = target
})
/** 记录停止观察调用。 */
readonly unobserve = vi.fn()
private target: Element = document.body
/** 创建使用指定回调和阈值的测试观察器。 */
constructor(
private readonly callback: IntersectionObserverCallback,
options: IntersectionObserverInit = {},
@@ -53,10 +58,12 @@ class IntersectionObserverMock implements IntersectionObserver {
intersectionObservers.push(this)
}
/** 返回测试期间未消费的观察记录。 */
takeRecords(): IntersectionObserverEntry[] {
return []
}
/** 手动向组件发送进入或离开视口的交叉状态。 */
trigger(isIntersecting = true) {
const bounds = this.target.getBoundingClientRect()
this.callback(
@@ -565,15 +572,19 @@ describe('MediaCard', () => {
h('div', [h('button', { 'aria-label': '图片加载成功', onClick: () => emit('load') }), slots.default?.()])
},
})
const VIconStub = {
props: ['icon'],
template: '<i :data-icon="icon" />',
}
const { container } = await renderWithProviders(MediaCard, {
props: { media, width: '9rem' },
initialState: { user: { superUser: true } },
global: { stubs: { VImg: VImgStub } },
global: { stubs: { VIcon: VIconStub, VImg: VImgStub } },
})
await fireEvent.click(container.querySelector('[aria-label="图片加载成功"]') as HTMLElement)
await waitFor(() => expect(container.querySelector('.v-avatar .iconify--mdi')).not.toBeNull())
await waitFor(() => expect(container.querySelector('[data-icon="mdi-alpha-a-circle"]')).not.toBeNull())
})
it('hides search and subscribe actions when the user lacks both permissions', async () => {

View File

@@ -196,7 +196,7 @@ describe('SubscribeCard display and progress', () => {
it('keeps mobile wash progress compact while preserving P, S, and R metadata', async () => {
setViewport(480)
const { media, rerender } = await renderCard({
const { container, media, rerender } = await renderCard({
best_version: true,
completed_episode: 3,
lack_episode: 2,
@@ -216,6 +216,16 @@ describe('SubscribeCard display and progress', () => {
expect(document.querySelector('.subscribe-card-mobile-image-meta__updated')).toHaveTextContent(lastUpdateText)
expect(document.querySelector('.subscribe-card-mobile-body')).not.toHaveTextContent('卡片测试媒体')
expect(document.querySelector('.subscribe-card-mobile-season')).toHaveTextContent('S01')
expect(document.querySelector('.subscribe-card-mobile-title-text')).toHaveTextContent('卡片测试媒体S01')
const image = container.querySelector<HTMLImageElement>('img')
expect(image).not.toBeNull()
await fireEvent.load(image as HTMLImageElement)
expect(document.querySelector('.subscribe-card-mobile-title')).toContainElement(
document.querySelector('.subscribe-card-mobile-best-version-badge'),
)
expect(document.querySelector('.subscribe-card-mobile-image-meta')).not.toHaveClass(
'subscribe-card-mobile-image-meta--with-badge',
)
await rerender({ media: { ...media, state: 'S' } })
expect(screen.getByLabelText('已暂停')).toBeInTheDocument()