feat(admin): UI-01 embed slot config form and live preview
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
<!-- 嵌入代码卡(P4b Task 5):展示位公开令牌 + 宿主页可粘贴的 loader 引用
|
||||
代码(EMBED_ORIGIN 来自 VITE_EMBED_ORIGIN,缺省本机 viewer dev)+ 一键
|
||||
复制。代码只认 token:后续换绑/启停均无需改动宿主页(A22)。 -->
|
||||
<!-- 嵌入代码卡(P4b Task 5;UI-01 Task 7 增强):展示位公开令牌 + 宿主页可
|
||||
粘贴的 loader 引用代码(EMBED_ORIGIN 来自 VITE_EMBED_ORIGIN,缺省本机
|
||||
viewer dev)+ 一键复制。代码只认 token:后续换绑/启停均无需改动宿主页
|
||||
(A22)。UI-01:按展示位当前设置生成 data-* 显式覆盖——仅含非默认字段
|
||||
(缺省字段省略 → 后台设置生效,loader explicit 表语义 §5.4)。 -->
|
||||
<template>
|
||||
<ElDialog
|
||||
v-model="visible"
|
||||
@@ -38,6 +40,12 @@
|
||||
:title="$t('embedSlotsManage.code.hint')"
|
||||
data-test="embed-code-hint"
|
||||
/>
|
||||
<ElAlert
|
||||
type="warning"
|
||||
:closable="false"
|
||||
:title="$t('embedSlotsManage.code.overrideHint')"
|
||||
data-test="embed-code-override-hint"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
@@ -55,7 +63,7 @@
|
||||
import { buildEmbedSnippet, embedOriginFromEnv } from '@/utils/system/embedSlots'
|
||||
|
||||
const props = defineProps<{
|
||||
/** 目标展示位(publicToken 拼入 data-slot) */
|
||||
/** 目标展示位(publicToken 拼入 data-slot;settings 生成显式 data-* 覆盖) */
|
||||
embedSlot: Api.EmbedSlot.Item | null
|
||||
}>()
|
||||
|
||||
@@ -63,7 +71,9 @@
|
||||
const { t } = useI18n()
|
||||
|
||||
const snippet = computed(() =>
|
||||
props.embedSlot === null ? '' : buildEmbedSnippet(embedOrigin(), props.embedSlot.publicToken)
|
||||
props.embedSlot === null
|
||||
? ''
|
||||
: buildEmbedSnippet(embedOrigin(), props.embedSlot.publicToken, props.embedSlot.settings)
|
||||
)
|
||||
|
||||
function embedOrigin(): string {
|
||||
|
||||
@@ -0,0 +1,507 @@
|
||||
<!-- 展示位本地实时预览(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>
|
||||
@@ -1,79 +1,148 @@
|
||||
<!-- 展示位新建/编辑对话框(P4b Task 5):名称 + 设置表单(位置/尺寸/间隔/
|
||||
语言/默认收起)。slot 为 null 即新建(表单取契约缺省值);编辑按契约
|
||||
整体替换语义提交完整 settings(intervalMs 夹取兜底 ElInputNumber 边界
|
||||
之外的键盘输入)。仅提交变更字段,无变更不制造服务端 no-op。 -->
|
||||
<!-- 展示位新建/编辑对话框(P4b Task 5;UI-01 Task 7 增强):名称 + 设置表单
|
||||
(位置/尺寸/间隔/语言/默认收起 + 图片适配/水平偏移/底部偏移/层级)+
|
||||
本地实时预览(桌面/手机双框,未保存修改即时反映——EmbedSlotPreview)。
|
||||
slot 为 null 即新建(表单取契约缺省值);编辑按契约整体替换语义提交完整
|
||||
settings(数值字段夹取兜底 ElInputNumber 边界之外的键盘输入)。仅提交
|
||||
变更字段,无变更不制造服务端 no-op;「未保存」标记 = 脏态可视(保存以
|
||||
外不持久化,预览亦纯本地零接口)。 -->
|
||||
<template>
|
||||
<ElDialog
|
||||
v-model="visible"
|
||||
:title="
|
||||
embedSlot === null ? $t('embedSlotsManage.create.title') : $t('embedSlotsManage.edit.title')
|
||||
"
|
||||
width="520px"
|
||||
destroy-on-close
|
||||
data-test="slot-form-dialog"
|
||||
>
|
||||
<ElForm label-width="130px">
|
||||
<ElFormItem :label="$t('embedSlotsManage.form.nameLabel')" :error="nameError">
|
||||
<ElInput
|
||||
v-model="draftName"
|
||||
:placeholder="$t('embedSlotsManage.form.namePlaceholder')"
|
||||
maxlength="64"
|
||||
show-word-limit
|
||||
data-test="slot-form-name"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('embedSlotsManage.form.positionLabel')">
|
||||
<ElSelect v-model="draft.position" style="width: 200px" data-test="slot-form-position">
|
||||
<ElOption
|
||||
v-for="opt in positionOptions"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
:label="opt.label"
|
||||
:data-test="`slot-form-position-${opt.value}`"
|
||||
<ElDialog v-model="visible" width="980px" top="4vh" destroy-on-close data-test="slot-form-dialog">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{{
|
||||
embedSlot === null
|
||||
? $t('embedSlotsManage.create.title')
|
||||
: $t('embedSlotsManage.edit.title')
|
||||
}}</span>
|
||||
<ElTag v-if="dirty" type="warning" effect="light" data-test="slot-form-dirty">
|
||||
{{ $t('embedSlotsManage.form.dirtyTag') }}
|
||||
</ElTag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex items-start gap-6">
|
||||
<ElForm label-width="130px" class="slot-form-dialog__form">
|
||||
<ElFormItem :label="$t('embedSlotsManage.form.nameLabel')" :error="nameError">
|
||||
<ElInput
|
||||
v-model="draftName"
|
||||
:placeholder="$t('embedSlotsManage.form.namePlaceholder')"
|
||||
maxlength="64"
|
||||
show-word-limit
|
||||
data-test="slot-form-name"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('embedSlotsManage.form.sizeLabel')">
|
||||
<ElSelect v-model="draft.size" style="width: 200px" data-test="slot-form-size">
|
||||
<ElOption
|
||||
v-for="opt in sizeOptions"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
:label="opt.label"
|
||||
:data-test="`slot-form-size-${opt.value}`"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('embedSlotsManage.form.intervalLabel')">
|
||||
<div class="flex items-center gap-2">
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('embedSlotsManage.form.positionLabel')">
|
||||
<ElSelect v-model="draft.position" style="width: 200px" data-test="slot-form-position">
|
||||
<ElOption
|
||||
v-for="opt in positionOptions"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
:label="opt.label"
|
||||
:data-test="`slot-form-position-${opt.value}`"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('embedSlotsManage.form.sizeLabel')">
|
||||
<ElSelect v-model="draft.size" style="width: 200px" data-test="slot-form-size">
|
||||
<ElOption
|
||||
v-for="opt in sizeOptions"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
:label="opt.label"
|
||||
:data-test="`slot-form-size-${opt.value}`"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('embedSlotsManage.form.intervalLabel')">
|
||||
<div class="flex items-center gap-2">
|
||||
<ElInputNumber
|
||||
v-model="draft.intervalMs"
|
||||
:min="EMBED_INTERVAL_MIN_MS"
|
||||
:max="EMBED_INTERVAL_MAX_MS"
|
||||
:step="1000"
|
||||
data-test="slot-form-interval"
|
||||
/>
|
||||
<span class="text-gray-400 text-sm">{{
|
||||
$t('embedSlotsManage.form.intervalUnit')
|
||||
}}</span>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('embedSlotsManage.form.langLabel')">
|
||||
<ElSelect v-model="draft.lang" style="width: 200px" data-test="slot-form-lang">
|
||||
<ElOption
|
||||
v-for="opt in langOptions"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
:label="opt.label"
|
||||
:data-test="`slot-form-lang-${opt.value}`"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('embedSlotsManage.form.collapsedLabel')">
|
||||
<div class="flex items-center gap-2">
|
||||
<ElSwitch v-model="draft.collapsedDefault" data-test="slot-form-collapsed" />
|
||||
<span class="text-gray-400 text-sm">{{
|
||||
$t('embedSlotsManage.form.collapsedHint')
|
||||
}}</span>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('embedSlotsManage.form.imageFitLabel')">
|
||||
<ElRadioGroup v-model="draft.imageFit" data-test="slot-form-image-fit">
|
||||
<ElRadio value="contain" data-test="slot-form-image-fit-contain">
|
||||
{{ $t('embedSlotsManage.form.imageFitContain') }}
|
||||
</ElRadio>
|
||||
<ElRadio value="cover" data-test="slot-form-image-fit-cover">
|
||||
{{ $t('embedSlotsManage.form.imageFitCover') }}
|
||||
</ElRadio>
|
||||
</ElRadioGroup>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('embedSlotsManage.form.offsetXLabel')">
|
||||
<div class="flex items-center gap-2">
|
||||
<ElInputNumber
|
||||
v-model="draft.offsetX"
|
||||
:min="EMBED_OFFSET_X_MIN"
|
||||
:max="EMBED_OFFSET_X_MAX"
|
||||
:step="4"
|
||||
data-test="slot-form-offset-x"
|
||||
/>
|
||||
<span class="text-gray-400 text-sm">{{
|
||||
$t('embedSlotsManage.form.pxUnitOffsetX')
|
||||
}}</span>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('embedSlotsManage.form.offsetBottomLabel')">
|
||||
<div class="flex items-center gap-2">
|
||||
<ElInputNumber
|
||||
v-model="draft.offsetBottom"
|
||||
:min="EMBED_OFFSET_BOTTOM_MIN"
|
||||
:max="EMBED_OFFSET_BOTTOM_MAX"
|
||||
:step="4"
|
||||
data-test="slot-form-offset-bottom"
|
||||
/>
|
||||
<span class="text-gray-400 text-sm">{{
|
||||
$t('embedSlotsManage.form.pxUnitOffsetBottom')
|
||||
}}</span>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('embedSlotsManage.form.zIndexLabel')">
|
||||
<ElInputNumber
|
||||
v-model="draft.intervalMs"
|
||||
:min="EMBED_INTERVAL_MIN_MS"
|
||||
:max="EMBED_INTERVAL_MAX_MS"
|
||||
:step="1000"
|
||||
data-test="slot-form-interval"
|
||||
v-model="draft.zIndex"
|
||||
:min="EMBED_Z_INDEX_MIN"
|
||||
:max="EMBED_Z_INDEX_MAX"
|
||||
:step="100"
|
||||
data-test="slot-form-z-index"
|
||||
/>
|
||||
<span class="text-gray-400 text-sm">{{ $t('embedSlotsManage.form.intervalUnit') }}</span>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('embedSlotsManage.form.langLabel')">
|
||||
<ElSelect v-model="draft.lang" style="width: 200px" data-test="slot-form-lang">
|
||||
<ElOption
|
||||
v-for="opt in langOptions"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
:label="opt.label"
|
||||
:data-test="`slot-form-lang-${opt.value}`"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('embedSlotsManage.form.collapsedLabel')">
|
||||
<div class="flex items-center gap-2">
|
||||
<ElSwitch v-model="draft.collapsedDefault" data-test="slot-form-collapsed" />
|
||||
<span class="text-gray-400 text-sm">{{ $t('embedSlotsManage.form.collapsedHint') }}</span>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
|
||||
<!-- 本地实时预览:纯示例数据(零接口),未保存修改即时反映 -->
|
||||
<div class="slot-form-dialog__preview" data-test="embed-preview-panel">
|
||||
<div class="font-medium">{{ $t('embedSlotsManage.preview.title') }}</div>
|
||||
<div class="text-gray-400 text-xs mb-3">{{ $t('embedSlotsManage.preview.hint') }}</div>
|
||||
<EmbedSlotPreview :draft="draft" mode="desktop" :album-title="previewAlbumTitle" />
|
||||
<EmbedSlotPreview :draft="draft" mode="mobile" :album-title="previewAlbumTitle" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<ElButton data-test="slot-form-cancel" @click="visible = false">
|
||||
@@ -90,13 +159,21 @@
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import EmbedSlotPreview from './EmbedSlotPreview.vue'
|
||||
import { fetchCreateEmbedSlot, fetchUpdateEmbedSlot } from '@/api/embedSlots'
|
||||
import {
|
||||
DEFAULT_EMBED_SETTINGS,
|
||||
EMBED_INTERVAL_MAX_MS,
|
||||
EMBED_INTERVAL_MIN_MS,
|
||||
buildSettingsPayload,
|
||||
EMBED_OFFSET_BOTTOM_MAX,
|
||||
EMBED_OFFSET_BOTTOM_MIN,
|
||||
EMBED_OFFSET_X_MAX,
|
||||
EMBED_OFFSET_X_MIN,
|
||||
EMBED_Z_INDEX_MAX,
|
||||
EMBED_Z_INDEX_MIN,
|
||||
buildSlotEditPayload,
|
||||
buildSettingsPayload,
|
||||
isSlotDraftDirty,
|
||||
type EmbedSettingsDraft
|
||||
} from '@/utils/system/embedSlots'
|
||||
|
||||
@@ -120,6 +197,14 @@
|
||||
const nameError = ref('')
|
||||
const submitting = ref(false)
|
||||
|
||||
// 脏态:编辑态与已保存条目比对 / 新建态与缺省比对 →「未保存」标记
|
||||
const dirty = computed(() => isSlotDraftDirty(props.embedSlot, draftName.value, draft))
|
||||
|
||||
// 预览相册标题:编辑已绑定相册用真实标题,否则词条占位(纯展示)
|
||||
const previewAlbumTitle = computed(
|
||||
() => props.embedSlot?.albumTitle ?? t('embedSlotsManage.preview.mockAlbumTitle')
|
||||
)
|
||||
|
||||
// 过滤/下拉选项(computed:语言切换时标签随 i18n 响应式刷新)
|
||||
const positionOptions = computed(() => [
|
||||
{ value: 'left' as const, label: t('embedSlotsManage.form.positionLeft') },
|
||||
@@ -183,3 +268,22 @@
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.slot-form-dialog__form {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.slot-form-dialog__preview {
|
||||
flex: none;
|
||||
width: 470px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-left: 20px;
|
||||
border-left: 1px solid var(--el-border-color-lighter);
|
||||
/* 高配置密度对话框:预览列内部滚动,对话框整体不超出视口 */
|
||||
max-height: calc(96vh - 200px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user