feat(admin): UI-01 embed slot config form and live preview

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-21 19:26:48 +08:00
parent ddf26a177f
commit ac23a4c40e
9 changed files with 1362 additions and 96 deletions
@@ -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>