feat(viewer): UI-02 heart particles, interaction notices, share and download

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 <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-21 22:20:19 +08:00
parent 89f45896b7
commit 160e7e5f1a
18 changed files with 1042 additions and 58 deletions
+172 -4
View File
@@ -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<typeof import('../api/client')>();
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<LikeBubble[]>; current: Ref<string> } {
let bubbles!: Ref<LikeBubble[]>;
let current!: Ref<string>;
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: `<PhotoLightbox
v-model:photo-id="current"
:sequence="sequence"
:likes="likes"
:like-bubbles="bubbles"
:album-title="albumTitle"
:share-url="shareUrl"
:download="download"
/>`,
});
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);
});
});