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:
浅念
2026-09-21 21:58:09 +08:00
parent 3d89fcdb99
commit 89f45896b7
14 changed files with 1536 additions and 340 deletions
+166 -15
View File
@@ -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)');
});
});