style(viewer): 广告轮播无缝环回——首尾克隆帧 + 到位瞬时回位
末帧→首帧不再反向长滚:经尾克隆单帧宽右→左自然滑入,过渡结束后 关过渡瞬时跳回等视觉真实帧;首帧→末帧(右滑/点末点)对称走头克隆。 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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%)'); // 等视觉真实首帧
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user