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>
This commit is contained in:
浅念
2026-09-18 19:52:27 +08:00
parent 955bea49b5
commit cdead49838
29 changed files with 2581 additions and 29 deletions
+117 -18
View File
@@ -2,11 +2,23 @@
import { computed, onBeforeUnmount, onMounted, ref, watch, watchEffect } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { ApiError, NETWORK_ERROR_CODE, getPublicAlbum, getPublicPhoto, type PublicAlbum } from '../api/client';
import {
ApiError,
NETWORK_ERROR_CODE,
getPublicAlbum,
getPublicPhoto,
getPublicPhotos,
type PublicAlbum,
type SortMode,
} from '../api/client';
import { errorMessageKey } from '../api/errors';
import LikeBubbles from '../components/LikeBubbles.vue';
import PhotoFeed from '../components/PhotoFeed.vue';
import PhotoLightbox from '../components/PhotoLightbox.vue';
import { usePhotoSequence } from '../feed/sequence';
import { useLikes } from '../likes/useLikes';
import { useAlbumRealtime } from '../realtime/useAlbumRealtime';
import { useVisitReport } from '../stats/visitor';
// 观众活动页(P1b Task 6 信息区 + Task 7 瀑布流 + Task 8 大图查看器)。
//
@@ -42,6 +54,8 @@ const loadErrorKey = ref<string | null>(null);
const loading = ref(true);
// banner 签名 URL 加载失败(onerror)后隐藏,布局自适应(无残破图占位)。
const bannerFailed = ref(false);
/** 相册浏览数(Task 10):album 加载初值 + 上报确认/ stats.updated 校准。 */
const viewCount = ref<number | null>(null);
// 快速连续切换语言/路由时的竞态防护:仅最新请求的结果可落地。
let loadSequence = 0;
@@ -55,9 +69,14 @@ async function load(): Promise<void> {
if (sequence !== loadSequence) return;
album.value = data;
loadErrorKey.value = null;
viewCount.value = data.viewCount;
// 浏览上报(Task 10):加载成功后(可见时)一次性上报——幂等键由 reporter
// 持久,窗口内刷新不重复计数(A14)。
visitReport.notifyAlbumLoaded();
} catch (error) {
if (sequence !== loadSequence) return;
album.value = null;
viewCount.value = null;
const code = error instanceof ApiError ? error.code : NETWORK_ERROR_CODE;
loadErrorKey.value = errorMessageKey(code);
} finally {
@@ -122,7 +141,59 @@ function selectCategory(categoryId: string | null): void {
// —— 照片序列(Task 8 提升:feed + lightbox 共享) ——
const sequence = usePhotoSequence({ token, locale, category: selectedCategory });
/** 排序模式(Task 10):latest=发布倒序(缺省)/ hot=点赞倒序短期快照。
* 切换=快照重取整表;**大图打开期间锁定**(当前浏览序列不被中途换序)。 */
const sortMode = ref<SortMode>('latest');
function selectSort(mode: SortMode): void {
if (lightboxPhotoId.value !== null || sortMode.value === mode) return;
sortMode.value = mode;
}
const sequence = usePhotoSequence({ token, locale, category: selectedCategory, sort: sortMode });
// —— 互动(Task 10):点赞批次 + WS 实时(气泡/校准/降级轮询)+ 浏览上报 ——
const likes = useLikes({ token, photos: () => sequence.photos });
/** 浏览上报编排:viewCount 权威值回调(album 初值可能随后被服务端计数刷新)。 */
const visitReport = useVisitReport({
token,
onCounted: (count) => {
if (count > (viewCount.value ?? 0)) viewCount.value = count;
},
});
// token 变化(相册间跳转):上报状态复位(新相册可再报)。
watch(token, () => visitReport.resetForToken());
/** 大图确认照片下线(404 刷新 / WS hidden/deleted 推送共用):轻提示 + 回退。 */
function onPhotoRemoved(photoId: string, neighborIndex: number): void {
showRemovedNotice();
if (lightboxPhotoId.value !== photoId) return;
const fallback = sequence.photos[Math.min(neighborIndex, sequence.photos.length - 1)];
if (fallback === undefined) {
closeLightbox();
} else {
lightboxPhotoId.value = fallback.photoId;
}
}
const realtime = useAlbumRealtime({
token,
sequence,
likes,
fetchFirstPage: () =>
getPublicPhotos(token.value, {
locale: locale.value,
category: selectedCategory.value ?? undefined,
sort: sortMode.value,
}),
filtersKey: () => `${token.value}|${locale.value}|${selectedCategory.value ?? ''}|${sortMode.value}`,
onPhotoRemoved,
setViewCount: (count) => {
viewCount.value = count;
},
});
// —— 大图查看器(Task 8) ——
@@ -262,25 +333,13 @@ watch(
},
);
/** 大图确认照片下线(404 刷新):从序列移除,回退到原位邻居(或关闭),轻提示。 */
function onPhotoRemoved(photoId: string, neighborIndex: number): void {
showRemovedNotice();
if (lightboxPhotoId.value !== photoId) return;
const fallback = sequence.photos[Math.min(neighborIndex, sequence.photos.length - 1)];
if (fallback === undefined) {
closeLightbox();
} else {
lightboxPhotoId.value = fallback.photoId;
}
}
onBeforeUnmount(() => {
if (removedNoticeTimer !== undefined) clearTimeout(removedNoticeTimer);
});
</script>
<template>
<section class="activity">
<section class="activity" :data-connection="realtime.connection">
<p v-if="loading" class="status">{{ t('album.loading') }}</p>
<div v-else-if="loadErrorKey !== null" class="error" role="alert">
@@ -309,6 +368,10 @@ onBeforeUnmount(() => {
<dt>{{ t('album.venue') }}</dt>
<dd>{{ venueText }}</dd>
</div>
<div v-if="viewCount !== null" class="meta-row">
<dt>{{ t('album.views') }}</dt>
<dd class="view-count">{{ viewCount }}</dd>
</div>
</dl>
</header>
@@ -347,9 +410,32 @@ onBeforeUnmount(() => {
</button>
</nav>
<!-- 排序切换(Task 10):latest/hot 快照重取;大图打开期间锁定(序列不变序)。 -->
<nav class="sort" :aria-label="t('album.sortLabel')">
<button
type="button"
class="chip"
:class="{ active: sortMode === 'latest' }"
:aria-pressed="sortMode === 'latest'"
:disabled="lightboxPhotoId !== null"
@click="selectSort('latest')"
>
{{ t('album.sortLatest') }}
</button>
<button
type="button"
class="chip"
:class="{ active: sortMode === 'hot' }"
:aria-pressed="sortMode === 'hot'"
:disabled="lightboxPhotoId !== null"
@click="selectSort('hot')"
>
{{ t('album.sortHot') }}
</button>
</nav>
<!-- 瀑布流(Task 7):序列数据在 PhotoSequence(Task 8 共享给大图)。 -->
<PhotoFeed :sequence="sequence" @open="openFromFeed" />
<!-- viewCount 契约占位 null:不渲染任何浏览计数节点。 -->
<PhotoFeed :sequence="sequence" :likes="likes" @open="openFromFeed" />
</template>
<!-- 大图查看器(Task 8):当前照片以 photoId 键控(v-model)。 -->
@@ -357,11 +443,15 @@ onBeforeUnmount(() => {
v-if="lightboxPhotoId !== null && currentPhoto !== null"
v-model:photo-id="lightboxPhotoId"
:sequence="sequence"
:likes="likes"
@removed="onPhotoRemoved"
@close="closeLightbox"
/>
<!-- 照片下线轻提示(大图内 404 刷新确认移除时)。 -->
<!-- 他人点赞气泡(Task 10):点击定位打开该照片大图。 -->
<LikeBubbles :bubbles="realtime.bubbles" @open="openFromFeed" />
<!-- 照片下线轻提示(大图 404 刷新 / WS hidden/deleted 推送移除时)。 -->
<p v-if="photoRemovedNotice" class="removed-notice" role="status">{{ t('viewer.photoRemoved') }}</p>
</section>
</template>
@@ -437,6 +527,15 @@ onBeforeUnmount(() => {
background: #eff6ff;
color: #1d4ed8;
}
.sort {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.sort .chip:disabled {
opacity: 0.55;
cursor: default;
}
.deep-link-notice {
border: 1px solid #fde68a;
border-radius: 8px;