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
+83 -31
View File
@@ -5,9 +5,12 @@ import type { DownloadVariant } from '../api/client';
import type { PhotoSequence } from '../feed/sequence';
import type { DownloadController } from '../download/useDownload';
import type { LikesController } from '../likes/useLikes';
import type { LikeBubble } from '../realtime/useAlbumRealtime';
import PhotoActionBar from './viewer/PhotoActionBar.vue';
import HeartParticles from './viewer/HeartParticles.vue';
import InteractionNotice from './viewer/InteractionNotice.vue';
import ThumbnailStrip, { type ThumbnailItem } from './viewer/ThumbnailStrip.vue';
import { copyText } from '../support/clipboard';
import { shareOrCopy } from '../support/share';
import { reducedMotion, toggleReducedMotion } from '../ui/prefs';
import { positionDisplay } from '../viewer/position';
import { MIN_SCALE, panBy, resetZoom, wheelFactor, zoomAt, type ZoomState } from '../viewer/zoom';
@@ -44,6 +47,13 @@ const props = defineProps<{
likes?: LikesController;
/** 下载控制器(P4a Task 4):缺省时不渲染下载位(组件单测/降级组装)。 */
download?: DownloadController;
/** 相册标题(§5.4 navigator.share 的分享标题)。 */
albumTitle?: string;
/** 当前照片分享深链(含 lang 参数,ActivityPage 组装;缺省回退当前地址)。 */
shareUrl?: string;
/** 他人点赞气泡(WS likes.burst,realtime router 已滤自身回声/隐藏/减少动画):
* 当前照片 → 心形粒子,其他照片 → 跨照片互动提示(§6.2 分层)。 */
likeBubbles?: LikeBubble[];
}>();
const emit = defineEmits<{
@@ -361,6 +371,53 @@ function likeCurrent(): void {
props.likes.like(photo.value.photoId);
}
// —— 互动编排(Task 5 / V05 §6.2):当前照片心形粒子 + 其他照片互动提示分层 ——
const heartParticles = ref<{ burst: (count: number) => void } | null>(null);
const interactionNotice = ref<{ notify: (entry: { photoId: string; delta: number; thumbUrl: string | null }) => void } | null>(null);
/** 本地点赞每次有效点击的粒子数(1-3 取中,§6.1 立即轻量反馈)。 */
const LOCAL_LIKE_PARTICLES = 2;
// 本地点击(useLikes 动效事件):当前照片 → 舞台右区心形粒子。
let stopLocalLikeListener: (() => void) | undefined;
if (props.likes !== undefined) {
stopLocalLikeListener = props.likes.onLocalLike((photoId) => {
if (photoId === props.photoId) heartParticles.value?.burst(LOCAL_LIKE_PARTICLES);
});
}
// 他人点赞气泡(§6.2 两类反馈分开):当前照片 → 粒子(1-2 枚按 delta 采样,
// 数值仍按真实 delta 校准——视觉采样不改变统计,§6.3);其他照片 → 互动提示。
// 仅处理挂载后新到的气泡:打开大图瞬间已在屏的不回放(§6.3 不回放历史动画)。
let bubblesInitialized = false;
let maxSeenBubbleKey = Number.NEGATIVE_INFINITY;
watch(
() => props.likeBubbles,
(bubbles) => {
if (bubbles === undefined) return;
if (!bubblesInitialized) {
bubblesInitialized = true;
for (const bubble of bubbles) maxSeenBubbleKey = Math.max(maxSeenBubbleKey, bubble.key);
return;
}
for (const bubble of bubbles) {
if (bubble.key <= maxSeenBubbleKey) continue;
maxSeenBubbleKey = bubble.key;
if (bubble.photoId === props.photoId) {
heartParticles.value?.burst(Math.min(2, Math.max(1, bubble.delta)));
} else {
interactionNotice.value?.notify({
photoId: bubble.photoId,
delta: bubble.delta,
thumbUrl: bubble.thumbUrl,
});
}
}
},
{ immediate: true },
);
// —— 下载(P4a Task 4):入口与二级菜单在 PhotoActionBar,控制器编排在此 ——
const canDownload = computed(() => props.download !== undefined && props.download.canDownload);
@@ -370,20 +427,22 @@ function onDownloadVariant(variant: DownloadVariant): void {
void props.download?.download(variant);
}
// —— 分享(V04 最小实现:复制当前照片深链——路由已同步 /p/:photoId;
// navigator.share 编排与语言参数属 Task 5/V05) ——
// —— 分享(V05 §5.4):navigator.share 优先(成功/取消无额外反馈——原生面板
// 已收尾;AbortError 静默),不可用或其他失败回退剪贴板;复制失败保留
// 可选中文本。分享目标为照片深链(含 lang 参数,ActivityPage 组装),
// 不分享 OSS 签名 URL ——
const shareState = ref<'idle' | 'copied' | 'failed'>('idle');
const shareUrlText = ref('');
let shareResetTimer: ReturnType<typeof setTimeout> | undefined;
async function shareCurrent(): Promise<void> {
shareUrlText.value = window.location.href;
const copied = await copyText(shareUrlText.value);
shareState.value = copied ? 'copied' : 'failed';
shareUrlText.value = props.shareUrl ?? window.location.href;
const outcome = await shareOrCopy({ title: props.albumTitle ?? '', url: shareUrlText.value });
shareState.value = outcome === 'copied' ? 'copied' : outcome === 'failed' ? 'failed' : 'idle';
if (shareResetTimer !== undefined) clearTimeout(shareResetTimer);
// 失败时保留可选中文本(不自动消失),成功反馈数秒后收起。
if (copied) {
if (outcome === 'copied') {
shareResetTimer = setTimeout(() => {
shareState.value = 'idle';
}, 3000);
@@ -461,6 +520,7 @@ onBeforeUnmount(() => {
document.removeEventListener('pointerdown', onOuterPointerDown, true);
document.body.style.overflow = savedBodyOverflow;
if (shareResetTimer !== undefined) clearTimeout(shareResetTimer);
stopLocalLikeListener?.(); // 粒子订阅随大图卸载解除(useLikes 侧不残留)
// 焦点归还触发元素(瀑布流单元格;深链直达时为 body——无可归还则不动)。
if (savedFocus !== null && savedFocus.isConnected) savedFocus.focus();
});
@@ -565,20 +625,27 @@ onBeforeUnmount(() => {
</svg>
</button>
<!-- 心形粒子层(Task 5 / V05 填充):装饰性、不可交互 -->
<div class="heart-particles-zone" aria-hidden="true" style="pointer-events: none" />
<!-- 跨照片互动提示层(Task 5 / V05 填充):底部操作栏上方安全区 -->
<div class="interaction-notice-zone" />
<!-- 心形粒子层(Task 5 / V05 §6.2):装饰性、不可交互;触发见上方互动编排 -->
<HeartParticles ref="heartParticles" />
<!-- 跨照片互动提示层(Task 5 / V05 §6.2):底部操作栏上方安全区;
点击定位 = 缩略图条共用入口(序列未含该照片时忽略——不伪造定位) -->
<InteractionNotice ref="interactionNotice" @select="selectThumbnail" />
<div class="stage-notices">
<p v-if="extending" class="loading">{{ t('album.loading') }}</p>
<!-- 分享反馈轻提示(复制成功/失败;失败保留可选中文本) -->
<!-- 分享反馈轻提示(回退复制成功/失败;失败保留可选中文本;原生分享
成功/取消不显示——面板已收尾,§5.4) -->
<p v-if="shareState !== 'idle'" class="share-status" role="status">
{{ shareState === 'copied' ? t('viewer.shareCopied') : t('viewer.shareCopyFailed') }}
<span v-if="shareState === 'failed'" class="share-url">{{ shareUrlText }}</span>
</p>
<!-- 下载失败轻提示(端点失败/网络;DOWNLOAD_DISABLED 专属文案)。 -->
<p v-if="download !== undefined && download.failed" class="download-failed" role="alert">
<!-- 下载失败轻提示(端点失败/网络;DOWNLOAD_DISABLED 专属文案):
仅归属到触发下载的那张照片(§5.4 异步错误归属)。 -->
<p
v-if="download !== undefined && download.failed && download.failedFor === photo.photoId"
class="download-failed"
role="alert"
>
{{ t(download.failed) }}
</p>
</div>
@@ -821,23 +888,8 @@ onBeforeUnmount(() => {
right: 8px;
}
/* 粒子/互动占位层(Task 5 填充):舞台右侧上浮 / 操作栏上方安全区。 */
.heart-particles-zone {
position: absolute;
inset: 0;
overflow: hidden;
}
.interaction-notice-zone {
position: absolute;
bottom: 12px;
left: 16px;
right: 16px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
pointer-events: none;
}
/* 粒子层/互动提示层的定位与样式由 HeartParticles / InteractionNotice 自带
* (Task 5:舞台右侧上浮 / 操作栏上方安全区)。 */
.stage-notices {
position: absolute;
bottom: 12px;