feat(embed,ui04): E01 v2 contract skeleton — displayMode/scrollSpeed/reactionsEnabled, v2 loader entry and fixed 195x280 shell
- contracts: EmbedSlotSettings optional v2 fields (carousel default) + SlotSettingsPayload; gen + redocly green - api: DTO validation + normalization full-set (cn manage + ov public projection share normalizeStoredSettings) - embed-loader: params v2 parse/merge/url family, loader-v2.js fixed shell (border/radius/#10161E/safe-area, resize ignored, stack gap 12), dual-artifact build+sync; v1 source frozen - viewer: EmbedPage v=2 branch, EmbedStream (viewport/track/real anchors/collapse/empty), stream.ts pure fns, i18n zh/en - admin: types + utils (create default vertical-stream, clampScrollSpeed, v1/v2 snippet selection) - tests: loader 36 (+9 v2), viewer 222, admin 82, api unit 51, v1 embed e2e 20, v2 E01 gate 2 (195x280 desktop+mobile cross-origin, no footer) Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -11,11 +11,13 @@ import {
|
||||
clampIntervalMs,
|
||||
clampOffsetBottom,
|
||||
clampOffsetX,
|
||||
clampScrollSpeed,
|
||||
clampZIndex,
|
||||
DEFAULT_EMBED_SETTINGS,
|
||||
EMBED_ORIGIN_FALLBACK,
|
||||
embedOriginFromEnv,
|
||||
explicitDataAttrs,
|
||||
explicitDataAttrsV2,
|
||||
isSlotDraftDirty,
|
||||
type EmbedSettingsDraft
|
||||
} from './embedSlots'
|
||||
@@ -34,7 +36,7 @@ const CURRENT_ITEM: Api.EmbedSlot.Item = {
|
||||
}
|
||||
|
||||
describe('buildSettingsPayload 表单 → settings 载荷', () => {
|
||||
it('缺省草稿 → 契约缺省值全集(整体提交:字段齐全,含 UI-01 新字段)', () => {
|
||||
it('缺省草稿 → 契约缺省值全集(整体提交:字段齐全,含 UI-01/UI-04 新字段)', () => {
|
||||
expect(buildSettingsPayload({ ...DEFAULT_EMBED_SETTINGS })).toEqual({
|
||||
position: 'right',
|
||||
size: 'md',
|
||||
@@ -44,7 +46,10 @@ describe('buildSettingsPayload 表单 → settings 载荷', () => {
|
||||
imageFit: 'contain',
|
||||
offsetX: 16,
|
||||
offsetBottom: 24,
|
||||
zIndex: 1000
|
||||
zIndex: 1000,
|
||||
displayMode: 'vertical-stream',
|
||||
scrollSpeed: 16,
|
||||
reactionsEnabled: true
|
||||
})
|
||||
})
|
||||
|
||||
@@ -91,6 +96,27 @@ describe('buildSettingsPayload 表单 → settings 载荷', () => {
|
||||
zIndex: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('UI-04 scrollSpeed → 夹取到 8..32(非有限值回退 16)', () => {
|
||||
expect(clampScrollSpeed(1)).toBe(8)
|
||||
expect(clampScrollSpeed(64)).toBe(32)
|
||||
expect(clampScrollSpeed(Number.NaN)).toBe(16)
|
||||
const draft: EmbedSettingsDraft = { ...DEFAULT_EMBED_SETTINGS, scrollSpeed: 20 }
|
||||
expect(buildSettingsPayload(draft).scrollSpeed).toBe(20)
|
||||
const overflow: EmbedSettingsDraft = { ...DEFAULT_EMBED_SETTINGS, scrollSpeed: 99 }
|
||||
expect(buildSettingsPayload(overflow).scrollSpeed).toBe(32)
|
||||
})
|
||||
|
||||
it('UI-04 displayMode/reactionsEnabled 变化 → 进编辑载荷(整体替换含新字段)', () => {
|
||||
const changed: EmbedSettingsDraft = {
|
||||
...DEFAULT_EMBED_SETTINGS,
|
||||
displayMode: 'carousel',
|
||||
reactionsEnabled: false
|
||||
}
|
||||
expect(buildSlotEditPayload(CURRENT_ITEM, CURRENT_ITEM.name, changed)).toEqual({
|
||||
settings: changed
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildSlotEditPayload 编辑载荷收敛', () => {
|
||||
@@ -140,10 +166,13 @@ describe('buildEmbedSnippet 嵌入代码拼接', () => {
|
||||
})
|
||||
|
||||
describe('嵌入代码 data-* 显式覆盖(UI-01:仅非默认字段,缺省让后台设置生效)', () => {
|
||||
it('全默认 settings → 零 data-* 覆盖(裸 loader 引用)', () => {
|
||||
expect(explicitDataAttrs({ ...DEFAULT_EMBED_SETTINGS })).toEqual({})
|
||||
it('全默认 settings(carousel)→ 零 data-* 覆盖(裸 v1 引用)', () => {
|
||||
expect(explicitDataAttrs({ ...DEFAULT_EMBED_SETTINGS, displayMode: 'carousel' })).toEqual({})
|
||||
expect(
|
||||
buildEmbedSnippet('https://view.example.com', 'tok', { ...DEFAULT_EMBED_SETTINGS })
|
||||
buildEmbedSnippet('https://view.example.com', 'tok', {
|
||||
...DEFAULT_EMBED_SETTINGS,
|
||||
displayMode: 'carousel'
|
||||
})
|
||||
).toBe(
|
||||
'<script src="https://view.example.com/embed/v1/loader.js" data-slot="tok" defer></script>'
|
||||
)
|
||||
@@ -155,6 +184,7 @@ describe('嵌入代码 data-* 显式覆盖(UI-01:仅非默认字段,缺省
|
||||
it('非默认字段 → 对应 data-*(烤串属性名对齐 loader dataset 键)', () => {
|
||||
const settings: EmbedSettingsDraft = {
|
||||
...DEFAULT_EMBED_SETTINGS,
|
||||
displayMode: 'carousel',
|
||||
position: 'left',
|
||||
intervalMs: 8000,
|
||||
imageFit: 'cover'
|
||||
@@ -179,7 +209,10 @@ describe('嵌入代码 data-* 显式覆盖(UI-01:仅非默认字段,缺省
|
||||
imageFit: 'cover',
|
||||
offsetX: 40,
|
||||
offsetBottom: 80,
|
||||
zIndex: 2000
|
||||
zIndex: 2000,
|
||||
displayMode: 'carousel',
|
||||
scrollSpeed: 24,
|
||||
reactionsEnabled: false
|
||||
}
|
||||
expect(Object.keys(explicitDataAttrs(settings))).toEqual([
|
||||
'data-position',
|
||||
@@ -194,6 +227,40 @@ describe('嵌入代码 data-* 显式覆盖(UI-01:仅非默认字段,缺省
|
||||
})
|
||||
})
|
||||
|
||||
describe('嵌入代码 v2 入口(UI-04:displayMode=vertical-stream → /embed/v2/)', () => {
|
||||
it('v2 默认 settings → 裸 v2 引用(无 v1 布局 data-*)', () => {
|
||||
expect(buildEmbedSnippet('https://view.example.com', 'tok', { ...DEFAULT_EMBED_SETTINGS })).toBe(
|
||||
'<script src="https://view.example.com/embed/v2/loader.js" data-slot="tok" defer></script>'
|
||||
)
|
||||
})
|
||||
|
||||
it('v2 非默认 speed/reactions/位置 → data-speed/data-reactions/data-position(v1 布局字段不生成)', () => {
|
||||
const settings: EmbedSettingsDraft = {
|
||||
...DEFAULT_EMBED_SETTINGS,
|
||||
position: 'left',
|
||||
size: 'sm',
|
||||
intervalMs: 8000,
|
||||
imageFit: 'cover',
|
||||
scrollSpeed: 24,
|
||||
reactionsEnabled: false
|
||||
}
|
||||
expect(buildEmbedSnippet('https://view.example.com', 'tok', settings)).toBe(
|
||||
'<script src="https://view.example.com/embed/v2/loader.js" data-slot="tok" data-position="left" data-speed="24" data-reactions="off" defer></script>'
|
||||
)
|
||||
})
|
||||
|
||||
it('explicitDataAttrsV2:剥离 v1 布局字段,追加 speed/reactions(仅非默认)', () => {
|
||||
const base = { ...DEFAULT_EMBED_SETTINGS, position: 'left' as const, size: 'sm' as const }
|
||||
expect(explicitDataAttrsV2(base)).toEqual({ 'data-position': 'left' })
|
||||
expect(explicitDataAttrsV2({ ...DEFAULT_EMBED_SETTINGS, scrollSpeed: 32 })).toEqual({
|
||||
'data-speed': '32'
|
||||
})
|
||||
expect(explicitDataAttrsV2({ ...DEFAULT_EMBED_SETTINGS, reactionsEnabled: false })).toEqual({
|
||||
'data-reactions': 'off'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildPreviewPlacement 本地预览停靠几何(草稿即时反映,纯本地)', () => {
|
||||
it('desktop:按配置展开(collapsed=collapsedDefault),几何随草稿字段变化', () => {
|
||||
const placement = buildPreviewPlacement({ ...DEFAULT_EMBED_SETTINGS }, 'desktop')
|
||||
|
||||
@@ -35,13 +35,19 @@ export const EMBED_OFFSET_BOTTOM_MAX = 240
|
||||
export const EMBED_Z_INDEX_MIN = 1
|
||||
export const EMBED_Z_INDEX_MAX = 2147483000
|
||||
|
||||
/** 契约 UI-04 v2(vertical-stream)字段边界(对齐 loader params.js v2) */
|
||||
export const EMBED_SCROLL_SPEED_MIN = 8
|
||||
export const EMBED_SCROLL_SPEED_MAX = 32
|
||||
|
||||
/** 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
|
||||
|
||||
/** 契约缺省设置(创建对话框初始值;服务端 settings 整体缺省时同值落库) */
|
||||
/** 契约缺省设置(创建对话框初始值;服务端 settings 整体缺省时同值落库——
|
||||
* 唯 UI-04 例外:管理端新建默认 displayMode=vertical-stream(§8.2「默认新建
|
||||
* 选择新版」,经表单显式提交;服务端/契约缺省仍是 carousel,存量记录不迁移)。 */
|
||||
export const DEFAULT_EMBED_SETTINGS: EmbedSettingsDraft = {
|
||||
position: 'right',
|
||||
size: 'md',
|
||||
@@ -51,7 +57,10 @@ export const DEFAULT_EMBED_SETTINGS: EmbedSettingsDraft = {
|
||||
imageFit: 'contain',
|
||||
offsetX: 16,
|
||||
offsetBottom: 24,
|
||||
zIndex: 1000
|
||||
zIndex: 1000,
|
||||
displayMode: 'vertical-stream',
|
||||
scrollSpeed: 16,
|
||||
reactionsEnabled: true
|
||||
}
|
||||
|
||||
/** 环境嵌入基址(VITE_EMBED_ORIGIN 缺省/空白兜底本机 dev;尾斜杠容忍) */
|
||||
@@ -62,17 +71,21 @@ export function embedOriginFromEnv(env: { VITE_EMBED_ORIGIN?: string }): string
|
||||
|
||||
/** 部署基址 + 展示位令牌 → 嵌入代码(宿主页粘贴即用;token 不可枚举)。
|
||||
* UI-01:可选携带 settings——仅与契约默认不同的字段生成 data-* 覆盖
|
||||
* (缺省字段省略 → 后台设置生效;loader 侧 explicit 表只记真正提供的字段)。 */
|
||||
* (缺省字段省略 → 后台设置生效;loader 侧 explicit 表只记真正提供的字段)。
|
||||
* UI-04:displayMode=vertical-stream → 入口用 /embed/v2/loader.js(data-speed/
|
||||
* data-reactions 覆盖仅 v2 生成);carousel → v1 入口(v2 字段不生成属性)。 */
|
||||
export function buildEmbedSnippet(
|
||||
origin: string,
|
||||
slotToken: string,
|
||||
settings?: Api.EmbedSlot.Settings | null
|
||||
): string {
|
||||
const attrs = explicitDataAttrs(settings)
|
||||
const isV2 = settings?.displayMode === 'vertical-stream'
|
||||
const attrs = isV2 ? explicitDataAttrsV2(settings) : 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>`
|
||||
const version = isV2 ? 'v2' : 'v1'
|
||||
return `<script src="${origin.replace(/\/+$/, '')}/embed/${version}/loader.js" data-slot="${slotToken}"${attrText} defer></script>`
|
||||
}
|
||||
|
||||
/** settings 字段 → loader data-* 属性名(dataset 驼峰 ↔ HTML 烤串) */
|
||||
@@ -85,7 +98,8 @@ const DATA_ATTR_NAMES: Partial<Record<keyof Api.EmbedSlot.Settings, string>> = {
|
||||
offsetX: 'data-offset-x',
|
||||
offsetBottom: 'data-offset-bottom',
|
||||
zIndex: 'data-z-index'
|
||||
// collapsedDefault 无 data-* 支持(loader 不读取;窄屏强制收起另有规则)
|
||||
// collapsedDefault 无 data-* 支持(loader 不读取;窄屏强制收起另有规则);
|
||||
// displayMode 不生成(版本由入口 URL 决定,§7.2 v2 不可经旧 data-size 复原)。
|
||||
}
|
||||
|
||||
/** settings → 显式覆盖的 data-* 属性表(仅非默认字段;顺序稳定便于断言/快照) */
|
||||
@@ -103,6 +117,25 @@ export function explicitDataAttrs(
|
||||
return attrs
|
||||
}
|
||||
|
||||
/** v2(vertical-stream)data-* 覆盖表:共用字段同上 + speed/reactions;
|
||||
* v1 布局字段(size/interval/imageFit)对 v2 无布局作用,不生成(§7.2)。 */
|
||||
export function explicitDataAttrsV2(
|
||||
settings?: Api.EmbedSlot.Settings | null
|
||||
): Record<string, string> {
|
||||
const attrs = explicitDataAttrs(settings)
|
||||
delete attrs['data-size']
|
||||
delete attrs['data-interval']
|
||||
delete attrs['data-image-fit']
|
||||
if (settings === null || settings === undefined) return attrs
|
||||
if (settings.scrollSpeed !== DEFAULT_EMBED_SETTINGS.scrollSpeed) {
|
||||
attrs['data-speed'] = String(settings.scrollSpeed)
|
||||
}
|
||||
if (settings.reactionsEnabled !== DEFAULT_EMBED_SETTINGS.reactionsEnabled) {
|
||||
attrs['data-reactions'] = settings.reactionsEnabled ? 'on' : 'off'
|
||||
}
|
||||
return attrs
|
||||
}
|
||||
|
||||
/** intervalMs 夹取到契约区间(非有限值回退缺省 5000;四舍五入整毫秒) */
|
||||
export function clampIntervalMs(value: number): number {
|
||||
if (!Number.isFinite(value)) return DEFAULT_EMBED_SETTINGS.intervalMs
|
||||
@@ -127,6 +160,12 @@ export function clampZIndex(value: number): number {
|
||||
return Math.min(EMBED_Z_INDEX_MAX, Math.max(EMBED_Z_INDEX_MIN, Math.round(value)))
|
||||
}
|
||||
|
||||
/** scrollSpeed 夹取到契约区间(UI-04:非有限值回退缺省 16) */
|
||||
export function clampScrollSpeed(value: number): number {
|
||||
if (!Number.isFinite(value)) return DEFAULT_EMBED_SETTINGS.scrollSpeed
|
||||
return Math.min(EMBED_SCROLL_SPEED_MAX, Math.max(EMBED_SCROLL_SPEED_MIN, Math.round(value)))
|
||||
}
|
||||
|
||||
/** 表单草稿 → 完整 settings 载荷(整体提交:字段齐全 + 数值字段夹取) */
|
||||
export function buildSettingsPayload(draft: EmbedSettingsDraft): Api.EmbedSlot.Settings {
|
||||
return {
|
||||
@@ -134,7 +173,8 @@ export function buildSettingsPayload(draft: EmbedSettingsDraft): Api.EmbedSlot.S
|
||||
intervalMs: clampIntervalMs(draft.intervalMs),
|
||||
offsetX: clampOffsetX(draft.offsetX),
|
||||
offsetBottom: clampOffsetBottom(draft.offsetBottom),
|
||||
zIndex: clampZIndex(draft.zIndex)
|
||||
zIndex: clampZIndex(draft.zIndex),
|
||||
scrollSpeed: clampScrollSpeed(draft.scrollSpeed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +189,10 @@ function settingsChanged(current: Api.EmbedSlot.Settings, next: EmbedSettingsDra
|
||||
current.imageFit !== next.imageFit ||
|
||||
current.offsetX !== clampOffsetX(next.offsetX) ||
|
||||
current.offsetBottom !== clampOffsetBottom(next.offsetBottom) ||
|
||||
current.zIndex !== clampZIndex(next.zIndex)
|
||||
current.zIndex !== clampZIndex(next.zIndex) ||
|
||||
current.displayMode !== next.displayMode ||
|
||||
current.scrollSpeed !== clampScrollSpeed(next.scrollSpeed) ||
|
||||
current.reactionsEnabled !== next.reactionsEnabled
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user