Files
photo-live/apps/viewer/src/components/PhotoFeed.vue
T
浅念 cdead49838 feat(viewer): likes, bubbles, visit reporting and hot sorting
Task 10 viewer 互动化:

- 点赞:useLikes 批次状态机(500ms 防抖 / 5 次满额即冲 / 失败同 batchId
  退避重试 3 次后回滚丢弃),乐观计数 + 服务端 total 权威校准
  (countVersion 单调),大图与格子双入口;
- WS 实时:RealtimeClient 纯状态机(半频心跳、指数退避重连、三败降级、
  重连成功 resync 第 1 页重取),消息路由器做 likes.burst 自身过滤
  (originBatchIds 求交)、隐藏页暂停与减少动画下仅校准不出气泡;
  photo.hidden/deleted 即时移出序列并回退邻居大图;降级后 8s 轮询接管;
- 浏览上报:visitorId 跨标签 BroadcastChannel first-writer-wins 协调,
  可见时上报、requestId 持久幂等、网络失败指数重试;
- 排序:最新/热门 chips(热门走 ov 冷快照),大图打开期间禁用切换;
  顶栏「减少动画」偏好开关;
- 契约包新增 ./realtime 子路径出口(浏览器面不可引桶出口——其连带
  internal-sync 的 node:crypto 会被 Vite 外置化致整页崩溃);
- e2e 注入钩子 __viewerWsUrl/__viewerWsRetryBaseMs/__viewerRealtimePollMs/
  __viewerBubbleMs 与 .activity[data-connection] 连接态锚。

vitest:viewer 105(新增 36:likes 批次 8、visitor 协调 5、上报门控 4、
路由 8、WS 状态机 7、排序 4);Playwright:viewer 21(新增 likes.spec 7、
journey 补互动腿断言)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 19:52:27 +08:00

353 lines
10 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 } from '../feed/layout';
// 观众瀑布流(Task 7;Task 8 起数据层上提):双列绝对定位(短列优先,条目高按
// browse 宽高比例预留)+ IntersectionObserver 懒加载 + 哨兵无限滚动 + 新照片提示条。
// 数据生命周期(分页/去重/轮询/深链 solo)在 ActivityPage 创建的 PhotoSequence
// composable(feed/sequence.ts)——feed 与大图查看器共享同一序列,切换大图时
// 保持浏览顺序、越过末尾自动扩展。
//
// 关键决策(沿用 Task 7):
// · 懒加载 = 仅当单元格接近视口才绑定 <img src>;shownIds 集合跨刷新保留——
// 刷新只换签名 URL,同 id 单元格不必重新判定;IO 不可用(旧环境/jsdom)时
// 退化为立即加载,功能不缺失。
// · 图片 onerror 重试一次:经 sequence.refreshPhotoUrls 换新签名 URL 再换绑;
// 再失败(或照片已下线)则收起该图(占位底色仍在,布局因高度预留不跳动)。
// · 单元格点击 emit('open', photoId) → ActivityPage 打开大图(Task 8)。
const props = defineProps<{
sequence: PhotoSequence;
/** 点赞控制器(Task 10):缺省不渲染单元格点赞位(组件单测组装)。 */
likes?: LikesController;
}>();
const emit = defineEmits<{
open: [photoId: string];
}>();
const { t } = 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 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(
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">
<button v-if="hasNew" type="button" class="new-photo-bar" @click="props.sequence.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="props.sequence.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`,
}"
@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])"
>
<button
v-if="likes !== undefined"
type="button"
class="cell-like"
: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>
</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>
/* 极简基线样式:视觉打磨留待 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);
cursor: pointer;
}
.cell-img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.cell-like {
position: absolute;
left: 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;
font-size: 0.75rem;
cursor: pointer;
}
.cell-like .heart {
color: #ef4444;
display: inline-block;
animation: heart-pop 400ms ease-out;
}
.cell-like .count {
font-variant-numeric: tabular-nums;
}
@keyframes heart-pop {
0% {
transform: scale(1);
}
40% {
transform: scale(1.6);
}
100% {
transform: scale(1);
}
}
.viewer-reduced-motion .cell-like .heart {
animation: none;
}
.end-note {
text-align: center;
color: #9ca3af;
font-size: 0.875rem;
padding: 12px 0;
margin: 0;
}
.sentinel {
height: 1px;
}
</style>