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:
@@ -57,6 +57,7 @@ describe('album header info area (UI-02 V02)', () => {
|
||||
clientSpy?.mockRestore();
|
||||
clientSpy = null;
|
||||
Reflect.deleteProperty(navigator, 'clipboard');
|
||||
Reflect.deleteProperty(navigator, 'share');
|
||||
});
|
||||
|
||||
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]]);
|
||||
});
|
||||
|
||||
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 wrapper = mountHeader({ album: makeAlbum(), viewCount: null });
|
||||
|
||||
await wrapper.find('.share-btn').trigger('click');
|
||||
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-url').exists()).toBe(false);
|
||||
|
||||
// 复制失败:轻提示 + 可选中文本(§5.4 回退语义,完整 navigator.share 属 Task 5)。
|
||||
// 复制失败:轻提示 + 可选中文本(§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');
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,13 +14,11 @@ function mountBar(props: {
|
||||
likeCount: number;
|
||||
canDownload?: boolean;
|
||||
canDownloadOriginal?: boolean;
|
||||
isLiking?: boolean;
|
||||
}): VueWrapper {
|
||||
const wrapper = mount(PhotoActionBar, {
|
||||
props: {
|
||||
canDownload: false,
|
||||
canDownloadOriginal: false,
|
||||
isLiking: false,
|
||||
...props,
|
||||
},
|
||||
global: { plugins: [i18n] },
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -5,9 +5,12 @@ import type { DownloadVariant } from '../api/client';
|
||||
import type { PhotoSequence } from '../feed/sequence';
|
||||
import type { DownloadController } from '../download/useDownload';
|
||||
import type { LikesController } from '../likes/useLikes';
|
||||
import type { LikeBubble } from '../realtime/useAlbumRealtime';
|
||||
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 { copyText } from '../support/clipboard';
|
||||
import { shareOrCopy } from '../support/share';
|
||||
import { reducedMotion, toggleReducedMotion } from '../ui/prefs';
|
||||
import { positionDisplay } from '../viewer/position';
|
||||
import { MIN_SCALE, panBy, resetZoom, wheelFactor, zoomAt, type ZoomState } from '../viewer/zoom';
|
||||
@@ -44,6 +47,13 @@ const props = defineProps<{
|
||||
likes?: LikesController;
|
||||
/** 下载控制器(P4a Task 4):缺省时不渲染下载位(组件单测/降级组装)。 */
|
||||
download?: DownloadController;
|
||||
/** 相册标题(§5.4 navigator.share 的分享标题)。 */
|
||||
albumTitle?: string;
|
||||
/** 当前照片分享深链(含 lang 参数,ActivityPage 组装;缺省回退当前地址)。 */
|
||||
shareUrl?: string;
|
||||
/** 他人点赞气泡(WS likes.burst,realtime router 已滤自身回声/隐藏/减少动画):
|
||||
* 当前照片 → 心形粒子,其他照片 → 跨照片互动提示(§6.2 分层)。 */
|
||||
likeBubbles?: LikeBubble[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -361,6 +371,53 @@ function likeCurrent(): void {
|
||||
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,控制器编排在此 ——
|
||||
|
||||
const canDownload = computed(() => props.download !== undefined && props.download.canDownload);
|
||||
@@ -370,20 +427,22 @@ function onDownloadVariant(variant: DownloadVariant): void {
|
||||
void props.download?.download(variant);
|
||||
}
|
||||
|
||||
// —— 分享(V04 最小实现:复制当前照片深链——路由已同步 /p/:photoId;
|
||||
// navigator.share 编排与语言参数属 Task 5/V05) ——
|
||||
// —— 分享(V05 §5.4):navigator.share 优先(成功/取消无额外反馈——原生面板
|
||||
// 已收尾;AbortError 静默),不可用或其他失败回退剪贴板;复制失败保留
|
||||
// 可选中文本。分享目标为照片深链(含 lang 参数,ActivityPage 组装),
|
||||
// 不分享 OSS 签名 URL ——
|
||||
|
||||
const shareState = ref<'idle' | 'copied' | 'failed'>('idle');
|
||||
const shareUrlText = ref('');
|
||||
let shareResetTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
async function shareCurrent(): Promise<void> {
|
||||
shareUrlText.value = window.location.href;
|
||||
const copied = await copyText(shareUrlText.value);
|
||||
shareState.value = copied ? 'copied' : 'failed';
|
||||
shareUrlText.value = props.shareUrl ?? window.location.href;
|
||||
const outcome = await shareOrCopy({ title: props.albumTitle ?? '', url: shareUrlText.value });
|
||||
shareState.value = outcome === 'copied' ? 'copied' : outcome === 'failed' ? 'failed' : 'idle';
|
||||
if (shareResetTimer !== undefined) clearTimeout(shareResetTimer);
|
||||
// 失败时保留可选中文本(不自动消失),成功反馈数秒后收起。
|
||||
if (copied) {
|
||||
if (outcome === 'copied') {
|
||||
shareResetTimer = setTimeout(() => {
|
||||
shareState.value = 'idle';
|
||||
}, 3000);
|
||||
@@ -461,6 +520,7 @@ onBeforeUnmount(() => {
|
||||
document.removeEventListener('pointerdown', onOuterPointerDown, true);
|
||||
document.body.style.overflow = savedBodyOverflow;
|
||||
if (shareResetTimer !== undefined) clearTimeout(shareResetTimer);
|
||||
stopLocalLikeListener?.(); // 粒子订阅随大图卸载解除(useLikes 侧不残留)
|
||||
// 焦点归还触发元素(瀑布流单元格;深链直达时为 body——无可归还则不动)。
|
||||
if (savedFocus !== null && savedFocus.isConnected) savedFocus.focus();
|
||||
});
|
||||
@@ -565,20 +625,27 @@ onBeforeUnmount(() => {
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- 心形粒子层(Task 5 / V05 填充):装饰性、不可交互 -->
|
||||
<div class="heart-particles-zone" aria-hidden="true" style="pointer-events: none" />
|
||||
<!-- 跨照片互动提示层(Task 5 / V05 填充):底部操作栏上方安全区 -->
|
||||
<div class="interaction-notice-zone" />
|
||||
<!-- 心形粒子层(Task 5 / V05 §6.2):装饰性、不可交互;触发见上方互动编排 -->
|
||||
<HeartParticles ref="heartParticles" />
|
||||
<!-- 跨照片互动提示层(Task 5 / V05 §6.2):底部操作栏上方安全区;
|
||||
点击定位 = 缩略图条共用入口(序列未含该照片时忽略——不伪造定位) -->
|
||||
<InteractionNotice ref="interactionNotice" @select="selectThumbnail" />
|
||||
|
||||
<div class="stage-notices">
|
||||
<p v-if="extending" class="loading">{{ t('album.loading') }}</p>
|
||||
<!-- 分享反馈轻提示(复制成功/失败;失败保留可选中文本) -->
|
||||
<!-- 分享反馈轻提示(回退复制成功/失败;失败保留可选中文本;原生分享
|
||||
成功/取消不显示——面板已收尾,§5.4) -->
|
||||
<p v-if="shareState !== 'idle'" class="share-status" role="status">
|
||||
{{ shareState === 'copied' ? t('viewer.shareCopied') : t('viewer.shareCopyFailed') }}
|
||||
<span v-if="shareState === 'failed'" class="share-url">{{ shareUrlText }}</span>
|
||||
</p>
|
||||
<!-- 下载失败轻提示(端点失败/网络;DOWNLOAD_DISABLED 专属文案)。 -->
|
||||
<p v-if="download !== undefined && download.failed" class="download-failed" role="alert">
|
||||
<!-- 下载失败轻提示(端点失败/网络;DOWNLOAD_DISABLED 专属文案):
|
||||
仅归属到触发下载的那张照片(§5.4 异步错误归属)。 -->
|
||||
<p
|
||||
v-if="download !== undefined && download.failed && download.failedFor === photo.photoId"
|
||||
class="download-failed"
|
||||
role="alert"
|
||||
>
|
||||
{{ t(download.failed) }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -821,23 +888,8 @@ onBeforeUnmount(() => {
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
/* 粒子/互动占位层(Task 5 填充):舞台右侧上浮 / 操作栏上方安全区。 */
|
||||
.heart-particles-zone {
|
||||
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;
|
||||
}
|
||||
/* 粒子层/互动提示层的定位与样式由 HeartParticles / InteractionNotice 自带
|
||||
* (Task 5:舞台右侧上浮 / 操作栏上方安全区)。 */
|
||||
.stage-notices {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import type { PublicAlbum, SortMode } from '../../api/client';
|
||||
import { copyText } from '../../support/clipboard';
|
||||
import { shareOrCopy } from '../../support/share';
|
||||
|
||||
// UI-02 V02 活动信息区(从 ActivityPage 提取):宣传图 + 标题/分享 + 说明 +
|
||||
// 元信息行(浏览/日期/地点)+ 排序/分类工具栏。数据与筛选决策仍在父级
|
||||
@@ -122,21 +122,23 @@ const venueText = computed(() => {
|
||||
|
||||
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');
|
||||
let shareResetTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const albumUrl = computed(() => {
|
||||
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> {
|
||||
const copied = await copyText(albumUrl.value);
|
||||
shareState.value = copied ? 'copied' : 'failed';
|
||||
const outcome = await shareOrCopy({ title: props.album.translation.title, url: albumUrl.value });
|
||||
shareState.value = outcome === 'copied' ? 'copied' : outcome === 'failed' ? 'failed' : 'idle';
|
||||
if (shareResetTimer !== undefined) clearTimeout(shareResetTimer);
|
||||
// 失败时保留可选中文本(不自动消失),成功反馈数秒后收起。
|
||||
if (copied) {
|
||||
if (outcome === 'copied') {
|
||||
shareResetTimer = setTimeout(() => {
|
||||
shareState.value = 'idle';
|
||||
}, 3000);
|
||||
@@ -182,7 +184,7 @@ onBeforeUnmount(() => {
|
||||
<header class="album-info">
|
||||
<div class="title-row">
|
||||
<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">
|
||||
<path d="M12 3v12" />
|
||||
<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;
|
||||
canDownload?: boolean;
|
||||
canDownloadOriginal?: boolean;
|
||||
/** 点赞提交中(预留:连续点赞不禁用,仅视觉态)。 */
|
||||
isLiking?: boolean;
|
||||
}>(),
|
||||
{ likePop: 0, canDownload: false, canDownloadOriginal: false, isLiking: false },
|
||||
{ likePop: 0, canDownload: false, canDownloadOriginal: false },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -99,7 +97,7 @@ onBeforeUnmount(cancelPress);
|
||||
<button
|
||||
type="button"
|
||||
class="action-btn"
|
||||
:aria-label="t('viewer.share')"
|
||||
:aria-label="t('viewer.sharePhoto')"
|
||||
@click="emit('share')"
|
||||
>
|
||||
<svg class="action-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
|
||||
@@ -68,6 +68,9 @@ export function useDownload(inputs: DownloadInputs) {
|
||||
/** 失败文案键(''=无失败):默认通用失败;DOWNLOAD_DISABLED 给专属文案
|
||||
* (T15 漏译修复——相册中途关闭下载开关时观众可理解的确定性提示)。 */
|
||||
const failed = ref<'' | 'download.failed' | 'errors.downloadDisabled'>('');
|
||||
/** 失败归属照片(UI-02 §5.4):异步错误绑定触发下载的 photoId——大图内切图
|
||||
* 后错误提示只在切回该照片时展示,不张冠李戴到当前照片。 */
|
||||
const failedFor = ref<string | null>(null);
|
||||
/** 微信引导浮层可见(点击下载时处于微信内置浏览器)。 */
|
||||
const wechatGuide = ref(false);
|
||||
const copyNotice = ref<'' | 'copied' | 'failed'>('');
|
||||
@@ -95,6 +98,7 @@ export function useDownload(inputs: DownloadInputs) {
|
||||
if (photoId === null || busy.value) return;
|
||||
busy.value = true;
|
||||
failed.value = '';
|
||||
failedFor.value = null;
|
||||
try {
|
||||
let credential = await getPhotoDownload(inputs.token.value, photoId, variant);
|
||||
if (isExpired(credential.expiresAt)) {
|
||||
@@ -108,6 +112,7 @@ export function useDownload(inputs: DownloadInputs) {
|
||||
error instanceof ApiError && error.code === ErrorCodes.DOWNLOAD_DISABLED
|
||||
? 'errors.downloadDisabled'
|
||||
: 'download.failed';
|
||||
failedFor.value = photoId; // 异步错误归属触发时的照片(§5.4)
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
@@ -140,6 +145,7 @@ export function useDownload(inputs: DownloadInputs) {
|
||||
return reactive({
|
||||
busy,
|
||||
failed,
|
||||
failedFor,
|
||||
wechatGuide,
|
||||
copyNotice,
|
||||
canDownload,
|
||||
|
||||
@@ -90,6 +90,10 @@ export default {
|
||||
more: 'More options',
|
||||
thumbnails: 'Thumbnail navigation',
|
||||
thumbItem: 'Photo {n}',
|
||||
// —— UI-02 (V05 interaction particles / share) additions ——
|
||||
photoLiked: '+{delta}',
|
||||
sharePhoto: 'Share photo',
|
||||
shareAlbum: 'Share album',
|
||||
},
|
||||
route: { tokenLabel: 'Album token', photoLabel: 'Photo' },
|
||||
errors: {
|
||||
|
||||
@@ -90,6 +90,10 @@ export default {
|
||||
more: '更多选项',
|
||||
thumbnails: '缩略图导航',
|
||||
thumbItem: '第 {n} 张',
|
||||
// —— UI-02(V05 互动粒子/分享)新增 ——
|
||||
photoLiked: '+{delta}',
|
||||
sharePhoto: '分享照片',
|
||||
shareAlbum: '分享相册',
|
||||
},
|
||||
route: { tokenLabel: '相册令牌', photoLabel: '照片' },
|
||||
errors: {
|
||||
|
||||
@@ -39,6 +39,10 @@ const MY_BATCHES_CAP = 100;
|
||||
/** sessionStorage 键:会话标识(每标签页会话一枚,跨刷新复用)。 */
|
||||
const SESSION_KEY = 'viewer.likeSessionId';
|
||||
|
||||
/** 本地点击动效监听器(UI-02 V05):每次有效点击立即通知——与批次发送/重试
|
||||
* 语义完全解耦(不为动画补帧额外发送点赞,§6.1)。 */
|
||||
export type LocalLikeListener = (photoId: string) => void;
|
||||
|
||||
/** 单照片状态(base=权威累计,pending=未落定乐观量,pop=动画 nonce)。 */
|
||||
interface PhotoLikeState {
|
||||
base: number;
|
||||
@@ -88,6 +92,7 @@ export function useLikes(inputs: LikesInputs) {
|
||||
|
||||
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const retryTimers = new Set<ReturnType<typeof setTimeout>>();
|
||||
const localLikeListeners = new Set<LocalLikeListener>();
|
||||
|
||||
function stateOf(photoId: string, seedCount?: number): PhotoLikeState {
|
||||
let state = states.get(photoId);
|
||||
@@ -110,6 +115,8 @@ export function useLikes(inputs: LikesInputs) {
|
||||
state.pending += 1;
|
||||
state.pop += 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) {
|
||||
flushQueue();
|
||||
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);未满防抖窗的部分留给下一窗。 */
|
||||
function flushQueue(): void {
|
||||
if (flushTimer !== null) {
|
||||
@@ -213,6 +228,7 @@ export function useLikes(inputs: LikesInputs) {
|
||||
if (flushTimer !== null) clearTimeout(flushTimer);
|
||||
for (const timer of retryTimers) clearTimeout(timer);
|
||||
retryTimers.clear();
|
||||
localLikeListeners.clear();
|
||||
});
|
||||
|
||||
return reactive({
|
||||
@@ -221,6 +237,8 @@ export function useLikes(inputs: LikesInputs) {
|
||||
/** 点击动画 nonce(每次点击 +1,模板 :key 重挂触发动画)。 */
|
||||
popNonce: (photoId: string) => states.get(photoId)?.pop ?? 0,
|
||||
like,
|
||||
/** 订阅本地点击动效事件(UI-02 V05 粒子等)。 */
|
||||
onLocalLike,
|
||||
calibrate,
|
||||
ownDeltaIn,
|
||||
recalibrateFromList,
|
||||
|
||||
@@ -184,6 +184,17 @@ const currentPhoto = computed(() => {
|
||||
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):开关来自相册载荷,当前照片随大图键控。 */
|
||||
const download = useDownload({
|
||||
token,
|
||||
@@ -344,19 +355,24 @@ onBeforeUnmount(() => {
|
||||
<PhotoFeed :sequence="sequence" :likes="likes" @open="openFromFeed" />
|
||||
</template>
|
||||
|
||||
<!-- 大图查看器(Task 8):当前照片以 photoId 键控(v-model)。 -->
|
||||
<!-- 大图查看器(Task 8):当前照片以 photoId 键控(v-model)。V05 新增:
|
||||
相册标题/照片深链(navigator.share)与实时气泡(粒子/互动提示分层)。 -->
|
||||
<PhotoLightbox
|
||||
v-if="lightboxPhotoId !== null && currentPhoto !== null"
|
||||
v-model:photo-id="lightboxPhotoId"
|
||||
:sequence="sequence"
|
||||
:likes="likes"
|
||||
:download="download"
|
||||
:album-title="album !== null ? album.translation.title : undefined"
|
||||
:share-url="photoShareUrl"
|
||||
:like-bubbles="realtime.bubbles"
|
||||
@removed="onPhotoRemoved"
|
||||
@close="closeLightbox"
|
||||
/>
|
||||
|
||||
<!-- 他人点赞气泡(Task 10):点击定位打开该照片大图。 -->
|
||||
<LikeBubbles :bubbles="realtime.bubbles" @open="openFromFeed" />
|
||||
<!-- 他人点赞气泡(Task 10):点击定位打开该照片大图。大图打开期间改由
|
||||
查看器内互动提示承载(§6.2:避免页面级气泡被模态框盖住或遮住下载)。 -->
|
||||
<LikeBubbles :bubbles="lightboxPhotoId === null ? realtime.bubbles : []" @open="openFromFeed" />
|
||||
|
||||
<!-- 照片下线轻提示(大图 404 刷新 / WS hidden/deleted 推送移除时)。 -->
|
||||
<p v-if="photoRemovedNotice" class="removed-notice" role="status">{{ t('viewer.photoRemoved') }}</p>
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
Reference in New Issue
Block a user