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:
@@ -3,9 +3,12 @@ import {
|
|||||||
containsNewPhotoId,
|
containsNewPhotoId,
|
||||||
layoutWaterfall,
|
layoutWaterfall,
|
||||||
mergeUniqueByPhotoId,
|
mergeUniqueByPhotoId,
|
||||||
|
responsiveColumns,
|
||||||
|
skeletonSources,
|
||||||
} from '../feed/layout';
|
} from '../feed/layout';
|
||||||
|
|
||||||
// Task 7 瀑布流纯函数层:列放置/高度预留/去重/新照片判定(组件行为在 feed.spec)。
|
// Task 7 瀑布流纯函数层:列放置/高度预留/去重/新照片判定(组件行为在 feed.spec)。
|
||||||
|
// UI-02 Task 3:响应式列数断点、4:3 安全比例、首屏骨架占位源。
|
||||||
// 数字断言手算依据:containerWidth 320、gap 8、双列 → columnWidth = floor(312/2) = 156。
|
// 数字断言手算依据:containerWidth 320、gap 8、双列 → columnWidth = floor(312/2) = 156。
|
||||||
|
|
||||||
describe('layoutWaterfall (pure placement)', () => {
|
describe('layoutWaterfall (pure placement)', () => {
|
||||||
@@ -46,10 +49,24 @@ describe('layoutWaterfall (pure placement)', () => {
|
|||||||
expect(layout.height).toBe(320);
|
expect(layout.height).toBe(320);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to a square placeholder for degenerate dimensions (no NaN)', () => {
|
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 }], 320);
|
const layout = layoutWaterfall(
|
||||||
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);
|
{ 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', () => {
|
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)', () => {
|
describe('mergeUniqueByPhotoId (page append dedupe)', () => {
|
||||||
it('appends only unseen photoIds and preserves order', () => {
|
it('appends only unseen photoIds and preserves order', () => {
|
||||||
const merged = mergeUniqueByPhotoId(
|
const merged = mergeUniqueByPhotoId(
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils';
|
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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import {
|
import {
|
||||||
ApiError,
|
ApiError,
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type PublicPhotoList,
|
type PublicPhotoList,
|
||||||
} from '../api/client';
|
} from '../api/client';
|
||||||
import { createViewerI18n, type ViewerI18n } from '../i18n';
|
import { createViewerI18n, type ViewerI18n } from '../i18n';
|
||||||
|
import type { LikesController } from '../likes/useLikes';
|
||||||
import PhotoFeed from '../components/PhotoFeed.vue';
|
import PhotoFeed from '../components/PhotoFeed.vue';
|
||||||
import { usePhotoSequence, type PhotoSequence } from '../feed/sequence';
|
import { usePhotoSequence, type PhotoSequence } from '../feed/sequence';
|
||||||
|
|
||||||
@@ -113,8 +114,11 @@ function makeList(items: PublicPhoto[], nextCursor: string | null = null): Publi
|
|||||||
let i18n: ViewerI18n;
|
let i18n: ViewerI18n;
|
||||||
const mounted: VueWrapper[] = [];
|
const mounted: VueWrapper[] = [];
|
||||||
|
|
||||||
/** 宿主:创建 sequence 并渲染 PhotoFeed(镜像 ActivityPage 组装);捕获 open 事件。 */
|
/** 宿主:创建 sequence 并渲染 PhotoFeed(镜像 ActivityPage 组装);捕获 open 事件。
|
||||||
function mountFeed(props: { token?: string; locale?: string; category?: string | null } = {}): {
|
* likes 可注入替身控制器(组件级点赞角标测试,不拉真实批次端点)。 */
|
||||||
|
function mountFeed(
|
||||||
|
props: { token?: string; locale?: string; category?: string | null; likes?: LikesController } = {},
|
||||||
|
): {
|
||||||
wrapper: VueWrapper;
|
wrapper: VueWrapper;
|
||||||
sequence: PhotoSequence;
|
sequence: PhotoSequence;
|
||||||
opened: string[];
|
opened: string[];
|
||||||
@@ -128,9 +132,13 @@ function mountFeed(props: { token?: string; locale?: string; category?: string |
|
|||||||
locale: computed(() => props.locale ?? 'zh-CN'),
|
locale: computed(() => props.locale ?? 'zh-CN'),
|
||||||
category: computed(() => props.category ?? null),
|
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] } });
|
const wrapper = mount(Host, { global: { plugins: [i18n] } });
|
||||||
mounted.push(wrapper);
|
mounted.push(wrapper);
|
||||||
@@ -145,7 +153,8 @@ describe('photo feed (Task 7 / Task 8 sequence props)', () => {
|
|||||||
FakeIntersectionObserver.instances = [];
|
FakeIntersectionObserver.instances = [];
|
||||||
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver);
|
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver);
|
||||||
vi.stubGlobal('ResizeObserver', FakeResizeObserver);
|
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')
|
const proto = Object.prototype.hasOwnProperty.call(HTMLElement.prototype, 'clientWidth')
|
||||||
? HTMLElement.prototype
|
? HTMLElement.prototype
|
||||||
: Element.prototype;
|
: Element.prototype;
|
||||||
@@ -169,11 +178,12 @@ describe('photo feed (Task 7 / Task 8 sequence props)', () => {
|
|||||||
expect(mockedGetPublicPhotos).toHaveBeenCalledWith('tok-feed', { locale: 'zh-CN' });
|
expect(mockedGetPublicPhotos).toHaveBeenCalledWith('tok-feed', { locale: 'zh-CN' });
|
||||||
const cells = wrapper.findAll('.cell');
|
const cells = wrapper.findAll('.cell');
|
||||||
expect(cells).toHaveLength(3);
|
expect(cells).toHaveLength(3);
|
||||||
// 700 容器宽 → columnWidth 346;800x500 比例 → 条高 round(346*.625)=216。
|
// 700 容器宽 → 三列 gap 6 → columnWidth 229;800x500 比例 → 条高 round(229*.625)=143。
|
||||||
// 3 条:col0 [224,0] → p2 入 col1 → p3 入 col0(top 224)→ 容器高 224+216=440。
|
// 3 条各占一列(left 0/235/470,top 全 0)→ 容器高 143。
|
||||||
expect(wrapper.find('.waterfall').attributes('style')).toContain('height: 440px');
|
expect(wrapper.find('.waterfall').attributes('style')).toContain('height: 143px');
|
||||||
expect(cells[1].attributes('style')).toContain('left: 354px');
|
expect(cells[0].attributes('style')).toContain('left: 0px');
|
||||||
expect(cells[2].attributes('style')).toContain('top: 224px');
|
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 () => {
|
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(wrapper.findAll('.cell')).toHaveLength(3);
|
||||||
expect(mockedGetPublicPhoto).toHaveBeenCalledTimes(1);
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -250,7 +250,7 @@ describe('photo sequence polling (Task 8)', () => {
|
|||||||
vi.useRealTimers();
|
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 scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
|
||||||
const { wrapper, sequence } = mountSequence();
|
const { wrapper, sequence } = mountSequence();
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
@@ -267,7 +267,8 @@ describe('photo sequence polling (Task 8)', () => {
|
|||||||
await flushPromises();
|
await flushPromises();
|
||||||
expect(sequence.hasNew).toBe(false);
|
expect(sequence.hasNew).toBe(false);
|
||||||
expect(sequence.photos.map((p) => p.photoId)).toEqual(['p9', 'p1', 'p2']);
|
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();
|
wrapper.unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,21 +4,27 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import type { PublicPhoto } from '../api/client';
|
import type { PublicPhoto } from '../api/client';
|
||||||
import type { PhotoSequence } from '../feed/sequence';
|
import type { PhotoSequence } from '../feed/sequence';
|
||||||
import type { LikesController } from '../likes/useLikes';
|
import type { LikesController } from '../likes/useLikes';
|
||||||
import { layoutWaterfall } from '../feed/layout';
|
import { layoutWaterfall, responsiveColumns, skeletonSources } from '../feed/layout';
|
||||||
|
|
||||||
// 观众瀑布流(Task 7;Task 8 起数据层上提):双列绝对定位(短列优先,条目高按
|
// 观众瀑布流(Task 7;Task 8 起数据层上提;UI-02 Task 3 视觉重排):响应式列数
|
||||||
// browse 宽高比例预留)+ IntersectionObserver 懒加载 + 哨兵无限滚动 + 新照片提示条。
|
// 绝对定位(短列优先,条目高按 browse 宽高比例预留)+ IntersectionObserver 懒加载
|
||||||
// 数据生命周期(分页/去重/轮询/深链 solo)在 ActivityPage 创建的 PhotoSequence
|
// + 哨兵无限滚动 + 新照片白色提示 pill。数据生命周期(分页/去重/轮询/深链 solo)
|
||||||
// composable(feed/sequence.ts)——feed 与大图查看器共享同一序列,切换大图时
|
// 在 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 独立交互不冒泡开大图;
|
||||||
// · 懒加载 = 仅当单元格接近视口才绑定 <img src>;shownIds 集合跨刷新保留——
|
// · 懒加载 = 仅当单元格接近视口才绑定 <img src>;shownIds 集合跨刷新保留——
|
||||||
// 刷新只换签名 URL,同 id 单元格不必重新判定;IO 不可用(旧环境/jsdom)时
|
// 刷新只换签名 URL,同 id 单元格不必重新判定;IO 不可用(旧环境/jsdom)时
|
||||||
// 退化为立即加载,功能不缺失。
|
// 退化为立即加载,功能不缺失;
|
||||||
// · 图片 onerror 重试一次:经 sequence.refreshPhotoUrls 换新签名 URL 再换绑;
|
// · 图片 onerror 重试一次:经 sequence.refreshPhotoUrls 换新签名 URL 再换绑;
|
||||||
// 再失败(或照片已下线)则收起该图(占位底色仍在,布局因高度预留不跳动)。
|
// 再失败(或照片已下线)则收起该图(占位底色仍在,布局因高度预留不跳动);
|
||||||
// · 单元格点击 emit('open', photoId) → ActivityPage 打开大图(Task 8)。
|
// · 单元格点击 emit('open', photoId) → ActivityPage 打开大图(Task 8);
|
||||||
|
// · 首屏骨架:以常见竖/横/方比例(skeletonSources)按真实瀑布流放置预留轮廓,
|
||||||
|
// 非均匀网格;宽高异常同样走 4:3 安全比例(无 NaN/无限高度)。
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
sequence: PhotoSequence;
|
sequence: PhotoSequence;
|
||||||
@@ -30,7 +36,7 @@ const emit = defineEmits<{
|
|||||||
open: [photoId: string];
|
open: [photoId: string];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
|
|
||||||
const photos = computed(() => props.sequence.photos);
|
const photos = computed(() => props.sequence.photos);
|
||||||
const initialLoading = computed(() => props.sequence.initialLoading);
|
const initialLoading = computed(() => props.sequence.initialLoading);
|
||||||
@@ -45,13 +51,27 @@ const shownIds = reactive(new Set<string>());
|
|||||||
const retriedIds = reactive(new Set<string>());
|
const retriedIds = reactive(new Set<string>());
|
||||||
const failedIds = reactive(new Set<string>());
|
const failedIds = reactive(new Set<string>());
|
||||||
|
|
||||||
// —— 布局:容器宽 → 纯函数双列放置(ResizeObserver 跟踪容器宽度变化) ——
|
// —— 布局:容器宽 → 响应式列配置 → 纯函数放置(ResizeObserver 跟踪宽度变化) ——
|
||||||
|
|
||||||
const trackEl = ref<HTMLElement | null>(null);
|
const trackEl = ref<HTMLElement | null>(null);
|
||||||
const sentinelEl = ref<HTMLElement | null>(null);
|
const sentinelEl = ref<HTMLElement | null>(null);
|
||||||
const containerWidth = ref(0);
|
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;
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
|
|
||||||
@@ -171,16 +191,42 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="photo-feed">
|
<div class="photo-feed">
|
||||||
<button v-if="hasNew" type="button" class="new-photo-bar" @click="props.sequence.refreshFromHint()">
|
<!-- 新照片提示 pill(UI-02 §4.3):接口只有 hasNew 布尔——不显示伪造数量;
|
||||||
{{ t('album.newPhotos') }}
|
点击加载合并,不强制回顶。sticky 悬浮于照片区顶部。 -->
|
||||||
|
<button
|
||||||
|
v-if="hasNew"
|
||||||
|
type="button"
|
||||||
|
class="new-photo-bar"
|
||||||
|
@click="props.sequence.refreshFromHint()"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M12 19V5" />
|
||||||
|
<path d="m5 12 7-7 7 7" />
|
||||||
|
</svg>
|
||||||
|
<span>{{ t('viewer.newPhotos') }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p v-if="initialLoading" class="status">{{ t('album.loading') }}</p>
|
<!-- 首屏骨架:按真实瀑布流比例占位(非均匀网格)。trackEl 与真实瀑布流
|
||||||
|
共用(v-if 两分支同时只存在一个,ref watch 处理换锚);读屏文案走
|
||||||
<div v-else-if="initialErrorKey !== null" class="error" role="alert">
|
视觉隐藏的 status。 -->
|
||||||
<p>{{ t(initialErrorKey) }}</p>
|
<p v-if="initialLoading" class="sr-only" role="status">{{ t('album.loading') }}</p>
|
||||||
<button type="button" @click="props.sequence.resetAndLoad">{{ t('album.retry') }}</button>
|
<div v-if="initialLoading" ref="trackEl" class="waterfall skeleton" aria-hidden="true">
|
||||||
|
<div
|
||||||
|
v-for="placement in skeletonLayout.placements"
|
||||||
|
:key="placement.index"
|
||||||
|
class="cell-skeleton"
|
||||||
|
:style="{
|
||||||
|
left: `${placement.left}px`,
|
||||||
|
top: `${placement.top}px`,
|
||||||
|
width: `${skeletonLayout.columnWidth}px`,
|
||||||
|
height: `${placement.height}px`,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-else-if="initialErrorKey !== null" class="error" role="alert">
|
||||||
|
{{ t(initialErrorKey) }}
|
||||||
|
<button type="button" @click="props.sequence.resetAndLoad">{{ t('album.retry') }}</button>
|
||||||
|
</p>
|
||||||
|
|
||||||
<div v-else-if="photos.length === 0" class="feed">
|
<div v-else-if="photos.length === 0" class="feed">
|
||||||
<p class="empty-notice">{{ t('album.emptyNotice') }}</p>
|
<p class="empty-notice">{{ t('album.emptyNotice') }}</p>
|
||||||
@@ -210,6 +256,7 @@ onBeforeUnmount(() => {
|
|||||||
decoding="async"
|
decoding="async"
|
||||||
@error="onImgError(photos[placement.index])"
|
@error="onImgError(photos[placement.index])"
|
||||||
>
|
>
|
||||||
|
<!-- 点赞角标:独立交互区(@click.stop),不随图片点击打开大图。 -->
|
||||||
<button
|
<button
|
||||||
v-if="likes !== undefined"
|
v-if="likes !== undefined"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -217,8 +264,16 @@ onBeforeUnmount(() => {
|
|||||||
:aria-label="t('viewer.like')"
|
:aria-label="t('viewer.like')"
|
||||||
@click.stop="likeCell(photos[placement.index])"
|
@click.stop="likeCell(photos[placement.index])"
|
||||||
>
|
>
|
||||||
<span :key="likes.popNonce(photos[placement.index].photoId)" class="heart" aria-hidden="true">♥</span>
|
<svg
|
||||||
<span class="count">{{ likes.displayFor(photos[placement.index]) }}</span>
|
:key="likes.popNonce(photos[placement.index].photoId)"
|
||||||
|
class="heart"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
focusable="false"
|
||||||
|
>
|
||||||
|
<path d="M12 21.35 10.55 20.03C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35Z" />
|
||||||
|
</svg>
|
||||||
|
<span class="count">{{ likeCountText(photos[placement.index]) }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -235,65 +290,71 @@ onBeforeUnmount(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* 极简基线样式:视觉打磨留待 P4a。 */
|
/* UI-02 Task 3(§4.3):token 经祖先 .viewer-shell 提供;独立挂载(无 shell)
|
||||||
|
* 时回退同值。照片区边到边,仅 4px 外沿内边距(--viewer-pad-photo)。 */
|
||||||
.photo-feed {
|
.photo-feed {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
padding: var(--viewer-pad-photo, 4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 加载状态仅骨架可见时供读屏(视觉由骨架承载)。 */
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
margin: -1px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
clip: rect(0 0 0 0);
|
||||||
|
clip-path: inset(50%);
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* —— 新照片提示 pill:白底蓝字蓝icon,悬浮照片区顶部 —— */
|
||||||
.new-photo-bar {
|
.new-photo-bar {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 10px;
|
||||||
z-index: 1;
|
z-index: 2;
|
||||||
display: block;
|
display: flex;
|
||||||
width: 100%;
|
align-items: center;
|
||||||
margin-bottom: 12px;
|
gap: 6px;
|
||||||
border: 1px solid #1d4ed8;
|
width: fit-content;
|
||||||
border-radius: 8px;
|
margin: 0 auto 12px;
|
||||||
background: #eff6ff;
|
border: none;
|
||||||
color: #1d4ed8;
|
border-radius: var(--viewer-radius-pill, 20px);
|
||||||
padding: 8px 12px;
|
background: var(--viewer-surface, #fff);
|
||||||
font-size: 0.875rem;
|
box-shadow: 0 2px 10px rgba(23, 32, 51, 0.16);
|
||||||
|
color: var(--viewer-primary, #1a73e8);
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.status {
|
.new-photo-bar svg {
|
||||||
color: #6b7280;
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 2.2;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
}
|
}
|
||||||
.more-status {
|
.new-photo-bar:focus-visible {
|
||||||
text-align: center;
|
outline: 2px solid var(--viewer-primary, #1a73e8);
|
||||||
padding: 8px 0;
|
outline-offset: 2px;
|
||||||
}
|
|
||||||
.error {
|
|
||||||
color: #b91c1c;
|
|
||||||
}
|
|
||||||
.error button,
|
|
||||||
.more-failed button {
|
|
||||||
margin-top: 8px;
|
|
||||||
border: 1px solid #d1d5db;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: #fff;
|
|
||||||
padding: 4px 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.more-failed {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.feed {
|
|
||||||
border: 1px dashed #e5e7eb;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 48px 16px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.empty-notice {
|
|
||||||
margin: 0;
|
|
||||||
color: #9ca3af;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.waterfall {
|
.waterfall {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* —— 照片卡片:无白色厚卡片/无文件名;图片边到边 + 4px 圆角 —— */
|
||||||
.cell {
|
.cell {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border-radius: 6px;
|
border-radius: var(--viewer-radius-img, 4px);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: linear-gradient(160deg, #f3f4f6, #e5e7eb);
|
background: var(--viewer-divider, #e5eaf1);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.cell-img {
|
.cell-img {
|
||||||
@@ -302,27 +363,32 @@ onBeforeUnmount(() => {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* —— 点赞角标:右下角半透明暗底(明亮照片上可读),白心 + 白色计数 —— */
|
||||||
.cell-like {
|
.cell-like {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 6px;
|
right: 6px;
|
||||||
bottom: 6px;
|
bottom: 6px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 999px;
|
border-radius: var(--viewer-radius-pill, 20px);
|
||||||
background: rgba(17, 24, 39, 0.65);
|
background: linear-gradient(to right, rgba(14, 18, 24, 0.42), rgba(14, 18, 24, 0.62));
|
||||||
color: #f9fafb;
|
color: #fff;
|
||||||
padding: 2px 8px;
|
padding: 3px 8px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
|
line-height: 1;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.cell-like .heart {
|
.cell-like .heart {
|
||||||
color: #ef4444;
|
width: 16px;
|
||||||
display: inline-block;
|
height: 16px;
|
||||||
|
fill: #fff;
|
||||||
animation: heart-pop 400ms ease-out;
|
animation: heart-pop 400ms ease-out;
|
||||||
}
|
}
|
||||||
.cell-like .count {
|
.cell-like .count {
|
||||||
|
color: #fff;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
@keyframes heart-pop {
|
@keyframes heart-pop {
|
||||||
@@ -339,9 +405,63 @@ onBeforeUnmount(() => {
|
|||||||
.viewer-reduced-motion .cell-like .heart {
|
.viewer-reduced-motion .cell-like .heart {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* —— 首屏骨架:真实瀑布流轮廓(比例来自 skeletonSources),轻呼吸动画 —— */
|
||||||
|
.cell-skeleton {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: var(--viewer-radius-img, 4px);
|
||||||
|
background: var(--viewer-divider, #e5eaf1);
|
||||||
|
animation: skeleton-pulse 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes skeleton-pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.viewer-reduced-motion .cell-skeleton {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* —— 空态/错误/翻页尾部 —— */
|
||||||
|
.feed {
|
||||||
|
padding: 48px 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.empty-notice {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--viewer-text-secondary, #647084);
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
.error button,
|
||||||
|
.more-failed button {
|
||||||
|
margin-top: 8px;
|
||||||
|
border: 1px solid var(--viewer-divider, #e5eaf1);
|
||||||
|
border-radius: var(--viewer-radius-pill, 20px);
|
||||||
|
background: var(--viewer-surface, #fff);
|
||||||
|
color: var(--viewer-primary, #1a73e8);
|
||||||
|
padding: 8px 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.more-failed {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
color: var(--viewer-text-secondary, #647084);
|
||||||
|
}
|
||||||
|
.more-status {
|
||||||
|
text-align: center;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
.end-note {
|
.end-note {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #9ca3af;
|
color: var(--viewer-text-secondary, #647084);
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
padding: 12px 0;
|
padding: 12px 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
// apps/viewer/src/feed/layout.ts —— 瀑布流纯函数层(Task 7):双列绝对定位布局
|
// apps/viewer/src/feed/layout.ts —— 瀑布流纯函数层(Task 7):绝对定位布局
|
||||||
// 与列表纯数据操作。抽出为无 DOM 依赖的模块以便 vitest 直接覆盖放置/去重逻辑。
|
// 与列表纯数据操作。抽出为无 DOM 依赖的模块以便 vitest 直接覆盖放置/去重逻辑。
|
||||||
//
|
//
|
||||||
// 布局模型(选型见计划 Task 7 Step 1:绝对定位而非 CSS columns——滚动位置可
|
// 布局模型(选型见计划 Task 7 Step 1:绝对定位而非 CSS columns——滚动位置可
|
||||||
// 恢复、条目高按 width/height 比例预留):
|
// 恢复、条目高按 width/height 比例预留):
|
||||||
// · 列宽 = (容器宽 - (列数-1)*列间距) / 列数,向下取整;
|
// · 列宽 = (容器宽 - (列数-1)*列间距) / 列数,向下取整;
|
||||||
// · 条目高 = round(列宽 * height/width)(宽高来自 browse 变体,服务端必返);
|
// · 条目高 = round(列宽 * height/width)(宽高来自 browse 变体,服务端必返);
|
||||||
// 宽高非正的脏数据回退正方形容高(不产生 NaN/0 塌陷);
|
// 宽高缺失/非正/非有限(NaN、Infinity)回退 4:3 安全占位比例(UI-02 §4.3,
|
||||||
|
// 不产生 NaN/无限高度);
|
||||||
// · 放置规则:总是放入当前「最矮」列,同高取索引小者(确定性输出);
|
// · 放置规则:总是放入当前「最矮」列,同高取索引小者(确定性输出);
|
||||||
// · 容器高 = 最高列去掉末尾间距(间距只在条目之间生效)。
|
// · 容器高 = 最高列去掉末尾间距(间距只在条目之间生效)。
|
||||||
|
//
|
||||||
|
// UI-02 Task 3:列数/间距随容器宽响应(responsiveColumns)——<600px 双列
|
||||||
|
// gap 4、600-959px 三列 gap 6、≥960px 四列 gap 8(桌面主内容限宽 1120px)。
|
||||||
|
|
||||||
/** 布局输入:仅需宽高(PublicPhoto 满足)。 */
|
/** 布局输入:仅需宽高(PublicPhoto 满足)。 */
|
||||||
export interface WaterfallSource {
|
export interface WaterfallSource {
|
||||||
@@ -35,8 +39,38 @@ export interface WaterfallLayout {
|
|||||||
export const WATERFALL_COLUMNS = 2;
|
export const WATERFALL_COLUMNS = 2;
|
||||||
export const WATERFALL_GAP = 8;
|
export const WATERFALL_GAP = 8;
|
||||||
|
|
||||||
|
/** 宽高缺失/异常时的安全占位比例(高/宽 = 3/4,UI-02 §4.3)。 */
|
||||||
|
export const WATERFALL_FALLBACK_RATIO = 3 / 4;
|
||||||
|
|
||||||
|
/** 响应式列配置(UI-02 §4.3 断点)。 */
|
||||||
|
export interface ResponsiveColumns {
|
||||||
|
columns: number;
|
||||||
|
gap: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 双列(可推广 N 列)瀑布流布局:逐条放入最矮列。O(n) 全量重算——分页追加
|
* 容器宽 → 列数/列间距(UI-02 Task 3):
|
||||||
|
* <600px 双列 gap 4;600-959px 三列 gap 6;≥960px 四列 gap 8。
|
||||||
|
* 以容器(而非 window)宽判定——桌面限宽 1120px 的主内容列直接取实际宽。
|
||||||
|
*/
|
||||||
|
export function responsiveColumns(containerWidth: number): ResponsiveColumns {
|
||||||
|
if (containerWidth < 600) return { columns: 2, gap: 4 };
|
||||||
|
if (containerWidth < 960) return { columns: 3, gap: 6 };
|
||||||
|
return { columns: 4, gap: 8 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 有效宽高比:非正/非有限值回退 4:3 安全比例(不产生 NaN/Infinity 高度)。 */
|
||||||
|
function aspectRatioOf(item: WaterfallSource): number {
|
||||||
|
const valid =
|
||||||
|
Number.isFinite(item.width) &&
|
||||||
|
Number.isFinite(item.height) &&
|
||||||
|
item.width > 0 &&
|
||||||
|
item.height > 0;
|
||||||
|
return valid ? item.height / item.width : WATERFALL_FALLBACK_RATIO;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* N 列瀑布流布局:逐条放入最矮列。O(n) 全量重算——分页追加
|
||||||
* 也整体重算(n 为已加载条数,P1 量级下开销可忽略),换来 resize/刷新的确定性。
|
* 也整体重算(n 为已加载条数,P1 量级下开销可忽略),换来 resize/刷新的确定性。
|
||||||
*/
|
*/
|
||||||
export function layoutWaterfall(
|
export function layoutWaterfall(
|
||||||
@@ -55,9 +89,12 @@ export function layoutWaterfall(
|
|||||||
const placements: WaterfallPlacement[] = [];
|
const placements: WaterfallPlacement[] = [];
|
||||||
|
|
||||||
items.forEach((item, index) => {
|
items.forEach((item, index) => {
|
||||||
const ratio =
|
const rounded = Math.round(columnWidth * aspectRatioOf(item));
|
||||||
item.width > 0 && item.height > 0 ? item.height / item.width : 1;
|
// 溢出(极小宽×极大高 → Infinity)或塌陷(0)都回退安全比例高度。
|
||||||
const height = Math.max(1, Math.round(columnWidth * ratio));
|
const height =
|
||||||
|
Number.isFinite(rounded) && rounded >= 1
|
||||||
|
? rounded
|
||||||
|
: Math.max(1, Math.round(columnWidth * WATERFALL_FALLBACK_RATIO));
|
||||||
let column = 0;
|
let column = 0;
|
||||||
for (let c = 1; c < columns; c += 1) {
|
for (let c = 1; c < columns; c += 1) {
|
||||||
if (columnHeights[c] < columnHeights[column]) column = c;
|
if (columnHeights[c] < columnHeights[column]) column = c;
|
||||||
@@ -77,6 +114,33 @@ export function layoutWaterfall(
|
|||||||
return { placements, columnWidth, height };
|
return { placements, columnWidth, height };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 骨架占位比例序列(UI-02 §4.3):常见竖/横/方混排,近似真实瀑布流轮廓。 */
|
||||||
|
const SKELETON_ASPECTS: ReadonlyArray<readonly [width: number, height: number]> = [
|
||||||
|
[3, 4],
|
||||||
|
[4, 3],
|
||||||
|
[1, 1],
|
||||||
|
[3, 2],
|
||||||
|
[4, 5],
|
||||||
|
[2, 3],
|
||||||
|
[1, 1],
|
||||||
|
[4, 3],
|
||||||
|
[3, 2],
|
||||||
|
[3, 4],
|
||||||
|
[1, 1],
|
||||||
|
[4, 5],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 首屏骨架占位源:按 SKELETON_ASPECTS 循环生成(非均匀网格)。真实照片
|
||||||
|
* 到达后由服务端宽高接管;骨架仅用于 initialLoading 期间的轮廓预留。
|
||||||
|
*/
|
||||||
|
export function skeletonSources(count = SKELETON_ASPECTS.length): WaterfallSource[] {
|
||||||
|
return Array.from({ length: count }, (_, index) => {
|
||||||
|
const [width, height] = SKELETON_ASPECTS[index % SKELETON_ASPECTS.length];
|
||||||
|
return { width, height };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** 追加分页结果时按 photoId 去重(防御:游标边界漂移、轮询与分页竞争)。 */
|
/** 追加分页结果时按 photoId 去重(防御:游标边界漂移、轮询与分页竞争)。 */
|
||||||
export function mergeUniqueByPhotoId<T extends { photoId: string }>(
|
export function mergeUniqueByPhotoId<T extends { photoId: string }>(
|
||||||
existing: readonly T[],
|
existing: readonly T[],
|
||||||
|
|||||||
@@ -133,11 +133,11 @@ export function usePhotoSequence(inputs: PhotoSequenceInputs) {
|
|||||||
return inflightMore;
|
return inflightMore;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 新照片提示条点击:整表刷新回第 1 页并回顶(提示消失)。 */
|
/** 新照片提示条点击:整表刷新回第 1 页(提示消失)。不强制回顶——用户浏览
|
||||||
|
* 位置保持(UI-02 §4.3「不在用户浏览时强制跳到顶部」)。 */
|
||||||
async function refreshFromHint(): Promise<void> {
|
async function refreshFromHint(): Promise<void> {
|
||||||
if (initialLoading.value) return;
|
if (initialLoading.value) return;
|
||||||
await resetAndLoad();
|
await resetAndLoad();
|
||||||
window.scrollTo(0, 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function indexOf(photoId: string): number {
|
function indexOf(photoId: string): number {
|
||||||
|
|||||||
Reference in New Issue
Block a user