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:
@@ -1,3 +1,6 @@
|
||||
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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -12,8 +15,10 @@ import { createViewerI18n, type ViewerI18n } from '../i18n';
|
||||
import PhotoLightbox from '../components/PhotoLightbox.vue';
|
||||
import { usePhotoSequence, type PhotoSequence } from '../feed/sequence';
|
||||
|
||||
// PhotoLightbox(Task 8)组件测试:browse 渲染/导航/序列扩展、wheel 缩放与双击
|
||||
// 复位、Esc/背景/按钮关闭、指针轻扫切换、签名过期静默重取与 404 移除上报。
|
||||
// PhotoLightbox 组件测试(Task 8 业务 + UI-02 V04 深色布局):
|
||||
// browse 渲染/导航/序列扩展、wheel 缩放与双击复位、Esc/关闭/舞台空白点击关闭、
|
||||
// 指针轻扫切换、签名过期静默重取与 404 移除上报;V04 新增:位置计数三种
|
||||
// 真实回退、缩略图条渲染与选择、更多菜单、焦点管理、body 滚动锁、安全区。
|
||||
// 深链解析与 URL 同步在 viewer-route.spec(真实路由)覆盖。
|
||||
|
||||
vi.mock('../api/client', async (importOriginal) => {
|
||||
@@ -44,8 +49,9 @@ let i18n: ViewerI18n;
|
||||
const mounted: VueWrapper[] = [];
|
||||
|
||||
/** 宿主:sequence + v-model photoId(镜像 ActivityPage 组装)。页面数据经
|
||||
* mockedGetPublicPhotos 的当前 mock 队列供给(与真实时序一致:挂载后到达)。 */
|
||||
function mountLightbox(photoId: string): VueWrapper {
|
||||
* mockedGetPublicPhotos 的当前 mock 队列供给(与真实时序一致:挂载后到达)。
|
||||
* solo:深链兜底条目(ensureInSequence 追加在序列尾部,自然位置未知)。 */
|
||||
function mountLightbox(photoId: string, options: { solo?: PublicPhoto } = {}): VueWrapper {
|
||||
const Host = defineComponent({
|
||||
components: { PhotoLightbox },
|
||||
setup() {
|
||||
@@ -54,6 +60,7 @@ function mountLightbox(photoId: string): VueWrapper {
|
||||
locale: ref('zh-CN'),
|
||||
category: ref(null),
|
||||
});
|
||||
if (options.solo !== undefined) sequence.ensureInSequence(options.solo);
|
||||
const current = ref(photoId);
|
||||
return { sequence, photoId: current };
|
||||
},
|
||||
@@ -95,7 +102,7 @@ function keydown(key: string): void {
|
||||
|
||||
/** test-utils 的 trigger 无法写 MouseEvent 只读坐标:直接派生 WheelEvent。 */
|
||||
function wheel(wrapper: VueWrapper, deltaY: number, clientX = 100, clientY = 80): void {
|
||||
wrapper.find('.lightbox').element.dispatchEvent(
|
||||
wrapper.find('.photo-viewer').element.dispatchEvent(
|
||||
new WheelEvent('wheel', { bubbles: true, cancelable: true, deltaY, clientX, clientY }),
|
||||
);
|
||||
}
|
||||
@@ -115,7 +122,7 @@ describe('photo lightbox (Task 8)', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders the current browse url with counter and like count', async () => {
|
||||
it('renders the current browse url with position indicator and like count', async () => {
|
||||
mockedGetPublicPhotos.mockResolvedValue(
|
||||
makeList([makePhoto('p1'), makePhoto('p2', 7), makePhoto('p3')]),
|
||||
);
|
||||
@@ -123,8 +130,11 @@ describe('photo lightbox (Task 8)', () => {
|
||||
await flushPromises();
|
||||
|
||||
expect(img(wrapper).attributes('src')).toBe('http://img.test/p2-browse.jpg?s=1');
|
||||
expect(wrapper.find('.counter').text()).toBe('2 / 3');
|
||||
expect(wrapper.find('.likes').text()).toBe('7 次点赞');
|
||||
// 3 张全部加载完成(无更多页)→ 筛选序列 i / n(§5.2)。
|
||||
expect(wrapper.find('.position-indicator').text()).toBe('2 / 3');
|
||||
// 底部操作栏(V04):Like 计数 + Like/Share 标签(无下载控制器 → 下载位省略)。
|
||||
expect(wrapper.find('.like-count').text()).toBe('7');
|
||||
expect(wrapper.findAll('.action-label').map((label) => label.text())).toEqual(['点赞', '分享']);
|
||||
});
|
||||
|
||||
it('navigates with buttons and arrow keys, keeping the list order', async () => {
|
||||
@@ -150,17 +160,18 @@ describe('photo lightbox (Task 8)', () => {
|
||||
expect(wrapper.find('.nav.next').attributes('disabled')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('closes on Esc, close button and backdrop click', async () => {
|
||||
it('closes on Esc, close button and empty-stage click', async () => {
|
||||
const wrapper = mountLightbox('p2');
|
||||
await flushPromises();
|
||||
|
||||
keydown('Escape');
|
||||
expect(lightbox(wrapper).emitted('close')).toHaveLength(1);
|
||||
|
||||
await wrapper.find('.control.close').trigger('click');
|
||||
await wrapper.find('.viewer-close').trigger('click');
|
||||
expect(lightbox(wrapper).emitted('close')).toHaveLength(2);
|
||||
|
||||
await wrapper.find('.backdrop').trigger('click');
|
||||
// 舞台空白区(目标=容器自身,非 <img>)点击关闭。
|
||||
await wrapper.find('.image-container').trigger('click');
|
||||
expect(lightbox(wrapper).emitted('close')).toHaveLength(3);
|
||||
});
|
||||
|
||||
@@ -198,10 +209,10 @@ describe('photo lightbox (Task 8)', () => {
|
||||
expect(img(wrapper).attributes('style')).toContain('scale(1)');
|
||||
});
|
||||
|
||||
it('swipe navigates when flat; drag on backdrop does not close', async () => {
|
||||
it('swipe navigates when flat; drag on stage does not close', async () => {
|
||||
const wrapper = mountLightbox('p2');
|
||||
await flushPromises();
|
||||
const root = wrapper.find('.lightbox');
|
||||
const root = wrapper.find('.photo-viewer');
|
||||
|
||||
// 左滑 80px → 下一张。
|
||||
root.element.dispatchEvent(pointerEvent('pointerdown', { pointerId: 1, clientX: 200, clientY: 150 }));
|
||||
@@ -210,11 +221,11 @@ describe('photo lightbox (Task 8)', () => {
|
||||
await flushPromises();
|
||||
expect(lightbox(wrapper).emitted('update:photoId')).toEqual([['p3']]);
|
||||
|
||||
// 拖拽距离超过阈值后收尾在背景上:click 不关闭。
|
||||
// 拖拽距离超过阈值后收尾在舞台上:click 不关闭。
|
||||
root.element.dispatchEvent(pointerEvent('pointerdown', { pointerId: 1, clientX: 200, clientY: 150 }));
|
||||
root.element.dispatchEvent(pointerEvent('pointermove', { pointerId: 1, clientX: 100, clientY: 160 }));
|
||||
root.element.dispatchEvent(pointerEvent('pointerup', { pointerId: 1, clientX: 100, clientY: 160 }));
|
||||
await wrapper.find('.backdrop').trigger('click');
|
||||
await wrapper.find('.image-container').trigger('click');
|
||||
expect(lightbox(wrapper).emitted('close')).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -297,3 +308,143 @@ describe('photo lightbox (Task 8)', () => {
|
||||
expect(img(wrapper).attributes('src')).toBe('http://img.test/p2-browse.jpg?s=3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('photo lightbox dark viewer (UI-02 V04)', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
i18n = createViewerI18n('zh-CN');
|
||||
mockedGetPublicPhotos.mockReset().mockResolvedValue(makeList([makePhoto('p1'), makePhoto('p2'), makePhoto('p3')]));
|
||||
mockedGetPublicPhoto.mockReset();
|
||||
document.body.style.overflow = '';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (mounted.length > 0) {
|
||||
mounted.pop()?.unmount();
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
document.body.style.overflow = '';
|
||||
document.documentElement.className = '';
|
||||
});
|
||||
|
||||
it('shows the loaded-count fallback while more pages remain (no fake total)', async () => {
|
||||
let resolvePage!: (value: PublicPhotoList) => void;
|
||||
mockedGetPublicPhotos.mockReset();
|
||||
mockedGetPublicPhotos
|
||||
.mockResolvedValueOnce(makeList([makePhoto('p1'), makePhoto('p2')], 'cur-1'))
|
||||
.mockReturnValueOnce(
|
||||
new Promise<PublicPhotoList>((resolve) => {
|
||||
resolvePage = resolve;
|
||||
}),
|
||||
);
|
||||
const wrapper = mountLightbox('p2');
|
||||
await flushPromises();
|
||||
|
||||
// 未到末页且接口无 total → 「已加载 N 张」(§5.2),不显示 i/n。
|
||||
expect(wrapper.find('.position-indicator').text()).toBe('已加载 2 张');
|
||||
|
||||
resolvePage(makeList([makePhoto('p3')], null));
|
||||
await flushPromises();
|
||||
// 全部加载完成 → 当前筛选序列 i / n。
|
||||
expect(wrapper.find('.position-indicator').text()).toBe('2 / 3');
|
||||
});
|
||||
|
||||
it('shows photo preview for an unpositioned solo deep-link entry', async () => {
|
||||
const wrapper = mountLightbox('solo-9', { solo: makePhoto('solo-9') });
|
||||
await flushPromises();
|
||||
|
||||
// solo 自然位置未知(首页加载后仍追加在尾部)→ 「照片预览」,不伪造排名。
|
||||
expect(wrapper.find('.position-indicator').text()).toBe('照片预览');
|
||||
});
|
||||
|
||||
it('renders the thumbnail strip window and selects without extra history entries', async () => {
|
||||
const wrapper = mountLightbox('p2');
|
||||
await flushPromises();
|
||||
|
||||
// 3 张全部落在 ±3 窗口内;当前项描边 + aria-current;点击只上抛切换。
|
||||
const thumbs = wrapper.findAll('.thumb');
|
||||
expect(thumbs.map((thumb) => thumb.find('img').attributes('src'))).toEqual([
|
||||
'http://img.test/p1-thumb.jpg?s=1',
|
||||
'http://img.test/p2-thumb.jpg?s=1',
|
||||
'http://img.test/p3-thumb.jpg?s=1',
|
||||
]);
|
||||
expect(thumbs[1].classes()).toContain('current');
|
||||
expect(thumbs[1].attributes('aria-current')).toBe('true');
|
||||
|
||||
await thumbs[0].trigger('click');
|
||||
expect(lightbox(wrapper).emitted('update:photoId')).toEqual([['p1']]);
|
||||
});
|
||||
|
||||
it('more menu exposes the reduce-motion toggle; Escape closes the menu before the viewer', async () => {
|
||||
const wrapper = mountLightbox('p2');
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('.viewer-more').trigger('click');
|
||||
expect(wrapper.find('.more-popover').exists()).toBe(true);
|
||||
const item = wrapper.find('.more-item');
|
||||
expect(item.text()).toContain('减少动画');
|
||||
expect(item.attributes('aria-checked')).toBe('false');
|
||||
await item.trigger('click');
|
||||
expect(wrapper.find('.more-item').attributes('aria-checked')).toBe('true');
|
||||
// 还原模块级偏好单例,避免影响后续用例。
|
||||
await wrapper.find('.more-item').trigger('click');
|
||||
|
||||
keydown('Escape');
|
||||
await flushPromises();
|
||||
expect(wrapper.find('.more-popover').exists()).toBe(false); // 先收菜单
|
||||
expect(lightbox(wrapper).emitted('close')).toBeUndefined(); // 不关查看器
|
||||
|
||||
keydown('Escape');
|
||||
await flushPromises();
|
||||
expect(lightbox(wrapper).emitted('close')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('moves focus into the dialog on open and returns it to the trigger on close', async () => {
|
||||
const trigger = document.createElement('button');
|
||||
document.body.appendChild(trigger);
|
||||
trigger.focus();
|
||||
|
||||
const wrapper = mountLightbox('p2');
|
||||
// jsdom 下脱离文档的元素 focus() 不改 activeElement:赶在调度器微任务
|
||||
// (rootEl watcher 调 focus)之前同步挂进文档。
|
||||
document.body.appendChild(wrapper.element);
|
||||
await flushPromises();
|
||||
|
||||
const root = wrapper.find('.photo-viewer');
|
||||
expect(root.attributes('role')).toBe('dialog');
|
||||
expect(root.attributes('aria-modal')).toBe('true');
|
||||
expect(root.attributes('tabindex')).toBe('-1');
|
||||
expect(root.attributes('aria-label')).toBe('照片查看器');
|
||||
expect(document.activeElement).toBe(root.element);
|
||||
|
||||
// 手动卸载(绕过 mounted 清单)验证焦点归还触发元素。
|
||||
const index = mounted.indexOf(wrapper);
|
||||
if (index >= 0) mounted.splice(index, 1);
|
||||
wrapper.unmount();
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
trigger.remove();
|
||||
});
|
||||
|
||||
it('locks body scroll while open and restores the previous value on close', async () => {
|
||||
document.body.style.overflow = 'auto';
|
||||
const wrapper = mountLightbox('p2');
|
||||
await flushPromises();
|
||||
expect(document.body.style.overflow).toBe('hidden');
|
||||
|
||||
const index = mounted.indexOf(wrapper);
|
||||
if (index >= 0) mounted.splice(index, 1);
|
||||
wrapper.unmount();
|
||||
expect(document.body.style.overflow).toBe('auto');
|
||||
});
|
||||
|
||||
it('applies dvh viewport height and safe-area insets for the immersive layout', () => {
|
||||
// vitest 不加载 SFC 样式:源级断言(与 viewer-shell 的 token 源级断言同法)。
|
||||
const source = readFileSync(
|
||||
join(dirname(fileURLToPath(import.meta.url)), '..', 'components', 'PhotoLightbox.vue'),
|
||||
'utf8',
|
||||
);
|
||||
expect(source).toContain('100dvh');
|
||||
expect(source).toContain('env(safe-area-inset-top, 0px)');
|
||||
expect(source).toContain('env(safe-area-inset-bottom, 0px)');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user