Files
photo-live/apps/viewer/src/components/ads/AdCarousel.vue
T
浅念 f1c13bff7c 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>
2026-09-29 00:58:48 +08:00

198 lines
6.1 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>