f1c13bff7c
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>
198 lines
6.1 KiB
Vue
198 lines
6.1 KiB
Vue
<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>
|