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:
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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/连接上限统计、录屏清单。)
|
||||
|
||||
@@ -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 用例。
|
||||
|
||||
Reference in New Issue
Block a user