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
+29 -3
View File
@@ -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<typeof spyWindowOpen>;
/** 宿主实例形态: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: `<PhotoLightbox v-model:photo-id="photoId" :sequence="sequence" :download="download" />`,
});
@@ -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(