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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user