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
+70 -6
View File
@@ -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[],
+2 -2
View File
@@ -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 {