feat(viewer): UI-02 heart particles, interaction notices, share and download

Task 5 (V05 §5.4/§6): decorative heart particles on the lightbox stage
(local like clicks + others' WS bursts for the current photo), compact
cross-photo interaction notices above the action bar (click to locate),
navigator.share-first share orchestration with clipboard fallback for
album and photo deep links (lang param), and download failures attributed
to the photo that triggered them. Self-echo dedup stays in the realtime
router (own batches never produce particles/notices); particles cap at
12 with visibility/unmount cleanup and are suppressed under reduced
motion; notices cap at 3 with ~4s auto-dismiss and throttled SR summary.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-21 22:20:19 +08:00
parent 89f45896b7
commit 160e7e5f1a
18 changed files with 1042 additions and 58 deletions
+18
View File
@@ -39,6 +39,10 @@ const MY_BATCHES_CAP = 100;
/** sessionStorage 键:会话标识(每标签页会话一枚,跨刷新复用)。 */
const SESSION_KEY = 'viewer.likeSessionId';
/** 本地点击动效监听器(UI-02 V05):每次有效点击立即通知——与批次发送/重试
* 语义完全解耦(不为动画补帧额外发送点赞,§6.1)。 */
export type LocalLikeListener = (photoId: string) => void;
/** 单照片状态(base=权威累计,pending=未落定乐观量,pop=动画 nonce)。 */
interface PhotoLikeState {
base: number;
@@ -88,6 +92,7 @@ export function useLikes(inputs: LikesInputs) {
let flushTimer: ReturnType<typeof setTimeout> | null = null;
const retryTimers = new Set<ReturnType<typeof setTimeout>>();
const localLikeListeners = new Set<LocalLikeListener>();
function stateOf(photoId: string, seedCount?: number): PhotoLikeState {
let state = states.get(photoId);
@@ -110,6 +115,8 @@ export function useLikes(inputs: LikesInputs) {
state.pending += 1;
state.pop += 1;
queued.set(photoId, (queued.get(photoId) ?? 0) + 1);
// 本地点击动效事件(UI-02 V05):仅通知订阅方(粒子等装饰层),不影响批次。
for (const listener of localLikeListeners) listener(photoId);
if ((queued.get(photoId) ?? 0) >= LIKE_FLUSH_CAP) {
flushQueue();
return;
@@ -119,6 +126,14 @@ export function useLikes(inputs: LikesInputs) {
}
}
/** 订阅本地点击动效事件(UI-02 V05):返回退订函数(订阅方随上下文卸载)。 */
function onLocalLike(listener: LocalLikeListener): () => void {
localLikeListeners.add(listener);
return () => {
localLikeListeners.delete(listener);
};
}
/** 冲队:每照片一批(新 batchId);未满防抖窗的部分留给下一窗。 */
function flushQueue(): void {
if (flushTimer !== null) {
@@ -213,6 +228,7 @@ export function useLikes(inputs: LikesInputs) {
if (flushTimer !== null) clearTimeout(flushTimer);
for (const timer of retryTimers) clearTimeout(timer);
retryTimers.clear();
localLikeListeners.clear();
});
return reactive({
@@ -221,6 +237,8 @@ export function useLikes(inputs: LikesInputs) {
/** 点击动画 nonce(每次点击 +1,模板 :key 重挂触发动画)。 */
popNonce: (photoId: string) => states.get(photoId)?.pop ?? 0,
like,
/** 订阅本地点击动效事件(UI-02 V05 粒子等)。 */
onLocalLike,
calibrate,
ownDeltaIn,
recalibrateFromList,