style(viewer): 广告轮播切换柔化——全帧堆叠淡入淡出

堆叠帧常驻 DOM 预加载(首轮切换不空白),活跃帧独占交互/聚焦;
恢复 10:3 高度锚点(各帧自然高度不一致会让切换时容器跳动)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-29 14:18:39 +08:00
parent 29204bc08a
commit 07f81ff59b
2 changed files with 39 additions and 15 deletions
@@ -31,7 +31,8 @@ function mountCarousel(ads: PublicAd[] = ADS): VueWrapper {
}
function currentSrc(wrapper: VueWrapper): string | null {
const img = wrapper.find('.ad-slide img').element as HTMLImageElement | undefined;
// 全帧堆叠后当前帧=活跃帧(2026-09-29 淡入淡出改造)。
const img = wrapper.find('.ad-slide.active img').element as HTMLImageElement | undefined;
return img?.getAttribute('src') ?? null;
}
+37 -14
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import type { PublicAd } from '../../api/client';
import { nextIndex, swipeStep } from '../../ads/carousel';
@@ -22,7 +22,6 @@ const emit = defineEmits<{ close: []; imageError: [adId: string] }>();
const { t } = useI18n();
const current = ref(0);
const currentAd = computed(() => props.ads[current.value] ?? null);
/** 指针悬停/按住期间暂停(恢复时重开完整 4s 窗口,非冻结剩余时长)。 */
const paused = ref(false);
@@ -99,18 +98,25 @@ function openAd(ad: PublicAd): void {
@pointerenter="paused = true" @pointerdown="paused = true" @pointerleave="paused = false"
@touchstart="onTouchStart" @touchend="onTouchEnd"
>
<button
v-if="currentAd !== null" type="button" class="ad-slide" :aria-label="t('ads.open')"
@click="openAd(currentAd)"
>
<img
:src="currentAd.imageUrl"
:alt="t('ads.label')"
loading="lazy"
draggable="false"
@error="currentAd !== null && emit('imageError', currentAd.adId)"
<!-- 全帧堆叠 + 透明度过渡(2026-09-29 切换柔化):所有帧常驻 DOM 预加载
(首轮切换不空白),仅活跃帧可交互/可聚焦;错误上报只看活跃帧。 -->
<div class="ad-slides">
<button
v-for="(ad, i) in ads" :key="ad.adId" type="button" class="ad-slide"
:class="{ active: i === current }" :aria-label="t('ads.open')"
:aria-hidden="i === current ? undefined : 'true'"
:tabindex="i === current ? 0 : -1"
@click="openAd(ad)"
>
</button>
<img
:src="ad.imageUrl"
:alt="t('ads.label')"
decoding="async"
draggable="false"
@error="i === current && emit('imageError', ad.adId)"
>
</button>
</div>
<span class="ad-badge">{{ t('ads.label') }}</span>
<button type="button" class="ad-close" :aria-label="t('ads.close')" @click="emit('close')">✕</button>
<nav v-if="ads.length > 1" class="ad-dots" :aria-label="t('ads.dots')">
@@ -130,7 +136,8 @@ function openAd(ad: PublicAd): void {
.ad-carousel {
position: relative;
width: 100%;
/* aspect-ratio: 10 / 3; 建议素材 1200×360 */
/* 堆叠帧的高度锚点(各帧自然高度不一致会让切换时容器跳动);建议素材 1200×360 */
aspect-ratio: 10 / 3;
margin: 16px 0;
overflow: hidden;
border-radius: var(--viewer-radius-img);
@@ -140,7 +147,14 @@ function openAd(ad: PublicAd): void {
* ThumbnailStrip pan-x 的对偶(pan-y)。 */
touch-action: pan-y;
}
.ad-slides {
position: relative;
width: 100%;
height: 100%;
}
.ad-slide {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
@@ -148,6 +162,15 @@ function openAd(ad: PublicAd): void {
border: 0;
background: transparent;
cursor: pointer;
/* 淡入淡出(切换柔化):非活跃帧透明且不拦截交互;transition 只对 opacity,
* 与 prefers-reduced-motion 无关(纯视觉淡变,非位移动画)。 */
opacity: 0;
transition: opacity 0.45s ease;
pointer-events: none;
}
.ad-slide.active {
opacity: 1;
pointer-events: auto;
}
.ad-slide img {
display: block;