fix(viewer): replace text arrow share icon with SVG share glyph
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
// apps/viewer/src/embed/reactions.ts —— E05 点赞气泡纯函数集(UI-04 §5.2)。
|
||||
// 事件校验、视觉抽样(delta → 心形颗数)、burstId 去重表(TTL+容量)、不可见
|
||||
// 照片事件缓存(5s/50 条)全部收敛为纯类/函数(vitest 直接覆盖);WS 编排在
|
||||
// useEmbedReactions.ts。
|
||||
|
||||
/** 视觉抽样(§5.2):delta 1 → 1 颗;2–5 → 2 颗;更大 → 3 颗(受全局限量)。 */
|
||||
export function heartsForDelta(delta: number): number {
|
||||
if (!Number.isFinite(delta) || delta < 1) return 0;
|
||||
if (delta === 1) return 1;
|
||||
if (delta <= 5) return 2;
|
||||
return 3;
|
||||
}
|
||||
|
||||
/** burstId 去重表(§5.2「短期去重表有 TTL 和容量上限」)。 */
|
||||
export class BurstDeduper {
|
||||
private readonly seen = new Map<string, number>();
|
||||
constructor(
|
||||
private readonly ttlMs: number = 30_000,
|
||||
private readonly capacity: number = 200,
|
||||
) {}
|
||||
/** 首见返回 true(记录);重复或超容量驱逐后重见也返回 true。 */
|
||||
accept(burstId: string, now: number): boolean {
|
||||
const prev = this.seen.get(burstId);
|
||||
if (prev !== undefined && now - prev < this.ttlMs) return false;
|
||||
this.evict(now);
|
||||
this.seen.set(burstId, now);
|
||||
return true;
|
||||
}
|
||||
private evict(now: number): void {
|
||||
if (this.seen.size < this.capacity) return;
|
||||
for (const [id, ts] of this.seen) {
|
||||
if (now - ts >= this.ttlMs) this.seen.delete(id);
|
||||
}
|
||||
while (this.seen.size >= this.capacity) {
|
||||
const oldest = this.seen.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
this.seen.delete(oldest);
|
||||
}
|
||||
}
|
||||
clear(): void {
|
||||
this.seen.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/** 不可见照片事件缓存(§5.2:最多 5 秒/50 条,超时丢弃,不为旧事件跳转)。 */
|
||||
export class PendingVisibleCache {
|
||||
private readonly pending = new Map<string, { hearts: number; at: number }>();
|
||||
constructor(
|
||||
private readonly ttlMs: number = 5_000,
|
||||
private readonly capacity: number = 50,
|
||||
) {}
|
||||
/** 入缓存(同照片颗数累加,封顶 3);容量满时丢弃最旧。 */
|
||||
add(photoId: string, hearts: number, now: number): void {
|
||||
const prev = this.pending.get(photoId);
|
||||
const merged = Math.min((prev?.hearts ?? 0) + hearts, 3);
|
||||
this.pending.set(photoId, { hearts: merged, at: now });
|
||||
if (this.pending.size > this.capacity) {
|
||||
const oldest = this.pending.keys().next().value;
|
||||
if (oldest !== undefined) this.pending.delete(oldest);
|
||||
}
|
||||
}
|
||||
/** 取出并移除(照片转为可见时展示);超时返回 0(丢弃)。 */
|
||||
take(photoId: string, now: number): number {
|
||||
const entry = this.pending.get(photoId);
|
||||
this.pending.delete(photoId);
|
||||
if (entry === undefined || now - entry.at > this.ttlMs) return 0;
|
||||
return entry.hearts;
|
||||
}
|
||||
/** 只读 id 快照(巡检遍历用,不改动缓存)。 */
|
||||
snapshotIds(): string[] {
|
||||
return [...this.pending.keys()];
|
||||
}
|
||||
clear(): void {
|
||||
this.pending.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发射节流(§5.2「最大每 250ms 一颗,事件可合并」):冷却期内到达的颗数并入
|
||||
* 待发队列(同照片累加,封顶 3),冷却结束整批放行。
|
||||
*/
|
||||
export class EmissionThrottle {
|
||||
private queue = new Map<string, number>();
|
||||
private lastEmit = -Infinity;
|
||||
constructor(private readonly intervalMs = 250) {}
|
||||
/** 尝试入队并判定本时刻是否放行;放行时返回合并后的 {photoId, hearts} 表。 */
|
||||
offer(photoId: string, hearts: number, now: number): Map<string, number> | null {
|
||||
this.queue.set(photoId, Math.min((this.queue.get(photoId) ?? 0) + hearts, 3));
|
||||
if (now - this.lastEmit < this.intervalMs) return null;
|
||||
this.lastEmit = now;
|
||||
const batch = this.queue;
|
||||
this.queue = new Map();
|
||||
return batch;
|
||||
}
|
||||
clear(): void {
|
||||
this.queue.clear();
|
||||
this.lastEmit = -Infinity;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
// apps/viewer/src/embed/useEmbedReactions.ts —— E05 嵌入组件轻量实时订阅
|
||||
// 编排(UI-04 §5.2/§5.3)。复用底层 RealtimeClient(./realtime/ws.ts),
|
||||
// 不经 useAlbumRealtime(避免带出观众页的浏览上报/点赞提交副作用——嵌入
|
||||
// 组件只读:不提交点赞、不创建 own batch,A15)。
|
||||
//
|
||||
// 生命周期规则(§5.3):
|
||||
// · 相册解析且组件活跃才建连;一个 iframe 最多一个连接;
|
||||
// · 相册换绑(albumToken 变化)→ 先停旧连接、清气泡与去重表、清内容;
|
||||
// · 收起/隐藏超过宽限期(10s)断开省连接,恢复校准后重连(不每一帧
|
||||
// 可见性变化都重连——宽限期合并抖动);
|
||||
// · 卸载清理 socket/timer。
|
||||
// 事件校验(§5.2):photoId 须在当前队列、delta ≥1、burstId 去重(TTL 表);
|
||||
// 相册正确性由 WS 房间隔离保证(服务端 subscribe 按相册房间广播,A10——
|
||||
// 帧 albumId 为内部 ID,客户端仅以订阅 token 隐式关联,不做逐帧比对)。
|
||||
// WS 失败/断线:不伪造气泡(EMB-A10)——照片流照常(取数轮询独立)。
|
||||
import { onBeforeUnmount, watch, type Ref } from 'vue';
|
||||
import type { ServerMessage } from '@photo/contracts/realtime';
|
||||
import { RealtimeClient, wsUrlFromApiBase } from '../realtime/ws';
|
||||
import { apiBase } from '../api/client';
|
||||
import { BurstDeduper, EmissionThrottle, heartsForDelta, PendingVisibleCache } from './reactions';
|
||||
|
||||
/** 收起/隐藏后断连宽限期(ms):合并可见性抖动,短暂停不停连接。 */
|
||||
const DISCONNECT_GRACE_MS = 10_000;
|
||||
/** pending 非空时的可见性巡检间隔(ms)。 */
|
||||
const PENDING_SWEEP_MS = 500;
|
||||
|
||||
export interface UseEmbedReactionsOptions {
|
||||
/** 当前公开相册 token(未解析/换绑为 null)。 */
|
||||
albumToken: Ref<string | null>;
|
||||
/** reactionsEnabled 且组件非 gone(false 时不建连)。 */
|
||||
enabled: Ref<boolean>;
|
||||
/** feed.active(收起/隐藏/不可见 → false)。 */
|
||||
active: Ref<boolean>;
|
||||
/** 照片可见性判定(stream.visiblePhotoIds 包装)。 */
|
||||
isPhotoVisible: (photoId: string) => boolean;
|
||||
/** 放行一批心形(已合并/节流;hearts 已封顶 3)。 */
|
||||
onEmit: (batch: Map<string, number>) => void;
|
||||
/** 测试注入时钟/套接字工厂。 */
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export function useEmbedReactions(options: UseEmbedReactionsOptions) {
|
||||
const { albumToken, enabled, active, isPhotoVisible, onEmit } = options;
|
||||
const now = options.now ?? (() => Date.now());
|
||||
|
||||
const deduper = new BurstDeduper();
|
||||
const pending = new PendingVisibleCache();
|
||||
const throttle = new EmissionThrottle();
|
||||
|
||||
let client: RealtimeClient | null = null;
|
||||
let subscribedToken: string | null = null;
|
||||
let disconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let sweepTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function stopSweep(): void {
|
||||
if (sweepTimer !== null) {
|
||||
clearInterval(sweepTimer);
|
||||
sweepTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startSweepIfPending(): void {
|
||||
if (sweepTimer === null) {
|
||||
sweepTimer = setInterval(() => sweepPending(), PENDING_SWEEP_MS);
|
||||
}
|
||||
}
|
||||
|
||||
/** 巡检:缓存中的照片转为可见 → 立即出泡;顺带清 TTL。 */
|
||||
function sweepPending(): void {
|
||||
const visible = isPhotoVisible;
|
||||
let any = false;
|
||||
// take 内部清理过期项;可见者并入节流队列。
|
||||
for (const photoId of [...cachedIds()]) {
|
||||
const hearts = pendingTake(photoId);
|
||||
if (hearts > 0 && visible(photoId)) {
|
||||
const batch = throttle.offer(photoId, hearts, now());
|
||||
if (batch !== null) onEmit(batch);
|
||||
} else if (hearts > 0) {
|
||||
// 仍不可见但未超时:放回(take 已删——重新入表沿用原时刻语义可接受,
|
||||
// 概率极低且粒度无感知;此处直接丢弃亦可,选择保守放回)。
|
||||
pending.add(photoId, hearts, now());
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
if (!any) stopSweep();
|
||||
}
|
||||
|
||||
// pending 缓存的只读 id 快照与 take 代理(隔离内部类型)。
|
||||
const cachedIds = (): string[] => pending.snapshotIds();
|
||||
const pendingTake = (photoId: string): number => pending.take(photoId, now());
|
||||
|
||||
function handleBurst(message: ServerMessage): void {
|
||||
if (message.type !== 'likes.burst') return;
|
||||
if (!Number.isFinite(message.delta) || message.delta < 1) return;
|
||||
if (!deduper.accept(message.burstId, now())) return;
|
||||
const hearts = heartsForDelta(message.delta);
|
||||
if (hearts <= 0) return;
|
||||
if (isPhotoVisible(message.photoId)) {
|
||||
const batch = throttle.offer(message.photoId, hearts, now());
|
||||
if (batch !== null) onEmit(batch);
|
||||
} else {
|
||||
pending.add(message.photoId, hearts, now());
|
||||
startSweepIfPending();
|
||||
}
|
||||
}
|
||||
|
||||
function stopClient(): void {
|
||||
client?.stop();
|
||||
client = null;
|
||||
subscribedToken = null;
|
||||
}
|
||||
|
||||
function clearAll(): void {
|
||||
deduper.clear();
|
||||
pending.clear();
|
||||
throttle.clear();
|
||||
stopSweep();
|
||||
}
|
||||
|
||||
function teardown(): void {
|
||||
stopClient();
|
||||
clearAll();
|
||||
if (disconnectTimer !== null) {
|
||||
clearTimeout(disconnectTimer);
|
||||
disconnectTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 活跃度变化:恢复 → 立即校准重连;失活 → 宽限期后断开(§5.3)。 */
|
||||
function onActiveChange(value: boolean): void {
|
||||
if (disconnectTimer !== null) {
|
||||
clearTimeout(disconnectTimer);
|
||||
disconnectTimer = null;
|
||||
}
|
||||
if (value) {
|
||||
if (client === null && subscribedToken !== albumToken.value) startClient();
|
||||
} else if (client !== null) {
|
||||
disconnectTimer = setTimeout(() => {
|
||||
disconnectTimer = null;
|
||||
stopClient();
|
||||
}, DISCONNECT_GRACE_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function startClient(): void {
|
||||
const token = albumToken.value;
|
||||
if (token === null || !enabled.value || !active.value) return;
|
||||
stopClient();
|
||||
clearAll(); // 换绑:清旧内容与去重(§5.3)
|
||||
client = new RealtimeClient({
|
||||
url: () => wsUrlFromApiBase(apiBase()),
|
||||
albumToken: () => albumToken.value ?? '',
|
||||
onMessage: (message) => handleBurst(message),
|
||||
onStateChange: () => undefined,
|
||||
onResync: () => undefined, // 只读计数流无需补播(§4.6 观众页语义不适用)
|
||||
});
|
||||
client.start();
|
||||
subscribedToken = token;
|
||||
}
|
||||
|
||||
// 换绑/开关/活跃度联动。
|
||||
watch(
|
||||
[albumToken, enabled],
|
||||
([token, on]) => {
|
||||
if (token !== null && on) startClient();
|
||||
else teardown();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
watch(active, onActiveChange);
|
||||
|
||||
onBeforeUnmount(teardown);
|
||||
|
||||
return {
|
||||
/** 测试接口:直接注入一帧(绕过 WS;模拟管理预览/单测)。 */
|
||||
ingestForTest(message: ServerMessage): void {
|
||||
handleBurst(message);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -216,6 +216,27 @@ export function useVerticalPhotoStream(options: UseVerticalPhotoStreamOptions) {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 照片在视口内的交集矩形(E05 气泡起点):top/height 为视口坐标;不可见
|
||||
* (无交集)返回 null。同一照片多副本时取首个有交集实例。
|
||||
*/
|
||||
function visibleRectOf(photoId: string): { top: number; height: number } | null {
|
||||
const s = state.value;
|
||||
if (s === null) return null;
|
||||
let top = -s.offset;
|
||||
for (const item of s.items) {
|
||||
const h = photoDisplayHeight(s.sizes[item.queueIdx], innerWidth);
|
||||
const visibleTop = Math.max(0, top);
|
||||
const visibleBottom = Math.min(viewportHeight, top + h);
|
||||
if (item.photoId === photoId && visibleBottom > visibleTop) {
|
||||
return { top: visibleTop, height: visibleBottom - visibleTop };
|
||||
}
|
||||
top += h + PHOTO_GAP_PX;
|
||||
if (top >= viewportHeight) break;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
watch(photos, (next) => reanchor(next), { immediate: false });
|
||||
watch(running, (value) => {
|
||||
if (value) startLoop();
|
||||
@@ -250,6 +271,7 @@ export function useVerticalPhotoStream(options: UseVerticalPhotoStreamOptions) {
|
||||
dropPhoto,
|
||||
nudgeOffset,
|
||||
visiblePhotoIds,
|
||||
visibleRectOf,
|
||||
/** 测试接口(§11.4 受控时间):绕过 rAF 按给定 dt 推进一帧并同步渲染。
|
||||
* 仅单测使用;生产路径不经过本入口。单张/零张静态(不推进,§3.2.8/9)。 */
|
||||
stepForTest(dtMs: number): void {
|
||||
|
||||
Reference in New Issue
Block a user