Files
photo-live/apps/admin/src/views/system/embed-slots/EmbedSlotPreview.vue
T
2026-09-22 18:10:53 +08:00

630 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 展示位本地实时预览(UI-01 Task 7):表单草稿 → 桌面/手机双框预览。
· 纯本地:不调用任何接口、不写展示位、不上报浏览——数据为内置示例
(3 张渐变示意照片 + 相册标题占位),未保存修改经 props 即时反映;
· 与生产组件共享展示结构:类名/结构镜像 viewer 嵌入页(embed-root/
embed-head/embed-stage/embed-foot + loader 收起把手),样式随
viewer 同步维护,非另起一套视觉;停靠几何由 buildPreviewPlacement
纯函数产出(与 loader wrapper 同规则);
· mobile 框模拟 <480px 窄屏:强制 sm + 默认收起(§5.4),把手可点击
本地展开演示(用户选择优先于默认值——仅预览内状态);
· zIndex 徽标 + 框内低层级示意元素(z-index 900)直观反映层级配置;
· 暂停/查看相册等控件为装饰(disabled),不触发任何动作。 -->
<template>
<div class="embed-preview" :class="`is-${mode}`">
<div
class="embed-preview-frame"
:data-test="mode === 'desktop' ? 'embed-preview-desktop' : 'embed-preview-mobile'"
>
<div v-if="mode === 'desktop'" class="embed-preview-chrome" aria-hidden="true">
<span class="embed-preview-dot"></span>
<span class="embed-preview-dot"></span>
<span class="embed-preview-dot"></span>
<span class="embed-preview-url">www.example.com</span>
</div>
<div v-else class="embed-preview-chrome embed-preview-chrome--phone" aria-hidden="true">
<span class="embed-preview-notch"></span>
</div>
<div class="embed-preview-body">
<!-- 宿主页面示意内容(纯装饰) -->
<div class="embed-preview-page" aria-hidden="true">
<span class="embed-preview-page-title"></span>
<span class="embed-preview-page-line"></span>
<span class="embed-preview-page-line is-short"></span>
<span class="embed-preview-page-banner"></span>
<span class="embed-preview-page-line"></span>
</div>
<!-- 低层级宿主元素示意(z-index 900):层级配置直观可见 -->
<span
v-if="mode === 'desktop'"
class="embed-preview-chat"
aria-hidden="true"
data-test="embed-preview-chat"
>
{{ $t('embedSlotsManage.preview.chatMock') }}
</span>
<span class="embed-preview-badge" data-test="embed-preview-badge">
{{ $t('embedSlotsManage.preview.badge') }}
</span>
<span class="embed-preview-zindex" data-test="embed-preview-zindex">
z-index {{ placement.zIndex }}
</span>
<!-- 悬浮组件本体(几何随草稿即时变化) -->
<div
class="embed-preview-dock"
:class="[`is-${placement.side}`, { 'is-v2': isV2 }]"
:style="widgetStyle"
data-test="embed-preview-widget"
>
<!-- UI-04 v2:真实组件预览(iframe 载 viewer /embed-preview——真实
EmbedStream/引擎/气泡层,样例数据注入;配置经 postMessage 实时
更新,§8.2「preview 用真实展示组件,数据源注入」)。 -->
<div v-if="isV2 && !isCollapsed" class="embed-preview-v2frame" data-test="embed-preview-v2">
<iframe
ref="previewFrame"
:src="previewSrc"
title="PhotoLive v2 preview"
data-test="embed-preview-v2-iframe"
></iframe>
</div>
<div
v-else-if="isCollapsed && isV2"
class="embed-handle embed-handle--v2"
data-test="embed-preview-handle"
role="button"
tabindex="0"
@click="expand"
@keydown.enter.prevent="expand"
@keydown.space.prevent="expand"
>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">
<path
fill="#1a73e8"
d="M9 2 7.17 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2h-3.17L15 2H9zm3 15c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5z"
/>
</svg>
<span>{{ $t('embedSlotsManage.preview.handleText') }}</span>
</div>
<div
v-else-if="isCollapsed"
class="embed-handle"
data-test="embed-preview-handle"
role="button"
tabindex="0"
@click="expand"
@keydown.enter.prevent="expand"
@keydown.space.prevent="expand"
>
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false">
<path
fill="#ffffff"
d="M9 2 7.17 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2h-3.17L15 2H9zm3 15c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5z"
/>
</svg>
<span>{{ $t('embedSlotsManage.preview.handleText') }}</span>
</div>
<!-- 镜像 viewer EmbedPage 结构:embed-root > embed-head + embed-carousel -->
<div v-else class="embed-root">
<div class="embed-head">
<span class="embed-heading">{{ $t('embedSlotsManage.preview.widgetTitle') }}</span>
<button
type="button"
class="embed-close"
:aria-label="$t('embedSlotsManage.preview.collapseLabel')"
data-test="embed-preview-collapse"
@click="collapse"
>
<svg
viewBox="0 0 24 24"
width="14"
height="14"
aria-hidden="true"
focusable="false"
>
<path
fill="currentColor"
d="M18.3 5.71 12 12.01l-6.3-6.3-1.4 1.41 6.29 6.3-6.3 6.3 1.42 1.4 6.29-6.29 6.3 6.3 1.4-1.42-6.3-6.29 6.3-6.3z"
/>
</svg>
</button>
</div>
<div class="embed-carousel">
<div
class="embed-stage"
:class="placement.imageFit === 'cover' ? 'is-cover' : 'is-contain'"
data-test="embed-preview-stage"
>
<!-- 示例照片为 1:1 渐变色块:contain 完整展示(衬中性深色底)、
cover 裁切填满——适配配置即时可见 -->
<div class="embed-preview-photo" :style="mockSlides[0].gradient">
<span>{{ $t('embedSlotsManage.preview.photoLabel', { n: 1 }) }}</span>
</div>
</div>
<footer class="embed-foot">
<p class="embed-title" data-test="embed-preview-title">{{ albumTitle }}</p>
<div class="embed-foot-row">
<span class="embed-indicator" data-test="embed-preview-indicator">01 / 03</span>
<div class="embed-foot-actions">
<button type="button" class="embed-foot-btn" disabled aria-disabled="true">
<svg
viewBox="0 0 24 24"
width="14"
height="14"
aria-hidden="true"
focusable="false"
>
<path fill="currentColor" d="M6 5h4v14H6zm8 0h4v14h-4z" />
</svg>
</button>
<span class="embed-foot-link is-disabled" data-test="embed-preview-view">
{{ $t('embedSlotsManage.preview.viewAlbum') }}
</span>
</div>
</div>
</footer>
</div>
</div>
</div>
</div>
</div>
<div class="embed-preview-caption">
<span>{{
mode === 'desktop'
? $t('embedSlotsManage.preview.desktop')
: $t('embedSlotsManage.preview.mobile')
}}</span>
<span v-if="mode === 'mobile'" class="embed-preview-caption-hint">
{{ $t('embedSlotsManage.preview.collapsedHint') }}
</span>
<!-- 工具区(§8.2:仅管理预览,不在实际组件):模拟点赞事件(测试模拟)。 -->
<button
v-if="isV2 && mode === 'desktop'"
type="button"
class="embed-preview-sim"
data-test="embed-preview-sim-like"
@click="simulateLike"
>
{{ $t('embedSlotsManage.preview.simLike') }}
</button>
<span v-if="isV2" class="embed-preview-caption-hint">
{{ $t('embedSlotsManage.preview.sampleDataHint') }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import {
buildPreviewPlacement,
buildPreviewWidgetStyle,
clampScrollSpeed,
embedOriginFromEnv,
type EmbedSettingsDraft
} from '@/utils/system/embedSlots'
const props = defineProps<{
/** 表单草稿(未保存修改即时反映) */
draft: EmbedSettingsDraft
/** 桌面(按配置)/ 手机(<480px 窄屏行为) */
mode: 'desktop' | 'mobile'
/** 相册标题占位(编辑已绑定相册时传入,否则词条占位) */
albumTitle: string
}>()
/** 示例照片(1:1 渐变色块;仅第 1 帧上屏,指示器按 3 帧示意) */
const mockSlides = [
{ gradient: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 60%, #d946ef 100%)' },
{ gradient: 'linear-gradient(135deg, #0ea5e9 0%, #2563eb 60%, #1d4ed8 100%)' },
{ gradient: 'linear-gradient(135deg, #f59e0b 0%, #ef4444 60%, #db2777 100%)' }
]
const placement = computed(() => buildPreviewPlacement(props.draft, props.mode))
const widgetStyle = computed(() => buildPreviewWidgetStyle(placement.value))
// ── UI-04 v2 真实组件预览(§8.2)──
const isV2 = computed(() => props.draft.displayMode === 'vertical-stream')
const previewFrame = ref<HTMLIFrameElement | null>(null)
const embedOrigin = embedOriginFromEnv(import.meta.env)
/** 预览 iframe src:初始配置经查询(速度/气泡/语言);后续更新走
* postMessage(不整页重载,避免滚动位置闪烁)。 */
const previewSrc = computed(() => {
const params = new URLSearchParams({
speed: String(clampScrollSpeed(props.draft.scrollSpeed)),
lang: props.draft.lang === 'auto' ? 'zh-CN' : props.draft.lang
})
if (!props.draft.reactionsEnabled) params.set('reactions', 'off')
return `${embedOrigin}/embed-preview?${params.toString()}`
})
function postPreview(payload: Record<string, unknown>): void {
previewFrame.value?.contentWindow?.postMessage(
{ photolivePreview: true, ...payload },
embedOrigin
)
}
/** 草稿速度/气泡变化 → 实时推送(不发请求不写展示位,纯预览内生效)。 */
watch(
() => [props.draft.scrollSpeed, props.draft.reactionsEnabled] as const,
([speed, reactions]) => {
if (!isV2.value) return
postPreview({ type: 'config', speed: clampScrollSpeed(speed), reactions })
}
)
/** 模拟点赞(§8.2 工具区按钮;事件为测试模拟,注入真实校验管线)。 */
function simulateLike(): void {
postPreview({ type: 'burst', photoIndex: -1 })
}
/** 把手/关闭仅切换预览内展开态(用户选择优先于默认值;不持久化) */
const expanded = ref(false)
const isCollapsed = computed(() => placement.value.collapsed && !expanded.value)
watch(
() => placement.value.collapsed,
() => {
expanded.value = false
}
)
function expand(): void {
expanded.value = true
}
function collapse(): void {
expanded.value = false
}
</script>
<style scoped>
.embed-preview {
display: flex;
flex-direction: column;
gap: 4px;
}
.embed-preview-frame {
position: relative;
display: flex;
flex-direction: column;
border: 1px solid #d1d5db;
border-radius: 8px;
overflow: hidden;
background: #f9fafb;
}
.is-desktop .embed-preview-frame {
width: 440px; /* EMBED_PREVIEW_FRAME_WIDTH.desktop(buildPreviewPlacement 视口宽) */
height: 380px;
}
.is-mobile .embed-preview-frame {
width: 375px; /* EMBED_PREVIEW_FRAME_WIDTH.mobile */
height: 320px;
margin: 0 auto;
}
.embed-preview-chrome {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
background: #e5e7eb;
border-bottom: 1px solid #d1d5db;
}
.embed-preview-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #9ca3af;
}
.embed-preview-url {
margin-left: 8px;
padding: 1px 10px;
border-radius: 999px;
background: #f9fafb;
color: #6b7280;
font-size: 10px;
line-height: 16px;
}
.embed-preview-chrome--phone {
justify-content: center;
}
.embed-preview-notch {
width: 90px;
height: 12px;
border-radius: 0 0 10px 10px;
background: #d1d5db;
}
.embed-preview-body {
position: relative;
flex: 1;
overflow: hidden;
background: linear-gradient(180deg, #ffffff 0%, #f3f4f6 100%);
}
/* 宿主页面示意内容(纯装饰灰度块) */
.embed-preview-page {
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px 16px;
}
.embed-preview-page-title {
width: 40%;
height: 12px;
border-radius: 4px;
background: #d1d5db;
}
.embed-preview-page-line {
width: 88%;
height: 8px;
border-radius: 4px;
background: #e5e7eb;
}
.embed-preview-page-line.is-short {
width: 62%;
}
.embed-preview-page-banner {
width: 100%;
height: 56px;
border-radius: 6px;
background: linear-gradient(90deg, #e0e7ff, #ede9fe);
}
/* 低层级宿主元素示意:z-index 900(组件层级低于它则被盖住) */
.embed-preview-chat {
position: absolute;
right: 8px;
bottom: 8px;
z-index: 900;
padding: 8px 12px;
border-radius: 999px;
background: #10b981;
color: #fff;
font-size: 11px;
box-shadow: 0 2px 8px rgb(0 0 0 / 0.18);
}
.embed-preview-badge {
position: absolute;
top: 8px;
right: 8px;
z-index: 2;
padding: 2px 8px;
border-radius: 4px;
background: #fef3c7;
border: 1px solid #f59e0b;
color: #92400e;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.04em;
}
.embed-preview-zindex {
position: absolute;
top: 8px;
left: 8px;
z-index: 2;
padding: 2px 8px;
border-radius: 4px;
background: #e0e7ff;
border: 1px solid #6366f1;
color: #3730a3;
font-size: 10px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.embed-preview-caption {
display: flex;
align-items: center;
gap: 8px;
color: #6b7280;
font-size: 12px;
text-align: center;
}
.is-desktop .embed-preview-caption {
justify-content: center;
}
.is-mobile .embed-preview-caption {
justify-content: center;
}
.embed-preview-caption-hint {
color: #9ca3af;
}
/* ── 悬浮组件本体:镜像 viewer 嵌入页类名(embed-root/head/stage/foot)与
loader wrapper(圆角 12 + 阴影)——视觉随 viewer 同步,不另起一套 ── */
.embed-preview-dock {
display: flex;
border-radius: 12px;
box-shadow: 0 4px 16px rgb(0 0 0 / 0.18);
overflow: hidden;
}
/* v2 壳(对齐 loader v2 wrapper:1px #fffc 边、#10161e 底、高 280)。 */
.embed-preview-dock.is-v2 {
border: 1px solid #fffc;
background: #10161e;
box-shadow: 0 4px 16px #0f172a29;
height: 280px;
}
.embed-preview-v2frame {
width: 100%;
height: 100%;
}
.embed-preview-v2frame iframe {
width: 100%;
height: 100%;
border: 0;
display: block;
}
/* v2 收起把手(对齐 loader v2:白底 100×44、蓝图标)。 */
.embed-handle--v2 {
padding: 0;
width: 100px;
gap: 8px;
border: 1px solid #e3eaf2;
background: #ffffff;
box-shadow: 0 4px 16px #0f172a20;
color: #32618e;
font-size: 12px;
}
.embed-preview-sim {
padding: 2px 10px;
border: 1px solid #d5dfed;
border-radius: 6px;
background: #fff;
color: #285586;
font-size: 11px;
line-height: 20px;
cursor: pointer;
}
.embed-preview-sim:hover {
background: #f0f6ff;
}
.embed-root {
background: #fff;
font-family:
system-ui,
-apple-system,
'Segoe UI',
'PingFang SC',
sans-serif;
color: #1f2328;
}
.embed-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 6px 6px 10px;
}
.embed-heading {
font-size: 12px;
font-weight: 600;
color: #5e6878;
letter-spacing: 0.02em;
}
.embed-close {
flex: none;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 0;
border-radius: 50%;
background: transparent;
color: #6b7280;
cursor: pointer;
}
.embed-close:hover {
background: #f3f4f6;
color: #374151;
}
.embed-stage {
position: relative;
aspect-ratio: 4 / 3;
overflow: hidden;
background: #1a1a2e; /* 中性深色舞台(viewer EmbedStage 同值) */
display: flex;
}
.embed-stage.is-contain {
align-items: center;
justify-content: center;
}
/* 示例照片 1:1:contain 完整展示(max 约束+居中),cover 铺满裁切 */
.embed-preview-photo {
display: flex;
align-items: center;
justify-content: center;
color: rgb(255 255 255 / 0.85);
font-size: 12px;
font-weight: 600;
}
.is-contain .embed-preview-photo {
aspect-ratio: 1 / 1;
max-width: 100%;
max-height: 100%;
}
.is-cover .embed-preview-photo {
width: 100%;
height: 100%;
}
.embed-foot {
padding: 8px 10px 10px;
}
.embed-title {
margin: 0 0 6px;
font-size: 13px;
font-weight: 600;
line-height: 1.3;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
}
.embed-foot-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 26px;
}
.embed-indicator {
font-size: 12px;
font-variant-numeric: tabular-nums;
color: #5e6878;
white-space: nowrap;
}
.embed-foot-actions {
display: flex;
align-items: center;
gap: 4px;
}
.embed-foot-btn,
.embed-foot-link {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 26px;
height: 26px;
padding: 0 6px;
border: 0;
border-radius: 6px;
background: transparent;
color: #1f2937;
font-size: 12px;
text-decoration: none;
}
.embed-foot-btn:disabled {
cursor: default;
opacity: 0.6;
}
.embed-foot-link.is-disabled {
cursor: default;
opacity: 0.6;
color: #1f2937;
}
/* 收起把手(镜像 loader createHandle:胶囊 44px + 深色底 + 白字) */
.embed-handle {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 44px;
padding: 0 14px;
border-radius: 22px;
background: rgb(15 23 42 / 0.88);
box-shadow: 0 2px 10px rgb(0 0 0 / 0.3);
color: #fff;
font-size: 13px;
font-weight: 500;
line-height: 1;
white-space: nowrap;
cursor: pointer;
user-select: none;
}
.is-left .embed-handle {
margin-right: auto;
}
.is-right .embed-handle {
margin-left: auto;
}
</style>