feat(admin,viewer,ui04): E06 management preview and v2 integration — real-component preview page, form mode fields, README

- viewer /embed-preview route: real EmbedStream with sample photography (picsum seeds), zero requests/WS (preview mode), postMessage config + simulated bursts through the real validation pipeline
- EmbedStream: preview prop (disables real WS subscription per §8.2 isolation) + exposed simulateBurst
- admin form: displayMode radio (new default), fixed 195x280 readonly, speed slider, reactions switch; v1 layout fields hidden in v2 (not silently effective)
- admin preview: v2 branch embeds the real preview iframe, white v2 handle, simulate-like tool button with sample-data note
- README-embed: v2 section (integration, hard sizing/docking, interactions/limits, artifact/version caching)
- tests: admin vitest 83 (+1 v2 placement), admin e2e 5 (v1 pinned carousel regression + full v2 chain incl. preview WYSIWYG heart and ov settings projection), viewer 244, v2 e2e 16

Note: part of the admin-side E06 changes (form/preview/utils/locales, mid-state of e2e spec) was swept into parallel commit eebf3b3 by a concurrent session's git add; this commit carries the final state of the remaining files and completes the set.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-22 18:20:50 +08:00
parent eebf3b3334
commit 3bba8d1dc5
7 changed files with 248 additions and 7 deletions
+11 -2
View File
@@ -224,12 +224,21 @@ test.describe.serial('embed slot management (P4b Task 5)', () => {
await expect(page.getByTestId('slot-form-interval')).toHaveCount(0)
await expect(page.getByTestId('slot-form-size')).toHaveCount(0)
// 真实组件预览(§8.2):iframe 挂载 + 模拟点赞按钮在工具区。
// 真实组件预览(§8.2):iframe 挂载 + 模拟点赞按钮在工具区;预览帧内为
// 真实 EmbedStream(.stream-viewport)且模拟点赞出心形(WYSIWYG 证据)。
await expect(page.getByTestId('embed-preview-v2-iframe')).toBeVisible()
await expect(page.getByTestId('embed-preview-sim-like')).toBeVisible()
const previewFrame = page
.frames()
.find((f) => f.url().includes('/embed-preview'))
expect(previewFrame).toBeDefined()
await expect(previewFrame!.locator('.stream-viewport')).toHaveCount(1)
await page.getByTestId('embed-preview-sim-like').click()
await expect(previewFrame!.locator('.heart').first()).toBeVisible({ timeout: 5_000 })
// 速度调整(24 px/s):滑轨点击定位((24-8)/(32-8)*180px ≈ 120)后断言值。
await page.getByTestId('slot-form-speed').click({ position: { x: 120, y: 10 } })
const runway = page.getByTestId('slot-form-speed').locator('.el-slider__runway')
await runway.click({ position: { x: 120, y: 4 } })
await expect(page.getByTestId('slot-form-speed-value')).toContainText('24')
await page.getByTestId('slot-form-submit').click()
await expect(page.getByTestId('slot-form-dialog')).toBeHidden()
+122
View File
@@ -0,0 +1,122 @@
<!-- apps/viewer/src/pages/EmbedPreviewPage.vue —— UI-04 E06 管理预览宿主页
(路由 /embed-preview,meta.layout 'embed' 裸渲染)。管理后台展示位表单的
「真实组件预览」iframe 目标:渲染与生产完全相同的 EmbedStream(引擎/交互/
气泡层同源),数据为内置样例(§8.2「preview 用真实展示组件,数据源注入」)。
隔离红线(§8.2「未保存预览不得修改线上 slot,不上报浏览、不提交点赞、
不订阅真实观众事件」):
· 零请求:不 fetch 公开 feed、不连 WS(EmbedStream preview 模式禁用订阅,
模拟事件经 ingestForTest 注入——真实组件的校验/节流/归属管线);
· 配置经 postMessage 实时更新(admin 表单草稿 → {type:'config'}),
避免整页重载闪烁;
· 模拟点赞经 {type:'burst'} 注入(工具区按钮触发,事件为测试模拟)。
载荷形状严格校验(数值夹取/枚举白名单),不透传任意样式。 -->
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import EmbedStream from './embed/EmbedStream.vue';
import type { PublicEmbedPhoto } from '../api/client';
const route = useRoute();
/** 样例数据(真实摄影素材 picsum 固定 seed——与 e2e fixtures 同源比例,
* §11.1「无渐变色块假图」;宽高为 seed 请求的真实尺寸)。 */
const SAMPLE_PHOTOS: PublicEmbedPhoto[] = [
['ui04-a', 800, 500],
['ui04-b', 800, 1100],
['ui04-c', 800, 800],
['ui04-d', 1200, 400],
['ui04-e', 800, 1400],
['ui04-f', 900, 600],
['ui04-g', 800, 1000],
['ui04-h', 1000, 700],
].map(([seed, w, h], i) => ({
photoId: `preview-${i + 1}`,
thumb: { url: `https://picsum.photos/seed/${seed}/800/800`, expiresAt: '2099-01-01T00:00:00.000Z' },
width: w,
height: h,
})) as PublicEmbedPhoto[];
function queryNumber(key: string, min: number, max: number, fallback: number): number {
const raw = route.query[key];
if (typeof raw !== 'string' || raw === '') return fallback;
const n = Number.parseInt(raw, 10);
if (!Number.isFinite(n)) return fallback;
return Math.min(max, Math.max(min, n));
}
const speed = ref(queryNumber('speed', 8, 32, 16));
const reactions = ref(route.query.reactions !== 'off');
const streamEl = ref<InstanceType<typeof EmbedStream> | null>(null);
/** 深链占位:预览无真实相册,点击照片不跳转(admin 工具区另有说明)。 */
function previewPhotoUrl(): string {
return 'about:blank';
}
interface PreviewMessage {
photolivePreview?: boolean;
type?: unknown;
speed?: unknown;
reactions?: unknown;
photoIndex?: unknown;
}
function clampSpeed(value: unknown): number | null {
const n = Number(value);
if (!Number.isFinite(n)) return null;
return Math.min(32, Math.max(8, Math.round(n)));
}
function onMessage(event: MessageEvent): void {
const data = event.data as PreviewMessage | null;
if (data === null || typeof data !== 'object' || data.photolivePreview !== true) return;
if (data.type === 'config') {
const s = clampSpeed(data.speed);
if (s !== null) speed.value = s;
if (typeof data.reactions === 'boolean') reactions.value = data.reactions;
} else if (data.type === 'burst') {
// 模拟点赞:注入真实校验管线(可见归属/去重/节流/抽样——§5.2 全链)。
const idx = Number(data.photoIndex);
const photoId =
Number.isFinite(idx) && idx >= 0 && idx < SAMPLE_PHOTOS.length
? SAMPLE_PHOTOS[idx].photoId
: SAMPLE_PHOTOS[0].photoId;
streamEl.value?.simulateBurst(photoId, 1 + Math.floor(Math.random() * 4));
}
}
onMounted(() => {
window.addEventListener('message', onMessage);
});
onBeforeUnmount(() => {
window.removeEventListener('message', onMessage);
});
</script>
<template>
<div class="embed-preview-root">
<EmbedStream
ref="streamEl"
:photos="SAMPLE_PHOTOS"
album-token="preview"
:photo-url="previewPhotoUrl"
:speed="speed"
:reactions="reactions"
:active="true"
:reduced-motion="false"
preview
@open-photo="() => undefined"
@collapse="() => undefined"
/>
</div>
</template>
<style scoped>
/* 与生产嵌入页同构:铺满 iframe 视口(外窗由预览容器提供 195×280 壳)。 */
.embed-preview-root {
position: fixed;
inset: 0;
}
</style>
+27 -3
View File
@@ -33,6 +33,9 @@ const props = defineProps<{
/** 组件活动闸门(收起/隐藏/不可见/gone → false,停滚动与轮询)。 */
active: boolean;
reducedMotion: boolean;
/** 管理预览模式(E06 §8.2):禁用真实 WS 订阅(隔离——不上报/不提交/
* 不连真实相册房间);模拟事件经 simulateBurst 注入真实校验管线。 */
preview?: boolean;
}>();
const emit = defineEmits<{
@@ -111,8 +114,11 @@ const firstKeyByPhoto = computed(() => {
});
// ── E05 气泡:真实 burst → 节流合并 → 照片交集下侧出泡(§5.2)──
// 预览模式禁用真实订阅(§8.2 隔离:不连相册房间;模拟事件走 ingestForTest)。
const albumTokenRef = computed(() => props.albumToken);
const reactionsEnabledRef = computed(() => props.reactions && props.albumToken !== null);
const reactionsEnabledRef = computed(
() => props.reactions && props.albumToken !== null && props.preview !== true,
);
const activeRef = computed(() => props.active);
const reactions = useEmbedReactions({
@@ -131,6 +137,23 @@ const reactions = useEmbedReactions({
},
});
/** 管理预览模拟点赞入口(E06 §8.2「事件明确是测试模拟」):经真实校验
* 管线(去重/节流/可见归属/抽样)注入,不触网络。 */
function simulateBurst(photoId: string, delta: number): void {
reactions.ingestForTest({
type: 'likes.burst',
photoId,
albumId: 'preview',
delta,
total: 0,
countVersion: 0,
burstId: `preview-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
originBatchIds: [],
});
}
defineExpose({ simulateBurst });
// 收起/隐藏/换绑/减少动效 → 清粒子(§5.3)。
watch(activeRef, (value) => {
if (!value) layerEl.value?.clear();
@@ -383,8 +406,9 @@ 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" />
<!-- E05 心形气泡层(装饰:pointer-events none + aria-hidden)。预览模式
恒挂载(模拟事件需要),生产按 reactions+albumToken。 -->
<ReactionLayer v-if="(reactions && albumToken !== null) || preview" ref="layerEl" />
<!-- 聚焦小提示(效果示例 focus-tip:聚焦时显示键盘操作说明,不常驻)。 -->
<span class="focus-tip" aria-hidden="true">{{ t('embed.streamKeyHint') }}</span>
<!-- 暂停/恢复状态播报(§4.2:aria-live,视觉极简,不常驻)。 -->
@@ -75,7 +75,7 @@ onBeforeUnmount(() => {
</script>
<template>
<div ref="rootEl" class="heart-layer" aria-hidden="true"></div>
<div ref="rootEl" class="heart-layer" aria-hidden="true" />
</template>
<style scoped>
+9
View File
@@ -1,11 +1,14 @@
import { createRouter, createWebHistory } from 'vue-router';
import ActivityPage from '../pages/ActivityPage.vue';
import EmbedPage from '../pages/EmbedPage.vue';
import EmbedPreviewPage from '../pages/EmbedPreviewPage.vue';
import HomePage from '../pages/HomePage.vue';
// 单图深链复用活动页组件(Task 8 起在此路由加载目标照片并进入大图模式)。
// /embed/:slotToken(P4b Task 4):宿主 loader iframe 载入的轮播页——meta.layout
// 'embed' 使 App.vue 裸渲染(无顶栏/页脚/内边距,iframe 小视口精简布局)。
// /embed-preview(UI-04 E06):管理后台预览 iframe 目标——真实 EmbedStream +
// 内置样例数据 + postMessage 配置/模拟点赞(零请求零订阅,§8.2 隔离)。
export const router = createRouter({
history: createWebHistory(),
routes: [
@@ -13,5 +16,11 @@ export const router = createRouter({
{ path: '/a/:token', name: 'activity', component: ActivityPage },
{ path: '/a/:token/p/:photoId', name: 'activity-photo', component: ActivityPage },
{ path: '/embed/:slotToken', name: 'embed', component: EmbedPage, meta: { layout: 'embed' } },
{
path: '/embed-preview',
name: 'embed-preview',
component: EmbedPreviewPage,
meta: { layout: 'embed' },
},
],
});
+26 -1
View File
@@ -65,7 +65,7 @@
- [x] E03 连续滚动(登记见下)
- [x] E04 操作和生命周期(登记见下)
- [x] E05 实时气泡(登记见下;含效果示例 embed-preview-2026-09-22.html 对齐)
- [ ] E06 管理预览与接入
- [x] E06 管理预览与接入(登记见下)
- [ ] E07 验收(EMB-A01…A16 逐项见第 5 节)
### E02 登记(2026-09-22)
@@ -154,6 +154,31 @@
(网络断言,EMB-A15);16s 真实点赞节奏录屏 recordings/e05-hearts-16s.webm。
- E05 出口确认:两上下文真实点赞触发正确图气泡(归属区间断言通过)。
### E06 登记(2026-09-22)
- 表单(SlotFormDialog):展示模式单选(照片上滚/旧版轮播;新建默认新版);
v2 字段组 = 固定尺寸只读「195 × 280」+ 上滚速度滑杆(8-32,实时值展示)+
点赞气泡开关;v1 布局字段(尺寸/间隔/适配)v2 下隐藏(§7.2「不保存后偷偷
生效」,切回旧版恢复编辑);位置/偏移/层级/语言/默认收起两模式共用。
- 真实组件预览(§8.2):EmbedSlotPreview v2 分支内嵌 viewer `/embed-preview`
iframe(新增路由,meta.layout embed)——渲染生产同源 EmbedStream/引擎/气泡层,
样例数据为 picsum 固定 seed 真实摄影(与 e2e fixtures 同比例);配置经
postMessage {type:config} 实时更新(不整页重载);「模拟点赞事件」按钮在
预览工具区(不在实际组件),经 simulateBurst 注入真实校验管线。
- 隔离(§8.2 红线):预览页零请求零订阅(preview 模式禁用 WS;不 fetch feed、
不上报浏览、不提交点赞);未保存草稿仅存在于表单/预览内存。
- 代码生成:displayMode=vertical-stream → /embed/v2/loader.js(E01 utils 已就绪,
EmbedCodeDialog 自动生效);data-speed/data-reactions 仅非默认生成。
- 文档:README-embed.md 增 v2 章节(接入/硬性尺寸与停靠/交互与限制/产物与
版本缓存说明)。
- 测试:admin vitest 83(+1 v2 placement:恒 195/无 sm 降档/mobile 默认收起;
v1 用例固定 carousel 后原断言保持);admin e2e 5(v1 用例先切 carousel 保
留回归 + 新增 UI-04 v2 全链:表单字段组/预览 iframe 内 .stream-viewport/
模拟点赞出心形/v2 代码卡/绑定后 ov settings 投影 displayMode+scrollSpeed=24
+reactions);viewer 244 / v2 e2e 16 全绿。
- E06 出口确认:后台保存与第三方跨源实际效果一致(admin e2e ov settings
投影 + viewer e2e 真实宿主效果两条链均有证据)。
## 4 测量与证据索引
(E02+ 起登记:尺寸实测、位移曲线、DOM/连接上限统计、录屏清单。)
+52
View File
@@ -112,3 +112,55 @@ pnpm --filter @photo/embed-loader run sync # 拷贝至 viewer public/(含 h
宿主页交互行为(iframe 出现/收起/记忆/窄屏/多槽堆叠/跨源宿主/postMessage 白名单/
后台设置动态生效)的端到端覆盖见 viewer Playwright `e2e/embed.spec.ts` 与
`e2e/embed-crossorigin.spec.ts`(宿主 fixture 页 / 3999 端口跨源静态页)。
---
# UI-04 v2:垂直照片流窗(/embed/v2/loader.js)
新版展示模式:**PC/手机均固定 195×280 CSS px、单列照片连续自动上滚、真实点赞
心形气泡、无标题栏与底部工具栏**(仅右上收起入口;点击照片打开观众深链)。
v1(旧轮播)源码冻结保留——存量接入不受影响;切换新版需更新 script 地址。
## 接入代码
```html
<script src="{EMBED_ORIGIN}/embed/v2/loader.js" data-slot="{publicToken}" defer></script>
```
- 后台展示位 `displayMode=vertical-stream` 时,嵌入代码卡自动生成 v2 入口;
- `data-speed`(8–32,像素/秒,缺省 16)与 `data-reactions="off"` 为显式覆盖,
缺省让后台设置生效(优先级同 v1:data-* > 后台 > 默认);
- v2 下 `data-size / data-interval / data-image-fit` 无布局作用(固定 195×280、
连续滚动),不生成也不解析。
## 尺寸与停靠(硬性)
- 宽 195 / 高 280(border-box,含 1px 白边);视口不足时按 `min(195, 可用宽)` /
`min(280, 可用高)` 收缩,低于可用下限转收起入口;**不整体缩放**(transform
scale 会同缩文字与触控区);
- 默认停靠:桌面 right 16 / 手机(≤599px 视口)right 12,bottom 24 + 安全区
(`offsetX/offsetBottom` 可配置,0–120 / 0–240);z-index 缺省 1000 可配置;
- 窄屏(<480px)默认收起(与 v1 策略一致);展开后两端尺寸一致;
- iframe 内容不得经 resize 消息反向改变窗口高度(v2 忽略 resize)。
## 交互与限制
- 悬停(鼠标)/键盘焦点/按住暂停,离开 500ms 恢复;Space 显式暂停(不自动
恢复);Escape 收起;↑↓/滚轮/拖动手动滚动(双向回绕,跟手 1:1);
- 点击照片:pointerdown 锁定目标,拖动 >8px 取消——动画中不点错图;新标签
打开 `/a/{albumToken}/p/{photoId}`(noopener);
- 点赞气泡只由真实 `likes.burst`(WebSocket 只读订阅相册公开 token)驱动:
断线不出泡、不伪造;组件自身不提交点赞、不上报浏览;
- 减少动态效果(prefers-reduced-motion):停自动滚动与粒子,手动可查看。
## 产物与版本
- 构建产物 `dist/v1/loader.js` 与 `dist/v2/loader.js`(同 build/sync 脚本;
viewer publicDir `public/embed/{v1,v2}/` 携带)。版本入口稳定可长缓存
(immutable 语义同 `/embed/v1/`,见 viewer nginx 模板);升级走新版本路径,
不原地变更已发布文件语义。
v2 行为的端到端覆盖见 viewer Playwright `e2e/embed-v2.spec.ts`(E01–E05 出口
门/尺寸测量/滚动连续性/交互/真实点赞气泡/录屏证据);管理端表单与真实组件
预览(`/embed-preview`,样例数据 + postMessage 配置/模拟事件)见 admin
`e2e/embed-slots.spec.ts` UI-04 用例。