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:
@@ -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' },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user