From 89f45896b74069d6ed98b1a2aeb91bfbf64c4e87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Mon, 21 Sep 2026 21:58:09 +0800 Subject: [PATCH] feat(viewer): UI-02 dark immersive photo viewer with thumbnail strip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/viewer/src/__tests__/download.spec.ts | 24 +- apps/viewer/src/__tests__/lightbox.spec.ts | 181 +++- .../src/__tests__/photo-action-bar.spec.ts | 117 +++ .../src/__tests__/thumbnail-strip.spec.ts | 115 +++ .../src/__tests__/viewer-position.spec.ts | 41 + .../viewer/src/__tests__/viewer-route.spec.ts | 14 +- apps/viewer/src/components/PhotoLightbox.vue | 782 +++++++++++------- .../src/components/album/AlbumHeader.vue | 26 +- .../src/components/viewer/PhotoActionBar.vue | 305 +++++++ .../src/components/viewer/ThumbnailStrip.vue | 184 +++++ apps/viewer/src/i18n/locales/en.ts | 11 + apps/viewer/src/i18n/locales/zh-CN.ts | 11 + apps/viewer/src/support/clipboard.ts | 28 + apps/viewer/src/viewer/position.ts | 37 + 14 files changed, 1536 insertions(+), 340 deletions(-) create mode 100644 apps/viewer/src/__tests__/photo-action-bar.spec.ts create mode 100644 apps/viewer/src/__tests__/thumbnail-strip.spec.ts create mode 100644 apps/viewer/src/__tests__/viewer-position.spec.ts create mode 100644 apps/viewer/src/components/viewer/PhotoActionBar.vue create mode 100644 apps/viewer/src/components/viewer/ThumbnailStrip.vue create mode 100644 apps/viewer/src/support/clipboard.ts create mode 100644 apps/viewer/src/viewer/position.ts diff --git a/apps/viewer/src/__tests__/download.spec.ts b/apps/viewer/src/__tests__/download.spec.ts index b5e1a4b..d1ff835 100644 --- a/apps/viewer/src/__tests__/download.spec.ts +++ b/apps/viewer/src/__tests__/download.spec.ts @@ -187,16 +187,16 @@ describe('photo download (P4a Task 4)', () => { // on/off:单按钮,无 caret/菜单。 const browseOnly = mountLightbox(makeAlbum()); await flushPromises(); - expect(browseOnly.find('.control.download').exists()).toBe(true); - expect(browseOnly.find('.control.download-caret').exists()).toBe(false); + expect(browseOnly.find('.download-main').exists()).toBe(true); + expect(browseOnly.find('.download-caret').exists()).toBe(false); // on/on:分体按钮(caret 出现,菜单默认收起)。 const split = mountLightbox(makeAlbum({ originalDownloadEnabled: true })); await flushPromises(); - expect(split.find('.control.download-caret').exists()).toBe(true); + expect(split.find('.download-caret').exists()).toBe(true); expect(split.find('.download-menu').exists()).toBe(false); // 主按钮 aria 展开 = false。 - expect(split.find('.control.download-caret').attributes('aria-expanded')).toBe('false'); + expect(split.find('.download-caret').attributes('aria-expanded')).toBe('false'); }); it('split menu offers both variants; picking original requests the original endpoint', async () => { @@ -204,9 +204,9 @@ describe('photo download (P4a Task 4)', () => { const wrapper = mountLightbox(makeAlbum({ originalDownloadEnabled: true })); await flushPromises(); - await wrapper.find('.control.download-caret').trigger('click'); + await wrapper.find('.download-caret').trigger('click'); const items = wrapper.findAll('.download-menu button'); - expect(items.map((item) => item.text())).toEqual(['下载图片', '下载原图']); + expect(items.map((item) => item.text())).toEqual(['浏览图', '原图']); await items[1]!.trigger('click'); await flushPromises(); @@ -224,7 +224,7 @@ describe('photo download (P4a Task 4)', () => { const wrapper = mountLightbox(makeAlbum()); await flushPromises(); - await wrapper.find('.control.download').trigger('click'); + await wrapper.find('.download-main').trigger('click'); await flushPromises(); expect(mockedGetPhotoDownload).toHaveBeenCalledTimes(2); expect(mockedGetPhotoDownload).toHaveBeenCalledWith('tok-dl', 'p1', 'browse'); @@ -236,7 +236,7 @@ describe('photo download (P4a Task 4)', () => { const wrapper = mountLightbox(makeAlbum()); await flushPromises(); - await wrapper.find('.control.download').trigger('click'); + await wrapper.find('.download-main').trigger('click'); await flushPromises(); expect(mockedGetPhotoDownload).toHaveBeenCalledTimes(1); expect(openSpy).toHaveBeenCalledWith('http://img.test/p1-browse.jpg', '_blank', 'noopener,noreferrer'); @@ -245,7 +245,7 @@ describe('photo download (P4a Task 4)', () => { // 端点失败(网络/403 等)→ 失败提示可再见(再次点击重试)。 openSpy.mockClear(); mockedGetPhotoDownload.mockReset().mockRejectedValue(new Error('boom')); - await wrapper.find('.control.download').trigger('click'); + await wrapper.find('.download-main').trigger('click'); await flushPromises(); expect(wrapper.find('.download-failed[role="alert"]').text()).toContain('下载失败'); expect(openSpy).not.toHaveBeenCalled(); @@ -259,7 +259,7 @@ describe('photo download (P4a Task 4)', () => { const wrapper = mountLightbox(makeAlbum()); await flushPromises(); - await wrapper.find('.control.download').trigger('click'); + await wrapper.find('.download-main').trigger('click'); await flushPromises(); expect(wrapper.find('.download-failed[role="alert"]').text()).toContain('该相册未开放下载'); expect(wrapper.find('.download-failed').text()).not.toContain('下载失败'); @@ -270,7 +270,7 @@ describe('photo download (P4a Task 4)', () => { const wrapper = mountLightbox(makeAlbum()); await flushPromises(); - await wrapper.find('.control.download').trigger('click'); + await wrapper.find('.download-main').trigger('click'); await flushPromises(); expect(wrapper.find('.wechat-guide[role="dialog"]').exists()).toBe(true); expect(wrapper.find('.wechat-guide h2').text()).toBe('保存图片指引'); @@ -289,7 +289,7 @@ describe('photo download (P4a Task 4)', () => { const wrapper = mountLightbox(makeAlbum()); await flushPromises(); - await wrapper.find('.control.download').trigger('click'); + await wrapper.find('.download-main').trigger('click'); await wrapper.find('.guide-copy').trigger('click'); await flushPromises(); expect(writeText).toHaveBeenCalledWith(window.location.href); diff --git a/apps/viewer/src/__tests__/lightbox.spec.ts b/apps/viewer/src/__tests__/lightbox.spec.ts index 5b87d77..1155537 100644 --- a/apps/viewer/src/__tests__/lightbox.spec.ts +++ b/apps/viewer/src/__tests__/lightbox.spec.ts @@ -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'); + // 舞台空白区(目标=容器自身,非 )点击关闭。 + 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((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)'); + }); +}); diff --git a/apps/viewer/src/__tests__/photo-action-bar.spec.ts b/apps/viewer/src/__tests__/photo-action-bar.spec.ts new file mode 100644 index 0000000..ba0ede6 --- /dev/null +++ b/apps/viewer/src/__tests__/photo-action-bar.spec.ts @@ -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']]); + }); +}); diff --git a/apps/viewer/src/__tests__/thumbnail-strip.spec.ts b/apps/viewer/src/__tests__/thumbnail-strip.spec.ts new file mode 100644 index 0000000..b7d8eab --- /dev/null +++ b/apps/viewer/src/__tests__/thumbnail-strip.spec.ts @@ -0,0 +1,115 @@ +import { mount, type VueWrapper } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import ThumbnailStrip, { type ThumbnailItem } from '../components/viewer/ThumbnailStrip.vue'; +import { createViewerI18n, type ViewerI18n } from '../i18n'; +import { reducedMotion } from '../ui/prefs'; + +// UI-02 §5.3 缩略图条单测:±3 窗口(最多 7 项,首尾自然收缩)、当前项描边与 +// aria-current、点击上抛 select(无浏览器历史副作用)、选中项滚入可见范围 +// (遵守减少动画)、无溢出不渲染渐隐提示。 +function makeItems(count: number): ThumbnailItem[] { + return Array.from({ length: count }, (_, i) => ({ + photoId: `p${i + 1}`, + thumbUrl: `http://img.test/p${i + 1}-thumb.jpg`, + })); +} + +let i18n: ViewerI18n; +const mounted: VueWrapper[] = []; + +function mountStrip(photos: ThumbnailItem[], currentPhotoId: string | null): VueWrapper { + const wrapper = mount(ThumbnailStrip, { + props: { photos, currentPhotoId }, + global: { plugins: [i18n] }, + }); + mounted.push(wrapper); + return wrapper; +} + +function thumbIds(wrapper: VueWrapper): (string | undefined)[] { + return wrapper.findAll('.thumb').map((thumb) => thumb.find('img').attributes('src')); +} + +describe('thumbnail strip (UI-02 V04)', () => { + beforeEach(() => { + localStorage.clear(); + i18n = createViewerI18n('zh-CN'); + }); + + afterEach(() => { + while (mounted.length > 0) mounted.pop()?.unmount(); + reducedMotion.value = false; + vi.restoreAllMocks(); + }); + + it('renders the current ±3 window (max 7 items) centered on the current photo', () => { + const wrapper = mountStrip(makeItems(12), 'p6'); + // p6 的 ±3 → p3..p9 共 7 项;thumb 变体 URL。 + expect(thumbIds(wrapper)).toEqual([ + 'http://img.test/p3-thumb.jpg', + 'http://img.test/p4-thumb.jpg', + 'http://img.test/p5-thumb.jpg', + 'http://img.test/p6-thumb.jpg', + 'http://img.test/p7-thumb.jpg', + 'http://img.test/p8-thumb.jpg', + 'http://img.test/p9-thumb.jpg', + ]); + }); + + it('clamps the window at both edges without wrapping or extra fetching', () => { + const head = mountStrip(makeItems(12), 'p1'); + expect(thumbIds(head)[0]).toBe('http://img.test/p1-thumb.jpg'); + expect(thumbIds(head)).toHaveLength(7); + + const tail = mountStrip(makeItems(12), 'p12'); + expect(thumbIds(tail)).toHaveLength(7); + expect(thumbIds(tail).at(-1)).toBe('http://img.test/p12-thumb.jpg'); + + // 少于 7 项全量展示(不加载整个相册补位)。 + const short = mountStrip(makeItems(3), 'p2'); + expect(thumbIds(short)).toHaveLength(3); + }); + + it('highlights the current item with the primary border and aria-current', () => { + const wrapper = mountStrip(makeItems(5), 'p3'); + const current = wrapper.findAll('.thumb')[2]; + expect(current.classes()).toContain('current'); + expect(current.attributes('aria-current')).toBe('true'); + expect(wrapper.findAll('.thumb')[0].attributes('aria-current')).toBeUndefined(); + // 无障碍名称带序号(第 {n} 张)。 + expect(current.attributes('aria-label')).toBe('第 3 张'); + }); + + it('emits select with the photoId on click (no browser history side effects)', async () => { + const wrapper = mountStrip(makeItems(9), 'p5'); + // p5 的 ±3 窗口从 p2 起。 + await wrapper.findAll('.thumb')[0].trigger('click'); + expect(wrapper.emitted('select')).toEqual([['p2']]); + }); + + it('scrolls the selected item into view, honoring the reduced-motion preference', async () => { + const scrollIntoView = vi.fn(); + HTMLElement.prototype.scrollIntoView = scrollIntoView; + + reducedMotion.value = false; + const wrapper = mountStrip(makeItems(12), 'p6'); + await nextTick(); // flush:'post' 滚动 watcher 在渲染后微任务执行 + expect(scrollIntoView).toHaveBeenCalledWith( + expect.objectContaining({ behavior: 'smooth', inline: 'center', block: 'nearest' }), + ); + + scrollIntoView.mockClear(); + reducedMotion.value = true; + await wrapper.setProps({ currentPhotoId: 'p8' }); + expect(scrollIntoView).toHaveBeenCalledWith( + expect.objectContaining({ behavior: 'auto', inline: 'center' }), + ); + }); + + it('hides the overflow fades when the track is not scrollable', () => { + // jsdom 布局为 0:scrollWidth === clientWidth → 两侧均无渐隐提示。 + const wrapper = mountStrip(makeItems(3), 'p2'); + expect(wrapper.find('.thumb-fade').exists()).toBe(false); + }); +}); diff --git a/apps/viewer/src/__tests__/viewer-position.spec.ts b/apps/viewer/src/__tests__/viewer-position.spec.ts new file mode 100644 index 0000000..27862d3 --- /dev/null +++ b/apps/viewer/src/__tests__/viewer-position.spec.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { positionDisplay } from '../viewer/position'; + +// UI-02 §5.2 位置计数形态判定(纯数据层):三种真实回退 + 前瞻 total 形态。 +// 组件侧当前传 total=null(公开端点游标分页无总数);lightbox.spec 覆盖 +// loaded / index / preview 三种模式的端到端渲染。 +describe('viewer position display fallbacks (UI-02 §5.2)', () => { + it('shows i / total only when the API provides an accurate total', () => { + expect(positionDisplay({ index: 11, loaded: 24, total: 248, atEnd: false, solo: false })).toEqual({ + kind: 'total', + current: 12, + total: 248, + }); + }); + + it('shows the loaded count while more pages remain and no total exists', () => { + expect(positionDisplay({ index: 2, loaded: 24, total: null, atEnd: false, solo: false })).toEqual({ + kind: 'loaded', + count: 24, + }); + }); + + it('shows i / n of the filtered sequence once all pages are loaded', () => { + expect(positionDisplay({ index: 4, loaded: 30, total: null, atEnd: true, solo: false })).toEqual({ + kind: 'index', + current: 5, + total: 30, + }); + }); + + it('never fabricates a position for unpositioned or solo deep-link entries', () => { + // solo:即使有准确 total,也不知道目标在筛选序列中的自然位置。 + expect(positionDisplay({ index: 24, loaded: 25, total: 248, atEnd: true, solo: true })).toEqual({ + kind: 'preview', + }); + // 下标未知(-1,防御分支)。 + expect(positionDisplay({ index: -1, loaded: 3, total: null, atEnd: true, solo: false })).toEqual({ + kind: 'preview', + }); + }); +}); diff --git a/apps/viewer/src/__tests__/viewer-route.spec.ts b/apps/viewer/src/__tests__/viewer-route.spec.ts index b332687..a7cd890 100644 --- a/apps/viewer/src/__tests__/viewer-route.spec.ts +++ b/apps/viewer/src/__tests__/viewer-route.spec.ts @@ -122,7 +122,7 @@ describe('viewer url sync and deep link (Task 8)', () => { scrollTo.mockClear(); keydown('Escape'); await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/a/tok-view')); - expect(wrapper.find('.lightbox').exists()).toBe(false); + expect(wrapper.find('.photo-viewer').exists()).toBe(false); expect(scrollTo).toHaveBeenCalledWith(0, 650); expect(replaceSpy).toHaveBeenCalledTimes(1); // 关闭未额外 replace }); @@ -137,13 +137,13 @@ describe('viewer url sync and deep link (Task 8)', () => { const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}); await wrapper.findAll('.cell')[0].trigger('click'); await flushPromises(); - expect(wrapper.find('.lightbox').exists()).toBe(true); + expect(wrapper.find('.photo-viewer').exists()).toBe(true); scrollTo.mockClear(); router.back(); // 等价浏览器后退(popstate 异步派发) await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/a/tok-view')); await flushPromises(); - expect(wrapper.find('.lightbox').exists()).toBe(false); + expect(wrapper.find('.photo-viewer').exists()).toBe(false); expect(scrollTo).toHaveBeenCalledWith(0, 320); }); @@ -164,15 +164,15 @@ describe('viewer url sync and deep link (Task 8)', () => { const wrapper = mountApp(); await flushPromises(); - // 单图先行:列表仍在加载,大图已可看(solo 序列 1/1)。 + // 单图先行:列表仍在加载,大图已可看(solo 位置未知 → 「照片预览」,§5.2 不伪造排名)。 expect(mockedGetPublicPhoto).toHaveBeenCalledWith('tok-view', 'p5'); expect(wrapper.find('img.photo').attributes('src')).toBe('http://img.test/p5-browse.jpg?s=1'); - expect(wrapper.find('.counter').text()).toBe('1 / 1'); + expect(wrapper.find('.position-indicator').text()).toBe('照片预览'); // 第 1 页到达(无 p5)→ 深链 watcher 追拉第 2 页 → p5 归位自然顺序(5/5)。 resolveFirstPage(makeList([makePhoto('p1'), makePhoto('p2'), makePhoto('p3')], 'cur-1')); await vi.waitFor(() => expect(wrapper.findAll('.cell')).toHaveLength(5)); - expect(wrapper.find('.counter').text()).toBe('5 / 5'); + expect(wrapper.find('.position-indicator').text()).toBe('5 / 5'); // 序列已构成:左切到前一张。 keydown('ArrowLeft'); @@ -189,7 +189,7 @@ describe('viewer url sync and deep link (Task 8)', () => { const wrapper = mountApp(); await flushPromises(); - expect(wrapper.find('.lightbox').exists()).toBe(false); + expect(wrapper.find('.photo-viewer').exists()).toBe(false); const notice = wrapper.find('.deep-link-notice[role="alert"]'); expect(notice.text()).toContain('照片不存在或已下线'); expect(notice.text()).not.toContain('PHOTO_NOT_PUBLIC'); // 不裸露错误码 diff --git a/apps/viewer/src/components/PhotoLightbox.vue b/apps/viewer/src/components/PhotoLightbox.vue index 2c49015..813d19e 100644 --- a/apps/viewer/src/components/PhotoLightbox.vue +++ b/apps/viewer/src/components/PhotoLightbox.vue @@ -1,39 +1,48 @@ @@ -337,112 +470,135 @@ onBeforeUnmount(() => {