feat(viewer): 相册页广告轮播——4s 自动/暂停/滑动/徽标/会话级关闭

Task 10(广告位轮播实施计划):
- ads/carousel.ts 纯逻辑:nextIndex 环形推进(末→首、单条恒 0)+ swipeStep
  滑动判定(≥40px 且水平分量严格大于垂直),carousel.spec 直测 7 例
- AdCarousel.vue:10:3 轮播,4s 自动推进(本地常量 ROTATE_MS);pointerenter/
  pointerdown 暂停、pointerleave 恢复;圆点跳页;touchstart/touchend 滑动翻页
  (cancel 合成 click);单条静态无圆点;左上角常驻「广告」徽标 + 右上角 ≥44px
  ✕ 关闭(emit close,组件无副作用);视觉一律 var(--viewer-*)(半透明层经
  color-mix 自 token 派生)
- client.ts:PublicAd/PublicAdListResponse 契约类型 + getPublicAds(同形
  apiRequest);__tests__/ad-carousel.spec.ts 组件级 6 例(圆点/关闭/window.open
  spy/假时钟自动轮播与暂停/滑动/单条静态)
- ActivityPage:load() 成功分支独立拉取广告(失败静默、会话级关闭记忆
  viewer:ads-dismissed 先行、沿用 sequence 防跨相册竞态);AlbumHeader 与
  PhotoFeed 之间挂 AdCarousel;i18n zh-CN/en 补 ads.* 词条

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-29 00:58:48 +08:00
parent 80405cfcc1
commit f1c13bff7c
8 changed files with 428 additions and 0 deletions
@@ -0,0 +1,113 @@
import { mount, type VueWrapper } from '@vue/test-utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { PublicAd } from '../api/client';
import { createViewerI18n } from '../i18n';
import AdCarousel from '../components/ads/AdCarousel.vue';
// 广告轮播组件(Task 10 Step 3):圆点跳页、关闭 emit、slide 点击新标签打开、
// 4s 自动轮播与指针悬停暂停、单条静态(无圆点)。会话级关闭记忆在 ActivityPage
//(组件只 emit close,无 sessionStorage 副作用)。
function makeAd(id: string): PublicAd {
return { adId: id, imageUrl: `https://ad.test/${id}.jpg`, linkUrl: `https://link.test/${id}` };
}
const ADS = [makeAd('a'), makeAd('b'), makeAd('c')];
const mounted: VueWrapper[] = [];
/** window.open 桩(记录调用即可):经工厂推导 spy 类型,绕开 spyOn 泛型约束。 */
function spyWindowOpen() {
return vi.spyOn(window, 'open').mockReturnValue({} as Window);
}
function mountCarousel(ads: PublicAd[] = ADS): VueWrapper {
const wrapper = mount(AdCarousel, {
props: { ads },
global: { plugins: [createViewerI18n('zh-CN')] },
});
mounted.push(wrapper);
return wrapper;
}
function currentSrc(wrapper: VueWrapper): string | null {
const img = wrapper.find('.ad-slide img').element as HTMLImageElement | undefined;
return img?.getAttribute('src') ?? null;
}
describe('AdCarousel', () => {
let openSpy: ReturnType<typeof spyWindowOpen>;
beforeEach(() => {
vi.useFakeTimers();
openSpy = spyWindowOpen();
});
afterEach(() => {
openSpy.mockRestore();
while (mounted.length > 0) mounted.pop()?.unmount();
vi.useRealTimers();
});
it('renders the ad badge and switches to the clicked dot', async () => {
const wrapper = mountCarousel();
expect(wrapper.get('.ad-badge').text()).toBe('广告');
expect(currentSrc(wrapper)).toBe('https://ad.test/a.jpg');
const dots = wrapper.findAll('.ad-dots button');
expect(dots.length).toBe(3);
await dots[1].trigger('click');
expect(currentSrc(wrapper)).toBe('https://ad.test/b.jpg');
expect(dots[1].classes()).toContain('ad-dot--active');
});
it('emits close when the ✕ button is clicked', async () => {
const wrapper = mountCarousel();
const close = wrapper.get('.ad-close');
expect(close.attributes('aria-label')).toBe('关闭广告');
await close.trigger('click');
expect(wrapper.emitted('close')?.length).toBe(1);
});
it('opens the current ad link in a new tab via window.open', async () => {
const wrapper = mountCarousel();
await wrapper.get('.ad-slide').trigger('click');
expect(openSpy).toHaveBeenCalledWith('https://link.test/a', '_blank', 'noopener,noreferrer');
});
it('auto-advances every 4s, pauses on pointer enter and resumes on leave', async () => {
const wrapper = mountCarousel();
expect(currentSrc(wrapper)).toBe('https://ad.test/a.jpg');
await vi.advanceTimersByTimeAsync(4000);
expect(currentSrc(wrapper)).toBe('https://ad.test/b.jpg');
await wrapper.find('.ad-carousel').trigger('pointerenter');
await vi.advanceTimersByTimeAsync(8000);
expect(currentSrc(wrapper)).toBe('https://ad.test/b.jpg'); // 悬停期间不切换
await wrapper.find('.ad-carousel').trigger('pointerleave');
await vi.advanceTimersByTimeAsync(4000);
expect(currentSrc(wrapper)).toBe('https://ad.test/c.jpg'); // 离开自动恢复
});
it('switches by horizontal swipe (touch) and cancels the synthetic click', async () => {
const wrapper = mountCarousel();
const root = wrapper.find('.ad-carousel');
await root.trigger('touchstart', { touches: [{ clientX: 200, clientY: 20 }] });
await root.trigger('touchend', { changedTouches: [{ clientX: 80, clientY: 25 }] });
expect(currentSrc(wrapper)).toBe('https://ad.test/b.jpg'); // 左滑 → 下一张
expect(openSpy).not.toHaveBeenCalled(); // 滑动不触发广告打开
await root.trigger('touchstart', { touches: [{ clientX: 100, clientY: 10 }] });
await root.trigger('touchend', { changedTouches: [{ clientX: 190, clientY: 12 }] });
expect(currentSrc(wrapper)).toBe('https://ad.test/a.jpg'); // 右滑 → 上一张
});
it('renders a single ad statically without dots or auto rotation', async () => {
const wrapper = mountCarousel([makeAd('solo')]);
expect(wrapper.find('.ad-dots').exists()).toBe(false);
await vi.advanceTimersByTimeAsync(12000);
expect(currentSrc(wrapper)).toBe('https://ad.test/solo.jpg');
});
});
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { nextIndex, swipeStep } from './carousel';
// 广告轮播纯逻辑(Task 10 Step 1):推进/环形回绕/单条静止 + 滑动手势判定
//(阈值 40px、水平分量须严格大于垂直分量)。
describe('nextIndex', () => {
it('advances forward and wraps from the tail to the head (ring)', () => {
expect(nextIndex(3, 0)).toBe(1);
expect(nextIndex(3, 1)).toBe(2);
expect(nextIndex(3, 2)).toBe(0); // 末→首
});
it('keeps a single item pinned at 0 (never switches)', () => {
expect(nextIndex(1, 0)).toBe(0);
expect(nextIndex(1, 0, -1)).toBe(0);
expect(nextIndex(0, 0)).toBe(0); // 空表防御:同 0
});
it('steps backwards wrapping from the head to the tail', () => {
expect(nextIndex(3, 0, -1)).toBe(2);
expect(nextIndex(3, 2, -1)).toBe(1);
expect(nextIndex(5, 4, -1)).toBe(3);
});
});
describe('swipeStep', () => {
it('maps an unambiguous horizontal swipe to its direction', () => {
expect(swipeStep(-80, 5)).toBe(1); // 左滑 → 下一张
expect(swipeStep(80, -5)).toBe(-1); // 右滑 → 上一张
});
it('ignores movement under the 40px threshold', () => {
expect(swipeStep(-39, 0)).toBe(0);
expect(swipeStep(39, 2)).toBe(0);
});
it('accepts exactly 40px (>= threshold)', () => {
expect(swipeStep(-40, 0)).toBe(1);
expect(swipeStep(40, 0)).toBe(-1);
});
it('ignores diagonal or vertically dominant movement', () => {
expect(swipeStep(60, 70)).toBe(0); // 垂直分量更大(斜向)
expect(swipeStep(50, 50)).toBe(0); // 等分量:水平须严格大于垂直
expect(swipeStep(-100, 100)).toBe(0);
});
});
+16
View File
@@ -0,0 +1,16 @@
/**
* 广告轮播纯逻辑(Task 10 Step 1):推进/环形回绕/滑动手势判定抽为纯函数,
* 供 AdCarousel.vue 复用并由本 spec 直测(不依赖组件挂载)。
*/
/** 轮播推进:环形(末→首);单条恒 0(不切换)。 */
export function nextIndex(count: number, current: number, step: 1 | -1 = 1): number {
if (count <= 1) return 0;
return (current + step + count) % count;
}
/** 滑动手势判定:水平位移 ≥40px 且水平分量大于垂直分量才视为翻页。 */
export function swipeStep(dx: number, dy: number): 1 | -1 | 0 {
if (Math.abs(dx) < 40 || Math.abs(dx) <= Math.abs(dy)) return 0;
return dx < 0 ? 1 : -1;
}
+10
View File
@@ -22,6 +22,11 @@ export type PublicEmbedAlbum = components['schemas']['PublicEmbedAlbum'];
export type PublicEmbedPhoto = components['schemas']['PublicEmbedPhoto'];
export type EmbedSlotSettings = components['schemas']['EmbedSlotSettings'];
// 广告位轮播 Task 10 公开数据类型(ov 域 GET /public/albums/{token}/ads:
// 全局投放 ∨ 绑定本相册的启用广告,仅展示所需字段)。
export type PublicAdListResponse = components['schemas']['PublicAdListResponse'];
export type PublicAd = components['schemas']['PublicAd'];
/** 公开照片排序模式(sort=hot 仅 ov 实现:likeTotal 倒序短期快照,§7)。 */
export type SortMode = 'latest' | 'hot';
@@ -140,6 +145,11 @@ export function getPublicPhoto(token: string, photoId: string, locale?: string):
);
}
/** 公开广告列表(Task 10):无广告返回空 items(非 404);失败由调用方静默。 */
export function getPublicAds(token: string): Promise<PublicAdListResponse> {
return apiRequest<PublicAdListResponse>(`/public/albums/${encodeURIComponent(token)}/ads`);
}
/** 点赞批次上报(Task 10):失败重试必须复用同一 batchId(服务端幂等键)。 */
export function postLikeBatch(token: string, photoId: string, body: LikeBatchRequest): Promise<LikeBatchResponse> {
return apiRequest<LikeBatchResponse>(
@@ -0,0 +1,197 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import type { PublicAd } from '../../api/client';
import { nextIndex, swipeStep } from '../../ads/carousel';
// 广告位轮播(Task 10 Step 3):相册头部 10:3 轮播。4s 自动推进(本地常量,
// 不入契约);pointerenter/pointerdown 暂停、pointerleave 恢复(恢复重开完整
// 4s 窗口);圆点跳页;移动端水平滑动翻页(swipeStep,滑动 cancel 合成 click
// 不打开广告);单条=静态(无圆点无自动切换)。左上角常驻「广告」徽标(合规
// 可识别性,随轮播恒可见)。组件无副作用:会话级关闭记忆(sessionStorage)在
// ActivityPage——此处只 emit close。
/** 自动轮播间隔(本地常量;服务端契约不含节奏,前端自治)。 */
const ROTATE_MS = 4000;
const props = defineProps<{ ads: PublicAd[] }>();
const emit = defineEmits<{ close: [] }>();
const { t } = useI18n();
const current = ref(0);
const currentAd = computed(() => props.ads[current.value] ?? null);
/** 指针悬停/按住期间暂停(恢复时重开完整 4s 窗口,非冻结剩余时长)。 */
const paused = ref(false);
let rotateTimer: ReturnType<typeof setInterval> | undefined;
function stopRotation(): void {
if (rotateTimer !== undefined) {
clearInterval(rotateTimer);
rotateTimer = undefined;
}
}
/** 按当前状态(条数/暂停)对齐定时器:多条件变化共用的一个收敛点。 */
function syncRotation(): void {
stopRotation();
if (props.ads.length <= 1 || paused.value) return;
rotateTimer = setInterval(() => {
current.value = nextIndex(props.ads.length, current.value);
}, ROTATE_MS);
}
/** 跳页(圆点/滑动共用):就位后重开完整 4s 窗口。 */
function goTo(index: number): void {
current.value = index;
syncRotation();
}
function stepBy(step: 1 | -1): void {
goTo(nextIndex(props.ads.length, current.value, step));
}
/** 新广告列表(token/locale 重载整表替换)→ 回首项并重对齐轮播。 */
watch(
() => props.ads,
() => {
current.value = 0;
syncRotation();
},
);
watch(paused, syncRotation);
onMounted(syncRotation);
onBeforeUnmount(stopRotation);
let touchStart: { x: number; y: number } | null = null;
function onTouchStart(event: TouchEvent): void {
const touch = event.touches[0];
if (touch !== undefined) touchStart = { x: touch.clientX, y: touch.clientY };
}
function onTouchEnd(event: TouchEvent): void {
if (touchStart === null) return;
const start = touchStart;
touchStart = null;
const touch = event.changedTouches[0];
if (touch === undefined) return;
const step = swipeStep(touch.clientX - start.x, touch.clientY - start.y);
if (step === 0) return;
// 滑动判定成立:取消随后合成的 click(翻页不打开广告链接)。
event.preventDefault();
stepBy(step);
}
function openAd(ad: PublicAd): void {
// noopener/noreferrer:新标签不持有 opener(与 useDownload/EmbedPage 同规)。
window.open(ad.linkUrl, '_blank', 'noopener,noreferrer');
}
</script>
<template>
<!-- aria-live=off:轮播自动切换不应打扰读屏(用户主动操作经按钮语义可达)。 -->
<section
class="ad-carousel" role="region" :aria-label="t('ads.label')" aria-live="off"
@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">
</button>
<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')">
<button
v-for="(ad, i) in ads" :key="ad.adId" type="button" class="ad-dot"
:class="{ 'ad-dot--active': i === current }" :aria-label="t('ads.goto', { n: i + 1 })"
@click="goTo(i)"
/>
</nav>
</section>
</template>
<style scoped>
/* 视觉 token 一律 var(--viewer-*)(UI-02 §3.1);半透明层经 color-mix 自 token
* 派生(不写死色值、不扩 token 作用域文件)。 */
.ad-carousel {
position: relative;
width: 100%;
aspect-ratio: 10 / 3; /* 建议素材 1200×360 */
margin-bottom: 16px;
overflow: hidden;
border-radius: var(--viewer-radius-img);
background: var(--viewer-dark-bg); /* 图像加载期的暗色衬底 */
font-family: var(--viewer-font-family);
}
.ad-slide {
display: block;
width: 100%;
height: 100%;
padding: 0;
border: 0;
background: transparent;
cursor: pointer;
}
.ad-slide img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.ad-badge {
position: absolute;
top: 8px;
left: 8px;
padding: 2px 10px;
border-radius: var(--viewer-radius-pill);
font-size: 12px;
line-height: 18px;
color: var(--viewer-surface);
background: color-mix(in srgb, var(--viewer-dark-bg) 72%, transparent);
pointer-events: none; /* 徽标不拦截 slide 点击 */
}
.ad-close {
position: absolute;
top: 0;
right: 0;
width: var(--viewer-touch-target); /* ≥44px 触达 */
height: var(--viewer-touch-target);
border: 0;
border-radius: 50%;
font-size: 18px;
line-height: 1;
color: var(--viewer-surface);
background: color-mix(in srgb, var(--viewer-dark-bg) 72%, transparent);
cursor: pointer;
}
.ad-dots {
position: absolute;
bottom: 10px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 4px;
}
.ad-dot {
padding: 8px; /* 8px 视觉圆点 + 8px 外扩 = 24px 命中区 */
border: 0;
background: transparent;
cursor: pointer;
}
.ad-dot::before {
content: '';
display: block;
width: 8px;
height: 8px;
border-radius: 50%;
background: color-mix(in srgb, var(--viewer-surface) 55%, transparent);
}
.ad-dot--active::before {
background: var(--viewer-surface);
}
</style>
+8
View File
@@ -19,6 +19,14 @@ export default {
},
lang: { switch: 'Switch language' },
prefs: { reduceMotion: 'Reduce motion' },
// Ad carousel (Task 10): badge text = label (compliance, always visible).
ads: {
label: 'Ad',
close: 'Close ads',
open: 'Open ad link',
dots: 'Carousel position',
goto: 'Go to {n}',
},
// Embed carousel iframe page (P4b Task 4): subset driven by the lang query.
embed: {
comingSoon: 'Photos coming soon',
+8
View File
@@ -19,6 +19,14 @@ export default {
},
lang: { switch: '切换语言' },
prefs: { reduceMotion: '减少动画' },
// 广告位轮播(Task 10):徽标文案=label(合规可识别性,随轮播恒可见)。
ads: {
label: '广告',
close: '关闭广告',
open: '打开广告链接',
dots: '轮播位置',
goto: '第 {n} 张',
},
// 嵌入轮播 iframe 页(P4b Task 4;UI-01 补全):独立于宿主语言的词条子集
// (lang 查询驱动)。图片无真实描述时只给上下文标签(§5.3 不编造画面)。
embed: {
+28
View File
@@ -5,14 +5,17 @@ import { useRoute, useRouter } from 'vue-router';
import {
ApiError,
NETWORK_ERROR_CODE,
getPublicAds,
getPublicAlbum,
getPublicPhoto,
getPublicPhotos,
type PublicAd,
type PublicAlbum,
type SortMode,
} from '../api/client';
import { errorMessageKey } from '../api/errors';
import { useDownload } from '../download/useDownload';
import AdCarousel from '../components/ads/AdCarousel.vue';
import AlbumHeader from '../components/album/AlbumHeader.vue';
import LikeBubbles from '../components/LikeBubbles.vue';
import PhotoFeed from '../components/PhotoFeed.vue';
@@ -56,6 +59,8 @@ const loadErrorKey = ref<string | null>(null);
const loading = ref(true);
/** 相册浏览数(Task 10):album 加载初值 + 上报确认/ stats.updated 校准。 */
const viewCount = ref<number | null>(null);
/** 广告位轮播数据(Task 10):相册加载成功后独立拉取;关闭为会话级记忆。 */
const ads = ref<PublicAd[]>([]);
// 快速连续切换语言/路由时的竞态防护:仅最新请求的结果可落地。
let loadSequence = 0;
@@ -69,6 +74,20 @@ async function load(): Promise<void> {
album.value = data;
loadErrorKey.value = null;
viewCount.value = data.viewCount;
// 广告独立拉取(Task 10):失败静默(广告缺席不阻塞相册浏览);会话级
// 关闭记忆先行(命中则连请求都省)。沿用 sequence 防跨相册竞态落地。
ads.value = [];
if (sessionStorage.getItem('viewer:ads-dismissed') !== '1') {
getPublicAds(token.value)
.then((result) => {
if (sequence !== loadSequence) return;
ads.value = result.items;
})
.catch(() => {
if (sequence !== loadSequence) return;
ads.value = [];
});
}
// 浏览上报(Task 10):加载成功后(可见时)一次性上报——幂等键由 reporter
// 持久,窗口内刷新不重复计数(A14)。
visitReport.notifyAlbumLoaded();
@@ -101,6 +120,12 @@ function selectCategory(categoryId: string | null): void {
selectedCategory.value = categoryId;
}
/** 关闭广告(Task 10):会话级记忆(本次会话不再拉取/渲染),组件只 emit。 */
function dismissAds(): void {
sessionStorage.setItem('viewer:ads-dismissed', '1');
ads.value = [];
}
// —— 照片序列(Task 8 提升:feed + lightbox 共享) ——
/** 排序模式(Task 10):latest=发布倒序(缺省)/ hot=点赞倒序短期快照。
@@ -340,6 +365,9 @@ onBeforeUnmount(() => {
@select-category="selectCategory"
/>
<!-- 广告位轮播(Task 10):相册头部之下;拉取失败/会话级关闭时整块缺席。 -->
<AdCarousel v-if="ads.length > 0" :ads="ads" @close="dismissAds" />
<!-- 深链目标不可见(404 防枚举同码):页内本地化提示 + 返回相册。 -->
<div v-if="deepLinkNotFound || deepLinkErrorKey !== null" class="deep-link-notice" role="alert">
<p>{{ deepLinkNotFound ? t('viewer.photoUnavailable') : t(deepLinkErrorKey ?? 'errors.unknown') }}</p>