Merge branch 'v2' into v3

This commit is contained in:
jxxghp
2026-08-10 07:42:11 +08:00
11 changed files with 967 additions and 104 deletions
+93 -9
View File
@@ -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">
+148 -26
View File
@@ -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()
})