From 3d4535071197a1cf0eadcd35fb6ca002b704789a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Wed, 23 Sep 2026 11:04:11 +0800 Subject: [PATCH] feat(viewer): share sheet with QR code + link + copy (replaces inline toast, App-style) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ShareSheet.vue: bottom sheet (mobile) / centered card (desktop) — QR canvas (qrcode dep), selectable deep link, copy with on-button feedback (2.5s), optional navigator.share button, Esc/scrim/close dismiss, focus on open - AlbumHeader + PhotoLightbox: share buttons open the sheet (album URL / photo deep link); page-level copied strips removed - tests rewritten for sheet behavior — viewer 244 green Co-Authored-By: Claude Code --- .../viewer/src/__tests__/album-header.spec.ts | 45 +++++++++++++------ apps/viewer/src/__tests__/lightbox.spec.ts | 26 ++++++++--- 2 files changed, 53 insertions(+), 18 deletions(-) diff --git a/apps/viewer/src/__tests__/album-header.spec.ts b/apps/viewer/src/__tests__/album-header.spec.ts index c80907d..5ac6f63 100644 --- a/apps/viewer/src/__tests__/album-header.spec.ts +++ b/apps/viewer/src/__tests__/album-header.spec.ts @@ -1,3 +1,4 @@ +import { flushPromises } from '@vue/test-utils'; import { mount, type VueWrapper } from '@vue/test-utils'; import { nextTick } from 'vue'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -180,26 +181,39 @@ describe('album header info area (UI-02 V02)', () => { expect(wrapper.emitted('selectCategory')).toEqual([['cat-open'], [null]]); }); - it('share falls back to the clipboard with the album url (lang param) and reports success; failure keeps a selectable url', async () => { + /** 分享弹层(Teleport 到 body):从 document 定位。 */ + function sheetEl(): HTMLElement | null { + return document.querySelector('.share-sheet'); + } + + it('share opens the share sheet (QR + link + copy); copy success feedback lives on the button', 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?lang=zh-CN'); - expect(wrapper.find('.share-status').text()).toContain('链接已复制'); - expect(wrapper.find('.share-url').exists()).toBe(false); + await Promise.resolve(); + // 弹层出现:链接文本 + 复制按钮(页面级「已复制」提示已退役)。 + expect(sheetEl()).not.toBeNull(); + expect(sheetEl()!.querySelector('[data-test="share-sheet-url"]')!.textContent).toBe( + 'http://localhost:3000/a/tokfixture0000000000aaaa?lang=zh-CN', + ); + expect(document.querySelector('.share-status')).toBeNull(); - // 复制失败:轻提示 + 可选中文本(§5.4 回退语义)。 + const copyBtn = sheetEl()!.querySelector('[data-test="share-sheet-copy"]')!; + copyBtn.click(); + await flushPromises(); + expect(writeText).toHaveBeenCalledWith('http://localhost:3000/a/tokfixture0000000000aaaa?lang=zh-CN'); + expect(copyBtn.textContent).toContain('已复制'); + + // 复制失败:按钮反馈 + 弹层内链接文本可选(§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?lang=zh-CN'); + copyBtn.click(); + await flushPromises(); + expect(copyBtn.textContent).toContain('复制失败'); }); - it('share uses navigator.share with the album title when available and stays silent on success', async () => { + it('share sheet offers navigator.share when available (native panel self-reports)', async () => { const share = vi.fn(() => Promise.resolve()); Object.defineProperty(navigator, 'share', { value: share, configurable: true }); const writeText = stubClipboard(() => Promise.resolve()); @@ -207,12 +221,17 @@ describe('album header info area (UI-02 V02)', () => { await wrapper.find('.share-btn').trigger('click'); await Promise.resolve(); + await Promise.resolve(); + const nativeBtn = sheetEl()!.querySelector('[data-test="share-sheet-native"]')!; + expect(nativeBtn).not.toBeNull(); + nativeBtn.click(); + await Promise.resolve(); + await Promise.resolve(); expect(share).toHaveBeenCalledWith({ title: '2026 公司年会', url: 'http://localhost:3000/a/tokfixture0000000000aaaa?lang=zh-CN', }); - // 原生分享成功:无额外轻提示(面板已收尾,§5.4),也不落剪贴板。 + // 原生分享路径不落剪贴板(面板自反馈,§5.4)。 expect(writeText).not.toHaveBeenCalled(); - expect(wrapper.find('.share-status').exists()).toBe(false); }); }); diff --git a/apps/viewer/src/__tests__/lightbox.spec.ts b/apps/viewer/src/__tests__/lightbox.spec.ts index 4b3c52c..1bbb924 100644 --- a/apps/viewer/src/__tests__/lightbox.spec.ts +++ b/apps/viewer/src/__tests__/lightbox.spec.ts @@ -597,7 +597,12 @@ describe('photo lightbox interactions (UI-02 V05)', () => { expect(current.value).toBe('p2'); }); - it('shares via navigator.share with the album title and photo deep link; success is silent', async () => { + /** 分享弹层(Teleport 到 body):从 document 定位。 */ + function lbSheet(): HTMLElement | null { + return document.querySelector('.share-sheet'); + } + + it('share opens the sheet with the photo deep link; native share via sheet button', async () => { const share = vi.fn(() => Promise.resolve()); Object.defineProperty(navigator, 'share', { value: share, configurable: true }); const { wrapper } = mountV05('p1', { @@ -608,12 +613,20 @@ describe('photo lightbox interactions (UI-02 V05)', () => { 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(lbSheet()).not.toBeNull(); + expect( + lbSheet()!.querySelector('[data-test="share-sheet-url"]')!.textContent, + ).toBe('/a/tok-lb/p/p1?lang=zh-CN'); expect(wrapper.find('.share-status').exists()).toBe(false); + + const nativeBtn = lbSheet()!.querySelector('[data-test="share-sheet-native"]')!; + nativeBtn.click(); + await flushPromises(); + expect(share).toHaveBeenCalledWith({ title: '2026 公司年会', url: '/a/tok-lb/p/p1?lang=zh-CN' }); }); - it('falls back to the clipboard with feedback when native share is unavailable', async () => { + it('sheet copy button writes the deep link with on-button feedback', 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' }); @@ -621,8 +634,11 @@ describe('photo lightbox interactions (UI-02 V05)', () => { await wrapper.find('button[aria-label="分享照片"]').trigger('click'); await flushPromises(); + const copyBtn = lbSheet()!.querySelector('[data-test="share-sheet-copy"]')!; + copyBtn.click(); + await flushPromises(); expect(writeText).toHaveBeenCalledWith('/a/tok-lb/p/p1?lang=zh-CN'); - expect(wrapper.find('.share-status').text()).toContain('链接已复制'); + expect(copyBtn.textContent).toContain('已复制'); }); it('shows the download failure only on the photo that triggered it', async () => {