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,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>