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
+58
View File
@@ -0,0 +1,58 @@
// apps/viewer/src/realtime/router.ts —— 服务端消息路由(P2 Task 10)。
// 纯函数 + 依赖注入:帧语义集中一处,单测直测(自身过滤/隐藏暂停/减少动画)。
//
// likes.burst 自身过滤(A21 客户端侧):originBatchIds 与本会话已提交批次求交
// 得己方 delta——己方贡献不播气泡(点击时已见过动画),他人贡献照常;total
// 恒为权威值,无论过滤与否都校准。页面不可见(visibilitychange)或「减少动画」
// 开启时跳过气泡(队列即弃——回台只看得到校准后的数字),计数校准不受影响。
import type { ServerMessage } from '@photo/contracts';
export interface RealtimeRouterDeps {
/** 页面当前是否不可见(document.hidden)。 */
isHidden(): boolean;
/** 「减少动画」偏好是否开启。 */
reducedMotion(): boolean;
/** originBatchIds 中属于本会话已提交批次的 delta 合计。 */
ownDeltaIn(batchIds: string[]): number;
/** 点赞计数权威校准(版本化)。 */
calibrateLike(photoId: string, total: number, countVersion: number): void;
/** 他人点赞气泡(photoId + 他人贡献 delta)。 */
showBubble(photoId: string, delta: number): void;
/** 新照片到达:亮提示条(不自动插入)。 */
notifyNewPhotos(): void;
/** 照片隐藏/删除:移出序列(调用方负责大图收口与提示)。 */
removePhoto(photoId: string): void;
/** 相册浏览数更新(stats.updated)。 */
setViewCount(viewCount: number): void;
}
export function handleServerMessage(message: ServerMessage, deps: RealtimeRouterDeps): void {
switch (message.type) {
case 'pong':
return; // 心跳应答:无动作
case 'error':
// subscribe 校验失败等:连接保持,服务端已说明——本地备注即可。
console.info(`[viewer] realtime error frame: ${message.code}`);
return;
case 'photo.published':
deps.notifyNewPhotos();
return;
case 'photo.hidden':
case 'photo.deleted':
deps.removePhoto(message.photoId);
return;
case 'likes.burst': {
const own = deps.ownDeltaIn(message.originBatchIds);
// 校准无条件执行(含纯己方 burst——批次响应丢失时这是唯一权威回执)。
deps.calibrateLike(message.photoId, message.total, message.countVersion);
const others = message.delta - own;
if (others > 0 && !deps.isHidden() && !deps.reducedMotion()) {
deps.showBubble(message.photoId, others);
}
return;
}
case 'stats.updated':
deps.setViewCount(message.viewCount);
return;
}
}
@@ -0,0 +1,223 @@
// apps/viewer/src/realtime/useAlbumRealtime.ts —— 相册页实时编排(P2 Task 10)。
// 组装 RealtimeClient + router + 降级轮询 + 气泡生命周期:
//
// · 挂载即连 WS(URL 由 API 基址推导),subscribe 当前 token;
// · 重连成功(onResync)→ 第 1 页重取校准:点赞计数(likes.recalibrate-
// FromList)+ 列表变化(新照片亮提示条;**筛选未变时**缺失照片移出序列
// ——排序/分类切换本身会换第 1 页集合,不视为移除);
// · 连续 3 次失败 → degraded:8s 轮询同套校准(无气泡补播——历史气泡不
// 重放,只对齐数字与列表);恢复 open 即停轮询;
// · 气泡:他人 likes.burst(router 已滤己方)→ 底部浮层(缩略图 + delta),
// 点击打开该照片大图;约 4s 自动消散,至多 3 枚;页面隐藏即清空并暂停
// (回台只看得到校准后的计数)。
//
// e2e 注入点(沿用 sequence __viewerPollMs 约定):__viewerRealtimePollMs /
// __viewerWsRetryBaseMs / __viewerBubbleMs。
import { onBeforeUnmount, onMounted, reactive, ref, watch, type Ref } from 'vue';
import { apiBase, type PublicPhotoList } from '../api/client';
import type { LikesController } from '../likes/useLikes';
import { containsNewPhotoId } from '../feed/layout';
import type { PhotoSequence } from '../feed/sequence';
import { reducedMotion } from '../ui/prefs';
import { handleServerMessage } from './router';
import { RealtimeClient, wsUrlFromApiBase, type RealtimeState } from './ws';
/** 降级轮询间隔(毫秒)。 */
export const DEGRADED_POLL_MS = 8000;
/** 气泡驻留时长(毫秒)。 */
export const BUBBLE_MS = 4000;
/** 同屏气泡上限(超出弃最旧)。 */
export const BUBBLE_CAP = 3;
export interface LikeBubble {
key: number;
photoId: string;
delta: number;
thumbUrl: string | null;
}
export interface AlbumRealtimeInputs {
token: Ref<string>;
sequence: PhotoSequence;
likes: LikesController;
/** 当前筛选下的第 1 页取数(token/locale/category/sort 由调用方组装)。 */
fetchFirstPage: () => Promise<PublicPhotoList>;
/** 筛选指纹(token+locale+category+sort):变化时跳过移除检测(换集合非下线)。 */
filtersKey: () => string;
/** 照片被推送移除(hidden/deleted):neighborIndex 为移除前下标(大图回退邻居)。 */
onPhotoRemoved: (photoId: string, neighborIndex: number) => void;
setViewCount: (viewCount: number) => void;
}
function overrideNumber(key: string): number | null {
const value = (globalThis as unknown as Record<string, unknown>)[key];
return typeof value === 'number' && value > 0 ? value : null;
}
/** e2e 注入 WS 目标(空串/未置 → 真实推导 URL);逐次连接求值以便运行中切换。 */
function overrideWsUrl(): string | null {
const value = (globalThis as unknown as Record<string, unknown>).__viewerWsUrl;
return typeof value === 'string' && value !== '' ? value : null;
}
export function useAlbumRealtime(inputs: AlbumRealtimeInputs) {
const connection = ref<RealtimeState>('connecting');
const bubbles = ref<LikeBubble[]>([]);
let client: RealtimeClient | null = null;
let pollTimer: ReturnType<typeof setInterval> | null = null;
const bubbleTimers = new Set<ReturnType<typeof setTimeout>>();
let bubbleKey = 0;
/** 校准竞态令牌:仅最新一次重取可落地。 */
let calibrationSequence = 0;
/** 上轮第 1 页集合(移除检测基准)。 */
let lastFiltersKey = '';
let lastFirstPageIds = new Set<string>();
function pollIntervalMs(): number {
return overrideNumber('__viewerRealtimePollMs') ?? DEGRADED_POLL_MS;
}
// —— 气泡 ——
function showBubble(photoId: string, delta: number): void {
const photo = inputs.sequence.photos.find((item) => item.photoId === photoId);
const bubble: LikeBubble = {
key: ++bubbleKey,
photoId,
delta,
thumbUrl: photo?.thumb.url ?? null,
};
bubbles.value = [...bubbles.value.slice(-(BUBBLE_CAP - 1)), bubble];
const timer = setTimeout(() => {
bubbleTimers.delete(timer);
bubbles.value = bubbles.value.filter((item) => item.key !== bubble.key);
}, overrideNumber('__viewerBubbleMs') ?? BUBBLE_MS);
bubbleTimers.add(timer);
}
function dismissBubble(key: number): void {
bubbles.value = bubbles.value.filter((item) => item.key !== key);
}
// —— 校准(重连 resync 与降级轮询共用) ——
async function recalibrate(): Promise<void> {
const run = ++calibrationSequence;
try {
const page = await inputs.fetchFirstPage();
if (run !== calibrationSequence) return;
const pageIds = new Set(page.items.map((item) => item.photoId));
inputs.likes.recalibrateFromList(page.items);
if (containsNewPhotoId(inputs.sequence.photos.map((photo) => photo.photoId), page.items)) {
inputs.sequence.hasNew = true;
}
const key = inputs.filtersKey();
if (key === lastFiltersKey) {
// 筛选未变而第 1 页缺席 → 已下线:移出序列并通知(大图收口在调用方)。
for (const id of lastFirstPageIds) {
if (!pageIds.has(id) && inputs.sequence.indexOf(id) !== -1) {
const neighborIndex = inputs.sequence.indexOf(id);
inputs.sequence.removePhoto(id);
inputs.onPhotoRemoved(id, neighborIndex);
}
}
}
lastFiltersKey = key;
lastFirstPageIds = pageIds;
} catch {
// 校准取数失败静默:下一轮(重连/轮询)再试。
}
}
// —— 降级轮询 ——
function stopPolling(): void {
if (pollTimer !== null) {
clearInterval(pollTimer);
pollTimer = null;
}
}
function startPolling(): void {
if (pollTimer !== null) return;
void recalibrate();
pollTimer = setInterval(() => void recalibrate(), pollIntervalMs());
}
// —— WS 客户端 ——
function stopClient(): void {
client?.stop();
client = null;
stopPolling();
connection.value = 'connecting';
}
function startClient(): void {
stopClient();
client = new RealtimeClient({
url: () => overrideWsUrl() ?? wsUrlFromApiBase(apiBase()),
albumToken: () => inputs.token.value,
retryBaseMs: overrideNumber('__viewerWsRetryBaseMs') ?? undefined,
onMessage: (message) =>
handleServerMessage(message, {
isHidden: () => typeof document !== 'undefined' && document.hidden,
reducedMotion: () => reducedMotion.value,
ownDeltaIn: (batchIds) => inputs.likes.ownDeltaIn(batchIds),
calibrateLike: (photoId, total, countVersion) =>
inputs.likes.calibrate(photoId, total, countVersion),
showBubble,
notifyNewPhotos: () => {
inputs.sequence.hasNew = true;
},
removePhoto: (photoId) => {
const neighborIndex = inputs.sequence.indexOf(photoId);
inputs.sequence.removePhoto(photoId);
inputs.onPhotoRemoved(photoId, neighborIndex);
},
setViewCount: inputs.setViewCount,
}),
onStateChange: (state) => {
connection.value = state;
if (state === 'degraded') startPolling();
else stopPolling();
},
onResync: () => void recalibrate(),
});
client.start();
}
function onVisibilityChange(): void {
if (document.hidden) {
// 不可见:暂停动画——在屏气泡清空(不排队补播),计数校准照常。
bubbles.value = [];
}
}
onMounted(() => {
startClient();
document.addEventListener('visibilitychange', onVisibilityChange);
});
onBeforeUnmount(() => {
stopClient();
document.removeEventListener('visibilitychange', onVisibilityChange);
for (const timer of bubbleTimers) clearTimeout(timer);
bubbleTimers.clear();
bubbles.value = [];
});
// token 变化(相册间跳转):换新连接(退订旧房间由断链完成)+ 状态复位。
watch(inputs.token, () => {
lastFiltersKey = '';
lastFirstPageIds = new Set();
bubbles.value = [];
startClient();
});
// reactive 包装:模板/脚本访问 realtime.bubbles 等 ref 自动解包(同 sequence 约定)。
return reactive({ connection, bubbles, dismissBubble });
}
export type AlbumRealtime = ReturnType<typeof useAlbumRealtime>;
+209
View File
@@ -0,0 +1,209 @@
// apps/viewer/src/realtime/ws.ts —— WS 客户端(P2 Task 10,契约帧见
// packages/contracts/src/realtime.ts)。与 Vue 解耦的纯状态机,便于单测:
//
// start → connecting(连 ov /v1/realtime,URL 由 API 基址 http→ws 推导)
// open → 发 subscribe{albumToken} + 周期 ping(HEARTBEAT_MS/2,服务端
// 20s 无心跳踢链,半频留裕量)
// close/error → 指数退避重连(retryBaseMs 1s→2s→4s,8s 封顶);**重连成功
// 即 onResync()**(订阅可能错过断线期事件——调用方做第 1 页重取
// 校准);连续 3 次失败 → degraded(调用方启动轮询降级;本客户端
// 仍按封顶间隔后台重试,恢复后回 open)。
// stop → 幂等收尾(关连接、清全部定时器)。
//
// 工厂注入 socketFactory:单测用假 WebSocket;运行时缺省浏览器原生。
// 浏览器面必须走 ./realtime 子路径:桶出口 index 会连带 internal-sync
// (node:crypto createHmac),进浏览器包即被 Vite 外置化而整页崩溃。
import {
HEARTBEAT_MS,
REALTIME_WS_PATH,
type ServerMessage,
} from '@photo/contracts/realtime';
/** 连接状态:open=正常;degraded=连续失败进入降级(轮询接管)。 */
export type RealtimeState = 'connecting' | 'open' | 'degraded';
/** 重连退避封顶(毫秒)。 */
export const RECONNECT_CAP_MS = 8000;
/** 连续失败多少次后进入 degraded(轮询降级阈值)。 */
export const DEGRADE_AFTER_FAILURES = 3;
/** 浏览器 WebSocket 的最小使用面(测试假件只需实现这些)。 */
export interface WebSocketLike {
send(data: string): void;
close(): void;
addEventListener(type: 'open' | 'message' | 'close' | 'error', listener: (event: unknown) => void): void;
}
export type WebSocketFactory = (url: string) => WebSocketLike;
export interface RealtimeClientOptions {
/** 目标 URL:静态串或逐次连接时求值的 getter(e2e 注入切换存活性)。 */
url: string | (() => string);
/** 相册 token 延迟求值:重连时重新读取(相册间跳转后重挂新客户端)。 */
albumToken: () => string;
onMessage: (message: ServerMessage) => void;
onStateChange: (state: RealtimeState) => void;
/** 断线后重连成功(服务端已重订):调用方做第 1 页校准。 */
onResync: () => void;
socketFactory?: WebSocketFactory;
/** 重连退避基值(毫秒,缺省 1000;e2e 可注入调短)。 */
retryBaseMs?: number;
/** 应用层 ping 间隔(毫秒,缺省 HEARTBEAT_MS/2)。 */
pingMs?: number;
}
/** API 基址 → WS URL:http→ws / https→wss,拼契约路径;空基址(同源部署)
* 按当前页面 origin 推导。 */
export function wsUrlFromApiBase(apiBase: string): string {
let origin = apiBase.replace(/\/+$/, '');
if (origin === '') {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
origin = `${proto}//${window.location.host}`;
} else {
origin = origin.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:');
}
return `${origin}${REALTIME_WS_PATH}`;
}
export class RealtimeClient {
private readonly options: RealtimeClientOptions;
private socket: WebSocketLike | null = null;
private pingTimer: ReturnType<typeof setInterval> | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private failures = 0;
private hadOpenSession = false;
private stopped = false;
private state: RealtimeState = 'connecting';
constructor(options: RealtimeClientOptions) {
this.options = options;
}
private get retryBaseMs(): number {
return this.options.retryBaseMs ?? 1000;
}
private get pingMs(): number {
return this.options.pingMs ?? Math.ceil(HEARTBEAT_MS / 2);
}
private setState(state: RealtimeState): void {
if (this.state === state) return;
this.state = state;
this.options.onStateChange(state);
}
start(): void {
this.stopped = false;
this.setState('connecting');
this.connect();
}
private connect(): void {
if (this.stopped) return;
const url = typeof this.options.url === 'function' ? this.options.url() : this.options.url;
const factory = this.options.socketFactory ?? ((target: string) => new WebSocket(target) as unknown as WebSocketLike);
let socket: WebSocketLike;
try {
socket = factory(url);
} catch {
this.scheduleReconnect();
return;
}
this.socket = socket;
socket.addEventListener('open', () => this.onOpen());
socket.addEventListener('error', () => {
/* 握手/链路错误随后必有 close——统一在 onClosed 处理重连 */
});
socket.addEventListener('message', (event: unknown) => this.onMessage(event));
socket.addEventListener('close', () => this.onClosed());
}
private onOpen(): void {
if (this.stopped || this.socket === null) return;
const resync = this.hadOpenSession;
this.hadOpenSession = true;
this.failures = 0;
this.setState('open');
this.send({ type: 'subscribe', albumToken: this.options.albumToken() });
if (resync) this.options.onResync();
if (this.pingTimer === null) {
this.pingTimer = setInterval(() => {
this.send({ type: 'ping', ts: Date.now() });
}, this.pingMs);
}
}
private onMessage(event: unknown): void {
let data: unknown;
if (typeof MessageEvent !== 'undefined' && event instanceof MessageEvent) {
data = event.data;
} else {
// 假件直传字符串(测试环境无 MessageEvent 构造)。
data = (event as { data?: unknown }).data;
}
if (typeof data !== 'string') return;
let message: ServerMessage;
try {
message = JSON.parse(data) as ServerMessage;
} catch {
return; // 非 JSON 帧静默丢弃
}
if (typeof message !== 'object' || message === null || typeof message.type !== 'string') return;
this.options.onMessage(message);
}
private onClosed(): void {
this.stopPing();
this.socket = null;
if (this.stopped) return;
this.failures += 1;
if (this.failures >= DEGRADE_AFTER_FAILURES) this.setState('degraded');
else this.setState('connecting');
this.scheduleReconnect();
}
private scheduleReconnect(): void {
if (this.stopped || this.reconnectTimer !== null) return;
const exp = Math.min(this.failures, 3) - 1; // 1s→2s→4s,其后封顶
const delay = Math.min(this.retryBaseMs * 2 ** Math.max(exp, 0), RECONNECT_CAP_MS);
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.connect();
}, delay);
}
private send(frame: unknown): void {
if (this.socket === null) return;
try {
this.socket.send(JSON.stringify(frame));
} catch {
// 发送缓冲异常(CLOSING 态):等 close 事件走重连。
}
}
private stopPing(): void {
if (this.pingTimer !== null) {
clearInterval(this.pingTimer);
this.pingTimer = null;
}
}
stop(): void {
this.stopped = true;
this.stopPing();
if (this.reconnectTimer !== null) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
const socket = this.socket;
this.socket = null;
if (socket !== null) {
try {
socket.close();
} catch {
// 已在关闭中
}
}
}
}