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
@@ -0,0 +1,87 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import type { LikeBubble } from '../realtime/useAlbumRealtime';
// 他人点赞气泡(Task 10):右下角浮层,含被赞照片缩略图 + ♥ +delta;点击打开
// 该照片大图(定位)。生成/消散/清空(页面隐藏)由 useAlbumRealtime 管理,
// 本组件纯展示。减少动画/不可见时根本不会收到气泡(router 门禁)。
defineProps<{
bubbles: LikeBubble[];
}>();
const emit = defineEmits<{
open: [photoId: string];
}>();
const { t } = useI18n();
</script>
<template>
<div class="like-bubbles" aria-live="polite">
<button
v-for="bubble in bubbles"
:key="bubble.key"
type="button"
class="like-bubble"
:aria-label="t('viewer.bubbleOpen')"
@click="emit('open', bubble.photoId)"
>
<img v-if="bubble.thumbUrl !== null" class="bubble-thumb" :src="bubble.thumbUrl" alt="" loading="lazy">
<span class="bubble-heart" aria-hidden="true">♥</span>
<span class="bubble-delta" aria-hidden="true">+{{ bubble.delta }}</span>
</button>
</div>
</template>
<style scoped>
/* 极简基线样式:视觉打磨留待 P4a。 */
.like-bubbles {
position: fixed;
right: 16px;
bottom: 24px;
z-index: 105;
display: flex;
flex-direction: column;
gap: 8px;
pointer-events: none;
}
.like-bubble {
pointer-events: auto;
display: flex;
align-items: center;
gap: 8px;
border: 1px solid rgba(255, 255, 255, 0.35);
border-radius: 10px;
background: rgba(17, 24, 39, 0.88);
color: #f9fafb;
padding: 6px 12px 6px 6px;
cursor: pointer;
animation: bubble-rise 300ms ease-out;
}
.bubble-thumb {
width: 36px;
height: 36px;
border-radius: 6px;
object-fit: cover;
display: block;
}
.bubble-heart {
color: #ef4444;
}
.bubble-delta {
font-variant-numeric: tabular-nums;
}
@keyframes bubble-rise {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.viewer-reduced-motion .like-bubble {
animation: none;
}
</style>
+55
View File
@@ -3,6 +3,7 @@ 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 起数据层上提):双列绝对定位(短列优先,条目高按
@@ -21,6 +22,8 @@ import { layoutWaterfall } from '../feed/layout';
const props = defineProps<{
sequence: PhotoSequence;
/** 点赞控制器(Task 10):缺省不渲染单元格点赞位(组件单测组装)。 */
likes?: LikesController;
}>();
const emit = defineEmits<{
@@ -154,6 +157,11 @@ async function onImgError(photo: PublicPhoto): Promise<void> {
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();
@@ -202,6 +210,16 @@ onBeforeUnmount(() => {
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>
@@ -284,6 +302,43 @@ onBeforeUnmount(() => {
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;
+55 -1
View File
@@ -2,6 +2,7 @@
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import type { PhotoSequence } from '../feed/sequence';
import type { LikesController } from '../likes/useLikes';
import { MIN_SCALE, panBy, resetZoom, wheelFactor, zoomAt, type ZoomState } from '../viewer/zoom';
// 大图查看器(Task 8):browse URL 全屏展示 + 左右切换(保持列表浏览序列,越过
@@ -24,6 +25,8 @@ import { MIN_SCALE, panBy, resetZoom, wheelFactor, zoomAt, type ZoomState } from
const props = defineProps<{
sequence: PhotoSequence;
photoId: string;
/** 点赞控制器(Task 10):缺省不渲染点赞位(组件单测/降级组装)。 */
likes?: LikesController;
}>();
const emit = defineEmits<{
@@ -253,6 +256,13 @@ function preload(url: string): void {
img.src = url;
}
// —— 点赞(Task 10):乐观 +1 与心跳动画由 LikesController 承担 ——
function likeCurrent(): void {
if (photo.value === null || props.likes === undefined) return;
props.likes.like(photo.value.photoId);
}
// —— 签名过期/下线处理 ——
/** 大图加载失败:静默重取单图接口换新签名 URL;404 → 通知父级移除;其他失败可重试。 */
@@ -327,7 +337,17 @@ onBeforeUnmount(() => {
<header class="bar">
<span class="counter">{{ t('viewer.counter', { current: index + 1, total }) }}</span>
<span class="likes">{{ t('viewer.likeCount', { count: photo.likeCount }) }}</span>
<button
v-if="likes !== undefined"
type="button"
class="control like-button"
:aria-label="t('viewer.like')"
@click="likeCurrent"
>
<span :key="likes.popNonce(photo.photoId)" class="heart" aria-hidden="true">♥</span>
<span class="like-count">{{ likes.displayFor(photo) }}</span>
</button>
<span v-else class="likes">{{ t('viewer.likeCount', { count: photo.likeCount }) }}</span>
<!-- P4a 预留:下载 / 分享操作位(P1b 不实现)。 -->
<button type="button" class="control close" :aria-label="t('viewer.close')" @click="emit('close')">
✕
@@ -415,6 +435,40 @@ onBeforeUnmount(() => {
color: #d1d5db;
font-size: 0.875rem;
}
.like-button {
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 999px;
padding: 4px 12px;
font-size: 0.9rem;
border: 1px solid rgba(249, 250, 251, 0.35);
}
.like-button .heart {
color: #ef4444;
display: inline-block;
}
/* :key 重挂触发心跳放大;「减少动画」时禁用(数字仍即时跳动)。 */
.like-button .heart {
animation: heart-pop 400ms ease-out;
}
.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 .like-button .heart {
animation: none;
}
.control {
border: none;
background: rgba(17, 24, 39, 0.55);