feat(admin): UI-01 embed slot config form and live preview
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user