fix(viewer): replace text arrow share icon with SVG share glyph

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-22 13:55:37 +08:00
parent 900ff2a74d
commit b353a3de06
54 changed files with 2130 additions and 14 deletions
+99
View File
@@ -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;
}
}
+180
View File
@@ -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 {