From 160e7e5f1a94cfcf57b941dc9e294d14a0bce2eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Mon, 21 Sep 2026 22:20:19 +0800 Subject: [PATCH] feat(viewer): UI-02 heart particles, interaction notices, share and download MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 5 (V05 §5.4/§6): decorative heart particles on the lightbox stage (local like clicks + others' WS bursts for the current photo), compact cross-photo interaction notices above the action bar (click to locate), navigator.share-first share orchestration with clipboard fallback for album and photo deep links (lang param), and download failures attributed to the photo that triggered them. Self-echo dedup stays in the realtime router (own batches never produce particles/notices); particles cap at 12 with visibility/unmount cleanup and are suppressed under reduced motion; notices cap at 3 with ~4s auto-dismiss and throttled SR summary. Co-Authored-By: Claude Code --- .../viewer/src/__tests__/album-header.spec.ts | 26 ++- apps/viewer/src/__tests__/download.spec.ts | 32 ++- .../src/__tests__/heart-particles.spec.ts | 100 ++++++++++ .../src/__tests__/interaction-notice.spec.ts | 104 ++++++++++ apps/viewer/src/__tests__/lightbox.spec.ts | 176 ++++++++++++++++- .../src/__tests__/photo-action-bar.spec.ts | 2 - apps/viewer/src/__tests__/share.spec.ts | 69 +++++++ apps/viewer/src/components/PhotoLightbox.vue | 114 ++++++++--- .../src/components/album/AlbumHeader.vue | 16 +- .../src/components/viewer/HeartParticles.vue | 184 ++++++++++++++++++ .../components/viewer/InteractionNotice.vue | 183 +++++++++++++++++ .../src/components/viewer/PhotoActionBar.vue | 6 +- apps/viewer/src/download/useDownload.ts | 6 + apps/viewer/src/i18n/locales/en.ts | 4 + apps/viewer/src/i18n/locales/zh-CN.ts | 4 + apps/viewer/src/likes/useLikes.ts | 18 ++ apps/viewer/src/pages/ActivityPage.vue | 22 ++- apps/viewer/src/support/share.ts | 34 ++++ 18 files changed, 1042 insertions(+), 58 deletions(-) create mode 100644 apps/viewer/src/__tests__/heart-particles.spec.ts create mode 100644 apps/viewer/src/__tests__/interaction-notice.spec.ts create mode 100644 apps/viewer/src/__tests__/share.spec.ts create mode 100644 apps/viewer/src/components/viewer/HeartParticles.vue create mode 100644 apps/viewer/src/components/viewer/InteractionNotice.vue create mode 100644 apps/viewer/src/support/share.ts diff --git a/apps/viewer/src/__tests__/album-header.spec.ts b/apps/viewer/src/__tests__/album-header.spec.ts index 92b41b0..39bafbe 100644 --- a/apps/viewer/src/__tests__/album-header.spec.ts +++ b/apps/viewer/src/__tests__/album-header.spec.ts @@ -57,6 +57,7 @@ describe('album header info area (UI-02 V02)', () => { clientSpy?.mockRestore(); clientSpy = null; Reflect.deleteProperty(navigator, 'clipboard'); + Reflect.deleteProperty(navigator, 'share'); }); it('renders banner, title, multi-line description, Intl view count, venue and sort/category toolbar', () => { @@ -179,22 +180,39 @@ describe('album header info area (UI-02 V02)', () => { expect(wrapper.emitted('selectCategory')).toEqual([['cat-open'], [null]]); }); - it('share copies the album url to the clipboard and reports success; failure keeps a selectable url', async () => { + it('share falls back to the clipboard with the album url (lang param) and reports success; failure keeps a selectable url', async () => { const writeText = stubClipboard(() => Promise.resolve()); const wrapper = mountHeader({ album: makeAlbum(), viewCount: null }); await wrapper.find('.share-btn').trigger('click'); await Promise.resolve(); - expect(writeText).toHaveBeenCalledWith('http://localhost:3000/a/tokfixture0000000000aaaa'); + expect(writeText).toHaveBeenCalledWith('http://localhost:3000/a/tokfixture0000000000aaaa?lang=zh-CN'); expect(wrapper.find('.share-status').text()).toContain('链接已复制'); expect(wrapper.find('.share-url').exists()).toBe(false); - // 复制失败:轻提示 + 可选中文本(§5.4 回退语义,完整 navigator.share 属 Task 5)。 + // 复制失败:轻提示 + 可选中文本(§5.4 回退语义)。 writeText.mockImplementation(() => Promise.reject(new Error('denied'))); await wrapper.find('.share-btn').trigger('click'); await Promise.resolve(); await Promise.resolve(); expect(wrapper.find('.share-status').text()).toContain('复制失败'); - expect(wrapper.find('.share-url').text()).toBe('http://localhost:3000/a/tokfixture0000000000aaaa'); + expect(wrapper.find('.share-url').text()).toBe('http://localhost:3000/a/tokfixture0000000000aaaa?lang=zh-CN'); + }); + + it('share uses navigator.share with the album title when available and stays silent on success', async () => { + const share = vi.fn(() => Promise.resolve()); + Object.defineProperty(navigator, 'share', { value: share, configurable: true }); + const writeText = stubClipboard(() => Promise.resolve()); + const wrapper = mountHeader({ album: makeAlbum(), viewCount: null }); + + await wrapper.find('.share-btn').trigger('click'); + await Promise.resolve(); + expect(share).toHaveBeenCalledWith({ + title: '2026 公司年会', + url: 'http://localhost:3000/a/tokfixture0000000000aaaa?lang=zh-CN', + }); + // 原生分享成功:无额外轻提示(面板已收尾,§5.4),也不落剪贴板。 + expect(writeText).not.toHaveBeenCalled(); + expect(wrapper.find('.share-status').exists()).toBe(false); }); }); diff --git a/apps/viewer/src/__tests__/download.spec.ts b/apps/viewer/src/__tests__/download.spec.ts index d1ff835..c13093a 100644 --- a/apps/viewer/src/__tests__/download.spec.ts +++ b/apps/viewer/src/__tests__/download.spec.ts @@ -1,5 +1,5 @@ import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'; -import { defineComponent, ref } from 'vue'; +import { defineComponent, nextTick, ref } from 'vue'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ApiError, @@ -89,9 +89,10 @@ function spyWindowOpen() { } let openSpy: ReturnType; -/** 宿主实例形态:setup 暴露 albumRef(ref 经代理解包)+ download 控制器。 */ +/** 宿主实例形态:setup 暴露 albumRef/currentRef(ref 经代理解包)+ download 控制器。 */ interface HostVm { albumRef: PublicAlbum | null; + currentRef: string; download: DownloadController; } @@ -112,7 +113,7 @@ function mountLightbox(album: PublicAlbum): VueWrapper & { vm: HostVm } { album: () => albumRef.value, photoId: () => current.value, }); - return { sequence, photoId: current, download, albumRef }; + return { sequence, photoId: current, currentRef: current, download, albumRef }; }, template: ``, }); @@ -251,6 +252,31 @@ describe('photo download (P4a Task 4)', () => { expect(openSpy).not.toHaveBeenCalled(); }); + it('attributes an async failure to the photo that triggered the download, not the current one', async () => { + // 在途请求挂起期间切图:错误归属触发时的 photoId(UI-02 §5.4)。 + let rejectDownload!: (error: Error) => void; + mockedGetPhotoDownload.mockReset().mockImplementation( + () => + new Promise((_resolve, reject) => { + rejectDownload = reject; + }), + ); + const wrapper = mountLightbox(makeAlbum()); + await flushPromises(); + + await wrapper.find('.download-main').trigger('click'); // p1 触发 + wrapper.vm.currentRef = 'p2'; // 请求在途时切到 p2 + rejectDownload(new Error('boom')); + await flushPromises(); + + expect(wrapper.vm.download.failedFor).toBe('p1'); + expect(wrapper.find('.download-failed').exists()).toBe(false); // p2 不背锅 + + wrapper.vm.currentRef = 'p1'; // 切回 p1:错误提示可见 + await nextTick(); + expect(wrapper.find('.download-failed[role="alert"]').text()).toContain('下载失败'); + }); + it('DOWNLOAD_DISABLED surfaces the dedicated localized notice (T15 漏译修复)', async () => { // 相册中途关闭下载开关(载荷仍开但端点 403)→ 专属文案而非通用失败。 mockedGetPhotoDownload.mockReset().mockRejectedValue( diff --git a/apps/viewer/src/__tests__/heart-particles.spec.ts b/apps/viewer/src/__tests__/heart-particles.spec.ts new file mode 100644 index 0000000..0ed2fdf --- /dev/null +++ b/apps/viewer/src/__tests__/heart-particles.spec.ts @@ -0,0 +1,100 @@ +import { mount, type VueWrapper } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import HeartParticles from '../components/viewer/HeartParticles.vue'; +import { setReducedMotion } from '../ui/prefs'; + +// UI-02 V05 HeartParticles 单测(§6.2/§6.3):burst 生成粒子上限 12(超限弃 +// 最旧)、动画时长后自清理、页面隐藏即清空、「减少动画」时不生成/开启即清空。 +// 粒子为纯装饰层(aria-hidden + pointer-events:none)。 + +const mounted: VueWrapper[] = []; + +interface ParticlesExposed { + burst: (count: number) => void; +} + +function mountParticles(): VueWrapper & { vm: ParticlesExposed } { + const wrapper = mount(HeartParticles); + mounted.push(wrapper); + return wrapper as VueWrapper & { vm: ParticlesExposed }; +} + +function setHidden(hidden: boolean): void { + Object.defineProperty(document, 'hidden', { value: hidden, configurable: true }); + document.dispatchEvent(new Event('visibilitychange')); +} + +describe('heart particles (UI-02 V05)', () => { + beforeEach(() => { + localStorage.clear(); + setReducedMotion(false); + vi.useFakeTimers(); + }); + + afterEach(() => { + while (mounted.length > 0) mounted.pop()?.unmount(); + vi.useRealTimers(); + setReducedMotion(false); + setHidden(false); + }); + + it('creates particles on burst and removes them after the float animation', async () => { + const wrapper = mountParticles(); + + wrapper.vm.burst(3); + await nextTick(); + expect(wrapper.findAll('.heart-particles-zone .particle')).toHaveLength(3); + + // 动画时长上限 1.8s + 收尾缓冲:之后自清理(不留冻结的过期粒子)。 + await vi.advanceTimersByTimeAsync(2000); + expect(wrapper.findAll('.particle')).toHaveLength(0); + }); + + it('caps concurrent particles at 12, dropping the oldest', async () => { + const wrapper = mountParticles(); + + wrapper.vm.burst(10); + await nextTick(); + expect(wrapper.findAll('.particle')).toHaveLength(10); + + wrapper.vm.burst(5); + await nextTick(); + expect(wrapper.findAll('.particle')).toHaveLength(12); + }); + + it('clears particles and timers when the page becomes hidden', async () => { + const wrapper = mountParticles(); + + wrapper.vm.burst(6); + await nextTick(); + expect(wrapper.findAll('.particle')).toHaveLength(6); + + setHidden(true); + await nextTick(); + expect(wrapper.findAll('.particle')).toHaveLength(0); + + // 隐藏期间清理的计时器不再复活(§6.3 不回放)。 + await vi.advanceTimersByTimeAsync(2000); + expect(wrapper.findAll('.particle')).toHaveLength(0); + }); + + it('does not render particles under reduced motion, and clears in-flight ones when it turns on', async () => { + const wrapper = mountParticles(); + + setReducedMotion(true); + wrapper.vm.burst(4); + await nextTick(); + expect(wrapper.findAll('.particle')).toHaveLength(0); // 不漂浮(§6.3) + + setReducedMotion(false); + wrapper.vm.burst(2); + await nextTick(); + expect(wrapper.findAll('.particle')).toHaveLength(2); + + // 漂浮中途开启「减少动画」:在屏粒子立即清空(不残留冻结心形)。 + setReducedMotion(true); + await nextTick(); + expect(wrapper.findAll('.particle')).toHaveLength(0); + }); +}); diff --git a/apps/viewer/src/__tests__/interaction-notice.spec.ts b/apps/viewer/src/__tests__/interaction-notice.spec.ts new file mode 100644 index 0000000..8ebe821 --- /dev/null +++ b/apps/viewer/src/__tests__/interaction-notice.spec.ts @@ -0,0 +1,104 @@ +import { mount, type VueWrapper } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import InteractionNotice from '../components/viewer/InteractionNotice.vue'; +import { createViewerI18n, type ViewerI18n } from '../i18n'; + +// UI-02 V05 InteractionNotice 单测(§6.2/§6.3):他人点赞紧凑通知(缩略图 + +// ♥ + delta)、点击定位上抛 select、同屏至多 3 条(超限弃最旧)、约 4s 自动 +// 消散、页面隐藏即清空;列表不 aria-live(读屏节流汇总,§6.3)。 + +let i18n: ViewerI18n; +const mounted: VueWrapper[] = []; + +interface NoticeExposed { + notify: (entry: { photoId: string; delta: number; thumbUrl: string | null }) => void; +} + +function mountNotice(): VueWrapper & { vm: NoticeExposed } { + const wrapper = mount(InteractionNotice, { global: { plugins: [i18n] } }); + mounted.push(wrapper); + return wrapper as VueWrapper & { vm: NoticeExposed }; +} + +function setHidden(hidden: boolean): void { + Object.defineProperty(document, 'hidden', { value: hidden, configurable: true }); + document.dispatchEvent(new Event('visibilitychange')); +} + +function entry(photoId: string, delta: number) { + return { photoId, delta, thumbUrl: `http://img.test/${photoId}-thumb.jpg` }; +} + +describe('interaction notice (UI-02 V05)', () => { + beforeEach(() => { + localStorage.clear(); + i18n = createViewerI18n('zh-CN'); + vi.useFakeTimers(); + }); + + afterEach(() => { + while (mounted.length > 0) mounted.pop()?.unmount(); + vi.useRealTimers(); + setHidden(false); + }); + + it('renders a compact notice with thumbnail, heart and +delta; click emits select', async () => { + const wrapper = mountNotice(); + + wrapper.vm.notify(entry('p2', 3)); + await nextTick(); + + const notice = wrapper.find('.notice'); + expect(notice.exists()).toBe(true); + expect(notice.find('.notice-thumb').attributes('src')).toBe('http://img.test/p2-thumb.jpg'); + expect(notice.find('.notice-heart').text()).toBe('♥'); + expect(notice.find('.notice-delta').text()).toBe('+3'); + // 列表本身不 aria-live(§6.3 取消逐条播报;节流汇总另置 role=status)。 + expect(wrapper.find('.interaction-notice-zone').attributes('aria-live')).toBeUndefined(); + expect(wrapper.find('.sr-summary').attributes('role')).toBe('status'); + + await notice.trigger('click'); + expect(wrapper.emitted('select')).toEqual([['p2']]); + }); + + it('caps concurrent notices at 3, dropping the oldest', async () => { + const wrapper = mountNotice(); + + wrapper.vm.notify(entry('p2', 1)); + wrapper.vm.notify(entry('p3', 2)); + wrapper.vm.notify(entry('p4', 4)); + wrapper.vm.notify(entry('p5', 8)); + await nextTick(); + + const deltas = wrapper.findAll('.notice-delta').map((delta) => delta.text()); + expect(deltas).toEqual(['+2', '+4', '+8']); // 最旧的 +1 被丢弃 + }); + + it('auto-dismisses notices after ~4s', async () => { + const wrapper = mountNotice(); + + wrapper.vm.notify(entry('p2', 1)); + await nextTick(); + expect(wrapper.findAll('.notice')).toHaveLength(1); + + await vi.advanceTimersByTimeAsync(4000); + expect(wrapper.findAll('.notice')).toHaveLength(0); + }); + + it('clears notices when the page becomes hidden', async () => { + const wrapper = mountNotice(); + + wrapper.vm.notify(entry('p2', 1)); + wrapper.vm.notify(entry('p3', 2)); + await nextTick(); + expect(wrapper.findAll('.notice')).toHaveLength(2); + + setHidden(true); + await nextTick(); + expect(wrapper.findAll('.notice')).toHaveLength(0); + + await vi.advanceTimersByTimeAsync(5000); + expect(wrapper.findAll('.notice')).toHaveLength(0); + }); +}); diff --git a/apps/viewer/src/__tests__/lightbox.spec.ts b/apps/viewer/src/__tests__/lightbox.spec.ts index 1155537..35111cd 100644 --- a/apps/viewer/src/__tests__/lightbox.spec.ts +++ b/apps/viewer/src/__tests__/lightbox.spec.ts @@ -2,32 +2,39 @@ import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'; -import { defineComponent, nextTick, ref } from 'vue'; +import { defineComponent, nextTick, reactive, ref, type Ref } from 'vue'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ApiError, getPublicPhoto, getPublicPhotos, + postLikeBatch, type PublicPhoto, type PublicPhotoList, } from '../api/client'; import { createViewerI18n, type ViewerI18n } from '../i18n'; import PhotoLightbox from '../components/PhotoLightbox.vue'; +import type { DownloadController } from '../download/useDownload'; import { usePhotoSequence, type PhotoSequence } from '../feed/sequence'; +import { useLikes } from '../likes/useLikes'; +import type { LikeBubble } from '../realtime/useAlbumRealtime'; // PhotoLightbox 组件测试(Task 8 业务 + UI-02 V04 深色布局): // browse 渲染/导航/序列扩展、wheel 缩放与双击复位、Esc/关闭/舞台空白点击关闭、 // 指针轻扫切换、签名过期静默重取与 404 移除上报;V04 新增:位置计数三种 -// 真实回退、缩略图条渲染与选择、更多菜单、焦点管理、body 滚动锁、安全区。 -// 深链解析与 URL 同步在 viewer-route.spec(真实路由)覆盖。 +// 真实回退、缩略图条渲染与选择、更多菜单、焦点管理、body 滚动锁、安全区; +// V05 新增(独立 describe):本地点赞/他人点赞 → 心形粒子与跨照片互动提示 +// 分层、navigator.share 分享与剪贴板回退、下载失败归属照片。深链解析与 URL +// 同步在 viewer-route.spec(真实路由)覆盖。 vi.mock('../api/client', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, getPublicPhotos: vi.fn(), getPublicPhoto: vi.fn() }; + return { ...actual, getPublicPhotos: vi.fn(), getPublicPhoto: vi.fn(), postLikeBatch: vi.fn() }; }); const mockedGetPublicPhotos = vi.mocked(getPublicPhotos); const mockedGetPublicPhoto = vi.mocked(getPublicPhoto); +const mockedPostLikeBatch = vi.mocked(postLikeBatch); function makePhoto(id: string, likeCount = 0): PublicPhoto { return { @@ -448,3 +455,164 @@ describe('photo lightbox dark viewer (UI-02 V04)', () => { expect(source).toContain('env(safe-area-inset-bottom, 0px)'); }); }); + +describe('photo lightbox interactions (UI-02 V05)', () => { + interface V05Options { + bubbles?: LikeBubble[]; + download?: unknown; + albumTitle?: string; + shareUrl?: string; + } + + /** 宿主:sequence + likes + 实时气泡 ref + v-model photoId(镜像 ActivityPage + * 的 V05 组装:粒子/互动提示/分享深链)。 */ + function mountV05( + photoId: string, + options: V05Options = {}, + ): { wrapper: VueWrapper; bubbles: Ref; current: Ref } { + let bubbles!: Ref; + let current!: Ref; + const Host = defineComponent({ + components: { PhotoLightbox }, + setup() { + const sequence = usePhotoSequence({ + token: ref('tok-lb'), + locale: ref('zh-CN'), + category: ref(null), + }); + const likes = useLikes({ token: ref('tok-lb'), photos: () => sequence.photos }); + bubbles = ref(options.bubbles ?? []); + current = ref(photoId); + return { + sequence, + likes, + bubbles, + current, + albumTitle: options.albumTitle, + shareUrl: options.shareUrl, + download: options.download, + }; + }, + template: ``, + }); + const wrapper = mount(Host, { global: { plugins: [i18n] } }); + mounted.push(wrapper); + return { wrapper, bubbles, current }; + } + + beforeEach(() => { + localStorage.clear(); + i18n = createViewerI18n('zh-CN'); + mockedGetPublicPhotos.mockReset().mockResolvedValue(makeList([makePhoto('p1'), makePhoto('p2'), makePhoto('p3')])); + mockedGetPublicPhoto.mockReset(); + mockedPostLikeBatch.mockReset().mockResolvedValue({ total: 0, countVersion: 1, batchAccepted: true }); + document.body.style.overflow = ''; + }); + + afterEach(() => { + while (mounted.length > 0) { + mounted.pop()?.unmount(); + } + Reflect.deleteProperty(navigator, 'share'); + Reflect.deleteProperty(navigator, 'clipboard'); + vi.restoreAllMocks(); + document.body.style.overflow = ''; + document.documentElement.className = ''; + }); + + it('bursts heart particles for a local like click on the current photo', async () => { + const { wrapper } = mountV05('p1'); + await flushPromises(); + + expect(wrapper.findAll('.heart-particles-zone .particle')).toHaveLength(0); + await wrapper.find('.like-slot .action-btn').trigger('click'); + await nextTick(); + expect(wrapper.findAll('.heart-particles-zone .particle')).toHaveLength(2); + }); + + it('routes realtime bursts: particles for the current photo, a notice for others; notice click navigates', async () => { + const { wrapper, bubbles, current } = mountV05('p1'); + await flushPromises(); + + // 当前照片的他人点赞(router 已滤自身回声)→ 舞台右区粒子。 + bubbles.value = [...bubbles.value, { key: 1, photoId: 'p1', delta: 5, thumbUrl: null }]; + await nextTick(); + expect(wrapper.findAll('.heart-particles-zone .particle')).toHaveLength(2); + + // 其他照片 → 互动提示(缩略图 + ♥ + delta),不新增当前照片粒子。 + bubbles.value = [...bubbles.value, { key: 2, photoId: 'p2', delta: 3, thumbUrl: 'http://img.test/p2-thumb.jpg' }]; + await nextTick(); + const notice = wrapper.find('.interaction-notice-zone .notice'); + expect(notice.exists()).toBe(true); + expect(notice.find('.notice-thumb').attributes('src')).toBe('http://img.test/p2-thumb.jpg'); + expect(notice.find('.notice-delta').text()).toBe('+3'); + expect(wrapper.findAll('.heart-particles-zone .particle')).toHaveLength(2); + + // 点击定位:与缩略图条共用入口(update:photoId,序列内导航)。 + await notice.trigger('click'); + expect(current.value).toBe('p2'); + }); + + it('shares via navigator.share with the album title and photo deep link; success is silent', async () => { + const share = vi.fn(() => Promise.resolve()); + Object.defineProperty(navigator, 'share', { value: share, configurable: true }); + const { wrapper } = mountV05('p1', { + albumTitle: '2026 公司年会', + shareUrl: '/a/tok-lb/p/p1?lang=zh-CN', + }); + await flushPromises(); + + await wrapper.find('button[aria-label="分享照片"]').trigger('click'); + await flushPromises(); + expect(share).toHaveBeenCalledWith({ title: '2026 公司年会', url: '/a/tok-lb/p/p1?lang=zh-CN' }); + // 原生分享成功:无应用内轻提示(面板已收尾,§5.4)。 + expect(wrapper.find('.share-status').exists()).toBe(false); + }); + + it('falls back to the clipboard with feedback when native share is unavailable', async () => { + const writeText = vi.fn(() => Promise.resolve()); + Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true }); + const { wrapper } = mountV05('p1', { shareUrl: '/a/tok-lb/p/p1?lang=zh-CN' }); + await flushPromises(); + + await wrapper.find('button[aria-label="分享照片"]').trigger('click'); + await flushPromises(); + expect(writeText).toHaveBeenCalledWith('/a/tok-lb/p/p1?lang=zh-CN'); + expect(wrapper.find('.share-status').text()).toContain('链接已复制'); + }); + + it('shows the download failure only on the photo that triggered it', async () => { + // 控制器桩:失败已归属 p1(useDownload.failedFor,§5.4 异步错误归属)。 + const download = reactive({ + busy: false, + failed: 'download.failed', + failedFor: 'p1', + wechatGuide: false, + copyNotice: '', + canDownload: true, + canDownloadOriginal: false, + }) as unknown as DownloadController; + const { wrapper, current } = mountV05('p1', { download }); + await flushPromises(); + + expect(wrapper.find('.download-failed').text()).toContain('下载失败'); + + // 切到 p2:p1 的下载错误不张冠李戴到当前照片。 + current.value = 'p2'; + await nextTick(); + expect(wrapper.find('.download-failed').exists()).toBe(false); + + // 切回 p1:错误提示仍归属原照片(可再见)。 + current.value = 'p1'; + await nextTick(); + expect(wrapper.find('.download-failed').exists()).toBe(true); + }); +}); diff --git a/apps/viewer/src/__tests__/photo-action-bar.spec.ts b/apps/viewer/src/__tests__/photo-action-bar.spec.ts index ba0ede6..02c8360 100644 --- a/apps/viewer/src/__tests__/photo-action-bar.spec.ts +++ b/apps/viewer/src/__tests__/photo-action-bar.spec.ts @@ -14,13 +14,11 @@ function mountBar(props: { likeCount: number; canDownload?: boolean; canDownloadOriginal?: boolean; - isLiking?: boolean; }): VueWrapper { const wrapper = mount(PhotoActionBar, { props: { canDownload: false, canDownloadOriginal: false, - isLiking: false, ...props, }, global: { plugins: [i18n] }, diff --git a/apps/viewer/src/__tests__/share.spec.ts b/apps/viewer/src/__tests__/share.spec.ts new file mode 100644 index 0000000..1181c81 --- /dev/null +++ b/apps/viewer/src/__tests__/share.spec.ts @@ -0,0 +1,69 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { copyText } from '../support/clipboard'; +import { shareOrCopy } from '../support/share'; + +// UI-02 V05 分享编排单测(§5.4):navigator.share 优先(成功即原生面板收尾); +// AbortError = 用户取消(静默,不落剪贴板);其他失败/不可用回退剪贴板; +// 复制失败返回 failed(调用方展示可选中文本)。 + +vi.mock('../support/clipboard', () => ({ copyText: vi.fn() })); + +const mockedCopyText = vi.mocked(copyText); + +function stubShare(implementation: () => Promise): ReturnType { + const fn = vi.fn(implementation); + Object.defineProperty(navigator, 'share', { value: fn, configurable: true }); + return fn; +} + +describe('share orchestration (UI-02 V05)', () => { + beforeEach(() => { + mockedCopyText.mockReset(); + }); + + afterEach(() => { + Reflect.deleteProperty(navigator, 'share'); + vi.restoreAllMocks(); + }); + + it('uses navigator.share when available and does not touch the clipboard', async () => { + const share = stubShare(() => Promise.resolve()); + + const outcome = await shareOrCopy({ title: 'Annual Party', url: 'https://x.test/a/tok?lang=en' }); + expect(outcome).toBe('shared'); + expect(share).toHaveBeenCalledWith({ title: 'Annual Party', url: 'https://x.test/a/tok?lang=en' }); + expect(mockedCopyText).not.toHaveBeenCalled(); + }); + + it('treats AbortError as a silent cancel (no error, no clipboard fallback)', async () => { + const share = stubShare(() => { + const error = new Error('The operation was aborted.'); + error.name = 'AbortError'; + return Promise.reject(error); + }); + + const outcome = await shareOrCopy({ title: 't', url: 'https://x.test/a/tok' }); + expect(outcome).toBe('cancelled'); + expect(share).toHaveBeenCalledTimes(1); + expect(mockedCopyText).not.toHaveBeenCalled(); + }); + + it('falls back to the clipboard when navigator.share rejects with another error', async () => { + stubShare(() => Promise.reject(new TypeError('Not allowed to share'))); + mockedCopyText.mockResolvedValue(true); + + const outcome = await shareOrCopy({ title: 't', url: 'https://x.test/a/tok' }); + expect(outcome).toBe('copied'); + expect(mockedCopyText).toHaveBeenCalledWith('https://x.test/a/tok'); + }); + + it('falls back to the clipboard without navigator.share; reports failure when copying fails', async () => { + expect('share' in navigator).toBe(false); // jsdom 默认无 navigator.share + + mockedCopyText.mockResolvedValueOnce(true); + await expect(shareOrCopy({ title: 't', url: 'https://x.test/a/tok' })).resolves.toBe('copied'); + + mockedCopyText.mockResolvedValueOnce(false); + await expect(shareOrCopy({ title: 't', url: 'https://x.test/a/tok' })).resolves.toBe('failed'); + }); +}); diff --git a/apps/viewer/src/components/PhotoLightbox.vue b/apps/viewer/src/components/PhotoLightbox.vue index 813d19e..3b30e9f 100644 --- a/apps/viewer/src/components/PhotoLightbox.vue +++ b/apps/viewer/src/components/PhotoLightbox.vue @@ -5,9 +5,12 @@ import type { DownloadVariant } from '../api/client'; import type { PhotoSequence } from '../feed/sequence'; import type { DownloadController } from '../download/useDownload'; import type { LikesController } from '../likes/useLikes'; +import type { LikeBubble } from '../realtime/useAlbumRealtime'; import PhotoActionBar from './viewer/PhotoActionBar.vue'; +import HeartParticles from './viewer/HeartParticles.vue'; +import InteractionNotice from './viewer/InteractionNotice.vue'; import ThumbnailStrip, { type ThumbnailItem } from './viewer/ThumbnailStrip.vue'; -import { copyText } from '../support/clipboard'; +import { shareOrCopy } from '../support/share'; import { reducedMotion, toggleReducedMotion } from '../ui/prefs'; import { positionDisplay } from '../viewer/position'; import { MIN_SCALE, panBy, resetZoom, wheelFactor, zoomAt, type ZoomState } from '../viewer/zoom'; @@ -44,6 +47,13 @@ const props = defineProps<{ likes?: LikesController; /** 下载控制器(P4a Task 4):缺省时不渲染下载位(组件单测/降级组装)。 */ download?: DownloadController; + /** 相册标题(§5.4 navigator.share 的分享标题)。 */ + albumTitle?: string; + /** 当前照片分享深链(含 lang 参数,ActivityPage 组装;缺省回退当前地址)。 */ + shareUrl?: string; + /** 他人点赞气泡(WS likes.burst,realtime router 已滤自身回声/隐藏/减少动画): + * 当前照片 → 心形粒子,其他照片 → 跨照片互动提示(§6.2 分层)。 */ + likeBubbles?: LikeBubble[]; }>(); const emit = defineEmits<{ @@ -361,6 +371,53 @@ function likeCurrent(): void { props.likes.like(photo.value.photoId); } +// —— 互动编排(Task 5 / V05 §6.2):当前照片心形粒子 + 其他照片互动提示分层 —— + +const heartParticles = ref<{ burst: (count: number) => void } | null>(null); +const interactionNotice = ref<{ notify: (entry: { photoId: string; delta: number; thumbUrl: string | null }) => void } | null>(null); + +/** 本地点赞每次有效点击的粒子数(1-3 取中,§6.1 立即轻量反馈)。 */ +const LOCAL_LIKE_PARTICLES = 2; + +// 本地点击(useLikes 动效事件):当前照片 → 舞台右区心形粒子。 +let stopLocalLikeListener: (() => void) | undefined; +if (props.likes !== undefined) { + stopLocalLikeListener = props.likes.onLocalLike((photoId) => { + if (photoId === props.photoId) heartParticles.value?.burst(LOCAL_LIKE_PARTICLES); + }); +} + +// 他人点赞气泡(§6.2 两类反馈分开):当前照片 → 粒子(1-2 枚按 delta 采样, +// 数值仍按真实 delta 校准——视觉采样不改变统计,§6.3);其他照片 → 互动提示。 +// 仅处理挂载后新到的气泡:打开大图瞬间已在屏的不回放(§6.3 不回放历史动画)。 +let bubblesInitialized = false; +let maxSeenBubbleKey = Number.NEGATIVE_INFINITY; +watch( + () => props.likeBubbles, + (bubbles) => { + if (bubbles === undefined) return; + if (!bubblesInitialized) { + bubblesInitialized = true; + for (const bubble of bubbles) maxSeenBubbleKey = Math.max(maxSeenBubbleKey, bubble.key); + return; + } + for (const bubble of bubbles) { + if (bubble.key <= maxSeenBubbleKey) continue; + maxSeenBubbleKey = bubble.key; + if (bubble.photoId === props.photoId) { + heartParticles.value?.burst(Math.min(2, Math.max(1, bubble.delta))); + } else { + interactionNotice.value?.notify({ + photoId: bubble.photoId, + delta: bubble.delta, + thumbUrl: bubble.thumbUrl, + }); + } + } + }, + { immediate: true }, +); + // —— 下载(P4a Task 4):入口与二级菜单在 PhotoActionBar,控制器编排在此 —— const canDownload = computed(() => props.download !== undefined && props.download.canDownload); @@ -370,20 +427,22 @@ function onDownloadVariant(variant: DownloadVariant): void { void props.download?.download(variant); } -// —— 分享(V04 最小实现:复制当前照片深链——路由已同步 /p/:photoId; -// navigator.share 编排与语言参数属 Task 5/V05) —— +// —— 分享(V05 §5.4):navigator.share 优先(成功/取消无额外反馈——原生面板 +// 已收尾;AbortError 静默),不可用或其他失败回退剪贴板;复制失败保留 +// 可选中文本。分享目标为照片深链(含 lang 参数,ActivityPage 组装), +// 不分享 OSS 签名 URL —— const shareState = ref<'idle' | 'copied' | 'failed'>('idle'); const shareUrlText = ref(''); let shareResetTimer: ReturnType | undefined; async function shareCurrent(): Promise { - shareUrlText.value = window.location.href; - const copied = await copyText(shareUrlText.value); - shareState.value = copied ? 'copied' : 'failed'; + shareUrlText.value = props.shareUrl ?? window.location.href; + const outcome = await shareOrCopy({ title: props.albumTitle ?? '', url: shareUrlText.value }); + shareState.value = outcome === 'copied' ? 'copied' : outcome === 'failed' ? 'failed' : 'idle'; if (shareResetTimer !== undefined) clearTimeout(shareResetTimer); // 失败时保留可选中文本(不自动消失),成功反馈数秒后收起。 - if (copied) { + if (outcome === 'copied') { shareResetTimer = setTimeout(() => { shareState.value = 'idle'; }, 3000); @@ -461,6 +520,7 @@ onBeforeUnmount(() => { document.removeEventListener('pointerdown', onOuterPointerDown, true); document.body.style.overflow = savedBodyOverflow; if (shareResetTimer !== undefined) clearTimeout(shareResetTimer); + stopLocalLikeListener?.(); // 粒子订阅随大图卸载解除(useLikes 侧不残留) // 焦点归还触发元素(瀑布流单元格;深链直达时为 body——无可归还则不动)。 if (savedFocus !== null && savedFocus.isConnected) savedFocus.focus(); }); @@ -565,20 +625,27 @@ onBeforeUnmount(() => { - -