feat(viewer,ui04): E05 realtime hearts + effect-demo alignment (initial offset, heart params, decode gate, bidirectional manual scroll)

- reactions.ts pure fns: delta->hearts sampling, burstId dedupe (TTL/capacity), pending-visible cache (5s/50), 250ms emission throttle with merge
- useEmbedReactions: read-only RealtimeClient subscription on album.publicToken (no viewer-page side effects, A15), rebind teardown, 10s grace disconnect, no fake bubbles on WS failure
- ReactionLayer: demo-frozen heart effect (12/16/20 sizes, two colors, 110px rise, 1.6s ease-out, 300ms stagger, max 8, drop-shadow) in unscoped block (created elements lack scoped hash)
- demo alignment: initial offset 32px, decode+500ms startup gate, 1:1 drag, arrows +-40, focus-tip, bidirectional manual scroll (manualScroll), loader handle 100x44 exact styles, <=599px offset default 12
- tests: +8 reactions unit (244 viewer), e2e E05: real cross-context like -> heart on correct photo (EMB-A09), zero likes/visits submission (EMB-A15), 16s hearts recording

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-22 17:38:02 +08:00
parent f6ff1d44f2
commit 91c85e196a
15 changed files with 667 additions and 120 deletions
+1
View File
@@ -142,6 +142,7 @@ function openPhoto(photoId: string): void {
<div v-if="isV2 && feed.status !== 'gone'" ref="rootEl" class="embed-root-v2">
<EmbedStream
:photos="feed.status === 'ready' ? feed.photos : []"
:album-token="feed.album?.publicToken ?? null"
:photo-url="streamPhotoUrl"
:speed="streamSpeed"
:reactions="streamReactions"
+174 -73
View File
@@ -1,29 +1,34 @@
<!-- apps/viewer/src/pages/embed/EmbedStream.vue —— UI-04 v2 垂直照片流窗
(EmbedPage 于 v=2 查询时渲染)。结构对齐方案 §2.2;交互层 §4:
· 暂停原因集合(Set,非互斥布尔):hiddenDocument/parentCollapsed(经 active)/
offscreen/hoverFinePointer/focusWithin/pointerActive/manualScroll/
reducedMotion/userPaused/insufficientContent——全部清空才自动滚动;
瞬态原因(指针/焦点/滚轮/拖动)离开后 500ms 恢复;键盘 Space 暂停属
用户显式暂停,不自动恢复(§4.2);
· 点击语义(§4.1):pointerdown 锁定目标 photoId;位移 >8px 或目标已
下线 → 取消点击;photoUrl 失效(换绑)亦拦截;
· 手动查看(§4.3):滚轮/拖动/上下方向键直接平移有限窗口(引擎
nudgeOffset,夹取窗口范围,不重放历史);Escape 收起;Space 暂停切换。
无 header/footer/页码/箭头/圆点(§2.2 硬性)。循环副本对辅助技术隐藏。 -->
(EmbedPage 于 v=2 查询时渲染)。结构与效果对齐方案 §2.2 与效果示例
docs/designs/embed-preview-2026-09-22.html:
· 照片带:原比例、宽满、3px 缝;初始偏移 32px(顶部露首照上缘片段);
· 交互层 §4:暂停原因集合(hidden/offscreen/hover(mouse)/focus/pointer/
manual/reduced/userPaused/decode-loading),瞬态 500ms 恢复,Space 显式
暂停不自动恢复;点击锁定 + >8px 拖动取消(1:1 跟手);滚轮/拖动/方向键
±40px 手动(双向回绕);Escape 收起;
· 气泡层 §5:ReactionLayer(真实 likes.burst 驱动,WS 只读订阅经
useEmbedReactions——不提交点赞不上报浏览 A15);
· 启动闸 §3.1:首窗图片 decode + 500ms 静置后才自动滚动;
· 无 header/footer/页码/箭头/圆点;循环副本对辅助技术隐藏。
-->
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import type { PublicEmbedPhoto } from '../../api/client';
import { useVerticalPhotoStream } from '../../embed/useVerticalPhotoStream';
import { useEmbedReactions } from '../../embed/useEmbedReactions';
import { photoRatio } from '../../embed/stream';
import ReactionLayer from './ReactionLayer.vue';
const props = defineProps<{
photos: PublicEmbedPhoto[];
/** 当前公开相册 token(气泡订阅目标;未解析为 null)。 */
albumToken: string | null;
/** 观众深链构造器(真实 <a> href;点击经锁定目标受控打开,§4.1)。 */
photoUrl: (photoId: string) => string;
/** 自动上滚速度(CSS px/s)。 */
speed: number;
/** 点赞气泡开关(E05 接线;false 时不建粒子层)。 */
/** 点赞气泡开关(false 不订阅不出泡)。 */
reactions: boolean;
/** 组件活动闸门(收起/隐藏/不可见/gone → false,停滚动与轮询)。 */
active: boolean;
@@ -39,6 +44,7 @@ const { t } = useI18n();
const viewportEl = ref<HTMLElement | null>(null);
const trackEl = ref<HTMLElement | null>(null);
const layerEl = ref<InstanceType<typeof ReactionLayer> | null>(null);
// ── 暂停原因集合(§4.2:Set 管理,自动滚动 = 全空)──
const pauseReasons = reactive(new Set<string>());
@@ -80,7 +86,7 @@ const stream = useVerticalPhotoStream({
running: runningRef,
});
// 顶层解构:模板自动解包(嵌套对象属性不参与 unwrap)。
const { items, photoById, dropPhoto, nudgeOffset } = stream;
const { items, photoById, dropPhoto, nudgeOffset, visibleRectOf } = stream;
/** 实例照片元数据(窗口外条目不在渲染列表,索引缺项防御回退 4:3)。 */
function metaOf(photoId: string): PublicEmbedPhoto | null {
@@ -104,7 +110,65 @@ const firstKeyByPhoto = computed(() => {
return first;
});
// ── 指针交互(§4.1/§4.2)──
// ── E05 气泡:真实 burst → 节流合并 → 照片交集下侧出泡(§5.2)──
const albumTokenRef = computed(() => props.albumToken);
const reactionsEnabledRef = computed(() => props.reactions && props.albumToken !== null);
const activeRef = computed(() => props.active);
const reactions = useEmbedReactions({
albumToken: albumTokenRef,
enabled: reactionsEnabledRef,
active: activeRef,
isPhotoVisible: (photoId) => stream.visiblePhotoIds().includes(photoId),
onEmit: (batch) => {
if (props.reducedMotion) return; // §5.3:reducedMotion 不生成动效
for (const [photoId, hearts] of batch) {
const rect = visibleRectOf(photoId);
if (rect === null) continue;
// 起点取交集下侧(originTop 由层内再避让顶部 44px 操作区)。
layerEl.value?.spawn(rect.top + rect.height - 10, hearts);
}
},
});
// 收起/隐藏/换绑/减少动效 → 清粒子(§5.3)。
watch(activeRef, (value) => {
if (!value) layerEl.value?.clear();
});
watch(
() => props.albumToken,
() => layerEl.value?.clear(),
);
watch(
() => props.reducedMotion,
(value) => {
if (value) layerEl.value?.clear();
},
);
// ── 启动闸(§3.1):首窗图片 decode + 500ms 静置——仅首载一次 ──
let bootstrapped = false;
setReason('decode', true);
watch(
items,
(list) => {
if (bootstrapped || list.length === 0) return;
bootstrapped = true;
void nextTick()
.then(() => {
const imgs = Array.from(
trackEl.value?.querySelectorAll<HTMLImageElement>('img') ?? [],
).slice(0, 3); // 首可见区与下一张(§3.3 预加载口径)
return Promise.all(imgs.map((img) => img.decode().catch(() => undefined)));
})
.then(() => {
window.setTimeout(() => setReason('decode', false), 500);
});
},
{ immediate: true },
);
// ── 指针交互(§4.1/§4.2;效果示例 1:1 跟手)──
const DRAG_CANCEL_PX = 8;
let pointerDownY: number | null = null;
let pointerMoved = 0;
@@ -112,6 +176,7 @@ let pointerMoved = 0;
let lockedTarget: string | null = null;
/** 拖动抑制一次 click。 */
let suppressClick = false;
let hoverPointerIsMouse = false;
function scheduleResume(): void {
if (resumeTimer !== null) clearTimeout(resumeTimer);
@@ -128,7 +193,7 @@ function onPointerDown(event: PointerEvent): void {
pointerMoved = 0;
const anchor = (event.target as HTMLElement).closest<HTMLElement>('a.stream-photo');
lockedTarget = anchor === null ? null : (anchor.dataset.photoId ?? null);
setReason('pointerActive', true);
setReason('pointer', true);
}
function onPointerMove(event: PointerEvent): void {
@@ -137,16 +202,16 @@ function onPointerMove(event: PointerEvent): void {
pointerMoved = Math.max(pointerMoved, Math.abs(dy));
if (pointerMoved > DRAG_CANCEL_PX) {
suppressClick = true;
setReason('manualScroll', true);
// 拖动平移:手指上移(dy<0)→ 内容上移(offset 增),同向跟手。
nudgeOffset(-dy * 0.5);
setReason('manual', true);
// 1:1 跟手(效果示例 manual(press.last - e.clientY)):手指上移内容上移。
nudgeOffset(pointerDownY - event.clientY);
pointerDownY = event.clientY;
}
}
function onPointerUp(): void {
pointerDownY = null;
setReason('pointerActive', false);
setReason('pointer', false);
scheduleResume();
}
@@ -167,7 +232,7 @@ function onPhotoRemoved(photoId: string): void {
if (lockedTarget === photoId) lockedTarget = null;
}
// ── 键盘语义(§4.2:聚焦时 Space 暂停切换 / Escape 收起 / 上下键手动)──
// ── 键盘语义(§4.2:Space 暂停切换 / Escape 收起 / 上下键 ±40 手动)──
function onKeydown(event: KeyboardEvent): void {
const target = event.target as HTMLElement | null;
// 不抢输入字段(防御:iframe 内无输入,但保持通用语义)。
@@ -185,50 +250,70 @@ function onKeydown(event: KeyboardEvent): void {
}
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
event.preventDefault();
setReason('manualScroll', true);
nudgeOffset(event.key === 'ArrowDown' ? 60 : -60);
setReason('manual', true);
nudgeOffset(event.key === 'ArrowDown' ? 40 : -40);
scheduleResume();
setTimeout(() => setReason('manualScroll', false), RESUME_DELAY_MS);
setTimeout(() => setReason('manual', false), RESUME_DELAY_MS);
}
}
function onWheel(event: WheelEvent): void {
if (props.photos.length <= 1) return;
event.preventDefault();
setReason('manualScroll', true);
setReason('manual', true);
nudgeOffset(event.deltaY);
scheduleResume();
setTimeout(() => setReason('manualScroll', false), RESUME_DELAY_MS);
setTimeout(() => setReason('manual', false), RESUME_DELAY_MS);
}
// ── 可见性/悬停/焦点(§4.2/§5.3)──
let hoverMedia: MediaQueryList | null = null;
// ── 可见性/悬停/焦点(§4.2/§5.3;效果示例口径)──
let visibilityObserver: IntersectionObserver | null = null;
let manualClearTimer: ReturnType<typeof setTimeout> | null = null;
function onVisibilityChange(): void {
setReason('hiddenDocument', document.hidden);
setReason('hidden', document.hidden);
if (document.hidden) layerEl.value?.clear();
}
function onPointerEnter(): void {
if (hoverMedia?.matches === true) setReason('hoverFinePointer', true);
function onPointerEnter(event: PointerEvent): void {
// 悬停仅鼠标(效果示例 e.pointerType==='mouse'——触摸不粘滞)。
if (event.pointerType === 'mouse') {
hoverPointerIsMouse = true;
if (resumeTimer !== null) {
clearTimeout(resumeTimer);
resumeTimer = null;
}
setReason('hover', true);
}
}
function onPointerLeave(): void {
setReason('hoverFinePointer', false);
scheduleResume();
function onPointerLeave(event: PointerEvent): void {
if (event.pointerType !== 'mouse' || !hoverPointerIsMouse) return;
hoverPointerIsMouse = false;
// 500ms 延时恢复(效果示例 resumeTimer 模式)。
if (resumeTimer !== null) clearTimeout(resumeTimer);
resumeTimer = setTimeout(() => {
resumeTimer = null;
setReason('hover', false);
scheduleResume();
}, RESUME_DELAY_MS);
}
function onFocusIn(): void {
setReason('focusWithin', true);
setReason('focus', true);
}
function onFocusOut(): void {
setReason('focusWithin', false);
scheduleResume();
// 500ms 后复检焦点是否仍在组件内(效果示例 contains(activeElement) 模式,
// 避免子元素间焦点转移的闪烁)。
setTimeout(() => {
if (viewportEl.value === null) return;
if (!viewportEl.value.contains(document.activeElement)) {
setReason('focus', false);
scheduleResume();
}
}, RESUME_DELAY_MS);
}
onMounted(() => {
document.addEventListener('visibilitychange', onVisibilityChange);
onVisibilityChange();
if (typeof window.matchMedia === 'function') {
hoverMedia = window.matchMedia('(hover: hover) and (pointer: fine)');
}
if (typeof IntersectionObserver !== 'undefined' && viewportEl.value !== null) {
visibilityObserver = new IntersectionObserver(
(entries) => {
@@ -242,9 +327,11 @@ onMounted(() => {
onBeforeUnmount(() => {
setReason('destroyed', true);
layerEl.value?.clear();
document.removeEventListener('visibilitychange', onVisibilityChange);
visibilityObserver?.disconnect();
if (resumeTimer !== null) clearTimeout(resumeTimer);
if (manualClearTimer !== null) clearTimeout(manualClearTimer);
});
</script>
@@ -252,6 +339,7 @@ onBeforeUnmount(() => {
<div
ref="viewportEl"
class="stream-viewport"
:class="{ 'reduced-motion': reducedMotion }"
role="region"
tabindex="0"
:aria-label="t('embed.streamLabel')"
@@ -295,40 +383,39 @@ onBeforeUnmount(() => {
<div v-if="photos.length === 0" class="stream-empty" role="note">
{{ t('embed.comingSoon') }}
</div>
<!-- E05 心形气泡层(装饰:pointer-events none + aria-hidden)。 -->
<ReactionLayer v-if="reactions && albumToken !== null" ref="layerEl" />
<!-- 聚焦小提示(效果示例 focus-tip:聚焦时显示键盘操作说明,不常驻)。 -->
<span class="focus-tip" aria-hidden="true">{{ t('embed.streamKeyHint') }}</span>
<!-- 暂停/恢复状态播报(§4.2:aria-live,视觉极简,不常驻)。 -->
<span class="stream-status" aria-live="polite">{{ statusText }}</span>
<!-- E05:HeartParticleLayer(reactions=true 时挂载) -->
<button
type="button"
class="stream-collapse"
:aria-label="t('embed.collapseWindow')"
@click="emit('collapse')"
>
<svg viewBox="0 0 12 12" width="12" height="12" aria-hidden="true" focusable="false">
<path fill="currentColor" d="M2 5.25h8v1.5H2z" />
</svg>
<span aria-hidden="true">−</span>
</button>
</div>
</template>
<style scoped>
/* iframe 内容铺满 loader wrapper 的 195×280 窗(圆角/边框/阴影由宿主负责);
* 深底色与宿主外壳一致,加载/缺口不露宿主内容(§2.1)。 */
* 深底色与宿主外壳一致,加载/缺口不露宿主内容(§2.1)。效果示例 .viewport。 */
.stream-viewport {
position: relative;
width: 100%;
height: 100%;
position: absolute;
inset: 0;
overflow: hidden;
background: #10161e;
outline: none;
touch-action: none;
}
.stream-viewport:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.7);
outline-offset: -2px;
outline: 3px solid #459aff;
outline-offset: -3px;
}
/* 上下 12px 弱暗渐隐(§2.3 可选项,opacity ≤0.12):暗示滚动照片带被窗口
* 裁切(设计稿 soft fade),不遮照片内容、不拦截点击(pointer-events 无)。 */
/* 上下 12px 弱暗渐隐(§2.3 可选项,opacity ≤0.12)。 */
.stream-viewport::before,
.stream-viewport::after {
content: '';
@@ -350,7 +437,7 @@ onBeforeUnmount(() => {
.stream-track {
display: flex;
flex-direction: column;
/* 3px 照片缝(§2.2) */
/* 3px 照片缝(§2.2;效果示例 .photo-link margin-bottom:3px) */
gap: 3px;
width: 100%;
will-change: transform;
@@ -369,10 +456,11 @@ onBeforeUnmount(() => {
background: #f6f8fb;
object-fit: cover;
}
/* 收起入口(§2.4):可视圆 24×24 右上内缩 6px;有效触区 44×44 锚定右上
* (透明扩展),不与照片链接重叠触发。 */
/* 收起入口(§2.4;效果示例 .collapse):44×44 触区右上、可视圆 24×24
* 内缩 6px(#ffffffd1 底、− 字形 19px #647084)。 */
.stream-collapse {
position: absolute;
z-index: 4;
top: 0;
right: 0;
width: 44px;
@@ -381,32 +469,27 @@ onBeforeUnmount(() => {
margin: 0;
border: 0;
background: transparent;
color: #647084;
cursor: pointer;
display: flex;
align-items: flex-start;
justify-content: flex-end;
}
.stream-collapse::before {
content: '';
.stream-collapse span {
position: absolute;
right: 6px;
top: 6px;
width: 24px;
height: 24px;
margin: 6px 6px 0 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.82);
display: flex;
align-items: center;
justify-content: center;
background: #ffffffd1;
display: grid;
place-items: center;
font-size: 19px;
font-weight: 400;
line-height: 1;
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.24);
}
.stream-collapse svg {
position: absolute;
top: 12px;
right: 12px;
color: #647084;
}
.stream-collapse:focus-visible {
outline: 2px solid #ffffff;
outline-offset: -2px;
outline: 3px solid #459aff;
outline-offset: -3px;
border-radius: 12px;
}
.stream-empty {
@@ -418,6 +501,24 @@ onBeforeUnmount(() => {
font: 500 0.75rem/1.4 system-ui, -apple-system, 'Segoe UI', 'PingFang SC', sans-serif;
color: rgba(255, 255, 255, 0.72);
}
/* 聚焦小提示(效果示例 .focus-tip):仅 :focus-visible 显示。 */
.focus-tip {
display: none;
position: absolute;
bottom: 7px;
left: 8px;
right: 8px;
z-index: 3;
color: #ffffff;
background: #13263ede;
padding: 5px 8px;
border-radius: 5px;
font-size: 10px;
pointer-events: none;
}
.stream-viewport:focus-visible .focus-tip {
display: block;
}
/* 状态播报:仅读屏消费,不出布局(§4.2「不常驻抢视觉」)。 */
.stream-status {
position: absolute;
@@ -0,0 +1,122 @@
<!-- apps/viewer/src/pages/embed/ReactionLayer.vue —— UI-04 E05 点赞心形粒子层。
效果参数逐项对齐 docs/designs/embed-preview-2026-09-22.html(效果示例冻结值,
并满足方案 §5.1 值域):
· 形状:实心 SVG 心形(非聊天气泡/非 emoji 字体——跨平台一致);
· 尺寸三档 12/16/20、双色 #FF6B7A/#FF8B98 交替、右缘 10/20/30;
· 动画 float 1.6s ease-out forwards:opacity 0→0.85(15%)→0.75(65%)→0,
上浮 110px(§5.1 值域 90–130)、漂移 var(--drift) ∈ {-8,5,8}px、
旋转 -5°→6°、scale .8→1.05;drop-shadow(0 1px 3px #ee5c7033);
· 一批最多 3 颗、颗间 300ms 错峰(≤250ms 频率上限的保守取值);
· 同屏上限 8;pointer-events none + aria-hidden(§5.1)。
spawn(originTop, count) 由 EmbedStream 依据照片交集下侧定位调用(§5.2
「起点取相应可见照片交集的下侧且不压操作区」——origin 已避开顶部 44px)。 -->
<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue';
const rootEl = ref<HTMLElement | null>(null);
const SIZES = [12, 16, 20] as const;
const COLORS = ['#FF6B7A', '#FF8B98'] as const;
const DRIFTS = ['-8px', '5px', '8px'] as const;
const MAX_ACTIVE = 8;
const STAGGER_MS = 300;
/** 全局序号(尺寸/颜色/漂移按 n%3 轮转——与效果示例 burst() 一致)。 */
let particleSeq = 0;
const timers = new Set<ReturnType<typeof setTimeout>>();
let alive = true;
const HEART_SVG =
'<svg viewBox="0 0 24 24" width="100%" height="100%" fill="currentColor" aria-hidden="true" focusable="false"><path d="M12 21s-9-5.5-9-12a5 5 0 0 1 9-3 5 5 0 0 1 9 3c0 6.5-9 12-9 12Z"/></svg>';
function spawnOne(originTop: number): void {
const root = rootEl.value;
if (root === null || !alive) return;
if (root.childElementCount >= MAX_ACTIVE) return; // 同屏上限(§5.1)
const n = particleSeq++;
const el = document.createElement('span');
el.className = 'heart';
el.innerHTML = HEART_SVG;
const size = SIZES[n % 3];
el.style.cssText = `width:${size}px;height:${size}px;top:${Math.max(
44,
originTop - size,
)}px;right:${10 + (n % 3) * 10}px;color:${COLORS[n % 2]};--drift:${DRIFTS[n % 3]}`;
el.addEventListener('animationend', () => el.remove());
root.appendChild(el);
}
defineExpose({
/** 在 originTop(视口坐标,照片交集下侧)放一颗心(0/1/2/3 颗由调用方
* 按 heartsForDelta 决定;错峰 300ms)。 */
spawn(originTop: number, count: number): void {
const bounded = Math.max(0, Math.min(count, 3));
for (let i = 0; i < bounded; i++) {
const timer = setTimeout(() => {
timers.delete(timer);
spawnOne(originTop);
}, i * STAGGER_MS);
timers.add(timer);
}
},
/** 清空粒子(收起/隐藏/换绑/销毁,§5.3)。 */
clear(): void {
for (const timer of timers) clearTimeout(timer);
timers.clear();
if (rootEl.value !== null) rootEl.value.replaceChildren();
},
});
onBeforeUnmount(() => {
alive = false;
for (const timer of timers) clearTimeout(timer);
timers.clear();
});
</script>
<template>
<div ref="rootEl" class="heart-layer" aria-hidden="true"></div>
</template>
<style scoped>
.heart-layer {
position: absolute;
inset: 0;
pointer-events: none;
overflow: hidden;
z-index: 3;
}
</style>
<!-- 心形样式不可 scoped:粒子元素为运行时 createElement(无 data-v 哈希,
scoped 选择器不命中)。以 .heart-layer 命名空间隔离(本页为嵌入 iframe
专页,无 .heart 类冲突面)。效果值冻结自 embed-preview-2026-09-22.html。 -->
<style>
.heart-layer .heart {
position: absolute;
right: 12px;
opacity: 0;
animation: heart-float 1.6s ease-out forwards;
filter: drop-shadow(0 1px 3px #ee5c7033);
}
@keyframes heart-float {
0% {
opacity: 0;
transform: translate(0, 0) rotate(-5deg) scale(0.8);
}
15% {
opacity: 0.85;
}
65% {
opacity: 0.75;
}
100% {
opacity: 0;
transform: translate(var(--drift), -110px) rotate(6deg) scale(1.05);
}
}
/* 减少动态效果:粒子不生成(生成侧拦截)+ 已在屏粒子立即隐藏。 */
.reduced-motion .heart-layer .heart {
display: none;
}
</style>