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:
@@ -1,13 +1,17 @@
|
||||
// apps/viewer/src/feed/layout.ts —— 瀑布流纯函数层(Task 7):双列绝对定位布局
|
||||
// apps/viewer/src/feed/layout.ts —— 瀑布流纯函数层(Task 7):绝对定位布局
|
||||
// 与列表纯数据操作。抽出为无 DOM 依赖的模块以便 vitest 直接覆盖放置/去重逻辑。
|
||||
//
|
||||
// 布局模型(选型见计划 Task 7 Step 1:绝对定位而非 CSS columns——滚动位置可
|
||||
// 恢复、条目高按 width/height 比例预留):
|
||||
// · 列宽 = (容器宽 - (列数-1)*列间距) / 列数,向下取整;
|
||||
// · 条目高 = 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 满足)。 */
|
||||
export interface WaterfallSource {
|
||||
@@ -35,8 +39,38 @@ export interface WaterfallLayout {
|
||||
export const WATERFALL_COLUMNS = 2;
|
||||
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/刷新的确定性。
|
||||
*/
|
||||
export function layoutWaterfall(
|
||||
@@ -55,9 +89,12 @@ export function layoutWaterfall(
|
||||
const placements: WaterfallPlacement[] = [];
|
||||
|
||||
items.forEach((item, index) => {
|
||||
const ratio =
|
||||
item.width > 0 && item.height > 0 ? item.height / item.width : 1;
|
||||
const height = Math.max(1, Math.round(columnWidth * ratio));
|
||||
const rounded = Math.round(columnWidth * aspectRatioOf(item));
|
||||
// 溢出(极小宽×极大高 → Infinity)或塌陷(0)都回退安全比例高度。
|
||||
const height =
|
||||
Number.isFinite(rounded) && rounded >= 1
|
||||
? rounded
|
||||
: Math.max(1, Math.round(columnWidth * WATERFALL_FALLBACK_RATIO));
|
||||
let column = 0;
|
||||
for (let c = 1; c < columns; c += 1) {
|
||||
if (columnHeights[c] < columnHeights[column]) column = c;
|
||||
@@ -77,6 +114,33 @@ export function layoutWaterfall(
|
||||
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 去重(防御:游标边界漂移、轮询与分页竞争)。 */
|
||||
export function mergeUniqueByPhotoId<T extends { photoId: string }>(
|
||||
existing: readonly T[],
|
||||
|
||||
@@ -133,11 +133,11 @@ export function usePhotoSequence(inputs: PhotoSequenceInputs) {
|
||||
return inflightMore;
|
||||
}
|
||||
|
||||
/** 新照片提示条点击:整表刷新回第 1 页并回顶(提示消失)。 */
|
||||
/** 新照片提示条点击:整表刷新回第 1 页(提示消失)。不强制回顶——用户浏览
|
||||
* 位置保持(UI-02 §4.3「不在用户浏览时强制跳到顶部」)。 */
|
||||
async function refreshFromHint(): Promise<void> {
|
||||
if (initialLoading.value) return;
|
||||
await resetAndLoad();
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
function indexOf(photoId: string): number {
|
||||
|
||||
Reference in New Issue
Block a user