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:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user