feat(viewer): waterfall feed with lazy load, filter and new-photo hint

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-18 10:23:27 +08:00
parent adc45b1563
commit d0b79dd0c9
15 changed files with 1305 additions and 27 deletions
+432
View File
@@ -0,0 +1,432 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import {
ApiError,
NETWORK_ERROR_CODE,
getPublicPhoto,
getPublicPhotos,
type PublicPhoto,
type PublicPhotoList,
} from '../api/client';
import { errorMessageKey } from '../api/errors';
import { containsNewPhotoId, layoutWaterfall, mergeUniqueByPhotoId } from '../feed/layout';
// 观众瀑布流(Task 7):双列绝对定位(短列优先,条目高按 browse 宽高比例预留)
// + IntersectionObserver 懒加载 + 游标无限滚动 + 分类筛选 + 新照片轮询提示。
//
// 关键决策:
// · 懒加载 = 仅当单元格接近视口才绑定 <img src>(等价 data-src → src 换绑);
// shownIds 集合跨刷新保留——刷新只换签名 URL,同 id 单元格不必重新判定;
// IO 不可用(旧环境/jsdom)时退化为立即加载,功能不缺失。
// · 图片 onerror 重试一次:调单图端点换新签名 URL 再换绑;再失败则收起该图
// (占位底色仍在,布局因高度预留不跳动)。
// · 轮询(30s,e2e 可用 window.__viewerPollMs 覆盖)只取第 1 页对比 id 集合,
// 命中新照片只亮提示条——不自动插入(避免滚动位置跳动,需求 1.2);
// 点击提示条 = 整表刷新回第 1 页并回顶。标签页隐藏暂停(R26 同精神)。
// · 竞态防护:feedSequence 令牌——分类/语言/token 切换或刷新会使在途的
// 分页/轮询结果作废;轮询失败静默(网络抖动不应打扰观众)。
const props = defineProps<{
token: string;
locale: string;
/** 当前分类筛选(null = 全部)。由活动页 chips 持有,切换即重置列表。 */
category: string | null;
}>();
const { t } = useI18n();
const photos = ref<PublicPhoto[]>([]);
const nextCursor = ref<string | null>(null);
const hasMore = ref(false);
const initialLoading = ref(true);
const initialErrorKey = ref<string | null>(null);
const loadingMore = ref(false);
const moreFailed = ref(false);
const hasNew = ref(false);
// 懒加载/重试视图状态(按 photoId,跨刷新保留,见顶部决策注)。
const shownIds = reactive(new Set<string>());
const retriedIds = reactive(new Set<string>());
const failedIds = reactive(new Set<string>());
let feedSequence = 0;
function fetchPage(cursor?: string): Promise<PublicPhotoList> {
return getPublicPhotos(props.token, {
locale: props.locale,
category: props.category ?? undefined,
cursor,
});
}
/** 整表重置加载(首载/分类切换/语言切换/token 切换/新照片刷新共用)。 */
async function resetAndLoad(): Promise<void> {
const sequence = ++feedSequence;
initialLoading.value = true;
initialErrorKey.value = null;
moreFailed.value = false;
loadingMore.value = false;
hasNew.value = false;
photos.value = [];
nextCursor.value = null;
hasMore.value = false;
pruneCellElements();
try {
const page = await fetchPage();
if (sequence !== feedSequence) return;
photos.value = [...page.items];
nextCursor.value = page.nextCursor;
hasMore.value = page.hasMore && page.nextCursor !== null;
} catch (error) {
if (sequence !== feedSequence) return;
const code = error instanceof ApiError ? error.code : NETWORK_ERROR_CODE;
initialErrorKey.value = errorMessageKey(code);
} finally {
if (sequence === feedSequence) initialLoading.value = false;
}
}
/** 无限滚动:哨兵进入视口(600px 预载)时取下一页;去重防御性合并。 */
async function loadMore(): Promise<void> {
if (initialLoading.value || initialErrorKey.value !== null) return;
if (loadingMore.value || !hasMore.value || nextCursor.value === null) return;
const sequence = feedSequence;
loadingMore.value = true;
moreFailed.value = false;
try {
const page = await fetchPage(nextCursor.value);
if (sequence !== feedSequence) return;
photos.value = mergeUniqueByPhotoId(photos.value, page.items);
nextCursor.value = page.nextCursor;
hasMore.value = page.hasMore && page.nextCursor !== null;
pruneCellElements();
} catch {
if (sequence === feedSequence) moreFailed.value = true;
} finally {
if (sequence === feedSequence) loadingMore.value = false;
}
}
/** 新照片提示条点击:整表刷新回第 1 页并回顶(提示消失)。 */
async function refreshFromHint(): Promise<void> {
if (initialLoading.value) return;
await resetAndLoad();
window.scrollTo(0, 0);
}
// —— 布局:容器宽 → 纯函数双列放置(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));
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(
sentinelEl,
(el) => {
sentinelObserver?.disconnect();
sentinelObserver = null;
if (el === null) return;
sentinelObserver = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) void loadMore();
},
{ rootMargin: '600px 0px' },
);
sentinelObserver.observe(el);
},
{ flush: 'post' },
);
/** 缩略图加载失败:重试一次(单图端点换新签名 URL);再失败收起图片。 */
async function onImgError(photo: PublicPhoto): Promise<void> {
const { photoId } = photo;
if (failedIds.has(photoId)) return;
if (retriedIds.has(photoId)) {
failedIds.add(photoId);
return;
}
retriedIds.add(photoId);
try {
const detail = await getPublicPhoto(props.token, photoId);
photo.thumb = detail.thumb;
photo.browse = detail.browse;
} catch {
failedIds.add(photoId);
}
}
// —— 新照片轮询 ——
const DEFAULT_POLL_INTERVAL_MS = 30_000;
/** e2e/开发注入点:window.__viewerPollMs 覆盖轮询间隔(仅测试加速用)。 */
function pollIntervalMs(): number {
const override = (globalThis as { __viewerPollMs?: unknown }).__viewerPollMs;
return typeof override === 'number' && override > 0 ? override : DEFAULT_POLL_INTERVAL_MS;
}
let pollTimer: ReturnType<typeof setInterval> | undefined;
function stopPolling(): void {
if (pollTimer !== undefined) {
clearInterval(pollTimer);
pollTimer = undefined;
}
}
function startPolling(): void {
stopPolling();
pollTimer = setInterval(() => void pollOnce(), pollIntervalMs());
}
/** 取当前筛选的第 1 页对比 id 集合;发现新照片只亮提示条(不自动插入)。失败静默。 */
async function pollOnce(): Promise<void> {
if (
document.hidden ||
initialLoading.value ||
initialErrorKey.value !== null ||
hasNew.value
) {
return;
}
const sequence = feedSequence;
try {
const page = await fetchPage();
if (sequence !== feedSequence) return;
if (containsNewPhotoId(photos.value.map((photo) => photo.photoId), page.items)) {
hasNew.value = true;
}
} catch {
// 轮询失败静默:网络抖动不打扰观众(下一次间隔重试)。
}
}
function onVisibilityChange(): void {
if (document.hidden) stopPolling();
else startPolling();
}
onMounted(() => {
void resetAndLoad();
startPolling();
document.addEventListener('visibilitychange', onVisibilityChange);
});
onBeforeUnmount(() => {
stopPolling();
document.removeEventListener('visibilitychange', onVisibilityChange);
lazyObserver?.disconnect();
sentinelObserver?.disconnect();
resizeObserver?.disconnect();
});
// 分类/语言/token 变化:整表重置(语言切换保留当前分类——由 props 自带)。
watch(
[() => props.token, () => props.locale, () => props.category],
() => void resetAndLoad(),
);
</script>
<template>
<div class="photo-feed">
<button v-if="hasNew" type="button" class="new-photo-bar" @click="refreshFromHint">
{{ t('album.newPhotos') }}
</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="resetAndLoad">{{ t('album.retry') }}</button>
</div>
<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`,
}"
>
<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])"
>
</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="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>
/* 极简基线样式:视觉打磨留待 P4a。 */
.photo-feed {
position: relative;
}
.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;
cursor: pointer;
}
.status {
color: #6b7280;
}
.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;
}
.waterfall {
position: relative;
}
.cell {
position: absolute;
border-radius: 6px;
overflow: hidden;
background: linear-gradient(160deg, #f3f4f6, #e5e7eb);
}
.cell-img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.end-note {
text-align: center;
color: #9ca3af;
font-size: 0.875rem;
padding: 12px 0;
margin: 0;
}
.sentinel {
height: 1px;
}
</style>