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>
59 lines
2.6 KiB
TypeScript
59 lines
2.6 KiB
TypeScript
// 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;
|
|
}
|
|
}
|