feat(viewer): share sheet with QR code + link + copy (replaces inline toast, App-style)

- 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 <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-23 11:04:11 +08:00
parent e3f40c696d
commit 3d45350711
2 changed files with 53 additions and 18 deletions
+32 -13
View File
@@ -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<HTMLButtonElement>('[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<HTMLButtonElement>('[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);
});
});
+21 -5
View File
@@ -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<HTMLButtonElement>('[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<HTMLButtonElement>('[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 () => {