cdead49838
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>
353 lines
10 KiB
Vue
353 lines
10 KiB
Vue
<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>
|