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
+187 -2
View File
@@ -4,12 +4,19 @@ import enMessages from '@/locales/langs/en.json'
import {
bindChangeKind,
buildEmbedSnippet,
buildPreviewPlacement,
buildPreviewWidgetStyle,
buildSettingsPayload,
buildSlotEditPayload,
clampIntervalMs,
clampOffsetBottom,
clampOffsetX,
clampZIndex,
DEFAULT_EMBED_SETTINGS,
EMBED_ORIGIN_FALLBACK,
embedOriginFromEnv,
explicitDataAttrs,
isSlotDraftDirty,
type EmbedSettingsDraft
} from './embedSlots'
@@ -27,13 +34,17 @@ const CURRENT_ITEM: Api.EmbedSlot.Item = {
}
describe('buildSettingsPayload 表单 → settings 载荷', () => {
it('缺省草稿 → 契约缺省值全集(整体提交:字段齐全)', () => {
it('缺省草稿 → 契约缺省值全集(整体提交:字段齐全,含 UI-01 新字段)', () => {
expect(buildSettingsPayload({ ...DEFAULT_EMBED_SETTINGS })).toEqual({
position: 'right',
size: 'md',
intervalMs: 5000,
lang: 'auto',
collapsedDefault: false
collapsedDefault: false,
imageFit: 'contain',
offsetX: 16,
offsetBottom: 24,
zIndex: 1000
})
})
@@ -46,6 +57,40 @@ describe('buildSettingsPayload 表单 → settings 载荷', () => {
const draft: EmbedSettingsDraft = { ...DEFAULT_EMBED_SETTINGS, intervalMs: 2000 }
expect(buildSettingsPayload(draft).intervalMs).toBe(3000)
})
it('UI-01 新数值字段(offsetX/offsetBottom/zIndex)→ 载荷夹取到契约区间', () => {
expect(clampOffsetX(-5)).toBe(0)
expect(clampOffsetX(999)).toBe(120)
expect(clampOffsetX(Number.NaN)).toBe(16)
expect(clampOffsetBottom(-1)).toBe(0)
expect(clampOffsetBottom(9999)).toBe(240)
expect(clampOffsetBottom(Number.NaN)).toBe(24)
expect(clampZIndex(0)).toBe(1)
expect(clampZIndex(9999999999)).toBe(2147483000)
expect(clampZIndex(Number.NaN)).toBe(1000)
const draft: EmbedSettingsDraft = {
...DEFAULT_EMBED_SETTINGS,
offsetX: 40,
offsetBottom: 80,
zIndex: 2000
}
expect(buildSettingsPayload(draft)).toMatchObject({
offsetX: 40,
offsetBottom: 80,
zIndex: 2000
})
const overflow: EmbedSettingsDraft = {
...DEFAULT_EMBED_SETTINGS,
offsetX: 400,
offsetBottom: -3,
zIndex: 0
}
expect(buildSettingsPayload(overflow)).toMatchObject({
offsetX: 120,
offsetBottom: 0,
zIndex: 1
})
})
})
describe('buildSlotEditPayload 编辑载荷收敛', () => {
@@ -94,6 +139,146 @@ describe('buildEmbedSnippet 嵌入代码拼接', () => {
})
})
describe('嵌入代码 data-* 显式覆盖(UI-01:仅非默认字段,缺省让后台设置生效)', () => {
it('全默认 settings → 零 data-* 覆盖(裸 loader 引用)', () => {
expect(explicitDataAttrs({ ...DEFAULT_EMBED_SETTINGS })).toEqual({})
expect(
buildEmbedSnippet('https://view.example.com', 'tok', { ...DEFAULT_EMBED_SETTINGS })
).toBe(
'<script src="https://view.example.com/embed/v1/loader.js" data-slot="tok" defer></script>'
)
expect(buildEmbedSnippet('https://view.example.com', 'tok', null)).toBe(
'<script src="https://view.example.com/embed/v1/loader.js" data-slot="tok" defer></script>'
)
})
it('非默认字段 → 对应 data-*(烤串属性名对齐 loader dataset 键)', () => {
const settings: EmbedSettingsDraft = {
...DEFAULT_EMBED_SETTINGS,
position: 'left',
intervalMs: 8000,
imageFit: 'cover'
}
expect(explicitDataAttrs(settings)).toEqual({
'data-position': 'left',
'data-interval': '8000',
'data-image-fit': 'cover'
})
expect(buildEmbedSnippet('https://view.example.com', 'tok', settings)).toBe(
'<script src="https://view.example.com/embed/v1/loader.js" data-slot="tok" data-position="left" data-interval="8000" data-image-fit="cover" defer></script>'
)
})
it('全部字段非默认 → 全集覆盖(collapsedDefault 无 data-* 支持,恒省略)', () => {
const settings: EmbedSettingsDraft = {
position: 'left',
size: 'sm',
intervalMs: 3000,
lang: 'en',
collapsedDefault: true,
imageFit: 'cover',
offsetX: 40,
offsetBottom: 80,
zIndex: 2000
}
expect(Object.keys(explicitDataAttrs(settings))).toEqual([
'data-position',
'data-size',
'data-interval',
'data-lang',
'data-image-fit',
'data-offset-x',
'data-offset-bottom',
'data-z-index'
])
})
})
describe('buildPreviewPlacement 本地预览停靠几何(草稿即时反映,纯本地)', () => {
it('desktop:按配置展开(collapsed=collapsedDefault),几何随草稿字段变化', () => {
const placement = buildPreviewPlacement({ ...DEFAULT_EMBED_SETTINGS }, 'desktop')
expect(placement).toEqual({
side: 'right',
size: 'md',
width: 320,
offsetX: 16,
offsetBottom: 24,
zIndex: 1000,
imageFit: 'contain',
collapsed: false
})
// 表单字段变化 → 预览几何即时跟随(未保存修改即时反映的纯逻辑基础)
const changed = buildPreviewPlacement(
{ ...DEFAULT_EMBED_SETTINGS, position: 'left', size: 'sm', offsetX: 40, zIndex: 2000 },
'desktop'
)
expect(changed.side).toBe('left')
expect(changed.width).toBe(280)
expect(changed.offsetX).toBe(40)
expect(changed.zIndex).toBe(2000)
expect(buildPreviewWidgetStyle(changed)).toMatchObject({
position: 'absolute',
left: '40px',
bottom: '24px',
width: '280px',
zIndex: '2000'
})
// collapsedDefault 仅作用于桌面预览初始形态
expect(
buildPreviewPlacement({ ...DEFAULT_EMBED_SETTINGS, collapsedDefault: true }, 'desktop')
.collapsed
).toBe(true)
})
it('mobile:窄屏强制 sm + 默认收起(§5.4 <480px);宽度按剩余宽收缩防溢出', () => {
const placement = buildPreviewPlacement({ ...DEFAULT_EMBED_SETTINGS }, 'mobile')
expect(placement.size).toBe('sm')
expect(placement.width).toBe(280)
expect(placement.collapsed).toBe(true) // 窄屏强制收起(与 collapsedDefault 无关)
expect(
buildPreviewPlacement({ ...DEFAULT_EMBED_SETTINGS, collapsedDefault: false }, 'mobile')
.collapsed
).toBe(true)
// 偏移挤占剩余宽 → 宽度取小(375 - 120 - 8 = 247)
expect(buildPreviewPlacement({ ...DEFAULT_EMBED_SETTINGS, offsetX: 120 }, 'mobile').width).toBe(
247
)
})
})
describe('isSlotDraftDirty 表单脏态(「未保存」标记)', () => {
it('编辑态与已保存一致 → 不脏;仅新字段/名称变化 → 脏', () => {
expect(isSlotDraftDirty(CURRENT_ITEM, CURRENT_ITEM.name, { ...DEFAULT_EMBED_SETTINGS })).toBe(
false
)
expect(
isSlotDraftDirty(CURRENT_ITEM, CURRENT_ITEM.name, {
...DEFAULT_EMBED_SETTINGS,
imageFit: 'cover'
})
).toBe(true)
expect(
isSlotDraftDirty(CURRENT_ITEM, CURRENT_ITEM.name, {
...DEFAULT_EMBED_SETTINGS,
zIndex: 2000
})
).toBe(true)
expect(
isSlotDraftDirty(CURRENT_ITEM, ` ${CURRENT_ITEM.name} `, {
...DEFAULT_EMBED_SETTINGS
})
).toBe(false) // 等价空白修剪不算变化
expect(isSlotDraftDirty(CURRENT_ITEM, '新名称', { ...DEFAULT_EMBED_SETTINGS })).toBe(true)
})
it('新建态:名称与设置均为缺省 → 不脏;任一输入 → 脏', () => {
expect(isSlotDraftDirty(null, '', { ...DEFAULT_EMBED_SETTINGS })).toBe(false)
expect(isSlotDraftDirty(null, ' ', { ...DEFAULT_EMBED_SETTINGS })).toBe(false)
expect(isSlotDraftDirty(null, '新展示位', { ...DEFAULT_EMBED_SETTINGS })).toBe(true)
expect(isSlotDraftDirty(null, '', { ...DEFAULT_EMBED_SETTINGS, offsetBottom: 100 })).toBe(true)
})
})
describe('bindChangeKind 绑定变更分类', () => {
it('null→id 绑定 / id→id 换绑 / id→null 解绑 / 值不变 none', () => {
expect(bindChangeKind(null, 'album-1')).toBe('bind')
+179 -13
View File
@@ -1,14 +1,21 @@
/**
* 展示位管理页纯逻辑(无组件依赖,vitest 直接覆盖;P4b Task 5)
* 展示位管理页纯逻辑(无组件依赖,vitest 直接覆盖;P4b Task 5,UI-01 Task 7 扩展)
*
* - 嵌入代码:`<script src="{EMBED_ORIGIN}/embed/v1/loader.js"
* data-slot="{publicToken}" defer>`(loader 由 viewer 静态服务;EMBED_ORIGIN
* 即 viewer 部署基址,经 VITE_EMBED_ORIGIN 配置,缺省兜底本机 dev 5175)
* - settings 表单 → 载荷:整体提交语义(提供时字段齐全),intervalMs 在
* 契约区间 3000..30000 内夹取(ElInputNumber min/max 之外的键盘输入兜底)
* 即 viewer 部署基址,经 VITE_EMBED_ORIGIN 配置,缺省兜底本机 dev 5175);
* UI-01:可选携带 settings → 仅非默认字段生成 data-* 覆盖(缺省字段省略,
* 后台设置生效——loader 侧 explicit 表语义,方案 §5.4)
* - settings 表单 → 载荷:整体提交语义(提供时字段齐全),intervalMs 及
* UI-01 新增数值字段(offsetX/offsetBottom/zIndex)在契约区间内夹取
* (ElInputNumber min/max 之外的键盘输入兜底)
* - 编辑载荷收敛:只提交变更字段(name/settings 任一变化才送;settings
* 一旦送即为完整对象——契约整体替换语义);绑定变更(albumId)走独立
* 绑定对话框,不经编辑载荷
* - 表单脏态(isSlotDraftDirty):编辑态与已保存条目比对、新建态与缺省比对,
* 驱动「未保存」标记(保存按钮以外唯一持久化入口)
* - 本地预览(buildPreviewPlacement/buildPreviewWidgetStyle):草稿 → 预览
* 框内悬浮组件的停靠几何(纯本地计算,不调用接口不写展示位)
* - 绑定变更分类:bind(未绑定→绑定)/ rebind(换绑)/ unbind(解绑)/
* none(未变化不提交,不制造服务端 no-op)
*/
@@ -20,6 +27,17 @@ export const EMBED_ORIGIN_FALLBACK = 'http://127.0.0.1:5175'
export const EMBED_INTERVAL_MIN_MS = 3000
export const EMBED_INTERVAL_MAX_MS = 30000
/** 契约 EmbedSlotSettings UI-01 新增偏移/层级边界(对齐 loader params.js) */
export const EMBED_OFFSET_X_MIN = 0
export const EMBED_OFFSET_X_MAX = 120
export const EMBED_OFFSET_BOTTOM_MIN = 0
export const EMBED_OFFSET_BOTTOM_MAX = 240
export const EMBED_Z_INDEX_MIN = 1
export const EMBED_Z_INDEX_MAX = 2147483000
/** size → 悬浮组件宽度 px(sm 窄屏降级档;与 loader SIZE_WIDTH 对齐) */
export const EMBED_SIZE_WIDTH: Record<Api.EmbedSlot.Size, number> = { sm: 280, md: 320 }
/** 设置表单草稿(与契约 Settings 同形;创建对话框初始值即契约缺省值) */
export type EmbedSettingsDraft = Api.EmbedSlot.Settings
@@ -29,7 +47,11 @@ export const DEFAULT_EMBED_SETTINGS: EmbedSettingsDraft = {
size: 'md',
intervalMs: 5000,
lang: 'auto',
collapsedDefault: false
collapsedDefault: false,
imageFit: 'contain',
offsetX: 16,
offsetBottom: 24,
zIndex: 1000
}
/** 环境嵌入基址(VITE_EMBED_ORIGIN 缺省/空白兜底本机 dev;尾斜杠容忍) */
@@ -38,9 +60,47 @@ export function embedOriginFromEnv(env: { VITE_EMBED_ORIGIN?: string }): string
return origin && origin !== '' ? origin.replace(/\/+$/, '') : EMBED_ORIGIN_FALLBACK
}
/** 部署基址 + 展示位令牌 → 嵌入代码(宿主页粘贴即用;token 不可枚举) */
export function buildEmbedSnippet(origin: string, slotToken: string): string {
return `<script src="${origin.replace(/\/+$/, '')}/embed/v1/loader.js" data-slot="${slotToken}" defer></script>`
/** 部署基址 + 展示位令牌 → 嵌入代码(宿主页粘贴即用;token 不可枚举)。
* UI-01:可选携带 settings——仅与契约默认不同的字段生成 data-* 覆盖
* (缺省字段省略 → 后台设置生效;loader 侧 explicit 表只记真正提供的字段)。 */
export function buildEmbedSnippet(
origin: string,
slotToken: string,
settings?: Api.EmbedSlot.Settings | null
): string {
const attrs = explicitDataAttrs(settings)
const attrText = Object.entries(attrs)
.map(([name, value]) => ` ${name}="${value}"`)
.join('')
return `<script src="${origin.replace(/\/+$/, '')}/embed/v1/loader.js" data-slot="${slotToken}"${attrText} defer></script>`
}
/** settings 字段 → loader data-* 属性名(dataset 驼峰 ↔ HTML 烤串) */
const DATA_ATTR_NAMES: Partial<Record<keyof Api.EmbedSlot.Settings, string>> = {
position: 'data-position',
size: 'data-size',
intervalMs: 'data-interval',
lang: 'data-lang',
imageFit: 'data-image-fit',
offsetX: 'data-offset-x',
offsetBottom: 'data-offset-bottom',
zIndex: 'data-z-index'
// collapsedDefault 无 data-* 支持(loader 不读取;窄屏强制收起另有规则)
}
/** settings → 显式覆盖的 data-* 属性表(仅非默认字段;顺序稳定便于断言/快照) */
export function explicitDataAttrs(
settings?: Api.EmbedSlot.Settings | null
): Record<string, string> {
const attrs: Record<string, string> = {}
if (settings === null || settings === undefined) return attrs
for (const [field, attr] of Object.entries(DATA_ATTR_NAMES) as Array<
[keyof Api.EmbedSlot.Settings, string]
>) {
const value = settings[field]
if (value !== DEFAULT_EMBED_SETTINGS[field]) attrs[attr] = String(value)
}
return attrs
}
/** intervalMs 夹取到契约区间(非有限值回退缺省 5000;四舍五入整毫秒) */
@@ -49,19 +109,47 @@ export function clampIntervalMs(value: number): number {
return Math.min(EMBED_INTERVAL_MAX_MS, Math.max(EMBED_INTERVAL_MIN_MS, Math.round(value)))
}
/** 表单草稿 → 完整 settings 载荷(整体提交:字段齐全 + intervalMs 夹取) */
export function buildSettingsPayload(draft: EmbedSettingsDraft): Api.EmbedSlot.Settings {
return { ...draft, intervalMs: clampIntervalMs(draft.intervalMs) }
/** offsetX 夹取到契约区间(非有限值回退缺省 16;四舍五入整像素) */
export function clampOffsetX(value: number): number {
if (!Number.isFinite(value)) return DEFAULT_EMBED_SETTINGS.offsetX
return Math.min(EMBED_OFFSET_X_MAX, Math.max(EMBED_OFFSET_X_MIN, Math.round(value)))
}
/** settings 是否有任一字段变化(编辑载荷只送变更面) */
/** offsetBottom 夹取到契约区间(非有限值回退缺省 24;四舍五入整像素) */
export function clampOffsetBottom(value: number): number {
if (!Number.isFinite(value)) return DEFAULT_EMBED_SETTINGS.offsetBottom
return Math.min(EMBED_OFFSET_BOTTOM_MAX, Math.max(EMBED_OFFSET_BOTTOM_MIN, Math.round(value)))
}
/** zIndex 夹取到契约区间(非有限值回退缺省 1000;四舍五入整数) */
export function clampZIndex(value: number): number {
if (!Number.isFinite(value)) return DEFAULT_EMBED_SETTINGS.zIndex
return Math.min(EMBED_Z_INDEX_MAX, Math.max(EMBED_Z_INDEX_MIN, Math.round(value)))
}
/** 表单草稿 → 完整 settings 载荷(整体提交:字段齐全 + 数值字段夹取) */
export function buildSettingsPayload(draft: EmbedSettingsDraft): Api.EmbedSlot.Settings {
return {
...draft,
intervalMs: clampIntervalMs(draft.intervalMs),
offsetX: clampOffsetX(draft.offsetX),
offsetBottom: clampOffsetBottom(draft.offsetBottom),
zIndex: clampZIndex(draft.zIndex)
}
}
/** settings 是否有任一字段变化(编辑载荷只送变更面;数值按夹取后比对) */
function settingsChanged(current: Api.EmbedSlot.Settings, next: EmbedSettingsDraft): boolean {
return (
current.position !== next.position ||
current.size !== next.size ||
current.intervalMs !== clampIntervalMs(next.intervalMs) ||
current.lang !== next.lang ||
current.collapsedDefault !== next.collapsedDefault
current.collapsedDefault !== next.collapsedDefault ||
current.imageFit !== next.imageFit ||
current.offsetX !== clampOffsetX(next.offsetX) ||
current.offsetBottom !== clampOffsetBottom(next.offsetBottom) ||
current.zIndex !== clampZIndex(next.zIndex)
)
}
@@ -80,6 +168,84 @@ export function buildSlotEditPayload(
return payload
}
/** 表单脏态(驱动「未保存」标记):编辑态与已保存条目逐字段比对;
* 新建态(current=null)名称非空或设置偏离缺省即脏(有未保存内容)。 */
export function isSlotDraftDirty(
current: Api.EmbedSlot.Item | null,
name: string,
draft: EmbedSettingsDraft
): boolean {
if (current === null) {
return name.trim() !== '' || settingsChanged(DEFAULT_EMBED_SETTINGS, draft)
}
return Object.keys(buildSlotEditPayload(current, name, draft)).length > 0
}
/** 本地预览停靠几何(buildPreviewPlacement 产物;纯本地渲染输入,不入载荷) */
export interface EmbedPreviewPlacement {
/** 停靠边(宿主左右侧) */
side: Api.EmbedSlot.Position
/** 尺寸档(mobile 窄屏强制 sm) */
size: Api.EmbedSlot.Size
/** 组件宽度 px(配置宽与视口剩余宽取小,§5.4 无溢出;下限 180) */
width: number
/** 距预览框边缘水平偏移 px(夹取后) */
offsetX: number
/** 距预览框底部垂直偏移 px(夹取后) */
offsetBottom: number
/** 层级(夹取后;预览框内真实生效) */
zIndex: number
/** 图片适配(舞台 contain/cover 直观反映) */
imageFit: Api.EmbedSlot.ImageFit
/** 初始是否收起:desktop=collapsedDefault;mobile=true(<480px 强制,§5.4) */
collapsed: boolean
}
/** 极窄视口防溢出下限(对齐 loader MIN_WRAPPER_WIDTH) */
export const EMBED_PREVIEW_MIN_WIDTH = 180
/** 预览框视口宽度 px(组件帧常量:desktop 440 / mobile 375) */
export const EMBED_PREVIEW_FRAME_WIDTH: Record<'desktop' | 'mobile', number> = {
desktop: 440,
mobile: 375
}
/** 表单草稿 → 预览停靠几何(desktop=按配置;mobile=窄屏强制 sm+收起)。
* 数值全部经契约夹取——表单输入的中间态(空/越界)预览不抖动;宽度按
* loader applyWrapperBox 同规则取配置宽与视口剩余宽的较小值。 */
export function buildPreviewPlacement(
draft: EmbedSettingsDraft,
mode: 'desktop' | 'mobile',
frameWidth: number = EMBED_PREVIEW_FRAME_WIDTH[mode]
): EmbedPreviewPlacement {
const narrow = mode === 'mobile'
const size: Api.EmbedSlot.Size = narrow ? 'sm' : draft.size
const offsetX = clampOffsetX(draft.offsetX)
const available = Math.max(frameWidth - offsetX - 8, EMBED_PREVIEW_MIN_WIDTH)
return {
side: draft.position,
size,
width: Math.min(EMBED_SIZE_WIDTH[size], available),
offsetX,
offsetBottom: clampOffsetBottom(draft.offsetBottom),
zIndex: clampZIndex(draft.zIndex),
imageFit: draft.imageFit,
collapsed: narrow ? true : draft.collapsedDefault
}
}
/** 停靠几何 → 预览框内悬浮组件 wrapper 样式(对齐 loader wrapper:固定停靠
* 边 + offset + 宽度 + zIndex;预览框内以 absolute 模拟宿主 fixed)。 */
export function buildPreviewWidgetStyle(placement: EmbedPreviewPlacement): Record<string, string> {
return {
position: 'absolute',
[placement.side]: `${placement.offsetX}px`,
bottom: `${placement.offsetBottom}px`,
width: `${placement.width}px`,
zIndex: String(placement.zIndex)
}
}
/** 绑定变更分类(确认文案与提交动作共用;none=不提交) */
export type BindChangeKind = 'bind' | 'rebind' | 'unbind' | 'none'