style(viewer): 广告轮播无缝环回——首尾克隆帧 + 到位瞬时回位

末帧→首帧不再反向长滚:经尾克隆单帧宽右→左自然滑入,过渡结束后
关过渡瞬时跳回等视觉真实帧;首帧→末帧(右滑/点末点)对称走头克隆。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-29 14:45:40 +08:00
parent 048732dfef
commit 38b913b429
2 changed files with 112 additions and 19 deletions
+18 -2
View File
@@ -38,7 +38,8 @@ function currentSrc(wrapper: VueWrapper): string | null {
/** 图加载失败(含现签 URL 过期)→ emit imageError(adId)(2026-09-29 裂图自愈)。 */
function triggerImgError(wrapper: VueWrapper): void {
wrapper.find('.ad-slide img').trigger('error');
// 触发活跃帧的图错误(头/尾克隆帧常驻轨道,首个 .ad-slide 是头克隆)。
wrapper.find('.ad-slide.active img').trigger('error');
}
describe('AdCarousel', () => {
@@ -81,7 +82,7 @@ describe('AdCarousel', () => {
it('opens the current ad link in a new tab via window.open', async () => {
const wrapper = mountCarousel();
await wrapper.get('.ad-slide').trigger('click');
await wrapper.get('.ad-slide.active').trigger('click');
expect(openSpy).toHaveBeenCalledWith('https://link.test/a', '_blank', 'noopener,noreferrer');
});
@@ -128,4 +129,19 @@ describe('AdCarousel', () => {
expect(emitted).toHaveLength(1);
expect(emitted![0]).toEqual(['a']); // 当前帧(index 0)的 adId
});
it('wraps last→first as one natural leftward slide then a silent snap', async () => {
const wrapper = mountCarousel(ADS);
await vi.advanceTimersByTimeAsync(8000); // a→b→c(真实末帧)
expect(currentSrc(wrapper)).toBe('https://ad.test/c.jpg');
expect(wrapper.find('.ad-slides').attributes('style')).toContain('translateX(-300%)');
await vi.advanceTimersByTimeAsync(4000); // 环回:a 经尾克隆右→左滑入
expect(currentSrc(wrapper)).toBe('https://ad.test/a.jpg'); // 活跃真实帧已回 a
let track = wrapper.find('.ad-slides');
expect(track.attributes('style')).toContain('translateX(-400%)'); // 视觉在尾克隆位
expect(track.classes()).not.toContain('no-transition');
await vi.advanceTimersByTimeAsync(600); // 过渡结束 → 关过渡瞬时回位
track = wrapper.find('.ad-slides');
expect(track.attributes('style')).toContain('translateX(-100%)'); // 等视觉真实首帧
});
});
+94 -17
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import type { PublicAd } from '../../api/client';
import { nextIndex, swipeStep } from '../../ads/carousel';
@@ -25,6 +25,65 @@ const current = ref(0);
/** 指针悬停/按住期间暂停(恢复时重开完整 4s 窗口,非冻结剩余时长)。 */
const paused = ref(false);
// —— 无缝环回(2026-09-29 用户反馈:末帧→首帧也要右→左自然滑入)——
// 轨道 = [末帧克隆, 真实帧…, 首帧克隆](仅多条时)。正进步进到尾克隆 /
// 后退步进到头克隆都获得单帧宽的自然滑动;过渡结束(定时器兜底)后关过渡、
// 瞬时跳回等视觉位的真实帧,再恢复过渡——观感连续无回卷。
const SLIDE_MS = 450;
/** 轨道渲染位:真实 index + 1(头克隆占位 0);克隆位 0 与 n+1 仅在环回瞬间。 */
const trackPos = ref(1);
/** 瞬时回位期间关闭过渡(transform 跳转不产生动画)。 */
const noTransition = ref(false);
let snapTimer: ReturnType<typeof setTimeout> | undefined;
/** 轨道帧序列:{ ad, real }——real 为真实 index,克隆帧为 null。 */
const trackSlides = computed(() => {
const n = props.ads.length;
if (n <= 1) return props.ads.map((ad) => ({ ad, real: 0 }));
return [
{ ad: props.ads[n - 1]!, real: null },
...props.ads.map((ad, real) => ({ ad, real })),
{ ad: props.ads[0]!, real: null },
];
});
function clearSnapTimer(): void {
if (snapTimer !== undefined) {
clearTimeout(snapTimer);
snapTimer = undefined;
}
}
async function snapBack(): Promise<void> {
noTransition.value = true;
trackPos.value = current.value + 1;
// 同帧应用「关过渡 + 新 transform」,下一拍恢复过渡(transform 已就位不触发)。
await nextTick();
noTransition.value = false;
}
/** 移动到真实 index(圆点/滑动/自动轮播共用):环回走克隆位 + 到位后回跳。 */
function moveTo(index: number): void {
clearSnapTimer();
const n = props.ads.length;
const from = current.value;
let pos = index + 1;
if (n > 1) {
if (from === n - 1 && index === 0) pos = n + 1; // 末→首:走尾克隆(右→左滑入)
else if (from === 0 && index === n - 1) pos = 0; // 首→末:走头克隆(右滑入)
}
current.value = index;
trackPos.value = pos;
if (pos === 0 || pos === n + 1) {
// 过渡时长 + 缓冲后瞬时回位;期间用户再操作会经 clearSnapTimer 作废。
snapTimer = setTimeout(() => {
snapTimer = undefined;
void snapBack();
}, SLIDE_MS + 60);
}
syncRotation();
}
let rotateTimer: ReturnType<typeof setInterval> | undefined;
function stopRotation(): void {
@@ -39,31 +98,39 @@ function syncRotation(): void {
stopRotation();
if (props.ads.length <= 1 || paused.value) return;
rotateTimer = setInterval(() => {
current.value = nextIndex(props.ads.length, current.value);
moveTo(nextIndex(props.ads.length, current.value));
}, ROTATE_MS);
}
/** 跳页(圆点/滑动共用):就位后重开完整 4s 窗口。 */
function goTo(index: number): void {
current.value = index;
syncRotation();
moveTo(index);
}
function stepBy(step: 1 | -1): void {
goTo(nextIndex(props.ads.length, current.value, step));
moveTo(nextIndex(props.ads.length, current.value, step));
}
/** 新广告列表(token/locale 重载整表替换)→ 回首项并重对齐轮播。 */
watch(
() => props.ads,
() => {
clearSnapTimer();
noTransition.value = false;
current.value = 0;
trackPos.value = props.ads.length > 1 ? 1 : 0;
syncRotation();
},
);
watch(paused, syncRotation);
onMounted(syncRotation);
onBeforeUnmount(stopRotation);
onMounted(() => {
trackPos.value = props.ads.length > 1 ? 1 : 0;
syncRotation();
});
onBeforeUnmount(() => {
stopRotation();
clearSnapTimer();
});
let touchStart: { x: number; y: number } | null = null;
@@ -99,22 +166,28 @@ function openAd(ad: PublicAd): void {
@touchstart="onTouchStart" @touchend="onTouchEnd"
>
<!-- 轨道式滑动(2026-09-29 用户定稿:从右往左滑入,不做淡入淡出):全帧
常驻轨道预加载(首轮切换不空白),track 整体 translateX;仅活跃帧
可聚焦;错误上报只看活跃帧。 -->
<div class="ad-slides" :style="{ transform: `translateX(-${current * 100}%)` }">
常驻轨道预加载(首轮切换不空白),track 整体 translateX;首尾克隆帧
令环回也是单帧宽自然滑入(到位后关过渡瞬时回位,见 script);仅活跃
真实帧可聚焦;错误上报只看活跃帧。 -->
<div
class="ad-slides" :class="{ 'no-transition': noTransition }"
:style="{ transform: `translateX(-${trackPos * 100}%)` }"
>
<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)"
v-for="(slide, i) in trackSlides" :key="`${slide.real ?? 'clone'}-${slide.ad.adId}-${i}`"
type="button" class="ad-slide"
:class="{ active: slide.real !== null && slide.real === current }"
:aria-label="t('ads.open')"
:aria-hidden="slide.real !== null && slide.real === current ? undefined : 'true'"
:tabindex="slide.real !== null && slide.real === current ? 0 : -1"
@click="openAd(slide.ad)"
>
<img
:src="ad.imageUrl"
:src="slide.ad.imageUrl"
:alt="t('ads.label')"
decoding="async"
draggable="false"
@error="i === current && emit('imageError', ad.adId)"
@error="slide.real !== null && slide.real === current && emit('imageError', slide.ad.adId)"
>
</button>
</div>
@@ -153,6 +226,10 @@ function openAd(ad: PublicAd): void {
/* 从右往左滑动切换(下一帧自右侧滑入;后退/右滑自然反向)。 */
transition: transform 0.45s ease;
}
/* 环回瞬时回位:克隆位→真实位等视觉跳转(无动画)。 */
.ad-slides.no-transition {
transition: none;
}
.ad-slide {
flex: 0 0 100%;
display: block;