feat(viewer): UI-02 dark immersive photo viewer with thumbnail strip
V04 (方案 §5):PhotoLightbox 模板重构为深色沉浸布局——顶栏(关闭/位置 计数/⋯ 减少动画菜单)+ 舞台(contain 大图 + 边缘 ‹› + 粒子/互动占位层)+ 缩略图条 + 底部操作栏;业务逻辑(序列导航/缩放手势/签名刷新/下载/点赞) 原样保留。 - 新增 viewer/position.ts:§5.2 位置计数真实回退(solo→照片预览;无 total 未到末页→已加载 N 张;全部加载→i/n;不伪造总数) - 新增 ThumbnailStrip:当前 ±3 窗口(≤7 项)、主色 2px 描边、thumb 变体、 滚入可见(遵守减少动画)、点击选择不新增历史 - 新增 PhotoActionBar:Like(coral 心形+计数+连续点赞提示)/Share/ Download 均分大触控;下载关闭整项移除;原图二级菜单(▾ + 长按) - 模态可用性:body 滚动锁、焦点进入/困住/归还触发元素、Esc 先收菜单、 菜单内左右键不抢占、dvh + 安全区、chrome 区手势/滚轮隔离 - 分享 V04 最小实现(复制当前深链,support/clipboard 从 AlbumHeader 提取共用);navigator.share 编排属 Task 5 - zh-CN/en 新词条;vitest 新增 23 例(位置回退/缩略图/操作栏/焦点/滚锁) Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
import { mount, type VueWrapper } from '@vue/test-utils';
|
||||
import { nextTick } from 'vue';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import PhotoActionBar from '../components/viewer/PhotoActionBar.vue';
|
||||
import { createViewerI18n, type ViewerI18n } from '../i18n';
|
||||
|
||||
// UI-02 §5.1/§5.4 底部操作栏单测:Like/Share/Download 三键与标签、计数
|
||||
// Intl 格式化、下载关闭时移除整项(剩余均分)、原图二级菜单(▾ 与长按)、
|
||||
// 事件上抛(like/share/download(variant))。
|
||||
let i18n: ViewerI18n;
|
||||
const mounted: VueWrapper[] = [];
|
||||
|
||||
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] },
|
||||
});
|
||||
mounted.push(wrapper);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
describe('photo action bar (UI-02 V04)', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
i18n = createViewerI18n('zh-CN');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (mounted.length > 0) mounted.pop()?.unmount();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders like/share/download as evenly distributed slots with formatted count and hint', () => {
|
||||
const wrapper = mountBar({ likeCount: 1286, canDownload: true });
|
||||
|
||||
// 三个 action-slot flex 均分(下载隐藏时由剩余 slot 自然重排,见下例)。
|
||||
expect(wrapper.findAll('.action-slot')).toHaveLength(3);
|
||||
const labels = wrapper.findAll('.action-label').map((label) => label.text());
|
||||
expect(labels).toEqual(['点赞', '分享', '下载']);
|
||||
// 计数 Intl 分组 + tabular-nums 容器;Like 下方次级提示(§5.1)。
|
||||
expect(wrapper.find('.like-count').text()).toBe('1,286');
|
||||
expect(wrapper.find('.like-hint').text()).toBe('连续点击,为照片点赞');
|
||||
});
|
||||
|
||||
it('removes the download action entirely when downloads are disabled', () => {
|
||||
const wrapper = mountBar({ likeCount: 0, canDownload: false });
|
||||
|
||||
expect(wrapper.find('.download-group').exists()).toBe(false);
|
||||
expect(wrapper.findAll('.action-slot')).toHaveLength(2); // 剩余两键均分栏宽
|
||||
expect(wrapper.findAll('.action-label').map((label) => label.text())).toEqual(['点赞', '分享']);
|
||||
});
|
||||
|
||||
it('emits like and share from the large tap targets', async () => {
|
||||
const wrapper = mountBar({ likeCount: 3 });
|
||||
const buttons = wrapper.findAll('.action-btn');
|
||||
expect(buttons).toHaveLength(2);
|
||||
|
||||
await buttons[0].trigger('click');
|
||||
await buttons[1].trigger('click');
|
||||
expect(wrapper.emitted('like')).toHaveLength(1);
|
||||
expect(wrapper.emitted('share')).toHaveLength(1);
|
||||
expect(wrapper.emitted('download')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('downloads the browse variant directly when original download is disabled', async () => {
|
||||
const wrapper = mountBar({ likeCount: 0, canDownload: true, canDownloadOriginal: false });
|
||||
|
||||
expect(wrapper.find('.download-caret').exists()).toBe(false);
|
||||
await wrapper.find('.download-group .action-btn').trigger('click');
|
||||
expect(wrapper.emitted('download')).toEqual([['browse']]);
|
||||
});
|
||||
|
||||
it('offers browse/original choices via the caret menu', async () => {
|
||||
const wrapper = mountBar({ likeCount: 0, canDownload: true, canDownloadOriginal: true });
|
||||
|
||||
const caret = wrapper.find('.download-caret');
|
||||
expect(caret.attributes('aria-haspopup')).toBe('menu');
|
||||
await caret.trigger('click');
|
||||
const menu = wrapper.find('.download-menu');
|
||||
expect(menu.attributes('role')).toBe('menu');
|
||||
expect(menu.text()).toContain('浏览图');
|
||||
expect(menu.text()).toContain('原图');
|
||||
|
||||
const items = menu.findAll('button');
|
||||
await items[1].trigger('click');
|
||||
expect(wrapper.emitted('download')).toEqual([['original']]);
|
||||
});
|
||||
|
||||
it('long-pressing the download button opens the variant menu instead of firing browse', async () => {
|
||||
vi.useFakeTimers();
|
||||
const wrapper = mountBar({ likeCount: 0, canDownload: true, canDownloadOriginal: true });
|
||||
|
||||
const button = wrapper.find('.download-group .action-btn');
|
||||
await button.trigger('pointerdown');
|
||||
vi.advanceTimersByTime(500);
|
||||
await nextTick(); // 定时器改状态后等一帧再查 DOM
|
||||
expect(wrapper.find('.download-menu').exists()).toBe(true);
|
||||
|
||||
// 长按已展开菜单:紧随的 click 被吞掉,不触发默认浏览图下载。
|
||||
await button.trigger('click');
|
||||
expect(wrapper.emitted('download')).toBeUndefined();
|
||||
|
||||
await wrapper.find('.download-menu button').trigger('click');
|
||||
expect(wrapper.emitted('download')).toEqual([['browse']]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user