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
+194 -74
View File
@@ -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 独立交互不冒泡开大图;
// · 懒加载 = 仅当单元格接近视口才绑定 <img src>;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<string>());
const retriedIds = reactive(new Set<string>());
const failedIds = reactive(new Set<string>());
// —— 布局:容器宽 → 纯函数双列放置(ResizeObserver 跟踪容器宽度变化) ——
// —— 布局:容器宽 → 响应式列配置 → 纯函数放置(ResizeObserver 跟踪宽度变化) ——
const trackEl = ref<HTMLElement | null>(null);
const sentinelEl = ref<HTMLElement | null>(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(() => {
<template>
<div class="photo-feed">
<button v-if="hasNew" type="button" class="new-photo-bar" @click="props.sequence.refreshFromHint()">
{{ t('album.newPhotos') }}
<!-- 新照片提示 pill(UI-02 §4.3):接口只有 hasNew 布尔——不显示伪造数量;
点击加载合并,不强制回顶。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>
<p v-if="initialLoading" class="status">{{ t('album.loading') }}</p>
<div v-else-if="initialErrorKey !== null" class="error" role="alert">
<p>{{ t(initialErrorKey) }}</p>
<button type="button" @click="props.sequence.resetAndLoad">{{ t('album.retry') }}</button>
<!-- 首屏骨架:按真实瀑布流比例占位(非均匀网格)。trackEl 与真实瀑布流
共用(v-if 两分支同时只存在一个,ref watch 处理换锚);读屏文案走
视觉隐藏的 status。 -->
<p v-if="initialLoading" class="sr-only" role="status">{{ t('album.loading') }}</p>
<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>
<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">
<p class="empty-notice">{{ t('album.emptyNotice') }}</p>
@@ -210,6 +256,7 @@ onBeforeUnmount(() => {
decoding="async"
@error="onImgError(photos[placement.index])"
>
<!-- 点赞角标:独立交互区(@click.stop),不随图片点击打开大图。 -->
<button
v-if="likes !== undefined"
type="button"
@@ -217,8 +264,16 @@ onBeforeUnmount(() => {
:aria-label="t('viewer.like')"
@click.stop="likeCell(photos[placement.index])"
>
<span :key="likes.popNonce(photos[placement.index].photoId)" class="heart" aria-hidden="true">♥</span>
<span class="count">{{ likes.displayFor(photos[placement.index]) }}</span>
<svg
: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>
</div>
</div>
@@ -235,65 +290,71 @@ onBeforeUnmount(() => {
</template>
<style scoped>
/* 极简基线样式:视觉打磨留待 P4a。 */
/* UI-02 Task 3(§4.3):token 经祖先 .viewer-shell 提供;独立挂载(无 shell)
* 时回退同值。照片区边到边,仅 4px 外沿内边距(--viewer-pad-photo)。 */
.photo-feed {
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 {
position: sticky;
top: 0;
z-index: 1;
display: block;
width: 100%;
margin-bottom: 12px;
border: 1px solid #1d4ed8;
border-radius: 8px;
background: #eff6ff;
color: #1d4ed8;
padding: 8px 12px;
font-size: 0.875rem;
top: 10px;
z-index: 2;
display: flex;
align-items: center;
gap: 6px;
width: fit-content;
margin: 0 auto 12px;
border: none;
border-radius: var(--viewer-radius-pill, 20px);
background: var(--viewer-surface, #fff);
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;
}
.status {
color: #6b7280;
.new-photo-bar svg {
width: 14px;
height: 14px;
fill: none;
stroke: currentColor;
stroke-width: 2.2;
stroke-linecap: round;
stroke-linejoin: round;
}
.more-status {
text-align: center;
padding: 8px 0;
}
.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;
.new-photo-bar:focus-visible {
outline: 2px solid var(--viewer-primary, #1a73e8);
outline-offset: 2px;
}
.waterfall {
position: relative;
}
/* —— 照片卡片:无白色厚卡片/无文件名;图片边到边 + 4px 圆角 —— */
.cell {
position: absolute;
border-radius: 6px;
border-radius: var(--viewer-radius-img, 4px);
overflow: hidden;
background: linear-gradient(160deg, #f3f4f6, #e5e7eb);
background: var(--viewer-divider, #e5eaf1);
cursor: pointer;
}
.cell-img {
@@ -302,27 +363,32 @@ onBeforeUnmount(() => {
height: 100%;
object-fit: cover;
}
/* —— 点赞角标:右下角半透明暗底(明亮照片上可读),白心 + 白色计数 —— */
.cell-like {
position: absolute;
left: 6px;
right: 6px;
bottom: 6px;
display: inline-flex;
align-items: center;
gap: 4px;
border: none;
border-radius: 999px;
background: rgba(17, 24, 39, 0.65);
color: #f9fafb;
padding: 2px 8px;
border-radius: var(--viewer-radius-pill, 20px);
background: linear-gradient(to right, rgba(14, 18, 24, 0.42), rgba(14, 18, 24, 0.62));
color: #fff;
padding: 3px 8px;
font-size: 0.75rem;
line-height: 1;
cursor: pointer;
}
.cell-like .heart {
color: #ef4444;
display: inline-block;
width: 16px;
height: 16px;
fill: #fff;
animation: heart-pop 400ms ease-out;
}
.cell-like .count {
color: #fff;
font-variant-numeric: tabular-nums;
}
@keyframes heart-pop {
@@ -339,9 +405,63 @@ onBeforeUnmount(() => {
.viewer-reduced-motion .cell-like .heart {
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 {
text-align: center;
color: #9ca3af;
color: var(--viewer-text-secondary, #647084);
font-size: 0.875rem;
padding: 12px 0;
margin: 0;