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:
@@ -0,0 +1,231 @@
|
||||
// apps/viewer/src/likes/useLikes.ts —— 点赞互动状态机(P2 Task 10)。
|
||||
//
|
||||
// 批次生命周期(技术方案 §7「批次收集」):
|
||||
// 点击 → 乐观 +1(display = base + pending,含心跳动画 nonce)→ 计入待冲
|
||||
// 队列;500ms 防抖到期或单照片累计 5 次 → 冲一批(batchId=UUID,delta=累计)
|
||||
// → POST likes。响应 {total, countVersion} 权威校准:base=total(可能因他人
|
||||
// 点赞跳变)、pending-=delta;countVersion 单调防乱序回退。
|
||||
// 失败 → 同 batchId 退避重试(1s/2s/4s,3 次后丢弃:pending-=delta + console
|
||||
// 备注——批次可能已落服务端(响应丢失),计数在下次交互/校准自然对齐)。
|
||||
//
|
||||
// 自身气泡过滤的数据面:每次成功提交登记 myBatches: batchId → delta(容量
|
||||
// 上限裁剪);WS likes.burst.originBatchIds 与之求交得「己方贡献」,供 router
|
||||
// 决定抑制动画但仍校准 total(见 realtime/router.ts)。
|
||||
//
|
||||
// 校准入口(三源殊途同归):
|
||||
// · 批次响应 calibrate(total, countVersion)(版本化,可回退修正);
|
||||
// · burst/stats 推送 calibrate(total, countVersion)(版本化);
|
||||
// · 列表重取 recalibrateFromList(items)(无版本号 → base 单调不减,防快照
|
||||
// 滞后让 display 回跳)。
|
||||
import { onBeforeUnmount, reactive, watch, type Ref } from 'vue';
|
||||
import {
|
||||
postLikeBatch,
|
||||
type PublicPhoto,
|
||||
type PublicPhotoList,
|
||||
} from '../api/client';
|
||||
import { randomUuid } from '../support/ids';
|
||||
|
||||
/** 冲批防抖窗(毫秒)。 */
|
||||
export const LIKE_FLUSH_DEBOUNCE_MS = 500;
|
||||
/** 单照片累计满额即冲(不等防抖窗)。 */
|
||||
export const LIKE_FLUSH_CAP = 5;
|
||||
/** 失败重试退避基值(毫秒),指数 1s/2s/4s。 */
|
||||
export const LIKE_RETRY_BASE_MS = 1000;
|
||||
/** 同 batchId 最大尝试次数(含首发),超出丢弃。 */
|
||||
export const LIKE_RETRY_MAX_ATTEMPTS = 3;
|
||||
/** 自身批次登记容量(burst 回显在其后到达,超限裁最旧)。 */
|
||||
const MY_BATCHES_CAP = 100;
|
||||
|
||||
/** sessionStorage 键:会话标识(每标签页会话一枚,跨刷新复用)。 */
|
||||
const SESSION_KEY = 'viewer.likeSessionId';
|
||||
|
||||
/** 单照片状态(base=权威累计,pending=未落定乐观量,pop=动画 nonce)。 */
|
||||
interface PhotoLikeState {
|
||||
base: number;
|
||||
pending: number;
|
||||
version: number;
|
||||
pop: number;
|
||||
}
|
||||
|
||||
/** 在途/重试批次。 */
|
||||
interface InflightBatch {
|
||||
photoId: string;
|
||||
batchId: string;
|
||||
delta: number;
|
||||
attempts: number;
|
||||
}
|
||||
|
||||
export interface LikesInputs {
|
||||
token: Ref<string>;
|
||||
/** 当前已加载照片序列(首页到达/重取时同步权威 base)。 */
|
||||
photos: () => PublicPhoto[];
|
||||
}
|
||||
|
||||
function readSessionId(): string {
|
||||
try {
|
||||
const existing = sessionStorage.getItem(SESSION_KEY);
|
||||
if (existing !== null && existing !== '') return existing;
|
||||
const created = randomUuid();
|
||||
sessionStorage.setItem(SESSION_KEY, created);
|
||||
return created;
|
||||
} catch {
|
||||
// sessionStorage 不可用(隐私模式极端策略):每页内存随机即可——幂等性
|
||||
// 依赖 batchId 而非 sessionId,仅损失「会话期间复用」的限速聚合粒度。
|
||||
return randomUuid();
|
||||
}
|
||||
}
|
||||
|
||||
export function useLikes(inputs: LikesInputs) {
|
||||
const sessionId = readSessionId();
|
||||
|
||||
/** photoId → 状态(响应式:display/pop 供模板直读)。 */
|
||||
const states = reactive(new Map<string, PhotoLikeState>());
|
||||
/** 待冲队列:photoId → 未提交点击数。 */
|
||||
const queued = new Map<string, number>();
|
||||
/** 成功批次登记:batchId → delta(自身气泡过滤)。 */
|
||||
const myBatches = new Map<string, number>();
|
||||
const inflight = new Set<InflightBatch>();
|
||||
|
||||
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const retryTimers = new Set<ReturnType<typeof setTimeout>>();
|
||||
|
||||
function stateOf(photoId: string, seedCount?: number): PhotoLikeState {
|
||||
let state = states.get(photoId);
|
||||
if (state === undefined) {
|
||||
state = { base: seedCount ?? 0, pending: 0, version: 0, pop: 0 };
|
||||
states.set(photoId, state);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
/** 乐观展示计数(模板用):base + pending。 */
|
||||
function displayFor(photo: Pick<PublicPhoto, 'photoId' | 'likeCount'>): number {
|
||||
const state = stateOf(photo.photoId, photo.likeCount);
|
||||
return state.base + state.pending;
|
||||
}
|
||||
|
||||
/** 点击:立即 +1(乐观)并入队;首个点击启动防抖窗。 */
|
||||
function like(photoId: string): void {
|
||||
const state = stateOf(photoId);
|
||||
state.pending += 1;
|
||||
state.pop += 1;
|
||||
queued.set(photoId, (queued.get(photoId) ?? 0) + 1);
|
||||
if ((queued.get(photoId) ?? 0) >= LIKE_FLUSH_CAP) {
|
||||
flushQueue();
|
||||
return;
|
||||
}
|
||||
if (flushTimer === null) {
|
||||
flushTimer = setTimeout(flushQueue, LIKE_FLUSH_DEBOUNCE_MS);
|
||||
}
|
||||
}
|
||||
|
||||
/** 冲队:每照片一批(新 batchId);未满防抖窗的部分留给下一窗。 */
|
||||
function flushQueue(): void {
|
||||
if (flushTimer !== null) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
for (const [photoId, delta] of queued) {
|
||||
queued.delete(photoId);
|
||||
sendBatch({ photoId, batchId: randomUuid(), delta, attempts: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
function sendBatch(batch: InflightBatch): void {
|
||||
inflight.add(batch);
|
||||
postLikeBatch(inputs.token.value, batch.photoId, {
|
||||
sessionId,
|
||||
batchId: batch.batchId,
|
||||
delta: batch.delta,
|
||||
})
|
||||
.then((response) => settleBatch(batch, response))
|
||||
.catch(() => retryBatch(batch));
|
||||
}
|
||||
|
||||
function settleBatch(
|
||||
batch: InflightBatch,
|
||||
response: { total: number; countVersion: number },
|
||||
): void {
|
||||
inflight.delete(batch);
|
||||
myBatches.set(batch.batchId, batch.delta);
|
||||
if (myBatches.size > MY_BATCHES_CAP) {
|
||||
const oldest = myBatches.keys().next().value;
|
||||
if (oldest !== undefined) myBatches.delete(oldest);
|
||||
}
|
||||
calibrate(batch.photoId, response.total, response.countVersion);
|
||||
const state = states.get(batch.photoId);
|
||||
if (state !== undefined) state.pending -= batch.delta;
|
||||
}
|
||||
|
||||
function retryBatch(batch: InflightBatch): void {
|
||||
inflight.delete(batch);
|
||||
batch.attempts += 1;
|
||||
if (batch.attempts >= LIKE_RETRY_MAX_ATTEMPTS) {
|
||||
// 丢弃:撤销乐观量(服务端可能已计入——响应丢失情形由下次校准收敛)。
|
||||
const state = states.get(batch.photoId);
|
||||
if (state !== undefined) state.pending -= batch.delta;
|
||||
console.info(
|
||||
`[viewer] like batch dropped after ${batch.attempts} attempts (batchId=${batch.batchId}); count recalibrates on next interaction`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const delay = LIKE_RETRY_BASE_MS * 2 ** (batch.attempts - 1);
|
||||
const timer = setTimeout(() => {
|
||||
retryTimers.delete(timer);
|
||||
sendBatch(batch);
|
||||
}, delay);
|
||||
retryTimers.add(timer);
|
||||
}
|
||||
|
||||
/** 权威校准(批次响应/burst/stats 推送):版本单调,旧版本忽略。 */
|
||||
function calibrate(photoId: string, total: number, countVersion: number): void {
|
||||
const state = stateOf(photoId);
|
||||
if (countVersion < state.version) return;
|
||||
state.base = total;
|
||||
state.version = countVersion;
|
||||
}
|
||||
|
||||
/** 己方批次在 burst.originBatchIds 中的贡献总量(自身气泡过滤)。 */
|
||||
function ownDeltaIn(batchIds: string[]): number {
|
||||
let own = 0;
|
||||
for (const id of batchIds) {
|
||||
const delta = myBatches.get(id);
|
||||
if (delta !== undefined) own += delta;
|
||||
}
|
||||
return own;
|
||||
}
|
||||
|
||||
/** 列表重取校准(无版本号):base 单调不减(防滞后快照回跳);在途乐观量保留。 */
|
||||
function recalibrateFromList(items: PublicPhoto[] | PublicPhotoList['items']): void {
|
||||
for (const photo of items) {
|
||||
const state = stateOf(photo.photoId, photo.likeCount);
|
||||
if (photo.likeCount > state.base) state.base = photo.likeCount;
|
||||
}
|
||||
}
|
||||
|
||||
// 序列到达/重取(首页、排序切换、刷新)→ 同步服务端计数。
|
||||
watch(
|
||||
() => inputs.photos(),
|
||||
(photos) => recalibrateFromList(photos),
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (flushTimer !== null) clearTimeout(flushTimer);
|
||||
for (const timer of retryTimers) clearTimeout(timer);
|
||||
retryTimers.clear();
|
||||
});
|
||||
|
||||
return reactive({
|
||||
/** 乐观展示计数。 */
|
||||
displayFor,
|
||||
/** 点击动画 nonce(每次点击 +1,模板 :key 重挂触发动画)。 */
|
||||
popNonce: (photoId: string) => states.get(photoId)?.pop ?? 0,
|
||||
like,
|
||||
calibrate,
|
||||
ownDeltaIn,
|
||||
recalibrateFromList,
|
||||
states,
|
||||
});
|
||||
}
|
||||
|
||||
export type LikesController = ReturnType<typeof useLikes>;
|
||||
Reference in New Issue
Block a user