feat(embed,ui04): E01 v2 contract skeleton — displayMode/scrollSpeed/reactionsEnabled, v2 loader entry and fixed 195x280 shell

- contracts: EmbedSlotSettings optional v2 fields (carousel default) + SlotSettingsPayload; gen + redocly green
- api: DTO validation + normalization full-set (cn manage + ov public projection share normalizeStoredSettings)
- embed-loader: params v2 parse/merge/url family, loader-v2.js fixed shell (border/radius/#10161E/safe-area, resize ignored, stack gap 12), dual-artifact build+sync; v1 source frozen
- viewer: EmbedPage v=2 branch, EmbedStream (viewport/track/real anchors/collapse/empty), stream.ts pure fns, i18n zh/en
- admin: types + utils (create default vertical-stream, clampScrollSpeed, v1/v2 snippet selection)
- tests: loader 36 (+9 v2), viewer 222, admin 82, api unit 51, v1 embed e2e 20, v2 E01 gate 2 (195x280 desktop+mobile cross-origin, no footer)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-22 12:50:28 +08:00
parent f8dbc3ab9b
commit 66d1eb3e9f
25 changed files with 1383 additions and 52 deletions
@@ -54,6 +54,10 @@ function makeSettings(overrides: Partial<EmbedSlotSettings> = {}): EmbedSlotSett
offsetX: 16,
offsetBottom: 24,
zIndex: 1000,
// UI-04 v2 字段(响应恒为归一化全集;旧记录读取归一为 carousel)。
displayMode: 'carousel',
scrollSpeed: 16,
reactionsEnabled: true,
...overrides,
};
}
+62
View File
@@ -0,0 +1,62 @@
// apps/viewer/src/embed/stream.ts —— v2 垂直照片流纯函数集(UI-04 §2.3/§3.2)。
// 高度比例防御、环形队列窗口数学、锚定计算全部收敛为纯函数(vitest 直接覆盖);
// 组件/编排层(EmbedStream.vue / useVerticalPhotoStream.ts)只做装配。
/** 元数据异常时的高度比例回退(4:3,§2.3)。 */
export const FALLBACK_RATIO = 4 / 3;
/** 超长图安全钳(高度/宽度上限;防无穷高度破坏循环,§2.3)。 */
export const MAX_RATIO = 8;
/** 照片缝宽度(px,§2.2 硬性 3px)。 */
export const PHOTO_GAP_PX = 3;
export interface StreamPhotoSize {
width: number;
height: number;
}
/** 元数据 → 高度比例(height/width);宽高须 >0 且有限,否则回退 4:3。 */
export function photoRatio(size: StreamPhotoSize): number {
if (
Number.isFinite(size.width) &&
Number.isFinite(size.height) &&
size.width > 0 &&
size.height > 0
) {
return Math.min(size.height / size.width, MAX_RATIO);
}
return FALLBACK_RATIO;
}
/** 容器内宽 → 照片展示高度(按比例;内宽 = 窗宽 - 边框)。 */
export function photoDisplayHeight(size: StreamPhotoSize, innerWidth: number): number {
return photoRatio(size) * innerWidth;
}
/**
* 环形队列窗口(§3.2 步骤 1):给定队列、窗口高度与预载余量,计算从
* startIndex 起需要装载的条目数(高度和 ≥ 窗口高 + 上下余量)。返回条目
* 数量(至少 1,队列空返回 0)。纯计算——实际 DOM 复用由编排层负责。
*/
export function windowCount(
sizes: StreamPhotoSize[],
startIndex: number,
innerWidth: number,
viewportHeight: number,
bufferPx: number,
): number {
if (sizes.length === 0) return 0;
const target = viewportHeight + bufferPx;
let acc = 0;
let count = 0;
for (let i = 0; i < sizes.length; i++) {
const item = sizes[(startIndex + i) % sizes.length];
acc += photoDisplayHeight(item, innerWidth) + PHOTO_GAP_PX;
count += 1;
if (acc >= target) return count;
}
// 一轮高度不足以覆盖窗口(极矮照片):返回整队(§3.2.7 少量可见循环
// 副本由编排层处理,本函数不放大队列)。
return count;
}
+3
View File
@@ -33,6 +33,9 @@ export default {
play: 'Play carousel',
prev: 'Previous photo',
next: 'Next photo',
// UI-04 v2 vertical photo stream window
streamLabel: 'Event photo window',
collapseWindow: 'Collapse photo window',
},
download: {
action: 'Download photo',
+3
View File
@@ -34,6 +34,9 @@ export default {
play: '播放轮播',
prev: '上一张',
next: '下一张',
// —— UI-04 v2 垂直照片流窗 ——
streamLabel: '活动照片窗',
collapseWindow: '收起照片窗',
},
download: {
action: '下载图片',
+63 -10
View File
@@ -1,13 +1,15 @@
<script setup lang="ts">
// apps/viewer/src/pages/EmbedPage.vue —— 嵌入轮播 iframe 页薄壳(路由 /embed/:slotToken
// + 数据/lifecycle 编排;P4b Task 4 创建,UI-01 Task 6 拆分)。独立精简布局:
// 无顶栏/页脚,App.vue 按 meta.layout 裸渲染。宿主 loader 以 iframe 载入本页
// (280-320px 小视口设计):
// · 数据/轮询/暂停/收起/destroy 编排见 ../embed/useEmbedFeed.ts;
// · 切换逻辑/舞台/底栏见 ./embed/(EmbedCarousel → EmbedStage + EmbedFooterBar);
// · 头部:通用「活动照片」标题 + 收起 ×(全状态可用,含空态——§5.3 断网
// 无缓存可收起;有效点击区 ≥44×44);
// · 空相册 → 「照片即将上线」小卡;槽 404 → destroy 后无 UI(feed.status gone);
// apps/viewer/src/pages/EmbedPage.vue —— 嵌入 iframe 页薄壳(路由 /embed/:slotToken
// + 数据/lifecycle 编排;P4b Task 4 创建,UI-01 Task 6 拆分,UI-04 增 v2 分支)。
// 独立精简布局:无顶栏/页脚,App.vue 按 meta.layout 裸渲染。宿主 loader 以 iframe
// 载入本页:
// · 数据/轮询/暂停/收起/destroy 编排见 ../embed/useEmbedFeed.ts(v1/v2 共用);
// · v=2(UI-04):./embed/EmbedStream.vue 垂直照片流窗(无 header/footer,
// 右上收起,195×280 外窗由 loader v2 负责);speed/reactions 查询为宿主
// data-* 显式覆盖,后台 settings.scrollSpeed/reactionsEnabled 次之;
// · v1(缺省):./embed/(EmbedCarousel → EmbedStage + EmbedFooterBar)+
// 通用「活动照片」标题头 + 收起 ×;
// · 空相册 → 「照片即将上线」;槽 404 → destroy 后无 UI(feed.status gone);
// · 语言独立于宿主与 localStorage:lang 查询(auto→相册 defaultLocale);
// · interval/fit 查询为宿主 data-* 显式覆盖(loader 仅显式透传);后台设置
// 经响应 settings 生效(显式 > 后台 > 默认,§5.4)。
@@ -17,6 +19,7 @@ import { useI18n } from 'vue-i18n';
import { useEmbedFeed } from '../embed/useEmbedFeed';
import { clampEmbedIntervalMs, EMBED_CAROUSEL_DEFAULT_MS } from '../embed/feed';
import EmbedCarousel from './embed/EmbedCarousel.vue';
import EmbedStream from './embed/EmbedStream.vue';
const route = useRoute();
const { t, locale } = useI18n();
@@ -31,6 +34,11 @@ const langParam = queryText(route.query.lang) ?? 'auto';
const intervalText = queryText(route.query.interval);
const intervalParamMs = intervalText === null ? null : Number.parseInt(intervalText, 10);
const fitParam = queryText(route.query.fit);
// UI-04 v2:loader v2 显式透传的速度/气泡开关(缺省不拼——后台设置生效)。
const speedText = queryText(route.query.speed);
const speedParam = speedText === null ? null : Number.parseInt(speedText, 10);
const reactionsOff = queryText(route.query.reactions) === 'off';
const isV2 = queryText(route.query.v) === '2';
// dev/e2e 轮询加速(跨源宿主可用;生产构建忽略——loader 侧同样仅显式透传)。
const pollText = queryText(route.query.poll);
const pollParamMs =
@@ -85,6 +93,22 @@ const imageFit = computed<'contain' | 'cover'>(() => {
return feed.settings?.imageFit ?? 'contain';
});
// UI-04 v2 上滚速度(CSS px/s):查询显式(夹取 8..32)→ 后台设置 → 缺省 16。
const streamSpeed = computed(() => {
if (speedParam !== null && Number.isFinite(speedParam)) {
return Math.min(32, Math.max(8, Math.round(speedParam)));
}
const s = feed.settings?.scrollSpeed;
return typeof s === 'number' && Number.isFinite(s) ? Math.min(32, Math.max(8, Math.round(s))) : 16;
});
// UI-04 v2 气泡开关:查询显式 off → false;否则后台设置 → 缺省 true。
const streamReactions = computed(() => {
if (reactionsOff) return false;
const s = feed.settings?.reactionsEnabled;
return typeof s === 'boolean' ? s : true;
});
const albumUrl = computed(() => {
const albumToken = feed.album?.publicToken;
if (albumToken === undefined) return null;
@@ -93,6 +117,15 @@ const albumUrl = computed(() => {
return url.toString();
});
/** v2 照片深链构造器(真实 <a> href;受控打开同 openPhoto)。 */
function streamPhotoUrl(photoId: string): string {
const albumToken = feed.album?.publicToken;
const base = albumToken ?? '';
const url = new URL(`/a/${encodeURIComponent(base)}/p/${encodeURIComponent(photoId)}`, window.location.origin);
url.searchParams.set('lang', feed.resolvedLocale ?? 'zh-CN');
return url.toString();
}
function openPhoto(photoId: string): void {
const albumToken = feed.album?.publicToken;
if (albumToken === undefined) return;
@@ -104,7 +137,21 @@ function openPhoto(photoId: string): void {
</script>
<template>
<div v-if="feed.status !== 'gone'" ref="rootEl" class="embed-root" :class="{ 'is-empty': feed.status !== 'ready' }">
<!-- v2(UI-04):无 header/footer 的垂直照片流窗(外壳尺寸/圆角/边框由宿主
loader v2 wrapper 负责;本页铺满窗口)。 -->
<div v-if="isV2 && feed.status !== 'gone'" ref="rootEl" class="embed-root-v2">
<EmbedStream
:photos="feed.status === 'ready' ? feed.photos : []"
:photo-url="streamPhotoUrl"
:speed="streamSpeed"
:reactions="streamReactions"
:active="feed.active"
:reduced-motion="reducedMotion"
@open-photo="openPhoto"
@collapse="feed.requestCollapse()"
/>
</div>
<div v-else-if="feed.status !== 'gone'" ref="rootEl" class="embed-root" :class="{ 'is-empty': feed.status !== 'ready' }">
<div class="embed-head">
<span class="embed-heading">{{ t('embed.widgetTitle') }}</span>
<button type="button" class="embed-close" :aria-label="t('embed.close')" @click="feed.requestCollapse()">
@@ -132,6 +179,12 @@ function openPhoto(photoId: string): void {
</template>
<style scoped>
/* v2 根:铺满宿主 195×280 外窗。iframe 内 html/body 无高度链(v1 为内容高
* 设计),fixed+inset0 直接锚定 iframe 视口——不引入影响 v1 的全局样式。 */
.embed-root-v2 {
position: fixed;
inset: 0;
}
/* iframe 内容适配宿主卡片:html/body 无背景(App.vue 基线未设背景,天然透亮,
* 圆角/阴影由宿主 loader wrapper 提供),根元素白底小卡 280-320px 设计。 */
.embed-root {
+168
View File
@@ -0,0 +1,168 @@
<!-- apps/viewer/src/pages/embed/EmbedStream.vue —— UI-04 v2 垂直照片流窗
(EmbedPage 于 v=2 查询时渲染)。结构对齐方案 §2.2:
PhotoStreamViewport(relative/overflow hidden/手动模式滚动)
Track(垂直照片实例带,translateY 驱动——E03 接线)
PhotoLink > img(原比例、宽满、block;真实 <a> 指向观众深链 §4.1)
HeartParticleLayer(E05:真实 likes.burst 粒子,pointer-events none)
CollapseButton(右上收起,§2.4 规格)
StateOverlay(loading/empty/error 按需)
无 header/footer/页码/箭头/圆点(§2.2 硬性)。图片高度按元数据比例预留
(异常宽高回退 4:3 占位,§2.3);间距 3px;外窗(loader wrapper)负责整体
圆角与边框,内部不再加圆角。 -->
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import type { PublicEmbedPhoto } from '../../api/client';
import { photoRatio } from '../../embed/stream';
const props = defineProps<{
photos: PublicEmbedPhoto[];
/** 观众深链构造器(真实 <a> href;点击经锁定目标受控打开,§4.1)。 */
photoUrl: (photoId: string) => string;
/** 自动上滚速度(CSS px/s;reducedMotion/暂停时不动,E03 接线 rAF)。 */
speed: number;
/** 点赞气泡开关(E05 接线;false 时不建粒子层)。 */
reactions: boolean;
/** 组件活动闸门(收起/隐藏/不可见/gone → false,停滚动与轮询)。 */
active: boolean;
reducedMotion: boolean;
}>();
const emit = defineEmits<{
(e: 'open-photo', photoId: string): void;
(e: 'collapse'): void;
}>();
const { t } = useI18n();
const ratios = computed(() => props.photos.map((p) => photoRatio(p)));
// ── 点击语义(§4.1;拖动取消/目标锁定在 E04 随滚动接线补全)──
function onPhotoClick(photo: PublicEmbedPhoto): void {
if (!props.active) return;
emit('open-photo', photo.photoId);
}
</script>
<template>
<div class="stream-viewport" role="region" :aria-label="t('embed.streamLabel')">
<div class="stream-track">
<a
v-for="(photo, i) in photos"
:key="photo.photoId"
class="stream-photo"
:href="photoUrl(photo.photoId)"
target="_blank"
rel="noopener noreferrer"
:aria-label="t('embed.photoAlt', { n: i + 1 })"
@click.prevent="onPhotoClick(photo)"
>
<img
class="stream-photo__img"
:src="photo.thumb.url"
:alt="t('embed.photoAlt', { n: i + 1 })"
:style="{ aspectRatio: `1 / ${ratios[i]}` }"
decoding="async"
loading="eager"
>
</a>
</div>
<!-- StateOverlay(§2.2):空相册紧凑空态(收起入口保持可用)。 -->
<div v-if="photos.length === 0" class="stream-empty" role="note">
{{ t('embed.comingSoon') }}
</div>
<!-- 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>
</button>
</div>
</template>
<style scoped>
/* iframe 内容铺满 loader wrapper 的 195×280 窗(圆角/边框/阴影由宿主负责);
* 深底色与宿主外壳一致,加载/缺口不露宿主内容(§2.1)。 */
.stream-viewport {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
background: #10161e;
}
.stream-track {
display: flex;
flex-direction: column;
/* 3px 照片缝(§2.2) */
gap: 3px;
width: 100%;
will-change: transform;
}
.stream-photo {
display: block;
width: 100%;
text-decoration: none;
-webkit-tap-highlight-color: transparent;
}
.stream-photo__img {
display: block;
width: 100%;
height: auto;
background: #f6f8fb;
object-fit: cover;
}
/* 收起入口(§2.4):可视圆 24×24 右上内缩 6px;有效触区 44×44 锚定右上
* (透明扩展),不与照片链接重叠触发。 */
.stream-collapse {
position: absolute;
top: 0;
right: 0;
width: 44px;
height: 44px;
padding: 0;
margin: 0;
border: 0;
background: transparent;
cursor: pointer;
display: flex;
align-items: flex-start;
justify-content: flex-end;
}
.stream-collapse::before {
content: '';
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;
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;
border-radius: 12px;
}
.stream-empty {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font: 500 0.75rem/1.4 system-ui, -apple-system, 'Segoe UI', 'PingFang SC', sans-serif;
color: rgba(255, 255, 255, 0.72);
}
</style>