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:
@@ -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;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import type { PublicAlbum, SortMode } from '../../api/client';
|
||||
import { copyText } from '../../support/clipboard';
|
||||
import { shareOrCopy } from '../../support/share';
|
||||
|
||||
// UI-02 V02 活动信息区(从 ActivityPage 提取):宣传图 + 标题/分享 + 说明 +
|
||||
// 元信息行(浏览/日期/地点)+ 排序/分类工具栏。数据与筛选决策仍在父级
|
||||
@@ -122,21 +122,23 @@ const venueText = computed(() => {
|
||||
|
||||
const descriptionText = computed(() => props.album.translation.description);
|
||||
|
||||
// —— 分享(V02 最小实现:复制相册链接;navigator.share 完整编排属 Task 5) ——
|
||||
// —— 分享(V05 §5.4 完整编排):navigator.share 优先(相册标题 + 相册路由
|
||||
// URL,附 lang 参数;不分享 OSS URL),不可用/失败回退剪贴板;复制失败
|
||||
// 保留可选中文本;用户取消原生分享不显示错误 ——
|
||||
const shareState = ref<'idle' | 'copied' | 'failed'>('idle');
|
||||
let shareResetTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const albumUrl = computed(() => {
|
||||
const path = router.resolve({ name: 'activity', params: { token: props.album.publicToken } }).href;
|
||||
return new URL(path, window.location.origin).href;
|
||||
return new URL(`${path}?lang=${locale.value}`, window.location.origin).href;
|
||||
});
|
||||
|
||||
async function share(): Promise<void> {
|
||||
const copied = await copyText(albumUrl.value);
|
||||
shareState.value = copied ? 'copied' : 'failed';
|
||||
const outcome = await shareOrCopy({ title: props.album.translation.title, url: albumUrl.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);
|
||||
@@ -182,7 +184,7 @@ onBeforeUnmount(() => {
|
||||
<header class="album-info">
|
||||
<div class="title-row">
|
||||
<h1 ref="titleEl" class="title" :class="{ clamped: !titleExpanded }">{{ album.translation.title }}</h1>
|
||||
<button type="button" class="share-btn" @click="share">
|
||||
<button type="button" class="share-btn" :aria-label="t('viewer.shareAlbum')" @click="share">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path d="M12 3v12" />
|
||||
<path d="m7 8 5-5 5 5" />
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { reducedMotion } from '../../ui/prefs';
|
||||
|
||||
// UI-02 §6.2/§6.3 装饰心形粒子(Task 5 / V05):coral 半透明心形自舞台右区
|
||||
// 上浮,2-3 条轻微漂移轨迹,约 1.2-1.8s 消散。纯装饰层:pointer-events:none
|
||||
// + aria-hidden(§6.2);同时至多 12 枚(超出弃最旧,§6.3),每枚带自清理
|
||||
// 定时器;页面隐藏/「减少动画」开启/组件卸载即清空,不排队回放历史动画。
|
||||
// 触发源由 PhotoLightbox 编排:当前照片本地点赞(useLikes.onLocalLike)或
|
||||
// 该照片收到他人点赞 WS 气泡(自身回声已在 realtime router 滤除)。计数与
|
||||
// 批次业务完全不受本层影响(§6.1「不为动画补帧额外发送点赞」)。
|
||||
|
||||
/** 同屏粒子上限(§6.3:超出丢弃最旧)。 */
|
||||
const PARTICLE_CAP = 12;
|
||||
/** 漂移轨迹变体(§6.3「2-3 条轻微漂移轨迹」)。 */
|
||||
const DRIFT_PATHS = ['a', 'b', 'c'] as const;
|
||||
|
||||
interface Particle {
|
||||
key: number;
|
||||
/** 起始水平位置(舞台右区,§6.2「大图内靠右侧上浮」;百分比)。 */
|
||||
x: number;
|
||||
/** 起始垂直位置(自底部,百分比)。 */
|
||||
y: number;
|
||||
/** 心形字号 = 粒子边长(18-30px,§6.3)。 */
|
||||
size: number;
|
||||
/** 水平漂移量(px,正负均可)。 */
|
||||
drift: number;
|
||||
/** 动画时长(1.2-1.8s,§6.3)。 */
|
||||
dur: number;
|
||||
path: (typeof DRIFT_PATHS)[number];
|
||||
}
|
||||
|
||||
const particles = ref<Particle[]>([]);
|
||||
|
||||
let nextKey = 0;
|
||||
const removeTimers = new Set<ReturnType<typeof setTimeout>>();
|
||||
|
||||
/** 减少动画判定:应用内偏好或系统 prefers-reduced-motion 任一命中即不漂浮。 */
|
||||
function motionReduced(): boolean {
|
||||
if (reducedMotion.value) return true;
|
||||
if (typeof window.matchMedia === 'function') {
|
||||
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 触发一次上浮(count 会被钳到上限内)。「减少动画」时不生成(§6.3)。 */
|
||||
function burst(count: number): void {
|
||||
if (motionReduced()) return;
|
||||
const created: Particle[] = [];
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const particle: Particle = {
|
||||
key: ++nextKey,
|
||||
x: 62 + Math.round(Math.random() * 26),
|
||||
y: 8 + Math.round(Math.random() * 26),
|
||||
size: 18 + Math.round(Math.random() * 12),
|
||||
drift: Math.round((Math.random() * 2 - 1) * 56),
|
||||
dur: 1200 + Math.round(Math.random() * 600),
|
||||
path: DRIFT_PATHS[nextKey % DRIFT_PATHS.length],
|
||||
};
|
||||
created.push(particle);
|
||||
const timer = setTimeout(() => {
|
||||
removeTimers.delete(timer);
|
||||
particles.value = particles.value.filter((item) => item.key !== particle.key);
|
||||
}, particle.dur + 60); // 动画结束后移除(+60ms 缓冲收尾帧)
|
||||
removeTimers.add(timer);
|
||||
}
|
||||
// 上限裁剪:slice(-0) 会返回整个数组,room<=0 时必须显式清空。
|
||||
const room = PARTICLE_CAP - created.length;
|
||||
particles.value = [...(room > 0 ? particles.value.slice(-room) : []), ...created];
|
||||
}
|
||||
|
||||
function clearAll(): void {
|
||||
for (const timer of removeTimers) clearTimeout(timer);
|
||||
removeTimers.clear();
|
||||
particles.value = [];
|
||||
}
|
||||
|
||||
function onVisibilityChange(): void {
|
||||
// 页面隐藏:清空在屏粒子与计时器(§6.3「恢复后不回放大量历史动画」)。
|
||||
if (document.hidden) clearAll();
|
||||
}
|
||||
|
||||
// 「减少动画」开启瞬间:正在漂浮的立即静止清空(不残留冻结的心形)。
|
||||
watch(reducedMotion, (enabled) => {
|
||||
if (enabled) clearAll();
|
||||
});
|
||||
|
||||
onMounted(() => document.addEventListener('visibilitychange', onVisibilityChange));
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
clearAll();
|
||||
});
|
||||
|
||||
defineExpose({ burst });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="heart-particles-zone" aria-hidden="true">
|
||||
<span
|
||||
v-for="particle in particles"
|
||||
:key="particle.key"
|
||||
class="particle"
|
||||
:class="`drift-${particle.path}`"
|
||||
:style="{
|
||||
left: `${particle.x}%`,
|
||||
bottom: `${particle.y}%`,
|
||||
fontSize: `${particle.size}px`,
|
||||
'--drift': `${particle.drift}px`,
|
||||
'--dur': `${particle.dur}ms`,
|
||||
}"
|
||||
>♥</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 装饰层:不拦截任何指针事件(§6.2);溢出裁剪防漂出舞台。 */
|
||||
.heart-particles-zone {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
.particle {
|
||||
position: absolute;
|
||||
line-height: 1;
|
||||
color: var(--viewer-like-accent, #ff667d);
|
||||
text-shadow: 0 0 8px rgba(255, 102, 125, 0.35);
|
||||
animation: heart-float-a var(--dur) ease-out forwards;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
/* 2-3 条漂移轨迹(§6.3):直线 / 先外后回 / 先内后回,摆幅共用 --drift。 */
|
||||
.drift-b {
|
||||
animation-name: heart-float-b;
|
||||
}
|
||||
.drift-c {
|
||||
animation-name: heart-float-c;
|
||||
}
|
||||
/* 系统「减少动画」:不漂浮(应用内偏好在 burst() 入口已拦截)。 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.particle {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@keyframes heart-float-a {
|
||||
from {
|
||||
transform: translateY(0) translateX(0) scale(0.9);
|
||||
opacity: 0.85;
|
||||
}
|
||||
to {
|
||||
transform: translateY(-220px) translateX(var(--drift)) scale(1.15);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes heart-float-b {
|
||||
from {
|
||||
transform: translateY(0) translateX(0) scale(0.9);
|
||||
opacity: 0.85;
|
||||
}
|
||||
55% {
|
||||
transform: translateY(-120px) translateX(calc(var(--drift) * 1.35)) scale(1);
|
||||
opacity: 0.55;
|
||||
}
|
||||
to {
|
||||
transform: translateY(-220px) translateX(var(--drift)) scale(1.15);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes heart-float-c {
|
||||
from {
|
||||
transform: translateY(0) translateX(0) scale(0.9);
|
||||
opacity: 0.85;
|
||||
}
|
||||
55% {
|
||||
transform: translateY(-120px) translateX(calc(var(--drift) * 0.4)) scale(1.02);
|
||||
opacity: 0.6;
|
||||
}
|
||||
to {
|
||||
transform: translateY(-220px) translateX(var(--drift)) scale(1.15);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,183 @@
|
||||
<script lang="ts">
|
||||
/** 单条他人点赞通知的输入(PhotoLightbox 从 realtime bubbles 派发)。 */
|
||||
export interface InteractionNoticeEntry {
|
||||
photoId: string;
|
||||
delta: number;
|
||||
/** 被赞照片缩略图(thumb 变体;序列未含该照片时为 null → 占位块)。 */
|
||||
thumbUrl: string | null;
|
||||
}
|
||||
|
||||
interface Notice extends InteractionNoticeEntry {
|
||||
key: number;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
// UI-02 §6.2 跨照片互动提示(Task 5 / V05):大图打开期间相册内「其他」照片
|
||||
// 收到他人点赞的紧凑通知——缩略图 + ♥ +delta,点击定位到该照片(保留既有
|
||||
// 可识别提示与点击定位能力)。至多 3 条、约 4s 自动消散(§6.3);页面隐藏/
|
||||
// 组件卸载即清空。不把其他照片的计数加到当前照片(数据面在 useLikes 校准,
|
||||
// 本层纯展示)。
|
||||
// 读屏节流(§6.3「取消逐个气泡 aria-live 播报;交互通知使用节流汇总」):
|
||||
// 列表本身不 aria-live,仅维护一个至多每 5s 更新一次的 role=status 汇总。
|
||||
// 数据由 PhotoLightbox 从 realtime bubbles 派发(自身回声/页面隐藏/减少动画
|
||||
// 已在 realtime router 层滤除——不重复订阅 WS)。
|
||||
|
||||
/** 通知驻留时长(毫秒,§6.3「约 4s」)。 */
|
||||
const NOTICE_MS = 4000;
|
||||
/** 同屏通知上限(§6.3:超出弃最旧)。 */
|
||||
const NOTICE_CAP = 3;
|
||||
/** 读屏汇总节流窗(毫秒)。 */
|
||||
const SUMMARY_THROTTLE_MS = 5000;
|
||||
|
||||
const emit = defineEmits<{ select: [photoId: string] }>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const notices = ref<Notice[]>([]);
|
||||
/** 读屏节流汇总文本(空 = 无待播内容)。 */
|
||||
const srSummary = ref('');
|
||||
|
||||
let nextKey = 0;
|
||||
const dismissTimers = new Set<ReturnType<typeof setTimeout>>();
|
||||
let summaryThrottleTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let summaryPendingDelta = 0;
|
||||
|
||||
/** 新增一条他人点赞通知:超限弃最旧,并安排自动消散。 */
|
||||
function notify(entry: InteractionNoticeEntry): void {
|
||||
const notice: Notice = { key: ++nextKey, ...entry };
|
||||
notices.value = [...notices.value.slice(-(NOTICE_CAP - 1)), notice];
|
||||
const timer = setTimeout(() => {
|
||||
dismissTimers.delete(timer);
|
||||
notices.value = notices.value.filter((item) => item.key !== notice.key);
|
||||
}, NOTICE_MS);
|
||||
dismissTimers.add(timer);
|
||||
announceSummary(entry.delta);
|
||||
}
|
||||
|
||||
function flushSummary(): void {
|
||||
if (summaryPendingDelta <= 0) return;
|
||||
srSummary.value = t('viewer.photoLiked', { delta: summaryPendingDelta });
|
||||
summaryPendingDelta = 0;
|
||||
}
|
||||
|
||||
/** 节流汇总:首条立即播报,节流窗内的后续 delta 合并到下一次。 */
|
||||
function announceSummary(delta: number): void {
|
||||
summaryPendingDelta += delta;
|
||||
if (summaryThrottleTimer !== undefined) return;
|
||||
flushSummary();
|
||||
summaryThrottleTimer = setTimeout(function fireSummary() {
|
||||
summaryThrottleTimer = undefined;
|
||||
if (summaryPendingDelta > 0) {
|
||||
flushSummary();
|
||||
summaryThrottleTimer = setTimeout(fireSummary, SUMMARY_THROTTLE_MS);
|
||||
}
|
||||
}, SUMMARY_THROTTLE_MS);
|
||||
}
|
||||
|
||||
function clearAll(): void {
|
||||
for (const timer of dismissTimers) clearTimeout(timer);
|
||||
dismissTimers.clear();
|
||||
if (summaryThrottleTimer !== undefined) clearTimeout(summaryThrottleTimer);
|
||||
summaryThrottleTimer = undefined;
|
||||
summaryPendingDelta = 0;
|
||||
srSummary.value = '';
|
||||
notices.value = [];
|
||||
}
|
||||
|
||||
function onVisibilityChange(): void {
|
||||
// 页面隐藏:清空在屏通知与计时器(§6.3,不排队回放)。
|
||||
if (document.hidden) clearAll();
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('visibilitychange', onVisibilityChange));
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
clearAll();
|
||||
});
|
||||
|
||||
defineExpose({ notify });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="interaction-notice-zone">
|
||||
<!-- 读屏节流汇总(§6.3):不逐条播报通知,至多每 5s 一次 +delta 汇总。 -->
|
||||
<p class="sr-summary" role="status">{{ srSummary }}</p>
|
||||
<button
|
||||
v-for="notice in notices"
|
||||
:key="notice.key"
|
||||
type="button"
|
||||
class="notice"
|
||||
:aria-label="t('viewer.bubbleOpen')"
|
||||
@click="emit('select', notice.photoId)"
|
||||
>
|
||||
<img v-if="notice.thumbUrl !== null" class="notice-thumb" :src="notice.thumbUrl" alt="" loading="lazy">
|
||||
<span v-else class="notice-thumb notice-placeholder" aria-hidden="true" />
|
||||
<span class="notice-heart" aria-hidden="true">♥</span>
|
||||
<span class="notice-delta" aria-hidden="true">{{ t('viewer.photoLiked', { delta: notice.delta }) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 大图内定位(§6.2「底部操作栏上方的安全区域」):舞台底部、不遮挡主要按钮;
|
||||
* 容器不拦截指针,通知按钮自身可点(定位到被赞照片)。 */
|
||||
.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;
|
||||
}
|
||||
.sr-summary {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.notice {
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.35);
|
||||
border-radius: var(--viewer-radius-pill, 20px);
|
||||
background: rgba(22, 28, 36, 0.92);
|
||||
color: #f3f4f6;
|
||||
padding: 5px 12px 5px 5px;
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.notice:focus-visible {
|
||||
outline: 2px solid var(--viewer-primary, #1a73e8);
|
||||
}
|
||||
.notice-thumb {
|
||||
display: block;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 14px;
|
||||
object-fit: cover;
|
||||
}
|
||||
.notice-placeholder {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
.notice-heart {
|
||||
color: var(--viewer-like-accent, #ff667d);
|
||||
}
|
||||
.notice-delta {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
@@ -17,10 +17,8 @@ const props = withDefaults(
|
||||
likePop?: number;
|
||||
canDownload?: boolean;
|
||||
canDownloadOriginal?: boolean;
|
||||
/** 点赞提交中(预留:连续点赞不禁用,仅视觉态)。 */
|
||||
isLiking?: boolean;
|
||||
}>(),
|
||||
{ likePop: 0, canDownload: false, canDownloadOriginal: false, isLiking: false },
|
||||
{ likePop: 0, canDownload: false, canDownloadOriginal: false },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -99,7 +97,7 @@ onBeforeUnmount(cancelPress);
|
||||
<button
|
||||
type="button"
|
||||
class="action-btn"
|
||||
:aria-label="t('viewer.share')"
|
||||
:aria-label="t('viewer.sharePhoto')"
|
||||
@click="emit('share')"
|
||||
>
|
||||
<svg class="action-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
|
||||
Reference in New Issue
Block a user