Files
photo-live/apps/viewer/src/components/PhotoFeed.vue
T
2026-09-22 13:49:15 +08:00

493 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue';
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, responsiveColumns, skeletonSources } from '../feed/layout';
// 观众瀑布流(Task 7;Task 8 起数据层上提;UI-02 Task 3 视觉重排):响应式列数
// 绝对定位(短列优先,条目高按 browse 宽高比例预留)+ IntersectionObserver 懒加载
// + 哨兵无限滚动 + 新照片白色提示 pill。数据生命周期(分页/去重/轮询/深链 solo)
// 在 ActivityPage 创建的 PhotoSequence composable(feed/sequence.ts)——feed 与
// 大图查看器共享同一序列,切换大图时保持浏览顺序、越过末尾自动扩展。
//
// 关键决策(沿用 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);
// · 首屏骨架:以常见竖/横/方比例(skeletonSources)按真实瀑布流放置预留轮廓,
// 非均匀网格;宽高异常同样走 4:3 安全比例(无 NaN/无限高度)。
const props = defineProps<{
sequence: PhotoSequence;
/** 点赞控制器(Task 10):缺省不渲染单元格点赞位(组件单测组装)。 */
likes?: LikesController;
}>();
const emit = defineEmits<{
open: [photoId: string];
}>();
const { t, locale } = useI18n();
const photos = computed(() => props.sequence.photos);
const initialLoading = computed(() => props.sequence.initialLoading);
const initialErrorKey = computed(() => props.sequence.initialErrorKey);
const loadingMore = computed(() => props.sequence.loadingMore);
const moreFailed = computed(() => props.sequence.moreFailed);
const hasMore = computed(() => props.sequence.hasMore);
const hasNew = computed(() => props.sequence.hasNew);
// 懒加载/重试视图状态(按 photoId,跨刷新保留,见顶部决策注)。
const shownIds = reactive(new Set<string>());
const retriedIds = reactive(new Set<string>());
const failedIds = reactive(new Set<string>());
// —— 布局:容器宽 → 响应式列配置 → 纯函数放置(ResizeObserver 跟踪宽度变化) ——
const trackEl = ref<HTMLElement | null>(null);
const sentinelEl = ref<HTMLElement | null>(null);
const containerWidth = ref(0);
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;
function measureContainer(): void {
if (trackEl.value !== null) containerWidth.value = trackEl.value.clientWidth;
}
watch(
trackEl,
(el) => {
resizeObserver?.disconnect();
resizeObserver = null;
if (el === null) return;
measureContainer();
if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(measureContainer);
resizeObserver.observe(el);
}
},
{ flush: 'post' },
);
// —— 懒加载/哨兵 IntersectionObserver ——
const cellElements = new Map<Element, string>();
let lazyObserver: IntersectionObserver | null = null;
let sentinelObserver: IntersectionObserver | null = null;
function ensureLazyObserver(): IntersectionObserver | null {
if (lazyObserver !== null) return lazyObserver;
if (typeof IntersectionObserver === 'undefined') return null;
lazyObserver = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const photoId = cellElements.get(entry.target);
if (photoId !== undefined) shownIds.add(photoId);
lazyObserver?.unobserve(entry.target);
}
},
{ rootMargin: '400px 0px' },
);
return lazyObserver;
}
/** v-for 函数 ref:登记 el → photoId 并纳入懒观察;IO 不可用退化立即显示。
* 按下标取 photos(卸载时 ref 收到 null 先行返回——函数 ref 在整表替换后
* 会对旧 vnode 重算,此时闭包里的数组访问必须可空)。 */
function registerCell(el: unknown, index: number): void {
if (!(el instanceof HTMLElement)) return;
const photo = photos.value[index];
if (photo === undefined) return;
cellElements.set(el, photo.photoId);
if (shownIds.has(photo.photoId)) return;
const observer = ensureLazyObserver();
if (observer === null) {
shownIds.add(photo.photoId);
return;
}
observer.observe(el);
}
/** 移除已脱离文档的单元格映射(整表替换后 DOM 销毁的 el 不再可达)。 */
function pruneCellElements(): void {
for (const [el] of cellElements) {
if (!el.isConnected) cellElements.delete(el);
}
}
watch(
photos,
() => pruneCellElements(),
);
watch(
sentinelEl,
(el) => {
sentinelObserver?.disconnect();
sentinelObserver = null;
if (el === null) return;
// IO 不可用(jsdom/旧环境)时不观察:无限滚动退化为已加载内容(不崩溃)。
if (typeof IntersectionObserver === 'undefined') return;
sentinelObserver = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) void props.sequence.loadMore();
},
{ rootMargin: '600px 0px' },
);
sentinelObserver.observe(el);
},
{ flush: 'post' },
);
/** 缩略图加载失败:重试一次(单图端点换新签名 URL);再失败/已下线则收起图片。 */
async function onImgError(photo: PublicPhoto): Promise<void> {
if (failedIds.has(photo.photoId)) return;
if (retriedIds.has(photo.photoId)) {
failedIds.add(photo.photoId);
return;
}
retriedIds.add(photo.photoId);
const result = await props.sequence.refreshPhotoUrls(photo.photoId);
if (result !== 'replaced') failedIds.add(photo.photoId);
}
/** 单元格点赞(Task 10):乐观 +1,不冒泡打开大图。 */
function likeCell(photo: PublicPhoto): void {
props.likes?.like(photo.photoId);
}
onBeforeUnmount(() => {
lazyObserver?.disconnect();
sentinelObserver?.disconnect();
resizeObserver?.disconnect();
});
</script>
<template>
<div class="photo-feed">
<!-- 新照片提示 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>
<!-- 首屏骨架:按真实瀑布流比例占位(非均匀网格)。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>
</div>
<template v-else>
<div ref="trackEl" class="waterfall" :style="{ height: `${layout.height}px` }">
<div
v-for="placement in layout.placements"
:key="photos[placement.index].photoId"
:ref="(el) => registerCell(el, placement.index)"
class="cell"
:style="{
left: `${placement.left}px`,
top: `${placement.top}px`,
width: `${layout.columnWidth}px`,
height: `${placement.height}px`,
}"
@click="emit('open', photos[placement.index].photoId)"
>
<img
v-if="shownIds.has(photos[placement.index].photoId) && !failedIds.has(photos[placement.index].photoId)"
class="cell-img"
:src="photos[placement.index].thumb.url"
:alt="`${t('route.photoLabel')} ${placement.index + 1}`"
loading="lazy"
decoding="async"
@error="onImgError(photos[placement.index])"
>
<!-- 点赞角标:独立交互区(@click.stop),不随图片点击打开大图。 -->
<button
v-if="likes !== undefined"
type="button"
class="cell-like"
:aria-label="t('viewer.like')"
@click.stop="likeCell(photos[placement.index])"
>
<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>
<p v-if="loadingMore" class="status more-status">{{ t('album.loadingMore') }}</p>
<div v-else-if="moreFailed" class="more-failed">
<button type="button" @click="props.sequence.loadMore">{{ t('album.retry') }}</button>
</div>
<p v-else-if="!hasMore" class="end-note">{{ t('album.end') }}</p>
<div v-if="hasMore" ref="sentinelEl" class="sentinel" aria-hidden="true" />
</template>
</div>
</template>
<style scoped>
/* 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(参考稿):顶部 8px 居中悬浮、白底蓝字、34px 高、
* 22px 圆角、轻投影 0 3px 15px —— */
.new-photo-bar {
position: sticky;
top: 8px;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
width: fit-content;
min-height: 34px;
margin: 0 auto 12px;
border: none;
border-radius: var(--viewer-radius-pill, 22px);
background: var(--viewer-surface, #fff);
box-shadow: 0 3px 15px rgba(23, 32, 51, 0.14);
color: var(--viewer-primary, #1a73e8);
padding: 0 17px;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
}
.new-photo-bar svg {
width: 14px;
height: 14px;
fill: none;
stroke: currentColor;
stroke-width: 2.2;
stroke-linecap: round;
stroke-linejoin: round;
}
.new-photo-bar:focus-visible {
outline: 2px solid var(--viewer-primary, #1a73e8);
outline-offset: 2px;
}
.waterfall {
position: relative;
}
/* —— 照片卡片:无白色厚卡片/无文件名;图片边到边 + 4px 圆角 —— */
.cell {
position: absolute;
border-radius: var(--viewer-radius-img, 4px);
overflow: hidden;
background: var(--viewer-divider, #e5eaf1);
cursor: pointer;
}
/* 参考稿:底部 40% 高度的轻渐变(transparent → rgba(0,0,0,.25)),
* 承托右下角白色点赞角标;不拦截指针(点击仍开大图)。 */
.cell::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 40%;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.25));
pointer-events: none;
}
.cell-img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
/* —— 点赞角标(参考稿):右下 3px 贴边、无底色胶囊——白心 19px + 白色计数,
* 仅靠 text-shadow 保证明亮照片上的可读性 —— */
.cell-like {
position: absolute;
right: 3px;
bottom: 3px;
z-index: 1;
display: inline-flex;
align-items: center;
gap: 4px;
border: none;
background: transparent;
color: #fff;
padding: 2px 3px;
font-size: 0.75rem;
line-height: 1;
text-shadow: 0 1px 4px #222;
cursor: pointer;
}
.cell-like .heart {
width: 19px;
height: 19px;
fill: #fff;
filter: drop-shadow(0 1px 4px #222);
animation: heart-pop 400ms ease-out;
}
.cell-like .count {
color: #fff;
font-variant-numeric: tabular-nums;
text-shadow: 0 1px 4px #222;
}
@keyframes heart-pop {
0% {
transform: scale(1);
}
40% {
transform: scale(1.6);
}
100% {
transform: scale(1);
}
}
.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;
}
/* 参考稿:到底提示 #96a0b0、12px、24px 16px 收底 */
.end-note {
text-align: center;
color: #96a0b0;
font-size: 0.75rem;
padding: 24px 16px;
margin: 0;
}
.sentinel {
height: 1px;
}
</style>