Files
photo-live/apps/admin/src/views/system/embed-slots/EmbedSlotPreview.vue
T
2026-09-21 19:26:48 +08:00

508 lines
15 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}`"
:style="widgetStyle"
data-test="embed-preview-widget"
>
<div
v-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>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import {
buildPreviewPlacement,
buildPreviewWidgetStyle,
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))
/** 把手/关闭仅切换预览内展开态(用户选择优先于默认值;不持久化) */
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;
}
.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>