// 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; } }