feat(viewer): UI-02 responsive waterfall with like badges

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 <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-21 21:34:56 +08:00
parent 9c3915b387
commit 3d89fcdb99
6 changed files with 465 additions and 99 deletions
+80 -4
View File
@@ -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(
+116 -11
View File
@@ -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: `<PhotoFeed :sequence="sequence" @open="onOpen" />`,
template: `<PhotoFeed :sequence="sequence" :likes="likes" @open="onOpen" />`,
});
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<PublicPhotoList>((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();
});
});
+3 -2
View File
@@ -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();
});