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
+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' },
},
],
});