From 3d89fcdb9954c799fed70779875ee1303e06bd8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Mon, 21 Sep 2026 21:34:56 +0800 Subject: [PATCH] feat(viewer): UI-02 responsive waterfall with like badges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 3 (V03 瀑布流重排, 方案 §4.3): - responsiveColumns 断点:<600px 双列 gap4 / 600-959px 三列 gap6 / ≥960px 四列 gap8;PhotoFeed 经既有 ResizeObserver 容器测量整体重排 - 照片卡片去卡片化:图片边到边 + 4px 圆角,无白色厚包裹/无常驻文件名; 右下角点赞角标(半透明暗底渐变、白心 16px、12px tabular-nums 计数), @click.stop 独立交互不冒泡开大图;计数经 Intl 分组 - 新照片提示改白色 pill(viewer.newPhotos,接口仅 hasNew 不伪造数量); refreshFromHint 不再强制回顶(用户浏览位置保持) - 首屏骨架按 skeletonSources 竖/横/方比例走真实瀑布流放置(非均匀网格); 宽高缺失/NaN/Infinity 回退 4:3 安全比例(无 NaN/无限高度) - 保留懒加载 IO、游标分页、photoId 稳定键、翻页失败底部重试与已到底文案 Co-Authored-By: Claude Code --- apps/viewer/src/__tests__/feed-layout.spec.ts | 84 +++++- apps/viewer/src/__tests__/feed.spec.ts | 127 ++++++++- apps/viewer/src/__tests__/sequence.spec.ts | 5 +- apps/viewer/src/components/PhotoFeed.vue | 268 +++++++++++++----- apps/viewer/src/feed/layout.ts | 76 ++++- apps/viewer/src/feed/sequence.ts | 4 +- 6 files changed, 465 insertions(+), 99 deletions(-) diff --git a/apps/viewer/src/__tests__/feed-layout.spec.ts b/apps/viewer/src/__tests__/feed-layout.spec.ts index b08edfd..bfca65c 100644 --- a/apps/viewer/src/__tests__/feed-layout.spec.ts +++ b/apps/viewer/src/__tests__/feed-layout.spec.ts @@ -3,9 +3,12 @@ import { containsNewPhotoId, layoutWaterfall, mergeUniqueByPhotoId, + responsiveColumns, + skeletonSources, } from '../feed/layout'; // Task 7 瀑布流纯函数层:列放置/高度预留/去重/新照片判定(组件行为在 feed.spec)。 +// UI-02 Task 3:响应式列数断点、4:3 安全比例、首屏骨架占位源。 // 数字断言手算依据:containerWidth 320、gap 8、双列 → columnWidth = floor(312/2) = 156。 describe('layoutWaterfall (pure placement)', () => { @@ -46,10 +49,24 @@ describe('layoutWaterfall (pure placement)', () => { expect(layout.height).toBe(320); }); - it('falls back to a square placeholder for degenerate dimensions (no NaN)', () => { - const layout = layoutWaterfall([{ width: 0, height: 500 }, { width: 400, height: 0 }], 320); - expect(layout.placements.map((p) => p.height)).toEqual([156, 156]); - expect(layout.placements.every((p) => Number.isFinite(p.top) && Number.isFinite(p.left))).toBe(true); + it('falls back to the 4:3 safe ratio for degenerate dimensions (no NaN/infinite heights)', () => { + const layout = layoutWaterfall( + [ + { width: 0, height: 500 }, + { width: 400, height: 0 }, + { width: Number.NaN, height: 100 }, + { width: 1, height: Number.POSITIVE_INFINITY }, + ], + 320, + ); + // 4:3 安全比例:round(156 * 3/4) = 117(原为正方形 156,UI-02 §4.3 改版)。 + expect(layout.placements.map((p) => p.height)).toEqual([117, 117, 117, 117]); + expect( + layout.placements.every( + (p) => Number.isFinite(p.top) && Number.isFinite(p.left) && Number.isFinite(p.height), + ), + ).toBe(true); + expect(Number.isFinite(layout.height)).toBe(true); }); it('returns an empty layout for no items and tolerates zero container width', () => { @@ -63,6 +80,65 @@ describe('layoutWaterfall (pure placement)', () => { }); }); +describe('responsiveColumns (UI-02 Task 3 breakpoints)', () => { + it('maps container width to 2/3/4 columns with 4/6/8px gaps', () => { + // <600px 手机双列 gap 4。 + expect(responsiveColumns(350)).toEqual({ columns: 2, gap: 4 }); + expect(responsiveColumns(599)).toEqual({ columns: 2, gap: 4 }); + // 600-959px 平板三列 gap 6。 + expect(responsiveColumns(600)).toEqual({ columns: 3, gap: 6 }); + expect(responsiveColumns(959)).toEqual({ columns: 3, gap: 6 }); + // ≥960px 桌面四列 gap 8(主内容限宽 1120px 由页面层负责)。 + expect(responsiveColumns(960)).toEqual({ columns: 4, gap: 8 }); + expect(responsiveColumns(1120)).toEqual({ columns: 4, gap: 8 }); + expect(responsiveColumns(1200)).toEqual({ columns: 4, gap: 8 }); + }); + + it('drives layoutWaterfall column geometry when passed as options', () => { + const layout = layoutWaterfall( + [ + { width: 100, height: 100 }, + { width: 100, height: 100 }, + { width: 100, height: 100 }, + { width: 100, height: 100 }, + ], + 700, + responsiveColumns(700), + ); + // 700 → 三列 gap 6:columnWidth = floor(688/3) = 229;前三条各占一列, + // 第四条三列同高(235)取索引小者 col0(top 235)。 + expect(layout.columnWidth).toBe(229); + expect(layout.placements.map((p) => p.left)).toEqual([0, 235, 470, 0]); + expect(layout.placements.map((p) => p.top)).toEqual([0, 0, 0, 235]); + // col0 累计 229+6+229+6=470(末尾间距只在条目间生效),容器高 → 464。 + expect(layout.height).toBe(464); + }); +}); + +describe('skeletonSources (first-load waterfall placeholder)', () => { + it('cycles varied aspects so the skeleton silhouette is not a uniform grid', () => { + const sources = skeletonSources(); + expect(sources).toHaveLength(12); + + const layout = layoutWaterfall(sources, 320, responsiveColumns(320)); + const heights = layout.placements.map((p) => p.height); + // 320 → 双列 gap 4:columnWidth = floor(316/2) = 158。 + // 比例序列 [3:4, 4:3, 1:1, 3:2, 4:5, 2:3, ...] → 高度 211/119/158/105/198/237… + expect(heights[0]).toBe(Math.round(158 * (4 / 3))); + expect(heights[1]).toBe(Math.round(158 * (3 / 4))); + expect(new Set(heights).size).toBeGreaterThan(1); + // 放置仍走最矮列规则(骨架与真实瀑布流同一轮廓)。 + expect(layout.placements.every((p) => p.column === 0 || p.column === 1)).toBe(true); + }); + + it('supports a custom count and repeats the aspect cycle beyond its length', () => { + expect(skeletonSources(3)).toHaveLength(3); + const extended = skeletonSources(15); + expect(extended).toHaveLength(15); + expect(extended[12]).toEqual(skeletonSources()[0]); + }); +}); + describe('mergeUniqueByPhotoId (page append dedupe)', () => { it('appends only unseen photoIds and preserves order', () => { const merged = mergeUniqueByPhotoId( diff --git a/apps/viewer/src/__tests__/feed.spec.ts b/apps/viewer/src/__tests__/feed.spec.ts index 8b647b5..dff250a 100644 --- a/apps/viewer/src/__tests__/feed.spec.ts +++ b/apps/viewer/src/__tests__/feed.spec.ts @@ -1,5 +1,5 @@ import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'; -import { computed, defineComponent } from 'vue'; +import { computed, defineComponent, nextTick } from 'vue'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ApiError, @@ -9,6 +9,7 @@ import { type PublicPhotoList, } from '../api/client'; import { createViewerI18n, type ViewerI18n } from '../i18n'; +import type { LikesController } from '../likes/useLikes'; import PhotoFeed from '../components/PhotoFeed.vue'; import { usePhotoSequence, type PhotoSequence } from '../feed/sequence'; @@ -113,8 +114,11 @@ function makeList(items: PublicPhoto[], nextCursor: string | null = null): Publi let i18n: ViewerI18n; const mounted: VueWrapper[] = []; -/** 宿主:创建 sequence 并渲染 PhotoFeed(镜像 ActivityPage 组装);捕获 open 事件。 */ -function mountFeed(props: { token?: string; locale?: string; category?: string | null } = {}): { +/** 宿主:创建 sequence 并渲染 PhotoFeed(镜像 ActivityPage 组装);捕获 open 事件。 + * likes 可注入替身控制器(组件级点赞角标测试,不拉真实批次端点)。 */ +function mountFeed( + props: { token?: string; locale?: string; category?: string | null; likes?: LikesController } = {}, +): { wrapper: VueWrapper; sequence: PhotoSequence; opened: string[]; @@ -128,9 +132,13 @@ function mountFeed(props: { token?: string; locale?: string; category?: string | locale: computed(() => props.locale ?? 'zh-CN'), category: computed(() => props.category ?? null), }); - return { sequence, onOpen: (photoId: string) => opened.push(photoId) }; + return { + sequence, + likes: props.likes ?? undefined, + onOpen: (photoId: string) => opened.push(photoId), + }; }, - template: ``, + template: ``, }); const wrapper = mount(Host, { global: { plugins: [i18n] } }); mounted.push(wrapper); @@ -145,7 +153,8 @@ describe('photo feed (Task 7 / Task 8 sequence props)', () => { FakeIntersectionObserver.instances = []; vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver); vi.stubGlobal('ResizeObserver', FakeResizeObserver); - // jsdom 无布局:clientWidth 恒 0 → 原型 getter mock 为 700(columnWidth=346)。 + // jsdom 无布局:clientWidth 恒 0 → 原型 getter mock 为 700。UI-02 Task 3 起 + // 响应式列数:700 落在 600-959 段 → 三列 gap 6(columnWidth = floor(688/3) = 229)。 const proto = Object.prototype.hasOwnProperty.call(HTMLElement.prototype, 'clientWidth') ? HTMLElement.prototype : Element.prototype; @@ -169,11 +178,12 @@ describe('photo feed (Task 7 / Task 8 sequence props)', () => { expect(mockedGetPublicPhotos).toHaveBeenCalledWith('tok-feed', { locale: 'zh-CN' }); const cells = wrapper.findAll('.cell'); expect(cells).toHaveLength(3); - // 700 容器宽 → columnWidth 346;800x500 比例 → 条高 round(346*.625)=216。 - // 3 条:col0 [224,0] → p2 入 col1 → p3 入 col0(top 224)→ 容器高 224+216=440。 - expect(wrapper.find('.waterfall').attributes('style')).toContain('height: 440px'); - expect(cells[1].attributes('style')).toContain('left: 354px'); - expect(cells[2].attributes('style')).toContain('top: 224px'); + // 700 容器宽 → 三列 gap 6 → columnWidth 229;800x500 比例 → 条高 round(229*.625)=143。 + // 3 条各占一列(left 0/235/470,top 全 0)→ 容器高 143。 + expect(wrapper.find('.waterfall').attributes('style')).toContain('height: 143px'); + expect(cells[0].attributes('style')).toContain('left: 0px'); + expect(cells[1].attributes('style')).toContain('left: 235px'); + expect(cells[2].attributes('style')).toContain('left: 470px'); }); it('emits open with the photoId when a cell is clicked', async () => { @@ -270,4 +280,99 @@ describe('photo feed (Task 7 / Task 8 sequence props)', () => { expect(wrapper.findAll('.cell')).toHaveLength(3); expect(mockedGetPublicPhoto).toHaveBeenCalledTimes(1); }); + + // —— UI-02 Task 3:骨架占位 / 点赞角标 / 新照片提示 pill —— + + /** 从内联 style 提取 height("left: 0px; top: 0px; width: 229px; height: 305px")。 */ + function styleHeight(el: { attributes: (name: string) => unknown }): number { + const match = /height: (\d+)px/.exec(String(el.attributes('style') ?? '')); + if (match === null) throw new Error('height not found in style'); + return Number(match[1]); + } + + it('shows a waterfall-shaped skeleton while the first page loads, then swaps in cells', async () => { + let resolvePage: (list: PublicPhotoList) => void = () => {}; + mockedGetPublicPhotos.mockReturnValueOnce( + new Promise((resolve) => { + resolvePage = resolve; + }), + ); + const { wrapper } = mountFeed(); + // 等挂载后的 post watch 完成容器测量(clientWidth mock 700)再断言几何。 + await flushPromises(); + + // 骨架:占位盒按 skeletonSources 比例放置(700 → 三列 gap 6 → columnWidth 229: + // 3:4 → 305、4:3 → 172…),不是均匀网格;读屏另有隐藏 loading 文案。 + expect(wrapper.find('.waterfall.skeleton').exists()).toBe(true); + expect(wrapper.find('.sr-only[role="status"]').text()).toBe('加载中…'); + const boxes = wrapper.findAll('.cell-skeleton'); + expect(boxes).toHaveLength(12); + expect(styleHeight(boxes[0])).toBe(Math.round(229 * (4 / 3))); + expect(styleHeight(boxes[1])).toBe(Math.round(229 * (3 / 4))); + expect(new Set(boxes.map((box) => styleHeight(box))).size).toBeGreaterThan(1); + expect(wrapper.find('.cell').exists()).toBe(false); + + resolvePage(makeList([makePhoto('p1')], null)); + await flushPromises(); + expect(wrapper.find('.waterfall.skeleton').exists()).toBe(false); + expect(wrapper.findAll('.cell')).toHaveLength(1); + }); + + it('renders the like badge overlay with the real count and no card chrome', async () => { + const likes = { + displayFor: (photo: PublicPhoto) => photo.likeCount, + popNonce: () => 0, + like: vi.fn(), + } as unknown as LikesController; + mockedGetPublicPhotos.mockResolvedValueOnce( + makeList([{ ...makePhoto('p1'), likeCount: 1286 }, makePhoto('p2')], null), + ); + const { wrapper } = mountFeed({ likes }); + await flushPromises(); + + const badge = wrapper.find('.cell .cell-like'); + expect(badge.exists()).toBe(true); + expect(badge.find('.heart').exists()).toBe(true); + // 真实计数(useLikes displayFor 值)经 Intl 分组。 + expect(badge.find('.count').text()).toBe('1,286'); + // 无白色卡片包裹/无常驻文件名/无额外标题:单元格可见文本只有点赞计数 + //(图片 alt 非可见文本,懒加载绑定不影响该断言)。 + expect(wrapper.findAll('.cell')[0].text()).toBe('1,286'); + }); + + it('clicking the like badge likes the photo without opening the lightbox', async () => { + const like = vi.fn(); + const likes = { displayFor: () => 0, popNonce: () => 0, like } as unknown as LikesController; + const { wrapper, opened } = mountFeed({ likes }); + await flushPromises(); + + await wrapper.find('.cell-like').trigger('click'); + expect(like).toHaveBeenCalledWith('p1'); + expect(opened).toEqual([]); + }); + + it('shows the new-photo pill on hasNew; clicking refreshes without scrolling to top', async () => { + const { wrapper, sequence } = mountFeed(); + await flushPromises(); + expect(wrapper.find('.new-photo-bar').exists()).toBe(false); + + sequence.hasNew = true; + await nextTick(); + const pill = wrapper.find('.new-photo-bar'); + expect(pill.exists()).toBe(true); + // 接口只有 hasNew 布尔 → 文案不伪造数量(§4.3)。 + expect(pill.text()).toBe('有新照片'); + + const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}); + mockedGetPublicPhotos.mockResolvedValueOnce( + makeList([makePhoto('p9'), makePhoto('p1'), makePhoto('p2'), makePhoto('p3')], 'cur-1'), + ); + await pill.trigger('click'); + await flushPromises(); + expect(sequence.hasNew).toBe(false); + expect(wrapper.find('.new-photo-bar').exists()).toBe(false); + expect(sequence.photos.map((photo) => photo.photoId)).toEqual(['p9', 'p1', 'p2', 'p3']); + // 不强制回顶(UI-02 §4.3:用户浏览位置保持)。 + expect(scrollTo).not.toHaveBeenCalled(); + }); }); diff --git a/apps/viewer/src/__tests__/sequence.spec.ts b/apps/viewer/src/__tests__/sequence.spec.ts index a96e0af..8ca711a 100644 --- a/apps/viewer/src/__tests__/sequence.spec.ts +++ b/apps/viewer/src/__tests__/sequence.spec.ts @@ -250,7 +250,7 @@ describe('photo sequence polling (Task 8)', () => { vi.useRealTimers(); }); - it('raises the hint when page 1 contains an unknown id; refresh scrolls top', async () => { + it('raises the hint when page 1 contains an unknown id; refresh merges without scrolling top', async () => { const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}); const { wrapper, sequence } = mountSequence(); await flushPromises(); @@ -267,7 +267,8 @@ describe('photo sequence polling (Task 8)', () => { await flushPromises(); expect(sequence.hasNew).toBe(false); expect(sequence.photos.map((p) => p.photoId)).toEqual(['p9', 'p1', 'p2']); - expect(scrollTo).toHaveBeenCalledWith(0, 0); + // UI-02 §4.3:提示点击只加载合并,不强制回顶(用户浏览位置保持)。 + expect(scrollTo).not.toHaveBeenCalled(); wrapper.unmount(); }); diff --git a/apps/viewer/src/components/PhotoFeed.vue b/apps/viewer/src/components/PhotoFeed.vue index 18daacc..ae22f70 100644 --- a/apps/viewer/src/components/PhotoFeed.vue +++ b/apps/viewer/src/components/PhotoFeed.vue @@ -4,21 +4,27 @@ import { useI18n } from 'vue-i18n'; import type { PublicPhoto } from '../api/client'; import type { PhotoSequence } from '../feed/sequence'; import type { LikesController } from '../likes/useLikes'; -import { layoutWaterfall } from '../feed/layout'; +import { layoutWaterfall, responsiveColumns, skeletonSources } from '../feed/layout'; -// 观众瀑布流(Task 7;Task 8 起数据层上提):双列绝对定位(短列优先,条目高按 -// browse 宽高比例预留)+ IntersectionObserver 懒加载 + 哨兵无限滚动 + 新照片提示条。 -// 数据生命周期(分页/去重/轮询/深链 solo)在 ActivityPage 创建的 PhotoSequence -// composable(feed/sequence.ts)——feed 与大图查看器共享同一序列,切换大图时 -// 保持浏览顺序、越过末尾自动扩展。 +// 观众瀑布流(Task 7;Task 8 起数据层上提;UI-02 Task 3 视觉重排):响应式列数 +// 绝对定位(短列优先,条目高按 browse 宽高比例预留)+ IntersectionObserver 懒加载 +// + 哨兵无限滚动 + 新照片白色提示 pill。数据生命周期(分页/去重/轮询/深链 solo) +// 在 ActivityPage 创建的 PhotoSequence composable(feed/sequence.ts)——feed 与 +// 大图查看器共享同一序列,切换大图时保持浏览顺序、越过末尾自动扩展。 // -// 关键决策(沿用 Task 7): +// 关键决策(沿用 Task 7 / UI-02 §4.3): +// · 响应式列数:responsiveColumns(容器宽)——<600 双列 gap4 / 600-959 三列 gap6 / +// ≥960 四列 gap8;ResizeObserver 跟踪容器宽变化后整体重排(确定性全量重算); +// · 卡片无白色厚包裹/无常驻文件名:图片边到边 + 4px 圆角,右下角点赞角标 +// (半透明暗底,明亮照片上可读);点赞角标 @click.stop 独立交互不冒泡开大图; // · 懒加载 = 仅当单元格接近视口才绑定 ;shownIds 集合跨刷新保留—— // 刷新只换签名 URL,同 id 单元格不必重新判定;IO 不可用(旧环境/jsdom)时 -// 退化为立即加载,功能不缺失。 +// 退化为立即加载,功能不缺失; // · 图片 onerror 重试一次:经 sequence.refreshPhotoUrls 换新签名 URL 再换绑; -// 再失败(或照片已下线)则收起该图(占位底色仍在,布局因高度预留不跳动)。 -// · 单元格点击 emit('open', photoId) → ActivityPage 打开大图(Task 8)。 +// 再失败(或照片已下线)则收起该图(占位底色仍在,布局因高度预留不跳动); +// · 单元格点击 emit('open', photoId) → ActivityPage 打开大图(Task 8); +// · 首屏骨架:以常见竖/横/方比例(skeletonSources)按真实瀑布流放置预留轮廓, +// 非均匀网格;宽高异常同样走 4:3 安全比例(无 NaN/无限高度)。 const props = defineProps<{ sequence: PhotoSequence; @@ -30,7 +36,7 @@ const emit = defineEmits<{ open: [photoId: string]; }>(); -const { t } = useI18n(); +const { t, locale } = useI18n(); const photos = computed(() => props.sequence.photos); const initialLoading = computed(() => props.sequence.initialLoading); @@ -45,13 +51,27 @@ const shownIds = reactive(new Set()); const retriedIds = reactive(new Set()); const failedIds = reactive(new Set()); -// —— 布局:容器宽 → 纯函数双列放置(ResizeObserver 跟踪容器宽度变化) —— +// —— 布局:容器宽 → 响应式列配置 → 纯函数放置(ResizeObserver 跟踪宽度变化) —— const trackEl = ref(null); const sentinelEl = ref(null); const containerWidth = ref(0); -const layout = computed(() => layoutWaterfall(photos.value, containerWidth.value)); +const columns = computed(() => responsiveColumns(containerWidth.value)); +const layout = computed(() => + layoutWaterfall(photos.value, containerWidth.value, columns.value), +); +const skeletonLayout = computed(() => + layoutWaterfall(skeletonSources(), containerWidth.value, columns.value), +); + +const countFormatter = computed(() => new Intl.NumberFormat(locale.value)); + +/** 点赞角标计数(真实值:base + 乐观 pending,见 useLikes)。 */ +function likeCountText(photo: PublicPhoto): string { + if (props.likes === undefined) return ''; + return countFormatter.value.format(props.likes.displayFor(photo)); +} let resizeObserver: ResizeObserver | null = null; @@ -171,16 +191,42 @@ onBeforeUnmount(() => {