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
-1
@@ -29,7 +29,11 @@ declare namespace Api {
|
|||||||
/** 轮播图片适配方式(UI-01:contain=4:3 舞台完整展示;cover=裁切填满) */
|
/** 轮播图片适配方式(UI-01:contain=4:3 舞台完整展示;cover=裁切填满) */
|
||||||
type ImageFit = 'contain' | 'cover'
|
type ImageFit = 'contain' | 'cover'
|
||||||
|
|
||||||
/** 展示设置(响应中字段恒齐全;UI-01 新增字段为可选扩展,响应恒为归一化全集) */
|
/** 展示模式(UI-04):carousel=旧版轮播(v1 loader);vertical-stream=
|
||||||
|
* v2 垂直照片流窗(固定 195×280 连续上滚,忽略 size/intervalMs/imageFit 布局作用) */
|
||||||
|
type DisplayMode = 'carousel' | 'vertical-stream'
|
||||||
|
|
||||||
|
/** 展示设置(响应中字段恒齐全;UI-01/UI-04 新增字段为可选扩展,响应恒为归一化全集) */
|
||||||
interface Settings {
|
interface Settings {
|
||||||
position: Position
|
position: Position
|
||||||
size: Size
|
size: Size
|
||||||
@@ -46,6 +50,12 @@ declare namespace Api {
|
|||||||
offsetBottom: number
|
offsetBottom: number
|
||||||
/** 宿主页面层级(UI-01;1..2147483000,缺省 1000) */
|
/** 宿主页面层级(UI-01;1..2147483000,缺省 1000) */
|
||||||
zIndex: number
|
zIndex: number
|
||||||
|
/** 展示模式(UI-04;服务端归一恒在,旧记录读取归一为 carousel) */
|
||||||
|
displayMode: DisplayMode
|
||||||
|
/** v2 自动上滚速度 CSS px/秒(8..32,缺省 16;仅 vertical-stream 生效) */
|
||||||
|
scrollSpeed: number
|
||||||
|
/** v2 点赞气泡开关(缺省 true;false 仅隐藏气泡) */
|
||||||
|
reactionsEnabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 展示位条目(管理视角) */
|
/** 展示位条目(管理视角) */
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ import {
|
|||||||
clampIntervalMs,
|
clampIntervalMs,
|
||||||
clampOffsetBottom,
|
clampOffsetBottom,
|
||||||
clampOffsetX,
|
clampOffsetX,
|
||||||
|
clampScrollSpeed,
|
||||||
clampZIndex,
|
clampZIndex,
|
||||||
DEFAULT_EMBED_SETTINGS,
|
DEFAULT_EMBED_SETTINGS,
|
||||||
EMBED_ORIGIN_FALLBACK,
|
EMBED_ORIGIN_FALLBACK,
|
||||||
embedOriginFromEnv,
|
embedOriginFromEnv,
|
||||||
explicitDataAttrs,
|
explicitDataAttrs,
|
||||||
|
explicitDataAttrsV2,
|
||||||
isSlotDraftDirty,
|
isSlotDraftDirty,
|
||||||
type EmbedSettingsDraft
|
type EmbedSettingsDraft
|
||||||
} from './embedSlots'
|
} from './embedSlots'
|
||||||
@@ -34,7 +36,7 @@ const CURRENT_ITEM: Api.EmbedSlot.Item = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('buildSettingsPayload 表单 → settings 载荷', () => {
|
describe('buildSettingsPayload 表单 → settings 载荷', () => {
|
||||||
it('缺省草稿 → 契约缺省值全集(整体提交:字段齐全,含 UI-01 新字段)', () => {
|
it('缺省草稿 → 契约缺省值全集(整体提交:字段齐全,含 UI-01/UI-04 新字段)', () => {
|
||||||
expect(buildSettingsPayload({ ...DEFAULT_EMBED_SETTINGS })).toEqual({
|
expect(buildSettingsPayload({ ...DEFAULT_EMBED_SETTINGS })).toEqual({
|
||||||
position: 'right',
|
position: 'right',
|
||||||
size: 'md',
|
size: 'md',
|
||||||
@@ -44,7 +46,10 @@ describe('buildSettingsPayload 表单 → settings 载荷', () => {
|
|||||||
imageFit: 'contain',
|
imageFit: 'contain',
|
||||||
offsetX: 16,
|
offsetX: 16,
|
||||||
offsetBottom: 24,
|
offsetBottom: 24,
|
||||||
zIndex: 1000
|
zIndex: 1000,
|
||||||
|
displayMode: 'vertical-stream',
|
||||||
|
scrollSpeed: 16,
|
||||||
|
reactionsEnabled: true
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -91,6 +96,27 @@ describe('buildSettingsPayload 表单 → settings 载荷', () => {
|
|||||||
zIndex: 1
|
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 编辑载荷收敛', () => {
|
describe('buildSlotEditPayload 编辑载荷收敛', () => {
|
||||||
@@ -140,10 +166,13 @@ describe('buildEmbedSnippet 嵌入代码拼接', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('嵌入代码 data-* 显式覆盖(UI-01:仅非默认字段,缺省让后台设置生效)', () => {
|
describe('嵌入代码 data-* 显式覆盖(UI-01:仅非默认字段,缺省让后台设置生效)', () => {
|
||||||
it('全默认 settings → 零 data-* 覆盖(裸 loader 引用)', () => {
|
it('全默认 settings(carousel)→ 零 data-* 覆盖(裸 v1 引用)', () => {
|
||||||
expect(explicitDataAttrs({ ...DEFAULT_EMBED_SETTINGS })).toEqual({})
|
expect(explicitDataAttrs({ ...DEFAULT_EMBED_SETTINGS, displayMode: 'carousel' })).toEqual({})
|
||||||
expect(
|
expect(
|
||||||
buildEmbedSnippet('https://view.example.com', 'tok', { ...DEFAULT_EMBED_SETTINGS })
|
buildEmbedSnippet('https://view.example.com', 'tok', {
|
||||||
|
...DEFAULT_EMBED_SETTINGS,
|
||||||
|
displayMode: 'carousel'
|
||||||
|
})
|
||||||
).toBe(
|
).toBe(
|
||||||
'<script src="https://view.example.com/embed/v1/loader.js" data-slot="tok" defer></script>'
|
'<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 键)', () => {
|
it('非默认字段 → 对应 data-*(烤串属性名对齐 loader dataset 键)', () => {
|
||||||
const settings: EmbedSettingsDraft = {
|
const settings: EmbedSettingsDraft = {
|
||||||
...DEFAULT_EMBED_SETTINGS,
|
...DEFAULT_EMBED_SETTINGS,
|
||||||
|
displayMode: 'carousel',
|
||||||
position: 'left',
|
position: 'left',
|
||||||
intervalMs: 8000,
|
intervalMs: 8000,
|
||||||
imageFit: 'cover'
|
imageFit: 'cover'
|
||||||
@@ -179,7 +209,10 @@ describe('嵌入代码 data-* 显式覆盖(UI-01:仅非默认字段,缺省
|
|||||||
imageFit: 'cover',
|
imageFit: 'cover',
|
||||||
offsetX: 40,
|
offsetX: 40,
|
||||||
offsetBottom: 80,
|
offsetBottom: 80,
|
||||||
zIndex: 2000
|
zIndex: 2000,
|
||||||
|
displayMode: 'carousel',
|
||||||
|
scrollSpeed: 24,
|
||||||
|
reactionsEnabled: false
|
||||||
}
|
}
|
||||||
expect(Object.keys(explicitDataAttrs(settings))).toEqual([
|
expect(Object.keys(explicitDataAttrs(settings))).toEqual([
|
||||||
'data-position',
|
'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 本地预览停靠几何(草稿即时反映,纯本地)', () => {
|
describe('buildPreviewPlacement 本地预览停靠几何(草稿即时反映,纯本地)', () => {
|
||||||
it('desktop:按配置展开(collapsed=collapsedDefault),几何随草稿字段变化', () => {
|
it('desktop:按配置展开(collapsed=collapsedDefault),几何随草稿字段变化', () => {
|
||||||
const placement = buildPreviewPlacement({ ...DEFAULT_EMBED_SETTINGS }, 'desktop')
|
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_MIN = 1
|
||||||
export const EMBED_Z_INDEX_MAX = 2147483000
|
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 对齐) */
|
/** size → 悬浮组件宽度 px(sm 窄屏降级档;与 loader SIZE_WIDTH 对齐) */
|
||||||
export const EMBED_SIZE_WIDTH: Record<Api.EmbedSlot.Size, number> = { sm: 280, md: 320 }
|
export const EMBED_SIZE_WIDTH: Record<Api.EmbedSlot.Size, number> = { sm: 280, md: 320 }
|
||||||
|
|
||||||
/** 设置表单草稿(与契约 Settings 同形;创建对话框初始值即契约缺省值) */
|
/** 设置表单草稿(与契约 Settings 同形;创建对话框初始值即契约缺省值) */
|
||||||
export type EmbedSettingsDraft = Api.EmbedSlot.Settings
|
export type EmbedSettingsDraft = Api.EmbedSlot.Settings
|
||||||
|
|
||||||
/** 契约缺省设置(创建对话框初始值;服务端 settings 整体缺省时同值落库) */
|
/** 契约缺省设置(创建对话框初始值;服务端 settings 整体缺省时同值落库——
|
||||||
|
* 唯 UI-04 例外:管理端新建默认 displayMode=vertical-stream(§8.2「默认新建
|
||||||
|
* 选择新版」,经表单显式提交;服务端/契约缺省仍是 carousel,存量记录不迁移)。 */
|
||||||
export const DEFAULT_EMBED_SETTINGS: EmbedSettingsDraft = {
|
export const DEFAULT_EMBED_SETTINGS: EmbedSettingsDraft = {
|
||||||
position: 'right',
|
position: 'right',
|
||||||
size: 'md',
|
size: 'md',
|
||||||
@@ -51,7 +57,10 @@ export const DEFAULT_EMBED_SETTINGS: EmbedSettingsDraft = {
|
|||||||
imageFit: 'contain',
|
imageFit: 'contain',
|
||||||
offsetX: 16,
|
offsetX: 16,
|
||||||
offsetBottom: 24,
|
offsetBottom: 24,
|
||||||
zIndex: 1000
|
zIndex: 1000,
|
||||||
|
displayMode: 'vertical-stream',
|
||||||
|
scrollSpeed: 16,
|
||||||
|
reactionsEnabled: true
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 环境嵌入基址(VITE_EMBED_ORIGIN 缺省/空白兜底本机 dev;尾斜杠容忍) */
|
/** 环境嵌入基址(VITE_EMBED_ORIGIN 缺省/空白兜底本机 dev;尾斜杠容忍) */
|
||||||
@@ -62,17 +71,21 @@ export function embedOriginFromEnv(env: { VITE_EMBED_ORIGIN?: string }): string
|
|||||||
|
|
||||||
/** 部署基址 + 展示位令牌 → 嵌入代码(宿主页粘贴即用;token 不可枚举)。
|
/** 部署基址 + 展示位令牌 → 嵌入代码(宿主页粘贴即用;token 不可枚举)。
|
||||||
* UI-01:可选携带 settings——仅与契约默认不同的字段生成 data-* 覆盖
|
* 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(
|
export function buildEmbedSnippet(
|
||||||
origin: string,
|
origin: string,
|
||||||
slotToken: string,
|
slotToken: string,
|
||||||
settings?: Api.EmbedSlot.Settings | null
|
settings?: Api.EmbedSlot.Settings | null
|
||||||
): string {
|
): string {
|
||||||
const attrs = explicitDataAttrs(settings)
|
const isV2 = settings?.displayMode === 'vertical-stream'
|
||||||
|
const attrs = isV2 ? explicitDataAttrsV2(settings) : explicitDataAttrs(settings)
|
||||||
const attrText = Object.entries(attrs)
|
const attrText = Object.entries(attrs)
|
||||||
.map(([name, value]) => ` ${name}="${value}"`)
|
.map(([name, value]) => ` ${name}="${value}"`)
|
||||||
.join('')
|
.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 烤串) */
|
/** settings 字段 → loader data-* 属性名(dataset 驼峰 ↔ HTML 烤串) */
|
||||||
@@ -85,7 +98,8 @@ const DATA_ATTR_NAMES: Partial<Record<keyof Api.EmbedSlot.Settings, string>> = {
|
|||||||
offsetX: 'data-offset-x',
|
offsetX: 'data-offset-x',
|
||||||
offsetBottom: 'data-offset-bottom',
|
offsetBottom: 'data-offset-bottom',
|
||||||
zIndex: 'data-z-index'
|
zIndex: 'data-z-index'
|
||||||
// collapsedDefault 无 data-* 支持(loader 不读取;窄屏强制收起另有规则)
|
// collapsedDefault 无 data-* 支持(loader 不读取;窄屏强制收起另有规则);
|
||||||
|
// displayMode 不生成(版本由入口 URL 决定,§7.2 v2 不可经旧 data-size 复原)。
|
||||||
}
|
}
|
||||||
|
|
||||||
/** settings → 显式覆盖的 data-* 属性表(仅非默认字段;顺序稳定便于断言/快照) */
|
/** settings → 显式覆盖的 data-* 属性表(仅非默认字段;顺序稳定便于断言/快照) */
|
||||||
@@ -103,6 +117,25 @@ export function explicitDataAttrs(
|
|||||||
return attrs
|
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;四舍五入整毫秒) */
|
/** intervalMs 夹取到契约区间(非有限值回退缺省 5000;四舍五入整毫秒) */
|
||||||
export function clampIntervalMs(value: number): number {
|
export function clampIntervalMs(value: number): number {
|
||||||
if (!Number.isFinite(value)) return DEFAULT_EMBED_SETTINGS.intervalMs
|
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)))
|
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 载荷(整体提交:字段齐全 + 数值字段夹取) */
|
/** 表单草稿 → 完整 settings 载荷(整体提交:字段齐全 + 数值字段夹取) */
|
||||||
export function buildSettingsPayload(draft: EmbedSettingsDraft): Api.EmbedSlot.Settings {
|
export function buildSettingsPayload(draft: EmbedSettingsDraft): Api.EmbedSlot.Settings {
|
||||||
return {
|
return {
|
||||||
@@ -134,7 +173,8 @@ export function buildSettingsPayload(draft: EmbedSettingsDraft): Api.EmbedSlot.S
|
|||||||
intervalMs: clampIntervalMs(draft.intervalMs),
|
intervalMs: clampIntervalMs(draft.intervalMs),
|
||||||
offsetX: clampOffsetX(draft.offsetX),
|
offsetX: clampOffsetX(draft.offsetX),
|
||||||
offsetBottom: clampOffsetBottom(draft.offsetBottom),
|
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.imageFit !== next.imageFit ||
|
||||||
current.offsetX !== clampOffsetX(next.offsetX) ||
|
current.offsetX !== clampOffsetX(next.offsetX) ||
|
||||||
current.offsetBottom !== clampOffsetBottom(next.offsetBottom) ||
|
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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { createServer, type Server } from 'node:http';
|
||||||
|
import { expect, test, type Page } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
cleanupSeededAlbums,
|
||||||
|
createEmbedSlot,
|
||||||
|
flushOutbox,
|
||||||
|
flushSync,
|
||||||
|
seedAlbum,
|
||||||
|
seedPhoto,
|
||||||
|
updateEmbedSlot,
|
||||||
|
} from './helpers';
|
||||||
|
|
||||||
|
// UI-04 v2(vertical-stream 照片流窗)跨源 Playwright:宿主 127.0.0.1:3999 ←
|
||||||
|
// viewer 5175 真跨源,loader /embed/v2/loader.js。随 E01-E07 逐任务扩展:
|
||||||
|
// E01 出口门 = 固定 195×280 小窗跨源可见且无 footer(本文件首批用例)。
|
||||||
|
const HOST_PORT = 3999;
|
||||||
|
const VIEWER_ORIGIN = 'http://127.0.0.1:5175';
|
||||||
|
const MINUTE = 60_000;
|
||||||
|
|
||||||
|
let slotToken = '';
|
||||||
|
|
||||||
|
async function seedFeed(alias: string): Promise<string> {
|
||||||
|
const album = await seedAlbum({
|
||||||
|
alias,
|
||||||
|
defaultLocale: 'zh-CN',
|
||||||
|
translations: [
|
||||||
|
{ locale: 'zh-CN', title: `${alias} 中文相册` },
|
||||||
|
{ locale: 'en', title: `${alias} Album` },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
for (let i = 1; i <= 6; i++) {
|
||||||
|
await seedPhoto(album.id, `${alias}-p${i}`, {
|
||||||
|
publishedAt: new Date(Date.now() - (7 - i) * MINUTE),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await flushSync(album.id);
|
||||||
|
const slot = await createEmbedSlot(`ui04-v2-${alias}`);
|
||||||
|
// v2 模式显式提交(E01 契约:displayMode vertical-stream + 速度/气泡)
|
||||||
|
await updateEmbedSlot(slot.id, {
|
||||||
|
albumId: album.id,
|
||||||
|
settings: {
|
||||||
|
position: 'right',
|
||||||
|
size: 'md',
|
||||||
|
intervalMs: 5000,
|
||||||
|
lang: 'auto',
|
||||||
|
collapsedDefault: false,
|
||||||
|
imageFit: 'contain',
|
||||||
|
offsetX: 16,
|
||||||
|
offsetBottom: 24,
|
||||||
|
zIndex: 1000,
|
||||||
|
displayMode: 'vertical-stream',
|
||||||
|
scrollSpeed: 16,
|
||||||
|
reactionsEnabled: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await flushOutbox();
|
||||||
|
return slot.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentHtml = '';
|
||||||
|
const server: Server = createServer((_req, res) => {
|
||||||
|
res.setHeader('content-type', 'text/html; charset=utf-8');
|
||||||
|
res.end(currentHtml);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function openHost(page: Page, viewport: { width: number; height: number }): Promise<void> {
|
||||||
|
await page.setViewportSize(viewport);
|
||||||
|
currentHtml = `<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>UI-04 v2 host</title></head>
|
||||||
|
<body style="min-height: 1200px; font-family: system-ui">
|
||||||
|
<h1>Host page (v2 stream)</h1>
|
||||||
|
<script src="${VIEWER_ORIGIN}/embed/v2/loader.js" data-slot="${slotToken}" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
await page.goto(`http://127.0.0.1:${HOST_PORT}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
slotToken = await seedFeed('ui04v2');
|
||||||
|
await new Promise<void>((resolve) => server.listen(HOST_PORT, '127.0.0.1', resolve));
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await cleanupSeededAlbums();
|
||||||
|
server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe.serial('E01 exit gate: fixed window, no footer', () => {
|
||||||
|
for (const [name, viewport] of [
|
||||||
|
['desktop 1440x900', { width: 1440, height: 900 }],
|
||||||
|
['mobile 390x844', { width: 390, height: 844 }],
|
||||||
|
] as const) {
|
||||||
|
test(`v2 window is fixed 195x280 on ${name}`, async ({ page }) => {
|
||||||
|
const mobile = viewport.width < 480;
|
||||||
|
await openHost(page, viewport);
|
||||||
|
const frame = page.locator('iframe[src*="/embed/"][src*="v=2"]');
|
||||||
|
const wrapper = frame.locator('..');
|
||||||
|
// 手机默认收起(沿用 v1 策略):点击把手展开后测量。
|
||||||
|
if (mobile) {
|
||||||
|
await page.locator('[role="button"]').first().click();
|
||||||
|
}
|
||||||
|
await expect(frame).toBeVisible();
|
||||||
|
await expect
|
||||||
|
.poll(async () => Math.round((await wrapper.boundingBox())?.width ?? 0))
|
||||||
|
.toBe(195);
|
||||||
|
await expect
|
||||||
|
.poll(async () => Math.round((await wrapper.boundingBox())?.height ?? 0))
|
||||||
|
.toBe(280);
|
||||||
|
// iframe 内:无 header/footer(E01 静态壳);右上收起按钮存在。
|
||||||
|
const inside = page.frameLocator('iframe[src*="/embed/"][src*="v=2"]');
|
||||||
|
await expect(inside.getByRole('button', { name: /收起照片窗|Collapse photo window/ })).toBeVisible();
|
||||||
|
// v1 结构不出现(判别渲染 DOM 而非原始 HTML——dev 模式内联 CSS 含全部
|
||||||
|
// scoped 类名,字符串断言会误报;「活动照片」字样亦存在于照片 alt 文案)。
|
||||||
|
await expect(inside.locator('.embed-head')).toHaveCount(0);
|
||||||
|
await expect(inside.locator('.embed-footer')).toHaveCount(0);
|
||||||
|
await expect(inside.getByText('查看相册')).toHaveCount(0);
|
||||||
|
await expect(inside.getByText('照片轮播')).toHaveCount(0);
|
||||||
|
await expect(inside.locator('.stream-viewport')).toHaveCount(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -54,6 +54,10 @@ function makeSettings(overrides: Partial<EmbedSlotSettings> = {}): EmbedSlotSett
|
|||||||
offsetX: 16,
|
offsetX: 16,
|
||||||
offsetBottom: 24,
|
offsetBottom: 24,
|
||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
|
// UI-04 v2 字段(响应恒为归一化全集;旧记录读取归一为 carousel)。
|
||||||
|
displayMode: 'carousel',
|
||||||
|
scrollSpeed: 16,
|
||||||
|
reactionsEnabled: true,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// apps/viewer/src/embed/stream.ts —— v2 垂直照片流纯函数集(UI-04 §2.3/§3.2)。
|
||||||
|
// 高度比例防御、环形队列窗口数学、锚定计算全部收敛为纯函数(vitest 直接覆盖);
|
||||||
|
// 组件/编排层(EmbedStream.vue / useVerticalPhotoStream.ts)只做装配。
|
||||||
|
|
||||||
|
/** 元数据异常时的高度比例回退(4:3,§2.3)。 */
|
||||||
|
export const FALLBACK_RATIO = 4 / 3;
|
||||||
|
|
||||||
|
/** 超长图安全钳(高度/宽度上限;防无穷高度破坏循环,§2.3)。 */
|
||||||
|
export const MAX_RATIO = 8;
|
||||||
|
|
||||||
|
/** 照片缝宽度(px,§2.2 硬性 3px)。 */
|
||||||
|
export const PHOTO_GAP_PX = 3;
|
||||||
|
|
||||||
|
export interface StreamPhotoSize {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 元数据 → 高度比例(height/width);宽高须 >0 且有限,否则回退 4:3。 */
|
||||||
|
export function photoRatio(size: StreamPhotoSize): number {
|
||||||
|
if (
|
||||||
|
Number.isFinite(size.width) &&
|
||||||
|
Number.isFinite(size.height) &&
|
||||||
|
size.width > 0 &&
|
||||||
|
size.height > 0
|
||||||
|
) {
|
||||||
|
return Math.min(size.height / size.width, MAX_RATIO);
|
||||||
|
}
|
||||||
|
return FALLBACK_RATIO;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 容器内宽 → 照片展示高度(按比例;内宽 = 窗宽 - 边框)。 */
|
||||||
|
export function photoDisplayHeight(size: StreamPhotoSize, innerWidth: number): number {
|
||||||
|
return photoRatio(size) * innerWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 环形队列窗口(§3.2 步骤 1):给定队列、窗口高度与预载余量,计算从
|
||||||
|
* startIndex 起需要装载的条目数(高度和 ≥ 窗口高 + 上下余量)。返回条目
|
||||||
|
* 数量(至少 1,队列空返回 0)。纯计算——实际 DOM 复用由编排层负责。
|
||||||
|
*/
|
||||||
|
export function windowCount(
|
||||||
|
sizes: StreamPhotoSize[],
|
||||||
|
startIndex: number,
|
||||||
|
innerWidth: number,
|
||||||
|
viewportHeight: number,
|
||||||
|
bufferPx: number,
|
||||||
|
): number {
|
||||||
|
if (sizes.length === 0) return 0;
|
||||||
|
const target = viewportHeight + bufferPx;
|
||||||
|
let acc = 0;
|
||||||
|
let count = 0;
|
||||||
|
for (let i = 0; i < sizes.length; i++) {
|
||||||
|
const item = sizes[(startIndex + i) % sizes.length];
|
||||||
|
acc += photoDisplayHeight(item, innerWidth) + PHOTO_GAP_PX;
|
||||||
|
count += 1;
|
||||||
|
if (acc >= target) return count;
|
||||||
|
}
|
||||||
|
// 一轮高度不足以覆盖窗口(极矮照片):返回整队(§3.2.7 少量可见循环
|
||||||
|
// 副本由编排层处理,本函数不放大队列)。
|
||||||
|
return count;
|
||||||
|
}
|
||||||
@@ -33,6 +33,9 @@ export default {
|
|||||||
play: 'Play carousel',
|
play: 'Play carousel',
|
||||||
prev: 'Previous photo',
|
prev: 'Previous photo',
|
||||||
next: 'Next photo',
|
next: 'Next photo',
|
||||||
|
// UI-04 v2 vertical photo stream window
|
||||||
|
streamLabel: 'Event photo window',
|
||||||
|
collapseWindow: 'Collapse photo window',
|
||||||
},
|
},
|
||||||
download: {
|
download: {
|
||||||
action: 'Download photo',
|
action: 'Download photo',
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ export default {
|
|||||||
play: '播放轮播',
|
play: '播放轮播',
|
||||||
prev: '上一张',
|
prev: '上一张',
|
||||||
next: '下一张',
|
next: '下一张',
|
||||||
|
// —— UI-04 v2 垂直照片流窗 ——
|
||||||
|
streamLabel: '活动照片窗',
|
||||||
|
collapseWindow: '收起照片窗',
|
||||||
},
|
},
|
||||||
download: {
|
download: {
|
||||||
action: '下载图片',
|
action: '下载图片',
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// apps/viewer/src/pages/EmbedPage.vue —— 嵌入轮播 iframe 页薄壳(路由 /embed/:slotToken
|
// apps/viewer/src/pages/EmbedPage.vue —— 嵌入 iframe 页薄壳(路由 /embed/:slotToken
|
||||||
// + 数据/lifecycle 编排;P4b Task 4 创建,UI-01 Task 6 拆分)。独立精简布局:
|
// + 数据/lifecycle 编排;P4b Task 4 创建,UI-01 Task 6 拆分,UI-04 增 v2 分支)。
|
||||||
// 无顶栏/页脚,App.vue 按 meta.layout 裸渲染。宿主 loader 以 iframe 载入本页
|
// 独立精简布局:无顶栏/页脚,App.vue 按 meta.layout 裸渲染。宿主 loader 以 iframe
|
||||||
// (280-320px 小视口设计):
|
// 载入本页:
|
||||||
// · 数据/轮询/暂停/收起/destroy 编排见 ../embed/useEmbedFeed.ts;
|
// · 数据/轮询/暂停/收起/destroy 编排见 ../embed/useEmbedFeed.ts(v1/v2 共用);
|
||||||
// · 切换逻辑/舞台/底栏见 ./embed/(EmbedCarousel → EmbedStage + EmbedFooterBar);
|
// · v=2(UI-04):./embed/EmbedStream.vue 垂直照片流窗(无 header/footer,
|
||||||
// · 头部:通用「活动照片」标题 + 收起 ×(全状态可用,含空态——§5.3 断网
|
// 右上收起,195×280 外窗由 loader v2 负责);speed/reactions 查询为宿主
|
||||||
// 无缓存可收起;有效点击区 ≥44×44);
|
// data-* 显式覆盖,后台 settings.scrollSpeed/reactionsEnabled 次之;
|
||||||
// · 空相册 → 「照片即将上线」小卡;槽 404 → destroy 后无 UI(feed.status gone);
|
// · v1(缺省):./embed/(EmbedCarousel → EmbedStage + EmbedFooterBar)+
|
||||||
|
// 通用「活动照片」标题头 + 收起 ×;
|
||||||
|
// · 空相册 → 「照片即将上线」;槽 404 → destroy 后无 UI(feed.status gone);
|
||||||
// · 语言独立于宿主与 localStorage:lang 查询(auto→相册 defaultLocale);
|
// · 语言独立于宿主与 localStorage:lang 查询(auto→相册 defaultLocale);
|
||||||
// · interval/fit 查询为宿主 data-* 显式覆盖(loader 仅显式透传);后台设置
|
// · interval/fit 查询为宿主 data-* 显式覆盖(loader 仅显式透传);后台设置
|
||||||
// 经响应 settings 生效(显式 > 后台 > 默认,§5.4)。
|
// 经响应 settings 生效(显式 > 后台 > 默认,§5.4)。
|
||||||
@@ -17,6 +19,7 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import { useEmbedFeed } from '../embed/useEmbedFeed';
|
import { useEmbedFeed } from '../embed/useEmbedFeed';
|
||||||
import { clampEmbedIntervalMs, EMBED_CAROUSEL_DEFAULT_MS } from '../embed/feed';
|
import { clampEmbedIntervalMs, EMBED_CAROUSEL_DEFAULT_MS } from '../embed/feed';
|
||||||
import EmbedCarousel from './embed/EmbedCarousel.vue';
|
import EmbedCarousel from './embed/EmbedCarousel.vue';
|
||||||
|
import EmbedStream from './embed/EmbedStream.vue';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
@@ -31,6 +34,11 @@ const langParam = queryText(route.query.lang) ?? 'auto';
|
|||||||
const intervalText = queryText(route.query.interval);
|
const intervalText = queryText(route.query.interval);
|
||||||
const intervalParamMs = intervalText === null ? null : Number.parseInt(intervalText, 10);
|
const intervalParamMs = intervalText === null ? null : Number.parseInt(intervalText, 10);
|
||||||
const fitParam = queryText(route.query.fit);
|
const fitParam = queryText(route.query.fit);
|
||||||
|
// UI-04 v2:loader v2 显式透传的速度/气泡开关(缺省不拼——后台设置生效)。
|
||||||
|
const speedText = queryText(route.query.speed);
|
||||||
|
const speedParam = speedText === null ? null : Number.parseInt(speedText, 10);
|
||||||
|
const reactionsOff = queryText(route.query.reactions) === 'off';
|
||||||
|
const isV2 = queryText(route.query.v) === '2';
|
||||||
// dev/e2e 轮询加速(跨源宿主可用;生产构建忽略——loader 侧同样仅显式透传)。
|
// dev/e2e 轮询加速(跨源宿主可用;生产构建忽略——loader 侧同样仅显式透传)。
|
||||||
const pollText = queryText(route.query.poll);
|
const pollText = queryText(route.query.poll);
|
||||||
const pollParamMs =
|
const pollParamMs =
|
||||||
@@ -85,6 +93,22 @@ const imageFit = computed<'contain' | 'cover'>(() => {
|
|||||||
return feed.settings?.imageFit ?? 'contain';
|
return feed.settings?.imageFit ?? 'contain';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// UI-04 v2 上滚速度(CSS px/s):查询显式(夹取 8..32)→ 后台设置 → 缺省 16。
|
||||||
|
const streamSpeed = computed(() => {
|
||||||
|
if (speedParam !== null && Number.isFinite(speedParam)) {
|
||||||
|
return Math.min(32, Math.max(8, Math.round(speedParam)));
|
||||||
|
}
|
||||||
|
const s = feed.settings?.scrollSpeed;
|
||||||
|
return typeof s === 'number' && Number.isFinite(s) ? Math.min(32, Math.max(8, Math.round(s))) : 16;
|
||||||
|
});
|
||||||
|
|
||||||
|
// UI-04 v2 气泡开关:查询显式 off → false;否则后台设置 → 缺省 true。
|
||||||
|
const streamReactions = computed(() => {
|
||||||
|
if (reactionsOff) return false;
|
||||||
|
const s = feed.settings?.reactionsEnabled;
|
||||||
|
return typeof s === 'boolean' ? s : true;
|
||||||
|
});
|
||||||
|
|
||||||
const albumUrl = computed(() => {
|
const albumUrl = computed(() => {
|
||||||
const albumToken = feed.album?.publicToken;
|
const albumToken = feed.album?.publicToken;
|
||||||
if (albumToken === undefined) return null;
|
if (albumToken === undefined) return null;
|
||||||
@@ -93,6 +117,15 @@ const albumUrl = computed(() => {
|
|||||||
return url.toString();
|
return url.toString();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** v2 照片深链构造器(真实 <a> href;受控打开同 openPhoto)。 */
|
||||||
|
function streamPhotoUrl(photoId: string): string {
|
||||||
|
const albumToken = feed.album?.publicToken;
|
||||||
|
const base = albumToken ?? '';
|
||||||
|
const url = new URL(`/a/${encodeURIComponent(base)}/p/${encodeURIComponent(photoId)}`, window.location.origin);
|
||||||
|
url.searchParams.set('lang', feed.resolvedLocale ?? 'zh-CN');
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
function openPhoto(photoId: string): void {
|
function openPhoto(photoId: string): void {
|
||||||
const albumToken = feed.album?.publicToken;
|
const albumToken = feed.album?.publicToken;
|
||||||
if (albumToken === undefined) return;
|
if (albumToken === undefined) return;
|
||||||
@@ -104,7 +137,21 @@ function openPhoto(photoId: string): void {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="feed.status !== 'gone'" ref="rootEl" class="embed-root" :class="{ 'is-empty': feed.status !== 'ready' }">
|
<!-- v2(UI-04):无 header/footer 的垂直照片流窗(外壳尺寸/圆角/边框由宿主
|
||||||
|
loader v2 wrapper 负责;本页铺满窗口)。 -->
|
||||||
|
<div v-if="isV2 && feed.status !== 'gone'" ref="rootEl" class="embed-root-v2">
|
||||||
|
<EmbedStream
|
||||||
|
:photos="feed.status === 'ready' ? feed.photos : []"
|
||||||
|
:photo-url="streamPhotoUrl"
|
||||||
|
:speed="streamSpeed"
|
||||||
|
:reactions="streamReactions"
|
||||||
|
:active="feed.active"
|
||||||
|
:reduced-motion="reducedMotion"
|
||||||
|
@open-photo="openPhoto"
|
||||||
|
@collapse="feed.requestCollapse()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="feed.status !== 'gone'" ref="rootEl" class="embed-root" :class="{ 'is-empty': feed.status !== 'ready' }">
|
||||||
<div class="embed-head">
|
<div class="embed-head">
|
||||||
<span class="embed-heading">{{ t('embed.widgetTitle') }}</span>
|
<span class="embed-heading">{{ t('embed.widgetTitle') }}</span>
|
||||||
<button type="button" class="embed-close" :aria-label="t('embed.close')" @click="feed.requestCollapse()">
|
<button type="button" class="embed-close" :aria-label="t('embed.close')" @click="feed.requestCollapse()">
|
||||||
@@ -132,6 +179,12 @@ function openPhoto(photoId: string): void {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
/* v2 根:铺满宿主 195×280 外窗。iframe 内 html/body 无高度链(v1 为内容高
|
||||||
|
* 设计),fixed+inset0 直接锚定 iframe 视口——不引入影响 v1 的全局样式。 */
|
||||||
|
.embed-root-v2 {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
/* iframe 内容适配宿主卡片:html/body 无背景(App.vue 基线未设背景,天然透亮,
|
/* iframe 内容适配宿主卡片:html/body 无背景(App.vue 基线未设背景,天然透亮,
|
||||||
* 圆角/阴影由宿主 loader wrapper 提供),根元素白底小卡 280-320px 设计。 */
|
* 圆角/阴影由宿主 loader wrapper 提供),根元素白底小卡 280-320px 设计。 */
|
||||||
.embed-root {
|
.embed-root {
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
<!-- apps/viewer/src/pages/embed/EmbedStream.vue —— UI-04 v2 垂直照片流窗
|
||||||
|
(EmbedPage 于 v=2 查询时渲染)。结构对齐方案 §2.2:
|
||||||
|
PhotoStreamViewport(relative/overflow hidden/手动模式滚动)
|
||||||
|
Track(垂直照片实例带,translateY 驱动——E03 接线)
|
||||||
|
PhotoLink > img(原比例、宽满、block;真实 <a> 指向观众深链 §4.1)
|
||||||
|
HeartParticleLayer(E05:真实 likes.burst 粒子,pointer-events none)
|
||||||
|
CollapseButton(右上收起,§2.4 规格)
|
||||||
|
StateOverlay(loading/empty/error 按需)
|
||||||
|
无 header/footer/页码/箭头/圆点(§2.2 硬性)。图片高度按元数据比例预留
|
||||||
|
(异常宽高回退 4:3 占位,§2.3);间距 3px;外窗(loader wrapper)负责整体
|
||||||
|
圆角与边框,内部不再加圆角。 -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import type { PublicEmbedPhoto } from '../../api/client';
|
||||||
|
import { photoRatio } from '../../embed/stream';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
photos: PublicEmbedPhoto[];
|
||||||
|
/** 观众深链构造器(真实 <a> href;点击经锁定目标受控打开,§4.1)。 */
|
||||||
|
photoUrl: (photoId: string) => string;
|
||||||
|
/** 自动上滚速度(CSS px/s;reducedMotion/暂停时不动,E03 接线 rAF)。 */
|
||||||
|
speed: number;
|
||||||
|
/** 点赞气泡开关(E05 接线;false 时不建粒子层)。 */
|
||||||
|
reactions: boolean;
|
||||||
|
/** 组件活动闸门(收起/隐藏/不可见/gone → false,停滚动与轮询)。 */
|
||||||
|
active: boolean;
|
||||||
|
reducedMotion: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'open-photo', photoId: string): void;
|
||||||
|
(e: 'collapse'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const ratios = computed(() => props.photos.map((p) => photoRatio(p)));
|
||||||
|
|
||||||
|
// ── 点击语义(§4.1;拖动取消/目标锁定在 E04 随滚动接线补全)──
|
||||||
|
function onPhotoClick(photo: PublicEmbedPhoto): void {
|
||||||
|
if (!props.active) return;
|
||||||
|
emit('open-photo', photo.photoId);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="stream-viewport" role="region" :aria-label="t('embed.streamLabel')">
|
||||||
|
<div class="stream-track">
|
||||||
|
<a
|
||||||
|
v-for="(photo, i) in photos"
|
||||||
|
:key="photo.photoId"
|
||||||
|
class="stream-photo"
|
||||||
|
:href="photoUrl(photo.photoId)"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
:aria-label="t('embed.photoAlt', { n: i + 1 })"
|
||||||
|
@click.prevent="onPhotoClick(photo)"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
class="stream-photo__img"
|
||||||
|
:src="photo.thumb.url"
|
||||||
|
:alt="t('embed.photoAlt', { n: i + 1 })"
|
||||||
|
:style="{ aspectRatio: `1 / ${ratios[i]}` }"
|
||||||
|
decoding="async"
|
||||||
|
loading="eager"
|
||||||
|
>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<!-- StateOverlay(§2.2):空相册紧凑空态(收起入口保持可用)。 -->
|
||||||
|
<div v-if="photos.length === 0" class="stream-empty" role="note">
|
||||||
|
{{ t('embed.comingSoon') }}
|
||||||
|
</div>
|
||||||
|
<!-- E05:HeartParticleLayer(reactions=true 时挂载) -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="stream-collapse"
|
||||||
|
:aria-label="t('embed.collapseWindow')"
|
||||||
|
@click="emit('collapse')"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 12 12" width="12" height="12" aria-hidden="true" focusable="false">
|
||||||
|
<path fill="currentColor" d="M2 5.25h8v1.5H2z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* iframe 内容铺满 loader wrapper 的 195×280 窗(圆角/边框/阴影由宿主负责);
|
||||||
|
* 深底色与宿主外壳一致,加载/缺口不露宿主内容(§2.1)。 */
|
||||||
|
.stream-viewport {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #10161e;
|
||||||
|
}
|
||||||
|
.stream-track {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
/* 3px 照片缝(§2.2) */
|
||||||
|
gap: 3px;
|
||||||
|
width: 100%;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
.stream-photo {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-decoration: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
.stream-photo__img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
background: #f6f8fb;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
/* 收起入口(§2.4):可视圆 24×24 右上内缩 6px;有效触区 44×44 锚定右上
|
||||||
|
* (透明扩展),不与照片链接重叠触发。 */
|
||||||
|
.stream-collapse {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.stream-collapse::before {
|
||||||
|
content: '';
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
margin: 6px 6px 0 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.82);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.24);
|
||||||
|
}
|
||||||
|
.stream-collapse svg {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
color: #647084;
|
||||||
|
}
|
||||||
|
.stream-collapse:focus-visible {
|
||||||
|
outline: 2px solid #ffffff;
|
||||||
|
outline-offset: -2px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
.stream-empty {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font: 500 0.75rem/1.4 system-ui, -apple-system, 'Segoe UI', 'PingFang SC', sans-serif;
|
||||||
|
color: rgba(255, 255, 255, 0.72);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -41,12 +41,26 @@
|
|||||||
|
|
||||||
## 2 版本与兼容策略(E01 定案登记)
|
## 2 版本与兼容策略(E01 定案登记)
|
||||||
|
|
||||||
(E01 完成后填写:v2 入口形态、路由/参数、新字段清单、v1 保留验证。)
|
- **入口形态**:新发布物 `/embed/v2/loader.js`(源 src/loader-v2.js,v1 源 loader.js 冻结
|
||||||
|
零改动,双产物同 build/sync 脚本);iframe 复用同一路由 `/embed/:slotToken` + 白名单
|
||||||
|
版本查询 `v=2`(v1 loader 不拼 v → 旧 carousel 分支,旧 v1 接入零变化)。
|
||||||
|
- **契约扩展**(全部可选、响应恒归一化全集):EmbedSlotSettings += `displayMode`
|
||||||
|
(carousel|vertical-stream,默认 carousel)、`scrollSpeed`(8..32 CSS px/s,默认 16)、
|
||||||
|
`reactionsEnabled`(默认 true);事件载荷 SlotSettingsPayload 同步可选字段。
|
||||||
|
- **缺省语义**:契约/服务端缺省 displayMode=carousel(存量记录读取归一为旧模式,
|
||||||
|
不一夜切换);管理端新建默认 vertical-stream 由表单显式提交(§8.2)。
|
||||||
|
- **v2 尺寸硬性**:loader 侧 V2_WIDTH/V2_HEIGHT=195/280 常量;size/intervalMs/imageFit
|
||||||
|
不进 v2 配置面(data-* 不解析、snippet 不生成、iframe 布局忽略);resize 消息 v2 忽略。
|
||||||
|
- **E01 证据**:v2 跨源宿主桌面/手机双端 getBoundingClientRect 实测 195×280(e2e
|
||||||
|
embed-v2.spec.ts E01 出口门 2 用例);ov 公开端点响应含新字段全集(实测 200);
|
||||||
|
v1 回归 20 e2e 全绿(EMB-A14 部分证据);loader 36 测试(+9 v2 参数面)。
|
||||||
|
|
||||||
## 3 任务进度
|
## 3 任务进度
|
||||||
|
|
||||||
- [x] E00 基线(本记录第 1 节)
|
- [x] E00 基线(本记录第 1 节)
|
||||||
- [ ] E01 契约和版本骨架
|
- [x] E01 契约和版本骨架(第 2 节;改动面:contracts openapi+events+gen、api dto/
|
||||||
|
embed.service(cn 管理与 ov 投影共用归一化)、embed-loader params+loader-v2+build/sync、
|
||||||
|
viewer EmbedPage v=2 分支+EmbedStream+stream.ts+i18n、admin 类型+utils+snippet v1/v2 版本选择)
|
||||||
- [ ] E02 静态高保真样板
|
- [ ] E02 静态高保真样板
|
||||||
- [ ] E03 连续滚动
|
- [ ] E03 连续滚动
|
||||||
- [ ] E04 操作和生命周期
|
- [ ] E04 操作和生命周期
|
||||||
|
|||||||
+3
-2
@@ -19,8 +19,9 @@ module.exports = tseslint.config(
|
|||||||
// 生成产物不参与 lint(契约生成类型已提交入库,改动随 `pnpm gen` 审查)
|
// 生成产物不参与 lint(契约生成类型已提交入库,改动随 `pnpm gen` 审查)
|
||||||
'**/generated/',
|
'**/generated/',
|
||||||
// embed-loader 发布产物拷贝(viewer publicDir 携带,源在 packages/embed-loader
|
// embed-loader 发布产物拷贝(viewer publicDir 携带,源在 packages/embed-loader
|
||||||
// ——esbuild minify 输出不适用 lint;P4b Task 3 起 viewer lint 曾因此红)
|
// ——esbuild minify 输出不适用 lint;P4b Task 3 起 viewer lint 曾因此红;
|
||||||
'apps/viewer/public/embed/v1/',
|
// UI-04 起 v1/v2 双产物同规则忽略)
|
||||||
|
'apps/viewer/public/embed/',
|
||||||
// Playwright 运行产物
|
// Playwright 运行产物
|
||||||
'**/playwright-report/',
|
'**/playwright-report/',
|
||||||
'**/test-results/',
|
'**/test-results/',
|
||||||
|
|||||||
@@ -4123,6 +4123,31 @@ components:
|
|||||||
maximum: 2147483000
|
maximum: 2147483000
|
||||||
description: 组件在宿主页面的层级(UI-01)。可缺省。
|
description: 组件在宿主页面的层级(UI-01)。可缺省。
|
||||||
default: 1000
|
default: 1000
|
||||||
|
displayMode:
|
||||||
|
type: string
|
||||||
|
description: |
|
||||||
|
展示模式(UI-04):carousel=旧版轮播(v1 loader,footer/舞台结构);
|
||||||
|
vertical-stream=新版垂直照片流(v2 loader,固定 195×280 连续上滚照片窗,
|
||||||
|
忽略 size/intervalMs/imageFit 的布局作用)。可缺省(默认 carousel——
|
||||||
|
UI-04 前旧记录读取归一为旧模式;新建走 v2 由管理端显式提交)。
|
||||||
|
enum:
|
||||||
|
- carousel
|
||||||
|
- vertical-stream
|
||||||
|
default: carousel
|
||||||
|
scrollSpeed:
|
||||||
|
type: integer
|
||||||
|
minimum: 8
|
||||||
|
maximum: 32
|
||||||
|
description: |
|
||||||
|
vertical-stream 自动上滚速度(CSS px/秒,UI-04)。可缺省(默认 16);
|
||||||
|
仅 v2 生效,carousel 模式忽略。
|
||||||
|
default: 16
|
||||||
|
reactionsEnabled:
|
||||||
|
type: boolean
|
||||||
|
description: |
|
||||||
|
vertical-stream 点赞心形气泡开关(UI-04)。可缺省(默认 true);
|
||||||
|
false 仅隐藏气泡(不产生 WS 订阅),照片滚动不受影响。仅 v2 生效。
|
||||||
|
default: true
|
||||||
|
|
||||||
EmbedSlot:
|
EmbedSlot:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@@ -157,6 +157,9 @@ export interface SlotSettingsPayload {
|
|||||||
offsetX?: number; // 0..120(默认 16)
|
offsetX?: number; // 0..120(默认 16)
|
||||||
offsetBottom?: number; // 0..240(默认 24)
|
offsetBottom?: number; // 0..240(默认 24)
|
||||||
zIndex?: number; // 1..2147483000(默认 1000)
|
zIndex?: number; // 1..2147483000(默认 1000)
|
||||||
|
displayMode?: string; // carousel|vertical-stream(默认 carousel,UI-04)
|
||||||
|
scrollSpeed?: number; // 8..32 CSS px/s(默认 16,UI-04;仅 v2 生效)
|
||||||
|
reactionsEnabled?: boolean; // 默认 true(UI-04;false 仅隐藏气泡)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 运行时校验用:全部合法事件类型字符串。satisfies 保证 数组 ⊆ 接口 union;
|
// 运行时校验用:全部合法事件类型字符串。satisfies 保证 数组 ⊆ 接口 union;
|
||||||
|
|||||||
+21
@@ -2123,6 +2123,27 @@ export interface components {
|
|||||||
* @default 1000
|
* @default 1000
|
||||||
*/
|
*/
|
||||||
zIndex: number;
|
zIndex: number;
|
||||||
|
/**
|
||||||
|
* @description 展示模式(UI-04):carousel=旧版轮播(v1 loader,footer/舞台结构);
|
||||||
|
* vertical-stream=新版垂直照片流(v2 loader,固定 195×280 连续上滚照片窗,
|
||||||
|
* 忽略 size/intervalMs/imageFit 的布局作用)。可缺省(默认 carousel——
|
||||||
|
* UI-04 前旧记录读取归一为旧模式;新建走 v2 由管理端显式提交)。
|
||||||
|
* @default carousel
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
displayMode: "carousel" | "vertical-stream";
|
||||||
|
/**
|
||||||
|
* @description vertical-stream 自动上滚速度(CSS px/秒,UI-04)。可缺省(默认 16);
|
||||||
|
* 仅 v2 生效,carousel 模式忽略。
|
||||||
|
* @default 16
|
||||||
|
*/
|
||||||
|
scrollSpeed: number;
|
||||||
|
/**
|
||||||
|
* @description vertical-stream 点赞心形气泡开关(UI-04)。可缺省(默认 true);
|
||||||
|
* false 仅隐藏气泡(不产生 WS 订阅),照片滚动不受影响。仅 v2 生效。
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
reactionsEnabled: boolean;
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* @description 展示位条目(管理视角)。albumId/albumTitle 为当前绑定相册(null=未
|
* @description 展示位条目(管理视角)。albumId/albumTitle 为当前绑定相册(null=未
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -3,10 +3,10 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "PhotoLive embeddable widget loader (versioned artifact /embed/v1/loader.js served by viewer static; handwritten zero-dependency JS, bundled+minified via esbuild)",
|
"description": "PhotoLive embeddable widget loader (versioned artifacts /embed/v1 + /embed/v2 served by viewer static; handwritten zero-dependency JS, bundled+minified via esbuild)",
|
||||||
"//": "发布物即 dist/v1/loader.js 文件本身(viewer publicDir 携带,不走 npm)——故 private。src/params.js 是 loader 与单测共用的唯一参数源,esbuild 打包时内联。无 TS:typecheck 任务不存在即被 turbo 干净跳过,lint 由根 flat config 覆盖 .js。",
|
"//": "发布物即 dist/{v1,v2}/loader.js 文件本身(viewer publicDir 携带,不走 npm)——故 private。src/params.js 是 loader 与单测共用的唯一参数源,esbuild 打包时内联。v1(carousel)源码冻结不动;v2(UI-04 vertical-stream)独立入口 loader-v2.js。无 TS:typecheck 任务不存在即被 turbo 干净跳过,lint 由根 flat config 覆盖 .js。",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "esbuild src/loader.js --bundle --minify --format=iife --target=es2018 --outfile=dist/v1/loader.js",
|
"build": "esbuild src/loader.js --bundle --minify --format=iife --target=es2018 --outfile=dist/v1/loader.js && esbuild src/loader-v2.js --bundle --minify --format=iife --target=es2018 --outfile=dist/v2/loader.js",
|
||||||
"sync": "node sync-to-viewer.js",
|
"sync": "node sync-to-viewer.js",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"lint": "eslint ."
|
"lint": "eslint ."
|
||||||
|
|||||||
@@ -0,0 +1,441 @@
|
|||||||
|
// packages/embed-loader/src/loader-v2.js
|
||||||
|
// PhotoLive 嵌入组件加载器 v2(UI-04 vertical-stream 照片流窗)——对外发布物
|
||||||
|
// (esbuild 打包为 dist/v2/loader.js,宿主以 <script src="{EMBED_ORIGIN}/
|
||||||
|
// embed/v2/loader.js" data-slot> 引入)。与 v1(src/loader.js)同约束:零依赖、
|
||||||
|
// 零全局(仅版本标记属性)、无 console、一切错误静默不抛(A11 不破坏宿主)。
|
||||||
|
//
|
||||||
|
// v2 与 v1 的宿主侧差异(方案 §2/§7/§8):
|
||||||
|
// · 窗口硬性固定 195×280 CSS px(border-box,含 1px 边框);PC/手机同值;
|
||||||
|
// 仅在视口不足时按 min(195,可用宽)/min(280,可用高) 收缩,绝不 transform
|
||||||
|
// 缩放;resize 消息不改高度(iframe 内容自适应窗口,窗口不迁就内容);
|
||||||
|
// · 外壳:圆角 12、边框 1px rgba(255,255,255,0.80)(单圈)、阴影
|
||||||
|
// 0 4px 16px rgba(15,23,42,0.16)、底色 #10161E(加载/缺口不露宿主内容);
|
||||||
|
// · 默认停靠:桌面 right 16 / 手机 right 12,bottom 24 + safe-area-inset;
|
||||||
|
// offsetX 仅在 data-* 与后台都未配置时按视口取默认;
|
||||||
|
// · 收起把手:白底胶囊 88–104×44、圆角 22、蓝图标 + 12px「照片/Photos」;
|
||||||
|
// · 多槽同侧纵排间距 12;可用高度不足时后续槽收起(空间约束≠用户选择);
|
||||||
|
// · 窄屏(<480px)默认收起沿用 v1 策略;展开后两端尺寸一致(195×280)。
|
||||||
|
// 收起按钮本体在 iframe 内(viewer EmbedStream 渲染,§2.4),经 collapse 消息
|
||||||
|
// 请求宿主收起——宿主与 iframe 各自清理,语义同 v1。
|
||||||
|
import {
|
||||||
|
parseEmbedConfigV2,
|
||||||
|
buildEmbedUrlV2,
|
||||||
|
mergeBackendConfigV2,
|
||||||
|
parseConfigMessageV2,
|
||||||
|
MESSAGE_TYPES,
|
||||||
|
V2_WIDTH,
|
||||||
|
V2_HEIGHT,
|
||||||
|
} from './params.js';
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
try {
|
||||||
|
var w = window;
|
||||||
|
// v2 独立标记:同页引入 v1+v2(迁移期)互不干扰。
|
||||||
|
if (w.__photoliveEmbedV2) return;
|
||||||
|
w.__photoliveEmbedV2 = '1';
|
||||||
|
|
||||||
|
var COLLAPSE_KEY = 'pl-embed-collapsed:'; // 与 v1 共用(同槽同会话记忆一致)
|
||||||
|
var STACK_GAP = 12; // §8.1 多槽同侧纵排间距
|
||||||
|
var HANDLE_MIN = 44; // 收起把手高/最小宽(§2.4)
|
||||||
|
// 收缩下限:低于此尺寸无法保证照片窗可用 → 改收起入口(§2.1「小到无法
|
||||||
|
// 操作时改成收起入口,而不是缩成不可点」)。
|
||||||
|
var MIN_OPERABLE_WIDTH = 150;
|
||||||
|
var MIN_OPERABLE_HEIGHT = 200;
|
||||||
|
|
||||||
|
function readCollapsed(slot) {
|
||||||
|
try {
|
||||||
|
return w.sessionStorage.getItem(COLLAPSE_KEY + slot) === '1';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function writeCollapsed(slot, collapsed) {
|
||||||
|
try {
|
||||||
|
if (collapsed) w.sessionStorage.setItem(COLLAPSE_KEY + slot, '1');
|
||||||
|
else w.sessionStorage.removeItem(COLLAPSE_KEY + slot);
|
||||||
|
} catch {
|
||||||
|
/* storage 异常:静默放弃记忆 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function hasChoice(slot) {
|
||||||
|
try {
|
||||||
|
return w.sessionStorage.getItem(COLLAPSE_KEY + slot) !== null;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selfScript() {
|
||||||
|
if (document.currentScript && document.currentScript.src) return document.currentScript;
|
||||||
|
var all = document.querySelectorAll('script[src*="/embed/v2/loader.js"]');
|
||||||
|
return all.length ? all[all.length - 1] : null;
|
||||||
|
}
|
||||||
|
function originOf(el) {
|
||||||
|
try {
|
||||||
|
return new URL(el.src, w.location.href).origin;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var instances = [];
|
||||||
|
var seenSlots = {};
|
||||||
|
var relayoutFrame = null;
|
||||||
|
|
||||||
|
function isZh(lang) {
|
||||||
|
if (lang === 'zh-CN') return true;
|
||||||
|
if (lang === 'auto') {
|
||||||
|
try {
|
||||||
|
return /^zh/i.test(w.navigator.language || '');
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收起把手(§2.4):白底胶囊、蓝色相机图标 + 照片/Photos 文案;全部
|
||||||
|
// inline style(零宿主 CSS 污染);有效点击区 ≥44×44。
|
||||||
|
function createHandle(inst) {
|
||||||
|
var h = document.createElement('div');
|
||||||
|
h.setAttribute('role', 'button');
|
||||||
|
h.setAttribute('tabindex', '0');
|
||||||
|
h.setAttribute('aria-label', isZh(inst.config.lang) ? '展开照片窗' : 'Show photo window');
|
||||||
|
var s = h.style;
|
||||||
|
s.position = 'fixed';
|
||||||
|
s.height = HANDLE_MIN + 'px';
|
||||||
|
s.minWidth = '88px';
|
||||||
|
s.boxSizing = 'border-box';
|
||||||
|
s.padding = '0 16px';
|
||||||
|
s.borderRadius = '22px';
|
||||||
|
s.background = '#ffffff';
|
||||||
|
s.boxShadow = '0 2px 10px rgba(15,23,42,0.18)';
|
||||||
|
s.zIndex = String(inst.config.zIndex);
|
||||||
|
s.cursor = 'pointer';
|
||||||
|
s.display = 'flex';
|
||||||
|
s.alignItems = 'center';
|
||||||
|
s.justifyContent = 'center';
|
||||||
|
s.gap = '6px';
|
||||||
|
s.font = '500 12px/1 system-ui, -apple-system, "Segoe UI", "PingFang SC", sans-serif';
|
||||||
|
s.color = '#1A73E8';
|
||||||
|
s.whiteSpace = 'nowrap';
|
||||||
|
h.innerHTML =
|
||||||
|
'<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">' +
|
||||||
|
'<path fill="#1A73E8" 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>' + (isZh(inst.config.lang) ? '照片' : 'Photos') + '</span>';
|
||||||
|
function activate(ev) {
|
||||||
|
if (ev.type === 'keydown') {
|
||||||
|
var k = ev.key;
|
||||||
|
if (k !== 'Enter' && k !== ' ' && k !== 'Spacebar') return;
|
||||||
|
ev.preventDefault();
|
||||||
|
}
|
||||||
|
inst.userChoice = true;
|
||||||
|
expandInstance(inst);
|
||||||
|
try {
|
||||||
|
if (inst.frame.contentWindow) {
|
||||||
|
inst.frame.contentWindow.postMessage(
|
||||||
|
{ photoliveEmbed: true, type: 'expand' },
|
||||||
|
inst.frameOrigin
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* 静默 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.addEventListener('click', activate);
|
||||||
|
h.addEventListener('keydown', activate);
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 展开态盒高(收缩后可能 < 280);收起态计把手 44(堆叠依据)。 */
|
||||||
|
function effectiveHeight(inst) {
|
||||||
|
return inst.collapsed ? HANDLE_MIN : inst.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 默认水平偏移:未配置时桌面 16 / 窄屏 12(§2.1)。data-* 显式或后台
|
||||||
|
* 配置到达后(offsetConfigured)按配置值。 */
|
||||||
|
function effectiveOffsetX(inst) {
|
||||||
|
if (inst.config.explicit.offsetX || inst.offsetConfigured) return inst.config.offsetX;
|
||||||
|
return inst.narrow ? 12 : 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** wrapper 几何:W=min(195,可用宽)、H=min(280,可用高);不足操作下限时
|
||||||
|
* 由调用方收起(空间约束)。 */
|
||||||
|
function measureBox(inst) {
|
||||||
|
var offsetX = effectiveOffsetX(inst);
|
||||||
|
var availW = w.innerWidth - offsetX - 8;
|
||||||
|
var availH = w.innerHeight - inst.config.offsetBottom - 8;
|
||||||
|
return {
|
||||||
|
offsetX: offsetX,
|
||||||
|
width: Math.max(120, Math.min(V2_WIDTH, availW)),
|
||||||
|
height: Math.max(160, Math.min(V2_HEIGHT, availH)),
|
||||||
|
operable: availW >= MIN_OPERABLE_WIDTH && availH >= MIN_OPERABLE_HEIGHT,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyWrapperBox(inst) {
|
||||||
|
var box = measureBox(inst);
|
||||||
|
inst.height = box.height;
|
||||||
|
var side = inst.config.position === 'left' ? 'left' : 'right';
|
||||||
|
var other = side === 'left' ? 'right' : 'left';
|
||||||
|
var ws = inst.wrapper.style;
|
||||||
|
if (!inst.collapsed) {
|
||||||
|
ws.display = 'block';
|
||||||
|
ws.width = box.width + 'px';
|
||||||
|
ws.height = box.height + 'px';
|
||||||
|
}
|
||||||
|
ws.zIndex = String(inst.config.zIndex);
|
||||||
|
ws[side] = box.offsetX + 'px';
|
||||||
|
ws[other] = '';
|
||||||
|
// 底部偏移 + 安全区(§2.1):不支持 env() 的旧浏览器整条声明失效回退
|
||||||
|
// 上一行设置的裸像素值(先写裸值再写 calc,双保险)。
|
||||||
|
ws.bottom = inst.config.offsetBottom + 'px';
|
||||||
|
ws.bottom =
|
||||||
|
'calc(' + inst.config.offsetBottom + 'px + env(safe-area-inset-bottom, 0px))';
|
||||||
|
if (inst.handle) {
|
||||||
|
var hs = inst.handle.style;
|
||||||
|
hs.zIndex = String(inst.config.zIndex);
|
||||||
|
hs[side] = box.offsetX + 'px';
|
||||||
|
hs[other] = '';
|
||||||
|
}
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同侧多槽纵排(§8.1):登记顺序自下而上,间距 12;可用高度不足时后续
|
||||||
|
// 槽收起(不写用户偏好)。
|
||||||
|
function relayout() {
|
||||||
|
var sides = { left: [], right: [] };
|
||||||
|
for (var i = 0; i < instances.length; i++) {
|
||||||
|
var inst = instances[i];
|
||||||
|
sides[inst.config.position === 'left' ? 'left' : 'right'].push(inst);
|
||||||
|
}
|
||||||
|
var keys = ['left', 'right'];
|
||||||
|
for (var s = 0; s < keys.length; s++) {
|
||||||
|
var list = sides[keys[s]];
|
||||||
|
var cursorBottom = 0;
|
||||||
|
for (var j = 0; j < list.length; j++) {
|
||||||
|
var it = list[j];
|
||||||
|
var bottom = j === 0 ? it.config.offsetBottom : cursorBottom + effectiveHeight(list[j - 1]) + STACK_GAP;
|
||||||
|
if (j > 0 && bottom + V2_HEIGHT > w.innerHeight) setCollapsed(it, false);
|
||||||
|
it.wrapper.style.bottom = bottom + 'px';
|
||||||
|
if (it.handle) it.handle.style.bottom = bottom + 'px';
|
||||||
|
cursorBottom = bottom;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleRelayout() {
|
||||||
|
if (relayoutFrame !== null || typeof w.requestAnimationFrame !== 'function') {
|
||||||
|
relayout();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
relayoutFrame = w.requestAnimationFrame(function () {
|
||||||
|
relayoutFrame = null;
|
||||||
|
relayout();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 收起核心(不触发重排调度——relayout 空间不足收起时避免再排)。 */
|
||||||
|
function setCollapsed(inst, persist) {
|
||||||
|
if (inst.collapsed) return;
|
||||||
|
inst.collapsed = true;
|
||||||
|
inst.wrapper.style.display = 'none';
|
||||||
|
if (!inst.handle) {
|
||||||
|
inst.handle = createHandle(inst);
|
||||||
|
document.body.appendChild(inst.handle);
|
||||||
|
}
|
||||||
|
inst.handle.style.display = 'flex';
|
||||||
|
if (persist !== false) writeCollapsed(inst.config.slot, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collapseInstance(inst, persist) {
|
||||||
|
setCollapsed(inst, persist);
|
||||||
|
scheduleRelayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandInstance(inst) {
|
||||||
|
if (!inst.collapsed) return;
|
||||||
|
inst.collapsed = false;
|
||||||
|
applyWrapperBox(inst);
|
||||||
|
if (inst.handle) inst.handle.style.display = 'none';
|
||||||
|
writeCollapsed(inst.config.slot, false);
|
||||||
|
scheduleRelayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 服务端终止展示(停用/解绑/404/410):移除照片窗与把手,不留空壳。 */
|
||||||
|
function destroyInstance(inst) {
|
||||||
|
if (inst.destroyed) return;
|
||||||
|
inst.destroyed = true;
|
||||||
|
if (inst.handle && inst.handle.parentNode) inst.handle.parentNode.removeChild(inst.handle);
|
||||||
|
if (inst.wrapper.parentNode) inst.wrapper.parentNode.removeChild(inst.wrapper);
|
||||||
|
try {
|
||||||
|
w.sessionStorage.removeItem(COLLAPSE_KEY + inst.config.slot);
|
||||||
|
} catch {
|
||||||
|
/* 静默 */
|
||||||
|
}
|
||||||
|
var i = instances.indexOf(inst);
|
||||||
|
if (i >= 0) instances.splice(i, 1);
|
||||||
|
delete seenSlots[inst.config.slot];
|
||||||
|
if (instances.length === 0) w.removeEventListener('message', onMessage);
|
||||||
|
scheduleRelayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 后台设置应用(config 消息已过 parseConfigMessageV2 版本闸):非 data-*
|
||||||
|
* 显式字段立即生效;collapsedDefault 仅在无用户选择时应用。 */
|
||||||
|
function applyConfig(inst, version, settings) {
|
||||||
|
inst.backendVersion = version;
|
||||||
|
inst.offsetConfigured = true; // 后台 settings 恒为全集 → offsetX 已「配置」
|
||||||
|
inst.config = mergeBackendConfigV2(inst.config, settings);
|
||||||
|
applyWrapperBox(inst);
|
||||||
|
if (!inst.userChoice && !hasChoice(inst.config.slot)) {
|
||||||
|
if (settings.collapsedDefault) collapseInstance(inst, false);
|
||||||
|
else if (!spaceConstrained(inst)) expandInstance(inst);
|
||||||
|
}
|
||||||
|
scheduleRelayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前视口是否空间不足(不足时默认收起而非塞下不可用的窗口)。 */
|
||||||
|
function spaceConstrained(inst) {
|
||||||
|
return !measureBox(inst).operable;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createInstance(origin, config) {
|
||||||
|
var wrapper = document.createElement('div');
|
||||||
|
var ws = wrapper.style;
|
||||||
|
ws.position = 'fixed';
|
||||||
|
ws.width = V2_WIDTH + 'px';
|
||||||
|
ws.height = V2_HEIGHT + 'px';
|
||||||
|
ws.boxSizing = 'border-box';
|
||||||
|
ws.borderRadius = '12px';
|
||||||
|
ws.border = '1px solid rgba(255,255,255,0.80)';
|
||||||
|
ws.boxShadow = '0 4px 16px rgba(15,23,42,0.16)';
|
||||||
|
ws.background = '#10161E';
|
||||||
|
ws.overflow = 'hidden';
|
||||||
|
|
||||||
|
var frame = document.createElement('iframe');
|
||||||
|
// sandbox 同 v1(含 allow-same-origin:iframe 保有项目域源,白名单按
|
||||||
|
// 源+source 双重严格校验;跨域隔离与链接新页安全不变)。
|
||||||
|
frame.setAttribute(
|
||||||
|
'sandbox',
|
||||||
|
'allow-scripts allow-popups allow-popups-to-escape-sandbox allow-same-origin'
|
||||||
|
);
|
||||||
|
frame.setAttribute('src', buildEmbedUrlV2(origin, config.slot, config));
|
||||||
|
frame.setAttribute('referrerpolicy', 'no-referrer-when-downgrade');
|
||||||
|
frame.setAttribute('title', 'PhotoLive photos');
|
||||||
|
var fs = frame.style;
|
||||||
|
fs.width = '100%';
|
||||||
|
fs.height = '100%';
|
||||||
|
fs.border = '0';
|
||||||
|
fs.display = 'block';
|
||||||
|
wrapper.appendChild(frame);
|
||||||
|
document.body.appendChild(wrapper);
|
||||||
|
|
||||||
|
var inst = {
|
||||||
|
config: config,
|
||||||
|
wrapper: wrapper,
|
||||||
|
frame: frame,
|
||||||
|
frameOrigin: originOf(frame) || origin,
|
||||||
|
height: V2_HEIGHT,
|
||||||
|
collapsed: false,
|
||||||
|
narrow: config.narrow,
|
||||||
|
handle: null,
|
||||||
|
userChoice: false,
|
||||||
|
backendVersion: 0,
|
||||||
|
offsetConfigured: config.explicit.offsetX,
|
||||||
|
destroyed: false,
|
||||||
|
};
|
||||||
|
instances.push(inst);
|
||||||
|
var box = applyWrapperBox(inst);
|
||||||
|
// 收起记忆 / 默认(窄屏或空间不足默认收起;用户选择优先,不反复弹出)。
|
||||||
|
if (hasChoice(config.slot)) {
|
||||||
|
if (readCollapsed(config.slot)) collapseInstance(inst, false);
|
||||||
|
} else if (inst.narrow || !box.operable) {
|
||||||
|
collapseInstance(inst, false);
|
||||||
|
}
|
||||||
|
return inst;
|
||||||
|
}
|
||||||
|
|
||||||
|
// postMessage 白名单:同 v1 双重严格校验(event.source 命中已登记 iframe
|
||||||
|
// 且 event.origin 严格等于该 iframe 源)。v2 忽略 resize(窗口固定)。
|
||||||
|
function onMessage(event) {
|
||||||
|
try {
|
||||||
|
var data = event.data;
|
||||||
|
if (!data || data.photoliveEmbed !== true) return;
|
||||||
|
var type = data.type;
|
||||||
|
if (MESSAGE_TYPES.indexOf(type) < 0) return;
|
||||||
|
for (var i = 0; i < instances.length; i++) {
|
||||||
|
var inst = instances[i];
|
||||||
|
if (!inst.frame.contentWindow || event.source !== inst.frame.contentWindow) continue;
|
||||||
|
if (event.origin !== inst.frameOrigin) return;
|
||||||
|
if (type === 'collapse') {
|
||||||
|
inst.userChoice = true;
|
||||||
|
collapseInstance(inst, true);
|
||||||
|
} else if (type === 'expand') {
|
||||||
|
inst.userChoice = true;
|
||||||
|
expandInstance(inst);
|
||||||
|
} else if (type === 'config') {
|
||||||
|
var parsed = parseConfigMessageV2(data, inst.backendVersion);
|
||||||
|
if (parsed !== null) applyConfig(inst, parsed.configVersion, parsed.settings);
|
||||||
|
} else if (type === 'destroy') {
|
||||||
|
destroyInstance(inst);
|
||||||
|
}
|
||||||
|
// 'resize':v2 窗口固定,显式忽略(§7.3)。
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* 静默 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNarrow() {
|
||||||
|
try {
|
||||||
|
return !!w.matchMedia && w.matchMedia('(max-width: 479px)').matches;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onViewportChange() {
|
||||||
|
var narrow = isNarrow();
|
||||||
|
for (var i = 0; i < instances.length; i++) {
|
||||||
|
var inst = instances[i];
|
||||||
|
if (inst.narrow !== narrow) {
|
||||||
|
inst.narrow = narrow;
|
||||||
|
if (narrow && !inst.userChoice && !hasChoice(inst.config.slot)) {
|
||||||
|
collapseInstance(inst, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
applyWrapperBox(inst);
|
||||||
|
}
|
||||||
|
scheduleRelayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
var self = selfScript();
|
||||||
|
if (!self) return;
|
||||||
|
var embedOrigin = originOf(self);
|
||||||
|
if (!embedOrigin) return;
|
||||||
|
var narrow = isNarrow();
|
||||||
|
var tags = document.querySelectorAll('script[src*="/embed/v2/loader.js"]');
|
||||||
|
for (var i = 0; i < tags.length; i++) {
|
||||||
|
var config = parseEmbedConfigV2(tags[i].dataset, { narrow: narrow });
|
||||||
|
if (!config.slot) continue;
|
||||||
|
if (seenSlots[config.slot]) continue;
|
||||||
|
seenSlots[config.slot] = true;
|
||||||
|
createInstance(embedOrigin, config);
|
||||||
|
}
|
||||||
|
w.addEventListener('message', onMessage);
|
||||||
|
w.addEventListener('resize', onViewportChange);
|
||||||
|
w.addEventListener('orientationchange', onViewportChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.body) {
|
||||||
|
init();
|
||||||
|
} else {
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* 静默:任何异常不得破坏宿主页 */
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -44,6 +44,20 @@ export const Z_INDEX_MAX = 2147483000;
|
|||||||
/** loader ↔ iframe postMessage 载荷 type 白名单(双向严格校验的取值集)。 */
|
/** loader ↔ iframe postMessage 载荷 type 白名单(双向严格校验的取值集)。 */
|
||||||
export const MESSAGE_TYPES = ['resize', 'collapse', 'expand', 'config', 'destroy'];
|
export const MESSAGE_TYPES = ['resize', 'collapse', 'expand', 'config', 'destroy'];
|
||||||
|
|
||||||
|
// ── UI-04 v2(vertical-stream 照片流窗)固定几何与参数域 ──
|
||||||
|
|
||||||
|
/** v2 固定窗尺寸(CSS px,方案 §2.1 硬性要求:PC/手机同值,含边框)。 */
|
||||||
|
export const V2_WIDTH = 195;
|
||||||
|
export const V2_HEIGHT = 280;
|
||||||
|
|
||||||
|
/** v2 自动上滚速度域(CSS px/秒)。 */
|
||||||
|
export const SPEED_MIN = 8;
|
||||||
|
export const SPEED_MAX = 32;
|
||||||
|
export const SPEED_DEFAULT = 16;
|
||||||
|
|
||||||
|
/** v2 展示模式取值(settings.displayMode;loader 侧仅接受白名单值)。 */
|
||||||
|
export const DISPLAY_MODES = ['carousel', 'vertical-stream'];
|
||||||
|
|
||||||
function oneOf(allowed, value, fallback) {
|
function oneOf(allowed, value, fallback) {
|
||||||
return allowed.indexOf(value) >= 0 ? value : fallback;
|
return allowed.indexOf(value) >= 0 ? value : fallback;
|
||||||
}
|
}
|
||||||
@@ -195,3 +209,122 @@ export function clampHeight(value) {
|
|||||||
if (isNaN(n) || !isFinite(n)) return null;
|
if (isNaN(n) || !isFinite(n)) return null;
|
||||||
return Math.min(HEIGHT_MAX, Math.max(HEIGHT_MIN, Math.round(n)));
|
return Math.min(HEIGHT_MAX, Math.max(HEIGHT_MIN, Math.round(n)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── UI-04 v2 参数解析(独立函数:v1 parseEmbedConfig 行为零改动)──
|
||||||
|
// v2 与 v1 共用 data-* 显式 > 后台设置 > 默认的优先级模型;差异:
|
||||||
|
// · 尺寸硬性固定 195×280(size/imageFit/intervalMs 不再影响布局,仅仍透传
|
||||||
|
// 给 iframe 侧的 v1 兼容字段——实际上 v2 iframe 忽略它们);
|
||||||
|
// · 新参数 data-speed(8..32)与 data-reactions(on|off);
|
||||||
|
// · 窄屏(<480px)仅默认收起,不降尺寸(§2.1:展开后两端同值)。
|
||||||
|
|
||||||
|
function clampSpeed(value) {
|
||||||
|
const n = Number(value);
|
||||||
|
if (isNaN(n) || !isFinite(n)) return SPEED_DEFAULT;
|
||||||
|
return Math.min(SPEED_MAX, Math.max(SPEED_MIN, Math.round(n)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v2 宿主 script data-* 配置解析(dataset 形如 {slot, lang, position, speed,
|
||||||
|
* reactions, offsetX, offsetBottom, zIndex, poll})。explicit 表语义同 v1:
|
||||||
|
* 只记 data-* 真正提供的字段,缺省字段允许后台设置生效。
|
||||||
|
*/
|
||||||
|
export function parseEmbedConfigV2(dataset, opts) {
|
||||||
|
const ds = dataset || {};
|
||||||
|
const slot = typeof ds.slot === 'string' ? ds.slot.replace(/^\s+|\s+$/g, '') : '';
|
||||||
|
const narrow = !!(opts && opts.narrow);
|
||||||
|
return {
|
||||||
|
version: 2,
|
||||||
|
slot: slot || null,
|
||||||
|
lang: provided(ds, 'lang') ? oneOf(LANG_VALUES, ds.lang, DEFAULTS.lang) : DEFAULTS.lang,
|
||||||
|
position: provided(ds, 'position')
|
||||||
|
? oneOf(POSITION_VALUES, ds.position, DEFAULTS.position)
|
||||||
|
: DEFAULTS.position,
|
||||||
|
speed: provided(ds, 'speed') ? clampSpeed(ds.speed) : SPEED_DEFAULT,
|
||||||
|
reactions: provided(ds, 'reactions')
|
||||||
|
? oneOf(['on', 'off'], ds.reactions, 'on') === 'on'
|
||||||
|
: true,
|
||||||
|
offsetX: provided(ds, 'offsetX') ? clampInt(ds.offsetX, OFFSET_X_MIN, OFFSET_X_MAX, DEFAULTS.offsetX) : DEFAULTS.offsetX,
|
||||||
|
offsetBottom: provided(ds, 'offsetBottom')
|
||||||
|
? clampInt(ds.offsetBottom, OFFSET_BOTTOM_MIN, OFFSET_BOTTOM_MAX, DEFAULTS.offsetBottom)
|
||||||
|
: DEFAULTS.offsetBottom,
|
||||||
|
zIndex: provided(ds, 'zIndex') ? clampInt(ds.zIndex, Z_INDEX_MIN, Z_INDEX_MAX, DEFAULTS.zIndex) : DEFAULTS.zIndex,
|
||||||
|
pollMs: provided(ds, 'poll') ? clampInt(ds.poll, 250, 60000, null) : null,
|
||||||
|
narrow: narrow,
|
||||||
|
explicit: {
|
||||||
|
lang: provided(ds, 'lang'),
|
||||||
|
position: provided(ds, 'position'),
|
||||||
|
speed: provided(ds, 'speed'),
|
||||||
|
reactions: provided(ds, 'reactions'),
|
||||||
|
offsetX: provided(ds, 'offsetX'),
|
||||||
|
offsetBottom: provided(ds, 'offsetBottom'),
|
||||||
|
zIndex: provided(ds, 'zIndex'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v2 后台设置归一(config 消息 / 公开端点 settings):在 v1 全集基础上裁决
|
||||||
|
* v2 字段(displayMode/scrollSpeed/reactionsEnabled);displayMode 仅接受
|
||||||
|
* vertical-stream 才对 v2 loader 有意义(carousel 值表示后台切回旧模式——
|
||||||
|
* v2 loader 不自毁,但忽略该字段;管理端切回旧模式应同时把站点 script 换回
|
||||||
|
* v1 入口,见 README-embed)。整体非对象 → null。
|
||||||
|
*/
|
||||||
|
export function normalizeBackendSettingsV2(value) {
|
||||||
|
if (!value || typeof value !== 'object') return null;
|
||||||
|
const base = normalizeBackendSettings(value);
|
||||||
|
if (base === null) return null;
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
displayMode: oneOf(DISPLAY_MODES, value.displayMode, 'carousel'),
|
||||||
|
scrollSpeed: clampSpeed(value.scrollSpeed),
|
||||||
|
reactionsEnabled: typeof value.reactionsEnabled === 'boolean' ? value.reactionsEnabled : true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** v2 后台设置合并(同 mergeBackendConfig 语义,作用于 v2 字段面)。 */
|
||||||
|
export function mergeBackendConfigV2(config, settings) {
|
||||||
|
const s = normalizeBackendSettingsV2(settings);
|
||||||
|
if (s === null) return config;
|
||||||
|
const e = config.explicit;
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
lang: e.lang ? config.lang : s.lang,
|
||||||
|
position: e.position ? config.position : s.position,
|
||||||
|
speed: e.speed ? config.speed : s.scrollSpeed,
|
||||||
|
reactions: e.reactions ? config.reactions : s.reactionsEnabled,
|
||||||
|
offsetX: e.offsetX ? config.offsetX : s.offsetX,
|
||||||
|
offsetBottom: e.offsetBottom ? config.offsetBottom : s.offsetBottom,
|
||||||
|
zIndex: e.zIndex ? config.zIndex : s.zIndex,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** v2 iframe config 消息裁决(同 parseConfigMessage,v2 settings 归一)。 */
|
||||||
|
export function parseConfigMessageV2(data, currentVersion) {
|
||||||
|
if (!data || typeof data !== 'object') return null;
|
||||||
|
const version = Number(data.configVersion);
|
||||||
|
if (isNaN(version) || !isFinite(version)) return null;
|
||||||
|
const local = currentVersion === null || currentVersion === undefined ? 0 : Number(currentVersion);
|
||||||
|
if (version <= local) return null;
|
||||||
|
const settings = normalizeBackendSettingsV2(data.settings);
|
||||||
|
if (settings === null) return null;
|
||||||
|
return { configVersion: Math.round(version), settings };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v2 iframe src:{origin}/embed/{slot}?v=2&lang=&pos=(槽 token 编码防注入)。
|
||||||
|
* speed/reactions 仅显式提供时拼接(缺省不覆盖后台设置);poll 同 v1。
|
||||||
|
*/
|
||||||
|
export function buildEmbedUrlV2(origin, slot, config) {
|
||||||
|
let url =
|
||||||
|
origin +
|
||||||
|
'/embed/' +
|
||||||
|
encodeURIComponent(slot) +
|
||||||
|
'?v=2&lang=' +
|
||||||
|
encodeURIComponent(config.lang) +
|
||||||
|
'&pos=' +
|
||||||
|
encodeURIComponent(config.position);
|
||||||
|
if (config.explicit.speed) url += '&speed=' + String(config.speed);
|
||||||
|
if (config.explicit.reactions && !config.reactions) url += '&reactions=off';
|
||||||
|
if (config.pollMs !== null && config.pollMs !== undefined) url += '&poll=' + String(config.pollMs);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,12 +6,17 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
MESSAGE_TYPES,
|
MESSAGE_TYPES,
|
||||||
buildEmbedUrl,
|
buildEmbedUrl,
|
||||||
|
buildEmbedUrlV2,
|
||||||
clampHeight,
|
clampHeight,
|
||||||
mergeBackendConfig,
|
mergeBackendConfig,
|
||||||
|
mergeBackendConfigV2,
|
||||||
normalizeBackendSettings,
|
normalizeBackendSettings,
|
||||||
|
normalizeBackendSettingsV2,
|
||||||
normalizeInterval,
|
normalizeInterval,
|
||||||
parseConfigMessage,
|
parseConfigMessage,
|
||||||
|
parseConfigMessageV2,
|
||||||
parseEmbedConfig,
|
parseEmbedConfig,
|
||||||
|
parseEmbedConfigV2,
|
||||||
SIZE_WIDTH,
|
SIZE_WIDTH,
|
||||||
} from './params.js';
|
} from './params.js';
|
||||||
|
|
||||||
@@ -308,3 +313,111 @@ describe('clampHeight', () => {
|
|||||||
expect(clampHeight(Infinity)).toBeNull();
|
expect(clampHeight(Infinity)).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── UI-04 v2(vertical-stream)参数面 ──
|
||||||
|
|
||||||
|
describe('parseEmbedConfigV2', () => {
|
||||||
|
it('空 dataset 全缺省:v2 字段默认 + explicit 全 false', () => {
|
||||||
|
const c = parseEmbedConfigV2({ slot: 'tok' }, {});
|
||||||
|
expect(c.version).toBe(2);
|
||||||
|
expect(c.slot).toBe('tok');
|
||||||
|
expect(c.lang).toBe('auto');
|
||||||
|
expect(c.position).toBe('right');
|
||||||
|
expect(c.speed).toBe(16);
|
||||||
|
expect(c.reactions).toBe(true);
|
||||||
|
expect(c.offsetX).toBe(16);
|
||||||
|
expect(c.offsetBottom).toBe(24);
|
||||||
|
expect(c.zIndex).toBe(1000);
|
||||||
|
expect(c.pollMs).toBeNull();
|
||||||
|
expect(c.explicit).toEqual({
|
||||||
|
lang: false,
|
||||||
|
position: false,
|
||||||
|
speed: false,
|
||||||
|
reactions: false,
|
||||||
|
offsetX: false,
|
||||||
|
offsetBottom: false,
|
||||||
|
zIndex: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('data-speed 夹取 8..32;非法回退 16', () => {
|
||||||
|
expect(parseEmbedConfigV2({ slot: 'a', speed: '1' }, {}).speed).toBe(8);
|
||||||
|
expect(parseEmbedConfigV2({ slot: 'a', speed: '24' }, {}).speed).toBe(24);
|
||||||
|
expect(parseEmbedConfigV2({ slot: 'a', speed: '99' }, {}).speed).toBe(32);
|
||||||
|
expect(parseEmbedConfigV2({ slot: 'a', speed: 'abc' }, {}).speed).toBe(16);
|
||||||
|
expect(parseEmbedConfigV2({ slot: 'a' }, {}).explicit.speed).toBe(false);
|
||||||
|
expect(parseEmbedConfigV2({ slot: 'a', speed: '24' }, {}).explicit.speed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('data-reactions 仅 on/off;其他回退 true', () => {
|
||||||
|
expect(parseEmbedConfigV2({ slot: 'a', reactions: 'off' }, {}).reactions).toBe(false);
|
||||||
|
expect(parseEmbedConfigV2({ slot: 'a', reactions: 'on' }, {}).reactions).toBe(true);
|
||||||
|
expect(parseEmbedConfigV2({ slot: 'a', reactions: 'yes' }, {}).reactions).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('v1 布局字段(size/interval/imageFit)不在 v2 配置面(195×280 硬性)', () => {
|
||||||
|
const c = parseEmbedConfigV2({ slot: 'a', size: 'md', interval: '8000', imageFit: 'cover' }, {});
|
||||||
|
expect(c.size).toBeUndefined();
|
||||||
|
expect(c.intervalMs).toBeUndefined();
|
||||||
|
expect(c.imageFit).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('normalizeBackendSettingsV2 / mergeBackendConfigV2', () => {
|
||||||
|
it('v2 字段归一:displayMode 白名单 / speed 夹取 / reactions 布尔回退', () => {
|
||||||
|
const s = normalizeBackendSettingsV2({
|
||||||
|
position: 'right', size: 'md', intervalMs: 5000, lang: 'auto', collapsedDefault: false,
|
||||||
|
imageFit: 'contain', offsetX: 16, offsetBottom: 24, zIndex: 1000,
|
||||||
|
displayMode: 'vertical-stream', scrollSpeed: 20, reactionsEnabled: false,
|
||||||
|
});
|
||||||
|
expect(s.displayMode).toBe('vertical-stream');
|
||||||
|
expect(s.scrollSpeed).toBe(20);
|
||||||
|
expect(s.reactionsEnabled).toBe(false);
|
||||||
|
expect(normalizeBackendSettingsV2({ displayMode: 'weird', scrollSpeed: 999, reactionsEnabled: 'x' }).displayMode).toBe('carousel');
|
||||||
|
expect(normalizeBackendSettingsV2({ scrollSpeed: 999 }).scrollSpeed).toBe(32);
|
||||||
|
expect(normalizeBackendSettingsV2({ reactionsEnabled: 'x' }).reactionsEnabled).toBe(true);
|
||||||
|
expect(normalizeBackendSettingsV2(null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('合并优先级:data-* 显式保持,缺省字段取后台值', () => {
|
||||||
|
const config = parseEmbedConfigV2({ slot: 'a', speed: '24' }, {});
|
||||||
|
const merged = mergeBackendConfigV2(config, {
|
||||||
|
position: 'left', size: 'md', intervalMs: 5000, lang: 'en', collapsedDefault: true,
|
||||||
|
imageFit: 'contain', offsetX: 40, offsetBottom: 80, zIndex: 2000,
|
||||||
|
displayMode: 'vertical-stream', scrollSpeed: 12, reactionsEnabled: false,
|
||||||
|
});
|
||||||
|
expect(merged.speed).toBe(24); // 显式 data-speed 胜出
|
||||||
|
expect(merged.reactions).toBe(false); // 后台(未显式)
|
||||||
|
expect(merged.position).toBe('left');
|
||||||
|
expect(merged.lang).toBe('en');
|
||||||
|
expect(merged.offsetX).toBe(40);
|
||||||
|
expect(merged.offsetBottom).toBe(80);
|
||||||
|
expect(merged.zIndex).toBe(2000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseConfigMessageV2 / buildEmbedUrlV2', () => {
|
||||||
|
it('config 消息:版本闸(低于等于忽略)+ 非法忽略', () => {
|
||||||
|
const settings = { position: 'right', size: 'md', intervalMs: 5000, lang: 'auto', collapsedDefault: false };
|
||||||
|
expect(parseConfigMessageV2({ photoliveEmbed: true, type: 'config', configVersion: 2, settings }, 1)).not.toBeNull();
|
||||||
|
expect(parseConfigMessageV2({ photoliveEmbed: true, type: 'config', configVersion: 1, settings }, 1)).toBeNull();
|
||||||
|
expect(parseConfigMessageV2({ photoliveEmbed: true, type: 'config', configVersion: 'x', settings }, 0)).toBeNull();
|
||||||
|
expect(parseConfigMessageV2({ photoliveEmbed: true, type: 'config', configVersion: 2, settings: null }, 0)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('URL:v=2 恒在;speed/reactions 仅显式拼接;poll 透传', () => {
|
||||||
|
const base = parseEmbedConfigV2({ slot: 'tok' }, {});
|
||||||
|
expect(buildEmbedUrlV2('https://e.test', 'tok', base)).toBe(
|
||||||
|
'https://e.test/embed/tok?v=2&lang=auto&pos=right',
|
||||||
|
);
|
||||||
|
const explicit = parseEmbedConfigV2({ slot: 'tok', speed: '20', reactions: 'off', poll: '500' }, {});
|
||||||
|
expect(buildEmbedUrlV2('https://e.test', 'tok', explicit)).toBe(
|
||||||
|
'https://e.test/embed/tok?v=2&lang=auto&pos=right&speed=20&reactions=off&poll=500',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('槽 token 编码防注入(路径段转义)', () => {
|
||||||
|
const c = parseEmbedConfigV2({ slot: 'a/b?c' }, {});
|
||||||
|
expect(buildEmbedUrlV2('https://e.test', 'a/b?c', c)).toContain('/embed/a%2Fb%3Fc?');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,31 +1,36 @@
|
|||||||
// packages/embed-loader/sync-to-viewer.js
|
// packages/embed-loader/sync-to-viewer.js
|
||||||
// 复制构建产物到 viewer 静态目录:vite publicDir(apps/viewer/public/)在 dev
|
// 复制构建产物到 viewer 静态目录:vite publicDir(apps/viewer/public/)在 dev
|
||||||
// 直接可访问、build 自动携带进 dist/。拷贝后 sha256 校验,不一致即失败(构建脚本
|
// 直接可访问、build 自动携带进 dist/。拷贝后 sha256 校验,不一致即失败(构建
|
||||||
// 级检查,非生产路径,允许 console)。用法:pnpm --filter @photo/embed-loader sync。
|
// 脚本级检查,非生产路径,允许 console)。用法:pnpm --filter @photo/embed-loader
|
||||||
|
// sync(先 build)。UI-04 起 v1/v2 双产物。
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { cpSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
import { cpSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
||||||
import { dirname, resolve } from 'node:path';
|
import { dirname, resolve } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
const here = dirname(fileURLToPath(import.meta.url));
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
const src = resolve(here, 'dist/v1/loader.js');
|
const targets = [
|
||||||
const dest = resolve(here, '../../apps/viewer/public/embed/v1/loader.js');
|
{ src: 'dist/v1/loader.js', dest: '../../apps/viewer/public/embed/v1/loader.js' },
|
||||||
|
{ src: 'dist/v2/loader.js', dest: '../../apps/viewer/public/embed/v2/loader.js' },
|
||||||
if (!existsSync(src)) {
|
];
|
||||||
console.error('[sync] missing dist/v1/loader.js — run `pnpm build` first');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
mkdirSync(dirname(dest), { recursive: true });
|
|
||||||
cpSync(src, dest);
|
|
||||||
|
|
||||||
function sha256(p) {
|
function sha256(p) {
|
||||||
return createHash('sha256').update(readFileSync(p)).digest('hex');
|
return createHash('sha256').update(readFileSync(p)).digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
const srcHash = sha256(src);
|
for (const { src: srcRel, dest: destRel } of targets) {
|
||||||
if (srcHash !== sha256(dest)) {
|
const src = resolve(here, srcRel);
|
||||||
console.error('[sync] hash mismatch after copy — viewer copy diverged');
|
const dest = resolve(here, destRel);
|
||||||
process.exit(1);
|
if (!existsSync(src)) {
|
||||||
|
console.error(`[sync] missing ${srcRel} — run \`pnpm build\` first`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
mkdirSync(dirname(dest), { recursive: true });
|
||||||
|
cpSync(src, dest);
|
||||||
|
const srcHash = sha256(src);
|
||||||
|
if (srcHash !== sha256(dest)) {
|
||||||
|
console.error(`[sync] hash mismatch after copy — ${destRel} diverged`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(`[sync] ${srcRel} -> ${destRel} (sha256 ${srcHash.slice(0, 12)}…)`);
|
||||||
}
|
}
|
||||||
console.log(`[sync] dist/v1/loader.js -> apps/viewer/public/embed/v1/loader.js (sha256 ${srcHash.slice(0, 12)}…)`);
|
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ export const EMBED_OFFSET_BOTTOM_MAX = 240;
|
|||||||
export const EMBED_Z_INDEX_MIN = 1;
|
export const EMBED_Z_INDEX_MIN = 1;
|
||||||
export const EMBED_Z_INDEX_MAX = 2147483000;
|
export const EMBED_Z_INDEX_MAX = 2147483000;
|
||||||
|
|
||||||
|
/** UI-04 新增字段枚举与边界(vertical-stream 展示模式,全部可选)。 */
|
||||||
|
const EMBED_SLOT_DISPLAY_MODES = ['carousel', 'vertical-stream'] as const;
|
||||||
|
export const EMBED_SCROLL_SPEED_MIN = 8;
|
||||||
|
export const EMBED_SCROLL_SPEED_MAX = 32;
|
||||||
|
|
||||||
export class EmbedSlotSettingsDto {
|
export class EmbedSlotSettingsDto {
|
||||||
@IsIn(EMBED_SLOT_POSITIONS)
|
@IsIn(EMBED_SLOT_POSITIONS)
|
||||||
position!: (typeof EMBED_SLOT_POSITIONS)[number];
|
position!: (typeof EMBED_SLOT_POSITIONS)[number];
|
||||||
@@ -91,6 +96,25 @@ export class EmbedSlotSettingsDto {
|
|||||||
@Min(EMBED_Z_INDEX_MIN)
|
@Min(EMBED_Z_INDEX_MIN)
|
||||||
@Max(EMBED_Z_INDEX_MAX)
|
@Max(EMBED_Z_INDEX_MAX)
|
||||||
zIndex?: number;
|
zIndex?: number;
|
||||||
|
|
||||||
|
// —— UI-04 可选扩展(vertical-stream 展示模式;旧 JSON 兼容同 UI-01 模式)——
|
||||||
|
|
||||||
|
/** 展示模式:carousel=旧轮播(缺省);vertical-stream=v2 垂直照片流。 */
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(EMBED_SLOT_DISPLAY_MODES)
|
||||||
|
displayMode?: (typeof EMBED_SLOT_DISPLAY_MODES)[number];
|
||||||
|
|
||||||
|
/** v2 自动上滚速度(CSS px/秒,8..32);缺省 16。 */
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(EMBED_SCROLL_SPEED_MIN)
|
||||||
|
@Max(EMBED_SCROLL_SPEED_MAX)
|
||||||
|
scrollSpeed?: number;
|
||||||
|
|
||||||
|
/** v2 点赞心形气泡开关;缺省 true(false 仅隐藏气泡)。 */
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
reactionsEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class EmbedSlotCreateRequestDto {
|
export class EmbedSlotCreateRequestDto {
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export type EmbedSlotSettingsResponse =
|
|||||||
const PUBLIC_TOKEN_LENGTH = 24;
|
const PUBLIC_TOKEN_LENGTH = 24;
|
||||||
const PUBLIC_TOKEN_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz';
|
const PUBLIC_TOKEN_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz';
|
||||||
|
|
||||||
/** 契约 EmbedSlotSettings 各字段缺省值(整体缺省时落库形态;UI-01 新字段同列)。 */
|
/** 契约 EmbedSlotSettings 各字段缺省值(整体缺省时落库形态;UI-01/UI-04 新字段同列)。 */
|
||||||
export const DEFAULT_EMBED_SLOT_SETTINGS: EmbedSlotSettingsResponse = {
|
export const DEFAULT_EMBED_SLOT_SETTINGS: EmbedSlotSettingsResponse = {
|
||||||
position: 'right',
|
position: 'right',
|
||||||
size: 'md',
|
size: 'md',
|
||||||
@@ -63,6 +63,12 @@ export const DEFAULT_EMBED_SLOT_SETTINGS: EmbedSlotSettingsResponse = {
|
|||||||
offsetX: 16,
|
offsetX: 16,
|
||||||
offsetBottom: 24,
|
offsetBottom: 24,
|
||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
|
// UI-04:displayMode 缺省 carousel——UI-04 前旧记录读取归一为旧模式;
|
||||||
|
// 新建走 v2 由管理端表单显式提交 vertical-stream(契约默认与服务端缺省
|
||||||
|
// 一致保持保守,避免存量展示位一夜切换新布局)。
|
||||||
|
displayMode: 'carousel',
|
||||||
|
scrollSpeed: 16,
|
||||||
|
reactionsEnabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -109,6 +115,10 @@ function normalizeSettings(
|
|||||||
offsetBottom:
|
offsetBottom:
|
||||||
settings.offsetBottom ?? DEFAULT_EMBED_SLOT_SETTINGS.offsetBottom,
|
settings.offsetBottom ?? DEFAULT_EMBED_SLOT_SETTINGS.offsetBottom,
|
||||||
zIndex: settings.zIndex ?? DEFAULT_EMBED_SLOT_SETTINGS.zIndex,
|
zIndex: settings.zIndex ?? DEFAULT_EMBED_SLOT_SETTINGS.zIndex,
|
||||||
|
displayMode: settings.displayMode ?? DEFAULT_EMBED_SLOT_SETTINGS.displayMode,
|
||||||
|
scrollSpeed: settings.scrollSpeed ?? DEFAULT_EMBED_SLOT_SETTINGS.scrollSpeed,
|
||||||
|
reactionsEnabled:
|
||||||
|
settings.reactionsEnabled ?? DEFAULT_EMBED_SLOT_SETTINGS.reactionsEnabled,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,6 +142,10 @@ export function normalizeStoredSettings(
|
|||||||
offsetX: row.offsetX ?? DEFAULT_EMBED_SLOT_SETTINGS.offsetX,
|
offsetX: row.offsetX ?? DEFAULT_EMBED_SLOT_SETTINGS.offsetX,
|
||||||
offsetBottom: row.offsetBottom ?? DEFAULT_EMBED_SLOT_SETTINGS.offsetBottom,
|
offsetBottom: row.offsetBottom ?? DEFAULT_EMBED_SLOT_SETTINGS.offsetBottom,
|
||||||
zIndex: row.zIndex ?? DEFAULT_EMBED_SLOT_SETTINGS.zIndex,
|
zIndex: row.zIndex ?? DEFAULT_EMBED_SLOT_SETTINGS.zIndex,
|
||||||
|
displayMode: row.displayMode ?? DEFAULT_EMBED_SLOT_SETTINGS.displayMode,
|
||||||
|
scrollSpeed: row.scrollSpeed ?? DEFAULT_EMBED_SLOT_SETTINGS.scrollSpeed,
|
||||||
|
reactionsEnabled:
|
||||||
|
row.reactionsEnabled ?? DEFAULT_EMBED_SLOT_SETTINGS.reactionsEnabled,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user