diff --git a/apps/viewer/src/__tests__/ad-carousel.spec.ts b/apps/viewer/src/__tests__/ad-carousel.spec.ts new file mode 100644 index 0000000..3896599 --- /dev/null +++ b/apps/viewer/src/__tests__/ad-carousel.spec.ts @@ -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; + + 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'); + }); +}); diff --git a/apps/viewer/src/ads/carousel.spec.ts b/apps/viewer/src/ads/carousel.spec.ts new file mode 100644 index 0000000..7bcd934 --- /dev/null +++ b/apps/viewer/src/ads/carousel.spec.ts @@ -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); + }); +}); diff --git a/apps/viewer/src/ads/carousel.ts b/apps/viewer/src/ads/carousel.ts new file mode 100644 index 0000000..5c63141 --- /dev/null +++ b/apps/viewer/src/ads/carousel.ts @@ -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; +} diff --git a/apps/viewer/src/api/client.ts b/apps/viewer/src/api/client.ts index 7fca60c..01d337e 100644 --- a/apps/viewer/src/api/client.ts +++ b/apps/viewer/src/api/client.ts @@ -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 { + return apiRequest(`/public/albums/${encodeURIComponent(token)}/ads`); +} + /** 点赞批次上报(Task 10):失败重试必须复用同一 batchId(服务端幂等键)。 */ export function postLikeBatch(token: string, photoId: string, body: LikeBatchRequest): Promise { return apiRequest( diff --git a/apps/viewer/src/components/ads/AdCarousel.vue b/apps/viewer/src/components/ads/AdCarousel.vue new file mode 100644 index 0000000..2111e2e --- /dev/null +++ b/apps/viewer/src/components/ads/AdCarousel.vue @@ -0,0 +1,197 @@ + + + + + diff --git a/apps/viewer/src/i18n/locales/en.ts b/apps/viewer/src/i18n/locales/en.ts index ef0cc7b..b13ab74 100644 --- a/apps/viewer/src/i18n/locales/en.ts +++ b/apps/viewer/src/i18n/locales/en.ts @@ -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', diff --git a/apps/viewer/src/i18n/locales/zh-CN.ts b/apps/viewer/src/i18n/locales/zh-CN.ts index e2810b2..07c6958 100644 --- a/apps/viewer/src/i18n/locales/zh-CN.ts +++ b/apps/viewer/src/i18n/locales/zh-CN.ts @@ -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: { diff --git a/apps/viewer/src/pages/ActivityPage.vue b/apps/viewer/src/pages/ActivityPage.vue index 8f44a12..3d69d19 100644 --- a/apps/viewer/src/pages/ActivityPage.vue +++ b/apps/viewer/src/pages/ActivityPage.vue @@ -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(null); const loading = ref(true); /** 相册浏览数(Task 10):album 加载初值 + 上报确认/ stats.updated 校准。 */ const viewCount = ref(null); +/** 广告位轮播数据(Task 10):相册加载成功后独立拉取;关闭为会话级记忆。 */ +const ads = ref([]); // 快速连续切换语言/路由时的竞态防护:仅最新请求的结果可落地。 let loadSequence = 0; @@ -69,6 +74,20 @@ async function load(): Promise { 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" /> + + +