mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-04 15:10:56 +08:00
Merge branch 'v2' into v3
This commit is contained in:
@@ -62,6 +62,34 @@ const isImageLoaded = ref(false)
|
||||
// 图片加载失败
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
// 图片请求代际隔离复用卡片的迟到事件,避免旧海报改变新媒体的 renderer 资格。
|
||||
const imageRequestRevision = ref(0)
|
||||
|
||||
// renderer 只能在真实海报完成可见淡入后退出,资源 load 本身不代表像素已完全覆盖卡片。
|
||||
const hasCompletedPosterReveal = ref(false)
|
||||
const POSTER_REVEAL_FALLBACK_MS = 400
|
||||
let posterRevealFallbackTimer: number | null = null
|
||||
|
||||
/** 清理当前海报的视觉覆盖状态与兜底提交。 */
|
||||
function resetPosterRevealState() {
|
||||
hasCompletedPosterReveal.value = false
|
||||
if (posterRevealFallbackTimer === null) return
|
||||
|
||||
window.clearTimeout(posterRevealFallbackTimer)
|
||||
posterRevealFallbackTimer = null
|
||||
}
|
||||
|
||||
/** 仅允许当前真实海报请求完成 renderer 排除提交。 */
|
||||
function completePosterReveal(revision: number, usesFallback: boolean) {
|
||||
if (revision !== imageRequestRevision.value || usesFallback) return
|
||||
|
||||
if (posterRevealFallbackTimer !== null) {
|
||||
window.clearTimeout(posterRevealFallbackTimer)
|
||||
posterRevealFallbackTimer = null
|
||||
}
|
||||
hasCompletedPosterReveal.value = true
|
||||
}
|
||||
|
||||
// 当前订阅状态
|
||||
const isSubscribed = ref(false)
|
||||
|
||||
@@ -430,6 +458,53 @@ const getImgUrl: Ref<string> = computed(() => {
|
||||
return getDisplayImageUrl(url, globalSettings.GLOBAL_IMAGE_CACHE)
|
||||
})
|
||||
|
||||
const hasLoadedRealPoster = computed(
|
||||
() => Boolean(getImgUrl.value) && isImageLoaded.value && hasCompletedPosterReveal.value && !imageLoadError.value,
|
||||
)
|
||||
|
||||
/** 为当前图片实例绑定不可跨媒体复用的成功与失败回调。 */
|
||||
const imageRequest = computed(() => {
|
||||
const revision = imageRequestRevision.value
|
||||
const src = getImgUrl.value
|
||||
const usesFallback = !src
|
||||
|
||||
return {
|
||||
handleError: () => {
|
||||
if (revision !== imageRequestRevision.value || usesFallback) return
|
||||
|
||||
resetPosterRevealState()
|
||||
isImageLoaded.value = false
|
||||
imageLoadError.value = true
|
||||
imageRequestRevision.value += 1
|
||||
},
|
||||
handleLoad: () => {
|
||||
if (revision !== imageRequestRevision.value) return
|
||||
|
||||
resetPosterRevealState()
|
||||
isImageLoaded.value = true
|
||||
if (!usesFallback) {
|
||||
posterRevealFallbackTimer = window.setTimeout(
|
||||
() => completePosterReveal(revision, usesFallback),
|
||||
POSTER_REVEAL_FALLBACK_MS,
|
||||
)
|
||||
}
|
||||
},
|
||||
handleReveal: (event: TransitionEvent) => {
|
||||
if (
|
||||
event.propertyName !== 'opacity' ||
|
||||
!(event.target instanceof Element) ||
|
||||
!event.target.matches('.v-img__img')
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
completePosterReveal(revision, usesFallback)
|
||||
},
|
||||
key: revision,
|
||||
src,
|
||||
}
|
||||
})
|
||||
|
||||
// 获取媒体类型文本
|
||||
function getMediaTypeText(type: string | undefined) {
|
||||
if (!type) return ''
|
||||
@@ -456,13 +531,21 @@ watch(isSubscribed, subscribed => {
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.media,
|
||||
() => {
|
||||
[() => props.media, () => props.media?.poster_path, () => props.media?.cover_url],
|
||||
([media], [previousMedia]) => {
|
||||
imageRequestRevision.value += 1
|
||||
resetPosterRevealState()
|
||||
isImageLoaded.value = false
|
||||
imageLoadError.value = false
|
||||
// 海报补全只重置图片代际;详情和订阅状态绑定媒体对象身份。
|
||||
if (media === previousMedia) return
|
||||
|
||||
resetMediaCardDetailState()
|
||||
subscribedSeasons.value = []
|
||||
subscribedSeasonModes.value = {}
|
||||
subscribedSeasonsLoaded.value = false
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
@@ -476,6 +559,7 @@ onActivated(resetMediaCardDetailState)
|
||||
onDeactivated(resetMediaCardDetailState)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resetPosterRevealState()
|
||||
resetMediaCardDetailState()
|
||||
document.removeEventListener('pointerdown', handleDocumentPointerDown)
|
||||
observer.value?.disconnect()
|
||||
@@ -492,6 +576,7 @@ onBeforeUnmount(() => {
|
||||
:height="props.height"
|
||||
:width="props.width"
|
||||
:ripple="false"
|
||||
:data-glass-optical-mode="hasLoadedRealPoster ? 'excluded' : undefined"
|
||||
class="app-hover-lift-card outline-none ring-gray-500 media-card"
|
||||
:class="{
|
||||
'app-hover-lift-card--hovering': isMediaCardActive(hover.isHovering),
|
||||
@@ -500,20 +585,19 @@ onBeforeUnmount(() => {
|
||||
}"
|
||||
@click.stop="handleMediaCardClick(hover.isHovering)"
|
||||
>
|
||||
<div
|
||||
v-if="!getImgUrl"
|
||||
class="media-card-placeholder d-flex align-center justify-center"
|
||||
>
|
||||
<div v-if="!getImgUrl" class="media-card-placeholder d-flex align-center justify-center">
|
||||
<VIcon :icon="placeholderIcon" size="64" color="medium-emphasis" />
|
||||
</div>
|
||||
<VImg
|
||||
:key="imageRequest.key"
|
||||
v-else
|
||||
aspect-ratio="2/3"
|
||||
:src="getImgUrl"
|
||||
:src="imageRequest.src"
|
||||
class="object-cover aspect-w-2 aspect-h-3"
|
||||
cover
|
||||
@load="isImageLoaded = true"
|
||||
@error="imageLoadError = true"
|
||||
@load="imageRequest.handleLoad"
|
||||
@error="imageRequest.handleError"
|
||||
@transitionend="imageRequest.handleReveal"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
|
||||
@@ -8,7 +8,7 @@ import { querySubscribeByMediaHandler, subscribeListHandler } from '@tests/suppo
|
||||
import { server } from '@tests/support/msw/server'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { HttpResponse, http } from 'msw'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { defineComponent, h, reactive, ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -90,6 +90,50 @@ interface RenderCardOptions {
|
||||
superUser?: boolean
|
||||
}
|
||||
|
||||
interface ControlledImageRequest {
|
||||
/** 模拟当前 VImg 请求失败。 */
|
||||
fail: () => void
|
||||
/** 模拟当前 VImg 请求成功。 */
|
||||
load: () => void
|
||||
/** 模拟当前 VImg 的 opacity 淡入完成。 */
|
||||
reveal: () => void
|
||||
/** 当前 VImg 实例发起的图片地址。 */
|
||||
src: string
|
||||
}
|
||||
|
||||
/** 创建可保留旧实例回调的图片替身,用于验证媒体复用时的迟到事件隔离。 */
|
||||
function createControlledImageStub(requests: ControlledImageRequest[]) {
|
||||
return defineComponent({
|
||||
name: 'VImg',
|
||||
emits: ['error', 'load'],
|
||||
props: { src: String },
|
||||
setup(props, { emit, slots }) {
|
||||
const src = props.src ?? ''
|
||||
const imageElement = ref<HTMLImageElement | null>(null)
|
||||
const request = {
|
||||
fail: () => emit('error', src),
|
||||
load: () => emit('load', src),
|
||||
reveal: () => {
|
||||
const event = new Event('transitionend', { bubbles: true }) as TransitionEvent
|
||||
Object.defineProperty(event, 'propertyName', { value: 'opacity' })
|
||||
imageElement.value?.dispatchEvent(event)
|
||||
},
|
||||
src,
|
||||
}
|
||||
requests.push(request)
|
||||
|
||||
return () =>
|
||||
h('div', { 'data-src': src }, [
|
||||
h('img', {
|
||||
ref: imageElement,
|
||||
class: 'v-img__img',
|
||||
}),
|
||||
slots.default?.(),
|
||||
])
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 使用指定媒体信息和用户权限渲染媒体卡片。 */
|
||||
async function renderCard(media: MediaInfo, options: RenderCardOptions = {}) {
|
||||
return renderWithProviders(MediaCard, {
|
||||
@@ -474,17 +518,22 @@ describe('MediaCard', () => {
|
||||
expect(dialogProps.selected).toEqual([])
|
||||
})
|
||||
|
||||
it('loads matching TV seasons before opening the subscription dialog', async () => {
|
||||
const media = createMediaInfo({ season: 2, title: '多季剧集', tmdb_id: 9551, type: '电视剧' })
|
||||
it('preserves loaded TV seasons when the same media receives a new poster', async () => {
|
||||
const media = reactive(createMediaInfo({ season: 2, title: '多季剧集', tmdb_id: 9551, type: '电视剧' }))
|
||||
const subscribeListRequest = vi.fn<(url: URL) => void>()
|
||||
server.use(
|
||||
querySubscribeByMediaHandler('tmdb:9551', { id: 81, season: 2 }),
|
||||
mediaExistsHandler({ data: { item: {} }, success: false }),
|
||||
subscribeListHandler([
|
||||
{ best_version: 0, id: 81, season: 3, tmdbid: 9551, type: '电视剧' },
|
||||
{ best_version: 1, best_version_full: 1, id: 82, season: 1, tmdbid: 9551, type: '电视剧' },
|
||||
{ id: 83, season: 4, tmdbid: 9999, type: '电视剧' },
|
||||
{ id: 84, tmdbid: 9551, type: '电影' },
|
||||
]),
|
||||
subscribeListHandler(
|
||||
[
|
||||
{ best_version: 0, id: 81, season: 3, tmdbid: 9551, type: '电视剧' },
|
||||
{ best_version: 1, best_version_full: 1, id: 82, season: 1, tmdbid: 9551, type: '电视剧' },
|
||||
{ id: 83, season: 4, tmdbid: 9999, type: '电视剧' },
|
||||
{ id: 84, tmdbid: 9551, type: '电影' },
|
||||
],
|
||||
200,
|
||||
subscribeListRequest,
|
||||
),
|
||||
http.get(new URL('system/setting/public/DefaultTvSubscribeConfig', API_BASE_URL).href, () =>
|
||||
HttpResponse.json({ data: { value: { best_version: 0 } }, success: true }),
|
||||
),
|
||||
@@ -503,6 +552,19 @@ describe('MediaCard', () => {
|
||||
subscribedSeasonModes: { 1: 'best_version_full', 3: 'normal' },
|
||||
subscribedSeasons: [1, 3],
|
||||
})
|
||||
expect(subscribeListRequest).toHaveBeenCalledOnce()
|
||||
|
||||
media.poster_path = '/original/updated.jpg'
|
||||
mocks.openSharedDialog.mockClear()
|
||||
await fireEvent.click(getActionButtons(container).at(-1) as HTMLButtonElement)
|
||||
|
||||
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
|
||||
expect(subscribeListRequest).toHaveBeenCalledOnce()
|
||||
const [, updatedDialogProps] = mocks.openSharedDialog.mock.calls[0] as [unknown, Record<string, unknown>]
|
||||
expect(updatedDialogProps).toMatchObject({
|
||||
subscribedSeasonModes: { 1: 'best_version_full', 3: 'normal' },
|
||||
subscribedSeasons: [1, 3],
|
||||
})
|
||||
})
|
||||
|
||||
it('matches custom media IDs when collecting subscribed TV seasons', async () => {
|
||||
@@ -585,7 +647,7 @@ describe('MediaCard', () => {
|
||||
expect(dialogProps).toMatchObject({ subscribedSeasons: expected })
|
||||
})
|
||||
|
||||
it('updates image badges on load and falls back after an image error', async () => {
|
||||
it('updates image badges on load and shows the typed placeholder after an image error', async () => {
|
||||
const media = createMediaInfo({
|
||||
poster_path: '/original/poster.jpg',
|
||||
source: 'themoviedb',
|
||||
@@ -593,35 +655,95 @@ describe('MediaCard', () => {
|
||||
type: '电视剧',
|
||||
vote_average: 8.6,
|
||||
})
|
||||
const VImgStub = defineComponent({
|
||||
name: 'VImg',
|
||||
emits: ['error', 'load'],
|
||||
props: { src: String },
|
||||
/** 渲染可主动触发图片成功和失败事件的测试替身。 */
|
||||
setup(props, { emit, slots }) {
|
||||
return () =>
|
||||
h('div', { 'data-src': props.src }, [
|
||||
h('button', { 'aria-label': '图片加载成功', onClick: () => emit('load') }),
|
||||
h('button', { 'aria-label': '图片加载失败', onClick: () => emit('error') }),
|
||||
slots.default?.(),
|
||||
])
|
||||
},
|
||||
})
|
||||
const requests: ControlledImageRequest[] = []
|
||||
const VImgStub = createControlledImageStub(requests)
|
||||
const { container } = await renderWithProviders(MediaCard, {
|
||||
props: { media, width: '9rem' },
|
||||
initialState: { user: { superUser: true } },
|
||||
global: { stubs: { VImg: VImgStub } },
|
||||
})
|
||||
|
||||
await fireEvent.click(container.querySelector('[aria-label="图片加载成功"]') as HTMLElement)
|
||||
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
|
||||
requests[0].load()
|
||||
await waitFor(() => expect(container.querySelector('.media-card')).toHaveClass('ring-1'))
|
||||
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
|
||||
|
||||
requests[0].reveal()
|
||||
await waitFor(() => expect(getCard(container)).toHaveAttribute('data-glass-optical-mode', 'excluded'))
|
||||
expect(container).toHaveTextContent('TV')
|
||||
expect(container).toHaveTextContent('8.6')
|
||||
|
||||
await fireEvent.click(container.querySelector('[aria-label="图片加载失败"]') as HTMLElement)
|
||||
requests[0].fail()
|
||||
await waitFor(() =>
|
||||
expect(container.querySelector('.media-card-title')?.parentElement).not.toHaveStyle({ display: 'none' }),
|
||||
)
|
||||
await waitFor(() => expect(container.querySelector('.media-card-placeholder')).not.toBeNull())
|
||||
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
|
||||
expect(getCard(container)).not.toHaveClass('ring-1')
|
||||
expect(requests.some(request => request.src.includes('no-image'))).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps placeholder-only cards inside the renderer without issuing an image request', async () => {
|
||||
const requests: ControlledImageRequest[] = []
|
||||
const { container } = await renderWithProviders(MediaCard, {
|
||||
props: { media: createMediaInfo({ poster_path: undefined, tmdb_id: 9553 }), width: '9rem' },
|
||||
initialState: { user: { superUser: true } },
|
||||
global: { stubs: { VImg: createControlledImageStub(requests) } },
|
||||
})
|
||||
|
||||
expect(requests).toHaveLength(0)
|
||||
expect(container.querySelector('.media-card-placeholder')).not.toBeNull()
|
||||
expect(getCard(container)).not.toHaveClass('ring-1')
|
||||
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
|
||||
})
|
||||
|
||||
it('excludes music cards from the renderer after the cover finishes revealing', async () => {
|
||||
const requests: ControlledImageRequest[] = []
|
||||
const { container } = await renderWithProviders(MediaCard, {
|
||||
props: {
|
||||
media: createMediaInfo({ cover_url: 'https://example.com/cover.jpg', poster_path: undefined, type: '音乐' }),
|
||||
width: '9rem',
|
||||
},
|
||||
initialState: { user: { superUser: true } },
|
||||
global: { stubs: { VImg: createControlledImageStub(requests) } },
|
||||
})
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
requests[0].load()
|
||||
requests[0].reveal()
|
||||
|
||||
await waitFor(() => expect(getCard(container)).toHaveAttribute('data-glass-optical-mode', 'excluded'))
|
||||
})
|
||||
|
||||
it('ignores a previous poster load after the card is reused for another media item', async () => {
|
||||
const requests: ControlledImageRequest[] = []
|
||||
const mediaA = createMediaInfo({ poster_path: '/original/a.jpg', title: '媒体 A', tmdb_id: 9554 })
|
||||
const mediaB = createMediaInfo({ poster_path: '/original/b.jpg', title: '媒体 B', tmdb_id: 9555 })
|
||||
const { container, rerender } = await renderWithProviders(MediaCard, {
|
||||
props: { media: mediaA, width: '9rem' },
|
||||
initialState: { user: { superUser: true } },
|
||||
global: { stubs: { VImg: createControlledImageStub(requests) } },
|
||||
})
|
||||
|
||||
requests[0].load()
|
||||
await waitFor(() => expect(getCard(container)).toHaveClass('media-card--image-loaded'))
|
||||
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
|
||||
|
||||
await rerender({ media: mediaB, width: '9rem' })
|
||||
await waitFor(() => expect(requests.some(request => request.src.includes('/w500/b.jpg'))).toBe(true))
|
||||
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
|
||||
|
||||
requests[0].reveal()
|
||||
await Promise.resolve()
|
||||
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
|
||||
|
||||
const currentRequest = requests.find(request => request.src.includes('/w500/b.jpg'))
|
||||
currentRequest?.load()
|
||||
await waitFor(() => expect(getCard(container)).toHaveClass('media-card--image-loaded'))
|
||||
expect(getCard(container)).not.toHaveAttribute('data-glass-optical-mode')
|
||||
|
||||
currentRequest?.reveal()
|
||||
await waitFor(() => expect(getCard(container)).toHaveAttribute('data-glass-optical-mode', 'excluded'))
|
||||
})
|
||||
|
||||
it('renders the AniList source badge after the poster loads', async () => {
|
||||
|
||||
@@ -220,7 +220,9 @@ describe('SiteImportDialog', () => {
|
||||
await fireEvent.click(await screen.findByRole('button', { name: '开始导入' }))
|
||||
expect(await screen.findByText('导入过程中出现 1 个错误')).toBeInTheDocument()
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: '关闭' }))
|
||||
const resultCloseButton = screen.getByText('关闭').closest('button')
|
||||
expect(resultCloseButton).not.toBeNull()
|
||||
await fireEvent.click(resultCloseButton!)
|
||||
expect(events.update).toHaveBeenCalledWith(false)
|
||||
expect(events.importSuccess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user