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
+22 -4
View File
@@ -57,6 +57,7 @@ describe('album header info area (UI-02 V02)', () => {
clientSpy?.mockRestore(); clientSpy?.mockRestore();
clientSpy = null; clientSpy = null;
Reflect.deleteProperty(navigator, 'clipboard'); Reflect.deleteProperty(navigator, 'clipboard');
Reflect.deleteProperty(navigator, 'share');
}); });
it('renders banner, title, multi-line description, Intl view count, venue and sort/category toolbar', () => { 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]]); 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 writeText = stubClipboard(() => Promise.resolve());
const wrapper = mountHeader({ album: makeAlbum(), viewCount: null }); const wrapper = mountHeader({ album: makeAlbum(), viewCount: null });
await wrapper.find('.share-btn').trigger('click'); await wrapper.find('.share-btn').trigger('click');
await Promise.resolve(); 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-status').text()).toContain('链接已复制');
expect(wrapper.find('.share-url').exists()).toBe(false); expect(wrapper.find('.share-url').exists()).toBe(false);
// 复制失败:轻提示 + 可选中文本(§5.4 回退语义,完整 navigator.share 属 Task 5)。 // 复制失败:轻提示 + 可选中文本(§5.4 回退语义)。
writeText.mockImplementation(() => Promise.reject(new Error('denied'))); writeText.mockImplementation(() => Promise.reject(new Error('denied')));
await wrapper.find('.share-btn').trigger('click'); await wrapper.find('.share-btn').trigger('click');
await Promise.resolve(); await Promise.resolve();
await Promise.resolve(); await Promise.resolve();
expect(wrapper.find('.share-status').text()).toContain('复制失败'); 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);
}); });
}); });
+29 -3
View File
@@ -1,5 +1,5 @@
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'; 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { import {
ApiError, ApiError,
@@ -89,9 +89,10 @@ function spyWindowOpen() {
} }
let openSpy: ReturnType<typeof spyWindowOpen>; let openSpy: ReturnType<typeof spyWindowOpen>;
/** 宿主实例形态:setup 暴露 albumRef(ref 经代理解包)+ download 控制器。 */ /** 宿主实例形态:setup 暴露 albumRef/currentRef(ref 经代理解包)+ download 控制器。 */
interface HostVm { interface HostVm {
albumRef: PublicAlbum | null; albumRef: PublicAlbum | null;
currentRef: string;
download: DownloadController; download: DownloadController;
} }
@@ -112,7 +113,7 @@ function mountLightbox(album: PublicAlbum): VueWrapper & { vm: HostVm } {
album: () => albumRef.value, album: () => albumRef.value,
photoId: () => current.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" />`, 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(); 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 () => { it('DOWNLOAD_DISABLED surfaces the dedicated localized notice (T15 漏译修复)', async () => {
// 相册中途关闭下载开关(载荷仍开但端点 403)→ 专属文案而非通用失败。 // 相册中途关闭下载开关(载荷仍开但端点 403)→ 专属文案而非通用失败。
mockedGetPhotoDownload.mockReset().mockRejectedValue( mockedGetPhotoDownload.mockReset().mockRejectedValue(
@@ -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);
});
});
@@ -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);
});
});
+172 -4
View File
@@ -2,32 +2,39 @@ import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'; 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { import {
ApiError, ApiError,
getPublicPhoto, getPublicPhoto,
getPublicPhotos, getPublicPhotos,
postLikeBatch,
type PublicPhoto, type PublicPhoto,
type PublicPhotoList, type PublicPhotoList,
} from '../api/client'; } from '../api/client';
import { createViewerI18n, type ViewerI18n } from '../i18n'; import { createViewerI18n, type ViewerI18n } from '../i18n';
import PhotoLightbox from '../components/PhotoLightbox.vue'; import PhotoLightbox from '../components/PhotoLightbox.vue';
import type { DownloadController } from '../download/useDownload';
import { usePhotoSequence, type PhotoSequence } from '../feed/sequence'; import { usePhotoSequence, type PhotoSequence } from '../feed/sequence';
import { useLikes } from '../likes/useLikes';
import type { LikeBubble } from '../realtime/useAlbumRealtime';
// PhotoLightbox 组件测试(Task 8 业务 + UI-02 V04 深色布局): // PhotoLightbox 组件测试(Task 8 业务 + UI-02 V04 深色布局):
// browse 渲染/导航/序列扩展、wheel 缩放与双击复位、Esc/关闭/舞台空白点击关闭、 // browse 渲染/导航/序列扩展、wheel 缩放与双击复位、Esc/关闭/舞台空白点击关闭、
// 指针轻扫切换、签名过期静默重取与 404 移除上报;V04 新增:位置计数三种 // 指针轻扫切换、签名过期静默重取与 404 移除上报;V04 新增:位置计数三种
// 真实回退、缩略图条渲染与选择、更多菜单、焦点管理、body 滚动锁、安全区。 // 真实回退、缩略图条渲染与选择、更多菜单、焦点管理、body 滚动锁、安全区;
// 深链解析与 URL 同步在 viewer-route.spec(真实路由)覆盖。 // V05 新增(独立 describe):本地点赞/他人点赞 → 心形粒子与跨照片互动提示
// 分层、navigator.share 分享与剪贴板回退、下载失败归属照片。深链解析与 URL
// 同步在 viewer-route.spec(真实路由)覆盖。
vi.mock('../api/client', async (importOriginal) => { vi.mock('../api/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('../api/client')>(); 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 mockedGetPublicPhotos = vi.mocked(getPublicPhotos);
const mockedGetPublicPhoto = vi.mocked(getPublicPhoto); const mockedGetPublicPhoto = vi.mocked(getPublicPhoto);
const mockedPostLikeBatch = vi.mocked(postLikeBatch);
function makePhoto(id: string, likeCount = 0): PublicPhoto { function makePhoto(id: string, likeCount = 0): PublicPhoto {
return { return {
@@ -448,3 +455,164 @@ describe('photo lightbox dark viewer (UI-02 V04)', () => {
expect(source).toContain('env(safe-area-inset-bottom, 0px)'); 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);
});
});
@@ -14,13 +14,11 @@ function mountBar(props: {
likeCount: number; likeCount: number;
canDownload?: boolean; canDownload?: boolean;
canDownloadOriginal?: boolean; canDownloadOriginal?: boolean;
isLiking?: boolean;
}): VueWrapper { }): VueWrapper {
const wrapper = mount(PhotoActionBar, { const wrapper = mount(PhotoActionBar, {
props: { props: {
canDownload: false, canDownload: false,
canDownloadOriginal: false, canDownloadOriginal: false,
isLiking: false,
...props, ...props,
}, },
global: { plugins: [i18n] }, global: { plugins: [i18n] },
+69
View File
@@ -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<void>): ReturnType<typeof vi.fn> {
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');
});
});
+83 -31
View File
@@ -5,9 +5,12 @@ import type { DownloadVariant } from '../api/client';
import type { PhotoSequence } from '../feed/sequence'; import type { PhotoSequence } from '../feed/sequence';
import type { DownloadController } from '../download/useDownload'; import type { DownloadController } from '../download/useDownload';
import type { LikesController } from '../likes/useLikes'; import type { LikesController } from '../likes/useLikes';
import type { LikeBubble } from '../realtime/useAlbumRealtime';
import PhotoActionBar from './viewer/PhotoActionBar.vue'; 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 ThumbnailStrip, { type ThumbnailItem } from './viewer/ThumbnailStrip.vue';
import { copyText } from '../support/clipboard'; import { shareOrCopy } from '../support/share';
import { reducedMotion, toggleReducedMotion } from '../ui/prefs'; import { reducedMotion, toggleReducedMotion } from '../ui/prefs';
import { positionDisplay } from '../viewer/position'; import { positionDisplay } from '../viewer/position';
import { MIN_SCALE, panBy, resetZoom, wheelFactor, zoomAt, type ZoomState } from '../viewer/zoom'; import { MIN_SCALE, panBy, resetZoom, wheelFactor, zoomAt, type ZoomState } from '../viewer/zoom';
@@ -44,6 +47,13 @@ const props = defineProps<{
likes?: LikesController; likes?: LikesController;
/** 下载控制器(P4a Task 4):缺省时不渲染下载位(组件单测/降级组装)。 */ /** 下载控制器(P4a Task 4):缺省时不渲染下载位(组件单测/降级组装)。 */
download?: DownloadController; download?: DownloadController;
/** 相册标题(§5.4 navigator.share 的分享标题)。 */
albumTitle?: string;
/** 当前照片分享深链(含 lang 参数,ActivityPage 组装;缺省回退当前地址)。 */
shareUrl?: string;
/** 他人点赞气泡(WS likes.burst,realtime router 已滤自身回声/隐藏/减少动画):
* 当前照片 → 心形粒子,其他照片 → 跨照片互动提示(§6.2 分层)。 */
likeBubbles?: LikeBubble[];
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -361,6 +371,53 @@ function likeCurrent(): void {
props.likes.like(photo.value.photoId); 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,控制器编排在此 —— // —— 下载(P4a Task 4):入口与二级菜单在 PhotoActionBar,控制器编排在此 ——
const canDownload = computed(() => props.download !== undefined && props.download.canDownload); const canDownload = computed(() => props.download !== undefined && props.download.canDownload);
@@ -370,20 +427,22 @@ function onDownloadVariant(variant: DownloadVariant): void {
void props.download?.download(variant); void props.download?.download(variant);
} }
// —— 分享(V04 最小实现:复制当前照片深链——路由已同步 /p/:photoId; // —— 分享(V05 §5.4):navigator.share 优先(成功/取消无额外反馈——原生面板
// navigator.share 编排与语言参数属 Task 5/V05) —— // 已收尾;AbortError 静默),不可用或其他失败回退剪贴板;复制失败保留
// 可选中文本。分享目标为照片深链(含 lang 参数,ActivityPage 组装),
// 不分享 OSS 签名 URL ——
const shareState = ref<'idle' | 'copied' | 'failed'>('idle'); const shareState = ref<'idle' | 'copied' | 'failed'>('idle');
const shareUrlText = ref(''); const shareUrlText = ref('');
let shareResetTimer: ReturnType<typeof setTimeout> | undefined; let shareResetTimer: ReturnType<typeof setTimeout> | undefined;
async function shareCurrent(): Promise<void> { async function shareCurrent(): Promise<void> {
shareUrlText.value = window.location.href; shareUrlText.value = props.shareUrl ?? window.location.href;
const copied = await copyText(shareUrlText.value); const outcome = await shareOrCopy({ title: props.albumTitle ?? '', url: shareUrlText.value });
shareState.value = copied ? 'copied' : 'failed'; shareState.value = outcome === 'copied' ? 'copied' : outcome === 'failed' ? 'failed' : 'idle';
if (shareResetTimer !== undefined) clearTimeout(shareResetTimer); if (shareResetTimer !== undefined) clearTimeout(shareResetTimer);
// 失败时保留可选中文本(不自动消失),成功反馈数秒后收起。 // 失败时保留可选中文本(不自动消失),成功反馈数秒后收起。
if (copied) { if (outcome === 'copied') {
shareResetTimer = setTimeout(() => { shareResetTimer = setTimeout(() => {
shareState.value = 'idle'; shareState.value = 'idle';
}, 3000); }, 3000);
@@ -461,6 +520,7 @@ onBeforeUnmount(() => {
document.removeEventListener('pointerdown', onOuterPointerDown, true); document.removeEventListener('pointerdown', onOuterPointerDown, true);
document.body.style.overflow = savedBodyOverflow; document.body.style.overflow = savedBodyOverflow;
if (shareResetTimer !== undefined) clearTimeout(shareResetTimer); if (shareResetTimer !== undefined) clearTimeout(shareResetTimer);
stopLocalLikeListener?.(); // 粒子订阅随大图卸载解除(useLikes 侧不残留)
// 焦点归还触发元素(瀑布流单元格;深链直达时为 body——无可归还则不动)。 // 焦点归还触发元素(瀑布流单元格;深链直达时为 body——无可归还则不动)。
if (savedFocus !== null && savedFocus.isConnected) savedFocus.focus(); if (savedFocus !== null && savedFocus.isConnected) savedFocus.focus();
}); });
@@ -565,20 +625,27 @@ onBeforeUnmount(() => {
</svg> </svg>
</button> </button>
<!-- 心形粒子层(Task 5 / V05 填充):装饰性、不可交互 --> <!-- 心形粒子层(Task 5 / V05 §6.2):装饰性、不可交互;触发见上方互动编排 -->
<div class="heart-particles-zone" aria-hidden="true" style="pointer-events: none" /> <HeartParticles ref="heartParticles" />
<!-- 跨照片互动提示层(Task 5 / V05 填充):底部操作栏上方安全区 --> <!-- 跨照片互动提示层(Task 5 / V05 §6.2):底部操作栏上方安全区;
<div class="interaction-notice-zone" /> 点击定位 = 缩略图条共用入口(序列未含该照片时忽略——不伪造定位) -->
<InteractionNotice ref="interactionNotice" @select="selectThumbnail" />
<div class="stage-notices"> <div class="stage-notices">
<p v-if="extending" class="loading">{{ t('album.loading') }}</p> <p v-if="extending" class="loading">{{ t('album.loading') }}</p>
<!-- 分享反馈轻提示(复制成功/失败;失败保留可选中文本) --> <!-- 分享反馈轻提示(回退复制成功/失败;失败保留可选中文本;原生分享
成功/取消不显示——面板已收尾,§5.4) -->
<p v-if="shareState !== 'idle'" class="share-status" role="status"> <p v-if="shareState !== 'idle'" class="share-status" role="status">
{{ shareState === 'copied' ? t('viewer.shareCopied') : t('viewer.shareCopyFailed') }} {{ shareState === 'copied' ? t('viewer.shareCopied') : t('viewer.shareCopyFailed') }}
<span v-if="shareState === 'failed'" class="share-url">{{ shareUrlText }}</span> <span v-if="shareState === 'failed'" class="share-url">{{ shareUrlText }}</span>
</p> </p>
<!-- 下载失败轻提示(端点失败/网络;DOWNLOAD_DISABLED 专属文案)。 --> <!-- 下载失败轻提示(端点失败/网络;DOWNLOAD_DISABLED 专属文案):
<p v-if="download !== undefined && download.failed" class="download-failed" role="alert"> 仅归属到触发下载的那张照片(§5.4 异步错误归属)。 -->
<p
v-if="download !== undefined && download.failed && download.failedFor === photo.photoId"
class="download-failed"
role="alert"
>
{{ t(download.failed) }} {{ t(download.failed) }}
</p> </p>
</div> </div>
@@ -821,23 +888,8 @@ onBeforeUnmount(() => {
right: 8px; right: 8px;
} }
/* 粒子/互动占位层(Task 5 填充):舞台右侧上浮 / 操作栏上方安全区。 */ /* 粒子层/互动提示层的定位与样式由 HeartParticles / InteractionNotice 自带
.heart-particles-zone { * (Task 5:舞台右侧上浮 / 操作栏上方安全区)。 */
position: absolute;
inset: 0;
overflow: hidden;
}
.interaction-notice-zone {
position: absolute;
bottom: 12px;
left: 16px;
right: 16px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
pointer-events: none;
}
.stage-notices { .stage-notices {
position: absolute; position: absolute;
bottom: 12px; bottom: 12px;
@@ -3,7 +3,7 @@ import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import type { PublicAlbum, SortMode } from '../../api/client'; import type { PublicAlbum, SortMode } from '../../api/client';
import { copyText } from '../../support/clipboard'; import { shareOrCopy } from '../../support/share';
// UI-02 V02 活动信息区(从 ActivityPage 提取):宣传图 + 标题/分享 + 说明 + // UI-02 V02 活动信息区(从 ActivityPage 提取):宣传图 + 标题/分享 + 说明 +
// 元信息行(浏览/日期/地点)+ 排序/分类工具栏。数据与筛选决策仍在父级 // 元信息行(浏览/日期/地点)+ 排序/分类工具栏。数据与筛选决策仍在父级
@@ -122,21 +122,23 @@ const venueText = computed(() => {
const descriptionText = computed(() => props.album.translation.description); const descriptionText = computed(() => props.album.translation.description);
// —— 分享(V02 最小实现:复制相册链接;navigator.share 完整编排属 Task 5) —— // —— 分享(V05 §5.4 完整编排):navigator.share 优先(相册标题 + 相册路由
// URL,附 lang 参数;不分享 OSS URL),不可用/失败回退剪贴板;复制失败
// 保留可选中文本;用户取消原生分享不显示错误 ——
const shareState = ref<'idle' | 'copied' | 'failed'>('idle'); const shareState = ref<'idle' | 'copied' | 'failed'>('idle');
let shareResetTimer: ReturnType<typeof setTimeout> | undefined; let shareResetTimer: ReturnType<typeof setTimeout> | undefined;
const albumUrl = computed(() => { const albumUrl = computed(() => {
const path = router.resolve({ name: 'activity', params: { token: props.album.publicToken } }).href; const path = router.resolve({ name: 'activity', params: { token: props.album.publicToken } }).href;
return new URL(path, window.location.origin).href; return new URL(`${path}?lang=${locale.value}`, window.location.origin).href;
}); });
async function share(): Promise<void> { async function share(): Promise<void> {
const copied = await copyText(albumUrl.value); const outcome = await shareOrCopy({ title: props.album.translation.title, url: albumUrl.value });
shareState.value = copied ? 'copied' : 'failed'; shareState.value = outcome === 'copied' ? 'copied' : outcome === 'failed' ? 'failed' : 'idle';
if (shareResetTimer !== undefined) clearTimeout(shareResetTimer); if (shareResetTimer !== undefined) clearTimeout(shareResetTimer);
// 失败时保留可选中文本(不自动消失),成功反馈数秒后收起。 // 失败时保留可选中文本(不自动消失),成功反馈数秒后收起。
if (copied) { if (outcome === 'copied') {
shareResetTimer = setTimeout(() => { shareResetTimer = setTimeout(() => {
shareState.value = 'idle'; shareState.value = 'idle';
}, 3000); }, 3000);
@@ -182,7 +184,7 @@ onBeforeUnmount(() => {
<header class="album-info"> <header class="album-info">
<div class="title-row"> <div class="title-row">
<h1 ref="titleEl" class="title" :class="{ clamped: !titleExpanded }">{{ album.translation.title }}</h1> <h1 ref="titleEl" class="title" :class="{ clamped: !titleExpanded }">{{ album.translation.title }}</h1>
<button type="button" class="share-btn" @click="share"> <button type="button" class="share-btn" :aria-label="t('viewer.shareAlbum')" @click="share">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"> <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M12 3v12" /> <path d="M12 3v12" />
<path d="m7 8 5-5 5 5" /> <path d="m7 8 5-5 5 5" />
@@ -0,0 +1,184 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { reducedMotion } from '../../ui/prefs';
// UI-02 §6.2/§6.3 装饰心形粒子(Task 5 / V05):coral 半透明心形自舞台右区
// 上浮,2-3 条轻微漂移轨迹,约 1.2-1.8s 消散。纯装饰层:pointer-events:none
// + aria-hidden(§6.2);同时至多 12 枚(超出弃最旧,§6.3),每枚带自清理
// 定时器;页面隐藏/「减少动画」开启/组件卸载即清空,不排队回放历史动画。
// 触发源由 PhotoLightbox 编排:当前照片本地点赞(useLikes.onLocalLike)或
// 该照片收到他人点赞 WS 气泡(自身回声已在 realtime router 滤除)。计数与
// 批次业务完全不受本层影响(§6.1「不为动画补帧额外发送点赞」)。
/** 同屏粒子上限(§6.3:超出丢弃最旧)。 */
const PARTICLE_CAP = 12;
/** 漂移轨迹变体(§6.3「2-3 条轻微漂移轨迹」)。 */
const DRIFT_PATHS = ['a', 'b', 'c'] as const;
interface Particle {
key: number;
/** 起始水平位置(舞台右区,§6.2「大图内靠右侧上浮」;百分比)。 */
x: number;
/** 起始垂直位置(自底部,百分比)。 */
y: number;
/** 心形字号 = 粒子边长(18-30px,§6.3)。 */
size: number;
/** 水平漂移量(px,正负均可)。 */
drift: number;
/** 动画时长(1.2-1.8s,§6.3)。 */
dur: number;
path: (typeof DRIFT_PATHS)[number];
}
const particles = ref<Particle[]>([]);
let nextKey = 0;
const removeTimers = new Set<ReturnType<typeof setTimeout>>();
/** 减少动画判定:应用内偏好或系统 prefers-reduced-motion 任一命中即不漂浮。 */
function motionReduced(): boolean {
if (reducedMotion.value) return true;
if (typeof window.matchMedia === 'function') {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
return false;
}
/** 触发一次上浮(count 会被钳到上限内)。「减少动画」时不生成(§6.3)。 */
function burst(count: number): void {
if (motionReduced()) return;
const created: Particle[] = [];
for (let i = 0; i < count; i += 1) {
const particle: Particle = {
key: ++nextKey,
x: 62 + Math.round(Math.random() * 26),
y: 8 + Math.round(Math.random() * 26),
size: 18 + Math.round(Math.random() * 12),
drift: Math.round((Math.random() * 2 - 1) * 56),
dur: 1200 + Math.round(Math.random() * 600),
path: DRIFT_PATHS[nextKey % DRIFT_PATHS.length],
};
created.push(particle);
const timer = setTimeout(() => {
removeTimers.delete(timer);
particles.value = particles.value.filter((item) => item.key !== particle.key);
}, particle.dur + 60); // 动画结束后移除(+60ms 缓冲收尾帧)
removeTimers.add(timer);
}
// 上限裁剪:slice(-0) 会返回整个数组,room<=0 时必须显式清空。
const room = PARTICLE_CAP - created.length;
particles.value = [...(room > 0 ? particles.value.slice(-room) : []), ...created];
}
function clearAll(): void {
for (const timer of removeTimers) clearTimeout(timer);
removeTimers.clear();
particles.value = [];
}
function onVisibilityChange(): void {
// 页面隐藏:清空在屏粒子与计时器(§6.3「恢复后不回放大量历史动画」)。
if (document.hidden) clearAll();
}
// 「减少动画」开启瞬间:正在漂浮的立即静止清空(不残留冻结的心形)。
watch(reducedMotion, (enabled) => {
if (enabled) clearAll();
});
onMounted(() => document.addEventListener('visibilitychange', onVisibilityChange));
onBeforeUnmount(() => {
document.removeEventListener('visibilitychange', onVisibilityChange);
clearAll();
});
defineExpose({ burst });
</script>
<template>
<div class="heart-particles-zone" aria-hidden="true">
<span
v-for="particle in particles"
:key="particle.key"
class="particle"
:class="`drift-${particle.path}`"
:style="{
left: `${particle.x}%`,
bottom: `${particle.y}%`,
fontSize: `${particle.size}px`,
'--drift': `${particle.drift}px`,
'--dur': `${particle.dur}ms`,
}"
>♥</span>
</div>
</template>
<style scoped>
/* 装饰层:不拦截任何指针事件(§6.2);溢出裁剪防漂出舞台。 */
.heart-particles-zone {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
}
.particle {
position: absolute;
line-height: 1;
color: var(--viewer-like-accent, #ff667d);
text-shadow: 0 0 8px rgba(255, 102, 125, 0.35);
animation: heart-float-a var(--dur) ease-out forwards;
will-change: transform, opacity;
}
/* 2-3 条漂移轨迹(§6.3):直线 / 先外后回 / 先内后回,摆幅共用 --drift。 */
.drift-b {
animation-name: heart-float-b;
}
.drift-c {
animation-name: heart-float-c;
}
/* 系统「减少动画」:不漂浮(应用内偏好在 burst() 入口已拦截)。 */
@media (prefers-reduced-motion: reduce) {
.particle {
animation: none;
}
}
@keyframes heart-float-a {
from {
transform: translateY(0) translateX(0) scale(0.9);
opacity: 0.85;
}
to {
transform: translateY(-220px) translateX(var(--drift)) scale(1.15);
opacity: 0;
}
}
@keyframes heart-float-b {
from {
transform: translateY(0) translateX(0) scale(0.9);
opacity: 0.85;
}
55% {
transform: translateY(-120px) translateX(calc(var(--drift) * 1.35)) scale(1);
opacity: 0.55;
}
to {
transform: translateY(-220px) translateX(var(--drift)) scale(1.15);
opacity: 0;
}
}
@keyframes heart-float-c {
from {
transform: translateY(0) translateX(0) scale(0.9);
opacity: 0.85;
}
55% {
transform: translateY(-120px) translateX(calc(var(--drift) * 0.4)) scale(1.02);
opacity: 0.6;
}
to {
transform: translateY(-220px) translateX(var(--drift)) scale(1.15);
opacity: 0;
}
}
</style>
@@ -0,0 +1,183 @@
<script lang="ts">
/** 单条他人点赞通知的输入(PhotoLightbox 从 realtime bubbles 派发)。 */
export interface InteractionNoticeEntry {
photoId: string;
delta: number;
/** 被赞照片缩略图(thumb 变体;序列未含该照片时为 null → 占位块)。 */
thumbUrl: string | null;
}
interface Notice extends InteractionNoticeEntry {
key: number;
}
</script>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
// UI-02 §6.2 跨照片互动提示(Task 5 / V05):大图打开期间相册内「其他」照片
// 收到他人点赞的紧凑通知——缩略图 + ♥ +delta,点击定位到该照片(保留既有
// 可识别提示与点击定位能力)。至多 3 条、约 4s 自动消散(§6.3);页面隐藏/
// 组件卸载即清空。不把其他照片的计数加到当前照片(数据面在 useLikes 校准,
// 本层纯展示)。
// 读屏节流(§6.3「取消逐个气泡 aria-live 播报;交互通知使用节流汇总」):
// 列表本身不 aria-live,仅维护一个至多每 5s 更新一次的 role=status 汇总。
// 数据由 PhotoLightbox 从 realtime bubbles 派发(自身回声/页面隐藏/减少动画
// 已在 realtime router 层滤除——不重复订阅 WS)。
/** 通知驻留时长(毫秒,§6.3「约 4s」)。 */
const NOTICE_MS = 4000;
/** 同屏通知上限(§6.3:超出弃最旧)。 */
const NOTICE_CAP = 3;
/** 读屏汇总节流窗(毫秒)。 */
const SUMMARY_THROTTLE_MS = 5000;
const emit = defineEmits<{ select: [photoId: string] }>();
const { t } = useI18n();
const notices = ref<Notice[]>([]);
/** 读屏节流汇总文本(空 = 无待播内容)。 */
const srSummary = ref('');
let nextKey = 0;
const dismissTimers = new Set<ReturnType<typeof setTimeout>>();
let summaryThrottleTimer: ReturnType<typeof setTimeout> | undefined;
let summaryPendingDelta = 0;
/** 新增一条他人点赞通知:超限弃最旧,并安排自动消散。 */
function notify(entry: InteractionNoticeEntry): void {
const notice: Notice = { key: ++nextKey, ...entry };
notices.value = [...notices.value.slice(-(NOTICE_CAP - 1)), notice];
const timer = setTimeout(() => {
dismissTimers.delete(timer);
notices.value = notices.value.filter((item) => item.key !== notice.key);
}, NOTICE_MS);
dismissTimers.add(timer);
announceSummary(entry.delta);
}
function flushSummary(): void {
if (summaryPendingDelta <= 0) return;
srSummary.value = t('viewer.photoLiked', { delta: summaryPendingDelta });
summaryPendingDelta = 0;
}
/** 节流汇总:首条立即播报,节流窗内的后续 delta 合并到下一次。 */
function announceSummary(delta: number): void {
summaryPendingDelta += delta;
if (summaryThrottleTimer !== undefined) return;
flushSummary();
summaryThrottleTimer = setTimeout(function fireSummary() {
summaryThrottleTimer = undefined;
if (summaryPendingDelta > 0) {
flushSummary();
summaryThrottleTimer = setTimeout(fireSummary, SUMMARY_THROTTLE_MS);
}
}, SUMMARY_THROTTLE_MS);
}
function clearAll(): void {
for (const timer of dismissTimers) clearTimeout(timer);
dismissTimers.clear();
if (summaryThrottleTimer !== undefined) clearTimeout(summaryThrottleTimer);
summaryThrottleTimer = undefined;
summaryPendingDelta = 0;
srSummary.value = '';
notices.value = [];
}
function onVisibilityChange(): void {
// 页面隐藏:清空在屏通知与计时器(§6.3,不排队回放)。
if (document.hidden) clearAll();
}
onMounted(() => document.addEventListener('visibilitychange', onVisibilityChange));
onBeforeUnmount(() => {
document.removeEventListener('visibilitychange', onVisibilityChange);
clearAll();
});
defineExpose({ notify });
</script>
<template>
<div class="interaction-notice-zone">
<!-- 读屏节流汇总(§6.3):不逐条播报通知,至多每 5s 一次 +delta 汇总。 -->
<p class="sr-summary" role="status">{{ srSummary }}</p>
<button
v-for="notice in notices"
:key="notice.key"
type="button"
class="notice"
:aria-label="t('viewer.bubbleOpen')"
@click="emit('select', notice.photoId)"
>
<img v-if="notice.thumbUrl !== null" class="notice-thumb" :src="notice.thumbUrl" alt="" loading="lazy">
<span v-else class="notice-thumb notice-placeholder" aria-hidden="true" />
<span class="notice-heart" aria-hidden="true">♥</span>
<span class="notice-delta" aria-hidden="true">{{ t('viewer.photoLiked', { delta: notice.delta }) }}</span>
</button>
</div>
</template>
<style scoped>
/* 大图内定位(§6.2「底部操作栏上方的安全区域」):舞台底部、不遮挡主要按钮;
* 容器不拦截指针,通知按钮自身可点(定位到被赞照片)。 */
.interaction-notice-zone {
position: absolute;
bottom: 12px;
left: 16px;
right: 16px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
pointer-events: none;
}
.sr-summary {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
border: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.notice {
pointer-events: auto;
display: flex;
align-items: center;
gap: 8px;
border: 1px solid rgba(255, 255, 255, 0.35);
border-radius: var(--viewer-radius-pill, 20px);
background: rgba(22, 28, 36, 0.92);
color: #f3f4f6;
padding: 5px 12px 5px 5px;
font-size: 0.8125rem;
cursor: pointer;
}
.notice:focus-visible {
outline: 2px solid var(--viewer-primary, #1a73e8);
}
.notice-thumb {
display: block;
width: 32px;
height: 32px;
border-radius: 14px;
object-fit: cover;
}
.notice-placeholder {
background: rgba(255, 255, 255, 0.14);
}
.notice-heart {
color: var(--viewer-like-accent, #ff667d);
}
.notice-delta {
font-variant-numeric: tabular-nums;
}
</style>
@@ -17,10 +17,8 @@ const props = withDefaults(
likePop?: number; likePop?: number;
canDownload?: boolean; canDownload?: boolean;
canDownloadOriginal?: boolean; canDownloadOriginal?: boolean;
/** 点赞提交中(预留:连续点赞不禁用,仅视觉态)。 */
isLiking?: boolean;
}>(), }>(),
{ likePop: 0, canDownload: false, canDownloadOriginal: false, isLiking: false }, { likePop: 0, canDownload: false, canDownloadOriginal: false },
); );
const emit = defineEmits<{ const emit = defineEmits<{
@@ -99,7 +97,7 @@ onBeforeUnmount(cancelPress);
<button <button
type="button" type="button"
class="action-btn" class="action-btn"
:aria-label="t('viewer.share')" :aria-label="t('viewer.sharePhoto')"
@click="emit('share')" @click="emit('share')"
> >
<svg class="action-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"> <svg class="action-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
+6
View File
@@ -68,6 +68,9 @@ export function useDownload(inputs: DownloadInputs) {
/** 失败文案键(''=无失败):默认通用失败;DOWNLOAD_DISABLED 给专属文案 /** 失败文案键(''=无失败):默认通用失败;DOWNLOAD_DISABLED 给专属文案
* (T15 漏译修复——相册中途关闭下载开关时观众可理解的确定性提示)。 */ * (T15 漏译修复——相册中途关闭下载开关时观众可理解的确定性提示)。 */
const failed = ref<'' | 'download.failed' | 'errors.downloadDisabled'>(''); const failed = ref<'' | 'download.failed' | 'errors.downloadDisabled'>('');
/** 失败归属照片(UI-02 §5.4):异步错误绑定触发下载的 photoId——大图内切图
* 后错误提示只在切回该照片时展示,不张冠李戴到当前照片。 */
const failedFor = ref<string | null>(null);
/** 微信引导浮层可见(点击下载时处于微信内置浏览器)。 */ /** 微信引导浮层可见(点击下载时处于微信内置浏览器)。 */
const wechatGuide = ref(false); const wechatGuide = ref(false);
const copyNotice = ref<'' | 'copied' | 'failed'>(''); const copyNotice = ref<'' | 'copied' | 'failed'>('');
@@ -95,6 +98,7 @@ export function useDownload(inputs: DownloadInputs) {
if (photoId === null || busy.value) return; if (photoId === null || busy.value) return;
busy.value = true; busy.value = true;
failed.value = ''; failed.value = '';
failedFor.value = null;
try { try {
let credential = await getPhotoDownload(inputs.token.value, photoId, variant); let credential = await getPhotoDownload(inputs.token.value, photoId, variant);
if (isExpired(credential.expiresAt)) { if (isExpired(credential.expiresAt)) {
@@ -108,6 +112,7 @@ export function useDownload(inputs: DownloadInputs) {
error instanceof ApiError && error.code === ErrorCodes.DOWNLOAD_DISABLED error instanceof ApiError && error.code === ErrorCodes.DOWNLOAD_DISABLED
? 'errors.downloadDisabled' ? 'errors.downloadDisabled'
: 'download.failed'; : 'download.failed';
failedFor.value = photoId; // 异步错误归属触发时的照片(§5.4)
} finally { } finally {
busy.value = false; busy.value = false;
} }
@@ -140,6 +145,7 @@ export function useDownload(inputs: DownloadInputs) {
return reactive({ return reactive({
busy, busy,
failed, failed,
failedFor,
wechatGuide, wechatGuide,
copyNotice, copyNotice,
canDownload, canDownload,
+4
View File
@@ -90,6 +90,10 @@ export default {
more: 'More options', more: 'More options',
thumbnails: 'Thumbnail navigation', thumbnails: 'Thumbnail navigation',
thumbItem: 'Photo {n}', thumbItem: 'Photo {n}',
// —— UI-02 (V05 interaction particles / share) additions ——
photoLiked: '+{delta}',
sharePhoto: 'Share photo',
shareAlbum: 'Share album',
}, },
route: { tokenLabel: 'Album token', photoLabel: 'Photo' }, route: { tokenLabel: 'Album token', photoLabel: 'Photo' },
errors: { errors: {
+4
View File
@@ -90,6 +90,10 @@ export default {
more: '更多选项', more: '更多选项',
thumbnails: '缩略图导航', thumbnails: '缩略图导航',
thumbItem: '第 {n} 张', thumbItem: '第 {n} 张',
// —— UI-02(V05 互动粒子/分享)新增 ——
photoLiked: '+{delta}',
sharePhoto: '分享照片',
shareAlbum: '分享相册',
}, },
route: { tokenLabel: '相册令牌', photoLabel: '照片' }, route: { tokenLabel: '相册令牌', photoLabel: '照片' },
errors: { errors: {
+18
View File
@@ -39,6 +39,10 @@ const MY_BATCHES_CAP = 100;
/** sessionStorage 键:会话标识(每标签页会话一枚,跨刷新复用)。 */ /** sessionStorage 键:会话标识(每标签页会话一枚,跨刷新复用)。 */
const SESSION_KEY = 'viewer.likeSessionId'; const SESSION_KEY = 'viewer.likeSessionId';
/** 本地点击动效监听器(UI-02 V05):每次有效点击立即通知——与批次发送/重试
* 语义完全解耦(不为动画补帧额外发送点赞,§6.1)。 */
export type LocalLikeListener = (photoId: string) => void;
/** 单照片状态(base=权威累计,pending=未落定乐观量,pop=动画 nonce)。 */ /** 单照片状态(base=权威累计,pending=未落定乐观量,pop=动画 nonce)。 */
interface PhotoLikeState { interface PhotoLikeState {
base: number; base: number;
@@ -88,6 +92,7 @@ export function useLikes(inputs: LikesInputs) {
let flushTimer: ReturnType<typeof setTimeout> | null = null; let flushTimer: ReturnType<typeof setTimeout> | null = null;
const retryTimers = new Set<ReturnType<typeof setTimeout>>(); const retryTimers = new Set<ReturnType<typeof setTimeout>>();
const localLikeListeners = new Set<LocalLikeListener>();
function stateOf(photoId: string, seedCount?: number): PhotoLikeState { function stateOf(photoId: string, seedCount?: number): PhotoLikeState {
let state = states.get(photoId); let state = states.get(photoId);
@@ -110,6 +115,8 @@ export function useLikes(inputs: LikesInputs) {
state.pending += 1; state.pending += 1;
state.pop += 1; state.pop += 1;
queued.set(photoId, (queued.get(photoId) ?? 0) + 1); queued.set(photoId, (queued.get(photoId) ?? 0) + 1);
// 本地点击动效事件(UI-02 V05):仅通知订阅方(粒子等装饰层),不影响批次。
for (const listener of localLikeListeners) listener(photoId);
if ((queued.get(photoId) ?? 0) >= LIKE_FLUSH_CAP) { if ((queued.get(photoId) ?? 0) >= LIKE_FLUSH_CAP) {
flushQueue(); flushQueue();
return; return;
@@ -119,6 +126,14 @@ export function useLikes(inputs: LikesInputs) {
} }
} }
/** 订阅本地点击动效事件(UI-02 V05):返回退订函数(订阅方随上下文卸载)。 */
function onLocalLike(listener: LocalLikeListener): () => void {
localLikeListeners.add(listener);
return () => {
localLikeListeners.delete(listener);
};
}
/** 冲队:每照片一批(新 batchId);未满防抖窗的部分留给下一窗。 */ /** 冲队:每照片一批(新 batchId);未满防抖窗的部分留给下一窗。 */
function flushQueue(): void { function flushQueue(): void {
if (flushTimer !== null) { if (flushTimer !== null) {
@@ -213,6 +228,7 @@ export function useLikes(inputs: LikesInputs) {
if (flushTimer !== null) clearTimeout(flushTimer); if (flushTimer !== null) clearTimeout(flushTimer);
for (const timer of retryTimers) clearTimeout(timer); for (const timer of retryTimers) clearTimeout(timer);
retryTimers.clear(); retryTimers.clear();
localLikeListeners.clear();
}); });
return reactive({ return reactive({
@@ -221,6 +237,8 @@ export function useLikes(inputs: LikesInputs) {
/** 点击动画 nonce(每次点击 +1,模板 :key 重挂触发动画)。 */ /** 点击动画 nonce(每次点击 +1,模板 :key 重挂触发动画)。 */
popNonce: (photoId: string) => states.get(photoId)?.pop ?? 0, popNonce: (photoId: string) => states.get(photoId)?.pop ?? 0,
like, like,
/** 订阅本地点击动效事件(UI-02 V05 粒子等)。 */
onLocalLike,
calibrate, calibrate,
ownDeltaIn, ownDeltaIn,
recalibrateFromList, recalibrateFromList,
+19 -3
View File
@@ -184,6 +184,17 @@ const currentPhoto = computed(() => {
return sequence.photos.find((photo) => photo.photoId === lightboxPhotoId.value) ?? null; return sequence.photos.find((photo) => photo.photoId === lightboxPhotoId.value) ?? null;
}); });
/** 当前照片分享深链(V05 §5.4):/a/:token/p/:photoId + lang 参数(当前 UI
* 语言),始终分享观众端路由——不分享 OSS 签名 URL。 */
const photoShareUrl = computed(() => {
if (lightboxPhotoId.value === null) return undefined;
const href = router.resolve({
name: 'activity-photo',
params: { token: token.value, photoId: lightboxPhotoId.value },
}).href;
return `${href}?lang=${locale.value}`;
});
/** 下载控制器(P4a Task 4):开关来自相册载荷,当前照片随大图键控。 */ /** 下载控制器(P4a Task 4):开关来自相册载荷,当前照片随大图键控。 */
const download = useDownload({ const download = useDownload({
token, token,
@@ -344,19 +355,24 @@ onBeforeUnmount(() => {
<PhotoFeed :sequence="sequence" :likes="likes" @open="openFromFeed" /> <PhotoFeed :sequence="sequence" :likes="likes" @open="openFromFeed" />
</template> </template>
<!-- 大图查看器(Task 8):当前照片以 photoId 键控(v-model)。 --> <!-- 大图查看器(Task 8):当前照片以 photoId 键控(v-model)。V05 新增:
相册标题/照片深链(navigator.share)与实时气泡(粒子/互动提示分层)。 -->
<PhotoLightbox <PhotoLightbox
v-if="lightboxPhotoId !== null && currentPhoto !== null" v-if="lightboxPhotoId !== null && currentPhoto !== null"
v-model:photo-id="lightboxPhotoId" v-model:photo-id="lightboxPhotoId"
:sequence="sequence" :sequence="sequence"
:likes="likes" :likes="likes"
:download="download" :download="download"
:album-title="album !== null ? album.translation.title : undefined"
:share-url="photoShareUrl"
:like-bubbles="realtime.bubbles"
@removed="onPhotoRemoved" @removed="onPhotoRemoved"
@close="closeLightbox" @close="closeLightbox"
/> />
<!-- 他人点赞气泡(Task 10):点击定位打开该照片大图。 --> <!-- 他人点赞气泡(Task 10):点击定位打开该照片大图。大图打开期间改由
<LikeBubbles :bubbles="realtime.bubbles" @open="openFromFeed" /> 查看器内互动提示承载(§6.2:避免页面级气泡被模态框盖住或遮住下载)。 -->
<LikeBubbles :bubbles="lightboxPhotoId === null ? realtime.bubbles : []" @open="openFromFeed" />
<!-- 照片下线轻提示(大图 404 刷新 / WS hidden/deleted 推送移除时)。 --> <!-- 照片下线轻提示(大图 404 刷新 / WS hidden/deleted 推送移除时)。 -->
<p v-if="photoRemovedNotice" class="removed-notice" role="status">{{ t('viewer.photoRemoved') }}</p> <p v-if="photoRemovedNotice" class="removed-notice" role="status">{{ t('viewer.photoRemoved') }}</p>
+34
View File
@@ -0,0 +1,34 @@
// apps/viewer/src/support/share.ts —— UI-02 §5.4 分享编排(Task 5 / V05):
// navigator.share 优先(成功后由原生分享面板收尾,应用内不再重复反馈;用户
// 取消 AbortError 静默——不显示错误);API 不可用或其他失败回退剪贴板
// (copyText 已含 execCommand 兜底),复制失败由调用方展示可选中文本。
// 始终分享观众端路由(相册 /a/:token、照片 /a/:token/p/:photoId?lang=…),
// 不分享 OSS 签名 URL(§5.4)。
import { copyText } from './clipboard';
export type ShareOutcome =
| 'shared' // navigator.share 成功(原生 UI 已反馈)
| 'cancelled' // 用户取消原生分享(AbortError:静默,无错误)
| 'copied' // 回退剪贴板成功
| 'failed'; // 回退剪贴板失败(调用方展示可选中文本)
/** navigator.share 是否可用(安全上下文 + 浏览器支持)。 */
export function canNativeShare(): boolean {
return typeof navigator.share === 'function';
}
/** 原生分享优先,剪贴板兜底(§5.4)。 */
export async function shareOrCopy(input: { title: string; url: string }): Promise<ShareOutcome> {
if (canNativeShare()) {
try {
await navigator.share({ title: input.title, url: input.url });
return 'shared';
} catch (error) {
if ((error as { name?: unknown } | null)?.name === 'AbortError') {
return 'cancelled';
}
// 其他失败(数据/权限/非安全上下文等):继续剪贴板回退。
}
}
return (await copyText(input.url)) ? 'copied' : 'failed';
}